feat(#772): adopt stable Codex hook events + commandWindows for Windows parity (#827)

* feat(#772): adopt stable Codex hook events + commandWindows for Windows parity

Register three new stable Codex hook events (SubagentStart, Stop,
PostToolUse) wired to gsd-context-monitor.js so Codex installs get
the same context-headroom tracking at subagent and session boundaries
that Claude/Qwen already have.

Add commandWindows field to the SessionStart hook entry on Windows so
Codex uses the .cmd shim directly (Git Bash/MSYS cannot POSIX-exec
node.exe). commandWindows is only emitted on win32; POSIX is unchanged.

Refactor reconcileCodexHooksJsonSessionStart into a generic
reconcileCodexHooksJsonEvent so any event name can be reconciled with
the same dedup/preserve-user-entries logic.

Add gsd-context-monitor.js and .cmd to MANAGED_HOOK_COMMAND_BASENAMES
_BY_SURFACE so idempotent re-runs de-duplicate entries correctly.

30 new tests covering: export surface, event registration for each of
the three events, commandWindows parity (POSIX vs win32), idempotency,
uninstall, and user-entry preservation.

Closes #772

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#772): windows path normalization + docs-lint

- Normalize scriptPath backslashes to forward slashes in
  ensureCodexHooksJsonEvent and ensureCodexHooksJsonSessionStart so that
  isManagedHookCommand can match stored commands against configDir on
  Windows CI runners. path.resolve returns backslash paths on Windows,
  but when platform is not 'win32' (e.g. platform:'linux' in tests),
  projectManagedHookCommand skips normalization — producing a mismatch
  that breaks idempotency deduplication (the same hook entry appended
  twice on re-register). Forward-slash paths are always valid in both
  Node.js and Codex, so the normalization is safe for all platforms.
- Fix changeset pr: 0 → 827 to resolve fail_malformed_fragment.
- Add Codex hook coverage table to docs/how-to/install-on-your-runtime.md
  documenting the SubagentStart/Stop/PostToolUse events + commandWindows
  Windows-parity field added by this enhancement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-07 18:56:36 -04:00
committed by GitHub
parent 41f91b2e88
commit 67703d1586
5 changed files with 648 additions and 28 deletions

View File

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

View File

@@ -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) { "<EventName>": [...] }
* 2) { "hooks": { "<EventName>": [...] } }
*
* @param {string} targetDir - Codex config dir (e.g. ~/.codex or <project>/.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,

View File

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

View File

@@ -181,6 +181,10 @@ const MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE: Record<string, Set<string>> = {
// 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',
]),
};

View File

@@ -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)}`);
});
});