@zgeoff/atc 0.1.10 → 0.1.12

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,9 +54,9 @@ 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
- | `K` | overlay | kill selected (confirm with `y`) — the entry stays revivable with `P`; a second `K` forgets it |
59
+ | `K` | overlay | kill selected (confirm with `y`) — the entry stays revivable with `P`, even across daemon restarts; a second `K` forgets it |
59
60
  | `?` | overlay | full key reference — the hint row only shows actions valid for the selected session |
60
61
  | `u` | overlay | restart an outdated daemon and restore the fleet — offered only while `⟳ update ready` shows |
61
62
  | `q` | home/overlay | quit the client — sessions keep running in the daemon |
@@ -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
@@ -170,8 +186,9 @@ restarts the daemon and restores the fleet at a moment you choose. Only a protoc
170
186
  the two could miscommunicate, forces the restart immediately. The daemon continuously writes the
171
187
  live fleet (name, cwd, agent, session id) to its SQLite store. If the daemon itself dies — crash,
172
188
  SIGKILL, reboot — the child processes die with it, but every session's transcript is already on
173
- disk. Start atc and press `R`: the whole fleet respawns with the matching CLI. Only deliberate kills
174
- (`K`, `Y` eject) remove entries from the fleet, so it stays restorable.
189
+ disk. Start atc and press `R`: the whole fleet respawns with the matching CLI. Killed sessions (`K`,
190
+ `Y` eject) stay in the fleet as exited entries — restore lists them as killed without booting a
191
+ terminal, and `P` still revives them. A second `K` forgets an entry for good.
175
192
 
176
193
  Restoring shows the whole fleet immediately — every incoming session appears in the list marked
177
194
  "waiting to restore" — and revives one at a time, most recently active first: the next resume starts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
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}'`);
package/src/daemon.ts CHANGED
@@ -615,29 +615,40 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
615
615
  getEffectiveDims: (sessionID) =>
616
616
  attachments.findEffectiveDims(sessionID) ?? ptyDims.get(sessionID) ?? { cols: 80, rows: 24 },
617
617
  restoreFleet: (cols, rows) => {
618
- const hasSession = (agentSessionID: string) =>
618
+ const hasLiveSession = (agentSessionID: string) =>
619
619
  mgr.sessions.some(
620
620
  (s) =>
621
621
  s.agentSessionID === agentSessionID &&
622
622
  (s.pty !== null || (s.kind === 'headless' && s.state !== 'exited')),
623
623
  );
624
624
 
625
+ const hasAnySession = (agentSessionID: string) =>
626
+ mgr.sessions.some((s) => s.agentSessionID === agentSessionID);
627
+
625
628
  const recency = store.collectFleetRecency();
626
629
 
627
630
  // Most recently active sessions revive first, so the ones the user was
628
631
  // just working in come back before long-idle ones; entries that never
629
- // reported an event keep their stored order at the end.
632
+ // reported an event keep their stored order at the end. Exited entries
633
+ // dedupe against every listed session, so repeated restores never
634
+ // double up the killed archive.
630
635
  const entries = store
631
636
  .loadFleet()
632
- .filter((entry) => !hasSession(entry.agentSessionID))
637
+ .filter((entry) =>
638
+ entry.exited === true
639
+ ? !hasAnySession(entry.agentSessionID)
640
+ : !hasLiveSession(entry.agentSessionID),
641
+ )
633
642
  .toSorted((a, b) =>
634
643
  (recency.get(b.agentSessionID) ?? '').localeCompare(recency.get(a.agentSessionID) ?? ''),
635
644
  );
636
645
 
637
646
  // The whole fleet registers as terminal-less sessions up front, so the
638
647
  // list shows every incoming session immediately instead of revealing
639
- // them one boot at a time.
640
- const queued = entries.map((entry) => mgr.restore(entry));
648
+ // them one boot at a time. Exited entries only register — they stay
649
+ // killed until revived by hand, so no terminal is adopted for them.
650
+ const registered = entries.map((entry) => mgr.restore(entry));
651
+ const queued = registered.filter((s) => s.state !== 'exited');
641
652
 
642
653
  const adoptQueued = (s: Session): boolean => {
643
654
  if (mgr.adoptTerminal(s.id, cols, rows) === null) {
@@ -656,7 +667,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
656
667
  const [first, ...rest] = queued;
657
668
 
658
669
  if (first === undefined) {
659
- return 0;
670
+ return registered.length;
660
671
  }
661
672
 
662
673
  // The first terminal attaches synchronously so a caller can attach at
@@ -682,7 +693,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
682
693
 
683
694
  void adoptRest(firstBooted);
684
695
 
685
- return queued.length;
696
+ return registered.length;
686
697
  },
687
698
  };
688
699
 
@@ -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/sessions.ts CHANGED
@@ -73,6 +73,7 @@ export interface FleetEntry {
73
73
  readonly agent: AgentKind;
74
74
  readonly pinned?: boolean;
75
75
  readonly lastAttachedAt?: number;
76
+ readonly exited?: boolean;
76
77
  }
77
78
 
78
79
  const fleetFile = join(stateDir, 'fleet.json');
@@ -111,6 +112,7 @@ export function parseFleetEntry(raw: unknown): FleetEntry | undefined {
111
112
  agent: toAgentKind(raw['agent']),
112
113
  ...(raw['pinned'] === true ? { pinned: true } : {}),
113
114
  ...(typeof raw['lastAttachedAt'] === 'number' ? { lastAttachedAt: raw['lastAttachedAt'] } : {}),
115
+ ...(raw['exited'] === true ? { exited: true } : {}),
114
116
  };
115
117
  }
116
118
 
@@ -206,17 +208,20 @@ export class SessionManager {
206
208
 
207
209
  // Registers a fleet entry as a session with no terminal yet, so a
208
210
  // fleet-wide restore can show every incoming session at once; adopting it
209
- // later attaches the terminal.
211
+ // later attaches the terminal. Exited entries come back as killed
212
+ // sessions: still listed and revivable, never auto-adopted.
210
213
  restore(entry: FleetEntry): Session {
214
+ const exited = entry.exited === true;
215
+
211
216
  const session: Session = {
212
217
  id: `s${++counter}-${Date.now().toString(36)}`,
213
218
  name: entry.name,
214
219
  cwd: entry.cwd,
215
220
  kind: 'headless',
216
221
  pty: null,
217
- state: 'running',
222
+ state: exited ? 'exited' : 'running',
218
223
  unread: false,
219
- lastMsg: 'waiting to restore',
224
+ lastMsg: exited ? 'killed' : 'waiting to restore',
220
225
  agentSessionID: entry.agentSessionID,
221
226
  agent: entry.agent,
222
227
  pinned: entry.pinned ?? false,
@@ -691,23 +696,28 @@ export class SessionManager {
691
696
 
692
697
  // Persisted continuously so a crash (or quit) leaves a restorable fleet.
693
698
  // Deliberate kills rewrite the file; unexpected session/atc deaths do not,
694
- // so the last known fleet survives for `R` restore.
699
+ // so the last known fleet survives for `R` restore. Sessions whose
700
+ // terminal is gone persist as exited entries, so the killed archive
701
+ // survives a daemon restart too.
695
702
  writeFleet() {
696
703
  const fleet: FleetEntry[] = [];
697
704
 
698
705
  for (const s of this.sessions) {
706
+ if (s.agentSessionID === undefined) {
707
+ continue;
708
+ }
709
+
699
710
  const live = s.pty !== null || (s.kind === 'headless' && s.state !== 'exited');
700
711
 
701
- if (live && s.agentSessionID !== undefined) {
702
- fleet.push({
703
- name: s.name,
704
- cwd: s.cwd,
705
- agentSessionID: s.agentSessionID,
706
- agent: s.agent,
707
- ...(s.pinned ? { pinned: true } : {}),
708
- lastAttachedAt: s.lastAttachedAt,
709
- });
710
- }
712
+ fleet.push({
713
+ name: s.name,
714
+ cwd: s.cwd,
715
+ agentSessionID: s.agentSessionID,
716
+ agent: s.agent,
717
+ ...(s.pinned ? { pinned: true } : {}),
718
+ lastAttachedAt: s.lastAttachedAt,
719
+ ...(live ? {} : { exited: true }),
720
+ });
711
721
  }
712
722
 
713
723
  this.store.writeFleet(fleet);
@@ -28,7 +28,8 @@ export class StateStore {
28
28
  cwd TEXT NOT NULL,
29
29
  pinned INTEGER NOT NULL DEFAULT 0,
30
30
  last_attached INTEGER,
31
- agent TEXT NOT NULL DEFAULT 'claude'
31
+ agent TEXT NOT NULL DEFAULT 'claude',
32
+ exited INTEGER NOT NULL DEFAULT 0
32
33
  );
33
34
  CREATE TABLE IF NOT EXISTS events (
34
35
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -75,6 +76,10 @@ export class StateStore {
75
76
  this.db.run("ALTER TABLE fleet ADD COLUMN agent TEXT NOT NULL DEFAULT 'claude'");
76
77
  }
77
78
 
79
+ if (!fleetColumns.has('exited')) {
80
+ this.db.run('ALTER TABLE fleet ADD COLUMN exited INTEGER NOT NULL DEFAULT 0');
81
+ }
82
+
78
83
  if (legacyFleetPath !== undefined) {
79
84
  this.adoptLegacyFleet(legacyFleetPath);
80
85
  }
@@ -90,9 +95,10 @@ export class StateStore {
90
95
  pinned: number;
91
96
  last_attached: number | null;
92
97
  agent: string | null;
98
+ exited: number;
93
99
  },
94
100
  []
95
- >('SELECT agent_session_id, name, cwd, pinned, last_attached, agent FROM fleet')
101
+ >('SELECT agent_session_id, name, cwd, pinned, last_attached, agent, exited FROM fleet')
96
102
  .all();
97
103
 
98
104
  const entries: FleetEntry[] = [];
@@ -105,6 +111,7 @@ export class StateStore {
105
111
  agent: toAgentKind(row.agent),
106
112
  ...(row.pinned === 0 ? {} : { pinned: true }),
107
113
  ...(row.last_attached === null ? {} : { lastAttachedAt: row.last_attached }),
114
+ ...(row.exited === 0 ? {} : { exited: true }),
108
115
  });
109
116
  }
110
117
 
@@ -128,11 +135,12 @@ export class StateStore {
128
135
  this.db.run('DELETE FROM fleet');
129
136
 
130
137
  const insert = this.db.query(
131
- 'INSERT OR REPLACE INTO fleet (agent_session_id, name, cwd, pinned, last_attached, agent) VALUES (?1, ?2, ?3, ?4, ?5, ?6)',
138
+ 'INSERT OR REPLACE INTO fleet (agent_session_id, name, cwd, pinned, last_attached, agent, exited) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)',
132
139
  );
133
140
 
134
141
  for (const entry of all) {
135
142
  const pinned = entry.pinned === true ? 1 : 0;
143
+ const exited = entry.exited === true ? 1 : 0;
136
144
 
137
145
  insert.run(
138
146
  entry.agentSessionID,
@@ -141,6 +149,7 @@ export class StateStore {
141
149
  pinned,
142
150
  entry.lastAttachedAt ?? null,
143
151
  entry.agent,
152
+ exited,
144
153
  );
145
154
  }
146
155
  });
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