pi-subagents 0.63.0 → 0.65.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 (107) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/README.md +2 -2
  3. package/agents/reviewer.md +1 -1
  4. package/agents/scout.md +1 -1
  5. package/docs/agents.md +18 -16
  6. package/docs/configuration.md +7 -15
  7. package/docs/extension-api.md +15 -6
  8. package/docs/missions.md +2 -0
  9. package/docs/observability.md +10 -12
  10. package/docs/tool-reference.md +7 -6
  11. package/docs/watchdog.md +92 -114
  12. package/docs/workflows.md +5 -3
  13. package/package.json +3 -4
  14. package/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
  15. package/skills/pi-subagents/references/execution-controls.md +6 -5
  16. package/src/agents/agent-management.ts +47 -15
  17. package/src/api/capability-ceiling.ts +0 -1
  18. package/src/api/{pi-args.ts → child-tool-plan.ts} +1 -1
  19. package/src/api/preflight.ts +2 -3
  20. package/src/extension/doctor.ts +2 -10
  21. package/src/extension/fanout-child.ts +9 -11
  22. package/src/extension/index.ts +27 -5
  23. package/src/extension/public-execution.ts +14 -0
  24. package/src/extension/rpc.ts +3 -2
  25. package/src/extension/schemas.ts +2 -1
  26. package/src/extension/tool-description.ts +9 -8
  27. package/src/intercom/native-supervisor-channel.ts +138 -60
  28. package/src/intercom/supervisor-ui.ts +243 -0
  29. package/src/runs/background/async-execution.ts +46 -24
  30. package/src/runs/background/async-job-tracker.ts +11 -0
  31. package/src/runs/background/async-resume.ts +11 -2
  32. package/src/runs/background/control-channel.ts +2 -204
  33. package/src/runs/background/notify.ts +41 -2
  34. package/src/runs/background/process-terminal.ts +1 -1
  35. package/src/runs/background/run-child-session.ts +613 -0
  36. package/src/runs/background/run-status.ts +0 -1
  37. package/src/runs/background/runner-aliases.ts +125 -0
  38. package/src/runs/background/runner-child-sessions.ts +31 -0
  39. package/src/runs/background/scheduled-runs.ts +18 -4
  40. package/src/runs/background/subagent-runner.ts +203 -898
  41. package/src/runs/foreground/async-steering-action.ts +1 -17
  42. package/src/runs/foreground/execution.ts +196 -378
  43. package/src/runs/foreground/foreground-control.ts +4 -0
  44. package/src/runs/foreground/subagent-executor.ts +121 -58
  45. package/src/runs/foreground/workflow-foreground-steering.ts +24 -98
  46. package/src/runs/shared/abort-recovery.ts +3 -3
  47. package/src/runs/shared/acceptance.ts +10 -0
  48. package/src/runs/shared/async-status-projection.ts +11 -43
  49. package/src/runs/shared/capability-ceiling.ts +1 -2
  50. package/src/runs/shared/child-hooks.ts +25 -0
  51. package/src/runs/shared/child-identity.ts +13 -2
  52. package/src/runs/shared/child-launch.ts +314 -0
  53. package/src/runs/shared/child-lifecycle.ts +25 -0
  54. package/src/runs/shared/child-runtime-config.ts +126 -0
  55. package/src/runs/shared/child-session.ts +342 -0
  56. package/src/runs/shared/child-tool-plan.ts +530 -0
  57. package/src/runs/shared/claude-code-adapter.ts +5 -1
  58. package/src/runs/shared/completion-guard.ts +1 -1
  59. package/src/runs/shared/external-cli-preflight.ts +16 -0
  60. package/src/runs/shared/mcp-direct-tool-allowlist.ts +5 -4
  61. package/src/runs/shared/model-exclusions.ts +82 -14
  62. package/src/runs/shared/model-fallback.ts +47 -4
  63. package/src/runs/shared/nested-events.ts +29 -45
  64. package/src/runs/shared/nested-path.ts +0 -14
  65. package/src/runs/shared/orca-progress-tabs.ts +12 -7
  66. package/src/runs/shared/parallel-utils.ts +0 -2
  67. package/src/runs/shared/permissions.ts +0 -13
  68. package/src/runs/shared/process-signal.ts +4 -1
  69. package/src/runs/shared/run-fanout-budget.ts +0 -13
  70. package/src/runs/shared/runtime-acknowledged-extensions.ts +0 -27
  71. package/src/runs/shared/structured-output.ts +17 -4
  72. package/src/runs/shared/subagent-control.ts +6 -2
  73. package/src/runs/shared/subagent-prompt-runtime.ts +87 -384
  74. package/src/runs/shared/tool-availability.ts +18 -62
  75. package/src/runs/shared/tool-budget.ts +0 -14
  76. package/src/runs/shared/worktree-cleanup-plan.ts +25 -6
  77. package/src/runs/shared/worktree.ts +117 -30
  78. package/src/shared/child-session-name.ts +1 -1
  79. package/src/shared/jsonl-writer.ts +11 -0
  80. package/src/shared/thinking-ceiling.ts +0 -6
  81. package/src/shared/types.ts +64 -30
  82. package/src/shared/utils.ts +3 -4
  83. package/src/slash/slash-commands.ts +0 -6
  84. package/src/tui/fleet.ts +0 -1
  85. package/src/tui/render.ts +238 -31
  86. package/src/watchdog/child-status.ts +54 -34
  87. package/src/watchdog/diff-tool.ts +77 -0
  88. package/src/watchdog/emission-guard.ts +5 -3
  89. package/src/watchdog/guidance.ts +20 -0
  90. package/src/watchdog/register-child.ts +28 -25
  91. package/src/watchdog/register-main.ts +10 -9
  92. package/src/watchdog/render.ts +4 -5
  93. package/src/watchdog/review.ts +15 -4
  94. package/src/watchdog/rules.ts +70 -0
  95. package/src/watchdog/runtime.ts +75 -92
  96. package/src/watchdog/scope.ts +0 -11
  97. package/src/watchdog/settings.ts +48 -104
  98. package/src/watchdog/types.ts +18 -32
  99. package/src/watchdog/warning-format.ts +0 -1
  100. package/src/workflows/chat-progress.ts +3 -2
  101. package/src/workflows/scripted-workflow.ts +48 -1
  102. package/src/workflows/workflow-checklist.ts +10 -12
  103. package/src/workflows/workflow-preflight.ts +28 -1
  104. package/src/runs/shared/child-protocol.ts +0 -415
  105. package/src/runs/shared/pi-args.ts +0 -1062
  106. package/src/runs/shared/subagent-startup-retry.ts +0 -116
  107. package/src/shared/post-exit-stdio-guard.ts +0 -85
@@ -0,0 +1,530 @@
1
+ /**
2
+ * Child tool plan: which builtin tools, MCP tools, runtime hooks, and extension
3
+ * files a child launch gets, and how that resolution is reported. Both the
4
+ * foreground path and the async runner build their session launch from this.
5
+ */
6
+ import { createHash } from "node:crypto";
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import {
11
+ formatUnresolvedMcpDirectToolSelectors,
12
+ resolveMcpDirectToolResolution,
13
+ type McpRuntimeSnapshotHost,
14
+ type ResolvedMcpDirectToolSelection,
15
+ } from "./mcp-direct-tool-allowlist.ts";
16
+ import {
17
+ TEMP_ROOT_DIR,
18
+ type JsonSchemaObject,
19
+ type LaunchResolvedChildExtensionsV1,
20
+ } from "../../shared/types.ts";
21
+ import { THINKING_LEVELS } from "../../shared/model-info.ts";
22
+ import { getAgentDir } from "../../shared/utils.ts";
23
+ import type { PermissionRules } from "./permissions.ts";
24
+ import {
25
+ capabilityCeilingAgentRestrictionSources,
26
+ intersectSubagentCapabilityCeilings,
27
+ isAgentAllowedByCapabilityCeiling,
28
+ type ResolvedSubagentCapabilityCeiling,
29
+ type SubagentCapabilityAudit,
30
+ } from "./capability-ceiling.ts";
31
+
32
+ const MAX_LAUNCH_RESOLVED_EXTENSION_IDS = 32;
33
+ const PROMPT_RUNTIME_EXTENSION_PATH = path.join(
34
+ path.dirname(fileURLToPath(import.meta.url)),
35
+ "subagent-prompt-runtime.ts",
36
+ );
37
+ const FANOUT_CHILD_EXTENSION_PATH = path.join(
38
+ path.dirname(fileURLToPath(import.meta.url)),
39
+ "..",
40
+ "..",
41
+ "extension",
42
+ "fanout-child.ts",
43
+ );
44
+ const FAST_MODE_EXTENSION_PATH = path.join(
45
+ path.dirname(fileURLToPath(import.meta.url)),
46
+ "fast-mode-extension.ts",
47
+ );
48
+ const SUBAGENT_RUNTIME_EXTENSION_PATHS = new Set([
49
+ PROMPT_RUNTIME_EXTENSION_PATH,
50
+ FANOUT_CHILD_EXTENSION_PATH,
51
+ FAST_MODE_EXTENSION_PATH,
52
+ ].map((extensionPath) => path.normalize(extensionPath)));
53
+
54
+ /** True for the extension files pi-subagents itself installs in child sessions. */
55
+ export function isSubagentRuntimeExtensionPath(extensionPath: string): boolean {
56
+ return SUBAGENT_RUNTIME_EXTENSION_PATHS.has(path.normalize(extensionPath));
57
+ }
58
+ const FAST_MODE_ALLOWED_MODELS = new Set([
59
+ "openai-codex/gpt-5.6-luna",
60
+ "openai-codex/gpt-5.6-sol",
61
+ ]);
62
+ const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64;
63
+
64
+ export function deriveForkPromptCacheKey(parentSessionId: string | undefined): string | undefined {
65
+ const parent = parentSessionId?.trim();
66
+ if (!parent) return undefined;
67
+ const digest = createHash("sha256").update(parent).digest("hex").slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH - "pi-fork:".length);
68
+ return `pi-fork:${digest}`;
69
+ }
70
+
71
+ function sanitizeSupervisorChannelSegment(value: string): string {
72
+ return (
73
+ value
74
+ .trim()
75
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
76
+ .replace(/^-+|-+$/g, "") || "unknown"
77
+ );
78
+ }
79
+
80
+ export function supervisorChannelDir(
81
+ runId: string,
82
+ agent: string,
83
+ childIndex: number,
84
+ ): string {
85
+ return path.join(
86
+ TEMP_ROOT_DIR,
87
+ "supervisor-channels",
88
+ `${sanitizeSupervisorChannelSegment(runId)}-${sanitizeSupervisorChannelSegment(agent)}-${childIndex}`,
89
+ );
90
+ }
91
+
92
+ export function applyThinkingSuffix(
93
+ model: string | undefined,
94
+ thinking: string | false | undefined,
95
+ replaceExisting = false,
96
+ ): string | undefined {
97
+ if (!model || !thinking) return model;
98
+ const colonIdx = model.lastIndexOf(":");
99
+ if (
100
+ colonIdx !== -1 &&
101
+ THINKING_LEVELS.some((level) => level === model.substring(colonIdx + 1))
102
+ ) {
103
+ return replaceExisting ? `${model.slice(0, colonIdx)}:${thinking}` : model;
104
+ }
105
+ return `${model}:${thinking}`;
106
+ }
107
+
108
+ function stripThinkingSuffix(model: string): string {
109
+ const colonIdx = model.lastIndexOf(":");
110
+ if (colonIdx === -1) return model;
111
+ return THINKING_LEVELS.some((level) => level === model.substring(colonIdx + 1))
112
+ ? model.slice(0, colonIdx)
113
+ : model;
114
+ }
115
+
116
+ function resolveFastModeExtension(input: Pick<ResolvePiLaunchToolPlanInput, "fast" | "model" | "modelCandidates" | "agentName">): string[] {
117
+ if (!input.fast) return [];
118
+ const candidates = (input.modelCandidates?.length ? input.modelCandidates : input.model ? [input.model] : [])
119
+ .map(stripThinkingSuffix);
120
+ if (candidates.length === 0) {
121
+ throw new Error(`fast mode requires an explicit supported native OpenAI-Codex model${input.agentName ? ` for agent '${input.agentName}'` : ""}.`);
122
+ }
123
+ const unsupported = candidates.filter((model) => !FAST_MODE_ALLOWED_MODELS.has(model));
124
+ if (unsupported.length > 0) {
125
+ throw new Error(`fast mode supports only ${[...FAST_MODE_ALLOWED_MODELS].join(", ")}; unsupported model${unsupported.length === 1 ? "" : "s"}: ${unsupported.join(", ")}.`);
126
+ }
127
+ return [FAST_MODE_EXTENSION_PATH];
128
+ }
129
+
130
+ export interface ResolvePiLaunchToolPlanInput {
131
+ tools?: string[];
132
+ excludeTools?: string[];
133
+ allowNestedSubagents?: boolean;
134
+ extensions?: string[];
135
+ subagentOnlyExtensions?: string[];
136
+ mcpDirectTools?: string[];
137
+ cwd?: string;
138
+ requireReadTool?: boolean;
139
+ structuredOutput?:
140
+ | boolean
141
+ | {
142
+ schema: JsonSchemaObject;
143
+ schemaPath: string;
144
+ outputPath: string;
145
+ };
146
+ fast?: boolean;
147
+ model?: string;
148
+ modelCandidates?: readonly string[];
149
+ capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
150
+ inheritedCapabilityCeiling?: ResolvedSubagentCapabilityCeiling;
151
+ agentName?: string;
152
+ permissionRules?: PermissionRules;
153
+ runtimeSnapshotHost?: McpRuntimeSnapshotHost;
154
+ }
155
+
156
+ export interface PiLaunchToolPlan {
157
+ capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
158
+ requestedBuiltinTools: string[];
159
+ declaredBuiltinTools: string[];
160
+ excludeTools: string[];
161
+ toolExtensionPaths: string[];
162
+ resolvedMcpSelections: ResolvedMcpDirectToolSelection[];
163
+ effectiveMcpSelections: ResolvedMcpDirectToolSelection[];
164
+ effectiveMcpTools: string[];
165
+ explicitToolAllowlist: boolean;
166
+ internalTools: string[];
167
+ effectiveToolAllowlist: string[];
168
+ requiredChildTools: string[];
169
+ fanoutAuthorized: boolean;
170
+ runtimeExtensions: string[];
171
+ configuredExtensions: string[];
172
+ extensionArgs: string[];
173
+ disableAmbientExtensions: boolean;
174
+ capabilityAudit?: SubagentCapabilityAudit;
175
+ /** Non-fatal launch warnings; they do not change behavior. */
176
+ warnings: string[];
177
+ }
178
+
179
+ function extensionIdentifier(value: string): string {
180
+ return `sha256:${createHash("sha256").update(path.normalize(value.trim())).digest("hex").slice(0, 16)}`;
181
+ }
182
+
183
+ function boundedExtensionIdentifiers(values: string[]): {
184
+ ids: string[];
185
+ omitted: number;
186
+ } {
187
+ const ids = [...new Set(values.map(extensionIdentifier))];
188
+ return {
189
+ ids: ids.slice(0, MAX_LAUNCH_RESOLVED_EXTENSION_IDS),
190
+ omitted: Math.max(0, ids.length - MAX_LAUNCH_RESOLVED_EXTENSION_IDS),
191
+ };
192
+ }
193
+
194
+ function hasPermissionRules(rules: PermissionRules | undefined): boolean {
195
+ return rules !== undefined && Object.keys(rules).length > 0;
196
+ }
197
+
198
+ /**
199
+ * Children are pi sessions inside the parent or the runner process; a spawned
200
+ * `pi` received extra MCP server definitions as a CLI argument, but a session
201
+ * has no such input. Selecting a server that exists only in pi-mcp-adapter's
202
+ * runtime snapshot therefore cannot work and fails the launch.
203
+ */
204
+ export function formatRuntimeSnapshotMcpServersError(agentName: string | undefined, serverNames: readonly string[]): string {
205
+ const subject = agentName ? `Agent '${agentName}'` : "Subagent";
206
+ return `${subject} selects MCP tools from servers that exist only in pi-mcp-adapter's runtime snapshot (${serverNames.join(", ")}). MCP servers from the runtime snapshot cannot be provided to in-process children; MCP tools must come from an ambient adapter extension in a background child (\`async: true\`), so add the server to the adapter's configuration file instead.`;
207
+ }
208
+
209
+ export function projectLaunchResolvedChildExtensions(
210
+ toolPlan: Pick<
211
+ PiLaunchToolPlan,
212
+ | "runtimeExtensions"
213
+ | "configuredExtensions"
214
+ | "extensionArgs"
215
+ | "disableAmbientExtensions"
216
+ >,
217
+ ): LaunchResolvedChildExtensionsV1 {
218
+ const runtime = boundedExtensionIdentifiers(toolPlan.runtimeExtensions);
219
+ const configured = boundedExtensionIdentifiers(toolPlan.configuredExtensions);
220
+ const effective = boundedExtensionIdentifiers(toolPlan.extensionArgs);
221
+ return {
222
+ version: 1,
223
+ source: "launch-resolved",
224
+ disableAmbientExtensions: toolPlan.disableAmbientExtensions,
225
+ runtime: runtime.ids,
226
+ configured: configured.ids,
227
+ effective: effective.ids,
228
+ omitted: {
229
+ runtime: runtime.omitted,
230
+ configured: configured.omitted,
231
+ effective: effective.omitted,
232
+ },
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Resolve the permission-system extension entry point when installed.
238
+ * Returns the absolute path to the extension's main module, or undefined
239
+ * when the package is not installed. Callers can check `autoInject` config
240
+ * to decide whether to include it in child sessions.
241
+ */
242
+ export function resolvePermissionSystemExtension(): string | undefined {
243
+ const agentDir = getAgentDir();
244
+ const candidates = [
245
+ // npm-scoped package (most common)
246
+ path.join(
247
+ agentDir,
248
+ "npm",
249
+ "node_modules",
250
+ "@gotgenes",
251
+ "pi-permission-system",
252
+ ),
253
+ // direct extension directory (some layouts)
254
+ path.join(agentDir, "extensions", "pi-permission-system"),
255
+ ];
256
+ const errors: Error[] = [];
257
+ for (const extDir of candidates) {
258
+ if (!fs.existsSync(extDir)) continue;
259
+ const pkgPath = path.join(extDir, "package.json");
260
+ if (!fs.existsSync(pkgPath)) {
261
+ errors.push(new Error(`Permission-system package manifest is missing at ${pkgPath}.`));
262
+ continue;
263
+ }
264
+ try {
265
+ let pkg: { pi?: { extensions?: string[] } };
266
+ const parsed: unknown = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
267
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
268
+ throw new Error("manifest root must be an object");
269
+ }
270
+ pkg = parsed as typeof pkg;
271
+ const extensions = pkg.pi?.extensions;
272
+ const entry = Array.isArray(extensions) ? extensions[0] : undefined;
273
+ if (typeof entry !== "string" || !entry.trim()) {
274
+ throw new Error(
275
+ `Permission-system package manifest at ${pkgPath} must declare pi.extensions[0] as a non-empty string.`,
276
+ );
277
+ }
278
+ const resolved = path.resolve(extDir, entry);
279
+ if (fs.existsSync(resolved)) return resolved;
280
+ throw new Error(
281
+ `Permission-system extension entry ${JSON.stringify(entry)} in ${pkgPath} does not exist at ${resolved}.`,
282
+ );
283
+ } catch (error) {
284
+ const message = error instanceof Error ? error.message : String(error);
285
+ errors.push(message.startsWith("Permission-system") ? new Error(message) : new Error(`Cannot read permission-system package manifest at ${pkgPath}: ${message}`));
286
+ }
287
+ }
288
+ if (errors.length > 0) throw errors[0]!;
289
+ return undefined;
290
+ }
291
+
292
+ export function resolvePiLaunchToolPlan(
293
+ input: ResolvePiLaunchToolPlanInput,
294
+ ): PiLaunchToolPlan {
295
+ const capabilityCeiling = intersectSubagentCapabilityCeilings(
296
+ input.capabilityCeiling,
297
+ input.inheritedCapabilityCeiling,
298
+ );
299
+ const allowedToolSet =
300
+ capabilityCeiling?.allowedTools === undefined
301
+ ? undefined
302
+ : new Set(capabilityCeiling.allowedTools);
303
+ const requestedBuiltinTools =
304
+ input.tools?.filter(
305
+ (tool) =>
306
+ !(tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js")),
307
+ ) ?? [];
308
+ if (input.requireReadTool && allowedToolSet && !allowedToolSet.has("read")) {
309
+ throw new Error(
310
+ `Capability ceiling from ${capabilityCeiling?.sources.join(", ") || "unknown source"} excludes required tool 'read' for lazy skill loading.`,
311
+ );
312
+ }
313
+ const declaredBuiltinTools =
314
+ input.tools === undefined
315
+ ? allowedToolSet
316
+ ? [...allowedToolSet]
317
+ : []
318
+ : (input.requireReadTool &&
319
+ requestedBuiltinTools.length > 0 &&
320
+ !requestedBuiltinTools.includes("read") &&
321
+ !allowedToolSet
322
+ ? ["read", ...requestedBuiltinTools]
323
+ : requestedBuiltinTools
324
+ ).filter((tool) => !allowedToolSet || allowedToolSet.has(tool));
325
+ const excludeTools = [...new Set((input.excludeTools ?? []).map((tool) => tool.trim()).filter(Boolean))];
326
+ const excludedToolSet = new Set(excludeTools);
327
+ const effectiveDeclaredBuiltinTools = declaredBuiltinTools.filter((tool) => !excludedToolSet.has(tool));
328
+ const fanoutAuthorized = effectiveDeclaredBuiltinTools.includes("subagent") || (
329
+ input.allowNestedSubagents === true &&
330
+ !excludedToolSet.has("subagent") &&
331
+ (!allowedToolSet || allowedToolSet.has("subagent"))
332
+ );
333
+ const toolExtensionPaths: string[] = capabilityCeiling?.denyExtensions
334
+ ? []
335
+ : (input.tools ?? []).filter(
336
+ (tool) =>
337
+ !requestedBuiltinTools.includes(tool) &&
338
+ (tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js")),
339
+ );
340
+ const mcpResolution = capabilityCeiling?.denyExtensions
341
+ ? { selections: [], unresolvedSelectors: [] }
342
+ : resolveMcpDirectToolResolution(input.mcpDirectTools, input.cwd, input.runtimeSnapshotHost);
343
+ if (mcpResolution.runtimeServerNames?.length) {
344
+ throw new Error(formatRuntimeSnapshotMcpServersError(input.agentName, mcpResolution.runtimeServerNames));
345
+ }
346
+ if (mcpResolution.unresolvedSelectors.length > 0) {
347
+ throw new Error(formatUnresolvedMcpDirectToolSelectors(mcpResolution.unresolvedSelectors));
348
+ }
349
+ const resolvedMcpSelections = mcpResolution.selections;
350
+ const resolvedMcpNames = new Set(resolvedMcpSelections.map((selection) => selection.name));
351
+ const legacyMcpNameCounts = countLegacyUnderscoreMcpToolNames(resolvedMcpSelections);
352
+ const effectiveMcpSelections = resolvedMcpSelections.filter(
353
+ (selection) =>
354
+ !allowedToolSet ||
355
+ allowedToolSet.has(selection.name) ||
356
+ isLegacyUnderscoreMcpToolAllowed(selection, allowedToolSet, resolvedMcpNames, legacyMcpNameCounts),
357
+ ).filter((selection) => !excludedToolSet.has(selection.name));
358
+ const effectiveMcpTools = effectiveMcpSelections.map(
359
+ (selection) => selection.name,
360
+ );
361
+ const explicitToolAllowlist =
362
+ input.tools !== undefined ||
363
+ (input.mcpDirectTools?.length ?? 0) > 0 ||
364
+ allowedToolSet !== undefined;
365
+ const internalTools = (input.structuredOutput ? ["structured_output"] : []).filter((tool) => !excludedToolSet.has(tool));
366
+ const effectiveToolAllowlist = [
367
+ ...new Set([
368
+ ...effectiveDeclaredBuiltinTools,
369
+ ...effectiveMcpTools,
370
+ ...internalTools,
371
+ ]),
372
+ ];
373
+ // Supervisor-coordination names stay in the --tools allowlist but are never
374
+ // strict requirements: children register contact_supervisor at runtime through
375
+ // the native supervisor channel (or pi-intercom). The pre-0.50 bridge always
376
+ // appended intercom alongside contact_supervisor, so that exact pairing is
377
+ // legacy plumbing, not a user demand for an external intercom provider;
378
+ // a lone intercom entry stays strictly required (#1207).
379
+ const legacySupervisorPairing = effectiveDeclaredBuiltinTools.includes("contact_supervisor");
380
+ const requiredChildTools = explicitToolAllowlist
381
+ ? [
382
+ ...new Set([
383
+ ...(input.tools !== undefined ? effectiveDeclaredBuiltinTools : []),
384
+ ...(input.mcpDirectTools?.length ? effectiveMcpTools : []),
385
+ ...internalTools,
386
+ ].filter((tool) => tool !== "contact_supervisor" && (!legacySupervisorPairing || tool !== "intercom"))),
387
+ ]
388
+ : [];
389
+ const permSystemExt = capabilityCeiling?.denyExtensions
390
+ ? undefined
391
+ : hasPermissionRules(input.permissionRules)
392
+ ? resolvePermissionSystemExtension()
393
+ : undefined;
394
+ if (input.fast && capabilityCeiling?.denyExtensions) throw new Error("fast mode requires a child runtime extension, but this launch denies extensions.");
395
+ const fastModeExtensions = resolveFastModeExtension({ fast: input.fast, model: input.model, modelCandidates: input.modelCandidates, agentName: input.agentName });
396
+ const runtimeExtensions = [
397
+ PROMPT_RUNTIME_EXTENSION_PATH,
398
+ ...fastModeExtensions,
399
+ ...(fanoutAuthorized ? [FANOUT_CHILD_EXTENSION_PATH] : []),
400
+ ...(permSystemExt ? [permSystemExt] : []),
401
+ ];
402
+ const disableAmbientExtensions =
403
+ capabilityCeiling?.denyExtensions === true ||
404
+ input.extensions !== undefined;
405
+ const warnings: string[] = [];
406
+ // An explicit empty list disables ambient extensions, including model providers.
407
+ if (capabilityCeiling?.denyExtensions !== true && Array.isArray(input.extensions) && input.extensions.length === 0) {
408
+ const agentLabel = input.agentName ? ` for agent '${input.agentName}'` : "";
409
+ warnings.push(
410
+ `extensions: [] override${agentLabel} disables ALL ambient extensions for this child (not just "adds nothing"), `
411
+ + "including any model-provider extension needed to resolve a provider-qualified model. "
412
+ + "List the extensions this child actually needs instead of an empty array.",
413
+ );
414
+ }
415
+ const configuredExtensions = capabilityCeiling?.denyExtensions
416
+ ? []
417
+ : [
418
+ ...toolExtensionPaths,
419
+ ...(input.extensions ?? []),
420
+ ...(input.subagentOnlyExtensions ?? []),
421
+ ];
422
+ const extensionArgs = disableAmbientExtensions
423
+ ? [...new Set([...runtimeExtensions, ...configuredExtensions])]
424
+ : [
425
+ ...new Set([
426
+ ...runtimeExtensions,
427
+ ...toolExtensionPaths,
428
+ ...(input.subagentOnlyExtensions ?? []),
429
+ ]),
430
+ ];
431
+ const requestedToolNames =
432
+ input.tools !== undefined
433
+ ? [
434
+ ...new Set([
435
+ ...requestedBuiltinTools,
436
+ ...resolvedMcpSelections.map((selection) => selection.name),
437
+ ]),
438
+ ]
439
+ : undefined;
440
+ const capabilityAudit = capabilityCeiling
441
+ ? ({
442
+ ceiling: capabilityCeiling,
443
+ ...(requestedToolNames ? { requestedTools: requestedToolNames } : {}),
444
+ effectiveTools: effectiveToolAllowlist,
445
+ ...(excludeTools.length > 0 ? { excludeTools } : {}),
446
+ removedTools:
447
+ requestedToolNames?.filter(
448
+ (tool) => !effectiveToolAllowlist.includes(tool),
449
+ ) ?? [],
450
+ internalTools,
451
+ extensionsDenied: capabilityCeiling.denyExtensions,
452
+ removedExtensionCount: capabilityCeiling.denyExtensions
453
+ ? (input.extensions?.length ?? 0) +
454
+ (input.subagentOnlyExtensions?.length ?? 0) +
455
+ (input.tools ?? []).filter(
456
+ (tool) =>
457
+ tool.includes("/") ||
458
+ tool.endsWith(".ts") ||
459
+ tool.endsWith(".js"),
460
+ ).length
461
+ : 0,
462
+ requestedMcpToolCount: input.mcpDirectTools?.length ?? 0,
463
+ effectiveMcpTools,
464
+ agentAllowed:
465
+ input.agentName === undefined
466
+ ? true
467
+ : isAgentAllowedByCapabilityCeiling(
468
+ input.agentName,
469
+ capabilityCeiling,
470
+ ),
471
+ ...(capabilityCeilingAgentRestrictionSources(capabilityCeiling)
472
+ ? {
473
+ agentRestrictionSources:
474
+ capabilityCeilingAgentRestrictionSources(capabilityCeiling),
475
+ }
476
+ : {}),
477
+ } satisfies SubagentCapabilityAudit)
478
+ : undefined;
479
+ return {
480
+ ...(capabilityCeiling ? { capabilityCeiling } : {}),
481
+ requestedBuiltinTools,
482
+ declaredBuiltinTools,
483
+ excludeTools,
484
+ toolExtensionPaths,
485
+ resolvedMcpSelections,
486
+ effectiveMcpSelections,
487
+ effectiveMcpTools,
488
+ explicitToolAllowlist,
489
+ internalTools,
490
+ effectiveToolAllowlist,
491
+ requiredChildTools,
492
+ fanoutAuthorized,
493
+ runtimeExtensions,
494
+ configuredExtensions,
495
+ extensionArgs,
496
+ disableAmbientExtensions,
497
+ warnings,
498
+ ...(capabilityAudit ? { capabilityAudit } : {}),
499
+ };
500
+ }
501
+
502
+ // Capability ceilings persisted before #1685 may still name hyphenated MCP server prefixes with underscores.
503
+ function countLegacyUnderscoreMcpToolNames(selections: readonly ResolvedMcpDirectToolSelection[]): Map<string, number> {
504
+ const counts = new Map<string, number>();
505
+ for (const selection of selections) {
506
+ const legacyName = legacyUnderscoreMcpToolName(selection);
507
+ if (legacyName !== selection.name) counts.set(legacyName, (counts.get(legacyName) ?? 0) + 1);
508
+ }
509
+ return counts;
510
+ }
511
+
512
+ function isLegacyUnderscoreMcpToolAllowed(
513
+ selection: ResolvedMcpDirectToolSelection,
514
+ allowedToolSet: ReadonlySet<string>,
515
+ resolvedMcpNames: ReadonlySet<string>,
516
+ legacyMcpNameCounts: ReadonlyMap<string, number>,
517
+ ): boolean {
518
+ const legacyName = legacyUnderscoreMcpToolName(selection);
519
+ return legacyMcpNameCounts.get(legacyName) === 1 && !resolvedMcpNames.has(legacyName) && allowedToolSet.has(legacyName);
520
+ }
521
+
522
+ function legacyUnderscoreMcpToolName(selection: ResolvedMcpDirectToolSelection): string {
523
+ const slash = selection.selector.indexOf("/");
524
+ if (slash < 1) return selection.name;
525
+ const toolName = selection.selector.slice(slash + 1);
526
+ const suffix = `_${toolName}`;
527
+ if (!selection.name.endsWith(suffix)) return selection.name;
528
+ const prefix = selection.name.slice(0, -suffix.length);
529
+ return `${prefix.replace(/-/g, "_")}${suffix}`;
530
+ }
@@ -118,7 +118,11 @@ export function resolveClaudeCodeLaunch(input: {
118
118
  versionArgs: [...prefix, "--version"],
119
119
  helpArgs: [...prefix, "--help"],
120
120
  validate(result) {
121
- if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)? \(Claude Code\)$/.test(result.version)) throw new Error(`Unsupported Claude Code version response: ${JSON.stringify(result.version)}.`);
121
+ // Claude Code now publishes both semver (`2.1.259 (Claude Code)`) and
122
+ // calendar/platform (`2026.4.24 macos-arm64 (2026-04-27)`) versions.
123
+ // The launch flags are validated from --help below, so only require a
124
+ // recognizable release identifier here rather than one display format.
125
+ if (!/^(?:\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)? \(Claude Code\)|\d{4}\.\d{1,2}\.\d{1,2} [A-Za-z0-9._-]+ \(\d{4}-\d{2}-\d{2}\))$/.test(result.version)) throw new Error(`Unsupported Claude Code version response: ${JSON.stringify(result.version)}.`);
122
126
  for (const required of ["Claude Code - starts an interactive session", "--print", "--input-format", "stream-json", "--verbose", "--permission-mode", writer ? "acceptEdits" : "plan", "--tools", "--strict-mcp-config", "--mcp-config", "--setting-sources", "--no-session-persistence", "--disable-slash-commands", "--no-chrome"]) {
123
127
  if (!result.help.includes(required)) throw new Error(`Claude Code help does not document required option ${JSON.stringify(required)}.`);
124
128
  }
@@ -24,7 +24,7 @@ const READ_ONLY_BUILTIN_TOOLS = new Set([
24
24
  const CURSOR_FILE_MUTATION_THINKING =
25
25
  /(?:^|\n)\s*Cursor (?:edit|write)\s*:/i;
26
26
 
27
- const REVIVED_TASK_PREFIX = /^You are reviving a previous subagent conversation\.\n\nOriginal run: .+\nOriginal agent: .+(?:\nOriginal session file: .+)?\n\nUse the stored session context as background\. Answer the orchestrator's follow-up below\. Do not assume the original child process is still alive\.\n\nFollow-up:\n/;
27
+ const REVIVED_TASK_PREFIX = /^You are reviving a previous subagent conversation\.\n\nOriginal run: .+\nOriginal agent: .+(?:\nOriginal session file: .+)?\n\nUse the stored session context as background\. Answer the orchestrator's follow-up below\. Do not assume the original child session is still running\.\n\nFollow-up:\n/;
28
28
  const IMPLEMENTATION_CHALLENGE_FOLLOW_UP_PATTERN = /^(?:Run )?implementation challenge pass (?:one|two|\d+)(?:\s+and implement any better current[- ]scope change\.?|\s+for the accepted candidate\.\s+Reconsider it and implement any better current[- ]scope change\.?)?$/i;
29
29
  const NO_BETTER_CHANGE_NEEDED_PATTERN = /^\s*no (?:better|further|additional) (?:current[- ]scope )?(?:code |source |file )?(?:change|changes|edit|edits|patch|patches) (?:is|are) needed\b/i;
30
30
  const KEPT_CURRENT_IMPLEMENTATION_PATTERN = /\b(?:kept (?:the )?current (?:implementation|candidate|shape)|(?:the )?current (?:implementation|candidate|shape) was kept)\b/i;
@@ -5,6 +5,7 @@ import * as path from "node:path";
5
5
  const MAX_PROBE_OUTPUT_BYTES = 256 * 1024;
6
6
  const MAX_PROBE_TIMEOUT_MS = 5_000;
7
7
  const MAX_CACHE_ENTRIES = 64;
8
+ const MAX_AVAILABILITY_REASON_LENGTH = 256;
8
9
 
9
10
  export type ExternalCliPreflightInvalidationReason = "launch" | "auth" | "parser" | "permission";
10
11
 
@@ -32,6 +33,10 @@ type CachedPreflight = Omit<ExternalCliPreflightResult, "cacheHit">;
32
33
  const cache = new Map<string, CachedPreflight>();
33
34
  const lookup = new Map<string, string>();
34
35
 
36
+ export type ExternalCliBinaryAvailability =
37
+ | { available: true }
38
+ | { available: false; unavailableReason: string };
39
+
35
40
  function resolveBinary(command: string, env: NodeJS.ProcessEnv): string {
36
41
  if (path.isAbsolute(command) || command.includes(path.sep)) {
37
42
  const resolved = path.resolve(command);
@@ -52,6 +57,17 @@ function resolveBinary(command: string, env: NodeJS.ProcessEnv): string {
52
57
  throw new Error(`External CLI binary '${command}' was not found on PATH.`);
53
58
  }
54
59
 
60
+ /** Resolve only the configured command; unlike preflight, this never starts a child process. */
61
+ export function resolveExternalCliBinaryAvailability(command: string, env: NodeJS.ProcessEnv): ExternalCliBinaryAvailability {
62
+ try {
63
+ resolveBinary(command, env);
64
+ return { available: true };
65
+ } catch (error) {
66
+ const reason = error instanceof Error ? error.message : String(error);
67
+ return { available: false, unavailableReason: reason.slice(0, MAX_AVAILABILITY_REASON_LENGTH) };
68
+ }
69
+ }
70
+
55
71
  function probeWithTimeout(binaryPath: string, args: readonly string[], env: NodeJS.ProcessEnv, label: string, timeoutMs: number, cwd?: string): string {
56
72
  const result = spawnSync(binaryPath, [...args], {
57
73
  cwd,
@@ -115,8 +115,11 @@ export function resolveMcpDirectToolResolution(
115
115
  ? mergeConfigs(config, { mcpServers: runtimeServers })
116
116
  : config;
117
117
  validateSelectedServerDefinitions(resolvedConfig, selectors);
118
+ const runtime = Object.keys(runtimeServers).length > 0
119
+ ? { mcpConfig: resolvedConfig, runtimeServerNames: Object.keys(runtimeServers) }
120
+ : {};
118
121
  const cache = loadMetadataCache();
119
- if (!cache) return { selections: [], unresolvedSelectors: selectors };
122
+ if (!cache) return { selections: [], unresolvedSelectors: selectors, ...runtime };
120
123
  const validMetadata: Record<string, ServerCacheEntry> = {};
121
124
  for (const [serverName, definition] of Object.entries(resolvedConfig.mcpServers)) {
122
125
  const serverCache = cache.servers[serverName];
@@ -131,9 +134,7 @@ export function resolveMcpDirectToolResolution(
131
134
  return {
132
135
  selections: grant.selections,
133
136
  unresolvedSelectors: grant.unresolvedSelectors,
134
- ...(Object.keys(runtimeServers).length > 0
135
- ? { mcpConfig: resolvedConfig, runtimeServerNames: Object.keys(runtimeServers) }
136
- : {}),
137
+ ...runtime,
137
138
  };
138
139
  }
139
140