@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
@@ -1,11 +1,46 @@
1
1
  import { access, appendFile, mkdir, open, readdir, readFile, realpath, rename, rm, stat, writeFile, } from 'node:fs/promises';
2
2
  /** The JSONL record terminator, as a byte — the only boundary a capped tail read can align to. */
3
3
  const NEWLINE_BYTE = 0x0a;
4
+ /**
5
+ * The largest single `read(2)` this port will ask for: 2^31 - 1, the int32 ceiling.
6
+ *
7
+ * Past it `fs.read` does not throw — it ABORTS the process on a V8 assertion
8
+ * (`node::fs::Read ... Assertion failed: args[3]->IsInt32()`, where args[3] is the length). That is
9
+ * not catchable, so one oversized journal takes the daemon down and with it every session on the
10
+ * machine, including unrelated ones. Reported from the field exactly that way.
11
+ *
12
+ * Reachable in practice because `Buffer.allocUnsafe` happily allocates well past this, so a
13
+ * `size - start` over 2 GiB allocates fine and then kills node at the syscall. A `.reticle/sessions`
14
+ * has been observed in the multi-gigabyte range, so "no journal is ever that big" is not a bound
15
+ * anybody is enforcing.
16
+ *
17
+ * Clamping rather than throwing is deliberate: a caller reading a tail wants the newest records, and
18
+ * returning the last 2 GiB of them is a better answer than an error. The boundary realignment below
19
+ * already handles a window that opens mid-record, which is exactly what a clamp produces.
20
+ */
21
+ const MAX_SINGLE_READ_BYTES = 2_147_483_647;
22
+ /**
23
+ * Refuse an offset that cannot survive the arithmetic below.
24
+ *
25
+ * `length = size - start`, so a non-integer or negative offset produces a non-integer or negative
26
+ * length, and those fail in two different ways one layer apart: `Buffer.allocUnsafe` throws a
27
+ * RangeError for a fractional length, while an oversized one reaches `fs.read` and aborts the
28
+ * process. Checking here means the caller gets one predictable rejection instead of either.
29
+ */
30
+ function requireByteCount(label, value) {
31
+ if (value === undefined)
32
+ return;
33
+ if (!Number.isSafeInteger(value) || value < 0) {
34
+ throw new RangeError(`${label} must be a non-negative safe integer, got ${String(value)} — a fractional or negative byte count cannot reach a read syscall safely`);
35
+ }
36
+ }
4
37
  /** Production adapter — the single import site of node:fs/promises in the server. */
5
38
  export function createNodeFileSystem() {
6
39
  return {
7
40
  readFile: (path) => readFile(path, 'utf8'),
8
41
  readFileFrom: async (path, byteOffset, maxBytes) => {
42
+ requireByteCount('byteOffset', byteOffset);
43
+ requireByteCount('maxBytes', maxBytes);
9
44
  const fh = await open(path, 'r');
10
45
  try {
11
46
  const { size } = await fh.stat();
@@ -18,14 +53,18 @@ export function createNodeFileSystem() {
18
53
  // One byte BEFORE that cut, so a cut landing exactly on a record boundary is recognised as
19
54
  // one instead of sacrificing the record after it.
20
55
  const start = capped ? cut - 1 : byteOffset;
21
- const length = size - start;
56
+ // Clamped to the int32 ceiling: past it `fs.read` aborts the process rather than throwing.
57
+ // Keep the NEWEST bytes, matching what every caller of a tail read is asking for.
58
+ const want = size - start;
59
+ const length = Math.min(want, MAX_SINGLE_READ_BYTES);
60
+ const readFrom = start + (want - length);
22
61
  const buf = Buffer.allocUnsafe(length);
23
- const { bytesRead } = await fh.read(buf, 0, length, start);
62
+ const { bytesRead } = await fh.read(buf, 0, length, readFrom);
24
63
  // ONLY what the read returned. `allocUnsafe` hands back whatever was last in that heap
25
64
  // block, and `fstat` runs one syscall ahead of `read` — so a file truncated in between, or
26
65
  // any short read, would otherwise decode that memory and hand it back as journal text.
27
66
  const got = buf.subarray(0, bytesRead);
28
- if (!capped)
67
+ if (!capped && readFrom === start)
29
68
  return { text: got.toString('utf8'), size, from: start };
30
69
  // The ceiling moved the start, so the window may open inside a record whose head was never
31
70
  // read. Drop up to the first boundary in BYTES, before decoding: `\n` is one byte and
@@ -35,11 +74,11 @@ export function createNodeFileSystem() {
35
74
  // No boundary in the whole window: there is no record here that can be read, and half of
36
75
  // one is worse than none. `from` still says how far the read reached.
37
76
  if (-1 === newline)
38
- return { text: '', size, from: start + bytesRead };
77
+ return { text: '', size, from: readFrom + bytesRead };
39
78
  return {
40
79
  text: got.subarray(newline + 1).toString('utf8'),
41
80
  size,
42
- from: start + newline + 1,
81
+ from: readFrom + newline + 1,
43
82
  };
44
83
  }
45
84
  finally {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Write a file so a reader never sees a half-written one, and two writers never see each other's.
3
+ *
4
+ * Write to a temporary sibling, then rename. The rename is what makes it atomic: on a POSIX
5
+ * filesystem it either replaces the destination entirely or does not happen, so a reader gets the
6
+ * old file or the new one and never a truncated middle.
7
+ *
8
+ * TWO properties, and the second is the one that was missing. Five stores in this package built
9
+ * this by hand and named the temp file after the DESTINATION — `${path}.tmp` — which is shared by
10
+ * every process writing that path. Two daemons on one repository then write the same temp file:
11
+ * one truncates the other mid-write and renames the remains into place, and the survivor is a file
12
+ * neither of them produced. Two other stores already pid-suffixed theirs, so the fix was known and
13
+ * had simply never been carried to the rest.
14
+ *
15
+ * Running parallel agent sessions against one checkout is the ordinary way to reach this, not an
16
+ * exotic one.
17
+ *
18
+ * The temp file stays BESIDE its destination rather than in the system temp directory, because a
19
+ * rename across devices is not atomic and `/tmp` is routinely a different device.
20
+ *
21
+ * One helper rather than five corrected copies: the sixth store gets this for free and cannot get
22
+ * it wrong, which is the only version of this fix that stays fixed.
23
+ */
24
+ import type { FileSystemPort } from './fs-port.js';
25
+ export declare function tempNameFor(path: string, pid?: number, seq?: number): string;
26
+ export declare function writeFileAtomic(fs: FileSystemPort, path: string, contents: string): Promise<void>;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Unique per WRITE, not per process.
3
+ *
4
+ * The pid separates two daemons. It does not separate two concurrent writes inside ONE daemon —
5
+ * `Promise.all` over two store saves, or two scheduled flushes of the same ledger — and those share
6
+ * a pid, so a pid-only name puts them straight back on the same temp file. The first version of
7
+ * this helper had exactly that hole and its own concurrency test found it.
8
+ *
9
+ * A counter rather than `Math.random()`: rule 7 keeps the clock and the random source out of pure
10
+ * logic, and a monotonic counter is deterministic, collision-free within a process, and needs
11
+ * neither.
12
+ */
13
+ let writeSeq = 0;
14
+ export function tempNameFor(path, pid = process.pid, seq) {
15
+ writeSeq += 1;
16
+ return `${path}.${String(pid)}.${String(seq ?? writeSeq)}.tmp`;
17
+ }
18
+ /**
19
+ * What Windows answers, for a moment, when another writer is still replacing the destination.
20
+ * POSIX never refuses such a rename; Windows does, and the save has to wait it out rather than
21
+ * fail. Seen on Windows CI with two writers on one path.
22
+ */
23
+ const TRANSIENT_RENAME = new Set(['EPERM', 'EBUSY', 'EACCES']);
24
+ const RENAME_ATTEMPTS = 6;
25
+ const RENAME_BACKOFF_MS = 15;
26
+ function isTransientRename(error) {
27
+ const code = error?.code;
28
+ return 'string' === typeof code && TRANSIENT_RENAME.has(code);
29
+ }
30
+ export async function writeFileAtomic(fs, path, contents) {
31
+ const tmp = tempNameFor(path);
32
+ await fs.writeFile(tmp, contents);
33
+ for (let attempt = 1;; attempt += 1) {
34
+ try {
35
+ await fs.rename(tmp, path);
36
+ return;
37
+ }
38
+ catch (error) {
39
+ if (attempt < RENAME_ATTEMPTS && isTransientRename(error)) {
40
+ await new Promise((resolve) => setTimeout(resolve, RENAME_BACKOFF_MS * attempt));
41
+ continue;
42
+ }
43
+ // The write failed; its temp file must not outlive it.
44
+ await fs.rm(tmp).catch(() => undefined);
45
+ throw error;
46
+ }
47
+ }
48
+ }
@@ -63,3 +63,9 @@ export declare function hasAnyAppConnectedBefore(stateDir: string, port: number,
63
63
  * diagnosis, which is why it now shares this function instead of keeping a second copy of the rule.
64
64
  */
65
65
  export declare function hasProjectConnectedBefore(stateDir: string, port: number, projectId: string | undefined): boolean;
66
+ /**
67
+ * Has any of these projects connected before: the first-move question for a directory that speaks
68
+ * for more than one app (a monorepo root), or for none by name (an app wired by the plugin alone).
69
+ * The ids come from `projectIdsAt`; each is still asked the narrow question.
70
+ */
71
+ export declare function hasAnyProjectConnectedBefore(stateDir: string, port: number, projectIds: readonly string[]): boolean;
@@ -112,3 +112,11 @@ export function hasProjectConnectedBefore(stateDir, port, projectId) {
112
112
  return false;
113
113
  return hasAnyAppConnectedBefore(stateDir, port, projectId);
114
114
  }
115
+ /**
116
+ * Has any of these projects connected before: the first-move question for a directory that speaks
117
+ * for more than one app (a monorepo root), or for none by name (an app wired by the plugin alone).
118
+ * The ids come from `projectIdsAt`; each is still asked the narrow question.
119
+ */
120
+ export function hasAnyProjectConnectedBefore(stateDir, port, projectIds) {
121
+ return projectIds.some((id) => hasProjectConnectedBefore(stateDir, port, id));
122
+ }
@@ -1,5 +1,5 @@
1
1
  /** One project this machine has paired before, in the shape the no-session facts already carry. */
2
- export interface RegisteredProject {
2
+ interface RegisteredProject {
3
3
  directory: string;
4
4
  projectId?: string;
5
5
  }
@@ -52,3 +52,4 @@ export declare function isUnscopedRoot(directory: string, home: string): boolean
52
52
  * reporting it as such would tell a correctly-scoped daemon to go and look somewhere else.
53
53
  */
54
54
  export declare function registeredElsewhere(home: string, directory: string): RegisteredProject[];
55
+ export {};
@@ -28,8 +28,11 @@ const PLAIN = 'authentication failed';
28
28
  * before `~/.reticle` was replaced. Both need the same first move: ask `status`, which can say which.
29
29
  */
30
30
  const WRONG_TOKEN = 'authentication failed: wrong pairing token — run `reticle status` for the cause';
31
- /** A paste-in snippet or a Next config that never saw a token. Reload cannot mint one. */
32
- const NO_TOKEN = 'authentication failed: no pairing token on the page';
31
+ /**
32
+ * A paste-in snippet or a Next config that never saw a token. Reload cannot mint one; restarting the
33
+ * dev server re-runs the substitution that should have put it there (#1047).
34
+ */
35
+ const NO_TOKEN = 'authentication failed: no pairing token on the page; the build did not substitute it, so restart the dev server';
33
36
  /** WebSocket close reasons are capped at 123 bytes; a longer one throws and closes with nothing. */
34
37
  const MAX_REASON_BYTES = 123;
35
38
  const DIFFERENT_PROJECT_PREFIX = 'this daemon serves a different project';
@@ -7,6 +7,7 @@ import { classifyConnectFailure } from '../../telemetry/connect-failure.js';
7
7
  import { ActionType, DriveErrorCode, DRIVE_PLAYWRIGHT_MISSING_MSG } from '@reticlehq/core';
8
8
  import { installNetworkMocks } from './network-mock.js';
9
9
  import { attachNetworkDetail } from './network-detail.js';
10
+ import { injectedConnectArgs } from '../pool/zero-install.js';
10
11
  /** Structured, code-tagged failure so callers branch on cause, not message text. */
11
12
  export class DriveError extends Error {
12
13
  code;
@@ -338,7 +339,7 @@ export class LaunchedRealInputProvider {
338
339
  await page.waitForFunction('!!globalThis.__reticleInstance', {
339
340
  timeout: INJECT_CONNECT_WAIT_MS,
340
341
  });
341
- const arg = JSON.stringify({ allowNonLocalhost: true, token: opts.token, url: opts.url });
342
+ const arg = JSON.stringify(injectedConnectArgs(opts));
342
343
  await page.evaluate(`globalThis.__reticleInstance.connect(${arg})`);
343
344
  }
344
345
  catch {
@@ -18,6 +18,11 @@ export interface Lease {
18
18
  readonly url: string;
19
19
  readonly navStatus?: number;
20
20
  release(): Promise<void>;
21
+ /**
22
+ * Hand the page the zero-install reader: evaluated in the live document, and added ahead of every
23
+ * document this lease loads next. False when there is no reader, or the page cannot run one.
24
+ */
25
+ injectReader?(): Promise<boolean>;
21
26
  }
22
27
  /** Default lease time-to-live: a lease untouched for this long is presumed orphaned and reclaimed. */
23
28
  export declare const DEFAULT_LEASE_TTL_MS: number;
@@ -32,6 +37,11 @@ interface BrowserPoolOptions {
32
37
  leaseTtlMs?: number;
33
38
  /** Per-lease navigation timeout — a page that won't load fails its own lease, never blocks a slot. */
34
39
  navTimeoutMs?: number;
40
+ /**
41
+ * The zero-install reader, built where the daemon knows its own port and pairing token. A thunk so
42
+ * the ~450KB build is only read if some page ever needs it; undefined when there is none to give.
43
+ */
44
+ zeroInstallScript?: () => string | undefined;
35
45
  }
36
46
  /**
37
47
  * Owns a single browser and leases isolated contexts out of it, capped at `maxContexts`.
@@ -30,6 +30,7 @@ export class BrowserPool {
30
30
  #genId;
31
31
  #now;
32
32
  #ttl;
33
+ #zeroInstall;
33
34
  #navTimeout;
34
35
  #browser;
35
36
  #launching;
@@ -70,6 +71,7 @@ export class BrowserPool {
70
71
  this.#now = opts.now ?? (() => Date.now());
71
72
  this.#ttl = opts.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
72
73
  this.#navTimeout = opts.navTimeoutMs ?? DEFAULT_NAV_TIMEOUT_MS;
74
+ this.#zeroInstall = opts.zeroInstallScript;
73
75
  }
74
76
  /** The TTL configured for leases — how long an untouched lease lives before the reaper reclaims it. */
75
77
  leaseTtlMs() {
@@ -405,6 +407,14 @@ export class BrowserPool {
405
407
  url,
406
408
  ...(navStatus !== undefined ? { navStatus } : {}),
407
409
  release: () => this.#release(sessionId),
410
+ injectReader: async () => {
411
+ const script = this.#zeroInstall?.();
412
+ if (script === undefined || page.evaluate === undefined)
413
+ return false;
414
+ await page.addInitScript?.(script);
415
+ await page.evaluate(script);
416
+ return true;
417
+ },
408
418
  };
409
419
  }
410
420
  catch (err) {
@@ -41,6 +41,7 @@ function wrapBrowser(browser) {
41
41
  return {
42
42
  goto: (url, opts) => page.goto(url, gotoOptions(opts?.timeoutMs)),
43
43
  close: () => page.close(),
44
+ evaluate: (script) => page.evaluate(script),
44
45
  // Playwright returns a Buffer; Uint8Array is what the visual store and differ take.
45
46
  screenshot: async (opts) => new Uint8Array(await page.screenshot({ fullPage: true === opts?.fullPage })),
46
47
  // Three moves, same as performGesture: a single move to the center can be a no-op if
@@ -34,6 +34,8 @@ export interface PooledPage {
34
34
  * reports dispatched/settled while the styles never ran.
35
35
  */
36
36
  hover?(x: number, y: number): Promise<void>;
37
+ /** Evaluate a script in the page's current document. OPTIONAL: without it no reader is injected. */
38
+ evaluate?(script: string): Promise<unknown>;
37
39
  /** Add an init script to run after document creation but before any page scripts run. */
38
40
  addInitScript?<Arg>(script: ((arg: Arg) => void) | string, arg?: Arg): Promise<InitScriptHandle | void>;
39
41
  /**
@@ -2,7 +2,7 @@ import { type SeedStorage } from '@reticlehq/core';
2
2
  import type { InitScriptHandle, PooledContext, PooledCookie, PooledPage } from './pool-contract.js';
3
3
  export declare function targetOriginOf(navUrl: string): string | undefined;
4
4
  export declare function normalizeCookies(cookies: SeedStorage['cookies'], navUrl: string): PooledCookie[];
5
- export interface StorageSeedResult {
5
+ interface StorageSeedResult {
6
6
  handle?: InitScriptHandle;
7
7
  checkError?: () => void;
8
8
  }
@@ -21,3 +21,4 @@ export interface StorageSeedResult {
21
21
  * has resolved, ensuring one-time semantics (reloads or future navigations do not reapply the seed).
22
22
  */
23
23
  export declare function seedStorageInto(context: PooledContext, page: PooledPage, targetUrl: string, seed: SeedStorage): Promise<StorageSeedResult | void>;
24
+ export {};
@@ -0,0 +1,27 @@
1
+ export interface InjectedConnect {
2
+ url: string;
3
+ /** Absent when this daemon runs without a pairing token; the bridge then asks for none. */
4
+ token?: string;
5
+ }
6
+ /**
7
+ * The connect options a page is given when WE supply the connection rather than the app.
8
+ *
9
+ * One builder for both injection paths — this one, and re-pointing an SDK a hosted preview already
10
+ * ships (`real-input.ts`) — so the two cannot come to disagree about what a daemon-driven page needs.
11
+ */
12
+ export declare function injectedConnectArgs(opts: InjectedConnect): Record<string, unknown>;
13
+ /**
14
+ * The script for a page: install the SDK if the page has none, then connect it to this daemon.
15
+ *
16
+ * The guard is on the SINGLETON, not on a timer: a page that loaded its own SDK already holds
17
+ * `__reticleInstance`, and evaluating a second copy would register a second session for one tab.
18
+ * `connect` is a no-op once connected, so calling it on the page's own instance is safe.
19
+ */
20
+ export declare function zeroInstallScript(bundle: string, opts: InjectedConnect): string;
21
+ /**
22
+ * The single-file build, or undefined when this install has none.
23
+ *
24
+ * Read once and kept: it is ~450KB and identical for every lease. Absent means an older browser
25
+ * package or a broken install, and the caller then behaves exactly as it did before this existed.
26
+ */
27
+ export declare function readReaderBundle(): string | undefined;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The zero-install reader: how a page whose app never ran `init` still reaches a verdict.
3
+ *
4
+ * `@reticlehq/browser` builds itself into one file (`dist/reticle-inject.js`). The daemon reads that
5
+ * file as TEXT and evaluates it in a leased page that never connected, so the server never imports DOM
6
+ * code and the page needs no build step. What comes back is an ordinary session that reports no
7
+ * framework adapter and no source mapping, which is the honest description of what it can see.
8
+ */
9
+ import { readFileSync } from 'node:fs';
10
+ import { createRequire } from 'node:module';
11
+ /** The subpath `@reticlehq/browser` exports its single-file build under. */
12
+ const READER_SPECIFIER = '@reticlehq/browser/inject';
13
+ /**
14
+ * The connect options a page is given when WE supply the connection rather than the app.
15
+ *
16
+ * One builder for both injection paths — this one, and re-pointing an SDK a hosted preview already
17
+ * ships (`real-input.ts`) — so the two cannot come to disagree about what a daemon-driven page needs.
18
+ */
19
+ export function injectedConnectArgs(opts) {
20
+ return {
21
+ allowNonLocalhost: true,
22
+ ...(opts.token === undefined ? {} : { token: opts.token }),
23
+ url: opts.url,
24
+ };
25
+ }
26
+ /**
27
+ * The script for a page: install the SDK if the page has none, then connect it to this daemon.
28
+ *
29
+ * The guard is on the SINGLETON, not on a timer: a page that loaded its own SDK already holds
30
+ * `__reticleInstance`, and evaluating a second copy would register a second session for one tab.
31
+ * `connect` is a no-op once connected, so calling it on the page's own instance is safe.
32
+ */
33
+ export function zeroInstallScript(bundle, opts) {
34
+ const args = JSON.stringify(injectedConnectArgs(opts));
35
+ return `(() => {\nif (!globalThis.__reticleInstance) {\n${bundle}\n}\nglobalThis.__reticleInstance.connect(${args});\n})();`;
36
+ }
37
+ let cached;
38
+ /**
39
+ * The single-file build, or undefined when this install has none.
40
+ *
41
+ * Read once and kept: it is ~450KB and identical for every lease. Absent means an older browser
42
+ * package or a broken install, and the caller then behaves exactly as it did before this existed.
43
+ */
44
+ export function readReaderBundle() {
45
+ if (cached === undefined) {
46
+ try {
47
+ cached = readFileSync(createRequire(import.meta.url).resolve(READER_SPECIFIER), 'utf8');
48
+ }
49
+ catch {
50
+ cached = null;
51
+ }
52
+ }
53
+ return cached ?? undefined;
54
+ }
@@ -93,6 +93,11 @@ export const CHANNEL_OF_PREFIX = {
93
93
  // channel at all.
94
94
  anim: ProtocolChannel.UI,
95
95
  dialog: ProtocolChannel.UI,
96
+ // A form field's value is on the subject's surface in exactly the sense `ui` means: it is what
97
+ // the person is looking at and typing into. The protocol has no `input` channel and does not
98
+ // want one — a field is a rendered thing, and the observation carries the field's NAME and
99
+ // length, never its contents for a password or a card.
100
+ field: ProtocolChannel.UI,
96
101
  focus: ProtocolChannel.UI,
97
102
  render: ProtocolChannel.UI,
98
103
  reveal: ProtocolChannel.UI,
@@ -124,6 +129,7 @@ export const NOT_EVIDENCE = {
124
129
  context: "Reticle's own context tool opening, not something the application did",
125
130
  flow: 'a flow being recorded by the tool',
126
131
  human: 'a person driving the HUD: pause, resume, a mark. The operator, not the subject',
132
+ hud: "usage telemetry about Reticle's own panel, taken off before the buffer. The tool, not the subject",
127
133
  page: "the SDK's own health report about the page it is inside",
128
134
  sdk: 'the SDK itself failing, which is a fact about the observer',
129
135
  transport: "the tool's own buffer overflowing",
@@ -25,7 +25,7 @@ export declare const PortState: {
25
25
  /** Answered with a document: a dev server serving an app. */
26
26
  readonly SERVES_DOCUMENT: "serves-document";
27
27
  };
28
- export type PortState = (typeof PortState)[keyof typeof PortState];
28
+ type PortState = (typeof PortState)[keyof typeof PortState];
29
29
  /** The probe's answer for one port, however it was obtained. */
30
30
  export declare function classifyPort(port: number, probe: (port: number) => Promise<PortState>): Promise<PortState>;
31
31
  /**
@@ -71,3 +71,4 @@ export declare function probeDevServerStates(ports?: readonly number[], probe?:
71
71
  }>;
72
72
  /** Every candidate port serving a document, in ascending order. Never rejects. */
73
73
  export declare function probeDevServers(ports?: readonly number[], probe?: (port: number) => Promise<boolean>): Promise<number[]>;
74
+ export {};
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The document a local dev server actually serves: its CSP header and HTML, read once and failing
3
+ * soft. Shared by `doctor` (is a policy blocking the bridge?) and a lease that never dialled (was
4
+ * it the page's own policy?), which is why it lives beside the dev-server probe and not in either.
5
+ */
6
+ /** What a served page said about its policy. The same shape `diagnoseObservedWebCsp` reads. */
7
+ export interface ObservedWebDocument {
8
+ url: string;
9
+ headers: string[];
10
+ html: string;
11
+ }
12
+ export type PageRequest = (url: string, init: RequestInit) => Promise<Response>;
13
+ interface ConnectedDocument {
14
+ url: string;
15
+ projectId?: string;
16
+ }
17
+ /** A connected tab for this project, or on its observed origin, disproves a CSP diagnosis. */
18
+ export declare function hasConnectedDocument(sessions: readonly ConnectedDocument[], documentUrls: readonly string[], projectId?: string): boolean;
19
+ /** Read the document a live local dev server actually sends, failing soft like the rest of doctor. */
20
+ export declare function observeWebDocument(rawUrl: string, request?: PageRequest): Promise<ObservedWebDocument | undefined>;
21
+ export {};
@@ -0,0 +1,57 @@
1
+ const CSP_HEADER = 'content-security-policy';
2
+ const DOCUMENT_TIMEOUT_MS = 1_000;
3
+ const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]'];
4
+ const requestPage = (url, init) => fetch(url, init);
5
+ function loopbackDocumentUrl(raw) {
6
+ try {
7
+ const url = new URL(raw);
8
+ if (!['http:', 'https:'].includes(url.protocol))
9
+ return undefined;
10
+ return LOOPBACK_HOSTS.includes(url.hostname) ? url : undefined;
11
+ }
12
+ catch {
13
+ return undefined;
14
+ }
15
+ }
16
+ function originOf(raw) {
17
+ try {
18
+ return new URL(raw).origin;
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ }
24
+ /** A connected tab for this project, or on its observed origin, disproves a CSP diagnosis. */
25
+ export function hasConnectedDocument(sessions, documentUrls, projectId) {
26
+ if (projectId !== undefined && sessions.some((session) => session.projectId === projectId)) {
27
+ return true;
28
+ }
29
+ const origins = new Set(documentUrls.map(originOf).filter((value) => value !== undefined));
30
+ return sessions.some((session) => {
31
+ const origin = originOf(session.url);
32
+ return undefined !== origin && origins.has(origin);
33
+ });
34
+ }
35
+ /** Read the document a live local dev server actually sends, failing soft like the rest of doctor. */
36
+ export async function observeWebDocument(rawUrl, request = requestPage) {
37
+ const url = loopbackDocumentUrl(rawUrl);
38
+ if (url === undefined)
39
+ return undefined;
40
+ try {
41
+ const response = await request(url.href, {
42
+ redirect: 'error',
43
+ signal: AbortSignal.timeout(DOCUMENT_TIMEOUT_MS),
44
+ });
45
+ if (!response.ok)
46
+ return undefined;
47
+ const policy = response.headers.get(CSP_HEADER);
48
+ return {
49
+ url: url.href,
50
+ headers: null === policy ? [] : [policy],
51
+ html: await response.text(),
52
+ };
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Read `background` out of an already-parsed `.reticle.json`: the same-origin endpoints the app calls
3
+ * on its own (telemetry, heartbeats), which must not hold a settle window open or contradict a claim.
4
+ *
5
+ * Takes the record rather than a path, like `readRetainPolicy`, because the config search belongs to
6
+ * the startup code that already does it once. Never throws, and drops a bad entry rather than the
7
+ * whole list.
8
+ */
9
+ export declare function readBackgroundTraffic(config: Record<string, unknown> | undefined): string[];
@@ -0,0 +1,25 @@
1
+ /** The key a project writes in `.reticle.json`. */
2
+ const BACKGROUND_KEY = 'background';
3
+ /**
4
+ * Shorter than this names no endpoint. `/` and `//` are refused on this rule, and so is anything a
5
+ * mistype could leave behind.
6
+ *
7
+ * ponytail: a length floor, not a proof. `/api` passes and would still exclude the whole backend;
8
+ * the entries a verdict ignored are reported on it, which is what keeps a too-broad pattern visible.
9
+ */
10
+ const MIN_PATTERN_LENGTH = 3;
11
+ function isEndpointPattern(value) {
12
+ return 'string' === typeof value && value.replace(/\//g, '').length >= MIN_PATTERN_LENGTH - 1;
13
+ }
14
+ /**
15
+ * Read `background` out of an already-parsed `.reticle.json`: the same-origin endpoints the app calls
16
+ * on its own (telemetry, heartbeats), which must not hold a settle window open or contradict a claim.
17
+ *
18
+ * Takes the record rather than a path, like `readRetainPolicy`, because the config search belongs to
19
+ * the startup code that already does it once. Never throws, and drops a bad entry rather than the
20
+ * whole list.
21
+ */
22
+ export function readBackgroundTraffic(config) {
23
+ const raw = config?.[BACKGROUND_KEY];
24
+ return Array.isArray(raw) ? raw.filter(isEndpointPattern) : [];
25
+ }
@@ -4,6 +4,9 @@ import { AGENT_ASK_NOTICE, AGENT_WAITING_NOTICE, PresenterTone, SessionState, YI
4
4
  import { ReticleTool } from '@reticlehq/core';
5
5
  import { sessionIdShape } from '../../surface/tools/tool-kit.js';
6
6
  import { asString } from '@reticlehq/core';
7
+ import { gapReportLines } from '../../judgement/runs/artifact/gap-report.js';
8
+ import { gapSummary } from '../../judgement/runs/artifact/gap-summary.js';
9
+ import { DiscoveryInvite } from '@reticlehq/core';
7
10
  /**
8
11
  * Is this a turn ending with nothing attached, rather than a call about a specific tab?
9
12
  *
@@ -44,16 +47,58 @@ export const LIVE_CONTROL_TOOLS = [
44
47
  ended: z.boolean(),
45
48
  sessionId: z.string().optional(),
46
49
  note: z.string().optional(),
50
+ talk_to_us: z
51
+ .string()
52
+ .optional()
53
+ .describe('For the person you are working with: a call with the founder. Offer it; never act on it.'),
54
+ gap: z
55
+ .array(z.string())
56
+ .optional()
57
+ .describe('What this session claimed and what held — an `unknown` is never counted as held.'),
47
58
  },
48
59
  handler: (deps, args) => {
49
60
  const requested = asString(args['sessionId']);
50
61
  if (endingTurnWithNothingAttached(deps, requested)) {
51
62
  return Promise.resolve({ ended: true, note: YIELD_WITHOUT_SESSION_NOTE });
52
63
  }
64
+ // A NAMED session that has DEPARTED is already ended, and nothing else may be ended in its
65
+ // place.
66
+ //
67
+ // `resolve` answers a departed id with its live SUCCESSOR. That is right for everything
68
+ // that observes -- a tab reloads, its id survives in sessionStorage, and an `assert` should
69
+ // keep working -- and wrong here, because ending a session is destructive and cannot be
70
+ // taken back. Reported from the field: `sessionId: "sac14d46c…"` returned `ended: true` for
71
+ // `"s08e37e5c…"`, once where the requested session's browser had just closed, so the
72
+ // behaviour was "target missing => end something else" rather than "target missing => say
73
+ // so" (#983).
74
+ //
75
+ // Not an error, either: this tool is documented as idempotent, and "the session you named is
76
+ // already gone" is precisely the idempotent answer. An id the daemon has never seen IS a
77
+ // mistake about which tab, and falls through to `resolve`, which refuses it with the live
78
+ // ids named.
79
+ if (requested !== undefined &&
80
+ deps.sessions.get(requested) === undefined &&
81
+ deps.sessions.departed(requested) !== undefined) {
82
+ return Promise.resolve({
83
+ ended: true,
84
+ sessionId: requested,
85
+ note: `session '${requested}' had already disconnected, so there was nothing to end. ` +
86
+ 'Nothing else was ended: a named session is matched exactly, never substituted.',
87
+ });
88
+ }
89
+ // Resolved synchronously, as before: a named session that does not exist still throws here
90
+ // rather than becoming a rejected promise the caller did not expect.
53
91
  const session = deps.sessions.resolve(requested);
54
- // One PRESENTER push for the transition; the optional summary rides the same push.
55
- session.setState(SessionState.ENDED, asString(args['summary']));
56
- return Promise.resolve({ ended: true, sessionId: session.id });
92
+ const summary = asString(args['summary']);
93
+ return session.readJournalActions().then((actions) => {
94
+ // The moment the agent calls the task complete is when "and what did not hold" is worth a
95
+ // line: the same fold `reticle_context` and `reticle report` serve, so they cannot disagree.
96
+ const gap = gapReportLines(gapSummary(actions));
97
+ const panel = [summary, gap[0]].filter((line) => line !== undefined);
98
+ // One PRESENTER push for the transition; the summary and the gap headline ride together.
99
+ session.setState(SessionState.ENDED, 0 === panel.length ? undefined : panel.join('\n'));
100
+ return { ended: true, sessionId: session.id, gap, talk_to_us: DiscoveryInvite.AGENT };
101
+ });
57
102
  },
58
103
  },
59
104
  {