@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 +9 -5
- package/package.json +1 -1
- package/src/boot-daemon.ts +26 -12
- package/src/cli.ts +1 -0
- package/src/daemon-connection.ts +7 -0
- package/src/daemon.ts +35 -21
- package/src/index.ts +56 -5
- package/src/mcp-server.ts +3 -1
- package/src/sessions.ts +27 -0
- package/src/ui.ts +10 -0
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.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
fleet,
|
|
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
package/src/boot-daemon.ts
CHANGED
|
@@ -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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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<
|
|
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
|
-
|
|
30
|
+
try {
|
|
31
|
+
const hello = await client.sendHello(build);
|
|
25
32
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
return { client, stale: hello['daemon'] !== build };
|
|
34
|
+
} catch (error) {
|
|
35
|
+
client.stop();
|
|
29
36
|
|
|
30
|
-
|
|
37
|
+
if (attempt > 0 || !isProtocolMismatch(error)) {
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
31
40
|
|
|
32
|
-
|
|
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
package/src/daemon-connection.ts
CHANGED
|
@@ -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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
}
|
|
622
|
+
stopDaemon = () => {
|
|
623
|
+
for (const timer of resizeTimers.values()) {
|
|
624
|
+
clearTimeout(timer);
|
|
625
|
+
}
|
|
613
626
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
627
|
+
for (const timer of detectTimers.values()) {
|
|
628
|
+
clearTimeout(timer);
|
|
629
|
+
}
|
|
617
630
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
631
|
+
server.stop(true);
|
|
632
|
+
reporter.stop(true);
|
|
633
|
+
mgr.killAll();
|
|
621
634
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
635
|
+
for (const model of screens.values()) {
|
|
636
|
+
model.stop();
|
|
637
|
+
}
|
|
625
638
|
|
|
626
|
-
|
|
639
|
+
store.stop();
|
|
627
640
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
}
|