pi-subagents 0.56.0 → 0.58.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 (108) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/agents/claude-code-writer.md +15 -0
  3. package/agents/claude-code.md +15 -0
  4. package/agents/codex-exec-writer.md +15 -0
  5. package/agents/codex-exec.md +15 -0
  6. package/agents/cursor-agent-writer.md +14 -0
  7. package/agents/cursor-agent.md +14 -0
  8. package/docs/agents.md +124 -21
  9. package/docs/configuration.md +37 -0
  10. package/docs/extension-api.md +41 -2
  11. package/docs/models.md +3 -3
  12. package/docs/observability.md +8 -7
  13. package/docs/tool-reference.md +14 -3
  14. package/docs/workflows.md +44 -0
  15. package/package.json +1 -1
  16. package/skills/pi-subagents/SKILL.md +2 -0
  17. package/skills/pi-subagents/references/execution-controls.md +2 -2
  18. package/skills/pi-subagents/references/management-authoring-rpc.md +1 -0
  19. package/skills/pi-subagents/references/prompting-and-roles.md +2 -2
  20. package/src/agents/agent-management.ts +36 -5
  21. package/src/agents/agent-refinements.ts +4 -4
  22. package/src/agents/agent-serializer.ts +5 -0
  23. package/src/agents/agents.ts +257 -51
  24. package/src/agents/builtin-names.ts +6 -0
  25. package/src/agents/runtime-agent-events.ts +70 -0
  26. package/src/agents/runtime-agent-registry.ts +18 -4
  27. package/src/api/agents.ts +10 -5
  28. package/src/api/preflight.ts +28 -3
  29. package/src/extension/config.ts +70 -0
  30. package/src/extension/doctor.ts +3 -3
  31. package/src/extension/index.ts +33 -8
  32. package/src/extension/public-execution.ts +29 -13
  33. package/src/extension/rpc.ts +55 -19
  34. package/src/extension/schemas.ts +6 -5
  35. package/src/extension/tool-description.ts +18 -12
  36. package/src/inspectors/herdr/actions.ts +2 -1
  37. package/src/inspectors/herdr/inspector-runner.ts +2 -10
  38. package/src/inspectors/herdr/session-roots-codec.ts +42 -0
  39. package/src/integrations/herdr-status.ts +51 -3
  40. package/src/runs/background/active-async-capacity.ts +77 -10
  41. package/src/runs/background/async-execution.ts +127 -19
  42. package/src/runs/background/async-job-tracker.ts +5 -0
  43. package/src/runs/background/async-resume.ts +6 -2
  44. package/src/runs/background/async-retention.ts +20 -3
  45. package/src/runs/background/async-status.ts +7 -0
  46. package/src/runs/background/chain-append.ts +2 -0
  47. package/src/runs/background/chain-root-attachment.ts +15 -1
  48. package/src/runs/background/fleet-view.ts +16 -10
  49. package/src/runs/background/inspect-rpc.ts +8 -8
  50. package/src/runs/background/notify.ts +26 -3
  51. package/src/runs/background/result-delivery-ownership.ts +45 -0
  52. package/src/runs/background/result-files.ts +27 -14
  53. package/src/runs/background/result-watcher.ts +36 -15
  54. package/src/runs/background/run-status.ts +33 -6
  55. package/src/runs/background/scheduled-runs.ts +7 -1
  56. package/src/runs/background/subagent-runner.ts +239 -56
  57. package/src/runs/background/wait-completions.ts +4 -0
  58. package/src/runs/foreground/execution.ts +92 -11
  59. package/src/runs/foreground/foreground-control.ts +6 -0
  60. package/src/runs/foreground/foreground-history.ts +22 -1
  61. package/src/runs/foreground/subagent-executor.ts +322 -102
  62. package/src/runs/foreground/workflow-detach-reconcile.ts +144 -18
  63. package/src/runs/shared/child-protocol.ts +21 -7
  64. package/src/runs/shared/claude-code-adapter.ts +129 -0
  65. package/src/runs/shared/codex-exec-adapter.ts +129 -0
  66. package/src/runs/shared/completion-guard.ts +4 -3
  67. package/src/runs/shared/cursor-agent-adapter.ts +114 -0
  68. package/src/runs/shared/dynamic-fanout.ts +3 -3
  69. package/src/runs/shared/external-cli-contract.ts +167 -0
  70. package/src/runs/shared/external-cli-preflight.ts +122 -0
  71. package/src/runs/shared/external-cli-runner.ts +348 -55
  72. package/src/runs/shared/fast-mode-extension.ts +5 -5
  73. package/src/runs/shared/launch-cwd.ts +16 -0
  74. package/src/runs/shared/long-running-guard.ts +2 -1
  75. package/src/runs/shared/mcp-config-sources.ts +386 -0
  76. package/src/runs/shared/mcp-direct-tool-allowlist.ts +155 -42
  77. package/src/runs/shared/model-exclusions.ts +69 -7
  78. package/src/runs/shared/model-fallback.ts +39 -5
  79. package/src/runs/shared/mutation-evidence.ts +7 -2
  80. package/src/runs/shared/nested-events.ts +3 -1
  81. package/src/runs/shared/nested-render.ts +2 -2
  82. package/src/runs/shared/parallel-utils.ts +8 -1
  83. package/src/runs/shared/pi-args.ts +61 -6
  84. package/src/runs/shared/process-signal.ts +13 -0
  85. package/src/runs/shared/run-history.ts +21 -1
  86. package/src/runs/shared/single-output.ts +17 -0
  87. package/src/runs/shared/subagent-prompt-runtime.ts +85 -9
  88. package/src/shared/fork-context.ts +21 -0
  89. package/src/shared/formatters.ts +13 -1
  90. package/src/shared/launch-contract.ts +4 -0
  91. package/src/shared/pruned-fork.ts +450 -0
  92. package/src/shared/session-file-trust.ts +19 -0
  93. package/src/shared/session-tokens.ts +14 -3
  94. package/src/shared/settings.ts +10 -2
  95. package/src/shared/shortcuts.ts +17 -0
  96. package/src/shared/types.ts +160 -10
  97. package/src/shared/utils.ts +6 -29
  98. package/src/shared/workflow-child-permit.ts +116 -0
  99. package/src/slash/delegation-adapters.ts +0 -1
  100. package/src/slash/slash-commands.ts +8 -6
  101. package/src/slash/subagents-admin.ts +3 -0
  102. package/src/tui/fleet-status.ts +27 -10
  103. package/src/tui/fleet-transcript.ts +11 -5
  104. package/src/tui/fleet.ts +28 -13
  105. package/src/tui/render.ts +55 -21
  106. package/src/workflows/scripted-workflow.ts +299 -31
  107. package/src/workflows/workflow-child-summary.ts +117 -0
  108. package/src/workflows/workflow-receipt.ts +155 -5
@@ -20,14 +20,30 @@ type RecordModelFailureOptions = ModelExclusionTarget & {
20
20
 
21
21
  let exclusions: ModelExclusion[] = [];
22
22
  let loaded = false;
23
- let defaultTTLMs = 24 * 60 * 60_000; // 24 hours, overridable via setDefaultTTL
23
+ /** Default duration for a new model exclusion when no per-record TTL is supplied. */
24
+ export const DEFAULT_MODEL_EXCLUSION_TTL_MS = 24 * 60 * 60_000;
25
+ /** Keeps a new expiry safely below JavaScript's maximum Date timestamp. */
26
+ export const MAX_MODEL_EXCLUSION_TTL_MS = 8_000_000_000_000_000;
27
+ const MAX_DATE_TIMESTAMP_MS = 8_640_000_000_000_000;
28
+ let defaultTTLMs = DEFAULT_MODEL_EXCLUSION_TTL_MS;
29
+ let loadedTTLCeilingMs: number | undefined;
24
30
  let persistTimer: ReturnType<typeof setTimeout> | null = null;
25
31
  let persistSeq = 0;
26
32
 
27
- /** Override the default exclusion TTL. */
28
- export function setDefaultTTL(ms: number): void {
29
- if (!Number.isFinite(ms) || ms <= 0) throw new Error("Default model exclusion TTL must be a finite positive number.");
33
+ /**
34
+ * Override the default TTL applied to newly recorded model exclusions.
35
+ *
36
+ * @param ms Duration in milliseconds. Must be finite and positive.
37
+ * @returns Nothing.
38
+ */
39
+ // TEST:test/unit/model-exclusions.test.ts[model exclusions — TTL expiry]
40
+ export function setDefaultTTL(ms: number, options?: { shortenExisting?: boolean }): void {
41
+ if (!Number.isFinite(ms) || ms <= 0 || ms > MAX_MODEL_EXCLUSION_TTL_MS) {
42
+ throw new Error(`Default model exclusion TTL must be a finite positive number no greater than ${MAX_MODEL_EXCLUSION_TTL_MS}.`);
43
+ }
30
44
  defaultTTLMs = ms;
45
+ loadedTTLCeilingMs = options?.shortenExisting ? ms : undefined;
46
+ if (loaded && loadedTTLCeilingMs !== undefined && shortenExclusionsToTTL(exclusions, loadedTTLCeilingMs, Date.now())) schedulePersist();
31
47
  }
32
48
 
33
49
  /**
@@ -78,9 +94,23 @@ function ensureLoaded(): void {
78
94
  const raw = fs.readFileSync(getExclusionsFilePath(), "utf-8");
79
95
  const data = JSON.parse(raw);
80
96
  if (data.version === 1) {
97
+ if (!Array.isArray(data.exclusions)) throw new Error("Model exclusion store version 1 must contain an exclusions array.");
98
+ for (let index = 0; index < data.exclusions.length; index++) {
99
+ const entry = data.exclusions[index];
100
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw new Error(`Model exclusion store entry ${index} must be an object.`);
101
+ if (entry.reason !== undefined && typeof entry.reason !== "string") throw new Error(`Model exclusion store entry ${index} has an invalid reason.`);
102
+ for (const field of ["recordedAt", "expiresAt"] as const) {
103
+ const timestamp = entry[field];
104
+ if (typeof timestamp !== "number" || !Number.isFinite(timestamp) || timestamp <= 0 || timestamp > MAX_DATE_TIMESTAMP_MS) {
105
+ throw new Error(`Model exclusion store entry ${index} has an invalid ${field}.`);
106
+ }
107
+ }
108
+ }
81
109
  const now = Date.now();
82
110
  exclusions = (data.exclusions ?? []).filter((e: ModelExclusion) => e.expiresAt > now);
111
+ const shortened = loadedTTLCeilingMs !== undefined && shortenExclusionsToTTL(exclusions, loadedTTLCeilingMs, now);
83
112
  exclusions = deduplicate(exclusions);
113
+ if (shortened) schedulePersist();
84
114
  }
85
115
  } catch (error) {
86
116
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
@@ -175,6 +205,18 @@ export function isExcluded(modelId: string, provider: string): boolean {
175
205
  return exclusions.some((entry) => entryMatches(entry, modelId, provider, Date.now()));
176
206
  }
177
207
 
208
+ /**
209
+ * Return the active exclusion matching a full model id, if any.
210
+ *
211
+ * The caller uses this for hard-fail diagnostics; fallback filtering should
212
+ * continue to use {@link filterFallbackCandidates}.
213
+ */
214
+ export function findModelExclusion(fullId: string, now = Date.now()): Readonly<ModelExclusion> | undefined {
215
+ ensureLoaded();
216
+ const { provider, modelId } = parseModelKey(fullId);
217
+ return exclusions.find((entry) => entryMatches(entry, modelId, provider, now));
218
+ }
219
+
178
220
  /**
179
221
  * Number of live (non-expired) exclusions.
180
222
  */
@@ -205,7 +247,10 @@ export function parseModelKey(fullId: string): { provider?: string; modelId: str
205
247
  * Filter a list of candidate fullIds, removing excluded models/providers and
206
248
  * duplicates while preserving order.
207
249
  */
208
- export function filterFallbackCandidates(candidates: string[], opts?: { now?: number }): string[] {
250
+ export function filterFallbackCandidates(candidates: string[], opts?: {
251
+ now?: number;
252
+ onExcluded?: (candidate: string, exclusion: Readonly<ModelExclusion>) => void;
253
+ }): string[] {
209
254
  ensureLoaded();
210
255
  const timestamp = opts?.now ?? Date.now();
211
256
  const seen = new Set<string>();
@@ -213,8 +258,11 @@ export function filterFallbackCandidates(candidates: string[], opts?: { now?: nu
213
258
  for (const raw of candidates) {
214
259
  if (!raw || seen.has(raw)) continue;
215
260
  const { provider: candidateProvider, modelId: candidateModelId } = parseModelKey(raw);
216
- const excluded = exclusions.some((entry) => entryMatches(entry, candidateModelId, candidateProvider, timestamp));
217
- if (excluded) continue;
261
+ const exclusion = exclusions.find((entry) => entryMatches(entry, candidateModelId, candidateProvider, timestamp));
262
+ if (exclusion) {
263
+ opts?.onExcluded?.(raw, exclusion);
264
+ continue;
265
+ }
218
266
  seen.add(raw);
219
267
  filtered.push(raw);
220
268
  }
@@ -241,3 +289,17 @@ function prune(items: ModelExclusion[], now: number): void {
241
289
  }
242
290
  items.length = write;
243
291
  }
292
+
293
+ function shortenExclusionsToTTL(items: ModelExclusion[], ttlMs: number, now: number): boolean {
294
+ let changed = false;
295
+ for (const entry of items) {
296
+ const configuredExpiry = entry.recordedAt + ttlMs;
297
+ if (entry.expiresAt > configuredExpiry) {
298
+ entry.expiresAt = configuredExpiry;
299
+ changed = true;
300
+ }
301
+ }
302
+ const previousLength = items.length;
303
+ prune(items, now);
304
+ return changed || items.length !== previousLength;
305
+ }
@@ -1,7 +1,8 @@
1
1
  import { splitKnownThinkingSuffix, type ModelInfo as AvailableModelInfo } from "../../shared/model-info.ts";
2
2
  import type { Usage } from "../../shared/types.ts";
3
- import { filterFallbackCandidates, parseModelKey, recordModelFailure } from "./model-exclusions.ts";
3
+ import { filterFallbackCandidates, findModelExclusion, parseModelKey, recordModelFailure } from "./model-exclusions.ts";
4
4
  import { checkModelScope, type ModelScopeCheckRule, type ModelScopeViolation, type ModelSource } from "./model-scope.ts";
5
+ import { redactSecretValues } from "./permissions.ts";
5
6
 
6
7
  export type { AvailableModelInfo };
7
8
 
@@ -278,6 +279,14 @@ function enforceModelScopes(
278
279
  for (const violation of violations) (onWarn ?? defaultScopeWarn)(violation);
279
280
  }
280
281
 
282
+ function throwForExplicitModelExclusion(model: string): void {
283
+ const exclusion = findModelExclusion(model);
284
+ if (!exclusion) return;
285
+ const reason = redactSecretValues((exclusion.reason ?? "runtime-failure").replace(/[\u0000-\u001f\u007f]+/g, " ")).slice(0, 240);
286
+ const expiry = Number.isFinite(exclusion.expiresAt) ? `; expires: ${new Date(exclusion.expiresAt).toISOString()}` : "";
287
+ throw new Error(`Requested subagent model '${model}' is excluded and cannot be replaced by a fallback (reason: ${reason}${expiry}).`);
288
+ }
289
+
281
290
  /**
282
291
  * Resolve the `--model` override passed to a spawned subagent.
283
292
  *
@@ -312,6 +321,9 @@ export function resolveSubagentModelOverride(
312
321
  } else {
313
322
  resolved = resolveRequiredSubagentModelCandidate(explicit, availableModels, preferredProvider);
314
323
  }
324
+ if (resolved && explicit !== undefined && options?.source === "explicit") {
325
+ throwForExplicitModelExclusion(resolved);
326
+ }
315
327
  if (resolved && options?.scope) {
316
328
  const source: ModelSource = explicit === undefined ? "inherited" : (options.source ?? "inherited");
317
329
  enforceModelScopes(resolved, options.scope, source, options.onWarn);
@@ -325,14 +337,15 @@ export function resolveEffectiveSubagentModel(
325
337
  parentModel: ParentModel | undefined,
326
338
  availableModels: AvailableModelInfo[] | undefined,
327
339
  preferredProvider?: string,
328
- options?: Omit<ResolveSubagentModelOverrideOptions, "source">,
340
+ options?: ResolveSubagentModelOverrideOptions,
329
341
  ): string | undefined {
342
+ const source = options?.source ?? (explicitModel !== undefined ? "explicit" : "inherited");
330
343
  const resolved = resolveSubagentModelOverride(
331
344
  explicitModel ?? agentModel,
332
345
  parentModel,
333
346
  availableModels,
334
347
  preferredProvider,
335
- { ...options, source: explicitModel !== undefined ? "explicit" : "inherited" },
348
+ { ...options, source },
336
349
  );
337
350
  if (resolved || explicitModel === undefined) return resolved;
338
351
  return resolveSubagentModelOverride(
@@ -340,7 +353,7 @@ export function resolveEffectiveSubagentModel(
340
353
  parentModel,
341
354
  availableModels,
342
355
  preferredProvider,
343
- { ...options, source: "inherited" },
356
+ { ...options, source: options?.source ?? "inherited" },
344
357
  );
345
358
  }
346
359
 
@@ -394,7 +407,12 @@ export function buildModelCandidates(
394
407
  seen.add(normalized);
395
408
  candidates.push(normalized);
396
409
  }
397
- return filterFallbackCandidates(candidates);
410
+ return filterFallbackCandidates(candidates, {
411
+ onExcluded(candidate, exclusion) {
412
+ const reason = redactSecretValues((exclusion.reason ?? "runtime-failure").replace(/[\u0000-\u001f\u007f]+/g, " ")).slice(0, 240);
413
+ console.warn(`[pi-subagents] Skipping model '${candidate}' due to a cached exclusion (reason: ${reason}; expires: ${new Date(exclusion.expiresAt).toISOString()}).`);
414
+ },
415
+ });
398
416
  }
399
417
 
400
418
  const RETRYABLE_MODEL_FAILURE_PATTERNS = [
@@ -419,6 +437,7 @@ const RETRYABLE_MODEL_FAILURE_PATTERNS = [
419
437
  /overloaded/i,
420
438
  /service unavailable/i,
421
439
  /temporar(?:ily)? unavailable/i,
440
+ /connection\s+(?:error|reset|closed|aborted)/i,
422
441
  /connection refused/i,
423
442
  /fetch failed/i,
424
443
  /network error/i,
@@ -451,6 +470,21 @@ export function isRetryableModelFailure(error: string | undefined): boolean {
451
470
  return RETRYABLE_MODEL_FAILURE_PATTERNS.some((pattern) => pattern.test(error));
452
471
  }
453
472
 
473
+ function messageError(message: unknown): string | undefined {
474
+ if (!message || typeof message !== "object") return undefined;
475
+ const value = (message as { errorMessage?: unknown }).errorMessage;
476
+ return typeof value === "string" ? value : undefined;
477
+ }
478
+
479
+ export function isRetryableModelFailureAttempt(input: { error: string | undefined; messages?: readonly unknown[]; toolCount?: number }): boolean {
480
+ if (!isRetryableModelFailure(input.error)) return false;
481
+ if ((input.toolCount ?? 0) > 0) return false;
482
+ if (input.error === "Subagent produced no output (possible model cold-start or empty response).") return true;
483
+ if ((input.toolCount ?? 0) === 0 && (input.messages?.length ?? 0) === 0) return true;
484
+ const error = input.error?.trim();
485
+ return Boolean(error && input.messages?.some((message) => messageError(message)?.trim() === error));
486
+ }
487
+
454
488
  export function recordRetryableModelFailure(model: string | undefined, error: string | undefined): void {
455
489
  if (!model || !isRetryableModelFailure(error)) return;
456
490
  const { provider, modelId } = parseModelKey(model);
@@ -9,8 +9,13 @@ const MAX_TRACKED_PATHS = 500;
9
9
  const MAX_HASH_BYTES = 1024 * 1024;
10
10
  const MAX_TIMEOUT_FILES = 20;
11
11
 
12
+ function gitArguments(args: string[]): string[] {
13
+ // Mutation evidence must not block child startup on a stale fsmonitor daemon.
14
+ return ["-c", "core.fsmonitor=false", ...args];
15
+ }
16
+
12
17
  function gitOutput(cwd: string, args: string[], maxBuffer = MAX_HASH_BYTES): string {
13
- return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer });
18
+ return execFileSync("git", gitArguments(args), { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer });
14
19
  }
15
20
 
16
21
  function splitNul(output: string): string[] {
@@ -21,7 +26,7 @@ function hashLargeDiff(cwd: string, relativePath: string): string {
21
26
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagents-tracked-diff-"));
22
27
  const diffPath = path.join(tempDir, "diff.patch");
23
28
  try {
24
- execFileSync("git", ["diff", "--no-ext-diff", "--binary", `--output=${diffPath}`, "HEAD", "--", relativePath], { cwd, stdio: "ignore" });
29
+ execFileSync("git", gitArguments(["diff", "--no-ext-diff", "--binary", `--output=${diffPath}`, "HEAD", "--", relativePath]), { cwd, stdio: "ignore" });
25
30
  const hash = createHash("sha256");
26
31
  const buffer = Buffer.allocUnsafe(64 * 1024);
27
32
  const fd = fs.openSync(diffPath, "r");
@@ -199,8 +199,10 @@ function sanitizeTokenUsage(value: unknown): NestedRunSummary["totalTokens"] | u
199
199
  const input = clampNumber(raw.input);
200
200
  const output = clampNumber(raw.output);
201
201
  const total = clampNumber(raw.total);
202
+ const window = clampNumber(raw.window);
203
+ const windowPeak = clampNumber(raw.windowPeak);
202
204
  return input !== undefined && output !== undefined && total !== undefined
203
- ? { input, output, total }
205
+ ? { input, output, total, ...(window !== undefined ? { window } : {}), ...(windowPeak !== undefined ? { windowPeak } : {}) }
204
206
  : undefined;
205
207
  }
206
208
 
@@ -1,4 +1,4 @@
1
- import { formatDuration, formatModelThinking, formatTokens, shortenPath } from "../../shared/formatters.ts";
1
+ import { formatDuration, formatModelThinking, formatTokenUsage, shortenPath } from "../../shared/formatters.ts";
2
2
  import { formatActivityLabel } from "../../shared/status-format.ts";
3
3
  import type { ActivityState, NestedRunSummary, NestedStepSummary } from "../../shared/types.ts";
4
4
 
@@ -68,7 +68,7 @@ function formatNestedActivity(input: {
68
68
  if (input.currentPath) facts.push(shortenPath(input.currentPath));
69
69
  if (input.turnCount !== undefined) facts.push(`${input.turnCount} turns`);
70
70
  if (input.toolCount !== undefined) facts.push(`${input.toolCount} tools`);
71
- if (input.totalTokens) facts.push(`${formatTokens(input.totalTokens.total)} tok`);
71
+ if (input.totalTokens) facts.push(formatTokenUsage(input.totalTokens));
72
72
  const activity = formatActivityLabel(input.lastActivityAt, input.activityState as ActivityState | undefined);
73
73
  return activity || facts.length ? [activity, ...facts].filter(Boolean).join(" | ") : undefined;
74
74
  }
@@ -28,22 +28,29 @@ export interface RunnerSubagentStep {
28
28
  outputName?: string;
29
29
  structured?: boolean;
30
30
  cwd?: string;
31
+ /** Original cwd input retained for launch diagnostics. */
32
+ requestedCwd?: string;
31
33
  model?: string;
34
+ contextLimit?: number;
32
35
  fast?: boolean;
33
36
  thinking?: string;
34
37
  thinkingCeiling?: import("../../shared/model-info.ts").ThinkingLevel;
35
38
  modelCandidates?: string[];
36
39
  /** The primary model is inherited from the parent session and should not be verified against the child-reported active registry model. */
37
40
  skipPrimaryModelVerification?: boolean;
38
- modelVerificationRegistry?: Array<{ provider: string; id: string; fullId: string }>;
41
+ modelVerificationRegistry?: Array<{ provider: string; id: string; fullId: string; contextWindow?: number }>;
39
42
  tools?: string[];
40
43
  extensions?: string[];
41
44
  subagentOnlyExtensions?: string[];
42
45
  mcpDirectTools?: string[];
46
+ mcpConfig?: import("./mcp-direct-tool-allowlist.ts").McpConfig;
47
+ runtimeServerNames?: string[];
48
+ mutationTools?: string[];
43
49
  completionGuard?: boolean;
44
50
  systemPrompt?: string | null;
45
51
  systemPromptMode?: "append" | "replace";
46
52
  inheritProjectContext: boolean;
53
+ inheritGlobalContext: boolean;
47
54
  inheritSkills: boolean;
48
55
  skills?: string[];
49
56
  outputPath?: string;
@@ -9,7 +9,10 @@ import {
9
9
  type NestedPathEntry,
10
10
  } from "./nested-path.ts";
11
11
  import {
12
- resolveMcpDirectToolSelections,
12
+ formatUnresolvedMcpDirectToolSelectors,
13
+ resolveMcpDirectToolResolution,
14
+ type McpConfig,
15
+ type McpRuntimeSnapshotHost,
13
16
  type ResolvedMcpDirectToolSelection,
14
17
  } from "./mcp-direct-tool-allowlist.ts";
15
18
  import { resolvePiPackageRoot } from "./pi-spawn.ts";
@@ -141,6 +144,7 @@ export const SUBAGENT_STEER_CAPABILITY_ENV = "PI_SUBAGENT_STEER_CAPABILITY";
141
144
  export const SUBAGENT_STEER_ACK_DIR_ENV = "PI_SUBAGENT_STEER_ACK_DIR";
142
145
  export const PI_INTERCOM_STABLE_ID_ENV = "PI_INTERCOM_STABLE_ID";
143
146
  export const PI_INTERCOM_SESSION_ID_ENV = "PI_INTERCOM_SESSION_ID";
147
+ export const SUBAGENT_INHERIT_GLOBAL_CONTEXT_ENV = "PI_SUBAGENT_INHERIT_GLOBAL_CONTEXT";
144
148
 
145
149
  export interface BuildPiArgsInput {
146
150
  parentSessionId?: string;
@@ -153,6 +157,7 @@ export interface BuildPiArgsInput {
153
157
  thinking?: string | false;
154
158
  systemPromptMode?: "append" | "replace";
155
159
  inheritProjectContext: boolean;
160
+ inheritGlobalContext: boolean;
156
161
  inheritSkills: boolean;
157
162
  requireReadTool?: boolean;
158
163
  tools?: string[];
@@ -160,6 +165,8 @@ export interface BuildPiArgsInput {
160
165
  subagentOnlyExtensions?: string[];
161
166
  systemPrompt?: string | null;
162
167
  mcpDirectTools?: string[];
168
+ mcpConfig?: McpConfig;
169
+ runtimeServerNames?: string[];
163
170
  cwd?: string;
164
171
  promptFileStem?: string;
165
172
  intercomSessionName?: string;
@@ -202,6 +209,7 @@ export interface BuildPiArgsInput {
202
209
  capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
203
210
  thinkingCeiling?: import("../../shared/model-info.ts").ThinkingLevel;
204
211
  extensionBindings?: ExtensionBindings;
212
+ runtimeSnapshotHost?: McpRuntimeSnapshotHost;
205
213
  }
206
214
 
207
215
  export interface BuildPiArgsResult {
@@ -278,6 +286,8 @@ export interface ResolvePiLaunchToolPlanInput {
278
286
  extensions?: string[];
279
287
  subagentOnlyExtensions?: string[];
280
288
  mcpDirectTools?: string[];
289
+ mcpConfig?: McpConfig;
290
+ runtimeServerNames?: string[];
281
291
  cwd?: string;
282
292
  requireReadTool?: boolean;
283
293
  structuredOutput?:
@@ -294,6 +304,7 @@ export interface ResolvePiLaunchToolPlanInput {
294
304
  inheritedCapabilityCeiling?: ResolvedSubagentCapabilityCeiling;
295
305
  agentName?: string;
296
306
  permissionRules?: PermissionRules;
307
+ runtimeSnapshotHost?: McpRuntimeSnapshotHost;
297
308
  }
298
309
 
299
310
  export interface PiLaunchToolPlan {
@@ -316,6 +327,8 @@ export interface PiLaunchToolPlan {
316
327
  capabilityAudit?: SubagentCapabilityAudit;
317
328
  /** Non-fatal launch warnings; they do not change behavior. */
318
329
  warnings: string[];
330
+ mcpConfig?: McpConfig;
331
+ runtimeServerNames?: string[];
319
332
  }
320
333
 
321
334
  function extensionIdentifier(value: string): string {
@@ -337,6 +350,21 @@ function hasPermissionRules(rules: PermissionRules | undefined): boolean {
337
350
  return rules !== undefined && Object.keys(rules).length > 0;
338
351
  }
339
352
 
353
+ function filterRuntimeMcpConfig(
354
+ config: McpConfig,
355
+ runtimeServerNames: readonly string[],
356
+ effectiveRuntimeServerNames: readonly string[],
357
+ ): McpConfig {
358
+ const runtimeNames = new Set(runtimeServerNames);
359
+ const effectiveNames = new Set(effectiveRuntimeServerNames);
360
+ return {
361
+ ...config,
362
+ mcpServers: Object.fromEntries(
363
+ Object.entries(config.mcpServers).filter(([name]) => !runtimeNames.has(name) || effectiveNames.has(name)),
364
+ ),
365
+ };
366
+ }
367
+
340
368
  export function projectLaunchResolvedChildExtensions(
341
369
  toolPlan: Pick<
342
370
  PiLaunchToolPlan,
@@ -461,15 +489,27 @@ export function resolvePiLaunchToolPlan(
461
489
  !requestedBuiltinTools.includes(tool) &&
462
490
  (tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js")),
463
491
  );
464
- const resolvedMcpSelections = capabilityCeiling?.denyExtensions
465
- ? []
466
- : resolveMcpDirectToolSelections(input.mcpDirectTools, input.cwd);
492
+ const mcpResolution = capabilityCeiling?.denyExtensions
493
+ ? { selections: [], unresolvedSelectors: [] }
494
+ : resolveMcpDirectToolResolution(input.mcpDirectTools, input.cwd, input.runtimeSnapshotHost, input.mcpConfig);
495
+ if (mcpResolution.unresolvedSelectors.length > 0) {
496
+ throw new Error(formatUnresolvedMcpDirectToolSelectors(mcpResolution.unresolvedSelectors));
497
+ }
498
+ const resolvedMcpSelections = mcpResolution.selections;
467
499
  const effectiveMcpSelections = resolvedMcpSelections.filter(
468
500
  (selection) => !allowedToolSet || allowedToolSet.has(selection.name),
469
501
  );
470
502
  const effectiveMcpTools = effectiveMcpSelections.map(
471
503
  (selection) => selection.name,
472
504
  );
505
+ const runtimeServerNames = mcpResolution.runtimeServerNames ?? input.runtimeServerNames ?? [];
506
+ const effectiveRuntimeServerNames = runtimeServerNames.filter((serverName) =>
507
+ effectiveMcpSelections.some((selection) => selection.selector.startsWith(`${serverName}/`)),
508
+ );
509
+ const effectiveMcpConfig = mcpResolution.mcpConfig ?? input.mcpConfig;
510
+ const filteredMcpConfig = effectiveMcpConfig
511
+ ? filterRuntimeMcpConfig(effectiveMcpConfig, runtimeServerNames, effectiveRuntimeServerNames)
512
+ : undefined;
473
513
  const explicitToolAllowlist =
474
514
  input.tools !== undefined ||
475
515
  (input.mcpDirectTools?.length ?? 0) > 0 ||
@@ -605,6 +645,9 @@ export function resolvePiLaunchToolPlan(
605
645
  extensionArgs,
606
646
  disableAmbientExtensions,
607
647
  warnings,
648
+ ...(effectiveRuntimeServerNames.length > 0 && filteredMcpConfig
649
+ ? { mcpConfig: filteredMcpConfig, runtimeServerNames: effectiveRuntimeServerNames }
650
+ : {}),
608
651
  ...(capabilityAudit ? { capabilityAudit } : {}),
609
652
  };
610
653
  }
@@ -644,6 +687,8 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
644
687
  extensions: input.extensions,
645
688
  subagentOnlyExtensions: input.subagentOnlyExtensions,
646
689
  mcpDirectTools: input.mcpDirectTools,
690
+ mcpConfig: input.mcpConfig,
691
+ runtimeServerNames: input.runtimeServerNames,
647
692
  cwd: input.cwd,
648
693
  requireReadTool: input.requireReadTool,
649
694
  structuredOutput: input.structuredOutput,
@@ -656,6 +701,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
656
701
  ),
657
702
  agentName: input.childAgentName,
658
703
  permissionRules: input.permissionRules,
704
+ runtimeSnapshotHost: input.runtimeSnapshotHost,
659
705
  });
660
706
  if (toolPlan.explicitToolAllowlist) {
661
707
  args.push(
@@ -669,6 +715,14 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
669
715
  }
670
716
  for (const extPath of toolPlan.extensionArgs)
671
717
  args.push("--extension", extPath);
718
+ let tempDir: string | undefined;
719
+ if (toolPlan.mcpConfig && toolPlan.runtimeServerNames?.length) {
720
+ if (!tempDir)
721
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
722
+ const mcpConfigPath = path.join(tempDir, "mcp-config.json");
723
+ fs.writeFileSync(mcpConfigPath, JSON.stringify(toolPlan.mcpConfig, null, 2), { mode: 0o600 });
724
+ args.push("--mcp-config", mcpConfigPath);
725
+ }
672
726
 
673
727
  if (!input.inheritProjectContext) {
674
728
  args.push("--no-context-files");
@@ -677,9 +731,9 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
677
731
  args.push("--no-skills");
678
732
  }
679
733
 
680
- let tempDir: string | undefined;
681
734
  if (input.systemPrompt !== undefined && input.systemPrompt !== null) {
682
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
735
+ if (!tempDir)
736
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
683
737
  const stem = (input.promptFileStem ?? "prompt").replace(/[^\w.-]/g, "_");
684
738
  const promptPath = path.join(tempDir, `${stem}.md`);
685
739
  // Inject <active_agent> tag so @gotgenes/pi-permission-system can
@@ -817,6 +871,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
817
871
  env.PI_SUBAGENT_INHERIT_PROJECT_CONTEXT = input.inheritProjectContext
818
872
  ? "1"
819
873
  : "0";
874
+ env[SUBAGENT_INHERIT_GLOBAL_CONTEXT_ENV] = input.inheritGlobalContext ? "1" : "0";
820
875
  env.PI_SUBAGENT_INHERIT_SKILLS = input.inheritSkills ? "1" : "0";
821
876
  env[PI_INTERCOM_STABLE_ID_ENV] = input.intercomSessionName || undefined;
822
877
  env[PI_INTERCOM_SESSION_ID_ENV] = undefined;
@@ -2,6 +2,19 @@ export function formatProcessSignalError(signal: string): string {
2
2
  return `Subagent process terminated by signal ${signal}.`;
3
3
  }
4
4
 
5
+ export function formatMidToolExitError(input: {
6
+ toolName: string;
7
+ exitCode: number | null;
8
+ processSignal?: string | null;
9
+ }): string {
10
+ const terminal = [`exit ${input.exitCode ?? "unknown"}`, ...(input.processSignal ? [`signal ${input.processSignal}`] : [])].join(", ");
11
+ return `Subagent process exited during '${input.toolName}' tool execution (${terminal}) before the tool completed. Earlier assistant output is not a terminal result.`;
12
+ }
13
+
14
+ export function isOrdinaryToolForMidToolExit(toolName: string): boolean {
15
+ return toolName !== "intercom" && toolName !== "contact_supervisor";
16
+ }
17
+
5
18
  export function isUnexplainedProcessSignal(input: {
6
19
  processSignal?: string | null;
7
20
  interrupted?: boolean;
@@ -2,6 +2,9 @@ import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { getAgentDir } from "../../shared/utils.ts";
5
+ import { isUnexplainedProcessSignal } from "./process-signal.ts";
6
+
7
+ export type RunOutcome = "completed" | "failed" | "timed_out" | "stopped" | "interrupted";
5
8
 
6
9
  export interface RunEntry {
7
10
  agent: string;
@@ -9,6 +12,7 @@ export interface RunEntry {
9
12
  taskHash?: string;
10
13
  ts: number;
11
14
  status: "ok" | "error";
15
+ outcome?: RunOutcome;
12
16
  duration: number;
13
17
  exit?: number;
14
18
  }
@@ -129,14 +133,30 @@ function appendPrivateHistoryLine(historyPath: string, line: string): void {
129
133
  }
130
134
  }
131
135
 
132
- export function recordRun(agent: string, task: string, exitCode: number, durationMs: number): void {
136
+ export function recordRun(
137
+ agent: string,
138
+ task: string,
139
+ exitCode: number,
140
+ durationMs: number,
141
+ terminal: { interrupted?: boolean; processSignal?: string | null; stopped?: boolean; timedOut?: boolean; turnBudgetExceeded?: boolean } = {},
142
+ ): void {
133
143
  try {
144
+ const outcome: RunOutcome = terminal.stopped
145
+ ? "stopped"
146
+ : terminal.interrupted
147
+ ? "interrupted"
148
+ : terminal.timedOut
149
+ ? "timed_out"
150
+ : exitCode !== 0 && isUnexplainedProcessSignal(terminal)
151
+ ? "stopped"
152
+ : exitCode === 0 ? "completed" : "failed";
134
153
  const entry: RunEntry = {
135
154
  agent,
136
155
  task: REDACTED_TASK,
137
156
  taskHash: hashTask(task),
138
157
  ts: Math.floor(Date.now() / 1000),
139
158
  status: exitCode === 0 ? "ok" : "error",
159
+ outcome,
140
160
  duration: durationMs,
141
161
  ...(exitCode !== 0 ? { exit: exitCode } : {}),
142
162
  };
@@ -96,6 +96,23 @@ function formatOutputPathInstruction(outputPath: string, capabilities?: OutputIn
96
96
  ].join("\n");
97
97
  }
98
98
 
99
+ export function requestedOutputPathFromTask(task: string): string | undefined {
100
+ for (const line of task.split(/\r?\n/).reverse()) {
101
+ const match = line.match(/^\s*(?:Write your findings to(?: exactly this path)?:|The runtime will persist it to exactly this path:)\s*(.+?)\s*$/i);
102
+ if (!match?.[1]) continue;
103
+ const requested = match[1].trim();
104
+ return requested.startsWith("`") && requested.endsWith("`") ? requested.slice(1, -1) : requested;
105
+ }
106
+ return undefined;
107
+ }
108
+
109
+ export function outputPathMappingFromTask(task: string, savedPath: string | undefined): { requestedPath: string; savedPath: string } | undefined {
110
+ const requestedPath = requestedOutputPathFromTask(task);
111
+ if (!requestedPath || !savedPath) return undefined;
112
+ if (path.isAbsolute(requestedPath) && path.normalize(requestedPath) === path.normalize(savedPath)) return undefined;
113
+ return { requestedPath, savedPath };
114
+ }
115
+
99
116
  export function injectSingleOutputInstruction(task: string, outputPath: string | undefined, capabilities?: OutputInstructionCapabilities): string {
100
117
  if (!outputPath) return task;
101
118
  return `${task}\n\n---\n**Output:**\n${formatOutputPathInstruction(outputPath, capabilities)}`;