@zgeoff/atc 0.1.10 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,10 +27,11 @@ atc
27
27
  ```
28
28
 
29
29
  Needs [Bun](https://bun.sh) (atc runs from source through it) and the `claude` CLI on your PATH.
30
- Grok sessions also need the `grok` CLI. From a checkout, `bun src/cli.ts` runs the same thing. atc
31
- is built to pair with [zoxide](https://github.com/ajeetdsouza/zoxide): the spawn directory picker
32
- feeds on its frecency list, so with zoxide installed every directory you visit is two keystrokes
33
- from a session. Without it the picker falls back to atc's own spawn history.
30
+ Grok sessions also need the `grok` CLI, and Codex sessions the `codex` CLI. From a checkout,
31
+ `bun src/cli.ts` runs the same thing. atc is built to pair with
32
+ [zoxide](https://github.com/ajeetdsouza/zoxide): the spawn directory picker feeds on its frecency
33
+ list, so with zoxide installed every directory you visit is two keystrokes from a session. Without
34
+ it the picker falls back to atc's own spawn history.
34
35
 
35
36
  The first invocation auto-spawns the daemon (`atc daemon` runs it in the foreground for systemd or
36
37
  debugging); the TUI is a thin client, so quitting or crashing it leaves every session running. Runs
@@ -42,7 +43,7 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
42
43
  | --------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
43
44
  | leader | anywhere | toggle session overlay — `Ctrl-Space` by default, configurable (see Config) |
44
45
  | `n` | home/overlay | spawn: pick agent → dir (zoxide + history, fuzzy) → name → optional first prompt. Fresh clients default to Claude; last-used is the last deliberate-spawn SessionStart. |
45
- | `r` | home/overlay | adopt: pick agent → dir → name. Claude opens `claude --resume`. Grok opens plain `grok`. |
46
+ | `r` | home/overlay | adopt: pick agent → dir → name. Claude opens `claude --resume`, Codex `codex resume`. Grok opens plain `grok`. |
46
47
  | `R` | home | restore last fleet after a daemon death — each session with its matching CLI |
47
48
  | `j`/`k`/`↑`/`↓` | overlay/picker | move |
48
49
  | `Enter` | overlay | attach (auto-acks) |
@@ -53,7 +54,7 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
53
54
  | `g` | overlay | toggle the grouped view: sessions cluster under repository headers |
54
55
  | `H` | overlay | eject to headless (Claude only). Hidden and ignored on a Grok row. |
55
56
  | `P` | overlay | revive: a fresh terminal resumes a headless or killed session in place |
56
- | `y` | overlay | yank the resume command (`claude --resume <id>` or `grok --resume <id>`) |
57
+ | `y` | overlay | yank the resume command (`claude --resume <id>`, `grok --resume <id>`, or `codex resume <id>`) |
57
58
  | `Y` | overlay | eject: yank the resume command, then kill the session here |
58
59
  | `K` | overlay | kill selected (confirm with `y`) — the entry stays revivable with `P`; a second `K` forgets it |
59
60
  | `?` | overlay | full key reference — the hint row only shows actions valid for the selected session |
@@ -91,6 +92,17 @@ mkdir -p ~/.grok/hooks
91
92
  atc grok-hooks > ~/.grok/hooks/atc-reporter.json
92
93
  ```
93
94
 
95
+ Codex attention comes from hook entries in `$CODEX_HOME/hooks.json` (`~/.codex` when `CODEX_HOME` is
96
+ unset). atc never writes that path either — `atc codex-hooks` prints the entries to merge in:
97
+
98
+ ```sh
99
+ atc codex-hooks
100
+ ```
101
+
102
+ Codex parses new hooks but never runs them until you trust them once: open `codex`, review the atc
103
+ hooks in its hooks list, and approve them. Sessions you start outside atc report events too; the
104
+ reporter exits immediately when no atc session id is present.
105
+
94
106
  `atc grok-hooks` prints this file, with the `hook-report` command resolved for this install:
95
107
 
96
108
  ```json
@@ -133,6 +145,8 @@ session.
133
145
  "claudeArgs": [],
134
146
  "grokBin": "grok",
135
147
  "grokArgs": [],
148
+ "codexBin": "codex",
149
+ "codexArgs": [],
136
150
  "leader": "ctrl-space"
137
151
  }
138
152
  ```
@@ -143,6 +157,8 @@ session.
143
157
  | `claudeArgs` | `[]` | Prepended to every Claude spawn, e.g. `["--model", "opus"]`. |
144
158
  | `grokBin` | `"grok"` | The binary spawned for Grok sessions. |
145
159
  | `grokArgs` | `[]` | Prepended to every Grok spawn. A user `--leader` in this list is dropped; atc always appends `--no-leader`. |
160
+ | `codexBin` | `"codex"` | The binary spawned for Codex sessions. |
161
+ | `codexArgs` | `[]` | Prepended to every Codex spawn. |
146
162
  | `leader` | `"ctrl-space"` | The overlay toggle: `ctrl-` plus a letter or one of `\` `]` `^` `_`, e.g. `"ctrl-]"`. |
147
163
 
148
164
  Pick a different leader when `Ctrl-Space` is taken on your machine — Raycast on macOS claims it, and
@@ -150,8 +166,8 @@ Pick a different leader when `Ctrl-Space` is taken on your machine — Raycast o
150
166
  unknown or reserved value falls back to the default.
151
167
 
152
168
  `atc mcp` exposes the fleet as MCP tools (list, spawn, drive, organise) to any MCP client, wrangled
153
- sessions included. `atc_session_spawn` takes an optional `agent` (`claude` or `grok`) and defaults
154
- to Claude; it never reads the TUI last-used value.
169
+ sessions included. `atc_session_spawn` takes an optional `agent` (`claude`, `grok`, or `codex`) and
170
+ defaults to Claude; it never reads the TUI last-used value.
155
171
 
156
172
  ```sh
157
173
  claude mcp add --scope user atc -- atc mcp
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Terminal control tower for Claude Code sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -1,13 +1,13 @@
1
1
  import type { HookEvent } from './hooks';
2
2
 
3
- export type AgentKind = 'claude' | 'grok';
3
+ export type AgentKind = 'claude' | 'grok' | 'codex';
4
4
 
5
5
  /**
6
6
  * Missing, empty, and unknown values become Claude so a fleet written
7
7
  * before the agent column still restores as Claude.
8
8
  */
9
9
  export function toAgentKind(raw: unknown): AgentKind {
10
- return raw === 'grok' ? 'grok' : 'claude';
10
+ return raw === 'grok' || raw === 'codex' ? raw : 'claude';
11
11
  }
12
12
 
13
13
  export interface SpawnOptions {
package/src/cli.ts CHANGED
@@ -46,6 +46,7 @@ const main = defineCommand({
46
46
  const config = await import('./config');
47
47
  const claude = await import('./claude-adapter');
48
48
  const grok = await import('./grok-adapter');
49
+ const codex = await import('./codex-adapter');
49
50
  const headless = await import('./start-headless-run');
50
51
 
51
52
  // Test harnesses shrink the outbound queue to force overflow
@@ -66,13 +67,14 @@ const main = defineCommand({
66
67
  );
67
68
 
68
69
  const grokAdapter = new grok.GrokAdapter(cfg);
70
+ const codexAdapter = new codex.CodexAdapter(cfg);
69
71
 
70
72
  const handle = daemon.startDaemon({
71
73
  socketPath: config.daemonSocketPath,
72
74
  reporterSocketPath: config.socketPath,
73
75
  build: getBuild(),
74
76
  adapter: claudeAdapter,
75
- adapters: { claude: claudeAdapter, grok: grokAdapter },
77
+ adapters: { claude: claudeAdapter, grok: grokAdapter, codex: codexAdapter },
76
78
  dbPath: config.dbFile,
77
79
  legacyFleetPath: config.legacyFleetFile,
78
80
  pidPath: config.daemonPidFile,
@@ -87,6 +89,19 @@ const main = defineCommand({
87
89
  });
88
90
  },
89
91
  }),
92
+ 'codex-hooks': () =>
93
+ defineCommand({
94
+ meta: {
95
+ name: 'codex-hooks',
96
+ description:
97
+ 'Print the Codex hook entries to merge into $CODEX_HOME/hooks.json (trust them once in the Codex TUI)',
98
+ },
99
+ async run() {
100
+ const hook = await import('./print-codex-hook-file');
101
+
102
+ hook.printCodexHookFile();
103
+ },
104
+ }),
90
105
  'grok-hooks': () =>
91
106
  defineCommand({
92
107
  meta: {
@@ -0,0 +1,178 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import type {
5
+ AdapterEvent,
6
+ AgentAdapter,
7
+ NameUpdate,
8
+ ResumeCheck,
9
+ SpawnOptions,
10
+ SpawnPlan,
11
+ } from './agent-adapter';
12
+ import type { Config } from './config';
13
+ import type { HookEvent } from './hooks';
14
+ import { isRecord } from './report';
15
+
16
+ /**
17
+ * The Codex CLI adapter: spawn arguments, hook payload mapping, resume
18
+ * semantics, and session_index.jsonl name-pulling. Hooks are a user-installed
19
+ * entry in the Codex hook config (`atc codex-hooks` prints it) that the user
20
+ * trusts once in the Codex TUI; atc never writes into the user's Codex config.
21
+ */
22
+ export class CodexAdapter implements AgentAdapter {
23
+ readonly kind = 'codex';
24
+
25
+ // Codex has no headless handoff.
26
+ readonly headlessRunner = null;
27
+
28
+ // Codex's hooks are authoritative; no screen heuristics needed.
29
+ readonly screenDetector = null;
30
+
31
+ private readonly config: Config;
32
+
33
+ constructor(config: Config) {
34
+ this.config = config;
35
+ }
36
+
37
+ planSpawn(opts: SpawnOptions): SpawnPlan {
38
+ return {
39
+ bin: this.config.codexBin,
40
+ args: [
41
+ ...this.config.codexArgs,
42
+
43
+ // Bare resume opens Codex's own session picker; an id resumes that
44
+ // session directly. Both accept a prompt afterwards.
45
+ ...(opts.resume === true ? ['resume'] : []),
46
+ ...(typeof opts.resume === 'string' ? ['resume', opts.resume] : []),
47
+ ...(opts.prompt === '' ? [] : [opts.prompt]),
48
+ ],
49
+ };
50
+ }
51
+
52
+ normalizeHook(e: HookEvent): AdapterEvent {
53
+ const sessionID = e.payload['session_id'];
54
+ const transcript = e.payload['transcript_path'];
55
+
56
+ const base: AdapterEvent = {
57
+ kind: 'heartbeat',
58
+ ...(typeof sessionID === 'string' ? { agentSessionID: sessionID } : {}),
59
+ };
60
+
61
+ const named: AdapterEvent = {
62
+ ...base,
63
+ ...(typeof sessionID === 'string' ? { nameSource: sessionID } : {}),
64
+ ...(typeof transcript === 'string' ? { transcriptSource: transcript } : {}),
65
+ };
66
+
67
+ switch (e.event) {
68
+ case 'SessionStart': {
69
+ return { ...named, kind: 'started' };
70
+ }
71
+ case 'PermissionRequest': {
72
+ const toolName = e.payload['tool_name'];
73
+
74
+ const message =
75
+ typeof toolName === 'string' && toolName !== ''
76
+ ? `waiting for approval: ${toolName}`
77
+ : 'waiting for approval';
78
+
79
+ return { ...base, kind: 'needs-input', message, detail: message };
80
+ }
81
+ case 'UserPromptSubmit': {
82
+ const prompt = e.payload['prompt'];
83
+ const preview = typeof prompt === 'string' ? prompt.slice(0, 80) : '';
84
+
85
+ return {
86
+ ...named,
87
+ kind: 'prompt-submitted',
88
+ ...(preview === '' ? {} : { message: preview, detail: truncateDetail(preview) }),
89
+ };
90
+ }
91
+ case 'Stop': {
92
+ const lastMessage = e.payload['last_assistant_message'];
93
+
94
+ return {
95
+ ...named,
96
+ kind: 'turn-done',
97
+ ...(typeof lastMessage === 'string' && lastMessage !== ''
98
+ ? { detail: truncateDetail(lastMessage) }
99
+ : {}),
100
+ };
101
+ }
102
+ case 'SessionEnd': {
103
+ return { ...base, kind: 'ended' };
104
+ }
105
+ default: {
106
+ return base;
107
+ }
108
+ }
109
+ }
110
+
111
+ // Codex records session titles in session_index.jsonl; the index does not
112
+ // say whether a title was set by /rename or auto-generated, so a title
113
+ // never overrides a user-typed name.
114
+ loadName(source: string, namedBy: 'user' | 'auto' | 'agent'): Promise<NameUpdate | null> {
115
+ if (namedBy === 'user') {
116
+ return Promise.resolve(null);
117
+ }
118
+
119
+ const index = join(resolveCodexHome(), 'session_index.jsonl');
120
+ let text: string;
121
+
122
+ try {
123
+ text = readFileSync(index, 'utf8');
124
+ } catch {
125
+ return Promise.resolve(null);
126
+ }
127
+
128
+ let name: string | undefined;
129
+
130
+ for (const line of text.split('\n')) {
131
+ if (line.trim() === '' || !line.includes(source)) {
132
+ continue;
133
+ }
134
+
135
+ try {
136
+ const parsed: unknown = JSON.parse(line);
137
+
138
+ if (
139
+ isRecord(parsed) &&
140
+ parsed['id'] === source &&
141
+ typeof parsed['thread_name'] === 'string'
142
+ ) {
143
+ name = parsed['thread_name'];
144
+ }
145
+ } catch {}
146
+ }
147
+
148
+ const update = name === undefined || name === '' ? null : { name };
149
+
150
+ return Promise.resolve(update);
151
+ }
152
+
153
+ canResume(session: ResumeCheck): boolean {
154
+ if (session.transcriptSource === undefined) {
155
+ return true;
156
+ }
157
+
158
+ return existsSync(session.transcriptSource);
159
+ }
160
+
161
+ // Shell command that re-opens this session outside atc (or anywhere).
162
+ buildResumeCommand(cwd: string, agentSessionID: string | undefined): string | null {
163
+ const resume = agentSessionID === undefined ? 'codex resume' : `codex resume ${agentSessionID}`;
164
+ const quoted = cwd.replaceAll("'", String.raw`'\''`);
165
+
166
+ return `cd '${quoted}' && ${resume}`;
167
+ }
168
+ }
169
+
170
+ function resolveCodexHome(): string {
171
+ const home = process.env['CODEX_HOME'];
172
+
173
+ return home !== undefined && home !== '' ? home : join(homedir(), '.codex');
174
+ }
175
+
176
+ function truncateDetail(text: string): string {
177
+ return text.length <= 600 ? text : `${text.slice(0, 599)}…`;
178
+ }
@@ -42,5 +42,10 @@ const GROK_PARENT_KEYS = new Set([
42
42
  ]);
43
43
 
44
44
  function isParentSessionKey(key: string): boolean {
45
- return key === 'CLAUDECODE' || key.startsWith('CLAUDE_CODE_') || GROK_PARENT_KEYS.has(key);
45
+ return (
46
+ key === 'CLAUDECODE' ||
47
+ key.startsWith('CLAUDE_CODE_') ||
48
+ key.startsWith('CODEX_SANDBOX') ||
49
+ GROK_PARENT_KEYS.has(key)
50
+ );
46
51
  }
package/src/config.ts CHANGED
@@ -8,6 +8,8 @@ export interface Config {
8
8
  claudeArgs: string[];
9
9
  grokBin: string;
10
10
  grokArgs: string[];
11
+ codexBin: string;
12
+ codexArgs: string[];
11
13
  leader: LeaderKey;
12
14
  }
13
15
 
@@ -21,6 +23,8 @@ const DEFAULTS: Config = {
21
23
  claudeArgs: [],
22
24
  grokBin: 'grok',
23
25
  grokArgs: [],
26
+ codexBin: 'codex',
27
+ codexArgs: [],
24
28
  leader: { code: 0, label: '^Space' },
25
29
  };
26
30
 
@@ -67,11 +71,18 @@ export function loadConfig(): Config {
67
71
  ? parsed['grokArgs'].filter((a): a is string => typeof a === 'string')
68
72
  : DEFAULTS.grokArgs;
69
73
 
74
+ const codexBin =
75
+ typeof parsed['codexBin'] === 'string' ? parsed['codexBin'] : DEFAULTS.codexBin;
76
+
77
+ const codexArgs = Array.isArray(parsed['codexArgs'])
78
+ ? parsed['codexArgs'].filter((a): a is string => typeof a === 'string')
79
+ : DEFAULTS.codexArgs;
80
+
70
81
  const leader =
71
82
  (typeof parsed['leader'] === 'string' ? decodeLeader(parsed['leader']) : null) ??
72
83
  DEFAULTS.leader;
73
84
 
74
- return { claudeBin, claudeArgs, grokBin, grokArgs, leader };
85
+ return { claudeBin, claudeArgs, grokBin, grokArgs, codexBin, codexArgs, leader };
75
86
  } catch {
76
87
  return { ...DEFAULTS };
77
88
  }
@@ -375,13 +375,18 @@ export class DaemonConnection {
375
375
 
376
376
  const rawAgent = req.p?.['agent'];
377
377
 
378
- if (rawAgent !== undefined && rawAgent !== 'claude' && rawAgent !== 'grok') {
379
- this.sendErr(req.id, 'bad_args', "session.spawn agent must be 'claude' or 'grok'");
378
+ if (
379
+ rawAgent !== undefined &&
380
+ rawAgent !== 'claude' &&
381
+ rawAgent !== 'grok' &&
382
+ rawAgent !== 'codex'
383
+ ) {
384
+ this.sendErr(req.id, 'bad_args', "session.spawn agent must be 'claude', 'grok', or 'codex'");
380
385
 
381
386
  return;
382
387
  }
383
388
 
384
- const agent: AgentKind = rawAgent === 'grok' ? 'grok' : 'claude';
389
+ const agent: AgentKind = rawAgent === 'grok' || rawAgent === 'codex' ? rawAgent : 'claude';
385
390
 
386
391
  if (this.ctx.findAdapter(agent) === null) {
387
392
  this.sendErr(req.id, 'unsupported', `no adapter for agent '${agent}'`);
@@ -5,5 +5,13 @@ import type { AgentKind } from './agent-adapter';
5
5
  * Claude, so session names stay aligned across a mixed fleet.
6
6
  */
7
7
  export function formatOverlayAgentMark(agent: AgentKind): string {
8
- return agent === 'grok' ? 'g' : ' ';
8
+ if (agent === 'grok') {
9
+ return 'g';
10
+ }
11
+
12
+ if (agent === 'codex') {
13
+ return 'x';
14
+ }
15
+
16
+ return ' ';
9
17
  }
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ import { countSessionStates, sortGroupedSessionViews, sortSessionViews } from '.
10
10
  import type { SessionState } from './sessions';
11
11
  import { ansi, cols, drawHelp, drawHome, drawOverlay, drawPicker, drawStatusBar, rows } from './ui';
12
12
 
13
- type AgentKind = 'claude' | 'grok';
13
+ type AgentKind = 'claude' | 'grok' | 'codex';
14
14
 
15
15
  type Mode =
16
16
  | 'home'
@@ -26,6 +26,7 @@ type Mode =
26
26
  const AGENT_PICKS = [
27
27
  { agent: 'claude' as const, label: 'Claude' },
28
28
  { agent: 'grok' as const, label: 'Grok' },
29
+ { agent: 'codex' as const, label: 'Codex' },
29
30
  ];
30
31
 
31
32
  interface MirrorSession {
@@ -320,7 +321,12 @@ function openAgentPicker(resume = false) {
320
321
  spawnResume = resume;
321
322
  spawnAgent = lastUsedAgent;
322
323
  pickerInput = '';
323
- pickerSelected = lastUsedAgent === 'grok' ? 1 : 0;
324
+
325
+ pickerSelected = Math.max(
326
+ 0,
327
+ AGENT_PICKS.findIndex((p) => p.agent === lastUsedAgent),
328
+ );
329
+
324
330
  mode = 'picker-agent';
325
331
 
326
332
  stdout.write(ansi.clear);
@@ -442,7 +448,7 @@ function toMirrorSession(value: unknown): MirrorSession | null {
442
448
  kind: value['kind'] === 'headless' ? 'headless' : 'pty',
443
449
  alive: value['alive'],
444
450
  resumable: typeof value['agentSessionID'] === 'string',
445
- agent: value['agent'] === 'grok' ? 'grok' : 'claude',
451
+ agent: value['agent'] === 'grok' || value['agent'] === 'codex' ? value['agent'] : 'claude',
446
452
  };
447
453
  }
448
454
 
@@ -541,7 +547,7 @@ function applyDaemonEvent(e: EventMsg) {
541
547
  s.lastMsg = e['lastMsg'];
542
548
  }
543
549
 
544
- if (e['agent'] === 'grok' || e['agent'] === 'claude') {
550
+ if (e['agent'] === 'grok' || e['agent'] === 'codex' || e['agent'] === 'claude') {
545
551
  s.agent = e['agent'];
546
552
  }
547
553
 
@@ -814,7 +820,13 @@ function applyOverlayKey(buf: Buffer) {
814
820
  return;
815
821
  }
816
822
 
817
- if (ch === 'H' && sel !== undefined && sel.kind === 'pty' && sel.alive && sel.agent !== 'grok') {
823
+ if (
824
+ ch === 'H' &&
825
+ sel !== undefined &&
826
+ sel.kind === 'pty' &&
827
+ sel.alive &&
828
+ sel.agent === 'claude'
829
+ ) {
818
830
  ejectTarget = sel.id;
819
831
  pickerInput = '';
820
832
  mode = 'picker-eject';
@@ -1149,7 +1161,12 @@ process.stdin.on('data', (buf: Buffer) => {
1149
1161
  },
1150
1162
  () => {
1151
1163
  pickerInput = '';
1152
- pickerSelected = spawnAgent === 'grok' ? 1 : 0;
1164
+
1165
+ pickerSelected = Math.max(
1166
+ 0,
1167
+ AGENT_PICKS.findIndex((p) => p.agent === spawnAgent),
1168
+ );
1169
+
1153
1170
  mode = 'picker-agent';
1154
1171
 
1155
1172
  stdout.write(ansi.clear);
package/src/mcp-server.ts CHANGED
@@ -43,7 +43,7 @@ const TOOLS: readonly MCPTool[] = [
43
43
  {
44
44
  name: 'atc_session_spawn',
45
45
  description:
46
- 'Spawn a new session in a directory. Optional agent is claude or grok; omitted agent is always Claude, never the TUI last-used value. Returns the new session descriptor. Give it a prompt to start it working immediately.',
46
+ 'Spawn a new session in a directory. Optional agent is claude, grok, or codex; omitted agent is always Claude, never the TUI last-used value. Returns the new session descriptor. Give it a prompt to start it working immediately.',
47
47
  inputSchema: {
48
48
  type: 'object',
49
49
  properties: {
@@ -52,7 +52,7 @@ const TOOLS: readonly MCPTool[] = [
52
52
  prompt: { type: 'string', description: 'First message for the session' },
53
53
  agent: {
54
54
  type: 'string',
55
- enum: ['claude', 'grok'],
55
+ enum: ['claude', 'grok', 'codex'],
56
56
  description: 'Which agent CLI to spawn; defaults to claude',
57
57
  },
58
58
  },
@@ -243,15 +243,22 @@ async function runTool(
243
243
  case 'atc_session_spawn': {
244
244
  const rawAgent = args['agent'];
245
245
 
246
- if (rawAgent !== undefined && rawAgent !== 'claude' && rawAgent !== 'grok') {
247
- throw new Error("agent must be 'claude' or 'grok'");
246
+ if (
247
+ rawAgent !== undefined &&
248
+ rawAgent !== 'claude' &&
249
+ rawAgent !== 'grok' &&
250
+ rawAgent !== 'codex'
251
+ ) {
252
+ throw new Error("agent must be 'claude', 'grok', or 'codex'");
248
253
  }
249
254
 
250
255
  const ok = await client.sendRequest('session.spawn', {
251
256
  cwd: args['cwd'],
252
257
  ...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
253
258
  ...(typeof args['prompt'] === 'string' ? { prompt: args['prompt'] } : {}),
254
- ...(rawAgent === 'claude' || rawAgent === 'grok' ? { agent: rawAgent } : {}),
259
+ ...(rawAgent === 'claude' || rawAgent === 'grok' || rawAgent === 'codex'
260
+ ? { agent: rawAgent }
261
+ : {}),
255
262
  cols: 100,
256
263
  rows: 30,
257
264
  });
@@ -0,0 +1,26 @@
1
+ import { buildCLICommand } from './build-cli-command';
2
+
3
+ const CODEX_HOOK_EVENTS = [
4
+ 'SessionStart',
5
+ 'UserPromptSubmit',
6
+ 'PermissionRequest',
7
+ 'Stop',
8
+ ] as const;
9
+
10
+ /**
11
+ * Print the Codex hook entries to stdout. The operator merges them into
12
+ * `$CODEX_HOME/hooks.json` and trusts them once in the Codex TUI — Codex
13
+ * parses untrusted hooks but never runs them. atc never writes that path.
14
+ */
15
+ export function printCodexHookFile(): void {
16
+ process.stdout.write(buildCodexHookFile());
17
+ }
18
+
19
+ function buildCodexHookFile(): string {
20
+ const cmd = buildCLICommand('hook-report');
21
+ const buildEntry = (timeout: number) => [{ hooks: [{ type: 'command', command: cmd, timeout }] }];
22
+ const hooks = Object.fromEntries(CODEX_HOOK_EVENTS.map((event) => [event, buildEntry(5)]));
23
+
24
+ // Codex caps SessionEnd hooks at three seconds.
25
+ return `${JSON.stringify({ hooks: { ...hooks, SessionEnd: buildEntry(3) } }, null, 2)}\n`;
26
+ }
package/src/ui.ts CHANGED
@@ -269,7 +269,7 @@ export function buildOverlayHint(s: OverlaySessionView | undefined): string {
269
269
  actions.push('a ack');
270
270
  }
271
271
 
272
- if (s.agent !== 'grok') {
272
+ if (s.agent === 'claude') {
273
273
  actions.push('H headless');
274
274
  }
275
275