@zgeoff/atc 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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 |
@@ -53,6 +54,11 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
53
54
  | `?` | overlay | full key reference — the hint row only shows actions valid for the selected session |
54
55
  | `q` | home/overlay | quit the client — sessions keep running in the daemon |
55
56
 
57
+ The overlay clusters sessions under dim headers when more than one group exists: a session's
58
+ explicit group wins, and sessions without one fall back to the directory they were spawned from.
59
+ Groups also come from outside — the `atc_session_update` MCP tool renames and regroups sessions, so
60
+ an agent can organise the fleet for you.
61
+
56
62
  Revive (`P`) and headless eject (`H`) resume the session from its saved transcript, so both need one
57
63
  to exist: a session killed before its first exchange has nothing on disk yet, and the overlay says
58
64
  so in its message column instead of resuming.
@@ -92,8 +98,8 @@ Pick a different leader when `Ctrl-Space` is taken on your machine — Raycast o
92
98
  `ctrl-]` is a solid replacement that no common terminal, multiplexer, or OS shortcut wants. An
93
99
  unknown or reserved value falls back to the default.
94
100
 
95
- `atc mcp` exposes the fleet as MCP tools (list, spawn, drive) to any MCP client, wrangled sessions
96
- included:
101
+ `atc mcp` exposes the fleet as MCP tools (list, spawn, drive, organise) to any MCP client, wrangled
102
+ sessions included:
97
103
 
98
104
  ```sh
99
105
  claude mcp add --scope user atc -- atc mcp
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "0.1.1",
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",
@@ -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,7 +1,7 @@
1
1
  import { basename } from 'node:path';
2
2
  import { bootDaemonClient } from './boot-daemon';
3
3
  import { loadConfig } from './config';
4
- import { collectDirs, formatDir, pickMatches } from './dirs';
4
+ import { collectDirs, findFuzzyScore, formatDir, pickMatches } from './dirs';
5
5
  import type { EventMsg } from './protocol';
6
6
  import { isRecord } from './report';
7
7
  import { countSessionStates, sortSessionViews } from './sessions';
@@ -15,6 +15,7 @@ type Mode =
15
15
  | 'help'
16
16
  | 'picker-dir'
17
17
  | 'picker-name'
18
+ | 'picker-group'
18
19
  | 'picker-prompt'
19
20
  | 'picker-eject';
20
21
 
@@ -22,6 +23,7 @@ interface MirrorSession {
22
23
  id: string;
23
24
  name: string;
24
25
  cwd: string;
26
+ group?: string;
25
27
  state: SessionState;
26
28
  unread: boolean;
27
29
  lastMsg: string;
@@ -46,6 +48,7 @@ let pickerInput = '';
46
48
  let pickerSelected = 0;
47
49
  let spawnDir = '';
48
50
  let spawnName = '';
51
+ let spawnGroup = '';
49
52
  let spawnResume = false;
50
53
  const stdout = process.stdout;
51
54
 
@@ -197,9 +200,11 @@ function pickOverlaySessions(): MirrorSession[] {
197
200
  return sorted;
198
201
  }
199
202
 
200
- const f = overlayFilter.toLowerCase();
203
+ const f = overlayFilter;
201
204
 
202
- 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
+ );
203
208
  }
204
209
 
205
210
  function renderOverlay() {
@@ -255,6 +260,18 @@ function renderPicker() {
255
260
  placeholder: basename(spawnDir),
256
261
  hint: `session name for ${formatDir(spawnDir)} · ⏎ accept · esc back`,
257
262
  });
263
+ } else if (mode === 'picker-group') {
264
+ const groups = [...new Set(fleet.map((s) => s.group).filter((g) => g !== undefined))];
265
+ const known = groups.length === 0 ? '' : ` · existing: ${groups.slice(0, 4).join(', ')}`;
266
+
267
+ drawPicker({
268
+ title: `${verb}: group`,
269
+ items: [],
270
+ selected: -1,
271
+ input: pickerInput,
272
+ placeholder: 'optional — ⏎ to skip',
273
+ hint: `group for the overlay hierarchy${known} · ⏎ accept · esc back`,
274
+ });
258
275
  } else if (mode === 'picker-eject') {
259
276
  drawPicker({
260
277
  title: 'eject: headless instruction',
@@ -280,6 +297,7 @@ function renderPicker() {
280
297
 
281
298
  async function openDirPicker(resume = false) {
282
299
  spawnResume = resume;
300
+ spawnGroup = '';
283
301
 
284
302
  let recent: string[] = [];
285
303
 
@@ -327,6 +345,7 @@ async function spawnFromPicker(prompt: string) {
327
345
  cols: cols(),
328
346
  rows: ptyRows(),
329
347
  ...(spawnResume ? { resume: true } : {}),
348
+ ...(spawnGroup === '' ? {} : { group: spawnGroup }),
330
349
  });
331
350
 
332
351
  const spawned = toMirrorSession(ok['session']);
@@ -382,6 +401,7 @@ function toMirrorSession(value: unknown): MirrorSession | null {
382
401
  id: value['id'],
383
402
  name: value['name'],
384
403
  cwd: value['cwd'],
404
+ ...(typeof value['group'] === 'string' ? { group: value['group'] } : {}),
385
405
  state,
386
406
  unread: value['unread'],
387
407
  lastMsg: value['lastMsg'],
@@ -463,6 +483,14 @@ function applyDaemonEvent(e: EventMsg) {
463
483
  s.kind = e['kind'];
464
484
  }
465
485
 
486
+ if (typeof e['group'] === 'string') {
487
+ if (e['group'] === '') {
488
+ delete s.group;
489
+ } else {
490
+ s.group = e['group'];
491
+ }
492
+ }
493
+
466
494
  if (typeof e['unread'] === 'boolean') {
467
495
  s.unread = e['unread'];
468
496
  }
@@ -529,6 +557,7 @@ function copyToClipboard(text: string) {
529
557
  const leader = loadConfig().leader;
530
558
 
531
559
  const KEY = {
560
+ tab: 0x09,
532
561
  ctrlC: 0x03,
533
562
  ctrlU: 0x15,
534
563
  esc: 0x1b,
@@ -663,6 +692,16 @@ function applyOverlayKey(buf: Buffer) {
663
692
 
664
693
  const ch = buf.toString();
665
694
 
695
+ if (buf[0] === KEY.tab) {
696
+ const needy = sortSessionViews(fleet).find((s) => s.state === 'needs_you' && s.alive);
697
+
698
+ if (needy !== undefined) {
699
+ void attach(needy.id);
700
+ }
701
+
702
+ return;
703
+ }
704
+
666
705
  if (buf[0] === KEY.enter && sel !== undefined && sel.alive) {
667
706
  void attach(sel.id);
668
707
 
@@ -975,6 +1014,30 @@ process.stdin.on('data', (buf: Buffer) => {
975
1014
  () => {
976
1015
  spawnName = pickerInput.trim();
977
1016
  pickerInput = '';
1017
+ mode = 'picker-group';
1018
+
1019
+ stdout.write(ansi.clear);
1020
+
1021
+ renderPicker();
1022
+ },
1023
+ () => {
1024
+ pickerInput = '';
1025
+ mode = 'picker-dir';
1026
+
1027
+ stdout.write(ansi.clear);
1028
+
1029
+ renderPicker();
1030
+ },
1031
+ );
1032
+
1033
+ return;
1034
+ }
1035
+ case 'picker-group': {
1036
+ applyTextKey(
1037
+ buf,
1038
+ () => {
1039
+ spawnGroup = pickerInput.trim();
1040
+ pickerInput = '';
978
1041
 
979
1042
  // Adopt skips the prompt step: claude --resume opens its own
980
1043
  // session picker inside the new PTY.
@@ -992,7 +1055,7 @@ process.stdin.on('data', (buf: Buffer) => {
992
1055
  },
993
1056
  () => {
994
1057
  pickerInput = '';
995
- mode = 'picker-dir';
1058
+ mode = 'picker-name';
996
1059
 
997
1060
  stdout.write(ansi.clear);
998
1061
 
@@ -1010,7 +1073,7 @@ process.stdin.on('data', (buf: Buffer) => {
1010
1073
  },
1011
1074
  () => {
1012
1075
  pickerInput = '';
1013
- mode = 'picker-name';
1076
+ mode = 'picker-group';
1014
1077
 
1015
1078
  stdout.write(ansi.clear);
1016
1079
 
package/src/mcp-server.ts CHANGED
@@ -69,6 +69,21 @@ const TOOLS: readonly MCPTool[] = [
69
69
  additionalProperties: false,
70
70
  },
71
71
  },
72
+ {
73
+ name: 'atc_session_update',
74
+ description:
75
+ 'Rename and/or regroup a session. Renames stick against auto-summaries; an empty group clears the grouping. Use this to organise the fleet: name sessions after their task and group related work.',
76
+ inputSchema: {
77
+ type: 'object',
78
+ properties: {
79
+ session: { type: 'string', description: 'The atc session id' },
80
+ name: { type: 'string', description: 'New display name; omit to keep' },
81
+ group: { type: 'string', description: 'New group; empty string clears, omit to keep' },
82
+ },
83
+ required: ['session'],
84
+ additionalProperties: false,
85
+ },
86
+ },
72
87
  {
73
88
  name: 'atc_session_kill',
74
89
  description: 'Kill a session. A second kill on a dead session removes it from the list.',
@@ -237,6 +252,15 @@ async function runTool(
237
252
 
238
253
  return 'sent';
239
254
  }
255
+ case 'atc_session_update': {
256
+ await client.sendRequest('session.update', {
257
+ session: args['session'],
258
+ ...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
259
+ ...(typeof args['group'] === 'string' ? { group: args['group'] } : {}),
260
+ });
261
+
262
+ return 'updated';
263
+ }
240
264
  case 'atc_session_kill': {
241
265
  await client.sendRequest('session.kill', { session: args['session'] });
242
266
 
package/src/sessions.ts CHANGED
@@ -23,6 +23,7 @@ export interface SessionDescriptor {
23
23
  readonly lastMsg: string;
24
24
  readonly lastDetail?: string;
25
25
  readonly claudeId?: string;
26
+ readonly group?: string;
26
27
  readonly namedBy: 'user' | 'auto' | 'agent';
27
28
  readonly createdAt: number;
28
29
  readonly kind: 'pty' | 'jsonl';
@@ -41,6 +42,7 @@ export interface Session {
41
42
  lastDetail?: string;
42
43
  claudeId?: string;
43
44
  transcriptSource?: string;
45
+ group?: string;
44
46
 
45
47
  // who last named this session: the agent's own rename beats everything, a
46
48
  // user-typed spawn name beats auto-summaries.
@@ -54,6 +56,7 @@ export interface FleetEntry {
54
56
  readonly name: string;
55
57
  readonly cwd: string;
56
58
  readonly claudeId: string;
59
+ readonly group?: string;
57
60
  }
58
61
 
59
62
  const fleetFile = join(stateDir, 'fleet.json');
@@ -187,6 +190,42 @@ export class SessionManager {
187
190
  }
188
191
 
189
192
  // Reports a headless run's lifecycle into the session state machine.
193
+ /**
194
+ * Renames and/or regroups a session on a caller's behalf. A rename lands
195
+ * at user strength, so auto-summaries stop overwriting it while an
196
+ * in-session rename still wins. An empty group clears the grouping.
197
+ */
198
+ updateSession(id: string, name?: string, group?: string): boolean {
199
+ const s = this.sessions.find((x) => x.id === id);
200
+
201
+ if (s === undefined) {
202
+ return false;
203
+ }
204
+
205
+ if (name !== undefined && name !== '' && s.namedBy !== 'agent') {
206
+ s.name = name;
207
+ s.namedBy = 'user';
208
+
209
+ this.onEvent('renamed', s);
210
+ }
211
+
212
+ if (group !== undefined) {
213
+ if (group === '') {
214
+ delete s.group;
215
+ } else {
216
+ s.group = group;
217
+ }
218
+
219
+ this.onEvent('state', s);
220
+ }
221
+
222
+ this.writeFleet();
223
+ this.writeStatus();
224
+ this.emitChange();
225
+
226
+ return true;
227
+ }
228
+
190
229
  updateSurfaceState(id: string, state: SessionState, msg: string) {
191
230
  const s = this.sessions.find((x) => x.id === id);
192
231
 
@@ -240,6 +279,7 @@ export class SessionManager {
240
279
  rows: number,
241
280
  resume: boolean | string = false,
242
281
  namedBy: 'user' | 'auto' = 'auto',
282
+ group?: string,
243
283
  ): Session {
244
284
  const id = `s${++counter}-${Date.now().toString(36)}`;
245
285
  const plan = this.adapter.planSpawn({ prompt, resume });
@@ -268,6 +308,7 @@ export class SessionManager {
268
308
  unread: false,
269
309
  lastMsg: initialMsg,
270
310
  ...(typeof resume === 'string' ? { claudeId: resume } : {}),
311
+ ...(group === undefined ? {} : { group }),
271
312
  namedBy,
272
313
  createdAt: Date.now(),
273
314
  };
@@ -309,6 +350,7 @@ export class SessionManager {
309
350
  lastMsg: s.lastMsg,
310
351
  ...(s.lastDetail === undefined ? {} : { lastDetail: s.lastDetail }),
311
352
  ...(s.claudeId === undefined ? {} : { claudeId: s.claudeId }),
353
+ ...(s.group === undefined ? {} : { group: s.group }),
312
354
  namedBy: s.namedBy,
313
355
  createdAt: s.createdAt,
314
356
  kind: s.kind,
@@ -525,7 +567,12 @@ export class SessionManager {
525
567
  const live = s.pty !== null || (s.kind === 'jsonl' && s.state !== 'exited');
526
568
 
527
569
  if (live && s.claudeId !== undefined) {
528
- fleet.push({ 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
@@ -153,6 +153,7 @@ export interface OverlaySessionView extends SessionView {
153
153
  readonly alive: boolean;
154
154
  readonly kind: 'pty' | 'jsonl';
155
155
  readonly resumable: boolean;
156
+ readonly group?: string;
156
157
  }
157
158
 
158
159
  export interface OverlayView {
@@ -172,15 +173,29 @@ export function drawOverlay(view: OverlayView) {
172
173
  rowsList.push(dimRow(width, empty));
173
174
  }
174
175
 
176
+ // Sessions cluster under dim headers when more than one group exists: an
177
+ // explicit group wins, sessions without one fall back to their spawn
178
+ // directory. The rows keep the sorted order, so urgent groups lead.
179
+ const grouped = new Set(view.sessions.map((s) => s.group ?? s.cwd)).size > 1;
180
+ let lastKey: string | null = null;
181
+
175
182
  for (const [i, s] of view.sessions.entries()) {
183
+ const key = s.group ?? s.cwd;
184
+
185
+ if (grouped && key !== lastKey) {
186
+ lastKey = key;
187
+
188
+ rowsList.push(dimRow(width, `▸ ${s.group ?? formatDir(s.cwd)}`));
189
+ }
190
+
176
191
  const sel = i === view.selected;
177
192
  const name = truncate(s.name, 16).padEnd(16);
178
193
  const state = STATE_LABEL[s.state].padEnd(9);
179
- const dir = truncate(formatDir(s.cwd), 18).padEnd(18);
180
- const msgWidth = Math.max(4, width - 4 - 2 - 17 - 10 - 19);
194
+ const dir = grouped ? '' : ` ${truncate(formatDir(s.cwd), 18).padEnd(18)}`;
195
+ const msgWidth = Math.max(4, width - 4 - 2 - 17 - 10 - (grouped ? 0 : 19));
181
196
  const msg = truncate(s.lastMsg, msgWidth).padEnd(msgWidth);
182
197
  const unread = s.unread ? `${ESC}[1;33m!${ESC}[0m` : ' ';
183
- const body = `${name} ${state} ${dir} ${msg}`;
198
+ const body = `${name} ${state}${dir} ${msg}`;
184
199
  const styledBody = sel ? `${ESC}[7m${body}${ESC}[0m` : body;
185
200
 
186
201
  rowsList.push(boxRow(width, `${GLYPH[s.state]}${unread}${styledBody}`, 2 + body.length));
@@ -249,6 +264,7 @@ export function drawHelp() {
249
264
 
250
265
  const lines = [
251
266
  '⏎ attach the selected session',
267
+ '⇥ attach the most urgent needs-you session',
252
268
  'a ack its notification without attaching',
253
269
  'H eject to a headless run',
254
270
  'P revive a dead or headless session',