@reticlehq/server 2.7.0 → 2.8.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 (236) hide show
  1. package/README.md +14 -5
  2. package/SKILL.md +64 -29
  3. package/dist/capsule/causal-summary.d.ts +20 -0
  4. package/dist/capsule/causal-summary.js +31 -6
  5. package/dist/capsule/divergence.js +7 -1
  6. package/dist/cli/chromium-hint.d.ts +33 -5
  7. package/dist/cli/chromium-hint.js +82 -12
  8. package/dist/cli/cli-doctor.js +28 -25
  9. package/dist/cli/cli-kill.d.ts +58 -0
  10. package/dist/cli/cli-kill.js +162 -0
  11. package/dist/cli/cli-launch.d.ts +17 -0
  12. package/dist/cli/cli-launch.js +28 -0
  13. package/dist/cli/cli-parse.d.ts +16 -1
  14. package/dist/cli/cli-parse.js +32 -9
  15. package/dist/cli/cli-port.d.ts +8 -0
  16. package/dist/cli/cli-port.js +16 -0
  17. package/dist/cli/cli-verify.js +2 -2
  18. package/dist/cli/doctor-sessions-line.d.ts +12 -0
  19. package/dist/cli/doctor-sessions-line.js +66 -0
  20. package/dist/cli/port-holder.d.ts +10 -0
  21. package/dist/cli/port-holder.js +26 -2
  22. package/dist/cli.js +99 -14
  23. package/dist/crawl/crawl-tools.js +12 -2
  24. package/dist/crawl/crawl.d.ts +13 -0
  25. package/dist/crawl/crawl.js +50 -2
  26. package/dist/events/contradictions.d.ts +14 -0
  27. package/dist/events/contradictions.js +26 -17
  28. package/dist/events/predicate-eval.d.ts +20 -1
  29. package/dist/events/predicate-eval.js +125 -3
  30. package/dist/events/predicate.js +170 -18
  31. package/dist/flows/flow-classify.js +1 -1
  32. package/dist/flows/flow-tools.js +12 -4
  33. package/dist/flows/heal-run.js +1 -1
  34. package/dist/honesty/blind-spots.js +6 -2
  35. package/dist/honesty/unsettled.d.ts +40 -0
  36. package/dist/honesty/unsettled.js +78 -0
  37. package/dist/honesty/verified.d.ts +7 -0
  38. package/dist/honesty/verified.js +3 -3
  39. package/dist/index.js +3 -0
  40. package/dist/init/agent-rules.d.ts +16 -0
  41. package/dist/init/agent-rules.js +96 -22
  42. package/dist/init/astro-patch.d.ts +2 -1
  43. package/dist/init/astro-patch.js +11 -5
  44. package/dist/init/capabilities.d.ts +22 -0
  45. package/dist/init/capabilities.js +76 -0
  46. package/dist/init/confirm.d.ts +96 -0
  47. package/dist/init/confirm.js +138 -0
  48. package/dist/init/cra.js +3 -2
  49. package/dist/init/non-js-project.js +29 -5
  50. package/dist/init/plan-framework.js +53 -15
  51. package/dist/init/plan.d.ts +40 -4
  52. package/dist/init/plan.js +163 -40
  53. package/dist/init/run.d.ts +24 -2
  54. package/dist/init/run.js +148 -42
  55. package/dist/init/slash-command.d.ts +1 -1
  56. package/dist/init/slash-command.js +46 -6
  57. package/dist/init/snippets.d.ts +44 -4
  58. package/dist/init/snippets.js +161 -37
  59. package/dist/init/vite-config.js +29 -3
  60. package/dist/input/network-mock-tools.d.ts +19 -0
  61. package/dist/input/network-mock-tools.js +29 -4
  62. package/dist/license/license.js +1 -1
  63. package/dist/mcp/mcp-proxy.d.ts +69 -3
  64. package/dist/mcp/mcp-proxy.js +205 -20
  65. package/dist/mcp/mcp.js +23 -7
  66. package/dist/mcp/proxy-lifecycle.d.ts +1 -1
  67. package/dist/mcp/proxy-lifecycle.js +1 -1
  68. package/dist/pool/browser-pool.d.ts +22 -2
  69. package/dist/pool/browser-pool.js +44 -4
  70. package/dist/project/learned-routes.d.ts +24 -0
  71. package/dist/project/learned-routes.js +63 -0
  72. package/dist/project/project-store.d.ts +6 -0
  73. package/dist/project/project-store.js +25 -1
  74. package/dist/session/no-session-diagnosis.d.ts +19 -0
  75. package/dist/session/no-session-diagnosis.js +71 -18
  76. package/dist/session/no-session-watch.js +12 -0
  77. package/dist/session/ref-provenance.d.ts +54 -0
  78. package/dist/session/ref-provenance.js +61 -0
  79. package/dist/session/session-lease.js +1 -1
  80. package/dist/telemetry/automation-hint.d.ts +17 -0
  81. package/dist/telemetry/automation-hint.js +71 -0
  82. package/dist/telemetry/bug-found.d.ts +0 -20
  83. package/dist/telemetry/bug-found.js +38 -21
  84. package/dist/telemetry/cli-telemetry.js +7 -1
  85. package/dist/telemetry/feedback.js +1 -1
  86. package/dist/telemetry/init-telemetry.js +9 -1
  87. package/dist/telemetry/install-source.d.ts +36 -0
  88. package/dist/telemetry/install-source.js +40 -0
  89. package/dist/telemetry/run-telemetry.js +4 -1
  90. package/dist/telemetry/session-metrics.js +5 -0
  91. package/dist/telemetry/telemetry.d.ts +5 -1
  92. package/dist/telemetry/telemetry.js +15 -2
  93. package/dist/telemetry/tool-refused.d.ts +19 -0
  94. package/dist/telemetry/tool-refused.js +55 -0
  95. package/dist/telemetry/verification-of.js +46 -7
  96. package/dist/temp-dir.d.ts +27 -0
  97. package/dist/temp-dir.js +46 -0
  98. package/dist/tools/act-tools.js +45 -1
  99. package/dist/tools/assert-grade.d.ts +1 -1
  100. package/dist/tools/assert-grade.js +27 -5
  101. package/dist/tools/assert-verdict.js +18 -1
  102. package/dist/tools/contract-tools.js +4 -0
  103. package/dist/tools/dynamic-tools.js +1 -1
  104. package/dist/tools/error-recovery.d.ts +10 -0
  105. package/dist/tools/error-recovery.js +64 -4
  106. package/dist/tools/feedback-invite.js +1 -1
  107. package/dist/tools/invoke-tool.js +139 -23
  108. package/dist/tools/lease-tools.d.ts +29 -1
  109. package/dist/tools/lease-tools.js +58 -5
  110. package/dist/tools/navigate-result.js +1 -1
  111. package/dist/tools/observe-tools.js +1 -1
  112. package/dist/tools/observed-nothing.js +1 -1
  113. package/dist/tools/react-grace.d.ts +48 -0
  114. package/dist/tools/react-grace.js +87 -0
  115. package/dist/tools/read-tools.js +20 -4
  116. package/dist/tools/settle-in-flight.d.ts +79 -0
  117. package/dist/tools/settle-in-flight.js +106 -0
  118. package/dist/tools/snapshot-delta.d.ts +7 -0
  119. package/dist/tools/snapshot-delta.js +130 -6
  120. package/dist/tools/tool-surface.js +19 -0
  121. package/dist/tools/tools.js +16 -4
  122. package/docs/README.md +56 -11
  123. package/docs/actions.mdx +87 -0
  124. package/docs/agent-cheatsheet.md +93 -29
  125. package/docs/architecture.md +46 -35
  126. package/docs/benchmarks.md +76 -72
  127. package/docs/best-practices.mdx +211 -0
  128. package/docs/capabilities.mdx +387 -0
  129. package/docs/cli/affected.mdx +51 -0
  130. package/docs/cli/capsules.mdx +69 -0
  131. package/docs/cli/cloud.mdx +127 -0
  132. package/docs/cli/doctor.mdx +76 -0
  133. package/docs/cli/drive.mdx +55 -0
  134. package/docs/cli/feedback.mdx +67 -0
  135. package/docs/cli/gate.mdx +64 -0
  136. package/docs/cli/help.mdx +92 -0
  137. package/docs/cli/hunt.mdx +57 -0
  138. package/docs/cli/identify.mdx +62 -0
  139. package/docs/cli/init.mdx +94 -0
  140. package/docs/cli/kill.mdx +91 -0
  141. package/docs/cli/license.mdx +48 -0
  142. package/docs/cli/mcp.mdx +61 -0
  143. package/docs/cli/open.mdx +68 -0
  144. package/docs/cli/restart.mdx +60 -0
  145. package/docs/cli/rollback.mdx +35 -0
  146. package/docs/cli/serve.mdx +62 -0
  147. package/docs/cli/status.mdx +64 -0
  148. package/docs/cli/stop.mdx +56 -0
  149. package/docs/cli/telemetry.mdx +57 -0
  150. package/docs/cli/update.mdx +52 -0
  151. package/docs/cli/verify.mdx +57 -0
  152. package/docs/cli/version.mdx +39 -0
  153. package/docs/cli/watch.mdx +55 -0
  154. package/docs/cli.mdx +219 -0
  155. package/docs/debugging.md +13 -9
  156. package/docs/deploy-checks.md +15 -11
  157. package/docs/desktop-apps.md +49 -40
  158. package/docs/desktop.mdx +188 -0
  159. package/docs/docs.json +363 -0
  160. package/docs/enterprise.md +20 -16
  161. package/docs/faq.mdx +88 -0
  162. package/docs/fixtures.md +11 -7
  163. package/docs/flows.md +26 -22
  164. package/docs/for-agents.md +71 -0
  165. package/docs/frameworks.mdx +199 -0
  166. package/docs/gate-plan.md +45 -41
  167. package/docs/gates.md +22 -18
  168. package/docs/getting-started.md +76 -65
  169. package/docs/human-control.md +22 -18
  170. package/docs/index.mdx +101 -0
  171. package/docs/install-agentic.mdx +215 -0
  172. package/docs/install-manual.mdx +194 -0
  173. package/docs/instrumentation.mdx +232 -0
  174. package/docs/integration-patterns.md +38 -34
  175. package/docs/local-registry.md +16 -12
  176. package/docs/multi-agent-testing.md +16 -12
  177. package/docs/packages/babel-plugin.mdx +70 -0
  178. package/docs/packages/browser.mdx +111 -0
  179. package/docs/packages/core.mdx +106 -0
  180. package/docs/packages/electron.mdx +87 -0
  181. package/docs/packages/eslint-plugin.mdx +84 -0
  182. package/docs/packages/next.mdx +91 -0
  183. package/docs/packages/react.mdx +91 -0
  184. package/docs/packages/server.mdx +98 -0
  185. package/docs/packages/tauri.mdx +63 -0
  186. package/docs/packages/test.mdx +108 -0
  187. package/docs/packages/vite-plugin.mdx +96 -0
  188. package/docs/packages.mdx +217 -0
  189. package/docs/platform-integration.md +33 -26
  190. package/docs/predicates.mdx +179 -0
  191. package/docs/quickstart.mdx +211 -0
  192. package/docs/recipes.mdx +242 -0
  193. package/docs/skill-file.mdx +105 -0
  194. package/docs/state-management.mdx +197 -0
  195. package/docs/style.css +14 -0
  196. package/docs/system-map.md +47 -32
  197. package/docs/telemetry-contract.md +93 -50
  198. package/docs/telemetry-events.mdx +430 -0
  199. package/docs/telemetry.md +57 -52
  200. package/docs/testing.md +10 -6
  201. package/docs/token-efficiency.md +30 -26
  202. package/docs/tools-act-and-wait.mdx +123 -0
  203. package/docs/tools-act-sequence.mdx +120 -0
  204. package/docs/tools-act.mdx +82 -0
  205. package/docs/tools-assert.mdx +70 -0
  206. package/docs/tools-console.mdx +59 -0
  207. package/docs/tools-inspect.mdx +85 -0
  208. package/docs/tools-navigate.mdx +63 -0
  209. package/docs/tools-network.mdx +86 -0
  210. package/docs/tools-observe.mdx +89 -0
  211. package/docs/tools-overview.mdx +157 -0
  212. package/docs/tools-query.mdx +82 -0
  213. package/docs/tools-session-and-feedback.mdx +91 -0
  214. package/docs/tools-sessions.mdx +65 -0
  215. package/docs/tools-snapshot.mdx +75 -0
  216. package/docs/tools-state.mdx +81 -0
  217. package/docs/tools-tools-and-run.mdx +125 -0
  218. package/docs/tools-wait-for.mdx +66 -0
  219. package/docs/troubleshooting.mdx +193 -0
  220. package/docs/usage.md +175 -153
  221. package/docs/vs-chrome-devtools-mcp.mdx +82 -0
  222. package/docs/vs-playwright-mcp.mdx +85 -0
  223. package/docs/vs-screenshots.mdx +96 -0
  224. package/docs/what-is-reticle.mdx +63 -0
  225. package/docs/why-reticle.mdx +107 -0
  226. package/package.json +2 -2
  227. package/docs/matrix/2.5.0/claude-code-machine.json +0 -19
  228. package/docs/matrix/2.5.0/codex-machine.json +0 -20
  229. package/docs/matrix/2.5.0/cursor-machine.json +0 -22
  230. package/docs/matrix/2.5.0/gemini-machine.json +0 -22
  231. package/docs/matrix/2.5.0/opencode-machine.json +0 -22
  232. package/docs/matrix/2.5.0/vscode-machine.json +0 -22
  233. package/docs/matrix/2.5.0/windsurf-machine.json +0 -22
  234. package/docs/matrix/MATRIX.md +0 -26
  235. package/docs/matrix/README.md +0 -59
  236. package/docs/mint.json +0 -61
@@ -2,7 +2,7 @@ import * as http from 'node:http';
2
2
  import { localInitializeResponse, isHandshakeLine } from './proxy-handshake.js';
3
3
  import { ToolCatalogCache } from './tool-catalog-cache.js';
4
4
  import * as net from 'node:net';
5
- import { appendFileSync, mkdirSync } from 'node:fs';
5
+ import { appendFileSync, mkdirSync, renameSync, statSync, truncateSync } from 'node:fs';
6
6
  import { homedir } from 'node:os';
7
7
  import { join } from 'node:path';
8
8
  import { LOOPBACK_HOST, MCP_SSE_PATH, ReticleDir, ReticleEnv, CONTRACT_FINGERPRINT, } from '@reticlehq/core';
@@ -10,6 +10,7 @@ import { SERVER_VERSION } from '../version/server-version.js';
10
10
  import { PEER_VERSION_PARAM, PEER_CONTRACT_PARAM } from '../version/peer-announce.js';
11
11
  import { onStreamDrop, onClientRequest, onReconnectBudgetSpent, onQueueExpired, OnDrop, OnRequest, } from './proxy-lifecycle.js';
12
12
  import { log } from '../log.js';
13
+ import { MAX_DAEMON_LOG_BYTES, rotateDaemonLog } from '../daemon/daemon.js';
13
14
  import { OutageStage, reportMcpOutage } from './mcp-outage.js';
14
15
  const DEFAULT_DAEMON_READY_TIMEOUT_MS = 10_000;
15
16
  /**
@@ -59,9 +60,77 @@ export const RECONNECT_INITIALIZE_ID = '__reticle_proxy_reinit';
59
60
  * session?" — needed a filter before it could be answered.
60
61
  */
61
62
  let logPort;
63
+ /**
64
+ * How much the proxy may append between size checks.
65
+ *
66
+ * Statting the file on every line would be a syscall per log line on a process that logs from a
67
+ * reconnect loop. Counting what we wrote costs nothing and is exact enough, because this process is
68
+ * the only writer of its own log. Well under the cap, so the cap is a cap rather than a suggestion.
69
+ */
70
+ export const PROXY_LOG_CHECK_BYTES = 256 * 1024;
71
+ /** Appended since the last real size check. Reset by every check, whatever the check decides. */
72
+ let bytesSinceProxyLogCheck = 0;
73
+ /**
74
+ * Keep the proxy log under the same cap as the daemon log, without a stat per write.
75
+ *
76
+ * Returns the new "bytes since the last check" counter, so the accounting is testable without a
77
+ * filesystem. Rotation itself is `rotateDaemonLog` — the proxy log had no cap at all while the
78
+ * daemon log on the SAME machine stayed small, which is the whole of this defect.
79
+ */
80
+ export function accountProxyLogWrite(bytesSinceCheck, written, path, deps) {
81
+ const total = bytesSinceCheck + written;
82
+ if (PROXY_LOG_CHECK_BYTES > total)
83
+ return total;
84
+ rotateDaemonLog(path, deps);
85
+ return 0;
86
+ }
87
+ /**
88
+ * Reclaim a proxy log that a previous process already let run away. Returns the bytes reclaimed.
89
+ *
90
+ * Reported from the field: one machine's `proxy-4400.log` reached a third of a 460GB disk and broke
91
+ * builds, Docker and ordinary shell commands with ENOSPC. Nothing in Reticle degraded first, so the
92
+ * failure surfaced as the operating system refusing to write files. Files like that exist on real
93
+ * machines now, and the cap alone does not help them: the user should not have to find one with `du`.
94
+ *
95
+ * TRUNCATED IN PLACE, never renamed or unlinked. A rename moves the bytes without reclaiming a byte,
96
+ * and a file that a running process still holds open keeps its blocks allocated after an unlink
97
+ * until that handle closes — which is why the reporter recovered with `: > ~/.reticle/proxy-4400.log`
98
+ * and why that is the operation to copy here.
99
+ *
100
+ * Best-effort, in the same spirit as `rotateDaemonLog`: refusing to start the MCP server over
101
+ * housekeeping is strictly worse than a large file.
102
+ */
103
+ export function recoverOversizedProxyLog(path, deps, max = MAX_DAEMON_LOG_BYTES) {
104
+ try {
105
+ const size = deps.fileSize(path);
106
+ if (max >= size)
107
+ return 0;
108
+ deps.truncateFile(path);
109
+ return size;
110
+ }
111
+ catch {
112
+ return 0;
113
+ }
114
+ }
115
+ /** The real filesystem behind the two rotation helpers. */
116
+ const proxyLogFileOps = {
117
+ fileSize: (p) => statSync(p).size,
118
+ renameFile: (from, to) => renameSync(from, to),
119
+ truncateFile: (p) => truncateSync(p, 0),
120
+ };
62
121
  /** Name the proxy log after the port it serves. Called before anything can log. */
63
122
  export function setProxyLogPort(port) {
64
123
  logPort = port;
124
+ // The one moment we know a size check is worth its syscall, and the only place a file inherited
125
+ // from an older build can be brought back under control.
126
+ const reclaimed = recoverOversizedProxyLog(proxyLogPath(port), proxyLogFileOps);
127
+ if (0 < reclaimed) {
128
+ proxyLog('reticle_mcp_proxy_log_truncated', {
129
+ port,
130
+ reclaimedBytes: reclaimed,
131
+ note: 'the proxy log had grown past its cap and was truncated in place to reclaim the space',
132
+ });
133
+ }
65
134
  }
66
135
  /** The proxy's own log file, so a silent drop leaves a readable trace the agent can go read. */
67
136
  export function proxyLogPath(port = logPort) {
@@ -91,7 +160,12 @@ export function proxyLog(event, fields = {}) {
91
160
  // to precede `process.exit(1)` and cost the human a manual /mcp reconnect — and it was
92
161
  // impossible to tell whether it happened during that person's work or a test run hours earlier.
93
162
  // Evidence you cannot place in time is an anecdote.
94
- appendFileSync(proxyLogPath(), `${JSON.stringify({ t: new Date().toISOString(), event, ...fields })}\n`, 'utf8');
163
+ const path = proxyLogPath();
164
+ const line = `${JSON.stringify({ t: new Date().toISOString(), event, ...fields })}\n`;
165
+ appendFileSync(path, line, 'utf8');
166
+ // A single proxy process is long-lived and logs from a reconnect loop, so rotating only at
167
+ // startup would leave the growth this file is capable of entirely unchecked while it runs.
168
+ bytesSinceProxyLogCheck = accountProxyLogWrite(bytesSinceProxyLogCheck, Buffer.byteLength(line, 'utf8'), path, proxyLogFileOps);
95
169
  }
96
170
  catch {
97
171
  // Logging must never be the thing that kills the proxy.
@@ -166,8 +240,10 @@ export class PendingRequests {
166
240
  * daemon under a live client left 20 of 20 concurrent calls hanging until the client's own timeout,
167
241
  * with the MCP server still perfectly alive. An agent cannot tell that apart from "still working".
168
242
  */
169
- export function streamLossReplies(pending, reason) {
170
- return pending.drain().map((id) => transportLossReply(JSON.parse(id), reason));
243
+ export function streamLossReplies(pending, reason, nextStep) {
244
+ return pending
245
+ .drain()
246
+ .map((id) => transportLossReply(JSON.parse(id), reason, nextStep));
171
247
  }
172
248
  /**
173
249
  * -32001: a server-defined error. Not -32603 (internal): nothing went wrong INSIDE the call, the
@@ -175,15 +251,21 @@ export function streamLossReplies(pending, reason) {
175
251
  * to retry.
176
252
  */
177
253
  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) {
254
+ /**
255
+ * The one shape every "the transport ate your call" answer takes, whichever leg died.
256
+ *
257
+ * `nextStep` is appended when the caller knows one. Without it this reply describes a condition and
258
+ * stops there, which is all a first-run user got out of their first ever tool call.
259
+ */
260
+ export function transportLossReply(id, reason, nextStep) {
180
261
  return JSON.stringify({
181
262
  jsonrpc: '2.0',
182
263
  id,
183
264
  error: {
184
265
  code: TRANSPORT_LOSS_CODE,
185
266
  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.',
267
+ 'complete. Reticle is recovering the connection; retry if the action is safe to repeat.' +
268
+ (nextStep === undefined ? '' : ` ${nextStep}`),
187
269
  },
188
270
  });
189
271
  }
@@ -207,8 +289,38 @@ const SHUTDOWN_DRAIN_MS = 5_000;
207
289
  * well under the 60s an MCP client typically allows, so the answer comes from us with a reason
208
290
  * rather than from their timer with none.
209
291
  */
210
- const QUEUE_WAIT_MS = 20_000;
292
+ export const QUEUE_WAIT_MS = 20_000;
211
293
  const SHUTDOWN_DRAIN_POLL_MS = 25;
294
+ /** A port that refuses is a port nothing is listening on YET — not a port that answered badly. */
295
+ const CONNECTION_REFUSED = 'ECONNREFUSED';
296
+ /**
297
+ * How long the FIRST connect keeps chasing a refused port before calling it a startup failure.
298
+ *
299
+ * It used to get exactly one attempt. Reported from a Windows first bootstrap: the daemon was still
300
+ * booting, the single connect was refused, and nothing tried again — only a fresh CLIENT request
301
+ * re-probes the port, and the client was already blocked on the call that then expired with -32001.
302
+ * The first tool call of somebody's first ever session is the worst place available to lose one.
303
+ *
304
+ * Deliberately longer than QUEUE_WAIT_MS: a retry that stops before the queue does cannot rescue the
305
+ * request it exists for, and a daemon found afterwards at least restores service for the next call.
306
+ */
307
+ const FIRST_CONNECT_WINDOW_MS = QUEUE_WAIT_MS + 15_000;
308
+ /** Retry a first connect, or report it? Pure, so the window can be held against the queue's. */
309
+ export function shouldRetryFirstConnect(error, elapsedMs) {
310
+ const code = error?.code;
311
+ return CONNECTION_REFUSED === code && FIRST_CONNECT_WINDOW_MS > elapsedMs;
312
+ }
313
+ /**
314
+ * What to do about it, carried on the reply the queue expiry sends.
315
+ *
316
+ * A bare -32001 on the first call after an install says nothing a user can act on: not whether to
317
+ * wait, not whether to retry, not whether the install itself is broken.
318
+ */
319
+ export function queueExpiryNextStep(port) {
320
+ return (`No Reticle daemon answered on port ${String(port)}. Retry the call — the next request starts ` +
321
+ 'one. If it keeps failing, run `reticle doctor` in a terminal: it reports what is holding the ' +
322
+ 'port and whether the daemon can start at all.');
323
+ }
212
324
  /**
213
325
  * Makes a reconnect invisible to the client.
214
326
  *
@@ -439,8 +551,9 @@ ensureDaemon) {
439
551
  return;
440
552
  // Drop what we could never send, and answer it, so the caller is never left guessing.
441
553
  stdinQueue.splice(0);
442
- for (const reply of streamLossReplies(pending, 'no daemon could be reached'))
554
+ for (const reply of streamLossReplies(pending, 'no daemon could be reached', queueExpiryNextStep(port))) {
443
555
  emit(reply);
556
+ }
444
557
  // The expiry is the proof that the reconnect we believed was in flight is not coming — a
445
558
  // wedged port accepts the socket and never serves SSE, so `connect` neither resolves nor
446
559
  // rejects and `dormant` would otherwise stay false forever. Going dormant is what makes the
@@ -464,6 +577,15 @@ ensureDaemon) {
464
577
  };
465
578
  let stopped = false;
466
579
  let attempts = 0;
580
+ /**
581
+ * When the proxy started, and how many times the first connect has been refused since.
582
+ *
583
+ * The clock is read at this boundary rather than injected, matching the shutdown drain below:
584
+ * it measures how long a real daemon has had to boot, which is a wall-clock fact about the
585
+ * machine and not a decision the caller could supply.
586
+ */
587
+ const startedAt = Date.now();
588
+ let firstConnectAttempts = 0;
467
589
  /**
468
590
  * The ONE way a line leaves for the daemon — so a POST that never lands still owes an answer.
469
591
  *
@@ -509,6 +631,7 @@ ensureDaemon) {
509
631
  clearQueueTimer();
510
632
  for (const queued of stdinQueue.splice(0))
511
633
  forward(url, queued);
634
+ handshakeAnswered = true;
512
635
  return;
513
636
  }
514
637
  if ('message' === event && !replay.shouldSuppressInbound(data)) {
@@ -518,11 +641,27 @@ ensureDaemon) {
518
641
  emit(data);
519
642
  }
520
643
  }
521
- function scheduleReconnect(reason, detail, pendingLost = 0) {
644
+ function scheduleReconnect(reason, detail) {
522
645
  if (stopped)
523
646
  return;
524
647
  postUrl = null;
525
648
  attempts++;
649
+ // Everything the dead session owed is settled HERE, in the one place every drop funnels
650
+ // through, and not in the response callback that first noticed the socket die. A reset emits
651
+ // on BOTH halves of the request, so whichever handler wins decides what gets paid — the same
652
+ // shape as the reconnect fan-out, and it left the other half paying nothing.
653
+ //
654
+ // Order matters between the two. Drain the queue FIRST: a queued request is tracked in
655
+ // `pending` as well, so answering without draining leaves the next `endpoint` frame free to
656
+ // flush it into the fresh session, where the daemon answers the same id a second time and the
657
+ // client sees two responses for one request. Then answer, because the new session cannot know
658
+ // what the old one owed and silence here is permanent.
659
+ stdinQueue.splice(0);
660
+ const lost = streamLossReplies(pending, reason);
661
+ for (const reply of lost)
662
+ emit(reply);
663
+ // How many calls this drop actually killed. Zero means the agent could not feel it.
664
+ const pendingLost = lost.length;
526
665
  // Once per process: this session has now lost its tools at least once, which is the number
527
666
  // that says whether the transport work actually landed for real users. `pendingLost` is the
528
667
  // part an agent can FEEL — almost every measured drop killed nothing in flight.
@@ -573,23 +712,45 @@ ensureDaemon) {
573
712
  // Announce this process to the daemon on the connect we already make — it is the single judge
574
713
  // of version skew, and this is the only moment it learns the agent's MCP server exists.
575
714
  const announce = `${MCP_SSE_PATH}?${PEER_VERSION_PARAM}=${encodeURIComponent(SERVER_VERSION)}&${PEER_CONTRACT_PARAM}=${encodeURIComponent(CONTRACT_FINGERPRINT)}`;
715
+ /**
716
+ * ONE reconnect per connect attempt, whoever notices the failure first.
717
+ *
718
+ * A dead socket emits on BOTH halves — `req`'s `error` and the response's `aborted`/`close`
719
+ * for the same TCP death — and this latch used to live inside the response callback, so
720
+ * `req.on('error')` scheduled a SECOND reconnect that the drop path could not see. Two chains
721
+ * came back, both died the same way, and both split again. The retry budget cannot stop that
722
+ * either: any one chain reaching an `endpoint` frame resets `attempts` to zero for all of
723
+ * them. It is the runaway behind a proxy log large enough to fill a disk, and the doubling is
724
+ * visible in the field logs as roughly two `reconnecting` lines per `reconnected` one.
725
+ */
726
+ let settled = false;
727
+ /**
728
+ * Whether this attempt ever got a response, as opposed to never reaching the daemon.
729
+ *
730
+ * `first` alone conflates two different situations, and the `req.on('error')` branch below
731
+ * treats both as a startup failure the caller handles. Before any stream exists that is right:
732
+ * there is no daemon, so rejecting is the honest answer. Once the FIRST stream has connected
733
+ * and is serving, a socket reset is a DROP, and rejecting it kills the proxy outright instead
734
+ * of reconnecting. The agent loses Reticle for the rest of the session, on its first stream,
735
+ * with nothing to retry it.
736
+ *
737
+ * It is also a race rather than a certainty, which is worse: a reset emits on both halves, so
738
+ * whether the proxy reconnects or dies depended on whether `res`'s `close` or `req`'s `error`
739
+ * won. Every later stream recovers correctly; you simply have to survive the first one.
740
+ */
741
+ let responded = false;
576
742
  const req = http.get({ host: LOOPBACK_HOST, port, path: announce }, (res) => {
743
+ responded = true;
577
744
  if (!first)
578
745
  proxyLog('reticle_mcp_proxy_reconnected', { port });
579
746
  res.setEncoding('utf8');
580
- // `end` and `error` can both fire on one response; only the first should drive a reconnect.
581
- let settled = false;
582
747
  const drop = (reason, detail) => {
583
748
  if (settled)
584
749
  return;
585
750
  settled = true;
586
- // Answer anything the dead session owed BEFORE reconnecting. The new session cannot know
587
- // about it, so silence here is permanent.
588
- const lost = streamLossReplies(pending, reason);
589
- for (const reply of lost)
590
- emit(reply);
591
- // How many calls this drop actually killed. Zero means the agent could not feel it.
592
- scheduleReconnect(reason, detail, lost.length);
751
+ // What the dead session owed is settled inside scheduleReconnect, so the other half of the
752
+ // socket's death pays exactly the same debt when it is the one that notices first.
753
+ scheduleReconnect(reason, detail);
593
754
  };
594
755
  const sse = new SseFrameParser();
595
756
  res.on('data', (chunk) => {
@@ -606,7 +767,31 @@ ensureDaemon) {
606
767
  });
607
768
  // The very first connect failing means there is no daemon to talk to — that is a startup
608
769
  // failure the caller handles. Later ones are just the daemon bouncing: keep retrying.
609
- req.on('error', (err) => first ? reject(err) : scheduleReconnect('connect_error', err.message));
770
+ req.on('error', (err) => {
771
+ if (settled)
772
+ return;
773
+ settled = true;
774
+ if (first && !responded) {
775
+ // A refused port on the very first connect is a daemon still booting, not a failure. Keep
776
+ // chasing it: nothing else will, because only a fresh client request re-probes the port
777
+ // and the client is already blocked on the call this would rescue.
778
+ if (shouldRetryFirstConnect(err, Date.now() - startedAt)) {
779
+ firstConnectAttempts++;
780
+ const retryInMs = reconnectDelayMs(firstConnectAttempts);
781
+ proxyLog('reticle_mcp_proxy_first_connect_retry', {
782
+ port,
783
+ attempt: firstConnectAttempts,
784
+ retryInMs,
785
+ note: 'the daemon has not finished booting; still waiting for it to accept',
786
+ });
787
+ setTimeout(() => connect(true), retryInMs).unref();
788
+ return;
789
+ }
790
+ reject(err);
791
+ return;
792
+ }
793
+ scheduleReconnect('connect_error', err.message);
794
+ });
610
795
  }
611
796
  connect(true);
612
797
  // ── stdin reader ─────────────────────────────────────────────────────────
package/dist/mcp/mcp.js CHANGED
@@ -353,15 +353,31 @@ export function createMcpServer(deps, profile = TOOL_SURFACE.DEFAULT) {
353
353
  // session says which one it saw.
354
354
  getSessionMetrics().recordSurface(profile);
355
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.
356
+ /**
357
+ * Record WHICH agent attached, at the handshake, for every session.
358
+ *
359
+ * `clients` and `clientVersions` were populated for almost nobody, and the reason was not that
360
+ * clients withhold `clientInfo` — they send it, and we read it. It was that the ONLY thing that
361
+ * ever called `recordClient` was the feedback hook below, which runs when a report is being built.
362
+ * So the field was really measuring "sessions that filed feedback", and a product whose users are
363
+ * agents could not say which agent, on which build, any of its numbers came from.
364
+ *
365
+ * `oninitialized` is the moment `clientInfo` first exists — reading it at construction time gets
366
+ * `undefined`, which is what made a lazy hook look like the only option.
367
+ */
368
+ server.server.oninitialized = () => {
369
+ const info = server.server.getClientVersion();
370
+ if (info?.name !== undefined)
371
+ getSessionMetrics().recordClient(info.name, info.version);
372
+ };
373
+ // Which agent is on the other end, for a feedback report. Registered as a lazy hook rather than
374
+ // read here: the handshake has not happened yet at construction time, and a report filed twenty
375
+ // tool calls later is exactly when we want the answer.
359
376
  setMcpClientNameHook(() => {
360
377
  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.
378
+ // Still records, because a client that skipped the handshake path above (a replayed initialize
379
+ // through the proxy, a transport that reconnects) is a client we would otherwise lose entirely.
380
+ // `recordClient` is idempotent per name.
365
381
  if (info?.name !== undefined)
366
382
  getSessionMetrics().recordClient(info.name, info.version);
367
383
  return info === undefined ? undefined : { name: info.name, version: info.version };
@@ -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
@@ -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
@@ -60,7 +60,8 @@ interface BrowserPoolOptions {
60
60
  export declare class BrowserPool {
61
61
  #private;
62
62
  constructor(launch: Launcher, opts: BrowserPoolOptions);
63
- /** Currently leased contexts. */
63
+ /** The TTL configured for leases — how long an untouched lease lives before the reaper reclaims it. */
64
+ leaseTtlMs(): number;
64
65
  /** Max simultaneous leases — the ceiling the parallel suite sizes its concurrency to. */
65
66
  capacity(): number;
66
67
  activeCount(): number;
@@ -68,8 +69,27 @@ export declare class BrowserPool {
68
69
  queuedCount(): number;
69
70
  /** The sessionIds of every currently leased context — used to report/group leased sessions. */
70
71
  leasedSessionIds(): string[];
71
- /** Release a lease by its sessionId (closes the context, frees the slot). No-op if unknown. */
72
+ /**
73
+ * Release a lease by its sessionId (closes the context, frees the slot). No-op if unknown.
74
+ *
75
+ * Resolves an alias first, because the agent releases with the id it was GIVEN, which for an app
76
+ * that named its own session is not the id the pool navigated with. Releasing by that name used to
77
+ * be a silent no-op that left the slot held until the reaper took it.
78
+ */
72
79
  release(sessionId: string): Promise<void>;
80
+ /**
81
+ * Record that a lease is also known by the name the APP registered.
82
+ *
83
+ * Leases are keyed by the id the pool navigated with, and the SDK normally adopts that id off the
84
+ * URL so the two agree. An app that passes an explicit `session` to `connect()` keeps its own name
85
+ * instead, which is legitimate, and the lease tool then hands the agent THAT name because it is the
86
+ * one it has to drive with. Every later touch and release arrives under it.
87
+ *
88
+ * Without this the touches hit nothing, the lease ages out despite continuous activity, and the
89
+ * reaper closes the context mid-flow — the failure in #157, where a session died on the very call
90
+ * that would have read a computed value.
91
+ */
92
+ alias(registeredId: string, leaseId: string): void;
73
93
  /** Mark a lease as still in use (called on agent activity) so the reaper doesn't reclaim it. */
74
94
  touch(sessionId: string): void;
75
95
  /**
@@ -30,6 +30,8 @@ export class BrowserPool {
30
30
  /** Leases reclaimed for going stale — see reapedLeaseCount(). */
31
31
  #reapedLeases = 0;
32
32
  #active = new Map();
33
+ /** Registered-session-id → lease id, for apps that keep their own session name. See `alias`. */
34
+ #aliases = new Map();
33
35
  /**
34
36
  * Slots claimed-or-active — the real concurrency gate. Incremented SYNCHRONOUSLY the instant an
35
37
  * acquire passes the cap check, BEFORE the async context creation, so a burst of concurrent
@@ -50,7 +52,10 @@ export class BrowserPool {
50
52
  this.#ttl = opts.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
51
53
  this.#navTimeout = opts.navTimeoutMs ?? DEFAULT_NAV_TIMEOUT_MS;
52
54
  }
53
- /** Currently leased contexts. */
55
+ /** The TTL configured for leases — how long an untouched lease lives before the reaper reclaims it. */
56
+ leaseTtlMs() {
57
+ return this.#ttl;
58
+ }
54
59
  /** Max simultaneous leases — the ceiling the parallel suite sizes its concurrency to. */
55
60
  capacity() {
56
61
  return this.#max;
@@ -66,13 +71,42 @@ export class BrowserPool {
66
71
  leasedSessionIds() {
67
72
  return [...this.#active.keys()];
68
73
  }
69
- /** Release a lease by its sessionId (closes the context, frees the slot). No-op if unknown. */
74
+ /**
75
+ * Release a lease by its sessionId (closes the context, frees the slot). No-op if unknown.
76
+ *
77
+ * Resolves an alias first, because the agent releases with the id it was GIVEN, which for an app
78
+ * that named its own session is not the id the pool navigated with. Releasing by that name used to
79
+ * be a silent no-op that left the slot held until the reaper took it.
80
+ */
70
81
  release(sessionId) {
71
- return this.#release(sessionId);
82
+ return this.#release(this.#leaseIdOf(sessionId));
83
+ }
84
+ /**
85
+ * Record that a lease is also known by the name the APP registered.
86
+ *
87
+ * Leases are keyed by the id the pool navigated with, and the SDK normally adopts that id off the
88
+ * URL so the two agree. An app that passes an explicit `session` to `connect()` keeps its own name
89
+ * instead, which is legitimate, and the lease tool then hands the agent THAT name because it is the
90
+ * one it has to drive with. Every later touch and release arrives under it.
91
+ *
92
+ * Without this the touches hit nothing, the lease ages out despite continuous activity, and the
93
+ * reaper closes the context mid-flow — the failure in #157, where a session died on the very call
94
+ * that would have read a computed value.
95
+ */
96
+ alias(registeredId, leaseId) {
97
+ if (registeredId === leaseId)
98
+ return;
99
+ if (!this.#active.has(leaseId))
100
+ return;
101
+ this.#aliases.set(registeredId, leaseId);
102
+ }
103
+ /** The lease this id refers to: itself, or whatever it is an alias for. */
104
+ #leaseIdOf(sessionId) {
105
+ return this.#active.has(sessionId) ? sessionId : (this.#aliases.get(sessionId) ?? sessionId);
72
106
  }
73
107
  /** Mark a lease as still in use (called on agent activity) so the reaper doesn't reclaim it. */
74
108
  touch(sessionId) {
75
- const lease = this.#active.get(sessionId);
109
+ const lease = this.#active.get(this.#leaseIdOf(sessionId));
76
110
  if (lease !== undefined)
77
111
  lease.touchedAt = this.#now();
78
112
  }
@@ -173,6 +207,12 @@ export class BrowserPool {
173
207
  if (lease === undefined)
174
208
  return; // already released or lost to a crash
175
209
  this.#active.delete(sessionId);
210
+ // Drop any alias pointing here, or the map grows for the life of the daemon and a later lease
211
+ // reusing that registered name would resolve to a context that is already closed.
212
+ for (const [registered, leaseId] of this.#aliases) {
213
+ if (leaseId === sessionId)
214
+ this.#aliases.delete(registered);
215
+ }
176
216
  await lease.context.close().catch(() => undefined);
177
217
  this.#releaseSlot();
178
218
  }
@@ -0,0 +1,24 @@
1
+ import { type ReticleEvent } from '@reticlehq/core';
2
+ interface RouteLearningSession {
3
+ url: string;
4
+ onEvent(handler: (event: ReticleEvent) => void): () => void;
5
+ }
6
+ interface RouteLearningBridge {
7
+ sessions: {
8
+ all(): RouteLearningSession[];
9
+ };
10
+ attachSessionReady(handler: (session: RouteLearningSession) => void): void;
11
+ }
12
+ interface RouteLearningStore {
13
+ recordRoutes(routes: readonly string[]): Promise<void>;
14
+ }
15
+ /** Coalesce navigation bursts so one crawl does not lock and rewrite project.json per event. */
16
+ export declare const ROUTE_PERSIST_DEBOUNCE_MS = 50;
17
+ /** Convert a validated route-change payload into a bounded route identity. */
18
+ export declare function routeFromEvent(event: ReticleEvent): string | undefined;
19
+ /** Read the same route shape from a session's absolute URL. */
20
+ export declare function routeFromUrl(value: string): string | undefined;
21
+ export declare function routesFromEvents(events: readonly ReticleEvent[]): string[];
22
+ /** Persist initial and subsequent routes for existing and future browser sessions. */
23
+ export declare function attachRouteLearning(bridge: RouteLearningBridge, project: RouteLearningStore): void;
24
+ export {};
@@ -0,0 +1,63 @@
1
+ import { EventType } from '@reticlehq/core';
2
+ /** Coalesce navigation bursts so one crawl does not lock and rewrite project.json per event. */
3
+ export const ROUTE_PERSIST_DEBOUNCE_MS = 50;
4
+ /** Convert a validated route-change payload into a bounded route identity. */
5
+ export function routeFromEvent(event) {
6
+ if (event.type !== EventType.ROUTE_CHANGE)
7
+ return undefined;
8
+ const pathname = event.data['pathname'];
9
+ if ('string' !== typeof pathname || 0 === pathname.length)
10
+ return undefined;
11
+ return pathname;
12
+ }
13
+ /** Read the same route shape from a session's absolute URL. */
14
+ export function routeFromUrl(value) {
15
+ try {
16
+ const url = new URL(value);
17
+ return url.pathname;
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ }
23
+ export function routesFromEvents(events) {
24
+ return events.flatMap((event) => {
25
+ const route = routeFromEvent(event);
26
+ return route === undefined ? [] : [route];
27
+ });
28
+ }
29
+ /** Persist initial and subsequent routes for existing and future browser sessions. */
30
+ export function attachRouteLearning(bridge, project) {
31
+ const attached = new WeakSet();
32
+ const pending = new Set();
33
+ let flushTimer;
34
+ const flush = () => {
35
+ flushTimer = undefined;
36
+ const routes = [...pending];
37
+ pending.clear();
38
+ if (routes.length > 0)
39
+ void project.recordRoutes(routes).catch(() => undefined);
40
+ };
41
+ const queue = (route) => {
42
+ pending.add(route);
43
+ if (flushTimer !== undefined)
44
+ clearTimeout(flushTimer);
45
+ flushTimer = setTimeout(flush, ROUTE_PERSIST_DEBOUNCE_MS);
46
+ };
47
+ const attach = (session) => {
48
+ if (attached.has(session))
49
+ return;
50
+ attached.add(session);
51
+ const initial = routeFromUrl(session.url);
52
+ if (initial !== undefined)
53
+ queue(initial);
54
+ session.onEvent((event) => {
55
+ const route = routeFromEvent(event);
56
+ if (route !== undefined)
57
+ queue(route);
58
+ });
59
+ };
60
+ for (const session of bridge.sessions.all())
61
+ attach(session);
62
+ bridge.attachSessionReady(attach);
63
+ }
@@ -26,6 +26,12 @@ export declare class ProjectStore {
26
26
  * (last PER_NAME of any one name, then cap to TOTAL overall) and writes byte-stably.
27
27
  */
28
28
  recordRun(record: Omit<RunRecord, 'at'>): Promise<void>;
29
+ /**
30
+ * Add discovered routes to the learned app map. Empty input is a no-op so "nothing observed"
31
+ * does not materialize as `learned.routes: []`; non-empty updates are serialized per file so
32
+ * concurrent browser sessions accumulate rather than overwriting one another.
33
+ */
34
+ recordRoutes(routes: readonly string[]): Promise<void>;
29
35
  /** The most-recent run for `name` (undefined on missing/malformed/none). Powers diff-vs-last. */
30
36
  lastRun(name: string): Promise<RunRecord | undefined>;
31
37
  }
@@ -1,4 +1,4 @@
1
- import { PROJECT_FILE_VERSION, PROJECT_RUN_CAP, ProjectFileSchema, ProjectReadError, } from '@reticlehq/core';
1
+ import { PROJECT_FILE_VERSION, PROJECT_ROUTE_CAP, PROJECT_RUN_CAP, ProjectFileSchema, ProjectReadError, } from '@reticlehq/core';
2
2
  import { reticleDirPaths } from './reticle-dir.js';
3
3
  import { withFileLock } from './file-lock.js';
4
4
  const JSON_INDENT = 2;
@@ -77,6 +77,30 @@ export class ProjectStore {
77
77
  await this.#fs.writeFile(path, this.#serialize(next));
78
78
  });
79
79
  }
80
+ /**
81
+ * Add discovered routes to the learned app map. Empty input is a no-op so "nothing observed"
82
+ * does not materialize as `learned.routes: []`; non-empty updates are serialized per file so
83
+ * concurrent browser sessions accumulate rather than overwriting one another.
84
+ */
85
+ async recordRoutes(routes) {
86
+ const additions = routes.filter((route) => route.length > 0);
87
+ if (0 === additions.length)
88
+ return;
89
+ const path = reticleDirPaths(this.#root).project;
90
+ await withFileLock(path, async () => {
91
+ const existing = await this.read();
92
+ const base = existing.ok ? existing.file : EMPTY_PROJECT;
93
+ const current = base.learned?.routes ?? [];
94
+ const merged = [...new Set([...current, ...additions])].slice(0, PROJECT_ROUTE_CAP);
95
+ if (merged.length === current.length &&
96
+ merged.every((route, index) => route === current[index]))
97
+ return;
98
+ const learned = { ...base.learned, routes: merged };
99
+ const next = { ...base, learned };
100
+ await this.#fs.mkdir(reticleDirPaths(this.#root).root);
101
+ await this.#fs.writeFile(path, this.#serialize(next));
102
+ });
103
+ }
80
104
  /** The most-recent run for `name` (undefined on missing/malformed/none). Powers diff-vs-last. */
81
105
  async lastRun(name) {
82
106
  const read = await this.read();