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
@@ -117,6 +117,7 @@ const FAST_MODE_ALLOWED_MODELS = new Set([
117
117
  "openai-codex/gpt-5.6-luna",
118
118
  "openai-codex/gpt-5.6-sol",
119
119
  ]);
120
+ const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64;
120
121
  export const SUBAGENT_CHILD_ENV = "PI_SUBAGENT_CHILD";
121
122
  export const SUBAGENT_ORCHESTRATOR_TARGET_ENV =
122
123
  "PI_SUBAGENT_ORCHESTRATOR_TARGET";
@@ -139,6 +140,7 @@ export const SUBAGENT_PARENT_PATH_ENV = "PI_SUBAGENT_PARENT_PATH";
139
140
  export const SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV =
140
141
  "PI_SUBAGENT_PARENT_CAPABILITY_TOKEN";
141
142
  export const SUBAGENT_PARENT_SESSION_ENV = "PI_SUBAGENT_PARENT_SESSION";
143
+ export const SUBAGENT_FORK_CACHE_KEY_ENV = "PI_SUBAGENT_FORK_CACHE_KEY";
142
144
  export const SUBAGENT_STEER_INBOX_ENV = "PI_SUBAGENT_STEER_INBOX";
143
145
  export const SUBAGENT_STEER_CAPABILITY_ENV = "PI_SUBAGENT_STEER_CAPABILITY";
144
146
  export const SUBAGENT_STEER_ACK_DIR_ENV = "PI_SUBAGENT_STEER_ACK_DIR";
@@ -146,8 +148,16 @@ export const PI_INTERCOM_STABLE_ID_ENV = "PI_INTERCOM_STABLE_ID";
146
148
  export const PI_INTERCOM_SESSION_ID_ENV = "PI_INTERCOM_SESSION_ID";
147
149
  export const SUBAGENT_INHERIT_GLOBAL_CONTEXT_ENV = "PI_SUBAGENT_INHERIT_GLOBAL_CONTEXT";
148
150
 
151
+ export function deriveForkPromptCacheKey(parentSessionId: string | undefined): string | undefined {
152
+ const parent = parentSessionId?.trim();
153
+ if (!parent) return undefined;
154
+ const digest = createHash("sha256").update(parent).digest("hex").slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH - "pi-fork:".length);
155
+ return `pi-fork:${digest}`;
156
+ }
157
+
149
158
  export interface BuildPiArgsInput {
150
159
  parentSessionId?: string;
160
+ forkCacheKey?: string;
151
161
  baseArgs: string[];
152
162
  task: string;
153
163
  sessionEnabled: boolean;
@@ -507,8 +517,13 @@ export function resolvePiLaunchToolPlan(
507
517
  throw new Error(formatUnresolvedMcpDirectToolSelectors(mcpResolution.unresolvedSelectors));
508
518
  }
509
519
  const resolvedMcpSelections = mcpResolution.selections;
520
+ const resolvedMcpNames = new Set(resolvedMcpSelections.map((selection) => selection.name));
521
+ const legacyMcpNameCounts = countLegacyUnderscoreMcpToolNames(resolvedMcpSelections);
510
522
  const effectiveMcpSelections = resolvedMcpSelections.filter(
511
- (selection) => !allowedToolSet || allowedToolSet.has(selection.name),
523
+ (selection) =>
524
+ !allowedToolSet ||
525
+ allowedToolSet.has(selection.name) ||
526
+ isLegacyUnderscoreMcpToolAllowed(selection, allowedToolSet, resolvedMcpNames, legacyMcpNameCounts),
512
527
  );
513
528
  const effectiveMcpTools = effectiveMcpSelections.map(
514
529
  (selection) => selection.name,
@@ -663,6 +678,36 @@ export function resolvePiLaunchToolPlan(
663
678
  };
664
679
  }
665
680
 
681
+ // Capability ceilings persisted before #1685 may still name hyphenated MCP server prefixes with underscores.
682
+ function countLegacyUnderscoreMcpToolNames(selections: readonly ResolvedMcpDirectToolSelection[]): Map<string, number> {
683
+ const counts = new Map<string, number>();
684
+ for (const selection of selections) {
685
+ const legacyName = legacyUnderscoreMcpToolName(selection);
686
+ if (legacyName !== selection.name) counts.set(legacyName, (counts.get(legacyName) ?? 0) + 1);
687
+ }
688
+ return counts;
689
+ }
690
+
691
+ function isLegacyUnderscoreMcpToolAllowed(
692
+ selection: ResolvedMcpDirectToolSelection,
693
+ allowedToolSet: ReadonlySet<string>,
694
+ resolvedMcpNames: ReadonlySet<string>,
695
+ legacyMcpNameCounts: ReadonlyMap<string, number>,
696
+ ): boolean {
697
+ const legacyName = legacyUnderscoreMcpToolName(selection);
698
+ return legacyMcpNameCounts.get(legacyName) === 1 && !resolvedMcpNames.has(legacyName) && allowedToolSet.has(legacyName);
699
+ }
700
+
701
+ function legacyUnderscoreMcpToolName(selection: ResolvedMcpDirectToolSelection): string {
702
+ const slash = selection.selector.indexOf("/");
703
+ if (slash < 1) return selection.name;
704
+ const toolName = selection.selector.slice(slash + 1);
705
+ const suffix = `_${toolName}`;
706
+ if (!selection.name.endsWith(suffix)) return selection.name;
707
+ const prefix = selection.name.slice(0, -suffix.length);
708
+ return `${prefix.replace(/-/g, "_")}${suffix}`;
709
+ }
710
+
666
711
  /** Escape XML-significant characters in a string for safe attribute interpolation. */
667
712
  function escapeXmlAttr(value: string): string {
668
713
  return value
@@ -974,6 +1019,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
974
1019
 
975
1020
  env[SUBAGENT_PARENT_SESSION_ENV] =
976
1021
  input.parentSessionId ?? process.env[SUBAGENT_PARENT_SESSION_ENV] ?? "";
1022
+ env[SUBAGENT_FORK_CACHE_KEY_ENV] = input.forkCacheKey?.trim() || undefined;
977
1023
 
978
1024
  return {
979
1025
  args,
@@ -8,6 +8,17 @@ export interface SingleOutputSnapshot {
8
8
  exists: boolean;
9
9
  mtimeMs?: number;
10
10
  size?: number;
11
+ error?: string;
12
+ }
13
+
14
+ function missingFileStatError(error: unknown): boolean {
15
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
16
+ return code === "ENOENT" || code === "ENOTDIR";
17
+ }
18
+
19
+ function snapshotFromStatError(error: unknown): SingleOutputSnapshot {
20
+ if (missingFileStatError(error)) return { exists: false };
21
+ return { exists: false, error: error instanceof Error ? error.message : String(error) };
11
22
  }
12
23
 
13
24
  /**
@@ -183,12 +194,36 @@ export function captureSingleOutputSnapshot(outputPath: string | undefined): Sin
183
194
  try {
184
195
  const stat = fs.statSync(outputPath);
185
196
  return { exists: true, mtimeMs: stat.mtimeMs, size: stat.size };
186
- } catch {
187
- // The snapshot is advisory; resolveSingleOutput reports concrete read/write failures.
188
- return { exists: false };
197
+ } catch (error) {
198
+ return snapshotFromStatError(error);
199
+ }
200
+ }
201
+
202
+ function inspectSingleOutputChange(
203
+ outputPath: string,
204
+ beforeRun: SingleOutputSnapshot | undefined,
205
+ ): { changed: boolean; error?: string } {
206
+ if (beforeRun?.error) return { changed: false, error: beforeRun.error };
207
+ try {
208
+ const stat = fs.statSync(outputPath);
209
+ return {
210
+ changed: !beforeRun?.exists || stat.mtimeMs !== beforeRun.mtimeMs || stat.size !== beforeRun.size,
211
+ };
212
+ } catch (error) {
213
+ if (missingFileStatError(error)) return { changed: false };
214
+ return { changed: false, error: error instanceof Error ? error.message : String(error) };
189
215
  }
190
216
  }
191
217
 
218
+ export function hasSingleOutputChangedSinceSnapshot(
219
+ outputPath: string | undefined,
220
+ beforeRun: SingleOutputSnapshot | undefined,
221
+ ): boolean | undefined {
222
+ if (!outputPath) return undefined;
223
+ const inspected = inspectSingleOutputChange(outputPath, beforeRun);
224
+ return inspected.error ? undefined : inspected.changed;
225
+ }
226
+
192
227
  function persistSingleOutput(
193
228
  outputPath: string | undefined,
194
229
  fullOutput: string,
@@ -216,23 +251,15 @@ export function resolveSingleOutput(
216
251
  const claimError = outputClaimError(outputPath, expectedClaimPath);
217
252
  if (claimError) return { fullOutput: fallbackOutput, saveError: claimError, fatalError: true };
218
253
 
219
- let changedSinceStart = false;
220
- try {
221
- const stat = fs.statSync(outputPath);
222
- changedSinceStart = !beforeRun?.exists
223
- || stat.mtimeMs !== beforeRun.mtimeMs
224
- || stat.size !== beforeRun.size;
225
- } catch (error) {
226
- const code = error && typeof error === "object" && "code" in error ? (error as { code?: unknown }).code : undefined;
227
- if (code !== "ENOENT" && code !== "ENOTDIR") {
228
- return {
229
- fullOutput: fallbackOutput,
230
- saveError: `Failed to inspect output file: ${error instanceof Error ? error.message : String(error)}`,
231
- };
232
- }
254
+ const changedSinceStart = inspectSingleOutputChange(outputPath, beforeRun);
255
+ if (changedSinceStart.error) {
256
+ return {
257
+ fullOutput: fallbackOutput,
258
+ saveError: `Failed to inspect output file: ${changedSinceStart.error}`,
259
+ };
233
260
  }
234
261
 
235
- if (changedSinceStart) {
262
+ if (changedSinceStart.changed) {
236
263
  try {
237
264
  return { fullOutput: fs.readFileSync(outputPath, "utf-8"), savedPath: outputPath };
238
265
  } catch (error) {
@@ -1,12 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import type { BeforeProviderRequestEvent, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import { registerNativeSupervisorClient } from "../../intercom/native-supervisor-channel.ts";
6
6
  import { shouldUseNativeFsWatch } from "../../shared/watch-strategy.ts";
7
7
  import { decodePermissionRules, permissionDecision, PERMISSION_AUDIT_PATH_ENV, PERMISSION_POLICY_ENV } from "./permissions.ts";
8
8
  import { consumeSteerRequestsFromDir, MAX_STEER_QUEUE_SIZE, steerAckPathFromDir, writeSteerAckAt, writeSteerCapabilityAt, writeSteerRequestToDir, type SteerDeliveryStatus, type SteerRequest } from "../background/control-channel.ts";
9
- import { SUBAGENT_CHILD_AGENT_ENV, SUBAGENT_CHILD_INDEX_ENV, SUBAGENT_FANOUT_CHILD_ENV, SUBAGENT_INHERIT_GLOBAL_CONTEXT_ENV, SUBAGENT_STEER_ACK_DIR_ENV, SUBAGENT_STEER_CAPABILITY_ENV, SUBAGENT_STEER_INBOX_ENV } from "./pi-args.ts";
9
+ import { SUBAGENT_CHILD_AGENT_ENV, SUBAGENT_CHILD_INDEX_ENV, SUBAGENT_FANOUT_CHILD_ENV, SUBAGENT_FORK_CACHE_KEY_ENV, SUBAGENT_INHERIT_GLOBAL_CONTEXT_ENV, SUBAGENT_STEER_ACK_DIR_ENV, SUBAGENT_STEER_CAPABILITY_ENV, SUBAGENT_STEER_INBOX_ENV } from "./pi-args.ts";
10
10
  import { RUNTIME_EXTENSION_ACK_EVENT, RUNTIME_EXTENSION_ACK_PATH_ENV, isRuntimeAcknowledgedExtensionId, writeRuntimeAcknowledgedExtensions } from "./runtime-acknowledged-extensions.ts";
11
11
  import { createStructuredOutputToolParameters, STRUCTURED_OUTPUT_ACCEPTANCE_CAPTURE_ENV, STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV, validateStructuredOutputValue } from "./structured-output.ts";
12
12
  import {
@@ -295,6 +295,22 @@ const COMPOSITE_TOOL_ID_APIS = new Set([
295
295
  "openai-completions",
296
296
  "openai-responses",
297
297
  ]);
298
+ const PROMPT_CACHE_KEY_APIS = new Set([
299
+ "azure-openai-responses",
300
+ "openai-codex-responses",
301
+ "openai-completions",
302
+ "openai-responses",
303
+ ]);
304
+
305
+ export function rewriteForkCacheProviderRequest(event: BeforeProviderRequestEvent, ctx?: Pick<ExtensionContext, "model">): unknown {
306
+ const forkCacheKey = process.env[SUBAGENT_FORK_CACHE_KEY_ENV]?.trim();
307
+ if (!forkCacheKey || !PROMPT_CACHE_KEY_APIS.has(ctx?.model?.api ?? "")) return undefined;
308
+ if (!event || typeof event !== "object") return undefined;
309
+ const payload = event.payload;
310
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
311
+ if (typeof (payload as { prompt_cache_key?: unknown }).prompt_cache_key !== "string") return undefined;
312
+ return { ...payload, prompt_cache_key: forkCacheKey };
313
+ }
298
314
 
299
315
  function portableToolId(id: string): string {
300
316
  if (PORTABLE_TOOL_ID_PATTERN.test(id) && id.length <= MAX_PORTABLE_TOOL_ID_LENGTH) return id;
@@ -748,6 +764,8 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
748
764
  });
749
765
  }
750
766
 
767
+ onRuntimeEvent("before_provider_request", (event: unknown, ctx?: ExtensionContext) => rewriteForkCacheProviderRequest(event as BeforeProviderRequestEvent, ctx));
768
+
751
769
  onRuntimeEvent("context", (event: unknown, ctx?: ExtensionContext) => {
752
770
  if (!event || typeof event !== "object" || !("messages" in event) || !Array.isArray(event.messages)) return undefined;
753
771
  const messages = stripParentOnlySubagentMessages(event.messages, {
@@ -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;
@@ -13,6 +13,22 @@ export interface WorkflowGraphBuildInput {
13
13
  dynamicGroupStatuses?: Record<number, { status: WorkflowNodeStatus; error?: string; acceptance?: SingleResult["acceptance"] }>;
14
14
  }
15
15
 
16
+ /** Return displayable workflow stages while hiding structural parallel groups and host monitors. */
17
+ export function workflowGraphStageNodes(graph: WorkflowGraphSnapshot | undefined): WorkflowGraphNode[] {
18
+ const nodes = graph?.nodes;
19
+ if (!nodes?.length) return [];
20
+ const stages: WorkflowGraphNode[] = [];
21
+ const visit = (node: WorkflowGraphNode): void => {
22
+ if (node.kind === "parallel-group" || node.kind === "dynamic-parallel-group") {
23
+ for (const child of node.children ?? []) visit(child);
24
+ return;
25
+ }
26
+ if (node.kind !== "host-step") stages.push(node);
27
+ };
28
+ for (const node of nodes) visit(node);
29
+ return stages;
30
+ }
31
+
16
32
  function normalizeStatus(status: string | undefined): WorkflowNodeStatus | undefined {
17
33
  switch (status) {
18
34
  case "complete":
@@ -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
 
@@ -559,6 +575,10 @@ export interface TimeoutRecoverySummary {
559
575
  termination: "timed-out" | "stopped";
560
576
  changedFiles: string[];
561
577
  truncated?: boolean;
578
+ /** True only when a timed-out child left tracked changes without its requested report. */
579
+ recoveryNeeded?: boolean;
580
+ reason?: "timed-out-with-dirty-worktree";
581
+ reportStatus?: "missing" | "written" | "not-requested" | "unknown";
562
582
  currentTool?: string;
563
583
  currentToolArgs?: string;
564
584
  currentPath?: string;
@@ -569,6 +589,9 @@ export interface TimeoutRecoverySummary {
569
589
  message: string;
570
590
  }
571
591
 
592
+ /** Safe parent-facing subset of a timeout recovery summary. */
593
+ export type TimeoutRecoveryProjection = Pick<TimeoutRecoverySummary, "termination" | "changedFiles" | "truncated" | "recoveryNeeded" | "reason" | "reportStatus">;
594
+
572
595
  export const SUBAGENT_LIFECYCLE_ARTIFACT_VERSION = 3;
573
596
  export type SubagentLifecycleArtifactVersion = typeof SUBAGENT_LIFECYCLE_ARTIFACT_VERSION;
574
597
 
@@ -751,6 +774,7 @@ export interface SteeringRecoveryDescriptor {
751
774
  model?: string;
752
775
  modelProvider?: string;
753
776
  modelOverrideFromParent?: boolean;
777
+ modelOrigin?: "explicit" | "inherited" | "configured";
754
778
  fallbackModels?: string[];
755
779
  fast?: boolean;
756
780
  thinking?: string;
@@ -1079,6 +1103,18 @@ export type AcceptanceLedgerStatus =
1079
1103
  | "reviewed"
1080
1104
  | "accepted";
1081
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
+
1082
1118
  export interface AcceptanceLedger {
1083
1119
  status: AcceptanceLedgerStatus;
1084
1120
  evidenceStatus: AcceptanceEvidenceStatus;
@@ -1088,6 +1124,7 @@ export interface AcceptanceLedger {
1088
1124
  criteria: ResolvedAcceptanceGate[];
1089
1125
  childReport?: AcceptanceReport;
1090
1126
  childReportParseError?: string;
1127
+ recovery?: AcceptanceRecoveryMetadata;
1091
1128
  runtimeChecks: AcceptanceRuntimeCheck[];
1092
1129
  verifyRuns: AcceptanceVerifyResult[];
1093
1130
  reviewResult?: AcceptanceReviewResult;
@@ -1272,10 +1309,11 @@ export interface WaitCompletionChild {
1272
1309
  model?: string;
1273
1310
  contextOverflow?: boolean;
1274
1311
  artifactPaths?: Partial<ArtifactPaths>;
1312
+ timeoutRecovery?: TimeoutRecoveryProjection;
1275
1313
  }
1276
1314
 
1277
1315
  /**
1278
- * 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
1279
1317
  * identity and the artifact trail; output text stays in the tool result content.
1280
1318
  */
1281
1319
  export interface WaitCompletion {
@@ -1290,6 +1328,27 @@ export interface WaitCompletion {
1290
1328
  workflowChildren?: WorkflowChildSummaryV1;
1291
1329
  }
1292
1330
 
1331
+ export interface AgentCapabilitiesSnapshot {
1332
+ agents: AgentCapabilityRow[];
1333
+ restrictedCount: number;
1334
+ capabilityCeilingSources?: string[];
1335
+ }
1336
+
1337
+ export interface AgentCapabilityRow {
1338
+ name: string;
1339
+ description: string;
1340
+ source: "builtin" | "package" | "user" | "project" | "runtime";
1341
+ executable: boolean;
1342
+ restrictionSources?: string[];
1343
+ aliases?: string[];
1344
+ runner: { type: "pi" } | { type: "external-cli"; adapter?: string; capabilities: ExternalCliCapabilities } | { type: "external-job"; provider: string; available?: boolean; capabilities: ExternalJobRunnerStatus["capabilities"] };
1345
+ tools: { ambient: boolean; names: string[]; mcpDirectTools: string[]; mutationTools?: string[] };
1346
+ model?: { value?: string; fallbackModels?: string[]; thinking?: string | false };
1347
+ execution?: { defaultAsync?: boolean; timeoutMs?: number };
1348
+ output?: { path?: string; mode?: OutputMode };
1349
+ extensions?: { names?: string[]; subagentOnly?: string[]; skills?: string[] };
1350
+ }
1351
+
1293
1352
  export interface Details {
1294
1353
  mode: SubagentResultMode | "management";
1295
1354
  runId?: string;
@@ -1300,7 +1359,7 @@ export interface Details {
1300
1359
  results: SingleResult[];
1301
1360
  workflowChildren?: WorkflowChildSummaryV1;
1302
1361
  /**
1303
- * Terminal completion payloads for runs this subagent_wait call observed
1362
+ * Terminal completion payloads for runs this bg_wait call observed
1304
1363
  * finishing. Async completions travel as result files that are consumed and
1305
1364
  * deleted after text delivery, so without this field their run and artifact
1306
1365
  * identity never reaches tool_result details.
@@ -1352,6 +1411,7 @@ export interface Details {
1352
1411
  runFanoutBudget?: RunFanoutBudgetSnapshot;
1353
1412
  runFanoutRejection?: RunFanoutRejection;
1354
1413
  activeAsyncCapacity?: ActiveAsyncCapacitySnapshot;
1414
+ agentCapabilities?: AgentCapabilitiesSnapshot;
1355
1415
  capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
1356
1416
  capabilityAudit?: SubagentCapabilityAudit;
1357
1417
  parallelHandoff?: ParallelHandoffReference;
@@ -1371,6 +1431,7 @@ export interface Details {
1371
1431
  mission?: MissionRecord;
1372
1432
  workflow?: {
1373
1433
  value?: unknown;
1434
+ resource?: WorkflowResourceProvenanceV1;
1374
1435
  preflightWarnings?: string[];
1375
1436
  trace: Array<{
1376
1437
  operation: "run" | "status" | "steer" | "host";
@@ -1852,9 +1913,10 @@ export interface AsyncStatus {
1852
1913
  parallelHandoff?: ParallelHandoffReference;
1853
1914
  }
1854
1915
 
1855
- export type AsyncJobStep = NonNullable<AsyncStatus["steps"]>[number] & {
1916
+ export type AsyncJobStep = Omit<NonNullable<AsyncStatus["steps"]>[number], "timeoutRecovery"> & {
1856
1917
  index?: number;
1857
1918
  description?: string;
1919
+ timeoutRecovery?: TimeoutRecoveryProjection;
1858
1920
  };
1859
1921
 
1860
1922
  export interface AsyncJobState {
@@ -1889,6 +1951,8 @@ export interface AsyncJobState {
1889
1951
  parallelGroups?: AsyncParallelGroupStatus[];
1890
1952
  /** Bounded host-owned CI/gate nodes loaded from the workflow status graph. */
1891
1953
  hostSteps?: HostStepNodeV1[];
1954
+ /** Full bounded workflow plan, including not-yet-materialized stages. */
1955
+ workflowGraph?: WorkflowGraphSnapshot;
1892
1956
  steps?: AsyncJobStep[];
1893
1957
  preflight?: WorkflowPreflightV1;
1894
1958
  stepsTotal?: number;
@@ -2084,6 +2148,8 @@ export interface ActiveAsyncCapacitySnapshot {
2084
2148
  export interface SubagentState {
2085
2149
  baseCwd: string;
2086
2150
  currentSessionId: string | null;
2151
+ /** Session for which active status projections were restored successfully. */
2152
+ statusProjectionSessionId?: string | null;
2087
2153
  /** Reload-stable identity for this parent Pi process/window. */
2088
2154
  completionOwnerId?: string;
2089
2155
  /** Runtime-owned artifact resolution inputs used by Fleet transcript targeting. */
@@ -2250,6 +2316,8 @@ export interface RunSyncOptions {
2250
2316
  allowIntercomDetach?: boolean;
2251
2317
  intercomEvents?: IntercomEventBus;
2252
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;
2253
2321
  onControlEvent?: (event: ControlEvent) => void;
2254
2322
  /** Exposes a non-terminating detach callback while the child is active. */
2255
2323
  onDetachReady?: (detach: (reason?: string) => boolean) => void;
@@ -2285,6 +2353,8 @@ export interface RunSyncOptions {
2285
2353
  fast?: boolean;
2286
2354
  /** The override came from the running parent session, not configuration. */
2287
2355
  modelOverrideFromParent?: boolean;
2356
+ /** How the launch model was selected: explicit per-call, configured agent primary, or inherited parent. */
2357
+ modelOrigin?: "explicit" | "inherited" | "configured";
2288
2358
  /** LLM intent arbiter for the completion mutation guard (rescues read-only review runs). */
2289
2359
  llmIntentArbiter?: import("../runs/shared/llm-intent-arbiter.ts").TaskMutationArbiter;
2290
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);