@reticlehq/server 2.4.0 → 2.5.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 (416) hide show
  1. package/SKILL.md +702 -0
  2. package/dist/bridge/auth-failure-reason.d.ts +19 -0
  3. package/dist/bridge/auth-failure-reason.js +29 -0
  4. package/dist/{bridge-security.d.ts → bridge/bridge-security.d.ts} +1 -1
  5. package/dist/{bridge-security.js → bridge/bridge-security.js} +1 -1
  6. package/dist/{bridge.d.ts → bridge/bridge.d.ts} +1 -1
  7. package/dist/{bridge.js → bridge/bridge.js} +63 -13
  8. package/dist/{bridge.test-harness.d.ts → bridge/bridge.test-harness.d.ts} +1 -11
  9. package/dist/{bridge.test-harness.js → bridge/bridge.test-harness.js} +9 -9
  10. package/dist/{pairing-token.js → bridge/pairing-token.js} +1 -1
  11. package/dist/capsule/capsule-store.d.ts +8 -3
  12. package/dist/capsule/capsule-store.js +7 -2
  13. package/dist/capsule/capsule.js +3 -3
  14. package/dist/capsule/causal-summary.d.ts +8 -0
  15. package/dist/capsule/causal-summary.js +22 -12
  16. package/dist/capsule/divergence.d.ts +9 -4
  17. package/dist/capsule/divergence.js +8 -8
  18. package/dist/capsule/predicate-to-links.js +11 -8
  19. package/dist/{cli-doctor.js → cli/cli-doctor.js} +9 -2
  20. package/dist/{cli-flow-commands.d.ts → cli/cli-flow-commands.d.ts} +2 -2
  21. package/dist/{cli-flow-commands.js → cli/cli-flow-commands.js} +15 -15
  22. package/dist/{cli-launch.d.ts → cli/cli-launch.d.ts} +32 -3
  23. package/dist/cli/cli-launch.js +171 -0
  24. package/dist/{cli-parse.d.ts → cli/cli-parse.d.ts} +2 -11
  25. package/dist/{cli-parse.js → cli/cli-parse.js} +113 -60
  26. package/dist/{cli-port.d.ts → cli/cli-port.d.ts} +12 -0
  27. package/dist/{cli-port.js → cli/cli-port.js} +9 -9
  28. package/dist/cli/cli-update-commands.d.ts +4 -0
  29. package/dist/cli/cli-update-commands.js +93 -0
  30. package/dist/{cli-verify.js → cli/cli-verify.js} +14 -14
  31. package/dist/{cloud-cli.js → cli/cloud-cli.js} +29 -24
  32. package/dist/cli.d.ts +2 -2
  33. package/dist/cli.js +174 -92
  34. package/dist/cloud/cloud-config.d.ts +0 -1
  35. package/dist/cloud/cloud-config.js +9 -9
  36. package/dist/cloud/cloud-sync.d.ts +1 -7
  37. package/dist/cloud/cloud-sync.js +12 -12
  38. package/dist/crawl/crawl-empty.d.ts +16 -0
  39. package/dist/crawl/crawl-empty.js +24 -0
  40. package/dist/crawl/crawl-tools.js +4 -1
  41. package/dist/crawl/crawl.js +26 -7
  42. package/dist/daemon/daemon-resilience.d.ts +49 -0
  43. package/dist/daemon/daemon-resilience.js +191 -0
  44. package/dist/daemon/daemon-usefulness.d.ts +61 -0
  45. package/dist/daemon/daemon-usefulness.js +62 -0
  46. package/dist/{daemon.d.ts → daemon/daemon.d.ts} +33 -8
  47. package/dist/{daemon.js → daemon/daemon.js} +80 -48
  48. package/dist/daemon/idle-grace.d.ts +32 -0
  49. package/dist/daemon/idle-grace.js +41 -0
  50. package/dist/{idle-shutdown.d.ts → daemon/idle-shutdown.d.ts} +13 -0
  51. package/dist/{idle-shutdown.js → daemon/idle-shutdown.js} +27 -4
  52. package/dist/domain/domain-model.js +2 -2
  53. package/dist/events/body-failures.js +8 -8
  54. package/dist/events/contradictions.d.ts +1 -1
  55. package/dist/events/contradictions.js +60 -10
  56. package/dist/events/echo-mismatch.js +10 -10
  57. package/dist/events/event-filters.js +6 -6
  58. package/dist/events/json-salvage.d.ts +2 -1
  59. package/dist/events/json-salvage.js +6 -6
  60. package/dist/events/observed-in-window.d.ts +12 -0
  61. package/dist/events/observed-in-window.js +21 -0
  62. package/dist/events/predicate-eval.d.ts +43 -20
  63. package/dist/events/predicate-eval.js +159 -55
  64. package/dist/events/predicate.d.ts +2 -2
  65. package/dist/events/predicate.js +112 -35
  66. package/dist/events/reconcile.js +5 -5
  67. package/dist/events/ring-buffer.js +1 -1
  68. package/dist/events/stale-response.js +6 -6
  69. package/dist/events/testid-near-miss.d.ts +22 -0
  70. package/dist/events/testid-near-miss.js +33 -0
  71. package/dist/events/unit-mismatch.js +8 -8
  72. package/dist/flows/affected-tools.js +19 -4
  73. package/dist/flows/affected.js +1 -1
  74. package/dist/flows/annotate-target.d.ts +1 -0
  75. package/dist/flows/annotate-target.js +20 -0
  76. package/dist/flows/annotate-tools.js +25 -3
  77. package/dist/flows/annotate.js +17 -4
  78. package/dist/flows/assertion-integrity.d.ts +2 -1
  79. package/dist/flows/assertion-tiers-store.d.ts +3 -66
  80. package/dist/flows/assertion-tiers-store.js +2 -2
  81. package/dist/flows/attributed-failure.d.ts +17 -0
  82. package/dist/flows/attributed-failure.js +20 -0
  83. package/dist/flows/buddy-status.d.ts +2 -1
  84. package/dist/flows/buddy-status.js +2 -2
  85. package/dist/flows/coverage.d.ts +3 -2
  86. package/dist/flows/coverage.js +2 -2
  87. package/dist/flows/decision.js +24 -9
  88. package/dist/flows/empty-flow.d.ts +6 -0
  89. package/dist/flows/empty-flow.js +24 -0
  90. package/dist/flows/flake-store.d.ts +0 -1
  91. package/dist/flows/flake-store.js +1 -1
  92. package/dist/flows/flake.d.ts +2 -1
  93. package/dist/flows/flake.js +2 -2
  94. package/dist/flows/flow-classify.js +12 -0
  95. package/dist/flows/flow-replay-run.js +4 -4
  96. package/dist/flows/flow-replay.d.ts +5 -7
  97. package/dist/flows/flow-replay.js +114 -47
  98. package/dist/flows/flow-report.js +1 -1
  99. package/dist/flows/flow-scope.d.ts +1 -6
  100. package/dist/flows/flow-step-runners.d.ts +26 -6
  101. package/dist/flows/flow-step-runners.js +157 -6
  102. package/dist/flows/flow-success.js +20 -15
  103. package/dist/flows/flow-tools.js +53 -7
  104. package/dist/flows/flows.js +15 -11
  105. package/dist/flows/gate.d.ts +4 -3
  106. package/dist/flows/gate.js +1 -1
  107. package/dist/flows/heal-run.d.ts +0 -8
  108. package/dist/flows/heal-run.js +5 -5
  109. package/dist/flows/heal.js +3 -3
  110. package/dist/flows/predicate-to-expect.d.ts +38 -0
  111. package/dist/flows/predicate-to-expect.js +95 -0
  112. package/dist/flows/replay.d.ts +16 -1
  113. package/dist/flows/replay.js +58 -22
  114. package/dist/flows/role-anchor-nearest.d.ts +6 -0
  115. package/dist/flows/role-anchor-nearest.js +45 -0
  116. package/dist/flows/server-verify.d.ts +1 -1
  117. package/dist/flows/server-verify.js +5 -5
  118. package/dist/flows/verify-change-tools.js +32 -10
  119. package/dist/flows/verify-change-verdict.d.ts +15 -0
  120. package/dist/flows/verify-change-verdict.js +25 -0
  121. package/dist/flows/watch-batcher.d.ts +3 -2
  122. package/dist/honesty/already-true.d.ts +2 -0
  123. package/dist/honesty/already-true.js +31 -0
  124. package/dist/honesty/blind-spots.d.ts +16 -3
  125. package/dist/honesty/blind-spots.js +24 -12
  126. package/dist/honesty/honesty.d.ts +3 -2
  127. package/dist/honesty/honesty.js +4 -4
  128. package/dist/honesty/uncaptured-bodies.js +1 -1
  129. package/dist/honesty/unread-outcome.js +2 -2
  130. package/dist/honesty/verified.d.ts +22 -3
  131. package/dist/honesty/verified.js +48 -8
  132. package/dist/http-server.js +14 -7
  133. package/dist/hunt/hunt-report.d.ts +2 -1
  134. package/dist/hunt/hunt-report.js +2 -2
  135. package/dist/index.d.ts +8 -6
  136. package/dist/index.js +32 -25
  137. package/dist/init/agent-rules.d.ts +2 -1
  138. package/dist/init/agent-rules.js +45 -7
  139. package/dist/init/app-choice.d.ts +20 -0
  140. package/dist/init/app-choice.js +25 -0
  141. package/dist/init/astro-host.d.ts +21 -0
  142. package/dist/init/astro-host.js +27 -0
  143. package/dist/init/astro-patch.d.ts +16 -0
  144. package/dist/init/astro-patch.js +240 -0
  145. package/dist/init/capabilities.js +4 -1
  146. package/dist/init/cra.d.ts +37 -0
  147. package/dist/init/cra.js +90 -0
  148. package/dist/init/cursor.js +39 -4
  149. package/dist/init/desktop-doctor.d.ts +3 -2
  150. package/dist/init/desktop-doctor.js +4 -4
  151. package/dist/init/detect.d.ts +3 -2
  152. package/dist/init/detect.js +16 -5
  153. package/dist/init/mcp-registered.d.ts +12 -0
  154. package/dist/init/mcp-registered.js +14 -0
  155. package/dist/init/mcp.d.ts +27 -2
  156. package/dist/init/mcp.js +40 -4
  157. package/dist/init/next-patch.d.ts +2 -4
  158. package/dist/init/next-patch.js +109 -19
  159. package/dist/init/node-io.js +23 -6
  160. package/dist/init/plan-framework.d.ts +16 -1
  161. package/dist/init/plan-framework.js +132 -8
  162. package/dist/init/plan.d.ts +73 -8
  163. package/dist/init/plan.js +133 -34
  164. package/dist/init/project-id.d.ts +5 -9
  165. package/dist/init/project-id.js +10 -22
  166. package/dist/init/run.d.ts +5 -0
  167. package/dist/init/run.js +210 -36
  168. package/dist/init/slash-command.d.ts +9 -0
  169. package/dist/init/slash-command.js +10 -1
  170. package/dist/init/snippets.d.ts +7 -10
  171. package/dist/init/snippets.js +44 -15
  172. package/dist/init/vite-config.js +1 -1
  173. package/dist/init/workspace-apps.d.ts +23 -0
  174. package/dist/init/workspace-apps.js +68 -0
  175. package/dist/input/driven-redaction.js +1 -1
  176. package/dist/input/network-detail.js +8 -8
  177. package/dist/input/network-mock-tools.js +2 -2
  178. package/dist/input/network-mock.js +4 -4
  179. package/dist/input/real-input.js +8 -3
  180. package/dist/input/scroll-find.js +2 -2
  181. package/dist/input/scroll-tools.d.ts +0 -5
  182. package/dist/input/scroll-tools.js +11 -4
  183. package/dist/input/viewport-tools.js +2 -2
  184. package/dist/journal/ambient-store.d.ts +0 -2
  185. package/dist/journal/ambient-store.js +1 -1
  186. package/dist/journal/ambient.js +1 -1
  187. package/dist/journal/attach-journal.d.ts +2 -1
  188. package/dist/journal/deviation-report.js +6 -6
  189. package/dist/journal/deviation-service.js +1 -1
  190. package/dist/journal/envelope-store.d.ts +0 -2
  191. package/dist/journal/envelope-store.js +1 -1
  192. package/dist/journal/envelope.d.ts +2 -3
  193. package/dist/journal/envelope.js +3 -3
  194. package/dist/journal/journal-query.js +1 -1
  195. package/dist/journal/journal-recorder.d.ts +2 -1
  196. package/dist/journal/journal-recorder.js +2 -2
  197. package/dist/journal/rollups.js +9 -9
  198. package/dist/journal/session-end.d.ts +2 -1
  199. package/dist/journal/session-journal.js +4 -4
  200. package/dist/license/license.d.ts +0 -14
  201. package/dist/license/license.js +8 -8
  202. package/dist/log.d.ts +10 -0
  203. package/dist/log.js +11 -1
  204. package/dist/mcp/mcp-is-error.d.ts +13 -0
  205. package/dist/mcp/mcp-is-error.js +18 -0
  206. package/dist/mcp/mcp-outage.d.ts +31 -0
  207. package/dist/mcp/mcp-outage.js +48 -0
  208. package/dist/mcp/mcp-proxy.d.ts +124 -0
  209. package/dist/mcp/mcp-proxy.js +697 -0
  210. package/dist/{mcp.d.ts → mcp/mcp.d.ts} +13 -10
  211. package/dist/{mcp.js → mcp/mcp.js} +110 -35
  212. package/dist/mcp/proxy-handshake.d.ts +3 -0
  213. package/dist/mcp/proxy-handshake.js +54 -0
  214. package/dist/mcp/proxy-lifecycle.d.ts +64 -0
  215. package/dist/mcp/proxy-lifecycle.js +70 -0
  216. package/dist/mcp/tool-catalog-cache.d.ts +29 -0
  217. package/dist/mcp/tool-catalog-cache.js +55 -0
  218. package/dist/oracles/propose-consequences.d.ts +2 -1
  219. package/dist/oracles/propose-consequences.js +16 -11
  220. package/dist/oracles/self-instrument.d.ts +2 -1
  221. package/dist/oracles/self-instrument.js +1 -1
  222. package/dist/phenomena/phenomena.js +3 -3
  223. package/dist/platform.d.ts +16 -0
  224. package/dist/platform.js +16 -0
  225. package/dist/pool/playwright-launcher.d.ts +15 -2
  226. package/dist/pool/playwright-launcher.js +21 -2
  227. package/dist/project/fs-port.js +1 -1
  228. package/dist/project/project-tools.js +2 -2
  229. package/dist/runs/build-verification-run.js +1 -1
  230. package/dist/runs/repair-prompt.d.ts +0 -6
  231. package/dist/runs/repair-prompt.js +3 -3
  232. package/dist/runs/risk-classify.js +1 -1
  233. package/dist/runs/run-diff.d.ts +0 -2
  234. package/dist/runs/run-diff.js +3 -3
  235. package/dist/runs/run-tools.js +2 -2
  236. package/dist/runs/verification-sync.js +2 -2
  237. package/dist/runs/verify-http.js +1 -1
  238. package/dist/runs/verify-server.js +1 -1
  239. package/dist/session/command-timeout.d.ts +11 -3
  240. package/dist/session/command-timeout.js +2 -2
  241. package/dist/session/control-envelope.d.ts +6 -0
  242. package/dist/session/control-envelope.js +7 -1
  243. package/dist/session/dev-server-probe.d.ts +13 -0
  244. package/dist/session/dev-server-probe.js +52 -0
  245. package/dist/session/last-act.d.ts +17 -3
  246. package/dist/session/last-act.js +16 -4
  247. package/dist/session/live-control-tools.js +7 -2
  248. package/dist/session/live-control.js +1 -1
  249. package/dist/session/looks-like-dev-server.d.ts +14 -0
  250. package/dist/session/looks-like-dev-server.js +25 -0
  251. package/dist/session/no-session-diagnosis.d.ts +38 -0
  252. package/dist/session/no-session-diagnosis.js +70 -0
  253. package/dist/session/no-session-watch.d.ts +19 -0
  254. package/dist/session/no-session-watch.js +61 -0
  255. package/dist/session/observed-state.d.ts +4 -0
  256. package/dist/session/observed-state.js +19 -1
  257. package/dist/session/output-budget.js +1 -1
  258. package/dist/session/session-health.d.ts +8 -1
  259. package/dist/session/session-health.js +8 -7
  260. package/dist/session/session-info.d.ts +26 -7
  261. package/dist/session/session-info.js +42 -1
  262. package/dist/session/session-lease.d.ts +0 -11
  263. package/dist/session/session-lease.js +1 -1
  264. package/dist/session/session-manager.d.ts +3 -0
  265. package/dist/session/session-manager.js +52 -9
  266. package/dist/session/session-readiness.d.ts +1 -1
  267. package/dist/session/session-readiness.js +6 -1
  268. package/dist/session/session-reaper.js +1 -1
  269. package/dist/session/session-reconnect.d.ts +32 -0
  270. package/dist/session/session-reconnect.js +42 -0
  271. package/dist/session/session.d.ts +14 -2
  272. package/dist/session/session.js +69 -26
  273. package/dist/session/state-select.d.ts +1 -1
  274. package/dist/session/state-select.js +1 -1
  275. package/dist/status-payload.d.ts +19 -0
  276. package/dist/status-payload.js +11 -0
  277. package/dist/telemetry/argument-shape.js +10 -1
  278. package/dist/telemetry/browser-mode.d.ts +27 -0
  279. package/dist/telemetry/browser-mode.js +33 -0
  280. package/dist/telemetry/bug-found.d.ts +17 -21
  281. package/dist/telemetry/bug-found.js +120 -8
  282. package/dist/telemetry/cli-telemetry.d.ts +2 -12
  283. package/dist/telemetry/cli-telemetry.js +27 -4
  284. package/dist/telemetry/daemon-telemetry.d.ts +19 -0
  285. package/dist/telemetry/daemon-telemetry.js +12 -2
  286. package/dist/telemetry/dev-repo.js +4 -4
  287. package/dist/telemetry/error-fingerprint.js +21 -2
  288. package/dist/telemetry/feedback-cli.d.ts +1 -1
  289. package/dist/telemetry/feedback-cli.js +4 -4
  290. package/dist/telemetry/feedback-context.d.ts +22 -0
  291. package/dist/telemetry/feedback-context.js +41 -1
  292. package/dist/telemetry/feedback-delivery.d.ts +20 -0
  293. package/dist/telemetry/feedback-delivery.js +30 -0
  294. package/dist/telemetry/feedback-outbox.d.ts +22 -0
  295. package/dist/telemetry/feedback-outbox.js +87 -0
  296. package/dist/telemetry/feedback.d.ts +22 -1
  297. package/dist/telemetry/feedback.js +67 -8
  298. package/dist/telemetry/git-facts.js +1 -1
  299. package/dist/telemetry/session-metrics.d.ts +2 -0
  300. package/dist/telemetry/session-metrics.js +68 -6
  301. package/dist/telemetry/telemetry.d.ts +27 -2
  302. package/dist/telemetry/telemetry.js +119 -29
  303. package/dist/telemetry/verification-of.d.ts +22 -0
  304. package/dist/telemetry/verification-of.js +84 -0
  305. package/dist/tools/act-capsule.js +2 -2
  306. package/dist/tools/act-danger.d.ts +12 -0
  307. package/dist/tools/act-danger.js +20 -3
  308. package/dist/tools/act-helpers.js +4 -4
  309. package/dist/tools/act-tools.d.ts +0 -8
  310. package/dist/tools/act-tools.js +84 -115
  311. package/dist/tools/act-view.js +7 -7
  312. package/dist/tools/alias-args.d.ts +17 -0
  313. package/dist/tools/alias-args.js +25 -0
  314. package/dist/tools/assert-grade.js +9 -9
  315. package/dist/tools/assert-verdict.d.ts +3 -1
  316. package/dist/tools/assert-verdict.js +8 -5
  317. package/dist/tools/browser-tools.js +30 -11
  318. package/dist/tools/contract-tools.js +1 -1
  319. package/dist/tools/coverage-identity.d.ts +34 -0
  320. package/dist/tools/coverage-identity.js +46 -0
  321. package/dist/tools/coverage-tools.js +10 -14
  322. package/dist/tools/dynamic-tools.d.ts +2 -1
  323. package/dist/tools/dynamic-tools.js +90 -18
  324. package/dist/tools/error-recovery.d.ts +20 -2
  325. package/dist/tools/error-recovery.js +179 -4
  326. package/dist/tools/feedback-tools.d.ts +12 -1
  327. package/dist/tools/feedback-tools.js +28 -5
  328. package/dist/tools/invoke-tool.d.ts +0 -6
  329. package/dist/tools/invoke-tool.js +57 -32
  330. package/dist/tools/lease-hint.d.ts +17 -0
  331. package/dist/tools/lease-hint.js +24 -0
  332. package/dist/tools/lease-tools.js +6 -5
  333. package/dist/tools/merge-tools.d.ts +10 -1
  334. package/dist/tools/merge-tools.js +9 -3
  335. package/dist/tools/merged-name-redirect.d.ts +13 -0
  336. package/dist/tools/merged-name-redirect.js +55 -0
  337. package/dist/tools/navigate-result.d.ts +17 -0
  338. package/dist/tools/navigate-result.js +31 -0
  339. package/dist/tools/observe-tools.js +54 -20
  340. package/dist/tools/observed-nothing.d.ts +25 -0
  341. package/dist/tools/observed-nothing.js +33 -0
  342. package/dist/tools/query-paginate.js +2 -2
  343. package/dist/tools/query-shape.d.ts +18 -0
  344. package/dist/tools/query-shape.js +39 -0
  345. package/dist/tools/read-tools.js +81 -20
  346. package/dist/tools/real-input-attempt.d.ts +28 -0
  347. package/dist/tools/real-input-attempt.js +88 -0
  348. package/dist/tools/reconcile-tools.js +7 -7
  349. package/dist/tools/reload-result.d.ts +14 -0
  350. package/dist/tools/reload-result.js +29 -0
  351. package/dist/tools/snapshot-delta.d.ts +4 -5
  352. package/dist/tools/snapshot-delta.js +0 -0
  353. package/dist/tools/tool-kit.d.ts +8 -0
  354. package/dist/tools/tool-surface.d.ts +83 -0
  355. package/dist/tools/tool-surface.js +204 -0
  356. package/dist/tools/tools-helpers.js +6 -6
  357. package/dist/tools/tools.d.ts +15 -0
  358. package/dist/tools/tools.js +53 -17
  359. package/dist/tools/unadvertised-help.d.ts +5 -0
  360. package/dist/tools/unadvertised-help.js +39 -0
  361. package/dist/trace.d.ts +69 -0
  362. package/dist/trace.js +150 -0
  363. package/dist/update/nudge-credit.d.ts +4 -0
  364. package/dist/update/nudge-credit.js +53 -0
  365. package/dist/update/sdk-sync.d.ts +21 -0
  366. package/dist/update/sdk-sync.js +42 -0
  367. package/dist/update/update-checker.js +3 -3
  368. package/dist/update/update-nudge.d.ts +63 -6
  369. package/dist/update/update-nudge.js +128 -15
  370. package/dist/update/updater.d.ts +1 -9
  371. package/dist/update/updater.js +13 -8
  372. package/dist/version/peer-announce.d.ts +4 -0
  373. package/dist/version/peer-announce.js +30 -0
  374. package/dist/{server-version.js → version/server-version.js} +5 -1
  375. package/dist/version/version-nudge.d.ts +28 -0
  376. package/dist/version/version-nudge.js +40 -0
  377. package/dist/version/version-skew.d.ts +66 -0
  378. package/dist/version/version-skew.js +71 -0
  379. package/dist/visual/visual-diff.js +2 -2
  380. package/dist/visual/visual-tools.js +4 -4
  381. package/docs/agent-cheatsheet.md +85 -0
  382. package/docs/architecture.md +113 -0
  383. package/docs/benchmarks.md +255 -0
  384. package/docs/debugging.md +102 -0
  385. package/docs/deploy-checks.md +69 -0
  386. package/docs/desktop-apps.md +256 -0
  387. package/docs/enterprise.md +62 -0
  388. package/docs/fixtures.md +28 -0
  389. package/docs/flows.md +166 -0
  390. package/docs/getting-started.md +469 -0
  391. package/docs/human-control.md +63 -0
  392. package/docs/integration-patterns.md +171 -0
  393. package/docs/local-registry.md +126 -0
  394. package/docs/mint.json +61 -0
  395. package/docs/multi-agent-testing.md +55 -0
  396. package/docs/platform-integration.md +122 -0
  397. package/docs/telemetry-contract.md +214 -0
  398. package/docs/telemetry.md +184 -0
  399. package/docs/testing.md +82 -0
  400. package/docs/token-efficiency.md +80 -0
  401. package/docs/usage.md +1046 -0
  402. package/package.json +8 -5
  403. package/dist/cli-launch.js +0 -112
  404. package/dist/daemon-resilience.d.ts +0 -22
  405. package/dist/daemon-resilience.js +0 -85
  406. package/dist/mcp-proxy.d.ts +0 -67
  407. package/dist/mcp-proxy.js +0 -337
  408. package/dist/tools/profiles.d.ts +0 -37
  409. package/dist/tools/profiles.js +0 -139
  410. /package/dist/{pairing-token.d.ts → bridge/pairing-token.d.ts} +0 -0
  411. /package/dist/{token-auth.d.ts → bridge/token-auth.d.ts} +0 -0
  412. /package/dist/{token-auth.js → bridge/token-auth.js} +0 -0
  413. /package/dist/{cli-doctor.d.ts → cli/cli-doctor.d.ts} +0 -0
  414. /package/dist/{cli-verify.d.ts → cli/cli-verify.d.ts} +0 -0
  415. /package/dist/{cloud-cli.d.ts → cli/cloud-cli.d.ts} +0 -0
  416. /package/dist/{server-version.d.ts → version/server-version.d.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticlehq/server",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Reticle bridge + MCP server. Hosts the browser WS endpoint and exposes MCP tools to the coding agent.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "@modelcontextprotocol/sdk": "^1.13.0",
23
23
  "ws": "^8.18.0",
24
24
  "zod": "^3.24.1",
25
- "@reticlehq/core": "2.4.0"
25
+ "@reticlehq/core": "2.5.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/pngjs": "^6.0.5",
@@ -45,7 +45,9 @@
45
45
  "files": [
46
46
  "dist",
47
47
  "README.md",
48
- "LICENSE-ENTERPRISE"
48
+ "LICENSE-ENTERPRISE",
49
+ "SKILL.md",
50
+ "docs"
49
51
  ],
50
52
  "keywords": [
51
53
  "mcp",
@@ -54,7 +56,7 @@
54
56
  "cli"
55
57
  ],
56
58
  "reticle": {
57
- "changelog": "Smart MCP proxy with auto-start daemon, auto-update, and rollback support. Agents can call reticle_version_info, reticle_apply_update, and reticle_rollback.",
59
+ "changelog": "An MCP server that stays up when the daemon dies, one tool surface instead of five profiles (`RETICLE_TOOL_PROFILE` retired for `RETICLE_ADVERTISE_ALL_TOOLS`), and tools that refuse instead of answering wrongly.",
58
60
  "breakingChanges": []
59
61
  },
60
62
  "publishConfig": {
@@ -68,6 +70,7 @@
68
70
  "dev": "tsc -b --watch",
69
71
  "typecheck": "tsc -b",
70
72
  "lint": "eslint src",
71
- "test:unit": "vitest run src --passWithNoTests"
73
+ "test:unit": "vitest run src --passWithNoTests",
74
+ "test:coverage": "vitest run src --passWithNoTests --coverage.enabled --coverage.provider=v8 --coverage.reporter=text-summary --coverage.include='src/**/*.ts' --coverage.exclude='src/**/*.test.ts'"
72
75
  }
73
76
  }
@@ -1,112 +0,0 @@
1
- import * as http from 'node:http';
2
- import { spawn } from 'node:child_process';
3
- import { isOpaqueOrigin, LOOPBACK_HOST, STATUS_PATH } from '@reticlehq/core';
4
- /**
5
- * Reduce the daemon's /status JSON to the compact view `reticle status` prints. Pure: narrows the
6
- * untrusted wire payload (never `any`) and tolerates a missing/partial body so a malformed response
7
- * degrades to "running, 0 sessions" instead of throwing.
8
- */
9
- export function summarizeStatus(payload) {
10
- if (typeof payload !== 'object' || payload === null)
11
- return { sessionCount: 0, sessions: [] };
12
- const obj = payload;
13
- const raw = Array.isArray(obj['sessions']) ? obj['sessions'] : [];
14
- const sessions = raw
15
- .map((s) => {
16
- if (typeof s !== 'object' || s === null)
17
- return null;
18
- const r = s;
19
- const sessionId = typeof r['sessionId'] === 'string' ? r['sessionId'] : '';
20
- if (sessionId === '')
21
- return null;
22
- return {
23
- sessionId,
24
- url: typeof r['url'] === 'string' ? r['url'] : '',
25
- throttled: r['throttled'] === true,
26
- stale: r['stale'] === true,
27
- pendingMarks: typeof r['pendingMarks'] === 'number' ? r['pendingMarks'] : 0,
28
- };
29
- })
30
- .filter((s) => s !== null);
31
- const sessionCount = typeof obj['sessionCount'] === 'number' ? obj['sessionCount'] : sessions.length;
32
- return { sessionCount, sessions };
33
- }
34
- /** How long the daemon /status probe waits before giving up — a local loopback call is near-instant. */
35
- const STATUS_PROBE_TIMEOUT_MS = 1000;
36
- /** GET the daemon's /status JSON. Resolves to the parsed body, or undefined on any failure. */
37
- export function fetchStatus(port) {
38
- return new Promise((resolve) => {
39
- const req = http.get({ host: LOOPBACK_HOST, port, path: STATUS_PATH, timeout: STATUS_PROBE_TIMEOUT_MS }, (res) => {
40
- let body = '';
41
- res.setEncoding('utf8');
42
- res.on('data', (chunk) => (body += chunk));
43
- res.on('end', () => {
44
- try {
45
- resolve(JSON.parse(body));
46
- }
47
- catch {
48
- resolve(undefined);
49
- }
50
- });
51
- });
52
- req.on('error', () => resolve(undefined));
53
- req.on('timeout', () => {
54
- req.destroy();
55
- resolve(undefined);
56
- });
57
- });
58
- }
59
- function sameOrigin(a, b) {
60
- try {
61
- const origin = new URL(a).origin;
62
- // Two opaque origins (desktop webviews, file:// docs) BOTH stringify to "null", so comparing them
63
- // would call every desktop app the same app and reuse the wrong session. Never match on opaque.
64
- if (isOpaqueOrigin(origin))
65
- return false;
66
- return origin === new URL(b).origin;
67
- }
68
- catch {
69
- return false;
70
- }
71
- }
72
- /**
73
- * Decide what `reticle open [url]` does, given the currently-connected tabs. Pure.
74
- * - no url + a tab connected → reuse it (the app is already open; don't spawn a duplicate).
75
- * - no url + nothing connected → ask for one.
76
- * - url + a tab already on that origin → reuse it (idempotent — re-running never piles up tabs).
77
- * - url + no matching tab → open it.
78
- */
79
- export function decideOpen(sessions, url) {
80
- if (url === undefined) {
81
- const first = sessions[0];
82
- return first !== undefined ? { action: 'reuse', url: first.url } : { action: 'need-url' };
83
- }
84
- const match = sessions.find((s) => s.url === url || sameOrigin(s.url, url));
85
- return match !== undefined ? { action: 'reuse', url: match.url } : { action: 'open', url };
86
- }
87
- /**
88
- * Percent-encode the cmd.exe metacharacters that `start` re-parses so a URL can't break out into
89
- * command execution on Windows (`?next=x&calc` → command chaining). `%` is left untouched so an
90
- * already-encoded URL isn't double-encoded; the browser decodes the escapes back to the real URL.
91
- */
92
- function encodeForWindowsStart(url) {
93
- return url.replace(/[&^|<>()"'!]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`);
94
- }
95
- /** The OS command that opens a URL in the default browser, per platform. Pure — unit-tested. */
96
- export function openCommand(url, platform) {
97
- if (platform === 'darwin')
98
- return { cmd: 'open', args: [url] };
99
- if (platform === 'win32') {
100
- return { cmd: 'cmd', args: ['/c', 'start', '', encodeForWindowsStart(url)] };
101
- }
102
- return { cmd: 'xdg-open', args: [url] };
103
- }
104
- /** Launch the default browser at `url` (detached). The spawn is injected so tests stay hermetic. */
105
- export function openInBrowser(url, platform = process.platform, run = defaultRun) {
106
- const { cmd, args } = openCommand(url, platform);
107
- run(cmd, args);
108
- }
109
- function defaultRun(cmd, args) {
110
- const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
111
- child.unref();
112
- }
@@ -1,22 +0,0 @@
1
- /**
2
- * Process-level resilience for the long-running daemon. The daemon serves many agents at once, so a
3
- * single stray error must not take the whole fleet down:
4
- *
5
- * - unhandledRejection (a fire-and-forget promise nobody awaited — the common case in async WS/pool
6
- * code) → LOG and keep running. One agent's async slip-up can't crash the daemon for everyone.
7
- * - uncaughtException (a synchronous throw escaped all try/catch) → the process state is undefined
8
- * per Node's guidance, so LOG a clear reason and exit cleanly; the next `reticle mcp` respawns a fresh
9
- * daemon, which beats crashing silently or limping along corrupt.
10
- */
11
- export interface ProcessLike {
12
- on(event: string, listener: (arg: unknown) => void): unknown;
13
- }
14
- type LogFn = (event: string, data: Record<string, unknown>) => void;
15
- /** The two ways a failure reaches the top of the process. Named so the analytics can tell them apart. */
16
- export declare const CrashKind: {
17
- readonly UNHANDLED_REJECTION: "unhandled_rejection";
18
- readonly UNCAUGHT_EXCEPTION: "uncaught_exception";
19
- };
20
- export type CrashKind = (typeof CrashKind)[keyof typeof CrashKind];
21
- export declare function installDaemonResilience(proc: ProcessLike, log: LogFn, onFatal: () => void): void;
22
- export {};
@@ -1,85 +0,0 @@
1
- /**
2
- * Process-level resilience for the long-running daemon. The daemon serves many agents at once, so a
3
- * single stray error must not take the whole fleet down:
4
- *
5
- * - unhandledRejection (a fire-and-forget promise nobody awaited — the common case in async WS/pool
6
- * code) → LOG and keep running. One agent's async slip-up can't crash the daemon for everyone.
7
- * - uncaughtException (a synchronous throw escaped all try/catch) → the process state is undefined
8
- * per Node's guidance, so LOG a clear reason and exit cleanly; the next `reticle mcp` respawns a fresh
9
- * daemon, which beats crashing silently or limping along corrupt.
10
- */
11
- import { TelemetryActor, TelemetryEventKind } from '@reticlehq/core';
12
- import { errorSkeleton, errorTypeOf, fingerprintCrash, MAX_REPORTED_FRAMES, reticleFrames, } from './telemetry/error-fingerprint.js';
13
- import { getSessionMetrics } from './telemetry/session-metrics.js';
14
- import { machineSnapshot } from './telemetry/machine-snapshot.js';
15
- import { getTelemetry } from './telemetry/telemetry.js';
16
- function describe(value) {
17
- return value instanceof Error ? value.message : String(value);
18
- }
19
- /** The two ways a failure reaches the top of the process. Named so the analytics can tell them apart. */
20
- export const CrashKind = {
21
- UNHANDLED_REJECTION: 'unhandled_rejection',
22
- UNCAUGHT_EXCEPTION: 'uncaught_exception',
23
- };
24
- /**
25
- * Report a crash with enough detail to actually diagnose it.
26
- *
27
- * This is the only place in the product that learns about a crash at all. An uncaught exception in a
28
- * long-running daemon takes down every agent attached to it, and the only other record is a line in a
29
- * local log nobody sends us.
30
- *
31
- * The first version sent a hash and nothing else, which made crashes rankable and undiagnosable — you
32
- * could see that forty machines hit `a3f2c1d8e9b0` and had no way to learn what that was. So it now
33
- * carries the four things an RCA needs: WHERE (our own frames, with function names and line numbers),
34
- * WHAT (the message with every variable part stripped), WHEN (the tool in flight), and WHY-ish (the
35
- * agent's approach run before it). All of it is our code and our vocabulary; the user's stack frames,
36
- * their paths, and the contents of their message are removed before any of it is built.
37
- *
38
- * Best-effort and wrapped: reporting a crash must never be the reason a crash gets worse.
39
- */
40
- function reportCrash(kind, value) {
41
- try {
42
- const errorType = errorTypeOf(value);
43
- const stack = value instanceof Error ? (value.stack ?? '') : '';
44
- const message = describe(value);
45
- const { breadcrumb, inFlight } = getSessionMetrics().trail;
46
- const machine = machineSnapshot();
47
- void getTelemetry().emit(TelemetryEventKind.RUNTIME_CRASHED, {
48
- // A crash is ALWAYS reached through something the agent asked for — the daemon does nothing on
49
- // its own — so attributing it to the agent is accurate rather than a guess.
50
- actor: TelemetryActor.AGENT,
51
- crash: {
52
- kind,
53
- errorType,
54
- fingerprint: fingerprintCrash(errorType, stack, message),
55
- // The skeleton, not the message: every quoted string, path, URL, id and number is replaced
56
- // by `*` first. This is what makes the fingerprint mean something to a human reading a chart.
57
- message: errorSkeleton(message).slice(0, 300),
58
- // Our own frames, our own published code. The file, the line, and the function — which is the
59
- // whole of "where did it break". Frames from the user's app never survive this.
60
- frames: reticleFrames(stack).slice(0, MAX_REPORTED_FRAMES),
61
- ...(inFlight !== undefined ? { tool: inFlight } : {}),
62
- ...(breadcrumb.length > 0 ? { breadcrumb } : {}),
63
- // Crashes cluster hard by runtime version and architecture, and we were recording neither.
64
- nodeVersion: process.versions.node,
65
- arch: process.arch,
66
- // "Out of memory" and "our bug" look identical in a stack trace. This is what tells them apart.
67
- ...(machine !== undefined ? { machine } : {}),
68
- },
69
- });
70
- }
71
- catch {
72
- /* a crash report must never be the reason a crash gets worse */
73
- }
74
- }
75
- export function installDaemonResilience(proc, log, onFatal) {
76
- proc.on('unhandledRejection', (reason) => {
77
- log('reticle_daemon_unhandled_rejection', { reason: describe(reason) });
78
- reportCrash(CrashKind.UNHANDLED_REJECTION, reason);
79
- });
80
- proc.on('uncaughtException', (err) => {
81
- log('reticle_daemon_uncaught_exception', { error: describe(err) });
82
- reportCrash(CrashKind.UNCAUGHT_EXCEPTION, err);
83
- onFatal();
84
- });
85
- }
@@ -1,67 +0,0 @@
1
- /**
2
- * How many consecutive failed reconnects before the proxy gives up and exits. At the capped backoff
3
- * this is a few minutes — long enough to ride out a daemon restart, short enough that a genuinely
4
- * dead daemon lets the agent host respawn the proxy (which spawns a fresh daemon) instead of hanging.
5
- */
6
- export declare const MAX_RECONNECT_ATTEMPTS = 60;
7
- export declare function reconnectDelayMs(attempt: number): number;
8
- /** JSON-RPC id the proxy uses for its own replayed `initialize` — never one the client could send. */
9
- export declare const RECONNECT_INITIALIZE_ID = "__reticle_proxy_reinit";
10
- /** The proxy's own log file, so a silent drop leaves a readable trace the agent can go read. */
11
- export declare function proxyLogPath(): string;
12
- /**
13
- * Makes a reconnect invisible to the client.
14
- *
15
- * The daemon builds a FRESH `McpServer` per SSE connection, so a reconnected session has never seen
16
- * the client's `initialize` — every subsequent `tools/call` would hit an uninitialized server. The
17
- * proxy therefore remembers the handshake the client sent once and replays it into each new session.
18
- * The replay is re-issued under a reserved id so the daemon's response to it can be dropped rather
19
- * than reaching the client's stdout, where a duplicate JSON-RPC id would be a protocol violation.
20
- */
21
- export declare class HandshakeReplay {
22
- #private;
23
- /** Record one outbound client line. Cheap enough to call for every stdin message. */
24
- observeOutbound(line: string): void;
25
- /** Lines to POST into a freshly-established session, before any queued client traffic. */
26
- replayLines(): string[];
27
- /** True when this inbound daemon line is the echo of our replayed handshake and must not be forwarded. */
28
- shouldSuppressInbound(line: string): boolean;
29
- }
30
- /** One parsed Server-Sent-Events frame: the event name (defaulted to "message") and its data. */
31
- export interface SseFrame {
32
- event: string;
33
- data: string;
34
- }
35
- /**
36
- * Incremental SSE frame parser for the MCP front door.
37
- *
38
- * A single SSE field can split across TCP reads (`da` in one chunk, `ta: {...}` in the next), and a
39
- * server may send CRLF or bare CR line endings — so the framing is stateful and edge-case-prone, yet it
40
- * carried every MCP message the agent sends and was only ever exercised end-to-end. Pulled out of the
41
- * socket handler as a pure, chunk-fed parser so those boundaries are unit-testable: feed raw chunks,
42
- * get back each complete frame (a blank line terminates a frame; `event:` names it, `data:` lines
43
- * accumulate newline-joined; `id:`/`retry:`/comments are ignored — not needed for the bridge).
44
- */
45
- export declare class SseFrameParser {
46
- #private;
47
- push(chunk: string): SseFrame[];
48
- }
49
- /**
50
- * Returns true if something is already listening on the reticle port.
51
- * Uses a plain TCP probe so we don't create a side-effectful SSE session
52
- * inside the daemon just to check reachability.
53
- */
54
- export declare function probeDaemon(port: number): Promise<boolean>;
55
- /** Poll until the daemon's HTTP port accepts connections or the deadline is reached. */
56
- export declare function waitForDaemon(port: number): Promise<void>;
57
- export declare function buildSessionUrl(rawData: string, port: number): string;
58
- /**
59
- * Bridge stdio ↔ SSE: connects to the running daemon's MCP endpoint and forwards
60
- * Claude Code's stdin/stdout JSON-RPC through it. Never resolves — runs until stdin closes.
61
- *
62
- * A dropped SSE stream used to exit the process, which is what made the agent's `reticle_*` tools
63
- * vanish mid-session with no message and no way for the agent to get them back — only a human running
64
- * `/mcp` could. The daemon demonstrably stays up across these drops, so the stream ending is a
65
- * transport event, not a shutdown: reconnect to it and replay the handshake instead of dying.
66
- */
67
- export declare function startMcpProxy(port: number): Promise<never>;
package/dist/mcp-proxy.js DELETED
@@ -1,337 +0,0 @@
1
- import * as http from 'node:http';
2
- import * as net from 'node:net';
3
- import { appendFileSync, mkdirSync } from 'node:fs';
4
- import { homedir } from 'node:os';
5
- import { join } from 'node:path';
6
- import { LOOPBACK_HOST, MCP_SSE_PATH, ReticleDir } from '@reticlehq/core';
7
- import { log } from './log.js';
8
- const DEFAULT_DAEMON_READY_TIMEOUT_MS = 10_000;
9
- /**
10
- * How long to wait for the spawned daemon's port to accept connections before giving up. The default
11
- * suits a normal machine; a slow CI/VM (heavy headless-browser launch) can raise it via the
12
- * RETICLE_DAEMON_READY_TIMEOUT_MS env var. Invalid/absent values fall back to the default.
13
- */
14
- const envDaemonReadyTimeoutMs = Number(process.env['RETICLE_DAEMON_READY_TIMEOUT_MS']);
15
- const DAEMON_READY_TIMEOUT_MS = Number.isFinite(envDaemonReadyTimeoutMs) && envDaemonReadyTimeoutMs > 0
16
- ? envDaemonReadyTimeoutMs
17
- : DEFAULT_DAEMON_READY_TIMEOUT_MS;
18
- const DAEMON_POLL_INTERVAL_MS = 100;
19
- function delay(ms) {
20
- return new Promise((resolve) => setTimeout(resolve, ms));
21
- }
22
- /** Reconnect backoff: linear, capped, so a briefly-restarting daemon is picked up fast. */
23
- const RECONNECT_BASE_MS = 250;
24
- const RECONNECT_CAP_MS = 5_000;
25
- /**
26
- * How many consecutive failed reconnects before the proxy gives up and exits. At the capped backoff
27
- * this is a few minutes — long enough to ride out a daemon restart, short enough that a genuinely
28
- * dead daemon lets the agent host respawn the proxy (which spawns a fresh daemon) instead of hanging.
29
- */
30
- export const MAX_RECONNECT_ATTEMPTS = 60;
31
- export function reconnectDelayMs(attempt) {
32
- return Math.min(RECONNECT_BASE_MS * attempt, RECONNECT_CAP_MS);
33
- }
34
- /** JSON-RPC id the proxy uses for its own replayed `initialize` — never one the client could send. */
35
- export const RECONNECT_INITIALIZE_ID = '__reticle_proxy_reinit';
36
- /** The proxy's own log file, so a silent drop leaves a readable trace the agent can go read. */
37
- export function proxyLogPath() {
38
- return join(homedir(), ReticleDir.ROOT, 'mcp-proxy.log');
39
- }
40
- /**
41
- * Log to stderr (which the agent host usually swallows) AND to ~/.reticle/mcp-proxy.log, which it
42
- * does not. A dropped MCP connection is invisible from the agent's side — no message, no exit code —
43
- * so the one thing that makes it diagnosable at all is a file somebody can read afterwards.
44
- */
45
- function proxyLog(event, fields = {}) {
46
- log(event, fields);
47
- try {
48
- const dir = join(homedir(), ReticleDir.ROOT);
49
- mkdirSync(dir, { recursive: true });
50
- appendFileSync(proxyLogPath(), `${JSON.stringify({ event, ...fields })}\n`, 'utf8');
51
- }
52
- catch {
53
- // Logging must never be the thing that kills the proxy.
54
- }
55
- }
56
- function parseJsonRpc(line) {
57
- try {
58
- const parsed = JSON.parse(line);
59
- return typeof parsed === 'object' && parsed !== null ? parsed : null;
60
- }
61
- catch {
62
- return null;
63
- }
64
- }
65
- const INITIALIZE_METHOD = 'initialize';
66
- const INITIALIZED_METHOD = 'notifications/initialized';
67
- /**
68
- * Makes a reconnect invisible to the client.
69
- *
70
- * The daemon builds a FRESH `McpServer` per SSE connection, so a reconnected session has never seen
71
- * the client's `initialize` — every subsequent `tools/call` would hit an uninitialized server. The
72
- * proxy therefore remembers the handshake the client sent once and replays it into each new session.
73
- * The replay is re-issued under a reserved id so the daemon's response to it can be dropped rather
74
- * than reaching the client's stdout, where a duplicate JSON-RPC id would be a protocol violation.
75
- */
76
- export class HandshakeReplay {
77
- #initialize = null;
78
- #initialized = null;
79
- #pendingReplayId = null;
80
- /** Record one outbound client line. Cheap enough to call for every stdin message. */
81
- observeOutbound(line) {
82
- const msg = parseJsonRpc(line);
83
- if (msg === null)
84
- return;
85
- // The first initialize is the session's identity; a client re-issuing one does not change it.
86
- if (msg.method === INITIALIZE_METHOD && this.#initialize === null)
87
- this.#initialize = line;
88
- else if (msg.method === INITIALIZED_METHOD)
89
- this.#initialized = line;
90
- }
91
- /** Lines to POST into a freshly-established session, before any queued client traffic. */
92
- replayLines() {
93
- if (this.#initialize === null)
94
- return [];
95
- const msg = parseJsonRpc(this.#initialize);
96
- if (msg === null)
97
- return [];
98
- this.#pendingReplayId = RECONNECT_INITIALIZE_ID;
99
- const reinit = { ...msg, id: RECONNECT_INITIALIZE_ID };
100
- return this.#initialized === null
101
- ? [JSON.stringify(reinit)]
102
- : [JSON.stringify(reinit), this.#initialized];
103
- }
104
- /** True when this inbound daemon line is the echo of our replayed handshake and must not be forwarded. */
105
- shouldSuppressInbound(line) {
106
- if (this.#pendingReplayId === null)
107
- return false;
108
- const msg = parseJsonRpc(line);
109
- if (msg === null || msg.id !== this.#pendingReplayId)
110
- return false;
111
- this.#pendingReplayId = null; // one response per replay — anything later is not ours to swallow
112
- return true;
113
- }
114
- }
115
- /**
116
- * Incremental SSE frame parser for the MCP front door.
117
- *
118
- * A single SSE field can split across TCP reads (`da` in one chunk, `ta: {...}` in the next), and a
119
- * server may send CRLF or bare CR line endings — so the framing is stateful and edge-case-prone, yet it
120
- * carried every MCP message the agent sends and was only ever exercised end-to-end. Pulled out of the
121
- * socket handler as a pure, chunk-fed parser so those boundaries are unit-testable: feed raw chunks,
122
- * get back each complete frame (a blank line terminates a frame; `event:` names it, `data:` lines
123
- * accumulate newline-joined; `id:`/`retry:`/comments are ignored — not needed for the bridge).
124
- */
125
- export class SseFrameParser {
126
- #buffer = '';
127
- #event = '';
128
- #data = '';
129
- push(chunk) {
130
- this.#buffer += chunk;
131
- // Normalise CRLF/CR so the splitter only handles \n, then hold the trailing partial line for the
132
- // next chunk (it may complete later).
133
- const normalised = this.#buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
134
- const lines = normalised.split('\n');
135
- this.#buffer = lines.pop() ?? '';
136
- const frames = [];
137
- for (const line of lines) {
138
- if (line === '') {
139
- if (this.#data !== '') {
140
- frames.push({ event: this.#event !== '' ? this.#event : 'message', data: this.#data });
141
- }
142
- this.#event = '';
143
- this.#data = '';
144
- }
145
- else if (line.startsWith('event:')) {
146
- this.#event = line.slice(6).trim();
147
- }
148
- else if (line.startsWith('data:')) {
149
- const val = line.slice(5).trim();
150
- this.#data = this.#data !== '' ? `${this.#data}\n${val}` : val;
151
- }
152
- }
153
- return frames;
154
- }
155
- }
156
- /**
157
- * Returns true if something is already listening on the reticle port.
158
- * Uses a plain TCP probe so we don't create a side-effectful SSE session
159
- * inside the daemon just to check reachability.
160
- */
161
- export function probeDaemon(port) {
162
- return new Promise((resolve) => {
163
- const socket = new net.Socket();
164
- socket.setTimeout(500);
165
- socket.on('connect', () => {
166
- socket.destroy();
167
- resolve(true);
168
- });
169
- socket.on('error', () => resolve(false));
170
- socket.on('timeout', () => {
171
- socket.destroy();
172
- resolve(false);
173
- });
174
- socket.connect(port, LOOPBACK_HOST);
175
- });
176
- }
177
- /** Poll until the daemon's HTTP port accepts connections or the deadline is reached. */
178
- export async function waitForDaemon(port) {
179
- const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
180
- while (Date.now() < deadline) {
181
- const reachable = await probeDaemon(port);
182
- if (reachable)
183
- return;
184
- await delay(DAEMON_POLL_INTERVAL_MS);
185
- }
186
- throw new Error(`reticle daemon did not become ready on port ${port} within ${DAEMON_READY_TIMEOUT_MS}ms`);
187
- }
188
- function postToSession(url, body) {
189
- return new Promise((resolve) => {
190
- const parsed = new URL(url);
191
- const bodyBuf = Buffer.from(body, 'utf8');
192
- const options = {
193
- host: parsed.hostname,
194
- port: parsed.port !== '' ? parseInt(parsed.port, 10) : 80,
195
- path: `${parsed.pathname}${parsed.search}`,
196
- method: 'POST',
197
- headers: {
198
- 'Content-Type': 'application/json',
199
- 'Content-Length': bodyBuf.byteLength,
200
- },
201
- };
202
- const req = http.request(options, (res) => {
203
- const status = res.statusCode ?? 0;
204
- if (status < 200 || status >= 300) {
205
- // A non-2xx from the daemon MCP endpoint used to be swallowed, hanging the JSON-RPC call
206
- // client-side with no diagnostic. Surface it (the forward is still fire-and-forget).
207
- log('reticle_mcp_proxy_post_non2xx', { status, path: options.path });
208
- }
209
- res.resume(); // drain so the socket is reused
210
- resolve();
211
- });
212
- req.on('error', (err) => {
213
- log('reticle_mcp_proxy_post_error', { error: err.message });
214
- resolve();
215
- });
216
- req.write(bodyBuf);
217
- req.end();
218
- });
219
- }
220
- export function buildSessionUrl(rawData, port) {
221
- return rawData.startsWith('/') ? `http://${LOOPBACK_HOST}:${port}${rawData}` : rawData;
222
- }
223
- /**
224
- * Bridge stdio ↔ SSE: connects to the running daemon's MCP endpoint and forwards
225
- * Claude Code's stdin/stdout JSON-RPC through it. Never resolves — runs until stdin closes.
226
- *
227
- * A dropped SSE stream used to exit the process, which is what made the agent's `reticle_*` tools
228
- * vanish mid-session with no message and no way for the agent to get them back — only a human running
229
- * `/mcp` could. The daemon demonstrably stays up across these drops, so the stream ending is a
230
- * transport event, not a shutdown: reconnect to it and replay the handshake instead of dying.
231
- */
232
- export function startMcpProxy(port) {
233
- return new Promise((_resolve, reject) => {
234
- let postUrl = null;
235
- const stdinQueue = [];
236
- const replay = new HandshakeReplay();
237
- let stopped = false;
238
- let attempts = 0;
239
- function onSseEvent(event, data, p) {
240
- if (event === 'endpoint') {
241
- const url = buildSessionUrl(data, p);
242
- postUrl = url;
243
- // The new session's McpServer has never seen the client's initialize — replay it first, then
244
- // flush whatever the client sent while we were reconnecting.
245
- for (const line of replay.replayLines())
246
- void postToSession(url, line);
247
- for (const queued of stdinQueue.splice(0))
248
- void postToSession(url, queued);
249
- return;
250
- }
251
- if (event === 'message' && !replay.shouldSuppressInbound(data)) {
252
- process.stdout.write(`${data}\n`);
253
- }
254
- }
255
- function scheduleReconnect(reason, detail) {
256
- if (stopped)
257
- return;
258
- postUrl = null;
259
- attempts++;
260
- if (attempts > MAX_RECONNECT_ATTEMPTS) {
261
- proxyLog('reticle_mcp_proxy_gave_up', {
262
- port,
263
- reason,
264
- attempts,
265
- ...(detail !== undefined ? { detail } : {}),
266
- });
267
- process.exit(1);
268
- }
269
- const wait = reconnectDelayMs(attempts);
270
- proxyLog('reticle_mcp_proxy_reconnecting', {
271
- port,
272
- reason,
273
- attempt: attempts,
274
- retryInMs: wait,
275
- ...(detail !== undefined ? { detail } : {}),
276
- });
277
- setTimeout(() => connect(false), wait).unref();
278
- }
279
- function connect(first) {
280
- const req = http.get({ host: LOOPBACK_HOST, port, path: MCP_SSE_PATH }, (res) => {
281
- attempts = 0; // a stream we actually established resets the budget
282
- if (!first)
283
- proxyLog('reticle_mcp_proxy_reconnected', { port });
284
- res.setEncoding('utf8');
285
- // `end` and `error` can both fire on one response; only the first should drive a reconnect.
286
- let settled = false;
287
- const drop = (reason, detail) => {
288
- if (settled)
289
- return;
290
- settled = true;
291
- scheduleReconnect(reason, detail);
292
- };
293
- const sse = new SseFrameParser();
294
- res.on('data', (chunk) => {
295
- for (const frame of sse.push(chunk))
296
- onSseEvent(frame.event, frame.data, port);
297
- });
298
- res.on('end', () => drop('sse_ended'));
299
- res.on('error', (err) => drop('sse_error', err.message));
300
- // A socket dying under us (daemon killed, network stack reset) emits NEITHER `end` nor
301
- // `error` — only `aborted`/`close`. Listening for just the first two is why the proxy sat
302
- // there afterwards holding a dead stream. `settled` keeps the clean case single-fire.
303
- res.on('aborted', () => drop('sse_aborted'));
304
- res.on('close', () => drop('sse_closed'));
305
- });
306
- // The very first connect failing means there is no daemon to talk to — that is a startup
307
- // failure the caller handles. Later ones are just the daemon bouncing: keep retrying.
308
- req.on('error', (err) => (first ? reject(err) : scheduleReconnect('connect_error', err.message)));
309
- }
310
- connect(true);
311
- // ── stdin reader ─────────────────────────────────────────────────────────
312
- process.stdin.setEncoding('utf8');
313
- let stdinBuffer = '';
314
- process.stdin.on('data', (chunk) => {
315
- stdinBuffer += chunk;
316
- const lines = stdinBuffer.split('\n');
317
- stdinBuffer = lines.pop() ?? '';
318
- for (const line of lines) {
319
- const trimmed = line.trim();
320
- if (trimmed === '')
321
- continue;
322
- replay.observeOutbound(trimmed);
323
- if (postUrl === null) {
324
- stdinQueue.push(trimmed);
325
- }
326
- else {
327
- void postToSession(postUrl, trimmed);
328
- }
329
- }
330
- });
331
- // The client going away is the one clean shutdown — stop reconnecting and exit.
332
- process.stdin.on('end', () => {
333
- stopped = true;
334
- process.exit(0);
335
- });
336
- });
337
- }