pi-subagents 0.67.0 → 0.69.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 (122) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +1 -1
  3. package/docs/agents.md +41 -12
  4. package/docs/configuration.md +61 -19
  5. package/docs/extension-api.md +5 -1
  6. package/docs/missions.md +2 -2
  7. package/docs/models.md +11 -79
  8. package/docs/observability.md +18 -8
  9. package/docs/standalone-background.md +13 -3
  10. package/docs/tool-reference.md +38 -14
  11. package/docs/watchdog.md +10 -12
  12. package/docs/workflows.md +59 -1
  13. package/index.ts +5 -2
  14. package/package.json +4 -2
  15. package/runner-peer-loader.mjs +24 -0
  16. package/runner-peer-preload.mjs +25 -11
  17. package/skills/pi-subagents/SKILL.md +18 -21
  18. package/skills/pi-subagents/references/constraints-and-recipes.md +3 -2
  19. package/skills/pi-subagents/references/execution-controls.md +6 -4
  20. package/skills/pi-subagents/references/management-authoring-rpc.md +0 -1
  21. package/skills/pi-subagents/references/multi-lane-orchestration.md +1 -1
  22. package/skills/pi-subagents/references/prompting-and-roles.md +16 -12
  23. package/skills/pi-subagents/references/review-and-validation.md +3 -3
  24. package/src/agents/agent-management.ts +57 -58
  25. package/src/agents/agent-serializer.ts +4 -3
  26. package/src/agents/agents.ts +185 -72
  27. package/src/agents/chain-serializer.ts +5 -0
  28. package/src/agents/runtime-agent-registry.ts +7 -6
  29. package/src/agents/skills.ts +1 -1
  30. package/src/api/preflight.ts +20 -16
  31. package/src/api/required-child-extensions.ts +6 -0
  32. package/src/extension/config.ts +10 -37
  33. package/src/extension/fanout-child.ts +3 -0
  34. package/src/extension/herdr-pi-bridge.ts +160 -0
  35. package/src/extension/index.ts +42 -31
  36. package/src/extension/public-execution.ts +3 -3
  37. package/src/extension/schemas.ts +23 -6
  38. package/src/extension/tool-description.ts +8 -7
  39. package/src/inspectors/ghostty/plugin.ts +13 -1
  40. package/src/intercom/native-supervisor-channel.ts +22 -18
  41. package/src/policy/authority.ts +4 -0
  42. package/src/profiles/profiles.ts +12 -6
  43. package/src/runs/background/active-run-index.ts +17 -1
  44. package/src/runs/background/async-execution.ts +309 -126
  45. package/src/runs/background/async-job-tracker.ts +8 -6
  46. package/src/runs/background/async-resume.ts +13 -4
  47. package/src/runs/background/async-status.ts +15 -4
  48. package/src/runs/background/auto-drain.ts +20 -10
  49. package/src/runs/background/binary-bootstrap.ts +5 -0
  50. package/src/runs/background/chain-append.ts +1 -1
  51. package/src/runs/background/chain-root-attachment.ts +14 -33
  52. package/src/runs/background/notify.ts +74 -6
  53. package/src/runs/background/result-files.ts +8 -4
  54. package/src/runs/background/result-watcher.ts +19 -2
  55. package/src/runs/background/run-child-session.ts +20 -29
  56. package/src/runs/background/runner-aliases.ts +4 -33
  57. package/src/runs/background/runner-child-launch.ts +4 -1
  58. package/src/runs/background/runner-child-sessions.ts +2 -2
  59. package/src/runs/background/runner-http-dispatcher.ts +119 -0
  60. package/src/runs/background/scheduled-runs.ts +11 -5
  61. package/src/runs/background/stale-run-reconciler.ts +35 -11
  62. package/src/runs/background/subagent-runner.ts +413 -276
  63. package/src/runs/background/subagent-wait.ts +128 -23
  64. package/src/runs/background/wait-completions.ts +75 -27
  65. package/src/runs/background/wait-subscriptions.ts +9 -3
  66. package/src/runs/background/wait-tool.ts +4 -2
  67. package/src/runs/foreground/async-stop-action.ts +93 -3
  68. package/src/runs/foreground/execution.ts +115 -219
  69. package/src/runs/foreground/foreground-history.ts +2 -1
  70. package/src/runs/foreground/subagent-executor.ts +281 -80
  71. package/src/runs/shared/acceptance.ts +194 -37
  72. package/src/runs/shared/async-status-projection.ts +123 -33
  73. package/src/runs/shared/child-launch-plan.ts +15 -3
  74. package/src/runs/shared/child-launch.ts +19 -6
  75. package/src/runs/shared/child-runtime-config.ts +5 -0
  76. package/src/runs/shared/child-session.ts +94 -50
  77. package/src/runs/shared/child-tool-plan.ts +28 -16
  78. package/src/runs/shared/dynamic-fanout.ts +2 -2
  79. package/src/runs/shared/external-cli-contract.ts +11 -1
  80. package/src/runs/shared/external-cli-preflight.ts +6 -2
  81. package/src/runs/shared/herdr-connection.ts +134 -0
  82. package/src/runs/shared/herdr-external-adapters.ts +169 -0
  83. package/src/runs/shared/herdr-machine.ts +279 -0
  84. package/src/runs/shared/herdr-pi-protocol.ts +59 -0
  85. package/src/runs/shared/herdr-placed-run.ts +263 -0
  86. package/src/runs/shared/model-resolution-diagnostic.ts +76 -0
  87. package/src/runs/shared/{model-fallback.ts → model-resolution.ts} +22 -237
  88. package/src/runs/shared/model-scope.ts +1 -1
  89. package/src/runs/shared/nested-events.ts +11 -2
  90. package/src/runs/shared/parallel-utils.ts +7 -2
  91. package/src/runs/shared/pi-spawn.ts +1 -1
  92. package/src/runs/shared/subagent-prompt-runtime.ts +4 -2
  93. package/src/runs/shared/worktree-setup-command.ts +27 -4
  94. package/src/runs/shared/worktree.ts +30 -8
  95. package/src/shared/child-cache-retention.ts +43 -0
  96. package/src/shared/launch-contract.ts +6 -9
  97. package/src/shared/pruned-fork.ts +1 -1
  98. package/src/shared/required-child-extensions.ts +81 -0
  99. package/src/shared/settings.ts +5 -2
  100. package/src/shared/shortcuts.ts +0 -4
  101. package/src/shared/types.ts +81 -29
  102. package/src/slash/slash-commands.ts +0 -6
  103. package/src/slash/subagents-admin.ts +13 -9
  104. package/src/tui/render.ts +20 -10
  105. package/src/watchdog/child-status.ts +28 -36
  106. package/src/watchdog/lsp-diagnostics.ts +1 -1
  107. package/src/watchdog/model-selection.ts +1 -1
  108. package/src/watchdog/register-child.ts +10 -3
  109. package/src/watchdog/register-main.ts +20 -20
  110. package/src/watchdog/render.ts +1 -1
  111. package/src/watchdog/review.ts +14 -30
  112. package/src/watchdog/rules.ts +1 -1
  113. package/src/watchdog/runtime.ts +23 -12
  114. package/src/watchdog/settings.ts +3 -6
  115. package/src/watchdog/types.ts +3 -5
  116. package/src/watchdog/warning-format.ts +1 -1
  117. package/src/workflows/scripted-workflow.ts +68 -7
  118. package/src/workflows/workflow-receipt.ts +21 -3
  119. package/src/workflows/workflow-resources.ts +13 -2
  120. package/src/runs/shared/model-exclusions.ts +0 -374
  121. package/src/runs/shared/readonly-model-continuation.ts +0 -69
  122. package/src/runs/shared/readonly-session-evidence.ts +0 -307
@@ -1,17 +1,11 @@
1
1
  import { splitKnownThinkingSuffix as splitThinkingSuffix, type ModelInfo as AvailableModelInfo } from "../../shared/model-info.ts";
2
- import type { Usage } from "../../shared/types.ts";
3
- import { filterFallbackCandidates, findModelExclusion, parseModelKey, recordModelFailure } from "./model-exclusions.ts";
4
2
  import { checkModelScope, type ModelScopeCheckRule, type ModelScopeViolation, type ModelSource } from "./model-scope.ts";
5
- import { redactSecretValues } from "./permissions.ts";
6
3
 
7
4
  export type { AvailableModelInfo };
8
5
 
9
- interface ModelAttemptSummary {
10
- model: string;
11
- success: boolean;
12
- exitCode?: number | null;
13
- error?: string;
14
- usage?: Usage;
6
+ export interface ModelSelectionEvidence {
7
+ model?: string;
8
+ requestedModel?: string;
15
9
  }
16
10
 
17
11
  export { splitThinkingSuffix };
@@ -290,57 +284,6 @@ function enforceModelScopes(
290
284
  for (const violation of violations) (onWarn ?? defaultScopeWarn)(violation);
291
285
  }
292
286
 
293
- const MODEL_EXCLUSION_DIAGNOSTIC_MAX_LENGTH = 240;
294
- const MODEL_EXCLUSION_DIAGNOSTIC_MAX_ENTRIES = 20;
295
-
296
- function sanitizeModelExclusionDiagnostic(value: string | undefined, fallback: string): string {
297
- const normalized = typeof value === "string"
298
- ? value.replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, " ").trim()
299
- : "";
300
- return redactSecretValues(normalized || fallback).slice(0, MODEL_EXCLUSION_DIAGNOSTIC_MAX_LENGTH);
301
- }
302
-
303
- function formatModelExclusionExpiry(expiresAt: number): string {
304
- if (!Number.isFinite(expiresAt)) return "unknown";
305
- const date = new Date(expiresAt);
306
- return Number.isNaN(date.getTime()) ? "unknown" : date.toISOString();
307
- }
308
-
309
- function formatExcludedCandidateEvidence(candidate: string, exclusion: NonNullable<ReturnType<typeof findModelExclusion>>): string {
310
- const { provider, modelId } = parseModelKey(candidate);
311
- const displayCandidate = sanitizeModelExclusionDiagnostic(candidate, "unknown");
312
- const displayModel = sanitizeModelExclusionDiagnostic(modelId, "unknown");
313
- const displayProvider = sanitizeModelExclusionDiagnostic(provider ?? exclusion.provider, "unspecified");
314
- const reason = sanitizeModelExclusionDiagnostic(exclusion.reason, "runtime-failure");
315
- return `${displayCandidate} — model: ${displayModel}; provider: ${displayProvider}; reason: ${reason}; expires: ${formatModelExclusionExpiry(exclusion.expiresAt)}`;
316
- }
317
-
318
- const MODEL_UNAVAILABLE_EXCLUSION_PATTERNS = [
319
- /model.*not found/i,
320
- /unknown model/i,
321
- /model.*unavailable/i,
322
- /model.*disabled/i,
323
- ];
324
-
325
- function isCurrentRegistryModel(candidate: string, availableModels: AvailableModelInfo[] | undefined): boolean {
326
- if (!availableModels || availableModels.length === 0) return false;
327
- const { baseModel } = splitThinkingSuffix(candidate);
328
- return availableModels.some((entry) => entry.fullId === baseModel);
329
- }
330
-
331
- function ignoreStaleModelUnavailableExclusion(candidate: string, exclusion: NonNullable<ReturnType<typeof findModelExclusion>>, availableModels: AvailableModelInfo[] | undefined): boolean {
332
- const reason = exclusion.reason ?? "";
333
- return MODEL_UNAVAILABLE_EXCLUSION_PATTERNS.some((pattern) => pattern.test(reason)) && isCurrentRegistryModel(candidate, availableModels);
334
- }
335
-
336
- function throwForExplicitModelExclusion(model: string): void {
337
- const exclusion = findModelExclusion(model);
338
- if (!exclusion) return;
339
- const reason = redactSecretValues((exclusion.reason ?? "runtime-failure").replace(/[\u0000-\u001f\u007f]+/g, " ")).slice(0, 240);
340
- const expiry = Number.isFinite(exclusion.expiresAt) ? `; expires: ${new Date(exclusion.expiresAt).toISOString()}` : "";
341
- throw new Error(`Requested subagent model '${model}' is excluded and cannot be replaced by a fallback (reason: ${reason}${expiry}).`);
342
- }
343
-
344
287
  /**
345
288
  * Resolve the `--model` override passed to a spawned subagent.
346
289
  *
@@ -377,7 +320,6 @@ export function resolveSubagentModelOverride(
377
320
  const candidate = resolveSubagentModelCandidate(explicit, availableModels, preferredProvider);
378
321
  if (options?.source === "explicit") {
379
322
  resolved = candidate ?? resolveRequiredSubagentModelCandidate(explicit, availableModels, preferredProvider);
380
- throwForExplicitModelExclusion(resolved);
381
323
  resolvedFromRegistry = true;
382
324
  } else if (candidate) {
383
325
  resolved = candidate;
@@ -421,19 +363,15 @@ export function resolveEffectiveSubagentModel(
421
363
 
422
364
  export type ModelOrigin = ModelSource | "configured";
423
365
 
424
- export interface BuildModelCandidatesOptions {
425
- /** Fallback models warn by default and throw when strict scope enforcement is enabled. */
366
+ export interface ResolveModelSelectionOptions {
426
367
  scope?: ModelScopeCheckRule | ModelScopeCheckRule[];
427
368
  onWarn?: (violation: ModelScopeViolation) => void;
428
369
  /** The primary model came from the running parent session, not configuration. */
429
370
  primaryModelFromParent?: boolean;
430
- /** How the primary model was selected. Explicit stays strict and does not rotate to fallbacks. */
371
+ /** How the model was selected. */
431
372
  origin?: ModelOrigin;
432
373
  }
433
374
 
434
- const ZERO_USABLE_MODEL_CANDIDATES_ERROR =
435
- "No usable subagent models remain after registry, scope, and cached-exclusion filtering.";
436
-
437
375
  export function resolveModelOrigin(input: {
438
376
  explicitModel?: string | boolean;
439
377
  agentModel?: string | boolean;
@@ -458,176 +396,30 @@ export function inheritsParentModel(
458
396
  return Boolean(parentModel && (!trimmed || trimmed === INHERIT_MODEL));
459
397
  }
460
398
 
461
- export function buildModelCandidates(
462
- primaryModel: string | undefined,
463
- fallbackModels: string[] | undefined,
399
+ export function resolveModelSelection(
400
+ model: string | undefined,
464
401
  availableModels: AvailableModelInfo[] | undefined,
465
402
  preferredProvider?: string,
466
- options?: BuildModelCandidatesOptions,
467
- ): string[] {
468
- if (!primaryModel) throwForUnresolvedEnforcedInheritScope(options?.scope, true);
403
+ options?: ResolveModelSelectionOptions,
404
+ ): ModelSelectionEvidence {
405
+ if (!model) throwForUnresolvedEnforcedInheritScope(options?.scope, true);
469
406
  const origin = options?.origin ?? (options?.primaryModelFromParent ? "inherited" : "configured");
407
+ const requestedModel = origin === "inherited" ? undefined : model;
470
408
  const scopes = configuredScopes(options?.scope);
471
- type ExcludedCandidate = { candidate: string; exclusion: NonNullable<ReturnType<typeof findModelExclusion>> };
472
- const excludedCandidates: ExcludedCandidate[] = [];
473
- let excludedCandidateCount = 0;
474
- const warnCachedExclusion = (candidate: string, exclusion: NonNullable<ReturnType<typeof findModelExclusion>>) => {
475
- excludedCandidateCount++;
476
- if (excludedCandidates.length < MODEL_EXCLUSION_DIAGNOSTIC_MAX_ENTRIES) excludedCandidates.push({ candidate, exclusion });
477
- const displayCandidate = sanitizeModelExclusionDiagnostic(candidate, "unknown");
478
- const reason = sanitizeModelExclusionDiagnostic(exclusion.reason, "runtime-failure");
479
- console.warn(`[pi-subagents] Skipping model '${displayCandidate}' due to a cached exclusion (reason: ${reason}; expires: ${formatModelExclusionExpiry(exclusion.expiresAt)}).`);
480
- };
481
- if (origin === "explicit" && primaryModel) {
482
- const normalized = resolveRequiredSubagentModelCandidate(primaryModel.trim(), availableModels, preferredProvider);
483
- throwForExplicitModelExclusion(normalized);
409
+ if (origin === "explicit" && model) {
410
+ const normalized = resolveRequiredSubagentModelCandidate(model.trim(), availableModels, preferredProvider);
484
411
  enforceModelScopes(normalized, scopes, "explicit", options?.onWarn);
485
- primaryModel = normalized;
486
- }
487
- const seen = new Set<string>();
488
- const candidates: string[] = [];
489
- const rawCandidates = [primaryModel, ...(fallbackModels ?? [])];
490
- let skippedPrimary: string | undefined;
491
- let skippedFallback: string | undefined;
492
- for (let index = 0; index < rawCandidates.length; index++) {
493
- const raw = rawCandidates[index];
494
- if (!raw) continue;
495
- const model = raw.trim();
496
- const normalized = index === 0 && (origin === "inherited" || origin === "explicit" || options?.primaryModelFromParent)
497
- ? model
498
- : resolveSubagentModelCandidate(model, availableModels, preferredProvider);
499
- if (!normalized) {
500
- if (index === 0) skippedPrimary = model;
501
- else {
502
- skippedFallback ??= model;
503
- console.warn(`[pi-subagents] Skipping fallback model '${model}' because it is unavailable in this environment.`);
504
- }
505
- continue;
506
- }
507
- if (seen.has(normalized)) continue;
508
- if (index > 0 || scopes.some((scope) => scope.enforce === true && scope.strict === true)) {
509
- enforceModelScopes(normalized, scopes, "inherited", options?.onWarn);
510
- }
511
- seen.add(normalized);
512
- candidates.push(normalized);
513
- }
514
- const resolved = filterFallbackCandidates(candidates, {
515
- onExcluded: warnCachedExclusion,
516
- ignoreExclusion: (candidate, exclusion) => ignoreStaleModelUnavailableExclusion(candidate, exclusion, availableModels),
517
- });
518
- if (resolved.length === 0) {
519
- if (skippedPrimary) resolveRequiredSubagentModelCandidate(skippedPrimary, availableModels, preferredProvider);
520
- if (candidates.length === 0 && skippedFallback) resolveRequiredSubagentModelCandidate(skippedFallback, availableModels, preferredProvider);
521
- if (candidates.length > 0) {
522
- const shownExclusions = excludedCandidates;
523
- const omittedExclusions = excludedCandidateCount - shownExclusions.length;
524
- const evidence = shownExclusions.length > 0
525
- ? ` (excluded: ${shownExclusions.map(({ candidate, exclusion }) => formatExcludedCandidateEvidence(candidate, exclusion)).join("; ")}${omittedExclusions > 0 ? `; ... and ${omittedExclusions} more` : ""})`
526
- : "";
527
- throw new Error(`${ZERO_USABLE_MODEL_CANDIDATES_ERROR}${evidence}`);
528
- }
529
- return resolved;
412
+ model = normalized;
530
413
  }
531
- if (skippedPrimary) {
532
- console.warn(`[pi-subagents] Skipping primary model '${skippedPrimary}' because it is unavailable in this environment.`);
414
+ const resolved = model && (origin === "inherited" || origin === "explicit" || options?.primaryModelFromParent)
415
+ ? model.trim()
416
+ : model ? resolveRequiredSubagentModelCandidate(model.trim(), availableModels, preferredProvider) : undefined;
417
+ if (resolved && scopes.some((scope) => scope.enforce === true && scope.strict === true)) {
418
+ enforceModelScopes(resolved, scopes, "inherited", options?.onWarn);
533
419
  }
534
- return resolved;
535
- }
536
-
537
- const RETRYABLE_MODEL_FAILURE_PATTERNS = [
538
- /^REQUEST_LIMIT_EXCEEDED$/,
539
- /rate\s*limit/i,
540
- /usage\s*limit/i,
541
- /too many requests/i,
542
- /\b429\b/,
543
- /quota/i,
544
- /billing/i,
545
- /credit/i,
546
- // OpenRouter can return only a status-prefixed body, without auth-related prose.
547
- /^\s*401\s*:/,
548
- /auth(?:entication)?/i,
549
- /unauthori[sz]ed/i,
550
- /forbidden/i,
551
- /api key/i,
552
- /token expired/i,
553
- /invalid key/i,
554
- /provider.*unavailable/i,
555
- /model.*unavailable/i,
556
- /model.*disabled/i,
557
- /model.*not found/i,
558
- /unknown model/i,
559
- /overloaded/i,
560
- /service unavailable/i,
561
- /temporar(?:ily)? unavailable/i,
562
- /connection\s+(?:error|reset|closed|aborted)/i,
563
- /connection refused/i,
564
- /fetch failed/i,
565
- /network error/i,
566
- /socket hang up/i,
567
- /stream ended without finish_reason/i,
568
- /upstream/i,
569
- /timed? out/i,
570
- /timeout/i,
571
- /\b500\b/,
572
- /\b502\b/,
573
- /\b503\b/,
574
- /\b504\b/,
575
- /internal server error/i,
576
- /cold.?start/i,
577
- /empty response/i,
578
- /no output/i,
579
- /model.*(?:load|fail|error)/i,
580
- ];
581
-
582
- /**
583
- * Failures reported as `<tool> failed (exit N): ...` or `<tool> failed with
584
- * exit code N` come from a tool call inside the child's task, not from the
585
- * provider/model, however network-flavored their details read. Retrying a
586
- * different model cannot fix them and would rerun the whole task. Tool names
587
- * include namespaced forms like `mcp.server/write`.
588
- */
589
- const TOOL_FAILURE_PREFIX = /^[\w.:@/-]+ failed (?:(?:\(exit \d+\):)|(?:with exit code \d+))(?:\s|$)/i;
590
-
591
- export function isRetryableModelFailure(error: string | undefined): boolean {
592
- if (!error) return false;
593
- if (TOOL_FAILURE_PREFIX.test(error.trim())) return false;
594
- return RETRYABLE_MODEL_FAILURE_PATTERNS.some((pattern) => pattern.test(error));
595
- }
596
-
597
- function messageError(message: unknown): string | undefined {
598
- if (!message || typeof message !== "object") return undefined;
599
- const value = (message as { errorMessage?: unknown }).errorMessage;
600
- return typeof value === "string" ? value : undefined;
601
- }
602
-
603
- export function isRetryableModelFailureAttempt(input: { error: string | undefined; messages?: readonly unknown[]; toolCount?: number }): boolean {
604
- if (!isRetryableModelFailure(input.error)) return false;
605
- if ((input.toolCount ?? 0) > 0) return false;
606
- if (input.error === "Subagent produced no output (possible model cold-start or empty response)." || /^Subagent produced no output after terminal assistant stopReason "[^"]+"\.$/.test(input.error ?? "")) return true;
607
- if ((input.toolCount ?? 0) === 0 && (input.messages?.length ?? 0) === 0) return true;
608
- const error = input.error?.trim();
609
- return Boolean(error && input.messages?.some((message) => messageError(message)?.trim() === error));
610
- }
611
-
612
- // Request-shape failures can match broad fallback signals such as "upstream",
613
- // but do not establish that the model is unhealthy for subsequent requests.
614
- const REQUEST_SHAPE_FAILURE_PATTERN = /\b(?:bad[ _]request|invalid[ _]argument|invalid_request_error)\b/i;
615
-
616
- export function recordRetryableModelFailure(model: string | undefined, error: string | undefined): void {
617
- if (!model || !error || !isRetryableModelFailure(error) || isContextOverflow(error)) return;
618
- if (REQUEST_SHAPE_FAILURE_PATTERN.test(error)) return;
619
- const { provider, modelId } = parseModelKey(model);
620
- recordModelFailure({ modelId, reason: error, ...(provider ? { provider } : {}) });
420
+ return { ...(resolved ? { model: resolved } : {}), ...(requestedModel ? { requestedModel } : {}) };
621
421
  }
622
-
623
- /**
624
- * Context-overflow signals. These are deliberately NOT part of
625
- * {@link RETRYABLE_MODEL_FAILURE_PATTERNS}: an overflow means the input was too
626
- * large for the model's context window, so retrying the same input on another
627
- * model (or the same model again) cannot succeed. Callers should treat overflow
628
- * as a terminal, non-retryable failure and surface a clear "input too large"
629
- * error instead of burning fallback attempts on a guaranteed failure.
630
- */
422
+ /** Context-overflow signals used to surface a clear input-too-large error. */
631
423
  const CONTEXT_OVERFLOW_PATTERNS = [
632
424
  /context(?: length| window| limit)? (?:exceed|overflow|too long)/i,
633
425
  /maximum context length/i,
@@ -644,13 +436,6 @@ const CONTEXT_OVERFLOW_PATTERNS = [
644
436
 
645
437
  export function isContextOverflow(error: string | undefined): boolean {
646
438
  if (!error) return false;
647
- if (TOOL_FAILURE_PREFIX.test(error.trim())) return false;
439
+ if (/^[\w.:@/-]+ failed (?:(?:\(exit \d+\):)|(?:with exit code \d+))(?:\s|$)/i.test(error.trim())) return false;
648
440
  return CONTEXT_OVERFLOW_PATTERNS.some((pattern) => pattern.test(error));
649
441
  }
650
-
651
- export function formatModelAttemptNote(attempt: ModelAttemptSummary, nextModel?: string): string {
652
- const failure = attempt.error?.trim() || `exit ${attempt.exitCode ?? 1}`;
653
- return nextModel
654
- ? `[fallback] ${attempt.model} failed: ${failure}. Retrying with ${nextModel}.`
655
- : `[fallback] ${attempt.model} failed: ${failure}.`;
656
- }
@@ -17,7 +17,7 @@ import { splitKnownThinkingSuffix } from "../../shared/model-info.ts";
17
17
 
18
18
  export interface ModelScopeRule {
19
19
  enforce?: boolean;
20
- /** Reject inherited and fallback models outside the allowlist instead of warning. */
20
+ /** Reject inherited models outside the allowlist instead of warning. */
21
21
  strict?: boolean;
22
22
  /** Glob-style allow patterns (only `*` is special), matched against `provider/id`. */
23
23
  allow?: string[];
@@ -232,10 +232,12 @@ function sanitizeLaunchResolvedExtensions(value: unknown): LaunchResolvedChildEx
232
232
  disableAmbientExtensions: raw.disableAmbientExtensions,
233
233
  runtime: stringList(raw.runtime),
234
234
  configured: stringList(raw.configured),
235
+ required: Array.isArray(raw.required) ? raw.required.filter((item): item is string => typeof item === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(item)).slice(0, 32) : [],
235
236
  effective: stringList(raw.effective),
236
237
  omitted: {
237
238
  runtime: omittedCount("runtime"),
238
239
  configured: omittedCount("configured"),
240
+ required: omittedCount("required"),
239
241
  effective: omittedCount("effective"),
240
242
  },
241
243
  };
@@ -1063,8 +1065,15 @@ export function isTopLevelAsyncDir(asyncDir: string): boolean {
1063
1065
  return containedPath(DIRS.async, resolved) && !containedPath(path.join(TEMP_ROOT_DIR, "nested-subagent-runs"), resolved);
1064
1066
  }
1065
1067
 
1066
- export function nestedResultsPath(rootRunId: string, id: string): string {
1068
+ export function nestedRunScope(rootRunId: string) {
1067
1069
  assertSafeId("rootRunId", rootRunId);
1070
+ return {
1071
+ asyncDirRoot: path.join(TEMP_ROOT_DIR, "nested-subagent-runs", rootRunId),
1072
+ resultsDir: path.join(DIRS.results, "nested", rootRunId),
1073
+ };
1074
+ }
1075
+
1076
+ export function nestedResultsPath(rootRunId: string, id: string): string {
1068
1077
  assertSafeId("id", id);
1069
- return path.join(DIRS.results, "nested", rootRunId, `${id}.json`);
1078
+ return path.join(nestedRunScope(rootRunId).resultsDir, `${id}.json`);
1070
1079
  }
@@ -10,6 +10,10 @@ export interface RunnerSubagentStep {
10
10
  sessionName?: string;
11
11
  task: string;
12
12
  runner?: ResolvedRunnerConfig;
13
+ /** Herdr saved machine this external-cli step runs on; `cwd` is then the directory on that machine. */
14
+ machine?: import("../../shared/types.ts").HerdrMachineReference;
15
+ remoteReads?: string[] | false;
16
+ machineEnv?: Record<string, string>;
13
17
  externalJobFollowUp?: {
14
18
  sourceRunId: string;
15
19
  sourceStepIndex: number;
@@ -37,7 +41,7 @@ export interface RunnerSubagentStep {
37
41
  fast?: boolean;
38
42
  thinking?: string;
39
43
  thinkingCeiling?: import("../../shared/model-info.ts").ThinkingLevel;
40
- modelCandidates?: string[];
44
+ requestedModel?: string;
41
45
  /** The primary model is inherited from the parent session and should not be verified against the child-reported active registry model. */
42
46
  skipPrimaryModelVerification?: boolean;
43
47
  modelVerificationRegistry?: Array<{ provider: string; id: string; fullId: string; contextWindow?: number }>;
@@ -47,6 +51,8 @@ export interface RunnerSubagentStep {
47
51
  allowNestedSubagents?: boolean;
48
52
  extensions?: string[];
49
53
  subagentOnlyExtensions?: string[];
54
+ /** Private immutable host policy snapshot serialized to the native runner. */
55
+ requiredExtensions?: import("../../shared/required-child-extensions.ts").RequiredChildExtensionSnapshot;
50
56
  mcpDirectTools?: string[];
51
57
  mutationTools?: string[];
52
58
  completionGuard?: boolean;
@@ -237,7 +243,6 @@ export interface ParallelTaskResult {
237
243
  error?: string;
238
244
  timedOut?: boolean;
239
245
  model?: string;
240
- attemptedModels?: string[];
241
246
  outputTargetPath?: string;
242
247
  outputTargetExists?: boolean;
243
248
  }
@@ -63,7 +63,7 @@ export interface PiSpawnDeps {
63
63
  export function resolveBunPiExecutable(deps: PiSpawnDeps = {}): string | undefined {
64
64
  const bunVersion = deps.bunVersion ?? process.versions.bun;
65
65
  const entry = deps.argv1 ?? process.argv[1];
66
- if (!bunVersion || !entry?.startsWith("/$bunfs/")) return undefined;
66
+ if (!bunVersion || !entry || !/^(?:\/\$bunfs\/|B:[\\/]~BUN[\\/])/.test(entry)) return undefined;
67
67
  const env = deps.env ?? process.env;
68
68
  return env[PI_SUBAGENT_PI_BINARY_ENV]?.trim() || (deps.execPath ?? process.execPath);
69
69
  }
@@ -17,6 +17,7 @@ import { registerChildWatchdog } from "../../watchdog/register-child.ts";
17
17
  import type { ChildWatchdogConfig } from "../../watchdog/child-status.ts";
18
18
  import { requestWatchdogPermission, type WatchdogPermissionRequest, type WatchdogPermissionResult } from "../../watchdog/permission-arbiter.ts";
19
19
  import { SUBAGENT_WATCHDOG_WARNING_TYPE } from "../../watchdog/types.ts";
20
+ import { inheritedNestedRouteOf } from "./nested-events.ts";
20
21
  import { registerWaitTool } from "../background/wait-tool.ts";
21
22
  import { drainOutstandingWork } from "../background/auto-drain.ts";
22
23
  import {
@@ -466,7 +467,8 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI, config?:
466
467
  watcherRestartTimer: null,
467
468
  resultFileCoalescer: { schedule: () => false, clear: () => {} },
468
469
  } as unknown as SubagentState;
469
- if (typeof pi.registerTool === "function") registerWaitTool(pi, waitState, config.waitTool.enabled, undefined, config.waitTool.defaultTimeoutMs);
470
+ const nestedRootRunId = inheritedNestedRouteOf(config)?.rootRunId;
471
+ if (typeof pi.registerTool === "function") registerWaitTool(pi, waitState, config.waitTool.enabled, undefined, config.waitTool.defaultTimeoutMs, { nestedRootRunId });
470
472
  const supervisorMetadata = childSupervisorMetadata(config);
471
473
  let nativeSupervisorClientRegistered = false;
472
474
  const registerNativeSupervisorClientOnce = (): void => {
@@ -495,7 +497,7 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI, config?:
495
497
  }
496
498
  config.holdFinalDrain?.(true);
497
499
  try {
498
- await drainOutstandingWork({ state: waitState, events: pi.events }, drainObservation);
500
+ await drainOutstandingWork({ state: waitState, events: pi.events, nestedRootRunId, hasPendingSupervisorRequest: config.hasPendingSupervisorRequest }, drainObservation);
499
501
  } finally {
500
502
  config.holdFinalDrain?.(false);
501
503
  }
@@ -2,6 +2,26 @@ import { spawn } from "node:child_process";
2
2
  import { createOwnedProcessTreeController } from "../background/owned-process-tree.ts";
3
3
  import type { ProcessTreeTerminal } from "../../shared/types.ts";
4
4
 
5
+ function terminateCommandTree(pid: number, controller: ReturnType<typeof createOwnedProcessTreeController>): Promise<ProcessTreeTerminal> {
6
+ if (process.platform !== "win32") return controller.terminate();
7
+ return new Promise((resolve) => {
8
+ let settled = false;
9
+ const fallback = () => {
10
+ if (settled) return;
11
+ settled = true;
12
+ void controller.terminate().then(resolve);
13
+ };
14
+ const cleanup = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
15
+ cleanup.once("error", fallback);
16
+ cleanup.once("close", (status) => {
17
+ if (settled) return;
18
+ if (status !== 0) return fallback();
19
+ settled = true;
20
+ resolve({ state: "observed", mechanism: "windows-taskkill", pid, verifiedAt: Date.now() });
21
+ });
22
+ });
23
+ }
24
+
5
25
  export interface SetupCommandOptions {
6
26
  cwd?: string;
7
27
  env?: NodeJS.ProcessEnv;
@@ -19,6 +39,7 @@ export interface SetupCommandOptions {
19
39
 
20
40
  export interface SetupCommandResult {
21
41
  stdout: string;
42
+ stdoutBuffer: Buffer;
22
43
  stderr: string;
23
44
  status: number | null;
24
45
  signal: NodeJS.Signals | null;
@@ -46,7 +67,7 @@ export async function runSetupCommand(
46
67
  options: SetupCommandOptions,
47
68
  ): Promise<SetupCommandResult> {
48
69
  const result: SetupCommandResult = {
49
- stdout: "", stderr: "", status: null, signal: null, outputIncomplete: false,
70
+ stdout: "", stdoutBuffer: Buffer.alloc(0), stderr: "", status: null, signal: null, outputIncomplete: false,
50
71
  };
51
72
  const maxBuffer = options.maxBuffer ?? 1024 * 1024;
52
73
  if (!Number.isSafeInteger(maxBuffer) || maxBuffer <= 0) throw new Error("Invalid setup command maxBuffer");
@@ -84,7 +105,8 @@ export async function runSetupCommand(
84
105
  let directSettled = false;
85
106
  const releaseUnknownIO = () => {
86
107
  if (!directSettled || result.processTree?.state !== "unknown") return;
87
- result.error ??= commandError("Worktree setup process tree settlement is unverified", "PROCESS_TREE_UNVERIFIED");
108
+ const detail = result.processTree.diagnostic ? `: ${result.processTree.diagnostic}` : "";
109
+ result.error = commandError(`Worktree setup process tree settlement is unverified${detail}`, "PROCESS_TREE_UNVERIFIED");
88
110
  result.outputIncomplete = true;
89
111
  // Only local I/O is released; unknown descendant ownership remains retained.
90
112
  child.stdin.destroy();
@@ -101,7 +123,7 @@ export async function runSetupCommand(
101
123
  releaseUnknownIO();
102
124
  return;
103
125
  }
104
- termination = tree.terminate().then((proof) => {
126
+ termination = terminateCommandTree(result.pid!, tree).then((proof) => {
105
127
  result.processTree = proof;
106
128
  releaseUnknownIO();
107
129
  return proof;
@@ -177,7 +199,8 @@ export async function runSetupCommand(
177
199
  if (termination) result.processTree = await termination;
178
200
  const cancelled = cancellation();
179
201
  if (cancelled) fail(cancelled);
180
- result.stdout = Buffer.concat(stdout).toString("utf8");
202
+ result.stdoutBuffer = Buffer.concat(stdout);
203
+ result.stdout = result.stdoutBuffer.toString("utf8");
181
204
  result.stderr = Buffer.concat(stderr).toString("utf8");
182
205
  return result;
183
206
  } finally {
@@ -180,7 +180,7 @@ export interface WorktreeSetupProgress {
180
180
  setup: WorktreeSetup;
181
181
  attempts: Array<{ index: number; branch: string; path?: string; validated: boolean; command?: WorktreeSetupProgress["command"]; hookCommand?: WorktreeSetupProgress["command"] }>;
182
182
  phase: string;
183
- command?: { command: string; args: string[]; pid?: number; processGroupId?: number; result?: Omit<SetupCommandResult, "stdout" | "stderr"> };
183
+ command?: { command: string; args: string[]; pid?: number; processGroupId?: number; result?: Omit<SetupCommandResult, "stdout" | "stdoutBuffer" | "stderr"> };
184
184
  unknown?: string;
185
185
  cleanup?: WorktreeCleanupReport;
186
186
  }
@@ -245,7 +245,7 @@ class SetupTransaction {
245
245
  ...options, signal: this.options.signal, deadlineAt: this.options.deadlineAt,
246
246
  onSpawn: (process) => { Object.assign(this.progress.command!, process); this.publish(); },
247
247
  });
248
- const { stdout: _stdout, stderr: _stderr, ...metadata } = result;
248
+ const { stdout: _stdout, stdoutBuffer: _stdoutBuffer, stderr: _stderr, ...metadata } = result;
249
249
  this.progress.command.result = metadata;
250
250
  if (result.processTree?.state === "unknown") this.unknown(result.error ?? "Command tree settlement unverified");
251
251
  this.publish();
@@ -743,13 +743,35 @@ export function resolveExpectedWorktreeAgentCwd(cwd: string, runId: string, inde
743
743
  function linkNodeModulesIfPresent(toplevel: string, worktreePath: string): boolean {
744
744
  const nodeModulesPath = path.join(toplevel, "node_modules");
745
745
  const nodeModulesLinkPath = path.join(worktreePath, "node_modules");
746
- if (!fs.existsSync(nodeModulesPath) || fs.existsSync(nodeModulesLinkPath)) return false;
746
+ const hasDirectoryEntry = (candidate: string): boolean => {
747
+ try { fs.lstatSync(candidate); return true; }
748
+ catch (error) {
749
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
750
+ throw error;
751
+ }
752
+ };
747
753
  try {
748
- fs.symlinkSync(nodeModulesPath, nodeModulesLinkPath);
754
+ if (hasDirectoryEntry(nodeModulesLinkPath)) return false;
755
+ let sourceRealPath: string;
756
+ try {
757
+ if (!fs.statSync(nodeModulesPath).isDirectory()) throw new Error("source node_modules is not a directory");
758
+ sourceRealPath = fs.realpathSync.native(nodeModulesPath);
759
+ } catch (error) {
760
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
761
+ throw error;
762
+ }
763
+ fs.symlinkSync(nodeModulesPath, nodeModulesLinkPath, process.platform === "win32" ? "junction" : "dir");
764
+ if (!fs.lstatSync(nodeModulesLinkPath).isSymbolicLink()
765
+ || fs.realpathSync.native(nodeModulesLinkPath) !== sourceRealPath) {
766
+ throw new Error("created link does not resolve to the source node_modules");
767
+ }
749
768
  return true;
750
- } catch {
751
- // Symlink creation is optional (e.g., unsupported filesystems on CI runners).
752
- return false;
769
+ } catch (error) {
770
+ const code = error instanceof Error && "code" in error && typeof error.code === "string" ? `${error.code}: ` : "";
771
+ throw new Error(
772
+ `failed to link node_modules from ${nodeModulesPath} to ${nodeModulesLinkPath}: ${code}${error instanceof Error ? error.message : String(error)}`,
773
+ { cause: error },
774
+ );
753
775
  }
754
776
  }
755
777
 
@@ -1295,7 +1317,7 @@ async function compensateSetup(tx: SetupTransaction): Promise<WorktreeCleanupRep
1295
1317
  deadlineAt: tx.options.deadlineAt, acceptedExitCodes,
1296
1318
  onSpawn: (process) => { Object.assign(tx.progress.command!, process); tx.publish(); },
1297
1319
  });
1298
- const { stdout: _stdout, stderr: _stderr, ...metadata } = result;
1320
+ const { stdout: _stdout, stdoutBuffer: _stdoutBuffer, stderr: _stderr, ...metadata } = result;
1299
1321
  tx.progress.command.result = metadata;
1300
1322
  if (result.processTree?.state === "unknown") tx.unknown(result.error ?? "Rollback command settlement unverified");
1301
1323
  tx.publish();
@@ -0,0 +1,43 @@
1
+ import type { StreamFn } from "@earendil-works/pi-agent-core";
2
+
3
+ /**
4
+ * Anthropic prices a cache write by the TTL it is asked for: 1.25x base input
5
+ * for the 5m window, 2x for the 1h one. Child sessions are short-lived and
6
+ * rarely idle, so a 1h window they never claim is a flat surcharge on every
7
+ * write. Setting this to `short` keeps that surcharge off children while the
8
+ * parent keeps `long`, the same split Claude Code makes between its main
9
+ * conversation and its subagents.
10
+ *
11
+ * Unset means children inherit the parent's retention, which is the behaviour
12
+ * before this setting existed.
13
+ */
14
+ export function childCacheRetention(env: NodeJS.ProcessEnv = process.env): string | undefined {
15
+ return env.PI_SUBAGENT_CACHE_RETENTION || undefined;
16
+ }
17
+
18
+ /**
19
+ * Launch-environment form for spawned children. Empty when unset, so the child
20
+ * inherits the parent's `PI_CACHE_RETENTION` rather than having it cleared.
21
+ */
22
+ export function childCacheRetentionEnv(env?: NodeJS.ProcessEnv): { PI_CACHE_RETENTION?: string } {
23
+ const retention = childCacheRetention(env);
24
+ return retention ? { PI_CACHE_RETENTION: retention } : {};
25
+ }
26
+
27
+ /**
28
+ * Pi resolves retention per request as `options.env?.[name] || process.env[name]`,
29
+ * so a per-call env beats the process-wide one. Wrapping the session's own
30
+ * stream function keeps this scoped to one child, with no shared-state race
31
+ * against a parent turn streaming concurrently.
32
+ */
33
+ export function pinChildCacheRetention(agent: { streamFunction: StreamFn } | undefined, env?: NodeJS.ProcessEnv): void {
34
+ if (!agent?.streamFunction) return;
35
+ const retention = childCacheRetention(env);
36
+ if (!retention) return;
37
+ const base = agent.streamFunction;
38
+ agent.streamFunction = (model, context, options) =>
39
+ base(model, context, {
40
+ ...options,
41
+ env: { ...(options?.env ?? {}), PI_CACHE_RETENTION: retention },
42
+ });
43
+ }