@d3ara1n/pi-subagent 1.3.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Role-based subagent orchestration for [pi](https://github.com/earendil-works/pi).
4
4
 
5
- Provides a `subagent_delegate` tool that lets the main model offload tasks to specialized pi child processes with configurable model roles, real-time TUI progress, and AI-generated summaries. Runs can be foreground (blocking) or background (asynchronous, collected later via `subagent_wait`/`subagent_check`, cancellable via `subagent_cancel`).
5
+ Provides a `subagent_delegate` tool that lets the main model offload tasks to specialized pi child processes with configurable model roles, real-time TUI progress, and AI-generated summaries. Runs can be foreground (blocking) or background (asynchronous, collected later via `subagent_wait`/`subagent_check`, cancellable via `subagent_cancel`). A centered live view (`/subagent:view`) shows every run's activity feed as it happens, and mid-run corrections can be queued into a running subagent from the view's input box or via `subagent_steer`.
6
6
 
7
7
  ## Design Philosophy
8
8
 
@@ -23,7 +23,7 @@ This means:
23
23
 
24
24
  1. Main model calls the `subagent_delegate` tool with a role and task description
25
25
  2. The extension resolves the role to a model via pi-model-roles
26
- 3. Spawns an isolated pi child process with the configured model, tools, and system prompt
26
+ 3. Spawns an isolated pi child process in RPC mode (`--mode rpc`) with the configured model, tools, and system prompt — agent events stream back over stdout while stdin carries the initial prompt and mid-run steering commands
27
27
  4. **Real-time TUI progress** shows tool calls, turns, and elapsed time as the subagent runs
28
28
  5. After completion, an **AI-generated one-line summary** is produced for compact display
29
29
  6. Returns the result to the main model with usage statistics (turns, tokens, cost)
@@ -54,10 +54,19 @@ This means:
54
54
 
55
55
  | Command | Description |
56
56
  |---------|-------------|
57
+ | `/subagent:view` | Open the live view: a tabbed overlay showing one run's full activity feed at a time, with an input box to steer the focused run |
57
58
  | `/subagent:doctor` | Diagnose pi invocation, model-role resolution, configuration, and role references |
58
59
  | `/subagent:status` | List background runs (active + collected) and their current state |
59
60
  | `/subagent:cancel <id\|all> [reason]` | Cancel a live background run (or every live run); the optional reason is recorded with the run |
60
61
 
62
+ ### Live view (`/subagent:view`)
63
+
64
+ A centered overlay covering most of the screen. A tab row across the top lists every run (icon · id · role); `Tab` cycles the focused run, and the rest of the viewport belongs to it alone — its complete activity feed, tail-capped to the visible area with a `⋮ earlier activity` marker when older entries roll off.
65
+
66
+ The feed itself is a continuous, append-only list: each entry is static text with a state icon, and the only animated thing is the ellipsis on a running entry (`.` → `..` → `...`). Finishing freezes an entry in place — its position never changes, only the icon flips. Streamed assistant text grows in place as the run's last line and settles into plain terminal-colored text at the turn boundary. Both foreground and background runs appear here; a foreground run stays listed while its delegate call blocks the main agent.
67
+
68
+ Below the feed sits a steer input box: type a correction and press Enter to queue it into the focused run (only while it is running). The message appears immediately in the feed as an `↩ steer:` entry and is delivered to the child after its current tool batch, before its next LLM call — the run keeps its progress. `Esc` closes the overlay.
69
+
61
70
  ## Dependencies
62
71
 
63
72
  - [`@d3ara1n/pi-model-roles`](../pi-model-roles) — model role resolution
@@ -182,13 +191,14 @@ Three execution properties, kept separate:
182
191
 
183
192
  - **Foreground** (default): the call blocks until the run finishes and returns the final output directly. (Under the hood foreground and background share one async run engine — foreground is simply background-but-blocking.)
184
193
  - **Parallel**: multiple `subagent_delegate` calls in one turn run concurrently — foreground and background alike, no special flag.
185
- - **Background** (`background: true`): non-blocking — `subagent_delegate` returns immediately with a run id. Use it when you have your own work to do (or a discussion with the user to continue) while the run executes; three companion tools manage the outcome:
194
+ - **Background** (`background: true`): non-blocking — `subagent_delegate` returns immediately with a run id. Use it when you have your own work to do (or a discussion with the user to continue) while the run executes; four companion tools manage the outcome:
186
195
 
187
196
  | Tool | Purpose | Returns to the model |
188
197
  |------|---------|---------------------|
189
198
  | `subagent_delegate(background: true)` | Start an async run | Just the id (`sub-N`) |
190
199
  | `subagent_wait(ids?, timeout_ms?)` | Block until **all** listed runs finish (omit `ids` for all current background runs) | Statuses only, one `id (role): finished/failed` line per run — never results; errors when the timeout hits with runs unfinished |
191
200
  | `subagent_check(id)` | One-shot snapshot of a single run | `queued` / `running` + current activity / the **full output** once finished / failure reason + partial output. Checking a terminal run **collects** it: the output is returned once and the run leaves the registry |
201
+ | `subagent_steer(id, message)` | Queue a mid-run correction into one running run (typically right after a check revealed it heading down a wrong path) | Confirmation that the steer is queued — delivered after the child's current tool batch, before its next LLM call; the run keeps its progress |
192
202
  | `subagent_cancel(id, reason?)` | Kill one live (queued/running) run | Confirmation with the partial-output size — the run settles as `cancelled` (warning styling, same family as timeout/budget) with the reason in its error message; the partial output stays in the registry for `subagent_check` to collect |
193
203
 
194
204
  Typical flow:
@@ -220,6 +230,17 @@ Semantics worth knowing:
220
230
  - **Top-level only:** nested subagents cannot delegate in the background (a subagent process exits when its task finishes, which would orphan the run).
221
231
  - The run registry lives in the pi process: a `/reload` or restart orphans in-flight background runs (their ids stop resolving). `/subagent:status` lists every registered run (active + collected) and its current state.
222
232
 
233
+ ### Steering a running subagent
234
+
235
+ Steering queues a correction into a running subagent without killing it — the middle ground between waiting it out and cancelling. The message is delivered after the child finishes its current tool batch, before its next LLM call, so the run keeps its progress and can change course. It is a suggestion injected between turns, not an interrupt: the child may comply immediately, finish what it was doing first, or ignore poor instructions entirely — to actually stop a run, cancel it.
236
+
237
+ There are two channels into the same mechanism:
238
+
239
+ - **The model** calls `subagent_steer(id, message)` — typically right after a `subagent_check` snapshot revealed the run heading down a wrong path (check → steer → check again later).
240
+ - **The user** types into the input box of `/subagent:view`, targeting the focused run. Every accepted steer also appears in the run's activity feed as an `↩ steer:` entry, so whoever watches the view sees what was injected and when.
241
+
242
+ Queued steers are visible in neither wait nor check results — they shape the run's subsequent behavior, not its transcript.
243
+
223
244
  ### Background TUI display
224
245
 
225
246
  Each tool row renders one aspect of the same decomposition the foreground row shows all at once (input · process · result · usage):
@@ -245,7 +266,7 @@ Hand the subagent precise context — selected code, a prior delegate's result,
245
266
  }
246
267
  ```
247
268
 
248
- The stored/displayed task stays as the original `task`. 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.
269
+ The stored/displayed task stays as the original `task`. `context` is delivered as a `<context>` block ahead of the task in the child's initial prompt the prompt travels over stdin, so size is not argv-bound and no spill file is involved.
249
270
 
250
271
  #### `files` (reference paths)
251
272
 
@@ -257,7 +278,7 @@ The stored/displayed task stays as the original `task`. When small, `context` in
257
278
  }
258
279
  ```
259
280
 
260
- Each path is injected as an independent `@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.
281
+ Each path is injected as an independent `<file name="...">` block in the child's initial prompt — the same wrap pi applies to `@file` arguments. **File contents stay out of your context window** — you pass only the paths; this process reads the bytes off disk and pipes them straight to the child. Prefer this over pasting file contents into `context`, since the child receives the content on its first turn without spending a tool call to read it.
261
282
 
262
283
  ### Budget enforcement
263
284
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-subagent",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "description": "Role-based subagent orchestration for pi — delegates tasks to specialized pi child processes with configurable model roles",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  import { startSubagentRun, type RunHandle } from "./run.ts";
46
46
  import { buildInboxReminder, injectReminder } from "./reminder.ts";
47
47
  import { renderDelegateCall, renderDelegateResult } from "./render.ts";
48
+ import { createViewPanel } from "./view.ts";
48
49
  import {
49
50
  renderBackgroundDelegateCall,
50
51
  renderBackgroundDelegateResult,
@@ -634,6 +635,57 @@ export default function subagentExtension(pi: ExtensionAPI) {
634
635
  renderResult: renderCheckResult,
635
636
  });
636
637
 
638
+ pi.registerTool({
639
+ name: "subagent_steer",
640
+ label: "Steer a running background subagent",
641
+ description:
642
+ "Queue a mid-run correction into ONE running background subagent — typically right after subagent_check showed it heading down a wrong path. The message is delivered after the child finishes its current tool batch, before its next LLM call; the run keeps its progress (unlike cancel). Only running runs accept steering; queued runs reject it, and terminal runs are collected by check instead. Typical flow: check → steer → check again later.",
643
+ promptSnippet: "Send a mid-run correction to a background subagent",
644
+ parameters: Type.Object({
645
+ id: Type.String({ description: "Run id returned by a background delegate call" }),
646
+ message: Type.String({
647
+ description:
648
+ "The correction. Concise and imperative — it lands mid-run, between the child's turns.",
649
+ }),
650
+ }),
651
+
652
+ async execute(_toolCallId, params) {
653
+ const run = backgroundRuns.get(params.id);
654
+ if (!run) {
655
+ const collected = collectedRuns.get(params.id);
656
+ if (collected) {
657
+ throw new Error(
658
+ `${params.id} (${collected.role}) was already collected — nothing left to steer. Delegate a new run if a correction is still needed.`,
659
+ );
660
+ }
661
+ const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
662
+ throw new Error(
663
+ `Unknown subagent id: ${params.id}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
664
+ );
665
+ }
666
+ if (run.state === "queued") {
667
+ throw new Error(
668
+ `${params.id} (${run.role}) is still queued for a concurrency slot — steer once it is running.`,
669
+ );
670
+ }
671
+ if (run.state !== "running") {
672
+ throw new Error(
673
+ `${params.id} (${run.role}) is ${run.state} — only running runs can be steered. subagent_check(${params.id}) returns its result.`,
674
+ );
675
+ }
676
+ run.steer(params.message);
677
+ return {
678
+ content: [
679
+ {
680
+ type: "text",
681
+ text: `Steer queued for ${params.id} (${run.role}) — delivered after its current tool batch. Verify the effect with subagent_check later.`,
682
+ },
683
+ ],
684
+ details: { id: params.id, role: run.role },
685
+ };
686
+ },
687
+ });
688
+
637
689
  pi.registerTool({
638
690
  name: "subagent_cancel",
639
691
  label: "Cancel a background subagent",
@@ -696,6 +748,38 @@ export default function subagentExtension(pi: ExtensionAPI) {
696
748
  renderResult: renderCancelResult,
697
749
  });
698
750
 
751
+ pi.registerCommand("subagent:view", {
752
+ description: "Open the live subagent activity view (watch progress, steer runs)",
753
+ handler: async (_args, ctx) => {
754
+ // Union of every known run: background registry (until collected) plus
755
+ // live in-flight runs (foreground delegate calls included). Dedupe by id —
756
+ // background runs appear in both.
757
+ const runsProvider = () => {
758
+ const seen = new Set<string>();
759
+ const out: RunHandle[] = [];
760
+ for (const r of [...backgroundRuns.values(), ...liveRuns]) {
761
+ if (!seen.has(r.id)) {
762
+ seen.add(r.id);
763
+ out.push(r);
764
+ }
765
+ }
766
+ return out;
767
+ };
768
+ if (runsProvider().length === 0) {
769
+ ctx.ui.notify("No subagent runs yet.", "info");
770
+ return;
771
+ }
772
+ await ctx.ui.custom(
773
+ (tui, theme, _keybindings, done) =>
774
+ createViewPanel(runsProvider, tui, theme, () => done(undefined)),
775
+ {
776
+ overlay: true,
777
+ overlayOptions: { anchor: "center", width: "90%", maxHeight: "85%" },
778
+ },
779
+ );
780
+ },
781
+ });
782
+
699
783
  pi.registerCommand("subagent:doctor", {
700
784
  description: "Diagnose pi-subagent configuration and dependencies",
701
785
  handler: async (_args, ctx) => {
package/src/run.ts CHANGED
@@ -24,6 +24,7 @@ import type {
24
24
  FallbackFrom,
25
25
  RunState,
26
26
  SubagentConfig,
27
+ SubagentControl,
27
28
  SubagentResult,
28
29
  SubagentRole,
29
30
  } from "./types.ts";
@@ -59,6 +60,8 @@ export interface RunHandle {
59
60
  readonly promise: Promise<SubagentResult>;
60
61
  /** Abort the run — no-op after settle. Tool-cancellation and session-shutdown reaping both funnel here. */
61
62
  abort(reason?: string): void;
63
+ /** Queue a steering message into the running child (RPC stdin). No-op when queued/settled. */
64
+ steer(message: string): void;
62
65
  /** Get notified on every frame change. Returns an unsubscribe function. */
63
66
  subscribe(fn: () => void): () => void;
64
67
  }
@@ -114,6 +117,8 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
114
117
  let result: SubagentResult | undefined;
115
118
  let thrown: Error | undefined;
116
119
  let settled = false;
120
+ /** Live stdin channel of the current spawn attempt (replaced on fallback retry). */
121
+ let control: SubagentControl | undefined;
117
122
  let abortReason: string | undefined;
118
123
  const controller = new AbortController();
119
124
  const onCallerAbort = () => controller.abort();
@@ -181,6 +186,10 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
181
186
  if (reason) abortReason = reason;
182
187
  controller.abort();
183
188
  },
189
+ steer(message: string) {
190
+ if (settled || currentState !== "running") return;
191
+ control?.steer(message);
192
+ },
184
193
  promise,
185
194
  };
186
195
 
@@ -291,6 +300,9 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
291
300
  depth: opts.depth,
292
301
  signal: controller.signal,
293
302
  onProgress: emitProgress,
303
+ onControl: (c) => {
304
+ control = c;
305
+ },
294
306
  });
295
307
 
296
308
  // Retry with fallback role on provider errors (quota, auth, timeout, etc.)
@@ -323,6 +335,9 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
323
335
  depth: opts.depth,
324
336
  signal: controller.signal,
325
337
  onProgress: emitProgress,
338
+ onControl: (c) => {
339
+ control = c;
340
+ },
326
341
  });
327
342
  runResult.fallbackFrom = fallbackFrom;
328
343
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Unit tests for spawn-side pure logic.
3
+ *
4
+ * node --test packages/pi-subagent/src/spawn.test.ts
5
+ */
6
+
7
+ import { test, describe } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+ import { composeInitialMessage } from "./spawn.ts";
13
+
14
+ describe("composeInitialMessage", () => {
15
+ test("wraps reference files in <file> blocks ahead of context and task", async () => {
16
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-sub-test-"));
17
+ try {
18
+ const fileA = path.join(dir, "a.md");
19
+ fs.writeFileSync(fileA, "alpha content");
20
+ const message = await composeInitialMessage([fileA], "some ctx", "do the thing");
21
+ assert.equal(
22
+ message,
23
+ `<file name="${fileA}">\nalpha content\n</file>\n\n<context>\nsome ctx\n</context>\n\n<task>\ndo the thing\n</task>`,
24
+ );
25
+ } finally {
26
+ fs.rmSync(dir, { recursive: true, force: true });
27
+ }
28
+ });
29
+
30
+ test("omits absent channels and preserves relative block order", async () => {
31
+ const message = await composeInitialMessage(undefined, undefined, "only a task");
32
+ assert.equal(message, "<task>\nonly a task\n</task>");
33
+ const ctxOnly = await composeInitialMessage(undefined, "ctx body", "");
34
+ assert.equal(ctxOnly, "<context>\nctx body\n</context>");
35
+ });
36
+
37
+ test("unreadable files degrade to a placeholder instead of failing the run", async () => {
38
+ const missing = path.join(os.tmpdir(), "pi-sub-test-does-not-exist.md");
39
+ const message = await composeInitialMessage([missing], undefined, "t");
40
+ assert.match(message, /^\[?<file name="/);
41
+ assert.match(message, /failed to read file/);
42
+ assert.match(message, /\n\n<task>\nt\n<\/task>$/);
43
+ });
44
+
45
+ test("blank (whitespace-only) context is dropped", async () => {
46
+ const message = await composeInitialMessage(undefined, " \n\t", "t");
47
+ assert.equal(message, "<task>\nt\n</task>");
48
+ });
49
+ });
package/src/spawn.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Spawn a pi child process and collect structured output with real-time progress.
3
3
  *
4
- * Uses pi's --mode 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 ──
@@ -259,52 +291,13 @@ export async function spawnSubagent(
259
291
  ].join("\n"),
260
292
  );
261
293
 
262
- // ── Context channel: independent size gate ──
263
- // Large context spills to @ctx.md (pi auto-wraps in <file>); small context
264
- // inlines as a structured <context> tag. Decoupled from the task gate so a
265
- // large context never drags a short task into a spill file.
266
- let contextInline = false;
267
- if (options.context && options.context.trim()) {
268
- if (options.context.length > INLINE_LIMIT) {
269
- const ctxPath = path.join(tmpDir, "context.md");
270
- await fs.promises.writeFile(ctxPath, options.context, { encoding: "utf-8", mode: 0o600 });
271
- args.push(`@${ctxPath}`);
272
- } else {
273
- contextInline = true;
274
- }
275
- }
276
-
277
- // ── Reference files channel: each as an independent @ argument ──
278
- // pi reads each and wraps in <file name="...">. Content never enters the
279
- // parent model's context — the child reads it directly.
280
- if (options.contextFiles) {
281
- for (const f of options.contextFiles) {
282
- args.push(`@${f}`);
283
- }
284
- }
285
-
286
- // ── Task channel: always inline, always the final block ──
287
- // The task is an instruction, not reference material — it stays inline so the
288
- // child sees it as the primary directive. A pathologically long task spills.
289
- let taskInline = true;
290
- if (task.length > INLINE_LIMIT) {
291
- const taskPath = path.join(tmpDir, "task.md");
292
- await fs.promises.writeFile(taskPath, task, { encoding: "utf-8", mode: 0o600 });
293
- args.push(`@${taskPath}`);
294
- taskInline = false;
295
- }
296
-
297
- // ── Compose the message body (inline context + task) ──
298
- // @file args are injected by pi BEFORE this message (buildInitialMessage),
299
- // so the final shape the child sees is:
300
- // [<file>...spilled context / reference files...</file>]
301
- // [<context>...inline context...</context>]
302
- // [<task>...task...</task>]
303
- const messageParts: string[] = [];
304
- if (contextInline) messageParts.push(`<context>\n${options.context}\n</context>`);
305
- if (taskInline) messageParts.push(`<task>\n${task}\n</task>`);
306
- const message = messageParts.join("\n\n");
307
- if (message) args.push(message);
294
+ // ── Initial prompt channel: one RPC prompt command over stdin ──
295
+ // RPC mode rejects @file argv, so reference files are inlined here as
296
+ // <file name="..."> blocks the same wrap pi applies to @file arguments
297
+ // (processFileArguments). Content still never enters the parent model's
298
+ // context; this process reads the bytes off disk and pipes them straight
299
+ // to the child.
300
+ const initialMessage = await composeInitialMessage(options.contextFiles, options.context, task);
308
301
 
309
302
  // Spawn process
310
303
  const invocation = getPiInvocation(args);
@@ -313,6 +306,15 @@ export async function spawnSubagent(
313
306
  let wasTimeout = false;
314
307
  let buffer = "";
315
308
 
309
+ /** Serialize one JSONL command frame for the child's stdin. */
310
+ const sendCommand = (command: Record<string, unknown>): void => {
311
+ try {
312
+ proc?.stdin?.write(`${JSON.stringify(command)}\n`);
313
+ } catch {
314
+ /* child gone — nothing to steer */
315
+ }
316
+ };
317
+
316
318
  const emitProgress = () => {
317
319
  options.onProgress?.({
318
320
  output: result.output,
@@ -326,9 +328,18 @@ export async function spawnSubagent(
326
328
  };
327
329
 
328
330
  let thinkingCounter = 0;
331
+ let textCounter = 0;
329
332
  // O(1) lookup from toolCallId → activityLog index.
330
333
  const toolCallIndex = new Map<string, number>();
331
334
 
335
+ // Streamed text deltas bypass the per-event emitProgress (copying the whole
336
+ // activityLog per token is quadratic); a time gate keeps live frames fresh
337
+ // enough for the :view overlay without flooding the frame pipeline.
338
+ let lastDeltaEmit = 0;
339
+ const DELTA_EMIT_INTERVAL_MS = 200;
340
+
341
+ let steerCounter = 0;
342
+
332
343
  // Normalized turn/cost budgets (0 = unlimited). Role overrides can arrive
333
344
  // raw from settings.json, so negatives/non-finites normalize here.
334
345
  const maxTurns = Number.isFinite(options.maxTurns) ? Math.max(0, options.maxTurns ?? 0) : 0;
@@ -353,6 +364,21 @@ export async function spawnSubagent(
353
364
  return;
354
365
  }
355
366
 
367
+ // RPC command acknowledgements ("response" lines) carry no agent state.
368
+ if (event.type === "response") return;
369
+
370
+ // RPC mode is a resident server: after the task completes (agent_end) the
371
+ // process would idle forever waiting for more stdin commands. We run one
372
+ // prompt per child, so ending our stdin side triggers its graceful
373
+ // shutdown (onInputEnd → runtime dispose → exit).
374
+ if (event.type === "agent_end") {
375
+ try {
376
+ proc?.stdin?.end();
377
+ } catch {
378
+ /* already gone */
379
+ }
380
+ }
381
+
356
382
  if (event.type === "message_end" && event.message) {
357
383
  const msg = event.message as SubagentMessage;
358
384
 
@@ -385,6 +411,7 @@ export async function spawnSubagent(
385
411
  checkBudget();
386
412
  }
387
413
 
414
+ freezeRunningTextEntries();
388
415
  emitProgress();
389
416
  }
390
417
 
@@ -444,6 +471,38 @@ export async function spawnSubagent(
444
471
  }
445
472
  }
446
473
  emitProgress();
474
+ } else if (aev.type === "text_start") {
475
+ result.activityLog.push({
476
+ kind: "text",
477
+ id: `text-${textCounter++}`,
478
+ status: "running",
479
+ text: "",
480
+ });
481
+ emitProgress();
482
+ } else if (aev.type === "text_delta" && aev.delta) {
483
+ // Append to the still-running text entry; skip the frame copy per token.
484
+ for (let i = result.activityLog.length - 1; i >= 0; i--) {
485
+ const entry = result.activityLog[i];
486
+ if (entry.kind === "text" && entry.status === "running") {
487
+ entry.text = (entry.text ?? "") + aev.delta;
488
+ break;
489
+ }
490
+ if (entry.kind === "text") break;
491
+ }
492
+ const now = Date.now();
493
+ if (now - lastDeltaEmit >= DELTA_EMIT_INTERVAL_MS) {
494
+ lastDeltaEmit = now;
495
+ emitProgress();
496
+ }
497
+ }
498
+ }
499
+ };
500
+
501
+ /** Freeze every still-running text entry — called on message_end (turn boundary). */
502
+ const freezeRunningTextEntries = () => {
503
+ for (const entry of result.activityLog) {
504
+ if (entry.kind === "text" && entry.status === "running") {
505
+ entry.status = "done";
447
506
  }
448
507
  }
449
508
  };
@@ -555,12 +614,34 @@ export async function spawnSubagent(
555
614
  cwd: options.cwd,
556
615
  env: childEnv,
557
616
  shell: false,
558
- stdio: ["ignore", "pipe", "pipe"],
617
+ stdio: ["pipe", "pipe", "pipe"],
559
618
  });
560
619
  proc = p;
561
620
  liveChildren.add(p);
562
621
  reapChildrenOnExit();
563
622
 
623
+ // Expose the stdin control channel (steering) to the owner. Writes are
624
+ // fire-and-forget: once the child is gone the try/catch in sendCommand
625
+ // swallows EPIPE.
626
+ options.onControl?.({
627
+ steer(message: string) {
628
+ if (processExited || terminationRequested) return;
629
+ sendCommand({ type: "steer", message });
630
+ // Mirror the steer into the activity feed so the :view overlay shows
631
+ // what was injected and when.
632
+ result.activityLog.push({
633
+ kind: "steer",
634
+ id: `steer-${steerCounter++}`,
635
+ status: "done",
636
+ text: message,
637
+ });
638
+ emitProgress();
639
+ },
640
+ });
641
+
642
+ // Kick off the run: RPC mode starts idle and waits for a prompt command.
643
+ sendCommand({ id: "init", type: "prompt", message: initialMessage });
644
+
564
645
  p.stdout.on("data", (data: Buffer) => {
565
646
  buffer += data.toString();
566
647
  const lines = buffer.split("\n");
package/src/types.ts CHANGED
@@ -74,15 +74,23 @@ export interface SubagentRole {
74
74
  /** Status of an individual tool call within a subagent run. */
75
75
  export type ToolStatus = "running" | "done" | "failed";
76
76
 
77
- /** A single entry in the real-time activity log (thinking block or 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
+ }