@reticlehq/server 2.8.0 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +130 -813
- package/dist/bridge/bridge.d.ts +23 -0
- package/dist/bridge/bridge.js +80 -2
- package/dist/bridge/bridge.test-harness.d.ts +5 -0
- package/dist/bridge/bridge.test-harness.js +6 -4
- package/dist/capsule/capsule-store.d.ts +2 -2
- package/dist/capsule/causal-summary.d.ts +16 -1
- package/dist/capsule/causal-summary.js +2 -1
- package/dist/cli/cli-doctor.js +41 -13
- package/dist/cli/cli-flow-commands.js +8 -8
- package/dist/cli/cli-launch.d.ts +0 -17
- package/dist/cli/cli-launch.js +0 -28
- package/dist/cli/cli-parse.d.ts +1 -1
- package/dist/cli/cli-parse.js +1 -1
- package/dist/cli/cli-port.d.ts +24 -0
- package/dist/cli/cli-port.js +66 -35
- package/dist/cli/cli-verify.d.ts +21 -0
- package/dist/cli/cli-verify.js +45 -1
- package/dist/cli/config-discovery.d.ts +35 -0
- package/dist/cli/config-discovery.js +163 -0
- package/dist/cli/doctor-daemon-line.js +4 -1
- package/dist/cli/doctor-project-line.d.ts +37 -0
- package/dist/cli/doctor-project-line.js +35 -0
- package/dist/cli/doctor-rows.d.ts +51 -0
- package/dist/cli/doctor-rows.js +54 -0
- package/dist/cli/doctor-sessions-line.js +6 -3
- package/dist/cli/drive-attach.d.ts +99 -0
- package/dist/cli/drive-attach.js +203 -0
- package/dist/cli/drive-command.d.ts +13 -0
- package/dist/cli/drive-command.js +103 -0
- package/dist/cli/open-note.d.ts +20 -0
- package/dist/cli/open-note.js +37 -0
- package/dist/cli/status-next-action.d.ts +37 -0
- package/dist/cli/status-next-action.js +61 -0
- package/dist/cli.js +65 -70
- package/dist/crawl/crawl.d.ts +7 -2
- package/dist/crawl/crawl.js +52 -9
- package/dist/ee/audit-log.js +2 -2
- package/dist/events/body-failures.d.ts +2 -2
- package/dist/events/contradiction-folds.d.ts +45 -0
- package/dist/events/contradiction-folds.js +42 -0
- package/dist/events/contradictions.d.ts +51 -1
- package/dist/events/contradictions.js +26 -7
- package/dist/events/declared.d.ts +45 -0
- package/dist/events/declared.js +73 -0
- package/dist/events/echo-mismatch.d.ts +9 -3
- package/dist/events/echo-mismatch.js +33 -5
- package/dist/events/event-filters.d.ts +1 -1
- package/dist/events/event-filters.js +7 -4
- package/dist/events/predicate-eval.d.ts +17 -91
- package/dist/events/predicate-eval.js +85 -326
- package/dist/events/predicate-parse.js +31 -3
- package/dist/events/predicate-route.d.ts +32 -0
- package/dist/events/predicate-route.js +102 -0
- package/dist/events/predicate-schema.d.ts +99 -0
- package/dist/events/predicate-schema.js +309 -0
- package/dist/events/predicate.d.ts +8 -0
- package/dist/events/predicate.js +127 -5
- package/dist/events/stale-response.d.ts +2 -3
- package/dist/events/stale-response.js +49 -2
- package/dist/events/unit-mismatch.d.ts +2 -2
- package/dist/flows/annotate-tools.js +3 -0
- package/dist/flows/annotate.js +18 -4
- package/dist/flows/coverage.d.ts +38 -5
- package/dist/flows/coverage.js +30 -4
- package/dist/flows/decision.d.ts +15 -0
- package/dist/flows/decision.js +1 -1
- package/dist/flows/flow-replay-run.js +8 -1
- package/dist/flows/flow-tools.js +9 -1
- package/dist/flows/replay.js +3 -0
- package/dist/flows/verify-change-tools.js +1 -1
- package/dist/honesty/accepted-write.d.ts +3 -0
- package/dist/honesty/accepted-write.js +8 -3
- package/dist/honesty/already-true.js +15 -0
- package/dist/honesty/blind-spots.js +4 -0
- package/dist/honesty/honesty.d.ts +11 -0
- package/dist/honesty/honesty.js +1 -0
- package/dist/honesty/uncaptured-bodies.d.ts +4 -2
- package/dist/honesty/uncaptured-bodies.js +9 -2
- package/dist/honesty/unread-outcome.d.ts +8 -0
- package/dist/honesty/unread-outcome.js +36 -9
- package/dist/honesty/unsettled.d.ts +9 -0
- package/dist/honesty/unsettled.js +7 -3
- package/dist/honesty/verified.d.ts +29 -3
- package/dist/honesty/verified.js +67 -8
- package/dist/http-server.d.ts +19 -0
- package/dist/http-server.js +202 -4
- package/dist/impact/impact-recorder.d.ts +23 -0
- package/dist/impact/impact-recorder.js +76 -0
- package/dist/impact/impact-store.d.ts +47 -0
- package/dist/impact/impact-store.js +171 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +60 -10
- package/dist/init/agent-rules.d.ts +20 -0
- package/dist/init/agent-rules.js +36 -0
- package/dist/init/astro-patch.js +27 -1
- package/dist/init/confirm.d.ts +21 -3
- package/dist/init/confirm.js +32 -5
- package/dist/init/connect-steps.d.ts +2 -0
- package/dist/init/connect-steps.js +50 -0
- package/dist/init/csp-check.d.ts +38 -0
- package/dist/init/csp-check.js +92 -0
- package/dist/init/csp-doctor.d.ts +25 -0
- package/dist/init/csp-doctor.js +49 -0
- package/dist/init/cursor.js +6 -9
- package/dist/init/detect.d.ts +8 -0
- package/dist/init/detect.js +33 -3
- package/dist/init/mcp-clients.d.ts +1 -0
- package/dist/init/mcp-clients.js +51 -7
- package/dist/init/mcp.d.ts +16 -2
- package/dist/init/mcp.js +23 -1
- package/dist/init/plan-framework.d.ts +10 -0
- package/dist/init/plan-framework.js +25 -1
- package/dist/init/plan.d.ts +7 -2
- package/dist/init/plan.js +66 -30
- package/dist/init/run.js +11 -5
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/slash-command.js +12 -4
- package/dist/init/snippets.d.ts +1 -1
- package/dist/init/snippets.js +35 -7
- package/dist/init/vite-config.js +24 -2
- package/dist/input/network-mock-tools.js +8 -1
- package/dist/input/scroll-find.d.ts +6 -0
- package/dist/input/scroll-find.js +17 -2
- package/dist/input/scroll-tools.js +4 -0
- package/dist/journal/attach-journal.d.ts +9 -1
- package/dist/journal/attach-journal.js +13 -2
- package/dist/journal/journal-query.d.ts +13 -0
- package/dist/journal/journal-query.js +28 -3
- package/dist/journal/workspace-gitignore.d.ts +10 -0
- package/dist/journal/workspace-gitignore.js +59 -0
- package/dist/license/license.d.ts +69 -4
- package/dist/license/license.js +84 -15
- package/dist/mcp/mcp-proxy.d.ts +7 -93
- package/dist/mcp/mcp-proxy.js +183 -250
- package/dist/mcp/mcp.d.ts +27 -2
- package/dist/mcp/mcp.js +77 -36
- package/dist/mcp/proxy-daemon-probe.d.ts +8 -0
- package/dist/mcp/proxy-daemon-probe.js +48 -0
- package/dist/mcp/proxy-handshake.d.ts +82 -1
- package/dist/mcp/proxy-handshake.js +110 -3
- package/dist/mcp/proxy-lifecycle.d.ts +10 -1
- package/dist/mcp/proxy-lifecycle.js +11 -2
- package/dist/mcp/proxy-log.d.ts +63 -0
- package/dist/mcp/proxy-log.js +132 -0
- package/dist/mcp/server-instructions.d.ts +17 -0
- package/dist/mcp/server-instructions.js +56 -0
- package/dist/mcp/shared-params.d.ts +27 -0
- package/dist/mcp/shared-params.js +36 -0
- package/dist/mcp/sse-frame-parser.d.ts +19 -0
- package/dist/mcp/sse-frame-parser.js +41 -0
- package/dist/project/reticle-dir.d.ts +2 -0
- package/dist/project/reticle-dir.js +1 -0
- package/dist/session/connection-memory.d.ts +59 -0
- package/dist/session/connection-memory.js +108 -0
- package/dist/session/dev-command.d.ts +26 -0
- package/dist/session/dev-command.js +89 -0
- package/dist/session/dev-server-probe.d.ts +68 -0
- package/dist/session/dev-server-probe.js +102 -7
- package/dist/session/last-act.d.ts +26 -0
- package/dist/session/last-act.js +35 -1
- package/dist/session/live-control-tools.js +42 -5
- package/dist/session/no-session-diagnosis.d.ts +55 -2
- package/dist/session/no-session-diagnosis.js +199 -22
- package/dist/session/no-session-next-action.d.ts +46 -0
- package/dist/session/no-session-next-action.js +85 -0
- package/dist/session/no-session-watch.d.ts +40 -1
- package/dist/session/no-session-watch.js +172 -11
- package/dist/session/pending-commands.d.ts +12 -0
- package/dist/session/pending-commands.js +13 -1
- package/dist/session/session-manager.d.ts +4 -0
- package/dist/session/session-manager.js +35 -5
- package/dist/session/session-readiness.js +1 -1
- package/dist/session/session.d.ts +10 -1
- package/dist/session/session.js +146 -5
- package/dist/telemetry/app-instrumented.d.ts +8 -0
- package/dist/telemetry/app-instrumented.js +10 -0
- package/dist/telemetry/bug-found.d.ts +18 -0
- package/dist/telemetry/bug-found.js +14 -0
- package/dist/telemetry/daemon-telemetry.js +29 -0
- package/dist/telemetry/install-source.d.ts +20 -0
- package/dist/telemetry/install-source.js +34 -0
- package/dist/telemetry/instrumentation-stall.d.ts +57 -0
- package/dist/telemetry/instrumentation-stall.js +103 -0
- package/dist/telemetry/license-activation.d.ts +48 -0
- package/dist/telemetry/license-activation.js +57 -0
- package/dist/telemetry/session-metrics.d.ts +8 -0
- package/dist/telemetry/session-metrics.js +10 -0
- package/dist/telemetry/telemetry.d.ts +3 -1
- package/dist/telemetry/telemetry.js +23 -3
- package/dist/tools/act-tools.js +166 -29
- package/dist/tools/assert-verdict.js +32 -7
- package/dist/tools/browser-tools.js +10 -0
- package/dist/tools/dynamic-tools.js +17 -8
- package/dist/tools/error-recovery.js +10 -6
- package/dist/tools/feedback-tools.js +31 -6
- package/dist/tools/invoke-tool.d.ts +1 -1
- package/dist/tools/invoke-tool.js +30 -8
- package/dist/tools/lease-hint.d.ts +30 -8
- package/dist/tools/lease-hint.js +65 -14
- package/dist/tools/lease-tools.d.ts +11 -0
- package/dist/tools/lease-tools.js +141 -96
- package/dist/tools/observe-tools.js +9 -3
- package/dist/tools/react-grace.d.ts +4 -0
- package/dist/tools/react-grace.js +6 -3
- package/dist/tools/read-tools.js +47 -1
- package/dist/tools/resolve-target.d.ts +33 -0
- package/dist/tools/resolve-target.js +53 -0
- package/dist/tools/settle-in-flight.d.ts +15 -0
- package/dist/tools/settle-in-flight.js +42 -2
- package/dist/tools/snapshot-delta.d.ts +3 -0
- package/dist/tools/snapshot-delta.js +67 -6
- package/dist/tools/tool-kit.d.ts +38 -3
- package/dist/tools/tool-names.d.ts +2 -0
- package/dist/tools/tool-names.js +2 -0
- package/dist/tools/tool-surface.d.ts +60 -0
- package/dist/tools/tool-surface.js +84 -19
- package/dist/tools/tools.js +61 -5
- package/dist/tools/unadvertised-help.js +9 -6
- package/docs/README.md +1 -2
- package/docs/actions.mdx +41 -8
- package/docs/agent-cheatsheet.md +6 -4
- package/docs/architecture.md +6 -0
- package/docs/benchmarks.md +60 -12
- package/docs/best-practices.mdx +11 -6
- package/docs/capabilities.mdx +204 -57
- package/docs/cli/affected.mdx +6 -4
- package/docs/cli/capsules.mdx +9 -8
- package/docs/cli/cloud.mdx +30 -4
- package/docs/cli/doctor.mdx +41 -8
- package/docs/cli/drive.mdx +34 -15
- package/docs/cli/gate.mdx +6 -6
- package/docs/cli/help.mdx +8 -6
- package/docs/cli/hunt.mdx +20 -4
- package/docs/cli/identify.mdx +16 -2
- package/docs/cli/init.mdx +35 -8
- package/docs/cli/kill.mdx +5 -5
- package/docs/cli/license.mdx +1 -1
- package/docs/cli/open.mdx +6 -0
- package/docs/cli/restart.mdx +6 -6
- package/docs/cli/serve.mdx +10 -3
- package/docs/cli/status.mdx +29 -5
- package/docs/cli/stop.mdx +8 -0
- package/docs/cli/verify.mdx +20 -1
- package/docs/cli/version.mdx +1 -1
- package/docs/cli/watch.mdx +9 -2
- package/docs/cli.mdx +44 -22
- package/docs/debugging.md +3 -1
- package/docs/deploy-checks.md +31 -2
- package/docs/desktop.mdx +276 -80
- package/docs/docs.json +13 -12
- package/docs/enterprise.md +9 -3
- package/docs/faq.mdx +49 -35
- package/docs/fixtures.md +2 -0
- package/docs/flows.md +7 -5
- package/docs/for-agents.md +3 -3
- package/docs/frameworks.mdx +46 -7
- package/docs/gate-plan.md +4 -2
- package/docs/gates.md +8 -6
- package/docs/getting-started.md +53 -12
- package/docs/human-control.md +2 -0
- package/docs/index.mdx +43 -1
- package/docs/install-agentic.mdx +13 -4
- package/docs/install-manual.mdx +6 -4
- package/docs/instrumentation.mdx +7 -1
- package/docs/integration-patterns.md +3 -1
- package/docs/local-registry.md +4 -2
- package/docs/multi-agent-testing.md +3 -1
- package/docs/packages/babel-plugin.mdx +15 -6
- package/docs/packages/browser.mdx +2 -2
- package/docs/packages/core.mdx +26 -16
- package/docs/packages/electron.mdx +5 -7
- package/docs/packages/eslint-plugin.mdx +5 -3
- package/docs/packages/next.mdx +3 -5
- package/docs/packages/react.mdx +3 -3
- package/docs/packages/server.mdx +2 -2
- package/docs/packages/tauri.mdx +6 -6
- package/docs/packages/test.mdx +18 -7
- package/docs/packages/vite-plugin.mdx +4 -6
- package/docs/packages.mdx +14 -13
- package/docs/platform-integration.md +2 -0
- package/docs/predicates.mdx +76 -7
- package/docs/quickstart.mdx +51 -5
- package/docs/recipes.mdx +63 -23
- package/docs/skill-file.mdx +3 -1
- package/docs/state-management.mdx +2 -0
- package/docs/system-map.md +3 -1
- package/docs/telemetry-contract.md +33 -5
- package/docs/telemetry-events.mdx +63 -18
- package/docs/telemetry.md +8 -5
- package/docs/testing.md +35 -1
- package/docs/token-efficiency.md +8 -2
- package/docs/tools-act-and-wait.mdx +64 -14
- package/docs/tools-act-sequence.mdx +34 -17
- package/docs/tools-act.mdx +24 -11
- package/docs/tools-assert.mdx +69 -10
- package/docs/tools-console.mdx +11 -7
- package/docs/tools-inspect.mdx +11 -8
- package/docs/tools-navigate.mdx +41 -5
- package/docs/tools-network.mdx +42 -9
- package/docs/tools-observe.mdx +66 -10
- package/docs/tools-overview.mdx +26 -23
- package/docs/tools-query.mdx +52 -8
- package/docs/tools-session-and-feedback.mdx +35 -9
- package/docs/tools-sessions.mdx +39 -10
- package/docs/tools-snapshot.mdx +57 -3
- package/docs/tools-state.mdx +24 -1
- package/docs/tools-tools-and-run.mdx +55 -24
- package/docs/tools-wait-for.mdx +34 -15
- package/docs/troubleshooting.mdx +80 -10
- package/docs/usage.md +46 -24
- package/docs/vs-chrome-devtools-mcp.mdx +41 -0
- package/docs/vs-playwright-mcp.mdx +42 -2
- package/docs/vs-screenshots.mdx +42 -1
- package/docs/what-is-reticle.mdx +1 -1
- package/docs/why-reticle.mdx +3 -1
- package/package.json +5 -5
- package/docs/desktop-apps.md +0 -265
|
@@ -9,24 +9,127 @@
|
|
|
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 { probeDevServers } from './dev-server-probe.js';
|
|
12
|
+
import { probeDevServers, probeDevServerStates } from './dev-server-probe.js';
|
|
13
13
|
import { diagnoseNoSession } from './no-session-diagnosis.js';
|
|
14
|
-
import {
|
|
14
|
+
import { detectDevCommand } from './dev-command.js';
|
|
15
|
+
import { nextActionFor, renderNextAction } from './no-session-next-action.js';
|
|
16
|
+
import { readProjectFramework, readProjectId, readProjectPort } from '../cli/cli-port.js';
|
|
17
|
+
import { discoverProjectConfigs } from '../cli/config-discovery.js';
|
|
18
|
+
import { hasProjectConnectedBefore, rememberConnected } from './connection-memory.js';
|
|
19
|
+
import { reticleStateHome } from '../daemon/daemon.js';
|
|
20
|
+
import { stallUptime } from '../telemetry/instrumentation-stall.js';
|
|
15
21
|
/** Slow enough to be free, fast enough that a dev server started 15s ago is already reflected. */
|
|
16
22
|
const REFRESH_MS = 15_000;
|
|
17
|
-
/**
|
|
18
|
-
|
|
23
|
+
/** The URL an auto-attach opens. `localhost` for the same reason the probe uses it: both stacks. */
|
|
24
|
+
const LOCALHOST = 'http://localhost';
|
|
25
|
+
/**
|
|
26
|
+
* What the failure path says, always naming the reason rather than swallowing it.
|
|
27
|
+
*
|
|
28
|
+
* An auto-attach that fails silently is the same class of defect as the message it replaces: the
|
|
29
|
+
* agent is told absence and never learns that Reticle tried and could not.
|
|
30
|
+
*/
|
|
31
|
+
function attachFailureClause(port, reason) {
|
|
32
|
+
const collision = /EADDRINUSE|address already in use/i.test(reason);
|
|
33
|
+
return (` Reticle also tried to open ${LOCALHOST}:${String(port)} in a browser it owns and could not: ` +
|
|
34
|
+
`${reason}.` +
|
|
35
|
+
(collision
|
|
36
|
+
? ' That is a port collision — something already holds the port this daemon needs, so the ' +
|
|
37
|
+
'browser Reticle drives could not be started. Stop the other Reticle process and retry, ' +
|
|
38
|
+
'rather than treating this as a problem with the app.'
|
|
39
|
+
: ''));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Start the watch. Returns a stop function; the timer is unref'd so it never holds the daemon up.
|
|
43
|
+
*
|
|
44
|
+
* Exported for the test that pins the after-boot config read below — the daemon itself starts this
|
|
45
|
+
* through `wireSessionScope`.
|
|
46
|
+
*/
|
|
47
|
+
export function startNoSessionWatch(options) {
|
|
19
48
|
const probe = options.probe ?? (() => probeDevServers());
|
|
20
49
|
let listening = [];
|
|
50
|
+
/**
|
|
51
|
+
* Ports that accepted a connection and then answered nothing in time.
|
|
52
|
+
*
|
|
53
|
+
* Tracked beside `listening` rather than folded into it: they are not dev servers as far as the
|
|
54
|
+
* probe knows, so they must not be spent as proof the app is up, and they are not absent either,
|
|
55
|
+
* so the diagnosis must stop telling people to start a server that is already running. A custom
|
|
56
|
+
* `options.probe` returns only the serving set, so this stays empty for injected probes.
|
|
57
|
+
*/
|
|
58
|
+
let slowListeners = [];
|
|
21
59
|
let running = false;
|
|
60
|
+
/** Ports auto-attach has already spent its one attempt on. Bounded: never a loop, never a retry. */
|
|
61
|
+
const attempted = new Set();
|
|
62
|
+
/** Set only when an attempt actually failed, so the diagnosis can say so instead of hiding it. */
|
|
63
|
+
let attachFailure;
|
|
64
|
+
const directory = options.directory ?? process.cwd();
|
|
65
|
+
// The boot answer still counts (it is what the daemon scoped its sessions with), but `.reticle.json`
|
|
66
|
+
// is routinely written by `init` AFTER this daemon started, so re-read rather than cache. See the
|
|
67
|
+
// `initialized` comment below, which this shares.
|
|
68
|
+
const isWired = () => options.initialized || readProjectId(directory) !== undefined;
|
|
69
|
+
// Read at boot: the daemon's own identity does not change under it, and this is the key the
|
|
70
|
+
// durable bit is stored under.
|
|
71
|
+
const stateDir = options.stateDir ?? reticleStateHome();
|
|
72
|
+
/**
|
|
73
|
+
* Only ever asked ABOUT A NAMED PROJECT.
|
|
74
|
+
*
|
|
75
|
+
* With no projectId the memory can still answer the weaker "has anything connected on this port",
|
|
76
|
+
* and that answer must not be spent here: a shared 4400 on a machine with several repos would
|
|
77
|
+
* then soften a genuinely-unwired directory's diagnosis on the strength of an unrelated app. That
|
|
78
|
+
* is the same over-confident claim this whole file exists to remove, pointing the other way.
|
|
79
|
+
*/
|
|
80
|
+
const connectedBefore = () => hasProjectConnectedBefore(stateDir, options.port, readProjectId(directory));
|
|
81
|
+
// Every path that registers a session goes through SessionManager.add, so this is the one hook
|
|
82
|
+
// that makes the bit durable. Recorded per port + projectId so a shared 4400 cannot make one
|
|
83
|
+
// project's success into evidence about another's.
|
|
84
|
+
options.sessions.setConnectionRecorder((projectId) => {
|
|
85
|
+
rememberConnected(stateDir, options.port, projectId);
|
|
86
|
+
});
|
|
87
|
+
/**
|
|
88
|
+
* Open the app ourselves when there is exactly one unambiguous candidate.
|
|
89
|
+
*
|
|
90
|
+
* AUTOMATIC, not offered — the deliberate call. Offering means a round trip and a decision by an
|
|
91
|
+
* agent that has strictly less evidence than this daemon does, on the one case where there is
|
|
92
|
+
* nothing left to decide: the project is wired, so a page WILL connect, and exactly one dev server
|
|
93
|
+
* is listening, so there is no ambiguity about which. That case is the bulk of the loss. Every
|
|
94
|
+
* other shape (several listeners, an unwired project, no listener) still returns prose, because
|
|
95
|
+
* there the daemon would be guessing and a guess that opens a browser is worse than a sentence.
|
|
96
|
+
*
|
|
97
|
+
* Bounded by construction: one attempt per port per daemon. A failure is recorded, never retried —
|
|
98
|
+
* a retry loop against a broken Chromium install would spin for the daemon's whole life.
|
|
99
|
+
*/
|
|
100
|
+
const autoAttach = async (ports) => {
|
|
101
|
+
const attach = options.attach;
|
|
102
|
+
if (attach === undefined)
|
|
103
|
+
return;
|
|
104
|
+
if (1 !== ports.length)
|
|
105
|
+
return;
|
|
106
|
+
const [only] = ports;
|
|
107
|
+
if (only === undefined || attempted.has(only))
|
|
108
|
+
return;
|
|
109
|
+
if (!isWired())
|
|
110
|
+
return;
|
|
111
|
+
attempted.add(only);
|
|
112
|
+
try {
|
|
113
|
+
await attach(`${LOCALHOST}:${String(only)}`);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
attachFailure = attachFailureClause(only, error instanceof Error ? error.message : String(error));
|
|
117
|
+
}
|
|
118
|
+
};
|
|
22
119
|
const refresh = () => {
|
|
23
120
|
// Nothing to diagnose while a session is live, and no reason to scan.
|
|
24
121
|
if (running || options.sessions.count() > 0)
|
|
25
122
|
return;
|
|
26
123
|
running = true;
|
|
27
|
-
void probe
|
|
28
|
-
.then((
|
|
124
|
+
void (options.probe === undefined
|
|
125
|
+
? probeDevServerStates().then((states) => {
|
|
126
|
+
slowListeners = states.slow;
|
|
127
|
+
return states.serving;
|
|
128
|
+
})
|
|
129
|
+
: probe())
|
|
130
|
+
.then(async (ports) => {
|
|
29
131
|
listening = ports;
|
|
132
|
+
await autoAttach(ports);
|
|
30
133
|
})
|
|
31
134
|
.catch(() => {
|
|
32
135
|
/* a diagnostic hint must never take the daemon down */
|
|
@@ -38,27 +141,82 @@ function startNoSessionWatch(options) {
|
|
|
38
141
|
refresh();
|
|
39
142
|
const timer = setInterval(refresh, REFRESH_MS);
|
|
40
143
|
timer.unref();
|
|
144
|
+
const nextAction = () => nextActionFor({
|
|
145
|
+
everConnected: options.sessions.everConnected(),
|
|
146
|
+
initialized: isWired(),
|
|
147
|
+
previouslyConnected: connectedBefore(),
|
|
148
|
+
listening,
|
|
149
|
+
// Read when asked, like everything else here: a `package.json` can gain a dev script, and a
|
|
150
|
+
// daemon that cached "there is none" at boot would keep saying so for the rest of the day.
|
|
151
|
+
dev: detectDevCommand(directory),
|
|
152
|
+
});
|
|
153
|
+
options.sessions.setNoSessionNextAction(nextAction);
|
|
41
154
|
options.sessions.setNoSessionHint(() => diagnoseNoSession({
|
|
42
155
|
everConnected: options.sessions.everConnected(),
|
|
43
|
-
|
|
156
|
+
// Read WHEN ASKED, for the same reason `projectPort` below is: `.reticle.json` is routinely
|
|
157
|
+
// written by `init` after this daemon started — that is the ordinary first-install order — and
|
|
158
|
+
// the boot-time answer is then permanently stale. Reported from the field as `reticle status`
|
|
159
|
+
// saying the project had never been through `init` about a project whose config named its
|
|
160
|
+
// framework and its projectId, and whose real problem was a dev server older than the plugin.
|
|
161
|
+
// The boot value still counts: it is the one the daemon scoped its sessions with.
|
|
162
|
+
initialized: isWired(),
|
|
44
163
|
listening,
|
|
164
|
+
slowListeners,
|
|
45
165
|
port: options.port,
|
|
46
166
|
// The directory `initialized` was decided in. Named in the message because "there is no
|
|
47
167
|
// `.reticle.json`" is a claim about ONE directory, and a reader standing somewhere else
|
|
48
168
|
// cannot tell whether it is a claim about their app at all.
|
|
49
|
-
directory
|
|
169
|
+
directory,
|
|
170
|
+
// Where the config actually is, when it is not here — and where we looked, when it is
|
|
171
|
+
// nowhere. Computed only on the branch that needs it: a wired daemon has its answer already,
|
|
172
|
+
// and a workspace scan on every hint would be a directory walk per tool refusal.
|
|
173
|
+
...(isWired()
|
|
174
|
+
? {}
|
|
175
|
+
: (() => {
|
|
176
|
+
const discovery = discoverProjectConfigs(directory);
|
|
177
|
+
const elsewhere = discovery.found.filter((c) => c.directory !== directory);
|
|
178
|
+
return 0 === elsewhere.length
|
|
179
|
+
? { searchedDirectories: discovery.searched }
|
|
180
|
+
: {
|
|
181
|
+
configsElsewhere: elsewhere.map((c) => ({
|
|
182
|
+
directory: c.directory,
|
|
183
|
+
...(c.projectId === undefined ? {} : { projectId: c.projectId }),
|
|
184
|
+
})),
|
|
185
|
+
};
|
|
186
|
+
})()),
|
|
187
|
+
// The one fact that outranks every absence below it, and the reason a fresh daemon stopped
|
|
188
|
+
// claiming that an install which has demonstrably worked has never worked.
|
|
189
|
+
previouslyConnected: connectedBefore(),
|
|
190
|
+
// Ranks the causes. Read when asked, like the rest: `init` writes this file after the daemon
|
|
191
|
+
// starts on an ordinary first install.
|
|
192
|
+
...(() => {
|
|
193
|
+
const framework = readProjectFramework(directory);
|
|
194
|
+
return framework === undefined ? {} : { framework };
|
|
195
|
+
})(),
|
|
50
196
|
leaseExpired: (options.reapedLeases?.() ?? 0) > 0,
|
|
197
|
+
// How long this daemon has been waiting with no app. The diagnosis uses it to surface
|
|
198
|
+
// "install never finished" — the same condition telemetry already knows about.
|
|
199
|
+
...(() => {
|
|
200
|
+
const upMs = stallUptime(Date.now());
|
|
201
|
+
return upMs === undefined ? {} : { daemonUpMs: upMs };
|
|
202
|
+
})(),
|
|
51
203
|
// Read here rather than at boot: `.reticle.json` can be written by `init` after this daemon
|
|
52
204
|
// started, which is the ordinary first-install order, and a port cached from before it existed
|
|
53
205
|
// would make the daemon confidently report no mismatch on the one run where there is one.
|
|
54
206
|
...(() => {
|
|
55
|
-
const configured = readProjectPort(
|
|
207
|
+
const configured = readProjectPort(directory);
|
|
56
208
|
return configured === undefined ? {} : { projectPort: configured };
|
|
57
209
|
})(),
|
|
58
|
-
})
|
|
210
|
+
}) +
|
|
211
|
+
// Prose for the human, then the literal command for the agent. Appended rather than folded
|
|
212
|
+
// into the diagnosis so that file stays pure and its cases stay independently pinned.
|
|
213
|
+
` ${renderNextAction(nextAction())}` +
|
|
214
|
+
(attachFailure ?? ''));
|
|
59
215
|
return () => {
|
|
60
216
|
clearInterval(timer);
|
|
217
|
+
options.sessions.setConnectionRecorder(undefined);
|
|
61
218
|
options.sessions.setNoSessionHint(undefined);
|
|
219
|
+
options.sessions.setNoSessionNextAction(undefined);
|
|
62
220
|
};
|
|
63
221
|
}
|
|
64
222
|
/**
|
|
@@ -69,7 +227,9 @@ function startNoSessionWatch(options) {
|
|
|
69
227
|
*/
|
|
70
228
|
export function wireSessionScope(sessions, activeProjectId, port,
|
|
71
229
|
/** Reader for the pool's aged-out-lease count; omitted when this daemon runs no pool. */
|
|
72
|
-
reapedLeases
|
|
230
|
+
reapedLeases,
|
|
231
|
+
/** Opens a URL in a Reticle-owned browser (the pool). Omitted ⇒ auto-attach is off. */
|
|
232
|
+
attach) {
|
|
73
233
|
if (activeProjectId !== undefined)
|
|
74
234
|
sessions.setDefaultScope({ projectId: activeProjectId });
|
|
75
235
|
return startNoSessionWatch({
|
|
@@ -77,5 +237,6 @@ reapedLeases) {
|
|
|
77
237
|
port,
|
|
78
238
|
initialized: activeProjectId !== undefined,
|
|
79
239
|
...(reapedLeases === undefined ? {} : { reapedLeases }),
|
|
240
|
+
...(attach === undefined ? {} : { attach }),
|
|
80
241
|
});
|
|
81
242
|
}
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import type { CommandResult } from '@reticlehq/core';
|
|
2
|
+
/**
|
|
3
|
+
* The page was asked and did not answer inside the caller's budget.
|
|
4
|
+
*
|
|
5
|
+
* A distinct type because the two ways a command can fail mean opposite things, and a caller that
|
|
6
|
+
* cannot tell them apart will do the wrong thing with one of them. A rejection from a replaced
|
|
7
|
+
* transport says the question never reached the page and is worth asking again; a timeout says it
|
|
8
|
+
* did reach the page, which did not answer in the time granted — and asking again spends a budget
|
|
9
|
+
* that is already gone. Retrying a timeout as though it were a disconnect turned one read into
|
|
10
|
+
* hundreds of commands on the wire against a page that was reconnecting in a loop.
|
|
11
|
+
*/
|
|
12
|
+
export declare class CommandTimeoutError extends Error {
|
|
13
|
+
}
|
|
2
14
|
export declare class PendingCommands {
|
|
3
15
|
#private;
|
|
4
16
|
/** Next correlation id. Monotonic per session, so a reply can never match an earlier command. */
|
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page was asked and did not answer inside the caller's budget.
|
|
3
|
+
*
|
|
4
|
+
* A distinct type because the two ways a command can fail mean opposite things, and a caller that
|
|
5
|
+
* cannot tell them apart will do the wrong thing with one of them. A rejection from a replaced
|
|
6
|
+
* transport says the question never reached the page and is worth asking again; a timeout says it
|
|
7
|
+
* did reach the page, which did not answer in the time granted — and asking again spends a budget
|
|
8
|
+
* that is already gone. Retrying a timeout as though it were a disconnect turned one read into
|
|
9
|
+
* hundreds of commands on the wire against a page that was reconnecting in a loop.
|
|
10
|
+
*/
|
|
11
|
+
export class CommandTimeoutError extends Error {
|
|
12
|
+
}
|
|
1
13
|
export class PendingCommands {
|
|
2
14
|
#pending = new Map();
|
|
3
15
|
#seq = 0;
|
|
@@ -11,7 +23,7 @@ export class PendingCommands {
|
|
|
11
23
|
return new Promise((resolve, reject) => {
|
|
12
24
|
const timer = setTimeout(() => {
|
|
13
25
|
this.#pending.delete(id);
|
|
14
|
-
reject(new
|
|
26
|
+
reject(new CommandTimeoutError(describeTimeout()));
|
|
15
27
|
}, timeoutMs);
|
|
16
28
|
timer.unref();
|
|
17
29
|
this.#pending.set(id, { resolve, reject, timer });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Session, type SessionInfo } from './session.js';
|
|
2
2
|
import { AttachmentHistory } from './attachment-history.js';
|
|
3
|
+
import type { NoSessionNextAction } from './no-session-next-action.js';
|
|
3
4
|
/**
|
|
4
5
|
* The agent's active project, used to scope auto-selection. `projectId` is the stable build-stamped
|
|
5
6
|
* identity (authoritative when present); `url` is the app origin, a fallback hint for older SDKs that
|
|
@@ -15,6 +16,7 @@ export declare class SessionManager {
|
|
|
15
16
|
#private;
|
|
16
17
|
/** Set the active project's default resolve scope (called once at daemon wiring). */
|
|
17
18
|
setDefaultScope(scope: ResolveScope | undefined): void;
|
|
19
|
+
setConnectionRecorder(record: ((projectId: string | undefined) => void) | undefined): void;
|
|
18
20
|
add(session: Session): Session | undefined;
|
|
19
21
|
remove(session: Session): boolean;
|
|
20
22
|
get(sessionId: string): Session | undefined;
|
|
@@ -35,6 +37,8 @@ export declare class SessionManager {
|
|
|
35
37
|
* already computed for the error path; this exposes it to the read path too.
|
|
36
38
|
*/
|
|
37
39
|
noSessionHint(): string | undefined;
|
|
40
|
+
setNoSessionNextAction(next: (() => NoSessionNextAction | undefined) | undefined): void;
|
|
41
|
+
noSessionNextAction(): NoSessionNextAction | undefined;
|
|
38
42
|
everConnected(): boolean;
|
|
39
43
|
/** Record a bridge-initiated close so `resolve` can explain a session that vanished. */
|
|
40
44
|
noteClosure(reason: string, at: number): void;
|
|
@@ -79,8 +79,20 @@ export class SessionManager {
|
|
|
79
79
|
setDefaultScope(scope) {
|
|
80
80
|
this.#defaultScope = scope;
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Told about every session that registers, so the fact outlives this process.
|
|
84
|
+
*
|
|
85
|
+
* Injected rather than done here: this class owns a registry, not the filesystem. Wired here
|
|
86
|
+
* because `add` is the ONE method every path that registers a session goes through — the same
|
|
87
|
+
* reasoning that put the redaction declaration below it.
|
|
88
|
+
*/
|
|
89
|
+
#recordConnection;
|
|
90
|
+
setConnectionRecorder(record) {
|
|
91
|
+
this.#recordConnection = record;
|
|
92
|
+
}
|
|
82
93
|
add(session) {
|
|
83
94
|
this.#everConnected = true;
|
|
95
|
+
this.#recordConnection?.(session.projectId);
|
|
84
96
|
const previous = this.#sessions.get(session.id);
|
|
85
97
|
this.#sessions.set(session.id, session);
|
|
86
98
|
// Publish what this app declared sensitive to the driven-path rule. Here rather than in the
|
|
@@ -172,6 +184,20 @@ export class SessionManager {
|
|
|
172
184
|
noSessionHint() {
|
|
173
185
|
return this.#noSessionHint?.();
|
|
174
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* The same diagnosis with the prose taken out — one action kind and one literal command.
|
|
189
|
+
*
|
|
190
|
+
* Separate from the hint rather than folded into it because the two have different consumers and
|
|
191
|
+
* different lifetimes: `resolve` throws an Error, which can only ever carry a string, while
|
|
192
|
+
* `reticle_sessions` returns a structured payload an agent can branch on without parsing English.
|
|
193
|
+
*/
|
|
194
|
+
#noSessionNextAction;
|
|
195
|
+
setNoSessionNextAction(next) {
|
|
196
|
+
this.#noSessionNextAction = next;
|
|
197
|
+
}
|
|
198
|
+
noSessionNextAction() {
|
|
199
|
+
return this.#noSessionNextAction?.();
|
|
200
|
+
}
|
|
175
201
|
/** Whether any session has connected since this daemon booted — half the diagnosis. */
|
|
176
202
|
#everConnected = false;
|
|
177
203
|
everConnected() {
|
|
@@ -224,11 +250,15 @@ export class SessionManager {
|
|
|
224
250
|
const closure = this.lastClosure();
|
|
225
251
|
// A diagnosis beats a checklist: it names which of the three causes this actually is.
|
|
226
252
|
const hint = this.#noSessionHint?.();
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
253
|
+
// BOTH, when there are both — the two answer different questions and the pair is strictly
|
|
254
|
+
// more than either. This used to be an either/or, and a recorded refusal DISCARDED the whole
|
|
255
|
+
// diagnosis: the one case where the daemon has hard evidence about why nothing is connected
|
|
256
|
+
// was also the one case where it threw away everything else it had learned, including the
|
|
257
|
+
// port scan and the next action. The refusal leads, because it is the fact.
|
|
258
|
+
const refusal = closure === undefined
|
|
259
|
+
? ''
|
|
260
|
+
: ` NOTE: the bridge REFUSED or closed a connection recently — "${closure.reason}". The app is probably still running and trying to connect: it was turned away, and the SDK does not retry after a policy close. The reason above names the fix — do not go looking for a stopped dev server.`;
|
|
261
|
+
throw new Error(`${hint ?? NO_SESSION_CONNECTED_ERROR}${refusal}`);
|
|
232
262
|
}
|
|
233
263
|
// Scope to the agent's active project FIRST, so a stray tab from another app/origin (e.g. a
|
|
234
264
|
// leftover dashboard on a different port) is structurally unselectable — it never enters the
|
|
@@ -11,7 +11,7 @@ const DEFAULT_POLL_MS = 25;
|
|
|
11
11
|
*/
|
|
12
12
|
export const RETICLE_LOOP_GUIDE = 'Reticle loop — LOOK: reticle_snapshot / reticle_query / reticle_inspect · ACT: reticle_act (or reticle_act_and_wait) · ' +
|
|
13
13
|
'OBSERVE: reticle_observe / reticle_wait_for / reticle_network / reticle_console · ASSERT: reticle_assert over program ' +
|
|
14
|
-
'truth, not just the DOM · REGRESS: reticle_record{action:"start"} → reticle_replay, or
|
|
14
|
+
'truth, not just the DOM · REGRESS: reticle_record{action:"start"} → reticle_replay, or reticle_verify{action:"flows"} for the whole ' +
|
|
15
15
|
'suite. The human can flag bugs from the panel — drain them with reticle_session{action:"review"} and resolve each once fixed. ' +
|
|
16
16
|
'MANDATORY: the moment you stop driving — finishing your reply or waiting on the human — call reticle_session {action:"yield"} ' +
|
|
17
17
|
'(mode:"waiting", or mode:"ask" with the question) so the panel shows your real state; reticle_session{action:"end"} ' +
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { WebSocket } from 'ws';
|
|
2
|
+
import type { ImpactSnapshot } from '@reticlehq/core';
|
|
2
3
|
import { LastAct } from './last-act.js';
|
|
3
4
|
import { type PageRuntime } from './command-timeout.js';
|
|
4
5
|
import { type SessionHealth } from './session-health.js';
|
|
@@ -148,6 +149,7 @@ export declare class Session {
|
|
|
148
149
|
handleResult(result: CommandResult): void;
|
|
149
150
|
/** Reject everything still in flight — used on disconnect. */
|
|
150
151
|
rejectAll(reason: string): void;
|
|
152
|
+
succeededBy(next: Session): void;
|
|
151
153
|
/** End this transport without letting a stale socket remove its replacement session. */
|
|
152
154
|
disconnect(reason: string): void;
|
|
153
155
|
getState(): SessionState;
|
|
@@ -184,8 +186,15 @@ export declare class Session {
|
|
|
184
186
|
* Push a lifecycle state to the panel with optional human-facing `text`. State changes still flow
|
|
185
187
|
* through `setState`; an auto-ended session rides a `warn` tone so the panel can shout "agent stopped".
|
|
186
188
|
*/
|
|
189
|
+
/**
|
|
190
|
+
* Push the impact record to this tab's HUD.
|
|
191
|
+
*
|
|
192
|
+
* Debounced: the record changes on every tool call, and the panel does not need 200 frames to
|
|
193
|
+
* show a counter going up. The LAST state always lands, because the timer re-reads the store
|
|
194
|
+
* when it fires rather than capturing a snapshot when it was scheduled.
|
|
195
|
+
*/
|
|
196
|
+
pushImpact(read: () => ImpactSnapshot | undefined, immediate?: boolean): void;
|
|
187
197
|
pushPresenter(state: SessionState, text?: string, tone?: PresenterTone): void;
|
|
188
|
-
/** Fire-and-forget a narration row to the live panel (so a resolved mark shows "✓ fixed"). */
|
|
189
198
|
pushNarration(text: string): void;
|
|
190
199
|
/** The one-time lease block on the first agent command, then undefined forever after. */
|
|
191
200
|
takeSessionLease(): SessionLease | undefined;
|
package/dist/session/session.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { recordImpact } from '../impact/impact-recorder.js';
|
|
1
2
|
import { LastAct } from './last-act.js';
|
|
2
3
|
import { commandTimeoutMessage } from './command-timeout.js';
|
|
3
4
|
import { readHealthEvent } from './session-health.js';
|
|
4
|
-
|
|
5
|
+
/** The HUD does not need 200 frames to watch a counter climb; the last state always lands. */
|
|
6
|
+
const IMPACT_PUSH_DEBOUNCE_MS = 700;
|
|
7
|
+
import { PendingCommands, CommandTimeoutError } from './pending-commands.js';
|
|
5
8
|
import { span } from '../trace.js';
|
|
6
9
|
import { AppRuntime, EventType, HumanControlDataSchema, HumanControlKind, HumanMarkDataSchema, ReticleCommand, MessageKind, parseEventPayload, PresenterTone, SESSION_HEALTH, SESSION_LEASE, SESSION_LIFECYCLE, SessionState, } from '@reticlehq/core';
|
|
7
10
|
import { RingBuffer } from '../events/ring-buffer.js';
|
|
@@ -18,6 +21,33 @@ import { buildSessionInfo } from './session-info.js';
|
|
|
18
21
|
const DEFAULT_COMMAND_TIMEOUT_MS = 8000;
|
|
19
22
|
/** Prefix on correlated command ids (c1, c2, …) — distinguishes them from mark ids. */
|
|
20
23
|
const COMMAND_ID_PREFIX = 'c';
|
|
24
|
+
/**
|
|
25
|
+
* Commands that may be re-issued against the connection that replaced this session.
|
|
26
|
+
*
|
|
27
|
+
* Reads only, and the boundary is the point. Re-reading a page costs nothing and answers the same
|
|
28
|
+
* question the caller asked. An act cannot be replayed: it may already have been dispatched in the
|
|
29
|
+
* page that went away, and performing it a second time behind the caller's back is a double submit
|
|
30
|
+
* nobody asked for. A replaced act still errors, and the caller's own retry — with the same id,
|
|
31
|
+
* which is still the right one — is the safe path.
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* How far to walk a chain of replacements before giving up.
|
|
35
|
+
*
|
|
36
|
+
* Generous for the real case (a page reloading a handful of times while a call is in flight) and
|
|
37
|
+
* small enough that a cycle costs nothing. The number is a backstop, not a policy: a chain longer
|
|
38
|
+
* than this means something is re-dialling in a loop, and that is a different problem.
|
|
39
|
+
*/
|
|
40
|
+
const MAX_SUCCESSOR_HOPS = 32;
|
|
41
|
+
const REBINDABLE_COMMANDS = new Set([
|
|
42
|
+
ReticleCommand.SNAPSHOT,
|
|
43
|
+
ReticleCommand.QUERY,
|
|
44
|
+
ReticleCommand.MATCH,
|
|
45
|
+
ReticleCommand.INSPECT,
|
|
46
|
+
ReticleCommand.STATE_READ,
|
|
47
|
+
ReticleCommand.STORAGE_READ,
|
|
48
|
+
ReticleCommand.CAPABILITIES,
|
|
49
|
+
ReticleCommand.ANIMATIONS,
|
|
50
|
+
]);
|
|
21
51
|
/** Prefix on minted action ids (a1, a2, …) — the journal's action identity, independent of commands. */
|
|
22
52
|
const ACTION_ID_PREFIX = 'a';
|
|
23
53
|
/** ws readyState for an OPEN socket — guard fire-and-forget pushes against a closing tab. */
|
|
@@ -192,8 +222,12 @@ export class Session {
|
|
|
192
222
|
if (event.type === EventType.HUMAN_MARK) {
|
|
193
223
|
// A human pinned a mistake to an element. Narrow at the boundary; an invalid mark is ignored.
|
|
194
224
|
const parsed = HumanMarkDataSchema.safeParse(event.data);
|
|
195
|
-
if (parsed.success)
|
|
225
|
+
if (parsed.success) {
|
|
196
226
|
this.#review.add(parsed.data, this.elapsed());
|
|
227
|
+
// The impact record's `marks` field existed and nothing ever wrote it: the report would
|
|
228
|
+
// have shown a permanent zero next to a page covered in pins.
|
|
229
|
+
recordImpact({ marks: 1 });
|
|
230
|
+
}
|
|
197
231
|
}
|
|
198
232
|
if (event.type === EventType.ROUTE_CHANGE) {
|
|
199
233
|
// Keep the reported URL live across SPA navigation. The SDK already emits route.change on
|
|
@@ -215,8 +249,18 @@ export class Session {
|
|
|
215
249
|
: seen;
|
|
216
250
|
this.#observed.observe(attributed);
|
|
217
251
|
this.#buffer.push(attributed, t, byteSize);
|
|
218
|
-
|
|
219
|
-
|
|
252
|
+
// Each subscriber is independent, and this runs inside the websocket's `message` handler — a
|
|
253
|
+
// NON-async callback, so a sync throw here escapes every try/catch the tool call is wrapped in
|
|
254
|
+
// and becomes an uncaughtException, which the daemon answers by exiting. One observer's bug must
|
|
255
|
+
// not cost the fleet its daemon. Same rule the session-ready fan-out already follows.
|
|
256
|
+
for (const listener of this.#listeners) {
|
|
257
|
+
try {
|
|
258
|
+
listener(attributed);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
/* an event observer must never break the session it observes */
|
|
262
|
+
}
|
|
263
|
+
}
|
|
220
264
|
}
|
|
221
265
|
// Passthroughs to ObservedState, which owns the per-session facts that must SURVIVE buffer
|
|
222
266
|
// eviction: refs the agent drove, and what the layer could not see. See observed-state.ts.
|
|
@@ -397,6 +441,12 @@ export class Session {
|
|
|
397
441
|
if ('string' === typeof ref)
|
|
398
442
|
this.recordActedRef(ref);
|
|
399
443
|
}
|
|
444
|
+
// The page already re-dialled under this same id. Nothing has been sent yet, so handing the
|
|
445
|
+
// command to the live connection cannot perform anything twice — it is the call the agent would
|
|
446
|
+
// have made itself after a `reticle_sessions` round trip, minus the round trip.
|
|
447
|
+
const successor = this.#liveSuccessor();
|
|
448
|
+
if (successor !== undefined)
|
|
449
|
+
return successor.command(name, args, timeoutMs);
|
|
400
450
|
const id = this.#pending.nextId(COMMAND_ID_PREFIX);
|
|
401
451
|
const payload = JSON.stringify({
|
|
402
452
|
kind: MessageKind.COMMAND,
|
|
@@ -425,7 +475,27 @@ export class Session {
|
|
|
425
475
|
this.recordActedLabelFrom(result);
|
|
426
476
|
return result;
|
|
427
477
|
})
|
|
428
|
-
.finally(() => recordBrowserLatency(Date.now() - sentAt))
|
|
478
|
+
.finally(() => recordBrowserLatency(Date.now() - sentAt))
|
|
479
|
+
.catch((error) => {
|
|
480
|
+
// The replacement landed while this command was on the wire, which is the reported case:
|
|
481
|
+
// `reticle_navigate` reloads the page, the reload sends a fresh HELLO carrying the same id,
|
|
482
|
+
// and the `reticle_snapshot` already in flight is rejected by the displaced session.
|
|
483
|
+
// A TIMEOUT is not a replacement. The catch fired on any rejection, so a command that had
|
|
484
|
+
// simply not been answered in budget was read as "you were replaced, ask again" — and
|
|
485
|
+
// against a page re-dialling in a loop that turned one read into hundreds of commands on the
|
|
486
|
+
// wire, minutes of wall clock, and an answer still quoting the budget it had blown.
|
|
487
|
+
const next = error instanceof CommandTimeoutError ? undefined : this.#liveSuccessor();
|
|
488
|
+
if (next === undefined || !REBINDABLE_COMMANDS.has(name))
|
|
489
|
+
throw error;
|
|
490
|
+
// What is LEFT of the caller's budget, not a fresh copy of it. Re-issuing with the original
|
|
491
|
+
// timeout means a caller who granted 8s can wait 16, and across a chain of replacements the
|
|
492
|
+
// total is unbounded — a wait nobody asked for, on the path that exists to save a round trip.
|
|
493
|
+
// A budget already spent means there is nothing left to retry into, so the error stands.
|
|
494
|
+
const remaining = timeoutMs - (Date.now() - sentAt);
|
|
495
|
+
if (remaining <= 0)
|
|
496
|
+
throw error;
|
|
497
|
+
return next.command(name, args, remaining);
|
|
498
|
+
});
|
|
429
499
|
}
|
|
430
500
|
handleResult(result) {
|
|
431
501
|
this.#pending.settle(result);
|
|
@@ -437,8 +507,52 @@ export class Session {
|
|
|
437
507
|
listener();
|
|
438
508
|
this.#disconnectListeners.clear();
|
|
439
509
|
}
|
|
510
|
+
/**
|
|
511
|
+
* The connection that took this session's id over, once one has.
|
|
512
|
+
*
|
|
513
|
+
* Set by the bridge at the moment it displaces this session, so the two facts a replaced handle
|
|
514
|
+
* needs — that it was replaced, and by whom — arrive together. Without it, every tool call holding
|
|
515
|
+
* this handle could only report "session replaced by a newer connection claiming the same id", and
|
|
516
|
+
* the caller's only recovery was `reticle_sessions` plus a retry: two round trips to rediscover an
|
|
517
|
+
* id the daemon was already holding, and which had not even changed.
|
|
518
|
+
*/
|
|
519
|
+
#successor;
|
|
520
|
+
succeededBy(next) {
|
|
521
|
+
// A session is never its own replacement; guarding here keeps `command` from recursing forever
|
|
522
|
+
// if a registry ever re-adds the same object.
|
|
523
|
+
if (next !== this)
|
|
524
|
+
this.#successor = next;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* The replacement, but only while it can actually answer.
|
|
528
|
+
*
|
|
529
|
+
* A successor is recorded once and never cleared, so without this a session that was replaced an
|
|
530
|
+
* hour ago keeps handing commands to a socket that has since closed — and the caller pays the full
|
|
531
|
+
* timeout to learn it, turning an instant "session replaced" into a wait. The point of delegating
|
|
532
|
+
* was to save the caller a round trip; delegating to a dead socket costs them more than the error
|
|
533
|
+
* did.
|
|
534
|
+
*/
|
|
535
|
+
#liveSuccessor() {
|
|
536
|
+
// Walked, not just read one hop. Two replacements in quick succession — a page that reloads
|
|
537
|
+
// twice — leave a CHAIN, and the handle the caller holds points at a session that is itself
|
|
538
|
+
// already displaced. Stopping at the first dead link fails safe and also fails: the open page is
|
|
539
|
+
// one hop further on, and the caller gets back exactly the error this exists to remove.
|
|
540
|
+
//
|
|
541
|
+
// Bounded, because `succeededBy` refuses self-succession but nothing prevents a cycle, and an
|
|
542
|
+
// unbounded walk over one is a hang — which is worse than the error it was trying to avoid.
|
|
543
|
+
let next = this.#successor;
|
|
544
|
+
for (let hops = 0; next !== undefined && hops < MAX_SUCCESSOR_HOPS; hops += 1) {
|
|
545
|
+
if (next.#socket.readyState === WS_OPEN)
|
|
546
|
+
return next;
|
|
547
|
+
next = next.#successor;
|
|
548
|
+
}
|
|
549
|
+
return undefined;
|
|
550
|
+
}
|
|
440
551
|
/** End this transport without letting a stale socket remove its replacement session. */
|
|
441
552
|
disconnect(reason) {
|
|
553
|
+
// "Longest run" means the longest SESSION, and it used to be fed a single tool call's duration
|
|
554
|
+
// - so the report's superlative was a number in milliseconds that never grew past a click.
|
|
555
|
+
recordImpact({}, { runMs: this.elapsed() });
|
|
442
556
|
this.rejectAll(reason);
|
|
443
557
|
try {
|
|
444
558
|
this.#socket.close(1008, reason);
|
|
@@ -515,10 +629,37 @@ export class Session {
|
|
|
515
629
|
* Push a lifecycle state to the panel with optional human-facing `text`. State changes still flow
|
|
516
630
|
* through `setState`; an auto-ended session rides a `warn` tone so the panel can shout "agent stopped".
|
|
517
631
|
*/
|
|
632
|
+
/**
|
|
633
|
+
* Push the impact record to this tab's HUD.
|
|
634
|
+
*
|
|
635
|
+
* Debounced: the record changes on every tool call, and the panel does not need 200 frames to
|
|
636
|
+
* show a counter going up. The LAST state always lands, because the timer re-reads the store
|
|
637
|
+
* when it fires rather than capturing a snapshot when it was scheduled.
|
|
638
|
+
*/
|
|
639
|
+
pushImpact(read, immediate = false) {
|
|
640
|
+
if (immediate) {
|
|
641
|
+
// A tab that just connected has nothing to show yet, so its first record goes out at once -
|
|
642
|
+
// waiting a debounce here is a report that reads "nothing recorded" over a month of history.
|
|
643
|
+
const first = read();
|
|
644
|
+
if (first !== undefined)
|
|
645
|
+
this.#post(ReticleCommand.IMPACT, { snapshot: first });
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (this.#impactTimer !== undefined)
|
|
649
|
+
return;
|
|
650
|
+
this.#impactTimer = setTimeout(() => {
|
|
651
|
+
this.#impactTimer = undefined;
|
|
652
|
+
const snapshot = read();
|
|
653
|
+
if (snapshot !== undefined)
|
|
654
|
+
this.#post(ReticleCommand.IMPACT, { snapshot });
|
|
655
|
+
}, IMPACT_PUSH_DEBOUNCE_MS);
|
|
656
|
+
this.#impactTimer.unref?.();
|
|
657
|
+
}
|
|
518
658
|
pushPresenter(state, text, tone) {
|
|
519
659
|
this.#post(ReticleCommand.PRESENTER, buildPresenterArgs(state, text, tone));
|
|
520
660
|
}
|
|
521
661
|
/** Fire-and-forget a narration row to the live panel (so a resolved mark shows "✓ fixed"). */
|
|
662
|
+
#impactTimer;
|
|
522
663
|
pushNarration(text) {
|
|
523
664
|
this.#post(ReticleCommand.NARRATE, { text, level: 'info' });
|
|
524
665
|
}
|
|
@@ -33,5 +33,13 @@ export interface InstrumentationFacts {
|
|
|
33
33
|
* never be the reason a session fails to register.
|
|
34
34
|
*/
|
|
35
35
|
export declare function reportAppInstrumented(facts: InstrumentationFacts, now?: () => number): void;
|
|
36
|
+
/**
|
|
37
|
+
* Has an app connected to this daemon run at all?
|
|
38
|
+
*
|
|
39
|
+
* The same flag `reportAppInstrumented` uses for idempotency, read from outside. The stall check
|
|
40
|
+
* needs exactly this question and must not answer it from a window counter, which resets on every
|
|
41
|
+
* flush and would then report a long-instrumented daemon as freshly stalled.
|
|
42
|
+
*/
|
|
43
|
+
export declare function appEverConnected(): boolean;
|
|
36
44
|
/** Tests only. */
|
|
37
45
|
export declare function resetAppInstrumented(): void;
|
|
@@ -47,6 +47,16 @@ export function reportAppInstrumented(facts, now = () => Date.now()) {
|
|
|
47
47
|
/* never let a metric interfere with a page connecting */
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Has an app connected to this daemon run at all?
|
|
52
|
+
*
|
|
53
|
+
* The same flag `reportAppInstrumented` uses for idempotency, read from outside. The stall check
|
|
54
|
+
* needs exactly this question and must not answer it from a window counter, which resets on every
|
|
55
|
+
* flush and would then report a long-instrumented daemon as freshly stalled.
|
|
56
|
+
*/
|
|
57
|
+
export function appEverConnected() {
|
|
58
|
+
return reported;
|
|
59
|
+
}
|
|
50
60
|
/** Tests only. */
|
|
51
61
|
export function resetAppInstrumented() {
|
|
52
62
|
reported = false;
|