@reticlehq/server 3.2.0 → 3.3.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 (223) hide show
  1. package/SKILL.md +16 -13
  2. package/dist/command/cli/adhoc-suite.d.ts +19 -0
  3. package/dist/command/cli/adhoc-suite.js +88 -0
  4. package/dist/command/cli/adhoc-verdict.d.ts +23 -2
  5. package/dist/command/cli/adhoc-verdict.js +18 -10
  6. package/dist/command/cli/cli-doctor.js +25 -5
  7. package/dist/command/cli/cli-parse-grammar.d.ts +7 -0
  8. package/dist/command/cli/cli-parse-grammar.js +7 -0
  9. package/dist/command/cli/cli-parse-verify.d.ts +2 -5
  10. package/dist/command/cli/cli-parse-verify.js +86 -13
  11. package/dist/command/cli/cli-parse.d.ts +7 -1
  12. package/dist/command/cli/cli-parse.js +33 -5
  13. package/dist/command/cli/cli-verify.d.ts +59 -0
  14. package/dist/command/cli/cli-verify.js +129 -17
  15. package/dist/command/cli/cloud-kit.d.ts +0 -2
  16. package/dist/command/cli/cloud-kit.js +1 -1
  17. package/dist/command/cli/doctor/doctor-sessions-line.d.ts +2 -1
  18. package/dist/command/cli/launch/cli-launch.d.ts +1 -29
  19. package/dist/command/cli/launch/cli-launch.js +6 -40
  20. package/dist/command/cli/ports/resolve/cli-port.d.ts +13 -0
  21. package/dist/command/cli/ports/resolve/cli-port.js +45 -1
  22. package/dist/command/cli/report-command.d.ts +16 -0
  23. package/dist/command/cli/report-command.js +97 -0
  24. package/dist/command/cli.js +5 -0
  25. package/dist/command/daemon/binding/daemon-status-probe.d.ts +30 -0
  26. package/dist/command/daemon/binding/daemon-status-probe.js +45 -0
  27. package/dist/command/daemon/binding/port-presence.d.ts +1 -1
  28. package/dist/command/daemon/idle-shutdown.d.ts +2 -1
  29. package/dist/command/setup/init/init-host.js +8 -0
  30. package/dist/features/crawl/crawl.d.ts +2 -0
  31. package/dist/features/crawl/crawl.js +1 -0
  32. package/dist/features/harness/jev-driver.js +6 -9
  33. package/dist/features/harness/platform-config.d.ts +10 -0
  34. package/dist/features/harness/platform-config.js +8 -0
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.js +40 -95
  37. package/dist/judgement/capsule/capsule-store.d.ts +31 -2
  38. package/dist/judgement/capsule/capsule-store.js +58 -6
  39. package/dist/judgement/capsule/capsule.js +5 -0
  40. package/dist/judgement/capsule/causal-summary.d.ts +13 -1
  41. package/dist/judgement/capsule/causal-summary.js +7 -0
  42. package/dist/judgement/domain/domain-model.js +38 -9
  43. package/dist/judgement/outcome/assertion-integrity.d.ts +3 -3
  44. package/dist/judgement/phenomena/phenomena.d.ts +2 -1
  45. package/dist/judgement/runs/artifact/gap-report.d.ts +17 -0
  46. package/dist/judgement/runs/artifact/gap-report.js +46 -0
  47. package/dist/judgement/runs/artifact/gap-summary.d.ts +58 -0
  48. package/dist/judgement/runs/artifact/gap-summary.js +85 -0
  49. package/dist/judgement/runs/artifact/render-report.js +4 -1
  50. package/dist/judgement/runs/artifact/to-artifact.d.ts +7 -2
  51. package/dist/judgement/runs/artifact/to-artifact.js +1 -1
  52. package/dist/judgement/runs/context-tools.js +21 -3
  53. package/dist/judgement/runs/drive-run.js +7 -2
  54. package/dist/language/flows/annotate-notes/annotate.d.ts +0 -15
  55. package/dist/language/flows/annotate-notes/annotate.js +66 -18
  56. package/dist/language/flows/flow-anchor.d.ts +12 -0
  57. package/dist/language/flows/flow-anchor.js +20 -0
  58. package/dist/language/flows/flow-expect-grammar.d.ts +2 -2
  59. package/dist/language/flows/flow-expect-grammar.js +92 -11
  60. package/dist/language/flows/flow-intent.js +1 -1
  61. package/dist/language/flows/flow-learning.d.ts +3 -0
  62. package/dist/language/flows/flow-learning.js +39 -8
  63. package/dist/language/flows/flow-replay-run.d.ts +31 -9
  64. package/dist/language/flows/flow-replay-run.js +146 -33
  65. package/dist/language/flows/flow-replay-types.d.ts +9 -0
  66. package/dist/language/flows/flow-replay.d.ts +12 -11
  67. package/dist/language/flows/flow-replay.js +79 -109
  68. package/dist/language/flows/flow-step-runners.d.ts +26 -10
  69. package/dist/language/flows/flow-step-runners.js +20 -4
  70. package/dist/language/flows/flow-success.d.ts +21 -6
  71. package/dist/language/flows/flow-success.js +50 -138
  72. package/dist/language/flows/flows.d.ts +8 -4
  73. package/dist/language/flows/flows.js +11 -2
  74. package/dist/language/flows/heal.d.ts +9 -1
  75. package/dist/language/flows/heal.js +24 -2
  76. package/dist/language/flows/learned-sources.d.ts +21 -0
  77. package/dist/language/flows/learned-sources.js +84 -0
  78. package/dist/language/flows/recording/recording-backtrack.d.ts +0 -7
  79. package/dist/language/flows/recording/recording-backtrack.js +1 -1
  80. package/dist/language/flows/recording/replay-halt.d.ts +2 -1
  81. package/dist/language/flows/recording/tape/recordings.d.ts +2 -2
  82. package/dist/language/flows/replay.js +12 -8
  83. package/dist/language/flows/stores/annotation-store.d.ts +5 -5
  84. package/dist/language/flows/stores/assertion-tiers-store.js +2 -3
  85. package/dist/language/flows/stores/flake-store.js +2 -3
  86. package/dist/language/flows/suite/coverage.d.ts +1 -1
  87. package/dist/language/flows/suite/parallel-suite.d.ts +2 -1
  88. package/dist/language/flows/verify-change-tools.js +14 -0
  89. package/dist/memory/cloud/cloud-sync.d.ts +0 -2
  90. package/dist/memory/cloud/cloud-sync.js +1 -1
  91. package/dist/memory/cloud/harness-config.d.ts +2 -0
  92. package/dist/memory/cloud/sync-cycle.d.ts +12 -0
  93. package/dist/memory/cloud/sync-cycle.js +29 -4
  94. package/dist/memory/cloud/sync-daemon.d.ts +13 -0
  95. package/dist/memory/cloud/sync-daemon.js +21 -0
  96. package/dist/memory/cloud/sync-disk.js +11 -6
  97. package/dist/memory/impact/impact-store.js +6 -0
  98. package/dist/memory/intent/inline-intent.js +8 -3
  99. package/dist/memory/intent/intent-shard-store.d.ts +33 -47
  100. package/dist/memory/intent/intent-shard-store.js +252 -140
  101. package/dist/memory/intent/intent-shard.d.ts +10 -11
  102. package/dist/memory/intent/intent-shard.js +1 -1
  103. package/dist/memory/intent/intent-store.d.ts +13 -8
  104. package/dist/memory/intent/intent-store.js +46 -90
  105. package/dist/memory/intent/intent-tools.js +34 -23
  106. package/dist/memory/journal/ambient-store.js +2 -3
  107. package/dist/memory/journal/deviation-report.js +4 -1
  108. package/dist/memory/journal/deviation-service.js +10 -2
  109. package/dist/memory/journal/drive-flow.d.ts +2 -2
  110. package/dist/memory/journal/drive-flow.js +21 -12
  111. package/dist/memory/journal/envelope-store.js +2 -3
  112. package/dist/memory/journal/journal-recorder.d.ts +21 -0
  113. package/dist/memory/journal/on-disk/retain-policy.d.ts +34 -0
  114. package/dist/memory/journal/on-disk/retain-policy.js +44 -0
  115. package/dist/memory/journal/on-disk/retention.d.ts +14 -10
  116. package/dist/memory/journal/on-disk/retention.js +13 -12
  117. package/dist/memory/journal/on-disk/startup-maintenance.d.ts +42 -0
  118. package/dist/memory/journal/on-disk/startup-maintenance.js +14 -0
  119. package/dist/memory/journal/on-disk/workspace-gitignore.d.ts +0 -11
  120. package/dist/memory/journal/on-disk/workspace-gitignore.js +6 -45
  121. package/dist/memory/journal/on-disk/workspace-tiers.d.ts +52 -0
  122. package/dist/memory/journal/on-disk/workspace-tiers.js +88 -0
  123. package/dist/memory/journal/route-template.d.ts +22 -0
  124. package/dist/memory/journal/route-template.js +44 -0
  125. package/dist/memory/journal/session-end.d.ts +4 -1
  126. package/dist/memory/journal/session-end.js +64 -6
  127. package/dist/memory/journal/session-journal.d.ts +8 -1
  128. package/dist/memory/journal/session-journal.js +10 -0
  129. package/dist/memory/project/baselines.d.ts +1 -1
  130. package/dist/memory/project/dir/reticle-dir.d.ts +1 -1
  131. package/dist/memory/project/fs/fs-port.js +44 -5
  132. package/dist/memory/project/fs/write-atomic.d.ts +26 -0
  133. package/dist/memory/project/fs/write-atomic.js +48 -0
  134. package/dist/memory/recall/prior/connection-memory.d.ts +6 -0
  135. package/dist/memory/recall/prior/connection-memory.js +8 -0
  136. package/dist/memory/recall/registered-projects.d.ts +2 -1
  137. package/dist/portal/bridge/auth-failure-reason.js +5 -2
  138. package/dist/portal/input/real-input.js +2 -1
  139. package/dist/portal/pool/browser-pool.d.ts +10 -0
  140. package/dist/portal/pool/browser-pool.js +10 -0
  141. package/dist/portal/pool/playwright-launcher.js +1 -0
  142. package/dist/portal/pool/pool-contract.d.ts +2 -0
  143. package/dist/portal/pool/storage-seed.d.ts +2 -1
  144. package/dist/portal/pool/zero-install.d.ts +27 -0
  145. package/dist/portal/pool/zero-install.js +54 -0
  146. package/dist/portal/realm/web-realm.js +6 -0
  147. package/dist/portal/session/dev-server/dev-server-probe.d.ts +2 -1
  148. package/dist/portal/session/dev-server/served-document.d.ts +21 -0
  149. package/dist/portal/session/dev-server/served-document.js +57 -0
  150. package/dist/portal/session/facts/background-traffic.d.ts +9 -0
  151. package/dist/portal/session/facts/background-traffic.js +25 -0
  152. package/dist/portal/session/live-control-tools.js +48 -3
  153. package/dist/portal/session/presence/attachment-history.d.ts +13 -2
  154. package/dist/portal/session/session-health.d.ts +61 -0
  155. package/dist/portal/session/session-health.js +40 -1
  156. package/dist/portal/session/session-info.d.ts +6 -0
  157. package/dist/portal/session/session-manager.d.ts +9 -0
  158. package/dist/portal/session/session-manager.js +23 -2
  159. package/dist/portal/session/session-successor.d.ts +11 -0
  160. package/dist/portal/session/session-successor.js +14 -1
  161. package/dist/portal/session/session-verdict-facts.d.ts +28 -0
  162. package/dist/portal/session/session-verdict-facts.js +15 -0
  163. package/dist/portal/session/session.d.ts +5 -0
  164. package/dist/portal/session/session.js +19 -7
  165. package/dist/surface/mcp/mcp-proxy.js +3 -3
  166. package/dist/surface/mcp/proxy-idle-exit.d.ts +2 -1
  167. package/dist/surface/mcp/server-instructions.js +12 -1
  168. package/dist/surface/tools/act/already-true.d.ts +40 -0
  169. package/dist/surface/tools/act/already-true.js +36 -0
  170. package/dist/surface/tools/act/navigation/navigate-arrival.d.ts +6 -0
  171. package/dist/surface/tools/act/navigation/navigate-arrival.js +13 -0
  172. package/dist/surface/tools/act/settle-in-flight.d.ts +6 -2
  173. package/dist/surface/tools/act/settle-in-flight.js +29 -6
  174. package/dist/surface/tools/act-capsule.js +22 -7
  175. package/dist/surface/tools/act-tools.js +25 -24
  176. package/dist/surface/tools/args/numeric-bounds.d.ts +15 -2
  177. package/dist/surface/tools/args/numeric-bounds.js +30 -3
  178. package/dist/surface/tools/assert/assert-grade.js +14 -2
  179. package/dist/surface/tools/assert/assert-verdict.js +30 -4
  180. package/dist/surface/tools/browser-tools.js +4 -3
  181. package/dist/surface/tools/dynamic-tools.js +6 -0
  182. package/dist/surface/tools/feedback-tools.d.ts +2 -1
  183. package/dist/surface/tools/feedback-tools.js +1 -1
  184. package/dist/surface/tools/invoke-tool.js +17 -4
  185. package/dist/surface/tools/lease-hint.d.ts +8 -0
  186. package/dist/surface/tools/lease-hint.js +10 -0
  187. package/dist/surface/tools/lease-tools.d.ts +16 -28
  188. package/dist/surface/tools/lease-tools.js +53 -5
  189. package/dist/surface/tools/lineage-tools.js +1 -1
  190. package/dist/surface/tools/observe-tools.js +14 -6
  191. package/dist/surface/tools/real-input-attempt.d.ts +0 -5
  192. package/dist/surface/tools/real-input-attempt.js +1 -1
  193. package/dist/surface/tools/tool-kit.d.ts +5 -0
  194. package/dist/surface/tools/tool-kit.js +6 -1
  195. package/dist/surface/tools/tool-surface.js +17 -5
  196. package/dist/surface/tools/tools.js +64 -29
  197. package/dist/telemetry/feedback-local.d.ts +1 -0
  198. package/dist/telemetry/feedback-local.js +1 -3
  199. package/dist/telemetry/feedback.d.ts +1 -1
  200. package/dist/telemetry/hud-metrics.d.ts +32 -0
  201. package/dist/telemetry/hud-metrics.js +95 -0
  202. package/dist/telemetry/session-metrics.d.ts +5 -0
  203. package/dist/telemetry/session-metrics.js +15 -1
  204. package/dist/wire-journal.d.ts +31 -0
  205. package/dist/wire-journal.js +100 -0
  206. package/docs/README.md +1 -0
  207. package/docs/cli/cloud.mdx +9 -7
  208. package/docs/cli/init.mdx +1 -0
  209. package/docs/cli/report.mdx +44 -0
  210. package/docs/desktop.mdx +1 -1
  211. package/docs/flows.md +11 -0
  212. package/docs/frameworks.mdx +113 -1
  213. package/docs/install-agentic.mdx +36 -0
  214. package/docs/install-manual.mdx +1 -1
  215. package/docs/packages/tauri.mdx +1 -1
  216. package/docs/packages.mdx +1 -1
  217. package/docs/predicates.mdx +128 -4
  218. package/docs/tools/query.mdx +10 -10
  219. package/docs/usage.md +1 -1
  220. package/docs/what-is-recorded.md +62 -0
  221. package/package.json +6 -6
  222. package/dist/judgement/outcome/predicate-to-expect.d.ts +0 -44
  223. package/dist/judgement/outcome/predicate-to-expect.js +0 -148
@@ -15,8 +15,6 @@ import { type NetworkMutationPort } from '../../portal/input/network-mutation.js
15
15
  import type { Perturbation } from '@reticlehq/core';
16
16
  import { type SeedStorage } from '@reticlehq/core';
17
17
  import type { ToolDef } from './tool-kit.js';
18
- /** The origin of a URL, or undefined when it does not parse. */
19
- export declare function originOf(url: string): string | undefined;
20
18
  /**
21
19
  * Append Reticle identity params (__reticle_session, optional __reticle_project) to a URL so the app's own SDK
22
20
  * adopts them on connect. Pure; falls back to plain concatenation if the URL can't be parsed.
@@ -78,31 +76,6 @@ export declare function resolveLeasedSessionId(sessions: {
78
76
  * a human's own URL.
79
77
  */
80
78
  export declare function carryReticleIdentity(fromUrl: string | undefined, toUrl: string): string;
81
- /** The narrow slice of a session the probe needs. Anything that quacks like this works. */
82
- interface ProbeableSession {
83
- command?: (name: string, args: Record<string, unknown>, timeoutMs: number) => Promise<unknown>;
84
- }
85
- /**
86
- * Does this tab still answer?
87
- *
88
- * PRESENCE IS NOT LIVENESS. The sessions map still holds a tab that is attached, streaming events,
89
- * and answering nothing, so `ready` read off a row in that map hands back a lease that `snapshot`,
90
- * `state` and `console` then reject (#692). It has to mean the tab replied.
91
- *
92
- * ANY reply counts, including one that reports the command failed. The question is whether the SDK
93
- * answers at all, not what it says — so an SDK too old to know the command replies
94
- * `unknown command '…'`, and that is proof. Only the absence of a reply is evidence of absence:
95
- * `PendingCommands.track` REJECTS on timeout and on a dropped socket, and resolves on every real
96
- * answer, so the two cases are already separated for us.
97
- *
98
- * `CAPABILITIES` is the probe because it takes no arguments and returns a small fixed list. No new
99
- * wire command is introduced: this rides an existing round trip.
100
- *
101
- * Fails OPEN. A registry entry with no `command` is a shape this code did not put there, and
102
- * turning a lease that works into a refusal over a probe that could not run would be a worse
103
- * failure than the one being fixed.
104
- */
105
- export declare function probeLeaseAlive(session: ProbeableSession | undefined, timeoutMs?: number): Promise<boolean>;
106
79
  /**
107
80
  * Poll until the leased tab's SDK has actually registered on the bridge, so the sessionId we return
108
81
  * is immediately usable (the page navigates first, then connects a moment later — without this wait
@@ -122,6 +95,7 @@ export declare function acquireLeasedSession(pool: {
122
95
  sessionId: string;
123
96
  release: () => Promise<void>;
124
97
  navStatus?: number;
98
+ injectReader?: () => Promise<boolean>;
125
99
  }>;
126
100
  /**
127
101
  * The address this lease's page said it could not reach. Optional so a test double need not
@@ -141,6 +115,21 @@ export declare function acquireLeasedSession(pool: {
141
115
  release: () => Promise<void>;
142
116
  }>;
143
117
  export declare function waitForLeasedSession(isConnected: () => boolean, sleeper?: (ms: number) => Promise<void>, attempts?: number, pollMs?: number): Promise<boolean>;
118
+ /**
119
+ * Wait for the leased tab to connect; if its app never does, hand it the zero-install reader.
120
+ *
121
+ * The decision is taken HERE, by the daemon, on the one fact that settles it: no HELLO arrived. A
122
+ * timer inside the page cannot know that — an app can connect from a dynamic import that resolves
123
+ * after `load` — and a reader injected into a page whose app was about to connect would take its
124
+ * place and drop the app's own options. So an app that connected itself is never touched, and a page
125
+ * that stayed silent through the whole readiness wait is the one that gets a reader.
126
+ */
127
+ export declare function connectOrInject(lease: {
128
+ injectReader?: () => Promise<boolean>;
129
+ }, isConnected: () => boolean, wait?: (isConnected: () => boolean) => Promise<boolean>): Promise<{
130
+ ready: boolean;
131
+ zeroInstall: boolean;
132
+ }>;
144
133
  export declare function hasOriginLock(origin: string): boolean;
145
134
  export declare const LEASE_ACQUIRE_TOOL: ToolDef;
146
135
  export declare const LEASE_TOOLS: ToolDef[];
@@ -192,4 +181,3 @@ export declare function suiteFixturePort(realInput: RealInputProvider | undefine
192
181
  * is the reach the boundary guard exists to ask about.
193
182
  */
194
183
  export declare function sessionMutationPort(realInput: RealInputProvider | undefined, appUrl: string | undefined): Promise<NetworkMutationPort | undefined>;
195
- export {};
@@ -14,6 +14,8 @@ import { mutationPortFor } from '../../portal/input/network-mutation.js';
14
14
  import { z } from 'zod';
15
15
  import { leaseNotConnectedHint } from './lease-hint.js';
16
16
  import { probeSdkMarker } from './gaps/sdk-marker-probe.js';
17
+ import { observeWebDocument } from '../../portal/session/dev-server/served-document.js';
18
+ import { diagnoseObservedWebCsp } from '@reticlehq/init';
17
19
  import { readProjectFramework, readProjectId } from '../../command/cli/ports/resolve/cli-port.js';
18
20
  import { hasAnyAppConnectedBefore } from '../../memory/recall/prior/connection-memory.js';
19
21
  import { AGENT_DRIVING_ELSEWHERE, AGENT_DRIVING_HERE_AGAIN, watchersToNotify, } from '../../portal/session/lease-visibility.js';
@@ -36,14 +38,38 @@ import { chromiumHint } from '../../command/cli/doctor/browser/chromium-hint.js'
36
38
  * to print a paragraph, and it can only ever add a sentence: the probe returns `undefined` on any
37
39
  * doubt and the hint then says nothing about markers.
38
40
  */
41
+ /**
42
+ * Whether the served page's own CSP refuses the bridge's SOCKET, read by the same code `doctor` uses.
43
+ *
44
+ * Only `connect-src` counts here. A lease injects the SDK itself, so the inline-snippet problem a
45
+ * `script-src` finding describes does not apply; what stops a leased page is the socket. Said in the
46
+ * lease's own words, because doctor's advice is about wiring a project, not about a tab.
47
+ */
48
+ async function observedCspBlock(url, port) {
49
+ const document = await observeWebDocument(url);
50
+ if (document === undefined)
51
+ return undefined;
52
+ const refusesSocket = diagnoseObservedWebCsp(document, port).some((finding) => `${finding.problem} ${finding.fix}`.includes(CONNECT_SRC));
53
+ if (!refusesSocket)
54
+ return undefined;
55
+ const allow = `ws://localhost:${String(port)} ws://127.0.0.1:${String(port)}`;
56
+ return {
57
+ problem: `its ${CONNECT_SRC} does not allow ws://localhost:${String(port)}`,
58
+ fix: `add ${allow} to ${CONNECT_SRC}, behind a development-only check`,
59
+ };
60
+ }
61
+ /** The CSP directive that governs the socket the SDK dials. */
62
+ const CONNECT_SRC = 'connect-src';
39
63
  async function leaseEvidence(deps, port, url) {
40
64
  const cwd = process.cwd();
41
65
  const projectId = readProjectId(cwd);
42
66
  const refusal = deps.sessions.lastClosure()?.reason;
43
67
  const framework = readProjectFramework(cwd);
44
68
  const sdkMarker = await probeSdkMarker(url);
69
+ const cspBlock = await observedCspBlock(url, port);
45
70
  return {
46
71
  ...(refusal === undefined ? {} : { refusal }),
72
+ ...(cspBlock === undefined ? {} : { cspBlock }),
47
73
  ...(framework === undefined ? {} : { framework }),
48
74
  ...(sdkMarker === undefined ? {} : { sdkMarker }),
49
75
  previouslyConnected: hasAnyAppConnectedBefore(reticleStateHome(), port, projectId),
@@ -52,7 +78,7 @@ async function leaseEvidence(deps, port, url) {
52
78
  }
53
79
  const POOL_UNAVAILABLE = 'browser pool unavailable — the lease tools need the daemon-managed pool (start Reticle via `reticle mcp`).';
54
80
  /** The origin of a URL, or undefined when it does not parse. */
55
- export function originOf(url) {
81
+ function originOf(url) {
56
82
  try {
57
83
  return new URL(url).origin;
58
84
  }
@@ -298,7 +324,7 @@ const LEASE_PROBE_TIMEOUT_MS = 1_500;
298
324
  * turning a lease that works into a refusal over a probe that could not run would be a worse
299
325
  * failure than the one being fixed.
300
326
  */
301
- export async function probeLeaseAlive(session, timeoutMs = LEASE_PROBE_TIMEOUT_MS) {
327
+ async function probeLeaseAlive(session, timeoutMs = LEASE_PROBE_TIMEOUT_MS) {
302
328
  const send = session?.command;
303
329
  if (send === undefined)
304
330
  return true;
@@ -338,7 +364,7 @@ export async function acquireLeasedSession(pool, sessions, url, projectId, seedS
338
364
  // id comes back, so handing it the lease id for an app that named its own session sent every flow
339
365
  // in the run at a session that does not exist.
340
366
  let registeredId;
341
- await waitForLeasedSession(() => {
367
+ await connectOrInject(lease, () => {
342
368
  registeredId = resolveLeasedSessionId(sessions, lease.sessionId);
343
369
  return registeredId !== undefined;
344
370
  });
@@ -364,6 +390,23 @@ export async function waitForLeasedSession(isConnected, sleeper = sleep, attempt
364
390
  }
365
391
  return isConnected();
366
392
  }
393
+ /**
394
+ * Wait for the leased tab to connect; if its app never does, hand it the zero-install reader.
395
+ *
396
+ * The decision is taken HERE, by the daemon, on the one fact that settles it: no HELLO arrived. A
397
+ * timer inside the page cannot know that — an app can connect from a dynamic import that resolves
398
+ * after `load` — and a reader injected into a page whose app was about to connect would take its
399
+ * place and drop the app's own options. So an app that connected itself is never touched, and a page
400
+ * that stayed silent through the whole readiness wait is the one that gets a reader.
401
+ */
402
+ export async function connectOrInject(lease, isConnected, wait = waitForLeasedSession) {
403
+ if (await wait(isConnected))
404
+ return { ready: true, zeroInstall: false };
405
+ if (true !== (await lease.injectReader?.()))
406
+ return { ready: false, zeroInstall: false };
407
+ const ready = await wait(isConnected);
408
+ return { ready, zeroInstall: ready };
409
+ }
367
410
  /**
368
411
  * Named on its own because `reticle drive <url>` runs it too.
369
412
  *
@@ -381,7 +424,7 @@ export function hasOriginLock(origin) {
381
424
  }
382
425
  export const LEASE_ACQUIRE_TOOL = {
383
426
  name: ReticleTool.LEASE_ACQUIRE,
384
- description: 'Lease a fresh isolated headless browser context from the shared pool and navigate it to the app URL (the app must already be running and embed @reticlehq/core). If this origin is already leased and still connected, this returns THAT session rather than minting a second tab — a second acquire on the same origin poisons default session resolution. Returns the sessionId the leased tab registers — pass it to other tools. The pool keeps all leases in ONE browser and caps concurrency; if at capacity this waits for a free slot. Release with reticle_lease{action:"release"} when the flow is done. PREFER AN ALREADY-OPEN TAB: if reticle_sessions lists a non-leased session for this app, drive THAT instead — a lease is invisible to the person watching the app, whose HUD lives in their own tab, and a tab flagged hidden/throttled is often still driveable. Lease for isolation you actually need (a second identity, a clean context, parallel flows) or when driving the open tab has failed — this call answers with `preferExisting` when a live tab was available.',
427
+ description: 'Lease a fresh isolated headless browser context from the shared pool and navigate it to the app URL (the app must already be running; one with no Reticle SDK gets one supplied, reported as zeroInstall). If this origin is already leased and still connected, this returns THAT session rather than minting a second tab — a second acquire on the same origin poisons default session resolution. Returns the sessionId the leased tab registers — pass it to other tools. The pool keeps all leases in ONE browser and caps concurrency; if at capacity this waits for a free slot. Release with reticle_lease{action:"release"} when the flow is done. PREFER AN ALREADY-OPEN TAB: if reticle_sessions lists a non-leased session for this app, drive THAT instead — a lease is invisible to the person watching the app, whose HUD lives in their own tab, and a tab flagged hidden/throttled is often still driveable. Lease for isolation you actually need (a second identity, a clean context, parallel flows) or when driving the open tab has failed — this call answers with `preferExisting` when a live tab was available.',
385
428
  inputSchema: {
386
429
  url: z
387
430
  .string()
@@ -398,6 +441,10 @@ export const LEASE_ACQUIRE_TOOL = {
398
441
  ready: z
399
442
  .boolean()
400
443
  .describe('Whether the leased tab is connected AND answering. On a reused lease this is probed, not assumed — a row in the session map is presence, not liveness. False ⇒ read notReadyReason.'),
444
+ zeroInstall: z
445
+ .boolean()
446
+ .optional()
447
+ .describe('Present when this app ships no Reticle SDK and the lease supplied one. Verdicts work on the DOM, network, console and routes; there is no framework adapter, so no component state and no source file:line. Install with `npx @reticlehq/server init` for those.'),
401
448
  notReadyReason: z
402
449
  .enum([LeaseNotReadyReason.SDK_NEVER_DIALLED, LeaseNotReadyReason.SDK_STOPPED_ANSWERING])
403
450
  .optional()
@@ -538,7 +585,7 @@ export const LEASE_ACQUIRE_TOOL = {
538
585
  // Resolved rather than assumed: an app that names its own session registers under that name,
539
586
  // and the id we hand back has to be the one the agent can actually drive.
540
587
  let registeredId;
541
- const ready = await waitForLeasedSession(() => {
588
+ const { ready, zeroInstall } = await connectOrInject(lease, () => {
542
589
  registeredId = resolveLeasedSessionId(deps.sessions, lease.sessionId);
543
590
  return registeredId !== undefined;
544
591
  });
@@ -569,6 +616,7 @@ export const LEASE_ACQUIRE_TOOL = {
569
616
  // word: no SDK ever dialled in (look at the install) versus one dialled in and stopped
570
617
  // answering (recover the tab). They want different next actions, so they get names.
571
618
  ...(ready ? {} : { notReadyReason: LeaseNotReadyReason.SDK_NEVER_DIALLED }),
619
+ ...(zeroInstall ? { zeroInstall: true } : {}),
572
620
  expiresInMs: pool.leaseTtlMs(),
573
621
  leased: pool.activeCount(),
574
622
  queued: pool.queuedCount(),
@@ -19,7 +19,7 @@ export const LINEAGE_TOOLS = [
19
19
  {
20
20
  name: ReticleTool.LINEAGE,
21
21
  example: { path: 'user.profile.name' },
22
- description: 'Trace BACKWARDS from a runtime value: which state change set it, which signal preceded that, and which request preceded the signal. Read-only — it draws no verdict and changes nothing. The state change is OBSERVED; every link above it is an INFERENCE from timing and says so in its own text. Where several candidates could explain a link it names ALL of them rather than choosing, because timing alone cannot justify the choice — so a chain here is a place to look, never a proven cause. Use it after a `no` verdict or a contradiction, instead of correlating reticle_state, reticle_network and reticle_observe by hand.',
22
+ description: 'Trace BACKWARDS from a runtime value: which state change set it, which driven action it was observed under, which signal preceded it, and which request preceded that. Read-only — it draws no verdict and changes nothing. The state change is OBSERVED; every link above it is an INFERENCE and says so in its own text — a driven action is the window-tier stamp the SDK put on the event, a signal or request is a timing look-back. A candidate stamped with a DIFFERENT action is excluded; an unstamped one is kept. Where several candidates could still explain a link it names ALL of them rather than choosing, because timing alone cannot justify the choice — so a chain here is a place to look, never a proven cause. Use it after a `no` verdict or a contradiction, instead of correlating reticle_look { action: "state" }, reticle_observe { action: "network" } and reticle_observe by hand.',
23
23
  inputSchema: {
24
24
  path: z
25
25
  .string()
@@ -7,7 +7,7 @@ import { z } from 'zod';
7
7
  import { aliasParam } from './args/alias-args.js';
8
8
  import { CONSOLE_ATTACH_NOTE, CONSOLE_LEVELS, ReticleCommand, DEFAULT_ASSERT_TIMEOUT_MS, PredicateKind, Verified, } from '@reticlehq/core';
9
9
  import { ReticleTool } from '@reticlehq/core';
10
- import { countSchema, cursorSchema, httpStatusSchema, timeoutMsSchema, windowMsSchema, } from './args/numeric-bounds.js';
10
+ import { countSchema, cursorSchema, httpStatusSchema, timeoutMsSchema, waitForTimeoutMsSchema, resumeAfter, MAX_BLOCKING_WAIT_MS, windowMsSchema, } from './args/numeric-bounds.js';
11
11
  import { buildReactionReport } from '@reticlehq/engine/question/reaction.js';
12
12
  import { findContradictions } from '@reticlehq/engine/disagreement/contradictions.js';
13
13
  import { evaluatePredicate, waitForPredicate, PredicateSchema, } from '@reticlehq/engine/question/predicate/predicate.js';
@@ -16,7 +16,7 @@ import { WALL_CLOCK } from '../../portal/session/timing/wall-clock.js';
16
16
  import { parsePredicate } from '@reticlehq/engine/question/predicate/predicate-parse.js';
17
17
  import { matchNet, matchConsole, isConsoleEvent, eventMatchesFilters, netEmptyHint, consoleEmptyHint, reconcileNet, projectNetCall, projectConsoleLog, withoutUrlRaw, } from '@reticlehq/engine/window/event-filters.js';
18
18
  import { applyEventBudget, costHint, withSizeCost, DEFAULT_OBSERVE_EVENT_LIMIT, DEFAULT_QUERY_LIMIT, } from '../../portal/session/output-budget.js';
19
- import { annotateStarvedFailure, healthEnvelope, bufferEnvelope, } from '../../portal/session/session-health.js';
19
+ import { annotateStarvedFailure, currentOf, healthEnvelope, bufferEnvelope, } from '../../portal/session/session-health.js';
20
20
  import { assertsDerivedIpcStatus, DERIVED_IPC_STATUS_ADVICE, isPresenceOnlyAssertion, PRESENCE_ONLY_ADVICE, } from './assert/assert-grade.js';
21
21
  import { assertVerdict } from './assert/assert-verdict.js';
22
22
  import { followLostObservation } from './act/act-observation.js';
@@ -172,6 +172,7 @@ export const OBSERVE_TOOLS = [
172
172
  currentDocumentId: session.currentDocumentId,
173
173
  currentEditEpoch: session.currentEditEpoch,
174
174
  appOrigin: session.url,
175
+ background: session.background,
175
176
  ...(judgingTheAct ? { ...session.lastAct.effect(), actionSince: actCursor } : {}),
176
177
  });
177
178
  // carry session health — a throttled tab means the observed timeline may be incomplete.
@@ -193,15 +194,19 @@ export const OBSERVE_TOOLS = [
193
194
  predicate: PredicateSchema.optional().describe('Predicate to wait for: { signal }, { net }, { element }, { kind: "net", ok: false } (assert on the OUTCOME — the honest field for IPC, which has no status code), { kind: "state", store, path, equals } (assert a registered store\'s value directly — the source of truth no DOM read can reach; equals takes a literal or { $gte | $contains | $length } pattern), { kind: "settled", quietMs } (deterministic network + DOM idle — prefer this over a fixed sleep), or a combination via allOf/anyOf.'),
194
195
  // Same concept, the neighbouring tool's name. See alias-args.ts.
195
196
  until: PredicateSchema.optional().describe("Alias for `predicate` (act_and_wait's name)."),
196
- timeout_ms: timeoutMsSchema
197
+ timeout_ms: waitForTimeoutMsSchema
197
198
  .optional()
198
- .describe('Maximum wait in milliseconds. Default: 4000. Capped at 55000: your MCP client aborts the request before a longer wait can return, so a bound above this would be advertised and not deliverable. To outlast it, poll — several short waits, each of which returns a verdict.'),
199
+ .describe(`Maximum wait in milliseconds. Default: 4000, up to 600000. One call waits at most ${String(MAX_BLOCKING_WAIT_MS)} ms, because your MCP client aborts a longer request; past that it returns resume_ms, and you call again with timeout_ms: resume_ms and the same since to keep waiting.`),
199
200
  since: cursorSchema
200
201
  .optional()
201
202
  .describe('Cursor from a prior reticle_act — scopes the wait to events after that act.'),
202
203
  ...sessionIdShape,
203
204
  },
204
205
  outputSchema: {
206
+ resume_ms: z
207
+ .number()
208
+ .optional()
209
+ .describe('Present when this call stopped at the per-call limit before the predicate was seen. Call again with timeout_ms set to this, the same predicate and the same since, to keep waiting.'),
205
210
  pass: z.boolean(),
206
211
  evidence: z.unknown().optional(),
207
212
  failureReason: z.string().optional(),
@@ -239,7 +244,9 @@ export const OBSERVE_TOOLS = [
239
244
  .describe('Where the code behind this verdict lives, as `file:line`. For an element/text assertion it is the matched element itself; for a FAILING signal/net/state assertion — which has no element to point at — it is the control last acted on, where the handler that should have fired lives. OMITTED when neither is known: this tool never borrows an unrelated location.'),
240
245
  },
241
246
  handler: async (deps, args) => {
242
- const waitBudget = asNumber(args['timeout_ms']) ?? DEFAULT_ASSERT_TIMEOUT_MS;
247
+ const requestedMs = asNumber(args['timeout_ms']) ?? DEFAULT_ASSERT_TIMEOUT_MS;
248
+ // One call never blocks past what the client allows; the rest comes back as resume_ms.
249
+ const waitBudget = Math.min(requestedMs, MAX_BLOCKING_WAIT_MS);
243
250
  // Spend the budget waiting for the APP as well as for the predicate. See resolve-within.
244
251
  let session = await resolveSessionWithin(deps.sessions, asString(args['sessionId']), waitBudget, WALL_CLOCK);
245
252
  // `until` is act_and_wait's name for this — see alias-args.ts.
@@ -275,6 +282,7 @@ export const OBSERVE_TOOLS = [
275
282
  ...annotateStarvedFailure(session, verdict),
276
283
  ...assertionSource(session, predicate, verdict),
277
284
  ...(followed.followed ? { sessionId: session.id } : {}),
285
+ ...((resume) => (resume === undefined ? {} : { resume_ms: resume }))(resumeAfter(requestedMs, waitBudget, verdict)),
278
286
  ...healthEnvelope(session),
279
287
  ...bufferEnvelope(session),
280
288
  });
@@ -454,7 +462,7 @@ export const OBSERVE_TOOLS = [
454
462
  ...coverage,
455
463
  // The SAME pointer the journal keeps, not a second lookup — one verdict, one file:line.
456
464
  ...(verdictEffect.source === undefined ? {} : { source: verdictEffect.source }),
457
- ...healthEnvelope(session),
465
+ ...healthEnvelope(currentOf(deps.sessions, session)),
458
466
  ...bufferEnvelope(session),
459
467
  });
460
468
  },
@@ -22,11 +22,6 @@
22
22
  import { ActionType, InputModeReason } from '@reticlehq/core';
23
23
  import type { Session } from '../../portal/session/session.js';
24
24
  import { type ToolDeps } from './tool-kit.js';
25
- /**
26
- * Expand a leading `~` against the caller's home directory. Absolute and relative paths pass
27
- * through unchanged. `home` is injected so unit tests do not depend on the machine's real home.
28
- */
29
- export declare function expandUserPath(rawPath: string, home: string): string;
30
25
  /**
31
26
  * If the action is `upload` AND the inner args carry `path`, read the file from disk and rewrite
32
27
  * the args to `{ content, name, type, __base64: true }` before the ACT command reaches the browser.
@@ -99,7 +99,7 @@ const UPLOAD_MAX_BYTES = Math.floor((TRANSPORT_LIMITS.MAX_MESSAGE_BYTES / (4 / 3
99
99
  * Expand a leading `~` against the caller's home directory. Absolute and relative paths pass
100
100
  * through unchanged. `home` is injected so unit tests do not depend on the machine's real home.
101
101
  */
102
- export function expandUserPath(rawPath, home) {
102
+ function expandUserPath(rawPath, home) {
103
103
  if ('~' === rawPath)
104
104
  return home;
105
105
  if (rawPath.startsWith('~/') || rawPath.startsWith('~\\'))
@@ -222,6 +222,11 @@ export declare const EnvelopeKey: {
222
222
  readonly VERIFY_NEXT: "verify_next";
223
223
  /** The contextual "tell us what just went wrong" invitation. Same story, same release. */
224
224
  readonly FEEDBACK_INVITE: "feedback_invite";
225
+ /**
226
+ * The same moment, for the HUMAN: a call with the founder, or an email through `identify`. The
227
+ * agent is told to offer it and never to book or submit anything itself.
228
+ */
229
+ readonly TALK_TO_US: "talk_to_us";
225
230
  /** SDK/daemon version skew — often the one fact that explains everything else in the session. */
226
231
  readonly VERSION_SKEW: "version_skew";
227
232
  /** A feedback report that was accepted and then failed to send. Only the reporter can act on it. */
@@ -28,7 +28,7 @@ export const intentArg = z
28
28
  'BAD: "P1 step 5", "drive 2 of 2", "same as before the fix", "the board renders cleanly" — these name a session, ' +
29
29
  'a step number or nothing checkable, and the person reading them later has none of that context. ' +
30
30
  'This is shared memory: it is pooled per project and later agents read it back to avoid re-deriving what you just established. ' +
31
- 'Recorded in .reticle/intent.json, the same ledger reticle_intent writes, and marked proved by this verdict if it passes.');
31
+ 'Recorded in .reticle/intent/, the same ledger reticle_intent writes, and marked proved by this verdict if it passes.');
32
32
  export const sessionIdShape = {
33
33
  sessionId: z
34
34
  .string()
@@ -75,6 +75,11 @@ export const EnvelopeKey = {
75
75
  VERIFY_NEXT: 'verify_next',
76
76
  /** The contextual "tell us what just went wrong" invitation. Same story, same release. */
77
77
  FEEDBACK_INVITE: 'feedback_invite',
78
+ /**
79
+ * The same moment, for the HUMAN: a call with the founder, or an email through `identify`. The
80
+ * agent is told to offer it and never to book or submit anything itself.
81
+ */
82
+ TALK_TO_US: 'talk_to_us',
78
83
  /** SDK/daemon version skew — often the one fact that explains everything else in the session. */
79
84
  VERSION_SKEW: 'version_skew',
80
85
  /** A feedback report that was accepted and then failed to send. Only the reporter can act on it. */
@@ -395,11 +395,23 @@ export function describeToolSurface(active, requested) {
395
395
  export function defaultAdvertisedNames() {
396
396
  const surface = resolveToolSurface();
397
397
  const names = [...filterToolNames(surface)];
398
- // `reticle_run` is real only where the surface keeps a dispatch hatch. The merged surface ships
399
- // the catalogue without one, and naming a hatch that is not there is the same defect one level in.
400
- return TOOL_SURFACE.MERGED === surface
401
- ? [...names, ReticleTool.TOOLS]
402
- : [...names, ReticleTool.TOOLS, ReticleTool.RUN];
398
+ /*
399
+ * Both meta tools, on every surface, because that is what the MCP actually advertises.
400
+ *
401
+ * This used to withhold `reticle_run` from the merged surface, reasoning that "naming a hatch
402
+ * that is not there is the same defect one level in". The reasoning is sound and the premise was
403
+ * false: `buildDynamicTools` returns `[reticle_tools, reticle_run]` unconditionally and
404
+ * `advertisedTools` appends both to every surface, so the hatch has always been there. Measured
405
+ * against a live daemon on the default surface: `reticle_run` is advertised, and
406
+ * `reticle_run { tool: "reticle_lease" }` reaches `reticle_lease`.
407
+ *
408
+ * Two functions answering one question, and this was the one the MCP never called. It is read by
409
+ * `server-instructions`, which gates "Everything else is one hop: reticle_tools lists it,
410
+ * reticle_run calls it" on this list - so agents were told the hatch was absent from a surface
411
+ * carrying it, and six recovery messages were later rewritten to match the lie.
412
+ * `advertised-names-are-real.test.ts` now pins this to `advertisedTools` itself.
413
+ */
414
+ return [...names, ReticleTool.TOOLS, ReticleTool.RUN];
403
415
  }
404
416
  /** The advertised NAME set for a surface, without needing the tool table. */
405
417
  function filterToolNames(surface) {
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { NoSessionAction, QueryBy, ReticleCommand, SnapshotMode } from '@reticlehq/core';
2
+ import { DiscoveryInvite, AttrNamesSchema, EventType, NoSessionAction, QueryBy, ReticleCommand, SnapshotMode, } from '@reticlehq/core';
3
3
  import { ReticleTool } from '@reticlehq/core';
4
4
  import { withSizeCost } from '../../portal/session/output-budget.js';
5
5
  import { applySnapshotDelta, SnapshotCache } from './read/snapshot-delta.js';
@@ -109,7 +109,7 @@ export const RAW_TOOLS = [
109
109
  lastOutage: z.object({ startedMs: z.number(), durationMs: z.number() }).optional(),
110
110
  })
111
111
  .optional()
112
- .describe('Present only when this tab has dropped and reconnected at least once: how long it has been attached in total, how many outages occurred, and when the last one started and how long it lasted. A verdict over this window spans a gap where nothing was observed.'),
112
+ .describe('Continuity of the SDK-to-daemon LINK for this tab, present for any tab the daemon has a record of: how long this attachment has lasted, how many times it dropped and came back, and when the last gap started and how long it lasted. `outages > 0` means a verdict over this window spans a gap where nothing was observed. These are link counters, NOT app health: they say nothing about whether the page origin is still serving, so a tab whose dev server has died reports `outages: 0` with a growing `connectedSinceMs` and is not evidence the app is up.'),
113
113
  }))
114
114
  .describe('Connected browser sessions with health state. `projectId` groups sessions by app (stable across port changes); `leased` marks a pool-managed headless context vs a human tab.'),
115
115
  why: z
@@ -125,6 +125,10 @@ export const RAW_TOOLS = [
125
125
  })
126
126
  .optional()
127
127
  .describe("Present ONLY when `sessions` is empty: the same answer as `why`, executable. `command` is the LITERAL command to run, sourced from this project's own package.json scripts and lockfile — it is absent, never guessed, when the project declares no dev script. `action` is one of daemon_split | start_dev_server | run_init | open_app | reopen_app. `daemon_split` outranks the rest and means the app IS running and instrumented, on a DIFFERENT daemon than the one you are attached to — do not start or re-init anything, read `reason`."),
128
+ talk_to_us: z
129
+ .string()
130
+ .optional()
131
+ .describe('Present ONLY when `sessions` is empty: a call with the founder for the person you are working with. Offer it; never act on it.'),
128
132
  lastKnown: z
129
133
  .object({
130
134
  sessionId: z.string(),
@@ -158,6 +162,8 @@ export const RAW_TOOLS = [
158
162
  ...(why === undefined ? {} : { why }),
159
163
  ...(next === undefined ? {} : { next_action: next }),
160
164
  ...(undefined === lastKnown ? {} : { lastKnown }),
165
+ // Setup that never connects is where most people give up; it is the talk worth having.
166
+ talk_to_us: DiscoveryInvite.AGENT,
161
167
  };
162
168
  }
163
169
  return { sessions };
@@ -261,7 +267,7 @@ export const RAW_TOOLS = [
261
267
  scope: asString(args['scope']) ?? '',
262
268
  mode,
263
269
  diff: true === args['diff'],
264
- }, deps.snapshots ?? SNAPSHOT_CACHE), mode), mode), mode)));
270
+ }, deps.snapshots ?? SNAPSHOT_CACHE), mode), mode), mode, resolved)));
265
271
  },
266
272
  },
267
273
  {
@@ -290,10 +296,7 @@ export const RAW_TOOLS = [
290
296
  .boolean()
291
297
  .optional()
292
298
  .describe('Return the `scope` element ITSELF instead of searching inside it. Use when the target is a plain layout container with no role, name, testid or text of its own — which is routinely the element that carries the handler, and is otherwise unreachable because every query excludes its own scope root. Requires `scope`.'),
293
- attrs: z
294
- .array(z.string())
295
- .optional()
296
- .describe("Attribute names to return per match, e.g. ['href'] to inventory links or ['src'] for images. Absent attributes are omitted; credential-bearing names are redacted."),
299
+ attrs: AttrNamesSchema.optional().describe("Attribute NAMES to return per match, e.g. ['href'] for links, ['src'] for images. Projects, never filters: 'name=value' is refused. Absent omitted; credentials redacted."),
297
300
  limit: countSchema
298
301
  .optional()
299
302
  .describe('Cap the returned descriptors to the first N (cuts tokens on broad queries). If more matched, the result carries total + truncated:true so the trim is never silent — narrow with name/scope.'),
@@ -371,6 +374,12 @@ export const RAW_TOOLS = [
371
374
  .optional()
372
375
  .describe('Structural clusters on the page — what IS here, to diagnose the miss.'),
373
376
  presentTestids: z.array(z.string()).optional(),
377
+ // Declared for the same reason presentRegions had to be: undeclared here, it is stripped
378
+ // from structuredContent without a word — and this field exists precisely to say a word.
379
+ presentTestidsTotal: z
380
+ .number()
381
+ .optional()
382
+ .describe('Present only when presentTestids was cut at its cap: how many there were. The list is document order, so a region low on the page is what got dropped — absence from the list proves nothing.'),
374
383
  knownEmptyState: z.boolean(),
375
384
  splitText: z
376
385
  .object({
@@ -614,24 +623,31 @@ function noteEmptyLeanTree(result, mode) {
614
623
  * wrapper or two is ordinary, so this is deliberately not `=== 0`.
615
624
  */
616
625
  const UNMOUNTED_ROOT_MAX_ELEMENTS = 3;
617
- /**
618
- * The third cause of an empty tree, and the one the walk cannot see: the app is not mounted.
619
- *
620
- * Reported from the field with the react-three-fiber crash. The source-mapping stamp threw inside
621
- * R3F's commit phase, React unmounted the entire tree, and the page went white — and the snapshot
622
- * answered `{ tree: "", nodes: 0 }`, which is also what a page that has not rendered yet answers.
623
- * The reporter spent a diagnosis pass separating the two, and said that detecting a dead root would
624
- * have pointed straight at the cause.
625
- *
626
- * The other two notes here explain an empty tree by what the WALK passed over. This one cannot: a
627
- * walk that visited nothing has nothing to have skipped. It reads the DOM count instead, which is
628
- * why the browser now reports it, and it fires only when the skip counts are silent — a page whose
629
- * elements were all hidden has plenty of DOM and belongs to `noteHiddenPage`.
630
- *
631
- * Like both of its neighbours it names a fact and hands over the next read, and does not diagnose:
632
- * "not mounted" is certain from the count, WHY is not, and the console is where the answer is.
633
- */
634
- function noteUnmountedRoot(result, mode) {
626
+ function crashEvidence(session) {
627
+ const since = asRecord(session)['eventsSince'];
628
+ if ('function' !== typeof since)
629
+ return undefined;
630
+ let events;
631
+ try {
632
+ events = since.call(session, 0);
633
+ }
634
+ catch {
635
+ return undefined;
636
+ }
637
+ if (!Array.isArray(events))
638
+ return undefined;
639
+ const errors = events.filter((e) => {
640
+ const type = asString(asRecord(e)['type']);
641
+ return EventType.CONSOLE_ERROR === type || EventType.ERROR_UNCAUGHT === type;
642
+ });
643
+ if (0 === errors.length)
644
+ return undefined;
645
+ const first = errors
646
+ .map((e) => asString(asRecord(asRecord(e)['data'])['message']))
647
+ .find((m) => undefined !== m && '' !== m);
648
+ return { count: errors.length, first: first ?? '' };
649
+ }
650
+ function noteUnmountedRoot(result, mode, session) {
635
651
  if (SnapshotMode.STATUS === mode)
636
652
  return result;
637
653
  const row = asRecord(result);
@@ -642,12 +658,31 @@ function noteUnmountedRoot(result, mode) {
642
658
  const elements = asNumber(row['domElements']);
643
659
  if (elements === undefined || elements > UNMOUNTED_ROOT_MAX_ELEMENTS)
644
660
  return result;
661
+ const mount = `the tree is empty because there is almost nothing in the DOM: ${String(elements)} ` +
662
+ `element(s) under this scope. That is a mount container with nothing rendered into it, not a ` +
663
+ `page whose contents were skipped — so the app is UNMOUNTED rather than slow, and waiting ` +
664
+ `will not change it. `;
665
+ // An uncaught error beside an empty mount container is the difference between "has not started"
666
+ // and "started and died", which are the two readings of this count and want opposite next moves.
667
+ // Nothing here is framework-specific, and deliberately so: the condition is a known mount
668
+ // container that holds nothing with an uncaught error beside it, which is as true of a Vue or
669
+ // Svelte root as a React one.
670
+ const crash = crashEvidence(session);
671
+ if (crash !== undefined) {
672
+ return {
673
+ ...row,
674
+ note: mount +
675
+ `The app did not merely fail to start — it CRASHED: ${String(crash.count)} uncaught ` +
676
+ `error(s) were logged in this session` +
677
+ ('' === crash.first ? '' : `, the first: "${crash.first}"`) +
678
+ `. Read reticle_console for the rest, and check anything that runs inside a framework ` +
679
+ `commit phase. Loading the app again will reproduce it rather than fix it.`,
680
+ };
681
+ }
645
682
  return {
646
683
  ...row,
647
- note: `the tree is empty because there is almost nothing in the DOM: ${String(elements)} ` +
648
- `element(s) under this scope. That is a mount container with nothing rendered into it, not a ` +
649
- `page whose contents were skipped — so the app is UNMOUNTED rather than slow, and waiting ` +
650
- `will not change it. An app that was rendering and then stopped has usually thrown: read ` +
684
+ note: mount +
685
+ `An app that was rendering and then stopped has usually thrown: read ` +
651
686
  `reticle_console for an uncaught error, and check anything that runs inside a framework ` +
652
687
  `commit phase. If the app has genuinely not started yet, load it and snapshot again.`,
653
688
  };
@@ -1,4 +1,5 @@
1
1
  import type { Feedback } from '@reticlehq/core/telemetry';
2
+ /** Reports live beside the rest of the workspace, not in a temp dir somebody has to be told about. */
2
3
  interface SavedFeedback {
3
4
  path: string;
4
5
  /** A ready-to-run command that turns the file into an issue. */
@@ -13,8 +13,6 @@
13
13
  import { mkdirSync, writeFileSync } from 'node:fs';
14
14
  import { join } from 'node:path';
15
15
  import { ReticleDir } from '@reticlehq/core';
16
- /** Reports live beside the rest of the workspace, not in a temp dir somebody has to be told about. */
17
- const FEEDBACK_SUBDIR = 'feedback';
18
16
  /** Filesystem-safe, sortable, and unique enough for one report per second per kind. */
19
17
  const stamp = (now) => new Date(now).toISOString().replace(/[:.]/g, '-').replace('Z', '');
20
18
  const asMarkdown = (input, text, context) => {
@@ -39,7 +37,7 @@ const asMarkdown = (input, text, context) => {
39
37
  */
40
38
  export function saveFeedbackLocally(cwd, input, text, context, now = Date.now()) {
41
39
  try {
42
- const dir = join(cwd, ReticleDir.ROOT, FEEDBACK_SUBDIR);
40
+ const dir = join(cwd, ReticleDir.ROOT, ReticleDir.FEEDBACK_SUBDIR);
43
41
  mkdirSync(dir, { recursive: true });
44
42
  const path = join(dir, `${stamp(now)}-${input.kind ?? 'note'}.md`);
45
43
  writeFileSync(path, asMarkdown(input, text, context), 'utf8');
@@ -27,7 +27,7 @@ import { type SessionFacts } from './feedback-context.js';
27
27
  export declare const FEEDBACK_ENV = "RETICLE_FEEDBACK";
28
28
  /** What replaces a redacted match. Visible on purpose: a reader must see that something was removed. */
29
29
  export declare const REDACTED = "[redacted]";
30
- export interface Redaction {
30
+ interface Redaction {
31
31
  text: string;
32
32
  /** Which rules fired, deduped. Surfaced back to the author so a redaction is never silent. */
33
33
  removed: string[];
@@ -0,0 +1,32 @@
1
+ import { HudPanel, HudView, type HudUseData } from '@reticlehq/core';
2
+ /**
3
+ * How people use the in-page HUD, rolled into the session summary.
4
+ *
5
+ * Windowed like the tool histograms beside it: a periodic flush reports what happened since the
6
+ * last one, so a dashboard sums across a session's events. Names only — every key is a control id
7
+ * the wire schema already checked against core's closed list.
8
+ */
9
+ /** How many steps of the journey one window keeps. A journey longer than this is cut, and says so. */
10
+ export declare const HUD_JOURNEY_MAX = 60;
11
+ export interface HudSummary {
12
+ /** Presses per control; a toggle counts under `id:on` / `id:off` so a switch reads both ways. */
13
+ hudControls?: Record<string, number>;
14
+ /** Milliseconds the HUD spent in each view during the window. */
15
+ hudViewMs?: Partial<Record<HudView, number>>;
16
+ /** Milliseconds each panel was showing during the window. */
17
+ hudPanelMs?: Partial<Record<HudPanel, number>>;
18
+ /** The window's presses and view changes, in order, cut at HUD_JOURNEY_MAX. */
19
+ hudJourney?: string[];
20
+ /** Present, and true, only when the journey was cut. */
21
+ hudJourneyCut?: true;
22
+ }
23
+ export declare class HudMetrics {
24
+ #private;
25
+ constructor(now: () => number);
26
+ record(use: HudUseData): void;
27
+ get empty(): boolean;
28
+ /** The window so far. Open view and panel are counted up to now, and keep running after. */
29
+ summarize(): HudSummary;
30
+ /** Start the next window. The current view and panel carry over, timed from now. */
31
+ reset(): void;
32
+ }