@reticlehq/server 2.5.0 → 2.6.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 (147) hide show
  1. package/SKILL.md +27 -10
  2. package/dist/bridge/bridge.d.ts +12 -0
  3. package/dist/bridge/bridge.js +32 -2
  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/cli-doctor.d.ts +0 -5
  7. package/dist/cli/cli-doctor.js +71 -3
  8. package/dist/cli/cli-parse-feedback.d.ts +31 -0
  9. package/dist/cli/cli-parse-feedback.js +56 -0
  10. package/dist/cli/cli-parse.d.ts +5 -9
  11. package/dist/cli/cli-parse.js +7 -23
  12. package/dist/cli/doctor-daemon-line.d.ts +37 -0
  13. package/dist/cli/doctor-daemon-line.js +35 -0
  14. package/dist/cli/port-holder.d.ts +43 -0
  15. package/dist/cli/port-holder.js +99 -0
  16. package/dist/cli.js +131 -11
  17. package/dist/daemon/daemon-resilience.d.ts +24 -0
  18. package/dist/daemon/daemon-resilience.js +105 -17
  19. package/dist/daemon/daemon.js +26 -1
  20. package/dist/daemon/heartbeat.d.ts +115 -0
  21. package/dist/daemon/heartbeat.js +141 -0
  22. package/dist/daemon/idle-shutdown.d.ts +13 -5
  23. package/dist/daemon/idle-shutdown.js +14 -6
  24. package/dist/daemon/port-presence.d.ts +76 -0
  25. package/dist/daemon/port-presence.js +93 -0
  26. package/dist/daemon/state-dir.d.ts +7 -0
  27. package/dist/daemon/state-dir.js +59 -0
  28. package/dist/daemon/unclean-predecessor.d.ts +23 -0
  29. package/dist/daemon/unclean-predecessor.js +27 -0
  30. package/dist/events/predicate-eval.d.ts +10 -0
  31. package/dist/events/predicate-eval.js +24 -4
  32. package/dist/events/predicate-parse.d.ts +23 -0
  33. package/dist/events/predicate-parse.js +54 -0
  34. package/dist/events/predicate.d.ts +5 -0
  35. package/dist/events/predicate.js +17 -3
  36. package/dist/events/ring-buffer.js +1 -16
  37. package/dist/flows/flow-replay.d.ts +1 -1
  38. package/dist/flows/flow-replay.js +19 -2
  39. package/dist/flows/flow-step-runners.js +2 -3
  40. package/dist/flows/flow-tools.js +10 -14
  41. package/dist/flows/role-drift-reason.d.ts +20 -0
  42. package/dist/flows/role-drift-reason.js +39 -0
  43. package/dist/flows/suite-flakes.d.ts +33 -0
  44. package/dist/flows/suite-flakes.js +32 -0
  45. package/dist/honesty/blind-spots.d.ts +19 -0
  46. package/dist/honesty/blind-spots.js +33 -0
  47. package/dist/honesty/verified.d.ts +10 -1
  48. package/dist/honesty/verified.js +16 -0
  49. package/dist/http-server.js +9 -0
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.js +10 -1
  52. package/dist/init/agent-rules.d.ts +18 -0
  53. package/dist/init/agent-rules.js +22 -4
  54. package/dist/init/cra.d.ts +13 -0
  55. package/dist/init/cra.js +16 -0
  56. package/dist/init/detect.d.ts +6 -0
  57. package/dist/init/detect.js +8 -0
  58. package/dist/init/mcp-clients.d.ts +81 -0
  59. package/dist/init/mcp-clients.js +248 -0
  60. package/dist/init/node-io.js +8 -2
  61. package/dist/init/plan-framework.js +36 -2
  62. package/dist/init/plan.d.ts +14 -0
  63. package/dist/init/plan.js +73 -9
  64. package/dist/init/run.d.ts +18 -1
  65. package/dist/init/run.js +133 -16
  66. package/dist/init/windows-quote.d.ts +38 -0
  67. package/dist/init/windows-quote.js +70 -0
  68. package/dist/mcp/mcp-outage.d.ts +1 -0
  69. package/dist/mcp/mcp-outage.js +8 -1
  70. package/dist/mcp/mcp-proxy.d.ts +11 -1
  71. package/dist/mcp/mcp-proxy.js +30 -7
  72. package/dist/mcp/mcp.js +29 -2
  73. package/dist/pool/browser-pool.d.ts +9 -0
  74. package/dist/pool/browser-pool.js +38 -9
  75. package/dist/session/attachment-history.d.ts +46 -0
  76. package/dist/session/attachment-history.js +65 -0
  77. package/dist/session/no-session-diagnosis.d.ts +7 -0
  78. package/dist/session/no-session-diagnosis.js +53 -3
  79. package/dist/session/no-session-watch.d.ts +3 -1
  80. package/dist/session/no-session-watch.js +10 -2
  81. package/dist/session/pending-commands.js +1 -0
  82. package/dist/session/session-info.d.ts +17 -1
  83. package/dist/session/session-info.js +5 -1
  84. package/dist/session/session-manager.d.ts +3 -0
  85. package/dist/session/session-manager.js +72 -3
  86. package/dist/session/session.d.ts +1 -0
  87. package/dist/session/session.js +8 -0
  88. package/dist/telemetry/crash-cause.d.ts +47 -0
  89. package/dist/telemetry/crash-cause.js +104 -0
  90. package/dist/telemetry/daemon-telemetry.d.ts +25 -7
  91. package/dist/telemetry/daemon-telemetry.js +28 -13
  92. package/dist/telemetry/error-class.d.ts +26 -0
  93. package/dist/telemetry/error-class.js +52 -0
  94. package/dist/telemetry/feedback-cli.d.ts +7 -2
  95. package/dist/telemetry/feedback-cli.js +49 -5
  96. package/dist/telemetry/feedback-context.d.ts +1 -6
  97. package/dist/telemetry/feedback-context.js +64 -4
  98. package/dist/telemetry/git-facts.d.ts +7 -0
  99. package/dist/telemetry/git-facts.js +4 -4
  100. package/dist/telemetry/project-profile.js +2 -1
  101. package/dist/telemetry/session-metrics.d.ts +45 -3
  102. package/dist/telemetry/session-metrics.js +247 -9
  103. package/dist/telemetry/telemetry.d.ts +19 -1
  104. package/dist/telemetry/telemetry.js +31 -8
  105. package/dist/tools/act-tools.js +33 -6
  106. package/dist/tools/assert-verdict.d.ts +7 -1
  107. package/dist/tools/assert-verdict.js +15 -3
  108. package/dist/tools/browser-tools.js +8 -2
  109. package/dist/tools/contract-tools.d.ts +0 -5
  110. package/dist/tools/contract-tools.js +26 -0
  111. package/dist/tools/dynamic-tools.js +6 -1
  112. package/dist/tools/error-recovery.d.ts +14 -0
  113. package/dist/tools/error-recovery.js +83 -7
  114. package/dist/tools/feedback-invite.d.ts +48 -0
  115. package/dist/tools/feedback-invite.js +71 -0
  116. package/dist/tools/invoke-tool.d.ts +0 -6
  117. package/dist/tools/invoke-tool.js +69 -1
  118. package/dist/tools/navigate-arrival.d.ts +26 -0
  119. package/dist/tools/navigate-arrival.js +60 -0
  120. package/dist/tools/navigate-result.d.ts +15 -1
  121. package/dist/tools/navigate-result.js +17 -10
  122. package/dist/tools/observe-tools.js +22 -4
  123. package/dist/tools/real-input-attempt.js +10 -5
  124. package/dist/tools/tool-kit.d.ts +8 -0
  125. package/dist/tools/tool-surface.js +13 -0
  126. package/docs/README.md +55 -0
  127. package/docs/agent-cheatsheet.md +2 -2
  128. package/docs/benchmarks.md +1 -1
  129. package/docs/fixtures-dispatch-receiver.yml +76 -0
  130. package/docs/fixtures.md +17 -0
  131. package/docs/gate-plan.md +98 -0
  132. package/docs/gates.md +117 -0
  133. package/docs/local-registry.md +1 -1
  134. package/docs/matrix/2.5.0/claude-code-machine.json +19 -0
  135. package/docs/matrix/2.5.0/codex-machine.json +20 -0
  136. package/docs/matrix/2.5.0/cursor-machine.json +22 -0
  137. package/docs/matrix/2.5.0/gemini-machine.json +22 -0
  138. package/docs/matrix/2.5.0/opencode-machine.json +22 -0
  139. package/docs/matrix/2.5.0/vscode-machine.json +22 -0
  140. package/docs/matrix/2.5.0/windsurf-machine.json +22 -0
  141. package/docs/matrix/MATRIX.md +26 -0
  142. package/docs/matrix/README.md +59 -0
  143. package/docs/mint.json +1 -1
  144. package/docs/system-map.md +152 -0
  145. package/docs/telemetry-contract.md +2 -2
  146. package/docs/telemetry.md +7 -0
  147. package/package.json +2 -2
@@ -18,7 +18,7 @@ import { spawn } from 'node:child_process';
18
18
  import { randomUUID, createHash } from 'node:crypto';
19
19
  import { appendFileSync, readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, } from 'node:fs';
20
20
  import { homedir, platform } from 'node:os';
21
- import { join } from 'node:path';
21
+ import { dirname, join } from 'node:path';
22
22
  import { TelemetryEventKind, TELEMETRY_EVENT_VERSION, ProjectIdSource, isSessionScoped, } from '@reticlehq/core';
23
23
  import { SERVER_VERSION } from '../version/server-version.js';
24
24
  const RETICLE_DIR = join(homedir(), '.reticle');
@@ -144,17 +144,40 @@ const resolveIdentity = () => {
144
144
  * credentials, `.git` suffix and case removed) so `git@github.com:acme/web.git` and
145
145
  * `https://github.com/Acme/web` are recognized as the same project rather than two.
146
146
  */
147
- const projectFingerprint = (cwd) => {
148
- const { origin } = gitFacts(cwd);
147
+ export const projectFingerprint = (cwd) => {
148
+ const { origin, root } = gitFacts(cwd);
149
+ // Ordered by stability, not by preference: an origin is comparable across machines, a repo root is
150
+ // stable across every directory inside one machine's checkout, a package.json root is the same for
151
+ // a non-git project — and the raw cwd is stable across nothing, which is why it is last. See
152
+ // `project-identity.test.ts` for what a wrong order costs.
153
+ const { key, source } = origin !== undefined
154
+ ? { key: origin, source: ProjectIdSource.GIT_ORIGIN }
155
+ : root !== undefined
156
+ ? { key: root, source: ProjectIdSource.GIT_ROOT }
157
+ : packageRoot(cwd) !== null
158
+ ? { key: packageRoot(cwd) ?? cwd, source: ProjectIdSource.PACKAGE_ROOT }
159
+ : { key: cwd, source: ProjectIdSource.CWD };
149
160
  return {
150
- projectId: createHash('sha256')
151
- .update(origin ?? cwd)
152
- .digest('hex')
153
- .slice(0, 32),
161
+ projectId: createHash('sha256').update(key).digest('hex').slice(0, 32),
154
162
  // Reported alongside so the analytics knows whether this id is comparable to another machine's.
155
- source: origin !== undefined ? ProjectIdSource.GIT_ORIGIN : ProjectIdSource.CWD,
163
+ source,
156
164
  };
157
165
  };
166
+ /** Nearest ancestor holding a package.json — the project boundary when there is no git. */
167
+ const packageRoot = (cwd) => {
168
+ let dir = cwd;
169
+ // Same bound as the git walk: deep monorepos are well inside 20, and an unbounded loop on a
170
+ // pathological path would hang the CLI's startup.
171
+ for (let depth = 0; depth < 20; depth += 1) {
172
+ if (existsSync(join(dir, 'package.json')))
173
+ return dir;
174
+ const parent = dirname(dir);
175
+ if (parent === dir)
176
+ return null;
177
+ dir = parent;
178
+ }
179
+ return null;
180
+ };
158
181
  /** Print the opt-out notice exactly once per machine (honest disclosure, the OSS-trust bar). */
159
182
  const showNoticeOnce = () => {
160
183
  try {
@@ -11,6 +11,7 @@ import { assertNativeInputSupported } from './act-danger.js';
11
11
  import { leanActResult, mutatedWithin } from './act-view.js';
12
12
  import { ReticleTool } from './tool-names.js';
13
13
  import { buildReactionReport, summarizeReaction } from '../events/reaction.js';
14
+ import { parsePredicate } from '../events/predicate-parse.js';
14
15
  import { causalSummary } from '../capsule/causal-summary.js';
15
16
  import { findContradictions } from '../events/contradictions.js';
16
17
  import { decideVerified } from '../honesty/verified.js';
@@ -19,7 +20,7 @@ import { saveFailedAssertCapsule } from './act-capsule.js';
19
20
  import { buildDivergenceCapsule } from '../capsule/capsule.js';
20
21
  import { predicateToExpectedLinks } from '../capsule/predicate-to-links.js';
21
22
  import { buildHonestyBlock } from '../honesty/honesty.js';
22
- import { buildCoverageStatement, blindSpotsFromState, Coverage, impeachesCapture, } from '../honesty/blind-spots.js';
23
+ import { buildCoverageStatement, blindSpotsFromState, transportGapNote, Coverage, impeachesCapture, } from '../honesty/blind-spots.js';
23
24
  import { hasAcceptedWrite } from '../honesty/accepted-write.js';
24
25
  import { hasUnreadWriteOutcome } from '../honesty/unread-outcome.js';
25
26
  import { evaluatePredicate, waitForPredicate, provenExpectedLinks, PredicateSchema, } from '../events/predicate.js';
@@ -54,7 +55,7 @@ export const ACT_TOOLS = [
54
55
  {
55
56
  name: ReticleTool.ACT,
56
57
  example: { ref: 'e42', action: 'fill', args: { value: 'hello' } },
57
- description: 'Execute one action against a ref: click|dblclick|hover|focus|fill|type|clear|select|check|uncheck|submit|press|scrollIntoView. Returns immediately with a `since` cursor — observe the reaction with reticle_observe. For a VERDICT instead of a cursor use reticle_act_and_wait { until } — the same action plus the assertion, in one call; prefer it whenever the action is supposed to cause something. Carries effect:{dispatched,targetMatched,visible,enabled,focusMoved,valueChanged,domMutatedWithin,occluded,occludedBy,scrolledIntoView} to tell "action missed" from "app didn\'t react"; dispatched=landed, settled=a real frame flushed, and a settle timeout never fails the tool. Fields at their uninformative default are OMITTED so a clean action collapses to its consequence: an absent dispatched/targetMatched/visible/enabled means true, an absent occluded/scrolledIntoView/valueChanged/defaultPrevented means false, an absent focusMoved/occludedBy means null. occluded=true means the click point is covered by another element (a real user could not click it) — synthetic dispatch still delivered the event; scrolledIntoView=true means an off-viewport target was scrolled in first. inputMode is "real" (native CDP, no synthetic effect block) or "synthetic"; clicks default to the occlusion-honest synthetic path even when CDP is configured — pass args.native:true to force a trusted native click (file pickers, clipboard). inputModeReason explains any real→synthetic choice so it is never silent. Full model (real-input, throttled tabs, `reticle drive`): docs/usage.md §18.',
58
+ description: 'Act WITHOUT checking the result — if the action is supposed to cause something, use reticle_act_and_wait { until } instead and get the verdict in the same call. This tool returns immediately with a `since` cursor; observe the reaction yourself with reticle_observe. Use it for actions with no observable consequence to assert (focus, hover, scrollIntoView, a fill you are about to submit). One action against a ref: click|dblclick|hover|focus|fill|type|clear|select|check|uncheck|submit|press|scrollIntoView. Carries effect:{dispatched,targetMatched,visible,enabled,focusMoved,valueChanged,domMutatedWithin,occluded,occludedBy,scrolledIntoView} to tell "action missed" from "app didn\'t react"; dispatched=landed, settled=a real frame flushed, and a settle timeout never fails the tool. Fields at their uninformative default are OMITTED so a clean action collapses to its consequence: an absent dispatched/targetMatched/visible/enabled means true, an absent occluded/scrolledIntoView/valueChanged/defaultPrevented means false, an absent focusMoved/occludedBy means null. occluded=true means the click point is covered by another element (a real user could not click it) — synthetic dispatch still delivered the event; scrolledIntoView=true means an off-viewport target was scrolled in first. inputMode is "real" (native CDP, no synthetic effect block) or "synthetic"; clicks default to the occlusion-honest synthetic path even when CDP is configured — pass args.native:true to force a trusted native click (file pickers, clipboard). args.holdMs keeps the pointer DOWN for that long between mousedown and mouseup — the only way to drive a hold-to-confirm control (hold-to-delete, hold-to-record, long-press); effect.heldMs reports the hold actually achieved, which a throttled background tab can stretch. args.confirmDangerous is a permission gate and NOT a duration: it allows a destructive control, it does not hold one. inputModeReason explains any real→synthetic choice so it is never silent. Full model (real-input, throttled tabs, `reticle drive`): docs/usage.md §18.',
58
59
  inputSchema: {
59
60
  ref: z
60
61
  .string()
@@ -63,7 +64,7 @@ export const ACT_TOOLS = [
63
64
  args: z
64
65
  .record(z.unknown())
65
66
  .optional()
66
- .describe('Action-specific arguments: { value } for fill/select, { text } for type/press, { native: true } to force a trusted native click, { confirmDangerous: true } to allow a potentially destructive control.'),
67
+ .describe('Action-specific arguments: { value } for fill/select, { text } for type/press, { native: true } to force a trusted native click, { holdMs: N } to keep the pointer DOWN for N ms (hold-to-confirm controls; effect.heldMs reports what was achieved), { confirmDangerous: true } to allow a potentially destructive control — a permission gate, NOT a duration.'),
67
68
  refuseWhenThrottled: z
68
69
  .boolean()
69
70
  .optional()
@@ -172,7 +173,18 @@ export const ACT_TOOLS = [
172
173
  },
173
174
  {
174
175
  name: ReticleTool.ACT_SEQUENCE,
175
- description: 'Run multiple actions in order (fill -> fill -> submit) in one round-trip. Returns per-step effects[] (see reticle_act).',
176
+ // The example is required for a core tool, and this one carries weight: the measured loop it
177
+ // replaces is literally a login form driven as three separate reticle_act calls (98 clicks and
178
+ // 21 fills inside looping sessions, 2026-08-10/11). Showing fill -> fill -> click is showing the
179
+ // exact shape an agent otherwise spends three round trips on.
180
+ example: {
181
+ steps: [
182
+ { ref: 'e12', action: 'fill', args: { value: 'a@b.com' } },
183
+ { ref: 'e13', action: 'fill', args: { value: 'hunter2' } },
184
+ { ref: 'e14', action: 'click' },
185
+ ],
186
+ },
187
+ description: 'Run multiple actions in order (fill -> fill -> submit) in ONE round-trip. Prefer this over repeating reticle_act for a multi-step journey, then assert its consequence once. Returns per-step effects[] (see reticle_act).',
176
188
  inputSchema: {
177
189
  steps: z
178
190
  .array(z.record(z.unknown()))
@@ -264,6 +276,10 @@ export const ACT_TOOLS = [
264
276
  pass: z.boolean(),
265
277
  evidence: z.unknown().optional(),
266
278
  failureReason: z.string().optional(),
279
+ observationLost: z
280
+ .boolean()
281
+ .optional()
282
+ .describe('The tab disconnected mid-wait, so this was never observed — the verdict is UNKNOWN, not a failure of the app.'),
267
283
  // The STRUCTURED cause — observed / expected / assertion — is what the repair literature ranks
268
284
  // above the prose failureReason (structured feedback beat narrative by 10.5pp) and above a bare
269
285
  // pointer. `verdict` is `await waitForPredicate(...)`, whose EvalResult carries these on a
@@ -332,7 +348,7 @@ export const ACT_TOOLS = [
332
348
  // `predicate` is what reticle_assert / reticle_wait_for call this — see alias-args.ts.
333
349
  const withUntil = aliasParam(args, 'until', ['predicate']);
334
350
  const until = withUntil['until'] !== undefined
335
- ? PredicateSchema.parse(withUntil['until'])
351
+ ? parsePredicate(withUntil['until'])
336
352
  : { kind: PredicateKind.SETTLED };
337
353
  const timeout = asNumber(args['timeout_ms']) ?? DEFAULT_ASSERT_TIMEOUT_MS;
338
354
  // Before anything is driven: this path cannot honour a native-input request, and taking the
@@ -404,12 +420,19 @@ export const ACT_TOOLS = [
404
420
  // structural boundary (virtualized rows, a cross-origin frame) is reported as coverage and
405
421
  // must not downgrade a verdict about what WAS observed.
406
422
  const impeaching = buildCoverageStatement(spots.filter((s) => impeachesCapture(s.kind)));
423
+ // Same rule as reticle_assert: a browser-side transport gap means part of this window was
424
+ // never seen, which is what `blindSpots` exists to say. `truncated` above covers the SERVER
425
+ // ring buffer evicting; this covers the BROWSER queue overflowing, and they are not the same
426
+ // loss — an act_and_wait that graded `proved` over 34 dropped events said so in a sentence
427
+ // whose own words were "over a clean capture".
428
+ const gapNote = transportGapNote(windowEvents);
429
+ const impeachingNotes = [impeaching.note, gapNote].filter((n) => n !== undefined);
407
430
  const honesty = buildHonestyBlock({
408
431
  grade: gradeOf(gradedLinks),
409
432
  attribution: 'window',
410
433
  truncated: session.bufferHealth().dropped > droppedBefore,
411
434
  coveragePartial: Coverage.PARTIAL === coverage.coverage,
412
- ...(impeaching.note === undefined ? {} : { blindSpots: [impeaching.note] }),
435
+ ...(0 === impeachingNotes.length ? {} : { blindSpots: impeachingNotes }),
413
436
  });
414
437
  const capsuleSaved = await saveFailedAssertCapsule({
415
438
  deps,
@@ -436,6 +459,10 @@ export const ACT_TOOLS = [
436
459
  ...(alreadyTrue ? { alreadyTrue } : {}),
437
460
  // An assertion nobody could evaluate must not be reported as one the app failed.
438
461
  ...(verdict.inconclusive === undefined ? {} : { inconclusive: verdict.inconclusive }),
462
+ // Nor must one nobody could OBSERVE. This is the act path, so it is the one that produced
463
+ // the measured false red: a reload mid-wait, graded assertion_failed at the clicked
464
+ // component's own file and line.
465
+ ...(true === verdict.observationLost ? { observationLost: true } : {}),
439
466
  honesty,
440
467
  contradictions,
441
468
  ...(outcomePending ? { outcomePending } : {}),
@@ -16,7 +16,13 @@ import { type Contradiction } from '../events/contradictions.js';
16
16
  */
17
17
  export declare function assertVerdict(session: Session, predicate: Predicate, pass: boolean, since: number,
18
18
  /** Set when the assertion was never evaluated — see honesty/verified.ts. */
19
- inconclusive?: string): Promise<{
19
+ inconclusive?: string,
20
+ /**
21
+ * Set when the tab went away mid-wait, so the assertion was never OBSERVED. `reticle_assert` and
22
+ * `reticle_wait_for` reach the same disconnect path as the act tools, and a verdict that blames
23
+ * the app for a lost connection is no more honest on this route than on that one.
24
+ */
25
+ observationLost?: boolean): Promise<{
20
26
  decision: Record<string, unknown>;
21
27
  contradictions: Contradiction[];
22
28
  coverage: Record<string, unknown>;
@@ -1,5 +1,5 @@
1
1
  import { findContradictions } from '../events/contradictions.js';
2
- import { blindSpotsFromState, buildCoverageStatement, Coverage, impeachesCapture, } from '../honesty/blind-spots.js';
2
+ import { blindSpotsFromState, buildCoverageStatement, Coverage, impeachesCapture, transportGapNote, } from '../honesty/blind-spots.js';
3
3
  import { buildHonestyBlock } from '../honesty/honesty.js';
4
4
  import { hasAcceptedWrite } from '../honesty/accepted-write.js';
5
5
  import { hasUnreadWriteOutcome } from '../honesty/unread-outcome.js';
@@ -20,7 +20,13 @@ import { gradeOfPredicate } from './assert-grade.js';
20
20
  */
21
21
  export async function assertVerdict(session, predicate, pass, since,
22
22
  /** Set when the assertion was never evaluated — see honesty/verified.ts. */
23
- inconclusive) {
23
+ inconclusive,
24
+ /**
25
+ * Set when the tab went away mid-wait, so the assertion was never OBSERVED. `reticle_assert` and
26
+ * `reticle_wait_for` reach the same disconnect path as the act tools, and a verdict that blames
27
+ * the app for a lost connection is no more honest on this route than on that one.
28
+ */
29
+ observationLost) {
24
30
  // Scope caveat, stated because it is a real limitation and not a bug: blind spots are tracked per
25
31
  // SESSION, not per assertion window, so a cross-origin iframe seen once marks every later verdict
26
32
  // in that session partial — including ones about a region it cannot affect. That errs toward
@@ -46,16 +52,22 @@ inconclusive) {
46
52
  // Only a spot that IMPEACHES the capture downgrades a verdict. A structural boundary (virtualized
47
53
  // rows, a cross-origin frame) is reported as coverage and must not impugn what WAS observed.
48
54
  const impeaching = buildCoverageStatement(spots.filter((sp) => impeachesCapture(sp.kind)));
55
+ // A gap in the WINDOW, as opposed to a standing limit of the page. Both mean the same thing to the
56
+ // rule — part of what happened was not seen — so both belong in `blindSpots`, which is the only
57
+ // input `decideVerified` reads for that.
58
+ const gap = transportGapNote(windowEvents);
59
+ const impeachingNotes = [impeaching.note, gap].filter((n) => n !== undefined);
49
60
  const outcomePending = hasAcceptedWrite(windowEvents);
50
61
  const outcomeUnread = hasUnreadWriteOutcome(windowEvents);
51
62
  const decision = decideVerified({
52
63
  pass,
53
64
  ...(inconclusive === undefined ? {} : { inconclusive }),
65
+ ...(true === observationLost ? { observationLost: true } : {}),
54
66
  honesty: buildHonestyBlock({
55
67
  grade: gradeOfPredicate(predicate),
56
68
  attribution: 'window',
57
69
  coveragePartial: Coverage.PARTIAL === statement.coverage,
58
- ...(impeaching.note === undefined ? {} : { blindSpots: [impeaching.note] }),
70
+ ...(0 === impeachingNotes.length ? {} : { blindSpots: impeachingNotes }),
59
71
  }),
60
72
  contradictions,
61
73
  ...(outcomePending ? { outcomePending } : {}),
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { navigateResult } from './navigate-result.js';
3
+ import { awaitArrival } from './navigate-arrival.js';
3
4
  import { reloadResult } from './reload-result.js';
4
5
  import { waitForReconnect, RELOAD_RECONNECT_TIMEOUT_MS } from '../session/session-reconnect.js';
5
6
  import { ReticleCommand } from '@reticlehq/core';
@@ -10,7 +11,7 @@ export const BROWSER_TOOLS = [
10
11
  {
11
12
  name: ReticleTool.NAVIGATE,
12
13
  example: { url: '/settings' },
13
- description: 'Navigate the connected browser tab to a URL, or reload it in place with { reload: true } (add { hard: true } to bypass the cache). `ok` means the navigation was DISPATCHED, not that the page arrived — the SDK is torn down by the navigation, so nothing can observe the new document. Call reticle_sessions to confirm a session reconnected at the new URL before acting.',
14
+ description: 'Navigate the connected browser tab to a URL, or reload it in place with { reload: true } (add { hard: true } to bypass the cache). `ok` means the navigation was DISPATCHED — the SDK is torn down by the navigation, so the page itself cannot report on it. The daemon then waits briefly for the SDK to reconnect: `confirmed:true` with a new `sessionId` means the page arrived and you can act immediately. `confirmed:false` means it did not arrive within the window — the page may be slow, uninstrumented, or not there; check reticle_sessions before acting.',
14
15
  inputSchema: {
15
16
  url: z.string().optional().describe('The URL to navigate to. Omit when using reload.'),
16
17
  reload: z
@@ -30,6 +31,8 @@ export const BROWSER_TOOLS = [
30
31
  // Present on a dispatched navigation: nothing here can see the new document, so arrival is
31
32
  // reported as unconfirmed rather than implied by `ok`. See navigate-result.ts.
32
33
  confirmed: z.boolean().optional(),
34
+ /** The session the SDK reconnected as, when arrival was confirmed — it is a NEW id. */
35
+ sessionId: z.string().optional(),
33
36
  note: z.string().optional(),
34
37
  },
35
38
  handler: async (deps, args) => {
@@ -65,7 +68,10 @@ export const BROWSER_TOOLS = [
65
68
  try {
66
69
  const result = (await commandOrThrow(deps, asString(args['sessionId']), ReticleCommand.NAVIGATE, { url }));
67
70
  // `ok` is the browser accepting the instruction, not the page arriving — see navigate-result.
68
- return navigateResult(result);
71
+ // The daemon is the only party that CAN see arrival (the SDK reconnects to it), so it looks,
72
+ // briefly, instead of telling the agent to go poll reticle_sessions itself.
73
+ const arrival = true === result.ok ? await awaitArrival(deps.sessions, url) : null;
74
+ return navigateResult(result, arrival);
69
75
  }
70
76
  finally {
71
77
  session.finishAction();
@@ -1,7 +1,2 @@
1
1
  import type { ToolDef } from './tools.js';
2
- /**
3
- * The capability-contract tools. `reticle_capabilities` reads the live session, or the
4
- * git-checked `.reticle/contract.json` when `{ fromDisk:true }`; `reticle_contract_save` persists the
5
- * live registry to that file (pretty-printed, stable key order — diffable in PRs).
6
- */
7
2
  export declare const CONTRACT_TOOLS: ToolDef[];
@@ -9,6 +9,31 @@ import { reticleDirPaths, readContract, writeContract } from '../project/reticle
9
9
  * git-checked `.reticle/contract.json` when `{ fromDisk:true }`; `reticle_contract_save` persists the
10
10
  * live registry to that file (pretty-printed, stable key order — diffable in PRs).
11
11
  */
12
+ /**
13
+ * Refuse to persist one project's surface into another project's repo.
14
+ *
15
+ * `writeContract` writes to the DAEMON's `.reticle/`, which is the directory the daemon was started
16
+ * in — not the session's project. A daemon started above several apps therefore saves app A's
17
+ * testids into app B's checkout, reports `saved: true`, and hands back a path that looks right. A
18
+ * contract is git-checked and diffable, so that is a change somebody commits.
19
+ *
20
+ * Refuse rather than redirect: a session advertises a projectId, not a project DIRECTORY, so there
21
+ * is no honest way to work out where the correct `.reticle/` lives. Naming both ids and stopping is
22
+ * the whole of what can be said truthfully.
23
+ *
24
+ * Silence is not a mismatch. An unstamped build carries no projectId, and a daemon above an
25
+ * uninitialised directory has none either; neither is evidence of two projects.
26
+ */
27
+ function assertSameProject(deps, sessionId) {
28
+ const mine = deps.projectId;
29
+ const theirs = deps.sessions.resolve(sessionId).projectId;
30
+ if (mine === undefined || theirs === undefined || mine === theirs)
31
+ return;
32
+ throw new Error(`refusing to save: this session belongs to project '${theirs}', but this daemon writes to ` +
33
+ `project '${mine}' (.reticle/ in the directory it was started in). Saving would put one ` +
34
+ "app's testable surface into another app's git-checked contract. Run a daemon from " +
35
+ `'${theirs}'s own directory and save there.`);
36
+ }
12
37
  export const CONTRACT_TOOLS = [
13
38
  {
14
39
  name: ReticleTool.CAPABILITIES,
@@ -54,6 +79,7 @@ export const CONTRACT_TOOLS = [
54
79
  handler: async (deps, args) => {
55
80
  const res = await commandOrThrow(deps, asString(args['sessionId']), ReticleCommand.CAPABILITIES, {});
56
81
  const caps = CapabilitiesSchema.parse(res);
82
+ assertSameProject(deps, asString(args['sessionId']));
57
83
  await writeContract(deps.fs, deps.reticleRoot, caps, deps.now);
58
84
  return {
59
85
  saved: true,
@@ -141,7 +141,12 @@ export function buildDynamicTools(allTools, profile) {
141
141
  if (target === undefined) {
142
142
  // A non-zero count here means our advertised surface is confusing the agent — it reached for
143
143
  // something it believed existed. That is a docs/naming defect, and it was invisible.
144
- getSessionMetrics().recordUnknownTool();
144
+ //
145
+ // The NAME goes with it: the count says the surface confused someone, the name says which
146
+ // capability they expected. It was already in hand here and thrown away, so the one place
147
+ // the product could learn what agents want was discarding it. Safe — a name from our own
148
+ // tool namespace, never app data.
149
+ getSessionMetrics().recordUnknownTool(name);
145
150
  // ...and for 22 of those names the capability DOES exist, it just moved when tools merged.
146
151
  // Answering "unknown tool" there is simply wrong; see merged-name-redirect.
147
152
  const moved = mergedNameRedirect(name);
@@ -40,6 +40,20 @@ export declare const RECOVERY: {
40
40
  * saying a server is running and the recovery saying to go start one.
41
41
  */
42
42
  export declare function recoveryFor(message: string): string | undefined;
43
+ /**
44
+ * A serialized zod issue array, rendered as a sentence. Anything else is returned untouched.
45
+ *
46
+ * This is the one boundary every agent-visible tool failure crosses, which is why the rewrite lives
47
+ * here rather than in each tool. `parsePredicate` solved this for the three predicate tools by never
48
+ * producing the array; it cannot help anywhere else, because the MCP SDK validates a tool's schema
49
+ * BEFORE our handler runs. So `reticle_session` with no action — the commonest mistake there is —
50
+ * never reaches the friendly "unknown action … expected: [tune, yield, …]" its handler already
51
+ * builds, and the agent gets the raw array instead. Driven live against the shipped daemon.
52
+ *
53
+ * Conservative by construction: it only rewrites a JSON ARRAY whose every element carries both
54
+ * `code` and `message`. A tool that legitimately returns JSON is left alone.
55
+ */
56
+ export declare function zodArrayAsSentence(message: string): string;
43
57
  /**
44
58
  * The ask attached to errors we do NOT recognize. An unrecognized error is, by definition, the case
45
59
  * where Reticle failed in a way nobody anticipated — the single highest-value moment to hear from the
@@ -40,7 +40,13 @@ function capMessage(message) {
40
40
  * it was still collecting the defect ask, inviting a bug report about a refusal that had just told
41
41
  * the agent precisely what to do.
42
42
  */
43
- const SELF_RECOVERING = [/cannot drive native input/i];
43
+ const SELF_RECOVERING = [
44
+ /cannot drive native input/i,
45
+ // The unknown-session refusal now lists the live ids inline. Appending "call reticle_sessions for
46
+ // the current ids" to a message that just named them contradicts the shorter path it offers, and
47
+ // a contradicted instruction is how one agent came to retry a dead id twelve times.
48
+ /Connected right now:/i,
49
+ ];
44
50
  /** A message that already carries its own concrete next action, so nothing should be appended. */
45
51
  function isSelfRecovering(message) {
46
52
  if (message.includes(SELF_RECOVERING_MARKER))
@@ -167,8 +173,13 @@ const RULES = [
167
173
  match: /potentially destructive (?:\w+ )*(?:action|tool) blocked/i,
168
174
  hint: RECOVERY.CONFIRM_DANGEROUS,
169
175
  },
176
+ // Split in two because `^` binds to the FIRST alternative only. As one pattern this read as though
177
+ // the anchor governed all four spellings; it never did, and the next person to add a fifth would
178
+ // have had a coin-flip's chance of getting the behaviour they intended. The anchored spelling is
179
+ // one Reticle authors at the start of its own message; the other three appear mid-message.
180
+ { match: /^cannot [\w()]+ (?:a|an|into a|on a) </i, hint: RECOVERY.WRONG_TARGET },
170
181
  {
171
- match: /^cannot [\w()]+ (?:a|an|into a|on a) <|no form to submit|upload target must be|is not an HTMLElement/i,
182
+ match: /no form to submit|upload target must be|is not an HTMLElement/i,
172
183
  hint: RECOVERY.WRONG_TARGET,
173
184
  },
174
185
  // Authored by Reticle, about the caller's arguments: the message already names the valid answers.
@@ -205,8 +216,74 @@ export function recoveryFor(message) {
205
216
  *
206
217
  * Matched by the SHAPES the validators actually produce (zod's codes, and the MCP layer's wrapper),
207
218
  * because these are our own machinery talking, not the app.
219
+ *
220
+ * `did not parse` is the shape `parsePredicate` produces. It was added because removing the raw zod
221
+ * array from that path ALSO removed the codes this pattern keys on — so a malformed predicate
222
+ * stopped being recognised as the agent's mistake and started being blamed on Reticle, complete with
223
+ * a request for a bug report. The e2e battery caught it (`no bad argument is blamed on Reticle`);
224
+ * no unit test could, which is why one now exists below.
225
+ */
226
+ /** Max issues rendered — a union rejection can emit one per member, which is how the array became unreadable. */
227
+ const MAX_RENDERED_ISSUES = 3;
228
+ function isZodIssue(value) {
229
+ if (typeof value !== 'object' || null === value)
230
+ return false;
231
+ const v = value;
232
+ return 'string' === typeof v['code'] && 'string' === typeof v['message'];
233
+ }
234
+ /** `["net","urlContains"]` → `net.urlContains`; an empty path means the object itself. */
235
+ function issueClause(issue) {
236
+ if ('unrecognized_keys' === issue.code && Array.isArray(issue.keys)) {
237
+ return `unknown field ${issue.keys.map(String).join(', ')}`;
238
+ }
239
+ const path = issue.path ?? [];
240
+ return `${0 === path.length ? 'the arguments' : path.map(String).join('.')}: ${issue.message}`;
241
+ }
242
+ /**
243
+ * A serialized zod issue array, rendered as a sentence. Anything else is returned untouched.
244
+ *
245
+ * This is the one boundary every agent-visible tool failure crosses, which is why the rewrite lives
246
+ * here rather than in each tool. `parsePredicate` solved this for the three predicate tools by never
247
+ * producing the array; it cannot help anywhere else, because the MCP SDK validates a tool's schema
248
+ * BEFORE our handler runs. So `reticle_session` with no action — the commonest mistake there is —
249
+ * never reaches the friendly "unknown action … expected: [tune, yield, …]" its handler already
250
+ * builds, and the agent gets the raw array instead. Driven live against the shipped daemon.
251
+ *
252
+ * Conservative by construction: it only rewrites a JSON ARRAY whose every element carries both
253
+ * `code` and `message`. A tool that legitimately returns JSON is left alone.
254
+ */
255
+ export function zodArrayAsSentence(message) {
256
+ const text = message.trim();
257
+ if (!text.startsWith('['))
258
+ return message;
259
+ let parsed;
260
+ try {
261
+ parsed = JSON.parse(text);
262
+ }
263
+ catch {
264
+ return message; // not JSON at all — e.g. prose that happens to open with a bracket
265
+ }
266
+ if (!Array.isArray(parsed) || 0 === parsed.length || !parsed.every(isZodIssue))
267
+ return message;
268
+ const shown = parsed.slice(0, MAX_RENDERED_ISSUES).map(issueClause).join('; ');
269
+ const rest = parsed.length - MAX_RENDERED_ISSUES;
270
+ return rest > 0 ? `${shown} (and ${String(rest)} more)` : shown;
271
+ }
272
+ /**
273
+ * `unknown field` and `<path>: Required` are the shapes `zodArrayAsSentence` produces.
274
+ *
275
+ * Added for the same reason `did not parse` was: rendering the array as a sentence REMOVES the very
276
+ * codes (`invalid_type`, `unrecognized_keys`) this pattern matched on, so a schema rejection fell
277
+ * through to the generic branch and came back as "may be a defect in Reticle" — worse than the dump
278
+ * it replaced, because it also asks for a bug report about the agent's own malformed call. This repo
279
+ * has now made that mistake twice; the second time a test caught it before CI did.
280
+ *
281
+ * Widened a THIRD time for PR #182, which formats the MCP SDK's validation error at the argument
282
+ * boundary — "Missing required parameter for reticle_session: action (one of: …)". That is better
283
+ * prose than the field-level sentence below (it names the TOOL and the allowed VALUES) and it was
284
+ * authored before this formatter existed, so it must not be punished for arriving first.
208
285
  */
209
- const ARGUMENT_REJECTION = /Unrecognized key|unrecognized_keys|invalid_type|Invalid arguments for tool|Unknown parameters? for|^Required |Expected \w+, received/i;
286
+ const ARGUMENT_REJECTION = /Unrecognized key|unrecognized_keys|invalid_type|did not parse|unknown field|: Required\b|Missing required parameter|Invalid parameter for|Invalid arguments for tool|Unknown parameters? for|^Required |Expected \w+, received/i;
210
287
  /**
211
288
  * What to say instead of asking for a bug report. The schema already stated the problem precisely;
212
289
  * this only says who can fix it and where to look.
@@ -237,10 +314,9 @@ export const FEEDBACK_ASK = 'This error is not one Reticle recognizes, which mea
237
314
  * agent always gets exactly one next move.
238
315
  */
239
316
  export function buildErrorPayload(rawMessage) {
240
- // Cap FIRST, then classify on the capped text — so what the classifier reads is exactly what the
241
- // agent reads. Classifying the full message and emitting a shorter one would let a recovery hint
242
- // reference a phrase that is no longer there.
243
- const message = capMessage(rawMessage);
317
+ // Render the zod array BEFORE capping: the cap would otherwise truncate the JSON mid-issue and
318
+ // leave an unparseable fragment as the agent's error text — the worst of both shapes.
319
+ const message = capMessage(zodArrayAsSentence(rawMessage));
244
320
  // A self-diagnosing message gets NEITHER a generic recovery nor the feedback ask. It already
245
321
  // inspected the machine and named the cause; a second, contradictory hint is noise, and inviting a
246
322
  // bug report about a condition Reticle diagnosed itself is exactly backwards.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The one line that tells an agent it can push back — worded for what just happened.
3
+ *
4
+ * Feedback is the highest-yield channel this product has: **14 reports over two days produced
5
+ * roughly 11 of the fixes in 2.6.0**, against ~6,100 numeric events that produced a funnel which
6
+ * turned out to be broken. Prose from an agent at the moment of friction beats any structured field
7
+ * we could add.
8
+ *
9
+ * It reached those 14 through a standing instruction in the MCP handshake — paid once at connect.
10
+ * The gap that leaves is the agent who hits something mid-task and is concentrating on the problem
11
+ * rather than on remembering that a feedback tool exists.
12
+ *
13
+ * So: invite at the moment of friction, and vary the wording. A fixed string repeated forty times
14
+ * is invisible by call five; a line that names what just went wrong is read because it is about the
15
+ * thing the agent is already looking at. ~12 tokens against a ~4,546 tok/turn surface.
16
+ *
17
+ * Every invite is counted (`feedbackPrompted`). If `feedback_submitted ÷ feedbackPrompted` stays
18
+ * flat, this is decoration and should be deleted — instrumenting our own nudge is the difference
19
+ * between a designed system and a hope.
20
+ */
21
+ /** What just happened, as far as the chokepoint can tell. Ordered most specific first. */
22
+ export declare const FrictionKind: {
23
+ /** A tool that does not exist — the agent named a capability it expected us to have. */
24
+ readonly UNKNOWN_TOOL: "unknown_tool";
25
+ /** A verdict came back `unknown`: we drove the app and could not tell what happened. */
26
+ readonly UNKNOWN_VERDICT: "unknown_verdict";
27
+ /** The same tool, several times in a row. */
28
+ readonly REPEATING: "repeating";
29
+ /** The tool refused or errored. */
30
+ readonly REFUSED: "refused";
31
+ };
32
+ export type FrictionKind = (typeof FrictionKind)[keyof typeof FrictionKind];
33
+ /**
34
+ * Which friction, if any, this result represents.
35
+ *
36
+ * Deliberately reads only what the chokepoint already has: no handler changes, and nothing here can
37
+ * see app data.
38
+ */
39
+ export declare function frictionOf(facts: {
40
+ unknownTool: boolean;
41
+ verifiedUnknown: boolean;
42
+ repeatRun: number;
43
+ errored: boolean;
44
+ /** Did this call produce a verdict? A verdict is progress, and progress is not being stuck. */
45
+ producedVerdict: boolean;
46
+ }): FrictionKind | undefined;
47
+ /** The line for a friction kind. */
48
+ export declare function inviteFor(kind: FrictionKind): string;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The one line that tells an agent it can push back — worded for what just happened.
3
+ *
4
+ * Feedback is the highest-yield channel this product has: **14 reports over two days produced
5
+ * roughly 11 of the fixes in 2.6.0**, against ~6,100 numeric events that produced a funnel which
6
+ * turned out to be broken. Prose from an agent at the moment of friction beats any structured field
7
+ * we could add.
8
+ *
9
+ * It reached those 14 through a standing instruction in the MCP handshake — paid once at connect.
10
+ * The gap that leaves is the agent who hits something mid-task and is concentrating on the problem
11
+ * rather than on remembering that a feedback tool exists.
12
+ *
13
+ * So: invite at the moment of friction, and vary the wording. A fixed string repeated forty times
14
+ * is invisible by call five; a line that names what just went wrong is read because it is about the
15
+ * thing the agent is already looking at. ~12 tokens against a ~4,546 tok/turn surface.
16
+ *
17
+ * Every invite is counted (`feedbackPrompted`). If `feedback_submitted ÷ feedbackPrompted` stays
18
+ * flat, this is decoration and should be deleted — instrumenting our own nudge is the difference
19
+ * between a designed system and a hope.
20
+ */
21
+ /** What just happened, as far as the chokepoint can tell. Ordered most specific first. */
22
+ export const FrictionKind = {
23
+ /** A tool that does not exist — the agent named a capability it expected us to have. */
24
+ UNKNOWN_TOOL: 'unknown_tool',
25
+ /** A verdict came back `unknown`: we drove the app and could not tell what happened. */
26
+ UNKNOWN_VERDICT: 'unknown_verdict',
27
+ /** The same tool, several times in a row. */
28
+ REPEATING: 'repeating',
29
+ /** The tool refused or errored. */
30
+ REFUSED: 'refused',
31
+ };
32
+ /**
33
+ * Runs of one tool before it counts as being stuck.
34
+ *
35
+ * Three, matching the verdict nudge: `reticle_act` led the field data at 110 consecutive-repeat
36
+ * runs, and a run of three is where a deliberate retry becomes a loop.
37
+ */
38
+ const REPEAT_INVITE_AT = 3;
39
+ const LINES = {
40
+ [FrictionKind.UNKNOWN_TOOL]: 'no such tool — reticle_feedback tells us what you expected it to do, which is how it gets built',
41
+ [FrictionKind.UNKNOWN_VERDICT]: 'Reticle could not tell what happened here. If you expected otherwise, reticle_feedback — an ' +
42
+ 'unknown verdict is our defect, not yours',
43
+ [FrictionKind.REPEATING]: 'stuck on the same call? reticle_feedback — say what you were trying to do and it gets fixed',
44
+ [FrictionKind.REFUSED]: 'if that diagnosis was wrong or unhelpful, reticle_feedback — error messages are a product ' +
45
+ 'surface and we fix the ones that fail',
46
+ };
47
+ /**
48
+ * Which friction, if any, this result represents.
49
+ *
50
+ * Deliberately reads only what the chokepoint already has: no handler changes, and nothing here can
51
+ * see app data.
52
+ */
53
+ export function frictionOf(facts) {
54
+ if (facts.unknownTool)
55
+ return FrictionKind.UNKNOWN_TOOL;
56
+ if (facts.verifiedUnknown)
57
+ return FrictionKind.UNKNOWN_VERDICT;
58
+ // Repetition is only friction when it is repetition WITHOUT PROGRESS. Found by driving the Tauri
59
+ // smoke app: three consecutive SUCCESSFUL act_and_wait calls, each producing a verdict, were
60
+ // answered with "stuck on the same call?" — nagging the exact loop this release exists to
61
+ // encourage. A verdict is progress.
62
+ if (facts.repeatRun >= REPEAT_INVITE_AT && !facts.producedVerdict)
63
+ return FrictionKind.REPEATING;
64
+ if (facts.errored)
65
+ return FrictionKind.REFUSED;
66
+ return undefined;
67
+ }
68
+ /** The line for a friction kind. */
69
+ export function inviteFor(kind) {
70
+ return LINES[kind];
71
+ }
@@ -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>;