pi-subagents 0.65.0 → 0.65.1

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 (49) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/docs/agents.md +1 -1
  3. package/docs/configuration.md +16 -0
  4. package/docs/extension-api.md +3 -0
  5. package/docs/tool-reference.md +8 -2
  6. package/docs/workflows.md +8 -0
  7. package/package.json +3 -1
  8. package/runner-server-preload.mjs +13 -0
  9. package/skills/pi-subagents/SKILL.md +2 -1
  10. package/skills/pi-subagents/references/execution-controls.md +5 -1
  11. package/skills/pi-subagents/references/multi-lane-orchestration.md +2 -0
  12. package/src/api/preflight.ts +5 -1
  13. package/src/extension/config.ts +4 -2
  14. package/src/extension/index.ts +31 -2
  15. package/src/extension/schemas.ts +1 -1
  16. package/src/extension/tool-description.ts +5 -1
  17. package/src/integrations/pi-web-session-liveness.ts +73 -0
  18. package/src/intercom/native-supervisor-channel.ts +22 -36
  19. package/src/intercom/supervisor-ui.ts +3 -2
  20. package/src/missions/workflow-state.ts +37 -16
  21. package/src/runs/background/async-execution.ts +8 -1
  22. package/src/runs/background/async-resume.ts +3 -1
  23. package/src/runs/background/async-retention.ts +9 -0
  24. package/src/runs/background/notify.ts +2 -0
  25. package/src/runs/background/retained-nested-route-tracker.ts +96 -0
  26. package/src/runs/background/run-child-session.ts +2 -1
  27. package/src/runs/background/runner-aliases.ts +32 -5
  28. package/src/runs/background/subagent-runner.ts +19 -0
  29. package/src/runs/foreground/execution.ts +15 -1
  30. package/src/runs/foreground/foreground-history.ts +3 -1
  31. package/src/runs/foreground/prompt-audit.ts +9 -5
  32. package/src/runs/foreground/subagent-executor.ts +61 -34
  33. package/src/runs/shared/acceptance.ts +14 -1
  34. package/src/runs/shared/child-session.ts +13 -18
  35. package/src/runs/shared/llm-intent-arbiter.ts +20 -11
  36. package/src/runs/shared/model-exclusions.ts +2 -1
  37. package/src/runs/shared/model-fallback.ts +31 -2
  38. package/src/runs/shared/nested-events.ts +3 -3
  39. package/src/runs/shared/parallel-utils.ts +1 -0
  40. package/src/runs/shared/worktree-cleanup-plan.ts +6 -3
  41. package/src/runs/shared/worktree.ts +75 -10
  42. package/src/shared/model-response-aliases.ts +13 -0
  43. package/src/shared/types.ts +8 -0
  44. package/src/shared/utils.ts +3 -0
  45. package/src/shared/watch-strategy.ts +2 -0
  46. package/src/tui/fleet-status.ts +1 -1
  47. package/src/tui/render.ts +21 -10
  48. package/src/workflows/scripted-workflow.ts +32 -6
  49. package/src/workflows/workflow-checklist.ts +4 -3
@@ -15,6 +15,7 @@ import type {
15
15
  } from "../../shared/types.ts";
16
16
  import { getAgentDir } from "../../shared/utils.ts";
17
17
  import { isTerminalParallelHandoffChildStatus } from "./parallel-handoff.ts";
18
+ import { MACHINE_DIFF_OPTIONS, validateWorktreePatchRepresentsCurrentWorktree } from "./worktree.ts";
18
19
 
19
20
  export const WORKTREE_CLEANUP_PLAN_VERSION = 1 as const;
20
21
  export const WORKTREE_CLEANUP_PLAN_TTL_MS = 30 * 60 * 1000;
@@ -572,7 +573,7 @@ function resolveBranchTip(repoRoot: string, branch: string): { value?: string; e
572
573
  return { value: result.stdout.trim() };
573
574
  }
574
575
 
575
- function isPatchCaptured(record: ManifestMetadataRecord, worktreePath: string): { path?: string; error?: string } {
576
+ function isPatchCaptured(record: ManifestMetadataRecord, worktreePath: string, baseCommit: string): { path?: string; error?: string } {
576
577
  const patchPath = metadataPatchPath(record);
577
578
  if (!patchPath || record.child.patch?.error !== undefined || record.child.patch?.changed !== true) return {};
578
579
  const inspection = inspectPath(patchPath);
@@ -587,6 +588,8 @@ function isPatchCaptured(record: ManifestMetadataRecord, worktreePath: string):
587
588
  if (!patchStat.isFile()) return { error: "captured handoff patch is not a file" };
588
589
  if (pathInside(worktreePath, inspection.realpath)) return { error: "durable handoff patch lives inside the worktree" };
589
590
  if (patchStat.size <= 0) return { error: "captured handoff patch is empty" };
591
+ const validationError = validateWorktreePatchRepresentsCurrentWorktree(worktreePath, baseCommit, patchPath);
592
+ if (validationError) return { error: `captured handoff patch failed validation: ${validationError}` };
590
593
  return { path: patchPath };
591
594
  }
592
595
 
@@ -665,14 +668,14 @@ function buildManagedEntry(input: {
665
668
  entry.preconditions.statusDigest = status.digest;
666
669
  if (status.output && status.output.trim()) return blockedEntry(entry, "dirty", "keep", "worktree has uncommitted or untracked changes");
667
670
 
668
- const diff = runGit(worktreePath, ["diff", "--quiet", resolvedBase.value, "--"]);
671
+ const diff = runGit(worktreePath, ["diff", "--quiet", ...MACHINE_DIFF_OPTIONS, resolvedBase.value, "--"]);
669
672
  if (diff.status !== 0 && diff.status !== 1) return blockedEntry(entry, "unknown", "unknown", `git diff safety check failed: ${gitFailure(diff, "git diff")}`);
670
673
  const ancestor = runGit(repoRoot, ["merge-base", "--is-ancestor", git.head, targetHead]);
671
674
  if (ancestor.status !== 0 && ancestor.status !== 1) return blockedEntry(entry, "unknown", "unknown", `local merge safety check failed: ${gitFailure(ancestor, "git merge-base --is-ancestor")}`);
672
675
  const branchTipIsAncestor = ancestor.status === 0;
673
676
  let divergenceSafe = diff.status === 0;
674
677
  if (!divergenceSafe) {
675
- const captured = isPatchCaptured(record, worktreePath);
678
+ const captured = isPatchCaptured(record, worktreePath, resolvedBase.value);
676
679
  if (captured.error) return blockedEntry(entry, "ineligible", "keep", captured.error);
677
680
  if (captured.path) {
678
681
  entry.patchPath = captured.path;
@@ -17,6 +17,9 @@ const WORKTREE_NAMING_COMPONENT_MAX_BYTES = 96;
17
17
  const WORKTREE_NAMING_LABEL_MAX_BYTES = 256;
18
18
  const WORKTREE_NAMING_BRANCH_MAX_BYTES = 256;
19
19
  const WORKTREE_COMMAND_OUTPUT_MAX_BYTES = 128 * 1024;
20
+ export const MACHINE_DIFF_OPTIONS = ["--no-color", "--no-ext-diff", "--no-textconv", "--default-prefix", "--line-prefix=", "--no-relative"] as const;
21
+ const MACHINE_PATCH_OPTIONS = [...MACHINE_DIFF_OPTIONS, "--binary"] as const;
22
+ const PATCH_VALIDATION_OPTIONS = ["apply", "--check", "--cached", "--reverse", "--binary", "--whitespace=nowarn"] as const;
20
23
 
21
24
  export interface WorktreeNamingInput {
22
25
  runId: string;
@@ -168,8 +171,8 @@ interface RepoState {
168
171
 
169
172
  const DEFAULT_WORKTREE_SETUP_HOOK_TIMEOUT_MS = 30000;
170
173
 
171
- function runGit(cwd: string, args: string[]): GitResult {
172
- const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", windowsHide: true, shell: false });
174
+ function runGit(cwd: string, args: string[], env?: NodeJS.ProcessEnv): GitResult {
175
+ const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", windowsHide: true, shell: false, ...(env ? { env: { ...process.env, ...env } } : {}) });
173
176
  return {
174
177
  stdout: result.stdout ?? "",
175
178
  stderr: result.stderr ?? "",
@@ -187,6 +190,53 @@ function runGitChecked(cwd: string, args: string[]): string {
187
190
  return result.stdout;
188
191
  }
189
192
 
193
+ /** Validate a captured patch against the worktree index without changing either. */
194
+ export function validateWorktreePatch(worktreePath: string, patchPath: string): string | undefined {
195
+ const result = runGit(worktreePath, [...PATCH_VALIDATION_OPTIONS, patchPath]);
196
+ if (result.status === 0) return undefined;
197
+ return result.stderr.trim() || result.stdout.trim() || `git -C ${worktreePath} apply --check failed`;
198
+ }
199
+
200
+ function currentWorktreePatch(worktreePath: string, baseCommit: string): { patch: string } | { error: string } {
201
+ let tempDir: string;
202
+ try {
203
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-worktree-index-"));
204
+ } catch (error) {
205
+ return { error: error instanceof Error ? error.message : String(error) };
206
+ }
207
+ const env = { GIT_INDEX_FILE: path.join(tempDir, "index") };
208
+ try {
209
+ const readTree = runGit(worktreePath, ["read-tree", "HEAD"], env);
210
+ if (readTree.status !== 0) return { error: readTree.stderr.trim() || readTree.stdout.trim() || "git read-tree failed" };
211
+ const add = runGit(worktreePath, ["add", "-A"], env);
212
+ if (add.status !== 0) return { error: add.stderr.trim() || add.stdout.trim() || "git add failed" };
213
+ const diff = runGit(worktreePath, ["diff", "--cached", ...MACHINE_PATCH_OPTIONS, baseCommit], env);
214
+ if (diff.status !== 0) return { error: diff.stderr.trim() || diff.stdout.trim() || "git diff failed" };
215
+ return { patch: diff.stdout };
216
+ } finally {
217
+ try {
218
+ fs.rmSync(tempDir, { recursive: true, force: true });
219
+ } catch {
220
+ // Cleanup safety depends on preserving the worktree, not on best-effort temp-index deletion.
221
+ }
222
+ }
223
+ }
224
+
225
+ export function validateWorktreePatchRepresentsCurrentWorktree(worktreePath: string, baseCommit: string, patchPath: string): string | undefined {
226
+ const validationError = validateWorktreePatch(worktreePath, patchPath);
227
+ if (validationError) return validationError;
228
+ let capturedPatch: string;
229
+ try {
230
+ capturedPatch = fs.readFileSync(patchPath, "utf-8");
231
+ } catch (error) {
232
+ return error instanceof Error ? error.message : String(error);
233
+ }
234
+ const current = currentWorktreePatch(worktreePath, baseCommit);
235
+ if ("error" in current) return current.error || "failed to capture current worktree patch";
236
+ if (capturedPatch !== current.patch) return "captured handoff patch does not match current worktree changes";
237
+ return undefined;
238
+ }
239
+
190
240
  function findGitWorktreePath(cwd: string, branch: string): string | undefined {
191
241
  const targetBranch = `branch refs/heads/${branch}`;
192
242
  let currentPath: string | undefined;
@@ -957,15 +1007,18 @@ function captureWorktreeDiff(
957
1007
  ): WorktreeDiff {
958
1008
  removeSyntheticPathsBeforeDiff(worktree);
959
1009
  runGitChecked(worktree.path, ["add", "-A"]);
960
- const diffStat = runGitChecked(worktree.path, ["diff", "--cached", "--stat", setup.baseCommit]).trim();
961
- const patch = runGitChecked(worktree.path, ["diff", "--cached", setup.baseCommit]);
962
- const numstat = runGitChecked(worktree.path, ["diff", "--cached", "--numstat", setup.baseCommit]);
1010
+ const diffStat = runGitChecked(worktree.path, ["diff", "--cached", ...MACHINE_DIFF_OPTIONS, "--stat", setup.baseCommit]).trim();
1011
+ const patch = runGitChecked(worktree.path, ["diff", "--cached", ...MACHINE_PATCH_OPTIONS, setup.baseCommit]);
1012
+ const numstat = runGitChecked(worktree.path, ["diff", "--cached", ...MACHINE_DIFF_OPTIONS, "--numstat", setup.baseCommit]);
963
1013
  fs.writeFileSync(patchPath, patch, "utf-8");
964
1014
 
965
1015
  if (!patch.trim()) {
966
1016
  return emptyDiff(worktree.index, agent, worktree.branch, patchPath);
967
1017
  }
968
1018
 
1019
+ const validationError = validateWorktreePatch(worktree.path, patchPath);
1020
+ if (validationError) throw new Error(`captured worktree patch is not machine-applyable: ${validationError}`);
1021
+
969
1022
  const parsed = parseNumstat(numstat);
970
1023
  return {
971
1024
  index: worktree.index,
@@ -1034,7 +1087,7 @@ function cleanupSingleWorktree(
1034
1087
  errors.push(`synthetic path cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
1035
1088
  }
1036
1089
  const status = runGit(worktree.path, ["status", "--porcelain"]);
1037
- const baseDiff = runGit(worktree.path, ["diff", "--quiet", setup.baseCommit, "--"]);
1090
+ const baseDiff = runGit(worktree.path, ["diff", "--quiet", ...MACHINE_DIFF_OPTIONS, setup.baseCommit, "--"]);
1038
1091
  if (status.status !== 0 || (baseDiff.status !== 0 && baseDiff.status !== 1)) {
1039
1092
  const reason = status.status !== 0
1040
1093
  ? status.stderr.trim() || status.stdout.trim() || "git status failed"
@@ -1055,13 +1108,25 @@ function cleanupSingleWorktree(
1055
1108
  const hasWork = status.stdout.trim().length > 0 || baseDiff.status === 1;
1056
1109
  if (hasWork && intent.kind === "preserve") {
1057
1110
  const captured = (intent.capturedDiffs ?? setup.capturedDiffs)?.find((diff) => diff.index === worktree.index);
1058
- const patchCaptured = captured !== undefined
1111
+ let patchValidationError: string | undefined;
1112
+ let patchCaptured = false;
1113
+ if (captured !== undefined
1059
1114
  && captured.error === undefined
1060
1115
  && fs.existsSync(captured.patchPath)
1061
- && fs.statSync(captured.patchPath).size > 0
1062
- && handoffRecordsPatch(intent.handoffManifestPath, captured.patchPath);
1116
+ && handoffRecordsPatch(intent.handoffManifestPath, captured.patchPath)) {
1117
+ try {
1118
+ if (fs.statSync(captured.patchPath).size > 0) {
1119
+ patchValidationError = validateWorktreePatchRepresentsCurrentWorktree(worktree.path, setup.baseCommit, captured.patchPath);
1120
+ patchCaptured = patchValidationError === undefined;
1121
+ }
1122
+ } catch (error) {
1123
+ patchValidationError = error instanceof Error ? error.message : String(error);
1124
+ }
1125
+ }
1063
1126
  if (!patchCaptured) {
1064
- const reason = "worktree contains changes that are not represented by a captured handoff patch";
1127
+ const reason = patchValidationError
1128
+ ? `captured handoff patch failed validation: ${patchValidationError}`
1129
+ : "worktree contains changes that are not represented by a captured handoff patch";
1065
1130
  return {
1066
1131
  index: worktree.index,
1067
1132
  path: worktree.path,
@@ -0,0 +1,13 @@
1
+ export function validateModelResponseAliases(value: unknown, label = "config.modelResponseAliases"): void {
2
+ if (value === undefined) return;
3
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be a JSON object`);
4
+ for (const [candidate, aliases] of Object.entries(value)) {
5
+ const slash = candidate.indexOf("/");
6
+ if (slash <= 0 || !candidate.slice(0, slash).trim() || !candidate.slice(slash + 1).trim()) {
7
+ throw new Error(`${label} key ${JSON.stringify(candidate)} must be a non-empty provider/model ID`);
8
+ }
9
+ if (!Array.isArray(aliases) || aliases.some((alias) => typeof alias !== "string" || !alias.trim())) {
10
+ throw new Error(`${label}[${JSON.stringify(candidate)}] must be an array of non-empty response ID strings`);
11
+ }
12
+ }
13
+ }
@@ -779,6 +779,8 @@ export interface RunFanoutRejection extends RunFanoutBudgetSnapshot {
779
779
  }
780
780
 
781
781
  export interface SteeringRecoveryDescriptor {
782
+ /** Captured response identity authority; absence means no declared aliases on revival. */
783
+ modelResponseAliases?: Record<string, string[]>;
782
784
  version: 1;
783
785
  launchContractDigest?: string;
784
786
  extensionBindings?: ExtensionBindings;
@@ -2047,6 +2049,7 @@ export interface ForegroundResumeChild {
2047
2049
  agentContract?: AgentContract;
2048
2050
  /** Private bounded launch fields needed to preserve the child contract on resume. */
2049
2051
  resumeContract?: {
2052
+ modelResponseAliases?: Record<string, string[]>;
2050
2053
  outputSchema?: JsonSchemaObject;
2051
2054
  agentContract?: AgentContract;
2052
2055
  acceptance?: AcceptanceInput;
@@ -2201,6 +2204,8 @@ export interface SubagentState {
2201
2204
  trustedSessionFileRoot?: string;
2202
2205
  /** Live async session roots created by this parent executor, keyed by run id. */
2203
2206
  liveAsyncSessionRoots?: Map<string, string>;
2207
+ /** Foreground nested routes retained after their direct parent settles, keyed by root run id. */
2208
+ retainedForegroundNestedRoutes?: Map<string, NestedRouteInfo>;
2204
2209
  /** Last valid parent session model observed for this session; used when continuation contexts omit ctx.model. */
2205
2210
  lastParentModel?: { provider: string; id: string };
2206
2211
  subagentInProgress?: boolean;
@@ -2409,6 +2414,7 @@ export interface RunSyncOptions {
2409
2414
  workflowChildPermitLaunch?: WorkflowChildPermitContext;
2410
2415
  /** Registry models available for heuristic bare-model resolution */
2411
2416
  availableModels?: Array<{ provider: string; id: string; fullId: string; contextWindow?: number }>;
2417
+ modelResponseAliases?: Record<string, string[]>;
2412
2418
  /** Current parent-session provider to prefer for ambiguous bare model ids */
2413
2419
  preferredModelProvider?: string;
2414
2420
  /** Parent Pi event host used to snapshot runtime-registered MCP servers before child launch. */
@@ -2544,6 +2550,8 @@ export interface ExtensionConfig {
2544
2550
  asyncWidget?: boolean;
2545
2551
  /** Configure the process-wide TTL policy for persisted model exclusions. */
2546
2552
  modelExclusions?: ModelExclusionsConfig;
2553
+ /** Exact provider/model candidates mapped to operator-declared equivalent response IDs. Empty arrays add no accepted IDs. */
2554
+ modelResponseAliases?: Record<string, string[]>;
2547
2555
  /** Tool description variant registered for the parent-facing subagent tool. Defaults to split metadata. */
2548
2556
  toolDescriptionMode?: ToolDescriptionMode;
2549
2557
  /** Inline chat rendering for the subagent tool. Defaults to rich. */
@@ -135,6 +135,9 @@ function isNotFoundError(error: unknown): boolean {
135
135
  * Read async job status from disk (with mtime-based caching)
136
136
  */
137
137
  export function readStatus(asyncDir: string): AsyncStatus | null {
138
+ if (Buffer.byteLength(path.basename(asyncDir), "utf-8") > 255) {
139
+ return null;
140
+ }
138
141
  const statusPath = path.join(asyncDir, "status.json");
139
142
 
140
143
  let stat: fs.Stats;
@@ -2,9 +2,11 @@ export type FileWatchPurpose =
2
2
  | "result-delivery"
3
3
  | "supervisor-channel"
4
4
  | "async-job-tracker"
5
+ | "retained-nested-route-tracker"
5
6
  | "runner-control-inbox"
6
7
  | "child-steering-inbox";
7
8
 
8
9
  export function shouldUseNativeFsWatch(_purpose: FileWatchPurpose, platform: NodeJS.Platform = process.platform): boolean {
10
+ if (_purpose === "retained-nested-route-tracker" && platform === "win32") return false;
9
11
  return platform !== "darwin";
10
12
  }
@@ -666,7 +666,7 @@ export class SubagentFleetStatus {
666
666
  const capacity = this.state.activeAsyncCapacity;
667
667
  const hasNativeRows = workEntries.some((entry) => !entry.external);
668
668
  const showNativeSummary = hasNativeRows || Boolean(capacity?.used);
669
- const asyncRuns = capacity && showNativeSummary ? `Async runs ${capacity.used}/${capacity.limit || "∞"}` : "";
669
+ const asyncRuns = capacity && showNativeSummary && (capacity.used > 0 || capacity.limit > 0) ? `Async runs ${capacity.used}/${capacity.limit || "∞"}` : "";
670
670
  const activeEntries = activeLeafAgentCount(workEntries);
671
671
  const noun = workEntries.some((entry) => entry.external) ? "job" : "agent";
672
672
  const agents = activeEntries > 0 ? `${activeEntries} active ${noun}${activeEntries === 1 ? "" : "s"}` : "";
package/src/tui/render.ts CHANGED
@@ -1284,7 +1284,7 @@ function workflowChecklistItemPriority(state: WorkflowChecklistState): number {
1284
1284
  return 6;
1285
1285
  }
1286
1286
 
1287
- function workflowChecklistItemLine(item: WorkflowChecklistItem, theme: Theme, indent: string, frame?: number): string {
1287
+ function workflowChecklistItemLine(item: WorkflowChecklistItem, theme: Theme, indent: string, frame: number | undefined, includeError: boolean): string {
1288
1288
  const identity = [item.label, item.agent && item.agent !== item.label ? item.agent : undefined].filter(Boolean).join(" · ");
1289
1289
  const context = contextModeBadge(theme, item.context);
1290
1290
  const state = item.state === "complete" ? "" : ` ${theme.fg("dim", `· ${workflowChecklistStateLabel(item.state)}`)}`;
@@ -1294,15 +1294,26 @@ function workflowChecklistItemLine(item: WorkflowChecklistItem, theme: Theme, in
1294
1294
  !item.currentTool && item.durationMs !== undefined ? formatDuration(item.durationMs) : undefined,
1295
1295
  item.toolCount !== undefined ? `${item.toolCount} tools` : undefined,
1296
1296
  item.outputName ? `out:${item.outputName}` : undefined,
1297
- item.error ? `error:${oneLine(item.error)}` : undefined,
1297
+ includeError && item.error ? `error:${oneLine(item.error)}` : undefined,
1298
1298
  ].filter(Boolean).join(" · ");
1299
1299
  return `${indent}${workflowChecklistGlyph(item, theme, frame)} ${theme.bold(identity || item.key)}${context}${state}${details ? ` ${theme.fg("dim", `· ${details}`)}` : ""}`;
1300
1300
  }
1301
1301
 
1302
1302
  const COLLAPSED_WORKFLOW_PHASE_LIMIT = 4;
1303
1303
 
1304
- function workflowChecklistWidgetLines(checklist: WorkflowChecklistProjection | undefined, theme: Theme, indent: string, expanded: boolean, frame?: number, includeSummary = true, limitPhases = !expanded, includeBottleneckOutput = expanded): string[] {
1304
+ interface WorkflowChecklistWidgetOptions {
1305
+ includeSummary?: boolean;
1306
+ limitPhases?: boolean;
1307
+ includeBottleneckOutput?: boolean;
1308
+ includeItemErrors?: boolean;
1309
+ }
1310
+
1311
+ function workflowChecklistWidgetLines(checklist: WorkflowChecklistProjection | undefined, theme: Theme, indent: string, expanded: boolean, frame?: number, options: WorkflowChecklistWidgetOptions = {}): string[] {
1305
1312
  if (!checklist?.total) return [];
1313
+ const includeSummary = options.includeSummary ?? true;
1314
+ const limitPhases = options.limitPhases ?? !expanded;
1315
+ const includeBottleneckOutput = options.includeBottleneckOutput ?? expanded;
1316
+ const includeItemErrors = options.includeItemErrors ?? true;
1306
1317
  const lines = includeSummary ? [`${indent}${theme.fg("dim", `Checklist ${formatWorkflowChecklistSummary(checklist)}`)}`] : [];
1307
1318
  const phases = !limitPhases || checklist.phases.length <= COLLAPSED_WORKFLOW_PHASE_LIMIT
1308
1319
  ? checklist.phases
@@ -1326,11 +1337,11 @@ function workflowChecklistWidgetLines(checklist: WorkflowChecklistProjection | u
1326
1337
  lines.push(`${indent}${glyph} ${theme.bold(formatWorkflowChecklistPhase(phase))}`);
1327
1338
  if (expanded) {
1328
1339
  for (const item of [...phase.items].sort((left, right) => workflowChecklistItemPriority(left.state) - workflowChecklistItemPriority(right.state))) {
1329
- lines.push(workflowChecklistItemLine(item, theme, `${indent} `, frame));
1340
+ lines.push(workflowChecklistItemLine(item, theme, `${indent} `, frame, includeItemErrors || item.kind === "host"));
1330
1341
  }
1331
1342
  }
1332
1343
  }
1333
- const bottleneck = formatWorkflowChecklistBottleneck(checklist.bottleneck, { includeOutput: includeBottleneckOutput });
1344
+ const bottleneck = formatWorkflowChecklistBottleneck(checklist.bottleneck, { includeOutput: includeBottleneckOutput, includeError: !expanded });
1334
1345
  if (bottleneck) {
1335
1346
  const tone = checklist.bottleneck?.state === "blocked" || checklist.bottleneck?.state === "failed" ? "error" : checklist.bottleneck?.state === "running" ? "accent" : "warning";
1336
1347
  lines.push(`${indent}${theme.fg(tone, `bottleneck · ${bottleneck}`)}`);
@@ -2371,7 +2382,7 @@ function foregroundStyleWidgetDetails(job: AsyncJobState, theme: Theme, expanded
2371
2382
  if (job.mode === "chain" && !job.activeParallelGroup && job.parallelGroups?.length) return widgetChainDetails(job, theme, expanded, width, frame);
2372
2383
  const lines: string[] = [
2373
2384
  ...(expanded ? workflowPreflightLines(job) : []),
2374
- ...workflowChecklistWidgetLines(projection.checklist, theme, " ", expanded, frame),
2385
+ ...workflowChecklistWidgetLines(projection.checklist, theme, " ", expanded, frame, { includeItemErrors: !expanded }),
2375
2386
  ];
2376
2387
  const group = activeParallelWidgetGroup(job);
2377
2388
  if (group) {
@@ -2767,7 +2778,7 @@ function buildWidgetLinesWithProjection(jobs: AsyncJobState[], theme: Theme, wid
2767
2778
  : [
2768
2779
  ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`,
2769
2780
  ...widgetLaneDetailLines(job, theme, projection),
2770
- ...workflowChecklistWidgetLines(projection.checklist, theme, " ", expanded, frame),
2781
+ ...workflowChecklistWidgetLines(projection.checklist, theme, " ", expanded, frame, { includeItemErrors: !expanded || !job.steps?.length }),
2771
2782
  ...widgetParallelAgentDetails(job, theme, expanded, width, frame),
2772
2783
  ];
2773
2784
  items.push([
@@ -2991,7 +3002,7 @@ function renderWorkflowChatProgress(d: Details, result: AgentToolResult<Details>
2991
3002
  if (d.preflight) c.addChild(new Text(truncLine(theme.fg("dim", formatWorkflowPreflightPlanSummary(d.preflight, { indent: rowIndent })), width), 0, 0));
2992
3003
  if (phase) c.addChild(new Text(truncLine(theme.fg("dim", `${rowIndent}Phase ${phase}`), width), 0, 0));
2993
3004
  const checklist = projectWorkflowChecklist({ hostSteps: workflow?.receipt?.hostSteps, preflight: d.preflight, trace: workflow?.trace, now: Date.now() });
2994
- for (const line of workflowChecklistWidgetLines(checklist, theme, rowIndent, false, frame, true, !expanded, expanded)) {
3005
+ for (const line of workflowChecklistWidgetLines(checklist, theme, rowIndent, false, frame, { limitPhases: !expanded, includeBottleneckOutput: expanded })) {
2995
3006
  c.addChild(new Text(truncLine(line, width), 0, 0));
2996
3007
  }
2997
3008
  if (rows.length === 0) {
@@ -3073,7 +3084,7 @@ function renderMultiCompact(d: Details, theme: Theme, layout: MainWindowRenderLa
3073
3084
  const detailIndent = mainWindowIndent(layout, 2);
3074
3085
  c.addChild(new Text(truncLine(`${glyph} ${theme.fg("toolTitle", theme.bold(d.mode))}${contextBadge}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`, width), 0, 0));
3075
3086
  if (checklistPrimary) {
3076
- for (const line of workflowChecklistWidgetLines(workflowChecklist, theme, rowIndent, false, frame, false)) {
3087
+ for (const line of workflowChecklistWidgetLines(workflowChecklist, theme, rowIndent, false, frame, { includeSummary: false })) {
3077
3088
  c.addChild(new Text(truncLine(line, width), 0, 0));
3078
3089
  }
3079
3090
  if (hasRunning || workflowChecklist.running > 0) c.addChild(new Text(truncLine(theme.fg("accent", `${rowIndent}${liveDetailHintText()}`), width), 0, 0));
@@ -3424,7 +3435,7 @@ export function renderSubagentResult(
3424
3435
  c.addChild(new Text(fit(` ${chainVis}`), 0, 0));
3425
3436
  }
3426
3437
  const workflowChecklist = foregroundWorkflowChecklist(d);
3427
- for (const line of workflowChecklistWidgetLines(workflowChecklist, theme, " ", true, frame)) {
3438
+ for (const line of workflowChecklistWidgetLines(workflowChecklist, theme, " ", true, frame, { includeItemErrors: false })) {
3428
3439
  c.addChild(new Text(fit(line), 0, 0));
3429
3440
  }
3430
3441
 
@@ -5,6 +5,7 @@ import { Worker } from "node:worker_threads";
5
5
  import { DEFAULT_GLOBAL_CONCURRENCY_LIMIT, Semaphore } from "../runs/shared/parallel-utils.ts";
6
6
  import { HOST_STEP_MAX_COUNT } from "../runs/shared/host-step-status.ts";
7
7
  import { classifyTaskMutationIntent } from "../runs/shared/task-intent.ts";
8
+ import { describeGateAcceptanceConflict } from "../runs/shared/acceptance.ts";
8
9
  import type { AcceptanceRecoveryMetadata, HostStepNodeV1, SingleResult } from "../shared/types.ts";
9
10
  import { normalizeWorkflowHostCommandParams, type WorkflowHostCommandParams, type WorkflowHostCommandResult } from "./host-command.ts";
10
11
 
@@ -74,6 +75,12 @@ function stableRunJson(value) {
74
75
  return JSON.stringify(value) ?? "undefined";
75
76
  }
76
77
 
78
+ function canonicalRunParams(params) {
79
+ if (params.gate === undefined || params.acceptance !== false) return params;
80
+ const { acceptance: _acceptance, ...withoutAcceptance } = params;
81
+ return withoutAcceptance;
82
+ }
83
+
77
84
  function isDirectWorkflowScriptPromiseHandlerCall() {
78
85
  const stack = new Error().stack;
79
86
  if (typeof stack !== "string") return false;
@@ -445,7 +452,7 @@ function validateLaneSpecs(laneSpecs) {
445
452
  validateLaneStageBounds(validationParams, stageLabel);
446
453
  validateRunCall(generatedKey, validationParams, stageLabel, validationFingerprints);
447
454
  const existingFingerprint = runFingerprints.get(generatedKey);
448
- if (existingFingerprint !== undefined && (resume === "previous" || existingFingerprint !== stableRunJson(params))) {
455
+ if (existingFingerprint !== undefined && (resume === "previous" || existingFingerprint !== stableRunJson(canonicalRunParams(params)))) {
449
456
  throw new Error("runs.lanes generated child key '" + generatedKey + "' is already used with incompatible launch params.");
450
457
  }
451
458
  stages.push({ key: stageKey, generatedKey, resume, params });
@@ -614,6 +621,19 @@ function validateLaneMetadata(value, label, workflowKey) {
614
621
  }
615
622
  }
616
623
 
624
+ function describeGateAcceptanceConflict(gate, acceptance) {
625
+ const render = (value) => {
626
+ let encoded;
627
+ try {
628
+ encoded = JSON.stringify(value) ?? String(value);
629
+ } catch {
630
+ encoded = String(value);
631
+ }
632
+ return encoded.length > 120 ? encoded.slice(0, 120) + "..." : encoded;
633
+ };
634
+ return " Both fields were present: gate=" + render(gate) + " acceptance=" + render(acceptance) + ".";
635
+ }
636
+
617
637
  function validateRunCall(key, params, label, fingerprints) {
618
638
  if (typeof key !== "string" || !runKeyPattern.test(key)) throw new Error(label + " has an invalid key.");
619
639
  if (hostKeys.has(key)) throw new Error("Workflow key '" + key + "' is already used by runs.host.");
@@ -627,7 +647,7 @@ function validateRunCall(key, params, label, fingerprints) {
627
647
  if (params.baseRef !== undefined && (typeof params.baseRef !== "string" || !validGitRef(params.baseRef))) throw new Error(label + " baseRef must be a valid Git ref.");
628
648
  validateLaneMetadata(params.lane, label + " lane", key);
629
649
  if (params.gate !== undefined && (typeof params.gate !== "string" || !params.gate.trim())) throw new Error(label + " gate must be a non-empty command string.");
630
- if (params.gate !== undefined && params.acceptance !== undefined) throw new Error(label + " gate cannot be combined with acceptance; use one gate command or acceptance.verify.");
650
+ if (params.gate !== undefined && params.acceptance !== undefined && params.acceptance !== false) throw new Error(label + " gate cannot be combined with acceptance; use one gate command or acceptance.verify." + describeGateAcceptanceConflict(params.gate, params.acceptance));
631
651
  if (params.gate !== undefined && params.resume !== undefined) throw new Error(label + " gate is not supported with retained resume.");
632
652
  if (params.extensionBindings !== undefined && params.resume !== undefined) throw new Error(label + " extensionBindings is not supported with retained resume; resume uses the original retained child binding.");
633
653
  if (params.resume !== undefined && typeof params.resume !== "string") {
@@ -644,7 +664,7 @@ function validateRunCall(key, params, label, fingerprints) {
644
664
  if (params.resume !== undefined && (typeof params.task !== "string" || !params.task.trim())) throw new Error(label + " resume requires a non-empty task follow-up.");
645
665
  validateExtensionBindings(params.extensionBindings, label);
646
666
  assertJsonValue(params, label + " params");
647
- const fingerprint = stableRunJson(params);
667
+ const fingerprint = stableRunJson(canonicalRunParams(params));
648
668
  const existing = fingerprints.get(key);
649
669
  if (existing !== undefined && existing !== fingerprint) throw new Error("Duplicate workflow key '" + key + "' used with incompatible launch params.");
650
670
  fingerprints.set(key, fingerprint);
@@ -1370,6 +1390,12 @@ function stableJson(value: unknown): string {
1370
1390
  return JSON.stringify(value) ?? "undefined";
1371
1391
  }
1372
1392
 
1393
+ function canonicalRunParams(params: Record<string, unknown>): Record<string, unknown> {
1394
+ if (params.gate === undefined || params.acceptance !== false) return params;
1395
+ const { acceptance: _acceptance, ...withoutAcceptance } = params;
1396
+ return withoutAcceptance;
1397
+ }
1398
+
1373
1399
  function validateKey(value: unknown, owner = "runs.run"): string {
1374
1400
  if (typeof value !== "string" || !KEY_PATTERN.test(value)) {
1375
1401
  throw new Error(`${owner} key must be 1-128 characters using letters, numbers, '.', '_' or '-', and start with a letter or number.`);
@@ -2082,7 +2108,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
2082
2108
  }
2083
2109
  return result;
2084
2110
  });
2085
- const fingerprint = stableJson(params);
2111
+ const fingerprint = stableJson(canonicalRunParams(params));
2086
2112
  const existing = launches.get(key);
2087
2113
  if (existing) {
2088
2114
  if (existing.fingerprint !== fingerprint) return respond(Promise.reject(new Error(`Duplicate workflow key '${key}' used with incompatible launch params.`)));
@@ -2109,8 +2135,8 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
2109
2135
  if (params.gate !== undefined && (typeof params.gate !== "string" || !params.gate.trim())) {
2110
2136
  return respond(Promise.reject(new Error(`runs.run('${key}') gate must be a non-empty command string.`)));
2111
2137
  }
2112
- if (params.gate !== undefined && params.acceptance !== undefined) {
2113
- return respond(Promise.reject(new Error(`runs.run('${key}') gate cannot be combined with acceptance; use one gate command or acceptance.verify.`)));
2138
+ if (params.gate !== undefined && params.acceptance !== undefined && params.acceptance !== false) {
2139
+ return respond(Promise.reject(new Error(`runs.run('${key}') gate cannot be combined with acceptance; use one gate command or acceptance.verify.` + describeGateAcceptanceConflict(params.gate, params.acceptance))));
2114
2140
  }
2115
2141
  if (params.gate !== undefined && params.resume !== undefined) {
2116
2142
  return respond(Promise.reject(new Error(`runs.run('${key}') gate is not supported with retained resume.`)));
@@ -402,11 +402,12 @@ export function formatWorkflowChecklistPhase(phase: WorkflowChecklistPhase): str
402
402
  return counts.length ? `${phase.label} ${counts.join(" · ")}` : phase.label;
403
403
  }
404
404
 
405
- export function formatWorkflowChecklistBottleneck(item: WorkflowChecklistItem | undefined, options: { includeOutput?: boolean } = {}): string | undefined {
405
+ export function formatWorkflowChecklistBottleneck(item: WorkflowChecklistItem | undefined, options: { includeOutput?: boolean; includeError?: boolean } = {}): string | undefined {
406
406
  if (!item) return undefined;
407
407
  const identity = [item.label, item.agent && item.agent !== item.label ? item.agent : undefined].filter((value): value is string => Boolean(value)).join(" · ") || item.key;
408
408
  const includeOutput = options.includeOutput ?? true;
409
- const details = [item.context ? `(${item.context})` : undefined, item.currentTool ? `${item.currentTool}${item.durationMs !== undefined ? ` ${formatDurationText(item.durationMs)}` : ""}` : undefined, !item.currentTool && item.currentPath ? item.currentPath : undefined, !item.currentTool && item.durationMs !== undefined ? formatDurationText(item.durationMs) : undefined, item.toolCount !== undefined ? `${item.toolCount} tools` : undefined, includeOutput && item.outputName ? `out:${item.outputName}` : undefined, item.error ? `error:${item.error.replace(/\bOutput:/g, "output:")}` : undefined].filter((value): value is string => Boolean(value));
409
+ const includeError = options.includeError ?? true;
410
+ const details = [item.context ? `(${item.context})` : undefined, item.currentTool ? `${item.currentTool}${item.durationMs !== undefined ? ` ${formatDurationText(item.durationMs)}` : ""}` : undefined, !item.currentTool && item.currentPath ? item.currentPath : undefined, !item.currentTool && item.durationMs !== undefined ? formatDurationText(item.durationMs) : undefined, item.toolCount !== undefined ? `${item.toolCount} tools` : undefined, includeOutput && item.outputName ? `out:${item.outputName}` : undefined, includeError && item.error ? `error:${item.error.replace(/\bOutput:/g, "output:")}` : undefined].filter((value): value is string => Boolean(value));
410
411
  return [identity, ...details].join(" · ");
411
412
  }
412
413
 
@@ -433,7 +434,7 @@ export function formatWorkflowChecklistText(projection: WorkflowChecklistProject
433
434
  lines.push(`${indent} ${marker} ${formatWorkflowChecklistItem(item)}`);
434
435
  }
435
436
  }
436
- const bottleneck = formatWorkflowChecklistBottleneck(projection.bottleneck);
437
+ const bottleneck = formatWorkflowChecklistBottleneck(projection.bottleneck, { includeError: options.includeItems === false });
437
438
  if (bottleneck) lines.push(`${indent} bottleneck · ${bottleneck}`);
438
439
  return lines;
439
440
  }