pi-subagents 0.66.0 → 0.67.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.
Files changed (115) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +4 -3
  3. package/agents/evidence-auditor.md +34 -0
  4. package/agents/reviewer.md +3 -2
  5. package/docs/agents.md +6 -3
  6. package/docs/configuration.md +7 -5
  7. package/docs/extension-api.md +33 -18
  8. package/docs/missions.md +8 -0
  9. package/docs/models.md +1 -1
  10. package/docs/observability.md +4 -4
  11. package/docs/standalone-background.md +49 -0
  12. package/docs/tool-reference.md +18 -8
  13. package/docs/watchdog.md +35 -4
  14. package/docs/workflows.md +26 -12
  15. package/inspector-runner.mjs +2 -2
  16. package/package.json +1 -1
  17. package/prompts/parallel-review.md +1 -1
  18. package/{runner-server-preload.mjs → runner-peer-preload.mjs} +8 -3
  19. package/skills/pi-subagents/SKILL.md +14 -0
  20. package/skills/pi-subagents/references/execution-controls.md +7 -5
  21. package/skills/pi-subagents/references/prompting-and-roles.md +2 -2
  22. package/src/agents/advertised-agent-prompt.ts +34 -3
  23. package/src/agents/agents.ts +6 -0
  24. package/src/agents/builtin-names.ts +1 -0
  25. package/src/api/delegation.ts +4 -0
  26. package/src/api/preflight.ts +76 -45
  27. package/src/api/shared-types.ts +2 -0
  28. package/src/extension/fanout-child.ts +63 -4
  29. package/src/extension/index.ts +20 -8
  30. package/src/extension/public-execution.ts +4 -2
  31. package/src/extension/rpc.ts +4 -0
  32. package/src/extension/schemas.ts +67 -78
  33. package/src/extension/tool-description.ts +29 -82
  34. package/src/inspectors/actions.ts +148 -0
  35. package/src/inspectors/ghostty/actions.ts +74 -0
  36. package/src/inspectors/ghostty/plugin.ts +17 -0
  37. package/src/inspectors/herdr/actions.ts +99 -179
  38. package/src/inspectors/herdr/plugin.ts +20 -0
  39. package/src/inspectors/herdr/project-panes.ts +1 -1
  40. package/src/inspectors/{herdr/inspector-runner.ts → inspector-runner.ts} +12 -12
  41. package/src/inspectors/plugins.ts +8 -0
  42. package/src/inspectors/{herdr/session-roots-codec.ts → session-roots-codec.ts} +3 -14
  43. package/src/inspectors/types.ts +51 -0
  44. package/src/intercom/intercom-bridge.ts +50 -8
  45. package/src/intercom/native-supervisor-channel.ts +22 -13
  46. package/src/runs/background/active-async-capacity.ts +4 -0
  47. package/src/runs/background/async-execution.ts +45 -56
  48. package/src/runs/background/async-resume.ts +5 -9
  49. package/src/runs/background/auto-drain.ts +6 -3
  50. package/src/runs/background/binary-bootstrap.ts +33 -0
  51. package/src/runs/background/fleet-view.ts +30 -2
  52. package/src/runs/background/notify.ts +31 -1
  53. package/src/runs/background/owned-process-tree.ts +29 -2
  54. package/src/runs/background/run-child-session.ts +61 -4
  55. package/src/runs/background/run-status.ts +3 -0
  56. package/src/runs/background/runner-aliases.ts +11 -3
  57. package/src/runs/background/runner-child-launch.ts +2 -0
  58. package/src/runs/background/runner-child-sessions.ts +5 -4
  59. package/src/runs/background/scheduled-runs.ts +40 -13
  60. package/src/runs/background/steering.ts +20 -2
  61. package/src/runs/background/subagent-runner.ts +47 -31
  62. package/src/runs/background/subagent-wait.ts +51 -8
  63. package/src/runs/background/wait-tool.ts +1 -1
  64. package/src/runs/foreground/async-steering-action.ts +18 -7
  65. package/src/runs/foreground/execution.ts +44 -31
  66. package/src/runs/foreground/prompt-audit.ts +3 -1
  67. package/src/runs/foreground/subagent-executor.ts +110 -100
  68. package/src/runs/foreground/workflow-detach-reconcile.ts +2 -0
  69. package/src/runs/foreground/workflow-foreground-steering.ts +2 -1
  70. package/src/runs/shared/acceptance.ts +5 -2
  71. package/src/runs/shared/async-status-projection.ts +4 -0
  72. package/src/runs/shared/capability-ceiling.ts +2 -0
  73. package/src/runs/shared/child-hooks.ts +25 -10
  74. package/src/runs/shared/child-launch.ts +12 -2
  75. package/src/runs/shared/child-lifecycle.ts +6 -3
  76. package/src/runs/shared/child-runtime-config.ts +3 -1
  77. package/src/runs/shared/child-session.ts +33 -2
  78. package/src/runs/shared/child-tool-plan.ts +122 -3
  79. package/src/runs/shared/completion-guard.ts +5 -3
  80. package/src/runs/shared/effective-system-prompt.ts +33 -0
  81. package/src/runs/shared/external-cli-runner.ts +9 -7
  82. package/src/runs/shared/llm-intent-arbiter.ts +12 -3
  83. package/src/runs/shared/model-fallback.ts +2 -0
  84. package/src/runs/shared/orca-progress-tabs.ts +1 -1
  85. package/src/runs/shared/pi-spawn.ts +10 -0
  86. package/src/runs/shared/subagent-prompt-runtime.ts +9 -3
  87. package/src/runs/shared/task-intent.ts +46 -13
  88. package/src/runs/shared/workflow-async-child-guidance.ts +18 -0
  89. package/src/runs/shared/worktree.ts +42 -12
  90. package/src/shared/fork-context.ts +15 -72
  91. package/src/shared/launch-contract.ts +65 -2
  92. package/src/shared/opencode-session-headers.ts +30 -0
  93. package/src/shared/types.ts +4 -1
  94. package/src/slash/delegation-adapters.ts +3 -1
  95. package/src/slash/delegation-request.ts +14 -0
  96. package/src/slash/slash-commands.ts +2 -1
  97. package/src/slash/subagents-admin.ts +11 -4
  98. package/src/tui/fleet-status.ts +164 -19
  99. package/src/tui/fleet.ts +16 -14
  100. package/src/tui/render.ts +149 -28
  101. package/src/watchdog/child-status.ts +8 -0
  102. package/src/watchdog/model-selection.ts +20 -0
  103. package/src/watchdog/permission-arbiter.ts +3 -1
  104. package/src/watchdog/register-child.ts +1 -0
  105. package/src/watchdog/register-main.ts +31 -27
  106. package/src/watchdog/review.ts +132 -67
  107. package/src/watchdog/runtime.ts +82 -20
  108. package/src/watchdog/scope.ts +1 -1
  109. package/src/watchdog/settings.ts +9 -3
  110. package/src/watchdog/tool-actions.ts +13 -12
  111. package/src/watchdog/turn-delta.ts +23 -0
  112. package/src/watchdog/types.ts +4 -0
  113. package/src/workflows/scripted-workflow.ts +237 -7
  114. package/src/workflows/workflow-checklist.ts +2 -2
  115. /package/src/inspectors/{herdr/shell-command.ts → shell-command.ts} +0 -0
@@ -6,7 +6,6 @@
6
6
  */
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
9
  import type { ChildWatchdogConfig, ChildWatchdogStatusEvent } from "../../watchdog/child-status.ts";
11
10
  import type { ThinkingLevel } from "../../shared/model-info.ts";
12
11
  import { intersectThinkingCeilings } from "../../shared/thinking-ceiling.ts";
@@ -35,6 +34,7 @@ import type { ChildRuntimeConfig } from "./child-runtime-config.ts";
35
34
  import { createCapturedChildHooks, withChildSessionErrorReporting } from "./child-hooks.ts";
36
35
  import type { ChildTranscriptWriter } from "../../shared/child-transcript.ts";
37
36
  import type { ChildSessionLaunch, ChildSessionStorage } from "./child-session.ts";
37
+ import type { ArbiterModelContext } from "./llm-intent-arbiter.ts";
38
38
 
39
39
  /** Environment variable pi-mcp-adapter reads for the tools a child may expose. */
40
40
  export const MCP_DIRECT_TOOLS_ENV = "MCP_DIRECT_TOOLS";
@@ -112,13 +112,21 @@ export interface BuildInProcessChildLaunchInput {
112
112
  * them and exposes the child environment external extensions read.
113
113
  */
114
114
  host: "parent" | "runner";
115
+ /**
116
+ * Builtin tool names the host runtime provides. When set, child tool plans
117
+ * intersect declared agent tools with this set, omitting tools the host
118
+ * cannot provide. Review/scout lanes fail closed when a requested,
119
+ * still-permitted repository inspection tool is missing from that set.
120
+ */
121
+ hostAvailableBuiltins?: readonly string[];
115
122
  }
116
123
 
117
124
  export interface InProcessChildCapture {
118
- completionIntentContext?(): Pick<ExtensionContext, "model" | "modelRegistry"> | undefined;
125
+ completionIntentContext?(): ArbiterModelContext | undefined;
119
126
  structuredOutput(): { called: boolean; value?: unknown; acceptanceReport?: unknown; acceptanceReportProvided: boolean };
120
127
  toolDiagnostic(): ChildToolDiagnostic | undefined;
121
128
  runtimeAcknowledgedExtensions(): RuntimeAcknowledgedChildExtensions | undefined;
129
+ finalDrainHeld(): boolean;
122
130
  }
123
131
 
124
132
  export interface InProcessChildLaunch {
@@ -192,6 +200,7 @@ export function buildInProcessChildLaunch(input: BuildInProcessChildLaunchInput)
192
200
  agentName: input.childAgentName,
193
201
  permissionRules: input.permissionRules,
194
202
  runtimeSnapshotHost: input.runtimeSnapshotHost,
203
+ hostAvailableBuiltins: input.hostAvailableBuiltins,
195
204
  });
196
205
 
197
206
  const inherited = input.inherited;
@@ -311,6 +320,7 @@ export function buildInProcessChildLaunch(input: BuildInProcessChildLaunchInput)
311
320
  structuredOutput: () => ({ called: structuredCalled, value: structuredValue, acceptanceReport: structuredAcceptanceReport, acceptanceReportProvided: structuredAcceptanceProvided }),
312
321
  toolDiagnostic: capturedHooks.toolDiagnostic,
313
322
  runtimeAcknowledgedExtensions: capturedHooks.runtimeAcknowledgedExtensions,
323
+ finalDrainHeld: capturedHooks.finalDrainHeld,
314
324
  },
315
325
  launchResolvedExtensions,
316
326
  warnings: toolPlan.warnings,
@@ -14,11 +14,14 @@ export function projectChildLifecycle(event: { type?: string; willRetry?: unknow
14
14
  if (state) state.compactionRetryActive = event.willRetry === true;
15
15
  return event.willRetry === true ? "cancel-drain" : "none";
16
16
  }
17
- if (event.type === "agent_start" || event.type === "auto_retry_start") {
17
+ if (event.type === "agent_start" || event.type === "auto_retry_start" || event.type === "turn_start") {
18
18
  if (state) state.compactionRetryActive = false;
19
+ return "cancel-drain";
20
+ }
21
+ if (event.type === "agent_end") {
22
+ if (event.willRetry !== true && state) state.compactionRetryActive = false;
23
+ return "cancel-drain";
19
24
  }
20
- if (event.type === "agent_end" && event.willRetry === true) return "cancel-drain";
21
- if (event.type === "agent_end" && state) state.compactionRetryActive = false;
22
25
  if (event.type === "agent_settled") return state?.compactionRetryActive ? "none" : "start-drain";
23
26
  if (terminalAssistantStop) return "start-drain";
24
27
  return "none";
@@ -1,4 +1,4 @@
1
- import type { JsonSchemaObject, ResolvedToolBudget, RunFanoutBudgetDescriptor } from "../../shared/types.ts";
1
+ import type { JsonSchemaObject, ResolvedToolBudget, RunFanoutBudgetDescriptor, SubagentState } from "../../shared/types.ts";
2
2
  import type { ThinkingLevel } from "../../shared/model-info.ts";
3
3
  import type { NestedPathEntry } from "./nested-path.ts";
4
4
  import type { PermissionRules } from "./permissions.ts";
@@ -86,6 +86,8 @@ export interface ChildRuntimeConfig {
86
86
  /** Receives child watchdog status events. */
87
87
  watchdogStatus?: (event: ChildWatchdogStatusEvent) => void;
88
88
  waitTool: ResolvedWaitToolConfig;
89
+ runtimeState?: SubagentState;
90
+ holdFinalDrain?: (held: boolean) => void;
89
91
  structuredOutput?: ChildStructuredOutput;
90
92
  requiredTools?: string[];
91
93
  mcpDirectTools?: string[];
@@ -93,6 +93,8 @@ export interface ChildSession {
93
93
  abort(): Promise<void>;
94
94
  /** Emits `session_shutdown` to the child's extensions and disposes the session; resolves once that shutdown work is done. */
95
95
  dispose(): Promise<void>;
96
+ /** True while Pi still has steering or follow-up input that has not started a turn. */
97
+ hasQueuedMessages?(): boolean;
96
98
  readonly messages: readonly AgentMessage[];
97
99
  readonly sessionFile: string | undefined;
98
100
  readonly sessionId: string;
@@ -103,6 +105,14 @@ export interface ChildSession {
103
105
  shutDown?: boolean;
104
106
  }
105
107
 
108
+ export function childSessionHasQueuedMessages(session: ChildSession | undefined): boolean {
109
+ try {
110
+ return session?.hasQueuedMessages?.() === true;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
106
116
  export interface ChildSessionFactory {
107
117
  create(launch: ChildSessionLaunch): Promise<ChildSession>;
108
118
  /** Abort and dispose every live attached child; detached children keep running and hold the shared runtime. */
@@ -124,6 +134,21 @@ export interface DefaultChildSessionFactoryOptions {
124
134
 
125
135
  type ModelRuntimeInstance = Awaited<ReturnType<PiCodingAgentModule["ModelRuntime"]["create"]>>;
126
136
 
137
+ const CHILD_PROMPT_RUNTIME_EXTENSION_PATH = "<inline:pi-subagents:prompt-runtime>";
138
+
139
+ /** The prompt runtime filters parent-only context before ambient extensions inspect
140
+ * the child prompt. Other inline hooks keep their normal position after ambient
141
+ * extensions, and ambient extension order stays unchanged. */
142
+ function prioritizeChildPromptRuntime<T extends { extensions: Array<{ path: string }> }>(result: T): T {
143
+ const index = result.extensions.findIndex(({ path }) => path === CHILD_PROMPT_RUNTIME_EXTENSION_PATH);
144
+ if (index <= 0) return result;
145
+ const extensions = [...result.extensions];
146
+ const [promptRuntime] = extensions.splice(index, 1);
147
+ if (!promptRuntime) return result;
148
+ extensions.unshift(promptRuntime);
149
+ return { ...result, extensions };
150
+ }
151
+
127
152
  /** One launch at a time from env application through `session_start`, so parallel launches never observe each other's `processEnv` while their extensions load and start. */
128
153
  let loading: Promise<unknown> = Promise.resolve();
129
154
 
@@ -198,8 +223,12 @@ export function createDefaultChildSessionFactory(options: DefaultChildSessionFac
198
223
  const modelRuntime = await sharedRuntime(pi);
199
224
  const agentDir = getAgentDir();
200
225
  const settingsManager = pi.SettingsManager.create(launch.cwd, agentDir);
201
- // Headless sessions skip Pi's CLI theme setup; extensions still need ctx.ui.theme.
202
- if (typeof pi.initTheme === "function") pi.initTheme(settingsManager.getTheme());
226
+ // Foreground children share Pi's global theme with the parent, so reinitializing it
227
+ // would overwrite the parent's active light/dark appearance. Detached runners have
228
+ // no initialized theme and must initialize one for headless extension renderers.
229
+ const themeKey = Symbol.for("@earendil-works/pi-coding-agent:theme");
230
+ const themeInitialized = Boolean((globalThis as Record<symbol, unknown>)[themeKey]);
231
+ if (!themeInitialized && typeof pi.initTheme === "function") pi.initTheme(settingsManager.getTheme());
203
232
  const loader = new pi.DefaultResourceLoader({
204
233
  cwd: launch.cwd,
205
234
  agentDir,
@@ -211,6 +240,7 @@ export function createDefaultChildSessionFactory(options: DefaultChildSessionFac
211
240
  noContextFiles: launch.noContextFiles,
212
241
  additionalExtensionPaths: launch.extensionPaths,
213
242
  extensionFactories: launch.hooks,
243
+ extensionsOverride: prioritizeChildPromptRuntime,
214
244
  ...(launch.systemPrompt !== undefined ? { systemPrompt: launch.systemPrompt } : {}),
215
245
  ...(launch.appendSystemPrompt !== undefined ? { appendSystemPrompt: [launch.appendSystemPrompt] } : {}),
216
246
  });
@@ -294,6 +324,7 @@ export function createDefaultChildSessionFactory(options: DefaultChildSessionFac
294
324
  steer: (text) => { evidence?.invalidate(); return session.steer(text); },
295
325
  followUp: (text) => { evidence?.invalidate(); return session.followUp(text); },
296
326
  abort: () => { evidence?.invalidate(); return session.abort(); },
327
+ hasQueuedMessages: () => session.agent?.hasQueuedMessages?.() === true,
297
328
  dispose: () => {
298
329
  if (!pending) {
299
330
  live.delete(child);
@@ -7,6 +7,7 @@ import { createHash } from "node:crypto";
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
11
  import {
11
12
  formatUnresolvedMcpDirectToolSelectors,
12
13
  resolveMcpDirectToolResolution,
@@ -60,6 +61,41 @@ const FAST_MODE_ALLOWED_MODELS = new Set([
60
61
  "openai-codex/gpt-5.6-sol",
61
62
  ]);
62
63
  const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64;
64
+ const PI_BUILTIN_TOOL_NAMES = new Set(["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"]);
65
+ const REPOSITORY_INSPECTION_TOOLS = new Set(["read", "grep", "find", "ls", "bash", "powershell"]);
66
+ const REVIEW_OR_SCOUT_AGENT_PATTERN = /\b(?:reviewer|scout)\b/i;
67
+ // These providers come from child hooks, not the host's builtin tool registry.
68
+ const NATIVE_COORDINATION_TOOL_NAMES = new Set(["subagent", "contact_supervisor", "subagent_supervisor"]);
69
+
70
+ export function isReviewOrScoutLaneAgent(agentName: string | undefined): boolean {
71
+ return typeof agentName === "string" && REVIEW_OR_SCOUT_AGENT_PATTERN.test(agentName);
72
+ }
73
+
74
+ export function missingPermittedRepositoryInspectionTools(
75
+ unavailableHostBuiltins: readonly string[],
76
+ excludeTools: readonly string[] = [],
77
+ ): string[] {
78
+ const excluded = new Set(excludeTools);
79
+ return unavailableHostBuiltins.filter((tool) => REPOSITORY_INSPECTION_TOOLS.has(tool) && !excluded.has(tool));
80
+ }
81
+
82
+ export function formatReviewLaneToolContractFailure(input: {
83
+ agentName?: string;
84
+ missingTools: readonly string[];
85
+ requestedTools?: readonly string[];
86
+ effectiveTools: readonly string[];
87
+ ceilingSources?: readonly string[];
88
+ excludeTools?: readonly string[];
89
+ }): string {
90
+ const subject = input.agentName ? `Agent '${input.agentName}'` : "Subagent";
91
+ return [
92
+ `${subject}: tool contract could not be satisfied; host runtime does not provide permitted required repository tools [${input.missingTools.join(", ")}].`,
93
+ `Requested tool names: ${input.requestedTools ? `[${input.requestedTools.join(", ")}]` : "not explicitly specified"}; effective tool allowlist: [${input.effectiveTools.join(", ")}].`,
94
+ ...(input.ceilingSources?.length ? [`Active capability ceiling sources: [${input.ceilingSources.join(", ")}].`] : []),
95
+ ...(input.excludeTools?.length ? [`Explicit excludeTools: [${input.excludeTools.join(", ")}].`] : []),
96
+ "This is a lane infrastructure failure, not a completed review/scout result.",
97
+ ].join(" ");
98
+ }
63
99
 
64
100
  export function deriveForkPromptCacheKey(parentSessionId: string | undefined): string | undefined {
65
101
  const parent = parentSessionId?.trim();
@@ -151,6 +187,15 @@ export interface ResolvePiLaunchToolPlanInput {
151
187
  agentName?: string;
152
188
  permissionRules?: PermissionRules;
153
189
  runtimeSnapshotHost?: McpRuntimeSnapshotHost;
190
+ /**
191
+ * When provided, child tool plans intersect declared builtin tools with
192
+ * this set. Tools the agent declares but the host does not provide are
193
+ * omitted with a non-fatal warning (tracked in `unavailableHostBuiltins`).
194
+ * Review/scout lanes fail closed when a requested, still-permitted
195
+ * repository inspection tool is among those host omissions. Intentionally
196
+ * empty or ceiling-restricted allowlists are not a minimum-tool contract.
197
+ */
198
+ hostAvailableBuiltins?: readonly string[];
154
199
  }
155
200
 
156
201
  export interface PiLaunchToolPlan {
@@ -174,6 +219,8 @@ export interface PiLaunchToolPlan {
174
219
  capabilityAudit?: SubagentCapabilityAudit;
175
220
  /** Non-fatal launch warnings; they do not change behavior. */
176
221
  warnings: string[];
222
+ /** Builtin tools the agent declared but the host runtime does not provide. */
223
+ unavailableHostBuiltins: string[];
177
224
  }
178
225
 
179
226
  function extensionIdentifier(value: string): string {
@@ -289,6 +336,31 @@ export function resolvePermissionSystemExtension(): string | undefined {
289
336
  return undefined;
290
337
  }
291
338
 
339
+ /**
340
+ * Extract the names of builtin tools the host provides. Use this to pass
341
+ * `hostAvailableBuiltins` to `resolvePiLaunchToolPlan` so child tool plans
342
+ * intersect declared agent tools with what the host actually supports.
343
+ *
344
+ * Returns `undefined` when builtin tool discovery fails or yields nothing,
345
+ * so callers skip the intersection (fail-safe to allowing all declared tools).
346
+ * This handles test mocks without proper tool registration and hosts whose
347
+ * getAllTools() throws before extensions load.
348
+ */
349
+ export function getHostBuiltinToolNames(pi: Pick<ExtensionAPI, "getAllTools">): string[] | undefined {
350
+ try {
351
+ const builtins = pi
352
+ .getAllTools()
353
+ .filter((tool) => {
354
+ const source = (tool.sourceInfo as { source?: string } | undefined)?.source;
355
+ return source === "builtin" || (source === "auto" && PI_BUILTIN_TOOL_NAMES.has(tool.name));
356
+ })
357
+ .map((tool) => tool.name);
358
+ return builtins.length > 0 ? builtins : undefined;
359
+ } catch {
360
+ return undefined;
361
+ }
362
+ }
363
+
292
364
  export function resolvePiLaunchToolPlan(
293
365
  input: ResolvePiLaunchToolPlanInput,
294
366
  ): PiLaunchToolPlan {
@@ -300,17 +372,27 @@ export function resolvePiLaunchToolPlan(
300
372
  capabilityCeiling?.allowedTools === undefined
301
373
  ? undefined
302
374
  : new Set(capabilityCeiling.allowedTools);
375
+ const hostAvailableSet =
376
+ input.hostAvailableBuiltins === undefined
377
+ ? undefined
378
+ : new Set(input.hostAvailableBuiltins);
303
379
  const requestedBuiltinTools =
304
380
  input.tools?.filter(
305
381
  (tool) =>
306
382
  !(tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js")),
307
383
  ) ?? [];
384
+ if (input.requireReadTool && hostAvailableSet && !hostAvailableSet.has("read")) {
385
+ const agentLabel = input.agentName ? ` for agent '${input.agentName}'` : "";
386
+ throw new Error(
387
+ `Host runtime does not provide required tool 'read'${agentLabel} for lazy skill loading.`,
388
+ );
389
+ }
308
390
  if (input.requireReadTool && allowedToolSet && !allowedToolSet.has("read")) {
309
391
  throw new Error(
310
392
  `Capability ceiling from ${capabilityCeiling?.sources.join(", ") || "unknown source"} excludes required tool 'read' for lazy skill loading.`,
311
393
  );
312
394
  }
313
- const declaredBuiltinTools =
395
+ const ceilingFilteredBuiltinTools =
314
396
  input.tools === undefined
315
397
  ? allowedToolSet
316
398
  ? [...allowedToolSet]
@@ -322,6 +404,12 @@ export function resolvePiLaunchToolPlan(
322
404
  ? ["read", ...requestedBuiltinTools]
323
405
  : requestedBuiltinTools
324
406
  ).filter((tool) => !allowedToolSet || allowedToolSet.has(tool));
407
+ const declaredBuiltinTools = hostAvailableSet
408
+ ? ceilingFilteredBuiltinTools.filter((tool) => hostAvailableSet.has(tool) || NATIVE_COORDINATION_TOOL_NAMES.has(tool))
409
+ : ceilingFilteredBuiltinTools;
410
+ const unavailableHostBuiltins = hostAvailableSet
411
+ ? ceilingFilteredBuiltinTools.filter((tool) => !hostAvailableSet.has(tool) && !NATIVE_COORDINATION_TOOL_NAMES.has(tool))
412
+ : [];
325
413
  const excludeTools = [...new Set((input.excludeTools ?? []).map((tool) => tool.trim()).filter(Boolean))];
326
414
  const excludedToolSet = new Set(excludeTools);
327
415
  const effectiveDeclaredBuiltinTools = declaredBuiltinTools.filter((tool) => !excludedToolSet.has(tool));
@@ -330,6 +418,9 @@ export function resolvePiLaunchToolPlan(
330
418
  !excludedToolSet.has("subagent") &&
331
419
  (!allowedToolSet || allowedToolSet.has("subagent"))
332
420
  );
421
+ if (effectiveDeclaredBuiltinTools.includes("subagent_supervisor") && !fanoutAuthorized) {
422
+ throw new Error("Tool 'subagent_supervisor' requires fanout authorization: include 'subagent' in the effective tools allowlist or enable allowNestedSubagents.");
423
+ }
333
424
  const toolExtensionPaths: string[] = capabilityCeiling?.denyExtensions
334
425
  ? []
335
426
  : (input.tools ?? []).filter(
@@ -370,8 +461,8 @@ export function resolvePiLaunchToolPlan(
370
461
  ...internalTools,
371
462
  ]),
372
463
  ];
373
- // Supervisor-coordination names stay in the --tools allowlist but are never
374
- // strict requirements: children register contact_supervisor at runtime through
464
+ // Upward contact stays in the --tools allowlist but is not a strict
465
+ // requirement: children register contact_supervisor at runtime through
375
466
  // the native supervisor channel (or pi-intercom). The pre-0.50 bridge always
376
467
  // appended intercom alongside contact_supervisor, so that exact pairing is
377
468
  // legacy plumbing, not a user demand for an external intercom provider;
@@ -437,6 +528,32 @@ export function resolvePiLaunchToolPlan(
437
528
  ]),
438
529
  ]
439
530
  : undefined;
531
+ const missingPermittedRepositoryTools = input.tools !== undefined
532
+ ? missingPermittedRepositoryInspectionTools(unavailableHostBuiltins, excludeTools)
533
+ : [];
534
+ if (missingPermittedRepositoryTools.length > 0 && isReviewOrScoutLaneAgent(input.agentName)) {
535
+ throw new Error(formatReviewLaneToolContractFailure({
536
+ agentName: input.agentName,
537
+ missingTools: missingPermittedRepositoryTools,
538
+ requestedTools: requestedToolNames,
539
+ effectiveTools: effectiveToolAllowlist,
540
+ ceilingSources: capabilityCeiling?.sources,
541
+ excludeTools,
542
+ }));
543
+ }
544
+ // Host pruning also happens without a ceiling (and therefore without an
545
+ // audit). Use the existing non-fatal launch warnings rather than inventing
546
+ // a ceiling or treating the requested allowlist as a minimum requirement.
547
+ if (unavailableHostBuiltins.length > 0) {
548
+ const subject = input.agentName ? `Agent '${input.agentName}'` : "Subagent";
549
+ warnings.push(
550
+ `${subject}: host runtime tool availability omitted [${unavailableHostBuiltins.join(", ")}]. `
551
+ + `Requested tool names: ${requestedToolNames ? `[${requestedToolNames.join(", ")}]` : "not explicitly specified"}; effective tool allowlist: [${effectiveToolAllowlist.join(", ")}]. `
552
+ + (capabilityCeiling ? `Active capability ceiling sources: [${capabilityCeiling.sources.join(", ") || "unknown source"}]. ` : "")
553
+ + (excludeTools.length > 0 ? `Explicit excludeTools: [${excludeTools.join(", ")}]. ` : "")
554
+ + "This is a non-fatal tool-plan diagnostic, not verification of the child's runtime tool menu.",
555
+ );
556
+ }
440
557
  const capabilityAudit = capabilityCeiling
441
558
  ? ({
442
559
  ceiling: capabilityCeiling,
@@ -474,6 +591,7 @@ export function resolvePiLaunchToolPlan(
474
591
  capabilityCeilingAgentRestrictionSources(capabilityCeiling),
475
592
  }
476
593
  : {}),
594
+ ...(unavailableHostBuiltins.length > 0 ? { unavailableHostBuiltins } : {}),
477
595
  } satisfies SubagentCapabilityAudit)
478
596
  : undefined;
479
597
  return {
@@ -495,6 +613,7 @@ export function resolvePiLaunchToolPlan(
495
613
  extensionArgs,
496
614
  disableAmbientExtensions,
497
615
  warnings,
616
+ unavailableHostBuiltins,
498
617
  ...(capabilityAudit ? { capabilityAudit } : {}),
499
618
  };
500
619
  }
@@ -98,10 +98,12 @@ export function validateImplementationToolContract(input: {
98
98
  const declaredMutationToolsWereRemoved = requestedMutationTools.length > 0 && !hasBuiltinMutationTool(input.tools);
99
99
  const configuredExtensionCapability = (input.configuredExtensions?.length ?? 0) > 0 && !declaredMutationToolsWereRemoved;
100
100
  if (hasMutationToolCapability(input.tools, input.mcpDirectTools) || configuredExtensionCapability) return undefined;
101
- const intent = classifyTaskMutationIntent(input.agent, input.task);
101
+ const intent = classifyTaskMutationIntent(input.acceptanceRole === "writer" ? "worker" : input.agent, input.task);
102
102
  if (intent.kind === "read-only") return undefined;
103
- const writerTaskMayMutate = isWriterRole(input.agent, input.acceptanceRole)
104
- && (taskMayMutate(input.task) || WRITER_DELIVERY_PATTERN.test(input.task));
103
+ const writerTaskMayMutate = input.acceptanceRole === "writer"
104
+ ? true
105
+ : isWriterRole(input.agent, input.acceptanceRole)
106
+ && (taskMayMutate(input.task) || WRITER_DELIVERY_PATTERN.test(input.task));
105
107
  if (intent.kind !== "implementation" && !writerTaskMayMutate && !declaredMutationToolsWereRemoved) return undefined;
106
108
  return `Agent '${input.agent}' was given an implementation task, but its tool allowlist has no mutation-capable tools. Add bash, edit, write, or another mutation-capable tool to the agent, or use a read-only task/agent.`;
107
109
  }
@@ -0,0 +1,33 @@
1
+ import type { AgentConfig } from "../../agents/agents.ts";
2
+ import { buildAgentMemoryInjection } from "../../agents/agent-memory.ts";
3
+ import { appendAgentRefinementOverlay } from "../../agents/agent-refinements.ts";
4
+ import { buildSkillInjection } from "../../agents/skills.ts";
5
+ import { injectOutputPathSystemPrompt } from "./single-output.ts";
6
+
7
+ export interface EffectiveSystemPromptInput {
8
+ /** Agent as handed to the child, including runtime-declared overlays such as the Intercom bridge. */
9
+ agent: AgentConfig;
10
+ resolvedSkills: Parameters<typeof buildSkillInjection>[0];
11
+ /** Directory that scopes memory and refinement lookups. */
12
+ cwd: string;
13
+ /** Omit when the caller injects the output path through another channel. */
14
+ outputPath?: string;
15
+ }
16
+
17
+ function appendSection(prompt: string, section: string): string {
18
+ return prompt ? `${prompt}\n\n${section}` : section;
19
+ }
20
+
21
+ /**
22
+ * Child system prompt in the order preflight and every execution path hash:
23
+ * base prompt, skills, memory, refinement overlay, output path. Runtime
24
+ * acceptance prose is appended later and stays outside launch identity.
25
+ */
26
+ export function buildEffectiveSystemPrompt(input: EffectiveSystemPromptInput): string {
27
+ let prompt = input.agent.systemPrompt?.trim() ?? "";
28
+ if (input.resolvedSkills.length > 0) prompt = appendSection(prompt, buildSkillInjection(input.resolvedSkills));
29
+ const memoryInjection = buildAgentMemoryInjection(input.agent, input.cwd);
30
+ if (memoryInjection) prompt = appendSection(prompt, memoryInjection);
31
+ prompt = appendAgentRefinementOverlay(prompt, { cwd: input.cwd, agentName: input.agent.name });
32
+ return injectOutputPathSystemPrompt(prompt, input.outputPath, input.agent);
33
+ }
@@ -3,7 +3,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { finished } from "node:stream/promises";
6
- import type { ExternalProcessStatus } from "../../shared/types.ts";
6
+ import type { ExternalProcessStatus, ProcessTreeTerminal } from "../../shared/types.ts";
7
7
  import { createOwnedProcessTreeController, type OwnedProcessTreeController } from "../background/owned-process-tree.ts";
8
8
  import { omitExtensionBindingsEnv } from "./extension-bindings.ts";
9
9
  import {
@@ -141,7 +141,7 @@ function classifyInvalidation(error: string): "auth" | "permission" | "launch" {
141
141
  return "launch";
142
142
  }
143
143
 
144
- function terminateExternalProcessTree(pid: number, controller: OwnedProcessTreeController): Promise<unknown> {
144
+ function terminateExternalProcessTree(pid: number, controller: OwnedProcessTreeController): Promise<ProcessTreeTerminal> {
145
145
  if (process.platform !== "win32") return controller.terminate();
146
146
  return new Promise((resolve) => {
147
147
  const cleanup = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
@@ -239,7 +239,7 @@ export function runExternalCli(input: {
239
239
  let settled = false;
240
240
  let processTree: OwnedProcessTreeController | undefined;
241
241
  let processPid: number | undefined;
242
- let termination: Promise<unknown> | undefined;
242
+ let termination: Promise<ProcessTreeTerminal> | undefined;
243
243
  const flushProgress = () => {
244
244
  if (!latestProgress) return;
245
245
  input.onParserProgress?.(latestProgress);
@@ -381,8 +381,7 @@ export function runExternalCli(input: {
381
381
  input.registerTimeout?.(undefined);
382
382
  input.registerStop?.(undefined);
383
383
  void (async () => {
384
- if (termination) await termination;
385
- else if (processTree) await processTree.finishAfterWriterClose();
384
+ const treeProof = termination ? await termination : processTree ? await processTree.finishAfterWriterClose() : undefined;
386
385
  const endedAt = Date.now();
387
386
  const externalProcess: ExternalProcessStatus = {
388
387
  ...initialProcess,
@@ -398,15 +397,18 @@ export function runExternalCli(input: {
398
397
  input.onProcess?.(externalProcess);
399
398
  const stderr = stderrTail.text().trim();
400
399
  const parserFailure = parserError?.message ?? (parserTerminal?.state === "failed" ? parserTerminal.error ?? "External CLI parser reported terminal failure." : undefined);
400
+ const treeFailure = process.platform !== "win32" && treeProof?.state === "unknown"
401
+ ? `Process-tree cleanup failed: ${treeProof.reason}.`
402
+ : undefined;
401
403
  const error = stopped
402
404
  ? input.stopMessage ?? "Subagent stopped by user."
403
405
  : timedOut
404
406
  ? input.timeoutMessage ?? "Subagent timed out."
405
- : spawnError?.message ?? parserFailure ?? (exitCode === 0 ? undefined : stderr || `External CLI exited with code ${exitCode}.`);
407
+ : spawnError?.message ?? parserFailure ?? treeFailure ?? (exitCode === 0 ? undefined : stderr || `External CLI exited with code ${exitCode}.`);
406
408
  if (error && input.preflight && !parserError) invalidateExternalCliPreflight(input.command, input.preflight, classifyInvalidation(error));
407
409
  const result: ExternalCliRunResult = {
408
410
  output: (!parserError && parserTerminal?.state === "completed" ? parserTerminal.output ?? "" : stdoutTail.text()).trim(),
409
- exitCode: timedOut || stopped || spawnError || parserFailure ? 1 : exitCode,
411
+ exitCode: timedOut || stopped || spawnError || parserFailure || treeFailure ? 1 : exitCode,
410
412
  ...(error ? { error } : {}),
411
413
  ...(timedOut ? { timedOut: true } : {}),
412
414
  ...(stopped ? { stopped: true } : {}),
@@ -4,6 +4,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import type { ProviderHeaders } from "@earendil-works/pi-ai";
5
5
  import { Type, type Static } from "typebox";
6
6
  import { agentStreamOptions } from "../../shared/agent-stream-options.ts";
7
+ import { opencodeSessionHeaders } from "../../shared/opencode-session-headers.ts";
7
8
 
8
9
  /**
9
10
  * LLM intent arbiter for the completion mutation guard.
@@ -56,6 +57,8 @@ interface ArbiterRuntime {
56
57
  /** Registered provider stream, usable only when its api matches the model. */
57
58
  registeredStreamFn?: StreamFn;
58
59
  registeredApi?: string;
60
+ /** Session id for OpenCode session-routing headers, when the caller has one. */
61
+ sessionId?: string;
59
62
  timeoutMs: number;
60
63
  }
61
64
 
@@ -76,7 +79,11 @@ export interface TaskMutationArbiterOptions {
76
79
  const DEFAULT_ARBITER_TIMEOUT_MS = 10_000;
77
80
 
78
81
  type RegistryModel = ReturnType<NonNullable<ExtensionContext["modelRegistry"]["find"]>>;
79
- type ArbiterModelContext = Pick<ExtensionContext, "model" | "modelRegistry">;
82
+ /** Model services the arbiter needs, plus the caller's session id for OpenCode session-routing headers. */
83
+ export type ArbiterModelContext = Pick<ExtensionContext, "model" | "modelRegistry"> & {
84
+ /** Session id the headers attach to (the child's, captured by detached runners; the parent's, passed by foreground callers). */
85
+ sessionId?: string;
86
+ };
80
87
 
81
88
  function resolveArbiterModel(
82
89
  ctx: ArbiterModelContext,
@@ -113,6 +120,7 @@ function resolveArbiterRuntime(
113
120
  explicitStreamFn: options?.streamFn,
114
121
  registeredStreamFn: registered?.streamSimple,
115
122
  registeredApi: registered?.api,
123
+ sessionId: ctx.sessionId,
116
124
  timeoutMs: options?.timeoutMs ?? DEFAULT_ARBITER_TIMEOUT_MS,
117
125
  };
118
126
  }
@@ -151,12 +159,13 @@ async function resolveArbiterAuth(
151
159
  function authWrappedStreamFn(
152
160
  base: StreamFn,
153
161
  auth: ArbiterAuth,
162
+ sessionId: string | undefined,
154
163
  ): StreamFn {
155
164
  return (model, context, streamOptions) => base(model, context, {
156
165
  ...(streamOptions ?? {}),
157
166
  ...(auth.apiKey ? { apiKey: auth.apiKey } : {}),
158
167
  ...(auth.env || streamOptions?.env ? { env: { ...(auth.env ?? {}), ...(streamOptions?.env ?? {}) } } : {}),
159
- headers: { ...(streamOptions?.headers ?? {}), ...(auth.headers ?? {}) },
168
+ headers: { ...opencodeSessionHeaders(model, sessionId), ...(streamOptions?.headers ?? {}), ...(auth.headers ?? {}) },
160
169
  });
161
170
  }
162
171
 
@@ -200,7 +209,7 @@ async function runArbitration(
200
209
  tools: [tool],
201
210
  },
202
211
  convertToLlm,
203
- ...agentStreamOptions(authWrappedStreamFn(streamFn, auth)),
212
+ ...agentStreamOptions(authWrappedStreamFn(streamFn, auth, runtime.sessionId)),
204
213
  getApiKey: (providerName) =>
205
214
  providerName === runtime.model.provider ? auth.apiKey : undefined,
206
215
  beforeToolCall: async ({ toolCall }) =>
@@ -543,6 +543,8 @@ const RETRYABLE_MODEL_FAILURE_PATTERNS = [
543
543
  /quota/i,
544
544
  /billing/i,
545
545
  /credit/i,
546
+ // OpenRouter can return only a status-prefixed body, without auth-related prose.
547
+ /^\s*401\s*:/,
546
548
  /auth(?:entication)?/i,
547
549
  /unauthori[sz]ed/i,
548
550
  /forbidden/i,
@@ -29,7 +29,7 @@ const ORCA_CREATE_WATCHDOG_SCRIPT = [
29
29
  "function exists(file){try{return fs.existsSync(file)}catch{return false}}",
30
30
  "function keepQueued(){try{const now=new Date();fs.utimesSync(done,now,now)}catch{}}",
31
31
  "function predecessorReady(){if(previous==='-')return true;if(exists(previous.replace(/\\.pending$/,'.ready')))return true;try{return Date.now()-fs.statSync(previous).mtimeMs>=waitTimeout}catch{return true}}",
32
- "function updateManifest(state,stdout=''){if(manifest==='-')return;try{const payload=JSON.parse(fs.readFileSync(manifest,'utf8'));payload.state=state;payload.updatedAt=new Date().toISOString();const raw=stdout.trim().split(/\\r?\\n/).filter(Boolean).at(-1);if(raw){try{payload.orca=JSON.parse(raw)}catch{payload.orcaRaw=raw.slice(0,4096)}}fs.writeFileSync(manifest,JSON.stringify(payload,null,2)+'\\n')}catch{}}",
32
+ "function updateManifest(state,stdout=''){if(manifest==='-')return;try{const payload=JSON.parse(fs.readFileSync(manifest,'utf8'));payload.state=state;payload.updatedAt=new Date().toISOString();const raw=stdout.trim();if(raw){try{payload.orca=JSON.parse(raw)}catch{payload.orcaRaw=raw.slice(0,4096)}}fs.writeFileSync(manifest,JSON.stringify(payload,null,2)+'\\n')}catch{}}",
33
33
  "function start(){",
34
34
  " try{",
35
35
  " const child=spawn(command,args,{stdio:['ignore','pipe','ignore'],windowsHide:true});",
@@ -49,6 +49,7 @@ export interface PiSpawnDeps {
49
49
  platform?: NodeJS.Platform;
50
50
  execPath?: string;
51
51
  argv1?: string;
52
+ bunVersion?: string;
52
53
  existsSync?: (filePath: string) => boolean;
53
54
  realpathSync?: (filePath: string) => string;
54
55
  readFileSync?: (filePath: string, encoding: "utf-8") => string;
@@ -58,6 +59,15 @@ export interface PiSpawnDeps {
58
59
  env?: NodeJS.ProcessEnv;
59
60
  }
60
61
 
62
+ /** Compiled Pi's entrypoint is virtual; execPath is the real (possibly renamed) image. */
63
+ export function resolveBunPiExecutable(deps: PiSpawnDeps = {}): string | undefined {
64
+ const bunVersion = deps.bunVersion ?? process.versions.bun;
65
+ const entry = deps.argv1 ?? process.argv[1];
66
+ if (!bunVersion || !entry?.startsWith("/$bunfs/")) return undefined;
67
+ const env = deps.env ?? process.env;
68
+ return env[PI_SUBAGENT_PI_BINARY_ENV]?.trim() || (deps.execPath ?? process.execPath);
69
+ }
70
+
61
71
  interface PiSpawnCommand {
62
72
  command: string;
63
73
  args: string[];
@@ -451,10 +451,11 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI, config?:
451
451
  registerPermissionGate(pi, config.permissions, config.childWatchdog);
452
452
  registerToolBudget(pi, config.toolBudget);
453
453
  registerChildWatchdog(pi, config.childWatchdog, config.watchdogStatus);
454
- const waitState = {
454
+ const waitState = config.runtimeState ?? {
455
455
  baseCwd: "",
456
456
  currentSessionId: null,
457
457
  asyncJobs: new Map(),
458
+ foregroundRuns: new Map(),
458
459
  foregroundControls: new Map(),
459
460
  lastForegroundControlId: null,
460
461
  cleanupTimers: new Map(),
@@ -486,13 +487,18 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI, config?:
486
487
  if (diagnostic) throw new Error(formatChildToolDiagnostic(diagnostic));
487
488
  });
488
489
  onRuntimeEvent("agent_end", async (_event: unknown, ctx: unknown) => {
489
- if ((ctx as { hasUI?: boolean } | undefined)?.hasUI === true) { drainObservation?.deny(); return; }
490
+ if ((ctx as { hasUI?: boolean } | undefined)?.hasUI === true) drainObservation?.deny();
490
491
  if (drainObservation) {
491
492
  try {
492
493
  if ((ctx as ExtensionContext)?.sessionManager?.getSessionFile() !== waitState.currentSessionId) drainObservation.deny();
493
494
  } catch { drainObservation.deny(); }
494
495
  }
495
- await drainOutstandingWork({ state: waitState, events: pi.events }, drainObservation);
496
+ config.holdFinalDrain?.(true);
497
+ try {
498
+ await drainOutstandingWork({ state: waitState, events: pi.events }, drainObservation);
499
+ } finally {
500
+ config.holdFinalDrain?.(false);
501
+ }
496
502
  });
497
503
  if (config.structuredOutput) registerStructuredOutputTool(pi, config.structuredOutput);
498
504