@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,52 +1,21 @@
1
- import { basename } from 'node:path';
1
+ import { match } from 'ts-pattern';
2
+ import { createActor } from 'xstate';
3
+ import type { AgentID } from '../agents/agent-adapter';
4
+ import { countSessionStates, sortGroupedSessionViews, sortSessionViews } from '../daemon/sessions';
5
+ import type { EventMsg } from '../protocol/protocol';
6
+ import { loadConfig } from '../shared/config';
2
7
  import { bootDaemonClient } from './boot-daemon';
8
+ import { buildClientMachine } from './build-client-machine';
3
9
  import { buildLeaderChords } from './build-leader-chords';
4
- import { loadConfig } from './config';
5
- import { collectDirs, findFuzzyScore, formatDir, pickMatches } from './dirs';
10
+ import { findFuzzyScore, formatDir } from './dirs';
11
+ import { KEY, isDown, isUp, planTextEdit } from './keys';
12
+ import { parseDaemonEvent } from './parse-daemon-event';
6
13
  import { pickTabTarget } from './pick-tab-target';
7
- import type { EventMsg } from './protocol';
8
- import { isRecord } from './report';
9
- import { countSessionStates, sortGroupedSessionViews, sortSessionViews } from './sessions';
10
- import type { SessionState } from './sessions';
14
+ import { SpawnPicker } from './spawn-picker';
15
+ import { toMirrorSession } from './to-mirror-session';
16
+ import type { MirrorSession } from './to-mirror-session';
11
17
  import { ansi, cols, drawHelp, drawHome, drawOverlay, drawPicker, drawStatusBar, rows } from './ui';
12
18
 
13
- type AgentKind = 'claude' | 'grok' | 'codex';
14
-
15
- type Mode =
16
- | 'home'
17
- | 'attached'
18
- | 'overlay'
19
- | 'help'
20
- | 'picker-agent'
21
- | 'picker-dir'
22
- | 'picker-name'
23
- | 'picker-prompt'
24
- | 'picker-eject';
25
-
26
- const AGENT_PICKS = [
27
- { agent: 'claude' as const, label: 'Claude' },
28
- { agent: 'grok' as const, label: 'Grok' },
29
- { agent: 'codex' as const, label: 'Codex' },
30
- ];
31
-
32
- interface MirrorSession {
33
- id: string;
34
- name: string;
35
- cwd: string;
36
- pinned: boolean;
37
- lastAttachedAt: number;
38
- repoRoot: string;
39
- state: SessionState;
40
- unread: boolean;
41
- lastMsg: string;
42
- createdAt: number;
43
- kind: 'pty' | 'headless';
44
- alive: boolean;
45
- resumable: boolean;
46
- agent: AgentKind;
47
- }
48
-
49
- let mode: Mode = 'home';
50
19
  let overlaySelected = 0;
51
20
  let confirmKill = false;
52
21
 
@@ -60,16 +29,14 @@ const doneAt = new Map<string, number>();
60
29
 
61
30
  let focusedID: string | null = null;
62
31
  let fleetCount = 0;
32
+ let lastUsedAgent: AgentID = 'claude';
33
+
34
+ // Read once at start, like the leader key: a newly configured backend gets
35
+ // its overlay letter on the next client run.
36
+ const agentMarks: Readonly<Record<AgentID, string>> = Object.fromEntries(
37
+ loadConfig().gateways.map((g) => [g.id, g.mark]),
38
+ );
63
39
 
64
- // picker state carried across the dir -> name -> prompt steps
65
- let allDirs: string[] = [];
66
- let pickerInput = '';
67
- let pickerSelected = 0;
68
- let spawnDir = '';
69
- let spawnName = '';
70
- let spawnResume = false;
71
- let spawnAgent: AgentKind = 'claude';
72
- let lastUsedAgent: AgentKind = 'claude';
73
40
  const stdout = process.stdout;
74
41
 
75
42
  // Full height: the atc status bar only exists on home/overlay screens;
@@ -82,6 +49,19 @@ function findFocused(): MirrorSession | null {
82
49
  return fleet.find((s) => s.id === focusedID) ?? null;
83
50
  }
84
51
 
52
+ function openHome() {
53
+ drawHome(fleetCount, leader.label);
54
+ scheduleStatus();
55
+ }
56
+
57
+ function openAttached(sessionID: string) {
58
+ focusedID = sessionID;
59
+
60
+ stdout.write(ansi.clear + ansi.showCursor);
61
+
62
+ scheduleStatus();
63
+ }
64
+
85
65
  let statusTimer: ReturnType<typeof setTimeout> | null = null;
86
66
 
87
67
  function scheduleStatus() {
@@ -92,7 +72,7 @@ function scheduleStatus() {
92
72
  statusTimer = setTimeout(() => {
93
73
  statusTimer = null;
94
74
 
95
- if (mode !== 'attached') {
75
+ if (service.getSnapshot().value !== 'attached') {
96
76
  const focused = findFocused();
97
77
  const urgent = sortSessionViews(fleet).find((s) => s.state === 'needs_you');
98
78
 
@@ -114,10 +94,7 @@ async function sendQuiet(m: string, p?: Readonly<Record<string, unknown>>) {
114
94
  }
115
95
 
116
96
  async function attach(sessionID: string) {
117
- focusedID = sessionID;
118
- mode = 'attached';
119
-
120
- stdout.write(ansi.clear + ansi.showCursor);
97
+ service.send({ type: 'ATTACH', sessionID });
121
98
 
122
99
  try {
123
100
  await client.sendRequest('session.attach', {
@@ -126,14 +103,8 @@ async function attach(sessionID: string) {
126
103
  rows: ptyRows(),
127
104
  });
128
105
  } catch {
129
- mode = 'overlay';
130
-
131
- openOverlay();
132
-
133
- return;
106
+ service.send({ type: 'OVERLAY' });
134
107
  }
135
-
136
- scheduleStatus();
137
108
  }
138
109
 
139
110
  function detachFocused() {
@@ -146,20 +117,15 @@ function toBase() {
146
117
  const f = findFocused();
147
118
 
148
119
  if (f !== null && f.alive) {
149
- mode = 'attached';
150
-
151
- stdout.write(ansi.clear + ansi.showCursor);
120
+ service.send({ type: 'ATTACH', sessionID: f.id });
152
121
  void sendQuiet('session.attach', { session: f.id, cols: cols(), rows: ptyRows() });
153
122
  } else {
154
- mode = 'home';
155
-
156
- drawHome(fleetCount, leader.label);
123
+ service.send({ type: 'HOME' });
157
124
  }
158
-
159
- scheduleStatus();
160
125
  }
161
126
 
162
127
  let ejectTarget = '';
128
+ let ejectInput = '';
163
129
 
164
130
  async function yankToHeadless(instruction: string) {
165
131
  try {
@@ -171,7 +137,7 @@ async function yankToHeadless(instruction: string) {
171
137
  recordActionFailure(ejectTarget, error);
172
138
  }
173
139
 
174
- openOverlay();
140
+ service.send({ type: 'OVERLAY' });
175
141
  }
176
142
 
177
143
  async function adoptSession(id: string) {
@@ -181,7 +147,8 @@ async function adoptSession(id: string) {
181
147
  await attach(id);
182
148
  } catch (error) {
183
149
  recordActionFailure(id, error);
184
- openOverlay();
150
+
151
+ service.send({ type: 'OVERLAY' });
185
152
  }
186
153
  }
187
154
 
@@ -196,8 +163,6 @@ function recordActionFailure(id: string, error: unknown) {
196
163
  }
197
164
 
198
165
  function openHelp() {
199
- mode = 'help';
200
-
201
166
  stdout.write(ansi.clear);
202
167
 
203
168
  drawHelp();
@@ -207,7 +172,7 @@ function applyHelpKey(buf: Buffer) {
207
172
  const ch = buf.toString();
208
173
 
209
174
  if (buf[0] === KEY.esc || ch === '?' || ch === 'q' || isLeaderKey(buf)) {
210
- openOverlay();
175
+ service.send({ type: 'OVERLAY' });
211
176
  }
212
177
  }
213
178
 
@@ -236,6 +201,7 @@ function renderOverlay() {
236
201
 
237
202
  drawOverlay({
238
203
  sessions,
204
+ agentMarks,
239
205
  selected: overlaySelected,
240
206
  confirmKill,
241
207
  filter: overlayFilter,
@@ -247,7 +213,6 @@ function renderOverlay() {
247
213
  }
248
214
 
249
215
  function openOverlay() {
250
- mode = 'overlay';
251
216
  confirmKill = false;
252
217
  overlayFilter = null;
253
218
 
@@ -260,102 +225,50 @@ function openOverlay() {
260
225
  renderOverlay();
261
226
  }
262
227
 
263
- function renderPicker() {
264
- const verb = spawnResume ? 'adopt' : 'spawn';
265
-
266
- if (mode === 'picker-agent') {
267
- pickerSelected = Math.min(pickerSelected, AGENT_PICKS.length - 1);
268
-
269
- drawPicker({
270
- title: `${verb}: agent`,
271
- items: AGENT_PICKS.map((pick) => pick.label),
272
- selected: pickerSelected,
273
- input: pickerInput,
274
- hint: '↑↓ move · ⏎ select · esc cancel',
275
- });
276
- } else if (mode === 'picker-dir') {
277
- const items = pickMatches(allDirs, pickerInput).map((d) => formatDir(d));
278
-
279
- pickerSelected = Math.min(pickerSelected, Math.max(0, Math.min(items.length, 10) - 1));
280
-
281
- drawPicker({
282
- title: `${verb}: directory`,
283
- items,
284
- selected: pickerSelected,
285
- input: pickerInput,
286
- hint: 'type to filter · ↑↓ move · ⏎ select · esc cancel',
287
- });
288
- } else if (mode === 'picker-name') {
289
- drawPicker({
290
- title: `${verb}: name`,
291
- items: [],
292
- selected: -1,
293
- input: pickerInput,
294
- placeholder: basename(spawnDir),
295
- hint: `session name for ${formatDir(spawnDir)} · ⏎ accept · esc back`,
296
- });
297
- } else if (mode === 'picker-eject') {
298
- drawPicker({
299
- title: 'eject: headless instruction',
300
- items: [],
301
- selected: -1,
302
- input: pickerInput,
303
- placeholder: 'continue the task autonomously',
304
- hint: 'instruction for the headless run · ⏎ eject · esc back',
305
- });
306
- } else {
307
- drawPicker({
308
- title: 'spawn: initial prompt',
309
- items: [],
310
- selected: -1,
311
- input: pickerInput,
312
- placeholder: 'optional — ⏎ to start interactive',
313
- hint: 'first message for the session · ⏎ spawn · esc back',
314
- });
315
- }
228
+ function renderEject() {
229
+ drawPicker({
230
+ title: 'eject: headless instruction',
231
+ items: [],
232
+ selected: -1,
233
+ input: ejectInput,
234
+ placeholder: 'continue the task autonomously',
235
+ hint: 'instruction for the headless run · ⏎ eject · esc back',
236
+ });
316
237
 
317
238
  scheduleStatus();
318
239
  }
319
240
 
320
- function openAgentPicker(resume = false) {
321
- spawnResume = resume;
322
- spawnAgent = lastUsedAgent;
323
- pickerInput = '';
324
-
325
- pickerSelected = Math.max(
326
- 0,
327
- AGENT_PICKS.findIndex((p) => p.agent === lastUsedAgent),
328
- );
329
-
330
- mode = 'picker-agent';
241
+ function openEject(sessionID: string) {
242
+ ejectTarget = sessionID;
243
+ ejectInput = '';
331
244
 
332
245
  stdout.write(ansi.clear);
333
246
 
334
- renderPicker();
247
+ renderEject();
335
248
  }
336
249
 
337
- async function openDirPicker() {
338
- let recent: string[] = [];
339
-
340
- try {
341
- const answer = await client.sendRequest('dirs.list');
342
-
343
- const dirs = answer['dirs'];
344
-
345
- if (Array.isArray(dirs)) {
346
- recent = dirs.filter((d): d is string => typeof d === 'string');
347
- }
348
- } catch {}
349
-
350
- allDirs = await collectDirs(recent);
250
+ const picker = new SpawnPicker<MirrorSession>({
251
+ sendRequest,
252
+ ptyRows,
253
+ isLeaderKey,
254
+ getLastUsedAgent,
255
+ scheduleStatus,
256
+ toBase,
257
+ attach,
258
+ toMirrorSession,
259
+ upsertMirror,
260
+ });
351
261
 
352
- pickerInput = '';
353
- pickerSelected = 0;
354
- mode = 'picker-dir';
262
+ function openPicker(resume: boolean) {
263
+ picker.open(resume);
264
+ }
355
265
 
356
- stdout.write(ansi.clear);
266
+ function sendRequest(m: string, p?: Readonly<Record<string, unknown>>) {
267
+ return client.sendRequest(m, p);
268
+ }
357
269
 
358
- renderPicker();
270
+ function getLastUsedAgent(): AgentID {
271
+ return lastUsedAgent;
359
272
  }
360
273
 
361
274
  async function restoreFleet() {
@@ -372,32 +285,6 @@ async function restoreFleet() {
372
285
  }
373
286
  }
374
287
 
375
- async function spawnFromPicker(prompt: string) {
376
- try {
377
- const ok = await client.sendRequest('session.spawn', {
378
- cwd: spawnDir,
379
- name: spawnName,
380
- prompt,
381
- cols: cols(),
382
- rows: ptyRows(),
383
- ...(spawnResume ? { resume: true } : {}),
384
- agent: spawnAgent,
385
- });
386
-
387
- const spawned = toMirrorSession(ok['session']);
388
-
389
- if (spawned !== null) {
390
- upsertMirror(spawned);
391
-
392
- await attach(spawned.id);
393
-
394
- return;
395
- }
396
- } catch {}
397
-
398
- toBase();
399
- }
400
-
401
288
  function quit(code = 0): never {
402
289
  detachFocused();
403
290
 
@@ -411,47 +298,6 @@ function quit(code = 0): never {
411
298
  process.exit(code);
412
299
  }
413
300
 
414
- const SESSION_STATES: readonly SessionState[] = ['running', 'needs_you', 'done', 'exited'];
415
-
416
- function toMirrorSession(value: unknown): MirrorSession | null {
417
- if (!isRecord(value)) {
418
- return null;
419
- }
420
-
421
- const state = SESSION_STATES.find((x) => x === value['state']);
422
-
423
- if (
424
- typeof value['id'] !== 'string' ||
425
- typeof value['name'] !== 'string' ||
426
- typeof value['cwd'] !== 'string' ||
427
- state === undefined ||
428
- typeof value['unread'] !== 'boolean' ||
429
- typeof value['lastMsg'] !== 'string' ||
430
- typeof value['createdAt'] !== 'number' ||
431
- typeof value['alive'] !== 'boolean'
432
- ) {
433
- return null;
434
- }
435
-
436
- return {
437
- id: value['id'],
438
- name: value['name'],
439
- cwd: value['cwd'],
440
- pinned: value['pinned'] === true,
441
- lastAttachedAt:
442
- typeof value['lastAttachedAt'] === 'number' ? value['lastAttachedAt'] : value['createdAt'],
443
- repoRoot: typeof value['repoRoot'] === 'string' ? value['repoRoot'] : value['cwd'],
444
- state,
445
- unread: value['unread'],
446
- lastMsg: value['lastMsg'],
447
- createdAt: value['createdAt'],
448
- kind: value['kind'] === 'headless' ? 'headless' : 'pty',
449
- alive: value['alive'],
450
- resumable: typeof value['agentSessionID'] === 'string',
451
- agent: value['agent'] === 'grok' || value['agent'] === 'codex' ? value['agent'] : 'claude',
452
- };
453
- }
454
-
455
301
  function upsertMirror(d: Readonly<MirrorSession>) {
456
302
  const existing = fleet.find((s) => s.id === d.id);
457
303
 
@@ -468,132 +314,92 @@ function upsertMirror(d: Readonly<MirrorSession>) {
468
314
  existing.kind = d.kind;
469
315
  existing.alive = d.alive;
470
316
  existing.resumable = d.resumable;
317
+ existing.canEject = d.canEject;
471
318
  existing.agent = d.agent;
472
319
  }
473
320
  }
474
321
 
475
322
  function refreshScreens() {
476
- if (mode === 'overlay') {
323
+ const currentMode = service.getSnapshot().value;
324
+
325
+ if (currentMode === 'overlay') {
477
326
  renderOverlay();
478
327
  }
479
328
 
480
329
  // Focused session died under us: surface the list instead of a dead screen.
481
330
  const f = findFocused();
482
331
 
483
- if (mode === 'attached' && f !== null && f.state === 'exited') {
484
- openOverlay();
332
+ if (currentMode === 'attached' && f !== null && f.state === 'exited') {
333
+ service.send({ type: 'OVERLAY' });
485
334
  }
486
335
 
487
336
  scheduleStatus();
488
337
  }
489
338
 
490
- function applyDaemonEvent(e: EventMsg) {
491
- switch (e.ev) {
492
- case 'session.output': {
493
- if (mode === 'attached' && e['s'] === focusedID && typeof e['d'] === 'string') {
494
- stdout.write(e['d']);
495
- }
339
+ function applyDaemonEvent(raw: EventMsg) {
340
+ const event = parseDaemonEvent(raw);
496
341
 
497
- return;
498
- }
499
- case 'session.added': {
500
- const d = toMirrorSession(e['session']);
342
+ if (event === null) {
343
+ return;
344
+ }
501
345
 
502
- if (d !== null) {
503
- upsertMirror(d);
346
+ match(event)
347
+ .with({ ev: 'session.output' }, (e) => {
348
+ if (service.getSnapshot().value === 'attached' && e.s === focusedID) {
349
+ stdout.write(e.d);
504
350
  }
505
-
351
+ })
352
+ .with({ ev: 'session.added' }, (e) => {
353
+ upsertMirror(e.session);
506
354
  refreshScreens();
355
+ })
356
+ .with({ ev: 'session.state' }, (e) => {
357
+ const existing = fleet.find((x) => x.id === e.session.id);
507
358
 
508
- return;
509
- }
510
- case 'session.state': {
511
- const s = fleet.find((x) => x.id === e['s']);
512
-
513
- if (s !== undefined) {
514
- const next = SESSION_STATES.find((x) => x === e['state']);
515
-
516
- if (next === 'done' && s.state !== 'done') {
517
- doneAt.set(s.id, Date.now());
518
- }
519
-
520
- if (next !== undefined) {
521
- s.state = next;
522
- }
523
-
524
- if (typeof e['alive'] === 'boolean') {
525
- s.alive = e['alive'];
526
- } else if (next === 'exited') {
527
- s.alive = false;
528
- }
529
-
530
- if (e['kind'] === 'pty' || e['kind'] === 'headless') {
531
- s.kind = e['kind'];
532
- }
533
-
534
- if (typeof e['pinned'] === 'boolean') {
535
- s.pinned = e['pinned'];
536
- }
537
-
538
- if (typeof e['lastAttachedAt'] === 'number') {
539
- s.lastAttachedAt = e['lastAttachedAt'];
540
- }
541
-
542
- if (typeof e['unread'] === 'boolean') {
543
- s.unread = e['unread'];
544
- }
545
-
546
- if (typeof e['lastMsg'] === 'string') {
547
- s.lastMsg = e['lastMsg'];
548
- }
549
-
550
- if (e['agent'] === 'grok' || e['agent'] === 'codex' || e['agent'] === 'claude') {
551
- s.agent = e['agent'];
359
+ if (existing !== undefined) {
360
+ if (e.session.state === 'done' && existing.state !== 'done') {
361
+ doneAt.set(e.session.id, Date.now());
552
362
  }
553
363
 
554
- if (typeof e['agentSessionID'] === 'string') {
555
- if (!s.resumable) {
556
- lastUsedAgent = s.agent;
557
- }
558
-
559
- s.resumable = true;
364
+ if (e.session.resumable && !existing.resumable) {
365
+ lastUsedAgent = e.session.agent;
560
366
  }
561
367
  }
562
368
 
369
+ upsertMirror(e.session);
563
370
  refreshScreens();
371
+ })
372
+ .with({ ev: 'session.renamed' }, (e) => {
373
+ const s = fleet.find((x) => x.id === e.s);
564
374
 
565
- return;
566
- }
567
- case 'session.renamed': {
568
- const s = fleet.find((x) => x.id === e['s']);
569
-
570
- if (s !== undefined && typeof e['name'] === 'string') {
571
- s.name = e['name'];
375
+ if (s !== undefined) {
376
+ s.name = e.name;
572
377
  }
573
378
 
574
379
  refreshScreens();
380
+ })
381
+ .with({ ev: 'session.removed' }, (e) => {
382
+ fleet = fleet.filter((x) => x.id !== e.s);
575
383
 
576
- return;
577
- }
578
- case 'session.removed': {
579
- fleet = fleet.filter((x) => x.id !== e['s']);
580
-
581
- if (typeof e['s'] === 'string') {
582
- doneAt.delete(e['s']);
583
- }
384
+ doneAt.delete(e.s);
584
385
 
585
- if (focusedID === e['s']) {
386
+ if (focusedID === e.s) {
586
387
  focusedID = null;
587
388
  }
588
389
 
589
390
  refreshScreens();
590
- break;
591
- }
391
+ })
592
392
 
593
- // Desync recovery arrives as ordinary repaint output; permission
594
- // events matter to structured clients, not this passthrough TUI.
595
- default:
596
- }
393
+ // Desync recovery arrives as ordinary repaint output; permission and
394
+ // resize events matter to structured clients, not this passthrough TUI.
395
+ .with(
396
+ { ev: 'session.resized' },
397
+ { ev: 'session.desync' },
398
+ { ev: 'permission.requested' },
399
+ { ev: 'permission.resolved' },
400
+ () => {},
401
+ )
402
+ .exhaustive();
597
403
  }
598
404
 
599
405
  // Best-effort clipboard: OSC 52 (works through zellij/tmux/ssh) plus any
@@ -617,23 +423,6 @@ function copyToClipboard(text: string) {
617
423
  const leader = loadConfig().leader;
618
424
  const leaderChords = buildLeaderChords(leader.code);
619
425
 
620
- const KEY = {
621
- tab: 0x09,
622
- ctrlC: 0x03,
623
- ctrlU: 0x15,
624
- esc: 0x1b,
625
- enter: 0x0d,
626
- backspace: 0x7f,
627
- } as const;
628
-
629
- function isUp(buf: Buffer): boolean {
630
- return buf.toString() === '\u001B[A' || buf.toString() === '\u001BOA';
631
- }
632
-
633
- function isDown(buf: Buffer): boolean {
634
- return buf.toString() === '\u001B[B' || buf.toString() === '\u001BOB';
635
- }
636
-
637
426
  // A fullscreen session can leave the terminal in an enhanced keyboard
638
427
  // encoding, so the leader arrives as a CSI chord instead of its bare byte.
639
428
  function isLeaderKey(buf: Buffer): boolean {
@@ -783,7 +572,7 @@ function applyOverlayKey(buf: Buffer) {
783
572
  }
784
573
 
785
574
  if (ch === 'n') {
786
- openAgentPicker();
575
+ service.send({ type: 'SPAWN', resume: false });
787
576
 
788
577
  return;
789
578
  }
@@ -809,7 +598,7 @@ function applyOverlayKey(buf: Buffer) {
809
598
  }
810
599
 
811
600
  if (ch === '?') {
812
- openHelp();
601
+ service.send({ type: 'HELP' });
813
602
 
814
603
  return;
815
604
  }
@@ -820,20 +609,8 @@ function applyOverlayKey(buf: Buffer) {
820
609
  return;
821
610
  }
822
611
 
823
- if (
824
- ch === 'H' &&
825
- sel !== undefined &&
826
- sel.kind === 'pty' &&
827
- sel.alive &&
828
- sel.agent === 'claude'
829
- ) {
830
- ejectTarget = sel.id;
831
- pickerInput = '';
832
- mode = 'picker-eject';
833
-
834
- stdout.write(ansi.clear);
835
-
836
- renderPicker();
612
+ if (ch === 'H' && sel !== undefined && sel.kind === 'pty' && sel.alive && sel.canEject) {
613
+ service.send({ type: 'EJECT', sessionID: sel.id });
837
614
 
838
615
  return;
839
616
  }
@@ -845,7 +622,7 @@ function applyOverlayKey(buf: Buffer) {
845
622
  }
846
623
 
847
624
  if (ch === 'r') {
848
- openAgentPicker(true);
625
+ service.send({ type: 'SPAWN', resume: true });
849
626
 
850
627
  return;
851
628
  }
@@ -892,96 +669,55 @@ async function yankResume(sessionID: string, eject: boolean) {
892
669
  void sendQuiet('session.kill', { session: sessionID });
893
670
  }
894
671
 
895
- if (mode === 'overlay') {
672
+ if (service.getSnapshot().value === 'overlay') {
896
673
  renderOverlay();
897
674
  }
898
675
  } catch {}
899
676
  }
900
677
 
901
- function applyTextKey(buf: Buffer, onSubmit: () => void, onCancel: () => void) {
902
- // Pasted chunks arrive as one buffer: split into per-char events so a
903
- // trailing newline still submits. Escape sequences stay intact.
904
- if (buf.length > 1 && buf[0] !== KEY.esc) {
905
- for (const ch of buf.toString()) {
906
- applyTextKey(Buffer.from(ch), onSubmit, onCancel);
678
+ function applyEjectKey(buf: Buffer) {
679
+ const edit = planTextEdit(buf, ejectInput, { isLeaderKey, moves: false });
907
680
 
908
- if (mode === 'attached' || ch === '\r') {
909
- return;
910
- }
681
+ switch (edit.kind) {
682
+ case 'none':
683
+ case 'move': {
684
+ return;
911
685
  }
912
-
913
- return;
914
- }
915
-
916
- if (buf[0] === KEY.esc && buf.length === 1) {
917
- onCancel();
918
-
919
- return;
920
- }
921
-
922
- if (isLeaderKey(buf)) {
923
- toBase();
924
-
925
- return;
926
- }
927
-
928
- if (buf[0] === KEY.enter) {
929
- onSubmit();
930
-
931
- return;
932
- }
933
-
934
- if (buf[0] === KEY.backspace) {
935
- pickerInput = pickerInput.slice(0, -1);
936
-
937
- renderPicker();
938
-
939
- return;
940
- }
941
-
942
- if (buf[0] === KEY.ctrlU) {
943
- pickerInput = '';
944
-
945
- renderPicker();
946
-
947
- return;
948
- }
949
-
950
- if (mode === 'picker-dir' || mode === 'picker-agent') {
951
- if (isDown(buf)) {
952
- pickerSelected++;
953
-
954
- renderPicker();
686
+ case 'cancel': {
687
+ service.send({ type: 'OVERLAY' });
955
688
 
956
689
  return;
957
690
  }
958
-
959
- if (isUp(buf)) {
960
- pickerSelected = Math.max(0, pickerSelected - 1);
961
-
962
- renderPicker();
691
+ case 'leader': {
692
+ toBase();
963
693
 
964
694
  return;
965
695
  }
966
- }
967
-
968
- const text = buf.toString();
969
- let printable = true;
696
+ case 'submit': {
697
+ ejectInput = edit.value;
698
+ void yankToHeadless(ejectInput.trim());
970
699
 
971
- for (const c of text) {
972
- if (c < ' ' || c === '\u007F') {
973
- printable = false;
974
- break;
700
+ return;
975
701
  }
976
- }
702
+ case 'input': {
703
+ ejectInput = edit.value;
977
704
 
978
- if (printable) {
979
- pickerInput += text;
980
-
981
- renderPicker();
705
+ renderEject();
706
+ }
982
707
  }
983
708
  }
984
709
 
710
+ const service = createActor(
711
+ buildClientMachine({
712
+ openHome,
713
+ openAttached,
714
+ openOverlay,
715
+ openHelp,
716
+ openPicker,
717
+ openEject,
718
+ }),
719
+ );
720
+
985
721
  const boot = await bootDaemonClient();
986
722
 
987
723
  let client = boot.client;
@@ -1050,7 +786,7 @@ async function restartDaemon() {
1050
786
  await sendQuiet('fleet.restore', { cols: cols(), rows: ptyRows() });
1051
787
  await refreshMirror().catch(() => {});
1052
788
 
1053
- openOverlay();
789
+ service.send({ type: 'OVERLAY' });
1054
790
  }
1055
791
 
1056
792
  process.stdin.setRawMode(true);
@@ -1061,10 +797,10 @@ process.stdin.resume();
1061
797
  const stdinDecoder = new TextDecoder('utf-8');
1062
798
 
1063
799
  process.stdin.on('data', (buf: Buffer) => {
1064
- switch (mode) {
800
+ switch (service.getSnapshot().value) {
1065
801
  case 'attached': {
1066
802
  if (isLeaderKey(buf)) {
1067
- openOverlay();
803
+ service.send({ type: 'OVERLAY' });
1068
804
 
1069
805
  return;
1070
806
  }
@@ -1080,7 +816,7 @@ process.stdin.on('data', (buf: Buffer) => {
1080
816
  }
1081
817
  case 'home': {
1082
818
  if (isLeaderKey(buf)) {
1083
- openOverlay();
819
+ service.send({ type: 'OVERLAY' });
1084
820
 
1085
821
  return;
1086
822
  }
@@ -1088,13 +824,13 @@ process.stdin.on('data', (buf: Buffer) => {
1088
824
  const ch = buf.toString();
1089
825
 
1090
826
  if (ch === 'n') {
1091
- openAgentPicker();
827
+ service.send({ type: 'SPAWN', resume: false });
1092
828
 
1093
829
  return;
1094
830
  }
1095
831
 
1096
832
  if (ch === 'r') {
1097
- openAgentPicker(true);
833
+ service.send({ type: 'SPAWN', resume: true });
1098
834
 
1099
835
  return;
1100
836
  }
@@ -1121,146 +857,43 @@ process.stdin.on('data', (buf: Buffer) => {
1121
857
 
1122
858
  return;
1123
859
  }
1124
- case 'picker-agent': {
1125
- applyTextKey(
1126
- buf,
1127
- () => {
1128
- spawnAgent = AGENT_PICKS[pickerSelected]?.agent ?? 'claude';
1129
- void openDirPicker();
1130
- },
1131
- () => {
1132
- toBase();
1133
- },
1134
- );
1135
-
1136
- return;
1137
- }
1138
- case 'picker-dir': {
1139
- applyTextKey(
1140
- buf,
1141
- () => {
1142
- const items = pickMatches(allDirs, pickerInput);
1143
- const raw = pickerInput.trim();
1144
- let chosen = items[pickerSelected] ?? null;
1145
-
1146
- if (chosen === null && (raw.startsWith('/') || raw.startsWith('~'))) {
1147
- chosen = raw.replace(/^~/u, process.env['HOME'] ?? '~');
1148
- }
1149
-
1150
- if (chosen === null) {
1151
- return;
1152
- }
1153
-
1154
- spawnDir = chosen;
1155
- pickerInput = '';
1156
- mode = 'picker-name';
1157
-
1158
- stdout.write(ansi.clear);
1159
-
1160
- renderPicker();
1161
- },
1162
- () => {
1163
- pickerInput = '';
1164
-
1165
- pickerSelected = Math.max(
1166
- 0,
1167
- AGENT_PICKS.findIndex((p) => p.agent === spawnAgent),
1168
- );
1169
-
1170
- mode = 'picker-agent';
1171
-
1172
- stdout.write(ansi.clear);
1173
-
1174
- renderPicker();
1175
- },
1176
- );
1177
-
1178
- return;
1179
- }
1180
- case 'picker-name': {
1181
- applyTextKey(
1182
- buf,
1183
- () => {
1184
- spawnName = pickerInput.trim();
1185
- pickerInput = '';
1186
-
1187
- // Adopt skips the prompt step: claude --resume opens its own
1188
- // session picker inside the new PTY.
1189
- if (spawnResume) {
1190
- void spawnFromPicker('');
1191
-
1192
- return;
1193
- }
1194
-
1195
- mode = 'picker-prompt';
1196
-
1197
- stdout.write(ansi.clear);
1198
-
1199
- renderPicker();
1200
- },
1201
- () => {
1202
- pickerInput = '';
1203
- mode = 'picker-dir';
1204
-
1205
- stdout.write(ansi.clear);
1206
-
1207
- renderPicker();
1208
- },
1209
- );
1210
-
1211
- return;
1212
- }
1213
- case 'picker-prompt': {
1214
- applyTextKey(
1215
- buf,
1216
- () => {
1217
- void spawnFromPicker(pickerInput.trim());
1218
- },
1219
- () => {
1220
- pickerInput = '';
1221
- mode = 'picker-name';
1222
-
1223
- stdout.write(ansi.clear);
1224
-
1225
- renderPicker();
1226
- },
1227
- );
860
+ case 'picker': {
861
+ picker.applyKey(buf);
1228
862
 
1229
863
  return;
1230
864
  }
1231
865
  case 'picker-eject': {
1232
- applyTextKey(
1233
- buf,
1234
- () => {
1235
- void yankToHeadless(pickerInput.trim());
1236
- },
1237
- () => {
1238
- openOverlay();
1239
- },
1240
- );
866
+ applyEjectKey(buf);
1241
867
  }
1242
868
  }
1243
869
  });
1244
870
 
1245
871
  stdout.on('resize', () => {
1246
- if (mode === 'attached' && focusedID !== null) {
872
+ const currentMode = service.getSnapshot().value;
873
+
874
+ if (currentMode === 'attached' && focusedID !== null) {
1247
875
  void sendQuiet('session.resize', { session: focusedID, cols: cols(), rows: ptyRows() });
1248
876
  }
1249
877
 
1250
- if (mode === 'home') {
878
+ if (currentMode === 'home') {
1251
879
  drawHome(fleetCount, leader.label);
1252
880
  }
1253
881
 
1254
- if (mode === 'overlay') {
882
+ if (currentMode === 'overlay') {
1255
883
  stdout.write(ansi.clear);
1256
884
 
1257
885
  renderOverlay();
1258
886
  }
1259
887
 
1260
- if (mode.startsWith('picker')) {
888
+ if (currentMode === 'picker') {
889
+ stdout.write(ansi.clear);
890
+ picker.render();
891
+ }
892
+
893
+ if (currentMode === 'picker-eject') {
1261
894
  stdout.write(ansi.clear);
1262
895
 
1263
- renderPicker();
896
+ renderEject();
1264
897
  }
1265
898
 
1266
899
  scheduleStatus();
@@ -1282,6 +915,4 @@ process.on('SIGHUP', () => quit());
1282
915
 
1283
916
  // ---- start ----
1284
917
  stdout.write(ansi.altScreenOn);
1285
-
1286
- drawHome(fleetCount, leader.label);
1287
- scheduleStatus();
918
+ service.start();