@reticlehq/server 2.10.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (290) hide show
  1. package/SKILL.md +73 -11
  2. package/dist/bridge/bridge.d.ts +0 -13
  3. package/dist/bridge/bridge.js +1 -1
  4. package/dist/bridge/bridge.test-harness.d.ts +2 -0
  5. package/dist/bridge/bridge.test-harness.js +8 -1
  6. package/dist/capsule/capsule-store.d.ts +2 -1
  7. package/dist/capsule/capsule-store.js +1 -1
  8. package/dist/capsule/causal-summary.d.ts +2 -2
  9. package/dist/chromium-launch-options.d.ts +28 -0
  10. package/dist/chromium-launch-options.js +29 -0
  11. package/dist/cli/cli-doctor.js +8 -0
  12. package/dist/cli/cli-flow-commands.d.ts +3 -1
  13. package/dist/cli/cli-flow-commands.js +34 -4
  14. package/dist/cli/cli-kill.d.ts +2 -1
  15. package/dist/cli/cli-parse.d.ts +3 -1
  16. package/dist/cli/cli-parse.js +29 -7
  17. package/dist/cli/cli-update-commands.js +20 -1
  18. package/dist/cli/cli-verify.d.ts +1 -1
  19. package/dist/cli/cli-verify.js +2 -2
  20. package/dist/cli/cloud-cli.js +12 -6
  21. package/dist/cli/config-discovery.d.ts +2 -1
  22. package/dist/cli/doctor-rows.d.ts +3 -1
  23. package/dist/cli/doctor-rows.js +3 -1
  24. package/dist/cli/gate-exit.d.ts +21 -0
  25. package/dist/cli/gate-exit.js +21 -0
  26. package/dist/cli/gate-hook-message.d.ts +33 -0
  27. package/dist/cli/gate-hook-message.js +46 -0
  28. package/dist/cli/mcp-command.d.ts +25 -0
  29. package/dist/cli/mcp-command.js +143 -0
  30. package/dist/cli/sibling-ports.d.ts +37 -0
  31. package/dist/cli/sibling-ports.js +56 -0
  32. package/dist/cli.js +37 -110
  33. package/dist/cloud/cloud-config.d.ts +2 -1
  34. package/dist/cloud/cloud-sync.d.ts +11 -0
  35. package/dist/cloud/cloud-sync.js +32 -0
  36. package/dist/crawl/crawl.d.ts +9 -13
  37. package/dist/crawl/crawl.js +20 -3
  38. package/dist/daemon/daemon-resolve.d.ts +79 -0
  39. package/dist/daemon/daemon-resolve.js +118 -0
  40. package/dist/daemon/free-port.d.ts +29 -0
  41. package/dist/daemon/free-port.js +55 -0
  42. package/dist/daemon/heartbeat.d.ts +0 -4
  43. package/dist/daemon/heartbeat.js +2 -2
  44. package/dist/dev/stale-issue-guard.d.ts +41 -0
  45. package/dist/dev/stale-issue-guard.js +75 -0
  46. package/dist/domain/domain-tools.js +3 -2
  47. package/dist/events/contradictions.d.ts +41 -0
  48. package/dist/events/contradictions.js +125 -12
  49. package/dist/events/declared.d.ts +14 -0
  50. package/dist/events/declared.js +33 -0
  51. package/dist/events/echo-mismatch.js +14 -0
  52. package/dist/events/observed-in-window.d.ts +12 -0
  53. package/dist/events/observed-in-window.js +17 -0
  54. package/dist/events/predicate-asks.d.ts +30 -0
  55. package/dist/events/predicate-asks.js +48 -0
  56. package/dist/events/predicate-eval.js +254 -25
  57. package/dist/events/predicate-parse.d.ts +0 -6
  58. package/dist/events/predicate-parse.js +51 -5
  59. package/dist/events/predicate-schema.d.ts +62 -0
  60. package/dist/events/predicate-schema.js +162 -4
  61. package/dist/events/predicate.d.ts +15 -0
  62. package/dist/events/predicate.js +41 -2
  63. package/dist/flows/coverage.d.ts +0 -8
  64. package/dist/flows/coverage.js +1 -1
  65. package/dist/flows/decision.d.ts +5 -1
  66. package/dist/flows/decision.js +33 -7
  67. package/dist/flows/flake.d.ts +0 -2
  68. package/dist/flows/flake.js +1 -1
  69. package/dist/flows/flow-intent.d.ts +52 -0
  70. package/dist/flows/flow-intent.js +123 -0
  71. package/dist/flows/flow-replay-run.js +27 -4
  72. package/dist/flows/flow-success.js +15 -3
  73. package/dist/flows/flow-tools.js +65 -8
  74. package/dist/flows/flows.d.ts +6 -1
  75. package/dist/flows/flows.js +34 -22
  76. package/dist/flows/predicate-to-expect.js +14 -3
  77. package/dist/flows/server-verify.js +16 -2
  78. package/dist/flows/verify-change-tools.js +9 -2
  79. package/dist/honesty/blind-spots.d.ts +8 -16
  80. package/dist/honesty/blind-spots.js +18 -5
  81. package/dist/honesty/feature-capture.d.ts +150 -0
  82. package/dist/honesty/feature-capture.js +233 -0
  83. package/dist/honesty/gap-ledger.d.ts +52 -0
  84. package/dist/honesty/gap-ledger.js +63 -0
  85. package/dist/honesty/instrumentation-gaps.d.ts +79 -0
  86. package/dist/honesty/instrumentation-gaps.js +77 -0
  87. package/dist/honesty/observability.d.ts +37 -0
  88. package/dist/honesty/observability.js +40 -0
  89. package/dist/honesty/tool-hit-rate.d.ts +86 -0
  90. package/dist/honesty/tool-hit-rate.js +90 -0
  91. package/dist/honesty/undeclared-change.d.ts +51 -0
  92. package/dist/honesty/undeclared-change.js +56 -0
  93. package/dist/honesty/verified.d.ts +15 -0
  94. package/dist/honesty/verified.js +68 -4
  95. package/dist/http-server.d.ts +0 -1
  96. package/dist/http-server.js +1 -1
  97. package/dist/hunt/hunt-report.d.ts +0 -2
  98. package/dist/hunt/hunt-report.js +1 -1
  99. package/dist/impact/impact-store.d.ts +0 -5
  100. package/dist/impact/impact-store.js +1 -1
  101. package/dist/index.d.ts +4 -2
  102. package/dist/index.js +41 -0
  103. package/dist/init/agent-rules.d.ts +17 -0
  104. package/dist/init/agent-rules.js +31 -1
  105. package/dist/init/capabilities.d.ts +0 -2
  106. package/dist/init/capabilities.js +1 -1
  107. package/dist/init/closing-hint.d.ts +36 -0
  108. package/dist/init/closing-hint.js +120 -0
  109. package/dist/init/confirm.d.ts +0 -25
  110. package/dist/init/confirm.js +3 -3
  111. package/dist/init/cra.d.ts +14 -1
  112. package/dist/init/cra.js +43 -12
  113. package/dist/init/dev-script.d.ts +48 -0
  114. package/dist/init/dev-script.js +63 -0
  115. package/dist/init/mcp.d.ts +16 -5
  116. package/dist/init/mcp.js +26 -10
  117. package/dist/init/node-io.js +3 -0
  118. package/dist/init/plan-framework.d.ts +0 -5
  119. package/dist/init/plan-framework.js +83 -9
  120. package/dist/init/plan.d.ts +10 -30
  121. package/dist/init/plan.js +30 -15
  122. package/dist/init/refresh-rules.d.ts +39 -0
  123. package/dist/init/refresh-rules.js +51 -0
  124. package/dist/init/run.d.ts +2 -10
  125. package/dist/init/run.js +26 -68
  126. package/dist/init/slash-command.d.ts +1 -1
  127. package/dist/init/slash-command.js +33 -10
  128. package/dist/init/snippets.js +53 -10
  129. package/dist/input/real-input.d.ts +2 -0
  130. package/dist/input/real-input.js +3 -2
  131. package/dist/input/scroll-find.d.ts +7 -4
  132. package/dist/input/scroll-find.js +63 -25
  133. package/dist/intent/inline-intent.d.ts +46 -0
  134. package/dist/intent/inline-intent.js +103 -0
  135. package/dist/intent/intent-store.d.ts +37 -0
  136. package/dist/intent/intent-store.js +112 -0
  137. package/dist/intent/intent-tools.d.ts +2 -0
  138. package/dist/intent/intent-tools.js +80 -0
  139. package/dist/intent/open-intents.d.ts +21 -0
  140. package/dist/intent/open-intents.js +25 -0
  141. package/dist/journal/envelope.d.ts +2 -2
  142. package/dist/journal/journal-recorder.d.ts +23 -0
  143. package/dist/journal/journal-recorder.js +35 -0
  144. package/dist/license/license.d.ts +8 -2
  145. package/dist/license/license.js +10 -15
  146. package/dist/mcp/attach-memory.d.ts +67 -0
  147. package/dist/mcp/attach-memory.js +142 -0
  148. package/dist/mcp/mcp-proxy.d.ts +0 -7
  149. package/dist/mcp/mcp-proxy.js +14 -2
  150. package/dist/mcp/mcp.d.ts +2 -0
  151. package/dist/mcp/mcp.js +45 -23
  152. package/dist/mcp/server-instructions.js +45 -3
  153. package/dist/mcp/tool-catalog-cache.d.ts +8 -0
  154. package/dist/mcp/tool-catalog-cache.js +10 -0
  155. package/dist/oracles/flow-instrument-gaps.d.ts +4 -4
  156. package/dist/oracles/flow-instrument-gaps.js +4 -5
  157. package/dist/oracles/self-instrument.d.ts +9 -9
  158. package/dist/oracles/self-instrument.js +19 -13
  159. package/dist/pool/browser-pool.d.ts +12 -1
  160. package/dist/pool/browser-pool.js +31 -1
  161. package/dist/pool/playwright-launcher.js +3 -1
  162. package/dist/project/artifact-root.d.ts +83 -0
  163. package/dist/project/artifact-root.js +112 -0
  164. package/dist/project/fs-port.d.ts +3 -0
  165. package/dist/project/fs-port.js +3 -2
  166. package/dist/project/memory-fs.d.ts +9 -0
  167. package/dist/project/memory-fs.js +93 -0
  168. package/dist/project/project-store.d.ts +17 -0
  169. package/dist/project/project-store.js +33 -0
  170. package/dist/project/project-tools.js +3 -32
  171. package/dist/project/remember-project.d.ts +24 -0
  172. package/dist/project/remember-project.js +40 -0
  173. package/dist/project/reticle-dir.d.ts +2 -0
  174. package/dist/project/reticle-dir.js +1 -0
  175. package/dist/project/session-root.d.ts +15 -0
  176. package/dist/project/session-root.js +31 -0
  177. package/dist/runs/context-tools.d.ts +2 -0
  178. package/dist/runs/context-tools.js +89 -0
  179. package/dist/runs/run-context.d.ts +40 -0
  180. package/dist/runs/run-context.js +158 -0
  181. package/dist/runs/run-diff.d.ts +2 -1
  182. package/dist/runs/run-tools.js +2 -1
  183. package/dist/runs/verification-sync.js +2 -2
  184. package/dist/session/dev-server-probe.d.ts +0 -8
  185. package/dist/session/dev-server-probe.js +1 -1
  186. package/dist/session/last-act.d.ts +7 -1
  187. package/dist/session/last-act.js +2 -2
  188. package/dist/session/lease-availability.d.ts +40 -0
  189. package/dist/session/lease-availability.js +43 -0
  190. package/dist/session/live-control-tools.js +1 -42
  191. package/dist/session/no-session-diagnosis.d.ts +20 -0
  192. package/dist/session/no-session-diagnosis.js +41 -12
  193. package/dist/session/no-session-next-action.d.ts +5 -0
  194. package/dist/session/no-session-next-action.js +35 -3
  195. package/dist/session/no-session-watch.d.ts +8 -0
  196. package/dist/session/no-session-watch.js +83 -65
  197. package/dist/session/output-budget.js +4 -1
  198. package/dist/session/session-health.d.ts +9 -0
  199. package/dist/session/session-health.js +23 -0
  200. package/dist/session/session-manager.js +50 -1
  201. package/dist/session/session-successor.d.ts +37 -0
  202. package/dist/session/session-successor.js +78 -0
  203. package/dist/session/session.d.ts +40 -1
  204. package/dist/session/session.js +84 -2
  205. package/dist/session/stall-clock.d.ts +18 -0
  206. package/dist/session/stall-clock.js +48 -0
  207. package/dist/telemetry/automation-hint.d.ts +2 -1
  208. package/dist/telemetry/daemon-telemetry.js +12 -23
  209. package/dist/telemetry/feedback-outbox.d.ts +2 -1
  210. package/dist/telemetry/project-profile.d.ts +0 -8
  211. package/dist/telemetry/project-profile.js +1 -1
  212. package/dist/telemetry/run-telemetry.d.ts +0 -2
  213. package/dist/telemetry/run-telemetry.js +1 -1
  214. package/dist/telemetry/session-metrics.d.ts +1 -1
  215. package/dist/telemetry/session-metrics.js +1 -1
  216. package/dist/telemetry/telemetry.d.ts +12 -3
  217. package/dist/telemetry/telemetry.js +25 -5
  218. package/dist/telemetry/verification-of.js +2 -2
  219. package/dist/tools/act-tools.js +159 -24
  220. package/dist/tools/assert-source.d.ts +22 -0
  221. package/dist/tools/assert-source.js +53 -0
  222. package/dist/tools/assert-verdict.d.ts +21 -2
  223. package/dist/tools/assert-verdict.js +59 -4
  224. package/dist/tools/browser-tools.js +0 -20
  225. package/dist/tools/complete-snapshot.d.ts +48 -0
  226. package/dist/tools/complete-snapshot.js +75 -0
  227. package/dist/tools/contract-tools.js +5 -3
  228. package/dist/tools/coverage-tools.d.ts +10 -1
  229. package/dist/tools/coverage-tools.js +124 -37
  230. package/dist/tools/dynamic-tools.js +29 -8
  231. package/dist/tools/error-recovery.d.ts +24 -14
  232. package/dist/tools/error-recovery.js +70 -9
  233. package/dist/tools/feedback-tools.d.ts +1 -1
  234. package/dist/tools/feedback-tools.js +2 -2
  235. package/dist/tools/invoke-tool.js +62 -2
  236. package/dist/tools/lease-hint.d.ts +10 -0
  237. package/dist/tools/lease-hint.js +53 -4
  238. package/dist/tools/lease-tools.d.ts +5 -1
  239. package/dist/tools/lease-tools.js +22 -8
  240. package/dist/tools/navigate-arrival.d.ts +0 -2
  241. package/dist/tools/navigate-arrival.js +1 -1
  242. package/dist/tools/observe-tools.js +70 -27
  243. package/dist/tools/read-tools.js +5 -3
  244. package/dist/tools/real-input-attempt.d.ts +22 -0
  245. package/dist/tools/real-input-attempt.js +153 -1
  246. package/dist/tools/reconcile-tools.js +18 -4
  247. package/dist/tools/resolve-target.d.ts +0 -7
  248. package/dist/tools/sdk-marker-probe.d.ts +29 -0
  249. package/dist/tools/sdk-marker-probe.js +57 -0
  250. package/dist/tools/snapshot-delta.d.ts +1 -1
  251. package/dist/tools/tool-kit.d.ts +28 -1
  252. package/dist/tools/tool-kit.js +16 -0
  253. package/dist/tools/tool-names.d.ts +3 -0
  254. package/dist/tools/tool-names.js +3 -0
  255. package/dist/tools/tool-surface.d.ts +85 -6
  256. package/dist/tools/tool-surface.js +122 -2
  257. package/dist/tools/tools.js +22 -4
  258. package/dist/tools/unadvertised-help.js +4 -4
  259. package/dist/tools/verify-next-baton.d.ts +53 -0
  260. package/dist/tools/verify-next-baton.js +52 -0
  261. package/dist/update/update-nudge.d.ts +7 -10
  262. package/dist/update/update-nudge.js +12 -3
  263. package/dist/update/updater.d.ts +1 -1
  264. package/docs/README.md +3 -1
  265. package/docs/cli/cloud.mdx +1 -1
  266. package/docs/cli/doctor.mdx +1 -0
  267. package/docs/cli/verify.mdx +4 -5
  268. package/docs/desktop.mdx +1 -1
  269. package/docs/docs.json +3 -3
  270. package/docs/enterprise.md +3 -1
  271. package/docs/gates.md +1 -1
  272. package/docs/install-manual.mdx +1 -1
  273. package/docs/license-activation.mdx +145 -0
  274. package/docs/packages/browser.mdx +2 -2
  275. package/docs/packages/server.mdx +1 -1
  276. package/docs/packages.mdx +8 -6
  277. package/docs/platform-integration.md +17 -0
  278. package/docs/predicates.mdx +8 -0
  279. package/docs/reticle-manual.md +106 -0
  280. package/docs/telemetry-contract.md +1 -12
  281. package/docs/telemetry-events.mdx +1 -34
  282. package/docs/telemetry.md +0 -1
  283. package/docs/tools-tools-and-run.mdx +2 -4
  284. package/docs/troubleshooting.mdx +9 -0
  285. package/docs/usage.md +3 -2
  286. package/package.json +2 -2
  287. package/dist/session/session-readiness.d.ts +0 -34
  288. package/dist/session/session-readiness.js +0 -36
  289. package/dist/telemetry/instrumentation-stall.d.ts +0 -57
  290. package/dist/telemetry/instrumentation-stall.js +0 -103
package/dist/cli.js CHANGED
@@ -6,7 +6,10 @@ import { join } from 'node:path';
6
6
  import { stateDirProblem } from './daemon/state-dir.js';
7
7
  import { statusNextAction } from './cli/status-next-action.js';
8
8
  import { hasConnectedBefore } from './session/connection-memory.js';
9
+ import { attachStatusFields } from './mcp/attach-memory.js';
9
10
  import { reticleStateHome } from './daemon/daemon.js';
11
+ import { handleMcp } from './cli/mcp-command.js';
12
+ import { resolveDaemonForProject } from './daemon/daemon-resolve.js';
10
13
  import { handleWatch, handleCapsules, handleGate, loadNamedFlows, resolveChangedFiles, } from './cli/cli-flow-commands.js';
11
14
  import { RETICLE_DEFAULT_PORT, ReticleDir, ReticleEnv } from '@reticlehq/core';
12
15
  import { loadDotEnv } from './telemetry/dev-repo.js';
@@ -20,8 +23,8 @@ import { SERVER_VERSION } from './version/server-version.js';
20
23
  import { log } from './log.js';
21
24
  import { readPid, isAlive, removePid, spawnDaemon, discoverDaemonPort, writeDaemonRegistry, logPath, } from './daemon/daemon.js';
22
25
  import { PortPresence, probePresence, presenceIsUsable, describePresence, } from './daemon/port-presence.js';
23
- import { waitForDaemon, startMcpProxy, probeDaemon, proxyLog, setProxyLogPort, } from './mcp/mcp-proxy.js';
24
- import { installDaemonResilience, installProxyResilience, recordExitReason, DaemonExitReason, } from './daemon/daemon-resilience.js';
26
+ import { waitForDaemon, probeDaemon } from './mcp/mcp-proxy.js';
27
+ import { installDaemonResilience, recordExitReason, DaemonExitReason, } from './daemon/daemon-resilience.js';
25
28
  import { IdleShutdown, resolveIdleShutdownMs, resolveIdleCheckMs } from './daemon/idle-shutdown.js';
26
29
  import { DaemonHeartbeat, resolveHeartbeatMs } from './daemon/heartbeat.js';
27
30
  import { everServedToolCall } from './daemon/daemon-usefulness.js';
@@ -238,6 +241,10 @@ function handleStatus(port) {
238
241
  // path does the first without the second — so this is the commonest reason `status` has nothing to
239
242
  // report, and it was not among the facts this command could state.
240
243
  const initialized = projectId !== undefined;
244
+ // The OTHER half of the install, and the half this command could not see. A host that registers
245
+ // Reticle and never lists its tools looks exactly like an abandoned install from here — same idle
246
+ // daemon, same zero sessions — and the user sees a tool that does nothing. See attach-memory.ts.
247
+ const client = attachStatusFields(reticleStateHome(), port);
241
248
  if (null === pid || !isAlive(pid)) {
242
249
  // `running: false` on its own has been reported about a port that was demonstrably occupied,
243
250
  // because the pid file is not the port. Ask the port before answering.
@@ -252,6 +259,7 @@ function handleStatus(port) {
252
259
  // `running: false` and nothing else, which reads as "Reticle is broken" for what is usually
253
260
  // just a daemon that has not been asked to do anything yet.
254
261
  ...withNextAction({ running, sessionCount: 0, previouslyConnected, initialized }),
262
+ ...client,
255
263
  });
256
264
  });
257
265
  return;
@@ -270,6 +278,7 @@ function handleStatus(port) {
270
278
  pid,
271
279
  ...nudge,
272
280
  ...withNextAction({ running: true, sessionCount: 0, previouslyConnected, initialized }),
281
+ ...client,
273
282
  });
274
283
  return;
275
284
  }
@@ -280,7 +289,7 @@ function handleStatus(port) {
280
289
  const next = summary.why === undefined
281
290
  ? withNextAction({ running: true, ...summary, previouslyConnected, initialized })
282
291
  : {};
283
- log('reticle_status', { port, running: true, pid, ...summary, ...next, ...nudge });
292
+ log('reticle_status', { port, running: true, pid, ...summary, ...next, ...client, ...nudge });
284
293
  });
285
294
  }
286
295
  /** Print the running package version (resolved once in server-version.ts). */
@@ -398,8 +407,17 @@ async function waitForNewSession(port, before) {
398
407
  return false;
399
408
  }
400
409
  function handleOpen(requestedPort, url) {
410
+ // Our project's daemon first: `discoverDaemonPort` returns the LOWEST live daemon on the machine,
411
+ // whoever it belongs to, so on a machine running two projects `reticle open` could drive the other
412
+ // one's browser. It stays as the last resort for a caller with no project id, where any daemon is
413
+ // better than none and there is no identity to confuse.
414
+ const myProject = readProjectId(process.cwd());
401
415
  probeDaemon(requestedPort)
402
- .then((here) => (here ? requestedPort : (discoverDaemonPort() ?? requestedPort)))
416
+ .then((here) => here
417
+ ? requestedPort
418
+ : (resolveDaemonForProject(myProject, reticleStateHome(), isAlive) ??
419
+ discoverDaemonPort() ??
420
+ requestedPort))
403
421
  .then(async (port) => {
404
422
  await ensureDaemon(port);
405
423
  const { sessions } = summarizeStatus(await fetchStatus(port));
@@ -585,109 +603,6 @@ function handleDaemonInner(parsed) {
585
603
  process.exit(1);
586
604
  });
587
605
  }
588
- /**
589
- * MCP proxy mode: ensures the daemon is running, then bridges Claude Code's
590
- * stdin/stdout to the daemon's SSE endpoint. This is the recommended way to
591
- * configure Reticle in.mcp.json — users never need to manage the daemon manually.
592
- *
593
- * Pass --drive <url> to have the daemon launch its own Playwright browser at that
594
- * URL. The agent then has full autonomous control without relying on the user's browser.
595
- */
596
- function handleMcp(opts) {
597
- const { port, driveUrl, headless, http, httpPort, httpToken } = opts;
598
- // The proxy IS the MCP server the editor launched. Nothing respawns it, so an uncaught throw here
599
- // is what a user experiences as "the MCP server disconnected — open /mcp and reconnect". Log it,
600
- // report it, keep serving: the reconnect and dormant paths already know how to rebuild the only
601
- // state this process has. See installProxyResilience for why its rule inverts the daemon's.
602
- // The proxy's crash handlers must write to the proxy's LOG FILE, not just stderr. The editor
603
- // swallows stderr, so wiring these to `log` meant a crash was handled and then unrecorded — the
604
- // failure a user reports as "it disconnected" left nothing behind to read.
605
- setProxyLogPort(port);
606
- installProxyResilience(process, proxyLog);
607
- /**
608
- * Make sure a daemon is on the port, spawning one if not.
609
- *
610
- * Called on first start AND on every reconnect. The proxy used to only retry the socket, so a
611
- * daemon that exited — crashed, `reticle stop`, or self-shut-down as idle — meant the retries hit
612
- * a dead port until the budget ran out and the agent's MCP server exited with it. Reticle simply
613
- * disappeared mid-session with nothing said. Respawning here makes the reconnect self-healing.
614
- */
615
- const ensure = async () => {
616
- // The same question every other surface asks. It used to be a bare TCP connect, so a stranger on
617
- // the bridge port answered "a daemon is here" — the proxy connected, the stream ended, and each
618
- // client request woke into the identical non-answer. Rejecting here is what puts the proxy
619
- // dormant with a reason, instead of pretending the wake succeeded.
620
- const presence = await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus });
621
- if (presenceIsUsable(presence))
622
- return;
623
- if (PortPresence.FREE !== presence)
624
- throw new Error(describePresence(presence, port));
625
- const scriptPath = process.argv[1];
626
- if (scriptPath === undefined) {
627
- log('reticle_mcp_no_script', {});
628
- process.exit(1);
629
- }
630
- const daemonArgs = [DAEMON_INNER_COMMAND, PORT_FLAG, String(port)];
631
- if (driveUrl !== undefined) {
632
- daemonArgs.push(DRIVE_FLAG, driveUrl);
633
- if (!headless)
634
- daemonArgs.push(HEADED_FLAG);
635
- }
636
- // Forward the HTTP-verify flags too (previously silently dropped for `reticle mcp`).
637
- if (http) {
638
- daemonArgs.push(HTTP_FLAG);
639
- if (httpPort !== undefined)
640
- daemonArgs.push(HTTP_PORT_FLAG, String(httpPort));
641
- if (httpToken !== undefined)
642
- daemonArgs.push(HTTP_TOKEN_FLAG, httpToken);
643
- }
644
- spawnDaemon(process.execPath, scriptPath, daemonArgs, port);
645
- // Announce the daemon only once the PORT ACCEPTS. This line used to be written the instant the
646
- // child was spawned, which on a Windows first bootstrap meant `reticle_mcp_daemon_started`
647
- // followed about ten seconds later by `reticle_mcp_daemon_unavailable` and a first
648
- // `reticle_sessions` that expired. A readiness signal that precedes readiness is worse than
649
- // none: a client that believes it stops waiting for the thing that has not happened.
650
- await waitForDaemon(port);
651
- log('reticle_mcp_daemon_started', { port, ...(driveUrl !== undefined ? { driveUrl } : {}) });
652
- };
653
- // Start the proxy WHATEVER happened to the daemon.
654
- //
655
- // This used to exit(1) when `ensure` failed, which is the third way an MCP server disappears on a
656
- // user: something else is holding the bridge port — a foreign daemon from another project, a
657
- // half-dead process, a port a colleague's tool grabbed — the spawned daemon cannot bind, and the
658
- // editor shows a server that failed to start. Nothing about that is unrecoverable: the proxy
659
- // answers `initialize` itself, serves the cached catalog, and its wake path retries a daemon on
660
- // every client request. Present-and-complaining beats absent, because absent needs a human.
661
- // `void`: the chain handles its own failure and the process must not wait on it — the proxy is
662
- // started from `finally` either way.
663
- void ensure()
664
- .catch((err) => {
665
- log('reticle_mcp_daemon_unavailable', {
666
- error: err instanceof Error ? err.message : String(err),
667
- note: 'serving anyway — the next tool call will try to start a daemon again',
668
- });
669
- })
670
- .finally(() => {
671
- // Also `void`: the proxy runs for the life of the process and is never awaited by anyone.
672
- //
673
- // The `.catch` is load-bearing despite that. `startMcpProxy` rejects when its FIRST connect
674
- // fails — which is precisely the case this block exists to tolerate, a daemon that is not
675
- // there yet — and with nothing attached that reject became an unhandledRejection. Reported
676
- // from a win32 user as a crash reading `connect ECONNREFUSED` with an EMPTY frame list,
677
- // because the stack of a refused socket is entirely node internals and the privacy filter
678
- // keeps only Reticle frames. So the one path we most want diagnosable arrived as an anonymous
679
- // crash. The proxy itself is unaffected — it has already installed its stdin reader and goes
680
- // on serving from cache, waking a daemon on the next request — which is exactly why this
681
- // must be logged as the expected condition it is rather than reported as a defect.
682
- void startMcpProxy(port, ensure).catch((err) => {
683
- log('reticle_mcp_proxy_first_connect_failed', {
684
- port,
685
- error: err instanceof Error ? err.message : String(err),
686
- note: 'serving from cache; the next client request will try to start a daemon',
687
- });
688
- });
689
- });
690
- }
691
606
  function main() {
692
607
  // Before anything reads process.env — notably the telemetry gate and the bridge's security
693
608
  // options — fold in a project-local `.env`. Values already in the environment always win.
@@ -718,7 +633,19 @@ function main() {
718
633
  if (projectPort !== undefined && isLikelyDevServerPort(projectPort)) {
719
634
  process.stderr.write(`${devServerPortWarning(projectPort)}\n`);
720
635
  }
721
- const defaultPort = envPort ?? projectPort ?? RETICLE_DEFAULT_PORT;
636
+ // Registry BEFORE the default, and after both explicit sources.
637
+ //
638
+ // This is the line that ends the split brain. Build plugins have always asked the registry which
639
+ // daemon serves this project; the CLI asked a number and then attached to whoever owned it. So
640
+ // every project on the machine funnelled into one daemon whose identity was whichever project won
641
+ // the race, and one kill on one well-known port was a machine-wide outage.
642
+ //
643
+ // An explicit port still wins, because a person who typed one is answering this question
644
+ // themselves. Below that, our OWN daemon wherever it is listening. Only then the default, which is
645
+ // now a starting preference rather than an assumption.
646
+ const myProjectId = readProjectId(process.cwd());
647
+ const myDaemonPort = resolveDaemonForProject(myProjectId, reticleStateHome(), isAlive);
648
+ const defaultPort = envPort ?? projectPort ?? myDaemonPort ?? RETICLE_DEFAULT_PORT;
722
649
  // Headed by default; hidden only where there is no display to be headed on. A run nobody can see
723
650
  // is a run nobody trusts, and every "did it actually do anything?" cost a human round-trip.
724
651
  const parsed = parseCliArgs(argv, defaultPort, process.env['CI'] !== undefined);
@@ -792,7 +719,7 @@ function main() {
792
719
  void handleHunt(parsed.dir);
793
720
  break;
794
721
  case 'gate':
795
- void handleGate(parsed.files, parsed.since);
722
+ void handleGate(parsed.files, parsed.since, parsed.hook);
796
723
  break;
797
724
  case 'watch':
798
725
  handleWatch();
@@ -804,7 +731,7 @@ function main() {
804
731
  void handleRollback();
805
732
  break;
806
733
  case 'mcp':
807
- handleMcp(parsed);
734
+ void handleMcp(parsed);
808
735
  break;
809
736
  case '_daemon':
810
737
  handleDaemonInner(parsed);
@@ -5,7 +5,7 @@ export declare const CLOUD_LINK_FILE = "cloud.json";
5
5
  /** `~/.reticle/credentials.json` — the user's per-cloud-project API keys (the only secret). */
6
6
  export declare const CREDENTIALS_FILE = "credentials.json";
7
7
  /** What the daemon mirrors to the cloud for a project. Each surface is independently toggleable. */
8
- export interface SyncPolicy {
8
+ interface SyncPolicy {
9
9
  /** Push verification-run artifacts to the dashboard Runs tab. */
10
10
  runs: boolean;
11
11
  /** Push per-flow project-memory outcomes (the regression history). */
@@ -33,3 +33,4 @@ export interface ProjectCloud {
33
33
  * so the single-project / CI flow is unchanged. `homeDir` is injected (testable; `os.homedir()` at call).
34
34
  */
35
35
  export declare function resolveProjectCloud(fs: FileSystemPort, reticleRoot: string, homeDir: string, env: NodeJS.ProcessEnv): Promise<ProjectCloud>;
36
+ export {};
@@ -21,6 +21,17 @@ export interface CloudConfig {
21
21
  }
22
22
  /** Resolve cloud credentials from the environment, or null when not logged in (sync disabled). */
23
23
  export declare function resolveCloudConfig(env: NodeJS.ProcessEnv): CloudConfig | null;
24
+ export declare const CLOUD_VERIFY_TIMEOUT_MS = 120000;
25
+ /**
26
+ * The fetch every cloud call goes through. Adds the abort signal, and turns the abort into a message
27
+ * that says what happened and what to do — a bare `AbortError` reaching a user or an agent is a riddle,
28
+ * and the agent-facing half of this product is judged on whether its errors are actionable.
29
+ */
30
+ export declare function cloudFetch(url: string, init: {
31
+ method: string;
32
+ headers: Record<string, string>;
33
+ body?: string;
34
+ }, timeoutMs?: number): Promise<Response>;
24
35
  export declare const SyncOutcome: {
25
36
  readonly SYNCED: "synced";
26
37
  readonly SKIPPED: "skipped";
@@ -30,6 +30,38 @@ export function resolveCloudConfig(env) {
30
30
  return null;
31
31
  return { url: url.replace(/\/+$/, ''), apiKey };
32
32
  }
33
+ /**
34
+ * Budget for a cloud call. Node's `fetch` has NO default timeout, so a connection that opens and then
35
+ * stalls never settles — and the caller `await`s it forever. In `reticle_flow_verify` that is an MCP
36
+ * tool call that never returns, and in `reticle cloud …` a terminal that prints nothing.
37
+ *
38
+ * One shared budget covers every call here: they are all small JSON request/response pairs against the
39
+ * same API, so there is nothing to tune per site. The ONE exception is the hosted verification submit,
40
+ * which blocks while a real browser runs the suite server-side — a normal response there is minutes,
41
+ * not seconds, so it gets its own (still bounded) budget rather than dragging the common one up.
42
+ */
43
+ const CLOUD_FETCH_TIMEOUT_MS = 30_000;
44
+ export const CLOUD_VERIFY_TIMEOUT_MS = 120_000;
45
+ /** Names Node gives an abort: `AbortSignal.timeout` raises TimeoutError, an explicit abort AbortError. */
46
+ const TIMEOUT_ERROR_NAMES = new Set(['TimeoutError', 'AbortError']);
47
+ /**
48
+ * The fetch every cloud call goes through. Adds the abort signal, and turns the abort into a message
49
+ * that says what happened and what to do — a bare `AbortError` reaching a user or an agent is a riddle,
50
+ * and the agent-facing half of this product is judged on whether its errors are actionable.
51
+ */
52
+ export async function cloudFetch(url, init, timeoutMs = CLOUD_FETCH_TIMEOUT_MS) {
53
+ try {
54
+ return await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
55
+ }
56
+ catch (err) {
57
+ if (err instanceof Error && TIMEOUT_ERROR_NAMES.has(err.name)) {
58
+ throw new Error(`Reticle Cloud request timed out after ${Math.round(timeoutMs / 1000)}s: ${init.method} ${url}. ` +
59
+ `The server accepted the connection but never answered. Check the network and ${CloudEnv.URL}, then retry — ` +
60
+ `verification works locally without cloud.`);
61
+ }
62
+ throw err;
63
+ }
64
+ }
33
65
  export const SyncOutcome = {
34
66
  SYNCED: 'synced',
35
67
  SKIPPED: 'skipped',
@@ -4,6 +4,15 @@ export interface CrawlSession {
4
4
  command(name: string, args?: Record<string, unknown>): Promise<CommandResult>;
5
5
  elapsed(): number;
6
6
  eventsSince(cursor: number): ReticleEvent[];
7
+ /**
8
+ * Which document is on screen now, so a finding is never drawn from a page the crawl has already
9
+ * navigated away from. Optional for the same reason `beginAction` is: a test fake supplies a
10
+ * minimal session, and its absence leaves the scoping inert rather than broken.
11
+ */
12
+ readonly currentDocumentId?: string | undefined;
13
+ /** Which round of source edits is in force, so a finding drawn entirely from pre-edit evidence
14
+ * says so. Optional for the same reason `currentDocumentId` is. */
15
+ readonly currentEditEpoch?: number | undefined;
7
16
  /**
8
17
  * Attribution window around each click. Optional so a caller can supply a minimal session, but a real
9
18
  * session MUST provide it: without a window the click's own effects carry no actionId, and an
@@ -44,19 +53,6 @@ export interface CrawlAnomaly {
44
53
  }
45
54
  /** Said out loud when the page was larger than one snapshot, so a clean sweep cannot mean "all clear". */
46
55
  export declare const CAPPED_SNAPSHOT_NOTE = "the page exceeded one snapshot, so controls past the cap were never seen \u2014 interactiveFound is a floor, not a total; re-run with a narrower `scope` to cover the rest";
47
- /**
48
- * Said out loud when the crawl's OWN clicks put controls on the page that it then never visited.
49
- *
50
- * The enumeration happens once, before the first click, so a control that only exists after a login,
51
- * a modal or a tab switch is invisible to this run. Measured on the bench app: a crawl of the login
52
- * screen visited three controls, its click on "Sign in" produced seven more, and it returned
53
- * `truncated: false` with an empty anomaly list and nine of twelve steps unused. Read as a sweep,
54
- * that is a clean bill of health for an app the crawl never entered.
55
- *
56
- * The same words as the capped-snapshot case on purpose — it is the same idea (interactiveFound is a
57
- * floor) reached by a different route, and a second vocabulary for it would just be more to learn.
58
- */
59
- export declare function revealedControlsNote(count: number): string;
60
56
  export interface CrawlReport {
61
57
  interactiveFound: number;
62
58
  stepsRun: number;
@@ -18,7 +18,7 @@ export const CAPPED_SNAPSHOT_NOTE = 'the page exceeded one snapshot, so controls
18
18
  * The same words as the capped-snapshot case on purpose — it is the same idea (interactiveFound is a
19
19
  * floor) reached by a different route, and a second vocabulary for it would just be more to learn.
20
20
  */
21
- export function revealedControlsNote(count) {
21
+ function revealedControlsNote(count) {
22
22
  return (`the page changed as you clicked: ${String(count)} control(s) that did not exist when this crawl ` +
23
23
  'started were never visited — interactiveFound is a floor, not a total; re-run the crawl from ' +
24
24
  'the state you are now in to cover them');
@@ -65,7 +65,19 @@ export function legitimatelyInert(desc) {
65
65
  return true;
66
66
  if (desc.includes('[checked]'))
67
67
  return true;
68
- return /\b(textbox|searchbox|combobox|spinbutton)\b/.test(desc);
68
+ // Inputs, and the ARIA LIVE-REGION roles.
69
+ //
70
+ // `alert`, `status`, `log`, `timer` and `marquee` exist to be READ — a screen reader announces
71
+ // them and nothing is meant to happen when one is clicked. Clicking twice and seeing nothing is
72
+ // therefore correct behaviour, and reporting it as `dead-control` accuses a working app.
73
+ //
74
+ // Found by driving a real app: the demo reported `dead-control — alert "Invalid email or
75
+ // password"`, which is a login form correctly telling somebody their password was wrong. That is
76
+ // the most expensive kind of false positive there is — the strongest claim the crawler makes,
77
+ // about an app that was working, and on the path a new user meets first.
78
+ //
79
+ // `alertdialog` is deliberately NOT here: it is interactive, and a word boundary keeps it out.
80
+ return /\b(textbox|searchbox|combobox|spinbutton|alert|status|log|timer|marquee)\b/.test(desc);
69
81
  }
70
82
  /**
71
83
  * How many interactive controls exist now that did not exist when the crawl enumerated.
@@ -199,7 +211,12 @@ export async function crawl(session, opts, sleep) {
199
211
  }
200
212
  // CONTRADICTIONS: the channels disagree about what this click did. Reported per control, so a
201
213
  // crawl of an unknown app surfaces its false greens without anyone knowing where to look.
202
- for (const c of findContradictions(events)) {
214
+ // A crawl clicks controls that navigate, so its windows are the ones most likely to straddle a
215
+ // document boundary — and a finding here is reported against a specific control by name.
216
+ for (const c of findContradictions(events, {
217
+ currentDocumentId: session.currentDocumentId,
218
+ currentEditEpoch: session.currentEditEpoch,
219
+ })) {
203
220
  counts.contradictions += 1;
204
221
  anomalies.push({
205
222
  kind: c.kind,
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Which daemon does THIS project belong to?
3
+ *
4
+ * The system answered that two incompatible ways. Build plugins asked the registry by projectId, the
5
+ * way `pickDaemonPort` was designed to be asked. The CLI and the MCP proxy asked a NUMBER —
6
+ * `envPort ?? projectPort ?? 4400` — and then attached to whatever owned it, whoever it belonged to.
7
+ *
8
+ * So every project on a machine funnelled into one daemon. Its recorded identity was whichever
9
+ * project won the race to the port, which made the registry entry wrong the moment a second project
10
+ * attached, and made one `kill` on one well-known port a machine-wide outage for every agent at once.
11
+ *
12
+ * It worked anyway, by luck: everyone defaulted to the same number, so everyone found each other.
13
+ * The luck ends the moment a daemon is anywhere but the default — which is exactly when discovery was
14
+ * supposed to help, and exactly when it returned nothing.
15
+ *
16
+ * This module is the one answer both sides now use. It is pure and injectable: no process spawning,
17
+ * no port binding, nothing that needs a real `~/.reticle` to test.
18
+ */
19
+ import { type DaemonRegistryEntry } from '@reticlehq/core';
20
+ /**
21
+ * Every well-formed registry entry in `home`.
22
+ *
23
+ * A corrupt or half-written entry is skipped rather than thrown on: the registry is written by other
24
+ * processes that can be killed mid-write, and one bad file must not make a machine's daemons
25
+ * undiscoverable. Validated through core's schema so a shape change cannot silently pass here.
26
+ */
27
+ export declare function readDaemonRegistry(home: string): DaemonRegistryEntry[];
28
+ /**
29
+ * The port of the live daemon serving `projectId`, or undefined when this project has none.
30
+ *
31
+ * Undefined means "start one", never "use the default and hope". That distinction is the fix: the old
32
+ * path had no way to express "no daemon of mine exists", so it fell through to a number and attached
33
+ * to a stranger.
34
+ *
35
+ * The selection rule is core's `pickDaemonPort`, shared with every build plugin, so the daemon side
36
+ * and the app side cannot disagree about which daemon is ours.
37
+ */
38
+ export declare function resolveDaemonForProject(projectId: string | undefined, home: string, alive: (pid: number) => boolean): number | undefined;
39
+ /**
40
+ * May `caller` take over a daemon already sitting on the port it wanted?
41
+ *
42
+ * Two deliberate escape hatches, both of which keep an existing install working:
43
+ *
44
+ * A daemon that claims NO project was started somewhere without a `.reticle.json` — the global MCP
45
+ * registration in a directory that is not an app, which is the majority of daemons in the field. It
46
+ * belongs to nobody, so it belongs to whoever asks; refusing would break every user running Reticle
47
+ * globally, which is how it is documented to be installed.
48
+ *
49
+ * A caller with no project of its own has no identity to defend and nothing to be confused about, so
50
+ * it keeps the old permissive behaviour rather than being locked out of a daemon it can use.
51
+ *
52
+ * Everything else — a named daemon and a differently-named caller — is the cross-project bleed, and
53
+ * is the one case this refuses.
54
+ */
55
+ export declare function adoptable(daemonProjectId: string | undefined, callerProjectId: string | undefined): boolean;
56
+ /** The project a daemon on `port` claims, or undefined when it claims none / is not registered. */
57
+ export declare function daemonProjectAt(port: number, home: string): string | undefined;
58
+ /**
59
+ * The port this agent's MCP proxy should serve, decided BEFORE the proxy starts.
60
+ *
61
+ * Resolved once rather than per reconnect because the proxy's transport is built around a fixed
62
+ * number, and making it chase a moving port would mean reworking the reconnect path — the one piece
63
+ * of this system whose failure mode is "the MCP server disappeared mid-session". The port it settles
64
+ * on is this project's own, so the thing it would be chasing is a daemon it alone is responsible for.
65
+ *
66
+ * The order is the whole fix:
67
+ * 1. our project's live daemon, wherever it is listening — a daemon that moved is still ours;
68
+ * 2. the preferred port, but only if free or holding a daemon we may adopt;
69
+ * 3. a port the OS gives us, when the preferred one belongs to somebody else.
70
+ *
71
+ * Step 3 is what stops a second project from attaching to the first project's daemon. That single
72
+ * silent adoption is what made every agent on a machine share one process, one identity and one
73
+ * blast radius.
74
+ */
75
+ export declare function resolveMcpPort(preferred: number, projectId: string | undefined, home: string, deps: {
76
+ alive: (pid: number) => boolean;
77
+ daemonPresent: (port: number) => Promise<boolean>;
78
+ pickPort: (preferred: number) => Promise<number>;
79
+ }): Promise<number>;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Which daemon does THIS project belong to?
3
+ *
4
+ * The system answered that two incompatible ways. Build plugins asked the registry by projectId, the
5
+ * way `pickDaemonPort` was designed to be asked. The CLI and the MCP proxy asked a NUMBER —
6
+ * `envPort ?? projectPort ?? 4400` — and then attached to whatever owned it, whoever it belonged to.
7
+ *
8
+ * So every project on a machine funnelled into one daemon. Its recorded identity was whichever
9
+ * project won the race to the port, which made the registry entry wrong the moment a second project
10
+ * attached, and made one `kill` on one well-known port a machine-wide outage for every agent at once.
11
+ *
12
+ * It worked anyway, by luck: everyone defaulted to the same number, so everyone found each other.
13
+ * The luck ends the moment a daemon is anywhere but the default — which is exactly when discovery was
14
+ * supposed to help, and exactly when it returned nothing.
15
+ *
16
+ * This module is the one answer both sides now use. It is pure and injectable: no process spawning,
17
+ * no port binding, nothing that needs a real `~/.reticle` to test.
18
+ */
19
+ import { readdirSync, readFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { DaemonRegistryEntrySchema, daemonRegistryPort, pickDaemonPort, } from '@reticlehq/core';
22
+ /**
23
+ * Every well-formed registry entry in `home`.
24
+ *
25
+ * A corrupt or half-written entry is skipped rather than thrown on: the registry is written by other
26
+ * processes that can be killed mid-write, and one bad file must not make a machine's daemons
27
+ * undiscoverable. Validated through core's schema so a shape change cannot silently pass here.
28
+ */
29
+ export function readDaemonRegistry(home) {
30
+ let files;
31
+ try {
32
+ files = readdirSync(home);
33
+ }
34
+ catch {
35
+ return []; // no ~/.reticle yet: no daemon has ever run here
36
+ }
37
+ const entries = [];
38
+ for (const file of files) {
39
+ if (null === daemonRegistryPort(file))
40
+ continue;
41
+ try {
42
+ const parsed = DaemonRegistryEntrySchema.safeParse(JSON.parse(readFileSync(join(home, file), 'utf8')));
43
+ if (parsed.success)
44
+ entries.push(parsed.data);
45
+ }
46
+ catch {
47
+ continue;
48
+ }
49
+ }
50
+ return entries;
51
+ }
52
+ /**
53
+ * The port of the live daemon serving `projectId`, or undefined when this project has none.
54
+ *
55
+ * Undefined means "start one", never "use the default and hope". That distinction is the fix: the old
56
+ * path had no way to express "no daemon of mine exists", so it fell through to a number and attached
57
+ * to a stranger.
58
+ *
59
+ * The selection rule is core's `pickDaemonPort`, shared with every build plugin, so the daemon side
60
+ * and the app side cannot disagree about which daemon is ours.
61
+ */
62
+ export function resolveDaemonForProject(projectId, home, alive) {
63
+ return pickDaemonPort(readDaemonRegistry(home), projectId, alive) ?? undefined;
64
+ }
65
+ /**
66
+ * May `caller` take over a daemon already sitting on the port it wanted?
67
+ *
68
+ * Two deliberate escape hatches, both of which keep an existing install working:
69
+ *
70
+ * A daemon that claims NO project was started somewhere without a `.reticle.json` — the global MCP
71
+ * registration in a directory that is not an app, which is the majority of daemons in the field. It
72
+ * belongs to nobody, so it belongs to whoever asks; refusing would break every user running Reticle
73
+ * globally, which is how it is documented to be installed.
74
+ *
75
+ * A caller with no project of its own has no identity to defend and nothing to be confused about, so
76
+ * it keeps the old permissive behaviour rather than being locked out of a daemon it can use.
77
+ *
78
+ * Everything else — a named daemon and a differently-named caller — is the cross-project bleed, and
79
+ * is the one case this refuses.
80
+ */
81
+ export function adoptable(daemonProjectId, callerProjectId) {
82
+ if (daemonProjectId === undefined || 0 === daemonProjectId.length)
83
+ return true;
84
+ if (callerProjectId === undefined || 0 === callerProjectId.length)
85
+ return true;
86
+ return daemonProjectId === callerProjectId;
87
+ }
88
+ /** The project a daemon on `port` claims, or undefined when it claims none / is not registered. */
89
+ export function daemonProjectAt(port, home) {
90
+ return readDaemonRegistry(home).find((e) => e.port === port)?.projectId;
91
+ }
92
+ /**
93
+ * The port this agent's MCP proxy should serve, decided BEFORE the proxy starts.
94
+ *
95
+ * Resolved once rather than per reconnect because the proxy's transport is built around a fixed
96
+ * number, and making it chase a moving port would mean reworking the reconnect path — the one piece
97
+ * of this system whose failure mode is "the MCP server disappeared mid-session". The port it settles
98
+ * on is this project's own, so the thing it would be chasing is a daemon it alone is responsible for.
99
+ *
100
+ * The order is the whole fix:
101
+ * 1. our project's live daemon, wherever it is listening — a daemon that moved is still ours;
102
+ * 2. the preferred port, but only if free or holding a daemon we may adopt;
103
+ * 3. a port the OS gives us, when the preferred one belongs to somebody else.
104
+ *
105
+ * Step 3 is what stops a second project from attaching to the first project's daemon. That single
106
+ * silent adoption is what made every agent on a machine share one process, one identity and one
107
+ * blast radius.
108
+ */
109
+ export async function resolveMcpPort(preferred, projectId, home, deps) {
110
+ const mine = resolveDaemonForProject(projectId, home, deps.alive);
111
+ if (mine !== undefined)
112
+ return mine;
113
+ if (!(await deps.daemonPresent(preferred)))
114
+ return preferred;
115
+ return adoptable(daemonProjectAt(preferred, home), projectId)
116
+ ? preferred
117
+ : deps.pickPort(preferred);
118
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A port this machine will actually let us bind.
3
+ *
4
+ * Once daemons are per-project, "which port" stops being a constant and becomes a question. The
5
+ * answer must not be "the next number up": probing 4401, 4402, 4403 races every other agent doing
6
+ * the same arithmetic at the same moment, and the loser finds out at bind time, which is after it has
7
+ * already told the caller which port to use.
8
+ *
9
+ * So the OS assigns it. Binding port 0 asks the kernel for a free one and hands back the number it
10
+ * chose, and nothing else can be holding it at that instant. There is still a window between closing
11
+ * the probe socket and the daemon binding for real — unavoidable without passing the listener itself
12
+ * to the child — but it is microseconds wide and loses to a retry, where a guessed number loses every
13
+ * time on a busy machine.
14
+ *
15
+ * The preferred port is tried first so the ordinary single-project install keeps landing on the
16
+ * documented default, and every doc, log line and troubleshooting page that says 4400 stays true.
17
+ */
18
+ /** Can we bind `port` on the loopback interface right now? */
19
+ export declare function portIsFree(port: number, host?: string): Promise<boolean>;
20
+ /** Ask the kernel for a free port. Rejects only if the machine cannot bind loopback at all. */
21
+ export declare function osAssignedPort(host?: string): Promise<number>;
22
+ /**
23
+ * `preferred` when it is free, otherwise whatever the OS gives us.
24
+ *
25
+ * Injectable so the decision is unit-testable without binding anything: the interesting case is the
26
+ * one where the preferred port is taken, and a test must be able to say so without racing a real
27
+ * socket.
28
+ */
29
+ export declare function pickDaemonPortToBind(preferred: number, isFree?: (port: number) => Promise<boolean>, assign?: () => Promise<number>): Promise<number>;