@reticlehq/server 3.3.0 → 3.4.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/LICENSE-ENTERPRISE +1 -1
- package/README.md +3 -3
- package/SKILL.md +12 -14
- package/dist/chromium-launch-options.d.ts +4 -3
- package/dist/chromium-launch-options.js +6 -2
- package/dist/command/cli/adhoc-suite.d.ts +22 -0
- package/dist/command/cli/adhoc-suite.js +90 -19
- package/dist/command/cli/adhoc-verdict.d.ts +35 -0
- package/dist/command/cli/adhoc-verdict.js +90 -3
- package/dist/command/cli/answers/gate-hook-message.d.ts +6 -0
- package/dist/command/cli/answers/gate-hook-message.js +6 -0
- package/dist/command/cli/auth/cloud-keystore.d.ts +0 -2
- package/dist/command/cli/auth/cloud-keystore.js +0 -3
- package/dist/command/cli/auth/cloud-session.d.ts +4 -0
- package/dist/command/cli/auth/cloud-session.js +2 -0
- package/dist/command/cli/cli-doctor.js +7 -5
- package/dist/command/cli/cli-flow-commands.d.ts +3 -6
- package/dist/command/cli/cli-flow-commands.js +67 -4
- package/dist/command/cli/cli-parse.d.ts +8 -1
- package/dist/command/cli/cli-parse.js +21 -3
- package/dist/command/cli/cli-update-commands.js +8 -1
- package/dist/command/cli/cli-verify.d.ts +24 -3
- package/dist/command/cli/cli-verify.js +54 -11
- package/dist/command/cli/cloud-cli.js +5 -2
- package/dist/command/cli/cloud-kit.js +3 -5
- package/dist/command/cli/cloud-login.js +21 -3
- package/dist/command/cli/demo/demo-page.js +6 -3
- package/dist/command/cli/doctor/browser/chromium-hint.d.ts +5 -0
- package/dist/command/cli/doctor/browser/chromium-hint.js +5 -0
- package/dist/command/cli/launch/cli-launch.d.ts +8 -0
- package/dist/command/cli/launch/cli-launch.js +21 -9
- package/dist/command/cli/launch/sdk-version-match.d.ts +84 -0
- package/dist/command/cli/launch/sdk-version-match.js +206 -0
- package/dist/command/cli/lifecycle/daemon-lifecycle.js +2 -16
- package/dist/command/cli/ports/resolve/cli-port.d.ts +25 -0
- package/dist/command/cli/ports/resolve/cli-port.js +125 -15
- package/dist/command/cli/setup-mcp-cli.js +13 -13
- package/dist/command/cli/tutorial.d.ts +2 -2
- package/dist/command/cli/tutorial.js +21 -7
- package/dist/command/cli.js +62 -10
- package/dist/command/daemon/binding/daemon-status-probe.d.ts +14 -2
- package/dist/command/daemon/binding/daemon-status-probe.js +34 -6
- package/dist/command/daemon/daemon-resolve.d.ts +9 -0
- package/dist/command/daemon/daemon-resolve.js +1 -1
- package/dist/command/daemon/lifetime/daemon-usefulness.d.ts +7 -1
- package/dist/command/daemon/lifetime/daemon-usefulness.js +30 -7
- package/dist/command/dev/stale-issue-guard.d.ts +3 -4
- package/dist/command/dev/stale-issue-guard.js +8 -8
- package/dist/command/setup/bringup/connect-lease.d.ts +17 -0
- package/dist/command/setup/bringup/connect-lease.js +38 -0
- package/dist/command/setup/bringup/dev-server-files.d.ts +68 -0
- package/dist/command/setup/bringup/dev-server-files.js +131 -0
- package/dist/command/setup/bringup/dev-server-log-cap.d.ts +35 -0
- package/dist/command/setup/bringup/dev-server-log-cap.js +121 -0
- package/dist/command/setup/bringup/ensure-daemon.d.ts +65 -4
- package/dist/command/setup/bringup/ensure-daemon.js +135 -16
- package/dist/command/setup/bringup/static-page-server.d.ts +35 -0
- package/dist/command/setup/bringup/static-page-server.js +151 -0
- package/dist/command/setup/desktop-shape.d.ts +4 -0
- package/dist/command/setup/desktop-shape.js +18 -2
- package/dist/command/setup/init/init-port.d.ts +15 -0
- package/dist/command/setup/init/init-port.js +44 -0
- package/dist/command/setup/init/init-runtime.d.ts +1 -1
- package/dist/command/setup/init/init-runtime.js +39 -2
- package/dist/command/setup/node-effects.d.ts +27 -14
- package/dist/command/setup/node-effects.js +80 -44
- package/dist/command/setup/probe/loopback-probe-urls.d.ts +7 -0
- package/dist/command/setup/probe/loopback-probe-urls.js +40 -0
- package/dist/command/setup/probe/page-probe.d.ts +13 -2
- package/dist/command/setup/probe/page-probe.js +18 -3
- package/dist/command/setup/run-setup.d.ts +26 -1
- package/dist/command/setup/run-setup.js +96 -13
- package/dist/command/setup/session-pick.d.ts +13 -0
- package/dist/command/setup/session-pick.js +20 -0
- package/dist/command/setup/setup-command.d.ts +2 -14
- package/dist/command/setup/setup-command.js +105 -31
- package/dist/command/setup/setup-mcp.js +3 -3
- package/dist/command/setup/terminal/wait-progress.d.ts +8 -0
- package/dist/command/setup/terminal/wait-progress.js +17 -0
- package/dist/features/crawl/crawl-empty.js +1 -1
- package/dist/features/crawl/crawl-tools.d.ts +6 -0
- package/dist/features/crawl/crawl-tools.js +57 -0
- package/dist/features/crawl/crawl.js +171 -157
- package/dist/features/exhaust/app-coverage.d.ts +33 -0
- package/dist/features/exhaust/app-coverage.js +31 -0
- package/dist/features/exhaust/code-coverage.d.ts +47 -0
- package/dist/features/exhaust/code-coverage.js +77 -0
- package/dist/features/exhaust/exhaust-run.d.ts +33 -0
- package/dist/features/exhaust/exhaust-run.js +82 -0
- package/dist/features/exhaust/explorer.d.ts +103 -0
- package/dist/features/exhaust/explorer.js +195 -0
- package/dist/features/exhaust/failure-branches.d.ts +33 -0
- package/dist/features/exhaust/failure-branches.js +92 -0
- package/dist/features/exhaust/ledger.d.ts +202 -0
- package/dist/features/exhaust/ledger.js +252 -0
- package/dist/features/exhaust/session-fold.d.ts +31 -0
- package/dist/features/exhaust/session-fold.js +79 -0
- package/dist/features/exhaust/session-port.d.ts +36 -0
- package/dist/features/exhaust/session-port.js +102 -0
- package/dist/features/harness/drive-report.d.ts +0 -9
- package/dist/features/harness/drive-report.js +18 -0
- package/dist/features/harness/driver.js +4 -7
- package/dist/features/harness/fill-values.d.ts +6 -0
- package/dist/features/harness/fill-values.js +3 -0
- package/dist/features/harness/jev-driver.js +30 -13
- package/dist/features/harness/openai-driver.js +4 -7
- package/dist/features/harness/platform-config.js +5 -6
- package/dist/index.d.ts +9 -0
- package/dist/index.js +40 -11
- package/dist/judgement/domain/domain-model.js +3 -1
- package/dist/judgement/oracles/self-instrument.js +4 -2
- package/dist/judgement/runs/artifact/replay-mapping.d.ts +2 -2
- package/dist/judgement/runs/artifact/replay-mapping.js +44 -9
- package/dist/judgement/runs/verification-sync.d.ts +3 -1
- package/dist/judgement/runs/verification-sync.js +1 -1
- package/dist/language/flows/change/affected.d.ts +9 -0
- package/dist/language/flows/change/affected.js +35 -0
- package/dist/language/flows/change/gate.d.ts +20 -0
- package/dist/language/flows/change/gate.js +12 -1
- package/dist/language/flows/flow-author.d.ts +7 -0
- package/dist/language/flows/flow-author.js +19 -0
- package/dist/language/flows/flow-expect-grammar.js +1 -1
- package/dist/language/flows/flow-journey.d.ts +54 -0
- package/dist/language/flows/flow-journey.js +119 -0
- package/dist/language/flows/flow-replay-run.d.ts +2 -3
- package/dist/language/flows/flow-replay-run.js +13 -47
- package/dist/language/flows/flow-replay.d.ts +22 -8
- package/dist/language/flows/flow-replay.js +55 -9
- package/dist/language/flows/flow-result.d.ts +2 -0
- package/dist/language/flows/flow-result.js +25 -0
- package/dist/language/flows/flow-tools.js +17 -5
- package/dist/language/flows/flows.d.ts +10 -1
- package/dist/language/flows/flows.js +90 -14
- package/dist/language/flows/recording/replay-halt.js +3 -1
- package/dist/language/flows/recording/tape/recordings.d.ts +30 -1
- package/dist/language/flows/recording/tape/recordings.js +55 -1
- package/dist/language/flows/replay.d.ts +19 -0
- package/dist/language/flows/replay.js +42 -6
- package/dist/language/flows/verify-change-tools.js +5 -2
- package/dist/launch-chromium.d.ts +80 -0
- package/dist/launch-chromium.js +218 -0
- package/dist/memory/cloud/cloud-config.d.ts +11 -2
- package/dist/memory/cloud/cloud-config.js +47 -7
- package/dist/memory/cloud/cloud-sync.d.ts +4 -1
- package/dist/memory/cloud/cloud-sync.js +6 -9
- package/dist/memory/cloud/harness-offer.js +5 -6
- package/dist/memory/cloud/harness-switch.js +5 -6
- package/dist/memory/cloud/sync-cycle.d.ts +68 -3
- package/dist/memory/cloud/sync-cycle.js +298 -55
- package/dist/memory/cloud/sync-daemon.js +24 -20
- package/dist/memory/cloud/sync-disk.js +16 -2
- package/dist/memory/intent/intent-shard.d.ts +10 -32
- package/dist/memory/intent/intent-shard.js +0 -10
- package/dist/memory/journal/drive-flow.d.ts +18 -13
- package/dist/memory/journal/drive-flow.js +45 -37
- package/dist/memory/journal/on-disk/workspace-tiers.js +2 -0
- package/dist/memory/journal/session-end.d.ts +9 -1
- package/dist/memory/journal/session-end.js +24 -12
- package/dist/memory/project/dir/reticle-dir.d.ts +2 -0
- package/dist/memory/project/dir/reticle-dir.js +1 -0
- package/dist/memory/project/project-tools.js +3 -1
- package/dist/portal/bridge/auth-failure-reason.d.ts +1 -1
- package/dist/portal/bridge/auth-failure-reason.js +4 -2
- package/dist/portal/bridge/bridge.d.ts +8 -0
- package/dist/portal/bridge/bridge.js +43 -2
- package/dist/portal/input/js-coverage.d.ts +25 -0
- package/dist/portal/input/js-coverage.js +30 -0
- package/dist/portal/input/real-input.d.ts +11 -0
- package/dist/portal/input/real-input.js +29 -4
- package/dist/portal/pool/playwright-launcher.d.ts +6 -0
- package/dist/portal/pool/playwright-launcher.js +20 -13
- package/dist/portal/session/dev-server/dev-command.d.ts +2 -2
- package/dist/portal/session/dev-server/dev-command.js +30 -12
- package/dist/portal/session/dev-server/dev-server-probe.js +17 -1
- package/dist/portal/session/dev-server/route-status-probe.d.ts +24 -0
- package/dist/portal/session/dev-server/route-status-probe.js +57 -0
- package/dist/portal/session/facts/observed-state.d.ts +11 -0
- package/dist/portal/session/facts/observed-state.js +34 -0
- package/dist/portal/session/no-session-diagnosis.d.ts +29 -0
- package/dist/portal/session/no-session-diagnosis.js +62 -5
- package/dist/portal/session/no-session-next-action.d.ts +13 -0
- package/dist/portal/session/no-session-next-action.js +48 -9
- package/dist/portal/session/no-session-watch.d.ts +18 -0
- package/dist/portal/session/no-session-watch.js +88 -2
- package/dist/portal/session/session-health.js +3 -2
- package/dist/portal/session/session-manager.d.ts +20 -6
- package/dist/portal/session/session-manager.js +19 -3
- package/dist/portal/session/session-reaper.d.ts +28 -0
- package/dist/portal/session/session-reaper.js +49 -1
- package/dist/portal/session/session.d.ts +5 -0
- package/dist/portal/session/session.js +15 -19
- package/dist/surface/http-server.d.ts +2 -1
- package/dist/surface/http-server.js +4 -2
- package/dist/surface/mcp/mcp-proxy.d.ts +2 -2
- package/dist/surface/mcp/mcp-proxy.js +2 -2
- package/dist/surface/mcp/mcp.js +4 -2
- package/dist/surface/mcp/proxy/proxy-daemon-probe.d.ts +13 -1
- package/dist/surface/mcp/proxy/proxy-daemon-probe.js +27 -2
- package/dist/surface/mcp/server-instructions.js +9 -7
- package/dist/surface/tools/act/after-match.d.ts +17 -0
- package/dist/surface/tools/act/after-match.js +30 -0
- package/dist/surface/tools/act/durable.d.ts +69 -0
- package/dist/surface/tools/act/durable.js +139 -0
- package/dist/surface/tools/act/navigation/navigate-arrival.d.ts +13 -6
- package/dist/surface/tools/act/navigation/navigate-arrival.js +47 -10
- package/dist/surface/tools/act/navigation/navigate-result.d.ts +1 -12
- package/dist/surface/tools/act/navigation/navigate-result.js +21 -3
- package/dist/surface/tools/act/proved-controls.d.ts +9 -0
- package/dist/surface/tools/act/proved-controls.js +11 -0
- package/dist/surface/tools/act/sequence-grade.d.ts +5 -0
- package/dist/surface/tools/act/sequence-grade.js +32 -1
- package/dist/surface/tools/act-capsule.d.ts +7 -0
- package/dist/surface/tools/act-capsule.js +46 -1
- package/dist/surface/tools/act-sequence-tool.js +25 -8
- package/dist/surface/tools/act-tools.js +26 -50
- package/dist/surface/tools/assert/assert-grade.js +12 -1
- package/dist/surface/tools/assert/assert-verdict.js +2 -2
- package/dist/surface/tools/browser-tools.js +14 -2
- package/dist/surface/tools/coverage-tools.js +27 -1
- package/dist/surface/tools/error-recovery.js +4 -4
- package/dist/surface/tools/feedback-invite.js +6 -4
- package/dist/surface/tools/harness-explore.d.ts +3 -2
- package/dist/surface/tools/harness-explore.js +7 -6
- package/dist/surface/tools/lease-tools.js +15 -6
- package/dist/surface/tools/live-call-text.js +4 -1
- package/dist/surface/tools/observe-tools.js +9 -1
- package/dist/surface/tools/tools.js +4 -0
- package/dist/telemetry/argument-shape.d.ts +0 -2
- package/dist/telemetry/argument-shape.js +0 -4
- package/dist/telemetry/error-class.d.ts +2 -3
- package/dist/telemetry/error-class.js +2 -3
- package/dist/telemetry/harness-drive.d.ts +0 -2
- package/dist/telemetry/harness-drive.js +0 -4
- package/dist/wire-journal.d.ts +3 -0
- package/dist/wire-journal.js +4 -0
- package/dist/workspace-packages.d.ts +0 -2
- package/dist/workspace-packages.js +1 -9
- package/docs/README.md +4 -2
- package/docs/actions.mdx +3 -3
- package/docs/agent-cheatsheet.md +4 -4
- package/docs/architecture.md +6 -6
- package/docs/best-practices.mdx +1 -1
- package/docs/capabilities.mdx +32 -1
- package/docs/cli/cloud.mdx +14 -4
- package/docs/cli/doctor.mdx +4 -0
- package/docs/cli/feedback.mdx +2 -2
- package/docs/cli/gate.mdx +9 -4
- package/docs/cli/update.mdx +10 -0
- package/docs/cli.mdx +9 -0
- package/docs/desktop.mdx +14 -10
- package/docs/environment.mdx +87 -0
- package/docs/faq.mdx +18 -16
- package/docs/first-drive.md +5 -13
- package/docs/flows.md +21 -2
- package/docs/frameworks.mdx +29 -10
- package/docs/getting-started.md +3 -3
- package/docs/http-transport.md +6 -3
- package/docs/human-control.md +2 -2
- package/docs/index.mdx +16 -6
- package/docs/install-agentic.mdx +1 -1
- package/docs/install-manual.mdx +1 -1
- package/docs/integration-patterns.md +3 -3
- package/docs/multi-agent-testing.md +1 -1
- package/docs/packages/babel-plugin.mdx +1 -1
- package/docs/packages/browser.mdx +2 -2
- package/docs/packages/react.mdx +2 -2
- package/docs/packages/tauri.mdx +1 -1
- package/docs/packages/vite-plugin.mdx +2 -2
- package/docs/packages.mdx +9 -9
- package/docs/predicates.mdx +50 -5
- package/docs/quickstart.mdx +2 -2
- package/docs/recipes.mdx +2 -2
- package/docs/state-management.mdx +2 -2
- package/docs/system-map.md +1 -1
- package/docs/telemetry.md +1 -1
- package/docs/token-efficiency.md +3 -3
- package/docs/tools/act-and-wait.mdx +1 -0
- package/docs/tools/act-sequence.mdx +1 -1
- package/docs/tools/console.mdx +1 -1
- package/docs/tools/inspect.mdx +1 -1
- package/docs/tools/network.mdx +1 -1
- package/docs/tools/query.mdx +1 -1
- package/docs/tools/sessions.mdx +1 -1
- package/docs/tools/snapshot.mdx +1 -1
- package/docs/tools/state.mdx +1 -1
- package/docs/tools/wait-for.mdx +1 -1
- package/docs/usage.md +7 -6
- package/docs/use-cases.mdx +157 -0
- package/docs/vs-screenshots.mdx +3 -3
- package/docs/what-is-recorded.md +2 -2
- package/docs/what-is-reticle.mdx +17 -20
- package/docs/why-reticle.mdx +14 -21
- package/package.json +10 -9
- package/dist/judgement/capsule/minimize.d.ts +0 -12
- package/dist/judgement/capsule/minimize.js +0 -22
- package/dist/judgement/phenomena/phenomena.d.ts +0 -27
- package/dist/judgement/phenomena/phenomena.js +0 -101
- package/dist/language/flows/flow-report.d.ts +0 -22
- package/dist/language/flows/flow-report.js +0 -106
|
@@ -17,6 +17,10 @@ import { DEV_SERVER_PORTS } from '../../command/cli/ports/resolve/cli-port.js';
|
|
|
17
17
|
import { siblingListenerNote } from '../../command/cli/ports/sibling-ports.js';
|
|
18
18
|
import { STALL_AFTER_MS } from './stall-clock.js';
|
|
19
19
|
import { pageTornDownWhileOn } from '@reticlehq/engine/evidence/page-teardown.js';
|
|
20
|
+
/** How every hello-refusal reason the bridge records separates its cause from its fix. */
|
|
21
|
+
const CAUSE_FIX_SEPARATOR = ' — ';
|
|
22
|
+
/** The lowest HTTP status that means the server itself failed to answer the route. */
|
|
23
|
+
const SERVER_ERROR_FLOOR = 500;
|
|
20
24
|
/** The one framework whose most likely cause differs from every other framework's. */
|
|
21
25
|
const NUXT = 'nuxt';
|
|
22
26
|
/**
|
|
@@ -49,6 +53,8 @@ const URL_THEN_LEASE = 'Once you have that URL, open it with reticle_lease {acti
|
|
|
49
53
|
* diagnosed the problem.
|
|
50
54
|
*/
|
|
51
55
|
function leaseAdvice(base, facts) {
|
|
56
|
+
if (true === facts.desktop)
|
|
57
|
+
return DESKTOP_NO_BROWSER;
|
|
52
58
|
const caveat = leaseCaveat(facts.leaseBrowser);
|
|
53
59
|
return caveat === undefined ? base : `${base} ${caveat}`;
|
|
54
60
|
}
|
|
@@ -180,6 +186,25 @@ function slowListenerClause(facts) {
|
|
|
180
186
|
const OPEN_THE_APP = 'Reticle only ever sees a page that is LOADED, so the commonest cause by a distance is that no ' +
|
|
181
187
|
`browser has opened the app yet: run ${OPEN_CMD} with the app's own URL (or ask the ` +
|
|
182
188
|
'human to open it).';
|
|
189
|
+
/**
|
|
190
|
+
* The desktop twin of OPEN_THE_APP. A desktop app's page exists only inside its own window, and the
|
|
191
|
+
* guidance used to send an Electron user to a browser URL — which reaches the renderer's dev server
|
|
192
|
+
* and none of the app around it.
|
|
193
|
+
*/
|
|
194
|
+
const DESKTOP_OPEN_THE_APP = 'This is a desktop app, and Reticle sees it only from inside its own window, so the commonest ' +
|
|
195
|
+
'cause by a distance is that the desktop app is not running: start it yourself, in the ' +
|
|
196
|
+
"background, with the project's desktop dev command (the script that runs `tauri dev`, " +
|
|
197
|
+
'`electron-vite dev`, `electron-forge start` or `electron .`), not the plain web dev server.';
|
|
198
|
+
/** Why the browser and lease routes are withheld for a desktop app. Replaces the lease offer. */
|
|
199
|
+
const DESKTOP_NO_BROWSER = 'Do not open its URL in a browser or with a lease: that loads the renderer outside the desktop ' +
|
|
200
|
+
'shell, without its IPC bridge or native APIs, so it is not the app you are verifying.';
|
|
201
|
+
function openTheApp(facts) {
|
|
202
|
+
return true === facts.desktop ? DESKTOP_OPEN_THE_APP : OPEN_THE_APP;
|
|
203
|
+
}
|
|
204
|
+
/** The "(or run open)" aside — a desktop window is reopened by the app, not by a browser command. */
|
|
205
|
+
function orOpenCommand(facts) {
|
|
206
|
+
return true === facts.desktop ? '' : ` (or run ${OPEN_CMD_BARE})`;
|
|
207
|
+
}
|
|
183
208
|
/**
|
|
184
209
|
* What a machine-wide port scan can and cannot say.
|
|
185
210
|
*
|
|
@@ -334,6 +359,22 @@ export function explainNoSession(facts) {
|
|
|
334
359
|
'while the dev server runs from a shell — they can be reading different `~/.reticle` ' +
|
|
335
360
|
'directories instead. The page says the same thing in the browser console.');
|
|
336
361
|
}
|
|
362
|
+
// Beside the token refusal and for the same reason: a turned-away hello is current, positive
|
|
363
|
+
// evidence, and every branch below reasons from an absence.
|
|
364
|
+
if (facts.helloRefused !== undefined) {
|
|
365
|
+
const { reason: said, url, projectId } = facts.helloRefused;
|
|
366
|
+
const split = said.indexOf(CAUSE_FIX_SEPARATOR);
|
|
367
|
+
const cause = -1 === split ? said : said.slice(0, split);
|
|
368
|
+
const fix = -1 === split ? undefined : said.slice(split + CAUSE_FIX_SEPARATOR.length);
|
|
369
|
+
const page = undefined === url || '' === url ? 'a page' : `the page at ${url}`;
|
|
370
|
+
const project = undefined === projectId ? '' : ` (project ${projectId})`;
|
|
371
|
+
return reason(
|
|
372
|
+
// ponytail: shares the token refusal's code, because a new code is a telemetry wire change
|
|
373
|
+
// and that surface has its own owner; split it out as `hello_refused` there.
|
|
374
|
+
NoSessionReason.AUTH_REFUSED, `no browser session connected: ${page}${project} dialled this daemon and was REFUSED ` +
|
|
375
|
+
`because ${cause}${undefined === fix ? '' : `; fix: ${fix}`}. Only an SDK dials the ` +
|
|
376
|
+
'bridge, so the app is running and instrumented — do not go looking for a stopped dev server.');
|
|
377
|
+
}
|
|
337
378
|
if (everConnected) {
|
|
338
379
|
// A reaped lease first, because it is the one cause with POSITIVE evidence behind it. Calling
|
|
339
380
|
// it "the tab was closed, ask the human to reopen the app" is wrong on every clause (#157):
|
|
@@ -347,9 +388,25 @@ export function explainNoSession(facts) {
|
|
|
347
388
|
`with reticle_lease {action:"acquire", url} and carry on. ${RETRY}`, 'A lease is a headless context, not a human tab, and it takes its cookies with it, so an ' +
|
|
348
389
|
`authenticated app needs signing in again.${alreadyListeningClause(listening)}`);
|
|
349
390
|
}
|
|
391
|
+
// Ranked above TAB_GONE because it is not an absence: the route itself answered. A tab that a
|
|
392
|
+
// 5xx tore down and a tab a human closed produce the same empty list, and until the watch asked
|
|
393
|
+
// the route, they produced the same sentence. Present tense throughout — "answers ... right
|
|
394
|
+
// now" — because a 500 now does not prove the teardown was a 500 then, and saying so would
|
|
395
|
+
// turn an observation into a story.
|
|
396
|
+
if (facts.lastKnownStatus !== undefined &&
|
|
397
|
+
facts.lastKnownStatus >= SERVER_ERROR_FLOOR &&
|
|
398
|
+
facts.lastKnownUrl !== undefined &&
|
|
399
|
+
'' !== facts.lastKnownUrl) {
|
|
400
|
+
return reason(NoSessionReason.ROUTE_SERVER_ERROR, 'no browser session connected, but one WAS connected to this daemon earlier, so the wiring ' +
|
|
401
|
+
`is correct. The page was torn down while on ${facts.lastKnownUrl}, and that route answers ` +
|
|
402
|
+
`HTTP ${String(facts.lastKnownStatus)} right now — a server error in the app, not a closed ` +
|
|
403
|
+
'tab and not an install problem. A route that throws server-side tears the page down and ' +
|
|
404
|
+
'the SDK cannot reconnect to it. Fix the route, then reload the tab' +
|
|
405
|
+
`${orOpenCommand(facts)}.${alreadyListeningClause(listening)} ${RETRY}`);
|
|
406
|
+
}
|
|
350
407
|
return reason(NoSessionReason.TAB_GONE, 'no browser session connected, but one WAS connected to this daemon earlier, so the wiring ' +
|
|
351
408
|
`is correct. ${tabGoneWhat(facts.lastKnownUrl)} Ask the human to reopen ` +
|
|
352
|
-
`the app
|
|
409
|
+
`the app${orOpenCommand(facts)}, or reload the tab. ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`, alreadyListeningClause(listening).trim());
|
|
353
410
|
}
|
|
354
411
|
// Ranked ahead of every "we have never seen one" branch below, because it is the one fact that
|
|
355
412
|
// OUTRANKS them: those branches all reason from an absence, and this reasons from a recorded
|
|
@@ -360,14 +417,14 @@ export function explainNoSession(facts) {
|
|
|
360
417
|
? `${unattributedListeners(listening)} If the dev server is not running, start it first ` +
|
|
361
418
|
'(the command is in `next_action`).'
|
|
362
419
|
: unattributedListeners(listening);
|
|
363
|
-
return reason(NoSessionReason.APP_NOT_REOPENED, `${RESTARTED_LEAD} ${
|
|
420
|
+
return reason(NoSessionReason.APP_NOT_REOPENED, `${RESTARTED_LEAD} ${openTheApp(facts)} ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`, `${DO_NOT_REINSTALL} ${listeners} ${rankedCauses(facts)}`);
|
|
364
421
|
}
|
|
365
422
|
// A config found elsewhere outranks every "you may have no SDK" branch below: those reason from
|
|
366
423
|
// an absence in ONE directory, and this is a file we read in another. Ranked here so the reader is
|
|
367
424
|
// never sent through an install on a project that has demonstrably been installed.
|
|
368
425
|
if (!initialized && (facts.configsElsewhere ?? []).length > 0) {
|
|
369
426
|
return reason(NoSessionReason.CONFIG_ELSEWHERE, 'no browser session connected, and this daemon has never seen one. ' +
|
|
370
|
-
`${configsElsewhereClause(facts)} ${
|
|
427
|
+
`${configsElsewhereClause(facts)} ${openTheApp(facts)} ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`, 'This daemon was started somewhere that is not the project, which is the normal outcome when ' +
|
|
371
428
|
`an editor launches it from your home directory. ${unattributedListeners(listening)} ` +
|
|
372
429
|
`${rankedCauses(facts)}`);
|
|
373
430
|
}
|
|
@@ -399,7 +456,7 @@ export function explainNoSession(facts) {
|
|
|
399
456
|
'the Vite or Babel plugin carries the SDK without that file at all.' +
|
|
400
457
|
`${searchedClause(facts)}${siblingListenerClause(facts)} ${leaseAdvice(URL_THEN_LEASE, facts)}`);
|
|
401
458
|
}
|
|
402
|
-
return reason(NoSessionReason.NO_LISTENER, `${stallClause(facts)}no browser session connected, and this daemon has never seen one. ${
|
|
459
|
+
return reason(NoSessionReason.NO_LISTENER, `${stallClause(facts)}no browser session connected, and this daemon has never seen one. ${openTheApp(facts)} ` +
|
|
403
460
|
'Nothing is listening on the ports Reticle scans either, and the usual reason is a dev ' +
|
|
404
461
|
'server that is not running: start it yourself in the background with the command in ' +
|
|
405
462
|
'`next_action`, tell the human in one line that it is running, then open the app in a ' +
|
|
@@ -428,7 +485,7 @@ export function explainNoSession(facts) {
|
|
|
428
485
|
`plugin. ${unattributedListeners(listening)}${searchedClause(facts)}${siblingListenerClause(facts)}`);
|
|
429
486
|
}
|
|
430
487
|
return reason(NoSessionReason.SDK_NOT_REACHING_DAEMON, `${stallClause(facts)}no browser session connected, and this daemon has never seen one for this project, which is ` +
|
|
431
|
-
`wired for Reticle. ${
|
|
488
|
+
`wired for Reticle. ${openTheApp(facts)} If the page IS open and still does not appear, the SDK ` +
|
|
432
489
|
`is not reaching this daemon (on ${String(port)}). ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`, `${unattributedListeners(listening)} ${rankedCauses(facts)}`);
|
|
433
490
|
}
|
|
434
491
|
/**
|
|
@@ -69,6 +69,19 @@ interface NextActionFacts {
|
|
|
69
69
|
* action that cannot help and can overwrite it.
|
|
70
70
|
*/
|
|
71
71
|
authRefused?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* The bridge refused the last hello for a non-token reason (protocol or wire-contract skew), in
|
|
74
|
+
* the bridge's own `cause — fix` sentence. Current, like `authRefused`: nothing connected since.
|
|
75
|
+
* Every other action below would send the agent to start, open or re-wire an app that is running,
|
|
76
|
+
* instrumented and being turned away.
|
|
77
|
+
*/
|
|
78
|
+
helloRefused?: string;
|
|
79
|
+
/**
|
|
80
|
+
* The url the session that went away was on, from the daemon's own tombstone. Positive evidence
|
|
81
|
+
* about where THIS project's app lives, which the port scan is not: the scan is machine-wide, and
|
|
82
|
+
* its one hit is as likely to be another repo's dev server as this one's.
|
|
83
|
+
*/
|
|
84
|
+
lastKnownUrl?: string;
|
|
72
85
|
}
|
|
73
86
|
export declare function nextActionFor(facts: NextActionFacts): NoSessionNextAction;
|
|
74
87
|
/** The same action as one sentence, for the prose message the human reads. */
|
|
@@ -12,7 +12,29 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Pure. The facts arrive from the watch; nothing here touches the disk or the clock.
|
|
14
14
|
*/
|
|
15
|
-
import { NoSessionAction, ReticleEnv } from '@reticlehq/core';
|
|
15
|
+
import { NoSessionAction, RETICLE_URL_PARAM, ReticleEnv } from '@reticlehq/core';
|
|
16
|
+
/**
|
|
17
|
+
* The departed session's url as a human would type it: the params a Reticle launcher stamps on a
|
|
18
|
+
* lease are stripped, because reopening WITH them would adopt the dead lease's identity. Undefined
|
|
19
|
+
* for a url that does not parse, or one with no port to name (a desktop webview's opaque origin).
|
|
20
|
+
*/
|
|
21
|
+
function reopenableUrl(raw) {
|
|
22
|
+
if (raw === undefined)
|
|
23
|
+
return undefined;
|
|
24
|
+
let url;
|
|
25
|
+
try {
|
|
26
|
+
url = new URL(raw);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
if ('http:' !== url.protocol && 'https:' !== url.protocol)
|
|
32
|
+
return undefined;
|
|
33
|
+
for (const param of Object.values(RETICLE_URL_PARAM))
|
|
34
|
+
url.searchParams.delete(param);
|
|
35
|
+
const port = '' === url.port ? ('https:' === url.protocol ? 443 : 80) : Number(url.port);
|
|
36
|
+
return { url: url.toString(), port };
|
|
37
|
+
}
|
|
16
38
|
/** `reticle init`, the only command here that is Reticle's own and so cannot be wrong. */
|
|
17
39
|
const INIT_COMMAND = 'reticle init';
|
|
18
40
|
const OPEN_COMMAND = 'reticle open';
|
|
@@ -28,6 +50,13 @@ export function nextActionFor(facts) {
|
|
|
28
50
|
if (splitBrain !== undefined) {
|
|
29
51
|
return { action: NoSessionAction.DAEMON_SPLIT, reason: splitBrain };
|
|
30
52
|
}
|
|
53
|
+
if (facts.helloRefused !== undefined) {
|
|
54
|
+
return {
|
|
55
|
+
action: NoSessionAction.OPEN_APP,
|
|
56
|
+
reason: `a page dialled this daemon and was refused (${facts.helloRefused}); do that, then reload ` +
|
|
57
|
+
'the page — the SDK does not retry after a refusal.',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
31
60
|
// `authRefused` is checked here and not only further down, because this branch short-circuits.
|
|
32
61
|
//
|
|
33
62
|
// Both refusal branches below were already written and already right, and neither could be
|
|
@@ -44,16 +73,24 @@ export function nextActionFor(facts) {
|
|
|
44
73
|
if (facts.everConnected && true !== facts.authRefused) {
|
|
45
74
|
const listening = facts.listening;
|
|
46
75
|
const only = 1 === listening.length ? listening[0] : undefined;
|
|
47
|
-
|
|
48
|
-
|
|
76
|
+
// The departed session's own url first: it is where this project's app demonstrably was, while
|
|
77
|
+
// a lone port from the scan may belong to anybody.
|
|
78
|
+
const known = reopenableUrl(facts.lastKnownUrl);
|
|
79
|
+
const bound = known !== undefined
|
|
80
|
+
? ` It was last on ${known.url}; reopen that — do not start a second stack.`
|
|
81
|
+
: 0 === listening.length
|
|
82
|
+
? ''
|
|
83
|
+
: only === undefined
|
|
84
|
+
? ` An app is already listening on ${listening.join(', ')}; just open the URL the human names — do not start a second stack.`
|
|
85
|
+
: ` An app is already listening on ${String(only)}; just open ${LOCALHOST}:${String(only)} — do not start a second stack.`;
|
|
86
|
+
const target = known !== undefined
|
|
87
|
+
? { command: `${OPEN_COMMAND} ${known.url}`, port: known.port }
|
|
49
88
|
: only === undefined
|
|
50
|
-
?
|
|
51
|
-
:
|
|
89
|
+
? {}
|
|
90
|
+
: { command: `${OPEN_COMMAND} ${LOCALHOST}:${String(only)}`, port: only };
|
|
52
91
|
return {
|
|
53
92
|
action: NoSessionAction.REOPEN_APP,
|
|
54
|
-
...
|
|
55
|
-
? {}
|
|
56
|
-
: { command: `${OPEN_COMMAND} ${LOCALHOST}:${String(only)}`, port: only }),
|
|
93
|
+
...target,
|
|
57
94
|
reason: 'a session was connected to this daemon earlier, so the wiring is correct — the tab was ' +
|
|
58
95
|
'closed, reloaded, or the lease aged out. Reopen the app, or take one you own with ' +
|
|
59
96
|
'reticle_lease {action:"acquire", url}.' +
|
|
@@ -108,7 +145,9 @@ export function nextActionFor(facts) {
|
|
|
108
145
|
// does not cover, it reported the app was not running.
|
|
109
146
|
reason: 'nothing is listening on the ports Reticle scans, so the app is probably not running — ' +
|
|
110
147
|
'though that scan is narrow, so if it IS up on another port, ask for its URL instead of ' +
|
|
111
|
-
`starting a second one. This is the project's own
|
|
148
|
+
`starting a second one. This is the project's own ` +
|
|
149
|
+
(undefined === dev.script ? 'launcher' : `\`${dev.script}\` script`) +
|
|
150
|
+
` — run it in the ` +
|
|
112
151
|
'background, tell the human it is running, then call reticle_sessions again.',
|
|
113
152
|
};
|
|
114
153
|
}
|
|
@@ -45,6 +45,24 @@ interface NoSessionWatchOptions {
|
|
|
45
45
|
* Omitted on a daemon with no pool, and auto-attach is then simply off.
|
|
46
46
|
*/
|
|
47
47
|
attach?: (url: string) => Promise<unknown>;
|
|
48
|
+
/**
|
|
49
|
+
* The ports THIS project's dev servers announced into the registry the build plugins write.
|
|
50
|
+
*
|
|
51
|
+
* Auto-attach opens a port only when it can be attributed to this project. The port scan is
|
|
52
|
+
* machine-wide, so its one hit is as likely to be another repo's app as this one's — and now that
|
|
53
|
+
* the attach path can launch an installed Chrome, a wrong guess parks a hidden headless browser on
|
|
54
|
+
* somebody else's app. Injected for tests; production reads the registry scoped by projectId/root.
|
|
55
|
+
*/
|
|
56
|
+
ownDevServerPorts?: () => readonly number[];
|
|
57
|
+
/**
|
|
58
|
+
* What the route the last session was on answers right now, as an HTTP status, or undefined
|
|
59
|
+
* when there is no answer to report.
|
|
60
|
+
*
|
|
61
|
+
* Injected so tests can pin the observation without opening a socket. Production (no override)
|
|
62
|
+
* asks the route itself — see route-status-probe. The one fact that separates a route that 500s
|
|
63
|
+
* from a closed tab (#808, option 3).
|
|
64
|
+
*/
|
|
65
|
+
routeStatus?: (url: string) => Promise<number | undefined>;
|
|
48
66
|
/**
|
|
49
67
|
* Where the durable "an app has connected here before" bit lives. The daemon's state home unless
|
|
50
68
|
* a test says otherwise.
|
|
@@ -9,13 +9,14 @@
|
|
|
9
9
|
* It only probes while NOTHING is connected. Once a session is live the question is moot, and a
|
|
10
10
|
* daemon that outlives the agent by hours has no business scanning ports it does not need.
|
|
11
11
|
*/
|
|
12
|
+
import { probeRouteStatus } from './dev-server/route-status-probe.js';
|
|
12
13
|
import { probeDevServers, probeDevServerStates } from './dev-server/dev-server-probe.js';
|
|
13
14
|
import { homedir } from 'node:os';
|
|
14
15
|
import { registeredElsewhere } from '../../memory/recall/registered-projects.js';
|
|
15
16
|
import { explainNoSession } from './no-session-diagnosis.js';
|
|
16
17
|
import { detectDevCommand } from './dev-server/dev-command.js';
|
|
17
18
|
import { nextActionFor, renderNextAction } from './no-session-next-action.js';
|
|
18
|
-
import { readProjectFramework, readProjectId, readProjectPort, } from '../../command/cli/ports/resolve/cli-port.js';
|
|
19
|
+
import { readProjectFramework, readProjectId, readProjectIsDesktop, readProjectPort, } from '../../command/cli/ports/resolve/cli-port.js';
|
|
19
20
|
import { discoverProjectConfigs } from '../../command/cli/config/config-discovery.js';
|
|
20
21
|
import { hasProjectConnectedBefore, rememberConnected, } from '../../memory/recall/prior/connection-memory.js';
|
|
21
22
|
import { isAlive, reticleStateHome } from '../../command/daemon/daemon.js';
|
|
@@ -24,6 +25,8 @@ import { stallUptime } from './stall-clock.js';
|
|
|
24
25
|
import { probeDaemon } from '../../surface/mcp/mcp-proxy.js';
|
|
25
26
|
import { findOccupiedSiblings } from '../../command/cli/ports/sibling-ports.js';
|
|
26
27
|
import { isAuthRefusalReason } from '../bridge/auth-failure-reason.js';
|
|
28
|
+
import { devServersForProject } from '@reticlehq/core';
|
|
29
|
+
import { readDevServers } from '../../command/daemon/dev-servers.js';
|
|
27
30
|
/** Slow enough to be free, fast enough that a dev server started 15s ago is already reflected. */
|
|
28
31
|
const REFRESH_MS = 15_000;
|
|
29
32
|
/** The URL an auto-attach opens. `localhost` for the same reason the probe uses it: both stacks. */
|
|
@@ -63,6 +66,15 @@ export function startNoSessionWatch(options) {
|
|
|
63
66
|
*/
|
|
64
67
|
let slowListeners = [];
|
|
65
68
|
let siblingListeners = [];
|
|
69
|
+
/**
|
|
70
|
+
* What the last-known route answered, cached beside the port scan for the same reason the scan
|
|
71
|
+
* is cached: the hint is read synchronously, so every fact it carries has to be gathered before
|
|
72
|
+
* it is asked. Keyed by URL so a status is never attributed to a route other than the one it
|
|
73
|
+
* came from — a fresh departure on a different URL reads as "not asked yet", not as the old
|
|
74
|
+
* answer.
|
|
75
|
+
*/
|
|
76
|
+
let lastKnownStatus;
|
|
77
|
+
const routeStatus = options.routeStatus ?? probeRouteStatus;
|
|
66
78
|
let running = false;
|
|
67
79
|
/** Ports auto-attach has already spent its one attempt on. Bounded: never a loop, never a retry. */
|
|
68
80
|
const attempted = new Set();
|
|
@@ -155,6 +167,31 @@ export function startNoSessionWatch(options) {
|
|
|
155
167
|
* Bounded by construction: one attempt per port per daemon. A failure is recorded, never retried —
|
|
156
168
|
* a retry loop against a broken Chromium install would spin for the daemon's whole life.
|
|
157
169
|
*/
|
|
170
|
+
const ownDevServerPorts = options.ownDevServerPorts ??
|
|
171
|
+
(() => devServersForProject(readDevServers(stateDir), {
|
|
172
|
+
projectId: readProjectId(directory),
|
|
173
|
+
root: directory,
|
|
174
|
+
}).map((entry) => entry.port));
|
|
175
|
+
/**
|
|
176
|
+
* Is `port` demonstrably this project's app? Either its dev server announced it, or this
|
|
177
|
+
* project's own last session was on it. Anything else is a guess, and a guess that opens a
|
|
178
|
+
* browser is worse than the sentence the diagnosis already writes about the ports it saw.
|
|
179
|
+
*/
|
|
180
|
+
const attributable = (port) => {
|
|
181
|
+
if (ownDevServerPorts().includes(port))
|
|
182
|
+
return true;
|
|
183
|
+
const known = options.sessions.lastKnown?.();
|
|
184
|
+
const projectId = readProjectId(directory);
|
|
185
|
+
if (known === undefined || projectId === undefined || known.projectId !== projectId) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
return Number(new URL(known.url).port) === port;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
158
195
|
const autoAttach = async (ports) => {
|
|
159
196
|
const attach = options.attach;
|
|
160
197
|
if (attach === undefined)
|
|
@@ -166,6 +203,8 @@ export function startNoSessionWatch(options) {
|
|
|
166
203
|
return;
|
|
167
204
|
if (!isWired())
|
|
168
205
|
return;
|
|
206
|
+
if (!attributable(only))
|
|
207
|
+
return;
|
|
169
208
|
attempted.add(only);
|
|
170
209
|
try {
|
|
171
210
|
await attach(`${LOCALHOST}:${String(only)}`);
|
|
@@ -193,6 +232,14 @@ export function startNoSessionWatch(options) {
|
|
|
193
232
|
: options.probe === undefined
|
|
194
233
|
? await findOccupiedSiblings(options.port, probeDaemon)
|
|
195
234
|
: [];
|
|
235
|
+
// Asked once per departed URL, on the same background cadence as the port scan, and never
|
|
236
|
+
// on the hint's own path. A url already answered is not asked again; a new departure is.
|
|
237
|
+
const known = options.sessions.lastKnown?.();
|
|
238
|
+
if (known !== undefined && lastKnownStatus?.url !== known.url) {
|
|
239
|
+
const status = await routeStatus(known.url);
|
|
240
|
+
if (status !== undefined)
|
|
241
|
+
lastKnownStatus = { url: known.url, status };
|
|
242
|
+
}
|
|
196
243
|
await autoAttach(ports);
|
|
197
244
|
})
|
|
198
245
|
.catch(() => {
|
|
@@ -225,6 +272,21 @@ export function startNoSessionWatch(options) {
|
|
|
225
272
|
*/
|
|
226
273
|
const lastCloseWasAuthFailure = () => isAuthRefusalReason(options.sessions.lastClosure?.()?.reason) &&
|
|
227
274
|
true !== options.sessions.connectedSinceLastClosure?.();
|
|
275
|
+
/**
|
|
276
|
+
* A hello refused for anything but its token, while it is still the bridge's current state.
|
|
277
|
+
*
|
|
278
|
+
* Only closures that carry the refused page count: the bridge attaches one exactly when a HELLO
|
|
279
|
+
* was turned away, so an origin or handshake-pool refusal (which has its own sentence on
|
|
280
|
+
* `resolve`) is not mistaken for one. Same ordering clause as the token refusal above.
|
|
281
|
+
*/
|
|
282
|
+
const currentHelloRefusal = () => {
|
|
283
|
+
const closure = options.sessions.lastClosure?.();
|
|
284
|
+
if (closure?.page === undefined || isAuthRefusalReason(closure.reason))
|
|
285
|
+
return undefined;
|
|
286
|
+
if (true === options.sessions.connectedSinceLastClosure?.())
|
|
287
|
+
return undefined;
|
|
288
|
+
return { reason: closure.reason, ...closure.page };
|
|
289
|
+
};
|
|
228
290
|
const nextAction = (scope) => {
|
|
229
291
|
const split = splitBrain();
|
|
230
292
|
return nextActionFor({
|
|
@@ -235,8 +297,18 @@ export function startNoSessionWatch(options) {
|
|
|
235
297
|
// Read when asked, like every other fact here: a page can dial at any moment, and a daemon
|
|
236
298
|
// that cached "nothing has been refused" at boot would keep saying so.
|
|
237
299
|
authRefused: lastCloseWasAuthFailure(),
|
|
300
|
+
...(() => {
|
|
301
|
+
const refused = currentHelloRefusal();
|
|
302
|
+
return refused === undefined ? {} : { helloRefused: refused.reason };
|
|
303
|
+
})(),
|
|
238
304
|
...(split === undefined ? {} : { splitBrain: split }),
|
|
239
305
|
listening,
|
|
306
|
+
// The url the departed tab was on — the same tombstone the prose diagnosis quotes, so the
|
|
307
|
+
// command and the sentence beside it name the same page.
|
|
308
|
+
...(() => {
|
|
309
|
+
const known = options.sessions.lastKnown?.();
|
|
310
|
+
return known === undefined ? {} : { lastKnownUrl: known.url };
|
|
311
|
+
})(),
|
|
240
312
|
// Read when asked, like everything else here: a `package.json` can gain a dev script, and a
|
|
241
313
|
// daemon that cached "there is none" at boot would keep saying so for the rest of the day.
|
|
242
314
|
dev: detectDevCommand(directory),
|
|
@@ -272,12 +344,19 @@ export function startNoSessionWatch(options) {
|
|
|
272
344
|
// beside it still said the tab had been closed. One fact, two answers, and only one of them
|
|
273
345
|
// was right. Read when asked, like the rest: a page can be refused at any moment.
|
|
274
346
|
authRefused: lastCloseWasAuthFailure(),
|
|
347
|
+
...(() => {
|
|
348
|
+
const refused = currentHelloRefusal();
|
|
349
|
+
return refused === undefined ? {} : { helloRefused: refused };
|
|
350
|
+
})(),
|
|
275
351
|
// Ranks the causes. Read when asked, like the rest: `init` writes this file after the daemon
|
|
276
352
|
// starts on an ordinary first install.
|
|
277
353
|
...(() => {
|
|
278
354
|
const framework = readProjectFramework(directory);
|
|
279
355
|
return framework === undefined ? {} : { framework };
|
|
280
356
|
})(),
|
|
357
|
+
// A desktop app is opened by its own dev command, never by a browser URL or a lease. Read when
|
|
358
|
+
// asked, like the framework: `init` may wire the app after this daemon started.
|
|
359
|
+
...(readProjectIsDesktop(directory) ? { desktop: true } : {}),
|
|
281
360
|
// Decided from the session that actually went away, not from a lifetime tally: the lease
|
|
282
361
|
// sentence is only right when the thing that vanished WAS a lease.
|
|
283
362
|
leaseExpired: (() => {
|
|
@@ -289,7 +368,14 @@ export function startNoSessionWatch(options) {
|
|
|
289
368
|
// has none to name.
|
|
290
369
|
...(() => {
|
|
291
370
|
const known = options.sessions.lastKnown?.();
|
|
292
|
-
|
|
371
|
+
if (undefined === known)
|
|
372
|
+
return {};
|
|
373
|
+
// The status rides only when it was fetched for THIS url. A departure since the fetch
|
|
374
|
+
// means the cached answer is about a route nobody is asking about.
|
|
375
|
+
const status = lastKnownStatus !== undefined && lastKnownStatus.url === known.url
|
|
376
|
+
? { lastKnownStatus: lastKnownStatus.status }
|
|
377
|
+
: {};
|
|
378
|
+
return { lastKnownUrl: known.url, ...status };
|
|
293
379
|
})(),
|
|
294
380
|
// How long this daemon has been waiting with no app. The diagnosis uses it to surface
|
|
295
381
|
// "install never finished" — the same condition telemetry already knows about.
|
|
@@ -29,7 +29,8 @@ export const PENDING_NAVIGATION_NOTICE_MS = 10_000;
|
|
|
29
29
|
*/
|
|
30
30
|
const SKEW_VERDICT_WARNING = 'version skew: `dispatched` and `settled` on this session mean the event was sent, NOT that the ' +
|
|
31
31
|
'app acted on it — a skewed pair drops actions silently. Re-read the control (aria-checked, ' +
|
|
32
|
-
'data-state, its text) before trusting any verdict from this session, and converge the versions
|
|
32
|
+
'data-state, its text) before trusting any verdict from this session, and converge the versions ' +
|
|
33
|
+
'(see session.versionSkew).';
|
|
33
34
|
/**
|
|
34
35
|
* The age of the oldest request that STARTED and never completed, or undefined when there is none
|
|
35
36
|
* worth reporting.
|
|
@@ -100,7 +101,7 @@ export function healthEnvelope(session) {
|
|
|
100
101
|
// a skewed link makes the ACTION unreliable, and there is no point warning about the quality of an
|
|
101
102
|
// observation of something that may never have happened.
|
|
102
103
|
if (health.versionSkew !== undefined) {
|
|
103
|
-
return { session: health, warning:
|
|
104
|
+
return { session: health, warning: SKEW_VERDICT_WARNING };
|
|
104
105
|
}
|
|
105
106
|
return health.throttled ? { session: health, warning: THROTTLED_WARNING } : { session: health };
|
|
106
107
|
}
|
|
@@ -14,6 +14,17 @@ interface ResolveScope {
|
|
|
14
14
|
}
|
|
15
15
|
/** The scheme://host:port of a URL, or undefined if it can't be parsed. Used to compare origins. */
|
|
16
16
|
export declare function originOf(url: string | undefined): string | undefined;
|
|
17
|
+
/** What a refused hello said about itself: the page it was on and the project it claimed. */
|
|
18
|
+
export interface RefusedPage {
|
|
19
|
+
readonly url?: string;
|
|
20
|
+
readonly projectId?: string;
|
|
21
|
+
}
|
|
22
|
+
/** One bridge-initiated close, with the page that was turned away when the hello named one. */
|
|
23
|
+
export interface ClosureRecord {
|
|
24
|
+
readonly at: number;
|
|
25
|
+
readonly reason: string;
|
|
26
|
+
readonly page?: RefusedPage;
|
|
27
|
+
}
|
|
17
28
|
export declare class SessionManager {
|
|
18
29
|
#private;
|
|
19
30
|
/** Set the active project's default resolve scope (called once at daemon wiring). */
|
|
@@ -57,13 +68,16 @@ export declare class SessionManager {
|
|
|
57
68
|
setNoSessionNextAction(next: (() => NoSessionNextAction | undefined) | undefined): void;
|
|
58
69
|
noSessionNextAction(): NoSessionNextAction | undefined;
|
|
59
70
|
everConnected(): boolean;
|
|
60
|
-
/**
|
|
61
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Record a bridge-initiated close so `resolve` can explain a session that vanished.
|
|
73
|
+
*
|
|
74
|
+
* `page` is what the refused hello said about itself, when it said anything. A refusal is the one
|
|
75
|
+
* piece of positive evidence the no-session diagnosis has, and "a page was refused" without WHICH
|
|
76
|
+
* page leaves the reader unable to tell their app from another project's tab on the same port.
|
|
77
|
+
*/
|
|
78
|
+
noteClosure(reason: string, at: number, page?: RefusedPage): void;
|
|
62
79
|
/** The most recent bridge-initiated close, if any. */
|
|
63
|
-
lastClosure():
|
|
64
|
-
at: number;
|
|
65
|
-
reason: string;
|
|
66
|
-
} | undefined;
|
|
80
|
+
lastClosure(): ClosureRecord | undefined;
|
|
67
81
|
connectedSinceLastClosure(): boolean;
|
|
68
82
|
/**
|
|
69
83
|
* The id of the session that most recently went away, if any.
|
|
@@ -325,9 +325,15 @@ export class SessionManager {
|
|
|
325
325
|
everConnected() {
|
|
326
326
|
return this.#everConnected;
|
|
327
327
|
}
|
|
328
|
-
/**
|
|
329
|
-
|
|
330
|
-
|
|
328
|
+
/**
|
|
329
|
+
* Record a bridge-initiated close so `resolve` can explain a session that vanished.
|
|
330
|
+
*
|
|
331
|
+
* `page` is what the refused hello said about itself, when it said anything. A refusal is the one
|
|
332
|
+
* piece of positive evidence the no-session diagnosis has, and "a page was refused" without WHICH
|
|
333
|
+
* page leaves the reader unable to tell their app from another project's tab on the same port.
|
|
334
|
+
*/
|
|
335
|
+
noteClosure(reason, at, page) {
|
|
336
|
+
this.#recentClosures.push({ at, reason, ...(page === undefined ? {} : { page }) });
|
|
331
337
|
if (this.#recentClosures.length > MAX_REMEMBERED_CLOSURES)
|
|
332
338
|
this.#recentClosures.shift();
|
|
333
339
|
this.#connectedSinceLastClosure = false;
|
|
@@ -512,6 +518,16 @@ export class SessionManager {
|
|
|
512
518
|
const allThrottled = bestScore >= 1;
|
|
513
519
|
const RECENCY_GAP_MS = allThrottled ? 0 : 1_000;
|
|
514
520
|
const clearWinner = runnerUp === undefined || best.ms + RECENCY_GAP_MS < runnerUp.ms;
|
|
521
|
+
// Healthy tabs heartbeat on the same cadence, so the recency gap between two of them is noise.
|
|
522
|
+
// When exactly one has the window focus it is the one a human is looking at: tabs piled up one
|
|
523
|
+
// per `init` run, and every tool called without a sessionId was refused while one of them was
|
|
524
|
+
// plainly the tab in front. Only among healthy ones — focus says nothing about a wedged tab.
|
|
525
|
+
const focused = allThrottled ? [] : candidates.filter((x) => x.s.health().focused);
|
|
526
|
+
const [onlyFocused, secondFocused] = focused;
|
|
527
|
+
if (!clearWinner && onlyFocused !== undefined && secondFocused === undefined) {
|
|
528
|
+
onlyFocused.s.markAgentActivity();
|
|
529
|
+
return onlyFocused.s;
|
|
530
|
+
}
|
|
515
531
|
if (!clearWinner) {
|
|
516
532
|
// Ambiguous: list sessions with their health so the agent can choose.
|
|
517
533
|
const detail = all
|
|
@@ -22,6 +22,34 @@ export declare function composeEndedNotice(base: string, undelivered: string[]):
|
|
|
22
22
|
* not a crash, since the agent is still connected and revives on its next action. Returns the ids.
|
|
23
23
|
*/
|
|
24
24
|
export declare function reapIdleSessions(sessions: SessionManager): string[];
|
|
25
|
+
/**
|
|
26
|
+
* Drop sessions that have ENDED, gone quiet and lost their socket, so a listing stops offering them.
|
|
27
|
+
*
|
|
28
|
+
* `reticle_session { action: "end" }` sets state and nothing else -- it never calls
|
|
29
|
+
* `sessions.remove`. `SessionManager.list()` does not filter by state, and `reapIdleSessions`
|
|
30
|
+
* above skips ended sessions by design. The only code path in the whole server that removes a
|
|
31
|
+
* session is the socket `close` handler in bridge.ts. So an ended session whose socket is already
|
|
32
|
+
* gone has nothing left that can remove it, and the row is listed for the life of the daemon
|
|
33
|
+
* (#938).
|
|
34
|
+
*
|
|
35
|
+
* That is what the field report describes: `{ ended: true }` three times, the row back on the next
|
|
36
|
+
* `reticle_sessions` call each time, still there after every tab on the origin was closed, 17 hours
|
|
37
|
+
* attached. And Reticle's own advice for a stale session is
|
|
38
|
+
* `Call reticle_session{action:"end"} to free this session` -- which is the one thing that does not
|
|
39
|
+
* free it.
|
|
40
|
+
*
|
|
41
|
+
* Deliberately NOT "remove on end". The panel is client-side, so `setState` has already pushed the
|
|
42
|
+
* ended state to the browser and the HUD keeps it; but `end` is documented idempotent, and removing
|
|
43
|
+
* immediately would make a second call fail to resolve. Waiting for the session to go quiet keeps
|
|
44
|
+
* that, keeps a just-ended session inspectable, and still collects the case that actually hurts: a
|
|
45
|
+
* row nobody can get rid of.
|
|
46
|
+
*
|
|
47
|
+
* Only once the socket is no longer open. An ended session whose tab is still connected but quiet
|
|
48
|
+
* belongs to a page that holds a live connection; dropping the record would orphan that socket,
|
|
49
|
+
* leaving a connection the daemon no longer tracks. Such a session is still removed the ordinary
|
|
50
|
+
* way, by the `close` handler, when the tab goes.
|
|
51
|
+
*/
|
|
52
|
+
export declare function reapEndedSessions(sessions: SessionManager, staleAfterMs?: number): string[];
|
|
25
53
|
/** End every active session immediately (the agent / MCP client disconnected → WARN). Returns ended ids. */
|
|
26
54
|
export declare function endAllSessions(sessions: SessionManager, reason: string): string[];
|
|
27
55
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PresenterTone, SESSION_LIFECYCLE, UNDELIVERED_NOTES_LABEL } from '@reticlehq/core';
|
|
1
|
+
import { PresenterTone, SESSION_LEASE, SESSION_LIFECYCLE, UNDELIVERED_NOTES_LABEL, } from '@reticlehq/core';
|
|
2
2
|
import { log } from '../../log.js';
|
|
3
3
|
/**
|
|
4
4
|
* Server-authoritative session liveness. The browser idle timer is throttled in a backgrounded tab
|
|
@@ -51,6 +51,50 @@ export function reapIdleSessions(sessions) {
|
|
|
51
51
|
log('session_reaped_idle', { sessions: ended });
|
|
52
52
|
return ended;
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Drop sessions that have ENDED, gone quiet and lost their socket, so a listing stops offering them.
|
|
56
|
+
*
|
|
57
|
+
* `reticle_session { action: "end" }` sets state and nothing else -- it never calls
|
|
58
|
+
* `sessions.remove`. `SessionManager.list()` does not filter by state, and `reapIdleSessions`
|
|
59
|
+
* above skips ended sessions by design. The only code path in the whole server that removes a
|
|
60
|
+
* session is the socket `close` handler in bridge.ts. So an ended session whose socket is already
|
|
61
|
+
* gone has nothing left that can remove it, and the row is listed for the life of the daemon
|
|
62
|
+
* (#938).
|
|
63
|
+
*
|
|
64
|
+
* That is what the field report describes: `{ ended: true }` three times, the row back on the next
|
|
65
|
+
* `reticle_sessions` call each time, still there after every tab on the origin was closed, 17 hours
|
|
66
|
+
* attached. And Reticle's own advice for a stale session is
|
|
67
|
+
* `Call reticle_session{action:"end"} to free this session` -- which is the one thing that does not
|
|
68
|
+
* free it.
|
|
69
|
+
*
|
|
70
|
+
* Deliberately NOT "remove on end". The panel is client-side, so `setState` has already pushed the
|
|
71
|
+
* ended state to the browser and the HUD keeps it; but `end` is documented idempotent, and removing
|
|
72
|
+
* immediately would make a second call fail to resolve. Waiting for the session to go quiet keeps
|
|
73
|
+
* that, keeps a just-ended session inspectable, and still collects the case that actually hurts: a
|
|
74
|
+
* row nobody can get rid of.
|
|
75
|
+
*
|
|
76
|
+
* Only once the socket is no longer open. An ended session whose tab is still connected but quiet
|
|
77
|
+
* belongs to a page that holds a live connection; dropping the record would orphan that socket,
|
|
78
|
+
* leaving a connection the daemon no longer tracks. Such a session is still removed the ordinary
|
|
79
|
+
* way, by the `close` handler, when the tab goes.
|
|
80
|
+
*/
|
|
81
|
+
export function reapEndedSessions(sessions, staleAfterMs = SESSION_LEASE.STALE_AFTER_MS) {
|
|
82
|
+
const dropped = [];
|
|
83
|
+
// Snapshot first: `remove` mutates the map this iterates.
|
|
84
|
+
for (const session of [...sessions.all()]) {
|
|
85
|
+
if (!session.isEnded())
|
|
86
|
+
continue;
|
|
87
|
+
if (session.socketOpen())
|
|
88
|
+
continue;
|
|
89
|
+
if (session.staleMs() < staleAfterMs)
|
|
90
|
+
continue;
|
|
91
|
+
if (sessions.remove(session))
|
|
92
|
+
dropped.push(session.id);
|
|
93
|
+
}
|
|
94
|
+
if (dropped.length > 0)
|
|
95
|
+
log('session_reaped_ended', { sessions: dropped });
|
|
96
|
+
return dropped;
|
|
97
|
+
}
|
|
54
98
|
/** End every active session immediately (the agent / MCP client disconnected → WARN). Returns ended ids. */
|
|
55
99
|
export function endAllSessions(sessions, reason) {
|
|
56
100
|
const ended = [];
|
|
@@ -81,6 +125,10 @@ export class SessionReaper {
|
|
|
81
125
|
return;
|
|
82
126
|
this.#timer = setInterval(() => {
|
|
83
127
|
reapIdleSessions(this.#sessions);
|
|
128
|
+
// Second rule, same sweep, kept as its own function: one ends a live session that went
|
|
129
|
+
// quiet, the other collects one that already ended. Merging them would put "end this" and
|
|
130
|
+
// "forget this" behind a single condition.
|
|
131
|
+
reapEndedSessions(this.#sessions);
|
|
84
132
|
}, this.#intervalMs);
|
|
85
133
|
this.#timer.unref();
|
|
86
134
|
}
|