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
@@ -14,6 +14,7 @@ import { WatchdogScopeArtifact } from "./scope.ts";
14
14
  import { resolveWatchdogConfig } from "./settings.ts";
15
15
  import { formatWatchdogOrchestrationActivity, formatWatchdogTurnDelta } from "./turn-delta.ts";
16
16
  import {
17
+ WATCHDOG_WARNING_IMPORTANCES,
17
18
  type ResolvedWatchdogConfig,
18
19
  type WatchdogEndpointConfig,
19
20
  type WatchdogLspRuntimeSnapshot,
@@ -31,6 +32,8 @@ type ReviewStopReason = "stop" | "error" | "aborted" | "length";
31
32
  export interface WatchdogReviewResult {
32
33
  warnings?: WatchdogWarning[];
33
34
  stopReason?: ReviewStopReason;
35
+ /** Provider error text for a failed review, surfaced in status `Last error`. */
36
+ errorMessage?: string;
34
37
  clarification?: { question: string; evidence: string };
35
38
  }
36
39
 
@@ -83,6 +86,7 @@ interface MainWatchdogRuntimeOptions {
83
86
  review?: WatchdogReviewFunction;
84
87
  reviewDescription?: string;
85
88
  displayWarning?: (warning: WatchdogWarningDetails, options?: WatchdogWarningSendOptions) => void;
89
+ displayUserWarning?: (warning: WatchdogWarningDetails) => void;
86
90
  /** Supplying this main-session delivery capability gates clarification (children omit it). */
87
91
  displayClarification?: (content: string) => void;
88
92
  reviewChangesOnly?: boolean;
@@ -130,6 +134,7 @@ export class MainWatchdogRuntime {
130
134
  private readonly reviewConnected: boolean;
131
135
  private readonly reviewDescription: string;
132
136
  private readonly displayWarning: ((warning: WatchdogWarningDetails, options?: WatchdogWarningSendOptions) => void) | undefined;
137
+ private readonly displayUserWarning: ((warning: WatchdogWarningDetails) => void) | undefined;
133
138
  private readonly reviewChangesOnly: boolean;
134
139
  private readonly lspDiagnostics: WatchdogLspDiagnosticsFunction;
135
140
  private readonly repoChangeSignature: typeof computeWatchdogRepoChangeSignature;
@@ -187,6 +192,7 @@ export class MainWatchdogRuntime {
187
192
  this.reviewConnected = Boolean(options.review);
188
193
  this.reviewDescription = options.reviewDescription ?? (options.review ? "injected seam" : "not wired");
189
194
  this.displayWarning = options.displayWarning;
195
+ this.displayUserWarning = options.displayUserWarning;
190
196
  this.displayClarification = options.displayClarification;
191
197
  this.reviewChangesOnly = options.reviewChangesOnly === true;
192
198
  this.lspDiagnostics = options.lspDiagnostics ?? collectWatchdogLspDiagnostics;
@@ -268,7 +274,7 @@ export class MainWatchdogRuntime {
268
274
  return this.getSnapshot();
269
275
  }
270
276
 
271
- reset(_reason = "reset", options: { clearReviewInputSignature?: boolean; resetChangeSignature?: boolean; clearLspLedger?: boolean; clearScope?: boolean } = {}): void {
277
+ reset(_reason = "reset", options: { clearReviewInputSignature?: boolean; resetChangeSignature?: boolean; clearLspLedger?: boolean; clearScope?: boolean; clearActivity?: boolean } = {}): void {
272
278
  this.activeReviewAbortController?.abort();
273
279
  this.abortActiveAgentEnd();
274
280
  this.epoch++;
@@ -290,10 +296,8 @@ export class MainWatchdogRuntime {
290
296
  this.lspLedger.reset();
291
297
  this.lastLspSnapshot = undefined;
292
298
  }
293
- if (options.clearScope) {
294
- this.scope.reset();
295
- this.clearActivity();
296
- }
299
+ if (options.clearScope) this.scope.reset();
300
+ if (options.clearScope || options.clearActivity) this.clearActivity();
297
301
  if (options.clearReviewInputSignature) this.lastReviewInputSignature = undefined;
298
302
  if (options.resetChangeSignature) this.resetRepoChangeBaseline({ reviewed: true });
299
303
  this.guard.reset();
@@ -467,13 +471,13 @@ export class MainWatchdogRuntime {
467
471
  this.ruleWarningsThisRun.add(violation.summary);
468
472
  const details = normalizeWatchdogWarningDetails(ruleViolationWarning(violation), { state: "displayed", displayedAt: new Date().toISOString() });
469
473
  this.lastWarning = details;
470
- this.displayWarning?.(details, { deliverAs: "steer" });
474
+ this.routeWarning(details, { deliverAs: "steer" });
471
475
  }
472
476
 
473
- recordDisplayedWarning(warning: WatchdogWarning): WatchdogWarningDetails {
477
+ displayRecordedWarning(warning: WatchdogWarning): void {
474
478
  const details = normalizeWatchdogWarningDetails(warning, { state: "displayed", source: warning.source ?? "main" });
475
479
  this.lastWarning = details;
476
- return details;
480
+ this.routeWarning(details);
477
481
  }
478
482
 
479
483
  getSnapshot(cwd?: string): WatchdogRuntimeSnapshot {
@@ -536,7 +540,13 @@ export class MainWatchdogRuntime {
536
540
  }
537
541
 
538
542
  private warningMeetsThreshold(warning: WatchdogWarning): boolean {
539
- return this.configResult.config.severityThreshold === "concern" || warning.severity === "blocker";
543
+ return (WATCHDOG_WARNING_IMPORTANCES as readonly string[]).includes(warning.importance)
544
+ && (this.configResult.config.severityThreshold === "concern" || warning.severity === "blocker");
545
+ }
546
+
547
+ private routeWarning(details: WatchdogWarningDetails, options?: WatchdogWarningSendOptions): void {
548
+ if (details.importance === "high") this.displayWarning?.(details, options);
549
+ else this.displayUserWarning?.(details);
540
550
  }
541
551
 
542
552
  private acceptWarning(epoch: number, reviewId: number, warning: WatchdogWarning): boolean {
@@ -658,7 +668,8 @@ export class MainWatchdogRuntime {
658
668
  }
659
669
  for (const warning of result.warnings ?? []) this.acceptWarning(reviewEpoch, reviewId, warning);
660
670
  if (result.stopReason && result.stopReason !== "stop") {
661
- this.fail(`Watchdog review ended with stop reason '${result.stopReason}'.`);
671
+ const detail = result.errorMessage?.trim() ? ` ${boundWatchdogReviewText(result.errorMessage.trim(), 600)}` : "";
672
+ this.fail(`Watchdog review ended with stop reason '${result.stopReason}'.${detail}`);
662
673
  return "completed";
663
674
  }
664
675
  this.displayAcceptedReviewWarning(options.correction);
@@ -690,7 +701,7 @@ export class MainWatchdogRuntime {
690
701
  };
691
702
  if (correction) {
692
703
  this.lastWarning = details;
693
- this.displayWarning?.(details, { deliverAs: "steer" });
704
+ this.routeWarning(details, { deliverAs: "steer" });
694
705
  return;
695
706
  }
696
707
  this.deliverBoundaryWarning(details);
@@ -711,7 +722,7 @@ export class MainWatchdogRuntime {
711
722
  : details;
712
723
  this.stalemate = stalemate;
713
724
  this.lastWarning = delivered;
714
- this.displayWarning?.(delivered, stalemate ? { triggerTurn: false } : undefined);
725
+ this.routeWarning(delivered, stalemate ? { triggerTurn: false } : undefined);
715
726
  }
716
727
 
717
728
  // The previous boundary finding is a repeat to count, not a duplicate, until stalemate.
@@ -111,9 +111,9 @@ const ROLE_MODEL_RULE_FIELDS = new Set(["allow", "deny", "note"]);
111
111
  const GUIDANCE_FIELDS = new Set(["watchdogMd"]);
112
112
  const SCOPE_FIELDS = new Set(["enabled"]);
113
113
  const CADENCE_FIELDS = new Set(["everyNTools"]);
114
- const ENDPOINT_FIELDS = new Set(["enabled", "model", "fallbackModels", "thinking"]);
115
- const CHILDREN_FIELDS = new Set(["enabled", "model", "fallbackModels", "thinking", "watchdogTailTimeoutMs", "cadence", "overrides"]);
116
- const CHILD_OVERRIDE_FIELDS = new Set(["enabled", "model", "fallbackModels", "thinking", "cadence"]);
114
+ const ENDPOINT_FIELDS = new Set(["enabled", "model", "thinking"]);
115
+ const CHILDREN_FIELDS = new Set(["enabled", "model", "thinking", "watchdogTailTimeoutMs", "cadence", "overrides"]);
116
+ const CHILD_OVERRIDE_FIELDS = new Set(["enabled", "model", "thinking", "cadence"]);
117
117
  const LSP_FIELDS = new Set(["enabled", "timeoutMs", "maxFiles", "maxDiagnostics"]);
118
118
 
119
119
  function cloneDefaultConfig(): ResolvedWatchdogConfig {
@@ -221,7 +221,6 @@ function parseEndpointPatch(value: unknown, field: string, meta: ParseMeta): Wat
221
221
  const input = parseObject(value, field, meta);
222
222
  assertKnownFields(input, ENDPOINT_FIELDS, field, meta);
223
223
  const patch: WatchdogEndpointPatch = {};
224
- if ("fallbackModels" in input) patch.fallbackModels = parseStringList(input.fallbackModels, `${field}.fallbackModels`, meta);
225
224
  if ("enabled" in input) patch.enabled = parseBoolean(input.enabled, `${field}.enabled`, meta);
226
225
  if ("model" in input) patch.model = parseNonEmptyString(input.model, `${field}.model`, meta);
227
226
  if ("thinking" in input) patch.thinking = parseThinking(input.thinking, `${field}.thinking`, meta);
@@ -232,7 +231,6 @@ function parseChildOverridePatch(value: unknown, field: string, meta: ParseMeta)
232
231
  const input = parseObject(value, field, meta);
233
232
  assertKnownFields(input, CHILD_OVERRIDE_FIELDS, field, meta);
234
233
  const patch: WatchdogChildOverridePatch = {};
235
- if ("fallbackModels" in input) patch.fallbackModels = parseStringList(input.fallbackModels, `${field}.fallbackModels`, meta);
236
234
  if ("enabled" in input) patch.enabled = parseBoolean(input.enabled, `${field}.enabled`, meta);
237
235
  if ("model" in input) patch.model = parseNonEmptyString(input.model, `${field}.model`, meta);
238
236
  if ("thinking" in input) patch.thinking = parseThinking(input.thinking, `${field}.thinking`, meta);
@@ -244,7 +242,6 @@ function parseChildrenPatch(value: unknown, field: string, meta: ParseMeta): Wat
244
242
  const input = parseObject(value, field, meta);
245
243
  assertKnownFields(input, CHILDREN_FIELDS, field, meta);
246
244
  const patch: WatchdogChildrenPatch = {};
247
- if ("fallbackModels" in input) patch.fallbackModels = parseStringList(input.fallbackModels, `${field}.fallbackModels`, meta);
248
245
  if ("enabled" in input) patch.enabled = parseBoolean(input.enabled, `${field}.enabled`, meta);
249
246
  if ("model" in input) patch.model = parseNonEmptyString(input.model, `${field}.model`, meta);
250
247
  if ("thinking" in input) patch.thinking = parseThinking(input.thinking, `${field}.thinking`, meta);
@@ -15,8 +15,8 @@ export const WATCHDOG_WARNING_CATEGORIES = [
15
15
  ] as const;
16
16
  export type WatchdogCategory = typeof WATCHDOG_WARNING_CATEGORIES[number];
17
17
 
18
- export const WATCHDOG_WARNING_CONFIDENCES = ["medium", "high"] as const;
19
- export type WatchdogConfidence = typeof WATCHDOG_WARNING_CONFIDENCES[number];
18
+ export const WATCHDOG_WARNING_IMPORTANCES = ["low", "medium", "high"] as const;
19
+ export type WatchdogImportance = typeof WATCHDOG_WARNING_IMPORTANCES[number];
20
20
 
21
21
  export const WATCHDOG_WARNING_SOURCES = ["main", "child", "lsp"] as const;
22
22
  export type WatchdogWarningSource = typeof WATCHDOG_WARNING_SOURCES[number];
@@ -44,11 +44,11 @@ export type WatchdogWarningState = typeof WATCHDOG_WARNING_STATES[number];
44
44
 
45
45
  export interface WatchdogWarning {
46
46
  severity: WatchdogSeverity;
47
+ importance: WatchdogImportance;
47
48
  summary: string;
48
49
  evidence: string;
49
50
  recommendedAction: string;
50
51
  category?: WatchdogCategory;
51
- confidence?: WatchdogConfidence;
52
52
  source?: WatchdogWarningSource;
53
53
  agent?: string;
54
54
  runId?: string;
@@ -87,14 +87,12 @@ export interface WatchdogCadenceConfig {
87
87
  export interface WatchdogEndpointConfig {
88
88
  enabled: boolean;
89
89
  model?: string;
90
- fallbackModels?: string[];
91
90
  thinking?: string | false;
92
91
  }
93
92
 
94
93
  export interface WatchdogChildOverrideConfig {
95
94
  enabled?: boolean;
96
95
  model?: string;
97
- fallbackModels?: string[];
98
96
  thinking?: string | false;
99
97
  cadence?: Partial<WatchdogCadenceConfig>;
100
98
  }
@@ -34,12 +34,12 @@ export function formatWatchdogWarningContent(warning: WatchdogWarning): string {
34
34
  const details = normalizeWatchdogWarningDetails(warning);
35
35
  const attrs = [
36
36
  `severity="${escapeXmlAttribute(details.severity)}"`,
37
+ `importance="${escapeXmlAttribute(details.importance)}"`,
37
38
  `category="${escapeXmlAttribute(details.category)}"`,
38
39
  `source="${escapeXmlAttribute(details.source)}"`,
39
40
  `guidance="weigh, don't blindly obey"`,
40
41
  ];
41
42
  const optionalTags = [
42
- tag("confidence", details.confidence),
43
43
  tag("agent", details.agent),
44
44
  tag("run_id", details.runId),
45
45
  tag("state", details.state),
@@ -1,11 +1,11 @@
1
- import { readFileSync } from "node:fs";
1
+ import { accessSync, constants, readFileSync, realpathSync, statSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import { dirname, resolve as resolvePath } from "node:path";
4
4
  import { Worker } from "node:worker_threads";
5
5
  import { DEFAULT_GLOBAL_CONCURRENCY_LIMIT, Semaphore } from "../runs/shared/parallel-utils.ts";
6
6
  import { HOST_STEP_MAX_COUNT } from "../runs/shared/host-step-status.ts";
7
7
  import { classifyTaskMutationIntent } from "../runs/shared/task-intent.ts";
8
- import { describeGateAcceptanceConflict } from "../runs/shared/acceptance.ts";
8
+ import { describeGateAcceptanceConflict, parseGateInput } from "../runs/shared/acceptance.ts";
9
9
  import type { AcceptanceRecoveryMetadata, HostStepNode, SingleResult } from "../shared/types.ts";
10
10
  import { normalizeWorkflowHostCommandParams, type WorkflowHostCommandParams, type WorkflowHostCommandResult } from "./host-command.ts";
11
11
 
@@ -550,7 +550,7 @@ function runLanes(laneSpecs) {
550
550
  generatedKey: stage.generatedKey,
551
551
  ...workflowPlanStringMetadata(stage.params),
552
552
  ...(typeof stage.params.as === "string" && stage.params.as.trim() ? { outputName: stage.params.as.trim() } : {}),
553
- ...(stage.params.outputSchema !== undefined ? { structured: true } : {}),
553
+ ...(stage.params.outputSchema ? { structured: true } : {}),
554
554
  })),
555
555
  })) });
556
556
  const firstItems = lanes.map((lane) => {
@@ -633,6 +633,24 @@ function validateLaneMetadata(value, label, workflowKey) {
633
633
  }
634
634
  }
635
635
 
636
+ // Mirrors parseGateInput in src/runs/shared/acceptance.ts; the sandbox cannot import it.
637
+ function describeGateShapeError(gate) {
638
+ const shape = "gate must be a non-empty command string or { command, output?: \"json\", schema?, timeoutMs? }.";
639
+ if (typeof gate === "string") return gate.trim() ? undefined : shape;
640
+ if (!gate || typeof gate !== "object" || Array.isArray(gate)) return shape;
641
+ for (const key of Object.keys(gate)) {
642
+ if (!["command", "output", "schema", "timeoutMs"].includes(key)) return "gate." + key + " is not supported.";
643
+ }
644
+ if (typeof gate.command !== "string" || !gate.command.trim()) return shape;
645
+ if (gate.output !== undefined && gate.output !== "json") return "gate.output must be \"json\" when present.";
646
+ if (gate.schema !== undefined) {
647
+ if (gate.output !== "json") return "gate.schema requires gate.output: \"json\".";
648
+ if (!gate.schema || typeof gate.schema !== "object" || Array.isArray(gate.schema)) return "gate.schema must be a JSON Schema object.";
649
+ }
650
+ if (gate.timeoutMs !== undefined && (!Number.isInteger(gate.timeoutMs) || gate.timeoutMs < 1)) return "gate.timeoutMs must be an integer >= 1.";
651
+ return undefined;
652
+ }
653
+
636
654
  function describeGateAcceptanceConflict(gate, acceptance) {
637
655
  const render = (value) => {
638
656
  let encoded;
@@ -658,7 +676,10 @@ function validateRunCall(key, params, label, fingerprints) {
658
676
  if (params.worktree !== undefined && typeof params.worktree !== "boolean") throw new Error(label + " worktree must be true or false.");
659
677
  if (params.baseRef !== undefined && (typeof params.baseRef !== "string" || !validGitRef(params.baseRef))) throw new Error(label + " baseRef must be a valid Git ref: use HEAD or a supported named ref (for example, refs/heads/main). Full 40/64-character commit IDs and revision expressions are unsupported.");
660
678
  validateLaneMetadata(params.lane, label + " lane", key);
661
- if (params.gate !== undefined && (typeof params.gate !== "string" || !params.gate.trim())) throw new Error(label + " gate must be a non-empty command string.");
679
+ if (params.gate !== undefined) {
680
+ const gateError = describeGateShapeError(params.gate);
681
+ if (gateError) throw new Error(label + " " + gateError);
682
+ }
662
683
  if (params.gate !== undefined && params.acceptance !== undefined && params.acceptance !== false) throw new Error(label + " gate cannot be combined with acceptance; use one gate command or acceptance.verify." + describeGateAcceptanceConflict(params.gate, params.acceptance));
663
684
  if (params.gate !== undefined && params.resume !== undefined) throw new Error(label + " gate is not supported with retained resume.");
664
685
  if (params.extensionBindings !== undefined && params.resume !== undefined) throw new Error(label + " extensionBindings is not supported with retained resume; resume uses the original retained child binding.");
@@ -952,6 +973,12 @@ function omitUndefinedWorkflowValues(value, seen = new Set()) {
952
973
  return normalized;
953
974
  }
954
975
 
976
+ function deepFreezeWorkflowArgs(value) {
977
+ if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value;
978
+ for (const entry of Object.values(value)) deepFreezeWorkflowArgs(entry);
979
+ return Object.freeze(value);
980
+ }
981
+
955
982
  parentPort.on("message", async (message) => {
956
983
  if (message.type === "response") {
957
984
  const entry = pending.get(message.callId);
@@ -971,6 +998,9 @@ parentPort.on("message", async (message) => {
971
998
  if (message.stateEnabled) sandbox.state = state;
972
999
  const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
973
1000
  contextObjectPrototype = vm.runInContext("Object.prototype", context);
1001
+ // Rebuild args inside the VM realm: a worker-realm object would expose the worker's unrestricted
1002
+ // Function through args.constructor.constructor, bypassing codeGeneration.strings: false.
1003
+ sandbox.args = deepFreezeWorkflowArgs(vm.runInContext("JSON.parse", context)(JSON.stringify(message.args ?? {})));
974
1004
  let compiled;
975
1005
  try {
976
1006
  assertPortableWorkflowScript(message.script);
@@ -1169,6 +1199,10 @@ export interface WorkflowChildSettledNotification {
1169
1199
 
1170
1200
  export interface RunWorkflowScriptOptions {
1171
1201
  script: string;
1202
+ /** Normalized raw-script input exposed as the deeply frozen sandbox global `args`. */
1203
+ args?: Readonly<Record<string, unknown>>;
1204
+ /** Parent-session cwd used to recover a stale process cwd. */
1205
+ processCwd?: string;
1172
1206
  /** Workflow run ID for notifications. Required when onChildSettled is provided. */
1173
1207
  workflowRunId?: string;
1174
1208
  /** Host-only first-slice admission context. It is never sent to the workflow worker. */
@@ -1922,6 +1956,32 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1922
1956
  }
1923
1957
  const launchSemaphore = new Semaphore(options.globalConcurrencyLimit ?? DEFAULT_GLOBAL_CONCURRENCY_LIMIT);
1924
1958
 
1959
+ if (options.processCwd !== undefined) {
1960
+ let staleCwd = false;
1961
+ try {
1962
+ realpathSync(process.cwd());
1963
+ } catch (error) {
1964
+ const code = typeof error === "object" && error !== null && "code" in error ? (error as NodeJS.ErrnoException).code : undefined;
1965
+ if (code !== "ENOENT") throw new Error("Workflow current cwd could not be validated.", { cause: error });
1966
+ staleCwd = true;
1967
+ }
1968
+ try {
1969
+ if (staleCwd) process.chdir(options.processCwd);
1970
+ else {
1971
+ const target = realpathSync(options.processCwd);
1972
+ if (!statSync(target).isDirectory()) {
1973
+ const error = new Error(`ENOTDIR: not a directory, access '${target}'`) as NodeJS.ErrnoException;
1974
+ error.code = "ENOTDIR";
1975
+ throw error;
1976
+ }
1977
+ accessSync(target, constants.X_OK);
1978
+ }
1979
+ } catch (error) {
1980
+ const detail = error instanceof Error ? error.message : String(error);
1981
+ throw new Error(`Workflow process cwd is unavailable: ${options.processCwd}: ${detail}`, { cause: error });
1982
+ }
1983
+ }
1984
+
1925
1985
  let acornPath: string;
1926
1986
  try {
1927
1987
  acornPath = resolveWorkflowParserEntry();
@@ -2379,8 +2439,9 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
2379
2439
  if (params.baseRef !== undefined && (typeof params.baseRef !== "string" || !validGitRef(params.baseRef))) {
2380
2440
  return respond(Promise.reject(new Error(`runs.run('${key}') ${BASE_REF_VALIDATION_ERROR}`)));
2381
2441
  }
2382
- if (params.gate !== undefined && (typeof params.gate !== "string" || !params.gate.trim())) {
2383
- return respond(Promise.reject(new Error(`runs.run('${key}') gate must be a non-empty command string.`)));
2442
+ if (params.gate !== undefined) {
2443
+ const parsedGate = parseGateInput(params.gate);
2444
+ if (!parsedGate.ok) return respond(Promise.reject(new Error(`runs.run('${key}') ${parsedGate.error}`)));
2384
2445
  }
2385
2446
  if (params.gate !== undefined && params.acceptance !== undefined && params.acceptance !== false) {
2386
2447
  return respond(Promise.reject(new Error(`runs.run('${key}') gate cannot be combined with acceptance; use one gate command or acceptance.verify.` + describeGateAcceptanceConflict(params.gate, params.acceptance))));
@@ -2509,6 +2570,6 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
2509
2570
  respond(deliver(promise), `runs.run('${key}') result`, (error) => children.set(key, responseBoundaryFailure(key, error)));
2510
2571
  });
2511
2572
 
2512
- worker.postMessage({ type: "start", script: options.script, stateEnabled: options.state !== undefined });
2573
+ worker.postMessage({ type: "start", script: options.script, ...(options.args ? { args: options.args } : {}), stateEnabled: options.state !== undefined });
2513
2574
  });
2514
2575
  }
@@ -37,6 +37,7 @@ export function buildWorkflowReceipt(input: {
37
37
  workflowRunId: string;
38
38
  state: WorkflowReceiptState;
39
39
  children: WorkflowScriptChildResult[];
40
+ argsDigest?: string;
40
41
  hostSteps?: WorkflowReceipt["hostSteps"];
41
42
  workflowChildren?: WorkflowReceipt["workflowChildren"];
42
43
  resource?: WorkflowResourceProvenance;
@@ -44,6 +45,7 @@ export function buildWorkflowReceipt(input: {
44
45
  createdAt?: number;
45
46
  }): WorkflowReceipt {
46
47
  const workflowRunId = assertSafeRunId(input.workflowRunId, "workflowRunId");
48
+ if (input.argsDigest !== undefined && !/^[a-f0-9]{64}$/u.test(input.argsDigest)) throw new Error("workflow receipt argsDigest must be a lowercase SHA-256 digest.");
47
49
  if (input.workflowChildren?.workflowRunId !== undefined && input.workflowChildren.workflowRunId !== workflowRunId) throw new Error("workflowChildren workflowRunId does not match its receipt.");
48
50
  const entries: Record<string, WorkflowReceiptEntry> = Object.create(null) as Record<string, WorkflowReceiptEntry>;
49
51
  for (const child of input.children) {
@@ -75,7 +77,7 @@ export function buildWorkflowReceipt(input: {
75
77
  const hostSteps = input.hostSteps?.map((hostStep, index) => parseHostStepNode(hostStep, `workflow receipt hostSteps[${index}]`));
76
78
  if (hostSteps) assertUniqueHostStepIds(hostSteps, "workflow receipt");
77
79
  const resource = parseWorkflowResource(input.resource, "workflow receipt");
78
- return { version: WORKFLOW_RECEIPT_VERSION, workflowRunId, state: input.state, createdAt: input.createdAt ?? Date.now(), entries, ...(resource ? { resource } : {}), ...(hostSteps?.length ? { hostSteps } : {}), ...(input.workflowChildren ? { workflowChildren: input.workflowChildren } : {}), ...(input.terminalOutcome ? { terminalOutcome: input.terminalOutcome } : {}) };
80
+ return { version: WORKFLOW_RECEIPT_VERSION, workflowRunId, state: input.state, createdAt: input.createdAt ?? Date.now(), entries, ...(input.argsDigest ? { argsDigest: input.argsDigest } : {}), ...(resource ? { resource } : {}), ...(hostSteps?.length ? { hostSteps } : {}), ...(input.workflowChildren ? { workflowChildren: input.workflowChildren } : {}), ...(input.terminalOutcome ? { terminalOutcome: input.terminalOutcome } : {}) };
79
81
  }
80
82
 
81
83
  export function writeWorkflowReceipt(asyncDir: string, receipt: WorkflowReceipt): string {
@@ -115,12 +117,26 @@ const EXTERNAL_CLI_CAPABILITIES = {
115
117
  extensionBindings: false,
116
118
  } as const;
117
119
 
120
+ function parseExternalCliMachine(value: unknown, label: string): void {
121
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label}.machine must be an object.`);
122
+ const machine = value as Record<string, unknown>;
123
+ const unknownMachine = Object.keys(machine).filter((field) => !["provider", "id", "label", "target", "session", "cwd", "remoteGit"].includes(field));
124
+ if (unknownMachine.length > 0) throw new Error(`${label}.machine has unsupported fields: ${unknownMachine.join(", ")}.`);
125
+ if (machine.provider !== "herdr" || typeof machine.id !== "string" || !machine.id.trim() || typeof machine.target !== "string" || !machine.target.trim() || typeof machine.cwd !== "string" || !machine.cwd.trim()) throw new Error(`${label}.machine is invalid.`);
126
+ for (const field of ["label", "session"] as const) if (machine[field] !== undefined && (typeof machine[field] !== "string" || !machine[field].trim())) throw new Error(`${label}.machine.${field} is invalid.`);
127
+ if (machine.remoteGit === undefined) return;
128
+ if (!machine.remoteGit || typeof machine.remoteGit !== "object" || Array.isArray(machine.remoteGit)) throw new Error(`${label}.machine.remoteGit must be an object.`);
129
+ const remoteGit = machine.remoteGit as Record<string, unknown>;
130
+ const unknownRemoteGit = Object.keys(remoteGit).filter((field) => !["head", "branch", "dirty"].includes(field));
131
+ if (unknownRemoteGit.length > 0 || (remoteGit.head !== undefined && (typeof remoteGit.head !== "string" || !remoteGit.head.trim())) || (remoteGit.branch !== undefined && (typeof remoteGit.branch !== "string" || !remoteGit.branch.trim())) || (remoteGit.dirty !== undefined && typeof remoteGit.dirty !== "boolean")) throw new Error(`${label}.machine.remoteGit is invalid.`);
132
+ }
133
+
118
134
  function parseExternalCliReceiptMetadata(value: unknown, key: string, source: string): ExternalCliReceiptMetadata | undefined {
119
135
  if (value === undefined) return undefined;
120
136
  const label = `Invalid workflow receipt '${source}': entry '${key}' externalAdapter`;
121
137
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
122
138
  const metadata = value as Record<string, unknown>;
123
- const unknownMetadata = Object.keys(metadata).filter((field) => !["adapter", "capabilities", "safety", "outputArtifacts", "handoff", "supervisor", "nonResumableReason"].includes(field));
139
+ const unknownMetadata = Object.keys(metadata).filter((field) => !["adapter", "capabilities", "machine", "safety", "outputArtifacts", "handoff", "supervisor", "nonResumableReason"].includes(field));
124
140
  if (unknownMetadata.length > 0) throw new Error(`${label} has unsupported fields: ${unknownMetadata.join(", ")}.`);
125
141
  const adapter = metadata.adapter;
126
142
  if (!adapter || typeof adapter !== "object" || Array.isArray(adapter)) throw new Error(`${label}.adapter must be an object.`);
@@ -136,6 +152,7 @@ function parseExternalCliReceiptMetadata(value: unknown, key: string, source: st
136
152
  for (const [capability, expected] of Object.entries(EXTERNAL_CLI_CAPABILITIES)) {
137
153
  if (capabilityRecord[capability] !== expected) throw new Error(`${label}.capabilities.${capability} is invalid.`);
138
154
  }
155
+ if (metadata.machine !== undefined) parseExternalCliMachine(metadata.machine, label);
139
156
  const safety = metadata.safety;
140
157
  if (adapterRecord.id === "codex-exec") {
141
158
  if (!safety || typeof safety !== "object" || Array.isArray(safety)) throw new Error(`${label}.safety is missing.`);
@@ -330,10 +347,11 @@ export function readWorkflowReceipt(asyncDirRoot: string, workflowRunId: string)
330
347
  assertUniqueHostStepIds(hostSteps, receiptPath);
331
348
  }
332
349
  const workflowResolution = parseWorkflowResolution(receipt.workflowResolution, receiptPath);
350
+ if (receipt.argsDigest !== undefined && (typeof receipt.argsDigest !== "string" || !/^[a-f0-9]{64}$/u.test(receipt.argsDigest))) throw new Error(`Invalid workflow receipt '${receiptPath}': argsDigest is invalid.`);
333
351
  const resource = parseWorkflowResource(receipt.resource, receiptPath);
334
352
  const terminalOutcome = parseTerminalOutcome(receipt.terminalOutcome, `Invalid workflow receipt '${receiptPath}': terminalOutcome`);
335
353
  const recovery = parseRecovery(receipt.recovery, workflowRunId, entries, receiptPath);
336
- return { version: 1, workflowRunId, state: receipt.state, createdAt: receipt.createdAt, entries, ...(resource ? { resource } : {}), ...(hostSteps?.length ? { hostSteps } : {}), ...(workflowChildren ? { workflowChildren } : {}), ...(workflowResolution ? { workflowResolution } : {}), ...(terminalOutcome ? { terminalOutcome } : {}), ...(recovery ? { recovery } : {}) };
354
+ return { version: 1, workflowRunId, state: receipt.state, createdAt: receipt.createdAt, entries, ...(receipt.argsDigest ? { argsDigest: receipt.argsDigest } : {}), ...(resource ? { resource } : {}), ...(hostSteps?.length ? { hostSteps } : {}), ...(workflowChildren ? { workflowChildren } : {}), ...(workflowResolution ? { workflowResolution } : {}), ...(terminalOutcome ? { terminalOutcome } : {}), ...(recovery ? { recovery } : {}) };
337
355
  }
338
356
 
339
357
  export function resolveWorkflowReceiptResumeEntry(input: {
@@ -125,7 +125,7 @@ function validatePlainJson(value: unknown, path: string, depth = 0): void {
125
125
  }
126
126
  }
127
127
 
128
- function normalizeArgs(value: unknown): { args: Record<string, unknown> } | { error: string } {
128
+ export function normalizeWorkflowArgs(value: unknown): { args: Record<string, unknown> } | { error: string } {
129
129
  if (value === undefined) return { args: {} };
130
130
  if (!isPlainRecord(value)) return { error: "workflow args must be a plain JSON object." };
131
131
  try {
@@ -137,6 +137,17 @@ function normalizeArgs(value: unknown): { args: Record<string, unknown> } | { er
137
137
  }
138
138
  }
139
139
 
140
+ export function deepFreezeWorkflowArgs<T extends Record<string, unknown>>(args: T): Readonly<T> {
141
+ deepFreezeWorkflowValue(args);
142
+ return args;
143
+ }
144
+
145
+ function deepFreezeWorkflowValue(value: unknown): void {
146
+ if (!Array.isArray(value) && !isPlainRecord(value)) return;
147
+ for (const entry of Object.values(value)) deepFreezeWorkflowValue(entry);
148
+ Object.freeze(value);
149
+ }
150
+
140
151
  function resolveRunCi(args: Readonly<Record<string, unknown>>): ReturnType<WorkflowResourceDefinition["resolve"]> {
141
152
  const allowed = new Set(["command", "timeoutMs"]);
142
153
  const unsupported = Object.keys(args).filter((key) => !allowed.has(key));
@@ -191,7 +202,7 @@ function resolveResource(nameValue: unknown, argsValue?: unknown, sessionId?: st
191
202
  if (!RESOURCE_NAME_PATTERN.test(name)) return { ok: false, error: "workflow must use a safe resource name." };
192
203
  const resource = findWorkflowResource(name) ?? (sessionId ? registry().bySession.get(sessionId)?.get(name) : undefined);
193
204
  if (!resource) return { ok: false, error: `Unknown workflow resource '${name}'. Available resources: ${listWorkflowResourceNames().join(", ")}.` };
194
- const normalizedArgs = normalizeArgs(argsValue);
205
+ const normalizedArgs = normalizeWorkflowArgs(argsValue);
195
206
  if ("error" in normalizedArgs) return { ok: false, error: normalizedArgs.error };
196
207
  const resolved = resource.resolve(normalizedArgs.args);
197
208
  if (resolved && typeof (resolved as unknown as { then?: unknown }).then === "function") {