From 39b1f8560da0d17cc6e646bc89fdd261fde0893f Mon Sep 17 00:00:00 2001 From: Amos Date: Sat, 27 Jun 2026 01:19:07 +0300 Subject: [PATCH] collapsed subagent_interrupt + subagent_resume -> subagent_message, enforced spawning whitelist --- README.md | 68 ++++-- agents/worker.md | 2 + pi-extension/subagents/index.ts | 394 +++++++++++++++++------------- pi-extension/subagents/session.ts | 92 ++++++- test/test.ts | 324 ++++++++++++------------ 5 files changed, 525 insertions(+), 355 deletions(-) diff --git a/README.md b/README.md index 6bbb619..4396e1a 100644 --- a/README.md +++ b/README.md @@ -62,14 +62,13 @@ Subagent panes are created without stealing keyboard focus (cmux, tmux). Launch ### Extensions -**Subagents** — 4 main-session tools + 1 command, plus 1 subagent-only tool: +**Subagents** — 3 main-session tools + 1 command, plus 1 subagent-only tool: -| Tool | Description | -| -------------------- | ------------------------------------------------------------------------------------------- | -| `subagent` | Spawn a sub-agent in a dedicated multiplexer pane (async — returns immediately) | -| `subagent_interrupt` | Interrupt a running Pi-backed subagent's current turn | -| `subagents_list` | List available agent definitions | -| `subagent_resume` | Resume a previous sub-agent session (async) | +| Tool | Description | +| ------------------ | ------------------------------------------------------------------------------------------------- | +| `subagent` | Spawn a sub-agent in a dedicated multiplexer pane (async — returns immediately) | +| `subagent_message` | Steer a running subagent (by `name`) or resume a finished one (by `sessionId`) | +| `subagents_list` | List available agent definitions | | Command | Description | | -------------------------- | ------------------------------------ | @@ -167,7 +166,7 @@ subagent({ name: "Designer", agent: "game-designer", cwd: "agents/game-designer" | ---------------------- | ------- | -------------- | ------------------------------------------------------------------------------------------------- | | `name` | string | required | Display name (shown in widget and pane title) | | `task` | string | required | Task prompt for the sub-agent | -| `agent` | string | — | Load defaults from agent definition | +| `agent` | string | required¹ | Agent profile to spawn (must be a known/permitted agent). ¹Optional only for a top-level `fork: true` clone | | `fork` | boolean | `false` | Force the full-context fork mode for this spawn, overriding any agent `session-mode` frontmatter | | `interactive` | boolean | derived | Mark this spawn as interactive (don't wake the parent on stall/recovery). Defaults to the agent's `interactive` frontmatter, otherwise the inverse of `auto-exit`. | | `model` | string | — | Override agent's default model | @@ -178,42 +177,48 @@ subagent({ name: "Designer", agent: "game-designer", cwd: "agents/game-designer" --- -## Interrupting a running subagent +## Messaging a subagent -Use `subagent_interrupt` to cancel the active turn of a running Pi-backed subagent: +`subagent_message` is the single tool for talking to a subagent after it has been spawned. It has two modes, selected by which argument you pass: + +**Steer a running subagent** — pass `name` to type a follow-up instruction directly into the live pane: ```typescript -subagent_interrupt({ id: "abcd1234" }); -// or -subagent_interrupt({ name: "Scout" }); +subagent_message({ name: "Scout", message: "Also check the auth middleware" }); ``` -This sends Escape to the child pane, cancelling the in-progress model turn. The subagent session stays alive — the pane, session file, and background polling all remain intact. After the interrupt, the widget immediately moves the child back to `waiting`, and stale pre-interrupt snapshots are ignored. If the child starts work later, newer snapshots return it to `active`; completion, failure, and `caller_ping` still flow through normally. +The message is delivered into the child's TUI editor (newlines are flattened to spaces so it fires as one turn). The child picks it up at its next turn boundary. The call returns immediately and does **not**, by itself, produce a new result — the subagent's eventual completion still arrives as a steer message. The widget moves the child to `waiting` until it resumes work. -This is a turn-level interrupt, not a method for forcibly terminating a subagent session. +**Resume a finished subagent** — pass `sessionId` (returned in the completed subagent's result) to relaunch and continue that session: -> **Note:** Only Pi-backed subagents are supported. Claude-backed runs will return an error. +```typescript +subagent_message({ sessionId: "019f05b2-f1c3", message: "Now write the tests too" }); +``` + +Resuming is fire-and-forget async: the relaunched session's result is delivered later as a steer message, exactly like a fresh `subagent` spawn. + +> **Guard:** a `sessionId` that maps to a still-running subagent is rejected — resuming would launch a second process mutating the same session file. Steer it by `name` instead. There is no hard-abort tool; to forcibly stop a subagent, use its pane directly. + +**`subagent_message` parameters:** +- `name` — exact display name of a running subagent to steer (mutually exclusive with `sessionId`) +- `sessionId` — id (or id prefix) of a finished session to resume (mutually exclusive with `name`) +- `message` (required) — the instruction or next task to deliver +- `autoExit` (resume only) — whether the resumed session auto-exits after its next response. Defaults to `true`; set `false` for an interactive handoff. --- ## caller_ping — Child-to-Parent Help Request -The `caller_ping` tool lets a subagent request help from its parent agent. When called, the child session **exits** and the parent receives a notification with the help message. The parent can then **resume** the child session with a response using `subagent_resume`. +The `caller_ping` tool lets a subagent request help from its parent agent. When called, the child session **exits** and the parent receives a notification with the help message, including the session id. The parent can then **resume** the child session with a response using `subagent_message`. **`caller_ping` parameters:** - `message` (required): What you need help with -**`subagent_resume` parameters:** -- `sessionPath` (required): Path to the child session `.jsonl` file -- `name` (optional): Display name for the resumed pane (defaults to `Resume`) -- `message` (optional): Follow-up prompt to send after resuming -- `autoExit` (optional): Whether the resumed session should auto-exit after its next response. Defaults to `true` for autonomous follow-up work; set `false` when resuming for an interactive handoff. - **Interaction flow:** 1. Child calls `caller_ping({ message: "Not sure which schema to use" })` 2. Child session exits (like `subagent_done`) -3. Parent receives a steer notification: *"Sub-agent Worker needs help: Not sure which schema to use"* -4. Parent resumes the child session via `subagent_resume` with the response +3. Parent receives a steer notification: *"Sub-agent Worker needs help: Not sure which schema to use"* plus the session id +4. Parent resumes the child session via `subagent_message({ sessionId, message })` with the response 5. Child picks up where it left off with the parent's guidance **Example:** @@ -260,7 +265,7 @@ The `tools` field is a strict allowlist. The child process is launched with exte | `model` | string | Default model (e.g. `anthropic/claude-sonnet-4-6`) | | `thinking` | string | Thinking level: `minimal`, `medium`, `high` | | `tools` | string | Strict allowlist of tool names. Built-ins (`read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`) plus custom-extension tools (`web_search`, `web_fetch`, `safe_bash`, `video_extract`, `youtube_search`, `google_image_search`). Only the extensions backing these tools are loaded into the child. | -| `subagent_agents` | string | Comma-separated list of agent names this agent may spawn. **Presence of this field grants the full spawning toolset** (`subagent`, `subagent_interrupt`, `subagents_list`, `subagent_resume`) and restricts spawn targets to the listed agents. Omit it and the agent cannot spawn at all. | +| `subagent_agents` | string | Comma-separated list of agent names this agent may spawn. **Presence of this field grants the full spawning toolset** (`subagent`, `subagent_message`, `subagents_list`) and restricts spawn targets to the listed agents. Omit it and the agent cannot spawn at all. | | `skills` | string | Comma-separated skill names to auto-load | | `session-mode` | string | Default child-session mode: `standalone`, `lineage-only`, or `fork` | | `auto-exit` | boolean | Auto-shutdown when the agent finishes its turn — no `subagent_done` call needed. If the user sends any input, auto-exit is permanently disabled and the user takes over the session. Recommended for autonomous agents (scout, researcher); not for interactive ones (worker). Also determines the default value of `interactive` (see below). | @@ -345,11 +350,20 @@ subagent({ name: "Scout", agent: "scout", interactive: true, task: "..." }); Access is **whitelist-only**. Every sub-agent process is launched with `--no-extensions` (extension discovery disabled) and `--tools `. Only the tools named in the agent's `tools` frontmatter are exposed, and only the extensions that register those tools are loaded back in via explicit `--extension` flags. There is no global default toolset and no deny-list to maintain — an agent gets exactly what it asks for. +### Spawns must name a known agent + +The agent whitelist is enforced at **every** depth, not just for restricted sub-agents: + +- A **restricted sub-agent** (one launched with `PI_SUBAGENT_ALLOWED`) may only spawn the agents pinned in its `subagent_agents` list. +- A **top-level session** may spawn any agent that appears in `subagents_list` (every discoverable definition). + +Every `subagent` call must set `agent` to a name in the caller's permitted set. A missing `agent`, or one that doesn't resolve to a real definition (e.g. `agent: "wizard"`), is rejected — it no longer silently falls back to an unrestricted, full-toolset child. The only agentless spawn allowed is a top-level `fork: true` clone, which carries the caller's own already-trusted toolset. + ### Granting the ability to spawn: `subagent_agents` Spawning is **off by default**. An agent cannot spawn sub-agents unless its frontmatter declares a `subagent_agents` list. Presence of that field: -1. Grants the full spawning toolset (`subagent`, `subagent_interrupt`, `subagents_list`, `subagent_resume`) — you do **not** list these in `tools`. +1. Grants the full spawning toolset (`subagent`, `subagent_message`, `subagents_list`) — you do **not** list these in `tools`. 2. Loads this extension into the child process. 3. Restricts the child to spawning **only** the named agents. The child's `subagents_list` is filtered to that set, and `subagent` calls for any other agent are rejected. Enforced via the `PI_SUBAGENT_ALLOWED` env var. diff --git a/agents/worker.md b/agents/worker.md index d8c4d17..c59b636 100644 --- a/agents/worker.md +++ b/agents/worker.md @@ -29,6 +29,8 @@ You can dispatch: You may only dispatch `scout` and `researcher` — no other agents are available to you. +**Always select the agent with the `agent` field**, e.g. `subagent({ agent: "scout", name: "recon", task: "…" })`. The `name` field is only a cosmetic pane label — it does NOT pick the agent. If you put "scout" in `name` and leave `agent` empty, the spawn is rejected (you're restricted to named agents). + ### When to dispatch a scout vs. read directly Dispatch a scout when: diff --git a/pi-extension/subagents/index.ts b/pi-extension/subagents/index.ts index d1b98c6..910e5d1 100644 --- a/pi-extension/subagents/index.ts +++ b/pi-extension/subagents/index.ts @@ -2,7 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-age import { keyHint } from "@mariozechner/pi-coding-agent"; import { Type, type Static } from "@sinclair/typebox"; import { Box, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { readdirSync, @@ -18,11 +18,11 @@ import { isMuxAvailable, muxSetupHint, createSurface, + sendCommand, sendLongCommand, pollForExit, closeSurface, getMuxBackend, - sendEscape, shellEscape, readScreen, } from "./cmux.ts"; @@ -30,6 +30,8 @@ import { import { findLastAssistantMessage, getNewEntries, + getSessionId, + resolveSessionFileById, seedSubagentSessionFile, summarizeSessionStats, type SessionStats, @@ -85,12 +87,19 @@ function getModuleAbortSignal(): AbortSignal { } const SubagentParams = Type.Object({ - name: Type.String({ description: "Display name for the subagent" }), + name: Type.String({ + description: + "Display label for this subagent's pane only — it does NOT select an agent profile. " + + "To run a specific agent (scout, researcher, etc.) you MUST set the `agent` field; `name` is purely cosmetic.", + }), task: Type.String({ description: "Task/prompt for the sub-agent" }), agent: Type.Optional( Type.String({ description: - "Agent name to load defaults from (e.g. 'worker', 'scout', 'reviewer'). Reads ~/.pi/agent/agents/.md for model, tools, skills.", + "Which agent profile to spawn (e.g. 'worker', 'scout', 'researcher'). " + + "This is what loads the agent's model, tools, and system prompt from .md. " + + "Set this whenever you want a specific agent — do NOT put the agent name in `name` and leave this empty, " + + "or the child launches with NO restrictions (full default toolset) instead of the agent's profile.", }), ), systemPrompt: Type.Optional( @@ -172,9 +181,8 @@ interface ListedAgentDefinition extends AgentDefinition { */ const SPAWNING_TOOLS = [ "subagent", - "subagent_interrupt", + "subagent_message", "subagents_list", - "subagent_resume", ] as const; /** Built-in tools pi provides natively — no extension needs to be loaded. */ @@ -534,12 +542,12 @@ function formatWidgetRightLabel(snapshot: StatusSnapshot): string { function resolveResultPresentation( result: Pick< SubagentResult, - "exitCode" | "elapsed" | "summary" | "sessionFile" | "errorMessage" + "exitCode" | "elapsed" | "summary" | "sessionFile" | "sessionId" | "errorMessage" >, name: string, ): string { - const sessionRef = result.sessionFile - ? `\n\nSession: ${result.sessionFile}\nResume: pi --session ${result.sessionFile}` + const sessionRef = result.sessionId + ? `\n\nSession id: ${result.sessionId}\nFollow up with subagent_message({ sessionId: "${result.sessionId}", message: "…" })` : ""; if (result.errorMessage) { @@ -552,7 +560,7 @@ function resolveResultPresentation( `(provider/agent error — auto-retry exhausted).\n\n` + `Error: ${result.errorMessage}\n\n` + `The subagent did not produce a result. You can retry by spawning a new ` + - `subagent or resume the session with subagent_resume.${sessionRef}` + `subagent or resume the session with subagent_message.${sessionRef}` ); } @@ -569,6 +577,8 @@ interface SubagentResult { task: string; summary: string; sessionFile?: string; + /** Canonical session header id, used for follow-ups via subagent_message. */ + sessionId?: string; claudeSessionId?: string; exitCode: number; elapsed: number; @@ -857,52 +867,63 @@ function observeRunningSubagent(running: RunningSubagent, observedAt = Date.now( }, observedAt); } -function resolveInterruptTarget(params: { id?: string; name?: string }): +function resolveRunningByName(name: string): | { running: RunningSubagent } | { error: string } { - const requestedId = params.id?.trim(); - if (requestedId) { - const running = runningSubagents.get(requestedId); - return running ? { running } : { error: `No running subagent with id "${requestedId}".` }; - } - - const requestedName = params.name?.trim(); + const requestedName = name.trim(); if (!requestedName) { - return { error: "Provide a running subagent id or exact display name." }; + return { error: "Provide the exact display name of a running subagent." }; } const matches = Array.from(runningSubagents.values()).filter((running) => running.name === requestedName); if (matches.length === 1) return { running: matches[0] }; if (matches.length === 0) { - return { error: `No running subagent named "${requestedName}".` }; + const names = Array.from(runningSubagents.values()).map((r) => r.name); + const hint = names.length + ? ` Currently running: ${[...new Set(names)].join(", ")}.` + : " No subagents are currently running."; + return { error: `No running subagent named "${requestedName}".${hint}` }; } const candidates = matches.map((running) => `${running.name} [${running.id}]`).join(", "); return { error: `Ambiguous subagent name "${requestedName}". Matches: ${candidates}` }; } -function requestSubagentInterrupt( +/** + * Type a follow-up message into a running subagent's live pane. Newlines are + * collapsed to spaces because each newline submits a turn in the child's TUI + * editor; a multi-line message would otherwise fire as several partial turns. + */ +function steerSubagent( running: RunningSubagent, - sendEscapeKey: (surface: string) => void = sendEscape, + message: string, + send: (surface: string, command: string) => void = sendCommand, ): { ok: true } | { error: string } { + const flattened = message.replace(/\s*\n\s*/g, " ").trim(); try { - sendEscapeKey(running.surface); + send(running.surface, flattened); return { ok: true }; } catch (error: any) { const backend = getMuxBackend() ?? "unknown"; return { error: - `Failed to send Escape to subagent "${running.name}" via ${backend}: ` + + `Failed to deliver message to subagent "${running.name}" via ${backend}: ` + `${error?.message ?? String(error)}`, }; } } -function handleSubagentInterrupt( - params: { id?: string; name?: string }, - sendEscapeKey: (surface: string) => void = sendEscape, +function handleSubagentSteer( + params: { name?: string; message?: string }, + send: (surface: string, command: string) => void = sendCommand, ) { - const resolved = resolveInterruptTarget(params); + const message = params.message?.trim(); + if (!message) { + const err = "`message` is required to steer a running subagent."; + return { content: [{ type: "text" as const, text: err }], details: { error: err } }; + } + + const resolved = resolveRunningByName(params.name ?? ""); if ("error" in resolved) { return { content: [{ type: "text" as const, text: resolved.error }], @@ -911,25 +932,14 @@ function handleSubagentInterrupt( } const running = resolved.running; - if (running.cli === "claude") { - return { - content: [{ - type: "text" as const, - text: - "Turn-only Escape interrupt is currently supported only for Pi-backed subagents. Claude-backed semantics have not been verified yet.", - }], - details: { error: "claude interrupt unsupported", id: running.id, name: running.name }, - }; - } - const now = Date.now(); observeRunningSubagent(running, now); - const interruption = requestSubagentInterrupt(running, sendEscapeKey); - if ("error" in interruption) { + const steer = steerSubagent(running, message, send); + if ("error" in steer) { return { - content: [{ type: "text" as const, text: interruption.error }], - details: { error: interruption.error, id: running.id, name: running.name }, + content: [{ type: "text" as const, text: steer.error }], + details: { error: steer.error, id: running.id, name: running.name }, }; } @@ -937,8 +947,13 @@ function handleSubagentInterrupt( updateWidget(); return { - content: [{ type: "text" as const, text: `Interrupt requested for subagent "${running.name}".` }], - details: { id: running.id, name: running.name, status: "interrupt_requested" }, + content: [{ + type: "text" as const, + text: + `Message delivered to running subagent "${running.name}". It picks this up at its next ` + + `turn boundary. If it exits, its result still arrives as a steer message.`, + }], + details: { id: running.id, name: running.name, status: "steered" }, }; } @@ -1014,9 +1029,9 @@ export const __test__ = { formatWidgetRightLabel, observeRunningSubagent, getToolExtensionPath, - resolveInterruptTarget, - requestSubagentInterrupt, - handleSubagentInterrupt, + resolveRunningByName, + steerSubagent, + handleSubagentSteer, resolveResultPresentation, resolveResumeLaunchBehavior, runningSubagents, @@ -1448,6 +1463,7 @@ async function watchSubagent( } const stats = existsSync(sessionFile) ? summarizeSessionStats(sessionFile) : null; + const subagentSessionId = existsSync(sessionFile) ? getSessionId(sessionFile) : null; closeSurface(surface); runningSubagents.delete(running.id); @@ -1457,6 +1473,7 @@ async function watchSubagent( task, summary, sessionFile, + ...(subagentSessionId ? { sessionId: subagentSessionId } : {}), exitCode: result.exitCode, elapsed, ping: result.ping, @@ -1557,19 +1574,50 @@ export default function subagentsExtension(pi: ExtensionAPI) { }; } - // Enforce the spawn allowlist when this process is itself a restricted - // subagent (e.g. a worker may only spawn scout/researcher). - if (params.agent && SUBAGENT_ALLOWLIST && !SUBAGENT_ALLOWLIST.has(params.agent)) { + // Strict whitelist at every depth. The caller's permitted set is: + // • a restricted subagent (PI_SUBAGENT_ALLOWED) → only its pinned agents; + // • a top-level session → every discoverable agent, i.e. exactly what + // `subagents_list` shows. + // Every spawn must name an agent in that set. The lone exception is a + // top-level `fork: true` clone, which has no role and inherits the + // caller's own already-trusted toolset. Without this guard a missing or + // unknown `agent` silently launches an unrestricted, full-toolset child. + const permittedAgents = SUBAGENT_ALLOWLIST + ? [...SUBAGENT_ALLOWLIST] + : discoverAgentDefinitions().map((a) => a.name); + const permittedSet = new Set(permittedAgents); + const permittedList = permittedAgents.join(", ") || "(none)"; + + if (!params.agent) { + const forkAllowed = !SUBAGENT_ALLOWLIST && params.fork === true; + if (!forkAllowed) { + return { + content: [ + { + type: "text", + text: + `You must specify which agent to spawn via the "agent" field. ` + + `Available agents: ${permittedList}. ` + + `(The "name" field is just a display label — set "agent" to one of the available agents.)`, + }, + ], + details: { error: "agent required" }, + }; + } + } else if (!permittedSet.has(params.agent)) { return { content: [ { type: "text", text: - `You may not spawn the "${params.agent}" agent. ` + - `You are only permitted to spawn: ${[...SUBAGENT_ALLOWLIST].join(", ")}.`, + `You may not spawn the "${params.agent}" agent — it is not ` + + `${SUBAGENT_ALLOWLIST ? "in your allowlist" : "a known agent"}. ` + + `Available agents: ${permittedList}.`, }, ], - details: { error: "agent not in allowlist" }, + details: { + error: SUBAGENT_ALLOWLIST ? "agent not in allowlist" : "unknown agent", + }, }; } @@ -1608,8 +1656,11 @@ export default function subagentsExtension(pi: ExtensionAPI) { updateWidget(); // reflect removal from Map immediately if (result.ping) { - // Subagent is requesting help — steer a ping message with session path for resume - const sessionRef = `\n\nSession: ${result.sessionFile}\nResume: pi --session ${result.sessionFile}`; + // Subagent is requesting help — steer a ping with its session id so + // the orchestrator can reply via subagent_message. + const sessionRef = result.sessionId + ? `\n\nSession id: ${result.sessionId}\nReply with subagent_message({ name: "${result.ping.name}", message: "…" }) while it runs, or sessionId after it exits.` + : ""; pi.sendMessage( { customType: "subagent_ping", @@ -1620,6 +1671,7 @@ export default function subagentsExtension(pi: ExtensionAPI) { message: result.ping.message, agent: running.agent, sessionFile: result.sessionFile, + ...(result.sessionId ? { sessionId: result.sessionId } : {}), }, }, { triggerTurn: true, deliverAs: "steer" }, @@ -1641,6 +1693,7 @@ export default function subagentsExtension(pi: ExtensionAPI) { exitCode: result.exitCode, elapsed: result.elapsed, sessionFile: result.sessionFile, + ...(result.sessionId ? { sessionId: result.sessionId } : {}), ...(result.errorMessage ? { errorMessage: result.errorMessage } : {}), ...(result.claudeSessionId ? { claudeSessionId: result.claudeSessionId } : {}), ...(result.stats ? { stats: result.stats } : {}), @@ -1742,57 +1795,6 @@ export default function subagentsExtension(pi: ExtensionAPI) { }, }); - // ── subagent_interrupt tool ── - pi.registerTool({ - name: "subagent_interrupt", - label: "Interrupt Subagent", - description: - "Send Escape to the active turn of a currently running Pi-backed subagent. " + - "The child pane, session, watcher, and running entry remain alive; this returns only a local acknowledgement " + - "and does not emit a subagent_result solely because of this request.", - promptSnippet: - "Send Escape to the active turn of a currently running Pi-backed subagent. " + - "The child pane, session, watcher, and running entry remain alive; this returns only a local acknowledgement " + - "and does not emit a subagent_result solely because of this request.", - parameters: Type.Object({ - id: Type.Optional(Type.String({ description: "Exact running subagent id" })), - name: Type.Optional(Type.String({ description: "Exact running subagent display name" })), - }), - - async execute(_toolCallId, params) { - return handleSubagentInterrupt(params); - }, - - renderCall(args, theme) { - const target = args.id ? `${args.id}` : args.name ?? "(unknown)"; - return new Text( - theme.fg("accent", "○") + - " " + - theme.fg("toolTitle", theme.bold(target)) + - theme.fg("dim", " — interrupt turn"), - 0, - 0, - ); - }, - - renderResult(result, _opts, theme) { - const details = result.details as any; - if (details?.status === "interrupt_requested") { - return new Text( - theme.fg("accent", "✗") + - " " + - theme.fg("toolTitle", theme.bold(details.name ?? details.id ?? "subagent")) + - theme.fg("dim", " — interrupt requested"), - 0, - 0, - ); - } - - const text = typeof result.content[0]?.text === "string" ? result.content[0].text : ""; - return new Text(theme.fg("dim", text), 0, 0); - }, - }); - // ── subagents_list tool ── pi.registerTool({ name: "subagents_list", @@ -1848,98 +1850,145 @@ export default function subagentsExtension(pi: ExtensionAPI) { - // ── subagent_resume tool ── + // ── subagent_message tool ── pi.registerTool({ - name: "subagent_resume", - label: "Resume Subagent", + name: "subagent_message", + label: "Message Subagent", description: - "Resume a previous sub-agent session in a new multiplexer pane. " + - "This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " + - "When the resumed sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " + - "DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT poll for status. All of that is wasted work — the harness handles delivery for you. " + - "DO NOT fabricate or assume results. After resuming, either end your turn or work on other independent tasks; the harness will wake you when the result is ready. " + - "Use when a sub-agent was cancelled or needs follow-up work.", + "Send a message to a subagent — either steer one that is still running, or resume a finished one. " + + "Provide exactly ONE of: `name` (the display name of a currently-running subagent, to inject a follow-up instruction into its live session) " + + "or `sessionId` (the id returned in a completed subagent's result, to relaunch that session and continue it). " + + "`message` is always required. " + + "Steering a running subagent returns immediately with a local acknowledgement and does NOT, by itself, emit a new result. " + + "Resuming is a fire-and-forget async call: when the resumed sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up. " + + "DO NOT poll, sleep, tail logs, or read session files to detect completion — the harness handles delivery. " + + "DO NOT fabricate or assume results. After calling, either end your turn or work on other independent tasks.", promptSnippet: - "Resume a previous sub-agent session in a new multiplexer pane. " + - "This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " + - "When the resumed sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " + - "DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT poll for status. All of that is wasted work — the harness handles delivery for you. " + - "DO NOT fabricate or assume results. After resuming, either end your turn or work on other independent tasks; the harness will wake you when the result is ready. " + - "Use when a sub-agent was cancelled or needs follow-up work.", + "Message a subagent: steer a running one (by name) or resume a finished one (by sessionId). " + + "`message` is required. Steering returns immediately; resuming delivers its result later as a steer message. " + + "Do not poll or fabricate results.", parameters: Type.Object({ - sessionPath: Type.String({ description: "Path to the session .jsonl file to resume" }), name: Type.Optional( - Type.String({ description: "Display name for the terminal tab. Default: 'Resume'" }), - ), - message: Type.Optional( Type.String({ - description: "Optional message to send after resuming (e.g. follow-up instructions)", + description: + "Exact display name of a currently-running subagent to steer. Mutually exclusive with sessionId.", }), ), + sessionId: Type.Optional( + Type.String({ + description: + "Session id (or id prefix) of a finished subagent to resume, as returned in its result. Mutually exclusive with name.", + }), + ), + message: Type.String({ + description: + "The message to deliver: a follow-up instruction for a running subagent, or the next task for a resumed session.", + }), autoExit: Type.Optional( Type.Boolean({ description: - "Whether the resumed session should automatically exit after completing its response. Defaults to true for autonomous follow-up work; set false for interactive resumed sessions.", + "Resume only: whether the resumed session should automatically exit after completing its response. Defaults to true for autonomous follow-up work; set false for interactive resumed sessions.", }), ), }), renderCall(args, theme) { - const name = args.name ?? "Resume"; - const text = - "○ " + - theme.fg("toolTitle", theme.bold(name)) + - theme.fg("dim", " — resuming session"); - return new Text(text, 0, 0); + const target = args.name ?? args.sessionId ?? "(unknown)"; + const verb = args.name ? " — steering" : " — resuming session"; + return new Text( + "○ " + theme.fg("toolTitle", theme.bold(target)) + theme.fg("dim", verb), + 0, + 0, + ); }, renderResult(result, _opts, theme) { const details = result.details as any; - const name = details?.name ?? "Resume"; + + if (details?.status === "steered") { + return new Text( + theme.fg("success", "✓") + + " " + + theme.fg("toolTitle", theme.bold(details.name ?? "subagent")) + + theme.fg("dim", " — message delivered"), + 0, + 0, + ); + } if (details?.status === "started") { return new Text( theme.fg("accent", "⟳") + " " + - theme.fg("toolTitle", theme.bold(name)) + + theme.fg("toolTitle", theme.bold(details.name ?? "Resume")) + theme.fg("dim", " — resumed"), 0, 0, ); } - // Fallback + // Fallback / error const text = typeof result.content[0]?.text === "string" ? result.content[0].text : ""; return new Text(theme.fg("dim", text), 0, 0); }, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - const name = params.name ?? "Resume"; - const { autoExit, interactive } = resolveResumeLaunchBehavior(params); - const startTime = Date.now(); - const id = Math.random().toString(16).slice(2, 10); + const hasName = !!params.name?.trim(); + const hasSession = !!params.sessionId?.trim(); + if (hasName === hasSession) { + const err = + "Provide exactly one of `name` (steer a running subagent) or `sessionId` (resume a finished session)."; + return { content: [{ type: "text" as const, text: err }], details: { error: err } }; + } if (!isMuxAvailable()) { return muxUnavailableResult(); } - if (!existsSync(params.sessionPath)) { - return { - content: [ - { type: "text", text: `Error: session file not found: ${params.sessionPath}` }, - ], - details: { error: "session not found" }, - }; + // ── Steer a running subagent ── + if (hasName) { + return handleSubagentSteer({ name: params.name, message: params.message }); } + // ── Resume a finished session ── + const requestedId = params.sessionId!.trim(); + const message = params.message; + const name = params.name ?? "Resume"; + const { autoExit, interactive } = resolveResumeLaunchBehavior(params); + const startTime = Date.now(); + const id = Math.random().toString(16).slice(2, 10); + + // Resolve the id (or path) to a concrete session file. + const sessionsRoot = dirname(ctx.sessionManager.getSessionDir()); + const sessionPath = + requestedId.includes("/") || requestedId.includes("\\") || requestedId.endsWith(".jsonl") + ? requestedId + : resolveSessionFileById(requestedId, sessionsRoot); + + if (!sessionPath || !existsSync(sessionPath)) { + const err = `No session found for id "${requestedId}". Use the session id returned in a completed subagent's result.`; + return { content: [{ type: "text" as const, text: err }], details: { error: err } }; + } + + // Guard: never resume a session that is still running — two processes + // mutating the same .jsonl corrupts it. Steer it by name instead. + for (const r of runningSubagents.values()) { + if (resolve(r.sessionFile) === resolve(sessionPath)) { + const err = `Session "${requestedId}" is still running as "${r.name}". Use name: "${r.name}" to steer it instead of resuming.`; + return { content: [{ type: "text" as const, text: err }], details: { error: err } }; + } + } + + const resumedSessionId = getSessionId(sessionPath) ?? requestedId; + // Record entry count before resuming so we can extract new messages - const entryCountBefore = getNewEntries(params.sessionPath, 0).length; + const entryCountBefore = getNewEntries(sessionPath, 0).length; const surface = createSurface(name); await new Promise((resolve) => setTimeout(resolve, getShellReadyDelayMs())); // Build pi resume command - const parts = ["pi", "--session", shellEscape(params.sessionPath)]; + const parts = ["pi", "--session", shellEscape(sessionPath)]; // Load subagent-done extension so the agent can self-terminate if needed const subagentDonePath = join(SUBAGENTS_DIR, "subagent-done.ts"); @@ -1964,7 +2013,7 @@ export default function subagentsExtension(pi: ExtensionAPI) { .replace(/^-|-$/g, "") || "resume"}-${msgTimestamp}.md`, ); mkdirSync(dirname(resumeMsgFile), { recursive: true }); - writeFileSync(resumeMsgFile, params.message, "utf8"); + writeFileSync(resumeMsgFile, message, "utf8"); parts.push(shellEscape(`@${resumeMsgFile}`)); } @@ -1974,7 +2023,7 @@ export default function subagentsExtension(pi: ExtensionAPI) { resumeEnvParts.push(`PI_CODING_AGENT_DIR=${shellEscape(process.env.PI_CODING_AGENT_DIR)}`); } resumeEnvParts.push(`PI_SUBAGENT_NAME=${shellEscape(name)}`); - resumeEnvParts.push(`PI_SUBAGENT_SESSION=${shellEscape(params.sessionPath)}`); + resumeEnvParts.push(`PI_SUBAGENT_SESSION=${shellEscape(sessionPath)}`); resumeEnvParts.push(`PI_SUBAGENT_ID=${shellEscape(id)}`); resumeEnvParts.push(`PI_SUBAGENT_ACTIVITY_FILE=${shellEscape(activityFile)}`); if (autoExit) { @@ -1998,7 +2047,7 @@ export default function subagentsExtension(pi: ExtensionAPI) { scriptPreamble: [ `# Subagent resume script for ${name}`, `# Generated: ${new Date().toISOString()}`, - `# Session: ${params.sessionPath}`, + `# Session: ${sessionPath}`, `# Surface: ${surface}`, ...(resumeMsgFile ? [`# Resume message file: ${resumeMsgFile}`] : []), ].join("\n"), @@ -2008,10 +2057,10 @@ export default function subagentsExtension(pi: ExtensionAPI) { const running: RunningSubagent = { id, name, - task: params.message ?? "resumed session", + task: message, surface, startTime, - sessionFile: params.sessionPath, + sessionFile: sessionPath, launchScriptFile, activityFile, interactive, @@ -2033,7 +2082,7 @@ export default function subagentsExtension(pi: ExtensionAPI) { updateWidget(); if (result.ping) { - const sessionRef = `\n\nSession: ${params.sessionPath}\nResume: pi --session ${params.sessionPath}`; + const sessionRef = `\n\nSession id: ${resumedSessionId}\nReply with subagent_message({ name: "${result.ping.name}", message: "…" }) while it runs, or sessionId after it exits.`; pi.sendMessage( { customType: "subagent_ping", @@ -2042,7 +2091,8 @@ export default function subagentsExtension(pi: ExtensionAPI) { details: { name: result.ping.name, message: result.ping.message, - sessionFile: params.sessionPath, + sessionFile: sessionPath, + sessionId: resumedSessionId, }, }, { triggerTurn: true, deliverAs: "steer" }, @@ -2050,7 +2100,7 @@ export default function subagentsExtension(pi: ExtensionAPI) { return; } - const allEntries = getNewEntries(params.sessionPath, entryCountBefore); + const allEntries = getNewEntries(sessionPath, entryCountBefore); const summary = findLastAssistantMessage(allEntries) ?? (result.errorMessage ? `Subagent error: ${result.errorMessage}` @@ -2058,7 +2108,7 @@ export default function subagentsExtension(pi: ExtensionAPI) { ? `Resumed session exited with code ${result.exitCode}` : "Resumed session exited without new output"); const presentation = resolveResultPresentation( - { ...result, summary, sessionFile: params.sessionPath }, + { ...result, summary, sessionFile: sessionPath, sessionId: resumedSessionId }, name, ); @@ -2069,10 +2119,11 @@ export default function subagentsExtension(pi: ExtensionAPI) { display: true, details: { name, - task: params.message ?? "resumed session", + task: message, exitCode: result.exitCode, elapsed: result.elapsed, - sessionFile: params.sessionPath, + sessionFile: sessionPath, + sessionId: resumedSessionId, ...(result.errorMessage ? { errorMessage: result.errorMessage } : {}), }, }, @@ -2097,7 +2148,8 @@ export default function subagentsExtension(pi: ExtensionAPI) { details: { id, name, - sessionPath: params.sessionPath, + sessionId: resumedSessionId, + sessionFile: sessionPath, launchScriptFile, status: "started", }, @@ -2188,7 +2240,7 @@ export default function subagentsExtension(pi: ExtensionAPI) { // Clean summary (remove session ref and leading label for display) const summary = rawContent - .replace(/\n\nSession: .+\nResume: .+$/, "") + .replace(/\n\nSession id: [\s\S]+$/, "") .replace(`Sub-agent "${name}" completed (${elapsed}).\n\n`, "") .replace(`Sub-agent "${name}" failed (exit code ${exitCode}).\n\n`, "") .replace( @@ -2209,10 +2261,20 @@ export default function subagentsExtension(pi: ExtensionAPI) { contentLines.push(line.slice(0, width - 6)); } } - if (details.sessionFile) { + if (details.sessionId || details.sessionFile) { contentLines.push(""); - contentLines.push(theme.fg("dim", `Session: ${details.sessionFile}`)); - contentLines.push(theme.fg("dim", `Resume: pi --session ${details.sessionFile}`)); + if (details.sessionId) { + contentLines.push(theme.fg("dim", `Session id: ${details.sessionId}`)); + contentLines.push( + theme.fg( + "dim", + `Follow up: subagent_message({ sessionId: "${details.sessionId}", message: "…" })`, + ), + ); + } + if (details.sessionFile) { + contentLines.push(theme.fg("muted", `Session file: ${details.sessionFile}`)); + } } } else { // Collapsed: preview + expand hint diff --git a/pi-extension/subagents/session.ts b/pi-extension/subagents/session.ts index 573ed6a..81c8614 100644 --- a/pi-extension/subagents/session.ts +++ b/pi-extension/subagents/session.ts @@ -1,4 +1,13 @@ -import { appendFileSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { + appendFileSync, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs"; import { randomBytes, randomUUID } from "node:crypto"; import { dirname, join } from "node:path"; @@ -83,6 +92,87 @@ export function getLeafId(sessionFile: string): string | null { return entries.length > 0 ? entries[entries.length - 1].id : null; } +/** + * Read the canonical session id from a session file's header. + * + * pi's `--session ` flag resolves against this header `id` (exact match, + * then prefix), NOT the filename — so this is the value to hand back to the + * orchestrator for follow-ups. + */ +export function getSessionId(sessionFile: string): string | null { + try { + const raw = readFileSync(sessionFile, "utf8"); + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const entry = JSON.parse(trimmed) as { type?: string; id?: string }; + if (entry.type === "session" && typeof entry.id === "string") { + return entry.id; + } + // Header is always the first entry; bail after the first parse. + break; + } + } catch { + // fall through + } + return null; +} + +function readHeaderId(sessionFile: string): string | null { + try { + const raw = readFileSync(sessionFile, "utf8"); + const firstLine = raw.split("\n", 1)[0]?.trim(); + if (!firstLine) return null; + const entry = JSON.parse(firstLine) as { type?: string; id?: string }; + return entry.type === "session" && typeof entry.id === "string" ? entry.id : null; + } catch { + return null; + } +} + +/** + * Resolve a session id (or id prefix) to a session file path by scanning every + * `*.jsonl` under `sessionsRoot` and matching the header `id`. Mirrors pi's own + * resolution order: exact match first, then prefix match. Most recently + * modified file wins on ties. Returns null when nothing matches. + */ +export function resolveSessionFileById(sessionId: string, sessionsRoot: string): string | null { + if (!sessionId || !existsSync(sessionsRoot)) return null; + + const candidates: Array<{ path: string; id: string; mtime: number }> = []; + const walk = (dir: string) => { + let entries: import("node:fs").Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.isFile() && entry.name.endsWith(".jsonl")) { + const id = readHeaderId(full); + if (!id) continue; + let mtime = 0; + try { + mtime = statSync(full).mtimeMs; + } catch { + /* ignore */ + } + candidates.push({ path: full, id, mtime }); + } + } + }; + walk(sessionsRoot); + + candidates.sort((a, b) => b.mtime - a.mtime); + const exact = candidates.find((c) => c.id === sessionId); + if (exact) return exact.path; + const prefix = candidates.find((c) => c.id.startsWith(sessionId)); + return prefix ? prefix.path : null; +} + /** * Return entries added after `afterLine` (1-indexed count of existing entries). */ diff --git a/test/test.ts b/test/test.ts index 60e1ffb..6dc3b36 100644 --- a/test/test.ts +++ b/test/test.ts @@ -10,6 +10,8 @@ import * as subagentsModule from "../pi-extension/subagents/index.ts"; import { getLeafId, getNewEntries, + getSessionId, + resolveSessionFileById, findLastAssistantMessage, appendBranchSummary, copySessionFile, @@ -259,6 +261,40 @@ describe("session.ts", () => { }); }); + describe("getSessionId / resolveSessionFileById", () => { + function writeSession(d: string, fname: string, id: string): string { + const p = join(d, fname); + writeFileSync(p, JSON.stringify({ type: "session", id, version: 3 }) + "\n"); + return p; + } + + it("reads the header id from a session file", () => { + const file = createSessionFile(dir, [SESSION_HEADER, MODEL_CHANGE, USER_MSG]); + assert.equal(getSessionId(file), "sess-001"); + }); + + it("returns null for a file without a session header", () => { + const file = createSessionFile(dir, [USER_MSG]); + assert.equal(getSessionId(file), null); + }); + + it("resolves a session file by exact id under the root", () => { + const a = writeSession(dir, "a.jsonl", "019f-aaaa"); + writeSession(dir, "b.jsonl", "019f-bbbb"); + assert.equal(resolveSessionFileById("019f-aaaa", dir), a); + }); + + it("resolves a session file by id prefix", () => { + const a = writeSession(dir, "p.jsonl", "019f-prefix-match"); + assert.equal(resolveSessionFileById("019f-prefix", dir), a); + }); + + it("returns null when no session matches", () => { + writeSession(dir, "c.jsonl", "abc"); + assert.equal(resolveSessionFileById("zzz", dir), null); + }); + }); + describe("findLastAssistantMessage", () => { it("finds last assistant text", () => { const entries = [USER_MSG, ASSISTANT_MSG, ASSISTANT_MSG_2] as any[]; @@ -1085,7 +1121,7 @@ describe("subagent discovery", () => { const allowlist = testApi.buildSubagentToolAllowlist(worker.tools, { grantSpawning: true }); assert.ok(allowlist, "expected an allowlist"); const tools = new Set(allowlist!.split(",")); - for (const t of ["subagent", "subagent_interrupt", "subagents_list", "subagent_resume"]) { + for (const t of ["subagent", "subagent_message", "subagents_list"]) { assert.ok(tools.has(t), `expected spawning tool ${t} in worker allowlist`); } assert.ok(tools.has("safe_bash"), "expected worker to keep safe_bash"); @@ -1484,6 +1520,46 @@ describe("tool registration", () => { }); + it("rejects a top-level spawn with no agent and no fork", async () => { + const { api, registeredTools } = createMockExtensionApi(); + (subagentsModule as any).default(api); + const subagentTool = registeredTools.find((tool) => tool.name === "subagent"); + assert.ok(subagentTool, "expected subagent tool to be registered"); + + const result = await subagentTool.execute("call-1", { name: "x", task: "do it" }); + assert.equal(result.details?.error, "agent required"); + assert.match(result.content[0].text, /specify which agent/i); + }); + + it("rejects a top-level spawn naming an unknown agent", async () => { + const { api, registeredTools } = createMockExtensionApi(); + (subagentsModule as any).default(api); + const subagentTool = registeredTools.find((tool) => tool.name === "subagent"); + assert.ok(subagentTool, "expected subagent tool to be registered"); + + const result = await subagentTool.execute("call-1", { + name: "x", + task: "do it", + agent: "wizard", + }); + assert.equal(result.details?.error, "unknown agent"); + assert.match(result.content[0].text, /not a known agent/i); + }); + + it("documents that `agent` (not `name`) selects the agent profile", () => { + const { api, registeredTools } = createMockExtensionApi(); + (subagentsModule as any).default(api); + + const subagentTool = registeredTools.find((tool) => tool.name === "subagent"); + assert.ok(subagentTool, "expected subagent tool to be registered"); + + const props = subagentTool.parameters.properties; + // `name` must clarify it is cosmetic and does NOT select a profile. + assert.match(props.name.description, /does NOT select/i); + // `agent` must steer the model to set it for a specific profile. + assert.match(props.agent.description, /which agent profile to spawn/i); + }); + it("renders partial subagent tool-call args without throwing", () => { const { api, registeredTools } = createMockExtensionApi(); (subagentsModule as any).default(api); @@ -1505,17 +1581,31 @@ describe("tool registration", () => { assert.match(output, /\(unnamed\)/); }); - it("registers subagent_resume with an autoExit override", () => { + it("registers subagent_message with name, sessionId, message, and an autoExit override", () => { const { api, registeredTools } = createMockExtensionApi(); (subagentsModule as any).default(api); - const resumeTool = registeredTools.find((tool) => tool.name === "subagent_resume"); - assert.ok(resumeTool, "expected subagent_resume tool to be registered"); + const messageTool = registeredTools.find((tool) => tool.name === "subagent_message"); + assert.ok(messageTool, "expected subagent_message tool to be registered"); - const autoExitSchema = resumeTool.parameters.properties.autoExit; + const props = messageTool.parameters.properties; + assert.ok(props.name, "expected name param"); + assert.ok(props.sessionId, "expected sessionId param"); + assert.equal(props.message.type, "string"); + assert.equal(messageTool.parameters.required?.includes("message"), true); + + const autoExitSchema = props.autoExit; assert.equal(autoExitSchema.type, "boolean"); assert.match(autoExitSchema.description, /Defaults to true/); }); + + it("no longer registers subagent_interrupt or subagent_resume", () => { + const { api, registeredTools } = createMockExtensionApi(); + (subagentsModule as any).default(api); + const names = registeredTools.map((tool) => tool.name); + assert.equal(names.includes("subagent_interrupt"), false); + assert.equal(names.includes("subagent_resume"), false); + }); }); describe("subagent activity snapshots", () => { @@ -1705,15 +1795,16 @@ describe("subagent interruption", () => { }; } - it("registers subagent_interrupt in the main session extension", () => { + it("registers subagent_message and not the old interrupt/resume tools", () => { const { api, registeredTools } = createMockExtensionApi(); - (subagentsModule as any).default(api); - - assert.equal(registeredTools.some((tool) => tool.name === "subagent_interrupt"), true); + const names = registeredTools.map((tool) => tool.name); + assert.equal(names.includes("subagent_message"), true); + assert.equal(names.includes("subagent_interrupt"), false); + assert.equal(names.includes("subagent_resume"), false); }); - it("resolves interrupt targets by exact id and reports name ambiguity", () => { + it("resolves a running subagent by exact name and reports ambiguity", () => { const testApi = (subagentsModule as any).__test__; const runningMap = testApi.runningSubagents as Map; runningMap.clear(); @@ -1723,147 +1814,51 @@ describe("subagent interruption", () => { runningMap.set("b2", makeRunning({ id: "b2", name: "Worker", surface: "b2", sessionFile: "b2.jsonl" })); runningMap.set("c3", makeRunning({ id: "c3", name: "Scout", surface: "c3", sessionFile: "c3.jsonl" })); - const byId = testApi.resolveInterruptTarget({ id: "c3", name: "Worker" }); - assert.equal(byId.running.id, "c3"); + const byName = testApi.resolveRunningByName("Scout"); + assert.equal(byName.running.id, "c3"); - const ambiguous = testApi.resolveInterruptTarget({ name: "Worker" }); + const ambiguous = testApi.resolveRunningByName("Worker"); assert.match(ambiguous.error, /Ambiguous subagent name/); + + const missing = testApi.resolveRunningByName("Ghost"); + assert.match(missing.error, /No running subagent named "Ghost"/); } finally { runningMap.clear(); } }); - it("returns an explicit error when Escape delivery fails", () => { + it("steers a running subagent by typing into its pane (newlines flattened)", () => { const testApi = (subagentsModule as any).__test__; - let aborted = false; - const running = makeRunning({ - abortController: { - abort() { - aborted = true; - }, - }, - }); - - const result = testApi.requestSubagentInterrupt(running, () => { - throw new Error("mux write failed"); - }); - - assert.match(result.error, /Failed to send Escape/); - assert.equal(aborted, false); - assert.equal("interruptRequested" in running, false); - }); - - it("leaves status unchanged when Escape delivery fails in the tool path", () => { - const testApi = (subagentsModule as any).__test__; - const runningMap = testApi.runningSubagents as Map; - runningMap.clear(); - - const activeState = observeStatus( - createStatusState({ source: "pi", startTimeMs: 0 }), - { - snapshot: "present", - updatedAt: 5_000, - sequence: 1, - phase: "active", - active: true, - activeScope: "tool", - activeSince: 5_000, - activityLabel: "bash", - }, - 5_000, - ); - - try { - runningMap.set("a1", makeRunning({ statusState: activeState })); - - const result = withMockedNow(20_000, () => testApi.handleSubagentInterrupt({ name: "Worker" }, () => { - throw new Error("mux write failed"); - })); - - assert.match(result.content[0].text, /Failed to send Escape/); - assert.equal(classifyStatus(runningMap.get("a1").statusState, 20_000).kind, "active"); - } finally { - runningMap.clear(); - } - }); - - it("sends Escape without aborting or mutating running state", () => { - const testApi = (subagentsModule as any).__test__; - let aborted = false; let sentSurface = ""; - const running = makeRunning({ - abortController: { - abort() { - aborted = true; - }, - }, - }); + let sentText = ""; + const running = makeRunning(); - const result = testApi.requestSubagentInterrupt(running, (surface: string) => { + const result = testApi.steerSubagent(running, "do this\nthen that", (surface: string, text: string) => { sentSurface = surface; + sentText = text; }); assert.deepEqual(result, { ok: true }); assert.equal(sentSurface, "pane-1"); - assert.equal(aborted, false); - assert.equal("interruptRequested" in running, false); + assert.equal(sentText, "do this then that"); }); - it("refreshes the latest activity snapshot before forcing local interrupt waiting", () => { + it("returns an explicit error when steering delivery fails", () => { const testApi = (subagentsModule as any).__test__; - const runningMap = testApi.runningSubagents as Map; - let sentSurface = ""; - runningMap.clear(); + const running = makeRunning(); - withTempDir((dir) => { - mkdirSync(join(dir, "subagent-activity"), { recursive: true }); - const activityFile = getSubagentActivityFile(dir, "a1"); - const activity = { - version: 1, - runningChildId: "a1", - createdAt: 1_000, - updatedAt: 19_000, - sequence: 7, - latestEvent: "tool_execution_start", - phase: "active", - agentActive: true, - turnActive: true, - providerActive: false, - toolActive: true, - activeScope: "tool", - activeSince: 19_000, - toolName: "bash", - }; - writeFileSync(activityFile, `${JSON.stringify(activity)}\n`); - - try { - runningMap.set("a1", makeRunning({ - activityFile, - statusState: createStatusState({ source: "pi", startTimeMs: 0 }), - })); - - withMockedNow(20_000, () => testApi.handleSubagentInterrupt({ name: "Worker" }, (surface: string) => { - sentSurface = surface; - })); - - assert.equal(sentSurface, "pane-1"); - const state = runningMap.get("a1").statusState; - const snapshot = classifyStatus(state, 20_000); - assert.equal(snapshot.kind, "waiting"); - assert.equal(snapshot.activityLabel, "interrupted"); - assert.equal(state.lastActivityAtMs, 20_000); - assert.equal(state.lastActivitySequence, 7); - assert.equal(state.localOverrideSequence, 7); - } finally { - runningMap.clear(); - } + const result = testApi.steerSubagent(running, "hi", () => { + throw new Error("mux write failed"); }); + + assert.match(result.error, /Failed to deliver message/); }); - it("acknowledges Pi-backed interrupt requests and forces local status waiting", () => { + it("delivers a steer message and forces local status waiting", () => { const testApi = (subagentsModule as any).__test__; const runningMap = testApi.runningSubagents as Map; let sentSurface = ""; + let sentText = ""; runningMap.clear(); const activeState = observeStatus( @@ -1884,65 +1879,69 @@ describe("subagent interruption", () => { try { runningMap.set("a1", makeRunning({ statusState: activeState })); - const result = withMockedNow(20_000, () => testApi.handleSubagentInterrupt({ name: "Worker" }, (surface: string) => { - sentSurface = surface; - })); + const result = withMockedNow(20_000, () => + testApi.handleSubagentSteer({ name: "Worker", message: "keep going" }, (surface: string, text: string) => { + sentSurface = surface; + sentText = text; + }), + ); assert.equal(sentSurface, "pane-1"); - assert.equal(result.content[0].text, 'Interrupt requested for subagent "Worker".'); - assert.deepEqual(result.details, { id: "a1", name: "Worker", status: "interrupt_requested" }); + assert.equal(sentText, "keep going"); + assert.equal(result.content[0].text.includes('Message delivered to running subagent "Worker"'), true); + assert.deepEqual(result.details, { id: "a1", name: "Worker", status: "steered" }); const snapshot = classifyStatus(runningMap.get("a1").statusState, 20_000); assert.equal(snapshot.kind, "waiting"); - assert.equal(snapshot.activityLabel, "interrupted"); assert.equal(runningMap.has("a1"), true); } finally { runningMap.clear(); } }); - it("sends Escape again for repeated interrupt requests", () => { + it("requires a message when steering", () => { const testApi = (subagentsModule as any).__test__; const runningMap = testApi.runningSubagents as Map; - const surfaces: string[] = []; runningMap.clear(); - try { runningMap.set("a1", makeRunning()); - - testApi.handleSubagentInterrupt({ name: "Worker" }, (surface: string) => { - surfaces.push(surface); - }); - testApi.handleSubagentInterrupt({ name: "Worker" }, (surface: string) => { - surfaces.push(surface); - }); - - assert.deepEqual(surfaces, ["pane-1", "pane-1"]); - assert.equal(runningMap.has("a1"), true); + const result = testApi.handleSubagentSteer({ name: "Worker", message: " " }, () => {}); + assert.match(result.content[0].text, /`message` is required/); } finally { runningMap.clear(); } }); - it("rejects Claude-backed interrupt requests before delivery", () => { + it("leaves status unchanged when steering delivery fails in the tool path", () => { const testApi = (subagentsModule as any).__test__; const runningMap = testApi.runningSubagents as Map; - let delivered = false; runningMap.clear(); + const activeState = observeStatus( + createStatusState({ source: "pi", startTimeMs: 0 }), + { + snapshot: "present", + updatedAt: 5_000, + sequence: 1, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 5_000, + activityLabel: "bash", + }, + 5_000, + ); + try { - runningMap.set("a1", makeRunning({ cli: "claude" })); + runningMap.set("a1", makeRunning({ statusState: activeState })); - const result = testApi.handleSubagentInterrupt({ name: "Worker" }, () => { - delivered = true; - }); + const result = withMockedNow(20_000, () => + testApi.handleSubagentSteer({ name: "Worker", message: "go" }, () => { + throw new Error("mux write failed"); + }), + ); - assert.equal(delivered, false); - assert.match(result.content[0].text, /currently supported only for Pi-backed subagents/i); - assert.deepEqual(result.details, { - error: "claude interrupt unsupported", - id: "a1", - name: "Worker", - }); + assert.match(result.content[0].text, /Failed to deliver message/); + assert.equal(classifyStatus(runningMap.get("a1").statusState, 20_000).kind, "active"); } finally { runningMap.clear(); } @@ -1956,13 +1955,15 @@ describe("subagent interruption", () => { elapsed: 61, summary: "Sub-agent exited with code 130", sessionFile: "/tmp/subagent.jsonl", + sessionId: "019f-abc", }, "Worker", ); assert.match(presentation, /failed \(exit code 130\)/); assert.doesNotMatch(presentation, /interrupted/); - assert.match(presentation, /Resume: pi --session/); + assert.match(presentation, /Session id: 019f-abc/); + assert.match(presentation, /subagent_message/); }); it("renders a clear provider/agent error when errorMessage is set", () => { @@ -1978,6 +1979,7 @@ describe("subagent interruption", () => { elapsed: 14, summary: "ignored when errorMessage is present", sessionFile: "/tmp/subagent.jsonl", + sessionId: "019f-xyz", errorMessage: "Anthropic 529 Overloaded after 3 retries", }, "Worker", @@ -1986,8 +1988,8 @@ describe("subagent interruption", () => { assert.match(presentation, /Sub-agent "Worker" failed/); assert.match(presentation, /provider\/agent error — auto-retry exhausted/); assert.match(presentation, /Error: Anthropic 529 Overloaded after 3 retries/); - assert.match(presentation, /subagent_resume/); - assert.match(presentation, /Resume: pi --session/); + assert.match(presentation, /subagent_message/); + assert.match(presentation, /Session id: 019f-xyz/); assert.doesNotMatch(presentation, /ignored when errorMessage is present/); }); });