patchwork-os 0.2.0-beta.5.canary.13 → 0.2.0-beta.5.canary.131

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 (117) hide show
  1. package/README.md +1 -1
  2. package/dist/analyticsPrefs.d.ts +3 -0
  3. package/dist/analyticsPrefs.js +7 -0
  4. package/dist/analyticsPrefs.js.map +1 -1
  5. package/dist/approvalHttp.js +15 -0
  6. package/dist/approvalHttp.js.map +1 -1
  7. package/dist/approvalQueue.d.ts +26 -0
  8. package/dist/approvalQueue.js +17 -0
  9. package/dist/approvalQueue.js.map +1 -1
  10. package/dist/bridge.js +16 -0
  11. package/dist/bridge.js.map +1 -1
  12. package/dist/commands/recipeInstall.js +5 -1
  13. package/dist/commands/recipeInstall.js.map +1 -1
  14. package/dist/commands/tools.d.ts +20 -1
  15. package/dist/commands/tools.js +112 -3
  16. package/dist/commands/tools.js.map +1 -1
  17. package/dist/haltPushDispatch.d.ts +33 -0
  18. package/dist/haltPushDispatch.js +103 -0
  19. package/dist/haltPushDispatch.js.map +1 -0
  20. package/dist/inboxRoutes.d.ts +22 -0
  21. package/dist/inboxRoutes.js +61 -1
  22. package/dist/inboxRoutes.js.map +1 -1
  23. package/dist/index.js +8 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/recipes/chainedRunner.js +51 -2
  26. package/dist/recipes/chainedRunner.js.map +1 -1
  27. package/dist/recipes/yamlRunner.d.ts +23 -1
  28. package/dist/recipes/yamlRunner.js +482 -260
  29. package/dist/recipes/yamlRunner.js.map +1 -1
  30. package/dist/recipesHttp.d.ts +2 -0
  31. package/dist/recipesHttp.js +20 -0
  32. package/dist/recipesHttp.js.map +1 -1
  33. package/dist/runLog.d.ts +22 -0
  34. package/dist/runLog.js +12 -1
  35. package/dist/runLog.js.map +1 -1
  36. package/dist/server.d.ts +14 -0
  37. package/dist/server.js +211 -11
  38. package/dist/server.js.map +1 -1
  39. package/dist/tools/batchLsp.d.ts +3 -0
  40. package/dist/tools/cancelClaudeTask.d.ts +1 -0
  41. package/dist/tools/clipboard.d.ts +2 -0
  42. package/dist/tools/closeTabs.d.ts +1 -0
  43. package/dist/tools/codeLens.d.ts +1 -0
  44. package/dist/tools/createIssueFromAIComment.d.ts +1 -0
  45. package/dist/tools/ctxSaveTrace.d.ts +1 -0
  46. package/dist/tools/debug.d.ts +4 -0
  47. package/dist/tools/decorations.d.ts +2 -0
  48. package/dist/tools/documentLinks.d.ts +1 -0
  49. package/dist/tools/editText.d.ts +1 -0
  50. package/dist/tools/enrichCommit.d.ts +1 -0
  51. package/dist/tools/explainDiagnostic.d.ts +1 -0
  52. package/dist/tools/explainSymbol.d.ts +1 -0
  53. package/dist/tools/fileOperations.d.ts +3 -0
  54. package/dist/tools/fileWatcher.d.ts +2 -0
  55. package/dist/tools/findFiles.d.ts +1 -0
  56. package/dist/tools/fixAllLintErrors.d.ts +1 -0
  57. package/dist/tools/foldingRanges.d.ts +1 -0
  58. package/dist/tools/formatDocument.d.ts +1 -0
  59. package/dist/tools/generateTests.d.ts +1 -0
  60. package/dist/tools/getAIComments.d.ts +1 -0
  61. package/dist/tools/getBufferContent.d.ts +1 -0
  62. package/dist/tools/getChangeImpact.d.ts +1 -0
  63. package/dist/tools/getClaudeTaskStatus.d.ts +1 -0
  64. package/dist/tools/getCodeCoverage.d.ts +1 -0
  65. package/dist/tools/getCommitsForIssue.d.ts +1 -0
  66. package/dist/tools/getDebugState.d.ts +1 -0
  67. package/dist/tools/getDocumentSymbols.d.ts +1 -0
  68. package/dist/tools/getGitHotspots.d.ts +1 -0
  69. package/dist/tools/getImportedSignatures.d.ts +1 -0
  70. package/dist/tools/getPRTemplate.d.ts +1 -0
  71. package/dist/tools/getSymbolHistory.d.ts +1 -0
  72. package/dist/tools/getTypeSignature.d.ts +1 -0
  73. package/dist/tools/getWorkspaceSettings.d.ts +1 -0
  74. package/dist/tools/gitWrite.d.ts +11 -0
  75. package/dist/tools/github/actions.d.ts +2 -0
  76. package/dist/tools/github/composite.d.ts +3 -0
  77. package/dist/tools/github/issues.d.ts +4 -0
  78. package/dist/tools/github/pr.d.ts +7 -0
  79. package/dist/tools/handoffNote.d.ts +1 -0
  80. package/dist/tools/hoverAtCursor.d.ts +1 -0
  81. package/dist/tools/httpClient.d.ts +2 -0
  82. package/dist/tools/inlayHints.d.ts +1 -0
  83. package/dist/tools/launchQuickTask.d.ts +1 -0
  84. package/dist/tools/listClaudeTasks.d.ts +1 -0
  85. package/dist/tools/listTerminals.d.ts +1 -0
  86. package/dist/tools/lsp.d.ts +15 -0
  87. package/dist/tools/navigateToSymbolByName.d.ts +1 -0
  88. package/dist/tools/openDiff.d.ts +1 -0
  89. package/dist/tools/openFile.d.ts +1 -0
  90. package/dist/tools/organizeImports.d.ts +1 -0
  91. package/dist/tools/planPersistence.d.ts +3 -0
  92. package/dist/tools/previewEdit.d.ts +1 -0
  93. package/dist/tools/refactorAnalyze.d.ts +1 -0
  94. package/dist/tools/refactorPreview.d.ts +1 -0
  95. package/dist/tools/replaceBlock.d.ts +1 -0
  96. package/dist/tools/resumeClaudeTask.d.ts +1 -0
  97. package/dist/tools/runClaudeTask.d.ts +1 -0
  98. package/dist/tools/screenshot.d.ts +1 -0
  99. package/dist/tools/searchAndReplace.d.ts +1 -0
  100. package/dist/tools/searchWorkspace.d.ts +1 -0
  101. package/dist/tools/selectionRanges.d.ts +1 -0
  102. package/dist/tools/semanticTokens.d.ts +1 -0
  103. package/dist/tools/signatureHelp.d.ts +1 -0
  104. package/dist/tools/terminal.d.ts +6 -0
  105. package/dist/tools/testTraceToSource.d.ts +1 -0
  106. package/dist/tools/transaction.d.ts +4 -0
  107. package/dist/tools/typeHierarchy.d.ts +1 -0
  108. package/dist/tools/utils.d.ts +18 -0
  109. package/dist/tools/utils.js +28 -6
  110. package/dist/tools/utils.js.map +1 -1
  111. package/dist/tools/vscodeCommands.d.ts +2 -0
  112. package/dist/tools/vscodeTasks.d.ts +2 -0
  113. package/dist/tools/workspaceSettings.d.ts +1 -0
  114. package/dist/wireHaltPushDispatch.d.ts +38 -0
  115. package/dist/wireHaltPushDispatch.js +71 -0
  116. package/dist/wireHaltPushDispatch.js.map +1 -0
  117. package/package.json +1 -1
@@ -42,6 +42,7 @@ import { findYamlRecipePath } from "../recipesHttp.js";
42
42
  import { sanitizeParsedJson as sanitizeParsed } from "../sanitizeParsedJson.js";
43
43
  import { ensureCmdShim } from "../winShim.js";
44
44
  import { executeAgent as _executeAgent, } from "./agentExecutor.js";
45
+ import { categoriseHaltReason } from "./haltCategory.js";
45
46
  import { assertValidManualRunId, deriveScopeKey, WriteEffectLedger, } from "./idempotencyKey.js";
46
47
  import { buildJudgeArtefactBlock, JUDGE_PROMPT_SUFFIX, parseJudgeVerdict, } from "./judgeVerdict.js";
47
48
  import { defaultDeprecationWarn, normalizeRecipeForRuntime, } from "./migrations/index.js";
@@ -127,6 +128,26 @@ export function evaluateExpect(result, expect) {
127
128
  return failures;
128
129
  }
129
130
  // Strip tool-call narration some models (e.g. Gemini) prepend before the markdown block.
131
+ /**
132
+ * Phase 0β — separator-agnostic inbox-path detector. Extracted so the
133
+ * Windows path-separator behaviour can be unit-tested by injecting
134
+ * `path.win32` / `path.posix` without booting a real recipe runner.
135
+ *
136
+ * Returns true when `candidate` resolves to a direct child of
137
+ * `inboxDirAbs`, isn't a dotfile, and lives in (not above) the inbox
138
+ * dir. Both arguments must already be platform-appropriate absolute
139
+ * paths (resolve them with the same path module before calling).
140
+ */
141
+ export function isInboxPathFor(candidate, inboxDirAbs, pathMod) {
142
+ const target = pathMod.resolve(candidate);
143
+ const rel = pathMod.relative(inboxDirAbs, target);
144
+ if (!rel || rel.startsWith("..") || pathMod.isAbsolute(rel))
145
+ return false;
146
+ if (pathMod.basename(target).startsWith("."))
147
+ return false;
148
+ // Only direct children — `~/.patchwork/inbox/foo.md`, not nested.
149
+ return !rel.includes(pathMod.sep);
150
+ }
130
151
  function stripLeadingNarration(text) {
131
152
  const lines = text.split("\n");
132
153
  const firstMarkdown = lines.findIndex((l) => /^(#|>|`|\||[-*+] |\d+\. |\*\*)/.test(l.trimStart()));
@@ -238,6 +259,84 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
238
259
  ...seedContext,
239
260
  };
240
261
  const stepDeps = resolveStepDeps(deps, { recipeName: recipe.name });
262
+ // Phase 0β — inbox provenance. When a recipe `file.write` / `file.append`
263
+ // step targets `~/.patchwork/inbox/`, prepend a YAML frontmatter block
264
+ // (first write only) recording recipe + run + trigger, and accumulate the
265
+ // delivered filename onto the run record's `inboxOutputs`. Old recipes /
266
+ // non-inbox paths pass through unchanged.
267
+ //
268
+ // Windows path-separator fix (CI repro 2026-05-20): the original
269
+ // implementation built the prefix as `${os.homedir()}/.patchwork/inbox/`
270
+ // and compared with `startsWith`, which failed on Windows where
271
+ // resolved absolute paths use `\` separators and `os.homedir()` returns
272
+ // `C:\Users\...`. Now we resolve both sides through `path.resolve()`
273
+ // and use `path.relative()` to detect containment so the comparison is
274
+ // separator-agnostic. Also case-insensitive on Win32 (NTFS).
275
+ const inboxDirAbs = path.resolve(path.join(os.homedir(), ".patchwork", "inbox"));
276
+ const inboxOutputs = [];
277
+ const isInboxPath = (abs) => isInboxPathFor(abs, inboxDirAbs, path);
278
+ const buildFrontmatter = () => {
279
+ const triggerKindAtWrite = yamlTriggerKind;
280
+ const lines = ["---", `recipe: ${recipe.name}`];
281
+ if (runSeq !== undefined)
282
+ lines.push(`runSeq: ${runSeq}`);
283
+ lines.push(`trigger: ${triggerKindAtWrite}`, `deliveredAt: ${new Date().toISOString()}`, "---", "", "");
284
+ return lines.join("\n");
285
+ };
286
+ const recordInboxDelivery = (abs) => {
287
+ inboxOutputs.push({
288
+ filename: path.basename(abs),
289
+ deliveredAt: Date.now(),
290
+ });
291
+ };
292
+ // Atomic read-or-default: a single `readFileSync` in a try/catch. No
293
+ // `existsSync`/`statSync` probe around the write — on Windows a stat
294
+ // immediately before write can race a concurrent fd holder and surface
295
+ // `EBUSY`/`EPERM`. The read either succeeds (file present) or throws
296
+ // ENOENT (treated as new file). Either way we never stat the same path
297
+ // we're about to write.
298
+ const readExistingOrEmpty = (abs) => {
299
+ try {
300
+ return readFileSync(abs, "utf-8");
301
+ }
302
+ catch {
303
+ return "";
304
+ }
305
+ };
306
+ const originalWrite = stepDeps.writeFile;
307
+ const originalAppend = stepDeps.appendFile;
308
+ stepDeps.writeFile = (p, content) => {
309
+ if (isInboxPath(p)) {
310
+ // First-write detection by content shape, not by stat. Empty string
311
+ // (ENOENT) and any file that does NOT already begin with `---\n`
312
+ // gets frontmatter; pre-frontmattered files are overwritten as-is
313
+ // so consumers can replay a recipe without doubling the header.
314
+ const existing = readExistingOrEmpty(p);
315
+ const hasFm = existing.startsWith("---\n");
316
+ const final = hasFm ? content : buildFrontmatter() + content;
317
+ originalWrite(p, final);
318
+ recordInboxDelivery(p);
319
+ return;
320
+ }
321
+ originalWrite(p, content);
322
+ };
323
+ stepDeps.appendFile = (p, content) => {
324
+ if (isInboxPath(p)) {
325
+ // file.append: never re-prepend. If file is brand-new, seed one
326
+ // frontmatter block so an append-only recipe still gets
327
+ // provenance. Same atomic read-or-default — no stat probe.
328
+ const existing = readExistingOrEmpty(p);
329
+ if (existing.length === 0) {
330
+ originalWrite(p, buildFrontmatter() + content);
331
+ }
332
+ else {
333
+ originalAppend(p, content);
334
+ }
335
+ recordInboxDelivery(p);
336
+ return;
337
+ }
338
+ originalAppend(p, content);
339
+ };
241
340
  // PR2b: one per-run budget shared across all agent steps. Absent
242
341
  // `recipe.budget` → no enforcement, no overhead.
243
342
  const runBudget = new RunBudget(recipe.budget);
@@ -272,6 +371,30 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
272
371
  const stepResults = [];
273
372
  let stepsRun = 0;
274
373
  let runError;
374
+ // Live-tail SSE broadcaster. Wrapped in a try/catch on every call so a
375
+ // misbehaving listener can never break the run (mirrors chainedRunner).
376
+ // No-ops when `activityLog` isn't wired (CLI runs, tests, mocks).
377
+ const broadcast = deps.activityLog;
378
+ const emit = (event, metadata) => {
379
+ if (!broadcast || runSeq === undefined || stepDeps.testMode)
380
+ return;
381
+ try {
382
+ broadcast.recordEvent(event, metadata);
383
+ }
384
+ catch {
385
+ /* live-tail must not break a recipe run */
386
+ }
387
+ };
388
+ // Emit recipe_started as soon as we have a runSeq. The dashboard
389
+ // RecipeRunInline component watches for this event to flip a row
390
+ // from "queued" to "running" without waiting for the first step.
391
+ emit("recipe_started", {
392
+ runSeq,
393
+ recipeName: recipe.name,
394
+ trigger: yamlTriggerKind,
395
+ totalSteps: recipe.steps.length,
396
+ ts: recipeStartedAt,
397
+ });
275
398
  // Push live step results into the run-log ring so the dashboard's
276
399
  // `/runs/[seq]` page surfaces verdicts + haltReasons mid-flight,
277
400
  // instead of waiting for the whole recipe to finish via
@@ -288,305 +411,380 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
288
411
  /* live-tail is best-effort; never break a recipe run for it */
289
412
  }
290
413
  };
291
- for (const step of recipe.steps) {
292
- // Evaluate `when` guard before running anything. Mirrors
293
- // chainedRunner.ts:248-266 render the template, then truthy-check the
294
- // result (empty string, "0", "false", "null", "undefined" are falsy).
295
- // A falsy guard records the step as `skipped`, increments stepsRun, and
296
- // continues it is NOT a failure. Bridge-dev iMessage recipes rely on
297
- // this to suppress the iMessage agent step when phone is empty.
298
- if (typeof step.when === "string" && step.when.length > 0) {
299
- const rendered = render(step.when, ctx).trim().toLowerCase();
300
- const truthy = !!rendered &&
301
- rendered !== "0" &&
302
- rendered !== "false" &&
303
- rendered !== "null" &&
304
- rendered !== "undefined";
305
- if (!truthy) {
306
- const skipId = step.into ?? step.agent?.into ?? `step_${stepsRun}`;
307
- stepResults.push({
308
- id: skipId,
309
- tool: step.agent ? "agent" : step.tool,
310
- status: "skipped",
311
- durationMs: 0,
312
- });
313
- stepsRun++;
314
- persistLiveStepResults();
315
- continue;
316
- }
317
- }
318
- // Handle agent steps separately
319
- if (step.agent) {
320
- const agentCfg = step.agent;
321
- const isJudge = agentCfg.kind === "judge";
322
- // PR3a: judge prompt convention. Append the structured-verdict
323
- // suffix and, when `reviews: <stepId>` is set, inject the
324
- // upstream step's output as an <artefact> block.
325
- let renderedPrompt = render(agentCfg.prompt, ctx);
326
- if (isJudge) {
327
- if (agentCfg.reviews) {
328
- renderedPrompt += buildJudgeArtefactBlock(ctx[agentCfg.reviews]);
414
+ // Track per-step start timestamps so done events carry durationMs
415
+ // without a second roundtrip.
416
+ const stepStartTs = new Map();
417
+ // Emit recipe_step_done for the step result just pushed onto
418
+ // `stepResults`. Every loop branch (skip / budget / agent / tool)
419
+ // pushes exactly one result before it ends, so the last element is
420
+ // always the current step. `stepId` mirrors recipe_step_start's
421
+ // `stepIdForEmit` so live consumers can correlate start↔done the
422
+ // pushed result's own id can diverge for agent steps without `into`.
423
+ const emitStepDone = (stepIdForEmit) => {
424
+ const justPushed = stepResults[stepResults.length - 1];
425
+ if (!justPushed)
426
+ return;
427
+ const haltReason = justPushed.haltReason;
428
+ emit("recipe_step_done", {
429
+ runSeq,
430
+ recipeName: recipe.name,
431
+ stepId: stepIdForEmit,
432
+ tool: justPushed.tool,
433
+ status: justPushed.status,
434
+ durationMs: justPushed.durationMs,
435
+ ...(justPushed.error !== undefined && { error: justPushed.error }),
436
+ ...(haltReason !== undefined && {
437
+ haltReason,
438
+ haltCategory: categoriseHaltReason(haltReason),
439
+ }),
440
+ ts: Date.now(),
441
+ });
442
+ };
443
+ // The step loop is wrapped so an uncaught throw from any unguarded
444
+ // call site (a `when`/prompt render on a malformed step, a path-jail
445
+ // re-check, etc.) cannot escape `runYamlRecipe` and strand the
446
+ // run-log entry at "running" forever. On throw we capture the
447
+ // message into `runError` and fall through to the normal
448
+ // finalization path, which marks the run "error".
449
+ try {
450
+ for (const step of recipe.steps) {
451
+ const stepIdForEmit = step.into ?? step.agent?.into ?? `step_${stepsRun}`;
452
+ const stepTs = Date.now();
453
+ stepStartTs.set(stepIdForEmit, stepTs);
454
+ emit("recipe_step_start", {
455
+ runSeq,
456
+ recipeName: recipe.name,
457
+ stepId: stepIdForEmit,
458
+ tool: step.agent ? "agent" : step.tool,
459
+ ts: stepTs,
460
+ });
461
+ // Evaluate `when` guard before running anything. Mirrors
462
+ // chainedRunner.ts:248-266 — render the template, then truthy-check the
463
+ // result (empty string, "0", "false", "null", "undefined" are falsy).
464
+ // A falsy guard records the step as `skipped`, increments stepsRun, and
465
+ // continues — it is NOT a failure. Bridge-dev iMessage recipes rely on
466
+ // this to suppress the iMessage agent step when phone is empty.
467
+ if (typeof step.when === "string" && step.when.length > 0) {
468
+ const rendered = render(step.when, ctx).trim().toLowerCase();
469
+ const truthy = !!rendered &&
470
+ rendered !== "0" &&
471
+ rendered !== "false" &&
472
+ rendered !== "null" &&
473
+ rendered !== "undefined";
474
+ if (!truthy) {
475
+ const skipId = step.into ?? step.agent?.into ?? `step_${stepsRun}`;
476
+ stepResults.push({
477
+ id: skipId,
478
+ tool: step.agent ? "agent" : step.tool,
479
+ status: "skipped",
480
+ durationMs: 0,
481
+ });
482
+ stepsRun++;
483
+ persistLiveStepResults();
484
+ emit("recipe_step_done", {
485
+ runSeq,
486
+ recipeName: recipe.name,
487
+ stepId: skipId,
488
+ tool: step.agent ? "agent" : step.tool,
489
+ status: "skipped",
490
+ durationMs: 0,
491
+ ts: Date.now(),
492
+ });
493
+ continue;
329
494
  }
330
- renderedPrompt += JUDGE_PROMPT_SUFFIX;
331
495
  }
332
- const intoKey = agentCfg.into ?? "agent_output";
333
- const stepId = intoKey;
334
- const stepStart = Date.now();
335
- let agentResult;
336
- // PR2b: per-recipe token budget. Admission check before dispatch;
337
- // reconcile actual consumption after. Subscription drivers
338
- // (Claude CLI, provider subprocess) report `usage === undefined`
339
- // `RunBudget.reconcile` records a fail-open warning per driver
340
- // per run and continues.
341
- const admission = runBudget.admit();
342
- if (!admission.admitted) {
343
- const reason = admission.reason ??
344
- "Run exceeded its token budget — budget_exceeded.";
345
- runError = runError ?? reason;
346
- stepResults.push({
347
- id: stepId,
348
- tool: "agent",
349
- status: "error",
350
- error: reason,
351
- haltReason: reason,
352
- durationMs: 0,
353
- });
354
- stepsRun++;
355
- persistLiveStepResults();
356
- continue;
357
- }
358
- try {
359
- const agentReturn = await _executeAgent({
360
- prompt: renderedPrompt,
361
- driver: agentCfg.driver === "api" ? "anthropic" : agentCfg.driver,
362
- model: agentCfg.model,
363
- ...(agentCfg.mcpAccess !== undefined && {
364
- mcpAccess: agentCfg.mcpAccess,
365
- }),
366
- }, buildAgentExecutorDeps(stepDeps, deps));
367
- agentResult = agentReturn.text;
368
- runBudget.reconcile(agentCfg.driver === "api" ? "anthropic" : (agentCfg.driver ?? "auto"), agentReturn.usage);
369
- // Catch both `[agent step failed: ...]` (existing) and the
370
- // silent-fail patterns `[agent step skipped: ...]` etc. via the
371
- // shared detector. Per-step opt-out via `silentFailDetection: false`.
372
- const agentSilentFail = step.silentFailDetection !== false
373
- ? detectSilentFail(agentResult)
374
- : null;
375
- if (agentResult.startsWith("[agent step failed:") || agentSilentFail) {
376
- const reason = agentSilentFail
377
- ? `silent-fail detected (${agentSilentFail.reason}): ${agentSilentFail.matched}`
378
- : agentResult;
496
+ // Handle agent steps separately
497
+ if (step.agent) {
498
+ const agentCfg = step.agent;
499
+ const isJudge = agentCfg.kind === "judge";
500
+ // PR3a: judge prompt convention. Append the structured-verdict
501
+ // suffix and, when `reviews: <stepId>` is set, inject the
502
+ // upstream step's output as an <artefact> block.
503
+ let renderedPrompt = render(agentCfg.prompt, ctx);
504
+ if (isJudge) {
505
+ if (agentCfg.reviews) {
506
+ renderedPrompt += buildJudgeArtefactBlock(ctx[agentCfg.reviews]);
507
+ }
508
+ renderedPrompt += JUDGE_PROMPT_SUFFIX;
509
+ }
510
+ const intoKey = agentCfg.into ?? "agent_output";
511
+ const stepId = intoKey;
512
+ const stepStart = Date.now();
513
+ let agentResult;
514
+ // PR2b: per-recipe token budget. Admission check before dispatch;
515
+ // reconcile actual consumption after. Subscription drivers
516
+ // (Claude CLI, provider subprocess) report `usage === undefined`
517
+ // — `RunBudget.reconcile` records a fail-open warning per driver
518
+ // per run and continues.
519
+ const admission = runBudget.admit();
520
+ if (!admission.admitted) {
521
+ const reason = admission.reason ??
522
+ "Run exceeded its token budget — budget_exceeded.";
379
523
  runError = runError ?? reason;
380
524
  stepResults.push({
381
525
  id: stepId,
382
526
  tool: "agent",
383
527
  status: "error",
384
528
  error: reason,
385
- haltReason: agentSilentFail
386
- ? `Agent step "${stepId}" returned no usable output (silent-fail: ${agentSilentFail.reason}).`
387
- : `Agent step "${stepId}" reported failure.`,
388
- durationMs: Date.now() - stepStart,
529
+ haltReason: reason,
530
+ durationMs: 0,
389
531
  });
532
+ stepsRun++;
533
+ persistLiveStepResults();
534
+ emitStepDone(stepIdForEmit);
535
+ continue;
390
536
  }
391
- else {
392
- const stripped = stripLeadingNarration(agentResult);
393
- if (!stripped.trim()) {
394
- const errMsg = `[agent step failed: ${agentCfg.driver ?? "agent"} returned only narration or whitespace — no content]`;
395
- runError = runError ?? errMsg;
537
+ try {
538
+ const agentReturn = await _executeAgent({
539
+ prompt: renderedPrompt,
540
+ driver: agentCfg.driver === "api" ? "anthropic" : agentCfg.driver,
541
+ model: agentCfg.model,
542
+ ...(agentCfg.mcpAccess !== undefined && {
543
+ mcpAccess: agentCfg.mcpAccess,
544
+ }),
545
+ }, buildAgentExecutorDeps(stepDeps, deps));
546
+ agentResult = agentReturn.text;
547
+ runBudget.reconcile(agentCfg.driver === "api"
548
+ ? "anthropic"
549
+ : (agentCfg.driver ?? "auto"), agentReturn.usage);
550
+ // Catch both `[agent step failed: ...]` (existing) and the
551
+ // silent-fail patterns `[agent step skipped: ...]` etc. via the
552
+ // shared detector. Per-step opt-out via `silentFailDetection: false`.
553
+ const agentSilentFail = step.silentFailDetection !== false
554
+ ? detectSilentFail(agentResult)
555
+ : null;
556
+ if (agentResult.startsWith("[agent step failed:") ||
557
+ agentSilentFail) {
558
+ const reason = agentSilentFail
559
+ ? `silent-fail detected (${agentSilentFail.reason}): ${agentSilentFail.matched}`
560
+ : agentResult;
561
+ runError = runError ?? reason;
396
562
  stepResults.push({
397
563
  id: stepId,
398
564
  tool: "agent",
399
565
  status: "error",
400
- error: errMsg,
401
- haltReason: `Agent step "${stepId}" returned only narration or whitespace — no content.`,
566
+ error: reason,
567
+ haltReason: agentSilentFail
568
+ ? `Agent step "${stepId}" returned no usable output (silent-fail: ${agentSilentFail.reason}).`
569
+ : `Agent step "${stepId}" reported failure.`,
402
570
  durationMs: Date.now() - stepStart,
403
571
  });
404
572
  }
405
573
  else {
406
- // Try to parse as JSON so dot-notation ({{meeting.field}}) works
574
+ const stripped = stripLeadingNarration(agentResult);
575
+ if (!stripped.trim()) {
576
+ const errMsg = `[agent step failed: ${agentCfg.driver ?? "agent"} returned only narration or whitespace — no content]`;
577
+ runError = runError ?? errMsg;
578
+ stepResults.push({
579
+ id: stepId,
580
+ tool: "agent",
581
+ status: "error",
582
+ error: errMsg,
583
+ haltReason: `Agent step "${stepId}" returned only narration or whitespace — no content.`,
584
+ durationMs: Date.now() - stepStart,
585
+ });
586
+ }
587
+ else {
588
+ // Try to parse as JSON so dot-notation ({{meeting.field}}) works
589
+ try {
590
+ const jsonMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(stripped) ?? [null, stripped];
591
+ const parsed = sanitizeParsed(JSON.parse((jsonMatch[1] ?? "").trim()));
592
+ ctx[intoKey] = parsed;
593
+ }
594
+ catch {
595
+ ctx[intoKey] = stripped;
596
+ }
597
+ outputs.push(intoKey);
598
+ // PR3a: parse + stash the judge verdict on the step result.
599
+ // Augment-only: a `request_changes` verdict still yields
600
+ // `status: "ok"`. The verdict surfaces via the runlog +
601
+ // future PR3b dashboard panel, but never gates the run.
602
+ const judgeVerdict = isJudge
603
+ ? parseJudgeVerdict(stripped)
604
+ : undefined;
605
+ stepResults.push({
606
+ id: stepId,
607
+ tool: "agent",
608
+ status: "ok",
609
+ ...(judgeVerdict !== undefined && { judgeVerdict }),
610
+ durationMs: Date.now() - stepStart,
611
+ });
612
+ }
613
+ }
614
+ }
615
+ catch (err) {
616
+ const msg = err instanceof Error ? err.message : String(err);
617
+ runError = runError ?? `agent step "${stepId}" failed: ${msg}`;
618
+ stepResults.push({
619
+ id: stepId,
620
+ tool: "agent",
621
+ status: "error",
622
+ error: msg,
623
+ haltReason: `Agent step "${stepId}" threw before completing: ${msg}`,
624
+ durationMs: Date.now() - stepStart,
625
+ });
626
+ }
627
+ stepsRun++;
628
+ persistLiveStepResults();
629
+ emitStepDone(stepIdForEmit);
630
+ continue;
631
+ }
632
+ const stepStart = Date.now();
633
+ const stepId = step.into ?? `step_${stepsRun}`;
634
+ // Resolve retry policy: step-level overrides recipe-level.
635
+ const retryCount = step.retry ?? recipe.on_error?.retry ?? 0;
636
+ const retryDelayMs = step.retryDelay ?? recipe.on_error?.retryDelay ?? 1000;
637
+ let result = null;
638
+ let stepError;
639
+ let thrownError;
640
+ let thrownErrorCode;
641
+ for (let attempt = 0; attempt <= retryCount; attempt++) {
642
+ if (attempt > 0) {
643
+ await new Promise((r) => setTimeout(r, retryDelayMs));
644
+ }
645
+ stepError = undefined;
646
+ thrownError = undefined;
647
+ thrownErrorCode = undefined;
648
+ try {
649
+ result = await executeStep(step, ctx, stepDeps);
650
+ // Detect tool-level errors reported as JSON {ok: false, error: ...}
651
+ if (result !== null) {
407
652
  try {
408
- const jsonMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(stripped) ?? [null, stripped];
409
- const parsed = sanitizeParsed(JSON.parse((jsonMatch[1] ?? "").trim()));
410
- ctx[intoKey] = parsed;
653
+ const parsed = JSON.parse(result);
654
+ if (parsed.ok === false && typeof parsed.error === "string") {
655
+ stepError = parsed.error;
656
+ }
411
657
  }
412
658
  catch {
413
- ctx[intoKey] = stripped;
659
+ /* non-JSON result is fine */
414
660
  }
415
- outputs.push(intoKey);
416
- // PR3a: parse + stash the judge verdict on the step result.
417
- // Augment-only: a `request_changes` verdict still yields
418
- // `status: "ok"`. The verdict surfaces via the runlog +
419
- // future PR3b dashboard panel, but never gates the run.
420
- const judgeVerdict = isJudge
421
- ? parseJudgeVerdict(stripped)
422
- : undefined;
423
- stepResults.push({
424
- id: stepId,
425
- tool: "agent",
426
- status: "ok",
427
- ...(judgeVerdict !== undefined && { judgeVerdict }),
428
- durationMs: Date.now() - stepStart,
429
- });
430
661
  }
662
+ // Silent-fail detection: tools that return string placeholders
663
+ // (`(git branches unavailable)`, `[agent step skipped: ...]`)
664
+ // or empty list-tool error shapes (`{count:0,error:"..."}`)
665
+ // succeed with bad data — flag them as `error` so the runner
666
+ // doesn't quietly hand garbage to a downstream agent. Per-step
667
+ // opt-out via `silentFailDetection: false`.
668
+ if (!stepError &&
669
+ result !== null &&
670
+ step.silentFailDetection !== false) {
671
+ const detected = detectSilentFail(result);
672
+ if (detected) {
673
+ stepError = `silent-fail detected (${detected.reason}): ${detected.matched}`;
674
+ }
675
+ }
676
+ }
677
+ catch (err) {
678
+ thrownError = err instanceof Error ? err.message : String(err);
679
+ // Preserve structured error codes (e.g. recipe_path_jail_escape)
680
+ // so callers and tests can branch on `err.code` per R2 M-4
681
+ // without scraping the message string.
682
+ const code = err?.code;
683
+ if (typeof code === "string")
684
+ thrownErrorCode = code;
685
+ result = null;
431
686
  }
687
+ if (!stepError && !thrownError)
688
+ break;
432
689
  }
433
- catch (err) {
434
- const msg = err instanceof Error ? err.message : String(err);
435
- runError = runError ?? `agent step "${stepId}" failed: ${msg}`;
690
+ // Recipe-level fallback: log_only / deliver_original treat step failure
691
+ // as non-fatal (fail-open) same semantics as step-level optional: true.
692
+ const fallback = recipe.on_error?.fallback;
693
+ const fallbackFailOpen = fallback === "log_only" || fallback === "deliver_original";
694
+ const failOpen = step.optional === true || fallbackFailOpen;
695
+ if (thrownError) {
696
+ const retryNote = retryCount > 0 ? ` after ${retryCount + 1} attempts` : "";
436
697
  stepResults.push({
437
698
  id: stepId,
438
- tool: "agent",
699
+ tool: step.tool,
439
700
  status: "error",
440
- error: msg,
441
- haltReason: `Agent step "${stepId}" threw before completing: ${msg}`,
701
+ error: thrownError,
702
+ ...(thrownErrorCode ? { errorCode: thrownErrorCode } : {}),
703
+ haltReason: `Tool "${step.tool ?? "?"}" in step "${stepId}" threw${retryNote}: ${thrownError}`,
442
704
  durationMs: Date.now() - stepStart,
443
705
  });
706
+ if (!failOpen) {
707
+ runError = runError ?? `${step.tool} failed: ${thrownError}`;
708
+ }
709
+ else if (fallbackFailOpen && !step.optional) {
710
+ console.warn(`step ${stepId} failed but on_error.fallback=${fallback} — treating as non-fatal: ${thrownError}`);
711
+ }
444
712
  }
445
- stepsRun++;
446
- persistLiveStepResults();
447
- continue;
448
- }
449
- const stepStart = Date.now();
450
- const stepId = step.into ?? `step_${stepsRun}`;
451
- // Resolve retry policy: step-level overrides recipe-level.
452
- const retryCount = step.retry ?? recipe.on_error?.retry ?? 0;
453
- const retryDelayMs = step.retryDelay ?? recipe.on_error?.retryDelay ?? 1000;
454
- let result = null;
455
- let stepError;
456
- let thrownError;
457
- let thrownErrorCode;
458
- for (let attempt = 0; attempt <= retryCount; attempt++) {
459
- if (attempt > 0) {
460
- await new Promise((r) => setTimeout(r, retryDelayMs));
461
- }
462
- stepError = undefined;
463
- thrownError = undefined;
464
- thrownErrorCode = undefined;
465
- try {
466
- result = await executeStep(step, ctx, stepDeps);
467
- // Detect tool-level errors reported as JSON {ok: false, error: ...}
468
- if (result !== null) {
469
- try {
470
- const parsed = JSON.parse(result);
471
- if (parsed.ok === false && typeof parsed.error === "string") {
472
- stepError = parsed.error;
713
+ else {
714
+ const finalStatus = result === null ? "skipped" : stepError ? "error" : "ok";
715
+ const retryNote = retryCount > 0 ? ` after ${retryCount + 1} attempts` : "";
716
+ stepResults.push({
717
+ id: stepId,
718
+ tool: step.tool,
719
+ status: finalStatus,
720
+ error: stepError,
721
+ ...(finalStatus === "error" && stepError
722
+ ? {
723
+ haltReason: `Tool "${step.tool ?? "?"}" in step "${stepId}" reported an error${retryNote}: ${stepError}`,
473
724
  }
725
+ : {}),
726
+ durationMs: Date.now() - stepStart,
727
+ });
728
+ if (stepError) {
729
+ if (!failOpen) {
730
+ runError = runError ?? `${step.tool} failed: ${stepError}`;
474
731
  }
475
- catch {
476
- /* non-JSON result is fine */
732
+ else if (fallbackFailOpen && !step.optional) {
733
+ console.warn(`step ${stepId} failed but on_error.fallback=${fallback} — treating as non-fatal: ${stepError}`);
477
734
  }
478
735
  }
479
- // Silent-fail detection: tools that return string placeholders
480
- // (`(git branches unavailable)`, `[agent step skipped: ...]`)
481
- // or empty list-tool error shapes (`{count:0,error:"..."}`)
482
- // succeed with bad dataflag them as `error` so the runner
483
- // doesn't quietly hand garbage to a downstream agent. Per-step
484
- // opt-out via `silentFailDetection: false`.
485
- if (!stepError &&
486
- result !== null &&
487
- step.silentFailDetection !== false) {
488
- const detected = detectSilentFail(result);
489
- if (detected) {
490
- stepError = `silent-fail detected (${detected.reason}): ${detected.matched}`;
736
+ }
737
+ stepsRun++;
738
+ if (result !== null) {
739
+ // Apply transform if presentrender template with $result injected
740
+ if (step.transform) {
741
+ try {
742
+ result = render(step.transform, { ...ctx, $result: result });
743
+ }
744
+ catch (err) {
745
+ // warn but fall through with original result
746
+ console.warn(`transform failed for step ${step.into ?? step.tool ?? "?"}: ${err}`);
491
747
  }
492
748
  }
493
- }
494
- catch (err) {
495
- thrownError = err instanceof Error ? err.message : String(err);
496
- // Preserve structured error codes (e.g. recipe_path_jail_escape)
497
- // so callers and tests can branch on `err.code` per R2 M-4
498
- // without scraping the message string.
499
- const code = err?.code;
500
- if (typeof code === "string")
501
- thrownErrorCode = code;
502
- result = null;
503
- }
504
- if (!stepError && !thrownError)
505
- break;
506
- }
507
- // Recipe-level fallback: log_only / deliver_original treat step failure
508
- // as non-fatal (fail-open) — same semantics as step-level optional: true.
509
- const fallback = recipe.on_error?.fallback;
510
- const fallbackFailOpen = fallback === "log_only" || fallback === "deliver_original";
511
- const failOpen = step.optional === true || fallbackFailOpen;
512
- if (thrownError) {
513
- const retryNote = retryCount > 0 ? ` after ${retryCount + 1} attempts` : "";
514
- stepResults.push({
515
- id: stepId,
516
- tool: step.tool,
517
- status: "error",
518
- error: thrownError,
519
- ...(thrownErrorCode ? { errorCode: thrownErrorCode } : {}),
520
- haltReason: `Tool "${step.tool ?? "?"}" in step "${stepId}" threw${retryNote}: ${thrownError}`,
521
- durationMs: Date.now() - stepStart,
522
- });
523
- if (!failOpen) {
524
- runError = runError ?? `${step.tool} failed: ${thrownError}`;
525
- }
526
- else if (fallbackFailOpen && !step.optional) {
527
- console.warn(`step ${stepId} failed but on_error.fallback=${fallback} — treating as non-fatal: ${thrownError}`);
528
- }
529
- }
530
- else {
531
- const finalStatus = result === null ? "skipped" : stepError ? "error" : "ok";
532
- const retryNote = retryCount > 0 ? ` after ${retryCount + 1} attempts` : "";
533
- stepResults.push({
534
- id: stepId,
535
- tool: step.tool,
536
- status: finalStatus,
537
- error: stepError,
538
- ...(finalStatus === "error" && stepError
539
- ? {
540
- haltReason: `Tool "${step.tool ?? "?"}" in step "${stepId}" reported an error${retryNote}: ${stepError}`,
749
+ if (step.into) {
750
+ ctx[step.into] = result;
751
+ if (step.tool) {
752
+ applyToolOutputContext(step.tool, step.into, result, ctx);
541
753
  }
542
- : {}),
543
- durationMs: Date.now() - stepStart,
544
- });
545
- if (stepError) {
546
- if (!failOpen) {
547
- runError = runError ?? `${step.tool} failed: ${stepError}`;
548
754
  }
549
- else if (fallbackFailOpen && !step.optional) {
550
- console.warn(`step ${stepId} failed but on_error.fallback=${fallback} treating as non-fatal: ${stepError}`);
755
+ if (step.tool === "file.write" || step.tool === "file.append") {
756
+ // R2 C-1 / F-02: re-validate the rendered path against the jail so a
757
+ // template substitution that survived earlier checks (e.g. via a
758
+ // chained sub-recipe deps override) cannot smuggle an out-of-jail
759
+ // path into the run log / dashboard outputs list.
760
+ const renderedPath = render(step.path, ctx);
761
+ outputs.push(resolveRecipePath(renderedPath, {
762
+ workspace: stepDeps.workdir,
763
+ write: true,
764
+ }));
551
765
  }
552
766
  }
767
+ persistLiveStepResults();
768
+ emitStepDone(stepIdForEmit);
553
769
  }
554
- stepsRun++;
555
- if (result !== null) {
556
- // Apply transform if present render template with $result injected
557
- if (step.transform) {
558
- try {
559
- result = render(step.transform, { ...ctx, $result: result });
560
- }
561
- catch (err) {
562
- // warn but fall through with original result
563
- console.warn(`transform failed for step ${step.into ?? step.tool ?? "?"}: ${err}`);
564
- }
565
- }
566
- if (step.into) {
567
- ctx[step.into] = result;
568
- if (step.tool) {
569
- applyToolOutputContext(step.tool, step.into, result, ctx);
570
- }
571
- }
572
- if (step.tool === "file.write" || step.tool === "file.append") {
573
- // R2 C-1 / F-02: re-validate the rendered path against the jail so a
574
- // template substitution that survived earlier checks (e.g. via a
575
- // chained sub-recipe deps override) cannot smuggle an out-of-jail
576
- // path into the run log / dashboard outputs list.
577
- const renderedPath = render(step.path, ctx);
578
- outputs.push(resolveRecipePath(renderedPath, {
579
- workspace: stepDeps.workdir,
580
- write: true,
581
- }));
582
- }
770
+ }
771
+ catch (err) {
772
+ const msg = err instanceof Error ? err.message : String(err);
773
+ runError = runError ?? `recipe run aborted: ${msg}`;
774
+ }
775
+ // Evaluate expect block before persisting so failures are stored in the
776
+ // run log. Guarded: a throw here must not skip finalization and strand
777
+ // the run at "running".
778
+ let assertionFailures = [];
779
+ if (recipe.expect) {
780
+ try {
781
+ assertionFailures = evaluateExpect({ stepsRun, outputs, context: ctx, errorMessage: runError }, recipe.expect);
782
+ }
783
+ catch (err) {
784
+ const msg = err instanceof Error ? err.message : String(err);
785
+ runError = runError ?? `expect evaluation failed: ${msg}`;
583
786
  }
584
- persistLiveStepResults();
585
787
  }
586
- // Evaluate expect block before persisting so failures are stored in the run log
587
- const assertionFailures = recipe.expect
588
- ? evaluateExpect({ stepsRun, outputs, context: ctx, errorMessage: runError }, recipe.expect)
589
- : [];
590
788
  // Write to RecipeRunLog so the dashboard Runs page shows this execution.
591
789
  // Bridge path: completeRun on the running entry opened above (live-tail).
592
790
  // CLI path: construct a local log + appendDirect (no live-tail).
@@ -615,6 +813,23 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
615
813
  outputTail,
616
814
  ...(runError !== undefined && { errorMessage: runError }),
617
815
  ...(assertionFailures.length > 0 ? { assertionFailures } : {}),
816
+ ...(inboxOutputs.length > 0 ? { inboxOutputs } : {}),
817
+ });
818
+ emit("recipe_done", {
819
+ runSeq,
820
+ recipeName: recipe.name,
821
+ status: runError ? "error" : "done",
822
+ durationMs: doneAt - recipeStartedAt,
823
+ stepCount: finalStepResults.length,
824
+ // A `done` run can still carry step errors — the runner
825
+ // continues past a non-fatal step failure. Surface it so
826
+ // live consumers can show "completed with errors".
827
+ hadStepErrors: finalStepResults.some((s) => s.status === "error"),
828
+ ...(runError !== undefined && { errorMessage: runError }),
829
+ ...(assertionFailures.length > 0 && {
830
+ assertionFailureCount: assertionFailures.length,
831
+ }),
832
+ ts: doneAt,
618
833
  });
619
834
  }
620
835
  else {
@@ -635,6 +850,7 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
635
850
  errorMessage: runError,
636
851
  stepResults: finalStepResults,
637
852
  ...(assertionFailures.length > 0 ? { assertionFailures } : {}),
853
+ ...(inboxOutputs.length > 0 ? { inboxOutputs } : {}),
638
854
  });
639
855
  }
640
856
  }
@@ -1359,11 +1575,17 @@ export async function dispatchRecipe(recipe, deps, seedContext = {}) {
1359
1575
  }
1360
1576
  return runChainedRecipe(chainedRecipe, options, deps.chainedDeps);
1361
1577
  }
1362
- // For non-chained recipes, lift `runLog` from chainedOptions onto the
1363
- // RunnerDeps so runYamlRecipe gets the bridge's singleton too.
1364
- return runYamlRecipe(recipe, deps.chainedOptions?.runLog
1365
- ? { ...deps, runLog: deps.chainedOptions.runLog }
1366
- : deps, seedContext);
1578
+ // For non-chained recipes, lift `runLog` AND `activityLog` from
1579
+ // chainedOptions onto the RunnerDeps so runYamlRecipe gets the
1580
+ // bridge's singletons too. The activityLog is what powers
1581
+ // recipe_started / recipe_step_start / recipe_step_done /
1582
+ // recipe_done SSE emission to dashboard subscribers.
1583
+ const lifted = { ...deps };
1584
+ if (deps.chainedOptions?.runLog)
1585
+ lifted.runLog = deps.chainedOptions.runLog;
1586
+ if (deps.chainedOptions?.activityLog)
1587
+ lifted.activityLog = deps.chainedOptions.activityLog;
1588
+ return runYamlRecipe(recipe, lifted, seedContext);
1367
1589
  }
1368
1590
  /** List all YAML recipes in a directory. Returns names. */
1369
1591
  export function listYamlRecipes(recipesDir) {