@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.
- package/SKILL.md +201 -18
- package/dist/bridge/bridge.d.ts +6 -1
- package/dist/bridge/bridge.js +27 -4
- package/dist/cli/chromium-hint.d.ts +40 -0
- package/dist/cli/chromium-hint.js +75 -0
- package/dist/cli/cli-doctor.js +3 -11
- package/dist/cli/cli-launch.d.ts +1 -0
- package/dist/cli/cli-launch.js +4 -1
- package/dist/cli/cli-port.js +20 -1
- package/dist/cli/doctor-daemon-line.js +10 -1
- package/dist/cli.js +40 -9
- package/dist/daemon/daemon-resilience.js +2 -2
- package/dist/daemon/loopback-alias.d.ts +32 -0
- package/dist/daemon/loopback-alias.js +80 -0
- package/dist/events/predicate-eval.d.ts +7 -3
- package/dist/events/predicate-eval.js +159 -92
- package/dist/events/predicate-parse.d.ts +1 -1
- package/dist/events/predicate-parse.js +18 -4
- package/dist/events/ring-buffer.d.ts +8 -0
- package/dist/events/ring-buffer.js +34 -1
- package/dist/honesty/honesty.d.ts +11 -0
- package/dist/honesty/honesty.js +9 -1
- package/dist/honesty/verified.js +26 -3
- package/dist/http-server.js +11 -1
- package/dist/index.js +26 -2
- package/dist/init/agent-rules.js +6 -1
- package/dist/init/detect.d.ts +8 -0
- package/dist/init/detect.js +15 -0
- package/dist/init/non-js-project.d.ts +27 -0
- package/dist/init/non-js-project.js +60 -0
- package/dist/init/plan-framework.d.ts +9 -0
- package/dist/init/plan-framework.js +19 -1
- package/dist/init/plan.js +20 -3
- package/dist/init/run.js +38 -9
- package/dist/init/snippets.d.ts +20 -1
- package/dist/init/snippets.js +71 -6
- package/dist/mcp/mcp-proxy.js +10 -3
- package/dist/mcp/mcp.js +35 -0
- package/dist/mcp/proxy-lifecycle.d.ts +19 -0
- package/dist/mcp/proxy-lifecycle.js +21 -0
- package/dist/pool/playwright-launcher.js +10 -2
- package/dist/session/live-control-tools.js +39 -2
- package/dist/session/live-control.d.ts +7 -9
- package/dist/session/live-control.js +34 -1
- package/dist/session/no-session-diagnosis.d.ts +4 -4
- package/dist/session/no-session-diagnosis.js +60 -29
- package/dist/session/review-store.d.ts +19 -0
- package/dist/session/review-store.js +34 -5
- package/dist/session/session-manager.d.ts +9 -0
- package/dist/session/session-manager.js +11 -0
- package/dist/session/session.d.ts +8 -0
- package/dist/session/session.js +12 -0
- package/dist/status-payload.d.ts +13 -1
- package/dist/status-payload.js +6 -1
- package/dist/telemetry/app-instrumented.d.ts +37 -0
- package/dist/telemetry/app-instrumented.js +54 -0
- package/dist/telemetry/daemon-telemetry.d.ts +4 -4
- package/dist/telemetry/daemon-telemetry.js +8 -4
- package/dist/telemetry/session-metrics.d.ts +3 -3
- package/dist/telemetry/session-metrics.js +4 -4
- package/dist/telemetry/telemetry.d.ts +3 -1
- package/dist/telemetry/telemetry.js +3 -1
- package/dist/telemetry/verification-of.js +26 -1
- package/dist/tools/act-tools.js +32 -10
- package/dist/tools/assert-verdict.js +7 -0
- package/dist/tools/error-recovery.js +6 -1
- package/dist/tools/invoke-tool.js +11 -10
- package/dist/tools/tool-kit.d.ts +52 -5
- package/dist/tools/tool-kit.js +55 -24
- package/dist/tools/tool-surface.js +9 -9
- package/dist/tools/tools.js +22 -0
- package/dist/visual/capture-cleanup.d.ts +16 -0
- package/dist/visual/capture-cleanup.js +39 -0
- package/dist/visual/visual-tools.d.ts +18 -0
- package/dist/visual/visual-tools.js +24 -5
- package/docs/agent-cheatsheet.md +14 -2
- package/docs/gate-plan.md +1 -1
- package/docs/gates.md +1 -1
- package/docs/telemetry-contract.md +13 -1
- package/docs/telemetry.md +9 -1
- package/docs/token-efficiency.md +2 -2
- 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
|
-
|
|
36
|
+
nextMarkId += 1;
|
|
20
37
|
const mark = {
|
|
21
|
-
id: `${MARK_ID_PREFIX}${String(
|
|
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. */
|
package/dist/session/session.js
CHANGED
|
@@ -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() {
|
package/dist/status-payload.d.ts
CHANGED
|
@@ -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[]
|
|
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 {};
|
package/dist/status-payload.js
CHANGED
|
@@ -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
|
-
*
|
|
28
|
-
* sessions never reported a summary at all.** Every "did anyone use Reticle" number is computed
|
|
29
|
-
* the
|
|
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
|
-
*
|
|
35
|
-
* sessions never reported a summary at all.** Every "did anyone use Reticle" number is computed
|
|
36
|
-
* the
|
|
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
|
-
*
|
|
77
|
-
* **
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
336
|
-
* **
|
|
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
|
|
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
|
}
|
package/dist/tools/act-tools.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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. */
|