cool-workflow 0.2.3 → 0.2.5

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 (143) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/.gemini-plugin/mcp.json +10 -0
  4. package/.gemini-plugin/plugin.json +40 -0
  5. package/.opencode-plugin/mcp.json +10 -0
  6. package/.opencode-plugin/plugin.json +40 -0
  7. package/README.md +202 -48
  8. package/apps/architecture-review/app.json +1 -1
  9. package/apps/architecture-review-fast/app.json +1 -1
  10. package/apps/end-to-end-golden-path/app.json +1 -1
  11. package/apps/pr-review-fix-ci/app.json +1 -1
  12. package/apps/release-cut/app.json +1 -1
  13. package/apps/research-synthesis/app.json +1 -1
  14. package/dist/cli/dispatch.js +33 -4
  15. package/dist/cli/entry.js +11 -19
  16. package/dist/cli/global-flags.js +113 -0
  17. package/dist/cli/io.js +6 -20
  18. package/dist/cli/parseargv.js +7 -1
  19. package/dist/core/capability-data.js +339 -10
  20. package/dist/core/format/help.js +80 -4
  21. package/dist/core/format/recovery-hint.js +32 -0
  22. package/dist/core/multi-agent/collaboration.js +35 -6
  23. package/dist/core/multi-agent/runtime.js +7 -0
  24. package/dist/core/multi-agent/trust-policy.js +7 -1
  25. package/dist/core/pipeline/contract.js +7 -0
  26. package/dist/core/pipeline/error-feedback.js +2 -2
  27. package/dist/core/trust/evidence-grounding.js +13 -1
  28. package/dist/core/util/cli-args.js +55 -0
  29. package/dist/core/version.js +1 -1
  30. package/dist/mcp/dispatch.js +22 -2
  31. package/dist/mcp/server.js +124 -13
  32. package/dist/mcp-server.js +20 -0
  33. package/dist/shell/commit.js +8 -2
  34. package/dist/shell/coordinator-io.js +73 -1
  35. package/dist/shell/drive.js +129 -64
  36. package/dist/shell/error-feedback-io.js +6 -0
  37. package/dist/shell/execution-backend/agent.js +205 -24
  38. package/dist/shell/execution-backend/container.js +44 -10
  39. package/dist/shell/execution-backend/local.js +47 -10
  40. package/dist/shell/fs-atomic.js +93 -12
  41. package/dist/shell/ledger-cli.js +9 -2
  42. package/dist/shell/multi-agent-cli.js +5 -1
  43. package/dist/shell/onramp.js +48 -5
  44. package/dist/shell/pipeline-cli.js +20 -2
  45. package/dist/shell/pipeline.js +2 -1
  46. package/dist/shell/reclamation-io.js +76 -7
  47. package/dist/shell/registry-cli.js +4 -0
  48. package/dist/shell/run-export.js +52 -4
  49. package/dist/shell/run-registry-io.js +1 -0
  50. package/dist/shell/run-store.js +156 -0
  51. package/dist/shell/scheduler-io.js +101 -10
  52. package/dist/shell/telemetry-ledger-io.js +36 -24
  53. package/dist/shell/trust-audit.js +104 -10
  54. package/dist/shell/workbench-host.js +121 -10
  55. package/dist/shell/workbench.js +79 -5
  56. package/dist/shell/worker-isolation.js +1 -1
  57. package/dist/shell/workflow-app-loader.js +67 -1
  58. package/dist/wiring/capability-table/basics.js +7 -2
  59. package/dist/wiring/capability-table/exec-backend.js +42 -24
  60. package/dist/wiring/capability-table/multi-agent.js +69 -69
  61. package/dist/wiring/capability-table/pipeline.js +60 -28
  62. package/dist/wiring/capability-table/registry-core.js +29 -6
  63. package/dist/wiring/capability-table/reporting.js +45 -39
  64. package/dist/wiring/capability-table/scheduling-registry.js +66 -60
  65. package/dist/wiring/capability-table/state.js +34 -34
  66. package/dist/wiring/capability-table/trust-ledger.js +66 -24
  67. package/dist/wiring/capability-table/workflow-apps.js +15 -15
  68. package/docs/agent-delegation-drive.7.md +17 -0
  69. package/docs/cli-mcp-parity.7.md +43 -13
  70. package/docs/contract-migration-tooling.7.md +4 -0
  71. package/docs/control-plane-scheduling.7.md +38 -0
  72. package/docs/cross-agent-ledger.7.md +20 -8
  73. package/docs/durable-state-and-locking.7.md +4 -0
  74. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  75. package/docs/execution-backends.7.md +4 -0
  76. package/docs/fix.7.md +4 -4
  77. package/docs/getting-started.md +40 -32
  78. package/docs/index.md +17 -0
  79. package/docs/launch/demo.tape +4 -3
  80. package/docs/mcp-app-surface.7.md +6 -0
  81. package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
  82. package/docs/multi-agent-eval-replay-harness.7.md +4 -0
  83. package/docs/multi-agent-operator-ux.7.md +4 -0
  84. package/docs/node-snapshot-diff-replay.7.md +4 -0
  85. package/docs/observability-cost-accounting.7.md +4 -0
  86. package/docs/project-index.md +43 -9
  87. package/docs/real-execution-backends.7.md +4 -0
  88. package/docs/release-and-migration.7.md +4 -0
  89. package/docs/release-tooling.7.md +22 -0
  90. package/docs/routine.7.md +22 -0
  91. package/docs/run-registry-control-plane.7.md +4 -0
  92. package/docs/run-retention-reclamation.7.md +28 -3
  93. package/docs/state-explosion-management.7.md +4 -0
  94. package/docs/team-collaboration.7.md +4 -0
  95. package/docs/trust-audit-anchor.7.md +4 -0
  96. package/docs/web-desktop-workbench.7.md +26 -2
  97. package/docs/workflow-app-framework.7.md +31 -0
  98. package/manifest/plugin.manifest.json +1 -1
  99. package/manifest/source-context-profiles.json +4 -3
  100. package/package.json +5 -2
  101. package/scripts/agents/agent-adapter-core.js +26 -0
  102. package/scripts/agents/claude-p-agent.js +4 -1
  103. package/scripts/agents/codex-agent.js +4 -0
  104. package/scripts/agents/cw-attest-wrap.js +1 -1
  105. package/scripts/agents/gemini-agent.js +4 -0
  106. package/scripts/agents/opencode-agent.js +5 -0
  107. package/scripts/block-unapproved-tag.js +160 -0
  108. package/scripts/bump-version.js +27 -10
  109. package/scripts/canonical-apps.js +4 -4
  110. package/scripts/children/batch-delegate-child.js +72 -4
  111. package/scripts/children/http-batch-delegate-child.js +132 -0
  112. package/scripts/children/http-delegate-child.js +8 -0
  113. package/scripts/dogfood-release.js +2 -2
  114. package/scripts/fake-date-for-reproduction.js +1 -1
  115. package/scripts/golden-path.js +4 -4
  116. package/scripts/lang-policy-check.js +82 -0
  117. package/scripts/purity-baseline.json +0 -30
  118. package/scripts/purity-gate.js +16 -3
  119. package/scripts/release-check.js +2 -1
  120. package/scripts/release-flow.js +264 -41
  121. package/scripts/release-gate.js +184 -0
  122. package/scripts/release-oneclick.js +438 -0
  123. package/scripts/release-tags.js +47 -0
  124. package/scripts/verdict-keygen.js +2 -2
  125. package/scripts/verify-bump-reproduction.js +193 -0
  126. package/scripts/verify-verdict-signature.js +1 -1
  127. package/scripts/version-sync-check.js +39 -22
  128. package/ui/workbench/app.css +41 -9
  129. package/ui/workbench/app.js +160 -27
  130. package/ui/workbench/index.html +2 -2
  131. package/docs/agent-framework.md +0 -177
  132. package/docs/designs/handoff-ledger.md +0 -145
  133. package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
  134. package/docs/launch/launch-kit.md +0 -195
  135. package/docs/launch/pre-launch-checklist.md +0 -53
  136. package/docs/readme-v0.1.87-full.md +0 -301
  137. package/docs/routines.md +0 -101
  138. package/docs/scheduled-tasks.md +0 -80
  139. package/scripts/agents/claude-p-agent.sh +0 -9
  140. package/scripts/block-unapproved-tag.sh +0 -60
  141. package/scripts/release-gate.sh +0 -94
  142. package/scripts/verify-bump-reproduction.sh +0 -148
  143. package/scripts/verify-container-selfref.js +0 -64
@@ -94,31 +94,43 @@ function loadTelemetryLedger(run) {
94
94
  /** Append one attestation record DURABLY to the append-only chain,
95
95
  * linking it to the prior record (or genesis). Returns the committed
96
96
  * record. Throws `TelemetryLedgerCorruptError` if the file on disk is
97
- * present but unparseable — never silently re-genesis. */
97
+ * present but unparseable — never silently re-genesis.
98
+ *
99
+ * The whole load->append->write is held under `withFileLock` (like every
100
+ * other read-modify-write in this codebase — see `recordTrustAuditEvent`).
101
+ * `writeJson` REPLACES the file, so without the lock two processes
102
+ * appending for the same run at once both read the same N-record ledger,
103
+ * both compute a record at chain position N+1, and the last atomic rename
104
+ * WINS — silently dropping the loser's record. The surviving chain still
105
+ * links correctly, so the loss is invisible to `verifyTelemetryLedger`.
106
+ * The `prevHash` and chain position MUST be read from the ledger loaded
107
+ * INSIDE the lock so the record links to the true current tail. */
98
108
  function appendTelemetryAttestation(run, input) {
99
- const ledger = loadTelemetryLedger(run);
100
- const now = input.now || new Date().toISOString();
101
- const prevHash = ledger.records.length ? ledger.records[ledger.records.length - 1].recordHash : (0, telemetry_ledger_1.genesisPrevHash)(run.id);
102
- const base = {
103
- schemaVersion: 1,
104
- runId: run.id,
105
- recordId: (0, telemetry_ledger_1.recordId)(ledger.records.length + 1),
106
- recordedAt: now,
107
- workerId: input.workerId,
108
- taskId: input.taskId,
109
- promptDigest: input.promptDigest,
110
- reportedUsageDigest: (0, telemetry_ledger_1.reportedUsageDigest)(input.reportedUsage),
111
- ...(input.reportedUsage ? { reportedUsage: input.reportedUsage } : {}),
112
- usageSignature: input.usageSignature,
113
- ...(input.resultDigest ? { resultDigest: input.resultDigest } : {}),
114
- attestation: input.attestation,
115
- attestationReason: input.attestationReason,
116
- prevHash,
117
- };
118
- const record = { ...base, recordHash: (0, telemetry_ledger_1.computeRecordHash)(base) };
119
- ledger.records.push(record);
120
- (0, fs_atomic_1.writeJson)(telemetryLedgerPath(run), ledger, { durable: true });
121
- return record;
109
+ return (0, fs_atomic_1.withFileLock)(telemetryLedgerPath(run), () => {
110
+ const ledger = loadTelemetryLedger(run);
111
+ const now = input.now || new Date().toISOString();
112
+ const prevHash = ledger.records.length ? ledger.records[ledger.records.length - 1].recordHash : (0, telemetry_ledger_1.genesisPrevHash)(run.id);
113
+ const base = {
114
+ schemaVersion: 1,
115
+ runId: run.id,
116
+ recordId: (0, telemetry_ledger_1.recordId)(ledger.records.length + 1),
117
+ recordedAt: now,
118
+ workerId: input.workerId,
119
+ taskId: input.taskId,
120
+ promptDigest: input.promptDigest,
121
+ reportedUsageDigest: (0, telemetry_ledger_1.reportedUsageDigest)(input.reportedUsage),
122
+ ...(input.reportedUsage ? { reportedUsage: input.reportedUsage } : {}),
123
+ usageSignature: input.usageSignature,
124
+ ...(input.resultDigest ? { resultDigest: input.resultDigest } : {}),
125
+ attestation: input.attestation,
126
+ attestationReason: input.attestationReason,
127
+ prevHash,
128
+ };
129
+ const record = { ...base, recordHash: (0, telemetry_ledger_1.computeRecordHash)(base) };
130
+ ledger.records.push(record);
131
+ (0, fs_atomic_1.writeJson)(telemetryLedgerPath(run), ledger, { durable: true });
132
+ return record;
133
+ });
122
134
  }
123
135
  /** Re-prove the whole telemetry chain for a run, reading from disk.
124
136
  * Absent -> present:false/verified:true (nothing to prove). Corrupt ->
@@ -85,8 +85,15 @@ function ensureTrustAudit(run) {
85
85
  run.paths.auditDir = dir;
86
86
  const audit = { schemaVersion: 1, ...trustAuditPaths(run) };
87
87
  run.audit = audit;
88
+ // Create-if-missing must NEVER truncate: this runs OUTSIDE the append
89
+ // lock, so between an existsSync(false) here and a plain create, another
90
+ // process may have already created the log AND appended real events —
91
+ // a truncating create then silently deletes them (one event lost, chain
92
+ // still verifies "clean" from genesis; seen once on CI under coverage
93
+ // I/O). Flag "a" is O_CREAT without O_TRUNC: it makes the file when
94
+ // missing and adds zero bytes when not, with no exists-check window.
88
95
  if (!fs.existsSync(audit.eventLogPath))
89
- fs.writeFileSync(audit.eventLogPath, "", "utf8");
96
+ fs.writeFileSync(audit.eventLogPath, "", { encoding: "utf8", flag: "a" });
90
97
  return audit;
91
98
  }
92
99
  /** Genesis prevHash for a run's chain (no prior event). Exported so
@@ -491,7 +498,22 @@ function recordTrustAuditEvent(run, input) {
491
498
  event.id = createEventId(input.kind, count);
492
499
  event.prevEventHash = prevHash;
493
500
  event.eventHash = computeEventHash(event);
494
- const line = `${JSON.stringify(event)}\n`;
501
+ // Newline-boundary safety (fail-closed). `durableAppendFileSync` only ever
502
+ // ADDS bytes at the end of file and never writes a separator of its own. A
503
+ // completed append always leaves the log ending in "\n"; if the last byte
504
+ // is NOT "\n", the previous append was torn by a crash (its bytes were
505
+ // never a confirmed event — the append never returned). Writing this new,
506
+ // already-cross-linked event straight onto that partial byte-run would
507
+ // MERGE the two into one line that no longer parses — losing THIS event and
508
+ // poisoning the forward chain (the next append's prevEventHash would point
509
+ // into an unparseable blob), with no repair for that shape. So put the new
510
+ // event on its own clean line: prepend a "\n" when the log does not already
511
+ // end in one, confining any crash artifact to its own now-orphaned line.
512
+ // (Reads only the last byte, so the O(1) tail-cache path is preserved.)
513
+ // An empty log (currentBytes === 0, e.g. the first append) has no tail to
514
+ // merge with, so it never needs a leading newline.
515
+ const leadingNewline = currentBytes > 0 && !(0, fs_atomic_1.logEndsWithNewline)(audit.eventLogPath, currentBytes) ? "\n" : "";
516
+ const line = `${leadingNewline}${JSON.stringify(event)}\n`;
495
517
  (0, fs_atomic_1.durableAppendFileSync)(audit.eventLogPath, line);
496
518
  writeAuditTailCache(tailCachePath, { schemaVersion: 1, logBytes: currentBytes + Buffer.byteLength(line, "utf8"), count: count + 1, lastHash: event.eventHash });
497
519
  return event;
@@ -634,9 +656,64 @@ function commitRows(events, run) {
634
656
  };
635
657
  });
636
658
  }
659
+ // Perf cycle P1 (read side): every read command (`cw status`, each
660
+ // `writeReport` call, multi-agent status) called `summarizeTrustAudit`,
661
+ // which read+parsed the log via `readEventsRaw`, THEN called
662
+ // `verifyTrustAudit(run)`, which read+parsed the SAME log AGAIN and
663
+ // recomputed a sha256 for every event -- two full passes for one call.
664
+ // It then durably (fsync) rewrote summary.json AND index.json on EVERY
665
+ // call, even when nothing about the run or the log had changed since the
666
+ // last call (a live audit reaches ~50k events, so `writeReport` alone --
667
+ // which calls this once per pipeline step and per feedback op -- turned
668
+ // into a real, repeated cost).
669
+ //
670
+ // Fix, in two parts:
671
+ // 1. Read the log ONCE (`readEventsRawCounted`) and hand the SAME parsed
672
+ // array to `verifyEventsChain` directly -- the exact same check
673
+ // `verifyTrustAudit` runs with no anchor, so the integrity result is
674
+ // byte-identical to before. `verifyTrustAudit` itself is untouched and
675
+ // every OTHER caller (doctor.ts, `cw audit verify`, drive.ts,
676
+ // run-export.ts) still gets a real, independent, always-full check.
677
+ // 2. Skip the durable rewrite of summary.json/index.json when the freshly
678
+ // and FULLY recomputed content is unchanged from last time. This is
679
+ // NOT a shortcut on verification -- the full parse+rehash+chain-walk
680
+ // above always runs, on every call, no exceptions. A cache that
681
+ // reused a stale "verified" verdict would be unsound here: this
682
+ // codebase's chain check is a sequential field-comparison walk, not a
683
+ // content-addressed (Merkle) chain, so tampering with an EARLY event
684
+ // can leave the file's total size and its LAST event's bytes fully
685
+ // unchanged while still flipping `verified` to false. So the cache
686
+ // below only ever gates the DISK WRITE of an already fully-verified
687
+ // result, never the verification itself, and the function's RETURN
688
+ // VALUE is always the fresh `summary` object either way.
689
+ function summaryFingerprintPathFor(summaryPath) {
690
+ return path.join(path.dirname(summaryPath), "summary-fingerprint.json");
691
+ }
692
+ function readSummaryFingerprint(fingerprintPath) {
693
+ if (!fs.existsSync(fingerprintPath))
694
+ return undefined;
695
+ try {
696
+ const parsed = JSON.parse(fs.readFileSync(fingerprintPath, "utf8"));
697
+ if (parsed && parsed.schemaVersion === 1 && typeof parsed.hash === "string")
698
+ return parsed.hash;
699
+ }
700
+ catch {
701
+ // Corrupt/unreadable fingerprint -- fall back to a real rewrite below.
702
+ }
703
+ return undefined;
704
+ }
705
+ function writeSummaryFingerprint(fingerprintPath, fingerprint) {
706
+ try {
707
+ (0, fs_atomic_1.writeJson)(fingerprintPath, fingerprint);
708
+ }
709
+ catch {
710
+ // Best-effort: a failed fingerprint write must never break the real call
711
+ // -- it just means the next call falls back to a real rewrite.
712
+ }
713
+ }
637
714
  function summarizeTrustAudit(run) {
638
715
  const audit = ensureTrustAudit(run);
639
- const events = readEventsRaw(audit.eventLogPath);
716
+ const { events, corruptLines } = readEventsRawCounted(audit.eventLogPath);
640
717
  const ma = run.multiAgent;
641
718
  const bb = run.blackboard;
642
719
  const summary = {
@@ -644,7 +721,7 @@ function summarizeTrustAudit(run) {
644
721
  runId: run.id,
645
722
  generatedAt: new Date().toISOString(),
646
723
  eventCount: events.length,
647
- integrity: verifyTrustAudit(run),
724
+ integrity: verifyEventsChain(run.id, events, corruptLines),
648
725
  eventLogPath: audit.eventLogPath,
649
726
  indexPath: audit.indexPath,
650
727
  summaryPath: audit.summaryPath,
@@ -688,11 +765,7 @@ function summarizeTrustAudit(run) {
688
765
  policyViolations: events.filter((e) => e.kind === "policy.violation").length,
689
766
  },
690
767
  };
691
- // Durable: the summary/index are the read-side view of the audit log; persist
692
- // them durably so a crash can't leave them pointing past the last durably-
693
- // appended event. Byte-behavior port of the old build's summarizeTrustAudit.
694
- (0, fs_atomic_1.writeJson)(audit.summaryPath, summary, { durable: true });
695
- (0, fs_atomic_1.writeJson)(audit.indexPath, {
768
+ const index = {
696
769
  schemaVersion: exports.TRUST_AUDIT_SCHEMA_VERSION,
697
770
  runId: run.id,
698
771
  events: events.map((event) => ({
@@ -707,7 +780,28 @@ function summarizeTrustAudit(run) {
707
780
  sandboxProfileId: event.sandboxProfileId,
708
781
  policyRef: event.policyRef,
709
782
  })),
710
- }, { durable: true });
783
+ };
784
+ // Durable: the summary/index are the read-side view of the audit log; persist
785
+ // them durably so a crash can't leave them pointing past the last durably-
786
+ // appended event. Byte-behavior port of the old build's summarizeTrustAudit.
787
+ //
788
+ // Skip the rewrite only when the freshly, fully recomputed content is
789
+ // unchanged from last time (see the comment on `summaryFingerprintPathFor`
790
+ // above) -- `generatedAt` is dropped before fingerprinting since it is the
791
+ // one field that legitimately changes on every call. The RETURNED
792
+ // `summary` below always carries a real, fresh `generatedAt` either way;
793
+ // only the ON-DISK bytes may keep the previous call's `generatedAt` when
794
+ // nothing else changed.
795
+ const { generatedAt: _generatedAt, ...summaryForFingerprint } = summary;
796
+ void _generatedAt;
797
+ const fingerprintPath = summaryFingerprintPathFor(audit.summaryPath);
798
+ const freshFingerprint = (0, hash_1.stableHash)({ summary: summaryForFingerprint, index });
799
+ const priorFingerprint = readSummaryFingerprint(fingerprintPath);
800
+ if (priorFingerprint !== freshFingerprint) {
801
+ (0, fs_atomic_1.writeJson)(audit.summaryPath, summary, { durable: true });
802
+ (0, fs_atomic_1.writeJson)(audit.indexPath, index, { durable: true });
803
+ writeSummaryFingerprint(fingerprintPath, { schemaVersion: 1, hash: freshFingerprint });
804
+ }
711
805
  run.audit = { schemaVersion: exports.TRUST_AUDIT_SCHEMA_VERSION, ...audit };
712
806
  return summary;
713
807
  }
@@ -52,13 +52,55 @@ var __importStar = (this && this.__importStar) || (function () {
52
52
  })();
53
53
  Object.defineProperty(exports, "__esModule", { value: true });
54
54
  exports.WorkbenchHost = void 0;
55
+ exports.formatServeHint = formatServeHint;
56
+ exports.printServeHint = printServeHint;
55
57
  const crypto = __importStar(require("node:crypto"));
56
58
  const fs = __importStar(require("node:fs"));
57
59
  const http = __importStar(require("node:http"));
58
60
  const path = __importStar(require("node:path"));
59
61
  const node_url_1 = require("node:url");
60
62
  const workbench_1 = require("./workbench");
63
+ // `[::1]` is the form Node's URL parser returns for a bracketed IPv6
64
+ // literal Host header (verified); the bare `::1` covers a non-bracketed
65
+ // header that our fallback split still yields. `.split(":")[0]` alone could
66
+ // never produce either (it returns `[` or ``), so those entries used to be
67
+ // dead — parseHostname below fixes that.
61
68
  const ALLOWED_HOSTNAMES = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
69
+ /** Extract the hostname from a Host header, correctly handling a bracketed
70
+ * IPv6 literal (`[::1]:7717`) that a naive `split(":")[0]` mangles into
71
+ * `"["`. Returns undefined when the header cannot be parsed at all. */
72
+ function parseHostname(hostHeader) {
73
+ try {
74
+ return new node_url_1.URL(`http://${hostHeader}`).hostname;
75
+ }
76
+ catch {
77
+ // A non-bracketed IPv6 literal (`::1`) is not a valid URL authority, so
78
+ // URL() throws — fall back to the raw header for that one case; a
79
+ // hostname:port still trims the port.
80
+ if (hostHeader.includes("]") || hostHeader.split(":").length > 2)
81
+ return hostHeader;
82
+ return hostHeader.split(":")[0];
83
+ }
84
+ }
85
+ function isTTY(stream) {
86
+ return Boolean(stream.isTTY);
87
+ }
88
+ /** Pure text for the human-friendly serve hint line. Kept separate from
89
+ * the TTY gate below so it is directly unit-testable with no fake stream
90
+ * at all: just a port number in, one line of text out. */
91
+ function formatServeHint(boundPort) {
92
+ return `workbench serving at http://127.0.0.1:${boundPort} (Ctrl-C to stop)`;
93
+ }
94
+ /** Prints the human hint line to STDERR only, and only on a real
95
+ * interactive terminal — the same TTY-gated-nicety pattern as
96
+ * term.ts's printSuccessSummary: silent when the stream is not a TTY, so
97
+ * a piped/non-interactive run (and the existing STDOUT descriptor line)
98
+ * never changes at all. */
99
+ function printServeHint(boundPort, stream = process.stderr) {
100
+ if (!isTTY(stream))
101
+ return;
102
+ stream.write(`${formatServeHint(boundPort)}\n`);
103
+ }
62
104
  function timingSafeEqual(a, b) {
63
105
  const bufA = Buffer.from(a, "utf8");
64
106
  const bufB = Buffer.from(b, "utf8");
@@ -72,6 +114,13 @@ function timingSafeEqual(a, b) {
72
114
  return crypto.timingSafeEqual(bufA, bufB);
73
115
  }
74
116
  function sendJson(res, status, body) {
117
+ // If a handler already began the response (e.g. an asset read threw AFTER
118
+ // writeHead), a second writeHead throws ERR_HTTP_HEADERS_SENT synchronously
119
+ // inside the request listener and kills the whole server. Guard against it.
120
+ if (res.headersSent) {
121
+ res.end();
122
+ return;
123
+ }
75
124
  const text = `${JSON.stringify(body, null, 2)}\n`;
76
125
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
77
126
  res.end(text);
@@ -124,8 +173,8 @@ class WorkbenchHost {
124
173
  try {
125
174
  const hostHeader = req.headers.host;
126
175
  if (hostHeader) {
127
- const hostname = hostHeader.split(":")[0];
128
- if (!ALLOWED_HOSTNAMES.has(hostname)) {
176
+ const hostname = parseHostname(hostHeader);
177
+ if (hostname === undefined || !ALLOWED_HOSTNAMES.has(hostname)) {
129
178
  sendJson(res, 403, { error: "forbidden: non-localhost Host header" });
130
179
  return;
131
180
  }
@@ -143,10 +192,6 @@ class WorkbenchHost {
143
192
  sendJson(res, 400, { error: "bad request: invalid URL" });
144
193
  return;
145
194
  }
146
- if (!this.checkAuth(req, url)) {
147
- sendJson(res, 401, { error: "unauthorized: token mismatch" });
148
- return;
149
- }
150
195
  // Decode the route path ONCE (old build's decodeRoutePath). Node's
151
196
  // `new URL()` does NOT throw on a malformed percent-escape like
152
197
  // `/%E0%A4%A`; only decodeURIComponent does — so this is where a
@@ -162,13 +207,36 @@ class WorkbenchHost {
162
207
  sendJson(res, 400, { error: "bad request: malformed URL path" });
163
208
  return;
164
209
  }
210
+ // Auth is checked AFTER the route is decoded, and only where run data
211
+ // (or environment data) can flow. Before this, a set token made the
212
+ // browser's own follow-up requests for /ui/app.css and /ui/app.js fail
213
+ // 401 — the page rendered as broken unstyled HTML with no explanation.
214
+ // The three shipped UI files are generic static code with no run data,
215
+ // so they are served without a token. Every /api/* route carries run
216
+ // data and stays behind the token. The "/" route is split: an
217
+ // INSTALLED index.html is the same generic static code (open), but the
218
+ // FALLBACK page embeds the serve descriptor — which carries the
219
+ // absolute repo root path — so a missing UI keeps "/" behind the token.
220
+ if (!this.checkAuth(req, url)) {
221
+ const uiIndexInstalled = fs.existsSync(path.resolve((0, workbench_1.workbenchUiRoot)(), "index.html"));
222
+ if (route.startsWith("/api/") || (route === "/" && !uiIndexInstalled)) {
223
+ sendJson(res, 401, { error: "unauthorized: token mismatch" });
224
+ return;
225
+ }
226
+ }
165
227
  if (route === "/api/serve") {
166
228
  sendJson(res, 200, this.descriptor(false));
167
229
  return;
168
230
  }
169
231
  if (route === "/api/index") {
170
232
  const scope = this.args.scope === "home" ? "home" : "repo";
171
- sendJson(res, 200, (0, workbench_1.buildWorkbenchIndex)({ ...this.args, scope, ...Object.fromEntries(url.searchParams.entries()) }));
233
+ // Only `text` may be driven by the query string — it is the sidebar
234
+ // filter. Spreading ALL query params (as before) let a request
235
+ // override the server's computed `scope` or inject an arbitrary
236
+ // `cwd`, reading run data from any directory the serving user can
237
+ // read, past the repo the operator chose to serve.
238
+ const text = url.searchParams.get("text") || undefined;
239
+ sendJson(res, 200, (0, workbench_1.buildWorkbenchIndex)({ ...this.args, scope, ...(text ? { text } : {}) }));
172
240
  return;
173
241
  }
174
242
  const runMatch = route.match(/^\/api\/run\/([^/]+)$/);
@@ -204,15 +272,30 @@ class WorkbenchHost {
204
272
  sendJson(res, 404, { error: `UI asset not installed: ${relative}` });
205
273
  return;
206
274
  }
275
+ // Read the bytes BEFORE writeHead: if the asset vanished or became
276
+ // unreadable between the checks above and here (TOCTOU), the throw
277
+ // then lands on the sendJson(500) path with headers not yet sent,
278
+ // instead of a fatal double-writeHead.
279
+ const bytes = fs.readFileSync(resolved);
207
280
  res.writeHead(200, { "Content-Type": contentTypeFor(resolved), "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
208
- res.end(fs.readFileSync(resolved));
281
+ res.end(bytes);
209
282
  }
210
283
  /** Binds the loopback server and resolves once listening. Returns the
211
284
  * actually-bound port (useful for `--port 0`). */
212
285
  listen() {
213
286
  return new Promise((resolve, reject) => {
287
+ // Defense in depth: `run()` already validated the port before ever
288
+ // reaching here, but a direct caller of listen() still fails closed
289
+ // with the clear message rather than node's opaque ERR_SOCKET_BAD_PORT.
290
+ let requestedPort;
291
+ try {
292
+ requestedPort = (0, workbench_1.parseWorkbenchPort)(this.args.port);
293
+ }
294
+ catch (error) {
295
+ reject(error);
296
+ return;
297
+ }
214
298
  const server = http.createServer((req, res) => this.handleRequest(req, res));
215
- const requestedPort = this.args.port !== undefined ? Number(this.args.port) : undefined;
216
299
  server.on("error", reject);
217
300
  server.listen(requestedPort ?? 7717, "127.0.0.1", () => {
218
301
  this.server = server;
@@ -243,14 +326,42 @@ class WorkbenchHost {
243
326
  process.exitCode = 1;
244
327
  return;
245
328
  }
329
+ // Validate --port HERE, before any dispatch. The CLI binding calls this
330
+ // as `void host.run()` (not awaited), so a rejection from listen() would
331
+ // otherwise surface as an unhandled promise rejection stack dump on the
332
+ // real CLI. Fail closed the same clean way as --require-token: one `cw:`
333
+ // stderr line + exit 1 + return, never a crash and never a bound server.
334
+ try {
335
+ (0, workbench_1.parseWorkbenchPort)(this.args.port);
336
+ }
337
+ catch (error) {
338
+ process.stderr.write(`cw: ${error instanceof Error ? error.message : String(error)}\n`);
339
+ process.exitCode = 1;
340
+ return;
341
+ }
246
342
  const once = Boolean(this.args.once || this.args.json);
247
343
  if (once) {
248
344
  process.stdout.write(`${JSON.stringify(this.descriptor(true))}\n`);
249
345
  return;
250
346
  }
251
- const boundPort = await this.listen();
347
+ // The bind itself can still fail at runtime (EADDRINUSE when the port
348
+ // is taken, EACCES on a privileged port) — listen() rejects for those.
349
+ // The port-VALUE check above only covers a malformed --port. Fail
350
+ // closed the same clean way rather than an unhandled-rejection crash.
351
+ let boundPort;
352
+ try {
353
+ boundPort = await this.listen();
354
+ }
355
+ catch (error) {
356
+ const message = error instanceof Error ? error.message : String(error);
357
+ process.stderr.write(`cw: ${message}\n`);
358
+ process.stderr.write(`Try: pick another port with --port <n>, or stop whatever is already serving.\n`);
359
+ process.exitCode = 1;
360
+ return;
361
+ }
252
362
  const descriptor = this.descriptor(false, boundPort);
253
363
  process.stdout.write(`${JSON.stringify({ ...descriptor, boundPort })}\n`);
364
+ printServeHint(boundPort);
254
365
  // Block forever (until the process is killed) — a real serve.
255
366
  await new Promise(() => { });
256
367
  }
@@ -47,6 +47,7 @@ var __importStar = (this && this.__importStar) || (function () {
47
47
  })();
48
48
  Object.defineProperty(exports, "__esModule", { value: true });
49
49
  exports.WORKBENCH_UI_RELATIVE = exports.WORKBENCH_DEFAULT_PORT = void 0;
50
+ exports.parseWorkbenchPort = parseWorkbenchPort;
50
51
  exports.buildWorkbenchRunView = buildWorkbenchRunView;
51
52
  exports.workbenchUiRoot = workbenchUiRoot;
52
53
  exports.buildWorkbenchServeDescriptor = buildWorkbenchServeDescriptor;
@@ -56,6 +57,32 @@ const capability_table_1 = require("../core/capability-table");
56
57
  const run_store_1 = require("./run-store");
57
58
  exports.WORKBENCH_DEFAULT_PORT = 7717;
58
59
  exports.WORKBENCH_UI_RELATIVE = "ui/workbench";
60
+ /** Parse and range-check a workbench `--port` value. Returns `undefined`
61
+ * when no port was given (the caller then uses WORKBENCH_DEFAULT_PORT),
62
+ * the validated integer otherwise. Throws a clear Error on a bad value —
63
+ * a non-number (`NaN`), a float, a negative, or a number over 65535 — so
64
+ * callers fail closed with an actionable line instead of node's opaque
65
+ * ERR_SOCKET_BAD_PORT or a `"port": null` descriptor. A valid port is an
66
+ * integer in [0, 65535]; 0 is the legitimately-supported ephemeral port. */
67
+ function parseWorkbenchPort(raw) {
68
+ if (raw === undefined)
69
+ return undefined;
70
+ const reject = () => {
71
+ throw new Error(`workbench serve --port must be an integer 0-65535 (got ${JSON.stringify(raw)})`);
72
+ };
73
+ // Only a string (the argv form) or a number is a real port. A valueless
74
+ // `--port` flag parses to boolean `true`; reject it rather than let
75
+ // Number(true) === 1 silently bind to port 1. A blank string is bad input
76
+ // too — Number("") === 0 would otherwise pass as the ephemeral port.
77
+ if (typeof raw !== "string" && typeof raw !== "number")
78
+ reject();
79
+ if (typeof raw === "string" && raw.trim() === "")
80
+ reject();
81
+ const port = Number(raw);
82
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
83
+ reject();
84
+ return port;
85
+ }
59
86
  /** Panel groups/members, in the SPEC's declared order. Each entry names
60
87
  * the capability id whose MCP handler is called to fill the panel. */
61
88
  const PANEL_MAP = {
@@ -104,6 +131,21 @@ function buildWorkbenchRunView(runId, args = {}) {
104
131
  resolved = false;
105
132
  resolveError = error instanceof Error ? error.message : String(error);
106
133
  }
134
+ // The run's lifecycle, from the SAME `run.show` capability handler the
135
+ // CLI/MCP use (the buildWorkbenchIndex composition style — never a
136
+ // duplicate implementation). This function's contract is never-throws,
137
+ // so any failure here just leaves the key out.
138
+ let lifecycle;
139
+ try {
140
+ const showRow = (0, capability_table_1.findCapability)("run.show");
141
+ const shown = showRow?.mcp ? showRow.mcp.handler({ ...args, runId, cwd }) : undefined;
142
+ if (shown && shown.found === true && shown.record && typeof shown.record.lifecycle === "string") {
143
+ lifecycle = shown.record.lifecycle;
144
+ }
145
+ }
146
+ catch {
147
+ lifecycle = undefined;
148
+ }
107
149
  const panels = {};
108
150
  const panelArgs = { ...args, runId, cwd };
109
151
  for (const [group, members] of Object.entries(PANEL_MAP)) {
@@ -114,7 +156,7 @@ function buildWorkbenchRunView(runId, args = {}) {
114
156
  : { capability, cli: cliCommandFor(capability), mcp: mcpToolFor(capability), status: "absent", error: resolveError };
115
157
  }
116
158
  }
117
- return { schemaVersion: 1, surface: "workbench", runId, resolved, ...(resolveError ? { error: resolveError } : {}), panels };
159
+ return { schemaVersion: 1, surface: "workbench", runId, resolved, ...(lifecycle ? { lifecycle } : {}), ...(resolveError ? { error: resolveError } : {}), panels };
118
160
  }
119
161
  /** Package-relative resolution only — never falls back to the invocation
120
162
  * cwd. `ui/` ships as a sibling of `dist/` in the published package
@@ -143,7 +185,10 @@ function buildWorkbenchServeDescriptor(args = {}, boundPort) {
143
185
  surface: "workbench",
144
186
  command: "serve",
145
187
  host: "127.0.0.1",
146
- port: boundPort ?? (args.port !== undefined ? Number(args.port) : exports.WORKBENCH_DEFAULT_PORT),
188
+ // `boundPort` (from a real listen()) is already a valid port. Otherwise
189
+ // validate the requested `--port` so the `--once`/`--json`/MCP descriptor
190
+ // path fails closed with a clear line instead of emitting `"port": null`.
191
+ port: boundPort ?? (parseWorkbenchPort(args.port) ?? exports.WORKBENCH_DEFAULT_PORT),
147
192
  once: Boolean(args.once),
148
193
  readOnly: true,
149
194
  scope: args.scope === "home" ? "home" : "repo",
@@ -158,13 +203,42 @@ function buildWorkbenchServeDescriptor(args = {}, boundPort) {
158
203
  * list`), each embedded VERBATIM from its own already-declared capability
159
204
  * handler — the Workbench adds no new source of truth. Composed the same
160
205
  * way the panels are (findCapability(...).mcp.handler), so `/api/index`
161
- * can never drift from the standalone `cw` commands. Read-only. */
206
+ * can never drift from the standalone `cw` commands. Read-only.
207
+ *
208
+ * When `args.text` names a non-blank filter (the Workbench UI's sidebar
209
+ * filter box, `ui/workbench/app.js`'s `loadIndex`), the run list is filled
210
+ * via the `run.search` capability instead of `run.list` — `run.list`'s own
211
+ * handler (`runListCli` -> `RunRegistry.list()`) never reads a `text`
212
+ * field, so calling it with a filter present would silently ignore it.
213
+ * With no `text` filter the call is unchanged: `run.list`, byte-identical
214
+ * to the payload before this branch existed. */
162
215
  function buildWorkbenchIndex(args = {}) {
163
216
  const scope = args.scope === "home" ? "home" : "repo";
164
217
  const scoped = { ...args, scope };
165
218
  const registryRow = (0, capability_table_1.findCapability)("registry.show");
166
- const runListRow = (0, capability_table_1.findCapability)("run.list");
219
+ const text = typeof args.text === "string" ? args.text.trim() : "";
220
+ const runListRow = (0, capability_table_1.findCapability)(text ? "run.search" : "run.list");
167
221
  const registry = registryRow?.mcp ? registryRow.mcp.handler(scoped) : undefined;
168
- const runs = runListRow?.mcp ? runListRow.mcp.handler(scoped) : [];
222
+ const runs = runListRow?.mcp ? newestRunPage(runListRow.mcp.handler, scoped) : [];
169
223
  return { schemaVersion: 1, surface: "workbench", command: "index", scope, registry, runs };
170
224
  }
225
+ /** The run list/search handler sorts oldest-first and returns only the
226
+ * first `limit` page — so the default page is the OLDEST runs, and a scope
227
+ * with more runs than the page size never shows the newest run at all. The
228
+ * Workbench wants the newest page: when the caller pinned no offset and the
229
+ * total exceeds one page, re-fetch with the offset that lands on the last
230
+ * page. The payload shape (total/offset/limit/records) is unchanged, so
231
+ * the UI can still show "showing latest N of M". */
232
+ function newestRunPage(handler, scoped) {
233
+ const first = handler(scoped);
234
+ if (scoped.offset !== undefined)
235
+ return first;
236
+ if (!first || typeof first !== "object")
237
+ return first;
238
+ const page = first;
239
+ const total = typeof page.total === "number" ? page.total : undefined;
240
+ const limit = typeof page.limit === "number" ? page.limit : undefined;
241
+ if (total === undefined || limit === undefined || total <= limit)
242
+ return first;
243
+ return handler({ ...scoped, offset: total - limit });
244
+ }
@@ -703,7 +703,7 @@ function recordWorkerOutput(run, workerId, resultPath, options = {}) {
703
703
  artifacts: [{ id: "result", kind: "markdown", path: destination }],
704
704
  evidence: resultNode.evidence.length ? resultNode.evidence : [{ id: "result:summary", source: "summary", summary: parsedResult.summary }],
705
705
  metadata: { taskId: task.id, workerId, resultNodeId: resultNode.id, sandboxProfileId: scope.sandboxProfileId },
706
- }, { persist: false, persistNode: node_store_1.appendRunNode });
706
+ }, { persist: false, persistNode: node_store_1.appendRunNode, pathExists: fs.existsSync });
707
707
  task.verifierNodeId = verifierResult.outputNodeId;
708
708
  // Step 5: completion — persist the worker scope with the verify-derived status.
709
709
  const output = { workerId, taskId: task.id, resultPath: absoluteResultPath, recordedAt: new Date().toISOString(), stateNodeId: resultNode.id, verifierNodeId: task.verifierNodeId, auditEventIds: [pathAudit.id, acceptedAudit.id] };