From c5828e059107ab3095227d1b54537cb742293379 Mon Sep 17 00:00:00 2001 From: alexion Date: Sat, 1 Aug 2026 22:24:04 -0400 Subject: [PATCH 1/6] feat(pi): label subagent work items --- .../pi/extensions/subagents/config.test.ts | 3 +- .../agents/pi/extensions/subagents/index.ts | 14 ++- .../pi/extensions/subagents/runner.test.ts | 6 +- .../agents/pi/extensions/subagents/runner.ts | 2 +- .../agents/pi/extensions/subagents/status.ts | 19 +++- .../extensions/subagents/supervisor.test.ts | 86 ++++++++++++++++++- .../pi/extensions/subagents/supervisor.ts | 36 ++++++-- .../agents/pi/extensions/subagents/types.ts | 16 ++-- modules/agents/pi/extensions/subagents/ui.ts | 3 +- 9 files changed, 157 insertions(+), 28 deletions(-) diff --git a/modules/agents/pi/extensions/subagents/config.test.ts b/modules/agents/pi/extensions/subagents/config.test.ts index 679cc91..2acda97 100644 --- a/modules/agents/pi/extensions/subagents/config.test.ts +++ b/modules/agents/pi/extensions/subagents/config.test.ts @@ -115,9 +115,10 @@ test("named spawn resolves overrides, frontmatter, config, and defaults", () => const config = loadConfig(cwd, true, diag, agentDir); const agents = loadAgents(cwd, true, diag, agentDir); - const resolved = resolveSpawn({ agent: "review", prompt: "check this", thinking: "low" }, config, agents); + const resolved = resolveSpawn({ agent: "review", prompt: "check this", label: "Review migration", thinking: "low" }, config, agents); assert.equal(resolved.prompt, "check this"); + assert.equal(resolved.label, "Review migration"); assert.equal(resolved.context, "independent"); assert.equal(resolved.model, "inherit"); assert.equal(resolved.thinking, "low"); diff --git a/modules/agents/pi/extensions/subagents/index.ts b/modules/agents/pi/extensions/subagents/index.ts index bb194e1..47d0600 100644 --- a/modules/agents/pi/extensions/subagents/index.ts +++ b/modules/agents/pi/extensions/subagents/index.ts @@ -4,6 +4,7 @@ import { loadAgents } from "./agents.ts"; import { loadConfig, resolveSpawn, type Diagnostics } from "./config.ts"; import { SubprocessRpcRunner } from "./runner.ts"; import { Supervisor } from "./supervisor.ts"; +import { milestoneNotification } from "./status.ts"; import type { SpawnRequest, SubagentStatus } from "./types.ts"; import { widget } from "./ui.ts"; @@ -23,7 +24,11 @@ export default function subagents(pi: ExtensionAPI) { supervisor = new Supervisor(new SubprocessRpcRunner(), cwd, { maxConcurrent: config.maxConcurrent, recentTerminalTtlMs: config.recentTerminalTtlMs, - onMilestone: (status, event) => pi.appendEntry("subagent_milestone", { event, status }), + onMilestone: (status, event) => { + pi.appendEntry("subagent_milestone", { event, status }); + const notification = milestoneNotification(status, event); + if (notification) ctx.ui?.notify?.(notification.message, notification.level); + }, onChange: (statuses) => { lastStatuses = statuses; updateUi(ctx, config.ui.enabled); @@ -51,6 +56,7 @@ export default function subagents(pi: ExtensionAPI) { description: "Start one ad hoc independent subagent and return immediately with its child id", parameters: Type.Object({ prompt: Type.String({ description: "Prompt for the delegated subagent" }), + label: Type.Optional(Type.String({ description: "Human-readable label for this work item" })), agent: Type.Optional(Type.String({ description: "Named agent definition to use" })), context: Type.Optional(Type.Union([Type.Literal("independent"), Type.Literal("fork")])), model: Type.Optional(Type.String({ description: "Optional model selector for the child" })), @@ -59,7 +65,7 @@ export default function subagents(pi: ExtensionAPI) { }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const accepted = getSupervisor(ctx).spawn(resolve(ctx, params as SpawnRequest)); - ctx.ui?.notify?.(`Started subagent ${accepted.id}`, "info"); + ctx.ui?.notify?.(`Started subagent ${accepted.label}`, "info"); return textResult(accepted); }, }); @@ -72,6 +78,7 @@ export default function subagents(pi: ExtensionAPI) { subagents: Type.Array( Type.Object({ prompt: Type.String({ description: "Prompt for the delegated subagent" }), + label: Type.Optional(Type.String({ description: "Human-readable label for this work item" })), agent: Type.Optional(Type.String({ description: "Named agent definition to use" })), context: Type.Optional(Type.Union([Type.Literal("independent"), Type.Literal("fork")])), model: Type.Optional(Type.String({ description: "Optional model selector for the child" })), @@ -164,7 +171,7 @@ export default function subagents(pi: ExtensionAPI) { description: "Start an ad hoc independent subagent", handler: async (args, ctx) => { const accepted = getSupervisor(ctx).spawn(resolve(ctx, parseSpawnArgs(args))); - ctx.ui.notify(`Started subagent ${accepted.id}`, "info"); + ctx.ui.notify(`Started subagent ${accepted.label}`, "info"); }, }); @@ -250,6 +257,7 @@ function parseSpawnArgs(args: string): SpawnRequest { const flag = parts.shift(); const value = parts.shift(); if (flag === "--agent") request.agent = value; + else if (flag === "--label") request.label = value; else if (flag === "--context" && (value === "independent" || value === "fork")) request.context = value; else if (flag === "--tools") request.tools = value; else if (flag === "--model") request.model = value; diff --git a/modules/agents/pi/extensions/subagents/runner.test.ts b/modules/agents/pi/extensions/subagents/runner.test.ts index cd5ca2f..4a13680 100644 --- a/modules/agents/pi/extensions/subagents/runner.test.ts +++ b/modules/agents/pi/extensions/subagents/runner.test.ts @@ -51,14 +51,18 @@ test("child RPC process disables discovery while explicitly loading subagents ex const { SubprocessRpcRunner } = await import("./runner.ts"); const runner = new SubprocessRpcRunner(); - await runner.start("child-1", { prompt: "work" }, "/tmp", events()); + await runner.start("child-1", { prompt: "work", label: "Review migration" }, "/tmp", events()); assert.equal(spawn.mock.callCount(), 1); const args = calls[0].args; const noExtensionsIndex = args.indexOf("--no-extensions"); const extensionIndex = args.indexOf("--extension"); + const nameIndex = args.indexOf("--name"); + assert.notEqual(noExtensionsIndex, -1, "child args keep automatic extension discovery disabled"); + assert.notEqual(nameIndex, -1, "child args include a process name"); + assert.equal(args[nameIndex + 1], "subagent Review migration"); assert.notEqual(extensionIndex, -1, "child args explicitly load the subagents extension entry"); assert.equal(args[extensionIndex + 1], fileURLToPath(new URL("./index.ts", import.meta.url))); assert.ok(noExtensionsIndex < extensionIndex); diff --git a/modules/agents/pi/extensions/subagents/runner.ts b/modules/agents/pi/extensions/subagents/runner.ts index 7804f7b..5291f0f 100644 --- a/modules/agents/pi/extensions/subagents/runner.ts +++ b/modules/agents/pi/extensions/subagents/runner.ts @@ -157,7 +157,7 @@ class RpcChildHandle implements ChildHandle { export class SubprocessRpcRunner implements ChildRunner { async start(id: string, request: SpawnRequest, cwd: string, events: RunnerEvents): Promise { - const args = [process.argv[1], "--mode", "rpc", "--no-extensions", "--extension", subagentsExtensionPath(), "--name", `subagent ${id}`, ...contextArgs(request), ...toolArgs(request), ...modelArgs(request)]; + const args = [process.argv[1], "--mode", "rpc", "--no-extensions", "--extension", subagentsExtensionPath(), "--name", `subagent ${request.label ?? id}`, ...contextArgs(request), ...toolArgs(request), ...modelArgs(request)]; const child = spawn(process.execPath, args, { cwd, env: childEnvironment(), diff --git a/modules/agents/pi/extensions/subagents/status.ts b/modules/agents/pi/extensions/subagents/status.ts index 76df5e7..1831b4e 100644 --- a/modules/agents/pi/extensions/subagents/status.ts +++ b/modules/agents/pi/extensions/subagents/status.ts @@ -1,4 +1,5 @@ -import type { ChildRecord, SpawnAccepted, SubagentResult, SubagentStatus } from "./types.ts"; +import { SUBAGENT_STATES, SUBAGENT_TERMINAL_STATES } from "./types.ts"; +import type { ChildRecord, SpawnAccepted, SubagentResult, SubagentState, SubagentStatus } from "./types.ts"; export function toAccepted(status: SubagentStatus): SpawnAccepted { return { @@ -17,9 +18,10 @@ export function cloneStatus(status: SubagentStatus): SubagentStatus { export function cloneResult(record: ChildRecord): SubagentResult { const status = cloneStatus(record.status); - const terminal = ["completed", "failed", "cancelled", "timed_out", "orphaned"].includes(status.state); + const terminal = isTerminalState(status.state); return { id: status.id, + label: status.label, state: status.state, running: !terminal, resultAvailable: status.resultAvailable, @@ -30,6 +32,19 @@ export function cloneResult(record: ChildRecord): SubagentResult { }; } +export function isTerminalState(state: SubagentState): boolean { + return (SUBAGENT_TERMINAL_STATES as readonly string[]).includes(state); +} + +export function milestoneNotification(status: SubagentStatus, event: string): { message: string; level: "info" | "error" } | undefined { + if (!isSubagentState(event) || !isTerminalState(event)) return undefined; + return { message: `Subagent ${status.label} ${event}`, level: event === "completed" ? "info" : "error" }; +} + +export function isSubagentState(value: string): value is SubagentState { + return (SUBAGENT_STATES as readonly string[]).includes(value); +} + export function elapsedMs(status: Pick): number { const start = Date.parse(status.startedAt); const end = status.completedAt ? Date.parse(status.completedAt) : Date.now(); diff --git a/modules/agents/pi/extensions/subagents/supervisor.test.ts b/modules/agents/pi/extensions/subagents/supervisor.test.ts index edaca0c..8b1e69b 100644 --- a/modules/agents/pi/extensions/subagents/supervisor.test.ts +++ b/modules/agents/pi/extensions/subagents/supervisor.test.ts @@ -1,7 +1,9 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { milestoneNotification } from "./status.ts"; import { Supervisor } from "./supervisor.ts"; import type { ChildHandle, ChildRunner, RunnerEvents, SpawnRequest } from "./types.ts"; +import { widget } from "./ui.ts"; class FakeHandle implements ChildHandle { cancelCalls = 0; @@ -110,6 +112,77 @@ test("completed children ignore later cancel", async () => { assert.equal(runner.starts[0].handle.cancelCalls, 0); }); +test("explicit labels are reused across accepted status list and result surfaces", async () => { + const runner = new FakeRunner(); + const supervisor = new Supervisor(runner, "/tmp"); + const label = "Review risky migration"; + + const accepted = supervisor.spawn({ prompt: "inspect the migration plan", label } as SpawnRequest & { label: string }); + await sleep(0); + runner.starts[0].events.completed("done", "agent_settled"); + + assert.deepEqual( + { + accepted: accepted.label, + status: supervisor.status(accepted.id).label, + list: supervisor.list().find((status) => status.id === accepted.id)?.label, + result: (supervisor.result(accepted.id) as { label?: string }).label, + }, + { + accepted: label, + status: label, + list: label, + result: label, + }, + ); +}); + +test("ad hoc fallback labels are prompt-derived and reused by widget and result surfaces", async () => { + const runner = new FakeRunner(); + const supervisor = new Supervisor(runner, "/tmp"); + const prompt = " Audit\n\tguest enablement plan "; + const label = "Audit guest enablement plan"; + + const accepted = supervisor.spawn({ prompt }); + await sleep(0); + runner.starts[0].events.completed("done", "agent_settled"); + const statuses = supervisor.list(); + const inspectorLines = widget(statuses, true)().render(240); + + assert.deepEqual( + { + accepted: accepted.label, + childRequest: runner.starts[0].request.label, + status: supervisor.status(accepted.id).label, + list: statuses.find((status) => status.id === accepted.id)?.label, + result: supervisor.result(accepted.id).label, + }, + { + accepted: label, + childRequest: label, + status: label, + list: label, + result: label, + }, + ); + assert.ok(inspectorLines.some((line) => line.includes(`${accepted.id} ${label} independent completed`)), inspectorLines.join("\n")); + assert.doesNotMatch(accepted.label, /^ad-hoc sg-/u); +}); + +test("milestone notifications use the stored label", async () => { + const runner = new FakeRunner(); + const supervisor = new Supervisor(runner, "/tmp"); + const accepted = supervisor.spawn({ prompt: "work", label: "Review migration" }); + await sleep(0); + runner.starts[0].events.completed("done", "agent_settled"); + + assert.deepEqual(milestoneNotification(supervisor.status(accepted.id), "completed"), { + message: "Subagent Review migration completed", + level: "info", + }); + assert.equal(milestoneNotification(supervisor.status(accepted.id), "running"), undefined); +}); + test("shutdown clears recent terminal expiry timer", async () => { const runner = new FakeRunner(); let changes = 0; @@ -128,17 +201,22 @@ test("shutdown clears recent terminal expiry timer", async () => { assert.equal(changes, afterShutdown); }); -test("batch spawn returns accepted ids and per-entry failures", async () => { +test("batch spawn returns explicit labels on accepted child requests and statuses while preserving failures", async () => { const runner = new FakeRunner(); const supervisor = new Supervisor(runner, "/tmp"); - const result = supervisor.spawnBatch([{ prompt: "one" }, { prompt: "" }, { prompt: "two" }]); + const result = supervisor.spawnBatch([ + { prompt: "one", label: "Review docs" }, + { prompt: "" }, + { prompt: "two", label: "Check tests" }, + ]); await sleep(0); - assert.equal(result.accepted.length, 2); + assert.deepEqual(result.accepted.map((accepted) => accepted.label), ["Review docs", "Check tests"]); assert.equal(result.failed.length, 1); assert.equal(result.failed[0].index, 1); - assert.equal(runner.starts.length, 2); + assert.deepEqual(runner.starts.map((start) => start.request.label), ["Review docs", "Check tests"]); + assert.deepEqual(result.accepted.map((accepted) => supervisor.status(accepted.id).label), ["Review docs", "Check tests"]); }); test("maxConcurrent preserves queued records", async () => { diff --git a/modules/agents/pi/extensions/subagents/supervisor.ts b/modules/agents/pi/extensions/subagents/supervisor.ts index 702aa46..75a9b08 100644 --- a/modules/agents/pi/extensions/subagents/supervisor.ts +++ b/modules/agents/pi/extensions/subagents/supervisor.ts @@ -11,7 +11,7 @@ import type { SubagentWaitMode, SubagentWaitResult, } from "./types.ts"; -import { cloneResult, cloneStatus, toAccepted } from "./status.ts"; +import { cloneResult, cloneStatus, isTerminalState, toAccepted } from "./status.ts"; interface RunningChild { record: ChildRecord; @@ -157,7 +157,7 @@ export class Supervisor { const now = new Date().toISOString(); const status: SubagentStatus = { id, - label: request.agent ?? `ad-hoc ${id}`, + label: deriveLabel(request, id), agent: request.agent, adHoc: !request.agent, context: this.resolveContext(request.context), @@ -172,7 +172,7 @@ export class Supervisor { lastEventAt: now, resultAvailable: false, }; - const child: RunningChild = { record: { status }, request: { ...request, prompt, context: status.context, tools: status.tools } }; + const child: RunningChild = { record: { status }, request: { ...request, prompt, label: status.label, context: status.context, tools: status.tools } }; this.children.set(id, child); this.emitMilestone(child, "accepted"); this.queue.push(child); @@ -418,6 +418,32 @@ export class Supervisor { } } -function isTerminal(state: SubagentStatus["state"]): boolean { - return ["completed", "failed", "cancelled", "timed_out", "orphaned"].includes(state); +function deriveLabel(request: SpawnRequest, id: string): string { + const explicit = normalizeLabel(request.label); + if (explicit) return explicit; + const agent = normalizeLabel(request.agent); + if (agent) return agent; + return promptLabel(request.prompt) ?? `ad-hoc ${id}`; +} + +function promptLabel(prompt: string): string | undefined { + const normalized = normalizeLabel(prompt); + if (!normalized) return undefined; + return truncateLabel(normalized); +} + +function normalizeLabel(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.replace(/\s+/gu, " ").trim(); + return normalized || undefined; +} + +function truncateLabel(label: string): string { + const maxLength = 80; + if (label.length <= maxLength) return label; + return `${label.slice(0, maxLength - 1).trimEnd()}…`; +} + +function isTerminal(state: SubagentStatus["state"]): boolean { + return isTerminalState(state); } diff --git a/modules/agents/pi/extensions/subagents/types.ts b/modules/agents/pi/extensions/subagents/types.ts index 14a5143..2274ccb 100644 --- a/modules/agents/pi/extensions/subagents/types.ts +++ b/modules/agents/pi/extensions/subagents/types.ts @@ -1,15 +1,9 @@ export type ContextMode = "independent" | "fork"; -export type SubagentState = - | "queued" - | "starting" - | "running" - | "settling" - | "completed" - | "failed" - | "cancelled" - | "timed_out" - | "orphaned"; +export const SUBAGENT_STATES = ["queued", "starting", "running", "settling", "completed", "failed", "cancelled", "timed_out", "orphaned"] as const; +export const SUBAGENT_TERMINAL_STATES = ["completed", "failed", "cancelled", "timed_out", "orphaned"] as const; + +export type SubagentState = (typeof SUBAGENT_STATES)[number]; export interface ToolProfile { activeTools: string[] | null; @@ -17,6 +11,7 @@ export interface ToolProfile { export interface SpawnRequest { prompt: string; + label?: string; context?: ContextMode; agent?: string; model?: string; @@ -60,6 +55,7 @@ export interface SubagentStatus { export interface SubagentResult { id: string; + label: string; state: SubagentState; running: boolean; resultAvailable: boolean; diff --git a/modules/agents/pi/extensions/subagents/ui.ts b/modules/agents/pi/extensions/subagents/ui.ts index b466096..aef20f7 100644 --- a/modules/agents/pi/extensions/subagents/ui.ts +++ b/modules/agents/pi/extensions/subagents/ui.ts @@ -1,9 +1,10 @@ +import { isTerminalState } from "./status.ts"; import type { SubagentStatus } from "./types.ts"; export function renderSummary(statuses: SubagentStatus[]): string[] { const running = statuses.filter((status) => ["starting", "running", "settling"].includes(status.state)).length; const queued = statuses.filter((status) => status.state === "queued").length; - const terminal = statuses.filter((status) => ["completed", "failed", "cancelled", "timed_out", "orphaned"].includes(status.state)).length; + const terminal = statuses.filter((status) => isTerminalState(status.state)).length; if (running === 0 && queued === 0 && terminal === 0) return []; return [`subagents: ${running} running · ${queued} queued · ${terminal} recent`]; } -- 2.47.3 From 8c85c00ae9c8536b8f055d8256cedb8bc5b42691 Mon Sep 17 00:00:00 2001 From: alexion Date: Sat, 1 Aug 2026 22:33:34 -0400 Subject: [PATCH 2/6] feat(pi): retain terminal subagent work --- .../agents/pi/extensions/subagents/index.ts | 24 +++++++- .../extensions/subagents/supervisor.test.ts | 58 +++++++------------ .../pi/extensions/subagents/supervisor.ts | 54 +++++------------ modules/agents/pi/extensions/subagents/ui.ts | 2 +- 4 files changed, 60 insertions(+), 78 deletions(-) diff --git a/modules/agents/pi/extensions/subagents/index.ts b/modules/agents/pi/extensions/subagents/index.ts index 47d0600..ea73bca 100644 --- a/modules/agents/pi/extensions/subagents/index.ts +++ b/modules/agents/pi/extensions/subagents/index.ts @@ -106,7 +106,7 @@ export default function subagents(pi: ExtensionAPI) { pi.registerTool({ name: "subagent_list", label: "List subagents", - description: "List active and recent subagents for this parent session", + description: "List active and terminal subagents for this parent session until terminal entries are cleared", parameters: Type.Object({}), async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { return textResult(getSupervisor(ctx).list()); @@ -167,6 +167,20 @@ export default function subagents(pi: ExtensionAPI) { }, }); + pi.registerTool({ + name: "subagent_clear", + label: "Clear terminal subagents", + description: "Remove terminal subagents from the current-session visible work set. Omitting ids clears all terminal children", + parameters: Type.Object({ + ids: Type.Optional(Type.Array(Type.String({ description: "Subagent id returned by subagent_spawn or subagent_batch" }))), + }), + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const input = params as { ids?: unknown }; + const ids = Array.isArray(input.ids) ? input.ids.map(String) : undefined; + return textResult({ cleared: getSupervisor(ctx).clearTerminal(ids) }); + }, + }); + pi.registerCommand("subagent-spawn", { description: "Start an ad hoc independent subagent", handler: async (args, ctx) => { @@ -194,6 +208,14 @@ export default function subagents(pi: ExtensionAPI) { }, }); + pi.registerCommand("subagent-clear", { + description: "Clear terminal subagent records. Pass ids to clear selected terminal records only", + handler: async (args, ctx) => { + const ids = args.trim().split(/\s+/u).filter(Boolean); + ctx.ui.notify(JSON.stringify({ cleared: getSupervisor(ctx).clearTerminal(ids.length > 0 ? ids : undefined) }, null, 2), "info"); + }, + }); + pi.registerCommand("subagent-status", { description: "Show a subagent status by id", handler: async (args, ctx) => { diff --git a/modules/agents/pi/extensions/subagents/supervisor.test.ts b/modules/agents/pi/extensions/subagents/supervisor.test.ts index 8b1e69b..ec1db7a 100644 --- a/modules/agents/pi/extensions/subagents/supervisor.test.ts +++ b/modules/agents/pi/extensions/subagents/supervisor.test.ts @@ -236,58 +236,42 @@ test("maxConcurrent preserves queued records", async () => { assert.equal(runner.starts.length, 2); }); -test("recent terminal statuses expire from list by ttl", async () => { +test("terminal records stay listed past ttl and remain retrievable until cleared", async () => { const runner = new FakeRunner(); const supervisor = new Supervisor(runner, "/tmp", { recentTerminalTtlMs: 5 }); - const accepted = await spawnStarted(supervisor); - - runner.starts[0].events.completed("done", "agent_settled"); - assert.equal(supervisor.list().some((status) => status.id === accepted.id), true); + const completed = await spawnStarted(supervisor, "one"); + const failed = await spawnStarted(supervisor, "two"); + runner.starts[0].events.completed("one done", "agent_settled"); + runner.starts[1].events.failed("two failed"); await sleep(10); - assert.equal(supervisor.list().some((status) => status.id === accepted.id), false); - assert.equal(supervisor.result(accepted.id).result, "done"); + const listedIds = supervisor.list().map((status) => status.id); + assert.ok(listedIds.includes(completed.id)); + assert.ok(listedIds.includes(failed.id)); + assert.equal(supervisor.result(completed.id).result, "one done"); + assert.equal(supervisor.result(failed.id).error, "two failed"); + + (supervisor as Supervisor & { clearTerminal(): void }).clearTerminal(); + + const afterClearIds = supervisor.list().map((status) => status.id); + assert.equal(afterClearIds.includes(completed.id), false); + assert.equal(afterClearIds.includes(failed.id), false); + assert.throws(() => supervisor.status(completed.id), /unknown subagent id/); + assert.throws(() => supervisor.result(failed.id), /unknown subagent id/); }); -test("recent terminal ttl does not hide active statuses", async () => { +test("zero recent terminal ttl does not hide terminal statuses", async () => { const runner = new FakeRunner(); const supervisor = new Supervisor(runner, "/tmp", { recentTerminalTtlMs: 0 }); const accepted = await spawnStarted(supervisor); + runner.starts[0].events.completed("done", "agent_settled"); + assert.equal(supervisor.list().some((status) => status.id === accepted.id), true); -}); - -test("zero recent terminal ttl hides terminal statuses immediately", async () => { - const runner = new FakeRunner(); - const supervisor = new Supervisor(runner, "/tmp", { recentTerminalTtlMs: 0 }); - const accepted = await spawnStarted(supervisor); - - runner.starts[0].events.completed("done", "agent_settled"); - - assert.equal(supervisor.list().some((status) => status.id === accepted.id), false); assert.equal(supervisor.result(accepted.id).result, "done"); }); -test("recent terminal ttl emits a change when an entry expires", async () => { - const runner = new FakeRunner(); - let changes = 0; - const supervisor = new Supervisor(runner, "/tmp", { - recentTerminalTtlMs: 5, - onChange: () => { - changes += 1; - }, - }); - await spawnStarted(supervisor); - const beforeComplete = changes; - - runner.starts[0].events.completed("done", "agent_settled"); - await sleep(15); - - assert.ok(changes > beforeComplete + 1); - assert.equal(supervisor.list().length, 0); -}); - test("wait blocks until multiple subagents are terminal", async () => { const runner = new FakeRunner(); const supervisor = new Supervisor(runner, "/tmp"); diff --git a/modules/agents/pi/extensions/subagents/supervisor.ts b/modules/agents/pi/extensions/subagents/supervisor.ts index 75a9b08..de45d58 100644 --- a/modules/agents/pi/extensions/subagents/supervisor.ts +++ b/modules/agents/pi/extensions/subagents/supervisor.ts @@ -48,7 +48,6 @@ export class Supervisor { private readonly children = new Map(); private readonly queue: RunningChild[] = []; private readonly waiters = new Set<() => void>(); - private recentTerminalTimer?: ReturnType; constructor( private readonly runner: ChildRunner, @@ -78,9 +77,7 @@ export class Supervisor { const active = statuses.filter((status) => !isTerminal(status.state)); const terminal = statuses .filter((status) => isTerminal(status.state)) - .filter((status) => this.isRecentTerminal(status)) - .sort((a, b) => Date.parse(b.completedAt ?? b.startedAt) - Date.parse(a.completedAt ?? a.startedAt)) - .slice(0, this.options.recentTerminalLimit ?? 10); + .sort((a, b) => Date.parse(b.completedAt ?? b.startedAt) - Date.parse(a.completedAt ?? a.startedAt)); return [...active, ...terminal]; } @@ -92,6 +89,20 @@ export class Supervisor { return cloneResult(this.require(id).record); } + clearTerminal(ids?: string[]): SubagentStatus[] { + const selectedIds = ids ? [...new Set(ids.map((id) => id.trim()).filter(Boolean))] : undefined; + if (selectedIds) for (const id of selectedIds) this.require(id); + const cleared: SubagentStatus[] = []; + for (const [id, child] of this.children) { + if (selectedIds && !selectedIds.includes(id)) continue; + if (!isTerminal(child.record.status.state)) continue; + cleared.push(cloneStatus(child.record.status)); + this.children.delete(id); + } + if (cleared.length > 0) this.emitChange(); + return cleared; + } + async wait( ids: string[], options: { timeoutMs?: number; signal?: AbortSignal; mode?: SubagentWaitMode } = {}, @@ -137,7 +148,6 @@ export class Supervisor { } async shutdown(): Promise { - this.clearRecentTerminalTimer(); await Promise.allSettled( [...this.children.values()].map(async (child) => { if (!isTerminal(child.record.status.state)) { @@ -146,7 +156,6 @@ export class Supervisor { } }), ); - this.clearRecentTerminalTimer(); } private createChild(request: SpawnRequest): SpawnAccepted { @@ -345,39 +354,6 @@ export class Supervisor { private emitChange() { this.options.onChange?.(this.list()); for (const waiter of this.waiters) waiter(); - this.scheduleRecentTerminalExpiry(); - } - - private scheduleRecentTerminalExpiry() { - this.clearRecentTerminalTimer(); - const ttl = this.options.recentTerminalTtlMs; - if (ttl === undefined || ttl <= 0) return; - const now = Date.now(); - const nextExpiryMs = [...this.children.values()] - .map((child) => child.record.status) - .filter((status) => isTerminal(status.state)) - .map((status) => Date.parse(status.completedAt ?? status.startedAt)) - .filter((completed) => Number.isFinite(completed)) - .map((completed) => completed + ttl - now) - .filter((remaining) => remaining > 0) - .sort((a, b) => a - b)[0]; - if (nextExpiryMs === undefined) return; - this.recentTerminalTimer = setTimeout(() => this.emitChange(), nextExpiryMs + 1); - } - - private clearRecentTerminalTimer() { - if (!this.recentTerminalTimer) return; - clearTimeout(this.recentTerminalTimer); - this.recentTerminalTimer = undefined; - } - - private isRecentTerminal(status: SubagentStatus): boolean { - const ttl = this.options.recentTerminalTtlMs; - if (ttl === undefined) return true; - if (ttl <= 0) return false; - const completed = Date.parse(status.completedAt ?? status.startedAt); - if (!Number.isFinite(completed)) return true; - return Date.now() - completed <= ttl; } private waitReady(ids: string[], mode: SubagentWaitMode): boolean { diff --git a/modules/agents/pi/extensions/subagents/ui.ts b/modules/agents/pi/extensions/subagents/ui.ts index aef20f7..b02e019 100644 --- a/modules/agents/pi/extensions/subagents/ui.ts +++ b/modules/agents/pi/extensions/subagents/ui.ts @@ -6,7 +6,7 @@ export function renderSummary(statuses: SubagentStatus[]): string[] { const queued = statuses.filter((status) => status.state === "queued").length; const terminal = statuses.filter((status) => isTerminalState(status.state)).length; if (running === 0 && queued === 0 && terminal === 0) return []; - return [`subagents: ${running} running · ${queued} queued · ${terminal} recent`]; + return [`subagents: ${running} running · ${queued} queued · ${terminal} terminal`]; } export function renderInspector(statuses: SubagentStatus[]): string[] { -- 2.47.3 From aafc68e911e2f9220e797a43851a1747f63e0923 Mon Sep 17 00:00:00 2001 From: alexion Date: Sat, 1 Aug 2026 22:41:25 -0400 Subject: [PATCH 3/6] feat(pi): render subagent monitor views --- .../extensions/subagents/supervisor.test.ts | 2 +- .../agents/pi/extensions/subagents/ui.test.ts | 68 ++++++++++++++++ modules/agents/pi/extensions/subagents/ui.ts | 81 +++++++++++++++---- 3 files changed, 135 insertions(+), 16 deletions(-) create mode 100644 modules/agents/pi/extensions/subagents/ui.test.ts diff --git a/modules/agents/pi/extensions/subagents/supervisor.test.ts b/modules/agents/pi/extensions/subagents/supervisor.test.ts index ec1db7a..7968dfd 100644 --- a/modules/agents/pi/extensions/subagents/supervisor.test.ts +++ b/modules/agents/pi/extensions/subagents/supervisor.test.ts @@ -165,7 +165,7 @@ test("ad hoc fallback labels are prompt-derived and reused by widget and result result: label, }, ); - assert.ok(inspectorLines.some((line) => line.includes(`${accepted.id} ${label} independent completed`)), inspectorLines.join("\n")); + assert.ok(inspectorLines.some((line) => line.includes(`completed 0s ${label} result: available`)), inspectorLines.join("\n")); assert.doesNotMatch(accepted.label, /^ad-hoc sg-/u); }); diff --git a/modules/agents/pi/extensions/subagents/ui.test.ts b/modules/agents/pi/extensions/subagents/ui.test.ts new file mode 100644 index 0000000..523eb4c --- /dev/null +++ b/modules/agents/pi/extensions/subagents/ui.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { SubagentState, SubagentStatus } from "./types.ts"; +import { renderInspector, renderSummary, widget } from "./ui.ts"; + +function status(overrides: Partial & { id: string; label: string; state: SubagentState }): SubagentStatus { + return { + adHoc: true, + context: "independent", + cwd: "/tmp", + elapsedMs: 0, + resultAvailable: false, + startedAt: "2026-08-01T00:00:00.000Z", + tools: "inherit", + ...overrides, + }; +} + +test("compact monitor aggregates visible children by actionable lifecycle group", () => { + assert.deepEqual(renderSummary([]), []); + + assert.deepEqual( + renderSummary([ + status({ id: "queued", label: "Queued", state: "queued" }), + status({ id: "starting", label: "Starting", state: "starting" }), + status({ id: "running", label: "Running", state: "running" }), + status({ id: "settling", label: "Settling", state: "settling" }), + status({ id: "completed", label: "Completed", state: "completed", resultAvailable: true }), + status({ id: "failed", label: "Failed", state: "failed", error: "boom" }), + status({ id: "timed-out", label: "Timed out", state: "timed_out" }), + status({ id: "cancelled", label: "Cancelled", state: "cancelled" }), + ]), + ["subagents: queued 1 · running 2 · settling 1 · completed 1 · failed 1 · timed out 1 · cancelled 1"], + ); +}); + +test("expanded monitor renders one truncated row per child with state, elapsed time, and activity marker", () => { + const lines = renderInspector([ + status({ + id: "sg-running", + label: "Audit unusually verbose guest enablement migration plan", + state: "running", + elapsedMs: 65_000, + lastEvent: "message_update", + }), + status({ + id: "sg-completed", + label: "Summarize review", + state: "completed", + elapsedMs: 3_600_000, + lastEvent: "completed", + resultAvailable: true, + }), + status({ id: "sg-failed", label: "Run risky test", state: "failed", elapsedMs: 2_000, error: "exit 1" }), + ]); + + assert.equal(lines.length, 3); + assert.match(lines[0], /^▶ running +1m05s +Audit unusually verbose guest enablement migration plan +last: message_update$/u); + assert.equal(lines[1], "✓ completed 1h00m00s Summarize review result: available"); + assert.equal(lines[2], "✗ failed 2s Run risky test error: exit 1"); + + const rendered = widget([ + status({ id: "sg-running", label: "Audit unusually verbose guest enablement migration plan", state: "running", elapsedMs: 65_000, lastEvent: "message_update" }), + ], true)().render(32); + + assert.deepEqual(rendered, ["▶ running 1m05s Audit unusual…"]); + assert.ok(rendered.every((line) => line.length <= 32)); +}); diff --git a/modules/agents/pi/extensions/subagents/ui.ts b/modules/agents/pi/extensions/subagents/ui.ts index b02e019..e710a47 100644 --- a/modules/agents/pi/extensions/subagents/ui.ts +++ b/modules/agents/pi/extensions/subagents/ui.ts @@ -1,29 +1,80 @@ -import { isTerminalState } from "./status.ts"; -import type { SubagentStatus } from "./types.ts"; +import type { SubagentState, SubagentStatus } from "./types.ts"; + +const COMPACT_GROUPS: Array<{ label: string; states: SubagentState[] }> = [ + { label: "queued", states: ["queued"] }, + { label: "running", states: ["starting", "running"] }, + { label: "settling", states: ["settling"] }, + { label: "completed", states: ["completed"] }, + { label: "failed", states: ["failed"] }, + { label: "timed out", states: ["timed_out"] }, + { label: "cancelled", states: ["cancelled"] }, + { label: "orphaned", states: ["orphaned"] }, +]; + +const STATE_PRESENTATION: Record = { + queued: { icon: "…", label: "queued" }, + starting: { icon: "◌", label: "starting" }, + running: { icon: "▶", label: "running" }, + settling: { icon: "◒", label: "settling" }, + completed: { icon: "✓", label: "completed" }, + failed: { icon: "✗", label: "failed" }, + cancelled: { icon: "■", label: "cancelled" }, + timed_out: { icon: "⏱", label: "timed out" }, + orphaned: { icon: "?", label: "orphaned" }, +}; export function renderSummary(statuses: SubagentStatus[]): string[] { - const running = statuses.filter((status) => ["starting", "running", "settling"].includes(status.state)).length; - const queued = statuses.filter((status) => status.state === "queued").length; - const terminal = statuses.filter((status) => isTerminalState(status.state)).length; - if (running === 0 && queued === 0 && terminal === 0) return []; - return [`subagents: ${running} running · ${queued} queued · ${terminal} terminal`]; + const groups = COMPACT_GROUPS.map((group) => ({ + label: group.label, + count: statuses.filter((status) => group.states.includes(status.state)).length, + })).filter((group) => group.count > 0); + + if (groups.length === 0) return []; + return [`subagents: ${groups.map((group) => `${group.label} ${group.count}`).join(" · ")}`]; } export function renderInspector(statuses: SubagentStatus[]): string[] { - const lines = renderSummary(statuses); - for (const status of statuses) { - lines.push( - `${status.id} ${status.label} ${status.context} ${status.state} ${Math.round(status.elapsedMs / 1000)}s ${status.model ?? "inherit"} ${status.tools} ${status.lastEvent ?? "none"} result:${status.resultAvailable ? "yes" : "no"}`, - ); - } - return lines; + return statuses.map((status) => renderStatusRow(status)); } export function widget(statuses: SubagentStatus[], expanded: boolean) { return () => ({ invalidate() {}, render(width: number) { - return (expanded ? renderInspector(statuses) : renderSummary(statuses)).map((line) => (line.length > width ? line.slice(0, Math.max(0, width - 1)) : line)); + return (expanded ? renderInspector(statuses) : renderSummary(statuses)).map((line) => truncateLine(line, width)); }, }); } + +function renderStatusRow(status: SubagentStatus): string { + const presentation = STATE_PRESENTATION[status.state]; + const marker = statusMarker(status); + return `${presentation.icon} ${presentation.label.padEnd(9)} ${formatDuration(status.elapsedMs)} ${status.label}${marker ? ` ${marker}` : ""}`; +} + +function statusMarker(status: SubagentStatus): string | undefined { + if (status.error) return `error: ${status.error}`; + if (status.resultAvailable) return "result: available"; + if (status.lastEvent) return `last: ${status.lastEvent}`; + if (status.state === "queued") return "waiting"; + if (status.state === "settling") return "settling"; + return undefined; +} + +function formatDuration(elapsedMs: number): string { + const totalSeconds = Math.max(0, Math.round(elapsedMs / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) return `${hours}h${String(minutes).padStart(2, "0")}m${String(seconds).padStart(2, "0")}s`; + if (minutes > 0) return `${minutes}m${String(seconds).padStart(2, "0")}s`; + return `${seconds}s`; +} + +function truncateLine(line: string, width: number): string { + if (width <= 0) return ""; + if (line.length <= width) return line; + if (width === 1) return "…"; + return `${line.slice(0, width - 1)}…`; +} -- 2.47.3 From ede3c0583f0e74519990b4185001ad3c5303feec Mon Sep 17 00:00:00 2001 From: alexion Date: Sat, 1 Aug 2026 22:56:50 -0400 Subject: [PATCH 4/6] feat(pi): capture subagent activity events --- .../pi/extensions/subagents/runner.test.ts | 49 ++++++++ .../agents/pi/extensions/subagents/runner.ts | 5 +- .../agents/pi/extensions/subagents/status.ts | 7 +- .../extensions/subagents/supervisor.test.ts | 67 +++++++++++ .../pi/extensions/subagents/supervisor.ts | 110 +++++++++++++++++- .../agents/pi/extensions/subagents/types.ts | 26 ++++- .../agents/pi/extensions/subagents/ui.test.ts | 21 ++++ modules/agents/pi/extensions/subagents/ui.ts | 1 + 8 files changed, 279 insertions(+), 7 deletions(-) diff --git a/modules/agents/pi/extensions/subagents/runner.test.ts b/modules/agents/pi/extensions/subagents/runner.test.ts index 4a13680..55c54e4 100644 --- a/modules/agents/pi/extensions/subagents/runner.test.ts +++ b/modules/agents/pi/extensions/subagents/runner.test.ts @@ -26,6 +26,55 @@ function events(): RunnerEvents { }; } +test("child RPC process forwards structured activity before collecting the final result", async (t) => { + const running: unknown[] = []; + const completed: Array<{ result: string; stopReason?: string }> = []; + const fakeChild = new EventEmitter() as EventEmitter & { + stdout: FakeStream; + stderr: FakeStream; + stdin: FakeStream; + killed: boolean; + pid?: number; + kill(signal?: NodeJS.Signals): boolean; + }; + fakeChild.stdout = new FakeStream(); + fakeChild.stderr = new FakeStream(); + fakeChild.stdin = new FakeStream(); + fakeChild.killed = false; + fakeChild.kill = () => { + fakeChild.killed = true; + return true; + }; + t.mock.method(fakeChild.stdin, "write", (chunk, callback?: (error?: Error | null) => void) => { + const request = JSON.parse(String(chunk)) as { id: string; type: string }; + callback?.(); + if (request.type === "get_last_assistant_text") { + queueMicrotask(() => { + fakeChild.stdout.emit("data", `${JSON.stringify({ id: request.id, type: "response", success: true, data: { text: "final answer" } })}\n`); + }); + } + return true; + }); + t.mock.method(childProcess, "spawn", () => fakeChild as unknown as childProcess.ChildProcessWithoutNullStreams); + + const { SubprocessRpcRunner } = await import("./runner.ts"); + const runner = new SubprocessRpcRunner(); + await runner.start("child-1", { prompt: "work", label: "Review migration" }, "/tmp", { + ...events(), + running: (event) => running.push(event), + completed: (result, stopReason) => completed.push({ result, stopReason }), + }); + + const firstActivity = { type: "message_start", role: "assistant", message: { id: "msg-1" } }; + const secondActivity = { type: "tool_execution_start", tool: "read", input: { path: "runner.ts" } }; + const settledActivity = { type: "agent_settled" }; + fakeChild.stdout.emit("data", `${JSON.stringify(firstActivity)}\n${JSON.stringify(secondActivity)}\n${JSON.stringify(settledActivity)}\n`); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(running, [firstActivity, secondActivity, settledActivity]); + assert.deepEqual(completed, [{ result: "final answer", stopReason: "agent_settled" }]); +}); + test("child RPC process disables discovery while explicitly loading subagents extension", async (t) => { const calls: Array<{ command: string; args: string[] }> = []; const fakeChild = new EventEmitter() as EventEmitter & { diff --git a/modules/agents/pi/extensions/subagents/runner.ts b/modules/agents/pi/extensions/subagents/runner.ts index 5291f0f..c0db926 100644 --- a/modules/agents/pi/extensions/subagents/runner.ts +++ b/modules/agents/pi/extensions/subagents/runner.ts @@ -89,16 +89,17 @@ class RpcChildHandle implements ChildHandle { } if (payload.type === "agent_started") { - this.events.running("agent_started"); + this.events.running(payload as Record); return; } if (payload.type === "agent_settled") { + this.events.running(payload as Record); this.finish().catch((error) => this.fail(error instanceof Error ? error.message : String(error))); return; } - if (payload.type) this.events.running(payload.type); + if (payload.type) this.events.running(payload as Record); } private async finish() { diff --git a/modules/agents/pi/extensions/subagents/status.ts b/modules/agents/pi/extensions/subagents/status.ts index 1831b4e..68f8481 100644 --- a/modules/agents/pi/extensions/subagents/status.ts +++ b/modules/agents/pi/extensions/subagents/status.ts @@ -13,7 +13,12 @@ export function toAccepted(status: SubagentStatus): SpawnAccepted { } export function cloneStatus(status: SubagentStatus): SubagentStatus { - return { ...status, elapsedMs: elapsedMs(status) }; + return { + ...status, + currentActivity: status.currentActivity ? { ...status.currentActivity } : undefined, + activityHistory: status.activityHistory.map((event) => ({ ...event })), + elapsedMs: elapsedMs(status), + }; } export function cloneResult(record: ChildRecord): SubagentResult { diff --git a/modules/agents/pi/extensions/subagents/supervisor.test.ts b/modules/agents/pi/extensions/subagents/supervisor.test.ts index 7968dfd..f1503ba 100644 --- a/modules/agents/pi/extensions/subagents/supervisor.test.ts +++ b/modules/agents/pi/extensions/subagents/supervisor.test.ts @@ -73,6 +73,73 @@ test("runtime timeout reaches timed_out", async () => { assert.equal(runner.starts[0].handle.cancelCalls, 1); }); +test("activity exposes ordered transcript events while status and list keep only summaries", async () => { + const runner = new FakeRunner(); + const supervisor = new Supervisor(runner, "/tmp"); + const accepted = await spawnStarted(supervisor); + + runner.starts[0].events.running({ type: "message_started", role: "assistant" }); + runner.starts[0].events.running({ + type: "message_delta", + role: "assistant", + assistantMessageEvent: { type: "content_delta", delta: "private transcript body" }, + }); + runner.starts[0].events.running({ type: "tool_started", tool: "read", input: { path: "secret-notes.md" } }); + runner.starts[0].events.running({ type: "tool_completed", tool: "read", output: "secret file contents" }); + + type ActivityStatus = ReturnType & { + activityHistory: Array<{ type: string; summary: string }>; + currentActivity: { summary: string }; + }; + const activity = supervisor.activity(accepted.id); + const status = supervisor.status(accepted.id) as ActivityStatus; + const listed = supervisor.list().find((item) => item.id === accepted.id) as ActivityStatus | undefined; + + assert.deepEqual( + activity.map((event) => event.type), + ["queued", "starting", "prompt accepted", "message_started", "message_delta", "tool_started", "tool_completed"], + ); + assert.deepEqual(activity[4], { + type: "message_delta", + summary: "assistant message content_delta", + at: activity[4].at, + role: "assistant", + tool: undefined, + phase: "content_delta", + text: "private transcript body", + input: undefined, + output: undefined, + error: undefined, + payload: { + type: "message_delta", + role: "assistant", + assistantMessageEvent: { type: "content_delta", delta: "private transcript body" }, + }, + }); + assert.deepEqual(activity[5], { + type: "tool_started", + summary: "read secret-notes.md", + at: activity[5].at, + role: undefined, + tool: "read", + phase: "started", + text: undefined, + input: { path: "secret-notes.md" }, + output: undefined, + error: undefined, + payload: { type: "tool_started", tool: "read", input: { path: "secret-notes.md" } }, + }); + assert.equal(activity[6].output, "secret file contents"); + + assert.ok(Array.isArray(status.activityHistory), "status should expose structured activityHistory"); + assert.deepEqual(status.activityHistory.map((event) => event.type), activity.map((event) => event.type)); + assert.deepEqual(status.activityHistory.map((event) => event.summary), activity.map((event) => event.summary)); + assert.equal(status.currentActivity.summary, "read"); + assert.equal(listed?.currentActivity.summary, "read"); + assert.doesNotMatch(JSON.stringify(status), /private transcript body|secret file contents/u); + assert.doesNotMatch(JSON.stringify(listed), /private transcript body|secret file contents/u); +}); + test("process failure reaches failed with diagnostics", async () => { const runner = new FakeRunner(); const supervisor = new Supervisor(runner, "/tmp"); diff --git a/modules/agents/pi/extensions/subagents/supervisor.ts b/modules/agents/pi/extensions/subagents/supervisor.ts index de45d58..9bcf5a9 100644 --- a/modules/agents/pi/extensions/subagents/supervisor.ts +++ b/modules/agents/pi/extensions/subagents/supervisor.ts @@ -3,6 +3,7 @@ import type { ChildRecord, ChildRunner, ContextMode, + RunnerActivity, RunnerEvents, SpawnAccepted, SpawnRequest, @@ -179,9 +180,11 @@ export class Supervisor { elapsedMs: 0, lastEvent: "queued", lastEventAt: now, + currentActivity: { type: "queued", summary: "queued", at: now }, + activityHistory: [{ type: "queued", summary: "queued", at: now }], resultAvailable: false, }; - const child: RunningChild = { record: { status }, request: { ...request, prompt, label: status.label, context: status.context, tools: status.tools } }; + const child: RunningChild = { record: { status, activityEvents: [{ type: "queued", summary: "queued", at: now }] }, request: { ...request, prompt, label: status.label, context: status.context, tools: status.tools } }; this.children.set(id, child); this.emitMilestone(child, "accepted"); this.queue.push(child); @@ -242,6 +245,7 @@ export class Supervisor { record.status.completedAt = now; record.status.lastEvent = "completed"; record.status.lastEventAt = now; + this.recordActivity(record, "completed", now); record.status.stopReason = stopReason; record.status.resultAvailable = true; if (child) this.emitMilestone(child, "completed"); @@ -260,6 +264,7 @@ export class Supervisor { record.status.completedAt = now; record.status.lastEvent = "failed"; record.status.lastEventAt = now; + this.recordActivity(record, "failed", now); record.status.error = error; record.status.stopReason = "failed"; if (child) this.emitMilestone(child, "failed"); @@ -274,6 +279,7 @@ export class Supervisor { child.record.status.completedAt = now; child.record.status.lastEvent = state; child.record.status.lastEventAt = now; + this.recordActivity(child.record, state, now); child.record.status.stopReason = reason; this.emitMilestone(child, state); } @@ -317,15 +323,30 @@ export class Supervisor { return [...this.children.values()].find((child) => child.record === record); } - private setState(status: SubagentStatus, state: SubagentStatus["state"], event: string) { + activity(id: string) { + return this.require(id).record.activityEvents.map((event) => ({ ...event })); + } + + private setState(status: SubagentStatus, state: SubagentStatus["state"], event: RunnerActivity) { if (isTerminal(status.state)) return; + const record = this.require(status.id).record; const now = new Date().toISOString(); + const activity = this.recordActivity(record, event, now); status.state = state; - status.lastEvent = event; + status.lastEvent = activity.type; status.lastEventAt = now; this.emitChange(); } + private recordActivity(record: ChildRecord, event: RunnerActivity, at: string) { + const activity = normalizeActivity(event, at); + record.activityEvents.push(activity); + const summary = summarizeActivity(activity); + record.status.currentActivity = summary; + record.status.activityHistory.push(summary); + return activity; + } + private require(id: string): RunningChild { const child = this.children.get(id); if (!child) throw new Error(`unknown subagent id: ${id}`); @@ -423,3 +444,86 @@ function truncateLabel(label: string): string { function isTerminal(state: SubagentStatus["state"]): boolean { return isTerminalState(state); } + +function normalizeActivity(event: RunnerActivity, at: string) { + if (typeof event === "string") return { type: event, summary: event, at }; + const type = typeof event.type === "string" ? event.type : "activity"; + const role = typeof event.role === "string" ? event.role : undefined; + const tool = toolFromActivity(event); + const phase = typeof event.phase === "string" ? event.phase : phaseFromType(type, event); + const text = textFromActivity(event); + const input = inputFromActivity(event); + const output = "output" in event ? event.output : "result" in event ? event.result : "partialResult" in event ? event.partialResult : undefined; + const error = typeof event.error === "string" ? event.error : undefined; + return { type, summary: summaryFor({ type, role, tool, phase, input, output, error }), at, role, tool, phase, text, input, output, error, payload: { ...event } }; +} + +function summarizeActivity(activity: ReturnType) { + const { type, summary, at, role, tool, phase } = activity; + return { type, summary, at, role, tool, phase }; +} + +function toolFromActivity(event: Record): string | undefined { + for (const key of ["tool", "toolName", "name"]) { + const value = event[key]; + if (typeof value === "string") return value; + } + return undefined; +} + +function phaseFromType(type: string, event: Record): string | undefined { + const assistantEvent = event.assistantMessageEvent; + if (assistantEvent && typeof assistantEvent === "object" && !Array.isArray(assistantEvent)) { + const assistantType = (assistantEvent as { type?: unknown }).type; + if (typeof assistantType === "string") return assistantType; + } + if (type.endsWith("_start")) return "started"; + if (type.endsWith("_started")) return "started"; + if (type.endsWith("_update")) return "update"; + if (type.endsWith("_delta")) return "delta"; + if (type.endsWith("_end")) return "completed"; + if (type.endsWith("_completed")) return "completed"; + if (type.endsWith("_failed")) return "failed"; + return undefined; +} + +function textFromActivity(event: Record): string | undefined { + for (const key of ["text", "body", "content", "delta"]) { + const value = event[key]; + if (typeof value === "string") return value; + } + const assistantEvent = event.assistantMessageEvent; + if (assistantEvent && typeof assistantEvent === "object" && !Array.isArray(assistantEvent)) { + for (const key of ["delta", "content"]) { + const value = (assistantEvent as Record)[key]; + if (typeof value === "string") return value; + } + } + return undefined; +} + +function inputFromActivity(event: Record): unknown { + if ("input" in event) return event.input; + if ("args" in event) return event.args; + return undefined; +} + +function summaryFor(activity: { type: string; role?: string; tool?: string; phase?: string; input?: unknown; output?: unknown; error?: string }): string { + if (activity.error) return `${activity.tool ?? activity.type} failed: ${activity.error}`; + if (activity.tool) return `${activity.tool}${inputHint(activity.input)}`; + if (activity.type.startsWith("message")) return `${activity.role ?? "assistant"} message${activity.phase ? ` ${activity.phase}` : ""}`; + return activity.type; +} + +function inputHint(input: unknown): string { + if (!input || typeof input !== "object" || Array.isArray(input)) return ""; + const path = (input as { path?: unknown }).path; + if (typeof path === "string" && path.trim()) return ` ${path.trim()}`; + const command = (input as { command?: unknown }).command; + if (typeof command === "string" && command.trim()) return ` ${truncateActivityHint(command.trim())}`; + return ""; +} + +function truncateActivityHint(value: string): string { + return value.length <= 80 ? value : `${value.slice(0, 79).trimEnd()}…`; +} diff --git a/modules/agents/pi/extensions/subagents/types.ts b/modules/agents/pi/extensions/subagents/types.ts index 2274ccb..db36bb3 100644 --- a/modules/agents/pi/extensions/subagents/types.ts +++ b/modules/agents/pi/extensions/subagents/types.ts @@ -31,6 +31,27 @@ export interface SpawnAccepted { hint: string; } +export interface SubagentActivitySummary { + type: string; + summary: string; + at: string; + role?: string; + tool?: string; + phase?: string; +} + +export interface SubagentActivityEvent extends SubagentActivitySummary { + text?: string; + input?: unknown; + output?: unknown; + error?: string; + payload?: Record; +} + +export interface SubagentCurrentActivity extends SubagentActivitySummary {} + +export type RunnerActivity = string | Record; + export interface SubagentStatus { id: string; label: string; @@ -47,6 +68,8 @@ export interface SubagentStatus { elapsedMs: number; lastEvent?: string; lastEventAt?: string; + currentActivity?: SubagentCurrentActivity; + activityHistory: SubagentActivitySummary[]; stopReason?: string; resultAvailable: boolean; childSession?: string; @@ -79,12 +102,13 @@ export interface SubagentWaitResult { export interface ChildRecord { status: SubagentStatus; + activityEvents: SubagentActivityEvent[]; result?: string; } export interface RunnerEvents { accepted(childSession?: string): void; - running(event: string): void; + running(event: RunnerActivity): void; settling(): void; completed(result: string, stopReason?: string): void; failed(error: string): void; diff --git a/modules/agents/pi/extensions/subagents/ui.test.ts b/modules/agents/pi/extensions/subagents/ui.test.ts index 523eb4c..b659e64 100644 --- a/modules/agents/pi/extensions/subagents/ui.test.ts +++ b/modules/agents/pi/extensions/subagents/ui.test.ts @@ -9,6 +9,7 @@ function status(overrides: Partial & { id: string; label: string context: "independent", cwd: "/tmp", elapsedMs: 0, + activityHistory: [], resultAvailable: false, startedAt: "2026-08-01T00:00:00.000Z", tools: "inherit", @@ -34,6 +35,26 @@ test("compact monitor aggregates visible children by actionable lifecycle group" ); }); +test("expanded monitor shows concise current activity summaries instead of raw event types", () => { + const rendered = widget([ + status({ + id: "sg-reading", + label: "Audit guest enablement plan", + state: "running", + elapsedMs: 12_000, + lastEvent: "message_update", + currentActivity: { + type: "message_update", + summary: "read secret-notes.md", + at: "2026-08-01T00:00:12.000Z", + }, + }), + ], true)().render(240); + + assert.deepEqual(rendered, ["▶ running 12s Audit guest enablement plan last: read secret-notes.md"]); + assert.doesNotMatch(rendered.join("\n"), /message_update|private transcript body/u); +}); + test("expanded monitor renders one truncated row per child with state, elapsed time, and activity marker", () => { const lines = renderInspector([ status({ diff --git a/modules/agents/pi/extensions/subagents/ui.ts b/modules/agents/pi/extensions/subagents/ui.ts index e710a47..25907c4 100644 --- a/modules/agents/pi/extensions/subagents/ui.ts +++ b/modules/agents/pi/extensions/subagents/ui.ts @@ -55,6 +55,7 @@ function renderStatusRow(status: SubagentStatus): string { function statusMarker(status: SubagentStatus): string | undefined { if (status.error) return `error: ${status.error}`; if (status.resultAvailable) return "result: available"; + if (status.currentActivity) return `last: ${status.currentActivity.summary}`; if (status.lastEvent) return `last: ${status.lastEvent}`; if (status.state === "queued") return "waiting"; if (status.state === "settling") return "settling"; -- 2.47.3 From 7bc0d0772c691b8dd43cbdbba0f784221cb9a08c Mon Sep 17 00:00:00 2001 From: alexion Date: Sat, 1 Aug 2026 23:06:55 -0400 Subject: [PATCH 5/6] feat(pi): add read-only subagent attach view --- .../agents/pi/extensions/subagents/index.ts | 24 ++++- .../pi/extensions/subagents/supervisor.ts | 3 +- .../agents/pi/extensions/subagents/ui.test.ts | 38 ++++++- modules/agents/pi/extensions/subagents/ui.ts | 98 ++++++++++++++++++- 4 files changed, 159 insertions(+), 4 deletions(-) diff --git a/modules/agents/pi/extensions/subagents/index.ts b/modules/agents/pi/extensions/subagents/index.ts index ea73bca..b7334d6 100644 --- a/modules/agents/pi/extensions/subagents/index.ts +++ b/modules/agents/pi/extensions/subagents/index.ts @@ -6,7 +6,7 @@ import { SubprocessRpcRunner } from "./runner.ts"; import { Supervisor } from "./supervisor.ts"; import { milestoneNotification } from "./status.ts"; import type { SpawnRequest, SubagentStatus } from "./types.ts"; -import { widget } from "./ui.ts"; +import { attachedChildView, widget } from "./ui.ts"; let supervisor: Supervisor | undefined; let lastDiagnostics: Diagnostics = { warnings: [] }; @@ -230,6 +230,28 @@ export default function subagents(pi: ExtensionAPI) { }, }); + pi.registerCommand("subagent-attach", { + description: "Open a read-only attached view for a subagent id", + handler: async (args, ctx) => { + const id = args.trim(); + if (!id) { + ctx.ui.notify("Usage: /subagent-attach ", "warning"); + return; + } + const currentSupervisor = getSupervisor(ctx); + currentSupervisor.status(id); + await ctx.ui.custom((tui, _theme, _keybindings, done) => attachedChildView({ + status: () => currentSupervisor.status(id), + activity: () => currentSupervisor.activity(id), + onDetach: () => done(), + onChange: () => tui.requestRender(), + }), { + overlay: true, + overlayOptions: { width: "90%", maxHeight: "90%", minWidth: 60 }, + }); + }, + }); + pi.registerCommand("subagent-wait", { description: "Wait for subagent ids separated by spaces", handler: async (args, ctx) => { diff --git a/modules/agents/pi/extensions/subagents/supervisor.ts b/modules/agents/pi/extensions/subagents/supervisor.ts index 9bcf5a9..7073e8c 100644 --- a/modules/agents/pi/extensions/subagents/supervisor.ts +++ b/modules/agents/pi/extensions/subagents/supervisor.ts @@ -2,6 +2,7 @@ import type { ChildHandle, ChildRecord, ChildRunner, + SubagentActivityEvent, ContextMode, RunnerActivity, RunnerEvents, @@ -323,7 +324,7 @@ export class Supervisor { return [...this.children.values()].find((child) => child.record === record); } - activity(id: string) { + activity(id: string): SubagentActivityEvent[] { return this.require(id).record.activityEvents.map((event) => ({ ...event })); } diff --git a/modules/agents/pi/extensions/subagents/ui.test.ts b/modules/agents/pi/extensions/subagents/ui.test.ts index b659e64..0c7ff57 100644 --- a/modules/agents/pi/extensions/subagents/ui.test.ts +++ b/modules/agents/pi/extensions/subagents/ui.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { SubagentState, SubagentStatus } from "./types.ts"; -import { renderInspector, renderSummary, widget } from "./ui.ts"; +import { attachedChildView, renderAttachedChildView, renderInspector, renderSummary, widget } from "./ui.ts"; function status(overrides: Partial & { id: string; label: string; state: SubagentState }): SubagentStatus { return { @@ -55,6 +55,42 @@ test("expanded monitor shows concise current activity summaries instead of raw e assert.doesNotMatch(rendered.join("\n"), /message_update|private transcript body/u); }); +test("attached child view is read-only, renders transcript activity, and supports detach plus scrolling", () => { + const child = status({ id: "sg-child", label: "Research worker", state: "running" }); + const activity = Array.from({ length: 24 }, (_, index) => ({ + type: "message_update", + summary: `assistant message ${index + 1}`, + at: `2026-08-01T00:00:${String(index + 1).padStart(2, "0")}.000Z`, + role: "assistant", + text: `captured child message ${index + 1}`, + })); + + const bottom = renderAttachedChildView(child, activity, { width: 100, scrollOffset: 0 }); + assert.match(bottom.join("\n"), /read-only attached view/u); + assert.match(bottom.join("\n"), /Esc\/q detach/u); + assert.match(bottom.join("\n"), /captured child message 24/u); + assert.doesNotMatch(bottom.join("\n"), /> |prompt|send|input channel/ui); + + const scrolled = renderAttachedChildView(child, activity, { width: 100, scrollOffset: 6 }); + assert.match(scrolled.join("\n"), /captured child message 1[0-9]/u); + assert.doesNotMatch(scrolled.join("\n"), /captured child message 24/u); + + let detached = false; + const component = attachedChildView({ + status: () => child, + activity: () => activity, + onDetach: () => { + detached = true; + }, + }); + component.handleInput("\u001b[A"); + assert.doesNotMatch(component.render(100).join("\n"), /captured child message 24/u); + component.handleInput("\u001b[B"); + assert.match(component.render(100).join("\n"), /captured child message 24/u); + component.handleInput("q"); + assert.equal(detached, true); +}); + test("expanded monitor renders one truncated row per child with state, elapsed time, and activity marker", () => { const lines = renderInspector([ status({ diff --git a/modules/agents/pi/extensions/subagents/ui.ts b/modules/agents/pi/extensions/subagents/ui.ts index 25907c4..2176440 100644 --- a/modules/agents/pi/extensions/subagents/ui.ts +++ b/modules/agents/pi/extensions/subagents/ui.ts @@ -1,4 +1,4 @@ -import type { SubagentState, SubagentStatus } from "./types.ts"; +import type { SubagentActivityEvent, SubagentState, SubagentStatus } from "./types.ts"; const COMPACT_GROUPS: Array<{ label: string; states: SubagentState[] }> = [ { label: "queued", states: ["queued"] }, @@ -46,6 +46,58 @@ export function widget(statuses: SubagentStatus[], expanded: boolean) { }); } +export interface AttachedChildViewOptions { + status: () => SubagentStatus; + activity: () => SubagentActivityEvent[]; + onDetach: () => void; + onChange?: () => void; +} + +export function attachedChildView(options: AttachedChildViewOptions) { + let scrollOffset = 0; + return { + invalidate() {}, + render(width: number) { + const status = options.status(); + const activity = options.activity(); + const lines = renderAttachedChildView(status, activity, { width, scrollOffset }); + scrollOffset = clampScrollOffset(scrollOffset, transcriptLines(activity).length, attachedViewportHeight(width)); + return lines; + }, + handleInput(data: string) { + const key = keyName(data); + if (key === "escape" || data === "q") { + options.onDetach(); + return; + } + const viewportHeight = attachedViewportHeight(80); + if (key === "up") scrollOffset += 1; + else if (key === "down") scrollOffset -= 1; + else if (key === "pageup") scrollOffset += viewportHeight; + else if (key === "pagedown") scrollOffset -= viewportHeight; + else return; + scrollOffset = clampScrollOffset(scrollOffset, options.activity().length, viewportHeight); + options.onChange?.(); + }, + }; +} + +export function renderAttachedChildView(status: SubagentStatus, activity: SubagentActivityEvent[], options: { width: number; scrollOffset?: number }): string[] { + const viewportHeight = attachedViewportHeight(options.width); + const body = transcriptLines(activity); + const offset = clampScrollOffset(options.scrollOffset ?? 0, body.length, viewportHeight); + const start = Math.max(0, body.length - viewportHeight - offset); + const visible = body.slice(start, start + viewportHeight); + const scrollHint = body.length > viewportHeight ? ` · ${start + 1}-${start + visible.length}/${body.length}` : ""; + const lines = [ + `subagent ${status.id} · ${status.label} · ${STATE_PRESENTATION[status.state].label}`, + `read-only attached view · ↑/↓ scroll · PgUp/PgDn · Esc/q detach${scrollHint}`, + "", + ...(visible.length > 0 ? visible : ["system no captured child activity yet"]), + ]; + return lines.map((line) => truncateLine(line, options.width)); +} + function renderStatusRow(status: SubagentStatus): string { const presentation = STATE_PRESENTATION[status.state]; const marker = statusMarker(status); @@ -79,3 +131,47 @@ function truncateLine(line: string, width: number): string { if (width === 1) return "…"; return `${line.slice(0, width - 1)}…`; } + +function attachedViewportHeight(width: number): number { + return width < 60 ? 8 : 18; +} + +function transcriptLines(activity: SubagentActivityEvent[]): string[] { + return activity.map((event) => transcriptLine(event)); +} + +function transcriptLine(event: SubagentActivityEvent): string { + if (event.error) return `tool ${event.tool ?? event.type} failed: ${event.error}`; + if (event.tool) return `tool ${event.tool}${event.phase ? ` ${event.phase}` : ""}${valueHint(event.input)}`; + if (event.text) return `${(event.role ?? "assistant").padEnd(8)} ${event.text}`; + if (event.output !== undefined) return `tool ${event.type} output${valueHint(event.output)}`; + return `system ${event.summary}`; +} + +function valueHint(value: unknown): string { + if (value === undefined) return ""; + if (typeof value === "string") return ` ${truncateActivityHint(value)}`; + if (!value || typeof value !== "object" || Array.isArray(value)) return ""; + const path = (value as { path?: unknown }).path; + if (typeof path === "string" && path.trim()) return ` ${path.trim()}`; + const command = (value as { command?: unknown }).command; + if (typeof command === "string" && command.trim()) return ` ${truncateActivityHint(command.trim())}`; + return ""; +} + +function truncateActivityHint(value: string): string { + return value.length <= 80 ? value : `${value.slice(0, 79).trimEnd()}…`; +} + +function clampScrollOffset(offset: number, lineCount: number, viewportHeight: number): number { + return Math.max(0, Math.min(offset, Math.max(0, lineCount - viewportHeight))); +} + +function keyName(data: string): string { + if (data === "\u001b") return "escape"; + if (data === "\u001b[A") return "up"; + if (data === "\u001b[B") return "down"; + if (data === "\u001b[5~") return "pageup"; + if (data === "\u001b[6~") return "pagedown"; + return data; +} -- 2.47.3 From eb67944e682e442d44ecff4588b385578254510c Mon Sep 17 00:00:00 2001 From: alexion Date: Sat, 1 Aug 2026 23:24:16 -0400 Subject: [PATCH 6/6] Revert "feat(pi): add read-only subagent attach view" This reverts commit 7bc0d0772c691b8dd43cbdbba0f784221cb9a08c. --- .../agents/pi/extensions/subagents/index.ts | 24 +---- .../pi/extensions/subagents/supervisor.ts | 3 +- .../agents/pi/extensions/subagents/ui.test.ts | 38 +------ modules/agents/pi/extensions/subagents/ui.ts | 98 +------------------ 4 files changed, 4 insertions(+), 159 deletions(-) diff --git a/modules/agents/pi/extensions/subagents/index.ts b/modules/agents/pi/extensions/subagents/index.ts index b7334d6..ea73bca 100644 --- a/modules/agents/pi/extensions/subagents/index.ts +++ b/modules/agents/pi/extensions/subagents/index.ts @@ -6,7 +6,7 @@ import { SubprocessRpcRunner } from "./runner.ts"; import { Supervisor } from "./supervisor.ts"; import { milestoneNotification } from "./status.ts"; import type { SpawnRequest, SubagentStatus } from "./types.ts"; -import { attachedChildView, widget } from "./ui.ts"; +import { widget } from "./ui.ts"; let supervisor: Supervisor | undefined; let lastDiagnostics: Diagnostics = { warnings: [] }; @@ -230,28 +230,6 @@ export default function subagents(pi: ExtensionAPI) { }, }); - pi.registerCommand("subagent-attach", { - description: "Open a read-only attached view for a subagent id", - handler: async (args, ctx) => { - const id = args.trim(); - if (!id) { - ctx.ui.notify("Usage: /subagent-attach ", "warning"); - return; - } - const currentSupervisor = getSupervisor(ctx); - currentSupervisor.status(id); - await ctx.ui.custom((tui, _theme, _keybindings, done) => attachedChildView({ - status: () => currentSupervisor.status(id), - activity: () => currentSupervisor.activity(id), - onDetach: () => done(), - onChange: () => tui.requestRender(), - }), { - overlay: true, - overlayOptions: { width: "90%", maxHeight: "90%", minWidth: 60 }, - }); - }, - }); - pi.registerCommand("subagent-wait", { description: "Wait for subagent ids separated by spaces", handler: async (args, ctx) => { diff --git a/modules/agents/pi/extensions/subagents/supervisor.ts b/modules/agents/pi/extensions/subagents/supervisor.ts index 7073e8c..9bcf5a9 100644 --- a/modules/agents/pi/extensions/subagents/supervisor.ts +++ b/modules/agents/pi/extensions/subagents/supervisor.ts @@ -2,7 +2,6 @@ import type { ChildHandle, ChildRecord, ChildRunner, - SubagentActivityEvent, ContextMode, RunnerActivity, RunnerEvents, @@ -324,7 +323,7 @@ export class Supervisor { return [...this.children.values()].find((child) => child.record === record); } - activity(id: string): SubagentActivityEvent[] { + activity(id: string) { return this.require(id).record.activityEvents.map((event) => ({ ...event })); } diff --git a/modules/agents/pi/extensions/subagents/ui.test.ts b/modules/agents/pi/extensions/subagents/ui.test.ts index 0c7ff57..b659e64 100644 --- a/modules/agents/pi/extensions/subagents/ui.test.ts +++ b/modules/agents/pi/extensions/subagents/ui.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { SubagentState, SubagentStatus } from "./types.ts"; -import { attachedChildView, renderAttachedChildView, renderInspector, renderSummary, widget } from "./ui.ts"; +import { renderInspector, renderSummary, widget } from "./ui.ts"; function status(overrides: Partial & { id: string; label: string; state: SubagentState }): SubagentStatus { return { @@ -55,42 +55,6 @@ test("expanded monitor shows concise current activity summaries instead of raw e assert.doesNotMatch(rendered.join("\n"), /message_update|private transcript body/u); }); -test("attached child view is read-only, renders transcript activity, and supports detach plus scrolling", () => { - const child = status({ id: "sg-child", label: "Research worker", state: "running" }); - const activity = Array.from({ length: 24 }, (_, index) => ({ - type: "message_update", - summary: `assistant message ${index + 1}`, - at: `2026-08-01T00:00:${String(index + 1).padStart(2, "0")}.000Z`, - role: "assistant", - text: `captured child message ${index + 1}`, - })); - - const bottom = renderAttachedChildView(child, activity, { width: 100, scrollOffset: 0 }); - assert.match(bottom.join("\n"), /read-only attached view/u); - assert.match(bottom.join("\n"), /Esc\/q detach/u); - assert.match(bottom.join("\n"), /captured child message 24/u); - assert.doesNotMatch(bottom.join("\n"), /> |prompt|send|input channel/ui); - - const scrolled = renderAttachedChildView(child, activity, { width: 100, scrollOffset: 6 }); - assert.match(scrolled.join("\n"), /captured child message 1[0-9]/u); - assert.doesNotMatch(scrolled.join("\n"), /captured child message 24/u); - - let detached = false; - const component = attachedChildView({ - status: () => child, - activity: () => activity, - onDetach: () => { - detached = true; - }, - }); - component.handleInput("\u001b[A"); - assert.doesNotMatch(component.render(100).join("\n"), /captured child message 24/u); - component.handleInput("\u001b[B"); - assert.match(component.render(100).join("\n"), /captured child message 24/u); - component.handleInput("q"); - assert.equal(detached, true); -}); - test("expanded monitor renders one truncated row per child with state, elapsed time, and activity marker", () => { const lines = renderInspector([ status({ diff --git a/modules/agents/pi/extensions/subagents/ui.ts b/modules/agents/pi/extensions/subagents/ui.ts index 2176440..25907c4 100644 --- a/modules/agents/pi/extensions/subagents/ui.ts +++ b/modules/agents/pi/extensions/subagents/ui.ts @@ -1,4 +1,4 @@ -import type { SubagentActivityEvent, SubagentState, SubagentStatus } from "./types.ts"; +import type { SubagentState, SubagentStatus } from "./types.ts"; const COMPACT_GROUPS: Array<{ label: string; states: SubagentState[] }> = [ { label: "queued", states: ["queued"] }, @@ -46,58 +46,6 @@ export function widget(statuses: SubagentStatus[], expanded: boolean) { }); } -export interface AttachedChildViewOptions { - status: () => SubagentStatus; - activity: () => SubagentActivityEvent[]; - onDetach: () => void; - onChange?: () => void; -} - -export function attachedChildView(options: AttachedChildViewOptions) { - let scrollOffset = 0; - return { - invalidate() {}, - render(width: number) { - const status = options.status(); - const activity = options.activity(); - const lines = renderAttachedChildView(status, activity, { width, scrollOffset }); - scrollOffset = clampScrollOffset(scrollOffset, transcriptLines(activity).length, attachedViewportHeight(width)); - return lines; - }, - handleInput(data: string) { - const key = keyName(data); - if (key === "escape" || data === "q") { - options.onDetach(); - return; - } - const viewportHeight = attachedViewportHeight(80); - if (key === "up") scrollOffset += 1; - else if (key === "down") scrollOffset -= 1; - else if (key === "pageup") scrollOffset += viewportHeight; - else if (key === "pagedown") scrollOffset -= viewportHeight; - else return; - scrollOffset = clampScrollOffset(scrollOffset, options.activity().length, viewportHeight); - options.onChange?.(); - }, - }; -} - -export function renderAttachedChildView(status: SubagentStatus, activity: SubagentActivityEvent[], options: { width: number; scrollOffset?: number }): string[] { - const viewportHeight = attachedViewportHeight(options.width); - const body = transcriptLines(activity); - const offset = clampScrollOffset(options.scrollOffset ?? 0, body.length, viewportHeight); - const start = Math.max(0, body.length - viewportHeight - offset); - const visible = body.slice(start, start + viewportHeight); - const scrollHint = body.length > viewportHeight ? ` · ${start + 1}-${start + visible.length}/${body.length}` : ""; - const lines = [ - `subagent ${status.id} · ${status.label} · ${STATE_PRESENTATION[status.state].label}`, - `read-only attached view · ↑/↓ scroll · PgUp/PgDn · Esc/q detach${scrollHint}`, - "", - ...(visible.length > 0 ? visible : ["system no captured child activity yet"]), - ]; - return lines.map((line) => truncateLine(line, options.width)); -} - function renderStatusRow(status: SubagentStatus): string { const presentation = STATE_PRESENTATION[status.state]; const marker = statusMarker(status); @@ -131,47 +79,3 @@ function truncateLine(line: string, width: number): string { if (width === 1) return "…"; return `${line.slice(0, width - 1)}…`; } - -function attachedViewportHeight(width: number): number { - return width < 60 ? 8 : 18; -} - -function transcriptLines(activity: SubagentActivityEvent[]): string[] { - return activity.map((event) => transcriptLine(event)); -} - -function transcriptLine(event: SubagentActivityEvent): string { - if (event.error) return `tool ${event.tool ?? event.type} failed: ${event.error}`; - if (event.tool) return `tool ${event.tool}${event.phase ? ` ${event.phase}` : ""}${valueHint(event.input)}`; - if (event.text) return `${(event.role ?? "assistant").padEnd(8)} ${event.text}`; - if (event.output !== undefined) return `tool ${event.type} output${valueHint(event.output)}`; - return `system ${event.summary}`; -} - -function valueHint(value: unknown): string { - if (value === undefined) return ""; - if (typeof value === "string") return ` ${truncateActivityHint(value)}`; - if (!value || typeof value !== "object" || Array.isArray(value)) return ""; - const path = (value as { path?: unknown }).path; - if (typeof path === "string" && path.trim()) return ` ${path.trim()}`; - const command = (value as { command?: unknown }).command; - if (typeof command === "string" && command.trim()) return ` ${truncateActivityHint(command.trim())}`; - return ""; -} - -function truncateActivityHint(value: string): string { - return value.length <= 80 ? value : `${value.slice(0, 79).trimEnd()}…`; -} - -function clampScrollOffset(offset: number, lineCount: number, viewportHeight: number): number { - return Math.max(0, Math.min(offset, Math.max(0, lineCount - viewportHeight))); -} - -function keyName(data: string): string { - if (data === "\u001b") return "escape"; - if (data === "\u001b[A") return "up"; - if (data === "\u001b[B") return "down"; - if (data === "\u001b[5~") return "pageup"; - if (data === "\u001b[6~") return "pagedown"; - return data; -} -- 2.47.3