@zgeoff/atc 0.1.11 → 1.0.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.
Files changed (74) hide show
  1. package/README.md +54 -15
  2. package/package.json +7 -2
  3. package/src/{agent-adapter.ts → agents/agent-adapter.ts} +31 -14
  4. package/src/{build-cli-command.ts → agents/build-cli-command.ts} +1 -1
  5. package/src/agents/build-hook-settings.ts +59 -0
  6. package/src/{claude-adapter.ts → agents/claude-adapter.ts} +41 -80
  7. package/src/{codex-adapter.ts → agents/codex-adapter.ts} +41 -30
  8. package/src/agents/gateway-adapter.ts +106 -0
  9. package/src/{grok-adapter.ts → agents/grok-adapter.ts} +83 -46
  10. package/src/agents/resolve-agent-home.ts +13 -0
  11. package/src/agents/truncate-detail.ts +7 -0
  12. package/src/agents/write-hook-settings.ts +37 -0
  13. package/src/cli.ts +25 -14
  14. package/src/{boot-daemon.ts → client/boot-daemon.ts} +8 -7
  15. package/src/client/build-client-machine.ts +119 -0
  16. package/src/client/collect-agent-picks.ts +31 -0
  17. package/src/{daemon-client.ts → client/daemon-client.ts} +4 -4
  18. package/src/{dirs.ts → client/dirs.ts} +8 -0
  19. package/src/client/format-overlay-agent-mark.ts +15 -0
  20. package/src/{index.ts → client/index.ts} +181 -550
  21. package/src/client/keys.ts +111 -0
  22. package/src/client/parse-daemon-event.ts +114 -0
  23. package/src/{pick-tab-target.ts → client/pick-tab-target.ts} +2 -2
  24. package/src/client/spawn-picker.ts +305 -0
  25. package/src/client/to-mirror-session.ts +65 -0
  26. package/src/{ui.ts → client/ui.ts} +13 -8
  27. package/src/{attach-registry.ts → daemon/attach-registry.ts} +12 -10
  28. package/src/daemon/build-session-event.ts +43 -0
  29. package/src/{daemon-connection.ts → daemon/daemon-connection.ts} +194 -108
  30. package/src/{daemon.ts → daemon/daemon.ts} +142 -301
  31. package/src/{hooks.ts → daemon/hooks.ts} +6 -4
  32. package/src/daemon/mint-session-id.ts +12 -0
  33. package/src/{permission-registry.ts → daemon/permission-registry.ts} +5 -3
  34. package/src/daemon/restore-fleet.ts +141 -0
  35. package/src/daemon/run-eject-handoff.ts +33 -0
  36. package/src/{screen-model.ts → daemon/screen-model.ts} +1 -1
  37. package/src/daemon/session-runtime.ts +64 -0
  38. package/src/{sessions.ts → daemon/sessions.ts} +98 -144
  39. package/src/{start-headless-run.ts → daemon/start-headless-run.ts} +9 -3
  40. package/src/daemon/start-headless-turn.ts +57 -0
  41. package/src/hook-report.ts +2 -2
  42. package/src/mcp-server.ts +33 -44
  43. package/src/protocol/parse-request-params.ts +31 -0
  44. package/src/{protocol.ts → protocol/protocol.ts} +1 -1
  45. package/src/protocol/request-param-schemas.ts +109 -0
  46. package/src/shared/agent-session-id.ts +8 -0
  47. package/src/shared/build-optional-boolean.ts +10 -0
  48. package/src/shared/build-optional-string-array.ts +14 -0
  49. package/src/shared/build-optional-string.ts +10 -0
  50. package/src/shared/collect-gateways.ts +113 -0
  51. package/src/{config.ts → shared/config.ts} +47 -31
  52. package/src/{get-build.ts → shared/get-build.ts} +21 -6
  53. package/src/shared/get-record.ts +20 -0
  54. package/src/shared/session-id.ts +8 -0
  55. package/src/shared/to-agent-session-id.ts +12 -0
  56. package/src/shared/to-session-id.ts +11 -0
  57. package/src/shared/to-shell-arg.ts +7 -0
  58. package/src/statusline.ts +2 -2
  59. package/src/store/bun-sqlite-driver.ts +96 -0
  60. package/src/store/fleet-entry.ts +72 -0
  61. package/src/store/run-migrations.ts +242 -0
  62. package/src/store/state-store.ts +226 -0
  63. package/src/format-overlay-agent-mark.ts +0 -17
  64. package/src/state-store.ts +0 -227
  65. /package/src/{normalize-hook-event.ts → agents/normalize-hook-event.ts} +0 -0
  66. /package/src/{print-codex-hook-file.ts → agents/print-codex-hook-file.ts} +0 -0
  67. /package/src/{print-grok-hook-file.ts → agents/print-grok-hook-file.ts} +0 -0
  68. /package/src/{build-leader-chords.ts → client/build-leader-chords.ts} +0 -0
  69. /package/src/{daemon-error.ts → protocol/daemon-error.ts} +0 -0
  70. /package/src/{outbound-queue.ts → protocol/outbound-queue.ts} +0 -0
  71. /package/src/{collect-clean-env.ts → shared/collect-clean-env.ts} +0 -0
  72. /package/src/{report.ts → shared/report.ts} +0 -0
  73. /package/src/{reset-input-modes.ts → shared/reset-input-modes.ts} +0 -0
  74. /package/src/{resolve-repo-root.ts → shared/resolve-repo-root.ts} +0 -0
@@ -1,14 +1,15 @@
1
- import { readFileSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { writeFileSync } from 'node:fs';
3
2
  import { spawn } from 'bun-pty';
4
3
  import type { IPty } from 'bun-pty';
5
- import { toAgentKind } from './agent-adapter';
6
- import type { AdapterEvent, AgentAdapter, AgentKind } from './agent-adapter';
7
- import { collectCleanEnv } from './collect-clean-env';
8
- import { socketPath, stateDir, statusFile } from './config';
4
+ import type { AdapterEvent, AgentAdapter, AgentID } from '../agents/agent-adapter';
5
+ import type { AgentSessionID } from '../shared/agent-session-id';
6
+ import { collectCleanEnv } from '../shared/collect-clean-env';
7
+ import { socketPath, statusFile } from '../shared/config';
8
+ import { resolveRepoRoot } from '../shared/resolve-repo-root';
9
+ import type { SessionID } from '../shared/session-id';
10
+ import type { FleetEntry, FleetStore } from '../store/fleet-entry';
9
11
  import type { HookEvent } from './hooks';
10
- import { isRecord } from './report';
11
- import { resolveRepoRoot } from './resolve-repo-root';
12
+ import { mintSessionID } from './mint-session-id';
12
13
 
13
14
  export type SessionState = 'running' | 'needs_you' | 'done' | 'exited';
14
15
 
@@ -17,15 +18,15 @@ export type SessionEventKind = 'added' | 'state' | 'renamed' | 'removed';
17
18
  // The over-the-wire view of a session: everything but the PTY handle, plus
18
19
  // the surface kind.
19
20
  export interface SessionDescriptor {
20
- readonly id: string;
21
+ readonly id: SessionID;
21
22
  readonly name: string;
22
23
  readonly cwd: string;
23
24
  readonly state: SessionState;
24
25
  readonly unread: boolean;
25
26
  readonly lastMsg: string;
26
27
  readonly lastDetail?: string;
27
- readonly agentSessionID?: string;
28
- readonly agent: AgentKind;
28
+ readonly agentSessionID?: AgentSessionID;
29
+ readonly agent: AgentID;
29
30
  readonly pinned: boolean;
30
31
  readonly lastAttachedAt: number;
31
32
  readonly repoRoot: string;
@@ -33,10 +34,14 @@ export interface SessionDescriptor {
33
34
  readonly createdAt: number;
34
35
  readonly kind: 'pty' | 'headless';
35
36
  readonly alive: boolean;
37
+
38
+ // Whether this session's agent can run a headless turn, so the client can
39
+ // offer the eject action without knowing which agent it is.
40
+ readonly canEject: boolean;
36
41
  }
37
42
 
38
43
  export interface Session {
39
- id: string;
44
+ id: SessionID;
40
45
  name: string;
41
46
  cwd: string;
42
47
  kind: 'pty' | 'headless';
@@ -45,8 +50,8 @@ export interface Session {
45
50
  unread: boolean;
46
51
  lastMsg: string;
47
52
  lastDetail?: string;
48
- agentSessionID?: string;
49
- agent: AgentKind;
53
+ agentSessionID?: AgentSessionID;
54
+ agent: AgentID;
50
55
  transcriptSource?: string;
51
56
  pinned: boolean;
52
57
 
@@ -64,84 +69,10 @@ export interface Session {
64
69
  createdAt: number;
65
70
  }
66
71
 
67
- let counter = 0;
68
-
69
- export interface FleetEntry {
70
- readonly name: string;
71
- readonly cwd: string;
72
- readonly agentSessionID: string;
73
- readonly agent: AgentKind;
74
- readonly pinned?: boolean;
75
- readonly lastAttachedAt?: number;
76
- }
77
-
78
- const fleetFile = join(stateDir, 'fleet.json');
79
-
80
- export interface FleetStore {
81
- readonly loadFleet: () => FleetEntry[];
82
- readonly writeFleet: (entries: readonly FleetEntry[]) => void;
83
- }
84
-
85
- const jsonFleetStore: FleetStore = {
86
- loadFleet: () => loadFleet(),
87
- writeFleet: (entries) => {
88
- try {
89
- writeFileSync(fleetFile, JSON.stringify(entries, null, 2));
90
- } catch {}
91
- },
92
- };
93
-
94
- export function parseFleetEntry(raw: unknown): FleetEntry | undefined {
95
- if (!isRecord(raw) || typeof raw['name'] !== 'string' || typeof raw['cwd'] !== 'string') {
96
- return undefined;
97
- }
98
-
99
- // Fleet files written before the id key was agent-neutral carry it under
100
- // its Claude-era name.
101
- const agentSessionID = raw['agentSessionID'] ?? raw['claudeId'];
102
-
103
- if (typeof agentSessionID !== 'string') {
104
- return undefined;
105
- }
106
-
107
- return {
108
- name: raw['name'],
109
- cwd: raw['cwd'],
110
- agentSessionID,
111
- agent: toAgentKind(raw['agent']),
112
- ...(raw['pinned'] === true ? { pinned: true } : {}),
113
- ...(typeof raw['lastAttachedAt'] === 'number' ? { lastAttachedAt: raw['lastAttachedAt'] } : {}),
114
- };
115
- }
116
-
117
- function loadFleet(): FleetEntry[] {
118
- try {
119
- const parsed: unknown = JSON.parse(readFileSync(fleetFile, 'utf8'));
120
-
121
- if (!Array.isArray(parsed)) {
122
- return [];
123
- }
124
-
125
- const entries: FleetEntry[] = [];
126
-
127
- for (const entry of parsed) {
128
- const parsedEntry = parseFleetEntry(entry);
129
-
130
- if (parsedEntry !== undefined) {
131
- entries.push(parsedEntry);
132
- }
133
- }
134
-
135
- return entries;
136
- } catch {
137
- return [];
138
- }
139
- }
140
-
141
72
  export class SessionManager {
142
73
  sessions: Session[] = [];
143
74
 
144
- focusedId: string | null = null;
75
+ focusedId: SessionID | null = null;
145
76
 
146
77
  onOutput: (s: Session, data: string) => void = () => {};
147
78
 
@@ -149,9 +80,12 @@ export class SessionManager {
149
80
 
150
81
  onEvent: (kind: SessionEventKind, s: Session) => void = () => {};
151
82
 
152
- private readonly fallback: AgentAdapter;
83
+ // Whether any registered adapter has a screen detector, decided once at
84
+ // construction since the registry never changes afterward. Lets a hot path
85
+ // that only cares about this answer skip walking live sessions to find it.
86
+ readonly hasScreenDetector: boolean;
153
87
 
154
- private readonly adapters: Partial<Readonly<Record<AgentKind, AgentAdapter>>>;
88
+ private readonly adapters: Readonly<Record<AgentID, AgentAdapter>>;
155
89
 
156
90
  private readonly store: FleetStore;
157
91
 
@@ -159,31 +93,30 @@ export class SessionManager {
159
93
 
160
94
  constructor(
161
95
  fallback: AgentAdapter,
162
- store: FleetStore = jsonFleetStore,
96
+ store: FleetStore,
163
97
  statusPath: string | undefined = statusFile,
164
- adapters: Partial<Readonly<Record<AgentKind, AgentAdapter>>> = {},
98
+ adapters: readonly AgentAdapter[] = [],
165
99
  ) {
166
- this.fallback = fallback;
167
- this.adapters = adapters;
100
+ // Each adapter names the id it answers to, so a registry key can never
101
+ // disagree with the adapter behind it. A later one wins the id.
102
+ this.adapters = Object.fromEntries([fallback, ...adapters].map((a) => [a.id, a]));
103
+ this.hasScreenDetector = Object.values(this.adapters).some((a) => a.screenDetector !== null);
168
104
  this.store = store;
169
105
  this.statusPath = statusPath ?? statusFile;
170
106
  }
171
107
 
172
108
  /**
173
- * Adapter for this kind, or null when none is registered. Never returns a
174
- * different kind than the one asked for.
109
+ * Adapter registered under this id, or null when there is none. Lookup
110
+ * never falls back to another id: a session whose agent is not registered
111
+ * is unsupported, not somebody else's spawn.
175
112
  */
176
- findAdapter(kind: AgentKind): AgentAdapter | null {
177
- if (kind === 'claude') {
178
- return this.adapters.claude ?? this.fallback;
179
- }
180
-
181
- return this.adapters[kind] ?? null;
113
+ findAdapter(id: AgentID): AgentAdapter | null {
114
+ return this.adapters[id] ?? null;
182
115
  }
183
116
 
184
117
  // Hands a live terminal session off to a headless run: the terminal dies,
185
118
  // the record lives on as a headless session and keeps its screen history.
186
- yankHeadless(id: string): Session | null {
119
+ yankHeadless(id: SessionID): Session | null {
187
120
  const s = this.sessions.find((x) => x.id === id);
188
121
 
189
122
  if (!s || s.pty === null || s.agentSessionID === undefined) {
@@ -206,17 +139,31 @@ export class SessionManager {
206
139
 
207
140
  // Registers a fleet entry as a session with no terminal yet, so a
208
141
  // fleet-wide restore can show every incoming session at once; adopting it
209
- // later attaches the terminal.
142
+ // later attaches the terminal. Exited entries come back as killed
143
+ // sessions: still listed and revivable, never auto-adopted.
210
144
  restore(entry: FleetEntry): Session {
145
+ const exited = entry.exited === true;
146
+
147
+ // An entry whose agent is no longer registered still gets its row, so a
148
+ // backend dropped from the config shows as itself instead of vanishing or
149
+ // reviving under another agent. Adopting a terminal for it is refused.
150
+ let lastMsg = 'waiting to restore';
151
+
152
+ if (exited) {
153
+ lastMsg = 'killed';
154
+ } else if (this.findAdapter(entry.agent) === null) {
155
+ lastMsg = `no adapter for '${entry.agent}'`;
156
+ }
157
+
211
158
  const session: Session = {
212
- id: `s${++counter}-${Date.now().toString(36)}`,
159
+ id: mintSessionID(),
213
160
  name: entry.name,
214
161
  cwd: entry.cwd,
215
162
  kind: 'headless',
216
163
  pty: null,
217
- state: 'running',
164
+ state: exited ? 'exited' : 'running',
218
165
  unread: false,
219
- lastMsg: 'waiting to restore',
166
+ lastMsg,
220
167
  agentSessionID: entry.agentSessionID,
221
168
  agent: entry.agent,
222
169
  pinned: entry.pinned ?? false,
@@ -235,7 +182,7 @@ export class SessionManager {
235
182
 
236
183
  // Adopts a headless session back into a terminal: a fresh PTY resumes the
237
184
  // same agent session id.
238
- adoptTerminal(id: string, cols: number, rows: number): Session | null {
185
+ adoptTerminal(id: SessionID, cols: number, rows: number): Session | null {
239
186
  const s = this.sessions.find((x) => x.id === id);
240
187
 
241
188
  if (!s || s.pty !== null || s.agentSessionID === undefined) {
@@ -280,7 +227,7 @@ export class SessionManager {
280
227
  this.emitChange();
281
228
  });
282
229
 
283
- this.writeFleet();
230
+ void this.writeFleet();
284
231
  this.onEvent('state', s);
285
232
  this.emitChange();
286
233
 
@@ -292,7 +239,7 @@ export class SessionManager {
292
239
  * user strength, so auto-summaries stop overwriting it while an
293
240
  * in-session rename still wins. Pinned sessions lead every list.
294
241
  */
295
- updateSession(id: string, name?: string, pinned?: boolean): boolean {
242
+ updateSession(id: SessionID, name?: string, pinned?: boolean): boolean {
296
243
  const s = this.sessions.find((x) => x.id === id);
297
244
 
298
245
  if (s === undefined) {
@@ -312,14 +259,14 @@ export class SessionManager {
312
259
  this.onEvent('state', s);
313
260
  }
314
261
 
315
- this.writeFleet();
262
+ void this.writeFleet();
316
263
  this.writeStatus();
317
264
  this.emitChange();
318
265
 
319
266
  return true;
320
267
  }
321
268
 
322
- updateSurfaceState(id: string, state: SessionState, msg: string) {
269
+ updateSurfaceState(id: SessionID, state: SessionState, msg: string) {
323
270
  const s = this.sessions.find((x) => x.id === id);
324
271
 
325
272
  if (!s || s.kind !== 'headless') {
@@ -336,7 +283,7 @@ export class SessionManager {
336
283
 
337
284
  // The detector-stack screen tier reports through here: only flips between
338
285
  // running and needs_you — hook-driven done/exited states always win.
339
- updateAttention(id: string, state: 'needs_you' | 'running', msg: string) {
286
+ updateAttention(id: SessionID, state: 'needs_you' | 'running', msg: string) {
340
287
  const s = this.sessions.find((x) => x.id === id);
341
288
 
342
289
  if (!s || s.pty === null || s.state === state) {
@@ -362,17 +309,17 @@ export class SessionManager {
362
309
  return this.sessions.find((s) => s.id === this.focusedId) ?? null;
363
310
  }
364
311
 
365
- // resume: true opens the agent's own session picker; a string resumes that
366
- // specific agent session id (fleet restore).
312
+ // resume: true opens the agent's own session picker; an agent session id
313
+ // resumes that specific session (fleet restore).
367
314
  spawn(
368
315
  cwd: string,
369
316
  name: string,
370
317
  prompt: string,
371
318
  cols: number,
372
319
  rows: number,
373
- resume: boolean | string = false,
320
+ resume: boolean | AgentSessionID = false,
374
321
  namedBy: 'user' | 'auto' = 'auto',
375
- agent: AgentKind = 'claude',
322
+ agent: AgentID = 'claude',
376
323
  ): Session {
377
324
  const adapter = this.findAdapter(agent);
378
325
 
@@ -380,7 +327,7 @@ export class SessionManager {
380
327
  throw new Error(`no adapter for agent '${agent}'`);
381
328
  }
382
329
 
383
- const id = `s${++counter}-${Date.now().toString(36)}`;
330
+ const id = mintSessionID();
384
331
  const plan = adapter.planSpawn({ prompt, resume });
385
332
 
386
333
  const pty = spawn(plan.bin, plan.args, {
@@ -435,7 +382,7 @@ export class SessionManager {
435
382
  });
436
383
 
437
384
  this.sessions.push(session);
438
- this.writeFleet();
385
+ void this.writeFleet();
439
386
  this.writeStatus();
440
387
  this.onEvent('added', session);
441
388
 
@@ -460,6 +407,7 @@ export class SessionManager {
460
407
  createdAt: s.createdAt,
461
408
  kind: s.kind,
462
409
  alive: s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'),
410
+ canEject: (this.findAdapter(s.agent)?.headlessRunner ?? null) !== null,
463
411
  }));
464
412
  }
465
413
 
@@ -491,9 +439,7 @@ export class SessionManager {
491
439
 
492
440
  if (ev.agentSessionID !== undefined && s.agentSessionID !== ev.agentSessionID) {
493
441
  s.agentSessionID = ev.agentSessionID;
494
-
495
- this.writeFleet();
496
-
442
+ void this.writeFleet();
497
443
  dirty = true;
498
444
  }
499
445
 
@@ -587,13 +533,13 @@ export class SessionManager {
587
533
  s.namedBy = update.namedBy;
588
534
  }
589
535
 
590
- this.writeFleet();
536
+ void this.writeFleet();
591
537
  this.onEvent('renamed', s);
592
538
  this.emitChange();
593
539
  }
594
540
 
595
541
  // Shell command that re-opens this session outside atc (or anywhere).
596
- buildResumeCommand(id: string): string | null {
542
+ buildResumeCommand(id: SessionID): string | null {
597
543
  const s = this.sessions.find((x) => x.id === id);
598
544
 
599
545
  if (!s) {
@@ -603,7 +549,7 @@ export class SessionManager {
603
549
  return this.findAdapter(s.agent)?.buildResumeCommand(s.cwd, s.agentSessionID) ?? null;
604
550
  }
605
551
 
606
- attach(id: string) {
552
+ attach(id: SessionID) {
607
553
  const s = this.sessions.find((x) => x.id === id);
608
554
 
609
555
  if (!s) {
@@ -612,8 +558,7 @@ export class SessionManager {
612
558
 
613
559
  s.unread = false;
614
560
  s.lastAttachedAt = Date.now();
615
-
616
- this.writeFleet();
561
+ void this.writeFleet();
617
562
 
618
563
  // Attaching answers the attention request: a still-pending prompt
619
564
  // re-flags it via the next notification.
@@ -626,7 +571,7 @@ export class SessionManager {
626
571
  this.emitChange();
627
572
  }
628
573
 
629
- ack(id: string) {
574
+ ack(id: SessionID) {
630
575
  const s = this.sessions.find((x) => x.id === id);
631
576
 
632
577
  if (s) {
@@ -638,7 +583,10 @@ export class SessionManager {
638
583
  this.emitChange();
639
584
  }
640
585
 
641
- kill(id: string) {
586
+ // A kill's response is the caller's cue that the session is safely
587
+ // archived, so the write it depends on must land before that response
588
+ // goes out.
589
+ async kill(id: SessionID): Promise<void> {
642
590
  const s = this.sessions.find((x) => x.id === id);
643
591
 
644
592
  if (!s) {
@@ -663,7 +611,8 @@ export class SessionManager {
663
611
  this.onEvent('removed', s);
664
612
  }
665
613
 
666
- this.writeFleet();
614
+ await this.writeFleet();
615
+
667
616
  this.emitChange();
668
617
  }
669
618
 
@@ -691,26 +640,31 @@ export class SessionManager {
691
640
 
692
641
  // Persisted continuously so a crash (or quit) leaves a restorable fleet.
693
642
  // Deliberate kills rewrite the file; unexpected session/atc deaths do not,
694
- // so the last known fleet survives for `R` restore.
695
- writeFleet() {
643
+ // so the last known fleet survives for `R` restore. Sessions whose
644
+ // terminal is gone persist as exited entries, so the killed archive
645
+ // survives a daemon restart too.
646
+ async writeFleet(): Promise<void> {
696
647
  const fleet: FleetEntry[] = [];
697
648
 
698
649
  for (const s of this.sessions) {
650
+ if (s.agentSessionID === undefined) {
651
+ continue;
652
+ }
653
+
699
654
  const live = s.pty !== null || (s.kind === 'headless' && s.state !== 'exited');
700
655
 
701
- if (live && s.agentSessionID !== undefined) {
702
- fleet.push({
703
- name: s.name,
704
- cwd: s.cwd,
705
- agentSessionID: s.agentSessionID,
706
- agent: s.agent,
707
- ...(s.pinned ? { pinned: true } : {}),
708
- lastAttachedAt: s.lastAttachedAt,
709
- });
710
- }
656
+ fleet.push({
657
+ name: s.name,
658
+ cwd: s.cwd,
659
+ agentSessionID: s.agentSessionID,
660
+ agent: s.agent,
661
+ ...(s.pinned ? { pinned: true } : {}),
662
+ lastAttachedAt: s.lastAttachedAt,
663
+ ...(live ? {} : { exited: true }),
664
+ });
711
665
  }
712
666
 
713
- this.store.writeFleet(fleet);
667
+ await this.store.writeFleet(fleet);
714
668
  }
715
669
 
716
670
  countStates() {
@@ -1,6 +1,7 @@
1
1
  import { query } from '@anthropic-ai/claude-agent-sdk';
2
- import { collectCleanEnv } from './collect-clean-env';
3
- import { isRecord } from './report';
2
+ import type { AgentSessionID } from '../shared/agent-session-id';
3
+ import { collectCleanEnv } from '../shared/collect-clean-env';
4
+ import { isRecord } from '../shared/report';
4
5
 
5
6
  const PERMISSION_MODES = [
6
7
  'default',
@@ -14,8 +15,9 @@ const PERMISSION_MODES = [
14
15
  interface HeadlessRunOptions {
15
16
  readonly cwd: string;
16
17
  readonly prompt: string;
17
- readonly resume?: string;
18
+ readonly resume?: AgentSessionID;
18
19
  readonly permissionMode?: string;
20
+ readonly settings?: string;
19
21
  }
20
22
 
21
23
  interface HeadlessRunHooks {
@@ -56,6 +58,10 @@ export function startHeadlessRun(
56
58
  },
57
59
  ...(opts.resume === undefined ? {} : { resume: opts.resume }),
58
60
  ...(mode === undefined ? {} : { permissionMode: mode }),
61
+
62
+ // The same generated file the terminal spawn passes, so the turn
63
+ // runs against the session's own backend and instrumentation.
64
+ ...(opts.settings === undefined ? {} : { extraArgs: { settings: opts.settings } }),
59
65
  },
60
66
  });
61
67
 
@@ -0,0 +1,57 @@
1
+ import type { SessionID } from '../shared/session-id';
2
+ import type { SessionRuntime } from './session-runtime';
3
+ import type { SessionManager } from './sessions';
4
+
5
+ /**
6
+ * Starts one headless turn for a session: finds its adapter's headless
7
+ * runner, wires the run's output and completion into the session manager,
8
+ * and records the live handle on the session's runtime. Returns false
9
+ * without starting anything when the session is unknown, its agent has no
10
+ * headless runner, or a turn is already running.
11
+ */
12
+ export function startHeadlessTurn(
13
+ mgr: SessionManager,
14
+ findRuntime: (sessionID: SessionID) => SessionRuntime | undefined,
15
+ sessionID: SessionID,
16
+ prompt: string,
17
+ ): boolean {
18
+ const s = mgr.sessions.find((x) => x.id === sessionID);
19
+ const runner = s === undefined ? null : (mgr.findAdapter(s.agent)?.headlessRunner ?? null);
20
+ const runtime = findRuntime(sessionID);
21
+
22
+ if (s === undefined || runner === null || runtime === undefined || runtime.headlessRun !== null) {
23
+ return false;
24
+ }
25
+
26
+ mgr.updateSurfaceState(sessionID, 'running', 'headless turn running');
27
+
28
+ const handle = runner(
29
+ {
30
+ cwd: s.cwd,
31
+ prompt,
32
+ ...(s.agentSessionID === undefined ? {} : { resume: s.agentSessionID }),
33
+ permissionMode: 'auto',
34
+ },
35
+ {
36
+ onOutput: (text) => {
37
+ mgr.onOutput(s, text);
38
+ },
39
+ onDone: (summary) => {
40
+ runtime.headlessRun = null;
41
+
42
+ const doneMsg = summary === '' ? 'headless turn done' : summary;
43
+
44
+ mgr.updateSurfaceState(sessionID, 'done', doneMsg);
45
+ },
46
+ onNeedsYou: (msg) => {
47
+ runtime.headlessRun = null;
48
+
49
+ mgr.updateSurfaceState(sessionID, 'needs_you', msg);
50
+ },
51
+ },
52
+ );
53
+
54
+ runtime.headlessRun = handle;
55
+
56
+ return true;
57
+ }
@@ -1,5 +1,5 @@
1
- import { normalizeHookEventName } from './normalize-hook-event';
2
- import { isRecord, sendReport } from './report';
1
+ import { normalizeHookEventName } from './agents/normalize-hook-event';
2
+ import { isRecord, sendReport } from './shared/report';
3
3
 
4
4
  /**
5
5
  * Runs as a hook inside wrangled sessions. Reads the hook event from stdin
package/src/mcp-server.ts CHANGED
@@ -1,6 +1,8 @@
1
- import { bootDaemonClient } from './boot-daemon';
2
- import { DaemonError } from './daemon-error';
3
- import { isRecord } from './report';
1
+ import { z } from 'zod';
2
+ import { bootDaemonClient } from './client/boot-daemon';
3
+ import { DaemonError } from './protocol/daemon-error';
4
+ import { REQUEST_PARAM_SCHEMAS } from './protocol/request-param-schemas';
5
+ import { isRecord } from './shared/report';
4
6
 
5
7
  // The slice of the daemon client the tool handlers need.
6
8
  interface FleetCaller {
@@ -18,20 +20,32 @@ interface MCPTool {
18
20
  readonly inputSchema: Readonly<Record<string, unknown>>;
19
21
  }
20
22
 
21
- const NO_INPUT: Readonly<Record<string, unknown>> = {
22
- type: 'object',
23
- properties: {},
24
- additionalProperties: false,
25
- };
23
+ const NO_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(z.strictObject({}));
26
24
 
27
- const SESSION_INPUT: Readonly<Record<string, unknown>> = {
28
- type: 'object',
29
- properties: {
30
- session: { type: 'string', description: 'The atc session id, from atc_session_list' },
31
- },
32
- required: ['session'],
33
- additionalProperties: false,
34
- };
25
+ /**
26
+ * The session id shape MCP tool schemas require: present and described.
27
+ * The wire request schemas below share a defaulted-session shape instead,
28
+ * since they tolerate an absent session by defaulting it to an empty
29
+ * string.
30
+ */
31
+ const SESSION_ID_BASE = z.object({
32
+ session: z.string().describe('The atc session id, from atc_session_list'),
33
+ });
34
+
35
+ const SESSION_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(SESSION_ID_BASE.strict());
36
+ const SPAWN_SCHEMA = REQUEST_PARAM_SCHEMAS['session.spawn'];
37
+
38
+ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
39
+ z.strictObject({
40
+ cwd: SPAWN_SCHEMA.shape.cwd.describe('Absolute path of the working directory'),
41
+ name: SPAWN_SCHEMA.shape.name.describe('Session name; defaults to the directory basename'),
42
+ prompt: SPAWN_SCHEMA.shape.prompt.describe('First message for the session'),
43
+ agent: SPAWN_SCHEMA.shape.agent.describe(
44
+ 'Which registered agent id to spawn; defaults to claude',
45
+ ),
46
+ }),
47
+ { io: 'input' },
48
+ );
35
49
 
36
50
  const TOOLS: readonly MCPTool[] = [
37
51
  {
@@ -43,22 +57,8 @@ const TOOLS: readonly MCPTool[] = [
43
57
  {
44
58
  name: 'atc_session_spawn',
45
59
  description:
46
- 'Spawn a new session in a directory. Optional agent is claude, grok, or codex; omitted agent is always Claude, never the TUI last-used value. Returns the new session descriptor. Give it a prompt to start it working immediately.',
47
- inputSchema: {
48
- type: 'object',
49
- properties: {
50
- cwd: { type: 'string', description: 'Absolute path of the working directory' },
51
- name: { type: 'string', description: 'Session name; defaults to the directory basename' },
52
- prompt: { type: 'string', description: 'First message for the session' },
53
- agent: {
54
- type: 'string',
55
- enum: ['claude', 'grok', 'codex'],
56
- description: 'Which agent CLI to spawn; defaults to claude',
57
- },
58
- },
59
- required: ['cwd'],
60
- additionalProperties: false,
61
- },
60
+ 'Spawn a new session in a directory. Optional agent is an agent id the daemon has registered, such as claude, grok, or codex; omitted agent is always Claude, never the TUI last-used value. An unregistered id is rejected. Returns the new session descriptor. Give it a prompt to start it working immediately.',
61
+ inputSchema: SPAWN_INPUT,
62
62
  },
63
63
  {
64
64
  name: 'atc_session_input',
@@ -243,22 +243,11 @@ async function runTool(
243
243
  case 'atc_session_spawn': {
244
244
  const rawAgent = args['agent'];
245
245
 
246
- if (
247
- rawAgent !== undefined &&
248
- rawAgent !== 'claude' &&
249
- rawAgent !== 'grok' &&
250
- rawAgent !== 'codex'
251
- ) {
252
- throw new Error("agent must be 'claude', 'grok', or 'codex'");
253
- }
254
-
255
246
  const ok = await client.sendRequest('session.spawn', {
256
247
  cwd: args['cwd'],
257
248
  ...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
258
249
  ...(typeof args['prompt'] === 'string' ? { prompt: args['prompt'] } : {}),
259
- ...(rawAgent === 'claude' || rawAgent === 'grok' || rawAgent === 'codex'
260
- ? { agent: rawAgent }
261
- : {}),
250
+ ...(rawAgent === undefined ? {} : { agent: rawAgent }),
262
251
  cols: 100,
263
252
  rows: 30,
264
253
  });