@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 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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import "../src/cli.ts";
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
+ }