@reticlehq/server 2.11.0 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +26 -2
- package/dist/bridge/bridge.d.ts +0 -13
- package/dist/bridge/bridge.js +1 -1
- package/dist/bridge/bridge.test-harness.d.ts +2 -0
- package/dist/bridge/bridge.test-harness.js +8 -1
- package/dist/capsule/capsule-store.d.ts +2 -1
- package/dist/capsule/capsule-store.js +1 -1
- package/dist/capsule/causal-summary.d.ts +2 -2
- package/dist/chromium-launch-options.d.ts +28 -0
- package/dist/chromium-launch-options.js +29 -0
- package/dist/cli/cli-doctor.js +8 -0
- package/dist/cli/cli-flow-commands.d.ts +3 -1
- package/dist/cli/cli-flow-commands.js +34 -4
- package/dist/cli/cli-kill.d.ts +2 -1
- package/dist/cli/cli-parse.d.ts +3 -1
- package/dist/cli/cli-parse.js +29 -7
- package/dist/cli/cli-update-commands.js +20 -1
- package/dist/cli/cli-verify.d.ts +1 -1
- package/dist/cli/cli-verify.js +2 -2
- package/dist/cli/cloud-cli.js +12 -6
- package/dist/cli/config-discovery.d.ts +2 -1
- package/dist/cli/doctor-rows.d.ts +3 -1
- package/dist/cli/doctor-rows.js +3 -1
- package/dist/cli/gate-exit.d.ts +21 -0
- package/dist/cli/gate-exit.js +21 -0
- package/dist/cli/gate-hook-message.d.ts +33 -0
- package/dist/cli/gate-hook-message.js +46 -0
- package/dist/cli/mcp-command.d.ts +25 -0
- package/dist/cli/mcp-command.js +143 -0
- package/dist/cli/sibling-ports.d.ts +37 -0
- package/dist/cli/sibling-ports.js +56 -0
- package/dist/cli.js +37 -110
- package/dist/cloud/cloud-config.d.ts +2 -1
- package/dist/cloud/cloud-sync.d.ts +11 -0
- package/dist/cloud/cloud-sync.js +32 -0
- package/dist/crawl/crawl.d.ts +9 -13
- package/dist/crawl/crawl.js +20 -3
- package/dist/daemon/daemon-resolve.d.ts +79 -0
- package/dist/daemon/daemon-resolve.js +118 -0
- package/dist/daemon/free-port.d.ts +29 -0
- package/dist/daemon/free-port.js +55 -0
- package/dist/daemon/heartbeat.d.ts +0 -4
- package/dist/daemon/heartbeat.js +2 -2
- package/dist/dev/stale-issue-guard.d.ts +0 -2
- package/dist/dev/stale-issue-guard.js +1 -1
- package/dist/events/contradictions.d.ts +41 -0
- package/dist/events/contradictions.js +125 -12
- package/dist/events/declared.d.ts +14 -0
- package/dist/events/declared.js +33 -0
- package/dist/events/echo-mismatch.js +14 -0
- package/dist/events/observed-in-window.d.ts +12 -0
- package/dist/events/observed-in-window.js +17 -0
- package/dist/events/predicate-asks.d.ts +13 -0
- package/dist/events/predicate-asks.js +22 -0
- package/dist/events/predicate-eval.js +64 -22
- package/dist/events/predicate-parse.d.ts +0 -6
- package/dist/events/predicate-parse.js +43 -3
- package/dist/events/predicate-schema.d.ts +44 -0
- package/dist/events/predicate-schema.js +126 -23
- package/dist/events/predicate.d.ts +15 -0
- package/dist/events/predicate.js +41 -2
- package/dist/flows/coverage.d.ts +0 -8
- package/dist/flows/coverage.js +1 -1
- package/dist/flows/decision.d.ts +5 -1
- package/dist/flows/decision.js +33 -7
- package/dist/flows/flake.d.ts +0 -2
- package/dist/flows/flake.js +1 -1
- package/dist/flows/flow-intent.d.ts +52 -0
- package/dist/flows/flow-intent.js +123 -0
- package/dist/flows/flow-replay-run.js +27 -4
- package/dist/flows/flow-success.js +15 -3
- package/dist/flows/flow-tools.js +32 -4
- package/dist/flows/flows.d.ts +6 -1
- package/dist/flows/flows.js +34 -22
- package/dist/flows/predicate-to-expect.js +6 -3
- package/dist/flows/server-verify.js +16 -2
- package/dist/flows/verify-change-tools.js +4 -1
- package/dist/honesty/blind-spots.d.ts +0 -17
- package/dist/honesty/blind-spots.js +1 -1
- package/dist/honesty/feature-capture.d.ts +150 -0
- package/dist/honesty/feature-capture.js +233 -0
- package/dist/honesty/instrumentation-gaps.d.ts +30 -6
- package/dist/honesty/instrumentation-gaps.js +61 -5
- package/dist/honesty/tool-hit-rate.d.ts +86 -0
- package/dist/honesty/tool-hit-rate.js +90 -0
- package/dist/honesty/undeclared-change.d.ts +51 -0
- package/dist/honesty/undeclared-change.js +56 -0
- package/dist/honesty/verified.d.ts +15 -0
- package/dist/honesty/verified.js +68 -4
- package/dist/http-server.d.ts +0 -1
- package/dist/http-server.js +1 -1
- package/dist/hunt/hunt-report.d.ts +0 -2
- package/dist/hunt/hunt-report.js +1 -1
- package/dist/impact/impact-store.d.ts +0 -5
- package/dist/impact/impact-store.js +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/init/agent-rules.d.ts +17 -0
- package/dist/init/agent-rules.js +31 -1
- package/dist/init/capabilities.d.ts +0 -2
- package/dist/init/capabilities.js +1 -1
- package/dist/init/closing-hint.d.ts +36 -0
- package/dist/init/closing-hint.js +120 -0
- package/dist/init/confirm.d.ts +0 -25
- package/dist/init/confirm.js +3 -3
- package/dist/init/cra.d.ts +14 -1
- package/dist/init/cra.js +43 -12
- package/dist/init/dev-script.d.ts +48 -0
- package/dist/init/dev-script.js +63 -0
- package/dist/init/mcp.d.ts +16 -5
- package/dist/init/mcp.js +26 -10
- package/dist/init/plan-framework.d.ts +0 -5
- package/dist/init/plan-framework.js +83 -9
- package/dist/init/plan.d.ts +10 -30
- package/dist/init/plan.js +30 -15
- package/dist/init/refresh-rules.d.ts +39 -0
- package/dist/init/refresh-rules.js +51 -0
- package/dist/init/run.d.ts +0 -26
- package/dist/init/run.js +14 -87
- package/dist/init/snippets.js +53 -10
- package/dist/input/real-input.d.ts +2 -0
- package/dist/input/real-input.js +3 -2
- package/dist/input/scroll-find.d.ts +7 -4
- package/dist/input/scroll-find.js +63 -25
- package/dist/intent/inline-intent.d.ts +46 -0
- package/dist/intent/inline-intent.js +103 -0
- package/dist/intent/intent-store.d.ts +37 -0
- package/dist/intent/intent-store.js +112 -0
- package/dist/intent/intent-tools.d.ts +2 -0
- package/dist/intent/intent-tools.js +80 -0
- package/dist/intent/open-intents.d.ts +21 -0
- package/dist/intent/open-intents.js +25 -0
- package/dist/journal/envelope.d.ts +26 -26
- package/dist/journal/journal-recorder.d.ts +23 -0
- package/dist/journal/journal-recorder.js +35 -0
- package/dist/mcp/attach-memory.d.ts +67 -0
- package/dist/mcp/attach-memory.js +142 -0
- package/dist/mcp/mcp-proxy.d.ts +0 -7
- package/dist/mcp/mcp-proxy.js +14 -2
- package/dist/mcp/mcp.d.ts +2 -0
- package/dist/mcp/mcp.js +45 -23
- package/dist/mcp/server-instructions.js +45 -3
- package/dist/mcp/tool-catalog-cache.d.ts +8 -0
- package/dist/mcp/tool-catalog-cache.js +10 -0
- package/dist/oracles/flow-instrument-gaps.d.ts +4 -4
- package/dist/oracles/flow-instrument-gaps.js +4 -5
- package/dist/oracles/self-instrument.d.ts +9 -9
- package/dist/oracles/self-instrument.js +19 -13
- package/dist/pool/browser-pool.d.ts +12 -1
- package/dist/pool/browser-pool.js +31 -1
- package/dist/pool/playwright-launcher.js +3 -1
- package/dist/project/fs-port.d.ts +3 -0
- package/dist/project/fs-port.js +3 -2
- package/dist/project/memory-fs.js +6 -1
- package/dist/project/project-tools.js +3 -32
- package/dist/project/reticle-dir.d.ts +2 -0
- package/dist/project/reticle-dir.js +1 -0
- package/dist/runs/context-tools.d.ts +2 -0
- package/dist/runs/context-tools.js +89 -0
- package/dist/runs/run-context.d.ts +40 -0
- package/dist/runs/run-context.js +158 -0
- package/dist/runs/run-diff.d.ts +2 -1
- package/dist/runs/verification-sync.js +2 -2
- package/dist/session/dev-server-probe.d.ts +0 -8
- package/dist/session/dev-server-probe.js +1 -1
- package/dist/session/last-act.d.ts +7 -1
- package/dist/session/last-act.js +2 -2
- package/dist/session/live-control-tools.js +1 -42
- package/dist/session/no-session-diagnosis.d.ts +10 -0
- package/dist/session/no-session-diagnosis.js +24 -7
- package/dist/session/no-session-next-action.d.ts +5 -0
- package/dist/session/no-session-next-action.js +35 -3
- package/dist/session/no-session-watch.d.ts +8 -0
- package/dist/session/no-session-watch.js +82 -64
- package/dist/session/output-budget.js +4 -1
- package/dist/session/session-health.d.ts +9 -0
- package/dist/session/session-health.js +23 -0
- package/dist/session/session-manager.js +50 -1
- package/dist/session/session-successor.d.ts +37 -0
- package/dist/session/session-successor.js +78 -0
- package/dist/session/session.d.ts +31 -1
- package/dist/session/session.js +75 -2
- package/dist/telemetry/automation-hint.d.ts +2 -1
- package/dist/telemetry/feedback-outbox.d.ts +2 -1
- package/dist/telemetry/project-profile.d.ts +0 -8
- package/dist/telemetry/project-profile.js +1 -1
- package/dist/telemetry/run-telemetry.d.ts +0 -2
- package/dist/telemetry/run-telemetry.js +1 -1
- package/dist/telemetry/verification-of.js +2 -2
- package/dist/tools/act-tools.js +126 -21
- package/dist/tools/assert-source.d.ts +22 -0
- package/dist/tools/assert-source.js +53 -0
- package/dist/tools/assert-verdict.d.ts +20 -3
- package/dist/tools/assert-verdict.js +36 -4
- package/dist/tools/browser-tools.js +0 -20
- package/dist/tools/complete-snapshot.d.ts +48 -0
- package/dist/tools/complete-snapshot.js +75 -0
- package/dist/tools/coverage-tools.d.ts +10 -1
- package/dist/tools/coverage-tools.js +123 -69
- package/dist/tools/dynamic-tools.js +29 -8
- package/dist/tools/error-recovery.d.ts +24 -14
- package/dist/tools/error-recovery.js +70 -9
- package/dist/tools/feedback-tools.d.ts +1 -1
- package/dist/tools/feedback-tools.js +2 -2
- package/dist/tools/invoke-tool.js +62 -2
- package/dist/tools/lease-hint.d.ts +10 -0
- package/dist/tools/lease-hint.js +53 -4
- package/dist/tools/lease-tools.d.ts +5 -1
- package/dist/tools/lease-tools.js +22 -8
- package/dist/tools/navigate-arrival.d.ts +0 -2
- package/dist/tools/navigate-arrival.js +1 -1
- package/dist/tools/observe-tools.js +63 -26
- package/dist/tools/read-tools.js +5 -3
- package/dist/tools/real-input-attempt.d.ts +22 -0
- package/dist/tools/real-input-attempt.js +153 -1
- package/dist/tools/reconcile-tools.js +18 -4
- package/dist/tools/resolve-target.d.ts +0 -7
- package/dist/tools/sdk-marker-probe.d.ts +29 -0
- package/dist/tools/sdk-marker-probe.js +57 -0
- package/dist/tools/snapshot-delta.d.ts +1 -1
- package/dist/tools/tool-kit.d.ts +12 -0
- package/dist/tools/tool-kit.js +16 -0
- package/dist/tools/tool-names.d.ts +3 -0
- package/dist/tools/tool-names.js +3 -0
- package/dist/tools/tool-surface.d.ts +85 -6
- package/dist/tools/tool-surface.js +122 -2
- package/dist/tools/tools.js +22 -4
- package/dist/tools/unadvertised-help.js +4 -4
- package/dist/tools/verify-next-baton.d.ts +53 -0
- package/dist/tools/verify-next-baton.js +52 -0
- package/dist/update/update-nudge.d.ts +7 -10
- package/dist/update/update-nudge.js +12 -3
- package/dist/update/updater.d.ts +1 -1
- package/docs/README.md +2 -1
- package/docs/cli/cloud.mdx +1 -1
- package/docs/cli/doctor.mdx +1 -0
- package/docs/cli/verify.mdx +4 -5
- package/docs/desktop.mdx +1 -1
- package/docs/docs.json +2 -2
- package/docs/gates.md +1 -1
- package/docs/install-manual.mdx +1 -1
- package/docs/packages/browser.mdx +2 -2
- package/docs/packages/server.mdx +1 -1
- package/docs/packages.mdx +8 -6
- package/docs/platform-integration.md +17 -0
- package/docs/predicates.mdx +8 -0
- package/docs/reticle-manual.md +106 -0
- package/docs/tools-tools-and-run.mdx +2 -4
- package/docs/troubleshooting.mdx +9 -0
- package/docs/usage.md +3 -2
- package/package.json +2 -2
- package/dist/session/session-readiness.d.ts +0 -34
- package/dist/session/session-readiness.js +0 -36
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
*/
|
|
17
17
|
interface ActEffect {
|
|
18
18
|
action?: string | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* The ref the action dispatched against. Recorded so the verdict nudge can suggest a call about
|
|
21
|
+
* the element the agent actually touched, rather than a worked example from a document. Written
|
|
22
|
+
* on the SAME success path as `action`, so a refused act leaves neither.
|
|
23
|
+
*/
|
|
24
|
+
ref?: string | undefined;
|
|
19
25
|
/** DOM mutations inside the acted element's own subtree. Undefined means nobody measured. */
|
|
20
26
|
mutatedWithin?: number | undefined;
|
|
21
27
|
}
|
|
@@ -35,7 +41,7 @@ export declare class LastAct {
|
|
|
35
41
|
* Marking only on the success path is what makes that unrepeatable: a failure mode added later
|
|
36
42
|
* cannot forget to clean up state that was never written.
|
|
37
43
|
*/
|
|
38
|
-
markActed(cursor: number, action: string | undefined, mutatedWithin: number | undefined): void;
|
|
44
|
+
markActed(cursor: number, action: string | undefined, mutatedWithin: number | undefined, ref?: string): void;
|
|
39
45
|
/**
|
|
40
46
|
* A navigation — by URL or by reload — moves the floor without being an act.
|
|
41
47
|
*
|
package/dist/session/last-act.js
CHANGED
|
@@ -18,9 +18,9 @@ export class LastAct {
|
|
|
18
18
|
* Marking only on the success path is what makes that unrepeatable: a failure mode added later
|
|
19
19
|
* cannot forget to clean up state that was never written.
|
|
20
20
|
*/
|
|
21
|
-
markActed(cursor, action, mutatedWithin) {
|
|
21
|
+
markActed(cursor, action, mutatedWithin, ref) {
|
|
22
22
|
this.#cursor = cursor;
|
|
23
|
-
this.#effect = { action, mutatedWithin };
|
|
23
|
+
this.#effect = { action, mutatedWithin, ...(ref === undefined ? {} : { ref }) };
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
26
|
* A navigation — by URL or by reload — moves the floor without being an act.
|
|
@@ -3,12 +3,7 @@ import { noteEmptyRead } from '../tools/observed-nothing.js';
|
|
|
3
3
|
import { AGENT_ASK_NOTICE, AGENT_WAITING_NOTICE, PresenterTone, SessionState, YIELD_WITHOUT_SESSION_NOTE, } from '@reticlehq/core';
|
|
4
4
|
import { ReticleTool } from '../tools/tool-names.js';
|
|
5
5
|
import { sessionIdShape } from '../tools/tool-kit.js';
|
|
6
|
-
import {
|
|
7
|
-
import { waitForReady, RETICLE_LOOP_GUIDE } from './session-readiness.js';
|
|
8
|
-
import { recoveryFor } from '../tools/error-recovery.js';
|
|
9
|
-
/** Default + ceiling for the readiness wait — keep it short so a truly-missing app fails fast. */
|
|
10
|
-
const WAIT_READY_DEFAULT_MS = 5000;
|
|
11
|
-
const WAIT_READY_MAX_MS = 30000;
|
|
6
|
+
import { asString } from '../tools/tools-helpers.js';
|
|
12
7
|
/**
|
|
13
8
|
* Is this a turn ending with nothing attached, rather than a call about a specific tab?
|
|
14
9
|
*
|
|
@@ -225,42 +220,6 @@ export const LIVE_CONTROL_TOOLS = [
|
|
|
225
220
|
return Promise.resolve(out);
|
|
226
221
|
},
|
|
227
222
|
},
|
|
228
|
-
{
|
|
229
|
-
name: ReticleTool.WAIT_READY,
|
|
230
|
-
description: 'Block until the app is connected, then continue — call this once right after init so your ' +
|
|
231
|
-
'first real tool call does not lose the race with the SDK connecting its WebSocket. Returns as ' +
|
|
232
|
-
'soon as a session exists (no latency if one already does), or after `timeoutMs` with a ' +
|
|
233
|
-
'recovery hint if none appears.',
|
|
234
|
-
inputSchema: {
|
|
235
|
-
timeoutMs: z
|
|
236
|
-
.number()
|
|
237
|
-
.optional()
|
|
238
|
-
.describe('How long to wait for a session (ms). Default 5000, max 30000.'),
|
|
239
|
-
},
|
|
240
|
-
outputSchema: {
|
|
241
|
-
ready: z.boolean(),
|
|
242
|
-
sessionCount: z.number(),
|
|
243
|
-
loop: z.string().optional(),
|
|
244
|
-
recovery: z.string().optional(),
|
|
245
|
-
},
|
|
246
|
-
handler: async (deps, args) => {
|
|
247
|
-
const requested = asNumber(args['timeoutMs']) ?? WAIT_READY_DEFAULT_MS;
|
|
248
|
-
const timeoutMs = Math.max(0, Math.min(requested, WAIT_READY_MAX_MS));
|
|
249
|
-
const ready = await waitForReady({
|
|
250
|
-
count: () => deps.sessions.count(),
|
|
251
|
-
timeoutMs,
|
|
252
|
-
now: deps.now,
|
|
253
|
-
sleep: (ms) => new Promise((res) => setTimeout(res, ms)),
|
|
254
|
-
});
|
|
255
|
-
// The first response a fresh agent gets — carry the loop guide so it learns how to drive Reticle.
|
|
256
|
-
if (ready)
|
|
257
|
-
return { ready: true, sessionCount: deps.sessions.count(), loop: RETICLE_LOOP_GUIDE };
|
|
258
|
-
const recovery = recoveryFor('no browser session connected');
|
|
259
|
-
return recovery !== undefined
|
|
260
|
-
? { ready: false, sessionCount: 0, recovery }
|
|
261
|
-
: { ready: false, sessionCount: 0 };
|
|
262
|
-
},
|
|
263
|
-
},
|
|
264
223
|
];
|
|
265
224
|
/**
|
|
266
225
|
* The single actionable next-move for a pending mark: point the agent at the source (or the element
|
|
@@ -117,6 +117,16 @@ export interface NoSessionFacts {
|
|
|
117
117
|
* install never finished" to the user-facing diagnosis rather than only to telemetry.
|
|
118
118
|
*/
|
|
119
119
|
daemonUpMs?: number;
|
|
120
|
+
/**
|
|
121
|
+
* Well-known Reticle ports other than `port` that currently accept a connection.
|
|
122
|
+
*
|
|
123
|
+
* Remaining half of #261. We cannot see an SDK dialling a port we are not on, but we can see that
|
|
124
|
+
* a well-known Reticle port has a listener. That is an observation: somebody else's daemon, another
|
|
125
|
+
* project, and an unrelated process all look the same from here. The sentence that reports it
|
|
126
|
+
* names the ports and refuses the conclusion. Absent or empty means nothing else was listening,
|
|
127
|
+
* which is the common case and must not gain a paragraph.
|
|
128
|
+
*/
|
|
129
|
+
siblingListeners?: readonly number[];
|
|
120
130
|
}
|
|
121
131
|
/**
|
|
122
132
|
* Every branch ends with this, and `recoveryFor` keys on it to suppress the generic no-session
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { leaseCaveat } from './lease-availability.js';
|
|
22
22
|
import { DEV_SERVER_PORTS } from '../cli/cli-port.js';
|
|
23
|
+
import { siblingListenerNote } from '../cli/sibling-ports.js';
|
|
23
24
|
import { STALL_AFTER_MS } from './stall-clock.js';
|
|
24
25
|
/** The one framework whose most likely cause differs from every other framework's. */
|
|
25
26
|
const NUXT = 'nuxt';
|
|
@@ -217,6 +218,24 @@ function portMismatchClause(facts) {
|
|
|
217
218
|
` Either restart the daemon on ${String(configured)}, or update \`.reticle.json\` to` +
|
|
218
219
|
` ${String(facts.port)} and restart the dev server so the app picks it up.`);
|
|
219
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* A listener on a well-known Reticle port we did not bind. Observation, not a cause.
|
|
223
|
+
*
|
|
224
|
+
* Leading space so the caller can concatenate it unconditionally. Empty when there is nothing to
|
|
225
|
+
* say, including when the occupied sibling is already the configured-port mismatch — that sentence
|
|
226
|
+
* already named both numbers, and repeating 4460 as a second "cause" would overclaim.
|
|
227
|
+
*/
|
|
228
|
+
function siblingListenerClause(facts) {
|
|
229
|
+
const occupied = facts.siblingListeners ?? [];
|
|
230
|
+
if (0 === occupied.length)
|
|
231
|
+
return '';
|
|
232
|
+
const mismatch = facts.projectPort;
|
|
233
|
+
const extra = mismatch !== undefined && mismatch !== facts.port
|
|
234
|
+
? occupied.filter((port) => port !== mismatch)
|
|
235
|
+
: occupied;
|
|
236
|
+
const note = siblingListenerNote(facts.port, extra);
|
|
237
|
+
return note === undefined ? '' : ` ${note}`;
|
|
238
|
+
}
|
|
220
239
|
/**
|
|
221
240
|
* The tail every "the app is wired and silent" branch ends with, ranked.
|
|
222
241
|
*
|
|
@@ -228,7 +247,7 @@ function rankedCauses(facts) {
|
|
|
228
247
|
const nuxt = facts.framework?.toLowerCase() === NUXT ? `${NUXT_STALE_BUNDLE} ` : '';
|
|
229
248
|
return (`${nuxt}${REAL_CAUSES} If none of those, the app may be dialling a different daemon than this ` +
|
|
230
249
|
`one (on ${String(facts.port)}): check the app's reticle port matches ` +
|
|
231
|
-
`${String(facts.port)}.${portMismatchClause(facts)} ${NON_LOCALHOST_GATE}`);
|
|
250
|
+
`${String(facts.port)}.${portMismatchClause(facts)}${siblingListenerClause(facts)} ${NON_LOCALHOST_GATE}`);
|
|
232
251
|
}
|
|
233
252
|
/**
|
|
234
253
|
* The scope answer, when the config was somewhere else all along.
|
|
@@ -353,7 +372,7 @@ export function diagnoseNoSession(facts) {
|
|
|
353
372
|
`file ${INIT_CMD} writes, so the app may carry no Reticle SDK — but check the app's ` +
|
|
354
373
|
'OWN directory before re-running `init`: in a monorepo the daemon often runs at the root ' +
|
|
355
374
|
'while the app lives in a subdirectory, and an app wired by the Vite or Babel plugin ' +
|
|
356
|
-
`carries the SDK without that file at all.${searchedClause(facts)} ${leaseAdvice(URL_THEN_LEASE, facts)} ${RETRY}`);
|
|
375
|
+
`carries the SDK without that file at all.${searchedClause(facts)}${siblingListenerClause(facts)} ${leaseAdvice(URL_THEN_LEASE, facts)} ${RETRY}`);
|
|
357
376
|
}
|
|
358
377
|
return (`${stallClause(facts)}no browser session connected, and this daemon has never seen one. ${OPEN_THE_APP} ` +
|
|
359
378
|
'Nothing is listening on the ports Reticle scans ' +
|
|
@@ -371,10 +390,8 @@ export function diagnoseNoSession(facts) {
|
|
|
371
390
|
// Deliberately NOT offering reticle_lease here, and a test pins that: a lease opens a URL, and
|
|
372
391
|
// if nothing is listening there is nothing at any URL to open. Asking for the real one is the
|
|
373
392
|
// only move that can recover the :7699 case.
|
|
374
|
-
`the app IS running, ask the human for its URL rather than assuming it is down. ${leaseAdvice(URL_THEN_LEASE, facts)}
|
|
375
|
-
|
|
376
|
-
// answered above, leading with that certainty instead of behind this scan's guess.
|
|
377
|
-
`${RETRY}`);
|
|
393
|
+
`the app IS running, ask the human for its URL rather than assuming it is down. ${leaseAdvice(URL_THEN_LEASE, facts)}` +
|
|
394
|
+
`${siblingListenerClause(facts)} ${RETRY}`);
|
|
378
395
|
}
|
|
379
396
|
if (!initialized) {
|
|
380
397
|
return ('no browser session connected, and this daemon has never seen one. ' +
|
|
@@ -384,7 +401,7 @@ export function diagnoseNoSession(facts) {
|
|
|
384
401
|
"a subdirectory, or in an app wired by the Vite or Babel plugin. Check the app's OWN " +
|
|
385
402
|
`directory: if it has no config, run ${INIT_CMD} there and restart the dev server; if it ` +
|
|
386
403
|
`has one, the app is wired and simply has no page open — ${OPEN_CMD}. ` +
|
|
387
|
-
`${unattributedListeners(listening)}${searchedClause(facts)} ${RETRY}`);
|
|
404
|
+
`${unattributedListeners(listening)}${searchedClause(facts)}${siblingListenerClause(facts)} ${RETRY}`);
|
|
388
405
|
}
|
|
389
406
|
return (`${stallClause(facts)}no browser session connected, and this daemon has never seen one for this project, which is ` +
|
|
390
407
|
`wired for Reticle. ${OPEN_THE_APP} ${unattributedListeners(listening)} ` +
|
|
@@ -29,6 +29,11 @@ interface NextActionFacts {
|
|
|
29
29
|
initialized: boolean;
|
|
30
30
|
listening: readonly number[];
|
|
31
31
|
dev: DevCommand | undefined;
|
|
32
|
+
/** Configs found in other workspace directories: positive evidence of a scope mismatch. */
|
|
33
|
+
configsElsewhere?: readonly {
|
|
34
|
+
directory: string;
|
|
35
|
+
projectId?: string;
|
|
36
|
+
}[];
|
|
32
37
|
/**
|
|
33
38
|
* An app for this project has connected on this port before, from durable state.
|
|
34
39
|
*
|
|
@@ -26,6 +26,25 @@ export function nextActionFor(facts) {
|
|
|
26
26
|
'reticle_lease {action:"acquire", url}.',
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
+
const configsElsewhere = facts.configsElsewhere ?? [];
|
|
30
|
+
if (!facts.initialized && configsElsewhere.length > 0) {
|
|
31
|
+
const named = configsElsewhere
|
|
32
|
+
.map((config) => config.projectId === undefined
|
|
33
|
+
? config.directory
|
|
34
|
+
: `${config.directory} ('${config.projectId}')`)
|
|
35
|
+
.join(', ');
|
|
36
|
+
const projectIds = configsElsewhere.flatMap((config) => config.projectId === undefined ? [] : [config.projectId]);
|
|
37
|
+
const leaseAlternative = 0 === projectIds.length
|
|
38
|
+
? ''
|
|
39
|
+
: ' Alternatively, acquire the app URL explicitly with reticle_lease ' +
|
|
40
|
+
'{action:"acquire", url, projectId} using the matching projectId above.';
|
|
41
|
+
return {
|
|
42
|
+
action: NoSessionAction.OPEN_APP,
|
|
43
|
+
reason: `a \`.reticle.json\` was found outside this daemon's directory: ${named}. This is a ` +
|
|
44
|
+
"scope problem, not an install problem. Restart the daemon from the app's directory (or " +
|
|
45
|
+
`point it there), then open the app.${leaseAlternative}`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
29
48
|
if (0 === facts.listening.length) {
|
|
30
49
|
const dev = facts.dev;
|
|
31
50
|
if (dev === undefined) {
|
|
@@ -41,9 +60,22 @@ export function nextActionFor(facts) {
|
|
|
41
60
|
action: NoSessionAction.START_DEV_SERVER,
|
|
42
61
|
command: dev.command,
|
|
43
62
|
...(dev.port === undefined ? {} : { port: dev.port }),
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
63
|
+
// "Probably", to match the branch above it and the paragraph beside it.
|
|
64
|
+
//
|
|
65
|
+
// The same fact was stated at two confidence levels by one function: no dev script said the
|
|
66
|
+
// app was "probably not running", and THIS branch — the one that hands over a command to run
|
|
67
|
+
// — asserted it flatly. That is the branch where being wrong costs something, because the
|
|
68
|
+
// agent then starts a SECOND dev server on a second port, which the guard below calls the
|
|
69
|
+
// exact confusion this probe exists to prevent.
|
|
70
|
+
//
|
|
71
|
+
// It also contradicted its own payload: the prose beside it says the scan is narrow, that a
|
|
72
|
+
// server on any other port is invisible to it, and that a running app should be checked for
|
|
73
|
+
// rather than assumed away. Measured on a machine with three dev servers up on ports the scan
|
|
74
|
+
// does not cover, it reported the app was not running.
|
|
75
|
+
reason: 'nothing is listening on the ports Reticle scans, so the app is probably not running — ' +
|
|
76
|
+
'though that scan is narrow, so if it IS up on another port, ask for its URL instead of ' +
|
|
77
|
+
`starting a second one. This is the project's own \`${dev.script}\` script — run it in the ` +
|
|
78
|
+
'background, tell the human it is running, then call reticle_sessions again.',
|
|
47
79
|
};
|
|
48
80
|
}
|
|
49
81
|
// Something IS up. Whatever else is true, do NOT hand back a start command: a second dev server on
|
|
@@ -18,6 +18,14 @@ interface NoSessionWatchOptions {
|
|
|
18
18
|
/** Where that was decided — this daemon's working directory unless a caller says otherwise. */
|
|
19
19
|
directory?: string;
|
|
20
20
|
probe?: () => Promise<number[]>;
|
|
21
|
+
/**
|
|
22
|
+
* Well-known Reticle ports other than ours that currently accept a connection.
|
|
23
|
+
*
|
|
24
|
+
* Remaining half of #261. Injected so tests can pin the observation without opening a socket.
|
|
25
|
+
* Production (no `probe` override) probes those ports itself. A test that injects `probe` and
|
|
26
|
+
* omits this is opting out: it owns the scan.
|
|
27
|
+
*/
|
|
28
|
+
occupiedSiblings?: () => Promise<readonly number[]>;
|
|
21
29
|
/**
|
|
22
30
|
* How many pooled leases have aged out, if a pool exists. Injected as a reader rather than the
|
|
23
31
|
* pool itself: the diagnosis needs one number, and taking the whole pool would tie the session
|
|
@@ -18,6 +18,8 @@ import { discoverProjectConfigs } from '../cli/config-discovery.js';
|
|
|
18
18
|
import { hasProjectConnectedBefore, rememberConnected } from './connection-memory.js';
|
|
19
19
|
import { reticleStateHome } from '../daemon/daemon.js';
|
|
20
20
|
import { stallUptime } from './stall-clock.js';
|
|
21
|
+
import { probeDaemon } from '../mcp/mcp-proxy.js';
|
|
22
|
+
import { findOccupiedSiblings } from '../cli/sibling-ports.js';
|
|
21
23
|
/** Slow enough to be free, fast enough that a dev server started 15s ago is already reflected. */
|
|
22
24
|
const REFRESH_MS = 15_000;
|
|
23
25
|
/** The URL an auto-attach opens. `localhost` for the same reason the probe uses it: both stacks. */
|
|
@@ -56,6 +58,7 @@ export function startNoSessionWatch(options) {
|
|
|
56
58
|
* `options.probe` returns only the serving set, so this stays empty for injected probes.
|
|
57
59
|
*/
|
|
58
60
|
let slowListeners = [];
|
|
61
|
+
let siblingListeners = [];
|
|
59
62
|
let running = false;
|
|
60
63
|
/** Ports auto-attach has already spent its one attempt on. Bounded: never a loop, never a retry. */
|
|
61
64
|
const attempted = new Set();
|
|
@@ -66,6 +69,27 @@ export function startNoSessionWatch(options) {
|
|
|
66
69
|
// is routinely written by `init` AFTER this daemon started, so re-read rather than cache. See the
|
|
67
70
|
// `initialized` comment below, which this shares.
|
|
68
71
|
const isWired = () => options.initialized || readProjectId(directory) !== undefined;
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the daemon's current project scope once for both halves of a sessions response.
|
|
74
|
+
*
|
|
75
|
+
* This remains a live read because `init` commonly writes the config after the daemon starts.
|
|
76
|
+
*/
|
|
77
|
+
const projectScopeFacts = () => {
|
|
78
|
+
const initialized = isWired();
|
|
79
|
+
if (initialized)
|
|
80
|
+
return { initialized };
|
|
81
|
+
const discovery = discoverProjectConfigs(directory);
|
|
82
|
+
const elsewhere = discovery.found.filter((config) => config.directory !== directory);
|
|
83
|
+
return 0 === elsewhere.length
|
|
84
|
+
? { initialized, searchedDirectories: discovery.searched }
|
|
85
|
+
: {
|
|
86
|
+
initialized,
|
|
87
|
+
configsElsewhere: elsewhere.map((config) => ({
|
|
88
|
+
directory: config.directory,
|
|
89
|
+
...(config.projectId === undefined ? {} : { projectId: config.projectId }),
|
|
90
|
+
})),
|
|
91
|
+
};
|
|
92
|
+
};
|
|
69
93
|
// Read at boot: the daemon's own identity does not change under it, and this is the key the
|
|
70
94
|
// durable bit is stored under.
|
|
71
95
|
const stateDir = options.stateDir ?? reticleStateHome();
|
|
@@ -129,6 +153,12 @@ export function startNoSessionWatch(options) {
|
|
|
129
153
|
: probe())
|
|
130
154
|
.then(async (ports) => {
|
|
131
155
|
listening = ports;
|
|
156
|
+
siblingListeners =
|
|
157
|
+
options.occupiedSiblings !== undefined
|
|
158
|
+
? await options.occupiedSiblings()
|
|
159
|
+
: options.probe === undefined
|
|
160
|
+
? await findOccupiedSiblings(options.port, probeDaemon)
|
|
161
|
+
: [];
|
|
132
162
|
await autoAttach(ports);
|
|
133
163
|
})
|
|
134
164
|
.catch(() => {
|
|
@@ -141,77 +171,65 @@ export function startNoSessionWatch(options) {
|
|
|
141
171
|
refresh();
|
|
142
172
|
const timer = setInterval(refresh, REFRESH_MS);
|
|
143
173
|
timer.unref();
|
|
144
|
-
const nextAction = () => nextActionFor({
|
|
174
|
+
const nextAction = (scope) => nextActionFor({
|
|
145
175
|
everConnected: options.sessions.everConnected(),
|
|
146
|
-
initialized:
|
|
176
|
+
initialized: scope.initialized,
|
|
177
|
+
...(scope.configsElsewhere === undefined ? {} : { configsElsewhere: scope.configsElsewhere }),
|
|
147
178
|
previouslyConnected: connectedBefore(),
|
|
148
179
|
listening,
|
|
149
180
|
// Read when asked, like everything else here: a `package.json` can gain a dev script, and a
|
|
150
181
|
// daemon that cached "there is none" at boot would keep saying so for the rest of the day.
|
|
151
182
|
dev: detectDevCommand(directory),
|
|
152
183
|
});
|
|
153
|
-
options.sessions.setNoSessionNextAction(nextAction);
|
|
154
|
-
options.sessions.setNoSessionHint(() =>
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
:
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
})(),
|
|
203
|
-
// Read here rather than at boot: `.reticle.json` can be written by `init` after this daemon
|
|
204
|
-
// started, which is the ordinary first-install order, and a port cached from before it existed
|
|
205
|
-
// would make the daemon confidently report no mismatch on the one run where there is one.
|
|
206
|
-
...(() => {
|
|
207
|
-
const configured = readProjectPort(directory);
|
|
208
|
-
return configured === undefined ? {} : { projectPort: configured };
|
|
209
|
-
})(),
|
|
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 ?? ''));
|
|
184
|
+
options.sessions.setNoSessionNextAction(() => nextAction(projectScopeFacts()));
|
|
185
|
+
options.sessions.setNoSessionHint(() => {
|
|
186
|
+
const scope = projectScopeFacts();
|
|
187
|
+
return (diagnoseNoSession({
|
|
188
|
+
everConnected: options.sessions.everConnected(),
|
|
189
|
+
// Read WHEN ASKED, for the same reason `projectPort` below is: `.reticle.json` is routinely
|
|
190
|
+
// written by `init` after this daemon started — that is the ordinary first-install order — and
|
|
191
|
+
// the boot-time answer is then permanently stale. Reported from the field as `reticle status`
|
|
192
|
+
// saying the project had never been through `init` about a project whose config named its
|
|
193
|
+
// framework and its projectId, and whose real problem was a dev server older than the plugin.
|
|
194
|
+
// The boot value still counts: it is the one the daemon scoped its sessions with.
|
|
195
|
+
...scope,
|
|
196
|
+
listening,
|
|
197
|
+
slowListeners,
|
|
198
|
+
port: options.port,
|
|
199
|
+
// The directory `initialized` was decided in. Named in the message because "there is no
|
|
200
|
+
// `.reticle.json`" is a claim about ONE directory, and a reader standing somewhere else
|
|
201
|
+
// cannot tell whether it is a claim about their app at all.
|
|
202
|
+
directory,
|
|
203
|
+
// The one fact that outranks every absence below it, and the reason a fresh daemon stopped
|
|
204
|
+
// claiming that an install which has demonstrably worked has never worked.
|
|
205
|
+
previouslyConnected: connectedBefore(),
|
|
206
|
+
// Ranks the causes. Read when asked, like the rest: `init` writes this file after the daemon
|
|
207
|
+
// starts on an ordinary first install.
|
|
208
|
+
...(() => {
|
|
209
|
+
const framework = readProjectFramework(directory);
|
|
210
|
+
return framework === undefined ? {} : { framework };
|
|
211
|
+
})(),
|
|
212
|
+
leaseExpired: (options.reapedLeases?.() ?? 0) > 0,
|
|
213
|
+
// How long this daemon has been waiting with no app. The diagnosis uses it to surface
|
|
214
|
+
// "install never finished" — the same condition telemetry already knows about.
|
|
215
|
+
...(() => {
|
|
216
|
+
const upMs = stallUptime(Date.now());
|
|
217
|
+
return upMs === undefined ? {} : { daemonUpMs: upMs };
|
|
218
|
+
})(),
|
|
219
|
+
// Read here rather than at boot: `.reticle.json` can be written by `init` after this daemon
|
|
220
|
+
// started, which is the ordinary first-install order, and a port cached from before it existed
|
|
221
|
+
// would make the daemon confidently report no mismatch on the one run where there is one.
|
|
222
|
+
...(() => {
|
|
223
|
+
const configured = readProjectPort(directory);
|
|
224
|
+
return configured === undefined ? {} : { projectPort: configured };
|
|
225
|
+
})(),
|
|
226
|
+
...(0 === siblingListeners.length ? {} : { siblingListeners }),
|
|
227
|
+
}) +
|
|
228
|
+
// Prose for the human, then the literal command for the agent. Both consume the same scope
|
|
229
|
+
// facts so a discovered workspace config cannot become an `init` recommendation below it.
|
|
230
|
+
` ${renderNextAction(nextAction(scope))}` +
|
|
231
|
+
(attachFailure ?? ''));
|
|
232
|
+
});
|
|
215
233
|
return () => {
|
|
216
234
|
clearInterval(timer);
|
|
217
235
|
options.sessions.setConnectionRecorder(undefined);
|
|
@@ -50,7 +50,10 @@ export function estimateTokens(text) {
|
|
|
50
50
|
}
|
|
51
51
|
export function sizeCost(payload) {
|
|
52
52
|
const json = JSON.stringify(payload) ?? '';
|
|
53
|
-
|
|
53
|
+
// Actual UTF-8 wire bytes, not json.length (UTF-16 code units) — the field is labeled `bytes`
|
|
54
|
+
// and drives the large-snapshot recommendation, so a multibyte-heavy payload was under-counted.
|
|
55
|
+
const bytes = Buffer.byteLength(json, 'utf8');
|
|
56
|
+
return { bytes, tokens: estimateTokens(json) };
|
|
54
57
|
}
|
|
55
58
|
/**
|
|
56
59
|
* Attach a `cost` size preview to a read result. Pure: the cost is computed over the result BEFORE
|
|
@@ -45,6 +45,15 @@ export declare function healthEnvelope(session: Session): HealthEnvelope;
|
|
|
45
45
|
* warn-only so background testing never breaks.
|
|
46
46
|
*/
|
|
47
47
|
export declare function refuseIfThrottled(session: Session, refuse: unknown): void;
|
|
48
|
+
/**
|
|
49
|
+
* Suffix the starvation note onto a FAILED predicate verdict when the tab is throttled. A pass is
|
|
50
|
+
* returned untouched — starvation is context for a failure, not a reason to doubt a hold. Pure and
|
|
51
|
+
* generic over the verdict shape so assert/wait_for/act_and_wait share one rule.
|
|
52
|
+
*/
|
|
53
|
+
export declare function annotateStarvedFailure<V extends {
|
|
54
|
+
pass?: boolean;
|
|
55
|
+
failureReason?: string;
|
|
56
|
+
}>(session: Session, verdict: V): V;
|
|
48
57
|
/** The page's own visibility/runtime report, narrowed out of an untrusted PAGE_HEALTH payload. */
|
|
49
58
|
interface HealthReport {
|
|
50
59
|
hidden: boolean | undefined;
|
|
@@ -35,6 +35,29 @@ export function refuseIfThrottled(session, refuse) {
|
|
|
35
35
|
throw new Error(`refusing to act: ${THROTTLED_WARNING}`);
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* The sentence appended to a FAILED predicate verdict waited out on a throttled tab.
|
|
40
|
+
*
|
|
41
|
+
* A starved tab and a missing element read identically as a bare near-miss: after a hard reload a
|
|
42
|
+
* backgrounded tab can sit on its loading state forever (hydration starved, nothing painted), and
|
|
43
|
+
* `wait_for { text }` timing out there looks exactly like "the code did not render" (#521). The
|
|
44
|
+
* health envelope already rode alongside saying `throttled:true`, but nothing connected it to the
|
|
45
|
+
* verdict an agent actually gates on. Appended, not replacing: the original diagnosis still leads,
|
|
46
|
+
* and on a healthy tab the failure says exactly what it always said.
|
|
47
|
+
*/
|
|
48
|
+
const STARVED_WAIT_NOTE = ' (the tab was throttled during this wait, so starvation can look exactly like absence: bring it to the front and re-check before concluding this is genuinely missing)';
|
|
49
|
+
/**
|
|
50
|
+
* Suffix the starvation note onto a FAILED predicate verdict when the tab is throttled. A pass is
|
|
51
|
+
* returned untouched — starvation is context for a failure, not a reason to doubt a hold. Pure and
|
|
52
|
+
* generic over the verdict shape so assert/wait_for/act_and_wait share one rule.
|
|
53
|
+
*/
|
|
54
|
+
export function annotateStarvedFailure(session, verdict) {
|
|
55
|
+
if (true === verdict.pass || verdict.failureReason === undefined)
|
|
56
|
+
return verdict;
|
|
57
|
+
if (true !== session.throttled())
|
|
58
|
+
return verdict;
|
|
59
|
+
return { ...verdict, failureReason: `${verdict.failureReason}${STARVED_WAIT_NOTE}` };
|
|
60
|
+
}
|
|
38
61
|
/**
|
|
39
62
|
* Narrow a PAGE_HEALTH payload. Every field is optional on the wire — an older SDK does not report
|
|
40
63
|
* `runtime` at all — so each is returned as undefined rather than defaulted here, letting the caller
|
|
@@ -2,6 +2,7 @@ import { NO_SESSION_CONNECTED_ERROR } from '@reticlehq/core';
|
|
|
2
2
|
import { declareDrivenRedactionKeys, forgetDrivenRedactionKeys, } from '../input/driven-redaction.js';
|
|
3
3
|
import { Session } from './session.js';
|
|
4
4
|
import { AttachmentHistory } from './attachment-history.js';
|
|
5
|
+
import { pickDocumentSuccessor } from './session-successor.js';
|
|
5
6
|
/** The scheme://host:port of a URL, or undefined if it can't be parsed. Used to compare origins. */
|
|
6
7
|
export function originOf(url) {
|
|
7
8
|
if (url === undefined)
|
|
@@ -61,6 +62,14 @@ function scopeMissError(connected, scope) {
|
|
|
61
62
|
*/
|
|
62
63
|
/** How many bridge-initiated closes to remember for diagnosis. */
|
|
63
64
|
const MAX_REMEMBERED_CLOSURES = 5;
|
|
65
|
+
/**
|
|
66
|
+
* How many recently-departed session ids to remember for successor rebind.
|
|
67
|
+
*
|
|
68
|
+
* A full-document navigation HELLO's back under a new id while the agent still holds the old one.
|
|
69
|
+
* The tombstone is how `resolve(oldId)` finds the unique same-origin successor instead of refusing.
|
|
70
|
+
* Bounded so a long-lived daemon cannot accumulate one record per tab forever.
|
|
71
|
+
*/
|
|
72
|
+
const MAX_TOMBSTONES = 32;
|
|
64
73
|
export class SessionManager {
|
|
65
74
|
#sessions = new Map();
|
|
66
75
|
/**
|
|
@@ -69,6 +78,13 @@ export class SessionManager {
|
|
|
69
78
|
* Owned here because this is the one place that sees both halves — every add and every remove.
|
|
70
79
|
*/
|
|
71
80
|
#attachment = new AttachmentHistory();
|
|
81
|
+
/**
|
|
82
|
+
* Recently departed sessions, keyed by the id the agent still holds.
|
|
83
|
+
*
|
|
84
|
+
* Insertion order is LRU: the oldest is dropped when the cap is hit. A reconnect under the same
|
|
85
|
+
* id forgets its tombstone — the live session is the answer then.
|
|
86
|
+
*/
|
|
87
|
+
#tombstones = new Map();
|
|
72
88
|
/**
|
|
73
89
|
* The active project's scope, set once from the daemon's .reticle.json. When a tool resolves a session
|
|
74
90
|
* without passing its own scope, this is applied — so auto-selection is project-scoped by default
|
|
@@ -100,6 +116,7 @@ export class SessionManager {
|
|
|
100
116
|
// that silently fails to register is a leak nothing would report.
|
|
101
117
|
declareDrivenRedactionKeys(session.id, session.redactKeys);
|
|
102
118
|
this.#attachment.attached(session.id);
|
|
119
|
+
this.#tombstones.delete(session.id);
|
|
103
120
|
return previous;
|
|
104
121
|
}
|
|
105
122
|
remove(session) {
|
|
@@ -110,8 +127,40 @@ export class SessionManager {
|
|
|
110
127
|
// Recorded, not forgotten: a session that comes back needs its gap measured, and a listing after
|
|
111
128
|
// the reconnect is exactly where that matters.
|
|
112
129
|
this.#attachment.detached(session.id);
|
|
130
|
+
this.#rememberTombstone(session);
|
|
113
131
|
return this.#sessions.delete(session.id);
|
|
114
132
|
}
|
|
133
|
+
#rememberTombstone(session) {
|
|
134
|
+
const origin = originOf(session.url);
|
|
135
|
+
if (origin === undefined)
|
|
136
|
+
return;
|
|
137
|
+
if (this.#tombstones.has(session.id))
|
|
138
|
+
this.#tombstones.delete(session.id);
|
|
139
|
+
this.#tombstones.set(session.id, {
|
|
140
|
+
id: session.id,
|
|
141
|
+
url: session.url,
|
|
142
|
+
...(session.projectId === undefined ? {} : { projectId: session.projectId }),
|
|
143
|
+
});
|
|
144
|
+
while (this.#tombstones.size > MAX_TOMBSTONES) {
|
|
145
|
+
const oldest = this.#tombstones.keys().next().value;
|
|
146
|
+
if (oldest === undefined)
|
|
147
|
+
break;
|
|
148
|
+
this.#tombstones.delete(oldest);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
#successorOf(sessionId) {
|
|
152
|
+
const departed = this.#tombstones.get(sessionId);
|
|
153
|
+
if (departed === undefined)
|
|
154
|
+
return undefined;
|
|
155
|
+
const picked = pickDocumentSuccessor(this.all().map((s) => ({
|
|
156
|
+
id: s.id,
|
|
157
|
+
url: s.url,
|
|
158
|
+
...(s.projectId === undefined ? {} : { projectId: s.projectId }),
|
|
159
|
+
})), departed);
|
|
160
|
+
if (picked === undefined)
|
|
161
|
+
return undefined;
|
|
162
|
+
return this.#sessions.get(picked.id);
|
|
163
|
+
}
|
|
115
164
|
get(sessionId) {
|
|
116
165
|
return this.#sessions.get(sessionId);
|
|
117
166
|
}
|
|
@@ -239,7 +288,7 @@ export class SessionManager {
|
|
|
239
288
|
}
|
|
240
289
|
resolve(sessionId, scope) {
|
|
241
290
|
if (sessionId !== undefined) {
|
|
242
|
-
const found = this.#sessions.get(sessionId);
|
|
291
|
+
const found = this.#sessions.get(sessionId) ?? this.#successorOf(sessionId);
|
|
243
292
|
if (found === undefined) {
|
|
244
293
|
throw new Error(this.#unknownSessionError(sessionId));
|
|
245
294
|
}
|