@d3ara1n/pi-subagent 1.3.1 → 1.5.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 +26 -5
- package/package.json +1 -1
- package/src/index.ts +84 -0
- package/src/run.ts +15 -0
- package/src/spawn.test.ts +49 -0
- package/src/spawn.ts +141 -60
- package/src/types.ts +11 -3
- package/src/utils.test.ts +37 -1
- package/src/utils.ts +14 -6
- package/src/view.ts +292 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Role-based subagent orchestration for [pi](https://github.com/earendil-works/pi).
|
|
4
4
|
|
|
5
|
-
Provides a `subagent_delegate` tool that lets the main model offload tasks to specialized pi child processes with configurable model roles, real-time TUI progress, and AI-generated summaries. Runs can be foreground (blocking) or background (asynchronous, collected later via `subagent_wait`/`subagent_check`, cancellable via `subagent_cancel`).
|
|
5
|
+
Provides a `subagent_delegate` tool that lets the main model offload tasks to specialized pi child processes with configurable model roles, real-time TUI progress, and AI-generated summaries. Runs can be foreground (blocking) or background (asynchronous, collected later via `subagent_wait`/`subagent_check`, cancellable via `subagent_cancel`). A centered live view (`/subagent:view`) shows every run's activity feed as it happens, and mid-run corrections can be queued into a running subagent from the view's input box or via `subagent_steer`.
|
|
6
6
|
|
|
7
7
|
## Design Philosophy
|
|
8
8
|
|
|
@@ -23,7 +23,7 @@ This means:
|
|
|
23
23
|
|
|
24
24
|
1. Main model calls the `subagent_delegate` tool with a role and task description
|
|
25
25
|
2. The extension resolves the role to a model via pi-model-roles
|
|
26
|
-
3. Spawns an isolated pi child process with the configured model, tools, and system prompt
|
|
26
|
+
3. Spawns an isolated pi child process in RPC mode (`--mode rpc`) with the configured model, tools, and system prompt — agent events stream back over stdout while stdin carries the initial prompt and mid-run steering commands
|
|
27
27
|
4. **Real-time TUI progress** shows tool calls, turns, and elapsed time as the subagent runs
|
|
28
28
|
5. After completion, an **AI-generated one-line summary** is produced for compact display
|
|
29
29
|
6. Returns the result to the main model with usage statistics (turns, tokens, cost)
|
|
@@ -54,10 +54,19 @@ This means:
|
|
|
54
54
|
|
|
55
55
|
| Command | Description |
|
|
56
56
|
|---------|-------------|
|
|
57
|
+
| `/subagent:view` | Open the live view: a tabbed overlay showing one run's full activity feed at a time, with an input box to steer the focused run |
|
|
57
58
|
| `/subagent:doctor` | Diagnose pi invocation, model-role resolution, configuration, and role references |
|
|
58
59
|
| `/subagent:status` | List background runs (active + collected) and their current state |
|
|
59
60
|
| `/subagent:cancel <id\|all> [reason]` | Cancel a live background run (or every live run); the optional reason is recorded with the run |
|
|
60
61
|
|
|
62
|
+
### Live view (`/subagent:view`)
|
|
63
|
+
|
|
64
|
+
A centered overlay covering most of the screen. A tab row across the top lists every run (icon · id · role); `Tab` cycles the focused run, and the rest of the viewport belongs to it alone — its complete activity feed, tail-capped to the visible area with a `⋮ earlier activity` marker when older entries roll off.
|
|
65
|
+
|
|
66
|
+
The feed itself is a continuous, append-only list: each entry is static text with a state icon, and the only animated thing is the ellipsis on a running entry (`.` → `..` → `...`). Finishing freezes an entry in place — its position never changes, only the icon flips. Streamed assistant text grows in place as the run's last line and settles into plain terminal-colored text at the turn boundary. Both foreground and background runs appear here; a foreground run stays listed while its delegate call blocks the main agent.
|
|
67
|
+
|
|
68
|
+
Below the feed sits a steer input box: type a correction and press Enter to queue it into the focused run (only while it is running). The message appears immediately in the feed as an `↩ steer:` entry and is delivered to the child after its current tool batch, before its next LLM call — the run keeps its progress. `Esc` closes the overlay.
|
|
69
|
+
|
|
61
70
|
## Dependencies
|
|
62
71
|
|
|
63
72
|
- [`@d3ara1n/pi-model-roles`](../pi-model-roles) — model role resolution
|
|
@@ -182,13 +191,14 @@ Three execution properties, kept separate:
|
|
|
182
191
|
|
|
183
192
|
- **Foreground** (default): the call blocks until the run finishes and returns the final output directly. (Under the hood foreground and background share one async run engine — foreground is simply background-but-blocking.)
|
|
184
193
|
- **Parallel**: multiple `subagent_delegate` calls in one turn run concurrently — foreground and background alike, no special flag.
|
|
185
|
-
- **Background** (`background: true`): non-blocking — `subagent_delegate` returns immediately with a run id. Use it when you have your own work to do (or a discussion with the user to continue) while the run executes;
|
|
194
|
+
- **Background** (`background: true`): non-blocking — `subagent_delegate` returns immediately with a run id. Use it when you have your own work to do (or a discussion with the user to continue) while the run executes; four companion tools manage the outcome:
|
|
186
195
|
|
|
187
196
|
| Tool | Purpose | Returns to the model |
|
|
188
197
|
|------|---------|---------------------|
|
|
189
198
|
| `subagent_delegate(background: true)` | Start an async run | Just the id (`sub-N`) |
|
|
190
199
|
| `subagent_wait(ids?, timeout_ms?)` | Block until **all** listed runs finish (omit `ids` for all current background runs) | Statuses only, one `id (role): finished/failed` line per run — never results; errors when the timeout hits with runs unfinished |
|
|
191
200
|
| `subagent_check(id)` | One-shot snapshot of a single run | `queued` / `running` + current activity / the **full output** once finished / failure reason + partial output. Checking a terminal run **collects** it: the output is returned once and the run leaves the registry |
|
|
201
|
+
| `subagent_steer(id, message)` | Queue a mid-run correction into one running run (typically right after a check revealed it heading down a wrong path) | Confirmation that the steer is queued — delivered after the child's current tool batch, before its next LLM call; the run keeps its progress |
|
|
192
202
|
| `subagent_cancel(id, reason?)` | Kill one live (queued/running) run | Confirmation with the partial-output size — the run settles as `cancelled` (warning styling, same family as timeout/budget) with the reason in its error message; the partial output stays in the registry for `subagent_check` to collect |
|
|
193
203
|
|
|
194
204
|
Typical flow:
|
|
@@ -220,6 +230,17 @@ Semantics worth knowing:
|
|
|
220
230
|
- **Top-level only:** nested subagents cannot delegate in the background (a subagent process exits when its task finishes, which would orphan the run).
|
|
221
231
|
- The run registry lives in the pi process: a `/reload` or restart orphans in-flight background runs (their ids stop resolving). `/subagent:status` lists every registered run (active + collected) and its current state.
|
|
222
232
|
|
|
233
|
+
### Steering a running subagent
|
|
234
|
+
|
|
235
|
+
Steering queues a correction into a running subagent without killing it — the middle ground between waiting it out and cancelling. The message is delivered after the child finishes its current tool batch, before its next LLM call, so the run keeps its progress and can change course. It is a suggestion injected between turns, not an interrupt: the child may comply immediately, finish what it was doing first, or ignore poor instructions entirely — to actually stop a run, cancel it.
|
|
236
|
+
|
|
237
|
+
There are two channels into the same mechanism:
|
|
238
|
+
|
|
239
|
+
- **The model** calls `subagent_steer(id, message)` — typically right after a `subagent_check` snapshot revealed the run heading down a wrong path (check → steer → check again later).
|
|
240
|
+
- **The user** types into the input box of `/subagent:view`, targeting the focused run. Every accepted steer also appears in the run's activity feed as an `↩ steer:` entry, so whoever watches the view sees what was injected and when.
|
|
241
|
+
|
|
242
|
+
Queued steers are visible in neither wait nor check results — they shape the run's subsequent behavior, not its transcript.
|
|
243
|
+
|
|
223
244
|
### Background TUI display
|
|
224
245
|
|
|
225
246
|
Each tool row renders one aspect of the same decomposition the foreground row shows all at once (input · process · result · usage):
|
|
@@ -245,7 +266,7 @@ Hand the subagent precise context — selected code, a prior delegate's result,
|
|
|
245
266
|
}
|
|
246
267
|
```
|
|
247
268
|
|
|
248
|
-
The stored/displayed task stays as the original `task`.
|
|
269
|
+
The stored/displayed task stays as the original `task`. `context` is delivered as a `<context>` block ahead of the task in the child's initial prompt — the prompt travels over stdin, so size is not argv-bound and no spill file is involved.
|
|
249
270
|
|
|
250
271
|
#### `files` (reference paths)
|
|
251
272
|
|
|
@@ -257,7 +278,7 @@ The stored/displayed task stays as the original `task`. When small, `context` in
|
|
|
257
278
|
}
|
|
258
279
|
```
|
|
259
280
|
|
|
260
|
-
Each path is injected as an independent
|
|
281
|
+
Each path is injected as an independent `<file name="...">` block in the child's initial prompt — the same wrap pi applies to `@file` arguments. **File contents stay out of your context window** — you pass only the paths; this process reads the bytes off disk and pipes them straight to the child. Prefer this over pasting file contents into `context`, since the child receives the content on its first turn without spending a tool call to read it.
|
|
261
282
|
|
|
262
283
|
### Budget enforcement
|
|
263
284
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d3ara1n/pi-subagent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Role-based subagent orchestration for pi — delegates tasks to specialized pi child processes with configurable model roles",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/index.ts
CHANGED
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
import { startSubagentRun, type RunHandle } from "./run.ts";
|
|
46
46
|
import { buildInboxReminder, injectReminder } from "./reminder.ts";
|
|
47
47
|
import { renderDelegateCall, renderDelegateResult } from "./render.ts";
|
|
48
|
+
import { createViewPanel } from "./view.ts";
|
|
48
49
|
import {
|
|
49
50
|
renderBackgroundDelegateCall,
|
|
50
51
|
renderBackgroundDelegateResult,
|
|
@@ -634,6 +635,57 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
634
635
|
renderResult: renderCheckResult,
|
|
635
636
|
});
|
|
636
637
|
|
|
638
|
+
pi.registerTool({
|
|
639
|
+
name: "subagent_steer",
|
|
640
|
+
label: "Steer a running background subagent",
|
|
641
|
+
description:
|
|
642
|
+
"Queue a mid-run correction into ONE running background subagent — typically right after subagent_check showed it heading down a wrong path. The message is delivered after the child finishes its current tool batch, before its next LLM call; the run keeps its progress (unlike cancel). Only running runs accept steering; queued runs reject it, and terminal runs are collected by check instead. Typical flow: check → steer → check again later.",
|
|
643
|
+
promptSnippet: "Send a mid-run correction to a background subagent",
|
|
644
|
+
parameters: Type.Object({
|
|
645
|
+
id: Type.String({ description: "Run id returned by a background delegate call" }),
|
|
646
|
+
message: Type.String({
|
|
647
|
+
description:
|
|
648
|
+
"The correction. Concise and imperative — it lands mid-run, between the child's turns.",
|
|
649
|
+
}),
|
|
650
|
+
}),
|
|
651
|
+
|
|
652
|
+
async execute(_toolCallId, params) {
|
|
653
|
+
const run = backgroundRuns.get(params.id);
|
|
654
|
+
if (!run) {
|
|
655
|
+
const collected = collectedRuns.get(params.id);
|
|
656
|
+
if (collected) {
|
|
657
|
+
throw new Error(
|
|
658
|
+
`${params.id} (${collected.role}) was already collected — nothing left to steer. Delegate a new run if a correction is still needed.`,
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
|
|
662
|
+
throw new Error(
|
|
663
|
+
`Unknown subagent id: ${params.id}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
if (run.state === "queued") {
|
|
667
|
+
throw new Error(
|
|
668
|
+
`${params.id} (${run.role}) is still queued for a concurrency slot — steer once it is running.`,
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
if (run.state !== "running") {
|
|
672
|
+
throw new Error(
|
|
673
|
+
`${params.id} (${run.role}) is ${run.state} — only running runs can be steered. subagent_check(${params.id}) returns its result.`,
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
run.steer(params.message);
|
|
677
|
+
return {
|
|
678
|
+
content: [
|
|
679
|
+
{
|
|
680
|
+
type: "text",
|
|
681
|
+
text: `Steer queued for ${params.id} (${run.role}) — delivered after its current tool batch. Verify the effect with subagent_check later.`,
|
|
682
|
+
},
|
|
683
|
+
],
|
|
684
|
+
details: { id: params.id, role: run.role },
|
|
685
|
+
};
|
|
686
|
+
},
|
|
687
|
+
});
|
|
688
|
+
|
|
637
689
|
pi.registerTool({
|
|
638
690
|
name: "subagent_cancel",
|
|
639
691
|
label: "Cancel a background subagent",
|
|
@@ -696,6 +748,38 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
696
748
|
renderResult: renderCancelResult,
|
|
697
749
|
});
|
|
698
750
|
|
|
751
|
+
pi.registerCommand("subagent:view", {
|
|
752
|
+
description: "Open the live subagent activity view (watch progress, steer runs)",
|
|
753
|
+
handler: async (_args, ctx) => {
|
|
754
|
+
// Union of every known run: background registry (until collected) plus
|
|
755
|
+
// live in-flight runs (foreground delegate calls included). Dedupe by id —
|
|
756
|
+
// background runs appear in both.
|
|
757
|
+
const runsProvider = () => {
|
|
758
|
+
const seen = new Set<string>();
|
|
759
|
+
const out: RunHandle[] = [];
|
|
760
|
+
for (const r of [...backgroundRuns.values(), ...liveRuns]) {
|
|
761
|
+
if (!seen.has(r.id)) {
|
|
762
|
+
seen.add(r.id);
|
|
763
|
+
out.push(r);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return out;
|
|
767
|
+
};
|
|
768
|
+
if (runsProvider().length === 0) {
|
|
769
|
+
ctx.ui.notify("No subagent runs yet.", "info");
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
await ctx.ui.custom(
|
|
773
|
+
(tui, theme, _keybindings, done) =>
|
|
774
|
+
createViewPanel(runsProvider, tui, theme, () => done(undefined)),
|
|
775
|
+
{
|
|
776
|
+
overlay: true,
|
|
777
|
+
overlayOptions: { anchor: "center", width: "90%", maxHeight: "85%" },
|
|
778
|
+
},
|
|
779
|
+
);
|
|
780
|
+
},
|
|
781
|
+
});
|
|
782
|
+
|
|
699
783
|
pi.registerCommand("subagent:doctor", {
|
|
700
784
|
description: "Diagnose pi-subagent configuration and dependencies",
|
|
701
785
|
handler: async (_args, ctx) => {
|
package/src/run.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
FallbackFrom,
|
|
25
25
|
RunState,
|
|
26
26
|
SubagentConfig,
|
|
27
|
+
SubagentControl,
|
|
27
28
|
SubagentResult,
|
|
28
29
|
SubagentRole,
|
|
29
30
|
} from "./types.ts";
|
|
@@ -59,6 +60,8 @@ export interface RunHandle {
|
|
|
59
60
|
readonly promise: Promise<SubagentResult>;
|
|
60
61
|
/** Abort the run — no-op after settle. Tool-cancellation and session-shutdown reaping both funnel here. */
|
|
61
62
|
abort(reason?: string): void;
|
|
63
|
+
/** Queue a steering message into the running child (RPC stdin). No-op when queued/settled. */
|
|
64
|
+
steer(message: string): void;
|
|
62
65
|
/** Get notified on every frame change. Returns an unsubscribe function. */
|
|
63
66
|
subscribe(fn: () => void): () => void;
|
|
64
67
|
}
|
|
@@ -114,6 +117,8 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
114
117
|
let result: SubagentResult | undefined;
|
|
115
118
|
let thrown: Error | undefined;
|
|
116
119
|
let settled = false;
|
|
120
|
+
/** Live stdin channel of the current spawn attempt (replaced on fallback retry). */
|
|
121
|
+
let control: SubagentControl | undefined;
|
|
117
122
|
let abortReason: string | undefined;
|
|
118
123
|
const controller = new AbortController();
|
|
119
124
|
const onCallerAbort = () => controller.abort();
|
|
@@ -181,6 +186,10 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
181
186
|
if (reason) abortReason = reason;
|
|
182
187
|
controller.abort();
|
|
183
188
|
},
|
|
189
|
+
steer(message: string) {
|
|
190
|
+
if (settled || currentState !== "running") return;
|
|
191
|
+
control?.steer(message);
|
|
192
|
+
},
|
|
184
193
|
promise,
|
|
185
194
|
};
|
|
186
195
|
|
|
@@ -291,6 +300,9 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
291
300
|
depth: opts.depth,
|
|
292
301
|
signal: controller.signal,
|
|
293
302
|
onProgress: emitProgress,
|
|
303
|
+
onControl: (c) => {
|
|
304
|
+
control = c;
|
|
305
|
+
},
|
|
294
306
|
});
|
|
295
307
|
|
|
296
308
|
// Retry with fallback role on provider errors (quota, auth, timeout, etc.)
|
|
@@ -323,6 +335,9 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
323
335
|
depth: opts.depth,
|
|
324
336
|
signal: controller.signal,
|
|
325
337
|
onProgress: emitProgress,
|
|
338
|
+
onControl: (c) => {
|
|
339
|
+
control = c;
|
|
340
|
+
},
|
|
326
341
|
});
|
|
327
342
|
runResult.fallbackFrom = fallbackFrom;
|
|
328
343
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for spawn-side pure logic.
|
|
3
|
+
*
|
|
4
|
+
* node --test packages/pi-subagent/src/spawn.test.ts
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { test, describe } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { composeInitialMessage } from "./spawn.ts";
|
|
13
|
+
|
|
14
|
+
describe("composeInitialMessage", () => {
|
|
15
|
+
test("wraps reference files in <file> blocks ahead of context and task", async () => {
|
|
16
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-sub-test-"));
|
|
17
|
+
try {
|
|
18
|
+
const fileA = path.join(dir, "a.md");
|
|
19
|
+
fs.writeFileSync(fileA, "alpha content");
|
|
20
|
+
const message = await composeInitialMessage([fileA], "some ctx", "do the thing");
|
|
21
|
+
assert.equal(
|
|
22
|
+
message,
|
|
23
|
+
`<file name="${fileA}">\nalpha content\n</file>\n\n<context>\nsome ctx\n</context>\n\n<task>\ndo the thing\n</task>`,
|
|
24
|
+
);
|
|
25
|
+
} finally {
|
|
26
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("omits absent channels and preserves relative block order", async () => {
|
|
31
|
+
const message = await composeInitialMessage(undefined, undefined, "only a task");
|
|
32
|
+
assert.equal(message, "<task>\nonly a task\n</task>");
|
|
33
|
+
const ctxOnly = await composeInitialMessage(undefined, "ctx body", "");
|
|
34
|
+
assert.equal(ctxOnly, "<context>\nctx body\n</context>");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("unreadable files degrade to a placeholder instead of failing the run", async () => {
|
|
38
|
+
const missing = path.join(os.tmpdir(), "pi-sub-test-does-not-exist.md");
|
|
39
|
+
const message = await composeInitialMessage([missing], undefined, "t");
|
|
40
|
+
assert.match(message, /^\[?<file name="/);
|
|
41
|
+
assert.match(message, /failed to read file/);
|
|
42
|
+
assert.match(message, /\n\n<task>\nt\n<\/task>$/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("blank (whitespace-only) context is dropped", async () => {
|
|
46
|
+
const message = await composeInitialMessage(undefined, " \n\t", "t");
|
|
47
|
+
assert.equal(message, "<task>\nt\n</task>");
|
|
48
|
+
});
|
|
49
|
+
});
|
package/src/spawn.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Spawn a pi child process and collect structured output with real-time progress.
|
|
3
3
|
*
|
|
4
|
-
* Uses pi's --mode
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Uses pi's --mode rpc: the child streams agent events on stdout and accepts
|
|
5
|
+
* JSON commands on stdin (initial prompt, mid-run steering). Fires onProgress
|
|
6
|
+
* on each event for streaming TUI updates; the message stream is parsed for
|
|
7
|
+
* usage/output extraction, with thinking blocks, tool calls, and streamed
|
|
8
|
+
* assistant text mirrored into the activity log for rendering.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
@@ -12,10 +13,7 @@ import * as fs from "node:fs";
|
|
|
12
13
|
import * as os from "node:os";
|
|
13
14
|
import * as path from "node:path";
|
|
14
15
|
import { fileURLToPath } from "node:url";
|
|
15
|
-
import type { SubagentMessage, SubagentResult } from "./types.ts";
|
|
16
|
-
|
|
17
|
-
/** Max chars for an inline channel block (context or task) before it spills to a temp @file. */
|
|
18
|
-
const INLINE_LIMIT = 8000;
|
|
16
|
+
import type { SubagentControl, SubagentMessage, SubagentResult } from "./types.ts";
|
|
19
17
|
|
|
20
18
|
const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent";
|
|
21
19
|
|
|
@@ -133,6 +131,36 @@ export function getPiInvocation(args: string[]): { command: string; args: string
|
|
|
133
131
|
return { command: "pi", args };
|
|
134
132
|
}
|
|
135
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Compose the initial RPC prompt message: reference files as <file> blocks,
|
|
136
|
+
* then context and task as structured tags — the same shape the child saw in
|
|
137
|
+
* json mode (@file arguments wrapped by pi's processFileArguments, followed by
|
|
138
|
+
* the inline message body).
|
|
139
|
+
*
|
|
140
|
+
* @internal — exported for testing.
|
|
141
|
+
*/
|
|
142
|
+
export async function composeInitialMessage(
|
|
143
|
+
files: string[] | undefined,
|
|
144
|
+
context: string | undefined,
|
|
145
|
+
task: string,
|
|
146
|
+
): Promise<string> {
|
|
147
|
+
const parts: string[] = [];
|
|
148
|
+
if (files) {
|
|
149
|
+
for (const f of files) {
|
|
150
|
+
let content: string;
|
|
151
|
+
try {
|
|
152
|
+
content = await fs.promises.readFile(f, "utf-8");
|
|
153
|
+
} catch {
|
|
154
|
+
content = `(failed to read file: ${f})`;
|
|
155
|
+
}
|
|
156
|
+
parts.push(`<file name="${f}">\n${content}\n</file>`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (context?.trim()) parts.push(`<context>\n${context}\n</context>`);
|
|
160
|
+
if (task.trim()) parts.push(`<task>\n${task}\n</task>`);
|
|
161
|
+
return parts.join("\n\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
136
164
|
/**
|
|
137
165
|
* Spawn a pi child process with the given model and configuration.
|
|
138
166
|
* Fires onProgress on each JSON event for streaming TUI updates.
|
|
@@ -153,7 +181,7 @@ export async function spawnSubagent(
|
|
|
153
181
|
systemPrompt?: string;
|
|
154
182
|
/** Extra context delivered as a separate channel from the task. */
|
|
155
183
|
context?: string;
|
|
156
|
-
/** Reference
|
|
184
|
+
/** Reference files injected as <file> blocks in the initial prompt (child reads them directly). */
|
|
157
185
|
contextFiles?: string[];
|
|
158
186
|
subagentRoles?: string[];
|
|
159
187
|
timeoutMs?: number;
|
|
@@ -164,6 +192,8 @@ export async function spawnSubagent(
|
|
|
164
192
|
maxCost?: number;
|
|
165
193
|
signal?: AbortSignal;
|
|
166
194
|
onProgress?: (update: Partial<SubagentResult>) => void;
|
|
195
|
+
/** Called once the child process exists — exposes the stdin steering channel. */
|
|
196
|
+
onControl?: (control: SubagentControl) => void;
|
|
167
197
|
},
|
|
168
198
|
): Promise<SubagentResult> {
|
|
169
199
|
const result: SubagentResult = {
|
|
@@ -202,8 +232,9 @@ export async function spawnSubagent(
|
|
|
202
232
|
let tmpDir: string | null = null;
|
|
203
233
|
|
|
204
234
|
try {
|
|
205
|
-
// Build CLI args
|
|
206
|
-
|
|
235
|
+
// Build CLI args. RPC mode (not json): stdin carries the initial prompt and
|
|
236
|
+
// mid-run steering commands; stdout streams the same agent events.
|
|
237
|
+
const args: string[] = ["--mode", "rpc", "--no-session", "--model", modelRef];
|
|
207
238
|
|
|
208
239
|
if (options.thinking) {
|
|
209
240
|
args.push("--thinking", options.thinking);
|
|
@@ -213,8 +244,9 @@ export async function spawnSubagent(
|
|
|
213
244
|
args.push("--tools", options.tools.join(","));
|
|
214
245
|
}
|
|
215
246
|
|
|
216
|
-
//
|
|
217
|
-
//
|
|
247
|
+
// Scratch dir handed to the child as PI_SUBAGENT_TMPDIR for its bash work
|
|
248
|
+
// (e.g. git clone). The initial prompt goes over stdin, so there is no
|
|
249
|
+
// argv length limit and no spill-to-tempfile channel anymore.
|
|
218
250
|
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
|
|
219
251
|
|
|
220
252
|
// ── System prompt channel: inline text via --append-system-prompt ──
|
|
@@ -259,52 +291,13 @@ export async function spawnSubagent(
|
|
|
259
291
|
].join("\n"),
|
|
260
292
|
);
|
|
261
293
|
|
|
262
|
-
// ──
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const ctxPath = path.join(tmpDir, "context.md");
|
|
270
|
-
await fs.promises.writeFile(ctxPath, options.context, { encoding: "utf-8", mode: 0o600 });
|
|
271
|
-
args.push(`@${ctxPath}`);
|
|
272
|
-
} else {
|
|
273
|
-
contextInline = true;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// ── Reference files channel: each as an independent @ argument ──
|
|
278
|
-
// pi reads each and wraps in <file name="...">. Content never enters the
|
|
279
|
-
// parent model's context — the child reads it directly.
|
|
280
|
-
if (options.contextFiles) {
|
|
281
|
-
for (const f of options.contextFiles) {
|
|
282
|
-
args.push(`@${f}`);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// ── Task channel: always inline, always the final block ──
|
|
287
|
-
// The task is an instruction, not reference material — it stays inline so the
|
|
288
|
-
// child sees it as the primary directive. A pathologically long task spills.
|
|
289
|
-
let taskInline = true;
|
|
290
|
-
if (task.length > INLINE_LIMIT) {
|
|
291
|
-
const taskPath = path.join(tmpDir, "task.md");
|
|
292
|
-
await fs.promises.writeFile(taskPath, task, { encoding: "utf-8", mode: 0o600 });
|
|
293
|
-
args.push(`@${taskPath}`);
|
|
294
|
-
taskInline = false;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// ── Compose the message body (inline context + task) ──
|
|
298
|
-
// @file args are injected by pi BEFORE this message (buildInitialMessage),
|
|
299
|
-
// so the final shape the child sees is:
|
|
300
|
-
// [<file>...spilled context / reference files...</file>]
|
|
301
|
-
// [<context>...inline context...</context>]
|
|
302
|
-
// [<task>...task...</task>]
|
|
303
|
-
const messageParts: string[] = [];
|
|
304
|
-
if (contextInline) messageParts.push(`<context>\n${options.context}\n</context>`);
|
|
305
|
-
if (taskInline) messageParts.push(`<task>\n${task}\n</task>`);
|
|
306
|
-
const message = messageParts.join("\n\n");
|
|
307
|
-
if (message) args.push(message);
|
|
294
|
+
// ── Initial prompt channel: one RPC prompt command over stdin ──
|
|
295
|
+
// RPC mode rejects @file argv, so reference files are inlined here as
|
|
296
|
+
// <file name="..."> blocks — the same wrap pi applies to @file arguments
|
|
297
|
+
// (processFileArguments). Content still never enters the parent model's
|
|
298
|
+
// context; this process reads the bytes off disk and pipes them straight
|
|
299
|
+
// to the child.
|
|
300
|
+
const initialMessage = await composeInitialMessage(options.contextFiles, options.context, task);
|
|
308
301
|
|
|
309
302
|
// Spawn process
|
|
310
303
|
const invocation = getPiInvocation(args);
|
|
@@ -313,6 +306,15 @@ export async function spawnSubagent(
|
|
|
313
306
|
let wasTimeout = false;
|
|
314
307
|
let buffer = "";
|
|
315
308
|
|
|
309
|
+
/** Serialize one JSONL command frame for the child's stdin. */
|
|
310
|
+
const sendCommand = (command: Record<string, unknown>): void => {
|
|
311
|
+
try {
|
|
312
|
+
proc?.stdin?.write(`${JSON.stringify(command)}\n`);
|
|
313
|
+
} catch {
|
|
314
|
+
/* child gone — nothing to steer */
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
316
318
|
const emitProgress = () => {
|
|
317
319
|
options.onProgress?.({
|
|
318
320
|
output: result.output,
|
|
@@ -326,9 +328,18 @@ export async function spawnSubagent(
|
|
|
326
328
|
};
|
|
327
329
|
|
|
328
330
|
let thinkingCounter = 0;
|
|
331
|
+
let textCounter = 0;
|
|
329
332
|
// O(1) lookup from toolCallId → activityLog index.
|
|
330
333
|
const toolCallIndex = new Map<string, number>();
|
|
331
334
|
|
|
335
|
+
// Streamed text deltas bypass the per-event emitProgress (copying the whole
|
|
336
|
+
// activityLog per token is quadratic); a time gate keeps live frames fresh
|
|
337
|
+
// enough for the :view overlay without flooding the frame pipeline.
|
|
338
|
+
let lastDeltaEmit = 0;
|
|
339
|
+
const DELTA_EMIT_INTERVAL_MS = 200;
|
|
340
|
+
|
|
341
|
+
let steerCounter = 0;
|
|
342
|
+
|
|
332
343
|
// Normalized turn/cost budgets (0 = unlimited). Role overrides can arrive
|
|
333
344
|
// raw from settings.json, so negatives/non-finites normalize here.
|
|
334
345
|
const maxTurns = Number.isFinite(options.maxTurns) ? Math.max(0, options.maxTurns ?? 0) : 0;
|
|
@@ -353,6 +364,21 @@ export async function spawnSubagent(
|
|
|
353
364
|
return;
|
|
354
365
|
}
|
|
355
366
|
|
|
367
|
+
// RPC command acknowledgements ("response" lines) carry no agent state.
|
|
368
|
+
if (event.type === "response") return;
|
|
369
|
+
|
|
370
|
+
// RPC mode is a resident server: after the task completes (agent_end) the
|
|
371
|
+
// process would idle forever waiting for more stdin commands. We run one
|
|
372
|
+
// prompt per child, so ending our stdin side triggers its graceful
|
|
373
|
+
// shutdown (onInputEnd → runtime dispose → exit).
|
|
374
|
+
if (event.type === "agent_end") {
|
|
375
|
+
try {
|
|
376
|
+
proc?.stdin?.end();
|
|
377
|
+
} catch {
|
|
378
|
+
/* already gone */
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
356
382
|
if (event.type === "message_end" && event.message) {
|
|
357
383
|
const msg = event.message as SubagentMessage;
|
|
358
384
|
|
|
@@ -385,6 +411,7 @@ export async function spawnSubagent(
|
|
|
385
411
|
checkBudget();
|
|
386
412
|
}
|
|
387
413
|
|
|
414
|
+
freezeRunningTextEntries();
|
|
388
415
|
emitProgress();
|
|
389
416
|
}
|
|
390
417
|
|
|
@@ -444,6 +471,38 @@ export async function spawnSubagent(
|
|
|
444
471
|
}
|
|
445
472
|
}
|
|
446
473
|
emitProgress();
|
|
474
|
+
} else if (aev.type === "text_start") {
|
|
475
|
+
result.activityLog.push({
|
|
476
|
+
kind: "text",
|
|
477
|
+
id: `text-${textCounter++}`,
|
|
478
|
+
status: "running",
|
|
479
|
+
text: "",
|
|
480
|
+
});
|
|
481
|
+
emitProgress();
|
|
482
|
+
} else if (aev.type === "text_delta" && aev.delta) {
|
|
483
|
+
// Append to the still-running text entry; skip the frame copy per token.
|
|
484
|
+
for (let i = result.activityLog.length - 1; i >= 0; i--) {
|
|
485
|
+
const entry = result.activityLog[i];
|
|
486
|
+
if (entry.kind === "text" && entry.status === "running") {
|
|
487
|
+
entry.text = (entry.text ?? "") + aev.delta;
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
if (entry.kind === "text") break;
|
|
491
|
+
}
|
|
492
|
+
const now = Date.now();
|
|
493
|
+
if (now - lastDeltaEmit >= DELTA_EMIT_INTERVAL_MS) {
|
|
494
|
+
lastDeltaEmit = now;
|
|
495
|
+
emitProgress();
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
/** Freeze every still-running text entry — called on message_end (turn boundary). */
|
|
502
|
+
const freezeRunningTextEntries = () => {
|
|
503
|
+
for (const entry of result.activityLog) {
|
|
504
|
+
if (entry.kind === "text" && entry.status === "running") {
|
|
505
|
+
entry.status = "done";
|
|
447
506
|
}
|
|
448
507
|
}
|
|
449
508
|
};
|
|
@@ -555,12 +614,34 @@ export async function spawnSubagent(
|
|
|
555
614
|
cwd: options.cwd,
|
|
556
615
|
env: childEnv,
|
|
557
616
|
shell: false,
|
|
558
|
-
stdio: ["
|
|
617
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
559
618
|
});
|
|
560
619
|
proc = p;
|
|
561
620
|
liveChildren.add(p);
|
|
562
621
|
reapChildrenOnExit();
|
|
563
622
|
|
|
623
|
+
// Expose the stdin control channel (steering) to the owner. Writes are
|
|
624
|
+
// fire-and-forget: once the child is gone the try/catch in sendCommand
|
|
625
|
+
// swallows EPIPE.
|
|
626
|
+
options.onControl?.({
|
|
627
|
+
steer(message: string) {
|
|
628
|
+
if (processExited || terminationRequested) return;
|
|
629
|
+
sendCommand({ type: "steer", message });
|
|
630
|
+
// Mirror the steer into the activity feed so the :view overlay shows
|
|
631
|
+
// what was injected and when.
|
|
632
|
+
result.activityLog.push({
|
|
633
|
+
kind: "steer",
|
|
634
|
+
id: `steer-${steerCounter++}`,
|
|
635
|
+
status: "done",
|
|
636
|
+
text: message,
|
|
637
|
+
});
|
|
638
|
+
emitProgress();
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
// Kick off the run: RPC mode starts idle and waits for a prompt command.
|
|
643
|
+
sendCommand({ id: "init", type: "prompt", message: initialMessage });
|
|
644
|
+
|
|
564
645
|
p.stdout.on("data", (data: Buffer) => {
|
|
565
646
|
buffer += data.toString();
|
|
566
647
|
const lines = buffer.split("\n");
|
package/src/types.ts
CHANGED
|
@@ -74,15 +74,23 @@ export interface SubagentRole {
|
|
|
74
74
|
/** Status of an individual tool call within a subagent run. */
|
|
75
75
|
export type ToolStatus = "running" | "done" | "failed";
|
|
76
76
|
|
|
77
|
-
/** A single entry in the real-time activity log (thinking block or
|
|
77
|
+
/** A single entry in the real-time activity log (thinking block, tool call, streamed assistant text, or a user steer). */
|
|
78
78
|
export interface ActivityEntry {
|
|
79
|
-
kind: "thinking" | "toolCall";
|
|
80
|
-
/** Synthetic id (thinking-N) or the toolCallId from the event stream. */
|
|
79
|
+
kind: "thinking" | "toolCall" | "text" | "steer";
|
|
80
|
+
/** Synthetic id (thinking-N / text-N / steer-N) or the toolCallId from the event stream. */
|
|
81
81
|
id: string;
|
|
82
82
|
status: ToolStatus;
|
|
83
83
|
/** Tool name + args (toolCall only). */
|
|
84
84
|
toolName?: string;
|
|
85
85
|
args?: Record<string, any>;
|
|
86
|
+
/** Accumulated streamed assistant text or the injected steer message (kinds "text"/"steer"). Grows in place until message_end freezes text entries. */
|
|
87
|
+
text?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Live control channel into a spawned child process (wired to the RPC stdin). */
|
|
91
|
+
export interface SubagentControl {
|
|
92
|
+
/** Queue a steering message — delivered after the child's current tool batch, before its next LLM call. No-op after the process exits. */
|
|
93
|
+
steer(message: string): void;
|
|
86
94
|
}
|
|
87
95
|
|
|
88
96
|
/** Usage statistics from a subagent execution. */
|
package/src/utils.test.ts
CHANGED
|
@@ -40,8 +40,9 @@ import {
|
|
|
40
40
|
freezeFrame,
|
|
41
41
|
createThrottler,
|
|
42
42
|
terminalResultLine,
|
|
43
|
+
buildDisplayItems,
|
|
43
44
|
} from "./utils.ts";
|
|
44
|
-
import type { SubagentResult, SubagentRole } from "./types.ts";
|
|
45
|
+
import type { ActivityEntry, SubagentResult, SubagentRole } from "./types.ts";
|
|
45
46
|
|
|
46
47
|
/** Shared SubagentResult fixture. */
|
|
47
48
|
const baseResult = (overrides: Partial<SubagentResult> = {}): SubagentResult => ({
|
|
@@ -693,3 +694,38 @@ describe("background run helpers", () => {
|
|
|
693
694
|
assert.ok(frozen.graceMs! >= 3000 && frozen.graceMs! <= 3010, `graceMs ~3000, got ${frozen.graceMs}`);
|
|
694
695
|
});
|
|
695
696
|
});
|
|
697
|
+
|
|
698
|
+
describe("streamed-text activity entries", () => {
|
|
699
|
+
const textEntry = (status: ActivityEntry["status"], text?: string): ActivityEntry => ({
|
|
700
|
+
kind: "text",
|
|
701
|
+
id: "text-0",
|
|
702
|
+
status,
|
|
703
|
+
...(text !== undefined ? { text } : {}),
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
test("buildDisplayItems excludes text entries (view-only content)", () => {
|
|
707
|
+
const log: ActivityEntry[] = [
|
|
708
|
+
{ kind: "thinking", id: "thinking-0", status: "done" },
|
|
709
|
+
{ kind: "toolCall", id: "call-1", status: "done", toolName: "bash", args: { command: "ls" } },
|
|
710
|
+
textEntry("done", "partial answer"),
|
|
711
|
+
];
|
|
712
|
+
const items = buildDisplayItems(log);
|
|
713
|
+
assert.equal(items.length, 2);
|
|
714
|
+
assert.deepEqual(items.map((i) => i.type), ["thinking", "toolCall"]);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test("buildDisplayItems returns empty for a text-only log", () => {
|
|
718
|
+
assert.deepEqual(buildDisplayItems([textEntry("running", "streaming...")]), []);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("describeCurrentActivity reports responding for a running text entry", () => {
|
|
722
|
+
assert.equal(
|
|
723
|
+
describeCurrentActivity({ activityLog: [textEntry("running", "hello")] }),
|
|
724
|
+
"responding",
|
|
725
|
+
);
|
|
726
|
+
assert.equal(
|
|
727
|
+
describeCurrentActivity({ activityLog: [textEntry("done", "hello")] }),
|
|
728
|
+
"responded",
|
|
729
|
+
);
|
|
730
|
+
});
|
|
731
|
+
});
|
package/src/utils.ts
CHANGED
|
@@ -113,13 +113,19 @@ export type DisplayItem =
|
|
|
113
113
|
| { type: "toolCall"; name: string; args: Record<string, any>; status?: ToolStatus }
|
|
114
114
|
| { type: "thinking"; status?: ToolStatus };
|
|
115
115
|
|
|
116
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* Map the real-time activity log into renderable display items (in order).
|
|
118
|
+
* Streamed-text entries are excluded — they are the :view overlay's exclusive
|
|
119
|
+
* content; the inline tool rows stay as they were.
|
|
120
|
+
*/
|
|
117
121
|
export function buildDisplayItems(activityLog: ActivityEntry[]): DisplayItem[] {
|
|
118
|
-
return activityLog
|
|
119
|
-
a.kind === "thinking"
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
122
|
+
return activityLog
|
|
123
|
+
.filter((a) => a.kind === "thinking" || a.kind === "toolCall")
|
|
124
|
+
.map((a) =>
|
|
125
|
+
a.kind === "thinking"
|
|
126
|
+
? { type: "thinking", status: a.status }
|
|
127
|
+
: { type: "toolCall", name: a.toolName ?? "?", args: a.args ?? {}, status: a.status },
|
|
128
|
+
);
|
|
123
129
|
}
|
|
124
130
|
|
|
125
131
|
export function shortenPath(p: string): string {
|
|
@@ -483,6 +489,8 @@ export function describeCurrentActivity(r: { activityLog: ActivityEntry[] }): st
|
|
|
483
489
|
const last = r.activityLog[r.activityLog.length - 1];
|
|
484
490
|
if (!last) return "waiting for first event";
|
|
485
491
|
if (last.kind === "thinking") return last.status === "running" ? "thinking" : "thought";
|
|
492
|
+
if (last.kind === "text") return last.status === "running" ? "responding" : "responded";
|
|
493
|
+
if (last.kind === "steer") return "steered — awaiting next turn";
|
|
486
494
|
return formatToolCall(last.toolName ?? "?", last.args ?? {}, (_color, text) => text);
|
|
487
495
|
}
|
|
488
496
|
|
package/src/view.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /subagent:view — live activity overlay for subagent runs.
|
|
3
|
+
*
|
|
4
|
+
* Design: a continuous, append-only list. Every entry is static text plus a
|
|
5
|
+
* state bit; the only animated thing is the ellipsis on a running entry's
|
|
6
|
+
* suffix ("." → ".." → "..."). Finishing freezes an entry in place — its
|
|
7
|
+
* position never changes, only the icon flips. Multiple runs stack: one
|
|
8
|
+
* header line per run, entries flowing beneath it; concurrent tool calls are
|
|
9
|
+
* adjacent spinning lines. Streamed assistant text is the single growing
|
|
10
|
+
* element: it renders as the run's last line and freezes at message_end.
|
|
11
|
+
*
|
|
12
|
+
* Layout: a centered screen overlay (overlay:true) occupying most of the
|
|
13
|
+
* terminal, framed with a thin border. An embedded Editor accepts steering
|
|
14
|
+
* input for the focused run (Tab cycles targets); Enter queues the message
|
|
15
|
+
* through the run's RPC stdin channel — delivered after the child's current
|
|
16
|
+
* tool batch, before its next LLM call.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import {
|
|
21
|
+
Editor,
|
|
22
|
+
type Component,
|
|
23
|
+
type EditorTheme,
|
|
24
|
+
type Focusable,
|
|
25
|
+
Key,
|
|
26
|
+
matchesKey,
|
|
27
|
+
truncateToWidth,
|
|
28
|
+
visibleWidth,
|
|
29
|
+
} from "@earendil-works/pi-tui";
|
|
30
|
+
import type { RunHandle } from "./run.ts";
|
|
31
|
+
import type { ActivityEntry } from "./types.ts";
|
|
32
|
+
import {
|
|
33
|
+
formatThinking,
|
|
34
|
+
formatTimePart,
|
|
35
|
+
formatToolCall,
|
|
36
|
+
formatUsageStats,
|
|
37
|
+
runIcon,
|
|
38
|
+
statusStyle,
|
|
39
|
+
taskPreview,
|
|
40
|
+
} from "./utils.ts";
|
|
41
|
+
|
|
42
|
+
/** Max body lines kept visible; older entries roll off the top. */
|
|
43
|
+
const VIEWPORT_LINES = 26;
|
|
44
|
+
/** Animation tick for the running-entry ellipsis. */
|
|
45
|
+
const ANIMATION_INTERVAL_MS = 150;
|
|
46
|
+
|
|
47
|
+
type TuiLike = { requestRender(): void };
|
|
48
|
+
type Fg = (color: string, text: string) => string;
|
|
49
|
+
|
|
50
|
+
/** Pad a string with trailing spaces to a visible width (left-justified). */
|
|
51
|
+
function padRight(s: string, width: number): string {
|
|
52
|
+
const v = visibleWidth(s);
|
|
53
|
+
return v >= width ? s : s + " ".repeat(width - v);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Animated ellipsis suffix for running entries: ".", "..", "..." cycling. */
|
|
57
|
+
function dots(): string {
|
|
58
|
+
return ".".repeat(1 + (Math.floor(Date.now() / 300) % 3));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build the display list of runs for the panel: running/queued first, then
|
|
63
|
+
* finished, each group ordered by registry id.
|
|
64
|
+
*/
|
|
65
|
+
export function sortViewRuns(runs: RunHandle[]): RunHandle[] {
|
|
66
|
+
const rank = (r: RunHandle) => (r.state === "running" || r.state === "queued" ? 0 : 1);
|
|
67
|
+
return [...runs].sort((a, b) => rank(a) - rank(b) || a.id.localeCompare(b.id));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class SubagentViewPanel implements Component, Focusable {
|
|
71
|
+
focused = true;
|
|
72
|
+
|
|
73
|
+
private runsProvider: () => RunHandle[];
|
|
74
|
+
private theme: Theme;
|
|
75
|
+
private tui: TuiLike;
|
|
76
|
+
private close: () => void;
|
|
77
|
+
private editor: Editor;
|
|
78
|
+
/** Focused run (Tab cycles); the whole viewport belongs to it. */
|
|
79
|
+
private focusIndex = 0;
|
|
80
|
+
/** Transient feedback line ("steer sent to sub-N"), auto-clears. */
|
|
81
|
+
private flash = "";
|
|
82
|
+
private flashUntil = 0;
|
|
83
|
+
private timer?: ReturnType<typeof setInterval>;
|
|
84
|
+
|
|
85
|
+
constructor(
|
|
86
|
+
runsProvider: () => RunHandle[],
|
|
87
|
+
tui: TuiLike,
|
|
88
|
+
theme: Theme,
|
|
89
|
+
onClose: () => void,
|
|
90
|
+
) {
|
|
91
|
+
this.runsProvider = runsProvider;
|
|
92
|
+
this.tui = tui;
|
|
93
|
+
this.theme = theme;
|
|
94
|
+
this.close = onClose;
|
|
95
|
+
|
|
96
|
+
const editorTheme: EditorTheme = {
|
|
97
|
+
borderColor: (s) => theme.fg("accent", s),
|
|
98
|
+
selectList: {
|
|
99
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
100
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
101
|
+
description: (t) => theme.fg("muted", t),
|
|
102
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
103
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
this.editor = new Editor(tui as never, editorTheme);
|
|
107
|
+
this.editor.onSubmit = (value) => this.submitSteer(value);
|
|
108
|
+
|
|
109
|
+
// Drive the ellipsis animation while the panel is open.
|
|
110
|
+
this.timer = setInterval(() => {
|
|
111
|
+
try {
|
|
112
|
+
this.tui.requestRender();
|
|
113
|
+
} catch {
|
|
114
|
+
/* ignore */
|
|
115
|
+
}
|
|
116
|
+
}, ANIMATION_INTERVAL_MS);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private focusedRun(): RunHandle | undefined {
|
|
120
|
+
const runs = sortViewRuns(this.runsProvider());
|
|
121
|
+
if (runs.length === 0) return undefined;
|
|
122
|
+
if (this.focusIndex >= runs.length) this.focusIndex = 0;
|
|
123
|
+
return runs[this.focusIndex];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private submitSteer(value: string): void {
|
|
127
|
+
const text = value.trim();
|
|
128
|
+
if (!text) return;
|
|
129
|
+
const target = this.focusedRun();
|
|
130
|
+
if (!target || target.state !== "running") {
|
|
131
|
+
this.showFlash("focused run is not running — nothing to steer");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
target.steer(text);
|
|
135
|
+
this.editor.setText("");
|
|
136
|
+
this.showFlash(`steer queued for ${target.id} (${target.role})`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private showFlash(message: string): void {
|
|
140
|
+
this.flash = message;
|
|
141
|
+
this.flashUntil = Date.now() + 3000;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
handleInput(data: string): void {
|
|
145
|
+
if (matchesKey(data, Key.escape)) {
|
|
146
|
+
this.closePanel();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (matchesKey(data, Key.tab)) {
|
|
150
|
+
const n = sortViewRuns(this.runsProvider()).length;
|
|
151
|
+
if (n > 1) {
|
|
152
|
+
this.focusIndex = (this.focusIndex + 1) % n;
|
|
153
|
+
this.tui.requestRender();
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
this.editor.handleInput(data);
|
|
158
|
+
this.tui.requestRender();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private closePanel(): void {
|
|
162
|
+
if (this.timer) {
|
|
163
|
+
clearInterval(this.timer);
|
|
164
|
+
this.timer = undefined;
|
|
165
|
+
}
|
|
166
|
+
this.close();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Render one activity entry as a static line; running entries get the
|
|
170
|
+
* animated ellipsis suffix. */
|
|
171
|
+
private renderEntry(e: ActivityEntry, width: number, fg: Fg): string {
|
|
172
|
+
const indent = " ";
|
|
173
|
+
if (e.kind === "thinking") {
|
|
174
|
+
if (e.status === "running") {
|
|
175
|
+
return truncateToWidth(indent + fg("accent", `◇ thinking${dots()}`), width);
|
|
176
|
+
}
|
|
177
|
+
return truncateToWidth(indent + formatThinking(e.status, fg), width);
|
|
178
|
+
}
|
|
179
|
+
if (e.kind === "steer") {
|
|
180
|
+
const firstLine = (e.text ?? "").trimEnd().split("\n")[0] || "";
|
|
181
|
+
return truncateToWidth(indent + fg("accent", `↩ steer: ${firstLine}`), width);
|
|
182
|
+
}
|
|
183
|
+
if (e.kind === "text") {
|
|
184
|
+
const buffer = e.text ?? "";
|
|
185
|
+
const lastLine = buffer.trimEnd().split("\n").pop() ?? "";
|
|
186
|
+
const body = lastLine || "…";
|
|
187
|
+
if (e.status === "running") {
|
|
188
|
+
return truncateToWidth(indent + fg("accent", `¶ ${body}${dots()}`), width);
|
|
189
|
+
}
|
|
190
|
+
// Frozen: no ANSI at all — same plain terminal foreground as the main
|
|
191
|
+
// UI's output text (theme "text" is a grey var, not the default fg).
|
|
192
|
+
return truncateToWidth(indent + `¶ ${body}`, width);
|
|
193
|
+
}
|
|
194
|
+
const { prefix, color } = statusStyle(e.status, fg);
|
|
195
|
+
const suffix = e.status === "running" ? fg("accent", ` ${dots()}`) : "";
|
|
196
|
+
const line = prefix + formatToolCall(e.toolName ?? "?", e.args ?? {}, color) + suffix;
|
|
197
|
+
return truncateToWidth(indent + line, width);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
render(width: number): string[] {
|
|
201
|
+
const th = this.theme;
|
|
202
|
+
// Same adaptation render.ts uses: utils formatters take a loose Fg.
|
|
203
|
+
const fg = th.fg.bind(th) as unknown as Fg;
|
|
204
|
+
// Border frame: inner content lives at width-2.
|
|
205
|
+
const innerW = Math.max(20, width - 2);
|
|
206
|
+
const row = (content: string) => {
|
|
207
|
+
const fitted = truncateToWidth(content, innerW);
|
|
208
|
+
return th.fg("border", "│") + padRight(fitted, innerW) + th.fg("border", "│");
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const runs = sortViewRuns(this.runsProvider());
|
|
212
|
+
const lines: string[] = [];
|
|
213
|
+
|
|
214
|
+
const runningCount = runs.filter((r) => r.state === "running").length;
|
|
215
|
+
|
|
216
|
+
// ── Tab row: one cell per run; the focused one is highlighted. ──
|
|
217
|
+
if (runs.length > 0) {
|
|
218
|
+
const cells = runs.map((r, i) => {
|
|
219
|
+
const focused = r === this.focusedRun();
|
|
220
|
+
const label = `${runIcon(r.snapshot, fg)} ${r.id} ${r.role}`;
|
|
221
|
+
const styled = focused ? th.bg("selectedBg", fg("accent", label)) : fg("dim", label);
|
|
222
|
+
return focused ? styled : `[${styled}]`;
|
|
223
|
+
});
|
|
224
|
+
lines.push(
|
|
225
|
+
row(
|
|
226
|
+
`${fg("accent", th.bold("subagents"))} ${th.fg("dim", `${runningCount} running · ${runs.length} total · Tab switch`)} ` +
|
|
227
|
+
cells.join(th.fg("dim", " ")),
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
} else {
|
|
231
|
+
lines.push(row(fg("muted", "subagents: no runs.")));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Focused run: full viewport, tail-capped entries. ──
|
|
235
|
+
// Reserve room for tab row (1) + steer bar (2) + hint (1).
|
|
236
|
+
const budget = Math.max(3, VIEWPORT_LINES - 4);
|
|
237
|
+
const run = this.focusedRun();
|
|
238
|
+
if (run) {
|
|
239
|
+
const snap = run.snapshot;
|
|
240
|
+
const icon = runIcon(snap, fg);
|
|
241
|
+
const time = formatTimePart({ ...snap, exitCode: run.state === "queued" ? -1 : snap.exitCode });
|
|
242
|
+
const parts = [
|
|
243
|
+
`${icon} ${fg("accent", th.bold(run.id))}`,
|
|
244
|
+
fg("text", run.role),
|
|
245
|
+
fg("dim", taskPreview(run.task)),
|
|
246
|
+
time ? fg("dim", time) : "",
|
|
247
|
+
fg("dim", formatUsageStats(snap.usage, snap.model)),
|
|
248
|
+
].filter(Boolean);
|
|
249
|
+
lines.push(row(parts.join(th.fg("dim", " · "))));
|
|
250
|
+
const entries = snap.activityLog.map((entry) => this.renderEntry(entry, innerW, fg));
|
|
251
|
+
if (entries.length > budget) {
|
|
252
|
+
entries.splice(0, entries.length - budget);
|
|
253
|
+
lines.push(row(fg("muted", "⋮ earlier activity")));
|
|
254
|
+
}
|
|
255
|
+
for (const ln of entries.slice(0, budget)) lines.push(row(ln));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Steer bar ──
|
|
259
|
+
if (Date.now() < this.flashUntil) {
|
|
260
|
+
lines.push(row(fg("success", this.flash)));
|
|
261
|
+
} else {
|
|
262
|
+
const target = run && run.state === "running" ? `${run.id} (${run.role})` : null;
|
|
263
|
+
const label = target
|
|
264
|
+
? fg("accent", target)
|
|
265
|
+
: fg("dim", run ? `${run.id} not running` : "no runs");
|
|
266
|
+
lines.push(row(fg("dim", `steer → ${label}`)));
|
|
267
|
+
}
|
|
268
|
+
for (const el of this.editor.render(innerW)) {
|
|
269
|
+
lines.push(row(el));
|
|
270
|
+
}
|
|
271
|
+
lines.push(row(fg("dim", "Enter steer · Tab switch run · Esc close")));
|
|
272
|
+
|
|
273
|
+
// Frame.
|
|
274
|
+
return [
|
|
275
|
+
th.fg("border", `╭${"─".repeat(innerW)}╮`),
|
|
276
|
+
...lines,
|
|
277
|
+
th.fg("border", `╰${"─".repeat(innerW)}╯`),
|
|
278
|
+
];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
invalidate(): void {}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Wire the panel to the overlay lifecycle: the animation timer dies with the panel. */
|
|
285
|
+
export function createViewPanel(
|
|
286
|
+
runsProvider: () => RunHandle[],
|
|
287
|
+
tui: TuiLike,
|
|
288
|
+
theme: Theme,
|
|
289
|
+
onClose: () => void,
|
|
290
|
+
): SubagentViewPanel {
|
|
291
|
+
return new SubagentViewPanel(runsProvider, tui, theme, onClose);
|
|
292
|
+
}
|