@ferris1225/pi-subagents 0.29.0 → 0.32.2

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/src/format.ts CHANGED
@@ -90,7 +90,7 @@ export function formatCompletionBlock(result: SingleResult, maxResultLines: numb
90
90
  const output = getResultOutput(result);
91
91
  const { text, truncated } = truncateResultOutput(output, maxResultLines);
92
92
  const fallbackNote = result.modelFallbackFrom
93
- ? ` (model fell back from ${result.modelFallbackFrom} to ${result.model ?? "main-window model"})`
93
+ ? ` (model pool fallback: primary ${result.modelFallbackFrom} final ${result.model ?? "dynamic default"})`
94
94
  : "";
95
95
  const startupRetryNote = result.startupRetries
96
96
  ? ` (recovered after ${result.startupRetries} startup retr${result.startupRetries === 1 ? "y" : "ies"} — concurrent pi startup race)`
@@ -98,7 +98,32 @@ export function formatCompletionBlock(result: SingleResult, maxResultLines: numb
98
98
  const modelRetryNote = result.modelRetries
99
99
  ? ` (recovered after ${result.modelRetries} same-model retr${result.modelRetries === 1 ? "y" : "ies"} on a transient provider error)`
100
100
  : "";
101
- const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${modelRetryNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, "", text];
101
+ const relations = [
102
+ result.forkedFromRunId !== undefined ? `forked from #${result.forkedFromRunId}` : undefined,
103
+ (result.forkChildRunIds?.length ?? 0) > 0 ? `fork children ${result.forkChildRunIds!.map((id) => `#${id}`).join(", ")}` : undefined,
104
+ ].filter((value): value is string => Boolean(value));
105
+ const relationNote = relations.length > 0 ? ` · ${relations.join(" · ")}` : "";
106
+ const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${modelRetryNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, ""];
107
+ if (result.isolation === "worktree") {
108
+ const isolation =
109
+ result.integrationStatus === "integrated"
110
+ ? "worktree · changes integrated into the original working tree"
111
+ : result.integrationStatus === "no_changes"
112
+ ? "worktree · no changes; temporary worktree removed"
113
+ : result.integrationStatus === "retained"
114
+ ? result.integrationApplied
115
+ ? "worktree · changes applied, but cleanup failed; recovery artifacts retained"
116
+ : "worktree · integration failed; recovery artifacts retained"
117
+ : "worktree · isolated";
118
+ lines.push(`Isolation: ${isolation}${relationNote}`);
119
+ if (result.integrationWorktreePath) lines.push(`Retained worktree: ${result.integrationWorktreePath}`);
120
+ if (result.integrationPatchPath) lines.push(`Retained patch: ${result.integrationPatchPath}`);
121
+ if (result.integrationError) lines.push(`Integration error: ${result.integrationError}`);
122
+ lines.push("");
123
+ } else if (relations.length > 0) {
124
+ lines.push(`Relation: ${relations.join(" · ")}`, "");
125
+ }
126
+ lines.push(text);
102
127
  // A run can exit cleanly while its last tools failed (e.g. a build that broke):
103
128
  // the final text alone may claim more than the tools achieved, so surface the
104
129
  // failures explicitly and tell the main agent to verify before relying on it.
@@ -122,13 +147,20 @@ export function formatCompletionBlock(result: SingleResult, maxResultLines: numb
122
147
 
123
148
  /** Instruction appended to a model-level failure: the sub-agent's provider never
124
149
  * produced usable output (or the run stalled), so the task is handed back to the
125
- * main window instead of being left as a dead failure. */
126
- export function modelLevelTakeoverNote(result: SingleResult): string {
150
+ * main window instead of being left as a dead failure. When the run preserved a
151
+ * session with earlier work (and the run id is known), steer the main agent to
152
+ * RESUME it in-context once a model is available, instead of re-dispatching
153
+ * fresh (which would re-scan everything). */
154
+ export function modelLevelTakeoverNote(result: SingleResult, opts?: { runId?: number }): string {
127
155
  const sameModel = result.modelRetries
128
156
  ? `, after ${result.modelRetries} same-model retr${result.modelRetries === 1 ? "y" : "ies"} on transient errors`
129
157
  : "";
130
- const retry = result.modelFallbackFrom ? ", and the retry with the main-window model also failed" : "";
131
- return `The sub-agent could not complete this task: its model was unavailable or failed (or the run stalled)${sameModel}${retry}. Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
158
+ const retry = result.modelFallbackFrom ? ", and the configured backup chain also failed" : "";
159
+ const sessionPreserved = Boolean(result.sessionDir && result.sessionId) && opts?.runId !== undefined;
160
+ const recovery = sessionPreserved
161
+ ? ` The sub-agent's earlier work in this run is preserved. Once a model is available again, call subagent_control with { action: "resume", id: ${opts!.runId} } to CONTINUE it in-context (it keeps the same run id and does not re-scan), or execute the task in the main window with your own tools.`
162
+ : ` Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
163
+ return `The sub-agent could not complete this task: its model was unavailable or failed (or the run stalled)${sameModel}${retry}.${recovery}`;
132
164
  }
133
165
 
134
166
  /** Resolve a run-id request to actual ids: an exact numeric match always wins
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Assembly point: builds the shared runtime and registers everything.
5
5
  * The heavy lifting lives in focused modules:
6
6
  * - dispatch.ts — the `subagent` tool (spawn, auto-fix chain, vision model)
7
- * - tools.ts — subagent_wait / subagent_status / subagent_stop
7
+ * - tools.ts — subagent_control / subagent_wait / status / stop
8
8
  * - widget.ts — session_start widget + one-time feature announcements
9
9
  * - runtime.ts — shared per-session state
10
10
  *
@@ -22,6 +22,7 @@ import { discoverAgents } from "./agents.ts";
22
22
  import { getConfigPath, loadConfig } from "./config.ts";
23
23
  import { registerSubagentTool } from "./dispatch.ts";
24
24
  import { matchRunIds } from "./format.ts";
25
+ import { registerInspectorCommand } from "./inspector-panel.ts";
25
26
  import { buildDelegationDirective } from "./prompt.ts";
26
27
  import { createRuntime } from "./runtime.ts";
27
28
  import { runSetup } from "./setup.ts";
@@ -57,15 +58,16 @@ export default function (pi: ExtensionAPI): void {
57
58
  ),
58
59
  );
59
60
 
60
- pi.on("session_shutdown", () => {
61
- runtime.shutdown();
61
+ pi.on("session_shutdown", async () => {
62
+ await runtime.shutdown();
62
63
  });
63
64
 
64
65
  registerSubagentTool(pi, runtime);
65
66
  registerLookupTools(pi, runtime);
67
+ registerInspectorCommand(pi, runtime);
66
68
 
67
69
  pi.registerCommand("subagents-setup", {
68
- description: "Configure pi-subagents: enable agents, pick per-agent models, toggle proactive injection",
70
+ description: "Configure pi-subagents: enabled agents, primary/backup model pools, and runtime settings",
69
71
  handler: async (_args, ctx) => {
70
72
  await runSetup(ctx, configPath);
71
73
  },
@@ -82,6 +84,7 @@ export default function (pi: ExtensionAPI): void {
82
84
  const { agents } = discoverAgents(ctx.cwd, {
83
85
  scope: config.agentScope,
84
86
  enabledNames: config.enabledAgents,
87
+ projectTrusted: ctx.isProjectTrusted?.() === true,
85
88
  });
86
89
  const directive = buildDelegationDirective(agents);
87
90
  if (!directive) return undefined;
@@ -0,0 +1,363 @@
1
+ /**
2
+ * /subagents-inspect — a live overlay listing logical sub-agent threads and the
3
+ * selected thread's real-time work: header facts (run id, agent, generation,
4
+ * model chain, thinking, elapsed, usage/cost), the streaming transcript, recent
5
+ * tools, and the append-only orchestration trajectory.
6
+ *
7
+ * Data comes from a read-only snapshot (buildInspectorSnapshot → monitor +
8
+ * runtime threads + inspectorStore); the component itself never mutates
9
+ * runtime state, except for the explicit park/resume keyboard shortcut which
10
+ * delegates to the same thread control surface as subagent_control.
11
+ *
12
+ * Layout: a two-pane master/detail at wide widths, compact single-pane detail
13
+ * when narrow. Every line is fitted with truncateToWidth — pi hard-crashes on
14
+ * over-wide lines.
15
+ */
16
+
17
+ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
18
+ import { stripVTControlCharacters } from "node:util";
19
+ import {
20
+ Key,
21
+ matchesKey,
22
+ truncateToWidth,
23
+ visibleWidth,
24
+ type Component,
25
+ type TUI,
26
+ } from "@earendil-works/pi-tui";
27
+ import { buildInspectorSnapshot, formatTrajectoryEvent, type InspectorThreadView } from "./inspector.ts";
28
+ import { formatDuration, formatUsageCompact, isRunActiveStatus, monitor, statusIcon, type RunStatus } from "./monitor.ts";
29
+ import type { SubagentRuntime } from "./runtime.ts";
30
+ import { inspectorStore } from "./trajectory.ts";
31
+
32
+ /** Below this width the overlay falls back to the single-pane layout. */
33
+ const WIDE_MIN = 110;
34
+ const LEFT_PANE_WIDTH = 42;
35
+ const MAX_LIST_ROWS = 12;
36
+ const MAX_TRANSCRIPT_LINES = 9;
37
+ const TICK_MS = 1_000;
38
+
39
+ export interface InspectorOverlayOptions {
40
+ runtime: Pick<SubagentRuntime, "threads">;
41
+ tui: TUI;
42
+ theme: Theme;
43
+ done: () => void;
44
+ notify?: (message: string) => void;
45
+ tickMs?: number;
46
+ }
47
+
48
+ export class InspectorOverlay implements Component {
49
+ private selectedId?: number;
50
+ private closed = false;
51
+ private readonly unsubscribeMonitor: () => void;
52
+ private readonly unsubscribeInspector: () => void;
53
+ private readonly timer: ReturnType<typeof setInterval>;
54
+
55
+ constructor(private readonly options: InspectorOverlayOptions) {
56
+ // Live updates: any monitor mutation or trajectory append rerenders.
57
+ this.unsubscribeMonitor = monitor.subscribe(() => this.rerender());
58
+ this.unsubscribeInspector = inspectorStore.subscribe(() => this.rerender());
59
+ this.timer = setInterval(() => this.rerender(), options.tickMs ?? TICK_MS);
60
+ if (typeof this.timer.unref === "function") this.timer.unref();
61
+ }
62
+
63
+ private rerender(): void {
64
+ if (this.closed) return;
65
+ try {
66
+ this.options.tui.requestRender();
67
+ } catch {
68
+ /* a closed/failed TUI must not throw through store notifications */
69
+ }
70
+ }
71
+
72
+ /** Escape path (and any external teardown) unsubscribes cleanly. */
73
+ dispose(): void {
74
+ if (this.closed) return;
75
+ this.closed = true;
76
+ this.unsubscribeMonitor();
77
+ this.unsubscribeInspector();
78
+ clearInterval(this.timer);
79
+ }
80
+
81
+ invalidate(): void {
82
+ /* stateless render — nothing cached */
83
+ }
84
+
85
+ private move(items: readonly { id: number }[], delta: number): void {
86
+ if (items.length === 0) return;
87
+ const index = items.findIndex((item) => item.id === this.selectedId);
88
+ const current = index === -1 ? items.length - 1 : index;
89
+ const next = (current + delta + items.length) % items.length;
90
+ this.selectedId = items[next].id;
91
+ }
92
+
93
+ /** Non-text quick action: park a live thread / resume a parked one. Text
94
+ * actions (steer/retarget with payloads) stay on subagent_control. */
95
+ private togglePark(): void {
96
+ const id = this.selectedId;
97
+ if (id === undefined) return;
98
+ const thread = this.options.runtime.threads.get(id);
99
+ if (!thread) return;
100
+ if (thread.state === "parked") {
101
+ void thread
102
+ .resume(undefined)
103
+ .then((pending) => {
104
+ if (pending.exitCode !== -1) this.options.notify?.(`Could not resume run #${id}: no candidate could start.`);
105
+ })
106
+ .catch((error) => this.options.notify?.(`Could not resume run #${id}: ${errorMessageText(error)}`));
107
+ return;
108
+ }
109
+ const phase = thread.control.getPhase();
110
+ if (phase === "queued" || phase === "starting" || phase === "running" || phase === "steering" || phase === "interrupting" || phase === "retrying" || phase === "settled") {
111
+ void thread
112
+ .park()
113
+ .catch((error) => this.options.notify?.(`Could not park run #${id}: ${errorMessageText(error)}`));
114
+ }
115
+ }
116
+
117
+ handleInput(data: string): void {
118
+ if (matchesKey(data, Key.escape)) {
119
+ this.dispose();
120
+ this.options.done();
121
+ return;
122
+ }
123
+ const { items } = buildInspectorSnapshot({ runtime: this.options.runtime });
124
+ if (this.selectedId === undefined || !items.some((item) => item.id === this.selectedId)) {
125
+ this.selectedId = items.length > 0 ? items[items.length - 1].id : undefined;
126
+ }
127
+ if (matchesKey(data, Key.up) || data === "k") this.move(items, -1);
128
+ else if (matchesKey(data, Key.down) || data === "j" || matchesKey(data, Key.tab)) this.move(items, 1);
129
+ else if (data === "p") this.togglePark();
130
+ this.rerender();
131
+ }
132
+
133
+ render(width: number): string[] {
134
+ const theme = this.options.theme;
135
+ const fit = (line: string): string => truncateToWidth(line, width);
136
+ const snapshot = buildInspectorSnapshot({ runtime: this.options.runtime, selectedId: this.selectedId });
137
+ const { items } = snapshot;
138
+
139
+ // Selection stability: keep the chosen id across updates; fall back to
140
+ // the newest thread only when there is no valid selection.
141
+ if (this.selectedId === undefined || !items.some((item) => item.id === this.selectedId)) {
142
+ this.selectedId = items.length > 0 ? items[items.length - 1].id : undefined;
143
+ }
144
+ const detail =
145
+ this.selectedId === undefined
146
+ ? undefined
147
+ : (buildInspectorSnapshot({ runtime: this.options.runtime, selectedId: this.selectedId }).detail ??
148
+ snapshot.detail);
149
+
150
+ const border = fit(theme.fg("accent", "─".repeat(Math.max(1, width))));
151
+ const activeCount = items.filter((item) => isRunActiveStatus(item.status)).length;
152
+ const liveText = activeCount > 0 ? `${activeCount} active` : "no active runs";
153
+ const header = fit(
154
+ `${theme.fg("accent", theme.bold("sub-agents"))} ${theme.fg(activeCount > 0 ? "accent" : "dim", "●")} ${theme.fg("dim", `${liveText} · ${items.length} thread${items.length === 1 ? "" : "s"}`)}`,
155
+ );
156
+ const footer = fit(
157
+ theme.fg(
158
+ "dim",
159
+ "↑/↓ select · p park/resume · subagent_control steer/retarget/park/resume/fork · subagent_stop destroys · Esc close",
160
+ ),
161
+ );
162
+
163
+ const lines: string[] = [border, header, border];
164
+ if (items.length === 0) {
165
+ lines.push(fit(theme.fg("dim", "No sub-agent threads yet — delegate work with the subagent tool; live progress appears here.")));
166
+ lines.push(border, footer);
167
+ return lines;
168
+ }
169
+
170
+ if (width >= WIDE_MIN && detail) {
171
+ const paneLines = this.renderListPane(items, LEFT_PANE_WIDTH - 2);
172
+ const detailLines = this.renderDetail(detail, width - LEFT_PANE_WIDTH - 3);
173
+ const rows = Math.max(paneLines.length, detailLines.length);
174
+ for (let i = 0; i < rows; i++) {
175
+ const left = padRight(paneLines[i] ?? "", LEFT_PANE_WIDTH);
176
+ const right = detailLines[i] ?? "";
177
+ lines.push(fit(`${left} ${theme.fg("borderMuted", "│")} ${right}`));
178
+ }
179
+ } else {
180
+ lines.push(...this.renderListPane(items, width));
181
+ lines.push(fit(theme.fg("borderMuted", "─ ".repeat(Math.max(1, Math.floor(width / 2))))));
182
+ if (detail) lines.push(...this.renderDetail(detail, width));
183
+ }
184
+ lines.push(border, footer);
185
+ return lines;
186
+ }
187
+
188
+ private renderListPane(items: ReadonlyArray<{ id: number; agent: string; label: string; status: RunStatus; stateText: string; generation?: number; elapsedMs?: number }>, width: number): string[] {
189
+ const theme = this.options.theme;
190
+ const selIndex = Math.max(0, items.findIndex((item) => item.id === this.selectedId));
191
+ const start = Math.max(0, Math.min(selIndex - Math.floor(MAX_LIST_ROWS / 2), items.length - MAX_LIST_ROWS));
192
+ const visible = items.slice(start, start + MAX_LIST_ROWS);
193
+ const lines: string[] = [];
194
+ for (let i = 0; i < visible.length; i++) {
195
+ const item = visible[i];
196
+ const selected = start + i === selIndex;
197
+ const cursor = selected ? theme.fg("accent", "❯ ") : " ";
198
+ const icon = statusIcon(item.status, theme);
199
+ const safeAgent = safeText(item.agent);
200
+ const safeLabel = safeText(item.label);
201
+ const safeState = safeText(item.stateText);
202
+ const name = selected ? theme.fg("accent", theme.bold(safeAgent)) : theme.fg("accent", safeAgent);
203
+ const labelPart = safeLabel ? theme.fg("dim", ` · ${safeLabel}`) : "";
204
+ const state = theme.fg("dim", ` ${item.elapsedMs !== undefined ? formatDuration(item.elapsedMs) + " " : ""}${safeState}`);
205
+ lines.push(truncateToWidth(`${cursor}${icon} ${theme.fg("dim", `#${item.id}`)} ${name}${labelPart}${state}`, width));
206
+ }
207
+ if (items.length > MAX_LIST_ROWS) {
208
+ lines.push(truncateToWidth(theme.fg("dim", ` ${selIndex + 1}/${items.length} · ↑/↓ for more`), width));
209
+ }
210
+ return lines;
211
+ }
212
+
213
+ private renderDetail(view: InspectorThreadView, width: number): string[] {
214
+ const theme = this.options.theme;
215
+ const fit = (line: string): string => truncateToWidth(line, width);
216
+ const dim = (text: string): string => theme.fg("dim", text);
217
+ const clean = safeText;
218
+ const lines: string[] = [];
219
+
220
+ // Identity + progress facts. Strip untrusted terminal controls BEFORE
221
+ // applying theme ANSI so OSC/CSI payloads can never reach the terminal.
222
+ const genPart = view.generation !== undefined ? ` · gen ${view.generation}` : "";
223
+ const safeState = clean(view.stateText);
224
+ const safePhase = clean(view.phase ?? "");
225
+ const phasePart = safePhase && safePhase !== safeState ? ` (${safePhase})` : "";
226
+ lines.push(fit(`${theme.fg("accent", theme.bold(`#${view.id} ${clean(view.agent)}`))}${dim(`${genPart} · ${safeState}${phasePart}`)}`));
227
+ if (view.label) lines.push(fit(dim(`label: ${clean(view.label)}`)));
228
+ lines.push(fit(dim(`task: ${oneLine(view.task)}`)));
229
+ const relations = [
230
+ view.forkedFromRunId !== undefined ? `forked from #${view.forkedFromRunId}` : undefined,
231
+ view.forkChildRunIds.length > 0 ? `fork children ${view.forkChildRunIds.map((id) => `#${id}`).join(", ")}` : undefined,
232
+ ].filter(Boolean);
233
+ if (relations.length > 0) lines.push(fit(dim(`relation: ${relations.join(" · ")}`)));
234
+ if (view.isolation === "worktree") {
235
+ lines.push(fit(dim(`isolation: worktree · ${view.integrationStatus ?? "active"}`)));
236
+ if (view.originalCwd && view.isolationCwd) lines.push(fit(dim(`cwd: ${clean(view.originalCwd)} → ${clean(view.isolationCwd)}`)));
237
+ if (view.integrationWorktreePath) lines.push(fit(theme.fg("warning", `retained worktree: ${clean(view.integrationWorktreePath)}`)));
238
+ if (view.integrationPatchPath) lines.push(fit(theme.fg("warning", `retained patch: ${clean(view.integrationPatchPath)}`)));
239
+ if (view.integrationError) lines.push(fit(theme.fg("error", `integration: ${clean(view.integrationError)}`)));
240
+ }
241
+
242
+ // Model & thinking.
243
+ const chain = view.modelChain.length > 0 ? view.modelChain.map(clean).join(" → ") : "";
244
+ const currentModel = clean(view.model ?? "");
245
+ const modelLine = view.modelFallbackFrom
246
+ ? `${currentModel} (pool fallback from ${clean(view.modelFallbackFrom)})`
247
+ : currentModel;
248
+ const thinkingSuffix = view.thinking ? ` · thinking ${clean(view.thinking)}` : "";
249
+ if (modelLine || chain) {
250
+ lines.push(fit(`${theme.fg("text", modelLine)}${dim(`${chain ? ` · chain: ${chain}` : ""}${thinkingSuffix}`)}`));
251
+ } else if (view.thinking) {
252
+ lines.push(fit(dim(`thinking ${clean(view.thinking)}`)));
253
+ }
254
+
255
+ // Elapsed + usage/cost + current activity.
256
+ const elapsed = view.elapsedMs !== undefined ? formatDuration(view.elapsedMs) : "—";
257
+ const usage = formatUsageCompact(view.usage);
258
+ const toolsText = view.toolCount > 0 ? ` · ${view.toolCount} tool${view.toolCount === 1 ? "" : "s"}` : "";
259
+ lines.push(fit(dim(`elapsed ${elapsed} · ${usage || "no usage yet"}${toolsText}`)));
260
+ const activity = view.activity ?? view.currentTool;
261
+ if (activity) lines.push(fit(theme.fg("accent", `▸ ${clean(activity)}`)));
262
+
263
+ // Streaming transcript: thinking first (dim), then output text.
264
+ const { text, textTruncated, thinking, thinkingTruncated } = view.transcript;
265
+ if (thinking) {
266
+ const thinkingLines = tailLines(thinking, 3);
267
+ if (thinkingTruncated) lines.push(fit(dim("thinking: … (older output dropped)")));
268
+ else lines.push(fit(dim("thinking:")));
269
+ for (const line of thinkingLines) lines.push(fit(dim(` ${line}`)));
270
+ }
271
+ if (text) {
272
+ const textLines = tailLines(text, MAX_TRANSCRIPT_LINES);
273
+ if (textTruncated) lines.push(fit(dim("output: … (older output dropped)")));
274
+ else lines.push(fit(dim("output:")));
275
+ for (const line of textLines) lines.push(fit(line));
276
+ } else if (isRunActiveStatus(view.status) && !thinking) {
277
+ lines.push(fit(dim("(waiting for first output…)")));
278
+ }
279
+
280
+ // Recent tools.
281
+ if (view.tools.length > 0) {
282
+ lines.push(fit(dim("recent tools:")));
283
+ for (const tool of view.tools) {
284
+ const icon = tool.running ? theme.fg("accent", "●") : tool.isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
285
+ const summary = tool.summary ? dim(` ${clean(tool.summary)}`) : "";
286
+ lines.push(fit(` ${icon} ${clean(tool.tool)}${summary}`));
287
+ }
288
+ }
289
+
290
+ // Append-only trajectory (current generation, most recent last).
291
+ if (view.trajectory.length > 0) {
292
+ const genLabel = view.generation !== undefined ? `gen ${view.generation} ` : "";
293
+ lines.push(fit(dim(`trajectory (${genLabel}latest ${view.trajectory.length} of ${view.trajectoryTotal}):`)));
294
+ for (const event of view.trajectory) {
295
+ lines.push(fit(` ${dim(`${timeOf(event.at)} `)}${formatTrajectoryEvent(event)}`));
296
+ }
297
+ }
298
+ return lines;
299
+ }
300
+ }
301
+
302
+ function safeText(text: string): string {
303
+ return stripVTControlCharacters(text);
304
+ }
305
+
306
+ function oneLine(text: string): string {
307
+ return safeText(text).replace(/\s+/g, " ").trim();
308
+ }
309
+
310
+ /** Last `max` lines of a possibly multi-line, already collected stream. */
311
+ function tailLines(text: string, max: number): string[] {
312
+ const lines = safeText(text).split("\n");
313
+ const tail = lines.slice(-max).map((line) => line.replace(/\s+$/, ""));
314
+ return tail;
315
+ }
316
+
317
+ function timeOf(at: number): string {
318
+ try {
319
+ const d = new Date(at);
320
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`;
321
+ } catch {
322
+ return "--:--:--";
323
+ }
324
+ }
325
+
326
+ function padRight(line: string, width: number): string {
327
+ const visible = visibleWidth(line);
328
+ return visible >= width ? line : `${line}${" ".repeat(width - visible)}`;
329
+ }
330
+
331
+ function errorMessageText(error: unknown): string {
332
+ return error instanceof Error ? error.message : String(error);
333
+ }
334
+
335
+ export function registerInspectorCommand(pi: ExtensionAPI, runtime: SubagentRuntime): void {
336
+ pi.registerCommand("subagents-inspect", {
337
+ description:
338
+ "Open the live sub-agent inspector overlay: threads, streaming output, recent tools, and the append-only trajectory per run",
339
+ handler: async (_args, ctx) => {
340
+ if (ctx.mode !== "tui") {
341
+ ctx.ui.notify("/subagents-inspect requires Pi's interactive TUI.", "error");
342
+ return;
343
+ }
344
+ await ctx.ui.custom<undefined>(
345
+ (tui, theme, _keybindings, done) =>
346
+ new InspectorOverlay({
347
+ runtime,
348
+ tui,
349
+ theme,
350
+ done: () => done(undefined),
351
+ notify: (message) => {
352
+ try {
353
+ ctx.ui.notify(message, "warning");
354
+ } catch {
355
+ /* notification failures are non-fatal */
356
+ }
357
+ },
358
+ }),
359
+ { overlay: true, overlayOptions: { width: "92%", maxHeight: "88%" } },
360
+ );
361
+ },
362
+ });
363
+ }