pi-subagents 0.60.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 (57) hide show
  1. package/CHANGELOG.md +32 -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 +12 -3
  8. package/docs/workflows.md +14 -13
  9. package/install.mjs +2 -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 +42 -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 +102 -63
  18. package/src/agents/agents.ts +527 -221
  19. package/src/api/background-work.ts +7 -2
  20. package/src/api/external-runs.ts +67 -4
  21. package/src/api/preflight.ts +13 -8
  22. package/src/api/shared-types.ts +1 -0
  23. package/src/extension/index.ts +7 -4
  24. package/src/extension/public-execution.ts +47 -4
  25. package/src/extension/rpc.ts +62 -4
  26. package/src/extension/schemas.ts +11 -7
  27. package/src/extension/tool-description.ts +14 -18
  28. package/src/runs/background/async-execution.ts +51 -35
  29. package/src/runs/background/async-job-tracker.ts +62 -3
  30. package/src/runs/background/async-resume.ts +3 -1
  31. package/src/runs/background/async-status.ts +59 -9
  32. package/src/runs/background/auto-drain.ts +1 -1
  33. package/src/runs/background/fleet-view.ts +1 -1
  34. package/src/runs/background/result-watcher.ts +1 -1
  35. package/src/runs/background/resume-guidance.ts +1 -1
  36. package/src/runs/background/run-status.ts +2 -2
  37. package/src/runs/background/subagent-runner.ts +1 -2
  38. package/src/runs/background/subagent-wait.ts +20 -21
  39. package/src/runs/background/wait-completions.ts +1 -1
  40. package/src/runs/background/wait-tool.ts +24 -18
  41. package/src/runs/foreground/execution.ts +61 -2
  42. package/src/runs/foreground/subagent-executor.ts +178 -58
  43. package/src/runs/shared/acceptance.ts +43 -18
  44. package/src/runs/shared/host-step-status.ts +1 -0
  45. package/src/runs/shared/model-fallback.ts +61 -17
  46. package/src/runs/shared/permissions.ts +1 -1
  47. package/src/runs/shared/tool-timeout.ts +1 -1
  48. package/src/runs/shared/workflow-graph.ts +3 -2
  49. package/src/shared/types.ts +40 -3
  50. package/src/shared/workflow-child-permit.ts +91 -0
  51. package/src/slash/prompt-template-bridge.ts +37 -1
  52. package/src/slash/slash-commands.ts +18 -26
  53. package/src/tui/render.ts +95 -52
  54. package/src/workflows/scripted-workflow.ts +153 -4
  55. package/src/workflows/workflow-child-summary.ts +1 -1
  56. package/src/workflows/workflow-receipt.ts +41 -4
  57. 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";
@@ -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 = [
@@ -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;
@@ -16,7 +16,7 @@ export const DEFAULT_FAST_TOOL_TIMEOUT_TOOLS = new Set([
16
16
  ]);
17
17
 
18
18
  /** Tools whose normal job can be to wait for a person or another run. */
19
- export const TOOL_TIMEOUT_EXEMPT_TOOLS = new Set(["contact_supervisor", "intercom", "subagent_wait"]);
19
+ export const TOOL_TIMEOUT_EXEMPT_TOOLS = new Set(["contact_supervisor", "intercom", "bg_wait", "subagent_wait"]);
20
20
 
21
21
  // Backward-compatible export name for existing callers/tests.
22
22
  export const TOOL_TIMEOUT_ALLOWLIST = TOOL_TIMEOUT_EXEMPT_TOOLS;
@@ -15,7 +15,8 @@ export interface WorkflowGraphBuildInput {
15
15
 
16
16
  /** Return displayable workflow stages while hiding structural parallel groups and host monitors. */
17
17
  export function workflowGraphStageNodes(graph: WorkflowGraphSnapshot | undefined): WorkflowGraphNode[] {
18
- if (!graph?.nodes?.length) return [];
18
+ const nodes = graph?.nodes;
19
+ if (!nodes?.length) return [];
19
20
  const stages: WorkflowGraphNode[] = [];
20
21
  const visit = (node: WorkflowGraphNode): void => {
21
22
  if (node.kind === "parallel-group" || node.kind === "dynamic-parallel-group") {
@@ -24,7 +25,7 @@ export function workflowGraphStageNodes(graph: WorkflowGraphSnapshot | undefined
24
25
  }
25
26
  if (node.kind !== "host-step") stages.push(node);
26
27
  };
27
- for (const node of graph.nodes) visit(node);
28
+ for (const node of nodes) visit(node);
28
29
  return stages;
29
30
  }
30
31
 
@@ -143,6 +143,20 @@ export interface WorkflowRecoveryAction {
143
143
  taskRequired: true;
144
144
  }
145
145
 
146
+ /**
147
+ * Bounded, host-generated identity for a resolved pi-subagents workflow resource.
148
+ * Permission/policy extensions can use it to distinguish resolved content from
149
+ * raw workflow scripts. This audit projection never grants execution authority.
150
+ */
151
+ export interface WorkflowResourceProvenanceV1 {
152
+ kind: "workflow";
153
+ name: string;
154
+ version: number;
155
+ invocation: "named";
156
+ expansion: "resolved";
157
+ id: string;
158
+ }
159
+
146
160
  /**
147
161
  * Bounded, launch-declared workflow lane metadata. This is display and
148
162
  * triage information only; capability ceilings, authorization, and cleanup
@@ -189,6 +203,7 @@ export type WorkflowReceiptEntry = WorkflowReceiptEntryResumability & {
189
203
  requestedContext?: "fresh" | "fork";
190
204
  resolvedContext?: "fresh" | "fork" | "mixed";
191
205
  outputReference?: string;
206
+ acceptanceRecovery?: AcceptanceRecoveryMetadata;
192
207
  externalAdapter?: ExternalCliReceiptMetadata;
193
208
  continuation: { runIds: string[] };
194
209
  };
@@ -199,6 +214,7 @@ export interface WorkflowReceipt {
199
214
  state: WorkflowReceiptState;
200
215
  createdAt: number;
201
216
  entries: Record<string, WorkflowReceiptEntry>;
217
+ resource?: WorkflowResourceProvenanceV1;
202
218
  hostSteps?: HostStepNodeV1[];
203
219
  workflowChildren?: WorkflowChildSummaryV1;
204
220
  workflowResolution?: WorkflowTerminalResolution;
@@ -330,7 +346,7 @@ export interface CompletionBatchConfig {
330
346
 
331
347
  export interface WaitToolConfigObject {
332
348
  enabled?: boolean;
333
- /** Default blocking window for subagent_wait calls that omit timeoutMs. */
349
+ /** Default blocking window for bg_wait calls that omit timeoutMs. */
334
350
  defaultTimeoutMs?: number;
335
351
  }
336
352
 
@@ -758,6 +774,7 @@ export interface SteeringRecoveryDescriptor {
758
774
  model?: string;
759
775
  modelProvider?: string;
760
776
  modelOverrideFromParent?: boolean;
777
+ modelOrigin?: "explicit" | "inherited" | "configured";
761
778
  fallbackModels?: string[];
762
779
  fast?: boolean;
763
780
  thinking?: string;
@@ -1086,6 +1103,18 @@ export type AcceptanceLedgerStatus =
1086
1103
  | "reviewed"
1087
1104
  | "accepted";
1088
1105
 
1106
+ /**
1107
+ * Durable child evidence that can be handed to a read-only review after the
1108
+ * acceptance envelope itself was rejected. This never upgrades acceptance;
1109
+ * the enclosing ledger remains rejected and the child remains unsuccessful.
1110
+ */
1111
+ export interface AcceptanceRecoveryMetadata {
1112
+ status: "available-for-review";
1113
+ reason: "acceptance-metadata-rejected";
1114
+ reportPath: string;
1115
+ reportHash: string;
1116
+ }
1117
+
1089
1118
  export interface AcceptanceLedger {
1090
1119
  status: AcceptanceLedgerStatus;
1091
1120
  evidenceStatus: AcceptanceEvidenceStatus;
@@ -1095,6 +1124,7 @@ export interface AcceptanceLedger {
1095
1124
  criteria: ResolvedAcceptanceGate[];
1096
1125
  childReport?: AcceptanceReport;
1097
1126
  childReportParseError?: string;
1127
+ recovery?: AcceptanceRecoveryMetadata;
1098
1128
  runtimeChecks: AcceptanceRuntimeCheck[];
1099
1129
  verifyRuns: AcceptanceVerifyResult[];
1100
1130
  reviewResult?: AcceptanceReviewResult;
@@ -1283,7 +1313,7 @@ export interface WaitCompletionChild {
1283
1313
  }
1284
1314
 
1285
1315
  /**
1286
- * Terminal completion observed for a run a subagent_wait call covered. Carries run
1316
+ * Terminal completion observed for a run a bg_wait call covered. Carries run
1287
1317
  * identity and the artifact trail; output text stays in the tool result content.
1288
1318
  */
1289
1319
  export interface WaitCompletion {
@@ -1329,7 +1359,7 @@ export interface Details {
1329
1359
  results: SingleResult[];
1330
1360
  workflowChildren?: WorkflowChildSummaryV1;
1331
1361
  /**
1332
- * Terminal completion payloads for runs this subagent_wait call observed
1362
+ * Terminal completion payloads for runs this bg_wait call observed
1333
1363
  * finishing. Async completions travel as result files that are consumed and
1334
1364
  * deleted after text delivery, so without this field their run and artifact
1335
1365
  * identity never reaches tool_result details.
@@ -1401,6 +1431,7 @@ export interface Details {
1401
1431
  mission?: MissionRecord;
1402
1432
  workflow?: {
1403
1433
  value?: unknown;
1434
+ resource?: WorkflowResourceProvenanceV1;
1404
1435
  preflightWarnings?: string[];
1405
1436
  trace: Array<{
1406
1437
  operation: "run" | "status" | "steer" | "host";
@@ -2117,6 +2148,8 @@ export interface ActiveAsyncCapacitySnapshot {
2117
2148
  export interface SubagentState {
2118
2149
  baseCwd: string;
2119
2150
  currentSessionId: string | null;
2151
+ /** Session for which active status projections were restored successfully. */
2152
+ statusProjectionSessionId?: string | null;
2120
2153
  /** Reload-stable identity for this parent Pi process/window. */
2121
2154
  completionOwnerId?: string;
2122
2155
  /** Runtime-owned artifact resolution inputs used by Fleet transcript targeting. */
@@ -2283,6 +2316,8 @@ export interface RunSyncOptions {
2283
2316
  allowIntercomDetach?: boolean;
2284
2317
  intercomEvents?: IntercomEventBus;
2285
2318
  onUpdate?: (r: import("@earendil-works/pi-agent-core").AgentToolResult<Details>) => void;
2319
+ /** Internal structured-delegation transport optimization: skip unchanged live snapshots. */
2320
+ suppressUnchangedDelegationUpdates?: boolean;
2286
2321
  onControlEvent?: (event: ControlEvent) => void;
2287
2322
  /** Exposes a non-terminating detach callback while the child is active. */
2288
2323
  onDetachReady?: (detach: (reason?: string) => boolean) => void;
@@ -2318,6 +2353,8 @@ export interface RunSyncOptions {
2318
2353
  fast?: boolean;
2319
2354
  /** The override came from the running parent session, not configuration. */
2320
2355
  modelOverrideFromParent?: boolean;
2356
+ /** How the launch model was selected: explicit per-call, configured agent primary, or inherited parent. */
2357
+ modelOrigin?: "explicit" | "inherited" | "configured";
2321
2358
  /** LLM intent arbiter for the completion mutation guard (rescues read-only review runs). */
2322
2359
  llmIntentArbiter?: import("../runs/shared/llm-intent-arbiter.ts").TaskMutationArbiter;
2323
2360
  /** Override the agent's default thinking level for this run */
@@ -1,4 +1,5 @@
1
1
  import { stableJsonDigest } from "./launch-contract.ts";
2
+ import type { WorkflowResourceProvenanceV1 } from "./types.ts";
2
3
 
3
4
  export interface WorkflowChildPermitInput {
4
5
  issuerPackage: string;
@@ -114,3 +115,93 @@ export function workflowChildPermitConsumed(permit: WorkflowChildPermit): boolea
114
115
  const state = records.get(permit as object)?.state;
115
116
  return state === "claimed" || state === "consumed";
116
117
  }
118
+
119
+ export interface WorkflowResourceHostAuthority {
120
+ keys: readonly string[];
121
+ commands: readonly string[];
122
+ }
123
+
124
+ export interface WorkflowResourceAuthority {
125
+ host?: WorkflowResourceHostAuthority;
126
+ }
127
+
128
+ export interface WorkflowResourcePermitInput {
129
+ resourceName: string;
130
+ resourceVersion: number;
131
+ resourceId: string;
132
+ scriptDigest: string;
133
+ authority: WorkflowResourceAuthority;
134
+ }
135
+
136
+ export interface WorkflowResourcePermit {
137
+ readonly __workflowResourcePermit: unique symbol;
138
+ }
139
+
140
+ interface WorkflowResourcePermitRecord {
141
+ resourceName: string;
142
+ resourceVersion: number;
143
+ resourceId: string;
144
+ scriptDigest: string;
145
+ authority: WorkflowResourceAuthority;
146
+ provenance: WorkflowResourceProvenanceV1;
147
+ state: "available" | "consumed";
148
+ }
149
+
150
+ const resourceRecords = new WeakMap<object, WorkflowResourcePermitRecord>();
151
+
152
+ function cloneWorkflowResourceAuthority(authority: WorkflowResourceAuthority): WorkflowResourceAuthority {
153
+ if (!authority || typeof authority !== "object" || Array.isArray(authority)) throw new Error("Workflow resource authority must be an object.");
154
+ if (authority.host === undefined) return Object.freeze({});
155
+ if (!authority.host || typeof authority.host !== "object" || Array.isArray(authority.host)) throw new Error("Workflow resource host authority must be an object.");
156
+ const { keys, commands } = authority.host;
157
+ if (!Array.isArray(keys) || keys.some((key) => typeof key !== "string" || !key.trim())) throw new Error("Workflow resource host authority keys must be non-empty strings.");
158
+ if (!Array.isArray(commands) || commands.some((command) => typeof command !== "string" || !command.trim())) throw new Error("Workflow resource host authority commands must be non-empty strings.");
159
+ return Object.freeze({ host: Object.freeze({ keys: Object.freeze([...keys]), commands: Object.freeze([...commands]) }) });
160
+ }
161
+
162
+ /** Package-internal permit for a workflow resource resolved by the extension. */
163
+ export function createWorkflowResourcePermit(input: WorkflowResourcePermitInput): WorkflowResourcePermit {
164
+ const resourceName = required(input.resourceName, "resourceName");
165
+ const resourceId = required(input.resourceId, "resourceId");
166
+ const scriptDigest = required(input.scriptDigest, "scriptDigest");
167
+ if (!Number.isInteger(input.resourceVersion) || input.resourceVersion < 1) throw new Error("resourceVersion must be a positive integer.");
168
+ const authority = cloneWorkflowResourceAuthority(input.authority);
169
+ const permit = Object.freeze(Object.create(null)) as WorkflowResourcePermit;
170
+ resourceRecords.set(permit as object, {
171
+ resourceName,
172
+ resourceVersion: input.resourceVersion,
173
+ resourceId,
174
+ scriptDigest,
175
+ authority,
176
+ provenance: Object.freeze({
177
+ kind: "workflow",
178
+ name: resourceName,
179
+ version: input.resourceVersion,
180
+ invocation: "named",
181
+ expansion: "resolved",
182
+ id: resourceId,
183
+ }),
184
+ state: "available",
185
+ });
186
+ return permit;
187
+ }
188
+
189
+ export function consumeWorkflowResourcePermit(permit: WorkflowResourcePermit, script: string): { provenance: WorkflowResourceProvenanceV1; authority: WorkflowResourceAuthority } | string {
190
+ const record = resourceRecords.get(permit as object);
191
+ if (!record) return "Workflow resource permit is invalid.";
192
+ if (record.state !== "available") return "Workflow resource permit is already consumed.";
193
+ if (stableJsonDigest(script) !== record.scriptDigest) return "Workflow resource permit does not match the resolved workflow script.";
194
+ record.state = "consumed";
195
+ return { provenance: record.provenance, authority: record.authority };
196
+ }
197
+
198
+ /** Validate a host call against the authority attached to a consumed resource. */
199
+ export function authorizeWorkflowResourceHost(permit: WorkflowResourcePermit, key: string, command: string): string | undefined {
200
+ const record = resourceRecords.get(permit as object);
201
+ if (!record || record.state !== "consumed") return "Workflow resource authority is unavailable.";
202
+ const host = record.authority.host;
203
+ if (!host) return "runs.host is not allowed for this workflow resource.";
204
+ if (!host.keys.includes(key)) return `runs.host('${key}') is not allowed for workflow resource '${record.resourceName}'.`;
205
+ if (!host.commands.includes(command.trim())) return `The command for runs.host('${key}') is not allowed for workflow resource '${record.resourceName}'.`;
206
+ return undefined;
207
+ }
@@ -7,6 +7,7 @@ import {
7
7
  type SubagentDelegationInvalidResponse,
8
8
  type SubagentDelegationRequest,
9
9
  type SubagentDelegationResponse,
10
+ type SubagentDelegationUpdate,
10
11
  } from "../api/delegation.ts";
11
12
  import { parseSubagentDelegationRequest } from "./delegation-request.ts";
12
13
  import {
@@ -66,6 +67,37 @@ function validId(value: unknown): value is string {
66
67
  return typeof value === "string" && value.trim().length > 0 && value.length <= 256 && !/[\r\n]/.test(value);
67
68
  }
68
69
 
70
+ function sameStringArray(left: string[] | undefined, right: string[] | undefined): boolean {
71
+ if (left === right) return true;
72
+ if (!left || !right || left.length !== right.length) return false;
73
+ return left.every((value, index) => value === right[index]);
74
+ }
75
+
76
+ function sameRecentTools(
77
+ left: Array<{ tool: string; args: string }> | undefined,
78
+ right: Array<{ tool: string; args: string }> | undefined,
79
+ ): boolean {
80
+ if (left === right) return true;
81
+ if (!left || !right || left.length !== right.length) return false;
82
+ return left.every((tool, index) => tool.tool === right[index]?.tool && tool.args === right[index]?.args);
83
+ }
84
+
85
+ /** Duration is a heartbeat clock, not delegation-visible progress; terminal usage remains authoritative. */
86
+ function sameStructuredDelegationUpdateProgress(left: SubagentDelegationUpdate, right: SubagentDelegationUpdate): boolean {
87
+ return left.requestId === right.requestId
88
+ && left.ownerRunId === right.ownerRunId
89
+ && left.nodeId === right.nodeId
90
+ && left.runId === right.runId
91
+ && left.currentTool === right.currentTool
92
+ && left.currentToolArgs === right.currentToolArgs
93
+ && left.recentOutput === right.recentOutput
94
+ && sameStringArray(left.recentOutputLines, right.recentOutputLines)
95
+ && sameRecentTools(left.recentTools, right.recentTools)
96
+ && left.model === right.model
97
+ && left.toolCount === right.toolCount
98
+ && left.tokens === right.tokens;
99
+ }
100
+
69
101
  export function registerPromptTemplateDelegationBridge<Ctx extends { cwd?: string }>(
70
102
  options: PromptTemplateBridgeOptions<Ctx>,
71
103
  ): {
@@ -298,6 +330,7 @@ export function registerPromptTemplateDelegationBridge<Ctx extends { cwd?: strin
298
330
  const executeRequest = structuredRequest && options.executeStructured
299
331
  ? options.executeStructured
300
332
  : options.execute;
333
+ let lastStructuredUpdate: SubagentDelegationUpdate | undefined;
301
334
  const result = await executeRequest(
302
335
  requestId,
303
336
  params,
@@ -307,7 +340,10 @@ export function registerPromptTemplateDelegationBridge<Ctx extends { cwd?: strin
307
340
  if (key ? !ownsAttempt(key, controller) : !ownsLegacyRequest(requestId, controller)) return;
308
341
  if (structuredRequest) {
309
342
  const payload = toSubagentDelegationUpdate(structuredRequest, update);
310
- if (payload) options.events.emit(SUBAGENT_DELEGATION_UPDATE_EVENT, payload);
343
+ if (payload && (!lastStructuredUpdate || !sameStructuredDelegationUpdateProgress(lastStructuredUpdate, payload))) {
344
+ lastStructuredUpdate = payload;
345
+ options.events.emit(SUBAGENT_DELEGATION_UPDATE_EVENT, payload);
346
+ }
311
347
  return;
312
348
  }
313
349
  const payload = toDelegationUpdate(requestId, update);