@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,568 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { spawn } from 'bun-pty';
4
+ import type { IPty } from 'bun-pty';
5
+ import type { AgentAdapter } from './agent-adapter';
6
+ import { collectCleanEnv } from './collect-clean-env';
7
+ import { socketPath, stateDir, statusFile } from './config';
8
+ import type { HookEvent } from './hooks';
9
+ import { isRecord } from './report';
10
+
11
+ export type SessionState = 'running' | 'needs_you' | 'done' | 'exited';
12
+
13
+ export type SessionEventKind = 'added' | 'state' | 'renamed' | 'removed';
14
+
15
+ // The over-the-wire view of a session: everything but the PTY handle, plus
16
+ // the surface kind.
17
+ export interface SessionDescriptor {
18
+ readonly id: string;
19
+ readonly name: string;
20
+ readonly cwd: string;
21
+ readonly state: SessionState;
22
+ readonly unread: boolean;
23
+ readonly lastMsg: string;
24
+ readonly lastDetail?: string;
25
+ readonly claudeId?: string;
26
+ readonly namedBy: 'user' | 'auto' | 'agent';
27
+ readonly createdAt: number;
28
+ readonly kind: 'pty' | 'jsonl';
29
+ readonly alive: boolean;
30
+ }
31
+
32
+ export interface Session {
33
+ id: string;
34
+ name: string;
35
+ cwd: string;
36
+ kind: 'pty' | 'jsonl';
37
+ pty: IPty | null;
38
+ state: SessionState;
39
+ unread: boolean;
40
+ lastMsg: string;
41
+ lastDetail?: string;
42
+ claudeId?: string;
43
+ transcriptSource?: string;
44
+
45
+ // who last named this session: the agent's own rename beats everything, a
46
+ // user-typed spawn name beats auto-summaries.
47
+ namedBy: 'user' | 'auto' | 'agent';
48
+ createdAt: number;
49
+ }
50
+
51
+ let counter = 0;
52
+
53
+ export interface FleetEntry {
54
+ readonly name: string;
55
+ readonly cwd: string;
56
+ readonly claudeId: string;
57
+ }
58
+
59
+ const fleetFile = join(stateDir, 'fleet.json');
60
+
61
+ export interface FleetStore {
62
+ readonly loadFleet: () => FleetEntry[];
63
+ readonly writeFleet: (entries: readonly FleetEntry[]) => void;
64
+ }
65
+
66
+ const jsonFleetStore: FleetStore = {
67
+ loadFleet: () => loadFleet(),
68
+ writeFleet: (entries) => {
69
+ try {
70
+ writeFileSync(fleetFile, JSON.stringify(entries, null, 2));
71
+ } catch {}
72
+ },
73
+ };
74
+
75
+ function loadFleet(): FleetEntry[] {
76
+ try {
77
+ const parsed: unknown = JSON.parse(readFileSync(fleetFile, 'utf8'));
78
+
79
+ if (!Array.isArray(parsed)) {
80
+ return [];
81
+ }
82
+
83
+ return parsed.filter(
84
+ (entry): entry is FleetEntry =>
85
+ isRecord(entry) &&
86
+ typeof entry['name'] === 'string' &&
87
+ typeof entry['cwd'] === 'string' &&
88
+ typeof entry['claudeId'] === 'string',
89
+ );
90
+ } catch {
91
+ return [];
92
+ }
93
+ }
94
+
95
+ export class SessionManager {
96
+ sessions: Session[] = [];
97
+
98
+ focusedId: string | null = null;
99
+
100
+ onOutput: (s: Session, data: string) => void = () => {};
101
+
102
+ onChange: () => void = () => {};
103
+
104
+ onEvent: (kind: SessionEventKind, s: Session) => void = () => {};
105
+
106
+ private readonly adapter: AgentAdapter;
107
+
108
+ private readonly store: FleetStore;
109
+
110
+ private readonly statusPath: string;
111
+
112
+ constructor(adapter: AgentAdapter, store: FleetStore = jsonFleetStore, statusPath = statusFile) {
113
+ this.adapter = adapter;
114
+ this.store = store;
115
+ this.statusPath = statusPath;
116
+ }
117
+
118
+ // Hands a live terminal session off to a headless run: the terminal dies,
119
+ // the record lives on as a jsonl session and keeps its screen history.
120
+ yankHeadless(id: string): Session | null {
121
+ const s = this.sessions.find((x) => x.id === id);
122
+
123
+ if (!s || s.pty === null || s.claudeId === undefined) {
124
+ return null;
125
+ }
126
+
127
+ const pty = s.pty;
128
+
129
+ s.pty = null;
130
+ s.kind = 'jsonl';
131
+ s.state = 'running';
132
+ s.lastMsg = 'ejected to headless';
133
+
134
+ pty.kill();
135
+ this.onEvent('state', s);
136
+ this.emitChange();
137
+
138
+ return s;
139
+ }
140
+
141
+ // Adopts a headless session back into a terminal: a fresh PTY resumes the
142
+ // same agent session id.
143
+ adoptTerminal(id: string, cols: number, rows: number): Session | null {
144
+ const s = this.sessions.find((x) => x.id === id);
145
+
146
+ if (!s || s.pty !== null || s.claudeId === undefined) {
147
+ return null;
148
+ }
149
+
150
+ const plan = this.adapter.planSpawn({ prompt: '', resume: s.claudeId });
151
+
152
+ const pty = spawn(plan.bin, plan.args, {
153
+ name: 'xterm-256color',
154
+ cols,
155
+ rows,
156
+ cwd: s.cwd,
157
+ env: collectCleanEnv({ ATC_SESSION_ID: s.id, ATC_SOCKET: socketPath }),
158
+ });
159
+
160
+ s.pty = pty;
161
+ s.kind = 'pty';
162
+ s.state = 'running';
163
+ s.lastMsg = 'revived';
164
+
165
+ pty.onData((d) => {
166
+ this.onOutput(s, d);
167
+ });
168
+
169
+ pty.onExit(() => {
170
+ s.pty = null;
171
+
172
+ if (s.kind === 'pty' && s.state !== 'exited') {
173
+ s.state = 'exited';
174
+ s.unread = this.focusedId !== s.id;
175
+ s.lastMsg = 'process exited';
176
+ }
177
+
178
+ this.onEvent('state', s);
179
+ this.emitChange();
180
+ });
181
+
182
+ this.writeFleet();
183
+ this.onEvent('state', s);
184
+ this.emitChange();
185
+
186
+ return s;
187
+ }
188
+
189
+ // Reports a headless run's lifecycle into the session state machine.
190
+ updateSurfaceState(id: string, state: SessionState, msg: string) {
191
+ const s = this.sessions.find((x) => x.id === id);
192
+
193
+ if (!s || s.kind !== 'jsonl') {
194
+ return;
195
+ }
196
+
197
+ s.state = state;
198
+ s.lastMsg = msg;
199
+ s.unread = this.focusedId !== s.id;
200
+
201
+ this.onEvent('state', s);
202
+ this.emitChange();
203
+ }
204
+
205
+ // The detector-stack screen tier reports through here: only flips between
206
+ // running and needs_you — hook-driven done/exited states always win.
207
+ updateAttention(id: string, state: 'needs_you' | 'running', msg: string) {
208
+ const s = this.sessions.find((x) => x.id === id);
209
+
210
+ if (!s || s.pty === null || s.state === state) {
211
+ return;
212
+ }
213
+
214
+ if (s.state !== 'running' && s.state !== 'needs_you') {
215
+ return;
216
+ }
217
+
218
+ s.state = state;
219
+ s.lastMsg = msg;
220
+
221
+ if (state === 'needs_you') {
222
+ s.unread = this.focusedId !== s.id;
223
+ }
224
+
225
+ this.onEvent('state', s);
226
+ this.emitChange();
227
+ }
228
+
229
+ get focused(): Session | null {
230
+ return this.sessions.find((s) => s.id === this.focusedId) ?? null;
231
+ }
232
+
233
+ // resume: true opens the agent's own session picker; a string resumes that
234
+ // specific agent session id (fleet restore).
235
+ spawn(
236
+ cwd: string,
237
+ name: string,
238
+ prompt: string,
239
+ cols: number,
240
+ rows: number,
241
+ resume: boolean | string = false,
242
+ namedBy: 'user' | 'auto' = 'auto',
243
+ ): Session {
244
+ const id = `s${++counter}-${Date.now().toString(36)}`;
245
+ const plan = this.adapter.planSpawn({ prompt, resume });
246
+
247
+ const pty = spawn(plan.bin, plan.args, {
248
+ name: 'xterm-256color',
249
+ cols,
250
+ rows,
251
+ cwd,
252
+ env: collectCleanEnv({ ATC_SESSION_ID: id, ATC_SOCKET: socketPath }),
253
+ });
254
+
255
+ let initialMsg = prompt;
256
+
257
+ if (initialMsg === '') {
258
+ initialMsg = resume === false ? 'started' : 'adopting…';
259
+ }
260
+
261
+ const session: Session = {
262
+ id,
263
+ name,
264
+ cwd,
265
+ kind: 'pty',
266
+ pty,
267
+ state: 'running',
268
+ unread: false,
269
+ lastMsg: initialMsg,
270
+ ...(typeof resume === 'string' ? { claudeId: resume } : {}),
271
+ namedBy,
272
+ createdAt: Date.now(),
273
+ };
274
+
275
+ pty.onData((d) => {
276
+ this.onOutput(session, d);
277
+ });
278
+
279
+ pty.onExit(() => {
280
+ session.pty = null;
281
+
282
+ // A session mid-handoff keeps its headless state; the terminal dying
283
+ // is expected there, not an exit.
284
+ if (session.kind === 'pty' && session.state !== 'exited') {
285
+ session.state = 'exited';
286
+ session.unread = this.focusedId !== session.id;
287
+ session.lastMsg = 'process exited';
288
+ }
289
+
290
+ this.onEvent('state', session);
291
+ this.emitChange();
292
+ });
293
+
294
+ this.sessions.push(session);
295
+ this.writeFleet();
296
+ this.writeStatus();
297
+ this.onEvent('added', session);
298
+
299
+ return session;
300
+ }
301
+
302
+ collectDescriptors(): SessionDescriptor[] {
303
+ return this.sessions.map((s) => ({
304
+ id: s.id,
305
+ name: s.name,
306
+ cwd: s.cwd,
307
+ state: s.state,
308
+ unread: s.unread,
309
+ lastMsg: s.lastMsg,
310
+ ...(s.lastDetail === undefined ? {} : { lastDetail: s.lastDetail }),
311
+ ...(s.claudeId === undefined ? {} : { claudeId: s.claudeId }),
312
+ namedBy: s.namedBy,
313
+ createdAt: s.createdAt,
314
+ kind: s.kind,
315
+ alive: s.pty !== null || (s.kind === 'jsonl' && s.state !== 'exited'),
316
+ }));
317
+ }
318
+
319
+ applyHook(e: HookEvent) {
320
+ const s = this.sessions.find((x) => x.id === e.atcId);
321
+
322
+ // Reporters belong to the terminal process; once a session is headless,
323
+ // late reports from the dying terminal must not clobber its state.
324
+ if (!s || s.kind === 'jsonl') {
325
+ return;
326
+ }
327
+
328
+ const ev = this.adapter.normalizeHook(e);
329
+ const focused = this.focusedId === s.id;
330
+ let dirty = false;
331
+
332
+ if (ev.agentSessionID !== undefined && s.claudeId !== ev.agentSessionID) {
333
+ s.claudeId = ev.agentSessionID;
334
+
335
+ this.writeFleet();
336
+
337
+ dirty = true;
338
+ }
339
+
340
+ if (ev.detail !== undefined) {
341
+ s.lastDetail = ev.detail;
342
+ }
343
+
344
+ if (ev.nameSource !== undefined) {
345
+ s.transcriptSource = ev.nameSource;
346
+ void this.refreshName(s, ev.nameSource);
347
+ }
348
+
349
+ switch (ev.kind) {
350
+ case 'started': {
351
+ if (s.lastMsg === 'adopting…') {
352
+ s.lastMsg = 'adopted';
353
+ dirty = true;
354
+ }
355
+
356
+ break;
357
+ }
358
+ case 'needs-input': {
359
+ s.state = 'needs_you';
360
+ s.unread = !focused;
361
+ s.lastMsg = ev.message ?? 'needs input';
362
+ dirty = true;
363
+ break;
364
+ }
365
+ case 'turn-done': {
366
+ s.state = 'done';
367
+ s.unread = !focused;
368
+ s.lastMsg = 'turn done';
369
+ dirty = true;
370
+ break;
371
+ }
372
+ case 'prompt-submitted': {
373
+ s.state = 'running';
374
+ s.unread = false;
375
+ s.lastMsg = ev.message ?? 'working';
376
+ dirty = true;
377
+ break;
378
+ }
379
+
380
+ // A live terminal can report an end without dying — resuming claude
381
+ // closes the superseded session while its process stays interactive.
382
+ // Exited is reserved for a gone terminal; onExit owns that transition.
383
+ case 'ended': {
384
+ if (s.pty === null) {
385
+ s.state = 'exited';
386
+ }
387
+
388
+ s.unread = false;
389
+ s.lastMsg = 'session ended';
390
+ dirty = true;
391
+ break;
392
+ }
393
+
394
+ // Heartbeats only matter for the agent-session-id capture above.
395
+ case 'heartbeat': {
396
+ break;
397
+ }
398
+ }
399
+
400
+ if (dirty) {
401
+ this.onEvent('state', s);
402
+ this.emitChange();
403
+ }
404
+ }
405
+
406
+ private async refreshName(s: Session, source: string) {
407
+ const update = await this.adapter.loadName(source, s.namedBy);
408
+
409
+ if (update === null || update.name === '' || update.name === s.name) {
410
+ return;
411
+ }
412
+
413
+ s.name = update.name;
414
+
415
+ if (update.namedBy !== undefined) {
416
+ s.namedBy = update.namedBy;
417
+ }
418
+
419
+ this.writeFleet();
420
+ this.onEvent('renamed', s);
421
+ this.emitChange();
422
+ }
423
+
424
+ // Shell command that re-opens this session outside atc (or anywhere).
425
+ buildResumeCommand(id: string): string | null {
426
+ const s = this.sessions.find((x) => x.id === id);
427
+
428
+ if (!s) {
429
+ return null;
430
+ }
431
+
432
+ return this.adapter.buildResumeCommand(s.cwd, s.claudeId);
433
+ }
434
+
435
+ attach(id: string) {
436
+ const s = this.sessions.find((x) => x.id === id);
437
+
438
+ if (!s) {
439
+ return;
440
+ }
441
+
442
+ s.unread = false;
443
+
444
+ // Attaching answers the attention request: a still-pending prompt
445
+ // re-flags it via the next notification.
446
+ if (s.state === 'needs_you') {
447
+ s.state = 'running';
448
+ s.lastMsg = 'attached';
449
+ }
450
+
451
+ this.onEvent('state', s);
452
+ this.emitChange();
453
+ }
454
+
455
+ ack(id: string) {
456
+ const s = this.sessions.find((x) => x.id === id);
457
+
458
+ if (s) {
459
+ s.unread = false;
460
+
461
+ this.onEvent('state', s);
462
+ }
463
+
464
+ this.emitChange();
465
+ }
466
+
467
+ kill(id: string) {
468
+ const s = this.sessions.find((x) => x.id === id);
469
+
470
+ if (!s) {
471
+ return;
472
+ }
473
+
474
+ if (s.pty) {
475
+ s.pty.kill();
476
+
477
+ s.pty = null;
478
+ s.state = 'exited';
479
+ s.lastMsg = 'killed';
480
+
481
+ this.onEvent('state', s);
482
+ } else {
483
+ this.sessions = this.sessions.filter((x) => x.id !== id);
484
+
485
+ if (this.focusedId === id) {
486
+ this.focusedId = null;
487
+ }
488
+
489
+ this.onEvent('removed', s);
490
+ }
491
+
492
+ this.writeFleet();
493
+ this.emitChange();
494
+ }
495
+
496
+ killAll() {
497
+ for (const s of this.sessions) {
498
+ s.pty?.kill();
499
+ }
500
+ }
501
+
502
+ private emitChange() {
503
+ this.writeStatus();
504
+ this.onChange();
505
+ }
506
+
507
+ // Consumed by the injected statusline command so wrangled sessions render
508
+ // fleet state inside their own status line.
509
+ writeStatus() {
510
+ const c = this.countStates();
511
+ const urgent = this.sortSessions().find((s) => s.state === 'needs_you');
512
+
513
+ try {
514
+ writeFileSync(this.statusPath, JSON.stringify({ ...c, urgent: urgent?.name ?? null }));
515
+ } catch {}
516
+ }
517
+
518
+ // Persisted continuously so a crash (or quit) leaves a restorable fleet.
519
+ // Deliberate kills rewrite the file; unexpected session/atc deaths do not,
520
+ // so the last known fleet survives for `R` restore.
521
+ writeFleet() {
522
+ const fleet: FleetEntry[] = [];
523
+
524
+ for (const s of this.sessions) {
525
+ const live = s.pty !== null || (s.kind === 'jsonl' && s.state !== 'exited');
526
+
527
+ if (live && s.claudeId !== undefined) {
528
+ fleet.push({ name: s.name, cwd: s.cwd, claudeId: s.claudeId });
529
+ }
530
+ }
531
+
532
+ this.store.writeFleet(fleet);
533
+ }
534
+
535
+ countStates() {
536
+ return countSessionStates(this.sessions);
537
+ }
538
+
539
+ sortSessions(): Session[] {
540
+ return sortSessionViews(this.sessions);
541
+ }
542
+ }
543
+
544
+ export function countSessionStates(
545
+ list: readonly { readonly state: SessionState }[],
546
+ ): Record<SessionState, number> {
547
+ const c = { needs_you: 0, running: 0, done: 0, exited: 0 };
548
+
549
+ for (const s of list) {
550
+ c[s.state]++;
551
+ }
552
+
553
+ return c;
554
+ }
555
+
556
+ // Overlay order: who needs you first, then finished turns, then busy, then dead.
557
+ export function sortSessionViews<
558
+ T extends { readonly state: SessionState; readonly createdAt: number },
559
+ >(list: readonly T[]): T[] {
560
+ const rank: Record<SessionState, number> = {
561
+ needs_you: 0,
562
+ done: 1,
563
+ running: 2,
564
+ exited: 3,
565
+ };
566
+
567
+ return [...list].toSorted((a, b) => rank[a.state] - rank[b.state] || a.createdAt - b.createdAt);
568
+ }
@@ -0,0 +1,160 @@
1
+ import { query } from '@anthropic-ai/claude-agent-sdk';
2
+ import { collectCleanEnv } from './collect-clean-env';
3
+ import { isRecord } from './report';
4
+
5
+ const PERMISSION_MODES = [
6
+ 'default',
7
+ 'acceptEdits',
8
+ 'bypassPermissions',
9
+ 'plan',
10
+ 'auto',
11
+ 'dontAsk',
12
+ ] as const;
13
+
14
+ interface HeadlessRunOptions {
15
+ readonly cwd: string;
16
+ readonly prompt: string;
17
+ readonly resume?: string;
18
+ readonly permissionMode?: string;
19
+ }
20
+
21
+ interface HeadlessRunHooks {
22
+ readonly onOutput: (text: string) => void;
23
+ readonly onDone: (summary: string) => void;
24
+ readonly onNeedsYou: (msg: string) => void;
25
+ }
26
+
27
+ interface HeadlessRunHandle {
28
+ readonly stop: () => void;
29
+ }
30
+
31
+ /**
32
+ * Runs one headless Agent SDK turn over a session, rendering its structured
33
+ * messages as plain lines into the session's output pipe. The run ends in
34
+ * done (with the result as the summary) or needs_you (errors, turn limits,
35
+ * anything a human must look at).
36
+ */
37
+ export function startHeadlessRun(
38
+ opts: HeadlessRunOptions,
39
+ hooks: HeadlessRunHooks,
40
+ ): HeadlessRunHandle {
41
+ const controller = new AbortController();
42
+
43
+ void (async () => {
44
+ try {
45
+ const mode = PERMISSION_MODES.find((m) => m === opts.permissionMode);
46
+ let stderrTail = '';
47
+
48
+ const stream = query({
49
+ prompt: opts.prompt,
50
+ options: {
51
+ cwd: opts.cwd,
52
+ env: collectCleanEnv(),
53
+ abortController: controller,
54
+ stderr: (data: string) => {
55
+ stderrTail = `${stderrTail}${data}`.slice(-2000);
56
+ },
57
+ ...(opts.resume === undefined ? {} : { resume: opts.resume }),
58
+ ...(mode === undefined ? {} : { permissionMode: mode }),
59
+ },
60
+ });
61
+
62
+ for await (const message of stream) {
63
+ const rendered = renderSdkMessage(message);
64
+
65
+ if (rendered !== null) {
66
+ hooks.onOutput(`${rendered}\r\n`);
67
+ }
68
+
69
+ if (message.type !== 'result') {
70
+ continue;
71
+ }
72
+
73
+ if (message.subtype === 'success') {
74
+ hooks.onDone(truncateSummary(message.result));
75
+ continue;
76
+ }
77
+
78
+ if (stderrTail.trim() !== '') {
79
+ hooks.onOutput(`headless stderr: ${stderrTail.trim()}\r\n`);
80
+ }
81
+
82
+ hooks.onNeedsYou(`headless run stopped: ${message.subtype}`);
83
+ }
84
+ } catch (error) {
85
+ if (!controller.signal.aborted) {
86
+ const msg = error instanceof Error ? error.message : String(error);
87
+
88
+ hooks.onOutput(`headless run failed: ${msg}\r\n`);
89
+ hooks.onNeedsYou(`headless run failed: ${truncateSummary(msg)}`);
90
+ }
91
+ }
92
+ })();
93
+
94
+ return {
95
+ stop() {
96
+ controller.abort();
97
+ },
98
+ };
99
+ }
100
+
101
+ interface RenderableMessage {
102
+ readonly type: string;
103
+ readonly subtype?: string;
104
+ readonly result?: string;
105
+ readonly message?: unknown;
106
+ }
107
+
108
+ /**
109
+ * One structured SDK message → zero or one plain-text line for the session's
110
+ * output pipe: assistant text verbatim, tool calls as compact one-liners,
111
+ * results as a closing line.
112
+ */
113
+ export function renderSdkMessage(message: RenderableMessage): string | null {
114
+ if (message.type === 'assistant') {
115
+ const inner = message.message;
116
+
117
+ if (!isRecord(inner) || !Array.isArray(inner['content'])) {
118
+ return null;
119
+ }
120
+
121
+ const content = inner['content'];
122
+ const lines: string[] = [];
123
+
124
+ for (const raw of content) {
125
+ const block: unknown = raw;
126
+
127
+ if (!isRecord(block)) {
128
+ continue;
129
+ }
130
+
131
+ if (
132
+ block['type'] === 'text' &&
133
+ typeof block['text'] === 'string' &&
134
+ block['text'].trim() !== ''
135
+ ) {
136
+ lines.push(block['text'].replaceAll('\n', '\r\n'));
137
+ }
138
+
139
+ if (block['type'] === 'tool_use' && typeof block['name'] === 'string') {
140
+ lines.push(`⚙ ${block['name']} ${truncateSummary(JSON.stringify(block['input'] ?? {}))}`);
141
+ }
142
+ }
143
+
144
+ return lines.length === 0 ? null : lines.join('\r\n');
145
+ }
146
+
147
+ if (message.type === 'result') {
148
+ return message.subtype === 'success'
149
+ ? `— headless turn done: ${truncateSummary(message.result ?? '')}`
150
+ : `— headless turn stopped: ${message.subtype ?? 'unknown'}`;
151
+ }
152
+
153
+ return null;
154
+ }
155
+
156
+ function truncateSummary(text: string): string {
157
+ const flat = text.replaceAll('\n', ' ');
158
+
159
+ return flat.length <= 200 ? flat : `${flat.slice(0, 199)}…`;
160
+ }