pi-subagents 0.58.0 → 0.60.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 (116) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/docs/agents.md +5 -3
  3. package/docs/configuration.md +4 -4
  4. package/docs/extension-api.md +1 -1
  5. package/docs/models.md +24 -1
  6. package/docs/observability.md +1 -1
  7. package/docs/tool-reference.md +93 -5
  8. package/docs/workflows.md +94 -3
  9. package/package.json +3 -1
  10. package/prompts/review-loop.md +2 -2
  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 +87 -37
  14. package/skills/pi-subagents/references/constraints-and-recipes.md +29 -233
  15. package/skills/pi-subagents/references/execution-controls.md +52 -7
  16. package/skills/pi-subagents/references/management-authoring-rpc.md +3 -4
  17. package/skills/pi-subagents/references/multi-lane-orchestration.md +13 -1
  18. package/skills/pi-subagents/references/prompting-and-roles.md +54 -26
  19. package/skills/pi-subagents/references/review-and-validation.md +73 -0
  20. package/src/agents/agent-management.ts +159 -42
  21. package/src/agents/agent-serializer.ts +4 -2
  22. package/src/agents/agents.ts +67 -26
  23. package/src/agents/runtime-agent-registry.ts +9 -16
  24. package/src/api/background-work.ts +5 -1
  25. package/src/api/delegation.ts +0 -7
  26. package/src/api/preflight.ts +6 -9
  27. package/src/api/shared-types.ts +2 -0
  28. package/src/extension/fanout-child.ts +5 -3
  29. package/src/extension/index.ts +51 -23
  30. package/src/extension/public-execution.ts +16 -2
  31. package/src/extension/schemas.ts +36 -10
  32. package/src/extension/tool-description.ts +24 -5
  33. package/src/intercom/result-intercom.ts +2 -0
  34. package/src/profiles/profiles.ts +5 -6
  35. package/src/runs/background/active-async-capacity.ts +2 -2
  36. package/src/runs/background/async-execution.ts +52 -37
  37. package/src/runs/background/async-job-tracker.ts +17 -12
  38. package/src/runs/background/async-resume.ts +23 -25
  39. package/src/runs/background/async-status-snapshot.ts +23 -261
  40. package/src/runs/background/async-status.ts +110 -8
  41. package/src/runs/background/chain-append.ts +6 -3
  42. package/src/runs/background/chain-root-attachment.ts +60 -8
  43. package/src/runs/background/control-channel.ts +3 -2
  44. package/src/runs/background/fleet-view.ts +21 -11
  45. package/src/runs/background/notify.ts +158 -6
  46. package/src/runs/background/result-files.ts +2 -1
  47. package/src/runs/background/result-watcher.ts +2 -0
  48. package/src/runs/background/resume-guidance.ts +1 -1
  49. package/src/runs/background/retained-children.ts +1 -1
  50. package/src/runs/background/run-status.ts +30 -9
  51. package/src/runs/background/scheduled-runs.ts +86 -7
  52. package/src/runs/background/stale-run-reconciler.ts +10 -4
  53. package/src/runs/background/steering.ts +4 -14
  54. package/src/runs/background/subagent-runner.ts +351 -358
  55. package/src/runs/background/subagent-wait.ts +68 -12
  56. package/src/runs/background/terminal-run-index.ts +1 -1
  57. package/src/runs/background/wait-completions.ts +25 -1
  58. package/src/runs/background/wait-config.ts +23 -9
  59. package/src/runs/background/wait-tool.ts +9 -2
  60. package/src/runs/foreground/async-steering-action.ts +2 -2
  61. package/src/runs/foreground/execution.ts +125 -122
  62. package/src/runs/foreground/foreground-control.ts +3 -0
  63. package/src/runs/foreground/foreground-history.ts +1 -0
  64. package/src/runs/foreground/subagent-executor.ts +576 -240
  65. package/src/runs/foreground/workflow-detach-reconcile.ts +99 -200
  66. package/src/runs/shared/abort-recovery.ts +119 -0
  67. package/src/runs/shared/async-status-projection.ts +597 -0
  68. package/src/runs/shared/background-process-options.ts +9 -0
  69. package/src/runs/shared/child-identity.ts +19 -4
  70. package/src/runs/shared/child-launch-plan.ts +151 -0
  71. package/src/runs/shared/completion-evidence.ts +89 -0
  72. package/src/runs/shared/completion-guard.ts +1 -1
  73. package/src/runs/shared/dynamic-fanout.ts +2 -2
  74. package/src/runs/shared/host-step-status.ts +230 -0
  75. package/src/runs/shared/lane-metadata.ts +105 -0
  76. package/src/runs/shared/mcp-config-sources.ts +42 -6
  77. package/src/runs/shared/mcp-direct-tool-allowlist.ts +93 -143
  78. package/src/runs/shared/mcp-direct-tool-grant.ts +194 -0
  79. package/src/runs/shared/model-fallback.ts +9 -2
  80. package/src/runs/shared/mutation-evidence.ts +52 -3
  81. package/src/runs/shared/nested-events.ts +6 -2
  82. package/src/runs/shared/nested-render.ts +7 -3
  83. package/src/runs/shared/parallel-handoff.ts +419 -7
  84. package/src/runs/shared/parallel-utils.ts +7 -0
  85. package/src/runs/shared/pi-args.ts +67 -3
  86. package/src/runs/shared/single-output.ts +72 -22
  87. package/src/runs/shared/subagent-prompt-runtime.ts +33 -6
  88. package/src/runs/shared/workflow-graph.ts +15 -0
  89. package/src/runs/shared/worktree-cleanup-plan.ts +847 -0
  90. package/src/runs/shared/worktree.ts +18 -0
  91. package/src/shared/child-session-name.ts +46 -0
  92. package/src/shared/extension-context.ts +24 -0
  93. package/src/shared/formatters.ts +5 -2
  94. package/src/shared/launch-contract.ts +1 -1
  95. package/src/shared/settings.ts +9 -103
  96. package/src/shared/types.ts +232 -30
  97. package/src/shared/utils.ts +35 -55
  98. package/src/slash/delegation-adapters.ts +1 -8
  99. package/src/slash/delegation-request.ts +0 -4
  100. package/src/slash/slash-bridge.ts +1 -2
  101. package/src/slash/slash-commands.ts +369 -90
  102. package/src/slash/slash-live-state.ts +22 -11
  103. package/src/tui/fleet-status.ts +134 -75
  104. package/src/tui/fleet.ts +11 -5
  105. package/src/tui/render-helpers.ts +31 -0
  106. package/src/tui/render.ts +895 -121
  107. package/src/watchdog/change-signature.ts +40 -1
  108. package/src/watchdog/turn-delta.ts +1 -1
  109. package/src/workflows/chat-progress.ts +6 -3
  110. package/src/workflows/host-command.ts +235 -0
  111. package/src/workflows/scripted-workflow.ts +503 -38
  112. package/src/workflows/workflow-child-summary.ts +9 -5
  113. package/src/workflows/workflow-preflight.ts +270 -0
  114. package/src/workflows/workflow-receipt.ts +43 -4
  115. package/src/workflows/workflow-settlement.ts +246 -0
  116. package/src/runs/shared/turn-budget.ts +0 -98
@@ -1,11 +1,17 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
4
+ import { stableJsonDigest } from "../../shared/launch-contract.ts";
4
5
  import type {
6
+ CleanupEligibility,
5
7
  ParallelHandoffGroup,
6
8
  ParallelHandoffManifest,
7
9
  ParallelHandoffReference,
10
+ ParallelHandoffLaneBinding,
11
+ ParallelHandoffMergeEvidence,
12
+ ParallelHandoffSupersessionEvidence,
8
13
  SubagentResultStatus,
14
+ WorkflowLaneMetadata,
9
15
  } from "../../shared/types.ts";
10
16
  import type {
11
17
  WorktreeCleanupReport,
@@ -14,6 +20,7 @@ import type {
14
20
  WorktreeCleanupIntent,
15
21
  } from "./worktree.ts";
16
22
  import { cleanupWorktrees } from "./worktree.ts";
23
+ import { assertWorkflowLaneKey, normalizeWorkflowLaneMetadata } from "./lane-metadata.ts";
17
24
 
18
25
  export interface ParallelHandoffResult {
19
26
  agent: string;
@@ -23,17 +30,121 @@ export interface ParallelHandoffResult {
23
30
  structuredOutput?: unknown;
24
31
  structuredOutputPath?: string;
25
32
  sessionPath?: string;
33
+ workflowKey?: string;
34
+ runId?: string;
35
+ lane?: WorkflowLaneMetadata;
26
36
  }
27
37
 
28
- function readManifest(manifestPath: string): ParallelHandoffManifest | undefined {
29
- if (!fs.existsSync(manifestPath)) return undefined;
30
- const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as ParallelHandoffManifest;
38
+ function optionalIdentity(value: unknown, label: string): string | undefined {
39
+ if (value === undefined) return undefined;
40
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${label} must be a non-empty string.`);
41
+ return value.trim();
42
+ }
43
+
44
+ function nonNegativeIndex(value: unknown, label: string): number {
45
+ if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`${label} must be a non-negative integer.`);
46
+ return value as number;
47
+ }
48
+
49
+ function normalizeLaneBinding(value: unknown, label: string): ParallelHandoffLaneBinding {
50
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
51
+ const binding = value as Record<string, unknown>;
52
+ const unknown = Object.keys(binding).filter((field) => !["index", "taskIndex", "workflowKey", "runId", "lane"].includes(field));
53
+ if (unknown.length > 0) throw new Error(`${label} has unsupported fields: ${unknown.join(", ")}.`);
54
+ const index = nonNegativeIndex(binding.index, `${label}.index`);
55
+ const taskIndex = nonNegativeIndex(binding.taskIndex, `${label}.taskIndex`);
56
+ const workflowKey = optionalIdentity(binding.workflowKey, `${label}.workflowKey`);
57
+ const runId = optionalIdentity(binding.runId, `${label}.runId`);
58
+ const lane = normalizeWorkflowLaneMetadata(binding.lane, `${label}.lane`);
59
+ assertWorkflowLaneKey(lane, workflowKey, `${label}.lane`);
60
+ return {
61
+ index,
62
+ taskIndex,
63
+ ...(workflowKey ? { workflowKey } : {}),
64
+ ...(runId ? { runId } : {}),
65
+ ...(lane ? { lane } : {}),
66
+ };
67
+ }
68
+
69
+ function validateManifestIdentity(parsed: ParallelHandoffManifest, manifestPath: string): ParallelHandoffManifest {
31
70
  if (parsed.version !== 1 || !Array.isArray(parsed.groups)) {
32
71
  throw new Error(`Invalid parallel handoff manifest: ${manifestPath}`);
33
72
  }
73
+ if (typeof parsed.runId !== "string" || !parsed.runId.trim()) throw new Error(`Invalid parallel handoff manifest '${manifestPath}': runId must be a non-empty string.`);
74
+ parsed.runId = parsed.runId.trim();
75
+ const workflowKeys = new Set<string>();
76
+ const childRunIds = new Set<string>();
77
+ const childIndexes = new Set<number>();
78
+ for (const [groupIndex, group] of parsed.groups.entries()) {
79
+ if (!group || typeof group !== "object" || !Array.isArray(group.children) || !group.cleanup || !Array.isArray(group.cleanup.tasks)) throw new Error(`Invalid parallel handoff manifest '${manifestPath}': groups[${groupIndex}] is malformed.`);
80
+ if (group.laneBindings !== undefined) {
81
+ if (!Array.isArray(group.laneBindings)) throw new Error(`Invalid parallel handoff manifest '${manifestPath}': groups[${groupIndex}].laneBindings must be an array.`);
82
+ const taskIndexes = new Set<number>();
83
+ const cleanupIndexes = new Set(group.cleanup.tasks.map((task) => task.index));
84
+ group.laneBindings = group.laneBindings.map((binding, bindingIndex) => {
85
+ const normalized = normalizeLaneBinding(binding, `Invalid parallel handoff manifest '${manifestPath}': groups[${groupIndex}].laneBindings[${bindingIndex}]`);
86
+ if (taskIndexes.has(normalized.taskIndex)) throw new Error(`Invalid parallel handoff manifest '${manifestPath}': groups[${groupIndex}] has duplicate lane binding taskIndex ${normalized.taskIndex}.`);
87
+ if (!cleanupIndexes.has(normalized.taskIndex)) throw new Error(`Invalid parallel handoff manifest '${manifestPath}': groups[${groupIndex}].laneBindings[${bindingIndex}] has no cleanup task for taskIndex ${normalized.taskIndex}.`);
88
+ taskIndexes.add(normalized.taskIndex);
89
+ return normalized;
90
+ });
91
+ }
92
+ const taskIndexes = new Set<number>();
93
+ for (const [childIndex, child] of group.children.entries()) {
94
+ if (!child || typeof child !== "object" || !child.patch || typeof child.patch !== "object") throw new Error(`Invalid parallel handoff manifest '${manifestPath}': groups[${groupIndex}].children[${childIndex}] is malformed.`);
95
+ const childRecord = child as unknown as Record<string, unknown>;
96
+ const index = nonNegativeIndex(childRecord.index, `Invalid parallel handoff manifest '${manifestPath}': groups[${groupIndex}].children[${childIndex}].index`);
97
+ const taskIndex = nonNegativeIndex(childRecord.taskIndex, `Invalid parallel handoff manifest '${manifestPath}': groups[${groupIndex}].children[${childIndex}].taskIndex`);
98
+ if (taskIndexes.has(taskIndex)) throw new Error(`Invalid parallel handoff manifest '${manifestPath}': groups[${groupIndex}] has duplicate child taskIndex ${taskIndex}.`);
99
+ taskIndexes.add(taskIndex);
100
+ if (childIndexes.has(index)) throw new Error(`Invalid parallel handoff manifest '${manifestPath}': duplicate child index ${index}.`);
101
+ childIndexes.add(index);
102
+ if (!group.cleanup.tasks.some((task) => task.index === taskIndex)) throw new Error(`Invalid parallel handoff manifest '${manifestPath}': groups[${groupIndex}].children[${childIndex}] has no cleanup task for taskIndex ${taskIndex}.`);
103
+ const workflowKey = optionalIdentity(childRecord.workflowKey, `Invalid parallel handoff manifest '${manifestPath}': groups[${groupIndex}].children[${childIndex}].workflowKey`);
104
+ const runId = optionalIdentity(childRecord.runId, `Invalid parallel handoff manifest '${manifestPath}': groups[${groupIndex}].children[${childIndex}].runId`);
105
+ const lane = normalizeWorkflowLaneMetadata(childRecord.lane, `Invalid parallel handoff manifest '${manifestPath}': groups[${groupIndex}].children[${childIndex}].lane`);
106
+ assertWorkflowLaneKey(lane, workflowKey, `Invalid parallel handoff manifest '${manifestPath}': groups[${groupIndex}].children[${childIndex}].lane`);
107
+ if (workflowKey && workflowKeys.has(workflowKey)) throw new Error(`Invalid parallel handoff manifest '${manifestPath}': duplicate workflow key '${workflowKey}'.`);
108
+ if (workflowKey) workflowKeys.add(workflowKey);
109
+ if (runId && childRunIds.has(runId)) throw new Error(`Invalid parallel handoff manifest '${manifestPath}': duplicate child run id '${runId}'.`);
110
+ if (runId) childRunIds.add(runId);
111
+ if (lane) childRecord.lane = lane;
112
+ if (workflowKey) childRecord.workflowKey = workflowKey;
113
+ if (runId) childRecord.runId = runId;
114
+ childRecord.index = index;
115
+ childRecord.taskIndex = taskIndex;
116
+ }
117
+ }
34
118
  return parsed;
35
119
  }
36
120
 
121
+ export function readParallelHandoffManifest(manifestPath: string): ParallelHandoffManifest | undefined {
122
+ if (!fs.existsSync(manifestPath)) return undefined;
123
+ const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as ParallelHandoffManifest;
124
+ return validateManifestIdentity(parsed, manifestPath);
125
+ }
126
+
127
+ export function resolveParallelHandoffChild(input: {
128
+ manifestPath: string;
129
+ runId: string;
130
+ workflowKey?: string;
131
+ childRunId?: string;
132
+ }): { group: ParallelHandoffGroup; child: ParallelHandoffGroup["children"][number] } | undefined {
133
+ const manifest = readParallelHandoffManifest(input.manifestPath);
134
+ if (!manifest) return undefined;
135
+ if (manifest.runId !== input.runId) throw new Error(`Managed worktree handoff belongs to run '${manifest.runId}', not '${input.runId}'.`);
136
+ const workflowKey = input.workflowKey?.trim() || undefined;
137
+ const childRunId = input.childRunId?.trim() || undefined;
138
+ if (!workflowKey && !childRunId) throw new Error("Parallel handoff child resolution requires workflowKey or childRunId.");
139
+ const matches = manifest.groups.flatMap((group) => group.children.map((child) => ({ group, child }))).filter(({ child }) => {
140
+ if (workflowKey && child.workflowKey !== workflowKey) return false;
141
+ if (childRunId && child.runId !== childRunId) return false;
142
+ return true;
143
+ });
144
+ if (matches.length > 1) throw new Error(`Parallel handoff has multiple children matching workflow identity${workflowKey ? ` '${workflowKey}'` : ` '${childRunId}'`}.`);
145
+ return matches[0];
146
+ }
147
+
37
148
  function resolveExistingPath(candidate: string): string {
38
149
  try {
39
150
  return fs.realpathSync(candidate);
@@ -48,7 +159,7 @@ function pathInside(root: string, candidate: string): boolean {
48
159
  }
49
160
 
50
161
  export function resolveRetainedWorktreeCwd(manifestPath: string, runId: string, childIndex: number): string | undefined {
51
- const manifest = readManifest(manifestPath);
162
+ const manifest = readParallelHandoffManifest(manifestPath);
52
163
  if (!manifest) return undefined;
53
164
  if (manifest.runId !== runId) throw new Error(`Managed worktree handoff belongs to run '${manifest.runId}', not '${runId}'.`);
54
165
  const match = manifest.groups
@@ -85,9 +196,278 @@ function referenceFor(manifestPath: string, manifest: ParallelHandoffManifest):
85
196
  childCount: children.length,
86
197
  changedPatches: children.filter((child) => child.patch.changed).length,
87
198
  cleanupState: manifest.groups.every((group) => group.cleanup.state === "complete") ? "complete" : "partial",
199
+ ...(Object.prototype.hasOwnProperty.call(manifest, "cleanupEligibility")
200
+ ? { cleanupEligibility: trustedStoredCleanupEligibility(manifest) }
201
+ : {}),
202
+ };
203
+ }
204
+
205
+ const COMMIT_PATTERN = /^[0-9a-f]{40}$/i;
206
+ const MANIFEST_DIGEST_PATTERN = /^[0-9a-f]{64}$/i;
207
+ const MAX_ATTESTATION_ACTOR_LENGTH = 128;
208
+ const MAX_ATTESTATION_TIMESTAMP_LENGTH = 64;
209
+ const MAX_SUPERSESSION_ID_LENGTH = 128;
210
+
211
+ function boundedString(value: unknown, label: string, maxBytes: number): string {
212
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${label} must be a non-empty string.`);
213
+ const normalized = value.trim();
214
+ if (normalized.includes("\n") || normalized.includes("\r")) throw new Error(`${label} must not contain newlines.`);
215
+ if (Buffer.byteLength(normalized, "utf-8") > maxBytes) throw new Error(`${label} must be at most ${maxBytes} bytes.`);
216
+ return normalized;
217
+ }
218
+
219
+ function commitString(value: unknown, label: string): string {
220
+ const normalized = boundedString(value, label, 64).toLowerCase();
221
+ if (!COMMIT_PATTERN.test(normalized)) throw new Error(`${label} must be a full 40-character commit SHA.`);
222
+ return normalized;
223
+ }
224
+
225
+ function manifestDigestString(value: unknown, label: string): string {
226
+ const normalized = boundedString(value, label, 64).toLowerCase();
227
+ if (!MANIFEST_DIGEST_PATTERN.test(normalized)) throw new Error(`${label} must be a full 64-character SHA-256 digest.`);
228
+ return normalized;
229
+ }
230
+
231
+ function manifestFactsDigest(manifest: ParallelHandoffManifest): string {
232
+ return stableJsonDigest({
233
+ version: manifest.version,
234
+ runId: manifest.runId,
235
+ mode: manifest.mode,
236
+ source: manifest.source,
237
+ cwd: manifest.cwd,
238
+ groups: manifest.groups,
239
+ });
240
+ }
241
+
242
+ function attestationTimestamp(value: unknown): string {
243
+ const normalized = boundedString(value, "attestedAt", MAX_ATTESTATION_TIMESTAMP_LENGTH);
244
+ if (!Number.isFinite(Date.parse(normalized))) throw new Error("attestedAt must be a valid timestamp.");
245
+ return normalized;
246
+ }
247
+
248
+ function normalizeMergeEvidence(value: unknown): ParallelHandoffMergeEvidence {
249
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("merge must be an object.");
250
+ const merge = value as Record<string, unknown>;
251
+ const prNumber = merge.prNumber;
252
+ if (typeof prNumber !== "number" || !Number.isInteger(prNumber) || prNumber < 1) throw new Error("merge.prNumber must be a positive integer.");
253
+ const treeEquivalent = merge.treeEquivalent;
254
+ if (treeEquivalent !== true && treeEquivalent !== false && treeEquivalent !== "unknown") throw new Error("merge.treeEquivalent must be true, false, or 'unknown'.");
255
+ const postMergeChecks = merge.postMergeChecks;
256
+ if (postMergeChecks !== "recorded" && postMergeChecks !== "unknown") throw new Error("merge.postMergeChecks must be 'recorded' or 'unknown'.");
257
+ return {
258
+ prNumber,
259
+ reviewedHead: commitString(merge.reviewedHead, "merge.reviewedHead"),
260
+ mergeCommit: commitString(merge.mergeCommit, "merge.mergeCommit"),
261
+ treeEquivalent,
262
+ postMergeChecks,
263
+ attestedBy: boundedString(merge.attestedBy, "merge.attestedBy", MAX_ATTESTATION_ACTOR_LENGTH),
264
+ attestedAt: attestationTimestamp(merge.attestedAt),
265
+ ...(merge.manifestDigest === undefined ? {} : { manifestDigest: manifestDigestString(merge.manifestDigest, "merge.manifestDigest") }),
266
+ };
267
+ }
268
+
269
+ function normalizeSupersessionEvidence(value: unknown): ParallelHandoffSupersessionEvidence {
270
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("supersession must be an object.");
271
+ const supersession = value as Record<string, unknown>;
272
+ return {
273
+ supersededBy: boundedString(supersession.supersededBy, "supersession.supersededBy", MAX_SUPERSESSION_ID_LENGTH),
274
+ attestedBy: boundedString(supersession.attestedBy, "supersession.attestedBy", MAX_ATTESTATION_ACTOR_LENGTH),
275
+ attestedAt: attestationTimestamp(supersession.attestedAt),
276
+ ...(supersession.manifestDigest === undefined ? {} : { manifestDigest: manifestDigestString(supersession.manifestDigest, "supersession.manifestDigest") }),
88
277
  };
89
278
  }
90
279
 
280
+ function normalizeStoredCleanupEligibility(value: unknown): CleanupEligibility {
281
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { state: "unknown" };
282
+ const eligibility = value as Record<string, unknown>;
283
+ if (eligibility.state === "active" || eligibility.state === "terminal-eligible" || eligibility.state === "superseded-eligible" || eligibility.state === "unknown") {
284
+ return { state: eligibility.state };
285
+ }
286
+ if (eligibility.state === "terminal-blocked" && typeof eligibility.reason === "string" && eligibility.reason.trim() && !eligibility.reason.includes("\n") && !eligibility.reason.includes("\r")) {
287
+ return { state: "terminal-blocked", reason: eligibility.reason.trim().slice(0, 256) };
288
+ }
289
+ return { state: "unknown" };
290
+ }
291
+
292
+ const STORED_CHILD_STATUSES = new Set(["pending", "running", "completed", "complete", "failed", "paused", "stopped", "detached", "rejected"]);
293
+
294
+ export function isTerminalParallelHandoffChildStatus(status: unknown): boolean {
295
+ return status === "completed" || status === "complete" || status === "failed" || status === "paused" || status === "stopped" || status === "rejected";
296
+ }
297
+
298
+ function hasValidStoredLaneShape(manifest: ParallelHandoffManifest): boolean {
299
+ if (!manifest || typeof manifest !== "object" || manifest.version !== 1 || typeof manifest.runId !== "string" || !manifest.runId.trim() || !Array.isArray(manifest.groups) || manifest.groups.length === 0) return false;
300
+ let hasManagedTask = false;
301
+ const valid = manifest.groups.every((group) => {
302
+ if (!group || typeof group !== "object" || typeof group.baseCommit !== "string" || !group.baseCommit.trim() || typeof group.repoRoot !== "string" || !group.repoRoot.trim()) return false;
303
+ const cleanup = group.cleanup;
304
+ if (!cleanup || typeof cleanup !== "object" || (cleanup.state !== "complete" && cleanup.state !== "partial") || !Array.isArray(cleanup.tasks) || !Array.isArray(group.children)) return false;
305
+ if (cleanup.tasks.some((task) => !task || typeof task !== "object" || !Number.isSafeInteger(task.index) || task.index < 0 || typeof task.path !== "string" || !task.path.trim() || typeof task.branch !== "string" || !task.branch.trim())) return false;
306
+ if (cleanup.tasks.length > 0) hasManagedTask = true;
307
+ return group.children.every((child) => Boolean(child) && typeof child === "object" && typeof child.status === "string" && STORED_CHILD_STATUSES.has(child.status));
308
+ });
309
+ return valid && hasManagedTask;
310
+ }
311
+
312
+ function trustedStoredCleanupEligibility(manifest: ParallelHandoffManifest): CleanupEligibility {
313
+ const eligibility = normalizeStoredCleanupEligibility(manifest.cleanupEligibility);
314
+ if (!hasValidStoredLaneShape(manifest)) return { state: "unknown" };
315
+ if (hasActiveChildren(manifest)) return { state: "active" };
316
+ if (eligibility.state === "active") return cleanupEligibilityForEvidence(manifest);
317
+ if (eligibility.state === "terminal-eligible" || eligibility.state === "superseded-eligible") {
318
+ const current = cleanupEligibilityForEvidence(manifest);
319
+ if (current.state === eligibility.state || (current.state === "terminal-blocked" && / evidence is stale$/.test(current.reason))) return current;
320
+ return { state: "unknown" };
321
+ }
322
+ return eligibility;
323
+ }
324
+
325
+ function hasActiveChildren(manifest: ParallelHandoffManifest): boolean {
326
+ return Array.isArray(manifest.groups) && manifest.groups.some((group) => {
327
+ if (!group || typeof group !== "object") return false;
328
+ const children = Array.isArray(group.children) ? group.children : [];
329
+ if (children.length === 0 && Array.isArray(group.cleanup?.tasks) && group.cleanup.tasks.length > 0) return true;
330
+ return children.some((child) => !child || typeof child !== "object" || !isTerminalParallelHandoffChildStatus(child.status));
331
+ });
332
+ }
333
+
334
+ function hasManagedWorktreeTasks(manifest: ParallelHandoffManifest): boolean {
335
+ return manifest.groups.length > 0 && manifest.groups.some((group) => Array.isArray(group.cleanup?.tasks) && group.cleanup.tasks.length > 0);
336
+ }
337
+
338
+ function validateManifestForLaneEvidence(manifest: ParallelHandoffManifest, laneId: string): void {
339
+ if (manifest.runId !== laneId) throw new Error(`Lane '${laneId}' does not match manifest run '${manifest.runId}'.`);
340
+ if (!hasValidStoredLaneShape(manifest)) throw new Error("Lane manifest is malformed.");
341
+ if (!manifest.groups.length || !hasManagedWorktreeTasks(manifest)) throw new Error("Lane manifest has no managed worktree tasks.");
342
+ if (hasActiveChildren(manifest)) throw new Error("Lane has an active child owner; merge evidence must be recorded after local reconciliation.");
343
+ for (const group of manifest.groups) {
344
+ if (typeof group.repoRoot !== "string" || !group.repoRoot.trim()) throw new Error("Lane manifest has no valid repository root.");
345
+ if (group.cleanup.state !== "complete" && group.cleanup.state !== "partial") throw new Error("Lane manifest has an invalid cleanup state.");
346
+ if (!Array.isArray(group.cleanup.tasks) || group.cleanup.tasks.some((task) => typeof task.path !== "string" || !task.path.trim() || typeof task.branch !== "string" || !task.branch.trim())) {
347
+ throw new Error("Lane manifest has an invalid cleanup task.");
348
+ }
349
+ if (!Array.isArray(group.children)) throw new Error("Lane manifest has invalid child records.");
350
+ for (const child of group.children) {
351
+ if (!STORED_CHILD_STATUSES.has(child.status)) {
352
+ throw new Error("Lane manifest has an invalid child status.");
353
+ }
354
+ }
355
+ }
356
+ }
357
+
358
+ function cleanupEligibilityForEvidence(manifest: ParallelHandoffManifest): CleanupEligibility {
359
+ if (hasActiveChildren(manifest)) return { state: "active" };
360
+ let merge: ParallelHandoffMergeEvidence | undefined;
361
+ if (manifest.merge) {
362
+ try {
363
+ merge = normalizeMergeEvidence(manifest.merge);
364
+ } catch {
365
+ return { state: "terminal-blocked", reason: "stored merge evidence is invalid" };
366
+ }
367
+ }
368
+ if (manifest.supersession) {
369
+ try {
370
+ const supersession = normalizeSupersessionEvidence(manifest.supersession);
371
+ if (supersession.manifestDigest !== manifestFactsDigest(manifest)) return { state: "terminal-blocked", reason: "stored supersession evidence is stale" };
372
+ } catch {
373
+ return { state: "terminal-blocked", reason: "stored supersession evidence is invalid" };
374
+ }
375
+ return { state: "superseded-eligible" };
376
+ }
377
+ if (!merge) return { state: "terminal-blocked", reason: "no merge or supersession evidence recorded" };
378
+ if (merge.manifestDigest !== manifestFactsDigest(manifest)) return { state: "terminal-blocked", reason: "stored merge evidence is stale" };
379
+ if (merge.treeEquivalent !== true) {
380
+ return { state: "terminal-blocked", reason: merge.treeEquivalent === "unknown" ? "merged tree equivalence was not attested" : "merged tree is not equivalent to the reviewed head" };
381
+ }
382
+ if (merge.postMergeChecks !== "recorded") return { state: "terminal-blocked", reason: "required post-merge checks were not recorded" };
383
+ return { state: "terminal-eligible" };
384
+ }
385
+
386
+ function sameMergeEvidence(left: ParallelHandoffMergeEvidence, right: ParallelHandoffMergeEvidence): boolean {
387
+ return left.prNumber === right.prNumber
388
+ && left.reviewedHead === right.reviewedHead
389
+ && left.mergeCommit === right.mergeCommit
390
+ && left.treeEquivalent === right.treeEquivalent
391
+ && left.postMergeChecks === right.postMergeChecks
392
+ && left.attestedBy === right.attestedBy
393
+ && left.attestedAt === right.attestedAt;
394
+ }
395
+
396
+ function sameSupersessionEvidence(left: ParallelHandoffSupersessionEvidence, right: ParallelHandoffSupersessionEvidence): boolean {
397
+ return left.supersededBy === right.supersededBy && left.attestedBy === right.attestedBy && left.attestedAt === right.attestedAt;
398
+ }
399
+
400
+ function firstRepositoryRoot(manifest: ParallelHandoffManifest): string | undefined {
401
+ const repoRoot = Array.isArray(manifest.groups) ? manifest.groups[0]?.repoRoot : undefined;
402
+ return typeof repoRoot === "string" && repoRoot.trim() ? repoRoot.trim() : undefined;
403
+ }
404
+
405
+ function formatCleanupCommand(manifestPath: string, manifest: ParallelHandoffManifest): string | undefined {
406
+ const repoRoot = firstRepositoryRoot(manifest);
407
+ return repoRoot ? `subagent({ action: "worktree.cleanup", repo: ${JSON.stringify(repoRoot)}, handoffPath: ${JSON.stringify(manifestPath)}, mode: "plan" })` : undefined;
408
+ }
409
+
410
+ export function formatStoredParallelHandoffCleanup(manifestPath: string, manifest?: ParallelHandoffManifest): string {
411
+ let stored = manifest;
412
+ if (!stored) {
413
+ try {
414
+ stored = readParallelHandoffManifest(manifestPath);
415
+ } catch {
416
+ stored = undefined;
417
+ }
418
+ }
419
+ if (!stored) return ["Cleanup eligibility: unknown", "Reason: lane manifest is missing or invalid; removal is not safe.", `Plan command: subagent({ action: "worktree.cleanup", handoffPath: ${JSON.stringify(manifestPath)}, mode: "plan" })`].join("\n");
420
+ const eligibility = trustedStoredCleanupEligibility(stored);
421
+ const lines = [`Cleanup eligibility: ${eligibility.state}`];
422
+ if (eligibility.state === "terminal-blocked") lines.push(`Reason: ${eligibility.reason}`);
423
+ if (eligibility.state === "unknown") lines.push("Reason: stored eligibility is missing or invalid; removal is not safe.");
424
+ if (eligibility.state === "active") lines.push("Reason: a child owner is still active; removal is not safe.");
425
+ const command = formatCleanupCommand(manifestPath, stored);
426
+ if (command) lines.push(`Plan command: ${command}`);
427
+ return lines.join("\n");
428
+ }
429
+
430
+ export interface ParallelHandoffEvidenceResult {
431
+ manifest: ParallelHandoffManifest;
432
+ reference: ParallelHandoffReference;
433
+ text: string;
434
+ }
435
+
436
+ export function recordParallelHandoffMerge(input: { manifestPath: string; laneId: string; merge: unknown; now?: number }): ParallelHandoffEvidenceResult {
437
+ const manifest = readParallelHandoffManifest(input.manifestPath);
438
+ if (!manifest) throw new Error(`Parallel handoff manifest not found: ${input.manifestPath}`);
439
+ validateManifestForLaneEvidence(manifest, boundedString(input.laneId, "laneId", MAX_SUPERSESSION_ID_LENGTH));
440
+ if (hasActiveChildren(manifest)) throw new Error("Lane has an active child owner; merge evidence must be recorded after local reconciliation.");
441
+ const merge = { ...normalizeMergeEvidence(input.merge), manifestDigest: manifestFactsDigest(manifest) };
442
+ if (manifest.merge) {
443
+ const existing = normalizeMergeEvidence(manifest.merge);
444
+ if (existing.reviewedHead !== merge.reviewedHead) throw new Error(`Lane manifest is stale: reviewed head is already recorded as ${existing.reviewedHead}.`);
445
+ if (!sameMergeEvidence(existing, merge)) throw new Error("Lane manifest already contains different merge evidence for this reviewed head.");
446
+ }
447
+ const reconciled: ParallelHandoffManifest = { ...manifest, merge };
448
+ delete reconciled.supersession;
449
+ const updated: ParallelHandoffManifest = { ...reconciled, cleanupEligibility: cleanupEligibilityForEvidence(reconciled), updatedAt: input.now ?? Date.now() };
450
+ writeAtomicJson(input.manifestPath, updated);
451
+ return { manifest: updated, reference: referenceFor(input.manifestPath, updated), text: formatStoredParallelHandoffCleanup(input.manifestPath, updated) };
452
+ }
453
+
454
+ export function recordParallelHandoffSupersession(input: { manifestPath: string; laneId: string; supersession: unknown; now?: number }): ParallelHandoffEvidenceResult {
455
+ const manifest = readParallelHandoffManifest(input.manifestPath);
456
+ if (!manifest) throw new Error(`Parallel handoff manifest not found: ${input.manifestPath}`);
457
+ const laneId = boundedString(input.laneId, "laneId", MAX_SUPERSESSION_ID_LENGTH);
458
+ validateManifestForLaneEvidence(manifest, laneId);
459
+ if (hasActiveChildren(manifest)) throw new Error("Lane has an active child owner; supersession must be recorded after local reconciliation.");
460
+ const supersession = { ...normalizeSupersessionEvidence(input.supersession), manifestDigest: manifestFactsDigest(manifest) };
461
+ if (supersession.supersededBy === laneId) throw new Error("supersession.supersededBy must identify a different replacement lane.");
462
+ if (manifest.supersession) {
463
+ const existing = normalizeSupersessionEvidence(manifest.supersession);
464
+ if (!sameSupersessionEvidence(existing, supersession)) throw new Error("Lane manifest already contains different supersession evidence.");
465
+ }
466
+ const updated: ParallelHandoffManifest = { ...manifest, supersession, cleanupEligibility: cleanupEligibilityForEvidence({ ...manifest, supersession }), updatedAt: input.now ?? Date.now() };
467
+ writeAtomicJson(input.manifestPath, updated);
468
+ return { manifest: updated, reference: referenceFor(input.manifestPath, updated), text: formatStoredParallelHandoffCleanup(input.manifestPath, updated) };
469
+ }
470
+
91
471
  function safeHandoffAgentName(agent: string): string {
92
472
  return agent.replace(/[^\w.-]/g, "_");
93
473
  }
@@ -125,18 +505,35 @@ export function writeParallelHandoffGroup(input: {
125
505
  diffs: WorktreeDiff[];
126
506
  cleanup?: WorktreeCleanupReport;
127
507
  results: ParallelHandoffResult[];
508
+ laneBindings?: ParallelHandoffLaneBinding[];
128
509
  now?: number;
129
510
  }): ParallelHandoffReference {
130
511
  const now = input.now ?? Date.now();
131
- const existing = readManifest(input.manifestPath);
512
+ const existing = readParallelHandoffManifest(input.manifestPath);
132
513
  if (existing && (existing.runId !== input.runId || existing.mode !== input.mode || existing.source !== input.source)) {
133
514
  throw new Error(`Parallel handoff manifest belongs to a different run: ${input.manifestPath}`);
134
515
  }
516
+ const laneBindings = input.laneBindings?.map((binding, index) => normalizeLaneBinding(binding, `parallel handoff lane binding ${index}`));
517
+ if (laneBindings) {
518
+ const taskIndexes = new Set<number>();
519
+ for (const binding of laneBindings) {
520
+ if (taskIndexes.has(binding.taskIndex)) throw new Error(`Parallel handoff has duplicate lane binding taskIndex ${binding.taskIndex}.`);
521
+ if (!input.setup.worktrees.some((worktree) => worktree.index === binding.taskIndex)) throw new Error(`Parallel handoff lane binding taskIndex ${binding.taskIndex} has no managed worktree.`);
522
+ if (binding.index !== input.flatStartIndex + binding.taskIndex) throw new Error(`Parallel handoff lane binding index ${binding.index} does not match taskIndex ${binding.taskIndex}.`);
523
+ taskIndexes.add(binding.taskIndex);
524
+ }
525
+ }
526
+ const bindingForTask = (taskIndex: number): ParallelHandoffLaneBinding | undefined => laneBindings?.find((binding) => binding.taskIndex === taskIndex);
135
527
  const group: ParallelHandoffGroup = {
136
528
  stepIndex: input.stepIndex,
137
529
  baseCommit: input.setup.baseCommit,
138
530
  repoRoot: input.setup.cwd,
139
531
  children: input.results.map((result, taskIndex) => {
532
+ const binding = bindingForTask(taskIndex);
533
+ const runId = optionalIdentity(result.runId ?? binding?.runId, `parallel handoff child ${taskIndex}.runId`);
534
+ const lane = normalizeWorkflowLaneMetadata(result.lane ?? binding?.lane, `parallel handoff child ${taskIndex}.lane`);
535
+ const workflowKey = optionalIdentity(result.workflowKey ?? binding?.workflowKey, `parallel handoff child ${taskIndex}.workflowKey`);
536
+ assertWorkflowLaneKey(lane, workflowKey, `parallel handoff child ${taskIndex}.lane`);
140
537
  const diff = input.diffs[taskIndex] ?? missingDiff({
141
538
  manifestPath: input.manifestPath,
142
539
  stepIndex: input.stepIndex,
@@ -148,6 +545,9 @@ export function writeParallelHandoffGroup(input: {
148
545
  index: input.flatStartIndex + taskIndex,
149
546
  taskIndex,
150
547
  agent: result.agent,
548
+ ...(workflowKey ? { workflowKey } : {}),
549
+ ...(runId ? { runId } : {}),
550
+ ...(lane ? { lane } : {}),
151
551
  status: result.status,
152
552
  summary: result.summary,
153
553
  ...(result.outputPath ? { outputPath: result.outputPath } : {}),
@@ -166,6 +566,7 @@ export function writeParallelHandoffGroup(input: {
166
566
  },
167
567
  };
168
568
  }),
569
+ ...(laneBindings && laneBindings.length > 0 && input.results.length === 0 ? { laneBindings } : {}),
169
570
  cleanup: input.cleanup ?? {
170
571
  state: "partial",
171
572
  pruned: false,
@@ -183,7 +584,7 @@ export function writeParallelHandoffGroup(input: {
183
584
  const groups = existing?.groups.filter((candidate) => candidate.stepIndex !== input.stepIndex) ?? [];
184
585
  groups.push(group);
185
586
  groups.sort((left, right) => left.stepIndex - right.stepIndex);
186
- const manifest: ParallelHandoffManifest = {
587
+ const manifestWithoutEligibility: ParallelHandoffManifest = {
187
588
  version: 1,
188
589
  runId: input.runId,
189
590
  mode: input.mode,
@@ -192,6 +593,16 @@ export function writeParallelHandoffGroup(input: {
192
593
  createdAt: existing?.createdAt ?? now,
193
594
  updatedAt: now,
194
595
  groups,
596
+ ...(existing && Object.hasOwn(existing, "merge") ? { merge: existing.merge } : {}),
597
+ ...(existing && Object.hasOwn(existing, "supersession") ? { supersession: existing.supersession } : {}),
598
+ };
599
+ const carriesCleanupMetadata = existing !== undefined
600
+ && (Object.hasOwn(existing, "merge") || Object.hasOwn(existing, "supersession") || Object.hasOwn(existing, "cleanupEligibility"));
601
+ const manifest: ParallelHandoffManifest = {
602
+ ...manifestWithoutEligibility,
603
+ ...(carriesCleanupMetadata
604
+ ? { cleanupEligibility: cleanupEligibilityForEvidence(manifestWithoutEligibility) }
605
+ : {}),
195
606
  };
196
607
  writeAtomicJson(input.manifestPath, manifest);
197
608
  return referenceFor(input.manifestPath, manifest);
@@ -210,6 +621,7 @@ export function writePendingParallelHandoff(input: {
210
621
  stepIndex: number;
211
622
  flatStartIndex: number;
212
623
  setup: WorktreeSetup;
624
+ laneBindings?: ParallelHandoffLaneBinding[];
213
625
  }): ParallelHandoffReference {
214
626
  return writeParallelHandoffGroup({ ...input, diffs: [], results: [] });
215
627
  }
@@ -227,7 +639,7 @@ export function discardPreservedWorktrees(
227
639
  authorization: Extract<WorktreeCleanupIntent, { kind: "discard" }>["authorization"],
228
640
  ): { manifest: ParallelHandoffManifest; text: string } {
229
641
  const resolvedPath = path.resolve(manifestPath);
230
- const manifest = readManifest(resolvedPath);
642
+ const manifest = readParallelHandoffManifest(resolvedPath);
231
643
  if (!manifest) throw new Error(`Parallel handoff manifest not found: ${resolvedPath}`);
232
644
  let attempted = 0;
233
645
  for (const group of manifest.groups) {
@@ -6,6 +6,8 @@ export interface RunnerSubagentStep {
6
6
  /** Resolved opt-in rules for native Pi child tool calls. */
7
7
  permissionRules?: import("./permissions.ts").PermissionRules;
8
8
  agent: string;
9
+ /** Human-readable display name for the child session, derived internally at launch. */
10
+ sessionName?: string;
9
11
  task: string;
10
12
  runner?: ResolvedRunnerConfig;
11
13
  externalJobFollowUp?: {
@@ -40,6 +42,7 @@ export interface RunnerSubagentStep {
40
42
  skipPrimaryModelVerification?: boolean;
41
43
  modelVerificationRegistry?: Array<{ provider: string; id: string; fullId: string; contextWindow?: number }>;
42
44
  tools?: string[];
45
+ allowNestedSubagents?: boolean;
43
46
  extensions?: string[];
44
47
  subagentOnlyExtensions?: string[];
45
48
  mcpDirectTools?: string[];
@@ -54,6 +57,7 @@ export interface RunnerSubagentStep {
54
57
  inheritSkills: boolean;
55
58
  skills?: string[];
56
59
  outputPath?: string;
60
+ outputClaimPath?: string;
57
61
  /** Defer the authoritative output instruction until a dynamic fanout item is materialized. */
58
62
  namespaceOutputPath?: boolean;
59
63
  outputMode?: "inline" | "file-only";
@@ -63,6 +67,7 @@ export interface RunnerSubagentStep {
63
67
  /** Resolved configured hard per-tool-call timeout (ms); fast tools still have a default when undefined. */
64
68
  toolTimeoutMs?: number;
65
69
  waitToolEnabled?: boolean;
70
+ waitToolDefaultTimeoutMs?: number;
66
71
  structuredOutput?: {
67
72
  schema: import("../../shared/types.ts").JsonSchemaObject;
68
73
  schemaPath: string;
@@ -88,6 +93,8 @@ export interface RunnerSubagentStep {
88
93
  runFanoutPath?: string;
89
94
  /** Run this single child in one managed worktree. */
90
95
  worktree?: boolean;
96
+ /** Bounded launch-declared lane metadata; display/triage only. */
97
+ lane?: import("../../shared/types.ts").WorkflowLaneMetadata;
91
98
  }
92
99
 
93
100
  export interface ParallelStepGroup {