From ef96fb71733f4a7750f3a227cf1c58bff9289d56 Mon Sep 17 00:00:00 2001 From: raizedz <200196788+raizedz@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:21:15 +0700 Subject: [PATCH 1/2] Fix Codex lifecycle hook quoting on Windows --- README.md | 2 ++ docs/agents.md | 6 +++++ src/server/codex.ts | 9 +++---- tests/codex.test.ts | 58 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fac9441a5..a968a3a3a 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ Press Enter to skip a step: the elevator in the office asks for your first proje Walk to an empty desk, press **E** and hire a worker. +For Codex workers, review the office's lifecycle commands in `/hooks` on first use. They use native shell quoting on Windows as well as macOS/Linux, including project paths with spaces. If an older Windows worker shows `Hook failed` and stays at **needs input** after finishing, update and restart the office, then resume the worker so it receives the corrected commands. See [Codex setup](docs/agents.md#codex-hook-troubleshooting). + Common options: ```bash diff --git a/docs/agents.md b/docs/agents.md index 02016b48b..7eb361e6f 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -72,6 +72,12 @@ OpenCode metrics come from assistant-message token/cost records exposed by its p DeepSeek Harness metrics come from ACP `usage_update`: tokens in context and the window's size, shown per worker and for the current desks, plus a session cost only when the harness reports one. The daily budget and historical ledger remain Claude-only. +## Codex hook troubleshooting + +The office quotes its Node executable, helper path and event for Codex's native command shell: `cmd /C` on Windows and a POSIX shell on macOS/Linux. Spaces, Unicode and apostrophes in the project path are supported. + +Older Windows installations generated POSIX single-quoted commands. Those can fail with `Hook failed` / exit code 1 even while the agent answers normally. Without a successful `SessionStart` and `Stop`, the office can keep showing **needs input** after a turn ends. Update and restart the office, then resume the worker to load the corrected command overrides; a CLI already running keeps its old overrides. Review changed hooks in `/hooks` if Codex asks. Native trust review is still required and is never bypassed. Restarting alone cannot recover a session id that the failed startup hook never reported; in that case, open a terminal and use Codex's resume picker to select the existing chat. + ## The office's prompts Everything the office tells a worker by itself can be rewritten in ⚙️ Settings → **🤖 Workers** → **📝 Edit the prompts…**: what **🤖 Hand to a worker**, **🔍 Review**, **Fix up & merge**, **Fix conflicts & merge** and **✍️ Ask a worker** send from the boards, what a meeting about an issue or a review panel starts with, the note the queue adds to a worktree task, the three board agents' briefs, every part the meeting room hands out, and the instructions for the model that writes the signs over workers' heads. Each one lists its `{{placeholders}}` (the issue number, the PR's branch, the file a meeting waits for…), which the office fills in when it sends it, and warns when one the office counts on is missing. **↺ Default** puts the office's own wording back. Admins edit them; they're the same on every floor and kept in `.agent-office/prompts.json` with the Default worker. diff --git a/src/server/codex.ts b/src/server/codex.ts index 788f4474b..7176ee653 100644 --- a/src/server/codex.ts +++ b/src/server/codex.ts @@ -82,18 +82,19 @@ export function validateCodexHook(event: string, payload: unknown): boolean { return !!normalizeCodexHook(event, payload); } -function shellQuote(value: string): string { - return `'${value.replaceAll("'", "'\"'\"'")}'`; +function shellQuote(value: string, platform: NodeJS.Platform): string { + // Codex runs hooks through cmd /C on Windows; POSIX single quotes are literal there. + return platform === 'win32' ? `"${value}"` : `'${value.replaceAll("'", "'\"'\"'")}'`; } /** * Build the CLI config overrides for all lifecycle hooks. The command is encoded as a TOML basic * string so paths containing spaces remain valid; the command itself is shell-quoted. */ -export function codexHookArgs(hookPath: string): string[] { +export function codexHookArgs(hookPath: string, platform: NodeJS.Platform = process.platform): string[] { const args: string[] = []; for (const event of CODEX_HOOK_EVENTS) { - const command = [process.execPath, hookPath, event].map(shellQuote).join(' '); + const command = [process.execPath, hookPath, event].map((value) => shellQuote(value, platform)).join(' '); const config = `hooks.${event}=[{hooks=[{type="command",command=${JSON.stringify(command)},timeout=3}]}]`; args.push('-c', config); } diff --git a/tests/codex.test.ts b/tests/codex.test.ts index 71d1863f0..b3074ad78 100644 --- a/tests/codex.test.ts +++ b/tests/codex.test.ts @@ -49,6 +49,64 @@ test('generates one stable CLI hook override per supported event', () => { } }); +test('quotes hook commands for cmd on Windows and retains POSIX apostrophe escaping', () => { + const file = "/office data/O'Neil/hook.cjs"; + const commandOf = (platform: NodeJS.Platform) => + JSON.parse(codexHookArgs(file, platform)[1].match(/command=("(?:[^"\\]|\\.)*")/)![1]) as string; + assert.ok(commandOf('win32').includes(`"${file}"`)); + assert.ok(commandOf('linux').includes(`'/office data/O'"'"'Neil/hook.cjs'`)); +}); + +test('generated hooks execute through the native shell with spaced Unicode and apostrophe paths', async () => { + const dir = mkdtempSync(path.join(tmpdir(), "office Việt & O'Neil ")); + const events: string[] = []; + const server = createServer((req, res) => { + req.resume(); + req.on('end', () => { + events.push(new URL(req.url!, 'http://localhost').searchParams.get('event')!); + res.writeHead(200).end(); + }); + }); + try { + const file = writeCodexHook(dir); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const args = codexHookArgs(file); + for (const event of ['SessionStart', 'UserPromptSubmit', 'Stop']) { + const config = args[CODEX_HOOK_EVENTS.indexOf(event as typeof CODEX_HOOK_EVENTS[number]) * 2 + 1]; + const command = JSON.parse(config.match(/command=("(?:[^"\\]|\\.)*")/)![1]) as string; + // Match Codex's cmd /C raw command wrapping, rather than spawning Node directly. + const windows = process.platform === 'win32'; + const child = spawn(windows ? process.env.ComSpec || 'cmd.exe' : '/bin/sh', + windows ? ['/d', '/s', '/c', `"${command}"`] : ['-c', command], { + windowsVerbatimArguments: windows, + env: { + ...process.env, + AGENT_OFFICE_HOOK_URL: `http://127.0.0.1:${address.port}`, + AGENT_OFFICE_HOOK_TOKEN: 'test-token', + AGENT_OFFICE_WORKER_ID: 'test-worker', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const result = await new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve, reject) => { + let stdout = '', stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('error', reject); + child.on('close', (code) => resolve({ code, stdout, stderr })); + child.stdin.end(JSON.stringify({ session_id: 'test-session', source: 'startup', prompt: 'test' })); + }); + assert.equal(result.code, 0, result.stderr); + assert.equal(result.stdout, '{}'); + } + assert.deepEqual(events, ['SessionStart', 'UserPromptSubmit', 'Stop']); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + rmSync(dir, { recursive: true, force: true }); + } +}); + test('a worker\'s own model and effort go on Codex\'s command line, in place of an office-wide model', () => { assert.deepEqual(codexModelArgs([]), []); assert.deepEqual(codexModelArgs(['--yolo'], 'gpt-5.5', 'high'), ['--yolo', '--model', 'gpt-5.5', '-c', 'model_reasoning_effort="high"']); From 58e61fefff08175375403207514727bd994b35fd Mon Sep 17 00:00:00 2001 From: raizedz <200196788+raizedz@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:30:34 +0700 Subject: [PATCH 2/2] Support PowerShell hook execution without shell path expansion --- docs/agents.md | 2 +- src/server/codex.ts | 11 +++++++---- src/server/providers/codex.ts | 7 ++++++- tests/codex.test.ts | 30 ++++++++++++++++++++---------- 4 files changed, 34 insertions(+), 16 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 7eb361e6f..7fe3059b7 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -74,7 +74,7 @@ DeepSeek Harness metrics come from ACP `usage_update`: tokens in context and the ## Codex hook troubleshooting -The office quotes its Node executable, helper path and event for Codex's native command shell: `cmd /C` on Windows and a POSIX shell on macOS/Linux. Spaces, Unicode and apostrophes in the project path are supported. +On Windows, the office prepends its Node install to the worker's PATH and passes the generated helper's absolute path in `AGENT_OFFICE_CODEX_HOOK`. Its fixed `node.exe -e` command loads that helper without interpolating paths into a shell. This works with both Codex's `cmd /C` runner and its PowerShell environment executor, including paths with spaces, Unicode and shell metacharacters. macOS/Linux keep POSIX quoting. The generated helper is `.agent-office/agent-office-codex-hook.cjs` in the project's data directory. Older Windows installations generated POSIX single-quoted commands. Those can fail with `Hook failed` / exit code 1 even while the agent answers normally. Without a successful `SessionStart` and `Stop`, the office can keep showing **needs input** after a turn ends. Update and restart the office, then resume the worker to load the corrected command overrides; a CLI already running keeps its old overrides. Review changed hooks in `/hooks` if Codex asks. Native trust review is still required and is never bypassed. Restarting alone cannot recover a session id that the failed startup hook never reported; in that case, open a terminal and use Codex's resume picker to select the existing chat. diff --git a/src/server/codex.ts b/src/server/codex.ts index 7176ee653..f349a6aaa 100644 --- a/src/server/codex.ts +++ b/src/server/codex.ts @@ -82,9 +82,8 @@ export function validateCodexHook(event: string, payload: unknown): boolean { return !!normalizeCodexHook(event, payload); } -function shellQuote(value: string, platform: NodeJS.Platform): string { - // Codex runs hooks through cmd /C on Windows; POSIX single quotes are literal there. - return platform === 'win32' ? `"${value}"` : `'${value.replaceAll("'", "'\"'\"'")}'`; +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\"'\"'")}'`; } /** @@ -94,7 +93,11 @@ function shellQuote(value: string, platform: NodeJS.Platform): string { export function codexHookArgs(hookPath: string, platform: NodeJS.Platform = process.platform): string[] { const args: string[] = []; for (const event of CODEX_HOOK_EVENTS) { - const command = [process.execPath, hookPath, event].map((value) => shellQuote(value, platform)).join(' '); + // Windows runners include cmd and PowerShell. Keep paths out of both shells' expansion rules; + // the provider supplies the helper path in env and prepends this Node install to PATH. + const command = platform === 'win32' + ? `node.exe -e "require(process.env.AGENT_OFFICE_CODEX_HOOK)" agent-office-codex-hook.cjs ${event}` + : [process.execPath, hookPath, event].map(shellQuote).join(' '); const config = `hooks.${event}=[{hooks=[{type="command",command=${JSON.stringify(command)},timeout=3}]}]`; args.push('-c', config); } diff --git a/src/server/providers/codex.ts b/src/server/providers/codex.ts index 24ca1c1ff..1512df8fb 100644 --- a/src/server/providers/codex.ts +++ b/src/server/providers/codex.ts @@ -98,7 +98,12 @@ export const codex: ProviderAdapter = { if (resumeSessionId) args.push('resume', resumeSessionId); if (prompt) args.push('--', prompt); tracker(h.state).clear(); - return { args, rotateToken: true }; + // Codex can execute Windows hooks through cmd or its PowerShell environment executor. + const pathKey = Object.keys(process.env).find((key) => key.toUpperCase() === 'PATH') ?? 'PATH'; + const env = process.platform === 'win32' + ? { [pathKey]: [path.dirname(process.execPath), process.env[pathKey]].filter(Boolean).join(path.delimiter), AGENT_OFFICE_CODEX_HOOK: setup.hook } + : undefined; + return { args, rotateToken: true, env }; }, bootHint: 'Open the terminal: complete login and review Office hooks in /hooks', hook: { strictJson: true, handle: codexHook }, diff --git a/tests/codex.test.ts b/tests/codex.test.ts index b3074ad78..7fc9876df 100644 --- a/tests/codex.test.ts +++ b/tests/codex.test.ts @@ -49,16 +49,17 @@ test('generates one stable CLI hook override per supported event', () => { } }); -test('quotes hook commands for cmd on Windows and retains POSIX apostrophe escaping', () => { +test('keeps paths out of Windows shell expansion and retains POSIX apostrophe escaping', () => { const file = "/office data/O'Neil/hook.cjs"; const commandOf = (platform: NodeJS.Platform) => JSON.parse(codexHookArgs(file, platform)[1].match(/command=("(?:[^"\\]|\\.)*")/)![1]) as string; - assert.ok(commandOf('win32').includes(`"${file}"`)); + assert.ok(commandOf('win32').startsWith('node.exe -e "require(process.env.AGENT_OFFICE_CODEX_HOOK)"')); + assert.equal(commandOf('win32').includes(file), false); assert.ok(commandOf('linux').includes(`'/office data/O'"'"'Neil/hook.cjs'`)); }); test('generated hooks execute through the native shell with spaced Unicode and apostrophe paths', async () => { - const dir = mkdtempSync(path.join(tmpdir(), "office Việt & O'Neil ")); + const dir = mkdtempSync(path.join(tmpdir(), "office Việt %TEMP% $env:TEMP ` & O'Neil ".replaceAll(':', '-'))); const events: string[] = []; const server = createServer((req, res) => { req.resume(); @@ -73,16 +74,25 @@ test('generated hooks execute through the native shell with spaced Unicode and a const address = server.address(); assert.ok(address && typeof address === 'object'); const args = codexHookArgs(file); - for (const event of ['SessionStart', 'UserPromptSubmit', 'Stop']) { + const windows = process.platform === 'win32'; + const launched = codex.launch({ h: { info: {}, state: codex.createState!() } as never, args: [], setup: { hook: file } }); + if (windows) { + assert.equal(launched.env?.AGENT_OFFICE_CODEX_HOOK, file); + const pathValue = Object.entries(launched.env!).find(([key]) => key.toUpperCase() === 'PATH')![1]; + assert.equal(pathValue?.split(path.delimiter)[0], path.dirname(process.execPath)); + } + const shells = windows ? ['cmd', 'powershell'] : ['sh']; + for (const shell of shells) for (const event of ['SessionStart', 'UserPromptSubmit', 'Stop']) { const config = args[CODEX_HOOK_EVENTS.indexOf(event as typeof CODEX_HOOK_EVENTS[number]) * 2 + 1]; const command = JSON.parse(config.match(/command=("(?:[^"\\]|\\.)*")/)![1]) as string; - // Match Codex's cmd /C raw command wrapping, rather than spawning Node directly. - const windows = process.platform === 'win32'; - const child = spawn(windows ? process.env.ComSpec || 'cmd.exe' : '/bin/sh', - windows ? ['/d', '/s', '/c', `"${command}"`] : ['-c', command], { - windowsVerbatimArguments: windows, + // Exercise both Codex Windows runners, rather than spawning the helper directly. + const program = shell === 'cmd' ? process.env.ComSpec || 'cmd.exe' : shell === 'powershell' ? 'powershell.exe' : '/bin/sh'; + const shellArgs = shell === 'cmd' ? ['/C', `"${command}"`] : shell === 'powershell' ? ['-NoProfile', '-NonInteractive', '-Command', command] : ['-c', command]; + const child = spawn(program, shellArgs, { + windowsVerbatimArguments: shell === 'cmd', env: { ...process.env, + ...launched.env, AGENT_OFFICE_HOOK_URL: `http://127.0.0.1:${address.port}`, AGENT_OFFICE_HOOK_TOKEN: 'test-token', AGENT_OFFICE_WORKER_ID: 'test-worker', @@ -100,7 +110,7 @@ test('generated hooks execute through the native shell with spaced Unicode and a assert.equal(result.code, 0, result.stderr); assert.equal(result.stdout, '{}'); } - assert.deepEqual(events, ['SessionStart', 'UserPromptSubmit', 'Stop']); + assert.deepEqual(events, shells.flatMap(() => ['SessionStart', 'UserPromptSubmit', 'Stop'])); } finally { await new Promise((resolve) => server.close(() => resolve())); rmSync(dir, { recursive: true, force: true });