@reticlehq/server 2.10.0 → 2.12.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 +73 -11
- package/dist/bridge/bridge.d.ts +0 -13
- package/dist/bridge/bridge.js +1 -1
- package/dist/bridge/bridge.test-harness.d.ts +2 -0
- package/dist/bridge/bridge.test-harness.js +8 -1
- package/dist/capsule/capsule-store.d.ts +2 -1
- package/dist/capsule/capsule-store.js +1 -1
- package/dist/capsule/causal-summary.d.ts +2 -2
- package/dist/chromium-launch-options.d.ts +28 -0
- package/dist/chromium-launch-options.js +29 -0
- package/dist/cli/cli-doctor.js +8 -0
- package/dist/cli/cli-flow-commands.d.ts +3 -1
- package/dist/cli/cli-flow-commands.js +34 -4
- package/dist/cli/cli-kill.d.ts +2 -1
- package/dist/cli/cli-parse.d.ts +3 -1
- package/dist/cli/cli-parse.js +29 -7
- package/dist/cli/cli-update-commands.js +20 -1
- package/dist/cli/cli-verify.d.ts +1 -1
- package/dist/cli/cli-verify.js +2 -2
- package/dist/cli/cloud-cli.js +12 -6
- package/dist/cli/config-discovery.d.ts +2 -1
- package/dist/cli/doctor-rows.d.ts +3 -1
- package/dist/cli/doctor-rows.js +3 -1
- package/dist/cli/gate-exit.d.ts +21 -0
- package/dist/cli/gate-exit.js +21 -0
- package/dist/cli/gate-hook-message.d.ts +33 -0
- package/dist/cli/gate-hook-message.js +46 -0
- package/dist/cli/mcp-command.d.ts +25 -0
- package/dist/cli/mcp-command.js +143 -0
- package/dist/cli/sibling-ports.d.ts +37 -0
- package/dist/cli/sibling-ports.js +56 -0
- package/dist/cli.js +37 -110
- package/dist/cloud/cloud-config.d.ts +2 -1
- package/dist/cloud/cloud-sync.d.ts +11 -0
- package/dist/cloud/cloud-sync.js +32 -0
- package/dist/crawl/crawl.d.ts +9 -13
- package/dist/crawl/crawl.js +20 -3
- package/dist/daemon/daemon-resolve.d.ts +79 -0
- package/dist/daemon/daemon-resolve.js +118 -0
- package/dist/daemon/free-port.d.ts +29 -0
- package/dist/daemon/free-port.js +55 -0
- package/dist/daemon/heartbeat.d.ts +0 -4
- package/dist/daemon/heartbeat.js +2 -2
- package/dist/dev/stale-issue-guard.d.ts +41 -0
- package/dist/dev/stale-issue-guard.js +75 -0
- package/dist/domain/domain-tools.js +3 -2
- package/dist/events/contradictions.d.ts +41 -0
- package/dist/events/contradictions.js +125 -12
- package/dist/events/declared.d.ts +14 -0
- package/dist/events/declared.js +33 -0
- package/dist/events/echo-mismatch.js +14 -0
- package/dist/events/observed-in-window.d.ts +12 -0
- package/dist/events/observed-in-window.js +17 -0
- package/dist/events/predicate-asks.d.ts +30 -0
- package/dist/events/predicate-asks.js +48 -0
- package/dist/events/predicate-eval.js +254 -25
- package/dist/events/predicate-parse.d.ts +0 -6
- package/dist/events/predicate-parse.js +51 -5
- package/dist/events/predicate-schema.d.ts +62 -0
- package/dist/events/predicate-schema.js +162 -4
- package/dist/events/predicate.d.ts +15 -0
- package/dist/events/predicate.js +41 -2
- package/dist/flows/coverage.d.ts +0 -8
- package/dist/flows/coverage.js +1 -1
- package/dist/flows/decision.d.ts +5 -1
- package/dist/flows/decision.js +33 -7
- package/dist/flows/flake.d.ts +0 -2
- package/dist/flows/flake.js +1 -1
- package/dist/flows/flow-intent.d.ts +52 -0
- package/dist/flows/flow-intent.js +123 -0
- package/dist/flows/flow-replay-run.js +27 -4
- package/dist/flows/flow-success.js +15 -3
- package/dist/flows/flow-tools.js +65 -8
- package/dist/flows/flows.d.ts +6 -1
- package/dist/flows/flows.js +34 -22
- package/dist/flows/predicate-to-expect.js +14 -3
- package/dist/flows/server-verify.js +16 -2
- package/dist/flows/verify-change-tools.js +9 -2
- package/dist/honesty/blind-spots.d.ts +8 -16
- package/dist/honesty/blind-spots.js +18 -5
- package/dist/honesty/feature-capture.d.ts +150 -0
- package/dist/honesty/feature-capture.js +233 -0
- package/dist/honesty/gap-ledger.d.ts +52 -0
- package/dist/honesty/gap-ledger.js +63 -0
- package/dist/honesty/instrumentation-gaps.d.ts +79 -0
- package/dist/honesty/instrumentation-gaps.js +77 -0
- package/dist/honesty/observability.d.ts +37 -0
- package/dist/honesty/observability.js +40 -0
- package/dist/honesty/tool-hit-rate.d.ts +86 -0
- package/dist/honesty/tool-hit-rate.js +90 -0
- package/dist/honesty/undeclared-change.d.ts +51 -0
- package/dist/honesty/undeclared-change.js +56 -0
- package/dist/honesty/verified.d.ts +15 -0
- package/dist/honesty/verified.js +68 -4
- package/dist/http-server.d.ts +0 -1
- package/dist/http-server.js +1 -1
- package/dist/hunt/hunt-report.d.ts +0 -2
- package/dist/hunt/hunt-report.js +1 -1
- package/dist/impact/impact-store.d.ts +0 -5
- package/dist/impact/impact-store.js +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +41 -0
- package/dist/init/agent-rules.d.ts +17 -0
- package/dist/init/agent-rules.js +31 -1
- package/dist/init/capabilities.d.ts +0 -2
- package/dist/init/capabilities.js +1 -1
- package/dist/init/closing-hint.d.ts +36 -0
- package/dist/init/closing-hint.js +120 -0
- package/dist/init/confirm.d.ts +0 -25
- package/dist/init/confirm.js +3 -3
- package/dist/init/cra.d.ts +14 -1
- package/dist/init/cra.js +43 -12
- package/dist/init/dev-script.d.ts +48 -0
- package/dist/init/dev-script.js +63 -0
- package/dist/init/mcp.d.ts +16 -5
- package/dist/init/mcp.js +26 -10
- package/dist/init/node-io.js +3 -0
- package/dist/init/plan-framework.d.ts +0 -5
- package/dist/init/plan-framework.js +83 -9
- package/dist/init/plan.d.ts +10 -30
- package/dist/init/plan.js +30 -15
- package/dist/init/refresh-rules.d.ts +39 -0
- package/dist/init/refresh-rules.js +51 -0
- package/dist/init/run.d.ts +2 -10
- package/dist/init/run.js +26 -68
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/slash-command.js +33 -10
- package/dist/init/snippets.js +53 -10
- package/dist/input/real-input.d.ts +2 -0
- package/dist/input/real-input.js +3 -2
- package/dist/input/scroll-find.d.ts +7 -4
- package/dist/input/scroll-find.js +63 -25
- package/dist/intent/inline-intent.d.ts +46 -0
- package/dist/intent/inline-intent.js +103 -0
- package/dist/intent/intent-store.d.ts +37 -0
- package/dist/intent/intent-store.js +112 -0
- package/dist/intent/intent-tools.d.ts +2 -0
- package/dist/intent/intent-tools.js +80 -0
- package/dist/intent/open-intents.d.ts +21 -0
- package/dist/intent/open-intents.js +25 -0
- package/dist/journal/envelope.d.ts +2 -2
- package/dist/journal/journal-recorder.d.ts +23 -0
- package/dist/journal/journal-recorder.js +35 -0
- package/dist/license/license.d.ts +8 -2
- package/dist/license/license.js +10 -15
- package/dist/mcp/attach-memory.d.ts +67 -0
- package/dist/mcp/attach-memory.js +142 -0
- package/dist/mcp/mcp-proxy.d.ts +0 -7
- package/dist/mcp/mcp-proxy.js +14 -2
- package/dist/mcp/mcp.d.ts +2 -0
- package/dist/mcp/mcp.js +45 -23
- package/dist/mcp/server-instructions.js +45 -3
- package/dist/mcp/tool-catalog-cache.d.ts +8 -0
- package/dist/mcp/tool-catalog-cache.js +10 -0
- package/dist/oracles/flow-instrument-gaps.d.ts +4 -4
- package/dist/oracles/flow-instrument-gaps.js +4 -5
- package/dist/oracles/self-instrument.d.ts +9 -9
- package/dist/oracles/self-instrument.js +19 -13
- package/dist/pool/browser-pool.d.ts +12 -1
- package/dist/pool/browser-pool.js +31 -1
- package/dist/pool/playwright-launcher.js +3 -1
- package/dist/project/artifact-root.d.ts +83 -0
- package/dist/project/artifact-root.js +112 -0
- package/dist/project/fs-port.d.ts +3 -0
- package/dist/project/fs-port.js +3 -2
- package/dist/project/memory-fs.d.ts +9 -0
- package/dist/project/memory-fs.js +93 -0
- package/dist/project/project-store.d.ts +17 -0
- package/dist/project/project-store.js +33 -0
- package/dist/project/project-tools.js +3 -32
- package/dist/project/remember-project.d.ts +24 -0
- package/dist/project/remember-project.js +40 -0
- package/dist/project/reticle-dir.d.ts +2 -0
- package/dist/project/reticle-dir.js +1 -0
- package/dist/project/session-root.d.ts +15 -0
- package/dist/project/session-root.js +31 -0
- package/dist/runs/context-tools.d.ts +2 -0
- package/dist/runs/context-tools.js +89 -0
- package/dist/runs/run-context.d.ts +40 -0
- package/dist/runs/run-context.js +158 -0
- package/dist/runs/run-diff.d.ts +2 -1
- package/dist/runs/run-tools.js +2 -1
- package/dist/runs/verification-sync.js +2 -2
- package/dist/session/dev-server-probe.d.ts +0 -8
- package/dist/session/dev-server-probe.js +1 -1
- package/dist/session/last-act.d.ts +7 -1
- package/dist/session/last-act.js +2 -2
- package/dist/session/lease-availability.d.ts +40 -0
- package/dist/session/lease-availability.js +43 -0
- package/dist/session/live-control-tools.js +1 -42
- package/dist/session/no-session-diagnosis.d.ts +20 -0
- package/dist/session/no-session-diagnosis.js +41 -12
- package/dist/session/no-session-next-action.d.ts +5 -0
- package/dist/session/no-session-next-action.js +35 -3
- package/dist/session/no-session-watch.d.ts +8 -0
- package/dist/session/no-session-watch.js +83 -65
- package/dist/session/output-budget.js +4 -1
- package/dist/session/session-health.d.ts +9 -0
- package/dist/session/session-health.js +23 -0
- package/dist/session/session-manager.js +50 -1
- package/dist/session/session-successor.d.ts +37 -0
- package/dist/session/session-successor.js +78 -0
- package/dist/session/session.d.ts +40 -1
- package/dist/session/session.js +84 -2
- package/dist/session/stall-clock.d.ts +18 -0
- package/dist/session/stall-clock.js +48 -0
- package/dist/telemetry/automation-hint.d.ts +2 -1
- package/dist/telemetry/daemon-telemetry.js +12 -23
- package/dist/telemetry/feedback-outbox.d.ts +2 -1
- package/dist/telemetry/project-profile.d.ts +0 -8
- package/dist/telemetry/project-profile.js +1 -1
- package/dist/telemetry/run-telemetry.d.ts +0 -2
- package/dist/telemetry/run-telemetry.js +1 -1
- package/dist/telemetry/session-metrics.d.ts +1 -1
- package/dist/telemetry/session-metrics.js +1 -1
- package/dist/telemetry/telemetry.d.ts +12 -3
- package/dist/telemetry/telemetry.js +25 -5
- package/dist/telemetry/verification-of.js +2 -2
- package/dist/tools/act-tools.js +159 -24
- package/dist/tools/assert-source.d.ts +22 -0
- package/dist/tools/assert-source.js +53 -0
- package/dist/tools/assert-verdict.d.ts +21 -2
- package/dist/tools/assert-verdict.js +59 -4
- package/dist/tools/browser-tools.js +0 -20
- package/dist/tools/complete-snapshot.d.ts +48 -0
- package/dist/tools/complete-snapshot.js +75 -0
- package/dist/tools/contract-tools.js +5 -3
- package/dist/tools/coverage-tools.d.ts +10 -1
- package/dist/tools/coverage-tools.js +124 -37
- package/dist/tools/dynamic-tools.js +29 -8
- package/dist/tools/error-recovery.d.ts +24 -14
- package/dist/tools/error-recovery.js +70 -9
- package/dist/tools/feedback-tools.d.ts +1 -1
- package/dist/tools/feedback-tools.js +2 -2
- package/dist/tools/invoke-tool.js +62 -2
- package/dist/tools/lease-hint.d.ts +10 -0
- package/dist/tools/lease-hint.js +53 -4
- package/dist/tools/lease-tools.d.ts +5 -1
- package/dist/tools/lease-tools.js +22 -8
- package/dist/tools/navigate-arrival.d.ts +0 -2
- package/dist/tools/navigate-arrival.js +1 -1
- package/dist/tools/observe-tools.js +70 -27
- package/dist/tools/read-tools.js +5 -3
- package/dist/tools/real-input-attempt.d.ts +22 -0
- package/dist/tools/real-input-attempt.js +153 -1
- package/dist/tools/reconcile-tools.js +18 -4
- package/dist/tools/resolve-target.d.ts +0 -7
- package/dist/tools/sdk-marker-probe.d.ts +29 -0
- package/dist/tools/sdk-marker-probe.js +57 -0
- package/dist/tools/snapshot-delta.d.ts +1 -1
- package/dist/tools/tool-kit.d.ts +28 -1
- package/dist/tools/tool-kit.js +16 -0
- package/dist/tools/tool-names.d.ts +3 -0
- package/dist/tools/tool-names.js +3 -0
- package/dist/tools/tool-surface.d.ts +85 -6
- package/dist/tools/tool-surface.js +122 -2
- package/dist/tools/tools.js +22 -4
- package/dist/tools/unadvertised-help.js +4 -4
- package/dist/tools/verify-next-baton.d.ts +53 -0
- package/dist/tools/verify-next-baton.js +52 -0
- package/dist/update/update-nudge.d.ts +7 -10
- package/dist/update/update-nudge.js +12 -3
- package/dist/update/updater.d.ts +1 -1
- package/docs/README.md +3 -1
- package/docs/cli/cloud.mdx +1 -1
- package/docs/cli/doctor.mdx +1 -0
- package/docs/cli/verify.mdx +4 -5
- package/docs/desktop.mdx +1 -1
- package/docs/docs.json +3 -3
- package/docs/enterprise.md +3 -1
- package/docs/gates.md +1 -1
- package/docs/install-manual.mdx +1 -1
- package/docs/license-activation.mdx +145 -0
- package/docs/packages/browser.mdx +2 -2
- package/docs/packages/server.mdx +1 -1
- package/docs/packages.mdx +8 -6
- package/docs/platform-integration.md +17 -0
- package/docs/predicates.mdx +8 -0
- package/docs/reticle-manual.md +106 -0
- package/docs/telemetry-contract.md +1 -12
- package/docs/telemetry-events.mdx +1 -34
- package/docs/telemetry.md +0 -1
- package/docs/tools-tools-and-run.mdx +2 -4
- package/docs/troubleshooting.mdx +9 -0
- package/docs/usage.md +3 -2
- package/package.json +2 -2
- package/dist/session/session-readiness.d.ts +0 -34
- package/dist/session/session-readiness.js +0 -36
- package/dist/telemetry/instrumentation-stall.d.ts +0 -57
- package/dist/telemetry/instrumentation-stall.js +0 -103
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,10 @@ import { join } from 'node:path';
|
|
|
6
6
|
import { stateDirProblem } from './daemon/state-dir.js';
|
|
7
7
|
import { statusNextAction } from './cli/status-next-action.js';
|
|
8
8
|
import { hasConnectedBefore } from './session/connection-memory.js';
|
|
9
|
+
import { attachStatusFields } from './mcp/attach-memory.js';
|
|
9
10
|
import { reticleStateHome } from './daemon/daemon.js';
|
|
11
|
+
import { handleMcp } from './cli/mcp-command.js';
|
|
12
|
+
import { resolveDaemonForProject } from './daemon/daemon-resolve.js';
|
|
10
13
|
import { handleWatch, handleCapsules, handleGate, loadNamedFlows, resolveChangedFiles, } from './cli/cli-flow-commands.js';
|
|
11
14
|
import { RETICLE_DEFAULT_PORT, ReticleDir, ReticleEnv } from '@reticlehq/core';
|
|
12
15
|
import { loadDotEnv } from './telemetry/dev-repo.js';
|
|
@@ -20,8 +23,8 @@ import { SERVER_VERSION } from './version/server-version.js';
|
|
|
20
23
|
import { log } from './log.js';
|
|
21
24
|
import { readPid, isAlive, removePid, spawnDaemon, discoverDaemonPort, writeDaemonRegistry, logPath, } from './daemon/daemon.js';
|
|
22
25
|
import { PortPresence, probePresence, presenceIsUsable, describePresence, } from './daemon/port-presence.js';
|
|
23
|
-
import { waitForDaemon,
|
|
24
|
-
import { installDaemonResilience,
|
|
26
|
+
import { waitForDaemon, probeDaemon } from './mcp/mcp-proxy.js';
|
|
27
|
+
import { installDaemonResilience, recordExitReason, DaemonExitReason, } from './daemon/daemon-resilience.js';
|
|
25
28
|
import { IdleShutdown, resolveIdleShutdownMs, resolveIdleCheckMs } from './daemon/idle-shutdown.js';
|
|
26
29
|
import { DaemonHeartbeat, resolveHeartbeatMs } from './daemon/heartbeat.js';
|
|
27
30
|
import { everServedToolCall } from './daemon/daemon-usefulness.js';
|
|
@@ -238,6 +241,10 @@ function handleStatus(port) {
|
|
|
238
241
|
// path does the first without the second — so this is the commonest reason `status` has nothing to
|
|
239
242
|
// report, and it was not among the facts this command could state.
|
|
240
243
|
const initialized = projectId !== undefined;
|
|
244
|
+
// The OTHER half of the install, and the half this command could not see. A host that registers
|
|
245
|
+
// Reticle and never lists its tools looks exactly like an abandoned install from here — same idle
|
|
246
|
+
// daemon, same zero sessions — and the user sees a tool that does nothing. See attach-memory.ts.
|
|
247
|
+
const client = attachStatusFields(reticleStateHome(), port);
|
|
241
248
|
if (null === pid || !isAlive(pid)) {
|
|
242
249
|
// `running: false` on its own has been reported about a port that was demonstrably occupied,
|
|
243
250
|
// because the pid file is not the port. Ask the port before answering.
|
|
@@ -252,6 +259,7 @@ function handleStatus(port) {
|
|
|
252
259
|
// `running: false` and nothing else, which reads as "Reticle is broken" for what is usually
|
|
253
260
|
// just a daemon that has not been asked to do anything yet.
|
|
254
261
|
...withNextAction({ running, sessionCount: 0, previouslyConnected, initialized }),
|
|
262
|
+
...client,
|
|
255
263
|
});
|
|
256
264
|
});
|
|
257
265
|
return;
|
|
@@ -270,6 +278,7 @@ function handleStatus(port) {
|
|
|
270
278
|
pid,
|
|
271
279
|
...nudge,
|
|
272
280
|
...withNextAction({ running: true, sessionCount: 0, previouslyConnected, initialized }),
|
|
281
|
+
...client,
|
|
273
282
|
});
|
|
274
283
|
return;
|
|
275
284
|
}
|
|
@@ -280,7 +289,7 @@ function handleStatus(port) {
|
|
|
280
289
|
const next = summary.why === undefined
|
|
281
290
|
? withNextAction({ running: true, ...summary, previouslyConnected, initialized })
|
|
282
291
|
: {};
|
|
283
|
-
log('reticle_status', { port, running: true, pid, ...summary, ...next, ...nudge });
|
|
292
|
+
log('reticle_status', { port, running: true, pid, ...summary, ...next, ...client, ...nudge });
|
|
284
293
|
});
|
|
285
294
|
}
|
|
286
295
|
/** Print the running package version (resolved once in server-version.ts). */
|
|
@@ -398,8 +407,17 @@ async function waitForNewSession(port, before) {
|
|
|
398
407
|
return false;
|
|
399
408
|
}
|
|
400
409
|
function handleOpen(requestedPort, url) {
|
|
410
|
+
// Our project's daemon first: `discoverDaemonPort` returns the LOWEST live daemon on the machine,
|
|
411
|
+
// whoever it belongs to, so on a machine running two projects `reticle open` could drive the other
|
|
412
|
+
// one's browser. It stays as the last resort for a caller with no project id, where any daemon is
|
|
413
|
+
// better than none and there is no identity to confuse.
|
|
414
|
+
const myProject = readProjectId(process.cwd());
|
|
401
415
|
probeDaemon(requestedPort)
|
|
402
|
-
.then((here) =>
|
|
416
|
+
.then((here) => here
|
|
417
|
+
? requestedPort
|
|
418
|
+
: (resolveDaemonForProject(myProject, reticleStateHome(), isAlive) ??
|
|
419
|
+
discoverDaemonPort() ??
|
|
420
|
+
requestedPort))
|
|
403
421
|
.then(async (port) => {
|
|
404
422
|
await ensureDaemon(port);
|
|
405
423
|
const { sessions } = summarizeStatus(await fetchStatus(port));
|
|
@@ -585,109 +603,6 @@ function handleDaemonInner(parsed) {
|
|
|
585
603
|
process.exit(1);
|
|
586
604
|
});
|
|
587
605
|
}
|
|
588
|
-
/**
|
|
589
|
-
* MCP proxy mode: ensures the daemon is running, then bridges Claude Code's
|
|
590
|
-
* stdin/stdout to the daemon's SSE endpoint. This is the recommended way to
|
|
591
|
-
* configure Reticle in.mcp.json — users never need to manage the daemon manually.
|
|
592
|
-
*
|
|
593
|
-
* Pass --drive <url> to have the daemon launch its own Playwright browser at that
|
|
594
|
-
* URL. The agent then has full autonomous control without relying on the user's browser.
|
|
595
|
-
*/
|
|
596
|
-
function handleMcp(opts) {
|
|
597
|
-
const { port, driveUrl, headless, http, httpPort, httpToken } = opts;
|
|
598
|
-
// The proxy IS the MCP server the editor launched. Nothing respawns it, so an uncaught throw here
|
|
599
|
-
// is what a user experiences as "the MCP server disconnected — open /mcp and reconnect". Log it,
|
|
600
|
-
// report it, keep serving: the reconnect and dormant paths already know how to rebuild the only
|
|
601
|
-
// state this process has. See installProxyResilience for why its rule inverts the daemon's.
|
|
602
|
-
// The proxy's crash handlers must write to the proxy's LOG FILE, not just stderr. The editor
|
|
603
|
-
// swallows stderr, so wiring these to `log` meant a crash was handled and then unrecorded — the
|
|
604
|
-
// failure a user reports as "it disconnected" left nothing behind to read.
|
|
605
|
-
setProxyLogPort(port);
|
|
606
|
-
installProxyResilience(process, proxyLog);
|
|
607
|
-
/**
|
|
608
|
-
* Make sure a daemon is on the port, spawning one if not.
|
|
609
|
-
*
|
|
610
|
-
* Called on first start AND on every reconnect. The proxy used to only retry the socket, so a
|
|
611
|
-
* daemon that exited — crashed, `reticle stop`, or self-shut-down as idle — meant the retries hit
|
|
612
|
-
* a dead port until the budget ran out and the agent's MCP server exited with it. Reticle simply
|
|
613
|
-
* disappeared mid-session with nothing said. Respawning here makes the reconnect self-healing.
|
|
614
|
-
*/
|
|
615
|
-
const ensure = async () => {
|
|
616
|
-
// The same question every other surface asks. It used to be a bare TCP connect, so a stranger on
|
|
617
|
-
// the bridge port answered "a daemon is here" — the proxy connected, the stream ended, and each
|
|
618
|
-
// client request woke into the identical non-answer. Rejecting here is what puts the proxy
|
|
619
|
-
// dormant with a reason, instead of pretending the wake succeeded.
|
|
620
|
-
const presence = await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus });
|
|
621
|
-
if (presenceIsUsable(presence))
|
|
622
|
-
return;
|
|
623
|
-
if (PortPresence.FREE !== presence)
|
|
624
|
-
throw new Error(describePresence(presence, port));
|
|
625
|
-
const scriptPath = process.argv[1];
|
|
626
|
-
if (scriptPath === undefined) {
|
|
627
|
-
log('reticle_mcp_no_script', {});
|
|
628
|
-
process.exit(1);
|
|
629
|
-
}
|
|
630
|
-
const daemonArgs = [DAEMON_INNER_COMMAND, PORT_FLAG, String(port)];
|
|
631
|
-
if (driveUrl !== undefined) {
|
|
632
|
-
daemonArgs.push(DRIVE_FLAG, driveUrl);
|
|
633
|
-
if (!headless)
|
|
634
|
-
daemonArgs.push(HEADED_FLAG);
|
|
635
|
-
}
|
|
636
|
-
// Forward the HTTP-verify flags too (previously silently dropped for `reticle mcp`).
|
|
637
|
-
if (http) {
|
|
638
|
-
daemonArgs.push(HTTP_FLAG);
|
|
639
|
-
if (httpPort !== undefined)
|
|
640
|
-
daemonArgs.push(HTTP_PORT_FLAG, String(httpPort));
|
|
641
|
-
if (httpToken !== undefined)
|
|
642
|
-
daemonArgs.push(HTTP_TOKEN_FLAG, httpToken);
|
|
643
|
-
}
|
|
644
|
-
spawnDaemon(process.execPath, scriptPath, daemonArgs, port);
|
|
645
|
-
// Announce the daemon only once the PORT ACCEPTS. This line used to be written the instant the
|
|
646
|
-
// child was spawned, which on a Windows first bootstrap meant `reticle_mcp_daemon_started`
|
|
647
|
-
// followed about ten seconds later by `reticle_mcp_daemon_unavailable` and a first
|
|
648
|
-
// `reticle_sessions` that expired. A readiness signal that precedes readiness is worse than
|
|
649
|
-
// none: a client that believes it stops waiting for the thing that has not happened.
|
|
650
|
-
await waitForDaemon(port);
|
|
651
|
-
log('reticle_mcp_daemon_started', { port, ...(driveUrl !== undefined ? { driveUrl } : {}) });
|
|
652
|
-
};
|
|
653
|
-
// Start the proxy WHATEVER happened to the daemon.
|
|
654
|
-
//
|
|
655
|
-
// This used to exit(1) when `ensure` failed, which is the third way an MCP server disappears on a
|
|
656
|
-
// user: something else is holding the bridge port — a foreign daemon from another project, a
|
|
657
|
-
// half-dead process, a port a colleague's tool grabbed — the spawned daemon cannot bind, and the
|
|
658
|
-
// editor shows a server that failed to start. Nothing about that is unrecoverable: the proxy
|
|
659
|
-
// answers `initialize` itself, serves the cached catalog, and its wake path retries a daemon on
|
|
660
|
-
// every client request. Present-and-complaining beats absent, because absent needs a human.
|
|
661
|
-
// `void`: the chain handles its own failure and the process must not wait on it — the proxy is
|
|
662
|
-
// started from `finally` either way.
|
|
663
|
-
void ensure()
|
|
664
|
-
.catch((err) => {
|
|
665
|
-
log('reticle_mcp_daemon_unavailable', {
|
|
666
|
-
error: err instanceof Error ? err.message : String(err),
|
|
667
|
-
note: 'serving anyway — the next tool call will try to start a daemon again',
|
|
668
|
-
});
|
|
669
|
-
})
|
|
670
|
-
.finally(() => {
|
|
671
|
-
// Also `void`: the proxy runs for the life of the process and is never awaited by anyone.
|
|
672
|
-
//
|
|
673
|
-
// The `.catch` is load-bearing despite that. `startMcpProxy` rejects when its FIRST connect
|
|
674
|
-
// fails — which is precisely the case this block exists to tolerate, a daemon that is not
|
|
675
|
-
// there yet — and with nothing attached that reject became an unhandledRejection. Reported
|
|
676
|
-
// from a win32 user as a crash reading `connect ECONNREFUSED` with an EMPTY frame list,
|
|
677
|
-
// because the stack of a refused socket is entirely node internals and the privacy filter
|
|
678
|
-
// keeps only Reticle frames. So the one path we most want diagnosable arrived as an anonymous
|
|
679
|
-
// crash. The proxy itself is unaffected — it has already installed its stdin reader and goes
|
|
680
|
-
// on serving from cache, waking a daemon on the next request — which is exactly why this
|
|
681
|
-
// must be logged as the expected condition it is rather than reported as a defect.
|
|
682
|
-
void startMcpProxy(port, ensure).catch((err) => {
|
|
683
|
-
log('reticle_mcp_proxy_first_connect_failed', {
|
|
684
|
-
port,
|
|
685
|
-
error: err instanceof Error ? err.message : String(err),
|
|
686
|
-
note: 'serving from cache; the next client request will try to start a daemon',
|
|
687
|
-
});
|
|
688
|
-
});
|
|
689
|
-
});
|
|
690
|
-
}
|
|
691
606
|
function main() {
|
|
692
607
|
// Before anything reads process.env — notably the telemetry gate and the bridge's security
|
|
693
608
|
// options — fold in a project-local `.env`. Values already in the environment always win.
|
|
@@ -718,7 +633,19 @@ function main() {
|
|
|
718
633
|
if (projectPort !== undefined && isLikelyDevServerPort(projectPort)) {
|
|
719
634
|
process.stderr.write(`${devServerPortWarning(projectPort)}\n`);
|
|
720
635
|
}
|
|
721
|
-
|
|
636
|
+
// Registry BEFORE the default, and after both explicit sources.
|
|
637
|
+
//
|
|
638
|
+
// This is the line that ends the split brain. Build plugins have always asked the registry which
|
|
639
|
+
// daemon serves this project; the CLI asked a number and then attached to whoever owned it. So
|
|
640
|
+
// every project on the machine funnelled into one daemon whose identity was whichever project won
|
|
641
|
+
// the race, and one kill on one well-known port was a machine-wide outage.
|
|
642
|
+
//
|
|
643
|
+
// An explicit port still wins, because a person who typed one is answering this question
|
|
644
|
+
// themselves. Below that, our OWN daemon wherever it is listening. Only then the default, which is
|
|
645
|
+
// now a starting preference rather than an assumption.
|
|
646
|
+
const myProjectId = readProjectId(process.cwd());
|
|
647
|
+
const myDaemonPort = resolveDaemonForProject(myProjectId, reticleStateHome(), isAlive);
|
|
648
|
+
const defaultPort = envPort ?? projectPort ?? myDaemonPort ?? RETICLE_DEFAULT_PORT;
|
|
722
649
|
// Headed by default; hidden only where there is no display to be headed on. A run nobody can see
|
|
723
650
|
// is a run nobody trusts, and every "did it actually do anything?" cost a human round-trip.
|
|
724
651
|
const parsed = parseCliArgs(argv, defaultPort, process.env['CI'] !== undefined);
|
|
@@ -792,7 +719,7 @@ function main() {
|
|
|
792
719
|
void handleHunt(parsed.dir);
|
|
793
720
|
break;
|
|
794
721
|
case 'gate':
|
|
795
|
-
void handleGate(parsed.files, parsed.since);
|
|
722
|
+
void handleGate(parsed.files, parsed.since, parsed.hook);
|
|
796
723
|
break;
|
|
797
724
|
case 'watch':
|
|
798
725
|
handleWatch();
|
|
@@ -804,7 +731,7 @@ function main() {
|
|
|
804
731
|
void handleRollback();
|
|
805
732
|
break;
|
|
806
733
|
case 'mcp':
|
|
807
|
-
handleMcp(parsed);
|
|
734
|
+
void handleMcp(parsed);
|
|
808
735
|
break;
|
|
809
736
|
case '_daemon':
|
|
810
737
|
handleDaemonInner(parsed);
|
|
@@ -5,7 +5,7 @@ export declare const CLOUD_LINK_FILE = "cloud.json";
|
|
|
5
5
|
/** `~/.reticle/credentials.json` — the user's per-cloud-project API keys (the only secret). */
|
|
6
6
|
export declare const CREDENTIALS_FILE = "credentials.json";
|
|
7
7
|
/** What the daemon mirrors to the cloud for a project. Each surface is independently toggleable. */
|
|
8
|
-
|
|
8
|
+
interface SyncPolicy {
|
|
9
9
|
/** Push verification-run artifacts to the dashboard Runs tab. */
|
|
10
10
|
runs: boolean;
|
|
11
11
|
/** Push per-flow project-memory outcomes (the regression history). */
|
|
@@ -33,3 +33,4 @@ export interface ProjectCloud {
|
|
|
33
33
|
* so the single-project / CI flow is unchanged. `homeDir` is injected (testable; `os.homedir()` at call).
|
|
34
34
|
*/
|
|
35
35
|
export declare function resolveProjectCloud(fs: FileSystemPort, reticleRoot: string, homeDir: string, env: NodeJS.ProcessEnv): Promise<ProjectCloud>;
|
|
36
|
+
export {};
|
|
@@ -21,6 +21,17 @@ export interface CloudConfig {
|
|
|
21
21
|
}
|
|
22
22
|
/** Resolve cloud credentials from the environment, or null when not logged in (sync disabled). */
|
|
23
23
|
export declare function resolveCloudConfig(env: NodeJS.ProcessEnv): CloudConfig | null;
|
|
24
|
+
export declare const CLOUD_VERIFY_TIMEOUT_MS = 120000;
|
|
25
|
+
/**
|
|
26
|
+
* The fetch every cloud call goes through. Adds the abort signal, and turns the abort into a message
|
|
27
|
+
* that says what happened and what to do — a bare `AbortError` reaching a user or an agent is a riddle,
|
|
28
|
+
* and the agent-facing half of this product is judged on whether its errors are actionable.
|
|
29
|
+
*/
|
|
30
|
+
export declare function cloudFetch(url: string, init: {
|
|
31
|
+
method: string;
|
|
32
|
+
headers: Record<string, string>;
|
|
33
|
+
body?: string;
|
|
34
|
+
}, timeoutMs?: number): Promise<Response>;
|
|
24
35
|
export declare const SyncOutcome: {
|
|
25
36
|
readonly SYNCED: "synced";
|
|
26
37
|
readonly SKIPPED: "skipped";
|
package/dist/cloud/cloud-sync.js
CHANGED
|
@@ -30,6 +30,38 @@ export function resolveCloudConfig(env) {
|
|
|
30
30
|
return null;
|
|
31
31
|
return { url: url.replace(/\/+$/, ''), apiKey };
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Budget for a cloud call. Node's `fetch` has NO default timeout, so a connection that opens and then
|
|
35
|
+
* stalls never settles — and the caller `await`s it forever. In `reticle_flow_verify` that is an MCP
|
|
36
|
+
* tool call that never returns, and in `reticle cloud …` a terminal that prints nothing.
|
|
37
|
+
*
|
|
38
|
+
* One shared budget covers every call here: they are all small JSON request/response pairs against the
|
|
39
|
+
* same API, so there is nothing to tune per site. The ONE exception is the hosted verification submit,
|
|
40
|
+
* which blocks while a real browser runs the suite server-side — a normal response there is minutes,
|
|
41
|
+
* not seconds, so it gets its own (still bounded) budget rather than dragging the common one up.
|
|
42
|
+
*/
|
|
43
|
+
const CLOUD_FETCH_TIMEOUT_MS = 30_000;
|
|
44
|
+
export const CLOUD_VERIFY_TIMEOUT_MS = 120_000;
|
|
45
|
+
/** Names Node gives an abort: `AbortSignal.timeout` raises TimeoutError, an explicit abort AbortError. */
|
|
46
|
+
const TIMEOUT_ERROR_NAMES = new Set(['TimeoutError', 'AbortError']);
|
|
47
|
+
/**
|
|
48
|
+
* The fetch every cloud call goes through. Adds the abort signal, and turns the abort into a message
|
|
49
|
+
* that says what happened and what to do — a bare `AbortError` reaching a user or an agent is a riddle,
|
|
50
|
+
* and the agent-facing half of this product is judged on whether its errors are actionable.
|
|
51
|
+
*/
|
|
52
|
+
export async function cloudFetch(url, init, timeoutMs = CLOUD_FETCH_TIMEOUT_MS) {
|
|
53
|
+
try {
|
|
54
|
+
return await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
if (err instanceof Error && TIMEOUT_ERROR_NAMES.has(err.name)) {
|
|
58
|
+
throw new Error(`Reticle Cloud request timed out after ${Math.round(timeoutMs / 1000)}s: ${init.method} ${url}. ` +
|
|
59
|
+
`The server accepted the connection but never answered. Check the network and ${CloudEnv.URL}, then retry — ` +
|
|
60
|
+
`verification works locally without cloud.`);
|
|
61
|
+
}
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
33
65
|
export const SyncOutcome = {
|
|
34
66
|
SYNCED: 'synced',
|
|
35
67
|
SKIPPED: 'skipped',
|
package/dist/crawl/crawl.d.ts
CHANGED
|
@@ -4,6 +4,15 @@ export interface CrawlSession {
|
|
|
4
4
|
command(name: string, args?: Record<string, unknown>): Promise<CommandResult>;
|
|
5
5
|
elapsed(): number;
|
|
6
6
|
eventsSince(cursor: number): ReticleEvent[];
|
|
7
|
+
/**
|
|
8
|
+
* Which document is on screen now, so a finding is never drawn from a page the crawl has already
|
|
9
|
+
* navigated away from. Optional for the same reason `beginAction` is: a test fake supplies a
|
|
10
|
+
* minimal session, and its absence leaves the scoping inert rather than broken.
|
|
11
|
+
*/
|
|
12
|
+
readonly currentDocumentId?: string | undefined;
|
|
13
|
+
/** Which round of source edits is in force, so a finding drawn entirely from pre-edit evidence
|
|
14
|
+
* says so. Optional for the same reason `currentDocumentId` is. */
|
|
15
|
+
readonly currentEditEpoch?: number | undefined;
|
|
7
16
|
/**
|
|
8
17
|
* Attribution window around each click. Optional so a caller can supply a minimal session, but a real
|
|
9
18
|
* session MUST provide it: without a window the click's own effects carry no actionId, and an
|
|
@@ -44,19 +53,6 @@ export interface CrawlAnomaly {
|
|
|
44
53
|
}
|
|
45
54
|
/** Said out loud when the page was larger than one snapshot, so a clean sweep cannot mean "all clear". */
|
|
46
55
|
export declare const CAPPED_SNAPSHOT_NOTE = "the page exceeded one snapshot, so controls past the cap were never seen \u2014 interactiveFound is a floor, not a total; re-run with a narrower `scope` to cover the rest";
|
|
47
|
-
/**
|
|
48
|
-
* Said out loud when the crawl's OWN clicks put controls on the page that it then never visited.
|
|
49
|
-
*
|
|
50
|
-
* The enumeration happens once, before the first click, so a control that only exists after a login,
|
|
51
|
-
* a modal or a tab switch is invisible to this run. Measured on the bench app: a crawl of the login
|
|
52
|
-
* screen visited three controls, its click on "Sign in" produced seven more, and it returned
|
|
53
|
-
* `truncated: false` with an empty anomaly list and nine of twelve steps unused. Read as a sweep,
|
|
54
|
-
* that is a clean bill of health for an app the crawl never entered.
|
|
55
|
-
*
|
|
56
|
-
* The same words as the capped-snapshot case on purpose — it is the same idea (interactiveFound is a
|
|
57
|
-
* floor) reached by a different route, and a second vocabulary for it would just be more to learn.
|
|
58
|
-
*/
|
|
59
|
-
export declare function revealedControlsNote(count: number): string;
|
|
60
56
|
export interface CrawlReport {
|
|
61
57
|
interactiveFound: number;
|
|
62
58
|
stepsRun: number;
|
package/dist/crawl/crawl.js
CHANGED
|
@@ -18,7 +18,7 @@ export const CAPPED_SNAPSHOT_NOTE = 'the page exceeded one snapshot, so controls
|
|
|
18
18
|
* The same words as the capped-snapshot case on purpose — it is the same idea (interactiveFound is a
|
|
19
19
|
* floor) reached by a different route, and a second vocabulary for it would just be more to learn.
|
|
20
20
|
*/
|
|
21
|
-
|
|
21
|
+
function revealedControlsNote(count) {
|
|
22
22
|
return (`the page changed as you clicked: ${String(count)} control(s) that did not exist when this crawl ` +
|
|
23
23
|
'started were never visited — interactiveFound is a floor, not a total; re-run the crawl from ' +
|
|
24
24
|
'the state you are now in to cover them');
|
|
@@ -65,7 +65,19 @@ export function legitimatelyInert(desc) {
|
|
|
65
65
|
return true;
|
|
66
66
|
if (desc.includes('[checked]'))
|
|
67
67
|
return true;
|
|
68
|
-
|
|
68
|
+
// Inputs, and the ARIA LIVE-REGION roles.
|
|
69
|
+
//
|
|
70
|
+
// `alert`, `status`, `log`, `timer` and `marquee` exist to be READ — a screen reader announces
|
|
71
|
+
// them and nothing is meant to happen when one is clicked. Clicking twice and seeing nothing is
|
|
72
|
+
// therefore correct behaviour, and reporting it as `dead-control` accuses a working app.
|
|
73
|
+
//
|
|
74
|
+
// Found by driving a real app: the demo reported `dead-control — alert "Invalid email or
|
|
75
|
+
// password"`, which is a login form correctly telling somebody their password was wrong. That is
|
|
76
|
+
// the most expensive kind of false positive there is — the strongest claim the crawler makes,
|
|
77
|
+
// about an app that was working, and on the path a new user meets first.
|
|
78
|
+
//
|
|
79
|
+
// `alertdialog` is deliberately NOT here: it is interactive, and a word boundary keeps it out.
|
|
80
|
+
return /\b(textbox|searchbox|combobox|spinbutton|alert|status|log|timer|marquee)\b/.test(desc);
|
|
69
81
|
}
|
|
70
82
|
/**
|
|
71
83
|
* How many interactive controls exist now that did not exist when the crawl enumerated.
|
|
@@ -199,7 +211,12 @@ export async function crawl(session, opts, sleep) {
|
|
|
199
211
|
}
|
|
200
212
|
// CONTRADICTIONS: the channels disagree about what this click did. Reported per control, so a
|
|
201
213
|
// crawl of an unknown app surfaces its false greens without anyone knowing where to look.
|
|
202
|
-
|
|
214
|
+
// A crawl clicks controls that navigate, so its windows are the ones most likely to straddle a
|
|
215
|
+
// document boundary — and a finding here is reported against a specific control by name.
|
|
216
|
+
for (const c of findContradictions(events, {
|
|
217
|
+
currentDocumentId: session.currentDocumentId,
|
|
218
|
+
currentEditEpoch: session.currentEditEpoch,
|
|
219
|
+
})) {
|
|
203
220
|
counts.contradictions += 1;
|
|
204
221
|
anomalies.push({
|
|
205
222
|
kind: c.kind,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which daemon does THIS project belong to?
|
|
3
|
+
*
|
|
4
|
+
* The system answered that two incompatible ways. Build plugins asked the registry by projectId, the
|
|
5
|
+
* way `pickDaemonPort` was designed to be asked. The CLI and the MCP proxy asked a NUMBER —
|
|
6
|
+
* `envPort ?? projectPort ?? 4400` — and then attached to whatever owned it, whoever it belonged to.
|
|
7
|
+
*
|
|
8
|
+
* So every project on a machine funnelled into one daemon. Its recorded identity was whichever
|
|
9
|
+
* project won the race to the port, which made the registry entry wrong the moment a second project
|
|
10
|
+
* attached, and made one `kill` on one well-known port a machine-wide outage for every agent at once.
|
|
11
|
+
*
|
|
12
|
+
* It worked anyway, by luck: everyone defaulted to the same number, so everyone found each other.
|
|
13
|
+
* The luck ends the moment a daemon is anywhere but the default — which is exactly when discovery was
|
|
14
|
+
* supposed to help, and exactly when it returned nothing.
|
|
15
|
+
*
|
|
16
|
+
* This module is the one answer both sides now use. It is pure and injectable: no process spawning,
|
|
17
|
+
* no port binding, nothing that needs a real `~/.reticle` to test.
|
|
18
|
+
*/
|
|
19
|
+
import { type DaemonRegistryEntry } from '@reticlehq/core';
|
|
20
|
+
/**
|
|
21
|
+
* Every well-formed registry entry in `home`.
|
|
22
|
+
*
|
|
23
|
+
* A corrupt or half-written entry is skipped rather than thrown on: the registry is written by other
|
|
24
|
+
* processes that can be killed mid-write, and one bad file must not make a machine's daemons
|
|
25
|
+
* undiscoverable. Validated through core's schema so a shape change cannot silently pass here.
|
|
26
|
+
*/
|
|
27
|
+
export declare function readDaemonRegistry(home: string): DaemonRegistryEntry[];
|
|
28
|
+
/**
|
|
29
|
+
* The port of the live daemon serving `projectId`, or undefined when this project has none.
|
|
30
|
+
*
|
|
31
|
+
* Undefined means "start one", never "use the default and hope". That distinction is the fix: the old
|
|
32
|
+
* path had no way to express "no daemon of mine exists", so it fell through to a number and attached
|
|
33
|
+
* to a stranger.
|
|
34
|
+
*
|
|
35
|
+
* The selection rule is core's `pickDaemonPort`, shared with every build plugin, so the daemon side
|
|
36
|
+
* and the app side cannot disagree about which daemon is ours.
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolveDaemonForProject(projectId: string | undefined, home: string, alive: (pid: number) => boolean): number | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* May `caller` take over a daemon already sitting on the port it wanted?
|
|
41
|
+
*
|
|
42
|
+
* Two deliberate escape hatches, both of which keep an existing install working:
|
|
43
|
+
*
|
|
44
|
+
* A daemon that claims NO project was started somewhere without a `.reticle.json` — the global MCP
|
|
45
|
+
* registration in a directory that is not an app, which is the majority of daemons in the field. It
|
|
46
|
+
* belongs to nobody, so it belongs to whoever asks; refusing would break every user running Reticle
|
|
47
|
+
* globally, which is how it is documented to be installed.
|
|
48
|
+
*
|
|
49
|
+
* A caller with no project of its own has no identity to defend and nothing to be confused about, so
|
|
50
|
+
* it keeps the old permissive behaviour rather than being locked out of a daemon it can use.
|
|
51
|
+
*
|
|
52
|
+
* Everything else — a named daemon and a differently-named caller — is the cross-project bleed, and
|
|
53
|
+
* is the one case this refuses.
|
|
54
|
+
*/
|
|
55
|
+
export declare function adoptable(daemonProjectId: string | undefined, callerProjectId: string | undefined): boolean;
|
|
56
|
+
/** The project a daemon on `port` claims, or undefined when it claims none / is not registered. */
|
|
57
|
+
export declare function daemonProjectAt(port: number, home: string): string | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* The port this agent's MCP proxy should serve, decided BEFORE the proxy starts.
|
|
60
|
+
*
|
|
61
|
+
* Resolved once rather than per reconnect because the proxy's transport is built around a fixed
|
|
62
|
+
* number, and making it chase a moving port would mean reworking the reconnect path — the one piece
|
|
63
|
+
* of this system whose failure mode is "the MCP server disappeared mid-session". The port it settles
|
|
64
|
+
* on is this project's own, so the thing it would be chasing is a daemon it alone is responsible for.
|
|
65
|
+
*
|
|
66
|
+
* The order is the whole fix:
|
|
67
|
+
* 1. our project's live daemon, wherever it is listening — a daemon that moved is still ours;
|
|
68
|
+
* 2. the preferred port, but only if free or holding a daemon we may adopt;
|
|
69
|
+
* 3. a port the OS gives us, when the preferred one belongs to somebody else.
|
|
70
|
+
*
|
|
71
|
+
* Step 3 is what stops a second project from attaching to the first project's daemon. That single
|
|
72
|
+
* silent adoption is what made every agent on a machine share one process, one identity and one
|
|
73
|
+
* blast radius.
|
|
74
|
+
*/
|
|
75
|
+
export declare function resolveMcpPort(preferred: number, projectId: string | undefined, home: string, deps: {
|
|
76
|
+
alive: (pid: number) => boolean;
|
|
77
|
+
daemonPresent: (port: number) => Promise<boolean>;
|
|
78
|
+
pickPort: (preferred: number) => Promise<number>;
|
|
79
|
+
}): Promise<number>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which daemon does THIS project belong to?
|
|
3
|
+
*
|
|
4
|
+
* The system answered that two incompatible ways. Build plugins asked the registry by projectId, the
|
|
5
|
+
* way `pickDaemonPort` was designed to be asked. The CLI and the MCP proxy asked a NUMBER —
|
|
6
|
+
* `envPort ?? projectPort ?? 4400` — and then attached to whatever owned it, whoever it belonged to.
|
|
7
|
+
*
|
|
8
|
+
* So every project on a machine funnelled into one daemon. Its recorded identity was whichever
|
|
9
|
+
* project won the race to the port, which made the registry entry wrong the moment a second project
|
|
10
|
+
* attached, and made one `kill` on one well-known port a machine-wide outage for every agent at once.
|
|
11
|
+
*
|
|
12
|
+
* It worked anyway, by luck: everyone defaulted to the same number, so everyone found each other.
|
|
13
|
+
* The luck ends the moment a daemon is anywhere but the default — which is exactly when discovery was
|
|
14
|
+
* supposed to help, and exactly when it returned nothing.
|
|
15
|
+
*
|
|
16
|
+
* This module is the one answer both sides now use. It is pure and injectable: no process spawning,
|
|
17
|
+
* no port binding, nothing that needs a real `~/.reticle` to test.
|
|
18
|
+
*/
|
|
19
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { DaemonRegistryEntrySchema, daemonRegistryPort, pickDaemonPort, } from '@reticlehq/core';
|
|
22
|
+
/**
|
|
23
|
+
* Every well-formed registry entry in `home`.
|
|
24
|
+
*
|
|
25
|
+
* A corrupt or half-written entry is skipped rather than thrown on: the registry is written by other
|
|
26
|
+
* processes that can be killed mid-write, and one bad file must not make a machine's daemons
|
|
27
|
+
* undiscoverable. Validated through core's schema so a shape change cannot silently pass here.
|
|
28
|
+
*/
|
|
29
|
+
export function readDaemonRegistry(home) {
|
|
30
|
+
let files;
|
|
31
|
+
try {
|
|
32
|
+
files = readdirSync(home);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return []; // no ~/.reticle yet: no daemon has ever run here
|
|
36
|
+
}
|
|
37
|
+
const entries = [];
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
if (null === daemonRegistryPort(file))
|
|
40
|
+
continue;
|
|
41
|
+
try {
|
|
42
|
+
const parsed = DaemonRegistryEntrySchema.safeParse(JSON.parse(readFileSync(join(home, file), 'utf8')));
|
|
43
|
+
if (parsed.success)
|
|
44
|
+
entries.push(parsed.data);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return entries;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The port of the live daemon serving `projectId`, or undefined when this project has none.
|
|
54
|
+
*
|
|
55
|
+
* Undefined means "start one", never "use the default and hope". That distinction is the fix: the old
|
|
56
|
+
* path had no way to express "no daemon of mine exists", so it fell through to a number and attached
|
|
57
|
+
* to a stranger.
|
|
58
|
+
*
|
|
59
|
+
* The selection rule is core's `pickDaemonPort`, shared with every build plugin, so the daemon side
|
|
60
|
+
* and the app side cannot disagree about which daemon is ours.
|
|
61
|
+
*/
|
|
62
|
+
export function resolveDaemonForProject(projectId, home, alive) {
|
|
63
|
+
return pickDaemonPort(readDaemonRegistry(home), projectId, alive) ?? undefined;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* May `caller` take over a daemon already sitting on the port it wanted?
|
|
67
|
+
*
|
|
68
|
+
* Two deliberate escape hatches, both of which keep an existing install working:
|
|
69
|
+
*
|
|
70
|
+
* A daemon that claims NO project was started somewhere without a `.reticle.json` — the global MCP
|
|
71
|
+
* registration in a directory that is not an app, which is the majority of daemons in the field. It
|
|
72
|
+
* belongs to nobody, so it belongs to whoever asks; refusing would break every user running Reticle
|
|
73
|
+
* globally, which is how it is documented to be installed.
|
|
74
|
+
*
|
|
75
|
+
* A caller with no project of its own has no identity to defend and nothing to be confused about, so
|
|
76
|
+
* it keeps the old permissive behaviour rather than being locked out of a daemon it can use.
|
|
77
|
+
*
|
|
78
|
+
* Everything else — a named daemon and a differently-named caller — is the cross-project bleed, and
|
|
79
|
+
* is the one case this refuses.
|
|
80
|
+
*/
|
|
81
|
+
export function adoptable(daemonProjectId, callerProjectId) {
|
|
82
|
+
if (daemonProjectId === undefined || 0 === daemonProjectId.length)
|
|
83
|
+
return true;
|
|
84
|
+
if (callerProjectId === undefined || 0 === callerProjectId.length)
|
|
85
|
+
return true;
|
|
86
|
+
return daemonProjectId === callerProjectId;
|
|
87
|
+
}
|
|
88
|
+
/** The project a daemon on `port` claims, or undefined when it claims none / is not registered. */
|
|
89
|
+
export function daemonProjectAt(port, home) {
|
|
90
|
+
return readDaemonRegistry(home).find((e) => e.port === port)?.projectId;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The port this agent's MCP proxy should serve, decided BEFORE the proxy starts.
|
|
94
|
+
*
|
|
95
|
+
* Resolved once rather than per reconnect because the proxy's transport is built around a fixed
|
|
96
|
+
* number, and making it chase a moving port would mean reworking the reconnect path — the one piece
|
|
97
|
+
* of this system whose failure mode is "the MCP server disappeared mid-session". The port it settles
|
|
98
|
+
* on is this project's own, so the thing it would be chasing is a daemon it alone is responsible for.
|
|
99
|
+
*
|
|
100
|
+
* The order is the whole fix:
|
|
101
|
+
* 1. our project's live daemon, wherever it is listening — a daemon that moved is still ours;
|
|
102
|
+
* 2. the preferred port, but only if free or holding a daemon we may adopt;
|
|
103
|
+
* 3. a port the OS gives us, when the preferred one belongs to somebody else.
|
|
104
|
+
*
|
|
105
|
+
* Step 3 is what stops a second project from attaching to the first project's daemon. That single
|
|
106
|
+
* silent adoption is what made every agent on a machine share one process, one identity and one
|
|
107
|
+
* blast radius.
|
|
108
|
+
*/
|
|
109
|
+
export async function resolveMcpPort(preferred, projectId, home, deps) {
|
|
110
|
+
const mine = resolveDaemonForProject(projectId, home, deps.alive);
|
|
111
|
+
if (mine !== undefined)
|
|
112
|
+
return mine;
|
|
113
|
+
if (!(await deps.daemonPresent(preferred)))
|
|
114
|
+
return preferred;
|
|
115
|
+
return adoptable(daemonProjectAt(preferred, home), projectId)
|
|
116
|
+
? preferred
|
|
117
|
+
: deps.pickPort(preferred);
|
|
118
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A port this machine will actually let us bind.
|
|
3
|
+
*
|
|
4
|
+
* Once daemons are per-project, "which port" stops being a constant and becomes a question. The
|
|
5
|
+
* answer must not be "the next number up": probing 4401, 4402, 4403 races every other agent doing
|
|
6
|
+
* the same arithmetic at the same moment, and the loser finds out at bind time, which is after it has
|
|
7
|
+
* already told the caller which port to use.
|
|
8
|
+
*
|
|
9
|
+
* So the OS assigns it. Binding port 0 asks the kernel for a free one and hands back the number it
|
|
10
|
+
* chose, and nothing else can be holding it at that instant. There is still a window between closing
|
|
11
|
+
* the probe socket and the daemon binding for real — unavoidable without passing the listener itself
|
|
12
|
+
* to the child — but it is microseconds wide and loses to a retry, where a guessed number loses every
|
|
13
|
+
* time on a busy machine.
|
|
14
|
+
*
|
|
15
|
+
* The preferred port is tried first so the ordinary single-project install keeps landing on the
|
|
16
|
+
* documented default, and every doc, log line and troubleshooting page that says 4400 stays true.
|
|
17
|
+
*/
|
|
18
|
+
/** Can we bind `port` on the loopback interface right now? */
|
|
19
|
+
export declare function portIsFree(port: number, host?: string): Promise<boolean>;
|
|
20
|
+
/** Ask the kernel for a free port. Rejects only if the machine cannot bind loopback at all. */
|
|
21
|
+
export declare function osAssignedPort(host?: string): Promise<number>;
|
|
22
|
+
/**
|
|
23
|
+
* `preferred` when it is free, otherwise whatever the OS gives us.
|
|
24
|
+
*
|
|
25
|
+
* Injectable so the decision is unit-testable without binding anything: the interesting case is the
|
|
26
|
+
* one where the preferred port is taken, and a test must be able to say so without racing a real
|
|
27
|
+
* socket.
|
|
28
|
+
*/
|
|
29
|
+
export declare function pickDaemonPortToBind(preferred: number, isFree?: (port: number) => Promise<boolean>, assign?: () => Promise<number>): Promise<number>;
|