@reticlehq/server 2.8.0 → 2.9.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 +130 -813
- package/dist/bridge/bridge.d.ts +23 -0
- package/dist/bridge/bridge.js +80 -2
- package/dist/bridge/bridge.test-harness.d.ts +5 -0
- package/dist/bridge/bridge.test-harness.js +6 -4
- package/dist/capsule/causal-summary.d.ts +16 -1
- package/dist/capsule/causal-summary.js +2 -1
- package/dist/cli/cli-doctor.js +41 -13
- package/dist/cli/cli-flow-commands.js +8 -8
- package/dist/cli/cli-launch.d.ts +0 -17
- package/dist/cli/cli-launch.js +0 -28
- package/dist/cli/cli-port.d.ts +24 -0
- package/dist/cli/cli-port.js +66 -35
- package/dist/cli/cli-verify.d.ts +21 -0
- package/dist/cli/cli-verify.js +45 -1
- package/dist/cli/config-discovery.d.ts +35 -0
- package/dist/cli/config-discovery.js +163 -0
- package/dist/cli/doctor-daemon-line.js +4 -1
- package/dist/cli/doctor-project-line.d.ts +37 -0
- package/dist/cli/doctor-project-line.js +35 -0
- package/dist/cli/doctor-rows.d.ts +51 -0
- package/dist/cli/doctor-rows.js +54 -0
- package/dist/cli/doctor-sessions-line.js +6 -3
- package/dist/cli/drive-attach.d.ts +99 -0
- package/dist/cli/drive-attach.js +203 -0
- package/dist/cli/drive-command.d.ts +13 -0
- package/dist/cli/drive-command.js +103 -0
- package/dist/cli/open-note.d.ts +20 -0
- package/dist/cli/open-note.js +37 -0
- package/dist/cli/status-next-action.d.ts +37 -0
- package/dist/cli/status-next-action.js +61 -0
- package/dist/cli.js +65 -70
- package/dist/events/contradictions.d.ts +32 -0
- package/dist/events/contradictions.js +9 -5
- package/dist/events/declared.d.ts +45 -0
- package/dist/events/declared.js +73 -0
- package/dist/events/echo-mismatch.d.ts +8 -2
- package/dist/events/echo-mismatch.js +33 -5
- package/dist/events/event-filters.d.ts +1 -1
- package/dist/events/event-filters.js +7 -4
- package/dist/events/predicate-eval.d.ts +17 -91
- package/dist/events/predicate-eval.js +85 -326
- package/dist/events/predicate-parse.js +31 -3
- package/dist/events/predicate-route.d.ts +32 -0
- package/dist/events/predicate-route.js +102 -0
- package/dist/events/predicate-schema.d.ts +99 -0
- package/dist/events/predicate-schema.js +309 -0
- package/dist/events/predicate.d.ts +8 -0
- package/dist/events/predicate.js +127 -5
- package/dist/flows/annotate-tools.js +3 -0
- package/dist/flows/annotate.js +18 -4
- package/dist/flows/coverage.d.ts +38 -5
- package/dist/flows/coverage.js +30 -4
- package/dist/flows/decision.d.ts +15 -0
- package/dist/flows/decision.js +1 -1
- package/dist/flows/flow-replay-run.js +8 -1
- package/dist/flows/flow-tools.js +9 -1
- package/dist/flows/replay.js +3 -0
- package/dist/honesty/accepted-write.d.ts +3 -0
- package/dist/honesty/accepted-write.js +8 -3
- package/dist/honesty/already-true.js +15 -0
- package/dist/honesty/blind-spots.js +4 -0
- package/dist/honesty/honesty.d.ts +11 -0
- package/dist/honesty/honesty.js +1 -0
- package/dist/honesty/uncaptured-bodies.d.ts +4 -2
- package/dist/honesty/uncaptured-bodies.js +9 -2
- package/dist/honesty/unread-outcome.d.ts +8 -0
- package/dist/honesty/unread-outcome.js +36 -9
- package/dist/honesty/unsettled.d.ts +9 -0
- package/dist/honesty/unsettled.js +7 -3
- package/dist/honesty/verified.d.ts +29 -3
- package/dist/honesty/verified.js +51 -8
- package/dist/http-server.d.ts +19 -0
- package/dist/http-server.js +202 -4
- package/dist/impact/impact-recorder.d.ts +23 -0
- package/dist/impact/impact-recorder.js +76 -0
- package/dist/impact/impact-store.d.ts +47 -0
- package/dist/impact/impact-store.js +171 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +44 -10
- package/dist/init/agent-rules.d.ts +20 -0
- package/dist/init/agent-rules.js +36 -0
- package/dist/init/astro-patch.js +27 -1
- package/dist/init/confirm.d.ts +21 -3
- package/dist/init/confirm.js +32 -5
- package/dist/init/connect-steps.d.ts +2 -0
- package/dist/init/connect-steps.js +50 -0
- package/dist/init/csp-check.d.ts +38 -0
- package/dist/init/csp-check.js +92 -0
- package/dist/init/csp-doctor.d.ts +25 -0
- package/dist/init/csp-doctor.js +49 -0
- package/dist/init/cursor.js +6 -9
- package/dist/init/detect.d.ts +8 -0
- package/dist/init/detect.js +33 -3
- package/dist/init/mcp-clients.d.ts +1 -0
- package/dist/init/mcp-clients.js +51 -7
- package/dist/init/mcp.d.ts +15 -0
- package/dist/init/mcp.js +19 -0
- package/dist/init/plan-framework.d.ts +10 -0
- package/dist/init/plan-framework.js +25 -1
- package/dist/init/plan.d.ts +7 -2
- package/dist/init/plan.js +66 -30
- package/dist/init/run.js +11 -5
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/slash-command.js +12 -4
- package/dist/init/snippets.d.ts +1 -1
- package/dist/init/snippets.js +35 -7
- package/dist/init/vite-config.js +24 -2
- package/dist/input/network-mock-tools.js +8 -1
- package/dist/input/scroll-find.d.ts +6 -0
- package/dist/input/scroll-find.js +17 -2
- package/dist/input/scroll-tools.js +4 -0
- package/dist/journal/attach-journal.d.ts +9 -1
- package/dist/journal/attach-journal.js +13 -2
- package/dist/journal/journal-query.d.ts +13 -0
- package/dist/journal/journal-query.js +28 -3
- package/dist/journal/workspace-gitignore.d.ts +10 -0
- package/dist/journal/workspace-gitignore.js +59 -0
- package/dist/mcp/mcp-proxy.d.ts +5 -66
- package/dist/mcp/mcp-proxy.js +180 -163
- package/dist/mcp/mcp.d.ts +10 -1
- package/dist/mcp/mcp.js +47 -27
- package/dist/mcp/proxy-handshake.d.ts +82 -1
- package/dist/mcp/proxy-handshake.js +109 -2
- package/dist/mcp/proxy-lifecycle.d.ts +10 -1
- package/dist/mcp/proxy-lifecycle.js +11 -2
- package/dist/mcp/proxy-log.d.ts +63 -0
- package/dist/mcp/proxy-log.js +132 -0
- package/dist/mcp/server-instructions.d.ts +17 -0
- package/dist/mcp/server-instructions.js +56 -0
- package/dist/mcp/shared-params.d.ts +27 -0
- package/dist/mcp/shared-params.js +36 -0
- package/dist/project/reticle-dir.d.ts +2 -0
- package/dist/project/reticle-dir.js +1 -0
- package/dist/session/connection-memory.d.ts +59 -0
- package/dist/session/connection-memory.js +108 -0
- package/dist/session/dev-command.d.ts +26 -0
- package/dist/session/dev-command.js +89 -0
- package/dist/session/dev-server-probe.d.ts +68 -0
- package/dist/session/dev-server-probe.js +102 -7
- package/dist/session/last-act.d.ts +26 -0
- package/dist/session/last-act.js +35 -1
- package/dist/session/live-control-tools.js +42 -5
- package/dist/session/no-session-diagnosis.d.ts +48 -2
- package/dist/session/no-session-diagnosis.js +176 -20
- package/dist/session/no-session-next-action.d.ts +46 -0
- package/dist/session/no-session-next-action.js +85 -0
- package/dist/session/no-session-watch.d.ts +40 -1
- package/dist/session/no-session-watch.js +165 -11
- package/dist/session/pending-commands.d.ts +12 -0
- package/dist/session/pending-commands.js +13 -1
- package/dist/session/session-manager.d.ts +4 -0
- package/dist/session/session-manager.js +35 -5
- package/dist/session/session.d.ts +10 -1
- package/dist/session/session.js +146 -5
- package/dist/telemetry/app-instrumented.d.ts +8 -0
- package/dist/telemetry/app-instrumented.js +10 -0
- package/dist/telemetry/bug-found.d.ts +18 -0
- package/dist/telemetry/bug-found.js +14 -0
- package/dist/telemetry/daemon-telemetry.js +24 -0
- package/dist/telemetry/install-source.d.ts +20 -0
- package/dist/telemetry/install-source.js +34 -0
- package/dist/telemetry/instrumentation-stall.d.ts +50 -0
- package/dist/telemetry/instrumentation-stall.js +90 -0
- package/dist/telemetry/session-metrics.d.ts +8 -0
- package/dist/telemetry/session-metrics.js +10 -0
- package/dist/telemetry/telemetry.d.ts +3 -1
- package/dist/telemetry/telemetry.js +13 -2
- package/dist/tools/act-tools.js +166 -29
- package/dist/tools/assert-verdict.js +32 -7
- package/dist/tools/browser-tools.js +10 -0
- package/dist/tools/dynamic-tools.js +16 -7
- package/dist/tools/error-recovery.js +9 -5
- package/dist/tools/invoke-tool.js +20 -3
- package/dist/tools/lease-hint.d.ts +30 -8
- package/dist/tools/lease-hint.js +65 -14
- package/dist/tools/lease-tools.d.ts +11 -0
- package/dist/tools/lease-tools.js +141 -96
- package/dist/tools/observe-tools.js +9 -3
- package/dist/tools/react-grace.d.ts +4 -0
- package/dist/tools/react-grace.js +6 -3
- package/dist/tools/read-tools.js +47 -1
- package/dist/tools/resolve-target.d.ts +33 -0
- package/dist/tools/resolve-target.js +53 -0
- package/dist/tools/settle-in-flight.d.ts +15 -0
- package/dist/tools/settle-in-flight.js +42 -2
- package/dist/tools/snapshot-delta.d.ts +3 -0
- package/dist/tools/snapshot-delta.js +67 -6
- package/dist/tools/tool-kit.d.ts +8 -0
- package/dist/tools/tool-surface.d.ts +60 -0
- package/dist/tools/tool-surface.js +85 -19
- package/dist/tools/tools.js +45 -4
- package/dist/tools/unadvertised-help.js +9 -6
- package/docs/README.md +1 -2
- package/docs/actions.mdx +41 -8
- package/docs/agent-cheatsheet.md +4 -2
- package/docs/architecture.md +6 -0
- package/docs/benchmarks.md +60 -12
- package/docs/best-practices.mdx +11 -6
- package/docs/capabilities.mdx +201 -54
- package/docs/cli/affected.mdx +6 -4
- package/docs/cli/capsules.mdx +9 -8
- package/docs/cli/cloud.mdx +30 -4
- package/docs/cli/doctor.mdx +41 -8
- package/docs/cli/drive.mdx +34 -15
- package/docs/cli/gate.mdx +6 -6
- package/docs/cli/help.mdx +8 -6
- package/docs/cli/hunt.mdx +18 -2
- package/docs/cli/identify.mdx +16 -2
- package/docs/cli/init.mdx +35 -8
- package/docs/cli/kill.mdx +5 -5
- package/docs/cli/license.mdx +1 -1
- package/docs/cli/open.mdx +6 -0
- package/docs/cli/restart.mdx +6 -6
- package/docs/cli/serve.mdx +10 -3
- package/docs/cli/status.mdx +29 -5
- package/docs/cli/stop.mdx +8 -0
- package/docs/cli/verify.mdx +20 -1
- package/docs/cli/version.mdx +1 -1
- package/docs/cli/watch.mdx +9 -2
- package/docs/cli.mdx +44 -22
- package/docs/debugging.md +3 -1
- package/docs/deploy-checks.md +30 -1
- package/docs/desktop.mdx +276 -80
- package/docs/docs.json +13 -12
- package/docs/enterprise.md +2 -0
- package/docs/faq.mdx +49 -35
- package/docs/fixtures.md +2 -0
- package/docs/flows.md +3 -1
- package/docs/for-agents.md +3 -3
- package/docs/frameworks.mdx +46 -7
- package/docs/gate-plan.md +4 -2
- package/docs/gates.md +7 -5
- package/docs/getting-started.md +53 -12
- package/docs/human-control.md +2 -0
- package/docs/index.mdx +43 -1
- package/docs/install-agentic.mdx +13 -4
- package/docs/install-manual.mdx +6 -4
- package/docs/instrumentation.mdx +7 -1
- package/docs/integration-patterns.md +3 -1
- package/docs/local-registry.md +4 -2
- package/docs/multi-agent-testing.md +3 -1
- package/docs/packages/babel-plugin.mdx +15 -6
- package/docs/packages/browser.mdx +2 -2
- package/docs/packages/core.mdx +26 -16
- package/docs/packages/electron.mdx +5 -7
- package/docs/packages/eslint-plugin.mdx +5 -3
- package/docs/packages/next.mdx +3 -5
- package/docs/packages/react.mdx +3 -3
- package/docs/packages/server.mdx +2 -2
- package/docs/packages/tauri.mdx +6 -6
- package/docs/packages/test.mdx +18 -7
- package/docs/packages/vite-plugin.mdx +4 -6
- package/docs/packages.mdx +14 -13
- package/docs/platform-integration.md +2 -0
- package/docs/predicates.mdx +76 -7
- package/docs/quickstart.mdx +51 -5
- package/docs/recipes.mdx +62 -22
- package/docs/skill-file.mdx +3 -1
- package/docs/state-management.mdx +2 -0
- package/docs/system-map.md +3 -1
- package/docs/telemetry-contract.md +16 -5
- package/docs/telemetry-events.mdx +63 -18
- package/docs/telemetry.md +8 -5
- package/docs/testing.md +35 -1
- package/docs/token-efficiency.md +8 -2
- package/docs/tools-act-and-wait.mdx +64 -14
- package/docs/tools-act-sequence.mdx +34 -17
- package/docs/tools-act.mdx +24 -11
- package/docs/tools-assert.mdx +69 -10
- package/docs/tools-console.mdx +11 -7
- package/docs/tools-inspect.mdx +11 -8
- package/docs/tools-navigate.mdx +41 -5
- package/docs/tools-network.mdx +42 -9
- package/docs/tools-observe.mdx +66 -10
- package/docs/tools-overview.mdx +26 -23
- package/docs/tools-query.mdx +52 -8
- package/docs/tools-session-and-feedback.mdx +30 -9
- package/docs/tools-sessions.mdx +39 -10
- package/docs/tools-snapshot.mdx +57 -3
- package/docs/tools-state.mdx +24 -1
- package/docs/tools-tools-and-run.mdx +40 -9
- package/docs/tools-wait-for.mdx +34 -15
- package/docs/troubleshooting.mdx +80 -10
- package/docs/usage.md +43 -21
- package/docs/vs-chrome-devtools-mcp.mdx +41 -0
- package/docs/vs-playwright-mcp.mdx +42 -2
- package/docs/vs-screenshots.mdx +42 -1
- package/docs/what-is-reticle.mdx +1 -1
- package/docs/why-reticle.mdx +3 -1
- package/package.json +5 -5
- package/docs/desktop-apps.md +0 -265
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { EventType, MUTATING_METHODS, NetInitiator } from '@reticlehq/core';
|
|
1
|
+
import { EventType, MUTATING_METHODS, NetInitiator, isDevToolingUrl, } from '@reticlehq/core';
|
|
2
|
+
import { asString } from '../tools/tools-helpers.js';
|
|
2
3
|
const IPC_METHOD = NetInitiator.IPC.toUpperCase();
|
|
3
4
|
/**
|
|
4
5
|
* A write whose outcome is sitting in a payload nobody read.
|
|
@@ -29,22 +30,48 @@ const IPC_METHOD = NetInitiator.IPC.toUpperCase();
|
|
|
29
30
|
* The residual gap is real and narrower: a handler that RESOLVES with a failure inside its payload
|
|
30
31
|
* is invisible without body capture. That case is `findBodyFailures`, which reads the payload and
|
|
31
32
|
* catches it precisely — with capture on, where it belongs.
|
|
33
|
+
*
|
|
34
|
+
* - **the app's own traffic only.** The DEV TOOLCHAIN writes too: Next's overlay answers
|
|
35
|
+
* `POST /__nextjs_original-stack-frames` with a JSON body the moment the app logs one React
|
|
36
|
+
* warning, and nothing captures it. Judged as the app's write, that made every verdict in a Next
|
|
37
|
+
* dev app `unknown` — decided by a channel the caller never asked about, on a request the app did
|
|
38
|
+
* not make. `findContradictions` has split this traffic out for the same reason since the overlay
|
|
39
|
+
* turned correct navigations red; this rule was reading the unsplit window.
|
|
32
40
|
*/
|
|
33
41
|
const OK_MIN = 200;
|
|
34
42
|
const OK_MAX = 300;
|
|
35
|
-
|
|
36
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The writes whose outcome went unread, as "METHOD url".
|
|
45
|
+
*
|
|
46
|
+
* Named rather than counted, because a caveat an agent cannot locate is a caveat it learns to skip.
|
|
47
|
+
* The sentence used to say "a write returned 2xx with a response body that was never recorded" and
|
|
48
|
+
* name no write, which is unactionable on any page that makes more than one call.
|
|
49
|
+
*/
|
|
50
|
+
export function unreadWriteLabels(events) {
|
|
51
|
+
const labels = [];
|
|
52
|
+
for (const event of events) {
|
|
37
53
|
if (event.type !== EventType.NET_REQUEST)
|
|
38
|
-
|
|
54
|
+
continue;
|
|
39
55
|
const method = ('string' === typeof event.data['method'] ? event.data['method'] : '').toUpperCase();
|
|
40
56
|
if (method === IPC_METHOD || !MUTATING_METHODS.includes(method))
|
|
41
|
-
|
|
57
|
+
continue;
|
|
58
|
+
const url = asString(event.data['url']);
|
|
59
|
+
if (isDevToolingUrl(url))
|
|
60
|
+
continue;
|
|
42
61
|
const status = event.data['status'];
|
|
43
62
|
if (typeof status !== 'number' || status < OK_MIN || status >= OK_MAX)
|
|
44
|
-
|
|
63
|
+
continue;
|
|
45
64
|
if (event.data['responseBody'] !== undefined)
|
|
46
|
-
|
|
65
|
+
continue; // read — findBodyFailures judged it
|
|
47
66
|
const size = event.data['responseSize'];
|
|
48
|
-
|
|
49
|
-
|
|
67
|
+
if ('number' !== typeof size || size <= 0)
|
|
68
|
+
continue;
|
|
69
|
+
const label = `${method} ${url ?? ''}`.trim();
|
|
70
|
+
if (!labels.includes(label))
|
|
71
|
+
labels.push(label);
|
|
72
|
+
}
|
|
73
|
+
return labels;
|
|
74
|
+
}
|
|
75
|
+
export function hasUnreadWriteOutcome(events) {
|
|
76
|
+
return unreadWriteLabels(events).length > 0;
|
|
50
77
|
}
|
|
@@ -21,6 +21,15 @@ export interface UnsettledWindow {
|
|
|
21
21
|
waitedFor: string;
|
|
22
22
|
/** Requests that started in this window and had not come back, as "METHOD url". */
|
|
23
23
|
stillInFlight: readonly string[];
|
|
24
|
+
/**
|
|
25
|
+
* Endpoints called more than once in this window, as "METHOD url ×N" — see `repeatedRequestLabels`.
|
|
26
|
+
*
|
|
27
|
+
* The case `stillInFlight` structurally cannot see: a retry loop against a dead backend completes
|
|
28
|
+
* every attempt, so nothing is ever outstanding, and the verdict fell through to a sentence that
|
|
29
|
+
* blamed unnamed churn on an app whose whole problem was one endpoint being hammered. Optional —
|
|
30
|
+
* a caller that does not supply it gets exactly the sentence that shipped before.
|
|
31
|
+
*/
|
|
32
|
+
repeated?: readonly string[];
|
|
24
33
|
}
|
|
25
34
|
/**
|
|
26
35
|
* The wait, in one phrase.
|
|
@@ -64,10 +64,14 @@ export function unsettledBecause(base, window) {
|
|
|
64
64
|
if (window === undefined)
|
|
65
65
|
return `${base} — re-check, or assert a consequence that settles`;
|
|
66
66
|
if (0 === window.stillInFlight.length) {
|
|
67
|
+
const repeated = window.repeated ?? [];
|
|
67
68
|
return (`${base}. It waited for ${window.waitedFor}, and no request started in this window was left ` +
|
|
68
|
-
'open — what kept the page busy was its own churn
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
'open — what kept the page busy was its own churn ' +
|
|
70
|
+
(0 === repeated.length
|
|
71
|
+
? '(a poll, a timer, an animation)'
|
|
72
|
+
: `(these fired repeatedly: ${repeated.join(', ')} — a poll or a retrying query)`) +
|
|
73
|
+
', so waiting longer will not help. Assert the consequence you care about (a signal, a route ' +
|
|
74
|
+
'change, or store state) instead of waiting for idle, and re-check if the app is genuinely slow');
|
|
71
75
|
}
|
|
72
76
|
const shown = window.stillInFlight.slice(0, MAX_LISTED_REQUESTS);
|
|
73
77
|
const rest = window.stillInFlight.length - shown.length;
|
|
@@ -42,22 +42,48 @@ interface VerifiedInputs {
|
|
|
42
42
|
}[];
|
|
43
43
|
/** Did a real frame flush before the wait gave up? */
|
|
44
44
|
settled?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* The caller NAMED the expected consequence before acting — an `until`/`predicate` of its own,
|
|
47
|
+
* rather than the default "wait for the page to go idle".
|
|
48
|
+
*
|
|
49
|
+
* This is the epistemic core of the tool: a declaration made before the action cannot be
|
|
50
|
+
* rationalised after it. When such a declaration HOLDS, settlement is corroboration, not a veto —
|
|
51
|
+
* so the two clauses that answer "the app had not gone quiet" step aside for it. Measured in the
|
|
52
|
+
* field on three different apps: the expected text was on screen, the write returned 204, the
|
|
53
|
+
* nested verdict passed with evidence, and the verdict was `unknown` because the SPA polls, or
|
|
54
|
+
* because the tab was hidden and a hidden tab never fires the animation frame `settled` is read
|
|
55
|
+
* from. A hidden tab is the NORMAL state for agent-driven verification, so a signal that is always
|
|
56
|
+
* false there cannot be a precondition for a verdict.
|
|
57
|
+
*
|
|
58
|
+
* Narrow on purpose. It steps aside for settlement ONLY: an absence-derived finding about what the
|
|
59
|
+
* action DID — a duplicate write, a dead control, a route that rendered nothing, a response
|
|
60
|
+
* nothing consumed — still downgrades, and every positively OBSERVED contradiction still wins
|
|
61
|
+
* outright. Fixing a false negative must not open a path to a false positive.
|
|
62
|
+
*/
|
|
63
|
+
declaredConsequence?: boolean;
|
|
45
64
|
/**
|
|
46
65
|
* A write in this window answered `202 Accepted` — the server took the request and has NOT
|
|
47
66
|
* finished processing it.
|
|
48
67
|
*/
|
|
49
68
|
outcomePending?: boolean;
|
|
50
69
|
/**
|
|
51
|
-
*
|
|
52
|
-
* never read. See `
|
|
70
|
+
* Writes in this window that returned 2xx with a payload that was never recorded, as "METHOD url",
|
|
71
|
+
* so their outcome was never read. See `unreadWriteLabels` — the status line describes the
|
|
72
|
+
* transport, not the result.
|
|
73
|
+
*
|
|
74
|
+
* The LABELS rather than a flag: every other clause names the evidence that decided it, and this
|
|
75
|
+
* one named nothing, so an agent holding the caveat could not tell which call it was about.
|
|
76
|
+
* Empty means nothing went unread.
|
|
53
77
|
*/
|
|
54
|
-
outcomeUnread?:
|
|
78
|
+
outcomeUnread?: readonly string[];
|
|
55
79
|
/**
|
|
56
80
|
* What the wait was for and what the window held when it ended — read ONLY by the two clauses that
|
|
57
81
|
* answer UNSETTLED, which is the commonest reason a verdict comes back `unknown` and was also the
|
|
58
82
|
* least actionable. Optional: without it those clauses say exactly what they said before.
|
|
59
83
|
*/
|
|
60
84
|
unsettled?: UnsettledWindow;
|
|
85
|
+
/** A passing absence assertion targeted a region that the current capture could not observe. */
|
|
86
|
+
absenceBlindSpot?: string;
|
|
61
87
|
}
|
|
62
88
|
interface VerifiedVerdict {
|
|
63
89
|
verified: Verified;
|
package/dist/honesty/verified.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { Verified, VerifiedReason, isAbsenceDerived } from '@reticlehq/core';
|
|
1
|
+
import { ContradictionKind, MUTATING_METHODS, Verified, VerifiedReason, isAbsenceDerived, } from '@reticlehq/core';
|
|
2
2
|
import { HonestyGrade } from './honesty.js';
|
|
3
3
|
import { unsettledBecause } from './unsettled.js';
|
|
4
4
|
export function decideVerified(inputs) {
|
|
5
5
|
const { pass, honesty, contradictions = [], settled, outcomePending, outcomeUnread } = inputs;
|
|
6
|
+
/** The caller named a consequence before acting and it held — see `declaredConsequence`. */
|
|
7
|
+
const declaredHeld = true === inputs.declaredConsequence && true === pass;
|
|
6
8
|
// Ahead of the failure clause, because a failure is only the most actionable fact when there WAS
|
|
7
9
|
// one. An assertion nobody could evaluate is not a defect in the app, and calling it one was
|
|
8
10
|
// putting the agent's own malformed calls into the bug count.
|
|
@@ -64,14 +66,42 @@ export function decideVerified(inputs) {
|
|
|
64
66
|
// Absence-derived only: report it, do not assert failure. UNKNOWN is the honest answer — Reticle
|
|
65
67
|
// drove the app and could not yet tell. The finding still rides out in `contradictions`, so an
|
|
66
68
|
// agent that wants to wait and re-check has everything it needs.
|
|
67
|
-
|
|
69
|
+
//
|
|
70
|
+
// ...except when the only finding is that a READ had not come back AND the caller declared the
|
|
71
|
+
// consequence that did. `request-never-settled` is a statement about when we stopped looking; the
|
|
72
|
+
// declaration is a statement about what was supposed to happen, made before it could. On a page
|
|
73
|
+
// that polls, the first is permanently true and vetoed every verdict the second earned.
|
|
74
|
+
//
|
|
75
|
+
// An open WRITE is the exception to the exception, and it is where the false-green defence lives:
|
|
76
|
+
// "the toast said Saved while the POST was still open" is the archetype this product exists to
|
|
77
|
+
// catch, and its outcome genuinely does not exist yet. A poll or a prefetch left hanging says
|
|
78
|
+
// nothing about the action; an unfinished mutation says the answer is not in yet. Read off the
|
|
79
|
+
// labels the window already carries — and when there are no labels to read, nothing is softened.
|
|
80
|
+
const openWrite = (inputs.unsettled?.stillInFlight ?? []).some((label) => MUTATING_METHODS.includes((label.split(' ')[0] ?? '').toUpperCase()));
|
|
81
|
+
const settlementOnly = declaredHeld &&
|
|
82
|
+
inputs.unsettled !== undefined &&
|
|
83
|
+
!openWrite &&
|
|
84
|
+
contradictions.every((c) => c.kind === ContradictionKind.REQUEST_NEVER_SETTLED);
|
|
85
|
+
if (contradictions.length > 0 && !settlementOnly) {
|
|
68
86
|
const kinds = contradictions.map((c) => c.kind).join(', ');
|
|
69
87
|
return {
|
|
70
88
|
verified: Verified.UNKNOWN,
|
|
71
|
-
|
|
89
|
+
// NOT `unsettled`: this clause fires whether or not the page went idle, and naming it after
|
|
90
|
+
// idle produced verdicts that read `unsettled` beside `settled: true`.
|
|
91
|
+
verifiedReason: VerifiedReason.EVIDENCE_INCOMPLETE,
|
|
72
92
|
because: unsettledBecause(`the assertion held, but this window closed before the app finished (${kinds})`, inputs.unsettled),
|
|
73
93
|
};
|
|
74
94
|
}
|
|
95
|
+
// An absence assertion over a region Reticle cannot observe is not disproved, but it is not proved
|
|
96
|
+
// either. This is narrower than general partial coverage: the target itself is scoped to the blind
|
|
97
|
+
// region, so the DOM result cannot answer the question the caller asked.
|
|
98
|
+
if (inputs.absenceBlindSpot !== undefined) {
|
|
99
|
+
return {
|
|
100
|
+
verified: Verified.UNKNOWN,
|
|
101
|
+
verifiedReason: VerifiedReason.ABSENCE_BLIND_SPOT,
|
|
102
|
+
because: inputs.absenceBlindSpot,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
75
105
|
// Below the contradiction clause, and for the same reason a contradiction outranks a dirty
|
|
76
106
|
// capture: evidence AGAINST beats absence of evidence, whichever absence it is. Both can hold at
|
|
77
107
|
// once — assert `{ text: 'Saved' }` that was already on screen while the write 500s — and ordering
|
|
@@ -132,15 +162,22 @@ export function decideVerified(inputs) {
|
|
|
132
162
|
//
|
|
133
163
|
// UNKNOWN, not NO: nothing is known to have failed. The remedy is in the sentence, because an
|
|
134
164
|
// agent that cannot act on a caveat will learn to skip it.
|
|
135
|
-
if (
|
|
165
|
+
if (outcomeUnread !== undefined && outcomeUnread.length > 0) {
|
|
136
166
|
return {
|
|
137
167
|
verified: Verified.UNKNOWN,
|
|
138
168
|
verifiedReason: VerifiedReason.OUTCOME_UNREAD,
|
|
139
|
-
because:
|
|
169
|
+
because: `a write returned 2xx with a response body that was never recorded (${outcomeUnread.join('; ')}), so its outcome is unread` +
|
|
170
|
+
' — a 200 describes the transport, not the result (a batch reports per-item failures in the body, and every GraphQL error is a 200). Enable it where your app calls connect(): `reticle.connect({ captureNetworkBodies: true })`, then re-run',
|
|
140
171
|
};
|
|
141
172
|
}
|
|
142
173
|
// Never settled: the page may still be moving, so the observation window may have closed early.
|
|
143
|
-
|
|
174
|
+
//
|
|
175
|
+
// Skipped when the caller DECLARED the consequence and it held. `settled` here is "a real
|
|
176
|
+
// animation frame fired within the budget", which a hidden or throttled tab never does — and a
|
|
177
|
+
// hidden tab is the ordinary case for agent-driven verification. It is corroboration; it is not
|
|
178
|
+
// the claim. It is still reported: `honesty.settled` carries the fact, and the sentence below says
|
|
179
|
+
// so in words.
|
|
180
|
+
if (false === settled && !declaredHeld) {
|
|
144
181
|
return {
|
|
145
182
|
verified: Verified.UNKNOWN,
|
|
146
183
|
verifiedReason: VerifiedReason.UNSETTLED,
|
|
@@ -155,11 +192,17 @@ export function decideVerified(inputs) {
|
|
|
155
192
|
// partial` — the prose contradicting the evidence block next to it. Whichever a reader believes,
|
|
156
193
|
// one of them lied, and the whole point of this layer is that the sentence can be trusted on its
|
|
157
194
|
// own. Seen on a one-way IPC send: coverage said the outcome was unobservable, `because` said clean.
|
|
195
|
+
// A green that rests on the declaration rather than on idle says so. The page not going quiet is a
|
|
196
|
+
// real fact about the app — a poll, a timer, a throttled tab — and a caller who cares must be able
|
|
197
|
+
// to see it without re-deriving it, so it is stated here and carried in `honesty.settled`.
|
|
198
|
+
const notIdle = false === settled
|
|
199
|
+
? ', though the page never went idle — this rests on the consequence you declared, not on the page going quiet'
|
|
200
|
+
: '';
|
|
158
201
|
return {
|
|
159
202
|
verified: Verified.YES,
|
|
160
203
|
verifiedReason: VerifiedReason.PROVED,
|
|
161
204
|
because: true === honesty.coverage?.partial
|
|
162
|
-
? `assertion held at ${honesty.grade} grade with no channel disagreeing, but coverage was PARTIAL — see \`coverage\` for what went unobserved`
|
|
163
|
-
: `assertion held at ${honesty.grade} grade over a clean capture with no channel disagreeing`,
|
|
205
|
+
? `assertion held at ${honesty.grade} grade with no channel disagreeing, but coverage was PARTIAL — see \`coverage\` for what went unobserved${notIdle}`
|
|
206
|
+
: `assertion held at ${honesty.grade} grade over a clean capture with no channel disagreeing${notIdle}`,
|
|
164
207
|
};
|
|
165
208
|
}
|
package/dist/http-server.d.ts
CHANGED
|
@@ -11,6 +11,15 @@ export interface SharedServer {
|
|
|
11
11
|
attachMcp(factory: () => McpServer): void;
|
|
12
12
|
/** Register the JSON the daemon returns from GET /status (live sessions + health for `reticle status`). */
|
|
13
13
|
attachStatus(provider: () => unknown): void;
|
|
14
|
+
/**
|
|
15
|
+
* Register the handler behind POST /drive: open a driveable browser at `url` and answer with the
|
|
16
|
+
* session it registered.
|
|
17
|
+
*
|
|
18
|
+
* This is how `reticle drive` stops fighting the daemon for the bridge port. The daemon already
|
|
19
|
+
* owns the browser plane, so driving is a request to it — see cli/drive-attach.ts. Left
|
|
20
|
+
* unattached, the route 404s, which is exactly what a newer CLI needs to hear from an older daemon.
|
|
21
|
+
*/
|
|
22
|
+
attachDrive(provider: (url: string) => Promise<unknown>): void;
|
|
14
23
|
/**
|
|
15
24
|
* Register a callback fired when the AGENT presence changes — true when the first MCP client (any
|
|
16
25
|
* agent: Codex/OpenCode/Claude/Hermes) connects, false when the last one disconnects. Agent-
|
|
@@ -18,6 +27,15 @@ export interface SharedServer {
|
|
|
18
27
|
* "is an agent attached?". The daemon uses this to tell the panel "agent live" vs "agent stopped".
|
|
19
28
|
*/
|
|
20
29
|
attachAgentPresence(cb: (connected: boolean) => void): void;
|
|
30
|
+
/**
|
|
31
|
+
* Tell every attached MCP proxy that this daemon is retiring, BEFORE the sockets go.
|
|
32
|
+
*
|
|
33
|
+
* The proxy sees a clean stream end whether the daemon shut down on schedule or died under it, so
|
|
34
|
+
* without this the two are one row and the transport-stability metric is dominated by the designed
|
|
35
|
+
* case. Separate from `close()` because it must run before the shutdown work rather than during it:
|
|
36
|
+
* `close()` is also the cleanup path of a start that failed, where there is nobody to tell.
|
|
37
|
+
*/
|
|
38
|
+
announceShutdown(): void;
|
|
21
39
|
close(): Promise<void>;
|
|
22
40
|
}
|
|
23
41
|
/**
|
|
@@ -32,3 +50,4 @@ export interface SharedServer {
|
|
|
32
50
|
export declare function createSharedServer(options?: {
|
|
33
51
|
token?: string;
|
|
34
52
|
}): SharedServer;
|
|
53
|
+
export declare function driveSchemeRefusal(url: string): string | undefined;
|
package/dist/http-server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as http from 'node:http';
|
|
2
2
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
3
|
-
import { MCP_SSE_PATH, MCP_MESSAGE_PATH, STATUS_PATH } from '@reticlehq/core';
|
|
3
|
+
import { MCP_SSE_PATH, MCP_MESSAGE_PATH, MCP_SHUTDOWN_EVENT, STATUS_PATH, DRIVE_PATH, } from '@reticlehq/core';
|
|
4
4
|
import { getSessionMetrics } from './telemetry/session-metrics.js';
|
|
5
5
|
import { noteAgentPeer, PEER_VERSION_PARAM, PEER_CONTRACT_PARAM } from './version/peer-announce.js';
|
|
6
6
|
import { log } from './log.js';
|
|
@@ -18,8 +18,15 @@ import { isLoopbackHost, isLocalWebOrigin, isLoopbackPeer, requestToken, tokensM
|
|
|
18
18
|
export function createSharedServer(options = {}) {
|
|
19
19
|
let mcpFactory;
|
|
20
20
|
let statusProvider;
|
|
21
|
+
let driveProvider;
|
|
21
22
|
let agentPresence;
|
|
22
23
|
const transports = new Map();
|
|
24
|
+
/**
|
|
25
|
+
* The raw response behind each SSE session, so the shutdown announcement can be written as an SSE
|
|
26
|
+
* frame the proxy reads and the agent never sees. `SSEServerTransport.send` only speaks JSON-RPC,
|
|
27
|
+
* and this message is addressed to the proxy rather than to the client behind it.
|
|
28
|
+
*/
|
|
29
|
+
const sseStreams = new Map();
|
|
23
30
|
const token = options.token;
|
|
24
31
|
// The agent control plane (MCP transport) and /status carry the same trust as the browser WS: a
|
|
25
32
|
// loopback peer is trusted (the local stdio proxy and `reticle status` always dial 127.0.0.1), but any
|
|
@@ -45,9 +52,23 @@ export function createSharedServer(options = {}) {
|
|
|
45
52
|
};
|
|
46
53
|
const httpServer = http.createServer((req, res) => {
|
|
47
54
|
const rawUrl = req.url ?? '/';
|
|
48
|
-
|
|
55
|
+
// Node's HTTP parser accepts request targets the WHATWG URL parser refuses (`//` among them), and
|
|
56
|
+
// a throw in a `request` listener is an uncaughtException — which the daemon answers by exiting,
|
|
57
|
+
// taking every agent and every browser session on this port with it, from whichever project. One
|
|
58
|
+
// malformed line on a loopback socket, and it lands BEFORE the authorization check below, so
|
|
59
|
+
// nothing had to be trusted to send it.
|
|
60
|
+
const url = parseRequestTarget(rawUrl);
|
|
61
|
+
if (null === url) {
|
|
62
|
+
log('http_bad_request_target', {});
|
|
63
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
64
|
+
res.end('bad request target');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
49
67
|
const path = url.pathname;
|
|
50
|
-
if (path === STATUS_PATH ||
|
|
68
|
+
if (path === STATUS_PATH ||
|
|
69
|
+
path === DRIVE_PATH ||
|
|
70
|
+
path === MCP_SSE_PATH ||
|
|
71
|
+
path === MCP_MESSAGE_PATH) {
|
|
51
72
|
if (!authorized(req, url)) {
|
|
52
73
|
res.writeHead(401, { 'Content-Type': 'text/plain' });
|
|
53
74
|
res.end('unauthorized');
|
|
@@ -60,6 +81,58 @@ export function createSharedServer(options = {}) {
|
|
|
60
81
|
res.end(body);
|
|
61
82
|
return;
|
|
62
83
|
}
|
|
84
|
+
if ('POST' === req.method && path === DRIVE_PATH) {
|
|
85
|
+
if (driveProvider === undefined) {
|
|
86
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
87
|
+
res.end('not found');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
// A state-changing route must require a content type that a browser cannot send without a
|
|
91
|
+
// preflight. `text/plain`, `application/x-www-form-urlencoded` and `multipart/form-data` make
|
|
92
|
+
// a POST a CORS SIMPLE request: the browser sends it cross-origin with no preflight, and
|
|
93
|
+
// blocking the response afterwards does not un-drive the browser. Since loopback origins are
|
|
94
|
+
// authorized here by design, any page on any localhost port could otherwise reach this route
|
|
95
|
+
// by guessing ours, and drive a browser to a URL it chose.
|
|
96
|
+
//
|
|
97
|
+
// Requiring `application/json` forces a preflight, which the browser will not complete without
|
|
98
|
+
// our opt-in, and we do not opt in. One header check closes the whole class.
|
|
99
|
+
if (!isJsonRequest(req)) {
|
|
100
|
+
res.writeHead(415, { 'Content-Type': 'text/plain' });
|
|
101
|
+
res.end('expected content-type: application/json');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const provider = driveProvider;
|
|
105
|
+
readBody(req)
|
|
106
|
+
.then(async (body) => {
|
|
107
|
+
const url = urlFromDriveRequest(body);
|
|
108
|
+
if (url === undefined) {
|
|
109
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
110
|
+
res.end('drive requires a url');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// Before the browser is asked for anything. See driveSchemeRefusal.
|
|
114
|
+
const refusal = driveSchemeRefusal(url);
|
|
115
|
+
if (refusal !== undefined) {
|
|
116
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
117
|
+
res.end(refusal);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// A refusal is an ANSWER. The caller is a foreground CLI waiting on this socket: a thrown
|
|
121
|
+
// handler that took the response down with it would hang `reticle drive` on a request
|
|
122
|
+
// that can never complete — the same shape of silence the raw EADDRINUSE bind had.
|
|
123
|
+
const payload = await provider(url).catch((err) => ({
|
|
124
|
+
error: err instanceof Error ? err.message : String(err),
|
|
125
|
+
}));
|
|
126
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
127
|
+
res.end(JSON.stringify(payload));
|
|
128
|
+
})
|
|
129
|
+
.catch((err) => {
|
|
130
|
+
log('drive_request_error', { error: err instanceof Error ? err.message : String(err) });
|
|
131
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
132
|
+
res.end('drive failed');
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
63
136
|
if ('GET' === req.method && path === MCP_SSE_PATH) {
|
|
64
137
|
// The agent's MCP server announces itself here, on the connect it already makes. The daemon is
|
|
65
138
|
// the single judge of skew, so this is where the third pair is decided — and unlike the CLI's
|
|
@@ -79,10 +152,12 @@ export function createSharedServer(options = {}) {
|
|
|
79
152
|
const transport = new SSEServerTransport(MCP_MESSAGE_PATH, res);
|
|
80
153
|
const sid = transport.sessionId;
|
|
81
154
|
transports.set(sid, transport);
|
|
155
|
+
sseStreams.set(sid, res);
|
|
82
156
|
if (1 === transports.size)
|
|
83
157
|
agentPresence?.(true); // first agent attached
|
|
84
158
|
res.on('close', () => {
|
|
85
159
|
transports.delete(sid);
|
|
160
|
+
sseStreams.delete(sid);
|
|
86
161
|
transport.close().catch(() => undefined);
|
|
87
162
|
mcpServer.close().catch(() => undefined);
|
|
88
163
|
log('mcp_client_disconnected', { sessionId: sid });
|
|
@@ -137,17 +212,33 @@ export function createSharedServer(options = {}) {
|
|
|
137
212
|
function attachStatus(provider) {
|
|
138
213
|
statusProvider = provider;
|
|
139
214
|
}
|
|
215
|
+
function attachDrive(provider) {
|
|
216
|
+
driveProvider = provider;
|
|
217
|
+
}
|
|
140
218
|
function attachAgentPresence(cb) {
|
|
141
219
|
agentPresence = cb;
|
|
142
220
|
}
|
|
143
221
|
function attachMcp(factory) {
|
|
144
222
|
mcpFactory = factory;
|
|
145
223
|
}
|
|
224
|
+
function announceShutdown() {
|
|
225
|
+
for (const [sid, stream] of sseStreams) {
|
|
226
|
+
try {
|
|
227
|
+
stream.write(`event: ${MCP_SHUTDOWN_EVENT}\ndata: {}\n\n`);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// A stream we cannot write to is a proxy that already left. Announcing a shutdown must never
|
|
231
|
+
// be the reason a shutdown fails.
|
|
232
|
+
log('mcp_shutdown_announce_failed', { sessionId: sid });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
146
236
|
async function close() {
|
|
147
237
|
for (const transport of transports.values()) {
|
|
148
238
|
await transport.close();
|
|
149
239
|
}
|
|
150
240
|
transports.clear();
|
|
241
|
+
sseStreams.clear();
|
|
151
242
|
await new Promise((resolve, reject) => {
|
|
152
243
|
httpServer.close((err) => {
|
|
153
244
|
// A server that never listened is already closed, and saying so is not an error worth
|
|
@@ -167,5 +258,112 @@ export function createSharedServer(options = {}) {
|
|
|
167
258
|
});
|
|
168
259
|
});
|
|
169
260
|
}
|
|
170
|
-
return {
|
|
261
|
+
return {
|
|
262
|
+
httpServer,
|
|
263
|
+
attachMcp,
|
|
264
|
+
attachStatus,
|
|
265
|
+
attachDrive,
|
|
266
|
+
attachAgentPresence,
|
|
267
|
+
announceShutdown,
|
|
268
|
+
close,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
/** Cap on a drive request body. A url is short; anything larger is not one. */
|
|
272
|
+
const MAX_DRIVE_BODY_BYTES = 8 * 1024;
|
|
273
|
+
/** Collect a request body, refusing one too large to be what this route accepts. */
|
|
274
|
+
/**
|
|
275
|
+
* Is this a request a cross-origin page could NOT have sent without a preflight?
|
|
276
|
+
*
|
|
277
|
+
* The three CORS "simple" content types are the whole attack surface here, so anything that is not
|
|
278
|
+
* JSON is refused rather than sniffed. Parameters are allowed (`application/json; charset=utf-8`)
|
|
279
|
+
* because real clients send them.
|
|
280
|
+
*/
|
|
281
|
+
const JSON_MEDIA_TYPE = 'application/json';
|
|
282
|
+
function isJsonRequest(req) {
|
|
283
|
+
// Read through the typed accessor rather than indexing the header bag: the index signature is
|
|
284
|
+
// `any`-shaped, and narrowing after an unsafe read is narrowing a lie.
|
|
285
|
+
const value = req.headersDistinct['content-type']?.[0];
|
|
286
|
+
if (undefined === value)
|
|
287
|
+
return false;
|
|
288
|
+
return JSON_MEDIA_TYPE === value.split(';')[0]?.trim().toLowerCase();
|
|
289
|
+
}
|
|
290
|
+
function readBody(req) {
|
|
291
|
+
return new Promise((resolve, reject) => {
|
|
292
|
+
let body = '';
|
|
293
|
+
req.setEncoding('utf8');
|
|
294
|
+
req.on('data', (chunk) => {
|
|
295
|
+
body += chunk;
|
|
296
|
+
if (body.length > MAX_DRIVE_BODY_BYTES) {
|
|
297
|
+
req.destroy();
|
|
298
|
+
reject(new Error('body too large'));
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
req.on('end', () => resolve(body));
|
|
302
|
+
req.on('error', reject);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
/** The `url` out of a drive request body, or undefined when there isn't a usable one. Pure. */
|
|
306
|
+
/**
|
|
307
|
+
* The schemes the daemon will open in a browser it owns.
|
|
308
|
+
*
|
|
309
|
+
* `drive` exists to drive a running web app, and nothing in the product hands this route anything
|
|
310
|
+
* else — the desktop paths have their own runtimes and never come through here. So the allowlist
|
|
311
|
+
* costs nothing real and closes a class outright.
|
|
312
|
+
*
|
|
313
|
+
* Kept deliberately small. `file:` reads the disk through a browser context WE opened, `data:` is a
|
|
314
|
+
* page that came from nowhere, and `javascript:` is not a document at all. None of them is a thing
|
|
315
|
+
* anybody drives.
|
|
316
|
+
*
|
|
317
|
+
* Worth stating what this is NOT: the CORS defence on the route already keeps a web page off it,
|
|
318
|
+
* because a JSON content type forces a preflight nobody opts into. The caller reaching this check is
|
|
319
|
+
* a local process, which already has broader access than the check removes. It is hygiene about what
|
|
320
|
+
* the daemon will open on request, not a security boundary — and a refusal here should read as a
|
|
321
|
+
* refusal, not an alarm.
|
|
322
|
+
*/
|
|
323
|
+
const DRIVEABLE_SCHEMES = ['http:', 'https:'];
|
|
324
|
+
/** The scheme of `url`, or undefined when it is not a URL at all. */
|
|
325
|
+
function schemeOf(url) {
|
|
326
|
+
try {
|
|
327
|
+
return new URL(url).protocol;
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
export function driveSchemeRefusal(url) {
|
|
334
|
+
const scheme = schemeOf(url);
|
|
335
|
+
if (scheme !== undefined && DRIVEABLE_SCHEMES.includes(scheme))
|
|
336
|
+
return undefined;
|
|
337
|
+
// Names what it got, because the caller is a CLI printing this to somebody who typed the url and
|
|
338
|
+
// needs to see which part of it was the problem.
|
|
339
|
+
return (`unsupported scheme ${scheme ?? '(unparseable url)'} — reticle drive opens a running web app, ` +
|
|
340
|
+
`so it accepts ${DRIVEABLE_SCHEMES.join(' and ')} only`);
|
|
341
|
+
}
|
|
342
|
+
function urlFromDriveRequest(body) {
|
|
343
|
+
let parsed;
|
|
344
|
+
try {
|
|
345
|
+
parsed = JSON.parse(body);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
if (typeof parsed !== 'object' || null === parsed)
|
|
351
|
+
return undefined;
|
|
352
|
+
const url = parsed['url'];
|
|
353
|
+
return 'string' === typeof url && url.length > 0 ? url : undefined;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* The one URL parse on the daemon's request path, and the only place allowed to do it.
|
|
357
|
+
*
|
|
358
|
+
* `null` for anything the parser refuses, so the caller answers rather than throwing. Exported for
|
|
359
|
+
* nothing — it is here, next to its single caller, because a second copy of this parse elsewhere in
|
|
360
|
+
* the handler would reintroduce the crash the guard exists to remove.
|
|
361
|
+
*/
|
|
362
|
+
function parseRequestTarget(rawUrl) {
|
|
363
|
+
try {
|
|
364
|
+
return new URL(rawUrl, 'http://localhost');
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
171
369
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ImpactCounts, type ImpactSnapshot } from '@reticlehq/core';
|
|
2
|
+
import { ImpactStore, type ImpactFoldMeta } from './impact-store.js';
|
|
3
|
+
/** Wire the recorder to a project. Idempotent; the first root wins for the daemon's lifetime. */
|
|
4
|
+
export declare function initImpact(opts: {
|
|
5
|
+
reticleRoot: string | undefined;
|
|
6
|
+
projectName?: string;
|
|
7
|
+
now?: () => number;
|
|
8
|
+
}): ImpactStore | undefined;
|
|
9
|
+
/** The live store, if one has been initialised. */
|
|
10
|
+
export declare function impactStore(): ImpactStore | undefined;
|
|
11
|
+
/** Test seam: drop the singleton so a suite can start from a clean record. */
|
|
12
|
+
export declare function resetImpactForTest(): void;
|
|
13
|
+
/** Record one delta, if the recorder is live. Never throws: stats must not break a tool call. */
|
|
14
|
+
export declare function recordImpact(delta: Partial<ImpactCounts>, meta?: ImpactFoldMeta): void;
|
|
15
|
+
export declare function impactSnapshot(): ImpactSnapshot | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* What one tool call did, as counters.
|
|
18
|
+
*
|
|
19
|
+
* Pure and exported so the shape is testable without a dispatch: given a result and a duration, it
|
|
20
|
+
* says what the record should gain. `verified` is the only field that can mint a verdict, which is
|
|
21
|
+
* the same rule the product states to agents - everything else moves or reads the app.
|
|
22
|
+
*/
|
|
23
|
+
export declare function deltaForToolResult(raw: unknown, durationMs: number, refused: boolean): Partial<ImpactCounts>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Verified } from '@reticlehq/core';
|
|
2
|
+
import { estimateTokens } from '../session/output-budget.js';
|
|
3
|
+
import { ImpactStore } from './impact-store.js';
|
|
4
|
+
/**
|
|
5
|
+
* The daemon's single impact recorder.
|
|
6
|
+
*
|
|
7
|
+
* One store per daemon, created on the first call that knows the project's `.reticle` root. A
|
|
8
|
+
* module singleton for the same reason the tool counters are: the dispatch chokepoint has to be
|
|
9
|
+
* able to record without every tool threading a store through its arguments, and a second recorder
|
|
10
|
+
* would silently split the user's own history in half.
|
|
11
|
+
*/
|
|
12
|
+
let store;
|
|
13
|
+
/** Wire the recorder to a project. Idempotent; the first root wins for the daemon's lifetime. */
|
|
14
|
+
export function initImpact(opts) {
|
|
15
|
+
// No root, no record. Programmatic callers and test doubles build their own deps and are not
|
|
16
|
+
// obliged to carry one, and a courtesy counter must never be the reason a tool call throws.
|
|
17
|
+
if (opts.reticleRoot === undefined || 0 === opts.reticleRoot.length)
|
|
18
|
+
return store;
|
|
19
|
+
store ??= new ImpactStore({ ...opts, reticleRoot: opts.reticleRoot });
|
|
20
|
+
return store;
|
|
21
|
+
}
|
|
22
|
+
/** The live store, if one has been initialised. */
|
|
23
|
+
export function impactStore() {
|
|
24
|
+
return store;
|
|
25
|
+
}
|
|
26
|
+
/** Test seam: drop the singleton so a suite can start from a clean record. */
|
|
27
|
+
export function resetImpactForTest() {
|
|
28
|
+
store = undefined;
|
|
29
|
+
}
|
|
30
|
+
/** Record one delta, if the recorder is live. Never throws: stats must not break a tool call. */
|
|
31
|
+
export function recordImpact(delta, meta = {}) {
|
|
32
|
+
try {
|
|
33
|
+
store?.record(delta, meta);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Counting is a courtesy to the user; failing to count is not worth failing their call over.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function impactSnapshot() {
|
|
40
|
+
return store?.snapshot();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* What one tool call did, as counters.
|
|
44
|
+
*
|
|
45
|
+
* Pure and exported so the shape is testable without a dispatch: given a result and a duration, it
|
|
46
|
+
* says what the record should gain. `verified` is the only field that can mint a verdict, which is
|
|
47
|
+
* the same rule the product states to agents - everything else moves or reads the app.
|
|
48
|
+
*/
|
|
49
|
+
export function deltaForToolResult(raw, durationMs, refused) {
|
|
50
|
+
const delta = {
|
|
51
|
+
calls: 1,
|
|
52
|
+
drivingMs: Math.max(0, Math.round(durationMs)),
|
|
53
|
+
tokensReturned: estimateTokens(JSON.stringify(raw) ?? ''),
|
|
54
|
+
};
|
|
55
|
+
if (refused) {
|
|
56
|
+
delta.refusals = 1;
|
|
57
|
+
return delta;
|
|
58
|
+
}
|
|
59
|
+
const verified = isRecord(raw) ? raw['verified'] : undefined;
|
|
60
|
+
if (verified === Verified.YES) {
|
|
61
|
+
delta.verdicts = 1;
|
|
62
|
+
delta.passed = 1;
|
|
63
|
+
}
|
|
64
|
+
else if (verified === Verified.NO) {
|
|
65
|
+
delta.verdicts = 1;
|
|
66
|
+
delta.failed = 1;
|
|
67
|
+
}
|
|
68
|
+
else if (verified === Verified.UNKNOWN) {
|
|
69
|
+
delta.verdicts = 1;
|
|
70
|
+
delta.unknown = 1;
|
|
71
|
+
}
|
|
72
|
+
return delta;
|
|
73
|
+
}
|
|
74
|
+
function isRecord(value) {
|
|
75
|
+
return 'object' === typeof value && null !== value;
|
|
76
|
+
}
|