@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
@@ -1,11 +1,11 @@
1
1
  import { healthEnvelope } from '../session/session-health.js';
2
- import { TelemetryActor, TelemetryEventKind, TRANSPORT_LIMITS, } from '@reticlehq/core';
2
+ import { TelemetryActor, TelemetryEventKind, TRANSPORT_LIMITS, fingerprintFinding, } from '@reticlehq/core';
3
3
  import { getSessionMetrics } from '../telemetry/session-metrics.js';
4
4
  import { getTelemetry } from '../telemetry/telemetry.js';
5
5
  import { takeUpdateNudge } from '../update/update-nudge.js';
6
6
  import { takeVersionSkew } from '../version/version-nudge.js';
7
7
  import { noteToolCall } from '../daemon/daemon-usefulness.js';
8
- import { bugsInResult } from '../telemetry/bug-found.js';
8
+ import { bugsInResult, routeOf } from '../telemetry/bug-found.js';
9
9
  import { noteToolServed, reportToolRefused } from '../telemetry/tool-refused.js';
10
10
  import { buildErrorPayload, refusalReasonFor } from './error-recovery.js';
11
11
  import { resultIsError } from '../mcp/mcp-is-error.js';
@@ -17,6 +17,7 @@ import { takeFeedbackPrompt } from './feedback-tools.js';
17
17
  import { takeFeedbackUndelivered } from '../telemetry/feedback-delivery.js';
18
18
  import { noteRefsMinted, wrongTabRefusal } from '../session/ref-provenance.js';
19
19
  import { span } from '../trace.js';
20
+ import { deltaForToolResult, impactSnapshot, initImpact, recordImpact, } from '../impact/impact-recorder.js';
20
21
  import { frictionOf, inviteFor } from './feedback-invite.js';
21
22
  /**
22
23
  * The live-session tools whose result MUST carry the
@@ -141,14 +142,19 @@ function reportBugsFound(toolName, result) {
141
142
  if (0 === bugs.length)
142
143
  return;
143
144
  const metrics = getSessionMetrics();
145
+ const route = routeOf(result);
144
146
  for (const bug of bugs) {
145
147
  // `recordBug` answers whether this kind is new to the session, which is the only thing that
146
148
  // separates "another defect" from "the same defect again". Counting instances as defects is how
147
149
  // a published number inflates itself.
148
150
  const first = metrics.recordBug(bug.kind);
151
+ const identity = route !== undefined
152
+ ? { kind: bug.kind, source: bug.source, route }
153
+ : { kind: bug.kind, source: bug.source };
154
+ const fingerprint = fingerprintFinding(identity);
149
155
  void getTelemetry().emit(TelemetryEventKind.BUG_FOUND, {
150
156
  actor: TelemetryActor.AGENT,
151
- bug: { ...bug, repeat: !first },
157
+ bug: { ...bug, repeat: !first, fingerprint },
152
158
  });
153
159
  }
154
160
  }
@@ -239,6 +245,9 @@ export async function runTool(tool, deps, args) {
239
245
  // closure carries this call's own identity, which is also what makes peak-concurrency measurable.
240
246
  // A daemon that has served even one tool call is doing a job for somebody; see daemon-usefulness.
241
247
  noteToolCall();
248
+ // The impact record needs the project's own `.reticle` root, and this is the first place every
249
+ // call knows it. Idempotent: the first root wins for the daemon's lifetime.
250
+ initImpact({ reticleRoot: deps.reticleRoot });
242
251
  // An oversized argument is refused BEFORE the handler deserialises it.
243
252
  //
244
253
  // tool-fuzz failed CI on the invariant that matters most — `every tool answers every hostile call`
@@ -352,6 +361,14 @@ export async function runTool(tool, deps, args) {
352
361
  // throw path would have measured half the wall and called it the whole of it, the same way `isError`
353
362
  // once did. A call that was served clears the retry chain, so the next refusal after it is a first
354
363
  // refusal rather than a retry of something unrelated.
364
+ // The user's own record of what Reticle did for them. Same chokepoint as everything else that
365
+ // counts, for the same reason: a second recording site is a second thing to forget. Separate
366
+ // store from telemetry - this one never leaves the machine.
367
+ recordImpact(deltaForToolResult(raw, Date.now() - startedAt, resultIsError(raw)));
368
+ // ...and the tab being driven is told, so the report is live rather than a thing you reload.
369
+ // Optional-call, not optional-chain-on-the-object: a test double is a partial Session, and a
370
+ // courtesy push must never be the reason a tool call throws.
371
+ session?.pushImpact?.(impactSnapshot);
355
372
  if (resultIsError(raw))
356
373
  reportRefusal(tool.name, raw.error);
357
374
  else {
@@ -5,13 +5,35 @@
5
5
  * — it does not wait for the human's tab. Measured over a day: lease sessions had a median of 30
6
6
  * tool calls and produced 46% of all bugs found, against a median of 1 call for everything else.
7
7
  *
8
- * When it fails it is almost always a PORT MISMATCH: the tab loads, and the app's SDK dials the port
9
- * it was BUILT with, which need not be the daemon that issued the lease. Verified by driving it —
10
- * against a matching daemon `acquire` returns ready:true and the session is driveable immediately;
11
- * against a different one the tab connects to the OTHER daemon instead.
12
- *
13
8
  * The old hint asked "is <url> running with @reticlehq/core enabled?", which is the one thing that
14
- * cannot be in doubt: the pool just navigated a real browser to it. Naming the true cause first is
15
- * the difference between a two-minute fix and abandoning the best path Reticle has.
9
+ * cannot be in doubt: the pool just navigated a real browser to it. Its replacement then swung to
10
+ * the other confident-but-unevidenced claim — "the usual cause is a PORT MISMATCH" — and the daemon
11
+ * ended up contradicting ITSELF inside one response, saying in `reticle_sessions` that a session had
12
+ * connected earlier "so the wiring is correct" and here that the port was probably wrong. Across a
13
+ * batch of field reports on four apps the port was correct every single time, and `reticle init`
14
+ * would have been the wrong action every single time.
15
+ *
16
+ * So: rank by what the daemon actually knows, and fall back to the differential only when it knows
17
+ * nothing. A cause we hold positive evidence against is not printed at all.
16
18
  */
17
- export declare function leaseNotConnectedHint(url: string, port: number): string;
19
+ /** What the daemon can say for certain at the moment a lease comes back unconnected. */
20
+ export interface LeaseEvidence {
21
+ /** A dial WAS made and turned away, with the reason. The only certain cause there is. */
22
+ refusal?: string;
23
+ /** An app for this project has connected on this port before (durable, survives a restart). */
24
+ previouslyConnected?: boolean;
25
+ /** The project has been through `init` — so recommending it again cannot help. */
26
+ initialized?: boolean;
27
+ /** The framework `.reticle.json` declares, used to rank rather than to decorate. */
28
+ framework?: string;
29
+ /**
30
+ * Whether any Reticle marker was found in the page the lease loaded.
31
+ *
32
+ * The single bit that separates the two commonest cases — "this app ships no SDK" and "the SDK
33
+ * loaded but could not reach the daemon" — which today produce identical messages. Absent means
34
+ * NOT CHECKED, and an absent bit prints nothing: a marker check that could not run must not be
35
+ * reported as a marker that was not found.
36
+ */
37
+ sdkMarker?: boolean;
38
+ }
39
+ export declare function leaseNotConnectedHint(url: string, port: number, evidence?: LeaseEvidence): string;
@@ -5,20 +5,71 @@
5
5
  * — it does not wait for the human's tab. Measured over a day: lease sessions had a median of 30
6
6
  * tool calls and produced 46% of all bugs found, against a median of 1 call for everything else.
7
7
  *
8
- * When it fails it is almost always a PORT MISMATCH: the tab loads, and the app's SDK dials the port
9
- * it was BUILT with, which need not be the daemon that issued the lease. Verified by driving it —
10
- * against a matching daemon `acquire` returns ready:true and the session is driveable immediately;
11
- * against a different one the tab connects to the OTHER daemon instead.
12
- *
13
8
  * The old hint asked "is <url> running with @reticlehq/core enabled?", which is the one thing that
14
- * cannot be in doubt: the pool just navigated a real browser to it. Naming the true cause first is
15
- * the difference between a two-minute fix and abandoning the best path Reticle has.
9
+ * cannot be in doubt: the pool just navigated a real browser to it. Its replacement then swung to
10
+ * the other confident-but-unevidenced claim — "the usual cause is a PORT MISMATCH" — and the daemon
11
+ * ended up contradicting ITSELF inside one response, saying in `reticle_sessions` that a session had
12
+ * connected earlier "so the wiring is correct" and here that the port was probably wrong. Across a
13
+ * batch of field reports on four apps the port was correct every single time, and `reticle init`
14
+ * would have been the wrong action every single time.
15
+ *
16
+ * So: rank by what the daemon actually knows, and fall back to the differential only when it knows
17
+ * nothing. A cause we hold positive evidence against is not printed at all.
16
18
  */
17
- export function leaseNotConnectedHint(url, port) {
18
- return (`the leased tab loaded ${url} but never dialled this daemon (port ${String(port)}). The usual ` +
19
- `cause is a PORT MISMATCH: the app's SDK connects to the port it was BUILT with, so a daemon on ` +
20
- `a different port never hears from it — check the app's reticle port (\`.reticle.json\`, ` +
21
- `RETICLE_PORT, or the build plugin's \`port\`) matches ${String(port)}, or start the daemon on ` +
22
- `the port the app expects. If the app carries no Reticle SDK at all, run \`reticle init\` in it ` +
23
- `first. The tab stays leased either way — release it with reticle_lease{action:"release"}.`);
19
+ const NUXT = 'nuxt';
20
+ const PORT_DIFFERENTIAL = "The usual cause is a PORT MISMATCH: the app's SDK connects to the port it was BUILT with, so a " +
21
+ "daemon on a different port never hears from it — check the app's reticle port (`.reticle.json`, " +
22
+ "RETICLE_PORT, or the build plugin's `port`), or start the daemon on the port the app expects.";
23
+ /**
24
+ * The causes that actually occur on a wired app, in the order they occur. Mirrors the no-session
25
+ * diagnosis deliberately: two surfaces answering the same question must not rank it differently.
26
+ */
27
+ const REAL_CAUSES = 'Causes that produce exactly this and are NOT a port problem: (a) the SDK is in the bundle but ' +
28
+ '`connect()` is never reached — a dev-mode guard or a missing runtime-config value returning ' +
29
+ 'early, which emits nothing at all; (b) the dev server was started BEFORE the Reticle plugin was ' +
30
+ 'added, so it is not in the bundle — restart it, HMR is not enough; (c) a peer dependency is ' +
31
+ 'missing (typically `@reticlehq/react` in a non-React app) so the dynamic import fails silently; ' +
32
+ '(d) the page is not on localhost, where the SDK needs BOTH `allowNonLocalhost: true` and a ' +
33
+ 'pairing token — the flag alone is not sufficient.';
34
+ const NUXT_FIRST = 'This project is Nuxt: the most likely cause by a distance is a dev server that predates the ' +
35
+ 'Reticle plugin, because Nuxt does NOT register a newly added plugin on HMR. Restart `nuxt dev` ' +
36
+ 'and re-acquire before investigating anything else.';
37
+ const RELEASE = 'The tab stays leased either way — release it with reticle_lease{action:"release"}.';
38
+ function markerClause(sdkMarker) {
39
+ if (sdkMarker === undefined)
40
+ return '';
41
+ return sdkMarker
42
+ ? ' A Reticle SDK marker WAS found in the page that loaded, so the app does ship the SDK — the ' +
43
+ 'question is only why it did not reach this daemon.'
44
+ : ' No Reticle SDK marker was found in the page that loaded. That is a check of the SERVED ' +
45
+ 'document, so a lazily-imported SDK can still be missed — but combined with the rest it ' +
46
+ 'points at a bundle that carries no Reticle at all, i.e. wiring that never took effect.';
47
+ }
48
+ export function leaseNotConnectedHint(url, port, evidence = {}) {
49
+ const opening = `the leased tab loaded ${url} but never dialled this daemon (port ${String(port)}).`;
50
+ const marker = markerClause(evidence.sdkMarker);
51
+ // 1. A recorded refusal. The only cause with proof behind it, so nothing outranks it.
52
+ if (evidence.refusal !== undefined) {
53
+ return `${opening} A dial DID arrive and was refused: ${evidence.refusal}${marker} ${RELEASE}`;
54
+ }
55
+ // 2. Nuxt, whose most likely cause differs from every other framework's and which `init` warns
56
+ // about at install time — while this hint never mentioned it.
57
+ const nuxt = evidence.framework?.toLowerCase() === NUXT ? ` ${NUXT_FIRST}` : '';
58
+ // 3. Proven wiring. Blaming the port here is the self-contradiction this rewrite exists to end.
59
+ if (true === evidence.previouslyConnected) {
60
+ return (`${opening} An app for this project HAS connected on this port before, so the port and the ` +
61
+ `wiring are both correct — what is failing is the SDK reaching initialise on the page.${nuxt}` +
62
+ `${marker} ${REAL_CAUSES} ${RELEASE}`);
63
+ }
64
+ // 4. Wired, but never seen connect. Rank the real causes; keep the port differential last, where
65
+ // the evidence for it actually sits.
66
+ if (true === evidence.initialized) {
67
+ return `${opening}${nuxt}${marker} ${REAL_CAUSES} If none of those, it may be dialling a different daemon than this one: check the app's reticle port matches ${String(port)}. ${RELEASE}`;
68
+ }
69
+ // 5. Nothing known. The differential, plus the possibility this app carries no SDK at all —
70
+ // except when the marker check already ruled that out, which is the whole reason for the bit.
71
+ const noSdk = true === evidence.sdkMarker
72
+ ? ''
73
+ : ' If the app carries no Reticle SDK at all, run `reticle init` in it first.';
74
+ return `${opening}${nuxt}${marker} ${PORT_DIFFERENTIAL}${noSdk} ${REAL_CAUSES} ${RELEASE}`;
24
75
  }
@@ -71,4 +71,15 @@ export declare function acquireLeasedSession(pool: {
71
71
  release: () => Promise<void>;
72
72
  }>;
73
73
  export declare function waitForLeasedSession(isConnected: () => boolean, sleeper?: (ms: number) => Promise<void>, attempts?: number, pollMs?: number): Promise<boolean>;
74
+ /**
75
+ * Named on its own because `reticle drive <url>` runs it too.
76
+ *
77
+ * When a daemon already owns the bridge port, `drive` asks that daemon for a browser instead of
78
+ * competing for the port (see cli/drive-attach.ts), and the browser it should get is exactly the one
79
+ * an agent gets: same pool, same isolation, same reporting. Sharing the ToolDef rather than the
80
+ * handler keeps that literal — the drive route dispatches through `runTool`, so it is counted like
81
+ * any other call instead of being a second, invisible dispatch path.
82
+ */
83
+ export declare const LEASE_ACQUIRE_TOOL: ToolDef;
84
+ export declare const LEASE_RELEASE_TOOL: ToolDef;
74
85
  export declare const LEASE_TOOLS: ToolDef[];
@@ -10,9 +10,32 @@
10
10
  */
11
11
  import { z } from 'zod';
12
12
  import { leaseNotConnectedHint } from './lease-hint.js';
13
+ import { readProjectFramework, readProjectId } from '../cli/cli-port.js';
14
+ import { hasConnectedBefore } from '../session/connection-memory.js';
15
+ import { reticleStateHome } from '../daemon/daemon.js';
13
16
  import { RETICLE_URL_PARAM, RETICLE_DEFAULT_PORT } from '@reticlehq/core';
14
17
  import { ReticleTool } from './tool-names.js';
15
18
  import { asString } from './tools-helpers.js';
19
+ import { chromiumHint } from '../cli/chromium-hint.js';
20
+ /**
21
+ * Everything the daemon already knows about why a leased tab might not have dialled in.
22
+ *
23
+ * Gathered here rather than guessed in the hint, because the daemon HAD this evidence all along and
24
+ * printed a static differential over the top of it — telling one agent in `reticle_sessions` that
25
+ * the wiring was proven correct and telling it seconds later that the port was probably wrong.
26
+ */
27
+ function leaseEvidence(deps, port) {
28
+ const cwd = process.cwd();
29
+ const projectId = readProjectId(cwd);
30
+ const refusal = deps.sessions.lastClosure()?.reason;
31
+ const framework = readProjectFramework(cwd);
32
+ return {
33
+ ...(refusal === undefined ? {} : { refusal }),
34
+ ...(framework === undefined ? {} : { framework }),
35
+ previouslyConnected: hasConnectedBefore(reticleStateHome(), port, projectId),
36
+ initialized: projectId !== undefined,
37
+ };
38
+ }
16
39
  const POOL_UNAVAILABLE = 'browser pool unavailable — the lease tools need the daemon-managed pool (start Reticle via `reticle mcp`).';
17
40
  /**
18
41
  * Append Reticle identity params (__reticle_session, optional __reticle_project) to a URL so the app's own SDK
@@ -129,102 +152,124 @@ export async function waitForLeasedSession(isConnected, sleeper = sleep, attempt
129
152
  }
130
153
  return isConnected();
131
154
  }
132
- export const LEASE_TOOLS = [
133
- {
134
- name: ReticleTool.LEASE_ACQUIRE,
135
- description: 'Lease a fresh isolated headless browser context from the shared pool and navigate it to the app URL (the app must already be running and embed @reticlehq/core). Returns the sessionId the leased tab registers — pass it to other tools. The pool keeps all leases in ONE browser and caps concurrency; if at capacity this waits for a free slot. Release with reticle_lease{action:"release"} when the flow is done.',
136
- inputSchema: {
137
- url: z
138
- .string()
139
- .describe('URL of the already-running app to drive (e.g. http://localhost:3000/dashboard).'),
140
- projectId: z
141
- .string()
142
- .optional()
143
- .describe('Stable project id to stamp on the leased tab so the agent can scope to it.'),
144
- },
145
- outputSchema: {
146
- sessionId: z.string(),
147
- url: z.string(),
148
- ready: z
149
- .boolean()
150
- .describe('Whether the leased tab connected — false ⇒ the app may not embed @reticlehq/core.'),
151
- expiresInMs: z
152
- .number()
153
- .describe('Milliseconds until this lease expires if untouched. Each tool call that targets this session resets the clock. Plan your work to finish or re-acquire before this runs out.'),
154
- leased: z.number().describe('How many contexts are currently leased from the pool.'),
155
- queued: z.number().describe('How many acquires are waiting for a free slot.'),
156
- hint: z.string().optional(),
157
- },
158
- handler: async (deps, args) => {
159
- const pool = deps.pool;
160
- if (pool === undefined)
161
- throw new Error(POOL_UNAVAILABLE);
162
- const url = asString(args['url']);
163
- if (url === undefined || 0 === url.length)
164
- throw new Error('reticle_lease{action:"acquire"} requires a url');
165
- const projectId = asString(args['projectId']);
166
- const sessionId = newLeaseId();
167
- const navUrl = appendReticleParams(url, sessionId, projectId);
168
- let lease;
169
- try {
170
- lease = await pool.acquire(navUrl, { sessionId });
171
- }
172
- catch (err) {
173
- // A raw page.goto failure is noisy and leaks the internal URL params — surface a clean,
174
- // actionable message instead.
175
- throw new Error(`could not open ${url} — is the app running there? (${cleanNavError(err)})`);
176
- }
177
- // Wait for the leased tab's SDK to connect so the returned sessionId is usable right away.
178
- // Resolved rather than assumed: an app that names its own session registers under that name,
179
- // and the id we hand back has to be the one the agent can actually drive.
180
- let registeredId;
181
- const ready = await waitForLeasedSession(() => {
182
- registeredId = resolveLeasedSessionId(deps.sessions, lease.sessionId);
183
- return registeredId !== undefined;
184
- });
185
- // Tell the pool the other name this lease answers to. Every later touch and release arrives
186
- // under the id we are about to hand back, and the pool is keyed by the id it navigated with;
187
- // without this the touches miss, the lease ages out despite continuous activity, and the
188
- // reaper closes the context mid-flow. See BrowserPool.alias and #157.
189
- if (registeredId !== undefined)
190
- pool.alias(registeredId, lease.sessionId);
191
- return {
192
- sessionId: registeredId ?? lease.sessionId,
193
- url,
194
- ready,
195
- expiresInMs: pool.leaseTtlMs(),
196
- leased: pool.activeCount(),
197
- queued: pool.queuedCount(),
198
- ...(ready
199
- ? {}
200
- : {
201
- hint: leaseNotConnectedHint(url, deps.bridgePort ?? RETICLE_DEFAULT_PORT),
202
- }),
203
- };
204
- },
155
+ /**
156
+ * Named on its own because `reticle drive <url>` runs it too.
157
+ *
158
+ * When a daemon already owns the bridge port, `drive` asks that daemon for a browser instead of
159
+ * competing for the port (see cli/drive-attach.ts), and the browser it should get is exactly the one
160
+ * an agent gets: same pool, same isolation, same reporting. Sharing the ToolDef rather than the
161
+ * handler keeps that literal — the drive route dispatches through `runTool`, so it is counted like
162
+ * any other call instead of being a second, invisible dispatch path.
163
+ */
164
+ export const LEASE_ACQUIRE_TOOL = {
165
+ name: ReticleTool.LEASE_ACQUIRE,
166
+ description: 'Lease a fresh isolated headless browser context from the shared pool and navigate it to the app URL (the app must already be running and embed @reticlehq/core). Returns the sessionId the leased tab registers — pass it to other tools. The pool keeps all leases in ONE browser and caps concurrency; if at capacity this waits for a free slot. Release with reticle_lease{action:"release"} when the flow is done.',
167
+ inputSchema: {
168
+ url: z
169
+ .string()
170
+ .describe('URL of the already-running app to drive (e.g. http://localhost:3000/dashboard).'),
171
+ projectId: z
172
+ .string()
173
+ .optional()
174
+ .describe('Stable project id to stamp on the leased tab so the agent can scope to it.'),
205
175
  },
206
- {
207
- name: ReticleTool.LEASE_RELEASE,
208
- description: 'Release a leased browser context by sessionId, closing it and freeing the pool slot for a queued acquire. Call this when a flow finishes so the pool stays within its concurrency cap.',
209
- inputSchema: {
210
- sessionId: z
211
- .string()
212
- .describe('The leased sessionId returned by reticle_lease{action:"acquire"}.'),
213
- },
214
- outputSchema: {
215
- released: z.boolean(),
216
- leased: z.number(),
217
- },
218
- handler: async (deps, args) => {
219
- const pool = deps.pool;
220
- if (pool === undefined)
221
- throw new Error(POOL_UNAVAILABLE);
222
- const sessionId = asString(args['sessionId']);
223
- if (sessionId === undefined || 0 === sessionId.length) {
224
- throw new Error('reticle_lease{action:"release"} requires a sessionId');
176
+ outputSchema: {
177
+ sessionId: z.string(),
178
+ url: z.string(),
179
+ ready: z
180
+ .boolean()
181
+ .describe('Whether the leased tab connected — false ⇒ the app may not embed @reticlehq/core.'),
182
+ expiresInMs: z
183
+ .number()
184
+ .describe('Milliseconds until this lease expires if untouched. Each tool call that targets this session resets the clock. Plan your work to finish or re-acquire before this runs out.'),
185
+ leased: z.number().describe('How many contexts are currently leased from the pool.'),
186
+ queued: z.number().describe('How many acquires are waiting for a free slot.'),
187
+ hint: z.string().optional(),
188
+ },
189
+ handler: async (deps, args) => {
190
+ const pool = deps.pool;
191
+ if (pool === undefined)
192
+ throw new Error(POOL_UNAVAILABLE);
193
+ const url = asString(args['url']);
194
+ if (url === undefined || 0 === url.length)
195
+ throw new Error('reticle_lease{action:"acquire"} requires a url');
196
+ // Preflight the browser before spending the round trip. Without it a missing Playwright Chromium
197
+ // only surfaces inside pool.acquire, where the launch failure is caught and reported as
198
+ // "could not open <url> — is the app running?" — sending the caller to debug an app that is
199
+ // fine. Say the real thing at the first refusal instead. The phrasing carries "Chromium is not
200
+ // installed" so error-recovery routes it to the NO_POOL fix (install + drive a human tab).
201
+ if (deps.browserProbe !== undefined) {
202
+ const probe = await deps.browserProbe();
203
+ if (!probe.exists) {
204
+ throw new Error(`Chromium is not installed for Playwright — ${chromiumHint(probe)}`);
225
205
  }
226
- await pool.release(sessionId);
227
- return { released: true, leased: pool.activeCount() };
228
- },
206
+ }
207
+ const projectId = asString(args['projectId']);
208
+ const sessionId = newLeaseId();
209
+ const navUrl = appendReticleParams(url, sessionId, projectId);
210
+ let lease;
211
+ try {
212
+ lease = await pool.acquire(navUrl, { sessionId });
213
+ }
214
+ catch (err) {
215
+ // A raw page.goto failure is noisy and leaks the internal URL params — surface a clean,
216
+ // actionable message instead.
217
+ throw new Error(`could not open ${url} — is the app running there? (${cleanNavError(err)})`);
218
+ }
219
+ // Wait for the leased tab's SDK to connect so the returned sessionId is usable right away.
220
+ // Resolved rather than assumed: an app that names its own session registers under that name,
221
+ // and the id we hand back has to be the one the agent can actually drive.
222
+ let registeredId;
223
+ const ready = await waitForLeasedSession(() => {
224
+ registeredId = resolveLeasedSessionId(deps.sessions, lease.sessionId);
225
+ return registeredId !== undefined;
226
+ });
227
+ // Tell the pool the other name this lease answers to. Every later touch and release arrives
228
+ // under the id we are about to hand back, and the pool is keyed by the id it navigated with;
229
+ // without this the touches miss, the lease ages out despite continuous activity, and the
230
+ // reaper closes the context mid-flow. See BrowserPool.alias and #157.
231
+ if (registeredId !== undefined)
232
+ pool.alias(registeredId, lease.sessionId);
233
+ return {
234
+ sessionId: registeredId ?? lease.sessionId,
235
+ url,
236
+ ready,
237
+ expiresInMs: pool.leaseTtlMs(),
238
+ leased: pool.activeCount(),
239
+ queued: pool.queuedCount(),
240
+ ...(ready
241
+ ? {}
242
+ : (() => {
243
+ const bridgePort = deps.bridgePort ?? RETICLE_DEFAULT_PORT;
244
+ return {
245
+ hint: leaseNotConnectedHint(url, bridgePort, leaseEvidence(deps, bridgePort)),
246
+ };
247
+ })()),
248
+ };
249
+ },
250
+ };
251
+ export const LEASE_RELEASE_TOOL = {
252
+ name: ReticleTool.LEASE_RELEASE,
253
+ description: 'Release a leased browser context by sessionId, closing it and freeing the pool slot for a queued acquire. Call this when a flow finishes so the pool stays within its concurrency cap.',
254
+ inputSchema: {
255
+ sessionId: z
256
+ .string()
257
+ .describe('The leased sessionId returned by reticle_lease{action:"acquire"}.'),
258
+ },
259
+ outputSchema: {
260
+ released: z.boolean(),
261
+ leased: z.number(),
262
+ },
263
+ handler: async (deps, args) => {
264
+ const pool = deps.pool;
265
+ if (pool === undefined)
266
+ throw new Error(POOL_UNAVAILABLE);
267
+ const sessionId = asString(args['sessionId']);
268
+ if (sessionId === undefined || 0 === sessionId.length) {
269
+ throw new Error('reticle_lease{action:"release"} requires a sessionId');
270
+ }
271
+ await pool.release(sessionId);
272
+ return { released: true, leased: pool.activeCount() };
229
273
  },
230
- ];
274
+ };
275
+ export const LEASE_TOOLS = [LEASE_ACQUIRE_TOOL, LEASE_RELEASE_TOOL];
@@ -262,7 +262,7 @@ export const OBSERVE_TOOLS = [
262
262
  // undocumented here, including `route` — and "did submitting the login form navigate away"
263
263
  // is the most common thing an agent wants to assert. A field report reached us from an agent
264
264
  // that guessed `urlContains` on route (net's spelling) and got unrecognized_keys.
265
- 'Predicate to evaluate. Kinds: { signal, name } { net, urlContains|method|status|count } ' +
265
+ 'Predicate to evaluate. Kinds: { signal, name } { net, urlContains|method|status|count|bodyContains } ' +
266
266
  '{ state, path|equals } { route, pathname (exact) | contains (path+query+hash) } ' +
267
267
  '{ element, testid|role|text } { text } { console, level|absent } { animation, name } ' +
268
268
  '{ settled } — combine with { allOf | anyOf | not }. Prefer a signal/net/state consequence ' +
@@ -386,6 +386,10 @@ export const OBSERVE_TOOLS = [
386
386
  .nonnegative()
387
387
  .optional()
388
388
  .describe('Keep only the most recent N matching calls (older are dropped and counted in droppedOldest) — cuts tokens on a wide window. Defaults to 200 when omitted; pass a higher number for more, or scope with since/until.'),
389
+ bodies: z
390
+ .boolean()
391
+ .optional()
392
+ .describe('Include request/response bodies (default true). Pass false for a body-free listing — method, url, status, timing only — for the common "did POST /x return 200?" read. Bodies dominate the payload, so this cuts the cheap case by a large factor.'),
389
393
  ...sessionIdShape,
390
394
  },
391
395
  outputSchema: {
@@ -411,6 +415,8 @@ export const OBSERVE_TOOLS = [
411
415
  const status = asNumber(args['status']);
412
416
  const ok = 'boolean' === typeof args['ok'] ? args['ok'] : undefined;
413
417
  const limit = asNumber(args['limit']);
418
+ // Default true keeps the current shape; `bodies: false` returns the body-free listing (#401).
419
+ const bodies = 'boolean' === typeof args['bodies'] ? args['bodies'] : true;
414
420
  const buffer = bufferEnvelope(session);
415
421
  // Completed calls + unresolved in-flight requests (a hung request shows as pending).
416
422
  const allNet = reconcileNet(await session.queryEvents({
@@ -425,14 +431,14 @@ export const OBSERVE_TOOLS = [
425
431
  }
426
432
  // Default the cap so an omitted `limit` can't dump a whole flooded session (since defaults to 0).
427
433
  const { events: budgeted, droppedOldest } = applyEventBudget(matched, limit ?? DEFAULT_QUERY_LIMIT);
428
- const calls = budgeted.map(projectNetCall);
434
+ const calls = budgeted.map((e) => projectNetCall(e, bodies));
429
435
  // A zero-match FILTER already reports what did fire (netEmptyHint above). Zero calls at all
430
436
  // fell through as a bare `[]`, which is indistinguishable from an observer that is not
431
437
  // recording — and those need opposite responses. Say the look happened.
432
438
  return withSizeCost(noteEmptyRead({
433
439
  calls,
434
440
  ...(droppedOldest > 0 ? { total: matched.length, droppedOldest } : {}),
435
- ...bodiesNotCaptured(calls),
441
+ ...(bodies ? bodiesNotCaptured(calls) : {}),
436
442
  ...buffer,
437
443
  }, 'calls', { noun: 'network calls' }));
438
444
  },
@@ -30,6 +30,10 @@ import type { SettleSource } from './settle-in-flight.js';
30
30
  * True only for the accusable shape: a SUCCESSFUL MUTATING request settled, and nothing has moved. A
31
31
  * read that changed nothing is a prefetch, a failed write is a different finding entirely, and an
32
32
  * action that already moved the UI has nothing to wait for — none of them buy a grace period.
33
+ *
34
+ * "Mutating" is read from the same constant the accuser uses rather than restated here. Restating it
35
+ * is how `IPC` came to be absent from this list and present in that one, so on a desktop app every
36
+ * successful write skipped the grace and a correct app was told it had ignored its own response.
33
37
  */
34
38
  export declare function awaitsReaction(events: readonly {
35
39
  type: string;
@@ -23,7 +23,7 @@
23
23
  * Deliberately short. This covers a task-boundary re-render, not a slow app: if nothing has moved
24
24
  * within the grace, "ignored" has become an honest description and the finding should stand.
25
25
  */
26
- import { EventType } from '@reticlehq/core';
26
+ import { EventType, MUTATING_METHODS } from '@reticlehq/core';
27
27
  /**
28
28
  * How long to let the app react. A React commit after a resolved fetch is one or two task hops; this
29
29
  * is generous against that and still far too short to paper over an app that genuinely dropped the
@@ -32,7 +32,6 @@ import { EventType } from '@reticlehq/core';
32
32
  const REACTION_GRACE_MS = 300;
33
33
  /** Poll cadence — the same as the settle wait, for the same reason. */
34
34
  const POLL_MS = 50;
35
- const MUTATING = ['POST', 'PUT', 'PATCH', 'DELETE'];
36
35
  /** Movement that would clear `response-ignored`; mirrors `uiAdvanced` in the detector. */
37
36
  const MOVEMENT = new Set([
38
37
  EventType.DOM_ADDED,
@@ -51,6 +50,10 @@ function moved(events) {
51
50
  * True only for the accusable shape: a SUCCESSFUL MUTATING request settled, and nothing has moved. A
52
51
  * read that changed nothing is a prefetch, a failed write is a different finding entirely, and an
53
52
  * action that already moved the UI has nothing to wait for — none of them buy a grace period.
53
+ *
54
+ * "Mutating" is read from the same constant the accuser uses rather than restated here. Restating it
55
+ * is how `IPC` came to be absent from this list and present in that one, so on a desktop app every
56
+ * successful write skipped the grace and a correct app was told it had ignored its own response.
54
57
  */
55
58
  export function awaitsReaction(events) {
56
59
  if (moved(events))
@@ -60,7 +63,7 @@ export function awaitsReaction(events) {
60
63
  return false;
61
64
  const method = 'string' === typeof e.data['method'] ? e.data['method'].toUpperCase() : '';
62
65
  const ok = e.data['ok'];
63
- return MUTATING.includes(method) && true === ok;
66
+ return MUTATING_METHODS.includes(method) && true === ok;
64
67
  });
65
68
  }
66
69
  /**
@@ -39,6 +39,15 @@ function unanchoredWarning(count) {
39
39
  const HUD_LEVELS = ['info', 'warn', 'error'];
40
40
  const HUD_LEVEL_LIST = HUD_LEVELS.join(' | ');
41
41
  const hudLevelEnum = z.enum(HUD_LEVELS);
42
+ /**
43
+ * How deep an unscoped `reticle_state` read goes before collapsing to size markers.
44
+ *
45
+ * 3 keeps every store name and every top-level key visible — enough to see what exists and to name a
46
+ * `path` — while collapsing the collections that make an unscoped read enormous. Measured on the
47
+ * bench fixture: unbounded 10,119 B, depth 4 1,323 B, depth 3 596 B, depth 2 322 B. Two loses the
48
+ * per-store keys, which is the orientation the next call needs.
49
+ */
50
+ const DEFAULT_STATE_DEPTH = 1;
42
51
  export const READ_TOOLS = [
43
52
  {
44
53
  name: ReticleTool.BASELINE_SAVE,
@@ -395,7 +404,44 @@ export const READ_TOOLS = [
395
404
  return { ...root, storeNames: names };
396
405
  }
397
406
  if (path === undefined && depth === undefined) {
398
- return { ...root, storeNames: names }; // unchanged shape, safe storeNames
407
+ // An unscoped read used to hand back the WHOLE store tree: 10,119 bytes on this repo's own
408
+ // fixture. A tool result is not paid once — it stays in the conversation and is re-sent on
409
+ // every later turn, so one such read cost roughly 34,000 tokens across a 16-turn run.
410
+ //
411
+ // The VALUES are bounded; the ENVELOPE is not touched. `{ stores, storeNames, component }`
412
+ // is the documented shape and callers depend on it — a first attempt applied the bound by
413
+ // routing through the path selector, which silently changed the reply to `{ found, value }`
414
+ // and took the component projection and the bridge round-trip with it. Capping in place
415
+ // keeps every store name and the component read exactly where they were; only collections
416
+ // deeper than the bound collapse to a marker like "[Array(40)]", which still says what is
417
+ // there and how much of it.
418
+ //
419
+ // A default, not a cap: any explicit `depth` is honoured at any value, and `path` reads a
420
+ // sub-tree at full fidelity. Nothing became unreachable, only cheaper by default.
421
+ const stores = root.stores;
422
+ const bounded = 'object' === typeof stores && null !== stores
423
+ ? Object.fromEntries(Object.entries(stores).map(([k, v]) => [k, capDepth(v, DEFAULT_STATE_DEPTH)]))
424
+ : undefined;
425
+ // Say that it was bounded, and only when it actually was. A read that quietly hands back
426
+ // less than it was asked for is the shape of a false green: the agent sees
427
+ // `deployments: "[Array(40)]"`, concludes it has read the store, and asserts over a summary.
428
+ // The marker alone is suggestive; this makes it explicit and names the way to the real value.
429
+ const trimmed = bounded !== undefined && JSON.stringify(bounded) !== JSON.stringify(stores);
430
+ return {
431
+ ...root,
432
+ ...(bounded === undefined ? {} : { stores: bounded }),
433
+ storeNames: names,
434
+ ...(trimmed
435
+ ? {
436
+ truncation: {
437
+ note: `bounded to depth ${String(DEFAULT_STATE_DEPTH)} per store — this is NOT the ` +
438
+ 'whole store. Values shown as "[Array(n)]" or "{…n keys}" were collapsed; read ' +
439
+ 'one with { store, path } (e.g. { store:"app", path:"deployments.0" }) or pass ' +
440
+ 'an explicit `depth` for more.',
441
+ },
442
+ }
443
+ : {}),
444
+ };
399
445
  }
400
446
  // Back-compat: an older browser returned the whole store; scope it here (may already be
401
447
  // size-truncated in transit for a very large store — that is the limitation this fix removes