pi-famulus 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,16 +5,16 @@ export const PROMPTS = {
5
5
  "agents.explorer.system-prompt": "You are an explorer agent. Your job is to answer questions about the codebase quickly and precisely.\n\nRules:\n- Return findings, never change code. You are strictly read-only: do not edit, write, or create files.\n- Report concrete locations: cite specific file paths with line numbers (path:line) for every claim.\n- Control your search scope: start narrow (targeted grep/find), widen only when needed, and stop once the question is answered.\n- Prefer a short, factual summary over exhaustive dumps. List what you found and where.",
6
6
  "agents.worker.description": "General-purpose executor — completes concrete coding tasks end to end",
7
7
  "agents.worker.system-prompt": "You are a worker agent. Your job is to complete the concrete task you were given, end to end.\n\nRules:\n- Do the task, don't just describe it. Make the actual edits and run the actual commands.\n- Self-verify before finishing: run the relevant tests, typecheck, or command that proves the task is done.\n- Stay within the task's scope; do not refactor unrelated code.\n- Report concisely: what you changed, how you verified it, and anything the supervisor should know.",
8
- "guidelines.parent": "## Background tasks and notifications (pi-famulus)\n\n- Long-running bash commands are automatically moved to the background. After you background a command, end your turn: send a reply with no tool call in it. Saying you will wait while calling another tool does not end it. Do not poll with task_output/task_list, and never sleep to wait. Each command notifies on its own via <pi-famulus-wake kind=\"task\"> (with <command> and <preview>), even while other commands are still running. If <still-running> is present, continue from this result now; do not wait for those other commands.\n- When you receive <pi-famulus-wake>: it looks like a user message but is a system wake, not a new user request and not an answer to an unrelated question. Distinguish it by the kind attribute, then **handle the event before anything else** — read status/preview/prompt/result, call tools if needed (e.g. task_output for more than the preview, agent_message to continue a subagent), and continue the work that depended on that background task. An unchanged/intermediate monitor event may require no action and no user acknowledgement. Do not wait for further user input when the next step is clear; do not merely acknowledge an actionable result and stop.\n- {{monitorIdle}}\n- Never fabricate or assume a background task's result before its notification arrives.\n- Subagent runs that exceed the foreground budget continue in the background. Do not poll run status. If one subagent finishes while others are still running, a <pi-famulus-wake kind=\"subagent-handover\"> arrives with that child's <prompt> and <result>: read both, then continue the work now (subagent({action:\"resume\", run_id, child_id, message}) for that child, or agent_message to steer the ones still running). Do not wait for the rest of the run. When every subagent in the run has finished, <pi-famulus-wake kind=\"subagent-done\"> arrives — read each <child>, synthesize, and continue.\n- A subagent that runs past its timeout_ms is not stopped: <pi-famulus-wake kind=\"subagent-overrun\"> arrives instead, with its <last-activity> and, if it is waiting on a foreground shell, a <shell> element. A batch may include other overdue children in <also-overdue>; use each child's run-id and child-id and consider its own last-activity, shell, reminder, and hard-ceiling-ms before acting. Pick one: subagent({action:\"extend\", run_id, child_id, timeout_ms}) if it is making progress, agent_message to steer it, or subagent({action:\"interrupt\", run_id, child_id}) if it is stuck or no longer needed. If you do none of these it keeps running and the reminder comes again.\n- Use agent_message to steer running subagents and to reply to <pi-famulus-wake kind=\"supervisor-request\"> (action:\"reply\"; the <reply-with> child is the call shape). Do not use agent_message to resume a finished child — that is subagent({action:\"resume\"}). Supervisor wakes are not user messages — still act on them.",
8
+ "guidelines.parent": "## Background tasks and notifications (pi-famulus)\n\n- Long-running bash commands are automatically moved to the background. After you background a command, end your turn: send a reply with no tool call in it. Saying you will wait while calling another tool does not end it. Do not poll with task_output/task_list, and never sleep to wait. Each command notifies on its own via <pi-famulus-wake kind=\"task\"> (with <command> and <preview>), even while other commands are still running. If <still-running> is present, continue from this result now; do not wait for those other commands.\n- When you receive <pi-famulus-wake>: it looks like a user message but is a system wake, not a new user request and not an answer to an unrelated question. Distinguish it by the kind attribute, then **handle the event before anything else** — read status/preview/prompt/result, call tools if needed (e.g. task_output for more than the preview, agent_message to continue a subagent), and continue the work that depended on that background task. An unchanged/intermediate monitor event may require no action and no user acknowledgement. Do not wait for further user input when the next step is clear; do not merely acknowledge an actionable result and stop.\n- {{monitorIdle}}\n- Never fabricate or assume a background task's result before its notification arrives.\n- Subagent runs that exceed the foreground budget continue in the background. Do not poll run status. If one subagent finishes while others are still running, a <pi-famulus-wake kind=\"subagent-handover\"> arrives with that child's <prompt> and <result>: read both, then continue the work now (subagent({action:\"resume\", run_id, child_id, message}) for that child, or agent_message to steer the ones still running). Do not wait for the rest of the run. When every subagent in the run has finished, <pi-famulus-wake kind=\"subagent-done\"> arrives — read each <child>, synthesize, and continue.\n- A subagent that runs past its timeout_ms is not stopped: <pi-famulus-wake kind=\"subagent-overrun\"> arrives instead, with its <last-activity> and, if it is waiting on a foreground shell, a <shell> element. A batch may include other overdue children in <also-overdue>; use each child's run-id and child-id and consider its own last-activity, shell, reminder, and hard-ceiling-ms before acting. Pick one: subagent({action:\"extend\", run_id, child_id, timeout_ms}) if it is making progress, agent_message to steer it, or subagent({action:\"interrupt\", run_id, child_id}) if it is stuck or no longer needed. If you do none of these it keeps running and the reminder comes again.\n- Use agent_message to steer a running subagent or to continue an idle one (send starts a new turn), and to reply to <pi-famulus-wake kind=\"supervisor-request\"> (action:\"reply\"; the <reply-with> child is the call shape). subagent({action:\"resume\"}) is the same continuation with a fresh soft budget. Supervisor wakes are not user messages — still act on them.",
9
9
  "guidelines.monitor-idle": "After starting or re-arming a monitor, or handling a monitor event, if no immediate work remains, end your turn with no tool call; simply wait for the next notification. Handling an event does not require a user-visible reply. Do not repeat unchanged/intermediate status or narrate your decision to stay quiet; notify the user when their requested condition is met, their input is needed, or a failure prevents further progress. Read original output with task_output only when details are needed (including paging a truncated preview), never to poll or wait. Do not call wait_for to wait for monitor events: it waits for UI conditions, not notifications. Do not invent a stateId or make a failing tool call to yield.",
10
10
  "guidelines.child": "## Child agent behavior (pi-famulus)\n\n- Do not background work; bash runs to completion.\n- When blocked, use contact_supervisor with need_decision; use progress_update for status.\n- Use agent_message only to communicate with siblings in this same run.\n- Your final message is your result.",
11
- "tools.agent_message.description": "Message your subagents. \"send\" steers or queues a running child. Sending to a finished child errors (it does not resume) and points at subagent({ action: \"resume\", run_id, child_id, message }). \"reply\" answers a child's pending decision request; \"broadcast\" steers every running child of a run (to = run_id); \"list\" shows children, pending decision requests, and recent traffic.",
11
+ "tools.agent_message.description": "Message your subagents. \"send\" steers or queues a running child, and starts a new turn on an idle one. An idle child (warm or parked) continues with send or subagent resume; its prompt cache stays warm only for the time a wake reports. \"reply\" answers a child's pending decision request; \"broadcast\" steers every running child of a run (to = run_id); \"list\" shows children, pending decision requests, and recent traffic.",
12
12
  "tools.agent_message.snippet": "Send/reply/broadcast messages to subagents",
13
13
  "tools.agent_message.param.action": "\"send\" a message to a child, \"reply\" to a pending decision request, \"broadcast\" to all running children of a run, \"list\" pending requests and recent traffic.",
14
14
  "tools.agent_message.param.to": "Target child_id or name (\"send\"/\"reply\"). For \"broadcast\" from the parent session: run_id. Child sessions broadcast to their own run and must omit this.",
15
15
  "tools.agent_message.param.message": "Message text.",
16
- "tools.agent_message.param.delivery": "\"steer\" (default) delivers to a running child after its current tool call/turn finishes; \"queue\" delivers after its current turn. Sending to a finished child is an error — resume with subagent({action:\"resume\"}).",
17
- "tools.agent_message.error.finished-child": "Child {{childId}} ({{name}}) has finished ({{status}}). agent_message does not resume children. Use subagent({ action: \"resume\", run_id: \"{{runId}}\", child_id: \"{{childId}}\", message: \"...\" }).",
16
+ "tools.agent_message.param.delivery": "\"steer\" (default) delivers to a running child after its current tool call/turn finishes; \"queue\" delivers after its current turn. Sending to an idle child starts a new turn.",
17
+ "tools.agent_message.error.finished-child": "Child {{childId}} ({{name}}) has finished ({{status}}), and this host cannot start another turn. Use subagent({ action: \"resume\", run_id: \"{{runId}}\", child_id: \"{{childId}}\", message: \"...\" }).",
18
18
  "tools.bash.description": "Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last {{maxLines}} lines or {{maxKb}}KB (whichever is hit first). Foreground commands that exceed the foreground budget are automatically moved to the background; you will be notified when they complete. Optionally provide a timeout in seconds (hard kill limit), or run_in_background to background immediately.",
19
19
  "tools.bash.snippet": "Execute bash commands (ls, grep, find, etc.)",
20
20
  "tools.bash.rules": ["You can inspect PI_* environment variables for current model and session details.","Long-running bash commands are moved to the background automatically; do not poll or sleep to wait for them. End your turn (a reply with no tool call) and resume from the task wake when it arrives."],
@@ -23,7 +23,7 @@ export const PROMPTS = {
23
23
  "tools.bash.param.run_in_background": "Start the command in the background and return immediately. You will be notified when it completes.",
24
24
  "tools.bash.result.backgrounded": "Command \"{{command}}\" moved to background (task_id: {{taskId}}). Output: {{outputPath}}.\nYou will be notified when it completes, even if other commands are still running. Do not poll or sleep: reply to the user now with no tool call, and continue from the <pi-famulus-wake kind=\"task\"> when it arrives.",
25
25
  "tools.bash.error.bare-sleep": "Refusing to run a bare sleep/idle-loop command: {{command}}. Do not sleep to wait for background work; completion is delivered via notification. Use the monitor tool to watch for a condition, or run_in_background for long commands.",
26
- "tools.child.agent_message.description": "Message sibling subagents in your run, or list pending requests. \"send\" delivers to a running sibling. Sending to a finished sibling errors and does not resume it; resume with subagent({ action: \"resume\", run_id, child_id, message }). \"broadcast\" steers every running sibling in your run; \"list\" shows pending decision requests and recent traffic. Cross-run messaging is rejected.",
26
+ "tools.child.agent_message.description": "Message sibling subagents in your run, or list pending requests. \"send\" delivers to a running sibling and starts a new turn on an idle one. \"broadcast\" steers every running sibling in your run; \"list\" shows pending decision requests and recent traffic. Cross-run messaging is rejected.",
27
27
  "tools.child.agent_message.snippet": "Message sibling subagents in your run",
28
28
  "tools.child.bash.description": "Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last {{maxLines}} lines or {{maxKb}}KB (whichever is hit first). The command runs to completion (or the optional timeout in seconds, after which it is killed). There is no background execution inside subagents.",
29
29
  "tools.child.bash.snippet": "Execute bash commands (ls, grep, find, etc.)",
@@ -158,11 +158,10 @@ export const PROMPT_SEGMENTS: Record<string, { prompt: PromptId; text?: string;
158
158
  "guidelines.no-fabrication": {"prompt":"guidelines.parent","text":"Never fabricate or assume a background task's result before its notification arrives."},
159
159
  "guidelines.subagent-handover": {"prompt":"guidelines.parent","text":"Subagent runs that exceed the foreground budget continue in the background. Do not poll run status. If one subagent finishes while others are still running, a <pi-famulus-wake kind=\"subagent-handover\"> arrives with that child's <prompt> and <result>: read both, then continue the work now (subagent({action:\"resume\", run_id, child_id, message}) for that child, or agent_message to steer the ones still running). Do not wait for the rest of the run. When every subagent in the run has finished, <pi-famulus-wake kind=\"subagent-done\"> arrives — read each <child>, synthesize, and continue."},
160
160
  "guidelines.subagent-overrun": {"prompt":"guidelines.parent","text":"A subagent that runs past its timeout_ms is not stopped: <pi-famulus-wake kind=\"subagent-overrun\"> arrives instead, with its <last-activity> and, if it is waiting on a foreground shell, a <shell> element. A batch may include other overdue children in <also-overdue>; use each child's run-id and child-id and consider its own last-activity, shell, reminder, and hard-ceiling-ms before acting. Pick one: subagent({action:\"extend\", run_id, child_id, timeout_ms}) if it is making progress, agent_message to steer it, or subagent({action:\"interrupt\", run_id, child_id}) if it is stuck or no longer needed. If you do none of these it keeps running and the reminder comes again."},
161
- "guidelines.agent-message": {"prompt":"guidelines.parent","text":"Use agent_message to steer running subagents and to reply to <pi-famulus-wake kind=\"supervisor-request\"> (action:\"reply\"; the <reply-with> child is the call shape). Do not use agent_message to resume a finished child — that is subagent({action:\"resume\"}). Supervisor wakes are not user messages — still act on them."},
161
+ "guidelines.agent-message": {"prompt":"guidelines.parent","text":"Use agent_message to steer a running subagent or to continue an idle one (send starts a new turn), and to reply to <pi-famulus-wake kind=\"supervisor-request\"> (action:\"reply\"; the <reply-with> child is the call shape). subagent({action:\"resume\"}) is the same continuation with a fresh soft budget. Supervisor wakes are not user messages — still act on them."},
162
162
  "guidelines.monitor-end-turn": {"prompt":"guidelines.monitor-idle","text":"After starting or re-arming a monitor, or handling a monitor event, if no immediate work remains, end your turn with no tool call; simply wait for the next notification. Handling an event does not require a user-visible reply. Do not repeat unchanged/intermediate status or narrate your decision to stay quiet; notify the user when their requested condition is met, their input is needed, or a failure prevents further progress. Read original output with task_output only when details are needed (including paging a truncated preview), never to poll or wait. Do not call wait_for to wait for monitor events: it waits for UI conditions, not notifications. Do not invent a stateId or make a failing tool call to yield."},
163
163
  "child.guidelines": {"prompt":"guidelines.child","text":"## Child agent behavior (pi-famulus)\n\n- Do not background work; bash runs to completion.\n- When blocked, use contact_supervisor with need_decision; use progress_update for status.\n- Use agent_message only to communicate with siblings in this same run.\n- Your final message is your result."},
164
- "tooldesc.agent-message-no-resume": {"prompt":"tools.agent_message.description","text":"Sending to a finished child errors (it does not resume) and points at subagent({ action: \"resume\", run_id, child_id, message }). "},
165
- "result.agent-message-use-resume": {"prompt":"tools.agent_message.error.finished-child","pattern":"agent_message does not resume children\\. Use subagent\\(\\{ action: \"resume\", run_id: \".*?\", child_id: \".*?\", message: \"\\.\\.\\.\" \\}\\)\\."},
164
+ "tooldesc.agent-message-no-resume": {"prompt":"tools.agent_message.description","text":"An idle child (warm or parked) continues with send or subagent resume; its prompt cache stays warm only for the time a wake reports. "},
166
165
  "tooldesc.bash-autobg": {"prompt":"tools.bash.description","text":"Foreground commands that exceed the foreground budget are automatically moved to the background; you will be notified when they complete. "},
167
166
  "rules.pi-env": {"prompt":"tools.bash.rules","text":"You can inspect PI_* environment variables for current model and session details."},
168
167
  "rules.bash-no-poll": {"prompt":"tools.bash.rules","text":"Long-running bash commands are moved to the background automatically; do not poll or sleep to wait for them."},
@@ -0,0 +1,268 @@
1
+ /**
2
+ * ChildSessionAdapter over a daemon-hosted `pi --mode rpc` process.
3
+ * The runner's timers stay; only the session lives in another process.
4
+ */
5
+ import { randomUUID } from "node:crypto";
6
+ import { mkdirSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import type { ManagerClient, ManagerEvent } from "../manager-client";
9
+ import { parentResourceArgs, resolvePiArgv } from "../pi-command";
10
+ import { resolveChildModel, type PiRuntimeDeps } from "./pi-runtime";
11
+ import type {
12
+ ChildRunRequest,
13
+ ChildSessionAdapter,
14
+ ChildSessionEvent,
15
+ ChildTokenUsage,
16
+ CreateSessionFn,
17
+ } from "./types";
18
+
19
+ export interface HostedSessionDeps extends PiRuntimeDeps {
20
+ getClient: () => ManagerClient | null;
21
+ getHome: () => string;
22
+ /** This extension's entry file, passed to the child with -e. */
23
+ extensionEntry: string;
24
+ piCommand?: string | null;
25
+ trustProject?: boolean;
26
+ }
27
+
28
+ interface HostingView {
29
+ residency: string;
30
+ activity: string;
31
+ driver: string;
32
+ cacheExpiresAt?: number;
33
+ sessionFile?: string;
34
+ sessionId?: string;
35
+ }
36
+
37
+ export function createHostedSessionFn(deps: HostedSessionDeps): CreateSessionFn {
38
+ return async (req) => {
39
+ const client = deps.getClient();
40
+ if (!client || !client.isAvailable()) {
41
+ throw new Error("pi-famulus daemon is not connected; a subagent needs it to run pi --mode rpc.");
42
+ }
43
+ client.requireHostedProtocol();
44
+ const pi = resolvePiArgv(deps.piCommand);
45
+ if ("error" in pi) throw new Error(pi.error);
46
+ const model = resolveChildModel(deps, req);
47
+ const thinking =
48
+ model.thinkingOverride ?? req.agent.thinking ?? deps.getParentThinkingLevel() ?? "off";
49
+ const cwd = deps.getCwd();
50
+ const promptPath = join(deps.getHome(), "agents", "prompts");
51
+ mkdirSync(promptPath, { recursive: true });
52
+ const promptFile = join(promptPath, `${req.childId}.md`);
53
+ writeFileSync(promptFile, req.agent.systemPrompt);
54
+ const inherited = parentResourceArgs();
55
+ const extensionPaths = inherited.filter((_, i) => inherited[i - 1] === "-e");
56
+ const alreadyLoadsUs = extensionPaths.some((path) => path === deps.extensionEntry);
57
+ const trustInherited = inherited.some((arg) => arg === "-a" || arg === "--approve" || arg === "-na" || arg === "--no-approve");
58
+ const argv = [
59
+ ...pi.argv,
60
+ "--mode", "rpc",
61
+ "--session-id", req.childId,
62
+ "--name", `${req.agent.name}: ${req.name}`,
63
+ ...inherited,
64
+ ...(alreadyLoadsUs ? [] : ["-e", deps.extensionEntry]),
65
+ ...(model.model ? ["--model", `${model.model.provider}/${model.model.id}`] : []),
66
+ "--thinking", String(thinking),
67
+ "--append-system-prompt", promptFile,
68
+ ...(trustInherited ? [] : [deps.getProjectTrusted?.() ? "-a" : "-na"]),
69
+ ...(req.agent.tools ? ["--tools", req.agent.tools.join(",")] : []),
70
+ ];
71
+ const env = { ...process.env } as Record<string, string>;
72
+ if (req.agent.tools) env.PI_FAMULUS_CHILD_TOOLS = req.agent.tools.join(",");
73
+ env.PI_FAMULUS_CHILD_RUN = req.runId;
74
+ env.PI_FAMULUS_CHILD_NAME = req.name;
75
+ const spawned = await client.agentSpawn({
76
+ childId: req.childId,
77
+ runId: req.runId,
78
+ name: req.name,
79
+ argv,
80
+ cwd,
81
+ env,
82
+ });
83
+ let warning = model.warning;
84
+ try {
85
+ await client.agentInput(req.childId, "set_steering_mode", "all", "parent");
86
+ } catch (err) {
87
+ const message = err instanceof Error ? err.message : String(err);
88
+ const note = `Steering mode was not set (${message}); queued input may wait until the current turn ends.`;
89
+ warning = warning ? `${warning} ${note}` : note;
90
+ }
91
+ await client.agentWatch(req.childId);
92
+ const session = new HostedRpcSession(client, req.childId, {
93
+ warning,
94
+ resolvedModel: model.model ? `${model.model.provider}/${model.model.id}` : undefined,
95
+ effectiveThinkingLevel: typeof thinking === "string" ? thinking : undefined,
96
+ sessionFile: typeof spawned.session_file === "string" ? spawned.session_file : undefined,
97
+ sessionId: typeof spawned.session_id === "string" ? spawned.session_id : req.childId,
98
+ });
99
+ return session;
100
+ };
101
+ }
102
+
103
+ class HostedRpcSession implements ChildSessionAdapter {
104
+ readonly warning?: string;
105
+ readonly resolvedModel?: string;
106
+ readonly effectiveThinkingLevel?: string;
107
+ private readonly listeners = new Set<(event: ChildSessionEvent) => void>();
108
+ private readonly unsubscribe: () => void;
109
+ private streaming = false;
110
+ private lastText = "";
111
+ private turns: { role: string; text: string }[] = [];
112
+ private usage: ChildTokenUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
113
+ private settle: (() => void) | null = null;
114
+ private hostingView: HostingView;
115
+ private disposed = false;
116
+
117
+ constructor(
118
+ private readonly client: ManagerClient,
119
+ private readonly childId: string,
120
+ init: { warning?: string; resolvedModel?: string; effectiveThinkingLevel?: string; sessionFile?: string; sessionId?: string },
121
+ ) {
122
+ this.warning = init.warning;
123
+ this.resolvedModel = init.resolvedModel;
124
+ this.effectiveThinkingLevel = init.effectiveThinkingLevel;
125
+ this.hostingView = {
126
+ residency: "warm",
127
+ activity: "starting",
128
+ driver: "parent",
129
+ sessionFile: init.sessionFile,
130
+ sessionId: init.sessionId,
131
+ };
132
+ this.unsubscribe = this.client.onEvent((event) => this.onManagerEvent(event as ManagerEvent & Record<string, unknown>));
133
+ }
134
+
135
+ hosting(): HostingView {
136
+ return { ...this.hostingView };
137
+ }
138
+
139
+ async prompt(text: string): Promise<void> {
140
+ const done = this.armSettle();
141
+ const op = this.streaming ? "prompt_steer" : "prompt";
142
+ await this.client.agentInput(this.childId, op, text, "parent");
143
+ if (!this.streaming) await done;
144
+ }
145
+
146
+ async steer(text: string): Promise<void> {
147
+ await this.client.agentInput(this.childId, "prompt_steer", text, "parent");
148
+ }
149
+
150
+ async followUp(text: string): Promise<void> {
151
+ await this.client.agentInput(this.childId, "prompt_follow", text, "parent");
152
+ }
153
+
154
+ async abort(): Promise<void> {
155
+ await this.client.agentInput(this.childId, "abort", "", "parent");
156
+ this.streaming = false;
157
+ this.settle?.();
158
+ this.settle = null;
159
+ }
160
+
161
+ async park(): Promise<void> {
162
+ await this.client.agentPark(this.childId);
163
+ this.hostingView.residency = "parked";
164
+ }
165
+
166
+ async waitForIdle(): Promise<void> {
167
+ if (!this.streaming) return;
168
+ await this.armSettle();
169
+ }
170
+
171
+ getLastAssistantText(): string | undefined {
172
+ return this.lastText || undefined;
173
+ }
174
+
175
+ getConversation() {
176
+ return this.turns.map((turn) => ({ ...turn }));
177
+ }
178
+
179
+ tokenUsage(): ChildTokenUsage {
180
+ return { ...this.usage };
181
+ }
182
+
183
+ isStreaming(): boolean {
184
+ return this.streaming;
185
+ }
186
+
187
+ subscribe(listener: (event: ChildSessionEvent) => void): () => void {
188
+ this.listeners.add(listener);
189
+ return () => this.listeners.delete(listener);
190
+ }
191
+
192
+ dispose(): void {
193
+ if (this.disposed) return;
194
+ this.disposed = true;
195
+ this.unsubscribe();
196
+ this.settle?.();
197
+ this.settle = null;
198
+ }
199
+
200
+ private armSettle(): Promise<void> {
201
+ return new Promise((resolve) => {
202
+ this.settle = resolve;
203
+ });
204
+ }
205
+
206
+ private onManagerEvent(event: ManagerEvent & Record<string, unknown>): void {
207
+ if (event.child_id !== this.childId) return;
208
+ if (event.event === "agent_state") {
209
+ this.hostingView = {
210
+ residency: String(event.residency ?? this.hostingView.residency),
211
+ activity: String(event.activity ?? this.hostingView.activity),
212
+ driver: String(event.driver ?? this.hostingView.driver),
213
+ cacheExpiresAt: typeof event.cache_expires_at === "number" ? event.cache_expires_at : this.hostingView.cacheExpiresAt,
214
+ sessionFile: typeof event.session_file === "string" ? event.session_file : this.hostingView.sessionFile,
215
+ sessionId: typeof event.session_id === "string" ? event.session_id : this.hostingView.sessionId,
216
+ };
217
+ if (event.activity === "running") this.streaming = true;
218
+ if (event.activity === "idle" || event.activity === "failed") {
219
+ this.streaming = false;
220
+ this.settle?.();
221
+ this.settle = null;
222
+ }
223
+ return;
224
+ }
225
+ if (event.event !== "agent_rpc" || !event.record || typeof event.record !== "object") return;
226
+ const record = event.record as { type?: string; message?: { role?: string; content?: { type?: string; text?: string }[]; usage?: Record<string, unknown> }; toolCallId?: string };
227
+ const type = record.type ?? "";
228
+ if (type === "agent_start") this.streaming = true;
229
+ if (type === "agent_settled") {
230
+ this.streaming = false;
231
+ this.settle?.();
232
+ this.settle = null;
233
+ }
234
+ if (type === "message_end" && record.message?.role === "assistant") {
235
+ const text = (record.message.content ?? []).map((part) => part.text ?? "").join("");
236
+ if (text) {
237
+ this.lastText = text;
238
+ this.turns.push({ role: "assistant", text });
239
+ }
240
+ const usage = record.message.usage;
241
+ if (usage) {
242
+ this.usage.input += num(usage.input);
243
+ this.usage.output += num(usage.output);
244
+ this.usage.cacheRead += num(usage.cacheRead);
245
+ this.usage.cacheWrite += num(usage.cacheWrite);
246
+ }
247
+ }
248
+ if (type === "message_end" && record.message?.role === "user") {
249
+ const text = (record.message.content ?? []).map((part) => part.text ?? "").join("");
250
+ if (text) this.turns.push({ role: "user", text });
251
+ }
252
+ const adapted: ChildSessionEvent = {
253
+ type,
254
+ ...(record.message?.role ? { role: record.message.role } : {}),
255
+ ...(record.toolCallId ? { toolCallId: record.toolCallId } : {}),
256
+ };
257
+ for (const listener of this.listeners) listener(adapted);
258
+ }
259
+ }
260
+
261
+ function num(value: unknown): number {
262
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
263
+ }
264
+
265
+ /** Request id for a child→parent decision. */
266
+ export function newCommsRequestId(): string {
267
+ return `rq_${randomUUID().slice(0, 8)}`;
268
+ }
@@ -38,6 +38,17 @@ export class ChildShellTracker {
38
38
  this.sizes.delete(taskId);
39
39
  }
40
40
 
41
+ /** A hosted child's shell is observed from the parent session's task_exited. */
42
+ endByTask(taskId: string): void {
43
+ for (const [childId, shell] of this.shells) {
44
+ if (shell.taskId === taskId) {
45
+ this.shells.delete(childId);
46
+ this.sizes.delete(taskId);
47
+ return;
48
+ }
49
+ }
50
+ }
51
+
41
52
  current(childId: string): ChildShell | undefined {
42
53
  return this.shells.get(childId);
43
54
  }
@@ -157,6 +157,10 @@ interface ResolvedModel {
157
157
  * falls back to the parent model with a warning (user-authored files must not
158
158
  * hard-fail across machines).
159
159
  */
160
+ export function resolveChildModel(deps: PiRuntimeDeps, req: ChildRunRequest): ResolvedModel {
161
+ return resolveModel(deps, req);
162
+ }
163
+
160
164
  function resolveModel(deps: PiRuntimeDeps, req: ChildRunRequest): ResolvedModel {
161
165
  const candidates = modelCandidates(deps);
162
166
  const findModel = (provider: string, id: string): Model | undefined =>
@@ -99,10 +99,10 @@ export interface InProcessRunnerOptions {
99
99
  /** Shared time source and scheduler. */
100
100
  clock?: Clock;
101
101
  /**
102
- * Per-generation admission hook. Awaited before each (re)start; the
103
- * releaser gets terminal=false for a resumable interruption so its local
104
- * slot can be returned while the child-owned machine permit is retained.
105
- * Rejecting cancels the generation as {status:"interrupted", error}.
102
+ * Per-generation admission hook. Awaited before each (re)start. The
103
+ * releaser is called with terminal=true when the turn settles, including
104
+ * an interrupted turn: the machine permit is released and the next turn
105
+ * acquires again. Rejecting cancels the generation as {status:"interrupted", error}.
106
106
  */
107
107
  acquire?: (req: ChildRunRequest, ticket: AdmissionTicket) => Promise<(terminal?: boolean) => void>;
108
108
  /**
@@ -462,8 +462,17 @@ class InProcessChildHandle implements DisposableChildHandle {
462
462
  await this.beginGeneration(message, false, false, gen);
463
463
  }
464
464
 
465
+ hosting(): { residency?: string; driver?: string; cacheExpiresAt?: number; sessionFile?: string; sessionId?: string } | undefined {
466
+ const session = this.session as { hosting?: () => { residency?: string; driver?: string; cacheExpiresAt?: number; sessionFile?: string; sessionId?: string } } | null;
467
+ return session?.hosting?.();
468
+ }
469
+
465
470
  async interrupt(): Promise<void> {
466
- if (this.status_ !== "running" && this.status_ !== "pending") return;
471
+ if (this.status_ !== "running" && this.status_ !== "pending") {
472
+ const session = this.session as { park?: () => Promise<void> } | null;
473
+ if (session?.park) await session.park();
474
+ return;
475
+ }
467
476
  this.settle(this.generation, {
468
477
  status: "interrupted",
469
478
  // A pending turn has produced nothing yet; the last assistant text
@@ -563,8 +572,8 @@ class InProcessChildHandle implements DisposableChildHandle {
563
572
  signal: admissionAbort.signal,
564
573
  });
565
574
  if (this.disposed || this.isSettled(gen)) {
566
- // A queued interruption is resumable; only disposal is terminal.
567
- release(this.disposed);
575
+ // The turn is over (interrupted or disposed). The next turn acquires again.
576
+ release(true);
568
577
  return;
569
578
  }
570
579
  this.releaseSlot = release;
@@ -756,8 +765,9 @@ class InProcessChildHandle implements DisposableChildHandle {
756
765
  result.warning = this.session.warning;
757
766
  }
758
767
  this.status_ = result.status;
759
- this.terminalSettle = result.status !== "interrupted" || result.error === "disposed";
760
- this.release(this.terminalSettle);
768
+ // Every settled turn releases its machine permit, including an
769
+ // interrupted one. The next turn (resume, send, user input) acquires again.
770
+ this.release(true);
761
771
  this.notifyActivity();
762
772
  this.resolveResult(result);
763
773
  }
@@ -140,6 +140,8 @@ export interface SubagentToolDeps {
140
140
  /** Agent definition resolver (M5 wires the real loader; default: worker). */
141
141
  resolveAgent?: (name: string | undefined) => AgentDefinition;
142
142
  /** Selectable models for action:"models" (§4.6); absent → action errors. */
143
+ /** Receives the watcher for turns started by agent_message send. */
144
+ registerResumeWatch?: (watch: (childId: string) => void) => void;
143
145
  listModels?: () => {
144
146
  provider: string;
145
147
  id: string;
@@ -346,6 +348,20 @@ export function createSubagentTool(
346
348
  return true;
347
349
  };
348
350
 
351
+ const armResumeWake = (childId: string): void => {
352
+ const registry = deps.getRegistry();
353
+ if (!registry) return;
354
+ const record = registry.list().find((run) => run.children.some((c) => c.childId === childId));
355
+ if (!record) return;
356
+ const handedOver = new Set<string>();
357
+ void registry.getResult(childId)?.then(() => {
358
+ if (!notifyChildHandover(registry, record.runId, childId, handedOver)) {
359
+ notifyRunCompleted(registry, record.runId);
360
+ }
361
+ });
362
+ };
363
+ deps.registerResumeWatch?.(armResumeWake);
364
+
349
365
  const startRun = async (
350
366
  params: SubagentParams,
351
367
  signal: AbortSignal | undefined,
@@ -547,7 +563,18 @@ export function createSubagentTool(
547
563
  };
548
564
  }
549
565
 
550
- const record = requireRun(registry, params.run_id);
566
+ const record = params.run_id
567
+ ? requireRun(registry, params.run_id)
568
+ : params.child_id
569
+ ? resolveRunByChild(registry, params.child_id)
570
+ : undefined;
571
+ if (!record) {
572
+ if (params.child_id && !params.run_id) {
573
+ throw new Error(`unknown child "${params.child_id}" (no run in this session holds it)`);
574
+ }
575
+ requireRun(registry, params.run_id);
576
+ throw new Error("run_id is required for this action");
577
+ }
551
578
 
552
579
  if (action === "get") {
553
580
  return {
@@ -585,18 +612,45 @@ export function createSubagentTool(
585
612
  const known = record.children.map((c) => `${c.name} (${c.childId})`).join(", ");
586
613
  throw new Error(`no child "${params.child_id}" in run ${record.runId} (children: ${known})`);
587
614
  }
615
+ const idle = record.children.filter((c) => {
616
+ if (params.child_id && c.childId !== params.child_id && c.name !== params.child_id) return false;
617
+ return c.status === "completed" || c.status === "failed" || c.status === "interrupted";
618
+ });
588
619
  for (const child of targets) {
589
620
  await registry.handle(child.childId)?.interrupt();
590
621
  }
591
- const scope = params.child_id ? `subagent ${params.child_id}` : `${targets.length} subagent(s)`;
622
+ for (const child of idle) {
623
+ await registry.handle(child.childId)?.interrupt();
624
+ }
625
+ const interrupted = [...targets, ...idle].map((c) => c.childId);
626
+ const scope = params.child_id ? `subagent ${params.child_id}` : `${interrupted.length} subagent(s)`;
592
627
  return {
593
628
  content: [{ type: "text", text: `Interrupted ${scope} in run ${record.runId}.` }],
594
- details: { run_id: record.runId, interrupted: targets.map((c) => c.childId) },
629
+ details: { run_id: record.runId, interrupted },
595
630
  };
596
631
  }
597
632
 
598
633
  if (action === "steer") {
599
634
  if (!params.message) throw new Error("message is required for steer");
635
+ const named = params.child_id
636
+ ? record.children.find((c) => c.childId === params.child_id || c.name === params.child_id)
637
+ : record.children.length === 1 ? record.children[0] : undefined;
638
+ if (named && named.status !== "running" && named.status !== "pending") {
639
+ const { queuedBehind } = await registry.resumeChild(named.childId, params.message);
640
+ armResumeWake(named.childId);
641
+ return {
642
+ content: [{
643
+ type: "text",
644
+ text: fill("tools.subagent.result.resumed", {
645
+ name: named.name,
646
+ childId: named.childId,
647
+ runId: record.runId,
648
+ queued: queuedText(queuedBehind),
649
+ }),
650
+ }],
651
+ details: { run_id: record.runId, child_id: named.childId, queued_behind: queuedBehind },
652
+ };
653
+ }
600
654
  const handle = resolveSingleActiveChild(registry, record, params.child_id, "steer");
601
655
  await handle.steer(params.message);
602
656
  return {
@@ -645,12 +699,7 @@ export function createSubagentTool(
645
699
  );
646
700
  // Resume is asynchronous. If siblings are still running, hand this child
647
701
  // back as soon as it finishes; otherwise the run-complete wake covers it.
648
- const handedOver = new Set<string>();
649
- void registry.getResult(child.childId)?.then(() => {
650
- if (!notifyChildHandover(registry, record.runId, child.childId, handedOver)) {
651
- notifyRunCompleted(registry, record.runId);
652
- }
653
- });
702
+ armResumeWake(child.childId);
654
703
  return {
655
704
  content: [
656
705
  {
@@ -733,6 +782,18 @@ export function createSubagentTool(
733
782
  };
734
783
  }
735
784
 
785
+ function resolveRunByChild(registry: SubagentRegistry, childIdOrName: string) {
786
+ const runs = registry.list();
787
+ const byId = runs.filter((run) => run.children.some((c) => c.childId === childIdOrName));
788
+ if (byId.length === 1) return byId[0];
789
+ const byName = runs.filter((run) => run.children.some((c) => c.name === childIdOrName));
790
+ if (byName.length > 1) {
791
+ const ids = byName.map((run) => run.runId).join(", ");
792
+ throw new Error(`child name "${childIdOrName}" is in more than one run (${ids}); pass run_id`);
793
+ }
794
+ return byName[0];
795
+ }
796
+
736
797
  /** Resume result clause for a turn waiting for an admission slot ("" when it started at once). */
737
798
  function queuedText(queuedBehind: number | null): string {
738
799
  if (queuedBehind === null) return "";
@@ -135,6 +135,8 @@ export interface ChildHandle {
135
135
  tokenUsage(): ChildTokenUsage;
136
136
  /** Cumulative observed LLM, tool, admission-queue and approximate wall time. */
137
137
  wallUsage(): ChildWallUsage;
138
+ /** Hosted-process view, when this child runs as `pi --mode rpc`. */
139
+ hosting?(): { residency?: string; driver?: string; cacheExpiresAt?: number; sessionFile?: string; sessionId?: string } | undefined;
138
140
  }
139
141
 
140
142
  /**