@zgeoff/atc 0.1.4 → 0.1.6

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
@@ -121,3 +121,9 @@ live fleet (name, cwd, Claude session id) to its SQLite store. If the daemon its
121
121
  SIGKILL, reboot — the child claude processes die with it, but every session's transcript is already
122
122
  on disk. Start atc and press `R`: the whole fleet respawns via `claude --resume`. Only deliberate
123
123
  kills (`K`, `Y` eject) remove entries from the fleet, so it stays restorable.
124
+
125
+ Restoring shows the whole fleet immediately — every incoming session appears in the list marked
126
+ "waiting to restore" — and revives one at a time, most recently active first: the next
127
+ `claude --resume` starts only once the previous one has reported it is up (its `SessionStart` hook),
128
+ so bringing back a dozen sessions no longer launches a dozen Claude processes at the same instant
129
+ and pins the machine. Each row flips live as its session comes back.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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",
package/src/cli.ts CHANGED
@@ -50,16 +50,26 @@ const main = defineCommand({
50
50
  // Test harnesses shrink the outbound queue to force overflow
51
51
  // deterministically; unset means the production default.
52
52
  const queueBytes = Number(process.env['ATC_QUEUE_BYTES']);
53
+ const cfg = config.loadConfig();
54
+
55
+ // Cap on how long a fleet restore waits for one revived session to
56
+ // report it has booted before moving to the next. Tests pin it to
57
+ // keep timing deterministic; unset means the production default.
58
+ const capOverride = Number(process.env['ATC_RESTORE_BOOT_TIMEOUT_MS']);
59
+
60
+ const restoreBootTimeoutMs =
61
+ Number.isFinite(capOverride) && capOverride >= 0 ? capOverride : 15_000;
53
62
 
54
63
  const handle = daemon.startDaemon({
55
64
  headlessRunner: (runOpts, hooks) => headless.startHeadlessRun(runOpts, hooks),
56
65
  socketPath: config.daemonSocketPath,
57
66
  reporterSocketPath: config.socketPath,
58
67
  build: getBuild(),
59
- adapter: new claude.ClaudeAdapter(config.loadConfig()),
68
+ adapter: new claude.ClaudeAdapter(cfg),
60
69
  dbPath: config.dbFile,
61
70
  legacyFleetPath: config.legacyFleetFile,
62
71
  pidPath: config.daemonPidFile,
72
+ restoreBootTimeoutMs,
63
73
  ...(Number.isFinite(queueBytes) && queueBytes > 0 ? { queueBytes } : {}),
64
74
  onQuit: () => process.exit(0),
65
75
  });
package/src/daemon.ts CHANGED
@@ -10,7 +10,7 @@ import { MAX_CHUNK, PROTOCOL_V } from './protocol';
10
10
  import type { EventMsg } from './protocol';
11
11
  import { ScreenModel } from './screen-model';
12
12
  import { SessionManager } from './sessions';
13
- import type { SessionDescriptor, SessionState } from './sessions';
13
+ import type { Session, SessionDescriptor, SessionState } from './sessions';
14
14
  import { StateStore } from './state-store';
15
15
 
16
16
  export interface DaemonOptions {
@@ -44,6 +44,13 @@ export interface DaemonOptions {
44
44
  // before starting the headless run anyway.
45
45
  readonly ejectSettleMs?: number;
46
46
 
47
+ // A fleet-wide restore revives one session at a time, waiting for each to
48
+ // report it has booted before starting the next so the machine is not
49
+ // buried under a dozen simultaneous agent boots. This caps how long a
50
+ // single revive waits for that signal before moving on regardless, so a
51
+ // session that never reports cannot stall the rest. Zero waits forever.
52
+ readonly restoreBootTimeoutMs?: number;
53
+
47
54
  // Called after a client-requested quit has stopped the daemon; the real
48
55
  // entrypoint exits the process, tests leave it unset.
49
56
  readonly onQuit?: () => void;
@@ -126,6 +133,37 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
126
133
  // (or a settle timeout) before the headless run starts.
127
134
  const pendingEjects = new Map<string, () => void>();
128
135
 
136
+ // A staggered fleet restore parks a resolver here while it waits for the
137
+ // session it just revived to report it has booted; the reporter fires it on
138
+ // SessionStart, and a dying revive fires it too so a failed resume does not
139
+ // hold up the rest. Resolving a settled promise again is a no-op.
140
+ const bootWaiters = new Map<string, () => void>();
141
+
142
+ // Blocks until the session reports SessionStart, it dies, or the cap
143
+ // elapses (a positive cap only; zero waits on the signal alone).
144
+ const waitForBoot = (sessionID: string, capMs: number): Promise<void> => {
145
+ const settled = Promise.withResolvers<void>();
146
+ const timer = capMs > 0 ? setTimeout(settled.resolve, capMs) : undefined;
147
+
148
+ bootWaiters.set(sessionID, settled.resolve);
149
+
150
+ if (timer !== undefined) {
151
+ restoreTimers.add(timer);
152
+ }
153
+
154
+ return (async () => {
155
+ await settled.promise;
156
+
157
+ bootWaiters.delete(sessionID);
158
+
159
+ if (timer !== undefined) {
160
+ clearTimeout(timer);
161
+
162
+ restoreTimers.delete(timer);
163
+ }
164
+ })();
165
+ };
166
+
129
167
  const startHeadlessTurn = (sessionID: string, prompt: string): boolean => {
130
168
  const runner = opts.headlessRunner;
131
169
  const s = mgr.sessions.find((x) => x.id === sessionID);
@@ -172,6 +210,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
172
210
  const screens = new Map<string, ScreenModel>();
173
211
  const resizeTimers = new Map<string, ReturnType<typeof setTimeout>>();
174
212
  const detectTimers = new Map<string, ReturnType<typeof setTimeout>>();
213
+ const restoreTimers = new Set<ReturnType<typeof setTimeout>>();
175
214
 
176
215
  // The screen tier of the detector stack: once a session's output has
177
216
  // quiesced, judge the serialized screen and flip running/needs_you.
@@ -345,6 +384,12 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
345
384
  mgr.onEvent = (kind, s) => {
346
385
  recordAttention(kind, s);
347
386
 
387
+ // A revive that dies before it ever announces itself must still release
388
+ // the staggered restore, or a failed resume would hold up the fleet.
389
+ if (kind === 'removed' || (kind === 'state' && s.pty === null)) {
390
+ bootWaiters.get(s.id)?.();
391
+ }
392
+
348
393
  if (kind === 'removed') {
349
394
  attachments.removeSession(s.id);
350
395
  seqs.delete(s.id);
@@ -397,6 +442,12 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
397
442
  pendingEjects.get(e.atcId)?.();
398
443
  }
399
444
 
445
+ // A revived session announcing itself is the cue a staggered restore
446
+ // waits on before booting the next one.
447
+ if (e.event === 'SessionStart') {
448
+ bootWaiters.get(e.atcId)?.();
449
+ }
450
+
400
451
  mgr.applyHook(e);
401
452
  }, opts.reporterSocketPath);
402
453
 
@@ -482,6 +533,10 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
482
533
 
483
534
  ptyDims.set(id, { cols, rows });
484
535
 
536
+ if (!screens.has(id)) {
537
+ screens.set(id, new ScreenModel(cols, rows));
538
+ }
539
+
485
540
  scheduleResize(id);
486
541
 
487
542
  return 'ok';
@@ -563,33 +618,74 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
563
618
  getEffectiveDims: (sessionID) =>
564
619
  attachments.findEffectiveDims(sessionID) ?? ptyDims.get(sessionID) ?? { cols: 80, rows: 24 },
565
620
  restoreFleet: (cols, rows) => {
566
- let restored = 0;
567
-
568
- for (const entry of store.loadFleet()) {
569
- const live = mgr.sessions.some((s) => s.pty !== null && s.claudeId === entry.claudeId);
621
+ const hasSession = (claudeId: string) =>
622
+ mgr.sessions.some(
623
+ (s) =>
624
+ s.claudeId === claudeId &&
625
+ (s.pty !== null || (s.kind === 'jsonl' && s.state !== 'exited')),
626
+ );
570
627
 
571
- if (live) {
572
- continue;
573
- }
628
+ const recency = store.collectFleetRecency();
574
629
 
575
- const s = mgr.spawn(
576
- entry.cwd,
577
- entry.name,
578
- '',
579
- cols,
580
- rows,
581
- entry.claudeId,
582
- 'auto',
583
- entry.group,
630
+ // Most recently active sessions revive first, so the ones the user was
631
+ // just working in come back before long-idle ones; entries that never
632
+ // reported an event keep their stored order at the end.
633
+ const entries = store
634
+ .loadFleet()
635
+ .filter((entry) => !hasSession(entry.claudeId))
636
+ .toSorted((a, b) =>
637
+ (recency.get(b.claudeId) ?? '').localeCompare(recency.get(a.claudeId) ?? ''),
584
638
  );
585
639
 
640
+ // The whole fleet registers as terminal-less sessions up front, so the
641
+ // list shows every incoming session immediately instead of revealing
642
+ // them one boot at a time.
643
+ const queued = entries.map((entry) => mgr.restore(entry));
644
+
645
+ const adoptQueued = (s: Session): boolean => {
646
+ if (mgr.adoptTerminal(s.id, cols, rows) === null) {
647
+ return false;
648
+ }
649
+
586
650
  ptyDims.set(s.id, { cols, rows });
587
- screens.set(s.id, new ScreenModel(cols, rows));
588
651
 
589
- restored++;
652
+ if (!screens.has(s.id)) {
653
+ screens.set(s.id, new ScreenModel(cols, rows));
654
+ }
655
+
656
+ return true;
657
+ };
658
+
659
+ const [first, ...rest] = queued;
660
+
661
+ if (first === undefined) {
662
+ return 0;
590
663
  }
591
664
 
592
- return restored;
665
+ // The first terminal attaches synchronously so a caller can attach at
666
+ // once; each later one waits for the previous session to report it has
667
+ // booted, so a heavy fleet comes up one process at a time instead of
668
+ // all at once. A per-session cap keeps a session that never reports
669
+ // from stalling the rest.
670
+ const cap = opts.restoreBootTimeoutMs ?? 0;
671
+
672
+ const adoptRest = async (previous: Session | null) => {
673
+ let prev = previous;
674
+
675
+ for (const s of rest) {
676
+ if (prev !== null) {
677
+ await waitForBoot(prev.id, cap);
678
+ }
679
+
680
+ prev = adoptQueued(s) ? s : null;
681
+ }
682
+ };
683
+
684
+ const firstBooted = adoptQueued(first) ? first : null;
685
+
686
+ void adoptRest(firstBooted);
687
+
688
+ return queued.length;
593
689
  },
594
690
  };
595
691
 
@@ -628,6 +724,10 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
628
724
  clearTimeout(timer);
629
725
  }
630
726
 
727
+ for (const timer of restoreTimers) {
728
+ clearTimeout(timer);
729
+ }
730
+
631
731
  server.stop(true);
632
732
  reporter.stop(true);
633
733
  mgr.killAll();
package/src/sessions.ts CHANGED
@@ -141,6 +141,32 @@ export class SessionManager {
141
141
  return s;
142
142
  }
143
143
 
144
+ // Registers a fleet entry as a session with no terminal yet, so a
145
+ // fleet-wide restore can show every incoming session at once; adopting it
146
+ // later attaches the terminal.
147
+ restore(entry: FleetEntry): Session {
148
+ const session: Session = {
149
+ id: `s${++counter}-${Date.now().toString(36)}`,
150
+ name: entry.name,
151
+ cwd: entry.cwd,
152
+ kind: 'jsonl',
153
+ pty: null,
154
+ state: 'running',
155
+ unread: false,
156
+ lastMsg: 'waiting to restore',
157
+ claudeId: entry.claudeId,
158
+ ...(entry.group === undefined ? {} : { group: entry.group }),
159
+ namedBy: 'auto',
160
+ createdAt: Date.now(),
161
+ };
162
+
163
+ this.sessions.push(session);
164
+ this.writeStatus();
165
+ this.onEvent('added', session);
166
+
167
+ return session;
168
+ }
169
+
144
170
  // Adopts a headless session back into a terminal: a fresh PTY resumes the
145
171
  // same agent session id.
146
172
  adoptTerminal(id: string, cols: number, rows: number): Session | null {
@@ -77,6 +77,18 @@ export class StateStore {
77
77
  return entries;
78
78
  }
79
79
 
80
+ // The latest hook-event timestamp per agent session id, from the event
81
+ // trail. Sessions that never reported an event are absent.
82
+ collectFleetRecency(): Map<string, string> {
83
+ const rows = this.db
84
+ .query<{ session_id: string; ts: string }, []>(
85
+ 'SELECT session_id, MAX(ts) AS ts FROM events WHERE session_id IS NOT NULL GROUP BY session_id',
86
+ )
87
+ .all();
88
+
89
+ return new Map(rows.map((row) => [row.session_id, row.ts]));
90
+ }
91
+
80
92
  writeFleet(entries: readonly FleetEntry[]): void {
81
93
  const replace = this.db.transaction((all: readonly FleetEntry[]) => {
82
94
  this.db.run('DELETE FROM fleet');