@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.
Files changed (252) hide show
  1. package/SKILL.md +26 -2
  2. package/dist/bridge/bridge.d.ts +0 -13
  3. package/dist/bridge/bridge.js +1 -1
  4. package/dist/bridge/bridge.test-harness.d.ts +2 -0
  5. package/dist/bridge/bridge.test-harness.js +8 -1
  6. package/dist/capsule/capsule-store.d.ts +2 -1
  7. package/dist/capsule/capsule-store.js +1 -1
  8. package/dist/capsule/causal-summary.d.ts +2 -2
  9. package/dist/chromium-launch-options.d.ts +28 -0
  10. package/dist/chromium-launch-options.js +29 -0
  11. package/dist/cli/cli-doctor.js +8 -0
  12. package/dist/cli/cli-flow-commands.d.ts +3 -1
  13. package/dist/cli/cli-flow-commands.js +34 -4
  14. package/dist/cli/cli-kill.d.ts +2 -1
  15. package/dist/cli/cli-parse.d.ts +3 -1
  16. package/dist/cli/cli-parse.js +29 -7
  17. package/dist/cli/cli-update-commands.js +20 -1
  18. package/dist/cli/cli-verify.d.ts +1 -1
  19. package/dist/cli/cli-verify.js +2 -2
  20. package/dist/cli/cloud-cli.js +12 -6
  21. package/dist/cli/config-discovery.d.ts +2 -1
  22. package/dist/cli/doctor-rows.d.ts +3 -1
  23. package/dist/cli/doctor-rows.js +3 -1
  24. package/dist/cli/gate-exit.d.ts +21 -0
  25. package/dist/cli/gate-exit.js +21 -0
  26. package/dist/cli/gate-hook-message.d.ts +33 -0
  27. package/dist/cli/gate-hook-message.js +46 -0
  28. package/dist/cli/mcp-command.d.ts +25 -0
  29. package/dist/cli/mcp-command.js +143 -0
  30. package/dist/cli/sibling-ports.d.ts +37 -0
  31. package/dist/cli/sibling-ports.js +56 -0
  32. package/dist/cli.js +37 -110
  33. package/dist/cloud/cloud-config.d.ts +2 -1
  34. package/dist/cloud/cloud-sync.d.ts +11 -0
  35. package/dist/cloud/cloud-sync.js +32 -0
  36. package/dist/crawl/crawl.d.ts +9 -13
  37. package/dist/crawl/crawl.js +20 -3
  38. package/dist/daemon/daemon-resolve.d.ts +79 -0
  39. package/dist/daemon/daemon-resolve.js +118 -0
  40. package/dist/daemon/free-port.d.ts +29 -0
  41. package/dist/daemon/free-port.js +55 -0
  42. package/dist/daemon/heartbeat.d.ts +0 -4
  43. package/dist/daemon/heartbeat.js +2 -2
  44. package/dist/dev/stale-issue-guard.d.ts +0 -2
  45. package/dist/dev/stale-issue-guard.js +1 -1
  46. package/dist/events/contradictions.d.ts +41 -0
  47. package/dist/events/contradictions.js +125 -12
  48. package/dist/events/declared.d.ts +14 -0
  49. package/dist/events/declared.js +33 -0
  50. package/dist/events/echo-mismatch.js +14 -0
  51. package/dist/events/observed-in-window.d.ts +12 -0
  52. package/dist/events/observed-in-window.js +17 -0
  53. package/dist/events/predicate-asks.d.ts +13 -0
  54. package/dist/events/predicate-asks.js +22 -0
  55. package/dist/events/predicate-eval.js +64 -22
  56. package/dist/events/predicate-parse.d.ts +0 -6
  57. package/dist/events/predicate-parse.js +43 -3
  58. package/dist/events/predicate-schema.d.ts +44 -0
  59. package/dist/events/predicate-schema.js +126 -23
  60. package/dist/events/predicate.d.ts +15 -0
  61. package/dist/events/predicate.js +41 -2
  62. package/dist/flows/coverage.d.ts +0 -8
  63. package/dist/flows/coverage.js +1 -1
  64. package/dist/flows/decision.d.ts +5 -1
  65. package/dist/flows/decision.js +33 -7
  66. package/dist/flows/flake.d.ts +0 -2
  67. package/dist/flows/flake.js +1 -1
  68. package/dist/flows/flow-intent.d.ts +52 -0
  69. package/dist/flows/flow-intent.js +123 -0
  70. package/dist/flows/flow-replay-run.js +27 -4
  71. package/dist/flows/flow-success.js +15 -3
  72. package/dist/flows/flow-tools.js +32 -4
  73. package/dist/flows/flows.d.ts +6 -1
  74. package/dist/flows/flows.js +34 -22
  75. package/dist/flows/predicate-to-expect.js +6 -3
  76. package/dist/flows/server-verify.js +16 -2
  77. package/dist/flows/verify-change-tools.js +4 -1
  78. package/dist/honesty/blind-spots.d.ts +0 -17
  79. package/dist/honesty/blind-spots.js +1 -1
  80. package/dist/honesty/feature-capture.d.ts +150 -0
  81. package/dist/honesty/feature-capture.js +233 -0
  82. package/dist/honesty/instrumentation-gaps.d.ts +30 -6
  83. package/dist/honesty/instrumentation-gaps.js +61 -5
  84. package/dist/honesty/tool-hit-rate.d.ts +86 -0
  85. package/dist/honesty/tool-hit-rate.js +90 -0
  86. package/dist/honesty/undeclared-change.d.ts +51 -0
  87. package/dist/honesty/undeclared-change.js +56 -0
  88. package/dist/honesty/verified.d.ts +15 -0
  89. package/dist/honesty/verified.js +68 -4
  90. package/dist/http-server.d.ts +0 -1
  91. package/dist/http-server.js +1 -1
  92. package/dist/hunt/hunt-report.d.ts +0 -2
  93. package/dist/hunt/hunt-report.js +1 -1
  94. package/dist/impact/impact-store.d.ts +0 -5
  95. package/dist/impact/impact-store.js +1 -1
  96. package/dist/index.d.ts +4 -1
  97. package/dist/init/agent-rules.d.ts +17 -0
  98. package/dist/init/agent-rules.js +31 -1
  99. package/dist/init/capabilities.d.ts +0 -2
  100. package/dist/init/capabilities.js +1 -1
  101. package/dist/init/closing-hint.d.ts +36 -0
  102. package/dist/init/closing-hint.js +120 -0
  103. package/dist/init/confirm.d.ts +0 -25
  104. package/dist/init/confirm.js +3 -3
  105. package/dist/init/cra.d.ts +14 -1
  106. package/dist/init/cra.js +43 -12
  107. package/dist/init/dev-script.d.ts +48 -0
  108. package/dist/init/dev-script.js +63 -0
  109. package/dist/init/mcp.d.ts +16 -5
  110. package/dist/init/mcp.js +26 -10
  111. package/dist/init/plan-framework.d.ts +0 -5
  112. package/dist/init/plan-framework.js +83 -9
  113. package/dist/init/plan.d.ts +10 -30
  114. package/dist/init/plan.js +30 -15
  115. package/dist/init/refresh-rules.d.ts +39 -0
  116. package/dist/init/refresh-rules.js +51 -0
  117. package/dist/init/run.d.ts +0 -26
  118. package/dist/init/run.js +14 -87
  119. package/dist/init/snippets.js +53 -10
  120. package/dist/input/real-input.d.ts +2 -0
  121. package/dist/input/real-input.js +3 -2
  122. package/dist/input/scroll-find.d.ts +7 -4
  123. package/dist/input/scroll-find.js +63 -25
  124. package/dist/intent/inline-intent.d.ts +46 -0
  125. package/dist/intent/inline-intent.js +103 -0
  126. package/dist/intent/intent-store.d.ts +37 -0
  127. package/dist/intent/intent-store.js +112 -0
  128. package/dist/intent/intent-tools.d.ts +2 -0
  129. package/dist/intent/intent-tools.js +80 -0
  130. package/dist/intent/open-intents.d.ts +21 -0
  131. package/dist/intent/open-intents.js +25 -0
  132. package/dist/journal/envelope.d.ts +26 -26
  133. package/dist/journal/journal-recorder.d.ts +23 -0
  134. package/dist/journal/journal-recorder.js +35 -0
  135. package/dist/mcp/attach-memory.d.ts +67 -0
  136. package/dist/mcp/attach-memory.js +142 -0
  137. package/dist/mcp/mcp-proxy.d.ts +0 -7
  138. package/dist/mcp/mcp-proxy.js +14 -2
  139. package/dist/mcp/mcp.d.ts +2 -0
  140. package/dist/mcp/mcp.js +45 -23
  141. package/dist/mcp/server-instructions.js +45 -3
  142. package/dist/mcp/tool-catalog-cache.d.ts +8 -0
  143. package/dist/mcp/tool-catalog-cache.js +10 -0
  144. package/dist/oracles/flow-instrument-gaps.d.ts +4 -4
  145. package/dist/oracles/flow-instrument-gaps.js +4 -5
  146. package/dist/oracles/self-instrument.d.ts +9 -9
  147. package/dist/oracles/self-instrument.js +19 -13
  148. package/dist/pool/browser-pool.d.ts +12 -1
  149. package/dist/pool/browser-pool.js +31 -1
  150. package/dist/pool/playwright-launcher.js +3 -1
  151. package/dist/project/fs-port.d.ts +3 -0
  152. package/dist/project/fs-port.js +3 -2
  153. package/dist/project/memory-fs.js +6 -1
  154. package/dist/project/project-tools.js +3 -32
  155. package/dist/project/reticle-dir.d.ts +2 -0
  156. package/dist/project/reticle-dir.js +1 -0
  157. package/dist/runs/context-tools.d.ts +2 -0
  158. package/dist/runs/context-tools.js +89 -0
  159. package/dist/runs/run-context.d.ts +40 -0
  160. package/dist/runs/run-context.js +158 -0
  161. package/dist/runs/run-diff.d.ts +2 -1
  162. package/dist/runs/verification-sync.js +2 -2
  163. package/dist/session/dev-server-probe.d.ts +0 -8
  164. package/dist/session/dev-server-probe.js +1 -1
  165. package/dist/session/last-act.d.ts +7 -1
  166. package/dist/session/last-act.js +2 -2
  167. package/dist/session/live-control-tools.js +1 -42
  168. package/dist/session/no-session-diagnosis.d.ts +10 -0
  169. package/dist/session/no-session-diagnosis.js +24 -7
  170. package/dist/session/no-session-next-action.d.ts +5 -0
  171. package/dist/session/no-session-next-action.js +35 -3
  172. package/dist/session/no-session-watch.d.ts +8 -0
  173. package/dist/session/no-session-watch.js +82 -64
  174. package/dist/session/output-budget.js +4 -1
  175. package/dist/session/session-health.d.ts +9 -0
  176. package/dist/session/session-health.js +23 -0
  177. package/dist/session/session-manager.js +50 -1
  178. package/dist/session/session-successor.d.ts +37 -0
  179. package/dist/session/session-successor.js +78 -0
  180. package/dist/session/session.d.ts +31 -1
  181. package/dist/session/session.js +75 -2
  182. package/dist/telemetry/automation-hint.d.ts +2 -1
  183. package/dist/telemetry/feedback-outbox.d.ts +2 -1
  184. package/dist/telemetry/project-profile.d.ts +0 -8
  185. package/dist/telemetry/project-profile.js +1 -1
  186. package/dist/telemetry/run-telemetry.d.ts +0 -2
  187. package/dist/telemetry/run-telemetry.js +1 -1
  188. package/dist/telemetry/verification-of.js +2 -2
  189. package/dist/tools/act-tools.js +126 -21
  190. package/dist/tools/assert-source.d.ts +22 -0
  191. package/dist/tools/assert-source.js +53 -0
  192. package/dist/tools/assert-verdict.d.ts +20 -3
  193. package/dist/tools/assert-verdict.js +36 -4
  194. package/dist/tools/browser-tools.js +0 -20
  195. package/dist/tools/complete-snapshot.d.ts +48 -0
  196. package/dist/tools/complete-snapshot.js +75 -0
  197. package/dist/tools/coverage-tools.d.ts +10 -1
  198. package/dist/tools/coverage-tools.js +123 -69
  199. package/dist/tools/dynamic-tools.js +29 -8
  200. package/dist/tools/error-recovery.d.ts +24 -14
  201. package/dist/tools/error-recovery.js +70 -9
  202. package/dist/tools/feedback-tools.d.ts +1 -1
  203. package/dist/tools/feedback-tools.js +2 -2
  204. package/dist/tools/invoke-tool.js +62 -2
  205. package/dist/tools/lease-hint.d.ts +10 -0
  206. package/dist/tools/lease-hint.js +53 -4
  207. package/dist/tools/lease-tools.d.ts +5 -1
  208. package/dist/tools/lease-tools.js +22 -8
  209. package/dist/tools/navigate-arrival.d.ts +0 -2
  210. package/dist/tools/navigate-arrival.js +1 -1
  211. package/dist/tools/observe-tools.js +63 -26
  212. package/dist/tools/read-tools.js +5 -3
  213. package/dist/tools/real-input-attempt.d.ts +22 -0
  214. package/dist/tools/real-input-attempt.js +153 -1
  215. package/dist/tools/reconcile-tools.js +18 -4
  216. package/dist/tools/resolve-target.d.ts +0 -7
  217. package/dist/tools/sdk-marker-probe.d.ts +29 -0
  218. package/dist/tools/sdk-marker-probe.js +57 -0
  219. package/dist/tools/snapshot-delta.d.ts +1 -1
  220. package/dist/tools/tool-kit.d.ts +12 -0
  221. package/dist/tools/tool-kit.js +16 -0
  222. package/dist/tools/tool-names.d.ts +3 -0
  223. package/dist/tools/tool-names.js +3 -0
  224. package/dist/tools/tool-surface.d.ts +85 -6
  225. package/dist/tools/tool-surface.js +122 -2
  226. package/dist/tools/tools.js +22 -4
  227. package/dist/tools/unadvertised-help.js +4 -4
  228. package/dist/tools/verify-next-baton.d.ts +53 -0
  229. package/dist/tools/verify-next-baton.js +52 -0
  230. package/dist/update/update-nudge.d.ts +7 -10
  231. package/dist/update/update-nudge.js +12 -3
  232. package/dist/update/updater.d.ts +1 -1
  233. package/docs/README.md +2 -1
  234. package/docs/cli/cloud.mdx +1 -1
  235. package/docs/cli/doctor.mdx +1 -0
  236. package/docs/cli/verify.mdx +4 -5
  237. package/docs/desktop.mdx +1 -1
  238. package/docs/docs.json +2 -2
  239. package/docs/gates.md +1 -1
  240. package/docs/install-manual.mdx +1 -1
  241. package/docs/packages/browser.mdx +2 -2
  242. package/docs/packages/server.mdx +1 -1
  243. package/docs/packages.mdx +8 -6
  244. package/docs/platform-integration.md +17 -0
  245. package/docs/predicates.mdx +8 -0
  246. package/docs/reticle-manual.md +106 -0
  247. package/docs/tools-tools-and-run.mdx +2 -4
  248. package/docs/troubleshooting.mdx +9 -0
  249. package/docs/usage.md +3 -2
  250. package/package.json +2 -2
  251. package/dist/session/session-readiness.d.ts +0 -34
  252. 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
  *
@@ -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 { asNumber, asString } from '../tools/tools-helpers.js';
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
- // Reachable only for a project that HAS been through `init` — the uninstrumented case is
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
- reason: 'nothing is listening on the ports Reticle scans, so the app is not running. This is the ' +
45
- `project's own \`${dev.script}\` script — run it in the background, tell the human it is ` +
46
- 'running, then call reticle_sessions again.',
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: isWired(),
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(() => diagnoseNoSession({
155
- everConnected: options.sessions.everConnected(),
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(),
163
- listening,
164
- slowListeners,
165
- port: options.port,
166
- // The directory `initialized` was decided in. Named in the message because "there is no
167
- // `.reticle.json`" is a claim about ONE directory, and a reader standing somewhere else
168
- // cannot tell whether it is a claim about their app at all.
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
- })(),
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
- })(),
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
- return { bytes: json.length, tokens: estimateTokens(json) };
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
  }