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
@@ -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,4 +1,4 @@
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";
@@ -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) => {
@@ -952,6 +952,12 @@ function omitUndefinedWorkflowValues(value, seen = new Set()) {
952
952
  return normalized;
953
953
  }
954
954
 
955
+ function deepFreezeWorkflowArgs(value) {
956
+ if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value;
957
+ for (const entry of Object.values(value)) deepFreezeWorkflowArgs(entry);
958
+ return Object.freeze(value);
959
+ }
960
+
955
961
  parentPort.on("message", async (message) => {
956
962
  if (message.type === "response") {
957
963
  const entry = pending.get(message.callId);
@@ -971,6 +977,9 @@ parentPort.on("message", async (message) => {
971
977
  if (message.stateEnabled) sandbox.state = state;
972
978
  const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
973
979
  contextObjectPrototype = vm.runInContext("Object.prototype", context);
980
+ // Rebuild args inside the VM realm: a worker-realm object would expose the worker's unrestricted
981
+ // Function through args.constructor.constructor, bypassing codeGeneration.strings: false.
982
+ sandbox.args = deepFreezeWorkflowArgs(vm.runInContext("JSON.parse", context)(JSON.stringify(message.args ?? {})));
974
983
  let compiled;
975
984
  try {
976
985
  assertPortableWorkflowScript(message.script);
@@ -1169,6 +1178,10 @@ export interface WorkflowChildSettledNotification {
1169
1178
 
1170
1179
  export interface RunWorkflowScriptOptions {
1171
1180
  script: string;
1181
+ /** Normalized raw-script input exposed as the deeply frozen sandbox global `args`. */
1182
+ args?: Readonly<Record<string, unknown>>;
1183
+ /** Parent-session cwd used to recover a stale process cwd. */
1184
+ processCwd?: string;
1172
1185
  /** Workflow run ID for notifications. Required when onChildSettled is provided. */
1173
1186
  workflowRunId?: string;
1174
1187
  /** Host-only first-slice admission context. It is never sent to the workflow worker. */
@@ -1922,6 +1935,32 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1922
1935
  }
1923
1936
  const launchSemaphore = new Semaphore(options.globalConcurrencyLimit ?? DEFAULT_GLOBAL_CONCURRENCY_LIMIT);
1924
1937
 
1938
+ if (options.processCwd !== undefined) {
1939
+ let staleCwd = false;
1940
+ try {
1941
+ realpathSync(process.cwd());
1942
+ } catch (error) {
1943
+ const code = typeof error === "object" && error !== null && "code" in error ? (error as NodeJS.ErrnoException).code : undefined;
1944
+ if (code !== "ENOENT") throw new Error("Workflow current cwd could not be validated.", { cause: error });
1945
+ staleCwd = true;
1946
+ }
1947
+ try {
1948
+ if (staleCwd) process.chdir(options.processCwd);
1949
+ else {
1950
+ const target = realpathSync(options.processCwd);
1951
+ if (!statSync(target).isDirectory()) {
1952
+ const error = new Error(`ENOTDIR: not a directory, access '${target}'`) as NodeJS.ErrnoException;
1953
+ error.code = "ENOTDIR";
1954
+ throw error;
1955
+ }
1956
+ accessSync(target, constants.X_OK);
1957
+ }
1958
+ } catch (error) {
1959
+ const detail = error instanceof Error ? error.message : String(error);
1960
+ throw new Error(`Workflow process cwd is unavailable: ${options.processCwd}: ${detail}`, { cause: error });
1961
+ }
1962
+ }
1963
+
1925
1964
  let acornPath: string;
1926
1965
  try {
1927
1966
  acornPath = resolveWorkflowParserEntry();
@@ -2509,6 +2548,6 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
2509
2548
  respond(deliver(promise), `runs.run('${key}') result`, (error) => children.set(key, responseBoundaryFailure(key, error)));
2510
2549
  });
2511
2550
 
2512
- worker.postMessage({ type: "start", script: options.script, stateEnabled: options.state !== undefined });
2551
+ worker.postMessage({ type: "start", script: options.script, ...(options.args ? { args: options.args } : {}), stateEnabled: options.state !== undefined });
2513
2552
  });
2514
2553
  }
@@ -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") {
@@ -1,374 +0,0 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
- import { splitKnownThinkingSuffix } from "../../shared/model-info.ts";
4
- import { TEMP_ROOT_DIR } from "../../shared/types.ts";
5
- import { getAgentDir } from "../../shared/utils.ts";
6
-
7
- export const EXCLUSIONS_PATH_ENV = "PI_MODEL_EXCLUSIONS_PATH";
8
-
9
- type ModelExclusionTarget = { modelId: string; provider?: string } | { provider: string; modelId?: never };
10
-
11
- export type ModelExclusion = ModelExclusionTarget & {
12
- reason?: string;
13
- recordedAt: number;
14
- expiresAt: number;
15
- };
16
-
17
- type RecordModelFailureOptions = ModelExclusionTarget & {
18
- reason?: string;
19
- ttlMs?: number;
20
- };
21
-
22
- let exclusions: ModelExclusion[] = [];
23
- let loaded = false;
24
- /** Default duration for a new model exclusion when no per-record TTL is supplied. */
25
- export const DEFAULT_MODEL_EXCLUSION_TTL_MS = 24 * 60 * 60_000;
26
- /** Keeps a new expiry safely below JavaScript's maximum Date timestamp. */
27
- export const MAX_MODEL_EXCLUSION_TTL_MS = 8_000_000_000_000_000;
28
- const MAX_DATE_TIMESTAMP_MS = 8_640_000_000_000_000;
29
- let defaultTTLMs = DEFAULT_MODEL_EXCLUSION_TTL_MS;
30
- let loadedTTLCeilingMs: number | undefined;
31
- let persistTimer: ReturnType<typeof setTimeout> | null = null;
32
- let persistSeq = 0;
33
-
34
- const AUTH_FAILURE_PATTERNS = [
35
- /auth(?:entication)?/i,
36
- /unauthori[sz]ed/i,
37
- /forbidden/i,
38
- /api key/i,
39
- /token expired/i,
40
- /invalid key/i,
41
- ];
42
-
43
- function isAuthModelExclusion(entry: ModelExclusion): boolean {
44
- const reason = entry.reason;
45
- return typeof reason === "string" && AUTH_FAILURE_PATTERNS.some((pattern) => pattern.test(reason));
46
- }
47
-
48
- function getAuthStoreMtimeMs(): number | undefined {
49
- const authStorePath = path.join(getAgentDir(), "auth.json");
50
- try {
51
- const stats = fs.statSync(authStorePath);
52
- return stats.isFile() && Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : undefined;
53
- } catch (error) {
54
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
55
- console.error(`[model-exclusions] Failed to stat Pi auth store at ${authStorePath}; preserving auth-related exclusions:`, error);
56
- }
57
- return undefined;
58
- }
59
- }
60
-
61
- function invalidateAuthExclusions(): void {
62
- if (!exclusions.some(isAuthModelExclusion)) return;
63
- const authStoreMtimeMs = getAuthStoreMtimeMs();
64
- if (authStoreMtimeMs === undefined) return;
65
- const retained = exclusions.filter((entry) => !isAuthModelExclusion(entry) || authStoreMtimeMs <= entry.recordedAt);
66
- if (retained.length === exclusions.length) return;
67
- exclusions = retained;
68
- schedulePersist();
69
- }
70
-
71
- /**
72
- * Override the default TTL applied to newly recorded model exclusions.
73
- *
74
- * @param ms Duration in milliseconds. Must be finite and positive.
75
- * @returns Nothing.
76
- */
77
- // TEST:test/unit/model-exclusions.test.ts[model exclusions — TTL expiry]
78
- export function setDefaultTTL(ms: number, options?: { shortenExisting?: boolean }): void {
79
- if (!Number.isFinite(ms) || ms <= 0 || ms > MAX_MODEL_EXCLUSION_TTL_MS) {
80
- throw new Error(`Default model exclusion TTL must be a finite positive number no greater than ${MAX_MODEL_EXCLUSION_TTL_MS}.`);
81
- }
82
- defaultTTLMs = ms;
83
- loadedTTLCeilingMs = options?.shortenExisting ? ms : undefined;
84
- if (loaded && loadedTTLCeilingMs !== undefined && shortenExclusionsToTTL(exclusions, loadedTTLCeilingMs, Date.now())) schedulePersist();
85
- }
86
-
87
- /**
88
- * Resolve the persistence path. Honors PI_MODEL_EXCLUSIONS_PATH; defaults to
89
- * <TEMP_ROOT_DIR>/model-exclusions.json. Resolved lazily so tests can point the
90
- * store at an isolated location after module load.
91
- */
92
- export function getExclusionsFilePath(): string {
93
- const envPath = process.env[EXCLUSIONS_PATH_ENV];
94
- if (typeof envPath === "string" && envPath.trim()) return envPath.trim();
95
- return path.join(TEMP_ROOT_DIR, "model-exclusions.json");
96
- }
97
-
98
- /**
99
- * Persist exclusions to disk immediately (atomic write via tmp + rename).
100
- * The store otherwise debounces writes; call this when durability matters
101
- * (and in tests).
102
- */
103
- export function flushPersist(): void {
104
- const file = getExclusionsFilePath();
105
- try {
106
- fs.mkdirSync(path.dirname(file), { recursive: true });
107
- const tmpPath = `${file}.${process.pid}.${persistSeq++}.tmp`;
108
- fs.writeFileSync(tmpPath, JSON.stringify({
109
- version: 1,
110
- exclusions: deduplicate(exclusions),
111
- }, null, 2), "utf-8");
112
- fs.renameSync(tmpPath, file);
113
- } catch (error) {
114
- console.error(`[model-exclusions] Failed to persist exclusions to ${file}:`, error);
115
- }
116
- }
117
-
118
- function schedulePersist(): void {
119
- if (persistTimer) clearTimeout(persistTimer);
120
- persistTimer = setTimeout(() => {
121
- persistTimer = null;
122
- flushPersist();
123
- }, 5000);
124
- // Never hold the process open just to flush exclusions.
125
- persistTimer.unref?.();
126
- }
127
-
128
- function ensureLoaded(): void {
129
- if (loaded) return;
130
- loaded = true;
131
- try {
132
- const raw = fs.readFileSync(getExclusionsFilePath(), "utf-8");
133
- const data = JSON.parse(raw);
134
- if (data.version === 1) {
135
- if (!Array.isArray(data.exclusions)) throw new Error("Model exclusion store version 1 must contain an exclusions array.");
136
- const now = Date.now();
137
- let droppedInvalid = false;
138
- exclusions = data.exclusions.flatMap((entry: unknown, index: number) => {
139
- const result = readPersistedExclusion(entry, index);
140
- if (!result.ok) {
141
- droppedInvalid = true;
142
- console.warn(`[model-exclusions] Ignoring invalid exclusion: ${result.message}`);
143
- return [];
144
- }
145
- return [result.exclusion];
146
- }).filter((e: ModelExclusion) => e.expiresAt > now);
147
- const shortened = loadedTTLCeilingMs !== undefined && shortenExclusionsToTTL(exclusions, loadedTTLCeilingMs, now);
148
- exclusions = deduplicate(exclusions);
149
- if (droppedInvalid) flushPersist();
150
- else if (shortened) schedulePersist();
151
- }
152
- invalidateAuthExclusions();
153
- } catch (error) {
154
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
155
- console.error(`[model-exclusions] Failed to load exclusions from ${getExclusionsFilePath()}:`, error);
156
- }
157
- }
158
- }
159
-
160
- type PersistedExclusionRead =
161
- | { ok: true; exclusion: ModelExclusion }
162
- | { ok: false; message: string };
163
-
164
- function invalidPersistedExclusion(index: number, message: string): PersistedExclusionRead {
165
- return { ok: false, message: `Model exclusion store entry ${index} ${message}.` };
166
- }
167
-
168
- function readPersistedExclusion(entry: unknown, index: number): PersistedExclusionRead {
169
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) return invalidPersistedExclusion(index, "must be an object");
170
- const candidate = entry as { modelId?: unknown; provider?: unknown; reason?: unknown; recordedAt?: unknown; expiresAt?: unknown };
171
- const { modelId, provider, reason } = candidate;
172
- if (modelId !== undefined && (typeof modelId !== "string" || modelId.length === 0)) return invalidPersistedExclusion(index, "has an invalid modelId");
173
- if (provider !== undefined && (typeof provider !== "string" || provider.length === 0)) return invalidPersistedExclusion(index, "has an invalid provider");
174
- if (reason !== undefined && typeof reason !== "string") return invalidPersistedExclusion(index, "has an invalid reason");
175
- const { recordedAt, expiresAt } = candidate;
176
- if (typeof recordedAt !== "number" || !Number.isFinite(recordedAt) || recordedAt <= 0 || recordedAt > MAX_DATE_TIMESTAMP_MS) return invalidPersistedExclusion(index, "has an invalid recordedAt");
177
- if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= 0 || expiresAt > MAX_DATE_TIMESTAMP_MS) return invalidPersistedExclusion(index, "has an invalid expiresAt");
178
- const metadata = { ...(reason === undefined ? {} : { reason }), recordedAt, expiresAt };
179
- if (modelId === undefined) {
180
- if (provider === undefined) return invalidPersistedExclusion(index, "must include modelId or provider");
181
- return { ok: true, exclusion: { provider, ...metadata } };
182
- }
183
- return { ok: true, exclusion: { modelId, ...(provider === undefined ? {} : { provider }), ...metadata } };
184
- }
185
-
186
- function dedupKey(entry: ModelExclusion): string {
187
- return `${entry.provider ?? ""}|${entry.modelId ?? ""}`;
188
- }
189
-
190
- function deduplicate(items: ModelExclusion[]): ModelExclusion[] {
191
- const map = new Map<string, ModelExclusion>();
192
- for (const entry of items) {
193
- const key = dedupKey(entry);
194
- const existing = map.get(key);
195
- if (!existing || entry.recordedAt > existing.recordedAt) {
196
- map.set(key, entry);
197
- }
198
- }
199
- return Array.from(map.values());
200
- }
201
-
202
- /**
203
- * Record a model failure as a temporary exclusion. While the exclusion is
204
- * active, {@link isExcluded} returns true for the model (or for every model of
205
- * the provider when modelId is omitted), and {@link filterFallbackCandidates}
206
- * removes matching candidates from fallback lists.
207
- */
208
- export function recordModelFailure(options: RecordModelFailureOptions): void {
209
- ensureLoaded();
210
- const ttl = options.ttlMs ?? defaultTTLMs;
211
- const now = Date.now();
212
- const target: ModelExclusionTarget = options.modelId !== undefined
213
- ? { modelId: options.modelId, ...(options.provider ? { provider: options.provider } : {}) }
214
- : { provider: options.provider };
215
- const exclusion: ModelExclusion = {
216
- ...target,
217
- reason: options.reason ?? "runtime-failure",
218
- recordedAt: now,
219
- expiresAt: now + ttl,
220
- };
221
- exclusions.unshift(exclusion);
222
- exclusions = deduplicate(exclusions);
223
- if (exclusions.length > 200) exclusions.length = 200;
224
- flushPersist();
225
- }
226
-
227
- /**
228
- * Drop all expired exclusions from memory and schedule a persist.
229
- */
230
- export function clearExpiredExclusions(): void {
231
- ensureLoaded();
232
- invalidateAuthExclusions();
233
- prune(exclusions, Date.now());
234
- schedulePersist();
235
- }
236
-
237
- /**
238
- * Remove every exclusion (e.g. after the operator fixes credentials).
239
- */
240
- export function clearExclusions(): void {
241
- ensureLoaded();
242
- exclusions.length = 0;
243
- schedulePersist();
244
- }
245
-
246
- /**
247
- * Whether an exclusion entry matches a candidate.
248
- *
249
- * Semantics:
250
- * - Entry with modelId: model-specific exclusion. Matches only that modelId;
251
- * when both the entry and the candidate carry a provider, the providers must
252
- * also agree so `openai/gpt-4` does not exclude `github-copilot/gpt-4`.
253
- * - Entry without modelId: provider-wide exclusion (e.g. quota or auth failure).
254
- * Matches every model of that provider.
255
- */
256
- function entryMatches(entry: ModelExclusion, candidateModelId: string, candidateProvider: string | undefined, now: number): boolean {
257
- if (entry.expiresAt <= now) return false;
258
- if (entry.modelId !== undefined) {
259
- if (entry.modelId !== candidateModelId) return false;
260
- return !entry.provider || !candidateProvider || entry.provider === candidateProvider;
261
- }
262
- return Boolean(entry.provider) && entry.provider === candidateProvider;
263
- }
264
-
265
- /**
266
- * Whether a model (or its provider) is currently excluded.
267
- */
268
- export function isExcluded(modelId: string, provider: string): boolean {
269
- ensureLoaded();
270
- invalidateAuthExclusions();
271
- return exclusions.some((entry) => entryMatches(entry, modelId, provider, Date.now()));
272
- }
273
-
274
- /**
275
- * Return the active exclusion matching a full model id, if any.
276
- *
277
- * The caller uses this for hard-fail diagnostics; fallback filtering should
278
- * continue to use {@link filterFallbackCandidates}.
279
- */
280
- export function findModelExclusion(fullId: string, now = Date.now()): Readonly<ModelExclusion> | undefined {
281
- ensureLoaded();
282
- invalidateAuthExclusions();
283
- const { provider, modelId } = parseModelKey(fullId);
284
- return exclusions.find((entry) => entryMatches(entry, modelId, provider, now));
285
- }
286
-
287
- /**
288
- * Number of live (non-expired) exclusions.
289
- */
290
- export function getExcludedCount(): number {
291
- ensureLoaded();
292
- clearExpiredExclusions();
293
- return exclusions.length;
294
- }
295
-
296
- /**
297
- * Split a candidate fullId into its provider + modelId components.
298
- *
299
- * A fullId may carry a thinking suffix (`provider/model:thinking`) which is
300
- * stripped before parsing, and the modelId itself may contain slashes
301
- * (e.g. `openrouter/google/gemini-flash`). The first `/`-segment is the
302
- * provider; everything after is the modelId. This MUST stay in lock-step with
303
- * the matching inside {@link isExcluded} so that a failure recorded via
304
- * {@link recordModelFailure} is later recognised by the candidate filter.
305
- */
306
- export function parseModelKey(fullId: string): { provider?: string; modelId: string } {
307
- const base = splitKnownThinkingSuffix(fullId).baseModel;
308
- if (!base.includes("/")) return { modelId: base };
309
- const slash = base.indexOf("/");
310
- return { provider: base.slice(0, slash), modelId: base.slice(slash + 1) };
311
- }
312
-
313
- /**
314
- * Filter a list of candidate fullIds, removing excluded models/providers and
315
- * duplicates while preserving order.
316
- */
317
- export function filterFallbackCandidates(candidates: string[], opts?: {
318
- now?: number;
319
- onExcluded?: (candidate: string, exclusion: Readonly<ModelExclusion>) => void;
320
- ignoreExclusion?: (candidate: string, exclusion: Readonly<ModelExclusion>) => boolean;
321
- }): string[] {
322
- ensureLoaded();
323
- invalidateAuthExclusions();
324
- const timestamp = opts?.now ?? Date.now();
325
- const seen = new Set<string>();
326
- const filtered: string[] = [];
327
- for (const raw of candidates) {
328
- if (!raw || seen.has(raw)) continue;
329
- const { provider: candidateProvider, modelId: candidateModelId } = parseModelKey(raw);
330
- const exclusion = exclusions.find((entry) => entryMatches(entry, candidateModelId, candidateProvider, timestamp) && opts?.ignoreExclusion?.(raw, entry) !== true);
331
- if (exclusion) {
332
- opts?.onExcluded?.(raw, exclusion);
333
- continue;
334
- }
335
- seen.add(raw);
336
- filtered.push(raw);
337
- }
338
- return filtered;
339
- }
340
-
341
- /**
342
- * Reload exclusions from disk (for tests and config hot-reload).
343
- * Discards any in-memory-only exclusions that were not yet persisted.
344
- */
345
- export function reloadFromDisk(): void {
346
- loaded = false;
347
- exclusions = [];
348
- ensureLoaded();
349
- }
350
-
351
- function prune(items: ModelExclusion[], now: number): void {
352
- let write = 0;
353
- for (let i = 0; i < items.length; i++) {
354
- const entry = items[i]!;
355
- if (entry.expiresAt > now) {
356
- items[write++] = entry;
357
- }
358
- }
359
- items.length = write;
360
- }
361
-
362
- function shortenExclusionsToTTL(items: ModelExclusion[], ttlMs: number, now: number): boolean {
363
- let changed = false;
364
- for (const entry of items) {
365
- const configuredExpiry = entry.recordedAt + ttlMs;
366
- if (entry.expiresAt > configuredExpiry) {
367
- entry.expiresAt = configuredExpiry;
368
- changed = true;
369
- }
370
- }
371
- const previousLength = items.length;
372
- prune(items, now);
373
- return changed || items.length !== previousLength;
374
- }