@reticlehq/server 2.5.0 → 2.7.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 (182) hide show
  1. package/SKILL.md +226 -26
  2. package/dist/bridge/bridge.d.ts +18 -1
  3. package/dist/bridge/bridge.js +59 -6
  4. package/dist/bridge/bridge.test-harness.d.ts +8 -0
  5. package/dist/bridge/bridge.test-harness.js +9 -1
  6. package/dist/cli/chromium-hint.d.ts +40 -0
  7. package/dist/cli/chromium-hint.js +75 -0
  8. package/dist/cli/cli-doctor.d.ts +0 -5
  9. package/dist/cli/cli-doctor.js +74 -14
  10. package/dist/cli/cli-launch.d.ts +1 -0
  11. package/dist/cli/cli-launch.js +4 -1
  12. package/dist/cli/cli-parse-feedback.d.ts +31 -0
  13. package/dist/cli/cli-parse-feedback.js +56 -0
  14. package/dist/cli/cli-parse.d.ts +5 -9
  15. package/dist/cli/cli-parse.js +7 -23
  16. package/dist/cli/cli-port.js +20 -1
  17. package/dist/cli/doctor-daemon-line.d.ts +37 -0
  18. package/dist/cli/doctor-daemon-line.js +44 -0
  19. package/dist/cli/port-holder.d.ts +43 -0
  20. package/dist/cli/port-holder.js +99 -0
  21. package/dist/cli.js +170 -19
  22. package/dist/daemon/daemon-resilience.d.ts +24 -0
  23. package/dist/daemon/daemon-resilience.js +105 -17
  24. package/dist/daemon/daemon.js +26 -1
  25. package/dist/daemon/heartbeat.d.ts +115 -0
  26. package/dist/daemon/heartbeat.js +141 -0
  27. package/dist/daemon/idle-shutdown.d.ts +13 -5
  28. package/dist/daemon/idle-shutdown.js +14 -6
  29. package/dist/daemon/loopback-alias.d.ts +32 -0
  30. package/dist/daemon/loopback-alias.js +80 -0
  31. package/dist/daemon/port-presence.d.ts +76 -0
  32. package/dist/daemon/port-presence.js +93 -0
  33. package/dist/daemon/state-dir.d.ts +7 -0
  34. package/dist/daemon/state-dir.js +59 -0
  35. package/dist/daemon/unclean-predecessor.d.ts +23 -0
  36. package/dist/daemon/unclean-predecessor.js +27 -0
  37. package/dist/events/predicate-eval.d.ts +17 -3
  38. package/dist/events/predicate-eval.js +183 -96
  39. package/dist/events/predicate-parse.d.ts +23 -0
  40. package/dist/events/predicate-parse.js +68 -0
  41. package/dist/events/predicate.d.ts +5 -0
  42. package/dist/events/predicate.js +17 -3
  43. package/dist/events/ring-buffer.d.ts +8 -0
  44. package/dist/events/ring-buffer.js +35 -17
  45. package/dist/flows/flow-replay.d.ts +1 -1
  46. package/dist/flows/flow-replay.js +19 -2
  47. package/dist/flows/flow-step-runners.js +2 -3
  48. package/dist/flows/flow-tools.js +10 -14
  49. package/dist/flows/role-drift-reason.d.ts +20 -0
  50. package/dist/flows/role-drift-reason.js +39 -0
  51. package/dist/flows/suite-flakes.d.ts +33 -0
  52. package/dist/flows/suite-flakes.js +32 -0
  53. package/dist/honesty/blind-spots.d.ts +19 -0
  54. package/dist/honesty/blind-spots.js +33 -0
  55. package/dist/honesty/honesty.d.ts +11 -0
  56. package/dist/honesty/honesty.js +9 -1
  57. package/dist/honesty/verified.d.ts +10 -1
  58. package/dist/honesty/verified.js +42 -3
  59. package/dist/http-server.js +20 -1
  60. package/dist/index.d.ts +1 -0
  61. package/dist/index.js +36 -3
  62. package/dist/init/agent-rules.d.ts +18 -0
  63. package/dist/init/agent-rules.js +28 -5
  64. package/dist/init/cra.d.ts +13 -0
  65. package/dist/init/cra.js +16 -0
  66. package/dist/init/detect.d.ts +14 -0
  67. package/dist/init/detect.js +23 -0
  68. package/dist/init/mcp-clients.d.ts +81 -0
  69. package/dist/init/mcp-clients.js +248 -0
  70. package/dist/init/node-io.js +8 -2
  71. package/dist/init/non-js-project.d.ts +27 -0
  72. package/dist/init/non-js-project.js +60 -0
  73. package/dist/init/plan-framework.d.ts +9 -0
  74. package/dist/init/plan-framework.js +55 -3
  75. package/dist/init/plan.d.ts +14 -0
  76. package/dist/init/plan.js +93 -12
  77. package/dist/init/run.d.ts +18 -1
  78. package/dist/init/run.js +169 -23
  79. package/dist/init/snippets.d.ts +20 -1
  80. package/dist/init/snippets.js +71 -6
  81. package/dist/init/windows-quote.d.ts +38 -0
  82. package/dist/init/windows-quote.js +70 -0
  83. package/dist/mcp/mcp-outage.d.ts +1 -0
  84. package/dist/mcp/mcp-outage.js +8 -1
  85. package/dist/mcp/mcp-proxy.d.ts +11 -1
  86. package/dist/mcp/mcp-proxy.js +39 -9
  87. package/dist/mcp/mcp.js +64 -2
  88. package/dist/mcp/proxy-lifecycle.d.ts +19 -0
  89. package/dist/mcp/proxy-lifecycle.js +21 -0
  90. package/dist/pool/browser-pool.d.ts +9 -0
  91. package/dist/pool/browser-pool.js +38 -9
  92. package/dist/pool/playwright-launcher.js +10 -2
  93. package/dist/session/attachment-history.d.ts +46 -0
  94. package/dist/session/attachment-history.js +65 -0
  95. package/dist/session/live-control-tools.js +39 -2
  96. package/dist/session/live-control.d.ts +7 -9
  97. package/dist/session/live-control.js +34 -1
  98. package/dist/session/no-session-diagnosis.d.ts +11 -4
  99. package/dist/session/no-session-diagnosis.js +95 -14
  100. package/dist/session/no-session-watch.d.ts +3 -1
  101. package/dist/session/no-session-watch.js +10 -2
  102. package/dist/session/pending-commands.js +1 -0
  103. package/dist/session/review-store.d.ts +19 -0
  104. package/dist/session/review-store.js +34 -5
  105. package/dist/session/session-info.d.ts +17 -1
  106. package/dist/session/session-info.js +5 -1
  107. package/dist/session/session-manager.d.ts +12 -0
  108. package/dist/session/session-manager.js +83 -3
  109. package/dist/session/session.d.ts +9 -0
  110. package/dist/session/session.js +20 -0
  111. package/dist/status-payload.d.ts +13 -1
  112. package/dist/status-payload.js +6 -1
  113. package/dist/telemetry/app-instrumented.d.ts +37 -0
  114. package/dist/telemetry/app-instrumented.js +54 -0
  115. package/dist/telemetry/crash-cause.d.ts +47 -0
  116. package/dist/telemetry/crash-cause.js +104 -0
  117. package/dist/telemetry/daemon-telemetry.d.ts +25 -7
  118. package/dist/telemetry/daemon-telemetry.js +32 -13
  119. package/dist/telemetry/error-class.d.ts +26 -0
  120. package/dist/telemetry/error-class.js +52 -0
  121. package/dist/telemetry/feedback-cli.d.ts +7 -2
  122. package/dist/telemetry/feedback-cli.js +49 -5
  123. package/dist/telemetry/feedback-context.d.ts +1 -6
  124. package/dist/telemetry/feedback-context.js +64 -4
  125. package/dist/telemetry/git-facts.d.ts +7 -0
  126. package/dist/telemetry/git-facts.js +4 -4
  127. package/dist/telemetry/project-profile.js +2 -1
  128. package/dist/telemetry/session-metrics.d.ts +45 -3
  129. package/dist/telemetry/session-metrics.js +247 -9
  130. package/dist/telemetry/telemetry.d.ts +21 -1
  131. package/dist/telemetry/telemetry.js +34 -9
  132. package/dist/telemetry/verification-of.js +26 -1
  133. package/dist/tools/act-tools.js +63 -14
  134. package/dist/tools/assert-verdict.d.ts +7 -1
  135. package/dist/tools/assert-verdict.js +22 -3
  136. package/dist/tools/browser-tools.js +8 -2
  137. package/dist/tools/contract-tools.d.ts +0 -5
  138. package/dist/tools/contract-tools.js +26 -0
  139. package/dist/tools/dynamic-tools.js +6 -1
  140. package/dist/tools/error-recovery.d.ts +14 -0
  141. package/dist/tools/error-recovery.js +89 -8
  142. package/dist/tools/feedback-invite.d.ts +48 -0
  143. package/dist/tools/feedback-invite.js +71 -0
  144. package/dist/tools/invoke-tool.d.ts +0 -6
  145. package/dist/tools/invoke-tool.js +76 -7
  146. package/dist/tools/navigate-arrival.d.ts +26 -0
  147. package/dist/tools/navigate-arrival.js +60 -0
  148. package/dist/tools/navigate-result.d.ts +15 -1
  149. package/dist/tools/navigate-result.js +17 -10
  150. package/dist/tools/observe-tools.js +22 -4
  151. package/dist/tools/real-input-attempt.js +10 -5
  152. package/dist/tools/tool-kit.d.ts +60 -5
  153. package/dist/tools/tool-kit.js +55 -24
  154. package/dist/tools/tool-surface.js +13 -0
  155. package/dist/tools/tools.js +22 -0
  156. package/dist/visual/capture-cleanup.d.ts +16 -0
  157. package/dist/visual/capture-cleanup.js +39 -0
  158. package/dist/visual/visual-tools.d.ts +18 -0
  159. package/dist/visual/visual-tools.js +24 -5
  160. package/docs/README.md +55 -0
  161. package/docs/agent-cheatsheet.md +15 -3
  162. package/docs/benchmarks.md +1 -1
  163. package/docs/fixtures-dispatch-receiver.yml +76 -0
  164. package/docs/fixtures.md +17 -0
  165. package/docs/gate-plan.md +98 -0
  166. package/docs/gates.md +117 -0
  167. package/docs/local-registry.md +1 -1
  168. package/docs/matrix/2.5.0/claude-code-machine.json +19 -0
  169. package/docs/matrix/2.5.0/codex-machine.json +20 -0
  170. package/docs/matrix/2.5.0/cursor-machine.json +22 -0
  171. package/docs/matrix/2.5.0/gemini-machine.json +22 -0
  172. package/docs/matrix/2.5.0/opencode-machine.json +22 -0
  173. package/docs/matrix/2.5.0/vscode-machine.json +22 -0
  174. package/docs/matrix/2.5.0/windsurf-machine.json +22 -0
  175. package/docs/matrix/MATRIX.md +26 -0
  176. package/docs/matrix/README.md +59 -0
  177. package/docs/mint.json +1 -1
  178. package/docs/system-map.md +152 -0
  179. package/docs/telemetry-contract.md +15 -3
  180. package/docs/telemetry.md +16 -1
  181. package/docs/token-efficiency.md +2 -2
  182. package/package.json +2 -2
@@ -7,10 +7,4 @@ export declare const SESSION_BOUND_TOOLS: ReadonlySet<string>;
7
7
  * `sessionId`-bearing tool to be classified into exactly one set (bound XOR exempt).
8
8
  */
9
9
  export declare const SESSION_EXEMPT_TOOLS: ReadonlySet<string>;
10
- /**
11
- * The single entry point both the MCP server and the programmatic invoker call instead of
12
- * `tool.handler` directly. Runs the handler, then — for a live-session tool returning a plain
13
- * object that did not already include `session` — splices the health envelope on. Idempotent
14
- * (handlers that already add health are left untouched) and never alters non-object results.
15
- */
16
10
  export declare function runTool(tool: ToolDef, deps: ToolDeps, args: Record<string, unknown>): Promise<unknown>;
@@ -1,5 +1,5 @@
1
1
  import { healthEnvelope } from '../session/session-health.js';
2
- import { TelemetryActor, TelemetryEventKind } from '@reticlehq/core';
2
+ import { TelemetryActor, TelemetryEventKind, TRANSPORT_LIMITS, } 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';
@@ -8,10 +8,12 @@ import { noteToolCall } from '../daemon/daemon-usefulness.js';
8
8
  import { bugsInResult } from '../telemetry/bug-found.js';
9
9
  import { verificationOf } from '../telemetry/verification-of.js';
10
10
  import { asString } from './tools-helpers.js';
11
+ import { EnvelopeKey } from './tool-kit.js';
11
12
  import { ReticleTool } from './tool-names.js';
12
13
  import { takeFeedbackPrompt } from './feedback-tools.js';
13
14
  import { takeFeedbackUndelivered } from '../telemetry/feedback-delivery.js';
14
15
  import { span } from '../trace.js';
16
+ import { frictionOf, inviteFor } from './feedback-invite.js';
15
17
  /**
16
18
  * The live-session tools whose result MUST carry the
17
19
  * session-health envelope. Owned in ONE place — not retrofitted per handler — so a throttled tab
@@ -138,6 +140,27 @@ function reportBugsFound(toolName, result) {
138
140
  * object that did not already include `session` — splices the health envelope on. Idempotent
139
141
  * (handlers that already add health are left untouched) and never alters non-object results.
140
142
  */
143
+ /**
144
+ * The name of the first argument whose string value exceeds the transport bound, if any.
145
+ *
146
+ * One level of nesting is searched because that is where callers put payloads — `reticle_act`'s
147
+ * `args.value`, and the fuzz's own `huge-string` shape. Deeper recursion would cost more than it
148
+ * buys: the failure is a single huge string, not a deeply nested structure of small ones.
149
+ */
150
+ function firstOversizedArg(args) {
151
+ const tooLong = (value) => 'string' === typeof value && value.length > TRANSPORT_LIMITS.MAX_STRING_LENGTH;
152
+ for (const [key, value] of Object.entries(args)) {
153
+ if (tooLong(value))
154
+ return key;
155
+ if ('object' === typeof value && null !== value && !Array.isArray(value)) {
156
+ for (const [inner, nested] of Object.entries(value)) {
157
+ if (tooLong(nested))
158
+ return `${key}.${inner}`;
159
+ }
160
+ }
161
+ }
162
+ return undefined;
163
+ }
141
164
  export async function runTool(tool, deps, args) {
142
165
  // Both dispatch paths (MCP + programmatic) pass through here — the one place "which tool is mostly
143
166
  // used" can be counted. This used to EMIT an event per call; it now increments an in-process counter
@@ -150,6 +173,28 @@ export async function runTool(tool, deps, args) {
150
173
  // closure carries this call's own identity, which is also what makes peak-concurrency measurable.
151
174
  // A daemon that has served even one tool call is doing a job for somebody; see daemon-usefulness.
152
175
  noteToolCall();
176
+ // An oversized argument is refused BEFORE the handler deserialises it.
177
+ //
178
+ // tool-fuzz failed CI on the invariant that matters most — `every tool answers every hostile call`
179
+ // — for a ~100KB string. Seen twice with different tools; the earlier one came back
180
+ // `-32001 sse_aborted` with the tool surface dropping 48→17, which is a RESTARTED daemon. The
181
+ // shape is: oversized argument → the daemon dies → the proxy answers the in-flight call with
182
+ // transport loss, and the next assertion is talking to a different daemon.
183
+ //
184
+ // `TRANSPORT_LIMITS.MAX_STRING_LENGTH` already bounds what crosses the bridge; a tool argument
185
+ // arrives over MCP stdio and never met it. Here, because this is the one place every invocation
186
+ // routes through, so a tool added later inherits the bound instead of having to remember it.
187
+ //
188
+ // A refusal is a normal answer. An UNANSWERED call is a hung agent, and that is the failure this
189
+ // prevents: the agent learns its argument was too big, which is something it can fix.
190
+ const oversized = firstOversizedArg(args);
191
+ if (oversized !== undefined) {
192
+ return {
193
+ error: `argument '${oversized}' is larger than ${String(TRANSPORT_LIMITS.MAX_STRING_LENGTH)} ` +
194
+ 'characters, which is the most Reticle moves in one call. Nothing ran. Send a smaller ' +
195
+ 'value — a selector or an id rather than a document, or a slice of the text you need.',
196
+ };
197
+ }
153
198
  // An ACTION is what gets abandoned. Counted here, against verifications, so "the agent drove the
154
199
  // page and then wandered off" becomes a number instead of an impression.
155
200
  if (ACTION_TOOLS.has(tool.name))
@@ -218,12 +263,12 @@ export async function runTool(tool, deps, args) {
218
263
  ? raw
219
264
  : {
220
265
  ...raw,
221
- ...(prompt !== undefined ? { feedback_prompt: prompt } : {}),
222
- ...(update !== undefined ? { update_available: update } : {}),
223
- ...(skew !== undefined ? { version_skew: skew } : {}),
266
+ ...(prompt !== undefined ? { [EnvelopeKey.FEEDBACK_PROMPT]: prompt } : {}),
267
+ ...(update !== undefined ? { [EnvelopeKey.UPDATE_AVAILABLE]: update } : {}),
268
+ ...(skew !== undefined ? { [EnvelopeKey.VERSION_SKEW]: skew } : {}),
224
269
  ...(undelivered !== undefined
225
270
  ? {
226
- feedback_undelivered: `your earlier report did NOT send: ${undelivered}. Tell the human what you found so it is not lost.`,
271
+ [EnvelopeKey.FEEDBACK_UNDELIVERED]: `your earlier report did NOT send: ${undelivered}. Tell the human what you found so it is not lost.`,
227
272
  }
228
273
  : {}),
229
274
  };
@@ -242,9 +287,33 @@ export async function runTool(tool, deps, args) {
242
287
  // one-time pool-lease reminder or the age cleanup nudge. Splice them regardless.
243
288
  const lease = resolved.takeSessionLease();
244
289
  if (lease !== undefined)
245
- envelope['session_lease'] = lease;
290
+ envelope[EnvelopeKey.SESSION_LEASE] = lease;
246
291
  const warning = resolved.ageWarning();
247
292
  if (warning !== undefined)
248
- envelope['session_age_warning'] = warning;
293
+ envelope[EnvelopeKey.SESSION_AGE_WARNING] = warning;
294
+ // Ask for a verdict when the agent has driven the page and not asked for one. Almost every
295
+ // verdict-less session in the field never called a verdict-producing tool ONCE;
296
+ // the counter behind this already existed and was reported only to us. One-shot per abandoned
297
+ // run, same discipline as the pool lease — a hint on every call is noise that gets tuned out.
298
+ const unverified = getSessionMetrics().takeUnverifiedNudge();
299
+ if (unverified !== undefined)
300
+ envelope[EnvelopeKey.VERIFY_NEXT] = unverified;
301
+ // Invite feedback at the moment of friction, worded for what just happened. Feedback produced
302
+ // ~11 of this release's fixes from 14 reports; the handshake instruction is what got those 14,
303
+ // and this closes the gap it leaves — the agent mid-problem, not thinking about feedback tooling.
304
+ // Counted so `feedback_submitted / feedbackPrompted` can tell us whether it works at all.
305
+ const friction = frictionOf({
306
+ unknownTool: false,
307
+ verifiedUnknown: 'unknown' === result.verified,
308
+ repeatRun: getSessionMetrics().currentRun,
309
+ errored: 'error' in result,
310
+ // A verdict is progress. Without this, three successful act_and_wait calls in a row — the exact
311
+ // loop this release exists to encourage — get told they are stuck.
312
+ producedVerdict: 'verified' in result,
313
+ });
314
+ if (friction !== undefined) {
315
+ envelope[EnvelopeKey.FEEDBACK_INVITE] = inviteFor(friction);
316
+ getSessionMetrics().recordFeedbackPrompt();
317
+ }
249
318
  return Object.keys(envelope).length > 0 ? { ...result, ...envelope } : result;
250
319
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Wait, briefly, for the SDK to reconnect after a navigation — so `confirmed` can mean something.
3
+ *
4
+ * `window.location.assign(url)` destroys the SDK that would report on the new document, so the
5
+ * navigation itself cannot be confirmed from inside the page. But the SDK reconnects TO THE DAEMON,
6
+ * which makes the daemon the only party that can see arrival happen. Before this, it did not look,
7
+ * and told the agent to poll `reticle_sessions` instead — a tool call on every navigation, on the
8
+ * least reliable tool we ship.
9
+ *
10
+ * Bounded and best-effort by construction: a navigation to a page that is not instrumented, or not
11
+ * there at all, must still return promptly with `confirmed:false` rather than hanging. The clock and
12
+ * the sleep are injected so this is testable without waiting on a real one.
13
+ */
14
+ import type { SessionManager } from '../session/session-manager.js';
15
+ import type { NavigateArrival } from './navigate-result.js';
16
+ /** Long enough for a dev server to serve a page and the SDK to dial back; short enough to not hang. */
17
+ export declare const ARRIVAL_TIMEOUT_MS = 5000;
18
+ export interface ArrivalClock {
19
+ now: () => number;
20
+ sleep: (ms: number) => Promise<void>;
21
+ }
22
+ /**
23
+ * Poll until a session is present at `target`, or the budget runs out. Returns `null` on timeout —
24
+ * never throws, because a failure to confirm is a legitimate answer, not an error.
25
+ */
26
+ export declare function awaitArrival(sessions: SessionManager, target: string, timeoutMs?: number, clock?: ArrivalClock): Promise<NavigateArrival | null>;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Wait, briefly, for the SDK to reconnect after a navigation — so `confirmed` can mean something.
3
+ *
4
+ * `window.location.assign(url)` destroys the SDK that would report on the new document, so the
5
+ * navigation itself cannot be confirmed from inside the page. But the SDK reconnects TO THE DAEMON,
6
+ * which makes the daemon the only party that can see arrival happen. Before this, it did not look,
7
+ * and told the agent to poll `reticle_sessions` instead — a tool call on every navigation, on the
8
+ * least reliable tool we ship.
9
+ *
10
+ * Bounded and best-effort by construction: a navigation to a page that is not instrumented, or not
11
+ * there at all, must still return promptly with `confirmed:false` rather than hanging. The clock and
12
+ * the sleep are injected so this is testable without waiting on a real one.
13
+ */
14
+ /** Long enough for a dev server to serve a page and the SDK to dial back; short enough to not hang. */
15
+ export const ARRIVAL_TIMEOUT_MS = 5_000;
16
+ const POLL_MS = 100;
17
+ /**
18
+ * Compare only origin + pathname.
19
+ *
20
+ * The app is entitled to add or rewrite the query and hash on arrival — a redirect to
21
+ * `?redirect=%2F`, a router normalising a trailing slash, an auth guard appending a reason. Matching
22
+ * the whole URL string would report `confirmed:false` for a navigation that plainly succeeded, which
23
+ * is the same lie in the opposite direction.
24
+ */
25
+ function samePage(a, b) {
26
+ try {
27
+ const x = new URL(a);
28
+ const y = new URL(b);
29
+ return x.origin === y.origin && x.pathname.replace(/\/$/, '') === y.pathname.replace(/\/$/, '');
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ function findArrival(sessions, target) {
36
+ for (const s of sessions.all()) {
37
+ if (samePage(s.url, target))
38
+ return { sessionId: s.id };
39
+ }
40
+ return null;
41
+ }
42
+ const REAL_CLOCK = {
43
+ now: () => Date.now(),
44
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
45
+ };
46
+ /**
47
+ * Poll until a session is present at `target`, or the budget runs out. Returns `null` on timeout —
48
+ * never throws, because a failure to confirm is a legitimate answer, not an error.
49
+ */
50
+ export async function awaitArrival(sessions, target, timeoutMs = ARRIVAL_TIMEOUT_MS, clock = REAL_CLOCK) {
51
+ const deadline = clock.now() + timeoutMs;
52
+ for (;;) {
53
+ const found = findArrival(sessions, target);
54
+ if (found !== null)
55
+ return found;
56
+ if (clock.now() >= deadline)
57
+ return null;
58
+ await clock.sleep(POLL_MS);
59
+ }
60
+ }
@@ -10,8 +10,22 @@
10
10
  * `reticle_act` already draws this line — `dispatched` (sent) versus `settled` (a frame flushed).
11
11
  * This gives navigate the same honesty without changing what it does.
12
12
  */
13
+ /** The session the SDK reconnected as after the page moved, when one was observed in the window. */
14
+ export interface NavigateArrival {
15
+ sessionId: string;
16
+ }
13
17
  export declare function navigateResult(result: {
14
18
  ok?: unknown;
15
19
  url?: unknown;
16
20
  reason?: unknown;
17
- }): Record<string, unknown>;
21
+ },
22
+ /**
23
+ * Arrival, if the daemon saw the SDK come back. `null` keeps the original honest envelope.
24
+ *
25
+ * Measured on three fixtures: `confirmed` was `false` on every navigation on every app, because
26
+ * nothing ever set it. A boolean that never varies teaches an agent nothing, and an agent that
27
+ * learns to ignore it will keep ignoring it when it starts meaning something. The daemon is the
28
+ * one place that CAN answer this — the SDK reconnects to it — so making it answer removes a
29
+ * `reticle_sessions` poll from every single navigation.
30
+ */
31
+ arrival?: NavigateArrival | null): Record<string, unknown>;
@@ -1,16 +1,14 @@
1
+ export function navigateResult(result,
1
2
  /**
2
- * The honest envelope for a navigation nobody can confirm.
3
+ * Arrival, if the daemon saw the SDK come back. `null` keeps the original honest envelope.
3
4
  *
4
- * `window.location.assign(url)` returns before the page moves, and the SDK that would report on the
5
- * new document is destroyed by the navigation itself. So a successful NAVIGATE means the browser
6
- * accepted the instruction — not that anything arrived. Driven against a dead URL it returned
7
- * `{"ok":true}` while the session died, which is the worst combination available: the agent believes
8
- * it navigated, is looking at nothing, and has lost Reticle.
9
- *
10
- * `reticle_act` already draws this line — `dispatched` (sent) versus `settled` (a frame flushed).
11
- * This gives navigate the same honesty without changing what it does.
5
+ * Measured on three fixtures: `confirmed` was `false` on every navigation on every app, because
6
+ * nothing ever set it. A boolean that never varies teaches an agent nothing, and an agent that
7
+ * learns to ignore it will keep ignoring it when it starts meaning something. The daemon is the
8
+ * one place that CAN answer this — the SDK reconnects to it — so making it answer removes a
9
+ * `reticle_sessions` poll from every single navigation.
12
10
  */
13
- export function navigateResult(result) {
11
+ arrival = null) {
14
12
  const ok = true === result.ok;
15
13
  const base = {
16
14
  ok,
@@ -20,6 +18,15 @@ export function navigateResult(result) {
20
18
  // A refusal is conclusive: the page never moved, so there is nothing unconfirmed to report.
21
19
  if (!ok)
22
20
  return base;
21
+ if (arrival !== null) {
22
+ return {
23
+ ...base,
24
+ confirmed: true,
25
+ // The SDK that reconnects after a navigation is a NEW session with a new id. Returning it here
26
+ // is the difference between a confirmation and a confirmation the agent can act on.
27
+ sessionId: arrival.sessionId,
28
+ };
29
+ }
23
30
  return {
24
31
  ...base,
25
32
  confirmed: false,
@@ -10,6 +10,7 @@ import { ReticleTool } from './tool-names.js';
10
10
  import { buildReactionReport } from '../events/reaction.js';
11
11
  import { findContradictions } from '../events/contradictions.js';
12
12
  import { evaluatePredicate, waitForPredicate, PredicateSchema } from '../events/predicate.js';
13
+ import { parsePredicate } from '../events/predicate-parse.js';
13
14
  import { matchNet, matchConsole, isConsoleEvent, eventMatchesFilters, netEmptyHint, consoleEmptyHint, reconcileNet, projectNetCall, projectConsoleLog, } from '../events/event-filters.js';
14
15
  import { applyEventBudget, costHint, withSizeCost, DEFAULT_QUERY_LIMIT, } from '../session/output-budget.js';
15
16
  import { healthEnvelope, bufferEnvelope } from '../session/session-health.js';
@@ -209,6 +210,10 @@ export const OBSERVE_TOOLS = [
209
210
  pass: z.boolean(),
210
211
  evidence: z.unknown().optional(),
211
212
  failureReason: z.string().optional(),
213
+ observationLost: z
214
+ .boolean()
215
+ .optional()
216
+ .describe('The tab disconnected mid-wait, so this was never observed — the verdict is UNKNOWN, not a failure of the app.'),
212
217
  session: z
213
218
  .object({ lastSeenMs: z.number(), throttled: z.boolean(), focused: z.boolean() })
214
219
  .optional(),
@@ -233,7 +238,7 @@ export const OBSERVE_TOOLS = [
233
238
  handler: async (deps, args) => {
234
239
  const session = deps.sessions.resolve(asString(args['sessionId']));
235
240
  // `until` is act_and_wait's name for this — see alias-args.ts.
236
- const predicate = PredicateSchema.parse(aliasParam(args, 'predicate', ['until'])['predicate']);
241
+ const predicate = parsePredicate(aliasParam(args, 'predicate', ['until'])['predicate']);
237
242
  // Honesty: explicit since wins; else default to the last act's cursor; else the whole buffer.
238
243
  const since = asNumber(args['since']) ?? session.lastAct.cursor() ?? 0;
239
244
  const verdict = await waitForPredicate(session, predicate, asNumber(args['timeout_ms']) ?? DEFAULT_ASSERT_TIMEOUT_MS, since);
@@ -252,7 +257,16 @@ export const OBSERVE_TOOLS = [
252
257
  example: { predicate: { kind: PredicateKind.SIGNAL, name: 'todos:loaded' } },
253
258
  description: 'Evaluate a predicate (optionally waiting up to timeout_ms). Returns { pass, evidence, failureReason? }. The end of every verify loop. Prefer a { signal } or { net } consequence over { element }/{ text } presence — a passing presence-only assertion returns `advice` because a wrong/healed element can fake it. By default it only counts events since your last act, so a stale buffered signal can never fake a pass; pass `since` (an observe/act cursor) to set the window explicitly.',
254
259
  inputSchema: {
255
- predicate: PredicateSchema.optional().describe('Predicate to evaluate: { signal }, { net }, { element } or a combination.'),
260
+ predicate: PredicateSchema.optional().describe(
261
+ // Every kind, each with the field that carries its argument. Five of the nine were
262
+ // undocumented here, including `route` — and "did submitting the login form navigate away"
263
+ // is the most common thing an agent wants to assert. A field report reached us from an agent
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 } ' +
266
+ '{ state, path|equals } { route, pathname (exact) | contains (path+query+hash) } ' +
267
+ '{ element, testid|role|text } { text } { console, level|absent } { animation, name } ' +
268
+ '{ settled } — combine with { allOf | anyOf | not }. Prefer a signal/net/state consequence ' +
269
+ 'over element/text presence.'),
256
270
  until: PredicateSchema.optional().describe("Alias for `predicate` (act_and_wait's name)."),
257
271
  timeout_ms: z
258
272
  .number()
@@ -268,6 +282,10 @@ export const OBSERVE_TOOLS = [
268
282
  pass: z.boolean(),
269
283
  evidence: z.unknown().optional(),
270
284
  failureReason: z.string().optional(),
285
+ observationLost: z
286
+ .boolean()
287
+ .optional()
288
+ .describe('The tab disconnected mid-wait, so this was never observed — the verdict is UNKNOWN, not a failure of the app.'),
271
289
  advice: z
272
290
  .string()
273
291
  .optional()
@@ -304,7 +322,7 @@ export const OBSERVE_TOOLS = [
304
322
  handler: async (deps, args) => {
305
323
  const session = deps.sessions.resolve(asString(args['sessionId']));
306
324
  // `until` is act_and_wait's name for this — see alias-args.ts.
307
- const predicate = PredicateSchema.parse(aliasParam(args, 'predicate', ['until'])['predicate']);
325
+ const predicate = parsePredicate(aliasParam(args, 'predicate', ['until'])['predicate']);
308
326
  const timeout = asNumber(args['timeout_ms']) ?? 0;
309
327
  // Honesty: explicit since wins; else default to the last act's cursor; else the whole buffer.
310
328
  const since = asNumber(args['since']) ?? session.lastAct.cursor() ?? 0;
@@ -323,7 +341,7 @@ export const OBSERVE_TOOLS = [
323
341
  : assertsDerivedIpcStatus(predicate)
324
342
  ? { advice: DERIVED_IPC_STATUS_ADVICE }
325
343
  : {};
326
- const { decision, contradictions, coverage } = await assertVerdict(session, predicate, verdict.pass, since, verdict.inconclusive);
344
+ const { decision, contradictions, coverage } = await assertVerdict(session, predicate, verdict.pass, since, verdict.inconclusive, verdict.observationLost);
327
345
  return withControl(session, {
328
346
  ...decision,
329
347
  ...verdict,
@@ -27,17 +27,22 @@ function synthetic(reason) {
27
27
  */
28
28
  export async function tryRealInput(deps, session, ref, action, args) {
29
29
  const provider = deps.realInput;
30
- if (provider === undefined)
31
- return synthetic(); // real input not configured — no diagnostic
30
+ const inner = asRecord(args['args']);
31
+ const askedForNative = true === inner[NATIVE_INPUT_ARG];
32
+ if (provider === undefined) {
33
+ // Silent by default: with no provider EVERY action is synthetic, and a reason on all of them is
34
+ // noise on the most-used tool in the product. But an agent that passed native:true asked a
35
+ // question and got the opposite answer — reported from the field as a silent downgrade that cost
36
+ // real debugging time, because the tool description promises a reason is "never silent".
37
+ return askedForNative ? synthetic(InputModeReason.NOT_CONFIGURED) : synthetic();
38
+ }
32
39
  if (!isPointerAction(action))
33
40
  return synthetic(InputModeReason.NOT_POINTER); // fill/type stay synthetic
34
- const inner = asRecord(args['args']);
35
41
  // "Don't click, run the code": a click/dblclick runs the occlusion-honest SYNTHETIC path by default
36
42
  // even with a provider configured — no coordinate gesture to be intercepted by the HUD or missed
37
43
  // off-screen. Opt into a trusted native click with args.native:true (file pickers, clipboard,
38
44
  // isTrusted-gated handlers). hover/drag genuinely need native pointer state, so they stay real.
39
- if ((action === ActionType.CLICK || action === ActionType.DBLCLICK) &&
40
- inner[NATIVE_INPUT_ARG] !== true) {
45
+ if ((action === ActionType.CLICK || action === ActionType.DBLCLICK) && !askedForNative) {
41
46
  return synthetic(InputModeReason.SYNTHETIC_CLICK_PREFERRED);
42
47
  }
43
48
  if (!(await provider.isAvailableFor(session.url)))
@@ -33,6 +33,14 @@ export interface ToolDeps {
33
33
  fs: FileSystemPort;
34
34
  /** absolute.reticle path (index.ts computes cwd/.reticle). */
35
35
  reticleRoot: string;
36
+ /**
37
+ * This daemon's OWN project id, derived from the directory it was started in.
38
+ *
39
+ * Optional because a daemon above an uninitialised directory has none, and because every existing
40
+ * test construction of ToolDeps predates it. Absence means "cannot tell", which must never be
41
+ * treated as a mismatch.
42
+ */
43
+ projectId?: string;
36
44
  /** injected clock for the contract's generatedAt stamp. */
37
45
  now: () => number;
38
46
  /**
@@ -76,11 +84,58 @@ export declare const sessionIdShape: {
76
84
  sessionId: z.ZodOptional<z.ZodString>;
77
85
  };
78
86
  /**
79
- * Fields that `runTool` / `withControl` splice onto EVERY session-bound tool result at runtime
80
- * (health, pool lease reminder, age cleanup nudge, and the delivered-once human-control envelope).
81
- * They are declared here and merged into each session-bound tool's outputSchema so a schema-strict
82
- * client (structuredContent validation) keeps them instead of silently dropping them — the `control`
83
- * 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.
84
139
  */
85
140
  export declare const sessionEnvelopeShape: z.ZodRawShape;
86
141
  /** Unwrap a browser command result or throw its error so the agent sees a clean failure. */
@@ -14,32 +14,63 @@ export const sessionIdShape = {
14
14
  .describe('OMIT THIS unless you mean a specific tab — Reticle scopes to your project, prefers the active one, and refuses rather than guesses when ambiguous. Pass an id from reticle_sessions only to target a particular tab. The previous wording ("omit when only ONE session is open") was wrong and cost real turns: with three sessions connected — an app plus two pool leases — resolution is still unambiguous, but that sentence sent the agent off to list and filter sessions by hand before every single call.'),
15
15
  };
16
16
  /**
17
- * Fields that `runTool` / `withControl` splice onto EVERY session-bound tool result at runtime
18
- * (health, pool lease reminder, age cleanup nudge, and the delivered-once human-control envelope).
19
- * They are declared here and merged into each session-bound tool's outputSchema so a schema-strict
20
- * client (structuredContent validation) keeps them instead of silently dropping them — the `control`
21
- * envelope is the human-in-the-loop guidance channel, so losing it is a safety failure, not cosmetic.
17
+ * Every key `runTool` splices onto a session-bound result, as a closed list.
18
+ *
19
+ * These are wire strings and were inlined at the splice sites, which is exactly how four of them
20
+ * came to be spliced and never declared. `sessionEnvelopeShape` is derived from this object and
21
+ * `envelope-keys-declared.test.ts` fails if a member is missing from it, so the two cannot drift.
22
+ *
23
+ * Each of these is a channel that exists BECAUSE the agent had no other way to learn the fact.
24
+ * Undeclared, a schema-strict client validating `structuredContent` drops it, and the channel is
25
+ * indistinguishable from never having been built.
22
26
  */
23
- export const sessionEnvelopeShape = {
24
- session: z.unknown().optional(),
25
- session_lease: z.unknown().optional(),
26
- session_age_warning: z.unknown().optional(),
27
- control: z.unknown().optional(),
28
- // `warning` rides alongside `session` whenever the tab is THROTTLED (healthEnvelope splices both).
29
- // It was declared on reticle_act's schema but nowhere else, so on a validating profile every other
30
- // session-bound tool (observe, assert, wait_for, act_sequence, act_and_wait, snapshot, query, …)
31
- // silently dropped it — a throttled tab, where drives can no-op, returned a healthy-looking result.
32
- // That is exactly what invoke-tool.ts's health splice exists to prevent, so it belongs in the shared
33
- // envelope, not per-tool.
34
- warning: z.string().optional(),
35
- // The one-shot "ask the human how this went" envelope. Undeclared, a validating profile would strip
36
- // it — and a feedback prompt that never reaches the agent is a feedback prompt that never reaches
37
- // the person, which is the entire failure mode this channel exists to fix.
38
- feedback_prompt: z.unknown().optional(),
39
- // One-shot "a newer Reticle exists" notice. Undeclared, a validating profile would strip it and the
40
- // agent would never learn an update was waiting.
41
- update_available: z.unknown().optional(),
27
+ export const EnvelopeKey = {
28
+ /** Session health — which tab, how fresh. */
29
+ SESSION: 'session',
30
+ /** One-shot pool-lease reminder. */
31
+ SESSION_LEASE: 'session_lease',
32
+ /** One-shot "these sessions are old, clean them up". */
33
+ SESSION_AGE_WARNING: 'session_age_warning',
34
+ /** The delivered-once human-in-the-loop guidance envelope. Losing it is a safety failure. */
35
+ CONTROL: 'control',
36
+ /**
37
+ * Rides alongside `session` whenever the tab is THROTTLED. It was declared on `reticle_act`'s
38
+ * schema and nowhere else, so on a validating profile every other session-bound tool silently
39
+ * dropped it — a throttled tab, where drives can no-op, returned a healthy-looking result.
40
+ */
41
+ WARNING: 'warning',
42
+ /** One-shot "ask the human how this went". */
43
+ FEEDBACK_PROMPT: 'feedback_prompt',
44
+ /** One-shot "a newer Reticle exists". */
45
+ UPDATE_AVAILABLE: 'update_available',
46
+ /**
47
+ * "You have driven this page N times without asking for a verdict."
48
+ *
49
+ * The largest known lever on the metric this product is judged by: verdict-less sessions
50
+ * overwhelmingly never call a verdict-producing tool at all. It was spliced undeclared, so on a
51
+ * validating client the nudge was built, fired, and thrown away before any agent saw it.
52
+ */
53
+ VERIFY_NEXT: 'verify_next',
54
+ /** The contextual "tell us what just went wrong" invitation. Same story, same release. */
55
+ FEEDBACK_INVITE: 'feedback_invite',
56
+ /** SDK/daemon version skew — often the one fact that explains everything else in the session. */
57
+ VERSION_SKEW: 'version_skew',
58
+ /** A feedback report that was accepted and then failed to send. Only the reporter can act on it. */
59
+ FEEDBACK_UNDELIVERED: 'feedback_undelivered',
42
60
  };
61
+ /**
62
+ * Fields that `runTool` / `withControl` splice onto EVERY session-bound tool result at runtime.
63
+ * Merged into each session-bound tool's outputSchema so a schema-strict client (structuredContent
64
+ * validation) keeps them instead of silently dropping them.
65
+ *
66
+ * Derived from `EnvelopeKey` rather than re-listed: this shape and the splice sites disagreed for a
67
+ * whole release, and every key that went missing was a channel nobody could see was missing.
68
+ */
69
+ export const sessionEnvelopeShape = Object.fromEntries(Object.values(EnvelopeKey).map((key) => [
70
+ key,
71
+ // `warning` is the one with a narrower type; the rest are opaque envelopes the client forwards.
72
+ EnvelopeKey.WARNING === key ? z.string().optional() : z.unknown().optional(),
73
+ ]));
43
74
  /** Unwrap a browser command result or throw its error so the agent sees a clean failure. */
44
75
  export async function commandOrThrow(deps, sessionId,
45
76
  // ReticleCommand, not string: the union already exists and every caller passes a member. Typed as
@@ -98,6 +98,19 @@ export const CORE_TOOL_NAMES = new Set([
98
98
  ReticleTool.QUERY,
99
99
  ReticleTool.ACT,
100
100
  ReticleTool.ACT_AND_WAIT,
101
+ // ACT_SEQUENCE is here because its absence was measurably CAUSING the biggest loop in the data.
102
+ //
103
+ // In the field `reticle_act` is called overwhelmingly more often than `reticle_act_sequence`, and
104
+ // `act` leads the repeat table by a wide margin — inside those sessions the repeated calls are
105
+ // clicks and fills, a login form driven one round trip at a time, which is exactly the antipattern
106
+ // SKILL.md warns about and exactly what this tool exists to collapse.
107
+ //
108
+ // The repeats are NOT retries: looping sessions have a LOWER error rate than non-looping ones. The
109
+ // calls succeed and get repeated, because the batching tool was reachable only through
110
+ // `reticle_run` — so an agent had to already know it existed to use it, and essentially nobody
111
+ // did. A tool an agent must already know about is a tool that never gets called; the same argument
112
+ // that put INSPECT and FEEDBACK here.
113
+ ReticleTool.ACT_SEQUENCE,
101
114
  ReticleTool.OBSERVE,
102
115
  ReticleTool.NETWORK,
103
116
  ReticleTool.CONSOLE,