@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 +6 -5
- package/package.json +1 -1
- package/src/daemon.ts +48 -29
- package/src/index.ts +18 -3
- package/src/pick-tab-target.ts +31 -0
- package/src/sessions.ts +26 -0
- package/src/state-store.ts +12 -0
- package/src/ui.ts +1 -1
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
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 {
|
|
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
|
|
618
|
-
mgr.sessions.some(
|
|
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
|
|
621
|
-
if (isLive(entry.claudeId)) {
|
|
622
|
-
return null;
|
|
623
|
-
}
|
|
628
|
+
const recency = store.collectFleetRecency();
|
|
624
629
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
'
|
|
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
|
-
|
|
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] =
|
|
659
|
+
const [first, ...rest] = queued;
|
|
643
660
|
|
|
644
661
|
if (first === undefined) {
|
|
645
662
|
return 0;
|
|
646
663
|
}
|
|
647
664
|
|
|
648
|
-
// The first
|
|
649
|
-
// later
|
|
650
|
-
// so a heavy fleet comes up one process at a time instead of
|
|
651
|
-
// once. A per-session cap keeps a session that never reports
|
|
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
|
|
672
|
+
const adoptRest = async (previous: Session | null) => {
|
|
656
673
|
let prev = previous;
|
|
657
674
|
|
|
658
|
-
for (const
|
|
675
|
+
for (const s of rest) {
|
|
659
676
|
if (prev !== null) {
|
|
660
677
|
await waitForBoot(prev.id, cap);
|
|
661
678
|
}
|
|
662
679
|
|
|
663
|
-
prev =
|
|
680
|
+
prev = adoptQueued(s) ? s : null;
|
|
664
681
|
}
|
|
665
682
|
};
|
|
666
683
|
|
|
667
|
-
|
|
684
|
+
const firstBooted = adoptQueued(first) ? first : null;
|
|
685
|
+
|
|
686
|
+
void adoptRest(firstBooted);
|
|
668
687
|
|
|
669
|
-
return
|
|
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
|
|
713
|
+
const target = pickTabTarget(fleet, (id) => doneAt.get(id));
|
|
699
714
|
|
|
700
|
-
if (
|
|
701
|
-
void attach(
|
|
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 {
|
package/src/state-store.ts
CHANGED
|
@@ -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
|
|
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',
|