@zgeoff/atc 0.1.8 → 0.1.9
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 +15 -8
- package/package.json +1 -1
- package/src/daemon-connection.ts +3 -7
- package/src/daemon.ts +4 -3
- package/src/index.ts +45 -55
- package/src/mcp-server.ts +3 -3
- package/src/pick-tab-target.ts +2 -0
- package/src/resolve-repo-root.ts +51 -0
- package/src/sessions.ts +64 -34
- package/src/state-store.ts +37 -20
- package/src/ui.ts +21 -12
package/README.md
CHANGED
|
@@ -27,7 +27,10 @@ atc
|
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
Needs [Bun](https://bun.sh) (atc runs from source through it) and the `claude` CLI on your PATH.
|
|
30
|
-
From a checkout, `bun src/cli.ts` runs the same thing.
|
|
30
|
+
From a checkout, `bun src/cli.ts` runs the same thing. atc is built to pair with
|
|
31
|
+
[zoxide](https://github.com/ajeetdsouza/zoxide): the spawn directory picker feeds on its frecency
|
|
32
|
+
list, so with zoxide installed every directory you visit is two keystrokes from a session. Without
|
|
33
|
+
it the picker falls back to atc's own spawn history.
|
|
31
34
|
|
|
32
35
|
The first invocation auto-spawns the daemon (`atc daemon` runs it in the foreground for systemd or
|
|
33
36
|
debugging); the TUI is a thin client, so quitting or crashing it leaves every session running. Runs
|
|
@@ -38,7 +41,7 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
|
|
|
38
41
|
| Key | Where | Action |
|
|
39
42
|
| --------------- | -------------- | ---------------------------------------------------------------------------------------------- |
|
|
40
43
|
| leader | anywhere | toggle session overlay — `Ctrl-Space` by default, configurable (see Config) |
|
|
41
|
-
| `n` | home/overlay | spawn: pick dir (zoxide + history, fuzzy) → name → optional
|
|
44
|
+
| `n` | home/overlay | spawn: pick dir (zoxide + history, fuzzy) → name → optional first prompt |
|
|
42
45
|
| `r` | home/overlay | adopt: pick dir → name → `claude --resume` (Claude's session picker opens in the new PTY) |
|
|
43
46
|
| `R` | home | restore last fleet after a daemon death — respawns every session via `claude --resume <id>` |
|
|
44
47
|
| `j`/`k`/`↑`/`↓` | overlay/picker | move |
|
|
@@ -46,6 +49,8 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
|
|
|
46
49
|
| `Tab` | overlay | attach the most urgent needs-you session, else the latest turn-done one |
|
|
47
50
|
| `/` | overlay | fuzzy filter by name/dir (chars in order), `⏎` attach top match, `esc` clear |
|
|
48
51
|
| `a` | overlay | ack notification without attaching |
|
|
52
|
+
| `p` | overlay | pin or unpin the selected session — pinned sessions stay at the top of the list |
|
|
53
|
+
| `g` | overlay | toggle the grouped view: sessions cluster under repository headers |
|
|
49
54
|
| `H` | overlay | eject to headless: the terminal dies, a headless Agent SDK run resumes the same session |
|
|
50
55
|
| `P` | overlay | revive: a fresh terminal resumes a headless or killed session in place |
|
|
51
56
|
| `y` | overlay | yank `cd <dir> && claude --resume <id>` to clipboard (OSC 52 + clip.exe/wl-copy/xclip) |
|
|
@@ -55,10 +60,12 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
|
|
|
55
60
|
| `u` | overlay | restart an outdated daemon and restore the fleet — offered only while `⟳ update ready` shows |
|
|
56
61
|
| `q` | home/overlay | quit the client — sessions keep running in the daemon |
|
|
57
62
|
|
|
58
|
-
The overlay
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
The overlay orders sessions by pinned first, then attention state, then most recently attached, so
|
|
64
|
+
the session you want is nearly always near the top. The grouped view (`g`) keeps that order but
|
|
65
|
+
clusters sessions under dim repository headers, with pinned sessions leading in their own cluster; a
|
|
66
|
+
git worktree clusters with its main repository, and a directory outside any repository stands alone.
|
|
67
|
+
The `atc_session_update` MCP tool renames and pins sessions, so an agent can organise the fleet for
|
|
68
|
+
you.
|
|
62
69
|
|
|
63
70
|
Revive (`P`) and headless eject (`H`) resume the session from its saved transcript, so both need one
|
|
64
71
|
to exist: a session killed before its first exchange has nothing on disk yet, and the overlay says
|
|
@@ -74,8 +81,8 @@ status bar only on the home and overlay screens.
|
|
|
74
81
|
Spawned sessions get a `--settings` file injecting `Notification`, `Stop`, `UserPromptSubmit`, and
|
|
75
82
|
`SessionEnd` hooks that report to a unix socket (`$XDG_RUNTIME_DIR/atc.sock`). Your global Claude
|
|
76
83
|
settings are untouched; sessions you start outside atc are unaffected. States: red `●` needs you,
|
|
77
|
-
cyan `◐` running, green `✓` turn done, gray `✗` exited. The
|
|
78
|
-
|
|
84
|
+
cyan `◐` running, green `✓` turn done, gray `✗` exited. The status bar turns red and names the most
|
|
85
|
+
urgent session.
|
|
79
86
|
|
|
80
87
|
## Config
|
|
81
88
|
|
package/package.json
CHANGED
package/src/daemon-connection.ts
CHANGED
|
@@ -15,7 +15,6 @@ interface SpawnParams {
|
|
|
15
15
|
readonly rows: number;
|
|
16
16
|
readonly resume: boolean | string;
|
|
17
17
|
readonly namedBy: 'user' | 'auto';
|
|
18
|
-
readonly group?: string;
|
|
19
18
|
}
|
|
20
19
|
|
|
21
20
|
export interface DaemonContext {
|
|
@@ -25,7 +24,7 @@ export interface DaemonContext {
|
|
|
25
24
|
readonly collectFleet: () => FleetEntry[];
|
|
26
25
|
readonly spawnSession: (p: SpawnParams) => SessionDescriptor;
|
|
27
26
|
readonly killSession: (id: string) => boolean;
|
|
28
|
-
readonly updateSession: (id: string, name?: string,
|
|
27
|
+
readonly updateSession: (id: string, name?: string, pinned?: boolean) => boolean;
|
|
29
28
|
readonly quitDaemon: () => void;
|
|
30
29
|
readonly ackSession: (id: string) => boolean;
|
|
31
30
|
readonly buildResumeCommand: (id: string) => string | null;
|
|
@@ -225,9 +224,9 @@ export class DaemonConnection {
|
|
|
225
224
|
case 'session.update': {
|
|
226
225
|
const sessionID = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
|
|
227
226
|
const name = typeof req.p?.['name'] === 'string' ? req.p['name'] : undefined;
|
|
228
|
-
const
|
|
227
|
+
const pinned = typeof req.p?.['pinned'] === 'boolean' ? req.p['pinned'] : undefined;
|
|
229
228
|
|
|
230
|
-
if (this.ctx.updateSession(sessionID, name,
|
|
229
|
+
if (this.ctx.updateSession(sessionID, name, pinned)) {
|
|
231
230
|
this.sendOk(req.id, {});
|
|
232
231
|
} else {
|
|
233
232
|
this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
|
|
@@ -370,8 +369,6 @@ export class DaemonConnection {
|
|
|
370
369
|
resume = rawResume;
|
|
371
370
|
}
|
|
372
371
|
|
|
373
|
-
const group = typeof req.p?.['group'] === 'string' ? req.p['group'] : '';
|
|
374
|
-
|
|
375
372
|
const session = this.ctx.spawnSession({
|
|
376
373
|
cwd,
|
|
377
374
|
name: name === '' ? basename(cwd) : name,
|
|
@@ -380,7 +377,6 @@ export class DaemonConnection {
|
|
|
380
377
|
rows: typeof req.p?.['rows'] === 'number' ? req.p['rows'] : 24,
|
|
381
378
|
resume,
|
|
382
379
|
namedBy: name === '' ? 'auto' : 'user',
|
|
383
|
-
...(group === '' ? {} : { group }),
|
|
384
380
|
});
|
|
385
381
|
|
|
386
382
|
this.sendOk(req.id, { session });
|
package/src/daemon.ts
CHANGED
|
@@ -418,7 +418,8 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
418
418
|
kind: s.kind,
|
|
419
419
|
alive: s.pty !== null || (s.kind === 'jsonl' && s.state !== 'exited'),
|
|
420
420
|
...(s.claudeId === undefined ? {} : { claudeId: s.claudeId }),
|
|
421
|
-
|
|
421
|
+
pinned: s.pinned,
|
|
422
|
+
lastAttachedAt: s.lastAttachedAt,
|
|
422
423
|
}),
|
|
423
424
|
renamed: () => ({
|
|
424
425
|
v: PROTOCOL_V,
|
|
@@ -457,7 +458,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
457
458
|
collectSpawnDirs: () => store.collectSpawnDirs(),
|
|
458
459
|
collectFleet: () => store.loadFleet(),
|
|
459
460
|
spawnSession: (p) => {
|
|
460
|
-
const s = mgr.spawn(p.cwd, p.name, p.prompt, p.cols, p.rows, p.resume, p.namedBy
|
|
461
|
+
const s = mgr.spawn(p.cwd, p.name, p.prompt, p.cols, p.rows, p.resume, p.namedBy);
|
|
461
462
|
|
|
462
463
|
ptyDims.set(s.id, { cols: p.cols, rows: p.rows });
|
|
463
464
|
screens.set(s.id, new ScreenModel(p.cols, p.rows));
|
|
@@ -465,7 +466,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
465
466
|
|
|
466
467
|
return getDescriptor(mgr, s.id);
|
|
467
468
|
},
|
|
468
|
-
updateSession: (id, name,
|
|
469
|
+
updateSession: (id, name, pinned) => mgr.updateSession(id, name, pinned),
|
|
469
470
|
quitDaemon: () => {
|
|
470
471
|
// The ok response for the quit request must flush before the sockets
|
|
471
472
|
// close under it.
|
package/src/index.ts
CHANGED
|
@@ -17,7 +17,6 @@ type Mode =
|
|
|
17
17
|
| 'help'
|
|
18
18
|
| 'picker-dir'
|
|
19
19
|
| 'picker-name'
|
|
20
|
-
| 'picker-group'
|
|
21
20
|
| 'picker-prompt'
|
|
22
21
|
| 'picker-eject';
|
|
23
22
|
|
|
@@ -25,7 +24,9 @@ interface MirrorSession {
|
|
|
25
24
|
id: string;
|
|
26
25
|
name: string;
|
|
27
26
|
cwd: string;
|
|
28
|
-
|
|
27
|
+
pinned: boolean;
|
|
28
|
+
lastAttachedAt: number;
|
|
29
|
+
repoRoot: string;
|
|
29
30
|
state: SessionState;
|
|
30
31
|
unread: boolean;
|
|
31
32
|
lastMsg: string;
|
|
@@ -56,7 +57,6 @@ let pickerInput = '';
|
|
|
56
57
|
let pickerSelected = 0;
|
|
57
58
|
let spawnDir = '';
|
|
58
59
|
let spawnName = '';
|
|
59
|
-
let spawnGroup = '';
|
|
60
60
|
let spawnResume = false;
|
|
61
61
|
const stdout = process.stdout;
|
|
62
62
|
|
|
@@ -202,8 +202,11 @@ function applyHelpKey(buf: Buffer) {
|
|
|
202
202
|
// fzf-style overlay search: null when inactive, the pattern while active.
|
|
203
203
|
let overlayFilter: string | null = null;
|
|
204
204
|
|
|
205
|
+
// Flat list by default; g toggles clustering under repository headers.
|
|
206
|
+
let overlayGrouped = false;
|
|
207
|
+
|
|
205
208
|
function pickOverlaySessions(): MirrorSession[] {
|
|
206
|
-
const sorted = sortGroupedSessionViews(fleet);
|
|
209
|
+
const sorted = overlayGrouped ? sortGroupedSessionViews(fleet) : sortSessionViews(fleet);
|
|
207
210
|
|
|
208
211
|
if (overlayFilter === null || overlayFilter === '') {
|
|
209
212
|
return sorted;
|
|
@@ -211,9 +214,7 @@ function pickOverlaySessions(): MirrorSession[] {
|
|
|
211
214
|
|
|
212
215
|
const f = overlayFilter;
|
|
213
216
|
|
|
214
|
-
return sorted.filter(
|
|
215
|
-
(s) => findFuzzyScore(`${s.name} ${s.group ?? ''} ${formatDir(s.cwd)}`, f) !== null,
|
|
216
|
-
);
|
|
217
|
+
return sorted.filter((s) => findFuzzyScore(`${s.name} ${formatDir(s.cwd)}`, f) !== null);
|
|
217
218
|
}
|
|
218
219
|
|
|
219
220
|
function renderOverlay() {
|
|
@@ -227,6 +228,7 @@ function renderOverlay() {
|
|
|
227
228
|
confirmKill,
|
|
228
229
|
filter: overlayFilter,
|
|
229
230
|
stale: daemonStale,
|
|
231
|
+
grouped: overlayGrouped,
|
|
230
232
|
});
|
|
231
233
|
|
|
232
234
|
scheduleStatus();
|
|
@@ -237,7 +239,7 @@ function openOverlay() {
|
|
|
237
239
|
confirmKill = false;
|
|
238
240
|
overlayFilter = null;
|
|
239
241
|
|
|
240
|
-
const focusedIndex =
|
|
242
|
+
const focusedIndex = pickOverlaySessions().findIndex((s) => s.id === focusedID);
|
|
241
243
|
|
|
242
244
|
overlaySelected = Math.max(0, focusedIndex);
|
|
243
245
|
|
|
@@ -270,18 +272,6 @@ function renderPicker() {
|
|
|
270
272
|
placeholder: basename(spawnDir),
|
|
271
273
|
hint: `session name for ${formatDir(spawnDir)} · ⏎ accept · esc back`,
|
|
272
274
|
});
|
|
273
|
-
} else if (mode === 'picker-group') {
|
|
274
|
-
const groups = [...new Set(fleet.map((s) => s.group).filter((g) => g !== undefined))];
|
|
275
|
-
const known = groups.length === 0 ? '' : ` · existing: ${groups.slice(0, 4).join(', ')}`;
|
|
276
|
-
|
|
277
|
-
drawPicker({
|
|
278
|
-
title: `${verb}: group`,
|
|
279
|
-
items: [],
|
|
280
|
-
selected: -1,
|
|
281
|
-
input: pickerInput,
|
|
282
|
-
placeholder: 'optional — ⏎ to skip',
|
|
283
|
-
hint: `group for the overlay hierarchy${known} · ⏎ accept · esc back`,
|
|
284
|
-
});
|
|
285
275
|
} else if (mode === 'picker-eject') {
|
|
286
276
|
drawPicker({
|
|
287
277
|
title: 'eject: headless instruction',
|
|
@@ -307,7 +297,6 @@ function renderPicker() {
|
|
|
307
297
|
|
|
308
298
|
async function openDirPicker(resume = false) {
|
|
309
299
|
spawnResume = resume;
|
|
310
|
-
spawnGroup = '';
|
|
311
300
|
|
|
312
301
|
let recent: string[] = [];
|
|
313
302
|
|
|
@@ -355,7 +344,6 @@ async function spawnFromPicker(prompt: string) {
|
|
|
355
344
|
cols: cols(),
|
|
356
345
|
rows: ptyRows(),
|
|
357
346
|
...(spawnResume ? { resume: true } : {}),
|
|
358
|
-
...(spawnGroup === '' ? {} : { group: spawnGroup }),
|
|
359
347
|
});
|
|
360
348
|
|
|
361
349
|
const spawned = toMirrorSession(ok['session']);
|
|
@@ -411,7 +399,10 @@ function toMirrorSession(value: unknown): MirrorSession | null {
|
|
|
411
399
|
id: value['id'],
|
|
412
400
|
name: value['name'],
|
|
413
401
|
cwd: value['cwd'],
|
|
414
|
-
|
|
402
|
+
pinned: value['pinned'] === true,
|
|
403
|
+
lastAttachedAt:
|
|
404
|
+
typeof value['lastAttachedAt'] === 'number' ? value['lastAttachedAt'] : value['createdAt'],
|
|
405
|
+
repoRoot: typeof value['repoRoot'] === 'string' ? value['repoRoot'] : value['cwd'],
|
|
415
406
|
state,
|
|
416
407
|
unread: value['unread'],
|
|
417
408
|
lastMsg: value['lastMsg'],
|
|
@@ -429,6 +420,9 @@ function upsertMirror(d: Readonly<MirrorSession>) {
|
|
|
429
420
|
fleet.push({ ...d });
|
|
430
421
|
} else {
|
|
431
422
|
existing.name = d.name;
|
|
423
|
+
existing.pinned = d.pinned;
|
|
424
|
+
existing.lastAttachedAt = d.lastAttachedAt;
|
|
425
|
+
existing.repoRoot = d.repoRoot;
|
|
432
426
|
existing.state = d.state;
|
|
433
427
|
existing.unread = d.unread;
|
|
434
428
|
existing.lastMsg = d.lastMsg;
|
|
@@ -497,12 +491,12 @@ function applyDaemonEvent(e: EventMsg) {
|
|
|
497
491
|
s.kind = e['kind'];
|
|
498
492
|
}
|
|
499
493
|
|
|
500
|
-
if (typeof e['
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
494
|
+
if (typeof e['pinned'] === 'boolean') {
|
|
495
|
+
s.pinned = e['pinned'];
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (typeof e['lastAttachedAt'] === 'number') {
|
|
499
|
+
s.lastAttachedAt = e['lastAttachedAt'];
|
|
506
500
|
}
|
|
507
501
|
|
|
508
502
|
if (typeof e['unread'] === 'boolean') {
|
|
@@ -746,6 +740,26 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
746
740
|
return;
|
|
747
741
|
}
|
|
748
742
|
|
|
743
|
+
if (ch === 'g') {
|
|
744
|
+
overlayGrouped = !overlayGrouped;
|
|
745
|
+
|
|
746
|
+
stdout.write(ansi.clear);
|
|
747
|
+
|
|
748
|
+
renderOverlay();
|
|
749
|
+
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (ch === 'p' && sel !== undefined) {
|
|
754
|
+
// Flipped locally too so the repaint is immediate; the daemon's state
|
|
755
|
+
// event confirms it.
|
|
756
|
+
sel.pinned = !sel.pinned;
|
|
757
|
+
void sendQuiet('session.update', { session: sel.id, pinned: sel.pinned });
|
|
758
|
+
renderOverlay();
|
|
759
|
+
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
|
|
749
763
|
if (ch === '?') {
|
|
750
764
|
openHelp();
|
|
751
765
|
|
|
@@ -1088,30 +1102,6 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
1088
1102
|
() => {
|
|
1089
1103
|
spawnName = pickerInput.trim();
|
|
1090
1104
|
pickerInput = '';
|
|
1091
|
-
mode = 'picker-group';
|
|
1092
|
-
|
|
1093
|
-
stdout.write(ansi.clear);
|
|
1094
|
-
|
|
1095
|
-
renderPicker();
|
|
1096
|
-
},
|
|
1097
|
-
() => {
|
|
1098
|
-
pickerInput = '';
|
|
1099
|
-
mode = 'picker-dir';
|
|
1100
|
-
|
|
1101
|
-
stdout.write(ansi.clear);
|
|
1102
|
-
|
|
1103
|
-
renderPicker();
|
|
1104
|
-
},
|
|
1105
|
-
);
|
|
1106
|
-
|
|
1107
|
-
return;
|
|
1108
|
-
}
|
|
1109
|
-
case 'picker-group': {
|
|
1110
|
-
applyTextKey(
|
|
1111
|
-
buf,
|
|
1112
|
-
() => {
|
|
1113
|
-
spawnGroup = pickerInput.trim();
|
|
1114
|
-
pickerInput = '';
|
|
1115
1105
|
|
|
1116
1106
|
// Adopt skips the prompt step: claude --resume opens its own
|
|
1117
1107
|
// session picker inside the new PTY.
|
|
@@ -1129,7 +1119,7 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
1129
1119
|
},
|
|
1130
1120
|
() => {
|
|
1131
1121
|
pickerInput = '';
|
|
1132
|
-
mode = 'picker-
|
|
1122
|
+
mode = 'picker-dir';
|
|
1133
1123
|
|
|
1134
1124
|
stdout.write(ansi.clear);
|
|
1135
1125
|
|
|
@@ -1147,7 +1137,7 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
1147
1137
|
},
|
|
1148
1138
|
() => {
|
|
1149
1139
|
pickerInput = '';
|
|
1150
|
-
mode = 'picker-
|
|
1140
|
+
mode = 'picker-name';
|
|
1151
1141
|
|
|
1152
1142
|
stdout.write(ansi.clear);
|
|
1153
1143
|
|
package/src/mcp-server.ts
CHANGED
|
@@ -72,13 +72,13 @@ const TOOLS: readonly MCPTool[] = [
|
|
|
72
72
|
{
|
|
73
73
|
name: 'atc_session_update',
|
|
74
74
|
description:
|
|
75
|
-
'Rename and/or
|
|
75
|
+
'Rename and/or pin a session. Renames stick against auto-summaries; pinned sessions lead every list. Use this to organise the fleet: name sessions after their task.',
|
|
76
76
|
inputSchema: {
|
|
77
77
|
type: 'object',
|
|
78
78
|
properties: {
|
|
79
79
|
session: { type: 'string', description: 'The atc session id' },
|
|
80
80
|
name: { type: 'string', description: 'New display name; omit to keep' },
|
|
81
|
-
|
|
81
|
+
pinned: { type: 'boolean', description: 'Pin or unpin; omit to keep' },
|
|
82
82
|
},
|
|
83
83
|
required: ['session'],
|
|
84
84
|
additionalProperties: false,
|
|
@@ -258,7 +258,7 @@ async function runTool(
|
|
|
258
258
|
await client.sendRequest('session.update', {
|
|
259
259
|
session: args['session'],
|
|
260
260
|
...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
|
|
261
|
-
...(typeof args['
|
|
261
|
+
...(typeof args['pinned'] === 'boolean' ? { pinned: args['pinned'] } : {}),
|
|
262
262
|
});
|
|
263
263
|
|
|
264
264
|
return 'updated';
|
package/src/pick-tab-target.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { sortSessionViews } from './sessions';
|
|
|
4
4
|
interface TabCandidate {
|
|
5
5
|
readonly id: string;
|
|
6
6
|
readonly state: SessionState;
|
|
7
|
+
readonly pinned: boolean;
|
|
8
|
+
readonly lastAttachedAt: number;
|
|
7
9
|
readonly createdAt: number;
|
|
8
10
|
readonly alive: boolean;
|
|
9
11
|
readonly kind: 'pty' | 'jsonl';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The repository a directory belongs to, for clustering sessions in the
|
|
6
|
+
* overlay. Walks toward the filesystem root looking for a `.git` entry: a
|
|
7
|
+
* directory marks the repository root itself, and a linked worktree's `.git`
|
|
8
|
+
* file points back at the main repository, so worktrees cluster with it. A
|
|
9
|
+
* directory outside any repository resolves to itself.
|
|
10
|
+
*/
|
|
11
|
+
export function resolveRepoRoot(cwd: string): string {
|
|
12
|
+
let dir = cwd;
|
|
13
|
+
|
|
14
|
+
while (true) {
|
|
15
|
+
const marker = join(dir, '.git');
|
|
16
|
+
const stat = statSync(marker, { throwIfNoEntry: false });
|
|
17
|
+
|
|
18
|
+
if (stat !== undefined && stat.isDirectory()) {
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (stat !== undefined && stat.isFile()) {
|
|
23
|
+
return findMainRoot(marker) ?? dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const parent = dirname(dir);
|
|
27
|
+
|
|
28
|
+
if (parent === dir) {
|
|
29
|
+
return cwd;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
dir = parent;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A linked worktree's `.git` file reads `gitdir: <main>/.git/worktrees/<name>`.
|
|
37
|
+
function findMainRoot(gitFile: string): string | null {
|
|
38
|
+
try {
|
|
39
|
+
const gitdir = /^gitdir:\s*(?<dir>.+)$/mu
|
|
40
|
+
.exec(readFileSync(gitFile, 'utf8'))
|
|
41
|
+
?.groups?.['dir']?.trim();
|
|
42
|
+
|
|
43
|
+
if (gitdir === undefined) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return /^(?<root>.+)\/\.git\/worktrees\/[^/]+$/u.exec(gitdir)?.groups?.['root'] ?? null;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/sessions.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { collectCleanEnv } from './collect-clean-env';
|
|
|
7
7
|
import { socketPath, stateDir, statusFile } from './config';
|
|
8
8
|
import type { HookEvent } from './hooks';
|
|
9
9
|
import { isRecord } from './report';
|
|
10
|
+
import { resolveRepoRoot } from './resolve-repo-root';
|
|
10
11
|
|
|
11
12
|
export type SessionState = 'running' | 'needs_you' | 'done' | 'exited';
|
|
12
13
|
|
|
@@ -23,7 +24,9 @@ export interface SessionDescriptor {
|
|
|
23
24
|
readonly lastMsg: string;
|
|
24
25
|
readonly lastDetail?: string;
|
|
25
26
|
readonly claudeId?: string;
|
|
26
|
-
readonly
|
|
27
|
+
readonly pinned: boolean;
|
|
28
|
+
readonly lastAttachedAt: number;
|
|
29
|
+
readonly repoRoot: string;
|
|
27
30
|
readonly namedBy: 'user' | 'auto' | 'agent';
|
|
28
31
|
readonly createdAt: number;
|
|
29
32
|
readonly kind: 'pty' | 'jsonl';
|
|
@@ -42,7 +45,15 @@ export interface Session {
|
|
|
42
45
|
lastDetail?: string;
|
|
43
46
|
claudeId?: string;
|
|
44
47
|
transcriptSource?: string;
|
|
45
|
-
|
|
48
|
+
pinned: boolean;
|
|
49
|
+
|
|
50
|
+
// when the operator last attached, so lists can lead with the sessions
|
|
51
|
+
// they were just working in; starts at creation time.
|
|
52
|
+
lastAttachedAt: number;
|
|
53
|
+
|
|
54
|
+
// the repository this session's directory belongs to (the directory
|
|
55
|
+
// itself outside any repository), for clustering in the overlay.
|
|
56
|
+
repoRoot: string;
|
|
46
57
|
|
|
47
58
|
// who last named this session: the agent's own rename beats everything, a
|
|
48
59
|
// user-typed spawn name beats auto-summaries.
|
|
@@ -56,7 +67,8 @@ export interface FleetEntry {
|
|
|
56
67
|
readonly name: string;
|
|
57
68
|
readonly cwd: string;
|
|
58
69
|
readonly claudeId: string;
|
|
59
|
-
readonly
|
|
70
|
+
readonly pinned?: boolean;
|
|
71
|
+
readonly lastAttachedAt?: number;
|
|
60
72
|
}
|
|
61
73
|
|
|
62
74
|
const fleetFile = join(stateDir, 'fleet.json');
|
|
@@ -155,7 +167,9 @@ export class SessionManager {
|
|
|
155
167
|
unread: false,
|
|
156
168
|
lastMsg: 'waiting to restore',
|
|
157
169
|
claudeId: entry.claudeId,
|
|
158
|
-
|
|
170
|
+
pinned: entry.pinned ?? false,
|
|
171
|
+
lastAttachedAt: entry.lastAttachedAt ?? Date.now(),
|
|
172
|
+
repoRoot: resolveRepoRoot(entry.cwd),
|
|
159
173
|
namedBy: 'auto',
|
|
160
174
|
createdAt: Date.now(),
|
|
161
175
|
};
|
|
@@ -215,13 +229,12 @@ export class SessionManager {
|
|
|
215
229
|
return s;
|
|
216
230
|
}
|
|
217
231
|
|
|
218
|
-
// Reports a headless run's lifecycle into the session state machine.
|
|
219
232
|
/**
|
|
220
|
-
* Renames and/or
|
|
221
|
-
*
|
|
222
|
-
* in-session rename still wins.
|
|
233
|
+
* Renames and/or pins a session on a caller's behalf. A rename lands at
|
|
234
|
+
* user strength, so auto-summaries stop overwriting it while an
|
|
235
|
+
* in-session rename still wins. Pinned sessions lead every list.
|
|
223
236
|
*/
|
|
224
|
-
updateSession(id: string, name?: string,
|
|
237
|
+
updateSession(id: string, name?: string, pinned?: boolean): boolean {
|
|
225
238
|
const s = this.sessions.find((x) => x.id === id);
|
|
226
239
|
|
|
227
240
|
if (s === undefined) {
|
|
@@ -235,12 +248,8 @@ export class SessionManager {
|
|
|
235
248
|
this.onEvent('renamed', s);
|
|
236
249
|
}
|
|
237
250
|
|
|
238
|
-
if (
|
|
239
|
-
|
|
240
|
-
delete s.group;
|
|
241
|
-
} else {
|
|
242
|
-
s.group = group;
|
|
243
|
-
}
|
|
251
|
+
if (pinned !== undefined && pinned !== s.pinned) {
|
|
252
|
+
s.pinned = pinned;
|
|
244
253
|
|
|
245
254
|
this.onEvent('state', s);
|
|
246
255
|
}
|
|
@@ -305,7 +314,6 @@ export class SessionManager {
|
|
|
305
314
|
rows: number,
|
|
306
315
|
resume: boolean | string = false,
|
|
307
316
|
namedBy: 'user' | 'auto' = 'auto',
|
|
308
|
-
group?: string,
|
|
309
317
|
): Session {
|
|
310
318
|
const id = `s${++counter}-${Date.now().toString(36)}`;
|
|
311
319
|
const plan = this.adapter.planSpawn({ prompt, resume });
|
|
@@ -334,7 +342,9 @@ export class SessionManager {
|
|
|
334
342
|
unread: false,
|
|
335
343
|
lastMsg: initialMsg,
|
|
336
344
|
...(typeof resume === 'string' ? { claudeId: resume } : {}),
|
|
337
|
-
|
|
345
|
+
pinned: false,
|
|
346
|
+
lastAttachedAt: Date.now(),
|
|
347
|
+
repoRoot: resolveRepoRoot(cwd),
|
|
338
348
|
namedBy,
|
|
339
349
|
createdAt: Date.now(),
|
|
340
350
|
};
|
|
@@ -376,7 +386,9 @@ export class SessionManager {
|
|
|
376
386
|
lastMsg: s.lastMsg,
|
|
377
387
|
...(s.lastDetail === undefined ? {} : { lastDetail: s.lastDetail }),
|
|
378
388
|
...(s.claudeId === undefined ? {} : { claudeId: s.claudeId }),
|
|
379
|
-
|
|
389
|
+
pinned: s.pinned,
|
|
390
|
+
lastAttachedAt: s.lastAttachedAt,
|
|
391
|
+
repoRoot: s.repoRoot,
|
|
380
392
|
namedBy: s.namedBy,
|
|
381
393
|
createdAt: s.createdAt,
|
|
382
394
|
kind: s.kind,
|
|
@@ -508,6 +520,9 @@ export class SessionManager {
|
|
|
508
520
|
}
|
|
509
521
|
|
|
510
522
|
s.unread = false;
|
|
523
|
+
s.lastAttachedAt = Date.now();
|
|
524
|
+
|
|
525
|
+
this.writeFleet();
|
|
511
526
|
|
|
512
527
|
// Attaching answers the attention request: a still-pending prompt
|
|
513
528
|
// re-flags it via the next notification.
|
|
@@ -597,7 +612,8 @@ export class SessionManager {
|
|
|
597
612
|
name: s.name,
|
|
598
613
|
cwd: s.cwd,
|
|
599
614
|
claudeId: s.claudeId,
|
|
600
|
-
...(s.
|
|
615
|
+
...(s.pinned ? { pinned: true } : {}),
|
|
616
|
+
lastAttachedAt: s.lastAttachedAt,
|
|
601
617
|
});
|
|
602
618
|
}
|
|
603
619
|
}
|
|
@@ -626,10 +642,17 @@ export function countSessionStates(
|
|
|
626
642
|
return c;
|
|
627
643
|
}
|
|
628
644
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
645
|
+
interface SortableSessionView {
|
|
646
|
+
readonly state: SessionState;
|
|
647
|
+
readonly pinned: boolean;
|
|
648
|
+
readonly lastAttachedAt: number;
|
|
649
|
+
readonly createdAt: number;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Overlay order: pinned sessions first in most-recently-attached order, then
|
|
653
|
+
// everyone else by urgency — who needs you, finished turns, busy, dead —
|
|
654
|
+
// with most-recently-attached breaking ties inside each state.
|
|
655
|
+
export function sortSessionViews<T extends SortableSessionView>(list: readonly T[]): T[] {
|
|
633
656
|
const rank: Record<SessionState, number> = {
|
|
634
657
|
needs_you: 0,
|
|
635
658
|
done: 1,
|
|
@@ -637,24 +660,31 @@ export function sortSessionViews<
|
|
|
637
660
|
exited: 3,
|
|
638
661
|
};
|
|
639
662
|
|
|
640
|
-
return [...list].toSorted((a, b) =>
|
|
663
|
+
return [...list].toSorted((a, b) => {
|
|
664
|
+
if (a.pinned !== b.pinned) {
|
|
665
|
+
return a.pinned ? -1 : 1;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const recency = b.lastAttachedAt - a.lastAttachedAt || b.createdAt - a.createdAt;
|
|
669
|
+
|
|
670
|
+
return a.pinned ? recency : rank[a.state] - rank[b.state] || recency;
|
|
671
|
+
});
|
|
641
672
|
}
|
|
642
673
|
|
|
643
|
-
//
|
|
644
|
-
|
|
645
|
-
|
|
674
|
+
// Never a filesystem path, so a repository can't collide with it.
|
|
675
|
+
export const PINNED_GROUP_KEY = ' pinned';
|
|
676
|
+
|
|
677
|
+
// Overlay display order for the grouped view: the flat sort with each
|
|
678
|
+
// repository's sessions pulled together at the position of its best-ranked
|
|
679
|
+
// member, so the renderer's adjacency-based headers appear once per group.
|
|
680
|
+
// Pinned sessions form their own leading group.
|
|
646
681
|
export function sortGroupedSessionViews<
|
|
647
|
-
T extends {
|
|
648
|
-
readonly state: SessionState;
|
|
649
|
-
readonly createdAt: number;
|
|
650
|
-
readonly group?: string;
|
|
651
|
-
readonly cwd: string;
|
|
652
|
-
},
|
|
682
|
+
T extends SortableSessionView & { readonly repoRoot: string },
|
|
653
683
|
>(list: readonly T[]): T[] {
|
|
654
684
|
const buckets = new Map<string, T[]>();
|
|
655
685
|
|
|
656
686
|
for (const s of sortSessionViews(list)) {
|
|
657
|
-
const key = s.
|
|
687
|
+
const key = s.pinned ? PINNED_GROUP_KEY : s.repoRoot;
|
|
658
688
|
const bucket = buckets.get(key);
|
|
659
689
|
|
|
660
690
|
if (bucket === undefined) {
|
package/src/state-store.ts
CHANGED
|
@@ -24,7 +24,8 @@ export class StateStore {
|
|
|
24
24
|
claude_id TEXT PRIMARY KEY,
|
|
25
25
|
name TEXT NOT NULL,
|
|
26
26
|
cwd TEXT NOT NULL,
|
|
27
|
-
|
|
27
|
+
pinned INTEGER NOT NULL DEFAULT 0,
|
|
28
|
+
last_attached INTEGER
|
|
28
29
|
);
|
|
29
30
|
CREATE TABLE IF NOT EXISTS events (
|
|
30
31
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -40,15 +41,21 @@ export class StateStore {
|
|
|
40
41
|
);
|
|
41
42
|
`);
|
|
42
43
|
|
|
43
|
-
// Stores created before
|
|
44
|
-
// above only applies to fresh databases.
|
|
45
|
-
const fleetColumns =
|
|
46
|
-
.
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
// Stores created before pinning and attach recency existed lack the
|
|
45
|
+
// columns; the create above only applies to fresh databases.
|
|
46
|
+
const fleetColumns = new Set(
|
|
47
|
+
this.db
|
|
48
|
+
.query<{ name: string }, []>('PRAGMA table_info(fleet)')
|
|
49
|
+
.all()
|
|
50
|
+
.map((c) => c.name),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
if (!fleetColumns.has('pinned')) {
|
|
54
|
+
this.db.run('ALTER TABLE fleet ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0');
|
|
55
|
+
}
|
|
49
56
|
|
|
50
|
-
if (!fleetColumns.
|
|
51
|
-
this.db.run('ALTER TABLE fleet ADD COLUMN
|
|
57
|
+
if (!fleetColumns.has('last_attached')) {
|
|
58
|
+
this.db.run('ALTER TABLE fleet ADD COLUMN last_attached INTEGER');
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
if (legacyFleetPath !== undefined) {
|
|
@@ -58,20 +65,28 @@ export class StateStore {
|
|
|
58
65
|
|
|
59
66
|
loadFleet(): FleetEntry[] {
|
|
60
67
|
const rows = this.db
|
|
61
|
-
.query<
|
|
62
|
-
|
|
63
|
-
|
|
68
|
+
.query<
|
|
69
|
+
{
|
|
70
|
+
claude_id: string;
|
|
71
|
+
name: string;
|
|
72
|
+
cwd: string;
|
|
73
|
+
pinned: number;
|
|
74
|
+
last_attached: number | null;
|
|
75
|
+
},
|
|
76
|
+
[]
|
|
77
|
+
>('SELECT claude_id, name, cwd, pinned, last_attached FROM fleet')
|
|
64
78
|
.all();
|
|
65
79
|
|
|
66
80
|
const entries: FleetEntry[] = [];
|
|
67
81
|
|
|
68
82
|
for (const row of rows) {
|
|
69
|
-
|
|
70
|
-
row.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
83
|
+
entries.push({
|
|
84
|
+
claudeId: row.claude_id,
|
|
85
|
+
name: row.name,
|
|
86
|
+
cwd: row.cwd,
|
|
87
|
+
...(row.pinned === 0 ? {} : { pinned: true }),
|
|
88
|
+
...(row.last_attached === null ? {} : { lastAttachedAt: row.last_attached }),
|
|
89
|
+
});
|
|
75
90
|
}
|
|
76
91
|
|
|
77
92
|
return entries;
|
|
@@ -94,11 +109,13 @@ export class StateStore {
|
|
|
94
109
|
this.db.run('DELETE FROM fleet');
|
|
95
110
|
|
|
96
111
|
const insert = this.db.query(
|
|
97
|
-
'INSERT OR REPLACE INTO fleet (claude_id, name, cwd,
|
|
112
|
+
'INSERT OR REPLACE INTO fleet (claude_id, name, cwd, pinned, last_attached) VALUES (?1, ?2, ?3, ?4, ?5)',
|
|
98
113
|
);
|
|
99
114
|
|
|
100
115
|
for (const entry of all) {
|
|
101
|
-
|
|
116
|
+
const pinned = entry.pinned === true ? 1 : 0;
|
|
117
|
+
|
|
118
|
+
insert.run(entry.claudeId, entry.name, entry.cwd, pinned, entry.lastAttachedAt ?? null);
|
|
102
119
|
}
|
|
103
120
|
});
|
|
104
121
|
|
package/src/ui.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { formatDir } from './dirs';
|
|
2
2
|
import { RESET_INPUT_MODES } from './reset-input-modes';
|
|
3
|
+
import { PINNED_GROUP_KEY } from './sessions';
|
|
3
4
|
import type { SessionState } from './sessions';
|
|
4
5
|
|
|
5
6
|
// The slice of a session the drawing layer needs; satisfied by both the
|
|
@@ -160,7 +161,8 @@ export interface OverlaySessionView extends SessionView {
|
|
|
160
161
|
readonly alive: boolean;
|
|
161
162
|
readonly kind: 'pty' | 'jsonl';
|
|
162
163
|
readonly resumable: boolean;
|
|
163
|
-
readonly
|
|
164
|
+
readonly pinned: boolean;
|
|
165
|
+
readonly repoRoot: string;
|
|
164
166
|
}
|
|
165
167
|
|
|
166
168
|
export interface OverlayView {
|
|
@@ -169,6 +171,7 @@ export interface OverlayView {
|
|
|
169
171
|
confirmKill: boolean;
|
|
170
172
|
filter: string | null;
|
|
171
173
|
stale: boolean;
|
|
174
|
+
grouped: boolean;
|
|
172
175
|
}
|
|
173
176
|
|
|
174
177
|
export function drawOverlay(view: OverlayView) {
|
|
@@ -181,32 +184,32 @@ export function drawOverlay(view: OverlayView) {
|
|
|
181
184
|
rowsList.push(dimRow(width, empty));
|
|
182
185
|
}
|
|
183
186
|
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
// directory. The rows keep the sorted order
|
|
187
|
-
const grouped = new Set(view.sessions.map((s) => s.group ?? s.cwd)).size > 1;
|
|
187
|
+
// The grouped view clusters sessions under dim repository headers, with
|
|
188
|
+
// pinned sessions leading in their own cluster; the flat view shows a
|
|
189
|
+
// directory column instead. The rows keep the sorted order either way.
|
|
188
190
|
let lastKey: string | null = null;
|
|
189
191
|
|
|
190
192
|
for (const [i, s] of view.sessions.entries()) {
|
|
191
|
-
const key = s.
|
|
193
|
+
const key = s.pinned ? PINNED_GROUP_KEY : s.repoRoot;
|
|
192
194
|
|
|
193
|
-
if (grouped && key !== lastKey) {
|
|
195
|
+
if (view.grouped && key !== lastKey) {
|
|
194
196
|
lastKey = key;
|
|
195
197
|
|
|
196
|
-
rowsList.push(dimRow(width, `▸ ${s.
|
|
198
|
+
rowsList.push(dimRow(width, `▸ ${s.pinned ? 'pinned' : formatDir(s.repoRoot)}`));
|
|
197
199
|
}
|
|
198
200
|
|
|
199
201
|
const sel = i === view.selected;
|
|
200
202
|
const name = truncate(s.name, 16).padEnd(16);
|
|
201
203
|
const state = STATE_LABEL[s.state].padEnd(9);
|
|
202
|
-
const dir = grouped ? '' : ` ${truncate(formatDir(s.cwd), 18).padEnd(18)}`;
|
|
203
|
-
const msgWidth = Math.max(4, width - 4 -
|
|
204
|
+
const dir = view.grouped ? '' : ` ${truncate(formatDir(s.cwd), 18).padEnd(18)}`;
|
|
205
|
+
const msgWidth = Math.max(4, width - 4 - 3 - 17 - 10 - (view.grouped ? 0 : 19));
|
|
204
206
|
const msg = truncate(s.lastMsg, msgWidth).padEnd(msgWidth);
|
|
205
207
|
const unread = s.unread ? `${ESC}[1;33m!${ESC}[0m` : ' ';
|
|
208
|
+
const pin = s.pinned ? `${ESC}[93m⋆${ESC}[0m` : ' ';
|
|
206
209
|
const body = `${name} ${state}${dir} ${msg}`;
|
|
207
210
|
const styledBody = sel ? `${ESC}[7m${body}${ESC}[0m` : body;
|
|
208
211
|
|
|
209
|
-
rowsList.push(boxRow(width, `${GLYPH[s.state]}${unread}${styledBody}`,
|
|
212
|
+
rowsList.push(boxRow(width, `${GLYPH[s.state]}${unread}${pin}${styledBody}`, 3 + body.length));
|
|
210
213
|
}
|
|
211
214
|
|
|
212
215
|
rowsList.push(boxDivider(width));
|
|
@@ -239,7 +242,7 @@ export function drawOverlay(view: OverlayView) {
|
|
|
239
242
|
drawBox(rowsList);
|
|
240
243
|
}
|
|
241
244
|
|
|
242
|
-
const GLOBAL_HINT = 'n new · ? keys';
|
|
245
|
+
const GLOBAL_HINT = 'g groups · n new · ? keys';
|
|
243
246
|
|
|
244
247
|
// Only the actions valid for the selected row appear; the full reference
|
|
245
248
|
// lives behind ?.
|
|
@@ -268,6 +271,10 @@ function buildOverlayHint(s: OverlaySessionView | undefined): string {
|
|
|
268
271
|
actions.push('K forget');
|
|
269
272
|
}
|
|
270
273
|
|
|
274
|
+
const pinAction = s.pinned ? 'p unpin' : 'p pin';
|
|
275
|
+
|
|
276
|
+
actions.push(pinAction);
|
|
277
|
+
|
|
271
278
|
return `${actions.join(' · ')} ▏ ${GLOBAL_HINT}`;
|
|
272
279
|
}
|
|
273
280
|
|
|
@@ -283,6 +290,8 @@ export function drawHelp() {
|
|
|
283
290
|
'y yank the resume command to the clipboard',
|
|
284
291
|
'Y yank the resume command, then kill here',
|
|
285
292
|
'K kill (K again on a dead session forgets it)',
|
|
293
|
+
'p pin or unpin — pinned sessions stay on top',
|
|
294
|
+
'g toggle grouping by repository',
|
|
286
295
|
'n new session',
|
|
287
296
|
'r adopt an external session',
|
|
288
297
|
'/ filter · ↑↓/jk move · q quit',
|