@reticlehq/server 2.6.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 (82) hide show
  1. package/SKILL.md +201 -18
  2. package/dist/bridge/bridge.d.ts +6 -1
  3. package/dist/bridge/bridge.js +27 -4
  4. package/dist/cli/chromium-hint.d.ts +40 -0
  5. package/dist/cli/chromium-hint.js +75 -0
  6. package/dist/cli/cli-doctor.js +3 -11
  7. package/dist/cli/cli-launch.d.ts +1 -0
  8. package/dist/cli/cli-launch.js +4 -1
  9. package/dist/cli/cli-port.js +20 -1
  10. package/dist/cli/doctor-daemon-line.js +10 -1
  11. package/dist/cli.js +40 -9
  12. package/dist/daemon/daemon-resilience.js +2 -2
  13. package/dist/daemon/loopback-alias.d.ts +32 -0
  14. package/dist/daemon/loopback-alias.js +80 -0
  15. package/dist/events/predicate-eval.d.ts +7 -3
  16. package/dist/events/predicate-eval.js +159 -92
  17. package/dist/events/predicate-parse.d.ts +1 -1
  18. package/dist/events/predicate-parse.js +18 -4
  19. package/dist/events/ring-buffer.d.ts +8 -0
  20. package/dist/events/ring-buffer.js +34 -1
  21. package/dist/honesty/honesty.d.ts +11 -0
  22. package/dist/honesty/honesty.js +9 -1
  23. package/dist/honesty/verified.js +26 -3
  24. package/dist/http-server.js +11 -1
  25. package/dist/index.js +26 -2
  26. package/dist/init/agent-rules.js +6 -1
  27. package/dist/init/detect.d.ts +8 -0
  28. package/dist/init/detect.js +15 -0
  29. package/dist/init/non-js-project.d.ts +27 -0
  30. package/dist/init/non-js-project.js +60 -0
  31. package/dist/init/plan-framework.d.ts +9 -0
  32. package/dist/init/plan-framework.js +19 -1
  33. package/dist/init/plan.js +20 -3
  34. package/dist/init/run.js +38 -9
  35. package/dist/init/snippets.d.ts +20 -1
  36. package/dist/init/snippets.js +71 -6
  37. package/dist/mcp/mcp-proxy.js +10 -3
  38. package/dist/mcp/mcp.js +35 -0
  39. package/dist/mcp/proxy-lifecycle.d.ts +19 -0
  40. package/dist/mcp/proxy-lifecycle.js +21 -0
  41. package/dist/pool/playwright-launcher.js +10 -2
  42. package/dist/session/live-control-tools.js +39 -2
  43. package/dist/session/live-control.d.ts +7 -9
  44. package/dist/session/live-control.js +34 -1
  45. package/dist/session/no-session-diagnosis.d.ts +4 -4
  46. package/dist/session/no-session-diagnosis.js +60 -29
  47. package/dist/session/review-store.d.ts +19 -0
  48. package/dist/session/review-store.js +34 -5
  49. package/dist/session/session-manager.d.ts +9 -0
  50. package/dist/session/session-manager.js +11 -0
  51. package/dist/session/session.d.ts +8 -0
  52. package/dist/session/session.js +12 -0
  53. package/dist/status-payload.d.ts +13 -1
  54. package/dist/status-payload.js +6 -1
  55. package/dist/telemetry/app-instrumented.d.ts +37 -0
  56. package/dist/telemetry/app-instrumented.js +54 -0
  57. package/dist/telemetry/daemon-telemetry.d.ts +4 -4
  58. package/dist/telemetry/daemon-telemetry.js +8 -4
  59. package/dist/telemetry/session-metrics.d.ts +3 -3
  60. package/dist/telemetry/session-metrics.js +4 -4
  61. package/dist/telemetry/telemetry.d.ts +3 -1
  62. package/dist/telemetry/telemetry.js +3 -1
  63. package/dist/telemetry/verification-of.js +26 -1
  64. package/dist/tools/act-tools.js +32 -10
  65. package/dist/tools/assert-verdict.js +7 -0
  66. package/dist/tools/error-recovery.js +6 -1
  67. package/dist/tools/invoke-tool.js +11 -10
  68. package/dist/tools/tool-kit.d.ts +52 -5
  69. package/dist/tools/tool-kit.js +55 -24
  70. package/dist/tools/tool-surface.js +9 -9
  71. package/dist/tools/tools.js +22 -0
  72. package/dist/visual/capture-cleanup.d.ts +16 -0
  73. package/dist/visual/capture-cleanup.js +39 -0
  74. package/dist/visual/visual-tools.d.ts +18 -0
  75. package/dist/visual/visual-tools.js +24 -5
  76. package/docs/agent-cheatsheet.md +14 -2
  77. package/docs/gate-plan.md +1 -1
  78. package/docs/gates.md +1 -1
  79. package/docs/telemetry-contract.md +13 -1
  80. package/docs/telemetry.md +9 -1
  81. package/docs/token-efficiency.md +2 -2
  82. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -132,6 +132,10 @@ async function serveWithHonestExit(parsed) {
132
132
  /** How long `serve` waits for the child to bind. Generous: a cold daemon start is seconds. */
133
133
  const SERVE_BIND_TIMEOUT_MS = 15_000;
134
134
  const PRESENCE_POLL_MS = 150;
135
+ /** How long a daemon gets to shut down on its own before `stop` stops asking politely. */
136
+ const GRACEFUL_STOP_MS = 5000;
137
+ /** How long after SIGKILL before we accept the process is not ours to kill and say so. */
138
+ const FORCED_STOP_MS = 2000;
135
139
  async function waitForPresence(port, want, timeoutMs) {
136
140
  const deadline = Date.now() + timeoutMs;
137
141
  for (;;) {
@@ -153,18 +157,35 @@ function handleStop(port, quiet) {
153
157
  }
154
158
  process.kill(pid, 'SIGTERM');
155
159
  const started = Date.now();
160
+ let escalated = false;
156
161
  const poll = setInterval(() => {
157
162
  if (!isAlive(pid)) {
158
163
  clearInterval(poll);
159
164
  removePid(port);
160
165
  if (!quiet)
161
- log('reticle_daemon_stopped', { port, pid });
166
+ log('reticle_daemon_stopped', { port, pid, ...(escalated ? { escalated } : {}) });
162
167
  return;
163
168
  }
164
- if (Date.now() - started > 5000) {
169
+ const waited = Date.now() - started;
170
+ // A daemon that ignores SIGTERM is WEDGED — which is the one situation `stop` exists for. Giving
171
+ // up here left the port held, the pid file stale, and a human running `kill -9` by hand at the
172
+ // first two minutes of setup. Escalate instead: graceful first, then unconditional.
173
+ if (!escalated && waited > GRACEFUL_STOP_MS) {
174
+ escalated = true;
175
+ try {
176
+ process.kill(pid, 'SIGKILL');
177
+ }
178
+ catch {
179
+ // Already gone between the liveness check and here — the next tick reports it stopped.
180
+ }
181
+ return;
182
+ }
183
+ if (waited > GRACEFUL_STOP_MS + FORCED_STOP_MS) {
165
184
  clearInterval(poll);
185
+ // Surviving SIGKILL means the pid is not ours to kill (permissions) or is an unkillable
186
+ // zombie. Neither is retryable, and both need the pid named so a human can act on it.
166
187
  if (!quiet)
167
- log('reticle_daemon_stop_timeout', { port, pid });
188
+ log('reticle_daemon_stop_timeout', { port, pid, escalated });
168
189
  process.exit(1);
169
190
  }
170
191
  }, 100);
@@ -360,11 +381,21 @@ function handleOpen(requestedPort, url) {
360
381
  ...(connected
361
382
  ? {}
362
383
  : {
363
- note: 'the browser was launched but no Reticle session appeared. The app may still be ' +
364
- 'loading, or it is not wired to this bridge — run `reticle doctor`, and check the ' +
365
- 'app connects on port ' +
366
- String(port) +
367
- '.',
384
+ // Two claims used to live here that this command cannot support. It said the browser
385
+ // "was launched" — it asked the OS to open a URL and cannot see whether a window
386
+ // appeared — and it offered "the app may still be loading" as a cause with equal
387
+ // weight to the real one. It then pointed at `reticle doctor`, whose Chromium check is
388
+ // about a browser THIS command never uses, so a missing Chromium reads as the
389
+ // explanation for a session that is missing for an unrelated reason. Both misdirects
390
+ // were reported from the field, each costing several calls of app-side wiring hunt.
391
+ note: `the URL was handed to the system default browser (this command does not use ` +
392
+ `Reticle's own Chromium, so a chromium warning from \`reticle doctor\` is unrelated ` +
393
+ `to this). No Reticle session appeared. By far the likeliest cause is that the app ` +
394
+ `carries no Reticle SDK, or dials a port other than ${String(port)} — run ` +
395
+ `\`reticle init\` in the app's directory and restart its dev server. If the app IS ` +
396
+ `wired, give the page a moment and check the browser console: the SDK announces its ` +
397
+ `own connect failures there, including the one it refuses to make from a ` +
398
+ `non-localhost host without allowNonLocalhost.`,
368
399
  }),
369
400
  });
370
401
  })
@@ -420,7 +451,7 @@ function handleDaemonInner(parsed) {
420
451
  // The reason rides out on the session summary, which is the only event that fires at this
421
452
  // exact moment. The proxy emits the matching `mcp_connection_lost` and cannot know it —
422
453
  // it sees a socket end and nothing more, which is how a scheduled idle exit came to make up
423
- // 299 of 321 "outages". See SessionSummary.exit.
454
+ // the large majority of "outages". See SessionSummary.exit.
424
455
  .shutdown(reason)
425
456
  .then(() => server.close())
426
457
  .then(() => {
@@ -42,8 +42,8 @@ const DISCONNECT_CODES = new Set([
42
42
  /**
43
43
  * Nobody went away — nobody was ever there. A refused connect is the daemon not being up YET.
44
44
  *
45
- * This is the difference that made `runtime_crashed` useless. Measured 2026-08-10/11: **84 of 84
46
- * crash events were one fingerprint, `connect ECONNREFUSED`, and every affected user was in the
45
+ * This is the difference that made `runtime_crashed` useless. In the field **every crash event
46
+ * carried one fingerprint, `connect ECONNREFUSED`, and every affected user was in the
47
47
  * never-verified set.** The proxy is BUILT to tolerate this — it answers the handshake from cache
48
48
  * and wakes a daemon on the next request — so reporting it as a crash meant the one metric that is
49
49
  * supposed to say "Reticle broke" said nothing but "the daemon had not booted yet", and a real crash
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Serve the IPv4-bound daemon on IPv6 loopback too, so `localhost` reaches it on every platform.
3
+ *
4
+ * The daemon binds `127.0.0.1` — deliberately, because Reticle must never be reachable off-host. But
5
+ * `localhost` is a NAME, and on Windows Chrome resolves it to `::1` before `127.0.0.1`. The SDK's
6
+ * default bridge URL says `localhost`. So on the platform that is most of our installs, and the
7
+ * browser that is most of that, the documented default cannot connect — and the error text names the
8
+ * daemon, containers and WSL, none of which is the cause.
9
+ *
10
+ * This is a byte forwarder rather than a second `http.Server` on purpose. The daemon speaks HTTP,
11
+ * WebSocket upgrades and SSE on one port; copying request/upgrade wiring onto a second server means a
12
+ * second place to keep in sync, and the first handler anybody forgets to mirror becomes a bug that
13
+ * only Windows sees. A TCP pipe carries all three and cannot drift from what it forwards.
14
+ *
15
+ * Best-effort by contract: a machine with IPv6 disabled, or something already holding `[::1]:port`,
16
+ * leaves the daemon exactly as it was before. Never fatal — this widens reachability, it is not a
17
+ * precondition for serving.
18
+ */
19
+ /** IPv6 loopback. The other half of what `localhost` can mean. */
20
+ export declare const IPV6_LOOPBACK = "::1";
21
+ export interface LoopbackAlias {
22
+ /** False when the alias could not be bound — the daemon still serves IPv4 as before. */
23
+ opened: boolean;
24
+ close: () => Promise<void>;
25
+ }
26
+ /**
27
+ * Accept on `[::1]:port` and pipe each connection to `127.0.0.1:port`.
28
+ *
29
+ * Both ends are loopback, so a forwarded peer is still a loopback peer and the bridge's loopback
30
+ * trust holds — the alias does not widen who may connect, only which address they may name.
31
+ */
32
+ export declare function openLoopbackAlias(port: number): Promise<LoopbackAlias>;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Serve the IPv4-bound daemon on IPv6 loopback too, so `localhost` reaches it on every platform.
3
+ *
4
+ * The daemon binds `127.0.0.1` — deliberately, because Reticle must never be reachable off-host. But
5
+ * `localhost` is a NAME, and on Windows Chrome resolves it to `::1` before `127.0.0.1`. The SDK's
6
+ * default bridge URL says `localhost`. So on the platform that is most of our installs, and the
7
+ * browser that is most of that, the documented default cannot connect — and the error text names the
8
+ * daemon, containers and WSL, none of which is the cause.
9
+ *
10
+ * This is a byte forwarder rather than a second `http.Server` on purpose. The daemon speaks HTTP,
11
+ * WebSocket upgrades and SSE on one port; copying request/upgrade wiring onto a second server means a
12
+ * second place to keep in sync, and the first handler anybody forgets to mirror becomes a bug that
13
+ * only Windows sees. A TCP pipe carries all three and cannot drift from what it forwards.
14
+ *
15
+ * Best-effort by contract: a machine with IPv6 disabled, or something already holding `[::1]:port`,
16
+ * leaves the daemon exactly as it was before. Never fatal — this widens reachability, it is not a
17
+ * precondition for serving.
18
+ */
19
+ import net from 'node:net';
20
+ import { LOOPBACK_HOST } from '@reticlehq/core';
21
+ /** IPv6 loopback. The other half of what `localhost` can mean. */
22
+ export const IPV6_LOOPBACK = '::1';
23
+ /**
24
+ * Accept on `[::1]:port` and pipe each connection to `127.0.0.1:port`.
25
+ *
26
+ * Both ends are loopback, so a forwarded peer is still a loopback peer and the bridge's loopback
27
+ * trust holds — the alias does not widen who may connect, only which address they may name.
28
+ */
29
+ export function openLoopbackAlias(port) {
30
+ /** Every socket this alias has open, so shutdown can end them instead of waiting them out. */
31
+ const live = new Set();
32
+ const server = net.createServer((inbound) => {
33
+ const outbound = net.connect(port, LOOPBACK_HOST);
34
+ live.add(inbound).add(outbound);
35
+ const drop = (socket) => {
36
+ live.delete(socket);
37
+ };
38
+ inbound.on('close', () => drop(inbound));
39
+ outbound.on('close', () => drop(outbound));
40
+ // A dead peer on either side must tear down the pair, not surface as an unhandled 'error'
41
+ // event — which in Node terminates the daemon this alias exists to make reachable.
42
+ inbound.on('error', () => outbound.destroy());
43
+ outbound.on('error', () => inbound.destroy());
44
+ inbound.pipe(outbound);
45
+ outbound.pipe(inbound);
46
+ });
47
+ return new Promise((resolve) => {
48
+ /**
49
+ * Stop listening and END EVERYTHING, rather than draining.
50
+ *
51
+ * `server.close()` fires its callback only once every existing connection has gone, so a single
52
+ * held-open socket — a keep-alive, an SSE stream, exactly what this daemon serves — means the
53
+ * callback never fires. The daemon's shutdown chain awaits this, so an un-drained alias silently
54
+ * became a daemon that ignores SIGTERM: `reticle stop` timed out, the process stayed alive, and
55
+ * the port stayed held. Caught by the heartbeat spec, which is the one gate that watches a
56
+ * daemon actually die.
57
+ *
58
+ * A best-effort side listener must never be able to hold up the shutdown of the thing it exists
59
+ * to make reachable. We are exiting; ending these sockets is the correct outcome, not a
60
+ * compromise.
61
+ */
62
+ const close = () => new Promise((done) => {
63
+ server.close(() => done());
64
+ for (const socket of live)
65
+ socket.destroy();
66
+ live.clear();
67
+ });
68
+ server.once('error', () => {
69
+ // Nothing was bound, so there is nothing to close — but the caller still gets a close it can
70
+ // call unconditionally, rather than a shape it has to branch on.
71
+ resolve({ opened: false, close: () => Promise.resolve() });
72
+ });
73
+ server.listen(port, IPV6_LOOPBACK, () => {
74
+ // Never the reason the process stays up: if the daemon is otherwise done, this must not be
75
+ // what keeps the event loop alive.
76
+ server.unref();
77
+ resolve({ opened: true, close });
78
+ });
79
+ });
80
+ }
@@ -58,11 +58,15 @@ export type Predicate = {
58
58
  kind: typeof PredicateKind.NOT;
59
59
  predicate: Predicate;
60
60
  };
61
+ export declare const PredicateSchema: z.ZodType<Predicate>;
61
62
  /**
62
- * Strict on every branch. A key that is nobody's spelling is now a schema error naming it, instead of
63
- * a stripped field and a green — see predicate-strict.test.ts for the MCP session that found this.
63
+ * The fields a given predicate kind accepts, read off the schema itself.
64
+ *
65
+ * Derived rather than listed so the two can never disagree: a rejection message that names a stale
66
+ * field set is worse than one that names none, because the agent trusts it and retries into the same
67
+ * wall. Empty for a kind that is not in the union.
64
68
  */
65
- export declare const PredicateSchema: z.ZodType<Predicate>;
69
+ export declare function predicateFieldsFor(kind: string): readonly string[];
66
70
  export interface EvalResult {
67
71
  pass: boolean;
68
72
  /**
@@ -9,6 +9,11 @@ import { describeObserved } from './observed-in-window.js';
9
9
  * the only key supplied left a predicate that asserts nothing and passes on anything.
10
10
  */
11
11
  const PREDICATE_ALIASES = {
12
+ // `text`/`value` on a `text` predicate: the kind is called "text", so `text:` is the first thing
13
+ // anyone writes for it, and `value` follows from the element query's own `value` field. Both were
14
+ // hard rejections, and a rejected predicate produces NO verdict at all — the drive ends with
15
+ // nothing rather than with a failure, which is the worst outcome of the three.
16
+ [PredicateKind.TEXT]: { text: 'contains', value: 'contains' },
12
17
  // `urlContains`/`url` reported from the field: an agent that had just written
13
18
  // `net { urlContains }` applied the same word to `route`, which spells it `contains`, and got
14
19
  // `unrecognized_keys` with no list of what would have worked. The parallel it assumed is a fair
@@ -19,111 +24,173 @@ const PREDICATE_ALIASES = {
19
24
  [PredicateKind.NET]: { url: 'urlContains' },
20
25
  [PredicateKind.SIGNAL]: { data: 'dataMatches' },
21
26
  };
27
+ /**
28
+ * Element-query fields an agent writes FLAT on an `element` predicate instead of nested under
29
+ * `query`. `reticle_query` takes exactly these at the top level, so an agent that has just located
30
+ * something writes the same words again when it asserts on it — and got `query: Required` plus an
31
+ * `unknown field` list for its trouble. Lifting them is unambiguous: they have no other meaning on
32
+ * this kind.
33
+ */
34
+ const ELEMENT_QUERY_FIELDS = [
35
+ 'by',
36
+ 'value',
37
+ 'role',
38
+ 'name',
39
+ 'text',
40
+ 'label',
41
+ 'placeholder',
42
+ 'testid',
43
+ 'alt',
44
+ 'component',
45
+ ];
22
46
  /** Rename known aliases before parse; an explicit canonical key always wins. */
23
47
  function applyPredicateAliases(input) {
24
48
  if (typeof input !== 'object' || null === input || Array.isArray(input))
25
49
  return input;
26
50
  const obj = input;
27
- const aliases = PREDICATE_ALIASES['string' === typeof obj['kind'] ? obj['kind'] : ''];
28
- if (aliases === undefined)
29
- return input;
51
+ const kind = 'string' === typeof obj['kind'] ? obj['kind'] : '';
52
+ const aliases = PREDICATE_ALIASES[kind];
53
+ let out = obj;
54
+ if (aliases !== undefined) {
55
+ out = { ...obj };
56
+ for (const [from, to] of Object.entries(aliases)) {
57
+ if (out[from] === undefined)
58
+ continue;
59
+ if (out[to] === undefined)
60
+ out[to] = out[from];
61
+ delete out[from];
62
+ }
63
+ }
64
+ return PredicateKind.ELEMENT === kind ? liftElementQuery(out) : out;
65
+ }
66
+ /**
67
+ * Fold flat query fields into `query`. An explicit `query` wins outright — a caller that supplied
68
+ * both told us which one it meant, and merging the two would invent a locator neither side wrote.
69
+ */
70
+ function liftElementQuery(obj) {
71
+ const loose = ELEMENT_QUERY_FIELDS.filter((field) => obj[field] !== undefined);
72
+ if (0 === loose.length)
73
+ return obj;
30
74
  const out = { ...obj };
31
- for (const [from, to] of Object.entries(aliases)) {
32
- if (out[from] === undefined)
33
- continue;
34
- if (out[to] === undefined)
35
- out[to] = out[from];
36
- delete out[from];
75
+ const query = {};
76
+ for (const field of loose) {
77
+ query[field] = out[field];
78
+ delete out[field];
37
79
  }
80
+ if (out['query'] === undefined)
81
+ out['query'] = query;
38
82
  return out;
39
83
  }
40
84
  /**
41
85
  * Strict on every branch. A key that is nobody's spelling is now a schema error naming it, instead of
42
86
  * a stripped field and a green — see predicate-strict.test.ts for the MCP session that found this.
87
+ *
88
+ * Built on demand rather than at module scope: the `allOf`/`anyOf`/`not` branches reference
89
+ * `PredicateSchema` itself, which does not exist yet while this module is initialising. Calling it
90
+ * after init is what makes the union introspectable — see `predicateFieldsFor`.
91
+ */
92
+ function predicateUnion() {
93
+ return z.discriminatedUnion('kind', [
94
+ z
95
+ .object({
96
+ kind: z.literal(PredicateKind.ELEMENT),
97
+ query: ElementQuerySchema,
98
+ state: z.nativeEnum(ElementState).optional(),
99
+ absent: z.boolean().optional(),
100
+ })
101
+ .strict(),
102
+ z
103
+ .object({
104
+ kind: z.literal(PredicateKind.TEXT),
105
+ contains: z.string(),
106
+ visible: z.boolean().optional(),
107
+ absent: z.boolean().optional(),
108
+ })
109
+ .strict(),
110
+ z
111
+ .object({
112
+ kind: z.literal(PredicateKind.NET),
113
+ method: z.string().optional(),
114
+ urlContains: z.string().optional(),
115
+ status: z.number().optional(),
116
+ ok: z.boolean().optional(),
117
+ since: z.number().optional(),
118
+ count: z.number().int().nonnegative().optional(),
119
+ })
120
+ .strict(),
121
+ z
122
+ .object({
123
+ kind: z.literal(PredicateKind.ROUTE),
124
+ pathname: z.string().optional(),
125
+ contains: z.string().optional(),
126
+ since: z.number().optional(),
127
+ })
128
+ .strict(),
129
+ z
130
+ .object({
131
+ kind: z.literal(PredicateKind.CONSOLE),
132
+ level: z.string().optional(),
133
+ absent: z.boolean().optional(),
134
+ since: z.number().optional(),
135
+ })
136
+ .strict(),
137
+ z
138
+ .object({
139
+ kind: z.literal(PredicateKind.ANIMATION),
140
+ name: z.string().optional(),
141
+ target: z.string().optional(),
142
+ completed: z.boolean().optional(),
143
+ since: z.number().optional(),
144
+ })
145
+ .strict(),
146
+ z
147
+ .object({
148
+ kind: z.literal(PredicateKind.SIGNAL),
149
+ name: z.string().optional(),
150
+ dataMatches: z.record(z.unknown()).optional(),
151
+ since: z.number().optional(),
152
+ })
153
+ .strict(),
154
+ z
155
+ .object({
156
+ kind: z.literal(PredicateKind.STATE),
157
+ store: z.string().optional(),
158
+ path: z.string(),
159
+ equals: z.unknown().optional(),
160
+ })
161
+ .strict(),
162
+ z
163
+ .object({
164
+ kind: z.literal(PredicateKind.SETTLED),
165
+ quietMs: z.number().positive().optional(),
166
+ })
167
+ .strict(),
168
+ z
169
+ .object({ kind: z.literal(PredicateKind.ALL_OF), predicates: z.array(PredicateSchema) })
170
+ .strict(),
171
+ z
172
+ .object({ kind: z.literal(PredicateKind.ANY_OF), predicates: z.array(PredicateSchema) })
173
+ .strict(),
174
+ z.object({ kind: z.literal(PredicateKind.NOT), predicate: PredicateSchema }).strict(),
175
+ ]);
176
+ }
177
+ export const PredicateSchema = z.lazy(() => z.preprocess(applyPredicateAliases, predicateUnion()));
178
+ /**
179
+ * The fields a given predicate kind accepts, read off the schema itself.
180
+ *
181
+ * Derived rather than listed so the two can never disagree: a rejection message that names a stale
182
+ * field set is worse than one that names none, because the agent trusts it and retries into the same
183
+ * wall. Empty for a kind that is not in the union.
43
184
  */
44
- export const PredicateSchema = z.lazy(() => z.preprocess(applyPredicateAliases, z.discriminatedUnion('kind', [
45
- z
46
- .object({
47
- kind: z.literal(PredicateKind.ELEMENT),
48
- query: ElementQuerySchema,
49
- state: z.nativeEnum(ElementState).optional(),
50
- absent: z.boolean().optional(),
51
- })
52
- .strict(),
53
- z
54
- .object({
55
- kind: z.literal(PredicateKind.TEXT),
56
- contains: z.string(),
57
- visible: z.boolean().optional(),
58
- absent: z.boolean().optional(),
59
- })
60
- .strict(),
61
- z
62
- .object({
63
- kind: z.literal(PredicateKind.NET),
64
- method: z.string().optional(),
65
- urlContains: z.string().optional(),
66
- status: z.number().optional(),
67
- ok: z.boolean().optional(),
68
- since: z.number().optional(),
69
- count: z.number().int().nonnegative().optional(),
70
- })
71
- .strict(),
72
- z
73
- .object({
74
- kind: z.literal(PredicateKind.ROUTE),
75
- pathname: z.string().optional(),
76
- contains: z.string().optional(),
77
- since: z.number().optional(),
78
- })
79
- .strict(),
80
- z
81
- .object({
82
- kind: z.literal(PredicateKind.CONSOLE),
83
- level: z.string().optional(),
84
- absent: z.boolean().optional(),
85
- since: z.number().optional(),
86
- })
87
- .strict(),
88
- z
89
- .object({
90
- kind: z.literal(PredicateKind.ANIMATION),
91
- name: z.string().optional(),
92
- target: z.string().optional(),
93
- completed: z.boolean().optional(),
94
- since: z.number().optional(),
95
- })
96
- .strict(),
97
- z
98
- .object({
99
- kind: z.literal(PredicateKind.SIGNAL),
100
- name: z.string().optional(),
101
- dataMatches: z.record(z.unknown()).optional(),
102
- since: z.number().optional(),
103
- })
104
- .strict(),
105
- z
106
- .object({
107
- kind: z.literal(PredicateKind.STATE),
108
- store: z.string().optional(),
109
- path: z.string(),
110
- equals: z.unknown().optional(),
111
- })
112
- .strict(),
113
- z
114
- .object({
115
- kind: z.literal(PredicateKind.SETTLED),
116
- quietMs: z.number().positive().optional(),
117
- })
118
- .strict(),
119
- z
120
- .object({ kind: z.literal(PredicateKind.ALL_OF), predicates: z.array(PredicateSchema) })
121
- .strict(),
122
- z
123
- .object({ kind: z.literal(PredicateKind.ANY_OF), predicates: z.array(PredicateSchema) })
124
- .strict(),
125
- z.object({ kind: z.literal(PredicateKind.NOT), predicate: PredicateSchema }).strict(),
126
- ])));
185
+ export function predicateFieldsFor(kind) {
186
+ for (const option of predicateUnion().options) {
187
+ const literal = option.shape['kind'];
188
+ if (literal instanceof z.ZodLiteral && literal.value === kind) {
189
+ return Object.keys(option.shape).filter((field) => 'kind' !== field);
190
+ }
191
+ }
192
+ return [];
193
+ }
127
194
  function str(value) {
128
195
  return 'string' === typeof value ? value : undefined;
129
196
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * One readable sentence when a predicate does not parse — never the zod array.
3
3
  *
4
- * Measured 2026-08-10: **9 of 58 tool errors that day were a serialized zod issue array**, all on
4
+ * In the field a meaningful share of all tool errors were **a serialized zod issue array**, all on
5
5
  * `reticle_act_and_wait`, `reticle_wait_for` and `reticle_assert` — the three tools that produced
6
6
  * every action-derived finding in the dataset. The least readable error we emit was landing on the
7
7
  * highest-value path, and an agent had to `JSON.parse` an error string to learn which field it got
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * One readable sentence when a predicate does not parse — never the zod array.
3
3
  *
4
- * Measured 2026-08-10: **9 of 58 tool errors that day were a serialized zod issue array**, all on
4
+ * In the field a meaningful share of all tool errors were **a serialized zod issue array**, all on
5
5
  * `reticle_act_and_wait`, `reticle_wait_for` and `reticle_assert` — the three tools that produced
6
6
  * every action-derived finding in the dataset. The least readable error we emit was landing on the
7
7
  * highest-value path, and an agent had to `JSON.parse` an error string to learn which field it got
@@ -13,7 +13,8 @@
13
13
  * show a valid call.
14
14
  */
15
15
  import { z } from 'zod';
16
- import { PredicateSchema } from './predicate-eval.js';
16
+ import { PredicateKind } from '@reticlehq/core';
17
+ import { PredicateSchema, predicateFieldsFor } from './predicate-eval.js';
17
18
  /** A valid call, short enough to sit inside an error without becoming the error. */
18
19
  const EXAMPLE = '{ kind: "signal", name: "todos:loaded" }';
19
20
  /** `path: ["net","urlContains"]` → `net.urlContains`; an empty path means the object itself. */
@@ -49,6 +50,19 @@ export function parsePredicate(input) {
49
50
  // back is how the zod array became unreadable in the first place.
50
51
  const issues = parsed.error.issues.slice(0, 3).map(describeIssue).join('; ');
51
52
  throw new Error(`that predicate did not parse (kind "${kind}"): ${issues}. Nothing ran — the predicate was ` +
52
- `not evaluated, so no verdict was produced. Call reticle_tools for the fields this kind ` +
53
- `accepts. A valid call looks like: ${EXAMPLE}`);
53
+ `not evaluated, so no verdict was produced. ${accepted(kind)} ` +
54
+ `A valid call looks like: ${EXAMPLE}`);
55
+ }
56
+ /**
57
+ * What WOULD have worked, in the same breath as what did not.
58
+ *
59
+ * Telling an agent only which field is wrong leaves it guessing again, and each guess costs another
60
+ * round trip on the one call path that produces verdicts. Naming the accepted fields — or, when the
61
+ * kind itself is the mistake, the accepted kinds — makes the retry informed instead.
62
+ */
63
+ function accepted(kind) {
64
+ const fields = predicateFieldsFor(kind);
65
+ if (0 < fields.length)
66
+ return `${kind} accepts: ${fields.join(', ')}.`;
67
+ return `"${kind}" is not a predicate kind — use one of: ${Object.values(PredicateKind).join(', ')}.`;
54
68
  }
@@ -24,6 +24,14 @@ export declare class RingBuffer {
24
24
  since(cursor: number): ReticleEvent[];
25
25
  /** Events within the last `windowMs`, relative to `now`. */
26
26
  window(windowMs: number, now: number): ReticleEvent[];
27
+ /**
28
+ * Did this buffer evict scarce evidence that belonged to a window opened at `cursor`?
29
+ *
30
+ * The one honest input to "was the capture clean". A `false` here means every non-churn event in
31
+ * the window is still held, whatever the drop counter says — and the drop counter says a great
32
+ * deal, because age eviction runs on every push.
33
+ */
34
+ lostSince(cursor: number): boolean;
27
35
  /** Snapshot of buffer health for the agent — live events held and cumulative drops since connect. */
28
36
  bufferHealth(): {
29
37
  total: number;
@@ -23,6 +23,16 @@ export class RingBuffer {
23
23
  #head = 0;
24
24
  #totalBytes = 0;
25
25
  #droppedCount = 0;
26
+ /**
27
+ * The timestamp of the newest NON-CHURN event this buffer has ever evicted, or `undefined` if it
28
+ * has never lost one. This — not the drop counter — is what impeaches an observation window.
29
+ *
30
+ * The counter cannot answer the question. It moves for age eviction (everything past 60s, on every
31
+ * push) and for churn eviction (the low-signal floor, sacrificed on purpose so scarce evidence
32
+ * survives), neither of which is evidence lost from the window an agent just observed. See
33
+ * `lostSince` and `ring-buffer-window-loss.test.ts`.
34
+ */
35
+ #lastScarceLossT;
26
36
  constructor(options = {}) {
27
37
  this.#maxEvents = options.maxEvents ?? RING_BUFFER_DEFAULTS.MAX_EVENTS;
28
38
  this.#maxAgeMs = options.maxAgeMs ?? RING_BUFFER_DEFAULTS.MAX_AGE_MS;
@@ -81,7 +91,10 @@ export class RingBuffer {
81
91
  }
82
92
  const victim = this.#findChurnAfterHead();
83
93
  if (-1 === victim) {
84
- // Buffer genuinely full of high-signal events — fall back to FIFO so it stays bounded.
94
+ // Buffer genuinely full of high-signal events — fall back to FIFO so it stays bounded. This
95
+ // is the one eviction path that destroys scarce evidence, and the only one that should ever
96
+ // make a verdict say it could not see.
97
+ this.#noteScarceLoss(this.#head);
85
98
  this.#totalBytes -= this.#eventBytes[this.#head] ?? 0;
86
99
  this.#head += 1;
87
100
  continue;
@@ -91,6 +104,7 @@ export class RingBuffer {
91
104
  this.#eventBytes.splice(victim, 1);
92
105
  }
93
106
  while (this.#liveCount() > 0 && (this.#events[this.#head]?.t ?? cutoff) < cutoff) {
107
+ this.#noteScarceLoss(this.#head);
94
108
  this.#totalBytes -= this.#eventBytes[this.#head] ?? 0;
95
109
  this.#head += 1;
96
110
  }
@@ -102,6 +116,25 @@ export class RingBuffer {
102
116
  this.#head = 0;
103
117
  }
104
118
  }
119
+ /** Record an eviction at `index` as scarce loss, unless it was the churn floor being sacrificed. */
120
+ #noteScarceLoss(index) {
121
+ const victim = this.#events[index];
122
+ if (victim === undefined || CHURN_TYPES.has(victim.type))
123
+ return;
124
+ if (this.#lastScarceLossT === undefined || victim.t > this.#lastScarceLossT) {
125
+ this.#lastScarceLossT = victim.t;
126
+ }
127
+ }
128
+ /**
129
+ * Did this buffer evict scarce evidence that belonged to a window opened at `cursor`?
130
+ *
131
+ * The one honest input to "was the capture clean". A `false` here means every non-churn event in
132
+ * the window is still held, whatever the drop counter says — and the drop counter says a great
133
+ * deal, because age eviction runs on every push.
134
+ */
135
+ lostSince(cursor) {
136
+ return this.#lastScarceLossT !== undefined && this.#lastScarceLossT >= cursor;
137
+ }
105
138
  /** First churn event after the head, or -1 if none within the bounded scan (keeps eviction cheap). */
106
139
  #findChurnAfterHead() {
107
140
  const end = Math.min(this.#events.length, this.#head + CHURN_SCAN_LIMIT);