@zgeoff/atc 0.1.5 → 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
@@ -122,7 +122,8 @@ SIGKILL, reboot — the child claude processes die with it, but every session's
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
124
 
125
- Restoring revives one session at a time: the next `claude --resume` starts only once the previous
126
- one has reported it is up (its `SessionStart` hook), so bringing back a dozen sessions no longer
127
- launches a dozen Claude processes at the same instant and pins the machine. The list fills in live
128
- as each comes back.
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.5",
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/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 { FleetEntry, Session, 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 {
@@ -533,6 +533,10 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
533
533
 
534
534
  ptyDims.set(id, { cols, rows });
535
535
 
536
+ if (!screens.has(id)) {
537
+ screens.set(id, new ScreenModel(cols, rows));
538
+ }
539
+
536
540
  scheduleResize(id);
537
541
 
538
542
  return 'ok';
@@ -614,59 +618,74 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
614
618
  getEffectiveDims: (sessionID) =>
615
619
  attachments.findEffectiveDims(sessionID) ?? ptyDims.get(sessionID) ?? { cols: 80, rows: 24 },
616
620
  restoreFleet: (cols, rows) => {
617
- const isLive = (claudeId: string | undefined) =>
618
- mgr.sessions.some((s) => s.pty !== null && s.claudeId === 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
+ );
619
627
 
620
- const restoreEntry = (entry: FleetEntry): Session | null => {
621
- if (isLive(entry.claudeId)) {
622
- return null;
623
- }
628
+ const recency = store.collectFleetRecency();
624
629
 
625
- const s = mgr.spawn(
626
- entry.cwd,
627
- entry.name,
628
- '',
629
- cols,
630
- rows,
631
- entry.claudeId,
632
- 'auto',
633
- 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) ?? ''),
634
638
  );
635
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
+
636
650
  ptyDims.set(s.id, { cols, rows });
637
- screens.set(s.id, new ScreenModel(cols, rows));
638
651
 
639
- return s;
652
+ if (!screens.has(s.id)) {
653
+ screens.set(s.id, new ScreenModel(cols, rows));
654
+ }
655
+
656
+ return true;
640
657
  };
641
658
 
642
- const [first, ...rest] = store.loadFleet().filter((entry) => !isLive(entry.claudeId));
659
+ const [first, ...rest] = queued;
643
660
 
644
661
  if (first === undefined) {
645
662
  return 0;
646
663
  }
647
664
 
648
- // The first revive is synchronous so a caller can attach at once; each
649
- // later revive waits for the previous session to report it has booted,
650
- // so a heavy fleet comes up one process at a time instead of all at
651
- // once. A per-session cap keeps a session that never reports from
652
- // stalling the rest.
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.
653
670
  const cap = opts.restoreBootTimeoutMs ?? 0;
654
671
 
655
- const restoreRest = async (previous: Session | null) => {
672
+ const adoptRest = async (previous: Session | null) => {
656
673
  let prev = previous;
657
674
 
658
- for (const entry of rest) {
675
+ for (const s of rest) {
659
676
  if (prev !== null) {
660
677
  await waitForBoot(prev.id, cap);
661
678
  }
662
679
 
663
- prev = restoreEntry(entry);
680
+ prev = adoptQueued(s) ? s : null;
664
681
  }
665
682
  };
666
683
 
667
- void restoreRest(restoreEntry(first));
684
+ const firstBooted = adoptQueued(first) ? first : null;
685
+
686
+ void adoptRest(firstBooted);
668
687
 
669
- return 1 + rest.length;
688
+ return queued.length;
670
689
  },
671
690
  };
672
691
 
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');