cool-workflow 0.2.4 → 0.2.6

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 (155) 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 +204 -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 +32 -4
  15. package/dist/cli/entry.js +11 -19
  16. package/dist/cli/global-flags.js +113 -0
  17. package/dist/cli/parseargv.js +7 -1
  18. package/dist/core/capability-data.js +337 -8
  19. package/dist/core/format/help.js +73 -3
  20. package/dist/core/format/recovery-hint.js +32 -0
  21. package/dist/core/multi-agent/collaboration.js +35 -6
  22. package/dist/core/multi-agent/runtime.js +7 -0
  23. package/dist/core/multi-agent/trust-policy.js +7 -1
  24. package/dist/core/pipeline/contract.js +7 -0
  25. package/dist/core/pipeline/error-feedback.js +2 -2
  26. package/dist/core/state/run-paths.js +3 -30
  27. package/dist/core/trust/evidence-grounding.js +13 -1
  28. package/dist/core/util/cli-args.js +22 -0
  29. package/dist/core/version.js +1 -1
  30. package/dist/mcp/dispatch.js +22 -2
  31. package/dist/mcp/server.js +215 -36
  32. package/dist/mcp/tool-process.js +181 -0
  33. package/dist/mcp-server.js +28 -1
  34. package/dist/shell/agent-config.js +11 -2
  35. package/dist/shell/audit-cli.js +1 -1
  36. package/dist/shell/commit.js +8 -2
  37. package/dist/shell/coordinator-io.js +73 -1
  38. package/dist/shell/drive.js +195 -128
  39. package/dist/shell/error-feedback-io.js +6 -0
  40. package/dist/shell/execution-backend/agent.js +201 -26
  41. package/dist/shell/execution-backend/container.js +44 -10
  42. package/dist/shell/execution-backend/local.js +33 -0
  43. package/dist/shell/fs-atomic.js +96 -12
  44. package/dist/shell/ledger-cli.js +9 -2
  45. package/dist/shell/metrics-cli.js +1 -1
  46. package/dist/shell/multi-agent-cli.js +9 -2
  47. package/dist/shell/multi-agent-operator-ux.js +1 -1
  48. package/dist/shell/observability.js +7 -4
  49. package/dist/shell/onramp.js +48 -5
  50. package/dist/shell/operator-ux.js +1 -1
  51. package/dist/shell/perf-trace.js +136 -0
  52. package/dist/shell/pipeline.js +3 -2
  53. package/dist/shell/reclamation-io.js +10 -9
  54. package/dist/shell/report-view-cli.js +2 -0
  55. package/dist/shell/run-export-cli.js +5 -2
  56. package/dist/shell/run-export.js +293 -31
  57. package/dist/shell/run-registry-io.js +8 -0
  58. package/dist/shell/run-store.js +183 -3
  59. package/dist/shell/scheduler-io.js +101 -10
  60. package/dist/shell/state-explosion-cli.js +4 -1
  61. package/dist/shell/telemetry-demo.js +1 -1
  62. package/dist/shell/telemetry-ledger-io.js +36 -24
  63. package/dist/shell/trust-audit.js +178 -36
  64. package/dist/shell/workbench-host.js +121 -10
  65. package/dist/shell/workbench.js +83 -6
  66. package/dist/shell/worker-isolation.js +1 -1
  67. package/dist/wiring/capability-table/basics.js +5 -0
  68. package/dist/wiring/capability-table/exec-backend.js +40 -22
  69. package/dist/wiring/capability-table/parity.js +10 -5
  70. package/dist/wiring/capability-table/pipeline.js +32 -0
  71. package/dist/wiring/capability-table/registry-core.js +26 -3
  72. package/dist/wiring/capability-table/reporting.js +7 -1
  73. package/dist/wiring/capability-table/scheduling-registry.js +8 -2
  74. package/dist/wiring/capability-table/trust-ledger.js +54 -12
  75. package/docs/agent-delegation-drive.7.md +17 -0
  76. package/docs/cli-mcp-parity.7.md +46 -5
  77. package/docs/contract-migration-tooling.7.md +4 -0
  78. package/docs/control-plane-scheduling.7.md +38 -0
  79. package/docs/cross-agent-ledger.7.md +20 -8
  80. package/docs/durable-state-and-locking.7.md +4 -0
  81. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  82. package/docs/execution-backends.7.md +4 -0
  83. package/docs/fix.7.md +4 -4
  84. package/docs/getting-started.md +40 -32
  85. package/docs/index.md +17 -0
  86. package/docs/launch/demo.tape +4 -3
  87. package/docs/mcp-app-surface.7.md +27 -0
  88. package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
  89. package/docs/multi-agent-eval-replay-harness.7.md +4 -0
  90. package/docs/multi-agent-operator-ux.7.md +4 -0
  91. package/docs/node-snapshot-diff-replay.7.md +4 -0
  92. package/docs/observability-cost-accounting.7.md +4 -0
  93. package/docs/project-index.md +53 -9
  94. package/docs/real-execution-backends.7.md +4 -0
  95. package/docs/release-and-migration.7.md +4 -0
  96. package/docs/release-tooling.7.md +12 -0
  97. package/docs/routine.7.md +22 -0
  98. package/docs/run-registry-control-plane.7.md +37 -9
  99. package/docs/run-retention-reclamation.7.md +12 -4
  100. package/docs/state-explosion-management.7.md +4 -0
  101. package/docs/team-collaboration.7.md +4 -0
  102. package/docs/trust-audit-anchor.7.md +4 -0
  103. package/docs/web-desktop-workbench.7.md +72 -2
  104. package/docs/workflow-app-framework.7.md +31 -0
  105. package/manifest/plugin.manifest.json +1 -1
  106. package/manifest/source-context-profiles.json +4 -3
  107. package/package.json +6 -3
  108. package/scripts/agents/agent-adapter-core.js +26 -0
  109. package/scripts/agents/claude-p-agent.js +4 -1
  110. package/scripts/agents/codex-agent.js +4 -0
  111. package/scripts/agents/cw-attest-wrap.js +1 -1
  112. package/scripts/agents/gemini-agent.js +4 -0
  113. package/scripts/agents/opencode-agent.js +5 -0
  114. package/scripts/block-unapproved-tag.js +160 -0
  115. package/scripts/canonical-apps.js +4 -4
  116. package/scripts/children/batch-delegate-child.js +72 -4
  117. package/scripts/children/http-batch-delegate-child.js +132 -0
  118. package/scripts/children/http-delegate-child.js +8 -0
  119. package/scripts/dogfood-release.js +2 -2
  120. package/scripts/fake-date-for-reproduction.js +1 -1
  121. package/scripts/gen-manifests.js +32 -61
  122. package/scripts/golden-path.js +4 -4
  123. package/scripts/lang-policy-check.js +82 -0
  124. package/scripts/parity-check.js +42 -23
  125. package/scripts/purity-baseline.json +0 -3
  126. package/scripts/purity-gate.js +16 -3
  127. package/scripts/release-check.js +2 -1
  128. package/scripts/release-flow.js +169 -41
  129. package/scripts/release-gate.js +208 -0
  130. package/scripts/release-oneclick.js +38 -7
  131. package/scripts/release-tags.js +47 -0
  132. package/scripts/schema-version-inventory.json +31 -0
  133. package/scripts/validate-run-state-schema.js +95 -4
  134. package/scripts/verdict-keygen.js +2 -2
  135. package/scripts/verify-bump-reproduction.js +193 -0
  136. package/scripts/verify-release-verdict.js +139 -0
  137. package/scripts/verify-verdict-signature.js +1 -1
  138. package/ui/workbench/app.css +50 -10
  139. package/ui/workbench/app.js +269 -34
  140. package/ui/workbench/index.html +4 -2
  141. package/ui/workbench/inspection.js +51 -0
  142. package/ui/workbench/navigation.js +44 -0
  143. package/docs/agent-framework.md +0 -177
  144. package/docs/designs/handoff-ledger.md +0 -145
  145. package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
  146. package/docs/launch/launch-kit.md +0 -195
  147. package/docs/launch/pre-launch-checklist.md +0 -53
  148. package/docs/readme-v0.1.87-full.md +0 -301
  149. package/docs/routines.md +0 -101
  150. package/docs/scheduled-tasks.md +0 -80
  151. package/scripts/agents/claude-p-agent.sh +0 -9
  152. package/scripts/block-unapproved-tag.sh +0 -75
  153. package/scripts/release-gate.sh +0 -94
  154. package/scripts/verify-bump-reproduction.sh +0 -148
  155. package/scripts/verify-container-selfref.js +0 -64
@@ -7,8 +7,11 @@
7
7
  // "Invariants and error behavior" #9 / "Edge cases" sections:
8
8
  // - transport: stdin/stdout, ONE JSON object per line, no
9
9
  // Content-Length headers (mcp.md:13);
10
- // - `initialize` -> protocolVersion/capabilities/serverInfo, ignoring
11
- // params (mcp.md:19,263-269);
10
+ // - `initialize` -> protocolVersion/capabilities/serverInfo (mcp.md:
11
+ // 19,263-269); the reply's protocolVersion echoes the client's
12
+ // requested one when it is in SUPPORTED_PROTOCOL_VERSIONS below, and
13
+ // falls back to the newest supported entry otherwise (with today's
14
+ // one-entry list this is byte-identical to the old fixed reply);
12
15
  // - `tools/list` -> { tools: [...] } from core/capability-table.ts via
13
16
  // mcp/dispatch.ts, ignoring params (mcp.md:20,271-277);
14
17
  // - `tools/call` -> { content: [{ type: "text", text: <2-space pretty
@@ -48,12 +51,76 @@
48
51
  // blow V8's per-string limit) becomes a small overflow notice instead
49
52
  // of a multi-hundred-MB payload — every result under the cap is
50
53
  // untouched, so this never affects the parity gate's own fixtures.
54
+ // - `tools/call` FAILURE result shape (post-v0.2.4 robustness hardening,
55
+ // not in the original mcp.md): an unknown tool name, a missing
56
+ // required tool argument, or the tool's own handler throwing is a
57
+ // normal RESULT, not a bare -32000 JSON-RPC protocol error — many MCP
58
+ // hosts never surface a protocol error back to the calling model, so
59
+ // it could not read the message or try again. The result is shaped
60
+ // { content: [{ type: "text", text: <message, plus a "Try: <hint>"
61
+ // line when core/format/recovery-hint.ts's recoveryHint finds one>
62
+ // }], isError: true }, same `resultMessage` helper as the success
63
+ // path. The envelope-level "missing field: name" check (right above
64
+ // this bullet) is unchanged — it still answers -32000.
51
65
  Object.defineProperty(exports, "__esModule", { value: true });
66
+ exports.mcpToolAuthority = mcpToolAuthority;
67
+ exports.negotiateProtocolVersion = negotiateProtocolVersion;
52
68
  exports.startServer = startServer;
53
69
  const version_1 = require("../core/version");
54
- const safe_json_1 = require("../core/format/safe-json");
70
+ const recovery_hint_1 = require("../core/format/recovery-hint");
55
71
  const dispatch_1 = require("./dispatch");
72
+ const tool_process_1 = require("./tool-process");
56
73
  const MAX_LINE_BYTES = 16 * 1024 * 1024;
74
+ /** Protocol versions this server can speak, oldest first. `initialize`
75
+ * echoes the client's `params.protocolVersion` when it is in this list,
76
+ * and answers with the newest entry (the last one) otherwise — the
77
+ * standard MCP version-negotiation shape. With one entry this is
78
+ * behavior-identical to the old hard-coded reply (mechanism first; a
79
+ * second version is a one-line append here). */
80
+ const SUPPORTED_PROTOCOL_VERSIONS = ["2024-11-05"];
81
+ /** Read the optional server-side tool policy. When both values are unset, the
82
+ * full present tool list and access stay unchanged. An enabled list is an
83
+ * allowlist; disabled names are then removed. */
84
+ function mcpToolAuthority(definitions = (0, dispatch_1.toolDefinitions)(), environment = process.env) {
85
+ const known = new Set(definitions.map((definition) => definition.name));
86
+ const enabled = configuredToolNames("CW_MCP_ENABLED_TOOLS", environment, known);
87
+ const disabled = configuredToolNames("CW_MCP_DISABLED_TOOLS", environment, known);
88
+ if (!enabled && !disabled)
89
+ return {};
90
+ const allowed = enabled ? new Set(enabled) : new Set(known);
91
+ for (const name of disabled ?? [])
92
+ allowed.delete(name);
93
+ return { allowed };
94
+ }
95
+ function configuredToolNames(name, environment, known) {
96
+ const value = environment[name];
97
+ if (value === undefined)
98
+ return undefined;
99
+ const names = value.split(",").map((part) => part.trim());
100
+ if (names.some((tool) => tool.length === 0))
101
+ throw new Error(`MCP tool policy ${name} contains an empty tool name`);
102
+ const selected = new Set(names);
103
+ for (const tool of selected) {
104
+ if (!known.has(tool))
105
+ throw new Error(`MCP tool policy ${name} names an unknown tool: ${tool}`);
106
+ }
107
+ return selected;
108
+ }
109
+ function permittedToolDefinitions(authority) {
110
+ const definitions = (0, dispatch_1.toolDefinitions)();
111
+ return authority.allowed ? definitions.filter((definition) => authority.allowed?.has(definition.name)) : definitions;
112
+ }
113
+ function toolPermitted(name, authority) {
114
+ return authority.allowed === undefined || authority.allowed.has(name);
115
+ }
116
+ /** Picks the `initialize` reply's protocolVersion from the client's
117
+ * requested one (see SUPPORTED_PROTOCOL_VERSIONS). Exported for the
118
+ * protocol-version smoke; pure. */
119
+ function negotiateProtocolVersion(requested) {
120
+ if (typeof requested === "string" && SUPPORTED_PROTOCOL_VERSIONS.includes(requested))
121
+ return requested;
122
+ return SUPPORTED_PROTOCOL_VERSIONS[SUPPORTED_PROTOCOL_VERSIONS.length - 1];
123
+ }
57
124
  // Tools whose result carries free-form text ORIGINALLY AUTHORED by a
58
125
  // worker/agent/operator/external caller — never computed or validated by
59
126
  // this codebase itself (a blackboard message body, a review comment, a
@@ -117,19 +184,15 @@ function resultMessage(id, result) {
117
184
  if (id !== undefined)
118
185
  message.id = id;
119
186
  return message;
187
+ // NOTE: a notification-shaped request (initialize/tools/list/tools/call
188
+ // with no `id`) still gets a reply here, just without an `id` key — a
189
+ // deliberate deviation from strict JSON-RPC (which says a notification
190
+ // MUST NOT be answered) pinned by SPEC/mcp.md's edge-cases. Revisit only
191
+ // as a deliberate spec change, not as a drive-by fix.
120
192
  }
121
- /** Handles one already-parsed JSON-RPC request object. May write zero or
122
- * one reply line to stdout. `await`ing callTool's result is a no-op for
123
- * the ~197 tools whose handler returns a plain value already (an `await`
124
- * on a non-Promise resolves on the next microtask, invisible to a
125
- * caller that already awaits handleRequest) -- it only matters for
126
- * `cw_run`, whose live drive loop returns a real Promise so it can
127
- * actually stay interruptible (see shell/drive.ts's driveAsync). This
128
- * must stay inside the existing try/catch: an async tool handler throws
129
- * by REJECTING its returned Promise rather than throwing synchronously,
130
- * and an unawaited rejection here would be an unhandled rejection
131
- * instead of the normal `-32000` JSON-RPC error reply. */
132
- async function handleRequest(message) {
193
+ /** Handles one already-parsed JSON-RPC request object. The parent owns the
194
+ * protocol reply while the durable tool process owns the blocking tool work. */
195
+ async function handleRequest(message, tools, authority) {
133
196
  const hasId = Object.prototype.hasOwnProperty.call(message, "id");
134
197
  const id = message.id;
135
198
  if (typeof message.method !== "string") {
@@ -142,15 +205,27 @@ async function handleRequest(message) {
142
205
  try {
143
206
  switch (message.method) {
144
207
  case "initialize": {
208
+ const params = (message.params ?? {});
145
209
  writeMessage(resultMessage(id, {
146
- protocolVersion: "2024-11-05",
210
+ protocolVersion: negotiateProtocolVersion(params.protocolVersion),
147
211
  capabilities: { tools: {} },
148
212
  serverInfo: { name: "cool-workflow", version: version_1.CURRENT_COOL_WORKFLOW_VERSION },
149
213
  }));
150
214
  return;
151
215
  }
152
216
  case "tools/list": {
153
- writeMessage(resultMessage(id, { tools: (0, dispatch_1.toolDefinitions)() }));
217
+ writeMessage(resultMessage(id, { tools: permittedToolDefinitions(authority) }));
218
+ return;
219
+ }
220
+ case "ping": {
221
+ // MCP (2024-11-05, the version negotiateProtocolVersion advertises)
222
+ // makes ping mandatory: reply promptly with an EMPTY result. Hosts
223
+ // ping for keep-alive and may drop a connection that never answers.
224
+ // Answered here in the fast protocol path (not the serial tool
225
+ // queue), so a ping during a long cw_run drive still gets a reply.
226
+ // A ping notification (no id) gets no reply, per JSON-RPC.
227
+ if (hasId)
228
+ writeMessage(resultMessage(id, {}));
154
229
  return;
155
230
  }
156
231
  case "tools/call": {
@@ -160,12 +235,34 @@ async function handleRequest(message) {
160
235
  throw new Error("MCP tools/call missing required field: name");
161
236
  }
162
237
  const args = params.arguments;
163
- const coreResult = await (0, dispatch_1.callTool)(name, args ?? {});
164
- const content = [{ type: "text", text: (0, safe_json_1.safeJsonStringify)(coreResult) }];
165
- const advisory = untrustedContentAdvisory(name);
166
- if (advisory)
167
- content.push({ type: "text", text: advisory });
168
- writeMessage(resultMessage(id, { content }));
238
+ // A failure from HERE down (an unknown tool name, a missing
239
+ // required tool argument, or the tool's own handler throwing) is a
240
+ // normal call outcome, not a broken request — many MCP hosts never
241
+ // surface a bare JSON-RPC protocol error (-32000) back to the
242
+ // calling model at all, so it could not see the message and try
243
+ // again. Answer with a normal RESULT instead, shaped isError:
244
+ // true, so the model always sees the message (and, when one
245
+ // applies, a "Try: <hint>" recovery line) and can self-correct.
246
+ // The envelope-level "missing field: name" check above stays OUT
247
+ // of this inner try/catch — that one is a malformed request, not
248
+ // a tool-call outcome, and keeps going through the outer
249
+ // try/catch as a -32000 error, unchanged.
250
+ try {
251
+ if (!toolPermitted(name, authority))
252
+ throw new Error(`MCP tool disabled by policy: ${name}`);
253
+ const text = await tools.execute(name, args ?? {});
254
+ const content = [{ type: "text", text }];
255
+ const advisory = untrustedContentAdvisory(name);
256
+ if (advisory)
257
+ content.push({ type: "text", text: advisory });
258
+ writeMessage(resultMessage(id, { content }));
259
+ }
260
+ catch (error) {
261
+ const text = error instanceof Error ? error.message : String(error);
262
+ const hint = (0, recovery_hint_1.recoveryHint)(text);
263
+ const errorText = hint ? `${text}\nTry: ${hint}` : text;
264
+ writeMessage(resultMessage(id, { content: [{ type: "text", text: errorText }], isError: true }));
265
+ }
169
266
  return;
170
267
  }
171
268
  default: {
@@ -180,22 +277,28 @@ async function handleRequest(message) {
180
277
  writeMessage(errorMessage(id, -32000, text));
181
278
  }
182
279
  }
183
- /** Handles one raw (already-trimmed, non-empty) stdin line. */
184
- async function handleLine(line) {
280
+ /** Parses a raw stdin line without writing. This lets a valid ping use the
281
+ * control path while parse errors keep their old place in the work queue. */
282
+ function parseLine(line) {
185
283
  let parsed;
186
284
  try {
187
285
  parsed = JSON.parse(line);
188
286
  }
189
287
  catch (error) {
190
288
  const detail = error instanceof Error ? error.message : String(error);
191
- writeMessage(errorMessage(null, -32700, `Parse error: ${detail}`));
192
- return;
289
+ return { error: { code: -32700, message: `Parse error: ${detail}` } };
193
290
  }
194
291
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
195
- writeMessage(errorMessage(null, -32600, "Invalid Request: not a JSON-RPC object"));
292
+ return { error: { code: -32600, message: "Invalid Request: not a JSON-RPC object" } };
293
+ }
294
+ return { message: parsed };
295
+ }
296
+ async function handleLine(parsed, tools, authority) {
297
+ if ("error" in parsed) {
298
+ writeMessage(errorMessage(null, parsed.error.code, parsed.error.message));
196
299
  return;
197
300
  }
198
- await handleRequest(parsed);
301
+ await handleRequest(parsed.message, tools, authority);
199
302
  }
200
303
  /** Starts the stdio read loop. Never resolves — the server is long-lived
201
304
  * and stops only when its stdin closes / the process exits.
@@ -205,28 +308,104 @@ async function handleLine(line) {
205
308
  * tool but `cw_run` is still a plain synchronous handler), so this adds
206
309
  * no real delay, but it keeps replies in the same order the requests
207
310
  * arrived even now that one tool (`cw_run`'s live drive loop) can take
208
- * many real event-loop turns to answer. */
311
+ * many real event-loop turns to answer. The one exception is `ping`,
312
+ * answered in handleRequest's fast path before any tool work, so a
313
+ * keep-alive ping still gets a reply while a long drive holds the queue. */
209
314
  function startServer() {
315
+ const authority = mcpToolAuthority();
210
316
  process.stdin.setEncoding("utf8");
317
+ const tools = new tool_process_1.ToolProcessExecutor();
318
+ // EOF is a normal protocol close: keep the queue alive so a client which
319
+ // sent a batch and then closed stdin still gets its ordered replies. A
320
+ // process stop is different. Stop the child before giving the same signal
321
+ // back to the parent, so a child in a synchronous lock wait cannot wake up
322
+ // later and write state after its MCP parent is gone.
323
+ const stopForSignal = (signal) => {
324
+ const stop = () => {
325
+ tools.close();
326
+ process.removeListener(signal, stop);
327
+ process.kill(process.pid, signal);
328
+ };
329
+ process.once(signal, stop);
330
+ };
331
+ stopForSignal("SIGINT");
332
+ stopForSignal("SIGTERM");
211
333
  let buffer = "";
334
+ // True while the REST of an oversize line is still streaming in: emit one
335
+ // -32700 for the whole line and skip everything up to its terminating
336
+ // newline, instead of dropping the head and then re-parsing the tail as a
337
+ // fresh (also-failing) line — which produced a second, spurious parse
338
+ // error per 16MB crossed.
339
+ let discarding = false;
212
340
  let queue = Promise.resolve();
341
+ // Chain each task onto `queue` WITH a per-task `.catch`. The `.catch` is
342
+ // load-bearing, not decoration: a task here can reject — a raw
343
+ // `writeMessage` (`process.stdout.write`) that throws mid-reply because the
344
+ // client closed the pipe, or any other throw out of handleLine — and
345
+ // without a handler that one rejection would leave `queue` REJECTED for
346
+ // good, so every later `queue.then(...)` is skipped and the server goes
347
+ // silent and answers no more requests (finding: one bad write poisons the
348
+ // queue). The `.catch` swallows the single failure onto stderr
349
+ // (diagnostics, never stdout data) and hands back a RESOLVED promise, so
350
+ // the next request is still served. Order is still kept: the next task
351
+ // only runs after this one settles.
352
+ const enqueue = (task) => {
353
+ queue = queue.then(task).catch((error) => {
354
+ const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
355
+ process.stderr.write(`cool-workflow mcp: a request failed and its reply was dropped; still serving: ${detail}\n`);
356
+ });
357
+ };
213
358
  process.stdin.on("data", (chunk) => {
214
359
  buffer += chunk;
215
360
  for (;;) {
361
+ if (discarding) {
362
+ // Skip the rest of an oversize line already reported. Until its
363
+ // terminating newline arrives, throw away what we have (so a huge
364
+ // line can't grow the buffer unboundedly); once found, resume
365
+ // normal parsing from the next line with no second error.
366
+ const nl = buffer.indexOf("\n");
367
+ if (nl === -1) {
368
+ buffer = "";
369
+ break;
370
+ }
371
+ buffer = buffer.slice(nl + 1);
372
+ discarding = false;
373
+ }
216
374
  const newlineIndex = buffer.indexOf("\n");
217
375
  if (newlineIndex === -1)
218
376
  break;
219
377
  const line = buffer.slice(0, newlineIndex);
220
378
  buffer = buffer.slice(newlineIndex + 1);
221
379
  const trimmed = line.trim();
222
- if (trimmed)
223
- queue = queue.then(() => handleLine(trimmed));
380
+ if (!trimmed)
381
+ continue;
382
+ const parsed = parseLine(trimmed);
383
+ // Ping is a control-plane keep-alive. It must not wait behind a tool
384
+ // process that is blocked on a file lock or an outside agent.
385
+ if ("message" in parsed && parsed.message.method === "ping") {
386
+ void handleLine(parsed, tools, authority).catch((error) => {
387
+ const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
388
+ process.stderr.write(`cool-workflow mcp: a ping reply failed: ${detail}\n`);
389
+ });
390
+ }
391
+ else {
392
+ enqueue(() => handleLine(parsed, tools, authority));
393
+ }
224
394
  }
225
- if (buffer.length > MAX_LINE_BYTES) {
395
+ // No newline yet and the pending (unterminated) line already exceeds the
396
+ // cap: report ONCE, drop the head, and discard the rest of this line
397
+ // until its newline arrives (guarded by `discarding` so a >32MB line
398
+ // yields a single -32700, not one per 16MB crossed).
399
+ if (!discarding && buffer.length > MAX_LINE_BYTES) {
226
400
  buffer = "";
227
- queue = queue.then(() => {
228
- writeMessage(errorMessage(null, -32700, `Parse error: request line exceeds ${MAX_LINE_BYTES} bytes`));
229
- });
401
+ discarding = true;
402
+ enqueue(() => writeMessage(errorMessage(null, -32700, `Parse error: request line exceeds ${MAX_LINE_BYTES} bytes`)));
230
403
  }
231
404
  });
405
+ // A client may send a batch and close stdin at once. Keep the tool process
406
+ // alive until the serial queue has written those replies, then close it so
407
+ // it cannot outlive the MCP parent.
408
+ process.stdin.on("end", () => {
409
+ void queue.finally(() => tools.close());
410
+ });
232
411
  }
@@ -0,0 +1,181 @@
1
+ "use strict";
2
+ // mcp/tool-process.ts — the private execution lane for MCP tools.
3
+ //
4
+ // The MCP server keeps its stdio protocol work in the parent process. This
5
+ // child process runs one tools/call at a time, so a synchronous file-lock wait
6
+ // or a long agent call cannot stop the parent from reading and answering ping.
7
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
8
+ if (k2 === undefined) k2 = k;
9
+ var desc = Object.getOwnPropertyDescriptor(m, k);
10
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
11
+ desc = { enumerable: true, get: function() { return m[k]; } };
12
+ }
13
+ Object.defineProperty(o, k2, desc);
14
+ }) : (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ o[k2] = m[k];
17
+ }));
18
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
19
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
20
+ }) : function(o, v) {
21
+ o["default"] = v;
22
+ });
23
+ var __importStar = (this && this.__importStar) || (function () {
24
+ var ownKeys = function(o) {
25
+ ownKeys = Object.getOwnPropertyNames || function (o) {
26
+ var ar = [];
27
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
28
+ return ar;
29
+ };
30
+ return ownKeys(o);
31
+ };
32
+ return function (mod) {
33
+ if (mod && mod.__esModule) return mod;
34
+ var result = {};
35
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
36
+ __setModuleDefault(result, mod);
37
+ return result;
38
+ };
39
+ })();
40
+ Object.defineProperty(exports, "__esModule", { value: true });
41
+ exports.ToolProcessExecutor = void 0;
42
+ exports.startToolProcessWorker = startToolProcessWorker;
43
+ const node_child_process_1 = require("node:child_process");
44
+ const path = __importStar(require("node:path"));
45
+ const safe_json_1 = require("../core/format/safe-json");
46
+ const dispatch_1 = require("./dispatch");
47
+ function isResponse(value) {
48
+ if (!value || typeof value !== "object")
49
+ return false;
50
+ const record = value;
51
+ return record.schemaVersion === 1 && typeof record.id === "number" && typeof record.ok === "boolean" &&
52
+ (record.ok ? typeof record.text === "string" : typeof record.error === "string");
53
+ }
54
+ function diagnostic(kind, chunk) {
55
+ const text = String(chunk);
56
+ if (text)
57
+ process.stderr.write(`cool-workflow mcp tool process ${kind}: ${text}`);
58
+ }
59
+ /** One durable, serial child process for tools/call. The public MCP server
60
+ * owns response shapes; this class moves only a tool request and its already
61
+ * rendered JSON text across IPC. A stopped child never causes an automatic
62
+ * retry because a tool may already have written part of its state. */
63
+ class ToolProcessExecutor {
64
+ workerPath;
65
+ child;
66
+ nextId = 1;
67
+ pending;
68
+ constructor(options = {}) {
69
+ this.workerPath = options.workerPath ?? path.join(__dirname, "tool-process.js");
70
+ }
71
+ execute(name, args) {
72
+ if (this.pending)
73
+ throw new Error("MCP tool process received concurrent work");
74
+ const child = this.ensureChild();
75
+ const id = this.nextId++;
76
+ const request = { schemaVersion: 1, id, name, args };
77
+ return new Promise((resolve, reject) => {
78
+ this.pending = { id, name, resolve, reject };
79
+ try {
80
+ child.send(request, (error) => {
81
+ if (!error || this.pending?.id !== id)
82
+ return;
83
+ this.pending = undefined;
84
+ reject(error);
85
+ });
86
+ }
87
+ catch (error) {
88
+ if (this.pending?.id === id)
89
+ this.pending = undefined;
90
+ reject(error instanceof Error ? error : new Error(String(error)));
91
+ }
92
+ });
93
+ }
94
+ close() {
95
+ const child = this.child;
96
+ this.child = undefined;
97
+ const pending = this.pending;
98
+ this.pending = undefined;
99
+ if (pending)
100
+ pending.reject(new Error(`MCP tool process stopped before ${pending.name} ended`));
101
+ if (!child)
102
+ return;
103
+ if (child.connected)
104
+ child.disconnect();
105
+ child.kill();
106
+ }
107
+ ensureChild() {
108
+ if (this.child && !this.child.killed && this.child.connected)
109
+ return this.child;
110
+ const child = (0, node_child_process_1.fork)(this.workerPath, [], {
111
+ cwd: process.cwd(),
112
+ env: process.env,
113
+ stdio: ["ignore", "pipe", "pipe", "ipc"],
114
+ serialization: "json",
115
+ });
116
+ child.stdout?.on("data", (chunk) => diagnostic("stdout", chunk));
117
+ child.stderr?.on("data", (chunk) => diagnostic("stderr", chunk));
118
+ child.on("message", (message) => this.handleMessage(child, message));
119
+ child.on("error", (error) => this.handleStopped(child, error));
120
+ child.on("exit", () => this.handleStopped(child));
121
+ this.child = child;
122
+ return child;
123
+ }
124
+ handleMessage(child, message) {
125
+ if (child !== this.child || !isResponse(message)) {
126
+ this.handleStopped(child, new Error("MCP tool process sent an invalid response"));
127
+ child.kill();
128
+ return;
129
+ }
130
+ const pending = this.pending;
131
+ if (!pending || pending.id !== message.id) {
132
+ this.handleStopped(child, new Error("MCP tool process sent an unexpected response"));
133
+ child.kill();
134
+ return;
135
+ }
136
+ this.pending = undefined;
137
+ if (message.ok)
138
+ pending.resolve(message.text);
139
+ else
140
+ pending.reject(new Error(message.error));
141
+ }
142
+ handleStopped(child, error) {
143
+ if (child !== this.child)
144
+ return;
145
+ this.child = undefined;
146
+ const pending = this.pending;
147
+ this.pending = undefined;
148
+ if (pending) {
149
+ pending.reject(error ?? new Error(`MCP tool process stopped before ${pending.name} ended`));
150
+ }
151
+ }
152
+ }
153
+ exports.ToolProcessExecutor = ToolProcessExecutor;
154
+ function isRequest(value) {
155
+ if (!value || typeof value !== "object")
156
+ return false;
157
+ const record = value;
158
+ return record.schemaVersion === 1 && typeof record.id === "number" && typeof record.name === "string";
159
+ }
160
+ function send(message) {
161
+ if (process.send)
162
+ process.send(message);
163
+ }
164
+ /** Child entry point. Kept in this module so the parent and child share one
165
+ * small, checked IPC shape. */
166
+ function startToolProcessWorker() {
167
+ process.on("disconnect", () => process.exit(0));
168
+ process.on("message", async (message) => {
169
+ if (!isRequest(message))
170
+ return;
171
+ try {
172
+ const result = await (0, dispatch_1.callTool)(message.name, message.args);
173
+ send({ schemaVersion: 1, id: message.id, ok: true, text: (0, safe_json_1.safeJsonStringify)(result) });
174
+ }
175
+ catch (error) {
176
+ send({ schemaVersion: 1, id: message.id, ok: false, error: error instanceof Error ? error.message : String(error) });
177
+ }
178
+ });
179
+ }
180
+ if (require.main === module)
181
+ startToolProcessWorker();
@@ -6,4 +6,31 @@
6
6
  // real MCP client launches it directly with `node`).
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
8
  const server_1 = require("./mcp/server");
9
- (0, server_1.startServer)();
9
+ /** Broken-pipe guard for the MCP stdio server, the same idea as
10
+ * cli/entry.ts's `main()`. When an MCP client closes the read end of our
11
+ * stdout part-way through a reply, the raw write gives an async 'error'
12
+ * event that no promise `.catch` can see; with no listener Node comes down
13
+ * hard with a `write EPIPE` stack and exit 1. One process-level listener
14
+ * turns that into a quiet exit 0 — the reader has gone, there is nothing
15
+ * left to say. Any other stream error is thrown again, same as before.
16
+ *
17
+ * This is a small COPY of cli/entry.ts's helper, not an import: the purity
18
+ * gate (scripts/purity-gate.js) forbids an mcp/ file from importing cli/,
19
+ * and pulling in the whole CLI entry graph for five lines would be worse. */
20
+ function exitQuietOnEpipe(stream) {
21
+ stream.on("error", (error) => {
22
+ if (error && error.code === "EPIPE")
23
+ process.exit(0);
24
+ throw error;
25
+ });
26
+ }
27
+ exitQuietOnEpipe(process.stdout);
28
+ exitQuietOnEpipe(process.stderr);
29
+ try {
30
+ (0, server_1.startServer)();
31
+ }
32
+ catch (error) {
33
+ const text = error instanceof Error ? error.message : String(error);
34
+ process.stderr.write(`cool-workflow mcp: ${text}\n`);
35
+ process.exitCode = 1;
36
+ }
@@ -246,10 +246,19 @@ function resolveAgentConfig(args = {}, env = process.env) {
246
246
  if (!finalCommand && !endpoint) {
247
247
  const detected = detectAgentFromPath(env);
248
248
  if (detected) {
249
- finalCommand = expandBuiltinAgentCommand(`builtin:${detected}`);
249
+ // Detect = use what was found. Auto-detect saw a BINARY on PATH, so it
250
+ // must resolve to the wrapper that runs THAT binary. For "gemini" the
251
+ // plain builtin:gemini template goes through opencode instead (a user
252
+ // choice kept for the explicit -gemini flag, where --help says "via
253
+ // opencode") — a machine with only the native gemini CLI would be
254
+ // detected as ready and then fail when the wrapper spawns a missing
255
+ // opencode. So the auto path maps gemini to builtin:gemini-cli, the
256
+ // wrapper that spawns the found binary itself.
257
+ const template = detected === "gemini" ? "gemini-cli" : detected;
258
+ finalCommand = expandBuiltinAgentCommand(`builtin:${template}`);
250
259
  finalSource = "auto";
251
260
  if (!finalModel)
252
- finalModel = `builtin:${detected}`;
261
+ finalModel = `builtin:${template}`;
253
262
  }
254
263
  }
255
264
  return {
@@ -160,7 +160,7 @@ function auditRepairCli(runId, args) {
160
160
  * data can never drift from the standalone commands' output. */
161
161
  function auditSummaryCli(runId, args) {
162
162
  const run = (0, run_store_1.loadRunFromCwd)(runId, invocationCwd(args));
163
- return (0, trust_audit_1.summarizeTrustAudit)(run);
163
+ return (0, trust_audit_1.summarizeTrustAudit)(run, { persist: args.__cwWorkbenchReadOnlyProjection !== true });
164
164
  }
165
165
  function auditMultiAgentCli(runId, args) {
166
166
  const run = (0, run_store_1.loadRunFromCwd)(runId, invocationCwd(args));
@@ -199,7 +199,13 @@ function commitState(run, input) {
199
199
  // NOT prematurely move the run off "interpret".
200
200
  if (gate.verifierGated)
201
201
  run.loopStage = "checkpoint";
202
- (0, fs_atomic_1.writeJson)(snapshotPath, { commit, run });
202
+ // Only store the commit's own (small, bounded) record. The whole run
203
+ // (all tasks, nodes, workers, feedback, and run.commits itself) used to
204
+ // be embedded here too, but no reader anywhere ever parses a `run` key
205
+ // back out of a commits/<id>.json file — see docs/run-retention-reclamation.7.md.
206
+ // Embedding the growing run made total commit-dir bytes grow like N^2
207
+ // in the number of commits.
208
+ (0, fs_atomic_1.writeJson)(snapshotPath, { commit });
203
209
  run.commits.push(commit);
204
210
  return commit;
205
211
  }
@@ -214,7 +220,7 @@ function recordCommitNode(run, commit, options, gate) {
214
220
  artifacts: [{ id: "snapshot", kind: "json", path: commit.snapshotPath }],
215
221
  evidence: commit.evidence || verifierNode.evidence,
216
222
  metadata: { ...(options.metadata || {}), reason: options.reason, commitId: commit.id, verifierGated: true, checkpoint: false, verifierNodeId: verifierNode.id, candidateId: gate.candidateId, selectionId: gate.selectionId, selectionNodeId: gate.selectionNodeId },
217
- }, { persist: false, persistNode: node_store_1.writeRunNode });
223
+ }, { persist: false, persistNode: node_store_1.writeRunNode, pathExists: fs.existsSync });
218
224
  if (gate.selectionNodeId && commitResult.outputNodeId)
219
225
  linkAdditionalParent(run, gate.selectionNodeId, commitResult.outputNodeId);
220
226
  return commitResult.outputNodeId;