@rahularya01/pi-essentials 0.1.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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +324 -0
  3. package/examples/mcp.json +30 -0
  4. package/examples/pi-essentials.json +32 -0
  5. package/examples/pi-settings.json +5 -0
  6. package/package.json +88 -0
  7. package/skills/pi-essentials/SKILL.md +50 -0
  8. package/src/config.ts +351 -0
  9. package/src/errors.ts +96 -0
  10. package/src/index.ts +43 -0
  11. package/src/mcp/commands.ts +390 -0
  12. package/src/mcp/config.ts +157 -0
  13. package/src/mcp/credential-store.ts +153 -0
  14. package/src/mcp/index.ts +67 -0
  15. package/src/mcp/manager.ts +941 -0
  16. package/src/mcp/oauth.ts +262 -0
  17. package/src/mcp/proxy-tool.ts +213 -0
  18. package/src/mcp/render.ts +164 -0
  19. package/src/mcp/types.ts +63 -0
  20. package/src/paths.ts +48 -0
  21. package/src/questions/ask.ts +134 -0
  22. package/src/questions/index.ts +72 -0
  23. package/src/questions/render.ts +69 -0
  24. package/src/questions/validate.ts +85 -0
  25. package/src/security/env.ts +132 -0
  26. package/src/security/limits.ts +20 -0
  27. package/src/security/ssrf.ts +237 -0
  28. package/src/subagents/activity.ts +132 -0
  29. package/src/subagents/builtins/oracle.md +11 -0
  30. package/src/subagents/builtins/reviewer.md +11 -0
  31. package/src/subagents/builtins/scout.md +12 -0
  32. package/src/subagents/builtins/worker.md +11 -0
  33. package/src/subagents/discover.ts +54 -0
  34. package/src/subagents/herdr.ts +150 -0
  35. package/src/subagents/index.ts +642 -0
  36. package/src/subagents/inspector-tail.d.mts +1 -0
  37. package/src/subagents/inspector-tail.mjs +140 -0
  38. package/src/subagents/render.ts +464 -0
  39. package/src/subagents/runner.ts +468 -0
  40. package/src/subagents/schema.ts +107 -0
  41. package/src/subagents/types.ts +131 -0
  42. package/src/subagents/worktree.ts +131 -0
  43. package/src/todos/index.ts +170 -0
  44. package/src/todos/render.ts +198 -0
  45. package/src/todos/state.ts +310 -0
  46. package/src/ui/render.ts +215 -0
  47. package/src/web/activity.ts +91 -0
  48. package/src/web/cache.ts +153 -0
  49. package/src/web/extract.ts +75 -0
  50. package/src/web/fetch.ts +167 -0
  51. package/src/web/html-to-markdown.ts +284 -0
  52. package/src/web/http.ts +238 -0
  53. package/src/web/index.ts +214 -0
  54. package/src/web/providers/brave.ts +27 -0
  55. package/src/web/providers/duckduckgo.ts +60 -0
  56. package/src/web/providers/exa.ts +29 -0
  57. package/src/web/providers/jina.ts +25 -0
  58. package/src/web/providers/searxng.ts +29 -0
  59. package/src/web/providers/tavily.ts +31 -0
  60. package/src/web/providers/types.ts +75 -0
  61. package/src/web/render.ts +130 -0
  62. package/src/web/search.ts +108 -0
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ // Standalone, read-only viewer run inside a Herdr pane (see herdr.ts / index.ts).
3
+ // Tails a subagent's raw `pi --mode json` event log and prints a live transcript.
4
+ // Deliberately dependency-free plain JS: it runs as its own process, outside pi's
5
+ // extension loader, so it cannot import this package's TypeScript sources.
6
+
7
+ import fs from "node:fs";
8
+ import { pathToFileURL } from "node:url";
9
+
10
+ const ANSI = {
11
+ reset: "\x1b[0m",
12
+ dim: "\x1b[2m",
13
+ bold: "\x1b[1m",
14
+ green: "\x1b[32m",
15
+ red: "\x1b[31m",
16
+ yellow: "\x1b[33m",
17
+ cyan: "\x1b[36m",
18
+ };
19
+
20
+ function color(code, text) {
21
+ return `${code}${text}${ANSI.reset}`;
22
+ }
23
+
24
+ function oneLine(text, max) {
25
+ const flat = String(text ?? "").replace(/\s+/g, " ").trim();
26
+ return flat.length > max ? `${flat.slice(0, Math.max(1, max - 1))}…` : flat;
27
+ }
28
+
29
+ /** Pure formatter: one parsed JSON event -> one printable line, or undefined to skip. */
30
+ export function formatEvent(event, state) {
31
+ if (!event || typeof event !== "object") return undefined;
32
+
33
+ if (event.type === "__meta__") {
34
+ const lines = [
35
+ color(ANSI.bold, `Subagent: ${event.agent ?? "agent"}`),
36
+ color(ANSI.dim, `Task: ${oneLine(event.task ?? "", 100) || "(no task)"}`),
37
+ color(ANSI.dim, "Read-only Herdr inspector · Ctrl+C to close this pane"),
38
+ "",
39
+ ];
40
+ return lines.join("\n");
41
+ }
42
+
43
+ if (event.type === "tool_execution_start" && event.toolName) {
44
+ return `${color(ANSI.yellow, "●")} ${color(ANSI.cyan, event.toolName)} ${color(ANSI.dim, "running")}`;
45
+ }
46
+ if (event.type === "tool_execution_end" && event.toolName) {
47
+ const ok = !event.isError;
48
+ return `${color(ok ? ANSI.green : ANSI.red, ok ? "✓" : "✗")} ${color(ANSI.cyan, event.toolName)}`;
49
+ }
50
+ if (event.type === "message_update") {
51
+ const delta = event.assistantMessageEvent;
52
+ if (delta?.type === "text_delta" && typeof delta.delta === "string") {
53
+ state.textBuffer = (state.textBuffer ?? "") + delta.delta;
54
+ }
55
+ return undefined;
56
+ }
57
+ if (event.type === "message_end" && event.message?.role === "assistant") {
58
+ if (event.message.errorMessage) {
59
+ state.textBuffer = "";
60
+ return color(ANSI.red, oneLine(event.message.errorMessage, 200));
61
+ }
62
+ const text = (state.textBuffer ?? "").trim();
63
+ state.textBuffer = "";
64
+ return text ? color(ANSI.dim, oneLine(text, 200)) : undefined;
65
+ }
66
+ return undefined;
67
+ }
68
+
69
+ function parseArgs(argv) {
70
+ const out = {};
71
+ for (let i = 0; i < argv.length; i++) {
72
+ if (argv[i] === "--log") out.log = argv[++i];
73
+ }
74
+ return out;
75
+ }
76
+
77
+ function main() {
78
+ const { log } = parseArgs(process.argv.slice(2));
79
+ if (!log) {
80
+ process.stderr.write("Usage: inspector-tail.mjs --log <path>\n");
81
+ process.exitCode = 1;
82
+ return;
83
+ }
84
+
85
+ const state = { textBuffer: "" };
86
+ let offset = 0;
87
+ let buffer = "";
88
+ let missing = false;
89
+
90
+ const consume = (chunk) => {
91
+ buffer += chunk;
92
+ const lines = buffer.split("\n");
93
+ buffer = lines.pop() ?? "";
94
+ for (const raw of lines) {
95
+ if (!raw.trim()) continue;
96
+ let event;
97
+ try {
98
+ event = JSON.parse(raw);
99
+ } catch {
100
+ continue;
101
+ }
102
+ const line = formatEvent(event, state);
103
+ if (line !== undefined) process.stdout.write(`${line}\n`);
104
+ }
105
+ };
106
+
107
+ const poll = () => {
108
+ let size;
109
+ try {
110
+ size = fs.statSync(log).size;
111
+ } catch {
112
+ if (!missing) {
113
+ missing = true;
114
+ process.stdout.write(`\n${color(ANSI.dim, "── run finished ──")}\n`);
115
+ }
116
+ return;
117
+ }
118
+ if (size <= offset) return;
119
+ const fd = fs.openSync(log, "r");
120
+ try {
121
+ const length = size - offset;
122
+ const chunk = Buffer.alloc(length);
123
+ fs.readSync(fd, chunk, 0, length, offset);
124
+ offset = size;
125
+ consume(chunk.toString("utf8"));
126
+ } finally {
127
+ fs.closeSync(fd);
128
+ }
129
+ };
130
+
131
+ poll();
132
+ const timer = setInterval(poll, 200);
133
+ process.on("SIGINT", () => {
134
+ clearInterval(timer);
135
+ process.exit(0);
136
+ });
137
+ }
138
+
139
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
140
+ if (isMain) main();
@@ -0,0 +1,464 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { matchesKey, Text, truncateToWidth, visibleWidth, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
3
+ import {
4
+ failLine,
5
+ firstText,
6
+ formatCost,
7
+ formatCount,
8
+ formatDuration,
9
+ GLYPH,
10
+ meta,
11
+ okLine,
12
+ oneLine,
13
+ plural,
14
+ safeKeyHint,
15
+ safeRender,
16
+ spinnerFrame,
17
+ titleLine,
18
+ type RenderableResult,
19
+ type RenderSlot,
20
+ } from "../ui/render.ts";
21
+ import type { TraceEvent } from "./activity.ts";
22
+ import type { ActiveRun, RunUsage } from "./runner.ts";
23
+
24
+ type DisplayRun = ActiveRun & {
25
+ status?: "running" | "cancelling" | "succeeded" | "failed";
26
+ finishedAt?: number;
27
+ output?: string;
28
+ error?: string;
29
+ usage?: RunUsage;
30
+ model?: string;
31
+ };
32
+
33
+ interface ResultSummary {
34
+ id?: string;
35
+ agent?: string;
36
+ exitCode?: number;
37
+ durationMs?: number;
38
+ usage?: RunUsage;
39
+ model?: string;
40
+ error?: string;
41
+ activity?: string;
42
+ events?: TraceEvent[];
43
+ running?: boolean;
44
+ status?: "queued" | "running" | "cancelling" | "succeeded" | "failed";
45
+ task?: string;
46
+ }
47
+
48
+ interface SubagentDetails {
49
+ mode?: string;
50
+ skipped?: number;
51
+ results?: ResultSummary[];
52
+ }
53
+
54
+ interface SubagentArgs {
55
+ agent?: string;
56
+ task?: string;
57
+ tasks?: Array<{ agent: string; task: string }>;
58
+ chain?: Array<{ agent: string; task: string }>;
59
+ }
60
+
61
+ export function renderSubagentCall(args: SubagentArgs, theme: Theme, context: RenderSlot): Text {
62
+ return safeRender(
63
+ () => {
64
+ const jobs = args?.tasks ?? args?.chain ?? (args?.agent ? [{ agent: args.agent, task: args.task ?? "" }] : []);
65
+ const mode = args?.chain?.length ? "chain" : args?.tasks?.length ? "parallel" : "single";
66
+ const agents = [...new Set(jobs.map((job) => job.agent))].join(", ");
67
+ let line = titleLine(theme, "subagent", agents || undefined);
68
+ line += meta(theme, [mode !== "single" ? `${mode} ×${jobs.length}` : undefined]);
69
+ if (jobs.length === 1 && jobs[0]?.task) {
70
+ line += `\n ${theme.fg("dim", oneLine(jobs[0].task, 88))}`;
71
+ }
72
+ return line;
73
+ },
74
+ "subagent",
75
+ context,
76
+ );
77
+ }
78
+
79
+ export function renderSubagentResult(
80
+ result: RenderableResult<SubagentDetails | undefined>,
81
+ options: { expanded: boolean; isPartial: boolean },
82
+ theme: Theme,
83
+ context: RenderSlot,
84
+ ): Text {
85
+ return safeRender(
86
+ () => {
87
+ const text = firstText(result);
88
+ if (options.isPartial) {
89
+ const live = result?.details?.results ?? [];
90
+ if (live.length === 0) {
91
+ return `${theme.fg("accent", GLYPH.running)} ${theme.fg("muted", oneLine(text, 88) || "working…")}`;
92
+ }
93
+ const states = live.map((row) => row.status === "queued" ? "queued"
94
+ : row.status === "failed" || (row.exitCode !== undefined && row.exitCode !== 0) ? "failed"
95
+ : row.status === "succeeded" || row.running === false || row.exitCode === 0 ? "completed"
96
+ : row.status ?? "running");
97
+ const header = theme.fg("text", "Subagents") + meta(theme,
98
+ ["running", "cancelling", "queued", "completed", "failed"].map((state) => {
99
+ const count = states.filter((value) => value === state).length;
100
+ return count ? `${count} ${state}` : undefined;
101
+ }));
102
+ const rows = live.map((row, index) => {
103
+ const state = states[index];
104
+ const glyph = state === "completed" ? GLYPH.ok : state === "failed" ? GLYPH.fail : state === "queued" ? "○" : "●";
105
+ const line = `${theme.fg(state === "failed" ? "error" : state === "completed" ? "success" : "accent", glyph)} ${theme.fg("accent", row.agent ?? "agent")}` +
106
+ meta(theme, [row.id, state, row.durationMs !== undefined ? formatDuration(row.durationMs) : undefined,
107
+ row.usage ? `${formatCount(row.usage.input)}→${formatCount(row.usage.output)} tok` : undefined,
108
+ formatCost(row.usage?.cost ?? 0), row.model]) +
109
+ theme.fg("dim", ` ${GLYPH.sep} ${oneLine(row.error || row.activity || row.task || state || "working…", 56)}`);
110
+ if (!options.expanded) return line;
111
+ const events = row.events ?? [];
112
+ if (events.length === 0) return line;
113
+ return `${line}\n${events.map((event) => ` ${renderTraceEvent(theme, event)}`).join("\n")}`;
114
+ });
115
+ let out = header;
116
+ for (const row of rows) out += `\n ${row}`;
117
+ if (!options.expanded) {
118
+ out += theme.fg("dim", `\n ${safeKeyHint("app.tools.expand", "for live detail")} ${GLYPH.sep} ↓/← fleet`);
119
+ }
120
+ return out;
121
+ }
122
+ if (context.isError) return failLine(theme, oneLine(text || "subagent failed", 96));
123
+
124
+ const results = result?.details?.results ?? [];
125
+ const totals = results.reduce(
126
+ (sum, row) => ({
127
+ input: sum.input + (row.usage?.input ?? 0),
128
+ output: sum.output + (row.usage?.output ?? 0),
129
+ cost: sum.cost + (row.usage?.cost ?? 0),
130
+ ms: Math.max(sum.ms, row.durationMs ?? 0),
131
+ }),
132
+ { input: 0, output: 0, cost: 0, ms: 0 },
133
+ );
134
+
135
+ const failed = results.filter((row) => row.exitCode !== 0).length;
136
+ const header =
137
+ okLine(theme, theme.fg("text", `${results.length - failed}/${results.length} finished`)) +
138
+ meta(theme, [
139
+ totals.ms ? formatDuration(totals.ms) : undefined,
140
+ totals.input || totals.output
141
+ ? `${formatCount(totals.input)}→${formatCount(totals.output)} tok`
142
+ : undefined,
143
+ formatCost(totals.cost),
144
+ ]);
145
+
146
+ const rows = results.map((row) => {
147
+ const ok = row.exitCode === 0;
148
+ return (
149
+ `${theme.fg(ok ? "success" : "error", ok ? GLYPH.ok : GLYPH.fail)} ${theme.fg("accent", row.agent ?? "agent")}` +
150
+ meta(theme, [row.id]) +
151
+ theme.fg(
152
+ "dim",
153
+ ` ${GLYPH.sep} ${formatDuration(row.durationMs ?? 0)}` +
154
+ (row.usage?.turns ? ` ${GLYPH.sep} ${plural(row.usage.turns, "turn")}` : "") +
155
+ (row.error ? ` ${GLYPH.sep} ${oneLine(row.error, 40)}` : ""),
156
+ )
157
+ );
158
+ });
159
+
160
+ let out = header;
161
+ for (const row of rows) out += `\n ${row}`;
162
+ if (result?.details?.skipped) {
163
+ out += theme.fg("warning", `\n ${GLYPH.sep} ${result.details.skipped} chain step(s) skipped after a failure`);
164
+ }
165
+ if (options.expanded) {
166
+ for (const line of text.split("\n")) out += `\n ${theme.fg("toolOutput", line)}`;
167
+ } else {
168
+ out += theme.fg("dim", `\n ${safeKeyHint("app.tools.expand", "to read the answers")}`);
169
+ }
170
+ return out;
171
+ },
172
+ oneLine(firstText(result), 120),
173
+ context,
174
+ );
175
+ }
176
+
177
+ export interface FleetOptions {
178
+ collapsed: boolean;
179
+ spawned: number;
180
+ budget: number;
181
+ now?: number;
182
+ selectedId?: string;
183
+ roster?: boolean;
184
+ }
185
+
186
+ export interface FleetHit {
187
+ y0: number;
188
+ y1: number;
189
+ id: string;
190
+ }
191
+
192
+ export interface FleetLayout {
193
+ lines: string[];
194
+ hits: FleetHit[];
195
+ }
196
+
197
+ function runPreview(run: ActiveRun): string {
198
+ return truncateToWidth((run.activity || run.task).replace(/\s+/g, " ").trim(), 44);
199
+ }
200
+
201
+ /** Six compact rows; roster mode changes focus and hints, not visibility. */
202
+ export function fleetLayout(theme: Theme, runs: DisplayRun[], options: FleetOptions, width = 120): FleetLayout {
203
+ if (runs.length === 0) return { lines: [], hits: [] };
204
+ const now = options.now ?? Date.now();
205
+ const running = runs.filter((run) => !run.status || run.status === "running" || run.status === "cancelling").length;
206
+ const noun = running === 1 ? "agent" : "agents";
207
+ const compact =
208
+ ` ${theme.fg("muted", `${running} running ${noun}`)}` +
209
+ theme.fg("dim", ` ${GLYPH.sep} ${options.spawned}/${options.budget} spawned ${GLYPH.sep} ${options.roster ? "↑↓/jk select · enter inspect · h herdr · esc back" : "↓/← to inspect"}`);
210
+
211
+ if (options.collapsed || !options.roster) {
212
+ return { lines: [truncateToWidth(compact, Math.max(0, width))], hits: [] };
213
+ }
214
+
215
+ const lines = [compact];
216
+ const hits: FleetHit[] = [];
217
+ const selectedIndex = Math.max(0, runs.findIndex((run) => run.id === options.selectedId));
218
+ const start = Math.max(0, Math.min(selectedIndex - 3, runs.length - 6));
219
+ for (const run of runs.slice(start, start + 6)) {
220
+ const selected = options.roster && run.id === options.selectedId;
221
+ const y0 = lines.length;
222
+ const marker = selected ? ">" : " ";
223
+ const name = selected ? theme.bold(theme.fg("accent", run.agent)) : theme.fg("text", run.agent);
224
+ lines.push(
225
+ `${marker} ${theme.fg("accent", run.status === "succeeded" ? GLYPH.ok : run.status === "failed" ? GLYPH.fail : spinnerFrame(run.startedAt, now))} ${theme.fg("dim", `[${run.id}]`)} ${name}` +
226
+ theme.fg("dim", ` ${formatDuration((run.finishedAt ?? now) - run.startedAt)} ${GLYPH.sep} ${run.status === "cancelling" ? "cancelling · " : ""}${runPreview(run)}`),
227
+ );
228
+ hits.push({ y0, y1: lines.length, id: run.id });
229
+ }
230
+ if (runs.length > 6) lines.push(theme.fg("dim", ` ${start + 1}-${Math.min(start + 6, runs.length)} of ${runs.length} agents`));
231
+ return { lines: lines.map((line) => truncateToWidth(line, Math.max(0, width))), hits };
232
+ }
233
+
234
+ export function fleetWidget(theme: Theme, runs: ActiveRun[], options: FleetOptions): string[] {
235
+ return fleetLayout(theme, runs, options).lines;
236
+ }
237
+
238
+ export function hitTestFleet(hits: FleetHit[], y: number): string | undefined {
239
+ return hits.find((hit) => y >= hit.y0 && y < hit.y1)?.id;
240
+ }
241
+
242
+ /** Clickable below-editor roster. Stable across ticker frames so mouse clicks land. */
243
+ export class FleetPanel implements Component {
244
+ private theme: Theme | undefined;
245
+ private runs: ActiveRun[] = [];
246
+ private options: FleetOptions = { collapsed: false, spawned: 0, budget: 0 };
247
+ private hits: FleetHit[] = [];
248
+ onSelect: ((id: string) => void) | undefined;
249
+
250
+ setTheme(theme: Theme): void {
251
+ this.theme = theme;
252
+ }
253
+
254
+ setState(runs: ActiveRun[], options: FleetOptions): void {
255
+ this.runs = runs;
256
+ this.options = options;
257
+ }
258
+
259
+ render(width: number): string[] {
260
+ if (!this.theme) return [];
261
+ const layout = fleetLayout(this.theme, this.runs, this.options, width);
262
+ this.hits = layout.hits;
263
+ return layout.lines;
264
+ }
265
+
266
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
267
+ if (event.type !== "click" || event.button !== "left") return undefined;
268
+ const id = hitTestFleet(this.hits, event.y);
269
+ if (!id) return undefined;
270
+ this.onSelect?.(id);
271
+ return { handled: true, render: true };
272
+ }
273
+
274
+ invalidate(): void {}
275
+ }
276
+
277
+ function toolGlyph(theme: Theme, status: Extract<TraceEvent, { kind: "tool" }>["status"]): string {
278
+ if (status === "running") return theme.fg("warning", "●");
279
+ if (status === "error") return theme.fg("error", GLYPH.fail);
280
+ return theme.fg("success", GLYPH.ok);
281
+ }
282
+
283
+ export function renderTraceEvent(theme: Theme, event: TraceEvent): string {
284
+ if (event.kind === "tool") {
285
+ const args = event.args ? ` ${theme.fg("dim", event.args)}` : "";
286
+ const suffix = event.status === "running" ? theme.fg("warning", " running") : "";
287
+ return `${theme.fg("borderMuted", "├─")} ${toolGlyph(theme, event.status)} ${theme.fg("toolTitle", event.name)}${args}${suffix}`;
288
+ }
289
+ return `${theme.fg("accent", "◆")} ${theme.fg("toolOutput", event.text)}`;
290
+ }
291
+
292
+ /** The selected child's header, task, and live tool/text transcript -- the right column of the inspector frame. */
293
+ export function conversationLines(theme: Theme, run: ActiveRun | undefined, now = Date.now()): string[] {
294
+ if (!run) return [theme.fg("muted", "Subagent finished.")];
295
+ const heading = `${theme.fg("accent", "●")} ${theme.bold(run.agent)}` +
296
+ theme.fg("dim", ` running ${GLYPH.sep} ${formatDuration(now - run.startedAt)}` +
297
+ (run.activity ? ` ${GLYPH.sep} ${oneLine(run.activity, 40)}` : ""));
298
+ const lines = [
299
+ heading,
300
+ ` ${theme.fg("muted", `Task: ${oneLine(run.task, 72) || "(no task)"}`)}`,
301
+ `${theme.fg("accent", "Conversation")} ${theme.fg("dim", "· live")}`,
302
+ ];
303
+ const events = run.events ?? [];
304
+ if (events.length === 0) lines.push(` ${theme.fg("muted", "waiting for the child to start…")}`);
305
+ else for (const event of events) lines.push(renderTraceEvent(theme, event));
306
+ return lines;
307
+ }
308
+
309
+ /** One row per child for the inspector's left roster column. */
310
+ export function rosterLines(theme: Theme, runs: ActiveRun[], selectedId: string | undefined, now = Date.now()): string[] {
311
+ return runs.map((run) => {
312
+ const selected = run.id === selectedId;
313
+ const marker = selected ? theme.fg("accent", ">") : " ";
314
+ const name = selected ? theme.bold(theme.fg("accent", run.agent)) : theme.fg("text", run.agent);
315
+ return `${marker} ${theme.fg("accent", spinnerFrame(run.startedAt, now))} ${name}` +
316
+ theme.fg("dim", ` ${run.id} ${GLYPH.sep} ${formatDuration(now - run.startedAt)}`);
317
+ });
318
+ }
319
+
320
+ const BOX = { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│", td: "┬", bu: "┴" } as const;
321
+
322
+ /** Pad/truncate to an exact visible width, ANSI-safe (colored text keeps its escapes). */
323
+ function cell(text: string, width: number): string {
324
+ return truncateToWidth(text, Math.max(0, width), "…", true);
325
+ }
326
+
327
+ function frameTitleBar(theme: Theme, title: string, status: string, width: number): string {
328
+ const inner = Math.max(0, width - 2);
329
+ const left = ` ${title} `;
330
+ const right = status ? ` ${status} ` : "";
331
+ const dashWidth = Math.max(1, inner - left.length - visibleWidth(right));
332
+ return (
333
+ theme.fg("borderMuted", BOX.tl) +
334
+ theme.bold(theme.fg("text", left)) +
335
+ theme.fg("borderMuted", BOX.h.repeat(dashWidth)) +
336
+ right +
337
+ theme.fg("borderMuted", BOX.tr)
338
+ );
339
+ }
340
+
341
+ function frameRule(theme: Theme, leftWidth: number, rightWidth: number, corners: readonly [string, string, string]): string {
342
+ return theme.fg(
343
+ "borderMuted",
344
+ `${corners[0]}${BOX.h.repeat(leftWidth)}${corners[1]}${BOX.h.repeat(rightWidth)}${corners[2]}`,
345
+ );
346
+ }
347
+
348
+ function frameContentRow(theme: Theme, left: string, right: string, leftWidth: number, rightWidth: number): string {
349
+ return `${theme.fg("borderMuted", BOX.v)}${cell(left, leftWidth)}${theme.fg("borderMuted", BOX.v)}${cell(right, rightWidth)}${theme.fg("borderMuted", BOX.v)}`;
350
+ }
351
+
352
+ /**
353
+ * Two-column bordered inspector frame -- a roster column beside the selected
354
+ * child's live transcript, matching pi-subagents' Fleet inspector layout
355
+ * instead of a single stacked list.
356
+ */
357
+ export function inspectorFrame(
358
+ theme: Theme,
359
+ runs: ActiveRun[],
360
+ selectedId: string | undefined,
361
+ options: { width: number; rows: number; scroll: number; now?: number },
362
+ ): { lines: string[]; maxScroll: number } {
363
+ const now = options.now ?? Date.now();
364
+ const run = (selectedId ? runs.find((r) => r.id === selectedId) : undefined) ?? runs[0];
365
+ const width = Math.max(20, options.width);
366
+ const leftWidth = Math.min(40, Math.max(18, Math.floor((width - 3) * 0.3)));
367
+ const rightWidth = Math.max(10, width - 3 - leftWidth);
368
+
369
+ const status = run ? `${theme.fg("accent", "●")} ${theme.bold(run.agent)} ${theme.fg("dim", GLYPH.sep)} ${theme.fg("muted", "running")}` : "";
370
+ const title = "Subagent fleet inspector · live controls";
371
+
372
+ const roster = rosterLines(theme, runs, run?.id, now);
373
+ const conversation = conversationLines(theme, run, now);
374
+ const rows = Math.max(3, options.rows);
375
+ const maxScroll = Math.max(0, conversation.length - rows);
376
+ const scroll = Math.max(0, Math.min(options.scroll, maxScroll));
377
+ const visibleConversation = conversation.slice(scroll, scroll + rows);
378
+
379
+ const lines: string[] = [frameTitleBar(theme, title, status, width)];
380
+ for (let i = 0; i < rows; i++) {
381
+ lines.push(frameContentRow(theme, roster[i] ?? "", visibleConversation[i] ?? "", leftWidth, rightWidth));
382
+ }
383
+ lines.push(frameRule(theme, leftWidth, rightWidth, [BOX.bl, BOX.bu, BOX.br]));
384
+ return { lines, maxScroll };
385
+ }
386
+
387
+ /** Overlay inspector: roster of children plus the selected child's live transcript. */
388
+ export class InspectorPanel implements Component {
389
+ private scroll = 0;
390
+
391
+ constructor(
392
+ private readonly getRuns: () => ActiveRun[],
393
+ private readonly getSelectedId: () => string | undefined,
394
+ private readonly onSelect: (id: string) => void,
395
+ private readonly theme: Theme,
396
+ private readonly done: () => void,
397
+ private readonly tui: { requestRender(): void },
398
+ private readonly onOpenHerdr?: (run: ActiveRun) => void,
399
+ ) {}
400
+
401
+ private selectedRun(): ActiveRun | undefined {
402
+ const runs = this.getRuns();
403
+ const id = this.getSelectedId();
404
+ return (id ? runs.find((run) => run.id === id) : undefined) ?? runs[0];
405
+ }
406
+
407
+ private move(delta: number): void {
408
+ const runs = this.getRuns();
409
+ if (runs.length === 0) {
410
+ this.done();
411
+ return;
412
+ }
413
+ const current = this.selectedRun();
414
+ const index = Math.max(0, runs.findIndex((run) => run.id === current?.id));
415
+ const next = runs[Math.max(0, Math.min(runs.length - 1, index + delta))];
416
+ if (next) this.onSelect(next.id);
417
+ this.scroll = 0;
418
+ this.tui.requestRender();
419
+ }
420
+
421
+ render(width: number): string[] {
422
+ const runs = this.getRuns();
423
+ const run = this.selectedRun();
424
+ if (!run) {
425
+ this.done();
426
+ return [this.theme.fg("muted", "No running subagents.")];
427
+ }
428
+ const rows = 16;
429
+ const { lines, maxScroll } = inspectorFrame(this.theme, runs, run.id, { width, rows, scroll: this.scroll });
430
+ this.scroll = Math.max(0, Math.min(this.scroll, maxScroll));
431
+ const footer = this.theme.fg("dim", ` ↑↓/jk agent ${GLYPH.sep} h herdr pane ${GLYPH.sep} pgup/pgdn scroll ${GLYPH.sep} esc close`);
432
+ return [...lines, footer];
433
+ }
434
+
435
+ handleInput(data: string): void {
436
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
437
+ this.done();
438
+ return;
439
+ }
440
+ if (matchesKey(data, "down") || matchesKey(data, "j")) this.move(1);
441
+ else if (matchesKey(data, "up") || matchesKey(data, "k")) this.move(-1);
442
+ else if (matchesKey(data, "h")) {
443
+ const run = this.selectedRun();
444
+ if (run) this.onOpenHerdr?.(run);
445
+ } else if (matchesKey(data, "pageDown")) {
446
+ this.scroll += 8;
447
+ this.tui.requestRender();
448
+ } else if (matchesKey(data, "pageUp")) {
449
+ this.scroll -= 8;
450
+ this.tui.requestRender();
451
+ }
452
+ }
453
+
454
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
455
+ if (event.type === "wheel" && event.wheelDelta) {
456
+ this.scroll += event.wheelDelta < 0 ? -1 : 1;
457
+ this.tui.requestRender();
458
+ return { handled: true, render: true };
459
+ }
460
+ return undefined;
461
+ }
462
+
463
+ invalidate(): void {}
464
+ }