feat(#2088): migrate Codex onto the Embeddable Orchestration System (ADR-1239)

Drive Codex install/uninstall through the descriptor-driven Host-Integration
Interface (declarative embedding adapter → engine surface dispatch) and fold
every positive `runtime === 'codex'` / `isCodex` projection into descriptor-driven
`runtime.hostBehaviors`. Install/uninstall output stays byte-parity-gated
(tests/fixtures/golden-install-parity/codex.json); no other runtime changes.

Three Context7-verified upgrades, each with a test on the user-reachable surface:
- Skill root → canonical $HOME/.agents/skills via a skills-kind `home` override,
  with pre-move migration cleanup (stale ~/.codex/skills/gsd-* removed on install
  and uninstall; user content preserved). Fixes getGlobalSkillsBase, writeManifest,
  and the skill-manifest inventory to honor the override so --skills-root /
  sync-skills / the manifest report the real location.
- Six new hooks.json lifecycle events (PreToolUse, PermissionRequest, PreCompact,
  PostCompact, SubagentStop, UserPromptSubmit) shared by install + uninstall;
  extendedHookEvents reconciled [] -> the schema-valid wired subset.
- Explicit `[agents] max_depth = 1` in the managed config.toml block, pinning the
  negotiated dispatch.maxDepth:1 axis. validateCodexConfigSchema now permits a
  known-scalar-only bare `[agents]` AgentsToml table (still rejects [[agents]] and
  unknown-key break-forms, #2760); mergeCodexConfig preserves the user's own
  AgentsToml scalars (max_threads etc.) instead of dropping them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-08 21:42:11 -04:00
parent e5a1d7f5c4
commit 6e773d97df
24 changed files with 1252 additions and 211 deletions

View File

@@ -102,6 +102,45 @@ const reset = '\x1b[0m';
// Codex config.toml constants
const GSD_CODEX_MARKER = '# GSD Agent Configuration \u2014 managed by gsd-core installer';
const GSD_CODEX_HOOKS_OWNERSHIP_PREFIX = '# GSD codex_hooks ownership: ';
// Known scalar fields of Codex's `AgentsToml` struct (codex-rs/config/src/
// config_toml.rs \u2014 `[agents]` table). Codex marks the struct
// `#[schemars(deny_unknown_fields)]`, so a bare `[agents]` table is valid ONLY
// when every direct key is one of these (named agent roles live in the flattened
// `[agents.<name>]` sub-tables, a separate `AgentRoleToml`). GSD writes only
// `max_depth` (ADR-1239 upgrade 2 / #2088); the full set is enumerated so the
// schema check accepts a user's other legitimate AgentsToml scalars too.
const CODEX_AGENTS_TOML_SCALAR_KEYS = new Set([
'max_threads',
'max_depth',
'job_max_runtime_seconds',
'interrupt_message',
]);
// GSD's managed dispatch-depth value. Codex's implicit default is also 1 (root
// sessions start at depth 0); writing it EXPLICITLY pins the negotiated
// `dispatch.maxDepth: 1` axis instead of relying on codex-cli's implicit default
// (ADR-1239 upgrade 2 / #2088). Per the negotiated capability, GSD-hosted Codex
// dispatch is single-level (maxDepth === 1 \u2192 `degradationFor` flattens waves).
const GSD_CODEX_AGENTS_MAX_DEPTH = 1;
// Codex hooks.json lifecycle events GSD registers beyond SessionStart (which has
// its own dedicated path). This is Codex's OWN hook-event vocabulary (per
// developers.openai.com/codex/config-reference), distinct from the cross-runtime
// settings.json `extendedHookEvents` descriptor field (a claude/gemini-family
// allowlist consumed only by hooksSurface==='settings-json' runtimes — Codex is
// codex-hooks-json). All route through gsd-context-monitor.js. #772 wired the
// first three; #2088 adds the remaining six documented events so GSD's monitor
// fires at the same lifecycle points as in Claude Code. Install and uninstall
// share this list so the registered set and the removed set never diverge.
const CODEX_EXTENDED_HOOK_EVENTS = [
'SubagentStart',
'Stop',
'PostToolUse',
'PreToolUse',
'PermissionRequest',
'PreCompact',
'PostCompact',
'SubagentStop',
'UserPromptSubmit',
];
// Codex's hook-enabling feature flag (issue #3566). Codex itself marks
// `codex_hooks` as a `legacy_key` in codex-rs/features/src/legacy.rs; the
// canonical current key under [features] is `hooks`. The installer always
@@ -357,6 +396,22 @@ function _hostBehaviors(runtime) {
return _resolveHostBehaviors(runtime, _capabilityRegistry);
}
/**
* Resolve the ACTUAL on-disk skills-install directory for a runtime, honoring a
* skills-kind `home` override (ADR-1239 upgrade 3 / #2088: e.g. Codex skills ->
* $HOME/.agents/skills instead of the runtime's configDir). Descriptor-driven
* (no runtime === '<id>' check) so the snapshot/rollback machinery and post-install
* verification look where the skills actually landed. Falls back to <targetDir>/skills.
*/
function _resolveSkillsRootDir(runtime, targetDir, scope) {
try {
const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
const skillsKind = layout.kinds.find((k) => k.kind === 'skills');
if (skillsKind) return path.join(skillsKind.home || targetDir, skillsKind.destSubpath);
} catch (_e) { /* fall through to the configDir default */ }
return path.join(targetDir, 'skills');
}
/**
* Construct the imperative Host-Integration adapter (ADR-1239 / #2086), FAIL-OPEN.
* `createImperativeAdapter` composes the capability registry via
@@ -3147,6 +3202,77 @@ function cleanupWindsurfLegacyDevinSkills(workspaceDir) {
return removed;
}
/**
* Migrate a skills kind that moved to an alternate `home` (ADR-1239 split-home):
* remove now-stale `<prefix>*` skill dirs left at the OLD configDir-rooted
* location by installs from before the move. Without this, upgrading (e.g. Codex
* relocating skills to ~/.agents/skills) orphans the pre-move dirs at
* ~/.codex/skills. Only managed `<prefix>*` dirs are touched; user-owned content
* (non-prefixed dirs, gsd-dev-preferences, symlinks) is preserved. Fail-open.
* @param {string} oldSkillsDir absolute path to the pre-move skills location
* @param {string} prefix managed skill-dir prefix (e.g. 'gsd-')
* @returns {number} count of stale dirs removed
*/
function cleanupMovedSkillsOldLocation(oldSkillsDir, prefix) {
if (!fs.existsSync(oldSkillsDir)) return 0;
// Mirror the user-owned list from cleanupCodexSkillMetadataSidecars (#2973).
const _userOwnedSkillDirs = new Set(['gsd-dev-preferences']);
let removed = 0;
for (const entry of fs.readdirSync(oldSkillsDir, { withFileTypes: true })) {
if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue;
if (_userOwnedSkillDirs.has(entry.name)) continue;
const dirToRemove = path.join(oldSkillsDir, entry.name);
try {
// Symlink guard (mirrors cleanupWindsurfLegacyDevinSkills): never delete
// through a symlinked gsd-* dir — it could escape the tree.
const stat = fs.lstatSync(dirToRemove);
if (stat.isSymbolicLink()) continue;
fs.rmSync(dirToRemove, { recursive: true, force: true });
removed++;
} catch (_err) {
// Fail open — a single bad dir must not block install/uninstall.
}
}
// Prune the old skills dir if now empty — leaves the configHome clean.
// Never remove a non-empty container (user may keep other content there).
try {
if (fs.existsSync(oldSkillsDir) && fs.readdirSync(oldSkillsDir).length === 0) {
fs.rmdirSync(oldSkillsDir);
}
} catch (_err) {
// best-effort container cleanup
}
return removed;
}
/**
* When a runtime's skills kind declares an alternate `home` (split-home move),
* return the now-stale configDir-rooted skills location that installs before the
* move used; null when no move is in effect (no home override, or home resolves
* to the same path). Descriptor-driven — no per-runtime hardcoding.
* @returns {string|null}
*/
function _resolveMovedSkillsOldDir(runtime, targetDir, scope) {
try {
const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
const skillsKind = layout.kinds.find((k) => k.kind === 'skills');
if (skillsKind && skillsKind.home) {
const oldDir = path.join(targetDir, skillsKind.destSubpath);
const newDir = path.join(skillsKind.home, skillsKind.destSubpath);
if (path.resolve(oldDir) !== path.resolve(newDir)) return oldDir;
}
} catch (_e) {
// No migration when the layout can't resolve — never block on this.
}
return null;
}
/**
* Generate the GSD config block for Codex config.toml.
* @param {Array<{name: string, description: string}>} agents
@@ -3162,6 +3288,17 @@ function generateCodexConfigBlock(agents, targetDir) {
'',
];
// ADR-1239 upgrade 2 / #2088 — explicit dispatch tuning. Pin `max_depth` on the
// `[agents]` (AgentsToml) table rather than relying on codex-cli's implicit
// default, realizing the negotiated `dispatch.maxDepth: 1` axis. This bare
// `[agents]` scalar table coexists with the flattened `[agents.<name>]` role
// sub-tables below (validated by validateCodexConfigSchema, which permits a
// known-scalar-only `[agents]`). Emitted before the role tables so the parent
// table is opened first.
lines.push('[agents]');
lines.push(`max_depth = ${GSD_CODEX_AGENTS_MAX_DEPTH}`);
lines.push('');
for (const { name, description } of agents) {
// #2727 — Codex 0.124.0 requires [agents.<name>] struct format, not [[agents]] sequence.
// [[agents]] (introduced in #2645) is rejected by codex-cli 0.124.0 with
@@ -3175,6 +3312,52 @@ function generateCodexConfigBlock(agents, targetDir) {
return lines.join('\n');
}
/**
* Extract a user's pre-existing AgentsToml scalar assignments from a bare
* `[agents]` table — every known scalar EXCEPT `max_depth` (which GSD manages
* and always re-emits as 1). Returned as raw `key = value` line strings so
* mergeCodexConfig can PRESERVE them in the managed block instead of silently
* dropping the user's tuning when the bare `[agents]` table is purged (#2088
* review finding: the loosened validator declares such a table legitimate, so
* install must not destroy it). Only the first bare `[agents]` section is read;
* `[agents.<name>]` role tables are ignored. Fail-open → [].
* @returns {string[]}
*/
function extractCodexUserAgentsScalars(content) {
const preserved = [];
let section;
try {
section = getTomlTableSections(content).find((s) => !s.array && s.path === 'agents');
} catch (_e) {
return preserved;
}
if (!section) return preserved;
const body = content.slice(section.headerEnd, section.end);
for (const record of getTomlLineRecords(body)) {
if (record.startsInMultilineString || record.tableHeader) continue;
const trimmed = record.text.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
if (!record.keySegments || record.keySegments.length !== 1) continue;
const key = record.keySegments[0];
if (key === 'max_depth') continue; // GSD-managed — GSD's value wins.
if (!CODEX_AGENTS_TOML_SCALAR_KEYS.has(key)) continue;
preserved.push(trimmed);
}
return preserved;
}
/**
* Splice preserved user AgentsToml scalar lines into the managed GSD config
* block, immediately after the `[agents]` header and before GSD's `max_depth`
* line. Operates on the pre-EOL-normalization block (LF joins), matching only
* the bare `[agents]` header (never `[agents.<name>]`). Returns the block
* unchanged when there is nothing to preserve or the anchor is absent.
*/
function spliceCodexAgentsScalars(block, scalarLines) {
if (!scalarLines || scalarLines.length === 0) return block;
return block.replace(/(\n\[agents\]\n)(max_depth = )/, `$1${scalarLines.join('\n')}\n$2`);
}
/**
* Strip any managed GSD agent sections from a TOML string.
*
@@ -3197,6 +3380,16 @@ function stripCodexGsdAgentSections(content) {
return true;
}
// GSD's managed `[agents]` scalar block (ADR-1239 upgrade 2 / #2088 — the
// `max_depth` dispatch-tuning table). Install purges any pre-existing bare
// `[agents]` and writes its own, so a known-scalar-only bare `[agents]` is
// GSD-owned; strip it on uninstall. (The marker path already removes it via
// the marker-to-EOF cut; this covers the no-marker fallback.)
if (!section.array && section.path === 'agents') {
const body = content.slice(section.headerEnd, section.end);
return codexBareAgentsHasOnlyKnownScalars(body);
}
// Legacy `[[agents]]` array-of-tables (#2645) — only strip blocks whose
// `name = "gsd-..."`, preserving user-authored [[agents]] entries.
if (section.array && section.path === 'agents') {
@@ -3224,7 +3417,11 @@ function stripGsdFromCodexConfig(content) {
const codexHooksOwnership = getManagedCodexHooksOwnership(content);
if (markerIndex !== -1) {
// Has GSD marker — remove everything from marker to EOF
// Has GSD marker — remove everything from marker to EOF. First recover the
// user's own AgentsToml scalars (max_threads etc.) that install folded into
// the managed [agents] block (#2088), so a full install→uninstall cycle
// round-trips the user's tuning. GSD-managed max_depth is dropped.
const preservedScalars = extractCodexUserAgentsScalars(content.slice(markerIndex));
let before = content.substring(0, markerIndex);
before = stripCodexHooksFeatureAssignments(before, codexHooksOwnership);
// Also strip GSD-injected feature keys above the marker (Case 3 inject)
@@ -3233,6 +3430,9 @@ function stripGsdFromCodexConfig(content) {
before = before.replace(/^\[features\]\s*\n(?=\[|$)/m, '');
before = before.replace(/^\[agents\]\s*\n(?=\[|$)/m, '');
before = before.replace(/^(?:\r?\n)+/, '').trimEnd();
if (preservedScalars.length > 0) {
before = (before ? before + eol + eol : '') + '[agents]' + eol + preservedScalars.join(eol);
}
if (!before) return null;
return before + eol;
}
@@ -3243,7 +3443,11 @@ function stripGsdFromCodexConfig(content) {
cleaned = cleaned.replace(/^multi_agent\s*=\s*true\s*(?:\r?\n)?/m, '');
cleaned = cleaned.replace(/^default_mode_request_user_input\s*=\s*true\s*(?:\r?\n)?/m, '');
// Remove [agents.gsd-*] sections (from header to next section or EOF)
// #2088: recover the user's own AgentsToml scalars before the [agents] table is
// stripped, so they survive uninstall even in the no-marker fallback path.
const preservedScalars = extractCodexUserAgentsScalars(cleaned);
// Remove [agents.gsd-*] sections + the managed known-scalar [agents] table.
cleaned = stripCodexGsdAgentSections(cleaned);
// Remove [features] section if now empty (only header, no keys before next section)
@@ -3254,6 +3458,10 @@ function stripGsdFromCodexConfig(content) {
cleaned = cleaned.replace(/^(?:\r?\n)+/, '').trimEnd();
if (preservedScalars.length > 0) {
cleaned = (cleaned ? cleaned + eol + eol : '') + '[agents]' + eol + preservedScalars.join(eol);
}
if (!cleaned) return null;
return cleaned + eol;
}
@@ -4725,6 +4933,33 @@ function parseTomlToObject(content) {
* - `hooks.<Event>` MUST be an array of tables when present (Codex ≥0.124
* rejects bare `[hooks.<Event>]` single-bracket maps).
*/
/**
* True when a bare `[agents]` table body contains ONLY known AgentsToml scalar
* keys (CODEX_AGENTS_TOML_SCALAR_KEYS) — i.e. it is a valid AgentsToml struct
* that Codex's `deny_unknown_fields` will accept, not the break-causing form
* (#2760) that carries an unknown key. Comments and blank lines are ignored; an
* empty body is trivially valid. Mirrors isLegacyGsdAgentsSection's line scan.
*/
function codexBareAgentsHasOnlyKnownScalars(body) {
const lineRecords = getTomlLineRecords(body);
for (const record of lineRecords) {
// Conservative reject of anything not positively a single known-scalar
// assignment. A multiline-string value cannot be a valid AgentsToml scalar
// (max_threads/max_depth/job_max_runtime_seconds are integers,
// interrupt_message is a bool — none are strings), so codex would reject it
// too; rejecting here is correct, not a false negative.
if (record.startsInMultilineString) return false;
if (record.tableHeader) return false;
const trimmed = record.text.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
if (!record.keySegments || record.keySegments.length !== 1 ||
!CODEX_AGENTS_TOML_SCALAR_KEYS.has(record.keySegments[0])) {
return false;
}
}
return true;
}
function validateCodexConfigSchema(content) {
let parsed;
try {
@@ -4753,10 +4988,21 @@ function validateCodexConfigSchema(content) {
}
if (!section.array && section.path === 'agents') {
return {
ok: false,
reason: 'bare [agents] table is invalid in current Codex schema (expected [agents.<name>] struct form)',
};
// #2760 rejected ALL bare `[agents]` tables because a bare table holding a
// non-AgentsToml key (`default = "x"`, a role name, etc.) triggers Codex's
// "invalid type: ..., expected struct AgentsToml" and breaks every CLI
// invocation. But a bare `[agents]` whose keys are all valid AgentsToml
// scalars (max_depth/max_threads/...) IS a valid struct — that is exactly
// GSD's managed `max_depth` dispatch-tuning block (ADR-1239 upgrade 2 /
// #2088), and a user's own scalar tuning. Permit known-scalar-only; still
// reject any bare `[agents]` carrying an unknown key.
const body = content.slice(section.headerEnd, section.end);
if (!codexBareAgentsHasOnlyKnownScalars(body)) {
return {
ok: false,
reason: 'bare [agents] table with a non-AgentsToml key is invalid in current Codex schema (expected [agents.<name>] struct form, or only AgentsToml scalars like max_depth/max_threads)',
};
}
}
// hooks.state.* is Codex's persistent hook-trust namespace (added in
@@ -5032,7 +5278,13 @@ function mergeCodexConfig(configPath, gsdBlock) {
const existing = fs.readFileSync(configPath, 'utf8');
const eol = detectLineEnding(existing);
const normalizedGsdBlock = gsdBlock.replace(/\r?\n/g, eol);
// #2088 review: the bare `[agents]` table is purged below (Case 2/3 via
// stripLeakedGsdCodexSections) to keep a single managed `[agents]`. Preserve
// the user's own AgentsToml scalar tuning (max_threads, job_max_runtime_seconds,
// interrupt_message — everything except GSD-managed max_depth) by re-emitting
// it inside the managed block, so install never silently drops it.
const mergedGsdBlock = spliceCodexAgentsScalars(gsdBlock, extractCodexUserAgentsScalars(existing));
const normalizedGsdBlock = mergedGsdBlock.replace(/\r?\n/g, eol);
const markerIndex = existing.indexOf(GSD_CODEX_MARKER);
// Case 2: Has GSD marker — truncate and re-append
@@ -6565,8 +6817,24 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
}
removedCount++;
// ADR-1239 split-home migration: the adapter/plan uninstall targets the new
// `home` location (e.g. Codex → ~/.agents/skills). A user who installed
// BEFORE the move and never reinstalled still has managed gsd-* skill dirs at
// the old configDir-rooted location (~/.codex/skills) — remove those too so
// uninstall leaves nothing behind. User-owned content is preserved.
{
const _movedOldSkillsDir = _resolveMovedSkillsOldDir(runtime, targetDir, scope);
if (_movedOldSkillsDir) {
const migrated = cleanupMovedSkillsOldLocation(_movedOldSkillsDir, 'gsd-');
if (migrated > 0) {
removedCount++;
console.log(` ${green}✓${reset} Removed ${migrated} legacy skill dir(s) from ${_movedOldSkillsDir}`);
}
}
}
// 1a. Non-layout Codex side-effects: agent .toml files, config.toml sections, hooks.json
if (isCodex) {
if (_hostBehaviors(runtime).tomlConfigInstall) {
const codexAgentsDir = path.join(targetDir, 'agents');
if (fs.existsSync(codexAgentsDir)) {
const tomlFiles = fs.readdirSync(codexAgentsDir);
@@ -6605,8 +6873,10 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
console.log(` ${green}✓${reset} Removed managed Codex SessionStart hook from hooks.json`);
}
// #772: remove new Codex hook event registrations added by this enhancement.
for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) {
// #772/#2088: remove every managed Codex extended hook-event registration.
// Shares CODEX_EXTENDED_HOOK_EVENTS with the install loop — removal set ==
// registration set, so no managed event is ever orphaned.
for (const eventName of CODEX_EXTENDED_HOOK_EVENTS) {
const eventCleanup = removeCodexHooksJsonEvent(targetDir, eventName);
if (eventCleanup.changed) {
removedCount++;
@@ -7514,11 +7784,16 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
// Claude local uses flatCommandsDir instead for manifest recording.
const flatCommandsDir = path.join(configDir, 'commands');
const opencodeCommandDir = path.join(configDir, _hostBehaviors(runtime).flatCommandDir || 'command');
// Hermes nests GSD skills under skills/gsd/ as a single category (#2841).
// Hermes nests GSD skills under skills/gsd/ as a single category (#2841) —
// already encoded in its layout descriptor's destSubpath ('skills/gsd').
// All other runtimes that use the Codex-style skills layout use a flat skills/ root.
const codexSkillsDir = isHermes
? path.join(configDir, 'skills', 'gsd')
: path.join(configDir, 'skills');
// ADR-1239 upgrade 3 (#2088): honor a skills-kind `home` override (e.g. Codex
// skills -> $HOME/.agents/skills instead of configDir/skills) via the same
// descriptor-driven helper used by the snapshot/rollback/verification paths,
// so the manifest records what's actually on disk. _resolveSkillsRootDir already
// resolves destSubpath (which includes hermes's 'skills/gsd' nesting) — do not
// re-append 'gsd' or the hermes dir gets double-nested to skills/gsd/gsd.
const codexSkillsDir = _resolveSkillsRootDir(runtime, configDir, options.scope === 'local' ? 'local' : 'global');
const codexSkillsManifestPrefix = isHermes ? 'skills/gsd/' : 'skills/';
const agentsDir = path.join(configDir, 'agents');
const manifest = {
@@ -7970,9 +8245,7 @@ function reportLocalPatches(configDir, runtime = DEFAULT_RUNTIME) {
if (meta.files && meta.files.length > 0) {
const reapplyCommand = _hostBehaviors(runtime).reapplyCommand
? _hostBehaviors(runtime).reapplyCommand
: runtime === 'codex'
? '$gsd-update --reapply'
: runtime === 'cursor'
: runtime === 'cursor'
? 'gsd-update --reapply (mention the skill name)'
: runtime === 'kimi'
? '/skill:gsd-update --reapply'
@@ -8209,8 +8482,8 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// Map<filename, Buffer> — content snapshot of each pre-existing gsd-* agent file.
const codexPreInstallAgentContents = new Map();
let codexPreInstallVersionBytes = null;
if (isCodex && !isMinimalMode(_effectiveInstallMode)) {
const _preSkillsDir = path.join(targetDir, 'skills');
if (_hostBehaviors(runtime).tomlConfigInstall && !isMinimalMode(_effectiveInstallMode)) {
const _preSkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
if (fs.existsSync(_preSkillsDir)) {
for (const entry of fs.readdirSync(_preSkillsDir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
@@ -8263,10 +8536,10 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// atomic-write temp files. It is safe to call before any writes have happened.
// The full restoreCodexSnapshot() (defined inside the config block) additionally
// handles config.toml, which is not yet touched at this point in the pipeline.
const _codexPreConfigRollback = !isCodex || isMinimalMode(_effectiveInstallMode) ? null : () => {
const _codexPreConfigRollback = !_hostBehaviors(runtime).tomlConfigInstall || isMinimalMode(_effectiveInstallMode) ? null : () => {
rollbackInstallerMigrations();
// skills/gsd-* — pass 1: restore snapshot entries (may be absent if deleted mid-install).
const _earlySkillsDir = path.join(targetDir, 'skills');
const _earlySkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
for (const skillName of codexPreInstallSkillNames) {
const skillDirPath = path.join(_earlySkillsDir, skillName);
const fileMap = codexPreInstallSkillContents.get(skillName);
@@ -8459,6 +8732,13 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
if (_isSkillsRuntime) {
// Layout-driven install for skills-based runtimes (full and minimal modes)
const scope = isGlobal ? 'global' : 'local';
// ADR-1239 upgrade 3 / #2088: a kind may declare an alternate install `home`
// (e.g. Codex skills -> $HOME/.agents/skills) instead of the runtime's normal
// configDir. Resolve the ACTUAL on-disk skills root here, descriptor-driven
// (no isCodex check), so downstream sidecar-cleanup and post-install
// verification look in the right place regardless of which runtime declares
// an alternate home for its skills kind.
const _skillsRootDir = _resolveSkillsRootDir(runtime, targetDir, scope);
// ADR-1239 / #2086: drive install through the public Host-Integration Interface
// (imperative adapter). The adapter delegates to the SAME installRuntimeArtifacts
// engine call -> byte-identical output (gated by golden-install-parity). Fail-open
@@ -8481,8 +8761,23 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// index BOTH SKILL.md and the sidecar, causing each GSD skill to appear twice
// in autocomplete. Cleaning them up fixes the duplication; SKILL.md alone is
// sufficient for Codex discovery. User-owned dirs are never touched.
if (isCodex) {
cleanupCodexSkillMetadataSidecars(path.join(targetDir, 'skills'));
if (_hostBehaviors(runtime).cleanupSkillSidecars) {
cleanupCodexSkillMetadataSidecars(_skillsRootDir);
}
// ADR-1239 split-home migration: when a runtime's skills kind moved to an
// alternate `home` (e.g. Codex → ~/.agents/skills), pre-move installs left
// managed gsd-* skill dirs at the old configDir-rooted location
// (~/.codex/skills). Reinstalling here writes the new location but would
// otherwise orphan the old one — clean up the stale gsd-* dirs.
{
const _movedOldSkillsDir = _resolveMovedSkillsOldDir(runtime, targetDir, scope);
if (_movedOldSkillsDir) {
const migrated = cleanupMovedSkillsOldLocation(_movedOldSkillsDir, 'gsd-');
if (migrated > 0) {
console.log(` ${green}✓${reset} Migrated ${migrated} skill dir(s) off the legacy ${_movedOldSkillsDir} location`);
}
}
}
// #1629 Finding B: Windsurf local only — remove legacy .devin/skills/gsd-*
@@ -8554,7 +8849,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
}
}
} else {
const skillsDir = path.join(targetDir, 'skills');
const skillsDir = _skillsRootDir;
if (fs.existsSync(skillsDir)) {
const count = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length;
@@ -8809,7 +9104,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
for (const file of fs.readdirSync(agentsDest)) {
if (
file.startsWith('gsd-') &&
(file.endsWith('.md') || (isCodex && file.endsWith('.toml')))
(file.endsWith('.md') || (_hostBehaviors(runtime).agentTomlFiles && file.endsWith('.toml')))
) {
fs.unlinkSync(path.join(agentsDest, file));
}
@@ -8827,7 +9122,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// Without stripping them here, a full → minimal reinstall would leave the
// runtime advertising the old full agent surface even though the agent
// files are gone. Reuse the same helper that powers `--uninstall`.
if (isCodex) {
if (_hostBehaviors(runtime).tomlConfigInstall) {
const codexConfigPath = path.join(targetDir, 'config.toml');
if (fs.existsSync(codexConfigPath)) {
const existing = fs.readFileSync(codexConfigPath, 'utf8');
@@ -8883,7 +9178,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
content = convertClaudeToOpencodeFrontmatter(content, { isAgent: true, modelOverride: _ocModelOverride });
} else if (_hostBehaviors(runtime).frontmatterDialect === 'kilo') {
content = convertClaudeToKiloFrontmatter(content, { isAgent: true });
} else if (isCodex) {
} else if (_hostBehaviors(runtime).frontmatterDialect === 'codex') {
content = convertClaudeAgentToCodexAgent(content);
} else if (isCopilot) {
content = convertClaudeAgentToCopilotAgent(content, isGlobal);
@@ -9197,7 +9492,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
}
// Write file manifest for future modification detection
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
console.log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`);
// Report any backed-up local patches
@@ -9340,7 +9635,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// (copyCommandsAsCodexSkills removes pre-existing gsd-* dirs before re-writing)
// are restored even when they are absent from disk at rollback time (#3245 CR).
// • Dirs that did not pre-exist: remove entirely.
const _rollbackSkillsDir = path.join(targetDir, 'skills');
const _rollbackSkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
// Pass 1 — restore snapshot entries (may be absent from disk if deleted mid-install).
for (const skillName of codexPreInstallSkillNames) {
const skillDirPath = path.join(_rollbackSkillsDir, skillName);
@@ -9451,7 +9746,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// Re-write the manifest now that .toml agent files exist on disk.
// The initial writeManifest call (before Codex config generation) could
// not include agents/gsd-*.toml because those files did not yet exist.
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
} else {
console.log(` ${dim}↳${reset} Skipping Codex agent config generation (minimal install)`);
}
@@ -9591,24 +9886,23 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
}
}
// ── Codex extended hook events (#772) ────────────────────────────────
// Codex CLI stabilised a full hook-event set in rust-v0.137.0. Register
// three new high-value lifecycle events — all routed through
// gsd-context-monitor.js so context-headroom warnings surface at:
// SubagentStart — subagent session open (environment / agent-name aware)
// Stop — model stop / session final-response moment
// PostToolUse — after each tool invocation (mirrors Claude baseline)
//
// Note: UserPromptSubmit is NOT wired — gsd-prompt-guard exits unless
// tool_name is Write|Edit (PreToolUse payload shape), so it would be a
// silent no-op for the UserPromptSubmit payload. Registration deferred
// to a follow-on issue.
// ── Codex extended hook events (#772, #2088) ─────────────────────────
// Codex CLI stabilised a full hook-event set in rust-v0.137.0. GSD
// registers CODEX_EXTENDED_HOOK_EVENTS (#2088 adds the 6 documented
// events beyond the original #772 three) — all routed through
// gsd-context-monitor.js so context-headroom warnings surface at each
// lifecycle point: SubagentStart/SubagentStop (subagent open/close),
// Stop (final-response), PreToolUse/PostToolUse (tool boundaries),
// PermissionRequest (approval prompts), Pre/PostCompact (context
// compaction), and UserPromptSubmit (per-turn context injection). The
// context-monitor script decides per-payload what to do; unregistered
// events simply never fire.
//
// Guard: only register when the context-monitor file exists and the node
// runner is available — same guards as the SessionStart path above.
const contextMonitorFile = path.join(targetDir, 'hooks', 'gsd-context-monitor.js');
if (codexNodeRunner && fs.existsSync(contextMonitorFile)) {
for (const codexEvent of ['SubagentStart', 'Stop', 'PostToolUse']) {
for (const codexEvent of CODEX_EXTENDED_HOOK_EVENTS) {
const eventWrite = ensureCodexHooksJsonEvent(targetDir, codexEvent, {
absoluteRunner: codexNodeRunner,
platform: process.platform,
@@ -9620,7 +9914,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
}
}
} else if (!codexNodeRunner) {
console.warn(` ${yellow}⚠${reset} Skipped Codex SubagentStart/Stop/PostToolUse hook registration — Node runner unavailable.`);
console.warn(` ${yellow}⚠${reset} Skipped Codex extended hook-event registration — Node runner unavailable.`);
}
// ── end Codex extended hook events ────────────────────────────────────
}
@@ -9694,7 +9988,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
console.log(` ${green}✓${reset} Cursor lifecycle hooks already up to date`);
}
// Re-run the manifest pass so the hook scripts + hooks.json are hash-tracked.
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
@@ -9712,7 +10006,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
writeClineArtifacts(targetDir, isGlobal);
// Re-run the manifest pass: these artifacts are written *after* the earlier
// writeManifest() call, so a second pass is needed to hash-track them.
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
@@ -11009,6 +11303,13 @@ module.exports = {
generateCodexAgentToml,
cleanupCodexSkillMetadataSidecars,
cleanupWindsurfLegacyDevinSkills,
cleanupMovedSkillsOldLocation,
_resolveMovedSkillsOldDir,
_resolveSkillsRootDir,
codexBareAgentsHasOnlyKnownScalars,
extractCodexUserAgentsScalars,
spliceCodexAgentsScalars,
CODEX_EXTENDED_HOOK_EVENTS,
generateCodexConfigBlock,
stripGsdFromCodexConfig,
migrateCodexHooksMapFormat,