@reticlehq/server 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/README.md +14 -5
  2. package/SKILL.md +64 -29
  3. package/dist/capsule/causal-summary.d.ts +20 -0
  4. package/dist/capsule/causal-summary.js +31 -6
  5. package/dist/capsule/divergence.js +7 -1
  6. package/dist/cli/chromium-hint.d.ts +33 -5
  7. package/dist/cli/chromium-hint.js +82 -12
  8. package/dist/cli/cli-doctor.js +28 -25
  9. package/dist/cli/cli-kill.d.ts +58 -0
  10. package/dist/cli/cli-kill.js +162 -0
  11. package/dist/cli/cli-launch.d.ts +17 -0
  12. package/dist/cli/cli-launch.js +28 -0
  13. package/dist/cli/cli-parse.d.ts +16 -1
  14. package/dist/cli/cli-parse.js +32 -9
  15. package/dist/cli/cli-port.d.ts +8 -0
  16. package/dist/cli/cli-port.js +16 -0
  17. package/dist/cli/cli-verify.js +2 -2
  18. package/dist/cli/doctor-sessions-line.d.ts +12 -0
  19. package/dist/cli/doctor-sessions-line.js +66 -0
  20. package/dist/cli/port-holder.d.ts +10 -0
  21. package/dist/cli/port-holder.js +26 -2
  22. package/dist/cli.js +99 -14
  23. package/dist/crawl/crawl-tools.js +12 -2
  24. package/dist/crawl/crawl.d.ts +13 -0
  25. package/dist/crawl/crawl.js +50 -2
  26. package/dist/events/contradictions.d.ts +14 -0
  27. package/dist/events/contradictions.js +26 -17
  28. package/dist/events/predicate-eval.d.ts +20 -1
  29. package/dist/events/predicate-eval.js +125 -3
  30. package/dist/events/predicate.js +170 -18
  31. package/dist/flows/flow-classify.js +1 -1
  32. package/dist/flows/flow-tools.js +12 -4
  33. package/dist/flows/heal-run.js +1 -1
  34. package/dist/honesty/blind-spots.js +6 -2
  35. package/dist/honesty/unsettled.d.ts +40 -0
  36. package/dist/honesty/unsettled.js +78 -0
  37. package/dist/honesty/verified.d.ts +7 -0
  38. package/dist/honesty/verified.js +3 -3
  39. package/dist/index.js +3 -0
  40. package/dist/init/agent-rules.d.ts +16 -0
  41. package/dist/init/agent-rules.js +96 -22
  42. package/dist/init/astro-patch.d.ts +2 -1
  43. package/dist/init/astro-patch.js +11 -5
  44. package/dist/init/capabilities.d.ts +22 -0
  45. package/dist/init/capabilities.js +76 -0
  46. package/dist/init/confirm.d.ts +96 -0
  47. package/dist/init/confirm.js +138 -0
  48. package/dist/init/cra.js +3 -2
  49. package/dist/init/non-js-project.js +29 -5
  50. package/dist/init/plan-framework.js +53 -15
  51. package/dist/init/plan.d.ts +40 -4
  52. package/dist/init/plan.js +163 -40
  53. package/dist/init/run.d.ts +24 -2
  54. package/dist/init/run.js +148 -42
  55. package/dist/init/slash-command.d.ts +1 -1
  56. package/dist/init/slash-command.js +46 -6
  57. package/dist/init/snippets.d.ts +44 -4
  58. package/dist/init/snippets.js +161 -37
  59. package/dist/init/vite-config.js +29 -3
  60. package/dist/input/network-mock-tools.d.ts +19 -0
  61. package/dist/input/network-mock-tools.js +29 -4
  62. package/dist/license/license.js +1 -1
  63. package/dist/mcp/mcp-proxy.d.ts +69 -3
  64. package/dist/mcp/mcp-proxy.js +205 -20
  65. package/dist/mcp/mcp.js +23 -7
  66. package/dist/mcp/proxy-lifecycle.d.ts +1 -1
  67. package/dist/mcp/proxy-lifecycle.js +1 -1
  68. package/dist/pool/browser-pool.d.ts +22 -2
  69. package/dist/pool/browser-pool.js +44 -4
  70. package/dist/project/learned-routes.d.ts +24 -0
  71. package/dist/project/learned-routes.js +63 -0
  72. package/dist/project/project-store.d.ts +6 -0
  73. package/dist/project/project-store.js +25 -1
  74. package/dist/session/no-session-diagnosis.d.ts +19 -0
  75. package/dist/session/no-session-diagnosis.js +71 -18
  76. package/dist/session/no-session-watch.js +12 -0
  77. package/dist/session/ref-provenance.d.ts +54 -0
  78. package/dist/session/ref-provenance.js +61 -0
  79. package/dist/session/session-lease.js +1 -1
  80. package/dist/telemetry/automation-hint.d.ts +17 -0
  81. package/dist/telemetry/automation-hint.js +71 -0
  82. package/dist/telemetry/bug-found.d.ts +0 -20
  83. package/dist/telemetry/bug-found.js +38 -21
  84. package/dist/telemetry/cli-telemetry.js +7 -1
  85. package/dist/telemetry/feedback.js +1 -1
  86. package/dist/telemetry/init-telemetry.js +9 -1
  87. package/dist/telemetry/install-source.d.ts +36 -0
  88. package/dist/telemetry/install-source.js +40 -0
  89. package/dist/telemetry/run-telemetry.js +4 -1
  90. package/dist/telemetry/session-metrics.js +5 -0
  91. package/dist/telemetry/telemetry.d.ts +5 -1
  92. package/dist/telemetry/telemetry.js +15 -2
  93. package/dist/telemetry/tool-refused.d.ts +19 -0
  94. package/dist/telemetry/tool-refused.js +55 -0
  95. package/dist/telemetry/verification-of.js +46 -7
  96. package/dist/temp-dir.d.ts +27 -0
  97. package/dist/temp-dir.js +46 -0
  98. package/dist/tools/act-tools.js +45 -1
  99. package/dist/tools/assert-grade.d.ts +1 -1
  100. package/dist/tools/assert-grade.js +27 -5
  101. package/dist/tools/assert-verdict.js +18 -1
  102. package/dist/tools/contract-tools.js +4 -0
  103. package/dist/tools/dynamic-tools.js +1 -1
  104. package/dist/tools/error-recovery.d.ts +10 -0
  105. package/dist/tools/error-recovery.js +64 -4
  106. package/dist/tools/feedback-invite.js +1 -1
  107. package/dist/tools/invoke-tool.js +139 -23
  108. package/dist/tools/lease-tools.d.ts +29 -1
  109. package/dist/tools/lease-tools.js +58 -5
  110. package/dist/tools/navigate-result.js +1 -1
  111. package/dist/tools/observe-tools.js +1 -1
  112. package/dist/tools/observed-nothing.js +1 -1
  113. package/dist/tools/react-grace.d.ts +48 -0
  114. package/dist/tools/react-grace.js +87 -0
  115. package/dist/tools/read-tools.js +20 -4
  116. package/dist/tools/settle-in-flight.d.ts +79 -0
  117. package/dist/tools/settle-in-flight.js +106 -0
  118. package/dist/tools/snapshot-delta.d.ts +7 -0
  119. package/dist/tools/snapshot-delta.js +130 -6
  120. package/dist/tools/tool-surface.js +19 -0
  121. package/dist/tools/tools.js +16 -4
  122. package/docs/README.md +56 -11
  123. package/docs/actions.mdx +87 -0
  124. package/docs/agent-cheatsheet.md +93 -29
  125. package/docs/architecture.md +46 -35
  126. package/docs/benchmarks.md +76 -72
  127. package/docs/best-practices.mdx +211 -0
  128. package/docs/capabilities.mdx +387 -0
  129. package/docs/cli/affected.mdx +51 -0
  130. package/docs/cli/capsules.mdx +69 -0
  131. package/docs/cli/cloud.mdx +127 -0
  132. package/docs/cli/doctor.mdx +76 -0
  133. package/docs/cli/drive.mdx +55 -0
  134. package/docs/cli/feedback.mdx +67 -0
  135. package/docs/cli/gate.mdx +64 -0
  136. package/docs/cli/help.mdx +92 -0
  137. package/docs/cli/hunt.mdx +57 -0
  138. package/docs/cli/identify.mdx +62 -0
  139. package/docs/cli/init.mdx +94 -0
  140. package/docs/cli/kill.mdx +91 -0
  141. package/docs/cli/license.mdx +48 -0
  142. package/docs/cli/mcp.mdx +61 -0
  143. package/docs/cli/open.mdx +68 -0
  144. package/docs/cli/restart.mdx +60 -0
  145. package/docs/cli/rollback.mdx +35 -0
  146. package/docs/cli/serve.mdx +62 -0
  147. package/docs/cli/status.mdx +64 -0
  148. package/docs/cli/stop.mdx +56 -0
  149. package/docs/cli/telemetry.mdx +57 -0
  150. package/docs/cli/update.mdx +52 -0
  151. package/docs/cli/verify.mdx +57 -0
  152. package/docs/cli/version.mdx +39 -0
  153. package/docs/cli/watch.mdx +55 -0
  154. package/docs/cli.mdx +219 -0
  155. package/docs/debugging.md +13 -9
  156. package/docs/deploy-checks.md +15 -11
  157. package/docs/desktop-apps.md +49 -40
  158. package/docs/desktop.mdx +188 -0
  159. package/docs/docs.json +363 -0
  160. package/docs/enterprise.md +20 -16
  161. package/docs/faq.mdx +88 -0
  162. package/docs/fixtures.md +11 -7
  163. package/docs/flows.md +26 -22
  164. package/docs/for-agents.md +71 -0
  165. package/docs/frameworks.mdx +199 -0
  166. package/docs/gate-plan.md +45 -41
  167. package/docs/gates.md +22 -18
  168. package/docs/getting-started.md +76 -65
  169. package/docs/human-control.md +22 -18
  170. package/docs/index.mdx +101 -0
  171. package/docs/install-agentic.mdx +215 -0
  172. package/docs/install-manual.mdx +194 -0
  173. package/docs/instrumentation.mdx +232 -0
  174. package/docs/integration-patterns.md +38 -34
  175. package/docs/local-registry.md +16 -12
  176. package/docs/multi-agent-testing.md +16 -12
  177. package/docs/packages/babel-plugin.mdx +70 -0
  178. package/docs/packages/browser.mdx +111 -0
  179. package/docs/packages/core.mdx +106 -0
  180. package/docs/packages/electron.mdx +87 -0
  181. package/docs/packages/eslint-plugin.mdx +84 -0
  182. package/docs/packages/next.mdx +91 -0
  183. package/docs/packages/react.mdx +91 -0
  184. package/docs/packages/server.mdx +98 -0
  185. package/docs/packages/tauri.mdx +63 -0
  186. package/docs/packages/test.mdx +108 -0
  187. package/docs/packages/vite-plugin.mdx +96 -0
  188. package/docs/packages.mdx +217 -0
  189. package/docs/platform-integration.md +33 -26
  190. package/docs/predicates.mdx +179 -0
  191. package/docs/quickstart.mdx +211 -0
  192. package/docs/recipes.mdx +242 -0
  193. package/docs/skill-file.mdx +105 -0
  194. package/docs/state-management.mdx +197 -0
  195. package/docs/style.css +14 -0
  196. package/docs/system-map.md +47 -32
  197. package/docs/telemetry-contract.md +93 -50
  198. package/docs/telemetry-events.mdx +430 -0
  199. package/docs/telemetry.md +57 -52
  200. package/docs/testing.md +10 -6
  201. package/docs/token-efficiency.md +30 -26
  202. package/docs/tools-act-and-wait.mdx +123 -0
  203. package/docs/tools-act-sequence.mdx +120 -0
  204. package/docs/tools-act.mdx +82 -0
  205. package/docs/tools-assert.mdx +70 -0
  206. package/docs/tools-console.mdx +59 -0
  207. package/docs/tools-inspect.mdx +85 -0
  208. package/docs/tools-navigate.mdx +63 -0
  209. package/docs/tools-network.mdx +86 -0
  210. package/docs/tools-observe.mdx +89 -0
  211. package/docs/tools-overview.mdx +157 -0
  212. package/docs/tools-query.mdx +82 -0
  213. package/docs/tools-session-and-feedback.mdx +91 -0
  214. package/docs/tools-sessions.mdx +65 -0
  215. package/docs/tools-snapshot.mdx +75 -0
  216. package/docs/tools-state.mdx +81 -0
  217. package/docs/tools-tools-and-run.mdx +125 -0
  218. package/docs/tools-wait-for.mdx +66 -0
  219. package/docs/troubleshooting.mdx +193 -0
  220. package/docs/usage.md +175 -153
  221. package/docs/vs-chrome-devtools-mcp.mdx +82 -0
  222. package/docs/vs-playwright-mcp.mdx +85 -0
  223. package/docs/vs-screenshots.mdx +96 -0
  224. package/docs/what-is-reticle.mdx +63 -0
  225. package/docs/why-reticle.mdx +107 -0
  226. package/package.json +2 -2
  227. package/docs/matrix/2.5.0/claude-code-machine.json +0 -19
  228. package/docs/matrix/2.5.0/codex-machine.json +0 -20
  229. package/docs/matrix/2.5.0/cursor-machine.json +0 -22
  230. package/docs/matrix/2.5.0/gemini-machine.json +0 -22
  231. package/docs/matrix/2.5.0/opencode-machine.json +0 -22
  232. package/docs/matrix/2.5.0/vscode-machine.json +0 -22
  233. package/docs/matrix/2.5.0/windsurf-machine.json +0 -22
  234. package/docs/matrix/MATRIX.md +0 -26
  235. package/docs/matrix/README.md +0 -59
  236. package/docs/mint.json +0 -61
@@ -0,0 +1,66 @@
1
+ import { SELF_RECOVERING_MARKER } from '../session/no-session-diagnosis.js';
2
+ /**
3
+ * The line `doctor` was missing: is anything actually CONNECTED?
4
+ *
5
+ * `doctor` collapses the first-run failure modes into one command, and it checked every one of them
6
+ * except the one the funnel actually stalls on. A user who has installed the SDK, wired the plugin
7
+ * and started the daemon runs `doctor`, gets node, chromium, daemon, bridge port and desktop wiring
8
+ * all ✓, and is no better off — because the thing that is wrong is that no page has ever dialled in,
9
+ * and nothing on that screen mentions pages.
10
+ *
11
+ * The daemon has always known. `/status` carries `sessionCount`, the session list, and `why` — the
12
+ * same no-session diagnosis an agent gets from an empty `reticle_sessions`, which is careful about
13
+ * what it can and cannot prove. `reticle status` prints it. `doctor` fetched the identical payload
14
+ * to read `version` and `contract` off it and dropped the rest on the floor.
15
+ *
16
+ * So this invents no new diagnosis. It surfaces one that already exists, on the command a stuck human
17
+ * runs, which is the difference between a checklist that ends in "everything is fine" and one that
18
+ * ends in a next action.
19
+ *
20
+ * Pure and payload-shaped rather than reaching for the bridge: `doctor` runs in a separate process
21
+ * from the daemon and can only ever know what `/status` told it.
22
+ */
23
+ /**
24
+ * The diagnosis is written for an AGENT, and `doctor` has a human in front of it.
25
+ *
26
+ * Every branch of it ends by telling the reader to call `reticle_sessions` again, which is right for
27
+ * the caller it was written for and useless to somebody in a shell: an MCP tool is not something you
28
+ * can type. Printing it unchanged would make `doctor` end on a recovery its reader cannot perform,
29
+ * which is the same defect as the aged-out lease message that told an agent to ask a human to reopen
30
+ * a tab that never existed.
31
+ *
32
+ * So the last sentence is swapped for the shell equivalent of itself. Only that sentence — the rest
33
+ * of the diagnosis is about the app and reads the same to either audience.
34
+ */
35
+ const HUMAN_RETRY = 'Then run `reticle doctor` again — it will appear within a second of the page loading.';
36
+ function forAHuman(why) {
37
+ // Imported, not re-typed: a copy of a sentence that has to match another file's byte-for-byte is a
38
+ // drift bug with a delay on it, and the failure is silent — the swap simply stops happening and
39
+ // doctor goes back to telling a human to call an MCP tool.
40
+ return why.replace(SELF_RECOVERING_MARKER, HUMAN_RETRY);
41
+ }
42
+ function countOf(facts) {
43
+ const raw = facts.sessionCount;
44
+ return 'number' === typeof raw && Number.isFinite(raw) && raw >= 0 ? raw : undefined;
45
+ }
46
+ export function sessionsLine(facts) {
47
+ const count = countOf(facts);
48
+ if (count === undefined) {
49
+ // An older daemon that does not report it. Saying "0 connected" here would be a claim about the
50
+ // app made from a payload that never mentioned the app — the exact kind of confident wrong
51
+ // sentence the no-session diagnosis exists to avoid.
52
+ return { text: ' sessions ? this daemon did not report connected pages (older build)' };
53
+ }
54
+ if (count > 0) {
55
+ const plural = 1 === count ? 'page' : 'pages';
56
+ return { text: ` sessions ✓ ${String(count)} ${plural} connected` };
57
+ }
58
+ const why = 'string' === typeof facts.why && facts.why.length > 0 ? forAHuman(facts.why) : undefined;
59
+ return {
60
+ // Deliberately ✗ rather than a neutral note. Zero connected pages is why somebody is running
61
+ // doctor, and a ✓-coloured checklist that hides the one failing thing is how the old output
62
+ // convinced people their setup was fine.
63
+ text: ' sessions ✗ no page has connected to this daemon',
64
+ ...(why === undefined ? {} : { why }),
65
+ };
66
+ }
@@ -17,6 +17,16 @@ export interface PortHolder {
17
17
  pid: number;
18
18
  command: string;
19
19
  }
20
+ /**
21
+ * Run a lookup and return its stdout, or null if it could not run.
22
+ *
23
+ * Null on ANY failure — no lsof (every Windows user, a slim container), a non-zero exit, a throw.
24
+ * This is a diagnostic nicety inside the commands people run when things are already broken; it must
25
+ * never be the reason one of them fails. Every caller handles the null by saying it could not
26
+ * identify the holder, rather than asserting the holder is not ours — a claim it has no evidence
27
+ * for, and one that was wrong for every Windows user.
28
+ */
29
+ export declare function captureLookup(command: string, args: readonly string[]): string | null;
20
30
  /**
21
31
  * The FIRST listener in `lsof -nP -iTCP:<port> -sTCP:LISTEN -F pc` output, or null.
22
32
  *
@@ -13,6 +13,27 @@
13
13
  * The parser is pure and the lookup takes an injected exec, so the rule is tested on every platform
14
14
  * while the shelling out stays at the call site.
15
15
  */
16
+ import { spawnSync } from 'node:child_process';
17
+ /** Bound on the holder lookup. A hung `lsof` must not hang the commands that diagnose hangs. */
18
+ const LOOKUP_TIMEOUT_MS = 2_000;
19
+ /**
20
+ * Run a lookup and return its stdout, or null if it could not run.
21
+ *
22
+ * Null on ANY failure — no lsof (every Windows user, a slim container), a non-zero exit, a throw.
23
+ * This is a diagnostic nicety inside the commands people run when things are already broken; it must
24
+ * never be the reason one of them fails. Every caller handles the null by saying it could not
25
+ * identify the holder, rather than asserting the holder is not ours — a claim it has no evidence
26
+ * for, and one that was wrong for every Windows user.
27
+ */
28
+ export function captureLookup(command, args) {
29
+ try {
30
+ const result = spawnSync(command, [...args], { encoding: 'utf8', timeout: LOOKUP_TIMEOUT_MS });
31
+ return 0 === result.status && 'string' === typeof result.stdout ? result.stdout : null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
16
37
  /** `lsof -F pc` emits one field per line, prefixed by its selector: `p<pid>`, `c<command>`. */
17
38
  const PID_FIELD = 'p';
18
39
  const COMMAND_FIELD = 'c';
@@ -59,8 +80,11 @@ export function describeForeignHolder(port, holder,
59
80
  * stranger, and the fix that works (`reticle stop`, it respawns on the next call) is never named.
60
81
  */
61
82
  ourPid) {
62
- const tail = 'a daemon cannot bind it. Stop that process, or run Reticle on a different port ' +
63
- '(`--port`), then retry.';
83
+ // Naming the command matters more than naming the process. "Stop that process" describes an
84
+ // outcome and leaves the reader to invent a route to it, and the route they invent is the `-ti`
85
+ // pipeline that takes their agent's proxy down with the holder.
86
+ const tail = 'a daemon cannot bind it. Free it with `reticle kill --force` (it signals the listener only, ' +
87
+ "never the agent's mcp proxy), or run Reticle on a different port (`--port`), then retry.";
64
88
  if (null === holder) {
65
89
  /**
66
90
  * We could not identify the holder — no `lsof` (every Windows user, and 35% of them by the
package/dist/cli.js CHANGED
@@ -22,10 +22,12 @@ import { installDaemonResilience, installProxyResilience, recordExitReason, Daem
22
22
  import { IdleShutdown, resolveIdleShutdownMs, resolveIdleCheckMs } from './daemon/idle-shutdown.js';
23
23
  import { DaemonHeartbeat, resolveHeartbeatMs } from './daemon/heartbeat.js';
24
24
  import { everServedToolCall } from './daemon/daemon-usefulness.js';
25
- import { fetchStatus, summarizeStatus, warnOnDaemonSkew, decideOpen, openInBrowser, } from './cli/cli-launch.js';
25
+ import { fetchStatus, summarizeStatus, warnOnDaemonSkew, decideOpen, drivePortConflict, openInBrowser, openCommand, } from './cli/cli-launch.js';
26
26
  import { handleVerify } from './cli/cli-verify.js';
27
+ import { runKill } from './cli/cli-kill.js';
27
28
  import { summarizeHunt } from './hunt/hunt-report.js';
28
29
  import { runInit } from './init/run.js';
30
+ import { confirmInstall, nodeConfirmDeps } from './init/confirm.js';
29
31
  import { handleDoctor } from './cli/cli-doctor.js';
30
32
  import { buildNodeIo } from './init/node-io.js';
31
33
  import { describeLicense } from './license/license.js';
@@ -38,6 +40,7 @@ import { reportCliRun } from './telemetry/cli-telemetry.js';
38
40
  export { parseCliArgs, CLI_USAGE };
39
41
  function handleInit(parsed) {
40
42
  const cwd = process.cwd();
43
+ const io = buildNodeIo(cwd);
41
44
  const result = runInit({
42
45
  cwd,
43
46
  port: parsed.port,
@@ -45,9 +48,14 @@ function handleInit(parsed) {
45
48
  dryRun: parsed.dryRun,
46
49
  install: parsed.install,
47
50
  ...(parsed.app === undefined ? {} : { app: parsed.app }),
48
- }, buildNodeIo(cwd));
49
- if (!result.ok)
50
- process.exit(1);
51
+ // The outcome is reported by confirmInstall instead, once it knows whether an app connected —
52
+ // `init` writing files was never the same thing as `init` working (#269).
53
+ deferOutcome: true,
54
+ }, io);
55
+ void confirmInstall(result, io, nodeConfirmDeps(parsed.port ?? RETICLE_DEFAULT_PORT)).then(() => {
56
+ if (!result.ok)
57
+ process.exit(1);
58
+ });
51
59
  }
52
60
  function handleServe(parsed) {
53
61
  void serveWithHonestExit(parsed);
@@ -190,6 +198,28 @@ function handleStop(port, quiet) {
190
198
  }
191
199
  }, 100);
192
200
  }
201
+ /**
202
+ * `reticle restart` — free the port, then put a daemon back on it and prove it bound.
203
+ *
204
+ * The two halves are reported separately on purpose. A restart that killed the old daemon and then
205
+ * could not start a new one leaves the user in a different place from one that could not kill
206
+ * anything at all, and a single `ok: false` cannot tell them which happened.
207
+ */
208
+ async function handleRestart(port, force) {
209
+ const freed = await runKill(port, force);
210
+ if (!freed) {
211
+ log('reticle_restart_aborted', {
212
+ port,
213
+ reason: 'the port was not freed, so nothing was started — the old holder is still there',
214
+ });
215
+ process.exit(1);
216
+ return;
217
+ }
218
+ // Reuses `serve`'s path, which waits for the daemon to ANSWER before claiming it started and exits
219
+ // non-zero when it does not. A restart that reports success for a daemon that never bound is the
220
+ // same lie `serve` already stopped telling.
221
+ await serveWithHonestExit({ port, headless: true, http: false });
222
+ }
193
223
  function handleStatus(port) {
194
224
  const pid = readPid(port);
195
225
  if (null === pid || !isAlive(pid)) {
@@ -364,8 +394,12 @@ function handleOpen(requestedPort, url) {
364
394
  log('reticle_open', {
365
395
  port,
366
396
  error: `could not launch a browser: ${launchError}`,
367
- recovery: 'Open the url yourself, or run `reticle doctor` — it checks the pieces this command ' +
368
- 'depends on.',
397
+ // Points at the OS handler, not at `reticle doctor`. Doctor's browser check is about
398
+ // Reticle's own Chromium, which this command never touches — the same misdirect the
399
+ // no-session note below spells out at length, left standing here for one more release.
400
+ recovery: 'Nothing was opened. This command asks the OS to open a url in your default browser ' +
401
+ `(\`${openCommand(decision.url, process.platform).cmd}\` on this platform) and that ` +
402
+ 'failed, so the fix is on the OS side: open the url yourself, or set a default browser.',
369
403
  });
370
404
  process.exit(1);
371
405
  return;
@@ -560,8 +594,13 @@ function handleMcp(opts) {
560
594
  daemonArgs.push(HTTP_TOKEN_FLAG, httpToken);
561
595
  }
562
596
  spawnDaemon(process.execPath, scriptPath, daemonArgs, port);
563
- log('reticle_mcp_daemon_started', { port, ...(driveUrl !== undefined ? { driveUrl } : {}) });
597
+ // Announce the daemon only once the PORT ACCEPTS. This line used to be written the instant the
598
+ // child was spawned, which on a Windows first bootstrap meant `reticle_mcp_daemon_started`
599
+ // followed about ten seconds later by `reticle_mcp_daemon_unavailable` and a first
600
+ // `reticle_sessions` that expired. A readiness signal that precedes readiness is worse than
601
+ // none: a client that believes it stops waiting for the thing that has not happened.
564
602
  await waitForDaemon(port);
603
+ log('reticle_mcp_daemon_started', { port, ...(driveUrl !== undefined ? { driveUrl } : {}) });
565
604
  };
566
605
  // Start the proxy WHATEVER happened to the daemon.
567
606
  //
@@ -602,20 +641,57 @@ function handleMcp(opts) {
602
641
  });
603
642
  }
604
643
  function handleLegacyDrive(parsed) {
644
+ void driveWithHonestConflict(parsed);
645
+ }
646
+ /**
647
+ * Refuse the port BEFORE binding it, and name what is holding it.
648
+ *
649
+ * `drive` went straight to `start`, which binds. The listen error surfaces asynchronously on the
650
+ * server object long after `start` has resolved, so the `.catch` on that promise could never see it
651
+ * and the process died with the raw `node:net` EADDRINUSE stack — in the one situation this command
652
+ * is most often run in, since `reticle_sessions` recommends `reticle drive` for a throttled tab and
653
+ * a throttled tab nearly always coexists with the daemon holding the port.
654
+ */
655
+ async function driveWithHonestConflict(parsed) {
656
+ const presence = await probePresence(parsed.port, { tcpOpen: probeDaemon, status: fetchStatus });
657
+ const conflict = drivePortConflict(presence, parsed.port, { ourPid: readPid(parsed.port) });
658
+ if (conflict !== undefined) {
659
+ log('reticle_drive_port_conflict', { port: parsed.port, presence, reason: conflict });
660
+ process.stderr.write(`${conflict}\n`);
661
+ process.exit(1);
662
+ return;
663
+ }
664
+ // The probe cannot close the race: something can take the port between here and the bind, and the
665
+ // bind reports it on the server, outside every promise this function holds. Catching it at the
666
+ // process is the only place that sees it at all — and only EADDRINUSE, so a genuine crash still
667
+ // crashes rather than being dressed up as a port conflict. Who won the race is not knowable from
668
+ // in here, so the sentence used is the one that is true of any holder.
669
+ process.on('uncaughtException', (error) => {
670
+ const code = error?.code;
671
+ if ('EADDRINUSE' !== code)
672
+ throw error;
673
+ const reason = drivePortConflict(PortPresence.FOREIGN, parsed.port);
674
+ log('reticle_drive_port_conflict', {
675
+ port: parsed.port,
676
+ presence: PortPresence.FOREIGN,
677
+ reason,
678
+ });
679
+ process.stderr.write(`${String(reason)}\n`);
680
+ process.exit(1);
681
+ });
605
682
  const options = {
606
683
  port: parsed.port,
607
684
  driveUrl: parsed.driveUrl,
608
685
  headless: parsed.headless,
609
686
  };
610
- start(options)
611
- .then(() => {
687
+ try {
688
+ await start(options);
612
689
  log('reticle_started', { port: parsed.port });
613
- })
614
- .catch((error) => {
615
- const message = error instanceof Error ? error.message : String(error);
616
- log('reticle_start_failed', { error: message });
690
+ }
691
+ catch (error) {
692
+ log('reticle_start_failed', { error: error instanceof Error ? error.message : String(error) });
617
693
  process.exit(1);
618
- });
694
+ }
619
695
  }
620
696
  function main() {
621
697
  // Before anything reads process.env — notably the telemetry gate and the bridge's security
@@ -669,6 +745,15 @@ function main() {
669
745
  case 'stop':
670
746
  handleStop(parsed.port, parsed.quiet);
671
747
  break;
748
+ case 'kill':
749
+ void runKill(parsed.port, parsed.force).then((freed) => {
750
+ if (!freed)
751
+ process.exit(1);
752
+ });
753
+ break;
754
+ case 'restart':
755
+ void handleRestart(parsed.port, parsed.force);
756
+ break;
672
757
  case 'status':
673
758
  handleStatus(parsed.port);
674
759
  break;
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import { ReticleTool } from '../tools/tool-names.js';
3
3
  import { asNumber, asString } from '../tools/tools-helpers.js';
4
4
  import { crawl } from './crawl.js';
5
+ import { routeFromUrl, routesFromEvents } from '../project/learned-routes.js';
5
6
  const nodeSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
6
7
  /**
7
8
  * The autonomous "smart monkey" tool. Builds on reticle_explore (which only LISTS) by
@@ -58,8 +59,10 @@ export const CRAWL_TOOLS = [
58
59
  .describe('Present only when the page exceeded one snapshot, so controls past the cap were never listed. When present, a zero-anomaly result does NOT mean the app is clean.'),
59
60
  truncated: z.boolean(),
60
61
  },
61
- handler: (deps, args) => {
62
+ handler: async (deps, args) => {
62
63
  const session = deps.sessions.resolve(asString(args['sessionId']));
64
+ const since = session.elapsed();
65
+ const initialRoute = routeFromUrl(session.url);
63
66
  const maxSteps = asNumber(args['maxSteps']);
64
67
  const settleMs = asNumber(args['settleMs']);
65
68
  const scope = asString(args['scope']);
@@ -69,7 +72,14 @@ export const CRAWL_TOOLS = [
69
72
  ...(scope !== undefined ? { scope } : {}),
70
73
  ...(true === args['confirmDangerous'] ? { confirmDangerous: true } : {}),
71
74
  };
72
- return crawl(session, opts, nodeSleep);
75
+ const report = await crawl(session, opts, nodeSleep);
76
+ const routes = [
77
+ ...(initialRoute === undefined ? [] : [initialRoute]),
78
+ ...routesFromEvents(session.eventsSince(since)),
79
+ ];
80
+ if (routes.length > 0)
81
+ await deps.project.recordRoutes(routes);
82
+ return report;
73
83
  },
74
84
  },
75
85
  ];
@@ -39,6 +39,19 @@ export interface CrawlAnomaly {
39
39
  }
40
40
  /** Said out loud when the page was larger than one snapshot, so a clean sweep cannot mean "all clear". */
41
41
  export declare const CAPPED_SNAPSHOT_NOTE = "the page exceeded one snapshot, so controls past the cap were never seen \u2014 interactiveFound is a floor, not a total; re-run with a narrower `scope` to cover the rest";
42
+ /**
43
+ * Said out loud when the crawl's OWN clicks put controls on the page that it then never visited.
44
+ *
45
+ * The enumeration happens once, before the first click, so a control that only exists after a login,
46
+ * a modal or a tab switch is invisible to this run. Measured on the bench app: a crawl of the login
47
+ * screen visited three controls, its click on "Sign in" produced seven more, and it returned
48
+ * `truncated: false` with an empty anomaly list and nine of twelve steps unused. Read as a sweep,
49
+ * that is a clean bill of health for an app the crawl never entered.
50
+ *
51
+ * The same words as the capped-snapshot case on purpose — it is the same idea (interactiveFound is a
52
+ * floor) reached by a different route, and a second vocabulary for it would just be more to learn.
53
+ */
54
+ export declare function revealedControlsNote(count: number): string;
42
55
  export interface CrawlReport {
43
56
  interactiveFound: number;
44
57
  stepsRun: number;
@@ -6,6 +6,23 @@ import { ReticleTool } from '../tools/tool-names.js';
6
6
  import { findContradictions } from '../events/contradictions.js';
7
7
  /** Said out loud when the page was larger than one snapshot, so a clean sweep cannot mean "all clear". */
8
8
  export const CAPPED_SNAPSHOT_NOTE = 'the page exceeded one snapshot, so controls past the cap were never seen — interactiveFound is a floor, not a total; re-run with a narrower `scope` to cover the rest';
9
+ /**
10
+ * Said out loud when the crawl's OWN clicks put controls on the page that it then never visited.
11
+ *
12
+ * The enumeration happens once, before the first click, so a control that only exists after a login,
13
+ * a modal or a tab switch is invisible to this run. Measured on the bench app: a crawl of the login
14
+ * screen visited three controls, its click on "Sign in" produced seven more, and it returned
15
+ * `truncated: false` with an empty anomaly list and nine of twelve steps unused. Read as a sweep,
16
+ * that is a clean bill of health for an app the crawl never entered.
17
+ *
18
+ * The same words as the capped-snapshot case on purpose — it is the same idea (interactiveFound is a
19
+ * floor) reached by a different route, and a second vocabulary for it would just be more to learn.
20
+ */
21
+ export function revealedControlsNote(count) {
22
+ return (`the page changed as you clicked: ${String(count)} control(s) that did not exist when this crawl ` +
23
+ 'started were never visited — interactiveFound is a floor, not a total; re-run the crawl from ' +
24
+ 'the state you are now in to cover them');
25
+ }
9
26
  /** Any buffer event that proves the app reacted to a click (vs a dead/no-op control). */
10
27
  function isActivity(e) {
11
28
  return (e.type === EventType.NET_REQUEST ||
@@ -50,6 +67,25 @@ export function legitimatelyInert(desc) {
50
67
  return true;
51
68
  return /\b(textbox|searchbox|combobox|spinbutton)\b/.test(desc);
52
69
  }
70
+ /**
71
+ * How many interactive controls exist now that did not exist when the crawl enumerated.
72
+ *
73
+ * Keyed on ref, which is the element's identity: a control that re-rendered keeps its ref and is not
74
+ * new, and one that vanished simply does not appear. Returns 0 rather than throwing if the snapshot
75
+ * fails — this is a disclosure, and failing the whole crawl to report on coverage would be worse
76
+ * than the silence it is fixing.
77
+ */
78
+ async function countRevealed(session, opts, before) {
79
+ const snap = await session.command(ReticleCommand.SNAPSHOT, {
80
+ mode: 'interactive',
81
+ ...(opts.scope !== undefined ? { scope: opts.scope } : {}),
82
+ });
83
+ if (!snap.ok)
84
+ return 0;
85
+ const tree = (snap.result ?? {}).tree ?? '';
86
+ const known = new Set(before.map((i) => (i.ref !== '' ? i.ref : i.desc)));
87
+ return parseInteractive(tree).filter((i) => !known.has(i.ref !== '' ? i.ref : i.desc)).length;
88
+ }
53
89
  export async function crawl(session, opts, sleep) {
54
90
  const maxSteps = opts.maxSteps ?? CRAWL_DEFAULTS.MAX_STEPS;
55
91
  const settleMs = opts.settleMs ?? CRAWL_DEFAULTS.SETTLE_MS;
@@ -168,6 +204,14 @@ export async function crawl(session, opts, sleep) {
168
204
  });
169
205
  }
170
206
  }
207
+ // One more look, AFTER the clicks. The enumeration above happened before any of them, so anything
208
+ // the crawl itself unlocked — a dashboard behind a login, a modal's contents, a tab's panel — was
209
+ // never in `items` and never had a chance to be visited. Counted by ref against what was
210
+ // enumerated, so a control that merely moved or re-rendered under the same ref is not miscounted
211
+ // as new, and a surface that SHRANK (a modal closed) reports nothing, because nothing went
212
+ // unvisited. Skipped when the snapshot was already capped: that case has its own note and a second
213
+ // one would only muddy it.
214
+ const revealed = coverageCapped ? 0 : await countRevealed(session, opts, items);
171
215
  return {
172
216
  interactiveFound: items.length,
173
217
  stepsRun,
@@ -177,8 +221,12 @@ export async function crawl(session, opts, sleep) {
177
221
  // True when coverage was bounded for EITHER reason: the step budget ran out, or the page was
178
222
  // bigger than one snapshot. Conflating "I stopped early" with "I saw everything" is what turns a
179
223
  // partial sweep into "all controls healthy".
180
- truncated: items.length > stepsRun || coverageCapped,
181
- ...(coverageCapped ? { coverageNote: CAPPED_SNAPSHOT_NOTE } : {}),
224
+ truncated: items.length > stepsRun || coverageCapped || revealed > 0,
225
+ ...(coverageCapped
226
+ ? { coverageNote: CAPPED_SNAPSHOT_NOTE }
227
+ : revealed > 0
228
+ ? { coverageNote: revealedControlsNote(revealed) }
229
+ : {}),
182
230
  // A bare `stepsRun: 0` made "the page had no controls" and "it had 34 and none were clicked" the
183
231
  // same answer. See crawl-empty.
184
232
  ...(() => {
@@ -35,6 +35,20 @@ export interface ContradictionOptions {
35
35
  * contain. See `findUnitMismatches` for the measured case this exists for.
36
36
  */
37
37
  prior?: readonly ReticleEvent[] | undefined;
38
+ /**
39
+ * Event time at which the action that opened this window was dispatched — the attribution floor.
40
+ *
41
+ * `duplicate-request` claims "one user action was performed", and that claim is only sound over ONE
42
+ * action's window. `reticle_observe` takes a caller-supplied window that can be arbitrarily wide, so
43
+ * two legitimate separate saves to the same endpoint read as a double submit — and the finding then
44
+ * sat on every verdict for that tab. Undefined means nothing attributed the window to an action, and
45
+ * the rule stays silent rather than accusing an app of something nobody can show it did.
46
+ *
47
+ * ponytail: a NET_REQUEST is stamped when it COMPLETED, so a write dispatched before the action and
48
+ * landing after it counts as inside. Keying on the matching NET_PENDING would fix that; it has not
49
+ * been worth the second index.
50
+ */
51
+ actionSince?: number | undefined;
38
52
  /**
39
53
  * DOM mutations observed INSIDE the target's own subtree, as the act tool measured them.
40
54
  *
@@ -281,23 +281,32 @@ export function findContradictions(allEvents, options = {}) {
281
281
  });
282
282
  }
283
283
  }
284
- // ── The same write fired more than once ─────────────────────────────────────────────────────
285
- const writeCounts = new Map();
286
- for (const call of settled) {
287
- if (!isMutating(call))
288
- continue;
289
- const key = `${call.method} ${call.url}`;
290
- writeCounts.set(key, (writeCounts.get(key) ?? 0) + 1);
291
- }
292
- for (const [key, count] of writeCounts) {
293
- if (count < 2)
294
- continue;
295
- found.push({
296
- kind: ContradictionKind.DUPLICATE_REQUEST,
297
- claim: 'one user action was performed',
298
- counter: `the same write fired ${String(count)} times`,
299
- detail: `${key} ×${String(count)}`,
300
- });
284
+ // ── The same write fired more than once, inside ONE action's window ─────────────────────────
285
+ // Attribution is the whole rule here, not a refinement of it: counting `method + url` over whatever
286
+ // window the caller handed in turns two legitimate separate saves into a double submit. See
287
+ // ContradictionOptions.actionSince.
288
+ const actionSince = options.actionSince;
289
+ if (actionSince !== undefined) {
290
+ const writeCounts = new Map();
291
+ for (const event of events) {
292
+ if (event.type !== EventType.NET_REQUEST || event.t < actionSince)
293
+ continue;
294
+ const call = netCall(event);
295
+ if (!isMutating(call))
296
+ continue;
297
+ const key = `${call.method} ${call.url}`;
298
+ writeCounts.set(key, (writeCounts.get(key) ?? 0) + 1);
299
+ }
300
+ for (const [key, count] of writeCounts) {
301
+ if (count < 2)
302
+ continue;
303
+ found.push({
304
+ kind: ContradictionKind.DUPLICATE_REQUEST,
305
+ claim: 'one user action was performed',
306
+ counter: `the same write fired ${String(count)} times`,
307
+ detail: `${key} ×${String(count)}`,
308
+ });
309
+ }
301
310
  }
302
311
  // ── The UI advanced over a request that never came back ─────────────────────────────────────
303
312
  // Gated on the UI having moved: an in-flight request while the app is still visibly waiting is
@@ -1,4 +1,4 @@
1
- import { ElementState, PredicateKind, type ElementQuery, type ReticleEvent } from '@reticlehq/core';
1
+ import { ElementState, PredicateKind, type ElementDescriptor, type ElementQuery, type ReticleEvent } from '@reticlehq/core';
2
2
  import { z } from 'zod';
3
3
  export type Predicate = {
4
4
  kind: typeof PredicateKind.ELEMENT;
@@ -58,6 +58,25 @@ export type Predicate = {
58
58
  kind: typeof PredicateKind.NOT;
59
59
  predicate: Predicate;
60
60
  };
61
+ export interface ResidualQueryChecks {
62
+ /** Dropped fields this side CAN check, as [field, wanted value] pairs. */
63
+ checks: [string, string][];
64
+ /** Dropped fields with no descriptor to check them against — refuse rather than ignore. */
65
+ unusable: string[];
66
+ }
67
+ /**
68
+ * Split a query's dropped fields into the ones the server can still enforce and the ones it cannot.
69
+ *
70
+ * The alternative — refuse every dropped field — breaks calls that work today and that our own
71
+ * cheatsheet advertises (`{ role: "button", text: "Save" }`), and breaks them into no verdict at all.
72
+ * Enforcing what we can and refusing only the rest keeps those calls working AS WRITTEN, which is the
73
+ * outcome the caller was already assuming.
74
+ */
75
+ export declare function residualQueryChecks(query: ElementQuery): ResidualQueryChecks;
76
+ /** Does this element satisfy every field the locator dropped? */
77
+ export declare function satisfiesResiduals(element: ElementDescriptor, checks: readonly [string, string][]): boolean;
78
+ /** How the element's own reading of a dropped field should be REPORTED back on a failure. */
79
+ export declare function describeResidual(element: ElementDescriptor, field: string): string;
61
80
  export declare const PredicateSchema: z.ZodType<Predicate>;
62
81
  /**
63
82
  * The fields a given predicate kind accepts, read off the schema itself.