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

@@ -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