@zgeoff/atc 0.1.12 → 1.0.0

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.
Files changed (74) hide show
  1. package/README.md +50 -12
  2. package/package.json +7 -2
  3. package/src/{agent-adapter.ts → agents/agent-adapter.ts} +31 -14
  4. package/src/{build-cli-command.ts → agents/build-cli-command.ts} +1 -1
  5. package/src/agents/build-hook-settings.ts +59 -0
  6. package/src/{claude-adapter.ts → agents/claude-adapter.ts} +41 -80
  7. package/src/{codex-adapter.ts → agents/codex-adapter.ts} +41 -30
  8. package/src/agents/gateway-adapter.ts +106 -0
  9. package/src/{grok-adapter.ts → agents/grok-adapter.ts} +83 -46
  10. package/src/agents/resolve-agent-home.ts +13 -0
  11. package/src/agents/truncate-detail.ts +7 -0
  12. package/src/agents/write-hook-settings.ts +37 -0
  13. package/src/cli.ts +25 -14
  14. package/src/{boot-daemon.ts → client/boot-daemon.ts} +8 -7
  15. package/src/client/build-client-machine.ts +119 -0
  16. package/src/client/collect-agent-picks.ts +31 -0
  17. package/src/{daemon-client.ts → client/daemon-client.ts} +4 -4
  18. package/src/{dirs.ts → client/dirs.ts} +8 -0
  19. package/src/client/format-overlay-agent-mark.ts +15 -0
  20. package/src/{index.ts → client/index.ts} +181 -550
  21. package/src/client/keys.ts +111 -0
  22. package/src/client/parse-daemon-event.ts +114 -0
  23. package/src/{pick-tab-target.ts → client/pick-tab-target.ts} +2 -2
  24. package/src/client/spawn-picker.ts +305 -0
  25. package/src/client/to-mirror-session.ts +65 -0
  26. package/src/{ui.ts → client/ui.ts} +13 -8
  27. package/src/{attach-registry.ts → daemon/attach-registry.ts} +12 -10
  28. package/src/daemon/build-session-event.ts +43 -0
  29. package/src/{daemon-connection.ts → daemon/daemon-connection.ts} +194 -108
  30. package/src/{daemon.ts → daemon/daemon.ts} +142 -312
  31. package/src/{hooks.ts → daemon/hooks.ts} +6 -4
  32. package/src/daemon/mint-session-id.ts +12 -0
  33. package/src/{permission-registry.ts → daemon/permission-registry.ts} +5 -3
  34. package/src/daemon/restore-fleet.ts +141 -0
  35. package/src/daemon/run-eject-handoff.ts +33 -0
  36. package/src/{screen-model.ts → daemon/screen-model.ts} +1 -1
  37. package/src/daemon/session-runtime.ts +64 -0
  38. package/src/{sessions.ts → daemon/sessions.ts} +77 -133
  39. package/src/{start-headless-run.ts → daemon/start-headless-run.ts} +9 -3
  40. package/src/daemon/start-headless-turn.ts +57 -0
  41. package/src/hook-report.ts +2 -2
  42. package/src/mcp-server.ts +33 -44
  43. package/src/protocol/parse-request-params.ts +31 -0
  44. package/src/{protocol.ts → protocol/protocol.ts} +1 -1
  45. package/src/protocol/request-param-schemas.ts +109 -0
  46. package/src/shared/agent-session-id.ts +8 -0
  47. package/src/shared/build-optional-boolean.ts +10 -0
  48. package/src/shared/build-optional-string-array.ts +14 -0
  49. package/src/shared/build-optional-string.ts +10 -0
  50. package/src/shared/collect-gateways.ts +113 -0
  51. package/src/{config.ts → shared/config.ts} +47 -31
  52. package/src/{get-build.ts → shared/get-build.ts} +21 -6
  53. package/src/shared/get-record.ts +20 -0
  54. package/src/shared/session-id.ts +8 -0
  55. package/src/shared/to-agent-session-id.ts +12 -0
  56. package/src/shared/to-session-id.ts +11 -0
  57. package/src/shared/to-shell-arg.ts +7 -0
  58. package/src/statusline.ts +2 -2
  59. package/src/store/bun-sqlite-driver.ts +96 -0
  60. package/src/store/fleet-entry.ts +72 -0
  61. package/src/store/run-migrations.ts +242 -0
  62. package/src/store/state-store.ts +226 -0
  63. package/src/format-overlay-agent-mark.ts +0 -17
  64. package/src/state-store.ts +0 -236
  65. /package/src/{normalize-hook-event.ts → agents/normalize-hook-event.ts} +0 -0
  66. /package/src/{print-codex-hook-file.ts → agents/print-codex-hook-file.ts} +0 -0
  67. /package/src/{print-grok-hook-file.ts → agents/print-grok-hook-file.ts} +0 -0
  68. /package/src/{build-leader-chords.ts → client/build-leader-chords.ts} +0 -0
  69. /package/src/{daemon-error.ts → protocol/daemon-error.ts} +0 -0
  70. /package/src/{outbound-queue.ts → protocol/outbound-queue.ts} +0 -0
  71. /package/src/{collect-clean-env.ts → shared/collect-clean-env.ts} +0 -0
  72. /package/src/{report.ts → shared/report.ts} +0 -0
  73. /package/src/{reset-input-modes.ts → shared/reset-input-modes.ts} +0 -0
  74. /package/src/{resolve-repo-root.ts → shared/resolve-repo-root.ts} +0 -0
package/README.md CHANGED
@@ -27,11 +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, 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.
30
+ Grok sessions also need the `grok` CLI, and Codex sessions the `codex` CLI — the agent picker lists
31
+ only the agents whose binary it can find. From a checkout, `bun src/cli.ts` runs the same thing. atc
32
+ is built to pair with [zoxide](https://github.com/ajeetdsouza/zoxide): the spawn directory picker
33
+ feeds on its frecency list, so with zoxide installed every directory you visit is two keystrokes
34
+ from a session. Without it the picker falls back to atc's own spawn history.
35
35
 
36
36
  The first invocation auto-spawns the daemon (`atc daemon` runs it in the foreground for systemd or
37
37
  debugging); the TUI is a thin client, so quitting or crashing it leaves every session running. Runs
@@ -52,7 +52,7 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
52
52
  | `a` | overlay | ack notification without attaching |
53
53
  | `p` | overlay | pin or unpin the selected session — pinned sessions stay at the top of the list |
54
54
  | `g` | overlay | toggle the grouped view: sessions cluster under repository headers |
55
- | `H` | overlay | eject to headless (Claude only). Hidden and ignored on a Grok row. |
55
+ | `H` | overlay | eject to headless. Hidden and ignored on a row whose agent has no headless handoff. |
56
56
  | `P` | overlay | revive: a fresh terminal resumes a headless or killed session in place |
57
57
  | `y` | overlay | yank the resume command (`claude --resume <id>`, `grok --resume <id>`, or `codex resume <id>`) |
58
58
  | `Y` | overlay | eject: yank the resume command, then kill the session here |
@@ -65,13 +65,14 @@ The overlay orders sessions by pinned first, then attention state, then most rec
65
65
  the session you want is nearly always near the top. The grouped view (`g`) keeps that order but
66
66
  clusters sessions under dim repository headers, with pinned sessions leading in their own cluster; a
67
67
  git worktree clusters with its main repository, and a directory outside any repository stands alone.
68
- A reserved column after the pin mark shows a dim `g` on Grok rows; Claude rows keep a space so names
69
- stay aligned. The `atc_session_update` MCP tool renames and pins sessions, so an agent can organise
70
- the fleet for you.
68
+ A reserved column after the pin mark shows a dim letter per agent — `g` on Grok rows, `x` on Codex
69
+ rows, and whatever letter a gateway was given; Claude rows keep a space so names stay aligned. The
70
+ `atc_session_update` MCP tool renames and pins sessions, so an agent can organise the fleet for you.
71
71
 
72
72
  Revive (`P`) resumes the session from its saved transcript, so a session killed before its first
73
73
  exchange has nothing on disk yet, and the overlay says so in its message column instead of resuming.
74
- Headless eject (`H`) is Claude-only and uses the same transcript; Grok has no headless handoff.
74
+ Headless eject (`H`) uses the same transcript, and a gateway session ejects to its own backend. Grok
75
+ and Codex have no headless handoff, so the key is hidden on their rows.
75
76
 
76
77
  Everything else is passed through to the focused session, which owns the full screen. Fleet state
77
78
  renders inside Claude Code's own status line (injected via the same `--settings` file): your
@@ -147,6 +148,7 @@ session.
147
148
  "grokArgs": [],
148
149
  "codexBin": "codex",
149
150
  "codexArgs": [],
151
+ "gateways": {},
150
152
  "leader": "ctrl-space"
151
153
  }
152
154
  ```
@@ -159,15 +161,51 @@ session.
159
161
  | `grokArgs` | `[]` | Prepended to every Grok spawn. A user `--leader` in this list is dropped; atc always appends `--no-leader`. |
160
162
  | `codexBin` | `"codex"` | The binary spawned for Codex sessions. |
161
163
  | `codexArgs` | `[]` | Prepended to every Codex spawn. |
164
+ | `gateways` | `{}` | Claude-compatible backends, keyed by agent id. Each becomes its own row in the agent picker. |
162
165
  | `leader` | `"ctrl-space"` | The overlay toggle: `ctrl-` plus a letter or one of `\` `]` `^` `_`, e.g. `"ctrl-]"`. |
163
166
 
164
167
  Pick a different leader when `Ctrl-Space` is taken on your machine — Raycast on macOS claims it, and
165
168
  `ctrl-]` is a solid replacement that no common terminal, multiplexer, or OS shortcut wants. An
166
169
  unknown or reserved value falls back to the default.
167
170
 
171
+ ### Gateways
172
+
173
+ A gateway runs the Claude CLI against a Claude-compatible backend, under its own agent id. Claude
174
+ and GLM sessions then sit side by side in one fleet:
175
+
176
+ ```json
177
+ {
178
+ "gateways": {
179
+ "zai": {
180
+ "label": "GLM (z.ai)",
181
+ "mark": "z",
182
+ "baseURL": "https://api.z.ai/api/anthropic",
183
+ "apiKeyHelper": "~/.local/bin/atc-zai-key",
184
+ "env": { "ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-5.2" }
185
+ }
186
+ }
187
+ }
188
+ ```
189
+
190
+ | Field | Default | Meaning |
191
+ | -------------- | ----------- | ----------------------------------------------------------------------------------------------- |
192
+ | `baseURL` | required | The backend's Anthropic-format endpoint. An entry without one is left out of the picker. |
193
+ | `label` | the id | The row shown in the agent picker. |
194
+ | `mark` | the id | The overlay column letter; the first character is used. |
195
+ | `bin`, `args` | `claudeBin` | The binary and leading arguments, when the backend needs a different build of the CLI. |
196
+ | `apiKeyHelper` | none | Command the CLI runs to read the credential, so no token is written into atc's state directory. |
197
+ | `env` | `{}` | Extra environment for the session, such as the model each Claude tier maps to. |
198
+
199
+ Two backends may be given the same `mark`. atc does not check, and a clash makes them
200
+ indistinguishable in the overlay column.
201
+
202
+ The id may not be `claude`, `grok`, or `codex`. atc writes one settings file per id and passes it as
203
+ `--settings`, on the terminal spawn and on a headless turn alike, so a gateway session reaches its
204
+ own backend rather than whatever the terminal exported.
205
+
168
206
  `atc mcp` exposes the fleet as MCP tools (list, spawn, drive, organise) to any MCP client, wrangled
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.
207
+ sessions included. `atc_session_spawn` takes an optional `agent` id and defaults to Claude; it never
208
+ reads the TUI last-used value.
171
209
 
172
210
  ```sh
173
211
  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.12",
3
+ "version": "1.0.0",
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",
@@ -36,7 +36,11 @@
36
36
  "@xterm/addon-serialize": "0.14.0",
37
37
  "@xterm/headless": "6.0.0",
38
38
  "bun-pty": "0.4.10",
39
- "citty": "0.2.2"
39
+ "citty": "0.2.2",
40
+ "kysely": "0.29.5",
41
+ "ts-pattern": "5.9.0",
42
+ "xstate": "5.32.5",
43
+ "zod": "4.4.3"
40
44
  },
41
45
  "devDependencies": {
42
46
  "@commitlint/cli": "21.2.0",
@@ -52,6 +56,7 @@
52
56
  "oxlint": "1.73.0",
53
57
  "oxlint-tsgolint": "0.24.0",
54
58
  "tslib": "2.8.1",
59
+ "type-fest": "5.8.0",
55
60
  "typescript": "7.0.2"
56
61
  },
57
62
  "packageManager": "bun@1.3.10"
@@ -1,21 +1,29 @@
1
- import type { HookEvent } from './hooks';
1
+ import type { HookEvent } from '../daemon/hooks';
2
+ import type { AgentSessionID } from '../shared/agent-session-id';
2
3
 
3
- export type AgentKind = 'claude' | 'grok' | 'codex';
4
+ /**
5
+ * Which agent a session runs under: the key the adapter registry is looked
6
+ * up by. Every agent CLI supplies one, and so does every configured backend
7
+ * that drives a CLI it does not own, so two ids can share one kind.
8
+ */
9
+ export type AgentID = string;
4
10
 
5
11
  /**
6
- * Missing, empty, and unknown values become Claude so a fleet written
7
- * before the agent column still restores as Claude.
12
+ * Missing and empty values become Claude so a fleet written before the agent
13
+ * column still restores as Claude. Any other string is returned as it stands,
14
+ * registered or not: an id whose adapter is gone must reach the caller intact
15
+ * so the session can be shown and refused, never quietly run as Claude.
8
16
  */
9
- export function toAgentKind(raw: unknown): AgentKind {
10
- return raw === 'grok' || raw === 'codex' ? raw : 'claude';
17
+ export function toAgentID(raw: unknown): AgentID {
18
+ return typeof raw === 'string' && raw !== '' ? raw : 'claude';
11
19
  }
12
20
 
13
21
  export interface SpawnOptions {
14
22
  readonly prompt: string;
15
23
 
16
- // true opens the agent's own session picker; a string resumes that
17
- // specific agent session id.
18
- readonly resume: boolean | string;
24
+ // true opens the agent's own session picker; an agent session id resumes
25
+ // that specific session.
26
+ readonly resume: boolean | AgentSessionID;
19
27
  }
20
28
 
21
29
  export interface SpawnPlan {
@@ -25,7 +33,7 @@ export interface SpawnPlan {
25
33
 
26
34
  export interface AdapterEvent {
27
35
  kind: 'started' | 'needs-input' | 'turn-done' | 'prompt-submitted' | 'ended' | 'heartbeat';
28
- agentSessionID?: string;
36
+ agentSessionID?: AgentSessionID;
29
37
  message?: string;
30
38
 
31
39
  // Fuller activity text than message: what the agent last said or was
@@ -57,15 +65,19 @@ interface ScreenDetector {
57
65
  }
58
66
 
59
67
  export interface ResumeCheck {
60
- readonly agentSessionID?: string;
68
+ readonly agentSessionID?: AgentSessionID;
61
69
  readonly transcriptSource?: string;
62
70
  }
63
71
 
64
72
  interface HeadlessRunRequest {
65
73
  readonly cwd: string;
66
74
  readonly prompt: string;
67
- readonly resume?: string;
75
+ readonly resume?: AgentSessionID;
68
76
  readonly permissionMode?: string;
77
+
78
+ // Settings file the run's CLI is started with, so a headless turn reaches
79
+ // the same backend the session's terminal did.
80
+ readonly settings?: string;
69
81
  }
70
82
 
71
83
  interface HeadlessRunEvents {
@@ -85,7 +97,9 @@ export type HeadlessRunner = (
85
97
  * session outside atc. The session core never sees past this interface.
86
98
  */
87
99
  export interface AgentAdapter {
88
- readonly kind: AgentKind;
100
+ // What the registry is keyed by, and what a session records. Unique across
101
+ // registered adapters.
102
+ readonly id: AgentID;
89
103
 
90
104
  // Runs one headless turn over a session; null means eject is unsupported
91
105
  // for this agent.
@@ -100,5 +114,8 @@ export interface AgentAdapter {
100
114
  namedBy: 'user' | 'auto' | 'agent',
101
115
  ) => Promise<NameUpdate | null>;
102
116
  readonly canResume: (session: ResumeCheck) => boolean;
103
- readonly buildResumeCommand: (cwd: string, agentSessionID: string | undefined) => string | null;
117
+ readonly buildResumeCommand: (
118
+ cwd: string,
119
+ agentSessionID: AgentSessionID | undefined,
120
+ ) => string | null;
104
121
  }
@@ -9,7 +9,7 @@ export function buildCLICommand(subcommand: string): string {
9
9
  const exec = process.execPath;
10
10
 
11
11
  if (basename(exec) === 'bun' || basename(exec) === 'bun.exe') {
12
- return `"${exec}" "${join(import.meta.dir, 'cli.ts')}" ${subcommand}`;
12
+ return `"${exec}" "${join(import.meta.dir, '..', 'cli.ts')}" ${subcommand}`;
13
13
  }
14
14
 
15
15
  return `"${exec}" ${subcommand}`;
@@ -0,0 +1,59 @@
1
+ import type { AgentID } from './agent-adapter';
2
+ import { buildCLICommand } from './build-cli-command';
3
+
4
+ /**
5
+ * What one agent id needs on top of the shared instrumentation. The
6
+ * environment block and the credential helper point the CLI at a backend
7
+ * other than the default one; both are absent for the stock agent.
8
+ */
9
+ export interface HookSettingsProfile {
10
+ readonly id: AgentID;
11
+ readonly env?: Readonly<Record<string, string>>;
12
+ readonly apiKeyHelper?: string;
13
+ }
14
+
15
+ /**
16
+ * The settings object injected into wrangled sessions via
17
+ * `claude --settings`. The user's own settings are untouched; these hooks
18
+ * only exist in sessions atc spawns, and identify themselves via
19
+ * ATC_SESSION_ID in the env.
20
+ *
21
+ * A settings-file env block outranks a shell export of the same variable, so
22
+ * a session's backend is decided here rather than by whatever the terminal
23
+ * happened to carry. The credential is never part of it: the helper command
24
+ * supplies that at run time, so it never reaches a file atc writes.
25
+ */
26
+ export function buildHookSettings(
27
+ profile: HookSettingsProfile,
28
+ statuslinePadding: number,
29
+ ): Record<string, unknown> {
30
+ const entry = [
31
+ { hooks: [{ type: 'command', command: buildCLICommand('hook-report'), timeout: 5 }] },
32
+ ];
33
+
34
+ return {
35
+ hooks: {
36
+ // SessionStart carries the session id at spawn/resume time, before any
37
+ // interaction — without it a session only enters the fleet file after
38
+ // its first prompt/notification.
39
+ SessionStart: entry,
40
+ Notification: entry,
41
+ Stop: entry,
42
+ UserPromptSubmit: entry,
43
+ SessionEnd: entry,
44
+ },
45
+
46
+ // Fleet status renders inside Claude Code's own status line; the injected
47
+ // command chains the user's configured statusline first, so mirror their
48
+ // padding.
49
+ statusLine: {
50
+ type: 'command',
51
+ command: buildCLICommand('statusline'),
52
+ padding: statuslinePadding,
53
+ },
54
+ ...(profile.env === undefined || Object.keys(profile.env).length === 0
55
+ ? {}
56
+ : { env: profile.env }),
57
+ ...(profile.apiKeyHelper === undefined ? {} : { apiKeyHelper: profile.apiKeyHelper }),
58
+ };
59
+ }
@@ -1,6 +1,12 @@
1
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { homedir } from 'node:os';
3
- import { join } from 'node:path';
1
+ import { existsSync } from 'node:fs';
2
+ import { z } from 'zod';
3
+ import type { HookEvent } from '../daemon/hooks';
4
+ import type { AgentSessionID } from '../shared/agent-session-id';
5
+ import { buildOptionalString } from '../shared/build-optional-string';
6
+ import type { Config } from '../shared/config';
7
+ import { isRecord } from '../shared/report';
8
+ import { toAgentSessionID } from '../shared/to-agent-session-id';
9
+ import { toShellArg } from '../shared/to-shell-arg';
4
10
  import type {
5
11
  AdapterEvent,
6
12
  AgentAdapter,
@@ -10,18 +16,28 @@ import type {
10
16
  SpawnOptions,
11
17
  SpawnPlan,
12
18
  } from './agent-adapter';
13
- import { buildCLICommand } from './build-cli-command';
14
- import type { Config } from './config';
15
- import { stateDir } from './config';
16
- import type { HookEvent } from './hooks';
17
- import { isRecord } from './report';
19
+ import { truncateDetail } from './truncate-detail';
20
+ import { writeHookSettings } from './write-hook-settings';
21
+
22
+ // Claude's hook payload keys, snake_case. An absent or wrong-typed field
23
+ // parses to undefined rather than failing the payload, so a broken reporter
24
+ // never breaks the session it reports on.
25
+ const CLAUDE_HOOK_PAYLOAD_SCHEMA = z.object({
26
+ session_id: buildOptionalString(),
27
+ transcript_path: buildOptionalString(),
28
+ message: buildOptionalString(),
29
+ last_assistant_message: buildOptionalString(),
30
+ prompt: buildOptionalString(),
31
+ });
32
+
33
+ type ClaudeHookPayload = z.infer<typeof CLAUDE_HOOK_PAYLOAD_SCHEMA>;
18
34
 
19
35
  /**
20
36
  * The Claude Code adapter: spawn arguments, `--settings` instrumentation,
21
37
  * resume semantics, transcript name-pulling, and statusline chaining.
22
38
  */
23
39
  export class ClaudeAdapter implements AgentAdapter {
24
- readonly kind = 'claude';
40
+ readonly id = 'claude';
25
41
 
26
42
  readonly headlessRunner: HeadlessRunner | null;
27
43
 
@@ -39,7 +55,7 @@ export class ClaudeAdapter implements AgentAdapter {
39
55
  }
40
56
 
41
57
  planSpawn(opts: SpawnOptions): SpawnPlan {
42
- this.settingsFile ??= writeHookSettings();
58
+ this.settingsFile ??= writeHookSettings({ id: this.id });
43
59
 
44
60
  return {
45
61
  bin: this.config.claudeBin,
@@ -55,19 +71,21 @@ export class ClaudeAdapter implements AgentAdapter {
55
71
  }
56
72
 
57
73
  normalizeHook(e: HookEvent): AdapterEvent {
58
- const sessionID = e.payload['session_id'];
59
- const transcript = e.payload['transcript_path'];
74
+ const parsed = CLAUDE_HOOK_PAYLOAD_SCHEMA.safeParse(e.payload);
75
+ const payload: ClaudeHookPayload = parsed.success ? parsed.data : {};
60
76
 
61
77
  const base: AdapterEvent = {
62
78
  kind: 'heartbeat',
63
- ...(typeof sessionID === 'string' ? { agentSessionID: sessionID } : {}),
79
+ ...(payload.session_id === undefined
80
+ ? {}
81
+ : { agentSessionID: toAgentSessionID(payload.session_id) }),
64
82
  };
65
83
 
66
84
  const named: AdapterEvent = {
67
85
  ...base,
68
- ...(typeof transcript === 'string'
69
- ? { nameSource: transcript, transcriptSource: transcript }
70
- : {}),
86
+ ...(payload.transcript_path === undefined
87
+ ? {}
88
+ : { nameSource: payload.transcript_path, transcriptSource: payload.transcript_path }),
71
89
  };
72
90
 
73
91
  switch (e.event) {
@@ -75,30 +93,29 @@ export class ClaudeAdapter implements AgentAdapter {
75
93
  return { ...named, kind: 'started' };
76
94
  }
77
95
  case 'Notification': {
78
- const message = e.payload['message'];
96
+ const message = payload.message;
79
97
 
80
98
  return {
81
99
  ...base,
82
100
  kind: 'needs-input',
83
- ...(typeof message === 'string' && message !== ''
101
+ ...(message !== undefined && message !== ''
84
102
  ? { message, detail: truncateDetail(message) }
85
103
  : {}),
86
104
  };
87
105
  }
88
106
  case 'Stop': {
89
- const lastMessage = e.payload['last_assistant_message'];
107
+ const lastMessage = payload.last_assistant_message;
90
108
 
91
109
  return {
92
110
  ...named,
93
111
  kind: 'turn-done',
94
- ...(typeof lastMessage === 'string' && lastMessage !== ''
112
+ ...(lastMessage !== undefined && lastMessage !== ''
95
113
  ? { detail: truncateDetail(lastMessage) }
96
114
  : {}),
97
115
  };
98
116
  }
99
117
  case 'UserPromptSubmit': {
100
- const prompt = e.payload['prompt'];
101
- const preview = typeof prompt === 'string' ? prompt.slice(0, 80) : '';
118
+ const preview = payload.prompt === undefined ? '' : payload.prompt.slice(0, 80);
102
119
 
103
120
  return {
104
121
  ...named,
@@ -178,66 +195,10 @@ export class ClaudeAdapter implements AgentAdapter {
178
195
  }
179
196
 
180
197
  // Shell command that re-opens this session outside atc (or anywhere).
181
- buildResumeCommand(cwd: string, agentSessionID: string | undefined): string | null {
198
+ buildResumeCommand(cwd: string, agentSessionID: AgentSessionID | undefined): string | null {
182
199
  const resume =
183
200
  agentSessionID === undefined ? 'claude --resume' : `claude --resume ${agentSessionID}`;
184
201
 
185
- const quoted = cwd.replaceAll("'", String.raw`'\''`);
186
-
187
- return `cd '${quoted}' && ${resume}`;
202
+ return `cd ${toShellArg(cwd)} && ${resume}`;
188
203
  }
189
204
  }
190
-
191
- // Settings file injected into wrangled sessions via `claude --settings`.
192
- // The user's own settings are untouched; these hooks only exist in sessions
193
- // atc spawns, and identify themselves via ATC_SESSION_ID in the env.
194
- function writeHookSettings(): string {
195
- const cmd = buildCLICommand('hook-report');
196
- const entry = [{ hooks: [{ type: 'command', command: cmd, timeout: 5 }] }];
197
-
198
- const settings = {
199
- hooks: {
200
- // SessionStart carries the session id at spawn/resume time, before any
201
- // interaction — without it a session only enters the fleet file after
202
- // its first prompt/notification.
203
- SessionStart: entry,
204
- Notification: entry,
205
- Stop: entry,
206
- UserPromptSubmit: entry,
207
- SessionEnd: entry,
208
- },
209
- };
210
-
211
- // Fleet status renders inside Claude Code's own status line; the injected
212
- // command chains the user's configured statusline first, so mirror their
213
- // padding.
214
- let padding = 0;
215
-
216
- try {
217
- const settingsPath = join(homedir(), '.claude', 'settings.json');
218
- const raw = readFileSync(settingsPath, 'utf8');
219
- const user: unknown = JSON.parse(raw);
220
- const statusLine = isRecord(user) ? user['statusLine'] : undefined;
221
- const userPadding = isRecord(statusLine) ? statusLine['padding'] : undefined;
222
-
223
- if (typeof userPadding === 'number') {
224
- padding = userPadding;
225
- }
226
- } catch {}
227
-
228
- const statusline = {
229
- type: 'command',
230
- command: buildCLICommand('statusline'),
231
- padding,
232
- };
233
-
234
- const file = join(stateDir, 'hook-settings.json');
235
-
236
- writeFileSync(file, JSON.stringify({ ...settings, statusLine: statusline }, null, 2));
237
-
238
- return file;
239
- }
240
-
241
- function truncateDetail(text: string): string {
242
- return text.length <= 600 ? text : `${text.slice(0, 599)}…`;
243
- }
@@ -1,6 +1,13 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { homedir } from 'node:os';
3
2
  import { join } from 'node:path';
3
+ import { z } from 'zod';
4
+ import type { HookEvent } from '../daemon/hooks';
5
+ import type { AgentSessionID } from '../shared/agent-session-id';
6
+ import { buildOptionalString } from '../shared/build-optional-string';
7
+ import type { Config } from '../shared/config';
8
+ import { isRecord } from '../shared/report';
9
+ import { toAgentSessionID } from '../shared/to-agent-session-id';
10
+ import { toShellArg } from '../shared/to-shell-arg';
4
11
  import type {
5
12
  AdapterEvent,
6
13
  AgentAdapter,
@@ -9,9 +16,21 @@ import type {
9
16
  SpawnOptions,
10
17
  SpawnPlan,
11
18
  } from './agent-adapter';
12
- import type { Config } from './config';
13
- import type { HookEvent } from './hooks';
14
- import { isRecord } from './report';
19
+ import { resolveAgentHome } from './resolve-agent-home';
20
+ import { truncateDetail } from './truncate-detail';
21
+
22
+ // Codex's hook payload keys, snake_case. An absent or wrong-typed field
23
+ // parses to undefined rather than failing the payload, so a broken reporter
24
+ // never breaks the session it reports on.
25
+ const CODEX_HOOK_PAYLOAD_SCHEMA = z.object({
26
+ session_id: buildOptionalString(),
27
+ transcript_path: buildOptionalString(),
28
+ prompt: buildOptionalString(),
29
+ last_assistant_message: buildOptionalString(),
30
+ tool_name: buildOptionalString(),
31
+ });
32
+
33
+ type CodexHookPayload = z.infer<typeof CODEX_HOOK_PAYLOAD_SCHEMA>;
15
34
 
16
35
  /**
17
36
  * The Codex CLI adapter: spawn arguments, hook payload mapping, resume
@@ -20,7 +39,7 @@ import { isRecord } from './report';
20
39
  * trusts once in the Codex TUI; atc never writes into the user's Codex config.
21
40
  */
22
41
  export class CodexAdapter implements AgentAdapter {
23
- readonly kind = 'codex';
42
+ readonly id = 'codex';
24
43
 
25
44
  // Codex has no headless handoff.
26
45
  readonly headlessRunner = null;
@@ -50,18 +69,22 @@ export class CodexAdapter implements AgentAdapter {
50
69
  }
51
70
 
52
71
  normalizeHook(e: HookEvent): AdapterEvent {
53
- const sessionID = e.payload['session_id'];
54
- const transcript = e.payload['transcript_path'];
72
+ const parsed = CODEX_HOOK_PAYLOAD_SCHEMA.safeParse(e.payload);
73
+ const payload: CodexHookPayload = parsed.success ? parsed.data : {};
55
74
 
56
75
  const base: AdapterEvent = {
57
76
  kind: 'heartbeat',
58
- ...(typeof sessionID === 'string' ? { agentSessionID: sessionID } : {}),
77
+ ...(payload.session_id === undefined
78
+ ? {}
79
+ : { agentSessionID: toAgentSessionID(payload.session_id) }),
59
80
  };
60
81
 
61
82
  const named: AdapterEvent = {
62
83
  ...base,
63
- ...(typeof sessionID === 'string' ? { nameSource: sessionID } : {}),
64
- ...(typeof transcript === 'string' ? { transcriptSource: transcript } : {}),
84
+ ...(payload.session_id === undefined ? {} : { nameSource: payload.session_id }),
85
+ ...(payload.transcript_path === undefined
86
+ ? {}
87
+ : { transcriptSource: payload.transcript_path }),
65
88
  };
66
89
 
67
90
  switch (e.event) {
@@ -69,18 +92,17 @@ export class CodexAdapter implements AgentAdapter {
69
92
  return { ...named, kind: 'started' };
70
93
  }
71
94
  case 'PermissionRequest': {
72
- const toolName = e.payload['tool_name'];
95
+ const toolName = payload.tool_name;
73
96
 
74
97
  const message =
75
- typeof toolName === 'string' && toolName !== ''
98
+ toolName !== undefined && toolName !== ''
76
99
  ? `waiting for approval: ${toolName}`
77
100
  : 'waiting for approval';
78
101
 
79
102
  return { ...base, kind: 'needs-input', message, detail: message };
80
103
  }
81
104
  case 'UserPromptSubmit': {
82
- const prompt = e.payload['prompt'];
83
- const preview = typeof prompt === 'string' ? prompt.slice(0, 80) : '';
105
+ const preview = payload.prompt === undefined ? '' : payload.prompt.slice(0, 80);
84
106
 
85
107
  return {
86
108
  ...named,
@@ -89,12 +111,12 @@ export class CodexAdapter implements AgentAdapter {
89
111
  };
90
112
  }
91
113
  case 'Stop': {
92
- const lastMessage = e.payload['last_assistant_message'];
114
+ const lastMessage = payload.last_assistant_message;
93
115
 
94
116
  return {
95
117
  ...named,
96
118
  kind: 'turn-done',
97
- ...(typeof lastMessage === 'string' && lastMessage !== ''
119
+ ...(lastMessage !== undefined && lastMessage !== ''
98
120
  ? { detail: truncateDetail(lastMessage) }
99
121
  : {}),
100
122
  };
@@ -116,7 +138,7 @@ export class CodexAdapter implements AgentAdapter {
116
138
  return Promise.resolve(null);
117
139
  }
118
140
 
119
- const index = join(resolveCodexHome(), 'session_index.jsonl');
141
+ const index = join(resolveAgentHome('CODEX_HOME', '.codex'), 'session_index.jsonl');
120
142
  let text: string;
121
143
 
122
144
  try {
@@ -159,20 +181,9 @@ export class CodexAdapter implements AgentAdapter {
159
181
  }
160
182
 
161
183
  // Shell command that re-opens this session outside atc (or anywhere).
162
- buildResumeCommand(cwd: string, agentSessionID: string | undefined): string | null {
184
+ buildResumeCommand(cwd: string, agentSessionID: AgentSessionID | undefined): string | null {
163
185
  const resume = agentSessionID === undefined ? 'codex resume' : `codex resume ${agentSessionID}`;
164
- const quoted = cwd.replaceAll("'", String.raw`'\''`);
165
186
 
166
- return `cd '${quoted}' && ${resume}`;
187
+ return `cd ${toShellArg(cwd)} && ${resume}`;
167
188
  }
168
189
  }
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
- }