Add /subagent-runs live monitoring panel

Extract run execution from index.ts into runner.ts, add an in-memory
RunRegistry shared between the subagent tool and a new /subagent-runs
command, give each run its own AbortController for per-run cancellation,
and add the runs-panel.ts monitor with a live-refresh timer. Also adds
TypeScript type-checking (tsconfig.json + typecheck script) so the
refactor has an automated safety net.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
EislM0203
2026-05-14 21:14:46 +02:00
co-authored by Claude Opus 4.7
parent 1921bf1a82
commit b8dbb25c17
8 changed files with 1006 additions and 372 deletions
+73 -367
View File
@@ -10,31 +10,34 @@
* - Chain: { chain: [{ agent: "name", task: "... {previous} ..." }, ...] }
*
* Uses JSON mode to capture structured output from subagents.
*
* Run execution lives in runner.ts; the live-run registry in runs.ts; the
* /subagent-runs monitor panel in runs-panel.ts. This file wires those together
* and owns the tool registration and result rendering.
*/
import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
import type { Message } from "@earendil-works/pi-ai";
import { StringEnum } from "@earendil-works/pi-ai";
import { type ExtensionAPI, getMarkdownTheme, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
import { type ExtensionAPI, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
import { Type } from "typebox";
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
import {
formatTokens,
getFinalOutput,
MAX_PARALLEL_TASKS,
runChain,
runParallel,
runSingle,
type SingleResult,
type SubagentDetails,
} from "./runner.js";
import { openRunsPanel } from "./runs-panel.js";
import { createRunRegistry } from "./runs.js";
const MAX_PARALLEL_TASKS = 8;
const MAX_CONCURRENCY = 4;
const COLLAPSED_ITEM_COUNT = 10;
function formatTokens(count: number): string {
if (count < 1000) return count.toString();
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
if (count < 1000000) return `${Math.round(count / 1000)}k`;
return `${(count / 1000000).toFixed(1)}M`;
}
function formatUsageStats(
usage: {
input: number;
@@ -129,49 +132,6 @@ function formatToolCall(
}
}
interface UsageStats {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
cost: number;
contextTokens: number;
turns: number;
}
interface SingleResult {
agent: string;
agentSource: "user" | "project" | "unknown";
task: string;
exitCode: number;
messages: Message[];
stderr: string;
usage: UsageStats;
model?: string;
stopReason?: string;
errorMessage?: string;
step?: number;
}
interface SubagentDetails {
mode: "single" | "parallel" | "chain";
agentScope: AgentScope;
projectAgentsDir: string | null;
results: SingleResult[];
}
function getFinalOutput(messages: Message[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === "assistant") {
for (const part of msg.content) {
if (part.type === "text") return part.text;
}
}
}
return "";
}
type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> };
function getDisplayItems(messages: Message[]): DisplayItem[] {
@@ -187,218 +147,6 @@ function getDisplayItems(messages: Message[]): DisplayItem[] {
return items;
}
async function mapWithConcurrencyLimit<TIn, TOut>(
items: TIn[],
concurrency: number,
fn: (item: TIn, index: number) => Promise<TOut>,
): Promise<TOut[]> {
if (items.length === 0) return [];
const limit = Math.max(1, Math.min(concurrency, items.length));
const results: TOut[] = new Array(items.length);
let nextIndex = 0;
const workers = new Array(limit).fill(null).map(async () => {
while (true) {
const current = nextIndex++;
if (current >= items.length) return;
results[current] = await fn(items[current], current);
}
});
await Promise.all(workers);
return results;
}
async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
const safeName = agentName.replace(/[^\w.-]+/g, "_");
const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
await withFileMutationQueue(filePath, async () => {
await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
});
return { dir: tmpDir, filePath };
}
function getPiInvocation(args: string[]): { command: string; args: string[] } {
const currentScript = process.argv[1];
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
return { command: process.execPath, args: [currentScript, ...args] };
}
const execName = path.basename(process.execPath).toLowerCase();
const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
if (!isGenericRuntime) {
return { command: process.execPath, args };
}
return { command: "pi", args };
}
type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
async function runSingleAgent(
defaultCwd: string,
agents: AgentConfig[],
agentName: string,
task: string,
cwd: string | undefined,
step: number | undefined,
signal: AbortSignal | undefined,
onUpdate: OnUpdateCallback | undefined,
makeDetails: (results: SingleResult[]) => SubagentDetails,
): Promise<SingleResult> {
const agent = agents.find((a) => a.name === agentName);
if (!agent) {
const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
return {
agent: agentName,
agentSource: "unknown",
task,
exitCode: 1,
messages: [],
stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
step,
};
}
const args: string[] = ["--mode", "json", "-p", "--no-session"];
if (agent.model) args.push("--model", agent.model);
if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
let tmpPromptDir: string | null = null;
let tmpPromptPath: string | null = null;
const currentResult: SingleResult = {
agent: agentName,
agentSource: agent.source,
task,
exitCode: 0,
messages: [],
stderr: "",
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
model: agent.model,
step,
};
const emitUpdate = () => {
if (onUpdate) {
onUpdate({
content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }],
details: makeDetails([currentResult]),
});
}
};
try {
if (agent.systemPrompt.trim()) {
const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt);
tmpPromptDir = tmp.dir;
tmpPromptPath = tmp.filePath;
args.push("--append-system-prompt", tmpPromptPath);
}
args.push(`Task: ${task}`);
let wasAborted = false;
const exitCode = await new Promise<number>((resolve) => {
const invocation = getPiInvocation(args);
const proc = spawn(invocation.command, invocation.args, {
cwd: cwd ?? defaultCwd,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
let buffer = "";
const processLine = (line: string) => {
if (!line.trim()) return;
let event: any;
try {
event = JSON.parse(line);
} catch {
return;
}
if (event.type === "message_end" && event.message) {
const msg = event.message as Message;
currentResult.messages.push(msg);
if (msg.role === "assistant") {
currentResult.usage.turns++;
const usage = msg.usage;
if (usage) {
currentResult.usage.input += usage.input || 0;
currentResult.usage.output += usage.output || 0;
currentResult.usage.cacheRead += usage.cacheRead || 0;
currentResult.usage.cacheWrite += usage.cacheWrite || 0;
currentResult.usage.cost += usage.cost?.total || 0;
currentResult.usage.contextTokens = usage.totalTokens || 0;
}
if (!currentResult.model && msg.model) currentResult.model = msg.model;
if (msg.stopReason) currentResult.stopReason = msg.stopReason;
if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage;
}
emitUpdate();
}
if (event.type === "tool_result_end" && event.message) {
currentResult.messages.push(event.message as Message);
emitUpdate();
}
};
proc.stdout.on("data", (data) => {
buffer += data.toString();
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) processLine(line);
});
proc.stderr.on("data", (data) => {
currentResult.stderr += data.toString();
});
proc.on("close", (code) => {
if (buffer.trim()) processLine(buffer);
resolve(code ?? 0);
});
proc.on("error", () => {
resolve(1);
});
if (signal) {
const killProc = () => {
wasAborted = true;
proc.kill("SIGTERM");
setTimeout(() => {
if (!proc.killed) proc.kill("SIGKILL");
}, 5000);
};
if (signal.aborted) killProc();
else signal.addEventListener("abort", killProc, { once: true });
}
});
currentResult.exitCode = exitCode;
if (wasAborted) throw new Error("Subagent was aborted");
return currentResult;
} finally {
if (tmpPromptPath)
try {
fs.unlinkSync(tmpPromptPath);
} catch {
/* ignore */
}
if (tmpPromptDir)
try {
fs.rmdirSync(tmpPromptDir);
} catch {
/* ignore */
}
}
}
const TaskItem = Type.Object({
agent: Type.String({ description: "Name of the agent to invoke" }),
task: Type.String({ description: "Task to delegate to the agent" }),
@@ -429,6 +177,25 @@ const SubagentParams = Type.Object({
});
export default function (pi: ExtensionAPI) {
// Shared by closure between the subagent tool (records runs) and the
// /subagent-runs command (displays them). Lives for the pi session.
const registry = createRunRegistry();
pi.registerCommand("subagent-runs", {
description: "Monitor subagent runs — live status, usage, and per-run cancel",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("/subagent-runs needs an interactive session", "warning");
return;
}
try {
await openRunsPanel(ctx, registry);
} catch (err) {
ctx.ui.notify(`/subagent-runs error: ${(err as Error).message}`, "error");
}
},
});
pi.registerTool({
name: "subagent",
label: "Subagent",
@@ -499,56 +266,34 @@ export default function (pi: ExtensionAPI) {
}
if (params.chain && params.chain.length > 0) {
const results: SingleResult[] = [];
let previousOutput = "";
const results = await runChain({
defaultCwd: ctx.cwd,
agents,
steps: params.chain,
signal,
onUpdate,
makeDetails: makeDetails("chain"),
tracker: registry,
});
for (let i = 0; i < params.chain.length; i++) {
const step = params.chain[i];
const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
// Create update callback that includes all previous results
const chainUpdate: OnUpdateCallback | undefined = onUpdate
? (partial) => {
// Combine completed results with current streaming result
const currentResult = partial.details?.results[0];
if (currentResult) {
const allResults = [...results, currentResult];
onUpdate({
content: partial.content,
details: makeDetails("chain")(allResults),
});
}
}
: undefined;
const result = await runSingleAgent(
ctx.cwd,
agents,
step.agent,
taskWithContext,
step.cwd,
i + 1,
signal,
chainUpdate,
makeDetails("chain"),
);
results.push(result);
const isError =
result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
if (isError) {
const errorMsg =
result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
return {
content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
details: makeDetails("chain")(results),
isError: true,
};
}
previousOutput = getFinalOutput(result.messages);
const last = results[results.length - 1];
const lastIsError =
last.exitCode !== 0 || last.stopReason === "error" || last.stopReason === "aborted";
if (lastIsError) {
const errorMsg =
last.errorMessage || last.stderr || getFinalOutput(last.messages) || "(no output)";
return {
content: [
{ type: "text", text: `Chain stopped at step ${last.step} (${last.agent}): ${errorMsg}` },
],
details: makeDetails("chain")(results),
isError: true,
};
}
return {
content: [{ type: "text", text: getFinalOutput(results[results.length - 1].messages) || "(no output)" }],
content: [
{ type: "text", text: getFinalOutput(results[results.length - 1].messages) || "(no output)" },
],
details: makeDetails("chain")(results),
};
}
@@ -565,56 +310,14 @@ export default function (pi: ExtensionAPI) {
details: makeDetails("parallel")([]),
};
// Track all results for streaming updates
const allResults: SingleResult[] = new Array(params.tasks.length);
// Initialize placeholder results
for (let i = 0; i < params.tasks.length; i++) {
allResults[i] = {
agent: params.tasks[i].agent,
agentSource: "unknown",
task: params.tasks[i].task,
exitCode: -1, // -1 = still running
messages: [],
stderr: "",
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
};
}
const emitParallelUpdate = () => {
if (onUpdate) {
const running = allResults.filter((r) => r.exitCode === -1).length;
const done = allResults.filter((r) => r.exitCode !== -1).length;
onUpdate({
content: [
{ type: "text", text: `Parallel: ${done}/${allResults.length} done, ${running} running...` },
],
details: makeDetails("parallel")([...allResults]),
});
}
};
const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
const result = await runSingleAgent(
ctx.cwd,
agents,
t.agent,
t.task,
t.cwd,
undefined,
signal,
// Per-task update callback
(partial) => {
if (partial.details?.results[0]) {
allResults[index] = partial.details.results[0];
emitParallelUpdate();
}
},
makeDetails("parallel"),
);
allResults[index] = result;
emitParallelUpdate();
return result;
const results = await runParallel({
defaultCwd: ctx.cwd,
agents,
tasks: params.tasks,
signal,
onUpdate,
makeDetails: makeDetails("parallel"),
tracker: registry,
});
const successCount = results.filter((r) => r.exitCode === 0).length;
@@ -635,7 +338,7 @@ export default function (pi: ExtensionAPI) {
}
if (params.agent && params.task) {
const result = await runSingleAgent(
const result = await runSingle(
ctx.cwd,
agents,
params.agent,
@@ -645,6 +348,9 @@ export default function (pi: ExtensionAPI) {
signal,
onUpdate,
makeDetails("single"),
registry,
"single",
undefined,
);
const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
if (isError) {
+447
View File
@@ -0,0 +1,447 @@
/**
* Subagent run execution — spawns child `pi` processes and orchestrates
* single / parallel / chain runs. Extracted from index.ts so the run registry
* has a clean home and index.ts stays focused on tool I/O and rendering.
*/
import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
import type { Message } from "@earendil-works/pi-ai";
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
import type { AgentConfig, AgentScope } from "./agents.js";
import type { RunRegistry } from "./runs.js";
export const MAX_PARALLEL_TASKS = 8;
export const MAX_CONCURRENCY = 4;
export interface UsageStats {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
cost: number;
contextTokens: number;
turns: number;
}
export interface SingleResult {
agent: string;
agentSource: "user" | "project" | "unknown";
task: string;
exitCode: number;
messages: Message[];
stderr: string;
usage: UsageStats;
model?: string;
stopReason?: string;
errorMessage?: string;
step?: number;
}
export interface SubagentDetails {
mode: "single" | "parallel" | "chain";
agentScope: AgentScope;
projectAgentsDir: string | null;
results: SingleResult[];
}
export type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
/** Compact token count: 1234 -> "1.2k", 2_000_000 -> "2.0M". */
export function formatTokens(count: number): string {
if (count < 1000) return count.toString();
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
if (count < 1000000) return `${Math.round(count / 1000)}k`;
return `${(count / 1000000).toFixed(1)}M`;
}
export function getFinalOutput(messages: Message[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === "assistant") {
for (const part of msg.content) {
if (part.type === "text") return part.text;
}
}
}
return "";
}
async function mapWithConcurrencyLimit<TIn, TOut>(
items: TIn[],
concurrency: number,
fn: (item: TIn, index: number) => Promise<TOut>,
): Promise<TOut[]> {
if (items.length === 0) return [];
const limit = Math.max(1, Math.min(concurrency, items.length));
const results: TOut[] = new Array(items.length);
let nextIndex = 0;
const workers = new Array(limit).fill(null).map(async () => {
while (true) {
const current = nextIndex++;
if (current >= items.length) return;
results[current] = await fn(items[current], current);
}
});
await Promise.all(workers);
return results;
}
async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
const safeName = agentName.replace(/[^\w.-]+/g, "_");
const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
await withFileMutationQueue(filePath, async () => {
await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
});
return { dir: tmpDir, filePath };
}
function getPiInvocation(args: string[]): { command: string; args: string[] } {
const currentScript = process.argv[1];
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
return { command: process.execPath, args: [currentScript, ...args] };
}
const execName = path.basename(process.execPath).toLowerCase();
const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
if (!isGenericRuntime) {
return { command: process.execPath, args };
}
return { command: "pi", args };
}
export async function runSingle(
defaultCwd: string,
agents: AgentConfig[],
agentName: string,
task: string,
cwd: string | undefined,
step: number | undefined,
signal: AbortSignal | undefined,
onUpdate: OnUpdateCallback | undefined,
makeDetails: (results: SingleResult[]) => SubagentDetails,
tracker: RunRegistry,
mode: "single" | "parallel" | "chain",
stepCount: number | undefined,
): Promise<SingleResult> {
const agent = agents.find((a) => a.name === agentName);
if (!agent) {
const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
return {
agent: agentName,
agentSource: "unknown",
task,
exitCode: 1,
messages: [],
stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
step,
};
}
const args: string[] = ["--mode", "json", "-p", "--no-session"];
if (agent.model) args.push("--model", agent.model);
if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
let tmpPromptDir: string | null = null;
let tmpPromptPath: string | null = null;
const currentResult: SingleResult = {
agent: agentName,
agentSource: agent.source,
task,
exitCode: 0,
messages: [],
stderr: "",
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
model: agent.model,
step,
};
// Per-run cancellation: this run can be killed either by the session-wide
// `signal` (whole-tool abort) or by its own controller (panel "cancel").
const runController = new AbortController();
const record = tracker.register({
agent: agentName,
agentSource: agent.source,
task,
mode,
step,
stepCount,
abort: () => runController.abort(),
});
const emitUpdate = () => {
tracker.update(record.id, {
usage: currentResult.usage,
outputSoFar: getFinalOutput(currentResult.messages),
stderr: currentResult.stderr,
});
if (onUpdate) {
onUpdate({
content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }],
details: makeDetails([currentResult]),
});
}
};
try {
if (agent.systemPrompt.trim()) {
const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt);
tmpPromptDir = tmp.dir;
tmpPromptPath = tmp.filePath;
args.push("--append-system-prompt", tmpPromptPath);
}
args.push(`Task: ${task}`);
let wasAborted = false;
const exitCode = await new Promise<number>((resolve) => {
const invocation = getPiInvocation(args);
const proc = spawn(invocation.command, invocation.args, {
cwd: cwd ?? defaultCwd,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
let buffer = "";
const processLine = (line: string) => {
if (!line.trim()) return;
let event: any;
try {
event = JSON.parse(line);
} catch {
return;
}
if (event.type === "message_end" && event.message) {
const msg = event.message as Message;
currentResult.messages.push(msg);
if (msg.role === "assistant") {
currentResult.usage.turns++;
const usage = msg.usage;
if (usage) {
currentResult.usage.input += usage.input || 0;
currentResult.usage.output += usage.output || 0;
currentResult.usage.cacheRead += usage.cacheRead || 0;
currentResult.usage.cacheWrite += usage.cacheWrite || 0;
currentResult.usage.cost += usage.cost?.total || 0;
currentResult.usage.contextTokens = usage.totalTokens || 0;
}
if (!currentResult.model && msg.model) currentResult.model = msg.model;
if (msg.stopReason) currentResult.stopReason = msg.stopReason;
if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage;
}
emitUpdate();
}
if (event.type === "tool_result_end" && event.message) {
currentResult.messages.push(event.message as Message);
emitUpdate();
}
};
proc.stdout.on("data", (data) => {
buffer += data.toString();
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) processLine(line);
});
proc.stderr.on("data", (data) => {
currentResult.stderr += data.toString();
});
proc.on("close", (code) => {
if (buffer.trim()) processLine(buffer);
resolve(code ?? 0);
});
proc.on("error", () => {
resolve(1);
});
const killProc = () => {
wasAborted = true;
proc.kill("SIGTERM");
setTimeout(() => {
if (!proc.killed) proc.kill("SIGKILL");
}, 5000);
};
if (signal) {
if (signal.aborted) killProc();
else signal.addEventListener("abort", killProc, { once: true });
}
if (runController.signal.aborted) killProc();
else runController.signal.addEventListener("abort", killProc, { once: true });
});
currentResult.exitCode = exitCode;
tracker.update(record.id, {
usage: currentResult.usage,
outputSoFar: getFinalOutput(currentResult.messages),
stderr: currentResult.stderr,
});
if (wasAborted) {
tracker.finalize(record.id, { status: "aborted", exitCode, errorMessage: "Subagent was aborted" });
throw new Error("Subagent was aborted");
}
const isError =
exitCode !== 0 || currentResult.stopReason === "error" || currentResult.stopReason === "aborted";
tracker.finalize(record.id, {
status: isError ? "error" : "done",
exitCode,
errorMessage: currentResult.errorMessage,
});
return currentResult;
} finally {
if (tmpPromptPath)
try {
fs.unlinkSync(tmpPromptPath);
} catch {
/* ignore */
}
if (tmpPromptDir)
try {
fs.rmdirSync(tmpPromptDir);
} catch {
/* ignore */
}
}
}
export interface RunChainOptions {
defaultCwd: string;
agents: AgentConfig[];
steps: { agent: string; task: string; cwd?: string }[];
signal: AbortSignal | undefined;
onUpdate: OnUpdateCallback | undefined;
makeDetails: (results: SingleResult[]) => SubagentDetails;
tracker: RunRegistry;
}
/**
* Run chain steps sequentially, substituting `{previous}` with the prior
* step's final output. Stops early on the first failing step. Returns the
* results of every step that ran (the last one is the failure, if any).
*/
export async function runChain(opts: RunChainOptions): Promise<SingleResult[]> {
const { defaultCwd, agents, steps, signal, onUpdate, makeDetails, tracker } = opts;
const results: SingleResult[] = [];
let previousOutput = "";
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
const chainUpdate: OnUpdateCallback | undefined = onUpdate
? (partial) => {
const currentResult = partial.details?.results[0];
if (currentResult) {
const allResults = [...results, currentResult];
onUpdate({
content: partial.content,
details: makeDetails(allResults),
});
}
}
: undefined;
const result = await runSingle(
defaultCwd,
agents,
step.agent,
taskWithContext,
step.cwd,
i + 1,
signal,
chainUpdate,
makeDetails,
tracker,
"chain",
steps.length,
);
results.push(result);
const isError =
result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
if (isError) break;
previousOutput = getFinalOutput(result.messages);
}
return results;
}
export interface RunParallelOptions {
defaultCwd: string;
agents: AgentConfig[];
tasks: { agent: string; task: string; cwd?: string }[];
signal: AbortSignal | undefined;
onUpdate: OnUpdateCallback | undefined;
makeDetails: (results: SingleResult[]) => SubagentDetails;
tracker: RunRegistry;
}
/** Run tasks concurrently (capped at MAX_CONCURRENCY), reporting live progress. */
export async function runParallel(opts: RunParallelOptions): Promise<SingleResult[]> {
const { defaultCwd, agents, tasks, signal, onUpdate, makeDetails, tracker } = opts;
const allResults: SingleResult[] = new Array(tasks.length);
for (let i = 0; i < tasks.length; i++) {
allResults[i] = {
agent: tasks[i].agent,
agentSource: "unknown",
task: tasks[i].task,
exitCode: -1, // -1 = still running
messages: [],
stderr: "",
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
};
}
const emitParallelUpdate = () => {
if (onUpdate) {
const running = allResults.filter((r) => r.exitCode === -1).length;
const done = allResults.filter((r) => r.exitCode !== -1).length;
onUpdate({
content: [{ type: "text", text: `Parallel: ${done}/${allResults.length} done, ${running} running...` }],
details: makeDetails([...allResults]),
});
}
};
return mapWithConcurrencyLimit(tasks, MAX_CONCURRENCY, async (t, index) => {
const result = await runSingle(
defaultCwd,
agents,
t.agent,
t.task,
t.cwd,
index + 1,
signal,
(partial) => {
if (partial.details?.results[0]) {
allResults[index] = partial.details.results[0];
emitParallelUpdate();
}
},
makeDetails,
tracker,
"parallel",
tasks.length,
);
allResults[index] = result;
emitParallelUpdate();
return result;
});
}
+225
View File
@@ -0,0 +1,225 @@
/**
* /subagent-runs — interactive panel listing subagent runs from the registry.
* Refreshes live while open; supports per-run cancel and clearing finished
* runs. Modeled on the /local-models panel.
*/
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import { formatTokens } from "./runner.js";
import type { RunRecord, RunRegistry, RunStatus } from "./runs.js";
const REFRESH_MS = 400;
function statusIcon(status: RunStatus): string {
switch (status) {
case "running":
return "⟳";
case "done":
return "✓";
case "error":
return "✗";
case "aborted":
return "⊘";
}
}
function formatElapsed(record: RunRecord): string {
const end = record.finishedAt ?? Date.now();
const secs = Math.max(0, Math.floor((end - record.startedAt) / 1000));
const m = Math.floor(secs / 60);
const s = secs % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
function formatUsage(record: RunRecord): string {
const u = record.usage;
const parts: string[] = [];
if (u.input) parts.push(`${formatTokens(u.input)}`);
if (u.output) parts.push(`${formatTokens(u.output)}`);
if (u.cost) parts.push(`$${u.cost.toFixed(2)}`);
return parts.join(" ");
}
function modeLabel(record: RunRecord): string {
if (record.step && record.stepCount) return `${record.mode} ${record.step}/${record.stepCount}`;
if (record.step) return `${record.mode} ${record.step}`;
return record.mode;
}
function oneLine(s: string): string {
return s.replace(/\s+/g, " ").trim();
}
/** What the list component asks openRunsPanel to do after it closes. */
type PanelAction = { kind: "close" } | { kind: "detail"; id: string };
/** Show the live run list. Resolves with the chosen PanelAction. */
function showRunsList(ctx: ExtensionCommandContext, registry: RunRegistry): Promise<PanelAction> {
return ctx.ui.custom<PanelAction>((tui, theme, _kb, done) => {
let index = 0;
const timer = setInterval(() => tui.requestRender(), REFRESH_MS);
const cleanup = () => clearInterval(timer);
function handleInput(data: string): void {
const runs = registry.list();
if (matchesKey(data, Key.up)) {
index = Math.max(0, index - 1);
tui.requestRender();
return;
}
if (matchesKey(data, Key.down)) {
index = Math.min(Math.max(0, runs.length - 1), index + 1);
tui.requestRender();
return;
}
if (matchesKey(data, Key.escape)) {
cleanup();
done({ kind: "close" });
return;
}
if (matchesKey(data, Key.enter)) {
const run = runs[index];
if (run) {
cleanup();
done({ kind: "detail", id: run.id });
}
return;
}
if (data === "c") {
const run = runs[index];
if (!run) return;
if (run.status === "running") {
run.abort();
ctx.ui.notify(`Cancelling "${run.agent}"...`, "info");
} else {
ctx.ui.notify("Run already finished", "warning");
}
tui.requestRender();
return;
}
if (data === "x") {
registry.clearFinished();
index = 0;
tui.requestRender();
return;
}
}
function render(width: number): string[] {
const runs = registry.list();
if (index >= runs.length) index = Math.max(0, runs.length - 1);
const lines: string[] = [];
const add = (s: string) => lines.push(truncateToWidth(s, width));
add(theme.fg("accent", "─".repeat(width)));
add(theme.fg("text", " Subagent Runs"));
lines.push("");
if (runs.length === 0) {
add(theme.fg("muted", " (no subagent runs yet)"));
} else {
for (let i = 0; i < runs.length; i++) {
const r = runs[i];
const line =
`${statusIcon(r.status)} ${r.status.padEnd(8)} ${r.agent.padEnd(12)} ` +
`${modeLabel(r).padEnd(14)} ${formatElapsed(r).padEnd(6)} ${formatUsage(r).padEnd(20)} ${oneLine(r.task)}`;
if (i === index) add(theme.fg("accent", `> ${line}`));
else add(` ${theme.fg("text", line)}`);
}
}
lines.push("");
// Pack key hints onto as many lines as the width needs, so none
// get truncated off on a narrow terminal.
const hints = ["↑↓ navigate", "Enter detail", "c cancel", "x clear finished", "Esc close"];
let hintLine = "";
for (const part of hints) {
const candidate = hintLine === "" ? part : `${hintLine}${part}`;
if (hintLine !== "" && visibleWidth(` ${candidate}`) > width) {
add(theme.fg("dim", ` ${hintLine}`));
hintLine = part;
} else {
hintLine = candidate;
}
}
if (hintLine !== "") add(theme.fg("dim", ` ${hintLine}`));
add(theme.fg("accent", "─".repeat(width)));
return lines;
}
return { render, handleInput, invalidate: () => {}, dispose: cleanup };
});
}
/** Show a read-only detail view for one run. Resolves when the user goes back. */
function showRunDetail(ctx: ExtensionCommandContext, registry: RunRegistry, id: string): Promise<void> {
return ctx.ui.custom<void>((tui, theme, _kb, done) => {
const timer = setInterval(() => tui.requestRender(), REFRESH_MS);
const cleanup = () => clearInterval(timer);
function handleInput(data: string): void {
if (matchesKey(data, Key.escape) || matchesKey(data, Key.enter)) {
cleanup();
done();
}
}
function render(width: number): string[] {
const lines: string[] = [];
const add = (s: string) => lines.push(truncateToWidth(s, width));
add(theme.fg("accent", "─".repeat(width)));
const r = registry.get(id);
if (!r) {
add(theme.fg("muted", " (run no longer available)"));
add(theme.fg("dim", " Esc back"));
add(theme.fg("accent", "─".repeat(width)));
return lines;
}
add(theme.fg("text", ` ${statusIcon(r.status)} ${r.agent} (${r.agentSource}) — ${r.status}`));
lines.push("");
add(theme.fg("muted", " Mode: ") + theme.fg("text", modeLabel(r)));
add(theme.fg("muted", " Elapsed: ") + theme.fg("text", formatElapsed(r)));
const usage = formatUsage(r);
if (usage) add(theme.fg("muted", " Usage: ") + theme.fg("text", usage));
if (r.exitCode !== undefined) add(theme.fg("muted", " Exit code: ") + theme.fg("text", String(r.exitCode)));
if (r.errorMessage) add(theme.fg("error", ` Error: ${r.errorMessage}`));
lines.push("");
add(theme.fg("muted", " ─── Task ───"));
for (const ln of r.task.split("\n")) add(` ${theme.fg("dim", ln)}`);
lines.push("");
add(theme.fg("muted", " ─── Output so far ───"));
const output = r.outputSoFar || "(no output yet)";
for (const ln of output.split("\n")) add(` ${theme.fg("toolOutput", ln)}`);
if (r.stderr.trim()) {
lines.push("");
add(theme.fg("muted", " ─── stderr ───"));
for (const ln of r.stderr.split("\n")) add(` ${theme.fg("error", ln)}`);
}
lines.push("");
add(theme.fg("dim", " Esc back"));
add(theme.fg("accent", "─".repeat(width)));
return lines;
}
return { render, handleInput, invalidate: () => {}, dispose: cleanup };
});
}
/**
* Open the /subagent-runs panel. Loops: show the list; if the user opens a
* detail view, show it then return to the list — until the user presses Esc.
*/
export async function openRunsPanel(ctx: ExtensionCommandContext, registry: RunRegistry): Promise<void> {
for (;;) {
const action = await showRunsList(ctx, registry);
if (action.kind === "close") break;
if (action.kind === "detail") await showRunDetail(ctx, registry, action.id);
}
}
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from "vitest";
import { createRunRegistry } from "./runs.js";
const baseInit = {
agent: "scout",
agentSource: "user" as const,
task: "explore the auth module",
mode: "single" as const,
abort: () => {},
};
describe("createRunRegistry", () => {
it("register creates a running record with a unique id", () => {
const registry = createRunRegistry();
const a = registry.register(baseInit);
const b = registry.register(baseInit);
expect(a.status).toBe("running");
expect(a.startedAt).toBeGreaterThan(0);
expect(a.id).not.toBe(b.id);
expect(registry.get(a.id)).toBe(a);
});
it("update merges live fields into an existing record", () => {
const registry = createRunRegistry();
const record = registry.register(baseInit);
registry.update(record.id, {
usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: 0.01, contextTokens: 15, turns: 1 },
outputSoFar: "partial output",
stderr: "a warning",
});
expect(record.usage.input).toBe(10);
expect(record.outputSoFar).toBe("partial output");
expect(record.stderr).toBe("a warning");
expect(record.status).toBe("running");
});
it("update is a no-op for an unknown id", () => {
const registry = createRunRegistry();
expect(() => registry.update("nope", { outputSoFar: "x" })).not.toThrow();
});
it("finalize sets terminal status, finishedAt, and exit info", () => {
const registry = createRunRegistry();
const record = registry.register(baseInit);
registry.finalize(record.id, { status: "error", exitCode: 1, errorMessage: "boom" });
expect(record.status).toBe("error");
expect(record.exitCode).toBe(1);
expect(record.errorMessage).toBe("boom");
expect(record.finishedAt).toBeGreaterThanOrEqual(record.startedAt);
});
it("list returns records newest-first", () => {
vi.useFakeTimers();
try {
const registry = createRunRegistry();
vi.setSystemTime(1000);
const first = registry.register(baseInit);
vi.setSystemTime(2000);
const second = registry.register(baseInit);
expect(registry.list().map((r) => r.id)).toEqual([second.id, first.id]);
} finally {
vi.useRealTimers();
}
});
it("get returns undefined for an unknown id", () => {
const registry = createRunRegistry();
expect(registry.get("missing")).toBeUndefined();
});
it("clearFinished drops finished records but keeps running ones", () => {
const registry = createRunRegistry();
const running = registry.register(baseInit);
const done = registry.register(baseInit);
const aborted = registry.register(baseInit);
registry.finalize(done.id, { status: "done", exitCode: 0 });
registry.finalize(aborted.id, { status: "aborted", exitCode: 1 });
registry.clearFinished();
expect(registry.list().map((r) => r.id)).toEqual([running.id]);
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* In-memory registry of subagent runs. Created once in the extension factory
* and shared by closure between the `subagent` tool (which records runs) and
* the `/subagent-runs` command (which displays them). Lives for the pi
* session; nothing is persisted to disk.
*/
import { randomUUID } from "node:crypto";
import type { UsageStats } from "./runner.js";
export type RunStatus = "running" | "done" | "error" | "aborted";
export interface RunRecord {
id: string;
agent: string;
agentSource: "user" | "project" | "unknown";
task: string;
mode: "single" | "parallel" | "chain";
/** 1-based position within a parallel batch or chain; unset for single. */
step?: number;
/** Total runs in this parallel batch or chain; unset for single. */
stepCount?: number;
status: RunStatus;
startedAt: number;
finishedAt?: number;
usage: UsageStats;
outputSoFar: string;
stderr: string;
exitCode?: number;
errorMessage?: string;
/** Cancels just this run. Safe to call after the run has finished. */
abort: () => void;
}
/** Fields supplied when a run is first registered. */
export interface RunInit {
agent: string;
agentSource: "user" | "project" | "unknown";
task: string;
mode: "single" | "parallel" | "chain";
step?: number;
stepCount?: number;
abort: () => void;
}
/** Live fields updated while a run is in flight. */
export interface RunUpdate {
usage?: UsageStats;
outputSoFar?: string;
stderr?: string;
}
/** Terminal fields recorded when a run ends. */
export interface RunFinal {
status: Exclude<RunStatus, "running">;
exitCode?: number;
errorMessage?: string;
}
export interface RunRegistry {
/** Create a new record (status "running") and return it. */
register(init: RunInit): RunRecord;
/** Merge live fields into an existing record. No-op if the id is unknown. */
update(id: string, patch: RunUpdate): void;
/** Mark a record terminal and stamp finishedAt. No-op if the id is unknown. */
finalize(id: string, final: RunFinal): void;
/** All records, newest-first. */
list(): RunRecord[];
get(id: string): RunRecord | undefined;
/** Drop every record whose status is not "running". */
clearFinished(): void;
}
export function createRunRegistry(): RunRegistry {
const records = new Map<string, RunRecord>();
return {
register(init) {
const record: RunRecord = {
id: randomUUID(),
agent: init.agent,
agentSource: init.agentSource,
task: init.task,
mode: init.mode,
step: init.step,
stepCount: init.stepCount,
status: "running",
startedAt: Date.now(),
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
outputSoFar: "",
stderr: "",
abort: init.abort,
};
records.set(record.id, record);
return record;
},
update(id, patch) {
const record = records.get(id);
if (!record) return;
if (patch.usage !== undefined) record.usage = patch.usage;
if (patch.outputSoFar !== undefined) record.outputSoFar = patch.outputSoFar;
if (patch.stderr !== undefined) record.stderr = patch.stderr;
},
finalize(id, final) {
const record = records.get(id);
if (!record) return;
record.status = final.status;
record.finishedAt = Date.now();
record.exitCode = final.exitCode;
record.errorMessage = final.errorMessage;
},
list() {
return Array.from(records.values()).sort((a, b) => b.startedAt - a.startedAt);
},
get(id) {
return records.get(id);
},
clearFinished() {
for (const [id, record] of records) {
if (record.status !== "running") records.delete(id);
}
},
};
}
+15
View File
@@ -8,6 +8,7 @@
"name": "pi-customizations",
"version": "0.1.0",
"devDependencies": {
"typescript": "^6.0.3",
"vitest": "^3.2.4"
},
"peerDependencies": {
@@ -4202,6 +4203,20 @@
"license": "MIT",
"peer": true
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/uint8array-extras": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
+13 -5
View File
@@ -3,22 +3,30 @@
"version": "0.1.0",
"description": "Personal pi coding agent customizations: subagent + local-models extensions, agents, prompt templates",
"private": true,
"keywords": ["pi-package"],
"keywords": [
"pi-package"
],
"pi": {
"extensions": ["./extensions"],
"prompts": ["./prompts"]
"extensions": [
"./extensions"
],
"prompts": [
"./prompts"
]
},
"scripts": {
"test": "vitest --run"
"test": "vitest --run",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"@earendil-works/pi-ai": "*",
"@earendil-works/pi-agent-core": "*",
"@earendil-works/pi-ai": "*",
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*",
"typebox": "*"
},
"devDependencies": {
"typescript": "^6.0.3",
"vitest": "^3.2.4"
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2023"],
"types": ["node"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["extensions/**/*.ts"]
}