@reticlehq/server 3.1.0 → 3.2.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 (228) hide show
  1. package/SKILL.md +17 -18
  2. package/dist/command/cli/auth/device-flow.d.ts +2 -2
  3. package/dist/command/cli/cli-doctor.js +13 -1
  4. package/dist/command/cli/cli-flow-commands.d.ts +2 -1
  5. package/dist/command/cli/cli-kill.js +1 -1
  6. package/dist/command/cli/cli-parse.d.ts +2 -1
  7. package/dist/command/cli/cli-parse.js +5 -2
  8. package/dist/command/cli/cli-verify.js +2 -2
  9. package/dist/command/cli/cloud-cli.js +7 -6
  10. package/dist/command/cli/cloud-kit.js +2 -6
  11. package/dist/command/cli/config/config-discovery.d.ts +12 -0
  12. package/dist/command/cli/config/config-discovery.js +19 -0
  13. package/dist/command/cli/doctor/doctor-rows.d.ts +12 -0
  14. package/dist/command/cli/doctor/doctor-rows.js +12 -0
  15. package/dist/command/cli/drive/drive-command.js +1 -1
  16. package/dist/command/cli/launch/cli-launch.d.ts +10 -2
  17. package/dist/command/cli/launch/cli-launch.js +32 -39
  18. package/dist/command/cli/lifecycle/daemon-lifecycle.d.ts +33 -0
  19. package/dist/command/cli/lifecycle/daemon-lifecycle.js +198 -0
  20. package/dist/command/cli/mcp-command.js +5 -3
  21. package/dist/command/cli/ports/resolve/cli-port.d.ts +2 -1
  22. package/dist/command/cli/ports/resolve/cli-port.js +5 -1
  23. package/dist/command/cli/status/status-lines.d.ts +28 -0
  24. package/dist/command/cli/status/status-lines.js +117 -0
  25. package/dist/command/cli.d.ts +1 -1
  26. package/dist/command/cli.js +24 -177
  27. package/dist/command/daemon/binding/daemon-status-probe.d.ts +2 -0
  28. package/dist/command/daemon/binding/daemon-status-probe.js +48 -0
  29. package/dist/command/daemon/binding/loopback-reach.d.ts +42 -0
  30. package/dist/command/daemon/binding/loopback-reach.js +63 -0
  31. package/dist/command/daemon/daemon-resilience.d.ts +13 -1
  32. package/dist/command/daemon/daemon-resilience.js +12 -1
  33. package/dist/command/daemon/daemon.d.ts +2 -11
  34. package/dist/command/daemon/daemon.js +8 -11
  35. package/dist/command/daemon/lifetime/heartbeat.d.ts +8 -0
  36. package/dist/command/daemon/lifetime/heartbeat.js +10 -0
  37. package/dist/command/daemon/lifetime/log-cap.d.ts +68 -0
  38. package/dist/command/daemon/lifetime/log-cap.js +115 -0
  39. package/dist/command/setup/agent-notice-memory.d.ts +16 -0
  40. package/dist/command/setup/agent-notice-memory.js +65 -0
  41. package/dist/command/setup/bringup/dev-server-wait.d.ts +1 -0
  42. package/dist/command/setup/bringup/dev-server-wait.js +16 -0
  43. package/dist/command/setup/bringup/ensure-daemon.js +1 -1
  44. package/dist/command/setup/init/init-runtime.js +13 -1
  45. package/dist/command/setup/node-effects.js +1 -1
  46. package/dist/command/setup/probe/page-probe.js +9 -2
  47. package/dist/command/setup/run-setup.d.ts +25 -7
  48. package/dist/command/setup/run-setup.js +51 -9
  49. package/dist/command/setup/setup-command.js +31 -3
  50. package/dist/command/setup/terminal/confirm.js +2 -1
  51. package/dist/command/update/updater.d.ts +27 -0
  52. package/dist/command/update/updater.js +35 -2
  53. package/dist/features/crawl/crawl-tools.js +3 -1
  54. package/dist/features/crawl/crawl.js +2 -1
  55. package/dist/features/harness/drive-report.d.ts +73 -0
  56. package/dist/features/harness/drive-report.js +196 -0
  57. package/dist/features/harness/driver.js +29 -8
  58. package/dist/features/harness/drivers.d.ts +19 -0
  59. package/dist/features/harness/drivers.js +19 -0
  60. package/dist/features/harness/fill-values.d.ts +67 -0
  61. package/dist/features/harness/fill-values.js +153 -0
  62. package/dist/features/harness/jev-driver.d.ts +188 -0
  63. package/dist/features/harness/jev-driver.js +664 -0
  64. package/dist/features/harness/openai-driver.d.ts +46 -0
  65. package/dist/features/harness/openai-driver.js +197 -0
  66. package/dist/features/harness/platform-config.d.ts +52 -0
  67. package/dist/features/harness/platform-config.js +89 -0
  68. package/dist/features/visual/visual-tools.js +26 -2
  69. package/dist/index.js +60 -55
  70. package/dist/judgement/domain/domain-tools.js +4 -2
  71. package/dist/judgement/runs/verification-sync.d.ts +2 -2
  72. package/dist/judgement/runs/verification-sync.js +9 -2
  73. package/dist/language/flows/affected-tools.js +6 -3
  74. package/dist/language/flows/flow-replay-run.d.ts +25 -2
  75. package/dist/language/flows/flow-replay-run.js +73 -27
  76. package/dist/language/flows/flow-replay.d.ts +17 -0
  77. package/dist/language/flows/flow-replay.js +148 -100
  78. package/dist/language/flows/flow-result.d.ts +18 -1
  79. package/dist/language/flows/flow-result.js +18 -2
  80. package/dist/language/flows/flow-store-for-session.d.ts +2 -1
  81. package/dist/language/flows/flow-tools.js +8 -4
  82. package/dist/language/flows/flows.d.ts +8 -7
  83. package/dist/language/flows/flows.js +7 -3
  84. package/dist/language/flows/narrow-write.d.ts +4 -4
  85. package/dist/language/flows/replay.js +1 -1
  86. package/dist/language/flows/suite-selection.d.ts +15 -1
  87. package/dist/language/flows/suite-selection.js +1 -1
  88. package/dist/language/flows/verify-change-tools.js +7 -4
  89. package/dist/memory/cloud/account-state.d.ts +1 -1
  90. package/dist/memory/cloud/account-state.js +5 -4
  91. package/dist/memory/cloud/cloud-config.d.ts +9 -0
  92. package/dist/memory/cloud/cloud-config.js +9 -0
  93. package/dist/memory/cloud/cloud-sync.d.ts +7 -2
  94. package/dist/memory/cloud/cloud-sync.js +10 -5
  95. package/dist/memory/cloud/harness-config.d.ts +10 -0
  96. package/dist/memory/cloud/harness-config.js +58 -0
  97. package/dist/memory/cloud/harness-offer.d.ts +44 -0
  98. package/dist/memory/cloud/harness-offer.js +146 -0
  99. package/dist/memory/cloud/harness-switch.d.ts +11 -0
  100. package/dist/memory/cloud/harness-switch.js +53 -0
  101. package/dist/memory/cloud/sync-daemon.d.ts +22 -2
  102. package/dist/memory/cloud/sync-daemon.js +24 -0
  103. package/dist/memory/impact/impact-recorder.d.ts +8 -0
  104. package/dist/memory/impact/impact-recorder.js +2 -1
  105. package/dist/memory/impact/impact-store.d.ts +5 -0
  106. package/dist/memory/impact/impact-store.js +19 -0
  107. package/dist/memory/intent/intent-shard-store.js +2 -2
  108. package/dist/memory/intent/intent-shard.d.ts +12 -12
  109. package/dist/memory/journal/attach-journal.d.ts +11 -0
  110. package/dist/memory/journal/attach-journal.js +1 -1
  111. package/dist/memory/journal/journal-query.d.ts +35 -1
  112. package/dist/memory/journal/journal-query.js +46 -0
  113. package/dist/memory/journal/journal-recorder.d.ts +50 -2
  114. package/dist/memory/journal/journal-recorder.js +45 -7
  115. package/dist/memory/journal/on-disk/retention.d.ts +87 -2
  116. package/dist/memory/journal/on-disk/retention.js +177 -8
  117. package/dist/memory/journal/on-disk/workspace-gitignore.d.ts +9 -0
  118. package/dist/memory/journal/on-disk/workspace-gitignore.js +9 -2
  119. package/dist/memory/journal/self-observation.d.ts +3 -0
  120. package/dist/memory/journal/self-observation.js +67 -0
  121. package/dist/memory/journal/session-end.d.ts +13 -3
  122. package/dist/memory/journal/session-end.js +24 -4
  123. package/dist/memory/journal/session-journal.d.ts +115 -5
  124. package/dist/memory/journal/session-journal.js +479 -32
  125. package/dist/memory/project/artifact-root-resolver.d.ts +3 -0
  126. package/dist/memory/project/artifact-root-resolver.js +89 -0
  127. package/dist/memory/project/artifact-root.d.ts +39 -2
  128. package/dist/memory/project/artifact-root.js +58 -1
  129. package/dist/memory/project/dir/fill-value-store.d.ts +32 -0
  130. package/dist/memory/project/dir/fill-value-store.js +88 -0
  131. package/dist/memory/project/dir/reticle-dir.d.ts +14 -4
  132. package/dist/memory/project/dir/reticle-dir.js +11 -0
  133. package/dist/memory/project/fs/fs-port.d.ts +22 -1
  134. package/dist/memory/project/fs/fs-port.js +33 -5
  135. package/dist/memory/project/learned-routes.d.ts +16 -2
  136. package/dist/memory/project/learned-routes.js +26 -10
  137. package/dist/memory/project/project-for-root.d.ts +30 -0
  138. package/dist/memory/project/project-for-root.js +43 -0
  139. package/dist/memory/project/project-tools.js +10 -5
  140. package/dist/memory/project/session-root.d.ts +35 -6
  141. package/dist/memory/project/session-root.js +70 -8
  142. package/dist/portal/bridge/auth-failure-reason.d.ts +9 -0
  143. package/dist/portal/bridge/auth-failure-reason.js +15 -1
  144. package/dist/portal/bridge/bridge.d.ts +6 -1
  145. package/dist/portal/bridge/bridge.js +36 -2
  146. package/dist/portal/session/facts/session-replaced.d.ts +19 -0
  147. package/dist/portal/session/facts/session-replaced.js +22 -0
  148. package/dist/portal/session/no-session-diagnosis.d.ts +1 -0
  149. package/dist/portal/session/no-session-diagnosis.js +67 -60
  150. package/dist/portal/session/no-session-next-action.js +3 -2
  151. package/dist/portal/session/no-session-watch.js +14 -10
  152. package/dist/portal/session/rebind.d.ts +18 -0
  153. package/dist/portal/session/rebind.js +38 -0
  154. package/dist/portal/session/session-manager.d.ts +4 -0
  155. package/dist/portal/session/session-manager.js +19 -1
  156. package/dist/portal/session/session.d.ts +21 -10
  157. package/dist/portal/session/session.js +29 -42
  158. package/dist/status-payload.d.ts +12 -1
  159. package/dist/status-payload.js +4 -1
  160. package/dist/surface/http-server.js +3 -5
  161. package/dist/surface/mcp/faults/mcp-outage.d.ts +2 -1
  162. package/dist/surface/mcp/faults/mcp-outage.js +20 -4
  163. package/dist/surface/mcp/mcp-proxy.d.ts +10 -1
  164. package/dist/surface/mcp/mcp-proxy.js +20 -9
  165. package/dist/surface/mcp/mcp.d.ts +10 -1
  166. package/dist/surface/mcp/mcp.js +59 -29
  167. package/dist/surface/mcp/proxy/proxy-daemon-probe.d.ts +15 -2
  168. package/dist/surface/mcp/proxy/proxy-daemon-probe.js +22 -5
  169. package/dist/surface/mcp/proxy/proxy-log.d.ts +5 -11
  170. package/dist/surface/mcp/proxy/proxy-log.js +7 -22
  171. package/dist/surface/tools/act/act-preflight.js +6 -1
  172. package/dist/surface/tools/act/sequence-grade.js +13 -0
  173. package/dist/surface/tools/act-capsule.js +27 -1
  174. package/dist/surface/tools/assert/assert-verdict.js +44 -17
  175. package/dist/surface/tools/coverage-tools.js +5 -2
  176. package/dist/surface/tools/dynamic-tools.js +37 -46
  177. package/dist/surface/tools/error-recovery.d.ts +6 -0
  178. package/dist/surface/tools/error-recovery.js +15 -0
  179. package/dist/surface/tools/explore-tools.js +80 -7
  180. package/dist/surface/tools/feedback-tools.js +49 -2
  181. package/dist/surface/tools/first-sentence.d.ts +24 -0
  182. package/dist/surface/tools/first-sentence.js +0 -0
  183. package/dist/surface/tools/harness-explore.d.ts +125 -1
  184. package/dist/surface/tools/harness-explore.js +401 -15
  185. package/dist/surface/tools/harness-plan.d.ts +76 -0
  186. package/dist/surface/tools/harness-plan.js +156 -0
  187. package/dist/surface/tools/harness-toolset.js +12 -1
  188. package/dist/surface/tools/invoke-tool.js +65 -5
  189. package/dist/surface/tools/live-call-text.d.ts +6 -0
  190. package/dist/surface/tools/live-call-text.js +36 -2
  191. package/dist/surface/tools/observe-tools.js +55 -9
  192. package/dist/surface/tools/read/query-windowed.d.ts +1 -1
  193. package/dist/surface/tools/read/query-windowed.js +1 -1
  194. package/dist/surface/tools/read-tools.js +1 -2
  195. package/dist/surface/tools/tool-kit.d.ts +18 -3
  196. package/dist/surface/tools/tools-helpers.d.ts +8 -7
  197. package/dist/surface/tools/tools-helpers.js +11 -13
  198. package/dist/surface/tools/tools.js +6 -1
  199. package/dist/telemetry/bug-found.js +32 -2
  200. package/dist/telemetry/feedback-context.js +3 -3
  201. package/dist/telemetry/harness-drive.d.ts +6 -0
  202. package/dist/telemetry/harness-drive.js +39 -0
  203. package/dist/telemetry/mcp-connection.d.ts +14 -1
  204. package/dist/telemetry/mcp-connection.js +17 -2
  205. package/dist/telemetry/onboarding-firsts.d.ts +30 -1
  206. package/dist/telemetry/onboarding-firsts.js +54 -12
  207. package/dist/telemetry/verification-of.js +31 -3
  208. package/docs/agent-cheatsheet.md +7 -3
  209. package/docs/autodrive.md +42 -2
  210. package/docs/benchmarks.md +1 -1
  211. package/docs/capabilities.mdx +1 -1
  212. package/docs/cli/cloud.mdx +3 -3
  213. package/docs/cli/doctor.mdx +1 -0
  214. package/docs/cli/status.mdx +35 -20
  215. package/docs/cli.mdx +8 -18
  216. package/docs/desktop.mdx +1 -1
  217. package/docs/faq.mdx +1 -1
  218. package/docs/install-manual.mdx +1 -1
  219. package/docs/packages/tauri.mdx +1 -1
  220. package/docs/packages.mdx +1 -1
  221. package/docs/predicates.mdx +16 -0
  222. package/docs/system-map.md +6 -4
  223. package/docs/telemetry.md +2 -1
  224. package/docs/token-efficiency.md +2 -2
  225. package/docs/tools/overview.mdx +1 -1
  226. package/docs/troubleshooting.mdx +8 -2
  227. package/docs/usage.md +1 -1
  228. package/package.json +6 -6
@@ -129,8 +129,10 @@ const NUXT_STALE_BUNDLE = 'This project is Nuxt: the most likely cause by a dist
129
129
  const RESTARTED_LEAD = 'no browser session connected. This daemon has served none since it started, but it is a NEW ' +
130
130
  'process — an app for this project HAS connected on this port before, which is recorded ' +
131
131
  'durably. So the wiring is correct and the port is correct: what is failing now is the SDK ' +
132
- 'reaching initialise on the page. Re-running the install is the wrong move here: it cannot help ' +
133
- 'on a project that has demonstrably connected, and it can overwrite a working config.';
132
+ 'reaching initialise on the page.';
133
+ /** Why the obvious move is the wrong one here. Reasoning, so it rides in the detail. */
134
+ const DO_NOT_REINSTALL = 'Re-running the install is the wrong move here: it cannot help on a project that has ' +
135
+ 'demonstrably connected, and it can overwrite a working config.';
134
136
  /**
135
137
  * The ports the scan actually covers, rendered for the message.
136
138
  *
@@ -259,10 +261,8 @@ function configsElsewhereClause(facts) {
259
261
  .map((c) => (c.projectId === undefined ? c.directory : `${c.directory} ('${c.projectId}')`))
260
262
  .join(', ');
261
263
  return (`A \`.reticle.json\` WAS found outside this daemon's directory: ${named}. So the app is wired ` +
262
- 'and this is a SCOPE problem, not an install problem — this daemon was started somewhere that ' +
263
- 'is not the project, which is the normal outcome when an editor launches it from your home ' +
264
- "directory. Restart the daemon from the app's directory (or point it there) rather than " +
265
- 'installing anything.');
264
+ 'and this is a SCOPE problem, not an install problem. Restart the daemon from the ' +
265
+ "app's directory (or point it there) rather than installing anything.");
266
266
  }
267
267
  /** Where we looked, when we found nothing. An absence nobody can check is not evidence. */
268
268
  function searchedClause(facts) {
@@ -327,13 +327,12 @@ export function explainNoSession(facts) {
327
327
  if (true === facts.authRefused) {
328
328
  return reason(NoSessionReason.AUTH_REFUSED, 'no browser session connected, and the reason is not the app: this daemon REFUSED the last ' +
329
329
  'page that dialled it, because the pairing token it presented is not the one this daemon ' +
330
- 'holds. Only an SDK dials the bridge, so the wiring is correct and the app is running. The ' +
331
- 'token is read by the build plugin when the dev server STARTS, so the usual cause is a ' +
332
- 'daemon restarted (or a `~/.reticle` cleared) after the dev server was already up, and the ' +
333
- 'fix is to restart the dev server so it picks up the current token. If the two were started ' +
334
- 'from different environments — an editor spawning the MCP server globally while the dev ' +
335
- 'server runs from a shell — they can be reading different `~/.reticle` directories instead. ' +
336
- 'The page says the same thing in the browser console.');
330
+ 'holds. Only an SDK dials the bridge, so the wiring is correct and the app is running. ' +
331
+ 'Restart the dev server so it picks up the current token.', 'The token is read by the build plugin when the dev server STARTS, so the usual cause is a ' +
332
+ 'daemon restarted (or a `~/.reticle` cleared) after the dev server was already up. If the ' +
333
+ 'two were started from different environments — an editor spawning the MCP server globally ' +
334
+ 'while the dev server runs from a shell — they can be reading different `~/.reticle` ' +
335
+ 'directories instead. The page says the same thing in the browser console.');
337
336
  }
338
337
  if (everConnected) {
339
338
  // A reaped lease first, because it is the one cause with POSITIVE evidence behind it. Calling
@@ -344,15 +343,13 @@ export function explainNoSession(facts) {
344
343
  // (#611), so this branch is only reached when the thing that vanished WAS the lease.
345
344
  if (true === facts.leaseExpired) {
346
345
  return reason(NoSessionReason.LEASE_EXPIRED, 'no browser session connected, but one WAS connected to this daemon earlier, so the wiring ' +
347
- 'is correct. The session that went away was a pooled lease and it aged out; a lease is a ' +
348
- 'headless context, not a human tab, and it takes its cookies with it (so an authenticated ' +
349
- 'app needs signing in again). Re-acquire with reticle_lease {action:"acquire", url} and ' +
350
- `carry on.${alreadyListeningClause(listening)} ${RETRY}`);
346
+ 'is correct. The session that went away was a pooled lease and it aged out. Re-acquire ' +
347
+ `with reticle_lease {action:"acquire", url} and carry on. ${RETRY}`, 'A lease is a headless context, not a human tab, and it takes its cookies with it, so an ' +
348
+ `authenticated app needs signing in again.${alreadyListeningClause(listening)}`);
351
349
  }
352
350
  return reason(NoSessionReason.TAB_GONE, 'no browser session connected, but one WAS connected to this daemon earlier, so the wiring ' +
353
351
  `is correct. ${tabGoneWhat(facts.lastKnownUrl)} Ask the human to reopen ` +
354
- `the app (or run ${OPEN_CMD_BARE}), or reload the tab.${alreadyListeningClause(listening)} ` +
355
- `${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`);
352
+ `the app (or run ${OPEN_CMD_BARE}), or reload the tab. ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`, alreadyListeningClause(listening).trim());
356
353
  }
357
354
  // Ranked ahead of every "we have never seen one" branch below, because it is the one fact that
358
355
  // OUTRANKS them: those branches all reason from an absence, and this reasons from a recorded
@@ -363,15 +360,16 @@ export function explainNoSession(facts) {
363
360
  ? `${unattributedListeners(listening)} If the dev server is not running, start it first ` +
364
361
  '(the command is in `next_action`).'
365
362
  : unattributedListeners(listening);
366
- return reason(NoSessionReason.APP_NOT_REOPENED, `${RESTARTED_LEAD} ${OPEN_THE_APP} ${listeners} ${rankedCauses(facts)} ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`);
363
+ return reason(NoSessionReason.APP_NOT_REOPENED, `${RESTARTED_LEAD} ${OPEN_THE_APP} ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`, `${DO_NOT_REINSTALL} ${listeners} ${rankedCauses(facts)}`);
367
364
  }
368
365
  // A config found elsewhere outranks every "you may have no SDK" branch below: those reason from
369
366
  // an absence in ONE directory, and this is a file we read in another. Ranked here so the reader is
370
367
  // never sent through an install on a project that has demonstrably been installed.
371
368
  if (!initialized && (facts.configsElsewhere ?? []).length > 0) {
372
369
  return reason(NoSessionReason.CONFIG_ELSEWHERE, 'no browser session connected, and this daemon has never seen one. ' +
373
- `${configsElsewhereClause(facts)} ${unattributedListeners(listening)} ${OPEN_THE_APP} ` +
374
- `${rankedCauses(facts)} ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`);
370
+ `${configsElsewhereClause(facts)} ${OPEN_THE_APP} ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`, 'This daemon was started somewhere that is not the project, which is the normal outcome when ' +
371
+ `an editor launches it from your home directory. ${unattributedListeners(listening)} ` +
372
+ `${rankedCauses(facts)}`);
375
373
  }
376
374
  if (0 === listening.length) {
377
375
  // Lead with the stronger EVIDENCE, and do not overstate what it is.
@@ -390,54 +388,62 @@ export function explainNoSession(facts) {
390
388
  // is expected rather than diagnostic.
391
389
  if (!initialized) {
392
390
  return reason(NoSessionReason.NO_LISTENER_NO_CONFIG, 'no browser session connected. Two things to weigh, and neither of them is proof. ' +
393
- `(1) ${slowListenerClause(facts)}Nothing is listening on the ports Reticle scans (${SCANNED_PORTS}), so the dev server ` +
394
- 'may not be running — START IT YOURSELF, in the background, using the command in ' +
395
- "`next_action` (it is read from this project's own scripts; if there is none, that field " +
396
- 'says so and you should ask rather than guess). Tell the human in one line that it is ' +
397
- 'running. That scan is narrow ' +
398
- 'though: a server on any other port is invisible to it, so if the app IS running, ask for ' +
399
- `its URL rather than assuming it is down, and open it with ${OPEN_CMD}. ` +
400
- `(2) There is no \`.reticle.json\` in ${where}. That is the ` +
401
- `file ${INIT_CMD} writes, so the app may carry no Reticle SDK — but check the app's ` +
402
- 'OWN directory before re-running `init`: in a monorepo the daemon often runs at the root ' +
403
- 'while the app lives in a subdirectory, and an app wired by the Vite or Babel plugin ' +
404
- `carries the SDK without that file at all.${searchedClause(facts)}${siblingListenerClause(facts)} ${leaseAdvice(URL_THEN_LEASE, facts)} ${RETRY}`);
391
+ `(1) ${slowListenerClause(facts)}Nothing is listening on the ports Reticle scans, so the ` +
392
+ 'dev server may not be running: START IT YOURSELF, in the background, using the command ' +
393
+ 'in `next_action`, and tell the human in one line that it is running. ' +
394
+ `(2) There is no \`.reticle.json\` in ${where}, which is the file ${INIT_CMD} writes, so ` +
395
+ `the app may carry no Reticle SDK. Check the app's OWN directory before re-running it. ${RETRY}`, `The scan covers ${SCANNED_PORTS} and nothing else, so a server on any other port is ` +
396
+ 'invisible to it: if the app IS running, ask for its URL rather than assuming it is down, ' +
397
+ `and open it with ${OPEN_CMD}. The missing config is not proof either — in a monorepo the ` +
398
+ 'daemon often runs at the root while the app lives in a subdirectory, and an app wired by ' +
399
+ 'the Vite or Babel plugin carries the SDK without that file at all.' +
400
+ `${searchedClause(facts)}${siblingListenerClause(facts)} ${leaseAdvice(URL_THEN_LEASE, facts)}`);
405
401
  }
406
402
  return reason(NoSessionReason.NO_LISTENER, `${stallClause(facts)}no browser session connected, and this daemon has never seen one. ${OPEN_THE_APP} ` +
407
- 'Nothing is listening on the ports Reticle scans ' +
408
- `(${SCANNED_PORTS}) either, and the most common reason for that is a dev server that is not ` +
409
- 'running: start it yourself in the background with the command in `next_action` — it is read ' +
410
- "from this project's own scripts, and says so rather than guessing when there is none — tell " +
411
- 'the human in one line that it is running, then open the app in a browser. ' +
412
- // The caveat is here rather than omitted because the scan is NARROW: an empty result is not
413
- // proof of absence. Reported twice, once against a server answering 200 on :7699 and once
414
- // against one on :5000 under a custom hostname. The common defaults are in the scanned set,
415
- // which narrows the gap and cannot close it — anything passed to `--port` is invisible.
416
- 'That scan is narrow, so it is not proof: a server on any other port is invisible to it. If ' +
403
+ 'Nothing is listening on the ports Reticle scans either, and the usual reason is a dev ' +
404
+ 'server that is not running: start it yourself in the background with the command in ' +
405
+ '`next_action`, tell the human in one line that it is running, then open the app in a ' +
406
+ `browser. ${RETRY}`,
407
+ // The caveat is in the detail rather than omitted because the scan is NARROW: an empty result
408
+ // is not proof of absence. Reported twice, once against a server answering 200 on :7699 and
409
+ // once against one on :5000 under a custom hostname. The common defaults are in the scanned
410
+ // set, which narrows the gap and cannot close it — anything passed to `--port` is invisible.
411
+ `The scan covers ${SCANNED_PORTS} and nothing else, so it is not proof: a server on any other ` +
412
+ 'port is invisible to it. If the app IS running, ask the human for its URL rather than ' +
417
413
  // Deliberately NOT offering reticle_lease here, and a test pins that: a lease opens a URL, and
418
414
  // if nothing is listening there is nothing at any URL to open. Asking for the real one is the
419
415
  // only move that can recover the :7699 case.
420
- `the app IS running, ask the human for its URL rather than assuming it is down. ${leaseAdvice(URL_THEN_LEASE, facts)}` +
421
- `${siblingListenerClause(facts)} ${RETRY}`);
416
+ `assuming it is down. The \`next_action\` command is read from this project's own scripts, ` +
417
+ 'and says so rather than guessing when there is none. ' +
418
+ `${leaseAdvice(URL_THEN_LEASE, facts)}${siblingListenerClause(facts)}`);
422
419
  }
423
420
  if (!initialized) {
424
421
  return reason(NoSessionReason.NO_CONFIG, 'no browser session connected, and this daemon has never seen one. ' +
425
- `What was actually checked: there is no \`.reticle.json\` in ${where}. That is the file ` +
426
- `${INIT_CMD} writes, so the app may carry no Reticle SDK — but it is not proof, and the ` +
427
- 'same absence is expected in a monorepo whose daemon runs at the root while the app lives in ' +
428
- "a subdirectory, or in an app wired by the Vite or Babel plugin. Check the app's OWN " +
429
- `directory: if it has no config, run ${INIT_CMD} there and restart the dev server; if it ` +
430
- `has one, the app is wired and simply has no page open — ${OPEN_CMD}. ` +
431
- `${unattributedListeners(listening)}${searchedClause(facts)}${siblingListenerClause(facts)} ${RETRY}`);
422
+ `What was actually checked: there is no \`.reticle.json\` in ${where}, which is the file ` +
423
+ `${INIT_CMD} writes, so the app may carry no Reticle SDK. Check the app's OWN directory: ` +
424
+ `if it has no config, run ${INIT_CMD} ` +
425
+ 'there and restart the dev server; if it has one, the app is wired and simply has no page ' +
426
+ `open — ${OPEN_CMD}. ${RETRY}`, 'That absence is not proof: the same one is expected in a monorepo whose daemon runs at the ' +
427
+ 'root while the app lives in a subdirectory, or in an app wired by the Vite or Babel ' +
428
+ `plugin. ${unattributedListeners(listening)}${searchedClause(facts)}${siblingListenerClause(facts)}`);
432
429
  }
433
430
  return reason(NoSessionReason.SDK_NOT_REACHING_DAEMON, `${stallClause(facts)}no browser session connected, and this daemon has never seen one for this project, which is ` +
434
- `wired for Reticle. ${OPEN_THE_APP} ${unattributedListeners(listening)} ` +
435
- 'If the page IS open and still does not appear, the app is wired and the SDK is not reaching ' +
436
- `this daemon (on ${String(port)}). ${rankedCauses(facts)} ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`);
431
+ `wired for Reticle. ${OPEN_THE_APP} If the page IS open and still does not appear, the SDK ` +
432
+ `is not reaching this daemon (on ${String(port)}). ${leaseAdvice(SELF_SERVE, facts)} ${RETRY}`, `${unattributedListeners(listening)} ${rankedCauses(facts)}`);
437
433
  }
438
- /** Pair a branch's verdict with its prose, so neither can be produced without the other. */
439
- function reason(code, message) {
440
- return { reason: code, message };
434
+ /**
435
+ * Pair a branch's verdict with its prose, so neither can be produced without the other.
436
+ *
437
+ * TWO pieces of prose now. `message` is the lead: what is true and what to do about it, in about
438
+ * four sentences. `detail` is the differential behind it -- the port-scan caveat, the ranked causes,
439
+ * the lease offer -- which is the part that was making this five hundred words in one paragraph.
440
+ *
441
+ * Split rather than deleted. Almost every clause in `detail` exists because somebody was sent the
442
+ * wrong way without it, and the reader who needs it is the agent about to spend a drive on a guess.
443
+ * The reader who does not is the person watching `reticle init`, who gets the lead.
444
+ */
445
+ function reason(code, message, detail) {
446
+ return { reason: code, message, ...(undefined === detail ? {} : { detail }) };
441
447
  }
442
448
  /**
443
449
  * The message alone, for the callers that render it.
@@ -446,5 +452,6 @@ function reason(code, message) {
446
452
  * pair would spread a telemetry concern across every error path that shows a user this text.
447
453
  */
448
454
  export function diagnoseNoSession(facts) {
449
- return explainNoSession(facts).message;
455
+ const { message, detail } = explainNoSession(facts);
456
+ return undefined === detail ? message : `${message} ${detail}`;
450
457
  }
@@ -140,8 +140,9 @@ export function nextActionFor(facts) {
140
140
  "own. Point the plugin at the daemon's copy — mount `~/.reticle/pairing-token` into the " +
141
141
  `container read-only and set \`${ReticleEnv.PAIRING_TOKEN_DIR}\` to the directory holding ` +
142
142
  'it — then ' +
143
- 'restart the dev server, because the token is inlined when Vite resolves its config. If the ' +
144
- 'dev server IS on this machine, the page is older than the current token: hard-reload it.',
143
+ 'restart the dev server, because the token is inlined when Vite resolves its config and ' +
144
+ 'kept in the prebundled module. Reloading the page serves that same token, whether the ' +
145
+ 'dev server is on this machine or in a container.',
145
146
  };
146
147
  }
147
148
  if (!facts.initialized && true === facts.authRefused) {
@@ -23,7 +23,7 @@ import { daemonsServingProjectElsewhere, splitBrainNote } from '../../command/da
23
23
  import { stallUptime } from './stall-clock.js';
24
24
  import { probeDaemon } from '../../surface/mcp/mcp-proxy.js';
25
25
  import { findOccupiedSiblings } from '../../command/cli/ports/sibling-ports.js';
26
- import { WS_CLOSE_REASON } from '../bridge/bridge.js';
26
+ import { isAuthRefusalReason } from '../bridge/auth-failure-reason.js';
27
27
  /** Slow enough to be free, fast enough that a dev server started 15s ago is already reflected. */
28
28
  const REFRESH_MS = 15_000;
29
29
  /** The URL an auto-attach opens. `localhost` for the same reason the probe uses it: both stacks. */
@@ -208,7 +208,7 @@ export function startNoSessionWatch(options) {
208
208
  /**
209
209
  * Is a refusal on the pairing token the CURRENT state of the bridge?
210
210
  *
211
- * The bridge records the refusal (`noteClosure(WS_CLOSE_REASON.AUTH_FAILED)`) and nothing read it
211
+ * The bridge records the refusal (the sentence it closed the socket with) and nothing read it
212
212
  * as a DIAGNOSIS. It is the one fact that proves an app is running and instrumented: only an SDK
213
213
  * dials the bridge, so a refused hello means the wiring works and this daemon would not serve it.
214
214
  *
@@ -223,7 +223,7 @@ export function startNoSessionWatch(options) {
223
223
  * recorded no refusal, which falls through to the behaviour that was there before -- the safe
224
224
  * direction for a fact whose job is to SUPPRESS advice rather than to add any.
225
225
  */
226
- const lastCloseWasAuthFailure = () => options.sessions.lastClosure?.()?.reason === WS_CLOSE_REASON.AUTH_FAILED &&
226
+ const lastCloseWasAuthFailure = () => isAuthRefusalReason(options.sessions.lastClosure?.()?.reason) &&
227
227
  true !== options.sessions.connectedSinceLastClosure?.();
228
228
  const nextAction = (scope) => {
229
229
  const split = splitBrain();
@@ -307,19 +307,23 @@ export function startNoSessionWatch(options) {
307
307
  ...(0 === siblingListeners.length ? {} : { siblingListeners }),
308
308
  });
309
309
  };
310
- options.sessions.setNoSessionHint(() => {
310
+ // The lead, then the literal command, then the differential. Both renderings consume the same
311
+ // scope facts so a discovered workspace config cannot become an `init` recommendation below it,
312
+ // and both come from ONE `explain()` call so they cannot describe different branches.
313
+ const rendered = () => {
311
314
  const scope = projectScopeFacts();
312
- return (explain().message +
313
- // Prose for the human, then the literal command for the agent. Both consume the same scope
314
- // facts so a discovered workspace config cannot become an `init` recommendation below it.
315
- ` ${renderNextAction(nextAction(scope))}` +
316
- (attachFailure ?? ''));
317
- });
315
+ const { message, detail } = explain();
316
+ const lead = `${message} ${renderNextAction(nextAction(scope))}${attachFailure ?? ''}`;
317
+ return { lead, full: undefined === detail ? lead : `${lead} ${detail}` };
318
+ };
319
+ options.sessions.setNoSessionHint(() => rendered().full);
320
+ options.sessions.setNoSessionLead(() => rendered().lead);
318
321
  options.sessions.setNoSessionReason(() => explain().reason);
319
322
  return () => {
320
323
  clearInterval(timer);
321
324
  options.sessions.setConnectionRecorder(undefined);
322
325
  options.sessions.setNoSessionHint(undefined);
326
+ options.sessions.setNoSessionLead(undefined);
323
327
  options.sessions.setNoSessionReason(undefined);
324
328
  options.sessions.setNoSessionNextAction(undefined);
325
329
  };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Commands that may be re-issued against the connection that replaced this session.
3
+ *
4
+ * Reads only, and the boundary is the point. Re-reading a page costs nothing and answers the same
5
+ * question the caller asked. An act cannot be replayed: it may already have been dispatched in the
6
+ * page that went away, and performing it a second time behind the caller's back is a double submit
7
+ * nobody asked for. A replaced act still errors, and the caller's own retry — with the same id,
8
+ * which is still the right one — is the safe path.
9
+ */
10
+ export declare const REBINDABLE_COMMANDS: ReadonlySet<string>;
11
+ /**
12
+ * How far to walk a chain of replacements before giving up.
13
+ *
14
+ * Generous for the real case (a page reloading a handful of times while a call is in flight) and
15
+ * small enough that a cycle costs nothing. The number is a backstop, not a policy: a chain longer
16
+ * than this means something is re-dialling in a loop, and that is a different problem.
17
+ */
18
+ export declare const MAX_SUCCESSOR_HOPS = 32;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Whether a command in flight may be re-issued against the connection that REPLACED this session,
3
+ * and how far a chain of replacements is worth walking.
4
+ *
5
+ * One question, so one file. It left `session.ts` when that file crossed the thousand-line cap on a
6
+ * merge: two branches had each grown it by a few lines and the sum went over, which is the moment
7
+ * the rule says to split rather than to raise the number. These two constants and the reasoning
8
+ * behind them are the most self-contained thing in there — `session.ts` reads them twice and owns
9
+ * nothing else about rebinding.
10
+ */
11
+ import { ReticleCommand } from '@reticlehq/core';
12
+ /**
13
+ * Commands that may be re-issued against the connection that replaced this session.
14
+ *
15
+ * Reads only, and the boundary is the point. Re-reading a page costs nothing and answers the same
16
+ * question the caller asked. An act cannot be replayed: it may already have been dispatched in the
17
+ * page that went away, and performing it a second time behind the caller's back is a double submit
18
+ * nobody asked for. A replaced act still errors, and the caller's own retry — with the same id,
19
+ * which is still the right one — is the safe path.
20
+ */
21
+ export const REBINDABLE_COMMANDS = new Set([
22
+ ReticleCommand.SNAPSHOT,
23
+ ReticleCommand.QUERY,
24
+ ReticleCommand.MATCH,
25
+ ReticleCommand.INSPECT,
26
+ ReticleCommand.STATE_READ,
27
+ ReticleCommand.STORAGE_READ,
28
+ ReticleCommand.CAPABILITIES,
29
+ ReticleCommand.ANIMATIONS,
30
+ ]);
31
+ /**
32
+ * How far to walk a chain of replacements before giving up.
33
+ *
34
+ * Generous for the real case (a page reloading a handful of times while a call is in flight) and
35
+ * small enough that a cycle costs nothing. The number is a backstop, not a policy: a chain longer
36
+ * than this means something is re-dialling in a loop, and that is a different problem.
37
+ */
38
+ export const MAX_SUCCESSOR_HOPS = 32;
@@ -30,6 +30,10 @@ export declare class SessionManager {
30
30
  count(): number;
31
31
  /** Wire the diagnosis provider (daemon boot). Absent ⇒ the plain, static message. */
32
32
  setNoSessionHint(hint: (() => string | undefined) | undefined): void;
33
+ /** Wire the short rendering. Registered from the same `explainNoSession` call as the hint. */
34
+ setNoSessionLead(lead: (() => string | undefined) | undefined): void;
35
+ /** The lead and the next action, for a surface a person reads. */
36
+ noSessionLead(): string | undefined;
33
37
  /**
34
38
  * The same diagnosis, for a caller that is ASKING about sessions rather than failing to get one.
35
39
  *
@@ -4,6 +4,7 @@ import { declareDrivenRedactionKeys, forgetDrivenRedactionKeys, } from '../input
4
4
  import { Session } from './session.js';
5
5
  import { AttachmentHistory } from './presence/attachment-history.js';
6
6
  import { pickDocumentSuccessor } from './session-successor.js';
7
+ import { SESSION_DISCONNECTED_REASON } from './facts/session-replaced.js';
7
8
  /** The scheme://host:port of a URL, or undefined if it can't be parsed. Used to compare origins. */
8
9
  export function originOf(url) {
9
10
  if (url === undefined)
@@ -143,7 +144,7 @@ export class SessionManager {
143
144
  remove(session) {
144
145
  if (this.#sessions.get(session.id) !== session)
145
146
  return false;
146
- session.rejectAll('session disconnected');
147
+ session.rejectAll(SESSION_DISCONNECTED_REASON);
147
148
  forgetDrivenRedactionKeys(session.id);
148
149
  // Recorded, not forgotten: a session that comes back needs its gap measured, and a listing after
149
150
  // the reconnect is exactly where that matters.
@@ -246,10 +247,27 @@ export class SessionManager {
246
247
  * different branches. See no-session-watch.ts.
247
248
  */
248
249
  #noSessionReason;
250
+ /**
251
+ * The same diagnosis with the differential left off: the lead and the next action, nothing else.
252
+ *
253
+ * Two renderings because there are two readers, and only one of them can spend the words. An
254
+ * agent about to burn a drive on a guess wants the ranked causes, the port-scan caveat and the
255
+ * lease offer. The person watching `reticle init` fail wants to know what is wrong and what to
256
+ * type, and was instead handed roughly five hundred words in one paragraph.
257
+ */
258
+ #noSessionLead;
249
259
  /** Wire the diagnosis provider (daemon boot). Absent ⇒ the plain, static message. */
250
260
  setNoSessionHint(hint) {
251
261
  this.#noSessionHint = hint;
252
262
  }
263
+ /** Wire the short rendering. Registered from the same `explainNoSession` call as the hint. */
264
+ setNoSessionLead(lead) {
265
+ this.#noSessionLead = lead;
266
+ }
267
+ /** The lead and the next action, for a surface a person reads. */
268
+ noSessionLead() {
269
+ return this.#noSessionLead?.();
270
+ }
253
271
  /**
254
272
  * The same diagnosis, for a caller that is ASKING about sessions rather than failing to get one.
255
273
  *
@@ -1,5 +1,5 @@
1
1
  import type { WebSocket } from 'ws';
2
- import type { ChannelId, ImpactSnapshot } from '@reticlehq/core';
2
+ import { type ChannelId, type ImpactSnapshot, type ProjectId } from '@reticlehq/core';
3
3
  import type { HandshakeFacts } from './facts/handshake-facts.js';
4
4
  import { LastAct } from './last-act.js';
5
5
  import { GapLedger } from '@reticlehq/engine/evidence/gap-ledger.js';
@@ -7,7 +7,7 @@ import { CaptureLedger } from '../../surface/tools/feature-capture.js';
7
7
  import { type PageRuntime } from './timing/command-timeout.js';
8
8
  import { type SessionHealth } from './session-health.js';
9
9
  export type { SessionHealth };
10
- import { PresenterTone, SessionState, type BrowserBrand, type CommandResult, type JournalAction, type HelloMessage, type HumanControlData, type ReticleEvent } from '@reticlehq/core';
10
+ import { PresenterTone, SessionState, type BrowserBrand, type CommandResult, type JournalAction, type JournalWriteLoss, type HelloMessage, type HumanControlData, type ReticleEvent } from '@reticlehq/core';
11
11
  import type { JournalReader, JournalRecorder } from '../../memory/journal/journal-recorder.js';
12
12
  import { type EventQueryOptions } from '../../memory/journal/journal-query.js';
13
13
  import { type AmbientCounts } from '@reticlehq/engine/window/ambient.js';
@@ -25,8 +25,14 @@ type Clock = () => number;
25
25
  export declare class Session implements HandshakeFacts {
26
26
  #private;
27
27
  readonly id: string;
28
- /** Stable build-stamped project identity; undefined for v1.0 SDKs that omit it. */
29
- readonly projectId: string | undefined;
28
+ /**
29
+ * Stable build-stamped project identity; undefined for SDKs old enough to omit it.
30
+ *
31
+ * MINTED HERE. This is one of the two places a projectId enters the daemon with its provenance
32
+ * intact: the field is `projectId` on a zod-parsed HELLO, so the page said what it is. Every
33
+ * downstream reader takes the brand from this property rather than re-blessing a string.
34
+ */
35
+ readonly projectId: ProjectId | undefined;
30
36
  /**
31
37
  * The `.reticle` directory this session's evidence belongs in — impact counters AND capsules.
32
38
  *
@@ -118,12 +124,13 @@ export declare class Session implements HandshakeFacts {
118
124
  */
119
125
  setJournal(recorder: JournalRecorder, reader?: JournalReader): void;
120
126
  /**
121
- * Journal-backed event query: the ring buffer's events, merged with the durable journal **only when
122
- * the buffer has evicted** (so a healthy session pays no disk cost), then filtered by since/until/
123
- * actionId. This is how "what did action N cause" is answered after the buffer has dropped the
124
- * evidence — the substrate's whole point. The sync `eventsSince`/`window` stay for the hot path.
127
+ * Journal-backed event query — the rule itself lives in `readQueryEvents`, beside the merge and
128
+ * filter it is made of. This is how "what did action N cause" is answered after the buffer has
129
+ * dropped the evidence. The sync `eventsSince`/`window` stay for the hot path.
125
130
  */
126
131
  queryEvents(options: EventQueryOptions): Promise<ReticleEvent[]>;
132
+ /** What the durable ledger could not WRITE — see `readJournalWriteLoss` for the rule and why. */
133
+ journalWriteLoss(): Promise<JournalWriteLoss | undefined>;
127
134
  /**
128
135
  * Open an action-attribution window: events observed until finishAction attribute to the returned
129
136
  * action id. Ids are minted independently of command correlation ids so the journal is self-contained.
@@ -200,9 +207,13 @@ export declare class Session implements HandshakeFacts {
200
207
  dropped: number;
201
208
  };
202
209
  /**
203
- * Did the buffer lose scarce evidence from a window opened at `cursor`? The input to whether a
210
+ * Did the EVENT STORE lose scarce evidence from a window opened at `cursor`? The input to whether a
204
211
  * verdict's capture was clean — see `RingBuffer.lostSince`, and never the raw drop counter, which
205
- * moves for the age and churn evictions that every live page produces continuously.
212
+ * moves for the age and churn evictions that every live page produces continuously. The journal is
213
+ * the other half of that store, so a durable read that could not reach back to `cursor` lost
214
+ * exactly what an eviction would have. Both boundaries are INCLUSIVE (`t` is a millisecond many
215
+ * records share) and an absent `lostThroughT` impeaches every window rather than none — the
216
+ * conservative direction, both times.
206
217
  */
207
218
  lostSince(cursor: number): boolean;
208
219
  onEvent(listener: (event: ReticleEvent) => void): () => void;
@@ -1,3 +1,4 @@
1
+ import { asProjectId } from '@reticlehq/core';
1
2
  import { refusedResult } from './page-commands/undeclared-command.js';
2
3
  import { recordImpact } from '../../memory/impact/impact-recorder.js';
3
4
  import { LastAct } from './last-act.js';
@@ -13,7 +14,7 @@ import { span } from '../../trace.js';
13
14
  import { keepCallerContextInDaemon, noteToDaemonLog } from './daemon-answers.js';
14
15
  import { isKnownRealm, EventType, HumanControlDataSchema, HumanControlKind, HumanMarkDataSchema, ReticleCommand, MessageKind, parseEventPayload, PresenterTone, SESSION_HEALTH, SESSION_LEASE, SESSION_LIFECYCLE, SessionState, } from '@reticlehq/core';
15
16
  import { RingBuffer } from '@reticlehq/engine/window/ring-buffer.js';
16
- import { filterEvents, mergeEventsBySeq, } from '../../memory/journal/journal-query.js';
17
+ import { readJournalWriteLoss, readQueryEvents, } from '../../memory/journal/journal-query.js';
17
18
  import {} from '@reticlehq/engine/window/ambient.js';
18
19
  import { ObservedState } from './facts/observed-state.js';
19
20
  import { recordBrowserLatency, recordSdkFailure } from '../../telemetry/session-metrics.js';
@@ -23,36 +24,10 @@ import { buildSessionRecommendation } from './presence/session-recommendation.js
23
24
  import { buildPresenterArgs } from './human/presenter-args.js';
24
25
  import { buildSessionLease } from './presence/session-lease.js';
25
26
  import { buildSessionInfo } from './session-info.js';
27
+ import { MAX_SUCCESSOR_HOPS, REBINDABLE_COMMANDS } from './rebind.js';
26
28
  const DEFAULT_COMMAND_TIMEOUT_MS = 8000;
27
29
  /** Prefix on correlated command ids (c1, c2, …) — distinguishes them from mark ids. */
28
30
  const COMMAND_ID_PREFIX = 'c';
29
- /**
30
- * Commands that may be re-issued against the connection that replaced this session.
31
- *
32
- * Reads only, and the boundary is the point. Re-reading a page costs nothing and answers the same
33
- * question the caller asked. An act cannot be replayed: it may already have been dispatched in the
34
- * page that went away, and performing it a second time behind the caller's back is a double submit
35
- * nobody asked for. A replaced act still errors, and the caller's own retry — with the same id,
36
- * which is still the right one — is the safe path.
37
- */
38
- /**
39
- * How far to walk a chain of replacements before giving up.
40
- *
41
- * Generous for the real case (a page reloading a handful of times while a call is in flight) and
42
- * small enough that a cycle costs nothing. The number is a backstop, not a policy: a chain longer
43
- * than this means something is re-dialling in a loop, and that is a different problem.
44
- */
45
- const MAX_SUCCESSOR_HOPS = 32;
46
- const REBINDABLE_COMMANDS = new Set([
47
- ReticleCommand.SNAPSHOT,
48
- ReticleCommand.QUERY,
49
- ReticleCommand.MATCH,
50
- ReticleCommand.INSPECT,
51
- ReticleCommand.STATE_READ,
52
- ReticleCommand.STORAGE_READ,
53
- ReticleCommand.CAPABILITIES,
54
- ReticleCommand.ANIMATIONS,
55
- ]);
56
31
  /** Prefix on minted action ids (a1, a2, …) — the journal's action identity, independent of commands. */
57
32
  const ACTION_ID_PREFIX = 'a';
58
33
  /**
@@ -69,7 +44,13 @@ const WS_OPEN = 1;
69
44
  */
70
45
  export class Session {
71
46
  id;
72
- /** Stable build-stamped project identity; undefined for v1.0 SDKs that omit it. */
47
+ /**
48
+ * Stable build-stamped project identity; undefined for SDKs old enough to omit it.
49
+ *
50
+ * MINTED HERE. This is one of the two places a projectId enters the daemon with its provenance
51
+ * intact: the field is `projectId` on a zod-parsed HELLO, so the page said what it is. Every
52
+ * downstream reader takes the brand from this property rather than re-blessing a string.
53
+ */
73
54
  projectId;
74
55
  /**
75
56
  * The `.reticle` directory this session's evidence belongs in — impact counters AND capsules.
@@ -145,7 +126,7 @@ export class Session {
145
126
  #editEpoch;
146
127
  constructor(hello, socket, clock) {
147
128
  this.id = hello.sessionId;
148
- this.projectId = hello.projectId;
129
+ this.projectId = hello.projectId === undefined ? undefined : asProjectId(hello.projectId);
149
130
  this.url = hello.url;
150
131
  this.title = hello.title;
151
132
  this.adapters = hello.adapters;
@@ -408,17 +389,16 @@ export class Session {
408
389
  this.#journalReader = reader;
409
390
  }
410
391
  /**
411
- * Journal-backed event query: the ring buffer's events, merged with the durable journal **only when
412
- * the buffer has evicted** (so a healthy session pays no disk cost), then filtered by since/until/
413
- * actionId. This is how "what did action N cause" is answered after the buffer has dropped the
414
- * evidence — the substrate's whole point. The sync `eventsSince`/`window` stay for the hot path.
392
+ * Journal-backed event query — the rule itself lives in `readQueryEvents`, beside the merge and
393
+ * filter it is made of. This is how "what did action N cause" is answered after the buffer has
394
+ * dropped the evidence. The sync `eventsSince`/`window` stay for the hot path.
415
395
  */
416
396
  async queryEvents(options) {
417
- if (this.#journalReader !== undefined && this.#buffer.bufferHealth().dropped > 0) {
418
- const durable = await this.#journalReader.readEvents();
419
- return filterEvents(mergeEventsBySeq(durable, this.#buffer.since(0)), options);
420
- }
421
- return filterEvents(this.#buffer.since(options.since ?? 0), options);
397
+ return readQueryEvents(this.#journalReader, this.#buffer, options);
398
+ }
399
+ /** What the durable ledger could not WRITE — see `readJournalWriteLoss` for the rule and why. */
400
+ async journalWriteLoss() {
401
+ return readJournalWriteLoss(this.#journalReader, this.#buffer);
422
402
  }
423
403
  /**
424
404
  * Open an action-attribution window: events observed until finishAction attribute to the returned
@@ -541,12 +521,19 @@ export class Session {
541
521
  return this.#buffer.bufferHealth();
542
522
  }
543
523
  /**
544
- * Did the buffer lose scarce evidence from a window opened at `cursor`? The input to whether a
524
+ * Did the EVENT STORE lose scarce evidence from a window opened at `cursor`? The input to whether a
545
525
  * verdict's capture was clean — see `RingBuffer.lostSince`, and never the raw drop counter, which
546
- * moves for the age and churn evictions that every live page produces continuously.
526
+ * moves for the age and churn evictions that every live page produces continuously. The journal is
527
+ * the other half of that store, so a durable read that could not reach back to `cursor` lost
528
+ * exactly what an eviction would have. Both boundaries are INCLUSIVE (`t` is a millisecond many
529
+ * records share) and an absent `lostThroughT` impeaches every window rather than none — the
530
+ * conservative direction, both times.
547
531
  */
548
532
  lostSince(cursor) {
549
- return this.#buffer.lostSince(cursor);
533
+ if (this.#buffer.lostSince(cursor))
534
+ return true;
535
+ const lost = this.#journalReader?.readLoss?.();
536
+ return lost !== undefined && (lost.lostThroughT === undefined || lost.lostThroughT >= cursor);
550
537
  }
551
538
  onEvent(listener) {
552
539
  this.#listeners.add(listener);
@@ -19,12 +19,23 @@ interface StatusPayload {
19
19
  * beside a live session.
20
20
  */
21
21
  why?: string;
22
+ /**
23
+ * The same diagnosis with the differential left off: the lead and the next action.
24
+ *
25
+ * Two fields because there are two readers. `why` is for an agent about to spend a drive on a
26
+ * guess, and carries the ranked causes, the port-scan caveat and the lease offer. `whyLead` is
27
+ * for a surface a PERSON reads: `reticle init` prints the daemon's account of a failed connect,
28
+ * and was printing roughly five hundred words of it in one paragraph.
29
+ */
30
+ whyLead?: string;
22
31
  /** Port of the verify HTTP endpoint this daemon serves — present only when started with `--http`. */
23
32
  verifyPort?: number;
24
33
  }
25
34
  export declare function statusPayload(sessionCount: number, sessions: SessionInfo[],
26
35
  /** The no-session diagnosis, injected so this stays pure and the port probe stays off this path. */
27
- why?: string, verifyPort?: number): StatusPayload;
36
+ why?: string, verifyPort?: number,
37
+ /** The short rendering of the same diagnosis. Absent on a daemon that has no provider wired. */
38
+ whyLead?: string): StatusPayload;
28
39
  /**
29
40
  * Why `serve --http` cannot claim success against an already-running daemon — or undefined when
30
41
  * that daemon already serves the verify HTTP endpoint on the wanted port.