@zgeoff/atc 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -52,6 +52,7 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
52
52
  | `Y` | overlay | eject: yank the resume command, then kill the session here — paste it in any pane to take over |
53
53
  | `K` | overlay | kill selected (confirm with `y`) — the entry stays revivable with `P`; a second `K` forgets it |
54
54
  | `?` | overlay | full key reference — the hint row only shows actions valid for the selected session |
55
+ | `u` | overlay | restart an outdated daemon and restore the fleet — offered only while `⟳ update ready` shows |
55
56
  | `q` | home/overlay | quit the client — sessions keep running in the daemon |
56
57
 
57
58
  The overlay clusters sessions under dim headers when more than one group exists: a session's
@@ -112,8 +113,11 @@ daemon's pid file sits in `$XDG_RUNTIME_DIR/atc-daemon.pid`, beside its sockets.
112
113
  ## Crash safety
113
114
 
114
115
  A client crash or closed window costs nothing: the daemon keeps hosting the fleet, and the next
115
- `atc` reconnects. The daemon continuously writes the live fleet (name, cwd, Claude session id) to
116
- its SQLite store. If the daemon itself dies — crash, SIGKILL, reboot — the child claude processes
117
- die with it, but every session's transcript is already on disk. Start atc and press `R`: the whole
118
- fleet respawns via `claude --resume`. Only deliberate kills (`K`, `Y` eject) remove entries from the
119
- fleet, so it stays restorable.
116
+ `atc` reconnects. After an update, a client meeting an older daemon keeps talking to it — killing it
117
+ would kill every hosted session — and shows `⟳ update ready` in the status bar; `u` in the overlay
118
+ restarts the daemon and restores the fleet at a moment you choose. Only a protocol mismatch, where
119
+ the two could miscommunicate, forces the restart immediately. The daemon continuously writes the
120
+ live fleet (name, cwd, Claude session id) to its SQLite store. If the daemon itself dies — crash,
121
+ SIGKILL, reboot — the child claude processes die with it, but every session's transcript is already
122
+ on disk. Start atc and press `R`: the whole fleet respawns via `claude --resume`. Only deliberate
123
+ kills (`K`, `Y` eject) remove entries from the fleet, so it stays restorable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Terminal control tower for Claude Code sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -4,37 +4,51 @@ import { basename, join } from 'node:path';
4
4
  import { daemonPidFile, daemonSocketPath } from './config';
5
5
  import { DaemonClient } from './daemon-client';
6
6
  import { getBuild } from './get-build';
7
+ import { isRecord } from './report';
8
+
9
+ export interface DaemonBoot {
10
+ readonly client: DaemonClient;
11
+ readonly stale: boolean;
12
+ }
7
13
 
8
14
  /**
9
15
  * Opens a handshaken client to the daemon, booting the daemon first when its
10
- * socket is absent. A daemon left running from an older build is restarted,
11
- * so a fresh client never talks to stale daemon code — the fleet stays
12
- * restorable afterwards through the usual cold-boot recovery. The expected
16
+ * socket is absent. A daemon from an older build stays in service — killing
17
+ * it would kill every hosted session — and is reported as stale so the
18
+ * caller can offer a deliberate restart. Only a protocol mismatch, where
19
+ * talking would misbehave, forces the restart immediately. The expected
13
20
  * build is read from disk on every attempt: a long-lived caller holding a
14
- * build string from its own boot would otherwise restart daemons that are
21
+ * build string from its own boot would otherwise flag daemons that are
15
22
  * already current.
16
23
  */
17
- export async function bootDaemonClient(): Promise<DaemonClient> {
24
+ export async function bootDaemonClient(): Promise<DaemonBoot> {
18
25
  for (let attempt = 0; attempt < 2; attempt++) {
19
26
  const build = getBuild();
20
27
 
21
28
  const client = await openOrBootDaemon();
22
- const hello = await client.sendHello(build);
23
29
 
24
- const daemonBuild = hello['daemon'];
30
+ try {
31
+ const hello = await client.sendHello(build);
25
32
 
26
- if (daemonBuild === build || attempt > 0) {
27
- return client;
28
- }
33
+ return { client, stale: hello['daemon'] !== build };
34
+ } catch (error) {
35
+ client.stop();
29
36
 
30
- client.stop();
37
+ if (attempt > 0 || !isProtocolMismatch(error)) {
38
+ throw error;
39
+ }
31
40
 
32
- await stopStaleDaemon();
41
+ await stopStaleDaemon();
42
+ }
33
43
  }
34
44
 
35
45
  throw new Error('the atc daemon could not be restarted');
36
46
  }
37
47
 
48
+ function isProtocolMismatch(error: unknown): boolean {
49
+ return isRecord(error) && error['code'] === 'protocol_mismatch';
50
+ }
51
+
38
52
  async function openOrBootDaemon(): Promise<DaemonClient> {
39
53
  let opened = await tryOpenDaemon();
40
54
 
package/src/cli.ts CHANGED
@@ -61,6 +61,7 @@ const main = defineCommand({
61
61
  legacyFleetPath: config.legacyFleetFile,
62
62
  pidPath: config.daemonPidFile,
63
63
  ...(Number.isFinite(queueBytes) && queueBytes > 0 ? { queueBytes } : {}),
64
+ onQuit: () => process.exit(0),
64
65
  });
65
66
 
66
67
  process.on('SIGTERM', () => {
@@ -26,6 +26,7 @@ export interface DaemonContext {
26
26
  readonly spawnSession: (p: SpawnParams) => SessionDescriptor;
27
27
  readonly killSession: (id: string) => boolean;
28
28
  readonly updateSession: (id: string, name?: string, group?: string) => boolean;
29
+ readonly quitDaemon: () => void;
29
30
  readonly ackSession: (id: string) => boolean;
30
31
  readonly buildResumeCommand: (id: string) => string | null;
31
32
  readonly answerPermission: (request: string, decision: string) => AnswerResult;
@@ -215,6 +216,12 @@ export class DaemonConnection {
215
216
 
216
217
  return;
217
218
  }
219
+ case 'daemon.quit': {
220
+ this.sendOk(req.id, {});
221
+ this.ctx.quitDaemon();
222
+
223
+ return;
224
+ }
218
225
  case 'session.update': {
219
226
  const sessionID = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
220
227
  const name = typeof req.p?.['name'] === 'string' ? req.p['name'] : undefined;
package/src/daemon.ts CHANGED
@@ -43,6 +43,10 @@ export interface DaemonOptions {
43
43
  // How long an eject waits for the dying terminal to report SessionEnd
44
44
  // before starting the headless run anyway.
45
45
  readonly ejectSettleMs?: number;
46
+
47
+ // Called after a client-requested quit has stopped the daemon; the real
48
+ // entrypoint exits the process, tests leave it unset.
49
+ readonly onQuit?: () => void;
46
50
  }
47
51
 
48
52
  interface HeadlessRunRequest {
@@ -77,6 +81,8 @@ export interface DaemonHandle {
77
81
  * or hostile peer.
78
82
  */
79
83
  export function startDaemon(opts: DaemonOptions): DaemonHandle {
84
+ let stopDaemon: (() => void) | null = null;
85
+
80
86
  if (opts.pidPath !== undefined) {
81
87
  writeFileSync(opts.pidPath, String(process.pid));
82
88
  }
@@ -409,6 +415,14 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
409
415
  return getDescriptor(mgr, s.id);
410
416
  },
411
417
  updateSession: (id, name, group) => mgr.updateSession(id, name, group),
418
+ quitDaemon: () => {
419
+ // The ok response for the quit request must flush before the sockets
420
+ // close under it.
421
+ setTimeout(() => {
422
+ stopDaemon?.();
423
+ opts.onQuit?.();
424
+ }, 80);
425
+ },
412
426
  killSession: (id) => {
413
427
  if (!mgr.sessions.some((s) => s.id === id)) {
414
428
  return false;
@@ -605,33 +619,33 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
605
619
  },
606
620
  });
607
621
 
608
- return {
609
- stop() {
610
- for (const timer of resizeTimers.values()) {
611
- clearTimeout(timer);
612
- }
622
+ stopDaemon = () => {
623
+ for (const timer of resizeTimers.values()) {
624
+ clearTimeout(timer);
625
+ }
613
626
 
614
- for (const timer of detectTimers.values()) {
615
- clearTimeout(timer);
616
- }
627
+ for (const timer of detectTimers.values()) {
628
+ clearTimeout(timer);
629
+ }
617
630
 
618
- server.stop(true);
619
- reporter.stop(true);
620
- mgr.killAll();
631
+ server.stop(true);
632
+ reporter.stop(true);
633
+ mgr.killAll();
621
634
 
622
- for (const model of screens.values()) {
623
- model.stop();
624
- }
635
+ for (const model of screens.values()) {
636
+ model.stop();
637
+ }
625
638
 
626
- store.stop();
639
+ store.stop();
627
640
 
628
- if (opts.pidPath !== undefined) {
629
- try {
630
- unlinkSync(opts.pidPath);
631
- } catch {}
632
- }
633
- },
641
+ if (opts.pidPath !== undefined) {
642
+ try {
643
+ unlinkSync(opts.pidPath);
644
+ } catch {}
645
+ }
634
646
  };
647
+
648
+ return { stop: stopDaemon };
635
649
  }
636
650
 
637
651
  // A session reporting a transcript path that does not exist on disk yet has
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ import { loadConfig } from './config';
4
4
  import { collectDirs, findFuzzyScore, formatDir, pickMatches } from './dirs';
5
5
  import type { EventMsg } from './protocol';
6
6
  import { isRecord } from './report';
7
- import { countSessionStates, sortSessionViews } from './sessions';
7
+ import { countSessionStates, sortGroupedSessionViews, sortSessionViews } from './sessions';
8
8
  import type { SessionState } from './sessions';
9
9
  import { ansi, cols, drawHelp, drawHome, drawOverlay, drawPicker, drawStatusBar, rows } from './ui';
10
10
 
@@ -81,6 +81,7 @@ function scheduleStatus() {
81
81
  focusedName: focused === null ? null : focused.name,
82
82
  urgentName: urgent === undefined ? null : urgent.name,
83
83
  leaderLabel: leader.label,
84
+ stale: daemonStale,
84
85
  });
85
86
  }
86
87
  }, 50);
@@ -194,7 +195,7 @@ function applyHelpKey(buf: Buffer) {
194
195
  let overlayFilter: string | null = null;
195
196
 
196
197
  function pickOverlaySessions(): MirrorSession[] {
197
- const sorted = sortSessionViews(fleet);
198
+ const sorted = sortGroupedSessionViews(fleet);
198
199
 
199
200
  if (overlayFilter === null || overlayFilter === '') {
200
201
  return sorted;
@@ -217,6 +218,7 @@ function renderOverlay() {
217
218
  selected: overlaySelected,
218
219
  confirmKill,
219
220
  filter: overlayFilter,
221
+ stale: daemonStale,
220
222
  });
221
223
 
222
224
  scheduleStatus();
@@ -227,7 +229,7 @@ function openOverlay() {
227
229
  confirmKill = false;
228
230
  overlayFilter = null;
229
231
 
230
- const focusedIndex = sortSessionViews(fleet).findIndex((s) => s.id === focusedID);
232
+ const focusedIndex = sortGroupedSessionViews(fleet).findIndex((s) => s.id === focusedID);
231
233
 
232
234
  overlaySelected = Math.max(0, focusedIndex);
233
235
 
@@ -727,6 +729,12 @@ function applyOverlayKey(buf: Buffer) {
727
729
  return;
728
730
  }
729
731
 
732
+ if (ch === 'u' && daemonStale) {
733
+ void restartDaemon();
734
+
735
+ return;
736
+ }
737
+
730
738
  if (ch === 'H' && sel !== undefined && sel.kind === 'pty' && sel.alive) {
731
739
  ejectTarget = sel.id;
732
740
  pickerInput = '';
@@ -883,16 +891,23 @@ function applyTextKey(buf: Buffer, onSubmit: () => void, onCancel: () => void) {
883
891
  }
884
892
  }
885
893
 
886
- const client = await bootDaemonClient();
894
+ const boot = await bootDaemonClient();
895
+
896
+ let client = boot.client;
897
+ let daemonStale = boot.stale;
887
898
 
888
899
  client.onEvent = applyDaemonEvent;
889
900
 
890
- {
901
+ await refreshMirror();
902
+
903
+ async function refreshMirror() {
891
904
  const list = await client.sendRequest('session.list');
892
905
 
893
906
  const sessions = list['sessions'];
894
907
 
895
908
  if (Array.isArray(sessions)) {
909
+ fleet = [];
910
+
896
911
  for (const raw of sessions) {
897
912
  const d = toMirrorSession(raw);
898
913
 
@@ -909,6 +924,42 @@ client.onEvent = applyDaemonEvent;
909
924
  fleetCount = Array.isArray(entries) ? entries.length : 0;
910
925
  }
911
926
 
927
+ /**
928
+ * The deliberate half of the stale-daemon story: quits the old daemon,
929
+ * boots one from the current build, and restores the whole fleet, so an
930
+ * update never interrupts sessions until the user picks the moment.
931
+ */
932
+ async function restartDaemon() {
933
+ try {
934
+ await client.sendRequest('daemon.quit');
935
+ } catch {}
936
+
937
+ client.stop();
938
+
939
+ const deadline = Date.now() + 8000;
940
+
941
+ let next = await bootDaemonClient();
942
+
943
+ while (next.stale && Date.now() < deadline) {
944
+ next.client.stop();
945
+
946
+ await new Promise((resolve) => {
947
+ setTimeout(resolve, 200);
948
+ });
949
+
950
+ next = await bootDaemonClient();
951
+ }
952
+
953
+ client = next.client;
954
+ daemonStale = next.stale;
955
+ client.onEvent = applyDaemonEvent;
956
+
957
+ await sendQuiet('fleet.restore', { cols: cols(), rows: ptyRows() });
958
+ await refreshMirror().catch(() => {});
959
+
960
+ openOverlay();
961
+ }
962
+
912
963
  process.stdin.setRawMode(true);
913
964
  process.stdin.resume();
914
965
 
package/src/mcp-server.ts CHANGED
@@ -113,7 +113,9 @@ const TOOLS: readonly MCPTool[] = [
113
113
  * stdout carries only JSON-RPC lines; the daemon is booted on demand.
114
114
  */
115
115
  export async function runMCPServer(build: string): Promise<void> {
116
- const client = await bootDaemonClient();
116
+ const boot = await bootDaemonClient();
117
+
118
+ const client = boot.client;
117
119
 
118
120
  const decoder = new TextDecoder('utf-8');
119
121
 
package/src/sessions.ts CHANGED
@@ -613,3 +613,30 @@ export function sortSessionViews<
613
613
 
614
614
  return [...list].toSorted((a, b) => rank[a.state] - rank[b.state] || a.createdAt - b.createdAt);
615
615
  }
616
+
617
+ // Overlay display order: the urgency sort with each group's sessions pulled
618
+ // together at the position of its most urgent member, so the renderer's
619
+ // adjacency-based headers appear once per group and urgent groups still lead.
620
+ export function sortGroupedSessionViews<
621
+ T extends {
622
+ readonly state: SessionState;
623
+ readonly createdAt: number;
624
+ readonly group?: string;
625
+ readonly cwd: string;
626
+ },
627
+ >(list: readonly T[]): T[] {
628
+ const buckets = new Map<string, T[]>();
629
+
630
+ for (const s of sortSessionViews(list)) {
631
+ const key = s.group ?? s.cwd;
632
+ const bucket = buckets.get(key);
633
+
634
+ if (bucket === undefined) {
635
+ buckets.set(key, [s]);
636
+ } else {
637
+ bucket.push(s);
638
+ }
639
+ }
640
+
641
+ return [...buckets.values()].flat();
642
+ }
package/src/ui.ts CHANGED
@@ -67,6 +67,7 @@ export interface StatusView {
67
67
  readonly focusedName: string | null;
68
68
  readonly urgentName: string | null;
69
69
  readonly leaderLabel: string;
70
+ readonly stale: boolean;
70
71
  }
71
72
 
72
73
  export function drawStatusBar(view: StatusView) {
@@ -93,6 +94,10 @@ export function drawStatusBar(view: StatusView) {
93
94
  parts.push(`✗ ${c.exited}`);
94
95
  }
95
96
 
97
+ if (view.stale) {
98
+ parts.push('⟳ update ready');
99
+ }
100
+
96
101
  const joined = parts.join(' ▏');
97
102
  const right = ` ${joined === '' ? 'idle' : joined} ▏${view.leaderLabel} `;
98
103
  const pad = Math.max(1, width - left.length - right.length);
@@ -161,6 +166,7 @@ export interface OverlayView {
161
166
  selected: number;
162
167
  confirmKill: boolean;
163
168
  filter: string | null;
169
+ stale: boolean;
164
170
  }
165
171
 
166
172
  export function drawOverlay(view: OverlayView) {
@@ -214,6 +220,10 @@ export function drawOverlay(view: OverlayView) {
214
220
 
215
221
  let hint = buildOverlayHint(view.sessions[view.selected]);
216
222
 
223
+ if (view.stale) {
224
+ hint += ' · u update daemon';
225
+ }
226
+
217
227
  if (view.filter !== null) {
218
228
  hint = 'type to filter · ↑↓ move · ⏎ attach · esc clear';
219
229
  }