@reticlehq/server 2.8.0 → 2.10.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/capsule-store.d.ts +2 -2
- 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-parse.d.ts +1 -1
- package/dist/cli/cli-parse.js +1 -1
- 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/crawl/crawl.d.ts +7 -2
- package/dist/crawl/crawl.js +52 -9
- package/dist/ee/audit-log.js +2 -2
- package/dist/events/body-failures.d.ts +2 -2
- package/dist/events/contradiction-folds.d.ts +45 -0
- package/dist/events/contradiction-folds.js +42 -0
- package/dist/events/contradictions.d.ts +51 -1
- package/dist/events/contradictions.js +26 -7
- package/dist/events/declared.d.ts +45 -0
- package/dist/events/declared.js +73 -0
- package/dist/events/echo-mismatch.d.ts +9 -3
- 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/events/stale-response.d.ts +2 -3
- package/dist/events/stale-response.js +49 -2
- package/dist/events/unit-mismatch.d.ts +2 -2
- 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/flows/verify-change-tools.js +1 -1
- 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 +67 -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 +25 -0
- package/dist/index.js +60 -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 +16 -2
- package/dist/init/mcp.js +23 -1
- 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/license/license.d.ts +69 -4
- package/dist/license/license.js +84 -15
- package/dist/mcp/mcp-proxy.d.ts +7 -93
- package/dist/mcp/mcp-proxy.js +183 -250
- package/dist/mcp/mcp.d.ts +27 -2
- package/dist/mcp/mcp.js +77 -36
- package/dist/mcp/proxy-daemon-probe.d.ts +8 -0
- package/dist/mcp/proxy-daemon-probe.js +48 -0
- package/dist/mcp/proxy-handshake.d.ts +82 -1
- package/dist/mcp/proxy-handshake.js +110 -3
- 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/mcp/sse-frame-parser.d.ts +19 -0
- package/dist/mcp/sse-frame-parser.js +41 -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 +55 -2
- package/dist/session/no-session-diagnosis.js +199 -22
- 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 +172 -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-readiness.js +1 -1
- 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 +29 -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 +57 -0
- package/dist/telemetry/instrumentation-stall.js +103 -0
- package/dist/telemetry/license-activation.d.ts +48 -0
- package/dist/telemetry/license-activation.js +57 -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 +23 -3
- 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 +17 -8
- package/dist/tools/error-recovery.js +10 -6
- package/dist/tools/feedback-tools.js +31 -6
- package/dist/tools/invoke-tool.d.ts +1 -1
- package/dist/tools/invoke-tool.js +30 -8
- 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 +38 -3
- package/dist/tools/tool-names.d.ts +2 -0
- package/dist/tools/tool-names.js +2 -0
- package/dist/tools/tool-surface.d.ts +60 -0
- package/dist/tools/tool-surface.js +84 -19
- package/dist/tools/tools.js +61 -5
- 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 +6 -4
- package/docs/architecture.md +6 -0
- package/docs/benchmarks.md +60 -12
- package/docs/best-practices.mdx +11 -6
- package/docs/capabilities.mdx +204 -57
- 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 +20 -4
- 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 +31 -2
- package/docs/desktop.mdx +276 -80
- package/docs/docs.json +13 -12
- package/docs/enterprise.md +9 -3
- package/docs/faq.mdx +49 -35
- package/docs/fixtures.md +2 -0
- package/docs/flows.md +7 -5
- package/docs/for-agents.md +3 -3
- package/docs/frameworks.mdx +46 -7
- package/docs/gate-plan.md +4 -2
- package/docs/gates.md +8 -6
- 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 +63 -23
- 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 +33 -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 +35 -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 +55 -24
- package/docs/tools-wait-for.mdx +34 -15
- package/docs/troubleshooting.mdx +80 -10
- package/docs/usage.md +46 -24
- 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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP proxy's own log file: where it lives, what bounds it, and how it is written.
|
|
3
|
+
*
|
|
4
|
+
* Split out of mcp-proxy.ts, which is the transport. This is housekeeping for a file, and the two
|
|
5
|
+
* share nothing but a port number — the proxy calls `proxyLog`, and everything else here exists to
|
|
6
|
+
* keep that file from becoming the largest thing on the user's disk.
|
|
7
|
+
*/
|
|
8
|
+
import { appendFileSync, mkdirSync, renameSync, statSync, truncateSync } from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { ReticleDir } from '@reticlehq/core';
|
|
12
|
+
import { log } from '../log.js';
|
|
13
|
+
import { MAX_DAEMON_LOG_BYTES, rotateDaemonLog } from '../daemon/daemon.js';
|
|
14
|
+
/**
|
|
15
|
+
* Which port this proxy serves, for the log file name. Set once at startup.
|
|
16
|
+
*
|
|
17
|
+
* One file per port, matching `daemon-<port>.log`. A single shared file interleaved every proxy on
|
|
18
|
+
* the machine into one stream, so the first question anyone asks of it — "what happened to MY
|
|
19
|
+
* session?" — needed a filter before it could be answered.
|
|
20
|
+
*/
|
|
21
|
+
let logPort;
|
|
22
|
+
/**
|
|
23
|
+
* How much the proxy may append between size checks.
|
|
24
|
+
*
|
|
25
|
+
* Statting the file on every line would be a syscall per log line on a process that logs from a
|
|
26
|
+
* reconnect loop. Counting what we wrote costs nothing and is exact enough, because this process is
|
|
27
|
+
* the only writer of its own log. Well under the cap, so the cap is a cap rather than a suggestion.
|
|
28
|
+
*/
|
|
29
|
+
export const PROXY_LOG_CHECK_BYTES = 256 * 1024;
|
|
30
|
+
/** Appended since the last real size check. Reset by every check, whatever the check decides. */
|
|
31
|
+
let bytesSinceProxyLogCheck = 0;
|
|
32
|
+
/**
|
|
33
|
+
* Keep the proxy log under the same cap as the daemon log, without a stat per write.
|
|
34
|
+
*
|
|
35
|
+
* Returns the new "bytes since the last check" counter, so the accounting is testable without a
|
|
36
|
+
* filesystem. Rotation itself is `rotateDaemonLog` — the proxy log had no cap at all while the
|
|
37
|
+
* daemon log on the SAME machine stayed small, which is the whole of this defect.
|
|
38
|
+
*/
|
|
39
|
+
export function accountProxyLogWrite(bytesSinceCheck, written, path, deps) {
|
|
40
|
+
const total = bytesSinceCheck + written;
|
|
41
|
+
if (PROXY_LOG_CHECK_BYTES > total)
|
|
42
|
+
return total;
|
|
43
|
+
rotateDaemonLog(path, deps);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Reclaim a proxy log that a previous process already let run away. Returns the bytes reclaimed.
|
|
48
|
+
*
|
|
49
|
+
* Reported from the field: one machine's `proxy-4400.log` reached a third of a 460GB disk and broke
|
|
50
|
+
* builds, Docker and ordinary shell commands with ENOSPC. Nothing in Reticle degraded first, so the
|
|
51
|
+
* failure surfaced as the operating system refusing to write files. Files like that exist on real
|
|
52
|
+
* machines now, and the cap alone does not help them: the user should not have to find one with `du`.
|
|
53
|
+
*
|
|
54
|
+
* TRUNCATED IN PLACE, never renamed or unlinked. A rename moves the bytes without reclaiming a byte,
|
|
55
|
+
* and a file that a running process still holds open keeps its blocks allocated after an unlink
|
|
56
|
+
* until that handle closes — which is why the reporter recovered with `: > ~/.reticle/proxy-4400.log`
|
|
57
|
+
* and why that is the operation to copy here.
|
|
58
|
+
*
|
|
59
|
+
* Best-effort, in the same spirit as `rotateDaemonLog`: refusing to start the MCP server over
|
|
60
|
+
* housekeeping is strictly worse than a large file.
|
|
61
|
+
*/
|
|
62
|
+
export function recoverOversizedProxyLog(path, deps, max = MAX_DAEMON_LOG_BYTES) {
|
|
63
|
+
try {
|
|
64
|
+
const size = deps.fileSize(path);
|
|
65
|
+
if (max >= size)
|
|
66
|
+
return 0;
|
|
67
|
+
deps.truncateFile(path);
|
|
68
|
+
return size;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** The real filesystem behind the two rotation helpers. */
|
|
75
|
+
const proxyLogFileOps = {
|
|
76
|
+
fileSize: (p) => statSync(p).size,
|
|
77
|
+
renameFile: (from, to) => renameSync(from, to),
|
|
78
|
+
truncateFile: (p) => truncateSync(p, 0),
|
|
79
|
+
};
|
|
80
|
+
/** Name the proxy log after the port it serves. Called before anything can log. */
|
|
81
|
+
export function setProxyLogPort(port) {
|
|
82
|
+
logPort = port;
|
|
83
|
+
// The one moment we know a size check is worth its syscall, and the only place a file inherited
|
|
84
|
+
// from an older build can be brought back under control.
|
|
85
|
+
const reclaimed = recoverOversizedProxyLog(proxyLogPath(port), proxyLogFileOps);
|
|
86
|
+
if (0 < reclaimed) {
|
|
87
|
+
proxyLog('reticle_mcp_proxy_log_truncated', {
|
|
88
|
+
port,
|
|
89
|
+
reclaimedBytes: reclaimed,
|
|
90
|
+
note: 'the proxy log had grown past its cap and was truncated in place to reclaim the space',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** The proxy's own log file, so a silent drop leaves a readable trace the agent can go read. */
|
|
95
|
+
export function proxyLogPath(port = logPort) {
|
|
96
|
+
const name = port === undefined ? 'mcp-proxy.log' : `proxy-${String(port)}.log`;
|
|
97
|
+
return join(homedir(), ReticleDir.ROOT, name);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Log to stderr (which the agent host usually swallows) AND to ~/.reticle/proxy-<port>.log, which it
|
|
101
|
+
* does not. A dropped MCP connection is invisible from the agent's side — no message, no exit code —
|
|
102
|
+
* so the one thing that makes it diagnosable at all is a file somebody can read afterwards.
|
|
103
|
+
*
|
|
104
|
+
* EXPORTED because the crash handlers need it. `installProxyResilience` was wired to the bare stderr
|
|
105
|
+
* logger, so the proxy's own uncaught exceptions and unhandled rejections — the exact events that
|
|
106
|
+
* present to a human as "the MCP server disconnected" — were written to a stream the editor throws
|
|
107
|
+
* away. The handler ran, the process kept serving, and the reason went nowhere. Reported as "the
|
|
108
|
+
* proxy has no log file… when it dies the diagnostic dies with it", which was half right: the file
|
|
109
|
+
* existed, and the one path that most needed it was not using it.
|
|
110
|
+
*/
|
|
111
|
+
export function proxyLog(event, fields = {}) {
|
|
112
|
+
log(event, fields);
|
|
113
|
+
try {
|
|
114
|
+
const dir = join(homedir(), ReticleDir.ROOT);
|
|
115
|
+
mkdirSync(dir, { recursive: true });
|
|
116
|
+
// WITH A TIMESTAMP. This file is the only record of an outage that survives the session, and
|
|
117
|
+
// without one it cannot answer the two questions anyone brings to it: when did this happen, and
|
|
118
|
+
// how often. A real 3,283-line log on a dev machine held a `gave_up` — the exact event that used
|
|
119
|
+
// to precede `process.exit(1)` and cost the human a manual /mcp reconnect — and it was
|
|
120
|
+
// impossible to tell whether it happened during that person's work or a test run hours earlier.
|
|
121
|
+
// Evidence you cannot place in time is an anecdote.
|
|
122
|
+
const path = proxyLogPath();
|
|
123
|
+
const line = `${JSON.stringify({ t: new Date().toISOString(), event, ...fields })}\n`;
|
|
124
|
+
appendFileSync(path, line, 'utf8');
|
|
125
|
+
// A single proxy process is long-lived and logs from a reconnect loop, so rotating only at
|
|
126
|
+
// startup would leave the growth this file is capable of entirely unchecked while it runs.
|
|
127
|
+
bytesSinceProxyLogCheck = accountProxyLogWrite(bytesSinceProxyLogCheck, Buffer.byteLength(line, 'utf8'), path, proxyLogFileOps);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Logging must never be the thing that kills the proxy.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface InstructionState {
|
|
2
|
+
/**
|
|
3
|
+
* Has an app ever connected to Reticle for this project, on this port?
|
|
4
|
+
*
|
|
5
|
+
* Read from the durable connection memory rather than from the live session list: a daemon four
|
|
6
|
+
* seconds old has no sessions and that says nothing about whether the app is wired. The question
|
|
7
|
+
* is whether this install has EVER worked, not whether it is working this second.
|
|
8
|
+
*/
|
|
9
|
+
previouslyConnected: boolean;
|
|
10
|
+
}
|
|
11
|
+
/** The instructions this daemon should advertise, given what it knows about the project. */
|
|
12
|
+
/**
|
|
13
|
+
* Instructions are sent ONCE at initialize; the tool surface is re-sent every turn. Measured on this
|
|
14
|
+
* server: 621 tokens here against 5,480 per turn there, so anything true of every tool belongs in
|
|
15
|
+
* this block rather than repeated across sixteen parameter descriptions. See shared-params.ts.
|
|
16
|
+
*/
|
|
17
|
+
export declare function buildServerInstructions(state: InstructionState): string;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { SHARED_PARAM_GUIDANCE } from './shared-params.js';
|
|
2
|
+
/**
|
|
3
|
+
* What every connected agent is told, before it has asked anything.
|
|
4
|
+
*
|
|
5
|
+
* The MCP `instructions` string lands in the agent's context at the handshake — no skill file, no
|
|
6
|
+
* restart, no action from the user. For an agent that arrived through a plugin listing or a pasted
|
|
7
|
+
* config block rather than through the skill, it is the ONLY thing Reticle ever says unprompted.
|
|
8
|
+
*
|
|
9
|
+
* It used to open on tool grammar. That is the right thing to say to an agent that has an app to
|
|
10
|
+
* point the tools at, and the wrong thing to say to one that does not — and in the field the
|
|
11
|
+
* overwhelming majority of daemons never see an app connect, never run a command and never call a
|
|
12
|
+
* tool. Those sessions are not agents failing a hard step. They are agents that were never asked to
|
|
13
|
+
* take an easy one: the MCP server registers itself in a single line of config, so a user reaches
|
|
14
|
+
* "the tools are here" without ever reaching "the app is wired", and nothing in that state reads as
|
|
15
|
+
* unfinished. Registering the server is the cheapest part of the product and it is the only part
|
|
16
|
+
* many installs ever complete.
|
|
17
|
+
*
|
|
18
|
+
* So the lead is state-dependent. A project that has never had an app connect is told the one thing
|
|
19
|
+
* it needs; a project that has is not nagged about a step it already took. The verdict discipline
|
|
20
|
+
* and the feedback ask are constant, because they matter in both states.
|
|
21
|
+
*/
|
|
22
|
+
/** The tools, and the rule that only two of them decide anything. Constant across both states. */
|
|
23
|
+
const VERDICT_DISCIPLINE = `Reticle verifies a running web app from the inside: look (reticle_snapshot / reticle_query), act and prove in one hop (reticle_act_and_wait), observe (reticle_state / reticle_network / reticle_console), assert (reticle_assert). Verify a user-facing change against the real app before you call it done, and never weaken a check to make it pass.
|
|
24
|
+
|
|
25
|
+
Only reticle_act_and_wait and reticle_assert produce a verdict. reticle_act and everything else move or read the app and prove nothing, so a drive that ends without one of those two has no result however many tools it used. Prefer reticle_act_and_wait({ ref, action, until }) — it names the expected consequence BEFORE the action, which is the difference between a check and a rationalisation. Only verified:"yes" is a pass — "unknown" means Reticle could not tell what happened, "no-fault" means nothing was declared to prove. Report either as not proved.`;
|
|
26
|
+
/**
|
|
27
|
+
* The first move, for a project no app has ever connected to.
|
|
28
|
+
*
|
|
29
|
+
* Deliberately concrete: the command, what it does to the app, and the call that confirms it. A
|
|
30
|
+
* general instruction to "set Reticle up" is the state these agents are already in — they have the
|
|
31
|
+
* tools and no app, and nothing tells them that combination is incomplete. It also names the dev
|
|
32
|
+
* server restart, because a plugin added to a config the running server already read is the single
|
|
33
|
+
* most common way this step half-succeeds.
|
|
34
|
+
*/
|
|
35
|
+
const FIRST_MOVE = `FIRST: no app has ever connected to Reticle in this project, so nothing here can be verified yet. Having these tools is not the same as being set up — the tools reach a daemon, and the daemon has nothing to look at until the app itself is instrumented.
|
|
36
|
+
|
|
37
|
+
Fix that before anything else: run \`npx @reticlehq/server init\` in the project, then RESTART the dev server (a plugin added to a config the running server already read is not in the bundle), then load the app in a browser. Confirm with reticle_sessions — a session listed there is the proof, and until one appears no other tool can tell you anything about this app.`;
|
|
38
|
+
/** The feedback ask. Constant: an agent that cannot get set up is the report we most need. */
|
|
39
|
+
const FEEDBACK_ASK = `Feedback is first-class here, not a courtesy. Reticle is built FOR agents, so your experience is the only signal that decides what gets built or fixed — and it disappears when your context does. Send it with \`reticle_feedback\` the moment you notice, then carry on with your task:
|
|
40
|
+
- Reticle broke, lied, or could not see something you needed (\`bug\` / \`gap\` / \`ambiguity\`).
|
|
41
|
+
- You wished it could do something, or something works but costs too many calls (\`feature_request\` / \`improvement\`).
|
|
42
|
+
- The install, the wiring, or the setup itself gave you trouble — including anything you had to work around by hand.
|
|
43
|
+
|
|
44
|
+
Nothing is too minor, and a report costs one call. If the tools are unreachable (setup unfinished, daemon down), file the same report from the shell instead: \`reticle feedback --agent --kind <one of those kinds> "what happened"\`. Report defects in RETICLE — a bug you find in the app under test is Reticle working, and belongs in your answer to the user.`;
|
|
45
|
+
/** The instructions this daemon should advertise, given what it knows about the project. */
|
|
46
|
+
/**
|
|
47
|
+
* Instructions are sent ONCE at initialize; the tool surface is re-sent every turn. Measured on this
|
|
48
|
+
* server: 621 tokens here against 5,480 per turn there, so anything true of every tool belongs in
|
|
49
|
+
* this block rather than repeated across sixteen parameter descriptions. See shared-params.ts.
|
|
50
|
+
*/
|
|
51
|
+
export function buildServerInstructions(state) {
|
|
52
|
+
const base = state.previouslyConnected
|
|
53
|
+
? `${VERDICT_DISCIPLINE}\n\n${FEEDBACK_ASK}`
|
|
54
|
+
: `${FIRST_MOVE}\n\n${VERDICT_DISCIPLINE}\n\n${FEEDBACK_ASK}`;
|
|
55
|
+
return `${base}\n\n${SHARED_PARAM_GUIDANCE}`;
|
|
56
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parameters that mean the same thing on every tool, documented ONCE instead of every turn.
|
|
3
|
+
*
|
|
4
|
+
* The advertised surface is re-sent on every turn; the MCP `instructions` block is sent once at
|
|
5
|
+
* initialize. Measured on this server: instructions cost 621 tokens total, the surface costs 5,480
|
|
6
|
+
* PER TURN — 87,672 over a 16-turn run. So a byte of prose moved from a parameter description into
|
|
7
|
+
* instructions is roughly 16x cheaper, and nothing is lost, because the text still reaches the model.
|
|
8
|
+
*
|
|
9
|
+
* `sessionId` is the clearest case: identical boilerplate on 16 tools, 2,076 bytes of the surface,
|
|
10
|
+
* re-explained on every turn to say the same thing it said last turn.
|
|
11
|
+
*
|
|
12
|
+
* This is deliberately NOT the `verify` experiment. That one removed the observation tools and
|
|
13
|
+
* measured what it cost: detection held but false alarms tripled, because evidence got expensive.
|
|
14
|
+
* This removes REPETITION, not evidence — every tool stays advertised and every observation stays
|
|
15
|
+
* one call away. Cost goes down; what the model can see does not change.
|
|
16
|
+
*
|
|
17
|
+
* The trade being made, stated: schema prose is expensive but sits adjacent to the call, while
|
|
18
|
+
* instructions are cheap but distant, and a client that drops `instructions` loses this text
|
|
19
|
+
* entirely. So the short forms below still have to be usable on their own — each one names the
|
|
20
|
+
* parameter's PURPOSE, and only the guidance moves.
|
|
21
|
+
*/
|
|
22
|
+
/** The short form left in the per-turn schema. Must still make sense with no instructions at all. */
|
|
23
|
+
export declare const SHARED_PARAM_SHORT: Readonly<Record<string, string>>;
|
|
24
|
+
/** The full text, sent once at initialize. Everything trimmed above is restated here. */
|
|
25
|
+
export declare const SHARED_PARAM_GUIDANCE: string;
|
|
26
|
+
/** Is this a parameter whose guidance now lives in the instructions block? */
|
|
27
|
+
export declare function isSharedParam(name: string): boolean;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parameters that mean the same thing on every tool, documented ONCE instead of every turn.
|
|
3
|
+
*
|
|
4
|
+
* The advertised surface is re-sent on every turn; the MCP `instructions` block is sent once at
|
|
5
|
+
* initialize. Measured on this server: instructions cost 621 tokens total, the surface costs 5,480
|
|
6
|
+
* PER TURN — 87,672 over a 16-turn run. So a byte of prose moved from a parameter description into
|
|
7
|
+
* instructions is roughly 16x cheaper, and nothing is lost, because the text still reaches the model.
|
|
8
|
+
*
|
|
9
|
+
* `sessionId` is the clearest case: identical boilerplate on 16 tools, 2,076 bytes of the surface,
|
|
10
|
+
* re-explained on every turn to say the same thing it said last turn.
|
|
11
|
+
*
|
|
12
|
+
* This is deliberately NOT the `verify` experiment. That one removed the observation tools and
|
|
13
|
+
* measured what it cost: detection held but false alarms tripled, because evidence got expensive.
|
|
14
|
+
* This removes REPETITION, not evidence — every tool stays advertised and every observation stays
|
|
15
|
+
* one call away. Cost goes down; what the model can see does not change.
|
|
16
|
+
*
|
|
17
|
+
* The trade being made, stated: schema prose is expensive but sits adjacent to the call, while
|
|
18
|
+
* instructions are cheap but distant, and a client that drops `instructions` loses this text
|
|
19
|
+
* entirely. So the short forms below still have to be usable on their own — each one names the
|
|
20
|
+
* parameter's PURPOSE, and only the guidance moves.
|
|
21
|
+
*/
|
|
22
|
+
/** The short form left in the per-turn schema. Must still make sense with no instructions at all. */
|
|
23
|
+
export const SHARED_PARAM_SHORT = {
|
|
24
|
+
sessionId: 'Target a specific tab. Omit unless you mean one — see instructions.',
|
|
25
|
+
since: 'Cursor from a prior reticle_act/reticle_observe.',
|
|
26
|
+
limit: 'Cap descriptors to the first N.',
|
|
27
|
+
};
|
|
28
|
+
/** The full text, sent once at initialize. Everything trimmed above is restated here. */
|
|
29
|
+
export const SHARED_PARAM_GUIDANCE = 'Arguments that mean the same thing on every tool: `sessionId` — omit unless you mean a specific ' +
|
|
30
|
+
'tab; Reticle scopes to your project, prefers the active one, and refuses rather than guesses when ' +
|
|
31
|
+
'ambiguous. `since` — a cursor from a prior act/observe, bounding an observation to what came ' +
|
|
32
|
+
'after it. `limit` — caps returned descriptors, cutting tokens on broad queries.';
|
|
33
|
+
/** Is this a parameter whose guidance now lives in the instructions block? */
|
|
34
|
+
export function isSharedParam(name) {
|
|
35
|
+
return Object.hasOwn(SHARED_PARAM_SHORT, name);
|
|
36
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** One parsed Server-Sent-Events frame: the event name (defaulted to "message") and its data. */
|
|
2
|
+
export interface SseFrame {
|
|
3
|
+
event: string;
|
|
4
|
+
data: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Incremental SSE frame parser for the MCP front door.
|
|
8
|
+
*
|
|
9
|
+
* A single SSE field can split across TCP reads (`da` in one chunk, `ta: {...}` in the next), and a
|
|
10
|
+
* server may send CRLF or bare CR line endings — so the framing is stateful and edge-case-prone, yet it
|
|
11
|
+
* carried every MCP message the agent sends and was only ever exercised end-to-end. Pulled out of the
|
|
12
|
+
* socket handler as a pure, chunk-fed parser so those boundaries are unit-testable: feed raw chunks,
|
|
13
|
+
* get back each complete frame (a blank line terminates a frame; `event:` names it, `data:` lines
|
|
14
|
+
* accumulate newline-joined; `id:`/`retry:`/comments are ignored — not needed for the bridge).
|
|
15
|
+
*/
|
|
16
|
+
export declare class SseFrameParser {
|
|
17
|
+
#private;
|
|
18
|
+
push(chunk: string): SseFrame[];
|
|
19
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Incremental SSE frame parser for the MCP front door.
|
|
3
|
+
*
|
|
4
|
+
* A single SSE field can split across TCP reads (`da` in one chunk, `ta: {...}` in the next), and a
|
|
5
|
+
* server may send CRLF or bare CR line endings — so the framing is stateful and edge-case-prone, yet it
|
|
6
|
+
* carried every MCP message the agent sends and was only ever exercised end-to-end. Pulled out of the
|
|
7
|
+
* socket handler as a pure, chunk-fed parser so those boundaries are unit-testable: feed raw chunks,
|
|
8
|
+
* get back each complete frame (a blank line terminates a frame; `event:` names it, `data:` lines
|
|
9
|
+
* accumulate newline-joined; `id:`/`retry:`/comments are ignored — not needed for the bridge).
|
|
10
|
+
*/
|
|
11
|
+
export class SseFrameParser {
|
|
12
|
+
#buffer = '';
|
|
13
|
+
#event = '';
|
|
14
|
+
#data = '';
|
|
15
|
+
push(chunk) {
|
|
16
|
+
this.#buffer += chunk;
|
|
17
|
+
// Normalise CRLF/CR so the splitter only handles \n, then hold the trailing partial line for the
|
|
18
|
+
// next chunk (it may complete later).
|
|
19
|
+
const normalised = this.#buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
20
|
+
const lines = normalised.split('\n');
|
|
21
|
+
this.#buffer = lines.pop() ?? '';
|
|
22
|
+
const frames = [];
|
|
23
|
+
for (const line of lines) {
|
|
24
|
+
if ('' === line) {
|
|
25
|
+
if (this.#data !== '') {
|
|
26
|
+
frames.push({ event: this.#event !== '' ? this.#event : 'message', data: this.#data });
|
|
27
|
+
}
|
|
28
|
+
this.#event = '';
|
|
29
|
+
this.#data = '';
|
|
30
|
+
}
|
|
31
|
+
else if (line.startsWith('event:')) {
|
|
32
|
+
this.#event = line.slice(6).trim();
|
|
33
|
+
}
|
|
34
|
+
else if (line.startsWith('data:')) {
|
|
35
|
+
const val = line.slice(5).trim();
|
|
36
|
+
this.#data = this.#data !== '' ? `${this.#data}\n${val}` : val;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return frames;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -13,6 +13,8 @@ export interface ReticleDirPaths {
|
|
|
13
13
|
baselines: string;
|
|
14
14
|
/**.../.reticle/project.json (cross-run outcome memory) */
|
|
15
15
|
project: string;
|
|
16
|
+
/**.../.reticle/impact.json (what Reticle has done for this user, local only) */
|
|
17
|
+
impact: string;
|
|
16
18
|
/**.../.reticle/visual (PNG baselines + diffs) */
|
|
17
19
|
visual: string;
|
|
18
20
|
/**.../.reticle/runs (verification-run artifacts) */
|
|
@@ -7,6 +7,7 @@ export function reticleDirPaths(root) {
|
|
|
7
7
|
flows: join(root, ReticleDir.FLOWS_SUBDIR),
|
|
8
8
|
baselines: join(root, ReticleDir.BASELINES_SUBDIR),
|
|
9
9
|
project: join(root, ReticleDir.PROJECT_FILE),
|
|
10
|
+
impact: join(root, ReticleDir.IMPACT_FILE),
|
|
10
11
|
visual: join(root, ReticleDir.VISUAL_SUBDIR),
|
|
11
12
|
runs: join(root, ReticleDir.RUNS_SUBDIR),
|
|
12
13
|
sessions: join(root, ReticleDir.SESSIONS_SUBDIR),
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One durable bit: has an app ever connected to Reticle on this port, for this project?
|
|
3
|
+
*
|
|
4
|
+
* `SessionManager.everConnected()` answers a narrower question than every reader of it assumed — it
|
|
5
|
+
* is scoped to the CURRENT PROCESS. Daemons are short-lived by design (they idle out and respawn),
|
|
6
|
+
* so a diagnosis built on that boolean tells a user whose app connected a minute ago that this
|
|
7
|
+
* install has never worked, and sends them looking for a missing config file that was always there.
|
|
8
|
+
* Reported repeatedly, and the confident phrasing is what made it expensive: "never seen one" reads
|
|
9
|
+
* as evidence about the install, not as a fact about a process that booted four seconds ago.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately tiny: one JSON file per port under the daemon's state home, holding the set of
|
|
12
|
+
* projectIds that have connected. It is a HINT, so every failure mode here degrades to "we do not
|
|
13
|
+
* know" rather than throwing — a diagnostic that can crash the tool it is diagnosing is worse than
|
|
14
|
+
* no diagnostic at all.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Record that a session connected. Best-effort — a daemon must never fail to serve a session
|
|
18
|
+
* because it could not write a hint about it.
|
|
19
|
+
*/
|
|
20
|
+
export declare function rememberConnected(stateDir: string, port: number, projectId: string | undefined): void;
|
|
21
|
+
/**
|
|
22
|
+
* Whether an app has connected on this port before, for this project.
|
|
23
|
+
*
|
|
24
|
+
* An absent `projectId` asks the weaker question — "has ANY app connected on this port" — because
|
|
25
|
+
* that is the honest reading when the caller cannot name a project: the alternative is to answer
|
|
26
|
+
* "no" for a daemon that plainly has served apps, which is the same over-confident claim this file
|
|
27
|
+
* exists to remove.
|
|
28
|
+
*/
|
|
29
|
+
export declare function hasConnectedBefore(stateDir: string, port: number, projectId: string | undefined): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Has an app connected before FOR THIS PROJECT — the question the first-move instructions ask.
|
|
32
|
+
*
|
|
33
|
+
* Separate from `hasConnectedBefore` because they are genuinely different questions and only one of
|
|
34
|
+
* them tolerates the untagged fallback. The no-session diagnosis wants "has this daemon ever served
|
|
35
|
+
* an app", and answering "no" there when it plainly has is the over-confident claim this file
|
|
36
|
+
* exists to remove. The first-move block claims something narrower and says so in its own first
|
|
37
|
+
* sentence: "no app has ever connected to Reticle IN THIS PROJECT".
|
|
38
|
+
*
|
|
39
|
+
* Deciding that narrower claim with the weaker question inverted it for exactly the population it
|
|
40
|
+
* was written for. An unwired project has no `.reticle.json`, so no projectId, so it fell into the
|
|
41
|
+
* untagged branch — which is true as soon as any app has ever connected on this port. On any
|
|
42
|
+
* machine where the default port has served anything at all, the guidance was suppressed and the
|
|
43
|
+
* agent was told the setup was done.
|
|
44
|
+
*
|
|
45
|
+
* So an absent projectId answers `false` here: a project with no Reticle identity has nothing
|
|
46
|
+
* recorded under another project's name that could be evidence about it. The cost of being wrong in
|
|
47
|
+
* this direction is one guidance block, read once at handshake, by a setup that is already working.
|
|
48
|
+
*
|
|
49
|
+
* KNOWN LIMIT, and the reason it is accepted rather than worked around. The WRITE side records the
|
|
50
|
+
* id the SDK supplied, while the read side asks `.reticle.json` — and the Vite plugin derives an id
|
|
51
|
+
* without writing that file. So a project wired by hand with the plugin and no `init` records a real
|
|
52
|
+
* id on every connect and reads `undefined` here forever, and is told at each handshake that nothing
|
|
53
|
+
* has connected. Two things make that tolerable: the block's own advice is to run `init`, which
|
|
54
|
+
* writes the file and heals the detection, so it is actionable rather than merely wrong; and the
|
|
55
|
+
* alternative — answering from the port — is the leak this function exists to close, which is the
|
|
56
|
+
* larger error by a wide margin. `no-session-watch` had already made exactly this call for its own
|
|
57
|
+
* diagnosis, which is why it now shares this function instead of keeping a second copy of the rule.
|
|
58
|
+
*/
|
|
59
|
+
export declare function hasProjectConnectedBefore(stateDir: string, port: number, projectId: string | undefined): boolean;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One durable bit: has an app ever connected to Reticle on this port, for this project?
|
|
3
|
+
*
|
|
4
|
+
* `SessionManager.everConnected()` answers a narrower question than every reader of it assumed — it
|
|
5
|
+
* is scoped to the CURRENT PROCESS. Daemons are short-lived by design (they idle out and respawn),
|
|
6
|
+
* so a diagnosis built on that boolean tells a user whose app connected a minute ago that this
|
|
7
|
+
* install has never worked, and sends them looking for a missing config file that was always there.
|
|
8
|
+
* Reported repeatedly, and the confident phrasing is what made it expensive: "never seen one" reads
|
|
9
|
+
* as evidence about the install, not as a fact about a process that booted four seconds ago.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately tiny: one JSON file per port under the daemon's state home, holding the set of
|
|
12
|
+
* projectIds that have connected. It is a HINT, so every failure mode here degrades to "we do not
|
|
13
|
+
* know" rather than throwing — a diagnostic that can crash the tool it is diagnosing is worse than
|
|
14
|
+
* no diagnostic at all.
|
|
15
|
+
*/
|
|
16
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
/** Sessions from an SDK old enough not to stamp a projectId still count, under this key. */
|
|
19
|
+
const UNTAGGED = '(untagged)';
|
|
20
|
+
/**
|
|
21
|
+
* Cap on remembered projects per port.
|
|
22
|
+
*
|
|
23
|
+
* A shared 4400 on a busy machine sees many projects over a week and this file is never garbage
|
|
24
|
+
* collected, so the set is bounded and oldest-out. The bit it answers is only ever used to soften a
|
|
25
|
+
* diagnosis, so losing the tail costs nothing.
|
|
26
|
+
*/
|
|
27
|
+
const MAX_REMEMBERED_PROJECTS = 32;
|
|
28
|
+
function memoryPath(stateDir, port) {
|
|
29
|
+
return join(stateDir, `connected-${String(port)}.json`);
|
|
30
|
+
}
|
|
31
|
+
/** The remembered project ids for a port. Empty on absent, unreadable, or malformed state. */
|
|
32
|
+
function read(stateDir, port) {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(readFileSync(memoryPath(stateDir, port), 'utf8'));
|
|
35
|
+
if (!Array.isArray(parsed))
|
|
36
|
+
return [];
|
|
37
|
+
return parsed.filter((entry) => 'string' === typeof entry);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Record that a session connected. Best-effort — a daemon must never fail to serve a session
|
|
45
|
+
* because it could not write a hint about it.
|
|
46
|
+
*/
|
|
47
|
+
export function rememberConnected(stateDir, port, projectId) {
|
|
48
|
+
const key = projectId ?? UNTAGGED;
|
|
49
|
+
const known = read(stateDir, port);
|
|
50
|
+
if (known.includes(key))
|
|
51
|
+
return;
|
|
52
|
+
const next = [...known, key].slice(-MAX_REMEMBERED_PROJECTS);
|
|
53
|
+
try {
|
|
54
|
+
mkdirSync(stateDir, { recursive: true });
|
|
55
|
+
writeFileSync(memoryPath(stateDir, port), JSON.stringify(next), 'utf8');
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// An unwritable state home is already reported elsewhere; it must not break the session path.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Whether an app has connected on this port before, for this project.
|
|
63
|
+
*
|
|
64
|
+
* An absent `projectId` asks the weaker question — "has ANY app connected on this port" — because
|
|
65
|
+
* that is the honest reading when the caller cannot name a project: the alternative is to answer
|
|
66
|
+
* "no" for a daemon that plainly has served apps, which is the same over-confident claim this file
|
|
67
|
+
* exists to remove.
|
|
68
|
+
*/
|
|
69
|
+
export function hasConnectedBefore(stateDir, port, projectId) {
|
|
70
|
+
const known = read(stateDir, port);
|
|
71
|
+
if (projectId === undefined)
|
|
72
|
+
return known.length > 0;
|
|
73
|
+
return known.includes(projectId);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Has an app connected before FOR THIS PROJECT — the question the first-move instructions ask.
|
|
77
|
+
*
|
|
78
|
+
* Separate from `hasConnectedBefore` because they are genuinely different questions and only one of
|
|
79
|
+
* them tolerates the untagged fallback. The no-session diagnosis wants "has this daemon ever served
|
|
80
|
+
* an app", and answering "no" there when it plainly has is the over-confident claim this file
|
|
81
|
+
* exists to remove. The first-move block claims something narrower and says so in its own first
|
|
82
|
+
* sentence: "no app has ever connected to Reticle IN THIS PROJECT".
|
|
83
|
+
*
|
|
84
|
+
* Deciding that narrower claim with the weaker question inverted it for exactly the population it
|
|
85
|
+
* was written for. An unwired project has no `.reticle.json`, so no projectId, so it fell into the
|
|
86
|
+
* untagged branch — which is true as soon as any app has ever connected on this port. On any
|
|
87
|
+
* machine where the default port has served anything at all, the guidance was suppressed and the
|
|
88
|
+
* agent was told the setup was done.
|
|
89
|
+
*
|
|
90
|
+
* So an absent projectId answers `false` here: a project with no Reticle identity has nothing
|
|
91
|
+
* recorded under another project's name that could be evidence about it. The cost of being wrong in
|
|
92
|
+
* this direction is one guidance block, read once at handshake, by a setup that is already working.
|
|
93
|
+
*
|
|
94
|
+
* KNOWN LIMIT, and the reason it is accepted rather than worked around. The WRITE side records the
|
|
95
|
+
* id the SDK supplied, while the read side asks `.reticle.json` — and the Vite plugin derives an id
|
|
96
|
+
* without writing that file. So a project wired by hand with the plugin and no `init` records a real
|
|
97
|
+
* id on every connect and reads `undefined` here forever, and is told at each handshake that nothing
|
|
98
|
+
* has connected. Two things make that tolerable: the block's own advice is to run `init`, which
|
|
99
|
+
* writes the file and heals the detection, so it is actionable rather than merely wrong; and the
|
|
100
|
+
* alternative — answering from the port — is the leak this function exists to close, which is the
|
|
101
|
+
* larger error by a wide margin. `no-session-watch` had already made exactly this call for its own
|
|
102
|
+
* diagnosis, which is why it now shares this function instead of keeping a second copy of the rule.
|
|
103
|
+
*/
|
|
104
|
+
export function hasProjectConnectedBefore(stateDir, port, projectId) {
|
|
105
|
+
if (projectId === undefined)
|
|
106
|
+
return false;
|
|
107
|
+
return hasConnectedBefore(stateDir, port, projectId);
|
|
108
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The literal command that starts THIS project's dev server, read from the project's own scripts.
|
|
3
|
+
*
|
|
4
|
+
* The no-session diagnosis has always said "ask the human to start it (`npm run dev`)". That is a
|
|
5
|
+
* guess on two axes — the script name and the package manager — and a guessed command is worse than
|
|
6
|
+
* none: the agent runs it, gets an error about a missing script, and concludes the app is broken
|
|
7
|
+
* rather than that Reticle was wrong. So this reads, and returns nothing when it cannot read.
|
|
8
|
+
*
|
|
9
|
+
* Pure apart from one injected reader, so every case is a unit test rather than a temp directory.
|
|
10
|
+
*/
|
|
11
|
+
/** A dev script, as something an agent can actually run. */
|
|
12
|
+
export interface DevCommand {
|
|
13
|
+
/** The literal shell command, e.g. `pnpm run dev`. */
|
|
14
|
+
command: string;
|
|
15
|
+
/** The package.json script it runs. */
|
|
16
|
+
script: string;
|
|
17
|
+
/** The port the script PINS, when it pins one. Absent means the tool picks — never a guess. */
|
|
18
|
+
port?: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The dev command for the project in `directory`, or undefined when there is not one to be sure of.
|
|
22
|
+
*
|
|
23
|
+
* `read` is injected so the whole decision is testable without a filesystem; the daemon uses the
|
|
24
|
+
* default disk reader.
|
|
25
|
+
*/
|
|
26
|
+
export declare function detectDevCommand(directory: string, read?: (path: string) => string | undefined): DevCommand | undefined;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The literal command that starts THIS project's dev server, read from the project's own scripts.
|
|
3
|
+
*
|
|
4
|
+
* The no-session diagnosis has always said "ask the human to start it (`npm run dev`)". That is a
|
|
5
|
+
* guess on two axes — the script name and the package manager — and a guessed command is worse than
|
|
6
|
+
* none: the agent runs it, gets an error about a missing script, and concludes the app is broken
|
|
7
|
+
* rather than that Reticle was wrong. So this reads, and returns nothing when it cannot read.
|
|
8
|
+
*
|
|
9
|
+
* Pure apart from one injected reader, so every case is a unit test rather than a temp directory.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
/**
|
|
14
|
+
* Script names checked, in order.
|
|
15
|
+
*
|
|
16
|
+
* `start` is last on purpose: in a Next or CRA app it is the PRODUCTION server, which serves a build
|
|
17
|
+
* with no dev-only SDK in it, so starting it would look like success and connect nothing. It is only
|
|
18
|
+
* reached when the project offers nothing better.
|
|
19
|
+
*/
|
|
20
|
+
const DEV_SCRIPTS = ['dev', 'develop', 'start'];
|
|
21
|
+
/** Lockfile → package manager, in precedence order. Anything else is npm. */
|
|
22
|
+
const LOCKFILES = [
|
|
23
|
+
{ file: 'pnpm-lock.yaml', manager: 'pnpm' },
|
|
24
|
+
{ file: 'yarn.lock', manager: 'yarn' },
|
|
25
|
+
];
|
|
26
|
+
const DEFAULT_PACKAGE_MANAGER = 'npm';
|
|
27
|
+
const PACKAGE_JSON = 'package.json';
|
|
28
|
+
/** `--port 4311` / `--port=4311`, the flag every dev server in this list accepts. */
|
|
29
|
+
const PORT_FLAG = /--port[= ]\s*(\d+)/;
|
|
30
|
+
/** `PORT=8080 remix dev` — the other half of how a project pins its port. */
|
|
31
|
+
const PORT_ENV = /(?:^|\s)PORT=(\d+)/;
|
|
32
|
+
/** Reads a file, or undefined if it is not there / not readable. */
|
|
33
|
+
function readTextFile(path) {
|
|
34
|
+
try {
|
|
35
|
+
return readFileSync(path, 'utf8');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** The port the script pins, when it pins one at all. */
|
|
42
|
+
function pinnedPort(script) {
|
|
43
|
+
const match = PORT_FLAG.exec(script) ?? PORT_ENV.exec(script);
|
|
44
|
+
const digits = match?.[1];
|
|
45
|
+
if (digits === undefined)
|
|
46
|
+
return undefined;
|
|
47
|
+
const port = Number.parseInt(digits, 10);
|
|
48
|
+
return Number.isSafeInteger(port) ? port : undefined;
|
|
49
|
+
}
|
|
50
|
+
/** The scripts block of a package.json, or an empty one for anything unreadable/unparseable. */
|
|
51
|
+
function readScripts(raw) {
|
|
52
|
+
if (raw === undefined)
|
|
53
|
+
return {};
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(raw);
|
|
56
|
+
if (null === parsed || 'object' !== typeof parsed)
|
|
57
|
+
return {};
|
|
58
|
+
const scripts = parsed.scripts;
|
|
59
|
+
if (null === scripts || 'object' !== typeof scripts)
|
|
60
|
+
return {};
|
|
61
|
+
return scripts;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The dev command for the project in `directory`, or undefined when there is not one to be sure of.
|
|
69
|
+
*
|
|
70
|
+
* `read` is injected so the whole decision is testable without a filesystem; the daemon uses the
|
|
71
|
+
* default disk reader.
|
|
72
|
+
*/
|
|
73
|
+
export function detectDevCommand(directory, read = readTextFile) {
|
|
74
|
+
const scripts = readScripts(read(join(directory, PACKAGE_JSON)));
|
|
75
|
+
const manager = LOCKFILES.find(({ file }) => read(join(directory, file)) !== undefined)?.manager ??
|
|
76
|
+
DEFAULT_PACKAGE_MANAGER;
|
|
77
|
+
for (const script of DEV_SCRIPTS) {
|
|
78
|
+
const body = scripts[script];
|
|
79
|
+
if ('string' !== typeof body || 0 === body.trim().length)
|
|
80
|
+
continue;
|
|
81
|
+
const port = pinnedPort(body);
|
|
82
|
+
return {
|
|
83
|
+
command: `${manager} run ${script}`,
|
|
84
|
+
script,
|
|
85
|
+
...(port === undefined ? {} : { port }),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|