@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/README.md +130 -74
- package/package.json +1 -1
- package/src/agents.ts +8 -13
- package/src/background.ts +59 -6
- package/src/completion.ts +28 -0
- package/src/config.ts +14 -2
- package/src/dispatch.ts +1438 -289
- package/src/format.ts +38 -6
- package/src/index.ts +7 -4
- package/src/inspector-panel.ts +363 -0
- package/src/inspector.ts +369 -0
- package/src/models.ts +192 -50
- package/src/monitor.ts +141 -7
- package/src/prompt.ts +9 -2
- package/src/recovery.ts +145 -0
- package/src/rpc-run.ts +1016 -0
- package/src/runtime.ts +183 -8
- package/src/session-fork.ts +84 -0
- package/src/setup.ts +271 -184
- package/src/spawn.ts +562 -856
- package/src/tools.ts +407 -65
- package/src/trajectory.ts +503 -0
- package/src/ui.ts +32 -16
- package/src/widget.ts +22 -5
- package/src/worktree.ts +687 -0
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
|
|
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
|
|
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
|
-
|
|
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
|
|
131
|
-
|
|
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 /
|
|
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:
|
|
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
|
+
}
|