@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.
@@ -0,0 +1,131 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import type { HookEvent } from './hooks';
4
+ import { isRecord } from './report';
5
+ import type { FleetEntry } from './sessions';
6
+
7
+ /**
8
+ * Daemon state in one SQLite store: the restorable fleet, the hook-event
9
+ * debug trail, and the spawn-directory history. The statusline contract
10
+ * file (status.json) stays a plain file because reporters inside wrangled
11
+ * sessions read it without speaking to the daemon. An existing fleet.json
12
+ * seeds the fleet table once, so upgrading never loses a restorable fleet.
13
+ */
14
+ export class StateStore {
15
+ private readonly db: Database;
16
+
17
+ constructor(dbPath: string, legacyFleetPath?: string) {
18
+ this.db = new Database(dbPath, { create: true });
19
+
20
+ this.db.run('PRAGMA journal_mode = WAL;');
21
+
22
+ this.db.run(`
23
+ CREATE TABLE IF NOT EXISTS fleet (
24
+ claude_id TEXT PRIMARY KEY,
25
+ name TEXT NOT NULL,
26
+ cwd TEXT NOT NULL
27
+ );
28
+ CREATE TABLE IF NOT EXISTS events (
29
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
30
+ ts TEXT NOT NULL,
31
+ atc_id TEXT NOT NULL,
32
+ event TEXT NOT NULL,
33
+ message TEXT,
34
+ session_id TEXT
35
+ );
36
+ CREATE TABLE IF NOT EXISTS spawn_history (
37
+ cwd TEXT PRIMARY KEY,
38
+ last_spawn INTEGER NOT NULL
39
+ );
40
+ `);
41
+
42
+ if (legacyFleetPath !== undefined) {
43
+ this.adoptLegacyFleet(legacyFleetPath);
44
+ }
45
+ }
46
+
47
+ loadFleet(): FleetEntry[] {
48
+ const rows = this.db
49
+ .query<{ claude_id: string; name: string; cwd: string }, []>(
50
+ 'SELECT claude_id, name, cwd FROM fleet',
51
+ )
52
+ .all();
53
+
54
+ return rows.map((row) => ({ claudeId: row.claude_id, name: row.name, cwd: row.cwd }));
55
+ }
56
+
57
+ writeFleet(entries: readonly FleetEntry[]): void {
58
+ const replace = this.db.transaction((all: readonly FleetEntry[]) => {
59
+ this.db.run('DELETE FROM fleet');
60
+
61
+ const insert = this.db.query(
62
+ 'INSERT OR REPLACE INTO fleet (claude_id, name, cwd) VALUES (?1, ?2, ?3)',
63
+ );
64
+
65
+ for (const entry of all) {
66
+ insert.run(entry.claudeId, entry.name, entry.cwd);
67
+ }
68
+ });
69
+
70
+ replace(entries);
71
+ }
72
+
73
+ recordEvent(e: HookEvent): void {
74
+ const rawMessage = e.payload['message'];
75
+ const rawSessionID = e.payload['session_id'];
76
+ const message = typeof rawMessage === 'string' ? rawMessage : null;
77
+ const sessionID = typeof rawSessionID === 'string' ? rawSessionID : null;
78
+
79
+ this.db
80
+ .query(
81
+ 'INSERT INTO events (ts, atc_id, event, message, session_id) VALUES (?1, ?2, ?3, ?4, ?5)',
82
+ )
83
+ .run(new Date().toISOString(), e.atcId, e.event, message, sessionID);
84
+ }
85
+
86
+ recordSpawnDir(cwd: string): void {
87
+ this.db
88
+ .query(
89
+ 'INSERT INTO spawn_history (cwd, last_spawn) VALUES (?1, ?2) ON CONFLICT(cwd) DO UPDATE SET last_spawn = ?2',
90
+ )
91
+ .run(cwd, Date.now());
92
+ }
93
+
94
+ collectSpawnDirs(): string[] {
95
+ const rows = this.db
96
+ .query<{ cwd: string }, []>('SELECT cwd FROM spawn_history ORDER BY last_spawn DESC')
97
+ .all();
98
+
99
+ return rows.map((row) => row.cwd);
100
+ }
101
+
102
+ stop(): void {
103
+ this.db.close();
104
+ }
105
+
106
+ private adoptLegacyFleet(legacyFleetPath: string): void {
107
+ const count = this.db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM fleet').get();
108
+
109
+ if (count === null || count.n > 0 || !existsSync(legacyFleetPath)) {
110
+ return;
111
+ }
112
+
113
+ try {
114
+ const parsed: unknown = JSON.parse(readFileSync(legacyFleetPath, 'utf8'));
115
+
116
+ if (!Array.isArray(parsed)) {
117
+ return;
118
+ }
119
+
120
+ const entries = parsed.filter(
121
+ (entry): entry is FleetEntry =>
122
+ isRecord(entry) &&
123
+ typeof entry['name'] === 'string' &&
124
+ typeof entry['cwd'] === 'string' &&
125
+ typeof entry['claudeId'] === 'string',
126
+ );
127
+
128
+ this.writeFleet(entries);
129
+ } catch {}
130
+ }
131
+ }
@@ -0,0 +1,103 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { statusFile } from './config';
5
+ import { isRecord, sendReport } from './report';
6
+
7
+ /**
8
+ * Runs as the statusLine command injected into wrangled sessions. Chains the
9
+ * user's own statusline (from ~/.claude/settings.json), appends the atc fleet
10
+ * segment, and heartbeats the session id back to the atc socket. Always
11
+ * exits 0 so it never breaks the session it renders for.
12
+ */
13
+ export async function runStatusline(): Promise<void> {
14
+ const raw = await new Response(Bun.stdin.stream()).text();
15
+
16
+ const sock = process.env['ATC_SOCKET'];
17
+ const atcId = process.env['ATC_SESSION_ID'];
18
+
19
+ if (sock !== undefined && sock !== '' && atcId !== undefined && atcId !== '') {
20
+ let payload: Record<string, unknown> = {};
21
+
22
+ try {
23
+ const parsed: unknown = JSON.parse(raw);
24
+
25
+ if (isRecord(parsed)) {
26
+ payload = parsed;
27
+ }
28
+ } catch {}
29
+
30
+ const line = `${JSON.stringify({ atcId, event: 'Statusline', payload })}\n`;
31
+
32
+ await sendReport(sock, line, 500);
33
+ }
34
+
35
+ let chained = '';
36
+
37
+ try {
38
+ const settingsPath = join(homedir(), '.claude', 'settings.json');
39
+ const rawSettings = readFileSync(settingsPath, 'utf8');
40
+ const settings: unknown = JSON.parse(rawSettings);
41
+ const statusLine = isRecord(settings) ? settings['statusLine'] : undefined;
42
+ const cmd = isRecord(statusLine) ? statusLine['command'] : undefined;
43
+
44
+ if (typeof cmd === 'string' && cmd !== '' && !isSelfCommand(cmd)) {
45
+ const proc = Bun.spawn(['bash', '-c', cmd], {
46
+ stdin: new TextEncoder().encode(raw),
47
+ stdout: 'pipe',
48
+ stderr: 'ignore',
49
+ });
50
+
51
+ const timer = setTimeout(() => {
52
+ proc.kill();
53
+ }, 1500);
54
+
55
+ const text = await new Response(proc.stdout).text();
56
+
57
+ chained = text.trimEnd();
58
+
59
+ clearTimeout(timer);
60
+ }
61
+ } catch {}
62
+
63
+ let segment = '';
64
+
65
+ try {
66
+ const status: unknown = JSON.parse(readFileSync(statusFile, 'utf8'));
67
+
68
+ if (isRecord(status)) {
69
+ const needsYou = typeof status['needs_you'] === 'number' ? status['needs_you'] : 0;
70
+ const done = typeof status['done'] === 'number' ? status['done'] : 0;
71
+ const running = typeof status['running'] === 'number' ? status['running'] : 0;
72
+ const urgent = typeof status['urgent'] === 'string' ? status['urgent'] : '';
73
+ const parts: string[] = [];
74
+
75
+ if (needsYou > 0) {
76
+ const who = urgent === '' ? '' : `: ${urgent}`;
77
+
78
+ parts.push(`\u001B[1;31m● ${needsYou} need you${who}\u001B[0m`);
79
+ }
80
+
81
+ if (done > 0) {
82
+ parts.push(`\u001B[32m✓ ${done}\u001B[0m`);
83
+ }
84
+
85
+ if (running > 0) {
86
+ parts.push(`\u001B[36m◐ ${running}\u001B[0m`);
87
+ }
88
+
89
+ segment = parts.join(' ');
90
+ }
91
+ } catch {}
92
+
93
+ const line = [chained, segment].filter((part) => part !== '').join(' \u001B[90m▏\u001B[0m ');
94
+
95
+ console.log(line);
96
+ process.exit(0);
97
+ }
98
+
99
+ // A user statusline that is atc's own injected command would chain into
100
+ // itself; the injected command always ends with the bare subcommand.
101
+ function isSelfCommand(cmd: string): boolean {
102
+ return cmd.includes('statusline.ts') || cmd.trimEnd().endsWith(' statusline');
103
+ }
package/src/ui.ts ADDED
@@ -0,0 +1,341 @@
1
+ import { formatDir } from './dirs';
2
+ import type { SessionState } from './sessions';
3
+
4
+ // The slice of a session the drawing layer needs; satisfied by both the
5
+ // daemon's sessions and the wire descriptors a client mirrors.
6
+ interface SessionView {
7
+ readonly name: string;
8
+ readonly cwd: string;
9
+ readonly state: SessionState;
10
+ readonly unread: boolean;
11
+ readonly lastMsg: string;
12
+ }
13
+
14
+ const ESC = '\u001B';
15
+
16
+ export const ansi = {
17
+ altScreenOn: `${ESC}[?1049h`,
18
+ altScreenOff: `${ESC}[?1049l`,
19
+ clear: `${ESC}[2J${ESC}[H`,
20
+ hideCursor: `${ESC}[?25l`,
21
+ showCursor: `${ESC}[?25h`,
22
+ saveCursor: `${ESC}7`,
23
+ restoreCursor: `${ESC}8`,
24
+ reset: `${ESC}[0m`,
25
+ moveTo: (row: number, col: number) => `${ESC}[${row};${col}H`,
26
+ };
27
+
28
+ const GLYPH: Record<SessionState, string> = {
29
+ needs_you: `${ESC}[31m●${ESC}[0m`,
30
+ running: `${ESC}[36m◐${ESC}[0m`,
31
+ done: `${ESC}[32m✓${ESC}[0m`,
32
+ exited: `${ESC}[90m✗${ESC}[0m`,
33
+ };
34
+
35
+ const STATE_LABEL: Record<SessionState, string> = {
36
+ needs_you: 'NEEDS YOU',
37
+ running: 'running',
38
+ done: 'done',
39
+ exited: 'exited',
40
+ };
41
+
42
+ function out(s: string) {
43
+ process.stdout.write(s);
44
+ }
45
+
46
+ function truncate(s: string, max: number): string {
47
+ // oxlint-disable-next-line no-control-regex -- stripping ANSI/newline control bytes is the point
48
+ const clean = s.replaceAll(/[\r\n\u001B]+/gu, ' ');
49
+
50
+ if (max <= 0) {
51
+ return '';
52
+ }
53
+
54
+ return clean.length <= max ? clean : `${clean.slice(0, Math.max(0, max - 1))}…`;
55
+ }
56
+
57
+ export function cols(): number {
58
+ return process.stdout.columns || 80;
59
+ }
60
+
61
+ export function rows(): number {
62
+ return process.stdout.rows || 24;
63
+ }
64
+
65
+ export interface StatusView {
66
+ readonly counts: Readonly<Record<SessionState, number>>;
67
+ readonly focusedName: string | null;
68
+ readonly urgentName: string | null;
69
+ }
70
+
71
+ export function drawStatusBar(view: StatusView) {
72
+ const c = view.counts;
73
+ const width = cols();
74
+ const left = ` atc ▏${view.focusedName ?? 'no session'} `;
75
+ const parts: string[] = [];
76
+
77
+ if (c.needs_you > 0) {
78
+ const who = view.urgentName === null ? '' : `: ${view.urgentName}`;
79
+
80
+ parts.push(`● ${c.needs_you} need you${who}`);
81
+ }
82
+
83
+ if (c.done > 0) {
84
+ parts.push(`✓ ${c.done} done`);
85
+ }
86
+
87
+ if (c.running > 0) {
88
+ parts.push(`◐ ${c.running} running`);
89
+ }
90
+
91
+ if (c.exited > 0) {
92
+ parts.push(`✗ ${c.exited}`);
93
+ }
94
+
95
+ const joined = parts.join(' ▏');
96
+ const right = ` ${joined === '' ? 'idle' : joined} ▏^Space `;
97
+ const pad = Math.max(1, width - left.length - right.length);
98
+ const bg = c.needs_you > 0 ? `${ESC}[1;97;41m` : `${ESC}[30;47m`;
99
+ const text = truncate(left + ' '.repeat(pad) + right, width).padEnd(width);
100
+
101
+ out(ansi.saveCursor + ansi.moveTo(rows(), 1) + bg + text + ansi.reset + ansi.restoreCursor);
102
+ }
103
+
104
+ // All box rows are built as {styled, plainWidth} pairs so centering and edge
105
+ // alignment never depend on ANSI-stripped length math at draw time.
106
+ interface Row {
107
+ readonly styled: string;
108
+ readonly width: number;
109
+ }
110
+
111
+ function drawBox(rowsList: readonly Row[]) {
112
+ const boxWidth = Math.max(...rowsList.map((r) => r.width));
113
+ const top = Math.max(1, Math.floor((rows() - 1 - rowsList.length) / 2));
114
+ const left = Math.max(1, Math.floor((cols() - boxWidth) / 2));
115
+ let buf = ansi.hideCursor;
116
+
117
+ for (const [i, r] of rowsList.entries()) {
118
+ buf += ansi.moveTo(top + i, left) + r.styled;
119
+ }
120
+
121
+ out(buf);
122
+ }
123
+
124
+ function boxTop(width: number, title: string): Row {
125
+ const t = ` ${title} `;
126
+
127
+ return { styled: `┌${t}${'─'.repeat(Math.max(0, width - 2 - t.length))}┐`, width };
128
+ }
129
+
130
+ function boxDivider(width: number): Row {
131
+ return { styled: `├${'─'.repeat(width - 2)}┤`, width };
132
+ }
133
+
134
+ function boxBottom(width: number): Row {
135
+ return { styled: `└${'─'.repeat(width - 2)}┘`, width };
136
+ }
137
+
138
+ function boxRow(width: number, styledContent: string, contentPlainLen: number): Row {
139
+ const pad = ' '.repeat(Math.max(0, width - 4 - contentPlainLen));
140
+
141
+ return { styled: `│ ${styledContent}${pad} │`, width };
142
+ }
143
+
144
+ function dimRow(width: number, text: string): Row {
145
+ const t = truncate(text, width - 4);
146
+
147
+ return boxRow(width, `${ESC}[90m${t}${ESC}[0m`, t.length);
148
+ }
149
+
150
+ // Selection-dependent hints need the liveness facts of each row.
151
+ export interface OverlaySessionView extends SessionView {
152
+ readonly alive: boolean;
153
+ readonly kind: 'pty' | 'jsonl';
154
+ readonly resumable: boolean;
155
+ }
156
+
157
+ export interface OverlayView {
158
+ sessions: readonly OverlaySessionView[];
159
+ selected: number;
160
+ confirmKill: boolean;
161
+ filter: string | null;
162
+ }
163
+
164
+ export function drawOverlay(view: OverlayView) {
165
+ const width = Math.min(cols() - 4, 90);
166
+ const rowsList: Row[] = [boxTop(width, 'sessions')];
167
+
168
+ if (view.sessions.length === 0) {
169
+ const empty = view.filter === null ? 'no sessions — n to spawn' : 'no matches';
170
+
171
+ rowsList.push(dimRow(width, empty));
172
+ }
173
+
174
+ for (const [i, s] of view.sessions.entries()) {
175
+ const sel = i === view.selected;
176
+ const name = truncate(s.name, 16).padEnd(16);
177
+ const state = STATE_LABEL[s.state].padEnd(9);
178
+ const dir = truncate(formatDir(s.cwd), 18).padEnd(18);
179
+ const msgWidth = Math.max(4, width - 4 - 2 - 17 - 10 - 19);
180
+ const msg = truncate(s.lastMsg, msgWidth).padEnd(msgWidth);
181
+ const unread = s.unread ? `${ESC}[1;33m!${ESC}[0m` : ' ';
182
+ const body = `${name} ${state} ${dir} ${msg}`;
183
+ const styledBody = sel ? `${ESC}[7m${body}${ESC}[0m` : body;
184
+
185
+ rowsList.push(boxRow(width, `${GLYPH[s.state]}${unread}${styledBody}`, 2 + body.length));
186
+ }
187
+
188
+ rowsList.push(boxDivider(width));
189
+
190
+ if (view.filter !== null) {
191
+ const pattern = truncate(view.filter, width - 8);
192
+
193
+ rowsList.push(
194
+ boxRow(width, `/ ${pattern}${ESC}[93m█${ESC}[0m`, 2 + pattern.length + 1),
195
+ boxDivider(width),
196
+ );
197
+ }
198
+
199
+ let hint = buildOverlayHint(view.sessions[view.selected]);
200
+
201
+ if (view.filter !== null) {
202
+ hint = 'type to filter · ↑↓ move · ⏎ attach · esc clear';
203
+ }
204
+
205
+ if (view.confirmKill) {
206
+ hint = 'kill selected session? y / n';
207
+ }
208
+
209
+ rowsList.push(dimRow(width, hint), boxBottom(width));
210
+
211
+ drawBox(rowsList);
212
+ }
213
+
214
+ const GLOBAL_HINT = 'n new · ? keys';
215
+
216
+ // Only the actions valid for the selected row appear; the full reference
217
+ // lives behind ?.
218
+ function buildOverlayHint(s: OverlaySessionView | undefined): string {
219
+ if (s === undefined) {
220
+ return GLOBAL_HINT;
221
+ }
222
+
223
+ const actions: string[] = [];
224
+
225
+ if (s.kind === 'jsonl' && s.alive) {
226
+ actions.push('P reattach', 'K kill');
227
+ } else if (s.alive) {
228
+ actions.push('⏎ attach');
229
+
230
+ if (s.state === 'needs_you') {
231
+ actions.push('a ack');
232
+ }
233
+
234
+ actions.push('H headless', 'y yank', 'Y eject', 'K kill');
235
+ } else {
236
+ if (s.resumable) {
237
+ actions.push('P revive', 'y yank');
238
+ }
239
+
240
+ actions.push('K forget');
241
+ }
242
+
243
+ return `${actions.join(' · ')} ▏ ${GLOBAL_HINT}`;
244
+ }
245
+
246
+ export function drawHelp() {
247
+ const width = Math.min(cols() - 4, 60);
248
+
249
+ const lines = [
250
+ '⏎ attach the selected session',
251
+ 'a ack its notification without attaching',
252
+ 'H eject to a headless run',
253
+ 'P revive a dead or headless session',
254
+ 'y yank the resume command to the clipboard',
255
+ 'Y yank the resume command, then kill here',
256
+ 'K kill (K again on a dead session forgets it)',
257
+ 'n new session',
258
+ 'r adopt an external session',
259
+ '/ filter · ↑↓/jk move · q quit',
260
+ ];
261
+
262
+ const rowsList: Row[] = [boxTop(width, 'keys')];
263
+
264
+ for (const line of lines) {
265
+ rowsList.push(boxRow(width, line, line.length));
266
+ }
267
+
268
+ rowsList.push(boxDivider(width), dimRow(width, 'esc/? back'), boxBottom(width));
269
+
270
+ drawBox(rowsList);
271
+ }
272
+
273
+ export interface PickerView {
274
+ title: string;
275
+ items: readonly string[];
276
+ selected: number;
277
+ input: string;
278
+ placeholder?: string;
279
+ hint: string;
280
+ }
281
+
282
+ export function drawPicker(view: PickerView) {
283
+ const width = Math.min(cols() - 4, 90);
284
+ const rowsList: Row[] = [boxTop(width, view.title)];
285
+ const shown = view.items.slice(0, 10);
286
+
287
+ for (const [i, item] of shown.entries()) {
288
+ const sel = i === view.selected;
289
+ const t = truncate(item, width - 4);
290
+ const styled = sel ? `${ESC}[7m${t.padEnd(width - 4)}${ESC}[0m` : t;
291
+ const plainLen = sel ? width - 4 : t.length;
292
+
293
+ rowsList.push(boxRow(width, styled, plainLen));
294
+ }
295
+
296
+ const inputShown = truncate(view.input, width - 8);
297
+ let inputStyled: string;
298
+ let inputLen: number;
299
+
300
+ if (inputShown === '' && view.placeholder !== undefined && view.placeholder !== '') {
301
+ const ph = truncate(view.placeholder, width - 8);
302
+
303
+ inputStyled = `> ${ESC}[93m█${ESC}[0m${ESC}[90m ${ph}${ESC}[0m`;
304
+ inputLen = 2 + 1 + 1 + ph.length;
305
+ } else {
306
+ inputStyled = `> ${inputShown}${ESC}[93m█${ESC}[0m`;
307
+ inputLen = 2 + inputShown.length + 1;
308
+ }
309
+
310
+ rowsList.push(
311
+ boxRow(width, inputStyled, inputLen),
312
+ boxDivider(width),
313
+ dimRow(width, view.hint),
314
+ boxBottom(width),
315
+ );
316
+
317
+ drawBox(rowsList);
318
+ }
319
+
320
+ export function drawHome(fleetCount = 0) {
321
+ out(ansi.clear + ansi.hideCursor);
322
+
323
+ const msgs = [
324
+ 'atc — control tower for claude sessions',
325
+ '',
326
+ 'n spawn a session',
327
+ 'r adopt an existing session (claude --resume)',
328
+ ...(fleetCount > 0 ? [`R restore last fleet (${fleetCount} sessions)`] : []),
329
+ '^Space session list',
330
+ 'q quit',
331
+ ];
332
+
333
+ const top = Math.max(1, Math.floor((rows() - 1 - msgs.length) / 2));
334
+
335
+ for (const [i, m] of msgs.entries()) {
336
+ const left = Math.max(1, Math.floor((cols() - m.length) / 2));
337
+ const styled = i === 0 ? `${ESC}[1m${m}${ESC}[0m` : `${ESC}[90m${m}${ESC}[0m`;
338
+
339
+ out(ansi.moveTo(top + i, left) + styled);
340
+ }
341
+ }