feat(architecture): [EoS/opencode] Migrate OpenCode onto the Embeddable Orchestration System (ADR-1239, #2087)

Route OpenCode (and its Kilo sibling) through the public Host-Integration Interface and
land two Context7-verified capability upgrades. Byte-identical install output for all 16
runtimes (golden parity asserted).

Through the interface (AC2):
- OpenCode/Kilo's bespoke commands+skills+plugin install (the inline
  `else if (isOpencode || isKilo)` block) moves into the engine
  (installOpencodeFamilyCommands/Artifacts in src/install-engine.cts), dispatched by
  installRuntimeArtifacts when the descriptor declares hostBehaviors.combinedFamilyInstall.
  opencode/kilo now flow CLI -> _runtimeAdapter -> installRuntimeArtifacts like the skills
  runtimes. _isSkillsRuntime no longer excludes them; the bespoke block + dead
  copyFlattenedCommands are removed.
- Every hardcoded `runtime === 'opencode'`/`isOpencode` branch is folded into
  descriptor-driven runtime.hostBehaviors. ZERO `runtime === 'opencode'`/`'kilo'`
  string-equality remain in bin/install.js / install-engine.cts / runtime-artifact-conversion.cts.

Upgrades (AC4):
- Background dispatch: OpenCode shipped experimental background subagents in v1.15 and
  made them default-on in v1.17 -> dispatch.background/backgroundDispatch flip to true;
  shouldFlattenDispatch(opencode) now returns false (behavioral change; type: Changed).
- Expanded event surface: the OpenCode plugin subscribes permission.asked/replied +
  session.error.

Tests: opencode-imperative-reference (adapter/profile, shouldFlattenDispatch pin,
fail-closed negotiate, hostBehaviors, AC2 source-guard) + extended plugin surface test.
Docs: capability matrix v1.15/v1.17 citations. Changeset (Changed). gitignore .memdb//.memtrace/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-08 16:53:54 -04:00
parent ab78797ac5
commit 396f44bd0b
16 changed files with 450 additions and 155 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 2087
---
**OpenCode is now driven through the public Host-Integration Interface, with two capability upgrades (ADR-1239 / EoS).** OpenCode and its Kilo sibling previously installed via a bespoke `runtime === 'opencode'`/`isOpencode` branch in `bin/install.js`; its commands+skills+plugin install now runs through the imperative adapter → the engine's combined-family install path (`installRuntimeArtifacts`), and every hardcoded `runtime === 'opencode'` branch is folded into descriptor-driven `runtime.hostBehaviors`. Install/uninstall output is **byte-identical** (golden parity asserted for all 16 runtimes). Two Context7-verified upgrades land: (1) **background dispatch** — OpenCode shipped experimental background subagents in v1.15 and made them default-on in v1.17, so `dispatch.background`/`backgroundDispatch` flip to `true`; GSD no longer force-flattens OpenCode-hosted wave dispatch (`shouldFlattenDispatch` now returns `false`), letting agents run concurrently where the host supports it. (2) **expanded event surface** — the OpenCode plugin now subscribes to `permission.asked`, `permission.replied`, and `session.error` (added to `EXTENSION_EVENT_SURFACES.opencode`), wiring the declared surface for future permission/error-aware bindings. (#2087)

4
.gitignore vendored
View File

@@ -250,3 +250,7 @@ reports/mutation/
# Local Crabbox machine configuration
.crabbox.yaml
.crabbox.local.yaml
# Memtrace local daemon/runtime state (per-machine; never committed)
.memdb/
.memtrace/

View File

@@ -663,6 +663,26 @@ const GsdCorePlugin = async ({ directory } = {}) => {
if (event.type === "session.idle") {
return;
}
// permission.asked / permission.replied — OpenCode permission lifecycle
// (#2087, opencode.ai/docs/plugins). GSD gates tool INPUTS at
// tool.execute.before (read-guard, injection-scanner); the permission
// grant/deny decision itself carries no GSD workflow-phase contribution,
// so these are recognized sentinels — wired so a future permission-aware
// gate can attach without a plugin change (the engine owns phase
// sequencing; this host bus is session/tool/permission-scoped, never
// phase-scoped — ADR-1239 §OpenCode).
if (event.type === "permission.asked" || event.type === "permission.replied") {
return;
}
// session.error — OpenCode session-error lifecycle point (#2087). No GSD
// hook fires here today (loop state is already persisted to .planning/);
// recognized so the declared extension-event surface is fully wired and a
// future error-class hook can attach without a plugin change.
if (event.type === "session.error") {
return;
}
},
};
};

View File

@@ -932,6 +932,9 @@ function resolveKiloConfigPath(configDir) {
return path.join(configDir, 'kilo.json');
}
// #2087 — attribution config-path resolvers, keyed by descriptor (hostBehaviors.attributionConfigResolver)
const ATTRIBUTION_CONFIG_RESOLVERS = { opencode: resolveOpencodeConfigPath, kilo: resolveKiloConfigPath };
/**
* Strip JSONC comments (// and /* *​/) from a string to produce valid JSON.
* Handles comments inside strings correctly (does not strip them).
@@ -1300,10 +1303,9 @@ function getCommitAttribution(runtime) {
let result;
if (runtime === 'opencode' || runtime === 'kilo') {
const resolveConfigPath = runtime === 'opencode'
? resolveOpencodeConfigPath
: resolveKiloConfigPath;
const _attrResolverKey = _hostBehaviors(runtime).attributionConfigResolver;
if (_attrResolverKey && ATTRIBUTION_CONFIG_RESOLVERS[_attrResolverKey]) {
const resolveConfigPath = ATTRIBUTION_CONFIG_RESOLVERS[_attrResolverKey];
const config = readSettings(resolveConfigPath(getGlobalConfigDir(runtime, null)));
result = (config && config.disable_ai_attribution === true) ? null : undefined;
} else if (_hostBehaviors(runtime).attributionSource === 'settings-json-commit') {
@@ -5984,62 +5986,14 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) {
// convertClaudeCommandToKiloSkill: moved to src/install-engine.cts (ADR-1239 Phase B).
// Imported from installEngine above.
/**
* Copy commands to a flat structure for OpenCode
* OpenCode expects: command/gsd-help.md (invoked as /gsd-help)
* Source structure: commands/gsd/help.md
*
* @param {string} srcDir - Source directory (e.g., commands/gsd/)
* @param {string} destDir - Destination directory (e.g., command/)
* @param {string} prefix - Prefix for filenames (e.g., 'gsd')
* @param {string} pathPrefix - Path prefix for file references
* @param {string} runtime - Target runtime ('claude', 'opencode', or 'kilo')
*/
// applyOpencodeFamilyPathPrefix: moved to src/install-engine.cts (ADR-1239 Phase B).
// Imported from installEngine above.
function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
if (!fs.existsSync(srcDir)) {
return;
}
// Remove old gsd-*.md files before copying new ones
if (fs.existsSync(destDir)) {
for (const file of fs.readdirSync(destDir)) {
if (file.startsWith(`${prefix}-`) && file.endsWith('.md')) {
fs.unlinkSync(path.join(destDir, file));
}
}
} else {
fs.mkdirSync(destDir, { recursive: true });
}
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(srcDir, entry.name);
if (entry.isDirectory()) {
// Recurse into subdirectories, adding to prefix
// e.g., commands/gsd/debug/start.md -> command/gsd-debug-start.md
copyFlattenedCommands(srcPath, destDir, `${prefix}-${entry.name}`, pathPrefix, runtime);
} else if (entry.name.endsWith('.md')) {
// Flatten: help.md -> gsd-help.md
const baseName = entry.name.replace('.md', '');
const destName = `${prefix}-${baseName}.md`;
const destPath = path.join(destDir, destName);
let content = fs.readFileSync(srcPath, 'utf8');
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
content = processAttribution(content, getCommitAttribution(runtime));
content = runtime === 'kilo'
? convertClaudeToKiloFrontmatter(content)
: convertClaudeToOpencodeFrontmatter(content);
fs.writeFileSync(destPath, content);
}
}
}
//
// copyFlattenedCommands (OpenCode/Kilo flattened command/ writer): moved to
// src/install-engine.cts as installOpencodeFamilyCommands (ADR-1239 / #2087).
// OpenCode/Kilo installs now route through installRuntimeArtifacts's
// combinedFamilyInstall path (installOpencodeFamilyArtifacts) instead of the
// bespoke inline block that used to call this function.
function listCodexSkillNames(skillsDir, prefix = 'gsd-') {
if (!fs.existsSync(skillsDir)) return [];
@@ -6929,9 +6883,10 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
// 4z. Remove the OpenCode native plugin adapter (#1914). Only GSD's own
// plugin file is removed; the plugins/ dir is pruned only if it becomes
// empty, preserving any user-authored OpenCode plugins.
if (isOpencode) {
const pluginsDir = path.join(targetDir, 'plugins');
const pluginPath = path.join(pluginsDir, 'gsd-core.js');
const _np = _hostBehaviors(runtime).nativePlugin;
if (_np) {
const pluginsDir = path.join(targetDir, _np.dir);
const pluginPath = path.join(pluginsDir, _np.file);
if (fs.existsSync(pluginPath)) {
try {
fs.unlinkSync(pluginPath);
@@ -7110,7 +7065,7 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
}
// 6. For OpenCode, clean up permissions from opencode.json or opencode.jsonc
if (isOpencode) {
if (resolveInstallPlan(runtime).finishPermissionWriter === 'opencode') {
const configPath = resolveOpencodeConfigPath(targetDir);
if (fs.existsSync(configPath)) {
try {
@@ -7558,7 +7513,7 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
// #1367: Claude local now writes flat gsd-*.md files at commands/ (not commands/gsd/).
// Claude local uses flatCommandsDir instead for manifest recording.
const flatCommandsDir = path.join(configDir, 'commands');
const opencodeCommandDir = path.join(configDir, 'command');
const opencodeCommandDir = path.join(configDir, _hostBehaviors(runtime).flatCommandDir || 'command');
// Hermes nests GSD skills under skills/gsd/ as a single category (#2841).
// All other runtimes that use the Codex-style skills layout use a flat skills/ root.
const codexSkillsDir = isHermes
@@ -7597,14 +7552,14 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
}
}
}
if ((isOpencode || isKilo) && fs.existsSync(opencodeCommandDir)) {
if (_hostBehaviors(runtime).flatCommandDir && fs.existsSync(opencodeCommandDir)) {
for (const file of fs.readdirSync(opencodeCommandDir)) {
if (file.startsWith('gsd-') && file.endsWith('.md')) {
manifest.files['command/' + file] = fileHash(path.join(opencodeCommandDir, file));
}
}
}
if ((isCodex || isCopilot || isAntigravity || isCursor || isWindsurf || isTrae || !isOpencode) && fs.existsSync(codexSkillsDir)) {
if (!_hostBehaviors(runtime).skipCodexSkillsManifest && fs.existsSync(codexSkillsDir)) {
// All runtimes (including Hermes post-#947) use the canonical 'gsd-' prefix.
const skillListPrefix = 'gsd-';
for (const skillName of listCodexSkillNames(codexSkillsDir, skillListPrefix)) {
@@ -7716,10 +7671,11 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
// Track the OpenCode native plugin adapter (#1914) so update/drift detection
// and uninstall can account for it.
if (isOpencode) {
const pluginInstallPath = path.join(configDir, 'plugins', 'gsd-core.js');
const _npM = _hostBehaviors(runtime).nativePlugin;
if (_npM) {
const pluginInstallPath = path.join(configDir, _npM.dir, _npM.file);
if (fs.existsSync(pluginInstallPath)) {
manifest.files['plugins/gsd-core.js'] = fileHash(pluginInstallPath);
manifest.files[`${_npM.dir}/${_npM.file}`] = fileHash(pluginInstallPath);
}
}
@@ -8012,8 +7968,8 @@ function reportLocalPatches(configDir, runtime = DEFAULT_RUNTIME) {
try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return []; }
if (meta.files && meta.files.length > 0) {
const reapplyCommand = (runtime === 'opencode' || runtime === 'kilo' || runtime === 'copilot')
? '/gsd-update --reapply'
const reapplyCommand = _hostBehaviors(runtime).reapplyCommand
? _hostBehaviors(runtime).reapplyCommand
: runtime === 'codex'
? '$gsd-update --reapply'
: runtime === 'cursor'
@@ -8187,7 +8143,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
const isWindowsHost = process.platform === 'win32';
const pathPrefix = computePathPrefix({
isGlobal,
isOpencode,
isOpencode: _hostBehaviors(runtime).skipHomePrefixSubstitution === true,
isWindowsHost,
resolvedTarget,
homeDir,
@@ -8473,7 +8429,6 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// Hermes: writeHermesCategoryDescription (not a layout kind)
// Cline global: skills emitted via layout; .clinerules still written below (#782)
// Cline local: no skills (only .clinerules) — falls through to cline-rules surface
// OpenCode/Kilo: copyFlattenedCommands (frontmatter conversion not in commandsKind)
// Claude local: copyWithPathReplacement + stale-skills cleanup
// Layout-driven path for all skills-based runtimes (full and minimal modes).
@@ -8485,12 +8440,14 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// (it declares any skills/commands/agents/kimi-agents kind for this scope).
// This replaces the prior hardcoded `isCodex || isCopilot || ...` roster so a
// newly-added runtime with an artifact layout installs without a per-runtime
// branch — the add-a-host tax ADR-1239 Phase B retires. Three legacy
// special-cased paths are preserved: opencode/kilo (combined commands+skills
// via copyFlattenedCommands + installOpencodeFamilySkills) and claude-local
// branch — the add-a-host tax ADR-1239 Phase B retires. OpenCode/Kilo now
// route through this SAME path too: their hostBehaviors.combinedFamilyInstall
// flag makes installRuntimeArtifacts (in src/install-engine.cts) delegate to
// installOpencodeFamilyArtifacts for the combined commands+skills+native-plugin
// install (ADR-1239 / #2087), replacing the bespoke inline block this comment
// used to describe. Claude-local remains the one special-cased path
// (copyWithPathReplacement + stale-skills cleanup).
const _isSkillsRuntime = (() => {
if (isOpencode || isKilo) return false; // specialized combined path
if (_hostBehaviors(runtime).localInstallStyle === 'legacy-flat' && !isGlobal) return false; // legacy flat local path (descriptor-driven; #2086)
const cap = _capabilityRegistry && _capabilityRegistry.runtimes && _capabilityRegistry.runtimes[runtime];
const layout = cap && cap.runtime && cap.runtime.artifactLayout;
@@ -8657,64 +8614,6 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
}
}
}
} else if (isOpencode || isKilo) {
// OpenCode/Kilo: flat structure in command/ directory
const commandDir = path.join(targetDir, 'command');
fs.mkdirSync(commandDir, { recursive: true });
// Copy commands/gsd/*.md as command/gsd-*.md (flatten structure)
const gsdSrc = _stageSkills(_commandsDir);
copyFlattenedCommands(gsdSrc, commandDir, 'gsd', pathPrefix, runtime);
if (verifyInstalled(commandDir, 'command/gsd-*')) {
const count = fs.readdirSync(commandDir).filter(f => f.startsWith('gsd-')).length;
console.log(` ${green}✓${reset} Installed ${count} commands to command/`);
} else {
failures.push('command/gsd-*');
}
// Also emit OpenCode-family skills (skills/<name>/SKILL.md). OpenCode and
// Kilo support native, on-demand skills in addition to flat commands — see
// resolveRuntimeArtifactLayout's opencode/kilo entries. Derive skills from
// the SAME staged command set (gsdSrc) so both surfaces match exactly. (#784)
const _skillCount = installOpencodeFamilySkills(runtime, targetDir, gsdSrc, pathPrefix, getCommitAttribution);
if (_skillCount > 0) {
console.log(` ${green}✓${reset} Installed ${_skillCount} skills to skills/`);
} else {
failures.push('skills/gsd-*');
}
// OpenCode-only: install the native plugin adapter (#1914). OpenCode
// declares hooksSurface: 'none', so GSD's lifecycle hooks are never
// registered as settings.json hooks the way Claude Code does — the hook
// *scripts* ship to <configDir>/hooks/ but nothing invokes them. This
// plugin bridges OpenCode's event bus onto those existing hook scripts
// (prompt guard, read guard, injection scanner, context monitor, ...),
// spawning them as subprocesses. OpenCode auto-discovers plugin files under
// <configDir>/plugins/ at startup — no opencode.json registration needed
// (its `plugin` array is for npm packages, not local file paths).
//
// The file MUST land as `.js`: OpenCode's loader globs
// `{plugin,plugins}/*.{ts,js}` (verified against its source) — a `.cjs`
// extension would never be discovered. The config dir carries a
// `{"type":"commonjs"}` package.json (written above), so the `.js` file is
// interpreted as CommonJS, matching the adapter's module.exports/require.
// Kilo has no plugin surface, so this is gated to OpenCode only.
if (isOpencode) {
const pluginSrc = path.join(src, '.opencode', 'plugins', 'gsd-core.js');
const pluginDestDir = path.join(targetDir, 'plugins');
const pluginDest = path.join(pluginDestDir, 'gsd-core.js');
if (fs.existsSync(pluginSrc)) {
fs.mkdirSync(pluginDestDir, { recursive: true });
fs.copyFileSync(pluginSrc, pluginDest);
if (fs.existsSync(pluginDest)) {
console.log(` ${green}✓${reset} Installed OpenCode plugin (bridges GSD hooks)`);
} else {
failures.push('plugins/gsd-core.js');
}
} else {
failures.push('plugins/gsd-core.js');
}
}
} else if (isCline) {
// Cline local install: rules-based only — commands are embedded in .clinerules (generated below).
// No skills/commands directory needed for local installs.
@@ -8963,7 +8862,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
}
content = processAttribution(content, getCommitAttribution(runtime));
// Convert frontmatter for runtime compatibility (agents need different handling)
if (isOpencode) {
if (_hostBehaviors(runtime).frontmatterDialect === 'opencode') {
// Resolve per-agent model for OpenCode agents.
// Precedence: model_overrides[agent] > model_profile_overrides.opencode.<tier> > omit.
// model_overrides (#2256): explicit per-agent override, highest precedence.
@@ -8982,7 +8881,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
}
}
content = convertClaudeToOpencodeFrontmatter(content, { isAgent: true, modelOverride: _ocModelOverride });
} else if (isKilo) {
} else if (_hostBehaviors(runtime).frontmatterDialect === 'kilo') {
content = convertClaudeToKiloFrontmatter(content, { isAgent: true });
} else if (isCodex) {
content = convertClaudeAgentToCodexAgent(content);
@@ -10018,7 +9917,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// installAllRuntimes can register it at finalize time when the user opts
// in (#2795). Computed here (not in finishInstall) so the same buildHookCommand
// / localCmd resolution logic is shared with the other JS hooks.
const updateBannerCommand = isOpencode || isKilo
const updateBannerCommand = _hostBehaviors(runtime).skipUpdateBannerCommand
? null
: (isGlobal
? buildHookCommand(targetDir, 'gsd-update-banner.js', hookOpts)
@@ -10105,7 +10004,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
const { isOpencode, isKilo, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
const plan = resolveInstallPlan(runtime);
if (shouldInstallStatusline && plan.writesSharedSettings && !isOpencode) {
if (shouldInstallStatusline && plan.writesSharedSettings && !_hostBehaviors(runtime).skipSettingsUi) {
if (!isGlobal && !forceStatusline) {
// Local installs skip statusLine by default: repo settings.json takes precedence over
// profile-level settings.json in Claude Code, so writing here would silently clobber
@@ -10131,7 +10030,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
// settings.json hooks block — opencode/kilo/codex/cursor/windsurf/trae/
// cline either lack the surface or use a different config schema.
const { shouldInstallBanner, bannerCommand } = bannerOpts;
if (shouldInstallBanner && settings && plan.writesSharedSettings && !isOpencode) {
if (shouldInstallBanner && settings && plan.writesSharedSettings && !_hostBehaviors(runtime).skipSettingsUi) {
if (!bannerCommand) {
console.warn(` ${yellow}⚠${reset} Skipped update banner registration — Node executable path unavailable. See #2979 / #3002.`);
} else {

View File

@@ -82,6 +82,9 @@
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "undocumented"
},
"hostBehaviors": {
"reapplyCommand": "/gsd-update --reapply"
}
}
}

View File

@@ -88,6 +88,14 @@
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "bun"
},
"hostBehaviors": {
"reapplyCommand": "/gsd-update --reapply",
"attributionConfigResolver": "kilo",
"flatCommandDir": "command",
"combinedFamilyInstall": true,
"frontmatterDialect": "kilo",
"skipUpdateBannerCommand": true
}
}
}

View File

@@ -75,15 +75,31 @@
"namedDispatch": true,
"nested": "undocumented",
"maxDepth": "undocumented",
"background": false,
"background": true,
"subagentToolkit": "full",
"backgroundDispatch": "undocumented"
"backgroundDispatch": true
},
"modelMode": "active",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "bun"
},
"hostBehaviors": {
"reapplyCommand": "/gsd-update --reapply",
"attributionConfigResolver": "opencode",
"flatCommandDir": "command",
"combinedFamilyInstall": true,
"frontmatterDialect": "opencode",
"nativePlugin": {
"dir": "plugins",
"file": "gsd-core.js",
"source": ".opencode/plugins/gsd-core.js"
},
"skipHomePrefixSubstitution": true,
"skipSettingsUi": true,
"skipUpdateBannerCommand": true,
"skipCodexSkillsManifest": true
}
}
}

View File

@@ -134,9 +134,9 @@ Documentation gaps:
| dispatch.namedDispatch | true | https://opencode.ai/docs/agents | "\"Subagents can be invoked: Automatically by primary agents for specialized tasks based on their descriptions. Manually b" |
| dispatch.nested | undocumented | no authoritative doc — searched: https://opencode.ai/docs/agents | — |
| dispatch.maxDepth | undocumented | no authoritative doc — searched: https://opencode.ai/docs/agents | — |
| dispatch.background | false | https://github.com/sst/opencode/issues/5887 | "\"Currently, sub-agent delegation in `opencode` appears to be synchronous or modal... There is no native 'fire-and-forget'" |
| dispatch.background | true | https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/tool/task.ts (v1.15.0, commit 22de34c4d) + src/effect/runtime-flags.ts (v1.17, commit 81f6e0668) | "New in v1.15.0: experimental background subagents — the Task tool gains a `background` parameter (`Schema.optional(Schema.Boolean)`) that launches subagents asynchronously with completion notifications. v1.17: `BACKGROUND_SUBAGENTS_ENABLED = true` (\"feat: enable background subagents by default\") — default-on, concurrent execution in all modes. (#2087, superseding the stale sst/opencode#5887 snapshot)" |
| dispatch.subagentToolkit | full | https://opencode.ai/docs/agents | "The 'general' subagent \"Has full tool access (except todo), so it can make file changes when needed.\"" |
| dispatch.backgroundDispatch | undocumented | no authoritative doc — https://github.com/anomalyco/opencode/issues/18100 and https://github.com/anomalyco/opencode/blob/dev/opencode/packages/opencode/src/tool/task.ts | Opencode supports background task dispatch via the Task tool's `background: true` parameter but whether a background-spawned agent can itself spawn further sub-agents is not documented. |
| dispatch.backgroundDispatch | true | https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/effect/runtime-flags.ts (v1.17, commit 81f6e0668) + src/server/routes/instance/httpapi/handlers/experimental.ts | "v1.17 `BACKGROUND_SUBAGENTS_ENABLED = true` enables background subagent execution by default in all modes; the experimental capabilities endpoint exposes `{ backgroundSubagents: true }`. Background-spawned subagents run concurrently without blocking the main interaction flow. (#2087)" |
Sources consulted:
- https://opencode.ai/docs/plugins

View File

@@ -940,6 +940,9 @@ const capabilities = {
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "undocumented"
},
"hostBehaviors": {
"reapplyCommand": "/gsd-update --reapply"
}
}
},
@@ -1502,6 +1505,14 @@ const capabilities = {
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "bun"
},
"hostBehaviors": {
"reapplyCommand": "/gsd-update --reapply",
"attributionConfigResolver": "kilo",
"flatCommandDir": "command",
"combinedFamilyInstall": true,
"frontmatterDialect": "kilo",
"skipUpdateBannerCommand": true
}
}
},
@@ -1880,15 +1891,31 @@ const capabilities = {
"namedDispatch": true,
"nested": "undocumented",
"maxDepth": "undocumented",
"background": false,
"background": true,
"subagentToolkit": "full",
"backgroundDispatch": "undocumented"
"backgroundDispatch": true
},
"modelMode": "active",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "bun"
},
"hostBehaviors": {
"reapplyCommand": "/gsd-update --reapply",
"attributionConfigResolver": "opencode",
"flatCommandDir": "command",
"combinedFamilyInstall": true,
"frontmatterDialect": "opencode",
"nativePlugin": {
"dir": "plugins",
"file": "gsd-core.js",
"source": ".opencode/plugins/gsd-core.js"
},
"skipHomePrefixSubstitution": true,
"skipSettingsUi": true,
"skipUpdateBannerCommand": true,
"skipCodexSkillsManifest": true
}
}
},
@@ -4233,6 +4260,9 @@ const runtimes = {
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "undocumented"
},
"hostBehaviors": {
"reapplyCommand": "/gsd-update --reapply"
}
}
},
@@ -4500,6 +4530,14 @@ const runtimes = {
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "bun"
},
"hostBehaviors": {
"reapplyCommand": "/gsd-update --reapply",
"attributionConfigResolver": "kilo",
"flatCommandDir": "command",
"combinedFamilyInstall": true,
"frontmatterDialect": "kilo",
"skipUpdateBannerCommand": true
}
}
},
@@ -4654,15 +4692,31 @@ const runtimes = {
"namedDispatch": true,
"nested": "undocumented",
"maxDepth": "undocumented",
"background": false,
"background": true,
"subagentToolkit": "full",
"backgroundDispatch": "undocumented"
"backgroundDispatch": true
},
"modelMode": "active",
"hookBus": "host",
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "bun"
},
"hostBehaviors": {
"reapplyCommand": "/gsd-update --reapply",
"attributionConfigResolver": "opencode",
"flatCommandDir": "command",
"combinedFamilyInstall": true,
"frontmatterDialect": "opencode",
"nativePlugin": {
"dir": "plugins",
"file": "gsd-core.js",
"source": ".opencode/plugins/gsd-core.js"
},
"skipHomePrefixSubstitution": true,
"skipSettingsUi": true,
"skipUpdateBannerCommand": true,
"skipCodexSkillsManifest": true
}
}
},

View File

@@ -555,6 +555,9 @@ const EXTENSION_EVENT_SURFACES: Readonly<Record<string, readonly string[]>> = Ob
opencode: Object.freeze([
'session.created', 'session.idle', 'experimental.session.compacting',
'tool.execute.before', 'tool.execute.after', 'file.edited',
// #2087 — additional documented plugin events GSD binds (opencode.ai/docs/plugins):
// permission decisions + session error surface.
'permission.asked', 'permission.replied', 'session.error',
]),
pi: Object.freeze(['tool_call']),
none: Object.freeze([]),

View File

@@ -26,6 +26,7 @@ import runtimeArtifactConversion = require('./runtime-artifact-conversion.cjs');
import runtimeArtifactLayout = require('./runtime-artifact-layout.cjs');
import runtimeArtifactInstallPlan = require('./runtime-artifact-install-plan.cjs');
import runtimeNamePolicy = require('./runtime-name-policy.cjs');
import installProfiles = require('./install-profiles.cjs');
const { processAttribution } = runtimeArtifactConversion;
// resolveRuntimeArtifactLayout: accessed via module ref (not destructured) so
@@ -65,6 +66,26 @@ type ResolveAttribution = (runtime: string) => any;
*/
const USER_OWNED_ARTIFACTS: string[] = ['USER-PROFILE.md'];
// ---------------------------------------------------------------------------
// Host-behavior helpers
// ---------------------------------------------------------------------------
/**
* Host-specific install behaviors declared on the runtime descriptor
* (capabilities/<runtime>/capability.json -> runtime.hostBehaviors).
* Mirrors bin/install.js's `_hostBehaviors` (ADR-1239 / #2086/#2087). Returns
* {} for runtimes that declare none or if the registry fails to load, so
* every behavior branch degrades to the generic path by default.
*/
function _hostBehaviors(runtime: string): any {
try {
const reg = require('./capability-registry.cjs');
return (reg && reg.runtimes && reg.runtimes[runtime] && reg.runtimes[runtime].runtime && reg.runtimes[runtime].runtime.hostBehaviors) || {};
} catch {
return {};
}
}
// ---------------------------------------------------------------------------
// Conversion helpers
// ---------------------------------------------------------------------------
@@ -563,6 +584,16 @@ function installRuntimeArtifacts(
resolvedProfile: any,
resolveAttribution: ResolveAttribution = () => undefined,
): void {
// Combined-family runtimes (OpenCode/Kilo, ADR-1239 / #2087): route through
// the dedicated combined commands+skills+plugin orchestrator instead of the
// generic layout-driven loop below, mirroring the bespoke install path that
// previously lived inline in bin/install.js.
const behaviors = _hostBehaviors(runtime);
if (behaviors.combinedFamilyInstall) {
installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution, behaviors);
return;
}
// Legacy cleanup before layout-driven writes
_runLegacyInstallMigrations(runtime, configDir, scope);
@@ -690,7 +721,7 @@ function installOpencodeFamilySkills(
const rawDir = rawCommandsDir;
if (!rawDir || !fs.existsSync(rawDir)) return 0;
const converter = runtime === 'kilo'
const converter = _hostBehaviors(runtime).frontmatterDialect === 'kilo'
? convertClaudeCommandToKiloSkill
: convertClaudeCommandToOpencodeSkill;
@@ -743,6 +774,121 @@ function installOpencodeFamilySkills(
return count;
}
// ---------------------------------------------------------------------------
// installOpencodeFamilyCommands
// ---------------------------------------------------------------------------
/**
* Install the flattened commands surface for an OpenCode-family runtime
* (OpenCode/Kilo): commands/gsd/**\/*.md -> command/gsd-<...>.md, with
* per-runtime frontmatter conversion and path-prefix/attribution rewrites.
*
* Mirrors bin/install.js's copyFlattenedCommands VERBATIM (ADR-1239 /
* #2087), except attribution is resolved via the injected
* `resolveAttribution` callback instead of a module-level getCommitAttribution.
*
* @param runtime - 'opencode' or 'kilo'
* @param destDir - destination directory for flattened commands (recurses with the same destDir)
* @param srcDir - source directory to walk (commands/gsd/, recursing into subdirectories)
* @param pathPrefix - computed config-path prefix for body rewrites
* @param resolveAttribution - injection: (runtime) => attribution string | undefined
* @param prefix - filename prefix accumulator (defaults to 'gsd'; grows on recursion)
*/
function installOpencodeFamilyCommands(
runtime: string,
destDir: string,
srcDir: string,
pathPrefix: string,
resolveAttribution: ResolveAttribution = () => undefined,
prefix: string = 'gsd',
): void {
if (!fs.existsSync(srcDir)) return;
// Remove old gsd-*.md files before copying new ones
if (fs.existsSync(destDir)) {
for (const file of fs.readdirSync(destDir)) {
if (file.startsWith(`${prefix}-`) && file.endsWith('.md')) fs.unlinkSync(path.join(destDir, file));
}
} else {
fs.mkdirSync(destDir, { recursive: true });
}
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
const srcPath = path.join(srcDir, entry.name);
if (entry.isDirectory()) {
installOpencodeFamilyCommands(runtime, destDir, srcPath, pathPrefix, resolveAttribution, `${prefix}-${entry.name}`);
} else if (entry.name.endsWith('.md')) {
const baseName = entry.name.replace('.md', '');
const destName = `${prefix}-${baseName}.md`;
let content = fs.readFileSync(srcPath, 'utf8');
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
content = processAttribution(content, resolveAttribution(runtime));
content = _hostBehaviors(runtime).frontmatterDialect === 'kilo'
? (runtimeArtifactConversion as any).convertClaudeToKiloFrontmatter(content)
: (runtimeArtifactConversion as any).convertClaudeToOpencodeFrontmatter(content);
fs.writeFileSync(path.join(destDir, destName), content);
}
}
}
// ---------------------------------------------------------------------------
// installOpencodeFamilyArtifacts
// ---------------------------------------------------------------------------
/**
* Combined-family install orchestrator for OpenCode/Kilo (ADR-1239 / #2087).
* Stages the flattened commands surface + skills surface + (OpenCode only)
* native plugin adapter, mirroring the bespoke `else if (isOpencode ||
* isKilo)` block previously inlined in bin/install.js.
*
* @param runtime - 'opencode' or 'kilo'
* @param configDir - resolved runtime config directory
* @param scope - install scope ('global' | 'local')
* @param resolvedProfile - from resolveProfile() / resolveEffectiveProfile()
* @param resolveAttribution - injection: (runtime) => attribution string | undefined
* @param behaviors - the runtime's hostBehaviors descriptor (already resolved by the caller)
*/
function installOpencodeFamilyArtifacts(
runtime: string,
configDir: string,
scope: string,
resolvedProfile: any,
resolveAttribution: ResolveAttribution = () => undefined,
behaviors: any = {},
): void {
const isGlobal = scope === 'global';
// findInstallSourceRoot resolves DIRECTLY to the commands/gsd source dir
// (via the .gsd-source marker or a walk-up from __dirname) — every other
// call site in runtime-artifact-layout.cts feeds its return value straight
// into stageSkillsForProfile/stageSkillsForRuntimeAsSkills. The repo/package
// root (needed below for the native plugin source) is two levels up.
const commandsGsdDir = runtimeArtifactLayout.findInstallSourceRoot(configDir);
const src = path.dirname(path.dirname(commandsGsdDir));
const rawCommandsDir = installProfiles.stageSkillsForProfile(commandsGsdDir, resolvedProfile);
const pathPrefix = (runtimeArtifactConversion as any)._computePathPrefix({
isGlobal,
isOpencode: behaviors.skipHomePrefixSubstitution === true,
isWindowsHost: process.platform === 'win32',
resolvedTarget: path.resolve(configDir).replace(/\\/g, '/'),
homeDir: os.homedir().replace(/\\/g, '/'),
});
const commandDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, 'command');
installOpencodeFamilyCommands(runtime, commandDir, rawCommandsDir, pathPrefix, resolveAttribution);
installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution);
const np = behaviors.nativePlugin;
if (np && np.source) {
const pluginSrc = path.join(src, np.source);
if (fs.existsSync(pluginSrc)) {
const destDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, np.dir);
fs.mkdirSync(destDir, { recursive: true });
fs.copyFileSync(pluginSrc, path.join(destDir, np.file));
}
}
}
// ---------------------------------------------------------------------------
// uninstallRuntimeArtifacts
// ---------------------------------------------------------------------------
@@ -807,6 +953,9 @@ export = {
installRuntimeArtifacts,
uninstallRuntimeArtifacts,
installOpencodeFamilySkills,
installOpencodeFamilyCommands,
installOpencodeFamilyArtifacts,
_hostBehaviors,
_copyStaged,
hasExistingSymlinkBetween,
preserveUserArtifacts,

View File

@@ -2507,7 +2507,7 @@ function rewriteStagedSkillBodies(stagedDir, opts) {
const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/');
const homeDir = homedir().replace(/\\/g, '/');
const isGlobal = scope === 'global';
const isOpencode = runtime === 'opencode';
const isOpencode = false; // #2087: opencode installs via the combined-family engine path, never through the generic rewrite
const isWindowsHost = platform === 'win32';
const pathPrefix = computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir });
const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined;
@@ -2543,7 +2543,7 @@ function rewriteStagedCommandBodies(stagedDir, opts) {
const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/');
const homeDir = homedir().replace(/\\/g, '/');
const isGlobal = scope === 'global';
const isOpencode = runtime === 'opencode';
const isOpencode = false; // #2087: opencode installs via the combined-family engine path, never through the generic rewrite
const isWindowsHost = platform === 'win32';
const pathPrefix = computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir });
const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined;
@@ -2672,6 +2672,11 @@ export = {
neutralizeAgentReferences,
convertClaudeCommandToOpencodeSkill,
convertClaudeCommandToKiloSkill,
// #2087 — opencode/kilo command-frontmatter converters, exported so the
// layout-driven `convertedCommandsKind` can resolve them by name (routes the
// opencode/kilo command install through the engine instead of the bespoke path).
convertClaudeToOpencodeFrontmatter,
convertClaudeToKiloFrontmatter,
readGsdCommandNames,
transformContentToHyphen,
// #1383: version resolver (exported for regression test of the Codex

View File

@@ -404,7 +404,7 @@
"hooks/managed-hooks-registry.cjs": "763730ef31e5fd1c",
"opencode.json": "2c12c446a88f2f36",
"package.json": "dbf8353f77358bc1",
"plugins/gsd-core.js": "63687dc233ca707e",
"plugins/gsd-core.js": "931ca839dc9eb7f1",
"scripts/changeset/README.md": "86ff89331dfd94b2",
"scripts/changeset/cli.cjs": "68f92a344b199271",
"scripts/changeset/github-release-notes.cjs": "795677f0c009b132",

View File

@@ -275,7 +275,9 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => {
hermes: true,
kilo: true,
kimi: true,
opencode: true,
// #2087: OpenCode background subagents (v1.15 param, v1.17 default-on) →
// dispatch.background/backgroundDispatch true → NOT force-flattened.
opencode: false,
qwen: true,
trae: true,
windsurf: true,

View File

@@ -0,0 +1,122 @@
// allow-test-rule: AC2 requires asserting no `runtime === 'opencode'` string-equality branch remains in bin/install.js/src — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2087)
'use strict';
/**
* opencode imperative reference host — ADR-1239 Phase D / #2087 (EoS/opencode).
*
* Proves opencode is driven through the PUBLIC Host-Integration Interface (the
* imperative adapter), that its negotiated axes classify + negotiate correctly,
* that negotiation fails CLOSED on a corrupted descriptor, that the Context7-
* verified dispatch UPGRADE (background subagents, v1.15/v1.17) changes
* `shouldFlattenDispatch`, and that the migration retired the hardcoded
* `runtime === 'opencode'` / `isOpencode` branches (folded into descriptor-driven
* `runtime.hostBehaviors` + the combined-family engine install path).
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs');
const {
profileOf,
negotiateHostCapabilities,
shouldFlattenDispatch,
extensionEventSurfaceFor,
PROFILE_BASELINES,
UNDOCUMENTED,
} = require('../gsd-core/bin/lib/host-integration.cjs');
const OC_CAP = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'opencode', 'capability.json'), 'utf8'),
);
const OC_AXES = OC_CAP.runtime.hostIntegration;
// -- AC2: driven through the public interface (imperative adapter) -----------
test('createImperativeAdapter classifies opencode as imperative + composes the registry', () => {
const adapter = createImperativeAdapter({ runtime: 'opencode' });
assert.equal(adapter.kind, 'imperative');
assert.equal(adapter.runtime, 'opencode');
assert.ok(adapter.registry && typeof adapter.registry === 'object');
assert.equal(typeof adapter.install, 'function');
assert.equal(typeof adapter.uninstall, 'function');
});
test('opencode axes classify as the programmatic-cli reference profile', () => {
assert.equal(profileOf(OC_AXES), 'programmatic-cli');
});
// -- AC4: the Context7-verified UPGRADE (background dispatch) -----------------
test('opencode descriptor declares background dispatch true/true (v1.15/v1.17 upgrade)', () => {
assert.equal(OC_AXES.dispatch.background, true, 'background subagents (v1.15 param, v1.17 default-on)');
assert.equal(OC_AXES.dispatch.backgroundDispatch, true);
});
test('background UPGRADE changes shouldFlattenDispatch: false now (may background), true for the old axes', () => {
// Post-upgrade: opencode may run subagents concurrently → NOT force-flattened.
assert.equal(shouldFlattenDispatch(OC_AXES.dispatch), false,
'with background:true+backgroundDispatch:true, GSD must NOT force-flatten opencode dispatch');
// Pin the behavioral change: the pre-#2087 axes DID force-flatten.
const preUpgrade = { ...OC_AXES.dispatch, background: false, backgroundDispatch: 'undocumented' };
assert.equal(shouldFlattenDispatch(preUpgrade), true,
'pre-upgrade (background:false) opencode was force-flattened — this is the behavioral change #2087 lands');
});
test('opencode extension-event surface includes the #2087 additions (permission + session.error)', () => {
const surface = extensionEventSurfaceFor('opencode');
assert.ok(surface, 'opencode is a consumed extensionEvents dialect');
for (const ev of ['permission.asked', 'permission.replied', 'session.error']) {
assert.ok(surface.includes(ev), `#2087 adds ${ev} to the opencode extension-event surface`);
}
// The engine still owns phase sequencing — no workflow-phase events on the bus.
assert.ok(!surface.some((e) => /plan:|verify:|ship:/.test(e)));
});
// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------
test('negotiateHostCapabilities never throws for opencode, even fully corrupted', () => {
assert.doesNotThrow(() => negotiateHostCapabilities({}));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...OC_AXES, embeddingMode: UNDOCUMENTED }));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...OC_AXES, embeddingMode: 'future-unknown' }));
});
test('a partial/empty opencode descriptor degrades to the safe floor, not the programmatic-cli baseline', () => {
const result = negotiateHostCapabilities({});
assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed');
assert.equal(result.effective.hookBus, 'none');
assert.notDeepEqual(result.effective, PROFILE_BASELINES['programmatic-cli']);
assert.ok(result.warnings.length > 0);
});
// -- AC2: the hardcoded branches are retired ---------------------------------
test('opencode descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => {
const hb = OC_CAP.runtime.hostBehaviors;
assert.ok(hb && typeof hb === 'object');
assert.equal(hb.combinedFamilyInstall, true, 'commands+skills+plugin install runs through the engine (adapter)');
assert.equal(hb.reapplyCommand, '/gsd-update --reapply');
assert.equal(hb.attributionConfigResolver, 'opencode');
assert.equal(hb.flatCommandDir, 'command');
assert.equal(hb.frontmatterDialect, 'opencode');
assert.equal(hb.skipHomePrefixSubstitution, true);
assert.equal(hb.skipSettingsUi, true);
assert.equal(hb.skipUpdateBannerCommand, true);
assert.equal(hb.skipCodexSkillsManifest, true);
assert.equal(hb.nativePlugin.file, 'gsd-core.js');
assert.equal(hb.nativePlugin.source, '.opencode/plugins/gsd-core.js');
});
test('no `runtime === "opencode"` string-equality branch remains in the install source (AC2)', () => {
const strip = (src) => src
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/[^\r\n]*/g, '')
.replace(/`[^`]*`/g, '');
for (const rel of ['bin/install.js', 'src/install-engine.cts', 'src/runtime-artifact-conversion.cts']) {
const src = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
const offenders = strip(src).match(/runtime\s*[!=]==\s*'opencode'/g) || [];
assert.deepEqual(offenders, [], `AC2: no hardcoded runtime==='opencode' branch may remain in ${rel}; found: ${offenders.join(', ')}`);
}
});

View File

@@ -340,10 +340,15 @@ test('plugin implements the full declared opencode extension-event surface (Clau
}
// Session/file events dispatch through the `event` handler.
assert.equal(typeof handlers.event, 'function', 'plugin exposes an event dispatcher');
// Every declared surface event resolves to a plugin handler.
// Every declared surface event resolves to a plugin handler. Session /
// permission / error events dispatch through the `event` handler (not
// top-level handler keys). #2087 added permission.asked/replied + session.error.
const EVENT_DISPATCHED = new Set([
'session.created', 'session.idle', 'file.edited',
'permission.asked', 'permission.replied', 'session.error',
]);
for (const ev of surface) {
const covered = typeof handlers[ev] === 'function'
|| ev === 'session.created' || ev === 'session.idle' || ev === 'file.edited';
const covered = typeof handlers[ev] === 'function' || EVENT_DISPATCHED.has(ev);
assert.ok(covered, `plugin covers opencode extension event: ${ev}`);
}
});