@reticlehq/server 2.12.0 → 2.13.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 (386) hide show
  1. package/SKILL.md +67 -109
  2. package/dist/bridge/auth-failure-reason.d.ts +6 -2
  3. package/dist/bridge/auth-failure-reason.js +15 -8
  4. package/dist/bridge/bridge.d.ts +18 -1
  5. package/dist/bridge/bridge.js +247 -164
  6. package/dist/bridge/pairing-token.d.ts +5 -0
  7. package/dist/bridge/pairing-token.js +28 -0
  8. package/dist/bridge/protocol-skew.d.ts +37 -0
  9. package/dist/bridge/protocol-skew.js +45 -0
  10. package/dist/capsule/capsule-store.d.ts +6 -6
  11. package/dist/capsule/causal-summary.js +7 -2
  12. package/dist/cli/cli-doctor.js +31 -1
  13. package/dist/cli/cli-launch.d.ts +11 -1
  14. package/dist/cli/cli-launch.js +59 -10
  15. package/dist/cli/cli-parse.d.ts +15 -1
  16. package/dist/cli/cli-parse.js +212 -8
  17. package/dist/cli/cli-verify.d.ts +1 -0
  18. package/dist/cli/cli-verify.js +3 -2
  19. package/dist/cli/cloud-cli.js +428 -259
  20. package/dist/cli/cloud-keystore.d.ts +37 -0
  21. package/dist/cli/cloud-keystore.js +97 -0
  22. package/dist/cli/cloud-kit.d.ts +37 -0
  23. package/dist/cli/cloud-kit.js +121 -0
  24. package/dist/cli/cloud-login.d.ts +17 -0
  25. package/dist/cli/cloud-login.js +183 -0
  26. package/dist/cli/cloud-session.d.ts +48 -0
  27. package/dist/cli/cloud-session.js +53 -0
  28. package/dist/cli/config-discovery.js +1 -0
  29. package/dist/cli/daemon-start-options.d.ts +32 -0
  30. package/dist/cli/daemon-start-options.js +38 -0
  31. package/dist/cli/device-flow.d.ts +76 -0
  32. package/dist/cli/device-flow.js +56 -0
  33. package/dist/cli/doctor-rows.d.ts +9 -0
  34. package/dist/cli/doctor-rows.js +9 -0
  35. package/dist/cli/drive-attach.d.ts +13 -0
  36. package/dist/cli/drive-attach.js +18 -0
  37. package/dist/cli/drive-command.js +7 -3
  38. package/dist/cli/mcp-command.js +51 -20
  39. package/dist/cli/project-name.d.ts +30 -0
  40. package/dist/cli/project-name.js +44 -0
  41. package/dist/cli/status-next-action.d.ts +14 -0
  42. package/dist/cli/status-next-action.js +4 -4
  43. package/dist/cli.d.ts +1 -0
  44. package/dist/cli.js +134 -80
  45. package/dist/cloud/cloud-config.d.ts +22 -1
  46. package/dist/cloud/cloud-config.js +64 -5
  47. package/dist/cloud/cloud-sync.d.ts +5 -5
  48. package/dist/cloud/cloud-sync.js +33 -2
  49. package/dist/cloud/sync-cycle.d.ts +110 -0
  50. package/dist/cloud/sync-cycle.js +270 -0
  51. package/dist/cloud/sync-daemon.d.ts +69 -0
  52. package/dist/cloud/sync-daemon.js +260 -0
  53. package/dist/cloud/sync-disk.d.ts +9 -0
  54. package/dist/cloud/sync-disk.js +191 -0
  55. package/dist/cloud/sync-hash.d.ts +2 -0
  56. package/dist/cloud/sync-hash.js +35 -0
  57. package/dist/crawl/crawl-tools.js +5 -3
  58. package/dist/crawl/crawl.d.ts +3 -0
  59. package/dist/crawl/crawl.js +5 -0
  60. package/dist/daemon/daemon-resolve.d.ts +43 -0
  61. package/dist/daemon/daemon-resolve.js +69 -0
  62. package/dist/daemon/dev-servers.d.ts +15 -0
  63. package/dist/daemon/dev-servers.js +68 -0
  64. package/dist/daemon/wake-decision.d.ts +32 -0
  65. package/dist/daemon/wake-decision.js +38 -0
  66. package/dist/domain/domain-tools.js +2 -2
  67. package/dist/events/contradictions.d.ts +18 -0
  68. package/dist/events/contradictions.js +134 -21
  69. package/dist/events/declared.d.ts +7 -4
  70. package/dist/events/declared.js +15 -6
  71. package/dist/events/echo-mismatch.js +64 -14
  72. package/dist/events/event-filters.d.ts +8 -0
  73. package/dist/events/event-filters.js +19 -2
  74. package/dist/events/predicate-eval.js +53 -5
  75. package/dist/events/predicate-precheck.d.ts +8 -0
  76. package/dist/events/predicate-precheck.js +41 -0
  77. package/dist/events/predicate-route.d.ts +14 -0
  78. package/dist/events/predicate-route.js +36 -8
  79. package/dist/events/predicate.d.ts +5 -0
  80. package/dist/events/predicate.js +37 -5
  81. package/dist/events/split-text-miss.d.ts +30 -0
  82. package/dist/events/split-text-miss.js +44 -0
  83. package/dist/flows/flake.d.ts +6 -6
  84. package/dist/flows/flow-memory-consult.d.ts +55 -0
  85. package/dist/flows/flow-memory-consult.js +38 -0
  86. package/dist/flows/flow-replay-run.d.ts +18 -7
  87. package/dist/flows/flow-replay-run.js +239 -22
  88. package/dist/flows/flow-replay.js +11 -2
  89. package/dist/flows/flow-step-runners.d.ts +8 -0
  90. package/dist/flows/flow-step-runners.js +8 -2
  91. package/dist/flows/flow-store-for-session.d.ts +31 -0
  92. package/dist/flows/flow-store-for-session.js +33 -0
  93. package/dist/flows/flow-tools.js +58 -32
  94. package/dist/flows/flows.d.ts +8 -0
  95. package/dist/flows/flows.js +468 -2
  96. package/dist/flows/heal-run.js +5 -2
  97. package/dist/flows/predicate-to-expect.d.ts +17 -12
  98. package/dist/flows/predicate-to-expect.js +30 -12
  99. package/dist/flows/recordings.d.ts +13 -1
  100. package/dist/flows/recordings.js +17 -2
  101. package/dist/flows/replay.d.ts +22 -2
  102. package/dist/flows/replay.js +28 -2
  103. package/dist/flows/verify-change-tools.js +5 -2
  104. package/dist/honesty/blind-spots.d.ts +55 -2
  105. package/dist/honesty/blind-spots.js +63 -3
  106. package/dist/honesty/honesty.d.ts +24 -0
  107. package/dist/honesty/honesty.js +25 -1
  108. package/dist/honesty/instrumentation-gaps.d.ts +9 -0
  109. package/dist/honesty/instrumentation-gaps.js +29 -2
  110. package/dist/honesty/verified.d.ts +2 -1
  111. package/dist/honesty/verified.js +16 -1
  112. package/dist/http-server.js +12 -0
  113. package/dist/impact/impact-recorder.d.ts +31 -8
  114. package/dist/impact/impact-recorder.js +104 -21
  115. package/dist/impact/impact-store.d.ts +15 -1
  116. package/dist/impact/impact-store.js +43 -2
  117. package/dist/index.js +113 -7
  118. package/dist/init/agent-rules.d.ts +13 -6
  119. package/dist/init/agent-rules.js +23 -12
  120. package/dist/init/astro-patch.d.ts +16 -0
  121. package/dist/init/astro-patch.js +28 -0
  122. package/dist/init/capabilities.d.ts +4 -1
  123. package/dist/init/capabilities.js +22 -2
  124. package/dist/init/confirm.d.ts +31 -7
  125. package/dist/init/confirm.js +48 -8
  126. package/dist/init/cra.d.ts +20 -4
  127. package/dist/init/cra.js +57 -17
  128. package/dist/init/csp-check.js +17 -2
  129. package/dist/init/csp-doctor.d.ts +8 -1
  130. package/dist/init/csp-doctor.js +8 -3
  131. package/dist/init/dev-script.d.ts +3 -1
  132. package/dist/init/dev-script.js +8 -5
  133. package/dist/init/format-generated.d.ts +18 -0
  134. package/dist/init/format-generated.js +39 -0
  135. package/dist/init/install-hint.d.ts +9 -0
  136. package/dist/init/install-hint.js +29 -0
  137. package/dist/init/mcp-clients.d.ts +7 -0
  138. package/dist/init/mcp-clients.js +7 -0
  139. package/dist/init/mcp.d.ts +1 -1
  140. package/dist/init/mcp.js +1 -1
  141. package/dist/init/node-io.js +11 -1
  142. package/dist/init/plan-framework.js +81 -45
  143. package/dist/init/plan.d.ts +20 -7
  144. package/dist/init/plan.js +20 -45
  145. package/dist/init/preflight.d.ts +28 -0
  146. package/dist/init/preflight.js +38 -0
  147. package/dist/init/run.d.ts +32 -9
  148. package/dist/init/run.js +141 -119
  149. package/dist/init/slash-command.d.ts +1 -1
  150. package/dist/init/snippets.js +41 -25
  151. package/dist/init/vite-config.d.ts +1 -1
  152. package/dist/init/vite-config.js +39 -25
  153. package/dist/init/workspace-apps.d.ts +17 -10
  154. package/dist/init/workspace-apps.js +110 -0
  155. package/dist/input/network-mock-tools.js +3 -6
  156. package/dist/input/real-input.js +25 -5
  157. package/dist/input/scroll-tools.js +4 -6
  158. package/dist/input/viewport-tools.js +5 -4
  159. package/dist/intent/inline-intent.d.ts +10 -19
  160. package/dist/intent/inline-intent.js +91 -1
  161. package/dist/intent/intent-shard-store.d.ts +72 -0
  162. package/dist/intent/intent-shard-store.js +203 -0
  163. package/dist/intent/intent-shard.d.ts +411 -0
  164. package/dist/intent/intent-shard.js +176 -0
  165. package/dist/intent/intent-store.d.ts +12 -0
  166. package/dist/intent/intent-store.js +21 -0
  167. package/dist/intent/intent-subject.d.ts +45 -0
  168. package/dist/intent/intent-subject.js +177 -0
  169. package/dist/intent/intent-tools.js +65 -2
  170. package/dist/intent/open-intents.d.ts +21 -0
  171. package/dist/intent/open-intents.js +15 -0
  172. package/dist/license/license-env.d.ts +29 -0
  173. package/dist/license/license-env.js +148 -0
  174. package/dist/loopback-agent.d.ts +31 -0
  175. package/dist/loopback-agent.js +57 -0
  176. package/dist/mcp/attach-memory.d.ts +18 -2
  177. package/dist/mcp/attach-memory.js +50 -7
  178. package/dist/mcp/client-identity.d.ts +31 -0
  179. package/dist/mcp/client-identity.js +51 -0
  180. package/dist/mcp/mcp-post-transport.d.ts +26 -0
  181. package/dist/mcp/mcp-post-transport.js +109 -0
  182. package/dist/mcp/mcp-proxy.d.ts +2 -1
  183. package/dist/mcp/mcp-proxy.js +52 -62
  184. package/dist/mcp/mcp.js +7 -1
  185. package/dist/mcp/proxy-backoff.d.ts +11 -0
  186. package/dist/mcp/proxy-backoff.js +13 -0
  187. package/dist/mcp/proxy-daemon-probe.d.ts +1 -0
  188. package/dist/mcp/proxy-daemon-probe.js +7 -1
  189. package/dist/mcp/proxy-handshake.d.ts +23 -0
  190. package/dist/mcp/proxy-handshake.js +31 -0
  191. package/dist/mcp/tool-catalog-cache.d.ts +8 -0
  192. package/dist/mcp/tool-catalog-cache.js +12 -0
  193. package/dist/memory/memory-tools.d.ts +2 -0
  194. package/dist/memory/memory-tools.js +82 -0
  195. package/dist/memory/project-memory.d.ts +34 -0
  196. package/dist/memory/project-memory.js +96 -0
  197. package/dist/oracles/propose-consequences.js +12 -3
  198. package/dist/pool/browser-pool.d.ts +63 -4
  199. package/dist/pool/browser-pool.js +113 -4
  200. package/dist/pool/playwright-launcher.js +9 -0
  201. package/dist/project/project-tools.js +3 -3
  202. package/dist/project/reticle-dir.d.ts +15 -2
  203. package/dist/project/reticle-dir.js +21 -4
  204. package/dist/runs/run-store.d.ts +10 -0
  205. package/dist/runs/run-store.js +10 -0
  206. package/dist/runs/verification-sync.d.ts +0 -11
  207. package/dist/runs/verification-sync.js +22 -1
  208. package/dist/runs/verify-server.d.ts +8 -1
  209. package/dist/runs/verify-server.js +14 -2
  210. package/dist/session/command-timeout.d.ts +5 -2
  211. package/dist/session/command-timeout.js +5 -4
  212. package/dist/session/lease-visibility.d.ts +67 -0
  213. package/dist/session/lease-visibility.js +76 -0
  214. package/dist/session/no-session-diagnosis.d.ts +23 -0
  215. package/dist/session/no-session-diagnosis.js +58 -17
  216. package/dist/session/no-session-next-action.d.ts +7 -0
  217. package/dist/session/no-session-next-action.js +42 -3
  218. package/dist/session/no-session-watch.d.ts +10 -6
  219. package/dist/session/no-session-watch.js +51 -19
  220. package/dist/session/pending-commands.d.ts +11 -1
  221. package/dist/session/pending-commands.js +12 -2
  222. package/dist/session/resolve-within.d.ts +34 -0
  223. package/dist/session/resolve-within.js +53 -0
  224. package/dist/session/session-info.d.ts +18 -0
  225. package/dist/session/session-info.js +11 -0
  226. package/dist/session/session-manager.d.ts +14 -0
  227. package/dist/session/session-manager.js +61 -3
  228. package/dist/session/session-mirror.d.ts +16 -0
  229. package/dist/session/session-mirror.js +22 -0
  230. package/dist/session/session-recommendation.d.ts +19 -5
  231. package/dist/session/session-recommendation.js +25 -9
  232. package/dist/session/session-successor.js +36 -1
  233. package/dist/session/session-tools.js +2 -2
  234. package/dist/session/session.d.ts +22 -2
  235. package/dist/session/session.js +87 -7
  236. package/dist/session/wall-clock.d.ts +12 -0
  237. package/dist/session/wall-clock.js +14 -0
  238. package/dist/setup/agent-configs.d.ts +96 -0
  239. package/dist/setup/agent-configs.js +333 -0
  240. package/dist/setup/agent-io.d.ts +9 -0
  241. package/dist/setup/agent-io.js +16 -0
  242. package/dist/setup/agent-writer.d.ts +51 -0
  243. package/dist/setup/agent-writer.js +115 -0
  244. package/dist/setup/approval-migration.d.ts +47 -0
  245. package/dist/setup/approval-migration.js +68 -0
  246. package/dist/setup/auto-approve.d.ts +85 -0
  247. package/dist/setup/auto-approve.js +150 -0
  248. package/dist/setup/bridge-port.d.ts +15 -0
  249. package/dist/setup/bridge-port.js +24 -0
  250. package/dist/setup/desktop-shape.d.ts +57 -0
  251. package/dist/setup/desktop-shape.js +63 -0
  252. package/dist/setup/dev-server-wait.d.ts +60 -0
  253. package/dist/setup/dev-server-wait.js +65 -0
  254. package/dist/setup/drive-agent.d.ts +71 -0
  255. package/dist/setup/drive-agent.js +171 -0
  256. package/dist/setup/drive-plan.d.ts +68 -0
  257. package/dist/setup/drive-plan.js +130 -0
  258. package/dist/setup/ensure-daemon.d.ts +29 -0
  259. package/dist/setup/ensure-daemon.js +58 -0
  260. package/dist/setup/init-runtime.d.ts +47 -0
  261. package/dist/setup/init-runtime.js +160 -0
  262. package/dist/setup/interrupt.d.ts +25 -0
  263. package/dist/setup/interrupt.js +40 -0
  264. package/dist/setup/license-key.d.ts +39 -0
  265. package/dist/setup/license-key.js +72 -0
  266. package/dist/setup/listeners.d.ts +37 -0
  267. package/dist/setup/listeners.js +85 -0
  268. package/dist/setup/node-effects.d.ts +60 -0
  269. package/dist/setup/node-effects.js +210 -0
  270. package/dist/setup/page-probe.d.ts +39 -0
  271. package/dist/setup/page-probe.js +49 -0
  272. package/dist/setup/relaunch.d.ts +36 -0
  273. package/dist/setup/relaunch.js +57 -0
  274. package/dist/setup/remaining-steps.d.ts +19 -0
  275. package/dist/setup/remaining-steps.js +31 -0
  276. package/dist/setup/run-setup.d.ts +99 -0
  277. package/dist/setup/run-setup.js +187 -0
  278. package/dist/setup/session-pick.d.ts +47 -0
  279. package/dist/setup/session-pick.js +51 -0
  280. package/dist/setup/setup-command.d.ts +45 -0
  281. package/dist/setup/setup-command.js +199 -0
  282. package/dist/setup/setup-options.d.ts +74 -0
  283. package/dist/setup/setup-options.js +67 -0
  284. package/dist/setup/transcripts.d.ts +16 -0
  285. package/dist/setup/transcripts.js +43 -0
  286. package/dist/status-payload.d.ts +14 -1
  287. package/dist/status-payload.js +23 -1
  288. package/dist/telemetry/daemon-telemetry.d.ts +8 -1
  289. package/dist/telemetry/daemon-telemetry.js +52 -5
  290. package/dist/telemetry/error-class.js +1 -1
  291. package/dist/telemetry/feedback-context.d.ts +9 -1
  292. package/dist/telemetry/feedback-context.js +60 -29
  293. package/dist/telemetry/feedback-local.d.ts +15 -0
  294. package/dist/telemetry/feedback-local.js +56 -0
  295. package/dist/telemetry/feedback.d.ts +11 -0
  296. package/dist/telemetry/feedback.js +19 -6
  297. package/dist/telemetry/init-telemetry.d.ts +2 -0
  298. package/dist/telemetry/init-telemetry.js +2 -0
  299. package/dist/telemetry/install-source.d.ts +14 -0
  300. package/dist/telemetry/install-source.js +30 -0
  301. package/dist/telemetry/license-activation.d.ts +7 -1
  302. package/dist/telemetry/license-activation.js +9 -5
  303. package/dist/telemetry/mcp-connection.js +7 -0
  304. package/dist/telemetry/project-profile.d.ts +12 -1
  305. package/dist/telemetry/project-profile.js +11 -6
  306. package/dist/telemetry/proxy-telemetry.d.ts +3 -0
  307. package/dist/telemetry/proxy-telemetry.js +21 -0
  308. package/dist/telemetry/session-metrics.d.ts +12 -0
  309. package/dist/telemetry/session-metrics.js +35 -1
  310. package/dist/telemetry/telemetry.js +17 -2
  311. package/dist/telemetry/tool-refused.d.ts +3 -1
  312. package/dist/telemetry/tool-refused.js +34 -2
  313. package/dist/tools/act-capsule.d.ts +10 -0
  314. package/dist/tools/act-capsule.js +8 -2
  315. package/dist/tools/act-danger.d.ts +2 -1
  316. package/dist/tools/act-danger.js +11 -3
  317. package/dist/tools/act-observation.d.ts +41 -0
  318. package/dist/tools/act-observation.js +36 -0
  319. package/dist/tools/act-preflight.d.ts +20 -0
  320. package/dist/tools/act-preflight.js +98 -0
  321. package/dist/tools/act-sequence-retry.d.ts +45 -0
  322. package/dist/tools/act-sequence-retry.js +58 -0
  323. package/dist/tools/act-sequence-tool.d.ts +13 -0
  324. package/dist/tools/act-sequence-tool.js +135 -0
  325. package/dist/tools/act-target.d.ts +14 -0
  326. package/dist/tools/act-target.js +47 -0
  327. package/dist/tools/act-tools.d.ts +21 -1
  328. package/dist/tools/act-tools.js +100 -213
  329. package/dist/tools/assert-verdict.js +22 -16
  330. package/dist/tools/browser-tools.js +6 -2
  331. package/dist/tools/dynamic-tools.js +24 -5
  332. package/dist/tools/error-recovery.d.ts +1 -0
  333. package/dist/tools/error-recovery.js +8 -0
  334. package/dist/tools/invoke-tool.js +59 -9
  335. package/dist/tools/lease-tools.d.ts +30 -4
  336. package/dist/tools/lease-tools.js +169 -5
  337. package/dist/tools/merged-name-redirect.d.ts +13 -0
  338. package/dist/tools/merged-name-redirect.js +22 -0
  339. package/dist/tools/numeric-bounds.d.ts +100 -0
  340. package/dist/tools/numeric-bounds.js +125 -0
  341. package/dist/tools/observe-tools.js +47 -40
  342. package/dist/tools/read-tools.js +28 -7
  343. package/dist/tools/real-input-attempt.d.ts +10 -0
  344. package/dist/tools/real-input-attempt.js +59 -1
  345. package/dist/tools/reconcile-tools.js +4 -4
  346. package/dist/tools/tool-kit.d.ts +12 -0
  347. package/dist/tools/tool-kit.js +6 -1
  348. package/dist/tools/tool-names.d.ts +2 -0
  349. package/dist/tools/tool-names.js +2 -0
  350. package/dist/tools/tools-helpers.d.ts +19 -0
  351. package/dist/tools/tools-helpers.js +46 -0
  352. package/dist/tools/tools.d.ts +2 -0
  353. package/dist/tools/tools.js +56 -8
  354. package/dist/update/reticle-deps.d.ts +9 -0
  355. package/dist/update/reticle-deps.js +27 -0
  356. package/dist/update/sdk-sync.d.ts +1 -2
  357. package/dist/update/sdk-sync.js +1 -21
  358. package/dist/update/update-nudge.d.ts +18 -0
  359. package/dist/update/update-nudge.js +20 -0
  360. package/dist/version/peer-announce.js +4 -2
  361. package/dist/version/sdk-fix.d.ts +47 -0
  362. package/dist/version/sdk-fix.js +127 -0
  363. package/dist/version/version-nudge.d.ts +14 -0
  364. package/dist/version/version-nudge.js +21 -0
  365. package/dist/version/version-skew.d.ts +38 -3
  366. package/dist/version/version-skew.js +83 -7
  367. package/dist/visual/visual-store.d.ts +6 -6
  368. package/dist/visual/visual-store.js +18 -16
  369. package/dist/visual/visual-tools.js +57 -12
  370. package/docs/cli/doctor.mdx +1 -0
  371. package/docs/cli/serve.mdx +4 -2
  372. package/docs/cli.mdx +7 -3
  373. package/docs/desktop.mdx +1 -1
  374. package/docs/docs.json +1 -1
  375. package/docs/getting-started.md +19 -3
  376. package/docs/index.mdx +9 -6
  377. package/docs/install-agentic.mdx +38 -12
  378. package/docs/install-manual.mdx +1 -1
  379. package/docs/packages.mdx +1 -1
  380. package/docs/skill-file.mdx +3 -3
  381. package/docs/telemetry-contract.md +108 -4
  382. package/docs/tools-act-sequence.mdx +16 -0
  383. package/docs/usage.md +1 -1
  384. package/package.json +4 -3
  385. package/dist/init/cursor.d.ts +0 -24
  386. package/dist/init/cursor.js +0 -80
@@ -9,6 +9,7 @@ import { log } from '../log.js';
9
9
  import { getSessionMetrics } from '../telemetry/session-metrics.js';
10
10
  import { describeSkew, sdkFix, SkewPair } from '../version/version-skew.js';
11
11
  import { noteVersionSkew } from '../version/version-nudge.js';
12
+ import { protocolSkewReason } from './protocol-skew.js';
12
13
  import { SERVER_VERSION } from '../version/server-version.js';
13
14
  /**
14
15
  * Take the mutable half of a session's identity from a repeat HELLO.
@@ -37,8 +38,19 @@ function refreshIdentity(session, hello) {
37
38
  * surfacing them is strictly better than the generic answer.
38
39
  */
39
40
  export const WS_CLOSE_REASON = {
40
- PROTOCOL_MISMATCH: 'protocol version mismatch — upgrade @reticlehq/browser',
41
+ /**
42
+ * The generic form. The live path picks a DIRECTION-aware reason instead (see protocol-skew.ts);
43
+ * this stays as the close-code table's label and as the fallback when the skew cannot be read.
44
+ */
45
+ PROTOCOL_MISMATCH: 'protocol version mismatch — @reticlehq/browser and server disagree',
41
46
  AUTH_FAILED: 'authentication failed — reload the page to pick up the current pairing token',
47
+ /**
48
+ * The daemon threw while handling a message. Closing is deliberate: an exception leaves the
49
+ * session in a state neither side can describe, and a socket left OPEN and unresponsive is the
50
+ * worst of the outcomes — the agent's next tool call reports "no browser session connected",
51
+ * which reads as an app nobody started. Closing at least makes the SDK reconnect and say so.
52
+ */
53
+ HANDLER_FAILED: 'the Reticle daemon failed handling a message — reload the page to reconnect',
42
54
  /**
43
55
  * The pool of half-open handshakes was full, so this dial was turned away before it could say
44
56
  * anything. Every other refusal here records why and this one did not — it closed the socket and
@@ -78,13 +90,13 @@ const WS_CLOSE = {
78
90
  HELLO_TIMEOUT: [1008, 'hello timeout'],
79
91
  INVALID_MESSAGE: [1008, 'invalid message'],
80
92
  HELLO_DUPLICATE: [1008, 'hello already received'],
81
- // Names the recovery, because the SDK prints this reason and then stops retrying — so it is the
82
- // last thing the developer sees. The common cause is a page served before the daemon existed,
83
- // which carries no token; a reload re-fetches the connect module and picks the current one up.
84
- // Kept under the 123-byte WebSocket close-reason limit.
93
+ // AUTH_FAILED's code is 1008; the reason string is chosen at close time by authFailureReason
94
+ // (no token vs wrong token vs different project). A reload cannot mint a credential into a CDN
95
+ // snippet or a Next env frozen at config eval. Kept under the 123-byte close-reason limit.
85
96
  AUTH_FAILED: [1008, WS_CLOSE_REASON.AUTH_FAILED],
86
97
  SESSION_LIMIT: [1013, 'session limit reached'],
87
98
  PROTOCOL_MISMATCH: [1008, WS_CLOSE_REASON.PROTOCOL_MISMATCH],
99
+ HANDLER_FAILED: [1011, WS_CLOSE_REASON.HANDLER_FAILED],
88
100
  };
89
101
  /** Parse a positive integer env override; anything else (unset, zero, junk) falls through to the default. */
90
102
  function positiveIntFromEnv(raw) {
@@ -175,6 +187,7 @@ export class Bridge {
175
187
  #maxSessions;
176
188
  #maxPendingConnections;
177
189
  #helloTimeoutMs;
190
+ #sdkFix;
178
191
  #pendingConnections = 0;
179
192
  #onReplay;
180
193
  /**
@@ -188,7 +201,15 @@ export class Bridge {
188
201
  * is the only thing that could have found it.
189
202
  */
190
203
  #onSessionReady = [];
191
- #onSessionCreate;
204
+ /*
205
+ * A LIST, not a single slot.
206
+ *
207
+ * It held one handler, so a second `attachSessionCreate` silently replaced the first — and the
208
+ * first is what stamps a session's artifactRoot, without which every verdict is recorded against
209
+ * wherever the daemon was started. A registration API whose failure mode is "the previous caller
210
+ * stops working, quietly" is a trap; anything that needs to run on a new session now simply runs.
211
+ */
212
+ #onSessionCreate = [];
192
213
  /** Fired when a session is removed — flushes its journal tail + persists what it learned. */
193
214
  #onSessionEnd;
194
215
  constructor(options) {
@@ -202,9 +223,24 @@ export class Bridge {
202
223
  this.#clock = options.clock ?? (() => Date.now());
203
224
  this.#token =
204
225
  options.token !== undefined && options.token.length > 0 ? options.token : undefined;
205
- this.#allowedOrigins = new Set((options.allowedOrigins ?? [])
206
- .map(normalizeOrigin)
207
- .filter((origin) => origin !== null));
226
+ // An entry that fails to normalize — typically a scheme-less `myapp.test` — used to be
227
+ // filtered out with no trace: the allow-list LOOKED configured, every dial from that origin
228
+ // was refused at the gate, and the refusal read as a scheme mismatch in the page rather than
229
+ // as a config value that was silently discarded. Warn at construction, naming the entry and
230
+ // the accepted form, so the config error surfaces at startup instead of as a mystery 403.
231
+ this.#allowedOrigins = new Set();
232
+ for (const entry of options.allowedOrigins ?? []) {
233
+ const normalized = normalizeOrigin(entry);
234
+ if (null === normalized) {
235
+ log('allowed_origin_ignored', {
236
+ entry,
237
+ warning: `${ReticleEnv.ALLOWED_ORIGINS} entry "${entry}" is not a valid origin and was ` +
238
+ `IGNORED — an origin needs a scheme, e.g. "http://${entry}" (scheme://host[:port]).`,
239
+ });
240
+ continue;
241
+ }
242
+ this.#allowedOrigins.add(normalized);
243
+ }
208
244
  // Binding beyond localhost means the browser dials in from a non-loopback Origin. With no
209
245
  // allow-list, #originAllowed rejects every such Origin at the WS handshake, so the bridge comes
210
246
  // up but accepts nothing — a silent, fail-closed footgun. Refuse to start with a clear message.
@@ -224,6 +260,7 @@ export class Bridge {
224
260
  this.#maxPendingConnections =
225
261
  options.maxPendingConnections ?? TRANSPORT_LIMITS.MAX_PENDING_CONNECTIONS;
226
262
  this.#helloTimeoutMs = options.helloTimeoutMs ?? TRANSPORT_LIMITS.HELLO_TIMEOUT_MS;
263
+ this.#sdkFix = options.sdkFix ?? (() => sdkFix(SERVER_VERSION));
227
264
  if (options.server !== undefined) {
228
265
  const srv = options.server;
229
266
  this.#wss = new WebSocketServer({
@@ -323,176 +360,221 @@ export class Bridge {
323
360
  socket.close(...WS_CLOSE.HELLO_TIMEOUT);
324
361
  }, this.#helloTimeoutMs);
325
362
  let droppedByRate = 0;
363
+ /*
364
+ * Everything below runs inside a guard, because a throw here used to leave the socket OPEN and
365
+ * unresponsive. Every other refusal in this file is careful to say WHY it closed, precisely so a
366
+ * failure is not mistaken for an app nobody started — and an unhandled exception silently
367
+ * produced that exact indistinguishable state. Measured: one undefined import turned into a test
368
+ * that timed out waiting for a close, with no error surfacing anywhere.
369
+ */
326
370
  socket.on('message', (raw) => {
327
- const now = this.#clock();
328
- if (now - messageWindowStartedAt >= 1000) {
329
- messageWindowStartedAt = now;
330
- messagesInWindow = 0;
331
- highValueInWindow = 0;
332
- }
333
- messagesInWindow += 1;
334
- const overRate = messagesInWindow > this.#maxMessagesPerSecond;
335
- const text = rawToString(raw);
336
- const parsed = this.#parse(text);
337
- if (null === parsed) {
338
- const got = helloProtocolMismatch(text);
339
- if (got !== null) {
340
- log('protocol_version_mismatch', { got, expected: RETICLE_PROTOCOL_VERSION });
341
- // Told to the AGENT too, not only to the SDK that is about to stop retrying. Without this
342
- // the next tool call answers "no browser session connected", which reads as "no app is
343
- // running" — so an SDK too old to connect is invisible.
344
- this.sessions.noteClosure(WS_CLOSE_REASON.PROTOCOL_MISMATCH, this.#clock());
345
- socket.close(...WS_CLOSE.PROTOCOL_MISMATCH);
346
- return;
371
+ try {
372
+ const now = this.#clock();
373
+ if (now - messageWindowStartedAt >= 1000) {
374
+ messageWindowStartedAt = now;
375
+ messagesInWindow = 0;
376
+ highValueInWindow = 0;
347
377
  }
348
- socket.close(...WS_CLOSE.INVALID_MESSAGE);
349
- return;
350
- }
351
- // Over the cap we SAMPLE — we never disconnect. Going blind is the one thing an observability
352
- // layer must not do when it sees too much, and the close was a policy code the SDK correctly
353
- // never retries: the app kept running and Reticle was blind from that instant, on exactly the
354
- // busiest apps. Measured: each request emits two messages (pending + settled), so the cap binds
355
- // at ~500 requests/second — a dashboard burst reaches it, a streaming app lives above it.
356
- //
357
- // Only EVENTS are dropped. A dropped hello would strand the session and a dropped
358
- // command_result would hang the agent's in-flight call, so control traffic is always processed
359
- // however fast it arrives — the cap exists to bound observation, not to break the protocol.
360
- // Over the cap we drop by VALUE, not by arrival order. Volume is inversely correlated with
361
- // value — a render-commit storm is thousands/second while the network call an assertion turns
362
- // on is one — so first-come-first-dropped spends the budget on churn and loses the signal.
363
- // High-value kinds get a bounded reserve on top of the cap; see event-priority.ts.
364
- if (overRate && parsed.kind === MessageKind.EVENT) {
365
- const withinReserve = isHighValueEvent(parsed.event.type) && highValueInWindow < highValueReserve;
366
- if (withinReserve) {
367
- highValueInWindow += 1;
368
- }
369
- else {
370
- droppedByRate += 1;
371
- if (session !== undefined)
372
- session.noteRateLimited(droppedByRate);
378
+ messagesInWindow += 1;
379
+ const overRate = messagesInWindow > this.#maxMessagesPerSecond;
380
+ const text = rawToString(raw);
381
+ const parsed = this.#parse(text);
382
+ if (null === parsed) {
383
+ const got = helloProtocolMismatch(text);
384
+ if (got !== null) {
385
+ log('protocol_version_mismatch', { got, expected: RETICLE_PROTOCOL_VERSION });
386
+ /*
387
+ * Name the component that is actually stale.
388
+ *
389
+ * This used to send one fixed "upgrade @reticlehq/browser" whichever way the versions
390
+ * disagreed, and the skew seen in practice runs the other way — a current SDK in the app
391
+ * dialling a daemon npx served from cache. Advice naming the wrong component is worse than
392
+ * none: the user upgrades what was already current, sees no change, and blames the tool.
393
+ */
394
+ const reason = protocolSkewReason(got, RETICLE_PROTOCOL_VERSION);
395
+ // Told to the AGENT too, not only to the SDK that is about to stop retrying. Without this
396
+ // the next tool call answers "no browser session connected", which reads as "no app is
397
+ // running" — so an SDK too old to connect is invisible.
398
+ this.sessions.noteClosure(reason, this.#clock());
399
+ socket.close(WS_CLOSE.PROTOCOL_MISMATCH[0], reason);
400
+ return;
401
+ }
402
+ socket.close(...WS_CLOSE.INVALID_MESSAGE);
373
403
  return;
374
404
  }
375
- }
376
- if (parsed.kind === MessageKind.HELLO) {
377
- if (session !== undefined) {
378
- // A repeat hello on the SAME socket for the SAME session is an identity refresh, not a
379
- // violation: the SDK re-announces when the app registers capabilities, which happens after
380
- // connect by design. Closing the socket on it made `hasCapabilities` permanently false for
381
- // every app that declared anything. A hello bearing a DIFFERENT session id is still a
382
- // protocol violation — that is the case this guard exists for.
383
- if (parsed.sessionId !== session.id) {
384
- socket.close(...WS_CLOSE.HELLO_DUPLICATE);
405
+ // Over the cap we SAMPLE — we never disconnect. Going blind is the one thing an observability
406
+ // layer must not do when it sees too much, and the close was a policy code the SDK correctly
407
+ // never retries: the app kept running and Reticle was blind from that instant, on exactly the
408
+ // busiest apps. Measured: each request emits two messages (pending + settled), so the cap binds
409
+ // at ~500 requests/second — a dashboard burst reaches it, a streaming app lives above it.
410
+ //
411
+ // Only EVENTS are dropped. A dropped hello would strand the session and a dropped
412
+ // command_result would hang the agent's in-flight call, so control traffic is always processed
413
+ // however fast it arrives — the cap exists to bound observation, not to break the protocol.
414
+ // Over the cap we drop by VALUE, not by arrival order. Volume is inversely correlated with
415
+ // value — a render-commit storm is thousands/second while the network call an assertion turns
416
+ // on is one — so first-come-first-dropped spends the budget on churn and loses the signal.
417
+ // High-value kinds get a bounded reserve on top of the cap; see event-priority.ts.
418
+ if (overRate && parsed.kind === MessageKind.EVENT) {
419
+ const withinReserve = isHighValueEvent(parsed.event.type) && highValueInWindow < highValueReserve;
420
+ if (withinReserve) {
421
+ highValueInWindow += 1;
422
+ }
423
+ else {
424
+ droppedByRate += 1;
425
+ if (session !== undefined)
426
+ session.noteRateLimited(droppedByRate);
385
427
  return;
386
428
  }
387
- refreshIdentity(session, parsed);
388
- return;
389
429
  }
390
- if (this.#token !== undefined && !tokensMatch(this.#token, parsed.token)) {
391
- // A daemon left running by ANOTHER project answers this app and rejects it on token. The
392
- // token is not wrong, it is somebody else's — and "authentication failed" sends the user to
393
- // check the one thing that is fine. See auth-failure-reason.
394
- const reason = authFailureReason(this.#servedProjects, parsed.projectId);
395
- log('authentication_failed', {
396
- served: [...this.#servedProjects],
397
- ...(parsed.projectId === undefined ? {} : { helloProject: parsed.projectId }),
398
- });
399
- this.sessions.noteClosure(WS_CLOSE_REASON.AUTH_FAILED, this.#clock());
400
- socket.close(WS_CLOSE.AUTH_FAILED[0], reason);
430
+ if (parsed.kind === MessageKind.HELLO) {
431
+ if (session !== undefined) {
432
+ // A repeat hello on the SAME socket for the SAME session is an identity refresh, not a
433
+ // violation: the SDK re-announces when the app registers capabilities, which happens after
434
+ // connect by design. Closing the socket on it made `hasCapabilities` permanently false for
435
+ // every app that declared anything. A hello bearing a DIFFERENT session id is still a
436
+ // protocol violation — that is the case this guard exists for.
437
+ if (parsed.sessionId !== session.id) {
438
+ socket.close(...WS_CLOSE.HELLO_DUPLICATE);
439
+ return;
440
+ }
441
+ refreshIdentity(session, parsed);
442
+ return;
443
+ }
444
+ if (this.#token !== undefined && !tokensMatch(this.#token, parsed.token)) {
445
+ // A daemon left running by ANOTHER project answers this app and rejects it on token. The
446
+ // token is not wrong, it is somebody else's — and "authentication failed" sends the user to
447
+ // check the one thing that is fine. See auth-failure-reason.
448
+ const reason = authFailureReason(this.#servedProjects, parsed.projectId, parsed.token);
449
+ log('authentication_failed', {
450
+ served: [...this.#servedProjects],
451
+ ...(parsed.projectId === undefined ? {} : { helloProject: parsed.projectId }),
452
+ presented: parsed.token !== undefined && 0 < parsed.token.length,
453
+ });
454
+ this.sessions.noteClosure(WS_CLOSE_REASON.AUTH_FAILED, this.#clock());
455
+ socket.close(WS_CLOSE.AUTH_FAILED[0], reason);
456
+ return;
457
+ }
458
+ const existing = this.sessions.get(parsed.sessionId);
459
+ if (existing === undefined && this.sessions.count() >= this.#maxSessions) {
460
+ socket.close(...WS_CLOSE.SESSION_LIMIT);
461
+ return;
462
+ }
463
+ clearTimeout(helloTimer);
464
+ releasePending();
465
+ session = new Session(parsed, socket, this.#clock);
466
+ // Recorded on ACCEPTANCE, so it is evidence of what this daemon really serves.
467
+ if (parsed.projectId !== undefined)
468
+ this.#servedProjects.add(parsed.projectId);
469
+ // Attach the durable journal (and anything else registered) before any events stream in.
470
+ // One throwing handler must not stop the others, or the order of registration would decide
471
+ // which features survive a bad frame.
472
+ for (const handler of this.#onSessionCreate) {
473
+ try {
474
+ handler(session);
475
+ }
476
+ catch {
477
+ /* a handler that failed is not a reason to drop the session */
478
+ }
479
+ }
480
+ // A tab that has just connected has no impact record yet, so the report would read "nothing
481
+ // recorded" over a file with a month of history in it. Push what is already on disk as soon
482
+ // as there is somewhere to push it to; a session is also a thing that HAPPENED, so it counts.
483
+ // A reconnecting tab keeps its id, so counting every connect made a page reload look like
484
+ // a fresh session - the number climbed while nothing new happened.
485
+ if (!this.#countedSessions.has(session.id)) {
486
+ this.#countedSessions.add(session.id);
487
+ // Against this session's OWN project. Counted here rather than at the chokepoint because
488
+ // a connect is not a tool call, and a session that connects and is never driven is still
489
+ // that project's session — not the daemon's.
490
+ recordImpact({ sessions: 1 }, {}, session.artifactRoot);
491
+ }
492
+ // ...and the tab is shown its own project's numbers. Captured into a const so the closure
493
+ // cannot be read as capturing a reassignable binding that TS believes may be undefined.
494
+ const attached = session;
495
+ attached.pushImpact(() => impactSnapshot(attached.artifactRoot), true);
496
+ const replaced = this.sessions.add(session);
497
+ if (replaced !== undefined) {
498
+ // Name the newcomer. A field report had a live session vanish during `reticle_lease` and the
499
+ // only evidence was "session replaced by a newer connection" in the page console — which
500
+ // says a replacement happened but not BY WHAT, so the cause had to be inferred. A session
501
+ // is only ever replaced by one carrying the SAME id, and knowing which URL claimed it is
502
+ // the difference between a diagnosis and a guess.
503
+ log('session_replaced', {
504
+ sessionId: session.id,
505
+ byUrl: session.url,
506
+ previousUrl: replaced.url,
507
+ });
508
+ // Hand the displaced session its replacement BEFORE ending it, so a tool call still holding
509
+ // the old handle can finish against the live connection instead of returning an error whose
510
+ // only answer is to go and rediscover an id that has not changed. See Session.succeededBy.
511
+ replaced.succeededBy(session);
512
+ replaced.disconnect(`session replaced by a newer connection claiming the same id (${session.id}) from ${session.url}`, true);
513
+ }
514
+ // The daemon is the single judge of skew, and HELLO is where the page announces itself.
515
+ // Reported on the session (reticle_sessions) AND queued for the next tool result, because an
516
+ // agent driving a flow never calls reticle_sessions and would never learn.
517
+ const skew = describeSkew({
518
+ what: 'the page',
519
+ version: parsed.sdkVersion,
520
+ contract: parsed.contract,
521
+ fix: this.#sdkFix(),
522
+ }, { version: SERVER_VERSION, contract: CONTRACT_FINGERPRINT });
523
+ if (skew !== undefined) {
524
+ log('version_skew', {
525
+ sessionId: session.id,
526
+ sdk: parsed.sdkVersion,
527
+ daemon: SERVER_VERSION,
528
+ });
529
+ session.versionSkew = skew;
530
+ noteVersionSkew(SkewPair.SDK, skew);
531
+ }
532
+ log('session_connected', { sessionId: session.id, url: session.url });
533
+ // The one fact that separates a broken install from an unused one. Best-effort and wrapped:
534
+ // a metric must never affect whether an app can connect. See SessionSummary.appConnects.
535
+ try {
536
+ getSessionMetrics().recordAppConnected();
537
+ }
538
+ catch {
539
+ /* never let a counter interfere with an app connecting */
540
+ }
541
+ // Each handler is independent, so one throwing must not cost the others their turn.
542
+ for (const onReady of this.#onSessionReady) {
543
+ try {
544
+ onReady(session);
545
+ }
546
+ catch {
547
+ /* a session-ready observer must never break the session it observes */
548
+ }
549
+ }
401
550
  return;
402
551
  }
403
- const existing = this.sessions.get(parsed.sessionId);
404
- if (existing === undefined && this.sessions.count() >= this.#maxSessions) {
405
- socket.close(...WS_CLOSE.SESSION_LIMIT);
552
+ if (session === undefined)
406
553
  return;
554
+ session.touch();
555
+ if (parsed.kind === MessageKind.EVENT) {
556
+ // A panel ▶ replay needs the daemon's flow store, which the Session can't reach — route it to
557
+ // the daemon-wired handler instead of the in-session control path. Everything else is normal.
558
+ const replay = replayRequest(parsed.event);
559
+ if (replay !== undefined)
560
+ this.#onReplay?.(session.id, replay);
561
+ // Pass the raw frame's byte length so the buffer doesn't re-serialize every event for accounting.
562
+ else
563
+ session.pushEvent(parsed.event, Buffer.byteLength(text, 'utf8'));
407
564
  }
408
- clearTimeout(helloTimer);
409
- releasePending();
410
- session = new Session(parsed, socket, this.#clock);
411
- // Recorded on ACCEPTANCE, so it is evidence of what this daemon really serves.
412
- if (parsed.projectId !== undefined)
413
- this.#servedProjects.add(parsed.projectId);
414
- this.#onSessionCreate?.(session); // attach the durable journal before any events stream in
415
- // A tab that has just connected has no impact record yet, so the report would read "nothing
416
- // recorded" over a file with a month of history in it. Push what is already on disk as soon
417
- // as there is somewhere to push it to; a session is also a thing that HAPPENED, so it counts.
418
- // A reconnecting tab keeps its id, so counting every connect made a page reload look like
419
- // a fresh session - the number climbed while nothing new happened.
420
- if (!this.#countedSessions.has(session.id)) {
421
- this.#countedSessions.add(session.id);
422
- recordImpact({ sessions: 1 });
423
- }
424
- session.pushImpact(impactSnapshot, true);
425
- const replaced = this.sessions.add(session);
426
- if (replaced !== undefined) {
427
- // Name the newcomer. A field report had a live session vanish during `reticle_lease` and the
428
- // only evidence was "session replaced by a newer connection" in the page console — which
429
- // says a replacement happened but not BY WHAT, so the cause had to be inferred. A session
430
- // is only ever replaced by one carrying the SAME id, and knowing which URL claimed it is
431
- // the difference between a diagnosis and a guess.
432
- log('session_replaced', {
433
- sessionId: session.id,
434
- byUrl: session.url,
435
- previousUrl: replaced.url,
436
- });
437
- // Hand the displaced session its replacement BEFORE ending it, so a tool call still holding
438
- // the old handle can finish against the live connection instead of returning an error whose
439
- // only answer is to go and rediscover an id that has not changed. See Session.succeededBy.
440
- replaced.succeededBy(session);
441
- replaced.disconnect(`session replaced by a newer connection claiming the same id (${session.id}) from ${session.url}`);
565
+ else if (parsed.kind === MessageKind.COMMAND_RESULT) {
566
+ session.handleResult(parsed);
442
567
  }
443
- // The daemon is the single judge of skew, and HELLO is where the page announces itself.
444
- // Reported on the session (reticle_sessions) AND queued for the next tool result, because an
445
- // agent driving a flow never calls reticle_sessions and would never learn.
446
- const skew = describeSkew({
447
- what: 'the page',
448
- version: parsed.sdkVersion,
449
- contract: parsed.contract,
450
- fix: sdkFix(SERVER_VERSION),
451
- }, { version: SERVER_VERSION, contract: CONTRACT_FINGERPRINT });
452
- if (skew !== undefined) {
453
- log('version_skew', {
454
- sessionId: session.id,
455
- sdk: parsed.sdkVersion,
456
- daemon: SERVER_VERSION,
457
- });
458
- session.versionSkew = skew;
459
- noteVersionSkew(SkewPair.SDK, skew);
460
- }
461
- log('session_connected', { sessionId: session.id, url: session.url });
462
- // The one fact that separates a broken install from an unused one. Best-effort and wrapped:
463
- // a metric must never affect whether an app can connect. See SessionSummary.appConnects.
568
+ }
569
+ catch (err) {
570
+ log('message_handler_failed', { error: err instanceof Error ? err.message : String(err) });
571
+ // Never let the close path throw too — that would restore the hang this guard removes.
464
572
  try {
465
- getSessionMetrics().recordAppConnected();
573
+ socket.close(...WS_CLOSE.HANDLER_FAILED);
466
574
  }
467
575
  catch {
468
- /* never let a counter interfere with an app connecting */
469
- }
470
- // Each handler is independent, so one throwing must not cost the others their turn.
471
- for (const onReady of this.#onSessionReady) {
472
- try {
473
- onReady(session);
474
- }
475
- catch {
476
- /* a session-ready observer must never break the session it observes */
477
- }
576
+ socket.terminate();
478
577
  }
479
- return;
480
- }
481
- if (session === undefined)
482
- return;
483
- session.touch();
484
- if (parsed.kind === MessageKind.EVENT) {
485
- // A panel ▶ replay needs the daemon's flow store, which the Session can't reach — route it to
486
- // the daemon-wired handler instead of the in-session control path. Everything else is normal.
487
- const replay = replayRequest(parsed.event);
488
- if (replay !== undefined)
489
- this.#onReplay?.(session.id, replay);
490
- // Pass the raw frame's byte length so the buffer doesn't re-serialize every event for accounting.
491
- else
492
- session.pushEvent(parsed.event, Buffer.byteLength(text, 'utf8'));
493
- }
494
- else if (parsed.kind === MessageKind.COMMAND_RESULT) {
495
- session.handleResult(parsed);
496
578
  }
497
579
  });
498
580
  socket.on('close', () => {
@@ -593,8 +675,9 @@ export class Bridge {
593
675
  attachSessionEnd(handler) {
594
676
  this.#onSessionEnd = handler;
595
677
  }
678
+ /** Register a handler to run when a session connects. Additive — every handler runs. */
596
679
  attachSessionCreate(handler) {
597
- this.#onSessionCreate = handler;
680
+ this.#onSessionCreate.push(handler);
598
681
  }
599
682
  close() {
600
683
  return new Promise((resolve) => {
@@ -25,3 +25,8 @@ export declare function nodePairingTokenDeps(): PairingTokenDeps;
25
25
  * fails, so the caller degrades to the prior tokenless behavior rather than bricking the daemon.
26
26
  */
27
27
  export declare function readOrCreatePairingToken(dir: string, deps: PairingTokenDeps): Promise<string | undefined>;
28
+ /**
29
+ * Sync twin of `readOrCreatePairingToken`. `reticle init` and Next's `withReticle` are both
30
+ * synchronous, and a snippet printed with no token is a page that can never authenticate.
31
+ */
32
+ export declare function readOrCreatePairingTokenSync(dir: string): string | undefined;
@@ -1,4 +1,5 @@
1
1
  import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
3
  import { randomBytes } from 'node:crypto';
3
4
  import { homedir } from 'node:os';
4
5
  import { join } from 'node:path';
@@ -59,3 +60,30 @@ export async function readOrCreatePairingToken(dir, deps) {
59
60
  return undefined;
60
61
  }
61
62
  }
63
+ /**
64
+ * Sync twin of `readOrCreatePairingToken`. `reticle init` and Next's `withReticle` are both
65
+ * synchronous, and a snippet printed with no token is a page that can never authenticate.
66
+ */
67
+ export function readOrCreatePairingTokenSync(dir) {
68
+ const path = pairingTokenPath(dir);
69
+ try {
70
+ const existing = readFileSync(path, 'utf8').trim();
71
+ if (existing.length > 0)
72
+ return existing;
73
+ }
74
+ catch {
75
+ /* missing or unreadable — fall through and create one */
76
+ }
77
+ try {
78
+ const token = randomBytes(TOKEN_BYTES).toString('hex');
79
+ if (0 === token.length)
80
+ return undefined;
81
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
82
+ writeFileSync(path, token, { encoding: 'utf8', mode: 0o600 });
83
+ chmodSync(path, 0o600);
84
+ return token;
85
+ }
86
+ catch {
87
+ return undefined;
88
+ }
89
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Which side of a protocol mismatch is the stale one, and therefore what to tell the user to do.
3
+ *
4
+ * The close reason was a single fixed string — "upgrade @reticlehq/browser" — sent whichever way the
5
+ * versions disagreed. That is correct only when the page is BEHIND the daemon, and the common real
6
+ * skew runs the other way: the app's package.json pulls a current `@reticlehq/browser` while the
7
+ * daemon is an older `@reticlehq/server` that npx resolved from cache. Observed in this repo's own
8
+ * logs as `got: 2, expected: 1` — a browser two steps ahead, told to upgrade itself.
9
+ *
10
+ * Advice that names the wrong component is worse than none: the user upgrades the thing that was
11
+ * already current, sees no change, and concludes the tool is broken.
12
+ *
13
+ * Kept short on purpose — a WebSocket close reason is capped at 123 BYTES, and a reason that
14
+ * overflows is dropped by the socket layer, which would take the diagnosis with it.
15
+ *
16
+ * NOT a duplicate of `version/version-skew.ts`, and the two should not be merged. That module
17
+ * describes peers which connected SUCCESSFULLY and then disagree about behaviour, so it has both
18
+ * versions and a wire-contract fingerprint to reason from and a whole sentence to say it in. This
19
+ * one runs where the hello was rejected outright: the connection never forms, a single integer is
20
+ * all that is known, and the answer has to fit in a close frame. The remedies are worded to match
21
+ * that module's, because a user who hits both should not be told two different things.
22
+ */
23
+ /** The page is older than the daemon: its SDK cannot speak the protocol the daemon expects. */
24
+ export declare const BROWSER_IS_OLDER = "protocol mismatch \u2014 upgrade @reticlehq/browser in this app";
25
+ /** The daemon is older than the page. Usually an npx cache serving a stale @reticlehq/server. */
26
+ export declare const DAEMON_IS_OLDER = "protocol mismatch \u2014 the daemon is older; run `reticle stop`, then retry";
27
+ /** The versions disagree but not in a direction we can read. */
28
+ export declare const SKEW_UNKNOWN = "protocol version mismatch \u2014 @reticlehq/browser and server disagree";
29
+ /** The cap a WebSocket close reason must fit inside, in bytes. */
30
+ export declare const CLOSE_REASON_MAX_BYTES = 123;
31
+ /**
32
+ * Name the stale component, given what the page announced and what this daemon speaks.
33
+ *
34
+ * `got` is the page's protocol version, `expected` is the daemon's. A `got` we could not read at
35
+ * all falls back to naming both, which is vague but never actively misleading.
36
+ */
37
+ export declare const protocolSkewReason: (got: number | null, expected: number) => string;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Which side of a protocol mismatch is the stale one, and therefore what to tell the user to do.
3
+ *
4
+ * The close reason was a single fixed string — "upgrade @reticlehq/browser" — sent whichever way the
5
+ * versions disagreed. That is correct only when the page is BEHIND the daemon, and the common real
6
+ * skew runs the other way: the app's package.json pulls a current `@reticlehq/browser` while the
7
+ * daemon is an older `@reticlehq/server` that npx resolved from cache. Observed in this repo's own
8
+ * logs as `got: 2, expected: 1` — a browser two steps ahead, told to upgrade itself.
9
+ *
10
+ * Advice that names the wrong component is worse than none: the user upgrades the thing that was
11
+ * already current, sees no change, and concludes the tool is broken.
12
+ *
13
+ * Kept short on purpose — a WebSocket close reason is capped at 123 BYTES, and a reason that
14
+ * overflows is dropped by the socket layer, which would take the diagnosis with it.
15
+ *
16
+ * NOT a duplicate of `version/version-skew.ts`, and the two should not be merged. That module
17
+ * describes peers which connected SUCCESSFULLY and then disagree about behaviour, so it has both
18
+ * versions and a wire-contract fingerprint to reason from and a whole sentence to say it in. This
19
+ * one runs where the hello was rejected outright: the connection never forms, a single integer is
20
+ * all that is known, and the answer has to fit in a close frame. The remedies are worded to match
21
+ * that module's, because a user who hits both should not be told two different things.
22
+ */
23
+ /** The page is older than the daemon: its SDK cannot speak the protocol the daemon expects. */
24
+ export const BROWSER_IS_OLDER = 'protocol mismatch — upgrade @reticlehq/browser in this app';
25
+ /** The daemon is older than the page. Usually an npx cache serving a stale @reticlehq/server. */
26
+ export const DAEMON_IS_OLDER = 'protocol mismatch — the daemon is older; run `reticle stop`, then retry';
27
+ /** The versions disagree but not in a direction we can read. */
28
+ export const SKEW_UNKNOWN = 'protocol version mismatch — @reticlehq/browser and server disagree';
29
+ /** The cap a WebSocket close reason must fit inside, in bytes. */
30
+ export const CLOSE_REASON_MAX_BYTES = 123;
31
+ /**
32
+ * Name the stale component, given what the page announced and what this daemon speaks.
33
+ *
34
+ * `got` is the page's protocol version, `expected` is the daemon's. A `got` we could not read at
35
+ * all falls back to naming both, which is vague but never actively misleading.
36
+ */
37
+ export const protocolSkewReason = (got, expected) => {
38
+ if (null === got || !Number.isFinite(got))
39
+ return SKEW_UNKNOWN;
40
+ if (got < expected)
41
+ return BROWSER_IS_OLDER;
42
+ if (got > expected)
43
+ return DAEMON_IS_OLDER;
44
+ return SKEW_UNKNOWN;
45
+ };