diff --git a/.changeset/772-codex-hook-events-commandwindows.md b/.changeset/772-codex-hook-events-commandwindows.md new file mode 100644 index 000000000..78a942559 --- /dev/null +++ b/.changeset/772-codex-hook-events-commandwindows.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 827 +--- +Codex installs now register three additional stable hook events (`SubagentStart`, `Stop`, `PostToolUse`) wired to `gsd-context-monitor.js`, matching the full event coverage available since Codex CLI stabilised these hooks. The `SessionStart` hook entry gains a `commandWindows` field on Windows installs so the `.cmd` shim is used for native execution (Git Bash/MSYS cannot POSIX-exec `node.exe` directly). Both new-event registration and uninstall paths handle the flat `{ "EventName": [...] }` and nested `{ "hooks": { "EventName": [...] } }` hooks.json shapes. `gsd-context-monitor.js` and its Windows `.cmd` sibling are added to the managed-hook allowlist so idempotent re-runs de-duplicate entries correctly. (#772) diff --git a/bin/install.js b/bin/install.js index c9cdd9ec2..2228b2103 100755 --- a/bin/install.js +++ b/bin/install.js @@ -864,9 +864,31 @@ function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) { return { content: updated, changed }; } -function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) { +/** + * Generic reconcile helper: ensure hooks.json contains exactly one managed GSD + * hook entry for `eventName`, while preserving all user-owned entries. + * + * Supports both known hooks.json shapes: + * 1) { "": [...] } + * 2) { "hooks": { "": [...] } } + * + * @param {string} targetDir - Codex config dir (e.g. ~/.codex or /.codex). + * @param {string} eventName - Codex hook event name (e.g. 'SessionStart', 'Stop'). + * @param {{ managedCommand?: string|null, commandWindows?: string|null, matcher?: string|null, timeout?: number|null }} opts + * managedCommand: POSIX hook command string to register, or null to remove. + * commandWindows: Windows .cmd shim path to emit as `commandWindows` field + * (#772). When provided, Codex uses this path on Windows and `managedCommand` + * on POSIX without needing per-platform config regeneration. + * matcher: optional Codex MatcherGroup pattern (e.g. 'Bash|Edit|Write'). + * timeout: optional timeout in seconds. + * @returns {{ changed: boolean, wrote: boolean, path: string }} + */ +function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) { const hooksJsonPath = path.join(targetDir, 'hooks.json'); const managedCommand = typeof opts.managedCommand === 'string' ? opts.managedCommand : null; + const commandWindows = typeof opts.commandWindows === 'string' ? opts.commandWindows : null; + const matcher = typeof opts.matcher === 'string' ? opts.matcher : undefined; + const timeout = typeof opts.timeout === 'number' ? opts.timeout : undefined; let parsed = {}; let currentContent = null; if (fs.existsSync(hooksJsonPath)) { @@ -885,22 +907,22 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) { const usesNestedHooksObject = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks); const hookTable = usesNestedHooksObject ? parsed.hooks : parsed; - const sessionStart = Array.isArray(hookTable.SessionStart) ? hookTable.SessionStart : []; + const eventEntries = Array.isArray(hookTable[eventName]) ? hookTable[eventName] : []; let removedLegacy = false; - const sanitizedSessionStart = []; - for (const entry of sessionStart) { + const sanitizedEntries = []; + for (const entry of eventEntries) { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; const originalHooks = Array.isArray(entry.hooks) ? entry.hooks : []; if (originalHooks.length === 0) { - sanitizedSessionStart.push(entry); + sanitizedEntries.push(entry); continue; } - const keptHooks = originalHooks.filter((hook) => { - const cmd = hook && typeof hook === 'object' ? hook.command : null; - const managed = isManagedHookCommand(cmd, { - surface: 'codex-hooks-json', - includeLegacyAliases: true, + const keptHooks = originalHooks.filter((hook) => { + const cmd = hook && typeof hook === 'object' ? hook.command : null; + const managed = isManagedHookCommand(cmd, { + surface: 'codex-hooks-json', + includeLegacyAliases: true, configDir: targetDir, }); if (managed) removedLegacy = true; @@ -908,24 +930,26 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) { }); if (keptHooks.length === 0) continue; const nextEntry = { ...entry, hooks: keptHooks }; - sanitizedSessionStart.push(nextEntry); + sanitizedEntries.push(nextEntry); } if (managedCommand) { - sanitizedSessionStart.push({ - hooks: [ - { - type: 'command', - command: managedCommand, - }, - ], - }); + const hookEntry = { type: 'command', command: managedCommand }; + // #772: emit commandWindows so Codex picks the .cmd shim on Windows and + // the POSIX command on other platforms — without requiring per-OS config + // regeneration. Sourced from HookHandlerConfig.command_windows field in + // codex-rs/config/src/hook_config.rs (alias: commandWindows). + if (commandWindows) hookEntry.commandWindows = commandWindows; + if (timeout !== undefined) hookEntry.timeout = timeout; + const newEntry = { hooks: [hookEntry] }; + if (matcher !== undefined) newEntry.matcher = matcher; + sanitizedEntries.push(newEntry); } - if (sanitizedSessionStart.length > 0) { - hookTable.SessionStart = sanitizedSessionStart; + if (sanitizedEntries.length > 0) { + hookTable[eventName] = sanitizedEntries; } else { - delete hookTable.SessionStart; + delete hookTable[eventName]; } if (usesNestedHooksObject) parsed.hooks = hookTable; @@ -939,6 +963,18 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) { return { changed: changed || removedLegacy, wrote: shouldWrite, path: hooksJsonPath }; } +/** + * Reconcile the GSD-managed SessionStart hook entry in hooks.json. + * Delegates to the generic reconcileCodexHooksJsonEvent helper. + * + * @param {string} targetDir + * @param {{ managedCommand?: string|null, commandWindows?: string|null }} opts + * @returns {{ changed: boolean, wrote: boolean, path: string }} + */ +function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) { + return reconcileCodexHooksJsonEvent(targetDir, 'SessionStart', opts); +} + /** * Build a typed IR for the Codex hook .cmd shim used on Windows (#3426). * @@ -1020,8 +1056,14 @@ function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) { * 2) { "hooks": { "SessionStart": [...] } } * * On Windows, writes a .cmd shim alongside the .js hook file and uses the - * .cmd path as the hook command to avoid the `bash.exe: cannot execute binary - * file` failure (#3426). + * .cmd shim path as the hook command to avoid the `bash.exe: cannot execute + * binary file` failure (#3426). + * + * #772: also emits `commandWindows` in the hook entry so that a + * cross-platform hooks.json works on both POSIX and Windows without + * requiring per-OS regeneration. Codex dispatches `commandWindows` on + * Windows and `command` on other platforms (HookHandlerConfig in + * codex-rs/config/src/hook_config.rs). * * @param {string} targetDir * @param {{ absoluteRunner: string|null, platform?: NodeJS.Platform }} opts @@ -1033,7 +1075,18 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) { const hooksJsonPath = path.join(targetDir, 'hooks.json'); if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath }; - const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-check-update.js'); + // Normalize backslashes to forward slashes so isManagedHookCommand can + // match stored commands against configDir on Windows CI runners where + // path.resolve returns backslash paths but the stored command may use + // forward slashes (or vice versa). Forward-slash paths are always valid on + // Windows for both Node.js and Codex, so this normalization is safe for all + // platforms. (#772 — same fix applied to ensureCodexHooksJsonEvent.) + const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/'); + + // #772: compute the Windows .cmd shim path cross-platform so that + // `commandWindows` can be emitted in hooks.json regardless of the host OS. + // The .cmd path is always the .js script path with extension replaced. + const cmdShimPath = scriptPath.replace(/\.js$/, '.cmd'); let managedCommand; if (platform === 'win32') { @@ -1070,7 +1123,96 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) { } if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath }; - return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand }); + + // #772: emit commandWindows — the .cmd shim path — but ONLY on Windows where + // the shim was actually written. On POSIX, commandWindows is omitted to avoid + // pointing Windows Codex at a non-existent .cmd file (the shim is only present + // when install() ran natively on Windows and wrote it via buildCodexHookWindowsShimIR). + const commandWindows = platform === 'win32' + ? JSON.stringify(cmdShimPath.replace(/\\/g, '/')) + : undefined; + + return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand, commandWindows }); +} + +/** + * Ensure hooks.json contains exactly one managed GSD hook entry for the given + * Codex event, wired to gsd-context-monitor.js. Preserves user-owned entries. + * + * Used for the new Codex events added in #772: + * SubagentStart — inject context / GSD_AGENT_NAME awareness at subagent open + * Stop — post-session context headroom tracking + * PostToolUse — mirror the Claude Code PostToolUse context monitor + * + * All three events are routed through gsd-context-monitor.js — the same hook + * used for PostToolUse in the Claude Code baseline — so context-headroom + * warnings surface at these key Codex session lifecycle moments. + * + * On Windows (#3426): writes a gsd-context-monitor.cmd shim alongside the .js + * file and uses the .cmd path as the hook command — exactly the same fix as + * SessionStart uses for gsd-check-update — to avoid the bash.exe POSIX-exec + * failure when Codex's hook dispatcher tries to run node.exe through Git Bash. + * + * @param {string} targetDir + * @param {string} eventName - One of 'SubagentStart', 'Stop', 'PostToolUse'. + * @param {{ absoluteRunner: string|null, platform?: NodeJS.Platform }} opts + * @returns {{ changed: boolean, wrote: boolean, path: string }} + */ +function ensureCodexHooksJsonEvent(targetDir, eventName, opts = {}) { + const platform = opts.platform || process.platform; + const absoluteRunner = opts.absoluteRunner || null; + const hooksJsonPath = path.join(targetDir, 'hooks.json'); + if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath }; + + // Normalize backslashes to forward slashes so that isManagedHookCommand can + // match the stored command against configDir on Windows. path.resolve on + // Windows returns backslash paths, but when platform is not 'win32' + // (e.g. platform: 'linux' in a test running on a Windows CI runner), + // projectManagedHookCommand does not normalize them — producing a mismatch + // between the stored command and the configDir-based hook-dir prefix used + // for deduplication. Forward-slash paths are always valid on Windows (Node.js + // and Codex both accept them), so normalizing here is safe for all platforms. + const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-context-monitor.js').replace(/\\/g, '/'); + + let managedCommand; + if (platform === 'win32') { + // #3426 fix pattern: on Windows, write a .cmd shim and use its path as the + // hook command. The same bash.exe POSIX-exec failure that affects + // gsd-check-update.js also affects gsd-context-monitor.js. + const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner); + if (!shimIR) return { changed: false, wrote: false, path: hooksJsonPath }; + try { + atomicWriteFileSync(shimIR.cmdPath, shimIR.render.cmd(), 'utf8'); + } catch (shimWriteErr) { + const reason = shimWriteErr && shimWriteErr.message ? shimWriteErr.message : String(shimWriteErr); + console.warn( + ` ${yellow}⚠${reset} Codex Windows hook NOT installed — .cmd shim write failed for ${eventName}: ${reason}. ` + + `Fix the write error (permissions? disk full?) and re-run the installer.`, + ); + return { changed: false, wrote: false, path: hooksJsonPath }; + } + managedCommand = shimIR.hookCommand; + } else { + managedCommand = projectManagedHookCommand({ + absoluteRunner, + scriptPath, + runtime: 'codex', + platform, + }); + } + + if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath }; + return reconcileCodexHooksJsonEvent(targetDir, eventName, { managedCommand, timeout: 10 }); +} + +/** + * Remove a GSD-managed event entry from hooks.json. Called during uninstall. + * + * @param {string} targetDir + * @param {string} eventName + */ +function removeCodexHooksJsonEvent(targetDir, eventName) { + return reconcileCodexHooksJsonEvent(targetDir, eventName, { managedCommand: null }); } function removeCodexHooksJsonSessionStart(targetDir) { @@ -7676,6 +7818,15 @@ function uninstall(isGlobal, runtime = 'claude') { removedCount++; 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']) { + const eventCleanup = removeCodexHooksJsonEvent(targetDir, eventName); + if (eventCleanup.changed) { + removedCount++; + console.log(` ${green}✓${reset} Removed managed Codex ${eventName} hook from hooks.json`); + } + } } // 1b. Non-layout Copilot side-effect: copilot-instructions.md cleanup @@ -10132,10 +10283,10 @@ function install(isGlobal, runtime = 'claude', options = {}) { } // Copy only the hook files that Codex actually registers via its hook configuration (#2153). - // Codex primarily needs gsd-check-update.js for the SessionStart update-check hook. + // #772: added gsd-context-monitor.js for the new SubagentStart/Stop/PostToolUse events. // We deliberately do *not* copy gsd-graphify-update.sh or hooks/lib/ for Codex // in this change (graphify auto-update support for Codex is out of scope for #3579). - const CODEX_HOOKS_TO_COPY = ['gsd-check-update.js']; + const CODEX_HOOKS_TO_COPY = ['gsd-check-update.js', 'gsd-context-monitor.js']; const codexHooksSrc = path.join(src, 'hooks', 'dist'); if (fs.existsSync(codexHooksSrc)) { const codexHooksDest = path.join(targetDir, 'hooks'); @@ -10265,6 +10416,39 @@ function install(isGlobal, runtime = 'claude', options = {}) { console.log(` ${green}✓${reset} Verified Codex hooks (SessionStart via hooks.json)`); } } + + // ── 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. + // + // 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']) { + const eventWrite = ensureCodexHooksJsonEvent(targetDir, codexEvent, { + absoluteRunner: codexNodeRunner, + platform: process.platform, + }); + if (eventWrite.wrote) { + console.log(` ${green}✓${reset} Configured Codex hooks (${codexEvent} via hooks.json)`); + } else if (eventWrite.changed) { + console.log(` ${green}✓${reset} Verified Codex hooks (${codexEvent} via hooks.json)`); + } + } + } else if (!codexNodeRunner) { + console.warn(` ${yellow}⚠${reset} Skipped Codex SubagentStart/Stop/PostToolUse hook registration — Node runner unavailable.`); + } + // ── end Codex extended hook events ──────────────────────────────────── } } catch (e) { // #2760 — schema-validation and write failures must be loud and fatal @@ -11899,6 +12083,9 @@ module.exports = { rewriteLegacyCodexHookBlock, buildCodexHookWindowsShimIR, ensureCodexHooksJsonSessionStart, + ensureCodexHooksJsonEvent, + removeCodexHooksJsonEvent, + reconcileCodexHooksJsonEvent, readGsdCommandNames, installRuntimeArtifacts, installOpencodeFamilySkills, diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index f14046ebb..f2f1e300e 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -161,6 +161,19 @@ Skills land in `~/.codex/skills/gsd-*/SKILL.md`. Agents are written with per-age **Minimum supported version:** Codex CLI 0.130.0. Earlier versions had additional skill-root scanning that can produce duplicate listings. +**Hook coverage** + +GSD registers the following Codex hook events automatically on install (requires Codex CLI 0.137.0+ for the stable hook-event schema): + +| Event | Hook | Purpose | +|---|---|---| +| `SessionStart` | `gsd-check-update.js` | Update check at session open; Windows installs also emit a `commandWindows` field pointing to the `.cmd` shim so Codex picks the correct executor on Windows without requiring per-OS config regeneration | +| `SubagentStart` | `gsd-context-monitor.js` | Inject context / GSD_AGENT_NAME awareness at subagent open | +| `Stop` | `gsd-context-monitor.js` | Context headroom tracking before model stop | +| `PostToolUse` | `gsd-context-monitor.js` | Mirror the context-monitor coverage available in Claude Code | + +All registered hooks are managed by GSD and are removed cleanly on `--uninstall`. + --- ### GitHub Copilot diff --git a/src/shell-command-projection.cts b/src/shell-command-projection.cts index ef2dad366..309872381 100644 --- a/src/shell-command-projection.cts +++ b/src/shell-command-projection.cts @@ -181,6 +181,10 @@ const MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE: Record> = { // reconcileCodexHooksJsonSessionStart can replace stale node-runner commands // with the .cmd shim on reinstall (and vice-versa on cross-platform moves). 'gsd-check-update.cmd', + // #772: context-monitor is now registered for Codex SubagentStart/Stop/PostToolUse. + 'gsd-context-monitor.js', + // #772: Windows .cmd shim for gsd-context-monitor — same #3426 pattern. + 'gsd-context-monitor.cmd', ]), }; diff --git a/tests/enh-772-codex-hook-events.test.cjs b/tests/enh-772-codex-hook-events.test.cjs new file mode 100644 index 000000000..528d1d6a1 --- /dev/null +++ b/tests/enh-772-codex-hook-events.test.cjs @@ -0,0 +1,411 @@ +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Enhancement #772: Adopt new stable Codex hook events + commandWindows for + * Windows parity. + * + * Codex CLI (rust-v0.137.0) stabilised the full hook-event set. This suite + * asserts that a Codex install: + * + * (a) Registers the 3 new high-value hook events in hooks.json: + * - SubagentStart — inject context / GSD_AGENT_NAME awareness at subagent open + * - Stop — post-session context headroom tracking + * - PostToolUse — mirror the Claude Code PostToolUse context monitor + * + * (b) Emits `commandWindows` in the SessionStart hooks.json entry so that + * Windows users get the .cmd shim path and non-Windows users get the POSIX + * node runner command. Both fields are present in the same entry; Codex picks + * the right one per its HookHandlerConfig schema + * (codex-rs/config/src/hook_config.rs: commandWindows / command_windows alias). + * + * Note: UserPromptSubmit is NOT wired (same rationale as Qwen #788 — the + * gsd-prompt-guard handler exits unless tool_name is Write|Edit, so it would be + * a silent no-op for the UserPromptSubmit payload shape). + * + * Test strategy: + * - Test new event registration via ensureCodexHooksJsonEvent() directly + * (mirrors the #3426 pattern of testing ensureCodexHooksJsonSessionStart + * directly with a stub hook file — avoids full install() migration dance). + * - Test commandWindows via ensureCodexHooksJsonSessionStart() directly. + * - IR-first discipline: assert on the structured result, not rendered text. + * + * Verified hook event schema: + * https://github.com/openai/codex/blob/main/codex-rs/protocol/src/protocol.rs + * https://github.com/openai/codex/blob/main/codex/codex-rs/config/src/hook_config.rs + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const INSTALL = require('../bin/install.js'); +const { + ensureCodexHooksJsonSessionStart, + ensureCodexHooksJsonEvent, + removeCodexHooksJsonEvent, + reconcileCodexHooksJsonEvent, +} = INSTALL; +const { createTempDir, cleanup } = require('./helpers.cjs'); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Extract all hook handler entries (full objects with type/command/etc.) for + * `eventName` from a hooks.json object (flat or nested-hooks shape). + */ +function hooksJsonHandlersForEvent(hooksJson, eventName) { + if (!hooksJson || typeof hooksJson !== 'object') return []; + const table = + hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks) + ? hooksJson.hooks + : hooksJson; + if (!Array.isArray(table[eventName])) return []; + return table[eventName].flatMap(entry => + Array.isArray(entry && entry.hooks) ? entry.hooks : [] + ); +} + +function readHooksJson(targetDir) { + const p = path.join(targetDir, 'hooks.json'); + if (!fs.existsSync(p)) return null; + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function stubHookFile(targetDir, hookName) { + const hooksDest = path.join(targetDir, 'hooks'); + fs.mkdirSync(hooksDest, { recursive: true }); + const dest = path.join(hooksDest, hookName); + if (!fs.existsSync(dest)) { + fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n'); + try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ } + } +} + +// ─── Suite 1: ensureCodexHooksJsonEvent export surface ─────────────────────── + +describe('enh-772: export surface — new functions are exported', () => { + test('ensureCodexHooksJsonEvent is a function', () => { + assert.strictEqual(typeof ensureCodexHooksJsonEvent, 'function', + 'ensureCodexHooksJsonEvent must be exported from bin/install.js'); + }); + + test('removeCodexHooksJsonEvent is a function', () => { + assert.strictEqual(typeof removeCodexHooksJsonEvent, 'function', + 'removeCodexHooksJsonEvent must be exported from bin/install.js'); + }); + + test('reconcileCodexHooksJsonEvent is a function', () => { + assert.strictEqual(typeof reconcileCodexHooksJsonEvent, 'function', + 'reconcileCodexHooksJsonEvent must be exported from bin/install.js'); + }); +}); + +// ─── Suite 2: ensureCodexHooksJsonEvent registers new events ───────────────── + +describe('enh-772: ensureCodexHooksJsonEvent registers SubagentStart, Stop, PostToolUse', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-772-events-'); + stubHookFile(tmpDir, 'gsd-context-monitor.js'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { + test(`${eventName}: ensureCodexHooksJsonEvent writes hooks.json`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + const result = ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + assert.ok(result && result.path, `result must have path for ${eventName}`); + assert.ok(result.wrote || result.changed, + `ensureCodexHooksJsonEvent must write or change hooks.json for ${eventName}`); + assert.ok(fs.existsSync(path.join(tmpDir, 'hooks.json')), + `hooks.json must exist after registering ${eventName}`); + }); + + test(`${eventName}: hooks.json contains the event entry`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + assert.ok(handlers.length > 0, + `Expected ${eventName} entry in hooks.json; got: ${JSON.stringify(hooksJson)}`); + }); + + test(`${eventName}: hook entry uses gsd-context-monitor`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + assert.ok( + handlers.some(h => h.command && h.command.includes('gsd-context-monitor')), + `${eventName} hook must use gsd-context-monitor; got: ${JSON.stringify(handlers)}` + ); + }); + + test(`${eventName}: hook entry has type: 'command'`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + const entry = handlers.find(h => h.command && h.command.includes('gsd-context-monitor')); + assert.strictEqual(entry && entry.type, 'command', + `${eventName} hook entry must have type 'command'`); + }); + + test(`${eventName}: hook entry has timeout: 10`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + const entry = handlers.find(h => h.command && h.command.includes('gsd-context-monitor')); + assert.strictEqual(entry && entry.timeout, 10, + `${eventName} hook entry must have timeout 10`); + }); + } + + test('null absoluteRunner returns unchanged result without writing', () => { + const result = ensureCodexHooksJsonEvent(tmpDir, 'SubagentStart', { + absoluteRunner: null, + platform: 'linux', + }); + assert.strictEqual(result.changed, false, + 'null runner must return changed: false'); + assert.ok(!fs.existsSync(path.join(tmpDir, 'hooks.json')), + 'hooks.json must NOT be written when runner is null'); + }); +}); + +// ─── Suite 3: commandWindows parity in SessionStart ────────────────────────── + +describe('enh-772: commandWindows parity — ensureCodexHooksJsonSessionStart emits commandWindows', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-772-cmdwin-'); + stubHookFile(tmpDir, 'gsd-check-update.js'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // commandWindows is ONLY emitted on win32 platform (where the .cmd shim is also + // written). On POSIX platforms, commandWindows is omitted to avoid pointing Windows + // Codex at a non-existent .cmd file (the shim is only present after a native Windows + // install that runs buildCodexHookWindowsShimIR and atomicWriteFileSync). + + test('POSIX platform: commandWindows is NOT emitted (shim not written on POSIX)', () => { + const fakeRunner = '"/usr/local/bin/node"'; + const result = ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + assert.ok(result && result.wrote, 'must write hooks.json on linux'); + + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); + assert.ok(handlers.length > 0, `Expected SessionStart handlers; got: ${JSON.stringify(hooksJson)}`); + + const entry = handlers[0]; + assert.ok( + entry.commandWindows === undefined, + `commandWindows must NOT be emitted on POSIX (shim not written); got: ${JSON.stringify(entry)}` + ); + }); + + test('POSIX platform: command references gsd-check-update.js (not .cmd)', () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); + const entry = handlers[0]; + assert.ok( + entry.command && entry.command.includes('gsd-check-update'), + `POSIX command must reference gsd-check-update; got: ${entry.command}` + ); + assert.ok( + !entry.command.endsWith('.cmd') && !entry.command.endsWith('.cmd"'), + `POSIX command must not end with .cmd; got: ${entry.command}` + ); + }); + + test('null absoluteRunner: no commandWindows emitted, no write', () => { + const result = ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: null, + platform: 'linux', + }); + assert.strictEqual(result.changed, false, 'null runner must return changed: false'); + const hooksJson = readHooksJson(tmpDir); + if (hooksJson) { + const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); + for (const h of handlers) { + assert.ok(!h.commandWindows, + `commandWindows must not be present when runner is null; got: ${JSON.stringify(h)}`); + } + } + }); + + test('Windows platform: SessionStart hook is written with commandWindows pointing to .cmd shim', () => { + // On win32, both `command` and `commandWindows` use the .cmd shim path + // (because managedCommand = shimIR.hookCommand = .cmd path, and + // commandWindows = same .cmd path). This ensures Codex picks the .cmd + // on Windows regardless of which field it reads. + const fakeRunner = '"C:/Program Files/nodejs/node.exe"'; + const result = ensureCodexHooksJsonSessionStart(tmpDir, { + absoluteRunner: fakeRunner, + platform: 'win32', + }); + // The shim write and hooks.json write should succeed in the tmp dir. + if (result.wrote) { + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); + assert.ok(handlers.length > 0, + `SessionStart must be registered on Windows path; got: ${JSON.stringify(hooksJson)}`); + const entry = handlers[0]; + assert.ok(typeof entry.commandWindows === 'string', + `commandWindows must be present on Windows path; got: ${JSON.stringify(entry)}`); + // commandWindows should reference the .cmd shim + assert.ok( + entry.commandWindows.includes('gsd-check-update') && entry.commandWindows.includes('.cmd'), + `commandWindows must reference gsd-check-update.cmd on win32; got: ${entry.commandWindows}` + ); + } + }); +}); + +// ─── Suite 4: idempotency ──────────────────────────────────────────────────── + +describe('enh-772: ensureCodexHooksJsonEvent is idempotent', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-772-idem-'); + stubHookFile(tmpDir, 'gsd-context-monitor.js'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { + test(`${eventName}: calling twice does not duplicate hook entries`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + const opts = { absoluteRunner: fakeRunner, platform: 'linux' }; + + ensureCodexHooksJsonEvent(tmpDir, eventName, opts); + ensureCodexHooksJsonEvent(tmpDir, eventName, opts); + + const hooksJson = readHooksJson(tmpDir); + const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + assert.strictEqual(handlers.length, 1, + `${eventName} should have exactly 1 hook handler after idempotent re-register; got ${handlers.length}: ${JSON.stringify(handlers)}`); + }); + } +}); + +// ─── Suite 5: removeCodexHooksJsonEvent ────────────────────────────────────── + +describe('enh-772: removeCodexHooksJsonEvent removes managed entries', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-772-remove-'); + stubHookFile(tmpDir, 'gsd-context-monitor.js'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { + test(`${eventName}: removeCodexHooksJsonEvent removes the managed entry`, () => { + const fakeRunner = '"/usr/local/bin/node"'; + ensureCodexHooksJsonEvent(tmpDir, eventName, { + absoluteRunner: fakeRunner, + platform: 'linux', + }); + + // Verify it was registered + let hooksJson = readHooksJson(tmpDir); + let handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + assert.ok(handlers.length > 0, `${eventName} must be registered before removal`); + + // Remove + const result = removeCodexHooksJsonEvent(tmpDir, eventName); + assert.ok(result.changed || result.wrote, + `removeCodexHooksJsonEvent must change hooks.json for ${eventName}`); + + hooksJson = readHooksJson(tmpDir); + if (hooksJson) { + handlers = hooksJsonHandlersForEvent(hooksJson, eventName); + assert.strictEqual(handlers.length, 0, + `After removal, ${eventName} should have 0 handlers; got: ${JSON.stringify(handlers)}`); + } + }); + } +}); + +// ─── Suite 6: reconcileCodexHooksJsonEvent preserves user entries ───────────── + +describe('enh-772: reconcileCodexHooksJsonEvent preserves user-owned entries', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-772-preserve-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('user-owned SubagentStart entry is preserved when GSD entry is registered', () => { + const hooksJsonPath = path.join(tmpDir, 'hooks.json'); + const userEntry = { + hooks: [{ type: 'command', command: 'my-custom-hook.sh' }] + }; + fs.writeFileSync(hooksJsonPath, JSON.stringify({ + SubagentStart: [userEntry] + }, null, 2) + '\n'); + + reconcileCodexHooksJsonEvent(tmpDir, 'SubagentStart', { + managedCommand: '"/usr/local/bin/node" "/home/me/.codex/hooks/gsd-context-monitor.js"', + }); + + const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + const table = hooksJson.hooks || hooksJson; + const entries = Array.isArray(table.SubagentStart) ? table.SubagentStart : []; + // Should have 2 entries: user entry + GSD entry + assert.ok(entries.length >= 2, + `User entry must be preserved; got entries: ${JSON.stringify(entries)}`); + // User entry must still be present + const userEntryStillPresent = entries.some(e => + Array.isArray(e.hooks) && e.hooks.some(h => h.command === 'my-custom-hook.sh') + ); + assert.ok(userEntryStillPresent, + `User entry must survive GSD registration; entries: ${JSON.stringify(entries)}`); + }); +});