import { convertToLlm, serializeConversation, type ExtensionAPI, } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import isPathInside from "is-path-inside"; import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; import { artifactPath, eventLogEndOffset, removeInteractiveState, INTERACTIVE_ARTIFACT_OWNER_FILE, isArtifactOutputSettled, isCompletionEvent, loadInteractiveStates, lastEvent, MAX_TURN_ID_LENGTH, listOutputHistory, listOutputTurns, readEvents, readEventBatch, readOutput, readOutputForTurnId, readOutputForTurn, updateInteractiveState, type SubagentArtifact, } from "../artifact"; import { assertCompletionGroupOpen, reserveCompletionGroup, releaseCompletionGroup, consumeCompletionSource, registerCompletionMember, type CompletionGroupReservation, type ResolvedCompletionPolicy, resolveCompletionPolicy, } from "../completion-coordinator"; import { cancelInteractiveSubagent, removeInteractiveSubagentState, formatInteractiveState, interactiveSubagentRegistry, launchInteractiveSubagent, pruneDeadInteractiveSubagents, sendCommandToPane, tmuxSetupHint, type InteractiveSubagentState, } from "../interactive-tmux"; import { debugLog } from "../helpers"; import { completionTriggersTurn, formatCompletionDeliveryBehavior, sanitizeOutput, } from "../notifications"; import { isOrchestratorV2Enabled } from "../completion-turn"; import { MAX_ORCHESTRATOR_ROUTING_ALIASES, MAX_ORCHESTRATOR_ROUTING_ALIAS_BYTES, MAX_ORCHESTRATOR_ROUTING_DESCRIPTION_BYTES, appendOrchestratorRoutingAuthorityEntry, isValidOrchestratorChildId, upsertOrchestratorRoutingEntry, type OrchestratorRoutingEntry, } from "../orchestrator-routing"; import { InteractiveParams, MAX_INTERACTIVE_CONTEXT_BYTES } from "../schemas"; import { registerToolWithDefaultGuidance } from "../tool-guidance"; import { updateRunningSubagentFooter } from "../artifact-poller"; import { getStartedSessionScopes, resolveToolSessionScope, sessionOwner, type SessionScope, type SessionToolToken, } from "../session-scope"; function isValidSubagentId(id: string): boolean { return isValidOrchestratorChildId(id); } const MAX_FOLLOWUP_BYTES = 64 * 1024; const MAX_ARTIFACT_PROVIDER_OUTPUT_BYTES = 64 * 1024; const MAX_FOLLOWUP_PREVIEW_CHARS = 500; const FOLLOWUP_COMPLETION_REMINDER = ' [MANDATORY COMPLETION PROTOCOL FOR EVERY FOLLOW-UP TURN: Before sending your final assistant response, write the result to output.md; make "$ARTIFACT_DIR/cli.mjs" done 0 your final tool call and wait for success. If it fails, do not send the final response; fix the cause and retry until completion is recorded. Do not rely on the lifecycle hook. After completion is recorded, remain in the Pi REPL and wait for follow-up; do not intentionally exit or close the pane unless explicitly asked.]'; function formatFollowupPreview(message: string): string { if (message.length <= MAX_FOLLOWUP_PREVIEW_CHARS) return message; return `${message.slice(0, MAX_FOLLOWUP_PREVIEW_CHARS)}… [truncated; ${message.length} chars total]`; } function formatArtifactProviderOutput(output: string | null): string { if (output === null) return ""; const sanitized = sanitizeOutput(output); const originalBytes = Buffer.byteLength(sanitized, "utf8"); let bounded = sanitized; if (originalBytes > MAX_ARTIFACT_PROVIDER_OUTPUT_BYTES) { const marker = `\n[Output truncated from ${originalBytes} bytes.]`; bounded = Buffer.from(sanitized, "utf8") .subarray( 0, Math.max( 0, MAX_ARTIFACT_PROVIDER_OUTPUT_BYTES - Buffer.byteLength(marker, "utf8"), ), ) .toString("utf8"); while ( Buffer.byteLength(`${bounded}${marker}`, "utf8") > MAX_ARTIFACT_PROVIDER_OUTPUT_BYTES ) { bounded = bounded.slice(0, -1); } bounded += marker; } return `\n\n${bounded || "(empty output)"}\n`; } type InitialRoutingMetadataResult = | { status: "persisted"; entry: OrchestratorRoutingEntry } | { status: "warning"; error: string }; function parentBranchEntries(ctx: unknown): readonly unknown[] { if (!ctx || typeof ctx !== "object") return []; const sessionManager = (ctx as { sessionManager?: unknown }).sessionManager; if (!sessionManager || typeof sessionManager !== "object") return []; const getBranch = (sessionManager as { getBranch?: unknown }).getBranch; if (typeof getBranch !== "function") return []; try { const branch = getBranch.call(sessionManager); return Array.isArray(branch) ? branch : []; } catch { return []; } } function persistInitialRoutingMetadata(params: { cwd: string; childId: string; description?: string; aliases?: string[]; authorityEntries?: readonly unknown[]; pi?: ExtensionAPI; }): InitialRoutingMetadataResult | undefined { if (params.description === undefined) return undefined; if (!isValidSubagentId(params.childId)) { return { status: "warning", error: `spawn returned invalid child id ${params.childId}`, }; } try { const overlay = upsertOrchestratorRoutingEntry( params.cwd, { childId: params.childId, description: params.description!, ...(params.aliases === undefined ? {} : { aliases: params.aliases }), provenance: "orchestratorv2", }, { authorityEntries: params.authorityEntries ?? [] }, ); const entry = overlay.records.find( (record) => record.childId === params.childId, ); if (!entry) throw new Error("routing metadata update was not persisted"); appendOrchestratorRoutingAuthorityEntry(params.pi ?? {}, params.cwd, entry); return { status: "persisted", entry }; } catch (error) { return { status: "warning", error: error instanceof Error ? error.message : String(error), }; } } function validateInitialRoutingMetadata( description: string | undefined, aliases: string[] | undefined, ): string | undefined { if (aliases !== undefined && description === undefined) { return "routingAliases requires routingDescription"; } if (description === undefined) return undefined; if (description.trim().length === 0) { return "description must be a non-empty string"; } const descriptionBytes = Buffer.byteLength(description, "utf8"); if (descriptionBytes > MAX_ORCHESTRATOR_ROUTING_DESCRIPTION_BYTES) { return `description exceeds ${MAX_ORCHESTRATOR_ROUTING_DESCRIPTION_BYTES} bytes`; } if (aliases === undefined) return undefined; if (aliases.length > MAX_ORCHESTRATOR_ROUTING_ALIASES) { return `aliases exceeds ${MAX_ORCHESTRATOR_ROUTING_ALIASES} entries`; } const seen = new Set(); for (const alias of aliases) { if (alias.trim().length === 0) return "alias must be a non-empty string"; if ( Buffer.byteLength(alias, "utf8") > MAX_ORCHESTRATOR_ROUTING_ALIAS_BYTES ) { return `alias exceeds ${MAX_ORCHESTRATOR_ROUTING_ALIAS_BYTES} bytes`; } if (seen.has(alias)) return `duplicate alias: ${alias}`; seen.add(alias); } return undefined; } function validateRoutingMetadataMode( topLevelOrchestratorV2: boolean, description: string | undefined, aliases: string[] | undefined, ): string | undefined { if (topLevelOrchestratorV2 && description === undefined) { return "routingDescription is required for a top-level Orchestratorv2 child"; } if (!topLevelOrchestratorV2 && (description !== undefined || aliases)) { return "routingDescription and routingAliases are reserved for a top-level Orchestratorv2 session"; } return undefined; } function persistInteractiveRollbackTombstone( state: InteractiveSubagentState, ): void { const tombstoneAt = Date.now(); if (!state.parentSessionId) return; try { updateInteractiveState(state.cwd, state.id, (entry) => { delete entry.completionPolicy; delete entry.completionGroupId; delete entry.notifyOnComplete; delete entry.triggerTurnOnComplete; entry.completionTombstone = "failed"; entry.completionTombstoneAt = tombstoneAt; }); } catch (error) { debugLog("warn", "interactive_spawn_tombstone_failed", { id: state.id, error: error instanceof Error ? error.message : String(error), }); } } function rollbackInteractiveSpawn(state: InteractiveSubagentState): void { state.completionPolicy = undefined; state.completionGroupId = undefined; try { cancelInteractiveSubagent(state.id, "cancel_interactive_subagent", state); } catch { /* Registration failed; pane cleanup is best effort. */ } removeInteractiveSubagentState(state); if (state.parentSessionId) { try { removeInteractiveState(state.cwd, state.id); } catch { persistInteractiveRollbackTombstone(state); } } } export function findArtifactById(id: string): SubagentArtifact | null { // Sub-agent ids are historically 4 random bytes (8 hex chars) and currently // 8 random bytes (16 hex chars). Validate before joining into a path so that // an LLM-supplied id like "../../../etc" cannot escape the artifact root. // (path.join normalises "..", so a malicious id would otherwise resolve // to a sibling directory and get exfiltrated to the parent LLM via // read_subagent_artifact). if (!isValidSubagentId(id)) return null; const root = process.env.PI_CODING_AGENT_SESSION_DIR ?? join(homedir(), ".pi", "agent", "sessions"); // Resolve the root once, with symlinks followed, so the containment check below // is anchored on the real on-disk location. realpathSync throws if root doesn't // exist; in that case there's nothing for us to find. let realRoot: string; try { realRoot = realpathSync(root); } catch { return null; } let topLevel: string[]; try { topLevel = readdirSync(root); } catch { return null; } for (const entry of topLevel) { const candidate = join(root, entry, "artifacts", id); try { if (statSync(candidate).isDirectory()) { // statSync follows symlinks, so a symlink at // //artifacts/ pointing outside the artifact root // would otherwise be returned as a valid artifact. Resolve the // candidate with realpath and verify it is still inside the // resolved root. realpathSync is safe here because statSync // above already confirmed candidate exists as a directory. let realCandidate: string; try { realCandidate = realpathSync(candidate); } catch { continue; } if (!isPathInside(realCandidate, realRoot)) continue; return artifactPath(join(root, entry, "artifacts"), id); } } catch { /* not here */ } } return null; } function getArtifactForState( state: Pick, ): SubagentArtifact { return artifactPath(dirname(state.artifactDir), basename(state.artifactDir)); } interface SelectedCompletion { turnId: string; protocolV2: boolean; } function completionForRead( art: SubagentArtifact, selector: { turn?: number; turnId?: string }, ): SelectedCompletion | undefined { const completions: ReturnType["records"] = []; const snapshotEndOffset = eventLogEndOffset(art); let cursor = 0; while (cursor < snapshotEndOffset) { const batch = readEventBatch(art, cursor); for (const record of batch.records) { if (record.endOffset > snapshotEndOffset) break; if (isCompletionEvent(record.event)) completions.push(record); } const nextOffset = batch.records.at(-1)?.endOffset ?? batch.endOffset; if (nextOffset <= cursor) break; cursor = Math.min(nextOffset, snapshotEndOffset); } const selected = selector.turnId ? completions.find( ({ event }) => event.type === "completion" && event.turnId === selector.turnId, ) : selector.turn !== undefined ? completions.filter(({ event }) => event.type === "done")[ selector.turn - 1 ] : completions.at(-1); if (!selected) return undefined; return selected.event.type === "completion" ? { turnId: selected.event.turnId, protocolV2: true } : { turnId: `legacy-${selected.startOffset}`, protocolV2: false }; } function resolveInteractiveToolStates(token: SessionToolToken | undefined): | { scope?: SessionScope; states: Map; } | undefined { const scope = resolveToolSessionScope(token); if (scope) return { scope, states: scope.interactiveStates }; if (!token && getStartedSessionScopes().length === 0) { return { states: interactiveSubagentRegistry }; } return undefined; } function findOwnedDiskArtifact( cwd: string, id: string, scope: SessionScope | undefined, ): SubagentArtifact | null { if (!scope) return findArtifactById(id); let parentSessionId: string | undefined; try { parentSessionId = scope.sessionManager?.getSessionId?.(); } catch { return null; } if (!parentSessionId) return null; const artifact = findArtifactById(id); if (!artifact) return null; try { const persistedOwner = readFileSync( join(artifact.dir, INTERACTIVE_ARTIFACT_OWNER_FILE), "utf8", ); return persistedOwner === parentSessionId ? artifact : null; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return null; // Older artifacts may still have exact evidence in the persisted state file. } const persisted = loadInteractiveStates(cwd)?.states[id]; if (!persisted || persisted.parentSessionId !== parentSessionId) return null; try { return realpathSync(persisted.artifactDir) === realpathSync(artifact.dir) ? artifact : null; } catch { return null; } } export function registerInteractiveSubagentTools( pi: ExtensionAPI, registrationScope?: SessionScope, ): void { const toolToken: SessionToolToken | undefined = registrationScope ? { id: registrationScope.id } : undefined; // ── Tool 6: spawn an attachable mux-backed Pi session ────────────── registerToolWithDefaultGuidance(pi, { name: "subagent_interactive", label: "Interactive Subagent", description: [ "Spawn a separate Pi process in a tmux/zellij pane and return immediately.", "Use this when the user wants to attach to the sub-agent session and continue follow-ups there.", "Works inside tmux or zellij. The tool returns attach/focus commands and the child session file.", "This is intentionally separate from SDK subagents: it favors observability and attachability over in-process execution.", "Completion coordination defaults to each: every terminal turn creates one TUI-only notice, while safely-idle results are coalesced into a compact immutable-reference manifest that resumes the parent.", "Use completionPolicy=group with a shared completionGroupId for related agents; the parent resumes once the spawning turn settles and every registered member is terminal.", "Human input takes priority, and successful read_subagent_artifact collection consumes the matching pending delivery.", "Deprecated notifyOnComplete and triggerTurnOnComplete inputs map to coordinated each delivery and cannot be combined with completionPolicy or completionGroupId.", ].join("\n"), parameters: InteractiveParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const registration = resolveInteractiveToolStates(toolToken); if (!registration) { return { content: [ { type: "text", text: "This interactive tool registration is no longer attached to a live session.", }, ], details: { status: "session_unavailable" }, isError: true, }; } if ( registration.scope?.lineageMode === "child" && !registration.scope.spawnTreeContext ) { return { content: [ { type: "text", text: "Recursive interactive spawning is disabled because this child has no explicit lineage bootstrap.", }, ], details: { status: "lineage_unavailable" }, isError: true, }; } const routingMetadataError = validateInitialRoutingMetadata( params.routingDescription, params.routingAliases, ); const topLevelOrchestratorV2 = registration.scope !== undefined && process.env.PI_SUBAGENTURA_CHILD !== "1" && isOrchestratorV2Enabled(pi); const routingModeError = validateRoutingMetadataMode( topLevelOrchestratorV2, params.routingDescription, params.routingAliases, ); if (routingMetadataError || routingModeError) { const error = routingMetadataError ?? routingModeError!; return { content: [ { type: "text", text: `Invalid initial routing metadata: ${error}`, }, ], details: { status: "invalid_routing_metadata", error, }, isError: true, }; } const contextParams = params as typeof params & { includeContext?: boolean; context?: string; }; if ( contextParams.context !== undefined && Buffer.byteLength(contextParams.context, "utf8") > MAX_INTERACTIVE_CONTEXT_BYTES ) { return { content: [ { type: "text", text: `Explicit context exceeds ${MAX_INTERACTIVE_CONTEXT_BYTES} bytes.`, }, ], details: { status: "invalid_context", maxBytes: MAX_INTERACTIVE_CONTEXT_BYTES, }, isError: true, }; } let completion: ResolvedCompletionPolicy; try { if ( !registration.scope && (params.completionPolicy !== undefined || params.completionGroupId !== undefined) ) { throw new Error( "completionPolicy and completionGroupId require a live parent session scope for coordinated delivery", ); } completion = registration.scope ? resolveCompletionPolicy(params) : { legacy: true }; if (registration.scope) { assertCompletionGroupOpen( completion.policy, completion.groupId, sessionOwner(registration.scope), ); } } catch (error) { const message = error instanceof Error ? error.message : String(error); return { content: [ { type: "text", text: `Sub-agent not started: ${message}` }, ], details: { status: "error", error: message }, isError: true, }; } const completionMode = params.notifyOnComplete ?? "notify"; const triggerTurn = completionTriggersTurn( completionMode, params.triggerTurnOnComplete ?? true, ); let completionReservation: CompletionGroupReservation | undefined; if (registration.scope) { try { completionReservation = reserveCompletionGroup( completion.policy, completion.groupId, sessionOwner(registration.scope), ); } catch (error) { const msg = error instanceof Error ? error.message : String(error); return { content: [{ type: "text", text: `Sub-agent not started: ${msg}` }], details: { status: "error", error: msg }, isError: true, }; } } debugLog("info", "tool_call", { toolName: "subagent_interactive", toolCallId: _toolCallId, taskLength: params.task?.length ?? 0, model: params.model ?? null, cwd: params.cwd ?? ctx.cwd, includeContext: contextParams.includeContext ?? false, notifyOnComplete: completion.legacy ? completionMode : null, triggerTurnOnComplete: completion.legacy ? triggerTurn : null, completionPolicy: completion.policy ?? "legacy", completionGroupId: completion.groupId ?? null, }); let contextText: string | null = contextParams.includeContext === false ? (contextParams.context ?? null) : null; let authorityEntries: readonly unknown[] | undefined; if (contextParams.includeContext === true) { const branch = ctx.sessionManager.getBranch(); authorityEntries = branch; const messages = branch .filter( (e): e is typeof e & { type: "message" } => e.type === "message", ) .map((e) => e.message); contextText = serializeConversation(convertToLlm(messages)); } else if (topLevelOrchestratorV2) { authorityEntries = parentBranchEntries(ctx); } const taskPreview = params.task.replace(/\s+/g, " ").slice(0, 48); const name = params.name ?? `Subagent: ${taskPreview || "interactive"}`; const targetCwd = params.cwd ?? ctx.cwd; try { const state = launchInteractiveSubagent({ name, task: params.task, persona: params.persona, model: params.model, cwd: targetCwd, contextText, background: params.background, // defaults to true (hidden) inside the helper notifyOnComplete: completion.legacy ? completionMode : undefined, triggerTurnOnComplete: completion.legacy ? triggerTurn : undefined, completionPolicy: completion.policy, completionGroupId: completion.groupId, muxPreference: params.mux, // pass through user's mux preference parentCwd: ctx.cwd, parentSessionId: ctx.sessionManager.getSessionId(), thinkingLevel: params.thinkingLevel, sessionScope: registration.scope, spawnTreeContext: registration.scope?.spawnTreeContext, }); if (registration.scope && completion.policy) { try { registerCompletionMember( "interactive", state.id, completion.policy, completion.groupId, sessionOwner(registration.scope), completionReservation, ); } catch (error) { releaseCompletionGroup(completionReservation); rollbackInteractiveSpawn(state); return { content: [ { type: "text", text: `Failed to start interactive sub-agent: ${error instanceof Error ? error.message : String(error)}`, }, ], details: { status: "error", error: String(error) }, isError: true, }; } } const routingMetadata = persistInitialRoutingMetadata({ cwd: ctx.cwd, childId: state.id, description: params.routingDescription, aliases: params.routingAliases, authorityEntries, pi, }); updateRunningSubagentFooter( ctx.ui, registration.scope ? sessionOwner(registration.scope) : undefined, ); const displayMode = state.windowName ? "background (new window/tab)" : "visible split"; const locationLines = [`Artifact: ${state.artifactDir}`]; if (!(state.mux === "tmux" && process.env.TMUX)) { locationLines.push(`Attach: ${state.attachCommand}`); } locationLines.push(`Focus: ${state.selectPaneCommand}`); locationLines.push(`Session: ${state.sessionFile}`); if (routingMetadata?.status === "warning") { locationLines.push( `Warning: initial routing metadata was not persisted: ${routingMetadata.error}`, ); } return { content: [ { type: "text", text: `Interactive sub-agent ${state.id} started (${displayMode}) in ${state.mux} pane ${state.paneId}.\n\n` + `${ completion.legacy ? formatCompletionDeliveryBehavior( completionMode, triggerTurn, "planned", ) : completion.policy === "group" ? `Completion will notify the user immediately and resume the parent once group ${completion.groupId} is sealed at parent settlement and all registered members finish.` : "Completion will notify the user immediately and resume the parent with immutable result references when safely idle." }\n\n` + locationLines.join("\n"), }, ], details: { ...state, status: "started", thinkingLevel: params.thinkingLevel, ...(routingMetadata === undefined ? {} : { routingMetadata }), }, }; } catch (error) { releaseCompletionGroup(completionReservation); const msg = error instanceof Error ? error.message : String(error); return { content: [ { type: "text", text: `Failed to start interactive sub-agent: ${msg}\n${tmuxSetupHint()}`, }, ], details: { status: "error", error: msg }, isError: true, }; } }, renderCall(args, theme) { const task = String(args.task ?? ""); const preview = task.length > 60 ? `${task.slice(0, 57)}…` : task; return new Text( theme.fg("toolTitle", theme.bold("subagent_interactive ")) + theme.fg("accent", String(args.name ?? preview)), 0, 0, ); }, renderResult(result, _options, theme) { const details = result.details as | (Partial & { thinkingLevel?: string }) | undefined; const id = details?.id ?? "unknown"; const paneId = details?.paneId ?? "unknown"; const thinking = details?.thinkingLevel ? ` · thinking: ${details.thinkingLevel}` : ""; if ((result as any).isError) { const first = result.content?.[0]; const text = first?.type === "text" ? first.text : "Failed to start interactive sub-agent"; return new Text(theme.fg("error", text), 0, 0); } return new Text( theme.fg("accent", "⚡ ") + theme.fg("toolTitle", `Interactive sub-agent ${id}`) + theme.fg("dim", ` — pane ${paneId}${thinking}`), 0, 0, ); }, }); // ── Tool 7: inspect attachable tmux-backed sessions ──────────────── registerToolWithDefaultGuidance(pi, { name: "get_interactive_subagent_status", label: "Get Interactive Subagent Status", description: "Inspect tmux-backed interactive subagents. Omit jobId to list all tracked sessions. Returns attach/select commands and session paths without capturing pane output.", parameters: Type.Object({ jobId: Type.Optional( Type.String({ description: "Interactive sub-agent ID returned by subagent_interactive", }), ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx): Promise { const registration = resolveInteractiveToolStates(toolToken); const visibleStates = registration?.states; if (visibleStates) { pruneDeadInteractiveSubagents(visibleStates.values()); updateRunningSubagentFooter( ctx.ui, registration.scope ? sessionOwner(registration.scope) : undefined, ); } const states = params.jobId ? [visibleStates?.get(params.jobId)].filter( (s): s is InteractiveSubagentState => Boolean(s), ) : visibleStates ? [...visibleStates.values()] : []; if (states.length === 0) { return { content: [ { type: "text", text: params.jobId ? `Interactive sub-agent ${params.jobId} not found.` : "No interactive sub-agents are tracked.", }, ], details: { status: "not_found", jobId: params.jobId }, isError: Boolean(params.jobId), }; } const sections = states.map((state) => { return formatInteractiveState(state); }); return { content: [{ type: "text", text: sections.join("\n\n---\n\n") }], details: { count: states.length, subagents: states.map((state) => ({ ...state })), }, }; }, }); // ── Tool 8: cancel an attachable tmux-backed session ─────────────── registerToolWithDefaultGuidance(pi, { name: "cancel_interactive_subagent", label: "Cancel Interactive Subagent", description: "Kill an interactive sub-agent pane and retain its cancelled artifact. Coordinated delivery still emits one TUI-only terminal notice and may later add a compact cancellation selector; the tool result does not inject a duplicate full-output cancellation message.", parameters: Type.Object({ jobId: Type.String({ description: "Interactive sub-agent ID returned by subagent_interactive", }), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx): Promise { const registration = resolveInteractiveToolStates(toolToken); const ownedState = registration?.states.get(params.jobId); const state = ownedState ? cancelInteractiveSubagent( params.jobId, "cancel_interactive_subagent", ownedState, ) : undefined; let userNotification: string; if (!state) { return { content: [ { type: "text", text: `Interactive sub-agent ${params.jobId} not found.`, }, ], details: { jobId: params.jobId, status: "not_found" }, isError: true, }; } updateRunningSubagentFooter( ctx.ui, registration?.scope ? sessionOwner(registration.scope) : undefined, ); const snapshotText = state.cancellationSnapshot?.path ? ` Snapshot ${state.cancellationSnapshot.status}: ${state.cancellationSnapshot.path}` : state.cancellationSnapshot?.error ? ` Snapshot error: ${state.cancellationSnapshot.error}` : ""; userNotification = `Interactive sub-agent ${params.jobId} cancelled; coordinated delivery will use one TUI notice and, when eligible, one compact cancellation selector. ` + `Artifacts retained at ${state.artifactDir}.${snapshotText}`; try { ctx.ui.notify(userNotification, "warning"); } catch { /* cancellation succeeded; a stale UI must not turn it into a tool failure */ } return { content: [ { type: "text", text: `Interactive sub-agent ${params.jobId} cancelled. ` + `Coordinated delivery will use one TUI notice and, when eligible, one compact cancellation selector. ` + `Artifacts retained at ${state.artifactDir}.` + (state.cancellationSnapshot?.path ? ` Snapshot ${state.cancellationSnapshot.status}: ${state.cancellationSnapshot.path}.` : state.cancellationSnapshot?.error ? ` Snapshot error: ${state.cancellationSnapshot.error}.` : ""), }, ], details: { ...state }, }; }, }); // ── Tool: send a follow-up message to a live interactive sub-agent ────── // The child REPL stays open after `done` (see buildChildSubagentProtocol in // interactive-tmux.ts), so the parent can push a new prompt into the same // session via tmux send-keys. Model context is preserved across messages — // this is a true follow-up turn, not a fresh spawn. // // Caps: the message must be non-empty (an empty Enter in the REPL would submit a // blank prompt) and at most MAX_FOLLOWUP_BYTES UTF-8 bytes (symmetric with // MAX_PERSONA_BYTES in interactive-tmux.ts — 64 KiB is well above any realistic // follow-up prompt; larger values are rejected up-front with a structured error). registerToolWithDefaultGuidance(pi, { name: "send_interactive_subagent_message", label: "Send Interactive Subagent Message", description: [ "Send a follow-up prompt to a live interactive sub-agent. The message is delivered into the", "child's existing REPL via tmux send-keys, so the child's model context is preserved — this", "is a true follow-up turn, not a fresh spawn. A workflow-owned child can accept a follow-up", "only after its completed turn is idle and its workflow runner has consumed the result. It is", "promoted to standalone only after that follow-up is sent successfully. An idle follow-up resets", "future completion delivery to independent each; a source can satisfy a group only once, so later", "turns from that source/group are also independent. The child will run the new turn and (per its", "system prompt) call '$ARTIFACT_DIR/cli.mjs done 0' again when it finishes. Use", "get_interactive_subagent_status to check the pane state first if you're not sure it's still alive.", ].join("\n"), parameters: Type.Object({ id: Type.String({ description: "Interactive sub-agent ID returned by subagent_interactive", }), message: Type.String({ description: "The follow-up prompt text to send into the child's REPL (must be non-empty; max 64 KiB)", }), }), async execute(_toolCallId, params): Promise { // Validate the id shape first for a precise error. if (!isValidSubagentId(params.id)) { return { content: [ { type: "text", text: `Invalid sub-agent id ${JSON.stringify(params.id)}; expected 8 or 16 lowercase hex chars.`, }, ], details: { id: params.id, status: "invalid_id" }, isError: true, }; } // Content validation (no registry I/O): fail fast on empty / oversized messages. // An empty message would submit a blank Enter in the child REPL; an oversized message // is more than the child can usefully consume and risks blowing the REPL history. if (params.message.trim().length === 0) { return { content: [ { type: "text", text: "Message is empty; send a non-empty follow-up prompt.", }, ], details: { id: params.id, status: "empty_message", messageLength: 0 }, isError: true, }; } const messageBytes = Buffer.byteLength(params.message, "utf8"); if (messageBytes > MAX_FOLLOWUP_BYTES) { return { content: [ { type: "text", text: `Message too large: ${messageBytes} bytes (max ${MAX_FOLLOWUP_BYTES}). Shorten the prompt and try again.`, }, ], details: { id: params.id, status: "message_too_large", messageLength: messageBytes, maxBytes: MAX_FOLLOWUP_BYTES, }, isError: true, }; } const registration = resolveInteractiveToolStates(toolToken); const state = registration?.states.get(params.id); if (!state) { return { content: [ { type: "text", text: `Interactive sub-agent ${params.id} not found.`, }, ], details: { id: params.id, status: "not_found" }, isError: true, }; } if ( state.completionOwner === "workflow" && (!state.workflowResultConsumed || state.status !== "idle") ) { return { content: [ { type: "text", text: `Interactive sub-agent ${params.id} is still under workflow ownership; its workflow must consume the current result and the pane must be idle before a follow-up can be sent.`, }, ], details: { id: params.id, status: "workflow_owned" }, isError: true, }; } // Standalone panes accept follow-ups while running or idle. Workflow ownership has two // independent release conditions: artifact polling folded the completion into idle, and the // workflow runner acknowledged returning that result. if (state.status !== "running" && state.status !== "idle") { return { content: [ { type: "text", text: `Interactive sub-agent ${params.id} is ${state.status}; follow-up messages can only be sent to running or idle sub-agents. Spawn a new one if needed.`, }, ], details: { id: params.id, status: state.status }, isError: true, }; } // sendCommandToPane uses send-keys + Enter; it throws synchronously if the // pane is gone (e.g. the child exited between the status check and now). // Wrap so the parent gets a structured error instead of an exception trace. const startsNewTurn = state.status === "idle"; try { sendCommandToPane(state, params.message + FOLLOWUP_COMPLETION_REMINDER); } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { content: [ { type: "text", text: `Failed to send message to interactive sub-agent ${params.id}: ${msg}`, }, ], details: { id: params.id, status: "send_failed", paneId: state.paneId, error: msg, }, isError: true, }; } // Reaching the send proves both workflow release conditions held at the guard above. if ( state.completionOwner === "workflow" && state.workflowResultConsumed && state.status === "idle" ) { state.completionOwner = "standalone"; state.workflowId = undefined; } let persistenceWarning: string | undefined; if (startsNewTurn) { state.completionPolicy = "each"; state.completionGroupId = undefined; state.notifyOnComplete = undefined; state.triggerTurnOnComplete = undefined; if (state.parentSessionId) { try { updateInteractiveState(state.cwd, state.id, (entry) => { entry.completionPolicy = "each"; delete entry.completionGroupId; delete entry.notifyOnComplete; delete entry.triggerTurnOnComplete; }); } catch (error) { persistenceWarning = "The message was sent, but the new completion policy could not be persisted; reload may require manual recovery."; debugLog("warn", "interactive_followup_policy_persist_failed", { id: state.id, error: error instanceof Error ? error.message : String(error), }); } } } const messagePreview = formatFollowupPreview(params.message); const messageTruncated = params.message.length > MAX_FOLLOWUP_PREVIEW_CHARS; return { content: [ { type: "text", text: `Sent follow-up to interactive sub-agent ${params.id} (${params.message.length} chars) in pane ${state.paneId}.` + `\n\nMessage sent:\n${messagePreview}` + (persistenceWarning ? `\n\nWarning: ${persistenceWarning}` : ""), }, ], details: { id: params.id, ...(persistenceWarning ? { persistenceWarning } : {}), paneId: state.paneId, messageLength: params.message.length, messagePreview, messageTruncated, status: "sent", }, }; }, }); // ── Tool: read an interactive sub-agent's artifact ─────────────── // Events and immutable terminal snapshots are authoritative. output.md remains // mutable staging for legacy or still-running artifacts only. registerToolWithDefaultGuidance(pi, { name: "read_subagent_artifact", label: "Read Subagent Artifact", description: [ "Read an interactive sub-agent's artifact on disk. Returns lifecycle events and, by default,", "the latest terminal immutable protocol-v2 snapshot. Mutable output.md is used only when", "no protocol-v2 terminal snapshot applies, including legacy or still-running artifacts.", "Use `since` (unix ms) to fetch only events newer than your last read. Use `turnId` for a", "protocol-v2 Pi turn, or legacy numeric `turn` for an output-N.md snapshot.", "Returning a terminal output consumes its matching pending coordinated delivery so it is not sent again automatically; events-only reads do not consume it.", ].join("\n"), parameters: Type.Object({ id: Type.String({ description: "Interactive sub-agent ID returned by subagent_interactive", }), since: Type.Optional( Type.Number({ description: "Only return events with ts >= this unix-ms timestamp", }), ), includeOutput: Type.Optional( Type.Boolean({ description: "Include the output (default true). Set false to fetch only events.", }), ), turn: Type.Optional( Type.Number({ description: "Read a specific turn's output-N.md snapshot. Omit to read the latest output.md.", }), ), turnId: Type.Optional( Type.String({ maxLength: MAX_TURN_ID_LENGTH, description: "Read a protocol-v2 immutable output by its Pi-derived turnId (max 256 characters).", }), ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx): Promise { // Validate the id shape FIRST so a malformed id gets a precise error // instead of being collapsed into the generic "not found" message. if (!isValidSubagentId(params.id)) { return { content: [ { type: "text", text: `Invalid sub-agent id ${JSON.stringify(params.id)}; expected 8 or 16 lowercase hex chars.`, }, ], details: { id: params.id, status: "invalid_id" }, isError: true, }; } if (params.turn !== undefined && params.turnId !== undefined) { return { content: [ { type: "text", text: "Pass either turn or turnId, not both.", }, ], details: { id: params.id, status: "invalid_selector" }, isError: true, }; } const registration = resolveInteractiveToolStates(toolToken); const state = registration?.states.get(params.id); const foreignInMemoryState = !state && interactiveSubagentRegistry.has(params.id); const art = state ? getArtifactForState(state) : registration && !foreignInMemoryState ? findOwnedDiskArtifact(ctx.cwd, params.id, registration.scope) : null; if (!art) { return { content: [ { type: "text", text: `No artifact found for sub-agent ${params.id}.`, }, ], details: { id: params.id, status: "not_found" }, isError: true, }; } const events = readEvents(art, params.since); // Historical selectors imply includeOutput: selecting a turn without its // immutable content would be surprising and provides no useful mapping. const wantsOutput = params.includeOutput !== false || params.turn !== undefined || params.turnId !== undefined; const selectedCompletion = wantsOutput ? completionForRead(art, params) : undefined; const output = wantsOutput ? params.turnId !== undefined ? readOutputForTurnId(art, params.turnId) : params.turn !== undefined ? readOutputForTurn(art, params.turn) : selectedCompletion?.protocolV2 ? readOutputForTurnId(art, selectedCompletion.turnId) : readOutput(art) : null; const lastEventValue = events.length > 0 ? events[events.length - 1] : null; // Distinguish three cases when output is missing/empty so the caller // doesn't see a misleading "not written yet" after the sub-agent has // already exited (the common case: model finished without writing). let outputText: string; if (!wantsOutput) { outputText = "(not requested)"; } else if (output === null) { if (params.turnId !== undefined) { outputText = `(no immutable snapshot for turnId ${params.turnId})`; } else if (params.turn !== undefined) { outputText = `(no snapshot for turn ${params.turn} — the poller may not have run yet, or this turn number is past the history)`; } else { const exited = lastEventValue && isArtifactOutputSettled(lastEventValue); outputText = exited ? `(sub-agent exited without writing output.md — last event: ${lastEventValue.type} @ ${lastEventValue.ts})` : `(${events.length} events, last: ${lastEventValue ? `${lastEventValue.type} @ ${lastEventValue.ts}` : "(none)"} — output.md not written yet)`; } } else if (output.length === 0) { outputText = "(empty — 0 chars)"; } else { outputText = `${output.length} chars`; } // Available turns summary so the caller knows what history exists. const availableTurns = listOutputTurns(art); const outputHistory = listOutputHistory(art); const turnsLine = availableTurns.length > 0 ? `Available turns: [${availableTurns.join(", ")}]\n` : ""; const historyLine = outputHistory.length > 0 ? `Protocol-v2 outputs: ${outputHistory .map(({ turnId, eventId }) => `${turnId} → ${eventId}`) .join(", ")}\n` : ""; if (wantsOutput && output !== null && selectedCompletion) { consumeCompletionSource( pi, { source: "interactive", sourceId: params.id, turnId: selectedCompletion.turnId, }, registration?.scope ? sessionOwner(registration.scope) : undefined, ); } return { content: [ { type: "text", text: `Artifact for ${params.id} (${events.length} event${events.length === 1 ? "" : "s"}${params.since ? ` since ${params.since}` : ""}).\n` + `Last event: ${lastEventValue ? `${lastEventValue.type} @ ${lastEventValue.ts}` : "(none)"}\n` + (params.turn !== undefined ? `Reading turn: ${params.turn}\n` : "") + (params.turnId !== undefined ? `Reading turnId: ${params.turnId}\n` : "") + turnsLine + historyLine + `Output: ${outputText}` + (wantsOutput ? formatArtifactProviderOutput(output) : ""), }, ], details: { id: params.id, artifactDir: art.dir, events, output, lastEvent: lastEventValue, availableTurns, outputHistory, }, }; }, }); // ── Tool: list known interactive sub-agent artifacts ───────────── registerToolWithDefaultGuidance(pi, { name: "list_subagent_artifacts", label: "List Subagent Artifacts", description: [ "List interactive sub-agent artifacts visible to this parent session.", "Returns id, name, status, and last-update time. Use read_subagent_artifact", "to fetch a specific one.", ].join("\n"), parameters: Type.Object({}), async execute(_toolCallId, _params, _signal, _onUpdate, ctx): Promise { const registration = resolveInteractiveToolStates(toolToken); const visibleStates = registration?.states; if (visibleStates) { pruneDeadInteractiveSubagents(visibleStates.values()); updateRunningSubagentFooter( ctx.ui, registration.scope ? sessionOwner(registration.scope) : undefined, ); } const states = visibleStates ? [...visibleStates.values()] : []; const summary = states.map((s) => { const art = getArtifactForState(s); const last = lastEvent(art); return { id: s.id, name: s.name, task: s.task, status: s.status, lastEvent: last, lastUpdate: last?.ts, artifactDir: s.artifactDir, }; }); if (summary.length === 0) { return { content: [ { type: "text", text: "No interactive sub-agents are tracked." }, ], details: { count: 0, subagents: [] }, }; } const lines = summary.map((s) => { const ev = s.lastEvent; const taskPreview = (s.task ?? "").replace(/\s+/g, " ").slice(0, 60); const evStr = ev ? `last: ${ev.type}${ev.message ? ` (${ev.message.slice(0, 60)})` : ""}` : "no events yet"; return `${s.id} ${s.name} [${s.status}] ${taskPreview} — ${evStr}`; }); return { content: [{ type: "text", text: lines.join("\n") }], details: { count: summary.length, subagents: summary }, }; }, }); }