@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
@@ -8,6 +8,7 @@ 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';
@@ -262,12 +263,12 @@ export async function runTool(tool, deps, args) {
262
263
  ? raw
263
264
  : {
264
265
  ...raw,
265
- ...(prompt !== undefined ? { feedback_prompt: prompt } : {}),
266
- ...(update !== undefined ? { update_available: update } : {}),
267
- ...(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 } : {}),
268
269
  ...(undelivered !== undefined
269
270
  ? {
270
- 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.`,
271
272
  }
272
273
  : {}),
273
274
  };
@@ -286,17 +287,17 @@ export async function runTool(tool, deps, args) {
286
287
  // one-time pool-lease reminder or the age cleanup nudge. Splice them regardless.
287
288
  const lease = resolved.takeSessionLease();
288
289
  if (lease !== undefined)
289
- envelope['session_lease'] = lease;
290
+ envelope[EnvelopeKey.SESSION_LEASE] = lease;
290
291
  const warning = resolved.ageWarning();
291
292
  if (warning !== undefined)
292
- envelope['session_age_warning'] = warning;
293
- // Ask for a verdict when the agent has driven the page and not asked for one. 137 of the 140
294
- // verdict-less sessions measured over 2026-08-10/11 never called a verdict-producing tool ONCE;
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;
295
296
  // the counter behind this already existed and was reported only to us. One-shot per abandoned
296
297
  // run, same discipline as the pool lease — a hint on every call is noise that gets tuned out.
297
298
  const unverified = getSessionMetrics().takeUnverifiedNudge();
298
299
  if (unverified !== undefined)
299
- envelope['verify_next'] = unverified;
300
+ envelope[EnvelopeKey.VERIFY_NEXT] = unverified;
300
301
  // Invite feedback at the moment of friction, worded for what just happened. Feedback produced
301
302
  // ~11 of this release's fixes from 14 reports; the handshake instruction is what got those 14,
302
303
  // and this closes the gap it leaves — the agent mid-problem, not thinking about feedback tooling.
@@ -311,7 +312,7 @@ export async function runTool(tool, deps, args) {
311
312
  producedVerdict: 'verified' in result,
312
313
  });
313
314
  if (friction !== undefined) {
314
- envelope['feedback_invite'] = inviteFor(friction);
315
+ envelope[EnvelopeKey.FEEDBACK_INVITE] = inviteFor(friction);
315
316
  getSessionMetrics().recordFeedbackPrompt();
316
317
  }
317
318
  return Object.keys(envelope).length > 0 ? { ...result, ...envelope } : result;
@@ -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. */
@@ -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
@@ -100,16 +100,16 @@ export const CORE_TOOL_NAMES = new Set([
100
100
  ReticleTool.ACT_AND_WAIT,
101
101
  // ACT_SEQUENCE is here because its absence was measurably CAUSING the biggest loop in the data.
102
102
  //
103
- // Measured 2026-08-10/11 (non-CI): `reticle_act` 319 calls, `reticle_act_sequence` **7**. `act`
104
- // also leads the repeat table at 110 consecutive-repeat runs, and inside those sessions the
105
- // repeated calls are 98 clicks and 21 fills — a login form driven one round trip at a time, which
106
- // is exactly the antipattern SKILL.md warns about and exactly what this tool exists to collapse.
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
107
  //
108
- // The repeats are NOT retries: looping sessions have a lower error rate (0.051) than non-looping
109
- // ones (0.078). The calls succeed and get repeated, because the batching tool was reachable only
110
- // through `reticle_run` — so an agent had to already know it existed to use it, and 7-vs-319 says
111
- // essentially nobody did. A tool an agent must already know about is a tool that never gets
112
- // called; the same argument that put INSPECT and FEEDBACK here.
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
113
  ReticleTool.ACT_SEQUENCE,
114
114
  ReticleTool.OBSERVE,
115
115
  ReticleTool.NETWORK,
@@ -78,6 +78,10 @@ const RAW_TOOLS = [
78
78
  recommendation: z.string().optional(),
79
79
  }))
80
80
  .describe('Connected browser sessions with health state. `projectId` groups sessions by app (stable across port changes); `leased` marks a pool-managed headless context vs a human tab.'),
81
+ why: z
82
+ .string()
83
+ .optional()
84
+ .describe('Present ONLY when `sessions` is empty: why nothing is connected, and the next action that fixes it. An empty list is never the end of the road — read this before concluding the app cannot be driven.'),
81
85
  },
82
86
  handler: async (deps) => {
83
87
  const provider = deps.realInput;
@@ -87,6 +91,14 @@ const RAW_TOOLS = [
87
91
  realInputAvailable: provider !== undefined ? await provider.isAvailableFor(s.url) : false,
88
92
  leased: leasedIds.has(s.sessionId),
89
93
  })));
94
+ // An empty list is the most common thing this tool ever returns, and on its own it is a dead
95
+ // end: the agent asked the one question it knows to ask, got a confident-looking answer with
96
+ // no next step, and stopped. The daemon already knows WHY nothing is connected — say it here
97
+ // rather than only when a later tool fails for want of a session.
98
+ if (0 === sessions.length) {
99
+ const why = deps.sessions.noSessionHint();
100
+ return { sessions, ...(why === undefined ? {} : { why }) };
101
+ }
90
102
  return { sessions };
91
103
  },
92
104
  },
@@ -193,6 +205,10 @@ const RAW_TOOLS = [
193
205
  .string()
194
206
  .optional()
195
207
  .describe('CSS selector or element ref to restrict the search to a subtree.'),
208
+ self: z
209
+ .boolean()
210
+ .optional()
211
+ .describe('Return the `scope` element ITSELF instead of searching inside it. Use when the target is a plain layout container with no role, name, testid or text of its own — which is routinely the element that carries the handler, and is otherwise unreachable because every query excludes its own scope root. Requires `scope`.'),
196
212
  attrs: z
197
213
  .array(z.string())
198
214
  .optional()
@@ -297,7 +313,13 @@ const RAW_TOOLS = [
297
313
  // The handler forwards an explicit allowlist, so a new input is silently dropped unless it is
298
314
  // added here — the browser saw no `attrs` and returned undefined while the unit tests, which
299
315
  // call matchQuery directly, passed. Schema plus implementation is not the whole wire.
316
+ //
317
+ // It happened again with `self`, in the same session that wrote this comment: declared on the
318
+ // tool, implemented in the browser, verified by unit tests that call `runQuery` directly, and
319
+ // dropped here — so the live call returned zero matches with no error. `query-forwarding.test.ts`
320
+ // now asserts the payload, because a comment is not a guard.
300
321
  attrs: args['attrs'],
322
+ self: args['self'],
301
323
  }).then((result) => withSizeCost(paginateQueryResult(result, asNumber(args['limit']), true === args['count_only'])));
302
324
  },
303
325
  },
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Remember the private parent of a capture after the daemon has read it.
3
+ *
4
+ * Legacy captures live directly in `tmpdir()` and therefore have no directory to reclaim. The
5
+ * caller has already applied the capture-path trust-boundary check; this second exact parent check
6
+ * keeps the cleanup set limited to the one-level private layout.
7
+ */
8
+ export declare function trackCaptureDirectory(path: string): void;
9
+ /**
10
+ * Remove empty private capture directories during the daemon's existing awaited shutdown path.
11
+ *
12
+ * Deliberately `rmdir`, never recursive removal: captures are unlinked after reading, so a genuine
13
+ * directory is empty by shutdown. If anything unexpected remains, refusing to remove the directory
14
+ * is safer than widening a renderer-supplied path into a recursive delete.
15
+ */
16
+ export declare function cleanupCaptureDirectories(): Promise<void>;
@@ -0,0 +1,39 @@
1
+ import { rmdir } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { basename, dirname } from 'node:path';
4
+ import { RETICLE_CAPTURE_FILE_PREFIX } from '@reticlehq/core';
5
+ /** Private shell directories whose captures this daemon has successfully consumed. */
6
+ const captureDirectories = new Set();
7
+ /**
8
+ * Remember the private parent of a capture after the daemon has read it.
9
+ *
10
+ * Legacy captures live directly in `tmpdir()` and therefore have no directory to reclaim. The
11
+ * caller has already applied the capture-path trust-boundary check; this second exact parent check
12
+ * keeps the cleanup set limited to the one-level private layout.
13
+ */
14
+ export function trackCaptureDirectory(path) {
15
+ const parent = dirname(path);
16
+ if (dirname(parent) === tmpdir() && basename(parent).startsWith(RETICLE_CAPTURE_FILE_PREFIX)) {
17
+ captureDirectories.add(parent);
18
+ }
19
+ }
20
+ /**
21
+ * Remove empty private capture directories during the daemon's existing awaited shutdown path.
22
+ *
23
+ * Deliberately `rmdir`, never recursive removal: captures are unlinked after reading, so a genuine
24
+ * directory is empty by shutdown. If anything unexpected remains, refusing to remove the directory
25
+ * is safer than widening a renderer-supplied path into a recursive delete.
26
+ */
27
+ export async function cleanupCaptureDirectories() {
28
+ await Promise.all([...captureDirectories].map(async (dir) => {
29
+ try {
30
+ await rmdir(dir);
31
+ }
32
+ catch {
33
+ // Best-effort shutdown housekeeping. A non-empty or already-removed directory is harmless.
34
+ }
35
+ finally {
36
+ captureDirectories.delete(dir);
37
+ }
38
+ }));
39
+ }
@@ -1,4 +1,22 @@
1
1
  import type { ToolDef } from '../tools/tools.js';
2
+ /**
3
+ * Only read a capture the shell wrote. The path arrives from the PAGE, and the daemon must not
4
+ * become a file-read oracle for whatever a compromised renderer names.
5
+ *
6
+ * Two layouts are accepted, and the pair is the whole subtlety:
7
+ *
8
+ * - `<tmp>/<prefix><dir>/<prefix><n>.png` — the shell's own PRIVATE capture directory, created 0700
9
+ * so no other local user can read the screenshot or pre-place a symlink at the path we write.
10
+ * - `<tmp>/<prefix><pid>-<n>.png` — the legacy flat layout. Kept because the shell ships as a
11
+ * SEPARATE package on the user's own upgrade schedule: an app still on the older
12
+ * `@reticlehq/electron` talking to a newer daemon must keep getting screenshots, not silently
13
+ * start returning no image. It grants the daemon nothing it did not already have.
14
+ *
15
+ * Every comparison is an exact match against an UNRESOLVED path, which is what refuses traversal:
16
+ * `<tmp>/<prefix>x/../../etc/<prefix>passwd.png` has a grandparent of `<tmp>/<prefix>x/..`, not
17
+ * `<tmp>`. Exactly one directory level is allowed, so a deeper nesting is refused too.
18
+ */
19
+ export declare function isCapturePath(path: string): boolean;
2
20
  /**
3
21
  * The opt-in pixel layer. Both tools require a DRIVEN browser (reticle drive /
4
22
  * RETICLE_CDP_URL) — the always-on SDK ships no screenshotter — and return a structured NO_PROVIDER
@@ -5,6 +5,7 @@ import { sessionIdShape } from '../tools/tool-kit.js';
5
5
  import { asNumber, asRecord, asString } from '../tools/tools-helpers.js';
6
6
  import { diffPng } from './visual-diff.js';
7
7
  import { VisualStore } from './visual-store.js';
8
+ import { trackCaptureDirectory } from './capture-cleanup.js';
8
9
  import { readFile, unlink } from 'node:fs/promises';
9
10
  import { tmpdir } from 'node:os';
10
11
  import { basename, dirname } from 'node:path';
@@ -87,6 +88,7 @@ async function desktopCapture(deps, sessionId, fullPage) {
87
88
  try {
88
89
  const bytes = await readFile(path);
89
90
  await unlink(path).catch(() => undefined);
91
+ trackCaptureDirectory(path);
90
92
  return isCompletePng(bytes) ? { png: new Uint8Array(bytes) } : {};
91
93
  }
92
94
  catch {
@@ -94,12 +96,29 @@ async function desktopCapture(deps, sessionId, fullPage) {
94
96
  }
95
97
  }
96
98
  /**
97
- * Only read a capture the shell wrote: inside the OS temp dir, with Reticle's own filename prefix.
98
- * The path arrives from the PAGE, and the daemon must not become a file-read oracle for whatever a
99
- * compromised renderer names.
99
+ * Only read a capture the shell wrote. The path arrives from the PAGE, and the daemon must not
100
+ * become a file-read oracle for whatever a compromised renderer names.
101
+ *
102
+ * Two layouts are accepted, and the pair is the whole subtlety:
103
+ *
104
+ * - `<tmp>/<prefix><dir>/<prefix><n>.png` — the shell's own PRIVATE capture directory, created 0700
105
+ * so no other local user can read the screenshot or pre-place a symlink at the path we write.
106
+ * - `<tmp>/<prefix><pid>-<n>.png` — the legacy flat layout. Kept because the shell ships as a
107
+ * SEPARATE package on the user's own upgrade schedule: an app still on the older
108
+ * `@reticlehq/electron` talking to a newer daemon must keep getting screenshots, not silently
109
+ * start returning no image. It grants the daemon nothing it did not already have.
110
+ *
111
+ * Every comparison is an exact match against an UNRESOLVED path, which is what refuses traversal:
112
+ * `<tmp>/<prefix>x/../../etc/<prefix>passwd.png` has a grandparent of `<tmp>/<prefix>x/..`, not
113
+ * `<tmp>`. Exactly one directory level is allowed, so a deeper nesting is refused too.
100
114
  */
101
- function isCapturePath(path) {
102
- return dirname(path) === tmpdir() && basename(path).startsWith(RETICLE_CAPTURE_FILE_PREFIX);
115
+ export function isCapturePath(path) {
116
+ if (!basename(path).startsWith(RETICLE_CAPTURE_FILE_PREFIX))
117
+ return false;
118
+ const parent = dirname(path);
119
+ if (parent === tmpdir())
120
+ return true;
121
+ return dirname(parent) === tmpdir() && basename(parent).startsWith(RETICLE_CAPTURE_FILE_PREFIX);
103
122
  }
104
123
  /**
105
124
  * A PNG is complete only if it ends with IEND. This check exists because a truncated image once
@@ -7,10 +7,12 @@ One screen to get fluent. Reticle is the **proof layer for AI agents** — no sc
7
7
  | Verb | Tool | One-liner |
8
8
  | --- | --- | --- |
9
9
  | **look** | `reticle_snapshot` / `reticle_query` | See the page (semantic tree) / find one specific element. |
10
- | **act** | `reticle_act` / `reticle_act_sequence` / `reticle_act_and_wait` | Click/fill a `ref` / **batch a whole journey in one hop** / act + wait for a predicate in one hop. |
10
+ | **act** | **`reticle_act_and_wait`** / `reticle_act_sequence` / `reticle_act` | **Act + name the consequence, one hop — reach for this first.** / batch a whole journey in one hop / move the app and prove nothing. |
11
11
  | **observe** | `reticle_observe` / `reticle_wait_for` | Everything the app did after `since` / block until true. |
12
12
  | **assert** | `reticle_assert` | Evaluate a predicate → `{ pass, evidence, failureReason? }`. The end of every loop. |
13
13
 
14
+ > **Only `reticle_act_and_wait` and `reticle_assert` produce a verdict.** Everything else moves or reads the app and proves nothing, so a drive ending without one of those two has no result however many tools it used. `reticle_act` is the tool agents reach for by habit and it catches nothing; `act_and_wait` is where defects actually surface. And `verified: "unknown"` is not a pass — it means Reticle drove the app and could not tell what happened. Report it as unknown.
15
+
14
16
  `reticle_act` returns a `since` cursor — pass it to `reticle_observe({ since })` to scope the window. Elements are addressed by stable refs (`e7`) from `snapshot`/`query`; they re-resolve across re-renders.
15
17
 
16
18
  **`assert`/`wait_for` are auto-scoped to your last act.** By default they only count events buffered _since_ the most recent act, so a stale signal from a previous step can't fake a pass — pass an explicit `since` to override. **Clicks run the code, not pixels:** `reticle_act` click fires the full pointer sequence on the element (no coordinate gesture for the HUD to intercept), reports `occluded:true` when something covers the target, and stays synthetic even with CDP configured (use `args:{ native:true }` for a trusted native click).
@@ -21,6 +23,16 @@ One screen to get fluent. Reticle is the **proof layer for AI agents** — no sc
21
23
  - Need to wait without acting? `reticle_wait_for({ predicate: { kind: "settled", quietMs } })`.
22
24
  - Waiting for a specific outcome? Pass that consequence as the predicate (`{ signal }` / `{ net }`), or `allOf` it with `{ kind: "settled" }` to wait for both the event _and_ the page going quiet.
23
25
 
26
+ **A predicate that does not parse produces NO verdict — not a failing one.** Nothing runs, so the drive ends with no result at all, which is strictly worse than a failure. The shapes below are the ones agents reach for most; the first column now works, but knowing the second saves you the round trip:
27
+
28
+ | You may write | It means |
29
+ | --- | --- |
30
+ | `{ kind: "text", text: "Saved" }` | `{ kind: "text", contains: "Saved" }` |
31
+ | `{ kind: "element", role: "button", text: "Save" }` | `{ kind: "element", query: { role, text } }` |
32
+ | `{ kind: "route", url: "/checkout" }` | `{ kind: "route", contains: "/checkout" }` |
33
+
34
+ If a predicate is still rejected, the error names the fields **that kind** accepts — read it rather than guessing again, and note `state` spells its selector `path` while `route` spells its `pathname`.
35
+
24
36
  **Assert a consequence, not just presence.** `{ signal }` / `{ net }` prove the feature actually did something; `{ element }` / `{ text }` only prove something is on screen — which a stale render or a locator healed to the wrong element can fake. A _passing_ presence-only `reticle_assert` returns `advice` nudging you to a consequence; heed it on anything that matters.
25
37
 
26
38
  ## The 4-layer cross-check — never trust a green the state contradicts
@@ -71,7 +83,7 @@ Frequently useful but NOT core, so reach them through `reticle_run({ tool, args
71
83
  ## Start here
72
84
 
73
85
  0. Just ran `reticle init` / started the dev server? Poll `reticle_sessions()` until your tab appears — readiness is server-internal now, so the first live call already blocks until the SDK connects.
74
- 1. `reticle_sessions` — find the connected tab (omit `sessionId` if there's only one).
86
+ 1. `reticle_sessions` — find the connected tab (omit `sessionId` if there's only one). **An empty list is not a dead end: read the `why` field.** It names which case this is — no app running, an app running that has never dialled this daemon, a project that never went through `init`, or a tab that closed — and the fix for each. Do not fall back to static reasoning until you have read it.
75
87
  2. `reticle_domain` — learn the app BEFORE testing: the saved flows, what each asserts, and the **gaps** (declared signals/testids that no flow verifies — untested intent). Tells you what to test and where the real risk is without crawling the whole app. Falls back to `reticle_capabilities` for the raw testable surface (`testids`, `signals`, `stores`, `flows`).
76
88
  3. Run the loop: **look → act → observe → assert**, cross-checking the 4 layers on anything that matters.
77
89
 
package/docs/gate-plan.md CHANGED
@@ -77,7 +77,7 @@ Nothing below is trustworthy until a gate result can be distinguished from a gat
77
77
  - [x] **`init` support per client** — `init/mcp-clients.ts` registers Reticle with **seven** clients, not two. Every path and shape was read from that client's own documentation rather than recalled: Cursor/Windsurf/Gemini share `mcpServers`, VS Code uses `servers`, OpenCode uses `mcp` with the command as an **array**, Codex is TOML. A single "write mcp.json" assuming the Cursor shape produces a file three of them ignore — an install that reports success and registers nothing.
78
78
  - Detection is conservative and one-directional: we write into a config a client **already has**, never creating `~/.gemini` or `~/.codeium` for somebody who does not use them.
79
79
  - Codex is deliberately not auto-written. Editing TOML without a parser risks every _other_ server in the user's file; it returns MANUAL with an exact block.
80
- - [x] **Per-client compat script** — `apps/e2e/client-compat.mjs`. Extracts the command from the config **exactly the way that client would**, runs it, and speaks MCP to it. 7/7 clients, 23 assertions, each advertising 17 tools.
80
+ - [x] **Per-client compat script** — `apps/e2e/client-compat.mjs`. Extracts the command from the config **exactly the way that client would**, runs it, and speaks MCP to it. 7/7 clients, 23 assertions, each advertising 18 tools.
81
81
  - It proves only the half a machine can prove, and the verdict is named `runnable-unverified` so the distinction survives into the matrix. Whether the client _reads_ that path needs the real client, which is the submitted half.
82
82
  - [x] **Submission flow** — `apps/e2e/matrix.mjs --validate`, run by CI's `matrix-records` job on every PR. The validator refuses what cannot be acted on: `works` without `checks.toolsVisible` (the self-report the flow exists to replace), `broken` without the client's verbatim error, or any record with no host or version. Its self-check runs first, because a validator that has never refused anything is not a validator. Contributor guide in [`docs/matrix/README.md`](./matrix/README.md).
83
83
  - [x] **Generated `MATRIX.md`** — from submitted records, prettier-ignored because a generated file that fails `format:check` teaches people to skip the gate. **The current honest state is seven clients and ZERO ticks**: `◐` means a runnable entry was written where the client documents it and _nobody has run that client_, and the generated legend says so rather than rounding `◐` up to `✅`.
package/docs/gates.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  ## 0. Last verified
10
10
 
11
- Every gate below was executed end to end against `main` on **2026-08-11** (macOS, M-series, `v2.5.0`). A green row means somebody watched it go green, not that it is supposed to be green.
11
+ Every gate below was executed end to end against `main` on **2026-08-12** (macOS, M-series, `v2.6.0`). A green row means somebody watched it go green, not that it is supposed to be green.
12
12
 
13
13
  | Gate | Result | Wall clock |
14
14
  | --- | --- | --- |
@@ -33,7 +33,7 @@ Do **not** add telemetry inside a tool handler. If you find yourself wanting to,
33
33
 
34
34
  `bug_found` fires once per OCCURRENCE. A defect hit five times in a session is five events, which is the right raw signal — frequency is what says which classes of defect actually cost anybody anything. But it means a naive count answers "how often were defects hit", not "how many defects were found", while looking like it answers the second.
35
35
 
36
- So every `bug_found` carries **`repeat`**: false the first time a KIND is seen in a session, true after. Count `repeat: false` for **distinct defects**; count everything for **instances**. Measured on a real app: 7 events, 3 defects, 4 repeats. Publishing the 7 as defects would have inflated the claim by more than double.
36
+ So every `bug_found` carries **`repeat`**: false the first time a KIND is seen in a session, true after. Count `repeat: false` for **distinct defects**; count everything for **instances**. Measured on a real app, the instance count was more than double the defect count. Publishing instances as defects inflates the claim accordingly.
37
37
 
38
38
  The denominator is **`verification_completed`**, which fires per verdict with `via`, `verified`, `passed` and `falseGreenCaught`. Defects per verification is the honest rate; raw defect counts grow with usage and say nothing on their own.
39
39
 
@@ -98,10 +98,21 @@ The single exception is `daemon_stopped`, which is **awaited** — because the p
98
98
  | `feedback_submitted` | `reticle feedback`, or an agent's report | the qualitative channel |
99
99
  | `identified` | `reticle identify` | joins anonymous machine ids to a person who volunteered one |
100
100
  | `mcp_client_connected` | an MCP client attached | how many sessions are agent-driven at all |
101
+ | `app_instrumented` | the first app carrying the SDK reached this daemon | **the funnel step everything turns on** — see below |
101
102
  | `mcp_connection_lost` | the proxy lost its daemon | **the transport-stability metric.** The disconnect that makes a user reopen `/mcp` is invisible without it |
102
103
  | `init_completed` | `reticle init` finished | does install actually work, outside the fixtures gate |
103
104
  | `bug_found` | a defect was detected in the app under test | the value delivered, as opposed to the work done |
104
105
 
106
+ ## The install has two halves — `app_instrumented`
107
+
108
+ Reticle is only usable when both halves are done: the MCP server is registered so the agent has the tools, and the SDK is loaded by a running page so there is something for those tools to look at. They are done by different commands, at different times, often in different directories. Almost everyone completes the first. The second is where the users go.
109
+
110
+ Nothing measured the second. `daemon_started` and `mcp_client_connected` describe the agent half. `session_appConnects` describes the app half but is a **window counter** — it resets on every flush, so a user whose app connected in one window reads zero in every other. The population it under-counts is precisely the population being measured, and a funnel built on it reported fewer instrumented users than there were users calling tools, which is impossible on its face.
111
+
112
+ `app_instrumented` fires **once per daemon run**, on the first session-ready only, so `daemon_started` → `app_instrumented` is a rate rather than an inference and a reloading page cannot inflate it. It carries `initialized` (had `init` run here), `agentAttached` (was an agent already waiting), and `msToFirstApp` (how long the daemon sat with nothing wired). It deliberately carries no stack and no framework: `project_profiled` already reports both for the same run, and the two join on `sessionId`.
113
+
114
+ What it still cannot see is **why** an app never connected — every cause for that is page-side (the non-localhost gate, a port mismatch, a stale build, a dev server never restarted), where the daemon has no visibility. That needs the SDK to report its own refusals, and it is the next thing to build.
115
+
105
116
  ## Sessions: `daemon_stopped` vs `session_progress`
106
117
 
107
118
  Count sessions with **`daemon_stopped`** (`final: true`). It fires once, at a clean exit.
@@ -124,6 +135,7 @@ Four counters and one flag were added because the data could not answer question
124
135
  | `verification.browser` | `verification_completed` | `headless` \| `headed` \| `attached` — who DROVE the browser. `attached` (Reticle launched nothing, the SDK connected from a browser somebody else opened) is the common case in production, so on its own this is mostly "somebody's own browser". |
125
136
  | `verification.brand` | `verification_completed` | WHICH browser it was: `chrome` \| `edge` \| `arc` \| `dia` \| `brave` \| `opera` \| `firefox` \| `safari` \| `other` — the closed `BrowserBrand` list in core. The axis `engine` cannot answer, since Chrome, Edge, Arc, Dia and Brave are all `blink`. The SDK reads `navigator.userAgentData.brands` (and the UA string on Firefox/Safari, which expose no `userAgentData`) and normalises IN THE PAGE: a raw brand or UA string is unbounded and fingerprintable and never leaves. Anything unrecognised is `other`. **Omitted rather than `"unknown"`** when the page did not say — a desktop webview has no brand and an older SDK does not report one, and a guess is indistinguishable from a measurement on a dashboard. |
126
137
  | `verification.reason` | `verification_completed` | WHICH clause of `decideVerified` produced the verdict, from core's closed `VerifiedReason`: `inconclusive` \| `observation_lost` \| `assertion_failed` \| `contradicted` \| `already_true` \| `unclean_capture` \| `vacuous_grade` \| `outcome_pending` \| `outcome_unread` \| `unsettled` \| `proved`. See below. |
138
+ | `verification.uncleanLoss` | `verification_completed` | WHAT was lost when `reason` is `unclean_capture`, from core's closed `CaptureLoss`: `buffer_loss` (our server ring buffer evicted evidence from the window) \| `transport_gap` (our browser queue overflowed) \| `blind_spot` (a boundary in the page — a cross-origin frame, a closed shadow root) \| `other`. Three owners, three fixes, and one bar on a dashboard until this existed. ONE value, not a list — a multi-value property is not something a breakdown can group by, so the first is sent, ours before the page's. **Absent whenever the capture was clean**, so its presence is itself the signal. Reported as `other` rather than omitted when the block says dirty and names nothing: a gap there would read as "no unclean verdicts happened". |
127
139
  | `bug.attribution` | `bug_found` | `app` \| `request` \| `reticle` — whose fault the defect was. **Absent means unclassified**, never `app`. See below. |
128
140
  | `outage.stage` / `outage.reason` / `outage.attempts` | `mcp_connection_lost` | which stage of the outage, why the stream went away (closed `OutageReason`, `other` for anything unnamed), and how many reconnects had been tried. See below. |
129
141
  | `tzOffsetMin` | every event | minutes offset from UTC. One integer, no location. |
package/docs/telemetry.md CHANGED
@@ -25,7 +25,7 @@ Thirteen kinds of events, each a single small JSON object:
25
25
  | `cli_command_run` | You run a `reticle` command | Which subcommand (`verify`, `status`, …) and which flags were present, by name |
26
26
  | `daemon_started` | The local daemon starts | — |
27
27
  | `daemon_stopped` | The local daemon stops | A summary of the session — see below |
28
- | `verification_completed` | A verification produces a verdict | Whether it passed, and whether Reticle refused to call a passing check verified |
28
+ | `verification_completed` | A verification produces a verdict | Whether it passed, whether Reticle refused to call a passing check verified, and **why** the verdict came out that way — see below |
29
29
  | `project_profiled` | Once per daemon start | The shape of the project — see below |
30
30
  | `version_changed` | You update or roll back | The two version numbers, and which direction |
31
31
  | `runtime_crashed` | The daemon hits an uncaught error | The error's type, **Reticle's own** stack frames, and the message with variables stripped — see below |
@@ -44,6 +44,14 @@ Thirteen kinds of events, each a single small JSON object:
44
44
 
45
45
  It also carries a snapshot of the **machine's** state — our own process's memory, free and total system RAM, load average and CPU count — taken at shutdown and again on any crash. This is what separates "your machine ran out of memory" from "Reticle has a bug", which otherwise produce identical-looking failures. No hostname, no username, no paths, no process list.
46
46
 
47
+ ### Why a verdict came out that way
48
+
49
+ `verification_completed` carries the **clause** that decided the verdict, from a fixed list we define: `proved`, `contradicted`, `assertion_failed`, `already_true`, `unclean_capture`, `vacuous_grade`, `outcome_pending`, `outcome_unread`, `unsettled`, `observation_lost`, `inconclusive`.
50
+
51
+ It exists because `verified: "unknown"` covered seven different situations belonging to three different owners — your app, your agent, and Reticle's own blind spots — and they arrived as one value. It is a **name from our own vocabulary**, never a description of your app: `contradicted` says two channels disagreed, not which ones, about what, or on which page.
52
+
53
+ When the clause is `unclean_capture`, one further name says which of our three losses caused it — `buffer_loss` (our server's event buffer), `transport_gap` (our browser-side queue), `blind_spot` (a boundary in the page, such as a cross-origin frame), or `other`. All four are facts about **Reticle's** ability to observe, not about what it observed. This field is how we found that Reticle was refusing to answer over windows that were completely intact.
54
+
47
55
  ### Bugs Reticle finds
48
56
 
49
57
  When Reticle catches a defect in the app it is verifying, it records **that a class of defect was found** — never what it was found in. The event carries the kind (`signal-contradicted`, `duplicate-request`, `console-error`, …) and how it surfaced, and nothing else: no selector, no URL, no element, no description of your app or its behaviour.
@@ -44,10 +44,10 @@ Measured live, all servers in one run, same tokenizer (`bench/harness/schema-tax
44
44
 
45
45
  | MCP server | tools | schema tokens |
46
46
  | ----------------------------------------- | ----: | ------------: |
47
- | **Reticle — the tool surface** | 17 | **~4,500** |
47
+ | **Reticle — the tool surface** | 18 | **~4,930** |
48
48
  | Playwright MCP | 23 | 3,725 |
49
49
  | Chrome DevTools MCP | 29 | 5,116 |
50
- | Reticle — `RETICLE_ADVERTISE_ALL_TOOLS=1` | 48 | ~32,000 |
50
+ | Reticle — `RETICLE_ADVERTISE_ALL_TOOLS=1` | 48 | ~30,200 |
51
51
 
52
52
  There is one tool surface: the verify loop advertised directly, plus two meta-tools (`reticle_tools`, `reticle_run`) that reach every other tool on demand. Nothing is unreachable; the cold tail simply is not re-sent every turn.
53
53
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticlehq/server",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "Reticle bridge + MCP server. Hosts the browser WS endpoint and exposes MCP tools to the coding agent.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "@modelcontextprotocol/sdk": "^1.13.0",
23
23
  "ws": "^8.18.0",
24
24
  "zod": "^3.24.1",
25
- "@reticlehq/core": "2.6.0"
25
+ "@reticlehq/core": "2.7.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/pngjs": "^6.0.5",