@reticlehq/server 2.11.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 (252) hide show
  1. package/SKILL.md +26 -2
  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 +0 -2
  45. package/dist/dev/stale-issue-guard.js +1 -1
  46. package/dist/events/contradictions.d.ts +41 -0
  47. package/dist/events/contradictions.js +125 -12
  48. package/dist/events/declared.d.ts +14 -0
  49. package/dist/events/declared.js +33 -0
  50. package/dist/events/echo-mismatch.js +14 -0
  51. package/dist/events/observed-in-window.d.ts +12 -0
  52. package/dist/events/observed-in-window.js +17 -0
  53. package/dist/events/predicate-asks.d.ts +13 -0
  54. package/dist/events/predicate-asks.js +22 -0
  55. package/dist/events/predicate-eval.js +64 -22
  56. package/dist/events/predicate-parse.d.ts +0 -6
  57. package/dist/events/predicate-parse.js +43 -3
  58. package/dist/events/predicate-schema.d.ts +44 -0
  59. package/dist/events/predicate-schema.js +126 -23
  60. package/dist/events/predicate.d.ts +15 -0
  61. package/dist/events/predicate.js +41 -2
  62. package/dist/flows/coverage.d.ts +0 -8
  63. package/dist/flows/coverage.js +1 -1
  64. package/dist/flows/decision.d.ts +5 -1
  65. package/dist/flows/decision.js +33 -7
  66. package/dist/flows/flake.d.ts +0 -2
  67. package/dist/flows/flake.js +1 -1
  68. package/dist/flows/flow-intent.d.ts +52 -0
  69. package/dist/flows/flow-intent.js +123 -0
  70. package/dist/flows/flow-replay-run.js +27 -4
  71. package/dist/flows/flow-success.js +15 -3
  72. package/dist/flows/flow-tools.js +32 -4
  73. package/dist/flows/flows.d.ts +6 -1
  74. package/dist/flows/flows.js +34 -22
  75. package/dist/flows/predicate-to-expect.js +6 -3
  76. package/dist/flows/server-verify.js +16 -2
  77. package/dist/flows/verify-change-tools.js +4 -1
  78. package/dist/honesty/blind-spots.d.ts +0 -17
  79. package/dist/honesty/blind-spots.js +1 -1
  80. package/dist/honesty/feature-capture.d.ts +150 -0
  81. package/dist/honesty/feature-capture.js +233 -0
  82. package/dist/honesty/instrumentation-gaps.d.ts +30 -6
  83. package/dist/honesty/instrumentation-gaps.js +61 -5
  84. package/dist/honesty/tool-hit-rate.d.ts +86 -0
  85. package/dist/honesty/tool-hit-rate.js +90 -0
  86. package/dist/honesty/undeclared-change.d.ts +51 -0
  87. package/dist/honesty/undeclared-change.js +56 -0
  88. package/dist/honesty/verified.d.ts +15 -0
  89. package/dist/honesty/verified.js +68 -4
  90. package/dist/http-server.d.ts +0 -1
  91. package/dist/http-server.js +1 -1
  92. package/dist/hunt/hunt-report.d.ts +0 -2
  93. package/dist/hunt/hunt-report.js +1 -1
  94. package/dist/impact/impact-store.d.ts +0 -5
  95. package/dist/impact/impact-store.js +1 -1
  96. package/dist/index.d.ts +4 -1
  97. package/dist/init/agent-rules.d.ts +17 -0
  98. package/dist/init/agent-rules.js +31 -1
  99. package/dist/init/capabilities.d.ts +0 -2
  100. package/dist/init/capabilities.js +1 -1
  101. package/dist/init/closing-hint.d.ts +36 -0
  102. package/dist/init/closing-hint.js +120 -0
  103. package/dist/init/confirm.d.ts +0 -25
  104. package/dist/init/confirm.js +3 -3
  105. package/dist/init/cra.d.ts +14 -1
  106. package/dist/init/cra.js +43 -12
  107. package/dist/init/dev-script.d.ts +48 -0
  108. package/dist/init/dev-script.js +63 -0
  109. package/dist/init/mcp.d.ts +16 -5
  110. package/dist/init/mcp.js +26 -10
  111. package/dist/init/plan-framework.d.ts +0 -5
  112. package/dist/init/plan-framework.js +83 -9
  113. package/dist/init/plan.d.ts +10 -30
  114. package/dist/init/plan.js +30 -15
  115. package/dist/init/refresh-rules.d.ts +39 -0
  116. package/dist/init/refresh-rules.js +51 -0
  117. package/dist/init/run.d.ts +0 -26
  118. package/dist/init/run.js +14 -87
  119. package/dist/init/snippets.js +53 -10
  120. package/dist/input/real-input.d.ts +2 -0
  121. package/dist/input/real-input.js +3 -2
  122. package/dist/input/scroll-find.d.ts +7 -4
  123. package/dist/input/scroll-find.js +63 -25
  124. package/dist/intent/inline-intent.d.ts +46 -0
  125. package/dist/intent/inline-intent.js +103 -0
  126. package/dist/intent/intent-store.d.ts +37 -0
  127. package/dist/intent/intent-store.js +112 -0
  128. package/dist/intent/intent-tools.d.ts +2 -0
  129. package/dist/intent/intent-tools.js +80 -0
  130. package/dist/intent/open-intents.d.ts +21 -0
  131. package/dist/intent/open-intents.js +25 -0
  132. package/dist/journal/envelope.d.ts +26 -26
  133. package/dist/journal/journal-recorder.d.ts +23 -0
  134. package/dist/journal/journal-recorder.js +35 -0
  135. package/dist/mcp/attach-memory.d.ts +67 -0
  136. package/dist/mcp/attach-memory.js +142 -0
  137. package/dist/mcp/mcp-proxy.d.ts +0 -7
  138. package/dist/mcp/mcp-proxy.js +14 -2
  139. package/dist/mcp/mcp.d.ts +2 -0
  140. package/dist/mcp/mcp.js +45 -23
  141. package/dist/mcp/server-instructions.js +45 -3
  142. package/dist/mcp/tool-catalog-cache.d.ts +8 -0
  143. package/dist/mcp/tool-catalog-cache.js +10 -0
  144. package/dist/oracles/flow-instrument-gaps.d.ts +4 -4
  145. package/dist/oracles/flow-instrument-gaps.js +4 -5
  146. package/dist/oracles/self-instrument.d.ts +9 -9
  147. package/dist/oracles/self-instrument.js +19 -13
  148. package/dist/pool/browser-pool.d.ts +12 -1
  149. package/dist/pool/browser-pool.js +31 -1
  150. package/dist/pool/playwright-launcher.js +3 -1
  151. package/dist/project/fs-port.d.ts +3 -0
  152. package/dist/project/fs-port.js +3 -2
  153. package/dist/project/memory-fs.js +6 -1
  154. package/dist/project/project-tools.js +3 -32
  155. package/dist/project/reticle-dir.d.ts +2 -0
  156. package/dist/project/reticle-dir.js +1 -0
  157. package/dist/runs/context-tools.d.ts +2 -0
  158. package/dist/runs/context-tools.js +89 -0
  159. package/dist/runs/run-context.d.ts +40 -0
  160. package/dist/runs/run-context.js +158 -0
  161. package/dist/runs/run-diff.d.ts +2 -1
  162. package/dist/runs/verification-sync.js +2 -2
  163. package/dist/session/dev-server-probe.d.ts +0 -8
  164. package/dist/session/dev-server-probe.js +1 -1
  165. package/dist/session/last-act.d.ts +7 -1
  166. package/dist/session/last-act.js +2 -2
  167. package/dist/session/live-control-tools.js +1 -42
  168. package/dist/session/no-session-diagnosis.d.ts +10 -0
  169. package/dist/session/no-session-diagnosis.js +24 -7
  170. package/dist/session/no-session-next-action.d.ts +5 -0
  171. package/dist/session/no-session-next-action.js +35 -3
  172. package/dist/session/no-session-watch.d.ts +8 -0
  173. package/dist/session/no-session-watch.js +82 -64
  174. package/dist/session/output-budget.js +4 -1
  175. package/dist/session/session-health.d.ts +9 -0
  176. package/dist/session/session-health.js +23 -0
  177. package/dist/session/session-manager.js +50 -1
  178. package/dist/session/session-successor.d.ts +37 -0
  179. package/dist/session/session-successor.js +78 -0
  180. package/dist/session/session.d.ts +31 -1
  181. package/dist/session/session.js +75 -2
  182. package/dist/telemetry/automation-hint.d.ts +2 -1
  183. package/dist/telemetry/feedback-outbox.d.ts +2 -1
  184. package/dist/telemetry/project-profile.d.ts +0 -8
  185. package/dist/telemetry/project-profile.js +1 -1
  186. package/dist/telemetry/run-telemetry.d.ts +0 -2
  187. package/dist/telemetry/run-telemetry.js +1 -1
  188. package/dist/telemetry/verification-of.js +2 -2
  189. package/dist/tools/act-tools.js +126 -21
  190. package/dist/tools/assert-source.d.ts +22 -0
  191. package/dist/tools/assert-source.js +53 -0
  192. package/dist/tools/assert-verdict.d.ts +20 -3
  193. package/dist/tools/assert-verdict.js +36 -4
  194. package/dist/tools/browser-tools.js +0 -20
  195. package/dist/tools/complete-snapshot.d.ts +48 -0
  196. package/dist/tools/complete-snapshot.js +75 -0
  197. package/dist/tools/coverage-tools.d.ts +10 -1
  198. package/dist/tools/coverage-tools.js +123 -69
  199. package/dist/tools/dynamic-tools.js +29 -8
  200. package/dist/tools/error-recovery.d.ts +24 -14
  201. package/dist/tools/error-recovery.js +70 -9
  202. package/dist/tools/feedback-tools.d.ts +1 -1
  203. package/dist/tools/feedback-tools.js +2 -2
  204. package/dist/tools/invoke-tool.js +62 -2
  205. package/dist/tools/lease-hint.d.ts +10 -0
  206. package/dist/tools/lease-hint.js +53 -4
  207. package/dist/tools/lease-tools.d.ts +5 -1
  208. package/dist/tools/lease-tools.js +22 -8
  209. package/dist/tools/navigate-arrival.d.ts +0 -2
  210. package/dist/tools/navigate-arrival.js +1 -1
  211. package/dist/tools/observe-tools.js +63 -26
  212. package/dist/tools/read-tools.js +5 -3
  213. package/dist/tools/real-input-attempt.d.ts +22 -0
  214. package/dist/tools/real-input-attempt.js +153 -1
  215. package/dist/tools/reconcile-tools.js +18 -4
  216. package/dist/tools/resolve-target.d.ts +0 -7
  217. package/dist/tools/sdk-marker-probe.d.ts +29 -0
  218. package/dist/tools/sdk-marker-probe.js +57 -0
  219. package/dist/tools/snapshot-delta.d.ts +1 -1
  220. package/dist/tools/tool-kit.d.ts +12 -0
  221. package/dist/tools/tool-kit.js +16 -0
  222. package/dist/tools/tool-names.d.ts +3 -0
  223. package/dist/tools/tool-names.js +3 -0
  224. package/dist/tools/tool-surface.d.ts +85 -6
  225. package/dist/tools/tool-surface.js +122 -2
  226. package/dist/tools/tools.js +22 -4
  227. package/dist/tools/unadvertised-help.js +4 -4
  228. package/dist/tools/verify-next-baton.d.ts +53 -0
  229. package/dist/tools/verify-next-baton.js +52 -0
  230. package/dist/update/update-nudge.d.ts +7 -10
  231. package/dist/update/update-nudge.js +12 -3
  232. package/dist/update/updater.d.ts +1 -1
  233. package/docs/README.md +2 -1
  234. package/docs/cli/cloud.mdx +1 -1
  235. package/docs/cli/doctor.mdx +1 -0
  236. package/docs/cli/verify.mdx +4 -5
  237. package/docs/desktop.mdx +1 -1
  238. package/docs/docs.json +2 -2
  239. package/docs/gates.md +1 -1
  240. package/docs/install-manual.mdx +1 -1
  241. package/docs/packages/browser.mdx +2 -2
  242. package/docs/packages/server.mdx +1 -1
  243. package/docs/packages.mdx +8 -6
  244. package/docs/platform-integration.md +17 -0
  245. package/docs/predicates.mdx +8 -0
  246. package/docs/reticle-manual.md +106 -0
  247. package/docs/tools-tools-and-run.mdx +2 -4
  248. package/docs/troubleshooting.mdx +9 -0
  249. package/docs/usage.md +3 -2
  250. package/package.json +2 -2
  251. package/dist/session/session-readiness.d.ts +0 -34
  252. package/dist/session/session-readiness.js +0 -36
@@ -0,0 +1,21 @@
1
+ /**
2
+ * What `reticle gate` means by its exit code.
3
+ *
4
+ * Split out of the handler because two different readers depend on it and they want opposite
5
+ * things. CI wants any problem to be a failure. A Stop hook — the layer that makes verification
6
+ * unskippable — wants to block a real regression and NOT block a project that has simply not
7
+ * recorded a flow yet, which is every project on its first day.
8
+ *
9
+ * `NOTHING_TO_CHECK` stays non-zero deliberately. The gate exits non-zero over an empty suite
10
+ * because "nothing was checked" and "everything passed" are indistinguishable from the outside,
11
+ * and that reasoning does not stop being true just because a second caller appeared.
12
+ */
13
+ export declare const GateExit: {
14
+ /** Affected flows were checked and hold. */
15
+ readonly PASS: 0;
16
+ /** Something is actually wrong: an affected flow with no passing artifact, a downgraded
17
+ * assertion, deleted coverage. A caller should stop here. */
18
+ readonly FAIL: 1;
19
+ /** The gate ran and had nothing to judge — no flows recorded. Non-zero, but not a regression. */
20
+ readonly NOTHING_TO_CHECK: 2;
21
+ };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * What `reticle gate` means by its exit code.
3
+ *
4
+ * Split out of the handler because two different readers depend on it and they want opposite
5
+ * things. CI wants any problem to be a failure. A Stop hook — the layer that makes verification
6
+ * unskippable — wants to block a real regression and NOT block a project that has simply not
7
+ * recorded a flow yet, which is every project on its first day.
8
+ *
9
+ * `NOTHING_TO_CHECK` stays non-zero deliberately. The gate exits non-zero over an empty suite
10
+ * because "nothing was checked" and "everything passed" are indistinguishable from the outside,
11
+ * and that reasoning does not stop being true just because a second caller appeared.
12
+ */
13
+ export const GateExit = {
14
+ /** Affected flows were checked and hold. */
15
+ PASS: 0,
16
+ /** Something is actually wrong: an affected flow with no passing artifact, a downgraded
17
+ * assertion, deleted coverage. A caller should stop here. */
18
+ FAIL: 1,
19
+ /** The gate ran and had nothing to judge — no flows recorded. Non-zero, but not a regression. */
20
+ NOTHING_TO_CHECK: 2,
21
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * What a Stop hook shows the human when the gate blocks.
3
+ *
4
+ * A hook runs at the moment an agent says it is finished, and whatever it prints is the last thing
5
+ * anybody reads. The gate's normal output is one long JSON line built for CI to parse; pasted into
6
+ * a terminal at the end of a session it is a wall of names with no instruction attached.
7
+ *
8
+ * So the hook path gets prose. It has to answer three things in the order a person asks them: what
9
+ * is not verified, why that matters right now, and what to do about it — including how to proceed
10
+ * anyway, because a gate with no honest escape hatch gets disabled entirely and then protects
11
+ * nothing.
12
+ */
13
+ /** Named so a reader of the hook output knows the flag is real and not a suggestion. */
14
+ export declare const GATE_SKIP_ENV = "RETICLE_SKIP_GATE";
15
+ export interface GateHookInput {
16
+ /** Flows the changed files touch that have no passing artifact. */
17
+ uncovered: readonly string[];
18
+ /** Flows held back as flaky — reported, never counted as a pass. */
19
+ quarantined: readonly string[];
20
+ /** Assertions that dropped from a real consequence to a fakeable one. */
21
+ downgraded: readonly string[];
22
+ /** Flows whose coverage was deleted while their files changed. */
23
+ deleted: readonly string[];
24
+ }
25
+ /**
26
+ * The message, or undefined when there is nothing to say.
27
+ *
28
+ * `NOTHING_TO_CHECK` returns undefined on purpose: a project that has not recorded a flow yet is
29
+ * every project on its first day, and blocking there would make the first experience of Reticle an
30
+ * agent that cannot stop. The gate still reports it — this decides only whether a HUMAN is
31
+ * interrupted.
32
+ */
33
+ export declare function gateHookMessage(exit: number, input: GateHookInput): string | undefined;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * What a Stop hook shows the human when the gate blocks.
3
+ *
4
+ * A hook runs at the moment an agent says it is finished, and whatever it prints is the last thing
5
+ * anybody reads. The gate's normal output is one long JSON line built for CI to parse; pasted into
6
+ * a terminal at the end of a session it is a wall of names with no instruction attached.
7
+ *
8
+ * So the hook path gets prose. It has to answer three things in the order a person asks them: what
9
+ * is not verified, why that matters right now, and what to do about it — including how to proceed
10
+ * anyway, because a gate with no honest escape hatch gets disabled entirely and then protects
11
+ * nothing.
12
+ */
13
+ import { GateExit } from './gate-exit.js';
14
+ /** Named so a reader of the hook output knows the flag is real and not a suggestion. */
15
+ export const GATE_SKIP_ENV = 'RETICLE_SKIP_GATE';
16
+ const MAX_NAMED = 5;
17
+ /** A short list, then a count — a hook that prints forty names is a hook people turn off. */
18
+ function name(list) {
19
+ const shown = list.slice(0, MAX_NAMED).join(', ');
20
+ return list.length > MAX_NAMED ? `${shown} (+${String(list.length - MAX_NAMED)} more)` : shown;
21
+ }
22
+ /**
23
+ * The message, or undefined when there is nothing to say.
24
+ *
25
+ * `NOTHING_TO_CHECK` returns undefined on purpose: a project that has not recorded a flow yet is
26
+ * every project on its first day, and blocking there would make the first experience of Reticle an
27
+ * agent that cannot stop. The gate still reports it — this decides only whether a HUMAN is
28
+ * interrupted.
29
+ */
30
+ export function gateHookMessage(exit, input) {
31
+ if (GateExit.FAIL !== exit)
32
+ return undefined;
33
+ const lines = ['Reticle: this change is not verified.'];
34
+ if (input.uncovered.length > 0)
35
+ lines.push(` no passing run covers: ${name(input.uncovered)}`);
36
+ if (input.downgraded.length > 0)
37
+ lines.push(` assertions weakened to something fakeable: ${name(input.downgraded)} — a check that cannot fail is not a check`);
38
+ if (input.deleted.length > 0)
39
+ lines.push(` coverage deleted while its files changed: ${name(input.deleted)}`);
40
+ if (input.quarantined.length > 0)
41
+ lines.push(` held back as flaky, so proving nothing: ${name(input.quarantined)}`);
42
+ lines.push('');
43
+ lines.push(' Replay them: npx @reticlehq/server verify <your app url>');
44
+ lines.push(` Or proceed anyway: ${GATE_SKIP_ENV}=1 — it is recorded, not silent.`);
45
+ return lines.join('\n');
46
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `reticle mcp` — the process an editor launches, and the one whose failure a user experiences as
3
+ * "the MCP server disconnected".
4
+ *
5
+ * Split out of `cli.ts` when that file passed the 1000-line backstop. It earns its own module rather
6
+ * than being an arbitrary slice: everything here is about ONE decision — which daemon this agent
7
+ * talks to for the rest of its life — and the port resolution at the top is the fix for agents on
8
+ * different projects silently sharing one daemon, one identity and one blast radius.
9
+ */
10
+ /**
11
+ * MCP proxy mode: ensures the daemon is running, then bridges Claude Code's
12
+ * stdin/stdout to the daemon's SSE endpoint. This is the recommended way to
13
+ * configure Reticle in.mcp.json — users never need to manage the daemon manually.
14
+ *
15
+ * Pass --drive <url> to have the daemon launch its own Playwright browser at that
16
+ * URL. The agent then has full autonomous control without relying on the user's browser.
17
+ */
18
+ export declare function handleMcp(opts: {
19
+ port: number;
20
+ driveUrl?: string;
21
+ headless: boolean;
22
+ http: boolean;
23
+ httpPort?: number;
24
+ httpToken?: string;
25
+ }): Promise<void>;
@@ -0,0 +1,143 @@
1
+ /**
2
+ * `reticle mcp` — the process an editor launches, and the one whose failure a user experiences as
3
+ * "the MCP server disconnected".
4
+ *
5
+ * Split out of `cli.ts` when that file passed the 1000-line backstop. It earns its own module rather
6
+ * than being an arbitrary slice: everything here is about ONE decision — which daemon this agent
7
+ * talks to for the rest of its life — and the port resolution at the top is the fix for agents on
8
+ * different projects silently sharing one daemon, one identity and one blast radius.
9
+ */
10
+ import { log } from '../log.js';
11
+ import { isAlive, spawnDaemon, reticleStateHome } from '../daemon/daemon.js';
12
+ import { PortPresence, probePresence, presenceIsUsable, describePresence, } from '../daemon/port-presence.js';
13
+ import { waitForDaemon, startMcpProxy, probeDaemon, proxyLog, setProxyLogPort, } from '../mcp/mcp-proxy.js';
14
+ import { installProxyResilience } from '../daemon/daemon-resilience.js';
15
+ import { readProjectId } from './cli-port.js';
16
+ import { DAEMON_INNER_COMMAND, PORT_FLAG, DRIVE_FLAG, HEADED_FLAG, HTTP_FLAG, HTTP_PORT_FLAG, HTTP_TOKEN_FLAG, } from './cli-parse.js';
17
+ import { resolveMcpPort } from '../daemon/daemon-resolve.js';
18
+ import { pickDaemonPortToBind } from '../daemon/free-port.js';
19
+ import { fetchStatus } from './cli-launch.js';
20
+ /**
21
+ * MCP proxy mode: ensures the daemon is running, then bridges Claude Code's
22
+ * stdin/stdout to the daemon's SSE endpoint. This is the recommended way to
23
+ * configure Reticle in.mcp.json — users never need to manage the daemon manually.
24
+ *
25
+ * Pass --drive <url> to have the daemon launch its own Playwright browser at that
26
+ * URL. The agent then has full autonomous control without relying on the user's browser.
27
+ */
28
+ export async function handleMcp(opts) {
29
+ const { driveUrl, headless, http, httpPort, httpToken } = opts;
30
+ // Decided before anything else, because every line below is about ONE port and the wrong one means
31
+ // serving another project's daemon. `resolveMcpPort` prefers our own daemon wherever it moved to,
32
+ // takes the requested port when it is free or unclaimed, and relocates to an OS-assigned port
33
+ // rather than adopting a daemon that belongs to somebody else.
34
+ //
35
+ // Synchronous-looking on purpose: a probe is one loopback connect, and doing it here keeps the
36
+ // proxy's transport built around a single fixed number. Making the proxy chase a moving port would
37
+ // mean reworking the reconnect path, which is the one piece whose failure reads to a user as
38
+ // "the MCP server disappeared mid-session".
39
+ const port = await resolveMcpPort(opts.port, readProjectId(process.cwd()), reticleStateHome(), {
40
+ alive: isAlive,
41
+ daemonPresent: async (p) => presenceIsUsable(await probePresence(p, { tcpOpen: probeDaemon, status: fetchStatus })),
42
+ pickPort: (p) => pickDaemonPortToBind(p),
43
+ });
44
+ if (port !== opts.port) {
45
+ log('reticle_mcp_port_relocated', {
46
+ requested: opts.port,
47
+ port,
48
+ reason: 'the requested port serves a different project',
49
+ });
50
+ }
51
+ // The proxy IS the MCP server the editor launched. Nothing respawns it, so an uncaught throw here
52
+ // is what a user experiences as "the MCP server disconnected — open /mcp and reconnect". Log it,
53
+ // report it, keep serving: the reconnect and dormant paths already know how to rebuild the only
54
+ // state this process has. See installProxyResilience for why its rule inverts the daemon's.
55
+ // The proxy's crash handlers must write to the proxy's LOG FILE, not just stderr. The editor
56
+ // swallows stderr, so wiring these to `log` meant a crash was handled and then unrecorded — the
57
+ // failure a user reports as "it disconnected" left nothing behind to read.
58
+ setProxyLogPort(port);
59
+ installProxyResilience(process, proxyLog);
60
+ /**
61
+ * Make sure a daemon is on the port, spawning one if not.
62
+ *
63
+ * Called on first start AND on every reconnect. The proxy used to only retry the socket, so a
64
+ * daemon that exited — crashed, `reticle stop`, or self-shut-down as idle — meant the retries hit
65
+ * a dead port until the budget ran out and the agent's MCP server exited with it. Reticle simply
66
+ * disappeared mid-session with nothing said. Respawning here makes the reconnect self-healing.
67
+ */
68
+ const ensure = async () => {
69
+ // The same question every other surface asks. It used to be a bare TCP connect, so a stranger on
70
+ // the bridge port answered "a daemon is here" — the proxy connected, the stream ended, and each
71
+ // client request woke into the identical non-answer. Rejecting here is what puts the proxy
72
+ // dormant with a reason, instead of pretending the wake succeeded.
73
+ const presence = await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus });
74
+ if (presenceIsUsable(presence))
75
+ return;
76
+ if (PortPresence.FREE !== presence)
77
+ throw new Error(describePresence(presence, port));
78
+ const scriptPath = process.argv[1];
79
+ if (scriptPath === undefined) {
80
+ log('reticle_mcp_no_script', {});
81
+ process.exit(1);
82
+ }
83
+ const daemonArgs = [DAEMON_INNER_COMMAND, PORT_FLAG, String(port)];
84
+ if (driveUrl !== undefined) {
85
+ daemonArgs.push(DRIVE_FLAG, driveUrl);
86
+ if (!headless)
87
+ daemonArgs.push(HEADED_FLAG);
88
+ }
89
+ // Forward the HTTP-verify flags too (previously silently dropped for `reticle mcp`).
90
+ if (http) {
91
+ daemonArgs.push(HTTP_FLAG);
92
+ if (httpPort !== undefined)
93
+ daemonArgs.push(HTTP_PORT_FLAG, String(httpPort));
94
+ if (httpToken !== undefined)
95
+ daemonArgs.push(HTTP_TOKEN_FLAG, httpToken);
96
+ }
97
+ spawnDaemon(process.execPath, scriptPath, daemonArgs, port);
98
+ // Announce the daemon only once the PORT ACCEPTS. This line used to be written the instant the
99
+ // child was spawned, which on a Windows first bootstrap meant `reticle_mcp_daemon_started`
100
+ // followed about ten seconds later by `reticle_mcp_daemon_unavailable` and a first
101
+ // `reticle_sessions` that expired. A readiness signal that precedes readiness is worse than
102
+ // none: a client that believes it stops waiting for the thing that has not happened.
103
+ await waitForDaemon(port);
104
+ log('reticle_mcp_daemon_started', { port, ...(driveUrl !== undefined ? { driveUrl } : {}) });
105
+ };
106
+ // Start the proxy WHATEVER happened to the daemon.
107
+ //
108
+ // This used to exit(1) when `ensure` failed, which is the third way an MCP server disappears on a
109
+ // user: something else is holding the bridge port — a foreign daemon from another project, a
110
+ // half-dead process, a port a colleague's tool grabbed — the spawned daemon cannot bind, and the
111
+ // editor shows a server that failed to start. Nothing about that is unrecoverable: the proxy
112
+ // answers `initialize` itself, serves the cached catalog, and its wake path retries a daemon on
113
+ // every client request. Present-and-complaining beats absent, because absent needs a human.
114
+ // `void`: the chain handles its own failure and the process must not wait on it — the proxy is
115
+ // started from `finally` either way.
116
+ void ensure()
117
+ .catch((err) => {
118
+ log('reticle_mcp_daemon_unavailable', {
119
+ error: err instanceof Error ? err.message : String(err),
120
+ note: 'serving anyway — the next tool call will try to start a daemon again',
121
+ });
122
+ })
123
+ .finally(() => {
124
+ // Also `void`: the proxy runs for the life of the process and is never awaited by anyone.
125
+ //
126
+ // The `.catch` is load-bearing despite that. `startMcpProxy` rejects when its FIRST connect
127
+ // fails — which is precisely the case this block exists to tolerate, a daemon that is not
128
+ // there yet — and with nothing attached that reject became an unhandledRejection. Reported
129
+ // from a win32 user as a crash reading `connect ECONNREFUSED` with an EMPTY frame list,
130
+ // because the stack of a refused socket is entirely node internals and the privacy filter
131
+ // keeps only Reticle frames. So the one path we most want diagnosable arrived as an anonymous
132
+ // crash. The proxy itself is unaffected — it has already installed its stdin reader and goes
133
+ // on serving from cache, waking a daemon on the next request — which is exactly why this
134
+ // must be logged as the expected condition it is rather than reported as a defect.
135
+ void startMcpProxy(port, ensure).catch((err) => {
136
+ log('reticle_mcp_proxy_first_connect_failed', {
137
+ port,
138
+ error: err instanceof Error ? err.message : String(err),
139
+ note: 'serving from cache; the next client request will try to start a daemon',
140
+ });
141
+ });
142
+ });
143
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Well-known Reticle ports other than the one this daemon bound.
3
+ *
4
+ * The remaining half of #261: when the SDK dials 4460 and we listen on 4400, we cannot see the
5
+ * refused inbound — but we CAN notice that a well-known Reticle port other than ours has a
6
+ * listener. That is an observation, not a diagnosis. A neighbour's daemon, another project, or an
7
+ * unrelated process on 4460 all look identical from here, so the sentence that reports it must not
8
+ * conclude that it is the daemon this app wants. Maintainer constraint on the issue: report the
9
+ * observation without the conclusion.
10
+ */
11
+ /**
12
+ * The port the bench fixture's SDK dials.
13
+ *
14
+ * Deliberate, and must not be "fixed" onto the default: `bench/harness/ports.mjs` documents a past
15
+ * incident caused by having more than one source of truth for it. It exists so a default daemon on
16
+ * 4400 and a bench run never collide. Doctor and the no-session diagnosis probe it as a sibling
17
+ * of 4400 for that reason — it is the port the original silent no-connect was on.
18
+ */
19
+ export declare const RETICLE_BENCH_PORT = 4460;
20
+ /** Ports a Reticle daemon is known to occupy. Probe every one except the port we ourselves bound. */
21
+ export declare const WELL_KNOWN_RETICLE_PORTS: readonly number[];
22
+ /** Well-known Reticle ports other than `boundPort`. Empty when we are the only well-known occupant. */
23
+ export declare function siblingPorts(boundPort: number, known?: readonly number[]): readonly number[];
24
+ /**
25
+ * One sentence for a listener on a sibling Reticle port. `undefined` when there is nothing to say.
26
+ *
27
+ * Wording is load-bearing: it names the observation and refuses the conclusion. A test pins that it
28
+ * does not claim the listener is the daemon this app wants, or that the SDK is dialling it.
29
+ */
30
+ export declare function siblingListenerNote(boundPort: number, occupiedSiblings: readonly number[]): string | undefined;
31
+ /**
32
+ * Which well-known siblings currently accept a TCP connection.
33
+ *
34
+ * Pure in the rule, impure at the edge: `isListening` is injected so this can be tested without a
35
+ * socket, and so a lying probe is a separate failure from a lying sentence.
36
+ */
37
+ export declare function findOccupiedSiblings(boundPort: number, isListening: (port: number) => Promise<boolean>): Promise<readonly number[]>;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Well-known Reticle ports other than the one this daemon bound.
3
+ *
4
+ * The remaining half of #261: when the SDK dials 4460 and we listen on 4400, we cannot see the
5
+ * refused inbound — but we CAN notice that a well-known Reticle port other than ours has a
6
+ * listener. That is an observation, not a diagnosis. A neighbour's daemon, another project, or an
7
+ * unrelated process on 4460 all look identical from here, so the sentence that reports it must not
8
+ * conclude that it is the daemon this app wants. Maintainer constraint on the issue: report the
9
+ * observation without the conclusion.
10
+ */
11
+ import { RETICLE_DEFAULT_PORT } from '@reticlehq/core';
12
+ /**
13
+ * The port the bench fixture's SDK dials.
14
+ *
15
+ * Deliberate, and must not be "fixed" onto the default: `bench/harness/ports.mjs` documents a past
16
+ * incident caused by having more than one source of truth for it. It exists so a default daemon on
17
+ * 4400 and a bench run never collide. Doctor and the no-session diagnosis probe it as a sibling
18
+ * of 4400 for that reason — it is the port the original silent no-connect was on.
19
+ */
20
+ export const RETICLE_BENCH_PORT = 4460;
21
+ /** Ports a Reticle daemon is known to occupy. Probe every one except the port we ourselves bound. */
22
+ export const WELL_KNOWN_RETICLE_PORTS = [
23
+ RETICLE_DEFAULT_PORT,
24
+ RETICLE_BENCH_PORT,
25
+ ];
26
+ /** Well-known Reticle ports other than `boundPort`. Empty when we are the only well-known occupant. */
27
+ export function siblingPorts(boundPort, known = WELL_KNOWN_RETICLE_PORTS) {
28
+ return known.filter((port) => port !== boundPort);
29
+ }
30
+ /**
31
+ * One sentence for a listener on a sibling Reticle port. `undefined` when there is nothing to say.
32
+ *
33
+ * Wording is load-bearing: it names the observation and refuses the conclusion. A test pins that it
34
+ * does not claim the listener is the daemon this app wants, or that the SDK is dialling it.
35
+ */
36
+ export function siblingListenerNote(boundPort, occupiedSiblings) {
37
+ if (0 === occupiedSiblings.length)
38
+ return undefined;
39
+ const listed = occupiedSiblings.map((port) => `:${String(port)}`).join(', ');
40
+ return (`something is listening on ${listed}, which this daemon is not` +
41
+ ` (this daemon is on :${String(boundPort)}); that may or may not be related.`);
42
+ }
43
+ /**
44
+ * Which well-known siblings currently accept a TCP connection.
45
+ *
46
+ * Pure in the rule, impure at the edge: `isListening` is injected so this can be tested without a
47
+ * socket, and so a lying probe is a separate failure from a lying sentence.
48
+ */
49
+ export async function findOccupiedSiblings(boundPort, isListening) {
50
+ const occupied = [];
51
+ for (const port of siblingPorts(boundPort)) {
52
+ if (await isListening(port))
53
+ occupied.push(port);
54
+ }
55
+ return occupied;
56
+ }
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";