Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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.

## 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.
8 changes: 6 additions & 2 deletions src/server/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,14 @@ function shellQuote(value: string): string {
* 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(' ');
// 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);
}
Expand Down
7 changes: 6 additions & 1 deletion src/server/providers/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,12 @@ export const codex: ProviderAdapter<CodexState, CodexSetup> = {
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 },
Expand Down
68 changes: 68 additions & 0 deletions tests/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,74 @@ test('generates one stable CLI hook override per supported event', () => {
}
});

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').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 %TEMP% $env:TEMP ` & O'Neil ".replaceAll(':', '-')));
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<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
assert.ok(address && typeof address === 'object');
const args = codexHookArgs(file);
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;
// 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',
},
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, shells.flatMap(() => ['SessionStart', 'UserPromptSubmit', 'Stop']));
} finally {
await new Promise<void>((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"']);
Expand Down