@reticlehq/server 2.3.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 (424) 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} +105 -15
  8. package/dist/{bridge.test-harness.d.ts → bridge/bridge.test-harness.d.ts} +6 -11
  9. package/dist/{bridge.test-harness.js → bridge/bridge.test-harness.js} +21 -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/cli-parse.d.ts +143 -0
  25. package/dist/{cli-parse.js → cli/cli-parse.js} +146 -65
  26. package/dist/{cli-port.d.ts → cli/cli-port.d.ts} +15 -0
  27. package/dist/{cli-port.js → cli/cli-port.js} +29 -8
  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 +182 -93
  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.d.ts +0 -6
  88. package/dist/flows/decision.js +64 -8
  89. package/dist/flows/empty-flow.d.ts +6 -0
  90. package/dist/flows/empty-flow.js +24 -0
  91. package/dist/flows/flake-store.d.ts +0 -1
  92. package/dist/flows/flake-store.js +1 -1
  93. package/dist/flows/flake.d.ts +2 -1
  94. package/dist/flows/flake.js +2 -2
  95. package/dist/flows/flow-classify.js +12 -0
  96. package/dist/flows/flow-replay-run.js +4 -4
  97. package/dist/flows/flow-replay.d.ts +5 -7
  98. package/dist/flows/flow-replay.js +114 -47
  99. package/dist/flows/flow-report.js +1 -1
  100. package/dist/flows/flow-scope.d.ts +1 -6
  101. package/dist/flows/flow-step-runners.d.ts +26 -6
  102. package/dist/flows/flow-step-runners.js +157 -6
  103. package/dist/flows/flow-success.js +20 -15
  104. package/dist/flows/flow-tools.js +67 -13
  105. package/dist/flows/flows.js +15 -11
  106. package/dist/flows/gate.d.ts +4 -3
  107. package/dist/flows/gate.js +1 -1
  108. package/dist/flows/heal-run.d.ts +0 -8
  109. package/dist/flows/heal-run.js +5 -5
  110. package/dist/flows/heal.js +3 -3
  111. package/dist/flows/predicate-to-expect.d.ts +38 -0
  112. package/dist/flows/predicate-to-expect.js +95 -0
  113. package/dist/flows/replay.d.ts +16 -1
  114. package/dist/flows/replay.js +58 -22
  115. package/dist/flows/role-anchor-nearest.d.ts +6 -0
  116. package/dist/flows/role-anchor-nearest.js +45 -0
  117. package/dist/flows/server-verify.d.ts +1 -1
  118. package/dist/flows/server-verify.js +5 -5
  119. package/dist/flows/verify-change-tools.js +32 -10
  120. package/dist/flows/verify-change-verdict.d.ts +15 -0
  121. package/dist/flows/verify-change-verdict.js +25 -0
  122. package/dist/flows/watch-batcher.d.ts +3 -2
  123. package/dist/honesty/already-true.d.ts +2 -0
  124. package/dist/honesty/already-true.js +31 -0
  125. package/dist/honesty/blind-spots.d.ts +16 -3
  126. package/dist/honesty/blind-spots.js +24 -12
  127. package/dist/honesty/honesty.d.ts +3 -2
  128. package/dist/honesty/honesty.js +4 -4
  129. package/dist/honesty/uncaptured-bodies.js +1 -1
  130. package/dist/honesty/unread-outcome.js +2 -2
  131. package/dist/honesty/verified.d.ts +22 -3
  132. package/dist/honesty/verified.js +48 -8
  133. package/dist/http-server.js +14 -7
  134. package/dist/hunt/hunt-report.d.ts +2 -1
  135. package/dist/hunt/hunt-report.js +2 -2
  136. package/dist/index.d.ts +8 -6
  137. package/dist/index.js +32 -25
  138. package/dist/init/agent-rules.d.ts +2 -1
  139. package/dist/init/agent-rules.js +45 -7
  140. package/dist/init/app-choice.d.ts +20 -0
  141. package/dist/init/app-choice.js +25 -0
  142. package/dist/init/astro-host.d.ts +21 -0
  143. package/dist/init/astro-host.js +27 -0
  144. package/dist/init/astro-patch.d.ts +16 -0
  145. package/dist/init/astro-patch.js +240 -0
  146. package/dist/init/capabilities.d.ts +17 -0
  147. package/dist/init/capabilities.js +54 -0
  148. package/dist/init/cra.d.ts +37 -0
  149. package/dist/init/cra.js +90 -0
  150. package/dist/init/cursor.js +39 -4
  151. package/dist/init/desktop-doctor.d.ts +3 -2
  152. package/dist/init/desktop-doctor.js +4 -4
  153. package/dist/init/detect.d.ts +26 -0
  154. package/dist/init/detect.js +78 -5
  155. package/dist/init/mcp-registered.d.ts +12 -0
  156. package/dist/init/mcp-registered.js +14 -0
  157. package/dist/init/mcp.d.ts +27 -2
  158. package/dist/init/mcp.js +40 -4
  159. package/dist/init/next-patch.d.ts +39 -0
  160. package/dist/init/next-patch.js +196 -0
  161. package/dist/init/node-io.js +48 -6
  162. package/dist/init/patch-kind.d.ts +20 -0
  163. package/dist/init/patch-kind.js +10 -0
  164. package/dist/init/plan-framework.d.ts +50 -0
  165. package/dist/init/plan-framework.js +341 -0
  166. package/dist/init/plan.d.ts +119 -6
  167. package/dist/init/plan.js +230 -131
  168. package/dist/init/project-id.d.ts +5 -9
  169. package/dist/init/project-id.js +10 -22
  170. package/dist/init/run.d.ts +22 -0
  171. package/dist/init/run.js +393 -28
  172. package/dist/init/slash-command.d.ts +27 -0
  173. package/dist/init/slash-command.js +61 -0
  174. package/dist/init/snippets.d.ts +41 -3
  175. package/dist/init/snippets.js +185 -10
  176. package/dist/init/vite-config.d.ts +4 -10
  177. package/dist/init/vite-config.js +15 -7
  178. package/dist/init/workspace-apps.d.ts +23 -0
  179. package/dist/init/workspace-apps.js +68 -0
  180. package/dist/input/driven-redaction.d.ts +15 -0
  181. package/dist/input/driven-redaction.js +76 -0
  182. package/dist/input/network-detail.d.ts +9 -3
  183. package/dist/input/network-detail.js +43 -23
  184. package/dist/input/network-mock-tools.js +2 -2
  185. package/dist/input/network-mock.js +4 -4
  186. package/dist/input/real-input.js +8 -3
  187. package/dist/input/scroll-find.js +2 -2
  188. package/dist/input/scroll-tools.d.ts +0 -5
  189. package/dist/input/scroll-tools.js +11 -4
  190. package/dist/input/viewport-tools.js +2 -2
  191. package/dist/journal/ambient-store.d.ts +0 -2
  192. package/dist/journal/ambient-store.js +1 -1
  193. package/dist/journal/ambient.js +1 -1
  194. package/dist/journal/attach-journal.d.ts +2 -1
  195. package/dist/journal/deviation-report.js +6 -6
  196. package/dist/journal/deviation-service.js +1 -1
  197. package/dist/journal/envelope-store.d.ts +0 -2
  198. package/dist/journal/envelope-store.js +1 -1
  199. package/dist/journal/envelope.d.ts +2 -3
  200. package/dist/journal/envelope.js +3 -3
  201. package/dist/journal/journal-query.js +1 -1
  202. package/dist/journal/journal-recorder.d.ts +2 -1
  203. package/dist/journal/journal-recorder.js +2 -2
  204. package/dist/journal/rollups.js +9 -9
  205. package/dist/journal/session-end.d.ts +2 -1
  206. package/dist/journal/session-journal.js +4 -4
  207. package/dist/license/license.d.ts +0 -14
  208. package/dist/license/license.js +8 -8
  209. package/dist/log.d.ts +10 -0
  210. package/dist/log.js +11 -1
  211. package/dist/mcp/mcp-is-error.d.ts +13 -0
  212. package/dist/mcp/mcp-is-error.js +18 -0
  213. package/dist/mcp/mcp-outage.d.ts +31 -0
  214. package/dist/mcp/mcp-outage.js +48 -0
  215. package/dist/mcp/mcp-proxy.d.ts +124 -0
  216. package/dist/mcp/mcp-proxy.js +697 -0
  217. package/dist/{mcp.d.ts → mcp/mcp.d.ts} +13 -10
  218. package/dist/{mcp.js → mcp/mcp.js} +110 -35
  219. package/dist/mcp/proxy-handshake.d.ts +3 -0
  220. package/dist/mcp/proxy-handshake.js +54 -0
  221. package/dist/mcp/proxy-lifecycle.d.ts +64 -0
  222. package/dist/mcp/proxy-lifecycle.js +70 -0
  223. package/dist/mcp/tool-catalog-cache.d.ts +29 -0
  224. package/dist/mcp/tool-catalog-cache.js +55 -0
  225. package/dist/oracles/propose-consequences.d.ts +2 -1
  226. package/dist/oracles/propose-consequences.js +16 -11
  227. package/dist/oracles/self-instrument.d.ts +2 -1
  228. package/dist/oracles/self-instrument.js +1 -1
  229. package/dist/phenomena/phenomena.js +3 -3
  230. package/dist/platform.d.ts +16 -0
  231. package/dist/platform.js +16 -0
  232. package/dist/pool/playwright-launcher.d.ts +15 -2
  233. package/dist/pool/playwright-launcher.js +21 -2
  234. package/dist/project/fs-port.js +1 -1
  235. package/dist/project/project-tools.js +2 -2
  236. package/dist/runs/build-verification-run.js +1 -1
  237. package/dist/runs/repair-prompt.d.ts +0 -6
  238. package/dist/runs/repair-prompt.js +3 -3
  239. package/dist/runs/risk-classify.js +1 -1
  240. package/dist/runs/run-diff.d.ts +0 -2
  241. package/dist/runs/run-diff.js +3 -3
  242. package/dist/runs/run-tools.js +2 -2
  243. package/dist/runs/verification-sync.js +2 -2
  244. package/dist/runs/verify-http.js +1 -1
  245. package/dist/runs/verify-server.js +1 -1
  246. package/dist/session/command-timeout.d.ts +11 -3
  247. package/dist/session/command-timeout.js +2 -2
  248. package/dist/session/control-envelope.d.ts +6 -0
  249. package/dist/session/control-envelope.js +7 -1
  250. package/dist/session/dev-server-probe.d.ts +13 -0
  251. package/dist/session/dev-server-probe.js +52 -0
  252. package/dist/session/last-act.d.ts +17 -3
  253. package/dist/session/last-act.js +16 -4
  254. package/dist/session/live-control-tools.js +7 -2
  255. package/dist/session/live-control.js +1 -1
  256. package/dist/session/looks-like-dev-server.d.ts +14 -0
  257. package/dist/session/looks-like-dev-server.js +25 -0
  258. package/dist/session/no-session-diagnosis.d.ts +38 -0
  259. package/dist/session/no-session-diagnosis.js +70 -0
  260. package/dist/session/no-session-watch.d.ts +19 -0
  261. package/dist/session/no-session-watch.js +61 -0
  262. package/dist/session/observed-state.d.ts +4 -0
  263. package/dist/session/observed-state.js +19 -1
  264. package/dist/session/output-budget.js +1 -1
  265. package/dist/session/session-health.d.ts +8 -1
  266. package/dist/session/session-health.js +8 -7
  267. package/dist/session/session-info.d.ts +26 -7
  268. package/dist/session/session-info.js +42 -1
  269. package/dist/session/session-lease.d.ts +0 -11
  270. package/dist/session/session-lease.js +1 -1
  271. package/dist/session/session-manager.d.ts +3 -0
  272. package/dist/session/session-manager.js +58 -9
  273. package/dist/session/session-readiness.d.ts +1 -1
  274. package/dist/session/session-readiness.js +6 -1
  275. package/dist/session/session-reaper.js +1 -1
  276. package/dist/session/session-reconnect.d.ts +32 -0
  277. package/dist/session/session-reconnect.js +42 -0
  278. package/dist/session/session.d.ts +20 -2
  279. package/dist/session/session.js +76 -26
  280. package/dist/session/state-select.d.ts +1 -1
  281. package/dist/session/state-select.js +1 -1
  282. package/dist/status-payload.d.ts +19 -0
  283. package/dist/status-payload.js +11 -0
  284. package/dist/telemetry/argument-shape.js +10 -1
  285. package/dist/telemetry/browser-mode.d.ts +27 -0
  286. package/dist/telemetry/browser-mode.js +33 -0
  287. package/dist/telemetry/bug-found.d.ts +17 -21
  288. package/dist/telemetry/bug-found.js +120 -8
  289. package/dist/telemetry/cli-telemetry.d.ts +2 -12
  290. package/dist/telemetry/cli-telemetry.js +27 -4
  291. package/dist/telemetry/daemon-telemetry.d.ts +19 -0
  292. package/dist/telemetry/daemon-telemetry.js +12 -2
  293. package/dist/telemetry/dev-repo.js +4 -4
  294. package/dist/telemetry/error-fingerprint.js +21 -2
  295. package/dist/telemetry/feedback-cli.d.ts +1 -1
  296. package/dist/telemetry/feedback-cli.js +4 -4
  297. package/dist/telemetry/feedback-context.d.ts +22 -0
  298. package/dist/telemetry/feedback-context.js +41 -1
  299. package/dist/telemetry/feedback-delivery.d.ts +20 -0
  300. package/dist/telemetry/feedback-delivery.js +30 -0
  301. package/dist/telemetry/feedback-outbox.d.ts +22 -0
  302. package/dist/telemetry/feedback-outbox.js +87 -0
  303. package/dist/telemetry/feedback.d.ts +22 -1
  304. package/dist/telemetry/feedback.js +67 -8
  305. package/dist/telemetry/git-facts.js +1 -1
  306. package/dist/telemetry/session-metrics.d.ts +2 -0
  307. package/dist/telemetry/session-metrics.js +68 -6
  308. package/dist/telemetry/telemetry.d.ts +27 -2
  309. package/dist/telemetry/telemetry.js +119 -29
  310. package/dist/telemetry/verification-of.d.ts +22 -0
  311. package/dist/telemetry/verification-of.js +84 -0
  312. package/dist/tools/act-capsule.js +2 -2
  313. package/dist/tools/act-danger.d.ts +12 -0
  314. package/dist/tools/act-danger.js +20 -3
  315. package/dist/tools/act-helpers.js +4 -4
  316. package/dist/tools/act-tools.d.ts +0 -8
  317. package/dist/tools/act-tools.js +84 -115
  318. package/dist/tools/act-view.js +7 -7
  319. package/dist/tools/alias-args.d.ts +17 -0
  320. package/dist/tools/alias-args.js +25 -0
  321. package/dist/tools/assert-grade.js +9 -9
  322. package/dist/tools/assert-verdict.d.ts +3 -1
  323. package/dist/tools/assert-verdict.js +8 -5
  324. package/dist/tools/browser-tools.js +30 -11
  325. package/dist/tools/contract-tools.js +1 -1
  326. package/dist/tools/coverage-identity.d.ts +34 -0
  327. package/dist/tools/coverage-identity.js +46 -0
  328. package/dist/tools/coverage-tools.js +10 -14
  329. package/dist/tools/dynamic-tools.d.ts +2 -1
  330. package/dist/tools/dynamic-tools.js +90 -18
  331. package/dist/tools/error-recovery.d.ts +20 -2
  332. package/dist/tools/error-recovery.js +179 -4
  333. package/dist/tools/feedback-tools.d.ts +12 -1
  334. package/dist/tools/feedback-tools.js +28 -5
  335. package/dist/tools/invoke-tool.d.ts +0 -6
  336. package/dist/tools/invoke-tool.js +57 -32
  337. package/dist/tools/lease-hint.d.ts +17 -0
  338. package/dist/tools/lease-hint.js +24 -0
  339. package/dist/tools/lease-tools.js +6 -5
  340. package/dist/tools/merge-tools.d.ts +10 -1
  341. package/dist/tools/merge-tools.js +9 -3
  342. package/dist/tools/merged-name-redirect.d.ts +13 -0
  343. package/dist/tools/merged-name-redirect.js +55 -0
  344. package/dist/tools/navigate-result.d.ts +17 -0
  345. package/dist/tools/navigate-result.js +31 -0
  346. package/dist/tools/observe-tools.js +54 -20
  347. package/dist/tools/observed-nothing.d.ts +25 -0
  348. package/dist/tools/observed-nothing.js +33 -0
  349. package/dist/tools/query-paginate.js +2 -2
  350. package/dist/tools/query-shape.d.ts +18 -0
  351. package/dist/tools/query-shape.js +39 -0
  352. package/dist/tools/read-tools.js +85 -20
  353. package/dist/tools/real-input-attempt.d.ts +28 -0
  354. package/dist/tools/real-input-attempt.js +88 -0
  355. package/dist/tools/reconcile-tools.js +7 -7
  356. package/dist/tools/reload-result.d.ts +14 -0
  357. package/dist/tools/reload-result.js +29 -0
  358. package/dist/tools/snapshot-delta.d.ts +4 -5
  359. package/dist/tools/snapshot-delta.js +0 -0
  360. package/dist/tools/tool-kit.d.ts +8 -0
  361. package/dist/tools/tool-surface.d.ts +83 -0
  362. package/dist/tools/tool-surface.js +204 -0
  363. package/dist/tools/tools-helpers.js +6 -6
  364. package/dist/tools/tools.d.ts +15 -0
  365. package/dist/tools/tools.js +53 -17
  366. package/dist/tools/unadvertised-help.d.ts +5 -0
  367. package/dist/tools/unadvertised-help.js +39 -0
  368. package/dist/trace.d.ts +69 -0
  369. package/dist/trace.js +150 -0
  370. package/dist/update/nudge-credit.d.ts +4 -0
  371. package/dist/update/nudge-credit.js +53 -0
  372. package/dist/update/sdk-sync.d.ts +21 -0
  373. package/dist/update/sdk-sync.js +42 -0
  374. package/dist/update/update-checker.js +3 -3
  375. package/dist/update/update-nudge.d.ts +63 -6
  376. package/dist/update/update-nudge.js +128 -15
  377. package/dist/update/updater.d.ts +1 -9
  378. package/dist/update/updater.js +13 -8
  379. package/dist/version/peer-announce.d.ts +4 -0
  380. package/dist/version/peer-announce.js +30 -0
  381. package/dist/{server-version.js → version/server-version.js} +5 -1
  382. package/dist/version/version-nudge.d.ts +28 -0
  383. package/dist/version/version-nudge.js +40 -0
  384. package/dist/version/version-skew.d.ts +66 -0
  385. package/dist/version/version-skew.js +71 -0
  386. package/dist/visual/visual-diff.js +2 -2
  387. package/dist/visual/visual-tools.js +4 -4
  388. package/docs/agent-cheatsheet.md +85 -0
  389. package/docs/architecture.md +113 -0
  390. package/docs/benchmarks.md +255 -0
  391. package/docs/debugging.md +102 -0
  392. package/docs/deploy-checks.md +69 -0
  393. package/docs/desktop-apps.md +256 -0
  394. package/docs/enterprise.md +62 -0
  395. package/docs/fixtures.md +28 -0
  396. package/docs/flows.md +166 -0
  397. package/docs/getting-started.md +469 -0
  398. package/docs/human-control.md +63 -0
  399. package/docs/integration-patterns.md +171 -0
  400. package/docs/local-registry.md +126 -0
  401. package/docs/mint.json +61 -0
  402. package/docs/multi-agent-testing.md +55 -0
  403. package/docs/platform-integration.md +122 -0
  404. package/docs/telemetry-contract.md +214 -0
  405. package/docs/telemetry.md +184 -0
  406. package/docs/testing.md +82 -0
  407. package/docs/token-efficiency.md +80 -0
  408. package/docs/usage.md +1046 -0
  409. package/package.json +8 -5
  410. package/dist/cli-launch.js +0 -112
  411. package/dist/cli-parse.d.ts +0 -140
  412. package/dist/daemon-resilience.d.ts +0 -22
  413. package/dist/daemon-resilience.js +0 -85
  414. package/dist/mcp-proxy.d.ts +0 -35
  415. package/dist/mcp-proxy.js +0 -190
  416. package/dist/tools/profiles.d.ts +0 -37
  417. package/dist/tools/profiles.js +0 -139
  418. /package/dist/{pairing-token.d.ts → bridge/pairing-token.d.ts} +0 -0
  419. /package/dist/{token-auth.d.ts → bridge/token-auth.d.ts} +0 -0
  420. /package/dist/{token-auth.js → bridge/token-auth.js} +0 -0
  421. /package/dist/{cli-doctor.d.ts → cli/cli-doctor.d.ts} +0 -0
  422. /package/dist/{cli-verify.d.ts → cli/cli-verify.d.ts} +0 -0
  423. /package/dist/{cloud-cli.d.ts → cli/cloud-cli.d.ts} +0 -0
  424. /package/dist/{server-version.d.ts → version/server-version.d.ts} +0 -0
@@ -0,0 +1,697 @@
1
+ import * as http from 'node:http';
2
+ import { localInitializeResponse, isHandshakeLine } from './proxy-handshake.js';
3
+ import { ToolCatalogCache } from './tool-catalog-cache.js';
4
+ import * as net from 'node:net';
5
+ import { appendFileSync, mkdirSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { LOOPBACK_HOST, MCP_SSE_PATH, ReticleDir, ReticleEnv, CONTRACT_FINGERPRINT, } from '@reticlehq/core';
9
+ import { SERVER_VERSION } from '../version/server-version.js';
10
+ import { PEER_VERSION_PARAM, PEER_CONTRACT_PARAM } from '../version/peer-announce.js';
11
+ import { onStreamDrop, onClientRequest, onReconnectBudgetSpent, OnDrop, OnRequest, } from './proxy-lifecycle.js';
12
+ import { log } from '../log.js';
13
+ import { OutageStage, reportMcpOutage } from './mcp-outage.js';
14
+ const DEFAULT_DAEMON_READY_TIMEOUT_MS = 10_000;
15
+ /**
16
+ * How long to wait for the spawned daemon's port to accept connections before giving up. The default
17
+ * suits a normal machine; a slow CI/VM (heavy headless-browser launch) can raise it via the
18
+ * RETICLE_DAEMON_READY_TIMEOUT_MS env var. Invalid/absent values fall back to the default.
19
+ */
20
+ const envDaemonReadyTimeoutMs = Number(process.env['RETICLE_DAEMON_READY_TIMEOUT_MS']);
21
+ const DAEMON_READY_TIMEOUT_MS = Number.isFinite(envDaemonReadyTimeoutMs) && envDaemonReadyTimeoutMs > 0
22
+ ? envDaemonReadyTimeoutMs
23
+ : DEFAULT_DAEMON_READY_TIMEOUT_MS;
24
+ const DAEMON_POLL_INTERVAL_MS = 100;
25
+ function delay(ms) {
26
+ return new Promise((resolve) => setTimeout(resolve, ms));
27
+ }
28
+ /** Reconnect backoff: linear, capped, so a briefly-restarting daemon is picked up fast. */
29
+ const RECONNECT_BASE_MS = 250;
30
+ const RECONNECT_CAP_MS = 5_000;
31
+ /**
32
+ * How many consecutive failed reconnects before the proxy stops RETRYING. It does not stop serving:
33
+ * the budget ends the retry loop and the proxy goes dormant, where the handshake and `tools/list`
34
+ * are still answered and the next client request starts a daemon. See onReconnectBudgetSpent.
35
+ *
36
+ * At the capped backoff this is a few minutes — long enough to ride out a daemon restart without
37
+ * spinning on a port that is wedged or held by a foreign process.
38
+ */
39
+ const DEFAULT_MAX_RECONNECT_ATTEMPTS = 60;
40
+ /**
41
+ * Overridable so the survival spec can reach exhaustion in seconds instead of minutes — the same
42
+ * reason the idle windows take an env override. A positive integer only; anything else falls back,
43
+ * so a typo cannot silently disable the retry loop.
44
+ */
45
+ export const MAX_RECONNECT_ATTEMPTS = (() => {
46
+ const raw = Number(process.env[ReticleEnv.RECONNECT_ATTEMPTS]);
47
+ return Number.isInteger(raw) && raw > 0 ? raw : DEFAULT_MAX_RECONNECT_ATTEMPTS;
48
+ })();
49
+ export function reconnectDelayMs(attempt) {
50
+ return Math.min(RECONNECT_BASE_MS * attempt, RECONNECT_CAP_MS);
51
+ }
52
+ /** JSON-RPC id the proxy uses for its own replayed `initialize` — never one the client could send. */
53
+ export const RECONNECT_INITIALIZE_ID = '__reticle_proxy_reinit';
54
+ /**
55
+ * Which port this proxy serves, for the log file name. Set once at startup.
56
+ *
57
+ * One file per port, matching `daemon-<port>.log`. A single shared file interleaved every proxy on
58
+ * the machine into one stream, so the first question anyone asks of it — "what happened to MY
59
+ * session?" — needed a filter before it could be answered.
60
+ */
61
+ let logPort;
62
+ /** Name the proxy log after the port it serves. Called before anything can log. */
63
+ export function setProxyLogPort(port) {
64
+ logPort = port;
65
+ }
66
+ /** The proxy's own log file, so a silent drop leaves a readable trace the agent can go read. */
67
+ export function proxyLogPath(port = logPort) {
68
+ const name = port === undefined ? 'mcp-proxy.log' : `proxy-${String(port)}.log`;
69
+ return join(homedir(), ReticleDir.ROOT, name);
70
+ }
71
+ /**
72
+ * Log to stderr (which the agent host usually swallows) AND to ~/.reticle/proxy-<port>.log, which it
73
+ * does not. A dropped MCP connection is invisible from the agent's side — no message, no exit code —
74
+ * so the one thing that makes it diagnosable at all is a file somebody can read afterwards.
75
+ *
76
+ * EXPORTED because the crash handlers need it. `installProxyResilience` was wired to the bare stderr
77
+ * logger, so the proxy's own uncaught exceptions and unhandled rejections — the exact events that
78
+ * present to a human as "the MCP server disconnected" — were written to a stream the editor throws
79
+ * away. The handler ran, the process kept serving, and the reason went nowhere. Reported as "the
80
+ * proxy has no log file… when it dies the diagnostic dies with it", which was half right: the file
81
+ * existed, and the one path that most needed it was not using it.
82
+ */
83
+ export function proxyLog(event, fields = {}) {
84
+ log(event, fields);
85
+ try {
86
+ const dir = join(homedir(), ReticleDir.ROOT);
87
+ mkdirSync(dir, { recursive: true });
88
+ // WITH A TIMESTAMP. This file is the only record of an outage that survives the session, and
89
+ // without one it cannot answer the two questions anyone brings to it: when did this happen, and
90
+ // how often. A real 3,283-line log on a dev machine held a `gave_up` — the exact event that used
91
+ // to precede `process.exit(1)` and cost the human a manual /mcp reconnect — and it was
92
+ // impossible to tell whether it happened during that person's work or a test run hours earlier.
93
+ // Evidence you cannot place in time is an anecdote.
94
+ appendFileSync(proxyLogPath(), `${JSON.stringify({ t: new Date().toISOString(), event, ...fields })}\n`, 'utf8');
95
+ }
96
+ catch {
97
+ // Logging must never be the thing that kills the proxy.
98
+ }
99
+ }
100
+ function parseJsonRpc(line) {
101
+ try {
102
+ const parsed = JSON.parse(line);
103
+ return 'object' === typeof parsed && parsed !== null ? parsed : null;
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
109
+ const INITIALIZE_METHOD = 'initialize';
110
+ /**
111
+ * How long the handshake waits on the daemon before the proxy answers it.
112
+ *
113
+ * Long enough for a cold daemon start (which is seconds), short enough that a client does not sit
114
+ * there. Measured failure: a listener that accepts and never serves SSE left `initialize` unanswered
115
+ * past 25s with nothing on stderr, and the reported client gave up at 60s having run no tools.
116
+ */
117
+ const LOCAL_HANDSHAKE_MS = 12_000;
118
+ const INITIALIZED_METHOD = 'notifications/initialized';
119
+ /**
120
+ * JSON-RPC requests forwarded on behalf of the client that have not been answered yet.
121
+ *
122
+ * Only used at shutdown, where it is the difference between "the client closed stdin" and "the client
123
+ * closed stdin and we dropped its `tools/list` on the floor while reporting exit 0". A request has an
124
+ * id AND a method; a notification has no id and is owed nothing; a response has an id and no method.
125
+ */
126
+ export class PendingRequests {
127
+ #ids = new Set();
128
+ observeOutbound(line) {
129
+ const msg = parseJsonRpc(line);
130
+ if (null === msg || msg.id === undefined || msg.method === undefined)
131
+ return;
132
+ this.#ids.add(JSON.stringify(msg.id));
133
+ }
134
+ observeInbound(line) {
135
+ const msg = parseJsonRpc(line);
136
+ if (null === msg || msg.id === undefined)
137
+ return;
138
+ this.#ids.delete(JSON.stringify(msg.id));
139
+ }
140
+ get unanswered() {
141
+ return [...this.#ids];
142
+ }
143
+ /**
144
+ * Claim ONE unanswered id, so exactly one path may answer it. False means somebody already did —
145
+ * a duplicate response to one id is a protocol violation, not a belt-and-braces retry.
146
+ */
147
+ take(id) {
148
+ return this.#ids.delete(JSON.stringify(id));
149
+ }
150
+ /** Take every unanswered id and forget them, so the same request is failed at most once. */
151
+ drain() {
152
+ const ids = [...this.#ids];
153
+ this.#ids.clear();
154
+ return ids;
155
+ }
156
+ }
157
+ /**
158
+ * JSON-RPC errors for every request that was in flight when the stream died.
159
+ *
160
+ * These are unrecoverable BY CONSTRUCTION: the daemon builds a fresh MCP session per SSE
161
+ * connection, so the reconnected session has never seen them. Re-sending them is not an option
162
+ * either — a `reticle_act` that already clicked would click twice, and inventing a second action in
163
+ * somebody's app is worse than reporting a failure.
164
+ *
165
+ * So the proxy answers them itself, under each request's own id. Found by brute force: killing the
166
+ * daemon under a live client left 20 of 20 concurrent calls hanging until the client's own timeout,
167
+ * with the MCP server still perfectly alive. An agent cannot tell that apart from "still working".
168
+ */
169
+ export function streamLossReplies(pending, reason) {
170
+ return pending.drain().map((id) => transportLossReply(JSON.parse(id), reason));
171
+ }
172
+ /**
173
+ * -32001: a server-defined error. Not -32603 (internal): nothing went wrong INSIDE the call, the
174
+ * transport under it went away, and the distinction is what tells a caller the action may be safe
175
+ * to retry.
176
+ */
177
+ const TRANSPORT_LOSS_CODE = -32001;
178
+ /** The one shape every "the transport ate your call" answer takes, whichever leg died. */
179
+ export function transportLossReply(id, reason) {
180
+ return JSON.stringify({
181
+ jsonrpc: '2.0',
182
+ id,
183
+ error: {
184
+ code: TRANSPORT_LOSS_CODE,
185
+ message: `the daemon connection dropped (${reason}) before this call was answered — it did NOT ` +
186
+ 'complete. Reticle is recovering the connection; retry if the action is safe to repeat.',
187
+ },
188
+ });
189
+ }
190
+ /**
191
+ * How long stdin closing gives the daemon to answer what is still in flight.
192
+ *
193
+ * A client can write its whole conversation and close in one flush; the daemon is a socket away and
194
+ * answers in milliseconds, so this is generous. Overshooting only delays an exit that is already
195
+ * ending the process.
196
+ */
197
+ const SHUTDOWN_DRAIN_MS = 5_000;
198
+ /**
199
+ * How long a request may sit in the queue, unsent, before the proxy answers it itself.
200
+ *
201
+ * The second population the stress test found. A request that was FORWARDED and lost is failed when
202
+ * the stream drops; one that never got forwarded — queued while dormant or reconnecting — was
203
+ * waiting for a session that, with a squatter holding the port, never arrives. It then hung until
204
+ * the client's own timeout, or forever if the client has none.
205
+ *
206
+ * Comfortably longer than a daemon restart (~1-2s) so a normal blip still flushes and succeeds, and
207
+ * well under the 60s an MCP client typically allows, so the answer comes from us with a reason
208
+ * rather than from their timer with none.
209
+ */
210
+ const QUEUE_WAIT_MS = 20_000;
211
+ const SHUTDOWN_DRAIN_POLL_MS = 25;
212
+ /**
213
+ * Makes a reconnect invisible to the client.
214
+ *
215
+ * The daemon builds a FRESH `McpServer` per SSE connection, so a reconnected session has never seen
216
+ * the client's `initialize` — every subsequent `tools/call` would hit an uninitialized server. The
217
+ * proxy therefore remembers the handshake the client sent once and replays it into each new session.
218
+ * The replay is re-issued under a reserved id so the daemon's response to it can be dropped rather
219
+ * than reaching the client's stdout, where a duplicate JSON-RPC id would be a protocol violation.
220
+ */
221
+ export class HandshakeReplay {
222
+ #initialize = null;
223
+ #initialized = null;
224
+ #pendingReplayId = null;
225
+ /** Record one outbound client line. Cheap enough to call for every stdin message. */
226
+ observeOutbound(line) {
227
+ const msg = parseJsonRpc(line);
228
+ if (null === msg)
229
+ return;
230
+ // The first initialize is the session's identity; a client re-issuing one does not change it.
231
+ if (msg.method === INITIALIZE_METHOD && null === this.#initialize)
232
+ this.#initialize = line;
233
+ else if (msg.method === INITIALIZED_METHOD)
234
+ this.#initialized = line;
235
+ }
236
+ /** Lines to POST into a freshly-established session, before any queued client traffic. */
237
+ replayLines() {
238
+ if (null === this.#initialize)
239
+ return [];
240
+ const msg = parseJsonRpc(this.#initialize);
241
+ if (null === msg)
242
+ return [];
243
+ this.#pendingReplayId = RECONNECT_INITIALIZE_ID;
244
+ const reinit = { ...msg, id: RECONNECT_INITIALIZE_ID };
245
+ return null === this.#initialized
246
+ ? [JSON.stringify(reinit)]
247
+ : [JSON.stringify(reinit), this.#initialized];
248
+ }
249
+ /** True when this inbound daemon line is the echo of our replayed handshake and must not be forwarded. */
250
+ shouldSuppressInbound(line) {
251
+ if (null === this.#pendingReplayId)
252
+ return false;
253
+ const msg = parseJsonRpc(line);
254
+ if (null === msg || msg.id !== this.#pendingReplayId)
255
+ return false;
256
+ this.#pendingReplayId = null; // one response per replay — anything later is not ours to swallow
257
+ return true;
258
+ }
259
+ }
260
+ /**
261
+ * Incremental SSE frame parser for the MCP front door.
262
+ *
263
+ * A single SSE field can split across TCP reads (`da` in one chunk, `ta: {...}` in the next), and a
264
+ * server may send CRLF or bare CR line endings — so the framing is stateful and edge-case-prone, yet it
265
+ * carried every MCP message the agent sends and was only ever exercised end-to-end. Pulled out of the
266
+ * socket handler as a pure, chunk-fed parser so those boundaries are unit-testable: feed raw chunks,
267
+ * get back each complete frame (a blank line terminates a frame; `event:` names it, `data:` lines
268
+ * accumulate newline-joined; `id:`/`retry:`/comments are ignored — not needed for the bridge).
269
+ */
270
+ export class SseFrameParser {
271
+ #buffer = '';
272
+ #event = '';
273
+ #data = '';
274
+ push(chunk) {
275
+ this.#buffer += chunk;
276
+ // Normalise CRLF/CR so the splitter only handles \n, then hold the trailing partial line for the
277
+ // next chunk (it may complete later).
278
+ const normalised = this.#buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
279
+ const lines = normalised.split('\n');
280
+ this.#buffer = lines.pop() ?? '';
281
+ const frames = [];
282
+ for (const line of lines) {
283
+ if ('' === line) {
284
+ if (this.#data !== '') {
285
+ frames.push({ event: this.#event !== '' ? this.#event : 'message', data: this.#data });
286
+ }
287
+ this.#event = '';
288
+ this.#data = '';
289
+ }
290
+ else if (line.startsWith('event:')) {
291
+ this.#event = line.slice(6).trim();
292
+ }
293
+ else if (line.startsWith('data:')) {
294
+ const val = line.slice(5).trim();
295
+ this.#data = this.#data !== '' ? `${this.#data}\n${val}` : val;
296
+ }
297
+ }
298
+ return frames;
299
+ }
300
+ }
301
+ /**
302
+ * Returns true if something is already listening on the reticle port.
303
+ * Uses a plain TCP probe so we don't create a side-effectful SSE session
304
+ * inside the daemon just to check reachability.
305
+ */
306
+ export function probeDaemon(port) {
307
+ return new Promise((resolve) => {
308
+ const socket = new net.Socket();
309
+ socket.setTimeout(500);
310
+ socket.on('connect', () => {
311
+ socket.destroy();
312
+ resolve(true);
313
+ });
314
+ socket.on('error', () => resolve(false));
315
+ socket.on('timeout', () => {
316
+ socket.destroy();
317
+ resolve(false);
318
+ });
319
+ socket.connect(port, LOOPBACK_HOST);
320
+ });
321
+ }
322
+ /** Poll until the daemon's HTTP port accepts connections or the deadline is reached. */
323
+ export async function waitForDaemon(port) {
324
+ const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
325
+ while (Date.now() < deadline) {
326
+ const reachable = await probeDaemon(port);
327
+ if (reachable)
328
+ return;
329
+ await delay(DAEMON_POLL_INTERVAL_MS);
330
+ }
331
+ throw new Error(`reticle daemon did not become ready on port ${port} within ${DAEMON_READY_TIMEOUT_MS}ms`);
332
+ }
333
+ /**
334
+ * POST one JSON-RPC line into the daemon's session. Resolves null on success, or a short reason
335
+ * when the request never reached a server that will answer it.
336
+ *
337
+ * It used to resolve `void` in every case, logging the failure and moving on. That is the THIRD way
338
+ * a call goes unanswered, and the only one neither `streamLossReplies` nor the queue timer can see:
339
+ * the SSE stream is healthy (so nothing drops) and the request was forwarded (so nothing is queued),
340
+ * but the POST leg is its own TCP connection and an ECONNRESET on it means the daemon never received
341
+ * the call. Nobody was ever going to reply, and the caller waited for its own timeout.
342
+ */
343
+ function postToSession(url, body) {
344
+ return new Promise((resolve) => {
345
+ const parsed = new URL(url);
346
+ const bodyBuf = Buffer.from(body, 'utf8');
347
+ const options = {
348
+ host: parsed.hostname,
349
+ port: parsed.port !== '' ? parseInt(parsed.port, 10) : 80,
350
+ path: `${parsed.pathname}${parsed.search}`,
351
+ method: 'POST',
352
+ headers: {
353
+ 'Content-Type': 'application/json',
354
+ 'Content-Length': bodyBuf.byteLength,
355
+ },
356
+ };
357
+ const req = http.request(options, (res) => {
358
+ const status = res.statusCode ?? 0;
359
+ const rejected = status < 200 || status >= 300;
360
+ if (rejected) {
361
+ // A non-2xx from the daemon MCP endpoint used to be swallowed, hanging the JSON-RPC call
362
+ // client-side with no diagnostic. It is a refusal: no response is coming over the stream.
363
+ log('reticle_mcp_proxy_post_non2xx', { status, path: options.path });
364
+ }
365
+ res.resume(); // drain so the socket is reused
366
+ resolve(rejected ? `daemon rejected the call with HTTP ${String(status)}` : null);
367
+ });
368
+ req.on('error', (err) => {
369
+ log('reticle_mcp_proxy_post_error', { error: err.message });
370
+ resolve(`post failed: ${err.message}`);
371
+ });
372
+ req.write(bodyBuf);
373
+ req.end();
374
+ });
375
+ }
376
+ export function buildSessionUrl(rawData, port) {
377
+ return rawData.startsWith('/') ? `http://${LOOPBACK_HOST}:${port}${rawData}` : rawData;
378
+ }
379
+ /**
380
+ * Bridge stdio ↔ SSE: connects to the running daemon's MCP endpoint and forwards
381
+ * Claude Code's stdin/stdout JSON-RPC through it. Never resolves — runs until stdin closes.
382
+ *
383
+ * A dropped SSE stream used to exit the process, which is what made the agent's `reticle_*` tools
384
+ * vanish mid-session with no message and no way for the agent to get them back — only a human running
385
+ * `/mcp` could. The daemon demonstrably stays up across these drops, so the stream ending is a
386
+ * transport event, not a shutdown: reconnect to it and replay the handshake instead of dying.
387
+ */
388
+ export function startMcpProxy(port,
389
+ /**
390
+ * Bring a daemon back before reconnecting. Without it the proxy retried a DEAD port until the
391
+ * budget ran out and then killed itself, taking the agent's whole Reticle surface with it — the
392
+ * failure mode for a daemon that crashed, was stopped, or shut itself down as idle.
393
+ */
394
+ ensureDaemon) {
395
+ return new Promise((_resolve, reject) => {
396
+ // Declared before the SSE handler that fills it: the handler is a closure created below but
397
+ // invoked on the first daemon frame, and a `const` referenced before its declaration executes is
398
+ // a runtime throw, not a type error.
399
+ const catalog = new ToolCatalogCache();
400
+ let postUrl = null;
401
+ /**
402
+ * No daemon is listening and the proxy has stopped chasing one.
403
+ *
404
+ * The alternative — respawning the moment the stream drops — turns an idle daemon's own
405
+ * self-shutdown into a permanent spawn loop, because the daemon it brings back is just as idle.
406
+ * Dormant means the next thing the CLIENT asks for is what brings Reticle back.
407
+ */
408
+ let dormant = false;
409
+ const stdinQueue = [];
410
+ /**
411
+ * Fail anything still queued after QUEUE_WAIT_MS. Re-armed on every push and cleared on flush,
412
+ * so a queue that drains normally never fires it.
413
+ */
414
+ let queueTimer;
415
+ const clearQueueTimer = () => {
416
+ if (queueTimer !== undefined)
417
+ clearTimeout(queueTimer);
418
+ queueTimer = undefined;
419
+ };
420
+ const armQueueTimer = () => {
421
+ if (queueTimer !== undefined)
422
+ return; // the oldest entry owns the deadline
423
+ queueTimer = setTimeout(() => {
424
+ queueTimer = undefined;
425
+ if (stopped || 0 === stdinQueue.length)
426
+ return;
427
+ // Drop what we could never send, and answer it, so the caller is never left guessing.
428
+ stdinQueue.splice(0);
429
+ for (const reply of streamLossReplies(pending, 'no daemon could be reached'))
430
+ emit(reply);
431
+ proxyLog('reticle_mcp_proxy_queue_expired', {
432
+ port,
433
+ note: 'answered queued requests instead of holding them for a daemon that never arrived',
434
+ });
435
+ }, QUEUE_WAIT_MS);
436
+ queueTimer.unref();
437
+ };
438
+ const replay = new HandshakeReplay();
439
+ const pending = new PendingRequests();
440
+ /** The ONE way a line reaches the client — so nothing can be answered without clearing the debt. */
441
+ const emit = (line) => {
442
+ pending.observeInbound(line);
443
+ process.stdout.write(`${line}\n`);
444
+ };
445
+ let stopped = false;
446
+ let attempts = 0;
447
+ /**
448
+ * The ONE way a line leaves for the daemon — so a POST that never lands still owes an answer.
449
+ *
450
+ * Only requests we are holding in `pending` get one: a notification is owed nothing, and the
451
+ * proxy's own replayed handshake carries a reserved id the client must never see a reply for.
452
+ */
453
+ const forward = (url, line) => {
454
+ void postToSession(url, line).then((failure) => {
455
+ if (null === failure)
456
+ return;
457
+ const msg = parseJsonRpc(line);
458
+ if (null === msg || msg.id === undefined || !pending.take(msg.id))
459
+ return;
460
+ proxyLog('reticle_mcp_proxy_post_unanswered', {
461
+ port,
462
+ method: String(msg.method),
463
+ reason: failure,
464
+ note: 'the stream is still up, so nothing else would ever have answered this call',
465
+ });
466
+ emit(transportLossReply(msg.id, failure));
467
+ });
468
+ };
469
+ function onSseEvent(event, data, p) {
470
+ if ('endpoint' === event) {
471
+ const url = buildSessionUrl(data, p);
472
+ postUrl = url;
473
+ // A stream that reached `endpoint` is a USABLE session — that, not a set of response
474
+ // headers, is what earns a fresh retry budget. Resetting on headers let a daemon that
475
+ // accepts SSE and drops it spin the reconnect loop at the 250ms floor forever, never backing
476
+ // off and never reaching the budget that puts the proxy dormant. Measured: 32 retries in 8s.
477
+ attempts = 0;
478
+ // The new session's McpServer has never seen the client's initialize — replay it first, then
479
+ // flush whatever the client sent while we were reconnecting.
480
+ for (const line of replay.replayLines())
481
+ forward(url, line);
482
+ clearQueueTimer();
483
+ for (const queued of stdinQueue.splice(0))
484
+ forward(url, queued);
485
+ return;
486
+ }
487
+ if ('message' === event && !replay.shouldSuppressInbound(data)) {
488
+ // Remember the tool catalog as it goes past: it is what makes a locally-answered handshake
489
+ // useful rather than toolless. See tool-catalog-cache.
490
+ catalog.observe(data);
491
+ emit(data);
492
+ }
493
+ }
494
+ function scheduleReconnect(reason, detail) {
495
+ if (stopped)
496
+ return;
497
+ postUrl = null;
498
+ attempts++;
499
+ // Once per process: this session has now lost its tools at least once, which is the number
500
+ // that says whether the transport work actually landed for real users.
501
+ reportMcpOutage(OutageStage.FIRST, { reason, attempts });
502
+ // The severe stage: we are about to stop retrying. Reported so the share of sessions where
503
+ // MCP never came back on its own is a number rather than an anecdote.
504
+ if (attempts > MAX_RECONNECT_ATTEMPTS) {
505
+ reportMcpOutage(OutageStage.BUDGET_SPENT, { reason, attempts });
506
+ // Stop RETRYING, never stop SERVING. See onReconnectBudgetSpent: exiting here made the
507
+ // client mark the MCP server disconnected and left a human to reconnect it by hand.
508
+ dormant = onReconnectBudgetSpent() === OnDrop.DORMANT;
509
+ proxyLog('reticle_mcp_proxy_dormant_after_budget', {
510
+ port,
511
+ reason,
512
+ attempts,
513
+ note: 'stopped retrying; the next client request will start a daemon',
514
+ ...(detail !== undefined ? { detail } : {}),
515
+ });
516
+ return;
517
+ }
518
+ const wait = reconnectDelayMs(attempts);
519
+ proxyLog('reticle_mcp_proxy_reconnecting', {
520
+ port,
521
+ reason,
522
+ attempt: attempts,
523
+ retryInMs: wait,
524
+ ...(detail !== undefined ? { detail } : {}),
525
+ });
526
+ setTimeout(() => {
527
+ // Reattach to a daemon that is ALREADY there. Deliberately does not spawn one: a daemon that
528
+ // shut itself down as idle would be respawned instantly, sit unused, shut down again, and
529
+ // loop forever — measured at four processes in 200s before this. If nothing is listening the
530
+ // proxy goes DORMANT and the next client request brings a daemon back (see the stdin reader).
531
+ void probeDaemon(port).then((listening) => {
532
+ if (onStreamDrop(listening) === OnDrop.REATTACH) {
533
+ connect(false);
534
+ return;
535
+ }
536
+ dormant = true;
537
+ proxyLog('reticle_mcp_proxy_dormant', {
538
+ port,
539
+ note: 'no daemon listening; will start one when the client next sends a request',
540
+ });
541
+ });
542
+ }, wait).unref();
543
+ }
544
+ function connect(first) {
545
+ // Announce this process to the daemon on the connect we already make — it is the single judge
546
+ // of version skew, and this is the only moment it learns the agent's MCP server exists.
547
+ const announce = `${MCP_SSE_PATH}?${PEER_VERSION_PARAM}=${encodeURIComponent(SERVER_VERSION)}&${PEER_CONTRACT_PARAM}=${encodeURIComponent(CONTRACT_FINGERPRINT)}`;
548
+ const req = http.get({ host: LOOPBACK_HOST, port, path: announce }, (res) => {
549
+ if (!first)
550
+ proxyLog('reticle_mcp_proxy_reconnected', { port });
551
+ res.setEncoding('utf8');
552
+ // `end` and `error` can both fire on one response; only the first should drive a reconnect.
553
+ let settled = false;
554
+ const drop = (reason, detail) => {
555
+ if (settled)
556
+ return;
557
+ settled = true;
558
+ // Answer anything the dead session owed BEFORE reconnecting. The new session cannot know
559
+ // about it, so silence here is permanent.
560
+ for (const reply of streamLossReplies(pending, reason))
561
+ emit(reply);
562
+ scheduleReconnect(reason, detail);
563
+ };
564
+ const sse = new SseFrameParser();
565
+ res.on('data', (chunk) => {
566
+ for (const frame of sse.push(chunk))
567
+ onSseEvent(frame.event, frame.data, port);
568
+ });
569
+ res.on('end', () => drop('sse_ended'));
570
+ res.on('error', (err) => drop('sse_error', err.message));
571
+ // A socket dying under us (daemon killed, network stack reset) emits NEITHER `end` nor
572
+ // `error` — only `aborted`/`close`. Listening for just the first two is why the proxy sat
573
+ // there afterwards holding a dead stream. `settled` keeps the clean case single-fire.
574
+ res.on('aborted', () => drop('sse_aborted'));
575
+ res.on('close', () => drop('sse_closed'));
576
+ });
577
+ // The very first connect failing means there is no daemon to talk to — that is a startup
578
+ // failure the caller handles. Later ones are just the daemon bouncing: keep retrying.
579
+ req.on('error', (err) => first ? reject(err) : scheduleReconnect('connect_error', err.message));
580
+ }
581
+ connect(true);
582
+ // ── stdin reader ─────────────────────────────────────────────────────────
583
+ process.stdin.setEncoding('utf8');
584
+ let handshakeAnswered = false;
585
+ /**
586
+ * Answer a queued `initialize` locally if the daemon has not produced an endpoint in time.
587
+ *
588
+ * Bounded because a hang is the worst outcome available here: no tools, no diagnosis, nothing to
589
+ * retry. Cancelled implicitly — once `postUrl` exists the queue flushes and the daemon answers,
590
+ * and `handshakeAnswered` keeps us from answering twice.
591
+ */
592
+ const armLocalHandshake = (line) => {
593
+ if (handshakeAnswered)
594
+ return;
595
+ const response = localInitializeResponse(line);
596
+ if (null === response)
597
+ return;
598
+ setTimeout(() => {
599
+ if (handshakeAnswered || postUrl !== null)
600
+ return;
601
+ handshakeAnswered = true;
602
+ // Drop the handshake from the queue. Otherwise the daemon, whenever it finally arrives,
603
+ // answers the SAME id a second time and the client sees two responses to one request — a
604
+ // corrupted stream, which is worse than the hang this replaces. The daemon still gets its own
605
+ // handshake: `replayLines` re-issues it under a distinct id and suppresses the echo.
606
+ for (let i = stdinQueue.length - 1; i >= 0; i--) {
607
+ const queued = stdinQueue[i];
608
+ if (queued !== undefined && isHandshakeLine(queued))
609
+ stdinQueue.splice(i, 1);
610
+ }
611
+ proxyLog('reticle_mcp_local_handshake', { port, reason: 'daemon did not answer in time' });
612
+ emit(response);
613
+ }, LOCAL_HANDSHAKE_MS).unref();
614
+ };
615
+ let stdinBuffer = '';
616
+ process.stdin.on('data', (chunk) => {
617
+ stdinBuffer += chunk;
618
+ const lines = stdinBuffer.split('\n');
619
+ stdinBuffer = lines.pop() ?? '';
620
+ for (const line of lines) {
621
+ const trimmed = line.trim();
622
+ if ('' === trimmed)
623
+ continue;
624
+ replay.observeOutbound(trimmed);
625
+ pending.observeOutbound(trimmed);
626
+ const action = onClientRequest(postUrl !== null, dormant);
627
+ if (action === OnRequest.SEND && postUrl !== null) {
628
+ forward(postUrl, trimmed);
629
+ continue;
630
+ }
631
+ // A queued `tools/list` with no daemon is the state that leaves an agent "connected with no
632
+ // tools" — the one a human has to fix by hand. If the catalog has been seen, answer it.
633
+ if (handshakeAnswered) {
634
+ const cached = catalog.answer(trimmed);
635
+ if (cached !== null) {
636
+ emit(cached);
637
+ continue;
638
+ }
639
+ }
640
+ stdinQueue.push(trimmed);
641
+ armQueueTimer();
642
+ // A queued `initialize` is the one message that must not wait indefinitely. If the daemon
643
+ // port is held by something that never serves SSE — wedged, foreign, or leaked by another
644
+ // project — the handshake never completes and the agent gets NO tools and no diagnosis.
645
+ // Answer it ourselves after a bounded wait; the daemon still receives the client's own
646
+ // initialize when a session is finally established (replayLines).
647
+ armLocalHandshake(trimmed);
648
+ // WAKE is the ONLY path allowed to start a daemon — see proxy-lifecycle.ts. The queue is
649
+ // flushed once the new session's `endpoint` frame arrives.
650
+ if (action === OnRequest.WAKE) {
651
+ dormant = false;
652
+ attempts = 0;
653
+ void (ensureDaemon?.() ?? Promise.resolve())
654
+ .then(() => connect(false))
655
+ .catch((err) => {
656
+ dormant = true;
657
+ proxyLog('reticle_mcp_proxy_wake_failed', {
658
+ port,
659
+ error: err instanceof Error ? err.message : String(err),
660
+ });
661
+ });
662
+ }
663
+ }
664
+ });
665
+ // The client going away is the one clean shutdown — stop reconnecting and exit.
666
+ //
667
+ // Exiting the INSTANT stdin ends drops whatever is still in flight and reports success for it: a
668
+ // client that wrote `initialize` + `tools/list` and closed in the same flush got the handshake
669
+ // answered, `tools/list` never, and exit 0 — so nothing supervising it could tell. A 4-second gap
670
+ // between the writes made it "work", which is the signature of a teardown race, not of a client
671
+ // that asked for too much. Drain first, and let the exit status carry the truth if it fails.
672
+ process.stdin.on('end', () => {
673
+ const quit = (code) => {
674
+ stopped = true;
675
+ process.exit(code);
676
+ };
677
+ if (0 === pending.unanswered.length) {
678
+ quit(0);
679
+ return;
680
+ }
681
+ const deadline = Date.now() + SHUTDOWN_DRAIN_MS;
682
+ const poll = setInterval(() => {
683
+ const stuck = pending.unanswered;
684
+ if (0 === stuck.length) {
685
+ clearInterval(poll);
686
+ quit(0);
687
+ return;
688
+ }
689
+ if (Date.now() < deadline)
690
+ return;
691
+ clearInterval(poll);
692
+ proxyLog('reticle_mcp_proxy_unanswered_at_exit', { port, ids: stuck });
693
+ quit(1);
694
+ }, SHUTDOWN_DRAIN_POLL_MS);
695
+ });
696
+ });
697
+ }