@d3ara1n/pi-subagent 1.3.0 → 1.4.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 +11 -3
- 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 +168 -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
|
@@ -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,17 @@ This means:
|
|
|
54
54
|
|
|
55
55
|
| Command | Description |
|
|
56
56
|
|---------|-------------|
|
|
57
|
+
| `/subagent:view` | Open the live activity view: a continuous feed of every run's thinking, tool calls, and streamed output — with an input box to steer a running subagent mid-flight |
|
|
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 continuous, append-only list: each entry is static text with a state icon; running entries carry an animated ellipsis (`.` → `..` → `...`) and freeze in place when they finish — position never changes. Multiple runs stack (one header line per run); streamed assistant text grows in place as the run's last line and freezes at the turn boundary.
|
|
65
|
+
|
|
66
|
+
The bottom of the panel has a steer input box: type a correction and press Enter to queue it into the focused running subagent (Tab cycles targets when several are running). The message is delivered after the child finishes its current tool batch, before its next LLM call — the run keeps its progress. Esc closes the panel.
|
|
67
|
+
|
|
61
68
|
## Dependencies
|
|
62
69
|
|
|
63
70
|
- [`@d3ara1n/pi-model-roles`](../pi-model-roles) — model role resolution
|
|
@@ -189,6 +196,7 @@ Three execution properties, kept separate:
|
|
|
189
196
|
| `subagent_delegate(background: true)` | Start an async run | Just the id (`sub-N`) |
|
|
190
197
|
| `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
198
|
| `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 |
|
|
199
|
+
| `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
200
|
| `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
201
|
|
|
194
202
|
Typical flow:
|
|
@@ -245,7 +253,7 @@ Hand the subagent precise context — selected code, a prior delegate's result,
|
|
|
245
253
|
}
|
|
246
254
|
```
|
|
247
255
|
|
|
248
|
-
The stored/displayed task stays as the original `task`.
|
|
256
|
+
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
257
|
|
|
250
258
|
#### `files` (reference paths)
|
|
251
259
|
|
|
@@ -257,7 +265,7 @@ The stored/displayed task stays as the original `task`. When small, `context` in
|
|
|
257
265
|
}
|
|
258
266
|
```
|
|
259
267
|
|
|
260
|
-
Each path is injected as an independent
|
|
268
|
+
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
269
|
|
|
262
270
|
### Budget enforcement
|
|
263
271
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d3ara1n/pi-subagent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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 ──
|
|
@@ -231,53 +263,41 @@ export async function spawnSubagent(
|
|
|
231
263
|
"--append-system-prompt",
|
|
232
264
|
`<subagent_env>\nPI_SUBAGENT_TMPDIR=${tmpDir}\nAvailable as $PI_SUBAGENT_TMPDIR in bash. Use for git clone and scratch files.\n</subagent_env>`,
|
|
233
265
|
);
|
|
266
|
+
// Shared behavioral policy for EVERY subagent run — built-in roles and
|
|
267
|
+
// agentOverrides customs alike. Role prompts (roles.ts) shape WHAT a role
|
|
268
|
+
// does; this shapes HOW any subagent behaves when the task exceeds its
|
|
269
|
+
// actual capabilities: report the gap and stop instead of improvising
|
|
270
|
+
// workarounds until timeout.
|
|
271
|
+
args.push(
|
|
272
|
+
"--append-system-prompt",
|
|
273
|
+
[
|
|
274
|
+
"<subagent_policy>",
|
|
275
|
+
"Before attempting the task, check it against your actual capabilities in this",
|
|
276
|
+
"session — the tool list here is definitive.",
|
|
277
|
+
"- If the task needs a capability you do not have (web access, bash, file",
|
|
278
|
+
" writes, ...) or material that is not present locally or in the provided",
|
|
279
|
+
" context/files, it is out of scope for you. Do NOT improvise workarounds.",
|
|
280
|
+
'- "Cannot complete" means a capability or material gap — not "difficult" or',
|
|
281
|
+
' "uncertain". If it is merely hard, keep working within your tools.',
|
|
282
|
+
"- When you hit a genuine gap, stop early and return:",
|
|
283
|
+
" ## Cannot complete",
|
|
284
|
+
" - Missing: the capability or material that is absent",
|
|
285
|
+
" - Needed: what would complete the task",
|
|
286
|
+
" - Found: partial findings so far (optional)",
|
|
287
|
+
"",
|
|
288
|
+
'An early "cannot complete" report is a successful outcome; grinding on',
|
|
289
|
+
"impossible workarounds until timeout is the failure.",
|
|
290
|
+
"</subagent_policy>",
|
|
291
|
+
].join("\n"),
|
|
292
|
+
);
|
|
234
293
|
|
|
235
|
-
// ──
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const ctxPath = path.join(tmpDir, "context.md");
|
|
243
|
-
await fs.promises.writeFile(ctxPath, options.context, { encoding: "utf-8", mode: 0o600 });
|
|
244
|
-
args.push(`@${ctxPath}`);
|
|
245
|
-
} else {
|
|
246
|
-
contextInline = true;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// ── Reference files channel: each as an independent @ argument ──
|
|
251
|
-
// pi reads each and wraps in <file name="...">. Content never enters the
|
|
252
|
-
// parent model's context — the child reads it directly.
|
|
253
|
-
if (options.contextFiles) {
|
|
254
|
-
for (const f of options.contextFiles) {
|
|
255
|
-
args.push(`@${f}`);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// ── Task channel: always inline, always the final block ──
|
|
260
|
-
// The task is an instruction, not reference material — it stays inline so the
|
|
261
|
-
// child sees it as the primary directive. A pathologically long task spills.
|
|
262
|
-
let taskInline = true;
|
|
263
|
-
if (task.length > INLINE_LIMIT) {
|
|
264
|
-
const taskPath = path.join(tmpDir, "task.md");
|
|
265
|
-
await fs.promises.writeFile(taskPath, task, { encoding: "utf-8", mode: 0o600 });
|
|
266
|
-
args.push(`@${taskPath}`);
|
|
267
|
-
taskInline = false;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// ── Compose the message body (inline context + task) ──
|
|
271
|
-
// @file args are injected by pi BEFORE this message (buildInitialMessage),
|
|
272
|
-
// so the final shape the child sees is:
|
|
273
|
-
// [<file>...spilled context / reference files...</file>]
|
|
274
|
-
// [<context>...inline context...</context>]
|
|
275
|
-
// [<task>...task...</task>]
|
|
276
|
-
const messageParts: string[] = [];
|
|
277
|
-
if (contextInline) messageParts.push(`<context>\n${options.context}\n</context>`);
|
|
278
|
-
if (taskInline) messageParts.push(`<task>\n${task}\n</task>`);
|
|
279
|
-
const message = messageParts.join("\n\n");
|
|
280
|
-
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);
|
|
281
301
|
|
|
282
302
|
// Spawn process
|
|
283
303
|
const invocation = getPiInvocation(args);
|
|
@@ -286,6 +306,15 @@ export async function spawnSubagent(
|
|
|
286
306
|
let wasTimeout = false;
|
|
287
307
|
let buffer = "";
|
|
288
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
|
+
|
|
289
318
|
const emitProgress = () => {
|
|
290
319
|
options.onProgress?.({
|
|
291
320
|
output: result.output,
|
|
@@ -299,9 +328,18 @@ export async function spawnSubagent(
|
|
|
299
328
|
};
|
|
300
329
|
|
|
301
330
|
let thinkingCounter = 0;
|
|
331
|
+
let textCounter = 0;
|
|
302
332
|
// O(1) lookup from toolCallId → activityLog index.
|
|
303
333
|
const toolCallIndex = new Map<string, number>();
|
|
304
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
|
+
|
|
305
343
|
// Normalized turn/cost budgets (0 = unlimited). Role overrides can arrive
|
|
306
344
|
// raw from settings.json, so negatives/non-finites normalize here.
|
|
307
345
|
const maxTurns = Number.isFinite(options.maxTurns) ? Math.max(0, options.maxTurns ?? 0) : 0;
|
|
@@ -326,6 +364,21 @@ export async function spawnSubagent(
|
|
|
326
364
|
return;
|
|
327
365
|
}
|
|
328
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
|
+
|
|
329
382
|
if (event.type === "message_end" && event.message) {
|
|
330
383
|
const msg = event.message as SubagentMessage;
|
|
331
384
|
|
|
@@ -358,6 +411,7 @@ export async function spawnSubagent(
|
|
|
358
411
|
checkBudget();
|
|
359
412
|
}
|
|
360
413
|
|
|
414
|
+
freezeRunningTextEntries();
|
|
361
415
|
emitProgress();
|
|
362
416
|
}
|
|
363
417
|
|
|
@@ -417,6 +471,38 @@ export async function spawnSubagent(
|
|
|
417
471
|
}
|
|
418
472
|
}
|
|
419
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";
|
|
420
506
|
}
|
|
421
507
|
}
|
|
422
508
|
};
|
|
@@ -528,12 +614,34 @@ export async function spawnSubagent(
|
|
|
528
614
|
cwd: options.cwd,
|
|
529
615
|
env: childEnv,
|
|
530
616
|
shell: false,
|
|
531
|
-
stdio: ["
|
|
617
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
532
618
|
});
|
|
533
619
|
proc = p;
|
|
534
620
|
liveChildren.add(p);
|
|
535
621
|
reapChildrenOnExit();
|
|
536
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
|
+
|
|
537
645
|
p.stdout.on("data", (data: Buffer) => {
|
|
538
646
|
buffer += data.toString();
|
|
539
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
|
+
}
|