@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/README.md +35 -16
- package/package.json +2 -2
- package/src/announcements.ts +59 -0
- package/src/dispatch.ts +1833 -1878
- package/src/fixloop.ts +1 -1
- package/src/format.ts +2 -1
- package/src/index.ts +4 -8
- package/src/models.ts +23 -35
- package/src/monitor.ts +29 -88
- package/src/rpc-run.ts +1 -26
- package/src/runtime.ts +284 -285
- package/src/setup.ts +4 -4
- package/src/spawn.ts +1 -6
- package/src/tools.ts +730 -748
- package/src/trajectory.ts +16 -207
- package/src/inspector-panel.ts +0 -363
- package/src/inspector.ts +0 -369
- package/src/widget.ts +0 -195
package/src/trajectory.ts
CHANGED
|
@@ -1,34 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Append-only
|
|
2
|
+
* Append-only lifecycle trajectories for logical sub-agent threads.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
397
|
-
|
|
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
|
-
|
|
451
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
312
|
+
export const trajectoryStore = new TrajectoryStore();
|
package/src/inspector-panel.ts
DELETED
|
@@ -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
|
-
}
|