pi-subagents 0.60.0 → 0.62.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 (76) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/docs/agents.md +7 -3
  3. package/docs/configuration.md +9 -5
  4. package/docs/extension-api.md +14 -7
  5. package/docs/models.md +1 -1
  6. package/docs/observability.md +1 -1
  7. package/docs/tool-reference.md +13 -4
  8. package/docs/workflows.md +14 -13
  9. package/install.mjs +1 -1
  10. package/package.json +1 -1
  11. package/skills/pi-subagents/SKILL.md +6 -4
  12. package/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
  13. package/skills/pi-subagents/references/execution-controls.md +41 -11
  14. package/skills/pi-subagents/references/multi-lane-orchestration.md +1 -1
  15. package/skills/pi-subagents/references/prompting-and-roles.md +4 -4
  16. package/skills/pi-subagents/references/review-and-validation.md +1 -1
  17. package/src/agents/agent-management.ts +121 -63
  18. package/src/agents/agent-serializer.ts +3 -0
  19. package/src/agents/agents.ts +543 -223
  20. package/src/agents/runtime-agent-registry.ts +5 -1
  21. package/src/api/background-work.ts +7 -2
  22. package/src/api/external-runs.ts +67 -4
  23. package/src/api/preflight.ts +17 -8
  24. package/src/api/shared-types.ts +1 -0
  25. package/src/extension/index.ts +7 -4
  26. package/src/extension/public-execution.ts +48 -4
  27. package/src/extension/rpc.ts +62 -4
  28. package/src/extension/schemas.ts +17 -9
  29. package/src/extension/tool-description.ts +15 -19
  30. package/src/runs/background/active-async-capacity.ts +26 -7
  31. package/src/runs/background/async-execution.ts +98 -51
  32. package/src/runs/background/async-job-tracker.ts +62 -3
  33. package/src/runs/background/async-resume.ts +6 -3
  34. package/src/runs/background/async-status.ts +59 -9
  35. package/src/runs/background/auto-drain.ts +1 -1
  36. package/src/runs/background/fleet-view.ts +1 -1
  37. package/src/runs/background/process-terminal.ts +16 -0
  38. package/src/runs/background/result-watcher.ts +1 -1
  39. package/src/runs/background/resume-guidance.ts +1 -1
  40. package/src/runs/background/run-status.ts +2 -2
  41. package/src/runs/background/scheduled-runs.ts +63 -6
  42. package/src/runs/background/steering.ts +4 -1
  43. package/src/runs/background/subagent-runner.ts +15 -9
  44. package/src/runs/background/subagent-wait.ts +20 -21
  45. package/src/runs/background/wait-completions.ts +1 -1
  46. package/src/runs/background/wait-tool.ts +18 -18
  47. package/src/runs/foreground/execution.ts +75 -9
  48. package/src/runs/foreground/subagent-executor.ts +181 -60
  49. package/src/runs/shared/acceptance.ts +113 -27
  50. package/src/runs/shared/capability-ceiling.ts +1 -0
  51. package/src/runs/shared/dynamic-fanout.ts +1 -1
  52. package/src/runs/shared/host-step-status.ts +1 -0
  53. package/src/runs/shared/model-fallback.ts +61 -17
  54. package/src/runs/shared/parallel-utils.ts +2 -6
  55. package/src/runs/shared/permissions.ts +1 -1
  56. package/src/runs/shared/pi-args.ts +24 -12
  57. package/src/runs/shared/pi-spawn.ts +69 -35
  58. package/src/runs/shared/structured-output.ts +33 -6
  59. package/src/runs/shared/subagent-prompt-runtime.ts +20 -3
  60. package/src/runs/shared/task-intent.ts +17 -6
  61. package/src/runs/shared/tool-timeout.ts +1 -1
  62. package/src/runs/shared/workflow-graph.ts +3 -2
  63. package/src/shared/atomic-json.ts +3 -1
  64. package/src/shared/fork-context.ts +0 -12
  65. package/src/shared/fork-session-cwd.ts +27 -0
  66. package/src/shared/launch-contract.ts +3 -0
  67. package/src/shared/types.ts +43 -4
  68. package/src/shared/workflow-child-permit.ts +91 -0
  69. package/src/slash/prompt-template-bridge.ts +37 -1
  70. package/src/slash/slash-commands.ts +18 -26
  71. package/src/slash/subagents-admin.ts +2 -0
  72. package/src/tui/render.ts +95 -52
  73. package/src/workflows/scripted-workflow.ts +153 -4
  74. package/src/workflows/workflow-child-summary.ts +1 -1
  75. package/src/workflows/workflow-receipt.ts +41 -4
  76. package/src/workflows/workflow-resources.ts +150 -0
@@ -47,7 +47,7 @@ const VALID_EVIDENCE_KINDS: AcceptanceEvidenceKind[] = [
47
47
  const VALID_EVIDENCE = new Set<AcceptanceEvidenceKind>(VALID_EVIDENCE_KINDS);
48
48
  const ACCEPTANCE_EVIDENCE_HELP = `Supported evidence kinds: ${VALID_EVIDENCE_KINDS.join(", ")}. Example: { level: "checked", evidence: ["commands-run", "changed-files"] }.`;
49
49
  const ACCEPTANCE_OBJECT_EXAMPLE = "Example: { level: \"checked\", evidence: [\"commands-run\", \"changed-files\"] }.";
50
- const ACCEPTANCE_CONFIG_KEYS = new Set(["level", "criteria", "evidence", "verify", "review", "stopRules", "reason"]);
50
+ const ACCEPTANCE_CONFIG_KEYS = new Set(["level", "report", "criteria", "evidence", "verify", "review", "stopRules", "reason"]);
51
51
  const ACCEPTANCE_GATE_KEYS = new Set(["id", "must", "evidence", "severity"]);
52
52
  const ACCEPTANCE_VERIFY_KEYS = new Set(["id", "command", "timeoutMs", "cwd", "env", "allowFailure"]);
53
53
  const ACCEPTANCE_REVIEW_KEYS = new Set(["agent", "focus", "required"]);
@@ -146,11 +146,46 @@ function inferLevel(input: {
146
146
  };
147
147
  }
148
148
 
149
- export function normalizeAcceptanceInput(input: AcceptanceInput | undefined): AcceptanceConfig {
149
+ type AcceptanceInputNormalizationResult = { value: unknown; error?: string };
150
+
151
+ function normalizeAcceptanceValue(input: unknown, pathLabel = "acceptance"): AcceptanceInputNormalizationResult {
152
+ if (typeof input !== "string") return { value: input };
153
+ const trimmed = input.trim();
154
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return { value: input };
155
+ let parsed: unknown;
156
+ try {
157
+ parsed = JSON.parse(trimmed);
158
+ } catch (error) {
159
+ return {
160
+ value: input,
161
+ error: `${pathLabel} JSON string must encode a valid acceptance object: ${error instanceof Error ? error.message : String(error)}`,
162
+ };
163
+ }
164
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
165
+ const kind = parsed === null ? "null" : Array.isArray(parsed) ? "an array" : typeof parsed;
166
+ return { value: input, error: `${pathLabel} JSON string must encode an object; got ${kind}.` };
167
+ }
168
+ return { value: parsed };
169
+ }
170
+
171
+ export function normalizeAcceptanceInput(input: unknown): AcceptanceConfig {
172
+ const normalized = normalizeAcceptanceValue(input);
173
+ if (normalized.error) throw new Error(normalized.error);
174
+ input = normalized.value;
150
175
  if (input === undefined || input === "auto") return { level: "auto" };
151
176
  if (input === false) return { level: "none", reason: "disabled by deprecated false shorthand" };
152
- if (typeof input === "string") return { level: input };
153
- return { ...input };
177
+ if (typeof input === "string") return { level: input as AcceptanceConfig["level"] };
178
+ return { ...(input as AcceptanceConfig) };
179
+ }
180
+
181
+ export type ResolvedAcceptanceReportMode = "off" | "optional" | "required";
182
+
183
+ export function resolveAcceptanceReportMode(input: unknown): ResolvedAcceptanceReportMode {
184
+ const normalized = normalizeAcceptanceValue(input);
185
+ if (normalized.error) throw new Error(normalized.error);
186
+ const value = normalized.value;
187
+ const report = value && typeof value === "object" && !Array.isArray(value) ? (value as AcceptanceConfig).report : undefined;
188
+ return value === false || report === "off" ? "off" : report === "on" ? "required" : "optional";
154
189
  }
155
190
 
156
191
  type GateAcceptanceNormalizationResult =
@@ -158,7 +193,11 @@ type GateAcceptanceNormalizationResult =
158
193
  | { ok: false; error: string };
159
194
 
160
195
  export function normalizeGateAcceptance(gate: unknown, acceptance: AcceptanceInput | undefined): GateAcceptanceNormalizationResult {
161
- if (gate === undefined) return acceptance === undefined ? { ok: true } : { ok: true, acceptance };
196
+ if (gate === undefined) {
197
+ if (acceptance === undefined) return { ok: true };
198
+ const normalized = normalizeAcceptanceValue(acceptance);
199
+ return normalized.error ? { ok: false, error: normalized.error } : { ok: true, acceptance: normalized.value as AcceptanceInput };
200
+ }
162
201
  if (typeof gate !== "string" || !gate.trim()) return { ok: false, error: "gate must be a non-empty command string." };
163
202
  if (acceptance !== undefined) return { ok: false, error: "gate cannot be combined with acceptance; use one gate command or acceptance.verify." };
164
203
  return { ok: true, acceptance: { level: "verified", verify: [{ id: "gate", command: gate.trim() }] } };
@@ -177,6 +216,9 @@ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"
177
216
  const errors: string[] = [];
178
217
  if (input === undefined) return errors;
179
218
  if (input === false) return errors;
219
+ const normalized = normalizeAcceptanceValue(input, pathLabel);
220
+ if (normalized.error) return [normalized.error];
221
+ input = normalized.value;
180
222
  if (typeof input === "string") {
181
223
  if (input === "reviewed") errors.push(`${pathLabel} ${EXPLICIT_REVIEWED_UNAVAILABLE}`);
182
224
  else if (!VALID_LEVELS.has(input as AcceptanceLevel)) errors.push(`${pathLabel} has invalid level '${input}'.`);
@@ -197,6 +239,9 @@ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"
197
239
  } else if (value.level !== undefined && (typeof value.level !== "string" || !VALID_LEVELS.has(value.level as AcceptanceLevel))) {
198
240
  errors.push(`${pathLabel}.level must be one of auto, none, attested, checked, verified.`);
199
241
  }
242
+ if (value.report !== undefined && value.report !== "on" && value.report !== "off") {
243
+ errors.push(`${pathLabel}.report must be on or off.`);
244
+ }
200
245
  if (value.level === "none" && (typeof value.reason !== "string" || !value.reason.trim())) {
201
246
  errors.push(`${pathLabel}.reason is required when level is none.`);
202
247
  }
@@ -304,29 +349,45 @@ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"
304
349
 
305
350
  export function validateExecutionAcceptance(input: {
306
351
  acceptance?: unknown;
307
- tasks?: Array<{ acceptance?: unknown }>;
352
+ outputSchema?: unknown;
353
+ tasks?: Array<{ acceptance?: unknown; outputSchema?: unknown }>;
308
354
  chain?: Array<{
309
355
  acceptance?: unknown;
310
- parallel?: Array<{ acceptance?: unknown }> | { acceptance?: unknown };
356
+ outputSchema?: unknown;
357
+ parallel?: Array<{ acceptance?: unknown; outputSchema?: unknown }> | { acceptance?: unknown; outputSchema?: unknown };
311
358
  }>;
312
359
  }): string[] {
313
360
  const errors = validateAcceptanceInput(input.acceptance, "acceptance");
361
+ errors.push(...validateAcceptanceReportMode(input.acceptance, input.outputSchema, "acceptance"));
314
362
  for (const [index, task] of (input.tasks ?? []).entries()) {
315
363
  errors.push(...validateAcceptanceInput(task.acceptance, `tasks[${index}].acceptance`));
364
+ errors.push(...validateAcceptanceReportMode(task.acceptance, task.outputSchema, `tasks[${index}].acceptance`));
316
365
  }
317
366
  for (const [stepIndex, step] of (input.chain ?? []).entries()) {
318
367
  errors.push(...validateAcceptanceInput(step.acceptance, `chain[${stepIndex}].acceptance`));
368
+ errors.push(...validateAcceptanceReportMode(step.acceptance, step.outputSchema, `chain[${stepIndex}].acceptance`));
319
369
  if (Array.isArray(step.parallel)) {
320
370
  for (const [taskIndex, task] of step.parallel.entries()) {
321
371
  errors.push(...validateAcceptanceInput(task.acceptance, `chain[${stepIndex}].parallel[${taskIndex}].acceptance`));
372
+ errors.push(...validateAcceptanceReportMode(task.acceptance, task.outputSchema, `chain[${stepIndex}].parallel[${taskIndex}].acceptance`));
322
373
  }
323
374
  } else if (step.parallel) {
324
375
  errors.push(...validateAcceptanceInput(step.parallel.acceptance, `chain[${stepIndex}].parallel.acceptance`));
376
+ errors.push(...validateAcceptanceReportMode(step.parallel.acceptance, step.parallel.outputSchema, `chain[${stepIndex}].parallel.acceptance`));
325
377
  }
326
378
  }
327
379
  return errors;
328
380
  }
329
381
 
382
+ function validateAcceptanceReportMode(acceptance: unknown, outputSchema: unknown, pathLabel: string): string[] {
383
+ const normalized = normalizeAcceptanceValue(acceptance, pathLabel);
384
+ if (normalized.error) return [];
385
+ acceptance = normalized.value;
386
+ if (!acceptance || typeof acceptance !== "object" || Array.isArray(acceptance)) return [];
387
+ if (!("report" in acceptance)) return [];
388
+ return outputSchema === undefined ? [`${pathLabel}.report requires outputSchema.`] : [];
389
+ }
390
+
330
391
  function normalizeCriteria(criteria: Array<string | { id?: string; must?: string; evidence?: AcceptanceEvidenceKind[]; severity?: "required" | "recommended" }> | undefined, evidence: AcceptanceEvidenceKind[]): ResolvedAcceptanceGate[] {
331
392
  return (criteria ?? []).map((criterion, index): ResolvedAcceptanceGate => {
332
393
  if (typeof criterion === "string") {
@@ -678,7 +739,15 @@ function parseAcceptanceReportBody(body: string): { report?: AcceptanceReport; e
678
739
  return validateAcceptanceReport(parseReportJson(body));
679
740
  }
680
741
 
681
- function parseUnterminatedAcceptanceReportFence(output: string): { report?: AcceptanceReport; error?: string } {
742
+ interface AcceptanceReportParseResult {
743
+ report?: AcceptanceReport;
744
+ error?: string;
745
+ /** True when an acceptance-report signal was found but its envelope was invalid. */
746
+ malformed?: boolean;
747
+ sourcePath?: string;
748
+ }
749
+
750
+ function parseUnterminatedAcceptanceReportFence(output: string): AcceptanceReportParseResult {
682
751
  const opener = /```acceptance[-_]report\b[^\n]*\n/gi.exec(output);
683
752
  if (!opener) return {};
684
753
  const bodyStart = opener.index + opener[0].length;
@@ -687,13 +756,13 @@ function parseUnterminatedAcceptanceReportFence(output: string): { report?: Acce
687
756
  const validation = validateAcceptanceReport(JSON.parse(output.slice(bodyStart).trim()) as unknown);
688
757
  return validation.report
689
758
  ? { report: validation.report }
690
- : { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}` };
759
+ : { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}`, malformed: true };
691
760
  } catch (error) {
692
- return { error: `Failed to parse acceptance-report: ${error instanceof Error ? error.message : String(error)}` };
761
+ return { error: `Failed to parse acceptance-report: ${error instanceof Error ? error.message : String(error)}`, malformed: true };
693
762
  }
694
763
  }
695
764
 
696
- function parseGenericJsonAcceptanceReportBody(body: string): { report?: AcceptanceReport; error?: string } {
765
+ function parseGenericJsonAcceptanceReportBody(body: string): AcceptanceReportParseResult {
697
766
  const parsed = parseReportJson(body);
698
767
  const normalized = normalizeAcceptanceReportValue(parsed);
699
768
  const hasCriteriaMarker = normalized.value !== null
@@ -704,12 +773,12 @@ function parseGenericJsonAcceptanceReportBody(body: string): { report?: Acceptan
704
773
  const validation = validateAcceptanceReport(parsed);
705
774
  return validation.report
706
775
  ? { report: validation.report }
707
- : { error: `Invalid acceptance-report: ${validation.errors.join("; ")}` };
776
+ : { error: `Invalid acceptance-report: ${validation.errors.join("; ")}`, malformed: true };
708
777
  }
709
778
 
710
779
  export const ACCEPTANCE_REPORT_NOT_FOUND = "Structured acceptance report not found.";
711
780
 
712
- export function parseAcceptanceReport(output: string): { report?: AcceptanceReport; error?: string } {
781
+ export function parseAcceptanceReport(output: string): AcceptanceReportParseResult {
713
782
  const explicitFencePresent = /```acceptance[-_]report\b/i.test(output);
714
783
  const fenced = fencedBlocks(output, "acceptance[-_]report");
715
784
  const parseErrors: string[] = [];
@@ -722,17 +791,17 @@ export function parseAcceptanceReport(output: string): { report?: AcceptanceRepo
722
791
  parseErrors.push(error instanceof Error ? error.message : String(error));
723
792
  }
724
793
  }
725
- if (parseErrors.length > 0) return { error: `Failed to parse acceptance-report: ${parseErrors.join("; ")}` };
794
+ if (parseErrors.length > 0) return { error: `Failed to parse acceptance-report: ${parseErrors.join("; ")}`, malformed: true };
726
795
  if (explicitFencePresent) {
727
796
  const recovered = parseUnterminatedAcceptanceReportFence(output);
728
797
  if (recovered.report || recovered.error) return recovered;
729
- return { error: "Failed to parse acceptance-report: Empty or unterminated acceptance-report fence." };
798
+ return { error: "Failed to parse acceptance-report: Empty or unterminated acceptance-report fence.", malformed: true };
730
799
  }
731
800
  for (const body of fencedBlocks(output, "(?:json|jsonc|json5)")) {
732
801
  try {
733
802
  const parsed = parseGenericJsonAcceptanceReportBody(body);
734
803
  if (parsed.report) return { report: parsed.report };
735
- if (parsed.error) return { error: `Failed to parse acceptance-report: ${parsed.error}` };
804
+ if (parsed.error) return { error: `Failed to parse acceptance-report: ${parsed.error}`, malformed: parsed.malformed };
736
805
  } catch {
737
806
  // Ignore unrelated malformed generic JSON. A recognizable report shape
738
807
  // returns exact validation errors above instead of being mistaken for prose.
@@ -742,20 +811,20 @@ export function parseAcceptanceReport(output: string): { report?: AcceptanceRepo
742
811
  if (markerIndex !== -1) {
743
812
  const jsonStart = output.indexOf("{", markerIndex);
744
813
  if (jsonStart === -1) {
745
- return { error: "Failed to parse acceptance-report: Expected a JSON object after ACCEPTANCE_REPORT:." };
814
+ return { error: "Failed to parse acceptance-report: Expected a JSON object after ACCEPTANCE_REPORT:.", malformed: true };
746
815
  }
747
816
  const json = extractBalancedJson(output, jsonStart);
748
817
  if (!json) {
749
- return { error: "Failed to parse acceptance-report: Unterminated JSON object after ACCEPTANCE_REPORT:." };
818
+ return { error: "Failed to parse acceptance-report: Unterminated JSON object after ACCEPTANCE_REPORT:.", malformed: true };
750
819
  }
751
820
  try {
752
821
  const parsed = JSON.parse(json) as unknown;
753
822
  const validation = validateAcceptanceReport(parsed);
754
823
  if (validation.report) return { report: validation.report };
755
- return { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}` };
824
+ return { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}`, malformed: true };
756
825
  } catch (error) {
757
826
  const message = error instanceof Error ? error.message : String(error);
758
- return { error: `Failed to parse acceptance-report: ${message}` };
827
+ return { error: `Failed to parse acceptance-report: ${message}`, malformed: true };
759
828
  }
760
829
  }
761
830
  return { error: ACCEPTANCE_REPORT_NOT_FOUND };
@@ -764,14 +833,18 @@ export function parseAcceptanceReport(output: string): { report?: AcceptanceRepo
764
833
  function parseAcceptanceReportSources(
765
834
  output: string,
766
835
  fileOutput: { content: string; path: string; authoritative?: boolean } | undefined,
767
- ): { report?: AcceptanceReport; error?: string } {
836
+ ): AcceptanceReportParseResult {
768
837
  const fromText = () => parseAcceptanceReport(output);
769
838
  const fromFile = () => {
770
839
  if (!fileOutput) return { error: ACCEPTANCE_REPORT_NOT_FOUND };
771
840
  const parsed = parseAcceptanceReport(fileOutput.content);
772
841
  return parsed.report || parsed.error === ACCEPTANCE_REPORT_NOT_FOUND
773
842
  ? parsed
774
- : { error: `${parsed.error} (in configured output ${fileOutput.path})` };
843
+ : {
844
+ ...parsed,
845
+ error: `${parsed.error} (in configured output ${fileOutput.path})`,
846
+ sourcePath: fileOutput.path,
847
+ };
775
848
  };
776
849
  const [primary, secondary] = fileOutput?.authoritative ? [fromFile, fromText] : [fromText, fromFile];
777
850
  const first = primary();
@@ -839,7 +912,7 @@ function validateStringArrayField(errors: string[], value: unknown, pathLabel: s
839
912
  }
840
913
  }
841
914
 
842
- function validateAcceptanceReport(value: unknown, pathLabel = ""): { report?: AcceptanceReport; errors: string[] } {
915
+ export function validateAcceptanceReport(value: unknown, pathLabel = ""): { report?: AcceptanceReport; errors: string[] } {
843
916
  const normalized = normalizeAcceptanceReportValue(value, pathLabel);
844
917
  value = normalized.value;
845
918
  pathLabel = normalized.pathLabel;
@@ -1272,7 +1345,7 @@ export async function evaluateAcceptance(input: {
1272
1345
  * be misattributed). Searched for the acceptance report; searched before
1273
1346
  * the assistant output when `authoritative` (outputMode "file-only").
1274
1347
  */
1275
- fileOutput?: { content: string; path: string; authoritative?: boolean };
1348
+ fileOutput?: { content: string; path: string; authoritative?: boolean; durable?: boolean };
1276
1349
  report?: AcceptanceReport;
1277
1350
  reportError?: string;
1278
1351
  reviewResult?: AcceptanceReviewResult;
@@ -1296,16 +1369,25 @@ export async function evaluateAcceptance(input: {
1296
1369
  };
1297
1370
  if (acceptance.level === "none") return ledger;
1298
1371
 
1299
- const parsed = input.reportError
1372
+ const parsed: AcceptanceReportParseResult = input.reportError
1300
1373
  ? { error: input.reportError }
1301
- : input.report
1374
+ : input.report !== undefined
1302
1375
  ? (() => {
1303
1376
  const validation = validateAcceptanceReport(input.report);
1304
1377
  return validation.report
1305
1378
  ? { report: validation.report }
1306
- : { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}` };
1379
+ : { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}`, malformed: true };
1307
1380
  })()
1308
1381
  : parseAcceptanceReportSources(input.output, input.fileOutput);
1382
+ const durableFileOutput = input.fileOutput?.authoritative && input.fileOutput.durable ? input.fileOutput : undefined;
1383
+ if (parsed.malformed && parsed.sourcePath && durableFileOutput) {
1384
+ ledger.recovery = {
1385
+ status: "available-for-review",
1386
+ reason: "acceptance-metadata-rejected",
1387
+ reportPath: parsed.sourcePath,
1388
+ reportHash: hash(durableFileOutput.content),
1389
+ };
1390
+ }
1309
1391
  const needsReport = acceptanceRequiresChildReport(acceptance);
1310
1392
  if (parsed.report) {
1311
1393
  ledger.childReport = parsed.report;
@@ -1314,6 +1396,10 @@ export async function evaluateAcceptance(input: {
1314
1396
  } else if (!input.reportOptional || needsReport || parsed.error !== ACCEPTANCE_REPORT_NOT_FOUND) {
1315
1397
  ledger.childReportParseError = parsed.error;
1316
1398
  ledger.runtimeChecks.push({ id: "attestation", status: "failed", message: parsed.error ?? "Structured acceptance report missing." });
1399
+ if (ledger.recovery) {
1400
+ ledger.status = "rejected";
1401
+ ledger.evidenceStatus = "rejected";
1402
+ }
1317
1403
  if (!input.reportOptional) {
1318
1404
  ledger.status = "rejected";
1319
1405
  ledger.evidenceStatus = "rejected";
@@ -22,6 +22,7 @@ export interface SubagentCapabilityAudit {
22
22
  requestedTools?: string[];
23
23
  effectiveTools: string[];
24
24
  removedTools: string[];
25
+ excludeTools?: string[];
25
26
  internalTools: string[];
26
27
  extensionsDenied: boolean;
27
28
  removedExtensionCount: number;
@@ -50,7 +50,7 @@ const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outpu
50
50
  const RUNNER_DYNAMIC_PARALLEL_KEYS = new Set([
51
51
  ...DYNAMIC_PARALLEL_KEYS,
52
52
  "outputName", "structured", "inheritProjectContext", "inheritGlobalContext", "inheritSkills", "skills", "outputPath", "namespaceOutputPath", "maxSubagentDepth", "timeoutMs", "waitToolEnabled", "waitToolDefaultTimeoutMs",
53
- "structuredOutput", "structuredOutputSchema", "tools", "allowNestedSubagents", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "mutationTools", "capabilityCeiling", "completionGuard", "systemPrompt",
53
+ "structuredOutput", "structuredOutputSchema", "tools", "excludeTools", "allowNestedSubagents", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "mutationTools", "capabilityCeiling", "completionGuard", "systemPrompt",
54
54
  "systemPromptMode", "thinking", "modelCandidates", "sessionFile", "effectiveAcceptance", "acceptanceInput", "acceptanceRole", "parentSessionId", "launchResolvedExtensions", "requestedCwd",
55
55
  ]);
56
56
  const DYNAMIC_COLLECT_KEYS = new Set(["as", "outputSchema"]);
@@ -144,6 +144,7 @@ export function validHostStepNodes(graph: WorkflowGraphSnapshot | undefined): Ho
144
144
  export function assertWorkflowGraphHostSteps(graph: WorkflowGraphSnapshot | undefined, source = "status", expectedRunId?: string): void {
145
145
  if (!graph) return;
146
146
  if (expectedRunId !== undefined && graph.runId !== expectedRunId) throw new Error(`Invalid host step '${source}': workflowGraph.runId does not match the status run id.`);
147
+ if (!Array.isArray(graph.nodes)) throw new Error(`Invalid host step '${source}.workflowGraph': nodes must be an array.`);
147
148
  const hostStepCount = graph.nodes.filter((node) => node.kind === "host-step").length;
148
149
  if (hostStepCount > HOST_STEP_MAX_COUNT) throw new Error(`Invalid host step '${source}': workflowGraph contains more than ${HOST_STEP_MAX_COUNT} host steps.`);
149
150
  const hostSteps: HostStepNodeV1[] = [];
@@ -321,15 +321,23 @@ export function resolveSubagentModelOverride(
321
321
  const explicit = trimmed && trimmed !== INHERIT_MODEL ? trimmed : undefined;
322
322
  if (!parentModel) throwForUnresolvedEnforcedInheritScope(options?.scope, explicit === undefined || options?.source === "inherited");
323
323
  let resolved: string | undefined;
324
+ let resolvedFromRegistry = explicit === undefined;
324
325
  if (explicit === undefined) {
325
326
  resolved = parentModel ? `${parentModel.provider}/${parentModel.id}` : undefined;
326
327
  } else {
327
- resolved = resolveRequiredSubagentModelCandidate(explicit, availableModels, preferredProvider);
328
- }
329
- if (resolved && explicit !== undefined && options?.source === "explicit") {
330
- throwForExplicitModelExclusion(resolved);
328
+ const candidate = resolveSubagentModelCandidate(explicit, availableModels, preferredProvider);
329
+ if (options?.source === "explicit") {
330
+ resolved = candidate ?? resolveRequiredSubagentModelCandidate(explicit, availableModels, preferredProvider);
331
+ throwForExplicitModelExclusion(resolved);
332
+ resolvedFromRegistry = true;
333
+ } else if (candidate) {
334
+ resolved = candidate;
335
+ resolvedFromRegistry = true;
336
+ } else {
337
+ resolved = explicit;
338
+ }
331
339
  }
332
- if (resolved && options?.scope) {
340
+ if (resolved && options?.scope && resolvedFromRegistry) {
333
341
  const source: ModelSource = explicit === undefined ? "inherited" : (options.source ?? "inherited");
334
342
  enforceModelScopes(resolved, options.scope, source, options.onWarn);
335
343
  }
@@ -362,12 +370,33 @@ export function resolveEffectiveSubagentModel(
362
370
  );
363
371
  }
364
372
 
373
+ export type ModelOrigin = ModelSource | "configured";
374
+
365
375
  export interface BuildModelCandidatesOptions {
366
376
  /** Fallback models warn by default and throw when strict scope enforcement is enabled. */
367
377
  scope?: ModelScopeCheckRule | ModelScopeCheckRule[];
368
378
  onWarn?: (violation: ModelScopeViolation) => void;
369
379
  /** The primary model came from the running parent session, not configuration. */
370
380
  primaryModelFromParent?: boolean;
381
+ /** How the primary model was selected. Explicit stays strict and does not rotate to fallbacks. */
382
+ origin?: ModelOrigin;
383
+ }
384
+
385
+ const ZERO_USABLE_MODEL_CANDIDATES_ERROR =
386
+ "No usable subagent models remain after registry, scope, and cached-exclusion filtering.";
387
+
388
+ export function resolveModelOrigin(input: {
389
+ explicitModel?: string | boolean;
390
+ agentModel?: string | boolean;
391
+ parentModel?: ParentModel;
392
+ fromParent?: boolean;
393
+ storedOrigin?: ModelOrigin;
394
+ }): ModelOrigin {
395
+ if (input.storedOrigin) return input.storedOrigin;
396
+ if (input.fromParent) return "inherited";
397
+ if (inheritsParentModel(input.explicitModel, input.agentModel, input.parentModel)) return "inherited";
398
+ const trimmed = typeof input.explicitModel === "string" ? input.explicitModel.trim() : "";
399
+ return trimmed && trimmed !== INHERIT_MODEL ? "explicit" : "configured";
371
400
  }
372
401
 
373
402
  export function inheritsParentModel(
@@ -388,36 +417,51 @@ export function buildModelCandidates(
388
417
  options?: BuildModelCandidatesOptions,
389
418
  ): string[] {
390
419
  if (!primaryModel) throwForUnresolvedEnforcedInheritScope(options?.scope, true);
420
+ const origin = options?.origin ?? (options?.primaryModelFromParent ? "inherited" : "configured");
421
+ const scopes = configuredScopes(options?.scope);
422
+ const warnCachedExclusion = (candidate: string, exclusion: NonNullable<ReturnType<typeof findModelExclusion>>) => {
423
+ const reason = redactSecretValues((exclusion.reason ?? "runtime-failure").replace(/[\u0000-\u001f\u007f]+/g, " ")).slice(0, 240);
424
+ console.warn(`[pi-subagents] Skipping model '${candidate}' due to a cached exclusion (reason: ${reason}; expires: ${new Date(exclusion.expiresAt).toISOString()}).`);
425
+ };
426
+ if (origin === "explicit" && primaryModel) {
427
+ const normalized = resolveRequiredSubagentModelCandidate(primaryModel.trim(), availableModels, preferredProvider);
428
+ throwForExplicitModelExclusion(normalized);
429
+ enforceModelScopes(normalized, scopes, "explicit", options?.onWarn);
430
+ primaryModel = normalized;
431
+ }
391
432
  const seen = new Set<string>();
392
433
  const candidates: string[] = [];
393
434
  const rawCandidates = [primaryModel, ...(fallbackModels ?? [])];
435
+ let skippedPrimary: string | undefined;
394
436
  for (let index = 0; index < rawCandidates.length; index++) {
395
437
  const raw = rawCandidates[index];
396
438
  if (!raw) continue;
397
439
  const model = raw.trim();
398
- const normalized = index === 0
399
- ? options?.primaryModelFromParent
400
- ? model
401
- : resolveRequiredSubagentModelCandidate(model, availableModels, preferredProvider)
440
+ const normalized = index === 0 && (origin === "inherited" || origin === "explicit" || options?.primaryModelFromParent)
441
+ ? model
402
442
  : resolveSubagentModelCandidate(model, availableModels, preferredProvider);
403
443
  if (!normalized) {
404
- console.warn(`[pi-subagents] Skipping fallback model '${model}' because it is unavailable in this environment.`);
444
+ if (index === 0) skippedPrimary = model;
445
+ else console.warn(`[pi-subagents] Skipping fallback model '${model}' because it is unavailable in this environment.`);
405
446
  continue;
406
447
  }
407
448
  if (seen.has(normalized)) continue;
408
- const scopes = configuredScopes(options?.scope);
409
449
  if (index > 0 || scopes.some((scope) => scope.enforce === true && scope.strict === true)) {
410
450
  enforceModelScopes(normalized, scopes, "inherited", options?.onWarn);
411
451
  }
412
452
  seen.add(normalized);
413
453
  candidates.push(normalized);
414
454
  }
415
- return filterFallbackCandidates(candidates, {
416
- onExcluded(candidate, exclusion) {
417
- const reason = redactSecretValues((exclusion.reason ?? "runtime-failure").replace(/[\u0000-\u001f\u007f]+/g, " ")).slice(0, 240);
418
- console.warn(`[pi-subagents] Skipping model '${candidate}' due to a cached exclusion (reason: ${reason}; expires: ${new Date(exclusion.expiresAt).toISOString()}).`);
419
- },
420
- });
455
+ const resolved = filterFallbackCandidates(candidates, { onExcluded: warnCachedExclusion });
456
+ if (resolved.length === 0) {
457
+ if (skippedPrimary) resolveRequiredSubagentModelCandidate(skippedPrimary, availableModels, preferredProvider);
458
+ if (candidates.length > 0) throw new Error(ZERO_USABLE_MODEL_CANDIDATES_ERROR);
459
+ return resolved;
460
+ }
461
+ if (skippedPrimary) {
462
+ console.warn(`[pi-subagents] Skipping primary model '${skippedPrimary}' because it is unavailable in this environment.`);
463
+ }
464
+ return resolved;
421
465
  }
422
466
 
423
467
  const RETRYABLE_MODEL_FAILURE_PATTERNS = [
@@ -42,6 +42,7 @@ export interface RunnerSubagentStep {
42
42
  skipPrimaryModelVerification?: boolean;
43
43
  modelVerificationRegistry?: Array<{ provider: string; id: string; fullId: string; contextWindow?: number }>;
44
44
  tools?: string[];
45
+ excludeTools?: string[];
45
46
  allowNestedSubagents?: boolean;
46
47
  extensions?: string[];
47
48
  subagentOnlyExtensions?: string[];
@@ -68,12 +69,7 @@ export interface RunnerSubagentStep {
68
69
  toolTimeoutMs?: number;
69
70
  waitToolEnabled?: boolean;
70
71
  waitToolDefaultTimeoutMs?: number;
71
- structuredOutput?: {
72
- schema: import("../../shared/types.ts").JsonSchemaObject;
73
- schemaPath: string;
74
- outputPath: string;
75
- acceptanceReportPath?: string;
76
- };
72
+ structuredOutput?: import("./structured-output.ts").StructuredOutputRuntime;
77
73
  structuredOutputSchema?: import("../../shared/types.ts").JsonSchemaObject;
78
74
  agentContract?: import("../../shared/types.ts").AgentContract;
79
75
  definitionDigest?: string;
@@ -7,7 +7,7 @@ export interface PermissionConfig { rules?: PermissionRules }
7
7
 
8
8
  export const PERMISSION_POLICY_ENV = "PI_SUBAGENT_PERMISSION_POLICY";
9
9
  export const PERMISSION_AUDIT_PATH_ENV = "PI_SUBAGENT_PERMISSION_AUDIT_PATH";
10
- const INTERNAL_TOOLS = new Set(["contact_supervisor", "intercom", "subagent_wait", "structured_output"]);
10
+ const INTERNAL_TOOLS = new Set(["contact_supervisor", "intercom", "bg_wait", "structured_output"]);
11
11
  const DECISIONS = new Set<PermissionDecision>(["allow", "ask", "deny"]);
12
12
  const MAX_POLICY_BYTES = 16 * 1024;
13
13
  const MAX_PREVIEW_BYTES = 2048;
@@ -20,8 +20,10 @@ import { encodeExtensionBindings, PI_SUBAGENT_EXTENSION_BINDINGS_ENV, type Exten
20
20
  import { RUNTIME_EXTENSION_ACK_PATH_ENV } from "./runtime-acknowledged-extensions.ts";
21
21
  import {
22
22
  STRUCTURED_OUTPUT_ACCEPTANCE_CAPTURE_ENV,
23
+ STRUCTURED_OUTPUT_ACCEPTANCE_REQUIRED_ENV,
23
24
  STRUCTURED_OUTPUT_CAPTURE_ENV,
24
25
  STRUCTURED_OUTPUT_SCHEMA_ENV,
26
+ type StructuredOutputRuntime,
25
27
  } from "./structured-output.ts";
26
28
  import {
27
29
  TEMP_ROOT_DIR,
@@ -171,6 +173,7 @@ export interface BuildPiArgsInput {
171
173
  inheritSkills: boolean;
172
174
  requireReadTool?: boolean;
173
175
  tools?: string[];
176
+ excludeTools?: string[];
174
177
  extensions?: string[];
175
178
  subagentOnlyExtensions?: string[];
176
179
  systemPrompt?: string | null;
@@ -201,12 +204,7 @@ export interface BuildPiArgsInput {
201
204
  steerInboxDir?: string;
202
205
  steerCapabilityPath?: string;
203
206
  steerAckDir?: string;
204
- structuredOutput?: {
205
- schema: JsonSchemaObject;
206
- schemaPath: string;
207
- outputPath: string;
208
- acceptanceReportPath?: string;
209
- };
207
+ structuredOutput?: StructuredOutputRuntime;
210
208
  fast?: boolean;
211
209
  modelCandidates?: readonly string[];
212
210
  toolBudget?: ResolvedToolBudget;
@@ -300,6 +298,7 @@ function resolveFastModeExtension(input: Pick<ResolvePiLaunchToolPlanInput, "fas
300
298
 
301
299
  export interface ResolvePiLaunchToolPlanInput {
302
300
  tools?: string[];
301
+ excludeTools?: string[];
303
302
  allowNestedSubagents?: boolean;
304
303
  extensions?: string[];
305
304
  subagentOnlyExtensions?: string[];
@@ -329,6 +328,7 @@ export interface PiLaunchToolPlan {
329
328
  capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
330
329
  requestedBuiltinTools: string[];
331
330
  declaredBuiltinTools: string[];
331
+ excludeTools: string[];
332
332
  toolExtensionPaths: string[];
333
333
  resolvedMcpSelections: ResolvedMcpDirectToolSelection[];
334
334
  effectiveMcpSelections: ResolvedMcpDirectToolSelection[];
@@ -499,8 +499,12 @@ export function resolvePiLaunchToolPlan(
499
499
  ? ["read", ...requestedBuiltinTools]
500
500
  : requestedBuiltinTools
501
501
  ).filter((tool) => !allowedToolSet || allowedToolSet.has(tool));
502
- const fanoutAuthorized = declaredBuiltinTools.includes("subagent") || (
502
+ const excludeTools = [...new Set((input.excludeTools ?? []).map((tool) => tool.trim()).filter(Boolean))];
503
+ const excludedToolSet = new Set(excludeTools);
504
+ const effectiveDeclaredBuiltinTools = declaredBuiltinTools.filter((tool) => !excludedToolSet.has(tool));
505
+ const fanoutAuthorized = effectiveDeclaredBuiltinTools.includes("subagent") || (
503
506
  input.allowNestedSubagents === true &&
507
+ !excludedToolSet.has("subagent") &&
504
508
  (!allowedToolSet || allowedToolSet.has("subagent"))
505
509
  );
506
510
  const toolExtensionPaths: string[] = capabilityCeiling?.denyExtensions
@@ -524,7 +528,7 @@ export function resolvePiLaunchToolPlan(
524
528
  !allowedToolSet ||
525
529
  allowedToolSet.has(selection.name) ||
526
530
  isLegacyUnderscoreMcpToolAllowed(selection, allowedToolSet, resolvedMcpNames, legacyMcpNameCounts),
527
- );
531
+ ).filter((selection) => !excludedToolSet.has(selection.name));
528
532
  const effectiveMcpTools = effectiveMcpSelections.map(
529
533
  (selection) => selection.name,
530
534
  );
@@ -540,10 +544,10 @@ export function resolvePiLaunchToolPlan(
540
544
  input.tools !== undefined ||
541
545
  (input.mcpDirectTools?.length ?? 0) > 0 ||
542
546
  allowedToolSet !== undefined;
543
- const internalTools = input.structuredOutput ? ["structured_output"] : [];
547
+ const internalTools = (input.structuredOutput ? ["structured_output"] : []).filter((tool) => !excludedToolSet.has(tool));
544
548
  const effectiveToolAllowlist = [
545
549
  ...new Set([
546
- ...declaredBuiltinTools,
550
+ ...effectiveDeclaredBuiltinTools,
547
551
  ...effectiveMcpTools,
548
552
  ...internalTools,
549
553
  ]),
@@ -554,11 +558,11 @@ export function resolvePiLaunchToolPlan(
554
558
  // appended intercom alongside contact_supervisor, so that exact pairing is
555
559
  // legacy plumbing, not a user demand for an external intercom provider;
556
560
  // a lone intercom entry stays strictly required (#1207).
557
- const legacySupervisorPairing = declaredBuiltinTools.includes("contact_supervisor");
561
+ const legacySupervisorPairing = effectiveDeclaredBuiltinTools.includes("contact_supervisor");
558
562
  const requiredChildTools = explicitToolAllowlist
559
563
  ? [
560
564
  ...new Set([
561
- ...(input.tools !== undefined ? declaredBuiltinTools : []),
565
+ ...(input.tools !== undefined ? effectiveDeclaredBuiltinTools : []),
562
566
  ...(input.mcpDirectTools?.length ? effectiveMcpTools : []),
563
567
  ...internalTools,
564
568
  ].filter((tool) => tool !== "contact_supervisor" && (!legacySupervisorPairing || tool !== "intercom"))),
@@ -620,6 +624,7 @@ export function resolvePiLaunchToolPlan(
620
624
  ceiling: capabilityCeiling,
621
625
  ...(requestedToolNames ? { requestedTools: requestedToolNames } : {}),
622
626
  effectiveTools: effectiveToolAllowlist,
627
+ ...(excludeTools.length > 0 ? { excludeTools } : {}),
623
628
  removedTools:
624
629
  requestedToolNames?.filter(
625
630
  (tool) => !effectiveToolAllowlist.includes(tool),
@@ -657,6 +662,7 @@ export function resolvePiLaunchToolPlan(
657
662
  ...(capabilityCeiling ? { capabilityCeiling } : {}),
658
663
  requestedBuiltinTools,
659
664
  declaredBuiltinTools,
665
+ excludeTools,
660
666
  toolExtensionPaths,
661
667
  resolvedMcpSelections,
662
668
  effectiveMcpSelections,
@@ -740,6 +746,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
740
746
 
741
747
  const toolPlan = resolvePiLaunchToolPlan({
742
748
  tools: input.tools,
749
+ excludeTools: input.excludeTools,
743
750
  allowNestedSubagents: input.allowNestedSubagents,
744
751
  extensions: input.extensions,
745
752
  subagentOnlyExtensions: input.subagentOnlyExtensions,
@@ -766,6 +773,8 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
766
773
  );
767
774
  if (toolPlan.effectiveToolAllowlist.length > 0)
768
775
  args.push(toolPlan.effectiveToolAllowlist.join(","));
776
+ } else if (toolPlan.excludeTools.length > 0) {
777
+ args.push("--exclude-tools", toolPlan.excludeTools.join(","));
769
778
  }
770
779
  if (toolPlan.disableAmbientExtensions) {
771
780
  args.push("--no-extensions");
@@ -999,10 +1008,13 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
999
1008
  env[SUBAGENT_CAPABILITY_CEILING_ENV] = encodedCapabilityCeiling;
1000
1009
  if (encodedPermissionRules)
1001
1010
  env[PERMISSION_POLICY_ENV] = encodedPermissionRules;
1011
+ env[STRUCTURED_OUTPUT_ACCEPTANCE_CAPTURE_ENV] = undefined;
1012
+ env[STRUCTURED_OUTPUT_ACCEPTANCE_REQUIRED_ENV] = undefined;
1002
1013
  if (input.structuredOutput) {
1003
1014
  env[STRUCTURED_OUTPUT_CAPTURE_ENV] = input.structuredOutput.outputPath;
1004
1015
  env[STRUCTURED_OUTPUT_SCHEMA_ENV] = input.structuredOutput.schemaPath;
1005
1016
  if (input.structuredOutput.acceptanceReportPath) env[STRUCTURED_OUTPUT_ACCEPTANCE_CAPTURE_ENV] = input.structuredOutput.acceptanceReportPath;
1017
+ if (input.structuredOutput.acceptanceReportRequired) env[STRUCTURED_OUTPUT_ACCEPTANCE_REQUIRED_ENV] = "1";
1006
1018
  }
1007
1019
  if (input.steerInboxDir) {
1008
1020
  env[SUBAGENT_STEER_INBOX_ENV] = input.steerInboxDir;