@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 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`. When small, `context` inlines as a `<context>` block; when large (over 8,000 chars) it spills to a temp file injected via `@file`, so a large context never drags a short task into a spill.
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 `@file` attachment the subagent reads directly. **File contents stay out of your context window** — you pass only the paths. 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.
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.0",
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 json to get a JSON event stream. Fires onProgress on each
5
- * event for streaming TUI updates; the message stream is parsed for
6
- * usage/output extraction, with thinking blocks and tool calls mirrored into
7
- * the activity log for rendering.
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 file paths injected as independent @file args (child reads them directly). */
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
- const args: string[] = ["--mode", "json", "--no-session", "--model", modelRef];
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
- // Temp dir for: large-context/task spill files, and as PI_SUBAGENT_TMPDIR
217
- // for subagent bash work (e.g. git clone).
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
- // ── Context channel: independent size gate ──
236
- // Large context spills to @ctx.md (pi auto-wraps in <file>); small context
237
- // inlines as a structured <context> tag. Decoupled from the task gate so a
238
- // large context never drags a short task into a spill file.
239
- let contextInline = false;
240
- if (options.context && options.context.trim()) {
241
- if (options.context.length > INLINE_LIMIT) {
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: ["ignore", "pipe", "pipe"],
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 tool call). */
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
- /** Map the real-time activity log into renderable display items (in order). */
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.map((a) =>
119
- a.kind === "thinking"
120
- ? { type: "thinking", status: a.status }
121
- : { type: "toolCall", name: a.toolName ?? "?", args: a.args ?? {}, status: a.status },
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
+ }