@zgeoff/atc 0.1.12 → 1.0.1

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 (75) hide show
  1. package/README.md +50 -12
  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/plan-vacated-rows.ts +26 -0
  25. package/src/client/spawn-picker.ts +305 -0
  26. package/src/client/to-mirror-session.ts +65 -0
  27. package/src/{ui.ts → client/ui.ts} +27 -8
  28. package/src/{attach-registry.ts → daemon/attach-registry.ts} +12 -10
  29. package/src/daemon/build-session-event.ts +43 -0
  30. package/src/{daemon-connection.ts → daemon/daemon-connection.ts} +194 -108
  31. package/src/{daemon.ts → daemon/daemon.ts} +148 -313
  32. package/src/{hooks.ts → daemon/hooks.ts} +6 -4
  33. package/src/daemon/mint-session-id.ts +12 -0
  34. package/src/{permission-registry.ts → daemon/permission-registry.ts} +5 -3
  35. package/src/daemon/restore-fleet.ts +141 -0
  36. package/src/daemon/run-eject-handoff.ts +33 -0
  37. package/src/{screen-model.ts → daemon/screen-model.ts} +1 -1
  38. package/src/daemon/session-runtime.ts +64 -0
  39. package/src/{sessions.ts → daemon/sessions.ts} +77 -133
  40. package/src/{start-headless-run.ts → daemon/start-headless-run.ts} +9 -3
  41. package/src/daemon/start-headless-turn.ts +57 -0
  42. package/src/hook-report.ts +2 -2
  43. package/src/mcp-server.ts +33 -44
  44. package/src/protocol/parse-request-params.ts +31 -0
  45. package/src/{protocol.ts → protocol/protocol.ts} +1 -1
  46. package/src/protocol/request-param-schemas.ts +109 -0
  47. package/src/shared/agent-session-id.ts +8 -0
  48. package/src/shared/build-optional-boolean.ts +10 -0
  49. package/src/shared/build-optional-string-array.ts +14 -0
  50. package/src/shared/build-optional-string.ts +10 -0
  51. package/src/shared/collect-gateways.ts +113 -0
  52. package/src/{config.ts → shared/config.ts} +47 -31
  53. package/src/{get-build.ts → shared/get-build.ts} +21 -6
  54. package/src/shared/get-record.ts +20 -0
  55. package/src/shared/session-id.ts +8 -0
  56. package/src/shared/to-agent-session-id.ts +12 -0
  57. package/src/shared/to-session-id.ts +11 -0
  58. package/src/shared/to-shell-arg.ts +7 -0
  59. package/src/statusline.ts +2 -2
  60. package/src/store/bun-sqlite-driver.ts +96 -0
  61. package/src/store/fleet-entry.ts +72 -0
  62. package/src/store/run-migrations.ts +242 -0
  63. package/src/store/state-store.ts +226 -0
  64. package/src/format-overlay-agent-mark.ts +0 -17
  65. package/src/state-store.ts +0 -236
  66. /package/src/{normalize-hook-event.ts → agents/normalize-hook-event.ts} +0 -0
  67. /package/src/{print-codex-hook-file.ts → agents/print-codex-hook-file.ts} +0 -0
  68. /package/src/{print-grok-hook-file.ts → agents/print-grok-hook-file.ts} +0 -0
  69. /package/src/{build-leader-chords.ts → client/build-leader-chords.ts} +0 -0
  70. /package/src/{daemon-error.ts → protocol/daemon-error.ts} +0 -0
  71. /package/src/{outbound-queue.ts → protocol/outbound-queue.ts} +0 -0
  72. /package/src/{collect-clean-env.ts → shared/collect-clean-env.ts} +0 -0
  73. /package/src/{report.ts → shared/report.ts} +0 -0
  74. /package/src/{reset-input-modes.ts → shared/reset-input-modes.ts} +0 -0
  75. /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,86 +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
- readonly exited?: boolean;
77
- }
78
-
79
- const fleetFile = join(stateDir, 'fleet.json');
80
-
81
- export interface FleetStore {
82
- readonly loadFleet: () => FleetEntry[];
83
- readonly writeFleet: (entries: readonly FleetEntry[]) => void;
84
- }
85
-
86
- const jsonFleetStore: FleetStore = {
87
- loadFleet: () => loadFleet(),
88
- writeFleet: (entries) => {
89
- try {
90
- writeFileSync(fleetFile, JSON.stringify(entries, null, 2));
91
- } catch {}
92
- },
93
- };
94
-
95
- export function parseFleetEntry(raw: unknown): FleetEntry | undefined {
96
- if (!isRecord(raw) || typeof raw['name'] !== 'string' || typeof raw['cwd'] !== 'string') {
97
- return undefined;
98
- }
99
-
100
- // Fleet files written before the id key was agent-neutral carry it under
101
- // its Claude-era name.
102
- const agentSessionID = raw['agentSessionID'] ?? raw['claudeId'];
103
-
104
- if (typeof agentSessionID !== 'string') {
105
- return undefined;
106
- }
107
-
108
- return {
109
- name: raw['name'],
110
- cwd: raw['cwd'],
111
- agentSessionID,
112
- agent: toAgentKind(raw['agent']),
113
- ...(raw['pinned'] === true ? { pinned: true } : {}),
114
- ...(typeof raw['lastAttachedAt'] === 'number' ? { lastAttachedAt: raw['lastAttachedAt'] } : {}),
115
- ...(raw['exited'] === true ? { exited: true } : {}),
116
- };
117
- }
118
-
119
- function loadFleet(): FleetEntry[] {
120
- try {
121
- const parsed: unknown = JSON.parse(readFileSync(fleetFile, 'utf8'));
122
-
123
- if (!Array.isArray(parsed)) {
124
- return [];
125
- }
126
-
127
- const entries: FleetEntry[] = [];
128
-
129
- for (const entry of parsed) {
130
- const parsedEntry = parseFleetEntry(entry);
131
-
132
- if (parsedEntry !== undefined) {
133
- entries.push(parsedEntry);
134
- }
135
- }
136
-
137
- return entries;
138
- } catch {
139
- return [];
140
- }
141
- }
142
-
143
72
  export class SessionManager {
144
73
  sessions: Session[] = [];
145
74
 
146
- focusedId: string | null = null;
75
+ focusedId: SessionID | null = null;
147
76
 
148
77
  onOutput: (s: Session, data: string) => void = () => {};
149
78
 
@@ -151,9 +80,12 @@ export class SessionManager {
151
80
 
152
81
  onEvent: (kind: SessionEventKind, s: Session) => void = () => {};
153
82
 
154
- 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;
155
87
 
156
- private readonly adapters: Partial<Readonly<Record<AgentKind, AgentAdapter>>>;
88
+ private readonly adapters: Readonly<Record<AgentID, AgentAdapter>>;
157
89
 
158
90
  private readonly store: FleetStore;
159
91
 
@@ -161,31 +93,30 @@ export class SessionManager {
161
93
 
162
94
  constructor(
163
95
  fallback: AgentAdapter,
164
- store: FleetStore = jsonFleetStore,
96
+ store: FleetStore,
165
97
  statusPath: string | undefined = statusFile,
166
- adapters: Partial<Readonly<Record<AgentKind, AgentAdapter>>> = {},
98
+ adapters: readonly AgentAdapter[] = [],
167
99
  ) {
168
- this.fallback = fallback;
169
- 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);
170
104
  this.store = store;
171
105
  this.statusPath = statusPath ?? statusFile;
172
106
  }
173
107
 
174
108
  /**
175
- * Adapter for this kind, or null when none is registered. Never returns a
176
- * 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.
177
112
  */
178
- findAdapter(kind: AgentKind): AgentAdapter | null {
179
- if (kind === 'claude') {
180
- return this.adapters.claude ?? this.fallback;
181
- }
182
-
183
- return this.adapters[kind] ?? null;
113
+ findAdapter(id: AgentID): AgentAdapter | null {
114
+ return this.adapters[id] ?? null;
184
115
  }
185
116
 
186
117
  // Hands a live terminal session off to a headless run: the terminal dies,
187
118
  // the record lives on as a headless session and keeps its screen history.
188
- yankHeadless(id: string): Session | null {
119
+ yankHeadless(id: SessionID): Session | null {
189
120
  const s = this.sessions.find((x) => x.id === id);
190
121
 
191
122
  if (!s || s.pty === null || s.agentSessionID === undefined) {
@@ -213,15 +144,26 @@ export class SessionManager {
213
144
  restore(entry: FleetEntry): Session {
214
145
  const exited = entry.exited === true;
215
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
+
216
158
  const session: Session = {
217
- id: `s${++counter}-${Date.now().toString(36)}`,
159
+ id: mintSessionID(),
218
160
  name: entry.name,
219
161
  cwd: entry.cwd,
220
162
  kind: 'headless',
221
163
  pty: null,
222
164
  state: exited ? 'exited' : 'running',
223
165
  unread: false,
224
- lastMsg: exited ? 'killed' : 'waiting to restore',
166
+ lastMsg,
225
167
  agentSessionID: entry.agentSessionID,
226
168
  agent: entry.agent,
227
169
  pinned: entry.pinned ?? false,
@@ -240,7 +182,7 @@ export class SessionManager {
240
182
 
241
183
  // Adopts a headless session back into a terminal: a fresh PTY resumes the
242
184
  // same agent session id.
243
- adoptTerminal(id: string, cols: number, rows: number): Session | null {
185
+ adoptTerminal(id: SessionID, cols: number, rows: number): Session | null {
244
186
  const s = this.sessions.find((x) => x.id === id);
245
187
 
246
188
  if (!s || s.pty !== null || s.agentSessionID === undefined) {
@@ -285,7 +227,7 @@ export class SessionManager {
285
227
  this.emitChange();
286
228
  });
287
229
 
288
- this.writeFleet();
230
+ void this.writeFleet();
289
231
  this.onEvent('state', s);
290
232
  this.emitChange();
291
233
 
@@ -297,7 +239,7 @@ export class SessionManager {
297
239
  * user strength, so auto-summaries stop overwriting it while an
298
240
  * in-session rename still wins. Pinned sessions lead every list.
299
241
  */
300
- updateSession(id: string, name?: string, pinned?: boolean): boolean {
242
+ updateSession(id: SessionID, name?: string, pinned?: boolean): boolean {
301
243
  const s = this.sessions.find((x) => x.id === id);
302
244
 
303
245
  if (s === undefined) {
@@ -317,14 +259,14 @@ export class SessionManager {
317
259
  this.onEvent('state', s);
318
260
  }
319
261
 
320
- this.writeFleet();
262
+ void this.writeFleet();
321
263
  this.writeStatus();
322
264
  this.emitChange();
323
265
 
324
266
  return true;
325
267
  }
326
268
 
327
- updateSurfaceState(id: string, state: SessionState, msg: string) {
269
+ updateSurfaceState(id: SessionID, state: SessionState, msg: string) {
328
270
  const s = this.sessions.find((x) => x.id === id);
329
271
 
330
272
  if (!s || s.kind !== 'headless') {
@@ -341,7 +283,7 @@ export class SessionManager {
341
283
 
342
284
  // The detector-stack screen tier reports through here: only flips between
343
285
  // running and needs_you — hook-driven done/exited states always win.
344
- updateAttention(id: string, state: 'needs_you' | 'running', msg: string) {
286
+ updateAttention(id: SessionID, state: 'needs_you' | 'running', msg: string) {
345
287
  const s = this.sessions.find((x) => x.id === id);
346
288
 
347
289
  if (!s || s.pty === null || s.state === state) {
@@ -367,17 +309,17 @@ export class SessionManager {
367
309
  return this.sessions.find((s) => s.id === this.focusedId) ?? null;
368
310
  }
369
311
 
370
- // resume: true opens the agent's own session picker; a string resumes that
371
- // 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).
372
314
  spawn(
373
315
  cwd: string,
374
316
  name: string,
375
317
  prompt: string,
376
318
  cols: number,
377
319
  rows: number,
378
- resume: boolean | string = false,
320
+ resume: boolean | AgentSessionID = false,
379
321
  namedBy: 'user' | 'auto' = 'auto',
380
- agent: AgentKind = 'claude',
322
+ agent: AgentID = 'claude',
381
323
  ): Session {
382
324
  const adapter = this.findAdapter(agent);
383
325
 
@@ -385,7 +327,7 @@ export class SessionManager {
385
327
  throw new Error(`no adapter for agent '${agent}'`);
386
328
  }
387
329
 
388
- const id = `s${++counter}-${Date.now().toString(36)}`;
330
+ const id = mintSessionID();
389
331
  const plan = adapter.planSpawn({ prompt, resume });
390
332
 
391
333
  const pty = spawn(plan.bin, plan.args, {
@@ -440,7 +382,7 @@ export class SessionManager {
440
382
  });
441
383
 
442
384
  this.sessions.push(session);
443
- this.writeFleet();
385
+ void this.writeFleet();
444
386
  this.writeStatus();
445
387
  this.onEvent('added', session);
446
388
 
@@ -465,6 +407,7 @@ export class SessionManager {
465
407
  createdAt: s.createdAt,
466
408
  kind: s.kind,
467
409
  alive: s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'),
410
+ canEject: (this.findAdapter(s.agent)?.headlessRunner ?? null) !== null,
468
411
  }));
469
412
  }
470
413
 
@@ -496,9 +439,7 @@ export class SessionManager {
496
439
 
497
440
  if (ev.agentSessionID !== undefined && s.agentSessionID !== ev.agentSessionID) {
498
441
  s.agentSessionID = ev.agentSessionID;
499
-
500
- this.writeFleet();
501
-
442
+ void this.writeFleet();
502
443
  dirty = true;
503
444
  }
504
445
 
@@ -592,13 +533,13 @@ export class SessionManager {
592
533
  s.namedBy = update.namedBy;
593
534
  }
594
535
 
595
- this.writeFleet();
536
+ void this.writeFleet();
596
537
  this.onEvent('renamed', s);
597
538
  this.emitChange();
598
539
  }
599
540
 
600
541
  // Shell command that re-opens this session outside atc (or anywhere).
601
- buildResumeCommand(id: string): string | null {
542
+ buildResumeCommand(id: SessionID): string | null {
602
543
  const s = this.sessions.find((x) => x.id === id);
603
544
 
604
545
  if (!s) {
@@ -608,7 +549,7 @@ export class SessionManager {
608
549
  return this.findAdapter(s.agent)?.buildResumeCommand(s.cwd, s.agentSessionID) ?? null;
609
550
  }
610
551
 
611
- attach(id: string) {
552
+ attach(id: SessionID) {
612
553
  const s = this.sessions.find((x) => x.id === id);
613
554
 
614
555
  if (!s) {
@@ -617,8 +558,7 @@ export class SessionManager {
617
558
 
618
559
  s.unread = false;
619
560
  s.lastAttachedAt = Date.now();
620
-
621
- this.writeFleet();
561
+ void this.writeFleet();
622
562
 
623
563
  // Attaching answers the attention request: a still-pending prompt
624
564
  // re-flags it via the next notification.
@@ -631,7 +571,7 @@ export class SessionManager {
631
571
  this.emitChange();
632
572
  }
633
573
 
634
- ack(id: string) {
574
+ ack(id: SessionID) {
635
575
  const s = this.sessions.find((x) => x.id === id);
636
576
 
637
577
  if (s) {
@@ -643,7 +583,10 @@ export class SessionManager {
643
583
  this.emitChange();
644
584
  }
645
585
 
646
- 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> {
647
590
  const s = this.sessions.find((x) => x.id === id);
648
591
 
649
592
  if (!s) {
@@ -668,7 +611,8 @@ export class SessionManager {
668
611
  this.onEvent('removed', s);
669
612
  }
670
613
 
671
- this.writeFleet();
614
+ await this.writeFleet();
615
+
672
616
  this.emitChange();
673
617
  }
674
618
 
@@ -699,7 +643,7 @@ export class SessionManager {
699
643
  // so the last known fleet survives for `R` restore. Sessions whose
700
644
  // terminal is gone persist as exited entries, so the killed archive
701
645
  // survives a daemon restart too.
702
- writeFleet() {
646
+ async writeFleet(): Promise<void> {
703
647
  const fleet: FleetEntry[] = [];
704
648
 
705
649
  for (const s of this.sessions) {
@@ -720,7 +664,7 @@ export class SessionManager {
720
664
  });
721
665
  }
722
666
 
723
- this.store.writeFleet(fleet);
667
+ await this.store.writeFleet(fleet);
724
668
  }
725
669
 
726
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
  });
@@ -0,0 +1,31 @@
1
+ import type { z } from 'zod';
2
+ import { isRecord } from '../shared/report';
3
+ import { REQUEST_PARAM_SCHEMAS } from './request-param-schemas';
4
+
5
+ export type RequestMethod = keyof typeof REQUEST_PARAM_SCHEMAS;
6
+
7
+ type RequestParams<M extends RequestMethod> = z.infer<(typeof REQUEST_PARAM_SCHEMAS)[M]>;
8
+
9
+ export type ParsedRequestParams<M extends RequestMethod> =
10
+ | { readonly ok: true; readonly data: RequestParams<M> }
11
+ | { readonly ok: false; readonly message: string };
12
+
13
+ /**
14
+ * Validates and defaults one request's params against its method's schema.
15
+ * A malformed shape folds into a single message the caller reports as
16
+ * bad_args, instead of a thrown ZodError.
17
+ */
18
+ export function parseRequestParams<M extends RequestMethod>(
19
+ method: M,
20
+ rawParams: unknown,
21
+ ): ParsedRequestParams<M> {
22
+ const input = isRecord(rawParams) ? rawParams : {};
23
+ const result = REQUEST_PARAM_SCHEMAS[method].safeParse(input);
24
+
25
+ if (!result.success) {
26
+ return { ok: false, message: result.error.issues[0]?.message ?? 'invalid params' };
27
+ }
28
+
29
+ // oxlint-disable-next-line no-unsafe-type-assertion -- method narrows M, but TS can't follow that through an indexed lookup into the schema map; a successful safeParse against that same lookup guarantees the shape at runtime
30
+ return { ok: true, data: result.data as RequestParams<M> };
31
+ }
@@ -1,4 +1,4 @@
1
- import { isRecord } from './report';
1
+ import { isRecord } from '../shared/report';
2
2
 
3
3
  /**
4
4
  * The daemon/client wire dialect: NDJSON over a unix socket, one JSON object