pi-subagents 0.65.1 → 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 (148) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +5 -4
  3. package/agents/evidence-auditor.md +34 -0
  4. package/agents/researcher.md +23 -13
  5. package/agents/reviewer.md +3 -2
  6. package/docs/agents.md +20 -3
  7. package/docs/configuration.md +25 -5
  8. package/docs/extension-api.md +124 -18
  9. package/docs/missions.md +8 -0
  10. package/docs/models.md +59 -2
  11. package/docs/observability.md +46 -6
  12. package/docs/standalone-background.md +49 -0
  13. package/docs/tool-reference.md +20 -10
  14. package/docs/watchdog.md +35 -4
  15. package/docs/workflows.md +40 -19
  16. package/inspector-runner.mjs +2 -2
  17. package/package.json +2 -1
  18. package/prompts/parallel-review.md +1 -1
  19. package/{runner-server-preload.mjs → runner-peer-preload.mjs} +8 -3
  20. package/skills/pi-subagents/SKILL.md +14 -0
  21. package/skills/pi-subagents/references/execution-controls.md +20 -5
  22. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
  23. package/skills/pi-subagents/references/prompting-and-roles.md +2 -2
  24. package/src/agents/advertised-agent-prompt.ts +94 -0
  25. package/src/agents/agent-management.ts +14 -1
  26. package/src/agents/agent-serializer.ts +2 -0
  27. package/src/agents/agents.ts +14 -0
  28. package/src/agents/builtin-names.ts +1 -0
  29. package/src/api/delegation.ts +4 -0
  30. package/src/api/preflight.ts +76 -45
  31. package/src/api/shared-types.ts +3 -1
  32. package/src/api/workflow-resources.ts +6 -0
  33. package/src/extension/fanout-child.ts +63 -4
  34. package/src/extension/index.ts +58 -8
  35. package/src/extension/public-execution.ts +4 -3
  36. package/src/extension/rpc.ts +8 -21
  37. package/src/extension/schemas.ts +71 -80
  38. package/src/extension/tool-description.ts +29 -81
  39. package/src/inspectors/actions.ts +148 -0
  40. package/src/inspectors/ghostty/actions.ts +74 -0
  41. package/src/inspectors/ghostty/plugin.ts +17 -0
  42. package/src/inspectors/herdr/actions.ts +99 -179
  43. package/src/inspectors/herdr/plugin.ts +20 -0
  44. package/src/inspectors/herdr/project-panes.ts +1 -1
  45. package/src/inspectors/{herdr/inspector-runner.ts → inspector-runner.ts} +12 -12
  46. package/src/inspectors/plugins.ts +8 -0
  47. package/src/inspectors/{herdr/session-roots-codec.ts → session-roots-codec.ts} +3 -14
  48. package/src/inspectors/types.ts +51 -0
  49. package/src/intercom/intercom-bridge.ts +50 -8
  50. package/src/intercom/native-supervisor-channel.ts +104 -67
  51. package/src/runs/background/active-async-capacity.ts +22 -18
  52. package/src/runs/background/async-execution.ts +45 -56
  53. package/src/runs/background/async-job-tracker.ts +35 -3
  54. package/src/runs/background/async-resume.ts +5 -9
  55. package/src/runs/background/async-status-snapshot.ts +10 -12
  56. package/src/runs/background/async-status.ts +17 -9
  57. package/src/runs/background/auto-drain.ts +44 -30
  58. package/src/runs/background/binary-bootstrap.ts +33 -0
  59. package/src/runs/background/chain-root-attachment.ts +8 -0
  60. package/src/runs/background/control-channel.ts +78 -44
  61. package/src/runs/background/fleet-view.ts +30 -2
  62. package/src/runs/background/notify.ts +117 -13
  63. package/src/runs/background/owned-process-tree.ts +35 -8
  64. package/src/runs/background/process-terminal.ts +23 -23
  65. package/src/runs/background/run-child-session.ts +121 -36
  66. package/src/runs/background/run-status.ts +78 -5
  67. package/src/runs/background/runner-aliases.ts +28 -9
  68. package/src/runs/background/runner-child-launch.ts +88 -0
  69. package/src/runs/background/runner-child-sessions.ts +5 -4
  70. package/src/runs/background/scheduled-runs.ts +40 -13
  71. package/src/runs/background/stale-run-reconciler.ts +3 -1
  72. package/src/runs/background/steering.ts +20 -2
  73. package/src/runs/background/subagent-runner.ts +458 -239
  74. package/src/runs/background/subagent-wait.ts +54 -8
  75. package/src/runs/background/wait-completions.ts +4 -0
  76. package/src/runs/background/wait-tool.ts +1 -1
  77. package/src/runs/foreground/async-steering-action.ts +37 -7
  78. package/src/runs/foreground/execution.ts +145 -56
  79. package/src/runs/foreground/prompt-audit.ts +3 -1
  80. package/src/runs/foreground/subagent-executor.ts +584 -297
  81. package/src/runs/foreground/workflow-detach-reconcile.ts +10 -5
  82. package/src/runs/foreground/workflow-foreground-steering.ts +57 -2
  83. package/src/runs/shared/acceptance.ts +7 -4
  84. package/src/runs/shared/agent-contract.ts +1 -1
  85. package/src/runs/shared/async-status-projection.ts +51 -47
  86. package/src/runs/shared/capability-ceiling.ts +2 -0
  87. package/src/runs/shared/child-hooks.ts +167 -3
  88. package/src/runs/shared/child-launch.ts +28 -13
  89. package/src/runs/shared/child-lifecycle.ts +6 -3
  90. package/src/runs/shared/child-runtime-config.ts +3 -1
  91. package/src/runs/shared/child-session.ts +75 -8
  92. package/src/runs/shared/child-tool-plan.ts +124 -5
  93. package/src/runs/shared/completion-evidence.ts +2 -2
  94. package/src/runs/shared/completion-guard.ts +6 -3
  95. package/src/runs/shared/effective-system-prompt.ts +33 -0
  96. package/src/runs/shared/external-cli-runner.ts +9 -7
  97. package/src/runs/shared/host-step-status.ts +11 -11
  98. package/src/runs/shared/llm-intent-arbiter.ts +21 -11
  99. package/src/runs/shared/model-fallback.ts +12 -6
  100. package/src/runs/shared/nested-events.ts +5 -5
  101. package/src/runs/shared/orca-progress-tabs.ts +7 -1
  102. package/src/runs/shared/parallel-handoff.ts +57 -12
  103. package/src/runs/shared/parallel-utils.ts +2 -2
  104. package/src/runs/shared/pi-spawn.ts +10 -0
  105. package/src/runs/shared/readonly-drain-observation.ts +42 -0
  106. package/src/runs/shared/readonly-model-continuation.ts +69 -0
  107. package/src/runs/shared/readonly-session-evidence.ts +307 -0
  108. package/src/runs/shared/run-fanout-budget.ts +8 -8
  109. package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
  110. package/src/runs/shared/subagent-prompt-runtime.ts +20 -4
  111. package/src/runs/shared/task-intent.ts +46 -13
  112. package/src/runs/shared/workflow-async-child-guidance.ts +18 -0
  113. package/src/runs/shared/worktree-setup-command.ts +190 -0
  114. package/src/runs/shared/worktree.ts +366 -208
  115. package/src/shared/fork-context.ts +15 -72
  116. package/src/shared/launch-contract.ts +65 -2
  117. package/src/shared/opencode-session-headers.ts +30 -0
  118. package/src/shared/types.ts +85 -61
  119. package/src/shared/utils.ts +7 -2
  120. package/src/shared/workflow-child-permit.ts +18 -13
  121. package/src/slash/delegation-adapters.ts +3 -1
  122. package/src/slash/delegation-request.ts +14 -0
  123. package/src/slash/slash-commands.ts +2 -1
  124. package/src/slash/subagents-admin.ts +11 -4
  125. package/src/tui/fleet-status.ts +164 -19
  126. package/src/tui/fleet.ts +27 -19
  127. package/src/tui/render.ts +172 -33
  128. package/src/watchdog/child-status.ts +8 -0
  129. package/src/watchdog/model-selection.ts +20 -0
  130. package/src/watchdog/permission-arbiter.ts +3 -1
  131. package/src/watchdog/register-child.ts +1 -0
  132. package/src/watchdog/register-main.ts +31 -27
  133. package/src/watchdog/review.ts +132 -67
  134. package/src/watchdog/runtime.ts +82 -20
  135. package/src/watchdog/scope.ts +1 -1
  136. package/src/watchdog/settings.ts +9 -3
  137. package/src/watchdog/tool-actions.ts +13 -12
  138. package/src/watchdog/turn-delta.ts +23 -0
  139. package/src/watchdog/types.ts +4 -0
  140. package/src/workflows/chat-progress.ts +3 -3
  141. package/src/workflows/scripted-workflow.ts +275 -17
  142. package/src/workflows/workflow-checklist.ts +13 -17
  143. package/src/workflows/workflow-child-summary.ts +57 -8
  144. package/src/workflows/workflow-preflight.ts +19 -19
  145. package/src/workflows/workflow-receipt.ts +3 -3
  146. package/src/workflows/workflow-resources.ts +96 -21
  147. package/src/workflows/workflow-settlement.ts +3 -0
  148. /package/src/inspectors/{herdr/shell-command.ts → shell-command.ts} +0 -0
@@ -2,13 +2,13 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import * as readline from "node:readline";
4
4
  import { fileURLToPath } from "node:url";
5
- import { parseMissionRecord } from "../../missions/store.ts";
6
- import type { MissionRecord } from "../../missions/types.ts";
7
- import { requestAsyncSteer, requestAsyncStop } from "../../runs/background/control-channel.ts";
8
- import { formatAsyncRunTranscript } from "../../runs/background/fleet-view.ts";
9
- import { steeringReceipt } from "../../runs/background/steering.ts";
10
- import type { AsyncStatus } from "../../shared/types.ts";
11
- import { readStatus } from "../../shared/utils.ts";
5
+ import { parseMissionRecord } from "../missions/store.ts";
6
+ import type { MissionRecord } from "../missions/types.ts";
7
+ import { requestAsyncSteer, requestAsyncStop } from "../runs/background/control-channel.ts";
8
+ import { formatAsyncRunTranscript } from "../runs/background/fleet-view.ts";
9
+ import { steeringReceipt } from "../runs/background/steering.ts";
10
+ import type { AsyncStatus } from "../shared/types.ts";
11
+ import { readStatus } from "../shared/utils.ts";
12
12
  import { decodeSessionRoots } from "./session-roots-codec.ts";
13
13
 
14
14
  export interface RunnerOptions {
@@ -31,7 +31,7 @@ export function formatInspectorDashboard(input: { status: AsyncStatus; asyncDir:
31
31
  const { status, asyncDir, mission } = input;
32
32
  const lines = [
33
33
  `pi-subagents inspector for ${status.runId}`,
34
- "This pane mirrors lifecycle artifacts; closing it does not stop the run.",
34
+ "This inspector mirrors lifecycle artifacts; closing it does not stop the run.",
35
35
  "",
36
36
  ];
37
37
  if (mission) {
@@ -43,7 +43,7 @@ export function formatInspectorDashboard(input: { status: AsyncStatus; asyncDir:
43
43
  lines.push(formatAsyncRunTranscript(status, asyncDir, { index: input.index, lines: 60, sessionRoots: input.sessionRoots }));
44
44
  const acceptsPlainGuidance = input.index !== undefined || status.mode === "single";
45
45
  const controls = [input.allowSteer === false || !acceptsPlainGuidance ? undefined : "type guidance", input.allowSteer === false ? undefined : "steer <message>", input.allowStop === false ? undefined : "stop", "status"].filter(Boolean);
46
- lines.push("", `Controls: ${controls.join(" | ")}`, "Supervisor replies remain in the parent Pi session (subagent_supervisor/intercom).");
46
+ lines.push("", `Controls: ${controls.join(" | ")}`, "Supervisor replies remain in the parent Pi session (subagent_supervisor/intercom); this inspector is read-only.");
47
47
  return lines.join("\n");
48
48
  }
49
49
 
@@ -91,7 +91,7 @@ function queueInspectorSteer(options: RunnerOptions, status: AsyncStatus, messag
91
91
  requestAsyncSteer(options.asyncDir, {
92
92
  message,
93
93
  ...(targetIndex !== undefined ? { targetIndex } : { targetIndexes: runningIndexes }),
94
- source: "herdr-inspector",
94
+ source: "inspector-runner",
95
95
  });
96
96
  return steeringReceipt(message, `Steering queued for run ${options.runId}.`);
97
97
  }
@@ -104,7 +104,7 @@ export function submitInspectorControl(options: RunnerOptions, line: string): st
104
104
  if (command === "stop") {
105
105
  if (options.allowStop === false) throw new Error("Authority policy does not allow stop from this inspector.");
106
106
  if (isTerminal(status)) throw new Error(`Run '${options.runId}' is ${status.state} and cannot be stopped.`);
107
- requestAsyncStop(options.asyncDir, { source: "herdr-inspector" });
107
+ requestAsyncStop(options.asyncDir, { source: "inspector-runner" });
108
108
  return `Stop requested for run ${options.runId}.`;
109
109
  }
110
110
  if (command.startsWith("steer ")) {
@@ -147,7 +147,7 @@ export function runInspector(argv = process.argv.slice(2)): void {
147
147
 
148
148
  if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
149
149
  try { runInspector(); } catch (cause) {
150
- process.stderr.write(`Herdr inspector failed: ${cause instanceof Error ? cause.message : String(cause)}\n`);
150
+ process.stderr.write(`Inspector failed: ${cause instanceof Error ? cause.message : String(cause)}\n`);
151
151
  process.exitCode = 1;
152
152
  }
153
153
  }
@@ -0,0 +1,8 @@
1
+ import { createHerdrInspectorPlugin } from "./herdr/plugin.ts";
2
+ import { createGhosttyInspectorPlugin } from "./ghostty/plugin.ts";
3
+ import type { InspectorPlugin } from "./types.ts";
4
+
5
+ /** Built-in inspector plugins, ordered by host preference. */
6
+ export function createBuiltinInspectorPlugins(): readonly InspectorPlugin[] {
7
+ return [createHerdrInspectorPlugin(), createGhosttyInspectorPlugin()];
8
+ }
@@ -19,24 +19,13 @@ function parseStringArray(value: unknown): string[] | undefined {
19
19
  return value;
20
20
  }
21
21
 
22
- /**
23
- * Decodes a `--session-roots` argument produced by {@link encodeSessionRoots}.
24
- * Falls back to parsing the value as raw JSON so any externally-launched
25
- * inspector runner (a cached copy, or a manual invocation) that still passes
26
- * the legacy unencoded form keeps working.
27
- */
22
+ /** Decodes a `--session-roots` argument produced by {@link encodeSessionRoots}. */
28
23
  export function decodeSessionRoots(raw: string): string[] {
29
24
  try {
30
25
  const decoded = parseStringArray(JSON.parse(Buffer.from(raw, "base64").toString("utf-8")));
31
26
  if (decoded) return decoded;
32
27
  } catch {
33
- // fall through to legacy raw-JSON parsing below
34
- }
35
- try {
36
- const parsed = parseStringArray(JSON.parse(raw));
37
- if (parsed) return parsed;
38
- } catch {
39
- // fall through to the shared error below
28
+ // Report one stable validation error below.
40
29
  }
41
- throw new Error("--session-roots must be a base64-encoded or raw JSON array of strings.");
30
+ throw new Error("--session-roots must be a base64-encoded JSON array of strings.");
42
31
  }
@@ -0,0 +1,51 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
+ import type { Details } from "../shared/types.ts";
3
+
4
+ export const INSPECTOR_ACTIONS = ["inspector.open", "inspector.command", "inspector.status", "inspector.close"] as const;
5
+ export type InspectorAction = typeof INSPECTOR_ACTIONS[number];
6
+
7
+ export interface InspectorParams {
8
+ id?: string;
9
+ runId?: string;
10
+ dir?: string;
11
+ index?: number;
12
+ focus?: boolean;
13
+ }
14
+
15
+ export interface InspectorTarget {
16
+ runId: string;
17
+ asyncDir: string;
18
+ index?: number;
19
+ status: {
20
+ cwd?: string;
21
+ state: string;
22
+ steps?: unknown[];
23
+ };
24
+ }
25
+
26
+ export interface InspectorContext {
27
+ cwd: string;
28
+ signal?: AbortSignal;
29
+ env: NodeJS.ProcessEnv;
30
+ now?: () => Date;
31
+ target: InspectorTarget;
32
+ }
33
+
34
+ export interface InspectorLaunch {
35
+ executable: string;
36
+ argv: string[];
37
+ displayCommand: string;
38
+ mission?: { id: string; path: string };
39
+ allowSteer: boolean;
40
+ allowStop: boolean;
41
+ sessionRoots: string[];
42
+ }
43
+
44
+ export interface InspectorPlugin {
45
+ readonly name: string;
46
+ available(context: InspectorContext): Promise<boolean> | boolean;
47
+ owns(context: InspectorContext): boolean;
48
+ open(context: InspectorContext, launch: InspectorLaunch, params: InspectorParams): Promise<AgentToolResult<Details>>;
49
+ status?(context: InspectorContext): Promise<AgentToolResult<Details>>;
50
+ close?(context: InspectorContext): Promise<AgentToolResult<Details>>;
51
+ }
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import type { AgentConfig } from "../agents/agents.ts";
5
+ import { agentDefinitionDigest } from "../shared/launch-contract.ts";
5
6
  import type { ExtensionConfig, IntercomBridgeConfig, IntercomBridgeMode } from "../shared/types.ts";
6
7
  import { getAgentDir } from "../shared/utils.ts";
7
8
 
@@ -18,9 +19,13 @@ function defaultSubagentConfigDir(agentDir = defaultAgentDir()): string {
18
19
  const DEFAULT_INTERCOM_TARGET_PREFIX = "subagent-chat";
19
20
  export const PI_INTERCOM_SESSION_ID_ENV = "PI_INTERCOM_SESSION_ID";
20
21
  export const INTERCOM_BRIDGE_MARKER = "Intercom orchestration channel:";
22
+ const ORCHESTRATOR_TARGET_PLACEHOLDER = "{orchestratorTarget}";
23
+ // The default template must stay session-independent: the child reads the
24
+ // supervisor target from its runtime config, and a prompt that names the
25
+ // parent session would make the launch digest vary per session (#2127).
21
26
  const DEFAULT_INTERCOM_BRIDGE_TEMPLATE = `The inherited thread is reference-only. Do not continue that conversation or send questions, status updates, or completion handoffs to the supervisor in normal assistant text.
22
27
 
23
- Use contact_supervisor first. It resolves the supervisor session "{orchestratorTarget}" and run metadata automatically.
28
+ Use contact_supervisor first. It resolves the supervisor session and run metadata automatically.
24
29
  - Need a decision, blocked, approval, or product/API/scope ambiguity: contact_supervisor({ reason: "need_decision", message: "<question>" })
25
30
  - Need structured supervisor input rather than a freeform reply: contact_supervisor({ reason: "interview_request", message: "<what input is needed>", interview: { title: "...", questions: [] } })
26
31
  - After contact_supervisor with reason "need_decision" or "interview_request", stay alive and continue only after the reply arrives. Do not finish your final response with a choose-one question.
@@ -36,6 +41,32 @@ export interface IntercomBridgeState {
36
41
  orchestratorTarget?: string;
37
42
  extensionDir: string;
38
43
  instruction: string;
44
+ /** True when the instruction template names the supervisor session, which ties the child prompt to the parent session. */
45
+ interpolatesOrchestratorTarget: boolean;
46
+ }
47
+
48
+ export type IntercomBridgeConfigValidation =
49
+ | { ok: true; value: IntercomBridgeConfig }
50
+ | { ok: false; error: string };
51
+
52
+ /** Validates untrusted bridge config from descriptors or delegation requests; `label` prefixes each error. */
53
+ export function validateIntercomBridgeConfig({ value, label }: { value: unknown; label: string }): IntercomBridgeConfigValidation {
54
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { ok: false, error: `${label} must be an object.` };
55
+ const bridge = value as Record<string, unknown>;
56
+ for (const field of Object.keys(bridge)) {
57
+ if (field !== "mode" && field !== "instructionFile" && field !== "resultDelivery") return { ok: false, error: `${label}.${field} is not supported.` };
58
+ }
59
+ if (bridge.mode !== undefined && bridge.mode !== "off" && bridge.mode !== "fork-only" && bridge.mode !== "always") return { ok: false, error: `${label}.mode is invalid.` };
60
+ if (bridge.instructionFile !== undefined && typeof bridge.instructionFile !== "string") return { ok: false, error: `${label}.instructionFile must be a string.` };
61
+ if (bridge.resultDelivery !== undefined && typeof bridge.resultDelivery !== "boolean") return { ok: false, error: `${label}.resultDelivery must be a boolean.` };
62
+ return {
63
+ ok: true,
64
+ value: {
65
+ ...(bridge.mode !== undefined ? { mode: bridge.mode as IntercomBridgeMode } : {}),
66
+ ...(bridge.instructionFile !== undefined ? { instructionFile: bridge.instructionFile as string } : {}),
67
+ ...(bridge.resultDelivery !== undefined ? { resultDelivery: bridge.resultDelivery as boolean } : {}),
68
+ },
69
+ };
39
70
  }
40
71
 
41
72
  export interface IntercomBridgeDiagnostic {
@@ -114,7 +145,7 @@ function resolveInstructionTemplate(instructionFile: string, settingsDir: string
114
145
  }
115
146
 
116
147
  function buildIntercomBridgeInstruction(orchestratorTarget: string, template: string): string {
117
- const instruction = template.replaceAll("{orchestratorTarget}", orchestratorTarget).trim();
148
+ const instruction = template.replaceAll(ORCHESTRATOR_TARGET_PLACEHOLDER, orchestratorTarget).trim();
118
149
  if (instruction.startsWith(INTERCOM_BRIDGE_MARKER)) return instruction;
119
150
  return `${INTERCOM_BRIDGE_MARKER}\n${instruction}`;
120
151
  }
@@ -149,24 +180,34 @@ export function resolveIntercomBridge(input: ResolveIntercomBridgeInput): Interc
149
180
  const orchestratorTarget = input.orchestratorTarget?.trim();
150
181
  const agentDir = path.resolve(input.agentDir ?? defaultAgentDir());
151
182
  const settingsDir = path.resolve(input.settingsDir ?? defaultSubagentConfigDir(agentDir));
152
- const defaultInstruction = buildIntercomBridgeInstruction(
153
- orchestratorTarget || "{orchestratorTarget}",
154
- DEFAULT_INTERCOM_BRIDGE_TEMPLATE,
155
- );
156
183
  const reason = inactiveReason(mode, input.context, orchestratorTarget);
157
184
  if (reason || !orchestratorTarget) {
158
- return { active: false, mode, resultDelivery: config.resultDelivery, extensionDir: NATIVE_INTERCOM_EXTENSION_DIR, instruction: defaultInstruction };
185
+ return {
186
+ active: false,
187
+ mode,
188
+ resultDelivery: config.resultDelivery,
189
+ extensionDir: NATIVE_INTERCOM_EXTENSION_DIR,
190
+ instruction: buildIntercomBridgeInstruction(ORCHESTRATOR_TARGET_PLACEHOLDER, DEFAULT_INTERCOM_BRIDGE_TEMPLATE),
191
+ interpolatesOrchestratorTarget: false,
192
+ };
159
193
  }
194
+ const template = resolveInstructionTemplate(config.instructionFile, settingsDir);
160
195
  return {
161
196
  active: true,
162
197
  mode,
163
198
  resultDelivery: config.resultDelivery,
164
199
  orchestratorTarget,
165
200
  extensionDir: NATIVE_INTERCOM_EXTENSION_DIR,
166
- instruction: buildIntercomBridgeInstruction(orchestratorTarget, resolveInstructionTemplate(config.instructionFile, settingsDir)),
201
+ instruction: buildIntercomBridgeInstruction(orchestratorTarget, template),
202
+ interpolatesOrchestratorTarget: template.includes(ORCHESTRATOR_TARGET_PLACEHOLDER),
167
203
  };
168
204
  }
169
205
 
206
+ /**
207
+ * Rewrites the launch prompt and tools for an active bridge. The parsed
208
+ * definition digest is captured first so launch identity keeps describing the
209
+ * agent file rather than this runtime overlay.
210
+ */
170
211
  export function applyIntercomBridgeToAgent(agent: AgentConfig, bridge: IntercomBridgeState): AgentConfig {
171
212
  if (!bridge.active || !bridge.orchestratorTarget) return agent;
172
213
 
@@ -185,6 +226,7 @@ export function applyIntercomBridgeToAgent(agent: AgentConfig, bridge: IntercomB
185
226
  if (tools === agent.tools && systemPrompt === agent.systemPrompt) return agent;
186
227
  return {
187
228
  ...agent,
229
+ definitionDigest: agentDefinitionDigest(agent),
188
230
  tools,
189
231
  systemPrompt,
190
232
  };
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
5
- import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
5
+ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
6
6
  import { Type } from "typebox";
7
7
  import type { ChildSupervisorMetadata } from "../runs/shared/child-runtime-config.ts";
8
8
  import { INTERCOM_DETACH_REQUEST_EVENT, POLL_INTERVAL_MS, TEMP_ROOT_DIR, type ControlEvent, type IntercomEventBus, type SubagentState } from "../shared/types.ts";
@@ -74,7 +74,7 @@ interface ContactSupervisorParams {
74
74
  }
75
75
 
76
76
  interface IntercomParams {
77
- action: "list" | "send" | "ask" | "reply" | "pending" | "status";
77
+ action: "list" | "pending" | "status" | "reply";
78
78
  to?: string;
79
79
  message?: string;
80
80
  replyTo?: string;
@@ -83,6 +83,10 @@ interface IntercomParams {
83
83
  type SupervisorWatch = (filename: fs.PathLike, listener: fs.WatchListener<string>) => fs.FSWatcher;
84
84
 
85
85
  interface NativeSupervisorChannelDeps {
86
+ /** Owned live/final-drain mailboxes. Only a completed poll retires the snapshot, never a demand probe. */
87
+ getChannelDirs?: () => { dirs: string[]; retire?: () => void };
88
+ /** Retained scheduled states for the current runtime owner, never foreign owners. */
89
+ getCurrentOwnerStates?: () => Iterable<SubagentState>;
86
90
  platform?: NodeJS.Platform;
87
91
  watch?: SupervisorWatch;
88
92
  timers?: Pick<typeof globalThis, "setInterval" | "clearInterval" | "setImmediate" | "clearImmediate">;
@@ -95,7 +99,7 @@ const ContactSupervisorParamsSchema = Type.Object({
95
99
  }, { additionalProperties: false });
96
100
 
97
101
  const IntercomParamsSchema = Type.Object({
98
- action: Type.String({ enum: ["list", "send", "ask", "reply", "pending", "status"] }),
102
+ action: Type.String({ enum: ["list", "pending", "status", "reply"] }),
99
103
  to: Type.Optional(Type.String()),
100
104
  message: Type.Optional(Type.String()),
101
105
  replyTo: Type.Optional(Type.String()),
@@ -284,18 +288,18 @@ function parseRequestFile(file: string, channelDir: string): PendingSupervisorRe
284
288
  }
285
289
  }
286
290
 
287
- function listRequestFiles(): Array<{ channelDir: string; file: string }> {
288
- let channelEntries: fs.Dirent[];
289
- try {
290
- channelEntries = fs.readdirSync(SUPERVISOR_CHANNEL_ROOT, { withFileTypes: true });
291
- } catch (error) {
292
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
293
- throw error;
291
+ function listRequestFiles(channelDirs?: string[]): Array<{ channelDir: string; file: string }> {
292
+ if (!channelDirs) {
293
+ try {
294
+ channelDirs = fs.readdirSync(SUPERVISOR_CHANNEL_ROOT, { withFileTypes: true })
295
+ .filter(entry => entry.isDirectory()).map(entry => path.join(SUPERVISOR_CHANNEL_ROOT, entry.name));
296
+ } catch (error) {
297
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
298
+ throw error;
299
+ }
294
300
  }
295
301
  const files: Array<{ channelDir: string; file: string }> = [];
296
- for (const entry of channelEntries) {
297
- if (!entry.isDirectory()) continue;
298
- const channelDir = path.join(SUPERVISOR_CHANNEL_ROOT, entry.name);
302
+ for (const channelDir of channelDirs) {
299
303
  const requestsDir = path.join(channelDir, REQUESTS_DIR);
300
304
  let requestEntries: fs.Dirent[];
301
305
  try {
@@ -381,18 +385,8 @@ function cleanupStaleEmptySupervisorChannels(nowMs = Date.now()): number {
381
385
  return removed;
382
386
  }
383
387
 
384
- function currentContextSessionId(state: Pick<SubagentState, "currentSessionId">, ctx: ExtensionContext): string | undefined {
385
- try {
386
- const sessionId = ctx.sessionManager.getSessionId();
387
- if (sessionId) return sessionId;
388
- } catch {
389
- // Fall through to the last known identity.
390
- }
391
- return state.currentSessionId ?? undefined;
392
- }
393
-
394
- function requestMatchesContext(request: SupervisorRequest, state: Pick<SubagentState, "currentSessionId">, ctx: ExtensionContext): boolean {
395
- const currentSessionId = currentContextSessionId(state, ctx);
388
+ function requestMatchesOwner(request: SupervisorRequest, state: Pick<SubagentState, "supervisorOwnerSessionId">): boolean {
389
+ const currentSessionId = state.supervisorOwnerSessionId;
396
390
  return Boolean(currentSessionId && request.orchestratorSessionId === currentSessionId);
397
391
  }
398
392
 
@@ -462,12 +456,12 @@ function requestRunInactive(request: SupervisorRequest, state: SubagentState): b
462
456
  return stepStatus === "complete" || stepStatus === "completed" || stepStatus === "failed" || stepStatus === "paused";
463
457
  }
464
458
 
465
- function requestLifecycle(request: PendingSupervisorRequest, state: SubagentState, ctx: ExtensionContext | undefined, now: number): SupervisorRequestLifecycle {
466
- if (ctx && !requestMatchesContext(request, state, ctx)) return "wrong-session";
459
+ function requestLifecycle(request: PendingSupervisorRequest, state: SubagentState, now: number, runState: SubagentState): SupervisorRequestLifecycle {
460
+ if (!requestMatchesOwner(request, state)) return "wrong-session";
467
461
  if (!fs.existsSync(request.requestFile)) return "missing";
468
462
  if (request.expectsReply && fs.existsSync(replyPath(request.channelDir, request.id))) return "resolved";
469
463
  if (request.expectsReply && now > requestExpiresAt(request, now)) return "expired";
470
- if (request.expectsReply && requestRunInactive(request, state)) return "inactive";
464
+ if (request.expectsReply && requestRunInactive(request, runState)) return "inactive";
471
465
  return "pending";
472
466
  }
473
467
 
@@ -475,10 +469,10 @@ function cleanupRequestLifecycle(request: PendingSupervisorRequest, lifecycle: S
475
469
  if (lifecycle === "resolved" || lifecycle === "expired" || lifecycle === "inactive") removeRequestFile(request.requestFile);
476
470
  }
477
471
 
478
- function refreshPendingRequests(pending: Map<string, PendingSupervisorRequest>, state: SubagentState, ctx: ExtensionContext | undefined, onLifecycle: SupervisorRequestLifecycleObserver): void {
472
+ function refreshPendingRequests(pending: Map<string, PendingSupervisorRequest>, state: SubagentState, onLifecycle: SupervisorRequestLifecycleObserver, runState: (request: SupervisorRequest) => SubagentState): void {
479
473
  const now = Date.now();
480
474
  for (const request of pending.values()) {
481
- const lifecycle = requestLifecycle(request, state, ctx, now);
475
+ const lifecycle = requestLifecycle(request, state, now, runState(request));
482
476
  if (lifecycle === "pending") continue;
483
477
  pending.delete(request.id);
484
478
  onLifecycle(request, lifecycle);
@@ -498,7 +492,6 @@ function requestVisibleText(request: PendingSupervisorRequest): string {
498
492
  `Agent: ${request.agent}`,
499
493
  `Child index: ${request.childIndex}`,
500
494
  ];
501
- if (request.childTarget) lines.push(`Child intercom target: ${request.childTarget}`);
502
495
  lines.push("");
503
496
  if (request.message) lines.push(request.message);
504
497
  if (request.reason === "interview_request") {
@@ -509,6 +502,7 @@ function requestVisibleText(request: PendingSupervisorRequest): string {
509
502
  if (request.interview !== undefined) lines.push(JSON.stringify(request.interview, null, "\t"));
510
503
  }
511
504
  if (request.expectsReply) lines.push("", `Reply with: ${supervisorReplyHint(request.id)}`);
505
+ lines.push("", `Live guidance: subagent({ action: "steer", id: ${JSON.stringify(request.runId)}, index: ${request.childIndex}, message: "..." })${request.expectsReply ? " (Reply to the pending request first.)" : ""}`);
512
506
  return lines.join("\n").trimEnd();
513
507
  }
514
508
 
@@ -563,6 +557,7 @@ function resolvePendingRequest(pending: Map<string, PendingSupervisorRequest>, p
563
557
  );
564
558
  if (matches.length === 1) return matches[0]!;
565
559
  if (matches.length > 1) throw new Error(`Multiple pending supervisor requests match '${params.to}'. Use replyTo.`);
560
+ throw new Error(`No pending supervisor request matches '${params.to}'. Use replyTo.`);
566
561
  }
567
562
  if (requests.length === 1) return requests[0]!;
568
563
  if (requests.length === 0) throw new Error("No pending supervisor requests need a reply.");
@@ -580,14 +575,16 @@ function publicPendingRequests(pending: Map<string, PendingSupervisorRequest>):
580
575
  }));
581
576
  }
582
577
 
583
- function buildParentSupervisorTool(pi: ExtensionAPI, pending: Map<string, PendingSupervisorRequest>, state: SubagentState, onLifecycle: SupervisorRequestLifecycleObserver): ToolDefinition<typeof IntercomParamsSchema, Record<string, unknown>> {
578
+ function buildParentSupervisorTool(pi: ExtensionAPI, pending: Map<string, PendingSupervisorRequest>, state: SubagentState, onLifecycle: SupervisorRequestLifecycleObserver, discover: () => void, runState: (request: SupervisorRequest) => SubagentState): ToolDefinition<typeof IntercomParamsSchema, Record<string, unknown>> {
584
579
  return {
585
580
  name: NATIVE_SUPERVISOR_TOOL_NAME,
586
581
  label: "Subagent Supervisor",
587
582
  description: "Native pi-subagents supervisor channel. Use reply/pending/status to answer child subagent requests without overriding pi-intercom.",
588
583
  parameters: IntercomParamsSchema,
589
584
  async execute(_id, params) {
590
- refreshPendingRequests(pending, state, state.lastUiContext ?? undefined, onLifecycle);
585
+ // Discover new request files even when demand-gated polling is idle.
586
+ discover();
587
+ refreshPendingRequests(pending, state, onLifecycle, runState);
591
588
  const input = params as IntercomParams;
592
589
  if (input.action === "status") {
593
590
  return { content: [{ type: "text", text: `Native supervisor channel active. Pending replies: ${pending.size}.` }], details: { active: true, pending: pending.size, root: SUPERVISOR_CHANNEL_ROOT } };
@@ -605,23 +602,28 @@ function buildParentSupervisorTool(pi: ExtensionAPI, pending: Map<string, Pendin
605
602
  clearForegroundSupervisorAttention(request, pending, state);
606
603
  return { content: [{ type: "text", text: `Replied to supervisor request ${request.id}.` }], details: { replyTo: request.id, runId: request.runId, agent: request.agent } };
607
604
  }
608
- if (input.action === "send" || input.action === "ask") {
609
- throw new Error("The native subagent supervisor handles replies only. Child agents initiate asks with contact_supervisor.");
610
- }
611
605
  throw new Error(`Unsupported supervisor action: ${input.action}`);
612
606
  },
613
607
  };
614
608
  }
615
609
 
616
610
  export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentState, deps: NativeSupervisorChannelDeps = {}): {
611
+ registerTools: () => void;
617
612
  start: () => void;
618
613
  activateTransport: () => void;
614
+ findPendingAsks: (target: { runId: string; agent: string; childIndex: number }) => string[];
619
615
  dispose: () => void;
620
616
  pending: Map<string, PendingSupervisorRequest>;
621
617
  getSupervisorRequestState: (event: ControlEvent) => SupervisorRequestState;
622
618
  } {
623
619
  const watch = deps.watch ?? fs.watch;
624
620
  const timers = deps.timers ?? globalThis;
621
+ const runState = (request: SupervisorRequest): SubagentState => {
622
+ for (const ownerState of deps.getCurrentOwnerStates?.() ?? []) {
623
+ if (ownerState.asyncJobs.has(request.runId)) return ownerState;
624
+ }
625
+ return state;
626
+ };
625
627
  const pending = new Map<string, PendingSupervisorRequest>();
626
628
  const requestCorrelations = new Map<string, SupervisorRequestCorrelation>();
627
629
  const correlationKey = (request: { runId: string; agent: string; childIndex: number; toolCallId?: string }): string | undefined => {
@@ -667,7 +669,7 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
667
669
  if (!fs.existsSync(correlation.request.requestFile)
668
670
  || fs.existsSync(replyPath(correlation.request.channelDir, correlation.request.id))
669
671
  || now > requestExpiresAt(correlation.request, now)
670
- || requestRunInactive(correlation.request, state)) {
672
+ || requestRunInactive(correlation.request, runState(correlation.request))) {
671
673
  rememberResolvedRequest(correlation.request);
672
674
  return "resolved";
673
675
  }
@@ -682,18 +684,26 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
682
684
  let started = false;
683
685
  let lastStaleCleanupAt = 0;
684
686
  const platform = deps.platform ?? process.platform;
685
- const useNativeWatcher = () => shouldUseNativeFsWatch("supervisor-channel", platform) && platform !== "win32";
687
+ const useNativeWatcher = () => !deps.getChannelDirs && shouldUseNativeFsWatch("supervisor-channel", platform) && platform !== "win32";
686
688
  const hasTransportDemand = () => {
687
689
  if (pending.size > 0) return true;
688
690
  if (state.foregroundControls.size > 0) return true;
689
- return [...state.asyncJobs.values()].some((job) => job.status === "queued" || job.status === "running");
691
+ if (deps.getChannelDirs?.().dirs.length) return true;
692
+ if ([...state.asyncJobs.values()].some((job) => job.status === "queued" || job.status === "running")) return true;
693
+ for (const ownerState of deps.getCurrentOwnerStates?.() ?? []) {
694
+ for (const job of ownerState.asyncJobs.values()) {
695
+ if (job.status === "queued" || job.status === "running") return true;
696
+ }
697
+ }
698
+ return false;
690
699
  };
691
700
 
692
701
  const registerParentTools = (): void => {
693
- if (!hasTool(pi, NATIVE_SUPERVISOR_TOOL_NAME)) pi.registerTool(buildParentSupervisorTool(pi, pending, state, observeRequestLifecycle));
702
+ if (!hasTool(pi, NATIVE_SUPERVISOR_TOOL_NAME)) pi.registerTool(buildParentSupervisorTool(pi, pending, state, observeRequestLifecycle, () => poll(), runState));
694
703
  };
695
704
 
696
705
  const cleanupStaleChannelsIfDue = (): void => {
706
+ if (deps.getChannelDirs) return; // The root owns global retention cleanup.
697
707
  const nowMs = Date.now();
698
708
  if (nowMs - lastStaleCleanupAt < STALE_EMPTY_CHANNEL_CLEANUP_INTERVAL_MS) return;
699
709
  lastStaleCleanupAt = nowMs;
@@ -706,15 +716,15 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
706
716
 
707
717
  const poll = (): void => {
708
718
  cleanupStaleChannelsIfDue();
709
- const ctx = state.lastUiContext;
710
- if (!ctx) return;
711
- refreshPendingRequests(pending, state, ctx, observeRequestLifecycle);
719
+ // Only display notifications require a live UI context, not request registration.
720
+ refreshPendingRequests(pending, state, observeRequestLifecycle, runState);
712
721
  const now = Date.now();
713
- for (const { channelDir, file } of listRequestFiles()) {
722
+ const channels = deps.getChannelDirs?.();
723
+ for (const { channelDir, file } of listRequestFiles(channels?.dirs)) {
714
724
  if (seenFiles.has(file)) continue;
715
725
  const request = parseRequestFile(file, channelDir);
716
- if (!request || !requestMatchesContext(request, state, ctx)) continue;
717
- const lifecycle = requestLifecycle(request, state, undefined, now);
726
+ if (!request || !requestMatchesOwner(request, state)) continue;
727
+ const lifecycle = requestLifecycle(request, state, now, runState(request));
718
728
  if (lifecycle !== "pending") {
719
729
  seenFiles.add(file);
720
730
  observeRequestLifecycle(request, lifecycle);
@@ -727,27 +737,34 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
727
737
  pending.set(request.id, request);
728
738
  markForegroundSupervisorAttention(request, state);
729
739
  }
730
- else {
731
- removeRequestFile(request.requestFile);
740
+ // The ask is already queued above. A sendMessage failure (no UI, stale context) must not
741
+ // lose it, and must not abort the loop before the remaining asks register.
742
+ try {
743
+ pi.sendMessage({
744
+ customType: SUPERVISOR_REQUEST_MESSAGE_TYPE,
745
+ content: requestVisibleText(request),
746
+ display: true,
747
+ details: {
748
+ id: request.id,
749
+ requestId: request.id,
750
+ reason: request.reason,
751
+ expectsReply: request.expectsReply,
752
+ runId: request.runId,
753
+ agent: request.agent,
754
+ childIndex: request.childIndex,
755
+ ...(request.childTarget ? { childTarget: request.childTarget } : {}),
756
+ ...(request.interview !== undefined ? { interview: request.interview } : {}),
757
+ requestBody: request.message,
758
+ ...(request.expectsReply ? { replyHint: supervisorReplyHint(request.id) } : {}),
759
+ },
760
+ }, { triggerTurn: true });
761
+ // sendMessage accepts synchronously; one-way updates stay on disk until it returns.
762
+ if (!request.expectsReply) removeRequestFile(request.requestFile);
763
+ } catch (error) {
764
+ // Allow an existing later scan to retry an unaccepted one-way update.
765
+ if (!request.expectsReply) seenFiles.delete(file);
766
+ console.error(`Failed to surface supervisor request ${request.id} as a user turn:`, error);
732
767
  }
733
- pi.sendMessage({
734
- customType: SUPERVISOR_REQUEST_MESSAGE_TYPE,
735
- content: requestVisibleText(request),
736
- display: true,
737
- details: {
738
- id: request.id,
739
- requestId: request.id,
740
- reason: request.reason,
741
- expectsReply: request.expectsReply,
742
- runId: request.runId,
743
- agent: request.agent,
744
- childIndex: request.childIndex,
745
- ...(request.childTarget ? { childTarget: request.childTarget } : {}),
746
- ...(request.interview !== undefined ? { interview: request.interview } : {}),
747
- requestBody: request.message,
748
- ...(request.expectsReply ? { replyHint: supervisorReplyHint(request.id) } : {}),
749
- },
750
- }, { triggerTurn: true });
751
768
  if (request.expectsReply) {
752
769
  (pi as { events?: IntercomEventBus }).events?.emit(INTERCOM_DETACH_REQUEST_EVENT, {
753
770
  requestId: request.id,
@@ -758,13 +775,14 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
758
775
  if (pending.has(request.id)) markForegroundSupervisorAttention(request, state);
759
776
  }
760
777
  }
778
+ channels?.retire?.();
761
779
  };
762
780
 
763
781
  const startPolling = (): void => {
764
782
  if (poller) return;
765
783
  poller = timers.setInterval(() => {
766
784
  poll();
767
- if (!useNativeWatcher() && platform === "darwin" && !hasTransportDemand()) {
785
+ if (!useNativeWatcher() && (platform === "darwin" || deps.getChannelDirs) && !hasTransportDemand()) {
768
786
  if (poller) timers.clearInterval(poller);
769
787
  poller = undefined;
770
788
  }
@@ -819,16 +837,35 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
819
837
  };
820
838
 
821
839
  return {
840
+ registerTools: registerParentTools,
822
841
  activateTransport: () => {
823
842
  if (!started) return;
824
843
  poll();
825
844
  if (!useNativeWatcher() && hasTransportDemand()) startPolling();
826
845
  },
846
+ findPendingAsks: (target) => {
847
+ // Receipt-only discovery: no registration, notification, cleanup or reply writes.
848
+ const channelDir = resolveSupervisorChannelDir(target.runId, target.agent, target.childIndex);
849
+ let files: string[];
850
+ try { files = fs.readdirSync(path.join(channelDir, REQUESTS_DIR)); }
851
+ catch (error) {
852
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
853
+ throw error;
854
+ }
855
+ const now = Date.now();
856
+ return files.filter(file => file.endsWith(".json")).flatMap(file => {
857
+ const request = parseRequestFile(path.join(channelDir, REQUESTS_DIR, file), channelDir);
858
+ return request?.expectsReply && request.runId === target.runId
859
+ && request.agent === target.agent && request.childIndex === target.childIndex
860
+ && requestLifecycle(request, state, now, runState(request)) === "pending" ? [request.id] : [];
861
+ }).sort();
862
+ },
827
863
  start: () => {
828
864
  if (started) return;
829
865
  started = true;
830
866
  registerParentTools();
831
867
  poll();
868
+ if (deps.getChannelDirs) return; // Child polling starts only after a descendant launch.
832
869
  try {
833
870
  fs.mkdirSync(SUPERVISOR_CHANNEL_ROOT, { recursive: true });
834
871
  if (!useNativeWatcher()) {