@reticlehq/server 3.3.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/LICENSE-ENTERPRISE +1 -1
  2. package/README.md +3 -3
  3. package/SKILL.md +12 -14
  4. package/dist/chromium-launch-options.d.ts +4 -3
  5. package/dist/chromium-launch-options.js +6 -2
  6. package/dist/command/cli/adhoc-suite.d.ts +22 -0
  7. package/dist/command/cli/adhoc-suite.js +90 -19
  8. package/dist/command/cli/adhoc-verdict.d.ts +35 -0
  9. package/dist/command/cli/adhoc-verdict.js +90 -3
  10. package/dist/command/cli/answers/gate-hook-message.d.ts +6 -0
  11. package/dist/command/cli/answers/gate-hook-message.js +6 -0
  12. package/dist/command/cli/auth/cloud-keystore.d.ts +0 -2
  13. package/dist/command/cli/auth/cloud-keystore.js +0 -3
  14. package/dist/command/cli/auth/cloud-session.d.ts +6 -2
  15. package/dist/command/cli/auth/cloud-session.js +2 -0
  16. package/dist/command/cli/auth/device-flow.d.ts +2 -2
  17. package/dist/command/cli/cli-doctor.js +7 -5
  18. package/dist/command/cli/cli-flow-commands.d.ts +3 -6
  19. package/dist/command/cli/cli-flow-commands.js +67 -4
  20. package/dist/command/cli/cli-parse.d.ts +8 -1
  21. package/dist/command/cli/cli-parse.js +24 -4
  22. package/dist/command/cli/cli-update-commands.js +8 -1
  23. package/dist/command/cli/cli-verify.d.ts +24 -3
  24. package/dist/command/cli/cli-verify.js +54 -11
  25. package/dist/command/cli/cloud-cli.js +64 -2
  26. package/dist/command/cli/cloud-kit.js +3 -5
  27. package/dist/command/cli/cloud-login.d.ts +1 -1
  28. package/dist/command/cli/cloud-login.js +27 -8
  29. package/dist/command/cli/demo/demo-page.js +6 -3
  30. package/dist/command/cli/doctor/browser/chromium-hint.d.ts +5 -0
  31. package/dist/command/cli/doctor/browser/chromium-hint.js +5 -0
  32. package/dist/command/cli/launch/cli-launch.d.ts +8 -0
  33. package/dist/command/cli/launch/cli-launch.js +21 -9
  34. package/dist/command/cli/launch/sdk-version-match.d.ts +84 -0
  35. package/dist/command/cli/launch/sdk-version-match.js +206 -0
  36. package/dist/command/cli/lifecycle/daemon-lifecycle.js +2 -16
  37. package/dist/command/cli/ports/resolve/cli-port.d.ts +25 -0
  38. package/dist/command/cli/ports/resolve/cli-port.js +125 -15
  39. package/dist/command/cli/setup-mcp-cli.js +21 -13
  40. package/dist/command/cli/tutorial.d.ts +2 -2
  41. package/dist/command/cli/tutorial.js +21 -7
  42. package/dist/command/cli.js +79 -11
  43. package/dist/command/daemon/binding/daemon-status-probe.d.ts +14 -2
  44. package/dist/command/daemon/binding/daemon-status-probe.js +34 -6
  45. package/dist/command/daemon/daemon-resolve.d.ts +9 -0
  46. package/dist/command/daemon/daemon-resolve.js +1 -1
  47. package/dist/command/daemon/lifetime/daemon-usefulness.d.ts +7 -1
  48. package/dist/command/daemon/lifetime/daemon-usefulness.js +30 -7
  49. package/dist/command/dev/stale-issue-guard.d.ts +3 -4
  50. package/dist/command/dev/stale-issue-guard.js +8 -8
  51. package/dist/command/setup/bringup/connect-lease.d.ts +17 -0
  52. package/dist/command/setup/bringup/connect-lease.js +38 -0
  53. package/dist/command/setup/bringup/dev-server-files.d.ts +68 -0
  54. package/dist/command/setup/bringup/dev-server-files.js +131 -0
  55. package/dist/command/setup/bringup/dev-server-log-cap.d.ts +35 -0
  56. package/dist/command/setup/bringup/dev-server-log-cap.js +121 -0
  57. package/dist/command/setup/bringup/dev-server-wait.d.ts +2 -0
  58. package/dist/command/setup/bringup/dev-server-wait.js +1 -1
  59. package/dist/command/setup/bringup/ensure-daemon.d.ts +65 -4
  60. package/dist/command/setup/bringup/ensure-daemon.js +135 -16
  61. package/dist/command/setup/bringup/static-page-server.d.ts +35 -0
  62. package/dist/command/setup/bringup/static-page-server.js +151 -0
  63. package/dist/command/setup/desktop-shape.d.ts +4 -0
  64. package/dist/command/setup/desktop-shape.js +18 -2
  65. package/dist/command/setup/init/init-port.d.ts +15 -0
  66. package/dist/command/setup/init/init-port.js +44 -0
  67. package/dist/command/setup/init/init-runtime.d.ts +1 -1
  68. package/dist/command/setup/init/init-runtime.js +91 -9
  69. package/dist/command/setup/node-effects.d.ts +27 -14
  70. package/dist/command/setup/node-effects.js +86 -45
  71. package/dist/command/setup/probe/loopback-probe-urls.d.ts +7 -0
  72. package/dist/command/setup/probe/loopback-probe-urls.js +40 -0
  73. package/dist/command/setup/probe/page-probe.d.ts +13 -2
  74. package/dist/command/setup/probe/page-probe.js +18 -3
  75. package/dist/command/setup/run-setup.d.ts +28 -1
  76. package/dist/command/setup/run-setup.js +103 -15
  77. package/dist/command/setup/session-pick.d.ts +13 -0
  78. package/dist/command/setup/session-pick.js +20 -0
  79. package/dist/command/setup/setup-command.d.ts +2 -14
  80. package/dist/command/setup/setup-command.js +105 -31
  81. package/dist/command/setup/setup-mcp.d.ts +2 -0
  82. package/dist/command/setup/setup-mcp.js +17 -8
  83. package/dist/command/setup/terminal/wait-progress.d.ts +8 -0
  84. package/dist/command/setup/terminal/wait-progress.js +17 -0
  85. package/dist/command/version/peer-announce.d.ts +6 -2
  86. package/dist/command/version/peer-announce.js +8 -6
  87. package/dist/command/version/version-nudge.d.ts +17 -3
  88. package/dist/command/version/version-nudge.js +25 -4
  89. package/dist/features/crawl/crawl-empty.js +1 -1
  90. package/dist/features/crawl/crawl-tools.d.ts +6 -0
  91. package/dist/features/crawl/crawl-tools.js +57 -0
  92. package/dist/features/crawl/crawl.js +171 -157
  93. package/dist/features/exhaust/app-coverage.d.ts +33 -0
  94. package/dist/features/exhaust/app-coverage.js +31 -0
  95. package/dist/features/exhaust/code-coverage.d.ts +47 -0
  96. package/dist/features/exhaust/code-coverage.js +77 -0
  97. package/dist/features/exhaust/exhaust-run.d.ts +33 -0
  98. package/dist/features/exhaust/exhaust-run.js +82 -0
  99. package/dist/features/exhaust/explorer.d.ts +103 -0
  100. package/dist/features/exhaust/explorer.js +195 -0
  101. package/dist/features/exhaust/failure-branches.d.ts +33 -0
  102. package/dist/features/exhaust/failure-branches.js +92 -0
  103. package/dist/features/exhaust/ledger.d.ts +202 -0
  104. package/dist/features/exhaust/ledger.js +252 -0
  105. package/dist/features/exhaust/session-fold.d.ts +31 -0
  106. package/dist/features/exhaust/session-fold.js +79 -0
  107. package/dist/features/exhaust/session-port.d.ts +36 -0
  108. package/dist/features/exhaust/session-port.js +102 -0
  109. package/dist/features/harness/drive-report.d.ts +0 -9
  110. package/dist/features/harness/drive-report.js +18 -0
  111. package/dist/features/harness/driver.js +4 -7
  112. package/dist/features/harness/fill-values.d.ts +10 -0
  113. package/dist/features/harness/fill-values.js +50 -11
  114. package/dist/features/harness/jev-driver.js +30 -13
  115. package/dist/features/harness/openai-driver.d.ts +12 -0
  116. package/dist/features/harness/openai-driver.js +21 -12
  117. package/dist/features/harness/platform-config.js +5 -6
  118. package/dist/features/license/license.d.ts +2 -2
  119. package/dist/index.d.ts +9 -0
  120. package/dist/index.js +56 -19
  121. package/dist/judgement/capsule/capsule-store.d.ts +4 -4
  122. package/dist/judgement/domain/domain-model.js +3 -1
  123. package/dist/judgement/oracles/self-instrument.js +4 -2
  124. package/dist/judgement/runs/artifact/replay-mapping.d.ts +2 -2
  125. package/dist/judgement/runs/artifact/replay-mapping.js +44 -9
  126. package/dist/judgement/runs/verification-sync.d.ts +3 -1
  127. package/dist/judgement/runs/verification-sync.js +1 -1
  128. package/dist/language/flows/change/affected.d.ts +9 -0
  129. package/dist/language/flows/change/affected.js +35 -0
  130. package/dist/language/flows/change/gate.d.ts +20 -0
  131. package/dist/language/flows/change/gate.js +12 -1
  132. package/dist/language/flows/flow-author.d.ts +7 -0
  133. package/dist/language/flows/flow-author.js +19 -0
  134. package/dist/language/flows/flow-expect-grammar.js +1 -1
  135. package/dist/language/flows/flow-journey.d.ts +54 -0
  136. package/dist/language/flows/flow-journey.js +119 -0
  137. package/dist/language/flows/flow-replay-run.d.ts +2 -3
  138. package/dist/language/flows/flow-replay-run.js +13 -47
  139. package/dist/language/flows/flow-replay.d.ts +22 -8
  140. package/dist/language/flows/flow-replay.js +55 -9
  141. package/dist/language/flows/flow-result.d.ts +2 -0
  142. package/dist/language/flows/flow-result.js +25 -0
  143. package/dist/language/flows/flow-tools.js +17 -5
  144. package/dist/language/flows/flows.d.ts +10 -1
  145. package/dist/language/flows/flows.js +90 -14
  146. package/dist/language/flows/recording/replay-halt.js +3 -1
  147. package/dist/language/flows/recording/tape/recordings.d.ts +30 -1
  148. package/dist/language/flows/recording/tape/recordings.js +55 -1
  149. package/dist/language/flows/replay.d.ts +19 -0
  150. package/dist/language/flows/replay.js +42 -6
  151. package/dist/language/flows/verify-change-tools.js +5 -2
  152. package/dist/launch-chromium.d.ts +80 -0
  153. package/dist/launch-chromium.js +218 -0
  154. package/dist/memory/cloud/cloud-config.d.ts +11 -2
  155. package/dist/memory/cloud/cloud-config.js +47 -7
  156. package/dist/memory/cloud/cloud-sync.d.ts +4 -1
  157. package/dist/memory/cloud/cloud-sync.js +6 -9
  158. package/dist/memory/cloud/harness-offer.js +5 -6
  159. package/dist/memory/cloud/harness-switch.js +5 -6
  160. package/dist/memory/cloud/sync-cycle.d.ts +77 -3
  161. package/dist/memory/cloud/sync-cycle.js +325 -55
  162. package/dist/memory/cloud/sync-daemon.js +24 -20
  163. package/dist/memory/cloud/sync-disk.js +16 -2
  164. package/dist/memory/intent/intent-shard.d.ts +10 -32
  165. package/dist/memory/intent/intent-shard.js +0 -10
  166. package/dist/memory/intent/intent-tools.js +8 -3
  167. package/dist/memory/journal/drive-flow.d.ts +18 -13
  168. package/dist/memory/journal/drive-flow.js +45 -37
  169. package/dist/memory/journal/envelope.d.ts +8 -8
  170. package/dist/memory/journal/journal-query.d.ts +14 -0
  171. package/dist/memory/journal/journal-query.js +15 -0
  172. package/dist/memory/journal/on-disk/workspace-tiers.js +2 -0
  173. package/dist/memory/journal/session-end.d.ts +9 -1
  174. package/dist/memory/journal/session-end.js +24 -12
  175. package/dist/memory/project/artifact-root-resolver.d.ts +10 -0
  176. package/dist/memory/project/artifact-root-resolver.js +46 -19
  177. package/dist/memory/project/dir/reticle-dir.d.ts +2 -0
  178. package/dist/memory/project/dir/reticle-dir.js +1 -0
  179. package/dist/memory/project/project-tools.js +3 -1
  180. package/dist/portal/bridge/auth-failure-reason.d.ts +1 -1
  181. package/dist/portal/bridge/auth-failure-reason.js +4 -2
  182. package/dist/portal/bridge/bridge.d.ts +12 -3
  183. package/dist/portal/bridge/bridge.js +49 -3
  184. package/dist/portal/input/js-coverage.d.ts +25 -0
  185. package/dist/portal/input/js-coverage.js +30 -0
  186. package/dist/portal/input/real-input.d.ts +11 -0
  187. package/dist/portal/input/real-input.js +29 -4
  188. package/dist/portal/input/viewport-tools.js +16 -6
  189. package/dist/portal/pool/playwright-launcher.d.ts +6 -0
  190. package/dist/portal/pool/playwright-launcher.js +20 -13
  191. package/dist/portal/session/dev-server/dev-command.d.ts +2 -2
  192. package/dist/portal/session/dev-server/dev-command.js +30 -12
  193. package/dist/portal/session/dev-server/dev-server-probe.js +17 -1
  194. package/dist/portal/session/dev-server/route-status-probe.d.ts +24 -0
  195. package/dist/portal/session/dev-server/route-status-probe.js +57 -0
  196. package/dist/portal/session/facts/observed-state.d.ts +11 -0
  197. package/dist/portal/session/facts/observed-state.js +34 -0
  198. package/dist/portal/session/no-session-diagnosis.d.ts +29 -0
  199. package/dist/portal/session/no-session-diagnosis.js +62 -5
  200. package/dist/portal/session/no-session-next-action.d.ts +13 -0
  201. package/dist/portal/session/no-session-next-action.js +48 -9
  202. package/dist/portal/session/no-session-watch.d.ts +18 -0
  203. package/dist/portal/session/no-session-watch.js +88 -2
  204. package/dist/portal/session/session-health.js +3 -2
  205. package/dist/portal/session/session-manager.d.ts +20 -6
  206. package/dist/portal/session/session-manager.js +19 -3
  207. package/dist/portal/session/session-reaper.d.ts +28 -0
  208. package/dist/portal/session/session-reaper.js +49 -1
  209. package/dist/portal/session/session.d.ts +10 -8
  210. package/dist/portal/session/session.js +24 -32
  211. package/dist/surface/http-server.d.ts +3 -2
  212. package/dist/surface/http-server.js +8 -5
  213. package/dist/surface/mcp/mcp-proxy.d.ts +2 -2
  214. package/dist/surface/mcp/mcp-proxy.js +2 -2
  215. package/dist/surface/mcp/mcp.js +5 -3
  216. package/dist/surface/mcp/proxy/proxy-daemon-probe.d.ts +13 -1
  217. package/dist/surface/mcp/proxy/proxy-daemon-probe.js +27 -2
  218. package/dist/surface/mcp/proxy/proxy-log.js +3 -5
  219. package/dist/surface/mcp/server-instructions.js +9 -7
  220. package/dist/surface/tools/act/after-match.d.ts +17 -0
  221. package/dist/surface/tools/act/after-match.js +30 -0
  222. package/dist/surface/tools/act/durable.d.ts +69 -0
  223. package/dist/surface/tools/act/durable.js +139 -0
  224. package/dist/surface/tools/act/navigation/navigate-arrival.d.ts +13 -6
  225. package/dist/surface/tools/act/navigation/navigate-arrival.js +47 -10
  226. package/dist/surface/tools/act/navigation/navigate-result.d.ts +1 -12
  227. package/dist/surface/tools/act/navigation/navigate-result.js +21 -3
  228. package/dist/surface/tools/act/proved-controls.d.ts +9 -0
  229. package/dist/surface/tools/act/proved-controls.js +11 -0
  230. package/dist/surface/tools/act/sequence-grade.d.ts +5 -0
  231. package/dist/surface/tools/act/sequence-grade.js +32 -1
  232. package/dist/surface/tools/act-capsule.d.ts +7 -0
  233. package/dist/surface/tools/act-capsule.js +46 -1
  234. package/dist/surface/tools/act-sequence-tool.js +25 -8
  235. package/dist/surface/tools/act-tools.js +28 -52
  236. package/dist/surface/tools/assert/assert-grade.js +12 -1
  237. package/dist/surface/tools/assert/assert-verdict.js +2 -2
  238. package/dist/surface/tools/browser-tools.js +14 -2
  239. package/dist/surface/tools/coverage-tools.js +27 -1
  240. package/dist/surface/tools/dynamic-tools.js +29 -2
  241. package/dist/surface/tools/error-recovery.js +5 -6
  242. package/dist/surface/tools/feedback-invite.js +6 -4
  243. package/dist/surface/tools/harness-explore.d.ts +3 -2
  244. package/dist/surface/tools/harness-explore.js +12 -7
  245. package/dist/surface/tools/invoke-tool.js +12 -8
  246. package/dist/surface/tools/lease-tools.js +15 -6
  247. package/dist/surface/tools/live-call-text.js +4 -1
  248. package/dist/surface/tools/observe-tools.js +9 -1
  249. package/dist/surface/tools/tool-kit.d.ts +10 -0
  250. package/dist/surface/tools/tools.js +4 -0
  251. package/dist/telemetry/argument-shape.d.ts +0 -2
  252. package/dist/telemetry/argument-shape.js +1 -5
  253. package/dist/telemetry/error-class.d.ts +2 -3
  254. package/dist/telemetry/error-class.js +2 -3
  255. package/dist/telemetry/harness-drive.d.ts +0 -2
  256. package/dist/telemetry/harness-drive.js +0 -4
  257. package/dist/telemetry/onboarding-firsts.d.ts +2 -2
  258. package/dist/telemetry/onboarding-firsts.js +2 -2
  259. package/dist/wire-journal.d.ts +3 -0
  260. package/dist/wire-journal.js +4 -0
  261. package/dist/workspace-packages.d.ts +0 -2
  262. package/dist/workspace-packages.js +1 -9
  263. package/docs/README.md +4 -2
  264. package/docs/actions.mdx +3 -4
  265. package/docs/agent-cheatsheet.md +4 -4
  266. package/docs/architecture.md +6 -6
  267. package/docs/best-practices.mdx +1 -1
  268. package/docs/capabilities.mdx +32 -1
  269. package/docs/cli/cloud.mdx +20 -8
  270. package/docs/cli/doctor.mdx +4 -0
  271. package/docs/cli/feedback.mdx +2 -2
  272. package/docs/cli/gate.mdx +9 -4
  273. package/docs/cli/update.mdx +10 -0
  274. package/docs/cli.mdx +22 -7
  275. package/docs/desktop.mdx +14 -10
  276. package/docs/environment.mdx +87 -0
  277. package/docs/faq.mdx +18 -16
  278. package/docs/first-drive.md +5 -13
  279. package/docs/flows.md +21 -2
  280. package/docs/frameworks.mdx +29 -10
  281. package/docs/getting-started.md +15 -5
  282. package/docs/http-transport.md +6 -3
  283. package/docs/human-control.md +2 -2
  284. package/docs/index.mdx +16 -6
  285. package/docs/install-agentic.mdx +2 -2
  286. package/docs/install-manual.mdx +1 -1
  287. package/docs/integration-patterns.md +3 -3
  288. package/docs/multi-agent-testing.md +1 -1
  289. package/docs/packages/babel-plugin.mdx +1 -1
  290. package/docs/packages/browser.mdx +2 -2
  291. package/docs/packages/next.mdx +3 -0
  292. package/docs/packages/react.mdx +2 -2
  293. package/docs/packages/tauri.mdx +1 -1
  294. package/docs/packages/vite-plugin.mdx +4 -2
  295. package/docs/packages.mdx +9 -9
  296. package/docs/predicates.mdx +50 -5
  297. package/docs/quickstart.mdx +12 -10
  298. package/docs/recipes.mdx +2 -2
  299. package/docs/state-management.mdx +2 -2
  300. package/docs/system-map.md +1 -1
  301. package/docs/telemetry.md +1 -1
  302. package/docs/token-efficiency.md +3 -3
  303. package/docs/tools/act-and-wait.mdx +1 -0
  304. package/docs/tools/act-sequence.mdx +1 -1
  305. package/docs/tools/act.mdx +1 -1
  306. package/docs/tools/console.mdx +1 -1
  307. package/docs/tools/inspect.mdx +1 -1
  308. package/docs/tools/network.mdx +1 -1
  309. package/docs/tools/query.mdx +1 -1
  310. package/docs/tools/sessions.mdx +1 -1
  311. package/docs/tools/snapshot.mdx +1 -1
  312. package/docs/tools/state.mdx +1 -1
  313. package/docs/tools/wait-for.mdx +1 -1
  314. package/docs/usage.md +7 -7
  315. package/docs/use-cases.mdx +157 -0
  316. package/docs/vs-screenshots.mdx +3 -3
  317. package/docs/what-is-recorded.md +2 -2
  318. package/docs/what-is-reticle.mdx +17 -20
  319. package/docs/why-reticle.mdx +14 -21
  320. package/package.json +10 -9
  321. package/dist/judgement/capsule/minimize.d.ts +0 -12
  322. package/dist/judgement/capsule/minimize.js +0 -22
  323. package/dist/judgement/phenomena/phenomena.d.ts +0 -27
  324. package/dist/judgement/phenomena/phenomena.js +0 -101
  325. package/dist/language/flows/flow-report.d.ts +0 -22
  326. package/dist/language/flows/flow-report.js +0 -106
@@ -9,13 +9,14 @@
9
9
  * It only probes while NOTHING is connected. Once a session is live the question is moot, and a
10
10
  * daemon that outlives the agent by hours has no business scanning ports it does not need.
11
11
  */
12
+ import { probeRouteStatus } from './dev-server/route-status-probe.js';
12
13
  import { probeDevServers, probeDevServerStates } from './dev-server/dev-server-probe.js';
13
14
  import { homedir } from 'node:os';
14
15
  import { registeredElsewhere } from '../../memory/recall/registered-projects.js';
15
16
  import { explainNoSession } from './no-session-diagnosis.js';
16
17
  import { detectDevCommand } from './dev-server/dev-command.js';
17
18
  import { nextActionFor, renderNextAction } from './no-session-next-action.js';
18
- import { readProjectFramework, readProjectId, readProjectPort, } from '../../command/cli/ports/resolve/cli-port.js';
19
+ import { readProjectFramework, readProjectId, readProjectIsDesktop, readProjectPort, } from '../../command/cli/ports/resolve/cli-port.js';
19
20
  import { discoverProjectConfigs } from '../../command/cli/config/config-discovery.js';
20
21
  import { hasProjectConnectedBefore, rememberConnected, } from '../../memory/recall/prior/connection-memory.js';
21
22
  import { isAlive, reticleStateHome } from '../../command/daemon/daemon.js';
@@ -24,6 +25,8 @@ import { stallUptime } from './stall-clock.js';
24
25
  import { probeDaemon } from '../../surface/mcp/mcp-proxy.js';
25
26
  import { findOccupiedSiblings } from '../../command/cli/ports/sibling-ports.js';
26
27
  import { isAuthRefusalReason } from '../bridge/auth-failure-reason.js';
28
+ import { devServersForProject } from '@reticlehq/core';
29
+ import { readDevServers } from '../../command/daemon/dev-servers.js';
27
30
  /** Slow enough to be free, fast enough that a dev server started 15s ago is already reflected. */
28
31
  const REFRESH_MS = 15_000;
29
32
  /** The URL an auto-attach opens. `localhost` for the same reason the probe uses it: both stacks. */
@@ -63,6 +66,15 @@ export function startNoSessionWatch(options) {
63
66
  */
64
67
  let slowListeners = [];
65
68
  let siblingListeners = [];
69
+ /**
70
+ * What the last-known route answered, cached beside the port scan for the same reason the scan
71
+ * is cached: the hint is read synchronously, so every fact it carries has to be gathered before
72
+ * it is asked. Keyed by URL so a status is never attributed to a route other than the one it
73
+ * came from — a fresh departure on a different URL reads as "not asked yet", not as the old
74
+ * answer.
75
+ */
76
+ let lastKnownStatus;
77
+ const routeStatus = options.routeStatus ?? probeRouteStatus;
66
78
  let running = false;
67
79
  /** Ports auto-attach has already spent its one attempt on. Bounded: never a loop, never a retry. */
68
80
  const attempted = new Set();
@@ -155,6 +167,31 @@ export function startNoSessionWatch(options) {
155
167
  * Bounded by construction: one attempt per port per daemon. A failure is recorded, never retried —
156
168
  * a retry loop against a broken Chromium install would spin for the daemon's whole life.
157
169
  */
170
+ const ownDevServerPorts = options.ownDevServerPorts ??
171
+ (() => devServersForProject(readDevServers(stateDir), {
172
+ projectId: readProjectId(directory),
173
+ root: directory,
174
+ }).map((entry) => entry.port));
175
+ /**
176
+ * Is `port` demonstrably this project's app? Either its dev server announced it, or this
177
+ * project's own last session was on it. Anything else is a guess, and a guess that opens a
178
+ * browser is worse than the sentence the diagnosis already writes about the ports it saw.
179
+ */
180
+ const attributable = (port) => {
181
+ if (ownDevServerPorts().includes(port))
182
+ return true;
183
+ const known = options.sessions.lastKnown?.();
184
+ const projectId = readProjectId(directory);
185
+ if (known === undefined || projectId === undefined || known.projectId !== projectId) {
186
+ return false;
187
+ }
188
+ try {
189
+ return Number(new URL(known.url).port) === port;
190
+ }
191
+ catch {
192
+ return false;
193
+ }
194
+ };
158
195
  const autoAttach = async (ports) => {
159
196
  const attach = options.attach;
160
197
  if (attach === undefined)
@@ -166,6 +203,8 @@ export function startNoSessionWatch(options) {
166
203
  return;
167
204
  if (!isWired())
168
205
  return;
206
+ if (!attributable(only))
207
+ return;
169
208
  attempted.add(only);
170
209
  try {
171
210
  await attach(`${LOCALHOST}:${String(only)}`);
@@ -193,6 +232,14 @@ export function startNoSessionWatch(options) {
193
232
  : options.probe === undefined
194
233
  ? await findOccupiedSiblings(options.port, probeDaemon)
195
234
  : [];
235
+ // Asked once per departed URL, on the same background cadence as the port scan, and never
236
+ // on the hint's own path. A url already answered is not asked again; a new departure is.
237
+ const known = options.sessions.lastKnown?.();
238
+ if (known !== undefined && lastKnownStatus?.url !== known.url) {
239
+ const status = await routeStatus(known.url);
240
+ if (status !== undefined)
241
+ lastKnownStatus = { url: known.url, status };
242
+ }
196
243
  await autoAttach(ports);
197
244
  })
198
245
  .catch(() => {
@@ -225,6 +272,21 @@ export function startNoSessionWatch(options) {
225
272
  */
226
273
  const lastCloseWasAuthFailure = () => isAuthRefusalReason(options.sessions.lastClosure?.()?.reason) &&
227
274
  true !== options.sessions.connectedSinceLastClosure?.();
275
+ /**
276
+ * A hello refused for anything but its token, while it is still the bridge's current state.
277
+ *
278
+ * Only closures that carry the refused page count: the bridge attaches one exactly when a HELLO
279
+ * was turned away, so an origin or handshake-pool refusal (which has its own sentence on
280
+ * `resolve`) is not mistaken for one. Same ordering clause as the token refusal above.
281
+ */
282
+ const currentHelloRefusal = () => {
283
+ const closure = options.sessions.lastClosure?.();
284
+ if (closure?.page === undefined || isAuthRefusalReason(closure.reason))
285
+ return undefined;
286
+ if (true === options.sessions.connectedSinceLastClosure?.())
287
+ return undefined;
288
+ return { reason: closure.reason, ...closure.page };
289
+ };
228
290
  const nextAction = (scope) => {
229
291
  const split = splitBrain();
230
292
  return nextActionFor({
@@ -235,8 +297,18 @@ export function startNoSessionWatch(options) {
235
297
  // Read when asked, like every other fact here: a page can dial at any moment, and a daemon
236
298
  // that cached "nothing has been refused" at boot would keep saying so.
237
299
  authRefused: lastCloseWasAuthFailure(),
300
+ ...(() => {
301
+ const refused = currentHelloRefusal();
302
+ return refused === undefined ? {} : { helloRefused: refused.reason };
303
+ })(),
238
304
  ...(split === undefined ? {} : { splitBrain: split }),
239
305
  listening,
306
+ // The url the departed tab was on — the same tombstone the prose diagnosis quotes, so the
307
+ // command and the sentence beside it name the same page.
308
+ ...(() => {
309
+ const known = options.sessions.lastKnown?.();
310
+ return known === undefined ? {} : { lastKnownUrl: known.url };
311
+ })(),
240
312
  // Read when asked, like everything else here: a `package.json` can gain a dev script, and a
241
313
  // daemon that cached "there is none" at boot would keep saying so for the rest of the day.
242
314
  dev: detectDevCommand(directory),
@@ -272,12 +344,19 @@ export function startNoSessionWatch(options) {
272
344
  // beside it still said the tab had been closed. One fact, two answers, and only one of them
273
345
  // was right. Read when asked, like the rest: a page can be refused at any moment.
274
346
  authRefused: lastCloseWasAuthFailure(),
347
+ ...(() => {
348
+ const refused = currentHelloRefusal();
349
+ return refused === undefined ? {} : { helloRefused: refused };
350
+ })(),
275
351
  // Ranks the causes. Read when asked, like the rest: `init` writes this file after the daemon
276
352
  // starts on an ordinary first install.
277
353
  ...(() => {
278
354
  const framework = readProjectFramework(directory);
279
355
  return framework === undefined ? {} : { framework };
280
356
  })(),
357
+ // A desktop app is opened by its own dev command, never by a browser URL or a lease. Read when
358
+ // asked, like the framework: `init` may wire the app after this daemon started.
359
+ ...(readProjectIsDesktop(directory) ? { desktop: true } : {}),
281
360
  // Decided from the session that actually went away, not from a lifetime tally: the lease
282
361
  // sentence is only right when the thing that vanished WAS a lease.
283
362
  leaseExpired: (() => {
@@ -289,7 +368,14 @@ export function startNoSessionWatch(options) {
289
368
  // has none to name.
290
369
  ...(() => {
291
370
  const known = options.sessions.lastKnown?.();
292
- return undefined === known ? {} : { lastKnownUrl: known.url };
371
+ if (undefined === known)
372
+ return {};
373
+ // The status rides only when it was fetched for THIS url. A departure since the fetch
374
+ // means the cached answer is about a route nobody is asking about.
375
+ const status = lastKnownStatus !== undefined && lastKnownStatus.url === known.url
376
+ ? { lastKnownStatus: lastKnownStatus.status }
377
+ : {};
378
+ return { lastKnownUrl: known.url, ...status };
293
379
  })(),
294
380
  // How long this daemon has been waiting with no app. The diagnosis uses it to surface
295
381
  // "install never finished" — the same condition telemetry already knows about.
@@ -29,7 +29,8 @@ export const PENDING_NAVIGATION_NOTICE_MS = 10_000;
29
29
  */
30
30
  const SKEW_VERDICT_WARNING = 'version skew: `dispatched` and `settled` on this session mean the event was sent, NOT that the ' +
31
31
  'app acted on it — a skewed pair drops actions silently. Re-read the control (aria-checked, ' +
32
- 'data-state, its text) before trusting any verdict from this session, and converge the versions:';
32
+ 'data-state, its text) before trusting any verdict from this session, and converge the versions ' +
33
+ '(see session.versionSkew).';
33
34
  /**
34
35
  * The age of the oldest request that STARTED and never completed, or undefined when there is none
35
36
  * worth reporting.
@@ -100,7 +101,7 @@ export function healthEnvelope(session) {
100
101
  // a skewed link makes the ACTION unreliable, and there is no point warning about the quality of an
101
102
  // observation of something that may never have happened.
102
103
  if (health.versionSkew !== undefined) {
103
- return { session: health, warning: `${SKEW_VERDICT_WARNING} ${health.versionSkew}` };
104
+ return { session: health, warning: SKEW_VERDICT_WARNING };
104
105
  }
105
106
  return health.throttled ? { session: health, warning: THROTTLED_WARNING } : { session: health };
106
107
  }
@@ -14,6 +14,17 @@ interface ResolveScope {
14
14
  }
15
15
  /** The scheme://host:port of a URL, or undefined if it can't be parsed. Used to compare origins. */
16
16
  export declare function originOf(url: string | undefined): string | undefined;
17
+ /** What a refused hello said about itself: the page it was on and the project it claimed. */
18
+ export interface RefusedPage {
19
+ readonly url?: string;
20
+ readonly projectId?: string;
21
+ }
22
+ /** One bridge-initiated close, with the page that was turned away when the hello named one. */
23
+ export interface ClosureRecord {
24
+ readonly at: number;
25
+ readonly reason: string;
26
+ readonly page?: RefusedPage;
27
+ }
17
28
  export declare class SessionManager {
18
29
  #private;
19
30
  /** Set the active project's default resolve scope (called once at daemon wiring). */
@@ -57,13 +68,16 @@ export declare class SessionManager {
57
68
  setNoSessionNextAction(next: (() => NoSessionNextAction | undefined) | undefined): void;
58
69
  noSessionNextAction(): NoSessionNextAction | undefined;
59
70
  everConnected(): boolean;
60
- /** Record a bridge-initiated close so `resolve` can explain a session that vanished. */
61
- noteClosure(reason: string, at: number): void;
71
+ /**
72
+ * Record a bridge-initiated close so `resolve` can explain a session that vanished.
73
+ *
74
+ * `page` is what the refused hello said about itself, when it said anything. A refusal is the one
75
+ * piece of positive evidence the no-session diagnosis has, and "a page was refused" without WHICH
76
+ * page leaves the reader unable to tell their app from another project's tab on the same port.
77
+ */
78
+ noteClosure(reason: string, at: number, page?: RefusedPage): void;
62
79
  /** The most recent bridge-initiated close, if any. */
63
- lastClosure(): {
64
- at: number;
65
- reason: string;
66
- } | undefined;
80
+ lastClosure(): ClosureRecord | undefined;
67
81
  connectedSinceLastClosure(): boolean;
68
82
  /**
69
83
  * The id of the session that most recently went away, if any.
@@ -325,9 +325,15 @@ export class SessionManager {
325
325
  everConnected() {
326
326
  return this.#everConnected;
327
327
  }
328
- /** Record a bridge-initiated close so `resolve` can explain a session that vanished. */
329
- noteClosure(reason, at) {
330
- this.#recentClosures.push({ at, reason });
328
+ /**
329
+ * Record a bridge-initiated close so `resolve` can explain a session that vanished.
330
+ *
331
+ * `page` is what the refused hello said about itself, when it said anything. A refusal is the one
332
+ * piece of positive evidence the no-session diagnosis has, and "a page was refused" without WHICH
333
+ * page leaves the reader unable to tell their app from another project's tab on the same port.
334
+ */
335
+ noteClosure(reason, at, page) {
336
+ this.#recentClosures.push({ at, reason, ...(page === undefined ? {} : { page }) });
331
337
  if (this.#recentClosures.length > MAX_REMEMBERED_CLOSURES)
332
338
  this.#recentClosures.shift();
333
339
  this.#connectedSinceLastClosure = false;
@@ -512,6 +518,16 @@ export class SessionManager {
512
518
  const allThrottled = bestScore >= 1;
513
519
  const RECENCY_GAP_MS = allThrottled ? 0 : 1_000;
514
520
  const clearWinner = runnerUp === undefined || best.ms + RECENCY_GAP_MS < runnerUp.ms;
521
+ // Healthy tabs heartbeat on the same cadence, so the recency gap between two of them is noise.
522
+ // When exactly one has the window focus it is the one a human is looking at: tabs piled up one
523
+ // per `init` run, and every tool called without a sessionId was refused while one of them was
524
+ // plainly the tab in front. Only among healthy ones — focus says nothing about a wedged tab.
525
+ const focused = allThrottled ? [] : candidates.filter((x) => x.s.health().focused);
526
+ const [onlyFocused, secondFocused] = focused;
527
+ if (!clearWinner && onlyFocused !== undefined && secondFocused === undefined) {
528
+ onlyFocused.s.markAgentActivity();
529
+ return onlyFocused.s;
530
+ }
515
531
  if (!clearWinner) {
516
532
  // Ambiguous: list sessions with their health so the agent can choose.
517
533
  const detail = all
@@ -22,6 +22,34 @@ export declare function composeEndedNotice(base: string, undelivered: string[]):
22
22
  * not a crash, since the agent is still connected and revives on its next action. Returns the ids.
23
23
  */
24
24
  export declare function reapIdleSessions(sessions: SessionManager): string[];
25
+ /**
26
+ * Drop sessions that have ENDED, gone quiet and lost their socket, so a listing stops offering them.
27
+ *
28
+ * `reticle_session { action: "end" }` sets state and nothing else -- it never calls
29
+ * `sessions.remove`. `SessionManager.list()` does not filter by state, and `reapIdleSessions`
30
+ * above skips ended sessions by design. The only code path in the whole server that removes a
31
+ * session is the socket `close` handler in bridge.ts. So an ended session whose socket is already
32
+ * gone has nothing left that can remove it, and the row is listed for the life of the daemon
33
+ * (#938).
34
+ *
35
+ * That is what the field report describes: `{ ended: true }` three times, the row back on the next
36
+ * `reticle_sessions` call each time, still there after every tab on the origin was closed, 17 hours
37
+ * attached. And Reticle's own advice for a stale session is
38
+ * `Call reticle_session{action:"end"} to free this session` -- which is the one thing that does not
39
+ * free it.
40
+ *
41
+ * Deliberately NOT "remove on end". The panel is client-side, so `setState` has already pushed the
42
+ * ended state to the browser and the HUD keeps it; but `end` is documented idempotent, and removing
43
+ * immediately would make a second call fail to resolve. Waiting for the session to go quiet keeps
44
+ * that, keeps a just-ended session inspectable, and still collects the case that actually hurts: a
45
+ * row nobody can get rid of.
46
+ *
47
+ * Only once the socket is no longer open. An ended session whose tab is still connected but quiet
48
+ * belongs to a page that holds a live connection; dropping the record would orphan that socket,
49
+ * leaving a connection the daemon no longer tracks. Such a session is still removed the ordinary
50
+ * way, by the `close` handler, when the tab goes.
51
+ */
52
+ export declare function reapEndedSessions(sessions: SessionManager, staleAfterMs?: number): string[];
25
53
  /** End every active session immediately (the agent / MCP client disconnected → WARN). Returns ended ids. */
26
54
  export declare function endAllSessions(sessions: SessionManager, reason: string): string[];
27
55
  /**
@@ -1,4 +1,4 @@
1
- import { PresenterTone, SESSION_LIFECYCLE, UNDELIVERED_NOTES_LABEL } from '@reticlehq/core';
1
+ import { PresenterTone, SESSION_LEASE, SESSION_LIFECYCLE, UNDELIVERED_NOTES_LABEL, } from '@reticlehq/core';
2
2
  import { log } from '../../log.js';
3
3
  /**
4
4
  * Server-authoritative session liveness. The browser idle timer is throttled in a backgrounded tab
@@ -51,6 +51,50 @@ export function reapIdleSessions(sessions) {
51
51
  log('session_reaped_idle', { sessions: ended });
52
52
  return ended;
53
53
  }
54
+ /**
55
+ * Drop sessions that have ENDED, gone quiet and lost their socket, so a listing stops offering them.
56
+ *
57
+ * `reticle_session { action: "end" }` sets state and nothing else -- it never calls
58
+ * `sessions.remove`. `SessionManager.list()` does not filter by state, and `reapIdleSessions`
59
+ * above skips ended sessions by design. The only code path in the whole server that removes a
60
+ * session is the socket `close` handler in bridge.ts. So an ended session whose socket is already
61
+ * gone has nothing left that can remove it, and the row is listed for the life of the daemon
62
+ * (#938).
63
+ *
64
+ * That is what the field report describes: `{ ended: true }` three times, the row back on the next
65
+ * `reticle_sessions` call each time, still there after every tab on the origin was closed, 17 hours
66
+ * attached. And Reticle's own advice for a stale session is
67
+ * `Call reticle_session{action:"end"} to free this session` -- which is the one thing that does not
68
+ * free it.
69
+ *
70
+ * Deliberately NOT "remove on end". The panel is client-side, so `setState` has already pushed the
71
+ * ended state to the browser and the HUD keeps it; but `end` is documented idempotent, and removing
72
+ * immediately would make a second call fail to resolve. Waiting for the session to go quiet keeps
73
+ * that, keeps a just-ended session inspectable, and still collects the case that actually hurts: a
74
+ * row nobody can get rid of.
75
+ *
76
+ * Only once the socket is no longer open. An ended session whose tab is still connected but quiet
77
+ * belongs to a page that holds a live connection; dropping the record would orphan that socket,
78
+ * leaving a connection the daemon no longer tracks. Such a session is still removed the ordinary
79
+ * way, by the `close` handler, when the tab goes.
80
+ */
81
+ export function reapEndedSessions(sessions, staleAfterMs = SESSION_LEASE.STALE_AFTER_MS) {
82
+ const dropped = [];
83
+ // Snapshot first: `remove` mutates the map this iterates.
84
+ for (const session of [...sessions.all()]) {
85
+ if (!session.isEnded())
86
+ continue;
87
+ if (session.socketOpen())
88
+ continue;
89
+ if (session.staleMs() < staleAfterMs)
90
+ continue;
91
+ if (sessions.remove(session))
92
+ dropped.push(session.id);
93
+ }
94
+ if (dropped.length > 0)
95
+ log('session_reaped_ended', { sessions: dropped });
96
+ return dropped;
97
+ }
54
98
  /** End every active session immediately (the agent / MCP client disconnected → WARN). Returns ended ids. */
55
99
  export function endAllSessions(sessions, reason) {
56
100
  const ended = [];
@@ -81,6 +125,10 @@ export class SessionReaper {
81
125
  return;
82
126
  this.#timer = setInterval(() => {
83
127
  reapIdleSessions(this.#sessions);
128
+ // Second rule, same sweep, kept as its own function: one ends a live session that went
129
+ // quiet, the other collects one that already ended. Merging them would put "end this" and
130
+ // "forget this" behind a single condition.
131
+ reapEndedSessions(this.#sessions);
84
132
  }, this.#intervalMs);
85
133
  this.#timer.unref();
86
134
  }
@@ -91,6 +91,8 @@ export declare class Session implements HandshakeFacts {
91
91
  * `reticle_session{action:"end"}` — a flag on the record, not on the page — could not help.
92
92
  */
93
93
  unresponsive(): boolean;
94
+ /** False once the socket is closing or closed: dropping this record then orphans nothing. */
95
+ socketOpen(): boolean;
94
96
  /** Wall-clock age of the session in milliseconds. */
95
97
  staleMs(): number;
96
98
  /** The document the most recent stamped event was observed under. See `#documentId`. */
@@ -114,6 +116,9 @@ export declare class Session implements HandshakeFacts {
114
116
  * against the same control after a re-render gave it a different ref.
115
117
  */
116
118
  private recordActedLabelFrom;
119
+ /** A control whose action PROVED its declared consequence — the coverage ledger's `proved`. */
120
+ recordProvedFrom(actPayload: unknown): void;
121
+ provedLabels(): ReadonlySet<string>;
117
122
  actedRefs(): ReadonlySet<string>;
118
123
  noteRateLimited(dropped: number): void;
119
124
  blindSpots(): Readonly<Record<string, number>>;
@@ -211,16 +216,13 @@ export declare class Session implements HandshakeFacts {
211
216
  total: number;
212
217
  dropped: number;
213
218
  };
219
+ /** Did the event store lose scarce evidence from a window opened at `cursor`? See `windowLost`. */
220
+ lostSince(cursor: number): boolean;
214
221
  /**
215
- * Did the EVENT STORE lose scarce evidence from a window opened at `cursor`? The input to whether a
216
- * verdict's capture was clean — see `RingBuffer.lostSince`, and never the raw drop counter, which
217
- * moves for the age and churn evictions that every live page produces continuously. The journal is
218
- * the other half of that store, so a durable read that could not reach back to `cursor` lost
219
- * exactly what an eviction would have. Both boundaries are INCLUSIVE (`t` is a millisecond many
220
- * records share) and an absent `lostThroughT` impeaches every window rather than none — the
221
- * conservative direction, both times.
222
+ * Hold events at/after `cursor` against count-cap eviction until the returned function runs, so a
223
+ * predicate's own match cannot be dropped inside the window it is graded on (#668).
222
224
  */
223
- lostSince(cursor: number): boolean;
225
+ protectWindow(cursor: number): () => void;
224
226
  onEvent(listener: (event: ReticleEvent) => void): () => void;
225
227
  onDisconnect(listener: () => void): () => void;
226
228
  /** Send a command to the browser and await its reply (or time out). */
@@ -14,9 +14,9 @@ import { span } from '../../trace.js';
14
14
  import { keepCallerContextInDaemon, noteToDaemonLog } from './daemon-answers.js';
15
15
  import { isKnownRealm, EventType, HumanControlDataSchema, HumanControlKind, HumanMarkDataSchema, HudUseDataSchema, ReticleCommand, MessageKind, parseEventPayload, PresenterTone, SESSION_HEALTH, SESSION_LEASE, SESSION_LIFECYCLE, SessionState, } from '@reticlehq/core';
16
16
  import { RingBuffer } from '@reticlehq/engine/window/ring-buffer.js';
17
- import { readJournalWriteLoss, readQueryEvents, } from '../../memory/journal/journal-query.js';
17
+ import { readJournalWriteLoss, readQueryEvents, windowLost, } from '../../memory/journal/journal-query.js';
18
18
  import {} from '@reticlehq/engine/window/ambient.js';
19
- import { ObservedState } from './facts/observed-state.js';
19
+ import { ObservedState, controlLabelsOf } from './facts/observed-state.js';
20
20
  import { recordBrowserLatency, recordHudUse, recordSdkFailure, } from '../../telemetry/session-metrics.js';
21
21
  import { LiveControl } from './human/live-control.js';
22
22
  import { ReviewStore } from './human/review-store.js';
@@ -241,6 +241,10 @@ export class Session {
241
241
  unresponsive() {
242
242
  return this.#unansweredCommands >= UNRESPONSIVE_AFTER_TIMEOUTS;
243
243
  }
244
+ /** False once the socket is closing or closed: dropping this record then orphans nothing. */
245
+ socketOpen() {
246
+ return this.#socket.readyState === WS_OPEN;
247
+ }
244
248
  /** Wall-clock age of the session in milliseconds. */
245
249
  staleMs() {
246
250
  return this.#clock() - this.#startedAt;
@@ -352,24 +356,16 @@ export class Session {
352
356
  * against the same control after a re-render gave it a different ref.
353
357
  */
354
358
  recordActedLabelFrom(result) {
355
- const payload = result.result;
356
- if (typeof payload !== 'object' || null === payload)
357
- return;
358
- const record = payload;
359
- // The TESTID first: it is the strongest identity a control has and it survives any re-render.
360
- // Coverage previously matched only on `role "name"`, so a control with a testid but no accessible
361
- // name — or on a stack where the act reply carried neither — was unrecognisable after a
362
- // re-render, and coverage read `exercised: 0` however much work had been done.
363
- const testid = record['testid'];
364
- if ('string' === typeof testid && testid.length > 0)
365
- this.#observed.recordActedLabel(testid);
366
- const role = record['role'];
367
- const name = record['name'];
368
- if (typeof role !== 'string' || typeof name !== 'string')
369
- return;
370
- if (0 === role.length || 0 === name.length)
371
- return;
372
- this.#observed.recordActedLabel(`${role} "${name}"`);
359
+ for (const label of controlLabelsOf(result.result))
360
+ this.#observed.recordActedLabel(label);
361
+ }
362
+ /** A control whose action PROVED its declared consequence — the coverage ledger's `proved`. */
363
+ recordProvedFrom(actPayload) {
364
+ for (const label of controlLabelsOf(actPayload))
365
+ this.#observed.recordProvedLabel(label);
366
+ }
367
+ provedLabels() {
368
+ return this.#observed.provedLabels();
373
369
  }
374
370
  actedRefs() {
375
371
  return this.#observed.actedRefs();
@@ -532,20 +528,16 @@ export class Session {
532
528
  bufferHealth() {
533
529
  return this.#buffer.bufferHealth();
534
530
  }
531
+ /** Did the event store lose scarce evidence from a window opened at `cursor`? See `windowLost`. */
532
+ lostSince(cursor) {
533
+ return windowLost(this.#journalReader, this.#buffer, cursor);
534
+ }
535
535
  /**
536
- * Did the EVENT STORE lose scarce evidence from a window opened at `cursor`? The input to whether a
537
- * verdict's capture was clean — see `RingBuffer.lostSince`, and never the raw drop counter, which
538
- * moves for the age and churn evictions that every live page produces continuously. The journal is
539
- * the other half of that store, so a durable read that could not reach back to `cursor` lost
540
- * exactly what an eviction would have. Both boundaries are INCLUSIVE (`t` is a millisecond many
541
- * records share) and an absent `lostThroughT` impeaches every window rather than none — the
542
- * conservative direction, both times.
536
+ * Hold events at/after `cursor` against count-cap eviction until the returned function runs, so a
537
+ * predicate's own match cannot be dropped inside the window it is graded on (#668).
543
538
  */
544
- lostSince(cursor) {
545
- if (this.#buffer.lostSince(cursor))
546
- return true;
547
- const lost = this.#journalReader?.readLoss?.();
548
- return lost !== undefined && (lost.lostThroughT === undefined || lost.lostThroughT >= cursor);
539
+ protectWindow(cursor) {
540
+ return this.#buffer.protect(cursor);
549
541
  }
550
542
  onEvent(listener) {
551
543
  this.#listeners.add(listener);
@@ -8,9 +8,10 @@ export interface SharedServer {
8
8
  * so each concurrent Claude Code client needs its own McpServer.
9
9
  * Must be called before listen.
10
10
  */
11
- attachMcp(factory: () => McpServer): void;
11
+ attachMcp(factory: (peerSkew?: string) => McpServer): void;
12
12
  /** Register the JSON the daemon returns from GET /status (live sessions + health for `reticle status`). */
13
- attachStatus(provider: () => unknown): void;
13
+ /** `held`: the request carried STATUS_HOLD_QUERY — a setup run is waiting on this daemon. */
14
+ attachStatus(provider: (held: boolean) => unknown): void;
14
15
  /**
15
16
  * Register the handler behind POST /drive: open a driveable browser at `url` and answer with the
16
17
  * session it registered.
@@ -1,8 +1,8 @@
1
1
  import * as http from 'node:http';
2
2
  import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
3
- import { MCP_SSE_PATH, MCP_MESSAGE_PATH, MCP_SHUTDOWN_EVENT, STATUS_PATH, DRIVE_PATH, } from '@reticlehq/core';
3
+ import { MCP_SSE_PATH, MCP_MESSAGE_PATH, MCP_SHUTDOWN_EVENT, STATUS_PATH, STATUS_HOLD_QUERY, DRIVE_PATH, } from '@reticlehq/core';
4
4
  import { getSessionMetrics } from '../telemetry/session-metrics.js';
5
- import { noteAgentPeer, PEER_VERSION_PARAM, PEER_CONTRACT_PARAM, } from '../command/version/peer-announce.js';
5
+ import { agentPeerSkew, PEER_VERSION_PARAM, PEER_CONTRACT_PARAM, } from '../command/version/peer-announce.js';
6
6
  import { log } from '../log.js';
7
7
  import { isLoopbackHost, isLocalWebOrigin, isLoopbackPeer, requestToken, tokensMatch, } from '../portal/bridge/token-auth.js';
8
8
  import { LOOPBACK_IDLE_MS } from './loopback-agent.js';
@@ -76,7 +76,9 @@ export function createSharedServer(options = {}) {
76
76
  }
77
77
  }
78
78
  if ('GET' === req.method && path === STATUS_PATH) {
79
- const body = JSON.stringify(statusProvider?.() ?? { running: true });
79
+ // A setup run waiting on this daemon says so on its polls; see daemon-usefulness.
80
+ const held = url.searchParams.has(STATUS_HOLD_QUERY);
81
+ const body = JSON.stringify(statusProvider?.(held) ?? { running: true });
80
82
  res.writeHead(200, { 'Content-Type': 'application/json' });
81
83
  res.end(body);
82
84
  return;
@@ -139,7 +141,7 @@ export function createSharedServer(options = {}) {
139
141
  // own check (a stderr line no agent reads), a nudge queued here rides out on the agent's next
140
142
  // tool result. That is the pair the user hits after `npm update`: a cached npx MCP package
141
143
  // talking to a daemon from a different build.
142
- noteAgentPeer(url.searchParams.get(PEER_VERSION_PARAM), url.searchParams.get(PEER_CONTRACT_PARAM));
144
+ const peerSkew = agentPeerSkew(url.searchParams.get(PEER_VERSION_PARAM), url.searchParams.get(PEER_CONTRACT_PARAM));
143
145
  if (mcpFactory === undefined) {
144
146
  res.writeHead(503, { 'Content-Type': 'text/plain' });
145
147
  res.end('MCP server not ready');
@@ -148,7 +150,8 @@ export function createSharedServer(options = {}) {
148
150
  // Fresh McpServer per connection: the MCP SDK's Protocol layer only supports
149
151
  // one active transport per Server instance, so concurrent clients each need
150
152
  // their own instance backed by the same shared ToolDeps.
151
- const mcpServer = mcpFactory();
153
+ // Handed to THIS connection's server only: the skew is about this agent, not the daemon (#1136).
154
+ const mcpServer = mcpFactory(peerSkew);
152
155
  const transport = new SSEServerTransport(MCP_MESSAGE_PATH, res);
153
156
  const sid = transport.sessionId;
154
157
  transports.set(sid, transport);
@@ -1,11 +1,11 @@
1
1
  export { SseFrameParser, type SseFrame } from './sse-frame-parser.js';
2
- export { probeDaemon, waitForDaemon } from './proxy/proxy-daemon-probe.js';
2
+ export { probeDaemon, waitForDaemon, waitForDaemonBind } from './proxy/proxy-daemon-probe.js';
3
3
  /**
4
4
  * Re-exported so every existing caller (and every spec) keeps importing the proxy's log from the
5
5
  * proxy. Where the code lives is a file-size decision; where it is imported from is an API.
6
6
  */
7
7
  export { accountProxyLogWrite, proxyLog, proxyLogPath, PROXY_LOG_CHECK_BYTES, recoverOversizedProxyLog, setProxyLogPort, } from './proxy/proxy-log.js';
8
- export { reconnectDelayMs, RECONNECT_BASE_MS, RECONNECT_CAP_MS } from './proxy/proxy-backoff.js';
8
+ export { reconnectDelayMs } from './proxy/proxy-backoff.js';
9
9
  export { MCP_PROXY_HTTP_AGENT_OPTIONS, postToSession, shouldRetryUnsentPost, type PostFailure, } from './proxy/mcp-post-transport.js';
10
10
  /**
11
11
  * Overridable so the survival spec can reach exhaustion in seconds instead of minutes — the same
@@ -6,7 +6,7 @@ import { toolsChangedNotification } from './proxy/proxy-handshake.js';
6
6
  import { LOOPBACK_HOST, MCP_SSE_PATH, MCP_SHUTDOWN_EVENT, ReticleEnv, CONTRACT_FINGERPRINT, } from '@reticlehq/core';
7
7
  import { SseFrameParser } from './sse-frame-parser.js';
8
8
  export { SseFrameParser } from './sse-frame-parser.js';
9
- export { probeDaemon, waitForDaemon } from './proxy/proxy-daemon-probe.js';
9
+ export { probeDaemon, waitForDaemon, waitForDaemonBind } from './proxy/proxy-daemon-probe.js';
10
10
  import { probeDaemon } from './proxy/proxy-daemon-probe.js';
11
11
  import { SERVER_VERSION } from '../../command/version/identity/server-version.js';
12
12
  import { buildServerInstructions } from './server-instructions.js';
@@ -33,7 +33,7 @@ import { OutageReason, OutageStage, reportMcpOutage } from './faults/mcp-outage.
33
33
  import { postToSession } from './proxy/mcp-post-transport.js';
34
34
  import { reconnectDelayMs } from './proxy/proxy-backoff.js';
35
35
  import { PROXY_IDLE_EXIT_EVENT, ProxyIdleExit, resolveProxyIdleExitMs } from './proxy-idle-exit.js';
36
- export { reconnectDelayMs, RECONNECT_BASE_MS, RECONNECT_CAP_MS } from './proxy/proxy-backoff.js';
36
+ export { reconnectDelayMs } from './proxy/proxy-backoff.js';
37
37
  export { MCP_PROXY_HTTP_AGENT_OPTIONS, postToSession, shouldRetryUnsentPost, } from './proxy/mcp-post-transport.js';
38
38
  /**
39
39
  * How many consecutive failed reconnects before the proxy stops RETRYING. It does not stop serving:
@@ -103,7 +103,7 @@ import { firstSentence } from '../tools/first-sentence.js';
103
103
  * making it a pointer costs nothing.
104
104
  */
105
105
  const PREDICATE_KINDS = 'Predicate object: { kind, ...fields }. kind is one of element | text | net | route | console | ' +
106
- 'animation | signal | state | settled | allOf | anyOf | not.';
106
+ 'animation | signal | state | settled | compare | allOf | anyOf | not.';
107
107
  /**
108
108
  * Said once per turn: the bug-catching options, and where to get the rest of the field grammar.
109
109
  *
@@ -118,9 +118,11 @@ const PREDICATE_KINDS = 'Predicate object: { kind, ...fields }. kind is one of e
118
118
  * paying for six.
119
119
  */
120
120
  const PREDICATE_FIELD_GRAMMAR_HINT = ' Bug-catching options: net.count (exact request count — catches double-submit), ' +
121
- 'net.bodyMatches (a shallow JSON match on what the SERVER answered — say the FIELD and its value, ' +
121
+ 'net.bodyMatches (a JSON field match on what the SERVER answered — say the FIELD (a dotted path reaches nested ones) and its value, ' +
122
122
  'because a substring of the body matches key names too), net.requestBodyMatches (the same on what ' +
123
123
  'the UI SENT — the verdict for "applying this filter actually puts it in the payload"), ' +
124
+ 'compare (page text vs server answer, no expected value: { left:{from:"text",scope}, ' +
125
+ 'right:{from:"net",urlContains,path}, as:"number" }), ' +
124
126
  'console.absent:true (action completed with a CLEAN ' +
125
127
  'console), absent:true on element/text (it should be gone). Call reticle_tools for the full field ' +
126
128
  'grammar of a kind.';
@@ -660,7 +662,7 @@ tools = tableForSurface(profile)) {
660
662
  content: [
661
663
  {
662
664
  type: 'text',
663
- text: JSON.stringify(liveCallValues(takeVersionSkewOnto(buildErrorPayload(message)), advertisedNames)),
665
+ text: JSON.stringify(liveCallValues(takeVersionSkewOnto(buildErrorPayload(message), deps.peerSkew), advertisedNames)),
664
666
  },
665
667
  ],
666
668
  };