@reticlehq/server 2.6.0 → 2.8.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 +251 -33
- package/dist/bridge/bridge.d.ts +6 -1
- package/dist/bridge/bridge.js +27 -4
- package/dist/capsule/causal-summary.d.ts +20 -0
- package/dist/capsule/causal-summary.js +31 -6
- package/dist/capsule/divergence.js +7 -1
- package/dist/cli/chromium-hint.d.ts +68 -0
- package/dist/cli/chromium-hint.js +145 -0
- package/dist/cli/cli-doctor.js +31 -36
- package/dist/cli/cli-kill.d.ts +58 -0
- package/dist/cli/cli-kill.js +162 -0
- package/dist/cli/cli-launch.d.ts +18 -0
- package/dist/cli/cli-launch.js +32 -1
- package/dist/cli/cli-parse.d.ts +16 -1
- package/dist/cli/cli-parse.js +32 -9
- package/dist/cli/cli-port.d.ts +8 -0
- package/dist/cli/cli-port.js +36 -1
- package/dist/cli/cli-verify.js +2 -2
- package/dist/cli/doctor-daemon-line.js +10 -1
- package/dist/cli/doctor-sessions-line.d.ts +12 -0
- package/dist/cli/doctor-sessions-line.js +66 -0
- package/dist/cli/port-holder.d.ts +10 -0
- package/dist/cli/port-holder.js +26 -2
- package/dist/cli.js +139 -23
- 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/daemon/daemon-resilience.js +2 -2
- package/dist/daemon/loopback-alias.d.ts +32 -0
- package/dist/daemon/loopback-alias.js +80 -0
- package/dist/events/contradictions.d.ts +14 -0
- package/dist/events/contradictions.js +26 -17
- package/dist/events/predicate-eval.d.ts +26 -3
- package/dist/events/predicate-eval.js +284 -95
- package/dist/events/predicate-parse.d.ts +1 -1
- package/dist/events/predicate-parse.js +18 -4
- package/dist/events/predicate.js +170 -18
- package/dist/events/ring-buffer.d.ts +8 -0
- package/dist/events/ring-buffer.js +34 -1
- package/dist/flows/flow-classify.js +1 -1
- package/dist/flows/flow-tools.js +12 -4
- package/dist/flows/heal-run.js +1 -1
- package/dist/honesty/blind-spots.js +6 -2
- package/dist/honesty/honesty.d.ts +11 -0
- package/dist/honesty/honesty.js +9 -1
- package/dist/honesty/unsettled.d.ts +40 -0
- package/dist/honesty/unsettled.js +78 -0
- package/dist/honesty/verified.d.ts +7 -0
- package/dist/honesty/verified.js +27 -4
- package/dist/http-server.js +11 -1
- package/dist/index.js +29 -2
- package/dist/init/agent-rules.d.ts +16 -0
- package/dist/init/agent-rules.js +96 -17
- package/dist/init/astro-patch.d.ts +2 -1
- package/dist/init/astro-patch.js +11 -5
- package/dist/init/capabilities.d.ts +22 -0
- package/dist/init/capabilities.js +76 -0
- package/dist/init/confirm.d.ts +96 -0
- package/dist/init/confirm.js +138 -0
- package/dist/init/cra.js +3 -2
- package/dist/init/detect.d.ts +8 -0
- package/dist/init/detect.js +15 -0
- package/dist/init/non-js-project.d.ts +27 -0
- package/dist/init/non-js-project.js +84 -0
- package/dist/init/plan-framework.d.ts +9 -0
- package/dist/init/plan-framework.js +72 -16
- package/dist/init/plan.d.ts +40 -4
- package/dist/init/plan.js +183 -43
- package/dist/init/run.d.ts +24 -2
- package/dist/init/run.js +181 -46
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/slash-command.js +46 -6
- package/dist/init/snippets.d.ts +64 -5
- package/dist/init/snippets.js +220 -31
- package/dist/init/vite-config.js +29 -3
- package/dist/input/network-mock-tools.d.ts +19 -0
- package/dist/input/network-mock-tools.js +29 -4
- package/dist/license/license.js +1 -1
- package/dist/mcp/mcp-proxy.d.ts +69 -3
- package/dist/mcp/mcp-proxy.js +215 -23
- package/dist/mcp/mcp.js +58 -7
- package/dist/mcp/proxy-lifecycle.d.ts +20 -1
- package/dist/mcp/proxy-lifecycle.js +22 -1
- package/dist/pool/browser-pool.d.ts +22 -2
- package/dist/pool/browser-pool.js +44 -4
- package/dist/pool/playwright-launcher.js +10 -2
- 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/session/live-control-tools.js +39 -2
- package/dist/session/live-control.d.ts +7 -9
- package/dist/session/live-control.js +34 -1
- package/dist/session/no-session-diagnosis.d.ts +23 -4
- package/dist/session/no-session-diagnosis.js +128 -44
- package/dist/session/no-session-watch.js +12 -0
- package/dist/session/ref-provenance.d.ts +54 -0
- package/dist/session/ref-provenance.js +61 -0
- package/dist/session/review-store.d.ts +19 -0
- package/dist/session/review-store.js +34 -5
- package/dist/session/session-lease.js +1 -1
- package/dist/session/session-manager.d.ts +9 -0
- package/dist/session/session-manager.js +11 -0
- package/dist/session/session.d.ts +8 -0
- package/dist/session/session.js +12 -0
- package/dist/status-payload.d.ts +13 -1
- package/dist/status-payload.js +6 -1
- package/dist/telemetry/app-instrumented.d.ts +37 -0
- package/dist/telemetry/app-instrumented.js +54 -0
- package/dist/telemetry/automation-hint.d.ts +17 -0
- package/dist/telemetry/automation-hint.js +71 -0
- package/dist/telemetry/bug-found.d.ts +0 -20
- package/dist/telemetry/bug-found.js +38 -21
- package/dist/telemetry/cli-telemetry.js +7 -1
- package/dist/telemetry/daemon-telemetry.d.ts +4 -4
- package/dist/telemetry/daemon-telemetry.js +8 -4
- package/dist/telemetry/feedback.js +1 -1
- package/dist/telemetry/init-telemetry.js +9 -1
- package/dist/telemetry/install-source.d.ts +36 -0
- package/dist/telemetry/install-source.js +40 -0
- package/dist/telemetry/run-telemetry.js +4 -1
- package/dist/telemetry/session-metrics.d.ts +3 -3
- package/dist/telemetry/session-metrics.js +9 -4
- package/dist/telemetry/telemetry.d.ts +7 -1
- package/dist/telemetry/telemetry.js +17 -2
- package/dist/telemetry/tool-refused.d.ts +19 -0
- package/dist/telemetry/tool-refused.js +55 -0
- package/dist/telemetry/verification-of.js +72 -8
- package/dist/temp-dir.d.ts +27 -0
- package/dist/temp-dir.js +46 -0
- package/dist/tools/act-tools.js +77 -11
- package/dist/tools/assert-grade.d.ts +1 -1
- package/dist/tools/assert-grade.js +27 -5
- package/dist/tools/assert-verdict.js +25 -1
- package/dist/tools/contract-tools.js +4 -0
- package/dist/tools/dynamic-tools.js +1 -1
- package/dist/tools/error-recovery.d.ts +10 -0
- package/dist/tools/error-recovery.js +70 -5
- package/dist/tools/feedback-invite.js +1 -1
- package/dist/tools/invoke-tool.js +149 -32
- package/dist/tools/lease-tools.d.ts +29 -1
- package/dist/tools/lease-tools.js +58 -5
- package/dist/tools/navigate-result.js +1 -1
- package/dist/tools/observe-tools.js +1 -1
- package/dist/tools/observed-nothing.js +1 -1
- package/dist/tools/react-grace.d.ts +48 -0
- package/dist/tools/react-grace.js +87 -0
- package/dist/tools/read-tools.js +20 -4
- package/dist/tools/settle-in-flight.d.ts +79 -0
- package/dist/tools/settle-in-flight.js +106 -0
- package/dist/tools/snapshot-delta.d.ts +7 -0
- package/dist/tools/snapshot-delta.js +130 -6
- package/dist/tools/tool-kit.d.ts +52 -5
- package/dist/tools/tool-kit.js +55 -24
- package/dist/tools/tool-surface.js +28 -9
- package/dist/tools/tools.js +38 -4
- package/dist/visual/capture-cleanup.d.ts +16 -0
- package/dist/visual/capture-cleanup.js +39 -0
- package/dist/visual/visual-tools.d.ts +18 -0
- package/dist/visual/visual-tools.js +24 -5
- package/docs/README.md +56 -11
- package/docs/actions.mdx +87 -0
- package/docs/agent-cheatsheet.md +102 -26
- package/docs/architecture.md +46 -35
- package/docs/benchmarks.md +76 -72
- package/docs/best-practices.mdx +211 -0
- package/docs/capabilities.mdx +387 -0
- package/docs/cli/affected.mdx +51 -0
- package/docs/cli/capsules.mdx +69 -0
- package/docs/cli/cloud.mdx +127 -0
- package/docs/cli/doctor.mdx +76 -0
- package/docs/cli/drive.mdx +55 -0
- package/docs/cli/feedback.mdx +67 -0
- package/docs/cli/gate.mdx +64 -0
- package/docs/cli/help.mdx +92 -0
- package/docs/cli/hunt.mdx +57 -0
- package/docs/cli/identify.mdx +62 -0
- package/docs/cli/init.mdx +94 -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 +68 -0
- package/docs/cli/restart.mdx +60 -0
- package/docs/cli/rollback.mdx +35 -0
- package/docs/cli/serve.mdx +62 -0
- package/docs/cli/status.mdx +64 -0
- package/docs/cli/stop.mdx +56 -0
- package/docs/cli/telemetry.mdx +57 -0
- package/docs/cli/update.mdx +52 -0
- package/docs/cli/verify.mdx +57 -0
- package/docs/cli/version.mdx +39 -0
- package/docs/cli/watch.mdx +55 -0
- package/docs/cli.mdx +219 -0
- package/docs/debugging.md +13 -9
- package/docs/deploy-checks.md +15 -11
- package/docs/desktop-apps.md +49 -40
- package/docs/desktop.mdx +188 -0
- package/docs/docs.json +363 -0
- package/docs/enterprise.md +20 -16
- package/docs/faq.mdx +88 -0
- package/docs/fixtures.md +11 -7
- package/docs/flows.md +26 -22
- package/docs/for-agents.md +71 -0
- package/docs/frameworks.mdx +199 -0
- package/docs/gate-plan.md +45 -41
- package/docs/gates.md +23 -19
- package/docs/getting-started.md +76 -65
- package/docs/human-control.md +22 -18
- package/docs/index.mdx +101 -0
- package/docs/install-agentic.mdx +215 -0
- package/docs/install-manual.mdx +194 -0
- package/docs/instrumentation.mdx +232 -0
- package/docs/integration-patterns.md +38 -34
- package/docs/local-registry.md +16 -12
- package/docs/multi-agent-testing.md +16 -12
- package/docs/packages/babel-plugin.mdx +70 -0
- package/docs/packages/browser.mdx +111 -0
- package/docs/packages/core.mdx +106 -0
- package/docs/packages/electron.mdx +87 -0
- package/docs/packages/eslint-plugin.mdx +84 -0
- package/docs/packages/next.mdx +91 -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 +108 -0
- package/docs/packages/vite-plugin.mdx +96 -0
- package/docs/packages.mdx +217 -0
- package/docs/platform-integration.md +33 -26
- package/docs/predicates.mdx +179 -0
- package/docs/quickstart.mdx +211 -0
- package/docs/recipes.mdx +242 -0
- package/docs/skill-file.mdx +105 -0
- package/docs/state-management.mdx +197 -0
- package/docs/style.css +14 -0
- package/docs/system-map.md +47 -32
- package/docs/telemetry-contract.md +101 -46
- package/docs/telemetry-events.mdx +430 -0
- package/docs/telemetry.md +63 -50
- package/docs/testing.md +10 -6
- package/docs/token-efficiency.md +30 -26
- package/docs/tools-act-and-wait.mdx +123 -0
- package/docs/tools-act-sequence.mdx +120 -0
- package/docs/tools-act.mdx +82 -0
- package/docs/tools-assert.mdx +70 -0
- package/docs/tools-console.mdx +59 -0
- package/docs/tools-inspect.mdx +85 -0
- package/docs/tools-navigate.mdx +63 -0
- package/docs/tools-network.mdx +86 -0
- package/docs/tools-observe.mdx +89 -0
- package/docs/tools-overview.mdx +157 -0
- package/docs/tools-query.mdx +82 -0
- package/docs/tools-session-and-feedback.mdx +91 -0
- package/docs/tools-sessions.mdx +65 -0
- package/docs/tools-snapshot.mdx +75 -0
- package/docs/tools-state.mdx +81 -0
- package/docs/tools-tools-and-run.mdx +125 -0
- package/docs/tools-wait-for.mdx +66 -0
- package/docs/troubleshooting.mdx +193 -0
- package/docs/usage.md +175 -153
- package/docs/vs-chrome-devtools-mcp.mdx +82 -0
- package/docs/vs-playwright-mcp.mdx +85 -0
- package/docs/vs-screenshots.mdx +96 -0
- package/docs/what-is-reticle.mdx +63 -0
- package/docs/why-reticle.mdx +107 -0
- package/package.json +2 -2
- 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
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
import { EventType, PerfMetric, isDevToolingUrl } from '@reticlehq/core';
|
|
2
|
+
/**
|
|
3
|
+
* THE INVARIANT: every field of this summary has a bounded size that does not depend on how much the
|
|
4
|
+
* app did. A verdict travels in the same payload as its evidence, so an unbounded evidence field is
|
|
5
|
+
* an unbounded chance that the verdict is the part a client truncates — and an agent that cannot read
|
|
6
|
+
* the verdict has run the verification and got nothing, which is indistinguishable from not running
|
|
7
|
+
* it. Measured: one click on a page with a registered TanStack Query store returned ~363KB, because
|
|
8
|
+
* `capValue` bounded each diff and nothing bounded the COUNT of them.
|
|
9
|
+
*
|
|
10
|
+
* 20 keeps the signal that is actually read — the first paths to move, and the settle window over all
|
|
11
|
+
* of them — while the full names of everything that changed stay one `reticle_state` call away.
|
|
12
|
+
*/
|
|
13
|
+
export const MAX_SUMMARY_ENTRIES = 20;
|
|
14
|
+
/** Trim one list to the cap and report the loss. Order is arrival order, so the head is the earliest. */
|
|
15
|
+
function cap(list, name, elided) {
|
|
16
|
+
if (list.length <= MAX_SUMMARY_ENTRIES)
|
|
17
|
+
return list;
|
|
18
|
+
elided[name] = list.length - MAX_SUMMARY_ENTRIES;
|
|
19
|
+
return list.slice(0, MAX_SUMMARY_ENTRIES);
|
|
20
|
+
}
|
|
2
21
|
function pushUnique(list, value) {
|
|
3
22
|
if ('string' === typeof value && value.length > 0 && !list.includes(value))
|
|
4
23
|
list.push(value);
|
|
@@ -118,6 +137,11 @@ export function causalSummary(events) {
|
|
|
118
137
|
break;
|
|
119
138
|
}
|
|
120
139
|
}
|
|
140
|
+
const elided = {};
|
|
141
|
+
// `stateSettleMs` is computed over EVERY diff, before the cap: the interval is a fact about the
|
|
142
|
+
// store, and shrinking it because the evidence was trimmed would make the transient window read as
|
|
143
|
+
// narrower than it was — the one number here that must survive the trim intact.
|
|
144
|
+
const settle = stateSettle(stateDiffs);
|
|
121
145
|
return {
|
|
122
146
|
net: {
|
|
123
147
|
total: netTotal,
|
|
@@ -126,13 +150,14 @@ export function causalSummary(events) {
|
|
|
126
150
|
...(headline === undefined ? {} : { headline }),
|
|
127
151
|
},
|
|
128
152
|
consoleErrors,
|
|
129
|
-
statePathsChanged,
|
|
130
|
-
storageKeysChanged,
|
|
131
|
-
stateDiffs,
|
|
132
|
-
...
|
|
133
|
-
storageDiffs,
|
|
153
|
+
statePathsChanged: cap(statePathsChanged, 'statePathsChanged', elided),
|
|
154
|
+
storageKeysChanged: cap(storageKeysChanged, 'storageKeysChanged', elided),
|
|
155
|
+
stateDiffs: cap(stateDiffs, 'stateDiffs', elided),
|
|
156
|
+
...settle,
|
|
157
|
+
storageDiffs: cap(storageDiffs, 'storageDiffs', elided),
|
|
134
158
|
...(route === undefined ? {} : { route }),
|
|
135
|
-
signals,
|
|
159
|
+
signals: cap(signals, 'signals', elided),
|
|
160
|
+
...(0 === Object.keys(elided).length ? {} : { elided }),
|
|
136
161
|
...(layoutShift === undefined ? {} : { layoutShift }),
|
|
137
162
|
longTasks,
|
|
138
163
|
};
|
|
@@ -7,7 +7,13 @@ function satisfies(link, events) {
|
|
|
7
7
|
case ConsequenceKind.SIGNAL:
|
|
8
8
|
return events.some((e) => e.type === EventType.SIGNAL && e.data['name'] === link.name);
|
|
9
9
|
case ConsequenceKind.STATE:
|
|
10
|
-
|
|
10
|
+
// Matched against the store name OR the changed path, because the link carries whichever half
|
|
11
|
+
// the predicate gave: `store` when it named one, and `path` otherwise (predicateToExpectedLinks).
|
|
12
|
+
// The event carries both. Comparing only the store meant every assertion written by path
|
|
13
|
+
// reported "never changed" however the app behaved — a false claim printed beside a `stateDiffs`
|
|
14
|
+
// entry showing that exact path changing.
|
|
15
|
+
return events.some((e) => e.type === EventType.STATE_CHANGE &&
|
|
16
|
+
(e.data['name'] === link.name || e.data['path'] === link.name));
|
|
11
17
|
case ConsequenceKind.NET:
|
|
12
18
|
return net(events).some((e) => 'string' === typeof e.data['url'] &&
|
|
13
19
|
e.data['url'].includes(link.urlContains) &&
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Chromium hint you can satisfy by following it.
|
|
3
|
+
*
|
|
4
|
+
* `npx playwright install chromium` looks obviously right and is quietly wrong. `npx` resolves the
|
|
5
|
+
* LATEST playwright, which pins a different browser revision than the playwright the daemon actually
|
|
6
|
+
* bundles — so the command downloads ~90 MiB of a build the daemon will not use, and the check still
|
|
7
|
+
* says missing afterwards. Reported from the field by an agent whose machine already had five
|
|
8
|
+
* chromium builds, none of them the wanted one, with no way to learn which one was wanted.
|
|
9
|
+
*
|
|
10
|
+
* The same reporter came back, because pinning the command fixes only half of it. Their five builds
|
|
11
|
+
* sat in the standard Windows browsers root and the line still read a flat `missing`, which leaves
|
|
12
|
+
* two readings — "the check is broken" and "none of these count" — and no way to choose. Present at
|
|
13
|
+
* the wrong revision and absent entirely are different problems: one is a single pinned download,
|
|
14
|
+
* the other means the lookup is aimed at a root nothing was ever installed into. Collapsing them is
|
|
15
|
+
* what made the loop unbreakable.
|
|
16
|
+
*
|
|
17
|
+
* Three things fix it, and they are what any unsatisfiable check needs: pin the command to the
|
|
18
|
+
* version doing the asking, say what was probed, and separate the verdicts that have different
|
|
19
|
+
* fixes. The wanted revision and the browsers root are both read back off the path Playwright itself
|
|
20
|
+
* resolves, so `PLAYWRIGHT_BROWSERS_PATH` and every platform default are honoured by construction
|
|
21
|
+
* rather than by a table of roots this file would have to keep in step with Playwright's.
|
|
22
|
+
*/
|
|
23
|
+
/** What the check found, gathered by the caller so this stays pure and testable. */
|
|
24
|
+
export interface ChromiumProbe {
|
|
25
|
+
/** Executable path the daemon's own playwright resolves to; undefined when playwright is absent. */
|
|
26
|
+
executablePath?: string | undefined;
|
|
27
|
+
/** Whether that path exists on disk. */
|
|
28
|
+
exists: boolean;
|
|
29
|
+
/** Version of the playwright the DAEMON bundles — not whatever `npx` would fetch. */
|
|
30
|
+
playwrightVersion?: string | undefined;
|
|
31
|
+
/** The revision that path names, e.g. `chromium-1223`. Undefined if the path has no revision in it. */
|
|
32
|
+
wantedRevision?: string | undefined;
|
|
33
|
+
/** The browsers root the lookup used, wherever `PLAYWRIGHT_BROWSERS_PATH` and the platform put it. */
|
|
34
|
+
browsersRoot?: string | undefined;
|
|
35
|
+
/** Chromium revisions actually sitting in that root, wanted or not. */
|
|
36
|
+
installedRevisions?: readonly string[] | undefined;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The install command, pinned when we know what to pin to.
|
|
40
|
+
*
|
|
41
|
+
* Unpinned is still the right fallback: a wrong-revision download is a bad outcome, but no command
|
|
42
|
+
* at all is a worse one.
|
|
43
|
+
*/
|
|
44
|
+
export declare function chromiumInstallCommand(playwrightVersion?: string): string;
|
|
45
|
+
/**
|
|
46
|
+
* Split a resolved executable path into the browsers root and the revision under it, or null.
|
|
47
|
+
*
|
|
48
|
+
* Reading them back off Playwright's own answer is the point. The alternative is reconstructing the
|
|
49
|
+
* root from `PLAYWRIGHT_BROWSERS_PATH` plus a per-platform default, which is a copy of Playwright's
|
|
50
|
+
* resolution rules that drifts the first time they change one — and getting it wrong here means
|
|
51
|
+
* telling a user their browsers are missing while looking in the wrong directory, which is the exact
|
|
52
|
+
* failure this file exists to stop.
|
|
53
|
+
*/
|
|
54
|
+
export declare function parseChromiumRevision(executablePath: string): {
|
|
55
|
+
revision: string;
|
|
56
|
+
root: string;
|
|
57
|
+
} | null;
|
|
58
|
+
/**
|
|
59
|
+
* Ask the playwright THIS process would use, not the one `npx` would fetch.
|
|
60
|
+
*
|
|
61
|
+
* `executablePath()` resolves the revision the daemon will actually launch AND the root it will look
|
|
62
|
+
* in, which is why the probe never reads `PLAYWRIGHT_BROWSERS_PATH` itself.
|
|
63
|
+
*/
|
|
64
|
+
export declare function probeChromium(): Promise<ChromiumProbe>;
|
|
65
|
+
/** The bundled playwright's version, or undefined if it cannot be read (the hint then goes unpinned). */
|
|
66
|
+
export declare function bundledPlaywrightVersion(): string | undefined;
|
|
67
|
+
/** The doctor line for the Chromium check — verdict, what was probed, and how to satisfy it. */
|
|
68
|
+
export declare function chromiumHint(probe: ChromiumProbe): string;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Chromium hint you can satisfy by following it.
|
|
3
|
+
*
|
|
4
|
+
* `npx playwright install chromium` looks obviously right and is quietly wrong. `npx` resolves the
|
|
5
|
+
* LATEST playwright, which pins a different browser revision than the playwright the daemon actually
|
|
6
|
+
* bundles — so the command downloads ~90 MiB of a build the daemon will not use, and the check still
|
|
7
|
+
* says missing afterwards. Reported from the field by an agent whose machine already had five
|
|
8
|
+
* chromium builds, none of them the wanted one, with no way to learn which one was wanted.
|
|
9
|
+
*
|
|
10
|
+
* The same reporter came back, because pinning the command fixes only half of it. Their five builds
|
|
11
|
+
* sat in the standard Windows browsers root and the line still read a flat `missing`, which leaves
|
|
12
|
+
* two readings — "the check is broken" and "none of these count" — and no way to choose. Present at
|
|
13
|
+
* the wrong revision and absent entirely are different problems: one is a single pinned download,
|
|
14
|
+
* the other means the lookup is aimed at a root nothing was ever installed into. Collapsing them is
|
|
15
|
+
* what made the loop unbreakable.
|
|
16
|
+
*
|
|
17
|
+
* Three things fix it, and they are what any unsatisfiable check needs: pin the command to the
|
|
18
|
+
* version doing the asking, say what was probed, and separate the verdicts that have different
|
|
19
|
+
* fixes. The wanted revision and the browsers root are both read back off the path Playwright itself
|
|
20
|
+
* resolves, so `PLAYWRIGHT_BROWSERS_PATH` and every platform default are honoured by construction
|
|
21
|
+
* rather than by a table of roots this file would have to keep in step with Playwright's.
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
/**
|
|
26
|
+
* A browsers-root entry that is a chromium build.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately not matching `chromium_headless_shell-*`: it lives beside the full build and is a
|
|
29
|
+
* different artifact, so counting it would let a root holding only headless shells report the full
|
|
30
|
+
* browser as present-but-mismatched.
|
|
31
|
+
*/
|
|
32
|
+
const CHROMIUM_DIR = /^chromium-\d+$/;
|
|
33
|
+
/** The same directory name as a path segment, on either separator, so Windows paths split too. */
|
|
34
|
+
const CHROMIUM_SEGMENT = /[\\/](chromium-\d+)[\\/]/;
|
|
35
|
+
/**
|
|
36
|
+
* The install command, pinned when we know what to pin to.
|
|
37
|
+
*
|
|
38
|
+
* Unpinned is still the right fallback: a wrong-revision download is a bad outcome, but no command
|
|
39
|
+
* at all is a worse one.
|
|
40
|
+
*/
|
|
41
|
+
export function chromiumInstallCommand(playwrightVersion) {
|
|
42
|
+
const pin = playwrightVersion === undefined ? 'playwright' : `playwright@${playwrightVersion}`;
|
|
43
|
+
return `npx ${pin} install chromium`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Split a resolved executable path into the browsers root and the revision under it, or null.
|
|
47
|
+
*
|
|
48
|
+
* Reading them back off Playwright's own answer is the point. The alternative is reconstructing the
|
|
49
|
+
* root from `PLAYWRIGHT_BROWSERS_PATH` plus a per-platform default, which is a copy of Playwright's
|
|
50
|
+
* resolution rules that drifts the first time they change one — and getting it wrong here means
|
|
51
|
+
* telling a user their browsers are missing while looking in the wrong directory, which is the exact
|
|
52
|
+
* failure this file exists to stop.
|
|
53
|
+
*/
|
|
54
|
+
export function parseChromiumRevision(executablePath) {
|
|
55
|
+
const match = CHROMIUM_SEGMENT.exec(executablePath);
|
|
56
|
+
if (null === match)
|
|
57
|
+
return null;
|
|
58
|
+
const revision = match[1];
|
|
59
|
+
if (revision === undefined)
|
|
60
|
+
return null;
|
|
61
|
+
return { revision, root: executablePath.slice(0, match.index) };
|
|
62
|
+
}
|
|
63
|
+
/** Chromium revisions present in a browsers root. Empty on an unreadable or absent root. */
|
|
64
|
+
function installedRevisions(root) {
|
|
65
|
+
try {
|
|
66
|
+
return readdirSync(root)
|
|
67
|
+
.filter((entry) => CHROMIUM_DIR.test(entry))
|
|
68
|
+
.sort();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// No root at all is the commonest case here and is not an error: it means nothing was installed.
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Ask the playwright THIS process would use, not the one `npx` would fetch.
|
|
77
|
+
*
|
|
78
|
+
* `executablePath()` resolves the revision the daemon will actually launch AND the root it will look
|
|
79
|
+
* in, which is why the probe never reads `PLAYWRIGHT_BROWSERS_PATH` itself.
|
|
80
|
+
*/
|
|
81
|
+
export async function probeChromium() {
|
|
82
|
+
try {
|
|
83
|
+
const { chromium } = await import('playwright');
|
|
84
|
+
const executablePath = chromium.executablePath();
|
|
85
|
+
const parsed = parseChromiumRevision(executablePath);
|
|
86
|
+
return {
|
|
87
|
+
executablePath,
|
|
88
|
+
exists: existsSync(executablePath),
|
|
89
|
+
playwrightVersion: bundledPlaywrightVersion(),
|
|
90
|
+
...(null === parsed
|
|
91
|
+
? {}
|
|
92
|
+
: {
|
|
93
|
+
wantedRevision: parsed.revision,
|
|
94
|
+
browsersRoot: parsed.root,
|
|
95
|
+
installedRevisions: installedRevisions(parsed.root),
|
|
96
|
+
}),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Playwright itself is absent — a different problem from a missing browser, and the hint says so.
|
|
101
|
+
return { exists: false };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** The bundled playwright's version, or undefined if it cannot be read (the hint then goes unpinned). */
|
|
105
|
+
export function bundledPlaywrightVersion() {
|
|
106
|
+
try {
|
|
107
|
+
const require = createRequire(import.meta.url);
|
|
108
|
+
const pkg = require('playwright/package.json');
|
|
109
|
+
if ('object' === typeof pkg && null !== pkg && 'version' in pkg) {
|
|
110
|
+
const { version } = pkg;
|
|
111
|
+
if ('string' === typeof version)
|
|
112
|
+
return version;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
/* an unreadable package.json is not worth failing a diagnostic over */
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
/** The doctor line for the Chromium check — verdict, what was probed, and how to satisfy it. */
|
|
121
|
+
export function chromiumHint(probe) {
|
|
122
|
+
if (probe.exists) {
|
|
123
|
+
// Naming the revision on the happy line too: it is the number the mismatch line talks about, and
|
|
124
|
+
// a reader comparing two machines has nothing to compare without it.
|
|
125
|
+
return probe.wantedRevision === undefined
|
|
126
|
+
? '✓ installed'
|
|
127
|
+
: `✓ installed (${probe.wantedRevision})`;
|
|
128
|
+
}
|
|
129
|
+
const command = chromiumInstallCommand(probe.playwrightVersion);
|
|
130
|
+
if (probe.executablePath === undefined) {
|
|
131
|
+
return `✗ the playwright package is not installed; run: ${command}`;
|
|
132
|
+
}
|
|
133
|
+
const present = probe.installedRevisions ?? [];
|
|
134
|
+
if (present.length > 0 && probe.wantedRevision !== undefined) {
|
|
135
|
+
// The reported machine. Every fact here is one the reader could not otherwise get: what is
|
|
136
|
+
// wanted, what is there, and where "there" is. `npx playwright install chromium` unpinned adds
|
|
137
|
+
// a build to this list and changes nothing, which is what happened.
|
|
138
|
+
return (`✗ wrong revision — the bundled playwright wants ${probe.wantedRevision}; ` +
|
|
139
|
+
`${probe.browsersRoot ?? ''} holds ${present.join(', ')}. run: ${command}`);
|
|
140
|
+
}
|
|
141
|
+
// Naming the path is what turns "missing" from a verdict into evidence: a reader with browsers on
|
|
142
|
+
// disk can see immediately whether this is a missing install or a lookup pointed somewhere else
|
|
143
|
+
// (PLAYWRIGHT_BROWSERS_PATH, a different browsers root, a revision bump).
|
|
144
|
+
return `✗ missing — looked for ${probe.executablePath}; run: ${command}`;
|
|
145
|
+
}
|
package/dist/cli/cli-doctor.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { ReticleEnv } from '@reticlehq/core';
|
|
4
4
|
import { readPid, reticleStateHome } from '../daemon/daemon.js';
|
|
@@ -6,36 +6,18 @@ import { PortPresence, probePresence } from '../daemon/port-presence.js';
|
|
|
6
6
|
import { probeDaemon } from '../mcp/mcp-proxy.js';
|
|
7
7
|
import { fetchStatus } from './cli-launch.js';
|
|
8
8
|
import { daemonLine } from './doctor-daemon-line.js';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { sessionsLine } from './doctor-sessions-line.js';
|
|
10
|
+
import { captureLookup, describeForeignHolder, findPortHolder } from './port-holder.js';
|
|
11
|
+
import { chromiumHint, probeChromium } from './chromium-hint.js';
|
|
11
12
|
import { SERVER_VERSION } from '../version/server-version.js';
|
|
12
13
|
import { CONTRACT_FINGERPRINT } from '@reticlehq/core';
|
|
13
14
|
import { diagnoseDesktop, isDesktopProject } from '../init/desktop-doctor.js';
|
|
15
|
+
import { diagnosePortMismatch, readProjectPort } from './cli-port.js';
|
|
14
16
|
/**
|
|
15
17
|
* `reticle doctor` — collapse the ~6 independent first-run failure modes into one command. Checks the
|
|
16
18
|
* Chromium install (the #1 silent failure), whether a daemon is up on the resolved bridge port, and
|
|
17
19
|
* reminds the user which port the app must dial. Human-readable to stdout (not the JSON log).
|
|
18
20
|
*/
|
|
19
|
-
/**
|
|
20
|
-
* Run a lookup and return its stdout, or null if it could not run.
|
|
21
|
-
*
|
|
22
|
-
* Null on ANY failure — no lsof (every Windows user, a slim container), a non-zero exit, a throw.
|
|
23
|
-
* This is a diagnostic nicety inside the command people run when things are already broken; it must
|
|
24
|
-
* never be the reason `doctor` fails. `describeForeignHolder` handles the null by saying it could
|
|
25
|
-
* not identify the holder and naming our recorded pid, rather than asserting the holder is not ours
|
|
26
|
-
* — a claim it has no evidence for, and one that was wrong for every Windows user.
|
|
27
|
-
*/
|
|
28
|
-
function runCapture(command, args) {
|
|
29
|
-
try {
|
|
30
|
-
const result = spawnSync(command, [...args], { encoding: 'utf8', timeout: LOOKUP_TIMEOUT_MS });
|
|
31
|
-
return 0 === result.status && 'string' === typeof result.stdout ? result.stdout : null;
|
|
32
|
-
}
|
|
33
|
-
catch {
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
/** Bound on the holder lookup. A hung `lsof` must not hang the command that diagnoses hangs. */
|
|
38
|
-
const LOOKUP_TIMEOUT_MS = 2_000;
|
|
39
21
|
/** Narrow the `/status` payload to the two fields the daemon line reads. */
|
|
40
22
|
function asIdentity(payload) {
|
|
41
23
|
if (typeof payload !== 'object' || null === payload)
|
|
@@ -60,16 +42,10 @@ export async function handleDoctor(port) {
|
|
|
60
42
|
};
|
|
61
43
|
line('reticle doctor');
|
|
62
44
|
line(` node ${process.version}`);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
? ' chromium ✓ installed'
|
|
68
|
-
: ' chromium ✗ missing — run: npx playwright install chromium');
|
|
69
|
-
}
|
|
70
|
-
catch {
|
|
71
|
-
line(' chromium ✗ missing — run: npx playwright install chromium');
|
|
72
|
-
}
|
|
45
|
+
// Filled in only on the daemon branch — there is nothing to ask when no daemon is answering, and
|
|
46
|
+
// the branches below already say so in their own terms.
|
|
47
|
+
let sessions;
|
|
48
|
+
line(` chromium ${chromiumHint(await probeChromium())}`);
|
|
73
49
|
// Ask the PORT, not just the pid file. "not running on :4400" has been printed about a port that
|
|
74
50
|
// was demonstrably occupied, which sends the reader to start a daemon that cannot bind. The three
|
|
75
51
|
// states are genuinely different problems with different fixes, so doctor names which one it is.
|
|
@@ -79,7 +55,8 @@ export async function handleDoctor(port) {
|
|
|
79
55
|
// Name WHICH daemon. The /status payload already carries version + contract, and doctor was
|
|
80
56
|
// throwing both away — while skew is invisible everywhere else, reaching the agent as a bare
|
|
81
57
|
// -32000 naming no version. This is the command a human runs at exactly that moment.
|
|
82
|
-
const
|
|
58
|
+
const payload = await fetchStatus(port);
|
|
59
|
+
const status = asIdentity(payload);
|
|
83
60
|
const built = daemonLine(port, pid, status, {
|
|
84
61
|
version: SERVER_VERSION,
|
|
85
62
|
contract: CONTRACT_FINGERPRINT,
|
|
@@ -87,6 +64,10 @@ export async function handleDoctor(port) {
|
|
|
87
64
|
line(built.text);
|
|
88
65
|
if (built.skew !== undefined)
|
|
89
66
|
line(` version ✗ ${built.skew}`);
|
|
67
|
+
// The same payload already carries whether anything has CONNECTED, and doctor was reading
|
|
68
|
+
// `version` off it and dropping the rest. Without this the command is silent on the one step the
|
|
69
|
+
// funnel stalls on: wired correctly, daemon up, and no page has ever dialled in.
|
|
70
|
+
sessions = sessionsLine('object' === typeof payload && null !== payload ? payload : {});
|
|
90
71
|
}
|
|
91
72
|
else if (presence === PortPresence.FOREIGN) {
|
|
92
73
|
// Name the holder when we can. `doctor` exists for exactly this moment, and "another process"
|
|
@@ -94,18 +75,32 @@ export async function handleDoctor(port) {
|
|
|
94
75
|
// pipeline that also kills the agent's own MCP proxy.
|
|
95
76
|
// `pid` is what our pid file recorded for this port. When it matches the process actually
|
|
96
77
|
// holding it, this is our OWN daemon wedged, not a stranger — and the fix is different.
|
|
97
|
-
line(` daemon ✗ ${describeForeignHolder(port, findPortHolder(port,
|
|
78
|
+
line(` daemon ✗ ${describeForeignHolder(port, findPortHolder(port, captureLookup), pid)}`);
|
|
98
79
|
}
|
|
99
80
|
else {
|
|
100
81
|
line(` daemon ✗ not running on :${port} — your agent runs \`reticle mcp\` (or \`reticle serve\`)`);
|
|
101
82
|
}
|
|
102
|
-
|
|
83
|
+
if (sessions !== undefined)
|
|
84
|
+
line(sessions.text);
|
|
85
|
+
line(` bridge port ${port} (your app must dial THIS port, not your dev-server port)`);
|
|
86
|
+
const projectPort = readProjectPort(process.cwd());
|
|
87
|
+
const mismatch = diagnosePortMismatch(port, projectPort);
|
|
88
|
+
if (mismatch !== undefined)
|
|
89
|
+
line(` port check ✗ ${mismatch}`);
|
|
103
90
|
// Where to LOOK when something is wrong. The daemon has always written a structured log here and
|
|
104
91
|
// nothing ever said so, so the first move in every investigation was reading source instead of
|
|
105
92
|
// reading the log. `RETICLE_TRACE=1` turns the same stream into a per-stage trace — see
|
|
106
93
|
// docs/debugging.md.
|
|
107
94
|
line(` daemon log ${join(reticleStateHome(), `daemon-${String(port)}.log`)}`);
|
|
108
95
|
line(` tracing ${ReticleEnv.TRACE}=1 on the daemon for per-stage timings in that log`);
|
|
96
|
+
// Below the checklist rather than inline: it is a paragraph, and a paragraph in the middle of a
|
|
97
|
+
// column of one-line checks buries the checks under it. This is the daemon's own no-session
|
|
98
|
+
// diagnosis, the same one an agent gets from an empty reticle_sessions.
|
|
99
|
+
if (sessions?.why !== undefined) {
|
|
100
|
+
line('');
|
|
101
|
+
line(' nothing has connected. What the daemon can tell from here:');
|
|
102
|
+
line(` ${sessions.why}`);
|
|
103
|
+
}
|
|
109
104
|
// Desktop setup RCA. Every one of these fails SILENTLY — a Tauri app with the default CSP runs
|
|
110
105
|
// perfectly and never connects; an Electron app without the preload line reports zero network
|
|
111
106
|
// activity forever, which reads as "makes no backend calls" rather than "you are blind to them".
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `reticle kill` — free the bridge port without taking the agent's transport with it.
|
|
3
|
+
*
|
|
4
|
+
* The obvious incantation, `lsof -ti tcp:4400 | xargs kill -9`, also kills `reticle mcp`: the proxy
|
|
5
|
+
* holds a CLIENT socket on that port, `-ti` does not filter to listeners, and so the one command
|
|
6
|
+
* people reach for when the bridge is stuck destroys the channel they were trying to fix. What
|
|
7
|
+
* follows is worse than an error — the client gets no reply at all, and nothing reaches the proxy log
|
|
8
|
+
* because the process that writes it is the one that died. That is the mechanism behind most
|
|
9
|
+
* "my MCP disconnected" reports (#114, #110).
|
|
10
|
+
*
|
|
11
|
+
* The safe form was written down, and a documented incantation is a worse answer than a command: it
|
|
12
|
+
* has to be found, remembered, and typed correctly at the moment someone is already stuck. So this is
|
|
13
|
+
* the command, and it signals exactly one pid — the LISTENER.
|
|
14
|
+
*
|
|
15
|
+
* It is not a rename of `stop`. `stop` reads the pid file, which is the right source when the daemon
|
|
16
|
+
* we started is the daemon that is wedged, and no source at all when the pid file is stale, was never
|
|
17
|
+
* written, or belongs to a daemon from another checkout. `kill` asks the PORT, which is the thing the
|
|
18
|
+
* user actually wants freed, and falls back to the pid file only where the lookup cannot run.
|
|
19
|
+
*/
|
|
20
|
+
import { type PortHolder } from './port-holder.js';
|
|
21
|
+
/** What the plan says to do with the port. */
|
|
22
|
+
export declare const KillAction: {
|
|
23
|
+
readonly NOTHING: "nothing";
|
|
24
|
+
readonly KILL: "kill";
|
|
25
|
+
readonly REFUSE: "refuse";
|
|
26
|
+
};
|
|
27
|
+
export type KillAction = (typeof KillAction)[keyof typeof KillAction];
|
|
28
|
+
export interface KillPlan {
|
|
29
|
+
action: KillAction;
|
|
30
|
+
/** The single pid to signal. Present only for `kill`. */
|
|
31
|
+
pid?: number;
|
|
32
|
+
/** True when the target was not identifiable as Reticle's and `--force` overrode the refusal. */
|
|
33
|
+
forced: boolean;
|
|
34
|
+
/** False when the listener lookup could not run, so the pid came from our own records instead. */
|
|
35
|
+
identifiedListener: boolean;
|
|
36
|
+
/** Why we will not act, for `refuse`. */
|
|
37
|
+
reason?: string;
|
|
38
|
+
holder?: PortHolder;
|
|
39
|
+
}
|
|
40
|
+
export declare const FORCE_FLAG = "--force";
|
|
41
|
+
/**
|
|
42
|
+
* Decide what to signal. Pure.
|
|
43
|
+
*
|
|
44
|
+
* `answersStatus` is the strongest evidence available and deliberately outranks the pid file: a
|
|
45
|
+
* daemon started from another checkout owns the port, is a Reticle daemon, and appears nowhere in our
|
|
46
|
+
* records. Refusing it would leave the port held by exactly the process this command exists to clear.
|
|
47
|
+
*/
|
|
48
|
+
export declare function planKill(input: {
|
|
49
|
+
/** The listener on the port, from a `-sTCP:LISTEN` lookup. Null when there is none, or no `lsof`. */
|
|
50
|
+
listener: PortHolder | null;
|
|
51
|
+
/** The pid our records hold for this port, or null when there is none or it is dead. */
|
|
52
|
+
recordedPid: number | null;
|
|
53
|
+
/** Whether the port answers `/status`, i.e. whatever holds it is a Reticle daemon. */
|
|
54
|
+
answersStatus: boolean;
|
|
55
|
+
force: boolean;
|
|
56
|
+
}): KillPlan;
|
|
57
|
+
/** `reticle kill` — free the port. Resolves to whether the port is now free. */
|
|
58
|
+
export declare function runKill(port: number, force: boolean): Promise<boolean>;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `reticle kill` — free the bridge port without taking the agent's transport with it.
|
|
3
|
+
*
|
|
4
|
+
* The obvious incantation, `lsof -ti tcp:4400 | xargs kill -9`, also kills `reticle mcp`: the proxy
|
|
5
|
+
* holds a CLIENT socket on that port, `-ti` does not filter to listeners, and so the one command
|
|
6
|
+
* people reach for when the bridge is stuck destroys the channel they were trying to fix. What
|
|
7
|
+
* follows is worse than an error — the client gets no reply at all, and nothing reaches the proxy log
|
|
8
|
+
* because the process that writes it is the one that died. That is the mechanism behind most
|
|
9
|
+
* "my MCP disconnected" reports (#114, #110).
|
|
10
|
+
*
|
|
11
|
+
* The safe form was written down, and a documented incantation is a worse answer than a command: it
|
|
12
|
+
* has to be found, remembered, and typed correctly at the moment someone is already stuck. So this is
|
|
13
|
+
* the command, and it signals exactly one pid — the LISTENER.
|
|
14
|
+
*
|
|
15
|
+
* It is not a rename of `stop`. `stop` reads the pid file, which is the right source when the daemon
|
|
16
|
+
* we started is the daemon that is wedged, and no source at all when the pid file is stale, was never
|
|
17
|
+
* written, or belongs to a daemon from another checkout. `kill` asks the PORT, which is the thing the
|
|
18
|
+
* user actually wants freed, and falls back to the pid file only where the lookup cannot run.
|
|
19
|
+
*/
|
|
20
|
+
import { PortPresence, probePresence } from '../daemon/port-presence.js';
|
|
21
|
+
import { isAlive, readPid, removePid } from '../daemon/daemon.js';
|
|
22
|
+
import { probeDaemon } from '../mcp/mcp-proxy.js';
|
|
23
|
+
import { log } from '../log.js';
|
|
24
|
+
import { fetchStatus } from './cli-launch.js';
|
|
25
|
+
import { captureLookup, findPortHolder } from './port-holder.js';
|
|
26
|
+
/** What the plan says to do with the port. */
|
|
27
|
+
export const KillAction = {
|
|
28
|
+
NOTHING: 'nothing',
|
|
29
|
+
KILL: 'kill',
|
|
30
|
+
REFUSE: 'refuse',
|
|
31
|
+
};
|
|
32
|
+
export const FORCE_FLAG = '--force';
|
|
33
|
+
/**
|
|
34
|
+
* Decide what to signal. Pure.
|
|
35
|
+
*
|
|
36
|
+
* `answersStatus` is the strongest evidence available and deliberately outranks the pid file: a
|
|
37
|
+
* daemon started from another checkout owns the port, is a Reticle daemon, and appears nowhere in our
|
|
38
|
+
* records. Refusing it would leave the port held by exactly the process this command exists to clear.
|
|
39
|
+
*/
|
|
40
|
+
export function planKill(input) {
|
|
41
|
+
const { listener, recordedPid, answersStatus, force } = input;
|
|
42
|
+
if (null === listener) {
|
|
43
|
+
// No listener identified. On Windows and in slim containers that means the lookup could not run
|
|
44
|
+
// rather than that the port is free, so the recorded pid is the best target we have — and the
|
|
45
|
+
// report says which of the two it is rather than implying a lookup we never made.
|
|
46
|
+
if (null === recordedPid)
|
|
47
|
+
return { action: KillAction.NOTHING, forced: false, identifiedListener: false };
|
|
48
|
+
return { action: KillAction.KILL, pid: recordedPid, forced: false, identifiedListener: false };
|
|
49
|
+
}
|
|
50
|
+
const ours = answersStatus || listener.pid === recordedPid;
|
|
51
|
+
if (ours || force) {
|
|
52
|
+
return {
|
|
53
|
+
action: KillAction.KILL,
|
|
54
|
+
pid: listener.pid,
|
|
55
|
+
forced: !ours,
|
|
56
|
+
identifiedListener: true,
|
|
57
|
+
holder: listener,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
action: KillAction.REFUSE,
|
|
62
|
+
forced: false,
|
|
63
|
+
identifiedListener: true,
|
|
64
|
+
holder: listener,
|
|
65
|
+
reason: `pid ${String(listener.pid)} ("${listener.command}") is listening, and it is not a Reticle ` +
|
|
66
|
+
`daemon — it never answered /status and it is not the pid Reticle recorded. Killing it would ` +
|
|
67
|
+
`be killing someone else's process. Run it again with ${FORCE_FLAG} if that is what you ` +
|
|
68
|
+
`meant, or start Reticle on another port.`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** How long the target gets to exit on SIGTERM before we stop asking politely. */
|
|
72
|
+
const GRACEFUL_KILL_MS = 5_000;
|
|
73
|
+
/** How long after SIGKILL before we accept the process is not ours to kill and say so. */
|
|
74
|
+
const FORCED_KILL_MS = 2_000;
|
|
75
|
+
const KILL_POLL_MS = 100;
|
|
76
|
+
/**
|
|
77
|
+
* Signal ONE pid and wait for it to go: SIGTERM, then SIGKILL, then give up and say it survived.
|
|
78
|
+
*
|
|
79
|
+
* The escalation is the reason this exists rather than a bare SIGTERM — there is a field report of a
|
|
80
|
+
* daemon that ignored SIGTERM until a human ran `kill -9` by hand, which is the moment the `lsof -ti`
|
|
81
|
+
* pipeline gets typed and the agent's proxy dies with the daemon.
|
|
82
|
+
*/
|
|
83
|
+
async function terminate(pid) {
|
|
84
|
+
const signal = (name) => {
|
|
85
|
+
try {
|
|
86
|
+
process.kill(pid, name);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Gone between the liveness check and here. The next poll reports it as gone.
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
signal('SIGTERM');
|
|
93
|
+
const started = Date.now();
|
|
94
|
+
let escalated = false;
|
|
95
|
+
for (;;) {
|
|
96
|
+
await new Promise((resolve) => setTimeout(resolve, KILL_POLL_MS));
|
|
97
|
+
if (!isAlive(pid))
|
|
98
|
+
return { gone: true, escalated };
|
|
99
|
+
const waited = Date.now() - started;
|
|
100
|
+
if (!escalated && waited > GRACEFUL_KILL_MS) {
|
|
101
|
+
escalated = true;
|
|
102
|
+
signal('SIGKILL');
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (waited > GRACEFUL_KILL_MS + FORCED_KILL_MS)
|
|
106
|
+
return { gone: false, escalated };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* What was NOT touched, said out loud on every kill.
|
|
111
|
+
*
|
|
112
|
+
* The command exists because of a pipeline that killed more than the reader intended and never told
|
|
113
|
+
* them, so a report that lists only the casualty repeats half the mistake. An `reticle mcp` proxy on
|
|
114
|
+
* this port survives, and its own recovery from a dead daemon is good: it goes dormant and restarts
|
|
115
|
+
* one on the next request.
|
|
116
|
+
*/
|
|
117
|
+
const PROXY_SPARED_NOTE = 'only the listener was signalled. Any `reticle mcp` proxy on this port holds a client connection, ' +
|
|
118
|
+
'not the port, and was left alone — it goes dormant and starts a fresh daemon on the next tool call.';
|
|
119
|
+
/** `reticle kill` — free the port. Resolves to whether the port is now free. */
|
|
120
|
+
export async function runKill(port, force) {
|
|
121
|
+
const presence = await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus });
|
|
122
|
+
const recorded = readPid(port);
|
|
123
|
+
const plan = planKill({
|
|
124
|
+
listener: findPortHolder(port, captureLookup),
|
|
125
|
+
recordedPid: null !== recorded && isAlive(recorded) ? recorded : null,
|
|
126
|
+
answersStatus: presence === PortPresence.DAEMON,
|
|
127
|
+
force,
|
|
128
|
+
});
|
|
129
|
+
if (KillAction.NOTHING === plan.action) {
|
|
130
|
+
removePid(port);
|
|
131
|
+
log('reticle_kill_nothing_to_do', { port, presence });
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
if (KillAction.REFUSE === plan.action) {
|
|
135
|
+
log('reticle_kill_refused', { port, reason: plan.reason, holder: plan.holder?.command });
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
const pid = plan.pid;
|
|
139
|
+
if (pid === undefined) {
|
|
140
|
+
// Unreachable by construction (planKill never returns KILL without a pid, and a test pins that),
|
|
141
|
+
// but the alternative to this branch is a non-null assertion, which this codebase does not use.
|
|
142
|
+
log('reticle_kill_nothing_to_do', { port, presence });
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
const { gone, escalated } = await terminate(pid);
|
|
146
|
+
if (gone)
|
|
147
|
+
removePid(port);
|
|
148
|
+
log(gone ? 'reticle_killed' : 'reticle_kill_survived', {
|
|
149
|
+
port,
|
|
150
|
+
pid,
|
|
151
|
+
escalated,
|
|
152
|
+
...(plan.forced ? { forced: true } : {}),
|
|
153
|
+
// Say where the pid came from. Without `lsof` this is the pid we recorded, not the pid we
|
|
154
|
+
// observed listening, and those are different claims.
|
|
155
|
+
listenerIdentified: plan.identifiedListener,
|
|
156
|
+
note: gone
|
|
157
|
+
? PROXY_SPARED_NOTE
|
|
158
|
+
: `pid ${String(pid)} survived SIGKILL, so it is not yours to kill (permissions) or is an ` +
|
|
159
|
+
`unkillable zombie. The port is still held. ${PROXY_SPARED_NOTE}`,
|
|
160
|
+
});
|
|
161
|
+
return gone;
|
|
162
|
+
}
|