@reticlehq/server 2.7.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (357) hide show
  1. package/README.md +14 -5
  2. package/SKILL.md +130 -778
  3. package/dist/bridge/bridge.d.ts +23 -0
  4. package/dist/bridge/bridge.js +80 -2
  5. package/dist/bridge/bridge.test-harness.d.ts +5 -0
  6. package/dist/bridge/bridge.test-harness.js +6 -4
  7. package/dist/capsule/causal-summary.d.ts +36 -1
  8. package/dist/capsule/causal-summary.js +33 -7
  9. package/dist/capsule/divergence.js +7 -1
  10. package/dist/cli/chromium-hint.d.ts +33 -5
  11. package/dist/cli/chromium-hint.js +82 -12
  12. package/dist/cli/cli-doctor.js +65 -34
  13. package/dist/cli/cli-flow-commands.js +8 -8
  14. package/dist/cli/cli-kill.d.ts +58 -0
  15. package/dist/cli/cli-kill.js +162 -0
  16. package/dist/cli/cli-parse.d.ts +16 -1
  17. package/dist/cli/cli-parse.js +32 -9
  18. package/dist/cli/cli-port.d.ts +32 -0
  19. package/dist/cli/cli-port.js +82 -35
  20. package/dist/cli/cli-verify.d.ts +21 -0
  21. package/dist/cli/cli-verify.js +47 -3
  22. package/dist/cli/config-discovery.d.ts +35 -0
  23. package/dist/cli/config-discovery.js +163 -0
  24. package/dist/cli/doctor-daemon-line.js +4 -1
  25. package/dist/cli/doctor-project-line.d.ts +37 -0
  26. package/dist/cli/doctor-project-line.js +35 -0
  27. package/dist/cli/doctor-rows.d.ts +51 -0
  28. package/dist/cli/doctor-rows.js +54 -0
  29. package/dist/cli/doctor-sessions-line.d.ts +12 -0
  30. package/dist/cli/doctor-sessions-line.js +69 -0
  31. package/dist/cli/drive-attach.d.ts +99 -0
  32. package/dist/cli/drive-attach.js +203 -0
  33. package/dist/cli/drive-command.d.ts +13 -0
  34. package/dist/cli/drive-command.js +103 -0
  35. package/dist/cli/open-note.d.ts +20 -0
  36. package/dist/cli/open-note.js +37 -0
  37. package/dist/cli/port-holder.d.ts +10 -0
  38. package/dist/cli/port-holder.js +26 -2
  39. package/dist/cli/status-next-action.d.ts +37 -0
  40. package/dist/cli/status-next-action.js +61 -0
  41. package/dist/cli.js +119 -39
  42. package/dist/crawl/crawl-tools.js +12 -2
  43. package/dist/crawl/crawl.d.ts +13 -0
  44. package/dist/crawl/crawl.js +50 -2
  45. package/dist/events/contradictions.d.ts +46 -0
  46. package/dist/events/contradictions.js +35 -22
  47. package/dist/events/declared.d.ts +45 -0
  48. package/dist/events/declared.js +73 -0
  49. package/dist/events/echo-mismatch.d.ts +8 -2
  50. package/dist/events/echo-mismatch.js +33 -5
  51. package/dist/events/event-filters.d.ts +1 -1
  52. package/dist/events/event-filters.js +7 -4
  53. package/dist/events/predicate-eval.d.ts +17 -72
  54. package/dist/events/predicate-eval.js +117 -236
  55. package/dist/events/predicate-parse.js +31 -3
  56. package/dist/events/predicate-route.d.ts +32 -0
  57. package/dist/events/predicate-route.js +102 -0
  58. package/dist/events/predicate-schema.d.ts +99 -0
  59. package/dist/events/predicate-schema.js +309 -0
  60. package/dist/events/predicate.d.ts +8 -0
  61. package/dist/events/predicate.js +296 -22
  62. package/dist/flows/annotate-tools.js +3 -0
  63. package/dist/flows/annotate.js +18 -4
  64. package/dist/flows/coverage.d.ts +38 -5
  65. package/dist/flows/coverage.js +30 -4
  66. package/dist/flows/decision.d.ts +15 -0
  67. package/dist/flows/decision.js +1 -1
  68. package/dist/flows/flow-classify.js +1 -1
  69. package/dist/flows/flow-replay-run.js +8 -1
  70. package/dist/flows/flow-tools.js +21 -5
  71. package/dist/flows/heal-run.js +1 -1
  72. package/dist/flows/replay.js +3 -0
  73. package/dist/honesty/accepted-write.d.ts +3 -0
  74. package/dist/honesty/accepted-write.js +8 -3
  75. package/dist/honesty/already-true.js +15 -0
  76. package/dist/honesty/blind-spots.js +10 -2
  77. package/dist/honesty/honesty.d.ts +11 -0
  78. package/dist/honesty/honesty.js +1 -0
  79. package/dist/honesty/uncaptured-bodies.d.ts +4 -2
  80. package/dist/honesty/uncaptured-bodies.js +9 -2
  81. package/dist/honesty/unread-outcome.d.ts +8 -0
  82. package/dist/honesty/unread-outcome.js +36 -9
  83. package/dist/honesty/unsettled.d.ts +49 -0
  84. package/dist/honesty/unsettled.js +82 -0
  85. package/dist/honesty/verified.d.ts +36 -3
  86. package/dist/honesty/verified.js +54 -11
  87. package/dist/http-server.d.ts +19 -0
  88. package/dist/http-server.js +202 -4
  89. package/dist/impact/impact-recorder.d.ts +23 -0
  90. package/dist/impact/impact-recorder.js +76 -0
  91. package/dist/impact/impact-store.d.ts +47 -0
  92. package/dist/impact/impact-store.js +171 -0
  93. package/dist/index.d.ts +7 -0
  94. package/dist/index.js +47 -10
  95. package/dist/init/agent-rules.d.ts +36 -0
  96. package/dist/init/agent-rules.js +132 -22
  97. package/dist/init/astro-patch.d.ts +2 -1
  98. package/dist/init/astro-patch.js +38 -6
  99. package/dist/init/capabilities.d.ts +22 -0
  100. package/dist/init/capabilities.js +76 -0
  101. package/dist/init/confirm.d.ts +114 -0
  102. package/dist/init/confirm.js +165 -0
  103. package/dist/init/connect-steps.d.ts +2 -0
  104. package/dist/init/connect-steps.js +50 -0
  105. package/dist/init/cra.js +3 -2
  106. package/dist/init/csp-check.d.ts +38 -0
  107. package/dist/init/csp-check.js +92 -0
  108. package/dist/init/csp-doctor.d.ts +25 -0
  109. package/dist/init/csp-doctor.js +49 -0
  110. package/dist/init/cursor.js +6 -9
  111. package/dist/init/detect.d.ts +8 -0
  112. package/dist/init/detect.js +33 -3
  113. package/dist/init/mcp-clients.d.ts +1 -0
  114. package/dist/init/mcp-clients.js +51 -7
  115. package/dist/init/mcp.d.ts +15 -0
  116. package/dist/init/mcp.js +19 -0
  117. package/dist/init/non-js-project.js +29 -5
  118. package/dist/init/plan-framework.d.ts +10 -0
  119. package/dist/init/plan-framework.js +78 -16
  120. package/dist/init/plan.d.ts +47 -6
  121. package/dist/init/plan.js +228 -69
  122. package/dist/init/run.d.ts +24 -2
  123. package/dist/init/run.js +157 -45
  124. package/dist/init/slash-command.d.ts +1 -1
  125. package/dist/init/slash-command.js +54 -6
  126. package/dist/init/snippets.d.ts +45 -5
  127. package/dist/init/snippets.js +194 -42
  128. package/dist/init/vite-config.js +53 -5
  129. package/dist/input/network-mock-tools.d.ts +19 -0
  130. package/dist/input/network-mock-tools.js +37 -5
  131. package/dist/input/scroll-find.d.ts +6 -0
  132. package/dist/input/scroll-find.js +17 -2
  133. package/dist/input/scroll-tools.js +4 -0
  134. package/dist/journal/attach-journal.d.ts +9 -1
  135. package/dist/journal/attach-journal.js +13 -2
  136. package/dist/journal/journal-query.d.ts +13 -0
  137. package/dist/journal/journal-query.js +28 -3
  138. package/dist/journal/workspace-gitignore.d.ts +10 -0
  139. package/dist/journal/workspace-gitignore.js +59 -0
  140. package/dist/license/license.js +1 -1
  141. package/dist/mcp/mcp-proxy.d.ts +35 -30
  142. package/dist/mcp/mcp-proxy.js +306 -104
  143. package/dist/mcp/mcp.d.ts +10 -1
  144. package/dist/mcp/mcp.js +70 -34
  145. package/dist/mcp/proxy-handshake.d.ts +82 -1
  146. package/dist/mcp/proxy-handshake.js +109 -2
  147. package/dist/mcp/proxy-lifecycle.d.ts +11 -2
  148. package/dist/mcp/proxy-lifecycle.js +12 -3
  149. package/dist/mcp/proxy-log.d.ts +63 -0
  150. package/dist/mcp/proxy-log.js +132 -0
  151. package/dist/mcp/server-instructions.d.ts +17 -0
  152. package/dist/mcp/server-instructions.js +56 -0
  153. package/dist/mcp/shared-params.d.ts +27 -0
  154. package/dist/mcp/shared-params.js +36 -0
  155. package/dist/pool/browser-pool.d.ts +22 -2
  156. package/dist/pool/browser-pool.js +44 -4
  157. package/dist/project/learned-routes.d.ts +24 -0
  158. package/dist/project/learned-routes.js +63 -0
  159. package/dist/project/project-store.d.ts +6 -0
  160. package/dist/project/project-store.js +25 -1
  161. package/dist/project/reticle-dir.d.ts +2 -0
  162. package/dist/project/reticle-dir.js +1 -0
  163. package/dist/session/connection-memory.d.ts +59 -0
  164. package/dist/session/connection-memory.js +108 -0
  165. package/dist/session/dev-command.d.ts +26 -0
  166. package/dist/session/dev-command.js +89 -0
  167. package/dist/session/dev-server-probe.d.ts +68 -0
  168. package/dist/session/dev-server-probe.js +102 -7
  169. package/dist/session/last-act.d.ts +26 -0
  170. package/dist/session/last-act.js +35 -1
  171. package/dist/session/live-control-tools.js +42 -5
  172. package/dist/session/no-session-diagnosis.d.ts +67 -2
  173. package/dist/session/no-session-diagnosis.js +236 -27
  174. package/dist/session/no-session-next-action.d.ts +46 -0
  175. package/dist/session/no-session-next-action.js +85 -0
  176. package/dist/session/no-session-watch.d.ts +40 -1
  177. package/dist/session/no-session-watch.js +174 -8
  178. package/dist/session/pending-commands.d.ts +12 -0
  179. package/dist/session/pending-commands.js +13 -1
  180. package/dist/session/ref-provenance.d.ts +54 -0
  181. package/dist/session/ref-provenance.js +61 -0
  182. package/dist/session/session-lease.js +1 -1
  183. package/dist/session/session-manager.d.ts +4 -0
  184. package/dist/session/session-manager.js +35 -5
  185. package/dist/session/session.d.ts +10 -1
  186. package/dist/session/session.js +146 -5
  187. package/dist/telemetry/app-instrumented.d.ts +8 -0
  188. package/dist/telemetry/app-instrumented.js +10 -0
  189. package/dist/telemetry/automation-hint.d.ts +17 -0
  190. package/dist/telemetry/automation-hint.js +71 -0
  191. package/dist/telemetry/bug-found.d.ts +15 -17
  192. package/dist/telemetry/bug-found.js +52 -21
  193. package/dist/telemetry/cli-telemetry.js +7 -1
  194. package/dist/telemetry/daemon-telemetry.js +24 -0
  195. package/dist/telemetry/feedback.js +1 -1
  196. package/dist/telemetry/init-telemetry.js +9 -1
  197. package/dist/telemetry/install-source.d.ts +56 -0
  198. package/dist/telemetry/install-source.js +74 -0
  199. package/dist/telemetry/instrumentation-stall.d.ts +50 -0
  200. package/dist/telemetry/instrumentation-stall.js +90 -0
  201. package/dist/telemetry/run-telemetry.js +4 -1
  202. package/dist/telemetry/session-metrics.d.ts +8 -0
  203. package/dist/telemetry/session-metrics.js +15 -0
  204. package/dist/telemetry/telemetry.d.ts +7 -1
  205. package/dist/telemetry/telemetry.js +26 -2
  206. package/dist/telemetry/tool-refused.d.ts +19 -0
  207. package/dist/telemetry/tool-refused.js +55 -0
  208. package/dist/telemetry/verification-of.js +46 -7
  209. package/dist/temp-dir.d.ts +27 -0
  210. package/dist/temp-dir.js +46 -0
  211. package/dist/tools/act-tools.js +209 -28
  212. package/dist/tools/assert-grade.d.ts +1 -1
  213. package/dist/tools/assert-grade.js +27 -5
  214. package/dist/tools/assert-verdict.js +49 -7
  215. package/dist/tools/browser-tools.js +10 -0
  216. package/dist/tools/contract-tools.js +4 -0
  217. package/dist/tools/dynamic-tools.js +16 -7
  218. package/dist/tools/error-recovery.d.ts +10 -0
  219. package/dist/tools/error-recovery.js +73 -9
  220. package/dist/tools/feedback-invite.js +1 -1
  221. package/dist/tools/invoke-tool.js +159 -26
  222. package/dist/tools/lease-hint.d.ts +30 -8
  223. package/dist/tools/lease-hint.js +65 -14
  224. package/dist/tools/lease-tools.d.ts +40 -1
  225. package/dist/tools/lease-tools.js +181 -83
  226. package/dist/tools/navigate-result.js +1 -1
  227. package/dist/tools/observe-tools.js +10 -4
  228. package/dist/tools/observed-nothing.js +1 -1
  229. package/dist/tools/react-grace.d.ts +52 -0
  230. package/dist/tools/react-grace.js +90 -0
  231. package/dist/tools/read-tools.js +67 -5
  232. package/dist/tools/resolve-target.d.ts +33 -0
  233. package/dist/tools/resolve-target.js +53 -0
  234. package/dist/tools/settle-in-flight.d.ts +94 -0
  235. package/dist/tools/settle-in-flight.js +146 -0
  236. package/dist/tools/snapshot-delta.d.ts +10 -0
  237. package/dist/tools/snapshot-delta.js +194 -9
  238. package/dist/tools/tool-kit.d.ts +8 -0
  239. package/dist/tools/tool-surface.d.ts +60 -0
  240. package/dist/tools/tool-surface.js +86 -1
  241. package/dist/tools/tools.js +61 -8
  242. package/dist/tools/unadvertised-help.js +9 -6
  243. package/docs/README.md +55 -11
  244. package/docs/actions.mdx +120 -0
  245. package/docs/agent-cheatsheet.md +95 -29
  246. package/docs/architecture.md +52 -35
  247. package/docs/benchmarks.md +127 -75
  248. package/docs/best-practices.mdx +216 -0
  249. package/docs/capabilities.mdx +534 -0
  250. package/docs/cli/affected.mdx +53 -0
  251. package/docs/cli/capsules.mdx +70 -0
  252. package/docs/cli/cloud.mdx +153 -0
  253. package/docs/cli/doctor.mdx +109 -0
  254. package/docs/cli/drive.mdx +74 -0
  255. package/docs/cli/feedback.mdx +67 -0
  256. package/docs/cli/gate.mdx +64 -0
  257. package/docs/cli/help.mdx +94 -0
  258. package/docs/cli/hunt.mdx +73 -0
  259. package/docs/cli/identify.mdx +76 -0
  260. package/docs/cli/init.mdx +121 -0
  261. package/docs/cli/kill.mdx +91 -0
  262. package/docs/cli/license.mdx +48 -0
  263. package/docs/cli/mcp.mdx +61 -0
  264. package/docs/cli/open.mdx +74 -0
  265. package/docs/cli/restart.mdx +60 -0
  266. package/docs/cli/rollback.mdx +35 -0
  267. package/docs/cli/serve.mdx +69 -0
  268. package/docs/cli/status.mdx +88 -0
  269. package/docs/cli/stop.mdx +64 -0
  270. package/docs/cli/telemetry.mdx +57 -0
  271. package/docs/cli/update.mdx +52 -0
  272. package/docs/cli/verify.mdx +76 -0
  273. package/docs/cli/version.mdx +39 -0
  274. package/docs/cli/watch.mdx +62 -0
  275. package/docs/cli.mdx +241 -0
  276. package/docs/debugging.md +16 -10
  277. package/docs/deploy-checks.md +45 -12
  278. package/docs/desktop.mdx +384 -0
  279. package/docs/docs.json +364 -0
  280. package/docs/enterprise.md +22 -16
  281. package/docs/faq.mdx +102 -0
  282. package/docs/fixtures.md +13 -7
  283. package/docs/flows.md +29 -23
  284. package/docs/for-agents.md +71 -0
  285. package/docs/frameworks.mdx +238 -0
  286. package/docs/gate-plan.md +48 -42
  287. package/docs/gates.md +28 -22
  288. package/docs/getting-started.md +129 -77
  289. package/docs/human-control.md +24 -18
  290. package/docs/index.mdx +143 -0
  291. package/docs/install-agentic.mdx +224 -0
  292. package/docs/install-manual.mdx +196 -0
  293. package/docs/instrumentation.mdx +238 -0
  294. package/docs/integration-patterns.md +41 -35
  295. package/docs/local-registry.md +19 -13
  296. package/docs/multi-agent-testing.md +19 -13
  297. package/docs/packages/babel-plugin.mdx +79 -0
  298. package/docs/packages/browser.mdx +111 -0
  299. package/docs/packages/core.mdx +116 -0
  300. package/docs/packages/electron.mdx +85 -0
  301. package/docs/packages/eslint-plugin.mdx +86 -0
  302. package/docs/packages/next.mdx +89 -0
  303. package/docs/packages/react.mdx +91 -0
  304. package/docs/packages/server.mdx +98 -0
  305. package/docs/packages/tauri.mdx +63 -0
  306. package/docs/packages/test.mdx +119 -0
  307. package/docs/packages/vite-plugin.mdx +94 -0
  308. package/docs/packages.mdx +218 -0
  309. package/docs/platform-integration.md +35 -26
  310. package/docs/predicates.mdx +248 -0
  311. package/docs/quickstart.mdx +257 -0
  312. package/docs/recipes.mdx +282 -0
  313. package/docs/skill-file.mdx +107 -0
  314. package/docs/state-management.mdx +199 -0
  315. package/docs/style.css +14 -0
  316. package/docs/system-map.md +50 -33
  317. package/docs/telemetry-contract.md +106 -52
  318. package/docs/telemetry-events.mdx +475 -0
  319. package/docs/telemetry.md +63 -55
  320. package/docs/testing.md +44 -6
  321. package/docs/token-efficiency.md +37 -27
  322. package/docs/tools-act-and-wait.mdx +173 -0
  323. package/docs/tools-act-sequence.mdx +137 -0
  324. package/docs/tools-act.mdx +95 -0
  325. package/docs/tools-assert.mdx +129 -0
  326. package/docs/tools-console.mdx +63 -0
  327. package/docs/tools-inspect.mdx +88 -0
  328. package/docs/tools-navigate.mdx +99 -0
  329. package/docs/tools-network.mdx +119 -0
  330. package/docs/tools-observe.mdx +145 -0
  331. package/docs/tools-overview.mdx +160 -0
  332. package/docs/tools-query.mdx +126 -0
  333. package/docs/tools-session-and-feedback.mdx +112 -0
  334. package/docs/tools-sessions.mdx +94 -0
  335. package/docs/tools-snapshot.mdx +129 -0
  336. package/docs/tools-state.mdx +104 -0
  337. package/docs/tools-tools-and-run.mdx +156 -0
  338. package/docs/tools-wait-for.mdx +85 -0
  339. package/docs/troubleshooting.mdx +263 -0
  340. package/docs/usage.md +209 -165
  341. package/docs/vs-chrome-devtools-mcp.mdx +123 -0
  342. package/docs/vs-playwright-mcp.mdx +125 -0
  343. package/docs/vs-screenshots.mdx +137 -0
  344. package/docs/what-is-reticle.mdx +63 -0
  345. package/docs/why-reticle.mdx +109 -0
  346. package/package.json +5 -5
  347. package/docs/desktop-apps.md +0 -256
  348. package/docs/matrix/2.5.0/claude-code-machine.json +0 -19
  349. package/docs/matrix/2.5.0/codex-machine.json +0 -20
  350. package/docs/matrix/2.5.0/cursor-machine.json +0 -22
  351. package/docs/matrix/2.5.0/gemini-machine.json +0 -22
  352. package/docs/matrix/2.5.0/opencode-machine.json +0 -22
  353. package/docs/matrix/2.5.0/vscode-machine.json +0 -22
  354. package/docs/matrix/2.5.0/windsurf-machine.json +0 -22
  355. package/docs/matrix/MATRIX.md +0 -26
  356. package/docs/matrix/README.md +0 -59
  357. package/docs/mint.json +0 -61
@@ -0,0 +1,17 @@
1
+ export interface InstructionState {
2
+ /**
3
+ * Has an app ever connected to Reticle for this project, on this port?
4
+ *
5
+ * Read from the durable connection memory rather than from the live session list: a daemon four
6
+ * seconds old has no sessions and that says nothing about whether the app is wired. The question
7
+ * is whether this install has EVER worked, not whether it is working this second.
8
+ */
9
+ previouslyConnected: boolean;
10
+ }
11
+ /** The instructions this daemon should advertise, given what it knows about the project. */
12
+ /**
13
+ * Instructions are sent ONCE at initialize; the tool surface is re-sent every turn. Measured on this
14
+ * server: 621 tokens here against 5,480 per turn there, so anything true of every tool belongs in
15
+ * this block rather than repeated across sixteen parameter descriptions. See shared-params.ts.
16
+ */
17
+ export declare function buildServerInstructions(state: InstructionState): string;
@@ -0,0 +1,56 @@
1
+ import { SHARED_PARAM_GUIDANCE } from './shared-params.js';
2
+ /**
3
+ * What every connected agent is told, before it has asked anything.
4
+ *
5
+ * The MCP `instructions` string lands in the agent's context at the handshake — no skill file, no
6
+ * restart, no action from the user. For an agent that arrived through a plugin listing or a pasted
7
+ * config block rather than through the skill, it is the ONLY thing Reticle ever says unprompted.
8
+ *
9
+ * It used to open on tool grammar. That is the right thing to say to an agent that has an app to
10
+ * point the tools at, and the wrong thing to say to one that does not — and in the field the
11
+ * overwhelming majority of daemons never see an app connect, never run a command and never call a
12
+ * tool. Those sessions are not agents failing a hard step. They are agents that were never asked to
13
+ * take an easy one: the MCP server registers itself in a single line of config, so a user reaches
14
+ * "the tools are here" without ever reaching "the app is wired", and nothing in that state reads as
15
+ * unfinished. Registering the server is the cheapest part of the product and it is the only part
16
+ * many installs ever complete.
17
+ *
18
+ * So the lead is state-dependent. A project that has never had an app connect is told the one thing
19
+ * it needs; a project that has is not nagged about a step it already took. The verdict discipline
20
+ * and the feedback ask are constant, because they matter in both states.
21
+ */
22
+ /** The tools, and the rule that only two of them decide anything. Constant across both states. */
23
+ const VERDICT_DISCIPLINE = `Reticle verifies a running web app from the inside: look (reticle_snapshot / reticle_query), act and prove in one hop (reticle_act_and_wait), observe (reticle_state / reticle_network / reticle_console), assert (reticle_assert). Verify a user-facing change against the real app before you call it done, and never weaken a check to make it pass.
24
+
25
+ Only reticle_act_and_wait and reticle_assert produce a verdict. reticle_act and everything else move or read the app and prove nothing, so a drive that ends without one of those two has no result however many tools it used. Prefer reticle_act_and_wait({ ref, action, until }) — it names the expected consequence BEFORE the action, which is the difference between a check and a rationalisation. A verdict of verified:"unknown" is not a pass: it means Reticle drove the app and could not tell what happened. Report it as unknown.`;
26
+ /**
27
+ * The first move, for a project no app has ever connected to.
28
+ *
29
+ * Deliberately concrete: the command, what it does to the app, and the call that confirms it. A
30
+ * general instruction to "set Reticle up" is the state these agents are already in — they have the
31
+ * tools and no app, and nothing tells them that combination is incomplete. It also names the dev
32
+ * server restart, because a plugin added to a config the running server already read is the single
33
+ * most common way this step half-succeeds.
34
+ */
35
+ const FIRST_MOVE = `FIRST: no app has ever connected to Reticle in this project, so nothing here can be verified yet. Having these tools is not the same as being set up — the tools reach a daemon, and the daemon has nothing to look at until the app itself is instrumented.
36
+
37
+ Fix that before anything else: run \`npx @reticlehq/server init\` in the project, then RESTART the dev server (a plugin added to a config the running server already read is not in the bundle), then load the app in a browser. Confirm with reticle_sessions — a session listed there is the proof, and until one appears no other tool can tell you anything about this app.`;
38
+ /** The feedback ask. Constant: an agent that cannot get set up is the report we most need. */
39
+ const FEEDBACK_ASK = `Feedback is first-class here, not a courtesy. Reticle is built FOR agents, so your experience is the only signal that decides what gets built or fixed — and it disappears when your context does. Send it with \`reticle_feedback\` the moment you notice, then carry on with your task:
40
+ - Reticle broke, lied, or could not see something you needed (\`bug\` / \`gap\` / \`ambiguity\`).
41
+ - You wished it could do something, or something works but costs too many calls (\`feature_request\` / \`improvement\`).
42
+ - The install, the wiring, or the setup itself gave you trouble — including anything you had to work around by hand.
43
+
44
+ Nothing is too minor, and a report costs one call. If the tools are unreachable (setup unfinished, daemon down), file the same report from the shell instead: \`reticle feedback --agent --kind <bug|gap|ambiguity|feature_request|improvement> "what happened"\`. Report defects in RETICLE — a bug you find in the app under test is Reticle working, and belongs in your answer to the user.`;
45
+ /** The instructions this daemon should advertise, given what it knows about the project. */
46
+ /**
47
+ * Instructions are sent ONCE at initialize; the tool surface is re-sent every turn. Measured on this
48
+ * server: 621 tokens here against 5,480 per turn there, so anything true of every tool belongs in
49
+ * this block rather than repeated across sixteen parameter descriptions. See shared-params.ts.
50
+ */
51
+ export function buildServerInstructions(state) {
52
+ const base = state.previouslyConnected
53
+ ? `${VERDICT_DISCIPLINE}\n\n${FEEDBACK_ASK}`
54
+ : `${FIRST_MOVE}\n\n${VERDICT_DISCIPLINE}\n\n${FEEDBACK_ASK}`;
55
+ return `${base}\n\n${SHARED_PARAM_GUIDANCE}`;
56
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Parameters that mean the same thing on every tool, documented ONCE instead of every turn.
3
+ *
4
+ * The advertised surface is re-sent on every turn; the MCP `instructions` block is sent once at
5
+ * initialize. Measured on this server: instructions cost 621 tokens total, the surface costs 5,480
6
+ * PER TURN — 87,672 over a 16-turn run. So a byte of prose moved from a parameter description into
7
+ * instructions is roughly 16x cheaper, and nothing is lost, because the text still reaches the model.
8
+ *
9
+ * `sessionId` is the clearest case: identical boilerplate on 16 tools, 2,076 bytes of the surface,
10
+ * re-explained on every turn to say the same thing it said last turn.
11
+ *
12
+ * This is deliberately NOT the `verify` experiment. That one removed the observation tools and
13
+ * measured what it cost: detection held but false alarms tripled, because evidence got expensive.
14
+ * This removes REPETITION, not evidence — every tool stays advertised and every observation stays
15
+ * one call away. Cost goes down; what the model can see does not change.
16
+ *
17
+ * The trade being made, stated: schema prose is expensive but sits adjacent to the call, while
18
+ * instructions are cheap but distant, and a client that drops `instructions` loses this text
19
+ * entirely. So the short forms below still have to be usable on their own — each one names the
20
+ * parameter's PURPOSE, and only the guidance moves.
21
+ */
22
+ /** The short form left in the per-turn schema. Must still make sense with no instructions at all. */
23
+ export declare const SHARED_PARAM_SHORT: Readonly<Record<string, string>>;
24
+ /** The full text, sent once at initialize. Everything trimmed above is restated here. */
25
+ export declare const SHARED_PARAM_GUIDANCE: string;
26
+ /** Is this a parameter whose guidance now lives in the instructions block? */
27
+ export declare function isSharedParam(name: string): boolean;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Parameters that mean the same thing on every tool, documented ONCE instead of every turn.
3
+ *
4
+ * The advertised surface is re-sent on every turn; the MCP `instructions` block is sent once at
5
+ * initialize. Measured on this server: instructions cost 621 tokens total, the surface costs 5,480
6
+ * PER TURN — 87,672 over a 16-turn run. So a byte of prose moved from a parameter description into
7
+ * instructions is roughly 16x cheaper, and nothing is lost, because the text still reaches the model.
8
+ *
9
+ * `sessionId` is the clearest case: identical boilerplate on 16 tools, 2,076 bytes of the surface,
10
+ * re-explained on every turn to say the same thing it said last turn.
11
+ *
12
+ * This is deliberately NOT the `verify` experiment. That one removed the observation tools and
13
+ * measured what it cost: detection held but false alarms tripled, because evidence got expensive.
14
+ * This removes REPETITION, not evidence — every tool stays advertised and every observation stays
15
+ * one call away. Cost goes down; what the model can see does not change.
16
+ *
17
+ * The trade being made, stated: schema prose is expensive but sits adjacent to the call, while
18
+ * instructions are cheap but distant, and a client that drops `instructions` loses this text
19
+ * entirely. So the short forms below still have to be usable on their own — each one names the
20
+ * parameter's PURPOSE, and only the guidance moves.
21
+ */
22
+ /** The short form left in the per-turn schema. Must still make sense with no instructions at all. */
23
+ export const SHARED_PARAM_SHORT = {
24
+ sessionId: 'Target a specific tab. Omit unless you mean one — see instructions.',
25
+ since: 'Cursor from a prior reticle_act/reticle_observe.',
26
+ limit: 'Cap descriptors to the first N.',
27
+ };
28
+ /** The full text, sent once at initialize. Everything trimmed above is restated here. */
29
+ export const SHARED_PARAM_GUIDANCE = 'Arguments that mean the same thing on every tool: `sessionId` — omit unless you mean a specific ' +
30
+ 'tab; Reticle scopes to your project, prefers the active one, and refuses rather than guesses when ' +
31
+ 'ambiguous. `since` — a cursor from a prior act/observe, bounding an observation to what came ' +
32
+ 'after it. `limit` — caps returned descriptors, cutting tokens on broad queries.';
33
+ /** Is this a parameter whose guidance now lives in the instructions block? */
34
+ export function isSharedParam(name) {
35
+ return Object.hasOwn(SHARED_PARAM_SHORT, name);
36
+ }
@@ -60,7 +60,8 @@ interface BrowserPoolOptions {
60
60
  export declare class BrowserPool {
61
61
  #private;
62
62
  constructor(launch: Launcher, opts: BrowserPoolOptions);
63
- /** Currently leased contexts. */
63
+ /** The TTL configured for leases — how long an untouched lease lives before the reaper reclaims it. */
64
+ leaseTtlMs(): number;
64
65
  /** Max simultaneous leases — the ceiling the parallel suite sizes its concurrency to. */
65
66
  capacity(): number;
66
67
  activeCount(): number;
@@ -68,8 +69,27 @@ export declare class BrowserPool {
68
69
  queuedCount(): number;
69
70
  /** The sessionIds of every currently leased context — used to report/group leased sessions. */
70
71
  leasedSessionIds(): string[];
71
- /** Release a lease by its sessionId (closes the context, frees the slot). No-op if unknown. */
72
+ /**
73
+ * Release a lease by its sessionId (closes the context, frees the slot). No-op if unknown.
74
+ *
75
+ * Resolves an alias first, because the agent releases with the id it was GIVEN, which for an app
76
+ * that named its own session is not the id the pool navigated with. Releasing by that name used to
77
+ * be a silent no-op that left the slot held until the reaper took it.
78
+ */
72
79
  release(sessionId: string): Promise<void>;
80
+ /**
81
+ * Record that a lease is also known by the name the APP registered.
82
+ *
83
+ * Leases are keyed by the id the pool navigated with, and the SDK normally adopts that id off the
84
+ * URL so the two agree. An app that passes an explicit `session` to `connect()` keeps its own name
85
+ * instead, which is legitimate, and the lease tool then hands the agent THAT name because it is the
86
+ * one it has to drive with. Every later touch and release arrives under it.
87
+ *
88
+ * Without this the touches hit nothing, the lease ages out despite continuous activity, and the
89
+ * reaper closes the context mid-flow — the failure in #157, where a session died on the very call
90
+ * that would have read a computed value.
91
+ */
92
+ alias(registeredId: string, leaseId: string): void;
73
93
  /** Mark a lease as still in use (called on agent activity) so the reaper doesn't reclaim it. */
74
94
  touch(sessionId: string): void;
75
95
  /**
@@ -30,6 +30,8 @@ export class BrowserPool {
30
30
  /** Leases reclaimed for going stale — see reapedLeaseCount(). */
31
31
  #reapedLeases = 0;
32
32
  #active = new Map();
33
+ /** Registered-session-id → lease id, for apps that keep their own session name. See `alias`. */
34
+ #aliases = new Map();
33
35
  /**
34
36
  * Slots claimed-or-active — the real concurrency gate. Incremented SYNCHRONOUSLY the instant an
35
37
  * acquire passes the cap check, BEFORE the async context creation, so a burst of concurrent
@@ -50,7 +52,10 @@ export class BrowserPool {
50
52
  this.#ttl = opts.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
51
53
  this.#navTimeout = opts.navTimeoutMs ?? DEFAULT_NAV_TIMEOUT_MS;
52
54
  }
53
- /** Currently leased contexts. */
55
+ /** The TTL configured for leases — how long an untouched lease lives before the reaper reclaims it. */
56
+ leaseTtlMs() {
57
+ return this.#ttl;
58
+ }
54
59
  /** Max simultaneous leases — the ceiling the parallel suite sizes its concurrency to. */
55
60
  capacity() {
56
61
  return this.#max;
@@ -66,13 +71,42 @@ export class BrowserPool {
66
71
  leasedSessionIds() {
67
72
  return [...this.#active.keys()];
68
73
  }
69
- /** Release a lease by its sessionId (closes the context, frees the slot). No-op if unknown. */
74
+ /**
75
+ * Release a lease by its sessionId (closes the context, frees the slot). No-op if unknown.
76
+ *
77
+ * Resolves an alias first, because the agent releases with the id it was GIVEN, which for an app
78
+ * that named its own session is not the id the pool navigated with. Releasing by that name used to
79
+ * be a silent no-op that left the slot held until the reaper took it.
80
+ */
70
81
  release(sessionId) {
71
- return this.#release(sessionId);
82
+ return this.#release(this.#leaseIdOf(sessionId));
83
+ }
84
+ /**
85
+ * Record that a lease is also known by the name the APP registered.
86
+ *
87
+ * Leases are keyed by the id the pool navigated with, and the SDK normally adopts that id off the
88
+ * URL so the two agree. An app that passes an explicit `session` to `connect()` keeps its own name
89
+ * instead, which is legitimate, and the lease tool then hands the agent THAT name because it is the
90
+ * one it has to drive with. Every later touch and release arrives under it.
91
+ *
92
+ * Without this the touches hit nothing, the lease ages out despite continuous activity, and the
93
+ * reaper closes the context mid-flow — the failure in #157, where a session died on the very call
94
+ * that would have read a computed value.
95
+ */
96
+ alias(registeredId, leaseId) {
97
+ if (registeredId === leaseId)
98
+ return;
99
+ if (!this.#active.has(leaseId))
100
+ return;
101
+ this.#aliases.set(registeredId, leaseId);
102
+ }
103
+ /** The lease this id refers to: itself, or whatever it is an alias for. */
104
+ #leaseIdOf(sessionId) {
105
+ return this.#active.has(sessionId) ? sessionId : (this.#aliases.get(sessionId) ?? sessionId);
72
106
  }
73
107
  /** Mark a lease as still in use (called on agent activity) so the reaper doesn't reclaim it. */
74
108
  touch(sessionId) {
75
- const lease = this.#active.get(sessionId);
109
+ const lease = this.#active.get(this.#leaseIdOf(sessionId));
76
110
  if (lease !== undefined)
77
111
  lease.touchedAt = this.#now();
78
112
  }
@@ -173,6 +207,12 @@ export class BrowserPool {
173
207
  if (lease === undefined)
174
208
  return; // already released or lost to a crash
175
209
  this.#active.delete(sessionId);
210
+ // Drop any alias pointing here, or the map grows for the life of the daemon and a later lease
211
+ // reusing that registered name would resolve to a context that is already closed.
212
+ for (const [registered, leaseId] of this.#aliases) {
213
+ if (leaseId === sessionId)
214
+ this.#aliases.delete(registered);
215
+ }
176
216
  await lease.context.close().catch(() => undefined);
177
217
  this.#releaseSlot();
178
218
  }
@@ -0,0 +1,24 @@
1
+ import { type ReticleEvent } from '@reticlehq/core';
2
+ interface RouteLearningSession {
3
+ url: string;
4
+ onEvent(handler: (event: ReticleEvent) => void): () => void;
5
+ }
6
+ interface RouteLearningBridge {
7
+ sessions: {
8
+ all(): RouteLearningSession[];
9
+ };
10
+ attachSessionReady(handler: (session: RouteLearningSession) => void): void;
11
+ }
12
+ interface RouteLearningStore {
13
+ recordRoutes(routes: readonly string[]): Promise<void>;
14
+ }
15
+ /** Coalesce navigation bursts so one crawl does not lock and rewrite project.json per event. */
16
+ export declare const ROUTE_PERSIST_DEBOUNCE_MS = 50;
17
+ /** Convert a validated route-change payload into a bounded route identity. */
18
+ export declare function routeFromEvent(event: ReticleEvent): string | undefined;
19
+ /** Read the same route shape from a session's absolute URL. */
20
+ export declare function routeFromUrl(value: string): string | undefined;
21
+ export declare function routesFromEvents(events: readonly ReticleEvent[]): string[];
22
+ /** Persist initial and subsequent routes for existing and future browser sessions. */
23
+ export declare function attachRouteLearning(bridge: RouteLearningBridge, project: RouteLearningStore): void;
24
+ export {};
@@ -0,0 +1,63 @@
1
+ import { EventType } from '@reticlehq/core';
2
+ /** Coalesce navigation bursts so one crawl does not lock and rewrite project.json per event. */
3
+ export const ROUTE_PERSIST_DEBOUNCE_MS = 50;
4
+ /** Convert a validated route-change payload into a bounded route identity. */
5
+ export function routeFromEvent(event) {
6
+ if (event.type !== EventType.ROUTE_CHANGE)
7
+ return undefined;
8
+ const pathname = event.data['pathname'];
9
+ if ('string' !== typeof pathname || 0 === pathname.length)
10
+ return undefined;
11
+ return pathname;
12
+ }
13
+ /** Read the same route shape from a session's absolute URL. */
14
+ export function routeFromUrl(value) {
15
+ try {
16
+ const url = new URL(value);
17
+ return url.pathname;
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ }
23
+ export function routesFromEvents(events) {
24
+ return events.flatMap((event) => {
25
+ const route = routeFromEvent(event);
26
+ return route === undefined ? [] : [route];
27
+ });
28
+ }
29
+ /** Persist initial and subsequent routes for existing and future browser sessions. */
30
+ export function attachRouteLearning(bridge, project) {
31
+ const attached = new WeakSet();
32
+ const pending = new Set();
33
+ let flushTimer;
34
+ const flush = () => {
35
+ flushTimer = undefined;
36
+ const routes = [...pending];
37
+ pending.clear();
38
+ if (routes.length > 0)
39
+ void project.recordRoutes(routes).catch(() => undefined);
40
+ };
41
+ const queue = (route) => {
42
+ pending.add(route);
43
+ if (flushTimer !== undefined)
44
+ clearTimeout(flushTimer);
45
+ flushTimer = setTimeout(flush, ROUTE_PERSIST_DEBOUNCE_MS);
46
+ };
47
+ const attach = (session) => {
48
+ if (attached.has(session))
49
+ return;
50
+ attached.add(session);
51
+ const initial = routeFromUrl(session.url);
52
+ if (initial !== undefined)
53
+ queue(initial);
54
+ session.onEvent((event) => {
55
+ const route = routeFromEvent(event);
56
+ if (route !== undefined)
57
+ queue(route);
58
+ });
59
+ };
60
+ for (const session of bridge.sessions.all())
61
+ attach(session);
62
+ bridge.attachSessionReady(attach);
63
+ }
@@ -26,6 +26,12 @@ export declare class ProjectStore {
26
26
  * (last PER_NAME of any one name, then cap to TOTAL overall) and writes byte-stably.
27
27
  */
28
28
  recordRun(record: Omit<RunRecord, 'at'>): Promise<void>;
29
+ /**
30
+ * Add discovered routes to the learned app map. Empty input is a no-op so "nothing observed"
31
+ * does not materialize as `learned.routes: []`; non-empty updates are serialized per file so
32
+ * concurrent browser sessions accumulate rather than overwriting one another.
33
+ */
34
+ recordRoutes(routes: readonly string[]): Promise<void>;
29
35
  /** The most-recent run for `name` (undefined on missing/malformed/none). Powers diff-vs-last. */
30
36
  lastRun(name: string): Promise<RunRecord | undefined>;
31
37
  }
@@ -1,4 +1,4 @@
1
- import { PROJECT_FILE_VERSION, PROJECT_RUN_CAP, ProjectFileSchema, ProjectReadError, } from '@reticlehq/core';
1
+ import { PROJECT_FILE_VERSION, PROJECT_ROUTE_CAP, PROJECT_RUN_CAP, ProjectFileSchema, ProjectReadError, } from '@reticlehq/core';
2
2
  import { reticleDirPaths } from './reticle-dir.js';
3
3
  import { withFileLock } from './file-lock.js';
4
4
  const JSON_INDENT = 2;
@@ -77,6 +77,30 @@ export class ProjectStore {
77
77
  await this.#fs.writeFile(path, this.#serialize(next));
78
78
  });
79
79
  }
80
+ /**
81
+ * Add discovered routes to the learned app map. Empty input is a no-op so "nothing observed"
82
+ * does not materialize as `learned.routes: []`; non-empty updates are serialized per file so
83
+ * concurrent browser sessions accumulate rather than overwriting one another.
84
+ */
85
+ async recordRoutes(routes) {
86
+ const additions = routes.filter((route) => route.length > 0);
87
+ if (0 === additions.length)
88
+ return;
89
+ const path = reticleDirPaths(this.#root).project;
90
+ await withFileLock(path, async () => {
91
+ const existing = await this.read();
92
+ const base = existing.ok ? existing.file : EMPTY_PROJECT;
93
+ const current = base.learned?.routes ?? [];
94
+ const merged = [...new Set([...current, ...additions])].slice(0, PROJECT_ROUTE_CAP);
95
+ if (merged.length === current.length &&
96
+ merged.every((route, index) => route === current[index]))
97
+ return;
98
+ const learned = { ...base.learned, routes: merged };
99
+ const next = { ...base, learned };
100
+ await this.#fs.mkdir(reticleDirPaths(this.#root).root);
101
+ await this.#fs.writeFile(path, this.#serialize(next));
102
+ });
103
+ }
80
104
  /** The most-recent run for `name` (undefined on missing/malformed/none). Powers diff-vs-last. */
81
105
  async lastRun(name) {
82
106
  const read = await this.read();
@@ -13,6 +13,8 @@ export interface ReticleDirPaths {
13
13
  baselines: string;
14
14
  /**.../.reticle/project.json (cross-run outcome memory) */
15
15
  project: string;
16
+ /**.../.reticle/impact.json (what Reticle has done for this user, local only) */
17
+ impact: string;
16
18
  /**.../.reticle/visual (PNG baselines + diffs) */
17
19
  visual: string;
18
20
  /**.../.reticle/runs (verification-run artifacts) */
@@ -7,6 +7,7 @@ export function reticleDirPaths(root) {
7
7
  flows: join(root, ReticleDir.FLOWS_SUBDIR),
8
8
  baselines: join(root, ReticleDir.BASELINES_SUBDIR),
9
9
  project: join(root, ReticleDir.PROJECT_FILE),
10
+ impact: join(root, ReticleDir.IMPACT_FILE),
10
11
  visual: join(root, ReticleDir.VISUAL_SUBDIR),
11
12
  runs: join(root, ReticleDir.RUNS_SUBDIR),
12
13
  sessions: join(root, ReticleDir.SESSIONS_SUBDIR),
@@ -0,0 +1,59 @@
1
+ /**
2
+ * One durable bit: has an app ever connected to Reticle on this port, for this project?
3
+ *
4
+ * `SessionManager.everConnected()` answers a narrower question than every reader of it assumed — it
5
+ * is scoped to the CURRENT PROCESS. Daemons are short-lived by design (they idle out and respawn),
6
+ * so a diagnosis built on that boolean tells a user whose app connected a minute ago that this
7
+ * install has never worked, and sends them looking for a missing config file that was always there.
8
+ * Reported repeatedly, and the confident phrasing is what made it expensive: "never seen one" reads
9
+ * as evidence about the install, not as a fact about a process that booted four seconds ago.
10
+ *
11
+ * Deliberately tiny: one JSON file per port under the daemon's state home, holding the set of
12
+ * projectIds that have connected. It is a HINT, so every failure mode here degrades to "we do not
13
+ * know" rather than throwing — a diagnostic that can crash the tool it is diagnosing is worse than
14
+ * no diagnostic at all.
15
+ */
16
+ /**
17
+ * Record that a session connected. Best-effort — a daemon must never fail to serve a session
18
+ * because it could not write a hint about it.
19
+ */
20
+ export declare function rememberConnected(stateDir: string, port: number, projectId: string | undefined): void;
21
+ /**
22
+ * Whether an app has connected on this port before, for this project.
23
+ *
24
+ * An absent `projectId` asks the weaker question — "has ANY app connected on this port" — because
25
+ * that is the honest reading when the caller cannot name a project: the alternative is to answer
26
+ * "no" for a daemon that plainly has served apps, which is the same over-confident claim this file
27
+ * exists to remove.
28
+ */
29
+ export declare function hasConnectedBefore(stateDir: string, port: number, projectId: string | undefined): boolean;
30
+ /**
31
+ * Has an app connected before FOR THIS PROJECT — the question the first-move instructions ask.
32
+ *
33
+ * Separate from `hasConnectedBefore` because they are genuinely different questions and only one of
34
+ * them tolerates the untagged fallback. The no-session diagnosis wants "has this daemon ever served
35
+ * an app", and answering "no" there when it plainly has is the over-confident claim this file
36
+ * exists to remove. The first-move block claims something narrower and says so in its own first
37
+ * sentence: "no app has ever connected to Reticle IN THIS PROJECT".
38
+ *
39
+ * Deciding that narrower claim with the weaker question inverted it for exactly the population it
40
+ * was written for. An unwired project has no `.reticle.json`, so no projectId, so it fell into the
41
+ * untagged branch — which is true as soon as any app has ever connected on this port. On any
42
+ * machine where the default port has served anything at all, the guidance was suppressed and the
43
+ * agent was told the setup was done.
44
+ *
45
+ * So an absent projectId answers `false` here: a project with no Reticle identity has nothing
46
+ * recorded under another project's name that could be evidence about it. The cost of being wrong in
47
+ * this direction is one guidance block, read once at handshake, by a setup that is already working.
48
+ *
49
+ * KNOWN LIMIT, and the reason it is accepted rather than worked around. The WRITE side records the
50
+ * id the SDK supplied, while the read side asks `.reticle.json` — and the Vite plugin derives an id
51
+ * without writing that file. So a project wired by hand with the plugin and no `init` records a real
52
+ * id on every connect and reads `undefined` here forever, and is told at each handshake that nothing
53
+ * has connected. Two things make that tolerable: the block's own advice is to run `init`, which
54
+ * writes the file and heals the detection, so it is actionable rather than merely wrong; and the
55
+ * alternative — answering from the port — is the leak this function exists to close, which is the
56
+ * larger error by a wide margin. `no-session-watch` had already made exactly this call for its own
57
+ * diagnosis, which is why it now shares this function instead of keeping a second copy of the rule.
58
+ */
59
+ export declare function hasProjectConnectedBefore(stateDir: string, port: number, projectId: string | undefined): boolean;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * One durable bit: has an app ever connected to Reticle on this port, for this project?
3
+ *
4
+ * `SessionManager.everConnected()` answers a narrower question than every reader of it assumed — it
5
+ * is scoped to the CURRENT PROCESS. Daemons are short-lived by design (they idle out and respawn),
6
+ * so a diagnosis built on that boolean tells a user whose app connected a minute ago that this
7
+ * install has never worked, and sends them looking for a missing config file that was always there.
8
+ * Reported repeatedly, and the confident phrasing is what made it expensive: "never seen one" reads
9
+ * as evidence about the install, not as a fact about a process that booted four seconds ago.
10
+ *
11
+ * Deliberately tiny: one JSON file per port under the daemon's state home, holding the set of
12
+ * projectIds that have connected. It is a HINT, so every failure mode here degrades to "we do not
13
+ * know" rather than throwing — a diagnostic that can crash the tool it is diagnosing is worse than
14
+ * no diagnostic at all.
15
+ */
16
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ /** Sessions from an SDK old enough not to stamp a projectId still count, under this key. */
19
+ const UNTAGGED = '(untagged)';
20
+ /**
21
+ * Cap on remembered projects per port.
22
+ *
23
+ * A shared 4400 on a busy machine sees many projects over a week and this file is never garbage
24
+ * collected, so the set is bounded and oldest-out. The bit it answers is only ever used to soften a
25
+ * diagnosis, so losing the tail costs nothing.
26
+ */
27
+ const MAX_REMEMBERED_PROJECTS = 32;
28
+ function memoryPath(stateDir, port) {
29
+ return join(stateDir, `connected-${String(port)}.json`);
30
+ }
31
+ /** The remembered project ids for a port. Empty on absent, unreadable, or malformed state. */
32
+ function read(stateDir, port) {
33
+ try {
34
+ const parsed = JSON.parse(readFileSync(memoryPath(stateDir, port), 'utf8'));
35
+ if (!Array.isArray(parsed))
36
+ return [];
37
+ return parsed.filter((entry) => 'string' === typeof entry);
38
+ }
39
+ catch {
40
+ return [];
41
+ }
42
+ }
43
+ /**
44
+ * Record that a session connected. Best-effort — a daemon must never fail to serve a session
45
+ * because it could not write a hint about it.
46
+ */
47
+ export function rememberConnected(stateDir, port, projectId) {
48
+ const key = projectId ?? UNTAGGED;
49
+ const known = read(stateDir, port);
50
+ if (known.includes(key))
51
+ return;
52
+ const next = [...known, key].slice(-MAX_REMEMBERED_PROJECTS);
53
+ try {
54
+ mkdirSync(stateDir, { recursive: true });
55
+ writeFileSync(memoryPath(stateDir, port), JSON.stringify(next), 'utf8');
56
+ }
57
+ catch {
58
+ // An unwritable state home is already reported elsewhere; it must not break the session path.
59
+ }
60
+ }
61
+ /**
62
+ * Whether an app has connected on this port before, for this project.
63
+ *
64
+ * An absent `projectId` asks the weaker question — "has ANY app connected on this port" — because
65
+ * that is the honest reading when the caller cannot name a project: the alternative is to answer
66
+ * "no" for a daemon that plainly has served apps, which is the same over-confident claim this file
67
+ * exists to remove.
68
+ */
69
+ export function hasConnectedBefore(stateDir, port, projectId) {
70
+ const known = read(stateDir, port);
71
+ if (projectId === undefined)
72
+ return known.length > 0;
73
+ return known.includes(projectId);
74
+ }
75
+ /**
76
+ * Has an app connected before FOR THIS PROJECT — the question the first-move instructions ask.
77
+ *
78
+ * Separate from `hasConnectedBefore` because they are genuinely different questions and only one of
79
+ * them tolerates the untagged fallback. The no-session diagnosis wants "has this daemon ever served
80
+ * an app", and answering "no" there when it plainly has is the over-confident claim this file
81
+ * exists to remove. The first-move block claims something narrower and says so in its own first
82
+ * sentence: "no app has ever connected to Reticle IN THIS PROJECT".
83
+ *
84
+ * Deciding that narrower claim with the weaker question inverted it for exactly the population it
85
+ * was written for. An unwired project has no `.reticle.json`, so no projectId, so it fell into the
86
+ * untagged branch — which is true as soon as any app has ever connected on this port. On any
87
+ * machine where the default port has served anything at all, the guidance was suppressed and the
88
+ * agent was told the setup was done.
89
+ *
90
+ * So an absent projectId answers `false` here: a project with no Reticle identity has nothing
91
+ * recorded under another project's name that could be evidence about it. The cost of being wrong in
92
+ * this direction is one guidance block, read once at handshake, by a setup that is already working.
93
+ *
94
+ * KNOWN LIMIT, and the reason it is accepted rather than worked around. The WRITE side records the
95
+ * id the SDK supplied, while the read side asks `.reticle.json` — and the Vite plugin derives an id
96
+ * without writing that file. So a project wired by hand with the plugin and no `init` records a real
97
+ * id on every connect and reads `undefined` here forever, and is told at each handshake that nothing
98
+ * has connected. Two things make that tolerable: the block's own advice is to run `init`, which
99
+ * writes the file and heals the detection, so it is actionable rather than merely wrong; and the
100
+ * alternative — answering from the port — is the leak this function exists to close, which is the
101
+ * larger error by a wide margin. `no-session-watch` had already made exactly this call for its own
102
+ * diagnosis, which is why it now shares this function instead of keeping a second copy of the rule.
103
+ */
104
+ export function hasProjectConnectedBefore(stateDir, port, projectId) {
105
+ if (projectId === undefined)
106
+ return false;
107
+ return hasConnectedBefore(stateDir, port, projectId);
108
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The literal command that starts THIS project's dev server, read from the project's own scripts.
3
+ *
4
+ * The no-session diagnosis has always said "ask the human to start it (`npm run dev`)". That is a
5
+ * guess on two axes — the script name and the package manager — and a guessed command is worse than
6
+ * none: the agent runs it, gets an error about a missing script, and concludes the app is broken
7
+ * rather than that Reticle was wrong. So this reads, and returns nothing when it cannot read.
8
+ *
9
+ * Pure apart from one injected reader, so every case is a unit test rather than a temp directory.
10
+ */
11
+ /** A dev script, as something an agent can actually run. */
12
+ export interface DevCommand {
13
+ /** The literal shell command, e.g. `pnpm run dev`. */
14
+ command: string;
15
+ /** The package.json script it runs. */
16
+ script: string;
17
+ /** The port the script PINS, when it pins one. Absent means the tool picks — never a guess. */
18
+ port?: number;
19
+ }
20
+ /**
21
+ * The dev command for the project in `directory`, or undefined when there is not one to be sure of.
22
+ *
23
+ * `read` is injected so the whole decision is testable without a filesystem; the daemon uses the
24
+ * default disk reader.
25
+ */
26
+ export declare function detectDevCommand(directory: string, read?: (path: string) => string | undefined): DevCommand | undefined;