@zgeoff/atc 0.1.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.
- package/LICENSE +21 -0
- package/README.md +85 -0
- package/bin/atc +2 -0
- package/package.json +58 -0
- package/src/agent-adapter.ts +60 -0
- package/src/attach-registry.ts +89 -0
- package/src/boot-daemon.ts +106 -0
- package/src/claude-adapter.ts +235 -0
- package/src/cli.ts +101 -0
- package/src/collect-clean-env.ts +21 -0
- package/src/config.ts +57 -0
- package/src/daemon-client.ts +131 -0
- package/src/daemon-connection.ts +511 -0
- package/src/daemon-error.ts +16 -0
- package/src/daemon.ts +648 -0
- package/src/dirs.ts +67 -0
- package/src/get-build.ts +32 -0
- package/src/hook-report.ts +31 -0
- package/src/hooks.ts +54 -0
- package/src/index.ts +1076 -0
- package/src/mcp-server.ts +272 -0
- package/src/outbound-queue.ts +85 -0
- package/src/permission-registry.ts +104 -0
- package/src/protocol.ts +129 -0
- package/src/report.ts +33 -0
- package/src/screen-model.ts +48 -0
- package/src/sessions.ts +568 -0
- package/src/start-headless-run.ts +160 -0
- package/src/state-store.ts +131 -0
- package/src/statusline.ts +103 -0
- package/src/ui.ts +341 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Geoff Whatley
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>atc</h1>
|
|
3
|
+
|
|
4
|
+
<p>
|
|
5
|
+
Control tower for Claude Code sessions: stock <code>claude</code> instances in PTYs behind a
|
|
6
|
+
keyboard-driven session list with hook-driven attention routing — no panes, no tiling, no
|
|
7
|
+
mouse.
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<p>
|
|
11
|
+
<a href="./docs/README.md">Documentation</a> •
|
|
12
|
+
<a href="./docs/architecture/overview.md">Architecture</a> •
|
|
13
|
+
<a href="./AGENTS.md">Agent Guidelines</a>
|
|
14
|
+
</p>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
## Run
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
bun src/cli.ts # or `atc` if bin/atc is on your PATH
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The first invocation auto-spawns the daemon (`atc daemon` runs it in the foreground for systemd or
|
|
24
|
+
debugging); the TUI is a thin client, so quitting or crashing it leaves every session running. Runs
|
|
25
|
+
fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches atc).
|
|
26
|
+
|
|
27
|
+
## Keys
|
|
28
|
+
|
|
29
|
+
| Key | Where | Action |
|
|
30
|
+
| --------------- | -------------- | ---------------------------------------------------------------------------------------------- |
|
|
31
|
+
| `Ctrl-Space` | anywhere | toggle session overlay |
|
|
32
|
+
| `n` | home/overlay | spawn: pick dir (zoxide + history, fuzzy) → name → optional first prompt |
|
|
33
|
+
| `r` | home/overlay | adopt: pick dir → name → `claude --resume` (Claude's session picker opens in the new PTY) |
|
|
34
|
+
| `R` | home | restore last fleet after a daemon death — respawns every session via `claude --resume <id>` |
|
|
35
|
+
| `j`/`k`/`↑`/`↓` | overlay/picker | move |
|
|
36
|
+
| `Enter` | overlay | attach (auto-acks) |
|
|
37
|
+
| `/` | overlay | fzf-style filter: type to narrow by name/dir, `⏎` attach top match, `esc` clear |
|
|
38
|
+
| `a` | overlay | ack notification without attaching |
|
|
39
|
+
| `H` | overlay | eject to headless: the terminal dies, a headless Agent SDK run resumes the same session |
|
|
40
|
+
| `P` | overlay | revive: a fresh terminal resumes a headless or killed session in place |
|
|
41
|
+
| `y` | overlay | yank `cd <dir> && claude --resume <id>` to clipboard (OSC 52 + clip.exe/wl-copy/xclip) |
|
|
42
|
+
| `Y` | overlay | eject: yank the resume command, then kill the session here — paste it in any pane to take over |
|
|
43
|
+
| `K` | overlay | kill selected (confirm with `y`) — the entry stays revivable with `P`; a second `K` forgets it |
|
|
44
|
+
| `?` | overlay | full key reference — the hint row only shows actions valid for the selected session |
|
|
45
|
+
| `q` | home/overlay | quit the client — sessions keep running in the daemon |
|
|
46
|
+
|
|
47
|
+
Revive (`P`) and headless eject (`H`) resume the session from its saved transcript, so both need one
|
|
48
|
+
to exist: a session killed before its first exchange has nothing on disk yet, and the overlay says
|
|
49
|
+
so in its message column instead of resuming.
|
|
50
|
+
|
|
51
|
+
Everything else is passed through to the focused Claude session, which owns the full screen. Fleet
|
|
52
|
+
state renders inside Claude Code's own status line (injected via the same `--settings` file): your
|
|
53
|
+
configured statusline runs first, and atc appends `▏● 2 need you: auth-bug`. atc draws its own
|
|
54
|
+
status bar only on the home and overlay screens.
|
|
55
|
+
|
|
56
|
+
## How state tracking works
|
|
57
|
+
|
|
58
|
+
Spawned sessions get a `--settings` file injecting `Notification`, `Stop`, `UserPromptSubmit`, and
|
|
59
|
+
`SessionEnd` hooks that report to a unix socket (`$XDG_RUNTIME_DIR/atc.sock`). Your global Claude
|
|
60
|
+
settings are untouched; sessions you start outside atc are unaffected. States: red `●` needs you,
|
|
61
|
+
cyan `◐` running, green `✓` turn done, gray `✗` exited. The overlay sorts needs-you first; the
|
|
62
|
+
status bar turns red and names the most urgent session.
|
|
63
|
+
|
|
64
|
+
## Config
|
|
65
|
+
|
|
66
|
+
`~/.config/atc/config.json`:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{ "claudeBin": "claude", "claudeArgs": [] }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`claudeArgs` is prepended to every spawn (e.g. `["--model", "opus"]`). `atc mcp` exposes the fleet
|
|
73
|
+
as MCP tools (list, spawn, drive) to any MCP client, wrangled sessions included. Daemon state — the
|
|
74
|
+
restorable fleet, spawn-dir history, and the hook-event trail — lives in `~/.local/state/atc/atc.db`
|
|
75
|
+
(SQLite), next to `status.json` (read by the injected statusline); the daemon's pid file sits in
|
|
76
|
+
`$XDG_RUNTIME_DIR/atc-daemon.pid`, beside its sockets.
|
|
77
|
+
|
|
78
|
+
## Crash safety
|
|
79
|
+
|
|
80
|
+
A client crash or closed window costs nothing: the daemon keeps hosting the fleet, and the next
|
|
81
|
+
`atc` reconnects. The daemon continuously writes the live fleet (name, cwd, Claude session id) to
|
|
82
|
+
its SQLite store. If the daemon itself dies — crash, SIGKILL, reboot — the child claude processes
|
|
83
|
+
die with it, but every session's transcript is already on disk. Start atc and press `R`: the whole
|
|
84
|
+
fleet respawns via `claude --resume`. Only deliberate kills (`K`, `Y` eject) remove entries from the
|
|
85
|
+
fleet, so it stays restorable.
|
package/bin/atc
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zgeoff/atc",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Terminal control tower for Claude Code sessions",
|
|
5
|
+
"homepage": "https://github.com/zgeoff/atc#readme",
|
|
6
|
+
"bugs": "https://github.com/zgeoff/atc/issues",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/zgeoff/atc.git"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"atc": "bin/atc"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"src",
|
|
18
|
+
"!src/**/*.test.ts"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
21
|
+
"scripts": {
|
|
22
|
+
"start": "bun src/cli.ts",
|
|
23
|
+
"build:agents": "bash scripts/build-agents-md.sh agents/shared.md agents/project.md AGENTS.md",
|
|
24
|
+
"audit": "bun audit",
|
|
25
|
+
"deadcode": "knip",
|
|
26
|
+
"lint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error",
|
|
27
|
+
"lint:fix": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error --fix",
|
|
28
|
+
"test": "bun test",
|
|
29
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
30
|
+
"format": "oxfmt . && format-codemod --quiet .",
|
|
31
|
+
"format:check": "oxfmt --check . && format-codemod --check --quiet .",
|
|
32
|
+
"prepare": "command -v git > /dev/null 2>&1 && lefthook install --force || true"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.222",
|
|
36
|
+
"@xterm/addon-serialize": "0.14.0",
|
|
37
|
+
"@xterm/headless": "6.0.0",
|
|
38
|
+
"bun-pty": "0.4.10",
|
|
39
|
+
"citty": "0.2.2"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@commitlint/cli": "21.2.0",
|
|
43
|
+
"@stylistic/eslint-plugin": "5.10.0",
|
|
44
|
+
"@tsconfig/strictest": "2.0.8",
|
|
45
|
+
"@types/bun": "1.3.14",
|
|
46
|
+
"@zgeoff/bun-test-extended": "0.0.4",
|
|
47
|
+
"@zgeoff/format-codemod": "0.0.8",
|
|
48
|
+
"@zgeoff/oxlint-config": "0.0.6",
|
|
49
|
+
"knip": "6.24.0",
|
|
50
|
+
"lefthook": "2.1.10",
|
|
51
|
+
"oxfmt": "0.57.0",
|
|
52
|
+
"oxlint": "1.73.0",
|
|
53
|
+
"oxlint-tsgolint": "0.24.0",
|
|
54
|
+
"tslib": "2.8.1",
|
|
55
|
+
"typescript": "7.0.2"
|
|
56
|
+
},
|
|
57
|
+
"packageManager": "bun@1.3.10"
|
|
58
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { HookEvent } from './hooks';
|
|
2
|
+
|
|
3
|
+
export interface SpawnOptions {
|
|
4
|
+
readonly prompt: string;
|
|
5
|
+
|
|
6
|
+
// true opens the agent's own session picker; a string resumes that
|
|
7
|
+
// specific agent session id.
|
|
8
|
+
readonly resume: boolean | string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SpawnPlan {
|
|
12
|
+
bin: string;
|
|
13
|
+
args: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AdapterEvent {
|
|
17
|
+
kind: 'started' | 'needs-input' | 'turn-done' | 'prompt-submitted' | 'ended' | 'heartbeat';
|
|
18
|
+
agentSessionID?: string;
|
|
19
|
+
message?: string;
|
|
20
|
+
|
|
21
|
+
// Fuller activity text than message: what the agent last said or was
|
|
22
|
+
// asked, for briefing. Bounded by the adapter.
|
|
23
|
+
detail?: string;
|
|
24
|
+
|
|
25
|
+
// Opaque handle the adapter can later pull a session name from.
|
|
26
|
+
nameSource?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface NameUpdate {
|
|
30
|
+
name: string;
|
|
31
|
+
namedBy?: 'agent';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type AttentionJudgment = 'needs-input' | 'working';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The universal attention fallback for agents without a hook system: judges
|
|
38
|
+
* the current serialized screen once output quiesces. null means no opinion
|
|
39
|
+
* and the session's state stands.
|
|
40
|
+
*/
|
|
41
|
+
interface ScreenDetector {
|
|
42
|
+
readonly detectAttention: (screen: string) => AttentionJudgment | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Everything specific to one agent CLI: how to spawn it, how to read its
|
|
47
|
+
* hook payloads, where its session names come from, and how to resume a
|
|
48
|
+
* session outside atc. The session core never sees past this interface.
|
|
49
|
+
*/
|
|
50
|
+
export interface AgentAdapter {
|
|
51
|
+
// The detector stack's screen tier; null when hooks are authoritative.
|
|
52
|
+
readonly screenDetector: ScreenDetector | null;
|
|
53
|
+
readonly planSpawn: (opts: SpawnOptions) => SpawnPlan;
|
|
54
|
+
readonly normalizeHook: (e: HookEvent) => AdapterEvent;
|
|
55
|
+
readonly loadName: (
|
|
56
|
+
source: string,
|
|
57
|
+
namedBy: 'user' | 'auto' | 'agent',
|
|
58
|
+
) => Promise<NameUpdate | null>;
|
|
59
|
+
readonly buildResumeCommand: (cwd: string, agentSessionID: string | undefined) => string | null;
|
|
60
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export interface Dims {
|
|
2
|
+
readonly cols: number;
|
|
3
|
+
readonly rows: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which clients are subscribed to which session's output, and the dims each
|
|
8
|
+
* reported. A session's effective size is the smallest reported width and
|
|
9
|
+
* height across its attached clients, so every attached terminal can render
|
|
10
|
+
* the whole screen.
|
|
11
|
+
*/
|
|
12
|
+
export class AttachRegistry<TClient> {
|
|
13
|
+
private readonly bySession = new Map<string, Map<TClient, Dims>>();
|
|
14
|
+
|
|
15
|
+
attach(sessionID: string, client: TClient, dims: Dims): void {
|
|
16
|
+
const clients = this.bySession.get(sessionID) ?? new Map<TClient, Dims>();
|
|
17
|
+
|
|
18
|
+
clients.set(client, dims);
|
|
19
|
+
this.bySession.set(sessionID, clients);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
detach(sessionID: string, client: TClient): void {
|
|
23
|
+
const clients = this.bySession.get(sessionID);
|
|
24
|
+
|
|
25
|
+
clients?.delete(client);
|
|
26
|
+
|
|
27
|
+
if (clients !== undefined && clients.size === 0) {
|
|
28
|
+
this.bySession.delete(sessionID);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
detachAll(client: TClient): string[] {
|
|
33
|
+
const affected: string[] = [];
|
|
34
|
+
|
|
35
|
+
for (const [sessionID, clients] of this.bySession) {
|
|
36
|
+
if (clients.delete(client)) {
|
|
37
|
+
affected.push(sessionID);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (clients.size === 0) {
|
|
41
|
+
this.bySession.delete(sessionID);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return affected;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
removeSession(sessionID: string): void {
|
|
49
|
+
this.bySession.delete(sessionID);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
hasClient(sessionID: string, client: TClient): boolean {
|
|
53
|
+
return this.bySession.get(sessionID)?.has(client) ?? false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
updateDims(sessionID: string, client: TClient, dims: Dims): boolean {
|
|
57
|
+
const clients = this.bySession.get(sessionID);
|
|
58
|
+
|
|
59
|
+
if (clients === undefined || !clients.has(client)) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
clients.set(client, dims);
|
|
64
|
+
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
collectClients(sessionID: string): TClient[] {
|
|
69
|
+
return [...(this.bySession.get(sessionID)?.keys() ?? [])];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
findEffectiveDims(sessionID: string): Dims | null {
|
|
73
|
+
const clients = this.bySession.get(sessionID);
|
|
74
|
+
|
|
75
|
+
if (clients === undefined || clients.size === 0) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let cols = Number.POSITIVE_INFINITY;
|
|
80
|
+
let rows = Number.POSITIVE_INFINITY;
|
|
81
|
+
|
|
82
|
+
for (const dims of clients.values()) {
|
|
83
|
+
cols = Math.min(cols, dims.cols);
|
|
84
|
+
rows = Math.min(rows, dims.rows);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { cols, rows };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { spawn as spawnChild } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { daemonPidFile, daemonSocketPath } from './config';
|
|
5
|
+
import { DaemonClient } from './daemon-client';
|
|
6
|
+
import { getBuild } from './get-build';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Opens a handshaken client to the daemon, booting the daemon first when its
|
|
10
|
+
* socket is absent. A daemon left running from an older build is restarted,
|
|
11
|
+
* so a fresh client never talks to stale daemon code — the fleet stays
|
|
12
|
+
* restorable afterwards through the usual cold-boot recovery. The expected
|
|
13
|
+
* build is read from disk on every attempt: a long-lived caller holding a
|
|
14
|
+
* build string from its own boot would otherwise restart daemons that are
|
|
15
|
+
* already current.
|
|
16
|
+
*/
|
|
17
|
+
export async function bootDaemonClient(): Promise<DaemonClient> {
|
|
18
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
19
|
+
const build = getBuild();
|
|
20
|
+
|
|
21
|
+
const client = await openOrBootDaemon();
|
|
22
|
+
const hello = await client.sendHello(build);
|
|
23
|
+
|
|
24
|
+
const daemonBuild = hello['daemon'];
|
|
25
|
+
|
|
26
|
+
if (daemonBuild === build || attempt > 0) {
|
|
27
|
+
return client;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
client.stop();
|
|
31
|
+
|
|
32
|
+
await stopStaleDaemon();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
throw new Error('the atc daemon could not be restarted');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function openOrBootDaemon(): Promise<DaemonClient> {
|
|
39
|
+
let opened = await tryOpenDaemon();
|
|
40
|
+
|
|
41
|
+
if (opened === null) {
|
|
42
|
+
spawnDaemonDetached();
|
|
43
|
+
|
|
44
|
+
const deadline = Date.now() + 5000;
|
|
45
|
+
|
|
46
|
+
while (opened === null && Date.now() < deadline) {
|
|
47
|
+
await Bun.sleep(100);
|
|
48
|
+
|
|
49
|
+
opened = await tryOpenDaemon();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (opened === null) {
|
|
54
|
+
throw new Error('the atc daemon did not come up; try `atc daemon` for its output');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return opened;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function tryOpenDaemon(): Promise<DaemonClient | null> {
|
|
61
|
+
try {
|
|
62
|
+
return await DaemonClient.open(daemonSocketPath);
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function stopStaleDaemon(): Promise<void> {
|
|
69
|
+
let pid = 0;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
pid = Number(readFileSync(daemonPidFile, 'utf8'));
|
|
73
|
+
} catch {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!Number.isInteger(pid) || pid <= 1) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
process.kill(pid, 'SIGTERM');
|
|
83
|
+
} catch {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const deadline = Date.now() + 3000;
|
|
88
|
+
|
|
89
|
+
while (Date.now() < deadline) {
|
|
90
|
+
try {
|
|
91
|
+
process.kill(pid, 0);
|
|
92
|
+
} catch {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await Bun.sleep(50);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function spawnDaemonDetached() {
|
|
101
|
+
const exec = process.execPath;
|
|
102
|
+
const isBun = basename(exec) === 'bun' || basename(exec) === 'bun.exe';
|
|
103
|
+
const args = isBun ? [join(import.meta.dir, 'cli.ts'), 'daemon'] : ['daemon'];
|
|
104
|
+
|
|
105
|
+
spawnChild(exec, args, { detached: true, stdio: 'ignore' }).unref();
|
|
106
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import type {
|
|
5
|
+
AdapterEvent,
|
|
6
|
+
AgentAdapter,
|
|
7
|
+
NameUpdate,
|
|
8
|
+
SpawnOptions,
|
|
9
|
+
SpawnPlan,
|
|
10
|
+
} from './agent-adapter';
|
|
11
|
+
import type { Config } from './config';
|
|
12
|
+
import { stateDir } from './config';
|
|
13
|
+
import type { HookEvent } from './hooks';
|
|
14
|
+
import { isRecord } from './report';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The Claude Code adapter: spawn arguments, `--settings` instrumentation,
|
|
18
|
+
* resume semantics, transcript name-pulling, and statusline chaining.
|
|
19
|
+
*/
|
|
20
|
+
export class ClaudeAdapter implements AgentAdapter {
|
|
21
|
+
// Claude's hooks are authoritative; no screen heuristics needed.
|
|
22
|
+
readonly screenDetector = null;
|
|
23
|
+
|
|
24
|
+
private readonly config: Config;
|
|
25
|
+
|
|
26
|
+
private readonly settingsFile: string;
|
|
27
|
+
|
|
28
|
+
constructor(config: Config) {
|
|
29
|
+
this.config = config;
|
|
30
|
+
this.settingsFile = writeHookSettings();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
planSpawn(opts: SpawnOptions): SpawnPlan {
|
|
34
|
+
return {
|
|
35
|
+
bin: this.config.claudeBin,
|
|
36
|
+
args: [
|
|
37
|
+
...this.config.claudeArgs,
|
|
38
|
+
'--settings',
|
|
39
|
+
this.settingsFile,
|
|
40
|
+
...(opts.resume === true ? ['--resume'] : []),
|
|
41
|
+
...(typeof opts.resume === 'string' ? ['--resume', opts.resume] : []),
|
|
42
|
+
...(opts.prompt === '' ? [] : [opts.prompt]),
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
normalizeHook(e: HookEvent): AdapterEvent {
|
|
48
|
+
const sessionID = e.payload['session_id'];
|
|
49
|
+
const transcript = e.payload['transcript_path'];
|
|
50
|
+
|
|
51
|
+
const base: AdapterEvent = {
|
|
52
|
+
kind: 'heartbeat',
|
|
53
|
+
...(typeof sessionID === 'string' ? { agentSessionID: sessionID } : {}),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const named: AdapterEvent = {
|
|
57
|
+
...base,
|
|
58
|
+
...(typeof transcript === 'string' ? { nameSource: transcript } : {}),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
switch (e.event) {
|
|
62
|
+
case 'SessionStart': {
|
|
63
|
+
return { ...named, kind: 'started' };
|
|
64
|
+
}
|
|
65
|
+
case 'Notification': {
|
|
66
|
+
const message = e.payload['message'];
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
...base,
|
|
70
|
+
kind: 'needs-input',
|
|
71
|
+
...(typeof message === 'string' && message !== ''
|
|
72
|
+
? { message, detail: truncateDetail(message) }
|
|
73
|
+
: {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
case 'Stop': {
|
|
77
|
+
const lastMessage = e.payload['last_assistant_message'];
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
...named,
|
|
81
|
+
kind: 'turn-done',
|
|
82
|
+
...(typeof lastMessage === 'string' && lastMessage !== ''
|
|
83
|
+
? { detail: truncateDetail(lastMessage) }
|
|
84
|
+
: {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
case 'UserPromptSubmit': {
|
|
88
|
+
const prompt = e.payload['prompt'];
|
|
89
|
+
const preview = typeof prompt === 'string' ? prompt.slice(0, 80) : '';
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
...named,
|
|
93
|
+
kind: 'prompt-submitted',
|
|
94
|
+
...(preview === '' ? {} : { message: preview, detail: truncateDetail(preview) }),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
case 'SessionEnd': {
|
|
98
|
+
return { ...base, kind: 'ended' };
|
|
99
|
+
}
|
|
100
|
+
default: {
|
|
101
|
+
return base;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Claude is the naming authority: /rename writes custom-title lines to the
|
|
107
|
+
// transcript, auto-summaries write summary lines. A custom title always
|
|
108
|
+
// wins; a summary never overrides a user-typed name.
|
|
109
|
+
async loadName(source: string, namedBy: 'user' | 'auto' | 'agent'): Promise<NameUpdate | null> {
|
|
110
|
+
try {
|
|
111
|
+
const proc = Bun.spawn(['grep', '-E', '"type":"(custom-title|summary)"', source], {
|
|
112
|
+
stdout: 'pipe',
|
|
113
|
+
stderr: 'ignore',
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const text = await new Response(proc.stdout).text();
|
|
117
|
+
|
|
118
|
+
let title: string | undefined;
|
|
119
|
+
let summary: string | undefined;
|
|
120
|
+
|
|
121
|
+
for (const line of text.split('\n')) {
|
|
122
|
+
if (line.trim() === '') {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const parsed: unknown = JSON.parse(line);
|
|
128
|
+
|
|
129
|
+
if (!isRecord(parsed)) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const customTitle = parsed['customTitle'];
|
|
134
|
+
const summaryText = parsed['summary'];
|
|
135
|
+
|
|
136
|
+
if (parsed['type'] === 'custom-title' && typeof customTitle === 'string') {
|
|
137
|
+
title = customTitle;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (parsed['type'] === 'summary' && typeof summaryText === 'string') {
|
|
141
|
+
summary = summaryText;
|
|
142
|
+
}
|
|
143
|
+
} catch {}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (title !== undefined && title !== '') {
|
|
147
|
+
return { name: title, namedBy: 'agent' };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (namedBy !== 'user' && summary !== undefined && summary !== '') {
|
|
151
|
+
return { name: summary };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return null;
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Shell command that re-opens this session outside atc (or anywhere).
|
|
161
|
+
buildResumeCommand(cwd: string, agentSessionID: string | undefined): string | null {
|
|
162
|
+
const resume =
|
|
163
|
+
agentSessionID === undefined ? 'claude --resume' : `claude --resume ${agentSessionID}`;
|
|
164
|
+
|
|
165
|
+
const quoted = cwd.replaceAll("'", String.raw`'\''`);
|
|
166
|
+
|
|
167
|
+
return `cd '${quoted}' && ${resume}`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Settings file injected into wrangled sessions via `claude --settings`.
|
|
172
|
+
// The user's own settings are untouched; these hooks only exist in sessions
|
|
173
|
+
// atc spawns, and identify themselves via ATC_SESSION_ID in the env.
|
|
174
|
+
function writeHookSettings(): string {
|
|
175
|
+
const cmd = buildCLICommand('hook-report');
|
|
176
|
+
const entry = [{ hooks: [{ type: 'command', command: cmd, timeout: 5 }] }];
|
|
177
|
+
|
|
178
|
+
const settings = {
|
|
179
|
+
hooks: {
|
|
180
|
+
// SessionStart carries the session id at spawn/resume time, before any
|
|
181
|
+
// interaction — without it a session only enters the fleet file after
|
|
182
|
+
// its first prompt/notification.
|
|
183
|
+
SessionStart: entry,
|
|
184
|
+
Notification: entry,
|
|
185
|
+
Stop: entry,
|
|
186
|
+
UserPromptSubmit: entry,
|
|
187
|
+
SessionEnd: entry,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Fleet status renders inside Claude Code's own status line; the injected
|
|
192
|
+
// command chains the user's configured statusline first, so mirror their
|
|
193
|
+
// padding.
|
|
194
|
+
let padding = 0;
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
|
198
|
+
const raw = readFileSync(settingsPath, 'utf8');
|
|
199
|
+
const user: unknown = JSON.parse(raw);
|
|
200
|
+
const statusLine = isRecord(user) ? user['statusLine'] : undefined;
|
|
201
|
+
const userPadding = isRecord(statusLine) ? statusLine['padding'] : undefined;
|
|
202
|
+
|
|
203
|
+
if (typeof userPadding === 'number') {
|
|
204
|
+
padding = userPadding;
|
|
205
|
+
}
|
|
206
|
+
} catch {}
|
|
207
|
+
|
|
208
|
+
const statusline = {
|
|
209
|
+
type: 'command',
|
|
210
|
+
command: buildCLICommand('statusline'),
|
|
211
|
+
padding,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const file = join(stateDir, 'hook-settings.json');
|
|
215
|
+
|
|
216
|
+
writeFileSync(file, JSON.stringify({ ...settings, statusLine: statusline }, null, 2));
|
|
217
|
+
|
|
218
|
+
return file;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function truncateDetail(text: string): string {
|
|
222
|
+
return text.length <= 600 ? text : `${text.slice(0, 599)}…`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Wrangled sessions invoke atc subcommands: under bun the CLI entry path is
|
|
226
|
+
// part of the command; a compiled binary is itself the entry.
|
|
227
|
+
function buildCLICommand(subcommand: string): string {
|
|
228
|
+
const exec = process.execPath;
|
|
229
|
+
|
|
230
|
+
if (basename(exec) === 'bun' || basename(exec) === 'bun.exe') {
|
|
231
|
+
return `"${exec}" "${join(import.meta.dir, 'cli.ts')}" ${subcommand}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return `"${exec}" ${subcommand}`;
|
|
235
|
+
}
|