@zgeoff/atc 0.1.12 → 1.0.1
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 +50 -12
- package/package.json +7 -2
- package/src/{agent-adapter.ts → agents/agent-adapter.ts} +31 -14
- package/src/{build-cli-command.ts → agents/build-cli-command.ts} +1 -1
- package/src/agents/build-hook-settings.ts +59 -0
- package/src/{claude-adapter.ts → agents/claude-adapter.ts} +41 -80
- package/src/{codex-adapter.ts → agents/codex-adapter.ts} +41 -30
- package/src/agents/gateway-adapter.ts +106 -0
- package/src/{grok-adapter.ts → agents/grok-adapter.ts} +83 -46
- package/src/agents/resolve-agent-home.ts +13 -0
- package/src/agents/truncate-detail.ts +7 -0
- package/src/agents/write-hook-settings.ts +37 -0
- package/src/cli.ts +25 -14
- package/src/{boot-daemon.ts → client/boot-daemon.ts} +8 -7
- package/src/client/build-client-machine.ts +119 -0
- package/src/client/collect-agent-picks.ts +31 -0
- package/src/{daemon-client.ts → client/daemon-client.ts} +4 -4
- package/src/{dirs.ts → client/dirs.ts} +8 -0
- package/src/client/format-overlay-agent-mark.ts +15 -0
- package/src/{index.ts → client/index.ts} +181 -550
- package/src/client/keys.ts +111 -0
- package/src/client/parse-daemon-event.ts +114 -0
- package/src/{pick-tab-target.ts → client/pick-tab-target.ts} +2 -2
- package/src/client/plan-vacated-rows.ts +26 -0
- package/src/client/spawn-picker.ts +305 -0
- package/src/client/to-mirror-session.ts +65 -0
- package/src/{ui.ts → client/ui.ts} +27 -8
- package/src/{attach-registry.ts → daemon/attach-registry.ts} +12 -10
- package/src/daemon/build-session-event.ts +43 -0
- package/src/{daemon-connection.ts → daemon/daemon-connection.ts} +194 -108
- package/src/{daemon.ts → daemon/daemon.ts} +148 -313
- package/src/{hooks.ts → daemon/hooks.ts} +6 -4
- package/src/daemon/mint-session-id.ts +12 -0
- package/src/{permission-registry.ts → daemon/permission-registry.ts} +5 -3
- package/src/daemon/restore-fleet.ts +141 -0
- package/src/daemon/run-eject-handoff.ts +33 -0
- package/src/{screen-model.ts → daemon/screen-model.ts} +1 -1
- package/src/daemon/session-runtime.ts +64 -0
- package/src/{sessions.ts → daemon/sessions.ts} +77 -133
- package/src/{start-headless-run.ts → daemon/start-headless-run.ts} +9 -3
- package/src/daemon/start-headless-turn.ts +57 -0
- package/src/hook-report.ts +2 -2
- package/src/mcp-server.ts +33 -44
- package/src/protocol/parse-request-params.ts +31 -0
- package/src/{protocol.ts → protocol/protocol.ts} +1 -1
- package/src/protocol/request-param-schemas.ts +109 -0
- package/src/shared/agent-session-id.ts +8 -0
- package/src/shared/build-optional-boolean.ts +10 -0
- package/src/shared/build-optional-string-array.ts +14 -0
- package/src/shared/build-optional-string.ts +10 -0
- package/src/shared/collect-gateways.ts +113 -0
- package/src/{config.ts → shared/config.ts} +47 -31
- package/src/{get-build.ts → shared/get-build.ts} +21 -6
- package/src/shared/get-record.ts +20 -0
- package/src/shared/session-id.ts +8 -0
- package/src/shared/to-agent-session-id.ts +12 -0
- package/src/shared/to-session-id.ts +11 -0
- package/src/shared/to-shell-arg.ts +7 -0
- package/src/statusline.ts +2 -2
- package/src/store/bun-sqlite-driver.ts +96 -0
- package/src/store/fleet-entry.ts +72 -0
- package/src/store/run-migrations.ts +242 -0
- package/src/store/state-store.ts +226 -0
- package/src/format-overlay-agent-mark.ts +0 -17
- package/src/state-store.ts +0 -236
- /package/src/{normalize-hook-event.ts → agents/normalize-hook-event.ts} +0 -0
- /package/src/{print-codex-hook-file.ts → agents/print-codex-hook-file.ts} +0 -0
- /package/src/{print-grok-hook-file.ts → agents/print-grok-hook-file.ts} +0 -0
- /package/src/{build-leader-chords.ts → client/build-leader-chords.ts} +0 -0
- /package/src/{daemon-error.ts → protocol/daemon-error.ts} +0 -0
- /package/src/{outbound-queue.ts → protocol/outbound-queue.ts} +0 -0
- /package/src/{collect-clean-env.ts → shared/collect-clean-env.ts} +0 -0
- /package/src/{report.ts → shared/report.ts} +0 -0
- /package/src/{reset-input-modes.ts → shared/reset-input-modes.ts} +0 -0
- /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
|
|
31
|
-
`bun src/cli.ts` runs the same thing. atc
|
|
32
|
-
[zoxide](https://github.com/ajeetdsouza/zoxide): the spawn directory picker
|
|
33
|
-
list, so with zoxide installed every directory you visit is two keystrokes
|
|
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
|
|
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
|
|
69
|
-
|
|
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`)
|
|
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`
|
|
170
|
-
|
|
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
|
|
3
|
+
"version": "1.0.1",
|
|
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 '
|
|
1
|
+
import type { HookEvent } from '../daemon/hooks';
|
|
2
|
+
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
2
3
|
|
|
3
|
-
|
|
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
|
|
7
|
-
*
|
|
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
|
|
10
|
-
return raw === '
|
|
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;
|
|
17
|
-
// specific
|
|
18
|
-
readonly resume: boolean |
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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
|
-
|
|
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: (
|
|
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
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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 {
|
|
14
|
-
import
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
|
59
|
-
const
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
69
|
-
? {
|
|
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 =
|
|
96
|
+
const message = payload.message;
|
|
79
97
|
|
|
80
98
|
return {
|
|
81
99
|
...base,
|
|
82
100
|
kind: 'needs-input',
|
|
83
|
-
...(
|
|
101
|
+
...(message !== undefined && message !== ''
|
|
84
102
|
? { message, detail: truncateDetail(message) }
|
|
85
103
|
: {}),
|
|
86
104
|
};
|
|
87
105
|
}
|
|
88
106
|
case 'Stop': {
|
|
89
|
-
const lastMessage =
|
|
107
|
+
const lastMessage = payload.last_assistant_message;
|
|
90
108
|
|
|
91
109
|
return {
|
|
92
110
|
...named,
|
|
93
111
|
kind: 'turn-done',
|
|
94
|
-
...(
|
|
112
|
+
...(lastMessage !== undefined && lastMessage !== ''
|
|
95
113
|
? { detail: truncateDetail(lastMessage) }
|
|
96
114
|
: {}),
|
|
97
115
|
};
|
|
98
116
|
}
|
|
99
117
|
case 'UserPromptSubmit': {
|
|
100
|
-
const
|
|
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:
|
|
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
|
-
|
|
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
|
|
13
|
-
import
|
|
14
|
-
|
|
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
|
|
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
|
|
54
|
-
const
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
64
|
-
...(
|
|
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 =
|
|
95
|
+
const toolName = payload.tool_name;
|
|
73
96
|
|
|
74
97
|
const message =
|
|
75
|
-
|
|
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
|
|
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 =
|
|
114
|
+
const lastMessage = payload.last_assistant_message;
|
|
93
115
|
|
|
94
116
|
return {
|
|
95
117
|
...named,
|
|
96
118
|
kind: 'turn-done',
|
|
97
|
-
...(
|
|
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(
|
|
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:
|
|
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
|
|
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
|
-
}
|