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
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { splitKnownThinkingSuffix } from "../../shared/model-info.ts";
4
4
  import { TEMP_ROOT_DIR } from "../../shared/types.ts";
5
+ import { getAgentDir } from "../../shared/utils.ts";
5
6
 
6
7
  export const EXCLUSIONS_PATH_ENV = "PI_MODEL_EXCLUSIONS_PATH";
7
8
 
@@ -30,6 +31,43 @@ let loadedTTLCeilingMs: number | undefined;
30
31
  let persistTimer: ReturnType<typeof setTimeout> | null = null;
31
32
  let persistSeq = 0;
32
33
 
34
+ const AUTH_FAILURE_PATTERNS = [
35
+ /auth(?:entication)?/i,
36
+ /unauthori[sz]ed/i,
37
+ /forbidden/i,
38
+ /api key/i,
39
+ /token expired/i,
40
+ /invalid key/i,
41
+ ];
42
+
43
+ function isAuthModelExclusion(entry: ModelExclusion): boolean {
44
+ const reason = entry.reason;
45
+ return typeof reason === "string" && AUTH_FAILURE_PATTERNS.some((pattern) => pattern.test(reason));
46
+ }
47
+
48
+ function getAuthStoreMtimeMs(): number | undefined {
49
+ const authStorePath = path.join(getAgentDir(), "auth.json");
50
+ try {
51
+ const stats = fs.statSync(authStorePath);
52
+ return stats.isFile() && Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : undefined;
53
+ } catch (error) {
54
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
55
+ console.error(`[model-exclusions] Failed to stat Pi auth store at ${authStorePath}; preserving auth-related exclusions:`, error);
56
+ }
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ function invalidateAuthExclusions(): void {
62
+ if (!exclusions.some(isAuthModelExclusion)) return;
63
+ const authStoreMtimeMs = getAuthStoreMtimeMs();
64
+ if (authStoreMtimeMs === undefined) return;
65
+ const retained = exclusions.filter((entry) => !isAuthModelExclusion(entry) || authStoreMtimeMs <= entry.recordedAt);
66
+ if (retained.length === exclusions.length) return;
67
+ exclusions = retained;
68
+ schedulePersist();
69
+ }
70
+
33
71
  /**
34
72
  * Override the default TTL applied to newly recorded model exclusions.
35
73
  *
@@ -95,23 +133,23 @@ function ensureLoaded(): void {
95
133
  const data = JSON.parse(raw);
96
134
  if (data.version === 1) {
97
135
  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
- }
109
136
  const now = Date.now();
110
- exclusions = (data.exclusions ?? []).filter((e: ModelExclusion) => e.expiresAt > now);
137
+ let droppedInvalid = false;
138
+ exclusions = data.exclusions.flatMap((entry: unknown, index: number) => {
139
+ const result = readPersistedExclusion(entry, index);
140
+ if (!result.ok) {
141
+ droppedInvalid = true;
142
+ console.warn(`[model-exclusions] Ignoring invalid exclusion: ${result.message}`);
143
+ return [];
144
+ }
145
+ return [result.exclusion];
146
+ }).filter((e: ModelExclusion) => e.expiresAt > now);
111
147
  const shortened = loadedTTLCeilingMs !== undefined && shortenExclusionsToTTL(exclusions, loadedTTLCeilingMs, now);
112
148
  exclusions = deduplicate(exclusions);
113
- if (shortened) schedulePersist();
149
+ if (droppedInvalid) flushPersist();
150
+ else if (shortened) schedulePersist();
114
151
  }
152
+ invalidateAuthExclusions();
115
153
  } catch (error) {
116
154
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
117
155
  console.error(`[model-exclusions] Failed to load exclusions from ${getExclusionsFilePath()}:`, error);
@@ -119,6 +157,32 @@ function ensureLoaded(): void {
119
157
  }
120
158
  }
121
159
 
160
+ type PersistedExclusionRead =
161
+ | { ok: true; exclusion: ModelExclusion }
162
+ | { ok: false; message: string };
163
+
164
+ function invalidPersistedExclusion(index: number, message: string): PersistedExclusionRead {
165
+ return { ok: false, message: `Model exclusion store entry ${index} ${message}.` };
166
+ }
167
+
168
+ function readPersistedExclusion(entry: unknown, index: number): PersistedExclusionRead {
169
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return invalidPersistedExclusion(index, "must be an object");
170
+ const candidate = entry as { modelId?: unknown; provider?: unknown; reason?: unknown; recordedAt?: unknown; expiresAt?: unknown };
171
+ const { modelId, provider, reason } = candidate;
172
+ if (modelId !== undefined && (typeof modelId !== "string" || modelId.length === 0)) return invalidPersistedExclusion(index, "has an invalid modelId");
173
+ if (provider !== undefined && (typeof provider !== "string" || provider.length === 0)) return invalidPersistedExclusion(index, "has an invalid provider");
174
+ if (reason !== undefined && typeof reason !== "string") return invalidPersistedExclusion(index, "has an invalid reason");
175
+ const { recordedAt, expiresAt } = candidate;
176
+ if (typeof recordedAt !== "number" || !Number.isFinite(recordedAt) || recordedAt <= 0 || recordedAt > MAX_DATE_TIMESTAMP_MS) return invalidPersistedExclusion(index, "has an invalid recordedAt");
177
+ if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= 0 || expiresAt > MAX_DATE_TIMESTAMP_MS) return invalidPersistedExclusion(index, "has an invalid expiresAt");
178
+ const metadata = { ...(reason === undefined ? {} : { reason }), recordedAt, expiresAt };
179
+ if (modelId === undefined) {
180
+ if (provider === undefined) return invalidPersistedExclusion(index, "must include modelId or provider");
181
+ return { ok: true, exclusion: { provider, ...metadata } };
182
+ }
183
+ return { ok: true, exclusion: { modelId, ...(provider === undefined ? {} : { provider }), ...metadata } };
184
+ }
185
+
122
186
  function dedupKey(entry: ModelExclusion): string {
123
187
  return `${entry.provider ?? ""}|${entry.modelId ?? ""}`;
124
188
  }
@@ -165,6 +229,7 @@ export function recordModelFailure(options: RecordModelFailureOptions): void {
165
229
  */
166
230
  export function clearExpiredExclusions(): void {
167
231
  ensureLoaded();
232
+ invalidateAuthExclusions();
168
233
  prune(exclusions, Date.now());
169
234
  schedulePersist();
170
235
  }
@@ -190,7 +255,7 @@ export function clearExclusions(): void {
190
255
  */
191
256
  function entryMatches(entry: ModelExclusion, candidateModelId: string, candidateProvider: string | undefined, now: number): boolean {
192
257
  if (entry.expiresAt <= now) return false;
193
- if (entry.modelId) {
258
+ if (entry.modelId !== undefined) {
194
259
  if (entry.modelId !== candidateModelId) return false;
195
260
  return !entry.provider || !candidateProvider || entry.provider === candidateProvider;
196
261
  }
@@ -202,6 +267,7 @@ function entryMatches(entry: ModelExclusion, candidateModelId: string, candidate
202
267
  */
203
268
  export function isExcluded(modelId: string, provider: string): boolean {
204
269
  ensureLoaded();
270
+ invalidateAuthExclusions();
205
271
  return exclusions.some((entry) => entryMatches(entry, modelId, provider, Date.now()));
206
272
  }
207
273
 
@@ -213,6 +279,7 @@ export function isExcluded(modelId: string, provider: string): boolean {
213
279
  */
214
280
  export function findModelExclusion(fullId: string, now = Date.now()): Readonly<ModelExclusion> | undefined {
215
281
  ensureLoaded();
282
+ invalidateAuthExclusions();
216
283
  const { provider, modelId } = parseModelKey(fullId);
217
284
  return exclusions.find((entry) => entryMatches(entry, modelId, provider, now));
218
285
  }
@@ -252,6 +319,7 @@ export function filterFallbackCandidates(candidates: string[], opts?: {
252
319
  onExcluded?: (candidate: string, exclusion: Readonly<ModelExclusion>) => void;
253
320
  }): string[] {
254
321
  ensureLoaded();
322
+ invalidateAuthExclusions();
255
323
  const timestamp = opts?.now ?? Date.now();
256
324
  const seen = new Set<string>();
257
325
  const filtered: string[] = [];
@@ -284,6 +284,31 @@ function enforceModelScopes(
284
284
  for (const violation of violations) (onWarn ?? defaultScopeWarn)(violation);
285
285
  }
286
286
 
287
+ const MODEL_EXCLUSION_DIAGNOSTIC_MAX_LENGTH = 240;
288
+ const MODEL_EXCLUSION_DIAGNOSTIC_MAX_ENTRIES = 20;
289
+
290
+ function sanitizeModelExclusionDiagnostic(value: string | undefined, fallback: string): string {
291
+ const normalized = typeof value === "string"
292
+ ? value.replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, " ").trim()
293
+ : "";
294
+ return redactSecretValues(normalized || fallback).slice(0, MODEL_EXCLUSION_DIAGNOSTIC_MAX_LENGTH);
295
+ }
296
+
297
+ function formatModelExclusionExpiry(expiresAt: number): string {
298
+ if (!Number.isFinite(expiresAt)) return "unknown";
299
+ const date = new Date(expiresAt);
300
+ return Number.isNaN(date.getTime()) ? "unknown" : date.toISOString();
301
+ }
302
+
303
+ function formatExcludedCandidateEvidence(candidate: string, exclusion: NonNullable<ReturnType<typeof findModelExclusion>>): string {
304
+ const { provider, modelId } = parseModelKey(candidate);
305
+ const displayCandidate = sanitizeModelExclusionDiagnostic(candidate, "unknown");
306
+ const displayModel = sanitizeModelExclusionDiagnostic(modelId, "unknown");
307
+ const displayProvider = sanitizeModelExclusionDiagnostic(provider ?? exclusion.provider, "unspecified");
308
+ const reason = sanitizeModelExclusionDiagnostic(exclusion.reason, "runtime-failure");
309
+ return `${displayCandidate} — model: ${displayModel}; provider: ${displayProvider}; reason: ${reason}; expires: ${formatModelExclusionExpiry(exclusion.expiresAt)}`;
310
+ }
311
+
287
312
  function throwForExplicitModelExclusion(model: string): void {
288
313
  const exclusion = findModelExclusion(model);
289
314
  if (!exclusion) return;
@@ -419,9 +444,15 @@ export function buildModelCandidates(
419
444
  if (!primaryModel) throwForUnresolvedEnforcedInheritScope(options?.scope, true);
420
445
  const origin = options?.origin ?? (options?.primaryModelFromParent ? "inherited" : "configured");
421
446
  const scopes = configuredScopes(options?.scope);
447
+ type ExcludedCandidate = { candidate: string; exclusion: NonNullable<ReturnType<typeof findModelExclusion>> };
448
+ const excludedCandidates: ExcludedCandidate[] = [];
449
+ let excludedCandidateCount = 0;
422
450
  const warnCachedExclusion = (candidate: string, exclusion: NonNullable<ReturnType<typeof findModelExclusion>>) => {
423
- const reason = redactSecretValues((exclusion.reason ?? "runtime-failure").replace(/[\u0000-\u001f\u007f]+/g, " ")).slice(0, 240);
424
- console.warn(`[pi-subagents] Skipping model '${candidate}' due to a cached exclusion (reason: ${reason}; expires: ${new Date(exclusion.expiresAt).toISOString()}).`);
451
+ excludedCandidateCount++;
452
+ if (excludedCandidates.length < MODEL_EXCLUSION_DIAGNOSTIC_MAX_ENTRIES) excludedCandidates.push({ candidate, exclusion });
453
+ const displayCandidate = sanitizeModelExclusionDiagnostic(candidate, "unknown");
454
+ const reason = sanitizeModelExclusionDiagnostic(exclusion.reason, "runtime-failure");
455
+ console.warn(`[pi-subagents] Skipping model '${displayCandidate}' due to a cached exclusion (reason: ${reason}; expires: ${formatModelExclusionExpiry(exclusion.expiresAt)}).`);
425
456
  };
426
457
  if (origin === "explicit" && primaryModel) {
427
458
  const normalized = resolveRequiredSubagentModelCandidate(primaryModel.trim(), availableModels, preferredProvider);
@@ -433,6 +464,7 @@ export function buildModelCandidates(
433
464
  const candidates: string[] = [];
434
465
  const rawCandidates = [primaryModel, ...(fallbackModels ?? [])];
435
466
  let skippedPrimary: string | undefined;
467
+ let skippedFallback: string | undefined;
436
468
  for (let index = 0; index < rawCandidates.length; index++) {
437
469
  const raw = rawCandidates[index];
438
470
  if (!raw) continue;
@@ -442,7 +474,10 @@ export function buildModelCandidates(
442
474
  : resolveSubagentModelCandidate(model, availableModels, preferredProvider);
443
475
  if (!normalized) {
444
476
  if (index === 0) skippedPrimary = model;
445
- else console.warn(`[pi-subagents] Skipping fallback model '${model}' because it is unavailable in this environment.`);
477
+ else {
478
+ skippedFallback ??= model;
479
+ console.warn(`[pi-subagents] Skipping fallback model '${model}' because it is unavailable in this environment.`);
480
+ }
446
481
  continue;
447
482
  }
448
483
  if (seen.has(normalized)) continue;
@@ -455,7 +490,15 @@ export function buildModelCandidates(
455
490
  const resolved = filterFallbackCandidates(candidates, { onExcluded: warnCachedExclusion });
456
491
  if (resolved.length === 0) {
457
492
  if (skippedPrimary) resolveRequiredSubagentModelCandidate(skippedPrimary, availableModels, preferredProvider);
458
- if (candidates.length > 0) throw new Error(ZERO_USABLE_MODEL_CANDIDATES_ERROR);
493
+ if (candidates.length === 0 && skippedFallback) resolveRequiredSubagentModelCandidate(skippedFallback, availableModels, preferredProvider);
494
+ if (candidates.length > 0) {
495
+ const shownExclusions = excludedCandidates;
496
+ const omittedExclusions = excludedCandidateCount - shownExclusions.length;
497
+ const evidence = shownExclusions.length > 0
498
+ ? ` (excluded: ${shownExclusions.map(({ candidate, exclusion }) => formatExcludedCandidateEvidence(candidate, exclusion)).join("; ")}${omittedExclusions > 0 ? `; ... and ${omittedExclusions} more` : ""})`
499
+ : "";
500
+ throw new Error(`${ZERO_USABLE_MODEL_CANDIDATES_ERROR}${evidence}`);
501
+ }
459
502
  return resolved;
460
503
  }
461
504
  if (skippedPrimary) {
@@ -16,17 +16,7 @@ import {
16
16
  type SubagentRunMode,
17
17
  type SubagentState,
18
18
  } from "../../shared/types.ts";
19
- import { isSafeNestedPathId, parseNestedPathEnv, sanitizeNestedPath, type NestedPathEntry } from "./nested-path.ts";
20
- import {
21
- SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV,
22
- SUBAGENT_PARENT_CHILD_INDEX_ENV,
23
- SUBAGENT_PARENT_CONTROL_INBOX_ENV,
24
- SUBAGENT_PARENT_DEPTH_ENV,
25
- SUBAGENT_PARENT_EVENT_SINK_ENV,
26
- SUBAGENT_PARENT_PATH_ENV,
27
- SUBAGENT_PARENT_ROOT_RUN_ID_ENV,
28
- SUBAGENT_PARENT_RUN_ID_ENV,
29
- } from "./pi-args.ts";
19
+ import { isSafeNestedPathId, sanitizeNestedPath, type NestedPathEntry } from "./nested-path.ts";
30
20
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
31
21
  import { sanitizeProcessTerminal } from "../background/process-terminal.ts";
32
22
  import { THINKING_LEVELS } from "../../shared/model-info.ts";
@@ -141,13 +131,9 @@ export function createNestedRoute(rootRunId: string): NestedRoute {
141
131
  return { rootRunId, eventSink, controlInbox, capabilityToken };
142
132
  }
143
133
 
144
- export function resolveNestedRouteFromEnv(env: NodeJS.ProcessEnv = process.env): NestedRoute | undefined {
145
- const rootRunId = env[SUBAGENT_PARENT_ROOT_RUN_ID_ENV];
146
- const eventSink = env[SUBAGENT_PARENT_EVENT_SINK_ENV];
147
- const controlInbox = env[SUBAGENT_PARENT_CONTROL_INBOX_ENV];
148
- const capabilityToken = env[SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV];
149
- if (!rootRunId || !eventSink || !controlInbox || !capabilityToken) return undefined;
150
- const route = { rootRunId, eventSink, controlInbox, capabilityToken };
134
+ /** Validate a route handed to an in-process child against its on-disk metadata. */
135
+ export function resolveNestedRoute(route: NestedRoute): NestedRoute {
136
+ const { rootRunId, capabilityToken } = route;
151
137
  validateRouteShape(route);
152
138
  const routeFile = path.join(commonRouteRoot(route), ROUTE_FILE);
153
139
  const metadata = JSON.parse(fs.readFileSync(routeFile, "utf-8")) as { rootRunId?: unknown; capabilityToken?: unknown };
@@ -157,24 +143,38 @@ export function resolveNestedRouteFromEnv(env: NodeJS.ProcessEnv = process.env):
157
143
  return route;
158
144
  }
159
145
 
160
- export function resolveInheritedNestedRouteFromEnv(env: NodeJS.ProcessEnv = process.env): NestedRoute | undefined {
146
+ export function resolveInheritedNestedRoute(route: NestedRoute): NestedRoute | undefined {
161
147
  try {
162
- return resolveNestedRouteFromEnv(env);
148
+ return resolveNestedRoute(route);
163
149
  } catch (error) {
164
150
  console.error("Ignoring invalid nested subagent event route:", error);
165
151
  return undefined;
166
152
  }
167
153
  }
168
154
 
169
- export function resolveNestedParentAddressFromEnv(env: NodeJS.ProcessEnv = process.env): { parentRunId: string; parentStepIndex?: number; depth: number; path: NestedPathEntry[] } | undefined {
170
- const parentRunId = env[SUBAGENT_PARENT_RUN_ID_ENV];
171
- if (!isSafeNestedId(parentRunId)) return undefined;
172
- const rawIndex = env[SUBAGENT_PARENT_CHILD_INDEX_ENV];
173
- const parentStepIndex = rawIndex && /^\d+$/.test(rawIndex) ? Number(rawIndex) : undefined;
174
- const depth = Math.min(Math.max(1, clampNumber(Number(env[SUBAGENT_PARENT_DEPTH_ENV])) ?? 1), MAX_DEPTH);
175
- const parsedPath = parseNestedPathEnv(env[SUBAGENT_PARENT_PATH_ENV]);
176
- const nestedPath = parsedPath.length ? parsedPath : [{ runId: parentRunId, ...(parentStepIndex !== undefined ? { stepIndex: parentStepIndex } : {}) }];
177
- return { parentRunId, ...(parentStepIndex !== undefined ? { parentStepIndex } : {}), depth, path: nestedPath };
155
+ export interface NestedParentAddress {
156
+ parentRunId: string;
157
+ parentStepIndex?: number;
158
+ depth: number;
159
+ path: NestedPathEntry[];
160
+ }
161
+
162
+ /** The nested route an executor inherited from its own child runtime, validated against disk. */
163
+ export function inheritedNestedRouteOf(runtime: { nestedRoute?: NestedRoute } | undefined): NestedRoute | undefined {
164
+ return runtime?.nestedRoute ? resolveInheritedNestedRoute(runtime.nestedRoute) : undefined;
165
+ }
166
+
167
+ /** The executor's own position in the nested run tree, from its child runtime. */
168
+ export function inheritedNestedParentAddressOf(runtime: { nestedParent?: { parentRunId: string; parentChildIndex?: number; depth: number; path: NestedPathEntry[] } } | undefined): NestedParentAddress | undefined {
169
+ const parent = runtime?.nestedParent;
170
+ if (!parent || !isSafeNestedId(parent.parentRunId)) return undefined;
171
+ const depth = Math.min(Math.max(1, clampNumber(parent.depth) ?? 1), MAX_DEPTH);
172
+ return {
173
+ parentRunId: parent.parentRunId,
174
+ ...(parent.parentChildIndex !== undefined ? { parentStepIndex: parent.parentChildIndex } : {}),
175
+ depth,
176
+ path: parent.path.length ? parent.path : [{ runId: parent.parentRunId, ...(parent.parentChildIndex !== undefined ? { stepIndex: parent.parentChildIndex } : {}) }],
177
+ };
178
178
  }
179
179
 
180
180
  export function resolveNestedAsyncDir(rootRunId: string, run: NestedRunSummary): string | undefined {
@@ -947,15 +947,6 @@ export function readNestedControlResults(route: NestedRoute): NestedControlResul
947
947
  .flatMap((entry) => readControlResultsFromFile(route, path.join(route.eventSink, entry)));
948
948
  }
949
949
 
950
- export function nestedRouteEnv(route: NestedRoute): Record<string, string> {
951
- return {
952
- [SUBAGENT_PARENT_EVENT_SINK_ENV]: route.eventSink,
953
- [SUBAGENT_PARENT_CONTROL_INBOX_ENV]: route.controlInbox,
954
- [SUBAGENT_PARENT_ROOT_RUN_ID_ENV]: route.rootRunId,
955
- [SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV]: route.capabilityToken,
956
- };
957
- }
958
-
959
950
  export function attachRootChildrenToSteps<T extends { children?: NestedRunSummary[]; index?: number }>(rootRunId: string, steps: T[] | undefined, children: NestedRunSummary[] | undefined): void {
960
951
  if (!steps?.length) return;
961
952
  for (const step of steps) {
@@ -1067,13 +1058,6 @@ export function nestedSummaryFromAsyncStatus(status: AsyncStatus, asyncDir: stri
1067
1058
  };
1068
1059
  }
1069
1060
 
1070
- export function nestedArtifactEnv(rootRunId: string, parentRunId: string): Record<string, string> {
1071
- return {
1072
- PI_SUBAGENT_NESTED_ROOT_RUN_ID: rootRunId,
1073
- PI_SUBAGENT_NESTED_PARENT_RUN_ID: parentRunId,
1074
- };
1075
- }
1076
-
1077
1061
  export function isTopLevelAsyncDir(asyncDir: string): boolean {
1078
1062
  const resolved = path.resolve(asyncDir);
1079
1063
  return containedPath(DIRS.async, resolved) && !containedPath(path.join(TEMP_ROOT_DIR, "nested-subagent-runs"), resolved);
@@ -36,17 +36,3 @@ export function sanitizeNestedPath(value: unknown): NestedPathEntry[] {
36
36
  };
37
37
  }).filter((part): part is NestedPathEntry => Boolean(part)).slice(0, MAX_NESTED_PATH_ENTRIES);
38
38
  }
39
-
40
- export function parseNestedPathEnv(value: string | undefined): NestedPathEntry[] {
41
- if (!value) return [];
42
- try {
43
- return sanitizeNestedPath(JSON.parse(value) as unknown);
44
- } catch {
45
- return [];
46
- }
47
- }
48
-
49
- export function encodeNestedPathEnv(value: NestedPathEntry[]): string {
50
- const sanitized = sanitizeNestedPath(value);
51
- return sanitized.length ? JSON.stringify(sanitized) : "";
52
- }
@@ -56,7 +56,8 @@ export interface OrcaProgressTab {
56
56
  append(text: string): void;
57
57
  section(input: { agent: string; index: number; count: number }): void;
58
58
  event(event: { type?: string; message?: Message; toolName?: string; args?: unknown }): void;
59
- finish(status: "completed" | "failed" | "stopped", sessionFile?: string): void;
59
+ /** Resolves once the mirrored log and its done marker are on disk, so a host may exit afterwards. */
60
+ finish(status: "completed" | "failed" | "stopped", sessionFile?: string): Promise<void>;
60
61
  }
61
62
 
62
63
  function executableFile(candidate: string): boolean {
@@ -475,7 +476,7 @@ export function createOrcaProgressTab(input: {
475
476
  }
476
477
  },
477
478
  finish(status, sessionFile) {
478
- if (!available || finished) return;
479
+ if (!available || finished) return Promise.resolve();
479
480
  finished = true;
480
481
  const sessionId = status === "completed" ? resolvePiSessionId(sessionFile) : undefined;
481
482
  let verifiedSessionFile: string | undefined;
@@ -488,11 +489,15 @@ export function createOrcaProgressTab(input: {
488
489
  const truncation = truncated ? `\n[progress mirror truncated at ${MAX_MIRROR_BYTES} bytes]\n` : "";
489
490
  let footer = `${truncation}\n${"─".repeat(48)}\n${terminalMessage}\n`;
490
491
  if (scheduledBytes + Buffer.byteLength(footer) > MAX_MIRROR_BYTES) footer = `\n${status}\n`;
491
- logStream.end(footer, () => {
492
- if (!available) return;
493
- try { fs.writeFileSync(donePath, `${status}\n`, { encoding: "utf-8", mode: 0o600 }); } catch { /* best effort */ }
494
- cleanupPaths = [logPath, donePath];
495
- scheduleDeferredCleanup();
492
+ return new Promise<void>((resolve) => {
493
+ logStream.end(footer, () => {
494
+ if (available) {
495
+ try { fs.writeFileSync(donePath, `${status}\n`, { encoding: "utf-8", mode: 0o600 }); } catch { /* best effort */ }
496
+ cleanupPaths = [logPath, donePath];
497
+ scheduleDeferredCleanup();
498
+ }
499
+ resolve();
500
+ });
496
501
  });
497
502
  },
498
503
  };
@@ -47,8 +47,6 @@ export interface RunnerSubagentStep {
47
47
  extensions?: string[];
48
48
  subagentOnlyExtensions?: string[];
49
49
  mcpDirectTools?: string[];
50
- mcpConfig?: import("./mcp-direct-tool-allowlist.ts").McpConfig;
51
- runtimeServerNames?: string[];
52
50
  mutationTools?: string[];
53
51
  completionGuard?: boolean;
54
52
  systemPrompt?: string | null;
@@ -5,8 +5,6 @@ export type PermissionDecision = "allow" | "ask" | "deny";
5
5
  export type PermissionRules = Record<string, PermissionDecision>;
6
6
  export interface PermissionConfig { rules?: PermissionRules }
7
7
 
8
- export const PERMISSION_POLICY_ENV = "PI_SUBAGENT_PERMISSION_POLICY";
9
- export const PERMISSION_AUDIT_PATH_ENV = "PI_SUBAGENT_PERMISSION_AUDIT_PATH";
10
8
  const INTERNAL_TOOLS = new Set(["contact_supervisor", "intercom", "bg_wait", "structured_output"]);
11
9
  const DECISIONS = new Set<PermissionDecision>(["allow", "ask", "deny"]);
12
10
  const MAX_POLICY_BYTES = 16 * 1024;
@@ -52,17 +50,6 @@ export function permissionDecision(rules: PermissionRules | undefined, toolName:
52
50
  return rules?.[toolName] ?? "allow";
53
51
  }
54
52
 
55
- export function encodePermissionRules(rules: PermissionRules | undefined): string | undefined {
56
- if (!rules || Object.keys(rules).length === 0) return undefined;
57
- const encoded = JSON.stringify(rules);
58
- if (Buffer.byteLength(encoded, "utf-8") > MAX_POLICY_BYTES) throw new Error("Resolved permission policy is too large.");
59
- return encoded;
60
- }
61
-
62
- export function decodePermissionRules(encoded: string | undefined): PermissionRules | undefined {
63
- if (!encoded?.trim()) return undefined;
64
- return validatePermissionRules(JSON.parse(encoded), PERMISSION_POLICY_ENV);
65
- }
66
53
 
67
54
  function redact(value: unknown, key = "", depth = 0): unknown {
68
55
  if (SECRET_KEY.test(key)) return "[redacted]";
@@ -4,9 +4,12 @@ export function formatProcessSignalError(signal: string): string {
4
4
 
5
5
  export function formatMidToolExitError(input: {
6
6
  toolName: string;
7
- exitCode: number | null;
7
+ exitCode?: number | null;
8
8
  processSignal?: string | null;
9
9
  }): string {
10
+ if (input.exitCode === undefined && !input.processSignal) {
11
+ return `Subagent session ended during '${input.toolName}' tool execution before the tool completed. Earlier assistant output is not a terminal result.`;
12
+ }
10
13
  const terminal = [`exit ${input.exitCode ?? "unknown"}`, ...(input.processSignal ? [`signal ${input.processSignal}`] : [])].join(", ");
11
14
  return `Subagent process exited during '${input.toolName}' tool execution (${terminal}) before the tool completed. Earlier assistant output is not a terminal result.`;
12
15
  }
@@ -9,7 +9,6 @@ import {
9
9
  type RunFanoutRejection,
10
10
  } from "../../shared/types.ts";
11
11
 
12
- export const RUN_FANOUT_BUDGET_ENV = "PI_SUBAGENT_RUN_FANOUT_BUDGET";
13
12
  const RUN_FANOUT_ROOT = path.join(TEMP_ROOT_DIR, "run-fanout-budgets");
14
13
 
15
14
  interface ManifestV1 {
@@ -124,18 +123,6 @@ export function readRunFanoutBudgetDescriptor(asyncDir: string | undefined): Run
124
123
  }
125
124
  }
126
125
 
127
- export function encodeRunFanoutBudgetDescriptor(descriptor: RunFanoutBudgetDescriptor): string {
128
- return Buffer.from(JSON.stringify(validateRunFanoutBudgetDescriptor(descriptor)), "utf-8").toString("base64url");
129
- }
130
-
131
- export function decodeRunFanoutBudgetDescriptor(encoded: string | undefined): RunFanoutBudgetDescriptor | undefined {
132
- if (!encoded) return undefined;
133
- try {
134
- return validateRunFanoutBudgetDescriptor(JSON.parse(Buffer.from(encoded, "base64url").toString("utf-8")));
135
- } catch (error) {
136
- throw new Error(`Invalid inherited run fan-out budget: ${error instanceof Error ? error.message : String(error)}`);
137
- }
138
- }
139
126
 
140
127
  interface AdmissionLockOwner {
141
128
  pid: number;
@@ -1,13 +1,8 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
1
  import type { RuntimeAcknowledgedChildExtensionsV1 } from "../../shared/types.ts";
4
2
 
5
3
  export const RUNTIME_EXTENSION_ACK_EVENT = "subagent:acknowledge-extension";
6
- export const RUNTIME_EXTENSION_ACK_PATH_ENV = "PI_SUBAGENT_RUNTIME_ACKNOWLEDGED_EXTENSIONS";
7
4
  export const MAX_RUNTIME_ACKNOWLEDGED_EXTENSION_IDS = 32;
8
5
  export const MAX_RUNTIME_ACKNOWLEDGED_EXTENSION_ID_LENGTH = 128;
9
- // Generous bound for a valid capture (32 ids x 128 chars plus envelope); larger files are child misbehavior.
10
- export const MAX_RUNTIME_ACKNOWLEDGED_EXTENSION_FILE_BYTES = 64 * 1024;
11
6
 
12
7
  export function isRuntimeAcknowledgedExtensionId(value: unknown): value is string {
13
8
  return typeof value === "string"
@@ -47,25 +42,3 @@ export function sanitizeRuntimeAcknowledgedExtensions(value: unknown): RuntimeAc
47
42
  : 0;
48
43
  return { ...projected, omitted: projected.omitted + omitted };
49
44
  }
50
-
51
- export function readRuntimeAcknowledgedExtensions(filePath: string | undefined): RuntimeAcknowledgedChildExtensionsV1 | undefined {
52
- if (!filePath) return undefined;
53
- try {
54
- if (fs.statSync(filePath).size > MAX_RUNTIME_ACKNOWLEDGED_EXTENSION_FILE_BYTES) return undefined;
55
- const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as unknown;
56
- return sanitizeRuntimeAcknowledgedExtensions(parsed);
57
- } catch {
58
- return undefined;
59
- }
60
- }
61
-
62
- export function writeRuntimeAcknowledgedExtensions(filePath: string, ids: Iterable<unknown>): void {
63
- const projection = projectRuntimeAcknowledgedExtensions(ids);
64
- try {
65
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
66
- if (projection) fs.writeFileSync(filePath, JSON.stringify(projection), { mode: 0o600 });
67
- else fs.rmSync(filePath, { force: true });
68
- } catch {
69
- // Runtime acknowledgement is best-effort observability only.
70
- }
71
- }
@@ -7,10 +7,6 @@ import { PI_CODING_AGENT_PACKAGE_ROOT_ENV } from "../../shared/utils.ts";
7
7
  import type { JsonSchemaObject } from "../../shared/types.ts";
8
8
  import type { ResolvedAcceptanceReportMode } from "./acceptance.ts";
9
9
 
10
- export const STRUCTURED_OUTPUT_SCHEMA_ENV = "PI_SUBAGENT_STRUCTURED_OUTPUT_SCHEMA";
11
- export const STRUCTURED_OUTPUT_CAPTURE_ENV = "PI_SUBAGENT_STRUCTURED_OUTPUT_CAPTURE";
12
- export const STRUCTURED_OUTPUT_ACCEPTANCE_CAPTURE_ENV = "PI_SUBAGENT_STRUCTURED_OUTPUT_ACCEPTANCE_CAPTURE";
13
- export const STRUCTURED_OUTPUT_ACCEPTANCE_REQUIRED_ENV = "PI_SUBAGENT_STRUCTURED_OUTPUT_ACCEPTANCE_REQUIRED";
14
10
  export const MISSING_STRUCTURED_OUTPUT_CALL_ERROR = "Missing structured_output call; this step has outputSchema and must finish by calling structured_output.";
15
11
  export const MISSING_STRUCTURED_ACCEPTANCE_REPORT_ERROR = "Missing acceptanceReport in structured_output call; acceptance.report is \"on\".";
16
12
 
@@ -200,6 +196,23 @@ export function readStructuredOutputAcceptanceReport(runtime: StructuredOutputRu
200
196
  }
201
197
  }
202
198
 
199
+ /**
200
+ * Capture callback that persists the structured output (and acceptance report)
201
+ * to the runtime's files, for hosts that read the value back from disk.
202
+ */
203
+ export function createStructuredOutputFileCapture(runtime: StructuredOutputRuntime): (value: unknown, acceptanceReport: unknown | undefined) => void {
204
+ return (value, acceptanceReport) => {
205
+ fs.mkdirSync(path.dirname(runtime.outputPath), { recursive: true });
206
+ if (runtime.acceptanceReportPath && acceptanceReport !== undefined) {
207
+ fs.mkdirSync(path.dirname(runtime.acceptanceReportPath), { recursive: true });
208
+ fs.writeFileSync(runtime.acceptanceReportPath, JSON.stringify(acceptanceReport), { mode: 0o600 });
209
+ } else if (runtime.acceptanceReportPath && fs.existsSync(runtime.acceptanceReportPath)) {
210
+ fs.unlinkSync(runtime.acceptanceReportPath);
211
+ }
212
+ fs.writeFileSync(runtime.outputPath, JSON.stringify(value), { mode: 0o600 });
213
+ };
214
+ }
215
+
203
216
  export function clearStructuredOutputCaptures(runtime: StructuredOutputRuntime): string | undefined {
204
217
  let cleanupError: string | undefined;
205
218
  for (const filePath of [runtime.outputPath, runtime.acceptanceReportPath]) {
@@ -45,11 +45,12 @@ export function resolveControlConfig(
45
45
  const enabled = override?.enabled ?? globalConfig?.enabled ?? DEFAULT_CONTROL_CONFIG.enabled;
46
46
  const overrideNeedsAttentionAfterMs = parsePositiveInt(override?.needsAttentionAfterMs);
47
47
  const globalNeedsAttentionAfterMs = parsePositiveInt(globalConfig?.needsAttentionAfterMs);
48
+ const globalNeedsAttentionAfterMsIsExplicit = (globalConfig as Partial<ResolvedControlConfig> | undefined)?.needsAttentionAfterMsIsExplicit ?? globalNeedsAttentionAfterMs !== undefined;
48
49
  const needsAttentionAfterMs = overrideNeedsAttentionAfterMs
49
50
  ?? globalNeedsAttentionAfterMs
50
51
  ?? DEFAULT_CONTROL_CONFIG.needsAttentionAfterMs;
51
52
  const needsAttentionAfterMsIsExplicit = overrideNeedsAttentionAfterMs !== undefined
52
- || globalNeedsAttentionAfterMs !== undefined;
53
+ || globalNeedsAttentionAfterMsIsExplicit;
53
54
  const activeNoticeAfterMs = parsePositiveInt(override?.activeNoticeAfterMs)
54
55
  ?? parsePositiveInt(globalConfig?.activeNoticeAfterMs)
55
56
  ?? DEFAULT_CONTROL_CONFIG.activeNoticeAfterMs;
@@ -98,11 +99,12 @@ export function deriveActivityState(input: {
98
99
  config: ResolvedControlConfig;
99
100
  startedAt: number;
100
101
  lastActivityAt?: number;
102
+ turnCount?: number;
101
103
  currentTool?: string;
102
104
  thinking?: string | false;
103
105
  now?: number;
104
106
  }): ActivityState | undefined {
105
- if (!input.config.enabled || input.currentTool) return undefined;
107
+ if (!input.config.enabled || input.currentTool || (input.turnCount ?? 0) === 0) return undefined;
106
108
  const now = input.now ?? Date.now();
107
109
  const lastActivity = input.lastActivityAt ?? input.startedAt;
108
110
  const ageMs = Math.max(0, now - lastActivity);
@@ -136,6 +138,7 @@ export function buildControlEvent(input: {
136
138
  tokens?: number;
137
139
  toolCount?: number;
138
140
  currentTool?: string;
141
+ toolCallId?: string;
139
142
  currentToolDurationMs?: number;
140
143
  currentPath?: string;
141
144
  elapsedMs?: number;
@@ -168,6 +171,7 @@ export function buildControlEvent(input: {
168
171
  ...(input.tokens !== undefined ? { tokens: input.tokens } : {}),
169
172
  ...(input.toolCount !== undefined ? { toolCount: input.toolCount } : {}),
170
173
  ...(input.currentTool ? { currentTool: input.currentTool } : {}),
174
+ ...(input.toolCallId ? { toolCallId: input.toolCallId } : {}),
171
175
  ...(input.currentToolDurationMs !== undefined ? { currentToolDurationMs: input.currentToolDurationMs } : {}),
172
176
  ...(input.currentPath ? { currentPath: input.currentPath } : {}),
173
177
  ...(elapsedMs !== undefined ? { elapsedMs } : {}),