@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 +82 -83
- package/package.json +6 -2
- package/src/cli.ts +1 -1
- package/src/client/index.ts +17 -5
- package/src/client/pick-tab-target.ts +1 -0
- package/src/client/to-mirror-session.ts +2 -0
- package/src/client/ui.ts +39 -9
- package/src/daemon/daemon-connection.ts +52 -2
- package/src/daemon/daemon.ts +28 -1
- package/src/daemon/restore-fleet.ts +9 -1
- package/src/daemon/screen-model.ts +42 -11
- package/src/daemon/sessions.ts +110 -18
- package/src/mcp-server.ts +49 -4
- package/src/protocol/request-param-schemas.ts +8 -0
- package/src/store/fleet-entry.ts +8 -0
- package/src/store/run-migrations.ts +10 -0
- package/src/store/state-store.ts +12 -1
package/README.md
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
<div align="center">
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
|
46
|
-
|
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
49
|
-
| `
|
|
50
|
-
| `/`
|
|
51
|
-
| `a`
|
|
52
|
-
| `
|
|
53
|
-
| `
|
|
54
|
-
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
87
|
+
# Grok
|
|
77
88
|
mkdir -p ~/.grok/hooks
|
|
78
89
|
atc grok-hooks > ~/.grok/hooks/atc-reporter.json
|
|
79
90
|
|
|
80
|
-
# Codex:
|
|
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
|
|
86
|
-
|
|
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
|
-
|
|
92
|
-
[configuration guide](./docs/guides/configuration.md) covers every field:
|
|
98
|
+
## Beyond the keyboard
|
|
93
99
|
|
|
94
|
-
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
-
|
|
101
|
-
|
|
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
|
-
|
|
106
|
-
sessions included:
|
|
109
|
+
## Configuration
|
|
107
110
|
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
##
|
|
115
|
+
## Documentation
|
|
113
116
|
|
|
114
|
-
|
|
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.
|
|
4
|
-
"description": "Terminal control tower for
|
|
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
package/src/client/index.ts
CHANGED
|
@@ -191,7 +191,16 @@ function pickOverlaySessions(): MirrorSession[] {
|
|
|
191
191
|
|
|
192
192
|
const f = overlayFilter;
|
|
193
193
|
|
|
194
|
-
|
|
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
|
-
//
|
|
592
|
-
// event confirms it.
|
|
593
|
-
|
|
594
|
-
|
|
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;
|
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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 });
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
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
|
-
|
|
97
|
+
await this.waitForFlush();
|
|
94
98
|
|
|
95
|
-
|
|
96
|
-
|
|
99
|
+
return RESET_INPUT_MODES + this.renderVisibleScreen() + this.renderInputModes();
|
|
100
|
+
}
|
|
97
101
|
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
package/src/daemon/sessions.ts
CHANGED
|
@@ -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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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.
|
|
634
|
+
this.killTerminal(s);
|
|
604
635
|
} else {
|
|
605
|
-
|
|
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
|
-
|
|
608
|
-
|
|
642
|
+
this.onEvent('state', child);
|
|
643
|
+
}
|
|
609
644
|
}
|
|
610
645
|
|
|
611
|
-
this.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}),
|
package/src/store/fleet-entry.ts
CHANGED
|
@@ -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
|
}
|
package/src/store/state-store.ts
CHANGED
|
@@ -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([
|
|
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();
|