@reticlehq/server 2.6.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 (276) hide show
  1. package/README.md +14 -5
  2. package/SKILL.md +251 -33
  3. package/dist/bridge/bridge.d.ts +6 -1
  4. package/dist/bridge/bridge.js +27 -4
  5. package/dist/capsule/causal-summary.d.ts +20 -0
  6. package/dist/capsule/causal-summary.js +31 -6
  7. package/dist/capsule/divergence.js +7 -1
  8. package/dist/cli/chromium-hint.d.ts +68 -0
  9. package/dist/cli/chromium-hint.js +145 -0
  10. package/dist/cli/cli-doctor.js +31 -36
  11. package/dist/cli/cli-kill.d.ts +58 -0
  12. package/dist/cli/cli-kill.js +162 -0
  13. package/dist/cli/cli-launch.d.ts +18 -0
  14. package/dist/cli/cli-launch.js +32 -1
  15. package/dist/cli/cli-parse.d.ts +16 -1
  16. package/dist/cli/cli-parse.js +32 -9
  17. package/dist/cli/cli-port.d.ts +8 -0
  18. package/dist/cli/cli-port.js +36 -1
  19. package/dist/cli/cli-verify.js +2 -2
  20. package/dist/cli/doctor-daemon-line.js +10 -1
  21. package/dist/cli/doctor-sessions-line.d.ts +12 -0
  22. package/dist/cli/doctor-sessions-line.js +66 -0
  23. package/dist/cli/port-holder.d.ts +10 -0
  24. package/dist/cli/port-holder.js +26 -2
  25. package/dist/cli.js +139 -23
  26. package/dist/crawl/crawl-tools.js +12 -2
  27. package/dist/crawl/crawl.d.ts +13 -0
  28. package/dist/crawl/crawl.js +50 -2
  29. package/dist/daemon/daemon-resilience.js +2 -2
  30. package/dist/daemon/loopback-alias.d.ts +32 -0
  31. package/dist/daemon/loopback-alias.js +80 -0
  32. package/dist/events/contradictions.d.ts +14 -0
  33. package/dist/events/contradictions.js +26 -17
  34. package/dist/events/predicate-eval.d.ts +26 -3
  35. package/dist/events/predicate-eval.js +284 -95
  36. package/dist/events/predicate-parse.d.ts +1 -1
  37. package/dist/events/predicate-parse.js +18 -4
  38. package/dist/events/predicate.js +170 -18
  39. package/dist/events/ring-buffer.d.ts +8 -0
  40. package/dist/events/ring-buffer.js +34 -1
  41. package/dist/flows/flow-classify.js +1 -1
  42. package/dist/flows/flow-tools.js +12 -4
  43. package/dist/flows/heal-run.js +1 -1
  44. package/dist/honesty/blind-spots.js +6 -2
  45. package/dist/honesty/honesty.d.ts +11 -0
  46. package/dist/honesty/honesty.js +9 -1
  47. package/dist/honesty/unsettled.d.ts +40 -0
  48. package/dist/honesty/unsettled.js +78 -0
  49. package/dist/honesty/verified.d.ts +7 -0
  50. package/dist/honesty/verified.js +27 -4
  51. package/dist/http-server.js +11 -1
  52. package/dist/index.js +29 -2
  53. package/dist/init/agent-rules.d.ts +16 -0
  54. package/dist/init/agent-rules.js +96 -17
  55. package/dist/init/astro-patch.d.ts +2 -1
  56. package/dist/init/astro-patch.js +11 -5
  57. package/dist/init/capabilities.d.ts +22 -0
  58. package/dist/init/capabilities.js +76 -0
  59. package/dist/init/confirm.d.ts +96 -0
  60. package/dist/init/confirm.js +138 -0
  61. package/dist/init/cra.js +3 -2
  62. package/dist/init/detect.d.ts +8 -0
  63. package/dist/init/detect.js +15 -0
  64. package/dist/init/non-js-project.d.ts +27 -0
  65. package/dist/init/non-js-project.js +84 -0
  66. package/dist/init/plan-framework.d.ts +9 -0
  67. package/dist/init/plan-framework.js +72 -16
  68. package/dist/init/plan.d.ts +40 -4
  69. package/dist/init/plan.js +183 -43
  70. package/dist/init/run.d.ts +24 -2
  71. package/dist/init/run.js +181 -46
  72. package/dist/init/slash-command.d.ts +1 -1
  73. package/dist/init/slash-command.js +46 -6
  74. package/dist/init/snippets.d.ts +64 -5
  75. package/dist/init/snippets.js +220 -31
  76. package/dist/init/vite-config.js +29 -3
  77. package/dist/input/network-mock-tools.d.ts +19 -0
  78. package/dist/input/network-mock-tools.js +29 -4
  79. package/dist/license/license.js +1 -1
  80. package/dist/mcp/mcp-proxy.d.ts +69 -3
  81. package/dist/mcp/mcp-proxy.js +215 -23
  82. package/dist/mcp/mcp.js +58 -7
  83. package/dist/mcp/proxy-lifecycle.d.ts +20 -1
  84. package/dist/mcp/proxy-lifecycle.js +22 -1
  85. package/dist/pool/browser-pool.d.ts +22 -2
  86. package/dist/pool/browser-pool.js +44 -4
  87. package/dist/pool/playwright-launcher.js +10 -2
  88. package/dist/project/learned-routes.d.ts +24 -0
  89. package/dist/project/learned-routes.js +63 -0
  90. package/dist/project/project-store.d.ts +6 -0
  91. package/dist/project/project-store.js +25 -1
  92. package/dist/session/live-control-tools.js +39 -2
  93. package/dist/session/live-control.d.ts +7 -9
  94. package/dist/session/live-control.js +34 -1
  95. package/dist/session/no-session-diagnosis.d.ts +23 -4
  96. package/dist/session/no-session-diagnosis.js +128 -44
  97. package/dist/session/no-session-watch.js +12 -0
  98. package/dist/session/ref-provenance.d.ts +54 -0
  99. package/dist/session/ref-provenance.js +61 -0
  100. package/dist/session/review-store.d.ts +19 -0
  101. package/dist/session/review-store.js +34 -5
  102. package/dist/session/session-lease.js +1 -1
  103. package/dist/session/session-manager.d.ts +9 -0
  104. package/dist/session/session-manager.js +11 -0
  105. package/dist/session/session.d.ts +8 -0
  106. package/dist/session/session.js +12 -0
  107. package/dist/status-payload.d.ts +13 -1
  108. package/dist/status-payload.js +6 -1
  109. package/dist/telemetry/app-instrumented.d.ts +37 -0
  110. package/dist/telemetry/app-instrumented.js +54 -0
  111. package/dist/telemetry/automation-hint.d.ts +17 -0
  112. package/dist/telemetry/automation-hint.js +71 -0
  113. package/dist/telemetry/bug-found.d.ts +0 -20
  114. package/dist/telemetry/bug-found.js +38 -21
  115. package/dist/telemetry/cli-telemetry.js +7 -1
  116. package/dist/telemetry/daemon-telemetry.d.ts +4 -4
  117. package/dist/telemetry/daemon-telemetry.js +8 -4
  118. package/dist/telemetry/feedback.js +1 -1
  119. package/dist/telemetry/init-telemetry.js +9 -1
  120. package/dist/telemetry/install-source.d.ts +36 -0
  121. package/dist/telemetry/install-source.js +40 -0
  122. package/dist/telemetry/run-telemetry.js +4 -1
  123. package/dist/telemetry/session-metrics.d.ts +3 -3
  124. package/dist/telemetry/session-metrics.js +9 -4
  125. package/dist/telemetry/telemetry.d.ts +7 -1
  126. package/dist/telemetry/telemetry.js +17 -2
  127. package/dist/telemetry/tool-refused.d.ts +19 -0
  128. package/dist/telemetry/tool-refused.js +55 -0
  129. package/dist/telemetry/verification-of.js +72 -8
  130. package/dist/temp-dir.d.ts +27 -0
  131. package/dist/temp-dir.js +46 -0
  132. package/dist/tools/act-tools.js +77 -11
  133. package/dist/tools/assert-grade.d.ts +1 -1
  134. package/dist/tools/assert-grade.js +27 -5
  135. package/dist/tools/assert-verdict.js +25 -1
  136. package/dist/tools/contract-tools.js +4 -0
  137. package/dist/tools/dynamic-tools.js +1 -1
  138. package/dist/tools/error-recovery.d.ts +10 -0
  139. package/dist/tools/error-recovery.js +70 -5
  140. package/dist/tools/feedback-invite.js +1 -1
  141. package/dist/tools/invoke-tool.js +149 -32
  142. package/dist/tools/lease-tools.d.ts +29 -1
  143. package/dist/tools/lease-tools.js +58 -5
  144. package/dist/tools/navigate-result.js +1 -1
  145. package/dist/tools/observe-tools.js +1 -1
  146. package/dist/tools/observed-nothing.js +1 -1
  147. package/dist/tools/react-grace.d.ts +48 -0
  148. package/dist/tools/react-grace.js +87 -0
  149. package/dist/tools/read-tools.js +20 -4
  150. package/dist/tools/settle-in-flight.d.ts +79 -0
  151. package/dist/tools/settle-in-flight.js +106 -0
  152. package/dist/tools/snapshot-delta.d.ts +7 -0
  153. package/dist/tools/snapshot-delta.js +130 -6
  154. package/dist/tools/tool-kit.d.ts +52 -5
  155. package/dist/tools/tool-kit.js +55 -24
  156. package/dist/tools/tool-surface.js +28 -9
  157. package/dist/tools/tools.js +38 -4
  158. package/dist/visual/capture-cleanup.d.ts +16 -0
  159. package/dist/visual/capture-cleanup.js +39 -0
  160. package/dist/visual/visual-tools.d.ts +18 -0
  161. package/dist/visual/visual-tools.js +24 -5
  162. package/docs/README.md +56 -11
  163. package/docs/actions.mdx +87 -0
  164. package/docs/agent-cheatsheet.md +102 -26
  165. package/docs/architecture.md +46 -35
  166. package/docs/benchmarks.md +76 -72
  167. package/docs/best-practices.mdx +211 -0
  168. package/docs/capabilities.mdx +387 -0
  169. package/docs/cli/affected.mdx +51 -0
  170. package/docs/cli/capsules.mdx +69 -0
  171. package/docs/cli/cloud.mdx +127 -0
  172. package/docs/cli/doctor.mdx +76 -0
  173. package/docs/cli/drive.mdx +55 -0
  174. package/docs/cli/feedback.mdx +67 -0
  175. package/docs/cli/gate.mdx +64 -0
  176. package/docs/cli/help.mdx +92 -0
  177. package/docs/cli/hunt.mdx +57 -0
  178. package/docs/cli/identify.mdx +62 -0
  179. package/docs/cli/init.mdx +94 -0
  180. package/docs/cli/kill.mdx +91 -0
  181. package/docs/cli/license.mdx +48 -0
  182. package/docs/cli/mcp.mdx +61 -0
  183. package/docs/cli/open.mdx +68 -0
  184. package/docs/cli/restart.mdx +60 -0
  185. package/docs/cli/rollback.mdx +35 -0
  186. package/docs/cli/serve.mdx +62 -0
  187. package/docs/cli/status.mdx +64 -0
  188. package/docs/cli/stop.mdx +56 -0
  189. package/docs/cli/telemetry.mdx +57 -0
  190. package/docs/cli/update.mdx +52 -0
  191. package/docs/cli/verify.mdx +57 -0
  192. package/docs/cli/version.mdx +39 -0
  193. package/docs/cli/watch.mdx +55 -0
  194. package/docs/cli.mdx +219 -0
  195. package/docs/debugging.md +13 -9
  196. package/docs/deploy-checks.md +15 -11
  197. package/docs/desktop-apps.md +49 -40
  198. package/docs/desktop.mdx +188 -0
  199. package/docs/docs.json +363 -0
  200. package/docs/enterprise.md +20 -16
  201. package/docs/faq.mdx +88 -0
  202. package/docs/fixtures.md +11 -7
  203. package/docs/flows.md +26 -22
  204. package/docs/for-agents.md +71 -0
  205. package/docs/frameworks.mdx +199 -0
  206. package/docs/gate-plan.md +45 -41
  207. package/docs/gates.md +23 -19
  208. package/docs/getting-started.md +76 -65
  209. package/docs/human-control.md +22 -18
  210. package/docs/index.mdx +101 -0
  211. package/docs/install-agentic.mdx +215 -0
  212. package/docs/install-manual.mdx +194 -0
  213. package/docs/instrumentation.mdx +232 -0
  214. package/docs/integration-patterns.md +38 -34
  215. package/docs/local-registry.md +16 -12
  216. package/docs/multi-agent-testing.md +16 -12
  217. package/docs/packages/babel-plugin.mdx +70 -0
  218. package/docs/packages/browser.mdx +111 -0
  219. package/docs/packages/core.mdx +106 -0
  220. package/docs/packages/electron.mdx +87 -0
  221. package/docs/packages/eslint-plugin.mdx +84 -0
  222. package/docs/packages/next.mdx +91 -0
  223. package/docs/packages/react.mdx +91 -0
  224. package/docs/packages/server.mdx +98 -0
  225. package/docs/packages/tauri.mdx +63 -0
  226. package/docs/packages/test.mdx +108 -0
  227. package/docs/packages/vite-plugin.mdx +96 -0
  228. package/docs/packages.mdx +217 -0
  229. package/docs/platform-integration.md +33 -26
  230. package/docs/predicates.mdx +179 -0
  231. package/docs/quickstart.mdx +211 -0
  232. package/docs/recipes.mdx +242 -0
  233. package/docs/skill-file.mdx +105 -0
  234. package/docs/state-management.mdx +197 -0
  235. package/docs/style.css +14 -0
  236. package/docs/system-map.md +47 -32
  237. package/docs/telemetry-contract.md +101 -46
  238. package/docs/telemetry-events.mdx +430 -0
  239. package/docs/telemetry.md +63 -50
  240. package/docs/testing.md +10 -6
  241. package/docs/token-efficiency.md +30 -26
  242. package/docs/tools-act-and-wait.mdx +123 -0
  243. package/docs/tools-act-sequence.mdx +120 -0
  244. package/docs/tools-act.mdx +82 -0
  245. package/docs/tools-assert.mdx +70 -0
  246. package/docs/tools-console.mdx +59 -0
  247. package/docs/tools-inspect.mdx +85 -0
  248. package/docs/tools-navigate.mdx +63 -0
  249. package/docs/tools-network.mdx +86 -0
  250. package/docs/tools-observe.mdx +89 -0
  251. package/docs/tools-overview.mdx +157 -0
  252. package/docs/tools-query.mdx +82 -0
  253. package/docs/tools-session-and-feedback.mdx +91 -0
  254. package/docs/tools-sessions.mdx +65 -0
  255. package/docs/tools-snapshot.mdx +75 -0
  256. package/docs/tools-state.mdx +81 -0
  257. package/docs/tools-tools-and-run.mdx +125 -0
  258. package/docs/tools-wait-for.mdx +66 -0
  259. package/docs/troubleshooting.mdx +193 -0
  260. package/docs/usage.md +175 -153
  261. package/docs/vs-chrome-devtools-mcp.mdx +82 -0
  262. package/docs/vs-playwright-mcp.mdx +85 -0
  263. package/docs/vs-screenshots.mdx +96 -0
  264. package/docs/what-is-reticle.mdx +63 -0
  265. package/docs/why-reticle.mdx +107 -0
  266. package/package.json +2 -2
  267. package/docs/matrix/2.5.0/claude-code-machine.json +0 -19
  268. package/docs/matrix/2.5.0/codex-machine.json +0 -20
  269. package/docs/matrix/2.5.0/cursor-machine.json +0 -22
  270. package/docs/matrix/2.5.0/gemini-machine.json +0 -22
  271. package/docs/matrix/2.5.0/opencode-machine.json +0 -22
  272. package/docs/matrix/2.5.0/vscode-machine.json +0 -22
  273. package/docs/matrix/2.5.0/windsurf-machine.json +0 -22
  274. package/docs/matrix/MATRIX.md +0 -26
  275. package/docs/matrix/README.md +0 -59
  276. package/docs/mint.json +0 -61
@@ -2,14 +2,15 @@ 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';
9
9
  import { SERVER_VERSION } from '../version/server-version.js';
10
10
  import { PEER_VERSION_PARAM, PEER_CONTRACT_PARAM } from '../version/peer-announce.js';
11
- import { onStreamDrop, onClientRequest, onReconnectBudgetSpent, OnDrop, OnRequest, } from './proxy-lifecycle.js';
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,11 +551,19 @@ 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
+ }
557
+ // The expiry is the proof that the reconnect we believed was in flight is not coming — a
558
+ // wedged port accepts the socket and never serves SSE, so `connect` neither resolves nor
559
+ // rejects and `dormant` would otherwise stay false forever. Going dormant is what makes the
560
+ // NEXT request re-probe the port instead of queueing behind a reconnect that will never
561
+ // land, and it is the only path by which a daemon started AFTER we gave up is ever found.
562
+ dormant = onQueueExpired() === OnDrop.DORMANT;
444
563
  proxyLog('reticle_mcp_proxy_queue_expired', {
445
564
  port,
446
- note: 'answered queued requests instead of holding them for a daemon that never arrived',
565
+ dormant,
566
+ note: 'answered queued requests and went dormant; the next client request re-probes the port',
447
567
  });
448
568
  }, QUEUE_WAIT_MS);
449
569
  queueTimer.unref();
@@ -457,6 +577,15 @@ ensureDaemon) {
457
577
  };
458
578
  let stopped = false;
459
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;
460
589
  /**
461
590
  * The ONE way a line leaves for the daemon — so a POST that never lands still owes an answer.
462
591
  *
@@ -502,6 +631,7 @@ ensureDaemon) {
502
631
  clearQueueTimer();
503
632
  for (const queued of stdinQueue.splice(0))
504
633
  forward(url, queued);
634
+ handshakeAnswered = true;
505
635
  return;
506
636
  }
507
637
  if ('message' === event && !replay.shouldSuppressInbound(data)) {
@@ -511,14 +641,30 @@ ensureDaemon) {
511
641
  emit(data);
512
642
  }
513
643
  }
514
- function scheduleReconnect(reason, detail, pendingLost = 0) {
644
+ function scheduleReconnect(reason, detail) {
515
645
  if (stopped)
516
646
  return;
517
647
  postUrl = null;
518
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;
519
665
  // Once per process: this session has now lost its tools at least once, which is the number
520
666
  // that says whether the transport work actually landed for real users. `pendingLost` is the
521
- // part an agent can FEEL — 320 of 321 measured drops killed nothing in flight.
667
+ // part an agent can FEEL — almost every measured drop killed nothing in flight.
522
668
  reportMcpOutage(OutageStage.FIRST, { reason, attempts, pendingLost });
523
669
  // The severe stage: we are about to stop retrying. Reported so the share of sessions where
524
670
  // MCP never came back on its own is a number rather than an anecdote.
@@ -566,23 +712,45 @@ ensureDaemon) {
566
712
  // Announce this process to the daemon on the connect we already make — it is the single judge
567
713
  // of version skew, and this is the only moment it learns the agent's MCP server exists.
568
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;
569
742
  const req = http.get({ host: LOOPBACK_HOST, port, path: announce }, (res) => {
743
+ responded = true;
570
744
  if (!first)
571
745
  proxyLog('reticle_mcp_proxy_reconnected', { port });
572
746
  res.setEncoding('utf8');
573
- // `end` and `error` can both fire on one response; only the first should drive a reconnect.
574
- let settled = false;
575
747
  const drop = (reason, detail) => {
576
748
  if (settled)
577
749
  return;
578
750
  settled = true;
579
- // Answer anything the dead session owed BEFORE reconnecting. The new session cannot know
580
- // about it, so silence here is permanent.
581
- const lost = streamLossReplies(pending, reason);
582
- for (const reply of lost)
583
- emit(reply);
584
- // How many calls this drop actually killed. Zero means the agent could not feel it.
585
- 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);
586
754
  };
587
755
  const sse = new SseFrameParser();
588
756
  res.on('data', (chunk) => {
@@ -599,7 +767,31 @@ ensureDaemon) {
599
767
  });
600
768
  // The very first connect failing means there is no daemon to talk to — that is a startup
601
769
  // failure the caller handles. Later ones are just the daemon bouncing: keep retrying.
602
- 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
+ });
603
795
  }
604
796
  connect(true);
605
797
  // ── stdin reader ─────────────────────────────────────────────────────────
package/dist/mcp/mcp.js CHANGED
@@ -18,6 +18,7 @@ import { SERVER_VERSION } from '../version/server-version.js';
18
18
  import { MCP_SERVER_NAME } from '../init/mcp.js';
19
19
  import { setMcpClientNameHook } from '../telemetry/feedback-context.js';
20
20
  import { getSessionMetrics } from '../telemetry/session-metrics.js';
21
+ import { parsePredicate } from '../events/predicate-parse.js';
21
22
  /**
22
23
  * Merge the runtime-spliced envelope (health/lease/age/control) into a session-bound tool's declared
23
24
  * outputSchema so structuredContent validation keeps those fields instead of dropping them. The tool's
@@ -272,6 +273,17 @@ export function installFriendlyArgErrors(server, examples, allowedKeys = new Map
272
273
  // McpError.message already carries "MCP error -32602: "; re-wrapping would print it twice.
273
274
  const raw = error instanceof Error ? error.message : String(error);
274
275
  const detail = raw.replace(/^MCP error -?\d+:\s*/, '');
276
+ // A malformed PREDICATE gets the sentence, not the array. The SDK validates tool input against
277
+ // the same zod schema and throws a serialized issue list — so the readable message
278
+ // `parsePredicate` exists to produce was being generated only on paths that never run over
279
+ // MCP, which is every path a real agent uses. Found by driving a live app: `reticle_assert`
280
+ // answered a near-miss predicate with a raw zod array naming the unknown keys and not one
281
+ // field that would have worked. It already carries its own guidance, so the generic example is
282
+ // dropped rather than argued with.
283
+ const predicateHelp = predicateMessage(args);
284
+ if (predicateHelp !== undefined) {
285
+ throw new McpError(ErrorCode.InvalidParams, predicateHelp);
286
+ }
275
287
  const example = examples.get(toolName);
276
288
  const help = example === undefined
277
289
  ? ` Call reticle_tools { names: ["${toolName}"] } for its parameters.`
@@ -280,6 +292,29 @@ export function installFriendlyArgErrors(server, examples, allowedKeys = new Map
280
292
  }
281
293
  };
282
294
  }
295
+ /**
296
+ * The readable reason a predicate argument did not parse, or undefined when the predicate is fine.
297
+ *
298
+ * Undefined matters as much as the message: a call can fail validation for a reason that has nothing
299
+ * to do with the predicate (a bad `timeout_ms`, a missing `ref`), and replacing that error with a
300
+ * predicate explanation would be confidently wrong. Re-parsing is the honest test — if it parses,
301
+ * the predicate is not what broke.
302
+ */
303
+ function predicateMessage(args) {
304
+ if (typeof args !== 'object' || null === args)
305
+ return undefined;
306
+ const record = args;
307
+ const predicate = record['predicate'] ?? record['until'];
308
+ if (predicate === undefined)
309
+ return undefined;
310
+ try {
311
+ parsePredicate(predicate);
312
+ return undefined;
313
+ }
314
+ catch (error) {
315
+ return error instanceof Error ? error.message : undefined;
316
+ }
317
+ }
283
318
  /**
284
319
  * Answer a call for a real-but-un-advertised tool with the way to make it work.
285
320
  *
@@ -318,15 +353,31 @@ export function createMcpServer(deps, profile = TOOL_SURFACE.DEFAULT) {
318
353
  // session says which one it saw.
319
354
  getSessionMetrics().recordSurface(profile);
320
355
  const server = new McpServer(SERVER_INFO, { instructions: SERVER_INSTRUCTIONS });
321
- // Which agent is on the other end, taken from its own `initialize` handshake. Registered as a lazy
322
- // hook rather than read here: the handshake has not happened yet at construction time, and a
323
- // 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.
324
376
  setMcpClientNameHook(() => {
325
377
  const info = server.server.getClientVersion();
326
- // Record it as a side effect of the first read so the session summary can report WHICH agents
327
- // drove this daemon — the multi-agent story, visible without a second plumbing path.
328
- // Name AND version: the version was already in this handshake and was being dropped, so a
329
- // 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.
330
381
  if (info?.name !== undefined)
331
382
  getSessionMetrics().recordClient(info.name, info.version);
332
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
@@ -44,6 +44,25 @@ export declare function onStreamDrop(daemonListening: boolean): OnDrop;
44
44
  * whether the server survives.
45
45
  */
46
46
  export declare function onReconnectBudgetSpent(): OnDrop;
47
+ /**
48
+ * What to do when queued requests expire with nothing connected. Always DORMANT.
49
+ *
50
+ * This is the hole that produced the worst report we have had: a first-run daemon came up wedged, the
51
+ * proxy spent its budget against it, and from then on every call returned −32001 — including after
52
+ * the human killed the wedged process and started a healthy daemon on the same port. The proxy never
53
+ * looked again.
54
+ *
55
+ * The cause is a state that lies. `WAKE` clears `dormant` and starts a reconnect; if that reconnect
56
+ * neither resolves nor rejects (exactly what a wedged port does — it accepts the socket and never
57
+ * serves SSE), `dormant` stays false forever. Every later request then reads as "a reconnect is
58
+ * already coming" and QUEUEs, the queue expires, and the request is answered with a failure. Nothing
59
+ * in that loop ever re-probes the port, so a daemon that appears afterwards is invisible.
60
+ *
61
+ * Expiring the queue is the proof that the in-flight reconnect is not coming. Going dormant makes the
62
+ * NEXT client request wake and re-probe — which is the only thing that can discover a daemon that
63
+ * arrived late, and it costs nothing when one has not.
64
+ */
65
+ export declare function onQueueExpired(): OnDrop;
47
66
  /** What to do with a client message. */
48
67
  export declare const OnRequest: {
49
68
  /** Connected — post it straight through. */
@@ -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
@@ -47,6 +47,27 @@ export function onStreamDrop(daemonListening) {
47
47
  export function onReconnectBudgetSpent() {
48
48
  return OnDrop.DORMANT;
49
49
  }
50
+ /**
51
+ * What to do when queued requests expire with nothing connected. Always DORMANT.
52
+ *
53
+ * This is the hole that produced the worst report we have had: a first-run daemon came up wedged, the
54
+ * proxy spent its budget against it, and from then on every call returned −32001 — including after
55
+ * the human killed the wedged process and started a healthy daemon on the same port. The proxy never
56
+ * looked again.
57
+ *
58
+ * The cause is a state that lies. `WAKE` clears `dormant` and starts a reconnect; if that reconnect
59
+ * neither resolves nor rejects (exactly what a wedged port does — it accepts the socket and never
60
+ * serves SSE), `dormant` stays false forever. Every later request then reads as "a reconnect is
61
+ * already coming" and QUEUEs, the queue expires, and the request is answered with a failure. Nothing
62
+ * in that loop ever re-probes the port, so a daemon that appears afterwards is invisible.
63
+ *
64
+ * Expiring the queue is the proof that the in-flight reconnect is not coming. Going dormant makes the
65
+ * NEXT client request wake and re-probe — which is the only thing that can discover a daemon that
66
+ * arrived late, and it costs nothing when one has not.
67
+ */
68
+ export function onQueueExpired() {
69
+ return OnDrop.DORMANT;
70
+ }
50
71
  /** What to do with a client message. */
51
72
  export const OnRequest = {
52
73
  /** Connected — post it straight through. */
@@ -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
  }