@reticlehq/server 2.5.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/SKILL.md +226 -26
  2. package/dist/bridge/bridge.d.ts +18 -1
  3. package/dist/bridge/bridge.js +59 -6
  4. package/dist/bridge/bridge.test-harness.d.ts +8 -0
  5. package/dist/bridge/bridge.test-harness.js +9 -1
  6. package/dist/cli/chromium-hint.d.ts +40 -0
  7. package/dist/cli/chromium-hint.js +75 -0
  8. package/dist/cli/cli-doctor.d.ts +0 -5
  9. package/dist/cli/cli-doctor.js +74 -14
  10. package/dist/cli/cli-launch.d.ts +1 -0
  11. package/dist/cli/cli-launch.js +4 -1
  12. package/dist/cli/cli-parse-feedback.d.ts +31 -0
  13. package/dist/cli/cli-parse-feedback.js +56 -0
  14. package/dist/cli/cli-parse.d.ts +5 -9
  15. package/dist/cli/cli-parse.js +7 -23
  16. package/dist/cli/cli-port.js +20 -1
  17. package/dist/cli/doctor-daemon-line.d.ts +37 -0
  18. package/dist/cli/doctor-daemon-line.js +44 -0
  19. package/dist/cli/port-holder.d.ts +43 -0
  20. package/dist/cli/port-holder.js +99 -0
  21. package/dist/cli.js +170 -19
  22. package/dist/daemon/daemon-resilience.d.ts +24 -0
  23. package/dist/daemon/daemon-resilience.js +105 -17
  24. package/dist/daemon/daemon.js +26 -1
  25. package/dist/daemon/heartbeat.d.ts +115 -0
  26. package/dist/daemon/heartbeat.js +141 -0
  27. package/dist/daemon/idle-shutdown.d.ts +13 -5
  28. package/dist/daemon/idle-shutdown.js +14 -6
  29. package/dist/daemon/loopback-alias.d.ts +32 -0
  30. package/dist/daemon/loopback-alias.js +80 -0
  31. package/dist/daemon/port-presence.d.ts +76 -0
  32. package/dist/daemon/port-presence.js +93 -0
  33. package/dist/daemon/state-dir.d.ts +7 -0
  34. package/dist/daemon/state-dir.js +59 -0
  35. package/dist/daemon/unclean-predecessor.d.ts +23 -0
  36. package/dist/daemon/unclean-predecessor.js +27 -0
  37. package/dist/events/predicate-eval.d.ts +17 -3
  38. package/dist/events/predicate-eval.js +183 -96
  39. package/dist/events/predicate-parse.d.ts +23 -0
  40. package/dist/events/predicate-parse.js +68 -0
  41. package/dist/events/predicate.d.ts +5 -0
  42. package/dist/events/predicate.js +17 -3
  43. package/dist/events/ring-buffer.d.ts +8 -0
  44. package/dist/events/ring-buffer.js +35 -17
  45. package/dist/flows/flow-replay.d.ts +1 -1
  46. package/dist/flows/flow-replay.js +19 -2
  47. package/dist/flows/flow-step-runners.js +2 -3
  48. package/dist/flows/flow-tools.js +10 -14
  49. package/dist/flows/role-drift-reason.d.ts +20 -0
  50. package/dist/flows/role-drift-reason.js +39 -0
  51. package/dist/flows/suite-flakes.d.ts +33 -0
  52. package/dist/flows/suite-flakes.js +32 -0
  53. package/dist/honesty/blind-spots.d.ts +19 -0
  54. package/dist/honesty/blind-spots.js +33 -0
  55. package/dist/honesty/honesty.d.ts +11 -0
  56. package/dist/honesty/honesty.js +9 -1
  57. package/dist/honesty/verified.d.ts +10 -1
  58. package/dist/honesty/verified.js +42 -3
  59. package/dist/http-server.js +20 -1
  60. package/dist/index.d.ts +1 -0
  61. package/dist/index.js +36 -3
  62. package/dist/init/agent-rules.d.ts +18 -0
  63. package/dist/init/agent-rules.js +28 -5
  64. package/dist/init/cra.d.ts +13 -0
  65. package/dist/init/cra.js +16 -0
  66. package/dist/init/detect.d.ts +14 -0
  67. package/dist/init/detect.js +23 -0
  68. package/dist/init/mcp-clients.d.ts +81 -0
  69. package/dist/init/mcp-clients.js +248 -0
  70. package/dist/init/node-io.js +8 -2
  71. package/dist/init/non-js-project.d.ts +27 -0
  72. package/dist/init/non-js-project.js +60 -0
  73. package/dist/init/plan-framework.d.ts +9 -0
  74. package/dist/init/plan-framework.js +55 -3
  75. package/dist/init/plan.d.ts +14 -0
  76. package/dist/init/plan.js +93 -12
  77. package/dist/init/run.d.ts +18 -1
  78. package/dist/init/run.js +169 -23
  79. package/dist/init/snippets.d.ts +20 -1
  80. package/dist/init/snippets.js +71 -6
  81. package/dist/init/windows-quote.d.ts +38 -0
  82. package/dist/init/windows-quote.js +70 -0
  83. package/dist/mcp/mcp-outage.d.ts +1 -0
  84. package/dist/mcp/mcp-outage.js +8 -1
  85. package/dist/mcp/mcp-proxy.d.ts +11 -1
  86. package/dist/mcp/mcp-proxy.js +39 -9
  87. package/dist/mcp/mcp.js +64 -2
  88. package/dist/mcp/proxy-lifecycle.d.ts +19 -0
  89. package/dist/mcp/proxy-lifecycle.js +21 -0
  90. package/dist/pool/browser-pool.d.ts +9 -0
  91. package/dist/pool/browser-pool.js +38 -9
  92. package/dist/pool/playwright-launcher.js +10 -2
  93. package/dist/session/attachment-history.d.ts +46 -0
  94. package/dist/session/attachment-history.js +65 -0
  95. package/dist/session/live-control-tools.js +39 -2
  96. package/dist/session/live-control.d.ts +7 -9
  97. package/dist/session/live-control.js +34 -1
  98. package/dist/session/no-session-diagnosis.d.ts +11 -4
  99. package/dist/session/no-session-diagnosis.js +95 -14
  100. package/dist/session/no-session-watch.d.ts +3 -1
  101. package/dist/session/no-session-watch.js +10 -2
  102. package/dist/session/pending-commands.js +1 -0
  103. package/dist/session/review-store.d.ts +19 -0
  104. package/dist/session/review-store.js +34 -5
  105. package/dist/session/session-info.d.ts +17 -1
  106. package/dist/session/session-info.js +5 -1
  107. package/dist/session/session-manager.d.ts +12 -0
  108. package/dist/session/session-manager.js +83 -3
  109. package/dist/session/session.d.ts +9 -0
  110. package/dist/session/session.js +20 -0
  111. package/dist/status-payload.d.ts +13 -1
  112. package/dist/status-payload.js +6 -1
  113. package/dist/telemetry/app-instrumented.d.ts +37 -0
  114. package/dist/telemetry/app-instrumented.js +54 -0
  115. package/dist/telemetry/crash-cause.d.ts +47 -0
  116. package/dist/telemetry/crash-cause.js +104 -0
  117. package/dist/telemetry/daemon-telemetry.d.ts +25 -7
  118. package/dist/telemetry/daemon-telemetry.js +32 -13
  119. package/dist/telemetry/error-class.d.ts +26 -0
  120. package/dist/telemetry/error-class.js +52 -0
  121. package/dist/telemetry/feedback-cli.d.ts +7 -2
  122. package/dist/telemetry/feedback-cli.js +49 -5
  123. package/dist/telemetry/feedback-context.d.ts +1 -6
  124. package/dist/telemetry/feedback-context.js +64 -4
  125. package/dist/telemetry/git-facts.d.ts +7 -0
  126. package/dist/telemetry/git-facts.js +4 -4
  127. package/dist/telemetry/project-profile.js +2 -1
  128. package/dist/telemetry/session-metrics.d.ts +45 -3
  129. package/dist/telemetry/session-metrics.js +247 -9
  130. package/dist/telemetry/telemetry.d.ts +21 -1
  131. package/dist/telemetry/telemetry.js +34 -9
  132. package/dist/telemetry/verification-of.js +26 -1
  133. package/dist/tools/act-tools.js +63 -14
  134. package/dist/tools/assert-verdict.d.ts +7 -1
  135. package/dist/tools/assert-verdict.js +22 -3
  136. package/dist/tools/browser-tools.js +8 -2
  137. package/dist/tools/contract-tools.d.ts +0 -5
  138. package/dist/tools/contract-tools.js +26 -0
  139. package/dist/tools/dynamic-tools.js +6 -1
  140. package/dist/tools/error-recovery.d.ts +14 -0
  141. package/dist/tools/error-recovery.js +89 -8
  142. package/dist/tools/feedback-invite.d.ts +48 -0
  143. package/dist/tools/feedback-invite.js +71 -0
  144. package/dist/tools/invoke-tool.d.ts +0 -6
  145. package/dist/tools/invoke-tool.js +76 -7
  146. package/dist/tools/navigate-arrival.d.ts +26 -0
  147. package/dist/tools/navigate-arrival.js +60 -0
  148. package/dist/tools/navigate-result.d.ts +15 -1
  149. package/dist/tools/navigate-result.js +17 -10
  150. package/dist/tools/observe-tools.js +22 -4
  151. package/dist/tools/real-input-attempt.js +10 -5
  152. package/dist/tools/tool-kit.d.ts +60 -5
  153. package/dist/tools/tool-kit.js +55 -24
  154. package/dist/tools/tool-surface.js +13 -0
  155. package/dist/tools/tools.js +22 -0
  156. package/dist/visual/capture-cleanup.d.ts +16 -0
  157. package/dist/visual/capture-cleanup.js +39 -0
  158. package/dist/visual/visual-tools.d.ts +18 -0
  159. package/dist/visual/visual-tools.js +24 -5
  160. package/docs/README.md +55 -0
  161. package/docs/agent-cheatsheet.md +15 -3
  162. package/docs/benchmarks.md +1 -1
  163. package/docs/fixtures-dispatch-receiver.yml +76 -0
  164. package/docs/fixtures.md +17 -0
  165. package/docs/gate-plan.md +98 -0
  166. package/docs/gates.md +117 -0
  167. package/docs/local-registry.md +1 -1
  168. package/docs/matrix/2.5.0/claude-code-machine.json +19 -0
  169. package/docs/matrix/2.5.0/codex-machine.json +20 -0
  170. package/docs/matrix/2.5.0/cursor-machine.json +22 -0
  171. package/docs/matrix/2.5.0/gemini-machine.json +22 -0
  172. package/docs/matrix/2.5.0/opencode-machine.json +22 -0
  173. package/docs/matrix/2.5.0/vscode-machine.json +22 -0
  174. package/docs/matrix/2.5.0/windsurf-machine.json +22 -0
  175. package/docs/matrix/MATRIX.md +26 -0
  176. package/docs/matrix/README.md +59 -0
  177. package/docs/mint.json +1 -1
  178. package/docs/system-map.md +152 -0
  179. package/docs/telemetry-contract.md +15 -3
  180. package/docs/telemetry.md +16 -1
  181. package/docs/token-efficiency.md +2 -2
  182. package/package.json +2 -2
@@ -19,6 +19,7 @@ import { BrowserLaunchKind, } from '@reticlehq/core';
19
19
  import { errorSkeleton, fingerprintError } from './error-fingerprint.js';
20
20
  import { describeToolParams } from './argument-shape.js';
21
21
  import { machineSnapshot } from './machine-snapshot.js';
22
+ import { classifyError } from './error-class.js';
22
23
  /** Cap the distinct error shapes held in memory — a pathological loop must not grow unbounded. */
23
24
  const MAX_ERROR_KINDS = 40;
24
25
  /** Distinct tool names whose repeat-runs are tracked. Its own cap: the tool surface, not error shapes. */
@@ -46,6 +47,13 @@ const MAX_PARAMS_PER_TOOL = 24;
46
47
  * strings and costs nothing to hold.
47
48
  */
48
49
  const BREADCRUMB_LENGTH = 12;
50
+ /**
51
+ * Actions driven with no verdict before Reticle asks for one.
52
+ *
53
+ * Three, because the median verdict-less session made FOUR tool calls (2026-08-10/11). A higher
54
+ * threshold never fires for the sessions that need it most.
55
+ */
56
+ const UNVERIFIED_ACTION_NUDGE_AT = 3;
49
57
  /**
50
58
  * Mutable counters for one daemon lifetime. A plain class, not a store: it is process-local, it never
51
59
  * persists, and it dies with the daemon.
@@ -54,6 +62,27 @@ export class SessionMetrics {
54
62
  #toolCalls = 0;
55
63
  #toolErrors = 0;
56
64
  #verifications = 0;
65
+ /**
66
+ * Session-LIFETIME totals for the four numbers the funnel is computed from.
67
+ *
68
+ * `reset()` zeroes every window counter on each periodic flush, but `durationMs` is measured from
69
+ * `#startedAt` and is never reset. So the FINAL summary — the one whose docstring promises "the
70
+ * whole session in a single event" — reported a 30-minute session containing zero tool calls,
71
+ * because it only ever carried the residue since the last flush.
72
+ *
73
+ * In the field almost every `daemon_stopped` row had `toolCalls: 0`, at a median
74
+ * duration of 30.5 minutes, while `session_progress` for the same daemons carried real tool
75
+ * histograms. Anyone computing "did this session use Reticle" off the event named for the end of
76
+ * the session got `no` essentially always. That is not a small skew — it is the funnel reading
77
+ * zero at the exact step this release exists to raise.
78
+ *
79
+ * Window semantics are unchanged: `session_progress` still reports its own window, so nothing that
80
+ * already sums those events double-counts. Only `final: true` swaps in the lifetime numbers.
81
+ */
82
+ #lifetimeToolCalls = 0;
83
+ #lifetimeToolErrors = 0;
84
+ #lifetimeVerifications = 0;
85
+ #lifetimeBugsFound = 0;
57
86
  #toolCounts = new Map();
58
87
  #toolParams = new Map();
59
88
  /** Ring of the most recent tool NAMES — the agent's approach run, for a crash report. */
@@ -69,6 +98,12 @@ export class SessionMetrics {
69
98
  #concurrent = 0;
70
99
  #peakConcurrent = 0;
71
100
  #unknownToolCalls = 0;
101
+ /**
102
+ * Names the agent guessed at, session-LIFETIME — a guess made early must still be reported at
103
+ * the end, so this is deliberately not cleared by a flush.
104
+ */
105
+ #unknownTools = new Map();
106
+ #lifetimeUnknownToolCalls = 0;
72
107
  #bugsFound = 0;
73
108
  #browserMs = 0;
74
109
  #browserCommands = 0;
@@ -101,7 +136,33 @@ export class SessionMetrics {
101
136
  * run, which is exactly what the field claims to mean.
102
137
  */
103
138
  #unsettledActions = 0;
139
+ /** One-shot latch for the verdict nudge — see takeUnverifiedNudge. */
140
+ #nudgedUnverified = false;
141
+ /** The agent detached. The one part of `endReason` a query cannot derive. */
142
+ #clientLeft = false;
143
+ /** Feedback invitations shown, session-lifetime — the denominator for feedback_submitted. */
144
+ #feedbackPrompted = 0;
145
+ /** Error buckets by whose defect. Session-lifetime: the mix is a fact about the session. */
146
+ #errorClasses = new Map();
147
+ /**
148
+ * The recovery hinge. A call's outcome is only known once the NEXT one starts, so the verdict for
149
+ * call N-1 is resolved at the start of call N (and for the last call, at summarize).
150
+ */
151
+ #currentCallErrored = false;
152
+ #priorCallErrored = false;
153
+ #errorsRecovered = 0;
154
+ #errorsRepeated = 0;
155
+ /**
156
+ * App SDK connections, session-lifetime. NOT reset by a flush: "did an app ever connect" is a
157
+ * fact about the session, and a page reload after a flush must not erase it.
158
+ */
159
+ #appConnects = 0;
160
+ #firstAppAt;
104
161
  #clients = new Set();
162
+ /** Client name -> version, session-lifetime. Never cleared: who drove is a fact about the session. */
163
+ #clientVersions = new Map();
164
+ /** Which tool surface the daemon advertised. */
165
+ #surface;
105
166
  #startedAt;
106
167
  #now;
107
168
  /** Clock injected — this file must stay testable without a real one, per the repo's clock rule. */
@@ -146,10 +207,36 @@ export class SessionMetrics {
146
207
  this.#browserCommands += 1;
147
208
  }
148
209
  /** A call for a tool that does not exist. Non-zero means our surface is confusing the agent. */
149
- recordUnknownTool() {
210
+ /**
211
+ * An agent reached for a tool that does not exist — and WHAT it reached for.
212
+ *
213
+ * The name is the point. A count says the surface confused someone; the name says which
214
+ * capability they expected us to have, in their own vocabulary. Safe to keep: it is a name from
215
+ * our own namespace, never app data. Bounded and truncated, because a guess is a short
216
+ * identifier and anything longer is not one.
217
+ */
218
+ recordUnknownTool(name) {
150
219
  this.#unknownToolCalls += 1;
220
+ this.#lifetimeUnknownToolCalls += 1;
221
+ if (name === undefined || 0 === name.length)
222
+ return;
223
+ const key = name.slice(0, 64);
224
+ const seen = this.#unknownTools.get(key);
225
+ if (seen !== undefined) {
226
+ this.#unknownTools.set(key, seen + 1);
227
+ return;
228
+ }
229
+ // Stop growing once full: a pathological loop must not balloon the payload. The head is what
230
+ // gets built; the tail is noise.
231
+ if (this.#unknownTools.size >= MAX_ERROR_KINDS)
232
+ return;
233
+ this.#unknownTools.set(key, 1);
151
234
  }
152
235
  recordToolCall(tool, args) {
236
+ // `recordToolCall` runs BEFORE a handler and `recordToolError` after it fails, so a call's
237
+ // outcome is only known once the NEXT call starts. Resolve the one before it here; the final
238
+ // call is resolved in summarize.
239
+ this.#resolveRecovery();
153
240
  // A run of the same tool back to back. Five calls to reticle_act is engagement; five in a row
154
241
  // after four failures is an agent stuck, and toolCounts reports both as "5".
155
242
  this.#currentRun = tool === this.#lastTool ? this.#currentRun + 1 : 1;
@@ -161,6 +248,7 @@ export class SessionMetrics {
161
248
  this.#repeatRuns.set(tool, this.#currentRun);
162
249
  }
163
250
  this.#toolCalls += 1;
251
+ this.#lifetimeToolCalls += 1;
164
252
  bump(this.#toolCounts, tool);
165
253
  this.#inFlight = tool;
166
254
  this.#breadcrumb.push(tool);
@@ -190,6 +278,11 @@ export class SessionMetrics {
190
278
  */
191
279
  recordToolError(message, tool) {
192
280
  this.#toolErrors += 1;
281
+ this.#lifetimeToolErrors += 1;
282
+ // Whose defect. Only the `schema` bucket is fixable by writing better descriptions, and it was
283
+ // indistinguishable from the rest inside one `toolErrors` count.
284
+ bump(this.#errorClasses, classifyError(message));
285
+ this.#currentCallErrored = true;
193
286
  if (NO_SESSION_ERROR.test(message))
194
287
  this.#noSessionErrors += 1;
195
288
  const fingerprint = fingerprintError(message);
@@ -236,14 +329,51 @@ export class SessionMetrics {
236
329
  get trail() {
237
330
  return { breadcrumb: [...this.#breadcrumb], inFlight: this.#inFlight };
238
331
  }
332
+ /**
333
+ * Ask the agent for a verdict, once, when it has driven the page without asking for one.
334
+ *
335
+ * In the field most sessions that made a tool call produced no verdict — and
336
+ * **almost all of those never called a verdict-producing tool even once.** They drove the app
337
+ * with `reticle_act` and never asked whether it worked. This counter already existed
338
+ * (`abandonedActions`); it was reported to US and never to the agent.
339
+ *
340
+ * Three is the threshold because the median verdict-less session made four tool calls: any higher
341
+ * and it never fires for the sessions that need it. Re-arms after a verdict, so a second
342
+ * abandoned run is caught — the loop can break more than once in a long session.
343
+ */
344
+ takeUnverifiedNudge() {
345
+ if (this.#unsettledActions < UNVERIFIED_ACTION_NUDGE_AT || this.#nudgedUnverified) {
346
+ return undefined;
347
+ }
348
+ this.#nudgedUnverified = true;
349
+ return (`you have driven this page ${String(this.#unsettledActions)} times without asking for a ` +
350
+ 'verdict. Only reticle_act_and_wait and reticle_assert produce one — everything else moves ' +
351
+ 'or reads the app and proves nothing. Name the consequence you expect and check it: ' +
352
+ 'reticle_assert({ sessionId, since, predicate }), or use reticle_act_and_wait({ ref, ' +
353
+ 'action, until }) for the next step so the action and its check are one call.');
354
+ }
355
+ /**
356
+ * An app's SDK dialled this daemon.
357
+ *
358
+ * Zero of these at shutdown means the daemon ran and no app ever connected — a broken install.
359
+ * Non-zero with no tool calls is the opposite problem. See SessionSummary.appConnects.
360
+ */
361
+ recordAppConnected() {
362
+ this.#appConnects += 1;
363
+ this.#firstAppAt ??= this.#now();
364
+ }
239
365
  /** One action driven at the page. Settled by the next verdict; see #unsettledActions. */
240
366
  recordAction() {
241
367
  this.#unsettledActions += 1;
242
368
  }
243
369
  recordVerification() {
244
370
  this.#verifications += 1;
371
+ this.#lifetimeVerifications += 1;
245
372
  // A verdict settles whatever was outstanding, however many actions it took to get there.
246
373
  this.#unsettledActions = 0;
374
+ // Re-arm the nudge: the loop can break more than once in an 11-hour session, and an agent that
375
+ // complied once and then drifted again is exactly the case worth catching.
376
+ this.#nudgedUnverified = false;
247
377
  }
248
378
  /**
249
379
  * One defect found in the app under test — the outcome number, kept broken down by kind.
@@ -254,6 +384,7 @@ export class SessionMetrics {
254
384
  */
255
385
  recordBug(kind) {
256
386
  this.#bugsFound += 1;
387
+ this.#lifetimeBugsFound += 1;
257
388
  if (this.#bugKinds.size < MAX_ERROR_KINDS || this.#bugKinds.has(kind))
258
389
  bump(this.#bugKinds, kind);
259
390
  const first = !this.#seenBugKinds.has(kind);
@@ -285,26 +416,76 @@ export class SessionMetrics {
285
416
  stats.failures[failure] = (stats.failures[failure] ?? 0) + 1;
286
417
  };
287
418
  }
288
- recordClient(name) {
419
+ recordClient(name, version) {
420
+ const key = name.slice(0, 64);
289
421
  if (this.#clients.size < MAX_CLIENTS)
290
- this.#clients.add(name.slice(0, 64));
422
+ this.#clients.add(key);
423
+ // The version was already in the handshake and was being dropped. A regression in agent
424
+ // behaviour is usually a client version, and without this it is unattributable.
425
+ if (version !== undefined && '' !== version && this.#clientVersions.size < MAX_CLIENTS) {
426
+ this.#clientVersions.set(key, version.slice(0, 32));
427
+ }
428
+ }
429
+ /**
430
+ * The agent's client went away.
431
+ *
432
+ * This is the only part of `endReason` that a query cannot derive from the counters: "the client
433
+ * detached" and "the agent stopped asking" produce identical numbers and are different findings —
434
+ * one is the task ending, the other is us losing them.
435
+ */
436
+ recordClientLeft() {
437
+ this.#clientLeft = true;
438
+ }
439
+ /** How many times in a row the current tool has been called. Read by the friction invite. */
440
+ get currentRun() {
441
+ return this.#currentRun;
442
+ }
443
+ /** Reticle invited the agent to send feedback. The denominator for feedback_submitted. */
444
+ recordFeedbackPrompt() {
445
+ this.#feedbackPrompted += 1;
446
+ }
447
+ /** Which tool surface was advertised to agents this session. */
448
+ recordSurface(surface) {
449
+ this.#surface = surface.slice(0, 32);
291
450
  }
292
451
  /**
293
452
  * Roll the counters into the event payload. `final` marks a clean shutdown; a periodic flush passes
294
453
  * false so a session killed before it can exit is not lost entirely (see the flush note in cli.ts).
295
454
  */
296
- summarize(final) {
455
+ summarize(final, exit) {
456
+ // The last call has no successor to resolve it, so resolve it here. Only on a FINAL summary:
457
+ // a periodic flush is not the end of anything and would resolve a call still in flight.
458
+ if (final)
459
+ this.#resolveRecovery();
297
460
  const machine = machineSnapshot();
461
+ // A FINAL summary reports the session; a periodic flush reports its window. `durationMs` was
462
+ // always session-lifetime, so a windowed count next to it described a 30-minute session that
463
+ // made no calls. The histograms below stay windowed by design — summing them across a session's
464
+ // events is correct, and duplicating every map to make them lifetime would double the memory
465
+ // this class exists to keep small.
466
+ const scalars = final
467
+ ? {
468
+ toolCalls: this.#lifetimeToolCalls,
469
+ toolErrors: this.#lifetimeToolErrors,
470
+ verifications: this.#lifetimeVerifications,
471
+ bugsFound: this.#lifetimeBugsFound,
472
+ }
473
+ : {
474
+ toolCalls: this.#toolCalls,
475
+ toolErrors: this.#toolErrors,
476
+ verifications: this.#verifications,
477
+ bugsFound: this.#bugsFound,
478
+ };
298
479
  return {
299
480
  durationMs: Math.max(0, this.#now() - this.#startedAt),
300
- toolCalls: this.#toolCalls,
481
+ toolCalls: scalars.toolCalls,
301
482
  toolCounts: Object.fromEntries(this.#toolCounts),
302
- toolErrors: this.#toolErrors,
483
+ toolErrors: scalars.toolErrors,
303
484
  ...(this.#errorKinds.size > 0 ? { errors: [...this.#errorKinds.values()] } : {}),
304
485
  sdkFailures: this.#sdkFailures,
305
486
  ...(this.#sdkFailureKinds.size > 0 ? { sdkErrors: [...this.#sdkFailureKinds.values()] } : {}),
306
- verifications: this.#verifications,
307
- bugsFound: this.#bugsFound,
487
+ verifications: scalars.verifications,
488
+ bugsFound: scalars.bugsFound,
308
489
  ...(this.#bugKinds.size > 0 ? { bugKinds: Object.fromEntries(this.#bugKinds) } : {}),
309
490
  ...(this.#noSessionErrors > 0 ? { noSessionErrors: this.#noSessionErrors } : {}),
310
491
  ...(this.#repeatRuns.size > 0
@@ -323,12 +504,69 @@ export class SessionMetrics {
323
504
  browserMs: this.#browserMs,
324
505
  browserCommands: this.#browserCommands,
325
506
  peakConcurrentTools: this.#peakConcurrent,
326
- unknownToolCalls: this.#unknownToolCalls,
507
+ unknownToolCalls: final ? this.#lifetimeUnknownToolCalls : this.#unknownToolCalls,
508
+ ...(this.#unknownTools.size > 0
509
+ ? { unknownTools: Object.fromEntries(this.#unknownTools) }
510
+ : {}),
327
511
  ...(machine !== undefined ? { machine } : {}),
328
512
  ...(this.#clients.size > 0 ? { clients: [...this.#clients] } : {}),
513
+ ...(this.#clientVersions.size > 0
514
+ ? { clientVersions: Object.fromEntries(this.#clientVersions) }
515
+ : {}),
516
+ ...(this.#surface === undefined ? {} : { surface: this.#surface }),
517
+ ...(final ? { endReason: this.#endReason() } : {}),
518
+ ...(this.#feedbackPrompted > 0 ? { feedbackPrompted: this.#feedbackPrompted } : {}),
519
+ ...(this.#errorClasses.size > 0
520
+ ? { errorClasses: Object.fromEntries(this.#errorClasses) }
521
+ : {}),
522
+ ...(this.#errorsRecovered > 0 ? { errorsRecovered: this.#errorsRecovered } : {}),
523
+ ...(this.#errorsRepeated > 0 ? { errorsRepeated: this.#errorsRepeated } : {}),
524
+ // Always present, including zero: absence and "no app ever connected" must not look alike,
525
+ // because zero IS the finding here.
526
+ appConnects: this.#appConnects,
527
+ ...(this.#firstAppAt === undefined
528
+ ? {}
529
+ : { msToFirstApp: Math.max(0, this.#firstAppAt - this.#startedAt) }),
329
530
  final,
531
+ // Only on a real exit. A periodic flush carrying `exit: "unknown"` would read as a daemon that
532
+ // died without a shutdown path, which is the one thing this field exists to make visible.
533
+ ...(final && exit !== undefined ? { exit } : {}),
330
534
  };
331
535
  }
536
+ /**
537
+ * Decide whether the error before the previous call was recovered from or repeated.
538
+ *
539
+ * Called at the start of each new call (resolving the one before it) and once at summarize (for
540
+ * the last call). Shifting rather than counting on the spot is what makes "the agent tried
541
+ * something else and it worked" distinguishable from "it failed the same way again" — an earlier
542
+ * version counted a recovery the moment a call STARTED, so a call that then failed was recorded
543
+ * as a success.
544
+ */
545
+ #resolveRecovery() {
546
+ if (this.#priorCallErrored) {
547
+ if (this.#currentCallErrored)
548
+ this.#errorsRepeated += 1;
549
+ else
550
+ this.#errorsRecovered += 1;
551
+ }
552
+ this.#priorCallErrored = this.#currentCallErrored;
553
+ this.#currentCallErrored = false;
554
+ }
555
+ /**
556
+ * What state the agent's work was in at the end. Ordered by which fact outranks which: a client
557
+ * that left explains everything after it, and a verdict settles whatever preceded it.
558
+ */
559
+ #endReason() {
560
+ if (0 === this.#lifetimeToolCalls)
561
+ return 'never_used';
562
+ if (this.#clientLeft)
563
+ return 'client_left';
564
+ if (this.#unsettledActions > 0)
565
+ return 'abandoned';
566
+ if (this.#lifetimeVerifications > 0)
567
+ return 'verified';
568
+ return 'explored';
569
+ }
332
570
  /** True when nothing at all happened — a flush of an idle daemon is not worth an event. */
333
571
  get empty() {
334
572
  return (0 === this.#toolCalls &&
@@ -1,4 +1,4 @@
1
- import { TelemetryEventKind, type Crash, type Feedback, type Identity, type BugFound, type InitOutcome, type McpConnection, type McpOutage, type ProjectProfile, type SessionSummary, type TelemetryActor, type Verification, type VersionChange } from '@reticlehq/core';
1
+ import { TelemetryEventKind, ProjectIdSource, type Crash, type Feedback, type Identity, type BugFound, type InitOutcome, type McpConnection, type McpOutage, type AppInstrumentation, type ProjectProfile, type SessionSummary, type TelemetryActor, type Verification, type VersionChange } from '@reticlehq/core';
2
2
  /**
3
3
  * Feedback gets its own, much larger budget — and a retry.
4
4
  *
@@ -15,6 +15,24 @@ import { TelemetryEventKind, type Crash, type Feedback, type Identity, type BugF
15
15
  export declare const FEEDBACK_TIMEOUT_MS = 15000;
16
16
  export declare const FEEDBACK_RETRIES = 1;
17
17
  export declare const FEEDBACK_RETRY_BACKOFF_MS = 750;
18
+ /**
19
+ * One-way project fingerprint: distinct-count of this = "projects Reticle is used in", reveals nothing.
20
+ *
21
+ * Prefers a hash of the git ORIGIN over a hash of the cwd. The contract in `@reticlehq/core` always
22
+ * said "one-way hash of the git remote (or cwd)", but the implementation only ever hashed the path —
23
+ * which meant the same repo cloned by four teammates, or checked out twice on one machine, counted as
24
+ * four separate projects. That inflates the project count and, worse, makes team adoption look like
25
+ * unrelated individuals. Hashing the origin dedupes a real project down to one id across every clone.
26
+ *
27
+ * Still one-way, still non-reversible, and still exactly what the published policy describes: the
28
+ * remote URL itself never leaves the machine, only its SHA-256. The URL is normalized first (scheme,
29
+ * credentials, `.git` suffix and case removed) so `git@github.com:acme/web.git` and
30
+ * `https://github.com/Acme/web` are recognized as the same project rather than two.
31
+ */
32
+ export declare const projectFingerprint: (cwd: string) => {
33
+ projectId: string;
34
+ source: ProjectIdSource;
35
+ };
18
36
  /** The current telemetry state and which control set it — what `reticle telemetry status` prints. */
19
37
  export declare const describeTelemetry: (env?: NodeJS.ProcessEnv, dir?: string, cwd?: string) => {
20
38
  enabled: boolean;
@@ -56,6 +74,8 @@ export interface TelemetryExtra {
56
74
  bug?: BugFound;
57
75
  /** `mcp_connection_lost`: the agent lost its tools — which stage, and why. */
58
76
  outage?: McpOutage;
77
+ /** `app_instrumented`: an app carrying the SDK reached this daemon for the first time. */
78
+ instrumentation?: AppInstrumentation;
59
79
  }
60
80
  export interface Telemetry {
61
81
  /**
@@ -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 {
@@ -294,13 +317,14 @@ export const createTelemetry = (opts) => {
294
317
  ...(extra?.init !== undefined ? { init: extra.init } : {}),
295
318
  ...(extra?.bug !== undefined ? { bug: extra.bug } : {}),
296
319
  ...(extra?.outage !== undefined ? { outage: extra.outage } : {}),
320
+ ...(extra?.instrumentation !== undefined ? { instrumentation: extra.instrumentation } : {}),
297
321
  };
298
322
  // Map the core contract onto PostHog's capture shape: id/name/time move up, the rest are properties.
299
323
  // The feedback body is FLATTENED into `feedback_*` properties rather than sent as a nested object:
300
324
  // PostHog filters and breakdowns operate on top-level properties, so a nested `feedback.stack`
301
325
  // would be invisible to the exact "which stacks report the most bugs" question this exists to
302
326
  // answer. One prefix keeps them grouped in the UI without a nested path.
303
- const { anonymousId: distinctId, event: name, ts, sessionId: eventSessionId, feedback, session, project, verification, versionChange, crash, identity, connection, init, bug, outage, ...rest } = event;
327
+ const { anonymousId: distinctId, event: name, ts, sessionId: eventSessionId, feedback, session, project, verification, versionChange, crash, identity, connection, init, bug, outage, instrumentation, ...rest } = event;
304
328
  // `$session_id` is PostHog's OWN session property, so sending ours under that name lights up
305
329
  // its built-in session views and funnels for free rather than requiring custom HogQL everywhere.
306
330
  // `sessionId` rides only on events that happened inside a daemon run. A one-shot CLI command
@@ -329,6 +353,7 @@ export const createTelemetry = (opts) => {
329
353
  init,
330
354
  bug,
331
355
  outage,
356
+ instrumentation,
332
357
  };
333
358
  for (const [prefix, block] of Object.entries(blocks)) {
334
359
  for (const [key, value] of Object.entries(block ?? {})) {
@@ -12,7 +12,7 @@
12
12
  * Extracted because a rule this easy to get wrong, feeding the one number shown to investors, should
13
13
  * be readable and testable on its own rather than inferred from a ternary inside a dispatcher.
14
14
  */
15
- import { VerifiedReason } from '@reticlehq/core';
15
+ import { CaptureLoss, VerifiedReason } from '@reticlehq/core';
16
16
  import { VERIFICATION_TOOLS } from '../tools/feedback-tools.js';
17
17
  import { getBrowserMode } from './browser-mode.js';
18
18
  /**
@@ -27,6 +27,27 @@ function reasonOf(result) {
27
27
  const raw = result['verifiedReason'];
28
28
  return 'string' === typeof raw && REASONS.has(raw) ? raw : undefined;
29
29
  }
30
+ /**
31
+ * WHICH loss made the capture unclean, read off the honesty block the verdict already carries.
32
+ *
33
+ * Narrowed against core's own list for the same reason `reasonOf` is: a value that is not a member
34
+ * is not an answer. Reported as `OTHER` rather than dropped when the block says the capture was
35
+ * dirty and names no kind — the loss is real either way, and a silent absence there would read as
36
+ * "no unclean verdicts happened", which is the opposite of the truth.
37
+ */
38
+ const LOSSES = new Set(Object.values(CaptureLoss));
39
+ function uncleanLossOf(result, reason) {
40
+ if (VerifiedReason.UNCLEAN_CAPTURE !== reason)
41
+ return undefined;
42
+ const honesty = result['honesty'];
43
+ const integrity = null !== honesty && 'object' === typeof honesty
44
+ ? honesty.integrity
45
+ : undefined;
46
+ const first = Array.isArray(integrity?.losses) ? integrity.losses[0] : undefined;
47
+ return 'string' === typeof first && LOSSES.has(first)
48
+ ? first
49
+ : CaptureLoss.OTHER;
50
+ }
30
51
  /** Suite statuses. `unverifiable` is a suite that ran but proved nothing — including an empty one. */
31
52
  const SuiteStatus = {
32
53
  PASS: 'pass',
@@ -62,6 +83,7 @@ export function verificationOf(toolName, result, durationMs, brand) {
62
83
  if (verified === undefined && passed === undefined)
63
84
  return undefined;
64
85
  const reason = reasonOf(result);
86
+ const uncleanLoss = uncleanLossOf(result, reason);
65
87
  return {
66
88
  via: toolName,
67
89
  verified: verified ?? (true === passed ? 'yes' : 'no'),
@@ -80,5 +102,8 @@ export function verificationOf(toolName, result, durationMs, brand) {
80
102
  // belonging to three different owners — the agent, the app, and Reticle's own blind spots — and
81
103
  // they need opposite responses. A suite verdict has no clause behind it and reports none.
82
104
  ...(reason === undefined ? {} : { reason }),
105
+ // WHAT was lost, when the reason was that something was. Three owners, three fixes, one bar
106
+ // until now.
107
+ ...(uncleanLoss === undefined ? {} : { uncleanLoss }),
83
108
  };
84
109
  }