@zgeoff/atc 2.0.0 → 2.2.0

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
@@ -1,11 +1,8 @@
1
1
  <div align="center">
2
- <h1>atc</h1>
3
-
4
- <p>
5
- Control tower for coding-agent sessions: stock <code>claude</code>, <code>grok</code>, and
6
- <code>codex</code> instances in PTYs behind a keyboard-driven session list with hook-driven
7
- attention routing — no panes, no tiling, no mouse.
8
- </p>
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="./docs/assets/atc-dark.png">
4
+ <img src="./docs/assets/atc-light.png" alt="atc" width="256">
5
+ </picture>
9
6
 
10
7
  <p>
11
8
  <a href="https://www.npmjs.com/package/@zgeoff/atc"><img src="https://img.shields.io/npm/v/%40zgeoff%2Fatc" alt="npm version"></a>
@@ -19,100 +16,102 @@
19
16
  </p>
20
17
  </div>
21
18
 
19
+ **atc** lets you run several coding agents at once in a single terminal pane and keep track of which
20
+ are waiting on you.
21
+
22
+ `claude`, `grok`, and `codex` sessions all run in a background daemon, with a session list in front
23
+ of them. There are no panes. One session fills the terminal, `Ctrl-Space` opens the list, a session
24
+ that needs an answer turns red, and `Tab` takes you to it. Quit atc and the sessions keep running.
25
+
26
+ <img src="./docs/assets/demo.gif" alt="atc: spawn a session, open the session list, jump back in" width="1100">
27
+
22
28
  ## Install
23
29
 
24
30
  ```sh
25
31
  bun add -g @zgeoff/atc
32
+ ```
33
+
34
+ atc needs [Bun](https://bun.sh) and at least one of the `claude`, `grok`, or `codex` CLIs on your
35
+ PATH. The agent picker lists the ones it finds.
36
+
37
+ ## Use
38
+
39
+ ```sh
26
40
  atc
27
41
  ```
28
42
 
29
- atc needs [Bun](https://bun.sh) and the `claude` CLI on your PATH. Grok sessions need the `grok`
30
- CLI, and Codex sessions the `codex` CLI — the agent picker lists only the agents whose binary it can
31
- find. With [zoxide](https://github.com/ajeetdsouza/zoxide) installed, the spawn directory picker
32
- feeds on its frecency list, so every directory you visit is two keystrokes from a session; without
33
- it, the picker falls back to atc's own spawn history.
34
-
35
- The first invocation auto-spawns the daemon; the TUI is a thin client, so quitting or crashing it
36
- leaves every session running. atc runs fine nested inside zellij or tmux — give the pane locked mode
37
- so the leader key reaches atc.
38
-
39
- ## Keys
40
-
41
- | Key | Where | Action |
42
- | --------------- | -------------- | ---------------------------------------------------------------------------------------------- |
43
- | leader | anywhere | toggle session overlay — `Ctrl-Space` by default, configurable |
44
- | `n` | home/overlay | spawn: pick agent → dir → name → optional first prompt |
45
- | `r` | home/overlay | adopt an existing session: pick agent → dir → name |
46
- | `R` | home | restore the last fleet after a daemon death |
47
- | `j`/`k`/`↑`/`↓` | overlay/picker | move |
48
- | `Enter` | overlay | attach (auto-acks) |
49
- | `Tab` | overlay | attach the most urgent needs-you session, else the latest turn-done one |
50
- | `/` | overlay | fuzzy filter by name/dir, `⏎` attach top match, `esc` clear |
51
- | `a` | overlay | ack a notification without attaching |
52
- | `p` | overlay | pin or unpin — pinned sessions stay at the top of the list |
53
- | `g` | overlay | toggle the grouped view: sessions cluster under repository headers |
54
- | `H` | overlay | eject to headless — hidden on agents with no headless handoff |
55
- | `P` | overlay | revive: a fresh terminal resumes a headless or killed session in place |
56
- | `y` | overlay | yank the resume command for the session's agent |
57
- | `Y` | overlay | eject: yank the resume command, then kill the session here |
58
- | `K` | overlay | kill selected (confirm with `y`) — the entry stays revivable with `P`; a second `K` forgets it |
59
- | `u` | overlay | restart an outdated daemon and restore the fleet — offered while `⟳ update ready` shows |
60
- | `?` | overlay | full key reference — the hint row only shows actions valid for the selected session |
61
- | `q` | home/overlay | quit the client — sessions keep running in the daemon |
62
-
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. Session states: red `●` needs you, cyan `◐`
65
- running, green `✓` turn done, gray `✗` exited. Everything else passes through to the focused
66
- session, which owns the full screen; while attached, atc appends a fleet segment
67
- (`▏● 2 need you: auth-bug`) to your own Claude Code statusline.
68
-
69
- ## Attention hooks
70
-
71
- Claude sessions report attention automatically: atc injects its hooks through a generated
72
- `--settings` file per spawn and never touches your own Claude config. Grok and Codex hooks are a
73
- one-time self-install — atc prints them and never writes into your agent config either:
43
+ The first run starts the daemon. Press `n` to spawn a session: pick an agent, pick a directory, give
44
+ it a name, and type a first prompt if you have one. The session takes the whole screen and you work
45
+ in it as you would in a plain terminal.
46
+
47
+ `Ctrl-Space` opens the session list over whatever you are attached to. Each row carries a state
48
+ mark:
49
+
50
+ | Mark | State |
51
+ | ---- | ------------------------------------- |
52
+ | `●` | needs you: a prompt or question waits |
53
+ | `◐` | running |
54
+ | `✓` | turn done |
55
+ | `✗` | exited |
56
+
57
+ Sessions that need you sort to the top, so the one you want is nearly always first.
58
+
59
+ | Key | Action |
60
+ | ------------ | -------------------------------------------- |
61
+ | `Ctrl-Space` | open or close the list |
62
+ | `Tab` | attach the session that needs you most |
63
+ | `Enter` | attach the selected session |
64
+ | `/` | filter by name or directory |
65
+ | `a` | acknowledge a notification without attaching |
66
+ | `K` | kill the selected session |
67
+ | `q` | quit the client; sessions keep running |
68
+ | `?` | every other key |
69
+
70
+ The directory picker reads your [zoxide](https://github.com/ajeetdsouza/zoxide) list when zoxide is
71
+ installed, and atc's own spawn history otherwise. Inside a Claude session your statusline gains a
72
+ fleet segment, so `● 2 need you: auth-bug` is visible without opening the list.
73
+
74
+ If the daemon dies, press `R` on the home screen and every session respawns from its transcript.
75
+ After you upgrade atc, the status bar shows `⟳ update ready`, and `u` restarts the daemon and
76
+ restores the fleet when you are ready.
77
+
78
+ atc runs inside zellij or tmux. Give the pane locked mode so the leader key reaches atc.
79
+
80
+ ## Grok and Codex
81
+
82
+ Claude sessions report attention on their own: atc passes a generated settings file at spawn time
83
+ and never edits your Claude config. Grok and Codex take a one-time hook install. atc prints the
84
+ hooks and leaves the install to you:
74
85
 
75
86
  ```sh
76
- # Grok: install the hook file
87
+ # Grok
77
88
  mkdir -p ~/.grok/hooks
78
89
  atc grok-hooks > ~/.grok/hooks/atc-reporter.json
79
90
 
80
- # Codex: print the entries, merge them into ~/.codex/hooks.json,
81
- # then trust them once in the codex TUI
91
+ # Codex: merge the output into ~/.codex/hooks.json, then trust it once in the codex TUI
82
92
  atc codex-hooks
83
93
  ```
84
94
 
85
- The [configuration guide](./docs/guides/configuration.md#attention-hooks-grok-and-codex) covers the
86
- detail; [agent integration](./docs/architecture/overview.md#agent-integration) covers how the
87
- reporting works.
88
-
89
- ## Configuration
95
+ The [configuration guide](./docs/guides/configuration.md#attention-hooks-grok-and-codex) covers both
96
+ installs in detail.
90
97
 
91
- `~/.config/atc/config.json` is created with defaults on first run. The
92
- [configuration guide](./docs/guides/configuration.md) covers every field:
98
+ ## Beyond the keyboard
93
99
 
94
- - [Agent binaries](./docs/guides/configuration.md) — the binary and prepended arguments per agent,
95
- e.g. `"claudeArgs": ["--model", "opus"]`.
96
- - [Leader key](./docs/guides/configuration.md#leader) — rebind the overlay toggle when `Ctrl-Space`
97
- is taken on your machine.
98
- - [Gateways](./docs/guides/configuration.md#gateways) — run the Claude CLI against Claude-compatible
99
- backends (GLM and friends), each as its own agent in one fleet.
100
- - [Daemon hooks](./docs/guides/configuration.md#daemon-hooks) — run your own commands on fleet
101
- events, or pipe the full NDJSON event stream from `atc events`.
102
- - [Attention hooks](./docs/guides/configuration.md#attention-hooks-grok-and-codex) — the Grok and
103
- Codex self-install in detail.
100
+ - `atc mcp` exposes the fleet as MCP tools, so an agent can spawn, drive, and read other agents. A
101
+ session spawned this way lists under the session that spawned it and is killed with it. Register
102
+ it with `claude mcp add --scope user atc -- atc mcp`.
103
+ - `atc events` prints every fleet event as one NDJSON line. The same stream is on a unix socket, and
104
+ `config.json` hooks run your own commands on events. The [events guide](./docs/guides/events.md)
105
+ covers all three.
106
+ - A Claude-compatible backend runs as its own agent in the same fleet. The
107
+ [gateways](./docs/guides/configuration.md#gateways) section covers the setup.
104
108
 
105
- `atc mcp` exposes the fleet as MCP tools (list, spawn, drive, organise) to any MCP client, wrangled
106
- sessions included:
109
+ ## Configuration
107
110
 
108
- ```sh
109
- claude mcp add --scope user atc -- atc mcp
110
- ```
111
+ atc creates `~/.config/atc/config.json` on first run. The
112
+ [configuration guide](./docs/guides/configuration.md) covers every field: agent binaries and
113
+ arguments, the leader key, gateways, and hooks.
111
114
 
112
- ## Crash safety
115
+ ## Documentation
113
116
 
114
- A client crash or closed window costs nothing: the daemon keeps hosting the fleet, and the next
115
- `atc` reconnects. If the daemon itself dies, every session's transcript is already on disk — press
116
- `R` and the whole fleet respawns with the matching CLI. After an update, the status bar shows
117
- `⟳ update ready`, and `u` restarts the daemon and restores the fleet at a moment you choose. The
118
- [recovery model](./docs/architecture/overview.md#recovery-model) covers the detail.
117
+ [docs/](./docs/README.md) covers the architecture, the daemon, and the wire protocol.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.0.0",
4
- "description": "Terminal control tower for Claude Code sessions",
3
+ "version": "2.2.0",
4
+ "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
7
7
  "license": "MIT",
@@ -59,5 +59,9 @@
59
59
  "type-fest": "5.8.0",
60
60
  "typescript": "7.0.2"
61
61
  },
62
+ "overrides": {
63
+ "fast-uri": "3.1.6",
64
+ "qs": "6.16.0"
65
+ },
62
66
  "packageManager": "bun@1.3.10"
63
67
  }
package/src/cli.ts CHANGED
@@ -8,7 +8,7 @@ const main = defineCommand({
8
8
  meta: {
9
9
  name: 'atc',
10
10
  version: pkg.version,
11
- description: 'Terminal control tower for Claude Code sessions',
11
+ description: 'Terminal control tower for coding-agent sessions',
12
12
  },
13
13
  default: 'tui',
14
14
  subCommands: {
@@ -191,7 +191,16 @@ function pickOverlaySessions(): MirrorSession[] {
191
191
 
192
192
  const f = overlayFilter;
193
193
 
194
- return sorted.filter((s) => findFuzzyScore(`${s.name} ${formatDir(s.cwd)}`, f) !== null);
194
+ const matched = new Set(
195
+ sorted
196
+ .filter((s) => findFuzzyScore(`${s.name} ${formatDir(s.cwd)}`, f) !== null)
197
+ .map((s) => s.id),
198
+ );
199
+
200
+ // A matching sub-session keeps its parent row, so it stays nested.
201
+ return sorted.filter(
202
+ (s) => matched.has(s.id) || sorted.some((c) => c.parent === s.id && matched.has(c.id)),
203
+ );
195
204
  }
196
205
 
197
206
  function renderOverlay() {
@@ -588,10 +597,13 @@ function applyOverlayKey(buf: Buffer) {
588
597
  }
589
598
 
590
599
  if (ch === 'p' && sel !== undefined) {
591
- // Flipped locally too so the repaint is immediate; the daemon's state
592
- // event confirms it.
593
- sel.pinned = !sel.pinned;
594
- void sendQuiet('session.update', { session: sel.id, pinned: sel.pinned });
600
+ // A sub-session pins with its parent. Flipped locally too so the
601
+ // repaint is immediate; the daemon's state event confirms it.
602
+ const target =
603
+ (sel.parent === null ? undefined : fleet.find((x) => x.id === sel.parent)) ?? sel;
604
+
605
+ target.pinned = !target.pinned;
606
+ void sendQuiet('session.update', { session: target.id, pinned: target.pinned });
595
607
  renderOverlay();
596
608
 
597
609
  return;
@@ -3,6 +3,7 @@ import { sortSessionViews } from '../daemon/sessions';
3
3
 
4
4
  interface TabCandidate {
5
5
  readonly id: string;
6
+ readonly parent: string | null;
6
7
  readonly state: SessionState;
7
8
  readonly pinned: boolean;
8
9
  readonly lastAttachedAt: number;
@@ -19,6 +19,7 @@ export interface MirrorSession {
19
19
  resumable: boolean;
20
20
  canEject: boolean;
21
21
  agent: AgentID;
22
+ parent: string | null;
22
23
  }
23
24
 
24
25
  // Only the fields a mirror can't function without; an unparseable descriptor
@@ -61,5 +62,6 @@ export function toMirrorSession(value: unknown): MirrorSession | null {
61
62
  resumable: typeof record['agentSessionID'] === 'string',
62
63
  canEject: record['canEject'] === true,
63
64
  agent: toAgentID(record['agent']),
65
+ parent: typeof record['parent'] === 'string' ? record['parent'] : null,
64
66
  };
65
67
  }
package/src/client/ui.ts CHANGED
@@ -174,6 +174,8 @@ function dimRow(width: number, text: string): Row {
174
174
 
175
175
  // Selection-dependent hints need the liveness facts of each row.
176
176
  export interface OverlaySessionView extends SessionView {
177
+ readonly id: string;
178
+ readonly parent: string | null;
177
179
  readonly alive: boolean;
178
180
  readonly kind: 'pty' | 'headless';
179
181
  readonly resumable: boolean;
@@ -208,20 +210,22 @@ export function drawOverlay(view: OverlayView) {
208
210
 
209
211
  // The grouped view clusters sessions under dim repository headers, with
210
212
  // pinned sessions leading in their own cluster; the flat view shows a
211
- // directory column instead. The rows keep the sorted order either way.
213
+ // directory column instead. The rows keep the sorted order either way. A
214
+ // sub-session row follows its parent indented, and never opens a header.
212
215
  let lastKey: string | null = null;
213
216
 
214
217
  for (const [i, s] of view.sessions.entries()) {
218
+ const sub = s.parent !== null && view.sessions.some((x) => x.id === s.parent);
215
219
  const key = s.pinned ? PINNED_GROUP_KEY : s.repoRoot;
216
220
 
217
- if (view.grouped && key !== lastKey) {
221
+ if (view.grouped && !sub && key !== lastKey) {
218
222
  lastKey = key;
219
223
 
220
224
  rowsList.push(dimRow(width, `▸ ${s.pinned ? 'pinned' : formatDir(s.repoRoot)}`));
221
225
  }
222
226
 
223
227
  const sel = i === view.selected;
224
- const name = truncate(s.name, 16).padEnd(16);
228
+ const name = (sub ? `↳ ${truncate(s.name, 14)}` : truncate(s.name, 16)).padEnd(16);
225
229
  const state = STATE_LABEL[s.state].padEnd(9);
226
230
  const dir = view.grouped ? '' : ` ${truncate(formatDir(s.cwd), 18).padEnd(18)}`;
227
231
  const msgWidth = Math.max(4, width - 4 - 4 - 17 - 10 - (view.grouped ? 0 : 19));
@@ -249,7 +253,8 @@ export function drawOverlay(view: OverlayView) {
249
253
  );
250
254
  }
251
255
 
252
- let hint = buildOverlayHint(view.sessions[view.selected]);
256
+ const selected = view.sessions[view.selected];
257
+ let hint = buildOverlayHint(selected, view.sessions);
253
258
 
254
259
  if (view.stale) {
255
260
  hint += ' · u update daemon';
@@ -260,7 +265,7 @@ export function drawOverlay(view: OverlayView) {
260
265
  }
261
266
 
262
267
  if (view.confirmKill) {
263
- hint = 'kill selected session? y / n';
268
+ hint = formatKillConfirm(selected, view.sessions);
264
269
  }
265
270
 
266
271
  rowsList.push(dimRow(width, hint), boxBottom(width));
@@ -268,11 +273,33 @@ export function drawOverlay(view: OverlayView) {
268
273
  drawBox(rowsList);
269
274
  }
270
275
 
276
+ // A kill acts on the selected session's whole set, so the confirm counts
277
+ // the live sub-sessions that go with it; a forget counts the dead ones.
278
+ function formatKillConfirm(
279
+ s: OverlaySessionView | undefined,
280
+ list: readonly OverlaySessionView[],
281
+ ): string {
282
+ const children = s === undefined ? [] : list.filter((x) => x.parent === s.id);
283
+ const affected = children.filter((x) => x.alive === (s?.alive ?? false));
284
+
285
+ if (affected.length === 0) {
286
+ return 'kill selected session? y / n';
287
+ }
288
+
289
+ const noun = affected.length === 1 ? 'sub-session' : 'sub-sessions';
290
+
291
+ return `kill selected session and its ${affected.length} ${noun}? y / n`;
292
+ }
293
+
271
294
  const GLOBAL_HINT = 'g groups · n new · ? keys';
272
295
 
273
296
  // Only the actions valid for the selected row appear; the full reference
274
- // lives behind ?. Grok has no headless handoff, so H is omitted there.
275
- export function buildOverlayHint(s: OverlaySessionView | undefined): string {
297
+ // lives behind ?. Grok has no headless handoff, so H is omitted there. A
298
+ // sub-session's pin action targets its parent.
299
+ export function buildOverlayHint(
300
+ s: OverlaySessionView | undefined,
301
+ list: readonly OverlaySessionView[] = [],
302
+ ): string {
276
303
  if (s === undefined) {
277
304
  return GLOBAL_HINT;
278
305
  }
@@ -301,7 +328,9 @@ export function buildOverlayHint(s: OverlaySessionView | undefined): string {
301
328
  actions.push('K forget');
302
329
  }
303
330
 
304
- const pinAction = s.pinned ? 'p unpin' : 'p pin';
331
+ const owner = (s.parent === null ? undefined : list.find((x) => x.id === s.parent)) ?? s;
332
+ const pinVerb = owner.pinned ? 'p unpin' : 'p pin';
333
+ const pinAction = owner === s ? pinVerb : `${pinVerb} parent`;
305
334
 
306
335
  actions.push(pinAction);
307
336
 
@@ -321,6 +350,7 @@ export function drawHelp() {
321
350
  'Y yank the resume command, then kill here',
322
351
  'K kill (K again on a dead session forgets it)',
323
352
  'p pin or unpin — pinned sessions stay on top',
353
+ ' a sub-session pins with its parent',
324
354
  'g toggle grouping by repository',
325
355
  ' Grok rows show a dim g after the pin mark',
326
356
  'n new session',
@@ -390,7 +420,7 @@ export function drawHome(fleetCount = 0, leaderLabel = '^Space') {
390
420
  out(ansi.clear + ansi.hideCursor);
391
421
 
392
422
  const msgs = [
393
- 'atc — control tower for Claude and Grok sessions',
423
+ 'atc — control tower for coding-agent sessions',
394
424
  '',
395
425
  'n spawn a session',
396
426
  'r adopt an existing session',
@@ -15,6 +15,7 @@ import type { SessionID } from '../shared/session-id';
15
15
  import type { FleetEntry } from '../store/fleet-entry';
16
16
  import type { Dims } from './attach-registry';
17
17
  import type { AnswerResult } from './permission-registry';
18
+ import type { ScreenText } from './screen-model';
18
19
  import type { SessionDescriptor } from './sessions';
19
20
 
20
21
  interface SpawnParams {
@@ -26,6 +27,7 @@ interface SpawnParams {
26
27
  readonly resume: SpawnOptions['resume'];
27
28
  readonly namedBy: 'user' | 'auto';
28
29
  readonly agent: AgentID;
30
+ readonly parent: SessionID | null;
29
31
  }
30
32
 
31
33
  export interface DaemonContext {
@@ -37,10 +39,11 @@ export interface DaemonContext {
37
39
  readonly findAdapter: (id: AgentID) => AgentAdapter | null;
38
40
  readonly spawnSession: (p: SpawnParams) => SessionDescriptor;
39
41
  readonly killSession: (id: SessionID) => Promise<boolean>;
40
- readonly updateSession: (id: SessionID, name?: string, pinned?: boolean) => boolean;
42
+ readonly updateSession: (id: SessionID, name?: string, pinned?: boolean) => boolean | 'child_pin';
41
43
  readonly quitDaemon: () => void;
42
44
  readonly ackSession: (id: SessionID) => boolean;
43
45
  readonly buildResumeCommand: (id: SessionID) => string | null;
46
+ readonly readSessionScreen: (id: SessionID) => Promise<ScreenText | 'missing' | 'no_screen'>;
44
47
  readonly answerPermission: (request: string, decision: string) => AnswerResult;
45
48
  readonly restoreFleet: (cols: number, rows: number) => Promise<number>;
46
49
  readonly attachSession: (
@@ -265,8 +268,15 @@ export class DaemonConnection {
265
268
  }
266
269
 
267
270
  const sessionID = parsed.data.session;
271
+ const updated = this.ctx.updateSession(sessionID, parsed.data.name, parsed.data.pinned);
268
272
 
269
- if (this.ctx.updateSession(sessionID, parsed.data.name, parsed.data.pinned)) {
273
+ if (updated === 'child_pin') {
274
+ this.sendErr(
275
+ req.id,
276
+ 'bad_args',
277
+ `session '${sessionID}' is a sub-session and takes its pin from its parent`,
278
+ );
279
+ } else if (updated) {
270
280
  this.sendOk(req.id, {});
271
281
  } else {
272
282
  this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
@@ -304,6 +314,29 @@ export class DaemonConnection {
304
314
 
305
315
  return;
306
316
  }
317
+ case 'session.screen': {
318
+ const parsed = parseRequestParams('session.screen', req.p);
319
+
320
+ if (!parsed.ok) {
321
+ this.sendErr(req.id, 'bad_args', parsed.message);
322
+
323
+ return;
324
+ }
325
+
326
+ const id = parsed.data.session;
327
+
328
+ const screen = await this.ctx.readSessionScreen(id);
329
+
330
+ if (screen === 'missing') {
331
+ this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
332
+ } else if (screen === 'no_screen') {
333
+ this.sendErr(req.id, 'session_dead', `session '${id}' has no captured screen`);
334
+ } else {
335
+ this.sendOk(req.id, { ...screen });
336
+ }
337
+
338
+ return;
339
+ }
307
340
  case 'session.eject': {
308
341
  const parsed = parseRequestParams('session.eject', req.p);
309
342
 
@@ -440,6 +473,22 @@ export class DaemonConnection {
440
473
  return;
441
474
  }
442
475
 
476
+ let parent: SessionID | null = null;
477
+
478
+ if (parsed.data.parent !== undefined) {
479
+ const owner = this.ctx.collectSessions().find((s) => s.id === parsed.data.parent);
480
+
481
+ if (owner === undefined) {
482
+ this.sendErr(req.id, 'no_such_session', `no session '${parsed.data.parent}'`);
483
+
484
+ return;
485
+ }
486
+
487
+ // A sub-session spawning a sub-session of its own lands beside it,
488
+ // so a set stays one level deep.
489
+ parent = owner.parent ?? owner.id;
490
+ }
491
+
443
492
  const session = this.ctx.spawnSession({
444
493
  cwd,
445
494
  name: name === '' ? basename(cwd) : name,
@@ -449,6 +498,7 @@ export class DaemonConnection {
449
498
  resume: parsed.data.resume,
450
499
  namedBy: name === '' ? 'auto' : 'user',
451
500
  agent,
501
+ parent,
452
502
  });
453
503
 
454
504
  this.sendOk(req.id, { session });
@@ -415,7 +415,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
415
415
  loadLastUsedAgent: () => store.loadLastUsedAgent(),
416
416
  findAdapter: (kind) => mgr.findAdapter(kind),
417
417
  spawnSession: (p) => {
418
- const s = mgr.spawn(p.cwd, p.name, p.prompt, p.cols, p.rows, p.resume, p.namedBy, p.agent);
418
+ const s = mgr.spawn(
419
+ p.cwd,
420
+ p.name,
421
+ p.prompt,
422
+ p.cols,
423
+ p.rows,
424
+ p.resume,
425
+ p.namedBy,
426
+ p.agent,
427
+ p.parent,
428
+ );
429
+
419
430
  const runtime = runtimes.get(s.id);
420
431
 
421
432
  if (runtime !== undefined) {
@@ -446,6 +457,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
446
457
  return false;
447
458
  }
448
459
 
460
+ for (const child of mgr.collectChildren(id)) {
461
+ runtimes.get(child.id)?.stopHeadlessRun();
462
+ }
463
+
449
464
  runtimes.get(id)?.stopHeadlessRun();
450
465
 
451
466
  await mgr.kill(id);
@@ -525,6 +540,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
525
540
  return true;
526
541
  },
527
542
  buildResumeCommand: (id) => mgr.buildResumeCommand(id),
543
+
544
+ // A killed session keeps its last screen until a second kill removes
545
+ // it, so a reader can still see what the agent printed before it died.
546
+ readSessionScreen: (id) => {
547
+ if (!mgr.sessions.some((x) => x.id === id)) {
548
+ return Promise.resolve('missing');
549
+ }
550
+
551
+ const screen = runtimes.get(id)?.screen ?? null;
552
+
553
+ return screen === null ? Promise.resolve('no_screen') : screen.renderText();
554
+ },
528
555
  answerPermission: (request, decision) => registry.answer(request, decision),
529
556
  attachSession: (client, sessionID, dims) => {
530
557
  const s = mgr.sessions.find((x) => x.id === sessionID);
@@ -51,7 +51,7 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<number>
51
51
  // reported an event keep their stored order at the end. Exited entries
52
52
  // dedupe against every listed session, so repeated restores never double
53
53
  // up the killed archive.
54
- const entries = stored
54
+ const kept = stored
55
55
  .filter((entry) =>
56
56
  entry.exited === true
57
57
  ? !hasAnySession(entry.agentSessionID)
@@ -61,6 +61,14 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<number>
61
61
  (recency.get(b.agentSessionID) ?? '').localeCompare(recency.get(a.agentSessionID) ?? ''),
62
62
  );
63
63
 
64
+ // Sub-sessions register after every top-level entry, so each one links
65
+ // to a parent that is already listed; the wrangling session also boots
66
+ // before the sessions it wrangles.
67
+ const entries = [
68
+ ...kept.filter((entry) => entry.parent === undefined),
69
+ ...kept.filter((entry) => entry.parent !== undefined),
70
+ ];
71
+
64
72
  // The whole fleet registers as terminal-less sessions up front, so the
65
73
  // list shows every incoming session immediately instead of revealing them
66
74
  // one boot at a time. Exited entries only register — they stay killed
@@ -2,6 +2,12 @@ import { SerializeAddon } from '@xterm/addon-serialize';
2
2
  import { Terminal } from '@xterm/headless';
3
3
  import { RESET_INPUT_MODES } from '../shared/reset-input-modes';
4
4
 
5
+ export interface ScreenText {
6
+ readonly text: string;
7
+ readonly cols: number;
8
+ readonly rows: number;
9
+ }
10
+
5
11
  // The serializer already re-emits the modes the vt engine models (mouse
6
12
  // tracking, bracketed paste, focus events); these are the ones it drops.
7
13
  const REPLAYED_DEC_MODES = new Set([1006, 1007, 2031]);
@@ -84,21 +90,34 @@ export class ScreenModel {
84
90
  });
85
91
  }
86
92
 
87
- // The terminal parses asynchronously, and bytes recorded while a flush is
88
- // awaited re-arm it — so the replay drains until no newer write is
89
- // pending. Serializing earlier would omit bytes already streamed live to
90
- // clients, and the replay's leading clear would erase them from the
91
- // client's screen for good.
93
+ // Serializing before the flush drains would omit bytes already streamed
94
+ // live to clients, and the replay's leading clear would erase them from
95
+ // the client's screen for good.
92
96
  async renderReplay(): Promise<string> {
93
- let pending: Promise<void>;
97
+ await this.waitForFlush();
94
98
 
95
- do {
96
- pending = this.flushed;
99
+ return RESET_INPUT_MODES + this.renderVisibleScreen() + this.renderInputModes();
100
+ }
97
101
 
98
- await pending;
99
- } while (pending !== this.flushed);
102
+ // The visible rows of whichever buffer the session is showing, as plain
103
+ // text with no escape sequences: one line per row, trailing blanks
104
+ // trimmed from each row, trailing blank rows dropped. Drains pending
105
+ // writes first, so text recorded just before the read is never missing.
106
+ async renderText(): Promise<ScreenText> {
107
+ await this.waitForFlush();
100
108
 
101
- return RESET_INPUT_MODES + this.renderVisibleScreen() + this.renderInputModes();
109
+ const buffer = this.term.buffer.active;
110
+ const lines: string[] = [];
111
+
112
+ for (let y = 0; y < this.term.rows; y++) {
113
+ lines.push((buffer.getLine(buffer.baseY + y)?.translateToString(true) ?? '').trimEnd());
114
+ }
115
+
116
+ while (lines.length > 0 && lines.at(-1) === '') {
117
+ lines.pop();
118
+ }
119
+
120
+ return { text: lines.join('\n'), cols: this.term.cols, rows: this.term.rows };
102
121
  }
103
122
 
104
123
  updateDims(cols: number, rows: number): void {
@@ -109,6 +128,18 @@ export class ScreenModel {
109
128
  this.term.dispose();
110
129
  }
111
130
 
131
+ // The terminal parses asynchronously, and bytes recorded while a flush is
132
+ // awaited re-arm it — so this drains until no newer write is pending.
133
+ private async waitForFlush(): Promise<void> {
134
+ let pending: Promise<void>;
135
+
136
+ do {
137
+ pending = this.flushed;
138
+
139
+ await pending;
140
+ } while (pending !== this.flushed);
141
+ }
142
+
112
143
  // A session on the alternate screen serializes as the normal buffer, a
113
144
  // buffer switch, then the alternate buffer. The switch clears nothing on a
114
145
  // terminal already in alternate mode — and the client always is, since its
@@ -38,6 +38,10 @@ export interface SessionDescriptor {
38
38
  // Whether this session's agent can run a headless turn, so the client can
39
39
  // offer the eject action without knowing which agent it is.
40
40
  readonly canEject: boolean;
41
+
42
+ // The session this one is a sub-session of, when it has one: it lists
43
+ // under that session and takes its pin from it.
44
+ readonly parent?: SessionID;
41
45
  }
42
46
 
43
47
  export interface Session {
@@ -67,6 +71,10 @@ export interface Session {
67
71
  // user-typed spawn name beats auto-summaries.
68
72
  namedBy: 'user' | 'auto' | 'agent';
69
73
  createdAt: number;
74
+
75
+ // The session that spawned this one as a sub-session; null for a
76
+ // top-level session. One level deep: a sub-session never owns another.
77
+ parent: SessionID | null;
70
78
  }
71
79
 
72
80
  export class SessionManager {
@@ -171,6 +179,7 @@ export class SessionManager {
171
179
  repoRoot: resolveRepoRoot(entry.cwd),
172
180
  namedBy: 'auto',
173
181
  createdAt: Date.now(),
182
+ parent: this.findByAgentSessionID(entry.parent)?.id ?? null,
174
183
  };
175
184
 
176
185
  this.sessions.push(session);
@@ -180,6 +189,14 @@ export class SessionManager {
180
189
  return session;
181
190
  }
182
191
 
192
+ private findByAgentSessionID(agentSessionID: AgentSessionID | undefined): Session | undefined {
193
+ if (agentSessionID === undefined) {
194
+ return undefined;
195
+ }
196
+
197
+ return this.sessions.find((s) => s.agentSessionID === agentSessionID);
198
+ }
199
+
183
200
  // Adopts a headless session back into a terminal: a fresh PTY resumes the
184
201
  // same agent session id.
185
202
  adoptTerminal(id: SessionID, cols: number, rows: number): Session | null {
@@ -237,15 +254,20 @@ export class SessionManager {
237
254
  /**
238
255
  * Renames and/or pins a session on a caller's behalf. A rename lands at
239
256
  * user strength, so auto-summaries stop overwriting it while an
240
- * in-session rename still wins. Pinned sessions lead every list.
257
+ * in-session rename still wins. Pinned sessions lead every list. A
258
+ * sub-session takes its pin from its parent, so pinning one is refused.
241
259
  */
242
- updateSession(id: SessionID, name?: string, pinned?: boolean): boolean {
260
+ updateSession(id: SessionID, name?: string, pinned?: boolean): boolean | 'child_pin' {
243
261
  const s = this.sessions.find((x) => x.id === id);
244
262
 
245
263
  if (s === undefined) {
246
264
  return false;
247
265
  }
248
266
 
267
+ if (pinned !== undefined && s.parent !== null) {
268
+ return 'child_pin';
269
+ }
270
+
249
271
  if (name !== undefined && name !== '' && s.namedBy !== 'agent') {
250
272
  s.name = name;
251
273
  s.namedBy = 'user';
@@ -310,7 +332,8 @@ export class SessionManager {
310
332
  }
311
333
 
312
334
  // resume: true opens the agent's own session picker; an agent session id
313
- // resumes that specific session (fleet restore).
335
+ // resumes that specific session (fleet restore). parent makes the new
336
+ // session a sub-session of that one.
314
337
  spawn(
315
338
  cwd: string,
316
339
  name: string,
@@ -320,6 +343,7 @@ export class SessionManager {
320
343
  resume: boolean | AgentSessionID = false,
321
344
  namedBy: 'user' | 'auto' = 'auto',
322
345
  agent: AgentID = 'claude',
346
+ parent: SessionID | null = null,
323
347
  ): Session {
324
348
  const adapter = this.findAdapter(agent);
325
349
 
@@ -360,6 +384,7 @@ export class SessionManager {
360
384
  repoRoot: resolveRepoRoot(cwd),
361
385
  namedBy,
362
386
  createdAt: Date.now(),
387
+ parent,
363
388
  };
364
389
 
365
390
  pty.onData((d) => {
@@ -408,9 +433,15 @@ export class SessionManager {
408
433
  kind: s.kind,
409
434
  alive: s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'),
410
435
  canEject: (this.findAdapter(s.agent)?.headlessRunner ?? null) !== null,
436
+ ...(s.parent === null ? {} : { parent: s.parent }),
411
437
  }));
412
438
  }
413
439
 
440
+ // The live and dead sub-sessions of a session, in list order.
441
+ collectChildren(id: SessionID): Session[] {
442
+ return this.sessions.filter((s) => s.parent === id);
443
+ }
444
+
414
445
  // Returns the normalized event kind so the caller can key lifecycle
415
446
  // bookkeeping on it, or null when no session or adapter matches.
416
447
  applyHook(e: HookEvent): AdapterEvent['kind'] | null {
@@ -585,7 +616,9 @@ export class SessionManager {
585
616
 
586
617
  // A kill's response is the caller's cue that the session is safely
587
618
  // archived, so the write it depends on must land before that response
588
- // goes out.
619
+ // goes out. A kill acts on the whole set: killing a session kills its
620
+ // live sub-sessions with it, and forgetting a dead one forgets its dead
621
+ // sub-sessions and promotes any live ones to top level.
589
622
  async kill(id: SessionID): Promise<void> {
590
623
  const s = this.sessions.find((x) => x.id === id);
591
624
 
@@ -594,21 +627,23 @@ export class SessionManager {
594
627
  }
595
628
 
596
629
  if (s.pty) {
597
- s.pty.kill();
598
-
599
- s.pty = null;
600
- s.state = 'exited';
601
- s.lastMsg = 'killed';
630
+ for (const child of this.collectChildren(id)) {
631
+ this.killTerminal(child);
632
+ }
602
633
 
603
- this.onEvent('state', s);
634
+ this.killTerminal(s);
604
635
  } else {
605
- this.sessions = this.sessions.filter((x) => x.id !== id);
636
+ for (const child of this.collectChildren(id)) {
637
+ if (child.pty === null && !(child.kind === 'headless' && child.state !== 'exited')) {
638
+ this.remove(child);
639
+ } else {
640
+ child.parent = null;
606
641
 
607
- if (this.focusedId === id) {
608
- this.focusedId = null;
642
+ this.onEvent('state', child);
643
+ }
609
644
  }
610
645
 
611
- this.onEvent('removed', s);
646
+ this.remove(s);
612
647
  }
613
648
 
614
649
  await this.writeFleet();
@@ -616,6 +651,33 @@ export class SessionManager {
616
651
  this.emitChange();
617
652
  }
618
653
 
654
+ // Ends a live session, terminal or headless, leaving a dead entry; a
655
+ // session already dead is left as it is.
656
+ private killTerminal(s: Session) {
657
+ if (s.pty !== null) {
658
+ s.pty.kill();
659
+
660
+ s.pty = null;
661
+ } else if (s.kind !== 'headless' || s.state === 'exited') {
662
+ return;
663
+ }
664
+
665
+ s.state = 'exited';
666
+ s.lastMsg = 'killed';
667
+
668
+ this.onEvent('state', s);
669
+ }
670
+
671
+ private remove(s: Session) {
672
+ this.sessions = this.sessions.filter((x) => x.id !== s.id);
673
+
674
+ if (this.focusedId === s.id) {
675
+ this.focusedId = null;
676
+ }
677
+
678
+ this.onEvent('removed', s);
679
+ }
680
+
619
681
  killAll() {
620
682
  for (const s of this.sessions) {
621
683
  s.pty?.kill();
@@ -653,6 +715,13 @@ export class SessionManager {
653
715
 
654
716
  const live = s.pty !== null || (s.kind === 'headless' && s.state !== 'exited');
655
717
 
718
+ // The link persists by the parent's agent session id, since atc ids
719
+ // are minted afresh on restore.
720
+ const parent =
721
+ s.parent === null
722
+ ? undefined
723
+ : this.sessions.find((x) => x.id === s.parent)?.agentSessionID;
724
+
656
725
  fleet.push({
657
726
  name: s.name,
658
727
  cwd: s.cwd,
@@ -661,6 +730,7 @@ export class SessionManager {
661
730
  ...(s.pinned ? { pinned: true } : {}),
662
731
  lastAttachedAt: s.lastAttachedAt,
663
732
  ...(live ? {} : { exited: true }),
733
+ ...(parent === undefined ? {} : { parent }),
664
734
  });
665
735
  }
666
736
 
@@ -689,6 +759,8 @@ export function countSessionStates(
689
759
  }
690
760
 
691
761
  interface SortableSessionView {
762
+ readonly id: string;
763
+ readonly parent: string | null;
692
764
  readonly state: SessionState;
693
765
  readonly pinned: boolean;
694
766
  readonly lastAttachedAt: number;
@@ -697,7 +769,10 @@ interface SortableSessionView {
697
769
 
698
770
  // Overlay order: pinned sessions first in most-recently-attached order, then
699
771
  // everyone else by urgency — who needs you, finished turns, busy, dead —
700
- // with most-recently-attached breaking ties inside each state.
772
+ // with most-recently-attached breaking ties inside each state. A
773
+ // sub-session sits directly under its parent, ranked among its siblings
774
+ // alone, so its attention never moves the parent's row; a sub-session whose
775
+ // parent is not listed ranks as a top-level row.
701
776
  export function sortSessionViews<T extends SortableSessionView>(list: readonly T[]): T[] {
702
777
  const rank: Record<SessionState, number> = {
703
778
  needs_you: 0,
@@ -706,7 +781,7 @@ export function sortSessionViews<T extends SortableSessionView>(list: readonly T
706
781
  exited: 3,
707
782
  };
708
783
 
709
- return [...list].toSorted((a, b) => {
784
+ const ranked = [...list].toSorted((a, b) => {
710
785
  if (a.pinned !== b.pinned) {
711
786
  return a.pinned ? -1 : 1;
712
787
  }
@@ -715,6 +790,20 @@ export function sortSessionViews<T extends SortableSessionView>(list: readonly T
715
790
 
716
791
  return a.pinned ? recency : rank[a.state] - rank[b.state] || recency;
717
792
  });
793
+
794
+ const listed = new Set(ranked.map((s) => s.id));
795
+
796
+ const sorted: T[] = [];
797
+
798
+ for (const s of ranked) {
799
+ if (s.parent !== null && listed.has(s.parent)) {
800
+ continue;
801
+ }
802
+
803
+ sorted.push(s, ...ranked.filter((child) => child.parent === s.id));
804
+ }
805
+
806
+ return sorted;
718
807
  }
719
808
 
720
809
  // Never a filesystem path, so a repository can't collide with it.
@@ -723,14 +812,17 @@ export const PINNED_GROUP_KEY = ' pinned';
723
812
  // Overlay display order for the grouped view: the flat sort with each
724
813
  // repository's sessions pulled together at the position of its best-ranked
725
814
  // member, so the renderer's adjacency-based headers appear once per group.
726
- // Pinned sessions form their own leading group.
815
+ // Pinned sessions form their own leading group. A sub-session keys by its
816
+ // parent, so a set never splits across groups.
727
817
  export function sortGroupedSessionViews<
728
818
  T extends SortableSessionView & { readonly repoRoot: string },
729
819
  >(list: readonly T[]): T[] {
820
+ const byID = new Map(list.map((s) => [s.id, s]));
730
821
  const buckets = new Map<string, T[]>();
731
822
 
732
823
  for (const s of sortSessionViews(list)) {
733
- const key = s.pinned ? PINNED_GROUP_KEY : s.repoRoot;
824
+ const owner = (s.parent === null ? undefined : byID.get(s.parent)) ?? s;
825
+ const key = owner.pinned ? PINNED_GROUP_KEY : owner.repoRoot;
734
826
  const bucket = buckets.get(key);
735
827
 
736
828
  if (bucket === undefined) {
package/src/mcp-server.ts CHANGED
@@ -43,6 +43,12 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
43
43
  agent: SPAWN_SCHEMA.shape.agent.describe(
44
44
  'Which registered agent id to spawn; defaults to claude',
45
45
  ),
46
+ detached: z
47
+ .boolean()
48
+ .optional()
49
+ .describe(
50
+ 'Spawn a top-level session. By default a spawn from inside an atc session becomes a sub-session of it: listed under it, pinned with it, killed with it.',
51
+ ),
46
52
  }),
47
53
  { io: 'input' },
48
54
  );
@@ -57,7 +63,7 @@ const TOOLS: readonly MCPTool[] = [
57
63
  {
58
64
  name: 'atc_session_spawn',
59
65
  description:
60
- 'Spawn a new session in a directory. Optional agent is an agent id the daemon has registered, such as claude, grok, or codex; omitted agent is always Claude, never the TUI last-used value. An unregistered id is rejected. Returns the new session descriptor. Give it a prompt to start it working immediately.',
66
+ 'Spawn a new session in a directory. Optional agent is an agent id the daemon has registered, such as claude, grok, or codex; omitted agent is always Claude, never the TUI last-used value. An unregistered id is rejected. Called from inside an atc session, the new session is a sub-session of the caller unless detached is true. Returns the new session descriptor. Give it a prompt to start it working immediately.',
61
67
  inputSchema: SPAWN_INPUT,
62
68
  },
63
69
  {
@@ -74,10 +80,16 @@ const TOOLS: readonly MCPTool[] = [
74
80
  additionalProperties: false,
75
81
  },
76
82
  },
83
+ {
84
+ name: 'atc_session_screen',
85
+ description:
86
+ 'Read the current terminal screen of a session as plain text, without attaching to it. Use it to see what a session printed or what it is waiting on before answering it with atc_session_input. A killed session keeps its last screen.',
87
+ inputSchema: SESSION_INPUT,
88
+ },
77
89
  {
78
90
  name: 'atc_session_update',
79
91
  description:
80
- '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.',
92
+ 'Rename and/or pin a session. Renames stick against auto-summaries; pinned sessions lead every list. A sub-session pins with its parent, so pin the parent instead. Use this to organise the fleet: name sessions after their task.',
81
93
  inputSchema: {
82
94
  type: 'object',
83
95
  properties: {
@@ -243,14 +255,23 @@ async function runTool(
243
255
  case 'atc_session_spawn': {
244
256
  const rawAgent = args['agent'];
245
257
 
246
- const ok = await client.sendRequest('session.spawn', {
258
+ // The server inherits the calling session's id from its environment,
259
+ // so a spawn from inside a session nests under it by default.
260
+ const caller = process.env['ATC_SESSION_ID'];
261
+ const nested = args['detached'] !== true && caller !== undefined && caller !== '';
262
+
263
+ const params = {
247
264
  cwd: args['cwd'],
248
265
  ...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
249
266
  ...(typeof args['prompt'] === 'string' ? { prompt: args['prompt'] } : {}),
250
267
  ...(rawAgent === undefined ? {} : { agent: rawAgent }),
251
268
  cols: 100,
252
269
  rows: 30,
253
- });
270
+ };
271
+
272
+ const ok = nested
273
+ ? await sendNestedSpawn(client, params, caller)
274
+ : await client.sendRequest('session.spawn', params);
254
275
 
255
276
  return JSON.stringify(ok['session'], null, 2);
256
277
  }
@@ -262,6 +283,11 @@ async function runTool(
262
283
 
263
284
  return 'sent';
264
285
  }
286
+ case 'atc_session_screen': {
287
+ const ok = await client.sendRequest('session.screen', { session: args['session'] });
288
+
289
+ return typeof ok['text'] === 'string' ? ok['text'] : JSON.stringify(ok);
290
+ }
265
291
  case 'atc_session_update': {
266
292
  await client.sendRequest('session.update', {
267
293
  session: args['session'],
@@ -304,3 +330,22 @@ function sendResult(id: string | number, result: Readonly<Record<string, unknown
304
330
  function sendError(id: string | number, code: number, message: string): void {
305
331
  process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })}\n`);
306
332
  }
333
+
334
+ // The inherited id can point at a session another daemon hosts, or one
335
+ // this daemon no longer lists; the spawn then lands top-level instead of
336
+ // failing the tool call.
337
+ async function sendNestedSpawn(
338
+ client: FleetCaller,
339
+ params: Readonly<Record<string, unknown>>,
340
+ parent: string,
341
+ ): Promise<Readonly<Record<string, unknown>>> {
342
+ try {
343
+ return await client.sendRequest('session.spawn', { ...params, parent });
344
+ } catch (error) {
345
+ if (error instanceof DaemonError && error.code === 'no_such_session') {
346
+ return client.sendRequest('session.spawn', params);
347
+ }
348
+
349
+ throw error;
350
+ }
351
+ }
@@ -42,6 +42,13 @@ export const REQUEST_PARAM_SCHEMAS = {
42
42
  .string({ error: 'session.spawn agent must be a non-empty agent id' })
43
43
  .min(1, 'session.spawn agent must be a non-empty agent id')
44
44
  .optional(),
45
+
46
+ // The session the new one is a sub-session of; absent or empty spawns a
47
+ // top-level session.
48
+ parent: z.preprocess(
49
+ (v) => (typeof v === 'string' && v !== '' ? v : undefined),
50
+ z.string().transform(toSessionID).optional(),
51
+ ),
45
52
  }),
46
53
  'session.kill': SESSION_DEFAULTED,
47
54
  'session.ack': SESSION_DEFAULTED,
@@ -64,6 +71,7 @@ export const REQUEST_PARAM_SCHEMAS = {
64
71
  message: 'session.resize requires positive cols and rows',
65
72
  }),
66
73
  'session.resumeCommand': SESSION_DEFAULTED,
74
+ 'session.screen': SESSION_DEFAULTED,
67
75
  'session.eject': SESSION_DEFAULTED.extend({
68
76
  prompt: buildDefaultedNonEmptyString(EJECT_DEFAULT_PROMPT),
69
77
  }),
@@ -3,6 +3,7 @@ import { toAgentID } from '../agents/agent-adapter';
3
3
  import type { AgentID } from '../agents/agent-adapter';
4
4
  import type { AgentSessionID } from '../shared/agent-session-id';
5
5
  import { buildOptionalBoolean } from '../shared/build-optional-boolean';
6
+ import { buildOptionalString } from '../shared/build-optional-string';
6
7
  import { isRecord } from '../shared/report';
7
8
  import { toAgentSessionID } from '../shared/to-agent-session-id';
8
9
 
@@ -14,6 +15,9 @@ export interface FleetEntry {
14
15
  readonly pinned?: boolean;
15
16
  readonly lastAttachedAt?: number;
16
17
  readonly exited?: boolean;
18
+
19
+ // The agent session id of the session this one is a sub-session of.
20
+ readonly parent?: AgentSessionID;
17
21
  }
18
22
 
19
23
  export interface FleetStore {
@@ -43,6 +47,7 @@ const FLEET_ENTRY_SCHEMA = z.preprocess(
43
47
  pinned: buildOptionalBoolean(),
44
48
  lastAttachedAt: buildOptionalNumber(),
45
49
  exited: buildOptionalBoolean(),
50
+ parent: buildOptionalString(),
46
51
  }),
47
52
  );
48
53
 
@@ -64,6 +69,9 @@ export function parseFleetEntry(raw: unknown): FleetEntry | undefined {
64
69
  ? {}
65
70
  : { lastAttachedAt: parsed.data.lastAttachedAt }),
66
71
  ...(parsed.data.exited === true ? { exited: true } : {}),
72
+ ...(parsed.data.parent === undefined || parsed.data.parent === ''
73
+ ? {}
74
+ : { parent: toAgentSessionID(parsed.data.parent) }),
67
75
  };
68
76
  }
69
77
 
@@ -11,6 +11,7 @@ interface FleetTable {
11
11
  last_attached: number | null;
12
12
  agent: string;
13
13
  exited: number;
14
+ parent: string | null;
14
15
  }
15
16
 
16
17
  interface EventsTable {
@@ -119,6 +120,11 @@ const MIGRATIONS: Record<string, Migration> = {
119
120
  .execute();
120
121
  },
121
122
  },
123
+ '007_add_fleet_parent': {
124
+ async up(db: Kysely<StateStoreSchema>) {
125
+ await db.schema.alterTable('fleet').addColumn('parent', 'text').execute();
126
+ },
127
+ },
122
128
  };
123
129
 
124
130
  const PROVIDER: MigrationProvider = {
@@ -238,5 +244,9 @@ function pickBaselineSteps(columns: ReadonlySet<string>): readonly string[] {
238
244
  steps.push('006_add_fleet_exited');
239
245
  }
240
246
 
247
+ if (columns.has('parent')) {
248
+ steps.push('007_add_fleet_parent');
249
+ }
250
+
241
251
  return steps;
242
252
  }
@@ -63,7 +63,16 @@ export class StateStore {
63
63
  async loadFleet(): Promise<FleetEntry[]> {
64
64
  const rows = await this.db
65
65
  .selectFrom('fleet')
66
- .select(['agent_session_id', 'name', 'cwd', 'pinned', 'last_attached', 'agent', 'exited'])
66
+ .select([
67
+ 'agent_session_id',
68
+ 'name',
69
+ 'cwd',
70
+ 'pinned',
71
+ 'last_attached',
72
+ 'agent',
73
+ 'exited',
74
+ 'parent',
75
+ ])
67
76
  .execute();
68
77
 
69
78
  const entries: FleetEntry[] = [];
@@ -77,6 +86,7 @@ export class StateStore {
77
86
  ...(row.pinned === 0 ? {} : { pinned: true }),
78
87
  ...(row.last_attached === null ? {} : { lastAttachedAt: row.last_attached }),
79
88
  ...(row.exited === 0 ? {} : { exited: true }),
89
+ ...(row.parent === null ? {} : { parent: toAgentSessionID(row.parent) }),
80
90
  });
81
91
  }
82
92
 
@@ -121,6 +131,7 @@ export class StateStore {
121
131
  last_attached: entry.lastAttachedAt ?? null,
122
132
  agent: entry.agent,
123
133
  exited: entry.exited === true ? 1 : 0,
134
+ parent: entry.parent ?? null,
124
135
  })
125
136
  .orReplace()
126
137
  .execute();