@reticlehq/server 2.7.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/README.md +14 -5
- package/SKILL.md +130 -778
- 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 +36 -1
- package/dist/capsule/causal-summary.js +33 -7
- package/dist/capsule/divergence.js +7 -1
- package/dist/cli/chromium-hint.d.ts +33 -5
- package/dist/cli/chromium-hint.js +82 -12
- package/dist/cli/cli-doctor.js +65 -34
- package/dist/cli/cli-flow-commands.js +8 -8
- package/dist/cli/cli-kill.d.ts +58 -0
- package/dist/cli/cli-kill.js +162 -0
- package/dist/cli/cli-parse.d.ts +16 -1
- package/dist/cli/cli-parse.js +32 -9
- package/dist/cli/cli-port.d.ts +32 -0
- package/dist/cli/cli-port.js +82 -35
- package/dist/cli/cli-verify.d.ts +21 -0
- package/dist/cli/cli-verify.js +47 -3
- 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.d.ts +12 -0
- package/dist/cli/doctor-sessions-line.js +69 -0
- 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/port-holder.d.ts +10 -0
- package/dist/cli/port-holder.js +26 -2
- package/dist/cli/status-next-action.d.ts +37 -0
- package/dist/cli/status-next-action.js +61 -0
- package/dist/cli.js +119 -39
- package/dist/crawl/crawl-tools.js +12 -2
- package/dist/crawl/crawl.d.ts +13 -0
- package/dist/crawl/crawl.js +50 -2
- package/dist/events/contradictions.d.ts +46 -0
- package/dist/events/contradictions.js +35 -22
- 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 -72
- package/dist/events/predicate-eval.js +117 -236
- 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 +296 -22
- 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-classify.js +1 -1
- package/dist/flows/flow-replay-run.js +8 -1
- package/dist/flows/flow-tools.js +21 -5
- package/dist/flows/heal-run.js +1 -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 +10 -2
- 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 +49 -0
- package/dist/honesty/unsettled.js +82 -0
- package/dist/honesty/verified.d.ts +36 -3
- package/dist/honesty/verified.js +54 -11
- 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 +47 -10
- package/dist/init/agent-rules.d.ts +36 -0
- package/dist/init/agent-rules.js +132 -22
- package/dist/init/astro-patch.d.ts +2 -1
- package/dist/init/astro-patch.js +38 -6
- package/dist/init/capabilities.d.ts +22 -0
- package/dist/init/capabilities.js +76 -0
- package/dist/init/confirm.d.ts +114 -0
- package/dist/init/confirm.js +165 -0
- package/dist/init/connect-steps.d.ts +2 -0
- package/dist/init/connect-steps.js +50 -0
- package/dist/init/cra.js +3 -2
- 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/non-js-project.js +29 -5
- package/dist/init/plan-framework.d.ts +10 -0
- package/dist/init/plan-framework.js +78 -16
- package/dist/init/plan.d.ts +47 -6
- package/dist/init/plan.js +228 -69
- package/dist/init/run.d.ts +24 -2
- package/dist/init/run.js +157 -45
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/slash-command.js +54 -6
- package/dist/init/snippets.d.ts +45 -5
- package/dist/init/snippets.js +194 -42
- package/dist/init/vite-config.js +53 -5
- package/dist/input/network-mock-tools.d.ts +19 -0
- package/dist/input/network-mock-tools.js +37 -5
- 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/license/license.js +1 -1
- package/dist/mcp/mcp-proxy.d.ts +35 -30
- package/dist/mcp/mcp-proxy.js +306 -104
- package/dist/mcp/mcp.d.ts +10 -1
- package/dist/mcp/mcp.js +70 -34
- package/dist/mcp/proxy-handshake.d.ts +82 -1
- package/dist/mcp/proxy-handshake.js +109 -2
- package/dist/mcp/proxy-lifecycle.d.ts +11 -2
- package/dist/mcp/proxy-lifecycle.js +12 -3
- 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/pool/browser-pool.d.ts +22 -2
- package/dist/pool/browser-pool.js +44 -4
- package/dist/project/learned-routes.d.ts +24 -0
- package/dist/project/learned-routes.js +63 -0
- package/dist/project/project-store.d.ts +6 -0
- package/dist/project/project-store.js +25 -1
- 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 +67 -2
- package/dist/session/no-session-diagnosis.js +236 -27
- 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 +174 -8
- package/dist/session/pending-commands.d.ts +12 -0
- package/dist/session/pending-commands.js +13 -1
- package/dist/session/ref-provenance.d.ts +54 -0
- package/dist/session/ref-provenance.js +61 -0
- package/dist/session/session-lease.js +1 -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/automation-hint.d.ts +17 -0
- package/dist/telemetry/automation-hint.js +71 -0
- package/dist/telemetry/bug-found.d.ts +15 -17
- package/dist/telemetry/bug-found.js +52 -21
- package/dist/telemetry/cli-telemetry.js +7 -1
- package/dist/telemetry/daemon-telemetry.js +24 -0
- package/dist/telemetry/feedback.js +1 -1
- package/dist/telemetry/init-telemetry.js +9 -1
- package/dist/telemetry/install-source.d.ts +56 -0
- package/dist/telemetry/install-source.js +74 -0
- package/dist/telemetry/instrumentation-stall.d.ts +50 -0
- package/dist/telemetry/instrumentation-stall.js +90 -0
- package/dist/telemetry/run-telemetry.js +4 -1
- package/dist/telemetry/session-metrics.d.ts +8 -0
- package/dist/telemetry/session-metrics.js +15 -0
- package/dist/telemetry/telemetry.d.ts +7 -1
- package/dist/telemetry/telemetry.js +26 -2
- package/dist/telemetry/tool-refused.d.ts +19 -0
- package/dist/telemetry/tool-refused.js +55 -0
- package/dist/telemetry/verification-of.js +46 -7
- package/dist/temp-dir.d.ts +27 -0
- package/dist/temp-dir.js +46 -0
- package/dist/tools/act-tools.js +209 -28
- package/dist/tools/assert-grade.d.ts +1 -1
- package/dist/tools/assert-grade.js +27 -5
- package/dist/tools/assert-verdict.js +49 -7
- package/dist/tools/browser-tools.js +10 -0
- package/dist/tools/contract-tools.js +4 -0
- package/dist/tools/dynamic-tools.js +16 -7
- package/dist/tools/error-recovery.d.ts +10 -0
- package/dist/tools/error-recovery.js +73 -9
- package/dist/tools/feedback-invite.js +1 -1
- package/dist/tools/invoke-tool.js +159 -26
- package/dist/tools/lease-hint.d.ts +30 -8
- package/dist/tools/lease-hint.js +65 -14
- package/dist/tools/lease-tools.d.ts +40 -1
- package/dist/tools/lease-tools.js +181 -83
- package/dist/tools/navigate-result.js +1 -1
- package/dist/tools/observe-tools.js +10 -4
- package/dist/tools/observed-nothing.js +1 -1
- package/dist/tools/react-grace.d.ts +52 -0
- package/dist/tools/react-grace.js +90 -0
- package/dist/tools/read-tools.js +67 -5
- 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 +94 -0
- package/dist/tools/settle-in-flight.js +146 -0
- package/dist/tools/snapshot-delta.d.ts +10 -0
- package/dist/tools/snapshot-delta.js +194 -9
- package/dist/tools/tool-kit.d.ts +8 -0
- package/dist/tools/tool-surface.d.ts +60 -0
- package/dist/tools/tool-surface.js +86 -1
- package/dist/tools/tools.js +61 -8
- package/dist/tools/unadvertised-help.js +9 -6
- package/docs/README.md +55 -11
- package/docs/actions.mdx +120 -0
- package/docs/agent-cheatsheet.md +95 -29
- package/docs/architecture.md +52 -35
- package/docs/benchmarks.md +127 -75
- package/docs/best-practices.mdx +216 -0
- package/docs/capabilities.mdx +534 -0
- package/docs/cli/affected.mdx +53 -0
- package/docs/cli/capsules.mdx +70 -0
- package/docs/cli/cloud.mdx +153 -0
- package/docs/cli/doctor.mdx +109 -0
- package/docs/cli/drive.mdx +74 -0
- package/docs/cli/feedback.mdx +67 -0
- package/docs/cli/gate.mdx +64 -0
- package/docs/cli/help.mdx +94 -0
- package/docs/cli/hunt.mdx +73 -0
- package/docs/cli/identify.mdx +76 -0
- package/docs/cli/init.mdx +121 -0
- package/docs/cli/kill.mdx +91 -0
- package/docs/cli/license.mdx +48 -0
- package/docs/cli/mcp.mdx +61 -0
- package/docs/cli/open.mdx +74 -0
- package/docs/cli/restart.mdx +60 -0
- package/docs/cli/rollback.mdx +35 -0
- package/docs/cli/serve.mdx +69 -0
- package/docs/cli/status.mdx +88 -0
- package/docs/cli/stop.mdx +64 -0
- package/docs/cli/telemetry.mdx +57 -0
- package/docs/cli/update.mdx +52 -0
- package/docs/cli/verify.mdx +76 -0
- package/docs/cli/version.mdx +39 -0
- package/docs/cli/watch.mdx +62 -0
- package/docs/cli.mdx +241 -0
- package/docs/debugging.md +16 -10
- package/docs/deploy-checks.md +45 -12
- package/docs/desktop.mdx +384 -0
- package/docs/docs.json +364 -0
- package/docs/enterprise.md +22 -16
- package/docs/faq.mdx +102 -0
- package/docs/fixtures.md +13 -7
- package/docs/flows.md +29 -23
- package/docs/for-agents.md +71 -0
- package/docs/frameworks.mdx +238 -0
- package/docs/gate-plan.md +48 -42
- package/docs/gates.md +28 -22
- package/docs/getting-started.md +129 -77
- package/docs/human-control.md +24 -18
- package/docs/index.mdx +143 -0
- package/docs/install-agentic.mdx +224 -0
- package/docs/install-manual.mdx +196 -0
- package/docs/instrumentation.mdx +238 -0
- package/docs/integration-patterns.md +41 -35
- package/docs/local-registry.md +19 -13
- package/docs/multi-agent-testing.md +19 -13
- package/docs/packages/babel-plugin.mdx +79 -0
- package/docs/packages/browser.mdx +111 -0
- package/docs/packages/core.mdx +116 -0
- package/docs/packages/electron.mdx +85 -0
- package/docs/packages/eslint-plugin.mdx +86 -0
- package/docs/packages/next.mdx +89 -0
- package/docs/packages/react.mdx +91 -0
- package/docs/packages/server.mdx +98 -0
- package/docs/packages/tauri.mdx +63 -0
- package/docs/packages/test.mdx +119 -0
- package/docs/packages/vite-plugin.mdx +94 -0
- package/docs/packages.mdx +218 -0
- package/docs/platform-integration.md +35 -26
- package/docs/predicates.mdx +248 -0
- package/docs/quickstart.mdx +257 -0
- package/docs/recipes.mdx +282 -0
- package/docs/skill-file.mdx +107 -0
- package/docs/state-management.mdx +199 -0
- package/docs/style.css +14 -0
- package/docs/system-map.md +50 -33
- package/docs/telemetry-contract.md +106 -52
- package/docs/telemetry-events.mdx +475 -0
- package/docs/telemetry.md +63 -55
- package/docs/testing.md +44 -6
- package/docs/token-efficiency.md +37 -27
- package/docs/tools-act-and-wait.mdx +173 -0
- package/docs/tools-act-sequence.mdx +137 -0
- package/docs/tools-act.mdx +95 -0
- package/docs/tools-assert.mdx +129 -0
- package/docs/tools-console.mdx +63 -0
- package/docs/tools-inspect.mdx +88 -0
- package/docs/tools-navigate.mdx +99 -0
- package/docs/tools-network.mdx +119 -0
- package/docs/tools-observe.mdx +145 -0
- package/docs/tools-overview.mdx +160 -0
- package/docs/tools-query.mdx +126 -0
- package/docs/tools-session-and-feedback.mdx +112 -0
- package/docs/tools-sessions.mdx +94 -0
- package/docs/tools-snapshot.mdx +129 -0
- package/docs/tools-state.mdx +104 -0
- package/docs/tools-tools-and-run.mdx +156 -0
- package/docs/tools-wait-for.mdx +85 -0
- package/docs/troubleshooting.mdx +263 -0
- package/docs/usage.md +209 -165
- package/docs/vs-chrome-devtools-mcp.mdx +123 -0
- package/docs/vs-playwright-mcp.mdx +125 -0
- package/docs/vs-screenshots.mdx +137 -0
- package/docs/what-is-reticle.mdx +63 -0
- package/docs/why-reticle.mdx +109 -0
- package/package.json +5 -5
- package/docs/desktop-apps.md +0 -256
- package/docs/matrix/2.5.0/claude-code-machine.json +0 -19
- package/docs/matrix/2.5.0/codex-machine.json +0 -20
- package/docs/matrix/2.5.0/cursor-machine.json +0 -22
- package/docs/matrix/2.5.0/gemini-machine.json +0 -22
- package/docs/matrix/2.5.0/opencode-machine.json +0 -22
- package/docs/matrix/2.5.0/vscode-machine.json +0 -22
- package/docs/matrix/2.5.0/windsurf-machine.json +0 -22
- package/docs/matrix/MATRIX.md +0 -26
- package/docs/matrix/README.md +0 -59
- package/docs/mint.json +0 -61
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
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type ImpactCounts, type ImpactScope, type ImpactSnapshot } from '@reticlehq/core';
|
|
2
|
+
export interface ImpactPaths {
|
|
3
|
+
project: string;
|
|
4
|
+
global: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* `reticleRoot` is the project's own `.reticle` directory (what every other store here is handed);
|
|
8
|
+
* the global scope always lives beside the daemon's own state in `~/.reticle`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function impactPaths(reticleRoot: string): ImpactPaths;
|
|
11
|
+
/** Read a scope, tolerating absence and corruption: a broken file starts over rather than throwing. */
|
|
12
|
+
export declare function readScope(path: string, now: number): ImpactScope;
|
|
13
|
+
/** YYYY-MM-DD in local time - the day boundary a person recognises, not UTC's. */
|
|
14
|
+
export declare function isoDay(now: number): string;
|
|
15
|
+
/**
|
|
16
|
+
* What a fold knows beyond the counters.
|
|
17
|
+
*
|
|
18
|
+
* `runMs` is a SESSION's lifetime, and it only ever feeds the "longest run" record. It is not a
|
|
19
|
+
* counter: adding it to a total would answer a question nobody asked, and the record used to be fed
|
|
20
|
+
* a single tool call's duration instead, which made the report's superlative a number of
|
|
21
|
+
* milliseconds that never grew past one click.
|
|
22
|
+
*/
|
|
23
|
+
export interface ImpactFoldMeta {
|
|
24
|
+
runMs?: number;
|
|
25
|
+
}
|
|
26
|
+
/** Fold one delta into a scope: totals, today's bucket, records and streak. Pure. */
|
|
27
|
+
export declare function applyDelta(scope: ImpactScope, delta: Partial<ImpactCounts>, now: number, meta?: ImpactFoldMeta): ImpactScope;
|
|
28
|
+
/**
|
|
29
|
+
* The live impact record for one project.
|
|
30
|
+
*
|
|
31
|
+
* Holds both scopes in memory, folds deltas in synchronously (so a reader always sees the truth),
|
|
32
|
+
* and flushes to disk on a debounce.
|
|
33
|
+
*/
|
|
34
|
+
export declare class ImpactStore {
|
|
35
|
+
#private;
|
|
36
|
+
constructor(opts: {
|
|
37
|
+
reticleRoot: string;
|
|
38
|
+
projectName?: string;
|
|
39
|
+
now?: () => number;
|
|
40
|
+
});
|
|
41
|
+
/** Notified after every fold, so the HUD can be pushed a fresh snapshot. */
|
|
42
|
+
onChange(fn: () => void): void;
|
|
43
|
+
record(delta: Partial<ImpactCounts>, meta?: ImpactFoldMeta): void;
|
|
44
|
+
snapshot(): ImpactSnapshot;
|
|
45
|
+
/** Write now (process exit, tests). */
|
|
46
|
+
flush(): void;
|
|
47
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { ReticleDir, IMPACT_DAILY_BUCKETS, IMPACT_SCHEMA_VERSION, ImpactScopeSchema, addImpactCounts, emptyImpactCounts, emptyImpactRecords, estimateImpactSavings, } from '@reticlehq/core';
|
|
5
|
+
/**
|
|
6
|
+
* Where Reticle keeps the record of what it has done for you.
|
|
7
|
+
*
|
|
8
|
+
* Two scopes, two files, both local and never uploaded: the project's own `.reticle/impact.json`,
|
|
9
|
+
* and a machine-wide `~/.reticle/impact.json` that answers "what has Reticle done for me overall"
|
|
10
|
+
* across every app you have instrumented.
|
|
11
|
+
*
|
|
12
|
+
* This is NOT telemetry. Telemetry answers our questions about the product and leaves the machine;
|
|
13
|
+
* this answers the user's question about their own work and never does.
|
|
14
|
+
*/
|
|
15
|
+
/** Writes are debounced: a verification loop is 50-200 calls, and each one is a counter bump. */
|
|
16
|
+
const WRITE_DEBOUNCE_MS = 800;
|
|
17
|
+
/**
|
|
18
|
+
* `reticleRoot` is the project's own `.reticle` directory (what every other store here is handed);
|
|
19
|
+
* the global scope always lives beside the daemon's own state in `~/.reticle`.
|
|
20
|
+
*/
|
|
21
|
+
export function impactPaths(reticleRoot) {
|
|
22
|
+
return {
|
|
23
|
+
project: join(reticleRoot, ReticleDir.IMPACT_FILE),
|
|
24
|
+
global: join(homedir(), ReticleDir.ROOT, ReticleDir.IMPACT_FILE),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function emptyScope(now) {
|
|
28
|
+
const counts = emptyImpactCounts();
|
|
29
|
+
return {
|
|
30
|
+
counts,
|
|
31
|
+
days: [],
|
|
32
|
+
records: emptyImpactRecords(),
|
|
33
|
+
savings: estimateImpactSavings(counts),
|
|
34
|
+
since: now,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Read a scope, tolerating absence and corruption: a broken file starts over rather than throwing. */
|
|
38
|
+
export function readScope(path, now) {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
41
|
+
const result = ImpactScopeSchema.safeParse(parsed);
|
|
42
|
+
return result.success ? result.data : emptyScope(now);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return emptyScope(now);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Write a scope atomically.
|
|
50
|
+
*
|
|
51
|
+
* tmp + rename, because two daemons can serve two apps in the same repo at once and a half-written
|
|
52
|
+
* counters file is one the next read discards - silently losing the whole history.
|
|
53
|
+
*/
|
|
54
|
+
function writeScope(path, scope) {
|
|
55
|
+
try {
|
|
56
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
57
|
+
const tmp = `${path}.${String(process.pid)}.tmp`;
|
|
58
|
+
writeFileSync(tmp, JSON.stringify(scope, null, 2), 'utf8');
|
|
59
|
+
renameSync(tmp, path);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// A stats file that cannot be written must never break a tool call. The counters stay in
|
|
63
|
+
// memory and the next write attempt carries them.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** YYYY-MM-DD in local time - the day boundary a person recognises, not UTC's. */
|
|
67
|
+
export function isoDay(now) {
|
|
68
|
+
const d = new Date(now);
|
|
69
|
+
const month = String(d.getMonth() + 1).padStart(2, '0');
|
|
70
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
71
|
+
return `${String(d.getFullYear())}-${month}-${day}`;
|
|
72
|
+
}
|
|
73
|
+
/** Whether `b` is the calendar day right after `a` - the streak rule. */
|
|
74
|
+
function isNextDay(a, b) {
|
|
75
|
+
const prev = new Date(`${a}T00:00:00`);
|
|
76
|
+
const next = new Date(`${b}T00:00:00`);
|
|
77
|
+
return 1 === Math.round((next.getTime() - prev.getTime()) / 86_400_000);
|
|
78
|
+
}
|
|
79
|
+
/** Fold one delta into a scope: totals, today's bucket, records and streak. Pure. */
|
|
80
|
+
export function applyDelta(scope, delta, now, meta = {}) {
|
|
81
|
+
const today = isoDay(now);
|
|
82
|
+
const counts = addImpactCounts(scope.counts, delta);
|
|
83
|
+
const days = scope.days.slice();
|
|
84
|
+
const last = days[days.length - 1];
|
|
85
|
+
if (last !== undefined && last.date === today) {
|
|
86
|
+
days[days.length - 1] = { date: today, counts: addImpactCounts(last.counts, delta) };
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
days.push({ date: today, counts: addImpactCounts(emptyImpactCounts(), delta) });
|
|
90
|
+
}
|
|
91
|
+
while (days.length > IMPACT_DAILY_BUCKETS)
|
|
92
|
+
days.shift();
|
|
93
|
+
const todayCounts = days[days.length - 1]?.counts ?? emptyImpactCounts();
|
|
94
|
+
const previousDay = days[days.length - 2];
|
|
95
|
+
const records = { ...scope.records };
|
|
96
|
+
records.bestVerdictDay = Math.max(records.bestVerdictDay, todayCounts.verdicts);
|
|
97
|
+
records.bestDefectDay = Math.max(records.bestDefectDay, todayCounts.failed);
|
|
98
|
+
if (last === undefined || last.date !== today) {
|
|
99
|
+
// A new day joins the streak only if it follows yesterday; otherwise it starts a new one.
|
|
100
|
+
records.streakDays =
|
|
101
|
+
previousDay !== undefined && isNextDay(previousDay.date, today) ? records.streakDays + 1 : 1;
|
|
102
|
+
}
|
|
103
|
+
else if (0 === records.streakDays) {
|
|
104
|
+
records.streakDays = 1;
|
|
105
|
+
}
|
|
106
|
+
records.bestStreakDays = Math.max(records.bestStreakDays, records.streakDays);
|
|
107
|
+
records.longestRunMs = Math.max(records.longestRunMs, meta.runMs ?? 0);
|
|
108
|
+
return { counts, days, records, savings: estimateImpactSavings(counts), since: scope.since };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The live impact record for one project.
|
|
112
|
+
*
|
|
113
|
+
* Holds both scopes in memory, folds deltas in synchronously (so a reader always sees the truth),
|
|
114
|
+
* and flushes to disk on a debounce.
|
|
115
|
+
*/
|
|
116
|
+
export class ImpactStore {
|
|
117
|
+
#paths;
|
|
118
|
+
#now;
|
|
119
|
+
#projectName;
|
|
120
|
+
#project;
|
|
121
|
+
#global;
|
|
122
|
+
#timer;
|
|
123
|
+
#onChange;
|
|
124
|
+
constructor(opts) {
|
|
125
|
+
this.#paths = impactPaths(opts.reticleRoot);
|
|
126
|
+
this.#now = opts.now ?? (() => Date.now());
|
|
127
|
+
this.#projectName = opts.projectName;
|
|
128
|
+
const now = this.#now();
|
|
129
|
+
this.#project = readScope(this.#paths.project, now);
|
|
130
|
+
this.#global = readScope(this.#paths.global, now);
|
|
131
|
+
}
|
|
132
|
+
/** Notified after every fold, so the HUD can be pushed a fresh snapshot. */
|
|
133
|
+
onChange(fn) {
|
|
134
|
+
this.#onChange = fn;
|
|
135
|
+
}
|
|
136
|
+
record(delta, meta = {}) {
|
|
137
|
+
const now = this.#now();
|
|
138
|
+
this.#project = applyDelta(this.#project, delta, now, meta);
|
|
139
|
+
this.#global = applyDelta(this.#global, delta, now, meta);
|
|
140
|
+
this.#scheduleFlush();
|
|
141
|
+
this.#onChange?.();
|
|
142
|
+
}
|
|
143
|
+
snapshot() {
|
|
144
|
+
const snap = {
|
|
145
|
+
schemaVersion: IMPACT_SCHEMA_VERSION,
|
|
146
|
+
project: this.#project,
|
|
147
|
+
global: this.#global,
|
|
148
|
+
};
|
|
149
|
+
if (this.#projectName !== undefined)
|
|
150
|
+
snap.projectName = this.#projectName;
|
|
151
|
+
return snap;
|
|
152
|
+
}
|
|
153
|
+
/** Write now (process exit, tests). */
|
|
154
|
+
flush() {
|
|
155
|
+
if (this.#timer !== undefined) {
|
|
156
|
+
clearTimeout(this.#timer);
|
|
157
|
+
this.#timer = undefined;
|
|
158
|
+
}
|
|
159
|
+
writeScope(this.#paths.project, this.#project);
|
|
160
|
+
writeScope(this.#paths.global, this.#global);
|
|
161
|
+
}
|
|
162
|
+
#scheduleFlush() {
|
|
163
|
+
if (this.#timer !== undefined)
|
|
164
|
+
return;
|
|
165
|
+
this.#timer = setTimeout(() => {
|
|
166
|
+
this.#timer = undefined;
|
|
167
|
+
this.flush();
|
|
168
|
+
}, WRITE_DEBOUNCE_MS);
|
|
169
|
+
this.#timer.unref?.();
|
|
170
|
+
}
|
|
171
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -123,6 +123,13 @@ export interface RunningServer {
|
|
|
123
123
|
agentAttached?: () => boolean;
|
|
124
124
|
/** The pairing token the bridge is enforcing (explicit, env, or auto-provisioned); undefined if none. */
|
|
125
125
|
token?: string;
|
|
126
|
+
/**
|
|
127
|
+
* Tell every attached MCP proxy this daemon is retiring, before anything closes.
|
|
128
|
+
*
|
|
129
|
+
* Optional because only the daemon entry point serves MCP over SSE — `start()` speaks stdio, where
|
|
130
|
+
* the client owns the process and there is no stream to warn. See SharedServer.announceShutdown.
|
|
131
|
+
*/
|
|
132
|
+
announceShutdown?: () => void;
|
|
126
133
|
close: () => Promise<void>;
|
|
127
134
|
}
|
|
128
135
|
export { resolveBridgeSecurity } from './bridge/bridge-security.js';
|