@zgeoff/atc 0.1.1 → 0.1.3
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 +19 -9
- package/package.json +1 -1
- package/src/boot-daemon.ts +26 -12
- package/src/cli.ts +1 -0
- package/src/daemon-connection.ts +25 -0
- package/src/daemon.ts +48 -23
- package/src/dirs.ts +56 -20
- package/src/index.ts +121 -7
- package/src/mcp-server.ts +27 -1
- package/src/sessions.ts +48 -1
- package/src/state-store.ts +29 -6
- package/src/ui.ts +29 -3
package/README.md
CHANGED
|
@@ -38,12 +38,13 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
|
|
|
38
38
|
| Key | Where | Action |
|
|
39
39
|
| --------------- | -------------- | ---------------------------------------------------------------------------------------------- |
|
|
40
40
|
| leader | anywhere | toggle session overlay — `Ctrl-Space` by default, configurable (see Config) |
|
|
41
|
-
| `n` | home/overlay | spawn: pick dir (zoxide + history, fuzzy) → name → optional first prompt
|
|
41
|
+
| `n` | home/overlay | spawn: pick dir (zoxide + history, fuzzy) → name → optional group → optional first prompt |
|
|
42
42
|
| `r` | home/overlay | adopt: pick dir → name → `claude --resume` (Claude's session picker opens in the new PTY) |
|
|
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
|
-
|
|
|
46
|
+
| `Tab` | overlay | attach the most urgent needs-you session |
|
|
47
|
+
| `/` | overlay | fuzzy filter by name/dir (chars in order), `⏎` attach top match, `esc` clear |
|
|
47
48
|
| `a` | overlay | ack notification without attaching |
|
|
48
49
|
| `H` | overlay | eject to headless: the terminal dies, a headless Agent SDK run resumes the same session |
|
|
49
50
|
| `P` | overlay | revive: a fresh terminal resumes a headless or killed session in place |
|
|
@@ -51,8 +52,14 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
|
|
|
51
52
|
| `Y` | overlay | eject: yank the resume command, then kill the session here — paste it in any pane to take over |
|
|
52
53
|
| `K` | overlay | kill selected (confirm with `y`) — the entry stays revivable with `P`; a second `K` forgets it |
|
|
53
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 |
|
|
54
56
|
| `q` | home/overlay | quit the client — sessions keep running in the daemon |
|
|
55
57
|
|
|
58
|
+
The overlay clusters sessions under dim headers when more than one group exists: a session's
|
|
59
|
+
explicit group wins, and sessions without one fall back to the directory they were spawned from.
|
|
60
|
+
Groups also come from outside — the `atc_session_update` MCP tool renames and regroups sessions, so
|
|
61
|
+
an agent can organise the fleet for you.
|
|
62
|
+
|
|
56
63
|
Revive (`P`) and headless eject (`H`) resume the session from its saved transcript, so both need one
|
|
57
64
|
to exist: a session killed before its first exchange has nothing on disk yet, and the overlay says
|
|
58
65
|
so in its message column instead of resuming.
|
|
@@ -92,8 +99,8 @@ Pick a different leader when `Ctrl-Space` is taken on your machine — Raycast o
|
|
|
92
99
|
`ctrl-]` is a solid replacement that no common terminal, multiplexer, or OS shortcut wants. An
|
|
93
100
|
unknown or reserved value falls back to the default.
|
|
94
101
|
|
|
95
|
-
`atc mcp` exposes the fleet as MCP tools (list, spawn, drive) to any MCP client, wrangled
|
|
96
|
-
included:
|
|
102
|
+
`atc mcp` exposes the fleet as MCP tools (list, spawn, drive, organise) to any MCP client, wrangled
|
|
103
|
+
sessions included:
|
|
97
104
|
|
|
98
105
|
```sh
|
|
99
106
|
claude mcp add --scope user atc -- atc mcp
|
|
@@ -106,8 +113,11 @@ daemon's pid file sits in `$XDG_RUNTIME_DIR/atc-daemon.pid`, beside its sockets.
|
|
|
106
113
|
## Crash safety
|
|
107
114
|
|
|
108
115
|
A client crash or closed window costs nothing: the daemon keeps hosting the fleet, and the next
|
|
109
|
-
`atc` reconnects.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
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
|
@@ -15,6 +15,7 @@ interface SpawnParams {
|
|
|
15
15
|
readonly rows: number;
|
|
16
16
|
readonly resume: boolean | string;
|
|
17
17
|
readonly namedBy: 'user' | 'auto';
|
|
18
|
+
readonly group?: string;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export interface DaemonContext {
|
|
@@ -24,6 +25,8 @@ export interface DaemonContext {
|
|
|
24
25
|
readonly collectFleet: () => FleetEntry[];
|
|
25
26
|
readonly spawnSession: (p: SpawnParams) => SessionDescriptor;
|
|
26
27
|
readonly killSession: (id: string) => boolean;
|
|
28
|
+
readonly updateSession: (id: string, name?: string, group?: string) => boolean;
|
|
29
|
+
readonly quitDaemon: () => void;
|
|
27
30
|
readonly ackSession: (id: string) => boolean;
|
|
28
31
|
readonly buildResumeCommand: (id: string) => string | null;
|
|
29
32
|
readonly answerPermission: (request: string, decision: string) => AnswerResult;
|
|
@@ -213,6 +216,25 @@ export class DaemonConnection {
|
|
|
213
216
|
|
|
214
217
|
return;
|
|
215
218
|
}
|
|
219
|
+
case 'daemon.quit': {
|
|
220
|
+
this.sendOk(req.id, {});
|
|
221
|
+
this.ctx.quitDaemon();
|
|
222
|
+
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
case 'session.update': {
|
|
226
|
+
const sessionID = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
|
|
227
|
+
const name = typeof req.p?.['name'] === 'string' ? req.p['name'] : undefined;
|
|
228
|
+
const group = typeof req.p?.['group'] === 'string' ? req.p['group'] : undefined;
|
|
229
|
+
|
|
230
|
+
if (this.ctx.updateSession(sessionID, name, group)) {
|
|
231
|
+
this.sendOk(req.id, {});
|
|
232
|
+
} else {
|
|
233
|
+
this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
216
238
|
case 'session.kill': {
|
|
217
239
|
this.applySessionVerb(req, this.ctx.killSession);
|
|
218
240
|
|
|
@@ -348,6 +370,8 @@ export class DaemonConnection {
|
|
|
348
370
|
resume = rawResume;
|
|
349
371
|
}
|
|
350
372
|
|
|
373
|
+
const group = typeof req.p?.['group'] === 'string' ? req.p['group'] : '';
|
|
374
|
+
|
|
351
375
|
const session = this.ctx.spawnSession({
|
|
352
376
|
cwd,
|
|
353
377
|
name: name === '' ? basename(cwd) : name,
|
|
@@ -356,6 +380,7 @@ export class DaemonConnection {
|
|
|
356
380
|
rows: typeof req.p?.['rows'] === 'number' ? req.p['rows'] : 24,
|
|
357
381
|
resume,
|
|
358
382
|
namedBy: name === '' ? 'auto' : 'user',
|
|
383
|
+
...(group === '' ? {} : { group }),
|
|
359
384
|
});
|
|
360
385
|
|
|
361
386
|
this.sendOk(req.id, { session });
|
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
|
}
|
|
@@ -367,6 +373,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
367
373
|
kind: s.kind,
|
|
368
374
|
alive: s.pty !== null || (s.kind === 'jsonl' && s.state !== 'exited'),
|
|
369
375
|
...(s.claudeId === undefined ? {} : { claudeId: s.claudeId }),
|
|
376
|
+
...(s.group === undefined ? {} : { group: s.group }),
|
|
370
377
|
}),
|
|
371
378
|
renamed: () => ({
|
|
372
379
|
v: PROTOCOL_V,
|
|
@@ -399,7 +406,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
399
406
|
collectSpawnDirs: () => store.collectSpawnDirs(),
|
|
400
407
|
collectFleet: () => store.loadFleet(),
|
|
401
408
|
spawnSession: (p) => {
|
|
402
|
-
const s = mgr.spawn(p.cwd, p.name, p.prompt, p.cols, p.rows, p.resume, p.namedBy);
|
|
409
|
+
const s = mgr.spawn(p.cwd, p.name, p.prompt, p.cols, p.rows, p.resume, p.namedBy, p.group);
|
|
403
410
|
|
|
404
411
|
ptyDims.set(s.id, { cols: p.cols, rows: p.rows });
|
|
405
412
|
screens.set(s.id, new ScreenModel(p.cols, p.rows));
|
|
@@ -407,6 +414,15 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
407
414
|
|
|
408
415
|
return getDescriptor(mgr, s.id);
|
|
409
416
|
},
|
|
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
|
+
},
|
|
410
426
|
killSession: (id) => {
|
|
411
427
|
if (!mgr.sessions.some((s) => s.id === id)) {
|
|
412
428
|
return false;
|
|
@@ -556,7 +572,16 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
556
572
|
continue;
|
|
557
573
|
}
|
|
558
574
|
|
|
559
|
-
const s = mgr.spawn(
|
|
575
|
+
const s = mgr.spawn(
|
|
576
|
+
entry.cwd,
|
|
577
|
+
entry.name,
|
|
578
|
+
'',
|
|
579
|
+
cols,
|
|
580
|
+
rows,
|
|
581
|
+
entry.claudeId,
|
|
582
|
+
'auto',
|
|
583
|
+
entry.group,
|
|
584
|
+
);
|
|
560
585
|
|
|
561
586
|
ptyDims.set(s.id, { cols, rows });
|
|
562
587
|
screens.set(s.id, new ScreenModel(cols, rows));
|
|
@@ -594,33 +619,33 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
594
619
|
},
|
|
595
620
|
});
|
|
596
621
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
}
|
|
622
|
+
stopDaemon = () => {
|
|
623
|
+
for (const timer of resizeTimers.values()) {
|
|
624
|
+
clearTimeout(timer);
|
|
625
|
+
}
|
|
602
626
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
627
|
+
for (const timer of detectTimers.values()) {
|
|
628
|
+
clearTimeout(timer);
|
|
629
|
+
}
|
|
606
630
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
631
|
+
server.stop(true);
|
|
632
|
+
reporter.stop(true);
|
|
633
|
+
mgr.killAll();
|
|
610
634
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
635
|
+
for (const model of screens.values()) {
|
|
636
|
+
model.stop();
|
|
637
|
+
}
|
|
614
638
|
|
|
615
|
-
|
|
639
|
+
store.stop();
|
|
616
640
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
},
|
|
641
|
+
if (opts.pidPath !== undefined) {
|
|
642
|
+
try {
|
|
643
|
+
unlinkSync(opts.pidPath);
|
|
644
|
+
} catch {}
|
|
645
|
+
}
|
|
623
646
|
};
|
|
647
|
+
|
|
648
|
+
return { stop: stopDaemon };
|
|
624
649
|
}
|
|
625
650
|
|
|
626
651
|
// A session reporting a transcript path that does not exist on disk yet has
|
package/src/dirs.ts
CHANGED
|
@@ -38,26 +38,21 @@ export function pickMatches(items: readonly string[], filter: string): string[]
|
|
|
38
38
|
return [...items];
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return { item, score };
|
|
57
|
-
})
|
|
58
|
-
.filter((x) => x.score >= 0);
|
|
59
|
-
|
|
60
|
-
return scored.toSorted((a, b) => a.score - b.score).map((x) => x.item);
|
|
41
|
+
const scored: { item: string; score: number }[] = [];
|
|
42
|
+
|
|
43
|
+
for (const item of items) {
|
|
44
|
+
// Basename hits beat full-path hits, so `vers` ranks ~/projects/vers
|
|
45
|
+
// above every path that merely passes through a vers directory.
|
|
46
|
+
const baseScore = findFuzzyScore(basename(item), filter);
|
|
47
|
+
const pathScore = findFuzzyScore(item, filter);
|
|
48
|
+
const score = baseScore === null ? pathScore : baseScore + 100;
|
|
49
|
+
|
|
50
|
+
if (score !== null) {
|
|
51
|
+
scored.push({ item, score });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return scored.toSorted((a, b) => b.score - a.score).map((x) => x.item);
|
|
61
56
|
}
|
|
62
57
|
|
|
63
58
|
export function formatDir(d: string): string {
|
|
@@ -65,3 +60,44 @@ export function formatDir(d: string): string {
|
|
|
65
60
|
|
|
66
61
|
return d.startsWith(home) ? `~${d.slice(home.length)}` : d;
|
|
67
62
|
}
|
|
63
|
+
|
|
64
|
+
const SEPARATORS = new Set(['/', '-', '_', '.', ' ']);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Scores how well a filter fuzzy-matches a candidate: every filter
|
|
68
|
+
* character must appear in order, consecutive characters and characters
|
|
69
|
+
* starting a word score higher, and a miss is null rather than zero so
|
|
70
|
+
* non-matches are distinguishable from weak matches.
|
|
71
|
+
*/
|
|
72
|
+
export function findFuzzyScore(candidate: string, filter: string): number | null {
|
|
73
|
+
const hay = candidate.toLowerCase();
|
|
74
|
+
const needle = filter.toLowerCase();
|
|
75
|
+
let score = 0;
|
|
76
|
+
let pos = 0;
|
|
77
|
+
let prevHit = -2;
|
|
78
|
+
|
|
79
|
+
for (const ch of needle) {
|
|
80
|
+
const hit = hay.indexOf(ch, pos);
|
|
81
|
+
|
|
82
|
+
if (hit === -1) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
score += 1;
|
|
87
|
+
|
|
88
|
+
if (hit === prevHit + 1) {
|
|
89
|
+
score += 2;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const before = hay[hit - 1];
|
|
93
|
+
|
|
94
|
+
if (hit === 0 || (before !== undefined && SEPARATORS.has(before))) {
|
|
95
|
+
score += 2;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
prevHit = hit;
|
|
99
|
+
pos = hit + 1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return score;
|
|
103
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
2
|
import { bootDaemonClient } from './boot-daemon';
|
|
3
3
|
import { loadConfig } from './config';
|
|
4
|
-
import { collectDirs, formatDir, pickMatches } from './dirs';
|
|
4
|
+
import { collectDirs, findFuzzyScore, formatDir, pickMatches } from './dirs';
|
|
5
5
|
import type { EventMsg } from './protocol';
|
|
6
6
|
import { isRecord } from './report';
|
|
7
7
|
import { countSessionStates, sortSessionViews } from './sessions';
|
|
@@ -15,6 +15,7 @@ type Mode =
|
|
|
15
15
|
| 'help'
|
|
16
16
|
| 'picker-dir'
|
|
17
17
|
| 'picker-name'
|
|
18
|
+
| 'picker-group'
|
|
18
19
|
| 'picker-prompt'
|
|
19
20
|
| 'picker-eject';
|
|
20
21
|
|
|
@@ -22,6 +23,7 @@ interface MirrorSession {
|
|
|
22
23
|
id: string;
|
|
23
24
|
name: string;
|
|
24
25
|
cwd: string;
|
|
26
|
+
group?: string;
|
|
25
27
|
state: SessionState;
|
|
26
28
|
unread: boolean;
|
|
27
29
|
lastMsg: string;
|
|
@@ -46,6 +48,7 @@ let pickerInput = '';
|
|
|
46
48
|
let pickerSelected = 0;
|
|
47
49
|
let spawnDir = '';
|
|
48
50
|
let spawnName = '';
|
|
51
|
+
let spawnGroup = '';
|
|
49
52
|
let spawnResume = false;
|
|
50
53
|
const stdout = process.stdout;
|
|
51
54
|
|
|
@@ -78,6 +81,7 @@ function scheduleStatus() {
|
|
|
78
81
|
focusedName: focused === null ? null : focused.name,
|
|
79
82
|
urgentName: urgent === undefined ? null : urgent.name,
|
|
80
83
|
leaderLabel: leader.label,
|
|
84
|
+
stale: daemonStale,
|
|
81
85
|
});
|
|
82
86
|
}
|
|
83
87
|
}, 50);
|
|
@@ -197,9 +201,11 @@ function pickOverlaySessions(): MirrorSession[] {
|
|
|
197
201
|
return sorted;
|
|
198
202
|
}
|
|
199
203
|
|
|
200
|
-
const f = overlayFilter
|
|
204
|
+
const f = overlayFilter;
|
|
201
205
|
|
|
202
|
-
return sorted.filter(
|
|
206
|
+
return sorted.filter(
|
|
207
|
+
(s) => findFuzzyScore(`${s.name} ${s.group ?? ''} ${formatDir(s.cwd)}`, f) !== null,
|
|
208
|
+
);
|
|
203
209
|
}
|
|
204
210
|
|
|
205
211
|
function renderOverlay() {
|
|
@@ -212,6 +218,7 @@ function renderOverlay() {
|
|
|
212
218
|
selected: overlaySelected,
|
|
213
219
|
confirmKill,
|
|
214
220
|
filter: overlayFilter,
|
|
221
|
+
stale: daemonStale,
|
|
215
222
|
});
|
|
216
223
|
|
|
217
224
|
scheduleStatus();
|
|
@@ -255,6 +262,18 @@ function renderPicker() {
|
|
|
255
262
|
placeholder: basename(spawnDir),
|
|
256
263
|
hint: `session name for ${formatDir(spawnDir)} · ⏎ accept · esc back`,
|
|
257
264
|
});
|
|
265
|
+
} else if (mode === 'picker-group') {
|
|
266
|
+
const groups = [...new Set(fleet.map((s) => s.group).filter((g) => g !== undefined))];
|
|
267
|
+
const known = groups.length === 0 ? '' : ` · existing: ${groups.slice(0, 4).join(', ')}`;
|
|
268
|
+
|
|
269
|
+
drawPicker({
|
|
270
|
+
title: `${verb}: group`,
|
|
271
|
+
items: [],
|
|
272
|
+
selected: -1,
|
|
273
|
+
input: pickerInput,
|
|
274
|
+
placeholder: 'optional — ⏎ to skip',
|
|
275
|
+
hint: `group for the overlay hierarchy${known} · ⏎ accept · esc back`,
|
|
276
|
+
});
|
|
258
277
|
} else if (mode === 'picker-eject') {
|
|
259
278
|
drawPicker({
|
|
260
279
|
title: 'eject: headless instruction',
|
|
@@ -280,6 +299,7 @@ function renderPicker() {
|
|
|
280
299
|
|
|
281
300
|
async function openDirPicker(resume = false) {
|
|
282
301
|
spawnResume = resume;
|
|
302
|
+
spawnGroup = '';
|
|
283
303
|
|
|
284
304
|
let recent: string[] = [];
|
|
285
305
|
|
|
@@ -327,6 +347,7 @@ async function spawnFromPicker(prompt: string) {
|
|
|
327
347
|
cols: cols(),
|
|
328
348
|
rows: ptyRows(),
|
|
329
349
|
...(spawnResume ? { resume: true } : {}),
|
|
350
|
+
...(spawnGroup === '' ? {} : { group: spawnGroup }),
|
|
330
351
|
});
|
|
331
352
|
|
|
332
353
|
const spawned = toMirrorSession(ok['session']);
|
|
@@ -382,6 +403,7 @@ function toMirrorSession(value: unknown): MirrorSession | null {
|
|
|
382
403
|
id: value['id'],
|
|
383
404
|
name: value['name'],
|
|
384
405
|
cwd: value['cwd'],
|
|
406
|
+
...(typeof value['group'] === 'string' ? { group: value['group'] } : {}),
|
|
385
407
|
state,
|
|
386
408
|
unread: value['unread'],
|
|
387
409
|
lastMsg: value['lastMsg'],
|
|
@@ -463,6 +485,14 @@ function applyDaemonEvent(e: EventMsg) {
|
|
|
463
485
|
s.kind = e['kind'];
|
|
464
486
|
}
|
|
465
487
|
|
|
488
|
+
if (typeof e['group'] === 'string') {
|
|
489
|
+
if (e['group'] === '') {
|
|
490
|
+
delete s.group;
|
|
491
|
+
} else {
|
|
492
|
+
s.group = e['group'];
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
466
496
|
if (typeof e['unread'] === 'boolean') {
|
|
467
497
|
s.unread = e['unread'];
|
|
468
498
|
}
|
|
@@ -529,6 +559,7 @@ function copyToClipboard(text: string) {
|
|
|
529
559
|
const leader = loadConfig().leader;
|
|
530
560
|
|
|
531
561
|
const KEY = {
|
|
562
|
+
tab: 0x09,
|
|
532
563
|
ctrlC: 0x03,
|
|
533
564
|
ctrlU: 0x15,
|
|
534
565
|
esc: 0x1b,
|
|
@@ -663,6 +694,16 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
663
694
|
|
|
664
695
|
const ch = buf.toString();
|
|
665
696
|
|
|
697
|
+
if (buf[0] === KEY.tab) {
|
|
698
|
+
const needy = sortSessionViews(fleet).find((s) => s.state === 'needs_you' && s.alive);
|
|
699
|
+
|
|
700
|
+
if (needy !== undefined) {
|
|
701
|
+
void attach(needy.id);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
|
|
666
707
|
if (buf[0] === KEY.enter && sel !== undefined && sel.alive) {
|
|
667
708
|
void attach(sel.id);
|
|
668
709
|
|
|
@@ -688,6 +729,12 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
688
729
|
return;
|
|
689
730
|
}
|
|
690
731
|
|
|
732
|
+
if (ch === 'u' && daemonStale) {
|
|
733
|
+
void restartDaemon();
|
|
734
|
+
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
691
738
|
if (ch === 'H' && sel !== undefined && sel.kind === 'pty' && sel.alive) {
|
|
692
739
|
ejectTarget = sel.id;
|
|
693
740
|
pickerInput = '';
|
|
@@ -844,16 +891,23 @@ function applyTextKey(buf: Buffer, onSubmit: () => void, onCancel: () => void) {
|
|
|
844
891
|
}
|
|
845
892
|
}
|
|
846
893
|
|
|
847
|
-
const
|
|
894
|
+
const boot = await bootDaemonClient();
|
|
895
|
+
|
|
896
|
+
let client = boot.client;
|
|
897
|
+
let daemonStale = boot.stale;
|
|
848
898
|
|
|
849
899
|
client.onEvent = applyDaemonEvent;
|
|
850
900
|
|
|
851
|
-
|
|
901
|
+
await refreshMirror();
|
|
902
|
+
|
|
903
|
+
async function refreshMirror() {
|
|
852
904
|
const list = await client.sendRequest('session.list');
|
|
853
905
|
|
|
854
906
|
const sessions = list['sessions'];
|
|
855
907
|
|
|
856
908
|
if (Array.isArray(sessions)) {
|
|
909
|
+
fleet = [];
|
|
910
|
+
|
|
857
911
|
for (const raw of sessions) {
|
|
858
912
|
const d = toMirrorSession(raw);
|
|
859
913
|
|
|
@@ -870,6 +924,42 @@ client.onEvent = applyDaemonEvent;
|
|
|
870
924
|
fleetCount = Array.isArray(entries) ? entries.length : 0;
|
|
871
925
|
}
|
|
872
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
|
+
|
|
873
963
|
process.stdin.setRawMode(true);
|
|
874
964
|
process.stdin.resume();
|
|
875
965
|
|
|
@@ -975,6 +1065,30 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
975
1065
|
() => {
|
|
976
1066
|
spawnName = pickerInput.trim();
|
|
977
1067
|
pickerInput = '';
|
|
1068
|
+
mode = 'picker-group';
|
|
1069
|
+
|
|
1070
|
+
stdout.write(ansi.clear);
|
|
1071
|
+
|
|
1072
|
+
renderPicker();
|
|
1073
|
+
},
|
|
1074
|
+
() => {
|
|
1075
|
+
pickerInput = '';
|
|
1076
|
+
mode = 'picker-dir';
|
|
1077
|
+
|
|
1078
|
+
stdout.write(ansi.clear);
|
|
1079
|
+
|
|
1080
|
+
renderPicker();
|
|
1081
|
+
},
|
|
1082
|
+
);
|
|
1083
|
+
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
case 'picker-group': {
|
|
1087
|
+
applyTextKey(
|
|
1088
|
+
buf,
|
|
1089
|
+
() => {
|
|
1090
|
+
spawnGroup = pickerInput.trim();
|
|
1091
|
+
pickerInput = '';
|
|
978
1092
|
|
|
979
1093
|
// Adopt skips the prompt step: claude --resume opens its own
|
|
980
1094
|
// session picker inside the new PTY.
|
|
@@ -992,7 +1106,7 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
992
1106
|
},
|
|
993
1107
|
() => {
|
|
994
1108
|
pickerInput = '';
|
|
995
|
-
mode = 'picker-
|
|
1109
|
+
mode = 'picker-name';
|
|
996
1110
|
|
|
997
1111
|
stdout.write(ansi.clear);
|
|
998
1112
|
|
|
@@ -1010,7 +1124,7 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
1010
1124
|
},
|
|
1011
1125
|
() => {
|
|
1012
1126
|
pickerInput = '';
|
|
1013
|
-
mode = 'picker-
|
|
1127
|
+
mode = 'picker-group';
|
|
1014
1128
|
|
|
1015
1129
|
stdout.write(ansi.clear);
|
|
1016
1130
|
|
package/src/mcp-server.ts
CHANGED
|
@@ -69,6 +69,21 @@ const TOOLS: readonly MCPTool[] = [
|
|
|
69
69
|
additionalProperties: false,
|
|
70
70
|
},
|
|
71
71
|
},
|
|
72
|
+
{
|
|
73
|
+
name: 'atc_session_update',
|
|
74
|
+
description:
|
|
75
|
+
'Rename and/or regroup a session. Renames stick against auto-summaries; an empty group clears the grouping. Use this to organise the fleet: name sessions after their task and group related work.',
|
|
76
|
+
inputSchema: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
properties: {
|
|
79
|
+
session: { type: 'string', description: 'The atc session id' },
|
|
80
|
+
name: { type: 'string', description: 'New display name; omit to keep' },
|
|
81
|
+
group: { type: 'string', description: 'New group; empty string clears, omit to keep' },
|
|
82
|
+
},
|
|
83
|
+
required: ['session'],
|
|
84
|
+
additionalProperties: false,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
72
87
|
{
|
|
73
88
|
name: 'atc_session_kill',
|
|
74
89
|
description: 'Kill a session. A second kill on a dead session removes it from the list.',
|
|
@@ -98,7 +113,9 @@ const TOOLS: readonly MCPTool[] = [
|
|
|
98
113
|
* stdout carries only JSON-RPC lines; the daemon is booted on demand.
|
|
99
114
|
*/
|
|
100
115
|
export async function runMCPServer(build: string): Promise<void> {
|
|
101
|
-
const
|
|
116
|
+
const boot = await bootDaemonClient();
|
|
117
|
+
|
|
118
|
+
const client = boot.client;
|
|
102
119
|
|
|
103
120
|
const decoder = new TextDecoder('utf-8');
|
|
104
121
|
|
|
@@ -237,6 +254,15 @@ async function runTool(
|
|
|
237
254
|
|
|
238
255
|
return 'sent';
|
|
239
256
|
}
|
|
257
|
+
case 'atc_session_update': {
|
|
258
|
+
await client.sendRequest('session.update', {
|
|
259
|
+
session: args['session'],
|
|
260
|
+
...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
|
|
261
|
+
...(typeof args['group'] === 'string' ? { group: args['group'] } : {}),
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
return 'updated';
|
|
265
|
+
}
|
|
240
266
|
case 'atc_session_kill': {
|
|
241
267
|
await client.sendRequest('session.kill', { session: args['session'] });
|
|
242
268
|
|
package/src/sessions.ts
CHANGED
|
@@ -23,6 +23,7 @@ export interface SessionDescriptor {
|
|
|
23
23
|
readonly lastMsg: string;
|
|
24
24
|
readonly lastDetail?: string;
|
|
25
25
|
readonly claudeId?: string;
|
|
26
|
+
readonly group?: string;
|
|
26
27
|
readonly namedBy: 'user' | 'auto' | 'agent';
|
|
27
28
|
readonly createdAt: number;
|
|
28
29
|
readonly kind: 'pty' | 'jsonl';
|
|
@@ -41,6 +42,7 @@ export interface Session {
|
|
|
41
42
|
lastDetail?: string;
|
|
42
43
|
claudeId?: string;
|
|
43
44
|
transcriptSource?: string;
|
|
45
|
+
group?: string;
|
|
44
46
|
|
|
45
47
|
// who last named this session: the agent's own rename beats everything, a
|
|
46
48
|
// user-typed spawn name beats auto-summaries.
|
|
@@ -54,6 +56,7 @@ export interface FleetEntry {
|
|
|
54
56
|
readonly name: string;
|
|
55
57
|
readonly cwd: string;
|
|
56
58
|
readonly claudeId: string;
|
|
59
|
+
readonly group?: string;
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
const fleetFile = join(stateDir, 'fleet.json');
|
|
@@ -187,6 +190,42 @@ export class SessionManager {
|
|
|
187
190
|
}
|
|
188
191
|
|
|
189
192
|
// Reports a headless run's lifecycle into the session state machine.
|
|
193
|
+
/**
|
|
194
|
+
* Renames and/or regroups a session on a caller's behalf. A rename lands
|
|
195
|
+
* at user strength, so auto-summaries stop overwriting it while an
|
|
196
|
+
* in-session rename still wins. An empty group clears the grouping.
|
|
197
|
+
*/
|
|
198
|
+
updateSession(id: string, name?: string, group?: string): boolean {
|
|
199
|
+
const s = this.sessions.find((x) => x.id === id);
|
|
200
|
+
|
|
201
|
+
if (s === undefined) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (name !== undefined && name !== '' && s.namedBy !== 'agent') {
|
|
206
|
+
s.name = name;
|
|
207
|
+
s.namedBy = 'user';
|
|
208
|
+
|
|
209
|
+
this.onEvent('renamed', s);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (group !== undefined) {
|
|
213
|
+
if (group === '') {
|
|
214
|
+
delete s.group;
|
|
215
|
+
} else {
|
|
216
|
+
s.group = group;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
this.onEvent('state', s);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
this.writeFleet();
|
|
223
|
+
this.writeStatus();
|
|
224
|
+
this.emitChange();
|
|
225
|
+
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
190
229
|
updateSurfaceState(id: string, state: SessionState, msg: string) {
|
|
191
230
|
const s = this.sessions.find((x) => x.id === id);
|
|
192
231
|
|
|
@@ -240,6 +279,7 @@ export class SessionManager {
|
|
|
240
279
|
rows: number,
|
|
241
280
|
resume: boolean | string = false,
|
|
242
281
|
namedBy: 'user' | 'auto' = 'auto',
|
|
282
|
+
group?: string,
|
|
243
283
|
): Session {
|
|
244
284
|
const id = `s${++counter}-${Date.now().toString(36)}`;
|
|
245
285
|
const plan = this.adapter.planSpawn({ prompt, resume });
|
|
@@ -268,6 +308,7 @@ export class SessionManager {
|
|
|
268
308
|
unread: false,
|
|
269
309
|
lastMsg: initialMsg,
|
|
270
310
|
...(typeof resume === 'string' ? { claudeId: resume } : {}),
|
|
311
|
+
...(group === undefined ? {} : { group }),
|
|
271
312
|
namedBy,
|
|
272
313
|
createdAt: Date.now(),
|
|
273
314
|
};
|
|
@@ -309,6 +350,7 @@ export class SessionManager {
|
|
|
309
350
|
lastMsg: s.lastMsg,
|
|
310
351
|
...(s.lastDetail === undefined ? {} : { lastDetail: s.lastDetail }),
|
|
311
352
|
...(s.claudeId === undefined ? {} : { claudeId: s.claudeId }),
|
|
353
|
+
...(s.group === undefined ? {} : { group: s.group }),
|
|
312
354
|
namedBy: s.namedBy,
|
|
313
355
|
createdAt: s.createdAt,
|
|
314
356
|
kind: s.kind,
|
|
@@ -525,7 +567,12 @@ export class SessionManager {
|
|
|
525
567
|
const live = s.pty !== null || (s.kind === 'jsonl' && s.state !== 'exited');
|
|
526
568
|
|
|
527
569
|
if (live && s.claudeId !== undefined) {
|
|
528
|
-
fleet.push({
|
|
570
|
+
fleet.push({
|
|
571
|
+
name: s.name,
|
|
572
|
+
cwd: s.cwd,
|
|
573
|
+
claudeId: s.claudeId,
|
|
574
|
+
...(s.group === undefined ? {} : { group: s.group }),
|
|
575
|
+
});
|
|
529
576
|
}
|
|
530
577
|
}
|
|
531
578
|
|
package/src/state-store.ts
CHANGED
|
@@ -23,7 +23,8 @@ export class StateStore {
|
|
|
23
23
|
CREATE TABLE IF NOT EXISTS fleet (
|
|
24
24
|
claude_id TEXT PRIMARY KEY,
|
|
25
25
|
name TEXT NOT NULL,
|
|
26
|
-
cwd TEXT NOT NULL
|
|
26
|
+
cwd TEXT NOT NULL,
|
|
27
|
+
grp TEXT
|
|
27
28
|
);
|
|
28
29
|
CREATE TABLE IF NOT EXISTS events (
|
|
29
30
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -39,6 +40,17 @@ export class StateStore {
|
|
|
39
40
|
);
|
|
40
41
|
`);
|
|
41
42
|
|
|
43
|
+
// Stores created before groups existed lack the column; the create
|
|
44
|
+
// above only applies to fresh databases.
|
|
45
|
+
const fleetColumns = this.db
|
|
46
|
+
.query<{ name: string }, []>('PRAGMA table_info(fleet)')
|
|
47
|
+
.all()
|
|
48
|
+
.map((c) => c.name);
|
|
49
|
+
|
|
50
|
+
if (!fleetColumns.includes('grp')) {
|
|
51
|
+
this.db.run('ALTER TABLE fleet ADD COLUMN grp TEXT');
|
|
52
|
+
}
|
|
53
|
+
|
|
42
54
|
if (legacyFleetPath !== undefined) {
|
|
43
55
|
this.adoptLegacyFleet(legacyFleetPath);
|
|
44
56
|
}
|
|
@@ -46,12 +58,23 @@ export class StateStore {
|
|
|
46
58
|
|
|
47
59
|
loadFleet(): FleetEntry[] {
|
|
48
60
|
const rows = this.db
|
|
49
|
-
.query<{ claude_id: string; name: string; cwd: string }, []>(
|
|
50
|
-
'SELECT claude_id, name, cwd FROM fleet',
|
|
61
|
+
.query<{ claude_id: string; name: string; cwd: string; grp: string | null }, []>(
|
|
62
|
+
'SELECT claude_id, name, cwd, grp FROM fleet',
|
|
51
63
|
)
|
|
52
64
|
.all();
|
|
53
65
|
|
|
54
|
-
|
|
66
|
+
const entries: FleetEntry[] = [];
|
|
67
|
+
|
|
68
|
+
for (const row of rows) {
|
|
69
|
+
const entry: FleetEntry =
|
|
70
|
+
row.grp === null
|
|
71
|
+
? { claudeId: row.claude_id, name: row.name, cwd: row.cwd }
|
|
72
|
+
: { claudeId: row.claude_id, name: row.name, cwd: row.cwd, group: row.grp };
|
|
73
|
+
|
|
74
|
+
entries.push(entry);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return entries;
|
|
55
78
|
}
|
|
56
79
|
|
|
57
80
|
writeFleet(entries: readonly FleetEntry[]): void {
|
|
@@ -59,11 +82,11 @@ export class StateStore {
|
|
|
59
82
|
this.db.run('DELETE FROM fleet');
|
|
60
83
|
|
|
61
84
|
const insert = this.db.query(
|
|
62
|
-
'INSERT OR REPLACE INTO fleet (claude_id, name, cwd) VALUES (?1, ?2, ?3)',
|
|
85
|
+
'INSERT OR REPLACE INTO fleet (claude_id, name, cwd, grp) VALUES (?1, ?2, ?3, ?4)',
|
|
63
86
|
);
|
|
64
87
|
|
|
65
88
|
for (const entry of all) {
|
|
66
|
-
insert.run(entry.claudeId, entry.name, entry.cwd);
|
|
89
|
+
insert.run(entry.claudeId, entry.name, entry.cwd, entry.group ?? null);
|
|
67
90
|
}
|
|
68
91
|
});
|
|
69
92
|
|
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);
|
|
@@ -153,6 +158,7 @@ export interface OverlaySessionView extends SessionView {
|
|
|
153
158
|
readonly alive: boolean;
|
|
154
159
|
readonly kind: 'pty' | 'jsonl';
|
|
155
160
|
readonly resumable: boolean;
|
|
161
|
+
readonly group?: string;
|
|
156
162
|
}
|
|
157
163
|
|
|
158
164
|
export interface OverlayView {
|
|
@@ -160,6 +166,7 @@ export interface OverlayView {
|
|
|
160
166
|
selected: number;
|
|
161
167
|
confirmKill: boolean;
|
|
162
168
|
filter: string | null;
|
|
169
|
+
stale: boolean;
|
|
163
170
|
}
|
|
164
171
|
|
|
165
172
|
export function drawOverlay(view: OverlayView) {
|
|
@@ -172,15 +179,29 @@ export function drawOverlay(view: OverlayView) {
|
|
|
172
179
|
rowsList.push(dimRow(width, empty));
|
|
173
180
|
}
|
|
174
181
|
|
|
182
|
+
// Sessions cluster under dim headers when more than one group exists: an
|
|
183
|
+
// explicit group wins, sessions without one fall back to their spawn
|
|
184
|
+
// directory. The rows keep the sorted order, so urgent groups lead.
|
|
185
|
+
const grouped = new Set(view.sessions.map((s) => s.group ?? s.cwd)).size > 1;
|
|
186
|
+
let lastKey: string | null = null;
|
|
187
|
+
|
|
175
188
|
for (const [i, s] of view.sessions.entries()) {
|
|
189
|
+
const key = s.group ?? s.cwd;
|
|
190
|
+
|
|
191
|
+
if (grouped && key !== lastKey) {
|
|
192
|
+
lastKey = key;
|
|
193
|
+
|
|
194
|
+
rowsList.push(dimRow(width, `▸ ${s.group ?? formatDir(s.cwd)}`));
|
|
195
|
+
}
|
|
196
|
+
|
|
176
197
|
const sel = i === view.selected;
|
|
177
198
|
const name = truncate(s.name, 16).padEnd(16);
|
|
178
199
|
const state = STATE_LABEL[s.state].padEnd(9);
|
|
179
|
-
const dir = truncate(formatDir(s.cwd), 18).padEnd(18)
|
|
180
|
-
const msgWidth = Math.max(4, width - 4 - 2 - 17 - 10 - 19);
|
|
200
|
+
const dir = grouped ? '' : ` ${truncate(formatDir(s.cwd), 18).padEnd(18)}`;
|
|
201
|
+
const msgWidth = Math.max(4, width - 4 - 2 - 17 - 10 - (grouped ? 0 : 19));
|
|
181
202
|
const msg = truncate(s.lastMsg, msgWidth).padEnd(msgWidth);
|
|
182
203
|
const unread = s.unread ? `${ESC}[1;33m!${ESC}[0m` : ' ';
|
|
183
|
-
const body = `${name} ${state}
|
|
204
|
+
const body = `${name} ${state}${dir} ${msg}`;
|
|
184
205
|
const styledBody = sel ? `${ESC}[7m${body}${ESC}[0m` : body;
|
|
185
206
|
|
|
186
207
|
rowsList.push(boxRow(width, `${GLYPH[s.state]}${unread}${styledBody}`, 2 + body.length));
|
|
@@ -199,6 +220,10 @@ export function drawOverlay(view: OverlayView) {
|
|
|
199
220
|
|
|
200
221
|
let hint = buildOverlayHint(view.sessions[view.selected]);
|
|
201
222
|
|
|
223
|
+
if (view.stale) {
|
|
224
|
+
hint += ' · u update daemon';
|
|
225
|
+
}
|
|
226
|
+
|
|
202
227
|
if (view.filter !== null) {
|
|
203
228
|
hint = 'type to filter · ↑↓ move · ⏎ attach · esc clear';
|
|
204
229
|
}
|
|
@@ -249,6 +274,7 @@ export function drawHelp() {
|
|
|
249
274
|
|
|
250
275
|
const lines = [
|
|
251
276
|
'⏎ attach the selected session',
|
|
277
|
+
'⇥ attach the most urgent needs-you session',
|
|
252
278
|
'a ack its notification without attaching',
|
|
253
279
|
'H eject to a headless run',
|
|
254
280
|
'P revive a dead or headless session',
|