@zgeoff/atc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,1076 @@
1
+ import { basename } from 'node:path';
2
+ import { bootDaemonClient } from './boot-daemon';
3
+ import { collectDirs, formatDir, pickMatches } from './dirs';
4
+ import type { EventMsg } from './protocol';
5
+ import { isRecord } from './report';
6
+ import { countSessionStates, sortSessionViews } from './sessions';
7
+ import type { SessionState } from './sessions';
8
+ import { ansi, cols, drawHelp, drawHome, drawOverlay, drawPicker, drawStatusBar, rows } from './ui';
9
+
10
+ type Mode =
11
+ | 'home'
12
+ | 'attached'
13
+ | 'overlay'
14
+ | 'help'
15
+ | 'picker-dir'
16
+ | 'picker-name'
17
+ | 'picker-prompt'
18
+ | 'picker-eject';
19
+
20
+ interface MirrorSession {
21
+ id: string;
22
+ name: string;
23
+ cwd: string;
24
+ state: SessionState;
25
+ unread: boolean;
26
+ lastMsg: string;
27
+ createdAt: number;
28
+ kind: 'pty' | 'jsonl';
29
+ alive: boolean;
30
+ resumable: boolean;
31
+ }
32
+
33
+ let mode: Mode = 'home';
34
+ let overlaySelected = 0;
35
+ let confirmKill = false;
36
+
37
+ // The client's mirror of the daemon's fleet, kept fresh by events.
38
+ let fleet: MirrorSession[] = [];
39
+ let focusedID: string | null = null;
40
+ let fleetCount = 0;
41
+
42
+ // picker state carried across the dir -> name -> prompt steps
43
+ let allDirs: string[] = [];
44
+ let pickerInput = '';
45
+ let pickerSelected = 0;
46
+ let spawnDir = '';
47
+ let spawnName = '';
48
+ let spawnResume = false;
49
+ const stdout = process.stdout;
50
+
51
+ // Full height: the atc status bar only exists on home/overlay screens;
52
+ // attached sessions render fleet state via their own status line.
53
+ function ptyRows(): number {
54
+ return Math.max(4, rows());
55
+ }
56
+
57
+ function findFocused(): MirrorSession | null {
58
+ return fleet.find((s) => s.id === focusedID) ?? null;
59
+ }
60
+
61
+ let statusTimer: ReturnType<typeof setTimeout> | null = null;
62
+
63
+ function scheduleStatus() {
64
+ if (statusTimer !== null) {
65
+ return;
66
+ }
67
+
68
+ statusTimer = setTimeout(() => {
69
+ statusTimer = null;
70
+
71
+ if (mode !== 'attached') {
72
+ const focused = findFocused();
73
+ const urgent = sortSessionViews(fleet).find((s) => s.state === 'needs_you');
74
+
75
+ drawStatusBar({
76
+ counts: countSessionStates(fleet),
77
+ focusedName: focused === null ? null : focused.name,
78
+ urgentName: urgent === undefined ? null : urgent.name,
79
+ });
80
+ }
81
+ }, 50);
82
+ }
83
+
84
+ async function sendQuiet(m: string, p?: Readonly<Record<string, unknown>>) {
85
+ try {
86
+ await client.sendRequest(m, p);
87
+ } catch {}
88
+ }
89
+
90
+ async function attach(sessionID: string) {
91
+ focusedID = sessionID;
92
+ mode = 'attached';
93
+
94
+ stdout.write(ansi.clear + ansi.showCursor);
95
+
96
+ try {
97
+ await client.sendRequest('session.attach', {
98
+ session: sessionID,
99
+ cols: cols(),
100
+ rows: ptyRows(),
101
+ });
102
+ } catch {
103
+ mode = 'overlay';
104
+
105
+ openOverlay();
106
+
107
+ return;
108
+ }
109
+
110
+ scheduleStatus();
111
+ }
112
+
113
+ function detachFocused() {
114
+ if (focusedID !== null) {
115
+ void sendQuiet('session.detach', { session: focusedID });
116
+ }
117
+ }
118
+
119
+ function toBase() {
120
+ const f = findFocused();
121
+
122
+ if (f !== null && f.alive) {
123
+ mode = 'attached';
124
+
125
+ stdout.write(ansi.clear + ansi.showCursor);
126
+ void sendQuiet('session.attach', { session: f.id, cols: cols(), rows: ptyRows() });
127
+ } else {
128
+ mode = 'home';
129
+
130
+ drawHome(fleetCount);
131
+ }
132
+
133
+ scheduleStatus();
134
+ }
135
+
136
+ let ejectTarget = '';
137
+
138
+ async function yankToHeadless(instruction: string) {
139
+ try {
140
+ await client.sendRequest('session.eject', {
141
+ session: ejectTarget,
142
+ ...(instruction === '' ? {} : { prompt: instruction }),
143
+ });
144
+ } catch (error) {
145
+ recordActionFailure(ejectTarget, error);
146
+ }
147
+
148
+ openOverlay();
149
+ }
150
+
151
+ async function adoptSession(id: string) {
152
+ try {
153
+ await client.sendRequest('session.adopt', { session: id, cols: cols(), rows: ptyRows() });
154
+
155
+ await attach(id);
156
+ } catch (error) {
157
+ recordActionFailure(id, error);
158
+ openOverlay();
159
+ }
160
+ }
161
+
162
+ // A failed overlay action lands in the session's message column, so the
163
+ // reason is visible instead of the action silently doing nothing.
164
+ function recordActionFailure(id: string, error: unknown) {
165
+ const s = fleet.find((x) => x.id === id);
166
+
167
+ if (s !== undefined) {
168
+ s.lastMsg = error instanceof Error ? error.message : String(error);
169
+ }
170
+ }
171
+
172
+ function openHelp() {
173
+ mode = 'help';
174
+
175
+ stdout.write(ansi.clear);
176
+
177
+ drawHelp();
178
+ }
179
+
180
+ function applyHelpKey(buf: Buffer) {
181
+ const ch = buf.toString();
182
+
183
+ if (buf[0] === KEY.esc || ch === '?' || ch === 'q' || buf[0] === KEY.ctrlSpace) {
184
+ openOverlay();
185
+ }
186
+ }
187
+
188
+ // fzf-style overlay search: null when inactive, the pattern while active.
189
+ let overlayFilter: string | null = null;
190
+
191
+ function pickOverlaySessions(): MirrorSession[] {
192
+ const sorted = sortSessionViews(fleet);
193
+
194
+ if (overlayFilter === null || overlayFilter === '') {
195
+ return sorted;
196
+ }
197
+
198
+ const f = overlayFilter.toLowerCase();
199
+
200
+ return sorted.filter((s) => `${s.name} ${formatDir(s.cwd)}`.toLowerCase().includes(f));
201
+ }
202
+
203
+ function renderOverlay() {
204
+ const sessions = pickOverlaySessions();
205
+
206
+ overlaySelected = Math.max(0, Math.min(overlaySelected, sessions.length - 1));
207
+
208
+ drawOverlay({
209
+ sessions,
210
+ selected: overlaySelected,
211
+ confirmKill,
212
+ filter: overlayFilter,
213
+ });
214
+
215
+ scheduleStatus();
216
+ }
217
+
218
+ function openOverlay() {
219
+ mode = 'overlay';
220
+ confirmKill = false;
221
+ overlayFilter = null;
222
+
223
+ const focusedIndex = sortSessionViews(fleet).findIndex((s) => s.id === focusedID);
224
+
225
+ overlaySelected = Math.max(0, focusedIndex);
226
+
227
+ stdout.write(ansi.clear);
228
+
229
+ renderOverlay();
230
+ }
231
+
232
+ function renderPicker() {
233
+ const verb = spawnResume ? 'adopt' : 'spawn';
234
+
235
+ if (mode === 'picker-dir') {
236
+ const items = pickMatches(allDirs, pickerInput).map((d) => formatDir(d));
237
+
238
+ pickerSelected = Math.min(pickerSelected, Math.max(0, Math.min(items.length, 10) - 1));
239
+
240
+ drawPicker({
241
+ title: `${verb}: directory`,
242
+ items,
243
+ selected: pickerSelected,
244
+ input: pickerInput,
245
+ hint: 'type to filter · ↑↓ move · ⏎ select · esc cancel',
246
+ });
247
+ } else if (mode === 'picker-name') {
248
+ drawPicker({
249
+ title: `${verb}: name`,
250
+ items: [],
251
+ selected: -1,
252
+ input: pickerInput,
253
+ placeholder: basename(spawnDir),
254
+ hint: `session name for ${formatDir(spawnDir)} · ⏎ accept · esc back`,
255
+ });
256
+ } else if (mode === 'picker-eject') {
257
+ drawPicker({
258
+ title: 'eject: headless instruction',
259
+ items: [],
260
+ selected: -1,
261
+ input: pickerInput,
262
+ placeholder: 'continue the task autonomously',
263
+ hint: 'instruction for the headless run · ⏎ eject · esc back',
264
+ });
265
+ } else {
266
+ drawPicker({
267
+ title: 'spawn: initial prompt',
268
+ items: [],
269
+ selected: -1,
270
+ input: pickerInput,
271
+ placeholder: 'optional — ⏎ to start interactive',
272
+ hint: 'first message for the session · ⏎ spawn · esc back',
273
+ });
274
+ }
275
+
276
+ scheduleStatus();
277
+ }
278
+
279
+ async function openDirPicker(resume = false) {
280
+ spawnResume = resume;
281
+
282
+ let recent: string[] = [];
283
+
284
+ try {
285
+ const answer = await client.sendRequest('dirs.list');
286
+
287
+ const dirs = answer['dirs'];
288
+
289
+ if (Array.isArray(dirs)) {
290
+ recent = dirs.filter((d): d is string => typeof d === 'string');
291
+ }
292
+ } catch {}
293
+
294
+ allDirs = await collectDirs(recent);
295
+
296
+ pickerInput = '';
297
+ pickerSelected = 0;
298
+ mode = 'picker-dir';
299
+
300
+ stdout.write(ansi.clear);
301
+
302
+ renderPicker();
303
+ }
304
+
305
+ async function restoreFleet() {
306
+ try {
307
+ await client.sendRequest('fleet.restore', { cols: cols(), rows: ptyRows() });
308
+ } catch {
309
+ return;
310
+ }
311
+
312
+ const first = sortSessionViews(fleet).find((s) => s.alive);
313
+
314
+ if (first !== undefined) {
315
+ await attach(first.id);
316
+ }
317
+ }
318
+
319
+ async function spawnFromPicker(prompt: string) {
320
+ try {
321
+ const ok = await client.sendRequest('session.spawn', {
322
+ cwd: spawnDir,
323
+ name: spawnName,
324
+ prompt,
325
+ cols: cols(),
326
+ rows: ptyRows(),
327
+ ...(spawnResume ? { resume: true } : {}),
328
+ });
329
+
330
+ const spawned = toMirrorSession(ok['session']);
331
+
332
+ if (spawned !== null) {
333
+ upsertMirror(spawned);
334
+
335
+ await attach(spawned.id);
336
+
337
+ return;
338
+ }
339
+ } catch {}
340
+
341
+ toBase();
342
+ }
343
+
344
+ function quit(code = 0): never {
345
+ detachFocused();
346
+
347
+ client.stop();
348
+ stdout.write(ansi.showCursor + ansi.altScreenOff + ansi.reset);
349
+
350
+ try {
351
+ process.stdin.setRawMode(false);
352
+ } catch {}
353
+
354
+ process.exit(code);
355
+ }
356
+
357
+ const SESSION_STATES: readonly SessionState[] = ['running', 'needs_you', 'done', 'exited'];
358
+
359
+ function toMirrorSession(value: unknown): MirrorSession | null {
360
+ if (!isRecord(value)) {
361
+ return null;
362
+ }
363
+
364
+ const state = SESSION_STATES.find((x) => x === value['state']);
365
+
366
+ if (
367
+ typeof value['id'] !== 'string' ||
368
+ typeof value['name'] !== 'string' ||
369
+ typeof value['cwd'] !== 'string' ||
370
+ state === undefined ||
371
+ typeof value['unread'] !== 'boolean' ||
372
+ typeof value['lastMsg'] !== 'string' ||
373
+ typeof value['createdAt'] !== 'number' ||
374
+ typeof value['alive'] !== 'boolean'
375
+ ) {
376
+ return null;
377
+ }
378
+
379
+ return {
380
+ id: value['id'],
381
+ name: value['name'],
382
+ cwd: value['cwd'],
383
+ state,
384
+ unread: value['unread'],
385
+ lastMsg: value['lastMsg'],
386
+ createdAt: value['createdAt'],
387
+ kind: value['kind'] === 'jsonl' ? 'jsonl' : 'pty',
388
+ alive: value['alive'],
389
+ resumable: typeof value['claudeId'] === 'string',
390
+ };
391
+ }
392
+
393
+ function upsertMirror(d: Readonly<MirrorSession>) {
394
+ const existing = fleet.find((s) => s.id === d.id);
395
+
396
+ if (existing === undefined) {
397
+ fleet.push({ ...d });
398
+ } else {
399
+ existing.name = d.name;
400
+ existing.state = d.state;
401
+ existing.unread = d.unread;
402
+ existing.lastMsg = d.lastMsg;
403
+ existing.kind = d.kind;
404
+ existing.alive = d.alive;
405
+ existing.resumable = d.resumable;
406
+ }
407
+ }
408
+
409
+ function refreshScreens() {
410
+ if (mode === 'overlay') {
411
+ renderOverlay();
412
+ }
413
+
414
+ // Focused session died under us: surface the list instead of a dead screen.
415
+ const f = findFocused();
416
+
417
+ if (mode === 'attached' && f !== null && f.state === 'exited') {
418
+ openOverlay();
419
+ }
420
+
421
+ scheduleStatus();
422
+ }
423
+
424
+ function applyDaemonEvent(e: EventMsg) {
425
+ switch (e.ev) {
426
+ case 'session.output': {
427
+ if (mode === 'attached' && e['s'] === focusedID && typeof e['d'] === 'string') {
428
+ stdout.write(e['d']);
429
+ }
430
+
431
+ return;
432
+ }
433
+ case 'session.added': {
434
+ const d = toMirrorSession(e['session']);
435
+
436
+ if (d !== null) {
437
+ upsertMirror(d);
438
+ }
439
+
440
+ refreshScreens();
441
+
442
+ return;
443
+ }
444
+ case 'session.state': {
445
+ const s = fleet.find((x) => x.id === e['s']);
446
+
447
+ if (s !== undefined) {
448
+ const next = SESSION_STATES.find((x) => x === e['state']);
449
+
450
+ if (next !== undefined) {
451
+ s.state = next;
452
+ }
453
+
454
+ if (typeof e['alive'] === 'boolean') {
455
+ s.alive = e['alive'];
456
+ } else if (next === 'exited') {
457
+ s.alive = false;
458
+ }
459
+
460
+ if (e['kind'] === 'pty' || e['kind'] === 'jsonl') {
461
+ s.kind = e['kind'];
462
+ }
463
+
464
+ if (typeof e['unread'] === 'boolean') {
465
+ s.unread = e['unread'];
466
+ }
467
+
468
+ if (typeof e['lastMsg'] === 'string') {
469
+ s.lastMsg = e['lastMsg'];
470
+ }
471
+
472
+ if (typeof e['claudeId'] === 'string') {
473
+ s.resumable = true;
474
+ }
475
+ }
476
+
477
+ refreshScreens();
478
+
479
+ return;
480
+ }
481
+ case 'session.renamed': {
482
+ const s = fleet.find((x) => x.id === e['s']);
483
+
484
+ if (s !== undefined && typeof e['name'] === 'string') {
485
+ s.name = e['name'];
486
+ }
487
+
488
+ refreshScreens();
489
+
490
+ return;
491
+ }
492
+ case 'session.removed': {
493
+ fleet = fleet.filter((x) => x.id !== e['s']);
494
+
495
+ if (focusedID === e['s']) {
496
+ focusedID = null;
497
+ }
498
+
499
+ refreshScreens();
500
+ break;
501
+ }
502
+
503
+ // Desync recovery arrives as ordinary repaint output; permission
504
+ // events matter to structured clients, not this passthrough TUI.
505
+ default:
506
+ }
507
+ }
508
+
509
+ // Best-effort clipboard: OSC 52 (works through zellij/tmux/ssh) plus any
510
+ // local clipboard helper that exists.
511
+ function copyToClipboard(text: string) {
512
+ stdout.write(`\u001B]52;c;${Buffer.from(text).toString('base64')}\u0007`);
513
+
514
+ for (const cmd of [['clip.exe'], ['wl-copy'], ['xclip', '-selection', 'clipboard']]) {
515
+ try {
516
+ const proc = Bun.spawn(cmd, { stdin: 'pipe', stdout: 'ignore', stderr: 'ignore' });
517
+
518
+ void proc.stdin.write(text);
519
+ void proc.stdin.end();
520
+ break;
521
+ } catch {}
522
+ }
523
+ }
524
+
525
+ // ---- input ----
526
+
527
+ const KEY = {
528
+ ctrlSpace: 0x00,
529
+ ctrlC: 0x03,
530
+ ctrlU: 0x15,
531
+ esc: 0x1b,
532
+ enter: 0x0d,
533
+ backspace: 0x7f,
534
+ } as const;
535
+
536
+ function isUp(buf: Buffer): boolean {
537
+ return buf.toString() === '\u001B[A' || buf.toString() === '\u001BOA';
538
+ }
539
+
540
+ function isDown(buf: Buffer): boolean {
541
+ return buf.toString() === '\u001B[B' || buf.toString() === '\u001BOB';
542
+ }
543
+
544
+ function applyOverlayFilterKey(buf: Buffer): boolean {
545
+ if (overlayFilter === null) {
546
+ return false;
547
+ }
548
+
549
+ // esc clears the filter but stays in the overlay; enter falls through to
550
+ // the normal handler so it attaches the selected match.
551
+ if (buf[0] === KEY.esc && buf.length === 1) {
552
+ overlayFilter = null;
553
+
554
+ stdout.write(ansi.clear);
555
+
556
+ renderOverlay();
557
+
558
+ return true;
559
+ }
560
+
561
+ if (buf[0] === KEY.backspace) {
562
+ overlayFilter = overlayFilter.slice(0, -1);
563
+
564
+ stdout.write(ansi.clear);
565
+
566
+ renderOverlay();
567
+
568
+ return true;
569
+ }
570
+
571
+ if (buf[0] === KEY.ctrlU) {
572
+ overlayFilter = '';
573
+
574
+ stdout.write(ansi.clear);
575
+
576
+ renderOverlay();
577
+
578
+ return true;
579
+ }
580
+
581
+ if (buf[0] === KEY.enter || isUp(buf) || isDown(buf) || buf[0] === KEY.ctrlSpace) {
582
+ return false;
583
+ }
584
+
585
+ const text = buf.toString();
586
+ let printable = text.length > 0;
587
+
588
+ for (const c of text) {
589
+ if (c < ' ' || c === '\u007F') {
590
+ printable = false;
591
+ break;
592
+ }
593
+ }
594
+
595
+ if (printable) {
596
+ overlayFilter += text;
597
+
598
+ stdout.write(ansi.clear);
599
+
600
+ renderOverlay();
601
+
602
+ return true;
603
+ }
604
+
605
+ return true;
606
+ }
607
+
608
+ function applyOverlayKey(buf: Buffer) {
609
+ const filtered = pickOverlaySessions();
610
+ const sel = filtered[overlaySelected];
611
+
612
+ if (confirmKill) {
613
+ if (buf[0] === 0x79 /* y */ && sel !== undefined) {
614
+ void sendQuiet('session.kill', { session: sel.id });
615
+ }
616
+
617
+ confirmKill = false;
618
+
619
+ renderOverlay();
620
+
621
+ return;
622
+ }
623
+
624
+ if (applyOverlayFilterKey(buf)) {
625
+ return;
626
+ }
627
+
628
+ if (buf.toString() === '/') {
629
+ overlayFilter = '';
630
+ overlaySelected = 0;
631
+
632
+ stdout.write(ansi.clear);
633
+
634
+ renderOverlay();
635
+
636
+ return;
637
+ }
638
+
639
+ if (buf[0] === KEY.ctrlSpace || (buf[0] === KEY.esc && buf.length === 1)) {
640
+ toBase();
641
+
642
+ return;
643
+ }
644
+
645
+ if (isDown(buf) || buf.toString() === 'j') {
646
+ overlaySelected = Math.min(filtered.length - 1, overlaySelected + 1);
647
+
648
+ renderOverlay();
649
+
650
+ return;
651
+ }
652
+
653
+ if (isUp(buf) || buf.toString() === 'k') {
654
+ overlaySelected = Math.max(0, overlaySelected - 1);
655
+
656
+ renderOverlay();
657
+
658
+ return;
659
+ }
660
+
661
+ const ch = buf.toString();
662
+
663
+ if (buf[0] === KEY.enter && sel !== undefined && sel.alive) {
664
+ void attach(sel.id);
665
+
666
+ return;
667
+ }
668
+
669
+ if (ch === 'a' && sel !== undefined) {
670
+ void sendQuiet('session.ack', { session: sel.id });
671
+ renderOverlay();
672
+
673
+ return;
674
+ }
675
+
676
+ if (ch === 'n') {
677
+ void openDirPicker();
678
+
679
+ return;
680
+ }
681
+
682
+ if (ch === '?') {
683
+ openHelp();
684
+
685
+ return;
686
+ }
687
+
688
+ if (ch === 'H' && sel !== undefined && sel.kind === 'pty' && sel.alive) {
689
+ ejectTarget = sel.id;
690
+ pickerInput = '';
691
+ mode = 'picker-eject';
692
+
693
+ stdout.write(ansi.clear);
694
+
695
+ renderPicker();
696
+
697
+ return;
698
+ }
699
+
700
+ if (ch === 'P' && sel !== undefined && sel.resumable && (sel.kind === 'jsonl' || !sel.alive)) {
701
+ void adoptSession(sel.id);
702
+
703
+ return;
704
+ }
705
+
706
+ if (ch === 'r') {
707
+ void openDirPicker(true);
708
+
709
+ return;
710
+ }
711
+
712
+ if ((ch === 'y' || ch === 'Y') && sel !== undefined) {
713
+ void yankResume(sel.id, ch === 'Y');
714
+
715
+ return;
716
+ }
717
+
718
+ if (ch === 'K' && sel !== undefined) {
719
+ confirmKill = true;
720
+
721
+ renderOverlay();
722
+
723
+ return;
724
+ }
725
+
726
+ if (ch === 'q' || buf[0] === KEY.ctrlC) {
727
+ quit();
728
+ }
729
+ }
730
+
731
+ async function yankResume(sessionID: string, eject: boolean) {
732
+ try {
733
+ const answer = await client.sendRequest('session.resumeCommand', { session: sessionID });
734
+
735
+ const command = answer['command'];
736
+
737
+ if (typeof command !== 'string') {
738
+ return;
739
+ }
740
+
741
+ copyToClipboard(command);
742
+
743
+ const s = fleet.find((x) => x.id === sessionID);
744
+
745
+ if (s !== undefined) {
746
+ s.lastMsg = 'resume cmd copied';
747
+ }
748
+
749
+ // Eject hands the session off entirely.
750
+ if (eject) {
751
+ void sendQuiet('session.kill', { session: sessionID });
752
+ }
753
+
754
+ if (mode === 'overlay') {
755
+ renderOverlay();
756
+ }
757
+ } catch {}
758
+ }
759
+
760
+ function applyTextKey(buf: Buffer, onSubmit: () => void, onCancel: () => void) {
761
+ // Pasted chunks arrive as one buffer: split into per-char events so a
762
+ // trailing newline still submits. Escape sequences stay intact.
763
+ if (buf.length > 1 && buf[0] !== KEY.esc) {
764
+ for (const ch of buf.toString()) {
765
+ applyTextKey(Buffer.from(ch), onSubmit, onCancel);
766
+
767
+ if (mode === 'attached' || ch === '\r') {
768
+ return;
769
+ }
770
+ }
771
+
772
+ return;
773
+ }
774
+
775
+ if (buf[0] === KEY.esc && buf.length === 1) {
776
+ onCancel();
777
+
778
+ return;
779
+ }
780
+
781
+ if (buf[0] === KEY.ctrlSpace) {
782
+ toBase();
783
+
784
+ return;
785
+ }
786
+
787
+ if (buf[0] === KEY.enter) {
788
+ onSubmit();
789
+
790
+ return;
791
+ }
792
+
793
+ if (buf[0] === KEY.backspace) {
794
+ pickerInput = pickerInput.slice(0, -1);
795
+
796
+ renderPicker();
797
+
798
+ return;
799
+ }
800
+
801
+ if (buf[0] === KEY.ctrlU) {
802
+ pickerInput = '';
803
+
804
+ renderPicker();
805
+
806
+ return;
807
+ }
808
+
809
+ if (mode === 'picker-dir') {
810
+ if (isDown(buf)) {
811
+ pickerSelected++;
812
+
813
+ renderPicker();
814
+
815
+ return;
816
+ }
817
+
818
+ if (isUp(buf)) {
819
+ pickerSelected = Math.max(0, pickerSelected - 1);
820
+
821
+ renderPicker();
822
+
823
+ return;
824
+ }
825
+ }
826
+
827
+ const text = buf.toString();
828
+ let printable = true;
829
+
830
+ for (const c of text) {
831
+ if (c < ' ' || c === '\u007F') {
832
+ printable = false;
833
+ break;
834
+ }
835
+ }
836
+
837
+ if (printable) {
838
+ pickerInput += text;
839
+
840
+ renderPicker();
841
+ }
842
+ }
843
+
844
+ const client = await bootDaemonClient();
845
+
846
+ client.onEvent = applyDaemonEvent;
847
+
848
+ {
849
+ const list = await client.sendRequest('session.list');
850
+
851
+ const sessions = list['sessions'];
852
+
853
+ if (Array.isArray(sessions)) {
854
+ for (const raw of sessions) {
855
+ const d = toMirrorSession(raw);
856
+
857
+ if (d !== null) {
858
+ upsertMirror(d);
859
+ }
860
+ }
861
+ }
862
+
863
+ const answer = await client.sendRequest('fleet.list').catch(() => ({}) as const);
864
+
865
+ const entries = (answer as Record<string, unknown>)['fleet'];
866
+
867
+ fleetCount = Array.isArray(entries) ? entries.length : 0;
868
+ }
869
+
870
+ process.stdin.setRawMode(true);
871
+ process.stdin.resume();
872
+
873
+ // Stateful decode for the stdin -> daemon path: a multi-byte character split
874
+ // across two stdin reads must not be mangled by per-chunk toString().
875
+ const stdinDecoder = new TextDecoder('utf-8');
876
+
877
+ process.stdin.on('data', (buf: Buffer) => {
878
+ switch (mode) {
879
+ case 'attached': {
880
+ if (buf[0] === KEY.ctrlSpace && buf.length === 1) {
881
+ openOverlay();
882
+
883
+ return;
884
+ }
885
+
886
+ if (focusedID !== null) {
887
+ void sendQuiet('session.input', {
888
+ session: focusedID,
889
+ d: stdinDecoder.decode(buf, { stream: true }),
890
+ });
891
+ }
892
+
893
+ return;
894
+ }
895
+ case 'home': {
896
+ if (buf[0] === KEY.ctrlSpace) {
897
+ openOverlay();
898
+
899
+ return;
900
+ }
901
+
902
+ const ch = buf.toString();
903
+
904
+ if (ch === 'n') {
905
+ void openDirPicker();
906
+
907
+ return;
908
+ }
909
+
910
+ if (ch === 'r') {
911
+ void openDirPicker(true);
912
+
913
+ return;
914
+ }
915
+
916
+ if (ch === 'R') {
917
+ void restoreFleet();
918
+
919
+ return;
920
+ }
921
+
922
+ if (ch === 'q' || buf[0] === KEY.ctrlC) {
923
+ quit();
924
+ }
925
+
926
+ return;
927
+ }
928
+ case 'overlay': {
929
+ applyOverlayKey(buf);
930
+
931
+ return;
932
+ }
933
+ case 'help': {
934
+ applyHelpKey(buf);
935
+
936
+ return;
937
+ }
938
+ case 'picker-dir': {
939
+ applyTextKey(
940
+ buf,
941
+ () => {
942
+ const items = pickMatches(allDirs, pickerInput);
943
+ const raw = pickerInput.trim();
944
+ let chosen = items[pickerSelected] ?? null;
945
+
946
+ if (chosen === null && (raw.startsWith('/') || raw.startsWith('~'))) {
947
+ chosen = raw.replace(/^~/u, process.env['HOME'] ?? '~');
948
+ }
949
+
950
+ if (chosen === null) {
951
+ return;
952
+ }
953
+
954
+ spawnDir = chosen;
955
+ pickerInput = '';
956
+ mode = 'picker-name';
957
+
958
+ stdout.write(ansi.clear);
959
+
960
+ renderPicker();
961
+ },
962
+ () => {
963
+ toBase();
964
+ },
965
+ );
966
+
967
+ return;
968
+ }
969
+ case 'picker-name': {
970
+ applyTextKey(
971
+ buf,
972
+ () => {
973
+ spawnName = pickerInput.trim();
974
+ pickerInput = '';
975
+
976
+ // Adopt skips the prompt step: claude --resume opens its own
977
+ // session picker inside the new PTY.
978
+ if (spawnResume) {
979
+ void spawnFromPicker('');
980
+
981
+ return;
982
+ }
983
+
984
+ mode = 'picker-prompt';
985
+
986
+ stdout.write(ansi.clear);
987
+
988
+ renderPicker();
989
+ },
990
+ () => {
991
+ pickerInput = '';
992
+ mode = 'picker-dir';
993
+
994
+ stdout.write(ansi.clear);
995
+
996
+ renderPicker();
997
+ },
998
+ );
999
+
1000
+ return;
1001
+ }
1002
+ case 'picker-prompt': {
1003
+ applyTextKey(
1004
+ buf,
1005
+ () => {
1006
+ void spawnFromPicker(pickerInput.trim());
1007
+ },
1008
+ () => {
1009
+ pickerInput = '';
1010
+ mode = 'picker-name';
1011
+
1012
+ stdout.write(ansi.clear);
1013
+
1014
+ renderPicker();
1015
+ },
1016
+ );
1017
+
1018
+ return;
1019
+ }
1020
+ case 'picker-eject': {
1021
+ applyTextKey(
1022
+ buf,
1023
+ () => {
1024
+ void yankToHeadless(pickerInput.trim());
1025
+ },
1026
+ () => {
1027
+ openOverlay();
1028
+ },
1029
+ );
1030
+ }
1031
+ }
1032
+ });
1033
+
1034
+ stdout.on('resize', () => {
1035
+ if (mode === 'attached' && focusedID !== null) {
1036
+ void sendQuiet('session.resize', { session: focusedID, cols: cols(), rows: ptyRows() });
1037
+ }
1038
+
1039
+ if (mode === 'home') {
1040
+ drawHome(fleetCount);
1041
+ }
1042
+
1043
+ if (mode === 'overlay') {
1044
+ stdout.write(ansi.clear);
1045
+
1046
+ renderOverlay();
1047
+ }
1048
+
1049
+ if (mode.startsWith('picker')) {
1050
+ stdout.write(ansi.clear);
1051
+
1052
+ renderPicker();
1053
+ }
1054
+
1055
+ scheduleStatus();
1056
+ });
1057
+
1058
+ process.on('uncaughtException', (err) => {
1059
+ stdout.write(ansi.showCursor + ansi.altScreenOff + ansi.reset);
1060
+
1061
+ try {
1062
+ process.stdin.setRawMode(false);
1063
+ } catch {}
1064
+
1065
+ console.error(err);
1066
+ process.exit(1);
1067
+ });
1068
+
1069
+ process.on('SIGTERM', () => quit());
1070
+ process.on('SIGHUP', () => quit());
1071
+
1072
+ // ---- start ----
1073
+ stdout.write(ansi.altScreenOn);
1074
+
1075
+ drawHome(fleetCount);
1076
+ scheduleStatus();