@reticlehq/server 2.10.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (290) hide show
  1. package/SKILL.md +73 -11
  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 +41 -0
  45. package/dist/dev/stale-issue-guard.js +75 -0
  46. package/dist/domain/domain-tools.js +3 -2
  47. package/dist/events/contradictions.d.ts +41 -0
  48. package/dist/events/contradictions.js +125 -12
  49. package/dist/events/declared.d.ts +14 -0
  50. package/dist/events/declared.js +33 -0
  51. package/dist/events/echo-mismatch.js +14 -0
  52. package/dist/events/observed-in-window.d.ts +12 -0
  53. package/dist/events/observed-in-window.js +17 -0
  54. package/dist/events/predicate-asks.d.ts +30 -0
  55. package/dist/events/predicate-asks.js +48 -0
  56. package/dist/events/predicate-eval.js +254 -25
  57. package/dist/events/predicate-parse.d.ts +0 -6
  58. package/dist/events/predicate-parse.js +51 -5
  59. package/dist/events/predicate-schema.d.ts +62 -0
  60. package/dist/events/predicate-schema.js +162 -4
  61. package/dist/events/predicate.d.ts +15 -0
  62. package/dist/events/predicate.js +41 -2
  63. package/dist/flows/coverage.d.ts +0 -8
  64. package/dist/flows/coverage.js +1 -1
  65. package/dist/flows/decision.d.ts +5 -1
  66. package/dist/flows/decision.js +33 -7
  67. package/dist/flows/flake.d.ts +0 -2
  68. package/dist/flows/flake.js +1 -1
  69. package/dist/flows/flow-intent.d.ts +52 -0
  70. package/dist/flows/flow-intent.js +123 -0
  71. package/dist/flows/flow-replay-run.js +27 -4
  72. package/dist/flows/flow-success.js +15 -3
  73. package/dist/flows/flow-tools.js +65 -8
  74. package/dist/flows/flows.d.ts +6 -1
  75. package/dist/flows/flows.js +34 -22
  76. package/dist/flows/predicate-to-expect.js +14 -3
  77. package/dist/flows/server-verify.js +16 -2
  78. package/dist/flows/verify-change-tools.js +9 -2
  79. package/dist/honesty/blind-spots.d.ts +8 -16
  80. package/dist/honesty/blind-spots.js +18 -5
  81. package/dist/honesty/feature-capture.d.ts +150 -0
  82. package/dist/honesty/feature-capture.js +233 -0
  83. package/dist/honesty/gap-ledger.d.ts +52 -0
  84. package/dist/honesty/gap-ledger.js +63 -0
  85. package/dist/honesty/instrumentation-gaps.d.ts +79 -0
  86. package/dist/honesty/instrumentation-gaps.js +77 -0
  87. package/dist/honesty/observability.d.ts +37 -0
  88. package/dist/honesty/observability.js +40 -0
  89. package/dist/honesty/tool-hit-rate.d.ts +86 -0
  90. package/dist/honesty/tool-hit-rate.js +90 -0
  91. package/dist/honesty/undeclared-change.d.ts +51 -0
  92. package/dist/honesty/undeclared-change.js +56 -0
  93. package/dist/honesty/verified.d.ts +15 -0
  94. package/dist/honesty/verified.js +68 -4
  95. package/dist/http-server.d.ts +0 -1
  96. package/dist/http-server.js +1 -1
  97. package/dist/hunt/hunt-report.d.ts +0 -2
  98. package/dist/hunt/hunt-report.js +1 -1
  99. package/dist/impact/impact-store.d.ts +0 -5
  100. package/dist/impact/impact-store.js +1 -1
  101. package/dist/index.d.ts +4 -2
  102. package/dist/index.js +41 -0
  103. package/dist/init/agent-rules.d.ts +17 -0
  104. package/dist/init/agent-rules.js +31 -1
  105. package/dist/init/capabilities.d.ts +0 -2
  106. package/dist/init/capabilities.js +1 -1
  107. package/dist/init/closing-hint.d.ts +36 -0
  108. package/dist/init/closing-hint.js +120 -0
  109. package/dist/init/confirm.d.ts +0 -25
  110. package/dist/init/confirm.js +3 -3
  111. package/dist/init/cra.d.ts +14 -1
  112. package/dist/init/cra.js +43 -12
  113. package/dist/init/dev-script.d.ts +48 -0
  114. package/dist/init/dev-script.js +63 -0
  115. package/dist/init/mcp.d.ts +16 -5
  116. package/dist/init/mcp.js +26 -10
  117. package/dist/init/node-io.js +3 -0
  118. package/dist/init/plan-framework.d.ts +0 -5
  119. package/dist/init/plan-framework.js +83 -9
  120. package/dist/init/plan.d.ts +10 -30
  121. package/dist/init/plan.js +30 -15
  122. package/dist/init/refresh-rules.d.ts +39 -0
  123. package/dist/init/refresh-rules.js +51 -0
  124. package/dist/init/run.d.ts +2 -10
  125. package/dist/init/run.js +26 -68
  126. package/dist/init/slash-command.d.ts +1 -1
  127. package/dist/init/slash-command.js +33 -10
  128. package/dist/init/snippets.js +53 -10
  129. package/dist/input/real-input.d.ts +2 -0
  130. package/dist/input/real-input.js +3 -2
  131. package/dist/input/scroll-find.d.ts +7 -4
  132. package/dist/input/scroll-find.js +63 -25
  133. package/dist/intent/inline-intent.d.ts +46 -0
  134. package/dist/intent/inline-intent.js +103 -0
  135. package/dist/intent/intent-store.d.ts +37 -0
  136. package/dist/intent/intent-store.js +112 -0
  137. package/dist/intent/intent-tools.d.ts +2 -0
  138. package/dist/intent/intent-tools.js +80 -0
  139. package/dist/intent/open-intents.d.ts +21 -0
  140. package/dist/intent/open-intents.js +25 -0
  141. package/dist/journal/envelope.d.ts +2 -2
  142. package/dist/journal/journal-recorder.d.ts +23 -0
  143. package/dist/journal/journal-recorder.js +35 -0
  144. package/dist/license/license.d.ts +8 -2
  145. package/dist/license/license.js +10 -15
  146. package/dist/mcp/attach-memory.d.ts +67 -0
  147. package/dist/mcp/attach-memory.js +142 -0
  148. package/dist/mcp/mcp-proxy.d.ts +0 -7
  149. package/dist/mcp/mcp-proxy.js +14 -2
  150. package/dist/mcp/mcp.d.ts +2 -0
  151. package/dist/mcp/mcp.js +45 -23
  152. package/dist/mcp/server-instructions.js +45 -3
  153. package/dist/mcp/tool-catalog-cache.d.ts +8 -0
  154. package/dist/mcp/tool-catalog-cache.js +10 -0
  155. package/dist/oracles/flow-instrument-gaps.d.ts +4 -4
  156. package/dist/oracles/flow-instrument-gaps.js +4 -5
  157. package/dist/oracles/self-instrument.d.ts +9 -9
  158. package/dist/oracles/self-instrument.js +19 -13
  159. package/dist/pool/browser-pool.d.ts +12 -1
  160. package/dist/pool/browser-pool.js +31 -1
  161. package/dist/pool/playwright-launcher.js +3 -1
  162. package/dist/project/artifact-root.d.ts +83 -0
  163. package/dist/project/artifact-root.js +112 -0
  164. package/dist/project/fs-port.d.ts +3 -0
  165. package/dist/project/fs-port.js +3 -2
  166. package/dist/project/memory-fs.d.ts +9 -0
  167. package/dist/project/memory-fs.js +93 -0
  168. package/dist/project/project-store.d.ts +17 -0
  169. package/dist/project/project-store.js +33 -0
  170. package/dist/project/project-tools.js +3 -32
  171. package/dist/project/remember-project.d.ts +24 -0
  172. package/dist/project/remember-project.js +40 -0
  173. package/dist/project/reticle-dir.d.ts +2 -0
  174. package/dist/project/reticle-dir.js +1 -0
  175. package/dist/project/session-root.d.ts +15 -0
  176. package/dist/project/session-root.js +31 -0
  177. package/dist/runs/context-tools.d.ts +2 -0
  178. package/dist/runs/context-tools.js +89 -0
  179. package/dist/runs/run-context.d.ts +40 -0
  180. package/dist/runs/run-context.js +158 -0
  181. package/dist/runs/run-diff.d.ts +2 -1
  182. package/dist/runs/run-tools.js +2 -1
  183. package/dist/runs/verification-sync.js +2 -2
  184. package/dist/session/dev-server-probe.d.ts +0 -8
  185. package/dist/session/dev-server-probe.js +1 -1
  186. package/dist/session/last-act.d.ts +7 -1
  187. package/dist/session/last-act.js +2 -2
  188. package/dist/session/lease-availability.d.ts +40 -0
  189. package/dist/session/lease-availability.js +43 -0
  190. package/dist/session/live-control-tools.js +1 -42
  191. package/dist/session/no-session-diagnosis.d.ts +20 -0
  192. package/dist/session/no-session-diagnosis.js +41 -12
  193. package/dist/session/no-session-next-action.d.ts +5 -0
  194. package/dist/session/no-session-next-action.js +35 -3
  195. package/dist/session/no-session-watch.d.ts +8 -0
  196. package/dist/session/no-session-watch.js +83 -65
  197. package/dist/session/output-budget.js +4 -1
  198. package/dist/session/session-health.d.ts +9 -0
  199. package/dist/session/session-health.js +23 -0
  200. package/dist/session/session-manager.js +50 -1
  201. package/dist/session/session-successor.d.ts +37 -0
  202. package/dist/session/session-successor.js +78 -0
  203. package/dist/session/session.d.ts +40 -1
  204. package/dist/session/session.js +84 -2
  205. package/dist/session/stall-clock.d.ts +18 -0
  206. package/dist/session/stall-clock.js +48 -0
  207. package/dist/telemetry/automation-hint.d.ts +2 -1
  208. package/dist/telemetry/daemon-telemetry.js +12 -23
  209. package/dist/telemetry/feedback-outbox.d.ts +2 -1
  210. package/dist/telemetry/project-profile.d.ts +0 -8
  211. package/dist/telemetry/project-profile.js +1 -1
  212. package/dist/telemetry/run-telemetry.d.ts +0 -2
  213. package/dist/telemetry/run-telemetry.js +1 -1
  214. package/dist/telemetry/session-metrics.d.ts +1 -1
  215. package/dist/telemetry/session-metrics.js +1 -1
  216. package/dist/telemetry/telemetry.d.ts +12 -3
  217. package/dist/telemetry/telemetry.js +25 -5
  218. package/dist/telemetry/verification-of.js +2 -2
  219. package/dist/tools/act-tools.js +159 -24
  220. package/dist/tools/assert-source.d.ts +22 -0
  221. package/dist/tools/assert-source.js +53 -0
  222. package/dist/tools/assert-verdict.d.ts +21 -2
  223. package/dist/tools/assert-verdict.js +59 -4
  224. package/dist/tools/browser-tools.js +0 -20
  225. package/dist/tools/complete-snapshot.d.ts +48 -0
  226. package/dist/tools/complete-snapshot.js +75 -0
  227. package/dist/tools/contract-tools.js +5 -3
  228. package/dist/tools/coverage-tools.d.ts +10 -1
  229. package/dist/tools/coverage-tools.js +124 -37
  230. package/dist/tools/dynamic-tools.js +29 -8
  231. package/dist/tools/error-recovery.d.ts +24 -14
  232. package/dist/tools/error-recovery.js +70 -9
  233. package/dist/tools/feedback-tools.d.ts +1 -1
  234. package/dist/tools/feedback-tools.js +2 -2
  235. package/dist/tools/invoke-tool.js +62 -2
  236. package/dist/tools/lease-hint.d.ts +10 -0
  237. package/dist/tools/lease-hint.js +53 -4
  238. package/dist/tools/lease-tools.d.ts +5 -1
  239. package/dist/tools/lease-tools.js +22 -8
  240. package/dist/tools/navigate-arrival.d.ts +0 -2
  241. package/dist/tools/navigate-arrival.js +1 -1
  242. package/dist/tools/observe-tools.js +70 -27
  243. package/dist/tools/read-tools.js +5 -3
  244. package/dist/tools/real-input-attempt.d.ts +22 -0
  245. package/dist/tools/real-input-attempt.js +153 -1
  246. package/dist/tools/reconcile-tools.js +18 -4
  247. package/dist/tools/resolve-target.d.ts +0 -7
  248. package/dist/tools/sdk-marker-probe.d.ts +29 -0
  249. package/dist/tools/sdk-marker-probe.js +57 -0
  250. package/dist/tools/snapshot-delta.d.ts +1 -1
  251. package/dist/tools/tool-kit.d.ts +28 -1
  252. package/dist/tools/tool-kit.js +16 -0
  253. package/dist/tools/tool-names.d.ts +3 -0
  254. package/dist/tools/tool-names.js +3 -0
  255. package/dist/tools/tool-surface.d.ts +85 -6
  256. package/dist/tools/tool-surface.js +122 -2
  257. package/dist/tools/tools.js +22 -4
  258. package/dist/tools/unadvertised-help.js +4 -4
  259. package/dist/tools/verify-next-baton.d.ts +53 -0
  260. package/dist/tools/verify-next-baton.js +52 -0
  261. package/dist/update/update-nudge.d.ts +7 -10
  262. package/dist/update/update-nudge.js +12 -3
  263. package/dist/update/updater.d.ts +1 -1
  264. package/docs/README.md +3 -1
  265. package/docs/cli/cloud.mdx +1 -1
  266. package/docs/cli/doctor.mdx +1 -0
  267. package/docs/cli/verify.mdx +4 -5
  268. package/docs/desktop.mdx +1 -1
  269. package/docs/docs.json +3 -3
  270. package/docs/enterprise.md +3 -1
  271. package/docs/gates.md +1 -1
  272. package/docs/install-manual.mdx +1 -1
  273. package/docs/license-activation.mdx +145 -0
  274. package/docs/packages/browser.mdx +2 -2
  275. package/docs/packages/server.mdx +1 -1
  276. package/docs/packages.mdx +8 -6
  277. package/docs/platform-integration.md +17 -0
  278. package/docs/predicates.mdx +8 -0
  279. package/docs/reticle-manual.md +106 -0
  280. package/docs/telemetry-contract.md +1 -12
  281. package/docs/telemetry-events.mdx +1 -34
  282. package/docs/telemetry.md +0 -1
  283. package/docs/tools-tools-and-run.mdx +2 -4
  284. package/docs/troubleshooting.mdx +9 -0
  285. package/docs/usage.md +3 -2
  286. package/package.json +2 -2
  287. package/dist/session/session-readiness.d.ts +0 -34
  288. package/dist/session/session-readiness.js +0 -36
  289. package/dist/telemetry/instrumentation-stall.d.ts +0 -57
  290. package/dist/telemetry/instrumentation-stall.js +0 -103
@@ -0,0 +1,158 @@
1
+ import { EventType, JournalVerdictEffectSchema, RUN_ESTABLISHED_CAP, buildRunContext, } from '@reticlehq/core';
2
+ /**
3
+ * The run, derived from the journal that is already there.
4
+ *
5
+ * `.reticle/sessions/<id>/` already holds an append-only causal ledger of every action dispatched
6
+ * and every event observed. The run is a FOLD over that, never a second store — the same argument
7
+ * that deleted `instrumentation_stalled` from this codebase applies without modification: two ways
8
+ * to compute one number is worse than one way, because the day they disagree the disagreement lands
9
+ * inside a verdict and a user finds it before we do. So nothing here writes: it reads the ledger and
10
+ * folds it, and if the fold is wrong the ledger is still the single authority.
11
+ *
12
+ * **One run per session, for now.** Nothing here assumes it: the fold is pure and takes its evidence
13
+ * as arguments, so a later multi-run or multi-agent model is a change of caller, not a rewrite. It
14
+ * is not built today because nobody has hit the case, and a coordination model invented ahead of its
15
+ * first user is a guess.
16
+ *
17
+ * Only what Reticle OBSERVED goes in. A settle outcome is an observation. A route change is an
18
+ * observation. A verdict a verification tool recorded is an observation. What the agent meant to do
19
+ * next is not, and never enters here.
20
+ */
21
+ /**
22
+ * The one key the route is filed under.
23
+ *
24
+ * The subject of "the app is on /checkout" is the route itself, so a later route change SUPERSEDES
25
+ * rather than appends. Filing it per-path would accumulate every page ever visited, which is the
26
+ * token bomb the cap exists to prevent.
27
+ */
28
+ const ROUTE_KEY = 'route';
29
+ /** The conclusions this fold is allowed to state, as prose rather than transcript. */
30
+ const Fact = {
31
+ route: (to) => `route is ${to}`,
32
+ settled: (verb) => `${verb} settled`,
33
+ unsettled: (verb) => `${verb} did not settle`,
34
+ };
35
+ /**
36
+ * The SUBJECT a journal action was about: the ref it spent, else the target it resolved from.
37
+ *
38
+ * Exported so the feature-use instrument files a re-fetch under the same key this fold establishes
39
+ * it under. A second copy of this rule there would be a second definition of "the same subject".
40
+ */
41
+ export function subjectOf(action) {
42
+ const ref = action.args['ref'];
43
+ if ('string' === typeof ref && ref.length > 0)
44
+ return ref;
45
+ const target = action.args['target'];
46
+ if ('string' === typeof target && target.length > 0)
47
+ return target;
48
+ return undefined;
49
+ }
50
+ /** What the action did, in the caller's own vocabulary — 'click', 'fill', else the tool's name. */
51
+ function verbOf(action) {
52
+ const named = action.args['action'];
53
+ return 'string' === typeof named && named.length > 0 ? named : action.tool;
54
+ }
55
+ /** The verdict a verification tool recorded on this action, if it recorded one. */
56
+ function verdictOf(action) {
57
+ const parsed = JournalVerdictEffectSchema.safeParse(action.effect);
58
+ return parsed.success ? parsed.data : undefined;
59
+ }
60
+ /** Which document and edit epoch each action was observed under, read off its attributed events. */
61
+ function scopeByAction(events) {
62
+ const out = new Map();
63
+ for (const event of events) {
64
+ if (event.actionId === undefined)
65
+ continue;
66
+ if (event.documentId === undefined && event.editEpoch === undefined)
67
+ continue;
68
+ const existing = out.get(event.actionId) ?? {};
69
+ out.set(event.actionId, {
70
+ doc: event.documentId ?? existing.doc,
71
+ epoch: event.editEpoch ?? existing.epoch,
72
+ });
73
+ }
74
+ return out;
75
+ }
76
+ /**
77
+ * Fold the journal into candidate facts, newest last.
78
+ *
79
+ * Only the last `RUN_ESTABLISHED_CAP` actions are read: anything older cannot survive the fold, so
80
+ * parsing it would be work done to throw away. An action with no observed settle outcome states
81
+ * nothing and is skipped — "we drove it and cannot say what happened" is not an established fact,
82
+ * and writing it down as one is how a memory starts lying.
83
+ */
84
+ export function establishedFromJournal(actions, events) {
85
+ const scope = scopeByAction(events);
86
+ let route;
87
+ for (const event of events) {
88
+ if (EventType.ROUTE_CHANGE !== event.type)
89
+ continue;
90
+ const pathname = event.data['pathname'];
91
+ const to = 'string' === typeof pathname ? pathname : event.data['to'];
92
+ if ('string' !== typeof to)
93
+ continue;
94
+ route = { key: ROUTE_KEY, fact: Fact.route(to), doc: event.documentId, epoch: event.editEpoch };
95
+ }
96
+ const out = [];
97
+ for (const action of actions.slice(-RUN_ESTABLISHED_CAP)) {
98
+ const settled = action.settled;
99
+ if (undefined === settled)
100
+ continue;
101
+ const key = subjectOf(action);
102
+ if (undefined === key)
103
+ continue;
104
+ const verb = verbOf(action);
105
+ out.push({
106
+ key,
107
+ fact: settled ? Fact.settled(verb) : Fact.unsettled(verb),
108
+ // The source pointer the verdict already reported. Read off the ledger rather than looked up
109
+ // again: a second lookup could disagree with the one the agent was shown.
110
+ source: verdictOf(action)?.source,
111
+ ...(scope.get(action.actionId) ?? {}),
112
+ });
113
+ }
114
+ if (route !== undefined)
115
+ out.push(route);
116
+ return out;
117
+ }
118
+ /**
119
+ * What a verdict already settled, newest last.
120
+ *
121
+ * Both verdict tools write here. `reticle_act_and_wait` closes its attribution window with one;
122
+ * `reticle_assert` drives nothing, so it appends its record WITHOUT opening a window — same effect
123
+ * shape, so this fold reads one kind of verdict rather than two.
124
+ */
125
+ export function provenFromJournal(actions, events) {
126
+ const scope = scopeByAction(events);
127
+ const out = [];
128
+ for (const action of actions.slice(-RUN_ESTABLISHED_CAP)) {
129
+ const verdict = verdictOf(action);
130
+ if (undefined === verdict)
131
+ continue;
132
+ out.push({
133
+ claim: verdict.claim,
134
+ verified: verdict.verified,
135
+ source: verdict.source,
136
+ ...(scope.get(action.actionId) ?? {}),
137
+ });
138
+ }
139
+ return out;
140
+ }
141
+ /**
142
+ * The context for one run.
143
+ *
144
+ * `step` is the journal's action count rather than a counter of its own, for the reason at the top
145
+ * of this file: a second counter is a second thing that can disagree with the ledger. The document
146
+ * and epoch are the session's own, so the staleness rule here is the same one every other verdict in
147
+ * this codebase is scoped by.
148
+ */
149
+ export function runContextFor(input) {
150
+ return buildRunContext({
151
+ step: input.actions.length,
152
+ intents: input.intents,
153
+ established: establishedFromJournal(input.actions, input.events),
154
+ proven: provenFromJournal(input.actions, input.events),
155
+ currentDocumentId: input.currentDocumentId,
156
+ currentEditEpoch: input.currentEditEpoch,
157
+ });
158
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ReticleVerificationRun } from '@reticlehq/core';
2
2
  /** A per-flow change between two runs. Only flows with a meaningful change are reported. */
3
- export interface FlowDelta {
3
+ interface FlowDelta {
4
4
  name: string;
5
5
  beforeMs: number;
6
6
  afterMs: number;
@@ -29,3 +29,4 @@ export interface RunDiff {
29
29
  * console diffs land once runs persist per-step detail.
30
30
  */
31
31
  export declare function diffRuns(before: ReticleVerificationRun, after: ReticleVerificationRun, noiseFloorPct?: number): RunDiff;
32
+ export {};
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { sessionRoot } from '../project/session-root.js';
2
3
  import { RunReadError } from '@reticlehq/core';
3
4
  import { ReticleTool } from '../tools/tool-names.js';
4
5
  import { asString } from '../tools/tools-helpers.js';
@@ -36,7 +37,7 @@ export const RUN_TOOLS = [
36
37
  error: z.string().optional(),
37
38
  },
38
39
  handler: async (deps, args) => {
39
- const store = new RunStore(deps.fs, deps.reticleRoot);
40
+ const store = new RunStore(deps.fs, sessionRoot(deps, asString(args['sessionId'])));
40
41
  // format:"diff" is a whole-history operation (two most-recent runs), not a single-run read.
41
42
  if ('diff' === asString(args['format'])) {
42
43
  const pair = await store.latestTwo();
@@ -14,7 +14,7 @@ import { buildVerificationRun } from './build-verification-run.js';
14
14
  import { mapReplayToFlowResult } from './replay-mapping.js';
15
15
  import { defaultRunId } from './runner-port.js';
16
16
  import { RunStore } from './run-store.js';
17
- import { syncRunToCloud, SyncOutcome } from '../cloud/cloud-sync.js';
17
+ import { cloudFetch, syncRunToCloud, SyncOutcome } from '../cloud/cloud-sync.js';
18
18
  import { resolveProjectCloud } from '../cloud/cloud-config.js';
19
19
  import { log } from '../log.js';
20
20
  /** Assemble the ReticleVerificationRun artifact for a verify suite (pure — no IO). */
@@ -58,7 +58,7 @@ export async function persistAndSyncVerificationRun(deps, timed, projectId) {
58
58
  const cloud = await resolveProjectCloud(deps.fs, deps.reticleRoot, homedir(), process.env);
59
59
  if (null === cloud.config || !cloud.policy.runs)
60
60
  return run.runId; // not attached / runs disabled → local only
61
- const result = await syncRunToCloud(run, cloud.config, (url, init) => fetch(url, init));
61
+ const result = await syncRunToCloud(run, cloud.config, cloudFetch);
62
62
  if (result.outcome !== SyncOutcome.SYNCED) {
63
63
  log('cloud-run-sync-failed', { runId: run.runId, status: result.status, error: result.error });
64
64
  }
@@ -69,13 +69,5 @@ export declare function probeDevServerStates(ports?: readonly number[], probe?:
69
69
  serving: number[];
70
70
  slow: number[];
71
71
  }>;
72
- /**
73
- * The strongest state either loopback family reports.
74
- *
75
- * Serving a document beats a bare connection beats nothing, so a server bound to only one family is
76
- * still seen at its best — the same "either, not both" rule `anyFamilyServes` follows, extended to
77
- * three states.
78
- */
79
- export declare function anyFamilyState(port: number, probe?: (port: number, host: string) => Promise<PortState>): Promise<PortState>;
80
72
  /** Every candidate port serving a document, in ascending order. Never rejects. */
81
73
  export declare function probeDevServers(ports?: readonly number[], probe?: (port: number) => Promise<boolean>): Promise<number[]>;
@@ -132,7 +132,7 @@ export async function probeDevServerStates(ports = [...DEV_SERVER_PORTS], probe
132
132
  * still seen at its best — the same "either, not both" rule `anyFamilyServes` follows, extended to
133
133
  * three states.
134
134
  */
135
- export async function anyFamilyState(port, probe = probeState) {
135
+ async function anyFamilyState(port, probe = probeState) {
136
136
  const answers = await Promise.all(PROBE_HOSTS.map((host) => probe(port, host).catch(() => PortState.CLOSED)));
137
137
  if (answers.includes(PortState.SERVES_DOCUMENT))
138
138
  return PortState.SERVES_DOCUMENT;
@@ -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.
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Whether the self-serve escape hatch can actually be taken, said in the same breath as the offer.
3
+ *
4
+ * `reticle_lease` is what `status`, `doctor` and the no-session diagnosis all recommend when no tab
5
+ * is connected: it opens a browser Reticle drives itself, so the agent does not have to wait for a
6
+ * human. It is the right advice and it is the one thing that cannot work when Chromium is missing or
7
+ * sitting at a revision this Playwright will not use — and the command giving the advice is usually
8
+ * the command that just diagnosed exactly that.
9
+ *
10
+ * Reported from the field, verbatim in shape: `doctor` printed `chromium ✗ wrong revision — the
11
+ * bundled playwright wants chromium-1234; the cache holds chromium-1228`, and then offered the lease
12
+ * as the way out. The reporter followed it, and could not have succeeded.
13
+ *
14
+ * Two things this refuses to do:
15
+ *
16
+ * **It does not fire on an absent probe.** A probe that could not run is not evidence the browser is
17
+ * missing, and a caveat asserting otherwise would be the same overclaiming this file exists to stop.
18
+ *
19
+ * **It does not tell the agent to install the browser.** A ~150MB download on somebody else's
20
+ * machine is not an agent's call to make unprompted, so the message names the command and hands it
21
+ * to the human rather than implying the agent should just run it.
22
+ */
23
+ /** What the caller learned about the browser. Gathered by the caller so this stays pure. */
24
+ export interface LeaseBrowserState {
25
+ /** Does the executable this Playwright wants actually exist on disk? */
26
+ exists: boolean;
27
+ /** The install command, already pinned to the bundled Playwright by `chromium-hint`. */
28
+ installCommand?: string | undefined;
29
+ /** The revision this Playwright wants, when the path names one. */
30
+ wantedRevision?: string | undefined;
31
+ /** Chromium revisions actually present, wanted or not. */
32
+ installedRevisions?: readonly string[] | undefined;
33
+ }
34
+ /**
35
+ * The sentence to append to a lease recommendation, or undefined when the lease is fine.
36
+ *
37
+ * Undefined rather than an empty string on the healthy path: a caller composing a message should not
38
+ * have to filter blanks, and a healthy install must pay nothing at all for this check.
39
+ */
40
+ export declare function leaseCaveat(state: LeaseBrowserState | undefined): string | undefined;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Whether the self-serve escape hatch can actually be taken, said in the same breath as the offer.
3
+ *
4
+ * `reticle_lease` is what `status`, `doctor` and the no-session diagnosis all recommend when no tab
5
+ * is connected: it opens a browser Reticle drives itself, so the agent does not have to wait for a
6
+ * human. It is the right advice and it is the one thing that cannot work when Chromium is missing or
7
+ * sitting at a revision this Playwright will not use — and the command giving the advice is usually
8
+ * the command that just diagnosed exactly that.
9
+ *
10
+ * Reported from the field, verbatim in shape: `doctor` printed `chromium ✗ wrong revision — the
11
+ * bundled playwright wants chromium-1234; the cache holds chromium-1228`, and then offered the lease
12
+ * as the way out. The reporter followed it, and could not have succeeded.
13
+ *
14
+ * Two things this refuses to do:
15
+ *
16
+ * **It does not fire on an absent probe.** A probe that could not run is not evidence the browser is
17
+ * missing, and a caveat asserting otherwise would be the same overclaiming this file exists to stop.
18
+ *
19
+ * **It does not tell the agent to install the browser.** A ~150MB download on somebody else's
20
+ * machine is not an agent's call to make unprompted, so the message names the command and hands it
21
+ * to the human rather than implying the agent should just run it.
22
+ */
23
+ /**
24
+ * The sentence to append to a lease recommendation, or undefined when the lease is fine.
25
+ *
26
+ * Undefined rather than an empty string on the healthy path: a caller composing a message should not
27
+ * have to filter blanks, and a healthy install must pay nothing at all for this check.
28
+ */
29
+ export function leaseCaveat(state) {
30
+ if (state === undefined || state.exists)
31
+ return undefined;
32
+ // Present-at-the-wrong-revision and absent-entirely have different fixes and different readings,
33
+ // and collapsing them is what made the reported loop unbreakable — "the check is broken" and
34
+ // "none of these count" look identical from a flat `missing`.
35
+ const installed = state.installedRevisions ?? [];
36
+ const mismatch = state.wantedRevision !== undefined && installed.length > 0
37
+ ? ` This Playwright wants ${state.wantedRevision} and the cache holds ${installed.join(', ')}, so the builds already on this machine will not be used.`
38
+ : '';
39
+ const command = state.installCommand === undefined ? '' : ` The fix is: ${state.installCommand}.`;
40
+ return ('NOTE — reticle_lease cannot run here: it drives a Reticle-owned Chromium and that browser is ' +
41
+ `not available.${mismatch}${command} That download is the human's call, not something to run ` +
42
+ 'unprompted on their machine, so hand this back rather than treating the lease as a way forward.');
43
+ }
@@ -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
@@ -18,6 +18,7 @@
18
18
  * Pure: everything it needs is passed in, so the probe that discovers listening ports stays out of
19
19
  * the hot resolve() path and this stays unit-testable.
20
20
  */
21
+ import { type LeaseBrowserState } from './lease-availability.js';
21
22
  export interface NoSessionFacts {
22
23
  /** Whether ANY session has connected to this daemon since it booted. */
23
24
  everConnected: boolean;
@@ -36,6 +37,15 @@ export interface NoSessionFacts {
36
37
  slowListeners?: readonly number[];
37
38
  /** The port this daemon is on — half of the mismatch the old message asked about. */
38
39
  port: number;
40
+ /**
41
+ * What the caller knows about the Chromium a lease would drive.
42
+ *
43
+ * Every branch below offers `reticle_lease` as the way not to wait for a human. When that browser
44
+ * is missing or at a revision this Playwright will not use, the offer is the one thing that cannot
45
+ * help — and it was being made by the same output that had just diagnosed exactly that. Optional:
46
+ * absent means the caller did not probe, which is not evidence the browser is missing.
47
+ */
48
+ leaseBrowser?: LeaseBrowserState | undefined;
39
49
  /**
40
50
  * The directory `initialized` was decided in, named in the message when it is known.
41
51
  *
@@ -107,6 +117,16 @@ export interface NoSessionFacts {
107
117
  * install never finished" to the user-facing diagnosis rather than only to telemetry.
108
118
  */
109
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[];
110
130
  }
111
131
  /**
112
132
  * Every branch ends with this, and `recoveryFor` keys on it to suppress the generic no-session
@@ -18,8 +18,10 @@
18
18
  * Pure: everything it needs is passed in, so the probe that discovers listening ports stays out of
19
19
  * the hot resolve() path and this stays unit-testable.
20
20
  */
21
+ import { leaseCaveat } from './lease-availability.js';
21
22
  import { DEV_SERVER_PORTS } from '../cli/cli-port.js';
22
- import { STALL_AFTER_MS } from '../telemetry/instrumentation-stall.js';
23
+ import { siblingListenerNote } from '../cli/sibling-ports.js';
24
+ import { STALL_AFTER_MS } from './stall-clock.js';
23
25
  /** The one framework whose most likely cause differs from every other framework's. */
24
26
  const NUXT = 'nuxt';
25
27
  /**
@@ -58,6 +60,17 @@ const RETRY = SELF_RECOVERING_MARKER;
58
60
  const URL_THEN_LEASE = 'Once you have that URL, open it with reticle_lease {action:"acquire", url} rather than a shell ' +
59
61
  'command — it needs no CLI on PATH (reach it with reticle_run {tool:"reticle_lease"} if it is ' +
60
62
  'not advertised directly).';
63
+ /**
64
+ * The lease advice, with the reason it cannot be taken when that is the case.
65
+ *
66
+ * A function rather than a constant so the caveat cannot be forgotten at one of the six sites that
67
+ * offer the lease — which is exactly how the offer came to be made by the command that had already
68
+ * diagnosed the problem.
69
+ */
70
+ function leaseAdvice(base, facts) {
71
+ const caveat = leaseCaveat(facts.leaseBrowser);
72
+ return caveat === undefined ? base : `${base} ${caveat}`;
73
+ }
61
74
  const SELF_SERVE = 'You do not have to wait for the human: reticle_lease {action:"acquire", url} opens a browser ' +
62
75
  'Reticle drives itself, and returns a sessionId you can use immediately (reach it with ' +
63
76
  'reticle_run {tool:"reticle_lease"} if it is not advertised directly; release it when you finish).';
@@ -205,6 +218,24 @@ function portMismatchClause(facts) {
205
218
  ` Either restart the daemon on ${String(configured)}, or update \`.reticle.json\` to` +
206
219
  ` ${String(facts.port)} and restart the dev server so the app picks it up.`);
207
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
+ }
208
239
  /**
209
240
  * The tail every "the app is wired and silent" branch ends with, ranked.
210
241
  *
@@ -216,7 +247,7 @@ function rankedCauses(facts) {
216
247
  const nuxt = facts.framework?.toLowerCase() === NUXT ? `${NUXT_STALE_BUNDLE} ` : '';
217
248
  return (`${nuxt}${REAL_CAUSES} If none of those, the app may be dialling a different daemon than this ` +
218
249
  `one (on ${String(facts.port)}): check the app's reticle port matches ` +
219
- `${String(facts.port)}.${portMismatchClause(facts)} ${NON_LOCALHOST_GATE}`);
250
+ `${String(facts.port)}.${portMismatchClause(facts)}${siblingListenerClause(facts)} ${NON_LOCALHOST_GATE}`);
220
251
  }
221
252
  /**
222
253
  * The scope answer, when the config was somewhere else all along.
@@ -292,7 +323,7 @@ export function diagnoseNoSession(facts) {
292
323
  }
293
324
  return ('no browser session connected, but one WAS connected to this daemon earlier, so the wiring ' +
294
325
  'is correct. The tab was closed, navigated away, or hard-reloaded. Ask the human to reopen ' +
295
- `the app (or run ${OPEN_CMD_BARE}), or reload the tab. ${SELF_SERVE} ${RETRY}`);
326
+ `the app (or run ${OPEN_CMD_BARE}), or reload the tab. ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`);
296
327
  }
297
328
  // Ranked ahead of every "we have never seen one" branch below, because it is the one fact that
298
329
  // OUTRANKS them: those branches all reason from an absence, and this reasons from a recorded
@@ -303,7 +334,7 @@ export function diagnoseNoSession(facts) {
303
334
  ? `${unattributedListeners(listening)} If the dev server is not running, start it first ` +
304
335
  '(the command is in `next_action`).'
305
336
  : unattributedListeners(listening);
306
- return `${RESTARTED_LEAD} ${OPEN_THE_APP} ${listeners} ${rankedCauses(facts)} ${SELF_SERVE} ${RETRY}`;
337
+ return `${RESTARTED_LEAD} ${OPEN_THE_APP} ${listeners} ${rankedCauses(facts)} ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`;
307
338
  }
308
339
  // A config found elsewhere outranks every "you may have no SDK" branch below: those reason from
309
340
  // an absence in ONE directory, and this is a file we read in another. Ranked here so the reader is
@@ -311,7 +342,7 @@ export function diagnoseNoSession(facts) {
311
342
  if (!initialized && (facts.configsElsewhere ?? []).length > 0) {
312
343
  return ('no browser session connected, and this daemon has never seen one. ' +
313
344
  `${configsElsewhereClause(facts)} ${unattributedListeners(listening)} ${OPEN_THE_APP} ` +
314
- `${rankedCauses(facts)} ${SELF_SERVE} ${RETRY}`);
345
+ `${rankedCauses(facts)} ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`);
315
346
  }
316
347
  if (0 === listening.length) {
317
348
  // Lead with the stronger EVIDENCE, and do not overstate what it is.
@@ -341,7 +372,7 @@ export function diagnoseNoSession(facts) {
341
372
  `file ${INIT_CMD} writes, so the app may carry no Reticle SDK — but check the app's ` +
342
373
  'OWN directory before re-running `init`: in a monorepo the daemon often runs at the root ' +
343
374
  'while the app lives in a subdirectory, and an app wired by the Vite or Babel plugin ' +
344
- `carries the SDK without that file at all.${searchedClause(facts)} ${URL_THEN_LEASE} ${RETRY}`);
375
+ `carries the SDK without that file at all.${searchedClause(facts)}${siblingListenerClause(facts)} ${leaseAdvice(URL_THEN_LEASE, facts)} ${RETRY}`);
345
376
  }
346
377
  return (`${stallClause(facts)}no browser session connected, and this daemon has never seen one. ${OPEN_THE_APP} ` +
347
378
  'Nothing is listening on the ports Reticle scans ' +
@@ -359,10 +390,8 @@ export function diagnoseNoSession(facts) {
359
390
  // Deliberately NOT offering reticle_lease here, and a test pins that: a lease opens a URL, and
360
391
  // if nothing is listening there is nothing at any URL to open. Asking for the real one is the
361
392
  // only move that can recover the :7699 case.
362
- `the app IS running, ask the human for its URL rather than assuming it is down. ${URL_THEN_LEASE} ` +
363
- // Reachable only for a project that HAS been through `init` — the uninstrumented case is
364
- // answered above, leading with that certainty instead of behind this scan's guess.
365
- `${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}`);
366
395
  }
367
396
  if (!initialized) {
368
397
  return ('no browser session connected, and this daemon has never seen one. ' +
@@ -372,10 +401,10 @@ export function diagnoseNoSession(facts) {
372
401
  "a subdirectory, or in an app wired by the Vite or Babel plugin. Check the app's OWN " +
373
402
  `directory: if it has no config, run ${INIT_CMD} there and restart the dev server; if it ` +
374
403
  `has one, the app is wired and simply has no page open — ${OPEN_CMD}. ` +
375
- `${unattributedListeners(listening)}${searchedClause(facts)} ${RETRY}`);
404
+ `${unattributedListeners(listening)}${searchedClause(facts)}${siblingListenerClause(facts)} ${RETRY}`);
376
405
  }
377
406
  return (`${stallClause(facts)}no browser session connected, and this daemon has never seen one for this project, which is ` +
378
407
  `wired for Reticle. ${OPEN_THE_APP} ${unattributedListeners(listening)} ` +
379
408
  'If the page IS open and still does not appear, the app is wired and the SDK is not reaching ' +
380
- `this daemon (on ${String(port)}). ${rankedCauses(facts)} ${SELF_SERVE} ${RETRY}`);
409
+ `this daemon (on ${String(port)}). ${rankedCauses(facts)} ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`);
381
410
  }
@@ -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