diff --git a/.informant/config.toml b/.informant/config.toml index fc44506..bfa1add 100644 --- a/.informant/config.toml +++ b/.informant/config.toml @@ -14,7 +14,7 @@ prepare = """ set -eu export DEBIAN_FRONTEND=noninteractive apt-get update -apt-get install -y --no-install-recommends git +apt-get install -y --no-install-recommends git lsof rm -rf /var/lib/apt/lists/* cd "$INFORMANT_PREPARE_ROOT" bun install --frozen-lockfile --ignore-scripts diff --git a/.informant/jobs/build.toml b/.informant/jobs/build.toml index bf0c70e..84a2d53 100644 --- a/.informant/jobs/build.toml +++ b/.informant/jobs/build.toml @@ -2,8 +2,11 @@ name = "build" needs = ["test", "typecheck"] command = """ set -eu +expected_dist="$(mktemp -d)" +trap 'rm -rf "$expected_dist"' EXIT +cp -R web/dist "$expected_dist/dist" bun run build -git diff --exit-code -- web/dist +diff -ru "$expected_dist/dist" web/dist """ timeout_minutes = 15 container = { cpu = 2, memory_mb = 4096 } diff --git a/extensions/subagents.ts b/extensions/subagents.ts index d949f8a..815ea96 100644 --- a/extensions/subagents.ts +++ b/extensions/subagents.ts @@ -1,1712 +1,45 @@ -import { resolve } from "node:path"; import { - createAgentSession, CustomEditor, - DefaultResourceLoader, - DynamicBorder, - getAgentDir, - ModelRuntime, - SessionManager, - SettingsManager, - type AgentSession, - type AgentSessionEvent, type ExtensionAPI, type ExtensionContext, - type KeybindingsManager, - type ModelRegistry, - type Theme, } from "@earendil-works/pi-coding-agent"; -import { - Container, - type EditorComponent, - type Focusable, - matchesKey, - SelectList, - type SelectItem, - Text, - truncateToWidth, - visibleWidth, - wrapTextWithAnsi, - type Component, - type TUI, -} from "@earendil-works/pi-tui"; -import { Type } from "typebox"; -import { - FOOTER_CONTRIBUTION_EVENT, - type FooterContribution, - type FooterUsage, -} from "./footer-events.js"; import { parseSubagentAbortRequest, SUBAGENT_ABORT_EVENT, - SUBAGENT_STATUS_EVENT, - type SubagentStatusEvent, - type SubagentWebSnapshot, - type SubagentWebUpdate, } from "./subagent-events.js"; - -const MAX_SUBAGENTS = 8; -const MAX_ACTIVITY_ITEMS = 500; -const MAX_TRANSCRIPT_ITEMS = 500; -const MAX_TRANSCRIPT_ENTRY_CHARS = 100_000; -const MAX_TRANSCRIPT_CHARS = 1_000_000; -const MAX_WEB_TRANSCRIPT_CHARS = 100_000; -export const MAX_WEB_STREAMING_CHARS = 20_000; -const WEB_STATUS_PUBLISH_INTERVAL_MS = 1_000; -const MAX_TOOL_OUTPUT_BYTES = 50 * 1024; -const DEFAULT_READ_WAIT_SECONDS = 15; -const DETAIL_VIEW_LINES = 22; -const USAGE_STATE_ENTRY = "vessup-subagent-usage"; -const SUBAGENT_SYSTEM_PROMPT = [ - "You are a subagent working for a main coding agent.", - "Work independently on the delegated task in the current working directory.", - "Use tools when useful, keep changes scoped to the task, and finish with a concise report of findings, changes, tests, and remaining risks.", - "Messages received after the initial task are instructions from the main agent; urgent steering messages supersede your current approach.", -].join(" "); - -export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; -export type SubagentEffort = (typeof THINKING_LEVELS)[number]; -export type SubagentStatus = "creating" | "working" | "completed" | "failed" | "terminating" | "terminated"; -export type MessageUrgency = "normal" | "urgent"; - -type ActivityItem = { - timestamp: number; - text: string; -}; - -type TranscriptItem = { - timestamp: number; - role: string; - text: string; -}; - -type ManagedSubagent = { - id: string; - prompt: string; - cwd: string; - createdAt: number; - updatedAt: number; - completedAt?: number; - status: SubagentStatus; - model: string; - effort: SubagentEffort; - currentTool?: string; - error?: string; - lastStopReason?: string; - turns: number; - queuedSteering: number; - queuedFollowUp: number; - activity: ActivityItem[]; - lastReadActivity: number; - transcript: TranscriptItem[]; - streamingText: string; - lastStreamActivityAt: number; - usage: Usage; - session?: AgentSession; - unsubscribe?: () => void; - runPromise?: Promise; - waiters: Set<() => void>; -}; - -type AgentSnapshot = { - id: string; - status: SubagentStatus; - model: string; - effort: SubagentEffort; - turns: number; - currentTool?: string; - queued: number; -}; - -type ToolDetails = { - agents: SubagentWebSnapshot[]; -}; - -type Usage = FooterUsage; -type AgentModel = NonNullable>; -type PersistedUsageState = { total: Usage; accounted: Usage }; - -type ManagerDialogResult = - | { action: "close" } - | { action: "view"; id: string } - | { action: "back" } - | { action: "model"; id: string } - | { action: "effort"; id: string } - | { action: "urgent"; id: string } - | { action: "queue"; id: string } - | { action: "terminate"; id: string }; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function zeroUsage(): Usage { - return { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }; -} - -function parseUsage(value: unknown): Usage | undefined { - if (!isRecord(value) || !isRecord(value.cost)) return undefined; - const cost = value.cost; - const fields = ["input", "output", "cacheRead", "cacheWrite", "totalTokens"] as const; - if (fields.some((field) => typeof value[field] !== "number" || !Number.isFinite(value[field]))) return undefined; - const costFields = ["input", "output", "cacheRead", "cacheWrite", "total"] as const; - if (costFields.some((field) => typeof cost[field] !== "number" || !Number.isFinite(cost[field]))) return undefined; - return { - input: value.input as number, - output: value.output as number, - cacheRead: value.cacheRead as number, - cacheWrite: value.cacheWrite as number, - totalTokens: value.totalTokens as number, - cost: { - input: cost.input as number, - output: cost.output as number, - cacheRead: cost.cacheRead as number, - cacheWrite: cost.cacheWrite as number, - total: cost.total as number, - }, - }; -} - -export function parsePersistedUsageState(value: unknown): PersistedUsageState | undefined { - if (!isRecord(value)) return undefined; - const total = parseUsage(value.total); - const accounted = parseUsage(value.accounted); - return total && accounted ? { total, accounted } : undefined; -} - -export function appendBoundedStreamingText(current: string, delta: string): string { - const combined = current + delta; - return combined.length <= MAX_WEB_STREAMING_CHARS - ? combined - : combined.slice(-MAX_WEB_STREAMING_CHARS); -} - -function cloneUsage(usage: Usage): Usage { - return { - input: usage.input, - output: usage.output, - cacheRead: usage.cacheRead, - cacheWrite: usage.cacheWrite, - totalTokens: usage.totalTokens, - cost: { ...usage.cost }, - }; -} - -function addUsage(target: Usage, usage: Usage | undefined): void { - if (!usage) return; - target.input += usage.input || 0; - target.output += usage.output || 0; - target.cacheRead += usage.cacheRead || 0; - target.cacheWrite += usage.cacheWrite || 0; - target.totalTokens += usage.totalTokens || 0; - target.cost.input += usage.cost?.input || 0; - target.cost.output += usage.cost?.output || 0; - target.cost.cacheRead += usage.cost?.cacheRead || 0; - target.cost.cacheWrite += usage.cost?.cacheWrite || 0; - target.cost.total += usage.cost?.total || 0; -} - -function subtractUsage(total: Usage, accounted: Usage): Usage { - return { - input: Math.max(0, total.input - accounted.input), - output: Math.max(0, total.output - accounted.output), - cacheRead: Math.max(0, total.cacheRead - accounted.cacheRead), - cacheWrite: Math.max(0, total.cacheWrite - accounted.cacheWrite), - totalTokens: Math.max(0, total.totalTokens - accounted.totalTokens), - cost: { - input: Math.max(0, total.cost.input - accounted.cost.input), - output: Math.max(0, total.cost.output - accounted.cost.output), - cacheRead: Math.max(0, total.cost.cacheRead - accounted.cost.cacheRead), - cacheWrite: Math.max(0, total.cost.cacheWrite - accounted.cost.cacheWrite), - total: Math.max(0, total.cost.total - accounted.cost.total), - }, - }; -} - -function hasUsage(usage: Usage): boolean { - return ( - usage.input > 0 || - usage.output > 0 || - usage.cacheRead > 0 || - usage.cacheWrite > 0 || - usage.totalTokens > 0 || - usage.cost.total > 0 - ); -} - -function formatTokens(count: number): string { - if (count < 1_000) return `${count}`; - if (count < 10_000) return `${(count / 1_000).toFixed(1)}k`; - if (count < 1_000_000) return `${Math.round(count / 1_000)}k`; - return `${(count / 1_000_000).toFixed(1)}M`; -} - -function formatDuration(milliseconds: number): string { - const seconds = Math.max(0, Math.floor(milliseconds / 1_000)); - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ${seconds % 60}s`; - return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; -} - -function formatClock(timestamp: number): string { - return new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); -} - -function sanitizeName(value: string): string { - return value - .trim() - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 40); -} - -function stringifyCompact(value: unknown, max = 200): string { - let text: string; - try { - text = JSON.stringify(value); - } catch { - text = String(value); - } - return text.length > max ? `${text.slice(0, max)}…` : text; -} - -function truncateChars(text: string, maximum: number): string { - return text.length <= maximum ? text : `${text.slice(0, maximum)}\n[… ${text.length - maximum} characters omitted]`; -} - -function contentToText(content: unknown): string { - if (typeof content === "string") return content; - if (!Array.isArray(content)) return ""; - const parts: string[] = []; - for (const block of content) { - if (!block || typeof block !== "object") continue; - const item = block as Record; - if (item.type === "text" && typeof item.text === "string") parts.push(item.text); - else if (item.type === "thinking" && typeof item.thinking === "string") parts.push(`[thinking]\n${item.thinking}`); - else if (item.type === "toolCall" && typeof item.name === "string") { - parts.push(`→ ${item.name} ${stringifyCompact(item.arguments)}`); - } else if (item.type === "image") { - parts.push("[image]"); - } - } - return parts.join("\n"); -} - -function messageToTranscript(message: unknown): TranscriptItem | undefined { - if (!message || typeof message !== "object") return undefined; - const item = message as Record; - if (typeof item.role !== "string") return undefined; - const text = truncateChars(contentToText(item.content), MAX_TRANSCRIPT_ENTRY_CHARS).trim(); - if (!text) return undefined; - return { - timestamp: typeof item.timestamp === "number" ? item.timestamp : Date.now(), - role: item.role, - text, - }; -} - -function messageUsage(message: unknown): Usage | undefined { - if (!message || typeof message !== "object") return undefined; - const usage = (message as Record).usage; - if (!usage || typeof usage !== "object") return undefined; - return usage as Usage; -} - -function messageRole(message: unknown): string | undefined { - return message && typeof message === "object" && typeof (message as Record).role === "string" - ? ((message as Record).role as string) - : undefined; -} - -function messageStopReason(message: unknown): string | undefined { - return message && typeof message === "object" && typeof (message as Record).stopReason === "string" - ? ((message as Record).stopReason as string) - : undefined; -} - -function messageError(message: unknown): string | undefined { - return message && typeof message === "object" && typeof (message as Record).errorMessage === "string" - ? ((message as Record).errorMessage as string) - : undefined; -} - -function finalAssistantText(agent: ManagedSubagent): string { - for (let index = agent.transcript.length - 1; index >= 0; index--) { - const item = agent.transcript[index]; - if (item?.role === "assistant") return item.text; - } - return agent.streamingText.trim(); -} - -function boundedWebTranscript(items: readonly TranscriptItem[]): TranscriptItem[] { - const retained: TranscriptItem[] = []; - let characters = 0; - for (let index = items.length - 1; index >= 0; index--) { - const item = items[index]; - if (!item) continue; - const remaining = MAX_WEB_TRANSCRIPT_CHARS - characters; - if (remaining <= 0 && retained.length > 0) break; - const text = truncateChars(item.text, Math.max(1, remaining)); - retained.push({ ...item, text }); - characters += text.length; - } - return retained.reverse(); -} - -function webTranscript(agent: ManagedSubagent): TranscriptItem[] { - return boundedWebTranscript(agent.transcript); -} - -export function isFailedStopReason(stopReason: string | undefined): boolean { - return stopReason === "error" || stopReason === "aborted"; -} - -export function countsAgainstSubagentLimit(agent: { status: SubagentStatus; session?: unknown }): boolean { - return agent.status === "creating" || agent.session !== undefined; -} - -export function isTerminalSubagentStatus(status: SubagentStatus): boolean { - return status === "completed" || status === "failed" || status === "terminated"; -} - -export function shouldArchiveTerminalSubagent(agent: { - status: SubagentStatus; - lastReadActivity: number; - activity: readonly unknown[]; -}): boolean { - return isTerminalSubagentStatus(agent.status) && agent.lastReadActivity < agent.activity.length; -} - -export async function abortRunningSubagentSessions } }>( - agents: readonly T[], -): Promise> { - const running = agents.filter((agent) => agent.session && (agent.status === "creating" || agent.status === "working")); - return await Promise.all(running.map(async (agent) => { - try { - await agent.session!.abort(); - return { agent }; - } catch (error) { - return { agent, error: error instanceof Error ? error : new Error(String(error)) }; - } - })); -} - -export function filterModelsToScope( - available: readonly T[], - scoped: ReadonlyArray<{ model: { provider: string; id: string } }>, -): readonly T[] { - if (scoped.length === 0) return available; - const allowed = new Set(scoped.map(({ model }) => `${model.provider}/${model.id}`)); - return available.filter((model) => allowed.has(`${model.provider}/${model.id}`)); -} - -export function inheritedSubagentModel( - current: T | undefined, - runtimeModel: T | undefined, -): T | undefined { - return runtimeModel ?? current; -} - -export function subagentModelRuntime(modelRegistry: ModelRegistry): ModelRuntime { - // ModelRegistry is the extension-facing compatibility facade around the - // canonical runtime. Sharing that runtime preserves runtime-only keys and - // provider-resolved headers/env/base URLs, while leaving stored OAuth in the - // credential store so both host and child continue to refresh it normally. - const runtime: unknown = Reflect.get(modelRegistry, "runtime"); - if (!(runtime instanceof ModelRuntime)) { - throw new Error("The host model registry does not expose its canonical runtime"); - } - return runtime; -} - -function statusIcon(status: SubagentStatus): string { - switch (status) { - case "creating": - case "working": - case "terminating": - return "◐"; - case "completed": - return "✓"; - case "failed": - return "✗"; - case "terminated": - return "■"; - } -} - -function statusColor(status: SubagentStatus): "warning" | "success" | "error" | "muted" { - switch (status) { - case "creating": - case "working": - case "terminating": - return "warning"; - case "completed": - return "success"; - case "failed": - return "error"; - case "terminated": - return "muted"; - } -} - -function truncateToolOutput(text: string): string { - const bytes = Buffer.byteLength(text, "utf8"); - if (bytes <= MAX_TOOL_OUTPUT_BYTES) return text; - let output = text.slice(0, MAX_TOOL_OUTPUT_BYTES); - while (Buffer.byteLength(output, "utf8") > MAX_TOOL_OUTPUT_BYTES) output = output.slice(0, -1); - return `${output}\n\n[Output truncated: ${bytes - Buffer.byteLength(output, "utf8")} bytes omitted. Re-read a specific subagent or use the transcript modal for details.]`; -} - -function modelName(model: { provider: string; id: string } | undefined): string { - return model ? `${model.provider}/${model.id}` : "no-model"; -} - -export function subagentModelGuidance( - current: { provider: string; id: string } | undefined, - available: readonly { provider: string; id: string }[], -): string { - const choices = [...new Set(available.map(modelName))]; - const inherited = modelName(current); - return [ - "Subagent model selection for this session:", - `- subagent_create inherits ${inherited} when model is omitted.`, - "- Only pass model when intentionally overriding the inherited model.", - `- Exact available provider/model IDs: ${choices.length > 0 ? choices.join(", ") : "none"}.`, - "- Never shorten, generalize, or invent a model ID.", - ].join("\n"); -} - -function unavailableModelMessage( - requested: string, - available: readonly { provider: string; id: string }[], - current: { provider: string; id: string } | undefined, - withinScope: boolean, -): string { - const choices = [...new Set(available.map(modelName))]; - const scope = withinScope ? " within the session scope" : ""; - const allowed = choices.length > 0 ? choices.join(", ") : "none"; - const inherit = current ? ` Omit model to inherit ${modelName(current)}.` : ""; - return `Model is unavailable${scope}: ${requested}. Exact available models: ${allowed}.${inherit}`; -} - -function asFooterUsage(usage: Usage): FooterUsage { - return cloneUsage(usage); -} - -class SubagentManager { - readonly agents = new Map(); - private archivedAgents = new Map(); - private nextId = 1; - private currentContext?: ExtensionContext; - private totalUsage = zeroUsage(); - private accountedUsage = zeroUsage(); - private usageDirty = false; - private footerSelected = false; - private lastWebStatusPublishedAt = 0; - private webStatusPublishTimer?: ReturnType; - private webTranscriptCursors = new Map(); - private webStreamingSnapshots = new Map(); - private abortAllInFlight?: Promise; - - constructor(private readonly pi: ExtensionAPI) {} - - setContext(ctx: ExtensionContext): void { - this.currentContext = ctx; - this.footerSelected = false; - this.totalUsage = zeroUsage(); - this.accountedUsage = zeroUsage(); - this.usageDirty = false; - this.webTranscriptCursors.clear(); - this.webStreamingSnapshots.clear(); - for (const entry of [...ctx.sessionManager.getBranch()].reverse()) { - if (entry.type !== "custom" || entry.customType !== USAGE_STATE_ENTRY) continue; - const restored = parsePersistedUsageState(entry.data); - if (restored) { - this.totalUsage = cloneUsage(restored.total); - this.accountedUsage = cloneUsage(restored.accounted); - } - break; - } - this.publishFooter(); - } - - persistUsage(): void { - if (!this.usageDirty || !hasUsage(this.totalUsage)) return; - this.pi.appendEntry(USAGE_STATE_ENTRY, { - total: cloneUsage(this.totalUsage), - accounted: cloneUsage(this.accountedUsage), - } satisfies PersistedUsageState); - this.usageDirty = false; - } - - clearContext(): void { - if (this.webStatusPublishTimer) clearTimeout(this.webStatusPublishTimer); - this.webStatusPublishTimer = undefined; - this.lastWebStatusPublishedAt = 0; - this.webTranscriptCursors.clear(); - this.webStreamingSnapshots.clear(); - this.archivedAgents.clear(); - const ctx = this.currentContext; - if (ctx) { - const sessionId = ctx.sessionManager.getSessionId(); - this.pi.events.emit(FOOTER_CONTRIBUTION_EVENT, { - sessionId, - key: "subagents", - remove: true, - } satisfies FooterContribution); - this.pi.events.emit(SUBAGENT_STATUS_EVENT, { - sessionId, - agents: [], - usage: zeroUsage(), - remove: true, - } satisfies SubagentStatusEvent); - } - this.currentContext = undefined; - this.footerSelected = false; - } - - hasAgents(): boolean { - return this.agents.size > 0; - } - - isFooterSelected(): boolean { - return this.footerSelected; - } - - setFooterSelected(selected: boolean): void { - if (this.footerSelected === selected) return; - this.footerSelected = selected && this.hasAgents(); - this.publishFooter(); - } - - getAgent(id: string): ManagedSubagent { - const agent = this.agents.get(id) ?? this.archivedAgents.get(id); - if (!agent) throw new Error(`Unknown subagent: ${id}`); - return agent; - } - - list(): ManagedSubagent[] { - return Array.from(this.agents.values()).sort((a, b) => a.createdAt - b.createdAt); - } - - snapshots(): AgentSnapshot[] { - return this.list().map((agent) => ({ - id: agent.id, - status: agent.status, - model: agent.model, - effort: agent.effort, - turns: agent.turns, - currentTool: agent.currentTool, - queued: agent.queuedSteering + agent.queuedFollowUp, - })); - } - - webSnapshots(): SubagentWebSnapshot[] { - return this.list().map((agent) => ({ - id: agent.id, - status: agent.status, - model: agent.model, - effort: agent.effort, - turns: agent.turns, - currentTool: agent.currentTool, - queued: agent.queuedSteering + agent.queuedFollowUp, - createdAt: agent.createdAt, - updatedAt: agent.updatedAt, - completedAt: agent.completedAt, - error: agent.error, - usage: cloneUsage(agent.usage), - transcript: webTranscript(agent), - streamingText: agent.streamingText || undefined, - })); - } - - private webStatusUpdates(): SubagentWebUpdate[] { - return this.list().map((agent) => { - const update: SubagentWebUpdate = { - id: agent.id, - status: agent.status, - model: agent.model, - effort: agent.effort, - turns: agent.turns, - currentTool: agent.currentTool ?? null, - queued: agent.queuedSteering + agent.queuedFollowUp, - createdAt: agent.createdAt, - updatedAt: agent.updatedAt, - completedAt: agent.completedAt ?? null, - error: agent.error ?? null, - usage: cloneUsage(agent.usage), - }; - - const hadTranscriptCursor = this.webTranscriptCursors.has(agent.id); - const previousTranscriptItem = this.webTranscriptCursors.get(agent.id); - const previousIndex = previousTranscriptItem ? agent.transcript.indexOf(previousTranscriptItem) : -1; - if (!hadTranscriptCursor || (previousTranscriptItem && previousIndex < 0)) { - update.transcriptReset = true; - update.transcriptDelta = webTranscript(agent); - } else { - const firstNewIndex = previousTranscriptItem ? previousIndex + 1 : 0; - if (firstNewIndex < agent.transcript.length) { - update.transcriptDelta = boundedWebTranscript(agent.transcript.slice(firstNewIndex)); - } - } - this.webTranscriptCursors.set(agent.id, agent.transcript.at(-1)); - - const hadStreamingSnapshot = this.webStreamingSnapshots.has(agent.id); - const previousStreamingText = this.webStreamingSnapshots.get(agent.id) ?? ""; - if (!hadStreamingSnapshot || !agent.streamingText.startsWith(previousStreamingText)) { - update.streamingTextReset = true; - update.streamingTextDelta = agent.streamingText; - } else if (agent.streamingText.length > previousStreamingText.length) { - update.streamingTextDelta = agent.streamingText.slice(previousStreamingText.length); - } - this.webStreamingSnapshots.set(agent.id, agent.streamingText); - return update; - }); - } - - private makeId(requestedName?: string): string { - const base = requestedName ? sanitizeName(requestedName) : `agent-${this.nextId++}`; - if (!base) return this.makeId(); - if (!this.agents.has(base) && !this.archivedAgents.has(base)) return base; - let suffix = 2; - while (this.agents.has(`${base}-${suffix}`) || this.archivedAgents.has(`${base}-${suffix}`)) suffix++; - return `${base}-${suffix}`; - } - - private activeSessionCount(): number { - return this.list().filter(countsAgainstSubagentLimit).length; - } - - private getModelRuntime(ctx: ExtensionContext): ModelRuntime { - return subagentModelRuntime(ctx.modelRegistry); - } - - async availableModels(ctx: ExtensionContext): Promise { - const available = await this.getModelRuntime(ctx).getAvailable(); - return filterModelsToScope(available, ctx.scopedModels); - } - - private async resolveModel(ctx: ExtensionContext, requested?: string, runtime?: ModelRuntime): Promise { - if (!requested) { - if (!ctx.model) return undefined; - const inheritedRuntime = runtime ?? this.getModelRuntime(ctx); - // Omitted model means exact host-session inheritance. Session model scope - // applies only to explicit overrides and may intentionally exclude the - // separately selected --model value. - return inheritedSubagentModel( - ctx.model as AgentModel, - inheritedRuntime.getModel(ctx.model.provider, ctx.model.id), - ); - } - - const available = await this.availableModels(ctx); - const slash = requested.indexOf("/"); - if (slash > 0) { - const provider = requested.slice(0, slash); - const id = requested.slice(slash + 1); - const model = available.find((item) => item.provider === provider && item.id === id); - if (model) return model; - throw new Error(unavailableModelMessage(requested, available, ctx.model, ctx.scopedModels.length > 0)); - } - - const matches = available.filter((item) => item.id === requested || item.name === requested); - if (matches.length === 1) return matches[0]; - if (matches.length > 1) { - throw new Error(`Model name is ambiguous; use provider/model: ${matches.map(modelName).join(", ")}`); - } - throw new Error(unavailableModelMessage(requested, available, ctx.model, ctx.scopedModels.length > 0)); - } - - private scopedEffort(ctx: ExtensionContext, model: AgentModel | undefined): SubagentEffort | undefined { - if (!model) return undefined; - return ctx.scopedModels.find( - (scoped) => scoped.model.provider === model.provider && scoped.model.id === model.id, - )?.thinkingLevel as SubagentEffort | undefined; - } - - private activity(agent: ManagedSubagent, text: string): void { - agent.updatedAt = Date.now(); - agent.activity.push({ timestamp: agent.updatedAt, text }); - if (agent.activity.length > MAX_ACTIVITY_ITEMS) { - const removed = agent.activity.length - MAX_ACTIVITY_ITEMS; - agent.activity.splice(0, removed); - agent.lastReadActivity = Math.max(0, agent.lastReadActivity - removed); - } - for (const waiter of agent.waiters) waiter(); - agent.waiters.clear(); - this.publishFooter(); - } - - private addTranscript(agent: ManagedSubagent, message: unknown): void { - const transcript = messageToTranscript(message); - if (!transcript) return; - agent.transcript.push(transcript); - if (agent.transcript.length > MAX_TRANSCRIPT_ITEMS) agent.transcript.splice(0, agent.transcript.length - MAX_TRANSCRIPT_ITEMS); - let retainedCharacters = agent.transcript.reduce((total, item) => total + item.text.length, 0); - while (retainedCharacters > MAX_TRANSCRIPT_CHARS && agent.transcript.length > 1) { - retainedCharacters -= agent.transcript.shift()?.text.length ?? 0; - } - } - - private accountUsage(agent: ManagedSubagent, usage: Usage | undefined): void { - if (!usage) return; - addUsage(agent.usage, usage); - addUsage(this.totalUsage, usage); - this.usageDirty = true; - this.publishFooter(); - } - - private subscribe(agent: ManagedSubagent, session: AgentSession): void { - agent.unsubscribe = session.subscribe((event: AgentSessionEvent) => { - switch (event.type) { - case "agent_start": - agent.status = "working"; - agent.error = undefined; - agent.lastStopReason = undefined; - this.activity(agent, "started an agent turn"); - break; - case "turn_start": - agent.turns++; - this.activity(agent, `started turn ${agent.turns}`); - break; - case "tool_execution_start": - agent.currentTool = event.toolName; - this.activity(agent, `running ${event.toolName} ${stringifyCompact(event.args)}`); - break; - case "tool_execution_end": - this.activity(agent, `${event.isError ? "failed" : "finished"} ${event.toolName}`); - agent.currentTool = undefined; - break; - case "message_update": { - const update = event.assistantMessageEvent; - if (update.type === "text_delta") { - agent.streamingText = appendBoundedStreamingText(agent.streamingText, update.delta); - } - const now = Date.now(); - if (agent.streamingText && now - agent.lastStreamActivityAt >= 5_000) { - agent.lastStreamActivityAt = now; - this.activity(agent, "writing a response"); - } - break; - } - case "message_end": { - this.addTranscript(agent, event.message); - const role = messageRole(event.message); - if (role === "assistant" || role === "toolResult") this.accountUsage(agent, messageUsage(event.message)); - if (role === "assistant") { - agent.streamingText = ""; - const stopReason = messageStopReason(event.message); - agent.lastStopReason = stopReason; - const error = messageError(event.message); - if (error) agent.error = error; - else if (stopReason !== "error" && stopReason !== "aborted") agent.error = undefined; - this.activity(agent, `assistant response finished${stopReason ? ` (${stopReason})` : ""}`); - } - break; - } - case "queue_update": - agent.queuedSteering = event.steering.length; - agent.queuedFollowUp = event.followUp.length; - this.activity( - agent, - `queue updated: ${agent.queuedSteering} steering, ${agent.queuedFollowUp} follow-up`, - ); - break; - case "agent_end": - if (event.willRetry) this.activity(agent, "waiting to retry"); - break; - case "agent_settled": - if (agent.status !== "terminated" && agent.status !== "terminating") { - const failed = isFailedStopReason(agent.lastStopReason); - agent.status = failed ? "failed" : "completed"; - agent.completedAt = Date.now(); - this.activity( - agent, - failed - ? `failed${agent.error ? `: ${agent.error}` : ` (${agent.lastStopReason})`}` - : "completed and is waiting for more instructions", - ); - } - break; - case "auto_retry_start": - this.activity(agent, `retrying after an error (attempt ${event.attempt}/${event.maxAttempts})`); - break; - case "compaction_start": - this.activity(agent, `compacting context (${event.reason})`); - break; - case "compaction_end": - this.accountUsage(agent, event.result?.usage); - this.activity( - agent, - event.errorMessage - ? `context compaction failed: ${event.errorMessage}` - : event.aborted - ? "context compaction aborted" - : `context compaction finished (${event.reason})`, - ); - break; - } - }); - } - - private attachRun(agent: ManagedSubagent, promise: Promise): void { - agent.runPromise = promise - .then(() => { - if (agent.status !== "terminated" && agent.status !== "terminating" && agent.status !== "failed") { - agent.status = "completed"; - agent.completedAt = Date.now(); - this.activity(agent, "task run settled"); - } - }) - .catch((error: unknown) => { - if (agent.status === "terminated" || agent.status === "terminating") return; - agent.status = "failed"; - agent.error = error instanceof Error ? error.message : String(error); - agent.completedAt = Date.now(); - this.activity(agent, `failed: ${agent.error}`); - }); - } - - async create( - ctx: ExtensionContext, - options: { prompt: string; name?: string; model?: string; effort?: SubagentEffort; cwd?: string }, - signal?: AbortSignal, - ): Promise { - if (this.activeSessionCount() >= MAX_SUBAGENTS) { - throw new Error(`At most ${MAX_SUBAGENTS} live subagent sessions may be retained at once. Terminate one before creating another.`); - } - if (signal?.aborted) throw new Error("Subagent creation was cancelled"); - - const cwd = resolve(ctx.cwd, options.cwd ?? "."); - const id = this.makeId(options.name); - const activeTools = this.pi.getActiveTools(); - const effort = options.effort ?? (ctx.thinkingLevel as SubagentEffort); - const agent: ManagedSubagent = { - id, - prompt: options.prompt, - cwd, - createdAt: Date.now(), - updatedAt: Date.now(), - status: "creating", - model: options.model ?? modelName(ctx.model), - effort, - turns: 0, - queuedSteering: 0, - queuedFollowUp: 0, - activity: [], - lastReadActivity: 0, - transcript: [], - streamingText: "", - lastStreamActivityAt: 0, - usage: zeroUsage(), - waiters: new Set(), - }; - this.agents.set(id, agent); - this.activity(agent, "creating isolated session"); - - try { - const runtime = await this.getModelRuntime(ctx); - const selectedModel = await this.resolveModel(ctx, options.model, runtime); - const selectedEffort = options.effort ?? this.scopedEffort(ctx, selectedModel) ?? effort; - agent.effort = selectedEffort; - const settingsManager = SettingsManager.create(ctx.cwd, getAgentDir()); - const resourceLoader = new DefaultResourceLoader({ - cwd, - agentDir: getAgentDir(), - settingsManager, - noExtensions: true, - appendSystemPrompt: [SUBAGENT_SYSTEM_PROMPT], - }); - await resourceLoader.reload(); - if (signal?.aborted) throw new Error("Subagent creation was cancelled"); - - const { session } = await createAgentSession({ - cwd, - agentDir: getAgentDir(), - modelRuntime: runtime, - model: selectedModel, - thinkingLevel: selectedEffort, - tools: activeTools, - resourceLoader, - settingsManager, - sessionManager: SessionManager.inMemory(cwd), - }); - if (!session.model) { - session.dispose(); - throw new Error("No authenticated model is available for the subagent"); - } - if (signal?.aborted) { - session.dispose(); - throw new Error("Subagent creation was cancelled"); - } - - agent.session = session; - agent.model = modelName(session.model); - agent.effort = session.thinkingLevel as SubagentEffort; - agent.status = "working"; - this.subscribe(agent, session); - this.activity(agent, `started with ${agent.model} at ${agent.effort} effort`); - this.attachRun(agent, session.prompt(options.prompt, { source: "extension" })); - // Creation already reports these startup events, so the first read waits for new activity. - agent.lastReadActivity = agent.activity.length; - return agent; - } catch (error) { - agent.status = signal?.aborted ? "terminated" : "failed"; - agent.error = error instanceof Error ? error.message : String(error); - agent.completedAt = Date.now(); - this.activity(agent, `${agent.status}: ${agent.error}`); - throw error; - } - } - - async send(id: string, message: string, urgency: MessageUrgency): Promise { - const agent = this.getAgent(id); - const session = agent.session; - if (!session) throw new Error(`Subagent ${id} no longer has a live session`); - if (agent.status === "terminating" || agent.status === "terminated") { - throw new Error(`Subagent ${id} is ${agent.status}`); - } - - if (session.isStreaming) { - if (urgency === "urgent") await session.steer(message); - else await session.followUp(message); - this.activity(agent, `${urgency === "urgent" ? "steered with" : "queued"} instruction: ${truncateChars(message, 160)}`); - return; - } - - agent.status = "working"; - this.activity(agent, `started follow-on instruction: ${truncateChars(message, 160)}`); - this.attachRun(agent, session.prompt(message, { source: "extension" })); - } - - async configure( - ctx: ExtensionContext, - id: string, - options: { model?: string; effort?: SubagentEffort }, - ): Promise { - const agent = this.getAgent(id); - const session = agent.session; - if (!session) throw new Error(`Subagent ${id} no longer has a live session`); - if (!options.model && !options.effort) throw new Error("Specify a model, effort, or both"); - - if (options.model) { - const model = await this.resolveModel(ctx, options.model); - if (!model) throw new Error("No model was selected"); - await session.setModel(model); - agent.model = modelName(session.model); - const pinnedEffort = this.scopedEffort(ctx, model); - if (!options.effort && pinnedEffort) session.setThinkingLevel(pinnedEffort); - agent.effort = session.thinkingLevel as SubagentEffort; - this.activity(agent, `model changed to ${agent.model} at ${agent.effort} effort`); - } - if (options.effort) { - session.setThinkingLevel(options.effort); - agent.effort = session.thinkingLevel as SubagentEffort; - this.activity(agent, `effort changed to ${agent.effort}`); - } - return agent; - } - - async abortAll(): Promise { - if (this.abortAllInFlight) return await this.abortAllInFlight; - const operation = (async () => { - const results = await abortRunningSubagentSessions(this.list()); - for (const { agent, error } of results) { - if (error) { - agent.error = error.message; - this.activity(agent, `abort failed: ${agent.error}`); - } else { - this.activity(agent, "aborted with the main agent"); - } - } - return results.length; - })(); - this.abortAllInFlight = operation; - try { - return await operation; - } finally { - if (this.abortAllInFlight === operation) this.abortAllInFlight = undefined; - } - } - - async terminate(id: string, remove = false): Promise { - const agent = this.getAgent(id); - if (agent.status !== "terminated") { - agent.status = "terminating"; - this.activity(agent, "termination requested"); - const session = agent.session; - if (session) { - try { - await session.abort(); - } catch (error) { - agent.error = error instanceof Error ? error.message : String(error); - } finally { - agent.unsubscribe?.(); - agent.unsubscribe = undefined; - try { - session.dispose(); - } catch (error) { - agent.error = error instanceof Error ? error.message : String(error); - } - agent.session = undefined; - } - } - agent.status = "terminated"; - agent.completedAt = Date.now(); - this.activity(agent, "terminated and released session resources"); - } - this.agents.delete(id); - this.webTranscriptCursors.delete(id); - this.webStreamingSnapshots.delete(id); - if (remove) this.archivedAgents.delete(id); - else if (shouldArchiveTerminalSubagent(agent)) this.archivedAgents.set(id, agent); - this.footerSelected = false; - this.publishFooter(); - if (this.webStatusPublishTimer) clearTimeout(this.webStatusPublishTimer); - this.publishWebStatus(); - return agent; - } - - async terminateAll(remove = false): Promise { - await Promise.all(this.list().map(async (agent) => this.terminate(agent.id, remove))); - if (remove) this.archivedAgents.clear(); - } - - async clearTerminalAgents(): Promise { - const terminalAgents = this.list().filter((agent) => isTerminalSubagentStatus(agent.status)); - if (terminalAgents.length === 0) return 0; - - await Promise.all(terminalAgents.map(async (agent) => { - const preserveUnreadOutput = shouldArchiveTerminalSubagent(agent); - await this.terminate(agent.id, true); - if (preserveUnreadOutput) this.archivedAgents.set(agent.id, agent); - })); - if (this.webStatusPublishTimer) clearTimeout(this.webStatusPublishTimer); - this.publishWebStatus(); - return terminalAgents.length; - } - - private hasUnread(agent: ManagedSubagent): boolean { - return agent.lastReadActivity < agent.activity.length; - } - - async waitForUpdates(agents: ManagedSubagent[], seconds: number, signal?: AbortSignal): Promise { - if (seconds <= 0 || agents.some((agent) => this.hasUnread(agent))) return; - const running = agents.filter((agent) => agent.status === "creating" || agent.status === "working"); - if (running.length === 0) return; - - await new Promise((done) => { - let finished = false; - const finish = () => { - if (finished) return; - finished = true; - clearTimeout(timer); - for (const agent of running) agent.waiters.delete(finish); - signal?.removeEventListener("abort", finish); - done(); - }; - const timer = setTimeout(finish, Math.min(30, seconds) * 1_000); - for (const agent of running) agent.waiters.add(finish); - signal?.addEventListener("abort", finish, { once: true }); - }); - } - - read(agents: ManagedSubagent[], includeTranscript: boolean): string { - if (agents.length === 0) return "No subagents are involved in this session."; - const now = Date.now(); - const sections: string[] = []; - for (const agent of agents) { - const heading = `## ${statusIcon(agent.status)} ${agent.id} — ${agent.status}`; - const metadata = [ - `Model: ${agent.model}`, - `Effort: ${agent.effort}`, - `Elapsed: ${formatDuration((agent.completedAt ?? now) - agent.createdAt)}`, - `Turns: ${agent.turns}`, - `Usage: ↑${formatTokens(agent.usage.input)} ↓${formatTokens(agent.usage.output)}${agent.usage.cost.total ? ` $${agent.usage.cost.total.toFixed(4)}` : ""}`, - ]; - if (agent.currentTool) metadata.push(`Current tool: ${agent.currentTool}`); - if (agent.queuedSteering || agent.queuedFollowUp) { - metadata.push(`Queued: ${agent.queuedSteering} steering, ${agent.queuedFollowUp} follow-up`); - } - if (agent.error) metadata.push(`Error: ${agent.error}`); - - const unread = agent.activity.slice(agent.lastReadActivity); - const activity = unread.length - ? unread.map((item) => `- ${formatClock(item.timestamp)} ${item.text}`).join("\n") - : "- No new activity."; - agent.lastReadActivity = agent.activity.length; - - let output = `${heading}\n${metadata.join("\n")}\n\nActivity since last read:\n${activity}`; - if (includeTranscript) { - const transcript = agent.transcript - .map((item) => `### ${formatClock(item.timestamp)} ${item.role}\n${item.text}`) - .join("\n\n"); - output += `\n\nTranscript:\n${transcript || agent.streamingText || "(empty)"}`; - } else { - const latest = finalAssistantText(agent); - if (latest) output += `\n\nLatest assistant output:\n${latest}`; - } - sections.push(output); - if (this.archivedAgents.get(agent.id) === agent) this.archivedAgents.delete(agent.id); - } - return truncateToolOutput(sections.join("\n\n---\n\n")); - } - - claimUnaccountedUsage(): Usage | undefined { - const usage = subtractUsage(this.totalUsage, this.accountedUsage); - if (!hasUsage(usage)) return undefined; - this.accountedUsage = cloneUsage(this.totalUsage); - this.usageDirty = true; - this.publishFooter(); - return usage; - } - - private footerText(): string | undefined { - if (this.agents.size === 0) return undefined; - let working = 0; - let completed = 0; - let failed = 0; - let terminated = 0; - for (const agent of this.agents.values()) { - if (agent.status === "creating" || agent.status === "working" || agent.status === "terminating") working++; - else if (agent.status === "completed") completed++; - else if (agent.status === "failed") failed++; - else terminated++; - } - const parts = [`◆ ${this.agents.size} subagent${this.agents.size === 1 ? "" : "s"}`]; - if (working) parts.push(`${working} working`); - if (completed) parts.push(`${completed} done`); - if (failed) parts.push(`${failed} failed`); - if (terminated) parts.push(`${terminated} stopped`); - return parts.join(" • "); - } - - private publishWebStatus(): void { - this.webStatusPublishTimer = undefined; - const ctx = this.currentContext; - if (!ctx) return; - const sessionId = ctx.sessionManager.getSessionId(); - const usage = asFooterUsage(subtractUsage(this.totalUsage, this.accountedUsage)); - this.lastWebStatusPublishedAt = Date.now(); - this.pi.events.emit(SUBAGENT_STATUS_EVENT, { - sessionId, - agents: this.webStatusUpdates(), - usage, - } satisfies SubagentStatusEvent); - } - - private publishFooter(): void { - const ctx = this.currentContext; - if (!ctx) return; - const sessionId = ctx.sessionManager.getSessionId(); - const usage = asFooterUsage(subtractUsage(this.totalUsage, this.accountedUsage)); - const statusText = this.footerText(); - const contribution: FooterContribution = { - sessionId, - key: "subagents", - status: statusText ? { text: statusText, selected: this.footerSelected } : undefined, - usage, - }; - this.pi.events.emit(FOOTER_CONTRIBUTION_EVENT, contribution); - - // Footer metadata stays immediate, while coalesced web events carry only - // transcript/streaming deltas. The server retains a bounded full snapshot - // for newly subscribed clients without retransmitting it on every burst. - const delay = WEB_STATUS_PUBLISH_INTERVAL_MS - (Date.now() - this.lastWebStatusPublishedAt); - if (delay <= 0) { - if (this.webStatusPublishTimer) clearTimeout(this.webStatusPublishTimer); - this.publishWebStatus(); - } else if (!this.webStatusPublishTimer) { - this.webStatusPublishTimer = setTimeout(() => this.publishWebStatus(), delay); - this.webStatusPublishTimer.unref?.(); - } - } -} - -interface AppEditorComponent extends EditorComponent, Partial { - getCursor?: () => { line: number; col: number }; - getLines?: () => string[]; - isShowingAutocomplete?: () => boolean; - dispose?: () => void; - actionHandlers?: Map void>; - onEscape?: () => void; - onCtrlD?: () => void; - onPasteImage?: () => void; - onExtensionShortcut?: (data: string) => boolean; -} - -class FooterNavigationEditor implements EditorComponent, Focusable { - readonly actionHandlers?: Map void>; - - constructor( - private readonly base: AppEditorComponent, - private readonly keybindings: KeybindingsManager, - private readonly manager: SubagentManager, - private readonly openManager: () => void, - ) { - this.actionHandlers = base.actionHandlers; - } - - get focused(): boolean { - return this.base.focused ?? false; - } - set focused(value: boolean) { - if ("focused" in this.base) this.base.focused = value; - } - get onSubmit(): ((text: string) => void) | undefined { - return this.base.onSubmit; - } - set onSubmit(value: ((text: string) => void) | undefined) { - this.base.onSubmit = value; - } - get onChange(): ((text: string) => void) | undefined { - return this.base.onChange; - } - set onChange(value: ((text: string) => void) | undefined) { - this.base.onChange = value; - } - get borderColor(): ((text: string) => string) | undefined { - return this.base.borderColor; - } - set borderColor(value: ((text: string) => string) | undefined) { - this.base.borderColor = value; - } - get onEscape(): (() => void) | undefined { - return this.base.onEscape; - } - set onEscape(value: (() => void) | undefined) { - this.base.onEscape = value; - } - get onCtrlD(): (() => void) | undefined { - return this.base.onCtrlD; - } - set onCtrlD(value: (() => void) | undefined) { - this.base.onCtrlD = value; - } - get onPasteImage(): (() => void) | undefined { - return this.base.onPasteImage; - } - set onPasteImage(value: (() => void) | undefined) { - this.base.onPasteImage = value; - } - get onExtensionShortcut(): ((data: string) => boolean) | undefined { - return this.base.onExtensionShortcut; - } - set onExtensionShortcut(value: ((data: string) => boolean) | undefined) { - this.base.onExtensionShortcut = value; - } - - render(width: number): string[] { - return this.base.render(width); - } - invalidate(): void { - this.base.invalidate(); - } - dispose(): void { - this.base.dispose?.(); - } - getText(): string { - return this.base.getText(); - } - setText(text: string): void { - this.base.setText(text); - } - addToHistory(text: string): void { - this.base.addToHistory?.(text); - } - insertTextAtCursor(text: string): void { - this.base.insertTextAtCursor?.(text); - } - getExpandedText(): string { - return this.base.getExpandedText?.() ?? this.base.getText(); - } - setAutocompleteProvider(provider: Parameters>[0]): void { - this.base.setAutocompleteProvider?.(provider); - } - setPaddingX(padding: number): void { - this.base.setPaddingX?.(padding); - } - setAutocompleteMaxVisible(maximum: number): void { - this.base.setAutocompleteMaxVisible?.(maximum); - } - - handleInput(data: string): void { - if (this.manager.isFooterSelected()) { - if (this.keybindings.matches(data, "tui.select.confirm")) { - this.manager.setFooterSelected(false); - this.openManager(); - return; - } - if ( - this.keybindings.matches(data, "tui.select.up") || - this.keybindings.matches(data, "tui.select.cancel") - ) { - this.manager.setFooterSelected(false); - return; - } - this.manager.setFooterSelected(false); - } - - if ( - this.manager.hasAgents() && - this.base.getText().length === 0 && - !this.base.isShowingAutocomplete?.() && - matchesKey(data, "alt+down") - ) { - const cursor = this.base.getCursor?.(); - const lines = this.base.getLines?.(); - if (!cursor || !lines || cursor.line === lines.length - 1) { - this.manager.setFooterSelected(true); - return; - } - } - this.base.handleInput(data); - } -} - -function padAnsi(text: string, width: number): string { - const fitted = truncateToWidth(text, Math.max(0, width), "…"); - return fitted + " ".repeat(Math.max(0, width - visibleWidth(fitted))); -} - -function frameLines(theme: Theme, title: string, body: string[], width: number): string[] { - if (width < 4) return body.map((line) => truncateToWidth(line, width, "")); - const inner = width - 2; - const titleText = truncateToWidth(` ${title} `, Math.max(0, inner - 2), "…"); - const topFill = Math.max(0, inner - visibleWidth(titleText)); - const top = theme.fg("borderAccent", `┌${titleText}${"─".repeat(topFill)}┐`); - const bottom = theme.fg("borderAccent", `└${"─".repeat(inner)}┘`); - return [ - top, - ...body.map( - (line) => theme.fg("borderAccent", "│") + padAnsi(line, inner) + theme.fg("borderAccent", "│"), - ), - bottom, - ]; -} - -class AgentListDialog implements Component { - private selected = 0; - private timer: ReturnType; - - constructor( - private readonly manager: SubagentManager, - private readonly tui: TUI, - private readonly theme: Theme, - private readonly keybindings: KeybindingsManager, - private readonly done: (result: ManagerDialogResult) => void, - ) { - this.timer = setInterval(() => tui.requestRender(), 500); - } - - dispose(): void { - clearInterval(this.timer); - } - invalidate(): void {} - - handleInput(data: string): void { - const agents = this.manager.list(); - if (this.keybindings.matches(data, "tui.select.cancel")) return this.done({ action: "close" }); - if (this.keybindings.matches(data, "tui.select.up")) { - this.selected = Math.max(0, this.selected - 1); - this.tui.requestRender(); - return; - } - if (this.keybindings.matches(data, "tui.select.down")) { - this.selected = Math.min(Math.max(0, agents.length - 1), this.selected + 1); - this.tui.requestRender(); - return; - } - const agent = agents[this.selected]; - if (!agent) return; - if (this.keybindings.matches(data, "tui.select.confirm")) return this.done({ action: "view", id: agent.id }); - if (data === "m") return this.done({ action: "model", id: agent.id }); - if (data === "e") return this.done({ action: "effort", id: agent.id }); - if (data === "x") return this.done({ action: "terminate", id: agent.id }); - } - - render(width: number): string[] { - const agents = this.manager.list(); - this.selected = Math.min(this.selected, Math.max(0, agents.length - 1)); - const body: string[] = []; - if (agents.length === 0) body.push(this.theme.fg("muted", " No subagents in this session")); - for (let index = 0; index < agents.length; index++) { - const agent = agents[index]; - const icon = this.theme.fg(statusColor(agent.status), statusIcon(agent.status)); - const queue = agent.queuedSteering + agent.queuedFollowUp; - let line = `${index === this.selected ? "›" : " "} ${icon} ${agent.id} ${agent.status}`; - if (agent.currentTool) line += ` · ${agent.currentTool}`; - if (queue) line += ` · ${queue} queued`; - line += ` · ${agent.model} · ${agent.effort}`; - if (index === this.selected) line = this.theme.bg("selectedBg", this.theme.fg("accent", line)); - body.push(line); - } - body.push(""); - body.push(this.theme.fg("dim", " ↑↓ select · enter transcript · m model · e effort · x terminate · esc close")); - return frameLines(this.theme, "Subagents", body, width); - } -} - -class AgentDetailDialog implements Component { - private scrollOffset = 0; - private timer: ReturnType; - - constructor( - private readonly agent: ManagedSubagent, - private readonly tui: TUI, - private readonly theme: Theme, - private readonly keybindings: KeybindingsManager, - private readonly done: (result: ManagerDialogResult) => void, - ) { - this.timer = setInterval(() => tui.requestRender(), 300); - } - - dispose(): void { - clearInterval(this.timer); - } - invalidate(): void {} - - handleInput(data: string): void { - if (this.keybindings.matches(data, "tui.select.cancel") || data === "b") return this.done({ action: "back" }); - if (this.keybindings.matches(data, "tui.select.up") || this.keybindings.matches(data, "tui.select.pageUp")) { - this.scrollOffset += this.keybindings.matches(data, "tui.select.pageUp") ? DETAIL_VIEW_LINES : 1; - this.tui.requestRender(); - return; - } - if (this.keybindings.matches(data, "tui.select.down") || this.keybindings.matches(data, "tui.select.pageDown")) { - this.scrollOffset = Math.max( - 0, - this.scrollOffset - (this.keybindings.matches(data, "tui.select.pageDown") ? DETAIL_VIEW_LINES : 1), - ); - this.tui.requestRender(); - return; - } - if (data === "m") return this.done({ action: "model", id: this.agent.id }); - if (data === "e") return this.done({ action: "effort", id: this.agent.id }); - if (data === "u") return this.done({ action: "urgent", id: this.agent.id }); - if (data === "q") return this.done({ action: "queue", id: this.agent.id }); - if (data === "x") return this.done({ action: "terminate", id: this.agent.id }); - } - - private transcriptLines(width: number): string[] { - const lines: string[] = []; - for (const item of this.agent.transcript) { - lines.push(this.theme.fg("muted", `[${formatClock(item.timestamp)}] ${item.role}`)); - const roleColor = item.role === "assistant" ? "text" : item.role === "toolResult" ? "dim" : "accent"; - for (const line of wrapTextWithAnsi(this.theme.fg(roleColor, item.text), Math.max(1, width))) lines.push(line); - lines.push(""); - } - if (this.agent.streamingText) { - lines.push(this.theme.fg("warning", "[streaming] assistant")); - for (const line of wrapTextWithAnsi(this.agent.streamingText, Math.max(1, width))) lines.push(line); - } - if (lines.length === 0) lines.push(this.theme.fg("muted", "(transcript is empty)")); - return lines; - } - - render(width: number): string[] { - const inner = Math.max(1, width - 4); - const allLines = this.transcriptLines(inner); - const maxOffset = Math.max(0, allLines.length - 1); - this.scrollOffset = Math.min(this.scrollOffset, maxOffset); - const end = Math.max(0, allLines.length - this.scrollOffset); - const start = Math.max(0, end - DETAIL_VIEW_LINES); - const visible = allLines.slice(start, end); - const status = this.theme.fg(statusColor(this.agent.status), `${statusIcon(this.agent.status)} ${this.agent.status}`); - const body = [ - ` ${status} · ${this.agent.model} · effort ${this.agent.effort} · ${formatDuration(Date.now() - this.agent.createdAt)}`, - this.theme.fg("dim", ` Task: ${truncateChars(this.agent.prompt.replace(/\s+/g, " "), 180)}`), - this.theme.fg( - "dim", - ` Usage: ↑${formatTokens(this.agent.usage.input)} ↓${formatTokens(this.agent.usage.output)}${this.agent.usage.cost.total ? ` $${this.agent.usage.cost.total.toFixed(4)}` : ""}`, - ), - this.theme.fg("borderMuted", " " + "─".repeat(Math.max(0, inner - 1))), - ...visible.map((line) => ` ${line}`), - this.theme.fg("borderMuted", " " + "─".repeat(Math.max(0, inner - 1))), - this.theme.fg( - "dim", - ` ↑↓/pg scroll${this.scrollOffset ? ` · ${this.scrollOffset} lines below` : ""} · m model · e effort · u steer · q queue · x terminate · b back`, - ), - ]; - return frameLines(this.theme, this.agent.id, body, width); - } -} - -async function selectOverlay( - ctx: ExtensionContext, - title: string, - items: SelectItem[], -): Promise { - if (ctx.mode !== "tui") return undefined; - return ctx.ui.custom( - (tui, theme, _keybindings, done) => { - const container = new Container(); - container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text))); - container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0)); - const list = new SelectList(items, Math.min(12, Math.max(1, items.length)), { - selectedPrefix: (text) => theme.fg("accent", text), - selectedText: (text) => theme.fg("accent", text), - description: (text) => theme.fg("muted", text), - scrollInfo: (text) => theme.fg("dim", text), - noMatch: (text) => theme.fg("warning", text), - }); - list.onSelect = (item) => done(item.value); - list.onCancel = () => done(undefined); - container.addChild(list); - container.addChild(new Text(theme.fg("dim", "↑↓ navigate · enter select · esc cancel"), 1, 0)); - container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text))); - return { - render: (width) => container.render(width), - invalidate: () => container.invalidate(), - handleInput: (data) => { - list.handleInput(data); - tui.requestRender(); - }, - }; - }, - { overlay: true, overlayOptions: { anchor: "center", width: "70%", maxHeight: "80%", minWidth: 48 } }, - ); -} - -async function showManager(manager: SubagentManager, ctx: ExtensionContext): Promise { - if (ctx.mode !== "tui") { - ctx.ui.notify("The subagent manager is only available in TUI mode", "warning"); - return; - } - - let detailId: string | undefined; - while (true) { - let result: ManagerDialogResult; - if (detailId && manager.agents.has(detailId)) { - const agent = manager.getAgent(detailId); - result = await ctx.ui.custom( - (tui, theme, keybindings, done) => new AgentDetailDialog(agent, tui, theme, keybindings, done), - { overlay: true, overlayOptions: { anchor: "center", width: "85%", maxHeight: "90%", minWidth: 56 } }, - ); - } else { - detailId = undefined; - result = await ctx.ui.custom( - (tui, theme, keybindings, done) => new AgentListDialog(manager, tui, theme, keybindings, done), - { overlay: true, overlayOptions: { anchor: "center", width: "85%", maxHeight: "85%", minWidth: 56 } }, - ); - } - - if (!result || result.action === "close") return; - if (result.action === "back") { - detailId = undefined; - continue; - } - if (result.action === "view") { - detailId = result.id; - continue; - } - - detailId = result.id; - try { - if (result.action === "model") { - const models = await manager.availableModels(ctx); - const selected = await selectOverlay( - ctx, - `Model for ${result.id}`, - models.map((model) => ({ value: modelName(model), label: model.id, description: `${model.provider} · ${model.name}` })), - ); - if (selected) await manager.configure(ctx, result.id, { model: selected }); - } else if (result.action === "effort") { - const selected = await selectOverlay( - ctx, - `Effort for ${result.id}`, - THINKING_LEVELS.map((level) => ({ value: level, label: level })), - ); - if (selected) await manager.configure(ctx, result.id, { effort: selected as SubagentEffort }); - } else if (result.action === "urgent" || result.action === "queue") { - const message = await ctx.ui.input( - result.action === "urgent" ? `Steer ${result.id}` : `Queue for ${result.id}`, - "Instruction for the subagent", - ); - if (message) await manager.send(result.id, message, result.action === "urgent" ? "urgent" : "normal"); - } else if (result.action === "terminate") { - const confirmed = await ctx.ui.confirm("Terminate subagent?", `Stop ${result.id} and release its resources?`); - if (confirmed) await manager.terminate(result.id); - } - } catch (error) { - ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); - } - } -} - -function toolResult(manager: SubagentManager, text: string): { content: [{ type: "text"; text: string }]; details: ToolDetails; usage?: Usage } { - const usage = manager.claimUnaccountedUsage(); - return { - content: [{ type: "text", text }], - details: { agents: manager.webSnapshots() }, - ...(usage ? { usage } : {}), - }; -} - -function stringEnum( - values: T, - options?: { description?: string; default?: T[number] }, -) { - return Type.Unsafe({ - type: "string", - enum: values, - ...(options?.description ? { description: options.description } : {}), - ...(options?.default ? { default: options.default } : {}), - }); -} - -const EffortSchema = stringEnum(THINKING_LEVELS, { - description: "Reasoning effort. The selected model may clamp unsupported levels.", -}); - -const CreateParams = Type.Object({ - prompt: Type.String({ description: "Complete task prompt for the new isolated subagent" }), - name: Type.Optional(Type.String({ description: "Short stable name used to address the subagent" })), - model: Type.Optional(Type.String({ description: "Exact provider/model-id or exact unambiguous model id. Omit to inherit the current model; never use a shortened family alias." })), - effort: Type.Optional(EffortSchema), - cwd: Type.Optional(Type.String({ description: "Working directory, relative to the main session unless absolute" })), -}); - -const ReadParams = Type.Object({ - id: Type.Optional(Type.String({ description: "Subagent id. Omit to read all subagents." })), - wait_seconds: Type.Optional( - Type.Integer({ - description: `Wait for meaningful new activity before returning. Default ${DEFAULT_READ_WAIT_SECONDS}, maximum 30.`, - minimum: 0, - maximum: 30, - }), - ), - include_transcript: Type.Optional(Type.Boolean({ description: "Include the full retained transcript instead of only latest output" })), -}); - -const SendParams = Type.Object({ - id: Type.String({ description: "Subagent id" }), - message: Type.String({ description: "Instruction to send" }), - urgency: stringEnum(["normal", "urgent"] as const, { - description: "urgent steers after the current tool batch; normal queues until the current run finishes", - }), -}); - -const ConfigureParams = Type.Object({ - id: Type.String({ description: "Subagent id" }), - model: Type.Optional(Type.String({ description: "Exact new provider/model-id or exact unambiguous model id. Omit to retain the current model; never use a shortened family alias." })), - effort: Type.Optional(EffortSchema), -}); - -const TerminateParams = Type.Object({ - id: Type.Optional(Type.String({ description: "Subagent id. Omit with all=true to terminate every subagent." })), - all: Type.Optional(Type.Boolean({ description: "Terminate every subagent" })), - remove: Type.Optional(Type.Boolean({ description: "Also remove terminated records from the footer and manager" })), -}); +import { SubagentManager } from "./subagents/manager.js"; +import { + filterModelsToScope, + subagentModelGuidance, +} from "./subagents/models.js"; +import { registerSubagentTools } from "./subagents/tools.js"; +import { + FooterNavigationEditor, + showManager, + type AppEditorComponent, +} from "./subagents/ui.js"; + +export { + abortRunningSubagentSessions, + countsAgainstSubagentLimit, + isFailedStopReason, + isTerminalSubagentStatus, + shouldArchiveTerminalSubagent, +} from "./subagents/lifecycle.js"; +export { + filterModelsToScope, + inheritedSubagentModel, + subagentModelGuidance, + subagentModelRuntime, +} from "./subagents/models.js"; +export { appendBoundedStreamingText } from "./subagents/transcript.js"; +export { MAX_WEB_STREAMING_CHARS, THINKING_LEVELS } from "./subagents/types.js"; +export type { + MessageUrgency, + SubagentEffort, + SubagentStatus, +} from "./subagents/types.js"; +export { parsePersistedUsageState } from "./subagents/usage.js"; export default function subagentsExtension(pi: ExtensionAPI): void { const manager = new SubagentManager(pi); @@ -1742,131 +75,7 @@ export default function subagentsExtension(pi: ExtensionAPI): void { return { systemPrompt: `${event.systemPrompt}\n\n${subagentModelGuidance(ctx.model, available)}` }; }); - pi.registerTool({ - name: "subagent_create", - label: "Create subagent", - description: `Create a background subagent with an isolated context, model, and reasoning effort. Returns immediately after startup. Up to ${MAX_SUBAGENTS} live subagent sessions are allowed.`, - promptSnippet: "Create a background subagent with a chosen prompt, model, and effort", - promptGuidelines: [ - "When calling subagent_create, omit model to inherit the current model unless deliberately choosing one of the exact session-available provider/model IDs listed in the system prompt; never shorten or invent a model ID.", - "After subagent_create returns, use subagent_read with its default wait roughly every 15–30 seconds while work continues; briefly tell the user about meaningful progress between polls without narrating every event.", - "Wait for subagent_create to return before calling another subagent management tool for that id.", - "Use subagent_send with urgent only when the current approach must change immediately; use normal for work that can wait until the current run finishes.", - "Use subagent_terminate when delegated work is no longer needed, and clean up retained subagents before finishing when appropriate.", - ], - parameters: CreateParams, - async execute(_toolCallId, params, signal, _onUpdate, ctx) { - const agent = await manager.create(ctx, params, signal); - return toolResult( - manager, - `Created ${agent.id} with ${agent.model} at ${agent.effort} effort. It is running in ${agent.cwd}. Use subagent_read to wait for and inspect progress.`, - ); - }, - renderCall(args, theme) { - const name = args.name ? ` ${theme.fg("accent", args.name)}` : ""; - const model = args.model ? ` · ${args.model}` : ""; - const effort = args.effort ? ` · ${args.effort}` : ""; - return new Text(`${theme.fg("toolTitle", theme.bold("subagent_create"))}${name}${theme.fg("muted", model + effort)}\n${theme.fg("dim", truncateChars(args.prompt, 180))}`, 0, 0); - }, - renderResult(result, _options, theme) { - const text = result.content[0]; - return new Text(theme.fg("toolOutput", text?.type === "text" ? text.text : "Created subagent"), 0, 0); - }, - }); - - pi.registerTool({ - name: "subagent_read", - label: "Read subagents", - description: "Wait for and read meaningful subagent activity, status, output, usage, or full transcripts. Omit id to monitor all subagents.", - promptSnippet: "Read and monitor background subagent activity and output", - parameters: ReadParams, - async execute(_toolCallId, params, signal) { - const agents = params.id ? [manager.getAgent(params.id)] : manager.list(); - await manager.waitForUpdates(agents, params.wait_seconds ?? DEFAULT_READ_WAIT_SECONDS, signal); - if (signal?.aborted) throw new Error("Subagent read was cancelled"); - return toolResult(manager, manager.read(agents, params.include_transcript ?? false)); - }, - renderCall(args, theme) { - return new Text( - theme.fg("toolTitle", theme.bold("subagent_read")) + - theme.fg("muted", ` ${args.id ?? "all"} · wait ${args.wait_seconds ?? DEFAULT_READ_WAIT_SECONDS}s`), - 0, - 0, - ); - }, - renderResult(result, { expanded }, theme) { - const raw = result.content[0]; - const text = raw?.type === "text" ? raw.text : "(no output)"; - return new Text(theme.fg("toolOutput", expanded ? text : text.split("\n").slice(0, 14).join("\n")), 0, 0); - }, - }); - - pi.registerTool({ - name: "subagent_send", - label: "Message subagent", - description: "Send an urgent steering message to a running subagent or queue a normal follow-up message for it.", - promptSnippet: "Steer a subagent urgently or queue a normal follow-up instruction", - parameters: SendParams, - async execute(_toolCallId, params) { - await manager.send(params.id, params.message, params.urgency); - return toolResult( - manager, - params.urgency === "urgent" - ? `Steering message sent to ${params.id}.` - : `Follow-up message queued for ${params.id}.`, - ); - }, - renderCall(args, theme) { - return new Text( - `${theme.fg("toolTitle", theme.bold("subagent_send"))} ${theme.fg("accent", args.id)} ${theme.fg(args.urgency === "urgent" ? "warning" : "muted", args.urgency)}\n${theme.fg("dim", truncateChars(args.message, 180))}`, - 0, - 0, - ); - }, - }); - - pi.registerTool({ - name: "subagent_configure", - label: "Configure subagent", - description: "Change a retained subagent's model and/or reasoning effort. Changes apply to its next model request.", - promptSnippet: "Change a subagent model or reasoning effort", - parameters: ConfigureParams, - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - const agent = await manager.configure(ctx, params.id, params); - return toolResult(manager, `${agent.id} now uses ${agent.model} at ${agent.effort} effort.`); - }, - renderCall(args, theme) { - return new Text( - `${theme.fg("toolTitle", theme.bold("subagent_configure"))} ${theme.fg("accent", args.id)}${theme.fg("muted", `${args.model ? ` · ${args.model}` : ""}${args.effort ? ` · ${args.effort}` : ""}`)}`, - 0, - 0, - ); - }, - }); - - pi.registerTool({ - name: "subagent_terminate", - label: "Terminate subagent", - description: "Abort one or all subagents, dispose their sessions, and optionally remove their retained transcript records.", - promptSnippet: "Terminate subagents and release their resources", - parameters: TerminateParams, - async execute(_toolCallId, params) { - if (params.all) { - await manager.terminateAll(params.remove ?? false); - return toolResult(manager, "Terminated all subagents and released their session resources."); - } - if (!params.id) throw new Error("Specify id or all=true"); - await manager.terminate(params.id, params.remove ?? false); - return toolResult(manager, `Terminated ${params.id} and released its session resources.`); - }, - renderCall(args, theme) { - return new Text( - `${theme.fg("toolTitle", theme.bold("subagent_terminate"))} ${theme.fg("warning", args.all ? "all" : (args.id ?? "?"))}`, - 0, - 0, - ); - }, - }); + registerSubagentTools(pi, manager); pi.registerCommand("subagents", { description: "Open the subagent manager", diff --git a/extensions/subagents/format.ts b/extensions/subagents/format.ts new file mode 100644 index 0000000..1e8687e --- /dev/null +++ b/extensions/subagents/format.ts @@ -0,0 +1,99 @@ +import { MAX_TOOL_OUTPUT_BYTES, type SubagentStatus } from "./types.js"; + +export function formatTokens(count: number): string { + if (count < 1_000) return `${count}`; + if (count < 10_000) return `${(count / 1_000).toFixed(1)}k`; + if (count < 1_000_000) return `${Math.round(count / 1_000)}k`; + return `${(count / 1_000_000).toFixed(1)}M`; +} + +export function formatDuration(milliseconds: number): string { + const seconds = Math.max(0, Math.floor(milliseconds / 1_000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ${seconds % 60}s`; + return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; +} + +export function formatClock(timestamp: number): string { + return new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +export function sanitizeName(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40); +} + +export function stringifyCompact(value: unknown, max = 200): string { + let text: string; + try { + text = JSON.stringify(value) ?? String(value); + } catch { + text = String(value); + } + const characters = Array.from(text); + return characters.length > max ? `${characters.slice(0, max).join("")}…` : text; +} + +export function truncateChars(text: string, maximum: number): string { + const characters = Array.from(text); + return characters.length <= maximum + ? text + : `${characters.slice(0, maximum).join("")}\n[… ${characters.length - maximum} characters omitted]`; +} + +export function statusIcon(status: SubagentStatus): string { + switch (status) { + case "creating": + case "working": + case "terminating": + return "◐"; + case "completed": + return "✓"; + case "failed": + return "✗"; + case "terminated": + return "■"; + } +} + +export function statusColor(status: SubagentStatus): "warning" | "success" | "error" | "muted" { + switch (status) { + case "creating": + case "working": + case "terminating": + return "warning"; + case "completed": + return "success"; + case "failed": + return "error"; + case "terminated": + return "muted"; + } +} + +export function truncateToolOutput(text: string): string { + const buffer = Buffer.from(text, "utf8"); + if (buffer.length <= MAX_TOOL_OUTPUT_BYTES) return text; + let end = MAX_TOOL_OUTPUT_BYTES; + let output = ""; + while (end > 0) { + try { + output = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, end)); + break; + } catch { + // A valid JavaScript string can only leave a partial UTF-8 code point at + // the byte boundary, so at most three trailing bytes are discarded. + end--; + } + } + return `${output}\n\n[Output truncated: ${buffer.length - end} bytes omitted. Re-read a specific subagent or use the transcript modal for details.]`; +} + +export function modelName(model: { provider: string; id: string } | undefined): string { + return model ? `${model.provider}/${model.id}` : "no-model"; +} diff --git a/extensions/subagents/lifecycle.ts b/extensions/subagents/lifecycle.ts new file mode 100644 index 0000000..a308d66 --- /dev/null +++ b/extensions/subagents/lifecycle.ts @@ -0,0 +1,35 @@ +import type { SubagentStatus } from "./types.js"; + +export function isFailedStopReason(stopReason: string | undefined): boolean { + return stopReason === "error" || stopReason === "aborted"; +} + +export function countsAgainstSubagentLimit(agent: { status: SubagentStatus; session?: unknown }): boolean { + return agent.status === "creating" || agent.session !== undefined; +} + +export function isTerminalSubagentStatus(status: SubagentStatus): boolean { + return status === "completed" || status === "failed" || status === "terminated"; +} + +export function shouldArchiveTerminalSubagent(agent: { + status: SubagentStatus; + lastReadActivity: number; + activity: readonly unknown[]; +}): boolean { + return isTerminalSubagentStatus(agent.status) && agent.lastReadActivity < agent.activity.length; +} + +export async function abortRunningSubagentSessions } }>( + agents: readonly T[], +): Promise> { + const running = agents.filter((agent) => agent.session && (agent.status === "creating" || agent.status === "working")); + return await Promise.all(running.map(async (agent) => { + try { + await agent.session!.abort(); + return { agent }; + } catch (error) { + return { agent, error: error instanceof Error ? error : new Error(String(error)) }; + } + })); +} diff --git a/extensions/subagents/manager.ts b/extensions/subagents/manager.ts new file mode 100644 index 0000000..f83320f --- /dev/null +++ b/extensions/subagents/manager.ts @@ -0,0 +1,811 @@ +import { resolve } from "node:path"; +import { + createAgentSession, + DefaultResourceLoader, + getAgentDir, + SessionManager, + SettingsManager, + type AgentSession, + type AgentSessionEvent, + type ExtensionAPI, + type ExtensionContext, + type ModelRuntime, +} from "@earendil-works/pi-coding-agent"; +import { FOOTER_CONTRIBUTION_EVENT, type FooterContribution } from "../footer-events.js"; +import { + SUBAGENT_STATUS_EVENT, + type SubagentStatusEvent, + type SubagentWebSnapshot, + type SubagentWebUpdate, +} from "../subagent-events.js"; +import { + formatClock, + formatDuration, + formatTokens, + modelName, + sanitizeName, + statusIcon, + stringifyCompact, + truncateChars, + truncateToolOutput, +} from "./format.js"; +import { + abortRunningSubagentSessions, + countsAgainstSubagentLimit, + isFailedStopReason, + isTerminalSubagentStatus, + shouldArchiveTerminalSubagent, +} from "./lifecycle.js"; +import { + filterModelsToScope, + inheritedSubagentModel, + subagentModelRuntime, + unavailableModelMessage, +} from "./models.js"; +import { + appendBoundedStreamingText, + boundedWebTranscript, + finalAssistantText, + messageError, + messageRole, + messageStopReason, + messageToTranscript, + messageUsage, + webTranscript, +} from "./transcript.js"; +import { + MAX_ACTIVITY_ITEMS, + MAX_SUBAGENTS, + MAX_TRANSCRIPT_CHARS, + MAX_TRANSCRIPT_ITEMS, + SUBAGENT_SYSTEM_PROMPT, + USAGE_STATE_ENTRY, + WEB_STATUS_PUBLISH_INTERVAL_MS, + type AgentModel, + type AgentSnapshot, + type ManagedSubagent, + type MessageUrgency, + type PersistedUsageState, + type SubagentEffort, + type TranscriptItem, + type Usage, +} from "./types.js"; +import { + addUsage, + asFooterUsage, + cloneUsage, + hasUsage, + parsePersistedUsageState, + subtractUsage, + zeroUsage, +} from "./usage.js"; + +export class SubagentManager { + readonly agents = new Map(); + private archivedAgents = new Map(); + private nextId = 1; + private currentContext?: ExtensionContext; + private totalUsage = zeroUsage(); + private accountedUsage = zeroUsage(); + private usageDirty = false; + private footerSelected = false; + private lastWebStatusPublishedAt = 0; + private webStatusPublishTimer?: ReturnType; + private webTranscriptCursors = new Map(); + private webStreamingSnapshots = new Map(); + private abortAllInFlight?: Promise; + + constructor(private readonly pi: ExtensionAPI) {} + + setContext(ctx: ExtensionContext): void { + this.currentContext = ctx; + this.footerSelected = false; + this.totalUsage = zeroUsage(); + this.accountedUsage = zeroUsage(); + this.usageDirty = false; + this.webTranscriptCursors.clear(); + this.webStreamingSnapshots.clear(); + for (const entry of [...ctx.sessionManager.getBranch()].reverse()) { + if (entry.type !== "custom" || entry.customType !== USAGE_STATE_ENTRY) continue; + const restored = parsePersistedUsageState(entry.data); + if (restored) { + this.totalUsage = cloneUsage(restored.total); + this.accountedUsage = cloneUsage(restored.accounted); + } + break; + } + this.publishFooter(); + } + + persistUsage(): void { + if (!this.usageDirty || !hasUsage(this.totalUsage)) return; + this.pi.appendEntry(USAGE_STATE_ENTRY, { + total: cloneUsage(this.totalUsage), + accounted: cloneUsage(this.accountedUsage), + } satisfies PersistedUsageState); + this.usageDirty = false; + } + + clearContext(): void { + if (this.webStatusPublishTimer) clearTimeout(this.webStatusPublishTimer); + this.webStatusPublishTimer = undefined; + this.lastWebStatusPublishedAt = 0; + this.webTranscriptCursors.clear(); + this.webStreamingSnapshots.clear(); + this.archivedAgents.clear(); + const ctx = this.currentContext; + if (ctx) { + const sessionId = ctx.sessionManager.getSessionId(); + this.pi.events.emit(FOOTER_CONTRIBUTION_EVENT, { + sessionId, + key: "subagents", + remove: true, + } satisfies FooterContribution); + this.pi.events.emit(SUBAGENT_STATUS_EVENT, { + sessionId, + agents: [], + usage: zeroUsage(), + remove: true, + } satisfies SubagentStatusEvent); + } + this.currentContext = undefined; + this.footerSelected = false; + } + + hasAgents(): boolean { + return this.agents.size > 0; + } + + isFooterSelected(): boolean { + return this.footerSelected; + } + + setFooterSelected(selected: boolean): void { + if (this.footerSelected === selected) return; + this.footerSelected = selected && this.hasAgents(); + this.publishFooter(); + } + + getAgent(id: string): ManagedSubagent { + const agent = this.agents.get(id) ?? this.archivedAgents.get(id); + if (!agent) throw new Error(`Unknown subagent: ${id}`); + return agent; + } + + list(): ManagedSubagent[] { + return Array.from(this.agents.values()).sort((a, b) => a.createdAt - b.createdAt); + } + + snapshots(): AgentSnapshot[] { + return this.list().map((agent) => ({ + id: agent.id, + status: agent.status, + model: agent.model, + effort: agent.effort, + turns: agent.turns, + currentTool: agent.currentTool, + queued: agent.queuedSteering + agent.queuedFollowUp, + })); + } + + webSnapshots(): SubagentWebSnapshot[] { + return this.list().map((agent) => ({ + id: agent.id, + status: agent.status, + model: agent.model, + effort: agent.effort, + turns: agent.turns, + currentTool: agent.currentTool, + queued: agent.queuedSteering + agent.queuedFollowUp, + createdAt: agent.createdAt, + updatedAt: agent.updatedAt, + completedAt: agent.completedAt, + error: agent.error, + usage: cloneUsage(agent.usage), + transcript: webTranscript(agent), + streamingText: agent.streamingText || undefined, + })); + } + + private webStatusUpdates(): SubagentWebUpdate[] { + return this.list().map((agent) => { + const update: SubagentWebUpdate = { + id: agent.id, + status: agent.status, + model: agent.model, + effort: agent.effort, + turns: agent.turns, + currentTool: agent.currentTool ?? null, + queued: agent.queuedSteering + agent.queuedFollowUp, + createdAt: agent.createdAt, + updatedAt: agent.updatedAt, + completedAt: agent.completedAt ?? null, + error: agent.error ?? null, + usage: cloneUsage(agent.usage), + }; + + const hadTranscriptCursor = this.webTranscriptCursors.has(agent.id); + const previousTranscriptItem = this.webTranscriptCursors.get(agent.id); + const previousIndex = previousTranscriptItem ? agent.transcript.indexOf(previousTranscriptItem) : -1; + if (!hadTranscriptCursor || (previousTranscriptItem && previousIndex < 0)) { + update.transcriptReset = true; + update.transcriptDelta = webTranscript(agent); + } else { + const firstNewIndex = previousTranscriptItem ? previousIndex + 1 : 0; + if (firstNewIndex < agent.transcript.length) { + update.transcriptDelta = boundedWebTranscript(agent.transcript.slice(firstNewIndex)); + } + } + this.webTranscriptCursors.set(agent.id, agent.transcript.at(-1)); + + const hadStreamingSnapshot = this.webStreamingSnapshots.has(agent.id); + const previousStreamingText = this.webStreamingSnapshots.get(agent.id) ?? ""; + if (!hadStreamingSnapshot || !agent.streamingText.startsWith(previousStreamingText)) { + update.streamingTextReset = true; + update.streamingTextDelta = agent.streamingText; + } else if (agent.streamingText.length > previousStreamingText.length) { + update.streamingTextDelta = agent.streamingText.slice(previousStreamingText.length); + } + this.webStreamingSnapshots.set(agent.id, agent.streamingText); + return update; + }); + } + + private makeId(requestedName?: string): string { + const base = requestedName ? sanitizeName(requestedName) : `agent-${this.nextId++}`; + if (!base) return this.makeId(); + if (!this.agents.has(base) && !this.archivedAgents.has(base)) return base; + let suffix = 2; + while (this.agents.has(`${base}-${suffix}`) || this.archivedAgents.has(`${base}-${suffix}`)) suffix++; + return `${base}-${suffix}`; + } + + private activeSessionCount(): number { + return this.list().filter(countsAgainstSubagentLimit).length; + } + + private getModelRuntime(ctx: ExtensionContext): ModelRuntime { + return subagentModelRuntime(ctx.modelRegistry); + } + + async availableModels(ctx: ExtensionContext): Promise { + const available = await this.getModelRuntime(ctx).getAvailable(); + return filterModelsToScope(available, ctx.scopedModels); + } + + private async resolveModel(ctx: ExtensionContext, requested?: string, runtime?: ModelRuntime): Promise { + if (!requested) { + if (!ctx.model) return undefined; + const inheritedRuntime = runtime ?? this.getModelRuntime(ctx); + // Omitted model means exact host-session inheritance. Session model scope + // applies only to explicit overrides and may intentionally exclude the + // separately selected --model value. + return inheritedSubagentModel( + ctx.model as AgentModel, + inheritedRuntime.getModel(ctx.model.provider, ctx.model.id), + ); + } + + const available = await this.availableModels(ctx); + const slash = requested.indexOf("/"); + if (slash > 0) { + const provider = requested.slice(0, slash); + const id = requested.slice(slash + 1); + const model = available.find((item) => item.provider === provider && item.id === id); + if (model) return model; + throw new Error(unavailableModelMessage(requested, available, ctx.model, ctx.scopedModels.length > 0)); + } + + const matches = available.filter((item) => item.id === requested || item.name === requested); + if (matches.length === 1) return matches[0]; + if (matches.length > 1) { + throw new Error(`Model name is ambiguous; use provider/model: ${matches.map(modelName).join(", ")}`); + } + throw new Error(unavailableModelMessage(requested, available, ctx.model, ctx.scopedModels.length > 0)); + } + + private scopedEffort(ctx: ExtensionContext, model: AgentModel | undefined): SubagentEffort | undefined { + if (!model) return undefined; + return ctx.scopedModels.find( + (scoped) => scoped.model.provider === model.provider && scoped.model.id === model.id, + )?.thinkingLevel as SubagentEffort | undefined; + } + + private activity(agent: ManagedSubagent, text: string): void { + agent.updatedAt = Date.now(); + agent.activity.push({ timestamp: agent.updatedAt, text }); + if (agent.activity.length > MAX_ACTIVITY_ITEMS) { + const removed = agent.activity.length - MAX_ACTIVITY_ITEMS; + agent.activity.splice(0, removed); + agent.lastReadActivity = Math.max(0, agent.lastReadActivity - removed); + } + for (const waiter of agent.waiters) waiter(); + agent.waiters.clear(); + this.publishFooter(); + } + + private addTranscript(agent: ManagedSubagent, message: unknown): void { + const transcript = messageToTranscript(message); + if (!transcript) return; + agent.transcript.push(transcript); + if (agent.transcript.length > MAX_TRANSCRIPT_ITEMS) agent.transcript.splice(0, agent.transcript.length - MAX_TRANSCRIPT_ITEMS); + let retainedCharacters = agent.transcript.reduce((total, item) => total + item.text.length, 0); + while (retainedCharacters > MAX_TRANSCRIPT_CHARS && agent.transcript.length > 1) { + retainedCharacters -= agent.transcript.shift()?.text.length ?? 0; + } + } + + private accountUsage(agent: ManagedSubagent, usage: Usage | undefined): void { + if (!usage) return; + addUsage(agent.usage, usage); + addUsage(this.totalUsage, usage); + this.usageDirty = true; + this.publishFooter(); + } + + private subscribe(agent: ManagedSubagent, session: AgentSession): void { + agent.unsubscribe = session.subscribe((event: AgentSessionEvent) => { + switch (event.type) { + case "agent_start": + agent.status = "working"; + agent.error = undefined; + agent.lastStopReason = undefined; + this.activity(agent, "started an agent turn"); + break; + case "turn_start": + agent.turns++; + this.activity(agent, `started turn ${agent.turns}`); + break; + case "tool_execution_start": + agent.currentTool = event.toolName; + this.activity(agent, `running ${event.toolName} ${stringifyCompact(event.args)}`); + break; + case "tool_execution_end": + this.activity(agent, `${event.isError ? "failed" : "finished"} ${event.toolName}`); + agent.currentTool = undefined; + break; + case "message_update": { + const update = event.assistantMessageEvent; + if (update.type === "text_delta") { + agent.streamingText = appendBoundedStreamingText(agent.streamingText, update.delta); + } + const now = Date.now(); + if (agent.streamingText && now - agent.lastStreamActivityAt >= 5_000) { + agent.lastStreamActivityAt = now; + this.activity(agent, "writing a response"); + } + break; + } + case "message_end": { + this.addTranscript(agent, event.message); + const role = messageRole(event.message); + if (role === "assistant" || role === "toolResult") this.accountUsage(agent, messageUsage(event.message)); + if (role === "assistant") { + agent.streamingText = ""; + const stopReason = messageStopReason(event.message); + agent.lastStopReason = stopReason; + const error = messageError(event.message); + if (error) agent.error = error; + else if (stopReason !== "error" && stopReason !== "aborted") agent.error = undefined; + this.activity(agent, `assistant response finished${stopReason ? ` (${stopReason})` : ""}`); + } + break; + } + case "queue_update": + agent.queuedSteering = event.steering.length; + agent.queuedFollowUp = event.followUp.length; + this.activity( + agent, + `queue updated: ${agent.queuedSteering} steering, ${agent.queuedFollowUp} follow-up`, + ); + break; + case "agent_end": + if (event.willRetry) this.activity(agent, "waiting to retry"); + break; + case "agent_settled": + if (agent.status !== "terminated" && agent.status !== "terminating") { + const failed = isFailedStopReason(agent.lastStopReason); + agent.status = failed ? "failed" : "completed"; + agent.completedAt = Date.now(); + this.activity( + agent, + failed + ? `failed${agent.error ? `: ${agent.error}` : ` (${agent.lastStopReason})`}` + : "completed and is waiting for more instructions", + ); + } + break; + case "auto_retry_start": + this.activity(agent, `retrying after an error (attempt ${event.attempt}/${event.maxAttempts})`); + break; + case "compaction_start": + this.activity(agent, `compacting context (${event.reason})`); + break; + case "compaction_end": + this.accountUsage(agent, event.result?.usage); + this.activity( + agent, + event.errorMessage + ? `context compaction failed: ${event.errorMessage}` + : event.aborted + ? "context compaction aborted" + : `context compaction finished (${event.reason})`, + ); + break; + } + }); + } + + private attachRun(agent: ManagedSubagent, promise: Promise): void { + agent.runPromise = promise + .then(() => { + if (agent.status !== "terminated" && agent.status !== "terminating" && agent.status !== "failed") { + agent.status = "completed"; + agent.completedAt = Date.now(); + this.activity(agent, "task run settled"); + } + }) + .catch((error: unknown) => { + if (agent.status === "terminated" || agent.status === "terminating") return; + agent.status = "failed"; + agent.error = error instanceof Error ? error.message : String(error); + agent.completedAt = Date.now(); + this.activity(agent, `failed: ${agent.error}`); + }); + } + + async create( + ctx: ExtensionContext, + options: { prompt: string; name?: string; model?: string; effort?: SubagentEffort; cwd?: string }, + signal?: AbortSignal, + ): Promise { + if (this.activeSessionCount() >= MAX_SUBAGENTS) { + throw new Error(`At most ${MAX_SUBAGENTS} live subagent sessions may be retained at once. Terminate one before creating another.`); + } + if (signal?.aborted) throw new Error("Subagent creation was cancelled"); + + const cwd = resolve(ctx.cwd, options.cwd ?? "."); + const id = this.makeId(options.name); + const activeTools = this.pi.getActiveTools(); + const effort = options.effort ?? (ctx.thinkingLevel as SubagentEffort); + const agent: ManagedSubagent = { + id, + prompt: options.prompt, + cwd, + createdAt: Date.now(), + updatedAt: Date.now(), + status: "creating", + model: options.model ?? modelName(ctx.model), + effort, + turns: 0, + queuedSteering: 0, + queuedFollowUp: 0, + activity: [], + lastReadActivity: 0, + transcript: [], + streamingText: "", + lastStreamActivityAt: 0, + usage: zeroUsage(), + waiters: new Set(), + }; + this.agents.set(id, agent); + this.activity(agent, "creating isolated session"); + + try { + const runtime = await this.getModelRuntime(ctx); + const selectedModel = await this.resolveModel(ctx, options.model, runtime); + const selectedEffort = options.effort ?? this.scopedEffort(ctx, selectedModel) ?? effort; + agent.effort = selectedEffort; + const settingsManager = SettingsManager.create(ctx.cwd, getAgentDir()); + const resourceLoader = new DefaultResourceLoader({ + cwd, + agentDir: getAgentDir(), + settingsManager, + noExtensions: true, + appendSystemPrompt: [SUBAGENT_SYSTEM_PROMPT], + }); + await resourceLoader.reload(); + if (signal?.aborted) throw new Error("Subagent creation was cancelled"); + + const { session } = await createAgentSession({ + cwd, + agentDir: getAgentDir(), + modelRuntime: runtime, + model: selectedModel, + thinkingLevel: selectedEffort, + tools: activeTools, + resourceLoader, + settingsManager, + sessionManager: SessionManager.inMemory(cwd), + }); + if (!session.model) { + session.dispose(); + throw new Error("No authenticated model is available for the subagent"); + } + if (signal?.aborted) { + session.dispose(); + throw new Error("Subagent creation was cancelled"); + } + + agent.session = session; + agent.model = modelName(session.model); + agent.effort = session.thinkingLevel as SubagentEffort; + agent.status = "working"; + this.subscribe(agent, session); + this.activity(agent, `started with ${agent.model} at ${agent.effort} effort`); + this.attachRun(agent, session.prompt(options.prompt, { source: "extension" })); + // Creation already reports these startup events, so the first read waits for new activity. + agent.lastReadActivity = agent.activity.length; + return agent; + } catch (error) { + agent.status = signal?.aborted ? "terminated" : "failed"; + agent.error = error instanceof Error ? error.message : String(error); + agent.completedAt = Date.now(); + this.activity(agent, `${agent.status}: ${agent.error}`); + throw error; + } + } + + async send(id: string, message: string, urgency: MessageUrgency): Promise { + const agent = this.getAgent(id); + const session = agent.session; + if (!session) throw new Error(`Subagent ${id} no longer has a live session`); + if (agent.status === "terminating" || agent.status === "terminated") { + throw new Error(`Subagent ${id} is ${agent.status}`); + } + + if (session.isStreaming) { + if (urgency === "urgent") await session.steer(message); + else await session.followUp(message); + this.activity(agent, `${urgency === "urgent" ? "steered with" : "queued"} instruction: ${truncateChars(message, 160)}`); + return; + } + + agent.status = "working"; + this.activity(agent, `started follow-on instruction: ${truncateChars(message, 160)}`); + this.attachRun(agent, session.prompt(message, { source: "extension" })); + } + + async configure( + ctx: ExtensionContext, + id: string, + options: { model?: string; effort?: SubagentEffort }, + ): Promise { + const agent = this.getAgent(id); + const session = agent.session; + if (!session) throw new Error(`Subagent ${id} no longer has a live session`); + if (!options.model && !options.effort) throw new Error("Specify a model, effort, or both"); + + if (options.model) { + const model = await this.resolveModel(ctx, options.model); + if (!model) throw new Error("No model was selected"); + await session.setModel(model); + agent.model = modelName(session.model); + const pinnedEffort = this.scopedEffort(ctx, model); + if (!options.effort && pinnedEffort) session.setThinkingLevel(pinnedEffort); + agent.effort = session.thinkingLevel as SubagentEffort; + this.activity(agent, `model changed to ${agent.model} at ${agent.effort} effort`); + } + if (options.effort) { + session.setThinkingLevel(options.effort); + agent.effort = session.thinkingLevel as SubagentEffort; + this.activity(agent, `effort changed to ${agent.effort}`); + } + return agent; + } + + async abortAll(): Promise { + if (this.abortAllInFlight) return await this.abortAllInFlight; + const operation = (async () => { + const results = await abortRunningSubagentSessions(this.list()); + for (const { agent, error } of results) { + if (error) { + agent.error = error.message; + this.activity(agent, `abort failed: ${agent.error}`); + } else { + this.activity(agent, "aborted with the main agent"); + } + } + return results.length; + })(); + this.abortAllInFlight = operation; + try { + return await operation; + } finally { + if (this.abortAllInFlight === operation) this.abortAllInFlight = undefined; + } + } + + async terminate(id: string, remove = false): Promise { + const agent = this.getAgent(id); + if (agent.status !== "terminated") { + agent.status = "terminating"; + this.activity(agent, "termination requested"); + const session = agent.session; + if (session) { + try { + await session.abort(); + } catch (error) { + agent.error = error instanceof Error ? error.message : String(error); + } finally { + agent.unsubscribe?.(); + agent.unsubscribe = undefined; + try { + session.dispose(); + } catch (error) { + agent.error = error instanceof Error ? error.message : String(error); + } + agent.session = undefined; + } + } + agent.status = "terminated"; + agent.completedAt = Date.now(); + this.activity(agent, "terminated and released session resources"); + } + this.agents.delete(id); + this.webTranscriptCursors.delete(id); + this.webStreamingSnapshots.delete(id); + if (remove) this.archivedAgents.delete(id); + else if (shouldArchiveTerminalSubagent(agent)) this.archivedAgents.set(id, agent); + this.footerSelected = false; + this.publishFooter(); + if (this.webStatusPublishTimer) clearTimeout(this.webStatusPublishTimer); + this.publishWebStatus(); + return agent; + } + + async terminateAll(remove = false): Promise { + await Promise.all(this.list().map(async (agent) => this.terminate(agent.id, remove))); + if (remove) this.archivedAgents.clear(); + } + + async clearTerminalAgents(): Promise { + const terminalAgents = this.list().filter((agent) => isTerminalSubagentStatus(agent.status)); + if (terminalAgents.length === 0) return 0; + + await Promise.all(terminalAgents.map(async (agent) => { + const preserveUnreadOutput = shouldArchiveTerminalSubagent(agent); + await this.terminate(agent.id, true); + if (preserveUnreadOutput) this.archivedAgents.set(agent.id, agent); + })); + if (this.webStatusPublishTimer) clearTimeout(this.webStatusPublishTimer); + this.publishWebStatus(); + return terminalAgents.length; + } + + private hasUnread(agent: ManagedSubagent): boolean { + return agent.lastReadActivity < agent.activity.length; + } + + async waitForUpdates(agents: ManagedSubagent[], seconds: number, signal?: AbortSignal): Promise { + if (seconds <= 0 || agents.some((agent) => this.hasUnread(agent))) return; + const running = agents.filter((agent) => agent.status === "creating" || agent.status === "working"); + if (running.length === 0) return; + + await new Promise((done) => { + let finished = false; + const finish = () => { + if (finished) return; + finished = true; + clearTimeout(timer); + for (const agent of running) agent.waiters.delete(finish); + signal?.removeEventListener("abort", finish); + done(); + }; + const timer = setTimeout(finish, Math.min(30, seconds) * 1_000); + for (const agent of running) agent.waiters.add(finish); + signal?.addEventListener("abort", finish, { once: true }); + }); + } + + read(agents: ManagedSubagent[], includeTranscript: boolean): string { + if (agents.length === 0) return "No subagents are involved in this session."; + const now = Date.now(); + const sections: string[] = []; + for (const agent of agents) { + const heading = `## ${statusIcon(agent.status)} ${agent.id} — ${agent.status}`; + const metadata = [ + `Model: ${agent.model}`, + `Effort: ${agent.effort}`, + `Elapsed: ${formatDuration((agent.completedAt ?? now) - agent.createdAt)}`, + `Turns: ${agent.turns}`, + `Usage: ↑${formatTokens(agent.usage.input)} ↓${formatTokens(agent.usage.output)}${agent.usage.cost.total ? ` $${agent.usage.cost.total.toFixed(4)}` : ""}`, + ]; + if (agent.currentTool) metadata.push(`Current tool: ${agent.currentTool}`); + if (agent.queuedSteering || agent.queuedFollowUp) { + metadata.push(`Queued: ${agent.queuedSteering} steering, ${agent.queuedFollowUp} follow-up`); + } + if (agent.error) metadata.push(`Error: ${agent.error}`); + + const unread = agent.activity.slice(agent.lastReadActivity); + const activity = unread.length + ? unread.map((item) => `- ${formatClock(item.timestamp)} ${item.text}`).join("\n") + : "- No new activity."; + agent.lastReadActivity = agent.activity.length; + + let output = `${heading}\n${metadata.join("\n")}\n\nActivity since last read:\n${activity}`; + if (includeTranscript) { + const transcript = agent.transcript + .map((item) => `### ${formatClock(item.timestamp)} ${item.role}\n${item.text}`) + .join("\n\n"); + output += `\n\nTranscript:\n${transcript || agent.streamingText || "(empty)"}`; + } else { + const latest = finalAssistantText(agent); + if (latest) output += `\n\nLatest assistant output:\n${latest}`; + } + sections.push(output); + if (this.archivedAgents.get(agent.id) === agent) this.archivedAgents.delete(agent.id); + } + return truncateToolOutput(sections.join("\n\n---\n\n")); + } + + claimUnaccountedUsage(): Usage | undefined { + const usage = subtractUsage(this.totalUsage, this.accountedUsage); + if (!hasUsage(usage)) return undefined; + this.accountedUsage = cloneUsage(this.totalUsage); + this.usageDirty = true; + this.publishFooter(); + return usage; + } + + private footerText(): string | undefined { + if (this.agents.size === 0) return undefined; + let working = 0; + let completed = 0; + let failed = 0; + let terminated = 0; + for (const agent of this.agents.values()) { + if (agent.status === "creating" || agent.status === "working" || agent.status === "terminating") working++; + else if (agent.status === "completed") completed++; + else if (agent.status === "failed") failed++; + else terminated++; + } + const parts = [`◆ ${this.agents.size} subagent${this.agents.size === 1 ? "" : "s"}`]; + if (working) parts.push(`${working} working`); + if (completed) parts.push(`${completed} done`); + if (failed) parts.push(`${failed} failed`); + if (terminated) parts.push(`${terminated} stopped`); + return parts.join(" • "); + } + + private publishWebStatus(): void { + this.webStatusPublishTimer = undefined; + const ctx = this.currentContext; + if (!ctx) return; + const sessionId = ctx.sessionManager.getSessionId(); + const usage = asFooterUsage(subtractUsage(this.totalUsage, this.accountedUsage)); + this.lastWebStatusPublishedAt = Date.now(); + this.pi.events.emit(SUBAGENT_STATUS_EVENT, { + sessionId, + agents: this.webStatusUpdates(), + usage, + } satisfies SubagentStatusEvent); + } + + private publishFooter(): void { + const ctx = this.currentContext; + if (!ctx) return; + const sessionId = ctx.sessionManager.getSessionId(); + const usage = asFooterUsage(subtractUsage(this.totalUsage, this.accountedUsage)); + const statusText = this.footerText(); + const contribution: FooterContribution = { + sessionId, + key: "subagents", + status: statusText ? { text: statusText, selected: this.footerSelected } : undefined, + usage, + }; + this.pi.events.emit(FOOTER_CONTRIBUTION_EVENT, contribution); + + // Footer metadata stays immediate, while coalesced web events carry only + // transcript/streaming deltas. The server retains a bounded full snapshot + // for newly subscribed clients without retransmitting it on every burst. + const delay = WEB_STATUS_PUBLISH_INTERVAL_MS - (Date.now() - this.lastWebStatusPublishedAt); + if (delay <= 0) { + if (this.webStatusPublishTimer) clearTimeout(this.webStatusPublishTimer); + this.publishWebStatus(); + } else if (!this.webStatusPublishTimer) { + this.webStatusPublishTimer = setTimeout(() => this.publishWebStatus(), delay); + this.webStatusPublishTimer.unref?.(); + } + } +} diff --git a/extensions/subagents/models.ts b/extensions/subagents/models.ts new file mode 100644 index 0000000..4800b2d --- /dev/null +++ b/extensions/subagents/models.ts @@ -0,0 +1,58 @@ +import { ModelRuntime, type ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { modelName } from "./format.js"; + +export function filterModelsToScope( + available: readonly T[], + scoped: ReadonlyArray<{ model: { provider: string; id: string } }>, +): readonly T[] { + if (scoped.length === 0) return available; + const allowed = new Set(scoped.map(({ model }) => `${model.provider}/${model.id}`)); + return available.filter((model) => allowed.has(`${model.provider}/${model.id}`)); +} + +export function inheritedSubagentModel( + current: T | undefined, + runtimeModel: T | undefined, +): T | undefined { + return runtimeModel ?? current; +} + +export function subagentModelRuntime(modelRegistry: ModelRegistry): ModelRuntime { + // ModelRegistry is the extension-facing compatibility facade around the + // canonical runtime. Sharing that runtime preserves runtime-only keys and + // provider-resolved headers/env/base URLs, while leaving stored OAuth in the + // credential store so both host and child continue to refresh it normally. + const runtime: unknown = Reflect.get(modelRegistry, "runtime"); + if (!(runtime instanceof ModelRuntime)) { + throw new Error("The host model registry does not expose its canonical runtime"); + } + return runtime; +} + +export function subagentModelGuidance( + current: { provider: string; id: string } | undefined, + available: readonly { provider: string; id: string }[], +): string { + const choices = [...new Set(available.map(modelName))]; + const inherited = modelName(current); + return [ + "Subagent model selection for this session:", + `- subagent_create inherits ${inherited} when model is omitted.`, + "- Only pass model when intentionally overriding the inherited model.", + `- Exact available provider/model IDs: ${choices.length > 0 ? choices.join(", ") : "none"}.`, + "- Never shorten, generalize, or invent a model ID.", + ].join("\n"); +} + +export function unavailableModelMessage( + requested: string, + available: readonly { provider: string; id: string }[], + current: { provider: string; id: string } | undefined, + withinScope: boolean, +): string { + const choices = [...new Set(available.map(modelName))]; + const scope = withinScope ? " within the session scope" : ""; + const allowed = choices.length > 0 ? choices.join(", ") : "none"; + const inherit = current ? ` Omit model to inherit ${modelName(current)}.` : ""; + return `Model is unavailable${scope}: ${requested}. Exact available models: ${allowed}.${inherit}`; +} diff --git a/extensions/subagents/tools.ts b/extensions/subagents/tools.ts new file mode 100644 index 0000000..8af545e --- /dev/null +++ b/extensions/subagents/tools.ts @@ -0,0 +1,206 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Text } from "@earendil-works/pi-tui"; +import { Type } from "typebox"; +import { truncateChars } from "./format.js"; +import type { SubagentManager } from "./manager.js"; +import { + DEFAULT_READ_WAIT_SECONDS, + MAX_SUBAGENTS, + THINKING_LEVELS, + type ToolDetails, + type Usage, +} from "./types.js"; + +function toolResult(manager: SubagentManager, text: string): { content: [{ type: "text"; text: string }]; details: ToolDetails; usage?: Usage } { + const usage = manager.claimUnaccountedUsage(); + return { + content: [{ type: "text", text }], + details: { agents: manager.webSnapshots() }, + ...(usage ? { usage } : {}), + }; +} + +function stringEnum( + values: T, + options?: { description?: string; default?: T[number] }, +) { + return Type.Unsafe({ + type: "string", + enum: values, + ...(options?.description ? { description: options.description } : {}), + ...(options?.default ? { default: options.default } : {}), + }); +} + +const EffortSchema = stringEnum(THINKING_LEVELS, { + description: "Reasoning effort. The selected model may clamp unsupported levels.", +}); + +const CreateParams = Type.Object({ + prompt: Type.String({ description: "Complete task prompt for the new isolated subagent" }), + name: Type.Optional(Type.String({ description: "Short stable name used to address the subagent" })), + model: Type.Optional(Type.String({ description: "Exact provider/model-id or exact unambiguous model id. Omit to inherit the current model; never use a shortened family alias." })), + effort: Type.Optional(EffortSchema), + cwd: Type.Optional(Type.String({ description: "Working directory, relative to the main session unless absolute" })), +}); + +const ReadParams = Type.Object({ + id: Type.Optional(Type.String({ description: "Subagent id. Omit to read all subagents." })), + wait_seconds: Type.Optional( + Type.Integer({ + description: `Wait for meaningful new activity before returning. Default ${DEFAULT_READ_WAIT_SECONDS}, maximum 30.`, + minimum: 0, + maximum: 30, + }), + ), + include_transcript: Type.Optional(Type.Boolean({ description: "Include the full retained transcript instead of only latest output" })), +}); + +const SendParams = Type.Object({ + id: Type.String({ description: "Subagent id" }), + message: Type.String({ description: "Instruction to send" }), + urgency: stringEnum(["normal", "urgent"] as const, { + description: "urgent steers after the current tool batch; normal queues until the current run finishes", + }), +}); + +const ConfigureParams = Type.Object({ + id: Type.String({ description: "Subagent id" }), + model: Type.Optional(Type.String({ description: "Exact new provider/model-id or exact unambiguous model id. Omit to retain the current model; never use a shortened family alias." })), + effort: Type.Optional(EffortSchema), +}); + +const TerminateParams = Type.Object({ + id: Type.Optional(Type.String({ description: "Subagent id. Omit with all=true to terminate every subagent." })), + all: Type.Optional(Type.Boolean({ description: "Terminate every subagent" })), + remove: Type.Optional(Type.Boolean({ description: "Also remove terminated records from the footer and manager" })), +}); + + +export function registerSubagentTools(pi: ExtensionAPI, manager: SubagentManager): void { + pi.registerTool({ + name: "subagent_create", + label: "Create subagent", + description: `Create a background subagent with an isolated context, model, and reasoning effort. Returns immediately after startup. Up to ${MAX_SUBAGENTS} live subagent sessions are allowed.`, + promptSnippet: "Create a background subagent with a chosen prompt, model, and effort", + promptGuidelines: [ + "When calling subagent_create, omit model to inherit the current model unless deliberately choosing one of the exact session-available provider/model IDs listed in the system prompt; never shorten or invent a model ID.", + "After subagent_create returns, use subagent_read with its default wait roughly every 15–30 seconds while work continues; briefly tell the user about meaningful progress between polls without narrating every event.", + "Wait for subagent_create to return before calling another subagent management tool for that id.", + "Use subagent_send with urgent only when the current approach must change immediately; use normal for work that can wait until the current run finishes.", + "Use subagent_terminate when delegated work is no longer needed, and clean up retained subagents before finishing when appropriate.", + ], + parameters: CreateParams, + async execute(_toolCallId, params, signal, _onUpdate, ctx) { + const agent = await manager.create(ctx, params, signal); + return toolResult( + manager, + `Created ${agent.id} with ${agent.model} at ${agent.effort} effort. It is running in ${agent.cwd}. Use subagent_read to wait for and inspect progress.`, + ); + }, + renderCall(args, theme) { + const name = args.name ? ` ${theme.fg("accent", args.name)}` : ""; + const model = args.model ? ` · ${args.model}` : ""; + const effort = args.effort ? ` · ${args.effort}` : ""; + return new Text(`${theme.fg("toolTitle", theme.bold("subagent_create"))}${name}${theme.fg("muted", model + effort)}\n${theme.fg("dim", truncateChars(args.prompt, 180))}`, 0, 0); + }, + renderResult(result, _options, theme) { + const text = result.content[0]; + return new Text(theme.fg("toolOutput", text?.type === "text" ? text.text : "Created subagent"), 0, 0); + }, + }); + + pi.registerTool({ + name: "subagent_read", + label: "Read subagents", + description: "Wait for and read meaningful subagent activity, status, output, usage, or full transcripts. Omit id to monitor all subagents.", + promptSnippet: "Read and monitor background subagent activity and output", + parameters: ReadParams, + async execute(_toolCallId, params, signal) { + const agents = params.id ? [manager.getAgent(params.id)] : manager.list(); + await manager.waitForUpdates(agents, params.wait_seconds ?? DEFAULT_READ_WAIT_SECONDS, signal); + if (signal?.aborted) throw new Error("Subagent read was cancelled"); + return toolResult(manager, manager.read(agents, params.include_transcript ?? false)); + }, + renderCall(args, theme) { + return new Text( + theme.fg("toolTitle", theme.bold("subagent_read")) + + theme.fg("muted", ` ${args.id ?? "all"} · wait ${args.wait_seconds ?? DEFAULT_READ_WAIT_SECONDS}s`), + 0, + 0, + ); + }, + renderResult(result, { expanded }, theme) { + const raw = result.content[0]; + const text = raw?.type === "text" ? raw.text : "(no output)"; + return new Text(theme.fg("toolOutput", expanded ? text : text.split("\n").slice(0, 14).join("\n")), 0, 0); + }, + }); + + pi.registerTool({ + name: "subagent_send", + label: "Message subagent", + description: "Send an urgent steering message to a running subagent or queue a normal follow-up message for it.", + promptSnippet: "Steer a subagent urgently or queue a normal follow-up instruction", + parameters: SendParams, + async execute(_toolCallId, params) { + await manager.send(params.id, params.message, params.urgency); + return toolResult( + manager, + params.urgency === "urgent" + ? `Steering message sent to ${params.id}.` + : `Follow-up message queued for ${params.id}.`, + ); + }, + renderCall(args, theme) { + return new Text( + `${theme.fg("toolTitle", theme.bold("subagent_send"))} ${theme.fg("accent", args.id)} ${theme.fg(args.urgency === "urgent" ? "warning" : "muted", args.urgency)}\n${theme.fg("dim", truncateChars(args.message, 180))}`, + 0, + 0, + ); + }, + }); + + pi.registerTool({ + name: "subagent_configure", + label: "Configure subagent", + description: "Change a retained subagent's model and/or reasoning effort. Changes apply to its next model request.", + promptSnippet: "Change a subagent model or reasoning effort", + parameters: ConfigureParams, + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const agent = await manager.configure(ctx, params.id, params); + return toolResult(manager, `${agent.id} now uses ${agent.model} at ${agent.effort} effort.`); + }, + renderCall(args, theme) { + return new Text( + `${theme.fg("toolTitle", theme.bold("subagent_configure"))} ${theme.fg("accent", args.id)}${theme.fg("muted", `${args.model ? ` · ${args.model}` : ""}${args.effort ? ` · ${args.effort}` : ""}`)}`, + 0, + 0, + ); + }, + }); + + pi.registerTool({ + name: "subagent_terminate", + label: "Terminate subagent", + description: "Abort one or all subagents, dispose their sessions, and optionally remove their retained transcript records.", + promptSnippet: "Terminate subagents and release their resources", + parameters: TerminateParams, + async execute(_toolCallId, params) { + if (params.all) { + await manager.terminateAll(params.remove ?? false); + return toolResult(manager, "Terminated all subagents and released their session resources."); + } + if (!params.id) throw new Error("Specify id or all=true"); + await manager.terminate(params.id, params.remove ?? false); + return toolResult(manager, `Terminated ${params.id} and released its session resources.`); + }, + renderCall(args, theme) { + return new Text( + `${theme.fg("toolTitle", theme.bold("subagent_terminate"))} ${theme.fg("warning", args.all ? "all" : (args.id ?? "?"))}`, + 0, + 0, + ); + }, + }); +} diff --git a/extensions/subagents/transcript.ts b/extensions/subagents/transcript.ts new file mode 100644 index 0000000..43eb4c0 --- /dev/null +++ b/extensions/subagents/transcript.ts @@ -0,0 +1,99 @@ +import { stringifyCompact, truncateChars } from "./format.js"; +import { + MAX_TRANSCRIPT_ENTRY_CHARS, + MAX_WEB_STREAMING_CHARS, + MAX_WEB_TRANSCRIPT_CHARS, + type ManagedSubagent, + type TranscriptItem, + type Usage, +} from "./types.js"; + +export function appendBoundedStreamingText(current: string, delta: string): string { + const combined = current + delta; + return combined.length <= MAX_WEB_STREAMING_CHARS + ? combined + : combined.slice(-MAX_WEB_STREAMING_CHARS); +} + +export function contentToText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const block of content) { + if (!block || typeof block !== "object") continue; + const item = block as Record; + if (item.type === "text" && typeof item.text === "string") parts.push(item.text); + else if (item.type === "thinking" && typeof item.thinking === "string") parts.push(`[thinking]\n${item.thinking}`); + else if (item.type === "toolCall" && typeof item.name === "string") { + parts.push(`→ ${item.name} ${stringifyCompact(item.arguments)}`); + } else if (item.type === "image") { + parts.push("[image]"); + } + } + return parts.join("\n"); +} + +export function messageToTranscript(message: unknown): TranscriptItem | undefined { + if (!message || typeof message !== "object") return undefined; + const item = message as Record; + if (typeof item.role !== "string") return undefined; + const text = truncateChars(contentToText(item.content), MAX_TRANSCRIPT_ENTRY_CHARS).trim(); + if (!text) return undefined; + return { + timestamp: typeof item.timestamp === "number" ? item.timestamp : Date.now(), + role: item.role, + text, + }; +} + +export function messageUsage(message: unknown): Usage | undefined { + if (!message || typeof message !== "object") return undefined; + const usage = (message as Record).usage; + if (!usage || typeof usage !== "object") return undefined; + return usage as Usage; +} + +export function messageRole(message: unknown): string | undefined { + return message && typeof message === "object" && typeof (message as Record).role === "string" + ? ((message as Record).role as string) + : undefined; +} + +export function messageStopReason(message: unknown): string | undefined { + return message && typeof message === "object" && typeof (message as Record).stopReason === "string" + ? ((message as Record).stopReason as string) + : undefined; +} + +export function messageError(message: unknown): string | undefined { + return message && typeof message === "object" && typeof (message as Record).errorMessage === "string" + ? ((message as Record).errorMessage as string) + : undefined; +} + +export function finalAssistantText(agent: ManagedSubagent): string { + for (let index = agent.transcript.length - 1; index >= 0; index--) { + const item = agent.transcript[index]; + if (item?.role === "assistant") return item.text; + } + return agent.streamingText.trim(); +} + +export function boundedWebTranscript(items: readonly TranscriptItem[]): TranscriptItem[] { + const retained: TranscriptItem[] = []; + let characters = 0; + for (let index = items.length - 1; index >= 0; index--) { + const item = items[index]; + if (!item) continue; + const remaining = MAX_WEB_TRANSCRIPT_CHARS - characters; + if (remaining <= 0 && retained.length > 0) break; + const text = truncateChars(item.text, Math.max(1, remaining)); + retained.push({ ...item, text }); + characters += text.length; + } + return retained.reverse(); +} + +export function webTranscript(agent: ManagedSubagent): TranscriptItem[] { + return boundedWebTranscript(agent.transcript); +} diff --git a/extensions/subagents/types.ts b/extensions/subagents/types.ts new file mode 100644 index 0000000..ec5be5a --- /dev/null +++ b/extensions/subagents/types.ts @@ -0,0 +1,94 @@ +import type { AgentSession, ModelRuntime } from "@earendil-works/pi-coding-agent"; +import type { FooterUsage } from "../footer-events.js"; +import type { SubagentWebSnapshot } from "../subagent-events.js"; + +export const MAX_SUBAGENTS = 8; +export const MAX_ACTIVITY_ITEMS = 500; +export const MAX_TRANSCRIPT_ITEMS = 500; +export const MAX_TRANSCRIPT_ENTRY_CHARS = 100_000; +export const MAX_TRANSCRIPT_CHARS = 1_000_000; +export const MAX_WEB_TRANSCRIPT_CHARS = 100_000; +export const MAX_WEB_STREAMING_CHARS = 20_000; +export const WEB_STATUS_PUBLISH_INTERVAL_MS = 1_000; +export const MAX_TOOL_OUTPUT_BYTES = 50 * 1024; +export const DEFAULT_READ_WAIT_SECONDS = 15; +export const DETAIL_VIEW_LINES = 22; +export const USAGE_STATE_ENTRY = "vessup-subagent-usage"; +export const SUBAGENT_SYSTEM_PROMPT = [ + "You are a subagent working for a main coding agent.", + "Work independently on the delegated task in the current working directory.", + "Use tools when useful, keep changes scoped to the task, and finish with a concise report of findings, changes, tests, and remaining risks.", + "Messages received after the initial task are instructions from the main agent; urgent steering messages supersede your current approach.", +].join(" "); + +export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; +export type SubagentEffort = (typeof THINKING_LEVELS)[number]; +export type SubagentStatus = "creating" | "working" | "completed" | "failed" | "terminating" | "terminated"; +export type MessageUrgency = "normal" | "urgent"; + +export type ActivityItem = { + timestamp: number; + text: string; +}; + +export type TranscriptItem = { + timestamp: number; + role: string; + text: string; +}; + +export type ManagedSubagent = { + id: string; + prompt: string; + cwd: string; + createdAt: number; + updatedAt: number; + completedAt?: number; + status: SubagentStatus; + model: string; + effort: SubagentEffort; + currentTool?: string; + error?: string; + lastStopReason?: string; + turns: number; + queuedSteering: number; + queuedFollowUp: number; + activity: ActivityItem[]; + lastReadActivity: number; + transcript: TranscriptItem[]; + streamingText: string; + lastStreamActivityAt: number; + usage: Usage; + session?: AgentSession; + unsubscribe?: () => void; + runPromise?: Promise; + waiters: Set<() => void>; +}; + +export type AgentSnapshot = { + id: string; + status: SubagentStatus; + model: string; + effort: SubagentEffort; + turns: number; + currentTool?: string; + queued: number; +}; + +export type ToolDetails = { + agents: SubagentWebSnapshot[]; +}; + +export type Usage = FooterUsage; +export type AgentModel = NonNullable>; +export type PersistedUsageState = { total: Usage; accounted: Usage }; + +export type ManagerDialogResult = + | { action: "close" } + | { action: "view"; id: string } + | { action: "back" } + | { action: "model"; id: string } + | { action: "effort"; id: string } + | { action: "urgent"; id: string } + | { action: "queue"; id: string } + | { action: "terminate"; id: string }; diff --git a/extensions/subagents/ui.ts b/extensions/subagents/ui.ts new file mode 100644 index 0000000..8510db8 --- /dev/null +++ b/extensions/subagents/ui.ts @@ -0,0 +1,480 @@ +import { + DynamicBorder, + type ExtensionContext, + type KeybindingsManager, + type Theme, +} from "@earendil-works/pi-coding-agent"; +import { + Container, + type EditorComponent, + type Focusable, + matchesKey, + SelectList, + type SelectItem, + Text, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, + type Component, + type TUI, +} from "@earendil-works/pi-tui"; +import { + formatClock, + formatDuration, + formatTokens, + modelName, + statusColor, + statusIcon, + truncateChars, +} from "./format.js"; +import type { SubagentManager } from "./manager.js"; +import { + DETAIL_VIEW_LINES, + THINKING_LEVELS, + type ManagedSubagent, + type ManagerDialogResult, + type SubagentEffort, +} from "./types.js"; + +export interface AppEditorComponent extends EditorComponent, Partial { + getCursor?: () => { line: number; col: number }; + getLines?: () => string[]; + isShowingAutocomplete?: () => boolean; + dispose?: () => void; + actionHandlers?: Map void>; + onEscape?: () => void; + onCtrlD?: () => void; + onPasteImage?: () => void; + onExtensionShortcut?: (data: string) => boolean; +} + +export class FooterNavigationEditor implements EditorComponent, Focusable { + readonly actionHandlers?: Map void>; + + constructor( + private readonly base: AppEditorComponent, + private readonly keybindings: KeybindingsManager, + private readonly manager: SubagentManager, + private readonly openManager: () => void, + ) { + this.actionHandlers = base.actionHandlers; + } + + get focused(): boolean { + return this.base.focused ?? false; + } + set focused(value: boolean) { + if ("focused" in this.base) this.base.focused = value; + } + get wantsKeyRelease(): boolean | undefined { + return this.base.wantsKeyRelease; + } + set wantsKeyRelease(value: boolean | undefined) { + this.base.wantsKeyRelease = value; + } + get onSubmit(): ((text: string) => void) | undefined { + return this.base.onSubmit; + } + set onSubmit(value: ((text: string) => void) | undefined) { + this.base.onSubmit = value; + } + get onChange(): ((text: string) => void) | undefined { + return this.base.onChange; + } + set onChange(value: ((text: string) => void) | undefined) { + this.base.onChange = value; + } + get borderColor(): ((text: string) => string) | undefined { + return this.base.borderColor; + } + set borderColor(value: ((text: string) => string) | undefined) { + this.base.borderColor = value; + } + get onEscape(): (() => void) | undefined { + return this.base.onEscape; + } + set onEscape(value: (() => void) | undefined) { + this.base.onEscape = value; + } + get onCtrlD(): (() => void) | undefined { + return this.base.onCtrlD; + } + set onCtrlD(value: (() => void) | undefined) { + this.base.onCtrlD = value; + } + get onPasteImage(): (() => void) | undefined { + return this.base.onPasteImage; + } + set onPasteImage(value: (() => void) | undefined) { + this.base.onPasteImage = value; + } + get onExtensionShortcut(): ((data: string) => boolean) | undefined { + return this.base.onExtensionShortcut; + } + set onExtensionShortcut(value: ((data: string) => boolean) | undefined) { + this.base.onExtensionShortcut = value; + } + + render(width: number): string[] { + return this.base.render(width); + } + invalidate(): void { + this.base.invalidate(); + } + dispose(): void { + this.base.dispose?.(); + } + getText(): string { + return this.base.getText(); + } + setText(text: string): void { + this.base.setText(text); + } + addToHistory(text: string): void { + this.base.addToHistory?.(text); + } + insertTextAtCursor(text: string): void { + this.base.insertTextAtCursor?.(text); + } + getExpandedText(): string { + return this.base.getExpandedText?.() ?? this.base.getText(); + } + setAutocompleteProvider(provider: Parameters>[0]): void { + this.base.setAutocompleteProvider?.(provider); + } + setPaddingX(padding: number): void { + this.base.setPaddingX?.(padding); + } + setAutocompleteMaxVisible(maximum: number): void { + this.base.setAutocompleteMaxVisible?.(maximum); + } + + handleInput(data: string): void { + if (this.manager.isFooterSelected()) { + if (this.keybindings.matches(data, "tui.select.confirm")) { + this.manager.setFooterSelected(false); + this.openManager(); + return; + } + if ( + this.keybindings.matches(data, "tui.select.up") || + this.keybindings.matches(data, "tui.select.cancel") + ) { + this.manager.setFooterSelected(false); + return; + } + this.manager.setFooterSelected(false); + } + + if ( + this.manager.hasAgents() && + this.base.getText().length === 0 && + !this.base.isShowingAutocomplete?.() && + matchesKey(data, "alt+down") + ) { + const cursor = this.base.getCursor?.(); + const lines = this.base.getLines?.(); + if (!cursor || !lines || cursor.line === lines.length - 1) { + this.manager.setFooterSelected(true); + return; + } + } + this.base.handleInput(data); + } +} + +function padAnsi(text: string, width: number): string { + const fitted = truncateToWidth(text, Math.max(0, width), "…"); + return fitted + " ".repeat(Math.max(0, width - visibleWidth(fitted))); +} + +function frameLines(theme: Theme, title: string, body: string[], width: number): string[] { + if (width < 4) return body.map((line) => truncateToWidth(line, width, "")); + const inner = width - 2; + const titleText = truncateToWidth(` ${title} `, Math.max(0, inner - 2), "…"); + const topFill = Math.max(0, inner - visibleWidth(titleText)); + const top = theme.fg("borderAccent", `┌${titleText}${"─".repeat(topFill)}┐`); + const bottom = theme.fg("borderAccent", `└${"─".repeat(inner)}┘`); + return [ + top, + ...body.map( + (line) => theme.fg("borderAccent", "│") + padAnsi(line, inner) + theme.fg("borderAccent", "│"), + ), + bottom, + ]; +} + +class AgentListDialog implements Component { + private selected = 0; + private timer: ReturnType; + + constructor( + private readonly manager: SubagentManager, + private readonly tui: TUI, + private readonly theme: Theme, + private readonly keybindings: KeybindingsManager, + private readonly done: (result: ManagerDialogResult) => void, + ) { + this.timer = setInterval(() => tui.requestRender(), 500); + } + + dispose(): void { + clearInterval(this.timer); + } + invalidate(): void {} + + handleInput(data: string): void { + const agents = this.manager.list(); + if (this.keybindings.matches(data, "tui.select.cancel")) return this.done({ action: "close" }); + if (this.keybindings.matches(data, "tui.select.up")) { + this.selected = Math.max(0, this.selected - 1); + this.tui.requestRender(); + return; + } + if (this.keybindings.matches(data, "tui.select.down")) { + this.selected = Math.min(Math.max(0, agents.length - 1), this.selected + 1); + this.tui.requestRender(); + return; + } + const agent = agents[this.selected]; + if (!agent) return; + if (this.keybindings.matches(data, "tui.select.confirm")) return this.done({ action: "view", id: agent.id }); + if (data === "m") return this.done({ action: "model", id: agent.id }); + if (data === "e") return this.done({ action: "effort", id: agent.id }); + if (data === "x") return this.done({ action: "terminate", id: agent.id }); + } + + render(width: number): string[] { + const agents = this.manager.list(); + this.selected = Math.min(this.selected, Math.max(0, agents.length - 1)); + const body: string[] = []; + if (agents.length === 0) body.push(this.theme.fg("muted", " No subagents in this session")); + for (let index = 0; index < agents.length; index++) { + const agent = agents[index]; + const icon = this.theme.fg(statusColor(agent.status), statusIcon(agent.status)); + const queue = agent.queuedSteering + agent.queuedFollowUp; + let line = `${index === this.selected ? "›" : " "} ${icon} ${agent.id} ${agent.status}`; + if (agent.currentTool) line += ` · ${agent.currentTool}`; + if (queue) line += ` · ${queue} queued`; + line += ` · ${agent.model} · ${agent.effort}`; + if (index === this.selected) line = this.theme.bg("selectedBg", this.theme.fg("accent", line)); + body.push(line); + } + body.push(""); + body.push(this.theme.fg("dim", " ↑↓ select · enter transcript · m model · e effort · x terminate · esc close")); + return frameLines(this.theme, "Subagents", body, width); + } +} + +export class AgentDetailDialog implements Component { + private scrollOffset = 0; + private timer: ReturnType; + private transcriptCache: { + width: number; + length: number; + first: ManagedSubagent["transcript"][number] | undefined; + last: ManagedSubagent["transcript"][number] | undefined; + streamingText: string; + lines: string[]; + } | undefined; + + constructor( + private readonly agent: ManagedSubagent, + private readonly tui: TUI, + private readonly theme: Theme, + private readonly keybindings: KeybindingsManager, + private readonly done: (result: ManagerDialogResult) => void, + ) { + this.timer = setInterval(() => tui.requestRender(), 300); + } + + dispose(): void { + clearInterval(this.timer); + } + invalidate(): void { + this.transcriptCache = undefined; + } + + handleInput(data: string): void { + if (this.keybindings.matches(data, "tui.select.cancel") || data === "b") return this.done({ action: "back" }); + if (this.keybindings.matches(data, "tui.select.up") || this.keybindings.matches(data, "tui.select.pageUp")) { + this.scrollOffset += this.keybindings.matches(data, "tui.select.pageUp") ? DETAIL_VIEW_LINES : 1; + this.tui.requestRender(); + return; + } + if (this.keybindings.matches(data, "tui.select.down") || this.keybindings.matches(data, "tui.select.pageDown")) { + this.scrollOffset = Math.max( + 0, + this.scrollOffset - (this.keybindings.matches(data, "tui.select.pageDown") ? DETAIL_VIEW_LINES : 1), + ); + this.tui.requestRender(); + return; + } + if (data === "m") return this.done({ action: "model", id: this.agent.id }); + if (data === "e") return this.done({ action: "effort", id: this.agent.id }); + if (data === "u") return this.done({ action: "urgent", id: this.agent.id }); + if (data === "q") return this.done({ action: "queue", id: this.agent.id }); + if (data === "x") return this.done({ action: "terminate", id: this.agent.id }); + } + + private transcriptLines(width: number): string[] { + const length = this.agent.transcript.length; + const first = this.agent.transcript[0]; + const last = this.agent.transcript.at(-1); + const streamingText = this.agent.streamingText; + const cached = this.transcriptCache; + if ( + cached + && cached.width === width + && cached.length === length + && cached.first === first + && cached.last === last + && cached.streamingText === streamingText + ) return cached.lines; + + const lines: string[] = []; + for (const item of this.agent.transcript) { + lines.push(this.theme.fg("muted", `[${formatClock(item.timestamp)}] ${item.role}`)); + const roleColor = item.role === "assistant" ? "text" : item.role === "toolResult" ? "dim" : "accent"; + for (const line of wrapTextWithAnsi(this.theme.fg(roleColor, item.text), Math.max(1, width))) lines.push(line); + lines.push(""); + } + if (streamingText) { + lines.push(this.theme.fg("warning", "[streaming] assistant")); + for (const line of wrapTextWithAnsi(streamingText, Math.max(1, width))) lines.push(line); + } + if (lines.length === 0) lines.push(this.theme.fg("muted", "(transcript is empty)")); + this.transcriptCache = { width, length, first, last, streamingText, lines }; + return lines; + } + + render(width: number): string[] { + const inner = Math.max(1, width - 4); + const allLines = this.transcriptLines(inner); + const maxOffset = Math.max(0, allLines.length - DETAIL_VIEW_LINES); + this.scrollOffset = Math.min(this.scrollOffset, maxOffset); + const end = Math.max(0, allLines.length - this.scrollOffset); + const start = Math.max(0, end - DETAIL_VIEW_LINES); + const visible = allLines.slice(start, end); + const status = this.theme.fg(statusColor(this.agent.status), `${statusIcon(this.agent.status)} ${this.agent.status}`); + const body = [ + ` ${status} · ${this.agent.model} · effort ${this.agent.effort} · ${formatDuration(Date.now() - this.agent.createdAt)}`, + this.theme.fg("dim", ` Task: ${truncateChars(this.agent.prompt.replace(/\s+/g, " "), 180)}`), + this.theme.fg( + "dim", + ` Usage: ↑${formatTokens(this.agent.usage.input)} ↓${formatTokens(this.agent.usage.output)}${this.agent.usage.cost.total ? ` $${this.agent.usage.cost.total.toFixed(4)}` : ""}`, + ), + this.theme.fg("borderMuted", " " + "─".repeat(Math.max(0, inner - 1))), + ...visible.map((line) => ` ${line}`), + this.theme.fg("borderMuted", " " + "─".repeat(Math.max(0, inner - 1))), + this.theme.fg( + "dim", + ` ↑↓/pg scroll${this.scrollOffset ? ` · ${this.scrollOffset} lines below` : ""} · m model · e effort · u steer · q queue · x terminate · b back`, + ), + ]; + return frameLines(this.theme, this.agent.id, body, width); + } +} + +async function selectOverlay( + ctx: ExtensionContext, + title: string, + items: SelectItem[], +): Promise { + if (ctx.mode !== "tui") return undefined; + return ctx.ui.custom( + (tui, theme, _keybindings, done) => { + const container = new Container(); + container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text))); + container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0)); + const list = new SelectList(items, Math.min(12, Math.max(1, items.length)), { + selectedPrefix: (text) => theme.fg("accent", text), + selectedText: (text) => theme.fg("accent", text), + description: (text) => theme.fg("muted", text), + scrollInfo: (text) => theme.fg("dim", text), + noMatch: (text) => theme.fg("warning", text), + }); + list.onSelect = (item) => done(item.value); + list.onCancel = () => done(undefined); + container.addChild(list); + container.addChild(new Text(theme.fg("dim", "↑↓ navigate · enter select · esc cancel"), 1, 0)); + container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text))); + return { + render: (width) => container.render(width), + invalidate: () => container.invalidate(), + handleInput: (data) => { + list.handleInput(data); + tui.requestRender(); + }, + }; + }, + { overlay: true, overlayOptions: { anchor: "center", width: "70%", maxHeight: "80%", minWidth: 48 } }, + ); +} + +export async function showManager(manager: SubagentManager, ctx: ExtensionContext): Promise { + if (ctx.mode !== "tui") { + ctx.ui.notify("The subagent manager is only available in TUI mode", "warning"); + return; + } + + let detailId: string | undefined; + while (true) { + let result: ManagerDialogResult; + const fromDetail = Boolean(detailId && manager.agents.has(detailId)); + if (fromDetail && detailId) { + const agent = manager.getAgent(detailId); + result = await ctx.ui.custom( + (tui, theme, keybindings, done) => new AgentDetailDialog(agent, tui, theme, keybindings, done), + { overlay: true, overlayOptions: { anchor: "center", width: "85%", maxHeight: "90%", minWidth: 56 } }, + ); + } else { + detailId = undefined; + result = await ctx.ui.custom( + (tui, theme, keybindings, done) => new AgentListDialog(manager, tui, theme, keybindings, done), + { overlay: true, overlayOptions: { anchor: "center", width: "85%", maxHeight: "85%", minWidth: 56 } }, + ); + } + + if (!result || result.action === "close") return; + if (result.action === "back") { + detailId = undefined; + continue; + } + if (result.action === "view") { + detailId = result.id; + continue; + } + + detailId = fromDetail ? result.id : undefined; + try { + if (result.action === "model") { + const models = await manager.availableModels(ctx); + const selected = await selectOverlay( + ctx, + `Model for ${result.id}`, + models.map((model) => ({ value: modelName(model), label: model.id, description: `${model.provider} · ${model.name}` })), + ); + if (selected) await manager.configure(ctx, result.id, { model: selected }); + } else if (result.action === "effort") { + const selected = await selectOverlay( + ctx, + `Effort for ${result.id}`, + THINKING_LEVELS.map((level) => ({ value: level, label: level })), + ); + if (selected) await manager.configure(ctx, result.id, { effort: selected as SubagentEffort }); + } else if (result.action === "urgent" || result.action === "queue") { + const message = await ctx.ui.input( + result.action === "urgent" ? `Steer ${result.id}` : `Queue for ${result.id}`, + "Instruction for the subagent", + ); + if (message) await manager.send(result.id, message, result.action === "urgent" ? "urgent" : "normal"); + } else if (result.action === "terminate") { + const confirmed = await ctx.ui.confirm("Terminate subagent?", `Stop ${result.id} and release its resources?`); + if (confirmed) await manager.terminate(result.id); + } + } catch (error) { + ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); + } + } +} diff --git a/extensions/subagents/usage.ts b/extensions/subagents/usage.ts new file mode 100644 index 0000000..00f0e6b --- /dev/null +++ b/extensions/subagents/usage.ts @@ -0,0 +1,104 @@ +import type { FooterUsage } from "../footer-events.js"; +import type { PersistedUsageState, Usage } from "./types.js"; + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function zeroUsage(): Usage { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +export function parseUsage(value: unknown): Usage | undefined { + if (!isRecord(value) || !isRecord(value.cost)) return undefined; + const cost = value.cost; + const fields = ["input", "output", "cacheRead", "cacheWrite", "totalTokens"] as const; + if (fields.some((field) => typeof value[field] !== "number" || !Number.isFinite(value[field]))) return undefined; + const costFields = ["input", "output", "cacheRead", "cacheWrite", "total"] as const; + if (costFields.some((field) => typeof cost[field] !== "number" || !Number.isFinite(cost[field]))) return undefined; + return { + input: value.input as number, + output: value.output as number, + cacheRead: value.cacheRead as number, + cacheWrite: value.cacheWrite as number, + totalTokens: value.totalTokens as number, + cost: { + input: cost.input as number, + output: cost.output as number, + cacheRead: cost.cacheRead as number, + cacheWrite: cost.cacheWrite as number, + total: cost.total as number, + }, + }; +} + +export function parsePersistedUsageState(value: unknown): PersistedUsageState | undefined { + if (!isRecord(value)) return undefined; + const total = parseUsage(value.total); + const accounted = parseUsage(value.accounted); + return total && accounted ? { total, accounted } : undefined; +} + +export function cloneUsage(usage: Usage): Usage { + return { + input: usage.input, + output: usage.output, + cacheRead: usage.cacheRead, + cacheWrite: usage.cacheWrite, + totalTokens: usage.totalTokens, + cost: { ...usage.cost }, + }; +} + +export function addUsage(target: Usage, usage: Usage | undefined): void { + if (!usage) return; + target.input += usage.input || 0; + target.output += usage.output || 0; + target.cacheRead += usage.cacheRead || 0; + target.cacheWrite += usage.cacheWrite || 0; + target.totalTokens += usage.totalTokens || 0; + target.cost.input += usage.cost?.input || 0; + target.cost.output += usage.cost?.output || 0; + target.cost.cacheRead += usage.cost?.cacheRead || 0; + target.cost.cacheWrite += usage.cost?.cacheWrite || 0; + target.cost.total += usage.cost?.total || 0; +} + +export function subtractUsage(total: Usage, accounted: Usage): Usage { + return { + input: Math.max(0, total.input - accounted.input), + output: Math.max(0, total.output - accounted.output), + cacheRead: Math.max(0, total.cacheRead - accounted.cacheRead), + cacheWrite: Math.max(0, total.cacheWrite - accounted.cacheWrite), + totalTokens: Math.max(0, total.totalTokens - accounted.totalTokens), + cost: { + input: Math.max(0, total.cost.input - accounted.cost.input), + output: Math.max(0, total.cost.output - accounted.cost.output), + cacheRead: Math.max(0, total.cost.cacheRead - accounted.cost.cacheRead), + cacheWrite: Math.max(0, total.cost.cacheWrite - accounted.cost.cacheWrite), + total: Math.max(0, total.cost.total - accounted.cost.total), + }, + }; +} + +export function hasUsage(usage: Usage): boolean { + return ( + usage.input > 0 || + usage.output > 0 || + usage.cacheRead > 0 || + usage.cacheWrite > 0 || + usage.totalTokens > 0 || + usage.cost.total > 0 + ); +} + +export function asFooterUsage(usage: Usage): FooterUsage { + return cloneUsage(usage); +} diff --git a/tests/subagents.test.ts b/tests/subagents.test.ts index b35d5b7..9074fca 100644 --- a/tests/subagents.test.ts +++ b/tests/subagents.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent"; -import { +import subagentsExtension, { abortRunningSubagentSessions, appendBoundedStreamingText, countsAgainstSubagentLimit, @@ -18,6 +18,30 @@ import { subagentModelGuidance, subagentModelRuntime, } from "../extensions/subagents.ts"; +import { stringifyCompact, truncateChars, truncateToolOutput } from "../extensions/subagents/format.ts"; +import { MAX_TOOL_OUTPUT_BYTES } from "../extensions/subagents/types.ts"; +import { AgentDetailDialog, FooterNavigationEditor } from "../extensions/subagents/ui.ts"; + +test("subagent entrypoint preserves its tool, command, and lifecycle registrations", () => { + const tools: string[] = []; + const commands: string[] = []; + const hooks: string[] = []; + const events: string[] = []; + const pi = { + events: { on(name: string) { events.push(name); }, emit() {} }, + on(name: string) { hooks.push(name); }, + registerTool(tool: { name: string }) { tools.push(tool.name); }, + registerCommand(name: string) { commands.push(name); }, + getActiveTools() { return []; }, + }; + + subagentsExtension(pi as never); + + assert.deepEqual(tools, ["subagent_create", "subagent_read", "subagent_send", "subagent_configure", "subagent_terminate"]); + assert.deepEqual(commands, ["subagents", "subagents-cleanup"]); + assert.deepEqual(hooks, ["before_agent_start", "session_start", "input", "agent_start", "agent_settled", "session_shutdown"]); + assert.deepEqual(events, ["vessup:subagents:abort"]); +}); const usage = { input: 10, @@ -28,6 +52,67 @@ const usage = { cost: { input: 0.1, output: 0.2, cacheRead: 0.01, cacheWrite: 0.02, total: 0.33 }, }; +test("compact formatting handles non-JSON values and preserves Unicode code points", () => { + assert.equal(stringifyCompact(undefined), "undefined"); + assert.equal(stringifyCompact(Symbol("value")), "Symbol(value)"); + assert.equal(stringifyCompact("🙂", 2), '"🙂…'); + assert.equal(truncateChars("a🙂b", 2), "a🙂\n[… 1 characters omitted]"); +}); + +test("subagent tool output truncates at a valid UTF-8 byte boundary", () => { + const source = `a${"🙂".repeat(Math.ceil(MAX_TOOL_OUTPUT_BYTES / 4) + 10)}`; + const result = truncateToolOutput(source); + const output = result.split("\n\n[Output truncated:", 1)[0]!; + assert.ok(Buffer.byteLength(output, "utf8") <= MAX_TOOL_OUTPUT_BYTES); + assert.equal(output.endsWith("�"), false); + assert.match(result, new RegExp(`Output truncated: ${Buffer.byteLength(source, "utf8") - Buffer.byteLength(output, "utf8")} bytes omitted`)); +}); + +test("subagent footer editor preserves key-release preferences", () => { + const base = { focused: false, wantsKeyRelease: true }; + const editor = new FooterNavigationEditor(base as never, {} as never, {} as never, () => {}); + assert.equal(editor.wantsKeyRelease, true); + editor.wantsKeyRelease = false; + assert.equal(base.wantsKeyRelease, false); +}); + +test("subagent detail rendering caches wrapped transcript lines", () => { + let contentFormats = 0; + const agent = { + id: "worker", + status: "completed", + model: "provider/model", + effort: "medium", + createdAt: Date.now(), + prompt: "task", + usage, + transcript: [{ timestamp: Date.now(), role: "assistant", text: "hello" }], + streamingText: "", + }; + const theme = { + fg(_color: string, text: string) { + if (text === "hello") contentFormats++; + return text; + }, + }; + const dialog = new AgentDetailDialog(agent as never, { requestRender() {} } as never, theme as never, {} as never, () => {}); + try { + dialog.render(80); + dialog.render(80); + assert.equal(contentFormats, 1); + agent.streamingText = "streaming"; + dialog.render(80); + assert.equal(contentFormats, 2); + dialog.render(79); + assert.equal(contentFormats, 3); + agent.transcript.push({ timestamp: Date.now(), role: "assistant", text: "next" }); + dialog.render(79); + assert.equal(contentFormats, 4); + } finally { + dialog.dispose(); + } +}); + test("creating agents reserve capacity before their session exists", () => { assert.equal(countsAgainstSubagentLimit({ status: "creating" }), true); assert.equal(countsAgainstSubagentLimit({ status: "completed", session: {} }), true); diff --git a/tests/web-server.test.ts b/tests/web-server.test.ts index f1ec7e4..7bf761b 100644 --- a/tests/web-server.test.ts +++ b/tests/web-server.test.ts @@ -830,6 +830,7 @@ test("TUI metadata changes update every connected web catalog", async () => { let resolveRenamed!: () => void; let resolveBranch!: () => void; let resolvePullRequest!: () => void; + let resolveCompletedPullRequest!: () => void; let resolveWorking!: () => void; let resolveIdle!: () => void; let resolvePreview!: () => void; @@ -841,6 +842,7 @@ test("TUI metadata changes update every connected web catalog", async () => { const renamed = new Promise((resolve) => { resolveRenamed = resolve; }); const branchUpdated = new Promise((resolve) => { resolveBranch = resolve; }); const pullRequestUpdated = new Promise((resolve) => { resolvePullRequest = resolve; }); + const completedPullRequestUpdated = new Promise((resolve) => { resolveCompletedPullRequest = resolve; }); const working = new Promise((resolve) => { resolveWorking = resolve; }); const idle = new Promise((resolve) => { resolveIdle = resolve; }); const previewUpdated = new Promise((resolve) => { resolvePreview = resolve; }); @@ -870,6 +872,7 @@ test("TUI metadata changes update every connected web catalog", async () => { if (message.session.name === "Renamed in TUI") resolveRenamed(); if (message.session.branch === "feature/live-metadata") resolveBranch(); if (message.session.pullRequest?.number === 3) resolvePullRequest(); + if (message.session.pullRequest?.number === 4) resolveCompletedPullRequest(); if (message.session.status === "working" && !message.session.compaction) { observedWorking = true; resolveWorking(); @@ -899,8 +902,10 @@ test("TUI metadata changes update every connected web catalog", async () => { await Promise.race([pullRequestUpdated, timedOut]); agent.send(JSON.stringify({ type: "agent.event", sessionId: tuiId, event: { type: "agent_start" } })); await Promise.race([working, timedOut]); + await writeFile(prMetadataFile, JSON.stringify({ number: 4, url: "https://github.com/Vessup/pi-kit/pull/4" })); agent.send(JSON.stringify({ type: "agent.event", sessionId: tuiId, event: { type: "agent_end" } })); await Promise.race([idle, timedOut]); + await Promise.race([completedPullRequestUpdated, timedOut]); agent.send(JSON.stringify({ type: "agent.event", sessionId: tuiId, event: { type: "message_end", message: { role: "assistant", content: "Latest assistant preview" } } })); agent.send(JSON.stringify({ type: "agent.update", session: { ...tuiSession, name: "Renamed in TUI", branch: "feature/live-metadata", preview: "Stale first preview", updatedAt: Date.now() } })); agent.send(JSON.stringify({ type: "agent.event", sessionId: tuiId, event: { type: "session_info_changed", name: "Preview barrier" } })); diff --git a/tests/web-worktrees.test.ts b/tests/web-worktrees.test.ts index e935070..a481f6e 100644 --- a/tests/web-worktrees.test.ts +++ b/tests/web-worktrees.test.ts @@ -320,16 +320,22 @@ test("async managed cleanup yields before spawning Git verification", async () = import { removeManagedWorktreeAsync } from ${JSON.stringify(moduleUrl)}; const started = Date.now(); const cleanup = removeManagedWorktreeAsync({ path: "/missing/topic", repoRoot: "/missing", name: "topic", branch: "topic", branchCreated: true }); - console.log(Date.now() - started); + const elapsed = Date.now() - started; + if (elapsed >= 500) { + console.error(\`removeManagedWorktreeAsync blocked for \${elapsed}ms before returning\`); + process.exitCode = 2; + } await cleanup.catch(() => undefined); `], env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH ?? ""}` }, - stdout: "pipe", + stdout: "ignore", stderr: "pipe", }); - const elapsed = Number((await new Response(probe.stdout).text()).trim()); - expect(await probe.exited).toBe(0); - expect(elapsed).toBeLessThan(500); + const [stderr, exitCode] = await Promise.all([ + new Response(probe.stderr).text(), + probe.exited, + ]); + if (exitCode !== 0) throw new Error(`Async cleanup probe failed: ${stderr.trim() || `exit ${exitCode}`}`); }, 5_000); test("managed worktree metadata is parsed and cleanup removes its checkout and branch", async () => { diff --git a/web/server/index.ts b/web/server/index.ts index 79334c1..7f2a53a 100644 --- a/web/server/index.ts +++ b/web/server/index.ts @@ -1136,6 +1136,7 @@ async function createManagedSessionUnlocked(cwd: string, name?: string, sessionF || event.type === "message_end"; if (catalogChanged) broadcastSessionToAll(record); else broadcast(record.id, { type: "server.session", session: sessionToClientPayload(record) } satisfies ServerSessionMessage); + if (event.type === "agent_end") void hydrateGitMetadata(record); }, onExit: () => { record.status = "offline"; @@ -1784,6 +1785,9 @@ async function handleAgentMessage(socket: Bun.ServerWebSocket, } else if (subagentsChanged) { broadcast(record.id, { type: "server.session", session: sessionToClientPayload(record) } satisfies ServerSessionMessage); } + // PRs are commonly opened during an agent run without changing branches. + // Refresh after completion so the catalog does not retain the pre-PR lookup. + if (event.event.type === "agent_end") void hydrateGitMetadata(record); } return; }