@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
@@ -4,7 +4,8 @@ export interface SessionInfo {
4
4
  url: string;
5
5
  /** Stable build-stamped project identity; absent for v1.0 SDKs that don't send it. */
6
6
  projectId?: string;
7
- title: string;
7
+ /** Absent when the page has no title — never the empty string. Fall back to `url`. */
8
+ title?: string;
8
9
  adapters: string[];
9
10
  hasCapabilities: boolean;
10
11
  /** Present only when the page's SDK version differs from the daemon's — see version-skew.ts. */
@@ -22,6 +23,21 @@ export interface SessionInfo {
22
23
  pendingMarks?: number;
23
24
  /** present with pendingMarks — nudges the agent to drain them with reticle_review. */
24
25
  review_suggestion?: string;
26
+ /**
27
+ * Continuity of this attachment — `{ connectedSinceMs, outages, lastOutage? }`.
28
+ *
29
+ * A tab that dropped for four seconds and came back is indistinguishable from one that never left
30
+ * (#117), and that difference decides whether a verdict over the window can be trusted. Attached
31
+ * by `SessionManager.list()`, which is the layer that sees every connect and disconnect.
32
+ */
33
+ attachment?: {
34
+ connectedSinceMs: number;
35
+ outages: number;
36
+ lastOutage?: {
37
+ startedMs: number;
38
+ durationMs: number;
39
+ };
40
+ };
25
41
  }
26
42
  /** The read-only slice of a Session that the projection needs. Keeps this module class-free. */
27
43
  interface SessionView {
@@ -18,7 +18,11 @@ export function buildSessionInfo(session) {
18
18
  sessionId: session.id,
19
19
  url: session.url,
20
20
  ...(session.projectId === undefined ? {} : { projectId: session.projectId }),
21
- title: session.title,
21
+ // Omitted when blank, never `""`. An empty title cannot be told apart from "we sampled before
22
+ // the page set one", and two such tabs make the listing unusable. Moving the sample later does
23
+ // not fix it — a page may set its title after connect, or never — so the projection refuses the
24
+ // bad answer and the reader falls back to `url`, which is right here in the same record.
25
+ ...('' === session.title.trim() ? {} : { title: session.title }),
22
26
  adapters: session.adapters,
23
27
  hasCapabilities: session.hasCapabilities,
24
28
  // On every listing, not buried in a log — skew explains failures that read as app bugs.
@@ -1,4 +1,5 @@
1
1
  import { Session, type SessionInfo } from './session.js';
2
+ import { AttachmentHistory } from './attachment-history.js';
2
3
  /**
3
4
  * The agent's active project, used to scope auto-selection. `projectId` is the stable build-stamped
4
5
  * identity (authoritative when present); `url` is the app origin, a fallback hint for older SDKs that
@@ -18,11 +19,22 @@ export declare class SessionManager {
18
19
  remove(session: Session): boolean;
19
20
  get(sessionId: string): Session | undefined;
20
21
  list(): SessionInfo[];
22
+ /** Continuity for one session — "has this been attached the whole time I am reasoning about?" */
23
+ attachmentOf(sessionId: string): ReturnType<AttachmentHistory['of']>;
21
24
  /** Every connected session — used by the liveness reaper to sweep for idle/disconnected ones. */
22
25
  all(): Session[];
23
26
  count(): number;
24
27
  /** Wire the diagnosis provider (daemon boot). Absent ⇒ the plain, static message. */
25
28
  setNoSessionHint(hint: (() => string | undefined) | undefined): void;
29
+ /**
30
+ * The same diagnosis, for a caller that is ASKING about sessions rather than failing to get one.
31
+ *
32
+ * `reticle_sessions` is the first thing an agent reaches for and it returned a bare empty array —
33
+ * confidently, with nothing to act on. An agent cannot tell an empty list apart from a daemon that
34
+ * is down, an app with no SDK, or a tab that was closed, so it stops there. The diagnosis was
35
+ * already computed for the error path; this exposes it to the read path too.
36
+ */
37
+ noSessionHint(): string | undefined;
26
38
  everConnected(): boolean;
27
39
  /** Record a bridge-initiated close so `resolve` can explain a session that vanished. */
28
40
  noteClosure(reason: string, at: number): void;
@@ -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() {
@@ -142,6 +161,17 @@ export class SessionManager {
142
161
  setNoSessionHint(hint) {
143
162
  this.#noSessionHint = hint;
144
163
  }
164
+ /**
165
+ * The same diagnosis, for a caller that is ASKING about sessions rather than failing to get one.
166
+ *
167
+ * `reticle_sessions` is the first thing an agent reaches for and it returned a bare empty array —
168
+ * confidently, with nothing to act on. An agent cannot tell an empty list apart from a daemon that
169
+ * is down, an app with no SDK, or a tab that was closed, so it stops there. The diagnosis was
170
+ * already computed for the error path; this exposes it to the read path too.
171
+ */
172
+ noSessionHint() {
173
+ return this.#noSessionHint?.();
174
+ }
145
175
  /** Whether any session has connected since this daemon booted — half the diagnosis. */
146
176
  #everConnected = false;
147
177
  everConnected() {
@@ -157,11 +187,35 @@ export class SessionManager {
157
187
  lastClosure() {
158
188
  return this.#recentClosures[this.#recentClosures.length - 1];
159
189
  }
190
+ /**
191
+ * A dead `sessionId`, answered with what the caller needs to recover — not with an errand.
192
+ *
193
+ * Telemetry, 2026-08-10: one agent called `reticle_navigate` twelve times against an id that was
194
+ * no longer connected. That single loop is **12 of the 58 tool errors recorded all day, 21%**. The
195
+ * refusal said only `no connected session with id 'x'`, and the recovery hint told it to call
196
+ * `reticle_sessions` and retry — two extra round trips to learn something this method already
197
+ * knows at the moment it refuses. An agent charged two calls to recover will often just repeat the
198
+ * one it made.
199
+ *
200
+ * So: name the live ids inline, and when there are none say that instead, because "retry with a
201
+ * valid one" is advice nobody can act on when no valid one exists.
202
+ */
203
+ #unknownSessionError(sessionId) {
204
+ const live = [...this.#sessions.values()];
205
+ if (0 === live.length) {
206
+ return (`no connected session with id '${sessionId}', and no sessions are connected at all — so ` +
207
+ 'there is no other id to retry with. The app is not dialling this daemon; call ' +
208
+ 'reticle_sessions for the diagnosis rather than retrying this call.');
209
+ }
210
+ const named = live.map((s) => `'${s.id}' (${s.url})`).join(', ');
211
+ return (`no connected session with id '${sessionId}'. Connected right now: ${named}. ` +
212
+ 'Retry with one of those, or omit sessionId entirely and let Reticle scope to your project.');
213
+ }
160
214
  resolve(sessionId, scope) {
161
215
  if (sessionId !== undefined) {
162
216
  const found = this.#sessions.get(sessionId);
163
217
  if (found === undefined) {
164
- throw new Error(`no connected session with id '${sessionId}'`);
218
+ throw new Error(this.#unknownSessionError(sessionId));
165
219
  }
166
220
  found.markAgentActivity(); // liveness — a targeted tool keeps the session alive / revives it
167
221
  return found;
@@ -174,7 +228,7 @@ export class SessionManager {
174
228
  throw new Error(hint);
175
229
  throw new Error(closure === undefined
176
230
  ? 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.`);
231
+ : `${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
232
  }
179
233
  // Scope to the agent's active project FIRST, so a stray tab from another app/origin (e.g. a
180
234
  // leftover dashboard on a different port) is structurally unselectable — it never enters the
@@ -197,6 +251,32 @@ export class SessionManager {
197
251
  only.markAgentActivity();
198
252
  return only;
199
253
  }
254
+ // Two projects and nothing to choose between them: refuse, BEFORE any scoring.
255
+ //
256
+ // Reported from the field: a call from `apps/next-app-router` was answered by a `rowy-*`
257
+ // session, and `act_and_wait` spent 16.9s returning `verified:'no'` about an app never under
258
+ // test — a confident false verdict, with a source pointer, about code the agent never touched.
259
+ //
260
+ // The recency rule below cannot prevent it. It asks "is one of these clearly fresher", which is
261
+ // the right question for two tabs of ONE app and the wrong question for two apps: the fresher
262
+ // tab of the wrong project is still the wrong project. The reported case had a clear winner,
263
+ // so the ambiguity check passed and the wrong app was selected.
264
+ //
265
+ // This sits ahead of scoring so freshness cannot rescue it. It only fires when nothing scoped
266
+ // the call — an explicit `sessionId` returns far above, and an explicit or default scope has
267
+ // already narrowed `all` to one project by here.
268
+ const projects = new Set(all.map((s) => s.projectId).filter((p) => p !== undefined));
269
+ if (projects.size > 1) {
270
+ const listed = all
271
+ .filter((s) => s.projectId !== undefined)
272
+ .map((s) => `'${String(s.projectId)}' (${s.url}, sessionId '${s.id}')`)
273
+ .join(', ');
274
+ throw new Error(`${String(projects.size)} different projects are connected and nothing says which one this ` +
275
+ `call is about: ${listed}. Pass sessionId to target one. Reticle refuses rather than ` +
276
+ 'guessing here because picking the wrong app produces a confident verdict about code you ' +
277
+ 'never touched — the daemon scopes to the directory it was started in, so running it above ' +
278
+ 'several apps leaves it unable to tell them apart.');
279
+ }
200
280
  // Multiple sessions: score each (lower = better candidate for auto-selection).
201
281
  // 0 = non-throttled (visible + recently-heard), 1 = throttled (hidden or stale heartbeat).
202
282
  const scored = all.map((s) => ({ s, score: s.throttled() ? 1 : 0, ms: s.lastSeenMs() }));
@@ -135,7 +135,14 @@ export declare class Session {
135
135
  total: number;
136
136
  dropped: number;
137
137
  };
138
+ /**
139
+ * Did the buffer lose scarce evidence from a window opened at `cursor`? The input to whether a
140
+ * verdict's capture was clean — see `RingBuffer.lostSince`, and never the raw drop counter, which
141
+ * moves for the age and churn evictions that every live page produces continuously.
142
+ */
143
+ lostSince(cursor: number): boolean;
138
144
  onEvent(listener: (event: ReticleEvent) => void): () => void;
145
+ onDisconnect(listener: () => void): () => void;
139
146
  /** Send a command to the browser and await its reply (or time out). */
140
147
  command(name: string, args?: Record<string, unknown>, timeoutMs?: number): Promise<CommandResult>;
141
148
  handleResult(result: CommandResult): void;
@@ -158,6 +165,8 @@ export declare class Session {
158
165
  drainInbox(): InboxMessage[];
159
166
  /** Diagnostic read of the inbox depth (does not clear). */
160
167
  inboxSize(): number;
168
+ /** Everything the human has said this session, delivered or not. Never clears. */
169
+ inboxHistory(): readonly InboxMessage[];
161
170
  /** Human marks still awaiting a fix (oldest first). Reading does not consume — resolveMark does. */
162
171
  pendingMarks(): ReviewMark[];
163
172
  /** Full mark history (pending + resolved), oldest first. */
@@ -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;
@@ -369,10 +370,22 @@ export class Session {
369
370
  bufferHealth() {
370
371
  return this.#buffer.bufferHealth();
371
372
  }
373
+ /**
374
+ * Did the buffer lose scarce evidence from a window opened at `cursor`? The input to whether a
375
+ * verdict's capture was clean — see `RingBuffer.lostSince`, and never the raw drop counter, which
376
+ * moves for the age and churn evictions that every live page produces continuously.
377
+ */
378
+ lostSince(cursor) {
379
+ return this.#buffer.lostSince(cursor);
380
+ }
372
381
  onEvent(listener) {
373
382
  this.#listeners.add(listener);
374
383
  return () => this.#listeners.delete(listener);
375
384
  }
385
+ onDisconnect(listener) {
386
+ this.#disconnectListeners.add(listener);
387
+ return () => this.#disconnectListeners.delete(listener);
388
+ }
376
389
  /** Send a command to the browser and await its reply (or time out). */
377
390
  command(name, args = {}, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS) {
378
391
  // Recorded HERE rather than at the tool call sites: this is the one place every driven action
@@ -420,6 +433,9 @@ export class Session {
420
433
  /** Reject everything still in flight — used on disconnect. */
421
434
  rejectAll(reason) {
422
435
  this.#pending.rejectAll(reason);
436
+ for (const listener of this.#disconnectListeners)
437
+ listener();
438
+ this.#disconnectListeners.clear();
423
439
  }
424
440
  /** End this transport without letting a stale socket remove its replacement session. */
425
441
  disconnect(reason) {
@@ -462,6 +478,10 @@ export class Session {
462
478
  inboxSize() {
463
479
  return this.#live.size();
464
480
  }
481
+ /** Everything the human has said this session, delivered or not. Never clears. */
482
+ inboxHistory() {
483
+ return this.#live.history();
484
+ }
465
485
  // ── Human review marks: the "annotate the bug where you see it" inbox (server-owned) ──────────
466
486
  /** Human marks still awaiting a fix (oldest first). Reading does not consume — resolveMark does. */
467
487
  pendingMarks() {
@@ -14,6 +14,18 @@ interface StatusPayload {
14
14
  contract: string;
15
15
  sessionCount: number;
16
16
  sessions: SessionInfo[];
17
+ /**
18
+ * Why nothing is connected — present ONLY when `sessionCount` is 0.
19
+ *
20
+ * `reticle status` is the most-run command in the field, and with no sessions it answered
21
+ * `sessionCount: 0` and stopped. That is the same dead end `reticle_sessions` used to be for
22
+ * agents, at the same point in the funnel — the daemon is up, the agent is attached, and the app
23
+ * never arrived — and it is where most installs stop. Agents got the diagnosis in 2.7.0; a human
24
+ * running the command we tell them to run in `init`'s closing line deserves the same sentence.
25
+ */
26
+ why?: string;
17
27
  }
18
- export declare function statusPayload(sessionCount: number, sessions: SessionInfo[]): StatusPayload;
28
+ export declare function statusPayload(sessionCount: number, sessions: SessionInfo[],
29
+ /** The no-session diagnosis, injected so this stays pure and the port probe stays off this path. */
30
+ why?: string): StatusPayload;
19
31
  export {};
@@ -1,11 +1,16 @@
1
1
  import { CONTRACT_FINGERPRINT } from '@reticlehq/core';
2
2
  import { SERVER_VERSION } from './version/server-version.js';
3
- export function statusPayload(sessionCount, sessions) {
3
+ export function statusPayload(sessionCount, sessions,
4
+ /** The no-session diagnosis, injected so this stays pure and the port probe stays off this path. */
5
+ why) {
4
6
  return {
5
7
  running: true,
6
8
  version: SERVER_VERSION,
7
9
  contract: CONTRACT_FINGERPRINT,
8
10
  sessionCount,
9
11
  sessions,
12
+ // Only when there is nothing to explain away: a diagnosis printed beside a live session would
13
+ // contradict it.
14
+ ...(0 === sessionCount && why !== undefined ? { why } : {}),
10
15
  };
11
16
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * When an app carrying the SDK first reaches this daemon.
3
+ *
4
+ * This is the step the whole funnel turns on, and the one nothing could measure. Registering the MCP
5
+ * server and instrumenting an app are separate acts, done by different commands, and the data says
6
+ * almost everybody does the first and almost nobody does the second — but proving that required
7
+ * inferring it from a window counter that resets on every flush, and which therefore reported fewer
8
+ * instrumented users than there were users calling tools.
9
+ *
10
+ * Once per daemon run, on the FIRST app connect only. A page that reloads, or an app that opens five
11
+ * tabs, must not read as five instrumented installs — the question is "did this install ever get an
12
+ * app wired", and it has one answer per run.
13
+ *
14
+ * Names, never values: whether `init` had been run and whether an agent was attached — never the
15
+ * project path, the URL, or anything from the page. The stack is deliberately NOT repeated here;
16
+ * `project_profiled` already carries it on the same daemon run, so the two join on sessionId.
17
+ */
18
+ export declare function markInstrumentationClock(now: number): void;
19
+ export interface InstrumentationFacts {
20
+ /** Whether this project has a projectId stamped — i.e. `init` has run here. */
21
+ initialized: boolean;
22
+ /**
23
+ * Whether an MCP client was attached when the app arrived.
24
+ *
25
+ * Both halves present is the only state in which Reticle can do anything, and it is worth being
26
+ * able to see the two orders apart: an app that connects with no agent attached is a person
27
+ * setting up, while an agent that has been waiting is a session about to do work.
28
+ */
29
+ agentAttached: boolean;
30
+ }
31
+ /**
32
+ * Report the first instrumented app of this daemon run. Best-effort and idempotent — a metric must
33
+ * never be the reason a session fails to register.
34
+ */
35
+ export declare function reportAppInstrumented(facts: InstrumentationFacts, now?: () => number): void;
36
+ /** Tests only. */
37
+ export declare function resetAppInstrumented(): void;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * When an app carrying the SDK first reaches this daemon.
3
+ *
4
+ * This is the step the whole funnel turns on, and the one nothing could measure. Registering the MCP
5
+ * server and instrumenting an app are separate acts, done by different commands, and the data says
6
+ * almost everybody does the first and almost nobody does the second — but proving that required
7
+ * inferring it from a window counter that resets on every flush, and which therefore reported fewer
8
+ * instrumented users than there were users calling tools.
9
+ *
10
+ * Once per daemon run, on the FIRST app connect only. A page that reloads, or an app that opens five
11
+ * tabs, must not read as five instrumented installs — the question is "did this install ever get an
12
+ * app wired", and it has one answer per run.
13
+ *
14
+ * Names, never values: whether `init` had been run and whether an agent was attached — never the
15
+ * project path, the URL, or anything from the page. The stack is deliberately NOT repeated here;
16
+ * `project_profiled` already carries it on the same daemon run, so the two join on sessionId.
17
+ */
18
+ import { TelemetryEventKind } from '@reticlehq/core';
19
+ import { getTelemetry } from './telemetry.js';
20
+ let reported = false;
21
+ /** Set at daemon start so the first connect can report how long the install sat un-instrumented. */
22
+ let daemonStartedAt;
23
+ export function markInstrumentationClock(now) {
24
+ daemonStartedAt = now;
25
+ reported = false;
26
+ }
27
+ /**
28
+ * Report the first instrumented app of this daemon run. Best-effort and idempotent — a metric must
29
+ * never be the reason a session fails to register.
30
+ */
31
+ export function reportAppInstrumented(facts, now = () => Date.now()) {
32
+ if (reported)
33
+ return;
34
+ reported = true;
35
+ try {
36
+ void getTelemetry().emit(TelemetryEventKind.APP_INSTRUMENTED, {
37
+ instrumentation: {
38
+ initialized: facts.initialized,
39
+ agentAttached: facts.agentAttached,
40
+ // How long the daemon ran before an app showed up. Large values are the interesting ones:
41
+ // Reticle was up, the agent had the tools, and nothing was wired for that whole time.
42
+ msToFirstApp: daemonStartedAt === undefined ? 0 : Math.max(0, now() - daemonStartedAt),
43
+ },
44
+ });
45
+ }
46
+ catch {
47
+ /* never let a metric interfere with a page connecting */
48
+ }
49
+ }
50
+ /** Tests only. */
51
+ export function resetAppInstrumented() {
52
+ reported = false;
53
+ daemonStartedAt = undefined;
54
+ }
@@ -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
+ * In the field `daemon_started` outran `daemon_stopped` by a wide margin: **a meaningful share of
28
+ * sessions never reported a summary at all.** Every "did anyone use Reticle" number is computed only
29
+ * on the sessions that did, and undercounts by an unknown amount, which is the one thing a funnel
30
+ * metric 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.