@reticlehq/server 2.12.0 → 2.13.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 (386) hide show
  1. package/SKILL.md +67 -109
  2. package/dist/bridge/auth-failure-reason.d.ts +6 -2
  3. package/dist/bridge/auth-failure-reason.js +15 -8
  4. package/dist/bridge/bridge.d.ts +18 -1
  5. package/dist/bridge/bridge.js +247 -164
  6. package/dist/bridge/pairing-token.d.ts +5 -0
  7. package/dist/bridge/pairing-token.js +28 -0
  8. package/dist/bridge/protocol-skew.d.ts +37 -0
  9. package/dist/bridge/protocol-skew.js +45 -0
  10. package/dist/capsule/capsule-store.d.ts +6 -6
  11. package/dist/capsule/causal-summary.js +7 -2
  12. package/dist/cli/cli-doctor.js +31 -1
  13. package/dist/cli/cli-launch.d.ts +11 -1
  14. package/dist/cli/cli-launch.js +59 -10
  15. package/dist/cli/cli-parse.d.ts +15 -1
  16. package/dist/cli/cli-parse.js +212 -8
  17. package/dist/cli/cli-verify.d.ts +1 -0
  18. package/dist/cli/cli-verify.js +3 -2
  19. package/dist/cli/cloud-cli.js +428 -259
  20. package/dist/cli/cloud-keystore.d.ts +37 -0
  21. package/dist/cli/cloud-keystore.js +97 -0
  22. package/dist/cli/cloud-kit.d.ts +37 -0
  23. package/dist/cli/cloud-kit.js +121 -0
  24. package/dist/cli/cloud-login.d.ts +17 -0
  25. package/dist/cli/cloud-login.js +183 -0
  26. package/dist/cli/cloud-session.d.ts +48 -0
  27. package/dist/cli/cloud-session.js +53 -0
  28. package/dist/cli/config-discovery.js +1 -0
  29. package/dist/cli/daemon-start-options.d.ts +32 -0
  30. package/dist/cli/daemon-start-options.js +38 -0
  31. package/dist/cli/device-flow.d.ts +76 -0
  32. package/dist/cli/device-flow.js +56 -0
  33. package/dist/cli/doctor-rows.d.ts +9 -0
  34. package/dist/cli/doctor-rows.js +9 -0
  35. package/dist/cli/drive-attach.d.ts +13 -0
  36. package/dist/cli/drive-attach.js +18 -0
  37. package/dist/cli/drive-command.js +7 -3
  38. package/dist/cli/mcp-command.js +51 -20
  39. package/dist/cli/project-name.d.ts +30 -0
  40. package/dist/cli/project-name.js +44 -0
  41. package/dist/cli/status-next-action.d.ts +14 -0
  42. package/dist/cli/status-next-action.js +4 -4
  43. package/dist/cli.d.ts +1 -0
  44. package/dist/cli.js +134 -80
  45. package/dist/cloud/cloud-config.d.ts +22 -1
  46. package/dist/cloud/cloud-config.js +64 -5
  47. package/dist/cloud/cloud-sync.d.ts +5 -5
  48. package/dist/cloud/cloud-sync.js +33 -2
  49. package/dist/cloud/sync-cycle.d.ts +110 -0
  50. package/dist/cloud/sync-cycle.js +270 -0
  51. package/dist/cloud/sync-daemon.d.ts +69 -0
  52. package/dist/cloud/sync-daemon.js +260 -0
  53. package/dist/cloud/sync-disk.d.ts +9 -0
  54. package/dist/cloud/sync-disk.js +191 -0
  55. package/dist/cloud/sync-hash.d.ts +2 -0
  56. package/dist/cloud/sync-hash.js +35 -0
  57. package/dist/crawl/crawl-tools.js +5 -3
  58. package/dist/crawl/crawl.d.ts +3 -0
  59. package/dist/crawl/crawl.js +5 -0
  60. package/dist/daemon/daemon-resolve.d.ts +43 -0
  61. package/dist/daemon/daemon-resolve.js +69 -0
  62. package/dist/daemon/dev-servers.d.ts +15 -0
  63. package/dist/daemon/dev-servers.js +68 -0
  64. package/dist/daemon/wake-decision.d.ts +32 -0
  65. package/dist/daemon/wake-decision.js +38 -0
  66. package/dist/domain/domain-tools.js +2 -2
  67. package/dist/events/contradictions.d.ts +18 -0
  68. package/dist/events/contradictions.js +134 -21
  69. package/dist/events/declared.d.ts +7 -4
  70. package/dist/events/declared.js +15 -6
  71. package/dist/events/echo-mismatch.js +64 -14
  72. package/dist/events/event-filters.d.ts +8 -0
  73. package/dist/events/event-filters.js +19 -2
  74. package/dist/events/predicate-eval.js +53 -5
  75. package/dist/events/predicate-precheck.d.ts +8 -0
  76. package/dist/events/predicate-precheck.js +41 -0
  77. package/dist/events/predicate-route.d.ts +14 -0
  78. package/dist/events/predicate-route.js +36 -8
  79. package/dist/events/predicate.d.ts +5 -0
  80. package/dist/events/predicate.js +37 -5
  81. package/dist/events/split-text-miss.d.ts +30 -0
  82. package/dist/events/split-text-miss.js +44 -0
  83. package/dist/flows/flake.d.ts +6 -6
  84. package/dist/flows/flow-memory-consult.d.ts +55 -0
  85. package/dist/flows/flow-memory-consult.js +38 -0
  86. package/dist/flows/flow-replay-run.d.ts +18 -7
  87. package/dist/flows/flow-replay-run.js +239 -22
  88. package/dist/flows/flow-replay.js +11 -2
  89. package/dist/flows/flow-step-runners.d.ts +8 -0
  90. package/dist/flows/flow-step-runners.js +8 -2
  91. package/dist/flows/flow-store-for-session.d.ts +31 -0
  92. package/dist/flows/flow-store-for-session.js +33 -0
  93. package/dist/flows/flow-tools.js +58 -32
  94. package/dist/flows/flows.d.ts +8 -0
  95. package/dist/flows/flows.js +468 -2
  96. package/dist/flows/heal-run.js +5 -2
  97. package/dist/flows/predicate-to-expect.d.ts +17 -12
  98. package/dist/flows/predicate-to-expect.js +30 -12
  99. package/dist/flows/recordings.d.ts +13 -1
  100. package/dist/flows/recordings.js +17 -2
  101. package/dist/flows/replay.d.ts +22 -2
  102. package/dist/flows/replay.js +28 -2
  103. package/dist/flows/verify-change-tools.js +5 -2
  104. package/dist/honesty/blind-spots.d.ts +55 -2
  105. package/dist/honesty/blind-spots.js +63 -3
  106. package/dist/honesty/honesty.d.ts +24 -0
  107. package/dist/honesty/honesty.js +25 -1
  108. package/dist/honesty/instrumentation-gaps.d.ts +9 -0
  109. package/dist/honesty/instrumentation-gaps.js +29 -2
  110. package/dist/honesty/verified.d.ts +2 -1
  111. package/dist/honesty/verified.js +16 -1
  112. package/dist/http-server.js +12 -0
  113. package/dist/impact/impact-recorder.d.ts +31 -8
  114. package/dist/impact/impact-recorder.js +104 -21
  115. package/dist/impact/impact-store.d.ts +15 -1
  116. package/dist/impact/impact-store.js +43 -2
  117. package/dist/index.js +113 -7
  118. package/dist/init/agent-rules.d.ts +13 -6
  119. package/dist/init/agent-rules.js +23 -12
  120. package/dist/init/astro-patch.d.ts +16 -0
  121. package/dist/init/astro-patch.js +28 -0
  122. package/dist/init/capabilities.d.ts +4 -1
  123. package/dist/init/capabilities.js +22 -2
  124. package/dist/init/confirm.d.ts +31 -7
  125. package/dist/init/confirm.js +48 -8
  126. package/dist/init/cra.d.ts +20 -4
  127. package/dist/init/cra.js +57 -17
  128. package/dist/init/csp-check.js +17 -2
  129. package/dist/init/csp-doctor.d.ts +8 -1
  130. package/dist/init/csp-doctor.js +8 -3
  131. package/dist/init/dev-script.d.ts +3 -1
  132. package/dist/init/dev-script.js +8 -5
  133. package/dist/init/format-generated.d.ts +18 -0
  134. package/dist/init/format-generated.js +39 -0
  135. package/dist/init/install-hint.d.ts +9 -0
  136. package/dist/init/install-hint.js +29 -0
  137. package/dist/init/mcp-clients.d.ts +7 -0
  138. package/dist/init/mcp-clients.js +7 -0
  139. package/dist/init/mcp.d.ts +1 -1
  140. package/dist/init/mcp.js +1 -1
  141. package/dist/init/node-io.js +11 -1
  142. package/dist/init/plan-framework.js +81 -45
  143. package/dist/init/plan.d.ts +20 -7
  144. package/dist/init/plan.js +20 -45
  145. package/dist/init/preflight.d.ts +28 -0
  146. package/dist/init/preflight.js +38 -0
  147. package/dist/init/run.d.ts +32 -9
  148. package/dist/init/run.js +141 -119
  149. package/dist/init/slash-command.d.ts +1 -1
  150. package/dist/init/snippets.js +41 -25
  151. package/dist/init/vite-config.d.ts +1 -1
  152. package/dist/init/vite-config.js +39 -25
  153. package/dist/init/workspace-apps.d.ts +17 -10
  154. package/dist/init/workspace-apps.js +110 -0
  155. package/dist/input/network-mock-tools.js +3 -6
  156. package/dist/input/real-input.js +25 -5
  157. package/dist/input/scroll-tools.js +4 -6
  158. package/dist/input/viewport-tools.js +5 -4
  159. package/dist/intent/inline-intent.d.ts +10 -19
  160. package/dist/intent/inline-intent.js +91 -1
  161. package/dist/intent/intent-shard-store.d.ts +72 -0
  162. package/dist/intent/intent-shard-store.js +203 -0
  163. package/dist/intent/intent-shard.d.ts +411 -0
  164. package/dist/intent/intent-shard.js +176 -0
  165. package/dist/intent/intent-store.d.ts +12 -0
  166. package/dist/intent/intent-store.js +21 -0
  167. package/dist/intent/intent-subject.d.ts +45 -0
  168. package/dist/intent/intent-subject.js +177 -0
  169. package/dist/intent/intent-tools.js +65 -2
  170. package/dist/intent/open-intents.d.ts +21 -0
  171. package/dist/intent/open-intents.js +15 -0
  172. package/dist/license/license-env.d.ts +29 -0
  173. package/dist/license/license-env.js +148 -0
  174. package/dist/loopback-agent.d.ts +31 -0
  175. package/dist/loopback-agent.js +57 -0
  176. package/dist/mcp/attach-memory.d.ts +18 -2
  177. package/dist/mcp/attach-memory.js +50 -7
  178. package/dist/mcp/client-identity.d.ts +31 -0
  179. package/dist/mcp/client-identity.js +51 -0
  180. package/dist/mcp/mcp-post-transport.d.ts +26 -0
  181. package/dist/mcp/mcp-post-transport.js +109 -0
  182. package/dist/mcp/mcp-proxy.d.ts +2 -1
  183. package/dist/mcp/mcp-proxy.js +52 -62
  184. package/dist/mcp/mcp.js +7 -1
  185. package/dist/mcp/proxy-backoff.d.ts +11 -0
  186. package/dist/mcp/proxy-backoff.js +13 -0
  187. package/dist/mcp/proxy-daemon-probe.d.ts +1 -0
  188. package/dist/mcp/proxy-daemon-probe.js +7 -1
  189. package/dist/mcp/proxy-handshake.d.ts +23 -0
  190. package/dist/mcp/proxy-handshake.js +31 -0
  191. package/dist/mcp/tool-catalog-cache.d.ts +8 -0
  192. package/dist/mcp/tool-catalog-cache.js +12 -0
  193. package/dist/memory/memory-tools.d.ts +2 -0
  194. package/dist/memory/memory-tools.js +82 -0
  195. package/dist/memory/project-memory.d.ts +34 -0
  196. package/dist/memory/project-memory.js +96 -0
  197. package/dist/oracles/propose-consequences.js +12 -3
  198. package/dist/pool/browser-pool.d.ts +63 -4
  199. package/dist/pool/browser-pool.js +113 -4
  200. package/dist/pool/playwright-launcher.js +9 -0
  201. package/dist/project/project-tools.js +3 -3
  202. package/dist/project/reticle-dir.d.ts +15 -2
  203. package/dist/project/reticle-dir.js +21 -4
  204. package/dist/runs/run-store.d.ts +10 -0
  205. package/dist/runs/run-store.js +10 -0
  206. package/dist/runs/verification-sync.d.ts +0 -11
  207. package/dist/runs/verification-sync.js +22 -1
  208. package/dist/runs/verify-server.d.ts +8 -1
  209. package/dist/runs/verify-server.js +14 -2
  210. package/dist/session/command-timeout.d.ts +5 -2
  211. package/dist/session/command-timeout.js +5 -4
  212. package/dist/session/lease-visibility.d.ts +67 -0
  213. package/dist/session/lease-visibility.js +76 -0
  214. package/dist/session/no-session-diagnosis.d.ts +23 -0
  215. package/dist/session/no-session-diagnosis.js +58 -17
  216. package/dist/session/no-session-next-action.d.ts +7 -0
  217. package/dist/session/no-session-next-action.js +42 -3
  218. package/dist/session/no-session-watch.d.ts +10 -6
  219. package/dist/session/no-session-watch.js +51 -19
  220. package/dist/session/pending-commands.d.ts +11 -1
  221. package/dist/session/pending-commands.js +12 -2
  222. package/dist/session/resolve-within.d.ts +34 -0
  223. package/dist/session/resolve-within.js +53 -0
  224. package/dist/session/session-info.d.ts +18 -0
  225. package/dist/session/session-info.js +11 -0
  226. package/dist/session/session-manager.d.ts +14 -0
  227. package/dist/session/session-manager.js +61 -3
  228. package/dist/session/session-mirror.d.ts +16 -0
  229. package/dist/session/session-mirror.js +22 -0
  230. package/dist/session/session-recommendation.d.ts +19 -5
  231. package/dist/session/session-recommendation.js +25 -9
  232. package/dist/session/session-successor.js +36 -1
  233. package/dist/session/session-tools.js +2 -2
  234. package/dist/session/session.d.ts +22 -2
  235. package/dist/session/session.js +87 -7
  236. package/dist/session/wall-clock.d.ts +12 -0
  237. package/dist/session/wall-clock.js +14 -0
  238. package/dist/setup/agent-configs.d.ts +96 -0
  239. package/dist/setup/agent-configs.js +333 -0
  240. package/dist/setup/agent-io.d.ts +9 -0
  241. package/dist/setup/agent-io.js +16 -0
  242. package/dist/setup/agent-writer.d.ts +51 -0
  243. package/dist/setup/agent-writer.js +115 -0
  244. package/dist/setup/approval-migration.d.ts +47 -0
  245. package/dist/setup/approval-migration.js +68 -0
  246. package/dist/setup/auto-approve.d.ts +85 -0
  247. package/dist/setup/auto-approve.js +150 -0
  248. package/dist/setup/bridge-port.d.ts +15 -0
  249. package/dist/setup/bridge-port.js +24 -0
  250. package/dist/setup/desktop-shape.d.ts +57 -0
  251. package/dist/setup/desktop-shape.js +63 -0
  252. package/dist/setup/dev-server-wait.d.ts +60 -0
  253. package/dist/setup/dev-server-wait.js +65 -0
  254. package/dist/setup/drive-agent.d.ts +71 -0
  255. package/dist/setup/drive-agent.js +171 -0
  256. package/dist/setup/drive-plan.d.ts +68 -0
  257. package/dist/setup/drive-plan.js +130 -0
  258. package/dist/setup/ensure-daemon.d.ts +29 -0
  259. package/dist/setup/ensure-daemon.js +58 -0
  260. package/dist/setup/init-runtime.d.ts +47 -0
  261. package/dist/setup/init-runtime.js +160 -0
  262. package/dist/setup/interrupt.d.ts +25 -0
  263. package/dist/setup/interrupt.js +40 -0
  264. package/dist/setup/license-key.d.ts +39 -0
  265. package/dist/setup/license-key.js +72 -0
  266. package/dist/setup/listeners.d.ts +37 -0
  267. package/dist/setup/listeners.js +85 -0
  268. package/dist/setup/node-effects.d.ts +60 -0
  269. package/dist/setup/node-effects.js +210 -0
  270. package/dist/setup/page-probe.d.ts +39 -0
  271. package/dist/setup/page-probe.js +49 -0
  272. package/dist/setup/relaunch.d.ts +36 -0
  273. package/dist/setup/relaunch.js +57 -0
  274. package/dist/setup/remaining-steps.d.ts +19 -0
  275. package/dist/setup/remaining-steps.js +31 -0
  276. package/dist/setup/run-setup.d.ts +99 -0
  277. package/dist/setup/run-setup.js +187 -0
  278. package/dist/setup/session-pick.d.ts +47 -0
  279. package/dist/setup/session-pick.js +51 -0
  280. package/dist/setup/setup-command.d.ts +45 -0
  281. package/dist/setup/setup-command.js +199 -0
  282. package/dist/setup/setup-options.d.ts +74 -0
  283. package/dist/setup/setup-options.js +67 -0
  284. package/dist/setup/transcripts.d.ts +16 -0
  285. package/dist/setup/transcripts.js +43 -0
  286. package/dist/status-payload.d.ts +14 -1
  287. package/dist/status-payload.js +23 -1
  288. package/dist/telemetry/daemon-telemetry.d.ts +8 -1
  289. package/dist/telemetry/daemon-telemetry.js +52 -5
  290. package/dist/telemetry/error-class.js +1 -1
  291. package/dist/telemetry/feedback-context.d.ts +9 -1
  292. package/dist/telemetry/feedback-context.js +60 -29
  293. package/dist/telemetry/feedback-local.d.ts +15 -0
  294. package/dist/telemetry/feedback-local.js +56 -0
  295. package/dist/telemetry/feedback.d.ts +11 -0
  296. package/dist/telemetry/feedback.js +19 -6
  297. package/dist/telemetry/init-telemetry.d.ts +2 -0
  298. package/dist/telemetry/init-telemetry.js +2 -0
  299. package/dist/telemetry/install-source.d.ts +14 -0
  300. package/dist/telemetry/install-source.js +30 -0
  301. package/dist/telemetry/license-activation.d.ts +7 -1
  302. package/dist/telemetry/license-activation.js +9 -5
  303. package/dist/telemetry/mcp-connection.js +7 -0
  304. package/dist/telemetry/project-profile.d.ts +12 -1
  305. package/dist/telemetry/project-profile.js +11 -6
  306. package/dist/telemetry/proxy-telemetry.d.ts +3 -0
  307. package/dist/telemetry/proxy-telemetry.js +21 -0
  308. package/dist/telemetry/session-metrics.d.ts +12 -0
  309. package/dist/telemetry/session-metrics.js +35 -1
  310. package/dist/telemetry/telemetry.js +17 -2
  311. package/dist/telemetry/tool-refused.d.ts +3 -1
  312. package/dist/telemetry/tool-refused.js +34 -2
  313. package/dist/tools/act-capsule.d.ts +10 -0
  314. package/dist/tools/act-capsule.js +8 -2
  315. package/dist/tools/act-danger.d.ts +2 -1
  316. package/dist/tools/act-danger.js +11 -3
  317. package/dist/tools/act-observation.d.ts +41 -0
  318. package/dist/tools/act-observation.js +36 -0
  319. package/dist/tools/act-preflight.d.ts +20 -0
  320. package/dist/tools/act-preflight.js +98 -0
  321. package/dist/tools/act-sequence-retry.d.ts +45 -0
  322. package/dist/tools/act-sequence-retry.js +58 -0
  323. package/dist/tools/act-sequence-tool.d.ts +13 -0
  324. package/dist/tools/act-sequence-tool.js +135 -0
  325. package/dist/tools/act-target.d.ts +14 -0
  326. package/dist/tools/act-target.js +47 -0
  327. package/dist/tools/act-tools.d.ts +21 -1
  328. package/dist/tools/act-tools.js +100 -213
  329. package/dist/tools/assert-verdict.js +22 -16
  330. package/dist/tools/browser-tools.js +6 -2
  331. package/dist/tools/dynamic-tools.js +24 -5
  332. package/dist/tools/error-recovery.d.ts +1 -0
  333. package/dist/tools/error-recovery.js +8 -0
  334. package/dist/tools/invoke-tool.js +59 -9
  335. package/dist/tools/lease-tools.d.ts +30 -4
  336. package/dist/tools/lease-tools.js +169 -5
  337. package/dist/tools/merged-name-redirect.d.ts +13 -0
  338. package/dist/tools/merged-name-redirect.js +22 -0
  339. package/dist/tools/numeric-bounds.d.ts +100 -0
  340. package/dist/tools/numeric-bounds.js +125 -0
  341. package/dist/tools/observe-tools.js +47 -40
  342. package/dist/tools/read-tools.js +28 -7
  343. package/dist/tools/real-input-attempt.d.ts +10 -0
  344. package/dist/tools/real-input-attempt.js +59 -1
  345. package/dist/tools/reconcile-tools.js +4 -4
  346. package/dist/tools/tool-kit.d.ts +12 -0
  347. package/dist/tools/tool-kit.js +6 -1
  348. package/dist/tools/tool-names.d.ts +2 -0
  349. package/dist/tools/tool-names.js +2 -0
  350. package/dist/tools/tools-helpers.d.ts +19 -0
  351. package/dist/tools/tools-helpers.js +46 -0
  352. package/dist/tools/tools.d.ts +2 -0
  353. package/dist/tools/tools.js +56 -8
  354. package/dist/update/reticle-deps.d.ts +9 -0
  355. package/dist/update/reticle-deps.js +27 -0
  356. package/dist/update/sdk-sync.d.ts +1 -2
  357. package/dist/update/sdk-sync.js +1 -21
  358. package/dist/update/update-nudge.d.ts +18 -0
  359. package/dist/update/update-nudge.js +20 -0
  360. package/dist/version/peer-announce.js +4 -2
  361. package/dist/version/sdk-fix.d.ts +47 -0
  362. package/dist/version/sdk-fix.js +127 -0
  363. package/dist/version/version-nudge.d.ts +14 -0
  364. package/dist/version/version-nudge.js +21 -0
  365. package/dist/version/version-skew.d.ts +38 -3
  366. package/dist/version/version-skew.js +83 -7
  367. package/dist/visual/visual-store.d.ts +6 -6
  368. package/dist/visual/visual-store.js +18 -16
  369. package/dist/visual/visual-tools.js +57 -12
  370. package/docs/cli/doctor.mdx +1 -0
  371. package/docs/cli/serve.mdx +4 -2
  372. package/docs/cli.mdx +7 -3
  373. package/docs/desktop.mdx +1 -1
  374. package/docs/docs.json +1 -1
  375. package/docs/getting-started.md +19 -3
  376. package/docs/index.mdx +9 -6
  377. package/docs/install-agentic.mdx +38 -12
  378. package/docs/install-manual.mdx +1 -1
  379. package/docs/packages.mdx +1 -1
  380. package/docs/skill-file.mdx +3 -3
  381. package/docs/telemetry-contract.md +108 -4
  382. package/docs/tools-act-sequence.mdx +16 -0
  383. package/docs/usage.md +1 -1
  384. package/package.json +4 -3
  385. package/dist/init/cursor.d.ts +0 -24
  386. package/dist/init/cursor.js +0 -80
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Telling the human that the agent went somewhere they cannot see.
3
+ *
4
+ * A lease is a separate pooled context, and it is the highest-value path in the product for
5
+ * autonomous work. It also has one failure mode that is entirely about people: the HUD lives in the
6
+ * human's OWN tab, so while an agent drives a lease that tab shows nothing at all. An idle agent and
7
+ * a busy one are then indistinguishable from the only seat a human occupies.
8
+ *
9
+ * That is not hypothetical. An agent followed the daemon's own throttle recommendation into a lease,
10
+ * drove fifteen calls, and the developer watching an empty HUD asked why nothing was running. The
11
+ * product was working perfectly and looked broken, which is the worst combination available.
12
+ *
13
+ * The fix rides on machinery that already exists — `pushNarration` posts `ReticleCommand.NARRATE`
14
+ * and every SDK in the field already renders it — so nothing here moves the wire contract. It is a
15
+ * message to tabs that are ALREADY connected, not a new capability.
16
+ */
17
+ /**
18
+ * Shown on a watching human's HUD when an agent takes a lease.
19
+ *
20
+ * States the fact and its consequence, because the fact alone ("agent leased a context") does not
21
+ * tell somebody why their screen is still. The consequence is the whole reason to say anything.
22
+ */
23
+ export const AGENT_DRIVING_ELSEWHERE = 'An agent is driving a separate leased context — its actions will not appear in this tab.';
24
+ /** Shown when the lease is released, so a HUD that went quiet is explained in both directions. */
25
+ export const AGENT_DRIVING_HERE_AGAIN = 'The agent released its separate context — this tab is live again.';
26
+ /**
27
+ * Shown to a tab that CONNECTS while a lease is already open.
28
+ *
29
+ * The acquire-time notice only reaches tabs that were already connected, so opening the app — or
30
+ * merely reloading it — during a lease landed somebody on a silent HUD with nothing explaining it.
31
+ * That is the same dark-HUD failure the acquire notice exists to prevent, arrived at from the other
32
+ * direction, and it is the more common one: a person opens the dashboard BECAUSE they want to watch.
33
+ *
34
+ * Worded for arrival rather than for a change of state — nothing just happened from this tab's point
35
+ * of view, so "an agent is driving elsewhere" would describe an event it never saw.
36
+ */
37
+ export const AGENT_ALREADY_DRIVING_ELSEWHERE = 'An agent is already driving a separate leased context — its actions will not appear in this tab.';
38
+ /**
39
+ * Whether a session arriving now should be told a lease is already running.
40
+ *
41
+ * Same rules as `watchersToNotify`, expressed for one session: never tell a leased context about
42
+ * itself, and never tell somebody watching app A about a lease against app B.
43
+ */
44
+ export function shouldGreetWithLeaseNotice(session, leasedSessionIds, leasedProjectIds) {
45
+ if (leasedSessionIds.includes(session.id))
46
+ return false;
47
+ if (0 === leasedSessionIds.length)
48
+ return false;
49
+ return leasedProjectIds.some((projectId) => projectId === undefined || session.projectId === undefined || session.projectId === projectId);
50
+ }
51
+ /**
52
+ * Which connected sessions belong to a human who should be told.
53
+ *
54
+ * Two exclusions, both deliberate:
55
+ *
56
+ * - Leased sessions. They ARE the agent's context; narrating "an agent is driving elsewhere" into
57
+ * the tab doing the driving is noise, and with several leases open they would talk about each
58
+ * other. Membership comes from the pool rather than an id prefix, because a naming convention is
59
+ * not a fact and `pool.leasedSessionIds()` is.
60
+ *
61
+ * - Other projects. One daemon serves many, and somebody watching app A does not need to hear about
62
+ * a lease taken against app B. A session that declares no project is NOT excluded: an older SDK
63
+ * sends no projectId, and silently dropping those would reintroduce the dark HUD for exactly the
64
+ * people least likely to work out why.
65
+ *
66
+ * Pure: no IO, no clock, no session objects. The caller does the posting.
67
+ */
68
+ export function watchersToNotify(sessions, leasedSessionIds, projectId) {
69
+ const leased = new Set(leasedSessionIds);
70
+ return sessions
71
+ .filter((session) => !leased.has(session.id))
72
+ .filter((session) => projectId === undefined ||
73
+ session.projectId === undefined ||
74
+ session.projectId === projectId)
75
+ .map((session) => session.id);
76
+ }
@@ -18,6 +18,7 @@
18
18
  * Pure: everything it needs is passed in, so the probe that discovers listening ports stays out of
19
19
  * the hot resolve() path and this stays unit-testable.
20
20
  */
21
+ import { NoSessionReason } from '@reticlehq/core';
21
22
  import { type LeaseBrowserState } from './lease-availability.js';
22
23
  export interface NoSessionFacts {
23
24
  /** Whether ANY session has connected to this daemon since it booted. */
@@ -134,4 +135,26 @@ export interface NoSessionFacts {
134
135
  * generic one that contradicts it.
135
136
  */
136
137
  export declare const SELF_RECOVERING_MARKER = "Then call reticle_sessions again \u2014 it will appear within a second of the page loading.";
138
+ /**
139
+ * The diagnosis, and the CODE for the branch that produced it.
140
+ *
141
+ * The prose has always been well-ranked and it was the only output, so the population that installs
142
+ * Reticle and never gets an app connected arrived as one undifferentiated silence. "Restarted the
143
+ * dev server and it still did not connect" and "never started the app" need opposite fixes and were
144
+ * the same absence (#615).
145
+ *
146
+ * One function returning both, rather than a classifier beside the writer. A reason computed
147
+ * separately would drift from the sentence the user is actually shown, and then the metric would
148
+ * describe a diagnosis nobody received.
149
+ */
150
+ export declare function explainNoSession(facts: NoSessionFacts): {
151
+ reason: NoSessionReason;
152
+ message: string;
153
+ };
154
+ /**
155
+ * The message alone, for the callers that render it.
156
+ *
157
+ * Kept as the narrow surface it always was: most callers want the sentence, and handing them the
158
+ * pair would spread a telemetry concern across every error path that shows a user this text.
159
+ */
137
160
  export declare function diagnoseNoSession(facts: NoSessionFacts): string;
@@ -18,6 +18,7 @@
18
18
  * Pure: everything it needs is passed in, so the probe that discovers listening ports stays out of
19
19
  * the hot resolve() path and this stays unit-testable.
20
20
  */
21
+ import { NoSessionReason } from '@reticlehq/core';
21
22
  import { leaseCaveat } from './lease-availability.js';
22
23
  import { DEV_SERVER_PORTS } from '../cli/cli-port.js';
23
24
  import { siblingListenerNote } from '../cli/sibling-ports.js';
@@ -300,7 +301,30 @@ function stallClause(facts) {
300
301
  'The most likely cause is that the dev server was not restarted after adding the Reticle ' +
301
302
  'plugin, so the running bundle carries no SDK. Restart the dev server first. ');
302
303
  }
303
- export function diagnoseNoSession(facts) {
304
+ function alreadyListeningClause(listening) {
305
+ if (0 === listening.length)
306
+ return '';
307
+ if (1 === listening.length) {
308
+ const port = listening[0];
309
+ return (` An app is already listening on ${String(port)}; just open http://localhost:${String(port)} ` +
310
+ '— do not start a second stack.');
311
+ }
312
+ return (` An app is already listening on ${listening.join(', ')}; just open one of those URLs — do not ` +
313
+ 'start a second stack.');
314
+ }
315
+ /**
316
+ * The diagnosis, and the CODE for the branch that produced it.
317
+ *
318
+ * The prose has always been well-ranked and it was the only output, so the population that installs
319
+ * Reticle and never gets an app connected arrived as one undifferentiated silence. "Restarted the
320
+ * dev server and it still did not connect" and "never started the app" need opposite fixes and were
321
+ * the same absence (#615).
322
+ *
323
+ * One function returning both, rather than a classifier beside the writer. A reason computed
324
+ * separately would drift from the sentence the user is actually shown, and then the metric would
325
+ * describe a diagnosis nobody received.
326
+ */
327
+ export function explainNoSession(facts) {
304
328
  const { everConnected, initialized, listening, port } = facts;
305
329
  // Named when known: a claim about a missing file is a claim about ONE directory.
306
330
  const where = facts.directory === undefined
@@ -311,19 +335,23 @@ export function diagnoseNoSession(facts) {
311
335
  // from the field (#157): an aged-out lease produced "the tab was closed … ask the human to
312
336
  // reopen the app", which is wrong on every clause — there is no human tab, and the recovery it
313
337
  // names is unavailable to the caller while the one that works goes unmentioned. The reporter
314
- // went looking for a port mismatch. Hedged rather than swapped: a human tab may ALSO have
315
- // closed, and this does not know which session went.
338
+ // went looking for a port mismatch.
339
+ //
340
+ // The hedge that used to close this branch ("if you were driving a human tab instead…") is
341
+ // gone with the reason for it. `leaseExpired` was a lifetime count of reaps, so it could not
342
+ // tell which session went and had to cover both; it now names the session that actually
343
+ // departed (#611), so this branch is only reached when the thing that vanished WAS the lease.
316
344
  if (true === facts.leaseExpired) {
317
- return ('no browser session connected, but one WAS connected to this daemon earlier, so the wiring ' +
318
- 'is correct. This daemon has expired at least one pooled lease, so the likeliest cause is ' +
319
- 'that a lease you were using aged out; a lease is a headless context, not a human tab, and ' +
320
- 'it takes its cookies with it (so an authenticated app needs signing in again). Re-acquire ' +
321
- 'with reticle_lease {action:"acquire", url} and carry on. If you were driving a human tab ' +
322
- `instead, it went away — reopen it or run ${OPEN_CMD_BARE}. ${RETRY}`);
345
+ return reason(NoSessionReason.LEASE_EXPIRED, 'no browser session connected, but one WAS connected to this daemon earlier, so the wiring ' +
346
+ 'is correct. The session that went away was a pooled lease and it aged out; a lease is a ' +
347
+ 'headless context, not a human tab, and it takes its cookies with it (so an authenticated ' +
348
+ 'app needs signing in again). Re-acquire with reticle_lease {action:"acquire", url} and ' +
349
+ `carry on.${alreadyListeningClause(listening)} ${RETRY}`);
323
350
  }
324
- return ('no browser session connected, but one WAS connected to this daemon earlier, so the wiring ' +
351
+ return reason(NoSessionReason.TAB_GONE, 'no browser session connected, but one WAS connected to this daemon earlier, so the wiring ' +
325
352
  'is correct. The tab was closed, navigated away, or hard-reloaded. Ask the human to reopen ' +
326
- `the app (or run ${OPEN_CMD_BARE}), or reload the tab. ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`);
353
+ `the app (or run ${OPEN_CMD_BARE}), or reload the tab.${alreadyListeningClause(listening)} ` +
354
+ `${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`);
327
355
  }
328
356
  // Ranked ahead of every "we have never seen one" branch below, because it is the one fact that
329
357
  // OUTRANKS them: those branches all reason from an absence, and this reasons from a recorded
@@ -334,13 +362,13 @@ export function diagnoseNoSession(facts) {
334
362
  ? `${unattributedListeners(listening)} If the dev server is not running, start it first ` +
335
363
  '(the command is in `next_action`).'
336
364
  : unattributedListeners(listening);
337
- return `${RESTARTED_LEAD} ${OPEN_THE_APP} ${listeners} ${rankedCauses(facts)} ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`;
365
+ return reason(NoSessionReason.APP_NOT_REOPENED, `${RESTARTED_LEAD} ${OPEN_THE_APP} ${listeners} ${rankedCauses(facts)} ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`);
338
366
  }
339
367
  // A config found elsewhere outranks every "you may have no SDK" branch below: those reason from
340
368
  // an absence in ONE directory, and this is a file we read in another. Ranked here so the reader is
341
369
  // never sent through an install on a project that has demonstrably been installed.
342
370
  if (!initialized && (facts.configsElsewhere ?? []).length > 0) {
343
- return ('no browser session connected, and this daemon has never seen one. ' +
371
+ return reason(NoSessionReason.CONFIG_ELSEWHERE, 'no browser session connected, and this daemon has never seen one. ' +
344
372
  `${configsElsewhereClause(facts)} ${unattributedListeners(listening)} ${OPEN_THE_APP} ` +
345
373
  `${rankedCauses(facts)} ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`);
346
374
  }
@@ -360,7 +388,7 @@ export function diagnoseNoSession(facts) {
360
388
  // So: report what was actually checked, name the directory, and name the case where the absence
361
389
  // is expected rather than diagnostic.
362
390
  if (!initialized) {
363
- return ('no browser session connected. Two things to weigh, and neither of them is proof. ' +
391
+ return reason(NoSessionReason.NO_LISTENER_NO_CONFIG, 'no browser session connected. Two things to weigh, and neither of them is proof. ' +
364
392
  `(1) ${slowListenerClause(facts)}Nothing is listening on the ports Reticle scans (${SCANNED_PORTS}), so the dev server ` +
365
393
  'may not be running — START IT YOURSELF, in the background, using the command in ' +
366
394
  "`next_action` (it is read from this project's own scripts; if there is none, that field " +
@@ -374,7 +402,7 @@ export function diagnoseNoSession(facts) {
374
402
  'while the app lives in a subdirectory, and an app wired by the Vite or Babel plugin ' +
375
403
  `carries the SDK without that file at all.${searchedClause(facts)}${siblingListenerClause(facts)} ${leaseAdvice(URL_THEN_LEASE, facts)} ${RETRY}`);
376
404
  }
377
- return (`${stallClause(facts)}no browser session connected, and this daemon has never seen one. ${OPEN_THE_APP} ` +
405
+ return reason(NoSessionReason.NO_LISTENER, `${stallClause(facts)}no browser session connected, and this daemon has never seen one. ${OPEN_THE_APP} ` +
378
406
  'Nothing is listening on the ports Reticle scans ' +
379
407
  `(${SCANNED_PORTS}) either, and the most common reason for that is a dev server that is not ` +
380
408
  'running: start it yourself in the background with the command in `next_action` — it is read ' +
@@ -394,7 +422,7 @@ export function diagnoseNoSession(facts) {
394
422
  `${siblingListenerClause(facts)} ${RETRY}`);
395
423
  }
396
424
  if (!initialized) {
397
- return ('no browser session connected, and this daemon has never seen one. ' +
425
+ return reason(NoSessionReason.NO_CONFIG, 'no browser session connected, and this daemon has never seen one. ' +
398
426
  `What was actually checked: there is no \`.reticle.json\` in ${where}. That is the file ` +
399
427
  `${INIT_CMD} writes, so the app may carry no Reticle SDK — but it is not proof, and the ` +
400
428
  'same absence is expected in a monorepo whose daemon runs at the root while the app lives in ' +
@@ -403,8 +431,21 @@ export function diagnoseNoSession(facts) {
403
431
  `has one, the app is wired and simply has no page open — ${OPEN_CMD}. ` +
404
432
  `${unattributedListeners(listening)}${searchedClause(facts)}${siblingListenerClause(facts)} ${RETRY}`);
405
433
  }
406
- return (`${stallClause(facts)}no browser session connected, and this daemon has never seen one for this project, which is ` +
434
+ return reason(NoSessionReason.SDK_NOT_REACHING_DAEMON, `${stallClause(facts)}no browser session connected, and this daemon has never seen one for this project, which is ` +
407
435
  `wired for Reticle. ${OPEN_THE_APP} ${unattributedListeners(listening)} ` +
408
436
  'If the page IS open and still does not appear, the app is wired and the SDK is not reaching ' +
409
437
  `this daemon (on ${String(port)}). ${rankedCauses(facts)} ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`);
410
438
  }
439
+ /** Pair a branch's verdict with its prose, so neither can be produced without the other. */
440
+ function reason(code, message) {
441
+ return { reason: code, message };
442
+ }
443
+ /**
444
+ * The message alone, for the callers that render it.
445
+ *
446
+ * Kept as the narrow surface it always was: most callers want the sentence, and handing them the
447
+ * pair would spread a telemetry concern across every error path that shows a user this text.
448
+ */
449
+ export function diagnoseNoSession(facts) {
450
+ return explainNoSession(facts).message;
451
+ }
@@ -34,6 +34,13 @@ interface NextActionFacts {
34
34
  directory: string;
35
35
  projectId?: string;
36
36
  }[];
37
+ /**
38
+ * The sentence for a live daemon elsewhere that this project's app is actually connected to.
39
+ *
40
+ * Passed rendered rather than as ports, because the rule that produces it lives in the module that
41
+ * owns "which daemon is whose" and this file must stay pure. Absent when there is no split.
42
+ */
43
+ splitBrain?: string;
37
44
  /**
38
45
  * An app for this project has connected on this port before, from durable state.
39
46
  *
@@ -18,12 +18,33 @@ const INIT_COMMAND = 'reticle init';
18
18
  const OPEN_COMMAND = 'reticle open';
19
19
  const LOCALHOST = 'http://localhost';
20
20
  export function nextActionFor(facts) {
21
+ // FIRST, above every other cause, because it is the only one supported by positive evidence about
22
+ // a daemon rather than by an absence. This project's own connection record names a live daemon
23
+ // that is not this one, which means the app is up and instrumented and the agent is simply looking
24
+ // at the wrong process. Every branch below would send it to fix something that is not broken —
25
+ // start a dev server that is running, open a page that is open, or re-run `init` over a config
26
+ // that works — and the agent has no way to suspect the real cause from here.
27
+ const splitBrain = facts.splitBrain;
28
+ if (splitBrain !== undefined) {
29
+ return { action: NoSessionAction.DAEMON_SPLIT, reason: splitBrain };
30
+ }
21
31
  if (facts.everConnected) {
32
+ const listening = facts.listening;
33
+ const only = 1 === listening.length ? listening[0] : undefined;
34
+ const bound = 0 === listening.length
35
+ ? ''
36
+ : only === undefined
37
+ ? ` An app is already listening on ${listening.join(', ')}; just open the URL the human names — do not start a second stack.`
38
+ : ` An app is already listening on ${String(only)}; just open ${LOCALHOST}:${String(only)} — do not start a second stack.`;
22
39
  return {
23
40
  action: NoSessionAction.REOPEN_APP,
41
+ ...(only === undefined
42
+ ? {}
43
+ : { command: `${OPEN_COMMAND} ${LOCALHOST}:${String(only)}`, port: only }),
24
44
  reason: 'a session was connected to this daemon earlier, so the wiring is correct — the tab was ' +
25
45
  'closed, reloaded, or the lease aged out. Reopen the app, or take one you own with ' +
26
- 'reticle_lease {action:"acquire", url}.',
46
+ 'reticle_lease {action:"acquire", url}.' +
47
+ bound,
27
48
  };
28
49
  }
29
50
  const configsElsewhere = facts.configsElsewhere ?? [];
@@ -100,12 +121,30 @@ export function nextActionFor(facts) {
100
121
  `\`${OPEN_COMMAND} <url>\`.`,
101
122
  };
102
123
  }
124
+ // Two causes sit under this branch and only one of them is fixed by opening a page.
125
+ //
126
+ // The prose diagnosis next door already ranks them and puts the stale bundle first: `init` writes
127
+ // the plugin into a config the RUNNING dev server has already read, so the served bundle carries
128
+ // no SDK and reloading it cannot ever produce a session. This function — the half an agent
129
+ // actually executes — used to say only "open the app", which is the answer to the OTHER cause.
130
+ // An agent that follows it opens a bundle with no Reticle in it, sees no session, and has been
131
+ // given no reason to suspect the one thing that would have worked.
132
+ //
133
+ // Named only while nothing has EVER connected. Once a session has been here the wiring is proven
134
+ // and sending an agent to restart a working dev server is a wild goose chase.
135
+ const neverConnected = true !== facts.previouslyConnected;
103
136
  return {
104
137
  action: NoSessionAction.OPEN_APP,
105
138
  command: `${OPEN_COMMAND} ${LOCALHOST}:${String(only)}`,
106
139
  port: only,
107
- reason: 'this project is wired and a dev server is listening — Reticle only ever sees a page a ' +
108
- 'browser has LOADED, and nothing has loaded one.',
140
+ reason: neverConnected
141
+ ? 'this project is wired and a dev server is listening, but no app has ever connected to ' +
142
+ 'this daemon — Reticle only ever sees a page a browser has LOADED, and nothing has loaded ' +
143
+ 'one. If opening it does not produce a session, the dev server is older than the Reticle ' +
144
+ 'plugin and its bundle carries no SDK: restart the dev server (do not rely on HMR) and ' +
145
+ 'load the page again.'
146
+ : 'this project is wired and a dev server is listening — Reticle only ever sees a page a ' +
147
+ 'browser has LOADED, and nothing has loaded one.',
109
148
  };
110
149
  }
111
150
  /** The same action as one sentence, for the prose message the human reads. */
@@ -27,11 +27,15 @@ interface NoSessionWatchOptions {
27
27
  */
28
28
  occupiedSiblings?: () => Promise<readonly number[]>;
29
29
  /**
30
- * How many pooled leases have aged out, if a pool exists. Injected as a reader rather than the
31
- * pool itself: the diagnosis needs one number, and taking the whole pool would tie the session
32
- * layer to the browser layer for it.
30
+ * Whether a given session id belonged to a pooled lease that aged out, if a pool exists.
31
+ * Injected as a predicate rather than the pool itself: the diagnosis needs one answer, and
32
+ * taking the whole pool would tie the session layer to the browser layer for it.
33
+ *
34
+ * Was a lifetime `reapedLeases: () => number` count, asked as `> 0`. That latched: after the
35
+ * first reap, every closed human tab was reported as an expired lease for the rest of the
36
+ * daemon's life, and the recovery it named would have thrown away the app session (#611).
33
37
  */
34
- reapedLeases?: () => number;
38
+ wasReapedLease?: (sessionId: string) => boolean;
35
39
  /**
36
40
  * Opens a URL in a browser Reticle owns, and resolves once it is open.
37
41
  *
@@ -61,8 +65,8 @@ export declare function startNoSessionWatch(options: NoSessionWatchOptions): ()
61
65
  * adjacent blocks in the bootstrap.
62
66
  */
63
67
  export declare function wireSessionScope(sessions: SessionManager, activeProjectId: string | undefined, port: number,
64
- /** Reader for the pool's aged-out-lease count; omitted when this daemon runs no pool. */
65
- reapedLeases?: () => number,
68
+ /** Asks the pool whether a session id was a lease it aged out; omitted when there is no pool. */
69
+ wasReapedLease?: (sessionId: string) => boolean,
66
70
  /** Opens a URL in a Reticle-owned browser (the pool). Omitted ⇒ auto-attach is off. */
67
71
  attach?: (url: string) => Promise<unknown>): () => void;
68
72
  export {};
@@ -10,13 +10,14 @@
10
10
  * daemon that outlives the agent by hours has no business scanning ports it does not need.
11
11
  */
12
12
  import { probeDevServers, probeDevServerStates } from './dev-server-probe.js';
13
- import { diagnoseNoSession } from './no-session-diagnosis.js';
13
+ import { explainNoSession } from './no-session-diagnosis.js';
14
14
  import { detectDevCommand } from './dev-command.js';
15
15
  import { nextActionFor, renderNextAction } from './no-session-next-action.js';
16
16
  import { readProjectFramework, readProjectId, readProjectPort } from '../cli/cli-port.js';
17
17
  import { discoverProjectConfigs } from '../cli/config-discovery.js';
18
18
  import { hasProjectConnectedBefore, rememberConnected } from './connection-memory.js';
19
- import { reticleStateHome } from '../daemon/daemon.js';
19
+ import { isAlive, reticleStateHome } from '../daemon/daemon.js';
20
+ import { daemonsServingProjectElsewhere, splitBrainNote } from '../daemon/daemon-resolve.js';
20
21
  import { stallUptime } from './stall-clock.js';
21
22
  import { probeDaemon } from '../mcp/mcp-proxy.js';
22
23
  import { findOccupiedSiblings } from '../cli/sibling-ports.js';
@@ -102,6 +103,19 @@ export function startNoSessionWatch(options) {
102
103
  * is the same over-confident claim this whole file exists to remove, pointing the other way.
103
104
  */
104
105
  const connectedBefore = () => hasProjectConnectedBefore(stateDir, options.port, readProjectId(directory));
106
+ /**
107
+ * The split brain, asked fresh every time like everything else here.
108
+ *
109
+ * Cheap — a directory listing of `~/.reticle` and a few small JSON reads — and only ever reached
110
+ * on a daemon with no session, which is the state this whole file exists for. Reading it once at
111
+ * boot would miss the ordinary case entirely: the app connects to the other daemon SECONDS after
112
+ * this one starts, which is precisely the window the agent then spends being told to start a dev
113
+ * server that is already running.
114
+ */
115
+ const splitBrain = () => {
116
+ const projectId = readProjectId(directory);
117
+ return splitBrainNote(options.port, daemonsServingProjectElsewhere(projectId, options.port, stateDir, isAlive, (port) => hasProjectConnectedBefore(stateDir, port, projectId)));
118
+ };
105
119
  // Every path that registers a session goes through SessionManager.add, so this is the one hook
106
120
  // that makes the bit durable. Recorded per port + projectId so a shared 4400 cannot make one
107
121
  // project's success into evidence about another's.
@@ -171,20 +185,27 @@ export function startNoSessionWatch(options) {
171
185
  refresh();
172
186
  const timer = setInterval(refresh, REFRESH_MS);
173
187
  timer.unref();
174
- const nextAction = (scope) => nextActionFor({
175
- everConnected: options.sessions.everConnected(),
176
- initialized: scope.initialized,
177
- ...(scope.configsElsewhere === undefined ? {} : { configsElsewhere: scope.configsElsewhere }),
178
- previouslyConnected: connectedBefore(),
179
- listening,
180
- // Read when asked, like everything else here: a `package.json` can gain a dev script, and a
181
- // daemon that cached "there is none" at boot would keep saying so for the rest of the day.
182
- dev: detectDevCommand(directory),
183
- });
188
+ const nextAction = (scope) => {
189
+ const split = splitBrain();
190
+ return nextActionFor({
191
+ everConnected: options.sessions.everConnected(),
192
+ initialized: scope.initialized,
193
+ ...(scope.configsElsewhere === undefined ? {} : { configsElsewhere: scope.configsElsewhere }),
194
+ previouslyConnected: connectedBefore(),
195
+ ...(split === undefined ? {} : { splitBrain: split }),
196
+ listening,
197
+ // Read when asked, like everything else here: a `package.json` can gain a dev script, and a
198
+ // daemon that cached "there is none" at boot would keep saying so for the rest of the day.
199
+ dev: detectDevCommand(directory),
200
+ });
201
+ };
184
202
  options.sessions.setNoSessionNextAction(() => nextAction(projectScopeFacts()));
185
- options.sessions.setNoSessionHint(() => {
203
+ // ONE call for both registrations below. The prose and the branch code have to come from the
204
+ // same evaluation or they can describe different branches - the facts are read when asked, so two
205
+ // calls a moment apart can genuinely disagree (#615).
206
+ const explain = () => {
186
207
  const scope = projectScopeFacts();
187
- return (diagnoseNoSession({
208
+ return explainNoSession({
188
209
  everConnected: options.sessions.everConnected(),
189
210
  // Read WHEN ASKED, for the same reason `projectPort` below is: `.reticle.json` is routinely
190
211
  // written by `init` after this daemon started — that is the ordinary first-install order — and
@@ -209,7 +230,12 @@ export function startNoSessionWatch(options) {
209
230
  const framework = readProjectFramework(directory);
210
231
  return framework === undefined ? {} : { framework };
211
232
  })(),
212
- leaseExpired: (options.reapedLeases?.() ?? 0) > 0,
233
+ // Decided from the session that actually went away, not from a lifetime tally: the lease
234
+ // sentence is only right when the thing that vanished WAS a lease.
235
+ leaseExpired: (() => {
236
+ const departed = options.sessions.lastDeparted();
237
+ return departed === undefined ? false : (options.wasReapedLease?.(departed) ?? false);
238
+ })(),
213
239
  // How long this daemon has been waiting with no app. The diagnosis uses it to surface
214
240
  // "install never finished" — the same condition telemetry already knows about.
215
241
  ...(() => {
@@ -224,16 +250,22 @@ export function startNoSessionWatch(options) {
224
250
  return configured === undefined ? {} : { projectPort: configured };
225
251
  })(),
226
252
  ...(0 === siblingListeners.length ? {} : { siblingListeners }),
227
- }) +
253
+ });
254
+ };
255
+ options.sessions.setNoSessionHint(() => {
256
+ const scope = projectScopeFacts();
257
+ return (explain().message +
228
258
  // Prose for the human, then the literal command for the agent. Both consume the same scope
229
259
  // facts so a discovered workspace config cannot become an `init` recommendation below it.
230
260
  ` ${renderNextAction(nextAction(scope))}` +
231
261
  (attachFailure ?? ''));
232
262
  });
263
+ options.sessions.setNoSessionReason(() => explain().reason);
233
264
  return () => {
234
265
  clearInterval(timer);
235
266
  options.sessions.setConnectionRecorder(undefined);
236
267
  options.sessions.setNoSessionHint(undefined);
268
+ options.sessions.setNoSessionReason(undefined);
237
269
  options.sessions.setNoSessionNextAction(undefined);
238
270
  };
239
271
  }
@@ -244,8 +276,8 @@ export function startNoSessionWatch(options) {
244
276
  * adjacent blocks in the bootstrap.
245
277
  */
246
278
  export function wireSessionScope(sessions, activeProjectId, port,
247
- /** Reader for the pool's aged-out-lease count; omitted when this daemon runs no pool. */
248
- reapedLeases,
279
+ /** Asks the pool whether a session id was a lease it aged out; omitted when there is no pool. */
280
+ wasReapedLease,
249
281
  /** Opens a URL in a Reticle-owned browser (the pool). Omitted ⇒ auto-attach is off. */
250
282
  attach) {
251
283
  if (activeProjectId !== undefined)
@@ -254,7 +286,7 @@ attach) {
254
286
  sessions,
255
287
  port,
256
288
  initialized: activeProjectId !== undefined,
257
- ...(reapedLeases === undefined ? {} : { reapedLeases }),
289
+ ...(wasReapedLease === undefined ? {} : { wasReapedLease }),
258
290
  ...(attach === undefined ? {} : { attach }),
259
291
  });
260
292
  }
@@ -11,6 +11,16 @@ import type { CommandResult } from '@reticlehq/core';
11
11
  */
12
12
  export declare class CommandTimeoutError extends Error {
13
13
  }
14
+ /**
15
+ * The transport under this command was displaced by a newer connection claiming the same id.
16
+ *
17
+ * Typed, not a string to match on, because the two failures need OPPOSITE handling and the only
18
+ * thing separating them used to be the wording of a message. A read can simply be re-asked on the
19
+ * successor; a WRITE cannot — nobody can prove it did not land — so the honest answer is that the
20
+ * outcome went unobserved, which is a verdict this engine already has a word for.
21
+ */
22
+ export declare class SessionReplacedError extends Error {
23
+ }
14
24
  export declare class PendingCommands {
15
25
  #private;
16
26
  /** Next correlation id. Monotonic per session, so a reply can never match an earlier command. */
@@ -20,5 +30,5 @@ export declare class PendingCommands {
20
30
  /** Resolve the command this reply belongs to. Unknown ids are ignored, never thrown on. */
21
31
  settle(result: CommandResult): void;
22
32
  /** Reject everything still in flight — used on disconnect, so no caller waits on a dead socket. */
23
- rejectAll(reason: string): void;
33
+ rejectAll(reason: string, replaced?: boolean): void;
24
34
  }
@@ -10,6 +10,16 @@
10
10
  */
11
11
  export class CommandTimeoutError extends Error {
12
12
  }
13
+ /**
14
+ * The transport under this command was displaced by a newer connection claiming the same id.
15
+ *
16
+ * Typed, not a string to match on, because the two failures need OPPOSITE handling and the only
17
+ * thing separating them used to be the wording of a message. A read can simply be re-asked on the
18
+ * successor; a WRITE cannot — nobody can prove it did not land — so the honest answer is that the
19
+ * outcome went unobserved, which is a verdict this engine already has a word for.
20
+ */
21
+ export class SessionReplacedError extends Error {
22
+ }
13
23
  export class PendingCommands {
14
24
  #pending = new Map();
15
25
  #seq = 0;
@@ -39,10 +49,10 @@ export class PendingCommands {
39
49
  pending.resolve(result);
40
50
  }
41
51
  /** Reject everything still in flight — used on disconnect, so no caller waits on a dead socket. */
42
- rejectAll(reason) {
52
+ rejectAll(reason, replaced = false) {
43
53
  for (const [id, pending] of this.#pending) {
44
54
  clearTimeout(pending.timer);
45
- pending.reject(new Error(reason));
55
+ pending.reject(replaced ? new SessionReplacedError(reason) : new Error(reason));
46
56
  this.#pending.delete(id);
47
57
  }
48
58
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Resolve a session, or spend the caller's stated budget waiting for one to arrive.
3
+ *
4
+ * Every tool that takes a `timeout_ms` used to resolve the session as its FIRST statement, so an
5
+ * empty session map threw before the budget was ever consulted. A caller that said "wait up to
6
+ * thirty seconds for the app" was refused in under a millisecond, and the only way to actually wait
7
+ * was to poll `reticle_sessions` blind.
8
+ *
9
+ * That is the wrong answer to the commonest situation in the product. The moment an agent has run
10
+ * `init` and restarted the dev server is exactly the moment there is no session yet and one is about
11
+ * to appear — and `no_session` is the refusal agents hit most. An agent that gets refused rarely
12
+ * retries; it concludes and moves on. Spending the budget it already asked for turns the most common
13
+ * dead end into a wait that ends in a verdict.
14
+ *
15
+ * Deliberately narrow in two ways.
16
+ *
17
+ * Only the EMPTY case waits. A `sessionId` that names a session which is not connected while others
18
+ * are is a different error with a different fix, and waiting on it would hide a typo behind a
19
+ * timeout.
20
+ *
21
+ * The original error is what gets thrown on expiry, never a generic timeout message. That error
22
+ * carries the daemon's whole diagnosis — the port scan, the next action, the literal command — and
23
+ * it is the single most valuable string this product emits to an agent that has nothing connected.
24
+ * Replacing it with "timed out" would trade the answer for a stopwatch reading.
25
+ */
26
+ /** Just the part of the session manager this needs, so a test does not have to build one. */
27
+ export interface ResolvableSessions<S> {
28
+ resolve: (sessionId?: string) => S;
29
+ count: () => number;
30
+ }
31
+ export declare function resolveSessionWithin<S>(sessions: ResolvableSessions<S>, sessionId: string | undefined, timeoutMs: number, deps: {
32
+ now: () => number;
33
+ sleep: (ms: number) => Promise<void>;
34
+ }): Promise<S>;