@reticlehq/server 2.8.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 (292) hide show
  1. package/SKILL.md +130 -813
  2. package/dist/bridge/bridge.d.ts +23 -0
  3. package/dist/bridge/bridge.js +80 -2
  4. package/dist/bridge/bridge.test-harness.d.ts +5 -0
  5. package/dist/bridge/bridge.test-harness.js +6 -4
  6. package/dist/capsule/causal-summary.d.ts +16 -1
  7. package/dist/capsule/causal-summary.js +2 -1
  8. package/dist/cli/cli-doctor.js +41 -13
  9. package/dist/cli/cli-flow-commands.js +8 -8
  10. package/dist/cli/cli-launch.d.ts +0 -17
  11. package/dist/cli/cli-launch.js +0 -28
  12. package/dist/cli/cli-port.d.ts +24 -0
  13. package/dist/cli/cli-port.js +66 -35
  14. package/dist/cli/cli-verify.d.ts +21 -0
  15. package/dist/cli/cli-verify.js +45 -1
  16. package/dist/cli/config-discovery.d.ts +35 -0
  17. package/dist/cli/config-discovery.js +163 -0
  18. package/dist/cli/doctor-daemon-line.js +4 -1
  19. package/dist/cli/doctor-project-line.d.ts +37 -0
  20. package/dist/cli/doctor-project-line.js +35 -0
  21. package/dist/cli/doctor-rows.d.ts +51 -0
  22. package/dist/cli/doctor-rows.js +54 -0
  23. package/dist/cli/doctor-sessions-line.js +6 -3
  24. package/dist/cli/drive-attach.d.ts +99 -0
  25. package/dist/cli/drive-attach.js +203 -0
  26. package/dist/cli/drive-command.d.ts +13 -0
  27. package/dist/cli/drive-command.js +103 -0
  28. package/dist/cli/open-note.d.ts +20 -0
  29. package/dist/cli/open-note.js +37 -0
  30. package/dist/cli/status-next-action.d.ts +37 -0
  31. package/dist/cli/status-next-action.js +61 -0
  32. package/dist/cli.js +65 -70
  33. package/dist/events/contradictions.d.ts +32 -0
  34. package/dist/events/contradictions.js +9 -5
  35. package/dist/events/declared.d.ts +45 -0
  36. package/dist/events/declared.js +73 -0
  37. package/dist/events/echo-mismatch.d.ts +8 -2
  38. package/dist/events/echo-mismatch.js +33 -5
  39. package/dist/events/event-filters.d.ts +1 -1
  40. package/dist/events/event-filters.js +7 -4
  41. package/dist/events/predicate-eval.d.ts +17 -91
  42. package/dist/events/predicate-eval.js +85 -326
  43. package/dist/events/predicate-parse.js +31 -3
  44. package/dist/events/predicate-route.d.ts +32 -0
  45. package/dist/events/predicate-route.js +102 -0
  46. package/dist/events/predicate-schema.d.ts +99 -0
  47. package/dist/events/predicate-schema.js +309 -0
  48. package/dist/events/predicate.d.ts +8 -0
  49. package/dist/events/predicate.js +127 -5
  50. package/dist/flows/annotate-tools.js +3 -0
  51. package/dist/flows/annotate.js +18 -4
  52. package/dist/flows/coverage.d.ts +38 -5
  53. package/dist/flows/coverage.js +30 -4
  54. package/dist/flows/decision.d.ts +15 -0
  55. package/dist/flows/decision.js +1 -1
  56. package/dist/flows/flow-replay-run.js +8 -1
  57. package/dist/flows/flow-tools.js +9 -1
  58. package/dist/flows/replay.js +3 -0
  59. package/dist/honesty/accepted-write.d.ts +3 -0
  60. package/dist/honesty/accepted-write.js +8 -3
  61. package/dist/honesty/already-true.js +15 -0
  62. package/dist/honesty/blind-spots.js +4 -0
  63. package/dist/honesty/honesty.d.ts +11 -0
  64. package/dist/honesty/honesty.js +1 -0
  65. package/dist/honesty/uncaptured-bodies.d.ts +4 -2
  66. package/dist/honesty/uncaptured-bodies.js +9 -2
  67. package/dist/honesty/unread-outcome.d.ts +8 -0
  68. package/dist/honesty/unread-outcome.js +36 -9
  69. package/dist/honesty/unsettled.d.ts +9 -0
  70. package/dist/honesty/unsettled.js +7 -3
  71. package/dist/honesty/verified.d.ts +29 -3
  72. package/dist/honesty/verified.js +51 -8
  73. package/dist/http-server.d.ts +19 -0
  74. package/dist/http-server.js +202 -4
  75. package/dist/impact/impact-recorder.d.ts +23 -0
  76. package/dist/impact/impact-recorder.js +76 -0
  77. package/dist/impact/impact-store.d.ts +47 -0
  78. package/dist/impact/impact-store.js +171 -0
  79. package/dist/index.d.ts +7 -0
  80. package/dist/index.js +44 -10
  81. package/dist/init/agent-rules.d.ts +20 -0
  82. package/dist/init/agent-rules.js +36 -0
  83. package/dist/init/astro-patch.js +27 -1
  84. package/dist/init/confirm.d.ts +21 -3
  85. package/dist/init/confirm.js +32 -5
  86. package/dist/init/connect-steps.d.ts +2 -0
  87. package/dist/init/connect-steps.js +50 -0
  88. package/dist/init/csp-check.d.ts +38 -0
  89. package/dist/init/csp-check.js +92 -0
  90. package/dist/init/csp-doctor.d.ts +25 -0
  91. package/dist/init/csp-doctor.js +49 -0
  92. package/dist/init/cursor.js +6 -9
  93. package/dist/init/detect.d.ts +8 -0
  94. package/dist/init/detect.js +33 -3
  95. package/dist/init/mcp-clients.d.ts +1 -0
  96. package/dist/init/mcp-clients.js +51 -7
  97. package/dist/init/mcp.d.ts +15 -0
  98. package/dist/init/mcp.js +19 -0
  99. package/dist/init/plan-framework.d.ts +10 -0
  100. package/dist/init/plan-framework.js +25 -1
  101. package/dist/init/plan.d.ts +7 -2
  102. package/dist/init/plan.js +66 -30
  103. package/dist/init/run.js +11 -5
  104. package/dist/init/slash-command.d.ts +1 -1
  105. package/dist/init/slash-command.js +12 -4
  106. package/dist/init/snippets.d.ts +1 -1
  107. package/dist/init/snippets.js +35 -7
  108. package/dist/init/vite-config.js +24 -2
  109. package/dist/input/network-mock-tools.js +8 -1
  110. package/dist/input/scroll-find.d.ts +6 -0
  111. package/dist/input/scroll-find.js +17 -2
  112. package/dist/input/scroll-tools.js +4 -0
  113. package/dist/journal/attach-journal.d.ts +9 -1
  114. package/dist/journal/attach-journal.js +13 -2
  115. package/dist/journal/journal-query.d.ts +13 -0
  116. package/dist/journal/journal-query.js +28 -3
  117. package/dist/journal/workspace-gitignore.d.ts +10 -0
  118. package/dist/journal/workspace-gitignore.js +59 -0
  119. package/dist/mcp/mcp-proxy.d.ts +5 -66
  120. package/dist/mcp/mcp-proxy.js +180 -163
  121. package/dist/mcp/mcp.d.ts +10 -1
  122. package/dist/mcp/mcp.js +47 -27
  123. package/dist/mcp/proxy-handshake.d.ts +82 -1
  124. package/dist/mcp/proxy-handshake.js +109 -2
  125. package/dist/mcp/proxy-lifecycle.d.ts +10 -1
  126. package/dist/mcp/proxy-lifecycle.js +11 -2
  127. package/dist/mcp/proxy-log.d.ts +63 -0
  128. package/dist/mcp/proxy-log.js +132 -0
  129. package/dist/mcp/server-instructions.d.ts +17 -0
  130. package/dist/mcp/server-instructions.js +56 -0
  131. package/dist/mcp/shared-params.d.ts +27 -0
  132. package/dist/mcp/shared-params.js +36 -0
  133. package/dist/project/reticle-dir.d.ts +2 -0
  134. package/dist/project/reticle-dir.js +1 -0
  135. package/dist/session/connection-memory.d.ts +59 -0
  136. package/dist/session/connection-memory.js +108 -0
  137. package/dist/session/dev-command.d.ts +26 -0
  138. package/dist/session/dev-command.js +89 -0
  139. package/dist/session/dev-server-probe.d.ts +68 -0
  140. package/dist/session/dev-server-probe.js +102 -7
  141. package/dist/session/last-act.d.ts +26 -0
  142. package/dist/session/last-act.js +35 -1
  143. package/dist/session/live-control-tools.js +42 -5
  144. package/dist/session/no-session-diagnosis.d.ts +48 -2
  145. package/dist/session/no-session-diagnosis.js +176 -20
  146. package/dist/session/no-session-next-action.d.ts +46 -0
  147. package/dist/session/no-session-next-action.js +85 -0
  148. package/dist/session/no-session-watch.d.ts +40 -1
  149. package/dist/session/no-session-watch.js +165 -11
  150. package/dist/session/pending-commands.d.ts +12 -0
  151. package/dist/session/pending-commands.js +13 -1
  152. package/dist/session/session-manager.d.ts +4 -0
  153. package/dist/session/session-manager.js +35 -5
  154. package/dist/session/session.d.ts +10 -1
  155. package/dist/session/session.js +146 -5
  156. package/dist/telemetry/app-instrumented.d.ts +8 -0
  157. package/dist/telemetry/app-instrumented.js +10 -0
  158. package/dist/telemetry/bug-found.d.ts +18 -0
  159. package/dist/telemetry/bug-found.js +14 -0
  160. package/dist/telemetry/daemon-telemetry.js +24 -0
  161. package/dist/telemetry/install-source.d.ts +20 -0
  162. package/dist/telemetry/install-source.js +34 -0
  163. package/dist/telemetry/instrumentation-stall.d.ts +50 -0
  164. package/dist/telemetry/instrumentation-stall.js +90 -0
  165. package/dist/telemetry/session-metrics.d.ts +8 -0
  166. package/dist/telemetry/session-metrics.js +10 -0
  167. package/dist/telemetry/telemetry.d.ts +3 -1
  168. package/dist/telemetry/telemetry.js +13 -2
  169. package/dist/tools/act-tools.js +166 -29
  170. package/dist/tools/assert-verdict.js +32 -7
  171. package/dist/tools/browser-tools.js +10 -0
  172. package/dist/tools/dynamic-tools.js +16 -7
  173. package/dist/tools/error-recovery.js +9 -5
  174. package/dist/tools/invoke-tool.js +20 -3
  175. package/dist/tools/lease-hint.d.ts +30 -8
  176. package/dist/tools/lease-hint.js +65 -14
  177. package/dist/tools/lease-tools.d.ts +11 -0
  178. package/dist/tools/lease-tools.js +141 -96
  179. package/dist/tools/observe-tools.js +9 -3
  180. package/dist/tools/react-grace.d.ts +4 -0
  181. package/dist/tools/react-grace.js +6 -3
  182. package/dist/tools/read-tools.js +47 -1
  183. package/dist/tools/resolve-target.d.ts +33 -0
  184. package/dist/tools/resolve-target.js +53 -0
  185. package/dist/tools/settle-in-flight.d.ts +15 -0
  186. package/dist/tools/settle-in-flight.js +42 -2
  187. package/dist/tools/snapshot-delta.d.ts +3 -0
  188. package/dist/tools/snapshot-delta.js +67 -6
  189. package/dist/tools/tool-kit.d.ts +8 -0
  190. package/dist/tools/tool-surface.d.ts +60 -0
  191. package/dist/tools/tool-surface.js +85 -19
  192. package/dist/tools/tools.js +45 -4
  193. package/dist/tools/unadvertised-help.js +9 -6
  194. package/docs/README.md +1 -2
  195. package/docs/actions.mdx +41 -8
  196. package/docs/agent-cheatsheet.md +4 -2
  197. package/docs/architecture.md +6 -0
  198. package/docs/benchmarks.md +60 -12
  199. package/docs/best-practices.mdx +11 -6
  200. package/docs/capabilities.mdx +201 -54
  201. package/docs/cli/affected.mdx +6 -4
  202. package/docs/cli/capsules.mdx +9 -8
  203. package/docs/cli/cloud.mdx +30 -4
  204. package/docs/cli/doctor.mdx +41 -8
  205. package/docs/cli/drive.mdx +34 -15
  206. package/docs/cli/gate.mdx +6 -6
  207. package/docs/cli/help.mdx +8 -6
  208. package/docs/cli/hunt.mdx +18 -2
  209. package/docs/cli/identify.mdx +16 -2
  210. package/docs/cli/init.mdx +35 -8
  211. package/docs/cli/kill.mdx +5 -5
  212. package/docs/cli/license.mdx +1 -1
  213. package/docs/cli/open.mdx +6 -0
  214. package/docs/cli/restart.mdx +6 -6
  215. package/docs/cli/serve.mdx +10 -3
  216. package/docs/cli/status.mdx +29 -5
  217. package/docs/cli/stop.mdx +8 -0
  218. package/docs/cli/verify.mdx +20 -1
  219. package/docs/cli/version.mdx +1 -1
  220. package/docs/cli/watch.mdx +9 -2
  221. package/docs/cli.mdx +44 -22
  222. package/docs/debugging.md +3 -1
  223. package/docs/deploy-checks.md +30 -1
  224. package/docs/desktop.mdx +276 -80
  225. package/docs/docs.json +13 -12
  226. package/docs/enterprise.md +2 -0
  227. package/docs/faq.mdx +49 -35
  228. package/docs/fixtures.md +2 -0
  229. package/docs/flows.md +3 -1
  230. package/docs/for-agents.md +3 -3
  231. package/docs/frameworks.mdx +46 -7
  232. package/docs/gate-plan.md +4 -2
  233. package/docs/gates.md +7 -5
  234. package/docs/getting-started.md +53 -12
  235. package/docs/human-control.md +2 -0
  236. package/docs/index.mdx +43 -1
  237. package/docs/install-agentic.mdx +13 -4
  238. package/docs/install-manual.mdx +6 -4
  239. package/docs/instrumentation.mdx +7 -1
  240. package/docs/integration-patterns.md +3 -1
  241. package/docs/local-registry.md +4 -2
  242. package/docs/multi-agent-testing.md +3 -1
  243. package/docs/packages/babel-plugin.mdx +15 -6
  244. package/docs/packages/browser.mdx +2 -2
  245. package/docs/packages/core.mdx +26 -16
  246. package/docs/packages/electron.mdx +5 -7
  247. package/docs/packages/eslint-plugin.mdx +5 -3
  248. package/docs/packages/next.mdx +3 -5
  249. package/docs/packages/react.mdx +3 -3
  250. package/docs/packages/server.mdx +2 -2
  251. package/docs/packages/tauri.mdx +6 -6
  252. package/docs/packages/test.mdx +18 -7
  253. package/docs/packages/vite-plugin.mdx +4 -6
  254. package/docs/packages.mdx +14 -13
  255. package/docs/platform-integration.md +2 -0
  256. package/docs/predicates.mdx +76 -7
  257. package/docs/quickstart.mdx +51 -5
  258. package/docs/recipes.mdx +62 -22
  259. package/docs/skill-file.mdx +3 -1
  260. package/docs/state-management.mdx +2 -0
  261. package/docs/system-map.md +3 -1
  262. package/docs/telemetry-contract.md +16 -5
  263. package/docs/telemetry-events.mdx +63 -18
  264. package/docs/telemetry.md +8 -5
  265. package/docs/testing.md +35 -1
  266. package/docs/token-efficiency.md +8 -2
  267. package/docs/tools-act-and-wait.mdx +64 -14
  268. package/docs/tools-act-sequence.mdx +34 -17
  269. package/docs/tools-act.mdx +24 -11
  270. package/docs/tools-assert.mdx +69 -10
  271. package/docs/tools-console.mdx +11 -7
  272. package/docs/tools-inspect.mdx +11 -8
  273. package/docs/tools-navigate.mdx +41 -5
  274. package/docs/tools-network.mdx +42 -9
  275. package/docs/tools-observe.mdx +66 -10
  276. package/docs/tools-overview.mdx +26 -23
  277. package/docs/tools-query.mdx +52 -8
  278. package/docs/tools-session-and-feedback.mdx +30 -9
  279. package/docs/tools-sessions.mdx +39 -10
  280. package/docs/tools-snapshot.mdx +57 -3
  281. package/docs/tools-state.mdx +24 -1
  282. package/docs/tools-tools-and-run.mdx +40 -9
  283. package/docs/tools-wait-for.mdx +34 -15
  284. package/docs/troubleshooting.mdx +80 -10
  285. package/docs/usage.md +43 -21
  286. package/docs/vs-chrome-devtools-mcp.mdx +41 -0
  287. package/docs/vs-playwright-mcp.mdx +42 -2
  288. package/docs/vs-screenshots.mdx +42 -1
  289. package/docs/what-is-reticle.mdx +1 -1
  290. package/docs/why-reticle.mdx +3 -1
  291. package/package.json +5 -5
  292. package/docs/desktop-apps.md +0 -265
@@ -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
+ }
@@ -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;
@@ -0,0 +1,89 @@
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
+ import { readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ /**
14
+ * Script names checked, in order.
15
+ *
16
+ * `start` is last on purpose: in a Next or CRA app it is the PRODUCTION server, which serves a build
17
+ * with no dev-only SDK in it, so starting it would look like success and connect nothing. It is only
18
+ * reached when the project offers nothing better.
19
+ */
20
+ const DEV_SCRIPTS = ['dev', 'develop', 'start'];
21
+ /** Lockfile → package manager, in precedence order. Anything else is npm. */
22
+ const LOCKFILES = [
23
+ { file: 'pnpm-lock.yaml', manager: 'pnpm' },
24
+ { file: 'yarn.lock', manager: 'yarn' },
25
+ ];
26
+ const DEFAULT_PACKAGE_MANAGER = 'npm';
27
+ const PACKAGE_JSON = 'package.json';
28
+ /** `--port 4311` / `--port=4311`, the flag every dev server in this list accepts. */
29
+ const PORT_FLAG = /--port[= ]\s*(\d+)/;
30
+ /** `PORT=8080 remix dev` — the other half of how a project pins its port. */
31
+ const PORT_ENV = /(?:^|\s)PORT=(\d+)/;
32
+ /** Reads a file, or undefined if it is not there / not readable. */
33
+ function readTextFile(path) {
34
+ try {
35
+ return readFileSync(path, 'utf8');
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
41
+ /** The port the script pins, when it pins one at all. */
42
+ function pinnedPort(script) {
43
+ const match = PORT_FLAG.exec(script) ?? PORT_ENV.exec(script);
44
+ const digits = match?.[1];
45
+ if (digits === undefined)
46
+ return undefined;
47
+ const port = Number.parseInt(digits, 10);
48
+ return Number.isSafeInteger(port) ? port : undefined;
49
+ }
50
+ /** The scripts block of a package.json, or an empty one for anything unreadable/unparseable. */
51
+ function readScripts(raw) {
52
+ if (raw === undefined)
53
+ return {};
54
+ try {
55
+ const parsed = JSON.parse(raw);
56
+ if (null === parsed || 'object' !== typeof parsed)
57
+ return {};
58
+ const scripts = parsed.scripts;
59
+ if (null === scripts || 'object' !== typeof scripts)
60
+ return {};
61
+ return scripts;
62
+ }
63
+ catch {
64
+ return {};
65
+ }
66
+ }
67
+ /**
68
+ * The dev command for the project in `directory`, or undefined when there is not one to be sure of.
69
+ *
70
+ * `read` is injected so the whole decision is testable without a filesystem; the daemon uses the
71
+ * default disk reader.
72
+ */
73
+ export function detectDevCommand(directory, read = readTextFile) {
74
+ const scripts = readScripts(read(join(directory, PACKAGE_JSON)));
75
+ const manager = LOCKFILES.find(({ file }) => read(join(directory, file)) !== undefined)?.manager ??
76
+ DEFAULT_PACKAGE_MANAGER;
77
+ for (const script of DEV_SCRIPTS) {
78
+ const body = scripts[script];
79
+ if ('string' !== typeof body || 0 === body.trim().length)
80
+ continue;
81
+ const port = pinnedPort(body);
82
+ return {
83
+ command: `${manager} run ${script}`,
84
+ script,
85
+ ...(port === undefined ? {} : { port }),
86
+ };
87
+ }
88
+ return undefined;
89
+ }
@@ -9,5 +9,73 @@
9
9
  * the probe refreshes in the background and the resolve path reads a cached answer. A stale answer
10
10
  * is fine here — it is a hint in an error message, not a decision.
11
11
  */
12
+ /**
13
+ * What the probe actually learned about a port. Three states, because two cannot express this.
14
+ *
15
+ * A bare TCP connect is not enough (macOS AirPlay Receiver holds 5000 on every Mac and would read as
16
+ * the user's dev server), and a document check alone is not enough either (a compiling SSR server
17
+ * reads as absent). The thing in between — accepted the connection, said nothing in time — is a
18
+ * listener we cannot classify, and reporting that honestly is the point.
19
+ */
20
+ export declare const PortState: {
21
+ /** Nothing accepted a connection on either loopback family. */
22
+ readonly CLOSED: "closed";
23
+ /** Accepted a connection, then answered nothing within the budget. Something IS there. */
24
+ readonly CONNECTED_NO_ANSWER: "connected-no-answer";
25
+ /** Answered with a document: a dev server serving an app. */
26
+ readonly SERVES_DOCUMENT: "serves-document";
27
+ };
28
+ export type PortState = (typeof PortState)[keyof typeof PortState];
29
+ /** The probe's answer for one port, however it was obtained. */
30
+ export declare function classifyPort(port: number, probe: (port: number) => Promise<PortState>): Promise<PortState>;
31
+ /**
32
+ * `localhost`, not `127.0.0.1` — so BOTH address families are tried.
33
+ *
34
+ * Measured: a plain `vite --port 4311` on this machine listens on `[::1]` only, and a probe pinned to
35
+ * the IPv4 loopback cannot see it. The old TCP probe had the same blind spot, which meant the
36
+ * diagnostic could miss the very dev server it exists to find while still reporting an unrelated
37
+ * service that happens to bind both stacks (macOS AirPlay does).
38
+ */
39
+ /**
40
+ * Both loopback addresses, BY ADDRESS — never by the name `localhost`.
41
+ *
42
+ * A dev server started with `--host` binds the wildcards `0.0.0.0` and `[::]` rather than the
43
+ * loopbacks, and a wildcard bind serves only the family it belongs to. Asking for `localhost` hands
44
+ * the choice of family to the resolver: on Windows it prefers `::1`, so a `0.0.0.0` listener is
45
+ * invisible and the scan reports "nothing is listening" about a dev server that is plainly running.
46
+ * Reported from the field on exactly that setup.
47
+ *
48
+ * `[::1]` is also how a `[::]` bind is reached, and on most stacks a `[::]` bind additionally
49
+ * accepts v4-mapped connections — so between the two, every ordinary bind is covered.
50
+ */
51
+ export declare const PROBE_HOSTS: readonly string[];
52
+ /**
53
+ * Is anything serving a document on this port, on EITHER loopback family?
54
+ *
55
+ * "Either" and not "both": one answer is enough, and requiring both would make the wildcard binds
56
+ * this exists to see fail the check again. A family that errors (no IPv6 stack at all) is an
57
+ * absence, never a rejection — a diagnostic must not throw at the tool it is diagnosing.
58
+ */
59
+ export declare function anyFamilyServes(port: number, probe?: (port: number, host: string) => Promise<boolean>): Promise<boolean>;
60
+ /**
61
+ * Every candidate port, split by what the probe could actually establish.
62
+ *
63
+ * `slow` is the set that accepted a connection and then said nothing inside the budget. Those ports
64
+ * are NOT dev servers as far as this check knows, and they are emphatically not absent — an SSR
65
+ * framework compiling its first route lands here every time. Reported separately so the diagnosis
66
+ * can stop telling people to start a server that is already running.
67
+ */
68
+ export declare function probeDevServerStates(ports?: readonly number[], probe?: (port: number) => Promise<PortState>): Promise<{
69
+ serving: number[];
70
+ slow: number[];
71
+ }>;
72
+ /**
73
+ * The strongest state either loopback family reports.
74
+ *
75
+ * Serving a document beats a bare connection beats nothing, so a server bound to only one family is
76
+ * still seen at its best — the same "either, not both" rule `anyFamilyServes` follows, extended to
77
+ * three states.
78
+ */
79
+ export declare function anyFamilyState(port: number, probe?: (port: number, host: string) => Promise<PortState>): Promise<PortState>;
12
80
  /** Every candidate port serving a document, in ascending order. Never rejects. */
13
81
  export declare function probeDevServers(ports?: readonly number[], probe?: (port: number) => Promise<boolean>): Promise<number[]>;
@@ -12,8 +12,37 @@
12
12
  import { request } from 'node:http';
13
13
  import { DEV_SERVER_PORTS } from '../cli/cli-port.js';
14
14
  import { looksLikeDevServer } from './looks-like-dev-server.js';
15
- /** Give up fast: an HTTP answer from localhost either lands quickly or nothing is there. */
15
+ /**
16
+ * Give up fast, but do not call giving up an absence.
17
+ *
18
+ * This budget suits Vite handing back a static index.html. It does NOT suit an SSR framework
19
+ * compiling a route on the first request: Nuxt and Next routinely take seconds cold, and treating
20
+ * that as "nothing is there" told a reporter to start a dev server that was already serving 57KB of
21
+ * HTML — advice that would have hit their dev lock with a second `nuxt dev`.
22
+ *
23
+ * So the budget stays short and the TIMEOUT changed meaning instead. See PortState.
24
+ */
16
25
  const HTTP_PROBE_TIMEOUT_MS = 400;
26
+ /**
27
+ * What the probe actually learned about a port. Three states, because two cannot express this.
28
+ *
29
+ * A bare TCP connect is not enough (macOS AirPlay Receiver holds 5000 on every Mac and would read as
30
+ * the user's dev server), and a document check alone is not enough either (a compiling SSR server
31
+ * reads as absent). The thing in between — accepted the connection, said nothing in time — is a
32
+ * listener we cannot classify, and reporting that honestly is the point.
33
+ */
34
+ export const PortState = {
35
+ /** Nothing accepted a connection on either loopback family. */
36
+ CLOSED: 'closed',
37
+ /** Accepted a connection, then answered nothing within the budget. Something IS there. */
38
+ CONNECTED_NO_ANSWER: 'connected-no-answer',
39
+ /** Answered with a document: a dev server serving an app. */
40
+ SERVES_DOCUMENT: 'serves-document',
41
+ };
42
+ /** The probe's answer for one port, however it was obtained. */
43
+ export function classifyPort(port, probe) {
44
+ return probe(port).catch(() => PortState.CLOSED);
45
+ }
17
46
  /**
18
47
  * `localhost`, not `127.0.0.1` — so BOTH address families are tried.
19
48
  *
@@ -22,7 +51,19 @@ const HTTP_PROBE_TIMEOUT_MS = 400;
22
51
  * diagnostic could miss the very dev server it exists to find while still reporting an unrelated
23
52
  * service that happens to bind both stacks (macOS AirPlay does).
24
53
  */
25
- const PROBE_HOST = 'localhost';
54
+ /**
55
+ * Both loopback addresses, BY ADDRESS — never by the name `localhost`.
56
+ *
57
+ * A dev server started with `--host` binds the wildcards `0.0.0.0` and `[::]` rather than the
58
+ * loopbacks, and a wildcard bind serves only the family it belongs to. Asking for `localhost` hands
59
+ * the choice of family to the resolver: on Windows it prefers `::1`, so a `0.0.0.0` listener is
60
+ * invisible and the scan reports "nothing is listening" about a dev server that is plainly running.
61
+ * Reported from the field on exactly that setup.
62
+ *
63
+ * `[::1]` is also how a `[::]` bind is reached, and on most stacks a `[::]` bind additionally
64
+ * accepts v4-mapped connections — so between the two, every ordinary bind is covered.
65
+ */
66
+ export const PROBE_HOSTS = ['127.0.0.1', '::1'];
26
67
  /**
27
68
  * Does this port serve a DOCUMENT — i.e. is it a dev server rather than merely something listening?
28
69
  *
@@ -30,23 +71,77 @@ const PROBE_HOST = 'localhost';
30
71
  * port 5000) as the user's dev server. See looks-like-dev-server for what it actually answers and
31
72
  * why "a socket accepted" is the wrong question.
32
73
  */
33
- function servesDocument(port, host = PROBE_HOST) {
74
+ function probeState(port, host) {
34
75
  return new Promise((resolve) => {
76
+ // Whether the SOCKET came up is tracked separately from whether a response did, because that is
77
+ // the whole difference between "nothing is there" and "something is there and still compiling".
78
+ let connected = false;
35
79
  const req = request({ host, port, path: '/', method: 'GET', timeout: HTTP_PROBE_TIMEOUT_MS }, (res) => {
36
80
  const answer = looksLikeDevServer(res.statusCode ?? 0, res.headers['content-type']);
37
81
  res.resume(); // drain, so the socket closes rather than lingering
38
- resolve(answer);
82
+ resolve(answer ? PortState.SERVES_DOCUMENT : PortState.CLOSED);
83
+ });
84
+ req.once('socket', (socket) => {
85
+ socket.once('connect', () => {
86
+ connected = true;
87
+ });
39
88
  });
40
89
  req.once('timeout', () => {
41
90
  req.destroy();
42
- resolve(false);
91
+ resolve(connected ? PortState.CONNECTED_NO_ANSWER : PortState.CLOSED);
43
92
  });
44
- req.once('error', () => resolve(false));
93
+ req.once('error', () => resolve(PortState.CLOSED));
45
94
  req.end();
46
95
  });
47
96
  }
97
+ /** Kept for callers that only need the yes/no. A slow listener is NOT a dev server, but it is not absent. */
98
+ function servesDocument(port, host) {
99
+ return probeState(port, host).then((state) => state === PortState.SERVES_DOCUMENT);
100
+ }
101
+ /**
102
+ * Is anything serving a document on this port, on EITHER loopback family?
103
+ *
104
+ * "Either" and not "both": one answer is enough, and requiring both would make the wildcard binds
105
+ * this exists to see fail the check again. A family that errors (no IPv6 stack at all) is an
106
+ * absence, never a rejection — a diagnostic must not throw at the tool it is diagnosing.
107
+ */
108
+ export async function anyFamilyServes(port, probe = servesDocument) {
109
+ const answers = await Promise.all(PROBE_HOSTS.map((host) => probe(port, host).catch(() => false)));
110
+ return answers.some((up) => up);
111
+ }
112
+ /**
113
+ * Every candidate port, split by what the probe could actually establish.
114
+ *
115
+ * `slow` is the set that accepted a connection and then said nothing inside the budget. Those ports
116
+ * are NOT dev servers as far as this check knows, and they are emphatically not absent — an SSR
117
+ * framework compiling its first route lands here every time. Reported separately so the diagnosis
118
+ * can stop telling people to start a server that is already running.
119
+ */
120
+ export async function probeDevServerStates(ports = [...DEV_SERVER_PORTS], probe = (p) => anyFamilyState(p)) {
121
+ const states = await Promise.all(ports.map((p) => classifyPort(p, probe).then((state) => ({ port: p, state }))));
122
+ const pick = (want) => states
123
+ .filter((s) => s.state === want)
124
+ .map((s) => s.port)
125
+ .sort((a, b) => a - b);
126
+ return { serving: pick(PortState.SERVES_DOCUMENT), slow: pick(PortState.CONNECTED_NO_ANSWER) };
127
+ }
128
+ /**
129
+ * The strongest state either loopback family reports.
130
+ *
131
+ * Serving a document beats a bare connection beats nothing, so a server bound to only one family is
132
+ * still seen at its best — the same "either, not both" rule `anyFamilyServes` follows, extended to
133
+ * three states.
134
+ */
135
+ export async function anyFamilyState(port, probe = probeState) {
136
+ const answers = await Promise.all(PROBE_HOSTS.map((host) => probe(port, host).catch(() => PortState.CLOSED)));
137
+ if (answers.includes(PortState.SERVES_DOCUMENT))
138
+ return PortState.SERVES_DOCUMENT;
139
+ if (answers.includes(PortState.CONNECTED_NO_ANSWER))
140
+ return PortState.CONNECTED_NO_ANSWER;
141
+ return PortState.CLOSED;
142
+ }
48
143
  /** Every candidate port serving a document, in ascending order. Never rejects. */
49
- export async function probeDevServers(ports = [...DEV_SERVER_PORTS], probe = (p) => servesDocument(p)) {
144
+ export async function probeDevServers(ports = [...DEV_SERVER_PORTS], probe = (p) => anyFamilyServes(p)) {
50
145
  const results = await Promise.all(ports.map((p) => probe(p).then((up) => (up ? p : null))));
51
146
  return results.filter((p) => p !== null).sort((a, b) => a - b);
52
147
  }