@zgeoff/atc 0.1.8 → 0.1.9

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