@ferris1225/pi-subagents 0.32.2 → 1.0.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/src/trajectory.ts CHANGED
@@ -1,34 +1,11 @@
1
1
  /**
2
- * Append-only inspector backing stores for sub-agent threads.
2
+ * Append-only lifecycle trajectories for logical sub-agent threads.
3
3
  *
4
- * Two structures, both owned by a single thread record ({@link TrajectoryLog} or
5
- * {@link InspectRunState}):
6
- *
7
- * - {@link TrajectoryLog} a typed, append-only event log covering
8
- * orchestration (dispatch, model-candidate switches, retries, control actions
9
- * steer/retarget/park/resume/stop) and live child activity (status transitions,
10
- * tool starts/ends, usage). Events keep their generation and source timestamps
11
- * forever: resume/restart clears the mutable summary fields but NEVER the
12
- * event history, so the inspector can show the full story of a thread across
13
- * generations. Fork and worktree events carry typed relationship/lifecycle
14
- * payloads so retained source/child state remains inspectable.
15
- *
16
- * - {@link TranscriptBuffer} — a bounded rolling window of streamed assistant
17
- * text/thinking for the CURRENT generation, dropped on restart and cleared on
18
- * parent-session teardown. Render-performance aid only; it trends toward
19
- * dropping old output while the trajectory log is the durable record.
20
- *
21
- * Aliveness back-compat: the widget's "thinking"/"responding" activity comes from
22
- * forwarded {@link SubagentLiveEvent}s, which carry no delta payload. The
23
- * trajectory needs the delta text itself. Rather than widening the live event
24
- * union (which would silently drop interpreter results from widget consumers),
25
- * the monitor fan-out handler extracts the delta text alongside forwarding and
26
- * appends it here — additive, zero churn for the widget path.
27
- *
28
- * Secret hygiene: tool arguments are summarized verbatim by key, but obvious
29
- * credential-bearing fields (token/password/authorization/apiKey/secret/...)
30
- * are redacted, and values are truncated to a compact length. Arbitrary deep
31
- * payloads never enter the transcript or the trajectory.
4
+ * Events cover dispatch, model candidates, retries, controls, tool activity,
5
+ * usage, worktrees, forks, and settlement. Each event keeps its generation and
6
+ * timestamp across resume/restart; mutable summary fields reset per generation.
7
+ * Tool arguments are reduced to a short terminal-safe summary with obvious
8
+ * credential fields and embedded secrets redacted.
32
9
  */
33
10
 
34
11
  import { stripVTControlCharacters } from "node:util";
@@ -156,75 +133,6 @@ function truncateSummary(text: string): string {
156
133
  return chars.length > TOOL_ARG_SUMMARY_MAX ? `${chars.slice(0, TOOL_ARG_SUMMARY_MAX - 1).join("")}…` : text;
157
134
  }
158
135
 
159
- // ---------------------------------------------------------------------------
160
- // Bounded transcript buffer (per-generation streaming output)
161
- // ---------------------------------------------------------------------------
162
-
163
- export interface TranscriptBudget {
164
- /** Max Unicode code points kept per section (not UTF-16 units/display cells). */
165
- maxTextChars: number;
166
- maxThinkingChars: number;
167
- }
168
-
169
- export const DEFAULT_TRANSCRIPT_BUDGET: TranscriptBudget = {
170
- maxTextChars: 8_000,
171
- maxThinkingChars: 2_000,
172
- };
173
-
174
- export class TranscriptBuffer {
175
- private text = "";
176
- private thinking = "";
177
- private textDropped = 0;
178
- private thinkingDropped = 0;
179
-
180
- constructor(private readonly budget: TranscriptBudget = DEFAULT_TRANSCRIPT_BUDGET) {}
181
-
182
- appendText(delta: string): void {
183
- if (!delta) return;
184
- const next = cap(
185
- (this.text + stripVTControlCharacters(delta)).replace(/\r\n?/g, "\n"),
186
- this.budget.maxTextChars,
187
- );
188
- this.text = next.value;
189
- this.textDropped += next.dropped;
190
- }
191
-
192
- appendThinking(delta: string): void {
193
- if (!delta) return;
194
- const next = cap(
195
- (this.thinking + stripVTControlCharacters(delta)).replace(/\r\n?/g, "\n"),
196
- this.budget.maxThinkingChars,
197
- );
198
- this.thinking = next.value;
199
- this.thinkingDropped += next.dropped;
200
- }
201
-
202
- /** Current-generation streams; structured so the renderer can flow-wrap. */
203
- snapshot(): { text: string; textTruncated: boolean; thinking: string; thinkingTruncated: boolean } {
204
- return {
205
- text: this.text,
206
- textTruncated: this.textDropped > 0,
207
- thinking: this.thinking,
208
- thinkingTruncated: this.thinkingDropped > 0,
209
- };
210
- }
211
-
212
- clear(): void {
213
- this.text = "";
214
- this.thinking = "";
215
- this.textDropped = 0;
216
- this.thinkingDropped = 0;
217
- }
218
- }
219
-
220
- function cap(value: string, max: number): { value: string; dropped: number } {
221
- const points = [...value];
222
- const limit = Math.max(0, max);
223
- if (points.length <= limit) return { value, dropped: 0 };
224
- const dropped = points.length - limit;
225
- return { value: points.slice(dropped).join(""), dropped };
226
- }
227
-
228
136
  // ---------------------------------------------------------------------------
229
137
  // Trajectory log
230
138
  // ---------------------------------------------------------------------------
@@ -370,134 +278,35 @@ export class TrajectoryLog {
370
278
  }
371
279
  }
372
280
 
373
- // ---------------------------------------------------------------------------
374
- // Inspector run-state projection (trajectory + transcript + retained run info)
375
- // ---------------------------------------------------------------------------
376
-
377
- /**
378
- * Per-thread projection the inspector reads: live trajectory + bounded
379
- * transcript + retained snapshot for completed/failed/parked threads so the
380
- * detail pane survives monitor-row removal. Render code must treat this as
381
- * read-only.
382
- */
383
- export class InspectRunState {
281
+ export class ThreadTrajectoryState {
384
282
  readonly trajectory: TrajectoryLog;
385
- readonly transcript = new TranscriptBuffer();
386
-
387
- agent = "";
388
- task = "";
389
- label = "";
390
- model?: string;
391
- thinking?: string;
392
- status = "queued";
393
- startedAt?: number;
394
- endedAt?: number;
395
283
 
396
- /** Present for the monitor-row lifetime and beyond (via retained snapshot). */
397
- runInfo?: {
398
- usage?: UsageStats;
399
- toolCount?: number;
400
- activity?: string;
401
- currentTool?: string;
402
- };
403
-
404
- constructor(
405
- readonly runId: number,
406
- notify: () => void,
407
- ) {
408
- this.trajectory = new TrajectoryLog(runId, notify);
284
+ constructor(readonly runId: number) {
285
+ this.trajectory = new TrajectoryLog(runId, () => {});
409
286
  }
410
287
 
411
288
  get generation(): number {
412
289
  return this.trajectory.generation;
413
290
  }
414
-
415
- /** Preserve run metadata before the monitor row goes away (beginTurn sweep,
416
- * finishRun removal). Only fillsAnnounce fields; the inspector's detail pane
417
- * stays truthful even for finished, swept threads. */
418
- retainFrom(run: {
419
- agent: string;
420
- task: string;
421
- label?: string;
422
- model?: string;
423
- thinking?: string;
424
- status: string;
425
- startedAt?: number;
426
- endedAt?: number;
427
- usage?: UsageStats;
428
- toolCount?: number;
429
- activity?: string;
430
- currentTool?: string;
431
- }): void {
432
- this.agent = run.agent;
433
- this.task = run.task;
434
- this.label = run.label ?? "";
435
- this.model = run.model ?? this.model;
436
- this.thinking = run.thinking ?? this.thinking;
437
- this.status = run.status;
438
- this.startedAt = run.startedAt;
439
- this.endedAt = run.endedAt;
440
- this.runInfo = {
441
- usage: run.usage,
442
- toolCount: run.toolCount,
443
- activity: run.activity,
444
- currentTool: run.currentTool,
445
- };
446
- }
447
291
  }
448
292
 
449
- /**
450
- * Registry of inspector projections for the parent session. Survives monitor
451
- * row removal so completed/failed/parked threads stay inspectable.
452
- */
453
- export class InspectorStore {
454
- private readonly states = new Map<number, InspectRunState>();
455
- private readonly listeners = new Set<() => void>();
293
+ /** Session-scoped registry of logical-thread trajectories. */
294
+ export class TrajectoryStore {
295
+ private readonly states = new Map<number, ThreadTrajectoryState>();
456
296
 
457
- /** Get (creating on demand) the projection for a thread id. */
458
- get(runId: number): InspectRunState {
297
+ get(runId: number): ThreadTrajectoryState {
459
298
  let state = this.states.get(runId);
460
299
  if (!state) {
461
- state = new InspectRunState(runId, () => this.emit());
300
+ state = new ThreadTrajectoryState(runId);
462
301
  this.states.set(runId, state);
463
302
  }
464
303
  return state;
465
304
  }
466
305
 
467
- find(runId: number): InspectRunState | undefined {
468
- return this.states.get(runId);
469
- }
470
-
471
- all(): InspectRunState[] {
472
- return [...this.states.values()].sort((a, b) => a.runId - b.runId);
473
- }
474
-
475
- subscribe(listener: () => void): () => void {
476
- this.listeners.add(listener);
477
- return () => {
478
- this.listeners.delete(listener);
479
- };
480
- }
481
-
482
- private emit(): void {
483
- for (const listener of this.listeners) {
484
- try {
485
- listener();
486
- } catch {
487
- /* subscriber errors must not break the store */
488
- }
489
- }
490
- }
491
-
492
- /** Parent-session teardown only: drop every projection and its history. */
493
306
  clearAll(): void {
494
- for (const state of this.states.values()) {
495
- state.trajectory.clearAll();
496
- state.transcript.clear();
497
- }
307
+ for (const state of this.states.values()) state.trajectory.clearAll();
498
308
  this.states.clear();
499
- this.emit();
500
309
  }
501
310
  }
502
311
 
503
- export const inspectorStore = new InspectorStore();
312
+ export const trajectoryStore = new TrajectoryStore();
@@ -1,363 +0,0 @@
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
- }