@zgeoff/atc 0.1.5 → 0.1.7

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
@@ -43,7 +43,7 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
43
43
  | `R` | home | restore last fleet after a daemon death — respawns every session via `claude --resume <id>` |
44
44
  | `j`/`k`/`↑`/`↓` | overlay/picker | move |
45
45
  | `Enter` | overlay | attach (auto-acks) |
46
- | `Tab` | overlay | attach the most urgent needs-you session |
46
+ | `Tab` | overlay | attach the most urgent needs-you session, else the latest turn-done one |
47
47
  | `/` | overlay | fuzzy filter by name/dir (chars in order), `⏎` attach top match, `esc` clear |
48
48
  | `a` | overlay | ack notification without attaching |
49
49
  | `H` | overlay | eject to headless: the terminal dies, a headless Agent SDK run resumes the same session |
@@ -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.7",
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/index.ts CHANGED
@@ -2,6 +2,7 @@ import { basename } from 'node:path';
2
2
  import { bootDaemonClient } from './boot-daemon';
3
3
  import { loadConfig } from './config';
4
4
  import { collectDirs, findFuzzyScore, formatDir, pickMatches } from './dirs';
5
+ import { pickTabTarget } from './pick-tab-target';
5
6
  import type { EventMsg } from './protocol';
6
7
  import { isRecord } from './report';
7
8
  import { countSessionStates, sortGroupedSessionViews, sortSessionViews } from './sessions';
@@ -39,6 +40,12 @@ let confirmKill = false;
39
40
 
40
41
  // The client's mirror of the daemon's fleet, kept fresh by events.
41
42
  let fleet: MirrorSession[] = [];
43
+
44
+ // When each session last flipped to done, so the Tab jump can prefer the
45
+ // most recently finished turn. Sessions already done before this client
46
+ // connected have no entry and rank last.
47
+ const doneAt = new Map<string, number>();
48
+
42
49
  let focusedID: string | null = null;
43
50
  let fleetCount = 0;
44
51
 
@@ -471,6 +478,10 @@ function applyDaemonEvent(e: EventMsg) {
471
478
  if (s !== undefined) {
472
479
  const next = SESSION_STATES.find((x) => x === e['state']);
473
480
 
481
+ if (next === 'done' && s.state !== 'done') {
482
+ doneAt.set(s.id, Date.now());
483
+ }
484
+
474
485
  if (next !== undefined) {
475
486
  s.state = next;
476
487
  }
@@ -524,6 +535,10 @@ function applyDaemonEvent(e: EventMsg) {
524
535
  case 'session.removed': {
525
536
  fleet = fleet.filter((x) => x.id !== e['s']);
526
537
 
538
+ if (typeof e['s'] === 'string') {
539
+ doneAt.delete(e['s']);
540
+ }
541
+
527
542
  if (focusedID === e['s']) {
528
543
  focusedID = null;
529
544
  }
@@ -695,10 +710,10 @@ function applyOverlayKey(buf: Buffer) {
695
710
  const ch = buf.toString();
696
711
 
697
712
  if (buf[0] === KEY.tab) {
698
- const needy = sortSessionViews(fleet).find((s) => s.state === 'needs_you' && s.alive);
713
+ const target = pickTabTarget(fleet, (id) => doneAt.get(id));
699
714
 
700
- if (needy !== undefined) {
701
- void attach(needy.id);
715
+ if (target !== undefined) {
716
+ void attach(target.id);
702
717
  }
703
718
 
704
719
  return;
@@ -0,0 +1,31 @@
1
+ import type { SessionState } from './sessions';
2
+ import { sortSessionViews } from './sessions';
3
+
4
+ interface TabCandidate {
5
+ readonly id: string;
6
+ readonly state: SessionState;
7
+ readonly createdAt: number;
8
+ readonly alive: boolean;
9
+ readonly kind: 'pty' | 'jsonl';
10
+ }
11
+
12
+ /**
13
+ * The Tab jump target: the most urgent alive needs-you session, else the
14
+ * alive terminal session whose turn finished most recently. Headless
15
+ * sessions never qualify for the fallback — they have no terminal to
16
+ * attach — and sessions with no recorded finish time rank last.
17
+ */
18
+ export function pickTabTarget<T extends TabCandidate>(
19
+ list: readonly T[],
20
+ findDoneAt: (id: string) => number | undefined,
21
+ ): T | undefined {
22
+ const needy = sortSessionViews(list).find((s) => s.state === 'needs_you' && s.alive);
23
+
24
+ if (needy !== undefined) {
25
+ return needy;
26
+ }
27
+
28
+ return sortSessionViews(list)
29
+ .filter((s) => s.state === 'done' && s.alive && s.kind === 'pty')
30
+ .toSorted((a, b) => (findDoneAt(b.id) ?? 0) - (findDoneAt(a.id) ?? 0))[0];
31
+ }
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');
package/src/ui.ts CHANGED
@@ -274,7 +274,7 @@ export function drawHelp() {
274
274
 
275
275
  const lines = [
276
276
  '⏎ attach the selected session',
277
- '⇥ attach the most urgent needs-you session',
277
+ '⇥ attach the most urgent needs-you, else latest done',
278
278
  'a ack its notification without attaching',
279
279
  'H eject to a headless run',
280
280
  'P revive a dead or headless session',