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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -420,3 +420,7 @@ Every change to the app that lands on `main` is published as a GitHub release by
## License

[MIT](LICENSE)

### Codex hooks on Windows

Windows hook commands use an encoded PowerShell launch. After an update that changes hooks, restart or resume existing Codex workers and review the updated commands in /hooks. See [agent configuration](docs/agents.md).
2 changes: 2 additions & 0 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,5 @@ DeepSeek Harness metrics come from ACP `usage_update`: tokens in context and the
## 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.

On Windows, Office lifecycle hooks launch through an encoded PowerShell command so executable and project paths containing spaces or shell characters stay literal. After updating the office, restart or resume existing Codex workers so they receive the corrected hook commands; review changed commands in /hooks when Codex asks.
12 changes: 10 additions & 2 deletions src/server/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,18 @@ 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(' ');
const argv = [process.execPath, hookPath, event];
// A quoted executable is not invoked by PowerShell without &, and cmd.exe does not
// recognize POSIX single quotes. An encoded PowerShell command works from either
// Windows shell and keeps paths (including apostrophes and metacharacters) literal.
const script = "$ProgressPreference = 'SilentlyContinue'; $OutputEncoding = [Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false); "
+ "[Console]::In.ReadToEnd() | & " + argv.map((arg) => `'${arg.replaceAll("'", "''")}'`).join(' ');
const command = platform === 'win32'
? `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(script, 'utf16le').toString('base64')}`
: argv.map(shellQuote).join(' ');
const config = `hooks.${event}=[{hooks=[{type="command",command=${JSON.stringify(command)},timeout=3}]}]`;
args.push('-c', config);
}
Expand Down
45 changes: 44 additions & 1 deletion tests/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ test('rejects unknown, malformed, empty, oversized, and child-scoped events', ()
});

test('generates one stable CLI hook override per supported event', () => {
const args = codexHookArgs('/tmp/office data/agent-office-codex-hook.cjs');
const args = codexHookArgs('/tmp/office data/agent-office-codex-hook.cjs', 'linux');
assert.equal(args.length, CODEX_HOOK_EVENTS.length * 2);
for (let i = 0; i < CODEX_HOOK_EVENTS.length; i++) {
assert.equal(args[i * 2], '-c');
Expand Down Expand Up @@ -140,3 +140,46 @@ test('helper forwards only the bounded root event fields to the authenticated br
rmSync(dir, { recursive: true, force: true });
}
});

// Run the generated command, rather than spawning the helper directly: the shell
// launch is what failed on Windows even though the direct helper tests passed.
test('Windows hook command forwards UTF-8 stdin through the shell with literal paths', { skip: process.platform !== 'win32' }, async () => {
const dir = mkdtempSync(path.join(tmpdir(), "office ' & $ hooks-"));
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => {
received.push(JSON.parse(Buffer.concat(chunks).toString('utf8')));
res.writeHead(200).end();
});
});
const received: unknown[] = [];
try {
const file = writeCodexHook(dir);
const config = codexHookArgs(file)[3]; // UserPromptSubmit
const match = config.match(/command=("(?:[^"\\]|\\.)*"),timeout/);
assert.ok(match);
const command = JSON.parse(match[1]) as string;
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
assert.ok(address && typeof address === 'object');
for (const shell of ['cmd.exe', 'powershell.exe']) {
const args = shell === 'cmd.exe' ? ['/d', '/s', '/c', command] : ['-NoProfile', '-NonInteractive', '-Command', command];
const child = spawn(shell, args, {
env: { ...process.env, AGENT_OFFICE_HOOK_URL: `http://127.0.0.1:${address.port}`, AGENT_OFFICE_HOOK_TOKEN: 'test', AGENT_OFFICE_WORKER_ID: 'worker-1' },
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '', stderr = '';
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
const exited = new Promise<number | null>((resolve, reject) => { child.on('error', reject); child.on('close', resolve); });
child.stdin.end(JSON.stringify({ session_id: 'thread-1', prompt: 'Fix caf\u00e9 \u4fee\u590d \u2728' }));
assert.equal(await exited, 0, stderr);
assert.equal(stdout, '{}', stderr);
}
assert.deepEqual(received, Array(2).fill({ session_id: 'thread-1', hook_event_name: 'UserPromptSubmit', prompt: 'Fix caf\u00e9 \u4fee\u590d \u2728' }));
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
rmSync(dir, { recursive: true, force: true });
}
});