import { Buffer } from "node:buffer"; import { StringEnum } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Text, type Component } from "@earendil-works/pi-tui"; import { Type, type Static } from "typebox"; import { SubagentManager, type SubagentSessionSnapshot, type WaitResult, type WaitUntil, type WaitWorkStatus, } from "./subagent-manager.js"; import { isReservedChildShutdownMessage, RESERVED_CHILD_SHUTDOWN_MESSAGE } from "./session-runner.js"; import { boundedPreview, formatJobStatusList, formatSingleJobStatus, projectJobStatus, sanitizeTerminalText, selectStatusList, type JobStatus, type StatusState, } from "./job-status.js"; import { CAPTURED_TEXT_MAX_BYTES, COLLECTED_OUTPUT_MAX_BYTES, capCollectedPayload, capCollectedPayloadWithDiagnostics, formatCollectedResult, truncateUtf8, } from "./output.js"; import { buildPublicAgentDiscovery, formatUnknownProfileDiagnostic, type PublicAgentProfile } from "./profile-discovery.js"; import { REPORT_MAX_BYTES, TASK_MAX_BYTES, THINKING_LEVELS, type AgentProfile, type JobRequest, type LaunchThinkingSource, type ResultState, type SessionState, type SubagentReport, type SubagentSession, type ThinkingLevel, type WorkState, } from "./types.js"; const MODEL_PATTERN = "^(?!\\s)(?![\\s\\S]*\\s$)[^\\u0000-\\u001f\\u007f-\\u009f]+$"; const INBOX_REPORT_LIMIT = 64; const INBOX_MESSAGE_MAX_BYTES = 40 * 1024; const INBOX_METADATA_MAX_BYTES = 24; class BoundedText implements Component { private readonly text: Text; constructor(value: string) { this.text = new Text(value, 0, 0); } render(width: number): string[] { const lines = this.text.render(width); const bounded: string[] = []; let bytes = 0; for (const line of lines) { const separatorBytes = bounded.length === 0 ? 0 : 1; const available = COLLECTED_OUTPUT_MAX_BYTES - bytes - separatorBytes; if (available <= 0) break; const safeLine = truncateUtf8(line, available).text; bounded.push(safeLine); bytes += separatorBytes + Buffer.byteLength(safeLine, "utf8"); if (safeLine.length < line.length) break; } return bounded; } invalidate(): void { this.text.invalidate(); } } const StartTaskFields = { task: Type.String({ minLength: 1, maxLength: TASK_MAX_BYTES }), agent: Type.Optional(Type.String({ default: "generic" })), writeAccess: Type.Optional(Type.Boolean({ default: false })), cwd: Type.Optional(Type.String()), model: Type.Optional(Type.String({ minLength: 1, pattern: MODEL_PATTERN })), }; const DisabledStartTask = Type.Object(StartTaskFields, { additionalProperties: false, }); const EnabledStartTask = Type.Object({ ...StartTaskFields, thinkingLevel: Type.Optional(StringEnum(THINKING_LEVELS)), }, { additionalProperties: false, }); const DisabledStartParams = Type.Object({ tasks: Type.Array(DisabledStartTask, { minItems: 1, maxItems: 8 }), }, { additionalProperties: false, }); const EnabledStartParams = Type.Object({ tasks: Type.Array(EnabledStartTask, { minItems: 1, maxItems: 8 }), }, { additionalProperties: false, }); export const StartParams = DisabledStartParams; export const startParamsFor = (allowThinkingOverrides: boolean) => allowThinkingOverrides ? EnabledStartParams : DisabledStartParams; export const AgentsParams = Type.Object({}, { additionalProperties: false }); export const StatusParams = Type.Object({ id: Type.Optional(Type.String()) }, { additionalProperties: false }); export const InboxParams = Type.Object({ id: Type.Optional(Type.String()), }, { additionalProperties: false }); export const ControlParams = Type.Object({ action: StringEnum(["cancel", "collect", "discard", "close"] as const), ids: Type.Array(Type.String(), { minItems: 1, maxItems: 8 }), }, { additionalProperties: false }); export const WaitParams = Type.Object({ ids: Type.Array(Type.String(), { minItems: 1, maxItems: 8 }), until: Type.Optional(StringEnum(["any", "all"] as const, { default: "all" })), timeoutMs: Type.Optional(Type.Integer({ minimum: 100, maximum: 300_000, default: 60_000, })), }, { additionalProperties: false }); export const SendParams = Type.Object({ id: Type.String({ pattern: "^job-[1-9][0-9]*$" }), message: Type.String({ minLength: 1, maxLength: CAPTURED_TEXT_MAX_BYTES }), delivery: Type.Optional(StringEnum(["follow_up", "redirect"] as const, { default: "follow_up" })), }, { additionalProperties: false }); export type StartInput = Static; export type AgentsInput = Static; export type StatusInput = Static; export type InboxInput = Static; export type ControlInput = Static; export type WaitInput = Static; export type SendInput = Static; export type WriteConfirmation = "approved" | "declined" | "unavailable"; export interface ToolServices { manager: SubagentManager; getProfiles(): Promise>; confirmWritable(requests: readonly JobRequest[], ctx: ExtensionContext): Promise; defaults(ctx: ExtensionContext): { cwd: string; parentModel?: string; thinkingLevel?: string }; } export type PublicState = StatusState | "collected" | "discarded"; export interface PublicJobDetail { id: string; state: PublicState; sessionState: SessionState; generationNumber: number; workState: WorkState; resultState: ResultState; task: string; launchModel?: string; launchThinkingLevel?: ThinkingLevel; launchThinkingSource?: LaunchThinkingSource; } export interface PublicInboxReport { id: string; session: string; generation: number; kind: "progress" | "help_request"; timestamp: number; } interface DisplayInboxReport extends PublicInboxReport { message: string; } export interface InboxOmission { session: string; omittedReports: number; } type PublicSession = Readonly; const publicState = (session: PublicSession): PublicState => { if (session.generation.resultState === "collected") return "collected"; if (session.generation.resultState === "discarded") return "discarded"; if (session.state === "failed" || session.generation.state === "failed") return "failed"; if (session.state === "closed" || session.generation.state === "cancelled") return "cancelled"; if (session.state === "opening" || session.generation.state === "queued") return "queued"; if ( session.generation.state === "running" || session.generation.state === "cancelling" || session.generation.state === "waiting_for_parent" ) return "running"; return "completed"; }; const toPublicJobDetail = (session: PublicSession, task = session.request.task): PublicJobDetail => ({ id: boundedPreview(session.id), state: publicState(session), sessionState: session.state, generationNumber: session.generation.number, workState: session.generation.state, resultState: session.generation.resultState, task: boundedPreview(task), ...(session.launchModel ? { launchModel: boundedPreview(session.launchModel) } : {}), ...(session.launchThinkingLevel ? { launchThinkingLevel: session.launchThinkingLevel } : {}), ...(session.launchThinkingSource ? { launchThinkingSource: session.launchThinkingSource } : {}), }); export interface ToolDetails { jobs: PublicJobDetail[]; diagnostics: string[]; operation?: "agents" | "start" | "status" | "inbox" | "wait" | "send" | "cancel" | "collect" | "discard" | "close"; profiles?: PublicAgentProfile[]; omittedProfiles?: number; statuses?: JobStatus[]; omittedStatuses?: number; inboxReports?: PublicInboxReport[]; omittedReports?: InboxOmission[]; } export type WaitToolDetails = WaitResult; export interface AgentsToolResponse { content: Array<{ type: "text"; text: string }>; details: ToolDetails & { operation: "agents"; profiles: PublicAgentProfile[]; omittedProfiles: number; }; } export interface ToolResponse { content: Array<{ type: "text"; text: string }>; details: ToolDetails | WaitToolDetails; } const response = ( content: string, jobs: readonly PublicSession[] = [], diagnostics: string[] = [], operation?: ToolDetails["operation"], ): ToolResponse & { details: ToolDetails } => ({ content: [{ type: "text", text: content }], details: { jobs: jobs.map((job) => toPublicJobDetail(job)), diagnostics, operation }, }); const unknownJobDiagnostic = (id: string): string => `Unknown job: ${boundedPreview(id)}`; const toRequest = ( task: StartInput["tasks"][number], allowThinkingOverrides: boolean, ): JobRequest => ({ task: task.task, agent: task.agent ?? "generic", writeAccess: task.writeAccess ?? false, cwd: task.cwd, model: task.model, ...(allowThinkingOverrides && task.thinkingLevel !== undefined ? { thinkingLevel: task.thinkingLevel } : {}), }); const summary = (sessions: readonly PublicSession[]): string => sessions.map((session) => `${boundedPreview(session.id)} (${publicState(session)})`).join(", "); export async function listAgents(services: ToolServices): Promise { const profiles = await services.getProfiles(); const discovery = buildPublicAgentDiscovery([...profiles.values()]); return { content: [{ type: "text", text: discovery.content }], details: { jobs: [], diagnostics: [], operation: "agents", profiles: discovery.profiles, omittedProfiles: discovery.omittedProfiles, }, }; } export async function startJobs( input: StartInput, services: ToolServices, ctx: ExtensionContext, allowThinkingOverrides = false, ): Promise { if (input.tasks.length > 8) return response("A start batch accepts at most 8 jobs.", [], ["A start batch accepts at most 8 jobs."], "start"); if (input.tasks.some((task) => Buffer.byteLength(task.task, "utf8") > TASK_MAX_BYTES)) { const diagnostic = `Task exceeds ${TASK_MAX_BYTES} UTF-8 bytes.`; return response(diagnostic, [], [diagnostic], "start"); } if (input.tasks.some((task) => isReservedChildShutdownMessage(task.task))) { return response(RESERVED_CHILD_SHUTDOWN_MESSAGE, [], [RESERVED_CHILD_SHUTDOWN_MESSAGE], "start"); } const requests = input.tasks.map((task) => toRequest(task, allowThinkingOverrides)); const profiles = await services.getProfiles(); const unknown = requests.find((request) => !profiles.has(request.agent)); if (unknown) { const diagnostic = formatUnknownProfileDiagnostic(unknown.agent, [...profiles.values()]); return response(diagnostic, [], [diagnostic], "start"); } const writable = requests.filter((request) => request.writeAccess); if (writable.length > 0) { const confirmation = await services.confirmWritable(writable, ctx); if (confirmation !== "approved") { const diagnostic = confirmation === "declined" ? "Writable jobs were declined." : "Writable confirmation requires interactive UI."; return response(diagnostic, [], [diagnostic], "start"); } } const jobs = await services.manager.enqueue(requests, profiles, services.defaults(ctx)); return response(`Started ${jobs.length} job${jobs.length === 1 ? "" : "s"}: ${summary(jobs)}.`, jobs, [], "start"); } export async function statusJobs(input: StatusInput, services: ToolServices): Promise { const now = Date.now(); if (input.id) { const session = services.manager.get(input.id); if (!session) { const diagnostic = unknownJobDiagnostic(input.id); return response(diagnostic, [], [diagnostic], "status"); } const status = projectJobStatus(session, now); return { content: [{ type: "text", text: formatSingleJobStatus(status, now) }], details: { jobs: [toPublicJobDetail(session, status.task)], statuses: [status], diagnostics: [], operation: "status" }, }; } const sessions = services.manager.list(); const selected = selectStatusList(sessions, now); return { content: [{ type: "text", text: formatJobStatusList(selected, now) }], details: { jobs: detailJobsForStatuses(sessions, selected.statuses), statuses: selected.statuses, omittedStatuses: selected.omitted, diagnostics: [], operation: "status", }, }; } const detailJobsForStatuses = ( sessions: readonly SubagentSessionSnapshot[], statuses: readonly JobStatus[], ): PublicJobDetail[] => { const sessionsById = new Map(sessions.map((session) => [session.id, session])); return statuses.flatMap((status) => { const session = sessionsById.get(status.id); return session ? [toPublicJobDetail(session, status.task)] : []; }); }; const toDisplayInboxReport = (report: SubagentReport): DisplayInboxReport => ({ id: boundedPreview(report.reportId, INBOX_METADATA_MAX_BYTES, INBOX_METADATA_MAX_BYTES), session: boundedPreview(report.sessionId, INBOX_METADATA_MAX_BYTES, INBOX_METADATA_MAX_BYTES), generation: report.generation, kind: report.kind === "help_request" ? "help_request" : "progress", timestamp: report.timestamp, message: truncateUtf8(sanitizeTerminalText(report.message.replace(/\t/gu, " ")), REPORT_MAX_BYTES).text, }); const inboxOmissions = (sessions: readonly SubagentSessionSnapshot[]): InboxOmission[] => sessions.map((session) => ({ session: boundedPreview(session.id, INBOX_METADATA_MAX_BYTES, INBOX_METADATA_MAX_BYTES), omittedReports: Math.max(0, session.omittedReports), })); const formatInboxReport = (report: DisplayInboxReport): string => [ `- ${report.id} · ${report.session} · gen ${report.generation} · ${report.kind} · ${report.timestamp}`, ` ${report.message}`, ].join("\n"); const formatInboxOmissions = (omissions: readonly InboxOmission[]): string[] => { const formatted: string[] = []; for (const omission of omissions) { if (omission.omittedReports <= 0) continue; formatted.push(`Reports omitted: ${omission.session} (${omission.omittedReports}).`); } return formatted; }; export async function readInbox(input: InboxInput, services: ToolServices): Promise { let selected: readonly SubagentSessionSnapshot[]; if (input.id === undefined) { selected = services.manager.list(); } else { const session = services.manager.get(input.id); if (!session) { const diagnostic = unknownJobDiagnostic(input.id); return { content: [{ type: "text", text: diagnostic }], details: { jobs: [], diagnostics: [diagnostic], operation: "inbox", inboxReports: [], omittedReports: [] }, }; } selected = [session]; } const omittedReports = inboxOmissions(selected); const displayedReports = services.manager.readInbox(input.id, { maxReports: INBOX_REPORT_LIMIT, maxMessageBytes: INBOX_MESSAGE_MAX_BYTES, }).map(toDisplayInboxReport); const inboxReports = displayedReports.map(({ message: _message, ...report }) => report); const omissions = formatInboxOmissions(omittedReports); const content = displayedReports.length > 0 ? ["Unread subagent reports:", ...displayedReports.map(formatInboxReport), ...omissions].join("\n") : ["No unread subagent reports.", ...omissions].join("\n"); return { content: [{ type: "text", text: content }], details: { jobs: [], diagnostics: [], operation: "inbox", inboxReports, omittedReports }, }; } const waitFacts = (job: WaitWorkStatus): string => [ `gen ${job.generation}`, job.state, ...(job.resultReady ? ["result ready"] : []), ...(job.queuedFollowUp ? ["follow-up queued"] : []), ...(job.blockedByResult ? ["blocked by result"] : []), ].join(" · "); const waitSummary = (jobs: readonly WaitWorkStatus[]): string => jobs.map((job) => `${boundedPreview(job.id)} (${waitFacts(job)})`).join(", "); const expectedWaitDiagnostic = (error: unknown): string | undefined => { if (!(error instanceof Error)) return undefined; const duplicate = /^Duplicate session ID: (.+)$/u.exec(error.message); if (duplicate) return `Duplicate job ID: ${boundedPreview(duplicate[1] ?? "")}`; const unknown = /^Unknown subagent session: (.+)$/u.exec(error.message); if (unknown) return unknownJobDiagnostic(unknown[1] ?? ""); return undefined; }; export async function waitJobs( input: WaitInput, services: ToolServices, signal?: AbortSignal, ): Promise { const until: WaitUntil = input.until ?? "all"; const timeoutMs = input.timeoutMs ?? 60_000; let result: WaitResult; try { result = await services.manager.waitFor({ ids: input.ids, until, timeoutMs, signal }); } catch (error) { const diagnostic = expectedWaitDiagnostic(error); if (!diagnostic) throw error; return response(diagnostic, [], [diagnostic], "wait"); } const jobs = waitSummary(result.jobs); const content = formatWaitContent(result.outcome, timeoutMs, jobs); return { content: [{ type: "text", text: content }], details: result }; } const SEND_DELIVERY_FAILURE_DIAGNOSTIC = "Unable to send message. Check the job status and try again."; const formatWaitContent = (outcome: WaitResult["outcome"], timeoutMs: number, jobs: string): string => { if (outcome === "completed") return `Wait completed: ${jobs}.`; if (outcome === "timed_out") { return `Wait timed out after ${timeoutMs} ms: ${jobs}.\nDo not wait again immediately; continue other work or return control.`; } return `Wait aborted: ${jobs}.`; }; const expectedSendDiagnostic = (delivery: SendInput["delivery"], id: string, error: unknown): string | undefined => { if (!(error instanceof Error)) return undefined; if (/^Unknown subagent session: .+$/u.test(error.message)) return unknownJobDiagnostic(id); const unavailable = /^Session .+ is (opening|closing|closed|failed) and cannot receive messages$/u.exec(error.message); if (unavailable) return `Cannot send to job: ${boundedPreview(id)} is ${unavailable[1]} and cannot receive messages.`; if (/^Session .+ already has a queued help reply$/u.test(error.message)) { return `Cannot send help reply to job: ${boundedPreview(id)} already has a queued help reply.`; } if (/^Session .+ already has a queued follow-up$/u.test(error.message)) { return `Cannot send follow-up to job: ${boundedPreview(id)} already has a queued follow-up.`; } if (delivery === "redirect" && /^Session .+ is not running; use follow_up instead$/u.test(error.message)) { return `Cannot redirect job: ${boundedPreview(id)} is not running; use follow_up instead.`; } if (delivery === "redirect" && /^Session .+ has no running child$/u.test(error.message)) { return `Cannot redirect job: ${boundedPreview(id)} has no running child.`; } return undefined; }; export async function sendJob(input: SendInput, services: ToolServices): Promise { if (Buffer.byteLength(input.message, "utf8") > CAPTURED_TEXT_MAX_BYTES) { const diagnostic = `Message exceeds ${CAPTURED_TEXT_MAX_BYTES} UTF-8 bytes.`; return response(diagnostic, [], [diagnostic], "send"); } if (isReservedChildShutdownMessage(input.message)) { return response(RESERVED_CHILD_SHUTDOWN_MESSAGE, [], [RESERVED_CHILD_SHUTDOWN_MESSAGE], "send"); } const delivery = input.delivery ?? "follow_up"; const before = services.manager.get(input.id); const pendingHelpReportId = before?.pendingHelpReportId; try { await services.manager.send(input.id, input.message, delivery); } catch (error) { const diagnostic = expectedSendDiagnostic(delivery, input.id, error) ?? SEND_DELIVERY_FAILURE_DIAGNOSTIC; return response(diagnostic, [], [diagnostic], "send"); } const session = services.manager.get(input.id); if (!session) throw new Error(`Unknown subagent session: ${input.id}`); const helpReplyQueued = before?.generation.state === "waiting_for_parent" && session.generation.state === "waiting_for_parent" && session.pendingHelpReportId === pendingHelpReportId; const content = formatSendContent( before?.generation.state === "waiting_for_parent", helpReplyQueued, delivery, session.queuedFollowUp || session.generation.state === "queued", input.id, ); return response(content, [session], [], "send"); } const formatSendContent = ( wasWaitingForParent: boolean, helpReplyQueued: boolean, delivery: SendInput["delivery"], followUpQueued: boolean, id: string, ): string => { const displayId = boundedPreview(id); if (wasWaitingForParent) { if (helpReplyQueued) return `Queued help reply to job: ${displayId}.`; return `Sent help reply to job: ${displayId}.`; } if (delivery === "redirect") return `Redirected running job: ${displayId}.`; if (followUpQueued) return `Queued follow-up for job: ${displayId}.`; return `Started follow-up for job: ${displayId}.`; }; const expectedControlDiagnostic = (action: ControlInput["action"], id: string, error: unknown): string | undefined => { if (action === "close") return `Unable to close job: ${boundedPreview(id)}. Check the job status and try again.`; if (!(error instanceof Error)) return undefined; if (action === "cancel" && /^Session .+ (?:has no cancellable work|has no cancellable running child)$/u.test(error.message)) { return `Cannot cancel job: ${boundedPreview(id)} has no cancellable work.`; } if ((action === "collect" || action === "discard") && /^Session .+ generation \d+ has no ready result to (?:collect|discard)$/u.test(error.message)) { return `Cannot ${action} job: ${boundedPreview(id)} has no ready result.`; } return undefined; }; export async function controlJobs(input: ControlInput, services: ToolServices): Promise { const sessions: PublicSession[] = []; const diagnostics: string[] = []; const collectedResults: string[] = []; for (const id of input.ids) { if (!services.manager.get(id)) { diagnostics.push(unknownJobDiagnostic(id)); continue; } try { if (input.action === "cancel") sessions.push(await services.manager.cancel(id)); else if (input.action === "discard") sessions.push(services.manager.discard(id)); else if (input.action === "close") sessions.push(await services.manager.close(id)); else { const ready = services.manager.peekReadyResult(id); const formatted = formatCollectedResult(ready); try { const collected = services.manager.collect(id); collectedResults.push(formatted); sessions.push(collected); } catch (error) { diagnostics.push( expectedControlDiagnostic("collect", id, error) ?? `Unable to collect job: ${boundedPreview(id)}. Check the job status and try again.`, ); } } } catch (error) { const diagnostic = expectedControlDiagnostic(input.action, id, error); if (input.action === "collect" && !diagnostic) throw error; diagnostics.push( diagnostic ?? `Unable to ${input.action} job: ${boundedPreview(id)}. Check the job status and try again.`, ); } } if (input.action === "collect" && collectedResults.length > 0) { const content = capCollectedPayloadWithDiagnostics( collectedResults.join("\n\n---\n\n"), diagnostics, ); return response(content, sessions, diagnostics, "collect"); } const action = controlActionLabel(input.action); const compact = sessions.length > 0 ? `${action}: ${summary(sessions)}.` : "No jobs changed."; return response(diagnostics.length > 0 ? `${compact} ${diagnostics.join(" ")}` : compact, sessions, diagnostics, input.action); } const controlActionLabel = (action: ControlInput["action"]): string => { if (action === "discard") return "Discarded"; if (action === "close") return "Closed"; return "Cancelled"; }; const description = [ "Start self-contained background tasks.", "Jobs are read-only unless writes are needed.", "Only collected output enters context.", "Cancel, collect, and discard leave sessions open; close sessions explicitly when no further work is needed.", "Each job's collected result is capped at 50 KiB, and batched collection shares a 50 KiB aggregate cap.", "Request concise output, split broad investigations, and collect large results individually.", "Concurrent writable jobs should receive non-overlapping work.", ].join(" "); const iconForState = (state: PublicState | WorkState): string => { if (state === "completed") return "✓"; if (state === "failed" || state === "cancelled") return "✗"; if (state === "collected") return "↳"; if (state === "discarded") return "⌫"; return state === "queued" ? "○" : "…"; }; const waitOutcomeLabel = (outcome: WaitResult["outcome"]): string => { if (outcome === "completed") return "Wait completed"; if (outcome === "timed_out") return "Wait timed out"; return "Wait aborted"; }; const renderWaitResult = ( result: ToolResponse, expanded: boolean, theme: { fg(color: string, text: string): string }, ): string => { if (!("outcome" in result.details)) return ""; const { outcome, jobs, until, timeoutMs, elapsedMs } = result.details; const label = waitOutcomeLabel(outcome); const compact = [ label, ...jobs.map((job) => `${iconForState(job.state)} ${boundedPreview(job.id)} ${waitFacts(job)}`), ].join("\n"); const detail = expanded ? `Condition: ${until}\nConfigured timeout: ${timeoutMs} ms\nElapsed: ${elapsedMs} ms` : ""; return theme.fg("muted", [compact, detail].filter(Boolean).join("\n\n")); }; const renderAgentProfiles = ( result: AgentsToolResponse, expanded: boolean, theme: { fg(color: string, text: string): string }, ): string => { const profiles = result.details.profiles ?? []; const omitted = result.details.omittedProfiles ?? 0; const compactLines = [ "Available subagent profiles:", ...profiles.map((profile) => `- ${profile.name} — ${profile.description}`), ]; if (omitted > 0) { const suffix = omitted === 1 ? "" : "s"; compactLines.push(`- ${omitted} additional profile${suffix} omitted`); } const compact = compactLines.join("\n"); if (!expanded) return theme.fg("muted", compact); const detail = profiles.map((profile) => [ `${profile.name} — ${profile.description}`, ` Model: ${profile.model ?? "parent model (inherited)"}`, ` Thinking: ${profile.thinking ?? "parent thinking (inherited)"}`, ` Read-only launch allowlist: ${profile.readOnlyToolAllowlist.join(", ") || "none"}`, ` Writable launch allowlist: ${profile.writableToolAllowlist.join(", ") || "none"}`, ` Supports write-capable tools: ${profile.supportsWrite ? "yes" : "no"}`, ].join("\n")).join("\n\n"); return theme.fg("muted", [compact, detail].filter(Boolean).join("\n\n")); }; const launchThinking = (job: Pick): string => { let source = "model or Pi default"; if (job.launchThinkingSource === "job") source = "job override"; else if (job.launchThinkingSource === "profile") source = "profile"; else if (job.launchThinkingSource === "parent") source = "parent session"; return job.launchThinkingLevel ? `${job.launchThinkingLevel} (${source})` : source; }; const launchDetail = (job: PublicJobDetail): string => [ ` ${boundedPreview(job.task)}`, ` Launch model: ${job.launchModel ?? "Pi default"}`, ` Launch thinking: ${launchThinking(job)}`, ].join("\n"); const formatInboxHeading = (reportCount: number): string => { if (reportCount === 0) return "Inbox: empty"; if (reportCount === 1) return "Inbox: 1 unread report"; return `Inbox: ${reportCount} unread reports`; }; const renderInboxResult = ( result: ToolResponse & { details: ToolDetails }, expanded: boolean, theme: { fg(color: string, text: string): string }, ): string => { const reports = result.details.inboxReports ?? []; const omissions = formatInboxOmissions(result.details.omittedReports ?? []); const inboxHeading = formatInboxHeading(reports.length); const compact = [ inboxHeading, ...omissions, ...result.details.diagnostics, ].join("\n"); const detail = result.content.find((part) => part.type === "text")?.text ?? compact; return theme.fg("muted", expanded ? detail : compact); }; const renderToolResult = (result: ToolResponse, expanded: boolean, theme: { fg(color: string, text: string): string }): string => { if ("outcome" in result.details) return renderWaitResult(result, expanded, theme); const { jobs, diagnostics, operation, statuses, omittedStatuses } = result.details; const content = result.content.find((part) => part.type === "text")?.text ?? ""; if (operation === "status" && statuses) { const compact = [ `Jobs: ${statuses.length} (+${omittedStatuses ?? 0} omitted)`, ...statuses.map((status) => `${iconForState(status.state)} ${boundedPreview(status.id)} ${status.state}`), ...diagnostics, ].join("\n"); return theme.fg("muted", expanded ? [compact, content].filter(Boolean).join("\n\n") : compact); } const heading = toolResultHeading(operation, jobs.length); const compact = operation === "send" ? content : [heading, ...jobs.map((job) => `${iconForState(job.state)} ${boundedPreview(job.id)} ${job.state}`), ...diagnostics].filter(Boolean).join("\n") || content; if (!expanded) return theme.fg("muted", compact); const detail = toolResultDetail(operation, diagnostics.length > 0, content, jobs); const expandedContent = [compact, detail].filter(Boolean).join("\n\n"); return theme.fg("muted", operation === "collect" ? capCollectedPayload(expandedContent) : expandedContent); }; const toolResultHeading = (operation: ToolDetails["operation"], jobCount: number): string => { if (jobCount === 0) return ""; if (operation === "start") return `Started ${jobCount} job${jobCount === 1 ? "" : "s"}`; if (operation === "collect") return `Collected ${jobCount} result${jobCount === 1 ? "" : "s"}`; if (operation === "discard") return `Discarded ${jobCount} job${jobCount === 1 ? "" : "s"}`; if (operation === "cancel") return `Cancelled ${jobCount} job${jobCount === 1 ? "" : "s"}`; if (operation === "close") return `Closed ${jobCount} job${jobCount === 1 ? "" : "s"}`; return `Jobs: ${jobCount}`; }; const toolResultDetail = ( operation: ToolDetails["operation"], hasDiagnostics: boolean, content: string, jobs: readonly PublicJobDetail[], ): string => { if (operation === "collect" || hasDiagnostics) return content; if (operation === "start" || operation === "cancel" || operation === "discard" || operation === "close") { return jobs.map(launchDetail).join("\n"); } return ""; }; export function registerSubagentTools( pi: ExtensionAPI, services: ToolServices, allowThinkingOverrides = false, ): void { pi.registerTool({ name: "subagent_agents", label: "Subagent Profiles", description: "List available subagent profile names and safe public capabilities. Call only when profile names or capabilities are unknown, not before every job. Launch allowlists are requested child Pi tools, not write authorization or a runtime sandbox.", parameters: AgentsParams, execute: async () => listAgents(services), renderCall: (_input, theme) => new Text(theme.fg("toolTitle", "subagent_agents"), 0, 0), renderResult: (result, { expanded }, theme) => new Text(renderAgentProfiles(result as AgentsToolResponse, expanded, theme), 0, 0), }); pi.registerTool({ name: "subagent_start", label: "Start Subagents", description, parameters: startParamsFor(allowThinkingOverrides), execute: async (_id, input, _signal, _update, ctx) => startJobs(input, services, ctx, allowThinkingOverrides), renderCall: (input, theme) => new Text(theme.fg("toolTitle", `subagent_start ${input.tasks.length} job${input.tasks.length === 1 ? "" : "s"}`), 0, 0), renderResult: (result, { expanded }, theme) => new Text(renderToolResult(result as ToolResponse & { details: ToolDetails }, expanded, theme), 0, 0), }); pi.registerTool({ name: "subagent_status", label: "Subagent Status", description: `List background subagent jobs or inspect one job. ${description}`, parameters: StatusParams, execute: async (_id, input) => statusJobs(input, services), renderCall: (input, theme) => new Text(theme.fg("toolTitle", input.id ? `subagent_status ${boundedPreview(input.id)}` : "subagent_status"), 0, 0), renderResult: (result, { expanded }, theme) => new Text(renderToolResult(result as ToolResponse & { details: ToolDetails }, expanded, theme), 0, 0), }); pi.registerTool({ name: "subagent_inbox", label: "Subagent Inbox", description: "Read and clear unread progress and help reports from one job or all jobs, with a 50 KiB aggregate cap including report headers. Report text is shown to the parent model and terminal after terminal-control stripping and the manager's 4 KiB per-report bound. Reports beyond the aggregate count or byte limits are returned by later reads; remaining reports stay unread. This does not change job work, results, or session lifecycle.", parameters: InboxParams, execute: async (_id, input) => readInbox(input, services), renderCall: (input, theme) => new Text(theme.fg("toolTitle", input.id ? `subagent_inbox ${boundedPreview(input.id)}` : "subagent_inbox"), 0, 0), renderResult: (result, { expanded }, theme) => new Text(renderInboxResult(result as ToolResponse & { details: ToolDetails }, expanded, theme), 0, 0), }); pi.registerTool({ name: "subagent_wait", label: "Wait for Subagents", description: [ "Wait once for requested jobs only when they are expected to finish soon and no useful parent work can proceed meanwhile.", "The wait lasts at most 5 minutes, returns as soon as the requested condition is satisfied, and never collects output or cancels jobs.", "After a timeout, do not call subagent_wait again immediately; continue other work or return control.", ].join(" "), parameters: WaitParams, execute: async (_id, input, signal) => waitJobs(input, services, signal), renderCall: (input, theme) => new Text( theme.fg("toolTitle", `subagent_wait ${input.until ?? "all"} ${input.ids.map((id) => boundedPreview(id)).join(", ")}`), 0, 0, ), renderResult: (result, { expanded }, theme) => new Text( renderToolResult(result as ToolResponse, expanded, theme), 0, 0, ), }); pi.registerTool({ name: "subagent_send", label: "Send to Subagent", description: [ "Send one message to an open subagent session; sessions stay open until explicitly closed.", "Cancel, collect, and discard also leave sessions open; close with subagent_control when no further work is needed.", "follow_up or redirect answers a waiting help request. Otherwise, follow_up starts a new generation after the current result is collected or discarded, so an unread result is a barrier.", "Otherwise, redirect requires the current generation to be running.", ].join(" "), parameters: SendParams, execute: async (_id, input) => sendJob(input, services), renderCall: (input, theme) => new Text(theme.fg("toolTitle", `subagent_send ${boundedPreview(input.id)} ${input.delivery ?? "follow_up"}`), 0, 0), renderResult: (result, { expanded }, theme) => new Text(renderToolResult(result as ToolResponse & { details: ToolDetails }, expanded, theme), 0, 0), }); pi.registerTool({ name: "subagent_control", label: "Control Subagents", description: `Cancel, collect, discard, or close background subagent jobs. ${description}`, parameters: ControlParams, execute: async (_id, input) => controlJobs(input, services), renderCall: (input, theme) => new Text(theme.fg("toolTitle", `subagent_control ${input.action} ${input.ids.map((id) => boundedPreview(id)).join(", ")}`), 0, 0), renderResult: (result, { expanded }, theme) => { const typed = result as ToolResponse & { details: ToolDetails }; const content = renderToolResult(typed, expanded, theme); return typed.details.operation === "collect" ? new BoundedText(content) : new Text(content, 0, 0); }, }); }