@reticlehq/server 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/SKILL.md +201 -18
  2. package/dist/bridge/bridge.d.ts +6 -1
  3. package/dist/bridge/bridge.js +27 -4
  4. package/dist/cli/chromium-hint.d.ts +40 -0
  5. package/dist/cli/chromium-hint.js +75 -0
  6. package/dist/cli/cli-doctor.js +3 -11
  7. package/dist/cli/cli-launch.d.ts +1 -0
  8. package/dist/cli/cli-launch.js +4 -1
  9. package/dist/cli/cli-port.js +20 -1
  10. package/dist/cli/doctor-daemon-line.js +10 -1
  11. package/dist/cli.js +40 -9
  12. package/dist/daemon/daemon-resilience.js +2 -2
  13. package/dist/daemon/loopback-alias.d.ts +32 -0
  14. package/dist/daemon/loopback-alias.js +80 -0
  15. package/dist/events/predicate-eval.d.ts +7 -3
  16. package/dist/events/predicate-eval.js +159 -92
  17. package/dist/events/predicate-parse.d.ts +1 -1
  18. package/dist/events/predicate-parse.js +18 -4
  19. package/dist/events/ring-buffer.d.ts +8 -0
  20. package/dist/events/ring-buffer.js +34 -1
  21. package/dist/honesty/honesty.d.ts +11 -0
  22. package/dist/honesty/honesty.js +9 -1
  23. package/dist/honesty/verified.js +26 -3
  24. package/dist/http-server.js +11 -1
  25. package/dist/index.js +26 -2
  26. package/dist/init/agent-rules.js +6 -1
  27. package/dist/init/detect.d.ts +8 -0
  28. package/dist/init/detect.js +15 -0
  29. package/dist/init/non-js-project.d.ts +27 -0
  30. package/dist/init/non-js-project.js +60 -0
  31. package/dist/init/plan-framework.d.ts +9 -0
  32. package/dist/init/plan-framework.js +19 -1
  33. package/dist/init/plan.js +20 -3
  34. package/dist/init/run.js +38 -9
  35. package/dist/init/snippets.d.ts +20 -1
  36. package/dist/init/snippets.js +71 -6
  37. package/dist/mcp/mcp-proxy.js +10 -3
  38. package/dist/mcp/mcp.js +35 -0
  39. package/dist/mcp/proxy-lifecycle.d.ts +19 -0
  40. package/dist/mcp/proxy-lifecycle.js +21 -0
  41. package/dist/pool/playwright-launcher.js +10 -2
  42. package/dist/session/live-control-tools.js +39 -2
  43. package/dist/session/live-control.d.ts +7 -9
  44. package/dist/session/live-control.js +34 -1
  45. package/dist/session/no-session-diagnosis.d.ts +4 -4
  46. package/dist/session/no-session-diagnosis.js +60 -29
  47. package/dist/session/review-store.d.ts +19 -0
  48. package/dist/session/review-store.js +34 -5
  49. package/dist/session/session-manager.d.ts +9 -0
  50. package/dist/session/session-manager.js +11 -0
  51. package/dist/session/session.d.ts +8 -0
  52. package/dist/session/session.js +12 -0
  53. package/dist/status-payload.d.ts +13 -1
  54. package/dist/status-payload.js +6 -1
  55. package/dist/telemetry/app-instrumented.d.ts +37 -0
  56. package/dist/telemetry/app-instrumented.js +54 -0
  57. package/dist/telemetry/daemon-telemetry.d.ts +4 -4
  58. package/dist/telemetry/daemon-telemetry.js +8 -4
  59. package/dist/telemetry/session-metrics.d.ts +3 -3
  60. package/dist/telemetry/session-metrics.js +4 -4
  61. package/dist/telemetry/telemetry.d.ts +3 -1
  62. package/dist/telemetry/telemetry.js +3 -1
  63. package/dist/telemetry/verification-of.js +26 -1
  64. package/dist/tools/act-tools.js +32 -10
  65. package/dist/tools/assert-verdict.js +7 -0
  66. package/dist/tools/error-recovery.js +6 -1
  67. package/dist/tools/invoke-tool.js +11 -10
  68. package/dist/tools/tool-kit.d.ts +52 -5
  69. package/dist/tools/tool-kit.js +55 -24
  70. package/dist/tools/tool-surface.js +9 -9
  71. package/dist/tools/tools.js +22 -0
  72. package/dist/visual/capture-cleanup.d.ts +16 -0
  73. package/dist/visual/capture-cleanup.js +39 -0
  74. package/dist/visual/visual-tools.d.ts +18 -0
  75. package/dist/visual/visual-tools.js +24 -5
  76. package/docs/agent-cheatsheet.md +14 -2
  77. package/docs/gate-plan.md +1 -1
  78. package/docs/gates.md +1 -1
  79. package/docs/telemetry-contract.md +13 -1
  80. package/docs/telemetry.md +9 -1
  81. package/docs/token-efficiency.md +2 -2
  82. package/package.json +2 -2
@@ -11,14 +11,31 @@ import { MarkStatus } from '@reticlehq/core';
11
11
  */
12
12
  /** Prefix on review-mark ids (m1, m2, …) — distinguishes them from command ids. */
13
13
  const MARK_ID_PREFIX = 'm';
14
+ /**
15
+ * Shared by every store in the process, so an id is never reissued.
16
+ *
17
+ * The sequence used to live on the instance, which looked right and was not: a store is created per
18
+ * Session, and a Session is recreated on every page reload and socket reattach. Each new store began
19
+ * again at `m1`, so an id an agent was still holding silently came to denote a DIFFERENT mark — and
20
+ * `resolve` retired that one instead, reporting success. Reported from the field by an agent that
21
+ * closed a bug nobody had fixed while the two it had fixed vanished unrecorded.
22
+ *
23
+ * Process-wide is deliberately not "forever": a daemon restart resets it, but a restart also
24
+ * destroys every session, so any id from before it resolves to nothing and `resolve` correctly
25
+ * returns false. The identity echo below covers what remains.
26
+ */
27
+ let nextMarkId = 0;
28
+ /** Tests only — assertions on `m1` must not depend on which test ran first. */
29
+ export function resetMarkIdsForTest() {
30
+ nextMarkId = 0;
31
+ }
14
32
  export class ReviewStore {
15
33
  #marks = [];
16
- #seq = 0;
17
34
  /** Store a new mark (status pending) stamped with the caller-supplied session-relative time. */
18
35
  add(data, at) {
19
- this.#seq += 1;
36
+ nextMarkId += 1;
20
37
  const mark = {
21
- id: `${MARK_ID_PREFIX}${String(this.#seq)}`,
38
+ id: `${MARK_ID_PREFIX}${String(nextMarkId)}`,
22
39
  note: data.note,
23
40
  anchor: data.anchor,
24
41
  strategy: data.strategy,
@@ -51,10 +68,22 @@ export class ReviewStore {
51
68
  * false for an unknown id or an already-resolved mark (so resolve is idempotent).
52
69
  */
53
70
  resolve(id) {
71
+ return this.resolveDetail(id).resolved;
72
+ }
73
+ /**
74
+ * Resolve, and say WHICH mark was retired.
75
+ *
76
+ * The bare boolean is what made the misattribution silent: `{"resolved":true}` is equally
77
+ * consistent with "I retired the bug you fixed" and "I retired somebody else's". Echoing the note
78
+ * lets a caller check the answer against what it believed it was resolving, which is exactly what
79
+ * the agent that hit this asked for — it had been re-reading the whole list after every resolve
80
+ * and hand-diffing the notes to get the same assurance.
81
+ */
82
+ resolveDetail(id) {
54
83
  const mark = this.#marks.find((m) => m.id === id);
55
84
  if (mark === undefined || mark.status === MarkStatus.RESOLVED)
56
- return false;
85
+ return { resolved: false, id };
57
86
  mark.status = MarkStatus.RESOLVED;
58
- return true;
87
+ return { resolved: true, id, note: mark.note };
59
88
  }
60
89
  }
@@ -26,6 +26,15 @@ export declare class SessionManager {
26
26
  count(): number;
27
27
  /** Wire the diagnosis provider (daemon boot). Absent ⇒ the plain, static message. */
28
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;
29
38
  everConnected(): boolean;
30
39
  /** Record a bridge-initiated close so `resolve` can explain a session that vanished. */
31
40
  noteClosure(reason: string, at: number): void;
@@ -161,6 +161,17 @@ export class SessionManager {
161
161
  setNoSessionHint(hint) {
162
162
  this.#noSessionHint = hint;
163
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
+ }
164
175
  /** Whether any session has connected since this daemon booted — half the diagnosis. */
165
176
  #everConnected = false;
166
177
  everConnected() {
@@ -135,6 +135,12 @@ 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;
139
145
  onDisconnect(listener: () => void): () => void;
140
146
  /** Send a command to the browser and await its reply (or time out). */
@@ -159,6 +165,8 @@ export declare class Session {
159
165
  drainInbox(): InboxMessage[];
160
166
  /** Diagnostic read of the inbox depth (does not clear). */
161
167
  inboxSize(): number;
168
+ /** Everything the human has said this session, delivered or not. Never clears. */
169
+ inboxHistory(): readonly InboxMessage[];
162
170
  /** Human marks still awaiting a fix (oldest first). Reading does not consume — resolveMark does. */
163
171
  pendingMarks(): ReviewMark[];
164
172
  /** Full mark history (pending + resolved), oldest first. */
@@ -370,6 +370,14 @@ export class Session {
370
370
  bufferHealth() {
371
371
  return this.#buffer.bufferHealth();
372
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
+ }
373
381
  onEvent(listener) {
374
382
  this.#listeners.add(listener);
375
383
  return () => this.#listeners.delete(listener);
@@ -470,6 +478,10 @@ export class Session {
470
478
  inboxSize() {
471
479
  return this.#live.size();
472
480
  }
481
+ /** Everything the human has said this session, delivered or not. Never clears. */
482
+ inboxHistory() {
483
+ return this.#live.history();
484
+ }
473
485
  // ── Human review marks: the "annotate the bug where you see it" inbox (server-owned) ──────────
474
486
  /** Human marks still awaiting a fix (oldest first). Reading does not consume — resolveMark does. */
475
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
+ }
@@ -24,10 +24,10 @@ export declare const SESSION_FLUSH_MS: number;
24
24
  * tick — a closed laptop, OOM, `kill -9`, a force-quit editor — reaches no shutdown handler and has
25
25
  * emitted nothing, so the whole session is invisible.
26
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.
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
31
  *
32
32
  * Ninety seconds is chosen against what it protects: a session that did real work usually does it
33
33
  * early (snapshot -> act -> assert is seconds), so one early tick captures nearly all of it. It
@@ -12,6 +12,7 @@ import { getSessionMetrics } from './session-metrics.js';
12
12
  import { profileProject } from './project-profile.js';
13
13
  import { startUpdateCheck } from '../update/update-nudge.js';
14
14
  import { markDaemonStart } from './mcp-connection.js';
15
+ import { markInstrumentationClock } from './app-instrumented.js';
15
16
  /** Let the daemon finish coming up before walking the source tree for a profile. */
16
17
  const PROJECT_PROFILE_DELAY_MS = 5_000;
17
18
  /**
@@ -31,10 +32,10 @@ export const SESSION_FLUSH_MS = 5 * 60 * 1000;
31
32
  * tick — a closed laptop, OOM, `kill -9`, a force-quit editor — reaches no shutdown handler and has
32
33
  * emitted nothing, so the whole session is invisible.
33
34
  *
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.
35
+ * In the field `daemon_started` outran `daemon_stopped` by a wide margin: **a meaningful share of
36
+ * sessions never reported a summary at all.** Every "did anyone use Reticle" number is computed only
37
+ * on the sessions that did, and undercounts by an unknown amount, which is the one thing a funnel
38
+ * metric must not do.
38
39
  *
39
40
  * Ninety seconds is chosen against what it protects: a session that did real work usually does it
40
41
  * early (snapshot -> act -> assert is seconds), so one early tick captures nearly all of it. It
@@ -53,6 +54,9 @@ export function installDaemonTelemetry(cwd, now = () => Date.now()) {
53
54
  // this, so a published fix was invisible until someone manually ran `reticle update`.
54
55
  startUpdateCheck(now);
55
56
  markDaemonStart(now());
57
+ // Same clock, different half of the install: one measures how long before an AGENT attached, this
58
+ // one how long before an APP did. The gap between them is the funnel.
59
+ markInstrumentationClock(now());
56
60
  // One profile per daemon start: what kind of project this is and how deeply Reticle is used in it.
57
61
  // Deferred off the boot path — it walks the source tree, and nothing about a daemon coming up
58
62
  // should wait on a filesystem scan.
@@ -73,10 +73,10 @@ export declare class SessionMetrics {
73
73
  /**
74
74
  * Ask the agent for a verdict, once, when it has driven the page without asking for one.
75
75
  *
76
- * Measured 2026-08-10/11: of 170 sessions that made a tool call, 140 produced no verdict — and
77
- * **137 of those never called a verdict-producing tool even once.** They drove the app 269 times
76
+ * In the field most sessions that made a tool call produced no verdict — and
77
+ * **almost all of those never called a verdict-producing tool even once.** They drove the app
78
78
  * with `reticle_act` and never asked whether it worked. This counter already existed
79
- * (`abandonedActions`, non-zero in 58 sessions); it was reported to US and never to the agent.
79
+ * (`abandonedActions`); it was reported to US and never to the agent.
80
80
  *
81
81
  * Three is the threshold because the median verdict-less session made four tool calls: any higher
82
82
  * and it never fires for the sessions that need it. Re-arms after a verdict, so a second
@@ -70,7 +70,7 @@ export class SessionMetrics {
70
70
  * whole session in a single event" — reported a 30-minute session containing zero tool calls,
71
71
  * because it only ever carried the residue since the last flush.
72
72
  *
73
- * Measured 2026-08-10/11: **496 of 499 `daemon_stopped` rows had `toolCalls: 0`**, at a median
73
+ * In the field almost every `daemon_stopped` row had `toolCalls: 0`, at a median
74
74
  * duration of 30.5 minutes, while `session_progress` for the same daemons carried real tool
75
75
  * histograms. Anyone computing "did this session use Reticle" off the event named for the end of
76
76
  * the session got `no` essentially always. That is not a small skew — it is the funnel reading
@@ -332,10 +332,10 @@ export class SessionMetrics {
332
332
  /**
333
333
  * Ask the agent for a verdict, once, when it has driven the page without asking for one.
334
334
  *
335
- * Measured 2026-08-10/11: of 170 sessions that made a tool call, 140 produced no verdict — and
336
- * **137 of those never called a verdict-producing tool even once.** They drove the app 269 times
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
337
  * with `reticle_act` and never asked whether it worked. This counter already existed
338
- * (`abandonedActions`, non-zero in 58 sessions); it was reported to US and never to the agent.
338
+ * (`abandonedActions`); it was reported to US and never to the agent.
339
339
  *
340
340
  * Three is the threshold because the median verdict-less session made four tool calls: any higher
341
341
  * and it never fires for the sessions that need it. Re-arms after a verdict, so a second
@@ -1,4 +1,4 @@
1
- import { TelemetryEventKind, ProjectIdSource, 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
  *
@@ -74,6 +74,8 @@ export interface TelemetryExtra {
74
74
  bug?: BugFound;
75
75
  /** `mcp_connection_lost`: the agent lost its tools — which stage, and why. */
76
76
  outage?: McpOutage;
77
+ /** `app_instrumented`: an app carrying the SDK reached this daemon for the first time. */
78
+ instrumentation?: AppInstrumentation;
77
79
  }
78
80
  export interface Telemetry {
79
81
  /**
@@ -317,13 +317,14 @@ export const createTelemetry = (opts) => {
317
317
  ...(extra?.init !== undefined ? { init: extra.init } : {}),
318
318
  ...(extra?.bug !== undefined ? { bug: extra.bug } : {}),
319
319
  ...(extra?.outage !== undefined ? { outage: extra.outage } : {}),
320
+ ...(extra?.instrumentation !== undefined ? { instrumentation: extra.instrumentation } : {}),
320
321
  };
321
322
  // Map the core contract onto PostHog's capture shape: id/name/time move up, the rest are properties.
322
323
  // The feedback body is FLATTENED into `feedback_*` properties rather than sent as a nested object:
323
324
  // PostHog filters and breakdowns operate on top-level properties, so a nested `feedback.stack`
324
325
  // would be invisible to the exact "which stacks report the most bugs" question this exists to
325
326
  // answer. One prefix keeps them grouped in the UI without a nested path.
326
- 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;
327
328
  // `$session_id` is PostHog's OWN session property, so sending ours under that name lights up
328
329
  // its built-in session views and funnels for free rather than requiring custom HogQL everywhere.
329
330
  // `sessionId` rides only on events that happened inside a daemon run. A one-shot CLI command
@@ -352,6 +353,7 @@ export const createTelemetry = (opts) => {
352
353
  init,
353
354
  bug,
354
355
  outage,
356
+ instrumentation,
355
357
  };
356
358
  for (const [prefix, block] of Object.entries(blocks)) {
357
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
  }
@@ -6,7 +6,7 @@
6
6
  import { z } from 'zod';
7
7
  import { aliasParam } from './alias-args.js';
8
8
  import { captureAct, compileSequenceStep } from '../flows/replay.js';
9
- import { ActionType, ActionWarning, DEFAULT_ASSERT_TIMEOUT_MS, InputMode, ReticleCommand, Verified, PredicateKind, } from '@reticlehq/core';
9
+ import { ActionType, ActionWarning, CaptureLoss, DEFAULT_ASSERT_TIMEOUT_MS, InputMode, ReticleCommand, Verified, PredicateKind, } from '@reticlehq/core';
10
10
  import { assertNativeInputSupported } from './act-danger.js';
11
11
  import { leanActResult, mutatedWithin } from './act-view.js';
12
12
  import { ReticleTool } from './tool-names.js';
@@ -55,7 +55,7 @@ export const ACT_TOOLS = [
55
55
  {
56
56
  name: ReticleTool.ACT,
57
57
  example: { ref: 'e42', action: 'fill', args: { value: 'hello' } },
58
- description: 'Act WITHOUT checking the result — if the action is supposed to cause something, use reticle_act_and_wait { until } instead and get the verdict in the same call. This tool returns immediately with a `since` cursor; observe the reaction yourself with reticle_observe. Use it for actions with no observable consequence to assert (focus, hover, scrollIntoView, a fill you are about to submit). One action against a ref: click|dblclick|hover|focus|fill|type|clear|select|check|uncheck|submit|press|scrollIntoView. Carries effect:{dispatched,targetMatched,visible,enabled,focusMoved,valueChanged,domMutatedWithin,occluded,occludedBy,scrolledIntoView} to tell "action missed" from "app didn\'t react"; dispatched=landed, settled=a real frame flushed, and a settle timeout never fails the tool. Fields at their uninformative default are OMITTED so a clean action collapses to its consequence: an absent dispatched/targetMatched/visible/enabled means true, an absent occluded/scrolledIntoView/valueChanged/defaultPrevented means false, an absent focusMoved/occludedBy means null. occluded=true means the click point is covered by another element (a real user could not click it) — synthetic dispatch still delivered the event; scrolledIntoView=true means an off-viewport target was scrolled in first. inputMode is "real" (native CDP, no synthetic effect block) or "synthetic"; clicks default to the occlusion-honest synthetic path even when CDP is configured — pass args.native:true to force a trusted native click (file pickers, clipboard). args.holdMs keeps the pointer DOWN for that long between mousedown and mouseup — the only way to drive a hold-to-confirm control (hold-to-delete, hold-to-record, long-press); effect.heldMs reports the hold actually achieved, which a throttled background tab can stretch. args.confirmDangerous is a permission gate and NOT a duration: it allows a destructive control, it does not hold one. inputModeReason explains any real→synthetic choice so it is never silent. Full model (real-input, throttled tabs, `reticle drive`): docs/usage.md §18.',
58
+ description: 'Act WITHOUT checking the result — if the action is supposed to cause something, use reticle_act_and_wait { until } instead and get the verdict in the same call. This tool returns immediately with a `since` cursor; observe the reaction yourself with reticle_observe. Use it for actions with no observable consequence to assert (focus, hover, scrollIntoView, a fill you are about to submit). One action against a ref: click|dblclick|hover|focus|fill|type|clear|select|check|uncheck|submit|press|scrollIntoView. Carries effect:{dispatched,targetMatched,visible,enabled,focusMoved,valueChanged,domMutatedWithin,occluded,occludedBy,scrolledIntoView} to tell "action missed" from "app didn\'t react"; dispatched=landed, settled=a real frame flushed, and a settle timeout never fails the tool. Fields at their uninformative default are OMITTED so a clean action collapses to its consequence: an absent dispatched/targetMatched/visible/enabled means true, an absent occluded/scrolledIntoView/valueChanged/defaultPrevented means false, an absent focusMoved/occludedBy means null. occluded=true means the click point is covered by another element (a real user could not click it) — synthetic dispatch still delivered the event; scrolledIntoView=true means an off-viewport target was scrolled in first. inputMode is "real" (native CDP, no synthetic effect block) or "synthetic"; clicks default to the occlusion-honest synthetic path even when CDP is configured — pass args.native:true to force a trusted native click (file pickers, clipboard). args.holdMs keeps the pointer DOWN for that long between mousedown and mouseup — the only way to drive a hold-to-confirm control (hold-to-delete, hold-to-record, long-press); effect.heldMs reports the hold actually achieved, which a throttled background tab can stretch. args.confirmDangerous is a permission gate and NOT a duration: it allows a destructive control, it does not hold one. inputModeReason explains any real→synthetic choice so it is never silent. Full model (real-input, throttled tabs, `reticle drive`): node_modules/@reticlehq/server/docs/usage.md §18.',
59
59
  inputSchema: {
60
60
  ref: z
61
61
  .string()
@@ -64,7 +64,7 @@ export const ACT_TOOLS = [
64
64
  args: z
65
65
  .record(z.unknown())
66
66
  .optional()
67
- .describe('Action-specific arguments: { value } for fill/select, { text } for type/press, { native: true } to force a trusted native click, { holdMs: N } to keep the pointer DOWN for N ms (hold-to-confirm controls; effect.heldMs reports what was achieved), { confirmDangerous: true } to allow a potentially destructive control — a permission gate, NOT a duration.'),
67
+ .describe('Action-specific arguments: { value } for fill/select, { text } for type/press (the key NAME, e.g. Escape or Tab), { toRef } for drag (the ref to drop ON — without it the drag lands nowhere), { native: true } to force a trusted native click, { holdMs: N } to keep the pointer DOWN for N ms (hold-to-confirm controls; effect.heldMs reports what was achieved), { confirmDangerous: true } to allow a potentially destructive control — a permission gate, NOT a duration.'),
68
68
  refuseWhenThrottled: z
69
69
  .boolean()
70
70
  .optional()
@@ -255,7 +255,7 @@ export const ACT_TOOLS = [
255
255
  args: z
256
256
  .record(z.unknown())
257
257
  .optional()
258
- .describe('Action-specific arguments: { value } for fill/select, { text } for type/press, { confirmDangerous: true } for a potentially destructive control.'),
258
+ .describe('Action-specific arguments: { value } for fill/select, { text } for type/press (the key NAME, e.g. Escape or Tab), { toRef } for drag (the ref to drop ON — without it the drag lands nowhere), { confirmDangerous: true } for a potentially destructive control.'),
259
259
  predicate: PredicateSchema.optional().describe('Alias for `until` (the name reticle_assert / reticle_wait_for use).'),
260
260
  until: PredicateSchema.optional().describe('Predicate to wait for after the action completes (same shape as reticle_assert). OMIT to wait for the page to SETTLE — network + DOM idle — the deterministic default instead of a sleep. To assert a consequence AND settle, allOf them: { kind: "allOf", predicates: [<your predicate>, { kind: "settled" }] }.'),
261
261
  timeout_ms: z
@@ -355,11 +355,6 @@ export const ACT_TOOLS = [
355
355
  // argument and ignoring it told the agent its trusted click had happened. See act-danger.
356
356
  assertNativeInputSupported(asRecord(args['args']));
357
357
  const since = session.elapsed();
358
- // `dropped` is cumulative for the SESSION, so comparing against a pre-action reading is what
359
- // asks "did the buffer lose anything while THIS action was observed?" — the question the
360
- // honesty block means. Reading the raw total pinned every later verdict to `unknown` after a
361
- // single early eviction (measured on the Next.js demo at dropped:51, windows intact).
362
- const droppedBefore = session.bufferHealth().dropped;
363
358
  // The cursor + effect are marked after the act dispatches (below) — a refused act leaves none.
364
359
  // The attribution window stays open across the settle wait below, so post-dispatch async events
365
360
  // (the whole point of act_and_wait) attribute to this action. finishAction fires after the wait.
@@ -427,12 +422,28 @@ export const ACT_TOOLS = [
427
422
  // whose own words were "over a clean capture".
428
423
  const gapNote = transportGapNote(windowEvents);
429
424
  const impeachingNotes = [impeaching.note, gapNote].filter((n) => n !== undefined);
425
+ const bufferLost = session.lostSince(since);
426
+ // Which loss, as an enum, beside the prose that describes it. Classified here because this is
427
+ // the only place that knows the three apart: our buffer, our transport, and the page's own
428
+ // boundaries. See `CaptureLoss`.
429
+ const losses = [
430
+ ...(bufferLost ? [CaptureLoss.BUFFER_LOSS] : []),
431
+ ...(gapNote === undefined ? [] : [CaptureLoss.TRANSPORT_GAP]),
432
+ ...(impeaching.note === undefined ? [] : [CaptureLoss.BLIND_SPOT]),
433
+ ];
430
434
  const honesty = buildHonestyBlock({
431
435
  grade: gradeOf(gradedLinks),
432
436
  attribution: 'window',
433
- truncated: session.bufferHealth().dropped > droppedBefore,
437
+ // Did the buffer lose scarce evidence FROM THIS WINDOW — not "did it evict anything while
438
+ // the action ran", which was the previous rule and which is true on essentially every live
439
+ // page. Age eviction retires everything past 60s on every push, and the churn floor is
440
+ // sacrificed on purpose; neither costs this verdict a single event it needed. In the
441
+ // field this made `unclean_capture` the dominant cause of `unknown`, reported for
442
+ // evictions that happened outside the window they impeached.
443
+ truncated: bufferLost,
434
444
  coveragePartial: Coverage.PARTIAL === coverage.coverage,
435
445
  ...(0 === impeachingNotes.length ? {} : { blindSpots: impeachingNotes }),
446
+ losses,
436
447
  });
437
448
  const capsuleSaved = await saveFailedAssertCapsule({
438
449
  deps,
@@ -446,8 +457,19 @@ export const ACT_TOOLS = [
446
457
  // Pass the action: an EMPTY window then reads as "the target does not react" rather than as a
447
458
  // clean run — `settled` is satisfied by a page that did nothing, i.e. by a click that missed.
448
459
  const acted = asString(args['action']);
460
+ // `prior` matters here for the same reason it matters on the assert path: a SCALE error
461
+ // disagrees with a value the API stated EARLIER in the session, which an action-scoped
462
+ // window can never contain. Without it `unit-mismatch` — the money false green, where a
463
+ // total renders 100x off — is structurally unreachable from `act_and_wait`, which is the
464
+ // tool the product tells agents to reach for first. Our best detector could not fire on our
465
+ // flagship path, and nothing said so: the finding was simply never produced.
466
+ //
467
+ // Learning material only. Attribution is unchanged — findings still come exclusively from
468
+ // `windowEvents`, exactly as on the assert path.
469
+ const prior = since > 0 ? (await session.queryEvents({ since: 0 })).filter((e) => e.t < since) : [];
449
470
  const contradictions = findContradictions(windowEvents, {
450
471
  action: acted,
472
+ prior,
451
473
  ...session.lastAct.effect(),
452
474
  });
453
475
  // The single field an agent reads. Everything below it is the evidence it was derived from;
@@ -1,3 +1,4 @@
1
+ import { CaptureLoss } from '@reticlehq/core';
1
2
  import { findContradictions } from '../events/contradictions.js';
2
3
  import { blindSpotsFromState, buildCoverageStatement, Coverage, impeachesCapture, transportGapNote, } from '../honesty/blind-spots.js';
3
4
  import { buildHonestyBlock } from '../honesty/honesty.js';
@@ -68,6 +69,12 @@ observationLost) {
68
69
  attribution: 'window',
69
70
  coveragePartial: Coverage.PARTIAL === statement.coverage,
70
71
  ...(0 === impeachingNotes.length ? {} : { blindSpots: impeachingNotes }),
72
+ // Which loss, as an enum, beside the prose. `assert` observes an already-open window and never
73
+ // consults the ring buffer's health, so `buffer_loss` is not one of its answers.
74
+ losses: [
75
+ ...(gap === undefined ? [] : [CaptureLoss.TRANSPORT_GAP]),
76
+ ...(impeaching.note === undefined ? [] : [CaptureLoss.BLIND_SPOT]),
77
+ ],
71
78
  }),
72
79
  contradictions,
73
80
  ...(outcomePending ? { outcomePending } : {}),
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { TRANSPORT_LIMITS } from '@reticlehq/core';
12
12
  import { SELF_RECOVERING_MARKER } from '../session/no-session-diagnosis.js';
13
+ import { chromiumInstallCommand, bundledPlaywrightVersion } from '../cli/chromium-hint.js';
13
14
  /** Marks where an over-long message had its middle removed. Named so tests assert it by value. */
14
15
  const ELISION = '… [elided] …';
15
16
  /**
@@ -129,7 +130,11 @@ export const RECOVERY = {
129
130
  'where it actually is. This is app/flow drift, which is exactly what the flow exists to catch.',
130
131
  NO_POOL: 'Reticle cannot launch its own browser here: the daemon-managed pool is absent, or Playwright ' +
131
132
  'has no Chromium installed (the message above says which). Run Reticle via `reticle mcp`, and ' +
132
- 'ask the human for `npx playwright install chromium` if that is what is missing. Meanwhile drive ' +
133
+ // Pinned to the bundled playwright: the unpinned command installs a revision this daemon never
134
+ // looks at, so the human runs it, it succeeds, and nothing changes.
135
+ 'ask the human for `' +
136
+ chromiumInstallCommand(bundledPlaywrightVersion()) +
137
+ '` if that is what is missing. Meanwhile drive ' +
133
138
  'a tab the human already has open — reticle_sessions lists them.',
134
139
  };
135
140
  /** Ordered match rules; the first hit wins. Substrings track the thrown messages they recover. */