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:
@@ -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([]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user