@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 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
- | `/` | overlay | fzf-style filter: type to narrow by name/dir, `⏎` attach top match, `esc` clear |
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 sessions
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. The daemon continuously writes the live fleet (name, cwd, Claude session id) to
110
- its SQLite store. If the daemon itself dies — crash, SIGKILL, reboot — the child claude processes
111
- die with it, but every session's transcript is already on disk. Start atc and press `R`: the whole
112
- fleet respawns via `claude --resume`. Only deliberate kills (`K`, `Y` eject) remove entries from the
113
- fleet, so it stays restorable.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Terminal control tower for Claude Code sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -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 left running from an older build is restarted,
11
- * so a fresh client never talks to stale daemon code — the fleet stays
12
- * restorable afterwards through the usual cold-boot recovery. The expected
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 restart daemons that are
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<DaemonClient> {
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
- const daemonBuild = hello['daemon'];
30
+ try {
31
+ const hello = await client.sendHello(build);
25
32
 
26
- if (daemonBuild === build || attempt > 0) {
27
- return client;
28
- }
33
+ return { client, stale: hello['daemon'] !== build };
34
+ } catch (error) {
35
+ client.stop();
29
36
 
30
- client.stop();
37
+ if (attempt > 0 || !isProtocolMismatch(error)) {
38
+ throw error;
39
+ }
31
40
 
32
- await stopStaleDaemon();
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
@@ -61,6 +61,7 @@ const main = defineCommand({
61
61
  legacyFleetPath: config.legacyFleetFile,
62
62
  pidPath: config.daemonPidFile,
63
63
  ...(Number.isFinite(queueBytes) && queueBytes > 0 ? { queueBytes } : {}),
64
+ onQuit: () => process.exit(0),
64
65
  });
65
66
 
66
67
  process.on('SIGTERM', () => {
@@ -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(entry.cwd, entry.name, '', cols, rows, entry.claudeId);
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
- return {
598
- stop() {
599
- for (const timer of resizeTimers.values()) {
600
- clearTimeout(timer);
601
- }
622
+ stopDaemon = () => {
623
+ for (const timer of resizeTimers.values()) {
624
+ clearTimeout(timer);
625
+ }
602
626
 
603
- for (const timer of detectTimers.values()) {
604
- clearTimeout(timer);
605
- }
627
+ for (const timer of detectTimers.values()) {
628
+ clearTimeout(timer);
629
+ }
606
630
 
607
- server.stop(true);
608
- reporter.stop(true);
609
- mgr.killAll();
631
+ server.stop(true);
632
+ reporter.stop(true);
633
+ mgr.killAll();
610
634
 
611
- for (const model of screens.values()) {
612
- model.stop();
613
- }
635
+ for (const model of screens.values()) {
636
+ model.stop();
637
+ }
614
638
 
615
- store.stop();
639
+ store.stop();
616
640
 
617
- if (opts.pidPath !== undefined) {
618
- try {
619
- unlinkSync(opts.pidPath);
620
- } catch {}
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 f = filter.toLowerCase();
42
-
43
- const scored = items
44
- .map((item) => {
45
- const base = basename(item).toLowerCase();
46
- let score = -1;
47
-
48
- if (base.startsWith(f)) {
49
- score = 0;
50
- } else if (base.includes(f)) {
51
- score = 1;
52
- } else if (item.toLowerCase().includes(f)) {
53
- score = 2;
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.toLowerCase();
204
+ const f = overlayFilter;
201
205
 
202
- return sorted.filter((s) => `${s.name} ${formatDir(s.cwd)}`.toLowerCase().includes(f));
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 client = await bootDaemonClient();
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-dir';
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-name';
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 client = await bootDaemonClient();
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({ name: s.name, cwd: s.cwd, claudeId: s.claudeId });
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
 
@@ -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
- return rows.map((row) => ({ claudeId: row.claude_id, name: row.name, cwd: row.cwd }));
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} ${dir} ${msg}`;
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',