@reticlehq/server 2.6.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 (276) hide show
  1. package/README.md +14 -5
  2. package/SKILL.md +251 -33
  3. package/dist/bridge/bridge.d.ts +6 -1
  4. package/dist/bridge/bridge.js +27 -4
  5. package/dist/capsule/causal-summary.d.ts +20 -0
  6. package/dist/capsule/causal-summary.js +31 -6
  7. package/dist/capsule/divergence.js +7 -1
  8. package/dist/cli/chromium-hint.d.ts +68 -0
  9. package/dist/cli/chromium-hint.js +145 -0
  10. package/dist/cli/cli-doctor.js +31 -36
  11. package/dist/cli/cli-kill.d.ts +58 -0
  12. package/dist/cli/cli-kill.js +162 -0
  13. package/dist/cli/cli-launch.d.ts +18 -0
  14. package/dist/cli/cli-launch.js +32 -1
  15. package/dist/cli/cli-parse.d.ts +16 -1
  16. package/dist/cli/cli-parse.js +32 -9
  17. package/dist/cli/cli-port.d.ts +8 -0
  18. package/dist/cli/cli-port.js +36 -1
  19. package/dist/cli/cli-verify.js +2 -2
  20. package/dist/cli/doctor-daemon-line.js +10 -1
  21. package/dist/cli/doctor-sessions-line.d.ts +12 -0
  22. package/dist/cli/doctor-sessions-line.js +66 -0
  23. package/dist/cli/port-holder.d.ts +10 -0
  24. package/dist/cli/port-holder.js +26 -2
  25. package/dist/cli.js +139 -23
  26. package/dist/crawl/crawl-tools.js +12 -2
  27. package/dist/crawl/crawl.d.ts +13 -0
  28. package/dist/crawl/crawl.js +50 -2
  29. package/dist/daemon/daemon-resilience.js +2 -2
  30. package/dist/daemon/loopback-alias.d.ts +32 -0
  31. package/dist/daemon/loopback-alias.js +80 -0
  32. package/dist/events/contradictions.d.ts +14 -0
  33. package/dist/events/contradictions.js +26 -17
  34. package/dist/events/predicate-eval.d.ts +26 -3
  35. package/dist/events/predicate-eval.js +284 -95
  36. package/dist/events/predicate-parse.d.ts +1 -1
  37. package/dist/events/predicate-parse.js +18 -4
  38. package/dist/events/predicate.js +170 -18
  39. package/dist/events/ring-buffer.d.ts +8 -0
  40. package/dist/events/ring-buffer.js +34 -1
  41. package/dist/flows/flow-classify.js +1 -1
  42. package/dist/flows/flow-tools.js +12 -4
  43. package/dist/flows/heal-run.js +1 -1
  44. package/dist/honesty/blind-spots.js +6 -2
  45. package/dist/honesty/honesty.d.ts +11 -0
  46. package/dist/honesty/honesty.js +9 -1
  47. package/dist/honesty/unsettled.d.ts +40 -0
  48. package/dist/honesty/unsettled.js +78 -0
  49. package/dist/honesty/verified.d.ts +7 -0
  50. package/dist/honesty/verified.js +27 -4
  51. package/dist/http-server.js +11 -1
  52. package/dist/index.js +29 -2
  53. package/dist/init/agent-rules.d.ts +16 -0
  54. package/dist/init/agent-rules.js +96 -17
  55. package/dist/init/astro-patch.d.ts +2 -1
  56. package/dist/init/astro-patch.js +11 -5
  57. package/dist/init/capabilities.d.ts +22 -0
  58. package/dist/init/capabilities.js +76 -0
  59. package/dist/init/confirm.d.ts +96 -0
  60. package/dist/init/confirm.js +138 -0
  61. package/dist/init/cra.js +3 -2
  62. package/dist/init/detect.d.ts +8 -0
  63. package/dist/init/detect.js +15 -0
  64. package/dist/init/non-js-project.d.ts +27 -0
  65. package/dist/init/non-js-project.js +84 -0
  66. package/dist/init/plan-framework.d.ts +9 -0
  67. package/dist/init/plan-framework.js +72 -16
  68. package/dist/init/plan.d.ts +40 -4
  69. package/dist/init/plan.js +183 -43
  70. package/dist/init/run.d.ts +24 -2
  71. package/dist/init/run.js +181 -46
  72. package/dist/init/slash-command.d.ts +1 -1
  73. package/dist/init/slash-command.js +46 -6
  74. package/dist/init/snippets.d.ts +64 -5
  75. package/dist/init/snippets.js +220 -31
  76. package/dist/init/vite-config.js +29 -3
  77. package/dist/input/network-mock-tools.d.ts +19 -0
  78. package/dist/input/network-mock-tools.js +29 -4
  79. package/dist/license/license.js +1 -1
  80. package/dist/mcp/mcp-proxy.d.ts +69 -3
  81. package/dist/mcp/mcp-proxy.js +215 -23
  82. package/dist/mcp/mcp.js +58 -7
  83. package/dist/mcp/proxy-lifecycle.d.ts +20 -1
  84. package/dist/mcp/proxy-lifecycle.js +22 -1
  85. package/dist/pool/browser-pool.d.ts +22 -2
  86. package/dist/pool/browser-pool.js +44 -4
  87. package/dist/pool/playwright-launcher.js +10 -2
  88. package/dist/project/learned-routes.d.ts +24 -0
  89. package/dist/project/learned-routes.js +63 -0
  90. package/dist/project/project-store.d.ts +6 -0
  91. package/dist/project/project-store.js +25 -1
  92. package/dist/session/live-control-tools.js +39 -2
  93. package/dist/session/live-control.d.ts +7 -9
  94. package/dist/session/live-control.js +34 -1
  95. package/dist/session/no-session-diagnosis.d.ts +23 -4
  96. package/dist/session/no-session-diagnosis.js +128 -44
  97. package/dist/session/no-session-watch.js +12 -0
  98. package/dist/session/ref-provenance.d.ts +54 -0
  99. package/dist/session/ref-provenance.js +61 -0
  100. package/dist/session/review-store.d.ts +19 -0
  101. package/dist/session/review-store.js +34 -5
  102. package/dist/session/session-lease.js +1 -1
  103. package/dist/session/session-manager.d.ts +9 -0
  104. package/dist/session/session-manager.js +11 -0
  105. package/dist/session/session.d.ts +8 -0
  106. package/dist/session/session.js +12 -0
  107. package/dist/status-payload.d.ts +13 -1
  108. package/dist/status-payload.js +6 -1
  109. package/dist/telemetry/app-instrumented.d.ts +37 -0
  110. package/dist/telemetry/app-instrumented.js +54 -0
  111. package/dist/telemetry/automation-hint.d.ts +17 -0
  112. package/dist/telemetry/automation-hint.js +71 -0
  113. package/dist/telemetry/bug-found.d.ts +0 -20
  114. package/dist/telemetry/bug-found.js +38 -21
  115. package/dist/telemetry/cli-telemetry.js +7 -1
  116. package/dist/telemetry/daemon-telemetry.d.ts +4 -4
  117. package/dist/telemetry/daemon-telemetry.js +8 -4
  118. package/dist/telemetry/feedback.js +1 -1
  119. package/dist/telemetry/init-telemetry.js +9 -1
  120. package/dist/telemetry/install-source.d.ts +36 -0
  121. package/dist/telemetry/install-source.js +40 -0
  122. package/dist/telemetry/run-telemetry.js +4 -1
  123. package/dist/telemetry/session-metrics.d.ts +3 -3
  124. package/dist/telemetry/session-metrics.js +9 -4
  125. package/dist/telemetry/telemetry.d.ts +7 -1
  126. package/dist/telemetry/telemetry.js +17 -2
  127. package/dist/telemetry/tool-refused.d.ts +19 -0
  128. package/dist/telemetry/tool-refused.js +55 -0
  129. package/dist/telemetry/verification-of.js +72 -8
  130. package/dist/temp-dir.d.ts +27 -0
  131. package/dist/temp-dir.js +46 -0
  132. package/dist/tools/act-tools.js +77 -11
  133. package/dist/tools/assert-grade.d.ts +1 -1
  134. package/dist/tools/assert-grade.js +27 -5
  135. package/dist/tools/assert-verdict.js +25 -1
  136. package/dist/tools/contract-tools.js +4 -0
  137. package/dist/tools/dynamic-tools.js +1 -1
  138. package/dist/tools/error-recovery.d.ts +10 -0
  139. package/dist/tools/error-recovery.js +70 -5
  140. package/dist/tools/feedback-invite.js +1 -1
  141. package/dist/tools/invoke-tool.js +149 -32
  142. package/dist/tools/lease-tools.d.ts +29 -1
  143. package/dist/tools/lease-tools.js +58 -5
  144. package/dist/tools/navigate-result.js +1 -1
  145. package/dist/tools/observe-tools.js +1 -1
  146. package/dist/tools/observed-nothing.js +1 -1
  147. package/dist/tools/react-grace.d.ts +48 -0
  148. package/dist/tools/react-grace.js +87 -0
  149. package/dist/tools/read-tools.js +20 -4
  150. package/dist/tools/settle-in-flight.d.ts +79 -0
  151. package/dist/tools/settle-in-flight.js +106 -0
  152. package/dist/tools/snapshot-delta.d.ts +7 -0
  153. package/dist/tools/snapshot-delta.js +130 -6
  154. package/dist/tools/tool-kit.d.ts +52 -5
  155. package/dist/tools/tool-kit.js +55 -24
  156. package/dist/tools/tool-surface.js +28 -9
  157. package/dist/tools/tools.js +38 -4
  158. package/dist/visual/capture-cleanup.d.ts +16 -0
  159. package/dist/visual/capture-cleanup.js +39 -0
  160. package/dist/visual/visual-tools.d.ts +18 -0
  161. package/dist/visual/visual-tools.js +24 -5
  162. package/docs/README.md +56 -11
  163. package/docs/actions.mdx +87 -0
  164. package/docs/agent-cheatsheet.md +102 -26
  165. package/docs/architecture.md +46 -35
  166. package/docs/benchmarks.md +76 -72
  167. package/docs/best-practices.mdx +211 -0
  168. package/docs/capabilities.mdx +387 -0
  169. package/docs/cli/affected.mdx +51 -0
  170. package/docs/cli/capsules.mdx +69 -0
  171. package/docs/cli/cloud.mdx +127 -0
  172. package/docs/cli/doctor.mdx +76 -0
  173. package/docs/cli/drive.mdx +55 -0
  174. package/docs/cli/feedback.mdx +67 -0
  175. package/docs/cli/gate.mdx +64 -0
  176. package/docs/cli/help.mdx +92 -0
  177. package/docs/cli/hunt.mdx +57 -0
  178. package/docs/cli/identify.mdx +62 -0
  179. package/docs/cli/init.mdx +94 -0
  180. package/docs/cli/kill.mdx +91 -0
  181. package/docs/cli/license.mdx +48 -0
  182. package/docs/cli/mcp.mdx +61 -0
  183. package/docs/cli/open.mdx +68 -0
  184. package/docs/cli/restart.mdx +60 -0
  185. package/docs/cli/rollback.mdx +35 -0
  186. package/docs/cli/serve.mdx +62 -0
  187. package/docs/cli/status.mdx +64 -0
  188. package/docs/cli/stop.mdx +56 -0
  189. package/docs/cli/telemetry.mdx +57 -0
  190. package/docs/cli/update.mdx +52 -0
  191. package/docs/cli/verify.mdx +57 -0
  192. package/docs/cli/version.mdx +39 -0
  193. package/docs/cli/watch.mdx +55 -0
  194. package/docs/cli.mdx +219 -0
  195. package/docs/debugging.md +13 -9
  196. package/docs/deploy-checks.md +15 -11
  197. package/docs/desktop-apps.md +49 -40
  198. package/docs/desktop.mdx +188 -0
  199. package/docs/docs.json +363 -0
  200. package/docs/enterprise.md +20 -16
  201. package/docs/faq.mdx +88 -0
  202. package/docs/fixtures.md +11 -7
  203. package/docs/flows.md +26 -22
  204. package/docs/for-agents.md +71 -0
  205. package/docs/frameworks.mdx +199 -0
  206. package/docs/gate-plan.md +45 -41
  207. package/docs/gates.md +23 -19
  208. package/docs/getting-started.md +76 -65
  209. package/docs/human-control.md +22 -18
  210. package/docs/index.mdx +101 -0
  211. package/docs/install-agentic.mdx +215 -0
  212. package/docs/install-manual.mdx +194 -0
  213. package/docs/instrumentation.mdx +232 -0
  214. package/docs/integration-patterns.md +38 -34
  215. package/docs/local-registry.md +16 -12
  216. package/docs/multi-agent-testing.md +16 -12
  217. package/docs/packages/babel-plugin.mdx +70 -0
  218. package/docs/packages/browser.mdx +111 -0
  219. package/docs/packages/core.mdx +106 -0
  220. package/docs/packages/electron.mdx +87 -0
  221. package/docs/packages/eslint-plugin.mdx +84 -0
  222. package/docs/packages/next.mdx +91 -0
  223. package/docs/packages/react.mdx +91 -0
  224. package/docs/packages/server.mdx +98 -0
  225. package/docs/packages/tauri.mdx +63 -0
  226. package/docs/packages/test.mdx +108 -0
  227. package/docs/packages/vite-plugin.mdx +96 -0
  228. package/docs/packages.mdx +217 -0
  229. package/docs/platform-integration.md +33 -26
  230. package/docs/predicates.mdx +179 -0
  231. package/docs/quickstart.mdx +211 -0
  232. package/docs/recipes.mdx +242 -0
  233. package/docs/skill-file.mdx +105 -0
  234. package/docs/state-management.mdx +197 -0
  235. package/docs/style.css +14 -0
  236. package/docs/system-map.md +47 -32
  237. package/docs/telemetry-contract.md +101 -46
  238. package/docs/telemetry-events.mdx +430 -0
  239. package/docs/telemetry.md +63 -50
  240. package/docs/testing.md +10 -6
  241. package/docs/token-efficiency.md +30 -26
  242. package/docs/tools-act-and-wait.mdx +123 -0
  243. package/docs/tools-act-sequence.mdx +120 -0
  244. package/docs/tools-act.mdx +82 -0
  245. package/docs/tools-assert.mdx +70 -0
  246. package/docs/tools-console.mdx +59 -0
  247. package/docs/tools-inspect.mdx +85 -0
  248. package/docs/tools-navigate.mdx +63 -0
  249. package/docs/tools-network.mdx +86 -0
  250. package/docs/tools-observe.mdx +89 -0
  251. package/docs/tools-overview.mdx +157 -0
  252. package/docs/tools-query.mdx +82 -0
  253. package/docs/tools-session-and-feedback.mdx +91 -0
  254. package/docs/tools-sessions.mdx +65 -0
  255. package/docs/tools-snapshot.mdx +75 -0
  256. package/docs/tools-state.mdx +81 -0
  257. package/docs/tools-tools-and-run.mdx +125 -0
  258. package/docs/tools-wait-for.mdx +66 -0
  259. package/docs/troubleshooting.mdx +193 -0
  260. package/docs/usage.md +175 -153
  261. package/docs/vs-chrome-devtools-mcp.mdx +82 -0
  262. package/docs/vs-playwright-mcp.mdx +85 -0
  263. package/docs/vs-screenshots.mdx +96 -0
  264. package/docs/what-is-reticle.mdx +63 -0
  265. package/docs/why-reticle.mdx +107 -0
  266. package/package.json +2 -2
  267. package/docs/matrix/2.5.0/claude-code-machine.json +0 -19
  268. package/docs/matrix/2.5.0/codex-machine.json +0 -20
  269. package/docs/matrix/2.5.0/cursor-machine.json +0 -22
  270. package/docs/matrix/2.5.0/gemini-machine.json +0 -22
  271. package/docs/matrix/2.5.0/opencode-machine.json +0 -22
  272. package/docs/matrix/2.5.0/vscode-machine.json +0 -22
  273. package/docs/matrix/2.5.0/windsurf-machine.json +0 -22
  274. package/docs/matrix/MATRIX.md +0 -26
  275. package/docs/matrix/README.md +0 -59
  276. package/docs/mint.json +0 -61
@@ -0,0 +1,87 @@
1
+ /**
2
+ * "The app ignored this response" is only sayable once the app has had a chance to read it.
3
+ *
4
+ * `response-ignored` fires when a write succeeded on the server and nothing on the client moved. It
5
+ * is a real and valuable finding — a lost write, a response parsed into the void, a render that never
6
+ * happened. It is also an ACCUSATION, and it is decided by where a window happens to end.
7
+ *
8
+ * Waiting for in-flight requests before taking the verdict made that boundary dangerous in a way it
9
+ * was not before. Previously the write was still pending when the window closed, so it never reached
10
+ * the settled list and this rule could not fire at all. Now the response lands inside the window BY
11
+ * DESIGN — and the app's re-render happens in a later task, a few milliseconds after it. Close the
12
+ * window in that gap and every channel agrees that the app took a successful write and did nothing.
13
+ *
14
+ * The verdict is then `verified:"no"` on a completely correct application, produced entirely by where
15
+ * we chose to stop looking. For a verification tool that is the more damaging direction of error: a
16
+ * false green is a missed catch, but a false accusation sends someone to fix code that is not broken,
17
+ * and it is how the instrument stops being believed.
18
+ *
19
+ * So the response is not the end of the window — the app's REACTION to it is. The grace is paid only
20
+ * in the exact case that would otherwise be accused: a successful mutating write with nothing moved
21
+ * after it. Every other action returns without a single poll.
22
+ *
23
+ * Deliberately short. This covers a task-boundary re-render, not a slow app: if nothing has moved
24
+ * within the grace, "ignored" has become an honest description and the finding should stand.
25
+ */
26
+ import { EventType } from '@reticlehq/core';
27
+ /**
28
+ * How long to let the app react. A React commit after a resolved fetch is one or two task hops; this
29
+ * is generous against that and still far too short to paper over an app that genuinely dropped the
30
+ * response.
31
+ */
32
+ const REACTION_GRACE_MS = 300;
33
+ /** Poll cadence — the same as the settle wait, for the same reason. */
34
+ const POLL_MS = 50;
35
+ const MUTATING = ['POST', 'PUT', 'PATCH', 'DELETE'];
36
+ /** Movement that would clear `response-ignored`; mirrors `uiAdvanced` in the detector. */
37
+ const MOVEMENT = new Set([
38
+ EventType.DOM_ADDED,
39
+ EventType.DOM_REMOVED,
40
+ EventType.DOM_ATTR,
41
+ EventType.DOM_TEXT,
42
+ EventType.STATE_CHANGE,
43
+ EventType.ROUTE_CHANGE,
44
+ ]);
45
+ function moved(events) {
46
+ return events.some((e) => MOVEMENT.has(e.type));
47
+ }
48
+ /**
49
+ * Is this a window that would be wrongly accused if it ended right now?
50
+ *
51
+ * True only for the accusable shape: a SUCCESSFUL MUTATING request settled, and nothing has moved. A
52
+ * read that changed nothing is a prefetch, a failed write is a different finding entirely, and an
53
+ * action that already moved the UI has nothing to wait for — none of them buy a grace period.
54
+ */
55
+ export function awaitsReaction(events) {
56
+ if (moved(events))
57
+ return false;
58
+ return events.some((e) => {
59
+ if (e.type !== EventType.NET_REQUEST)
60
+ return false;
61
+ const method = 'string' === typeof e.data['method'] ? e.data['method'].toUpperCase() : '';
62
+ const ok = e.data['ok'];
63
+ return MUTATING.includes(method) && true === ok;
64
+ });
65
+ }
66
+ /**
67
+ * Wait, bounded by BOTH the grace and the caller's remaining budget, for the app to react to a write
68
+ * it has just been told succeeded.
69
+ *
70
+ * Returns whether the app moved. A `false` is not a verdict — it hands the question back to the
71
+ * contradiction engine, which will raise `response-ignored` exactly as it does today. The point is
72
+ * only that by then the app has been given its chance, so the finding means what it says.
73
+ */
74
+ export async function waitForReaction(session, since, budgetMs, opts) {
75
+ if (!awaitsReaction(session.eventsSince(since)))
76
+ return true;
77
+ const window = Math.min(REACTION_GRACE_MS, budgetMs);
78
+ if (window <= 0)
79
+ return false;
80
+ const deadline = session.elapsed() + window;
81
+ while (session.elapsed() < deadline) {
82
+ await opts.sleep(Math.min(POLL_MS, deadline - session.elapsed()));
83
+ if (moved(session.eventsSince(since)))
84
+ return true;
85
+ }
86
+ return false;
87
+ }
@@ -11,7 +11,7 @@ import { replayProgram } from '../flows/replay.js';
11
11
  import { diffLines } from '../project/baselines.js';
12
12
  import { selectPath, capDepth, projectComponentState } from '../session/state-select.js';
13
13
  import { costHint } from '../session/output-budget.js';
14
- import { buildReactionReport } from '../events/reaction.js';
14
+ import { buildReactionReport, summarizeReaction } from '../events/reaction.js';
15
15
  import { asString, asNumber, parseInteractive } from './tools-helpers.js';
16
16
  import { sessionIdShape, commandOrThrow, snapshotTree } from './tool-kit.js';
17
17
  import { bufferEnvelope } from '../session/session-health.js';
@@ -178,17 +178,33 @@ export const READ_TOOLS = [
178
178
  const report = buildReactionReport(events, session.elapsed() - rec.cursor);
179
179
  // Self-generating oracles: propose ranked mustHold from what the recorded window actually did.
180
180
  const proposedConsequences = proposeConsequences(events);
181
- return Promise.resolve({
181
+ const digest = summarizeReaction(report);
182
+ // Say that the timeline was left out, and where it lives.
183
+ //
184
+ // Dropping the raw events is right: they were most of a response large enough to truncate the
185
+ // part that mattered. But a field that simply stops appearing is indistinguishable from one
186
+ // this version never had, and an agent cannot ask for something it does not know exists. The
187
+ // same rule the capped diff arrays follow: a trim is never silent.
188
+ const timeline = events.length > 0
189
+ ? {
190
+ timeline_omitted: `${String(events.length)} event(s) were recorded and are not included here. Call reticle_observe { since: ${String(rec.cursor)} } for the raw timeline.`,
191
+ }
192
+ : {};
193
+ const body = {
182
194
  recordingName: name,
183
195
  program,
196
+ ...timeline,
184
197
  ...(unstable > 0
185
198
  ? {
186
199
  warning: unanchoredWarning(unstable),
187
200
  }
188
201
  : {}),
189
202
  ...(proposedConsequences.length > 0 ? { proposedConsequences } : {}),
190
- ...report,
191
- cost: costHint(report, events.length),
203
+ ...digest,
204
+ };
205
+ return Promise.resolve({
206
+ ...body,
207
+ cost: costHint(body, events.length),
192
208
  });
193
209
  },
194
210
  },
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Spend the budget you were given before reporting that you ran out of certainty.
3
+ *
4
+ * `waitForPredicate` resolves the instant the predicate holds. On an app that navigates
5
+ * optimistically — which is normal, correct behaviour, and the norm on exactly the flows agents
6
+ * verify most: login, submit, save — that instant arrives while the write is still in flight. The
7
+ * verdict was then taken over a window the app had not finished writing, and came back
8
+ * `unknown / unsettled` with a recovery note asking the CALLER to re-check.
9
+ *
10
+ * Measured on the most common flow there is: a login that genuinely succeeded returned at 492ms out
11
+ * of an 8000ms budget. The declared signal had fired with matching data, state had moved from
12
+ * anonymous to authenticated, the token was in storage, honesty grade was `signal` and capture
13
+ * integrity was clean. Seven and a half seconds of authorised waiting went unused, and the agent was
14
+ * handed a shrug.
15
+ *
16
+ * A false NEGATIVE is not the mirror of a false positive here. A false positive stops an agent early;
17
+ * a false negative makes it redo work that already succeeded, and — far worse — teaches it to
18
+ * discount the verdict channel. The verdict channel is the entire product, and an agent that learns
19
+ * to ignore `unknown` will ignore the true ones too.
20
+ *
21
+ * What this does NOT do is decide the request succeeded. At 492ms nobody can know that, and inventing
22
+ * a green here would be the exact defect this product exists to prevent. It waits for the answer
23
+ * rather than guessing at it:
24
+ *
25
+ * - everything settles → the normal contradiction machinery sees the real responses, so a failure
26
+ * still contradicts and a success no longer looks like an absence;
27
+ * - still in flight when the budget genuinely runs out → `unsettled`, as before, but now an honest
28
+ * limit instead of an early exit.
29
+ */
30
+ /** Just the shape this needs — a real Session satisfies it, and a test can supply it. */
31
+ export interface SettleSource {
32
+ eventsSince(cursor: number): readonly {
33
+ type: string;
34
+ data: Record<string, unknown>;
35
+ }[];
36
+ elapsed(): number;
37
+ }
38
+ interface WaitOpts {
39
+ /** Injected so the wait is testable without real time passing (rule: never call the clock inline). */
40
+ sleep(ms: number): Promise<void>;
41
+ }
42
+ /**
43
+ * Requests that started in this window and have not come back.
44
+ *
45
+ * A settle for a request this window never saw START is ignored on purpose: the window is floored at
46
+ * the act cursor, so a response belonging to an earlier action can land inside it, and counting that
47
+ * as progress would let unrelated traffic close the window early.
48
+ */
49
+ export declare function inFlightRequestIds(events: readonly {
50
+ type: string;
51
+ data: Record<string, unknown>;
52
+ }[]): string[];
53
+ /**
54
+ * The same outstanding requests, as "METHOD url" — what an unsettled verdict has to be able to name.
55
+ *
56
+ * A correlation id is the right key for the wait and useless in a sentence: an agent told that
57
+ * `r-17` is open cannot act on it, while `POST /api/login` is the thing to re-check or to assert
58
+ * directly. Read off the NET_PENDING the window already holds, so this costs one pass over events
59
+ * that are in hand.
60
+ */
61
+ export declare function inFlightRequestLabels(events: readonly {
62
+ type: string;
63
+ data: Record<string, unknown>;
64
+ }[]): string[];
65
+ export interface SettleResult {
66
+ /** True when nothing this window started is still outstanding. */
67
+ settled: boolean;
68
+ /** What was still open when the wait ended — empty when `settled`. */
69
+ stillInFlight: string[];
70
+ }
71
+ /**
72
+ * Wait, bounded by `budgetMs`, for the requests this window started to come back.
73
+ *
74
+ * Returns immediately when nothing is in flight, so the common green path — an action with no traffic
75
+ * — pays nothing at all. A zero or negative budget means no wait: the caller may have spent the whole
76
+ * timeout reaching the predicate, and a negative deadline must mean "stop", never "loop forever".
77
+ */
78
+ export declare function waitForInFlight(session: SettleSource, since: number, budgetMs: number, opts: WaitOpts): Promise<SettleResult>;
79
+ export {};
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Spend the budget you were given before reporting that you ran out of certainty.
3
+ *
4
+ * `waitForPredicate` resolves the instant the predicate holds. On an app that navigates
5
+ * optimistically — which is normal, correct behaviour, and the norm on exactly the flows agents
6
+ * verify most: login, submit, save — that instant arrives while the write is still in flight. The
7
+ * verdict was then taken over a window the app had not finished writing, and came back
8
+ * `unknown / unsettled` with a recovery note asking the CALLER to re-check.
9
+ *
10
+ * Measured on the most common flow there is: a login that genuinely succeeded returned at 492ms out
11
+ * of an 8000ms budget. The declared signal had fired with matching data, state had moved from
12
+ * anonymous to authenticated, the token was in storage, honesty grade was `signal` and capture
13
+ * integrity was clean. Seven and a half seconds of authorised waiting went unused, and the agent was
14
+ * handed a shrug.
15
+ *
16
+ * A false NEGATIVE is not the mirror of a false positive here. A false positive stops an agent early;
17
+ * a false negative makes it redo work that already succeeded, and — far worse — teaches it to
18
+ * discount the verdict channel. The verdict channel is the entire product, and an agent that learns
19
+ * to ignore `unknown` will ignore the true ones too.
20
+ *
21
+ * What this does NOT do is decide the request succeeded. At 492ms nobody can know that, and inventing
22
+ * a green here would be the exact defect this product exists to prevent. It waits for the answer
23
+ * rather than guessing at it:
24
+ *
25
+ * - everything settles → the normal contradiction machinery sees the real responses, so a failure
26
+ * still contradicts and a success no longer looks like an absence;
27
+ * - still in flight when the budget genuinely runs out → `unsettled`, as before, but now an honest
28
+ * limit instead of an early exit.
29
+ */
30
+ import { EventType } from '@reticlehq/core';
31
+ /** How often to re-read the buffer. Short enough to return promptly, long enough not to spin. */
32
+ const POLL_MS = 50;
33
+ const idOf = (data) => 'string' === typeof data['id'] ? data['id'] : undefined;
34
+ /**
35
+ * Requests that started in this window and have not come back.
36
+ *
37
+ * A settle for a request this window never saw START is ignored on purpose: the window is floored at
38
+ * the act cursor, so a response belonging to an earlier action can land inside it, and counting that
39
+ * as progress would let unrelated traffic close the window early.
40
+ */
41
+ export function inFlightRequestIds(events) {
42
+ const settled = new Set();
43
+ for (const e of events) {
44
+ if (e.type !== EventType.NET_REQUEST)
45
+ continue;
46
+ const id = idOf(e.data);
47
+ if (id !== undefined)
48
+ settled.add(id);
49
+ }
50
+ const open = [];
51
+ for (const e of events) {
52
+ if (e.type !== EventType.NET_PENDING)
53
+ continue;
54
+ const id = idOf(e.data);
55
+ if (id !== undefined && !settled.has(id) && !open.includes(id))
56
+ open.push(id);
57
+ }
58
+ return open;
59
+ }
60
+ /**
61
+ * The same outstanding requests, as "METHOD url" — what an unsettled verdict has to be able to name.
62
+ *
63
+ * A correlation id is the right key for the wait and useless in a sentence: an agent told that
64
+ * `r-17` is open cannot act on it, while `POST /api/login` is the thing to re-check or to assert
65
+ * directly. Read off the NET_PENDING the window already holds, so this costs one pass over events
66
+ * that are in hand.
67
+ */
68
+ export function inFlightRequestLabels(events) {
69
+ const open = new Set(inFlightRequestIds(events));
70
+ const labels = [];
71
+ for (const e of events) {
72
+ if (e.type !== EventType.NET_PENDING)
73
+ continue;
74
+ const id = idOf(e.data);
75
+ if (id === undefined || !open.has(id))
76
+ continue;
77
+ open.delete(id); // one label per request, however many times its start was recorded
78
+ const method = 'string' === typeof e.data['method'] ? e.data['method'] : 'request';
79
+ const url = 'string' === typeof e.data['url'] ? e.data['url'] : 'an unreported url';
80
+ labels.push(`${method} ${url}`);
81
+ }
82
+ return labels;
83
+ }
84
+ /**
85
+ * Wait, bounded by `budgetMs`, for the requests this window started to come back.
86
+ *
87
+ * Returns immediately when nothing is in flight, so the common green path — an action with no traffic
88
+ * — pays nothing at all. A zero or negative budget means no wait: the caller may have spent the whole
89
+ * timeout reaching the predicate, and a negative deadline must mean "stop", never "loop forever".
90
+ */
91
+ export async function waitForInFlight(session, since, budgetMs, opts) {
92
+ let open = inFlightRequestIds(session.eventsSince(since));
93
+ if (0 === open.length)
94
+ return { settled: true, stillInFlight: [] };
95
+ if (budgetMs <= 0)
96
+ return { settled: false, stillInFlight: open };
97
+ const deadline = session.elapsed() + budgetMs;
98
+ while (session.elapsed() < deadline) {
99
+ // Never overshoot the budget: the last sleep is trimmed to whatever is actually left.
100
+ await opts.sleep(Math.min(POLL_MS, deadline - session.elapsed()));
101
+ open = inFlightRequestIds(session.eventsSince(since));
102
+ if (0 === open.length)
103
+ return { settled: true, stillInFlight: [] };
104
+ }
105
+ return { settled: false, stillInFlight: open };
106
+ }
@@ -24,13 +24,20 @@ interface SnapshotDelta {
24
24
  addedCount: number;
25
25
  removedCount: number;
26
26
  }
27
+ /** Where focus moved, when it moved. Absent fields mean nothing held focus on that side. */
28
+ export interface FocusChange {
29
+ from?: string;
30
+ to?: string;
31
+ }
27
32
  type DeltaDecision = {
28
33
  mode: typeof SnapshotDeltaMode.FULL;
29
34
  } | {
30
35
  mode: typeof SnapshotDeltaMode.UNCHANGED;
36
+ focusChanged?: FocusChange;
31
37
  } | {
32
38
  mode: typeof SnapshotDeltaMode.DELTA;
33
39
  delta: SnapshotDelta;
40
+ focusChanged?: FocusChange;
34
41
  };
35
42
  /** Pure: decide full vs delta vs unchanged given the previous tree (same route) and the next tree. */
36
43
  export declare function snapshotDelta(prevTree: string | undefined, nextTree: string): DeltaDecision;
@@ -12,7 +12,108 @@
12
12
  * invalidates the prior snapshot (a diff across pages would be meaningless), so the next snapshot
13
13
  * returns full — never a misleading cross-page delta.
14
14
  */
15
- import { normalizeLines, diffLines } from '../project/baselines.js';
15
+ /**
16
+ * Identity for comparison, ref for action.
17
+ *
18
+ * The delta path used `normalizeLines` from the baseline layer, which strips the ref marker on
19
+ * purpose — a ref stored in a baseline would make every later diff noisy. Correct there, wrong here:
20
+ * every delta line came back as `- button "Row actions"` with no ref, and acting needs a ref, so the
21
+ * full snapshot had to be taken anyway. The diff call cost tokens and bought nothing. One helper, two
22
+ * callers, opposite requirements — so this path keeps the ref-stripped line as the KEY and emits the
23
+ * ORIGINAL line with its ref.
24
+ *
25
+ * Focus is stripped from the key too. It rides in the line as `[focused]`, alone or comma-joined with
26
+ * other states, so a focus move used to surface as one line removed and its twin added — structural
27
+ * change reported where nothing had been added at all. Focus is a property OF a line, so it gets its
28
+ * own field. Only focus is exempt: `disabled` moving is a real change and must still show.
29
+ */
30
+ /** The ref marker, as `formatLine` writes it. Bounded quantifier: `\s*` backtracks polynomially. */
31
+ const REF_MARKER = /\s?\(ref=e\d+\)/;
32
+ /** The state bracket — ` [disabled,checked,expanded,focused]` — and the one state that is not structural. */
33
+ const STATE_BRACKET = /\s\[([a-z,]+)\]$/;
34
+ const FOCUSED_STATE = 'focused';
35
+ /** True when this line carries focus. */
36
+ function isFocused(line) {
37
+ return true === STATE_BRACKET.exec(line)?.[1]?.split(',').includes(FOCUSED_STATE);
38
+ }
39
+ /** The same line with focus removed — and the bracket dropped when focus was all it held. */
40
+ function withoutFocus(line) {
41
+ const match = STATE_BRACKET.exec(line);
42
+ const states = match?.[1];
43
+ if (null === match || undefined === match || undefined === states)
44
+ return line;
45
+ const rest = states.split(',').filter((s) => s !== FOCUSED_STATE);
46
+ const head = line.slice(0, match.index);
47
+ return 0 === rest.length ? head : `${head} [${rest.join(',')}]`;
48
+ }
49
+ /** What makes two lines "the same element" for diffing: no ref, no focus, no surrounding space. */
50
+ function deltaKey(line) {
51
+ return withoutFocus(line.replace(REF_MARKER, '')).trim();
52
+ }
53
+ function deltaLines(tree) {
54
+ return tree
55
+ .split('\n')
56
+ .map((line) => ({ key: deltaKey(line), text: withoutFocus(line).trim() }))
57
+ .filter((line) => line.key.length > 0);
58
+ }
59
+ /**
60
+ * Multiset diff on the key, emitting the original text.
61
+ *
62
+ * Multiset rather than set because duplicate labels are the norm — twelve identical `Row actions`
63
+ * buttons — and the count is the only thing that says how many left. Emitting the ORIGINAL text is
64
+ * what finally tells them apart: with refs, "which twelve" has an answer.
65
+ */
66
+ function diffKeyed(prev, next) {
67
+ const bucket = (lines) => {
68
+ const map = new Map();
69
+ for (const line of lines) {
70
+ const existing = map.get(line.key);
71
+ if (existing === undefined)
72
+ map.set(line.key, [line.text]);
73
+ else
74
+ existing.push(line.text);
75
+ }
76
+ return map;
77
+ };
78
+ const before = bucket(prev);
79
+ const after = bucket(next);
80
+ const removed = [];
81
+ const added = [];
82
+ for (const key of new Set([...before.keys(), ...after.keys()])) {
83
+ const was = before.get(key) ?? [];
84
+ const now = after.get(key) ?? [];
85
+ // Pair the SAME REF first. Twelve identical `Row actions` buttons are one key, and the ref is the
86
+ // only thing that says which of them left — an end-of-list surplus would name an arbitrary one.
87
+ const survived = new Set(now);
88
+ const wentOrChanged = was.filter((text) => !survived.has(text));
89
+ const arrivedOrChanged = now.filter((text) => !new Set(was).has(text));
90
+ // Whatever is left over on both sides is the SAME element with a re-minted ref (a re-render, or a
91
+ // navigation that restarts the sequence). Pairing those off is what stops a ref change reading as
92
+ // a removal plus an addition; only the count difference is a real structural change.
93
+ const paired = Math.min(wentOrChanged.length, arrivedOrChanged.length);
94
+ removed.push(...wentOrChanged.slice(paired));
95
+ added.push(...arrivedOrChanged.slice(paired));
96
+ }
97
+ return { removed, added };
98
+ }
99
+ /**
100
+ * Where focus sits now: the line to show, plus the identity to compare it BY.
101
+ *
102
+ * These have to be two different things. Comparing the rendered lines said focus had moved whenever
103
+ * the focused element merely changed — typing into a focused textbox rewrites its `[value=...]`, so
104
+ * `from` and `to` came back naming the same element with different text. Focus identity is the
105
+ * ELEMENT, so the comparison is on the ref, and the line is only what gets displayed. Where there is
106
+ * no ref to compare (a non-interactive line), the ref-stripped key stands in.
107
+ *
108
+ * Read from the raw tree so a line the diff filters out cannot silently swallow the focus report.
109
+ */
110
+ function focusedLine(tree) {
111
+ const focused = tree.split('\n').find((line) => isFocused(line));
112
+ if (focused === undefined)
113
+ return undefined;
114
+ const text = withoutFocus(focused).trim();
115
+ return { id: REF_MARKER.exec(focused)?.[0]?.trim() ?? deltaKey(focused), text };
116
+ }
16
117
  export const SnapshotDeltaMode = {
17
118
  FULL: 'full',
18
119
  DELTA: 'delta',
@@ -22,12 +123,25 @@ export const SnapshotDeltaMode = {
22
123
  export function snapshotDelta(prevTree, nextTree) {
23
124
  if (prevTree === undefined)
24
125
  return { mode: SnapshotDeltaMode.FULL };
25
- const { added, removed } = diffLines(normalizeLines(prevTree), normalizeLines(nextTree));
26
- if (0 === added.length && 0 === removed.length)
27
- return { mode: SnapshotDeltaMode.UNCHANGED };
126
+ const { added, removed } = diffKeyed(deltaLines(prevTree), deltaLines(nextTree));
127
+ const before = focusedLine(prevTree);
128
+ const now = focusedLine(nextTree);
129
+ // Reported only on a MOVE. Repeating "focus is still here" every turn is the noise the delta exists
130
+ // to remove, and an absent field is how the agent knows nothing happened.
131
+ const focusChanged = before?.id === now?.id
132
+ ? undefined
133
+ : {
134
+ ...(before === undefined ? {} : { from: before.text }),
135
+ ...(now === undefined ? {} : { to: now.text }),
136
+ };
137
+ const moved = focusChanged === undefined ? {} : { focusChanged };
138
+ if (0 === added.length && 0 === removed.length) {
139
+ return { mode: SnapshotDeltaMode.UNCHANGED, ...moved };
140
+ }
28
141
  return {
29
142
  mode: SnapshotDeltaMode.DELTA,
30
143
  delta: { added, removed, addedCount: added.length, removedCount: removed.length },
144
+ ...moved,
31
145
  };
32
146
  }
33
147
  const DEFAULT_MAX_ENTRIES = 50;
@@ -105,8 +219,18 @@ export function applySnapshotDelta(raw, opts, cache) {
105
219
  // statement about the cap, not about the page. Carried through on both branches: a delta computed
106
220
  // over a prefix is real but not exhaustive either.
107
221
  const capped = true === r['truncated'] ? { truncated: true, note: CAPPED_DIFF_NOTE } : {};
222
+ // Spread onto BOTH branches. A value computed correctly and declared nowhere on the way out is the
223
+ // defect shape that hit this release five separate times: nothing throws, no test reddens, the
224
+ // field is simply absent forever.
225
+ const moved = decision.focusChanged === undefined ? {} : { focusChanged: decision.focusChanged };
108
226
  if (decision.mode === SnapshotDeltaMode.UNCHANGED) {
109
- return { mode: SnapshotDeltaMode.UNCHANGED, status: r['status'], ...capped };
227
+ return { mode: SnapshotDeltaMode.UNCHANGED, status: r['status'], ...moved, ...capped };
110
228
  }
111
- return { mode: SnapshotDeltaMode.DELTA, delta: decision.delta, status: r['status'], ...capped };
229
+ return {
230
+ mode: SnapshotDeltaMode.DELTA,
231
+ delta: decision.delta,
232
+ status: r['status'],
233
+ ...moved,
234
+ ...capped,
235
+ };
112
236
  }
@@ -84,11 +84,58 @@ export declare const sessionIdShape: {
84
84
  sessionId: z.ZodOptional<z.ZodString>;
85
85
  };
86
86
  /**
87
- * Fields that `runTool` / `withControl` splice onto EVERY session-bound tool result at runtime
88
- * (health, pool lease reminder, age cleanup nudge, and the delivered-once human-control envelope).
89
- * They are declared here and merged into each session-bound tool's outputSchema so a schema-strict
90
- * client (structuredContent validation) keeps them instead of silently dropping them — the `control`
91
- * envelope is the human-in-the-loop guidance channel, so losing it is a safety failure, not cosmetic.
87
+ * Every key `runTool` splices onto a session-bound result, as a closed list.
88
+ *
89
+ * These are wire strings and were inlined at the splice sites, which is exactly how four of them
90
+ * came to be spliced and never declared. `sessionEnvelopeShape` is derived from this object and
91
+ * `envelope-keys-declared.test.ts` fails if a member is missing from it, so the two cannot drift.
92
+ *
93
+ * Each of these is a channel that exists BECAUSE the agent had no other way to learn the fact.
94
+ * Undeclared, a schema-strict client validating `structuredContent` drops it, and the channel is
95
+ * indistinguishable from never having been built.
96
+ */
97
+ export declare const EnvelopeKey: {
98
+ /** Session health — which tab, how fresh. */
99
+ readonly SESSION: "session";
100
+ /** One-shot pool-lease reminder. */
101
+ readonly SESSION_LEASE: "session_lease";
102
+ /** One-shot "these sessions are old, clean them up". */
103
+ readonly SESSION_AGE_WARNING: "session_age_warning";
104
+ /** The delivered-once human-in-the-loop guidance envelope. Losing it is a safety failure. */
105
+ readonly CONTROL: "control";
106
+ /**
107
+ * Rides alongside `session` whenever the tab is THROTTLED. It was declared on `reticle_act`'s
108
+ * schema and nowhere else, so on a validating profile every other session-bound tool silently
109
+ * dropped it — a throttled tab, where drives can no-op, returned a healthy-looking result.
110
+ */
111
+ readonly WARNING: "warning";
112
+ /** One-shot "ask the human how this went". */
113
+ readonly FEEDBACK_PROMPT: "feedback_prompt";
114
+ /** One-shot "a newer Reticle exists". */
115
+ readonly UPDATE_AVAILABLE: "update_available";
116
+ /**
117
+ * "You have driven this page N times without asking for a verdict."
118
+ *
119
+ * The largest known lever on the metric this product is judged by: verdict-less sessions
120
+ * overwhelmingly never call a verdict-producing tool at all. It was spliced undeclared, so on a
121
+ * validating client the nudge was built, fired, and thrown away before any agent saw it.
122
+ */
123
+ readonly VERIFY_NEXT: "verify_next";
124
+ /** The contextual "tell us what just went wrong" invitation. Same story, same release. */
125
+ readonly FEEDBACK_INVITE: "feedback_invite";
126
+ /** SDK/daemon version skew — often the one fact that explains everything else in the session. */
127
+ readonly VERSION_SKEW: "version_skew";
128
+ /** A feedback report that was accepted and then failed to send. Only the reporter can act on it. */
129
+ readonly FEEDBACK_UNDELIVERED: "feedback_undelivered";
130
+ };
131
+ export type EnvelopeKey = (typeof EnvelopeKey)[keyof typeof EnvelopeKey];
132
+ /**
133
+ * Fields that `runTool` / `withControl` splice onto EVERY session-bound tool result at runtime.
134
+ * Merged into each session-bound tool's outputSchema so a schema-strict client (structuredContent
135
+ * validation) keeps them instead of silently dropping them.
136
+ *
137
+ * Derived from `EnvelopeKey` rather than re-listed: this shape and the splice sites disagreed for a
138
+ * whole release, and every key that went missing was a channel nobody could see was missing.
92
139
  */
93
140
  export declare const sessionEnvelopeShape: z.ZodRawShape;
94
141
  /** Unwrap a browser command result or throw its error so the agent sees a clean failure. */