@mcrescenzo/opencode-workflows 0.1.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 (71) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/CODE_OF_CONDUCT.md +134 -0
  3. package/CONTRIBUTING.md +95 -0
  4. package/LICENSE +21 -0
  5. package/README.md +746 -0
  6. package/SECURITY.md +38 -0
  7. package/commands/repo-bughunt.md +94 -0
  8. package/commands/repo-review.md +148 -0
  9. package/commands/workflow-live-gates-release-check.md +143 -0
  10. package/docs/workflow-plugin.md +400 -0
  11. package/opencode-workflows.js +5 -0
  12. package/package.json +86 -0
  13. package/skills/opencode-workflow-authoring/SKILL.md +180 -0
  14. package/skills/repo-review-command-protocol/SKILL.md +56 -0
  15. package/skills/workflow-model-tiering/SKILL.md +57 -0
  16. package/skills/workflow-plan-review/SKILL.md +91 -0
  17. package/workflow-kernel/approval-hashing.js +39 -0
  18. package/workflow-kernel/async-util.js +33 -0
  19. package/workflow-kernel/audited-shell-policy.js +200 -0
  20. package/workflow-kernel/authority-policy.js +670 -0
  21. package/workflow-kernel/budget-accounting.js +142 -0
  22. package/workflow-kernel/capability-adapter.js +753 -0
  23. package/workflow-kernel/child-agent-runner.js +1264 -0
  24. package/workflow-kernel/constants.js +117 -0
  25. package/workflow-kernel/diagnostics.js +152 -0
  26. package/workflow-kernel/drain-runtime.js +421 -0
  27. package/workflow-kernel/errors.js +181 -0
  28. package/workflow-kernel/event-journal.js +487 -0
  29. package/workflow-kernel/extension-registry.js +144 -0
  30. package/workflow-kernel/free-text-redactor.js +91 -0
  31. package/workflow-kernel/gate-shapes.js +82 -0
  32. package/workflow-kernel/git-util.js +45 -0
  33. package/workflow-kernel/index.js +72 -0
  34. package/workflow-kernel/integration-mode.js +155 -0
  35. package/workflow-kernel/lane-effort-policy.js +134 -0
  36. package/workflow-kernel/lifecycle-control.js +608 -0
  37. package/workflow-kernel/live-gate-probes.js +916 -0
  38. package/workflow-kernel/notification-toast-cards.js +393 -0
  39. package/workflow-kernel/notification-toast-policy.js +179 -0
  40. package/workflow-kernel/notification-toast-scope.js +100 -0
  41. package/workflow-kernel/notification-toast.js +287 -0
  42. package/workflow-kernel/path-policy.js +219 -0
  43. package/workflow-kernel/result-readback.js +106 -0
  44. package/workflow-kernel/role-template-loading.js +606 -0
  45. package/workflow-kernel/run-context.js +139 -0
  46. package/workflow-kernel/run-observability.js +43 -0
  47. package/workflow-kernel/run-store-fs.js +231 -0
  48. package/workflow-kernel/run-store-locks.js +180 -0
  49. package/workflow-kernel/run-store-projections.js +421 -0
  50. package/workflow-kernel/run-store-rehydrate.js +68 -0
  51. package/workflow-kernel/run-store-state.js +147 -0
  52. package/workflow-kernel/run-store-status-format.js +1154 -0
  53. package/workflow-kernel/run-store-status.js +107 -0
  54. package/workflow-kernel/sandbox-executor.js +1131 -0
  55. package/workflow-kernel/session-access.js +56 -0
  56. package/workflow-kernel/structured-output.js +143 -0
  57. package/workflow-kernel/test-fix-drain-adapter.js +119 -0
  58. package/workflow-kernel/text-json.js +136 -0
  59. package/workflow-kernel/workflow-plugin.js +3017 -0
  60. package/workflow-kernel/workflow-source.js +444 -0
  61. package/workflow-kernel/worktree-adapter.js +256 -0
  62. package/workflow-kernel/worktree-git.js +147 -0
  63. package/workflows/repo-bughunt.js +362 -0
  64. package/workflows/repo-cleanup.js +383 -0
  65. package/workflows/repo-complexity.js +404 -0
  66. package/workflows/repo-deps.js +457 -0
  67. package/workflows/repo-modernize.js +395 -0
  68. package/workflows/repo-perf.js +394 -0
  69. package/workflows/repo-review.js +831 -0
  70. package/workflows/repo-security-audit.js +466 -0
  71. package/workflows/repo-test-gaps.js +377 -0
@@ -0,0 +1,3017 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { execFile } from "node:child_process";
3
+ import crypto from "node:crypto";
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { integrateLaneCommits } from "./integration-mode.js";
8
+ import { createExtensionRegistry } from "./extension-registry.js";
9
+ import { assertWritableWorkflowPath, safeWriteFileWithinRoot } from "./path-policy.js";
10
+ import {
11
+ BUNDLED_COMMAND_DIR,
12
+ BUNDLED_SKILL_DIR,
13
+ BUNDLED_WORKFLOW_DIR,
14
+ DEFAULT_CHILD_PROMPT_TIMEOUT_MS,
15
+ DEFAULT_CONCURRENCY,
16
+ DEFAULT_GUEST_DEADLINE_MS,
17
+ DEFAULT_MAX_AGENTS,
18
+ DEFAULT_SUBPROCESS_MAX_BUFFER,
19
+ DEFAULT_SUBPROCESS_TIMEOUT_MS,
20
+ HARD_CONCURRENCY_LIMIT,
21
+ LANE_OUTCOMES,
22
+ MAX_CHILD_PROMPT_TIMEOUT_MS,
23
+ OPENCODE_WORKFLOWS_DEBUG_CAPTURE_ENV,
24
+ MAX_STATUS_STRING_CHARS,
25
+ SECRET_GLOBS,
26
+ normalizeHardConcurrencyLimit,
27
+ resolveOpencodeConfigDir,
28
+ } from "./constants.js";
29
+ import {
30
+ extractTextFromError,
31
+ hash,
32
+ hashStable,
33
+ jsonPreview,
34
+ redactDurableValue,
35
+ stableStringify,
36
+ truncateText,
37
+ } from "./text-json.js";
38
+ import {
39
+ WorkflowCancelledError,
40
+ WorkflowBudgetStoppedError,
41
+ } from "./errors.js";
42
+ import { sessionApi } from "./session-access.js";
43
+ import { redactFreeTextSecrets } from "./free-text-redactor.js";
44
+ import {
45
+ cancelRun,
46
+ clearNotificationRuntimeState,
47
+ deliverWorkflowNotifications,
48
+ idleNotificationSessions,
49
+ killRun,
50
+ maybeDeliverCompletionNotification,
51
+ pauseRun,
52
+ abortRunChildren,
53
+ rejectWaitingAgents,
54
+ updateNotificationIdleState,
55
+ writeCompletionNotification,
56
+ } from "./lifecycle-control.js";
57
+ import {
58
+ assertLiveGateProbeAllowed,
59
+ collectTextParts,
60
+ createCapabilityAdapter,
61
+ liveGateReport,
62
+ promoteCapabilities,
63
+ promoteVerifiedGateCapabilities,
64
+ unwrapClientResult,
65
+ verifyRequiredAuthorityGates,
66
+ verifyNetworkMcpAuthorityGates,
67
+ VERIFIED_PROBE_TTL_MS,
68
+ } from "./capability-adapter.js";
69
+ import {
70
+ normalizeBudgetCeilings,
71
+ } from "./budget-accounting.js";
72
+ import {
73
+ approvalSnapshotList,
74
+ approvalHash,
75
+ computeDiffPlanHash,
76
+ } from "./approval-hashing.js";
77
+ import {
78
+ appendApplyLedger,
79
+ appendEvent,
80
+ appendIntegrationLedger,
81
+ appendValidationLedger,
82
+ buildResumeSignatureIndex,
83
+ compactJournal,
84
+ applyLedgerHasCompleted,
85
+ computeDomainMutationHash,
86
+ countNonEmptyLines,
87
+ domainMutationApprovalManifestForRun,
88
+ domainMutationManifest,
89
+ finalizeStagedDomainMutations,
90
+ loadJournal,
91
+ stagedDomainMutationManifest,
92
+ } from "./event-journal.js";
93
+ import {
94
+ acquireWorkflowLock,
95
+ assertSafeRunId,
96
+ cleanupRuns,
97
+ computeSalvageCandidates,
98
+ ensurePrivateDir,
99
+ ensureRunRoot,
100
+ isPathInside,
101
+ lifecycleRequestPath,
102
+ lockPathForRun,
103
+ readLock,
104
+ readLaneProjections,
105
+ readLaneRequestCheckpoint,
106
+ readLifecycleRequests,
107
+ readRunById,
108
+ reconcileRuns,
109
+ rehydrateRunFromPriorState,
110
+ runDirForRoot,
111
+ runRoots,
112
+ runs,
113
+ statusText,
114
+ eventsText,
115
+ timeoutResumeEligibilityForState,
116
+ writeFilePrivate,
117
+ writeJsonAtomic,
118
+ writeSalvagedLaneOutcome,
119
+ writeState,
120
+ } from "./run-store-status.js";
121
+ import {
122
+ createWorkflowToastEventSink,
123
+ showToast,
124
+ startWorkflowProgressToasts,
125
+ workflowApplyToastCard,
126
+ workflowHeartbeatToastCard,
127
+ workflowTerminalToastCard,
128
+ } from "./notification-toast.js";
129
+ import { applyLaneEffortParams } from "./lane-effort-policy.js";
130
+ import {
131
+ AD_HOC_AUTHORITY_PROFILE,
132
+ WORKFLOW_AUTHORITY_PROFILES,
133
+ assertWriteWorkflowAllowed,
134
+ authorityAutoApproveTier,
135
+ authorityArgsForWorkflow,
136
+ authoritySummary,
137
+ autoApproveCovers,
138
+ configureWorkflowPermissions,
139
+ effectiveAutoApproveCeiling,
140
+ modelKey,
141
+ normalizeAutoApproveTier,
142
+ resolveDrainMode,
143
+ resolveLaneModel,
144
+ resolveRequestedModel,
145
+ resolveRunAuthority,
146
+ } from "./authority-policy.js";
147
+ import {
148
+ buildNestedSnapshots,
149
+ hasExplicitWorkflowSource,
150
+ isTrustedWorkflowPath,
151
+ parseWorkflowSource,
152
+ resolveWorkflowSourceForStart,
153
+ } from "./workflow-source.js";
154
+ import {
155
+ listRoles,
156
+ listTemplates,
157
+ listWorkflows,
158
+ saveTemplate,
159
+ saveWorkflow,
160
+ } from "./role-template-loading.js";
161
+ import { readJsonFile } from "./run-store-status.js";
162
+ import { ajv, compileSchemaWithIdentity, validateStructuredResult } from "./structured-output.js";
163
+ // Re-exported below (and surfaced through index.js + the kernel barrel) so the public
164
+ // workflow-plugin export contract and the __test surface are preserved after the
165
+ // child-agent-runner / sandbox-executor extraction.
166
+ import {
167
+ addEditPlanFromResult,
168
+ checkpointHitForSignature,
169
+ classifyResumeCacheHit,
170
+ createEditWorktree,
171
+ normalizePatches,
172
+ runChildAgent,
173
+ } from "./child-agent-runner.js";
174
+ import { executeSandbox, runNestedWorkflow } from "./sandbox-executor.js";
175
+ import { inlineResultProjection } from "./result-readback.js";
176
+
177
+ const execFileAsync = promisify(execFile);
178
+
179
+ // Coupling-surface contract for the shared mutable run object threaded through this
180
+ // orchestrator and the extracted boundaries (sandbox-executor.js, child-agent-runner.js).
181
+ // @typedef {import("./run-context.js").RunContext} RunContext
182
+
183
+ // Terminal states a run may be resumed from (jbs3.1). "paused"/"interrupted" are
184
+ // cooperative/force-kill stops. "failed" (a lane threw a non-cancellation error) and
185
+ // "budget_stopped" (a cost/token/agent ceiling was hit) are ALSO resumable: their
186
+ // completed lanes are journaled and replay from cache at zero re-spend, so resuming
187
+ // re-runs only the failed/remaining lanes instead of stranding finished work. An
188
+ // explicit operator "cancelled" stays NON-resumable by design (cancel == abandon).
189
+ const RESUMABLE_STATUSES = new Set(["paused", "interrupted", "failed", "budget_stopped"]);
190
+
191
+ // Background heuristic (jbs3.7, mfv9.6). A foreground run blocks the calling agent inside
192
+ // workflow_run for the whole execution, so the agent cannot workflow_pause / workflow_cancel its
193
+ // own run mid-flight -- only the human ESC-to-interrupt path can. For a large fan-out that is a real
194
+ // loss of control. Wide, deep, or explicitly long runs default to background when background is
195
+ // omitted; explicit args.background and resume-pinned priorState.background still win.
196
+ const BACKGROUND_RECOMMEND_MAX_AGENTS = 8;
197
+ const BACKGROUND_RECOMMEND_WAVES = 3;
198
+ const BACKGROUND_RECOMMEND_DURATION_MS = 10 * 60 * 1000;
199
+ async function gitOutput(directory, args) {
200
+ const { stdout } = await execFileAsync("git", ["-C", directory, ...args], { encoding: "utf8", timeout: DEFAULT_SUBPROCESS_TIMEOUT_MS, maxBuffer: DEFAULT_SUBPROCESS_MAX_BUFFER });
201
+ return stdout.trim();
202
+ }
203
+
204
+ async function gitHead(directory) {
205
+ try {
206
+ return await gitOutput(directory, ["rev-parse", "HEAD"]);
207
+ } catch (error) {
208
+ throw new Error(`Workflow edit/apply requires a Git worktree with an initial commit: ${extractTextFromError(error)}`);
209
+ }
210
+ }
211
+
212
+ async function assertGitCleanAtBase(directory, baseCommit) {
213
+ const head = await gitHead(directory);
214
+ if (head !== baseCommit) throw new Error(`baseCommit mismatch: primary HEAD is ${head}, expected ${baseCommit}`);
215
+ const status = await gitOutput(directory, ["status", "--porcelain=v1", "--untracked-files=all"]);
216
+ const dirty = status.split(/\r?\n/).filter((line) => {
217
+ if (!line.trim()) return false;
218
+ const rel = line.slice(3).replace(/\\/g, "/");
219
+ return !rel.startsWith(".opencode/workflows/runs/");
220
+ });
221
+ if (dirty.length > 0) {
222
+ throw new Error(`Primary Git worktree is dirty; refusing apply: ${dirty.slice(0, 5).join(", ")}`);
223
+ }
224
+ }
225
+
226
+ async function gitPathTracked(directory, relativePath) {
227
+ try {
228
+ await execFileAsync("git", ["-C", directory, "ls-files", "--error-unmatch", "--", relativePath], { encoding: "utf8", timeout: DEFAULT_SUBPROCESS_TIMEOUT_MS, maxBuffer: DEFAULT_SUBPROCESS_MAX_BUFFER });
229
+ return true;
230
+ } catch {
231
+ return false;
232
+ }
233
+ }
234
+
235
+ function parseGitStatusFiles(status) {
236
+ return status.split(/\r?\n/).filter(Boolean).map((line) => {
237
+ const statusCode = line.slice(0, 2).trim() || "modified";
238
+ const rawPath = line.slice(3).trim();
239
+ const file = rawPath.includes(" -> ") ? rawPath.split(" -> ").pop() : rawPath;
240
+ return { status: statusCode, path: file.replace(/\\/g, "/") };
241
+ });
242
+ }
243
+
244
+ async function dirtyWorktreeSalvage(worktreePath, extra = {}) {
245
+ if (!worktreePath) return undefined;
246
+ let status;
247
+ try {
248
+ status = await gitOutput(worktreePath, ["status", "--porcelain=v1", "--untracked-files=all"]);
249
+ } catch (error) {
250
+ return { ...extra, dirty: undefined, worktreePath, error: truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS) };
251
+ }
252
+ const changedFiles = parseGitStatusFiles(status);
253
+ if (changedFiles.length === 0) return undefined;
254
+ let diffStat = "";
255
+ try {
256
+ diffStat = await gitOutput(worktreePath, ["diff", "--stat", "HEAD", "--"]);
257
+ } catch {
258
+ // Diff stat is best-effort salvage metadata; status remains authoritative.
259
+ }
260
+ return {
261
+ ...extra,
262
+ dirty: true,
263
+ worktreePath,
264
+ changedFiles,
265
+ changedFileCount: changedFiles.length,
266
+ diffStat,
267
+ statusLines: status.split(/\r?\n/).filter(Boolean),
268
+ };
269
+ }
270
+
271
+ function parseCommandMarkdown(source, fallbackDescription) {
272
+ const normalized = String(source ?? "").replace(/\r\n/g, "\n");
273
+ const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
274
+ if (!match) return { description: fallbackDescription, template: normalized.trim() };
275
+
276
+ const description = match[1].match(/^description:\s*(.+?)\s*$/m)?.[1]?.replace(/^['"]|['"]$/g, "") ?? fallbackDescription;
277
+ return { description, template: match[2].trimStart() };
278
+ }
279
+
280
+ async function registerBundledCommand(cfg, name, commandPath, fallbackDescription) {
281
+ if (cfg.command[name]) return;
282
+ try {
283
+ const source = await fs.readFile(commandPath, "utf8");
284
+ cfg.command[name] = parseCommandMarkdown(source, fallbackDescription);
285
+ } catch {
286
+ /* bundled command markdown absent (e.g. trimmed tarball) — degrade, do not break plugin load */
287
+ }
288
+ }
289
+
290
+ async function registerCommandsFromDir(cfg, commandDir) {
291
+ let commandFiles = [];
292
+ try {
293
+ commandFiles = (await fs.readdir(commandDir)).filter((file) => file.endsWith(".md"));
294
+ } catch {
295
+ /* command dir absent (e.g. trimmed tarball or an extension without commands) — degrade */
296
+ return;
297
+ }
298
+ for (const file of commandFiles.sort()) {
299
+ const name = file.slice(0, -3);
300
+ await registerBundledCommand(cfg, name, path.join(commandDir, file), `Run the ${name} workflow`);
301
+ }
302
+ }
303
+
304
+ async function configureWorkflowEntrypoints(cfg, extensionAssetDirs = { workflows: [], commands: [], skills: [] }) {
305
+ cfg.skills = cfg.skills && typeof cfg.skills === "object" && !Array.isArray(cfg.skills) ? cfg.skills : {};
306
+ cfg.skills.paths = Array.isArray(cfg.skills.paths) ? cfg.skills.paths : [];
307
+ if (!cfg.skills.paths.includes(BUNDLED_SKILL_DIR)) cfg.skills.paths.push(BUNDLED_SKILL_DIR);
308
+ // Extension skill dirs merge in (explicitly-configured trusted asset dirs).
309
+ for (const skillDir of extensionAssetDirs?.skills ?? []) {
310
+ if (!cfg.skills.paths.includes(skillDir)) cfg.skills.paths.push(skillDir);
311
+ }
312
+
313
+ cfg.command = cfg.command && typeof cfg.command === "object" && !Array.isArray(cfg.command) ? cfg.command : {};
314
+ // Register every bundled command markdown generically (no per-command hardcoding). The command
315
+ // name is the file basename; the description is parsed from the markdown (fallback otherwise).
316
+ // Bundled FIRST, then extensions: registerBundledCommand's `if (cfg.command[name]) return` guard
317
+ // gives bundled > extension precedence — an extension can only add NET-NEW command names.
318
+ await registerCommandsFromDir(cfg, BUNDLED_COMMAND_DIR);
319
+ for (const cmdDir of extensionAssetDirs?.commands ?? []) {
320
+ await registerCommandsFromDir(cfg, cmdDir);
321
+ }
322
+ }
323
+
324
+ function coerceLaneTimeoutMs(value, label) {
325
+ if (value === undefined || value === null) return undefined;
326
+ if (!Number.isInteger(value) || value <= 0) throw new Error(`${label} must be a positive integer number of milliseconds`);
327
+ if (value > MAX_CHILD_PROMPT_TIMEOUT_MS) throw new Error(`${label} must be <= ${MAX_CHILD_PROMPT_TIMEOUT_MS}ms`);
328
+ return value;
329
+ }
330
+
331
+ function laneTimeoutAliasValue(source = {}, label = "lane timeout") {
332
+ const laneTimeoutMs = coerceLaneTimeoutMs(source.laneTimeoutMs, `${label}.laneTimeoutMs`);
333
+ const childPromptTimeoutMs = coerceLaneTimeoutMs(source.childPromptTimeoutMs, `${label}.childPromptTimeoutMs`);
334
+ if (laneTimeoutMs !== undefined && childPromptTimeoutMs !== undefined && laneTimeoutMs !== childPromptTimeoutMs) {
335
+ throw new Error(`${label} laneTimeoutMs and childPromptTimeoutMs must match when both are provided`);
336
+ }
337
+ return laneTimeoutMs ?? childPromptTimeoutMs;
338
+ }
339
+
340
+ async function checkDurableLifecycleRequest(pluginContextOrRun, maybeToolContext, maybeRun) {
341
+ const run = maybeRun ?? pluginContextOrRun;
342
+ const pluginContext = maybeRun ? pluginContextOrRun : {};
343
+ const toolContext = maybeRun ? maybeToolContext : {};
344
+ const requests = await readLifecycleRequests(run.dir);
345
+ if (requests.kill) {
346
+ // A force-terminate request observed by a still-live owner: abandon the run to a resumable
347
+ // terminal state (interrupted) rather than the cooperative cancel/pause path. run.killed
348
+ // makes runWorkflowExecution's catch keep the interrupted status.
349
+ run.lifecycleRequests = requests;
350
+ run.killed = true;
351
+ run.status = "interrupted";
352
+ run.abortController.abort();
353
+ rejectWaitingAgents(run, new WorkflowCancelledError(requests.kill.reason || "Workflow run was force-terminated by durable request"));
354
+ await abortRunChildren(pluginContext, run, toolContext?.directory);
355
+ await appendEvent(run, { type: "run.killed", requestedAt: requests.kill.requestedAt, reason: requests.kill.reason });
356
+ await writeState(run);
357
+ throw new WorkflowCancelledError(requests.kill.reason || "Workflow run was force-terminated by durable request");
358
+ }
359
+ if (requests.cancel) {
360
+ run.lifecycleRequests = requests;
361
+ run.status = "cancelling";
362
+ run.abortController.abort();
363
+ rejectWaitingAgents(run, new WorkflowCancelledError(requests.cancel.reason || "Workflow run was cancelled by durable request"));
364
+ await abortRunChildren(pluginContext, run, toolContext?.directory);
365
+ await appendEvent(run, { type: "run.cancel_requested", requestedAt: requests.cancel.requestedAt, reason: requests.cancel.reason });
366
+ await writeState(run);
367
+ throw new WorkflowCancelledError(requests.cancel.reason || "Workflow run was cancelled by durable request");
368
+ }
369
+ if (requests.pause) {
370
+ run.lifecycleRequests = requests;
371
+ run.pauseRequested = true;
372
+ run.status = "pausing";
373
+ run.abortController.abort();
374
+ rejectWaitingAgents(run, new WorkflowCancelledError(requests.pause.reason || "Workflow run was paused by durable request"));
375
+ await abortRunChildren(pluginContext, run, toolContext?.directory);
376
+ await appendEvent(run, { type: "run.pause_requested", requestedAt: requests.pause.requestedAt, reason: requests.pause.reason });
377
+ await writeState(run);
378
+ throw new WorkflowCancelledError(requests.pause.reason || "Workflow run was paused by durable request");
379
+ }
380
+ }
381
+
382
+ async function readResumeRunEntry(context, runId) {
383
+ for (const root of runRoots(context)) {
384
+ const dir = runDirForRoot(root, runId);
385
+ const state = await readJsonFile(path.join(dir, "state.json"), undefined);
386
+ if (state !== undefined) return { root, dir, state };
387
+ }
388
+ throw new Error(`Workflow run is not resumable: ${runId} was not found`);
389
+ }
390
+
391
+ // Transitional lifecycle statuses: a cooperative pause/cancel has been requested but the run has
392
+ // not finished unwinding yet, so it is neither still cleanly running nor settled into a
393
+ // resumable/terminal status. A resume attempt in this window previously hit the generic "not
394
+ // resumable from status pausing" error, which reads like a hard rejection. Return actionable
395
+ // settle guidance instead so an agent that pauses-then-immediately-resumes knows to poll for the
396
+ // settled status rather than treating the transitional state as a dead end.
397
+ const SETTLING_STATUSES = new Set(["pausing", "cancelling"]);
398
+
399
+ async function assertResumableState(entry, runId, args = {}) {
400
+ const status = String(entry?.state?.status ?? "unknown");
401
+ if (RESUMABLE_STATUSES.has(status)) {
402
+ if (Object.hasOwn(args, "resumePolicy")) {
403
+ throw new Error("resumePolicy is only valid when resuming an eligible timed-out run");
404
+ }
405
+ return;
406
+ }
407
+ if (status === "timed-out") {
408
+ const runLock = await readLock(lockPathForRun(entry.dir, "run"));
409
+ const state = runLock ? { ...entry.state, locks: { ...(entry.state.locks ?? {}), run: runLock } } : entry.state;
410
+ const timeoutRecovery = timeoutResumeEligibilityForState(state);
411
+ if (!timeoutRecovery.eligible) {
412
+ throw new Error(`Workflow run ${runId} timed-out resume is blocked: ${timeoutRecovery.blockedReasons.join("; ")}`);
413
+ }
414
+ if (args.resumePolicy !== "extend-deadline") {
415
+ throw new Error(`Workflow run ${runId} timed-out resume requires resumePolicy:"extend-deadline"`);
416
+ }
417
+ const priorMaxRuntimeMs = entry.state.maxRuntimeMs;
418
+ if (!Number.isInteger(args.maxRuntimeMs) || args.maxRuntimeMs <= priorMaxRuntimeMs) {
419
+ throw new Error(`Workflow run ${runId} timed-out resume requires maxRuntimeMs greater than prior maxRuntimeMs ${priorMaxRuntimeMs}`);
420
+ }
421
+ return;
422
+ }
423
+ if (SETTLING_STATUSES.has(status)) {
424
+ if (status === "pausing") {
425
+ throw new Error(`Workflow run ${runId} is still settling (status pausing); poll workflow_status({ runId: "${runId}" }) until status is paused, then resume with workflow_run({ resumeRunId: "${runId}" }).`);
426
+ }
427
+ throw new Error(`Workflow run ${runId} is still settling (status cancelling); poll workflow_status({ runId: "${runId}" }) until it reaches a terminal status. A cancelled run is terminal and not resumable.`);
428
+ }
429
+ throw new Error(`Workflow run ${runId} is not resumable from status ${status}; only ${[...RESUMABLE_STATUSES].join(", ")} runs can be resumed`);
430
+ }
431
+
432
+ function backgroundHeuristic(approval = {}) {
433
+ const maxAgents = Number.isInteger(approval?.maxAgents) ? approval.maxAgents : 0;
434
+ const concurrency = Math.max(1, Number.isInteger(approval?.concurrency) ? approval.concurrency : 1);
435
+ const waves = Math.max(1, Math.ceil(maxAgents / concurrency));
436
+ const maxAgentsSignal = approval?.maxAgentsSignal !== false;
437
+ const maxRuntimeSignal = approval?.maxRuntimeSignal !== false;
438
+ const wide = maxAgentsSignal && maxAgents >= BACKGROUND_RECOMMEND_MAX_AGENTS;
439
+ const deep = maxAgentsSignal && waves >= BACKGROUND_RECOMMEND_WAVES;
440
+ const long = maxRuntimeSignal && Number.isInteger(approval?.maxRuntimeMs) && approval.maxRuntimeMs >= BACKGROUND_RECOMMEND_DURATION_MS;
441
+ const signals = [
442
+ wide ? `maxAgents=${maxAgents}` : undefined,
443
+ deep ? `~${waves} concurrency waves (concurrency=${concurrency})` : undefined,
444
+ long ? `maxRuntimeMs=${approval.maxRuntimeMs}ms` : undefined,
445
+ ].filter(Boolean);
446
+ return { recommended: wide || deep || long, maxAgents, concurrency, waves, wide, deep, long, signals };
447
+ }
448
+
449
+ function backgroundSignalsText(heuristic) {
450
+ return (heuristic?.signals ?? []).join(", ");
451
+ }
452
+
453
+ function workflowBackgroundDecision(meta = {}, sourcePath = "", args = {}, priorState = null, sizing = {}) {
454
+ if (priorState && typeof priorState.background === "boolean") {
455
+ return { enabled: priorState.background, source: "resume" };
456
+ }
457
+ if (typeof args.background === "boolean") {
458
+ return { enabled: args.background, source: "explicit" };
459
+ }
460
+ if (meta.harness === "drain") {
461
+ const runtimeArgs = args.args && typeof args.args === "object" && !Array.isArray(args.args) ? args.args : {};
462
+ if (resolveDrainMode(runtimeArgs) === "autonomous-local") {
463
+ return { enabled: true, source: "drain-autonomous-local" };
464
+ }
465
+ }
466
+ const heuristic = backgroundHeuristic(sizing);
467
+ if (heuristic.recommended) return { enabled: true, source: "heuristic", heuristic };
468
+ return { enabled: false, source: "foreground-default", heuristic };
469
+ }
470
+
471
+ function effectiveWorkflowBackground(meta = {}, sourcePath = "", args = {}, priorState = null, sizing = {}) {
472
+ return workflowBackgroundDecision(meta, sourcePath, args, priorState, sizing).enabled;
473
+ }
474
+
475
+ // Returns an advisory background-recommendation line for the approval preview, or undefined when
476
+ // background is already enabled or the run is small enough that blocking the agent foreground is
477
+ // cheap. Gates on maxAgents (wide), concurrency via wave count (deep/serialized), and an explicit
478
+ // maxRuntimeMs (long-running) so all three size signals from the bead are considered.
479
+ function backgroundRecommendation(approval) {
480
+ if (approval?.background === true) return undefined;
481
+ if (approval?.backgroundDecision?.source === "resume") return undefined;
482
+ const heuristic = backgroundHeuristic(approval);
483
+ if (!heuristic.recommended) return undefined;
484
+ const signals = backgroundSignalsText(heuristic);
485
+ return [
486
+ `Background recommended (heuristic): this is a large/long run (${signals}).`,
487
+ "Running it foreground blocks the calling agent for the whole run, so the agent cannot workflow_pause/workflow_cancel it mid-run (only the human ESC-to-interrupt path can).",
488
+ "Pass background: true to retain a control channel: poll workflow_status, then workflow_pause/workflow_cancel as needed. The human ESC path is unchanged either way.",
489
+ ].join(" ");
490
+ }
491
+
492
+ function backgroundDefaultLine(approval) {
493
+ if (approval?.backgroundDecision?.source !== "heuristic") return undefined;
494
+ const heuristic = approval.backgroundDecision.heuristic ?? backgroundHeuristic(approval);
495
+ return [
496
+ `Background defaulted (heuristic): this is a large/long run (${backgroundSignalsText(heuristic)}).`,
497
+ "The run starts in background so the calling agent keeps a control channel for workflow_status, workflow_pause, and workflow_cancel.",
498
+ ].join(" ");
499
+ }
500
+
501
+ function backgroundNotificationWarning(approval, runId) {
502
+ if (approval?.background !== true) return undefined;
503
+ if (approval?.notificationDelivery?.promptAsyncAvailable !== false) return undefined;
504
+ const statusHint = runId
505
+ ? `Poll workflow_status({ runId: "${runId}" }) for completion and use detail:"result" for final output.`
506
+ : "After launch, poll workflow_status with the returned run id and use detail:\"result\" for final output.";
507
+ return `Background notification warning: session.promptAsync is unavailable, so no completion prompt can be delivered to the invoking session. ${statusHint}`;
508
+ }
509
+
510
+ function authorityIsolationSummary(authority) {
511
+ if (authority.integration) return "local integration worktrees; primary-tree writes require workflow_apply";
512
+ if (authority.worktreeEdit) return "native edit worktrees; primary-tree writes require workflow_apply";
513
+ if (authority.edit) return "primary-tree write authority gated by workflow_apply";
514
+ return "no workflow-managed write isolation requested";
515
+ }
516
+
517
+ function mutationDomainSummary(run) {
518
+ if (run.meta?.harness === "drain") {
519
+ const runtimeArgs = run.runtimeArgs && typeof run.runtimeArgs === "object" ? run.runtimeArgs : {};
520
+ const mode = resolveDrainMode(runtimeArgs);
521
+ return mode === "autonomous-local"
522
+ ? "domain state via the trusted drain adapter; primary tree changes require workflow_apply"
523
+ : "none (drain dry-run)";
524
+ }
525
+ if (run.authority?.integration) return "integration worktrees and workflow_apply-gated primary tree";
526
+ if (run.authority?.edit || run.authority?.worktreeEdit) return "workflow_apply-gated primary tree";
527
+ return "none declared";
528
+ }
529
+
530
+ function isRunAborted(run, toolContext) {
531
+ return run.abortController.signal.aborted || (!run.ignoreToolAbort && toolContext.abort?.aborted);
532
+ }
533
+
534
+ function throwIfAborted(run, toolContext) {
535
+ if (isRunAborted(run, toolContext)) throw new WorkflowCancelledError();
536
+ }
537
+
538
+ function laneCancelledByFanout(run, callId) {
539
+ for (const scope of run.cancelledFanoutScopes ?? []) {
540
+ if (callId === scope || callId.startsWith(`${scope}/`)) return true;
541
+ }
542
+ return false;
543
+ }
544
+
545
+ function throwIfLaneCancelled(run, callId) {
546
+ if (laneCancelledByFanout(run, callId)) throw new WorkflowCancelledError("Workflow lane was cancelled by failFast fanout");
547
+ }
548
+
549
+ async function acquireAgentSlot(run, callId) {
550
+ if (run.abortController.signal.aborted) throw new WorkflowCancelledError();
551
+ throwIfLaneCancelled(run, callId);
552
+ if (run.activeAgents < run.concurrency) {
553
+ run.activeAgents += 1;
554
+ return;
555
+ }
556
+ let resolve;
557
+ let reject;
558
+ const promise = new Promise((res, rej) => {
559
+ resolve = res;
560
+ reject = rej;
561
+ });
562
+ run.waitingAgents.push({ resolve, reject, callId });
563
+ await promise;
564
+ if (run.abortController.signal.aborted) {
565
+ releaseAgentSlot(run);
566
+ throw new WorkflowCancelledError();
567
+ }
568
+ // A waiter is resolved by releaseAgentSlot HANDING OFF the slot (it does not
569
+ // decrement activeAgents), so this just-resolved lane now owns the slot. If the
570
+ // lane is fanout-cancelled in the microtask window after the hand-off, throwing
571
+ // here leaves runChildAgent with acquired=false, so its finally skips
572
+ // releaseAgentSlot and the handed-off slot leaks -> activeAgents permanently
573
+ // inflates and the concurrency gate eventually deadlocks (leak survives resume).
574
+ // Release the slot before throwing, mirroring the abort branch above (R5).
575
+ if (laneCancelledByFanout(run, callId)) {
576
+ releaseAgentSlot(run);
577
+ throw new WorkflowCancelledError("Workflow lane was cancelled by failFast fanout");
578
+ }
579
+ }
580
+
581
+ function releaseAgentSlot(run) {
582
+ const next = run.waitingAgents.shift();
583
+ if (next) {
584
+ next.resolve();
585
+ return;
586
+ }
587
+ run.activeAgents = Math.max(0, run.activeAgents - 1);
588
+ }
589
+
590
+ // Nested workflow() lanes execute inside the parent run and draw from the same maxAgents
591
+ // budget; a nested workflow's own declared maxAgents is ignored at runtime. Surface this so
592
+ // authors do not underbudget a parent that delegates to a larger nested cold path, and warn
593
+ // (heuristically) when a snapshotted nested workflow statically declares more agents than the
594
+ // parent budget. The warning is advisory only — dynamic workflow code may launch fewer lanes —
595
+ // so it never blocks an otherwise-valid approval.
596
+ function nestedBudgetNotes(run) {
597
+ if (!run.nestedSnapshots?.size) return [];
598
+ const uniqueByPath = [...new Map([...run.nestedSnapshots.values()].map((item) => [item.sourcePath, item])).values()];
599
+ const notes = ["Nested budget: nested workflow() lanes run inside this run and share its Max agents budget; a nested workflow's own declared maxAgents is ignored at runtime."];
600
+ for (const item of uniqueByPath) {
601
+ let declared;
602
+ try {
603
+ declared = parseWorkflowSource(item.source).meta?.maxAgents;
604
+ } catch {
605
+ declared = undefined;
606
+ }
607
+ if (typeof declared === "number" && declared > run.maxAgents) {
608
+ notes.push(`Nested budget warning (heuristic): nested workflow ${item.sourcePath} declares maxAgents=${declared}, higher than this run's Max agents=${run.maxAgents}. If it cold-runs its full fan-out it may budget-stop; raise this workflow's maxAgents or pass a precomputed result to skip the nested run.`);
609
+ }
610
+ }
611
+ return notes;
612
+ }
613
+
614
+ function finiteOrNull(value) {
615
+ return Number.isFinite(value) ? value : null;
616
+ }
617
+
618
+ function intOrNull(value) {
619
+ return Number.isInteger(value) ? value : null;
620
+ }
621
+
622
+ function truthyEnvFlag(value) {
623
+ if (typeof value !== "string") return false;
624
+ return /^(1|true|yes|on)$/i.test(value.trim());
625
+ }
626
+
627
+ function sourceLineCount(source) {
628
+ if (source.length === 0) return 0;
629
+ return source.split(/\r\n|\r|\n/).length;
630
+ }
631
+
632
+ function sourcePreviewMetadata(source, sourcePath, args = {}) {
633
+ const metadata = {
634
+ path: sourcePath,
635
+ byteLength: Buffer.byteLength(source, "utf8"),
636
+ lineCount: sourceLineCount(source),
637
+ inline: sourcePath === "<inline>",
638
+ };
639
+ if (args.includeSourceSnippet === true) {
640
+ const maxChars = Number.isInteger(args.sourceSnippetMaxChars) ? Math.min(Math.max(args.sourceSnippetMaxChars, 1), 2000) : 800;
641
+ metadata.snippet = truncateText(source, maxChars);
642
+ metadata.snippetChars = maxChars;
643
+ }
644
+ return metadata;
645
+ }
646
+
647
+ function approvalPreviewEnvelope(run) {
648
+ const phases = Array.isArray(run.meta.phases) ? run.meta.phases.join(", ") : "not declared";
649
+ const phaseList = Array.isArray(run.meta.phases) ? [...run.meta.phases] : [];
650
+ const nestedSnapshots = approvalSnapshotList(run.nestedSnapshots);
651
+ const nested = nestedSnapshots.length ? nestedSnapshots.map((item) => `${item.sourcePath} ${item.sourceHash}`).join("\n") : "none";
652
+ const approvedHash = approvalHash(run);
653
+ const backgroundHint = backgroundRecommendation(run) || null;
654
+ const backgroundDefault = backgroundDefaultLine(run) || null;
655
+ const notificationWarning = backgroundNotificationWarning(run) || null;
656
+ const requiredGates = Array.isArray(run.authority?.requiredGates) ? [...run.authority.requiredGates] : [];
657
+ return {
658
+ type: "workflow_preview",
659
+ status: "approval_required",
660
+ executed: false,
661
+ workflow: {
662
+ name: run.meta.name || "unnamed workflow",
663
+ description: run.meta.description || "not declared",
664
+ phases: phaseList,
665
+ phasesText: phases,
666
+ },
667
+ source: {
668
+ path: run.sourcePath,
669
+ sourceHash: run.sourceHash,
670
+ external: run.externalSource === true,
671
+ ...run.sourceMetadata,
672
+ },
673
+ approvalHash: approvedHash,
674
+ runtimeArgsPreview: run.argsPreview,
675
+ laneBudget: {
676
+ maxAgents: run.maxAgents,
677
+ concurrency: run.concurrency,
678
+ laneTimeoutMs: run.laneTimeoutMs,
679
+ guestDeadlineMs: run.guestDeadlineMs,
680
+ maxRuntimeMs: intOrNull(run.maxRuntimeMs),
681
+ },
682
+ modelPlan: {
683
+ defaultChildModel: run.defaultChildModel,
684
+ fast: run.modelTiers?.fast ?? run.defaultChildModel,
685
+ deep: run.modelTiers?.deep ?? run.defaultChildModel,
686
+ },
687
+ budgetCeilings: {
688
+ maxCost: finiteOrNull(run.budgetCeilings?.maxCost),
689
+ maxTokens: intOrNull(run.budgetCeilings?.maxTokens),
690
+ },
691
+ autoApprove: run.autoApprove ?? null,
692
+ background: {
693
+ enabled: run.background === true,
694
+ source: run.backgroundDecision?.source ?? null,
695
+ defaultReason: backgroundDefault,
696
+ recommendation: backgroundHint,
697
+ notificationWarning,
698
+ },
699
+ notificationDelivery: {
700
+ promptAsyncAvailable: run.notificationDelivery?.promptAsyncAvailable === true,
701
+ completionPrompt: run.background === true
702
+ ? (run.notificationDelivery?.promptAsyncAvailable === true ? "available" : "unavailable")
703
+ : "not_applicable",
704
+ warning: notificationWarning,
705
+ },
706
+ debugCapture: {
707
+ enabled: run.debugCapture === true,
708
+ source: run.debugCaptureSource ?? (run.debugCapture === true ? "unknown" : "off"),
709
+ artifacts: run.debugCapture === true ? "debug/<lane>/prompt.md, schema.json, transcript.jsonl" : "none",
710
+ },
711
+ authority: {
712
+ profile: run.authority.profile || AD_HOC_AUTHORITY_PROFILE,
713
+ requiredGates,
714
+ isolation: authorityIsolationSummary(run.authority),
715
+ summary: authoritySummary(run.authority),
716
+ details: run.authority,
717
+ },
718
+ mutationDomains: {
719
+ summary: mutationDomainSummary(run),
720
+ },
721
+ resume: run.resumePreview ? {
722
+ policy: run.resumePolicy ?? null,
723
+ ...run.resumePreview,
724
+ summary: resumeReplayLine(run.resumePreview),
725
+ } : null,
726
+ nestedSnapshots,
727
+ nestedSnapshotText: nested,
728
+ nestedBudgetNotes: nestedBudgetNotes(run),
729
+ capabilities: {
730
+ childSession: run.capabilities.childSession,
731
+ permissions: run.capabilities.permissions,
732
+ structuredOutput: run.capabilities.structuredOutput,
733
+ worktree: run.capabilities.worktree,
734
+ directoryRooting: run.capabilities.directoryRooting,
735
+ worktreeEditIsolation: run.capabilities.worktreeEditIsolation,
736
+ },
737
+ consent: [
738
+ ...(run.meta?.harness === "drain" && run.authority?.profile === "drain-autonomous-local"
739
+ ? ["for an autonomous-local drain, this launch approval authorizes in-run apply of a verified successful diff plan to the local primary tree (accepted changes land; staged domain mutations finalize) instead of stopping at awaiting-diff-approval. Failed drains keep failed-with-diff-plan for review via workflow_apply."]
740
+ : []),
741
+ ...(requiredGates.length
742
+ ? ["after approval, elevated launch may run required live-gate preflight probes before mutation/lane launch; probes spawn short-lived child sessions (model token use) and, for worktree/directory-rooting gates, create and remove scratch worktrees. The preview never probes; these side effects begin only after approval."]
743
+ : []),
744
+ ...(run.debugCapture === true
745
+ ? ["debug capture is enabled; lane prompts, schemas, and child transcripts are persisted under the private run directory after secrets redaction and size caps. This increases local sensitive evidence and disk usage."]
746
+ : []),
747
+ ],
748
+ approvalHashCovers: "workflow source, runtime args, authority, model, budgets, concurrency, debug capture, resume policy, capabilities, and nested snapshots",
749
+ };
750
+ }
751
+
752
+ function autoApprovePreviewLine(autoApprove) {
753
+ if (!autoApprove) return null;
754
+ if (autoApprove.eligible) {
755
+ return `Auto-approve: eligible (tier=${autoApprove.tier}, ceiling=${autoApprove.effectiveCeiling})`;
756
+ }
757
+ if (!autoApprove.configuredCeiling) {
758
+ return `Auto-approve: off (resolved tier=${autoApprove.tier}; configure options.autoApprove to enable single-call launch)`;
759
+ }
760
+ if (!autoApprove.effectiveCeiling) {
761
+ return `Auto-approve: off for this call (configured=${autoApprove.configuredCeiling}, requested=${autoApprove.requestedCeiling ?? "none"})`;
762
+ }
763
+ return `Auto-approve: not eligible (tier=${autoApprove.tier}, ceiling=${autoApprove.effectiveCeiling})`;
764
+ }
765
+
766
+ function workflowPreviewJson(run) {
767
+ return JSON.stringify(approvalPreviewEnvelope(run), null, 2);
768
+ }
769
+
770
+ function approvalSummary(run) {
771
+ const preview = approvalPreviewEnvelope(run);
772
+ // Human-first layout: the plan a user needs to reason about (what/models/lanes/authority/cost)
773
+ // leads; hashes, capabilities, and consent demote into a "Technical envelope" footer. Every line
774
+ // label is preserved verbatim so order-independent regex assertions over the preview still match.
775
+ return [
776
+ `Workflow approval required for ${preview.workflow.name}.`,
777
+ `Description: ${preview.workflow.description}`,
778
+ `Phases: ${preview.workflow.phasesText}`,
779
+ `Runtime args preview: ${preview.runtimeArgsPreview}`,
780
+ `Max agents: ${preview.laneBudget.maxAgents}`,
781
+ `Concurrency: ${preview.laneBudget.concurrency}`,
782
+ `Default child model: ${preview.modelPlan.defaultChildModel}`,
783
+ `Model plan: fast=${preview.modelPlan.fast} deep=${preview.modelPlan.deep}`,
784
+ `Lane timeout: ${preview.laneBudget.laneTimeoutMs}ms`,
785
+ ...(preview.laneBudget.maxRuntimeMs !== null ? [`Run deadline (maxRuntimeMs, operability limit, not approval-bound): ${preview.laneBudget.maxRuntimeMs}ms`] : []),
786
+ `Budget ceilings: maxCost=${preview.budgetCeilings.maxCost ?? "none"}, maxTokens=${preview.budgetCeilings.maxTokens ?? "none"}`,
787
+ `Debug capture: ${preview.debugCapture.enabled ? `enabled (${preview.debugCapture.source})` : "off"}`,
788
+ `Background: ${preview.background.enabled}`,
789
+ ...(preview.background.defaultReason ? [preview.background.defaultReason] : []),
790
+ ...(preview.background.recommendation ? [preview.background.recommendation] : []),
791
+ ...(preview.background.notificationWarning ? [preview.background.notificationWarning] : []),
792
+ `Authority profile: ${preview.authority.profile}`,
793
+ `Required gates: ${preview.authority.requiredGates.length ? preview.authority.requiredGates.join(", ") : "none"}`,
794
+ `Isolation: ${preview.authority.isolation}`,
795
+ `Authority: ${preview.authority.summary}`,
796
+ ...(preview.autoApprove ? [autoApprovePreviewLine(preview.autoApprove)] : []),
797
+ `Mutation domains: ${preview.mutationDomains.summary}`,
798
+ ...(preview.resume?.policy ? [`Resume policy: ${preview.resume.policy}`] : []),
799
+ ...(preview.resume ? [preview.resume.summary] : []),
800
+ `Nested workflow snapshots:\n${preview.nestedSnapshotText}`,
801
+ ...preview.nestedBudgetNotes,
802
+ "--- Technical envelope (hashes, capabilities, consent) ---",
803
+ `Source: ${preview.source.path}`,
804
+ `sourceHash: ${preview.source.sourceHash}`,
805
+ ...(preview.source.external ? [`External source (allowExternalScriptPath opt-in): true`] : []),
806
+ `approvalHash: ${preview.approvalHash}`,
807
+ `Capability summary: childSession=${preview.capabilities.childSession}, permissions=${preview.capabilities.permissions}, structuredOutput=${preview.capabilities.structuredOutput}, worktree=${preview.capabilities.worktree}, directoryRooting=${preview.capabilities.directoryRooting}, worktreeEditIsolation=${preview.capabilities.worktreeEditIsolation}`,
808
+ "Capability note: available-unverified is API shape only, not behavioral proof; elevated authority fails closed unless required capabilities are available/verified.",
809
+ ...preview.consent.map((line) => `Consent: ${line}`),
810
+ `approvalHash covers ${preview.approvalHashCovers}.`,
811
+ "Re-run with approve: true and approvalHash set to this approvalHash to execute this exact workflow envelope.",
812
+ ].join("\n");
813
+ }
814
+
815
+ function approvalPreviewResponse(run, args) {
816
+ return args.format === "json" ? workflowPreviewJson(run) : approvalSummary(run);
817
+ }
818
+
819
+ function approvalMismatchResponse(run, args) {
820
+ const freshPreview = approvalPreviewEnvelope(run);
821
+ return JSON.stringify({
822
+ type: "workflow_approval_mismatch",
823
+ status: "approval_mismatch",
824
+ executed: false,
825
+ reason: typeof args.approvalHash === "string" && args.approvalHash.length > 0 ? "approval_hash_mismatch" : "missing_approval_hash",
826
+ message: "Workflow approval required: nothing executed because approve:true did not include the current approvalHash for this workflow envelope. Review the freshPreview and re-run with its approvalHash if the plan is acceptable.",
827
+ suppliedApprovalHash: typeof args.approvalHash === "string" ? args.approvalHash : null,
828
+ freshApprovalHash: freshPreview.approvalHash,
829
+ freshPreview,
830
+ }, null, 2);
831
+ }
832
+
833
+ function workflowAutoApprovePreview(pluginContext, args, authority) {
834
+ const configuredCeiling = normalizeAutoApproveTier(pluginContext?.workflowAutoApproveCeiling);
835
+ const hasRequestedCeiling = args.autoApprove !== undefined && args.autoApprove !== null;
836
+ const requestedCeiling = hasRequestedCeiling ? normalizeAutoApproveTier(args.autoApprove) : null;
837
+ if (!configuredCeiling && !hasRequestedCeiling) return null;
838
+ const tier = authorityAutoApproveTier(authority);
839
+ const effectiveCeiling = effectiveAutoApproveCeiling(configuredCeiling, args.autoApprove);
840
+ return {
841
+ tier,
842
+ configuredCeiling: configuredCeiling || null,
843
+ requestedCeiling: requestedCeiling || null,
844
+ effectiveCeiling: effectiveCeiling || null,
845
+ eligible: Boolean(effectiveCeiling && autoApproveCovers(effectiveCeiling, tier)),
846
+ };
847
+ }
848
+
849
+ function workflowAutoApproval(pluginContext, args, approval) {
850
+ const autoApprove = approval.autoApprove ?? workflowAutoApprovePreview(pluginContext, args, approval.authority);
851
+ if (!autoApprove?.eligible) return null;
852
+ return { tier: autoApprove.tier, ceiling: autoApprove.effectiveCeiling };
853
+ }
854
+
855
+ function applyApprovalMismatchError({ runId, reason, field, supplied, expected, state, plan, actualPlanHash, currentDomainHash }) {
856
+ const mismatchSummary = reason === "staged_domain_mutations_changed"
857
+ ? "staged domain mutations changed after diff approval"
858
+ : `${field} mismatch`;
859
+ const freshApplyApproval = {
860
+ runId,
861
+ status: state?.status ?? null,
862
+ approvedSourceHash: state?.sourceHash ?? null,
863
+ baseCommit: plan?.baseCommit ?? null,
864
+ diffPlanHash: actualPlanHash ?? plan?.diffPlanHash ?? null,
865
+ domainMutationHash: currentDomainHash ?? plan?.domainMutationHash ?? null,
866
+ };
867
+ return new Error(JSON.stringify({
868
+ type: "workflow_apply_approval_mismatch",
869
+ status: "approval_mismatch",
870
+ executed: false,
871
+ reason,
872
+ field,
873
+ message: `${mismatchSummary}. Re-read workflow_status with detail:"full" for the run and retry with the fresh hash fields if the diff plan is still acceptable.`,
874
+ supplied,
875
+ expected,
876
+ freshStatusCommand: `workflow_status({ runId: "${runId}", format: "json", detail: "full" })`,
877
+ freshApplyApproval,
878
+ }, null, 2));
879
+ }
880
+
881
+ function workflowResultReadbackCommand(runId) {
882
+ return `workflow_status({ runId: "${runId}", format: "json", detail: "result" })`;
883
+ }
884
+
885
+ function workflowResultReadbackLines(run) {
886
+ return [
887
+ `Read redacted result: ${workflowResultReadbackCommand(run.id)}`,
888
+ "JSON result payload: status.result.output",
889
+ ];
890
+ }
891
+
892
+ function workflowInlineResultLines(run, output) {
893
+ const projection = inlineResultProjection(output);
894
+ if (projection.inline) {
895
+ return [
896
+ `Result (redacted JSON, ${projection.bytes} bytes):`,
897
+ projection.text,
898
+ ];
899
+ }
900
+ return [
901
+ `Result omitted from workflow_run: redacted JSON is ${projection.bytes} bytes, above inline cap ${projection.maxBytes}.`,
902
+ `Read full/partial result: ${workflowResultReadbackCommand(run.id)}`,
903
+ ];
904
+ }
905
+
906
+ function workflowToastOptions(pluginContext) {
907
+ return { ascii: pluginContext?.__workflowToastAscii === true };
908
+ }
909
+
910
+ function normalizeWorkflowToastAscii(value) {
911
+ return value === true || value === "ascii" || value === "plain-ascii";
912
+ }
913
+
914
+ async function showWorkflowRunToast(pluginContext, run, kind) {
915
+ const options = workflowToastOptions(pluginContext);
916
+ const card = kind === "heartbeat" ? workflowHeartbeatToastCard(run, options) : workflowTerminalToastCard(run, options);
917
+ await showToast(pluginContext, card.variant, card.title, card.message);
918
+ }
919
+
920
+ async function showWorkflowApplyToast(pluginContext, state) {
921
+ if (!["apply-running", "applied", "review-required", "apply-failed"].includes(state.status)) return;
922
+ const card = workflowApplyToastCard(state, workflowToastOptions(pluginContext));
923
+ await showToast(pluginContext, card.variant, card.title, card.message);
924
+ }
925
+
926
+ function msBetween(start, end) {
927
+ const startMs = typeof start === "string" ? Date.parse(start) : Number.NaN;
928
+ const endMs = typeof end === "string" ? Date.parse(end) : Number.NaN;
929
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return undefined;
930
+ return Math.max(0, endMs - startMs);
931
+ }
932
+
933
+ async function appendPersistedRunEvent(runDir, state, event) {
934
+ const eventRun = {
935
+ id: state.id,
936
+ dir: runDir,
937
+ eventCount: await countNonEmptyLines(path.join(runDir, "events.jsonl")),
938
+ };
939
+ await appendEvent(eventRun, event);
940
+ state.lastEventAt = eventRun.lastEventAt;
941
+ state.lastEventType = eventRun.lastEventType;
942
+ }
943
+
944
+ async function completeApprovalWaitMetric(runDir, state, diffPlanHash) {
945
+ if (!state.approvalWait?.startedAt) return;
946
+ const completedAt = new Date().toISOString();
947
+ const durationMs = msBetween(state.approvalWait.startedAt, completedAt);
948
+ state.approvalWait = {
949
+ ...state.approvalWait,
950
+ completedAt,
951
+ durationMs,
952
+ diffPlanHash: state.approvalWait.diffPlanHash ?? diffPlanHash,
953
+ };
954
+ state.operatorMetrics = {
955
+ ...(state.operatorMetrics ?? {}),
956
+ approvalWaitMs: durationMs ?? null,
957
+ awaitingDiffApprovalAt: state.approvalWait.startedAt,
958
+ appliedAt: completedAt,
959
+ };
960
+ await appendPersistedRunEvent(runDir, state, {
961
+ type: "run.approval_wait_completed",
962
+ diffPlanHash,
963
+ awaitingDiffApprovalAt: state.approvalWait.startedAt,
964
+ appliedAt: completedAt,
965
+ approvalWaitMs: durationMs,
966
+ });
967
+ }
968
+
969
+ // createEditWorktree and createIntegrationLaneWorktree moved to child-agent-runner.js
970
+ // (lane-owned worktree creation); imported above and re-exported below for the barrel.
971
+
972
+ async function cleanupWorktrees(run) {
973
+ // Best-effort removal of every worktree created for this run. The native (v2) removeWorktree
974
+ // also deletes the worktree's branch. The filesystem fallback only removes throwaway
975
+ // worktree directories created under the run dir, never anything outside it.
976
+ for (const record of run.editWorktrees ?? []) {
977
+ try {
978
+ await run.adapter.removeWorktree({ directory: record.path, id: record.id });
979
+ } catch {
980
+ // Native worktree removal is best effort.
981
+ }
982
+ if (record.path && record.path.startsWith(`${run.dir}${path.sep}`)) {
983
+ try {
984
+ await fs.rm(record.path, { recursive: true, force: true });
985
+ } catch {
986
+ // Filesystem fallback cleanup is best effort.
987
+ }
988
+ }
989
+ }
990
+ const integrationRecords = run.integrationWorktrees ?? [];
991
+ if (integrationRecords.length === 0) return false;
992
+ run.worktreeCleanup = run.worktreeCleanup ?? { integration: [] };
993
+ let changed = false;
994
+ for (const record of integrationRecords) {
995
+ const summary = {
996
+ role: record.role,
997
+ callId: record.callId,
998
+ laneId: record.laneId,
999
+ path: record.path,
1000
+ branch: record.branch,
1001
+ };
1002
+ try {
1003
+ if (!run.worktreeAdapter?.remove) {
1004
+ Object.assign(summary, { removed: false, preserved: true, reason: "missing-worktree-adapter" });
1005
+ } else {
1006
+ const result = await run.worktreeAdapter.remove(record);
1007
+ Object.assign(summary, {
1008
+ path: result.path ?? summary.path,
1009
+ removed: result.removed === true,
1010
+ preserved: result.preserved === true,
1011
+ reason: result.reason,
1012
+ });
1013
+ }
1014
+ } catch (error) {
1015
+ Object.assign(summary, { removed: false, preserved: true, reason: "remove-failed", error: truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS) });
1016
+ }
1017
+ changed = true;
1018
+ run.worktreeCleanup.integration.push(summary);
1019
+ try { await appendIntegrationLedger(run, { phase: "worktree-cleanup", ...summary }); } catch {}
1020
+ if (!summary.removed) {
1021
+ try { await appendEvent(run, { type: "integration.worktree_preserved", ...summary }); } catch {}
1022
+ }
1023
+ }
1024
+ return changed;
1025
+ }
1026
+
1027
+ // Hard-enforced read-only-vs-edit asymmetry for integration. A salvaged lane is read-only by
1028
+ // construction: workflow_salvage skips edit/integration lanes entirely and never writes a
1029
+ // worktree commit, so a salvaged entry cannot merge or auto-apply. This predicate encodes that
1030
+ // invariant in code, not just docs: even a salvaged entry that somehow carried committed: true
1031
+ // is rejected here, keeping it out of integrateLaneCommits() and the path to runAutoApply().
1032
+ function isLaneIntegrable(lane) {
1033
+ return Boolean(lane)
1034
+ && lane.committed === true
1035
+ && lane.salvagedFromTranscript !== true
1036
+ && lane.acceptedForIntegration !== false;
1037
+ }
1038
+
1039
+ const DRAIN_FAILURE_STATUSES = new Set(["failed", "not_dry", "max_waves_exceeded", "budget_exhausted"]);
1040
+ function drainOutputFailed(output) {
1041
+ return output !== null && typeof output === "object" && (DRAIN_FAILURE_STATUSES.has(output.status) || output.drainBlocking === true);
1042
+ }
1043
+
1044
+ function applyErrorWithRollbackFailures(error, rollbackFailures) {
1045
+ const primary = extractTextFromError(error);
1046
+ if (!Array.isArray(rollbackFailures) || rollbackFailures.length === 0) return primary;
1047
+ const paths = rollbackFailures.map((failure) => failure.path).filter(Boolean).join(", ");
1048
+ return `${primary}; Rollback incomplete${paths ? `: ${paths}` : ""}`;
1049
+ }
1050
+
1051
+ // Only host/extension-trusted workflow sources may auto-apply (never a project/global shadow), so an
1052
+ // approved-but-untrusted script cannot escalate a launch approval into an unattended primary-tree write
1053
+ // + domain mutation. Trusted = the core bundled dir + any explicitly-configured extension workflow dir
1054
+ // (read from the SAME registry that confers drain-adapter trust). A project/global shadow that WINS
1055
+ // resolution still lands on a project/global path, so it is denied here regardless of resolution order.
1056
+ function isTrustedAutoApplySource(sourcePath, pluginContext) {
1057
+ const resolved = path.resolve(String(sourcePath ?? ""));
1058
+ const isUnder = (root) => resolved === root || resolved.startsWith(root + path.sep);
1059
+ if (isUnder(BUNDLED_WORKFLOW_DIR)) return true;
1060
+ const extWfDirs = pluginContext?.workflowExtensionRegistry?.assetDirs()?.workflows ?? [];
1061
+ return extWfDirs.some((dir) => isUnder(path.resolve(String(dir))));
1062
+ }
1063
+
1064
+ // Generic autonomous-local auto-apply gate. Keyed on the PERSISTED authority (resume-safe), the drain
1065
+ // harness, a trusted source, and the registered adapter opting in (supportsAutoApply). Domain-neutral.
1066
+ function shouldAutoApplyDrain(run, pluginContext) {
1067
+ if (run.meta?.harness !== "drain") return false;
1068
+ if (run.authority?.profile !== "drain-autonomous-local") return false;
1069
+ if (!isTrustedAutoApplySource(run.sourcePath, pluginContext)) return false;
1070
+ const registration = pluginContext.workflowExtensionRegistry?.drainAdapter(run.meta?.adapter);
1071
+ if (registration && registration.supportsAutoApply !== true) return false;
1072
+ return true;
1073
+ }
1074
+
1075
+ // Autonomous-local auto-apply (.5): for an autonomous-local drain in a trusted source, a successful
1076
+ // drain with a verified diff plan is applied to the primary tree IN-RUN (still hash-gated, still using
1077
+ // the same patch/rollback/domain-finalization path as workflow_apply) instead of stopping at
1078
+ // awaiting-diff-approval. Staged domain mutations are finalized and read back. Apply failures
1079
+ // enter the retryable apply-failed state. Returns true on success.
1080
+ async function runAutoApply(pluginContext, toolContext, run) {
1081
+ const plan = run.editPlan;
1082
+ if (!plan || !Array.isArray(plan.patches) || plan.patches.length === 0) return false;
1083
+ const root = path.resolve(toolContext.worktree || toolContext.directory);
1084
+ let planned;
1085
+ try {
1086
+ await assertGitCleanAtBase(root, plan.baseCommit);
1087
+ planned = await validatePatchTargets(root, plan.patches);
1088
+ } catch (error) {
1089
+ run.status = "apply-failed";
1090
+ run.error = extractTextFromError(error);
1091
+ await appendApplyLedger(run.dir, { phase: "failed", diffPlanHash: plan.diffPlanHash, error: run.error, auto: true });
1092
+ await writeState(run);
1093
+ return false;
1094
+ }
1095
+ run.status = "apply-running";
1096
+ await writeState(run);
1097
+ await appendApplyLedger(run.dir, { phase: "started", diffPlanHash: plan.diffPlanHash, patchCount: plan.patches.length, auto: true });
1098
+ try {
1099
+ for (const { patch } of planned) {
1100
+ await appendApplyLedger(run.dir, { phase: "before-write", diffPlanHash: plan.diffPlanHash, path: patch.path, contentHash: hash(patch.content), auto: true });
1101
+ // TOCTOU-safe (R18): re-validate ancestors + open the final component with
1102
+ // O_NOFOLLOW immediately at write time, so a symlink swapped in after
1103
+ // validatePatchTargets cannot redirect the write outside root.
1104
+ await safeWriteFileWithinRoot(root, patch.path, patch.content);
1105
+ await appendApplyLedger(run.dir, { phase: "after-write", diffPlanHash: plan.diffPlanHash, path: patch.path, contentHash: hash(patch.content), auto: true });
1106
+ }
1107
+ await appendApplyLedger(run.dir, { phase: "completed", diffPlanHash: plan.diffPlanHash, auto: true });
1108
+ } catch (error) {
1109
+ const rollbackFailures = await rollbackPatches(planned, root);
1110
+ run.status = "apply-failed";
1111
+ run.error = applyErrorWithRollbackFailures(error, rollbackFailures);
1112
+ await appendApplyLedger(run.dir, { phase: "failed", diffPlanHash: plan.diffPlanHash, error: run.error, rollbackFailures, auto: true });
1113
+ await writeState(run);
1114
+ return false;
1115
+ }
1116
+ try {
1117
+ const domainFinalization = await finalizeStagedDomainMutations(run.dir, run, (op) => pluginContext.__workflowDomainMutationHandlers?.[op] ?? pluginContext.workflowExtensionRegistry?.mutationHandler(op));
1118
+ run.domainFinalization = domainFinalization;
1119
+ run.error = undefined;
1120
+ await appendApplyLedger(run.dir, { phase: "domain-finalized", finalized: domainFinalization?.finalized ?? 0, auto: true });
1121
+ return true;
1122
+ } catch (error) {
1123
+ run.status = "apply-failed";
1124
+ run.error = `Domain finalization failed after auto-apply: ${extractTextFromError(error)}`;
1125
+ await appendApplyLedger(run.dir, { phase: "failed", diffPlanHash: plan.diffPlanHash, error: run.error, auto: true });
1126
+ await writeState(run);
1127
+ return false;
1128
+ }
1129
+ }
1130
+
1131
+ // Orchestrator-resident primitives the extracted sandbox/child-agent boundaries depend on.
1132
+ // Injected (rather than imported by those modules) so the import graph stays acyclic:
1133
+ // workflow-plugin.js -> sandbox-executor.js -> child-agent-runner.js, never back. These all
1134
+ // read/write the shared mutable run object and remain owned here because other orchestrator
1135
+ // code (and the test surface) also uses them.
1136
+ const SANDBOX_DEPS = {
1137
+ throwIfAborted,
1138
+ throwIfLaneCancelled,
1139
+ acquireAgentSlot,
1140
+ releaseAgentSlot,
1141
+ checkDurableLifecycleRequest,
1142
+ dirtyWorktreeSalvage,
1143
+ laneTimeoutAliasValue,
1144
+ };
1145
+
1146
+ async function runWorkflowExecution(pluginContext, toolContext, run, body, args) {
1147
+ let stopProgressToasts = () => {};
1148
+ // Run-level wall-clock deadline (jbs3.6): when maxRuntimeMs is set, arm a single timer that
1149
+ // aborts the run controller so a wedged lane/host call cannot outlive the deadline. The abort
1150
+ // surfaces as a WorkflowCancelledError through the cooperative checks; run.deadlineExceeded
1151
+ // makes the catch below record a terminal "timed-out" status with a partial result.json.
1152
+ let deadlineTimer;
1153
+ if (Number.isInteger(run.maxRuntimeMs) && run.maxRuntimeMs > 0) {
1154
+ deadlineTimer = setTimeout(() => {
1155
+ if (run.abortController.signal.aborted) return;
1156
+ run.deadlineExceeded = true;
1157
+ run.abortController.abort();
1158
+ rejectWaitingAgents(run, new WorkflowCancelledError(`Workflow run exceeded maxRuntimeMs deadline of ${run.maxRuntimeMs}ms`));
1159
+ abortRunChildren(pluginContext, run, toolContext.directory).catch(() => {});
1160
+ appendEvent(run, { type: "run.deadline_exceeded", maxRuntimeMs: run.maxRuntimeMs }).catch(() => {});
1161
+ }, run.maxRuntimeMs);
1162
+ if (typeof deadlineTimer.unref === "function") deadlineTimer.unref();
1163
+ }
1164
+ try {
1165
+ await showWorkflowRunToast(pluginContext, run, "heartbeat");
1166
+ stopProgressToasts = startWorkflowProgressToasts(pluginContext, run);
1167
+ const output = await executeSandbox(pluginContext, toolContext, run, body, args, {
1168
+ ...SANDBOX_DEPS,
1169
+ checkDurableLifecycleRequest: (checkedRun) => checkDurableLifecycleRequest(pluginContext, toolContext, checkedRun),
1170
+ });
1171
+ throwIfAborted(run, toolContext);
1172
+ const drainFailed = drainOutputFailed(output);
1173
+ const integrationLanes = run.integrationPlan?.lanes?.filter(isLaneIntegrable) ?? [];
1174
+ if (integrationLanes.length > 0) {
1175
+ await appendIntegrationLedger(run, { phase: "integration-started", laneCount: integrationLanes.length });
1176
+ const result = await integrateLaneCommits({
1177
+ adapter: run.worktreeAdapter,
1178
+ runId: run.id,
1179
+ baseCommit: run.integrationPlan.baseCommit,
1180
+ lanes: integrationLanes,
1181
+ secretGlobs: SECRET_GLOBS,
1182
+ signal: run.abortController.signal,
1183
+ });
1184
+ await appendIntegrationLedger(run, { phase: "integration-completed", status: result.status, mergedLaneCount: result.mergedLanes?.length ?? 0, patchCount: result.patches?.length ?? 0 });
1185
+ if (result.validation) {
1186
+ const validationRecord = {
1187
+ phase: "integration-validation",
1188
+ validationKey: `integration:${run.id}`,
1189
+ status: result.validation.accepted === true ? "passed" : "failed",
1190
+ reason: result.validation.reason,
1191
+ validationCommands: result.validation.validationCommands,
1192
+ evidence: result.validation.evidence,
1193
+ };
1194
+ await appendValidationLedger(run, validationRecord);
1195
+ await appendEvent(run, { type: "integration.validation", status: validationRecord.status, reason: validationRecord.reason });
1196
+ }
1197
+ run.integrationPlan.integrationResult = result;
1198
+ run.integrationPlan.patches = result.patches ?? [];
1199
+ if (result.integrationWorktree) run.integrationWorktrees.push({ role: "integration", ...result.integrationWorktree });
1200
+ if (result.status === "review-required") {
1201
+ run.status = "review-required";
1202
+ run.error = result.reason || result.error || "Integration requires review";
1203
+ run.finishedAt = new Date().toISOString();
1204
+ run.resultPath = path.join(run.dir, "result.json");
1205
+ await writeJsonAtomic(run.resultPath, redactDurableValue({ output, integration: result }));
1206
+ await appendIntegrationLedger(run, { phase: "review-required", reason: result.reason, culpritLane: result.culpritLane, conflictCount: result.conflicts?.length ?? 0 });
1207
+ await appendEvent(run, { type: "integration.review_required", reason: result.reason, culpritLane: result.culpritLane, conflictCount: result.conflicts?.length ?? 0 });
1208
+ const notification = await writeCompletionNotification(run);
1209
+ await writeState(run);
1210
+ await maybeDeliverCompletionNotification(pluginContext, notification);
1211
+ await showWorkflowRunToast(pluginContext, run, "terminal");
1212
+ return [
1213
+ `Workflow ${run.id} review-required.`,
1214
+ ...workflowInlineResultLines(run, output),
1215
+ `Result file: ${run.resultPath}`,
1216
+ ...workflowResultReadbackLines(run),
1217
+ result.reason ? `Reason: ${result.reason}` : undefined,
1218
+ result.culpritLane ? `Culprit lane: ${result.culpritLane}` : undefined,
1219
+ ].filter((line) => line !== undefined).join("\n");
1220
+ }
1221
+ if (result.patches?.length > 0) {
1222
+ run.editPlan = {
1223
+ sourceHash: run.sourceHash,
1224
+ baseCommit: run.integrationPlan.baseCommit,
1225
+ patches: result.patches,
1226
+ worktrees: run.integrationWorktrees,
1227
+ integration: true,
1228
+ lanes: result.lanes,
1229
+ mergedLanes: result.mergedLanes,
1230
+ };
1231
+ }
1232
+ }
1233
+ if (run.editPlan?.patches?.length > 0) {
1234
+ run.editPlan.worktrees = run.editPlan.integration ? run.integrationWorktrees : run.editWorktrees;
1235
+ run.editPlan.domainMutationManifest = await stagedDomainMutationManifest(run.dir);
1236
+ run.editPlan.domainMutationHash = computeDomainMutationHash(run.editPlan.domainMutationManifest);
1237
+ run.editPlan.diffPlanHash = computeDiffPlanHash(run.editPlan);
1238
+ await writeJsonAtomic(path.join(run.dir, "diff-plan.json"), run.editPlan);
1239
+ if (run.editPlan.integration) await appendIntegrationLedger(run, { phase: "diff-plan-created", diffPlanHash: run.editPlan.diffPlanHash, patchCount: run.editPlan.patches.length });
1240
+ // A failed/partial drain must not be masked as a clean apply-ready success.
1241
+ // Preserve the diff plan for review but surface the failure in run status, event, toast, and summary.
1242
+ run.status = drainFailed ? "failed-with-diff-plan" : "awaiting-diff-approval";
1243
+ if (run.status === "awaiting-diff-approval" && !run.approvalWait?.startedAt) {
1244
+ run.approvalWait = { startedAt: new Date().toISOString(), diffPlanHash: run.editPlan.diffPlanHash };
1245
+ }
1246
+ } else {
1247
+ run.editPlan = undefined;
1248
+ run.status = "completed";
1249
+ }
1250
+ // Autonomous-local auto-apply (.5): a trusted autonomous-local drain applies a verified, successful
1251
+ // diff plan in-run instead of stopping at awaiting-diff-approval. Failed drains keep failed-with-diff-plan.
1252
+ if (run.status === "awaiting-diff-approval" && shouldAutoApplyDrain(run, pluginContext)) {
1253
+ const applied = await runAutoApply(pluginContext, toolContext, run);
1254
+ if (applied) run.status = "completed";
1255
+ }
1256
+ run.finishedAt = new Date().toISOString();
1257
+ run.resultPath = path.join(run.dir, "result.json");
1258
+ await writeJsonAtomic(run.resultPath, redactDurableValue({ output }));
1259
+ await appendEvent(run, {
1260
+ type: run.status === "awaiting-diff-approval" ? "run.awaiting_diff_approval" : run.status === "failed-with-diff-plan" ? "run.failed_with_diff_plan" : run.status === "apply-failed" ? "run.apply_failed" : "run.completed",
1261
+ diffPlanHash: run.editPlan?.diffPlanHash,
1262
+ awaitingDiffApprovalAt: run.approvalWait?.startedAt,
1263
+ drainStatus: typeof output === "object" && output ? output.status : undefined,
1264
+ });
1265
+ const notification = await writeCompletionNotification(run);
1266
+ await writeState(run);
1267
+ await maybeDeliverCompletionNotification(pluginContext, notification);
1268
+ await showWorkflowRunToast(pluginContext, run, "terminal");
1269
+ return [
1270
+ `Workflow ${run.id} ${run.status === "awaiting-diff-approval" ? "awaiting diff approval" : run.status === "failed-with-diff-plan" ? "failed with diff plan for review" : run.status === "apply-failed" ? "auto-apply failed" : "completed"}.`,
1271
+ ...workflowInlineResultLines(run, output),
1272
+ `Result file: ${run.resultPath}`,
1273
+ ...workflowResultReadbackLines(run),
1274
+ run.editPlan?.diffPlanHash ? `Diff plan hash: ${run.editPlan.diffPlanHash}` : undefined,
1275
+ drainFailed && typeof output === "object" && output ? `Drain status: ${output.status}` : undefined,
1276
+ ].filter((line) => line !== undefined).join("\n");
1277
+ } catch (error) {
1278
+ rejectWaitingAgents(run, error);
1279
+ // Precedence: an explicit force-kill (resumable "interrupted") and a wall-clock deadline
1280
+ // (terminal "timed-out") both abort the controller, so they must be classified before the
1281
+ // generic aborted->"cancelled" fallback.
1282
+ // Precedence: a budget stop does NOT abort the controller, so it is classified before the
1283
+ // generic aborted->"cancelled"/"failed" fallback. The thrown WorkflowBudgetStoppedError loses
1284
+ // its prototype/code crossing the QuickJS guest boundary (sandbox-executor re-wraps lane
1285
+ // errors as plain VM errors), so detect it the same way the rest of the run state is tracked:
1286
+ // off the durable run object. journalFailure records a per-lane "budget_stopped" outcome via
1287
+ // laneOutcomeForError BEFORE the error crosses that boundary, so run.laneOutcomes is the
1288
+ // reliable run-level signal. Keep the direct error checks for any controller-side throw that
1289
+ // never crossed the VM. budget_stopped and failed are both resumable (RESUMABLE_STATUSES).
1290
+ const budgetStopped = error instanceof WorkflowBudgetStoppedError
1291
+ || error?.code === "WORKFLOW_BUDGET_STOPPED"
1292
+ || (run.laneOutcomes?.budget_stopped ?? 0) > 0;
1293
+ run.status = run.killed ? "interrupted"
1294
+ : run.deadlineExceeded ? "timed-out"
1295
+ : run.pauseRequested ? "paused"
1296
+ : budgetStopped ? "budget_stopped"
1297
+ : run.abortController.signal.aborted ? "cancelled"
1298
+ : "failed";
1299
+ run.error = extractTextFromError(error);
1300
+ run.finishedAt = new Date().toISOString();
1301
+ if (run.status === "timed-out" || run.status === "budget_stopped" || run.status === "failed") {
1302
+ // Persist a partial result so an operator/agent can see how the run terminated and what
1303
+ // completed-lane work survived (durable lane projections/journal remain on disk
1304
+ // independently). For the resumable terminal states (failed/budget_stopped) this makes the
1305
+ // recoverable work observable so a resume reuses it instead of re-spending.
1306
+ run.resultPath = path.join(run.dir, "result.json");
1307
+ await writeJsonAtomic(run.resultPath, redactDurableValue({
1308
+ status: run.status,
1309
+ partial: true,
1310
+ resumable: RESUMABLE_STATUSES.has(run.status),
1311
+ maxRuntimeMs: run.maxRuntimeMs,
1312
+ budgetCeilings: run.budgetCeilings,
1313
+ laneOutcomes: run.laneOutcomes,
1314
+ error: truncateText(run.error, MAX_STATUS_STRING_CHARS),
1315
+ }));
1316
+ }
1317
+ const eventType = run.status === "paused" ? "run.paused"
1318
+ : run.status === "interrupted" ? "run.killed"
1319
+ : run.status === "timed-out" ? "run.timed_out"
1320
+ : run.status === "budget_stopped" ? "run.budget_stopped"
1321
+ : run.status === "cancelled" ? "run.cancelled"
1322
+ : "run.failed";
1323
+ await appendEvent(run, { type: eventType, error: truncateText(run.error, MAX_STATUS_STRING_CHARS) });
1324
+ const notification = await writeCompletionNotification(run);
1325
+ await writeState(run);
1326
+ await maybeDeliverCompletionNotification(pluginContext, notification);
1327
+ await showWorkflowRunToast(pluginContext, run, "terminal");
1328
+ throw error;
1329
+ } finally {
1330
+ clearTimeout(deadlineTimer);
1331
+ stopProgressToasts();
1332
+ run.eventSink = undefined;
1333
+ try {
1334
+ // The diff plan is self-contained (patches carry their content), so worktrees are
1335
+ // vestigial once the sandbox finishes; remove them for every terminal outcome to
1336
+ // avoid leaking git worktrees/branches and throwaway directories.
1337
+ const cleaned = await cleanupWorktrees(run);
1338
+ if (cleaned && run.status !== "running") await writeState(run);
1339
+ } catch (error) {
1340
+ run.diagnostics ??= {};
1341
+ run.diagnostics.finalizationCleanupError = truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS);
1342
+ try { await appendEvent(run, { type: "run.finalization_cleanup_failed", error: run.diagnostics.finalizationCleanupError }); } catch {}
1343
+ } finally {
1344
+ if (!run.background || run.status !== "running") runs.delete(run.id);
1345
+ if (run.status !== "running") await run.releaseRunLock?.();
1346
+ }
1347
+ }
1348
+ }
1349
+
1350
+ function sameStableValue(a, b) {
1351
+ return stableStringify(a) === stableStringify(b);
1352
+ }
1353
+
1354
+ // jbs3.4: resume replay preview for the approval gate. A resumed run re-executes the body and
1355
+ // serves completed lanes from the journal cache when each lane's signature still matches. The
1356
+ // signature (event-journal.laneSignature) is keyed on sourceHash + runtimeArgs + the lane's resolved
1357
+ // model + body/capability-derived fields. On an allowed resume the source and model envelope are
1358
+ // PINNED (a change is rejected by assertResumeEnvelopeUnchanged before approval), so the only
1359
+ // operator-facing lever that still invalidates the cache is runtimeArgs -- and changing any of these
1360
+ // shared, top-level signature inputs invalidates EVERY completed lane uniformly, forcing it to
1361
+ // re-run and re-pay its prior spend (which still counts toward the budget ceiling). This computes
1362
+ // how many completed lanes would re-run and the approximate dollar re-spend so the operator sees it
1363
+ // before approving. Returns null for a cold start (no prior run to replay).
1364
+ async function computeResumeReplayPreview(resumeEntry, priorState, proposed) {
1365
+ if (!resumeEntry || !priorState) return null;
1366
+ const journal = await loadJournal(resumeEntry.dir);
1367
+ const completedLanes = [...journal.values()].filter((entry) => entry.outcome === "success");
1368
+ // jbs3.3 edit-and-resume: an edited body changes sourceHash, but lane reuse is now content-addressed
1369
+ // PER LANE (event-journal.laneSignature no longer mixes in the whole-file hash), so a source edit no
1370
+ // longer uniformly invalidates every completed lane — only the lanes whose own resolved inputs
1371
+ // changed re-run. Which lanes those are is not known until the new body executes (each lane's prompt
1372
+ // is resolved during replay), so the preview reports an honest worst-case bound rather than the false
1373
+ // "everything re-runs" the whole-run hash compare would give. The other levers (runtimeArgs, model)
1374
+ // are top-level signature inputs whose change DOES invalidate every lane uniformly, so they are still
1375
+ // reported as a hard count.
1376
+ const editedBody = proposed.editAndResume === true && proposed.sourceHash !== priorState.sourceHash;
1377
+ const reasons = [];
1378
+ if (!editedBody && proposed.sourceHash !== priorState.sourceHash) reasons.push("source");
1379
+ if (stableStringify(proposed.runtimeArgs ?? null) !== stableStringify(priorState.runtimeArgs ?? null)) reasons.push("runtime args");
1380
+ if (typeof priorState.defaultChildModel === "string" && proposed.defaultChildModel !== priorState.defaultChildModel) reasons.push("model");
1381
+ const invalidated = reasons.length > 0;
1382
+ const willReRun = invalidated ? completedLanes : [];
1383
+ const reSpend = willReRun.reduce((sum, entry) => sum + (Number.isFinite(entry.cost) ? entry.cost : 0), 0);
1384
+ const maxReSpend = completedLanes.reduce((sum, entry) => sum + (Number.isFinite(entry.cost) ? entry.cost : 0), 0);
1385
+ return { completed: completedLanes.length, willReRun: willReRun.length, reSpend, reasons, editedBody, maxReSpend };
1386
+ }
1387
+
1388
+ function resumeReplayLine(preview) {
1389
+ if (preview.editedBody) {
1390
+ return `Resume replay: edited body — unchanged lanes replay from cache at no new spend; only edited/dependent lanes re-run (up to ${preview.completed} cached lanes, ~$${preview.maxReSpend.toFixed(4)} worst-case re-spend; replayed spend already counts toward the budget ceiling).`;
1391
+ }
1392
+ if (preview.willReRun > 0) {
1393
+ return `Resume replay: ${preview.willReRun} lanes will re-run, ~$${preview.reSpend.toFixed(4)} re-spend (changed ${preview.reasons.join(", ")} invalidates ${preview.willReRun} of ${preview.completed} cached lanes; replayed spend already counts toward the budget ceiling).`;
1394
+ }
1395
+ return `Resume replay: 0 lanes will re-run, ~$0 re-spend (${preview.completed} completed lanes replay from cache at no new spend).`;
1396
+ }
1397
+
1398
+ function assertResumeEnvelopeUnchanged(args, prior, requested, opts = {}) {
1399
+ if (!prior) return;
1400
+ if (Object.hasOwn(args, "maxAgents") && args.maxAgents !== prior.maxAgents) throw new Error(`resumeRunId cannot change maxAgents from ${prior.maxAgents} to ${args.maxAgents}`);
1401
+ // jbs3.4: the model envelope is pinned to the prior segment (defaultChildModel/modelTiers above).
1402
+ // Reject -- rather than silently ignore -- an operator who passes a DIFFERENT childModel/modelTiers
1403
+ // on resume: changing the model would invalidate every completed lane's cached signature and force
1404
+ // a full re-run/re-spend, so it must start a new workflow instead. Read the requested arg directly
1405
+ // (normalized the same way as the resolved envelope) so the comparison is apples-to-apples.
1406
+ if (Object.hasOwn(args, "childModel") && args.childModel != null && typeof prior.defaultChildModel === "string") {
1407
+ const requestedModel = modelKey(resolveRequestedModel(args.childModel, "default child"));
1408
+ if (requestedModel !== prior.defaultChildModel) throw new Error(`resumeRunId cannot change the child model from ${prior.defaultChildModel} to ${requestedModel} without starting a new workflow`);
1409
+ }
1410
+ if (Object.hasOwn(args, "modelTiers") && args.modelTiers && typeof args.modelTiers === "object" && !Array.isArray(args.modelTiers)) {
1411
+ for (const tier of ["fast", "deep"]) {
1412
+ if (!Object.hasOwn(args.modelTiers, tier) || args.modelTiers[tier] == null) continue;
1413
+ const requestedTier = modelKey(resolveRequestedModel(args.modelTiers[tier], `${tier} tier`));
1414
+ const priorTier = prior.modelTiers && typeof prior.modelTiers[tier] === "string" ? prior.modelTiers[tier] : prior.defaultChildModel;
1415
+ if (typeof priorTier === "string" && requestedTier !== priorTier) throw new Error(`resumeRunId cannot change the ${tier}-tier model from ${priorTier} to ${requestedTier} without starting a new workflow`);
1416
+ }
1417
+ }
1418
+ const requestedLaneTimeoutMs = laneTimeoutAliasValue(args, "workflow_run");
1419
+ if (requestedLaneTimeoutMs !== undefined && requestedLaneTimeoutMs !== (prior.laneTimeoutMs ?? DEFAULT_CHILD_PROMPT_TIMEOUT_MS)) throw new Error("resumeRunId cannot change laneTimeoutMs without starting a new workflow");
1420
+ const priorBudget = normalizeBudgetCeilings(prior.budgetCeilings);
1421
+ // jbs3.1: resuming a budget-stopped run may RAISE the cost/token ceiling (re-approved) so the
1422
+ // remaining/failed lanes get headroom; the raise (never a lower) is validated by
1423
+ // resolveResumeBudgetCeilings, so the equality pin is relaxed only on that path.
1424
+ if (!opts.allowBudgetRaise) {
1425
+ if (Object.hasOwn(args, "maxCost") && args.maxCost !== priorBudget.maxCost) throw new Error("resumeRunId cannot change maxCost without starting a new workflow");
1426
+ if (Object.hasOwn(args, "maxTokens") && args.maxTokens !== priorBudget.maxTokens) throw new Error("resumeRunId cannot change maxTokens without starting a new workflow");
1427
+ }
1428
+ if (Object.hasOwn(args, "authority") && !sameStableValue(args.authority, prior.authority)) throw new Error("resumeRunId cannot change authority without starting a new workflow");
1429
+ if (Object.hasOwn(args, "profile") && args.profile !== prior.authority?.profile) throw new Error("resumeRunId cannot change profile without starting a new workflow");
1430
+ if (Object.hasOwn(args, "background") && args.background !== (prior.background === true)) throw new Error("resumeRunId cannot change background mode without starting a new workflow");
1431
+ if (Object.hasOwn(args, "debugCapture") && args.debugCapture !== (prior.debugCapture?.enabled === true)) throw new Error("resumeRunId cannot change debugCapture without starting a new workflow");
1432
+ }
1433
+
1434
+ // jbs3.1: on a budget-stopped resume the operator may RAISE the cost/token ceiling so the
1435
+ // remaining/failed lanes have headroom; completed lanes still replay from cache at zero new
1436
+ // spend. A raise is the ONLY permitted change — lowering a ceiling below the prior value (which
1437
+ // historical replayed spend may already exceed) is rejected; omitting a ceiling keeps the prior.
1438
+ function resolveResumeBudgetCeilings(args, priorBudget) {
1439
+ const next = { maxCost: priorBudget.maxCost, maxTokens: priorBudget.maxTokens };
1440
+ if (Object.hasOwn(args, "maxCost") && Number.isFinite(args.maxCost)) {
1441
+ if (Number.isFinite(priorBudget.maxCost) && args.maxCost < priorBudget.maxCost) {
1442
+ throw new Error(`resumeRunId cannot lower maxCost on a budget-stopped resume (prior ${priorBudget.maxCost}, requested ${args.maxCost}); raise it or start a new workflow`);
1443
+ }
1444
+ next.maxCost = args.maxCost;
1445
+ }
1446
+ if (Object.hasOwn(args, "maxTokens") && Number.isInteger(args.maxTokens)) {
1447
+ if (Number.isInteger(priorBudget.maxTokens) && args.maxTokens < priorBudget.maxTokens) {
1448
+ throw new Error(`resumeRunId cannot lower maxTokens on a budget-stopped resume (prior ${priorBudget.maxTokens}, requested ${args.maxTokens}); raise it or start a new workflow`);
1449
+ }
1450
+ next.maxTokens = args.maxTokens;
1451
+ }
1452
+ return normalizeBudgetCeilings(next);
1453
+ }
1454
+
1455
+ // Best-effort read of the invoking session's active model so the default child
1456
+ // model can inherit it. Tries the live session first (when the SDK exposes a
1457
+ // model on the session), then the global config default. Never throws; returns
1458
+ // { model: null } when nothing is readable, in which case callers must supply an
1459
+ // explicit child model (there is no hard-coded model fallback).
1460
+ async function readActiveSessionModel(pluginContext, toolContext) {
1461
+ const client = pluginContext?.client;
1462
+ if (!client) return { model: null, source: "none" };
1463
+ try {
1464
+ if (toolContext?.sessionID && client.session?.get) {
1465
+ const got = await client.session.get({ path: { id: toolContext.sessionID } });
1466
+ const m = got?.data?.model;
1467
+ if (typeof m === "string" && m.includes("/")) return { model: m, source: "active" };
1468
+ }
1469
+ } catch { /* fall through to config default */ }
1470
+ try {
1471
+ const cfg = await client.config?.get?.();
1472
+ const m = cfg?.data?.model;
1473
+ if (typeof m === "string" && m.includes("/")) return { model: m, source: "config-default" };
1474
+ } catch { /* fall through */ }
1475
+ return { model: null, source: "none" };
1476
+ }
1477
+
1478
+ const WORKFLOW_PROVIDER_LIST_TTL_MS = VERIFIED_PROBE_TTL_MS;
1479
+ const workflowProviderListCache = new Map();
1480
+ const workflowProviderListClientKeys = new WeakMap();
1481
+ let workflowProviderListClientKeySeq = 0;
1482
+
1483
+ function workflowProviderListCacheKey(pluginContext) {
1484
+ if (pluginContext?.serverUrl !== undefined && pluginContext?.serverUrl !== null && pluginContext?.serverUrl !== false) {
1485
+ return `server:${String(pluginContext.serverUrl)}`;
1486
+ }
1487
+ const client = pluginContext?.client;
1488
+ if (client && (typeof client === "object" || typeof client === "function")) {
1489
+ let key = workflowProviderListClientKeys.get(client);
1490
+ if (!key) {
1491
+ workflowProviderListClientKeySeq += 1;
1492
+ key = `client:${workflowProviderListClientKeySeq}`;
1493
+ workflowProviderListClientKeys.set(client, key);
1494
+ }
1495
+ return key;
1496
+ }
1497
+ return "default";
1498
+ }
1499
+
1500
+ function emptyProviderListReadback() {
1501
+ return { providersRaw: [], providerDefault: {} };
1502
+ }
1503
+
1504
+ async function readWorkflowProviderList(pluginContext) {
1505
+ const client = pluginContext?.client;
1506
+ if (!client?.config?.providers) return emptyProviderListReadback();
1507
+
1508
+ const key = workflowProviderListCacheKey(pluginContext);
1509
+ const existing = workflowProviderListCache.get(key);
1510
+ const now = Date.now();
1511
+ if (existing) {
1512
+ if (now - existing.ts < WORKFLOW_PROVIDER_LIST_TTL_MS) return await existing.promise;
1513
+ workflowProviderListCache.delete(key);
1514
+ }
1515
+
1516
+ const entry = { ts: now, promise: undefined };
1517
+ entry.promise = (async () => {
1518
+ try {
1519
+ const res = await client.config.providers();
1520
+ return {
1521
+ providersRaw: Array.isArray(res?.data?.providers) ? res.data.providers : [],
1522
+ providerDefault: res?.data?.default ?? {},
1523
+ };
1524
+ } catch {
1525
+ if (workflowProviderListCache.get(key) === entry) workflowProviderListCache.delete(key);
1526
+ return emptyProviderListReadback();
1527
+ }
1528
+ })();
1529
+ workflowProviderListCache.set(key, entry);
1530
+ return await entry.promise;
1531
+ }
1532
+
1533
+ function invalidateWorkflowProviderListCache(pluginContextOrScope) {
1534
+ if (pluginContextOrScope === undefined || pluginContextOrScope === "all") {
1535
+ const count = workflowProviderListCache.size;
1536
+ workflowProviderListCache.clear();
1537
+ return count;
1538
+ }
1539
+ const key = typeof pluginContextOrScope === "string"
1540
+ ? pluginContextOrScope
1541
+ : workflowProviderListCacheKey(pluginContextOrScope);
1542
+ return workflowProviderListCache.delete(key) ? 1 : 0;
1543
+ }
1544
+
1545
+ function hardConcurrencyLimitForContext(pluginContext) {
1546
+ return normalizeHardConcurrencyLimit(pluginContext?.__workflowHardConcurrencyLimit, HARD_CONCURRENCY_LIMIT);
1547
+ }
1548
+
1549
+ // Read-only model discovery for the planning agent: the invoking session's model
1550
+ // plus every available/authenticated provider and its models, and a no-deviation
1551
+ // fast/deep suggestion (both default to the session model — stay in family).
1552
+ // Never throws; missing providers degrade to an empty list.
1553
+ async function buildWorkflowModels(pluginContext, toolContext) {
1554
+ const session = await readActiveSessionModel(pluginContext, toolContext);
1555
+ const { providersRaw, providerDefault } = await readWorkflowProviderList(pluginContext);
1556
+ const providers = providersRaw.map((p) => ({
1557
+ id: p.id,
1558
+ name: p.name,
1559
+ source: p.source,
1560
+ default: providerDefault[p.id] ?? null,
1561
+ models: Object.values(p.models ?? {}).map((m) => ({
1562
+ id: m.id,
1563
+ name: m.name,
1564
+ ...(m.variants ? { variants: Object.keys(m.variants) } : {}),
1565
+ })),
1566
+ }));
1567
+ const sessionModel = session.model;
1568
+ const slash = typeof sessionModel === "string" ? sessionModel.indexOf("/") : -1;
1569
+ const providerID = slash > 0 ? sessionModel.slice(0, slash) : null;
1570
+ const modelID = slash > 0 ? sessionModel.slice(slash + 1) : null;
1571
+ // Default suggestion = stay in the session family at the same model (no deviation).
1572
+ const suggested = { fast: sessionModel ?? null, deep: sessionModel ?? null };
1573
+ return {
1574
+ session: { model: sessionModel, providerID, modelID, family: providerID, source: session.source },
1575
+ providers,
1576
+ suggested,
1577
+ };
1578
+ }
1579
+
1580
+ // Pre-flight model validation (jbs3.5): cross-reference every resolved run model
1581
+ // (defaultChildModel + modelTiers.fast/deep) against the live provider/model list
1582
+ // at plan time and reject an unknown model BEFORE approval, any capability probe,
1583
+ // or any lane launch — with the available-models list in the message.
1584
+ //
1585
+ // Degrades gracefully for transient provider-list gaps: an empty/unreadable
1586
+ // provider list (buildWorkflowModels never throws and returns [] when the read
1587
+ // fails) skips validation rather than rejecting every model. Only a non-empty,
1588
+ // successfully-read list can reject, so a real list that genuinely omits the
1589
+ // requested model is the only way to fail closed.
1590
+ function assertModelsAvailable(providers, models) {
1591
+ if (!Array.isArray(providers) || providers.length === 0) return;
1592
+ const index = new Map();
1593
+ for (const p of providers) {
1594
+ index.set(p.id, new Set((p.models ?? []).map((m) => m.id)));
1595
+ }
1596
+ const available = providers
1597
+ .map((p) => `${p.id}: ${(p.models ?? []).map((m) => m.id).join(", ") || "(none)"}`)
1598
+ .join("; ");
1599
+ for (const { label, model } of models) {
1600
+ if (!model || typeof model !== "string") continue;
1601
+ const slash = model.indexOf("/");
1602
+ const providerID = slash > 0 ? model.slice(0, slash) : null;
1603
+ const modelID = slash > 0 ? model.slice(slash + 1) : null;
1604
+ const providerModels = providerID ? index.get(providerID) : undefined;
1605
+ if (!providerModels) {
1606
+ throw new Error(`Model "${model}" (${label}) not available: provider "${providerID ?? model}" is not in the available provider list. Available: ${available}`);
1607
+ }
1608
+ if (!providerModels.has(modelID)) {
1609
+ throw new Error(`Model "${model}" (${label}) not available from provider "${providerID}"; available: ${available}`);
1610
+ }
1611
+ }
1612
+ }
1613
+
1614
+ // jbs3.10: a workflow may declare meta.argsSchema (a JSON Schema). When present, the runtime args
1615
+ // payload (args.args) is validated against it at plan time — before the approval envelope is built
1616
+ // and before any lane launches — so a malformed payload fails loudly here instead of surfacing as a
1617
+ // confusing mid-run failure (or silently running with missing/extra fields). Reuses the existing
1618
+ // shared AJV instance (structured-output.js) rather than adding a second validator.
1619
+ function assertWorkflowArgsMatchSchema(meta, runtimeArgs) {
1620
+ const schema = meta?.argsSchema;
1621
+ if (schema === undefined || schema === null) return;
1622
+ if (typeof schema !== "object" || Array.isArray(schema)) {
1623
+ throw new Error("Workflow meta.argsSchema must be a JSON Schema object");
1624
+ }
1625
+ let validate;
1626
+ try {
1627
+ // A workflow is typically planned twice (preview, then approve) in one process; if the author
1628
+ // gave the schema an $id, reuse the already-compiled validator instead of recompiling (which
1629
+ // AJV rejects as a duplicate id) — but only when the schema CONTENT still matches what was
1630
+ // registered under that $id. A reused $id carrying a differently-shaped schema (edit-and-resume,
1631
+ // or an unrelated workflow picking the same id) must recompile, not silently validate against the
1632
+ // stale rules. compileSchemaWithIdentity enforces that content check; see structured-output.js.
1633
+ validate = compileSchemaWithIdentity(schema);
1634
+ } catch (error) {
1635
+ throw new Error(`Invalid workflow meta.argsSchema: ${extractTextFromError(error)}`);
1636
+ }
1637
+ if (validate(runtimeArgs ?? null)) return;
1638
+ throw new Error(
1639
+ `Workflow args do not match meta.argsSchema:\n${ajv.errorsText(validate.errors, { separator: "\n", dataVar: "args" })}`,
1640
+ );
1641
+ }
1642
+
1643
+ async function planWorkflowEnvelope(pluginContext, toolContext, args) {
1644
+ const resumeRunId = args.resumeRunId ? assertSafeRunId(args.resumeRunId, "resumeRunId") : undefined;
1645
+ // jbs3.3 edit-and-resume: the opt-in only makes sense when resuming AND supplying the edited body.
1646
+ // Reject up front (before any source read) so a misuse can never silently fall back to running the
1647
+ // unedited persisted script or starting a brand-new run.
1648
+ if (args.editAndResume === true) {
1649
+ if (!resumeRunId) throw new Error("editAndResume requires resumeRunId (it resumes a prior run with an edited body)");
1650
+ if (!hasExplicitWorkflowSource(args)) throw new Error("editAndResume requires the edited body via source/scriptPath/name");
1651
+ }
1652
+ const resumeEntry = resumeRunId ? await readResumeRunEntry(toolContext, resumeRunId) : undefined;
1653
+ if (resumeEntry) await assertResumableState(resumeEntry, resumeRunId, args);
1654
+ const priorState = resumeEntry ? await readJsonFile(path.join(resumeEntry.dir, "state.json"), null) : null;
1655
+ // Trusted extension workflow dirs (read uniformly from pluginContext — the single source of truth
1656
+ // under double-instantiation) merge into source resolution + scriptPath admission + nested snapshots.
1657
+ const extWfDirs = pluginContext.workflowExtensionRegistry?.assetDirs()?.workflows ?? [];
1658
+ const { source, sourcePath } = await resolveWorkflowSourceForStart(toolContext, args, resumeEntry, extWfDirs);
1659
+ const sourceHash = hash(source);
1660
+ const { meta, body } = parseWorkflowSource(source);
1661
+ const sourceMetadata = sourcePreviewMetadata(source, sourcePath, args);
1662
+ assertWorkflowArgsMatchSchema(meta, args.args);
1663
+ // Drain workflows accept a runtime-args laneTimeoutMs alias; arg-shape validation is handled by
1664
+ // the canonical drain normalization (authorityArgsForWorkflow) for harness==="drain" workflows.
1665
+ const isDrainHarness = meta.harness === "drain";
1666
+ const nestedSnapshots = await buildNestedSnapshots(toolContext, source, extWfDirs);
1667
+ const adapter = await createCapabilityAdapter(pluginContext);
1668
+ const maxAgents = resumeEntry && Number.isInteger(priorState?.maxAgents)
1669
+ ? priorState.maxAgents
1670
+ : Number.isInteger(args.maxAgents)
1671
+ ? args.maxAgents
1672
+ : Number.isInteger(meta.maxAgents)
1673
+ ? meta.maxAgents
1674
+ : DEFAULT_MAX_AGENTS;
1675
+ const sessionModel = await readActiveSessionModel(pluginContext, toolContext);
1676
+ // jbs3.4: pin the model envelope on resume exactly like maxAgents/authority/budget/concurrency.
1677
+ // A resumed run reuses the prior segment's resolved defaultChildModel/modelTiers so completed
1678
+ // lanes keep matching their cached signatures (event-journal.laneSignature keys on the resolved
1679
+ // model) even when the invoking session's model has since changed -- otherwise every completed
1680
+ // lane would silently invalidate and re-spend. A different childModel/modelTiers passed on resume
1681
+ // is rejected by assertResumeEnvelopeUnchanged below rather than being silently honored.
1682
+ const defaultChildModel = resumeEntry && typeof priorState?.defaultChildModel === "string"
1683
+ ? priorState.defaultChildModel
1684
+ : modelKey(resolveRequestedModel(args.childModel || meta.childModel || meta.defaultChildModel || sessionModel.model, "default child"));
1685
+ // No hard-coded model fallback (AGENTS.md: "Model IDs from config, never literals"). The default
1686
+ // child model is resolved, in order, from an explicit childModel arg, the workflow meta, then the
1687
+ // invoking session's model. When none is readable there is no model to launch lanes with, so fail
1688
+ // explicitly and prompt the caller for one rather than silently selecting a provider/model.
1689
+ if (!defaultChildModel) {
1690
+ throw new Error(
1691
+ "No child model could be resolved: the active session exposes no model and no childModel was supplied. " +
1692
+ "Pass childModel (\"provider/model\") to workflow_run, set meta.childModel/meta.defaultChildModel in the workflow, " +
1693
+ "or run from a session that has a configured model.",
1694
+ );
1695
+ }
1696
+ // A lane that declares tier: "fast"|"deep" resolves to modelTiers[tier]; both tiers always
1697
+ // populate (defaulting to defaultChildModel) so the value is deterministic and hash-stable,
1698
+ // and legacy lanes (no tier) stay on defaultChildModel via resolveLaneModel's fallback.
1699
+ const tierSource = (args.modelTiers && typeof args.modelTiers === "object" && !Array.isArray(args.modelTiers))
1700
+ ? args.modelTiers
1701
+ : (meta.modelTiers && typeof meta.modelTiers === "object" && !Array.isArray(meta.modelTiers) ? meta.modelTiers : {});
1702
+ const modelTiers = resumeEntry && priorState?.modelTiers && typeof priorState.modelTiers === "object" && !Array.isArray(priorState.modelTiers)
1703
+ ? { fast: priorState.modelTiers.fast ?? defaultChildModel, deep: priorState.modelTiers.deep ?? defaultChildModel }
1704
+ : {
1705
+ fast: modelKey(resolveRequestedModel(tierSource.fast || defaultChildModel, "fast tier")),
1706
+ deep: modelKey(resolveRequestedModel(tierSource.deep || defaultChildModel, "deep tier")),
1707
+ };
1708
+ // Pre-flight model validation against the live provider list (jbs3.5). Reuse
1709
+ // buildWorkflowModels' provider read and reject an unknown model here — before
1710
+ // the approval envelope is built, before capability probes, and before any lane
1711
+ // launches. Skip on resume: the resumed models are pinned by priorState and were
1712
+ // validated at original plan time, and re-checking against a possibly-changed
1713
+ // provider list could spuriously block a previously-approved run.
1714
+ if (!resumeEntry) {
1715
+ const { providers: availableProviders } = await buildWorkflowModels(pluginContext, toolContext);
1716
+ assertModelsAvailable(availableProviders, [
1717
+ { label: "default child model", model: defaultChildModel },
1718
+ { label: "fast tier", model: modelTiers.fast },
1719
+ { label: "deep tier", model: modelTiers.deep },
1720
+ ]);
1721
+ }
1722
+ // Canonicalize drain invocations (profile<->mode reconciliation) BEFORE authority/background/hash
1723
+ // so all of them — and the workflow body — see one consistent args object. No-op for non-drain.
1724
+ args = authorityArgsForWorkflow(meta, args);
1725
+ const authority = resumeEntry && priorState?.authority && typeof priorState.authority === "object" ? priorState.authority : resolveRunAuthority(meta, args);
1726
+ const argsPreview = jsonPreview(args.args ?? null);
1727
+ const requestedBudgetCeilings = {
1728
+ maxCost: Number.isFinite(args.maxCost) ? args.maxCost : Number.isFinite(meta.maxCost) ? meta.maxCost : undefined,
1729
+ maxTokens: Number.isInteger(args.maxTokens) ? args.maxTokens : Number.isInteger(meta.maxTokens) ? meta.maxTokens : undefined,
1730
+ };
1731
+ // jbs3.1: a budget-stopped resume may RAISE the ceiling (re-approved); any other resume pins
1732
+ // the prior ceiling exactly. A cold start uses the requested ceiling.
1733
+ const resumingBudgetStopped = resumeEntry && String(priorState?.status ?? "") === "budget_stopped";
1734
+ const priorBudgetCeilings = normalizeBudgetCeilings(priorState?.budgetCeilings);
1735
+ const budgetCeilings = !resumeEntry
1736
+ ? requestedBudgetCeilings
1737
+ : resumingBudgetStopped
1738
+ ? resolveResumeBudgetCeilings(args, priorBudgetCeilings)
1739
+ : priorBudgetCeilings;
1740
+ const hardConcurrencyLimit = hardConcurrencyLimitForContext(pluginContext);
1741
+ const concurrency = resumeEntry && Number.isInteger(priorState?.concurrency) ? priorState.concurrency : Math.max(1, Math.min(Number.isInteger(args.concurrency) ? args.concurrency : meta.concurrency || DEFAULT_CONCURRENCY, hardConcurrencyLimit));
1742
+ const workflowRunLaneTimeoutMs = laneTimeoutAliasValue(args, "workflow_run");
1743
+ const bundledRuntimeLaneTimeoutMs = isDrainHarness && args.args && typeof args.args === "object" && !Array.isArray(args.args) ? laneTimeoutAliasValue(args.args, "drain args") : undefined;
1744
+ if (workflowRunLaneTimeoutMs !== undefined && bundledRuntimeLaneTimeoutMs !== undefined && workflowRunLaneTimeoutMs !== bundledRuntimeLaneTimeoutMs) {
1745
+ throw new Error("workflow_run laneTimeoutMs and drain args laneTimeoutMs must match when both are provided");
1746
+ }
1747
+ const requestedLaneTimeoutMs = workflowRunLaneTimeoutMs ?? bundledRuntimeLaneTimeoutMs ?? laneTimeoutAliasValue(meta, "workflow meta");
1748
+ const laneTimeoutMs = resumeEntry && Number.isInteger(priorState?.laneTimeoutMs) ? priorState.laneTimeoutMs : requestedLaneTimeoutMs ?? DEFAULT_CHILD_PROMPT_TIMEOUT_MS;
1749
+ const guestDeadlineMs = resumeEntry && Number.isInteger(priorState?.guestDeadlineMs) ? priorState.guestDeadlineMs : Number.isInteger(args.guestDeadlineMs) ? args.guestDeadlineMs : Number.isInteger(meta.guestDeadlineMs) ? meta.guestDeadlineMs : DEFAULT_GUEST_DEADLINE_MS;
1750
+ // Optional run-level wall-clock deadline (jbs3.6). Distinct from the per-lane laneTimeoutMs
1751
+ // and the synchronous guest-burst guestDeadlineMs: when set, runWorkflowExecution hard-stops
1752
+ // the whole run (terminal status "timed-out" + partial result) after this many ms regardless
1753
+ // of where work is wedged. undefined => no run-level deadline.
1754
+ const resumingTimedOutWithExtendedDeadline = resumeEntry && priorState?.status === "timed-out" && args.resumePolicy === "extend-deadline";
1755
+ const maxRuntimeMs = resumeEntry
1756
+ ? resumingTimedOutWithExtendedDeadline && Number.isInteger(args.maxRuntimeMs)
1757
+ ? args.maxRuntimeMs
1758
+ : Number.isInteger(priorState?.maxRuntimeMs)
1759
+ ? priorState.maxRuntimeMs
1760
+ : undefined
1761
+ : Number.isInteger(args.maxRuntimeMs)
1762
+ ? args.maxRuntimeMs
1763
+ : Number.isInteger(meta.maxRuntimeMs)
1764
+ ? meta.maxRuntimeMs
1765
+ : undefined;
1766
+ const baseCommit = authority.edit || authority.worktreeEdit || authority.integration ? await gitHead(toolContext.worktree || toolContext.directory) : undefined;
1767
+ const backgroundSizingSignals = {
1768
+ // Workflow meta.maxAgents is a ceiling, and many bundled review workflows intentionally
1769
+ // over-provision it. Treat only per-call maxAgents as an expected fan-out signal.
1770
+ maxAgentsSignal: Number.isInteger(args.maxAgents),
1771
+ maxRuntimeSignal: Number.isInteger(args.maxRuntimeMs) || Number.isInteger(meta.maxRuntimeMs),
1772
+ };
1773
+ const backgroundDecision = workflowBackgroundDecision(meta, sourcePath, args, priorState, { maxAgents, concurrency, maxRuntimeMs, ...backgroundSizingSignals });
1774
+ const background = backgroundDecision.enabled;
1775
+ const notificationDelivery = { promptAsyncAvailable: sessionApi(pluginContext).has("promptAsync") };
1776
+ const requestedDebugCapture = args.debugCapture === true || truthyEnvFlag(process.env[OPENCODE_WORKFLOWS_DEBUG_CAPTURE_ENV]);
1777
+ const debugCapture = resumeEntry
1778
+ ? priorState?.debugCapture?.enabled === true
1779
+ : requestedDebugCapture;
1780
+ const debugCaptureSource = debugCapture
1781
+ ? (args.debugCapture === true ? "workflow_run" : truthyEnvFlag(process.env[OPENCODE_WORKFLOWS_DEBUG_CAPTURE_ENV]) ? OPENCODE_WORKFLOWS_DEBUG_CAPTURE_ENV : "resume")
1782
+ : "off";
1783
+ assertResumeEnvelopeUnchanged(args, priorState, { defaultChildModel, authority, budgetCeilings, maxAgents }, { allowBudgetRaise: resumingBudgetStopped });
1784
+ const resumePreview = await computeResumeReplayPreview(resumeEntry, priorState, { sourceHash, runtimeArgs: args.args ?? null, defaultChildModel, modelTiers, editAndResume: args.editAndResume === true });
1785
+ const externalSource = sourcePath !== "<inline>" && !isTrustedWorkflowPath(sourcePath, toolContext, extWfDirs);
1786
+ const autoApprove = workflowAutoApprovePreview(pluginContext, args, authority);
1787
+
1788
+ return {
1789
+ resumeRunId,
1790
+ resumeEntry,
1791
+ priorState,
1792
+ source,
1793
+ body,
1794
+ adapter,
1795
+ meta,
1796
+ approval: {
1797
+ meta,
1798
+ sourcePath,
1799
+ sourceHash,
1800
+ sourceMetadata,
1801
+ externalSource,
1802
+ runtimeArgs: args.args ?? null,
1803
+ maxAgents,
1804
+ concurrency,
1805
+ laneTimeoutMs,
1806
+ defaultChildModel,
1807
+ modelTiers,
1808
+ authority,
1809
+ argsPreview,
1810
+ budgetCeilings,
1811
+ baseCommit,
1812
+ guestDeadlineMs,
1813
+ maxRuntimeMs,
1814
+ debugCapture,
1815
+ debugCaptureSource,
1816
+ ...backgroundSizingSignals,
1817
+ background,
1818
+ backgroundDecision,
1819
+ notificationDelivery,
1820
+ resumeRunId: args.resumeRunId,
1821
+ resumePolicy: args.resumePolicy ?? null,
1822
+ resumePreview,
1823
+ capabilities: adapter.capabilities,
1824
+ nestedSnapshots,
1825
+ autoApprove,
1826
+ },
1827
+ };
1828
+ }
1829
+
1830
+ async function startWorkflow(pluginContext, toolContext, args) {
1831
+ assertWriteWorkflowAllowed(toolContext, "workflow_run");
1832
+ const { resumeRunId, resumeEntry, priorState, source, body, adapter, meta, approval } = await planWorkflowEnvelope(pluginContext, toolContext, args);
1833
+ const autoApproved = args.approve === true ? null : workflowAutoApproval(pluginContext, args, approval);
1834
+ if (args.approve !== true && !autoApproved) return approvalPreviewResponse(approval, args);
1835
+ if (args.approve === true && args.approvalHash !== approvalHash(approval)) return approvalMismatchResponse(approval, args);
1836
+ const {
1837
+ sourcePath,
1838
+ sourceHash,
1839
+ sourceMetadata,
1840
+ nestedSnapshots,
1841
+ argsPreview,
1842
+ maxAgents,
1843
+ concurrency,
1844
+ laneTimeoutMs,
1845
+ defaultChildModel,
1846
+ modelTiers,
1847
+ authority,
1848
+ budgetCeilings,
1849
+ baseCommit,
1850
+ guestDeadlineMs,
1851
+ maxRuntimeMs,
1852
+ background,
1853
+ backgroundDecision,
1854
+ notificationDelivery,
1855
+ debugCapture,
1856
+ debugCaptureSource,
1857
+ } = approval;
1858
+ // Verify shape-derived capabilities with live probes before the run starts (after the
1859
+ // approval check, so the approval envelope is hashed over pre-probe state and a preview
1860
+ // never probes). This settles run.capabilities before any lane executes.
1861
+ await promoteCapabilities(pluginContext, toolContext, adapter, authority, { childLanesAllowed: maxAgents > 0 });
1862
+ // A non-dry drain manages its own integration-worktree isolation internally (the drain runtime),
1863
+ // so it delegates those capability checks rather than requiring them pre-launch here.
1864
+ const drainDelegatesIntegrationGates = meta.harness === "drain" && args.args?.dryRun !== true;
1865
+ const requiredGateStatus = await verifyRequiredAuthorityGates(pluginContext, toolContext, adapter, authority);
1866
+ // Keep the historical network/MCP verifier hook for diagnostics compatibility.
1867
+ // webfetch/websearch/mcp authority is enforced by permission rules plus the
1868
+ // permissionEnforcement gate; networkAccess remains informational/reserved,
1869
+ // while mcpAccess can be probed explicitly through workflow_live_gates.
1870
+ await verifyNetworkMcpAuthorityGates(pluginContext, toolContext, authority);
1871
+ promoteVerifiedGateCapabilities(adapter, requiredGateStatus);
1872
+ if (authority.profile === AD_HOC_AUTHORITY_PROFILE) {
1873
+ const needsPermissions = authority.shell || authority.network || authority.mcp || authority.edit || authority.worktreeEdit || authority.integration || maxAgents > 0;
1874
+ const needsWorktreeIsolation = authority.edit || authority.worktreeEdit || authority.integration;
1875
+ function requireCapability(name, reason) {
1876
+ if (adapter.capabilities[name] !== "available") {
1877
+ throw new Error(`${reason}; ${name}=${adapter.capabilities[name]} (available-unverified is not behavioral proof)`);
1878
+ }
1879
+ }
1880
+ if (needsPermissions) requireCapability("permissions", "Child-lane or elevated workflow authority requires verified permission enforcement");
1881
+ if (needsWorktreeIsolation && !drainDelegatesIntegrationGates) {
1882
+ requireCapability("worktree", "Edit/worktreeEdit/integration workflows require verified worktree API behavior");
1883
+ requireCapability("directoryRooting", "Edit/worktreeEdit/integration workflows require verified child directory rooting");
1884
+ requireCapability("worktreeEditIsolation", "Edit/worktreeEdit/integration workflows require verified worktree edit isolation");
1885
+ }
1886
+ }
1887
+
1888
+ const root = resumeEntry?.root ?? await ensureRunRoot(toolContext);
1889
+ const runId = resumeRunId ?? crypto.randomUUID();
1890
+ const dir = runDirForRoot(root, runId);
1891
+ if (runs.has(runId)) throw new Error(`Workflow run is already active: ${runId}`);
1892
+ await ensurePrivateDir(dir);
1893
+ let releaseRunLock = await acquireWorkflowLock(lockPathForRun(dir, "run"), { operation: "run", runId });
1894
+ let run;
1895
+ try {
1896
+ if (resumeRunId) {
1897
+ await fs.rm(lifecycleRequestPath(dir, "pause"), { force: true });
1898
+ // A force-killed run is resumable; its durable kill-request.json must be cleared on resume
1899
+ // or checkDurableLifecycleRequest would immediately re-abandon the freshly resumed run.
1900
+ await fs.rm(lifecycleRequestPath(dir, "kill"), { force: true });
1901
+ }
1902
+ await writeFilePrivate(path.join(dir, "script.js"), source, "utf8");
1903
+ const resumeJournal = args.resumeRunId ? await loadJournal(dir) : new Map();
1904
+ if (args.resumeRunId) await compactJournal(dir, resumeJournal);
1905
+ const resumeSignatureIndex = buildResumeSignatureIndex(resumeJournal);
1906
+
1907
+ run = {
1908
+ id: runId,
1909
+ dir,
1910
+ projectDirectory: toolContext.directory,
1911
+ projectWorktree: toolContext.worktree,
1912
+ sourcePath,
1913
+ sourceHash,
1914
+ sourceMetadata,
1915
+ meta,
1916
+ authority,
1917
+ runtimeArgs: args.args ?? null,
1918
+ nestedSnapshots,
1919
+ argsPreview,
1920
+ status: "running",
1921
+ startedAt: new Date().toISOString(),
1922
+ resumedAt: undefined,
1923
+ finishedAt: undefined,
1924
+ currentPhase: undefined,
1925
+ agentsStarted: 0,
1926
+ maxAgents,
1927
+ concurrency,
1928
+ laneTimeoutMs,
1929
+ defaultChildModel,
1930
+ modelTiers,
1931
+ activeAgents: 0,
1932
+ waitingAgents: [],
1933
+ tokens: { input: 0, output: 0, reasoning: 0 },
1934
+ replayedTokens: { input: 0, output: 0, reasoning: 0 },
1935
+ cost: 0,
1936
+ replayedCost: 0,
1937
+ cacheStats: { hits: 0, misses: 0, invalidated: 0 },
1938
+ budgetCeilings,
1939
+ laneOutcomes: Object.fromEntries(LANE_OUTCOMES.map((outcome) => [outcome, 0])),
1940
+ droppedLaneCount: 0,
1941
+ error: undefined,
1942
+ resultPath: undefined,
1943
+ closeout: undefined,
1944
+ lifecycleRequests: undefined,
1945
+ notification: undefined,
1946
+ notificationTarget: background ? {
1947
+ sessionID: toolContext.sessionID,
1948
+ messageID: toolContext.messageID,
1949
+ directory: toolContext.directory,
1950
+ agent: toolContext.agent,
1951
+ } : undefined,
1952
+ recovery: undefined,
1953
+ capabilities: adapter.capabilities,
1954
+ diagnostics: adapter.diagnostics,
1955
+ adapter,
1956
+ editWorktrees: [],
1957
+ editPlan: authority.edit || authority.worktreeEdit ? { sourceHash, baseCommit, patches: [], worktrees: [] } : undefined,
1958
+ integrationPlan: authority.integration ? { sourceHash, baseCommit, lanes: [], worktrees: [], integrationResult: undefined } : undefined,
1959
+ integrationWorktrees: [],
1960
+ worktreeAdapter: undefined,
1961
+ laneRecords: new Map(),
1962
+ children: new Map(),
1963
+ activeLaneAbortControllers: new Map(),
1964
+ cancelledFanoutScopes: new Set(),
1965
+ resumeJournal,
1966
+ resumeSignatureIndex,
1967
+ resumeSignatureClaims: new Set(),
1968
+ abortController: new AbortController(),
1969
+ pauseRequested: false,
1970
+ background,
1971
+ backgroundDecision,
1972
+ notificationDelivery,
1973
+ debugCapture: { enabled: debugCapture === true, source: debugCaptureSource },
1974
+ autoApproved,
1975
+ recentLogs: [],
1976
+ ignoreToolAbort: background,
1977
+ hostCalls: 0,
1978
+ eventCount: 0,
1979
+ journalRecords: 0,
1980
+ nestingDepth: 0,
1981
+ guestDeadlineMs,
1982
+ maxRuntimeMs,
1983
+ deadlineExceeded: false,
1984
+ killed: false,
1985
+ releaseRunLock,
1986
+ };
1987
+ if (run.notificationTarget?.sessionID) idleNotificationSessions.delete(run.notificationTarget.sessionID);
1988
+ if (args.resumeRunId) {
1989
+ // Rehydrate run-level state persisted by the prior segment so a resumed run does not
1990
+ // reset observability/limit counters, worktree ledgers, or the original start time.
1991
+ rehydrateRunFromPriorState(run, priorState);
1992
+ // jbs3.1: rehydrate restores the prior (possibly breached) ceiling from state.json; re-apply
1993
+ // the approved envelope ceiling so a budget-stopped resume's RAISED ceiling takes effect.
1994
+ // For every non-raise resume this is the same value rehydrate just set (a safe no-op).
1995
+ run.budgetCeilings = budgetCeilings;
1996
+ run.resumedAt = new Date().toISOString();
1997
+ // Seed the in-memory caps from the true on-disk totals so the MAX_* limits enforce
1998
+ // against the whole append-only file, not just this session's appends.
1999
+ run.journalRecords = await countNonEmptyLines(path.join(dir, "journal.jsonl"));
2000
+ run.eventCount = await countNonEmptyLines(path.join(dir, "events.jsonl"));
2001
+ }
2002
+ runs.set(run.id, run);
2003
+ run.eventSink = createWorkflowToastEventSink(pluginContext, run);
2004
+ if (run.autoApproved) {
2005
+ await appendEvent(run, { type: "run.auto_approved", tier: run.autoApproved.tier, ceiling: run.autoApproved.ceiling });
2006
+ }
2007
+ await appendEvent(run, { type: "run.started", capabilities: run.capabilities, diagnostics: run.diagnostics });
2008
+ await writeState(run);
2009
+
2010
+ if (run.background) {
2011
+ run.done = runWorkflowExecution(pluginContext, toolContext, run, body, args.args).catch((_error) => {
2012
+ // Background run error after state-write attempt: best effort.
2013
+ });
2014
+ void run.done;
2015
+ const notificationWarning = backgroundNotificationWarning(run, run.id);
2016
+ return [
2017
+ `Workflow ${run.id} started in background.`,
2018
+ `Status: workflow_status({ runId: "${run.id}" })`,
2019
+ ...(notificationWarning ? [notificationWarning] : []),
2020
+ "Background runs continue only while the current OpenCode process stays alive.",
2021
+ ].join("\n");
2022
+ }
2023
+
2024
+ return await runWorkflowExecution(pluginContext, toolContext, run, body, args.args);
2025
+ } catch (error) {
2026
+ if (!run || run.status === "running") {
2027
+ // A throw after runs.set (e.g. appendEvent/writeState failing) leaves a phantom
2028
+ // 'running' entry that would block resume-id retry for the process lifetime. Drop it,
2029
+ // but only if this invocation still owns the slot (guard against a concurrent owner).
2030
+ if (run && runs.get(run.id) === run) runs.delete(run.id);
2031
+ await releaseRunLock?.();
2032
+ }
2033
+ throw error;
2034
+ }
2035
+ }
2036
+
2037
+ async function validatePatchAncestors(root, patchPath) {
2038
+ const rootReal = await fs.realpath(root);
2039
+ const parts = patchPath.split(/[\\/]+/).filter(Boolean);
2040
+ let current = root;
2041
+ for (const part of parts.slice(0, -1)) {
2042
+ current = path.join(current, part);
2043
+ let stat;
2044
+ try {
2045
+ stat = await fs.lstat(current);
2046
+ } catch (error) {
2047
+ if (error.code === "ENOENT") return;
2048
+ throw error;
2049
+ }
2050
+ if (stat.isSymbolicLink()) throw new Error(`Patch ancestor is a symlink: ${patchPath}`);
2051
+ if (!stat.isDirectory()) throw new Error(`Patch ancestor is not a directory: ${patchPath}`);
2052
+ const real = await fs.realpath(current);
2053
+ if (!isPathInside(rootReal, real)) throw new Error(`Patch ancestor escapes primary root: ${patchPath}`);
2054
+ }
2055
+ }
2056
+
2057
+ async function validatePatchTargets(root, patches, options = {}) {
2058
+ const requireTracked = options.requireTracked !== false;
2059
+ const seen = new Set();
2060
+ const planned = [];
2061
+ for (const patch of patches) {
2062
+ if (!patch || typeof patch !== "object") throw new Error("Invalid edit patch entry");
2063
+ if (typeof patch.path !== "string" || typeof patch.content !== "string") throw new Error("Edit patches must include string path and content");
2064
+ if (seen.has(patch.path)) {
2065
+ const overlappingError = new Error(`Overlapping edit patch path: ${patch.path}`);
2066
+ overlappingError.code = "OVERLAPPING_PATCH";
2067
+ throw overlappingError;
2068
+ }
2069
+ seen.add(patch.path);
2070
+ if (path.isAbsolute(patch.path) || patch.path.split(/[\\/]+/).includes("..")) throw new Error(`Patch escapes primary root: ${patch.path}`);
2071
+ assertWritableWorkflowPath(patch.path);
2072
+ const target = path.resolve(root, patch.path);
2073
+ if (!target.startsWith(`${root}${path.sep}`) && target !== root) throw new Error(`Patch escapes primary root: ${patch.path}`);
2074
+ await validatePatchAncestors(root, patch.path);
2075
+ let existed = false;
2076
+ let previousContent;
2077
+ try {
2078
+ const stat = await fs.lstat(target);
2079
+ if (stat.isSymbolicLink()) throw new Error(`Patch target is a symlink: ${patch.path}`);
2080
+ if (stat.isDirectory()) throw new Error(`Patch target is a directory: ${patch.path}`);
2081
+ previousContent = await fs.readFile(target, "utf8");
2082
+ existed = true;
2083
+ if (requireTracked) {
2084
+ const tracked = await gitPathTracked(root, patch.path);
2085
+ if (!tracked) throw new Error(`Patch target exists but is untracked: ${patch.path}`);
2086
+ }
2087
+ } catch (error) {
2088
+ if (error.code !== "ENOENT") throw error;
2089
+ }
2090
+ planned.push({ patch, target, existed, previousContent });
2091
+ }
2092
+ return planned;
2093
+ }
2094
+
2095
+ function verifyAppliedPatchContents(planned) {
2096
+ for (const item of planned) {
2097
+ if (!item.existed || item.previousContent !== item.patch.content) {
2098
+ throw new Error(`Applied patch content no longer matches approved diff plan: ${item.patch.path}`);
2099
+ }
2100
+ }
2101
+ }
2102
+
2103
+ async function rollbackPatches(planned, root) {
2104
+ const failures = [];
2105
+ for (const item of [...planned].reverse()) {
2106
+ try {
2107
+ if (item.existed) {
2108
+ // Restore prior content TOCTOU-safely (R18): when a root is supplied, the
2109
+ // O_NOFOLLOW write refuses to follow a symlink swapped in for the target so
2110
+ // rollback cannot be redirected outside root either.
2111
+ if (root) await safeWriteFileWithinRoot(root, item.patch.path, item.previousContent);
2112
+ else await fs.writeFile(item.target, item.previousContent, "utf8");
2113
+ } else {
2114
+ await fs.rm(item.target, { force: true });
2115
+ }
2116
+ } catch (error) {
2117
+ failures.push({
2118
+ path: item.patch?.path,
2119
+ error: truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS),
2120
+ });
2121
+ }
2122
+ }
2123
+ return failures;
2124
+ }
2125
+
2126
+ // Read a raw, unredacted child transcript via the SDK session.messages API. Salvage must
2127
+ // read transcripts directly (not through the redacting session_read wrapper some setups may
2128
+ // layer on top of session history, e.g. a separate opencode-sessions plugin — not a dependency
2129
+ // of this package, named only as a point of comparison):
2130
+ // the journal already stores unredacted lane results, and salvage recovers that same class of
2131
+ // content. Returns { readable, messages, reason }.
2132
+ async function readRawTranscript(pluginContext, childID) {
2133
+ const session = sessionApi(pluginContext);
2134
+ if (!session.has("messages")) return { readable: false, messages: null, reason: "session.messages unavailable" };
2135
+ try {
2136
+ const result = await session.messages({ sessionID: childID });
2137
+ const unwrapped = unwrapClientResult(result, `Salvage transcript read for ${childID}`);
2138
+ return { readable: true, messages: unwrapped, reason: undefined };
2139
+ } catch (error) {
2140
+ return { readable: false, messages: null, reason: extractTextFromError(error) };
2141
+ }
2142
+ }
2143
+
2144
+ function extractMessagesArray(value) {
2145
+ if (!value) return [];
2146
+ if (Array.isArray(value)) return value;
2147
+ if (Array.isArray(value.data)) return value.data;
2148
+ if (value.data && Array.isArray(value.data.messages)) return value.data.messages;
2149
+ if (value.data && Array.isArray(value.data.data)) return value.data.data;
2150
+ if (Array.isArray(value.messages)) return value.messages;
2151
+ return [];
2152
+ }
2153
+
2154
+ function isAssistantMessage(message) {
2155
+ if (!message || typeof message !== "object") return false;
2156
+ const role = String(message.role ?? message.type ?? "").toLowerCase();
2157
+ return role === "assistant";
2158
+ }
2159
+
2160
+ // Find the final assistant message in a transcript and return its concatenated text. Structured
2161
+ // (schema) lanes normally surface their JSON as the model's final text reply, so this is the
2162
+ // best-effort recovery target for salvage.
2163
+ function extractFinalAssistantText(messagesValue) {
2164
+ const messages = extractMessagesArray(messagesValue);
2165
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
2166
+ const message = messages[i];
2167
+ if (isAssistantMessage(message)) {
2168
+ const parts = [];
2169
+ collectTextParts(message, parts);
2170
+ const text = parts.join("\n").trim();
2171
+ if (text) return { found: true, text };
2172
+ }
2173
+ }
2174
+ return { found: false, text: "" };
2175
+ }
2176
+
2177
+ function tryParseJson(text) {
2178
+ try {
2179
+ return { ok: true, value: JSON.parse(text) };
2180
+ } catch (error) {
2181
+ return { ok: false, error: extractTextFromError(error) };
2182
+ }
2183
+ }
2184
+
2185
+ // The approval hash covers the recomputed preview facts (per-candidate callId/childID, skip
2186
+ // reason, final-message presence/length, final-message hash, parse verdict, signature
2187
+ // availability). Re-reading transcripts and re-verifying this hash on approve guarantees the
2188
+ // transcript state has not drifted between preview and approve.
2189
+ function normalizeSalvageSchemaSnapshot(requestCheckpoint) {
2190
+ const snapshot = requestCheckpoint?.schemaSnapshot;
2191
+ if (!snapshot || typeof snapshot !== "object") {
2192
+ return {
2193
+ status: requestCheckpoint?.schemaHash ? "unavailable" : "absent",
2194
+ hash: requestCheckpoint?.schemaHash,
2195
+ schema: undefined,
2196
+ };
2197
+ }
2198
+ const status = ["present", "absent", "oversized"].includes(snapshot.status) ? snapshot.status : "unavailable";
2199
+ return {
2200
+ status,
2201
+ hash: typeof snapshot.hash === "string" ? snapshot.hash : requestCheckpoint?.schemaHash,
2202
+ bytes: Number.isInteger(snapshot.bytes) ? snapshot.bytes : undefined,
2203
+ schema: status === "present" && snapshot.schema && typeof snapshot.schema === "object" ? snapshot.schema : undefined,
2204
+ };
2205
+ }
2206
+
2207
+ function salvageValidationForParsedValue(parsed, schemaSnapshot) {
2208
+ const base = {
2209
+ kind: schemaSnapshot.schema ? "json-schema" : "json-parse",
2210
+ originalSchemaAvailable: Boolean(schemaSnapshot.schema),
2211
+ schemaSnapshotStatus: schemaSnapshot.status,
2212
+ schemaHash: schemaSnapshot.hash,
2213
+ };
2214
+ if (!parsed.ok) {
2215
+ return { ...base, schemaVerdict: "not-checked", recoveredResultValid: false, validationError: parsed.error };
2216
+ }
2217
+ if (!schemaSnapshot.schema) {
2218
+ return { ...base, schemaVerdict: "not-checked", recoveredResultValid: true };
2219
+ }
2220
+ try {
2221
+ validateStructuredResult(schemaSnapshot.schema, parsed.value);
2222
+ return { ...base, schemaVerdict: "valid", recoveredResultValid: true };
2223
+ } catch (error) {
2224
+ return {
2225
+ ...base,
2226
+ schemaVerdict: "invalid",
2227
+ recoveredResultValid: false,
2228
+ validationError: truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS),
2229
+ };
2230
+ }
2231
+ }
2232
+
2233
+ function salvageApprovalPayload(enriched) {
2234
+ return enriched.map((entry) => ({
2235
+ callId: entry.callId,
2236
+ childID: entry.childID,
2237
+ skipped: entry.skipped ?? null,
2238
+ finalMessageFound: entry.finalMessageFound,
2239
+ finalMessageLength: entry.finalMessageLength,
2240
+ parseVerdict: entry.parseVerdict,
2241
+ finalMessageHash: entry.finalMessageHash,
2242
+ resumeSignatureAvailable: entry.resumeSignatureAvailable,
2243
+ validationKind: entry.salvageValidation?.kind,
2244
+ originalSchemaAvailable: entry.salvageValidation?.originalSchemaAvailable,
2245
+ schemaSnapshotStatus: entry.salvageValidation?.schemaSnapshotStatus,
2246
+ schemaHash: entry.salvageValidation?.schemaHash,
2247
+ schemaVerdict: entry.salvageValidation?.schemaVerdict,
2248
+ validationErrorHash: entry.salvageValidation?.validationError ? hash(entry.salvageValidation.validationError) : undefined,
2249
+ }));
2250
+ }
2251
+
2252
+ function computeSalvageApprovalHash(runId, payload) {
2253
+ return hashStable({ scope: "workflow_salvage.v1", runId, candidates: payload });
2254
+ }
2255
+
2256
+ // Build the enriched per-candidate preview by reading each orphan's lane projection + raw
2257
+ // transcript. Edit/integration lanes are reported but never salvaged (no worktree commit
2258
+ // exists; integrate() requires lane.committed). Read-only lanes are JSON-structurally
2259
+ // validated against their final assistant message.
2260
+ async function computeSalvagePreview(pluginContext, runDir, state, runId) {
2261
+ const candidates = await computeSalvageCandidates(runDir, state);
2262
+ const lanes = await readLaneProjections(runDir, state);
2263
+ const laneByCallId = new Map(lanes.map((lane) => [lane.callId, lane]).filter(([, lane]) => lane));
2264
+ const enriched = [];
2265
+ for (const candidate of candidates) {
2266
+ const lane = laneByCallId.get(candidate.callId) ?? {};
2267
+ const resumeSignatureAvailable = Boolean(lane.signatureHash);
2268
+ const base = {
2269
+ callId: candidate.callId,
2270
+ childID: candidate.childID,
2271
+ title: lane.title,
2272
+ taskSummary: lane.taskSummary,
2273
+ model: lane.model,
2274
+ agent: lane.agent,
2275
+ role: lane.role,
2276
+ timeoutMs: lane.timeoutMs,
2277
+ permissionPolicy: lane.permissionPolicy,
2278
+ signatureHash: lane.signatureHash,
2279
+ startedAt: lane.startedAt,
2280
+ };
2281
+ // Edit/integration lanes carry a worktree (and integration lanes carry an integrationLane
2282
+ // record). Salvage is read-only-only: there is no worktree commit to recover and integrate()
2283
+ // already requires lane.committed, so report-and-skip rather than ever salvaging.
2284
+ if (lane.worktreePath || lane.integrationLane) {
2285
+ enriched.push({
2286
+ ...base,
2287
+ skipped: "edit-lane-without-commit",
2288
+ finalMessageFound: false,
2289
+ finalMessageLength: 0,
2290
+ parseVerdict: "skipped",
2291
+ finalMessageHash: null,
2292
+ resumeSignatureAvailable,
2293
+ });
2294
+ continue;
2295
+ }
2296
+ const transcript = await readRawTranscript(pluginContext, candidate.childID);
2297
+ if (!transcript.readable) {
2298
+ enriched.push({
2299
+ ...base,
2300
+ skipped: `transcript-unreadable:${truncateText(transcript.reason, 120)}`,
2301
+ finalMessageFound: false,
2302
+ finalMessageLength: 0,
2303
+ parseVerdict: "unreadable",
2304
+ finalMessageHash: null,
2305
+ resumeSignatureAvailable,
2306
+ });
2307
+ continue;
2308
+ }
2309
+ const final = extractFinalAssistantText(transcript.messages);
2310
+ const parsed = tryParseJson(final.text);
2311
+ const requestCheckpoint = await readLaneRequestCheckpoint(runDir, candidate.callId);
2312
+ const schemaSnapshot = normalizeSalvageSchemaSnapshot(requestCheckpoint);
2313
+ const salvageValidation = salvageValidationForParsedValue(parsed, schemaSnapshot);
2314
+ enriched.push({
2315
+ ...base,
2316
+ skipped: undefined,
2317
+ finalMessageFound: final.found,
2318
+ finalMessageLength: final.text.length,
2319
+ finalText: final.text,
2320
+ parsedValue: parsed.ok ? parsed.value : undefined,
2321
+ parseVerdict: parsed.ok ? "valid" : "invalid",
2322
+ parseError: parsed.ok ? undefined : parsed.error,
2323
+ salvageValidation,
2324
+ finalMessageHash: hash(final.text),
2325
+ resumeSignatureAvailable,
2326
+ });
2327
+ }
2328
+ return enriched;
2329
+ }
2330
+
2331
+ // Explicit, hash-gated recovery of orphaned read-only lane results from persisted child
2332
+ // transcripts. Gated like workflow_reconcile (assertWriteWorkflowAllowed). Preview lists
2333
+ // recoverable lanes without writing; approve (matching hash) writes synthetic journal entries
2334
+ // tagged salvagedFromTranscript: true. Salvage never calls integrate()/runAutoApply and never
2335
+ // touches state.json, worktrees, or integration ledgers.
2336
+ async function salvageRun(pluginContext, context, args) {
2337
+ assertWriteWorkflowAllowed(context, "workflow_salvage");
2338
+ if (typeof args.runId !== "string" || !args.runId.trim()) throw new Error("workflow_salvage requires runId");
2339
+ const runId = args.runId.trim();
2340
+ const entry = await readRunById(context, runId);
2341
+ if (entry.kind !== "valid") throw new Error(`Cannot salvage invalid run ${runId}: ${entry.status}`);
2342
+ const runDir = entry.dir;
2343
+ const state = entry.state;
2344
+ const runLock = await readLock(lockPathForRun(runDir, "run"));
2345
+ if (runLock?.active) {
2346
+ throw new Error(
2347
+ `Workflow ${runId} still holds an active run lock (pid ${runLock.process?.pid}); ` +
2348
+ "wait for the owning run to settle (or run workflow_reconcile) before retrying workflow_salvage."
2349
+ );
2350
+ }
2351
+
2352
+ const enriched = await computeSalvagePreview(pluginContext, runDir, state, runId);
2353
+ const computedHash = computeSalvageApprovalHash(runId, salvageApprovalPayload(enriched));
2354
+
2355
+ const selectedCallIds = Array.isArray(args.callIds) && args.callIds.length > 0
2356
+ ? new Set(args.callIds.filter((callId) => typeof callId === "string"))
2357
+ : null;
2358
+
2359
+ if (args.approve !== true || args.approvalHash !== computedHash) {
2360
+ const candidates = enriched.map((entry) => {
2361
+ const view = {
2362
+ callId: entry.callId,
2363
+ childID: entry.childID,
2364
+ parseVerdict: entry.parseVerdict,
2365
+ validationKind: entry.salvageValidation?.kind ?? "not-checked",
2366
+ originalSchemaAvailable: entry.salvageValidation?.originalSchemaAvailable === true,
2367
+ schemaSnapshotStatus: entry.salvageValidation?.schemaSnapshotStatus,
2368
+ schemaHash: entry.salvageValidation?.schemaHash,
2369
+ schemaVerdict: entry.salvageValidation?.schemaVerdict,
2370
+ finalMessageFound: entry.finalMessageFound,
2371
+ finalMessageLength: entry.finalMessageLength,
2372
+ resumeSignatureAvailable: entry.resumeSignatureAvailable,
2373
+ };
2374
+ if (entry.skipped) view.skipped = entry.skipped;
2375
+ if (entry.salvageValidation?.validationError) view.validationError = truncateText(entry.salvageValidation.validationError, MAX_STATUS_STRING_CHARS);
2376
+ if (entry.finalMessageFound) view.redactedSnippet = truncateText(redactFreeTextSecrets(entry.finalText ?? ""), 200);
2377
+ return view;
2378
+ });
2379
+ return JSON.stringify({
2380
+ mode: "preview",
2381
+ runId,
2382
+ approvalHash: computedHash,
2383
+ candidates,
2384
+ note: "Preview only; nothing written. Re-run with approve: true and this approvalHash (optionally callIds to narrow) to write tagged synthetic journal entries for schema-validated or JSON-parse-validated read-only lanes. Salvage never calls integrate/runAutoApply.",
2385
+ }, null, 2);
2386
+ }
2387
+
2388
+ // Approve: the recomputed preview hash already matched args.approvalHash, so the transcript
2389
+ // state is exactly what the operator approved. Write synthetic entries only for non-skipped
2390
+ // read-only lanes; outcome is success only when the final message parsed as JSON.
2391
+ const salvaged = [];
2392
+ const skipped = [];
2393
+ for (const entry of enriched) {
2394
+ if (selectedCallIds && !selectedCallIds.has(entry.callId)) continue;
2395
+ if (entry.skipped) {
2396
+ skipped.push({ callId: entry.callId, childID: entry.childID, reason: entry.skipped });
2397
+ continue;
2398
+ }
2399
+ const outcome = entry.salvageValidation?.recoveredResultValid === true ? "success" : "failure";
2400
+ await writeSalvagedLaneOutcome(runDir, entry.callId, {
2401
+ runId,
2402
+ signatureHash: entry.signatureHash,
2403
+ outcome,
2404
+ childID: entry.childID,
2405
+ title: entry.title,
2406
+ taskSummary: entry.taskSummary,
2407
+ model: entry.model,
2408
+ agent: entry.agent,
2409
+ role: entry.role,
2410
+ timeoutMs: entry.timeoutMs,
2411
+ permissionPolicy: entry.permissionPolicy,
2412
+ startedAt: entry.startedAt,
2413
+ result: outcome === "success" ? entry.parsedValue : undefined,
2414
+ errorSummary: outcome === "success"
2415
+ ? undefined
2416
+ : entry.salvageValidation?.kind === "json-schema"
2417
+ ? `Salvaged transcript final message did not match stored JSON schema: ${truncateText(entry.salvageValidation?.validationError ?? "", MAX_STATUS_STRING_CHARS)}`
2418
+ : `Salvaged transcript final message did not parse as JSON: ${truncateText(entry.parseError ?? "", MAX_STATUS_STRING_CHARS)}`,
2419
+ salvageValidation: entry.salvageValidation,
2420
+ });
2421
+ salvaged.push({
2422
+ callId: entry.callId,
2423
+ childID: entry.childID,
2424
+ outcome,
2425
+ parseVerdict: entry.parseVerdict,
2426
+ validationKind: entry.salvageValidation?.kind,
2427
+ schemaSnapshotStatus: entry.salvageValidation?.schemaSnapshotStatus,
2428
+ schemaVerdict: entry.salvageValidation?.schemaVerdict,
2429
+ resumeSignatureAvailable: entry.resumeSignatureAvailable,
2430
+ });
2431
+ }
2432
+ return JSON.stringify({ mode: "approve", runId, approvalHash: computedHash, salvaged, skipped }, null, 2);
2433
+ }
2434
+
2435
+ async function applyWorkflow(pluginContext, context, args) {
2436
+ assertWriteWorkflowAllowed(context, "workflow_apply");
2437
+ if (args.approvalIntent !== "apply") throw new Error('workflow_apply requires approvalIntent: "apply"');
2438
+ const entry = await readRunById(context, args.runId);
2439
+ if (entry.kind !== "valid") throw new Error(`Cannot apply invalid run ${args.runId}: ${entry.status}`);
2440
+ const releaseApplyLock = await acquireWorkflowLock(lockPathForRun(entry.dir, "apply"), { operation: "apply", runId: args.runId });
2441
+ try {
2442
+ // Fail closed while the owning run still holds an ACTIVE run.lock. apply.lock serializes two
2443
+ // concurrent applies, but it does NOT serialize apply-vs-active-run: a backgrounded run writes
2444
+ // awaiting-diff-approval/apply-failed to state.json and only releases run.lock afterward in its
2445
+ // finally block (runWorkflowExecution line ~1085). Applying in that window races the owner's own
2446
+ // writeState() in its finally, which would clobber apply-running/applied back to the pre-apply
2447
+ // status (a lost update on state.json). The on-disk status alone is not proof of settlement.
2448
+ // Stale/dead run.locks (no live owner) are admitted so interrupted/stale-active recovery and the
2449
+ // normal foreground path (run.lock already released before the caller can call apply) still work.
2450
+ const runLock = await readLock(lockPathForRun(entry.dir, "run"));
2451
+ if (runLock?.active) {
2452
+ throw new Error(
2453
+ `Workflow ${args.runId} still holds an active run lock (pid ${runLock.process?.pid}); ` +
2454
+ `wait for the owning run to settle (or run workflow_reconcile) before retrying workflow_apply.`
2455
+ );
2456
+ }
2457
+ const state = entry.state;
2458
+ // A crash between the completed-ledger append and the state.json=applied write leaves
2459
+ // on-disk status apply-running, which reconcile (workflow_reconcile / stale detection)
2460
+ // rewrites to interrupted or stale-active. Those statuses are admitted here only so the
2461
+ // matching-completed-ledger idempotency path below can finalize the staged mutations; if
2462
+ // no completed record matches, the gate still rejects them (verified after actualPlanHash).
2463
+ const isInterruptedRecovery = state.status === "interrupted" || state.status === "stale-active";
2464
+ if (state.status !== "applied" && state.status !== "awaiting-diff-approval" && state.status !== "apply-failed" && state.status !== "failed-with-diff-plan" && !isInterruptedRecovery) {
2465
+ throw new Error(`Workflow ${args.runId} is not awaiting diff approval; status=${state.status}`);
2466
+ }
2467
+ if (state.sourceHash !== args.approvedSourceHash) {
2468
+ throw applyApprovalMismatchError({
2469
+ runId: args.runId,
2470
+ reason: "approved_source_hash_mismatch",
2471
+ field: "approvedSourceHash",
2472
+ supplied: args.approvedSourceHash,
2473
+ expected: state.sourceHash ?? null,
2474
+ state,
2475
+ });
2476
+ }
2477
+ const plan = JSON.parse(await fs.readFile(path.join(entry.dir, "diff-plan.json"), "utf8"));
2478
+ const actualPlanHash = computeDiffPlanHash(plan);
2479
+ if (actualPlanHash !== args.diffPlanHash) {
2480
+ throw applyApprovalMismatchError({
2481
+ runId: args.runId,
2482
+ reason: "diff_plan_hash_mismatch",
2483
+ field: "diffPlanHash",
2484
+ supplied: args.diffPlanHash,
2485
+ expected: actualPlanHash,
2486
+ state,
2487
+ plan,
2488
+ actualPlanHash,
2489
+ });
2490
+ }
2491
+ if (plan.baseCommit !== args.baseCommit) {
2492
+ throw applyApprovalMismatchError({
2493
+ runId: args.runId,
2494
+ reason: "base_commit_mismatch",
2495
+ field: "baseCommit",
2496
+ supplied: args.baseCommit,
2497
+ expected: plan.baseCommit ?? null,
2498
+ state,
2499
+ plan,
2500
+ actualPlanHash,
2501
+ });
2502
+ }
2503
+ if (plan.domainMutationHash !== args.domainMutationHash) {
2504
+ throw applyApprovalMismatchError({
2505
+ runId: args.runId,
2506
+ reason: "domain_mutation_hash_mismatch",
2507
+ field: "domainMutationHash",
2508
+ supplied: args.domainMutationHash,
2509
+ expected: plan.domainMutationHash ?? null,
2510
+ state,
2511
+ plan,
2512
+ actualPlanHash,
2513
+ });
2514
+ }
2515
+ const currentDomainManifest = await domainMutationApprovalManifestForRun(entry.dir);
2516
+ const currentDomainHash = computeDomainMutationHash(currentDomainManifest);
2517
+ if (currentDomainHash !== plan.domainMutationHash) {
2518
+ throw applyApprovalMismatchError({
2519
+ runId: args.runId,
2520
+ reason: "staged_domain_mutations_changed",
2521
+ field: "domainMutationHash",
2522
+ supplied: args.domainMutationHash,
2523
+ expected: currentDomainHash,
2524
+ state,
2525
+ plan,
2526
+ actualPlanHash,
2527
+ currentDomainHash,
2528
+ });
2529
+ }
2530
+ // Primary-tree cleanliness is enforced by assertGitCleanAtBase(root, plan.baseCommit) on the
2531
+ // fresh-apply path below; there is no caller-supplied dirty-state proof to validate here.
2532
+ const root = path.resolve(context.worktree || context.directory);
2533
+ async function finalizeAfterApply() {
2534
+ try {
2535
+ const domainFinalization = await finalizeStagedDomainMutations(entry.dir, state, (op) => pluginContext.__workflowDomainMutationHandlers?.[op] ?? pluginContext.workflowExtensionRegistry?.mutationHandler(op));
2536
+ state.domainFinalization = domainFinalization;
2537
+ state.status = "applied";
2538
+ state.finishedAt = new Date().toISOString();
2539
+ state.error = undefined;
2540
+ await completeApprovalWaitMetric(entry.dir, state, actualPlanHash);
2541
+ await writeJsonAtomic(path.join(entry.dir, "state.json"), state);
2542
+ await writeJsonAtomic(path.join(entry.dir, "closeout.json"), {
2543
+ runId: state.id,
2544
+ status: state.status,
2545
+ finishedAt: state.finishedAt,
2546
+ resultPath: state.resultPath,
2547
+ operatorMetrics: state.operatorMetrics,
2548
+ domainFinalization: state.domainFinalization,
2549
+ durability: state.durability,
2550
+ });
2551
+ await showWorkflowApplyToast(pluginContext, state);
2552
+ return domainFinalization;
2553
+ } catch (error) {
2554
+ state.status = "apply-failed";
2555
+ state.error = `Domain finalization failed after apply: ${extractTextFromError(error)}`;
2556
+ await writeJsonAtomic(path.join(entry.dir, "state.json"), state);
2557
+ await showWorkflowApplyToast(pluginContext, state);
2558
+ throw error;
2559
+ }
2560
+ }
2561
+ async function verifyRecoveryAppliedContents() {
2562
+ try {
2563
+ const planned = await validatePatchTargets(root, plan.patches, { requireTracked: false });
2564
+ verifyAppliedPatchContents(planned);
2565
+ } catch (error) {
2566
+ state.status = "apply-failed";
2567
+ state.error = extractTextFromError(error);
2568
+ await writeJsonAtomic(path.join(entry.dir, "state.json"), state);
2569
+ await showWorkflowApplyToast(pluginContext, state);
2570
+ throw error;
2571
+ }
2572
+ }
2573
+ if (state.status === "applied") {
2574
+ await verifyRecoveryAppliedContents();
2575
+ const domainFinalization = await finalizeAfterApply();
2576
+ return domainFinalization.finalized > 0
2577
+ ? `Workflow ${args.runId} already applied; finalized ${domainFinalization.finalized} domain mutations.`
2578
+ : `Workflow ${args.runId} already applied.`;
2579
+ }
2580
+ const alreadyCompleted = await applyLedgerHasCompleted(entry.dir, actualPlanHash);
2581
+ if (isInterruptedRecovery && !alreadyCompleted) {
2582
+ // Admitted into the gate above, but no completed ledger record matches: the crash (if
2583
+ // any) happened before the patch writes finished, so there is nothing to finalize
2584
+ // idempotently and a fresh apply from this status is unsafe. Reject like the original gate.
2585
+ throw new Error(`Workflow ${args.runId} is not awaiting diff approval; status=${state.status}`);
2586
+ }
2587
+ if (alreadyCompleted) {
2588
+ await verifyRecoveryAppliedContents();
2589
+ const domainFinalization = await finalizeAfterApply();
2590
+ return domainFinalization.finalized > 0
2591
+ ? `Workflow ${args.runId} already applied; finalized ${domainFinalization.finalized} domain mutations.`
2592
+ : `Workflow ${args.runId} already applied.`;
2593
+ }
2594
+
2595
+ let planned;
2596
+ try {
2597
+ await assertGitCleanAtBase(root, plan.baseCommit);
2598
+ planned = await validatePatchTargets(root, plan.patches);
2599
+ } catch (error) {
2600
+ state.status = error?.code === "OVERLAPPING_PATCH" ? "review-required" : "apply-failed";
2601
+ state.error = extractTextFromError(error);
2602
+ await writeJsonAtomic(path.join(entry.dir, "state.json"), state);
2603
+ await showWorkflowApplyToast(pluginContext, state);
2604
+ throw error;
2605
+ }
2606
+
2607
+ state.status = "apply-running";
2608
+ await writeJsonAtomic(path.join(entry.dir, "state.json"), state);
2609
+ await appendApplyLedger(entry.dir, { phase: "started", diffPlanHash: actualPlanHash, patchCount: plan.patches.length });
2610
+ await showWorkflowApplyToast(pluginContext, state);
2611
+ try {
2612
+ for (const { patch } of planned) {
2613
+ await appendApplyLedger(entry.dir, { phase: "before-write", path: patch.path, contentHash: hash(patch.content) });
2614
+ // TOCTOU-safe (R18): re-validate ancestors + open the final component with
2615
+ // O_NOFOLLOW immediately at write time, so a symlink swapped in after
2616
+ // validatePatchTargets cannot redirect the write outside root.
2617
+ await safeWriteFileWithinRoot(root, patch.path, patch.content);
2618
+ await appendApplyLedger(entry.dir, { phase: "after-write", path: patch.path, contentHash: hash(patch.content) });
2619
+ }
2620
+ await appendApplyLedger(entry.dir, { phase: "completed", diffPlanHash: actualPlanHash });
2621
+ } catch (error) {
2622
+ const rollbackFailures = await rollbackPatches(planned, root);
2623
+ state.status = error?.code === "OVERLAPPING_PATCH" ? "review-required" : "apply-failed";
2624
+ state.error = applyErrorWithRollbackFailures(error, rollbackFailures);
2625
+ await appendApplyLedger(entry.dir, { phase: "failed", diffPlanHash: actualPlanHash, error: state.error, rollbackFailures });
2626
+ await writeJsonAtomic(path.join(entry.dir, "state.json"), state);
2627
+ await showWorkflowApplyToast(pluginContext, state);
2628
+ throw error;
2629
+ }
2630
+ const domainFinalization = await finalizeAfterApply();
2631
+ return domainFinalization.finalized > 0
2632
+ ? `Workflow ${args.runId} applied ${plan.patches.length} patches and finalized ${domainFinalization.finalized} domain mutations.`
2633
+ : `Workflow ${args.runId} applied ${plan.patches.length} patches.`;
2634
+ } finally {
2635
+ await releaseApplyLock();
2636
+ }
2637
+ }
2638
+
2639
+ async function WorkflowPlugin(pluginContext, options) {
2640
+ pluginContext.__workflowHardConcurrencyLimit = normalizeHardConcurrencyLimit(
2641
+ options?.hardConcurrencyLimit ?? pluginContext?.hardConcurrencyLimit ?? pluginContext?.__workflowHardConcurrencyLimit,
2642
+ HARD_CONCURRENCY_LIMIT,
2643
+ );
2644
+ pluginContext.__workflowToastAscii = normalizeWorkflowToastAscii(
2645
+ options?.toastCards?.ascii ?? options?.workflowToastAscii ?? pluginContext?.__workflowToastAscii,
2646
+ );
2647
+ pluginContext.workflowAutoApproveCeiling = normalizeAutoApproveTier(
2648
+ options?.autoApprove ?? pluginContext?.workflowAutoApproveCeiling,
2649
+ ) || false;
2650
+ const hardConcurrencyLimit = hardConcurrencyLimitForContext(pluginContext);
2651
+ // Trusted extension seam: opencode delivers per-plugin `options` as the factory's second arg
2652
+ // (the [path, {options}] tuple form in opencode.json). One registry per pluginContext — factories
2653
+ // are double-instantiated, so a per-instance registry avoids duplicate-name throws. Extension
2654
+ // modules are TRUSTED host code, loaded only from explicit config (never auto-discovered).
2655
+ const extensionRegistry = createExtensionRegistry();
2656
+ pluginContext.workflowExtensionRegistry = extensionRegistry;
2657
+ const extensionPaths = Array.isArray(options?.extensions) ? options.extensions : [];
2658
+ // Load extensions in the factory body (the factory is async and opencode awaits it) so the static
2659
+ // `tool` map returned below can include extension-contributed tools. Fail loud: a configured-but-
2660
+ // unloadable extension rejects the factory promise rather than silently disabling itself.
2661
+ if (extensionPaths.length) {
2662
+ await extensionRegistry.loadExtensions(extensionPaths, { configDir: resolveOpencodeConfigDir() });
2663
+ }
2664
+ const hooks = {
2665
+ config: async (cfg) => {
2666
+ configureWorkflowPermissions(cfg);
2667
+ await configureWorkflowEntrypoints(cfg, pluginContext.workflowExtensionRegistry?.assetDirs());
2668
+ },
2669
+ event: async ({ event }) => {
2670
+ // The event hook is fire-and-forget: opencode does not await it, and a throw (or rejected
2671
+ // promise) can destabilize the session or silence later events (AGENTS.md non-negotiable
2672
+ // invariant). deliverWorkflowNotifications touches the filesystem (readdir/readFile), which
2673
+ // can reject with EPERM/EACCES/EIO, so guard the whole body and swallow any failure here.
2674
+ try {
2675
+ updateNotificationIdleState(event);
2676
+ await deliverWorkflowNotifications(pluginContext, event);
2677
+ } catch {
2678
+ // Notification delivery is best effort; persisted state on disk remains authoritative.
2679
+ }
2680
+ },
2681
+ dispose: async () => {
2682
+ clearNotificationRuntimeState();
2683
+ },
2684
+ "chat.params": async (input, output) => {
2685
+ applyLaneEffortParams(input, output);
2686
+ },
2687
+ tool: {
2688
+ workflow_run: tool({
2689
+ description:
2690
+ "Run a sandboxed OpenCode workflow. By default this is two-phase: call WITHOUT approve=true to get the plan summary (use format=json for a structured workflow_preview with approvalHash, models, lane budget, authority, and cost), then call WITH approve=true and the matching approvalHash to execute; missing/stale hashes return approval_mismatch with executed=false. If the plugin is configured with options.autoApprove, an eligible call may launch immediately without approvalHash when the resolved authority tier is covered by the configured ceiling; args.autoApprove can only narrow that configured ceiling for one call. workflow_apply remains independently hash-gated.",
2691
+ args: {
2692
+ name: tool.schema.string().optional(),
2693
+ scriptPath: tool.schema.string().optional(),
2694
+ allowExternalScriptPath: tool.schema.boolean().optional(),
2695
+ source: tool.schema.string().optional(),
2696
+ includeSourceSnippet: tool.schema.boolean().optional(),
2697
+ sourceSnippetMaxChars: tool.schema.number().int().positive().max(2000).optional(),
2698
+ args: tool.schema.any().optional(),
2699
+ approve: tool.schema.boolean().optional(),
2700
+ approvalHash: tool.schema.string().optional(),
2701
+ autoApprove: tool.schema.enum(["readOnly", "worktree", "all"]).optional(),
2702
+ format: tool.schema.enum(["summary", "json"]).optional(),
2703
+ resumeRunId: tool.schema.string().optional(),
2704
+ resumePolicy: tool.schema.enum(["extend-deadline"]).optional(),
2705
+ editAndResume: tool.schema.boolean().optional(),
2706
+ maxAgents: tool.schema.number().int().positive().max(100000).optional(),
2707
+ concurrency: tool.schema.number().int().positive().max(hardConcurrencyLimit).optional(),
2708
+ laneTimeoutMs: tool.schema.number().int().positive().max(MAX_CHILD_PROMPT_TIMEOUT_MS).optional(),
2709
+ childPromptTimeoutMs: tool.schema.number().int().positive().max(MAX_CHILD_PROMPT_TIMEOUT_MS).optional(),
2710
+ childModel: tool.schema.string().optional(),
2711
+ modelTiers: tool.schema.object({
2712
+ fast: tool.schema.string().optional(),
2713
+ deep: tool.schema.string().optional(),
2714
+ }).optional(),
2715
+ profile: tool.schema.enum(Object.keys(WORKFLOW_AUTHORITY_PROFILES)).optional(),
2716
+ background: tool.schema.boolean().optional(),
2717
+ debugCapture: tool.schema.boolean().optional(),
2718
+ authority: tool.schema.any().optional(),
2719
+ baseCommit: tool.schema.string().optional(),
2720
+ maxCost: tool.schema.number().nonnegative().optional(),
2721
+ maxTokens: tool.schema.number().int().nonnegative().optional(),
2722
+ guestDeadlineMs: tool.schema.number().int().positive().max(60_000).optional(),
2723
+ maxRuntimeMs: tool.schema.number().int().positive().max(24 * 60 * 60 * 1000).optional(),
2724
+ },
2725
+ async execute(args, context) {
2726
+ return await startWorkflow(pluginContext, context, args);
2727
+ },
2728
+ }),
2729
+ workflow_status: tool({
2730
+ description: "Show recent workflow runs or one workflow run state.",
2731
+ args: {
2732
+ runId: tool.schema.string().optional(),
2733
+ limit: tool.schema.number().int().positive().max(100).optional(),
2734
+ includePendingApproval: tool.schema.boolean().optional(),
2735
+ format: tool.schema.enum(["summary", "json"]).optional(),
2736
+ detail: tool.schema.enum(["compact", "full", "result"]).optional(),
2737
+ reconcile: tool.schema.boolean().optional(),
2738
+ },
2739
+ async execute(args, context) {
2740
+ return await statusText(context, args);
2741
+ },
2742
+ }),
2743
+ workflow_events: tool({
2744
+ description: "Read a workflow run's redacted lifecycle events with type-prefix filtering and offset pagination.",
2745
+ args: {
2746
+ runId: tool.schema.string(),
2747
+ typePrefix: tool.schema.string().optional(),
2748
+ sinceTimestamp: tool.schema.string().optional(),
2749
+ beforeTimestamp: tool.schema.string().optional(),
2750
+ offset: tool.schema.number().int().nonnegative().optional(),
2751
+ limit: tool.schema.number().int().positive().max(500).optional(),
2752
+ order: tool.schema.enum(["newest", "oldest"]).optional(),
2753
+ format: tool.schema.enum(["json", "summary"]).optional(),
2754
+ },
2755
+ async execute(args, context) {
2756
+ return await eventsText(context, args);
2757
+ },
2758
+ }),
2759
+ workflow_reconcile: tool({
2760
+ description: "Persist recovery state for stale workflow runs and clear stale workflow locks.",
2761
+ args: {
2762
+ runId: tool.schema.string().optional(),
2763
+ limit: tool.schema.number().int().positive().max(100).optional(),
2764
+ includePendingApproval: tool.schema.boolean().optional(),
2765
+ format: tool.schema.enum(["summary", "json"]).optional(),
2766
+ detail: tool.schema.enum(["compact", "full", "result"]).optional(),
2767
+ },
2768
+ async execute(args, context) {
2769
+ return await reconcileRuns(context, args);
2770
+ },
2771
+ }),
2772
+ workflow_cancel: tool({
2773
+ description: "Cancel an active workflow run and abort its child OpenCode sessions best-effort.",
2774
+ args: {
2775
+ runId: tool.schema.string(),
2776
+ },
2777
+ async execute(args, context) {
2778
+ return await cancelRun(pluginContext, context, args);
2779
+ },
2780
+ }),
2781
+ workflow_pause: tool({
2782
+ description: "Pause an active workflow run, abort active child sessions best-effort, and preserve durable state for resume.",
2783
+ args: {
2784
+ runId: tool.schema.string(),
2785
+ },
2786
+ async execute(args, context) {
2787
+ return await pauseRun(pluginContext, context, args);
2788
+ },
2789
+ }),
2790
+ workflow_kill: tool({
2791
+ description: "Force-terminate a wedged workflow run immediately: abort it, release its locks without waiting on cooperative settle, and leave it in a resumable (interrupted) state. Use when workflow_cancel/workflow_pause do not return because a lane is stuck.",
2792
+ args: {
2793
+ runId: tool.schema.string(),
2794
+ },
2795
+ async execute(args, context) {
2796
+ return await killRun(pluginContext, context, args);
2797
+ },
2798
+ }),
2799
+ workflow_save: tool({
2800
+ description: "Save a reusable workflow script to the project workflow directory by default, or globally with an explicit globalScopeIntent opt-in.",
2801
+ args: {
2802
+ name: tool.schema.string(),
2803
+ source: tool.schema.string(),
2804
+ scope: tool.schema.enum(["global", "project"]).optional(),
2805
+ globalScopeIntent: tool.schema.literal("save-global-workflow").optional(),
2806
+ overwrite: tool.schema.boolean().optional(),
2807
+ },
2808
+ async execute(args, context) {
2809
+ return await saveWorkflow(context, args);
2810
+ },
2811
+ }),
2812
+ workflow_list: tool({
2813
+ description: "List saved project and global workflows without running them.",
2814
+ args: {
2815
+ format: tool.schema.enum(["summary", "json"]).optional(),
2816
+ },
2817
+ async execute(args, context) {
2818
+ // Surface the invoking session's model as the display default for workflows that declare no
2819
+ // model (the child default is the session model at run time). Read-only and best-effort:
2820
+ // readActiveSessionModel never throws and never prompts a model.
2821
+ const sessionModel = await readActiveSessionModel(pluginContext, context);
2822
+ return await listWorkflows(
2823
+ context,
2824
+ args,
2825
+ sessionModel.model,
2826
+ pluginContext.workflowExtensionRegistry?.assetDirs()?.workflows ?? [],
2827
+ );
2828
+ },
2829
+ }),
2830
+ workflow_cleanup: tool({
2831
+ description: "Dry-run or apply workflow run retention cleanup while preserving active, corrupt, and ambiguous edit runs.",
2832
+ args: {
2833
+ dryRun: tool.schema.boolean().optional().describe("When true or omitted, only previews which completed run dirs would be removed. Set false to delete eligible old runs."),
2834
+ keep: tool.schema.number().int().min(0).max(1000).optional().describe("Number of newest completed terminal runs to preserve; active, corrupt, ambiguous edit, and pinned runs are always preserved."),
2835
+ },
2836
+ async execute(args, context) {
2837
+ return await cleanupRuns(context, args);
2838
+ },
2839
+ }),
2840
+ workflow_apply: tool({
2841
+ description: "Apply an awaiting workflow diff plan to the primary tree after explicit hash-gated approval. Use hash fields copied from a prior workflow_status({detail:\"full\"}) or workflow_run apply-preview for the same run; stale or missing hashes fail closed with a structured workflow_apply_approval_mismatch payload.",
2842
+ args: {
2843
+ runId: tool.schema.string().describe("Run id currently in awaiting-diff-approval, apply-failed, failed-with-diff-plan, applied, or an interrupted recovery state."),
2844
+ approvedSourceHash: tool.schema.string().describe("Exact sourceHash copied from workflow_status detail:\"full\" for this run; proves the workflow source that produced the diff plan."),
2845
+ baseCommit: tool.schema.string().describe("Exact editPlan.baseCommit from workflow_status detail:\"full\"; primary HEAD must still match before patch writes."),
2846
+ diffPlanHash: tool.schema.string().describe("Exact editPlan.diffPlanHash from workflow_status detail:\"full\"; covers the normalized patch plan and staged domain manifest."),
2847
+ domainMutationHash: tool.schema.string().describe("Exact editPlan.domainMutationHash from workflow_status detail:\"full\"; fails if staged domain mutations changed after diff approval."),
2848
+ approvalIntent: tool.schema.literal("apply").describe("Must be the literal string \"apply\" to show this call is an explicit apply approval."),
2849
+ },
2850
+ async execute(args, context) {
2851
+ return await applyWorkflow(pluginContext, context, args);
2852
+ },
2853
+ }),
2854
+ workflow_salvage: tool({
2855
+ description: "Recover orphaned read-only lane results from persisted child transcripts into tagged synthetic journal entries, behind an explicit preview/approve gate.",
2856
+ args: {
2857
+ runId: tool.schema.string().describe("Interrupted or stale-active run id whose lane checkpoints should be inspected for salvage candidates."),
2858
+ callIds: tool.schema.array(tool.schema.string()).optional().describe("Optional subset of candidate lane callIds to salvage; omitted means preview or approve all eligible candidates."),
2859
+ approve: tool.schema.boolean().optional().describe("Omit or false to preview candidates and approvalHash; true writes tagged synthetic journal entries when the hash matches."),
2860
+ approvalHash: tool.schema.string().optional().describe("Approval hash returned by a prior salvage preview for the same run and candidate set. Required when approve is true."),
2861
+ },
2862
+ async execute(args, context) {
2863
+ return await salvageRun(pluginContext, context, args);
2864
+ },
2865
+ }),
2866
+ workflow_roles: tool({
2867
+ description: "List workflow specialist role prompts, hash provenance, and typed roles.json defaults.",
2868
+ args: {
2869
+ format: tool.schema.enum(["summary", "json"]).optional(),
2870
+ },
2871
+ async execute(args) {
2872
+ return await listRoles(args, { roleDir: pluginContext?.__workflowRoleDir });
2873
+ },
2874
+ }),
2875
+ workflow_models: tool({
2876
+ description: "List the invoking session's model and all available/authenticated providers and models, with a no-deviation fast/deep suggestion. Read-only; no run is started.",
2877
+ args: {
2878
+ format: tool.schema.enum(["summary", "json"]).optional(),
2879
+ },
2880
+ async execute(args, context) {
2881
+ const models = await buildWorkflowModels(pluginContext, context);
2882
+ if (args.format === "json") return JSON.stringify(models);
2883
+ const lines = [
2884
+ `Session model: ${models.session.model ?? "unknown"} (source: ${models.session.source})`,
2885
+ `Suggested: fast=${models.suggested.fast ?? "?"} deep=${models.suggested.deep ?? "?"}`,
2886
+ "Providers:",
2887
+ ...models.providers.map((p) => ` ${p.id} [${p.source}] -> ${p.models.map((m) => m.id).join(", ")}`),
2888
+ ];
2889
+ return lines.join("\n");
2890
+ },
2891
+ }),
2892
+ workflow_templates: tool({
2893
+ description: "List shipped v2 workflow templates without writing files; pass includeSource=true to retrieve source bodies explicitly.",
2894
+ args: {
2895
+ format: tool.schema.enum(["summary", "json"]).optional(),
2896
+ template: tool.schema.string().optional(),
2897
+ includeSource: tool.schema.boolean().optional(),
2898
+ },
2899
+ async execute(args, context) {
2900
+ return await listTemplates(args);
2901
+ },
2902
+ }),
2903
+ workflow_template_save: tool({
2904
+ description: "Save a shipped v2 workflow template to project or global workflows.",
2905
+ args: {
2906
+ template: tool.schema.string().describe("Bundled template name from workflow_templates, for example first-run-slice or scoped-parallel."),
2907
+ name: tool.schema.string().optional().describe("Saved workflow slug; omitted uses the template name."),
2908
+ scope: tool.schema.enum(["global", "project"]).optional().describe("Destination workflow directory; omitted defaults to project for template saves."),
2909
+ globalScopeIntent: tool.schema.literal("save-global-workflow").optional().describe("Required only with scope:\"global\" to confirm this writes into the shared global workflow directory."),
2910
+ overwrite: tool.schema.boolean().optional().describe("Set true to replace an existing saved workflow with the same name."),
2911
+ },
2912
+ async execute(args, context) {
2913
+ return await saveTemplate(context, args);
2914
+ },
2915
+ }),
2916
+ workflow_live_gates: tool({
2917
+ description: "Report workflow v2 live-gate capability status for the active runtime.",
2918
+ args: {
2919
+ format: tool.schema.enum(["summary", "json"]).optional(),
2920
+ probePermissionEnforcement: tool.schema.boolean().optional(),
2921
+ probeDeniedBash: tool.schema.boolean().optional(),
2922
+ probeCommandScopedBash: tool.schema.boolean().optional(),
2923
+ probeSecretReadDeny: tool.schema.boolean().optional(),
2924
+ probeStructuredOutput: tool.schema.boolean().optional(),
2925
+ probeWorktreeApi: tool.schema.boolean().optional(),
2926
+ probeDirectoryRooting: tool.schema.boolean().optional(),
2927
+ probeWorktreeEditIsolation: tool.schema.boolean().optional(),
2928
+ probeIntegrationWorktreeIsolation: tool.schema.boolean().optional(),
2929
+ probeBackgroundContinuation: tool.schema.boolean().optional(),
2930
+ probeConcurrencyCapacity: tool.schema.boolean().optional(),
2931
+ concurrencyProbeLimit: tool.schema.number().int().positive().max(hardConcurrencyLimit).optional(),
2932
+ probeCancellation: tool.schema.boolean().optional(),
2933
+ probeWorkflowNotification: tool.schema.boolean().optional(),
2934
+ probeNetworkAccess: tool.schema.boolean().optional(),
2935
+ probeMcpAccess: tool.schema.boolean().optional(),
2936
+ resetProbeCache: tool.schema.boolean().optional(),
2937
+ resetProbeCacheScope: tool.schema.enum(["runtime", "all"]).optional(),
2938
+ approvalIntent: tool.schema.literal("probe").optional(),
2939
+ },
2940
+ async execute(args, context) {
2941
+ assertLiveGateProbeAllowed(context, args);
2942
+ if (args.resetProbeCache === true) {
2943
+ invalidateWorkflowProviderListCache(args.resetProbeCacheScope === "all" ? "all" : pluginContext);
2944
+ }
2945
+ return await liveGateReport(pluginContext, context, args);
2946
+ },
2947
+ }),
2948
+ // review_materialize is contributed by the beads extension (workflow-domains/beads/beads-extension.js
2949
+ // `tools` factory); the core kernel ships no beads-specific tool.
2950
+ },
2951
+ };
2952
+ // Merge extension-contributed tools into the static tool map (Stage 4). toolKit injects the kernel's
2953
+ // single @opencode-ai/plugin `tool`/`schema` (one zod instance) plus pluginContext + the write guard,
2954
+ // so an extension needs no @opencode-ai/plugin dependency of its own. Core tool names are reserved
2955
+ // (fail-closed): an extension may only contribute NET-NEW tool names.
2956
+ const toolKit = { tool, schema: tool.schema, assertWriteWorkflowAllowed, pluginContext };
2957
+ Object.assign(hooks.tool, extensionRegistry.tools(toolKit, Object.keys(hooks.tool)));
2958
+ return hooks;
2959
+ }
2960
+
2961
+ WorkflowPlugin.__test = {
2962
+ acquireAgentSlot,
2963
+ releaseAgentSlot,
2964
+ configureWorkflowEntrypoints,
2965
+ isTrustedAutoApplySource,
2966
+ shouldAutoApplyDrain,
2967
+ normalizePatches,
2968
+ planWorkflowEnvelope,
2969
+ assertWorkflowArgsMatchSchema,
2970
+ readActiveSessionModel,
2971
+ buildWorkflowModels,
2972
+ hardConcurrencyLimitForContext,
2973
+ normalizeWorkflowToastAscii,
2974
+ workflowProviderListCache,
2975
+ workflowProviderListCacheKey,
2976
+ WORKFLOW_PROVIDER_LIST_TTL_MS,
2977
+ invalidateWorkflowProviderListCache,
2978
+ salvageRun,
2979
+ extractFinalAssistantText,
2980
+ tryParseJson,
2981
+ computeSalvageApprovalHash,
2982
+ salvageApprovalPayload,
2983
+ classifyResumeCacheHit,
2984
+ checkpointHitForSignature,
2985
+ isLaneIntegrable,
2986
+ backgroundRecommendation,
2987
+ assertResumableState,
2988
+ };
2989
+
2990
+ export {
2991
+ acquireAgentSlot,
2992
+ addEditPlanFromResult,
2993
+ applyWorkflow,
2994
+ approvalSummary,
2995
+ assertGitCleanAtBase,
2996
+ cleanupWorktrees,
2997
+ configureWorkflowEntrypoints,
2998
+ createEditWorktree,
2999
+ executeSandbox,
3000
+ gitHead,
3001
+ gitOutput,
3002
+ gitPathTracked,
3003
+ isRunAborted,
3004
+ normalizePatches,
3005
+ planWorkflowEnvelope,
3006
+ releaseAgentSlot,
3007
+ rollbackPatches,
3008
+ runChildAgent,
3009
+ runNestedWorkflow,
3010
+ runWorkflowExecution,
3011
+ salvageRun,
3012
+ startWorkflow,
3013
+ throwIfAborted,
3014
+ validatePatchTargets,
3015
+ };
3016
+
3017
+ export default WorkflowPlugin;