@zgeoff/atc 0.1.0 → 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 CHANGED
@@ -7,6 +7,11 @@
7
7
  mouse.
8
8
  </p>
9
9
 
10
+ <p>
11
+ <a href="https://www.npmjs.com/package/@zgeoff/atc"><img src="https://img.shields.io/npm/v/%40zgeoff%2Fatc" alt="npm version"></a>
12
+ <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
13
+ </p>
14
+
10
15
  <p>
11
16
  <a href="./docs/README.md">Documentation</a> •
12
17
  <a href="./docs/architecture/overview.md">Architecture</a> •
@@ -14,12 +19,16 @@
14
19
  </p>
15
20
  </div>
16
21
 
17
- ## Run
22
+ ## Install
18
23
 
19
24
  ```sh
20
- bun src/cli.ts # or `atc` if bin/atc is on your PATH
25
+ bun add -g @zgeoff/atc
26
+ atc
21
27
  ```
22
28
 
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.
31
+
23
32
  The first invocation auto-spawns the daemon (`atc daemon` runs it in the foreground for systemd or
24
33
  debugging); the TUI is a thin client, so quitting or crashing it leaves every session running. Runs
25
34
  fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches atc).
@@ -28,13 +37,14 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
28
37
 
29
38
  | Key | Where | Action |
30
39
  | --------------- | -------------- | ---------------------------------------------------------------------------------------------- |
31
- | `Ctrl-Space` | anywhere | toggle session overlay |
32
- | `n` | home/overlay | spawn: pick dir (zoxide + history, fuzzy) → name → optional first prompt |
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 group → optional first prompt |
33
42
  | `r` | home/overlay | adopt: pick dir → name → `claude --resume` (Claude's session picker opens in the new PTY) |
34
43
  | `R` | home | restore last fleet after a daemon death — respawns every session via `claude --resume <id>` |
35
44
  | `j`/`k`/`↑`/`↓` | overlay/picker | move |
36
45
  | `Enter` | overlay | attach (auto-acks) |
37
- | `/` | 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 |
38
48
  | `a` | overlay | ack notification without attaching |
39
49
  | `H` | overlay | eject to headless: the terminal dies, a headless Agent SDK run resumes the same session |
40
50
  | `P` | overlay | revive: a fresh terminal resumes a headless or killed session in place |
@@ -44,6 +54,11 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
44
54
  | `?` | overlay | full key reference — the hint row only shows actions valid for the selected session |
45
55
  | `q` | home/overlay | quit the client — sessions keep running in the daemon |
46
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
+
47
62
  Revive (`P`) and headless eject (`H`) resume the session from its saved transcript, so both need one
48
63
  to exist: a session killed before its first exchange has nothing on disk yet, and the overlay says
49
64
  so in its message column instead of resuming.
@@ -63,17 +78,36 @@ status bar turns red and names the most urgent session.
63
78
 
64
79
  ## Config
65
80
 
66
- `~/.config/atc/config.json`:
81
+ `~/.config/atc/config.json` (created with defaults on first run):
67
82
 
68
83
  ```json
69
- { "claudeBin": "claude", "claudeArgs": [] }
84
+ {
85
+ "claudeBin": "claude",
86
+ "claudeArgs": [],
87
+ "leader": "ctrl-space"
88
+ }
89
+ ```
90
+
91
+ | Field | Default | Meaning |
92
+ | ------------ | -------------- | ------------------------------------------------------------------------------------- |
93
+ | `claudeBin` | `"claude"` | The binary spawned for every session. |
94
+ | `claudeArgs` | `[]` | Prepended to every spawn, e.g. `["--model", "opus"]`. |
95
+ | `leader` | `"ctrl-space"` | The overlay toggle: `ctrl-` plus a letter or one of `\` `]` `^` `_`, e.g. `"ctrl-]"`. |
96
+
97
+ Pick a different leader when `Ctrl-Space` is taken on your machine — Raycast on macOS claims it, and
98
+ `ctrl-]` is a solid replacement that no common terminal, multiplexer, or OS shortcut wants. An
99
+ unknown or reserved value falls back to the default.
100
+
101
+ `atc mcp` exposes the fleet as MCP tools (list, spawn, drive, organise) to any MCP client, wrangled
102
+ sessions included:
103
+
104
+ ```sh
105
+ claude mcp add --scope user atc -- atc mcp
70
106
  ```
71
107
 
72
- `claudeArgs` is prepended to every spawn (e.g. `["--model", "opus"]`). `atc mcp` exposes the fleet
73
- as MCP tools (list, spawn, drive) to any MCP client, wrangled sessions included. Daemon state — the
74
- restorable fleet, spawn-dir history, and the hook-event trail — lives in `~/.local/state/atc/atc.db`
75
- (SQLite), next to `status.json` (read by the injected statusline); the daemon's pid file sits in
76
- `$XDG_RUNTIME_DIR/atc-daemon.pid`, beside its sockets.
108
+ Daemon state — the restorable fleet, spawn-dir history, and the hook-event trail — lives in
109
+ `~/.local/state/atc/atc.db` (SQLite), next to `status.json` (read by the injected statusline); the
110
+ daemon's pid file sits in `$XDG_RUNTIME_DIR/atc-daemon.pid`, beside its sockets.
77
111
 
78
112
  ## Crash safety
79
113
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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",
package/src/config.ts CHANGED
@@ -6,11 +6,18 @@ import { isRecord } from './report';
6
6
  export interface Config {
7
7
  claudeBin: string;
8
8
  claudeArgs: string[];
9
+ leader: LeaderKey;
10
+ }
11
+
12
+ interface LeaderKey {
13
+ readonly code: number;
14
+ readonly label: string;
9
15
  }
10
16
 
11
17
  const DEFAULTS: Config = {
12
18
  claudeBin: 'claude',
13
19
  claudeArgs: [],
20
+ leader: { code: 0, label: '^Space' },
14
21
  };
15
22
 
16
23
  const configDir = join(homedir(), '.config', 'atc');
@@ -50,8 +57,58 @@ export function loadConfig(): Config {
50
57
  ? parsed['claudeArgs'].filter((a): a is string => typeof a === 'string')
51
58
  : DEFAULTS.claudeArgs;
52
59
 
53
- return { claudeBin, claudeArgs };
60
+ const leader =
61
+ (typeof parsed['leader'] === 'string' ? decodeLeader(parsed['leader']) : null) ??
62
+ DEFAULTS.leader;
63
+
64
+ return { claudeBin, claudeArgs, leader };
54
65
  } catch {
55
66
  return { ...DEFAULTS };
56
67
  }
57
68
  }
69
+
70
+ // Control bytes the terminal needs for its own input: enter, tab, and esc
71
+ // as leaders would swallow ordinary typing.
72
+ const RESERVED_CODES = new Set([0x09, 0x0d, 0x1b]);
73
+
74
+ /**
75
+ * Decodes a leader name like "ctrl-space", "ctrl-]", or "ctrl-a" into its
76
+ * control byte and status-bar label. Unknown or reserved keys decode to
77
+ * null, so a bad config falls back to the default instead of breaking the
78
+ * client.
79
+ */
80
+ function decodeLeader(name: string): LeaderKey | null {
81
+ const m = /^ctrl-(?<key>.+)$/i.exec(name.trim().toLowerCase());
82
+ const key = m?.groups?.['key'];
83
+
84
+ if (key === undefined) {
85
+ return null;
86
+ }
87
+
88
+ if (key === 'space') {
89
+ return { code: 0, label: '^Space' };
90
+ }
91
+
92
+ let code: number | null = null;
93
+ const cp = key.codePointAt(0);
94
+
95
+ if (/^[a-z]$/.test(key) && cp !== undefined) {
96
+ code = cp - 96;
97
+ } else if (key === '[') {
98
+ code = 0x1b;
99
+ } else if (key === '\\') {
100
+ code = 0x1c;
101
+ } else if (key === ']') {
102
+ code = 0x1d;
103
+ } else if (key === '^') {
104
+ code = 0x1e;
105
+ } else if (key === '_') {
106
+ code = 0x1f;
107
+ }
108
+
109
+ if (code === null || RESERVED_CODES.has(code)) {
110
+ return null;
111
+ }
112
+
113
+ return { code, label: `^${key.toUpperCase()}` };
114
+ }
@@ -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(entry.cwd, entry.name, '', cols, rows, entry.claudeId);
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 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,6 +1,7 @@
1
1
  import { basename } from 'node:path';
2
2
  import { bootDaemonClient } from './boot-daemon';
3
- import { collectDirs, formatDir, pickMatches } from './dirs';
3
+ import { loadConfig } from './config';
4
+ import { collectDirs, findFuzzyScore, formatDir, pickMatches } from './dirs';
4
5
  import type { EventMsg } from './protocol';
5
6
  import { isRecord } from './report';
6
7
  import { countSessionStates, sortSessionViews } from './sessions';
@@ -14,6 +15,7 @@ type Mode =
14
15
  | 'help'
15
16
  | 'picker-dir'
16
17
  | 'picker-name'
18
+ | 'picker-group'
17
19
  | 'picker-prompt'
18
20
  | 'picker-eject';
19
21
 
@@ -21,6 +23,7 @@ interface MirrorSession {
21
23
  id: string;
22
24
  name: string;
23
25
  cwd: string;
26
+ group?: string;
24
27
  state: SessionState;
25
28
  unread: boolean;
26
29
  lastMsg: string;
@@ -45,6 +48,7 @@ let pickerInput = '';
45
48
  let pickerSelected = 0;
46
49
  let spawnDir = '';
47
50
  let spawnName = '';
51
+ let spawnGroup = '';
48
52
  let spawnResume = false;
49
53
  const stdout = process.stdout;
50
54
 
@@ -76,6 +80,7 @@ function scheduleStatus() {
76
80
  counts: countSessionStates(fleet),
77
81
  focusedName: focused === null ? null : focused.name,
78
82
  urgentName: urgent === undefined ? null : urgent.name,
83
+ leaderLabel: leader.label,
79
84
  });
80
85
  }
81
86
  }, 50);
@@ -127,7 +132,7 @@ function toBase() {
127
132
  } else {
128
133
  mode = 'home';
129
134
 
130
- drawHome(fleetCount);
135
+ drawHome(fleetCount, leader.label);
131
136
  }
132
137
 
133
138
  scheduleStatus();
@@ -180,7 +185,7 @@ function openHelp() {
180
185
  function applyHelpKey(buf: Buffer) {
181
186
  const ch = buf.toString();
182
187
 
183
- if (buf[0] === KEY.esc || ch === '?' || ch === 'q' || buf[0] === KEY.ctrlSpace) {
188
+ if (buf[0] === KEY.esc || ch === '?' || ch === 'q' || buf[0] === leader.code) {
184
189
  openOverlay();
185
190
  }
186
191
  }
@@ -195,9 +200,11 @@ function pickOverlaySessions(): MirrorSession[] {
195
200
  return sorted;
196
201
  }
197
202
 
198
- const f = overlayFilter.toLowerCase();
203
+ const f = overlayFilter;
199
204
 
200
- return sorted.filter((s) => `${s.name} ${formatDir(s.cwd)}`.toLowerCase().includes(f));
205
+ return sorted.filter(
206
+ (s) => findFuzzyScore(`${s.name} ${s.group ?? ''} ${formatDir(s.cwd)}`, f) !== null,
207
+ );
201
208
  }
202
209
 
203
210
  function renderOverlay() {
@@ -253,6 +260,18 @@ function renderPicker() {
253
260
  placeholder: basename(spawnDir),
254
261
  hint: `session name for ${formatDir(spawnDir)} · ⏎ accept · esc back`,
255
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
+ });
256
275
  } else if (mode === 'picker-eject') {
257
276
  drawPicker({
258
277
  title: 'eject: headless instruction',
@@ -278,6 +297,7 @@ function renderPicker() {
278
297
 
279
298
  async function openDirPicker(resume = false) {
280
299
  spawnResume = resume;
300
+ spawnGroup = '';
281
301
 
282
302
  let recent: string[] = [];
283
303
 
@@ -325,6 +345,7 @@ async function spawnFromPicker(prompt: string) {
325
345
  cols: cols(),
326
346
  rows: ptyRows(),
327
347
  ...(spawnResume ? { resume: true } : {}),
348
+ ...(spawnGroup === '' ? {} : { group: spawnGroup }),
328
349
  });
329
350
 
330
351
  const spawned = toMirrorSession(ok['session']);
@@ -380,6 +401,7 @@ function toMirrorSession(value: unknown): MirrorSession | null {
380
401
  id: value['id'],
381
402
  name: value['name'],
382
403
  cwd: value['cwd'],
404
+ ...(typeof value['group'] === 'string' ? { group: value['group'] } : {}),
383
405
  state,
384
406
  unread: value['unread'],
385
407
  lastMsg: value['lastMsg'],
@@ -461,6 +483,14 @@ function applyDaemonEvent(e: EventMsg) {
461
483
  s.kind = e['kind'];
462
484
  }
463
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
+
464
494
  if (typeof e['unread'] === 'boolean') {
465
495
  s.unread = e['unread'];
466
496
  }
@@ -524,8 +554,10 @@ function copyToClipboard(text: string) {
524
554
 
525
555
  // ---- input ----
526
556
 
557
+ const leader = loadConfig().leader;
558
+
527
559
  const KEY = {
528
- ctrlSpace: 0x00,
560
+ tab: 0x09,
529
561
  ctrlC: 0x03,
530
562
  ctrlU: 0x15,
531
563
  esc: 0x1b,
@@ -578,7 +610,7 @@ function applyOverlayFilterKey(buf: Buffer): boolean {
578
610
  return true;
579
611
  }
580
612
 
581
- if (buf[0] === KEY.enter || isUp(buf) || isDown(buf) || buf[0] === KEY.ctrlSpace) {
613
+ if (buf[0] === KEY.enter || isUp(buf) || isDown(buf) || buf[0] === leader.code) {
582
614
  return false;
583
615
  }
584
616
 
@@ -636,7 +668,7 @@ function applyOverlayKey(buf: Buffer) {
636
668
  return;
637
669
  }
638
670
 
639
- if (buf[0] === KEY.ctrlSpace || (buf[0] === KEY.esc && buf.length === 1)) {
671
+ if (buf[0] === leader.code || (buf[0] === KEY.esc && buf.length === 1)) {
640
672
  toBase();
641
673
 
642
674
  return;
@@ -660,6 +692,16 @@ function applyOverlayKey(buf: Buffer) {
660
692
 
661
693
  const ch = buf.toString();
662
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
+
663
705
  if (buf[0] === KEY.enter && sel !== undefined && sel.alive) {
664
706
  void attach(sel.id);
665
707
 
@@ -778,7 +820,7 @@ function applyTextKey(buf: Buffer, onSubmit: () => void, onCancel: () => void) {
778
820
  return;
779
821
  }
780
822
 
781
- if (buf[0] === KEY.ctrlSpace) {
823
+ if (buf[0] === leader.code) {
782
824
  toBase();
783
825
 
784
826
  return;
@@ -877,7 +919,7 @@ const stdinDecoder = new TextDecoder('utf-8');
877
919
  process.stdin.on('data', (buf: Buffer) => {
878
920
  switch (mode) {
879
921
  case 'attached': {
880
- if (buf[0] === KEY.ctrlSpace && buf.length === 1) {
922
+ if (buf[0] === leader.code && buf.length === 1) {
881
923
  openOverlay();
882
924
 
883
925
  return;
@@ -893,7 +935,7 @@ process.stdin.on('data', (buf: Buffer) => {
893
935
  return;
894
936
  }
895
937
  case 'home': {
896
- if (buf[0] === KEY.ctrlSpace) {
938
+ if (buf[0] === leader.code) {
897
939
  openOverlay();
898
940
 
899
941
  return;
@@ -972,6 +1014,30 @@ process.stdin.on('data', (buf: Buffer) => {
972
1014
  () => {
973
1015
  spawnName = pickerInput.trim();
974
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 = '';
975
1041
 
976
1042
  // Adopt skips the prompt step: claude --resume opens its own
977
1043
  // session picker inside the new PTY.
@@ -989,7 +1055,7 @@ process.stdin.on('data', (buf: Buffer) => {
989
1055
  },
990
1056
  () => {
991
1057
  pickerInput = '';
992
- mode = 'picker-dir';
1058
+ mode = 'picker-name';
993
1059
 
994
1060
  stdout.write(ansi.clear);
995
1061
 
@@ -1007,7 +1073,7 @@ process.stdin.on('data', (buf: Buffer) => {
1007
1073
  },
1008
1074
  () => {
1009
1075
  pickerInput = '';
1010
- mode = 'picker-name';
1076
+ mode = 'picker-group';
1011
1077
 
1012
1078
  stdout.write(ansi.clear);
1013
1079
 
@@ -1037,7 +1103,7 @@ stdout.on('resize', () => {
1037
1103
  }
1038
1104
 
1039
1105
  if (mode === 'home') {
1040
- drawHome(fleetCount);
1106
+ drawHome(fleetCount, leader.label);
1041
1107
  }
1042
1108
 
1043
1109
  if (mode === 'overlay') {
@@ -1072,5 +1138,5 @@ process.on('SIGHUP', () => quit());
1072
1138
  // ---- start ----
1073
1139
  stdout.write(ansi.altScreenOn);
1074
1140
 
1075
- drawHome(fleetCount);
1141
+ drawHome(fleetCount, leader.label);
1076
1142
  scheduleStatus();
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({ 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
@@ -66,6 +66,7 @@ export interface StatusView {
66
66
  readonly counts: Readonly<Record<SessionState, number>>;
67
67
  readonly focusedName: string | null;
68
68
  readonly urgentName: string | null;
69
+ readonly leaderLabel: string;
69
70
  }
70
71
 
71
72
  export function drawStatusBar(view: StatusView) {
@@ -93,7 +94,7 @@ export function drawStatusBar(view: StatusView) {
93
94
  }
94
95
 
95
96
  const joined = parts.join(' ▏');
96
- const right = ` ${joined === '' ? 'idle' : joined} ▏^Space `;
97
+ const right = ` ${joined === '' ? 'idle' : joined} ▏${view.leaderLabel} `;
97
98
  const pad = Math.max(1, width - left.length - right.length);
98
99
  const bg = c.needs_you > 0 ? `${ESC}[1;97;41m` : `${ESC}[30;47m`;
99
100
  const text = truncate(left + ' '.repeat(pad) + right, width).padEnd(width);
@@ -152,6 +153,7 @@ export interface OverlaySessionView extends SessionView {
152
153
  readonly alive: boolean;
153
154
  readonly kind: 'pty' | 'jsonl';
154
155
  readonly resumable: boolean;
156
+ readonly group?: string;
155
157
  }
156
158
 
157
159
  export interface OverlayView {
@@ -171,15 +173,29 @@ export function drawOverlay(view: OverlayView) {
171
173
  rowsList.push(dimRow(width, empty));
172
174
  }
173
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
+
174
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
+
175
191
  const sel = i === view.selected;
176
192
  const name = truncate(s.name, 16).padEnd(16);
177
193
  const state = STATE_LABEL[s.state].padEnd(9);
178
- const dir = truncate(formatDir(s.cwd), 18).padEnd(18);
179
- 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));
180
196
  const msg = truncate(s.lastMsg, msgWidth).padEnd(msgWidth);
181
197
  const unread = s.unread ? `${ESC}[1;33m!${ESC}[0m` : ' ';
182
- const body = `${name} ${state} ${dir} ${msg}`;
198
+ const body = `${name} ${state}${dir} ${msg}`;
183
199
  const styledBody = sel ? `${ESC}[7m${body}${ESC}[0m` : body;
184
200
 
185
201
  rowsList.push(boxRow(width, `${GLYPH[s.state]}${unread}${styledBody}`, 2 + body.length));
@@ -248,6 +264,7 @@ export function drawHelp() {
248
264
 
249
265
  const lines = [
250
266
  '⏎ attach the selected session',
267
+ '⇥ attach the most urgent needs-you session',
251
268
  'a ack its notification without attaching',
252
269
  'H eject to a headless run',
253
270
  'P revive a dead or headless session',
@@ -317,7 +334,7 @@ export function drawPicker(view: PickerView) {
317
334
  drawBox(rowsList);
318
335
  }
319
336
 
320
- export function drawHome(fleetCount = 0) {
337
+ export function drawHome(fleetCount = 0, leaderLabel = '^Space') {
321
338
  out(ansi.clear + ansi.hideCursor);
322
339
 
323
340
  const msgs = [
@@ -326,7 +343,7 @@ export function drawHome(fleetCount = 0) {
326
343
  'n spawn a session',
327
344
  'r adopt an existing session (claude --resume)',
328
345
  ...(fleetCount > 0 ? [`R restore last fleet (${fleetCount} sessions)`] : []),
329
- '^Space session list',
346
+ `${leaderLabel.padEnd(7)} session list`,
330
347
  'q quit',
331
348
  ];
332
349