@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
@@ -1,6 +1,7 @@
1
1
  import { NO_SESSION_CONNECTED_ERROR } from '@reticlehq/core';
2
2
  import { declareDrivenRedactionKeys, forgetDrivenRedactionKeys, } from '../input/driven-redaction.js';
3
3
  import { Session } from './session.js';
4
+ import { AttachmentHistory } from './attachment-history.js';
4
5
  /** The scheme://host:port of a URL, or undefined if it can't be parsed. Used to compare origins. */
5
6
  export function originOf(url) {
6
7
  if (url === undefined)
@@ -62,6 +63,12 @@ function scopeMissError(connected, scope) {
62
63
  const MAX_REMEMBERED_CLOSURES = 5;
63
64
  export class SessionManager {
64
65
  #sessions = new Map();
66
+ /**
67
+ * Continuity per session, so a listing can answer "was this attached the whole time".
68
+ *
69
+ * Owned here because this is the one place that sees both halves — every add and every remove.
70
+ */
71
+ #attachment = new AttachmentHistory();
65
72
  /**
66
73
  * The active project's scope, set once from the daemon's .reticle.json. When a tool resolves a session
67
74
  * without passing its own scope, this is applied — so auto-selection is project-scoped by default
@@ -80,6 +87,7 @@ export class SessionManager {
80
87
  // bridge because EVERY path that registers a session goes through this method, and a declaration
81
88
  // that silently fails to register is a leak nothing would report.
82
89
  declareDrivenRedactionKeys(session.id, session.redactKeys);
90
+ this.#attachment.attached(session.id);
83
91
  return previous;
84
92
  }
85
93
  remove(session) {
@@ -87,13 +95,24 @@ export class SessionManager {
87
95
  return false;
88
96
  session.rejectAll('session disconnected');
89
97
  forgetDrivenRedactionKeys(session.id);
98
+ // Recorded, not forgotten: a session that comes back needs its gap measured, and a listing after
99
+ // the reconnect is exactly where that matters.
100
+ this.#attachment.detached(session.id);
90
101
  return this.#sessions.delete(session.id);
91
102
  }
92
103
  get(sessionId) {
93
104
  return this.#sessions.get(sessionId);
94
105
  }
95
106
  list() {
96
- return [...this.#sessions.values()].map((s) => s.info());
107
+ return [...this.#sessions.values()].map((s) => {
108
+ const info = s.info();
109
+ const attachment = this.#attachment.of(s.id);
110
+ return attachment === undefined ? info : { ...info, attachment };
111
+ });
112
+ }
113
+ /** Continuity for one session — "has this been attached the whole time I am reasoning about?" */
114
+ attachmentOf(sessionId) {
115
+ return this.#attachment.of(sessionId);
97
116
  }
98
117
  /** Every connected session — used by the liveness reaper to sweep for idle/disconnected ones. */
99
118
  all() {
@@ -157,11 +176,35 @@ export class SessionManager {
157
176
  lastClosure() {
158
177
  return this.#recentClosures[this.#recentClosures.length - 1];
159
178
  }
179
+ /**
180
+ * A dead `sessionId`, answered with what the caller needs to recover — not with an errand.
181
+ *
182
+ * Telemetry, 2026-08-10: one agent called `reticle_navigate` twelve times against an id that was
183
+ * no longer connected. That single loop is **12 of the 58 tool errors recorded all day, 21%**. The
184
+ * refusal said only `no connected session with id 'x'`, and the recovery hint told it to call
185
+ * `reticle_sessions` and retry — two extra round trips to learn something this method already
186
+ * knows at the moment it refuses. An agent charged two calls to recover will often just repeat the
187
+ * one it made.
188
+ *
189
+ * So: name the live ids inline, and when there are none say that instead, because "retry with a
190
+ * valid one" is advice nobody can act on when no valid one exists.
191
+ */
192
+ #unknownSessionError(sessionId) {
193
+ const live = [...this.#sessions.values()];
194
+ if (0 === live.length) {
195
+ return (`no connected session with id '${sessionId}', and no sessions are connected at all — so ` +
196
+ 'there is no other id to retry with. The app is not dialling this daemon; call ' +
197
+ 'reticle_sessions for the diagnosis rather than retrying this call.');
198
+ }
199
+ const named = live.map((s) => `'${s.id}' (${s.url})`).join(', ');
200
+ return (`no connected session with id '${sessionId}'. Connected right now: ${named}. ` +
201
+ 'Retry with one of those, or omit sessionId entirely and let Reticle scope to your project.');
202
+ }
160
203
  resolve(sessionId, scope) {
161
204
  if (sessionId !== undefined) {
162
205
  const found = this.#sessions.get(sessionId);
163
206
  if (found === undefined) {
164
- throw new Error(`no connected session with id '${sessionId}'`);
207
+ throw new Error(this.#unknownSessionError(sessionId));
165
208
  }
166
209
  found.markAgentActivity(); // liveness — a targeted tool keeps the session alive / revives it
167
210
  return found;
@@ -174,7 +217,7 @@ export class SessionManager {
174
217
  throw new Error(hint);
175
218
  throw new Error(closure === undefined
176
219
  ? NO_SESSION_CONNECTED_ERROR
177
- : `${NO_SESSION_CONNECTED_ERROR} NOTE: the bridge closed a session recently — "${closure.reason}". The app is probably still running; it was disconnected, and the SDK does not retry after a policy close. Reload the page to reconnect.`);
220
+ : `${NO_SESSION_CONNECTED_ERROR} NOTE: the bridge REFUSED or closed a connection recently — "${closure.reason}". The app is probably still running and trying to connect: it was turned away, and the SDK does not retry after a policy close. The reason above names the fix — do not go looking for a stopped dev server.`);
178
221
  }
179
222
  // Scope to the agent's active project FIRST, so a stray tab from another app/origin (e.g. a
180
223
  // leftover dashboard on a different port) is structurally unselectable — it never enters the
@@ -197,6 +240,32 @@ export class SessionManager {
197
240
  only.markAgentActivity();
198
241
  return only;
199
242
  }
243
+ // Two projects and nothing to choose between them: refuse, BEFORE any scoring.
244
+ //
245
+ // Reported from the field: a call from `apps/next-app-router` was answered by a `rowy-*`
246
+ // session, and `act_and_wait` spent 16.9s returning `verified:'no'` about an app never under
247
+ // test — a confident false verdict, with a source pointer, about code the agent never touched.
248
+ //
249
+ // The recency rule below cannot prevent it. It asks "is one of these clearly fresher", which is
250
+ // the right question for two tabs of ONE app and the wrong question for two apps: the fresher
251
+ // tab of the wrong project is still the wrong project. The reported case had a clear winner,
252
+ // so the ambiguity check passed and the wrong app was selected.
253
+ //
254
+ // This sits ahead of scoring so freshness cannot rescue it. It only fires when nothing scoped
255
+ // the call — an explicit `sessionId` returns far above, and an explicit or default scope has
256
+ // already narrowed `all` to one project by here.
257
+ const projects = new Set(all.map((s) => s.projectId).filter((p) => p !== undefined));
258
+ if (projects.size > 1) {
259
+ const listed = all
260
+ .filter((s) => s.projectId !== undefined)
261
+ .map((s) => `'${String(s.projectId)}' (${s.url}, sessionId '${s.id}')`)
262
+ .join(', ');
263
+ throw new Error(`${String(projects.size)} different projects are connected and nothing says which one this ` +
264
+ `call is about: ${listed}. Pass sessionId to target one. Reticle refuses rather than ` +
265
+ 'guessing here because picking the wrong app produces a confident verdict about code you ' +
266
+ 'never touched — the daemon scopes to the directory it was started in, so running it above ' +
267
+ 'several apps leaves it unable to tell them apart.');
268
+ }
200
269
  // Multiple sessions: score each (lower = better candidate for auto-selection).
201
270
  // 0 = non-throttled (visible + recently-heard), 1 = throttled (hidden or stale heartbeat).
202
271
  const scored = all.map((s) => ({ s, score: s.throttled() ? 1 : 0, ms: s.lastSeenMs() }));
@@ -136,6 +136,7 @@ export declare class Session {
136
136
  dropped: number;
137
137
  };
138
138
  onEvent(listener: (event: ReticleEvent) => void): () => void;
139
+ onDisconnect(listener: () => void): () => void;
139
140
  /** Send a command to the browser and await its reply (or time out). */
140
141
  command(name: string, args?: Record<string, unknown>, timeoutMs?: number): Promise<CommandResult>;
141
142
  handleResult(result: CommandResult): void;
@@ -48,6 +48,7 @@ export class Session {
48
48
  #buffer = new RingBuffer();
49
49
  #pending = new PendingCommands();
50
50
  #listeners = new Set();
51
+ #disconnectListeners = new Set();
51
52
  #actionSeq = 0;
52
53
  #lastSeenAt;
53
54
  #hidden = false;
@@ -373,6 +374,10 @@ export class Session {
373
374
  this.#listeners.add(listener);
374
375
  return () => this.#listeners.delete(listener);
375
376
  }
377
+ onDisconnect(listener) {
378
+ this.#disconnectListeners.add(listener);
379
+ return () => this.#disconnectListeners.delete(listener);
380
+ }
376
381
  /** Send a command to the browser and await its reply (or time out). */
377
382
  command(name, args = {}, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS) {
378
383
  // Recorded HERE rather than at the tool call sites: this is the one place every driven action
@@ -420,6 +425,9 @@ export class Session {
420
425
  /** Reject everything still in flight — used on disconnect. */
421
426
  rejectAll(reason) {
422
427
  this.#pending.rejectAll(reason);
428
+ for (const listener of this.#disconnectListeners)
429
+ listener();
430
+ this.#disconnectListeners.clear();
423
431
  }
424
432
  /** End this transport without letting a stale socket remove its replacement session. */
425
433
  disconnect(reason) {
@@ -0,0 +1,47 @@
1
+ /**
2
+ * What a crash was, when the stack cannot say where it was.
3
+ *
4
+ * `reticleFrames` keeps only frames inside our own published packages, on the rule that a crash
5
+ * stack is mostly the user's application and node internals and neither is ours to collect. That
6
+ * rule is right and stays. But a refused socket has a stack that is ENTIRELY node internals:
7
+ *
8
+ * Error: connect ECONNREFUSED 127.0.0.1:4400
9
+ * at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1637:16)
10
+ *
11
+ * so every crash of that shape arrives with an error type, a redacted message, and nothing else —
12
+ * 65 of them in one measured day, one fingerprint, zero location on all 65, and 63 of those from
13
+ * two people who emitted no other event of any kind. A loopback-heavy tool produces plenty of
14
+ * these, and the next one is just as blind.
15
+ *
16
+ * Everything here is derived from a Node SYSTEM ERROR's own structured properties, and every field
17
+ * is chosen so that it carries nothing of the reporter's: the syscall and errno are our own
18
+ * vocabulary, `loopback` is one bit, the port is compared against values we already know and
19
+ * reported as an enum, and the frame names a line in NODE's source. The address and the port number
20
+ * are read and deliberately thrown away.
21
+ */
22
+ /** The structured cause of a crash, when the error carries one. Every field is optional. */
23
+ export interface CrashCause {
24
+ syscall?: string;
25
+ errno?: string;
26
+ loopback?: boolean;
27
+ port?: CrashPort;
28
+ internalFrame?: string;
29
+ }
30
+ import { CrashPort } from '@reticlehq/core';
31
+ /**
32
+ * The innermost frame that names NODE's own source.
33
+ *
34
+ * Innermost because that is where the failure actually happened, and it distinguishes a connect
35
+ * from a DNS lookup from a TLS handshake — the thing that, in the report this came from, had to be
36
+ * worked out by hand from a proxy's reject path. `node:net:1637` is a line in Node's published
37
+ * source: it carries nothing about the machine, the app, or anyone's directory layout, which is
38
+ * why it can be kept when the user's own frames cannot.
39
+ */
40
+ export declare function innermostInternalFrame(stack: string): string | undefined;
41
+ /**
42
+ * Read a Node system error's structured cause, keeping only what is ours to keep.
43
+ *
44
+ * `knownPorts` is passed IN rather than read from the environment so this stays pure and so the
45
+ * comparison is testable — the port itself is used to answer one enum and is then discarded.
46
+ */
47
+ export declare function crashCause(value: unknown, knownPorts: readonly number[]): CrashCause;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * What a crash was, when the stack cannot say where it was.
3
+ *
4
+ * `reticleFrames` keeps only frames inside our own published packages, on the rule that a crash
5
+ * stack is mostly the user's application and node internals and neither is ours to collect. That
6
+ * rule is right and stays. But a refused socket has a stack that is ENTIRELY node internals:
7
+ *
8
+ * Error: connect ECONNREFUSED 127.0.0.1:4400
9
+ * at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1637:16)
10
+ *
11
+ * so every crash of that shape arrives with an error type, a redacted message, and nothing else —
12
+ * 65 of them in one measured day, one fingerprint, zero location on all 65, and 63 of those from
13
+ * two people who emitted no other event of any kind. A loopback-heavy tool produces plenty of
14
+ * these, and the next one is just as blind.
15
+ *
16
+ * Everything here is derived from a Node SYSTEM ERROR's own structured properties, and every field
17
+ * is chosen so that it carries nothing of the reporter's: the syscall and errno are our own
18
+ * vocabulary, `loopback` is one bit, the port is compared against values we already know and
19
+ * reported as an enum, and the frame names a line in NODE's source. The address and the port number
20
+ * are read and deliberately thrown away.
21
+ */
22
+ import { CrashPort } from '@reticlehq/core';
23
+ import { reticleFrames } from './error-fingerprint.js';
24
+ /** IPv4 loopback is the whole 127/8 block, not just 127.0.0.1. */
25
+ const IPV4_LOOPBACK = /^127\./;
26
+ /** `node:net:1637` out of `at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1637:16)`. */
27
+ const NODE_INTERNAL_FRAME = /\((node:[\w/.-]+):(\d+):\d+\)/;
28
+ /** V8 renders an IPv4-mapped IPv6 address this way; the mapped half is what decides loopback. */
29
+ const IPV6_MAPPED_PREFIX = '::ffff:';
30
+ /**
31
+ * Was this aimed at the machine it was running on?
32
+ *
33
+ * One bit, and it splits two populations with different owners: refused ON loopback is a Reticle
34
+ * lifecycle problem (a daemon that is not up), refused off-box is a network problem that is not
35
+ * ours. Today they are indistinguishable in the data.
36
+ */
37
+ function isLoopback(address) {
38
+ const host = address.toLowerCase();
39
+ if ('::1' === host || 'localhost' === host)
40
+ return true;
41
+ const unmapped = host.startsWith(IPV6_MAPPED_PREFIX)
42
+ ? host.slice(IPV6_MAPPED_PREFIX.length)
43
+ : host;
44
+ return IPV4_LOOPBACK.test(unmapped);
45
+ }
46
+ /**
47
+ * The innermost frame that names NODE's own source.
48
+ *
49
+ * Innermost because that is where the failure actually happened, and it distinguishes a connect
50
+ * from a DNS lookup from a TLS handshake — the thing that, in the report this came from, had to be
51
+ * worked out by hand from a proxy's reject path. `node:net:1637` is a line in Node's published
52
+ * source: it carries nothing about the machine, the app, or anyone's directory layout, which is
53
+ * why it can be kept when the user's own frames cannot.
54
+ */
55
+ export function innermostInternalFrame(stack) {
56
+ for (const line of stack.split('\n')) {
57
+ const match = line.match(NODE_INTERNAL_FRAME);
58
+ if (null === match)
59
+ continue;
60
+ const [, module, lineNumber] = match;
61
+ if (module === undefined || lineNumber === undefined)
62
+ continue;
63
+ return `${module}:${lineNumber}`;
64
+ }
65
+ return undefined;
66
+ }
67
+ /**
68
+ * Read a Node system error's structured cause, keeping only what is ours to keep.
69
+ *
70
+ * `knownPorts` is passed IN rather than read from the environment so this stays pure and so the
71
+ * comparison is testable — the port itself is used to answer one enum and is then discarded.
72
+ */
73
+ export function crashCause(value, knownPorts) {
74
+ if (!(value instanceof Error))
75
+ return {};
76
+ const error = value;
77
+ const cause = {};
78
+ if ('string' === typeof error.syscall && error.syscall.length > 0)
79
+ cause.syscall = error.syscall;
80
+ // `code` (`ECONNREFUSED`), not the numeric `errno` (-61): the symbolic name is stable across
81
+ // platforms, where the number is not.
82
+ if ('string' === typeof error.code && error.code.length > 0)
83
+ cause.errno = error.code;
84
+ if ('string' === typeof error.address)
85
+ cause.loopback = isLoopback(error.address);
86
+ if ('number' === typeof error.port) {
87
+ cause.port = knownPorts.includes(error.port) ? CrashPort.RETICLE : CrashPort.OTHER;
88
+ }
89
+ // Only for a SYSTEM error, and only when nothing of ours is on the stack.
90
+ //
91
+ // Both halves are about collecting the minimum that answers the question. Any error raised
92
+ // anywhere in Node carries `node:internal/*` frames from whatever called it, so an ungated rule
93
+ // would attach one to every crash — noise on the reports that already have a location, and more
94
+ // than we need. The blind reports are the ones with a syscall and no frames of our own, and they
95
+ // are the only ones this exists for.
96
+ if (cause.syscall === undefined && cause.errno === undefined)
97
+ return cause;
98
+ if (reticleFrames(error.stack ?? '').length > 0)
99
+ return cause;
100
+ const frame = innermostInternalFrame(error.stack ?? '');
101
+ if (frame !== undefined)
102
+ cause.internalFrame = frame;
103
+ return cause;
104
+ }
@@ -1,12 +1,12 @@
1
1
  /**
2
- * How often a long-lived daemon flushes its counters.
2
+ * The daemon's telemetry lifecycle, in one place: start, the periodic flush that survives a kill, the
3
+ * one-shot project profile, and the rich summary on shutdown.
3
4
  *
4
- * ponytail: coarse on purpose. A daemon that is SIGKILLed — OOM, a closed laptop, `kill -9` — never
5
- * reaches its shutdown handler, so without this the whole session's counters die with it. Thirty
6
- * minutes bounds that loss to one window while keeping the event volume near zero for the common case
7
- * (most daemons stop cleanly and send exactly one summary). If the lost windows ever show up as a
8
- * real gap, the upgrade is a small on-disk journal replayed at the next start, not a shorter timer.
5
+ * Extracted from `cli.ts` because it is a cohesive unit with its own timers and its own failure rule
6
+ * (nothing here may ever be able to fail a daemon start), and because `cli.ts` is a dispatcher — the
7
+ * more of this that lives there, the harder it is to see what a command actually does.
9
8
  */
9
+ import { type SessionSummary } from '@reticlehq/core';
10
10
  /**
11
11
  * How often a running daemon rolls up its window.
12
12
  *
@@ -17,6 +17,24 @@
17
17
  * serve a tool.
18
18
  */
19
19
  export declare const SESSION_FLUSH_MS: number;
20
+ /**
21
+ * When a session first rolls up, independent of the periodic interval.
22
+ *
23
+ * The interval alone leaves a hole exactly the width of itself: a daemon SIGKILLed before its first
24
+ * tick — a closed laptop, OOM, `kill -9`, a force-quit editor — reaches no shutdown handler and has
25
+ * emitted nothing, so the whole session is invisible.
26
+ *
27
+ * Measured 2026-08-10/11 (non-CI): **614 `daemon_started` against 499 `daemon_stopped` — 19% of
28
+ * sessions never reported a summary at all.** Every "did anyone use Reticle" number is computed on
29
+ * the 81% that did, and undercounts by an unknown amount, which is the one thing a funnel metric
30
+ * must not do.
31
+ *
32
+ * Ninety seconds is chosen against what it protects: a session that did real work usually does it
33
+ * early (snapshot -> act -> assert is seconds), so one early tick captures nearly all of it. It
34
+ * costs nothing on the daemons that dominate the population, because only a NON-EMPTY window emits
35
+ * and 74% of daemons never serve a tool at all.
36
+ */
37
+ export declare const FIRST_FLUSH_MS: number;
20
38
  /** Stops the timers this installed. Called from the daemon's shutdown path. */
21
39
  export interface DaemonTelemetry {
22
40
  /**
@@ -29,7 +47,7 @@ export interface DaemonTelemetry {
29
47
  * fails or hangs still cannot block the exit, because `emit` swallows its own errors and is bounded
30
48
  * by its own request timeout.
31
49
  */
32
- shutdown(): Promise<void>;
50
+ shutdown(exit?: SessionSummary['exit']): Promise<void>;
33
51
  }
34
52
  /**
35
53
  * Report the daemon as started, profile the project once, and keep the session counters flushing.
@@ -14,15 +14,6 @@ import { startUpdateCheck } from '../update/update-nudge.js';
14
14
  import { markDaemonStart } from './mcp-connection.js';
15
15
  /** Let the daemon finish coming up before walking the source tree for a profile. */
16
16
  const PROJECT_PROFILE_DELAY_MS = 5_000;
17
- /**
18
- * How often a long-lived daemon flushes its counters.
19
- *
20
- * ponytail: coarse on purpose. A daemon that is SIGKILLed — OOM, a closed laptop, `kill -9` — never
21
- * reaches its shutdown handler, so without this the whole session's counters die with it. Thirty
22
- * minutes bounds that loss to one window while keeping the event volume near zero for the common case
23
- * (most daemons stop cleanly and send exactly one summary). If the lost windows ever show up as a
24
- * real gap, the upgrade is a small on-disk journal replayed at the next start, not a shorter timer.
25
- */
26
17
  /**
27
18
  * How often a running daemon rolls up its window.
28
19
  *
@@ -33,6 +24,24 @@ const PROJECT_PROFILE_DELAY_MS = 5_000;
33
24
  * serve a tool.
34
25
  */
35
26
  export const SESSION_FLUSH_MS = 5 * 60 * 1000;
27
+ /**
28
+ * When a session first rolls up, independent of the periodic interval.
29
+ *
30
+ * The interval alone leaves a hole exactly the width of itself: a daemon SIGKILLed before its first
31
+ * tick — a closed laptop, OOM, `kill -9`, a force-quit editor — reaches no shutdown handler and has
32
+ * emitted nothing, so the whole session is invisible.
33
+ *
34
+ * Measured 2026-08-10/11 (non-CI): **614 `daemon_started` against 499 `daemon_stopped` — 19% of
35
+ * sessions never reported a summary at all.** Every "did anyone use Reticle" number is computed on
36
+ * the 81% that did, and undercounts by an unknown amount, which is the one thing a funnel metric
37
+ * must not do.
38
+ *
39
+ * Ninety seconds is chosen against what it protects: a session that did real work usually does it
40
+ * early (snapshot -> act -> assert is seconds), so one early tick captures nearly all of it. It
41
+ * costs nothing on the daemons that dominate the population, because only a NON-EMPTY window emits
42
+ * and 74% of daemons never serve a tool at all.
43
+ */
44
+ export const FIRST_FLUSH_MS = 90 * 1000;
36
45
  /**
37
46
  * Report the daemon as started, profile the project once, and keep the session counters flushing.
38
47
  * Every timer is `unref`'d so telemetry can never be the reason a daemon refuses to exit.
@@ -57,7 +66,7 @@ export function installDaemonTelemetry(cwd, now = () => Date.now()) {
57
66
  /* a profile is a nice-to-have; it must never touch the daemon */
58
67
  }
59
68
  }, PROJECT_PROFILE_DELAY_MS).unref();
60
- const flush = setInterval(() => {
69
+ const flushWindow = () => {
61
70
  if (metrics.empty)
62
71
  return; // an idle window is not worth an event
63
72
  // NOT daemon_stopped: the daemon is still running. See SESSION_PROGRESS.
@@ -65,19 +74,25 @@ export function installDaemonTelemetry(cwd, now = () => Date.now()) {
65
74
  session: metrics.summarize(false),
66
75
  });
67
76
  metrics.reset();
68
- }, SESSION_FLUSH_MS);
77
+ };
78
+ // The early one closes the hole the interval cannot: a session killed before its first tick had
79
+ // emitted nothing at all. See FIRST_FLUSH_MS.
80
+ const firstFlush = setTimeout(flushWindow, FIRST_FLUSH_MS);
81
+ firstFlush.unref();
82
+ const flush = setInterval(flushWindow, SESSION_FLUSH_MS);
69
83
  flush.unref();
70
84
  let stopped;
71
85
  return {
72
- shutdown() {
86
+ shutdown(exit) {
73
87
  // Both SIGTERM and the idle-shutdown path can call this; the second caller awaits the first
74
88
  // send rather than emitting a duplicate summary.
75
89
  stopped ??= (async () => {
76
90
  clearInterval(flush);
91
+ clearTimeout(firstFlush);
77
92
  // The rich one: the whole session in a single event — duration, the tool histogram, error
78
93
  // shapes, verifications, browser launches. This is what replaced the per-tool-call event.
79
94
  await getTelemetry().emit(TelemetryEventKind.DAEMON_STOPPED, {
80
- session: metrics.summarize(true),
95
+ session: metrics.summarize(true, exit),
81
96
  });
82
97
  })();
83
98
  return stopped;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Whose defect was that tool error?
3
+ *
4
+ * `toolErrors` was one number covering three failures with three different fixes, so the top-line
5
+ * count could be acted on only by unpacking `errors[]` by hand and reading the prose. Measured over
6
+ * 2026-08-10/11: 60 `state`, 22 `refusal`, 20 `schema` (serialized zod arrays) and 20 stale refs.
7
+ * Read as "126 tool errors" that hides the sixth of them where OUR schema failed to explain itself —
8
+ * which is the only bucket we can fix by writing better descriptions.
9
+ *
10
+ * Classified from the message SHAPE, never stored raw: this returns a bucket, and the caller keeps
11
+ * counts. The full message is already fingerprinted and skeletonised elsewhere.
12
+ */
13
+ /** Whose defect. Ordered by which fix it implies. */
14
+ export declare const ErrorClass: {
15
+ /** Missing/unknown parameter, wrong type, unparseable predicate. **Our schema is unclear.** */
16
+ readonly SCHEMA: "schema";
17
+ /** No session, stale ref, disconnected. The world moved under the agent; not a schema problem. */
18
+ readonly STATE: "state";
19
+ /** We said no on purpose — a destructive block, an unsupported capability. Working as intended. */
20
+ readonly REFUSAL: "refusal";
21
+ /** Unrecognised. The bucket to watch: a large one is a blind spot, not a long tail. */
22
+ readonly OTHER: "other";
23
+ };
24
+ export type ErrorClass = (typeof ErrorClass)[keyof typeof ErrorClass];
25
+ /** Which bucket this message belongs to. Never throws — a classifier must not break a tool path. */
26
+ export declare function classifyError(message: string): ErrorClass;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Whose defect was that tool error?
3
+ *
4
+ * `toolErrors` was one number covering three failures with three different fixes, so the top-line
5
+ * count could be acted on only by unpacking `errors[]` by hand and reading the prose. Measured over
6
+ * 2026-08-10/11: 60 `state`, 22 `refusal`, 20 `schema` (serialized zod arrays) and 20 stale refs.
7
+ * Read as "126 tool errors" that hides the sixth of them where OUR schema failed to explain itself —
8
+ * which is the only bucket we can fix by writing better descriptions.
9
+ *
10
+ * Classified from the message SHAPE, never stored raw: this returns a bucket, and the caller keeps
11
+ * counts. The full message is already fingerprinted and skeletonised elsewhere.
12
+ */
13
+ /** Whose defect. Ordered by which fix it implies. */
14
+ export const ErrorClass = {
15
+ /** Missing/unknown parameter, wrong type, unparseable predicate. **Our schema is unclear.** */
16
+ SCHEMA: 'schema',
17
+ /** No session, stale ref, disconnected. The world moved under the agent; not a schema problem. */
18
+ STATE: 'state',
19
+ /** We said no on purpose — a destructive block, an unsupported capability. Working as intended. */
20
+ REFUSAL: 'refusal',
21
+ /** Unrecognised. The bucket to watch: a large one is a blind spot, not a long tail. */
22
+ OTHER: 'other',
23
+ };
24
+ /**
25
+ * Patterns, most specific first.
26
+ *
27
+ * REFUSAL is tested before STATE because a destructive block often names a session too, and
28
+ * "we refused on purpose" is the more useful reading of that event.
29
+ */
30
+ const PATTERNS = [
31
+ [/confirmdangerous|destructive action blocked/i, ErrorClass.REFUSAL],
32
+ [
33
+ /\brefus(e|ed|es|ing)\b|not supported|unsupported|cannot .* a <|contenteditable/i,
34
+ ErrorClass.REFUSAL,
35
+ ],
36
+ [
37
+ /did not parse|unknown field|unrecognized_keys|missing required|requires a (string|number)|invalid parameter|expected .* received|is larger than/i,
38
+ ErrorClass.SCHEMA,
39
+ ],
40
+ [
41
+ /no browser session connected|no connected session|multiple sessions connected|session disconnected|no longer resolves|no such session|not connected/i,
42
+ ErrorClass.STATE,
43
+ ],
44
+ ];
45
+ /** Which bucket this message belongs to. Never throws — a classifier must not break a tool path. */
46
+ export function classifyError(message) {
47
+ for (const [pattern, cls] of PATTERNS) {
48
+ if (pattern.test(message))
49
+ return cls;
50
+ }
51
+ return ErrorClass.OTHER;
52
+ }
@@ -1,15 +1,20 @@
1
1
  import { TelemetryAction } from '../cli/cli-parse.js';
2
2
  /**
3
- * `reticle feedback [--rating 1-5] [--bug] "what happened"` — the human half of the feedback channel.
3
+ * `reticle feedback [--rating 1-5] [--bug] "what happened"` — the feedback channel as a command.
4
4
  *
5
5
  * Kept to a single command with no prompts, no editor, and no account: the cost of telling us
6
6
  * something has to be lower than the cost of shrugging and moving on, or we only ever hear from people
7
7
  * angry enough to open an issue. A bare rating is a valid report; the words are optional garnish.
8
8
  *
9
+ * `--agent --kind <…>` is the same channel for an AGENT that cannot reach `reticle_feedback` — during
10
+ * `init`, while instrumentation is half-wired, or when the daemon will not start. Those are the
11
+ * reports we are least likely to hear and most need: every other path we have requires the tool
12
+ * surface to already work, so a Reticle that fails before it is running fails silently by design.
13
+ *
9
14
  * What is sent is PRINTED before it goes, every time. A channel that carries free text and does not
10
15
  * show you the payload is asking for trust it has not earned.
11
16
  */
12
- export declare function handleFeedback(text: string, rating: number | undefined, bug: boolean): Promise<void>;
17
+ export declare function handleFeedback(text: string, rating: number | undefined, bug: boolean, feedbackKind?: string, agent?: boolean): Promise<void>;
13
18
  /**
14
19
  * `reticle identify` — opt-in, and it shows the consent notice before it sends anything.
15
20
  *
@@ -1,32 +1,43 @@
1
1
  import { FeedbackKind, FeedbackSource } from '@reticlehq/core';
2
- import { BUG_FLAG, RATING_FLAG } from '../cli/cli-parse.js';
2
+ import { AGENT_FLAG, BUG_FLAG, FEEDBACK_KINDS, KIND_FLAG, RATING_FLAG } from '../cli/cli-parse.js';
3
3
  import { describeFeedbackPayload, submitFeedback } from './feedback.js';
4
4
  import { describeTelemetry, setTelemetryEnabled } from './telemetry.js';
5
5
  import { TelemetryAction } from '../cli/cli-parse.js';
6
6
  import { clearIdentity, IDENTIFY_NOTICE, readIdentity, saveIdentity, submitIdentity, UsageContextKind, } from './identify.js';
7
7
  /**
8
- * `reticle feedback [--rating 1-5] [--bug] "what happened"` — the human half of the feedback channel.
8
+ * `reticle feedback [--rating 1-5] [--bug] "what happened"` — the feedback channel as a command.
9
9
  *
10
10
  * Kept to a single command with no prompts, no editor, and no account: the cost of telling us
11
11
  * something has to be lower than the cost of shrugging and moving on, or we only ever hear from people
12
12
  * angry enough to open an issue. A bare rating is a valid report; the words are optional garnish.
13
13
  *
14
+ * `--agent --kind <…>` is the same channel for an AGENT that cannot reach `reticle_feedback` — during
15
+ * `init`, while instrumentation is half-wired, or when the daemon will not start. Those are the
16
+ * reports we are least likely to hear and most need: every other path we have requires the tool
17
+ * surface to already work, so a Reticle that fails before it is running fails silently by design.
18
+ *
14
19
  * What is sent is PRINTED before it goes, every time. A channel that carries free text and does not
15
20
  * show you the payload is asking for trust it has not earned.
16
21
  */
17
- export async function handleFeedback(text, rating, bug) {
22
+ export async function handleFeedback(text, rating, bug, feedbackKind, agent = false) {
18
23
  const line = (s) => {
19
24
  process.stdout.write(`${s}\n`);
20
25
  };
21
26
  if ('' === text && rating === undefined) {
22
27
  line(`usage: reticle feedback [${RATING_FLAG} 1-5] [${BUG_FLAG}] "what worked, what didn't"`);
28
+ line(` agents: reticle feedback ${AGENT_FLAG} ${KIND_FLAG} <${FEEDBACK_KINDS.join('|')}> "what happened"`);
23
29
  line(' your words go to the maintainers; nothing from your app is ever included.');
24
30
  process.exitCode = 1;
25
31
  return;
26
32
  }
27
33
  const receipt = await submitFeedback({
28
- source: FeedbackSource.HUMAN,
29
- kind: bug ? FeedbackKind.BUG : FeedbackKind.EXPERIENCE,
34
+ // An agent filing through the CLI must not land in the HUMAN bucket: that bucket carries the
35
+ // ratings and the experience reports, and a few hundred agent reports mixed into it would make
36
+ // the one number that says whether people like Reticle mean nothing.
37
+ source: agent ? FeedbackSource.AGENT : FeedbackSource.HUMAN,
38
+ // `--kind` wins when given; `--bug` stays the shorthand it always was.
39
+ kind: feedbackKind ??
40
+ (bug ? FeedbackKind.BUG : FeedbackKind.EXPERIENCE),
30
41
  // A rating with no words is a real report, but the schema wants text — say what the rating means.
31
42
  text: '' === text ? `rated ${String(rating)}/5 with no comment` : text,
32
43
  ...(rating !== undefined ? { rating } : {}),
@@ -38,6 +49,39 @@ export async function handleFeedback(text, rating, bug) {
38
49
  line(receipt.sent
39
50
  ? 'thanks your feedback is in. it genuinely changes what gets built next.'
40
51
  : `not sent ${receipt.reason ?? 'unknown reason'}`);
52
+ for (const extra of identifyInvite(agent))
53
+ line(extra);
54
+ }
55
+ /**
56
+ * Offer a HUMAN a way to be replied to — printed after the receipt, never as a prompt.
57
+ *
58
+ * Feedback is anonymous by design and stays that way: the report has already been sent by the time
59
+ * this prints, so nothing here gates or delays it. This is an offer, not a question.
60
+ *
61
+ * It routes through `reticle identify`, which already does consented identity properly — it prints
62
+ * what it will send, states that identifying links this machine's prior anonymous history to that
63
+ * identity, and has `--forget`. Capturing an address inline on `feedback_submitted` instead would
64
+ * put PII on the anonymous event stream and break what `docs/telemetry.md` promises in public:
65
+ * "no domain sniffing, no email inference" and "no personal data is collected".
66
+ *
67
+ * Only for humans (an agent has no email), and only when nobody has identified yet — so somebody
68
+ * who declined is never asked twice.
69
+ */
70
+ function identifyInvite(agent) {
71
+ if (agent)
72
+ return [];
73
+ try {
74
+ if (readIdentity() !== undefined)
75
+ return [];
76
+ }
77
+ catch {
78
+ return []; // unreadable identity file: say nothing rather than risk asking twice
79
+ }
80
+ return [
81
+ '',
82
+ 'want a reply? `reticle identify --context <company|side_project|oss|learning> --email you@example.com`',
83
+ ' optional, and it tells you exactly what it sends before sending it.',
84
+ ];
41
85
  }
42
86
  /**
43
87
  * `reticle identify` — opt-in, and it shows the consent notice before it sends anything.