diff --git a/README.md b/README.md index 9218d93..bd02c98 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,26 @@ export PI_SUBAGENT_SHELL_READY_DELAY_MS=2500 Subagent panes are created without stealing keyboard focus (cmux, tmux). Launch commands target child surfaces by explicit ID, so focus and command delivery are independent. Note: the `interactive` option controls parent status notifications, not terminal focus. +### Pane layout (tmux) + +Under tmux, each subagent is a horizontal split off the parent pi pane. Left to itself, tmux halves the target pane on every split and dumps freed space onto a neighbor on close, so panes drift to wildly uneven widths. To prevent that, the extension automatically re-applies an even layout to the subagent window after every spawn and every exit (debounced so a burst of parallel spawns or staggered exits collapses into a single layout call). This only runs under tmux — cmux uses tabs, and zellij/wezterm manage their own layouts. + +The layout is a single constant in `pi-extension/subagents/cmux.ts`: + +```ts +const SUBAGENT_TMUX_LAYOUT = "even-horizontal"; +``` + +Change it to switch the geometry, then reload the extension (or restart pi): + +| Value | Geometry | +| ------------------ | ------------------------------------------------------------------------ | +| `even-horizontal` | Equal columns across the window (matches a manual `Ctrl+b` then `Alt+1`). Default. | +| `main-vertical` | One large main pane on the left, subagents tiled in a column on the right. | +| `tiled` | Grid of roughly equal panes — scales best when many subagents run at once. | + +The value is passed straight to `tmux select-layout`, so any named tmux layout works. An unrecognized value is ignored silently (the resize is best-effort and never blocks spawning), so double-check the spelling if a change seems to have no effect. + ## What's Included ### Extensions @@ -171,31 +191,37 @@ Resuming is fire-and-forget async: the relaunched session always runs its follow --- -## caller_ping — Child-to-Parent Help Request +## ask_question — Subagent Asks the Orchestrator -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`. +The `ask_question` tool lets a subagent ask its parent (the orchestrator that spawned it) a single freeform question — for ambiguous requirements, a decision that materially affects the work, a blocker, or confirmation only the parent has. It is framed like the top-level `ask_user_question` tool so agents reach for it proactively instead of guessing. -**`caller_ping` parameters:** -- `message` (required): What you need help with +Unlike the old `caller_ping`, **the session stays open while it waits.** Auto-exit is suppressed for that turn, the subagent parks in a `waiting` state, and the parent replies with `subagent_message` — which lands as the subagent's next turn so it continues right where it paused. + +**`ask_question` parameters:** +- `question` (required): The single freeform question. Include enough context to answer it directly. No options/multiple-choice — ask one question per call. **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"* 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 +1. Child calls `ask_question({ question: "Found two migration files — use v1 or v2?" })` +2. The child's turn ends but the **session stays open** (parked as `waiting`); the tool returns *"Question sent — stop and wait for the reply."* +3. The parent's watcher picks up the question and steers a notification: *"Sub-agent worker-2 asks: Found two migration files — use v1 or v2?"* plus the unique subagent name (and session id as backup) +4. Parent replies while it runs via `subagent_message({ name, message })` (or `subagent_message({ sessionId, message })` if it has since exited) +5. The reply arrives as the subagent's next message and it continues with the parent's guidance + +**Parallel questions are supported.** Each subagent has its own session and its own watcher, so multiple subagents can be waiting on answers at once. Default names are deduped at spawn (`worker`, `worker-2`, …), and every notification surfaces that unique name, so each reply targets the right subagent. **Example:** ```typescript // Inside a worker subagent -await caller_ping({ - message: "Found two conflicting migration files — should I use v1 or v2?" +await ask_question({ + question: "Found two conflicting migration files — should I use v1 or v2?" }); -// Session exits here. Parent receives the ping, then resumes this session -// with guidance like "Use v2, v1 is deprecated" +// Session stays open. Parent answers with subagent_message({ name: "worker", message: "Use v2…" }), +// which arrives as this subagent's next turn. ``` -> **Note:** `caller_ping` is only available inside subagent contexts. Calling it from a standalone pi session returns an error. +> **Note:** `ask_question` is only available inside subagent contexts. Calling it from a standalone pi session returns an error. +> +> If the parent never replies, the subagent stays open indefinitely (shown as `waiting`) until the human closes its pane — mirroring interactive-agent behavior. There is no timeout. --- @@ -232,7 +258,7 @@ The `tools` field is a strict allowlist. The child process is launched with exte | `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). | +| `auto-exit` | boolean | Auto-shutdown when the agent finishes its turn. 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). | | `interactive` | boolean | derived | Override whether stall/recovery transitions wake the parent session. Defaults to the inverse of `auto-exit`: autonomous agents (`auto-exit: true`) are non-interactive and get stall pings; agents without `auto-exit` are interactive and stay quiet. Explicit values take precedence. | | `cwd` | string | Default working directory (absolute or relative to project root) | | `disable-model-invocation` | boolean | Hide this agent from discovery surfaces like `subagents_list`. The agent still remains directly invokable by explicit name via `subagent({ agent: "name", ... })`. | @@ -260,13 +286,20 @@ session-mode: lineage-only ### `auto-exit` -When set to `true`, the agent session shuts down automatically as soon as the agent finishes its turn — no explicit `subagent_done` call is needed. +When set to `true`, the agent session shuts down automatically as soon as the agent finishes its turn. The agent just writes its final message and stops; there is no "done" tool to call. The last assistant message becomes the summary returned to the parent. **Behavior:** - The session closes after the agent's final message (on the `agent_end` event) - If the user sends **any input** before the agent finishes, auto-exit is permanently disabled for that session — the user takes over interactively -- The modeHint injected into the agent's task is adjusted accordingly: autonomous agents see "Complete your task autonomously." rather than instructions to call `subagent_done` +- The modeHint injected into the agent's task is adjusted accordingly: autonomous agents are told to simply stop when finished, while interactive agents are told the session ends when the user exits the pane + +**Auto-exit is suppressed while work is still in flight.** Even an `auto-exit` agent will *not* shut down at the end of a turn when either: + +- it has a pending [`ask_question`](#ask_question--subagent-asks-the-orchestrator) awaiting the orchestrator's reply, or +- it spawned **its own child subagents** (e.g. a `worker` delegating to `scout`/`researcher`) that haven't finished yet. + +In both cases the session parks as `waiting` instead of exiting, and resumes when the next turn lands (the reply, or a child's result steered back). This is what lets a worker write *"I'll wait for the results"* and stop without killing the session out from under its children — it stays open until the **last** child reports back, then the worker's final summary is returned to the orchestrator as usual. Child liveness is read from the spawning extension's live count, so a nested agent that never spawned anything still exits immediately as before. **When to use:** diff --git a/agents/worker.md b/agents/worker.md index 7e70d9f..d237a7a 100644 --- a/agents/worker.md +++ b/agents/worker.md @@ -11,14 +11,14 @@ auto-exit: true You are a worker agent. You operate in an isolated context — you have no knowledge of any prior conversation. All necessary context will be provided in the task description. -You run in your own interactive pane. Work autonomously to complete the assigned task, but the user can step in at any time. When you have finished the task, call the `subagent_done` tool to signal completion and hand control back. If you get stuck and need a decision from the orchestrator, call `caller_ping` with your question. +You run in your own pane and work autonomously to complete the assigned task. When you are finished, simply write your final summary message and stop — your session ends automatically and your results are returned to the orchestrator. Do not announce that you are finishing; just produce the answer. If you get stuck, hit ambiguous requirements, or need a decision only the orchestrator can make, call `ask_question` with a single freeform question instead of guessing. Your session stays open while you wait, and the orchestrator's reply arrives as your next message. Guidelines: - Read files before editing to understand existing code - Make targeted edits, not wholesale rewrites - Use `safe_bash` for running commands (tests, builds, installs, etc.) - If something fails, diagnose and fix it -- Your FINAL assistant message (before calling `subagent_done`) should summarize what you did and what changed +- Your FINAL assistant message should summarize what you did and what changed ## Delegation — protecting your context window @@ -61,6 +61,8 @@ Fetch directly when: If you need two independent investigations (e.g. "map the auth code" AND "look up the library's session API"), emit multiple `subagent` tool calls in the same turn — they run in parallel automatically. Don't serialize independent work. After spawning, the results arrive as steer messages — don't poll or fabricate them. +After dispatching subagents you can just say what you're waiting for and stop the turn — your session will **not** close while children are still running. It stays open until every child has reported back, then wakes you with each result. Don't spin in a loop trying to "check" on them. + ### What a subagent doesn't replace Subagents can't edit files for you. You still do the `edit`/`write` calls yourself, with the focused context the scouts gave you. Treat them as a context-protecting prefetch, not a substitute for thinking. diff --git a/pi-extension/subagents/activity.ts b/pi-extension/subagents/activity.ts index 6e93c58..5fd2f7a 100644 --- a/pi-extension/subagents/activity.ts +++ b/pi-extension/subagents/activity.ts @@ -20,8 +20,7 @@ export type SubagentActivityEvent = | "tool_execution_update" | "tool_result" | "tool_execution_end" - | "caller_ping" - | "subagent_done" + | "ask_question" | "session_shutdown"; export interface SubagentActivityState { @@ -70,8 +69,7 @@ export interface SubagentActivityRecorder { toolExecutionUpdate(toolCallId?: string, toolName?: string): void; toolResult(toolCallId?: string, toolName?: string): void; toolExecutionEnd(toolCallId?: string, toolName?: string): void; - callerPing(): void; - subagentDone(): void; + askQuestion(): void; sessionShutdown(reason: SubagentShutdownReason): void; } @@ -95,8 +93,7 @@ const KNOWN_EVENTS = new Set([ "tool_execution_update", "tool_result", "tool_execution_end", - "caller_ping", - "subagent_done", + "ask_question", "session_shutdown", ]); const MAX_ACTIVITY_STRING_LENGTH = 200; @@ -239,8 +236,7 @@ function createNoopRecorder(): SubagentActivityRecorder { toolExecutionUpdate() {}, toolResult() {}, toolExecutionEnd() {}, - callerPing() {}, - subagentDone() {}, + askQuestion() {}, sessionShutdown() {}, }; } @@ -497,11 +493,15 @@ export function createSubagentActivityRecorder(params: { refreshActiveScope(current); }, "immediate"); }, - callerPing() { - markDone("caller_ping"); - }, - subagentDone() { - markDone("subagent_done"); + askQuestion() { + // The subagent paused to ask the orchestrator a question. Park it in the + // "waiting" phase (do NOT disable the recorder) so the status widget shows + // it as waiting and recording resumes when the answer arrives. + record("ask_question", (current, observedAt) => { + clearActiveState(current); + current.phase = "waiting"; + current.waitingSince = observedAt; + }, "immediate"); }, sessionShutdown(reason) { if (reason === "quit") markDone("session_shutdown"); diff --git a/pi-extension/subagents/cmux.ts b/pi-extension/subagents/cmux.ts index 23cd09c..edb9294 100644 --- a/pi-extension/subagents/cmux.ts +++ b/pi-extension/subagents/cmux.ts @@ -1256,28 +1256,23 @@ export function closeSurface(surface: string): void { export interface PollResult { /** How the subagent exited */ - reason: "done" | "ping" | "sentinel" | "error"; + reason: "done" | "sentinel" | "error"; /** Shell exit code (from sentinel). 0 for file-based exits. */ exitCode: number; - /** Ping data if reason is "ping" */ - ping?: { name: string; message: string }; /** Error message if reason is "error" (auto-retry exhausted, provider overload, etc.) */ errorMessage?: string; } /** - * Interpret an `.exit` sidecar payload (written by subagent_done / caller_ping / - * the error path in subagent-done.ts). Centralized so both the fast and slow - * paths in pollForExit decode the payload the same way. + * Interpret an `.exit` sidecar payload (written by the error path in + * subagent-done.ts). Centralized so both the fast and slow paths in + * pollForExit decode the payload the same way. Clean completions write no + * sidecar and are detected via the terminal sentinel instead. + * + * Note: ask_question does NOT write a `.exit` sidecar — it keeps the session + * open and signals the parent via a separate `.ask` file (see deliverPendingQuestion). */ function interpretExitSidecar(data: any): PollResult { - if (data?.type === "ping") { - return { - reason: "ping", - exitCode: 0, - ping: { name: data.name, message: data.message }, - }; - } if (data?.type === "error") { const errorMessage = typeof data.errorMessage === "string" && data.errorMessage.trim() !== "" @@ -1292,8 +1287,8 @@ export const __pollForExitTest__ = { interpretExitSidecar }; /** * Poll until the subagent exits. Checks for a `.exit` sidecar file first - * (written by subagent_done / caller_ping), falling back to the terminal - * sentinel for crash detection. + * (written by the error path), falling back to the terminal sentinel for + * clean-completion and crash detection. */ export async function pollForExit( surface: string, @@ -1312,7 +1307,7 @@ export async function pollForExit( throw new Error("Aborted while waiting for subagent to finish"); } - // Fast path: check for .exit sidecar file (written by subagent_done / caller_ping) + // Fast path: check for .exit sidecar file (written by the error path) if (options.sessionFile) { try { const exitFile = `${options.sessionFile}.exit`; diff --git a/pi-extension/subagents/index.ts b/pi-extension/subagents/index.ts index 8c80264..64d65c8 100644 --- a/pi-extension/subagents/index.ts +++ b/pi-extension/subagents/index.ts @@ -548,7 +548,6 @@ interface SubagentResult { error?: string; /** Provider/agent error message when auto-retry exhausted (overload, rate limit, etc.). */ errorMessage?: string; - ping?: { name: string; message: string }; /** Aggregate usage/model/tool stats parsed from the completed session file. */ stats?: SessionStats; } @@ -588,10 +587,21 @@ interface RunningSubagent { /** All currently running subagents, keyed by id. */ const runningSubagents = new Map(); +// When this extension is loaded inside a subagent that itself spawns children +// (e.g. a worker delegating to scout/researcher), `subagent-done.ts` runs in the +// same process and needs to know whether this session still has children in +// flight — so it can suppress auto-exit and keep the session open until they all +// report back. Expose a live count through a process-global symbol that both +// modules share. (subagent-done.ts reads it; if absent it assumes zero.) +const RUNNING_CHILDREN_COUNT_KEY = Symbol.for("pi-subagents/running-children-count"); +(globalThis as any)[RUNNING_CHILDREN_COUNT_KEY] = () => runningSubagents.size; + // ── Widget management ── /** Latest ExtensionContext from session_start, used for widget updates. */ let latestCtx: ExtensionContext | null = null; +/** Latest ExtensionAPI, used to deliver ask_question notifications from the watcher. */ +let latestPi: ExtensionAPI | null = null; /** Interval timer for widget re-renders. */ let widgetInterval: ReturnType | null = null; @@ -731,7 +741,7 @@ function updateWidget() { * first positional message so that /skill: args land in messages[1..] and arrive * as standalone prompts in the child session. */ -const SUBAGENT_CONTROL_TOOLS = ["caller_ping", "subagent_done"] as const; +const SUBAGENT_CONTROL_TOOLS = ["ask_question"] as const; /** * Build the child --tools allowlist. @@ -739,7 +749,7 @@ const SUBAGENT_CONTROL_TOOLS = ["caller_ping", "subagent_done"] as const; * Pi 0.70+ applies --tools to built-in, extension, and custom tools. If a * subagent definition restricts tools to e.g. "read,bash,write", the child * control tools from subagent-done.ts would otherwise be hidden, leaving a - * manually resumed or user-touched subagent unable to call subagent_done. + * manually resumed or user-touched subagent unable to call ask_question. */ function buildSubagentToolAllowlist( effectiveTools?: string, @@ -1113,11 +1123,11 @@ async function launchSubagent( // Only full-context fork mode inherits prior conversation state. // Blank-session modes need the wrapper instructions and artifact-backed handoff. const modeHint = agentDefs?.autoExit - ? "Complete your task autonomously." - : "Complete your task. When finished, call the subagent_done tool. The user can interact with you at any time."; + ? "Complete your task autonomously. When you are finished, simply stop — your session ends automatically." + : "Complete your task. The user can interact with you at any time, and the session ends when the user exits the pane."; const summaryInstruction = agentDefs?.autoExit ? "Your FINAL assistant message should summarize what you accomplished." - : "Your FINAL assistant message (before calling subagent_done or before the user exits) should summarize what you accomplished."; + : "Your FINAL assistant message (before the user exits) should summarize what you accomplished."; // An agent with a non-empty subagent_agents list is granted the spawning // toolset and may only spawn the listed agents (enforced via PI_SUBAGENT_ALLOWED). const grantSpawning = !!(agentDefs?.subagentAgents && agentDefs.subagentAgents.length > 0); @@ -1377,6 +1387,50 @@ function copyClaudeSession(sentinelFile: string): string | null { } } +/** + * Detect an `ask_question` signal from a still-running subagent and notify the + * orchestrator without ending the subagent. Each subagent has its own + * `${sessionFile}.ask` file and its own watcher, so parallel questions from + * multiple subagents are delivered independently. The file is deleted after + * delivery so it fires once per question (a subagent may ask again later). + */ +function deliverPendingQuestion(running: RunningSubagent): void { + const askFile = `${running.sessionFile}.ask`; + let payload: any = null; + try { + if (!existsSync(askFile)) return; + payload = JSON.parse(readFileSync(askFile, "utf-8")); + } catch { + // Malformed/partway-written file — drop it and move on. + } + try { + unlinkSync(askFile); + } catch {} + if (!payload?.question) return; + + const name = running.name; // unique (deduped at spawn) — used to target the reply + const sessionId = existsSync(running.sessionFile) ? getSessionId(running.sessionFile) : null; + const elapsed = Math.floor((Date.now() - running.startTime) / 1000); + const replyHint = sessionId + ? `\n\nReply with subagent_message({ name: "${name}", message: "…" }) while it runs (or sessionId: "${sessionId}" after it exits). It stays open until you reply.` + : `\n\nReply with subagent_message({ name: "${name}", message: "…" }). It stays open until you reply.`; + + latestPi?.sendMessage( + { + customType: "subagent_question", + content: `Sub-agent "${name}" asks (${formatElapsed(elapsed)}):\n\n${payload.question}${replyHint}`, + display: true, + details: { + name, + agent: running.agent, + question: payload.question, + ...(sessionId ? { sessionId } : {}), + }, + }, + { triggerTurn: true, deliverAs: "steer" }, + ); +} + async function watchSubagent( running: RunningSubagent, signal: AbortSignal, @@ -1390,6 +1444,7 @@ async function watchSubagent( sentinelFile: running.sentinelFile, onTick() { observeRunningSubagent(running); + deliverPendingQuestion(running); }, }); @@ -1464,7 +1519,6 @@ async function watchSubagent( ...(subagentSessionId ? { sessionId: subagentSessionId } : {}), exitCode: result.exitCode, elapsed, - ping: result.ping, ...(result.errorMessage ? { errorMessage: result.errorMessage } : {}), ...(stats ? { stats } : {}), }; @@ -1497,6 +1551,7 @@ async function watchSubagent( } export default function subagentsExtension(pi: ExtensionAPI) { + latestPi = pi; // Capture the UI context for widget updates pi.on("session_start", (_event, ctx) => { latestCtx = ctx; @@ -1666,30 +1721,6 @@ export default function subagentsExtension(pi: ExtensionAPI) { .then((result) => { updateWidget(); // reflect removal from Map immediately - if (result.ping) { - // 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", - content: `Sub-agent "${result.ping.name}" needs help (${formatElapsed(result.elapsed)}):\n\n${result.ping.message}${sessionRef}`, - display: true, - details: { - name: result.ping.name, - message: result.ping.message, - agent: running.agent, - sessionFile: result.sessionFile, - ...(result.sessionId ? { sessionId: result.sessionId } : {}), - }, - }, - { triggerTurn: true, deliverAs: "steer" }, - ); - return; - } - const presentation = resolveResultPresentation(result, running.name); pi.sendMessage( @@ -2091,25 +2122,6 @@ export default function subagentsExtension(pi: ExtensionAPI) { .then((result) => { updateWidget(); - if (result.ping) { - 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", - content: `Sub-agent "${result.ping.name}" needs help (${formatElapsed(result.elapsed)}):\n\n${result.ping.message}${sessionRef}`, - display: true, - details: { - name: result.ping.name, - message: result.ping.message, - sessionFile: sessionPath, - sessionId: resumedSessionId, - }, - }, - { triggerTurn: true, deliverAs: "steer" }, - ); - return; - } - const allEntries = getNewEntries(sessionPath, entryCountBefore); const summary = findLastAssistantMessage(allEntries) ?? (result.errorMessage @@ -2338,8 +2350,8 @@ export default function subagentsExtension(pi: ExtensionAPI) { }; }); - // ── subagent_ping message renderer ── - pi.registerMessageRenderer("subagent_ping", (message, options, theme) => { + // ── subagent_question message renderer ── + pi.registerMessageRenderer("subagent_question", (message, options, theme) => { const details = message.details as any; if (!details) return undefined; @@ -2350,19 +2362,19 @@ export default function subagentsExtension(pi: ExtensionAPI) { const bgFn = (text: string) => theme.bg("toolSuccessBg", text); const icon = theme.fg("accent", "?"); - const header = `${icon} ${theme.fg("toolTitle", theme.bold(name))}${agentTag} ${theme.fg("dim", "— needs help")}`; + const header = `${icon} ${theme.fg("toolTitle", theme.bold(name))}${agentTag} ${theme.fg("dim", "— asks a question")}`; const contentLines = [header]; if (options.expanded) { contentLines.push(""); - contentLines.push(details.message ?? ""); - if (details.sessionFile) { + contentLines.push(details.question ?? ""); + if (details.sessionId) { contentLines.push(""); - contentLines.push(theme.fg("dim", `Session: ${details.sessionFile}`)); + contentLines.push(theme.fg("dim", `Session id: ${details.sessionId}`)); } } else { - const preview = (details.message ?? "").split("\n")[0].slice(0, width - 10); + const preview = (details.question ?? "").split("\n")[0].slice(0, width - 10); contentLines.push(theme.fg("dim", preview)); contentLines.push(theme.fg("muted", keyHint("app.tools.expand", "to expand"))); } diff --git a/pi-extension/subagents/subagent-done.ts b/pi-extension/subagents/subagent-done.ts index 1dea3d7..e301d1b 100644 --- a/pi-extension/subagents/subagent-done.ts +++ b/pi-extension/subagents/subagent-done.ts @@ -1,7 +1,16 @@ /** * Extension loaded into sub-agents. * - Shows agent identity + available tools as a styled widget above the editor (toggle with Ctrl+Alt+O) - * - Provides a `subagent_done` tool for autonomous agents to self-terminate + * - Provides an `ask_question` tool for asking the parent orchestrator a question + * + * Subagents do NOT self-terminate via a tool. Auto-exit agents shut down + * automatically when their agent loop ends (see the `agent_end` handler); + * interactive agents end when the human exits the pane. + * + * `ask_question` keeps the session OPEN: it writes a `${sessionFile}.ask` + * signal the parent's watcher picks up, parks the session in a "waiting" state + * (auto-exit is suppressed for that turn via `awaitingAnswer`), and the parent + * replies with subagent_message — which lands as the subagent's next turn. */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Box, Text } from "@mariozechner/pi-tui"; @@ -13,6 +22,32 @@ export function shouldMarkUserTookOver(agentStarted: boolean): boolean { return agentStarted; } +/** + * Number of child subagents this session itself still has in flight. + * + * When this extension is loaded inside a subagent that can spawn its own + * children (e.g. a worker delegating to scout/researcher), `index.ts` runs in + * the same process and publishes a live count through a shared process-global + * symbol. A subagent that spawns children and then writes a "waiting for + * results" message would otherwise auto-exit the instant that turn ends — + * killing the session before its children report back. Reading this count lets + * `agent_end` keep the session open until every child has finished and its + * result has been delivered. + * + * Returns 0 when the spawning tools aren't loaded (scout/researcher, or a + * standalone session), so those agents auto-exit exactly as before. + */ +export function runningChildrenCount(): number { + const fn = (globalThis as any)[Symbol.for("pi-subagents/running-children-count")]; + if (typeof fn !== "function") return 0; + try { + const n = fn(); + return typeof n === "number" && n > 0 ? n : 0; + } catch { + return 0; + } +} + export function shouldAutoExitOnAgentEnd( _userTookOver: boolean, messages: any[] | undefined, @@ -143,6 +178,10 @@ export default function (pi: ExtensionAPI) { let userTookOver = false; let agentStarted = false; + // Set when ask_question is called; suppresses auto-exit for that turn so the + // session stays open while it waits for the orchestrator's reply. Cleared at + // the start of the next turn (when the reply lands). + let awaitingAnswer = false; // Show widget + status bar on session start pi.on("session_start", (_event, ctx) => { @@ -168,12 +207,27 @@ export default function (pi: ExtensionAPI) { pi.on("agent_start", () => { agentStarted = true; + // A new turn is starting — any pending ask_question has now been answered + // (or superseded), so let auto-exit resume normally when this turn ends. + awaitingAnswer = false; recorder.agentStart(); }); pi.on("agent_end", (event, ctx) => { const messages = (event as any).messages as any[] | undefined; - const shouldExit = autoExit && shouldAutoExitOnAgentEnd(userTookOver, messages); + // Never shut down while this session still has work in flight: + // - awaitingAnswer: an ask_question is pending the orchestrator's reply. + // - runningChildrenCount(): this subagent spawned its own children and is + // waiting for their results (delivered as steered turns). Exiting now + // would strand those children and drop their results. + // In both cases the session parks as `waiting` and resumes when the next + // turn lands. + const hasPendingChildren = runningChildrenCount() > 0; + const shouldExit = + !awaitingAnswer && + !hasPendingChildren && + autoExit && + shouldAutoExitOnAgentEnd(userTookOver, messages); if (shouldExit) { // Surface stopReason: "error" turns (auto-retry exhausted, provider @@ -266,59 +320,70 @@ export default function (pi: ExtensionAPI) { }); pi.registerTool({ - name: "caller_ping", - label: "Caller Ping", + name: "ask_question", + label: "ask_question", description: - "Send a help request to the parent agent and exit this session. " + - "The parent will be notified with your message and can resume this session with a response. " + - "Use when you're stuck, need clarification, or need the parent to take action.", + "Ask the orchestrator (the parent agent that spawned you) a single question and pause until they reply. " + + "Use this when requirements are ambiguous, a decision would materially affect your work, you're blocked, " + + "or you need information or confirmation only the orchestrator has. Prefer asking over guessing. " + + "Your session stays open while you wait — the answer arrives as your next message, then you continue. " + + "Ask exactly one question per call; make separate calls for unrelated questions.", + promptSnippet: + "Use this tool to ask the orchestrator one clarifying, missing-requirement, preference, or decision question before continuing — instead of guessing.", + promptGuidelines: [ + "Ask exactly one question per tool call.", + "If you need answers to multiple things, make separate ask_question calls instead of bundling them.", + "Prefer this tool over guessing when requirements, preferences, or implementation choices are unclear.", + "Use it when multiple valid paths exist and the right one depends on the orchestrator's intent.", + "Give enough context in the question that the orchestrator can answer without re-reading your whole task.", + "After asking, stop and wait — the reply will arrive as your next message.", + ], parameters: Type.Object({ - message: Type.String({ description: "What you need help with" }), + question: Type.String({ + description: + "The single freeform question to ask the orchestrator. Include enough context to answer it directly.", + }), }), - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { const sessionFile = process.env.PI_SUBAGENT_SESSION; if (!sessionFile) { throw new Error( - "caller_ping is only available in subagent contexts. " + + "ask_question is only available in subagent contexts. " + "PI_SUBAGENT_SESSION environment variable is not set.", ); } - recorder.callerPing(); - const exitData = { - type: "ping" as const, + // Keep the session open: suppress auto-exit for this turn and park in the + // "waiting" phase. The parent's watcher picks up the `.ask` signal and + // notifies the orchestrator, who replies via subagent_message. + awaitingAnswer = true; + recorder.askQuestion(); + const askData = { name: process.env.PI_SUBAGENT_NAME ?? "subagent", - message: params.message, + agent: process.env.PI_SUBAGENT_AGENT ?? "", + question: params.question, }; - writeFileSync(`${sessionFile}.exit`, JSON.stringify(exitData)); + writeFileSync(`${sessionFile}.ask`, JSON.stringify(askData)); - ctx.shutdown(); return { - content: [{ type: "text", text: "Ping sent. Session will exit and parent will be notified." }], - details: {}, + content: [ + { + type: "text", + text: + "Question sent to the orchestrator. Stop here and wait — do not continue working or " + + "assume an answer. Their reply will arrive as your next message.", + }, + ], + details: { question: params.question }, }; }, + + renderCall(args, theme) { + const text = + theme.fg("toolTitle", theme.bold("ask_question ")) + + theme.fg("muted", String((args as any).question ?? "")); + return new Text(text, 0, 0); + }, }); - pi.registerTool({ - name: "subagent_done", - label: "Subagent Done", - description: - "Call this tool when you have completed your task. " + - "It will close this session and return your results to the main session. " + - "Your LAST assistant message before calling this becomes the summary returned to the caller.", - parameters: Type.Object({}), - async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { - const sessionFile = process.env.PI_SUBAGENT_SESSION; - recorder.subagentDone(); - if (sessionFile) { - writeFileSync(`${sessionFile}.exit`, JSON.stringify({ type: "done" })); - } - ctx.shutdown(); - return { - content: [{ type: "text", text: "Shutting down subagent session." }], - details: {}, - }; - }, - }); } diff --git a/test/test.ts b/test/test.ts index dae8336..f2babae 100644 --- a/test/test.ts +++ b/test/test.ts @@ -1,6 +1,6 @@ import { describe, it, before, after } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, rmSync } from "node:fs"; +import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, rmSync, existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; @@ -56,7 +56,9 @@ import { shouldMarkUserTookOver, shouldAutoExitOnAgentEnd, findLatestAssistantError, + runningChildrenCount, } from "../pi-extension/subagents/subagent-done.ts"; +import subagentDoneExtension from "../pi-extension/subagents/subagent-done.ts"; import { __pollForExitTest__ } from "../pi-extension/subagents/cmux.ts"; // --- Helpers --- @@ -1179,7 +1181,7 @@ describe("subagent discovery", () => { it("buildSubagentToolAllowlist preserves requested tools and adds child control tools", () => { assert.equal( testApi.buildSubagentToolAllowlist("read,bash,web_search"), - "read,bash,web_search,caller_ping,subagent_done", + "read,bash,web_search,ask_question", ); }); @@ -1389,19 +1391,116 @@ describe("subagent-done.ts", () => { assert.equal(findLatestAssistantError([]), null); }); }); + + describe("runningChildrenCount", () => { + const KEY = Symbol.for("pi-subagents/running-children-count"); + function withGlobal(value: unknown, run: () => void) { + const prev = (globalThis as any)[KEY]; + (globalThis as any)[KEY] = value; + try { + run(); + } finally { + (globalThis as any)[KEY] = prev; + } + } + + it("returns 0 when the spawning tools aren't loaded (no global)", () => { + withGlobal(undefined, () => { + assert.equal(runningChildrenCount(), 0); + }); + }); + + it("reflects the live child count published by index.ts", () => { + withGlobal(() => 3, () => { + assert.equal(runningChildrenCount(), 3); + }); + }); + + it("treats zero/negative/non-number/throwing getters as 0", () => { + withGlobal(() => 0, () => assert.equal(runningChildrenCount(), 0)); + withGlobal(() => -1, () => assert.equal(runningChildrenCount(), 0)); + withGlobal(() => "two", () => assert.equal(runningChildrenCount(), 0)); + withGlobal(() => { throw new Error("boom"); }, () => assert.equal(runningChildrenCount(), 0)); + }); + }); + + describe("ask_question tool", () => { + function setupSubagentExtension(sessionFile: string) { + const saved = { + session: process.env.PI_SUBAGENT_SESSION, + name: process.env.PI_SUBAGENT_NAME, + agent: process.env.PI_SUBAGENT_AGENT, + autoExit: process.env.PI_SUBAGENT_AUTO_EXIT, + }; + process.env.PI_SUBAGENT_SESSION = sessionFile; + process.env.PI_SUBAGENT_NAME = "scout-2"; + process.env.PI_SUBAGENT_AGENT = "scout"; + process.env.PI_SUBAGENT_AUTO_EXIT = "1"; + const mock = createMockExtensionApi(); + subagentDoneExtension(mock.api); + const restore = () => { + restoreEnvVar("PI_SUBAGENT_SESSION", saved.session); + restoreEnvVar("PI_SUBAGENT_NAME", saved.name); + restoreEnvVar("PI_SUBAGENT_AGENT", saved.agent); + restoreEnvVar("PI_SUBAGENT_AUTO_EXIT", saved.autoExit); + }; + return { mock, restore }; + } + + it("registers ask_question (and no caller_ping) with a single freeform question param", () => { + const dir = createTestDir(); + const { mock, restore } = setupSubagentExtension(join(dir, "s.jsonl")); + try { + const names = mock.registeredTools.map((t) => t.name); + assert.ok(names.includes("ask_question")); + assert.ok(!names.includes("caller_ping")); + const tool = mock.registeredTools.find((t) => t.name === "ask_question"); + assert.deepEqual(Object.keys(tool.parameters.properties), ["question"]); + assert.match(tool.description, /orchestrator/i); + } finally { + restore(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("writes a .ask signal with name/agent/question and does NOT shut the session down", async () => { + const dir = createTestDir(); + const sessionFile = join(dir, "s.jsonl"); + const { mock, restore } = setupSubagentExtension(sessionFile); + try { + const tool = mock.registeredTools.find((t) => t.name === "ask_question"); + let shutdownCalled = false; + const ctx = { shutdown() { shutdownCalled = true; } } as any; + const out = await tool.execute("call-1", { question: "Which API base URL?" }, undefined, undefined, ctx); + + assert.equal(shutdownCalled, false, "ask_question must keep the session open"); + assert.match(out.content[0].text, /wait/i); + + const askFile = `${sessionFile}.ask`; + assert.ok(existsSync(askFile), ".ask signal file should be written"); + const payload = JSON.parse(readFileSync(askFile, "utf-8")); + assert.equal(payload.question, "Which API base URL?"); + assert.equal(payload.name, "scout-2"); + assert.equal(payload.agent, "scout"); + // No .exit sidecar — the session is not exiting. + assert.ok(!existsSync(`${sessionFile}.exit`)); + } finally { + restore(); + rmSync(dir, { recursive: true, force: true }); + } + }); + }); }); describe("cmux.ts interpretExitSidecar", () => { const { interpretExitSidecar } = __pollForExitTest__; - it("decodes ping payloads", () => { + it("no longer decodes ping payloads (ask_question keeps the session open instead)", () => { + // ask_question writes a `.ask` signal, not a `.exit` ping sidecar, so an + // unknown `type: "ping"` payload now falls through to a clean done. assert.deepEqual( interpretExitSidecar({ type: "ping", name: "Worker", message: "need help" }), - { - reason: "ping", - exitCode: 0, - ping: { name: "Worker", message: "need help" }, - }, + { reason: "done", exitCode: 0 }, ); }); @@ -1638,7 +1737,7 @@ describe("subagent activity snapshots", () => { assert.equal(read.activity.waitingSince, 3_000); currentNow = 4_000; - recorder.subagentDone(); + recorder.agentEndDone(); read = readSubagentActivityFile(activityFile, "child-2"); assert.ok(read.ok); assert.equal(read.activity.phase, "done");