@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,17 +1,22 @@
1
1
  import { unlinkSync, writeFileSync } from 'node:fs';
2
- import type { AgentAdapter, AgentKind } from './agent-adapter';
2
+ import type { AgentAdapter } from '../agents/agent-adapter';
3
+ import { MAX_CHUNK, PROTOCOL_V } from '../protocol/protocol';
4
+ import type { EventMsg } from '../protocol/protocol';
5
+ import type { SessionID } from '../shared/session-id';
6
+ import { StateStore } from '../store/state-store';
3
7
  import { AttachRegistry } from './attach-registry';
4
- import type { Dims } from './attach-registry';
8
+ import { buildSessionEvent } from './build-session-event';
5
9
  import { DaemonConnection } from './daemon-connection';
6
10
  import type { DaemonContext, OutputClient } from './daemon-connection';
7
11
  import { startHookServer } from './hooks';
8
12
  import { PermissionRegistry } from './permission-registry';
9
- import { MAX_CHUNK, PROTOCOL_V } from './protocol';
10
- import type { EventMsg } from './protocol';
13
+ import { restoreFleet } from './restore-fleet';
14
+ import { runEjectHandoff } from './run-eject-handoff';
11
15
  import { ScreenModel } from './screen-model';
16
+ import { SessionRuntime } from './session-runtime';
12
17
  import { SessionManager } from './sessions';
13
- import type { Session, SessionDescriptor, SessionState } from './sessions';
14
- import { StateStore } from './state-store';
18
+ import type { SessionDescriptor, SessionState } from './sessions';
19
+ import { startHeadlessTurn } from './start-headless-turn';
15
20
 
16
21
  export interface DaemonOptions {
17
22
  readonly socketPath: string;
@@ -21,9 +26,10 @@ export interface DaemonOptions {
21
26
  readonly build: string;
22
27
  readonly adapter: AgentAdapter;
23
28
 
24
- // Optional per-kind adapters. Lookup never falls back across kinds: a
25
- // grok session with no grok adapter is unsupported, not a Claude spawn.
26
- readonly adapters?: Partial<Readonly<Record<AgentKind, AgentAdapter>>>;
29
+ // Adapters registered over and above the default one, each keyed by the id
30
+ // it declares. Lookup never falls back across ids: a grok session with no
31
+ // grok adapter is unsupported, not a Claude spawn.
32
+ readonly adapters?: readonly AgentAdapter[];
27
33
 
28
34
  // SQLite path for daemon state; a fleet.json at legacyFleetPath seeds the
29
35
  // fleet table once so upgrading keeps the restorable fleet.
@@ -56,7 +62,7 @@ export interface DaemonOptions {
56
62
  }
57
63
 
58
64
  export interface DaemonHandle {
59
- readonly stop: () => void;
65
+ readonly stop: () => Promise<void>;
60
66
  }
61
67
 
62
68
  /**
@@ -68,15 +74,16 @@ export interface DaemonHandle {
68
74
  * because transports guarantee byte integrity and such a line means a buggy
69
75
  * or hostile peer.
70
76
  */
71
- export function startDaemon(opts: DaemonOptions): DaemonHandle {
72
- let stopDaemon: (() => void) | null = null;
77
+ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
78
+ let stopDaemon: (() => Promise<void>) | null = null;
73
79
 
74
80
  if (opts.pidPath !== undefined) {
75
81
  writeFileSync(opts.pidPath, String(process.pid));
76
82
  }
77
83
 
78
- const store = new StateStore(opts.dbPath, opts.legacyFleetPath);
79
- const mgr = new SessionManager(opts.adapter, store, opts.statusPath, opts.adapters ?? {});
84
+ const store = await StateStore.open(opts.dbPath, opts.legacyFleetPath);
85
+
86
+ const mgr = new SessionManager(opts.adapter, store, opts.statusPath, opts.adapters ?? []);
80
87
  const clients = new Set<DaemonConnection>();
81
88
 
82
89
  const emitEvent = (event: EventMsg) => {
@@ -102,130 +109,52 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
102
109
  emitEvent({ v: PROTOCOL_V, ev: 'permission.resolved', request: id, decision });
103
110
  };
104
111
 
105
- const headlessRuns = new Map<string, { readonly stop: () => void }>();
106
-
107
- // Resuming an agent session while its old process is still shutting down
108
- // corrupts the handoff, so an eject waits for the terminal's final report
109
- // (or a settle timeout) before the headless run starts.
110
- const pendingEjects = new Map<string, () => void>();
111
-
112
- // A staggered fleet restore parks a resolver here while it waits for the
113
- // session it just revived to report it has booted; the reporter fires it on
114
- // SessionStart, and a dying revive fires it too so a failed resume does not
115
- // hold up the rest. Resolving a settled promise again is a no-op.
116
- const bootWaiters = new Map<string, () => void>();
117
-
118
- // Blocks until the session reports SessionStart, it dies, or the cap
119
- // elapses (a positive cap only; zero waits on the signal alone).
120
- const waitForBoot = (sessionID: string, capMs: number): Promise<void> => {
121
- const settled = Promise.withResolvers<void>();
122
- const timer = capMs > 0 ? setTimeout(settled.resolve, capMs) : undefined;
123
-
124
- bootWaiters.set(sessionID, settled.resolve);
125
-
126
- if (timer !== undefined) {
127
- restoreTimers.add(timer);
128
- }
129
-
130
- return (async () => {
131
- await settled.promise;
132
-
133
- bootWaiters.delete(sessionID);
134
-
135
- if (timer !== undefined) {
136
- clearTimeout(timer);
137
-
138
- restoreTimers.delete(timer);
139
- }
140
- })();
141
- };
142
-
143
- const startHeadlessTurn = (sessionID: string, prompt: string): boolean => {
144
- const s = mgr.sessions.find((x) => x.id === sessionID);
145
- const runner = s === undefined ? null : (mgr.findAdapter(s.agent)?.headlessRunner ?? null);
146
-
147
- if (s === undefined || runner === null || headlessRuns.has(sessionID)) {
148
- return false;
149
- }
112
+ // One runtime per live session: its screen model, output sequence
113
+ // counter, PTY dims, its resize/detect/boot timers, its live headless
114
+ // run, and its pending eject and boot waiters. Created when the session
115
+ // manager announces the session and disposed the moment it is removed, so
116
+ // removal is always a single lookup plus dispose.
117
+ const runtimes = new Map<SessionID, SessionRuntime>();
150
118
 
151
- mgr.updateSurfaceState(sessionID, 'running', 'headless turn running');
152
-
153
- const handle = runner(
154
- {
155
- cwd: s.cwd,
156
- prompt,
157
- ...(s.agentSessionID === undefined ? {} : { resume: s.agentSessionID }),
158
- permissionMode: 'auto',
159
- },
160
- {
161
- onOutput: (text) => {
162
- mgr.onOutput(s, text);
163
- },
164
- onDone: (summary) => {
165
- headlessRuns.delete(sessionID);
166
-
167
- const doneMsg = summary === '' ? 'headless turn done' : summary;
168
-
169
- mgr.updateSurfaceState(sessionID, 'done', doneMsg);
170
- },
171
- onNeedsYou: (msg) => {
172
- headlessRuns.delete(sessionID);
173
- mgr.updateSurfaceState(sessionID, 'needs_you', msg);
174
- },
175
- },
176
- );
177
-
178
- headlessRuns.set(sessionID, handle);
179
-
180
- return true;
181
- };
119
+ const findRuntime = (sessionID: SessionID) => runtimes.get(sessionID);
182
120
 
183
121
  const attachments = new AttachRegistry<OutputClient>();
184
- const seqs = new Map<string, number>();
185
- const ptyDims = new Map<string, Dims>();
186
- const screens = new Map<string, ScreenModel>();
187
- const resizeTimers = new Map<string, ReturnType<typeof setTimeout>>();
188
- const detectTimers = new Map<string, ReturnType<typeof setTimeout>>();
189
- const restoreTimers = new Set<ReturnType<typeof setTimeout>>();
190
- const pendingLastUsed = new Set<string>();
191
122
 
192
123
  // The screen tier of the detector stack: once a session's output has
193
124
  // quiesced, judge the serialized screen and flip running/needs_you.
194
- const scheduleDetect = (sessionID: string) => {
125
+ const scheduleDetect = (sessionID: SessionID) => {
126
+ if (!mgr.hasScreenDetector) {
127
+ return;
128
+ }
129
+
195
130
  const s = mgr.sessions.find((x) => x.id === sessionID);
196
131
  const detector = s === undefined ? null : (mgr.findAdapter(s.agent)?.screenDetector ?? null);
132
+ const runtime = runtimes.get(sessionID);
197
133
 
198
- if (detector === null) {
134
+ if (detector === null || runtime === undefined) {
199
135
  return;
200
136
  }
201
137
 
202
- const pending = detectTimers.get(sessionID);
138
+ clearTimeout(runtime.detectTimer);
203
139
 
204
- if (pending !== undefined) {
205
- clearTimeout(pending);
206
- }
207
-
208
- detectTimers.set(
209
- sessionID,
210
- setTimeout(() => {
211
- detectTimers.delete(sessionID);
212
- void applyScreenJudgment(sessionID);
213
- }, 300),
214
- );
140
+ runtime.detectTimer = setTimeout(() => {
141
+ runtime.detectTimer = undefined;
142
+ void applyScreenJudgment(sessionID);
143
+ }, 300);
215
144
  };
216
145
 
217
146
  // Replay is the serialized screen sent as ordinary output events: the
218
147
  // client cannot tell replay from live and does not need to. A clear leads
219
148
  // so a stale or desynced client screen resets first.
220
- const sendReplay = async (sessionID: string, client: OutputClient) => {
221
- const model = screens.get(sessionID);
149
+ const sendReplay = async (sessionID: SessionID, client: OutputClient) => {
150
+ const runtime = runtimes.get(sessionID);
222
151
 
223
- if (model === undefined) {
152
+ if (runtime === undefined || runtime.screen === null) {
224
153
  return;
225
154
  }
226
155
 
227
- const replay = `\u001B[2J\u001B[H${await model.renderReplay()}`;
228
- const seq = seqs.get(sessionID) ?? 0;
156
+ const replay = `\u001B[2J\u001B[H${await runtime.screen.renderReplay()}`;
157
+ const seq = runtime.seq;
229
158
 
230
159
  for (let i = 0; i < replay.length; i += MAX_CHUNK) {
231
160
  const chunk = replay.slice(i, i + MAX_CHUNK);
@@ -238,24 +167,28 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
238
167
  }
239
168
  };
240
169
 
241
- const applyEffectiveDims = (sessionID: string) => {
170
+ const applyEffectiveDims = (sessionID: SessionID) => {
242
171
  const dims = attachments.findEffectiveDims(sessionID);
243
172
 
244
173
  if (dims === null) {
245
174
  return;
246
175
  }
247
176
 
248
- const prev = ptyDims.get(sessionID);
177
+ const runtime = runtimes.get(sessionID);
178
+ const prev = runtime?.dims ?? null;
249
179
 
250
- if (prev !== undefined && prev.cols === dims.cols && prev.rows === dims.rows) {
180
+ if (prev !== null && prev.cols === dims.cols && prev.rows === dims.rows) {
251
181
  return;
252
182
  }
253
183
 
254
184
  const s = mgr.sessions.find((x) => x.id === sessionID);
255
185
 
256
186
  s?.pty?.resize(dims.cols, dims.rows);
257
- screens.get(sessionID)?.updateDims(dims.cols, dims.rows);
258
- ptyDims.set(sessionID, dims);
187
+ runtime?.screen?.updateDims(dims.cols, dims.rows);
188
+
189
+ if (runtime !== undefined) {
190
+ runtime.dims = dims;
191
+ }
259
192
 
260
193
  emitEvent({
261
194
  v: PROTOCOL_V,
@@ -268,27 +201,26 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
268
201
 
269
202
  // Debounced so two clients resizing in opposite directions cannot produce
270
203
  // a SIGWINCH storm; a no-op effective size never reaches the PTY.
271
- const scheduleResize = (sessionID: string) => {
272
- if (resizeTimers.has(sessionID)) {
204
+ const scheduleResize = (sessionID: SessionID) => {
205
+ const runtime = runtimes.get(sessionID);
206
+
207
+ if (runtime === undefined || runtime.resizeTimer !== undefined) {
273
208
  return;
274
209
  }
275
210
 
276
- resizeTimers.set(
277
- sessionID,
278
- setTimeout(() => {
279
- resizeTimers.delete(sessionID);
211
+ runtime.resizeTimer = setTimeout(() => {
212
+ runtime.resizeTimer = undefined;
280
213
 
281
- applyEffectiveDims(sessionID);
282
- }, 50),
283
- );
214
+ applyEffectiveDims(sessionID);
215
+ }, 50);
284
216
  };
285
217
 
286
- const applyScreenJudgment = async (sessionID: string) => {
218
+ const applyScreenJudgment = async (sessionID: SessionID) => {
287
219
  const s = mgr.sessions.find((x) => x.id === sessionID);
288
220
  const detector = s === undefined ? null : (mgr.findAdapter(s.agent)?.screenDetector ?? null);
289
- const model = screens.get(sessionID);
221
+ const model = runtimes.get(sessionID)?.screen ?? null;
290
222
 
291
- if (detector === null || model === undefined) {
223
+ if (detector === null || model === null) {
292
224
  return;
293
225
  }
294
226
 
@@ -306,9 +238,11 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
306
238
  };
307
239
 
308
240
  mgr.onOutput = (s, data) => {
241
+ const runtime = runtimes.get(s.id);
242
+
309
243
  // The screen model consumes every byte continuously — background output
310
244
  // is consumed, not discarded.
311
- screens.get(s.id)?.record(data);
245
+ runtime?.screen?.record(data);
312
246
  scheduleDetect(s.id);
313
247
 
314
248
  const conns = attachments.collectClients(s.id);
@@ -317,7 +251,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
317
251
  return;
318
252
  }
319
253
 
320
- let seq = seqs.get(s.id) ?? 0;
254
+ let seq = runtime?.seq ?? 0;
321
255
 
322
256
  for (let i = 0; i < data.length; i += MAX_CHUNK) {
323
257
  const chunk = data.slice(i, i + MAX_CHUNK);
@@ -331,13 +265,16 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
331
265
  }
332
266
  }
333
267
 
334
- seqs.set(s.id, seq);
268
+ if (runtime !== undefined) {
269
+ runtime.seq = seq;
270
+ }
335
271
  };
336
272
 
337
273
  // Permission requests are synthesized from attention transitions: entering
338
274
  // needs_you opens one, and leaving it (answered directly in the terminal,
339
- // or the session dying) dismisses whatever is pending.
340
- const lastStates = new Map<string, SessionState>();
275
+ // or the session dying) dismisses whatever is pending. Each session's
276
+ // previous state is tracked purely to detect that transition.
277
+ const lastStates = new Map<SessionID, SessionState>();
341
278
 
342
279
  const recordAttention: SessionManager['onEvent'] = (kind, s) => {
343
280
  if (kind === 'removed') {
@@ -363,76 +300,50 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
363
300
  mgr.onEvent = (kind, s) => {
364
301
  recordAttention(kind, s);
365
302
 
303
+ if (kind === 'added') {
304
+ runtimes.set(s.id, new SessionRuntime());
305
+ }
306
+
366
307
  // A revive that dies before it ever announces itself must still release
367
308
  // the staggered restore, or a failed resume would hold up the fleet.
368
- if (kind === 'removed' || (kind === 'state' && s.pty === null)) {
369
- bootWaiters.get(s.id)?.();
309
+ if (kind === 'state' && s.pty === null) {
310
+ runtimes.get(s.id)?.bootWaiter?.();
370
311
  }
371
312
 
372
313
  if (kind === 'removed') {
373
314
  attachments.removeSession(s.id);
374
- seqs.delete(s.id);
375
- ptyDims.delete(s.id);
376
- screens.get(s.id)?.stop();
377
- screens.delete(s.id);
378
-
379
- const pendingDetect = detectTimers.get(s.id);
315
+ runtimes.get(s.id)?.dispose();
316
+ runtimes.delete(s.id);
317
+ }
380
318
 
381
- if (pendingDetect !== undefined) {
382
- clearTimeout(pendingDetect);
319
+ const event = buildSessionEvent(mgr, kind, s);
383
320
 
384
- detectTimers.delete(s.id);
385
- }
321
+ if (event !== null) {
322
+ emitEvent(event);
386
323
  }
387
-
388
- const builders: Record<typeof kind, () => EventMsg> = {
389
- added: () => ({ v: PROTOCOL_V, ev: 'session.added', session: getDescriptor(mgr, s.id) }),
390
- state: () => ({
391
- v: PROTOCOL_V,
392
- ev: 'session.state',
393
- s: s.id,
394
- state: s.state,
395
- unread: s.unread,
396
- lastMsg: s.lastMsg,
397
- kind: s.kind,
398
- alive: s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'),
399
- ...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
400
- agent: s.agent,
401
- pinned: s.pinned,
402
- lastAttachedAt: s.lastAttachedAt,
403
- }),
404
- renamed: () => ({
405
- v: PROTOCOL_V,
406
- ev: 'session.renamed',
407
- s: s.id,
408
- name: s.name,
409
- namedBy: s.namedBy,
410
- }),
411
- removed: () => ({ v: PROTOCOL_V, ev: 'session.removed', s: s.id }),
412
- };
413
-
414
- emitEvent(builders[kind]());
415
324
  };
416
325
 
417
326
  const reporter = startHookServer((e) => {
418
327
  if (e.event !== 'Statusline') {
419
- store.recordEvent(e);
328
+ void store.recordEvent(e);
420
329
  }
421
330
 
422
331
  const kind = mgr.applyHook(e);
332
+ const runtime = runtimes.get(e.atcId);
423
333
 
424
334
  if (kind === 'ended') {
425
- pendingEjects.get(e.atcId)?.();
335
+ runtime?.pendingEject?.();
426
336
  }
427
337
 
428
338
  // A revived session announcing itself is the cue a staggered restore
429
339
  // waits on before booting the next one.
430
340
  if (kind === 'started') {
431
- bootWaiters.get(e.atcId)?.();
341
+ runtime?.bootWaiter?.();
432
342
  const started = mgr.sessions.find((s) => s.id === e.atcId);
433
343
 
434
- if (started !== undefined && pendingLastUsed.delete(started.id)) {
435
- store.writeLastUsedAgent(started.agent);
344
+ if (started !== undefined && runtime !== undefined && runtime.pendingLastUsed) {
345
+ runtime.pendingLastUsed = false;
346
+ void store.writeLastUsedAgent(started.agent);
436
347
  }
437
348
  }
438
349
  }, opts.reporterSocketPath);
@@ -446,11 +357,16 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
446
357
  findAdapter: (kind) => mgr.findAdapter(kind),
447
358
  spawnSession: (p) => {
448
359
  const s = mgr.spawn(p.cwd, p.name, p.prompt, p.cols, p.rows, p.resume, p.namedBy, p.agent);
360
+ const runtime = runtimes.get(s.id);
361
+
362
+ if (runtime !== undefined) {
363
+ runtime.pendingLastUsed = true;
364
+ runtime.dims = { cols: p.cols, rows: p.rows };
365
+
366
+ runtime.screen = new ScreenModel(p.cols, p.rows);
367
+ }
449
368
 
450
- pendingLastUsed.add(s.id);
451
- ptyDims.set(s.id, { cols: p.cols, rows: p.rows });
452
- screens.set(s.id, new ScreenModel(p.cols, p.rows));
453
- store.recordSpawnDir(p.cwd);
369
+ void store.recordSpawnDir(p.cwd);
454
370
 
455
371
  return getDescriptor(mgr, s.id);
456
372
  },
@@ -459,18 +375,21 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
459
375
  // The ok response for the quit request must flush before the sockets
460
376
  // close under it.
461
377
  setTimeout(() => {
462
- stopDaemon?.();
463
- opts.onQuit?.();
378
+ void (async () => {
379
+ await stopDaemon?.();
380
+
381
+ opts.onQuit?.();
382
+ })();
464
383
  }, 80);
465
384
  },
466
- killSession: (id) => {
385
+ killSession: async (id) => {
467
386
  if (!mgr.sessions.some((s) => s.id === id)) {
468
387
  return false;
469
388
  }
470
389
 
471
- headlessRuns.get(id)?.stop();
472
- headlessRuns.delete(id);
473
- mgr.kill(id);
390
+ runtimes.get(id)?.stopHeadlessRun();
391
+
392
+ await mgr.kill(id);
474
393
 
475
394
  return true;
476
395
  },
@@ -497,20 +416,19 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
497
416
  return 'missing';
498
417
  }
499
418
 
500
- const settled = Promise.withResolvers<void>();
501
- const timer = setTimeout(settled.resolve, opts.ejectSettleMs ?? 4000);
502
-
503
- pendingEjects.set(id, settled.resolve);
504
-
505
- void (async () => {
506
- await settled.promise;
419
+ const runtime = runtimes.get(id);
507
420
 
508
- clearTimeout(timer);
509
-
510
- pendingEjects.delete(id);
421
+ if (runtime === undefined) {
422
+ return 'missing';
423
+ }
511
424
 
512
- startHeadlessTurn(id, prompt);
513
- })();
425
+ runEjectHandoff({
426
+ sessionID: id,
427
+ prompt,
428
+ settleMs: opts.ejectSettleMs ?? 4000,
429
+ runtime,
430
+ startHeadlessTurn: (sid, p) => startHeadlessTurn(mgr, findRuntime, sid, p),
431
+ });
514
432
 
515
433
  return 'ok';
516
434
  },
@@ -519,19 +437,19 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
519
437
  return 'no_transcript';
520
438
  }
521
439
 
522
- headlessRuns.get(id)?.stop();
523
- headlessRuns.delete(id);
440
+ const runtime = runtimes.get(id);
524
441
 
442
+ runtime?.stopHeadlessRun();
525
443
  const adopted = mgr.adoptTerminal(id, cols, rows);
526
444
 
527
445
  if (adopted === null) {
528
446
  return 'missing';
529
447
  }
530
448
 
531
- ptyDims.set(id, { cols, rows });
449
+ if (runtime !== undefined) {
450
+ runtime.dims = { cols, rows };
532
451
 
533
- if (!screens.has(id)) {
534
- screens.set(id, new ScreenModel(cols, rows));
452
+ runtime.screen ??= new ScreenModel(cols, rows);
535
453
  }
536
454
 
537
455
  scheduleResize(id);
@@ -586,11 +504,11 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
586
504
  }
587
505
 
588
506
  if (s.kind === 'headless') {
589
- if (headlessRuns.has(sessionID)) {
507
+ if ((runtimes.get(sessionID)?.headlessRun ?? null) !== null) {
590
508
  return 'busy';
591
509
  }
592
510
 
593
- return startHeadlessTurn(sessionID, data.trimEnd()) ? 'ok' : 'dead';
511
+ return startHeadlessTurn(mgr, findRuntime, sessionID, data.trimEnd()) ? 'ok' : 'dead';
594
512
  }
595
513
 
596
514
  if (s.pty === null) {
@@ -613,77 +531,10 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
613
531
  resyncClient: sendReplay,
614
532
  ...(opts.queueBytes === undefined ? {} : { queueBytes: opts.queueBytes }),
615
533
  getEffectiveDims: (sessionID) =>
616
- attachments.findEffectiveDims(sessionID) ?? ptyDims.get(sessionID) ?? { cols: 80, rows: 24 },
617
- restoreFleet: (cols, rows) => {
618
- const hasSession = (agentSessionID: string) =>
619
- mgr.sessions.some(
620
- (s) =>
621
- s.agentSessionID === agentSessionID &&
622
- (s.pty !== null || (s.kind === 'headless' && s.state !== 'exited')),
623
- );
624
-
625
- const recency = store.collectFleetRecency();
626
-
627
- // Most recently active sessions revive first, so the ones the user was
628
- // just working in come back before long-idle ones; entries that never
629
- // reported an event keep their stored order at the end.
630
- const entries = store
631
- .loadFleet()
632
- .filter((entry) => !hasSession(entry.agentSessionID))
633
- .toSorted((a, b) =>
634
- (recency.get(b.agentSessionID) ?? '').localeCompare(recency.get(a.agentSessionID) ?? ''),
635
- );
636
-
637
- // The whole fleet registers as terminal-less sessions up front, so the
638
- // list shows every incoming session immediately instead of revealing
639
- // them one boot at a time.
640
- const queued = entries.map((entry) => mgr.restore(entry));
641
-
642
- const adoptQueued = (s: Session): boolean => {
643
- if (mgr.adoptTerminal(s.id, cols, rows) === null) {
644
- return false;
645
- }
646
-
647
- ptyDims.set(s.id, { cols, rows });
648
-
649
- if (!screens.has(s.id)) {
650
- screens.set(s.id, new ScreenModel(cols, rows));
651
- }
652
-
653
- return true;
654
- };
655
-
656
- const [first, ...rest] = queued;
657
-
658
- if (first === undefined) {
659
- return 0;
660
- }
661
-
662
- // The first terminal attaches synchronously so a caller can attach at
663
- // once; each later one waits for the previous session to report it has
664
- // booted, so a heavy fleet comes up one process at a time instead of
665
- // all at once. A per-session cap keeps a session that never reports
666
- // from stalling the rest.
667
- const cap = opts.restoreBootTimeoutMs ?? 0;
668
-
669
- const adoptRest = async (previous: Session | null) => {
670
- let prev = previous;
671
-
672
- for (const s of rest) {
673
- if (prev !== null) {
674
- await waitForBoot(prev.id, cap);
675
- }
676
-
677
- prev = adoptQueued(s) ? s : null;
678
- }
679
- };
680
-
681
- const firstBooted = adoptQueued(first) ? first : null;
682
-
683
- void adoptRest(firstBooted);
684
-
685
- return queued.length;
686
- },
534
+ attachments.findEffectiveDims(sessionID) ??
535
+ runtimes.get(sessionID)?.dims ?? { cols: 80, rows: 24 },
536
+ restoreFleet: (cols, rows) =>
537
+ restoreFleet({ mgr, store, findRuntime, cols, rows, capMs: opts.restoreBootTimeoutMs ?? 0 }),
687
538
  };
688
539
 
689
540
  try {
@@ -712,28 +563,18 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
712
563
  },
713
564
  });
714
565
 
715
- stopDaemon = () => {
716
- for (const timer of resizeTimers.values()) {
717
- clearTimeout(timer);
718
- }
719
-
720
- for (const timer of detectTimers.values()) {
721
- clearTimeout(timer);
722
- }
723
-
724
- for (const timer of restoreTimers) {
725
- clearTimeout(timer);
726
- }
727
-
566
+ stopDaemon = async () => {
728
567
  server.stop(true);
729
568
  reporter.stop(true);
730
569
  mgr.killAll();
731
570
 
732
- for (const model of screens.values()) {
733
- model.stop();
571
+ for (const runtime of runtimes.values()) {
572
+ runtime.dispose();
734
573
  }
735
574
 
736
- store.stop();
575
+ runtimes.clear();
576
+
577
+ await store.stop();
737
578
 
738
579
  if (opts.pidPath !== undefined) {
739
580
  try {
@@ -745,7 +586,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
745
586
  return { stop: stopDaemon };
746
587
  }
747
588
 
748
- function hasResumableTranscript(mgr: SessionManager, id: string): boolean {
589
+ function hasResumableTranscript(mgr: SessionManager, id: SessionID): boolean {
749
590
  const s = mgr.sessions.find((x) => x.id === id);
750
591
 
751
592
  if (s === undefined) {
@@ -764,7 +605,7 @@ function hasResumableTranscript(mgr: SessionManager, id: string): boolean {
764
605
  });
765
606
  }
766
607
 
767
- function getDescriptor(mgr: SessionManager, id: string): SessionDescriptor {
608
+ function getDescriptor(mgr: SessionManager, id: SessionID): SessionDescriptor {
768
609
  const d = mgr.collectDescriptors().find((x) => x.id === id);
769
610
 
770
611
  if (d === undefined) {