@zgeoff/atc 0.1.1 → 0.1.2
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 +10 -4
- package/package.json +1 -1
- package/src/daemon-connection.ts +18 -0
- package/src/daemon.ts +13 -2
- package/src/dirs.ts +56 -20
- package/src/index.ts +68 -5
- package/src/mcp-server.ts +24 -0
- package/src/sessions.ts +48 -1
- package/src/state-store.ts +29 -6
- package/src/ui.ts +19 -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 |
|
|
@@ -53,6 +54,11 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
|
|
|
53
54
|
| `?` | overlay | full key reference — the hint row only shows actions valid for the selected session |
|
|
54
55
|
| `q` | home/overlay | quit the client — sessions keep running in the daemon |
|
|
55
56
|
|
|
57
|
+
The overlay clusters sessions under dim headers when more than one group exists: a session's
|
|
58
|
+
explicit group wins, and sessions without one fall back to the directory they were spawned from.
|
|
59
|
+
Groups also come from outside — the `atc_session_update` MCP tool renames and regroups sessions, so
|
|
60
|
+
an agent can organise the fleet for you.
|
|
61
|
+
|
|
56
62
|
Revive (`P`) and headless eject (`H`) resume the session from its saved transcript, so both need one
|
|
57
63
|
to exist: a session killed before its first exchange has nothing on disk yet, and the overlay says
|
|
58
64
|
so in its message column instead of resuming.
|
|
@@ -92,8 +98,8 @@ Pick a different leader when `Ctrl-Space` is taken on your machine — Raycast o
|
|
|
92
98
|
`ctrl-]` is a solid replacement that no common terminal, multiplexer, or OS shortcut wants. An
|
|
93
99
|
unknown or reserved value falls back to the default.
|
|
94
100
|
|
|
95
|
-
`atc mcp` exposes the fleet as MCP tools (list, spawn, drive) to any MCP client, wrangled
|
|
96
|
-
included:
|
|
101
|
+
`atc mcp` exposes the fleet as MCP tools (list, spawn, drive, organise) to any MCP client, wrangled
|
|
102
|
+
sessions included:
|
|
97
103
|
|
|
98
104
|
```sh
|
|
99
105
|
claude mcp add --scope user atc -- atc mcp
|
package/package.json
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,7 @@ 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;
|
|
27
29
|
readonly ackSession: (id: string) => boolean;
|
|
28
30
|
readonly buildResumeCommand: (id: string) => string | null;
|
|
29
31
|
readonly answerPermission: (request: string, decision: string) => AnswerResult;
|
|
@@ -213,6 +215,19 @@ export class DaemonConnection {
|
|
|
213
215
|
|
|
214
216
|
return;
|
|
215
217
|
}
|
|
218
|
+
case 'session.update': {
|
|
219
|
+
const sessionID = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
|
|
220
|
+
const name = typeof req.p?.['name'] === 'string' ? req.p['name'] : undefined;
|
|
221
|
+
const group = typeof req.p?.['group'] === 'string' ? req.p['group'] : undefined;
|
|
222
|
+
|
|
223
|
+
if (this.ctx.updateSession(sessionID, name, group)) {
|
|
224
|
+
this.sendOk(req.id, {});
|
|
225
|
+
} else {
|
|
226
|
+
this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
216
231
|
case 'session.kill': {
|
|
217
232
|
this.applySessionVerb(req, this.ctx.killSession);
|
|
218
233
|
|
|
@@ -348,6 +363,8 @@ export class DaemonConnection {
|
|
|
348
363
|
resume = rawResume;
|
|
349
364
|
}
|
|
350
365
|
|
|
366
|
+
const group = typeof req.p?.['group'] === 'string' ? req.p['group'] : '';
|
|
367
|
+
|
|
351
368
|
const session = this.ctx.spawnSession({
|
|
352
369
|
cwd,
|
|
353
370
|
name: name === '' ? basename(cwd) : name,
|
|
@@ -356,6 +373,7 @@ export class DaemonConnection {
|
|
|
356
373
|
rows: typeof req.p?.['rows'] === 'number' ? req.p['rows'] : 24,
|
|
357
374
|
resume,
|
|
358
375
|
namedBy: name === '' ? 'auto' : 'user',
|
|
376
|
+
...(group === '' ? {} : { group }),
|
|
359
377
|
});
|
|
360
378
|
|
|
361
379
|
this.sendOk(req.id, { session });
|
package/src/daemon.ts
CHANGED
|
@@ -367,6 +367,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
367
367
|
kind: s.kind,
|
|
368
368
|
alive: s.pty !== null || (s.kind === 'jsonl' && s.state !== 'exited'),
|
|
369
369
|
...(s.claudeId === undefined ? {} : { claudeId: s.claudeId }),
|
|
370
|
+
...(s.group === undefined ? {} : { group: s.group }),
|
|
370
371
|
}),
|
|
371
372
|
renamed: () => ({
|
|
372
373
|
v: PROTOCOL_V,
|
|
@@ -399,7 +400,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
399
400
|
collectSpawnDirs: () => store.collectSpawnDirs(),
|
|
400
401
|
collectFleet: () => store.loadFleet(),
|
|
401
402
|
spawnSession: (p) => {
|
|
402
|
-
const s = mgr.spawn(p.cwd, p.name, p.prompt, p.cols, p.rows, p.resume, p.namedBy);
|
|
403
|
+
const s = mgr.spawn(p.cwd, p.name, p.prompt, p.cols, p.rows, p.resume, p.namedBy, p.group);
|
|
403
404
|
|
|
404
405
|
ptyDims.set(s.id, { cols: p.cols, rows: p.rows });
|
|
405
406
|
screens.set(s.id, new ScreenModel(p.cols, p.rows));
|
|
@@ -407,6 +408,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
407
408
|
|
|
408
409
|
return getDescriptor(mgr, s.id);
|
|
409
410
|
},
|
|
411
|
+
updateSession: (id, name, group) => mgr.updateSession(id, name, group),
|
|
410
412
|
killSession: (id) => {
|
|
411
413
|
if (!mgr.sessions.some((s) => s.id === id)) {
|
|
412
414
|
return false;
|
|
@@ -556,7 +558,16 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
556
558
|
continue;
|
|
557
559
|
}
|
|
558
560
|
|
|
559
|
-
const s = mgr.spawn(
|
|
561
|
+
const s = mgr.spawn(
|
|
562
|
+
entry.cwd,
|
|
563
|
+
entry.name,
|
|
564
|
+
'',
|
|
565
|
+
cols,
|
|
566
|
+
rows,
|
|
567
|
+
entry.claudeId,
|
|
568
|
+
'auto',
|
|
569
|
+
entry.group,
|
|
570
|
+
);
|
|
560
571
|
|
|
561
572
|
ptyDims.set(s.id, { cols, rows });
|
|
562
573
|
screens.set(s.id, new ScreenModel(cols, rows));
|
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
|
|
|
@@ -197,9 +200,11 @@ function pickOverlaySessions(): MirrorSession[] {
|
|
|
197
200
|
return sorted;
|
|
198
201
|
}
|
|
199
202
|
|
|
200
|
-
const f = overlayFilter
|
|
203
|
+
const f = overlayFilter;
|
|
201
204
|
|
|
202
|
-
return sorted.filter(
|
|
205
|
+
return sorted.filter(
|
|
206
|
+
(s) => findFuzzyScore(`${s.name} ${s.group ?? ''} ${formatDir(s.cwd)}`, f) !== null,
|
|
207
|
+
);
|
|
203
208
|
}
|
|
204
209
|
|
|
205
210
|
function renderOverlay() {
|
|
@@ -255,6 +260,18 @@ function renderPicker() {
|
|
|
255
260
|
placeholder: basename(spawnDir),
|
|
256
261
|
hint: `session name for ${formatDir(spawnDir)} · ⏎ accept · esc back`,
|
|
257
262
|
});
|
|
263
|
+
} else if (mode === 'picker-group') {
|
|
264
|
+
const groups = [...new Set(fleet.map((s) => s.group).filter((g) => g !== undefined))];
|
|
265
|
+
const known = groups.length === 0 ? '' : ` · existing: ${groups.slice(0, 4).join(', ')}`;
|
|
266
|
+
|
|
267
|
+
drawPicker({
|
|
268
|
+
title: `${verb}: group`,
|
|
269
|
+
items: [],
|
|
270
|
+
selected: -1,
|
|
271
|
+
input: pickerInput,
|
|
272
|
+
placeholder: 'optional — ⏎ to skip',
|
|
273
|
+
hint: `group for the overlay hierarchy${known} · ⏎ accept · esc back`,
|
|
274
|
+
});
|
|
258
275
|
} else if (mode === 'picker-eject') {
|
|
259
276
|
drawPicker({
|
|
260
277
|
title: 'eject: headless instruction',
|
|
@@ -280,6 +297,7 @@ function renderPicker() {
|
|
|
280
297
|
|
|
281
298
|
async function openDirPicker(resume = false) {
|
|
282
299
|
spawnResume = resume;
|
|
300
|
+
spawnGroup = '';
|
|
283
301
|
|
|
284
302
|
let recent: string[] = [];
|
|
285
303
|
|
|
@@ -327,6 +345,7 @@ async function spawnFromPicker(prompt: string) {
|
|
|
327
345
|
cols: cols(),
|
|
328
346
|
rows: ptyRows(),
|
|
329
347
|
...(spawnResume ? { resume: true } : {}),
|
|
348
|
+
...(spawnGroup === '' ? {} : { group: spawnGroup }),
|
|
330
349
|
});
|
|
331
350
|
|
|
332
351
|
const spawned = toMirrorSession(ok['session']);
|
|
@@ -382,6 +401,7 @@ function toMirrorSession(value: unknown): MirrorSession | null {
|
|
|
382
401
|
id: value['id'],
|
|
383
402
|
name: value['name'],
|
|
384
403
|
cwd: value['cwd'],
|
|
404
|
+
...(typeof value['group'] === 'string' ? { group: value['group'] } : {}),
|
|
385
405
|
state,
|
|
386
406
|
unread: value['unread'],
|
|
387
407
|
lastMsg: value['lastMsg'],
|
|
@@ -463,6 +483,14 @@ function applyDaemonEvent(e: EventMsg) {
|
|
|
463
483
|
s.kind = e['kind'];
|
|
464
484
|
}
|
|
465
485
|
|
|
486
|
+
if (typeof e['group'] === 'string') {
|
|
487
|
+
if (e['group'] === '') {
|
|
488
|
+
delete s.group;
|
|
489
|
+
} else {
|
|
490
|
+
s.group = e['group'];
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
466
494
|
if (typeof e['unread'] === 'boolean') {
|
|
467
495
|
s.unread = e['unread'];
|
|
468
496
|
}
|
|
@@ -529,6 +557,7 @@ function copyToClipboard(text: string) {
|
|
|
529
557
|
const leader = loadConfig().leader;
|
|
530
558
|
|
|
531
559
|
const KEY = {
|
|
560
|
+
tab: 0x09,
|
|
532
561
|
ctrlC: 0x03,
|
|
533
562
|
ctrlU: 0x15,
|
|
534
563
|
esc: 0x1b,
|
|
@@ -663,6 +692,16 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
663
692
|
|
|
664
693
|
const ch = buf.toString();
|
|
665
694
|
|
|
695
|
+
if (buf[0] === KEY.tab) {
|
|
696
|
+
const needy = sortSessionViews(fleet).find((s) => s.state === 'needs_you' && s.alive);
|
|
697
|
+
|
|
698
|
+
if (needy !== undefined) {
|
|
699
|
+
void attach(needy.id);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
666
705
|
if (buf[0] === KEY.enter && sel !== undefined && sel.alive) {
|
|
667
706
|
void attach(sel.id);
|
|
668
707
|
|
|
@@ -975,6 +1014,30 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
975
1014
|
() => {
|
|
976
1015
|
spawnName = pickerInput.trim();
|
|
977
1016
|
pickerInput = '';
|
|
1017
|
+
mode = 'picker-group';
|
|
1018
|
+
|
|
1019
|
+
stdout.write(ansi.clear);
|
|
1020
|
+
|
|
1021
|
+
renderPicker();
|
|
1022
|
+
},
|
|
1023
|
+
() => {
|
|
1024
|
+
pickerInput = '';
|
|
1025
|
+
mode = 'picker-dir';
|
|
1026
|
+
|
|
1027
|
+
stdout.write(ansi.clear);
|
|
1028
|
+
|
|
1029
|
+
renderPicker();
|
|
1030
|
+
},
|
|
1031
|
+
);
|
|
1032
|
+
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
case 'picker-group': {
|
|
1036
|
+
applyTextKey(
|
|
1037
|
+
buf,
|
|
1038
|
+
() => {
|
|
1039
|
+
spawnGroup = pickerInput.trim();
|
|
1040
|
+
pickerInput = '';
|
|
978
1041
|
|
|
979
1042
|
// Adopt skips the prompt step: claude --resume opens its own
|
|
980
1043
|
// session picker inside the new PTY.
|
|
@@ -992,7 +1055,7 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
992
1055
|
},
|
|
993
1056
|
() => {
|
|
994
1057
|
pickerInput = '';
|
|
995
|
-
mode = 'picker-
|
|
1058
|
+
mode = 'picker-name';
|
|
996
1059
|
|
|
997
1060
|
stdout.write(ansi.clear);
|
|
998
1061
|
|
|
@@ -1010,7 +1073,7 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
1010
1073
|
},
|
|
1011
1074
|
() => {
|
|
1012
1075
|
pickerInput = '';
|
|
1013
|
-
mode = 'picker-
|
|
1076
|
+
mode = 'picker-group';
|
|
1014
1077
|
|
|
1015
1078
|
stdout.write(ansi.clear);
|
|
1016
1079
|
|
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.',
|
|
@@ -237,6 +252,15 @@ async function runTool(
|
|
|
237
252
|
|
|
238
253
|
return 'sent';
|
|
239
254
|
}
|
|
255
|
+
case 'atc_session_update': {
|
|
256
|
+
await client.sendRequest('session.update', {
|
|
257
|
+
session: args['session'],
|
|
258
|
+
...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
|
|
259
|
+
...(typeof args['group'] === 'string' ? { group: args['group'] } : {}),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
return 'updated';
|
|
263
|
+
}
|
|
240
264
|
case 'atc_session_kill': {
|
|
241
265
|
await client.sendRequest('session.kill', { session: args['session'] });
|
|
242
266
|
|
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
|
@@ -153,6 +153,7 @@ export interface OverlaySessionView extends SessionView {
|
|
|
153
153
|
readonly alive: boolean;
|
|
154
154
|
readonly kind: 'pty' | 'jsonl';
|
|
155
155
|
readonly resumable: boolean;
|
|
156
|
+
readonly group?: string;
|
|
156
157
|
}
|
|
157
158
|
|
|
158
159
|
export interface OverlayView {
|
|
@@ -172,15 +173,29 @@ export function drawOverlay(view: OverlayView) {
|
|
|
172
173
|
rowsList.push(dimRow(width, empty));
|
|
173
174
|
}
|
|
174
175
|
|
|
176
|
+
// Sessions cluster under dim headers when more than one group exists: an
|
|
177
|
+
// explicit group wins, sessions without one fall back to their spawn
|
|
178
|
+
// directory. The rows keep the sorted order, so urgent groups lead.
|
|
179
|
+
const grouped = new Set(view.sessions.map((s) => s.group ?? s.cwd)).size > 1;
|
|
180
|
+
let lastKey: string | null = null;
|
|
181
|
+
|
|
175
182
|
for (const [i, s] of view.sessions.entries()) {
|
|
183
|
+
const key = s.group ?? s.cwd;
|
|
184
|
+
|
|
185
|
+
if (grouped && key !== lastKey) {
|
|
186
|
+
lastKey = key;
|
|
187
|
+
|
|
188
|
+
rowsList.push(dimRow(width, `▸ ${s.group ?? formatDir(s.cwd)}`));
|
|
189
|
+
}
|
|
190
|
+
|
|
176
191
|
const sel = i === view.selected;
|
|
177
192
|
const name = truncate(s.name, 16).padEnd(16);
|
|
178
193
|
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);
|
|
194
|
+
const dir = grouped ? '' : ` ${truncate(formatDir(s.cwd), 18).padEnd(18)}`;
|
|
195
|
+
const msgWidth = Math.max(4, width - 4 - 2 - 17 - 10 - (grouped ? 0 : 19));
|
|
181
196
|
const msg = truncate(s.lastMsg, msgWidth).padEnd(msgWidth);
|
|
182
197
|
const unread = s.unread ? `${ESC}[1;33m!${ESC}[0m` : ' ';
|
|
183
|
-
const body = `${name} ${state}
|
|
198
|
+
const body = `${name} ${state}${dir} ${msg}`;
|
|
184
199
|
const styledBody = sel ? `${ESC}[7m${body}${ESC}[0m` : body;
|
|
185
200
|
|
|
186
201
|
rowsList.push(boxRow(width, `${GLYPH[s.state]}${unread}${styledBody}`, 2 + body.length));
|
|
@@ -249,6 +264,7 @@ export function drawHelp() {
|
|
|
249
264
|
|
|
250
265
|
const lines = [
|
|
251
266
|
'⏎ attach the selected session',
|
|
267
|
+
'⇥ attach the most urgent needs-you session',
|
|
252
268
|
'a ack its notification without attaching',
|
|
253
269
|
'H eject to a headless run',
|
|
254
270
|
'P revive a dead or headless session',
|