cool-workflow 0.1.96 → 0.1.98

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 (74) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/apps/architecture-review/app.json +1 -1
  4. package/apps/architecture-review-fast/app.json +1 -1
  5. package/apps/end-to-end-golden-path/app.json +1 -1
  6. package/apps/pr-review-fix-ci/app.json +1 -1
  7. package/apps/release-cut/app.json +1 -1
  8. package/apps/research-synthesis/app.json +1 -1
  9. package/dist/candidate-scoring.js +3 -3
  10. package/dist/capability-core.js +9 -1
  11. package/dist/capability-registry.js +7 -1
  12. package/dist/cli/command-surface.js +4 -0
  13. package/dist/cli/handlers/ledger.js +169 -0
  14. package/dist/cli/handlers/scheduling.js +7 -1
  15. package/dist/drive.js +108 -61
  16. package/dist/execution-backend/agent.js +84 -24
  17. package/dist/execution-backend.js +25 -5
  18. package/dist/ledger.js +313 -0
  19. package/dist/mcp/tool-call.js +36 -0
  20. package/dist/mcp/tool-definitions.js +26 -0
  21. package/dist/mcp-server.js +4 -0
  22. package/dist/onramp.js +2 -0
  23. package/dist/orchestrator/app-operations.js +6 -0
  24. package/dist/orchestrator/cli-options.js +8 -2
  25. package/dist/orchestrator/lifecycle-operations.js +40 -13
  26. package/dist/orchestrator/migration-operations.js +1 -1
  27. package/dist/orchestrator.js +11 -3
  28. package/dist/remote-source.js +10 -3
  29. package/dist/run-export.js +45 -5
  30. package/dist/sandbox-profile.js +6 -1
  31. package/dist/triggers.js +7 -1
  32. package/dist/version.js +1 -1
  33. package/dist/workbench-host.js +18 -2
  34. package/docs/agent-delegation-drive.7.md +4 -0
  35. package/docs/cli-mcp-parity.7.md +16 -2
  36. package/docs/contract-migration-tooling.7.md +4 -0
  37. package/docs/control-plane-scheduling.7.md +4 -0
  38. package/docs/cross-agent-ledger.7.md +217 -0
  39. package/docs/demo.7.md +80 -0
  40. package/docs/designs/handoff-ledger.md +145 -0
  41. package/docs/doctor.7.md +97 -0
  42. package/docs/durable-state-and-locking.7.md +4 -0
  43. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  44. package/docs/execution-backends.7.md +4 -0
  45. package/docs/fix.7.md +44 -0
  46. package/docs/handoff-setup.md +120 -0
  47. package/docs/init.7.md +62 -0
  48. package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
  49. package/docs/multi-agent-eval-replay-harness.7.md +4 -0
  50. package/docs/multi-agent-operator-ux.7.md +4 -0
  51. package/docs/node-snapshot-diff-replay.7.md +4 -0
  52. package/docs/observability-cost-accounting.7.md +4 -0
  53. package/docs/pipeline-verbs.7.md +93 -0
  54. package/docs/project-index.md +28 -5
  55. package/docs/real-execution-backends.7.md +4 -0
  56. package/docs/release-and-migration.7.md +4 -0
  57. package/docs/release-tooling.7.md +4 -0
  58. package/docs/routine.7.md +73 -0
  59. package/docs/run-registry-control-plane.7.md +4 -0
  60. package/docs/run-retention-reclamation.7.md +4 -0
  61. package/docs/state-explosion-management.7.md +4 -0
  62. package/docs/team-collaboration.7.md +4 -0
  63. package/docs/web-desktop-workbench.7.md +4 -0
  64. package/manifest/README.md +16 -10
  65. package/manifest/plugin.manifest.json +1 -1
  66. package/package.json +1 -1
  67. package/scripts/agents/agent-adapter-core.js +4 -1
  68. package/scripts/agents/codex-agent.js +34 -4
  69. package/scripts/canonical-apps.js +4 -4
  70. package/scripts/children/batch-delegate-child.js +40 -13
  71. package/scripts/children/http-delegate-child.js +2 -1
  72. package/scripts/dogfood-release.js +1 -1
  73. package/scripts/golden-path.js +4 -4
  74. package/scripts/release-flow.js +31 -17
package/dist/drive.js CHANGED
@@ -43,6 +43,8 @@ const observability_1 = require("./observability");
43
43
  const loop_expansion_1 = require("./loop-expansion");
44
44
  const telemetry_attestation_1 = require("./telemetry-attestation");
45
45
  const state_1 = require("./state");
46
+ const commit_1 = require("./commit");
47
+ const report_1 = require("./orchestrator/report");
46
48
  const trust_audit_1 = require("./trust-audit");
47
49
  const compare_1 = require("./compare");
48
50
  exports.DRIVE_SCHEMA_VERSION = 1;
@@ -183,7 +185,7 @@ function buildAgentRequest(ctx, run, task, manifest, preparedOutcome) {
183
185
  * fulfill step then settles it through runBackend instead of spawning again,
184
186
  * so the concurrent round and the serial step share EVERY envelope/accept
185
187
  * branch by construction. */
186
- function processSelectedTask(ctx, selected, preparedOutcome) {
188
+ function processSelectedTask(ctx, selected, preparedOutcome, deferPersist = false) {
187
189
  const { runner, runId } = ctx;
188
190
  let run = runner.loadRun(runId);
189
191
  // 1. DISPATCH (only a fresh pending task; a running task is a retry on its scope).
@@ -191,7 +193,7 @@ function processSelectedTask(ctx, selected, preparedOutcome) {
191
193
  let workerId = selected.workerId;
192
194
  let dispatched = false;
193
195
  if (selected.status === "pending") {
194
- const manifest = runner.dispatch(runId, { limit: 1, backend: selected.agentType || "agent" });
196
+ const manifest = runner.dispatch(runId, { limit: 1, backend: selected.agentType || "agent", ...(deferPersist ? { persistState: false } : {}) });
195
197
  const task = manifest.tasks.find((entry) => entry.id === selected.id) || manifest.tasks[0];
196
198
  if (!task || !task.workerId) {
197
199
  return step("dispatch", "failed", { runId, taskId: selected.id, phase: selected.phase, reason: "dispatch produced no worker scope" });
@@ -216,10 +218,10 @@ function processSelectedTask(ctx, selected, preparedOutcome) {
216
218
  emitProgress(`↺ ${selected.label || selected.id} (${selected.phase}) — accepting cached result`);
217
219
  try {
218
220
  node_fs_1.default.writeFileSync(manifest.resultPath, node_fs_1.default.readFileSync(cachePath, "utf8"), "utf8");
219
- runner.recordWorkerOutput(runId, workerId, manifest.resultPath, {});
221
+ runner.recordWorkerOutput(runId, workerId, manifest.resultPath, deferPersist ? { persistState: false } : {});
220
222
  }
221
223
  catch (error) {
222
- return handleHop(ctx, selected, workerId, `result cache rejected: ${error instanceof Error ? error.message : String(error)}`);
224
+ return handleHop(ctx, selected, workerId, `result cache rejected: ${error instanceof Error ? error.message : String(error)}`, deferPersist);
223
225
  }
224
226
  return step("accept", "ok", {
225
227
  runId,
@@ -233,7 +235,7 @@ function processSelectedTask(ctx, selected, preparedOutcome) {
233
235
  // CHILD run and bind its report back as this task's result. Leaf work is still
234
236
  // external-agent delegation at every level; CW imports no model SDK here.
235
237
  if (selected.subWorkflow) {
236
- return runSubWorkflow(ctx, run, selected, workerId, manifest);
238
+ return runSubWorkflow(ctx, run, selected, workerId, manifest, deferPersist);
237
239
  }
238
240
  emitProgress(`→ ${selected.label || selected.id} (${selected.phase}) — ${dispatched ? "dispatched, " : ""}spawning agent, may take minutes…`);
239
241
  const envelope = (0, execution_backend_1.runBackend)(buildAgentRequest(ctx, run, selected, manifest, preparedOutcome));
@@ -242,16 +244,17 @@ function processSelectedTask(ctx, selected, preparedOutcome) {
242
244
  const reportedUsage = handle?.metadata?.reportedUsage;
243
245
  const usageSignature = handle?.metadata?.usageSignature;
244
246
  if (envelope.status !== "completed") {
245
- return handleHop(ctx, selected, workerId, `agent hop ${envelope.status}: ${envelope.result.summary}`);
247
+ return handleHop(ctx, selected, workerId, `agent hop ${envelope.status}: ${envelope.result.summary}`, deferPersist);
246
248
  }
247
249
  // 3. ACCEPT — the SEPARATE recordWorkerOutput layer validates + records result.md.
248
250
  // A missing result.md is a failed hop (pre-checked so no terminal side effect);
249
251
  // an invalid result.md throws at validation BEFORE any state mutation.
250
252
  if (!manifest.resultPath || !node_fs_1.default.existsSync(manifest.resultPath)) {
251
- return handleHop(ctx, selected, workerId, "agent produced no result.md");
253
+ return handleHop(ctx, selected, workerId, "agent produced no result.md", deferPersist);
252
254
  }
253
255
  try {
254
256
  runner.recordWorkerOutput(runId, workerId, manifest.resultPath, {
257
+ ...(deferPersist ? { persistState: false } : {}),
255
258
  agentDelegation: {
256
259
  handle: handle,
257
260
  model: reportedModel,
@@ -270,7 +273,7 @@ function processSelectedTask(ctx, selected, preparedOutcome) {
270
273
  });
271
274
  }
272
275
  catch (error) {
273
- return handleHop(ctx, selected, workerId, `result.md rejected: ${error instanceof Error ? error.message : String(error)}`);
276
+ return handleHop(ctx, selected, workerId, `result.md rejected: ${error instanceof Error ? error.message : String(error)}`, deferPersist);
274
277
  }
275
278
  if (cachePath && manifest.resultPath && node_fs_1.default.existsSync(manifest.resultPath)) {
276
279
  writeResultCache(cachePath, node_fs_1.default.readFileSync(manifest.resultPath, "utf8"));
@@ -404,41 +407,61 @@ function writeResultCache(file, content) {
404
407
  * collect-all: a failed/hung/dirty hop never aborts its siblings, every hop
405
408
  * settles and is recorded (failures park via the same retryOrPark). */
406
409
  function driveConcurrentRound(ctx, limit) {
407
- const run = ctx.runner.loadRun(ctx.runId);
408
- const selected = selectDriveTask(run);
409
- const gate = terminalOrConfigStep(ctx, run, selected);
410
- if (gate)
411
- return [gate];
412
- const phase = (0, dispatch_1.firstRunnablePhase)(run);
413
- const width = Math.max(1, Math.floor(limit) || 1);
414
- const batch = run.tasks
415
- .filter((task) => phase.taskIds.includes(task.id) && (task.status === "pending" || task.status === "running"))
416
- .slice(0, width)
417
- .map((task) => task.id);
418
- // Phase A+B: dispatch every batch task (sequential dispatch mutates state),
419
- // then collect ALL spawn-style child outcomes in one concurrent window. The
420
- // token-budget gate ran at round entry; it is NOT re-checked between accepts —
421
- // the spawns already happened, and refusing to RECORD finished work would
422
- // discard real results (collect-all + never-claw-back). Overshoot is bounded
423
- // by the round width; the next round blocks.
424
- const prepared = prepareConcurrentOutcomes(ctx, batch);
425
- // Phase C: settle + accept in deterministic batch order, regardless of the
426
- // wall-clock order the children finished in.
427
- const steps = [];
428
- for (const taskId of batch) {
429
- const failStep = prepared.failSteps.get(taskId);
430
- if (failStep) {
431
- steps.push(failStep);
432
- continue;
410
+ // The whole round runs inside ONE cached in-memory run object (loadWithCache —
411
+ // reentrant, so a sub-workflow task's recursive drive() call cannot clobber this
412
+ // scope's cache). Every dispatch/accept in the round defers its own state.json
413
+ // write (persistState:false / deferPersist) and mutates that SAME shared object;
414
+ // the round flushes to disk exactly ONCE at the end instead of once per task —
415
+ // was O(N) full-state durable rewrites per round (measured: dominates wall time
416
+ // at scale), now O(1). A crash mid-round loses that round's dispatch/accept
417
+ // bookkeeping (bounded by the round width, i.e. limits.maxConcurrentAgents) and
418
+ // forces a safe-but-wasteful re-dispatch/re-spawn on the next drive never
419
+ // disk corruption or double-counting, since the atomic-rename+fsync write
420
+ // itself is untouched; only the write FREQUENCY changed.
421
+ return ctx.runner.loadWithCache(() => {
422
+ const run = ctx.runner.loadRun(ctx.runId);
423
+ const selected = selectDriveTask(run);
424
+ const gate = terminalOrConfigStep(ctx, run, selected);
425
+ if (gate)
426
+ return [gate];
427
+ const phase = (0, dispatch_1.firstRunnablePhase)(run);
428
+ const width = Math.max(1, Math.floor(limit) || 1);
429
+ const batch = run.tasks
430
+ .filter((task) => phase.taskIds.includes(task.id) && (task.status === "pending" || task.status === "running"))
431
+ .slice(0, width)
432
+ .map((task) => task.id);
433
+ // Phase A+B: dispatch every batch task (sequential — dispatch mutates state),
434
+ // then collect ALL spawn-style child outcomes in one concurrent window. The
435
+ // token-budget gate ran at round entry; it is NOT re-checked between accepts —
436
+ // the spawns already happened, and refusing to RECORD finished work would
437
+ // discard real results (collect-all + never-claw-back). Overshoot is bounded
438
+ // by the round width; the next round blocks.
439
+ const prepared = prepareConcurrentOutcomes(ctx, batch);
440
+ // Phase C: settle + accept in deterministic batch order, regardless of the
441
+ // wall-clock order the children finished in.
442
+ const steps = [];
443
+ for (const taskId of batch) {
444
+ const failStep = prepared.failSteps.get(taskId);
445
+ if (failStep) {
446
+ steps.push(failStep);
447
+ continue;
448
+ }
449
+ // Re-read per task: a prior accept in this round mutated state (the SAME
450
+ // cached object — no disk round-trip until the round-end flush below).
451
+ const freshRun = ctx.runner.loadRun(ctx.runId);
452
+ const fresh = freshRun.tasks.find((task) => task.id === taskId);
453
+ if (!fresh || (fresh.status !== "pending" && fresh.status !== "running"))
454
+ continue;
455
+ steps.push(processSelectedTask(ctx, fresh, prepared.outcomes.get(taskId), true));
433
456
  }
434
- // Re-read per task: a prior accept in this round mutated state.
435
- const freshRun = ctx.runner.loadRun(ctx.runId);
436
- const fresh = freshRun.tasks.find((task) => task.id === taskId);
437
- if (!fresh || (fresh.status !== "pending" && fresh.status !== "running"))
438
- continue;
439
- steps.push(processSelectedTask(ctx, fresh, prepared.outcomes.get(taskId)));
440
- }
441
- return steps.length > 0 ? steps : [driveStep(ctx)];
457
+ if (steps.length > 0) {
458
+ const settledRun = ctx.runner.loadRun(ctx.runId);
459
+ (0, commit_1.commitState)(settledRun, `concurrent-round:${batch.length}-tasks`);
460
+ (0, report_1.writeReport)(settledRun);
461
+ (0, state_1.saveCheckpoint)(settledRun);
462
+ }
463
+ return steps.length > 0 ? steps : [driveStep(ctx)];
464
+ });
442
465
  }
443
466
  /** Dispatch each batch task and run every spawn-style agent child concurrently
444
467
  * (one batch delegate child, per-job timeout kill). Returns outcomes keyed by
@@ -457,7 +480,7 @@ function prepareConcurrentOutcomes(ctx, batch) {
457
480
  continue;
458
481
  let workerId = task.workerId;
459
482
  if (task.status === "pending") {
460
- const manifest = runner.dispatch(runId, { limit: 1, backend: task.agentType || "agent" });
483
+ const manifest = runner.dispatch(runId, { limit: 1, backend: task.agentType || "agent", persistState: false });
461
484
  const dispatchedTask = manifest.tasks.find((entry) => entry.id === task.id) || manifest.tasks[0];
462
485
  if (!dispatchedTask || !dispatchedTask.workerId) {
463
486
  failSteps.set(taskId, step("dispatch", "failed", { runId, taskId, phase: task.phase, reason: "dispatch produced no worker scope" }));
@@ -475,6 +498,16 @@ function prepareConcurrentOutcomes(ctx, batch) {
475
498
  continue;
476
499
  const job = (0, execution_backend_1.prepareAgentSpawn)(buildAgentRequest(ctx, run, task, manifest));
477
500
  if (job) {
501
+ const sandboxPolicy = manifest.sandboxPolicy;
502
+ if (sandboxPolicy) {
503
+ const filteredEnv = (0, execution_backend_1.buildChildEnv)(sandboxPolicy);
504
+ for (const key of Object.keys(process.env)) {
505
+ if (/^(CW_|ANTHROPIC_|OPENAI_|GEMINI_|DEEPSEEK_|CODEX_|GOOGLE_|COHERE_|MISTRAL_|OLLAMA_|AZURE_|AWS_)/i.test(key)) {
506
+ filteredEnv[key] = process.env[key];
507
+ }
508
+ }
509
+ job.env = filteredEnv;
510
+ }
478
511
  jobs.push(job);
479
512
  jobTaskIds.push(taskId);
480
513
  }
@@ -489,7 +522,7 @@ function prepareConcurrentOutcomes(ctx, batch) {
489
522
  }
490
523
  /** A failed agent hop: charge one attempt and (reuse v0.1.37 retryOrPark) either
491
524
  * retry on the SAME worker scope next step, or PARK past the retry budget. */
492
- function handleHop(ctx, task, workerId, reason) {
525
+ function handleHop(ctx, task, workerId, reason, deferPersist = false) {
493
526
  const persisted = ctx.runner.showWorker(ctx.runId, workerId).retryCount || 0;
494
527
  const prior = Math.max(ctx.attempts.get(task.id) || 0, persisted);
495
528
  const entry = {
@@ -510,7 +543,8 @@ function handleHop(ctx, task, workerId, reason) {
510
543
  ctx.runner.recordWorkerFailure(ctx.runId, workerId, decided.parkedReason || reason, {
511
544
  code: "agent-delegation-parked",
512
545
  retryable: false,
513
- retryCount: attempts
546
+ retryCount: attempts,
547
+ ...(deferPersist ? { persistState: false } : {})
514
548
  });
515
549
  return step("park", "parked", {
516
550
  runId: ctx.runId,
@@ -522,7 +556,7 @@ function handleHop(ctx, task, workerId, reason) {
522
556
  });
523
557
  }
524
558
  // Retryable: leave the task running (scope reused) for the next step.
525
- (0, worker_isolation_1.recordWorkerRetryAttempt)(ctx.runner.loadRun(ctx.runId), workerId, decided.attempts || prior + 1, reason);
559
+ (0, worker_isolation_1.recordWorkerRetryAttempt)(ctx.runner.loadRun(ctx.runId), workerId, decided.attempts || prior + 1, reason, deferPersist ? { persist: false } : {});
526
560
  return step("fulfill", "failed", {
527
561
  runId: ctx.runId,
528
562
  taskId: task.id,
@@ -555,17 +589,17 @@ function renderSubInputs(spec, parentInputs) {
555
589
  * own telemetry/audit live in the child run; the parent records ONE honest
556
590
  * `worker.sub-workflow` cross-link (child run id + report digest + child audit
557
591
  * verdict) — nothing is summed or fabricated. */
558
- function runSubWorkflow(ctx, run, selected, workerId, manifest) {
592
+ function runSubWorkflow(ctx, run, selected, workerId, manifest, deferPersist = false) {
559
593
  const spec = selected.subWorkflow;
560
594
  const parentApp = run.workflow.id;
561
595
  // Fail-closed BEFORE planning a child (no child state minted when refused).
562
596
  if (ctx.depth + 1 > exports.MAX_SUB_WORKFLOW_DEPTH) {
563
- return handleHop(ctx, selected, workerId, `sub-workflow depth limit exceeded (> ${exports.MAX_SUB_WORKFLOW_DEPTH})`);
597
+ return handleHop(ctx, selected, workerId, `sub-workflow depth limit exceeded (> ${exports.MAX_SUB_WORKFLOW_DEPTH})`, deferPersist);
564
598
  }
565
599
  // Include the CURRENT app on the path, so a direct self-cycle (A→A) is caught at
566
600
  // depth 0 — before any child run dir is minted.
567
601
  if ([...ctx.visitedAppIds, parentApp].includes(spec.appId)) {
568
- return handleHop(ctx, selected, workerId, `sub-workflow cycle detected: ${[...ctx.visitedAppIds, parentApp, spec.appId].join(" -> ")}`);
602
+ return handleHop(ctx, selected, workerId, `sub-workflow cycle detected: ${[...ctx.visitedAppIds, parentApp, spec.appId].join(" -> ")}`, deferPersist);
569
603
  }
570
604
  // Deterministic child run id derived from the parent run + task (no clock/random).
571
605
  const childRunId = `sub-${run.id}-${(0, state_1.safeFileName)(selected.id)}`;
@@ -582,7 +616,7 @@ function runSubWorkflow(ctx, run, selected, workerId, manifest) {
582
616
  childRun = ctx.runner.plan(spec.appId, childInputs);
583
617
  }
584
618
  catch (error) {
585
- return handleHop(ctx, selected, workerId, `sub-workflow plan failed (${spec.appId}): ${errMessage(error)}`);
619
+ return handleHop(ctx, selected, workerId, `sub-workflow plan failed (${spec.appId}): ${errMessage(error)}`, deferPersist);
586
620
  }
587
621
  const childResult = drive(ctx.runner, childRun.id, {
588
622
  now: ctx.now,
@@ -593,7 +627,7 @@ function runSubWorkflow(ctx, run, selected, workerId, manifest) {
593
627
  visitedAppIds: [...ctx.visitedAppIds, parentApp]
594
628
  });
595
629
  if (childResult.status !== "complete") {
596
- return handleHop(ctx, selected, workerId, `sub-workflow ${spec.appId} did not complete (status: ${childResult.status})`);
630
+ return handleHop(ctx, selected, workerId, `sub-workflow ${spec.appId} did not complete (status: ${childResult.status})`, deferPersist);
597
631
  }
598
632
  // Bind the child's bytes: the rendered report (default) or the verdict result.
599
633
  const finalChild = ctx.runner.loadRun(childRun.id);
@@ -606,15 +640,15 @@ function runSubWorkflow(ctx, run, selected, workerId, manifest) {
606
640
  childBytes = node_fs_1.default.existsSync(finalChild.paths.report) ? node_fs_1.default.readFileSync(finalChild.paths.report, "utf8") : undefined;
607
641
  }
608
642
  if (childBytes === undefined) {
609
- return handleHop(ctx, selected, workerId, `sub-workflow ${spec.appId} produced no ${spec.bindResult || "report"}`);
643
+ return handleHop(ctx, selected, workerId, `sub-workflow ${spec.appId} produced no ${spec.bindResult || "report"}`, deferPersist);
610
644
  }
611
645
  // Accept through the SAME path as any other result (verifier/schema/evidence gate).
612
646
  try {
613
647
  node_fs_1.default.writeFileSync(manifest.resultPath, childBytes, "utf8");
614
- ctx.runner.recordWorkerOutput(run.id, workerId, manifest.resultPath, {});
648
+ ctx.runner.recordWorkerOutput(run.id, workerId, manifest.resultPath, deferPersist ? { persistState: false } : {});
615
649
  }
616
650
  catch (error) {
617
- return handleHop(ctx, selected, workerId, `sub-workflow result rejected by parent gate: ${errMessage(error)}`);
651
+ return handleHop(ctx, selected, workerId, `sub-workflow result rejected by parent gate: ${errMessage(error)}`, deferPersist);
618
652
  }
619
653
  // Honest cross-link (provenance only — never fails the accepted hop): one
620
654
  // worker.sub-workflow audit event on the parent pins the child run + report digest
@@ -720,6 +754,7 @@ function drive(runner, runId, options = {}) {
720
754
  return; // only the first not-yet-complete phase is "active"
721
755
  }
722
756
  };
757
+ let exhaustedMaxIterations = !options.once;
723
758
  for (let i = 0; i < maxIterations; i++) {
724
759
  const width = concurrency > 1 ? concurrency : autoWidth(runner.loadRun(runId));
725
760
  const roundSteps = width > 1 ? driveConcurrentRound(ctx, width) : [driveStep(ctx)];
@@ -741,27 +776,39 @@ function drive(runner, runId, options = {}) {
741
776
  // reuses the run we just advanced; goes to stderr via emitProgress so stdout is clean.
742
777
  emitPhaseProgress(runner.loadRun(runId));
743
778
  const last = roundSteps[roundSteps.length - 1];
744
- if (options.once)
779
+ if (options.once) {
780
+ exhaustedMaxIterations = false;
745
781
  break;
746
- if (last && (last.status === "complete" || last.status === "parked" || last.status === "blocked"))
782
+ }
783
+ if (last && (last.status === "complete" || last.status === "parked" || last.status === "blocked")) {
784
+ exhaustedMaxIterations = false;
747
785
  break;
786
+ }
748
787
  }
749
788
  const run = runner.loadRun(runId);
750
789
  const completedWorkers = countCompleted(run);
751
790
  const parkedWorkers = countParked(run);
752
791
  const committed = (run.commits || []).find((commit) => commit.reason && commit.reason.startsWith("agent-delegation-drive"));
753
792
  const last = steps[steps.length - 1];
793
+ if (exhaustedMaxIterations) {
794
+ steps.push(step("blocked", "blocked", {
795
+ runId,
796
+ reason: `drive reached max iteration limit (${maxIterations}) before a terminal state`
797
+ }));
798
+ }
754
799
  const status = options.once
755
800
  ? completedWorkers === plannedWorkers && committed
756
801
  ? "complete"
757
802
  : last && (last.status === "parked" || last.status === "blocked")
758
803
  ? last.status
759
804
  : "in-progress"
760
- : parkedWorkers > 0 || (last && last.status === "parked")
761
- ? "parked"
762
- : last && last.status === "blocked"
763
- ? "blocked"
764
- : "complete";
805
+ : exhaustedMaxIterations
806
+ ? "blocked"
807
+ : parkedWorkers > 0 || (last && last.status === "parked")
808
+ ? "parked"
809
+ : last && last.status === "blocked"
810
+ ? "blocked"
811
+ : "complete";
765
812
  return {
766
813
  schemaVersion: 1,
767
814
  runId,
@@ -12,6 +12,7 @@ exports.recordedAgentHandle = recordedAgentHandle;
12
12
  exports.extractEndpointResult = extractEndpointResult;
13
13
  exports.agentHandle = agentHandle;
14
14
  exports.prepareAgentSpawn = prepareAgentSpawn;
15
+ exports.reconcileBatchOutcomes = reconcileBatchOutcomes;
15
16
  exports.runAgentBatchOutcomes = runAgentBatchOutcomes;
16
17
  // Agent-delegation pure helpers + concurrent batch fulfillment for the
17
18
  // execution-backend driver layer. Carved out of execution-backend.ts
@@ -259,36 +260,95 @@ function prepareAgentSpawn(request) {
259
260
  // `node -e` string — F11). It reads jobs JSON on stdin, spawns ALL concurrently
260
261
  // (shell:false, inherited env — the agent's own credentials resolve; CW never
261
262
  // reads them), per-job SIGTERM at timeoutMs + SIGKILL at +5s, caps each captured
262
- // stdout at 32MB, and prints the outcome array when every job has settled. stderr
263
- // is drained (a full pipe must never wedge a child). A kill yields exitCode null —
264
- // the no-exit-code refusal. We spawn it BY PATH (shell:false); the path is
265
- // resolved from this compiled module (dist/execution-backend/agent.js) up to the
266
- // package's `scripts/children/` dir, which package.json ships in "files".
263
+ // stdout at 32MB, and streams ONE NDJSON line per job the instant it settles.
264
+ // stderr is drained (a full pipe must never wedge a child). A kill yields
265
+ // exitCode null — the no-exit-code refusal. We spawn it BY PATH (shell:false);
266
+ // the path is resolved from this compiled module (dist/execution-backend/agent.js)
267
+ // up to the package's `scripts/children/` dir, which package.json ships in "files".
267
268
  const BATCH_DELEGATE_CHILD_SCRIPT = node_path_1.default.resolve(__dirname, "..", "..", "scripts", "children", "batch-delegate-child.js");
269
+ /** Parse the delegate child's NDJSON stdout and reconcile it against `jobs` by
270
+ * index. Runs even when `child.error` is set (ENOBUFS from the combined
271
+ * output exceeding maxBuffer, ETIMEDOUT from the parent backstop, or a
272
+ * nonzero/null exit) — a batch-level failure must fail-close ONLY the jobs
273
+ * whose line never fully arrived, never every job in the batch: a job whose
274
+ * line already streamed through keeps its REAL outcome.
275
+ *
276
+ * `stdout` is split on the raw newline BYTE, on a Buffer, before any UTF-8
277
+ * decoding — never on a decoded string. 0x0A never appears inside a UTF-8
278
+ * continuation byte, so this is a safe boundary; decoding is deferred to
279
+ * ONE LINE at a time (bounded by the delegate's own 32MB-per-job cap), so
280
+ * no single decode ever approaches V8's hard per-string character ceiling
281
+ * regardless of how large the COMBINED batch output is. Decoding the whole
282
+ * combined buffer as one string up front (the prior approach) could itself
283
+ * throw past that ceiling for a large-enough batch — an uncaught crash, not
284
+ * a graceful `child.error` — which this line-at-a-time approach avoids by
285
+ * construction. The trailing split segment is always dropped before
286
+ * parsing (empty from a clean trailing newline, or a line truncated
287
+ * mid-write by a hard kill — either way, never a complete line), and one
288
+ * corrupt line can never crash the reconciliation of its siblings. */
289
+ function reconcileBatchOutcomes(jobs, child) {
290
+ const buf = Buffer.isBuffer(child.stdout) ? child.stdout : Buffer.from(String(child.stdout || ""), "utf8");
291
+ const byIndex = new Map();
292
+ let lineStart = 0;
293
+ for (let i = 0; i < buf.length; i++) {
294
+ if (buf[i] !== 0x0a)
295
+ continue;
296
+ const lineBuf = buf.subarray(lineStart, i);
297
+ lineStart = i + 1;
298
+ if (lineBuf.length === 0)
299
+ continue;
300
+ let parsed;
301
+ try {
302
+ parsed = JSON.parse(lineBuf.toString("utf8"));
303
+ }
304
+ catch {
305
+ continue;
306
+ }
307
+ if (typeof parsed.i !== "number" || parsed.i < 0 || parsed.i >= jobs.length)
308
+ continue;
309
+ byIndex.set(parsed.i, {
310
+ ...(parsed.spawnError ? { spawnError: parsed.spawnError } : {}),
311
+ exitCode: typeof parsed.exitCode === "number" ? parsed.exitCode : null,
312
+ stdout: String(parsed.stdout || "")
313
+ });
314
+ }
315
+ const reason = child.error
316
+ ? (0, util_1.messageOf)(child.error)
317
+ : typeof child.status === "number" && child.status !== 0
318
+ ? `batch delegate exited with ${child.status}`
319
+ : "batch delegate produced no outcome for this job";
320
+ return jobs.map((_, index) => byIndex.get(index) || { spawnError: `batch delegate failed: ${reason}`, exitCode: null, stdout: "" });
321
+ }
268
322
  /** Run a batch of agent spawns concurrently; outcomes index-align with jobs. The
269
323
  * parent backstop timeout (max job timeout + 30s) means even a wedged delegate
270
- * child cannot deadlock the drive: on any batch-level failure EVERY job settles
271
- * as a fail-closed spawn refusal never a fabricated completion, never a hang. */
324
+ * child cannot deadlock the drive. `maxBuffer` scales with batch size (the
325
+ * delegate's own per-job 32MB cap is the real safety bound no separate outer
326
+ * ceiling here, since a flat ceiling that stops scaling with job count is
327
+ * exactly what let one verbose batch strand its siblings before this fix).
328
+ * Collect-all is a real guarantee even under buffer/timeout pressure: a job
329
+ * whose NDJSON line fully streamed through keeps its real outcome regardless
330
+ * of what happens to the rest of the batch. */
272
331
  function runAgentBatchOutcomes(jobs) {
273
332
  if (!jobs.length)
274
333
  return [];
275
334
  const maxTimeout = Math.max(...jobs.map((job) => job.timeoutMs));
276
- const child = (0, node_child_process_1.spawnSync)(process.execPath, [BATCH_DELEGATE_CHILD_SCRIPT], {
277
- input: JSON.stringify(jobs),
278
- encoding: "utf8",
279
- maxBuffer: 33 * 1024 * 1024 * jobs.length,
280
- timeout: maxTimeout + 30000
281
- });
282
- if (!child.error && typeof child.status === "number" && child.status === 0) {
283
- try {
284
- const parsed = JSON.parse(String(child.stdout || ""));
285
- if (Array.isArray(parsed) && parsed.length === jobs.length)
286
- return parsed;
287
- }
288
- catch {
289
- // fall through to the fail-closed mapping below
290
- }
335
+ // No `encoding` option: keep stdout as a raw Buffer so reconcileBatchOutcomes
336
+ // can split on the newline byte and decode one line at a time — decoding the
337
+ // WHOLE combined buffer as one string up front could itself throw past V8's
338
+ // per-string character ceiling for a large-enough batch (an uncaught crash,
339
+ // not a graceful child.error). The try/catch below is a second backstop for
340
+ // any other unexpected native failure at this boundary — a wedged or
341
+ // over-limit delegate must fail every job closed, never crash the drive.
342
+ let child;
343
+ try {
344
+ child = (0, node_child_process_1.spawnSync)(process.execPath, [BATCH_DELEGATE_CHILD_SCRIPT], {
345
+ input: JSON.stringify(jobs),
346
+ maxBuffer: 34 * 1024 * 1024 * jobs.length,
347
+ timeout: maxTimeout + 30000
348
+ });
349
+ }
350
+ catch (error) {
351
+ child = { error: error instanceof Error ? error : new Error(String(error)), status: null, stdout: null };
291
352
  }
292
- const reason = child.error ? (0, util_1.messageOf)(child.error) : `batch delegate exited ${child.status === null ? "without an exit code (timed out or killed)" : `with ${child.status}`}`;
293
- return jobs.map(() => ({ spawnError: `batch delegate failed: ${reason}`, exitCode: null, stdout: "" }));
353
+ return reconcileBatchOutcomes(jobs, child);
294
354
  }
@@ -39,6 +39,7 @@ exports.attestSandbox = attestSandbox;
39
39
  exports.probeBackend = probeBackend;
40
40
  exports.runBackend = runBackend;
41
41
  exports.delegateChildScript = delegateChildScript;
42
+ exports.shouldStreamAgentStderr = shouldStreamAgentStderr;
42
43
  exports.createExecutionBackend = createExecutionBackend;
43
44
  exports.backendListPayload = backendListPayload;
44
45
  exports.backendShowPayload = backendShowPayload;
@@ -410,7 +411,7 @@ function executeLocal(descriptor, policy, request, label, attestation) {
410
411
  // Shell injection guard: reject args that contain shell control characters
411
412
  // (beyond template placeholders). The command itself is operator-configured.
412
413
  const shellArg = [command, ...args].join(" ").replace(/\{\{[a-zA-Z0-9_.-]+\}\}/g, "");
413
- if (/[;&|`$(){}<>!\n\r]/.test(shellArg)) {
414
+ if (/[;&|`$(){}<>!\n\r#*?~]/.test(shellArg)) {
414
415
  throw new Error(`Shell backend refused: args contain shell control characters. ` +
415
416
  `Use the node, bun, or agent backend instead for untrusted inputs.`);
416
417
  }
@@ -675,6 +676,25 @@ function runHttpDelegation(descriptor, policy, request, label, handle, attestati
675
676
  // agentSubstitutions, substituteAgentArg, recordedAgentHandle, extractEndpointResult,
676
677
  // agentHandle) and the concurrent batch live in ./execution-backend/agent.ts; the
677
678
  // stateful runners below build the refusal/delegated envelopes and stay here.
679
+ /** Decide whether cw FORWARDS the agent wrapper's live stderr view (stdio
680
+ * "inherit") or captures it ("pipe"). Default follows isTTY — interactive shows
681
+ * the live view, a pipe/CI stays silent (Rule of Silence). The two env knobs are
682
+ * explicit opt-out/opt-in, honored regardless of isTTY so a piped/CI run can still
683
+ * opt into the wrapper's plain append-only trace, exactly as the man page promises
684
+ * (agent-delegation-drive.7.md "Live output"):
685
+ * CW_NO_STREAM=1 — master off (wins over everything)
686
+ * CW_AGENT_STREAM=0 — off
687
+ * CW_AGENT_STREAM=1 — on, even without a TTY
688
+ * With no env set this returns isTTY — byte-identical to the prior inline gate (POLA). */
689
+ function shouldStreamAgentStderr(env, isTTY) {
690
+ if (env.CW_NO_STREAM === "1")
691
+ return false;
692
+ if (env.CW_AGENT_STREAM === "0")
693
+ return false;
694
+ if (env.CW_AGENT_STREAM === "1")
695
+ return true;
696
+ return isTTY;
697
+ }
678
698
  function runAgentProcess(descriptor, policy, request, label, handle, attestation) {
679
699
  const resolved = (0, agent_1.resolveAgentInvocation)(request);
680
700
  const subst = (0, agent_1.agentSubstitutions)(request, resolved.model);
@@ -692,10 +712,10 @@ function runAgentProcess(descriptor, policy, request, label, handle, attestation
692
712
  outcome = request.preparedAgentOutcome;
693
713
  }
694
714
  else {
695
- // Live output on by default when stderr is a TTY. stdout is always
696
- // captured as data. CI/pipes stay silent. CW_AGENT_STREAM=0 or
697
- // CW_NO_STREAM=1 forces off; CW_AGENT_STREAM=1 forces on.
698
- const streamStderr = process.env.CW_AGENT_STREAM !== "0" && Boolean(process.stderr.isTTY) && process.env.CW_NO_STREAM !== "1";
715
+ // Live output on by default when stderr is a TTY. stdout is always captured
716
+ // as data. CI/pipes stay silent unless CW_AGENT_STREAM=1 opts them into the
717
+ // wrapper's plain append-only trace; CW_AGENT_STREAM=0 / CW_NO_STREAM=1 force off.
718
+ const streamStderr = shouldStreamAgentStderr(process.env, Boolean(process.stderr.isTTY));
699
719
  // Build child env from sandbox policy as baseline (respects env.inherit/expose/deny),
700
720
  // then re-allow CW_* + well-known API key env vars the agent needs.
701
721
  const childEnv = buildChildEnv(policy);