pi-subagents 0.67.0 → 0.68.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 (120) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +1 -1
  3. package/docs/agents.md +37 -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 +15 -12
  11. package/docs/watchdog.md +10 -12
  12. package/docs/workflows.md +11 -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 +4 -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 +184 -71
  27. package/src/agents/chain-serializer.ts +5 -0
  28. package/src/agents/runtime-agent-registry.ts +7 -6
  29. package/src/api/preflight.ts +20 -16
  30. package/src/api/required-child-extensions.ts +6 -0
  31. package/src/extension/config.ts +10 -37
  32. package/src/extension/fanout-child.ts +3 -0
  33. package/src/extension/herdr-pi-bridge.ts +160 -0
  34. package/src/extension/index.ts +42 -31
  35. package/src/extension/public-execution.ts +3 -3
  36. package/src/extension/schemas.ts +16 -5
  37. package/src/extension/tool-description.ts +8 -7
  38. package/src/intercom/native-supervisor-channel.ts +22 -18
  39. package/src/policy/authority.ts +4 -0
  40. package/src/profiles/profiles.ts +12 -6
  41. package/src/runs/background/active-run-index.ts +17 -1
  42. package/src/runs/background/async-execution.ts +309 -126
  43. package/src/runs/background/async-job-tracker.ts +8 -6
  44. package/src/runs/background/async-resume.ts +13 -4
  45. package/src/runs/background/async-status.ts +15 -4
  46. package/src/runs/background/auto-drain.ts +20 -10
  47. package/src/runs/background/binary-bootstrap.ts +5 -0
  48. package/src/runs/background/chain-append.ts +1 -1
  49. package/src/runs/background/chain-root-attachment.ts +14 -33
  50. package/src/runs/background/notify.ts +74 -6
  51. package/src/runs/background/result-files.ts +8 -4
  52. package/src/runs/background/result-watcher.ts +19 -2
  53. package/src/runs/background/run-child-session.ts +20 -29
  54. package/src/runs/background/runner-aliases.ts +4 -33
  55. package/src/runs/background/runner-child-launch.ts +4 -1
  56. package/src/runs/background/runner-child-sessions.ts +2 -2
  57. package/src/runs/background/runner-http-dispatcher.ts +119 -0
  58. package/src/runs/background/scheduled-runs.ts +11 -5
  59. package/src/runs/background/stale-run-reconciler.ts +35 -11
  60. package/src/runs/background/subagent-runner.ts +396 -275
  61. package/src/runs/background/subagent-wait.ts +128 -23
  62. package/src/runs/background/wait-completions.ts +75 -27
  63. package/src/runs/background/wait-subscriptions.ts +9 -3
  64. package/src/runs/background/wait-tool.ts +4 -2
  65. package/src/runs/foreground/async-stop-action.ts +93 -3
  66. package/src/runs/foreground/execution.ts +91 -218
  67. package/src/runs/foreground/foreground-history.ts +2 -1
  68. package/src/runs/foreground/subagent-executor.ts +266 -80
  69. package/src/runs/shared/acceptance.ts +34 -10
  70. package/src/runs/shared/async-status-projection.ts +123 -33
  71. package/src/runs/shared/child-launch-plan.ts +15 -3
  72. package/src/runs/shared/child-launch.ts +19 -6
  73. package/src/runs/shared/child-runtime-config.ts +5 -0
  74. package/src/runs/shared/child-session.ts +94 -50
  75. package/src/runs/shared/child-tool-plan.ts +28 -16
  76. package/src/runs/shared/dynamic-fanout.ts +2 -2
  77. package/src/runs/shared/external-cli-contract.ts +11 -1
  78. package/src/runs/shared/external-cli-preflight.ts +6 -2
  79. package/src/runs/shared/herdr-connection.ts +134 -0
  80. package/src/runs/shared/herdr-external-adapters.ts +169 -0
  81. package/src/runs/shared/herdr-machine.ts +279 -0
  82. package/src/runs/shared/herdr-pi-protocol.ts +59 -0
  83. package/src/runs/shared/herdr-placed-run.ts +263 -0
  84. package/src/runs/shared/model-resolution-diagnostic.ts +76 -0
  85. package/src/runs/shared/{model-fallback.ts → model-resolution.ts} +22 -237
  86. package/src/runs/shared/model-scope.ts +1 -1
  87. package/src/runs/shared/nested-events.ts +11 -2
  88. package/src/runs/shared/parallel-utils.ts +7 -2
  89. package/src/runs/shared/pi-spawn.ts +1 -1
  90. package/src/runs/shared/subagent-prompt-runtime.ts +4 -2
  91. package/src/runs/shared/worktree-setup-command.ts +27 -4
  92. package/src/runs/shared/worktree.ts +3 -3
  93. package/src/shared/child-cache-retention.ts +43 -0
  94. package/src/shared/launch-contract.ts +6 -9
  95. package/src/shared/pruned-fork.ts +1 -1
  96. package/src/shared/required-child-extensions.ts +81 -0
  97. package/src/shared/settings.ts +5 -2
  98. package/src/shared/shortcuts.ts +0 -4
  99. package/src/shared/types.ts +70 -29
  100. package/src/slash/slash-commands.ts +0 -6
  101. package/src/slash/subagents-admin.ts +13 -9
  102. package/src/tui/render.ts +20 -10
  103. package/src/watchdog/child-status.ts +28 -36
  104. package/src/watchdog/lsp-diagnostics.ts +1 -1
  105. package/src/watchdog/model-selection.ts +1 -1
  106. package/src/watchdog/register-child.ts +10 -3
  107. package/src/watchdog/register-main.ts +20 -20
  108. package/src/watchdog/render.ts +1 -1
  109. package/src/watchdog/review.ts +14 -30
  110. package/src/watchdog/rules.ts +1 -1
  111. package/src/watchdog/runtime.ts +23 -12
  112. package/src/watchdog/settings.ts +3 -6
  113. package/src/watchdog/types.ts +3 -5
  114. package/src/watchdog/warning-format.ts +1 -1
  115. package/src/workflows/scripted-workflow.ts +42 -3
  116. package/src/workflows/workflow-receipt.ts +21 -3
  117. package/src/workflows/workflow-resources.ts +13 -2
  118. package/src/runs/shared/model-exclusions.ts +0 -374
  119. package/src/runs/shared/readonly-model-continuation.ts +0 -69
  120. package/src/runs/shared/readonly-session-evidence.ts +0 -307
@@ -241,10 +241,15 @@ function pendingResultExists(resultsDir: string, sessionId: string, runId: strin
241
241
  return firstExistingResultFile(resultPendingPaths(resultsDir, sessionId, runId)) !== undefined;
242
242
  }
243
243
 
244
+ export function resultPayloadMatchesSessionRun(data: unknown, sessionId: string, runId: string): boolean {
245
+ if (!data || typeof data !== "object" || Array.isArray(data)) return false;
246
+ const record = data as Record<string, unknown>;
247
+ return (nonEmptyString(record.runId) ?? nonEmptyString(record.id)) === runId && nonEmptyString(record.sessionId) === sessionId;
248
+ }
249
+
244
250
  function pendingResultPayloadMatches(filePath: string, sessionId: string, runId: string): boolean {
245
251
  try {
246
- const data = JSON.parse(fs.readFileSync(filePath, "utf-8")) as Record<string, unknown>;
247
- return (nonEmptyString(data.runId) ?? nonEmptyString(data.id)) === runId && nonEmptyString(data.sessionId) === sessionId;
252
+ return resultPayloadMatchesSessionRun(JSON.parse(fs.readFileSync(filePath, "utf-8")), sessionId, runId);
248
253
  } catch (error) {
249
254
  if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !isUnaddressableResultCandidate(error)) console.error(`Ignoring invalid pending async result '${filePath}':`, error);
250
255
  return false;
@@ -252,8 +257,7 @@ function pendingResultPayloadMatches(filePath: string, sessionId: string, runId:
252
257
  }
253
258
 
254
259
  function assertPendingResultPayloadMatches(filePath: string, sessionId: string, runId: string): boolean {
255
- const data = JSON.parse(fs.readFileSync(filePath, "utf-8")) as Record<string, unknown>;
256
- return (nonEmptyString(data.runId) ?? nonEmptyString(data.id)) === runId && nonEmptyString(data.sessionId) === sessionId;
260
+ return resultPayloadMatchesSessionRun(JSON.parse(fs.readFileSync(filePath, "utf-8")), sessionId, runId);
257
261
  }
258
262
 
259
263
  export function promotePendingResultFile(resultsDir: string, sessionId: string, runId: string, file = resultFileName(runId), options: { logFailure?: boolean } = {}): "none" | "promoted" | "pending" {
@@ -70,6 +70,7 @@ type ResultFileChild = {
70
70
  sessionName?: string;
71
71
  output?: string;
72
72
  structuredOutput?: unknown;
73
+ structuredOutputPath?: string;
73
74
  outputState?: SubagentOutputState;
74
75
  error?: string;
75
76
  success?: boolean;
@@ -81,6 +82,8 @@ type ResultFileChild = {
81
82
  processSignal?: string | null;
82
83
  sessionFile?: string;
83
84
  artifactPaths?: { outputPath?: string };
85
+ outputSaveError?: string;
86
+ artifactOutputSaveFailed?: true;
84
87
  intercomTarget?: string;
85
88
  children?: unknown;
86
89
  };
@@ -408,7 +411,9 @@ export function createResultWatcher(
408
411
  if (typeof data.sessionId !== "string" || !data.sessionId) return;
409
412
  const sessionId = data.sessionId;
410
413
  const completionOwnerId = data.completionOwnerId;
411
- const runId = data.runId ?? data.id ?? file.replace(/\.json$/i, "");
414
+ const runId = typeof data.runId === "string" && data.runId
415
+ ? data.runId
416
+ : typeof data.id === "string" && data.id ? data.id : file.replace(/\.json$/i, "");
412
417
  const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : undefined;
413
418
  let observerSucceeded = true;
414
419
  try {
@@ -428,7 +433,7 @@ export function createResultWatcher(
428
433
  if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
429
434
  // Recorded before dedupe and before the unlink below so bg_wait can
430
435
  // use the in-memory record or its bounded durable replay after cleanup.
431
- recordWaitCompletion(state, runId, data, Date.now(), completionTtlMs, {
436
+ const completionPersisted = recordWaitCompletion(state, runId, data, Date.now(), completionTtlMs, {
432
437
  resultsDir,
433
438
  sessionId,
434
439
  });
@@ -456,6 +461,10 @@ export function createResultWatcher(
456
461
  }
457
462
  if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
458
463
  if (markReplacedPayload()) return;
464
+ if (!completionPersisted) {
465
+ scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
466
+ return;
467
+ }
459
468
  if (!removeDeliveredResult(file, sessionId, runId, toolCallId)) scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
460
469
  return;
461
470
  }
@@ -513,6 +522,10 @@ export function createResultWatcher(
513
522
  }
514
523
  if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
515
524
  if (markReplacedPayload()) return;
525
+ if (!completionPersisted) {
526
+ scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
527
+ return;
528
+ }
516
529
  if (!removeDeliveredResult(file, sessionId, runId, toolCallId)) scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
517
530
  return;
518
531
  }
@@ -600,6 +613,10 @@ export function createResultWatcher(
600
613
  return;
601
614
  }
602
615
  if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
616
+ if (!completionPersisted) {
617
+ scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
618
+ return;
619
+ }
603
620
  if (!removeDeliveredResult(file, sessionId, runId, toolCallId)) scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
604
621
  } catch (error) {
605
622
  if (isAccessDenied(error)) {
@@ -19,13 +19,13 @@ import {
19
19
  type ChildWatchdogStatusEvent,
20
20
  } from "../../watchdog/child-status.ts";
21
21
  import { projectChildLifecycle, type ChildLifecycleAction, type ChildLifecycleState } from "../shared/child-lifecycle.ts";
22
- import { formatSubagentModelVerificationError } from "../shared/model-fallback.ts";
22
+ import { formatSubagentModelVerificationError } from "../shared/model-resolution.ts";
23
+ import { formatChildModelResolutionDiagnostic, isChildModelResolutionFailure } from "../shared/model-resolution-diagnostic.ts";
23
24
  import { isMutatingTool, resolveCurrentPath } from "../shared/long-running-guard.ts";
24
25
  import { effectiveToolTimeoutMs, formatToolTimeoutMessage, toolTimeoutCallKey } from "../shared/tool-timeout.ts";
25
26
  import { createReportedChildSessionInput, type InProcessChildLaunch } from "../shared/child-launch.ts";
26
27
  import { childSessionHasQueuedMessages, projectChildSessionEventForJson, type ChildSession, type ChildSessionEvent, type ChildSessionFactory } from "../shared/child-session.ts";
27
28
  import { formatSteerMessage } from "../shared/subagent-prompt-runtime.ts";
28
- import { getReadonlySessionEvidence, requestReadonlySessionEvidence, type SettledReadonlyEvidence } from "../shared/readonly-session-evidence.ts";
29
29
  import type { SteerDeliveryStatus, SteerRequest } from "./control-channel.ts";
30
30
  import { takeMatchingAcceptedSteer, unconsumedSteerReason } from "./steering.ts";
31
31
 
@@ -99,16 +99,6 @@ export interface RunChildSessionInput {
99
99
  modelVerificationRegistry?: Array<{ provider: string; id: string; fullId: string }>;
100
100
  modelResponseAliases?: Record<string, string[]>;
101
101
  mutationTools?: readonly string[];
102
- /** Internal guarded continuation handoff; never part of persisted results. */
103
- readonlyContinuation?: { source: ChildSession; expected: SettledReadonlyEvidence; modelId: string };
104
- collectReadonlyEvidence?: boolean;
105
- canContinue?: () => boolean;
106
- }
107
-
108
- const settledChildren = new WeakMap<RunChildSessionResult, ChildSession>();
109
- export function getSettledReadonlyChild(result: RunChildSessionResult): ChildSession | undefined {
110
- const child = settledChildren.get(result);
111
- return child && getReadonlySessionEvidence(child) ? child : undefined;
112
102
  }
113
103
 
114
104
  export interface RunChildSessionResult {
@@ -118,6 +108,7 @@ export interface RunChildSessionResult {
118
108
  toolCount: number;
119
109
  durationMs: number;
120
110
  model?: string;
111
+ nativeMachine?: { provider: "herdr"; machineId: string; initialGit?: import("../../shared/types.ts").HerdrRemoteGitStatus; finalGit?: import("../../shared/types.ts").HerdrRemoteGitStatus };
121
112
  error?: string;
122
113
  finalOutput: string;
123
114
  outputState: SubagentOutputState;
@@ -133,12 +124,12 @@ export interface RunChildSessionResult {
133
124
  currentToolArgs?: string;
134
125
  currentPath?: string;
135
126
  afterCompactionSettlement?: boolean;
127
+ abortRecoveryDiagnostic?: string;
136
128
  /** Set by the runner while it finalizes the attempt. */
137
129
  toolBudget?: ToolBudgetState;
138
130
  toolBudgetBlocked?: boolean;
139
131
  structuredOutput?: unknown;
140
132
  runtimeAcknowledgedExtensions?: RuntimeAcknowledgedChildExtensions;
141
- abortRecoveryDiagnostic?: string;
142
133
  effects?: EffectsProjection;
143
134
  }
144
135
 
@@ -575,8 +566,21 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
575
566
  const closed = finish();
576
567
  const finalOutput = getFinalOutput(messages);
577
568
  let finalError = error ?? assistantError;
578
- if (!finalError && promptError !== undefined) {
579
- finalError = promptError instanceof Error ? promptError.message : String(promptError);
569
+ const promptErrorMessage = promptError === undefined ? undefined : promptError instanceof Error ? promptError.message : String(promptError);
570
+ if (!finalError && promptErrorMessage !== undefined) {
571
+ finalError = promptErrorMessage;
572
+ }
573
+ // A child launched without the ambient extensions resolves a provider
574
+ // extension's model as "not found". Annotate only a creation/prompt failure
575
+ // that produced no turn; keep the core error and add the rule and the
576
+ // remedies that load the extension for this child.
577
+ if (promptErrorMessage !== undefined
578
+ && finalError === promptErrorMessage
579
+ && isChildModelResolutionFailure(promptErrorMessage)
580
+ && messages.length === 0
581
+ && usage.turns === 0
582
+ && !input.launch.session.ambientExtensions) {
583
+ finalError = `${promptErrorMessage}\n\n${formatChildModelResolutionDiagnostic({ agent: input.launch.config.agent, model: input.launch.session.model, host: "runner", capabilityCeiling: input.launch.toolPlan.capabilityCeiling })}`;
580
584
  }
581
585
  const forcedDrainAfterFinalSuccess = (forced || forcedTermination) && (cleanTerminalAssistantStopReceived || agentSettledReceived) && !finalError;
582
586
  const forcedDrainAfterEmptyTerminal = forcedDrainAfterFinalSuccess && hasEmptyTerminalAssistantResponse(messages);
@@ -596,6 +600,7 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
596
600
  toolCount,
597
601
  durationMs: Date.now() - startedAt,
598
602
  model,
603
+ nativeMachine: session?.machineEvidence ? { provider: "herdr", machineId: session.machineEvidence.machineId, ...(session.machineEvidence.initial ? { initialGit: session.machineEvidence.initial } : {}), ...(session.machineEvidence.final ? { finalGit: session.machineEvidence.final } : {}) } : undefined,
599
604
  error: stopped ? stopMessage() : timedOut ? (error ?? timeoutMessage()) : interrupted || (forcedDrainAfterFinalSuccess && !forcedDrainAfterEmptyTerminal) ? undefined : finalError,
600
605
  finalOutput: (timedOut || stopped) && !finalOutput.trim() ? (stopped ? stopMessage() : error ?? timeoutMessage()) : finalOutput,
601
606
  outputState: finalOutput.trim() ? "present" : "absent",
@@ -612,7 +617,6 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
612
617
  currentPath,
613
618
  afterCompactionSettlement: afterCompactionSettlement || undefined,
614
619
  });
615
- if (session && !forced && !forcedTermination && !interrupted && !timedOut && !stopped && getReadonlySessionEvidence(session)) settledChildren.set(result, session);
616
620
  resolve(result);
617
621
  });
618
622
  };
@@ -634,17 +638,7 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
634
638
 
635
639
  void (async () => {
636
640
  try {
637
- const continuation = input.readonlyContinuation;
638
- const checkContinuation = () => {
639
- if (!continuation) return;
640
- if (interrupted || timedOut || stopped || input.canContinue?.() !== true
641
- || (input.runDeadlineAt !== undefined && Date.now() >= input.runDeadlineAt)
642
- || getReadonlySessionEvidence(continuation.source) !== continuation.expected
643
- || continuation.source.detached || continuation.source.shutDown) throw new Error("Read-only continuation handoff vetoed");
644
- };
645
- checkContinuation();
646
641
  const createInput = createReportedChildSessionInput(input.launch, input.transcriptWriter);
647
- if (input.collectReadonlyEvidence || continuation) requestReadonlySessionEvidence(createInput, continuation?.expected);
648
642
  const created = await input.factory.create(createInput);
649
643
  if (settled) {
650
644
  void created.dispose();
@@ -661,8 +655,6 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
661
655
  if (cleanTerminalAssistantStopReceived || agentSettledReceived) queuedDrainHold = true;
662
656
  return followUp(text);
663
657
  };
664
- checkContinuation();
665
- if (continuation && (created.modelId !== continuation.modelId || input.launch.capture.completionIntentContext?.()?.model?.api !== continuation.expected.api)) throw new Error("Read-only continuation model changed");
666
658
  unsubscribe = created.subscribe(processEvent);
667
659
  input.registerWatchdogStatus?.((event) => processEvent(event as unknown as ChildSessionEvent));
668
660
  input.registerSteer?.(async (request) => {
@@ -686,7 +678,6 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
686
678
  return queued;
687
679
  });
688
680
  if (interrupted || timedOut || stopped) abortChild();
689
- checkContinuation();
690
681
  await created.prompt(input.prompt);
691
682
  promptSettled = true;
692
683
  settle(undefined);
@@ -8,11 +8,9 @@
8
8
  * aliases those specifiers to the copies shipped inside the installed pi
9
9
  * package; the parent computes the same map and hands it to the runner
10
10
  * through `JITI_ALIAS`, so child sessions and hooks retain host API identity.
11
- * Only Pi 0.85.0's missing server exports may come from this extension.
12
11
  */
13
12
  import * as fs from "node:fs";
14
13
  import * as path from "node:path";
15
- import { fileURLToPath } from "node:url";
16
14
 
17
15
  export const JITI_ALIAS_ENV = "JITI_ALIAS";
18
16
 
@@ -37,13 +35,6 @@ const CHORD_PEER_ALIASES = [
37
35
  { specifier: "@earendil-works/chord/context", pkg: "@earendil-works/chord", subpath: "./context" },
38
36
  ];
39
37
 
40
- /** Experimental dependencies accidentally published in exactly Pi 0.85.0. */
41
- const PI0850_PEER_ALIASES = [
42
- { specifier: "@earendil-works/pi-server", pkg: "@earendil-works/pi-server", subpath: "." },
43
- { specifier: "@earendil-works/pi-server/unix", pkg: "@earendil-works/pi-server", subpath: "./unix" },
44
- { specifier: "@earendil-works/pi-client/unix", pkg: "@earendil-works/pi-client", subpath: "./unix" },
45
- ];
46
-
47
38
  interface PackageManifest {
48
39
  name?: unknown;
49
40
  version?: unknown;
@@ -132,40 +123,20 @@ function findPeerPackageDir(piPackageRoot: string, pkg: string, hostName: unknow
132
123
  }
133
124
 
134
125
  /** The alias map the runner needs, or the specifiers that could not be resolved. */
135
- export function resolveHostPeerAliases(
136
- piPackageRoot: string,
137
- extensionRoot = fileURLToPath(new URL("../../../", import.meta.url)),
138
- ): { aliases: Record<string, string>; missing: string[]; supplemental: string[] } {
126
+ export function resolveHostPeerAliases(piPackageRoot: string): { aliases: Record<string, string>; missing: string[] } {
139
127
  const aliases: Record<string, string> = {};
140
128
  const missing: string[] = [];
141
- const supplemental: string[] = [];
142
129
  const hostManifest = readManifest(piPackageRoot);
143
- const isPi0850 = hostManifest?.version === "0.85.0";
144
130
  // Only known stable pre-chord versions may omit it. Unknown/prerelease
145
131
  // hosts retain the required aliases, rather than hiding a broken install.
146
132
  const stableVersion = typeof hostManifest?.version === "string" ? /^0\.(\d+)\.\d+$/.exec(hostManifest.version) : null;
147
133
  const isPreChord = stableVersion !== null && Number(stableVersion[1]) < 85;
148
- const required = [...HOST_PEER_ALIASES, ...(isPreChord ? [] : CHORD_PEER_ALIASES), ...(isPi0850 ? PI0850_PEER_ALIASES : [])];
134
+ const required = [...HOST_PEER_ALIASES, ...(isPreChord ? [] : CHORD_PEER_ALIASES)];
149
135
  for (const { specifier, pkg, subpath } of required) {
150
136
  const packageDir = findPeerPackageDir(piPackageRoot, pkg, hostManifest?.name);
151
- let target = packageDir ? resolvePackageSubpath(packageDir, subpath) : undefined;
152
- // Pi 0.85.0 omitted this runtime dependency. Never replace working host
153
- // exports or extend this exact version contract to other peers/hosts.
154
- if ((!target || !fs.existsSync(target))
155
- && (specifier === "@earendil-works/pi-server" || specifier === "@earendil-works/pi-server/unix")
156
- && isPi0850
157
- && (!packageDir || readManifest(packageDir)?.version === "0.85.0")) {
158
- const localDir = findHostPeerPackageDir(extensionRoot, pkg);
159
- if (localDir && readManifest(localDir)?.version === "0.85.0") {
160
- const localTarget = resolvePackageSubpath(localDir, subpath);
161
- if (localTarget && fs.existsSync(localTarget)) {
162
- target = localTarget;
163
- supplemental.push(specifier);
164
- }
165
- }
166
- }
137
+ const target = packageDir ? resolvePackageSubpath(packageDir, subpath) : undefined;
167
138
  if (target && fs.existsSync(target)) aliases[specifier] = target;
168
139
  else missing.push(specifier);
169
140
  }
170
- return { aliases, missing, supplemental };
141
+ return { aliases, missing };
171
142
  }
@@ -35,6 +35,9 @@ export function buildRunnerChildLaunch(step: RunnerSubagentStep, ctx: RunnerChil
35
35
  ? formatAcceptancePrompt(step.effectiveAcceptance, { reportOptional: isAgentContract(step.agentContract), structuredOutput: Boolean(step.structuredOutput?.acceptanceReportPath) })
36
36
  : "";
37
37
  return buildInProcessChildLaunch({
38
+ machine: step.machine,
39
+ remoteSkillNames: step.skills,
40
+ remoteReads: step.remoteReads,
38
41
  parentSessionId: step.parentSessionId,
39
42
  forkCacheKey: step.context === "fork" ? deriveForkPromptCacheKey(step.parentSessionId) : undefined,
40
43
  sessionEnabled: attempt.sessionEnabled,
@@ -50,8 +53,8 @@ export function buildRunnerChildLaunch(step: RunnerSubagentStep, ctx: RunnerChil
50
53
  allowNestedSubagents: step.allowNestedSubagents,
51
54
  extensions: step.extensions,
52
55
  subagentOnlyExtensions: step.subagentOnlyExtensions,
56
+ requiredExtensions: step.requiredExtensions,
53
57
  fast: step.fast,
54
- modelCandidates: step.modelCandidates,
55
58
  systemPrompt: acceptancePrompt ? `${step.systemPrompt ?? ""}\n${acceptancePrompt}` : step.systemPrompt ?? "",
56
59
  systemPromptMode: step.systemPromptMode,
57
60
  mcpDirectTools: step.mcpDirectTools,
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import * as path from "node:path";
12
12
  import { pathToFileURL } from "node:url";
13
- import { createDefaultChildSessionFactory, type ChildSessionFactory, type DefaultChildSessionFactoryOptions } from "../shared/child-session.ts";
13
+ import { createPlacementChildSessionFactory, type ChildSessionFactory, type DefaultChildSessionFactoryOptions } from "../shared/child-session.ts";
14
14
 
15
15
  export interface RunnerChildSessionConfig {
16
16
  /** Test seam: module whose default export is a `ChildSessionFactory`, or a function returning one. */
@@ -22,7 +22,7 @@ function isChildSessionFactory(value: unknown): value is ChildSessionFactory {
22
22
  }
23
23
 
24
24
  export async function loadRunnerChildSessionFactory(config: RunnerChildSessionConfig, options?: DefaultChildSessionFactoryOptions): Promise<ChildSessionFactory> {
25
- if (!config.childSessionFactoryModule) return createDefaultChildSessionFactory(options);
25
+ if (!config.childSessionFactoryModule) return createPlacementChildSessionFactory(options);
26
26
  const loaded = await import(pathToFileURL(path.resolve(config.childSessionFactoryModule)).href) as { default?: unknown };
27
27
  const candidate = typeof loaded.default === "function" ? (loaded.default as () => unknown)() : loaded.default;
28
28
  if (!isChildSessionFactory(candidate)) {
@@ -0,0 +1,119 @@
1
+ import { EventEmitter } from "node:events";
2
+ import * as fs from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import * as path from "node:path";
5
+ import { getConfigDirName } from "../../shared/utils.ts";
6
+
7
+ /** Pi's default undici header/body idle timeout. */
8
+ export const DEFAULT_HTTP_IDLE_TIMEOUT_MS = 300_000;
9
+
10
+ export interface HttpIdleTimeoutResolution {
11
+ timeoutMs: number;
12
+ source: "project" | "global" | "default";
13
+ warning?: string;
14
+ }
15
+
16
+ /**
17
+ * Mirrors pi-coding-agent's `parseHttpIdleTimeoutMs`: numbers (or numeric
18
+ * strings) are floored, `"disabled"` means 0, anything else is invalid.
19
+ */
20
+ export function parseHttpIdleTimeoutMs(value: unknown): number | undefined {
21
+ if (typeof value === "string") {
22
+ const trimmed = value.trim();
23
+ if (trimmed.toLowerCase() === "disabled") return 0;
24
+ if (trimmed.length === 0) return undefined;
25
+ return parseHttpIdleTimeoutMs(Number(trimmed));
26
+ }
27
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined;
28
+ return Math.floor(value);
29
+ }
30
+
31
+ function readSetting(file: string): { present: boolean; value?: unknown; error?: string } {
32
+ let raw: string;
33
+ try {
34
+ raw = fs.readFileSync(file, "utf8");
35
+ } catch (error) {
36
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { present: false };
37
+ return { present: false, error: `cannot read ${file}: ${error instanceof Error ? error.message : String(error)}` };
38
+ }
39
+ try {
40
+ const parsed = JSON.parse(raw) as unknown;
41
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { present: false };
42
+ if (!Object.hasOwn(parsed, "httpIdleTimeoutMs")) return { present: false };
43
+ return { present: true, value: (parsed as Record<string, unknown>).httpIdleTimeoutMs };
44
+ } catch (error) {
45
+ return { present: false, error: `cannot parse ${file}: ${error instanceof Error ? error.message : String(error)}` };
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Resolves Pi's `httpIdleTimeoutMs` the way the host does: the project
51
+ * `.pi/settings.json` overrides the global `<agentDir>/settings.json`, and an
52
+ * unset value falls back to 300 000 ms. Detached runners install their own
53
+ * undici dispatcher before pi-coding-agent is loaded, so they cannot borrow
54
+ * Pi's SettingsManager and must read the setting themselves.
55
+ */
56
+ export function resolveHttpIdleTimeoutMs(options: { agentDir: string; cwd: string }): HttpIdleTimeoutResolution {
57
+ const candidates: Array<{ source: "project" | "global"; file: string }> = [
58
+ { source: "project", file: path.join(options.cwd, getConfigDirName(), "settings.json") },
59
+ { source: "global", file: path.join(options.agentDir, "settings.json") },
60
+ ];
61
+ const warnings: string[] = [];
62
+ for (const candidate of candidates) {
63
+ const read = readSetting(candidate.file);
64
+ if (read.error) {
65
+ warnings.push(read.error);
66
+ continue;
67
+ }
68
+ if (!read.present) continue;
69
+ const timeoutMs = parseHttpIdleTimeoutMs(read.value);
70
+ if (timeoutMs === undefined) {
71
+ // Pi rejects the merged value outright; a malformed override must not
72
+ // silently fall through to a different scope's setting.
73
+ warnings.push(`invalid httpIdleTimeoutMs in ${candidate.file}: ${String(read.value)}`);
74
+ return { timeoutMs: DEFAULT_HTTP_IDLE_TIMEOUT_MS, source: "default", warning: warnings.join("; ") };
75
+ }
76
+ return { timeoutMs, source: candidate.source, ...(warnings.length ? { warning: warnings.join("; ") } : {}) };
77
+ }
78
+ return { timeoutMs: DEFAULT_HTTP_IDLE_TIMEOUT_MS, source: "default", ...(warnings.length ? { warning: warnings.join("; ") } : {}) };
79
+ }
80
+
81
+ /** Dispatcher options shared by the runner and its regression test. */
82
+ export function runnerHttpDispatcherOptions(timeoutMs: number): {
83
+ allowH2: false;
84
+ proxyTunnel: true;
85
+ headersTimeout: number;
86
+ bodyTimeout: number;
87
+ } {
88
+ return {
89
+ allowH2: false,
90
+ // Keep HTTP origins on CONNECT tunnels, matching Pi's dispatcher.
91
+ proxyTunnel: true,
92
+ headersTimeout: timeoutMs,
93
+ bodyTimeout: timeoutMs,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Installs the runner's proxy-aware undici dispatcher as the process global and
99
+ * routes global fetch through it, with header/body idle clocks taken from Pi's
100
+ * `httpIdleTimeoutMs`. Detached runners skip Pi's CLI dispatcher setup: the Node
101
+ * entrypoint never runs it, and the binary bootstrap runs inside the extension
102
+ * factory, before Pi applies the setting to its own dispatcher. Best effort: a
103
+ * failure logs and leaves the existing dispatcher in place.
104
+ */
105
+ export function installRunnerHttpDispatcher(options: { agentDir: string; cwd: string }): void {
106
+ try {
107
+ // SAFETY: require loads the pinned direct dependency described by these types.
108
+ const undici = createRequire(import.meta.url)("undici") as typeof import("undici");
109
+ const idle = resolveHttpIdleTimeoutMs(options);
110
+ if (idle.warning) console.error(`[pi-subagents] httpIdleTimeoutMs: ${idle.warning}; using ${idle.timeoutMs}ms`);
111
+ const dispatcher = new undici.EnvHttpProxyAgent(runnerHttpDispatcherOptions(idle.timeoutMs));
112
+ // Fetch rejects stream errors; the listener prevents an unhandled EventEmitter error.
113
+ EventEmitter.prototype.on.call(dispatcher, "error", () => {});
114
+ undici.setGlobalDispatcher(dispatcher);
115
+ undici.install();
116
+ } catch (error) {
117
+ console.error(`[pi-subagents] proxy-aware HTTP dispatcher not installed: ${error instanceof Error ? error.message : String(error)}`);
118
+ }
119
+ }
@@ -15,6 +15,7 @@ import { previewSimpleWorkflowRun } from "../../workflows/scripted-workflow.ts";
15
15
  import { resolveGitRepositoryIdentity } from "../../workflows/chat-progress.ts";
16
16
  import { getConfigDirName } from "../../shared/utils.ts";
17
17
  import { normalizeWorktreeBaseRef } from "../shared/worktree.ts";
18
+ import { deepFreezeWorkflowArgs, normalizeWorkflowArgs } from "../../workflows/workflow-resources.ts";
18
19
 
19
20
  export const SCHEDULED_RUN_ACTIONS = [
20
21
  "schedule.create",
@@ -40,7 +41,7 @@ export type ScheduleRunState = "running" | "skipped" | "missed" | "completed" |
40
41
  export type ScheduleTrigger =
41
42
  | { kind: "once"; at: string; nextRunAt?: string }
42
43
  | { kind: "interval"; every: string; everyMs: number; anchorAt: string; nextRunAt: string };
43
- export type ScheduleTarget = { workflowScript: string; baseRef?: string };
44
+ export type ScheduleTarget = { workflowScript: string; args: Record<string, unknown>; baseRef?: string };
44
45
 
45
46
  export interface ScheduleRecord {
46
47
  schemaVersion: 1;
@@ -283,14 +284,17 @@ function readJson(file: string, label: string): unknown {
283
284
 
284
285
  function parseScheduleTarget(value: unknown, file: string): ScheduleTarget {
285
286
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Schedule record '${file}' has invalid trigger or target.`);
286
- const target = value as { workflowScript?: unknown; baseRef?: unknown; agent?: unknown; task?: unknown };
287
+ const target = value as { workflowScript?: unknown; args?: unknown; baseRef?: unknown; agent?: unknown; task?: unknown };
287
288
  if (typeof target.workflowScript === "string" && target.workflowScript.trim()) {
289
+ let baseRef: string | undefined;
288
290
  try {
289
- const baseRef = normalizeWorktreeBaseRef(target.baseRef);
290
- return { workflowScript: target.workflowScript.trim(), ...(baseRef === undefined ? {} : { baseRef }) };
291
+ baseRef = normalizeWorktreeBaseRef(target.baseRef);
291
292
  } catch (error) {
292
293
  throw new Error(`Schedule record '${file}' has an invalid baseRef: ${error instanceof Error ? error.message : String(error)}`);
293
294
  }
295
+ const normalizedArgs = normalizeWorkflowArgs(target.args);
296
+ if ("error" in normalizedArgs) throw new Error(`Schedule record '${file}' has invalid args: ${normalizedArgs.error}`);
297
+ return { workflowScript: target.workflowScript.trim(), args: deepFreezeWorkflowArgs(normalizedArgs.args), ...(baseRef === undefined ? {} : { baseRef }) };
294
298
  }
295
299
  if (target.agent !== undefined || target.task !== undefined) throw new Error(`Schedule record '${file}' uses a removed legacy agent target; recreate it with target.workflowScript.`);
296
300
  throw new Error(`Schedule record '${file}' requires a workflowScript target.`);
@@ -447,7 +451,9 @@ function sanitizeTarget(params: SubagentParamsLike): { target?: ScheduleTarget;
447
451
  }
448
452
  const acceptanceErrors = validateExecutionAcceptance(params as Parameters<typeof validateExecutionAcceptance>[0]);
449
453
  if (acceptanceErrors.length) return { error: acceptanceErrors.join(" ") };
450
- return { target: { workflowScript: params.workflowScript.trim(), ...(baseRef === undefined ? {} : { baseRef }) } };
454
+ const normalizedArgs = normalizeWorkflowArgs(params.args);
455
+ if ("error" in normalizedArgs) return { error: normalizedArgs.error };
456
+ return { target: { workflowScript: params.workflowScript.trim(), args: deepFreezeWorkflowArgs(normalizedArgs.args), ...(baseRef === undefined ? {} : { baseRef }) } };
451
457
  }
452
458
 
453
459
  function executionParams(schedule: ScheduleRecord, quiet = false): SubagentParamsLike {
@@ -99,13 +99,13 @@ interface ResultChildOutcome {
99
99
  sessionFile?: string;
100
100
  model?: string;
101
101
  thinking?: string;
102
- attemptedModels?: string[];
103
- modelAttempts?: NonNullable<AsyncStatus["steps"]>[number]["modelAttempts"];
102
+ requestedModel?: string;
104
103
  contextOverflow?: boolean;
105
104
  }
106
105
 
107
106
  interface ResultRepairData {
108
107
  state: "complete" | "failed" | "partial" | "paused" | "stopped" | "rejected";
108
+ error?: string;
109
109
  results?: ResultChildOutcome[];
110
110
  }
111
111
 
@@ -120,7 +120,8 @@ function regularFileExists(filePath: string): boolean {
120
120
 
121
121
  function readResultRepairData(resultPath: string): ResultRepairData | undefined {
122
122
  try {
123
- const data = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as { success?: boolean; state?: string; exitCode?: number; results?: unknown };
123
+ const data = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as { success?: boolean; state?: string; exitCode?: number; error?: unknown; results?: unknown };
124
+ if (data.error !== undefined && typeof data.error !== "string") throw new Error(`Invalid async result file '${resultPath}': error must be a string.`);
124
125
  const state = data.success ? "complete" : data.state === "stopped" ? "stopped" : data.state === "rejected" ? "rejected" : data.state === "partial" ? "partial" : data.state === "paused" || data.exitCode === 0 ? "paused" : "failed";
125
126
  const results = Array.isArray(data.results)
126
127
  ? data.results.map((entry, index) => {
@@ -131,7 +132,7 @@ function readResultRepairData(resultPath: string): ResultRepairData | undefined
131
132
  return child;
132
133
  })
133
134
  : undefined;
134
- return { state, ...(results ? { results } : {}) };
135
+ return { state, ...(data.error ? { error: data.error } : {}), ...(results ? { results } : {}) };
135
136
  } catch (error) {
136
137
  if (isNotFoundError(error)) return undefined;
137
138
  throw new Error(`Failed to read async result file '${resultPath}': ${getErrorMessage(error)}`, {
@@ -146,9 +147,16 @@ function childState(overallState: ResultRepairData["state"], child: ResultChildO
146
147
  return overallState;
147
148
  }
148
149
 
150
+ function failureDiagnostic(value: unknown): string | undefined {
151
+ return typeof value === "string" && value.trim() ? value : undefined;
152
+ }
153
+
149
154
  function terminalStatusFromResult(status: AsyncStatus, resultPath: string, now: number): AsyncStatus | undefined {
150
155
  const repair = readResultRepairData(resultPath);
151
156
  if (!repair) return undefined;
157
+ const rootError = failureDiagnostic(status.error)
158
+ ?? failureDiagnostic(repair.error)
159
+ ?? repair.results?.map((child) => child.success === false ? failureDiagnostic(child.error) : undefined).find(Boolean);
152
160
  const steps = (status.steps ?? []).map((step, index) => {
153
161
  if (step.status !== "running" && step.status !== "pending") return step;
154
162
  const child = repair.results?.[index];
@@ -167,14 +175,14 @@ function terminalStatusFromResult(status: AsyncStatus, resultPath: string, now:
167
175
  sessionFile: step.sessionFile ?? child?.sessionFile,
168
176
  model,
169
177
  thinking,
170
- attemptedModels: child?.attemptedModels ?? step.attemptedModels,
171
- modelAttempts: child?.modelAttempts ?? step.modelAttempts,
178
+ requestedModel: child?.requestedModel ?? step.requestedModel,
172
179
  contextOverflow: child?.contextOverflow ?? step.contextOverflow,
173
180
  };
174
181
  });
175
182
  const terminalStatus: AsyncStatus = {
176
183
  ...status,
177
184
  state: repair.state,
185
+ ...(rootError && (repair.state === "failed" || repair.state === "partial") ? { error: rootError } : {}),
178
186
  ...(status.lifecycleArtifactVersion === 3 && (!status.processTerminal || status.processTerminal.state === "pending") ? {
179
187
  processTerminal: { version: 1 as const, state: "unknown" as const, runId: status.runId, runnerProcessInstanceId: "observer-unavailable", reason: "observer-unavailable" as const },
180
188
  } : {}),
@@ -264,8 +272,7 @@ function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, r
264
272
  error: step.status === "complete" || step.status === "completed" ? undefined : step.error ?? message,
265
273
  success: step.status === "complete" || step.status === "completed",
266
274
  model: step.model,
267
- attemptedModels: step.attemptedModels,
268
- modelAttempts: step.modelAttempts,
275
+ requestedModel: step.requestedModel,
269
276
  contextOverflow: step.contextOverflow,
270
277
  sessionFile: step.sessionFile,
271
278
  })),
@@ -378,9 +385,26 @@ export function reconcileAsyncRun(asyncDir: string, options: ReconcileAsyncRunOp
378
385
  ? terminalStatusFromResult(effectiveStatus, existingResultPath, now)
379
386
  : undefined;
380
387
  if (terminalStatus) {
381
- writeAtomicJson(path.join(asyncDir, "status.json"), terminalStatus);
382
- updateActiveRunIndex(asyncDir, terminalStatus.state, terminalStatus.toolCallId);
383
- return { status: terminalStatus, repaired: true, resultPath: existingResultPath, message: "Existing async result file was used to repair stale running status." };
388
+ const currentStatus = readStatus(asyncDir);
389
+ const currentRootError = failureDiagnostic(currentStatus?.error);
390
+ const currentTerminal = currentStatus?.processTerminal?.state === "observed" || currentStatus?.processTerminal?.state === "unknown"
391
+ ? currentStatus.processTerminal
392
+ : undefined;
393
+ const steps = terminalStatus.steps?.map((step, index) => {
394
+ const currentStepTerminal = currentStatus?.steps?.[index]?.processTerminal;
395
+ return currentStepTerminal?.state === "observed" || currentStepTerminal?.state === "unknown"
396
+ ? { ...step, processTerminal: currentStepTerminal }
397
+ : step;
398
+ });
399
+ const statusToWrite: AsyncStatus = {
400
+ ...terminalStatus,
401
+ ...(currentRootError ? { error: currentRootError } : {}),
402
+ ...(currentTerminal ? { processTerminal: currentTerminal } : {}),
403
+ ...(steps ? { steps } : {}),
404
+ };
405
+ writeAtomicJson(statusPath, statusToWrite);
406
+ updateActiveRunIndex(asyncDir, statusToWrite.state, statusToWrite.toolCallId);
407
+ return { status: statusToWrite, repaired: true, resultPath: existingResultPath, message: "Existing async result file was used to repair stale running status." };
384
408
  }
385
409
  if (effectiveStatus.displayDismissedAt === undefined) return { status: effectiveStatus, repaired: false, resultPath: existingResultPath };
386
410
  }