Skip to content
67 changes: 66 additions & 1 deletion packages/coding-agent/docs/rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ Response:

`success: true` means the prompt was accepted, queued, or handled immediately. `success: false` means the prompt was rejected before acceptance. Failures after acceptance are reported through the normal event and message stream, not as a second `response` for the same request id.

Acceptance is not settlement.
If the parent spawns coordinated children, events continue while RPC mode waits for the frozen child batch and automatically resumes the parent with hidden results.
Wait for `agent_settled`, not the `prompt` response or the first `agent_end`, before treating the prompt cycle as complete.

The `images` field is optional. Each image uses `ImageContent` format: `{"type": "image", "data": "base64-encoded-data", "mimeType": "image/png"}`.

#### steer
Expand Down Expand Up @@ -125,6 +129,8 @@ See [set_follow_up_mode](#set_follow_up_mode) for controlling how follow-up mess

Abort the current agent operation.

During a coordinated child wait, this interrupts every unfinished child in the frozen batch, emits terminal notices and a cancelled wait end event, and settles without another parent provider call.

```json
{"type": "abort"}
```
Expand Down Expand Up @@ -850,6 +856,9 @@ Events are streamed to stdout as JSON lines during agent operation. Events do no
| `tool_execution_update` | Tool execution progress (streaming output) |
| `tool_execution_end` | Tool completes |
| `queue_update` | Pending steering/follow-up queue changed |
| `subagent_wait_start` | Parent begins waiting for a frozen, name-sorted child batch |
| `subagent_notice` | One child reaches a terminal state; contains deterministic safe metadata only |
| `subagent_wait_end` | Frozen child batch wait ends or is cancelled |
| `compaction_start` | Compaction begins |
| `compaction_end` | Compaction completes |
| `auto_retry_start` | Auto-retry begins (after transient error) |
Expand Down Expand Up @@ -881,7 +890,8 @@ Emitted when one low-level agent run completes. Contains all messages generated

### agent_settled

Emitted after the full session-level run settles. At this point Diffex will not continue automatically through retry, compaction retry, or queued follow-up messages.
Emitted after the full session-level run settles.
At this point Diffex will not continue automatically through retry, compaction retry, coordinated child delivery, or queued follow-up messages.

```json
{"type": "agent_settled"}
Expand Down Expand Up @@ -1026,6 +1036,61 @@ Emitted whenever the pending steering or follow-up queue changes.
}
```

### subagent_wait_start / subagent_notice / subagent_wait_end

When a parent assistant run ends with unconsumed children spawned through parent tool calls, RPC mode emits `subagent_wait_start` with the frozen, name-sorted batch:

```json
{
"type": "subagent_wait_start",
"names": ["alpha", "beta"]
}
```

Each child emits one `subagent_notice` when its current attempt reaches a terminal state:

```json
{
"type": "subagent_notice",
"name": "beta",
"status": "idle",
"attempt": "initial",
"retryAvailable": false,
"text": "beta has finished execution."
}
```

Notice text is deterministic:

| Condition | Text |
|-----------|------|
| Successful completion | `<name> has finished execution.` |
| Initial failure with a retry available | `<name> failed and a retry is being planned.` |
| Failure after the coordinated retry | `<name> failed after one retry.` |
| Interruption | `<name> was interrupted.` |

Notice events never contain child results, partial output, or errors.
They are not added to agent messages, persisted as session entries, or included in provider context.

After every child is terminal, RPC mode emits the wait end event:

```json
{
"type": "subagent_wait_end",
"names": ["alpha", "beta"],
"cancelled": false
}
```

Diffex then supplies one hidden, non-displayed context message to the parent with name-sorted child results, partial output, errors, and retry availability.
That payload is model context, not a result-bearing session event.
The parent continues automatically, and a later `agent_settled` marks completion.

Terminal child results explicitly returned by the `agents` tool are consumed and excluded from automatic delivery.
An initial failure offers one corrective wake through `agent_action` per child for the current parent prompt cycle.
A second retry in the same cycle is rejected, while a later explicit user prompt starts a fresh cycle.
The parent can decline a retry and settle normally.

### compaction_start / compaction_end

Emitted when compaction runs, whether manual or automatic.
Expand Down
37 changes: 34 additions & 3 deletions packages/coding-agent/docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,9 @@ interface PromptOptions {
- `true` when the prompt was accepted, queued, or handled immediately
- `false` when prompt preflight rejected before acceptance

It fires before `prompt()` resolves. `prompt()` still resolves only after the full accepted run finishes, including retries. Failures after acceptance are reported through the normal event and message stream, not through `preflightResult(false)`.
It fires before `prompt()` resolves.
`prompt()` still resolves only after the full accepted run finishes, including retries and coordinated child-agent batches.
Failures after acceptance are reported through the normal event and message stream, not through `preflightResult(false)`.

The `prompt()` method handles prompt templates, extension commands, and message sending:

Expand Down Expand Up @@ -315,10 +317,19 @@ session.subscribe((event) => {
// event.toolResults: tool results from this turn
break;

// Session events (queue, compaction, retry)
// Session events (queue, compaction, retry, child coordination)
case "queue_update":
console.log(event.steering, event.followUp);
break;
case "subagent_wait_start":
console.log(`Waiting for: ${event.names.join(", ")}`);
break;
case "subagent_notice":
console.log(event.text);
break;
case "subagent_wait_end":
console.log(`Child wait cancelled: ${event.cancelled}`);
break;
case "compaction_start":
case "compaction_end":
case "auto_retry_start":
Expand All @@ -331,6 +342,12 @@ session.subscribe((event) => {
});
```

`subagent_wait_start` identifies the frozen, name-sorted batch that must finish before the parent settles.
`subagent_notice` is emitted as each child terminates and contains only the child name, terminal status, attempt kind, retry availability, and deterministic notice text.
It never contains child results, partial output, or errors.
`subagent_wait_end` closes the wait and reports whether it was cancelled.
These lifecycle events are not added to agent messages, persisted as session entries, or included in provider context.

## Options Reference

### Directories
Expand Down Expand Up @@ -514,14 +531,28 @@ const { session } = await createAgentSession({ resourceLoader: loader });

Every root session enables three child-agent coordination tools by default:

- `spawn_agent` starts a child agent asynchronously
- `spawn_agent` starts a child agent asynchronously, while keeping the enclosing parent prompt active until coordinated work finishes
- `agent_action` sends, wakes, or interrupts a child agent
- `agents` lists child status immediately or waits for controller changes

Root sessions run at most four children concurrently by default.
Set `subagents: { maxConcurrent: number }` to configure that limit, or `subagents: false` to disable child agents and omit all three coordination tools.
The names `spawn_agent`, `agent_action`, and `agents` are reserved and cannot be supplied through `customTools`.

When the parent finishes an assistant run, the session freezes every unconsumed child attempt spawned through parent model tool calls.
It waits until every child in that batch is idle, failed, or interrupted, then adds one non-displayed context message with name-sorted results, partial output, errors, and retry availability.
The parent model continues automatically from that message, so callers do not need to send another user prompt.
Terminal results returned explicitly by `agents` are marked consumed and are not delivered again.

An initial child failure offers the parent one coordinated retry for that child during the current explicit parent prompt cycle.
The parent can analyze the delivered error and partial output, then call `agent_action` with `action: "message"` and `wake: true`.
A second retry in the same cycle is rejected, while a later explicit user prompt starts a fresh cycle.
The parent may decline the retry and settle normally.

Calling `session.abort()` during a coordinated wait interrupts unfinished children in the frozen batch, emits their interrupted notices, and settles without another provider call.
Only children spawned by parent model tool calls use automatic coordination.
Invoking the `spawn_agent` tool definition directly through the SDK leaves that child and its retries manually managed.

Specify which tools to enable:

- Built-in tool names: `read`, `bash`, `edit`, `write`, `update_plan`, `grep`, `find`, `ls`
Expand Down
24 changes: 23 additions & 1 deletion packages/coding-agent/docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,28 @@ Type `/` in the editor to open command completion. Extensions can register custo
`/subagents` runs immediately, including while the parent agent is working or compacting.
It displays the current in-memory roster without adding the command or roster to model context.

## Automatic Sub-agent Coordination

`spawn_agent` starts child work asynchronously, so the parent can launch multiple children without waiting between tool calls.
The enclosing parent prompt remains active after the parent attempts to finish.
Diffex freezes the children started by that prompt, waits until all of them finish, fail, or are interrupted, and displays a deterministic notice as each child terminates.

After the whole batch is terminal, Diffex gives the parent one hidden, name-sorted context message containing the child results, partial output, errors, and retry availability.
The parent continues automatically and produces its final response without another user prompt.
If the parent already retrieved a terminal result with `agents`, Diffex does not deliver that result again.

An initial failure gives the parent one opportunity to send a corrective `agent_action` message with `wake: true`.
Only one coordinated retry is allowed per child during the current user prompt.
The parent may decline the retry, and a later explicit user prompt starts a fresh retry cycle.

While Diffex waits, the status indicator remains active and steering or follow-up input stays queued.
Press Escape to restore queued input to the editor and interrupt unfinished children in the frozen batch.
Diffex displays interrupted notices and settles without making another parent model call.

Completion notices use assistant styling, but are not conversation messages.
They are not persisted or included in model context.
JSON and RPC modes emit the same notices as structured session events.

## Message Queue

You can submit messages while the agent is still working:
Expand Down Expand Up @@ -305,6 +327,6 @@ diffex --exclude-tools ask_question

Diffex keeps the core small and pushes workflow-specific behavior into extensions, skills, prompt templates, and packages.

It intentionally does not include built-in MCP, sub-agents, permission popups, full plan mode, persistent to-dos, or background bash. The built-in `update_plan` tool is only a lightweight progress checklist. You can build or install richer workflows as extensions or packages, or use external tools such as containers and tmux.
It intentionally does not include built-in MCP, permission popups, full plan mode, persistent to-dos, or background bash. The built-in `update_plan` tool is only a lightweight progress checklist. You can build or install richer workflows as extensions or packages, or use external tools such as containers and tmux.

For the full rationale, read the [blog post](https://mariozechner.at/posts/2025-11-30-pi-coding-agent/).
122 changes: 121 additions & 1 deletion packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ import type { SettingsManager } from "./settings-manager.ts";
import type { SlashCommandInfo } from "./slash-commands.ts";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts";
import type { SubagentController } from "./subagents/controller.ts";
import type { SubagentSummary } from "./subagents/types.ts";
import type { SubagentBatchResult, SubagentDeliveryBatch, SubagentSummary } from "./subagents/types.ts";
import { type BuildSystemPromptOptions, buildSystemPrompt } from "./system-prompt.ts";
import { type BashOperations, createLocalBashOperations } from "./tools/bash.ts";
import { createAllToolDefinitions } from "./tools/index.ts";
Expand Down Expand Up @@ -183,6 +183,16 @@ export type AgentSessionEvent =
}
| { type: "summarization_retry_finished" }
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
| { type: "subagent_wait_start"; names: readonly string[] }
| { type: "subagent_wait_end"; names: readonly string[]; cancelled: boolean }
| {
type: "subagent_notice";
name: string;
status: "idle" | "failed" | "interrupted";
attempt: "initial" | "retry";
retryAvailable: boolean;
text: string;
}
| { type: "bash_execution_update"; id?: string; delta: string };

/** Listener function for agent session events */
Expand Down Expand Up @@ -383,6 +393,8 @@ export class AgentSession {
private _baseSystemPromptOptions!: BuildSystemPromptOptions;
private _systemPromptOverride?: string;
private readonly _subagentController: SubagentController | undefined;
private _subagentWaitAbortController: AbortController | undefined;
private _agentRunAborted = false;
private _disposePromise: Promise<void> | undefined;

constructor(config: AgentSessionConfig) {
Expand Down Expand Up @@ -682,6 +694,7 @@ export class AgentSession {
// When a user message starts, check if it's from either queue and remove it BEFORE emitting
// This ensures the UI sees the updated queue state
if (event.type === "message_start" && event.message.role === "user") {
this._subagentController?.beginCoordinationCycle();
this._overflowRecoveryAttempted = false;
const messageText = contentText(event.message.content, "");
if (messageText) {
Expand Down Expand Up @@ -1235,6 +1248,7 @@ export class AgentSession {
// =========================================================================

private async _runAgentPrompt(messages: AgentMessage | AgentMessage[]): Promise<void> {
this._agentRunAborted = false;
this._isAgentRunActive = true;
try {
await this.agent.prompt(messages);
Expand All @@ -1248,6 +1262,99 @@ export class AgentSession {
}
}

private _createSubagentNotice(result: SubagentBatchResult): Extract<AgentSessionEvent, { type: "subagent_notice" }> {
const text =
result.status === "idle"
? `${result.name} has finished execution.`
: result.status === "interrupted"
? `${result.name} was interrupted.`
: result.retryAvailable
? `${result.name} failed and a retry is being planned.`
: `${result.name} failed after one retry.`;
return {
type: "subagent_notice",
name: result.name,
status: result.status,
attempt: result.attempt,
retryAvailable: result.retryAvailable,
text,
};
}

private _createSubagentResultMessage(results: readonly SubagentBatchResult[]): CustomMessage {
const agents = results.map((result) => ({
name: result.name,
status: result.status,
attempt: result.attempt,
retry_available: result.retryAvailable,
...(result.status === "idle" && result.result !== undefined ? { result: result.result } : {}),
...(result.status !== "idle" && result.result !== undefined ? { partial_output: result.result } : {}),
...(result.error === undefined ? {} : { error: result.error }),
}));
const retryTargets = results.filter((result) => result.retryAvailable).map((result) => result.name);
return {
role: "custom",
customType: "subagent_results",
content: JSON.stringify({
type: "subagent_batch_results",
agents,
...(retryTargets.length === 0
? {}
: {
retry_instruction:
"Analyze each failed child's error and partial output. If a correction can help, call agent_action with action set to message, a corrective message, and wake set to true. Only one coordinated retry is available for each listed child in this parent prompt cycle. You may decline a retry and answer normally.",
retry_targets: retryTargets,
}),
}),
display: false,
details: { names: results.map((result) => result.name) },
timestamp: Date.now(),
};
}

private async _waitForSubagentBatch(
batch: SubagentDeliveryBatch,
): Promise<{ cancelled: boolean; results: readonly SubagentBatchResult[] }> {
const controller = this._subagentController;
if (!controller) return { cancelled: false, results: Object.freeze([]) };

const names = Object.freeze(batch.attempts.map((attempt) => attempt.name));
const noticed = new Set<string>();
const emitNotice = (result: SubagentBatchResult): void => {
if (noticed.has(result.name)) return;
noticed.add(result.name);
this._emit(this._createSubagentNotice(result));
};
const waitAbortController = new AbortController();
this._subagentWaitAbortController = waitAbortController;
if (this._agentRunAborted) {
waitAbortController.abort(new Error("Subagent coordination cancelled"));
}
this._emit({ type: "subagent_wait_start", names });

let cancelled = false;
try {
try {
await controller.waitForBatch(batch, {
signal: waitAbortController.signal,
onTerminal: emitNotice,
});
} catch (error) {
if (!waitAbortController.signal.aborted) throw error;
cancelled = true;
await controller.interruptBatch(batch);
await controller.waitForBatch(batch, { onTerminal: emitNotice });
}
const results = controller.consumeBatch(batch);
return { cancelled, results };
} finally {
if (this._subagentWaitAbortController === waitAbortController) {
this._subagentWaitAbortController = undefined;
}
this._emit({ type: "subagent_wait_end", names, cancelled });
}
}

private async _handlePostAgentRun(): Promise<boolean> {
const msg = this._lastAssistantMessage;
this._lastAssistantMessage = undefined;
Expand All @@ -1273,6 +1380,17 @@ export class AgentSession {
return true;
}

const batch = this._subagentController?.freezeUnconsumedBatch();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If abort() runs after the children spawn but before _waitForSubagentBatch() installs its controller, the parent run is aborted but this code can still freeze the batch and create a fresh, un-aborted wait. A child that never settles would then keep both abort() and the original prompt pending. The cancellation state for the full session run should be preserved, then either initialize this wait as already cancelled or interrupt and skip the pending batch when the parent message was aborted. Aregression where abort lands immediately before post-run handling would help cover the timing window.

if (batch) {
const delivery = await this._waitForSubagentBatch(batch);
if (delivery.cancelled) return false;
if (delivery.results.length > 0) {
const message = this._createSubagentResultMessage(delivery.results);
await this.sendCustomMessage(message, { deliverAs: "steer" });
return true;
}
}

// The agent loop drains both queues before emitting agent_end. Any messages
// here were queued by agent_end extension handlers and need a continuation.
return this.agent.hasQueuedMessages();
Expand Down Expand Up @@ -1723,6 +1841,8 @@ export class AgentSession {
*/
async abort(): Promise<void> {
this.abortRetry();
if (this._isAgentRunActive) this._agentRunAborted = true;
this._subagentWaitAbortController?.abort(new Error("Subagent coordination cancelled"));
this.agent.abort();
await this.waitForIdle();
}
Expand Down
Loading