@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
@@ -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>;
@@ -0,0 +1,55 @@
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
+ import { createServer } from 'node:net';
19
+ import { LOOPBACK_HOST } from '@reticlehq/core';
20
+ /** Can we bind `port` on the loopback interface right now? */
21
+ export function portIsFree(port, host = LOOPBACK_HOST) {
22
+ return new Promise((resolve) => {
23
+ const probe = createServer();
24
+ probe.once('error', () => resolve(false));
25
+ probe.listen(port, host, () => {
26
+ probe.close(() => resolve(true));
27
+ });
28
+ });
29
+ }
30
+ /** Ask the kernel for a free port. Rejects only if the machine cannot bind loopback at all. */
31
+ export function osAssignedPort(host = LOOPBACK_HOST) {
32
+ return new Promise((resolve, reject) => {
33
+ const probe = createServer();
34
+ probe.once('error', reject);
35
+ probe.listen(0, host, () => {
36
+ const address = probe.address();
37
+ if (null === address || 'string' === typeof address) {
38
+ probe.close(() => reject(new Error('could not read an OS-assigned port')));
39
+ return;
40
+ }
41
+ const { port } = address;
42
+ probe.close(() => resolve(port));
43
+ });
44
+ });
45
+ }
46
+ /**
47
+ * `preferred` when it is free, otherwise whatever the OS gives us.
48
+ *
49
+ * Injectable so the decision is unit-testable without binding anything: the interesting case is the
50
+ * one where the preferred port is taken, and a test must be able to say so without racing a real
51
+ * socket.
52
+ */
53
+ export async function pickDaemonPortToBind(preferred, isFree = portIsFree, assign = osAssignedPort) {
54
+ return (await isFree(preferred)) ? preferred : assign();
55
+ }
@@ -19,10 +19,6 @@
19
19
  */
20
20
  /** The event name a beat carries. One `grep` separates liveness from everything else in the file. */
21
21
  export declare const DAEMON_HEARTBEAT_EVENT = "reticle_daemon_alive";
22
- /** Written by installExitTrace when the process exits through Node. */
23
- export declare const DAEMON_EXIT_EVENT = "reticle_daemon_exiting";
24
- /** Written by installExitTrace when a signal arrives, which is a CAUSE the exit line does not carry. */
25
- export declare const DAEMON_SIGNAL_EVENT = "reticle_daemon_signalled";
26
22
  /**
27
23
  * How often a live daemon says so.
28
24
  *
@@ -20,9 +20,9 @@
20
20
  /** The event name a beat carries. One `grep` separates liveness from everything else in the file. */
21
21
  export const DAEMON_HEARTBEAT_EVENT = 'reticle_daemon_alive';
22
22
  /** Written by installExitTrace when the process exits through Node. */
23
- export const DAEMON_EXIT_EVENT = 'reticle_daemon_exiting';
23
+ const DAEMON_EXIT_EVENT = 'reticle_daemon_exiting';
24
24
  /** Written by installExitTrace when a signal arrives, which is a CAUSE the exit line does not carry. */
25
- export const DAEMON_SIGNAL_EVENT = 'reticle_daemon_signalled';
25
+ const DAEMON_SIGNAL_EVENT = 'reticle_daemon_signalled';
26
26
  /**
27
27
  * How often a live daemon says so.
28
28
  *
@@ -27,8 +27,6 @@ export interface IssueState {
27
27
  state: 'open' | 'closed';
28
28
  labels: readonly string[];
29
29
  }
30
- /** The label that means "fixed, waiting for a release" — an honest reason to still be open. */
31
- export declare const PENDING_RELEASE_LABEL = "fixed-pending-release";
32
30
  export declare function closedRefsIn(commitMessage: string): number[];
33
31
  /**
34
32
  * Of the issues these commits claimed to close, the ones still reading as available work.
@@ -23,7 +23,7 @@
23
23
  * below testable without a network or a repo.
24
24
  */
25
25
  /** The label that means "fixed, waiting for a release" — an honest reason to still be open. */
26
- export const PENDING_RELEASE_LABEL = 'fixed-pending-release';
26
+ const PENDING_RELEASE_LABEL = 'fixed-pending-release';
27
27
  /**
28
28
  * Issue numbers a commit message CLAIMS to have closed.
29
29
  *
@@ -113,5 +113,46 @@ export interface ContradictionOptions {
113
113
  * with no view.
114
114
  */
115
115
  renderProved?: boolean | undefined;
116
+ /**
117
+ * The document currently under observation, as the session derived it from its own event stream.
118
+ *
119
+ * Every rule below reasons about "the same window", and a window is scoped by time and by
120
+ * ring-buffer capacity and by nothing else — so it can still hold the traffic of a page a full
121
+ * navigation or a reload has already thrown away. Naming that traffic as the cause of an action
122
+ * taken now is true about the bytes and false about the world.
123
+ *
124
+ * Undefined means nobody could say which document is current (an SDK too old to stamp one, a caller
125
+ * with no session in hand), and the scoping then does nothing at all — `isSameDocument` treats
126
+ * absence as current on both sides, so the engine behaves exactly as it did before this existed.
127
+ */
128
+ currentDocumentId?: string | undefined;
129
+ /**
130
+ * The edit epoch currently in force, as the session derived it from its own event stream.
131
+ *
132
+ * The edit-shaped half of `currentDocumentId`. A hot update replaces modules and re-renders inside
133
+ * the SAME document, so the document id cannot see it and observations of code the agent has
134
+ * already rewritten go on answering for it in silence.
135
+ *
136
+ * Undefined means nobody could say (an SDK too old to stamp one, a page with no hot-update channel,
137
+ * a caller with no session in hand) and the scoping then does nothing at all — `isSameEditEpoch`
138
+ * treats absence as current on both sides.
139
+ */
140
+ currentEditEpoch?: number | undefined;
116
141
  }
142
+ /**
143
+ * Cross-channel contradictions in this window, with the edit-epoch caveat attached when it applies.
144
+ *
145
+ * Cross-epoch evidence is LABELLED rather than excluded, which is the opposite of what the document
146
+ * scoping does, and the difference is the point. A navigation is total — it throws away the page,
147
+ * the refs, the in-flight requests and the state — so nothing recorded before it is still about the
148
+ * world, and dropping it is the only honest option. A hot update is not: most modules, most of the
149
+ * DOM, the whole network log and every console line survive one, so most of what was observed a
150
+ * second before an edit is still true a second after. Excluding it would empty windows that hold
151
+ * real findings, and an emptied window reads as "nothing happened" — which is the more expensive of
152
+ * the two wrong answers and the one this whole family of checks exists to prevent.
153
+ *
154
+ * So the findings stand and the caveat is said out loud, and only when it is unambiguous: EVERY
155
+ * observation in the window predates the edit. One post-edit observation and the agent is already
156
+ * looking at the code it wrote, so the label would be noise.
157
+ */
117
158
  export declare function findContradictions(allEvents: readonly ReticleEvent[], options?: ContradictionOptions): Contradiction[];