@reticlehq/server 2.7.0 → 2.9.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 (357) hide show
  1. package/README.md +14 -5
  2. package/SKILL.md +130 -778
  3. package/dist/bridge/bridge.d.ts +23 -0
  4. package/dist/bridge/bridge.js +80 -2
  5. package/dist/bridge/bridge.test-harness.d.ts +5 -0
  6. package/dist/bridge/bridge.test-harness.js +6 -4
  7. package/dist/capsule/causal-summary.d.ts +36 -1
  8. package/dist/capsule/causal-summary.js +33 -7
  9. package/dist/capsule/divergence.js +7 -1
  10. package/dist/cli/chromium-hint.d.ts +33 -5
  11. package/dist/cli/chromium-hint.js +82 -12
  12. package/dist/cli/cli-doctor.js +65 -34
  13. package/dist/cli/cli-flow-commands.js +8 -8
  14. package/dist/cli/cli-kill.d.ts +58 -0
  15. package/dist/cli/cli-kill.js +162 -0
  16. package/dist/cli/cli-parse.d.ts +16 -1
  17. package/dist/cli/cli-parse.js +32 -9
  18. package/dist/cli/cli-port.d.ts +32 -0
  19. package/dist/cli/cli-port.js +82 -35
  20. package/dist/cli/cli-verify.d.ts +21 -0
  21. package/dist/cli/cli-verify.js +47 -3
  22. package/dist/cli/config-discovery.d.ts +35 -0
  23. package/dist/cli/config-discovery.js +163 -0
  24. package/dist/cli/doctor-daemon-line.js +4 -1
  25. package/dist/cli/doctor-project-line.d.ts +37 -0
  26. package/dist/cli/doctor-project-line.js +35 -0
  27. package/dist/cli/doctor-rows.d.ts +51 -0
  28. package/dist/cli/doctor-rows.js +54 -0
  29. package/dist/cli/doctor-sessions-line.d.ts +12 -0
  30. package/dist/cli/doctor-sessions-line.js +69 -0
  31. package/dist/cli/drive-attach.d.ts +99 -0
  32. package/dist/cli/drive-attach.js +203 -0
  33. package/dist/cli/drive-command.d.ts +13 -0
  34. package/dist/cli/drive-command.js +103 -0
  35. package/dist/cli/open-note.d.ts +20 -0
  36. package/dist/cli/open-note.js +37 -0
  37. package/dist/cli/port-holder.d.ts +10 -0
  38. package/dist/cli/port-holder.js +26 -2
  39. package/dist/cli/status-next-action.d.ts +37 -0
  40. package/dist/cli/status-next-action.js +61 -0
  41. package/dist/cli.js +119 -39
  42. package/dist/crawl/crawl-tools.js +12 -2
  43. package/dist/crawl/crawl.d.ts +13 -0
  44. package/dist/crawl/crawl.js +50 -2
  45. package/dist/events/contradictions.d.ts +46 -0
  46. package/dist/events/contradictions.js +35 -22
  47. package/dist/events/declared.d.ts +45 -0
  48. package/dist/events/declared.js +73 -0
  49. package/dist/events/echo-mismatch.d.ts +8 -2
  50. package/dist/events/echo-mismatch.js +33 -5
  51. package/dist/events/event-filters.d.ts +1 -1
  52. package/dist/events/event-filters.js +7 -4
  53. package/dist/events/predicate-eval.d.ts +17 -72
  54. package/dist/events/predicate-eval.js +117 -236
  55. package/dist/events/predicate-parse.js +31 -3
  56. package/dist/events/predicate-route.d.ts +32 -0
  57. package/dist/events/predicate-route.js +102 -0
  58. package/dist/events/predicate-schema.d.ts +99 -0
  59. package/dist/events/predicate-schema.js +309 -0
  60. package/dist/events/predicate.d.ts +8 -0
  61. package/dist/events/predicate.js +296 -22
  62. package/dist/flows/annotate-tools.js +3 -0
  63. package/dist/flows/annotate.js +18 -4
  64. package/dist/flows/coverage.d.ts +38 -5
  65. package/dist/flows/coverage.js +30 -4
  66. package/dist/flows/decision.d.ts +15 -0
  67. package/dist/flows/decision.js +1 -1
  68. package/dist/flows/flow-classify.js +1 -1
  69. package/dist/flows/flow-replay-run.js +8 -1
  70. package/dist/flows/flow-tools.js +21 -5
  71. package/dist/flows/heal-run.js +1 -1
  72. package/dist/flows/replay.js +3 -0
  73. package/dist/honesty/accepted-write.d.ts +3 -0
  74. package/dist/honesty/accepted-write.js +8 -3
  75. package/dist/honesty/already-true.js +15 -0
  76. package/dist/honesty/blind-spots.js +10 -2
  77. package/dist/honesty/honesty.d.ts +11 -0
  78. package/dist/honesty/honesty.js +1 -0
  79. package/dist/honesty/uncaptured-bodies.d.ts +4 -2
  80. package/dist/honesty/uncaptured-bodies.js +9 -2
  81. package/dist/honesty/unread-outcome.d.ts +8 -0
  82. package/dist/honesty/unread-outcome.js +36 -9
  83. package/dist/honesty/unsettled.d.ts +49 -0
  84. package/dist/honesty/unsettled.js +82 -0
  85. package/dist/honesty/verified.d.ts +36 -3
  86. package/dist/honesty/verified.js +54 -11
  87. package/dist/http-server.d.ts +19 -0
  88. package/dist/http-server.js +202 -4
  89. package/dist/impact/impact-recorder.d.ts +23 -0
  90. package/dist/impact/impact-recorder.js +76 -0
  91. package/dist/impact/impact-store.d.ts +47 -0
  92. package/dist/impact/impact-store.js +171 -0
  93. package/dist/index.d.ts +7 -0
  94. package/dist/index.js +47 -10
  95. package/dist/init/agent-rules.d.ts +36 -0
  96. package/dist/init/agent-rules.js +132 -22
  97. package/dist/init/astro-patch.d.ts +2 -1
  98. package/dist/init/astro-patch.js +38 -6
  99. package/dist/init/capabilities.d.ts +22 -0
  100. package/dist/init/capabilities.js +76 -0
  101. package/dist/init/confirm.d.ts +114 -0
  102. package/dist/init/confirm.js +165 -0
  103. package/dist/init/connect-steps.d.ts +2 -0
  104. package/dist/init/connect-steps.js +50 -0
  105. package/dist/init/cra.js +3 -2
  106. package/dist/init/csp-check.d.ts +38 -0
  107. package/dist/init/csp-check.js +92 -0
  108. package/dist/init/csp-doctor.d.ts +25 -0
  109. package/dist/init/csp-doctor.js +49 -0
  110. package/dist/init/cursor.js +6 -9
  111. package/dist/init/detect.d.ts +8 -0
  112. package/dist/init/detect.js +33 -3
  113. package/dist/init/mcp-clients.d.ts +1 -0
  114. package/dist/init/mcp-clients.js +51 -7
  115. package/dist/init/mcp.d.ts +15 -0
  116. package/dist/init/mcp.js +19 -0
  117. package/dist/init/non-js-project.js +29 -5
  118. package/dist/init/plan-framework.d.ts +10 -0
  119. package/dist/init/plan-framework.js +78 -16
  120. package/dist/init/plan.d.ts +47 -6
  121. package/dist/init/plan.js +228 -69
  122. package/dist/init/run.d.ts +24 -2
  123. package/dist/init/run.js +157 -45
  124. package/dist/init/slash-command.d.ts +1 -1
  125. package/dist/init/slash-command.js +54 -6
  126. package/dist/init/snippets.d.ts +45 -5
  127. package/dist/init/snippets.js +194 -42
  128. package/dist/init/vite-config.js +53 -5
  129. package/dist/input/network-mock-tools.d.ts +19 -0
  130. package/dist/input/network-mock-tools.js +37 -5
  131. package/dist/input/scroll-find.d.ts +6 -0
  132. package/dist/input/scroll-find.js +17 -2
  133. package/dist/input/scroll-tools.js +4 -0
  134. package/dist/journal/attach-journal.d.ts +9 -1
  135. package/dist/journal/attach-journal.js +13 -2
  136. package/dist/journal/journal-query.d.ts +13 -0
  137. package/dist/journal/journal-query.js +28 -3
  138. package/dist/journal/workspace-gitignore.d.ts +10 -0
  139. package/dist/journal/workspace-gitignore.js +59 -0
  140. package/dist/license/license.js +1 -1
  141. package/dist/mcp/mcp-proxy.d.ts +35 -30
  142. package/dist/mcp/mcp-proxy.js +306 -104
  143. package/dist/mcp/mcp.d.ts +10 -1
  144. package/dist/mcp/mcp.js +70 -34
  145. package/dist/mcp/proxy-handshake.d.ts +82 -1
  146. package/dist/mcp/proxy-handshake.js +109 -2
  147. package/dist/mcp/proxy-lifecycle.d.ts +11 -2
  148. package/dist/mcp/proxy-lifecycle.js +12 -3
  149. package/dist/mcp/proxy-log.d.ts +63 -0
  150. package/dist/mcp/proxy-log.js +132 -0
  151. package/dist/mcp/server-instructions.d.ts +17 -0
  152. package/dist/mcp/server-instructions.js +56 -0
  153. package/dist/mcp/shared-params.d.ts +27 -0
  154. package/dist/mcp/shared-params.js +36 -0
  155. package/dist/pool/browser-pool.d.ts +22 -2
  156. package/dist/pool/browser-pool.js +44 -4
  157. package/dist/project/learned-routes.d.ts +24 -0
  158. package/dist/project/learned-routes.js +63 -0
  159. package/dist/project/project-store.d.ts +6 -0
  160. package/dist/project/project-store.js +25 -1
  161. package/dist/project/reticle-dir.d.ts +2 -0
  162. package/dist/project/reticle-dir.js +1 -0
  163. package/dist/session/connection-memory.d.ts +59 -0
  164. package/dist/session/connection-memory.js +108 -0
  165. package/dist/session/dev-command.d.ts +26 -0
  166. package/dist/session/dev-command.js +89 -0
  167. package/dist/session/dev-server-probe.d.ts +68 -0
  168. package/dist/session/dev-server-probe.js +102 -7
  169. package/dist/session/last-act.d.ts +26 -0
  170. package/dist/session/last-act.js +35 -1
  171. package/dist/session/live-control-tools.js +42 -5
  172. package/dist/session/no-session-diagnosis.d.ts +67 -2
  173. package/dist/session/no-session-diagnosis.js +236 -27
  174. package/dist/session/no-session-next-action.d.ts +46 -0
  175. package/dist/session/no-session-next-action.js +85 -0
  176. package/dist/session/no-session-watch.d.ts +40 -1
  177. package/dist/session/no-session-watch.js +174 -8
  178. package/dist/session/pending-commands.d.ts +12 -0
  179. package/dist/session/pending-commands.js +13 -1
  180. package/dist/session/ref-provenance.d.ts +54 -0
  181. package/dist/session/ref-provenance.js +61 -0
  182. package/dist/session/session-lease.js +1 -1
  183. package/dist/session/session-manager.d.ts +4 -0
  184. package/dist/session/session-manager.js +35 -5
  185. package/dist/session/session.d.ts +10 -1
  186. package/dist/session/session.js +146 -5
  187. package/dist/telemetry/app-instrumented.d.ts +8 -0
  188. package/dist/telemetry/app-instrumented.js +10 -0
  189. package/dist/telemetry/automation-hint.d.ts +17 -0
  190. package/dist/telemetry/automation-hint.js +71 -0
  191. package/dist/telemetry/bug-found.d.ts +15 -17
  192. package/dist/telemetry/bug-found.js +52 -21
  193. package/dist/telemetry/cli-telemetry.js +7 -1
  194. package/dist/telemetry/daemon-telemetry.js +24 -0
  195. package/dist/telemetry/feedback.js +1 -1
  196. package/dist/telemetry/init-telemetry.js +9 -1
  197. package/dist/telemetry/install-source.d.ts +56 -0
  198. package/dist/telemetry/install-source.js +74 -0
  199. package/dist/telemetry/instrumentation-stall.d.ts +50 -0
  200. package/dist/telemetry/instrumentation-stall.js +90 -0
  201. package/dist/telemetry/run-telemetry.js +4 -1
  202. package/dist/telemetry/session-metrics.d.ts +8 -0
  203. package/dist/telemetry/session-metrics.js +15 -0
  204. package/dist/telemetry/telemetry.d.ts +7 -1
  205. package/dist/telemetry/telemetry.js +26 -2
  206. package/dist/telemetry/tool-refused.d.ts +19 -0
  207. package/dist/telemetry/tool-refused.js +55 -0
  208. package/dist/telemetry/verification-of.js +46 -7
  209. package/dist/temp-dir.d.ts +27 -0
  210. package/dist/temp-dir.js +46 -0
  211. package/dist/tools/act-tools.js +209 -28
  212. package/dist/tools/assert-grade.d.ts +1 -1
  213. package/dist/tools/assert-grade.js +27 -5
  214. package/dist/tools/assert-verdict.js +49 -7
  215. package/dist/tools/browser-tools.js +10 -0
  216. package/dist/tools/contract-tools.js +4 -0
  217. package/dist/tools/dynamic-tools.js +16 -7
  218. package/dist/tools/error-recovery.d.ts +10 -0
  219. package/dist/tools/error-recovery.js +73 -9
  220. package/dist/tools/feedback-invite.js +1 -1
  221. package/dist/tools/invoke-tool.js +159 -26
  222. package/dist/tools/lease-hint.d.ts +30 -8
  223. package/dist/tools/lease-hint.js +65 -14
  224. package/dist/tools/lease-tools.d.ts +40 -1
  225. package/dist/tools/lease-tools.js +181 -83
  226. package/dist/tools/navigate-result.js +1 -1
  227. package/dist/tools/observe-tools.js +10 -4
  228. package/dist/tools/observed-nothing.js +1 -1
  229. package/dist/tools/react-grace.d.ts +52 -0
  230. package/dist/tools/react-grace.js +90 -0
  231. package/dist/tools/read-tools.js +67 -5
  232. package/dist/tools/resolve-target.d.ts +33 -0
  233. package/dist/tools/resolve-target.js +53 -0
  234. package/dist/tools/settle-in-flight.d.ts +94 -0
  235. package/dist/tools/settle-in-flight.js +146 -0
  236. package/dist/tools/snapshot-delta.d.ts +10 -0
  237. package/dist/tools/snapshot-delta.js +194 -9
  238. package/dist/tools/tool-kit.d.ts +8 -0
  239. package/dist/tools/tool-surface.d.ts +60 -0
  240. package/dist/tools/tool-surface.js +86 -1
  241. package/dist/tools/tools.js +61 -8
  242. package/dist/tools/unadvertised-help.js +9 -6
  243. package/docs/README.md +55 -11
  244. package/docs/actions.mdx +120 -0
  245. package/docs/agent-cheatsheet.md +95 -29
  246. package/docs/architecture.md +52 -35
  247. package/docs/benchmarks.md +127 -75
  248. package/docs/best-practices.mdx +216 -0
  249. package/docs/capabilities.mdx +534 -0
  250. package/docs/cli/affected.mdx +53 -0
  251. package/docs/cli/capsules.mdx +70 -0
  252. package/docs/cli/cloud.mdx +153 -0
  253. package/docs/cli/doctor.mdx +109 -0
  254. package/docs/cli/drive.mdx +74 -0
  255. package/docs/cli/feedback.mdx +67 -0
  256. package/docs/cli/gate.mdx +64 -0
  257. package/docs/cli/help.mdx +94 -0
  258. package/docs/cli/hunt.mdx +73 -0
  259. package/docs/cli/identify.mdx +76 -0
  260. package/docs/cli/init.mdx +121 -0
  261. package/docs/cli/kill.mdx +91 -0
  262. package/docs/cli/license.mdx +48 -0
  263. package/docs/cli/mcp.mdx +61 -0
  264. package/docs/cli/open.mdx +74 -0
  265. package/docs/cli/restart.mdx +60 -0
  266. package/docs/cli/rollback.mdx +35 -0
  267. package/docs/cli/serve.mdx +69 -0
  268. package/docs/cli/status.mdx +88 -0
  269. package/docs/cli/stop.mdx +64 -0
  270. package/docs/cli/telemetry.mdx +57 -0
  271. package/docs/cli/update.mdx +52 -0
  272. package/docs/cli/verify.mdx +76 -0
  273. package/docs/cli/version.mdx +39 -0
  274. package/docs/cli/watch.mdx +62 -0
  275. package/docs/cli.mdx +241 -0
  276. package/docs/debugging.md +16 -10
  277. package/docs/deploy-checks.md +45 -12
  278. package/docs/desktop.mdx +384 -0
  279. package/docs/docs.json +364 -0
  280. package/docs/enterprise.md +22 -16
  281. package/docs/faq.mdx +102 -0
  282. package/docs/fixtures.md +13 -7
  283. package/docs/flows.md +29 -23
  284. package/docs/for-agents.md +71 -0
  285. package/docs/frameworks.mdx +238 -0
  286. package/docs/gate-plan.md +48 -42
  287. package/docs/gates.md +28 -22
  288. package/docs/getting-started.md +129 -77
  289. package/docs/human-control.md +24 -18
  290. package/docs/index.mdx +143 -0
  291. package/docs/install-agentic.mdx +224 -0
  292. package/docs/install-manual.mdx +196 -0
  293. package/docs/instrumentation.mdx +238 -0
  294. package/docs/integration-patterns.md +41 -35
  295. package/docs/local-registry.md +19 -13
  296. package/docs/multi-agent-testing.md +19 -13
  297. package/docs/packages/babel-plugin.mdx +79 -0
  298. package/docs/packages/browser.mdx +111 -0
  299. package/docs/packages/core.mdx +116 -0
  300. package/docs/packages/electron.mdx +85 -0
  301. package/docs/packages/eslint-plugin.mdx +86 -0
  302. package/docs/packages/next.mdx +89 -0
  303. package/docs/packages/react.mdx +91 -0
  304. package/docs/packages/server.mdx +98 -0
  305. package/docs/packages/tauri.mdx +63 -0
  306. package/docs/packages/test.mdx +119 -0
  307. package/docs/packages/vite-plugin.mdx +94 -0
  308. package/docs/packages.mdx +218 -0
  309. package/docs/platform-integration.md +35 -26
  310. package/docs/predicates.mdx +248 -0
  311. package/docs/quickstart.mdx +257 -0
  312. package/docs/recipes.mdx +282 -0
  313. package/docs/skill-file.mdx +107 -0
  314. package/docs/state-management.mdx +199 -0
  315. package/docs/style.css +14 -0
  316. package/docs/system-map.md +50 -33
  317. package/docs/telemetry-contract.md +106 -52
  318. package/docs/telemetry-events.mdx +475 -0
  319. package/docs/telemetry.md +63 -55
  320. package/docs/testing.md +44 -6
  321. package/docs/token-efficiency.md +37 -27
  322. package/docs/tools-act-and-wait.mdx +173 -0
  323. package/docs/tools-act-sequence.mdx +137 -0
  324. package/docs/tools-act.mdx +95 -0
  325. package/docs/tools-assert.mdx +129 -0
  326. package/docs/tools-console.mdx +63 -0
  327. package/docs/tools-inspect.mdx +88 -0
  328. package/docs/tools-navigate.mdx +99 -0
  329. package/docs/tools-network.mdx +119 -0
  330. package/docs/tools-observe.mdx +145 -0
  331. package/docs/tools-overview.mdx +160 -0
  332. package/docs/tools-query.mdx +126 -0
  333. package/docs/tools-session-and-feedback.mdx +112 -0
  334. package/docs/tools-sessions.mdx +94 -0
  335. package/docs/tools-snapshot.mdx +129 -0
  336. package/docs/tools-state.mdx +104 -0
  337. package/docs/tools-tools-and-run.mdx +156 -0
  338. package/docs/tools-wait-for.mdx +85 -0
  339. package/docs/troubleshooting.mdx +263 -0
  340. package/docs/usage.md +209 -165
  341. package/docs/vs-chrome-devtools-mcp.mdx +123 -0
  342. package/docs/vs-playwright-mcp.mdx +125 -0
  343. package/docs/vs-screenshots.mdx +137 -0
  344. package/docs/what-is-reticle.mdx +63 -0
  345. package/docs/why-reticle.mdx +109 -0
  346. package/package.json +5 -5
  347. package/docs/desktop-apps.md +0 -256
  348. package/docs/matrix/2.5.0/claude-code-machine.json +0 -19
  349. package/docs/matrix/2.5.0/codex-machine.json +0 -20
  350. package/docs/matrix/2.5.0/cursor-machine.json +0 -22
  351. package/docs/matrix/2.5.0/gemini-machine.json +0 -22
  352. package/docs/matrix/2.5.0/opencode-machine.json +0 -22
  353. package/docs/matrix/2.5.0/vscode-machine.json +0 -22
  354. package/docs/matrix/2.5.0/windsurf-machine.json +0 -22
  355. package/docs/matrix/MATRIX.md +0 -26
  356. package/docs/matrix/README.md +0 -59
  357. package/docs/mint.json +0 -61
package/dist/mcp/mcp.js CHANGED
@@ -5,11 +5,14 @@ import { PredicateSchema } from '../events/predicate.js';
5
5
  import { isToonable, resultToToon } from '@reticlehq/core';
6
6
  import { TOOLS } from '../tools/tools.js';
7
7
  import { filterTools, describeToolSurface, TOOL_SURFACE, } from '../tools/tool-surface.js';
8
+ import { ReticleTool } from '../tools/tool-names.js';
9
+ import { SHARED_PARAM_SHORT } from './shared-params.js';
8
10
  import { buildDynamicTools } from '../tools/dynamic-tools.js';
9
11
  import { runTool, SESSION_BOUND_TOOLS } from '../tools/invoke-tool.js';
10
12
  import { sessionEnvelopeShape } from '../tools/tool-kit.js';
11
13
  import { buildErrorPayload } from '../tools/error-recovery.js';
12
14
  import { resultIsError } from './mcp-is-error.js';
15
+ import { buildServerInstructions } from './server-instructions.js';
13
16
  import { unadvertisedToolHelp } from '../tools/unadvertised-help.js';
14
17
  /** The JSON-RPC method the SDK registers its tool dispatcher under. */
15
18
  const CALL_TOOL_METHOD = 'tools/call';
@@ -31,27 +34,6 @@ export function withSessionEnvelope(name, outputSchema) {
31
34
  return { ...sessionEnvelopeShape, ...outputSchema };
32
35
  }
33
36
  const SERVER_INFO = { name: MCP_SERVER_NAME, version: SERVER_VERSION };
34
- /**
35
- * What every agent is told at `initialize`, before it has called a single tool.
36
- *
37
- * This is the only channel that reaches an agent with NO dependency on the project: no CLAUDE.md, no
38
- * AGENTS.md, no pasted skill, no successful `reticle init`. An agent whose setup half-worked has read
39
- * none of those files and is precisely the agent whose report we need — so the standing instruction
40
- * to send feedback has to live here, where it arrives with the connection itself.
41
- *
42
- * Kept to a handful of lines: it is prepended to every session's context, so each sentence is paid
43
- * for on every turn, forever.
44
- */
45
- const SERVER_INSTRUCTIONS = `Reticle verifies a running web app from the inside: look (reticle_snapshot / reticle_query), act and prove in one hop (reticle_act_and_wait), observe (reticle_state / reticle_network / reticle_console), assert (reticle_assert). Verify a user-facing change against the real app before you call it done, and never weaken a check to make it pass.
46
-
47
- Only reticle_act_and_wait and reticle_assert produce a verdict. reticle_act and everything else move or read the app and prove nothing, so a drive that ends without one of those two has no result however many tools it used. Prefer reticle_act_and_wait({ ref, action, until }) — it names the expected consequence BEFORE the action, which is the difference between a check and a rationalisation. A verdict of verified:"unknown" is not a pass: it means Reticle drove the app and could not tell what happened. Report it as unknown.
48
-
49
- Feedback is first-class here, not a courtesy. Reticle is built FOR agents, so your experience is the only signal that decides what gets built or fixed — and it disappears when your context does. Send it with \`reticle_feedback\` the moment you notice, then carry on with your task:
50
- - Reticle broke, lied, or could not see something you needed (\`bug\` / \`gap\` / \`ambiguity\`).
51
- - You wished it could do something, or something works but costs too many calls (\`feature_request\` / \`improvement\`).
52
- - The install, the wiring, or the setup itself gave you trouble — including anything you had to work around by hand.
53
-
54
- Nothing is too minor, and a report costs one call. If the tools are unreachable (setup unfinished, daemon down), file the same report from the shell instead: \`reticle feedback --agent --kind <bug|gap|ambiguity|feature_request|improvement> "what happened"\`. Report defects in RETICLE — a bug you find in the app under test is Reticle working, and belongs in your answer to the user.`;
55
37
  /** First sentence of a description (purpose only) for lean profiles — keeps per-turn def cost
56
38
  * down. Cuts at the first sentence-ending period or newline; falls back to a 160-char cap. The
57
39
  * first clause retains the essentials (enum hints like "role | text | label" live there). */
@@ -144,8 +126,10 @@ const PREDICATE_KINDS = 'Predicate object: { kind, ...fields }. kind is one of e
144
126
  * paying for six.
145
127
  */
146
128
  const PREDICATE_FIELD_GRAMMAR_HINT = ' Bug-catching options: net.count (exact request count — catches double-submit), ' +
147
- 'console.absent:true (action completed with a CLEAN console), absent:true on element/text ' +
148
- '(it should be gone). Call reticle_tools for the full field grammar of a kind.';
129
+ 'net.bodyContains (a substring of what the SERVER answered — catches a UI that echoes the input ' +
130
+ 'it sent instead of the result it got back), console.absent:true (action completed with a CLEAN ' +
131
+ 'console), absent:true on element/text (it should be gone). Call reticle_tools for the full field ' +
132
+ 'grammar of a kind.';
149
133
  const COMPACT_PREDICATE_DESCRIPTION = `${PREDICATE_KINDS}${PREDICATE_FIELD_GRAMMAR_HINT}`;
150
134
  /**
151
135
  * Lean copy of a tool's zod input shape for lean profiles: each parameter's description is
@@ -170,6 +154,16 @@ function leanZodShape(shape, predicateAnchor) {
170
154
  out[key] = schema.isOptional() ? compact.optional() : compact;
171
155
  continue;
172
156
  }
157
+ // Shared boilerplate is documented once in the initialize instructions instead of on every
158
+ // tool, every turn. `sessionId` alone was 2,076 bytes of identical text across 16 tools.
159
+ const shared = SHARED_PARAM_SHORT[key];
160
+ if (shared !== undefined) {
161
+ // `.describe` returns the same schema with new prose, so optionality carries over. Re-wrapping
162
+ // in `.optional()` would nest one inside another and change the parameter's TYPE, which is a
163
+ // thing this lean path is meant to do for predicates and nothing else.
164
+ out[key] = schema.describe(shared);
165
+ continue;
166
+ }
173
167
  const desc = schema.description;
174
168
  out[key] = 'string' === typeof desc ? schema.describe(firstSentence(desc)) : schema;
175
169
  }
@@ -228,10 +222,25 @@ export function advertisedConfig(tool, advertised, profile) {
228
222
  // sentence and the agent would have nothing left to learn the surface from. Measured when it
229
223
  // happened: dynamic's tools/list fell 1,543 -> 849 bytes, which looks like a saving and is a
230
224
  // capability loss.
231
- const terse = profile === TOOL_SURFACE.DEFAULT;
225
+ //
226
+ // The two meta-tools are exempt, on every surface. Their descriptions are not a description OF a
227
+ // capability, they ARE the capability: the only place an agent is ever told that the tools it can
228
+ // see are not all the tools there are, and how to reach the rest. Trimmed to a first sentence,
229
+ // `reticle_tools` advertises as "Discover Reticle tools on demand." — which names no way to list
230
+ // anything, so a well-behaved agent has no reason to call it and the entire cold tail goes dark.
231
+ // The comment above already caught this for the retired `dynamic` profile; `default` is now both
232
+ // the lean surface and the one carrying the meta-tools, so the exemption follows it.
233
+ const isMetaTool = tool.name === ReticleTool.TOOLS || tool.name === ReticleTool.RUN;
234
+ // `verify` is lean for the same reason `default` is, and more so: it exists to make a verdict
235
+ // cheap, and it was serving FULL descriptions plus outputSchema because the check named only the
236
+ // default. That put 8,207 bytes on the wire for three tools.
237
+ const lean = profile === TOOL_SURFACE.DEFAULT || profile === TOOL_SURFACE.VERIFY;
238
+ const terse = lean && !isMetaTool;
232
239
  // The first advertised tool carrying a predicate spells the grammar out; the rest point at it.
233
240
  const anchor = advertised.find((t) => Object.values(t.inputSchema).some((schema) => isPredicateParam(schema)))?.name;
234
- const outputSchema = terse ? undefined : withSessionEnvelope(tool.name, tool.outputSchema);
241
+ // Output schemas are dropped on every lean surface, meta-tools included: they are the single
242
+ // largest component and nothing on a lean surface validates against them.
243
+ const outputSchema = lean ? undefined : withSessionEnvelope(tool.name, tool.outputSchema);
235
244
  return {
236
245
  description: withExample(terse ? firstSentence(tool.description) : tool.description, tool.example),
237
246
  inputSchema: terse
@@ -346,22 +355,49 @@ function toolNameOf(request) {
346
355
  const name = params.name;
347
356
  return 'string' === typeof name ? name : undefined;
348
357
  }
349
- export function createMcpServer(deps, profile = TOOL_SURFACE.DEFAULT) {
358
+ export function createMcpServer(deps, profile = TOOL_SURFACE.DEFAULT,
359
+ /**
360
+ * Has an app ever connected for this project? Decides what the instructions LEAD with.
361
+ *
362
+ * Passed in rather than read here, so construction stays free of disk I/O and the decision is
363
+ * testable without a state directory. Defaulting to `false` is the deliberate safe side: an agent
364
+ * told to instrument a project that is already wired loses a paragraph, while an agent not told
365
+ * loses the entire install — and the second is what the field overwhelmingly shows.
366
+ */
367
+ previouslyConnected = false) {
350
368
  const encoding = (process.env[ENCODING_ENV] ?? '').toLowerCase();
351
369
  // Which surface this daemon advertises. The 18-tool default and the 48-tool full surface are
352
370
  // different products from inside an agent's context, so outcomes are only comparable when the
353
371
  // session says which one it saw.
354
372
  getSessionMetrics().recordSurface(profile);
355
- const server = new McpServer(SERVER_INFO, { instructions: SERVER_INSTRUCTIONS });
356
- // Which agent is on the other end, taken from its own `initialize` handshake. Registered as a lazy
357
- // hook rather than read here: the handshake has not happened yet at construction time, and a
358
- // feedback report filed twenty tool calls later is exactly when we want the answer.
373
+ const server = new McpServer(SERVER_INFO, {
374
+ instructions: buildServerInstructions({ previouslyConnected }),
375
+ });
376
+ /**
377
+ * Record WHICH agent attached, at the handshake, for every session.
378
+ *
379
+ * `clients` and `clientVersions` were populated for almost nobody, and the reason was not that
380
+ * clients withhold `clientInfo` — they send it, and we read it. It was that the ONLY thing that
381
+ * ever called `recordClient` was the feedback hook below, which runs when a report is being built.
382
+ * So the field was really measuring "sessions that filed feedback", and a product whose users are
383
+ * agents could not say which agent, on which build, any of its numbers came from.
384
+ *
385
+ * `oninitialized` is the moment `clientInfo` first exists — reading it at construction time gets
386
+ * `undefined`, which is what made a lazy hook look like the only option.
387
+ */
388
+ server.server.oninitialized = () => {
389
+ const info = server.server.getClientVersion();
390
+ if (info?.name !== undefined)
391
+ getSessionMetrics().recordClient(info.name, info.version);
392
+ };
393
+ // Which agent is on the other end, for a feedback report. Registered as a lazy hook rather than
394
+ // read here: the handshake has not happened yet at construction time, and a report filed twenty
395
+ // tool calls later is exactly when we want the answer.
359
396
  setMcpClientNameHook(() => {
360
397
  const info = server.server.getClientVersion();
361
- // Record it as a side effect of the first read so the session summary can report WHICH agents
362
- // drove this daemon — the multi-agent story, visible without a second plumbing path.
363
- // Name AND version: the version was already in this handshake and was being dropped, so a
364
- // regression in agent behaviour could never be attributed to a client build.
398
+ // Still records, because a client that skipped the handshake path above (a replayed initialize
399
+ // through the proxy, a transport that reconnects) is a client we would otherwise lose entirely.
400
+ // `recordClient` is idempotent per name.
365
401
  if (info?.name !== undefined)
366
402
  getSessionMetrics().recordClient(info.name, info.version);
367
403
  return info === undefined ? undefined : { name: info.name, version: info.version };
@@ -1,3 +1,84 @@
1
+ /**
2
+ * The protocol's own way to say "the tool list you have is out of date, fetch it again".
3
+ *
4
+ * Load-bearing here rather than a nicety. When we answer `initialize` ourselves the catalog we serve
5
+ * is one we made up, and it is usually empty. Nothing else in the protocol ever corrects that: a
6
+ * client re-lists only when told to, and every other recovery path in the proxy is driven by the
7
+ * NEXT client request, which a client holding no tools never makes. Without this the session is
8
+ * connected, initialized and toolless until a human notices.
9
+ */
10
+ export declare const TOOLS_CHANGED_NOTIFICATION = "notifications/tools/list_changed";
1
11
  /** True when this queued client line is the handshake we answered ourselves. */
2
12
  export declare function isHandshakeLine(line: string): boolean;
3
- export declare function localInitializeResponse(line: string): string | null;
13
+ export declare function localInitializeResponse(line: string,
14
+ /**
15
+ * What the daemon would have advertised, so the client is not left with none.
16
+ *
17
+ * A client reads `instructions` ONCE, at initialize, and this response is sent precisely when no
18
+ * daemon is up to send its own — which is the first run of a fresh install. So the block telling
19
+ * someone that having these tools is not the same as being set up was permanently absent for the
20
+ * exact population it addresses, and the daemon's later correct instructions arrive at a client
21
+ * that will never read the field again.
22
+ *
23
+ * Empty means "nothing to say", and the field is then omitted rather than sent blank.
24
+ */
25
+ instructions?: string): string | null;
26
+ /**
27
+ * The line that tells a client its tool list is stale, or null when there is nothing to correct.
28
+ *
29
+ * When a daemon arrives after we answered the handshake ourselves, the client is holding a tool list
30
+ * we invented, and on a cold start that list is empty. Nothing else in this protocol corrects it: a
31
+ * client re-lists only when told to, and every other recovery path in the proxy is driven by the
32
+ * NEXT client request, which a client holding no tools never makes. Connected, initialized and
33
+ * toolless for the rest of the session, with a human required to notice and reconnect by hand.
34
+ *
35
+ * Sent only when the catalog was ours. A client that completed a real handshake with the daemon
36
+ * already has the true list, and telling it to refetch would be a round trip for nothing.
37
+ *
38
+ * Lives here rather than in the proxy because it belongs to the locally-answered handshake, which is
39
+ * this module's subject.
40
+ *
41
+ * Worth knowing before relying on it: this is a best-effort correction, not the mechanism. Research
42
+ * into the four clients we target found that only some honour `notifications/tools/list_changed` at
43
+ * all, so the catalog still has to be right in the FIRST answer for the rest. This closes the gap
44
+ * where it is honoured and costs one line where it is not.
45
+ */
46
+ export declare function toolsChangedNotification(catalogWasLocal: boolean): string | null;
47
+ /**
48
+ * The largest single JSON-RPC line the proxy will accumulate from a client.
49
+ *
50
+ * Generous — a tool call with a big argument payload is legitimate — but finite, because without a
51
+ * ceiling the buffer grows to whatever is sent and the process is one `cat bigfile` away from an
52
+ * out-of-memory exit that would take the agent's whole tool surface with it.
53
+ */
54
+ export declare const MAX_STDIN_LINE_BYTES: number;
55
+ export interface DrainedLines {
56
+ /** Complete lines, in order. Empty when the chunk carried no newline. */
57
+ lines: string[];
58
+ /** The trailing partial line, carried into the next chunk. */
59
+ rest: string;
60
+ /** A single line exceeded the cap and was discarded rather than accumulated. */
61
+ overflowed: boolean;
62
+ /**
63
+ * Mid-discard: an oversized line was dropped and the REST of it is still arriving.
64
+ *
65
+ * Carried back in on the next call, and it is what makes dropping safe rather than merely tidy.
66
+ * Without it the tail of the discarded line arrives looking like a complete line and is forwarded
67
+ * as a JSON-RPC message — a fragment of something nobody sent, failing to parse, on a link where
68
+ * the client is waiting for answers to real requests.
69
+ */
70
+ discarding: boolean;
71
+ }
72
+ /**
73
+ * Append a chunk and split off whatever complete lines it produced.
74
+ *
75
+ * The reason this is not `(buffer + chunk).split('\n')` inline: that re-scans the WHOLE accumulated
76
+ * buffer on every chunk, so a single large line costs O(n²) in the size of the line. Measured
77
+ * against the real proxy, a 32 MB line took ~8s and a 50 MB line ~35s — with the event loop pinned
78
+ * throughout, so every other tool call on that link was frozen with no response and no error. The
79
+ * client sees the whole MCP surface hang.
80
+ *
81
+ * A chunk with no newline in it cannot complete a line, so there is nothing to split and the cost is
82
+ * the concatenation alone. Only a chunk that actually carries a newline pays for a scan.
83
+ */
84
+ export declare function drainLines(buffer: string, chunk: string, maxLineBytes?: number, discarding?: boolean): DrainedLines;
@@ -18,6 +18,16 @@ import { MCP_SERVER_NAME } from '../init/mcp.js';
18
18
  import { SERVER_VERSION } from '../version/server-version.js';
19
19
  /** The version we answer with if the client proposed none. */
20
20
  const FALLBACK_PROTOCOL_VERSION = '2024-11-05';
21
+ /**
22
+ * The protocol's own way to say "the tool list you have is out of date, fetch it again".
23
+ *
24
+ * Load-bearing here rather than a nicety. When we answer `initialize` ourselves the catalog we serve
25
+ * is one we made up, and it is usually empty. Nothing else in the protocol ever corrects that: a
26
+ * client re-lists only when told to, and every other recovery path in the proxy is driven by the
27
+ * NEXT client request, which a client holding no tools never makes. Without this the session is
28
+ * connected, initialized and toolless until a human notices.
29
+ */
30
+ export const TOOLS_CHANGED_NOTIFICATION = 'notifications/tools/list_changed';
21
31
  function parseLine(line) {
22
32
  try {
23
33
  const parsed = JSON.parse(line);
@@ -32,7 +42,19 @@ export function isHandshakeLine(line) {
32
42
  const msg = parseLine(line);
33
43
  return 'initialize' === msg?.method || 'notifications/initialized' === msg?.method;
34
44
  }
35
- export function localInitializeResponse(line) {
45
+ export function localInitializeResponse(line,
46
+ /**
47
+ * What the daemon would have advertised, so the client is not left with none.
48
+ *
49
+ * A client reads `instructions` ONCE, at initialize, and this response is sent precisely when no
50
+ * daemon is up to send its own — which is the first run of a fresh install. So the block telling
51
+ * someone that having these tools is not the same as being set up was permanently absent for the
52
+ * exact population it addresses, and the daemon's later correct instructions arrive at a client
53
+ * that will never read the field again.
54
+ *
55
+ * Empty means "nothing to say", and the field is then omitted rather than sent blank.
56
+ */
57
+ instructions = '') {
36
58
  const msg = parseLine(line);
37
59
  if (null === msg || msg.method !== 'initialize')
38
60
  return null;
@@ -47,8 +69,93 @@ export function localInitializeResponse(line) {
47
69
  // Echo what the client asked for: answering with a version it did not offer is its own
48
70
  // handshake failure.
49
71
  protocolVersion: 'string' === typeof proposed ? proposed : FALLBACK_PROTOCOL_VERSION,
50
- capabilities: { tools: {} },
72
+ // `listChanged` is not decoration: a client that was not told the list can change has no
73
+ // reason to honour the notification we send when the daemon finally arrives, and declaring a
74
+ // capability we then rely on is the difference between a fix and a message into the void.
75
+ capabilities: { tools: { listChanged: true } },
51
76
  serverInfo: { name: MCP_SERVER_NAME, version: SERVER_VERSION },
77
+ ...('' === instructions ? {} : { instructions }),
52
78
  },
53
79
  });
54
80
  }
81
+ /**
82
+ * The line that tells a client its tool list is stale, or null when there is nothing to correct.
83
+ *
84
+ * When a daemon arrives after we answered the handshake ourselves, the client is holding a tool list
85
+ * we invented, and on a cold start that list is empty. Nothing else in this protocol corrects it: a
86
+ * client re-lists only when told to, and every other recovery path in the proxy is driven by the
87
+ * NEXT client request, which a client holding no tools never makes. Connected, initialized and
88
+ * toolless for the rest of the session, with a human required to notice and reconnect by hand.
89
+ *
90
+ * Sent only when the catalog was ours. A client that completed a real handshake with the daemon
91
+ * already has the true list, and telling it to refetch would be a round trip for nothing.
92
+ *
93
+ * Lives here rather than in the proxy because it belongs to the locally-answered handshake, which is
94
+ * this module's subject.
95
+ *
96
+ * Worth knowing before relying on it: this is a best-effort correction, not the mechanism. Research
97
+ * into the four clients we target found that only some honour `notifications/tools/list_changed` at
98
+ * all, so the catalog still has to be right in the FIRST answer for the rest. This closes the gap
99
+ * where it is honoured and costs one line where it is not.
100
+ */
101
+ export function toolsChangedNotification(catalogWasLocal) {
102
+ if (!catalogWasLocal)
103
+ return null;
104
+ return JSON.stringify({ jsonrpc: '2.0', method: TOOLS_CHANGED_NOTIFICATION });
105
+ }
106
+ /**
107
+ * The largest single JSON-RPC line the proxy will accumulate from a client.
108
+ *
109
+ * Generous — a tool call with a big argument payload is legitimate — but finite, because without a
110
+ * ceiling the buffer grows to whatever is sent and the process is one `cat bigfile` away from an
111
+ * out-of-memory exit that would take the agent's whole tool surface with it.
112
+ */
113
+ export const MAX_STDIN_LINE_BYTES = 16 * 1024 * 1024;
114
+ /**
115
+ * Append a chunk and split off whatever complete lines it produced.
116
+ *
117
+ * The reason this is not `(buffer + chunk).split('\n')` inline: that re-scans the WHOLE accumulated
118
+ * buffer on every chunk, so a single large line costs O(n²) in the size of the line. Measured
119
+ * against the real proxy, a 32 MB line took ~8s and a 50 MB line ~35s — with the event loop pinned
120
+ * throughout, so every other tool call on that link was frozen with no response and no error. The
121
+ * client sees the whole MCP surface hang.
122
+ *
123
+ * A chunk with no newline in it cannot complete a line, so there is nothing to split and the cost is
124
+ * the concatenation alone. Only a chunk that actually carries a newline pays for a scan.
125
+ */
126
+ export function drainLines(buffer, chunk, maxLineBytes = MAX_STDIN_LINE_BYTES, discarding = false) {
127
+ // Finish discarding first. Everything up to and including the next newline belongs to the line
128
+ // that was already dropped, and only what follows it is a message again.
129
+ if (discarding) {
130
+ const cut = chunk.indexOf('\n');
131
+ if (-1 === cut)
132
+ return { lines: [], rest: '', overflowed: true, discarding: true };
133
+ return {
134
+ ...drainLines('', chunk.slice(cut + 1), maxLineBytes),
135
+ overflowed: true,
136
+ };
137
+ }
138
+ const next = buffer + chunk;
139
+ if (!chunk.includes('\n')) {
140
+ // Nothing can be complete yet. Cap here rather than on the split path: this is the branch an
141
+ // oversized line stays on, so it is the only one that can grow without bound. Dropping the
142
+ // buffer is half the job — the rest of that line is still coming, hence `discarding`.
143
+ if (next.length > maxLineBytes) {
144
+ return { lines: [], rest: '', overflowed: true, discarding: true };
145
+ }
146
+ return { lines: [], rest: next, overflowed: false, discarding: false };
147
+ }
148
+ const parts = next.split('\n');
149
+ const rest = parts.pop() ?? '';
150
+ // A completed line over the cap is dropped, not truncated: half a JSON-RPC message is not a
151
+ // message, and forwarding a prefix would be a parse error attributed to the client. Its newline
152
+ // has already arrived, so there is nothing left to swallow for it.
153
+ const oversizedLine = parts.some((line) => line.length > maxLineBytes);
154
+ const restOversized = rest.length > maxLineBytes;
155
+ return {
156
+ lines: oversizedLine ? parts.filter((line) => line.length <= maxLineBytes) : parts,
157
+ rest: restOversized ? '' : rest,
158
+ overflowed: oversizedLine || restOversized,
159
+ discarding: restOversized,
160
+ };
161
+ }
@@ -7,7 +7,7 @@
7
7
  * that crashed or was stopped used to take the agent's whole Reticle surface with it. Together they
8
8
  * loop: the daemon exits as useless, the proxy immediately brings back a daemon that is equally
9
9
  * useless, which exits, forever. Measured with a 4s grace: four processes in 200 seconds. At the
10
- * real 300s grace that is a new process every five minutes for the 74% of installs that never call a
10
+ * real 300s grace that is a new process every five minutes for the many installs that never call a
11
11
  * tool, for as long as the editor is open.
12
12
  *
13
13
  * The rule that resolves it: a dropped stream is not demand. Reattach to a daemon that is already
@@ -17,6 +17,7 @@
17
17
  * reach it — and a guard that re-implements the decision instead of calling it is insensitive to the
18
18
  * thing it claims to guard.
19
19
  */
20
+ import { PortPresence } from '../daemon/port-presence.js';
20
21
  /** What to do when the SSE stream drops. */
21
22
  export declare const OnDrop: {
22
23
  /** A daemon is listening — reattach to it. */
@@ -28,8 +29,16 @@ export type OnDrop = (typeof OnDrop)[keyof typeof OnDrop];
28
29
  /**
29
30
  * A stream drop only ever justifies reattaching, never spawning. This is the whole fix: the proxy
30
31
  * used to spawn here, which turned a daemon's own idle shutdown into a permanent respawn loop.
32
+ *
33
+ * It takes the PRESENCE, not a boolean. It used to take `daemonListening`, filled from a bare TCP
34
+ * connect — a probe that cannot tell a Reticle daemon from anything else that accepts a socket. A
35
+ * stranger on the bridge port therefore read as "a daemon is there", and since a stranger answers
36
+ * and closes, every reattach dropped and reattached again: measured at nineteen reconnects in
37
+ * forty-eight seconds, each logged as `reticle_mcp_proxy_reconnected`, forever. `probePresence`
38
+ * already asks the second question and every human-facing command already uses it; this was the last
39
+ * decision point still guessing from the first.
31
40
  */
32
- export declare function onStreamDrop(daemonListening: boolean): OnDrop;
41
+ export declare function onStreamDrop(presence: PortPresence): OnDrop;
33
42
  /**
34
43
  * What to do when the reconnect budget runs out. Always DORMANT — never exit.
35
44
  *
@@ -7,7 +7,7 @@
7
7
  * that crashed or was stopped used to take the agent's whole Reticle surface with it. Together they
8
8
  * loop: the daemon exits as useless, the proxy immediately brings back a daemon that is equally
9
9
  * useless, which exits, forever. Measured with a 4s grace: four processes in 200 seconds. At the
10
- * real 300s grace that is a new process every five minutes for the 74% of installs that never call a
10
+ * real 300s grace that is a new process every five minutes for the many installs that never call a
11
11
  * tool, for as long as the editor is open.
12
12
  *
13
13
  * The rule that resolves it: a dropped stream is not demand. Reattach to a daemon that is already
@@ -17,6 +17,7 @@
17
17
  * reach it — and a guard that re-implements the decision instead of calling it is insensitive to the
18
18
  * thing it claims to guard.
19
19
  */
20
+ import { PortPresence } from '../daemon/port-presence.js';
20
21
  /** What to do when the SSE stream drops. */
21
22
  export const OnDrop = {
22
23
  /** A daemon is listening — reattach to it. */
@@ -27,9 +28,17 @@ export const OnDrop = {
27
28
  /**
28
29
  * A stream drop only ever justifies reattaching, never spawning. This is the whole fix: the proxy
29
30
  * used to spawn here, which turned a daemon's own idle shutdown into a permanent respawn loop.
31
+ *
32
+ * It takes the PRESENCE, not a boolean. It used to take `daemonListening`, filled from a bare TCP
33
+ * connect — a probe that cannot tell a Reticle daemon from anything else that accepts a socket. A
34
+ * stranger on the bridge port therefore read as "a daemon is there", and since a stranger answers
35
+ * and closes, every reattach dropped and reattached again: measured at nineteen reconnects in
36
+ * forty-eight seconds, each logged as `reticle_mcp_proxy_reconnected`, forever. `probePresence`
37
+ * already asks the second question and every human-facing command already uses it; this was the last
38
+ * decision point still guessing from the first.
30
39
  */
31
- export function onStreamDrop(daemonListening) {
32
- return daemonListening ? OnDrop.REATTACH : OnDrop.DORMANT;
40
+ export function onStreamDrop(presence) {
41
+ return PortPresence.DAEMON === presence ? OnDrop.REATTACH : OnDrop.DORMANT;
33
42
  }
34
43
  /**
35
44
  * What to do when the reconnect budget runs out. Always DORMANT — never exit.
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The MCP proxy's own log file: where it lives, what bounds it, and how it is written.
3
+ *
4
+ * Split out of mcp-proxy.ts, which is the transport. This is housekeeping for a file, and the two
5
+ * share nothing but a port number — the proxy calls `proxyLog`, and everything else here exists to
6
+ * keep that file from becoming the largest thing on the user's disk.
7
+ */
8
+ /**
9
+ * How much the proxy may append between size checks.
10
+ *
11
+ * Statting the file on every line would be a syscall per log line on a process that logs from a
12
+ * reconnect loop. Counting what we wrote costs nothing and is exact enough, because this process is
13
+ * the only writer of its own log. Well under the cap, so the cap is a cap rather than a suggestion.
14
+ */
15
+ export declare const PROXY_LOG_CHECK_BYTES: number;
16
+ /**
17
+ * Keep the proxy log under the same cap as the daemon log, without a stat per write.
18
+ *
19
+ * Returns the new "bytes since the last check" counter, so the accounting is testable without a
20
+ * filesystem. Rotation itself is `rotateDaemonLog` — the proxy log had no cap at all while the
21
+ * daemon log on the SAME machine stayed small, which is the whole of this defect.
22
+ */
23
+ export declare function accountProxyLogWrite(bytesSinceCheck: number, written: number, path: string, deps: {
24
+ fileSize(p: string): number;
25
+ renameFile(from: string, to: string): void;
26
+ }): number;
27
+ /**
28
+ * Reclaim a proxy log that a previous process already let run away. Returns the bytes reclaimed.
29
+ *
30
+ * Reported from the field: one machine's `proxy-4400.log` reached a third of a 460GB disk and broke
31
+ * builds, Docker and ordinary shell commands with ENOSPC. Nothing in Reticle degraded first, so the
32
+ * failure surfaced as the operating system refusing to write files. Files like that exist on real
33
+ * machines now, and the cap alone does not help them: the user should not have to find one with `du`.
34
+ *
35
+ * TRUNCATED IN PLACE, never renamed or unlinked. A rename moves the bytes without reclaiming a byte,
36
+ * and a file that a running process still holds open keeps its blocks allocated after an unlink
37
+ * until that handle closes — which is why the reporter recovered with `: > ~/.reticle/proxy-4400.log`
38
+ * and why that is the operation to copy here.
39
+ *
40
+ * Best-effort, in the same spirit as `rotateDaemonLog`: refusing to start the MCP server over
41
+ * housekeeping is strictly worse than a large file.
42
+ */
43
+ export declare function recoverOversizedProxyLog(path: string, deps: {
44
+ fileSize(p: string): number;
45
+ truncateFile(p: string): void;
46
+ }, max?: number): number;
47
+ /** Name the proxy log after the port it serves. Called before anything can log. */
48
+ export declare function setProxyLogPort(port: number): void;
49
+ /** The proxy's own log file, so a silent drop leaves a readable trace the agent can go read. */
50
+ export declare function proxyLogPath(port?: number | undefined): string;
51
+ /**
52
+ * Log to stderr (which the agent host usually swallows) AND to ~/.reticle/proxy-<port>.log, which it
53
+ * does not. A dropped MCP connection is invisible from the agent's side — no message, no exit code —
54
+ * so the one thing that makes it diagnosable at all is a file somebody can read afterwards.
55
+ *
56
+ * EXPORTED because the crash handlers need it. `installProxyResilience` was wired to the bare stderr
57
+ * logger, so the proxy's own uncaught exceptions and unhandled rejections — the exact events that
58
+ * present to a human as "the MCP server disconnected" — were written to a stream the editor throws
59
+ * away. The handler ran, the process kept serving, and the reason went nowhere. Reported as "the
60
+ * proxy has no log file… when it dies the diagnostic dies with it", which was half right: the file
61
+ * existed, and the one path that most needed it was not using it.
62
+ */
63
+ export declare function proxyLog(event: string, fields?: Record<string, unknown>): void;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The MCP proxy's own log file: where it lives, what bounds it, and how it is written.
3
+ *
4
+ * Split out of mcp-proxy.ts, which is the transport. This is housekeeping for a file, and the two
5
+ * share nothing but a port number — the proxy calls `proxyLog`, and everything else here exists to
6
+ * keep that file from becoming the largest thing on the user's disk.
7
+ */
8
+ import { appendFileSync, mkdirSync, renameSync, statSync, truncateSync } from 'node:fs';
9
+ import { homedir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { ReticleDir } from '@reticlehq/core';
12
+ import { log } from '../log.js';
13
+ import { MAX_DAEMON_LOG_BYTES, rotateDaemonLog } from '../daemon/daemon.js';
14
+ /**
15
+ * Which port this proxy serves, for the log file name. Set once at startup.
16
+ *
17
+ * One file per port, matching `daemon-<port>.log`. A single shared file interleaved every proxy on
18
+ * the machine into one stream, so the first question anyone asks of it — "what happened to MY
19
+ * session?" — needed a filter before it could be answered.
20
+ */
21
+ let logPort;
22
+ /**
23
+ * How much the proxy may append between size checks.
24
+ *
25
+ * Statting the file on every line would be a syscall per log line on a process that logs from a
26
+ * reconnect loop. Counting what we wrote costs nothing and is exact enough, because this process is
27
+ * the only writer of its own log. Well under the cap, so the cap is a cap rather than a suggestion.
28
+ */
29
+ export const PROXY_LOG_CHECK_BYTES = 256 * 1024;
30
+ /** Appended since the last real size check. Reset by every check, whatever the check decides. */
31
+ let bytesSinceProxyLogCheck = 0;
32
+ /**
33
+ * Keep the proxy log under the same cap as the daemon log, without a stat per write.
34
+ *
35
+ * Returns the new "bytes since the last check" counter, so the accounting is testable without a
36
+ * filesystem. Rotation itself is `rotateDaemonLog` — the proxy log had no cap at all while the
37
+ * daemon log on the SAME machine stayed small, which is the whole of this defect.
38
+ */
39
+ export function accountProxyLogWrite(bytesSinceCheck, written, path, deps) {
40
+ const total = bytesSinceCheck + written;
41
+ if (PROXY_LOG_CHECK_BYTES > total)
42
+ return total;
43
+ rotateDaemonLog(path, deps);
44
+ return 0;
45
+ }
46
+ /**
47
+ * Reclaim a proxy log that a previous process already let run away. Returns the bytes reclaimed.
48
+ *
49
+ * Reported from the field: one machine's `proxy-4400.log` reached a third of a 460GB disk and broke
50
+ * builds, Docker and ordinary shell commands with ENOSPC. Nothing in Reticle degraded first, so the
51
+ * failure surfaced as the operating system refusing to write files. Files like that exist on real
52
+ * machines now, and the cap alone does not help them: the user should not have to find one with `du`.
53
+ *
54
+ * TRUNCATED IN PLACE, never renamed or unlinked. A rename moves the bytes without reclaiming a byte,
55
+ * and a file that a running process still holds open keeps its blocks allocated after an unlink
56
+ * until that handle closes — which is why the reporter recovered with `: > ~/.reticle/proxy-4400.log`
57
+ * and why that is the operation to copy here.
58
+ *
59
+ * Best-effort, in the same spirit as `rotateDaemonLog`: refusing to start the MCP server over
60
+ * housekeeping is strictly worse than a large file.
61
+ */
62
+ export function recoverOversizedProxyLog(path, deps, max = MAX_DAEMON_LOG_BYTES) {
63
+ try {
64
+ const size = deps.fileSize(path);
65
+ if (max >= size)
66
+ return 0;
67
+ deps.truncateFile(path);
68
+ return size;
69
+ }
70
+ catch {
71
+ return 0;
72
+ }
73
+ }
74
+ /** The real filesystem behind the two rotation helpers. */
75
+ const proxyLogFileOps = {
76
+ fileSize: (p) => statSync(p).size,
77
+ renameFile: (from, to) => renameSync(from, to),
78
+ truncateFile: (p) => truncateSync(p, 0),
79
+ };
80
+ /** Name the proxy log after the port it serves. Called before anything can log. */
81
+ export function setProxyLogPort(port) {
82
+ logPort = port;
83
+ // The one moment we know a size check is worth its syscall, and the only place a file inherited
84
+ // from an older build can be brought back under control.
85
+ const reclaimed = recoverOversizedProxyLog(proxyLogPath(port), proxyLogFileOps);
86
+ if (0 < reclaimed) {
87
+ proxyLog('reticle_mcp_proxy_log_truncated', {
88
+ port,
89
+ reclaimedBytes: reclaimed,
90
+ note: 'the proxy log had grown past its cap and was truncated in place to reclaim the space',
91
+ });
92
+ }
93
+ }
94
+ /** The proxy's own log file, so a silent drop leaves a readable trace the agent can go read. */
95
+ export function proxyLogPath(port = logPort) {
96
+ const name = port === undefined ? 'mcp-proxy.log' : `proxy-${String(port)}.log`;
97
+ return join(homedir(), ReticleDir.ROOT, name);
98
+ }
99
+ /**
100
+ * Log to stderr (which the agent host usually swallows) AND to ~/.reticle/proxy-<port>.log, which it
101
+ * does not. A dropped MCP connection is invisible from the agent's side — no message, no exit code —
102
+ * so the one thing that makes it diagnosable at all is a file somebody can read afterwards.
103
+ *
104
+ * EXPORTED because the crash handlers need it. `installProxyResilience` was wired to the bare stderr
105
+ * logger, so the proxy's own uncaught exceptions and unhandled rejections — the exact events that
106
+ * present to a human as "the MCP server disconnected" — were written to a stream the editor throws
107
+ * away. The handler ran, the process kept serving, and the reason went nowhere. Reported as "the
108
+ * proxy has no log file… when it dies the diagnostic dies with it", which was half right: the file
109
+ * existed, and the one path that most needed it was not using it.
110
+ */
111
+ export function proxyLog(event, fields = {}) {
112
+ log(event, fields);
113
+ try {
114
+ const dir = join(homedir(), ReticleDir.ROOT);
115
+ mkdirSync(dir, { recursive: true });
116
+ // WITH A TIMESTAMP. This file is the only record of an outage that survives the session, and
117
+ // without one it cannot answer the two questions anyone brings to it: when did this happen, and
118
+ // how often. A real 3,283-line log on a dev machine held a `gave_up` — the exact event that used
119
+ // to precede `process.exit(1)` and cost the human a manual /mcp reconnect — and it was
120
+ // impossible to tell whether it happened during that person's work or a test run hours earlier.
121
+ // Evidence you cannot place in time is an anecdote.
122
+ const path = proxyLogPath();
123
+ const line = `${JSON.stringify({ t: new Date().toISOString(), event, ...fields })}\n`;
124
+ appendFileSync(path, line, 'utf8');
125
+ // A single proxy process is long-lived and logs from a reconnect loop, so rotating only at
126
+ // startup would leave the growth this file is capable of entirely unchecked while it runs.
127
+ bytesSinceProxyLogCheck = accountProxyLogWrite(bytesSinceProxyLogCheck, Buffer.byteLength(line, 'utf8'), path, proxyLogFileOps);
128
+ }
129
+ catch {
130
+ // Logging must never be the thing that kills the proxy.
131
+ }
132
+ }