pi-subagents 0.59.0 → 0.61.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 (72) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/docs/agents.md +2 -2
  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 +15 -3
  8. package/docs/workflows.md +15 -14
  9. package/install.mjs +2 -1
  10. package/package.json +1 -1
  11. package/skills/council-mode/SKILL.md +48 -243
  12. package/skills/council-mode/references/pass-contracts.md +150 -0
  13. package/skills/pi-subagents/SKILL.md +89 -37
  14. package/skills/pi-subagents/references/constraints-and-recipes.md +30 -234
  15. package/skills/pi-subagents/references/execution-controls.md +79 -7
  16. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  17. package/skills/pi-subagents/references/multi-lane-orchestration.md +13 -1
  18. package/skills/pi-subagents/references/prompting-and-roles.md +35 -28
  19. package/skills/pi-subagents/references/review-and-validation.md +73 -0
  20. package/src/agents/agent-management.ts +256 -88
  21. package/src/agents/agents.ts +527 -221
  22. package/src/api/background-work.ts +7 -2
  23. package/src/api/external-runs.ts +67 -4
  24. package/src/api/preflight.ts +13 -8
  25. package/src/api/shared-types.ts +3 -0
  26. package/src/extension/index.ts +7 -4
  27. package/src/extension/public-execution.ts +48 -4
  28. package/src/extension/rpc.ts +62 -4
  29. package/src/extension/schemas.ts +12 -7
  30. package/src/extension/tool-description.ts +16 -16
  31. package/src/runs/background/async-execution.ts +53 -37
  32. package/src/runs/background/async-job-tracker.ts +65 -3
  33. package/src/runs/background/async-resume.ts +3 -1
  34. package/src/runs/background/async-status.ts +104 -11
  35. package/src/runs/background/auto-drain.ts +1 -1
  36. package/src/runs/background/control-channel.ts +3 -2
  37. package/src/runs/background/fleet-view.ts +1 -1
  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 +15 -4
  41. package/src/runs/background/subagent-runner.ts +6 -3
  42. package/src/runs/background/subagent-wait.ts +30 -23
  43. package/src/runs/background/wait-completions.ts +4 -1
  44. package/src/runs/background/wait-tool.ts +24 -18
  45. package/src/runs/foreground/execution.ts +72 -4
  46. package/src/runs/foreground/subagent-executor.ts +276 -59
  47. package/src/runs/shared/acceptance.ts +43 -18
  48. package/src/runs/shared/async-status-projection.ts +138 -4
  49. package/src/runs/shared/background-process-options.ts +9 -0
  50. package/src/runs/shared/host-step-status.ts +1 -0
  51. package/src/runs/shared/mcp-direct-tool-grant.ts +2 -5
  52. package/src/runs/shared/model-fallback.ts +61 -17
  53. package/src/runs/shared/mutation-evidence.ts +52 -3
  54. package/src/runs/shared/permissions.ts +1 -1
  55. package/src/runs/shared/pi-args.ts +47 -1
  56. package/src/runs/shared/single-output.ts +45 -18
  57. package/src/runs/shared/subagent-prompt-runtime.ts +20 -2
  58. package/src/runs/shared/tool-timeout.ts +1 -1
  59. package/src/runs/shared/workflow-graph.ts +16 -0
  60. package/src/shared/types.ts +74 -4
  61. package/src/shared/workflow-child-permit.ts +91 -0
  62. package/src/slash/prompt-template-bridge.ts +37 -1
  63. package/src/slash/slash-commands.ts +18 -26
  64. package/src/tui/fleet-status.ts +11 -3
  65. package/src/tui/render-helpers.ts +31 -0
  66. package/src/tui/render.ts +673 -145
  67. package/src/watchdog/change-signature.ts +40 -1
  68. package/src/workflows/host-command.ts +6 -1
  69. package/src/workflows/scripted-workflow.ts +206 -6
  70. package/src/workflows/workflow-child-summary.ts +1 -1
  71. package/src/workflows/workflow-receipt.ts +41 -4
  72. package/src/workflows/workflow-resources.ts +150 -0
@@ -678,7 +678,15 @@ function parseAcceptanceReportBody(body: string): { report?: AcceptanceReport; e
678
678
  return validateAcceptanceReport(parseReportJson(body));
679
679
  }
680
680
 
681
- function parseUnterminatedAcceptanceReportFence(output: string): { report?: AcceptanceReport; error?: string } {
681
+ interface AcceptanceReportParseResult {
682
+ report?: AcceptanceReport;
683
+ error?: string;
684
+ /** True when an acceptance-report signal was found but its envelope was invalid. */
685
+ malformed?: boolean;
686
+ sourcePath?: string;
687
+ }
688
+
689
+ function parseUnterminatedAcceptanceReportFence(output: string): AcceptanceReportParseResult {
682
690
  const opener = /```acceptance[-_]report\b[^\n]*\n/gi.exec(output);
683
691
  if (!opener) return {};
684
692
  const bodyStart = opener.index + opener[0].length;
@@ -687,13 +695,13 @@ function parseUnterminatedAcceptanceReportFence(output: string): { report?: Acce
687
695
  const validation = validateAcceptanceReport(JSON.parse(output.slice(bodyStart).trim()) as unknown);
688
696
  return validation.report
689
697
  ? { report: validation.report }
690
- : { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}` };
698
+ : { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}`, malformed: true };
691
699
  } catch (error) {
692
- return { error: `Failed to parse acceptance-report: ${error instanceof Error ? error.message : String(error)}` };
700
+ return { error: `Failed to parse acceptance-report: ${error instanceof Error ? error.message : String(error)}`, malformed: true };
693
701
  }
694
702
  }
695
703
 
696
- function parseGenericJsonAcceptanceReportBody(body: string): { report?: AcceptanceReport; error?: string } {
704
+ function parseGenericJsonAcceptanceReportBody(body: string): AcceptanceReportParseResult {
697
705
  const parsed = parseReportJson(body);
698
706
  const normalized = normalizeAcceptanceReportValue(parsed);
699
707
  const hasCriteriaMarker = normalized.value !== null
@@ -704,12 +712,12 @@ function parseGenericJsonAcceptanceReportBody(body: string): { report?: Acceptan
704
712
  const validation = validateAcceptanceReport(parsed);
705
713
  return validation.report
706
714
  ? { report: validation.report }
707
- : { error: `Invalid acceptance-report: ${validation.errors.join("; ")}` };
715
+ : { error: `Invalid acceptance-report: ${validation.errors.join("; ")}`, malformed: true };
708
716
  }
709
717
 
710
718
  export const ACCEPTANCE_REPORT_NOT_FOUND = "Structured acceptance report not found.";
711
719
 
712
- export function parseAcceptanceReport(output: string): { report?: AcceptanceReport; error?: string } {
720
+ export function parseAcceptanceReport(output: string): AcceptanceReportParseResult {
713
721
  const explicitFencePresent = /```acceptance[-_]report\b/i.test(output);
714
722
  const fenced = fencedBlocks(output, "acceptance[-_]report");
715
723
  const parseErrors: string[] = [];
@@ -722,17 +730,17 @@ export function parseAcceptanceReport(output: string): { report?: AcceptanceRepo
722
730
  parseErrors.push(error instanceof Error ? error.message : String(error));
723
731
  }
724
732
  }
725
- if (parseErrors.length > 0) return { error: `Failed to parse acceptance-report: ${parseErrors.join("; ")}` };
733
+ if (parseErrors.length > 0) return { error: `Failed to parse acceptance-report: ${parseErrors.join("; ")}`, malformed: true };
726
734
  if (explicitFencePresent) {
727
735
  const recovered = parseUnterminatedAcceptanceReportFence(output);
728
736
  if (recovered.report || recovered.error) return recovered;
729
- return { error: "Failed to parse acceptance-report: Empty or unterminated acceptance-report fence." };
737
+ return { error: "Failed to parse acceptance-report: Empty or unterminated acceptance-report fence.", malformed: true };
730
738
  }
731
739
  for (const body of fencedBlocks(output, "(?:json|jsonc|json5)")) {
732
740
  try {
733
741
  const parsed = parseGenericJsonAcceptanceReportBody(body);
734
742
  if (parsed.report) return { report: parsed.report };
735
- if (parsed.error) return { error: `Failed to parse acceptance-report: ${parsed.error}` };
743
+ if (parsed.error) return { error: `Failed to parse acceptance-report: ${parsed.error}`, malformed: parsed.malformed };
736
744
  } catch {
737
745
  // Ignore unrelated malformed generic JSON. A recognizable report shape
738
746
  // returns exact validation errors above instead of being mistaken for prose.
@@ -742,20 +750,20 @@ export function parseAcceptanceReport(output: string): { report?: AcceptanceRepo
742
750
  if (markerIndex !== -1) {
743
751
  const jsonStart = output.indexOf("{", markerIndex);
744
752
  if (jsonStart === -1) {
745
- return { error: "Failed to parse acceptance-report: Expected a JSON object after ACCEPTANCE_REPORT:." };
753
+ return { error: "Failed to parse acceptance-report: Expected a JSON object after ACCEPTANCE_REPORT:.", malformed: true };
746
754
  }
747
755
  const json = extractBalancedJson(output, jsonStart);
748
756
  if (!json) {
749
- return { error: "Failed to parse acceptance-report: Unterminated JSON object after ACCEPTANCE_REPORT:." };
757
+ return { error: "Failed to parse acceptance-report: Unterminated JSON object after ACCEPTANCE_REPORT:.", malformed: true };
750
758
  }
751
759
  try {
752
760
  const parsed = JSON.parse(json) as unknown;
753
761
  const validation = validateAcceptanceReport(parsed);
754
762
  if (validation.report) return { report: validation.report };
755
- return { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}` };
763
+ return { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}`, malformed: true };
756
764
  } catch (error) {
757
765
  const message = error instanceof Error ? error.message : String(error);
758
- return { error: `Failed to parse acceptance-report: ${message}` };
766
+ return { error: `Failed to parse acceptance-report: ${message}`, malformed: true };
759
767
  }
760
768
  }
761
769
  return { error: ACCEPTANCE_REPORT_NOT_FOUND };
@@ -764,14 +772,18 @@ export function parseAcceptanceReport(output: string): { report?: AcceptanceRepo
764
772
  function parseAcceptanceReportSources(
765
773
  output: string,
766
774
  fileOutput: { content: string; path: string; authoritative?: boolean } | undefined,
767
- ): { report?: AcceptanceReport; error?: string } {
775
+ ): AcceptanceReportParseResult {
768
776
  const fromText = () => parseAcceptanceReport(output);
769
777
  const fromFile = () => {
770
778
  if (!fileOutput) return { error: ACCEPTANCE_REPORT_NOT_FOUND };
771
779
  const parsed = parseAcceptanceReport(fileOutput.content);
772
780
  return parsed.report || parsed.error === ACCEPTANCE_REPORT_NOT_FOUND
773
781
  ? parsed
774
- : { error: `${parsed.error} (in configured output ${fileOutput.path})` };
782
+ : {
783
+ ...parsed,
784
+ error: `${parsed.error} (in configured output ${fileOutput.path})`,
785
+ sourcePath: fileOutput.path,
786
+ };
775
787
  };
776
788
  const [primary, secondary] = fileOutput?.authoritative ? [fromFile, fromText] : [fromText, fromFile];
777
789
  const first = primary();
@@ -1272,7 +1284,7 @@ export async function evaluateAcceptance(input: {
1272
1284
  * be misattributed). Searched for the acceptance report; searched before
1273
1285
  * the assistant output when `authoritative` (outputMode "file-only").
1274
1286
  */
1275
- fileOutput?: { content: string; path: string; authoritative?: boolean };
1287
+ fileOutput?: { content: string; path: string; authoritative?: boolean; durable?: boolean };
1276
1288
  report?: AcceptanceReport;
1277
1289
  reportError?: string;
1278
1290
  reviewResult?: AcceptanceReviewResult;
@@ -1296,16 +1308,25 @@ export async function evaluateAcceptance(input: {
1296
1308
  };
1297
1309
  if (acceptance.level === "none") return ledger;
1298
1310
 
1299
- const parsed = input.reportError
1311
+ const parsed: AcceptanceReportParseResult = input.reportError
1300
1312
  ? { error: input.reportError }
1301
1313
  : input.report
1302
1314
  ? (() => {
1303
1315
  const validation = validateAcceptanceReport(input.report);
1304
1316
  return validation.report
1305
1317
  ? { report: validation.report }
1306
- : { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}` };
1318
+ : { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}`, malformed: true };
1307
1319
  })()
1308
1320
  : parseAcceptanceReportSources(input.output, input.fileOutput);
1321
+ const durableFileOutput = input.fileOutput?.authoritative && input.fileOutput.durable ? input.fileOutput : undefined;
1322
+ if (parsed.malformed && parsed.sourcePath && durableFileOutput) {
1323
+ ledger.recovery = {
1324
+ status: "available-for-review",
1325
+ reason: "acceptance-metadata-rejected",
1326
+ reportPath: parsed.sourcePath,
1327
+ reportHash: hash(durableFileOutput.content),
1328
+ };
1329
+ }
1309
1330
  const needsReport = acceptanceRequiresChildReport(acceptance);
1310
1331
  if (parsed.report) {
1311
1332
  ledger.childReport = parsed.report;
@@ -1314,6 +1335,10 @@ export async function evaluateAcceptance(input: {
1314
1335
  } else if (!input.reportOptional || needsReport || parsed.error !== ACCEPTANCE_REPORT_NOT_FOUND) {
1315
1336
  ledger.childReportParseError = parsed.error;
1316
1337
  ledger.runtimeChecks.push({ id: "attestation", status: "failed", message: parsed.error ?? "Structured acceptance report missing." });
1338
+ if (ledger.recovery) {
1339
+ ledger.status = "rejected";
1340
+ ledger.evidenceStatus = "rejected";
1341
+ }
1317
1342
  if (!input.reportOptional) {
1318
1343
  ledger.status = "rejected";
1319
1344
  ledger.evidenceStatus = "rejected";
@@ -2,6 +2,7 @@ import { sanitizeDisplayText, truncateDisplayText } from "../../shared/display-t
2
2
  import { formatModelThinking } from "../../shared/formatters.ts";
3
3
  import type { AsyncJobState, AsyncJobStep, HostStepFreshnessV1, HostStepMonitorKind, HostStepNodeV1, HostStepState, HostStepVerdict, NestedRunSummary, NestedStepSummary, SubagentRunMode, WorkflowGraphSnapshot, WorkflowPreflightLaneV1, WorkflowPreflightV1 } from "../../shared/types.ts";
4
4
  import { HOST_STEP_MAX_COUNT, HOST_STEP_MAX_DETAIL_CHARS, HOST_STEP_MAX_LABEL_CHARS, HOST_STEP_MAX_PROVIDER_CHARS, HOST_STEP_MAX_REASON_CHARS, HOST_STEP_MAX_REF_CHARS, HOST_STEP_MAX_ROLE_CHARS, HOST_STEP_MAX_TARGET_CHARS, hostStepReportName, parseHostStepNode, validHostStepNodes } from "./host-step-status.ts";
5
+ import { workflowGraphStageNodes } from "./workflow-graph.ts";
5
6
 
6
7
  export const ASYNC_STATUS_SNAPSHOT_KIND = "pi-subagents.async-status-snapshot";
7
8
  export const ASYNC_STATUS_SNAPSHOT_VERSION = 1;
@@ -15,6 +16,21 @@ const DEFAULT_MAX_SERIALIZED_BYTES = 32 * 1024;
15
16
  export type AsyncStatusSnapshotState = "queued" | "running" | "complete" | "failed" | "partial" | "paused" | "stopped" | "rejected";
16
17
  export type AsyncStatusSnapshotKind = "subagent" | "workflow" | "step";
17
18
 
19
+ const ASYNC_STATUS_SNAPSHOT_STATES: Record<AsyncStatusSnapshotState, true> = {
20
+ queued: true,
21
+ running: true,
22
+ complete: true,
23
+ failed: true,
24
+ partial: true,
25
+ paused: true,
26
+ stopped: true,
27
+ rejected: true,
28
+ };
29
+
30
+ function isAsyncStatusSnapshotState(value: string): value is AsyncStatusSnapshotState {
31
+ return Object.hasOwn(ASYNC_STATUS_SNAPSHOT_STATES, value);
32
+ }
33
+
18
34
  export interface AsyncStatusSnapshotActivityV1 {
19
35
  state?: string;
20
36
  currentTool?: string;
@@ -87,6 +103,7 @@ export interface AsyncStatusWorkflowRow {
87
103
  state: AsyncJobStep["status"] | HostStepState | "planned";
88
104
  /** Present only for typed host-owned monitor rows; legacy child rows omit it. */
89
105
  kind?: HostStepMonitorKind;
106
+ context?: AsyncJobStep["context"];
90
107
  modelThinking?: string;
91
108
  activity?: string;
92
109
  startedAt?: number;
@@ -155,7 +172,8 @@ function publicCount(value: unknown): number | undefined {
155
172
  function normalizeState(value: string): AsyncStatusSnapshotState {
156
173
  if (value === "completed") return "complete";
157
174
  if (value === "pending") return "queued";
158
- return value as AsyncStatusSnapshotState;
175
+ if (isAsyncStatusSnapshotState(value)) return value;
176
+ return "partial";
159
177
  }
160
178
 
161
179
  function terminalState(state: AsyncStatusSnapshotState): boolean {
@@ -230,6 +248,16 @@ function projectStep(step: AsyncJobStep | NestedStepSummary, index: number, dept
230
248
  return node;
231
249
  }
232
250
 
251
+ function projectWorkflowGraphNode(node: WorkflowGraphSnapshot["nodes"][number], index: number, depth: number, ctx: ProjectionContext): AsyncStatusSnapshotNodeV1 {
252
+ const step: AsyncJobStep = {
253
+ agent: node.agent ?? node.label,
254
+ status: workflowGraphStepStatus(node.status),
255
+ workflowKey: node.id,
256
+ label: node.label,
257
+ };
258
+ return projectStep(step, node.flatIndex ?? index, depth, ctx);
259
+ }
260
+
233
261
  function projectNestedRun(child: NestedRunSummary, index: number, depth: number, ctx: ProjectionContext): AsyncStatusSnapshotNodeV1 {
234
262
  const state = normalizeState(child.state);
235
263
  const startedAt = publicTime(child.startedAt);
@@ -310,9 +338,14 @@ function projectRun(job: AsyncJobState, ctx: ProjectionContext): AsyncStatusSnap
310
338
  };
311
339
  if (ctx.caps.maxDepth > 0) {
312
340
  const stepChildren = job.steps?.map((step, index) => projectStep(step, step.index ?? index, 1, ctx)) ?? [];
341
+ const loadedKeys = new Set(job.steps?.flatMap((step) => step.workflowKey ? [step.workflowKey] : []) ?? []);
342
+ const graphStages = job.mode === "workflow" ? workflowGraphStageNodes(job.workflowGraph) : [];
343
+ const graphChildren = graphStages
344
+ .filter((graphNode) => !loadedKeys.has(graphNode.id))
345
+ .map((graphNode, index) => projectWorkflowGraphNode(graphNode, index, 1, ctx));
313
346
  const nestedChildren = job.nestedChildren?.map((child, index) => projectNestedRun(child, index, 1, ctx)) ?? [];
314
347
  const hostStepChildren = validHostStepList(job.hostSteps).map((hostStep) => projectHostStep(hostStep, ctx));
315
- const ordinaryChildren = [...stepChildren, ...nestedChildren];
348
+ const ordinaryChildren = [...stepChildren, ...graphChildren, ...nestedChildren];
316
349
  const retainedHostSteps = hostStepChildren.slice(0, ctx.caps.maxChildrenPerNode);
317
350
  const retainedOrdinaryChildren = ordinaryChildren.slice(0, ctx.caps.maxChildrenPerNode - retainedHostSteps.length);
318
351
  const bounded: AsyncStatusSnapshotNodeV1[] = [];
@@ -320,7 +353,10 @@ function projectRun(job: AsyncJobState, ctx: ProjectionContext): AsyncStatusSnap
320
353
  ctx.omitted.children += ordinaryChildren.length - retainedOrdinaryChildren.length + hostStepChildren.length - retainedHostSteps.length;
321
354
  if (bounded.length) node.children = bounded;
322
355
  } else {
323
- ctx.omitted.children += (job.steps?.length ?? 0) + (job.nestedChildren?.length ?? 0) + validHostStepList(job.hostSteps).length;
356
+ const loadedKeys = new Set(job.steps?.flatMap((step) => step.workflowKey ? [step.workflowKey] : []) ?? []);
357
+ const graphStages = job.mode === "workflow" ? workflowGraphStageNodes(job.workflowGraph) : [];
358
+ const graphCount = graphStages.filter((graphNode) => !loadedKeys.has(graphNode.id)).length;
359
+ ctx.omitted.children += (job.steps?.length ?? 0) + graphCount + (job.nestedChildren?.length ?? 0) + validHostStepList(job.hostSteps).length;
324
360
  }
325
361
  return node;
326
362
  }
@@ -392,6 +428,50 @@ function isWorkflowPreflight(value: readonly HostStepNodeV1[] | WorkflowGraphSna
392
428
  return value !== undefined && !Array.isArray(value) && "lanes" in value;
393
429
  }
394
430
 
431
+ function isWorkflowGraph(value: readonly HostStepNodeV1[] | WorkflowGraphSnapshot | WorkflowPreflightV1 | undefined): value is WorkflowGraphSnapshot {
432
+ return value !== undefined && !Array.isArray(value) && "nodes" in value;
433
+ }
434
+
435
+ function workflowGraphRowState(status: WorkflowGraphSnapshot["nodes"][number]["status"]): AsyncStatusWorkflowRow["state"] {
436
+ switch (status) {
437
+ case "pending":
438
+ return "planned";
439
+ case "completed":
440
+ return "complete";
441
+ case "detached":
442
+ return "paused";
443
+ default:
444
+ return status;
445
+ }
446
+ }
447
+
448
+ function workflowGraphStepStatus(status: WorkflowGraphSnapshot["nodes"][number]["status"]): AsyncJobStep["status"] {
449
+ switch (status) {
450
+ case "completed":
451
+ return "complete";
452
+ case "detached":
453
+ return "paused";
454
+ default:
455
+ return status;
456
+ }
457
+ }
458
+
459
+ function workflowGraphRowName(node: WorkflowGraphSnapshot["nodes"][number]): string {
460
+ const key = publicText(node.id, "stage", HOST_STEP_MAX_LABEL_CHARS);
461
+ const label = publicOptionalText(node.label, HOST_STEP_MAX_LABEL_CHARS);
462
+ const phase = publicOptionalText(node.phase, HOST_STEP_MAX_LABEL_CHARS);
463
+ const agent = publicOptionalText(node.agent, HOST_STEP_MAX_LABEL_CHARS);
464
+ return `${phase ? `${phase}: ` : ""}${key}${label && label !== node.id ? ` · ${label}` : ""}${agent ? ` (${agent})` : ""}`;
465
+ }
466
+
467
+ function projectWorkflowGraphRow(node: WorkflowGraphSnapshot["nodes"][number], preflight?: WorkflowPreflightLaneV1): AsyncStatusWorkflowRow {
468
+ return {
469
+ name: workflowGraphRowName(node),
470
+ state: workflowGraphRowState(node.status),
471
+ ...(preflight ? { preflight } : {}),
472
+ };
473
+ }
474
+
395
475
  /** Project loaded workflow child facts plus stored preflight hints into compact rows. */
396
476
  export function projectAsyncWorkflowRows(
397
477
  steps: readonly AsyncJobStep[] | undefined,
@@ -399,9 +479,62 @@ export function projectAsyncWorkflowRows(
399
479
  preflightOverride?: WorkflowPreflightV1,
400
480
  ): AsyncStatusWorkflowRow[] {
401
481
  const preflight = preflightOverride ?? (isWorkflowPreflight(hostStepsOrPreflight) ? hostStepsOrPreflight : undefined);
402
- const hostSteps = isWorkflowPreflight(hostStepsOrPreflight) ? undefined : hostStepsOrPreflight;
482
+ const graph = isWorkflowGraph(hostStepsOrPreflight) ? hostStepsOrPreflight : undefined;
483
+ const hostSteps = isWorkflowPreflight(hostStepsOrPreflight) || graph ? undefined : hostStepsOrPreflight;
403
484
  const loaded = steps ?? [];
404
485
  const declared = new Map<string, WorkflowPreflightLaneV1>();
486
+ if (graph) {
487
+ const loadedIndexesByKey = new Map<string, number[]>();
488
+ for (const [index, step] of loaded.entries()) {
489
+ if (!step.workflowKey) continue;
490
+ const indexes = loadedIndexesByKey.get(step.workflowKey) ?? [];
491
+ indexes.push(index);
492
+ loadedIndexesByKey.set(step.workflowKey, indexes);
493
+ }
494
+ const consumed = new Set<number>();
495
+ const childRows: AsyncStatusWorkflowRow[] = [];
496
+ for (const lane of preflight?.lanes ?? []) declared.set(lane.key, lane);
497
+ const graphStages = workflowGraphStageNodes(graph);
498
+ const graphKeys = new Set(graphStages.map((node) => node.id));
499
+ const graphPhaseByNodeId = new Map<string, string>();
500
+ for (const phase of graph.phases) {
501
+ for (const nodeId of phase.nodeIds) {
502
+ if (graphKeys.has(nodeId) && !graphPhaseByNodeId.has(nodeId)) graphPhaseByNodeId.set(nodeId, phase.title);
503
+ }
504
+ }
505
+ const graphLaneKeys = new Set(graphPhaseByNodeId.values());
506
+ const preflightForNode = (node: WorkflowGraphSnapshot["nodes"][number]): WorkflowPreflightLaneV1 | undefined => {
507
+ const phaseTitle = graphPhaseByNodeId.get(node.id);
508
+ return declared.get(node.id) ?? (node.phase ? declared.get(node.phase) : undefined) ?? (phaseTitle ? declared.get(phaseTitle) : undefined);
509
+ };
510
+ for (const node of graphStages) {
511
+ const indexes = (loadedIndexesByKey.get(node.id) ?? []).filter((index) => !consumed.has(index));
512
+ if (indexes.length > 0) {
513
+ for (const index of indexes) {
514
+ consumed.add(index);
515
+ childRows.push(projectLoadedWorkflowRow(loaded[index]!, index, preflightForNode(node)));
516
+ }
517
+ } else {
518
+ childRows.push(projectWorkflowGraphRow(node, preflightForNode(node)));
519
+ }
520
+ }
521
+ for (const lane of preflight?.lanes ?? []) {
522
+ if (graphKeys.has(lane.key) || graphLaneKeys.has(lane.key)) continue;
523
+ const indexes = (loadedIndexesByKey.get(lane.key) ?? []).filter((index) => !consumed.has(index));
524
+ if (indexes.length > 0) {
525
+ for (const index of indexes) {
526
+ consumed.add(index);
527
+ childRows.push(projectLoadedWorkflowRow(loaded[index]!, index, lane));
528
+ }
529
+ } else {
530
+ childRows.push({ name: lane.key, state: "planned", preflight: lane });
531
+ }
532
+ }
533
+ for (const [index, step] of loaded.entries()) {
534
+ if (!consumed.has(index)) childRows.push(projectLoadedWorkflowRow(step, index, step.workflowKey ? declared.get(step.workflowKey) : undefined));
535
+ }
536
+ return [...childRows, ...validHostStepList(graph).map(hostStepRow)];
537
+ }
405
538
  const loadedIndexByKey = new Map<string, number>();
406
539
  for (const [index, step] of loaded.entries()) {
407
540
  if (step.workflowKey !== undefined && !loadedIndexByKey.has(step.workflowKey)) loadedIndexByKey.set(step.workflowKey, index);
@@ -431,6 +564,7 @@ function projectLoadedWorkflowRow(step: AsyncJobStep, index: number, preflight?:
431
564
  return {
432
565
  name: workflowStepName(step, index),
433
566
  state: step.status,
567
+ ...(step.context ? { context: step.context } : {}),
434
568
  ...(modelThinking ? { modelThinking } : {}),
435
569
  ...(activity ? { activity } : {}),
436
570
  ...(step.startedAt !== undefined ? { startedAt: step.startedAt } : {}),
@@ -0,0 +1,9 @@
1
+ export function backgroundProcessOptions(platform: NodeJS.Platform = process.platform): {
2
+ detached: boolean;
3
+ windowsHide: true;
4
+ } {
5
+ return {
6
+ detached: platform !== "win32",
7
+ windowsHide: true,
8
+ };
9
+ }
@@ -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[] = [];
@@ -125,11 +125,8 @@ export function formatUnresolvedMcpDirectToolSelectors(selectors: readonly strin
125
125
 
126
126
  function getServerPrefix(serverName: string, mode: McpToolPrefix): string {
127
127
  if (mode === "none") return "";
128
- if (mode === "short") {
129
- const short = serverName.replace(/-?mcp$/i, "").replace(/-/g, "_");
130
- return short || "mcp";
131
- }
132
- return serverName.replace(/-/g, "_");
128
+ if (mode === "short") return serverName.replace(/-?mcp$/i, "") || "mcp";
129
+ return serverName;
133
130
  }
134
131
 
135
132
  function formatToolName(toolName: string, serverName: string, prefix: McpToolPrefix): string {
@@ -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 = [
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import * as fs from "node:fs";
4
4
  import * as os from "node:os";
5
5
  import * as path from "node:path";
6
- import type { ArtifactPaths, TimeoutRecoverySummary, TrackedMutationEvidence, TrackedMutationFingerprint, TrackedMutationSnapshot } from "../../shared/types.ts";
6
+ import type { ArtifactPaths, TimeoutRecoveryProjection, TimeoutRecoverySummary, TrackedMutationEvidence, TrackedMutationFingerprint, TrackedMutationSnapshot } from "../../shared/types.ts";
7
7
 
8
8
  const MAX_TRACKED_PATHS = 500;
9
9
  const MAX_HASH_BYTES = 1024 * 1024;
@@ -15,7 +15,7 @@ function gitArguments(args: string[]): string[] {
15
15
  }
16
16
 
17
17
  function gitOutput(cwd: string, args: string[], maxBuffer = MAX_HASH_BYTES): string {
18
- return execFileSync("git", gitArguments(args), { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer });
18
+ return execFileSync("git", gitArguments(args), { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer, windowsHide: true });
19
19
  }
20
20
 
21
21
  function splitNul(output: string): string[] {
@@ -26,7 +26,7 @@ function hashLargeDiff(cwd: string, relativePath: string): string {
26
26
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagents-tracked-diff-"));
27
27
  const diffPath = path.join(tempDir, "diff.patch");
28
28
  try {
29
- execFileSync("git", gitArguments(["diff", "--no-ext-diff", "--binary", `--output=${diffPath}`, "HEAD", "--", relativePath]), { cwd, stdio: "ignore" });
29
+ execFileSync("git", gitArguments(["diff", "--no-ext-diff", "--binary", `--output=${diffPath}`, "HEAD", "--", relativePath]), { cwd, stdio: "ignore", windowsHide: true });
30
30
  const hash = createHash("sha256");
31
31
  const buffer = Buffer.allocUnsafe(64 * 1024);
32
32
  const fd = fs.openSync(diffPath, "r");
@@ -110,9 +110,48 @@ function formatPathList(paths: string[]): string {
110
110
  return paths.length > MAX_TIMEOUT_FILES ? `${shown}, ... (${paths.length - MAX_TIMEOUT_FILES} more)` : shown;
111
111
  }
112
112
 
113
+ /** Keep status and completion details to bounded routing evidence, not raw output or effects. */
114
+ export function projectTimeoutRecovery(value: unknown): TimeoutRecoveryProjection | undefined {
115
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
116
+ const source = value as Record<string, unknown>;
117
+ const termination = source.termination === "timed-out" || source.termination === "stopped" ? source.termination : undefined;
118
+ if (!termination || !Array.isArray(source.changedFiles)) return undefined;
119
+ const allChangedFiles = source.changedFiles.filter((file): file is string => typeof file === "string");
120
+ const changedFiles = allChangedFiles.slice(0, MAX_TIMEOUT_FILES);
121
+ const reportStatus = source.reportStatus === "missing"
122
+ || source.reportStatus === "written"
123
+ || source.reportStatus === "not-requested"
124
+ || source.reportStatus === "unknown"
125
+ ? source.reportStatus
126
+ : undefined;
127
+ return {
128
+ termination,
129
+ changedFiles,
130
+ ...(source.truncated === true || allChangedFiles.length > changedFiles.length ? { truncated: true } : {}),
131
+ ...(source.recoveryNeeded === true ? { recoveryNeeded: true } : {}),
132
+ ...(source.reason === "timed-out-with-dirty-worktree" ? { reason: source.reason } : {}),
133
+ ...(reportStatus ? { reportStatus } : {}),
134
+ };
135
+ }
136
+
137
+ /** Render only the bounded recovery route needed by a parent/operator. */
138
+ export function formatTimeoutRecoveryLines(value: unknown, indent = ""): string[] {
139
+ const recovery = projectTimeoutRecovery(value);
140
+ if (!recovery?.recoveryNeeded) return [];
141
+ const changedFiles = recovery.changedFiles.length > 0
142
+ ? `${recovery.changedFiles.join(", ")}${recovery.truncated ? ", …" : ""}`
143
+ : "none";
144
+ const changedFileCount = recovery.changedFiles.length > 0 ? `${recovery.changedFiles.length}${recovery.truncated ? "+" : ""}` : "0";
145
+ return [
146
+ `${indent}Recovery needed: review the diff and artifacts before resuming or launching dependent stages.`,
147
+ `${indent}Recovery evidence: requested report: ${recovery.reportStatus ?? "unknown"}; changed tracked files: ${changedFiles} (${changedFileCount}); classification: ${recovery.reason ?? recovery.termination}`,
148
+ ];
149
+ }
150
+
113
151
  export function buildTimeoutRecoverySummary(input: {
114
152
  termination: "timed-out" | "stopped";
115
153
  evidence: TrackedMutationEvidence;
154
+ requiredOutputMissing?: boolean;
116
155
  currentTool?: string;
117
156
  currentToolArgs?: string;
118
157
  currentPath?: string;
@@ -122,11 +161,19 @@ export function buildTimeoutRecoverySummary(input: {
122
161
  }): TimeoutRecoverySummary {
123
162
  const warning = "Inspect partial changes before retrying or resuming the child.";
124
163
  const changedFiles = input.evidence.changedFiles.slice(0, MAX_TIMEOUT_FILES);
164
+ let reportStatus: "missing" | "written" | "not-requested" = "not-requested";
165
+ if (input.requiredOutputMissing === true) reportStatus = "missing";
166
+ else if (input.requiredOutputMissing === false) reportStatus = "written";
167
+ const recoveryNeeded = input.termination === "timed-out"
168
+ && reportStatus === "missing"
169
+ && input.evidence.changedFiles.length > 0;
125
170
  const lines = [
126
171
  "Recovery summary:",
127
172
  `- termination: ${input.termination}`,
128
173
  `- changed tracked files: ${input.evidence.unavailable ? `unavailable (${input.evidence.unavailable})` : formatPathList(input.evidence.changedFiles)}`,
129
174
  ];
175
+ if (input.requiredOutputMissing !== undefined) lines.push(`- requested report: ${reportStatus}`);
176
+ if (recoveryNeeded) lines.push("- Recovery needed: review the diff and artifacts before resuming or launching dependent stages.");
130
177
  if (input.currentTool) lines.push(`- active tool: ${input.currentTool}${input.currentToolArgs ? ` — ${input.currentToolArgs}` : ""}`);
131
178
  if (input.currentPath) lines.push(`- active path: ${input.currentPath}`);
132
179
  if (input.sessionFile) lines.push(`- session file: ${input.sessionFile}`);
@@ -138,6 +185,8 @@ export function buildTimeoutRecoverySummary(input: {
138
185
  termination: input.termination,
139
186
  changedFiles,
140
187
  truncated: input.evidence.changedFiles.length > changedFiles.length || input.evidence.truncated || undefined,
188
+ ...(recoveryNeeded ? { recoveryNeeded: true, reason: "timed-out-with-dirty-worktree" as const } : {}),
189
+ reportStatus,
141
190
  currentTool: input.currentTool,
142
191
  currentToolArgs: input.currentToolArgs,
143
192
  currentPath: input.currentPath,
@@ -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", "subagent_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;