@reticlehq/server 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/SKILL.md +17 -18
  2. package/dist/command/cli/auth/device-flow.d.ts +2 -2
  3. package/dist/command/cli/cli-doctor.js +13 -1
  4. package/dist/command/cli/cli-flow-commands.d.ts +2 -1
  5. package/dist/command/cli/cli-kill.js +1 -1
  6. package/dist/command/cli/cli-parse.d.ts +2 -1
  7. package/dist/command/cli/cli-parse.js +5 -2
  8. package/dist/command/cli/cli-verify.js +2 -2
  9. package/dist/command/cli/cloud-cli.js +7 -6
  10. package/dist/command/cli/cloud-kit.js +2 -6
  11. package/dist/command/cli/config/config-discovery.d.ts +12 -0
  12. package/dist/command/cli/config/config-discovery.js +19 -0
  13. package/dist/command/cli/doctor/doctor-rows.d.ts +12 -0
  14. package/dist/command/cli/doctor/doctor-rows.js +12 -0
  15. package/dist/command/cli/drive/drive-command.js +1 -1
  16. package/dist/command/cli/launch/cli-launch.d.ts +10 -2
  17. package/dist/command/cli/launch/cli-launch.js +32 -39
  18. package/dist/command/cli/lifecycle/daemon-lifecycle.d.ts +33 -0
  19. package/dist/command/cli/lifecycle/daemon-lifecycle.js +198 -0
  20. package/dist/command/cli/mcp-command.js +5 -3
  21. package/dist/command/cli/ports/resolve/cli-port.d.ts +2 -1
  22. package/dist/command/cli/ports/resolve/cli-port.js +5 -1
  23. package/dist/command/cli/status/status-lines.d.ts +28 -0
  24. package/dist/command/cli/status/status-lines.js +117 -0
  25. package/dist/command/cli.d.ts +1 -1
  26. package/dist/command/cli.js +24 -177
  27. package/dist/command/daemon/binding/daemon-status-probe.d.ts +2 -0
  28. package/dist/command/daemon/binding/daemon-status-probe.js +48 -0
  29. package/dist/command/daemon/binding/loopback-reach.d.ts +42 -0
  30. package/dist/command/daemon/binding/loopback-reach.js +63 -0
  31. package/dist/command/daemon/daemon-resilience.d.ts +13 -1
  32. package/dist/command/daemon/daemon-resilience.js +12 -1
  33. package/dist/command/daemon/daemon.d.ts +2 -11
  34. package/dist/command/daemon/daemon.js +8 -11
  35. package/dist/command/daemon/lifetime/heartbeat.d.ts +8 -0
  36. package/dist/command/daemon/lifetime/heartbeat.js +10 -0
  37. package/dist/command/daemon/lifetime/log-cap.d.ts +68 -0
  38. package/dist/command/daemon/lifetime/log-cap.js +115 -0
  39. package/dist/command/setup/agent-notice-memory.d.ts +16 -0
  40. package/dist/command/setup/agent-notice-memory.js +65 -0
  41. package/dist/command/setup/bringup/dev-server-wait.d.ts +1 -0
  42. package/dist/command/setup/bringup/dev-server-wait.js +16 -0
  43. package/dist/command/setup/bringup/ensure-daemon.js +1 -1
  44. package/dist/command/setup/init/init-runtime.js +13 -1
  45. package/dist/command/setup/node-effects.js +1 -1
  46. package/dist/command/setup/probe/page-probe.js +9 -2
  47. package/dist/command/setup/run-setup.d.ts +25 -7
  48. package/dist/command/setup/run-setup.js +51 -9
  49. package/dist/command/setup/setup-command.js +31 -3
  50. package/dist/command/setup/terminal/confirm.js +2 -1
  51. package/dist/command/update/updater.d.ts +27 -0
  52. package/dist/command/update/updater.js +35 -2
  53. package/dist/features/crawl/crawl-tools.js +3 -1
  54. package/dist/features/crawl/crawl.js +2 -1
  55. package/dist/features/harness/drive-report.d.ts +73 -0
  56. package/dist/features/harness/drive-report.js +196 -0
  57. package/dist/features/harness/driver.js +29 -8
  58. package/dist/features/harness/drivers.d.ts +19 -0
  59. package/dist/features/harness/drivers.js +19 -0
  60. package/dist/features/harness/fill-values.d.ts +67 -0
  61. package/dist/features/harness/fill-values.js +153 -0
  62. package/dist/features/harness/jev-driver.d.ts +188 -0
  63. package/dist/features/harness/jev-driver.js +664 -0
  64. package/dist/features/harness/openai-driver.d.ts +46 -0
  65. package/dist/features/harness/openai-driver.js +197 -0
  66. package/dist/features/harness/platform-config.d.ts +52 -0
  67. package/dist/features/harness/platform-config.js +89 -0
  68. package/dist/features/visual/visual-tools.js +26 -2
  69. package/dist/index.js +60 -55
  70. package/dist/judgement/domain/domain-tools.js +4 -2
  71. package/dist/judgement/runs/verification-sync.d.ts +2 -2
  72. package/dist/judgement/runs/verification-sync.js +9 -2
  73. package/dist/language/flows/affected-tools.js +6 -3
  74. package/dist/language/flows/flow-replay-run.d.ts +25 -2
  75. package/dist/language/flows/flow-replay-run.js +73 -27
  76. package/dist/language/flows/flow-replay.d.ts +17 -0
  77. package/dist/language/flows/flow-replay.js +148 -100
  78. package/dist/language/flows/flow-result.d.ts +18 -1
  79. package/dist/language/flows/flow-result.js +18 -2
  80. package/dist/language/flows/flow-store-for-session.d.ts +2 -1
  81. package/dist/language/flows/flow-tools.js +8 -4
  82. package/dist/language/flows/flows.d.ts +8 -7
  83. package/dist/language/flows/flows.js +7 -3
  84. package/dist/language/flows/narrow-write.d.ts +4 -4
  85. package/dist/language/flows/replay.js +1 -1
  86. package/dist/language/flows/suite-selection.d.ts +15 -1
  87. package/dist/language/flows/suite-selection.js +1 -1
  88. package/dist/language/flows/verify-change-tools.js +7 -4
  89. package/dist/memory/cloud/account-state.d.ts +1 -1
  90. package/dist/memory/cloud/account-state.js +5 -4
  91. package/dist/memory/cloud/cloud-config.d.ts +9 -0
  92. package/dist/memory/cloud/cloud-config.js +9 -0
  93. package/dist/memory/cloud/cloud-sync.d.ts +7 -2
  94. package/dist/memory/cloud/cloud-sync.js +10 -5
  95. package/dist/memory/cloud/harness-config.d.ts +10 -0
  96. package/dist/memory/cloud/harness-config.js +58 -0
  97. package/dist/memory/cloud/harness-offer.d.ts +44 -0
  98. package/dist/memory/cloud/harness-offer.js +146 -0
  99. package/dist/memory/cloud/harness-switch.d.ts +11 -0
  100. package/dist/memory/cloud/harness-switch.js +53 -0
  101. package/dist/memory/cloud/sync-daemon.d.ts +22 -2
  102. package/dist/memory/cloud/sync-daemon.js +24 -0
  103. package/dist/memory/impact/impact-recorder.d.ts +8 -0
  104. package/dist/memory/impact/impact-recorder.js +2 -1
  105. package/dist/memory/impact/impact-store.d.ts +5 -0
  106. package/dist/memory/impact/impact-store.js +19 -0
  107. package/dist/memory/intent/intent-shard-store.js +2 -2
  108. package/dist/memory/intent/intent-shard.d.ts +12 -12
  109. package/dist/memory/journal/attach-journal.d.ts +11 -0
  110. package/dist/memory/journal/attach-journal.js +1 -1
  111. package/dist/memory/journal/journal-query.d.ts +35 -1
  112. package/dist/memory/journal/journal-query.js +46 -0
  113. package/dist/memory/journal/journal-recorder.d.ts +50 -2
  114. package/dist/memory/journal/journal-recorder.js +45 -7
  115. package/dist/memory/journal/on-disk/retention.d.ts +87 -2
  116. package/dist/memory/journal/on-disk/retention.js +177 -8
  117. package/dist/memory/journal/on-disk/workspace-gitignore.d.ts +9 -0
  118. package/dist/memory/journal/on-disk/workspace-gitignore.js +9 -2
  119. package/dist/memory/journal/self-observation.d.ts +3 -0
  120. package/dist/memory/journal/self-observation.js +67 -0
  121. package/dist/memory/journal/session-end.d.ts +13 -3
  122. package/dist/memory/journal/session-end.js +24 -4
  123. package/dist/memory/journal/session-journal.d.ts +115 -5
  124. package/dist/memory/journal/session-journal.js +479 -32
  125. package/dist/memory/project/artifact-root-resolver.d.ts +3 -0
  126. package/dist/memory/project/artifact-root-resolver.js +89 -0
  127. package/dist/memory/project/artifact-root.d.ts +39 -2
  128. package/dist/memory/project/artifact-root.js +58 -1
  129. package/dist/memory/project/dir/fill-value-store.d.ts +32 -0
  130. package/dist/memory/project/dir/fill-value-store.js +88 -0
  131. package/dist/memory/project/dir/reticle-dir.d.ts +14 -4
  132. package/dist/memory/project/dir/reticle-dir.js +11 -0
  133. package/dist/memory/project/fs/fs-port.d.ts +22 -1
  134. package/dist/memory/project/fs/fs-port.js +33 -5
  135. package/dist/memory/project/learned-routes.d.ts +16 -2
  136. package/dist/memory/project/learned-routes.js +26 -10
  137. package/dist/memory/project/project-for-root.d.ts +30 -0
  138. package/dist/memory/project/project-for-root.js +43 -0
  139. package/dist/memory/project/project-tools.js +10 -5
  140. package/dist/memory/project/session-root.d.ts +35 -6
  141. package/dist/memory/project/session-root.js +70 -8
  142. package/dist/portal/bridge/auth-failure-reason.d.ts +9 -0
  143. package/dist/portal/bridge/auth-failure-reason.js +15 -1
  144. package/dist/portal/bridge/bridge.d.ts +6 -1
  145. package/dist/portal/bridge/bridge.js +36 -2
  146. package/dist/portal/session/facts/session-replaced.d.ts +19 -0
  147. package/dist/portal/session/facts/session-replaced.js +22 -0
  148. package/dist/portal/session/no-session-diagnosis.d.ts +1 -0
  149. package/dist/portal/session/no-session-diagnosis.js +67 -60
  150. package/dist/portal/session/no-session-next-action.js +3 -2
  151. package/dist/portal/session/no-session-watch.js +14 -10
  152. package/dist/portal/session/rebind.d.ts +18 -0
  153. package/dist/portal/session/rebind.js +38 -0
  154. package/dist/portal/session/session-manager.d.ts +4 -0
  155. package/dist/portal/session/session-manager.js +19 -1
  156. package/dist/portal/session/session.d.ts +21 -10
  157. package/dist/portal/session/session.js +29 -42
  158. package/dist/status-payload.d.ts +12 -1
  159. package/dist/status-payload.js +4 -1
  160. package/dist/surface/http-server.js +3 -5
  161. package/dist/surface/mcp/faults/mcp-outage.d.ts +2 -1
  162. package/dist/surface/mcp/faults/mcp-outage.js +20 -4
  163. package/dist/surface/mcp/mcp-proxy.d.ts +10 -1
  164. package/dist/surface/mcp/mcp-proxy.js +20 -9
  165. package/dist/surface/mcp/mcp.d.ts +10 -1
  166. package/dist/surface/mcp/mcp.js +59 -29
  167. package/dist/surface/mcp/proxy/proxy-daemon-probe.d.ts +15 -2
  168. package/dist/surface/mcp/proxy/proxy-daemon-probe.js +22 -5
  169. package/dist/surface/mcp/proxy/proxy-log.d.ts +5 -11
  170. package/dist/surface/mcp/proxy/proxy-log.js +7 -22
  171. package/dist/surface/tools/act/act-preflight.js +6 -1
  172. package/dist/surface/tools/act/sequence-grade.js +13 -0
  173. package/dist/surface/tools/act-capsule.js +27 -1
  174. package/dist/surface/tools/assert/assert-verdict.js +44 -17
  175. package/dist/surface/tools/coverage-tools.js +5 -2
  176. package/dist/surface/tools/dynamic-tools.js +37 -46
  177. package/dist/surface/tools/error-recovery.d.ts +6 -0
  178. package/dist/surface/tools/error-recovery.js +15 -0
  179. package/dist/surface/tools/explore-tools.js +80 -7
  180. package/dist/surface/tools/feedback-tools.js +49 -2
  181. package/dist/surface/tools/first-sentence.d.ts +24 -0
  182. package/dist/surface/tools/first-sentence.js +0 -0
  183. package/dist/surface/tools/harness-explore.d.ts +125 -1
  184. package/dist/surface/tools/harness-explore.js +401 -15
  185. package/dist/surface/tools/harness-plan.d.ts +76 -0
  186. package/dist/surface/tools/harness-plan.js +156 -0
  187. package/dist/surface/tools/harness-toolset.js +12 -1
  188. package/dist/surface/tools/invoke-tool.js +65 -5
  189. package/dist/surface/tools/live-call-text.d.ts +6 -0
  190. package/dist/surface/tools/live-call-text.js +36 -2
  191. package/dist/surface/tools/observe-tools.js +55 -9
  192. package/dist/surface/tools/read/query-windowed.d.ts +1 -1
  193. package/dist/surface/tools/read/query-windowed.js +1 -1
  194. package/dist/surface/tools/read-tools.js +1 -2
  195. package/dist/surface/tools/tool-kit.d.ts +18 -3
  196. package/dist/surface/tools/tools-helpers.d.ts +8 -7
  197. package/dist/surface/tools/tools-helpers.js +11 -13
  198. package/dist/surface/tools/tools.js +6 -1
  199. package/dist/telemetry/bug-found.js +32 -2
  200. package/dist/telemetry/feedback-context.js +3 -3
  201. package/dist/telemetry/harness-drive.d.ts +6 -0
  202. package/dist/telemetry/harness-drive.js +39 -0
  203. package/dist/telemetry/mcp-connection.d.ts +14 -1
  204. package/dist/telemetry/mcp-connection.js +17 -2
  205. package/dist/telemetry/onboarding-firsts.d.ts +30 -1
  206. package/dist/telemetry/onboarding-firsts.js +54 -12
  207. package/dist/telemetry/verification-of.js +31 -3
  208. package/docs/agent-cheatsheet.md +7 -3
  209. package/docs/autodrive.md +42 -2
  210. package/docs/benchmarks.md +1 -1
  211. package/docs/capabilities.mdx +1 -1
  212. package/docs/cli/cloud.mdx +3 -3
  213. package/docs/cli/doctor.mdx +1 -0
  214. package/docs/cli/status.mdx +35 -20
  215. package/docs/cli.mdx +8 -18
  216. package/docs/desktop.mdx +1 -1
  217. package/docs/faq.mdx +1 -1
  218. package/docs/install-manual.mdx +1 -1
  219. package/docs/packages/tauri.mdx +1 -1
  220. package/docs/packages.mdx +1 -1
  221. package/docs/predicates.mdx +16 -0
  222. package/docs/system-map.md +6 -4
  223. package/docs/telemetry.md +2 -1
  224. package/docs/token-efficiency.md +2 -2
  225. package/docs/tools/overview.mdx +1 -1
  226. package/docs/troubleshooting.mdx +8 -2
  227. package/docs/usage.md +1 -1
  228. package/package.json +6 -6
@@ -1,9 +1,35 @@
1
- import { type JournalAction, type ReticleEvent } from '@reticlehq/core';
1
+ import { type JournalAction, type JournalWriteLoss, type ReticleEvent } from '@reticlehq/core';
2
2
  /** Where a recorder persists. `SessionJournal` satisfies this structurally. */
3
3
  export interface JournalSink {
4
4
  appendEvents(events: readonly ReticleEvent[]): Promise<void>;
5
5
  appendAction(action: JournalAction): Promise<void>;
6
6
  }
7
+ /**
8
+ * What a durable read could NOT produce — the report that travels beside the events.
9
+ *
10
+ * The journal is append-only and one session's file is never pruned, so a long-lived session's
11
+ * ledger outgrows what a JavaScript string can hold and the read has to stop somewhere; and what a
12
+ * reader keeps across many reads has to stop somewhere too. Where it stopped is not a detail: an
13
+ * agent reading a short answer it cannot tell apart from a complete one is the false green this
14
+ * whole layer exists to prevent. Absent means nothing was lost.
15
+ */
16
+ export interface JournalReadLoss {
17
+ /**
18
+ * Journal bytes not represented in the answer — skipped past by a read that would have exceeded
19
+ * its ceiling, plus the bytes of records evicted after parsing. A floor, not a total: the record
20
+ * straddling a cut is discarded with them and is not counted here.
21
+ */
22
+ droppedBytes: number;
23
+ /** Parsed records evicted from the retained set. Exact, unlike `droppedBytes`. */
24
+ droppedEvents: number;
25
+ /**
26
+ * Latest timestamp of parsed cache evictions, inclusive. Omitted after a byte cut skips
27
+ * unparsed records: their time range is unknown even when newer bytes survive.
28
+ */
29
+ lostThroughT?: number;
30
+ /** Human-and-agent readable summary. Present so a consumer never has to compose one. */
31
+ note: string;
32
+ }
7
33
  /** Read side of the durable journal — the fall-through source when the ring buffer has evicted. */
8
34
  export interface JournalReader {
9
35
  readEvents(): Promise<ReticleEvent[]>;
@@ -12,6 +38,21 @@ export interface JournalReader {
12
38
  * needs events should be forced to grow a second method to satisfy the type.
13
39
  */
14
40
  readActions?(): Promise<JournalAction[]>;
41
+ /**
42
+ * What the reads so far could not reach. Optional on the same reasoning as `readActions`: a reader
43
+ * that omits it declares no loss, which is the behaviour every partial double already had.
44
+ */
45
+ readLoss?(): JournalReadLoss | undefined;
46
+ /**
47
+ * What the WRITER could not put on disk: the ledger's closure report, when its byte ceiling
48
+ * refused a batch. `undefined` means nothing was refused.
49
+ *
50
+ * Optional for the same reason `readActions` is, and absence means NOT MEASURED rather than "no
51
+ * loss" — a reader that cannot answer must not be made to answer "clean". The separate question
52
+ * of what a bounded READ declined to hand back belongs to the reader that bounded it; this one is
53
+ * only ever about writes that never happened.
54
+ */
55
+ readWriteLoss?(): Promise<JournalWriteLoss | undefined>;
15
56
  }
16
57
  interface JournalRecorderOptions {
17
58
  /** Injected elapsed-ms clock (never read from Date here — the clock-injection rule). */
@@ -31,7 +72,14 @@ interface JournalRecorderOptions {
31
72
  export declare class JournalRecorder {
32
73
  #private;
33
74
  constructor(sink: JournalSink, options: JournalRecorderOptions);
34
- /** Attribute (if an action is active), enqueue for journaling, and return the possibly-stamped event. */
75
+ /**
76
+ * Attribute (if an action is active), enqueue for journaling, and return the possibly-stamped event.
77
+ *
78
+ * Reticle fetching its own SDK is attributed and returned like anything else but never written to
79
+ * disk. That is a decision about what is worth KEEPING, so it is taken here, where the journal is
80
+ * written, and not in a reader: a reader that hid these would leave the bytes on disk and the
81
+ * cost unpaid, and a second reader would still find them and disagree about what happened.
82
+ */
35
83
  observe(event: ReticleEvent): ReticleEvent;
36
84
  /** Open an attribution window. One action is active at a time (the agent drives sequentially). */
37
85
  beginAction(actionId: string, tool: string, args: Record<string, unknown>): void;
@@ -1,4 +1,17 @@
1
1
  import { EventAttribution, JOURNAL_FILE_VERSION, } from '@reticlehq/core';
2
+ import { isSelfObservation } from './self-observation.js';
3
+ import { log } from '../../log.js';
4
+ /**
5
+ * Logged the FIRST time a journal write is lost, and never again for that recorder.
6
+ *
7
+ * The swallow stays — a failed local journal write must never break a live session, and a tool call
8
+ * is not the place to learn the disk is full. But a loss that says nothing is how a journal wrote
9
+ * nothing for a whole session while the session reported healthy and reads kept answering from the
10
+ * in-memory cache. Once per recorder, on the existing daemon-log channel: enough to find in the
11
+ * daemon log when somebody asks why a journal file is short, quiet enough that a full disk does not
12
+ * produce one line per batch.
13
+ */
14
+ const JOURNAL_WRITE_LOST = 'journal_write_lost';
2
15
  const DEFAULT_FLUSH_AT = 64;
3
16
  /**
4
17
  * Owns action-window attribution and batched, order-preserving journaling — the durable half of the
@@ -16,12 +29,20 @@ export class JournalRecorder {
16
29
  #pending = [];
17
30
  #active;
18
31
  #chain = Promise.resolve();
32
+ #lossReported = false;
19
33
  constructor(sink, options) {
20
34
  this.#sink = sink;
21
35
  this.#now = options.now;
22
36
  this.#flushAt = options.flushAt ?? DEFAULT_FLUSH_AT;
23
37
  }
24
- /** Attribute (if an action is active), enqueue for journaling, and return the possibly-stamped event. */
38
+ /**
39
+ * Attribute (if an action is active), enqueue for journaling, and return the possibly-stamped event.
40
+ *
41
+ * Reticle fetching its own SDK is attributed and returned like anything else but never written to
42
+ * disk. That is a decision about what is worth KEEPING, so it is taken here, where the journal is
43
+ * written, and not in a reader: a reader that hid these would leave the bytes on disk and the
44
+ * cost unpaid, and a second reader would still find them and disagree about what happened.
45
+ */
25
46
  observe(event) {
26
47
  let out = event;
27
48
  const active = this.#active;
@@ -33,9 +54,13 @@ export class JournalRecorder {
33
54
  active.seqTo = active.seqTo === undefined ? event.seq : Math.max(active.seqTo, event.seq);
34
55
  }
35
56
  }
36
- this.#pending.push(out);
37
- if (this.#pending.length >= this.#flushAt)
38
- this.#enqueueFlush();
57
+ // Attribution above still runs, and the return value is what the live ring buffer stores — the
58
+ // session keeps seeing this event. What is declined is the DURABLE copy: see isSelfObservation.
59
+ if (!isSelfObservation(out)) {
60
+ this.#pending.push(out);
61
+ if (this.#pending.length >= this.#flushAt)
62
+ this.#enqueueFlush();
63
+ }
39
64
  return out;
40
65
  }
41
66
  /** Open an attribution window. One action is active at a time (the agent drives sequentially). */
@@ -63,7 +88,9 @@ export class JournalRecorder {
63
88
  at: active.tStart,
64
89
  };
65
90
  this.#enqueueFlush();
66
- this.#chain = this.#chain.then(() => this.#sink.appendAction(action)).catch(() => undefined);
91
+ this.#chain = this.#chain
92
+ .then(() => this.#sink.appendAction(action))
93
+ .catch((error) => this.#reportLoss(error));
67
94
  }
68
95
  /**
69
96
  * Append one action record for a tool that PROVED something without driving the page.
@@ -98,7 +125,9 @@ export class JournalRecorder {
98
125
  at,
99
126
  };
100
127
  this.#enqueueFlush();
101
- this.#chain = this.#chain.then(() => this.#sink.appendAction(action)).catch(() => undefined);
128
+ this.#chain = this.#chain
129
+ .then(() => this.#sink.appendAction(action))
130
+ .catch((error) => this.#reportLoss(error));
102
131
  }
103
132
  /** Persist any buffered events now (call on session end). Awaits the write chain to settle. */
104
133
  async flush() {
@@ -110,6 +139,15 @@ export class JournalRecorder {
110
139
  return;
111
140
  const batch = this.#pending;
112
141
  this.#pending = [];
113
- this.#chain = this.#chain.then(() => this.#sink.appendEvents(batch)).catch(() => undefined);
142
+ this.#chain = this.#chain
143
+ .then(() => this.#sink.appendEvents(batch))
144
+ .catch((error) => this.#reportLoss(error));
145
+ }
146
+ /** Swallow a lost write, but say so once. See JOURNAL_WRITE_LOST. */
147
+ #reportLoss(error) {
148
+ if (this.#lossReported)
149
+ return;
150
+ this.#lossReported = true;
151
+ log(JOURNAL_WRITE_LOST, { error: error instanceof Error ? error.message : String(error) });
114
152
  }
115
153
  }
@@ -11,6 +11,55 @@ export declare const DEFAULT_DIFF_RETENTION = 10;
11
11
  * for the one case where delivery was refused and somebody has to file the report by hand.
12
12
  */
13
13
  export declare const DEFAULT_FEEDBACK_RETENTION = 20;
14
+ /**
15
+ * The TOTAL the evidence tier of one `.reticle/` may occupy, across sessions, runs, overlay diffs
16
+ * and feedback copies together.
17
+ *
18
+ * Every bound above this line counts things, and a count cannot see a size: twenty session
19
+ * directories is twenty UNBOUNDED directories. A user's `.reticle/sessions` reached 8 GB with all
20
+ * three counts being honoured the whole time, because one drive against a chatty app appends
21
+ * response bodies and DOM text for as long as the drive lasts, and nothing was looking at the total.
22
+ *
23
+ * Why this number and not one an order of magnitude either side. An ordinary session journal is
24
+ * hundreds of kilobytes to a few megabytes; a long drive against an app with large JSON responses
25
+ * runs to tens. Twenty of the ordinary ones fit inside this budget outright, which is the working
26
+ * set somebody plausibly wants — the evidence a person reads is from the drive they are looking at
27
+ * and the two before it, and nobody has ever opened the twentieth. A tenth of this (~50 MB) is
28
+ * smaller than a single bad session, so the budget would evict the newest evidence while it was
29
+ * still being written and the count bound could never be reached; ten times it (~5 GB) is not a
30
+ * bound a laptop notices, and 5 GB of untouched journals is the same abandoned-Reticle report with
31
+ * a different number in it. This is roughly one node_modules: large enough to be invisible in a
32
+ * project checkout, small enough that it can never be the reason a disk fills.
33
+ *
34
+ * Both bounds stay, and they are independent on purpose. The count is the cheap guard — it needs one
35
+ * stat per entry and it keeps the directory listing readable. The budget is the guard that holds
36
+ * when the counts are all satisfied and the bytes are not, which is the only situation anybody has
37
+ * actually been hurt by. Either one alone leaves the other's failure unguarded.
38
+ */
39
+ export declare const DEFAULT_EVIDENCE_BUDGET_BYTES: number;
40
+ /**
41
+ * One candidate entry inside a `.reticle/`, classified by the top-level directory it lives under.
42
+ *
43
+ * `under` is what decides the tier, and it is compared against `TRANSIENT_DIRS` — the same list the
44
+ * workspace gitignore writes, guarded there as an exhaustive partition of what `.reticle/` holds.
45
+ * Anything else is MEMORY: flows, capsules, baselines, the contract, the intent ledger. Those are
46
+ * small, durable and the reason the directory exists, so they are never evicted, never counted, and
47
+ * must keep being written however full the disk is.
48
+ */
49
+ export interface TierEntry {
50
+ /** Absolute path, and what is removed. */
51
+ path: string;
52
+ /** The `.reticle/` top-level entry this lives under. */
53
+ under: string;
54
+ sizeBytes: number;
55
+ mtimeMs: number;
56
+ }
57
+ /**
58
+ * Pure: the paths to remove so the EVIDENCE tier fits in `budgetBytes`. Oldest first, memory-tier
59
+ * entries never selected and never counted. No IO — the call site stats and removes, exactly as
60
+ * `selectPrunable` and `pruneByRecency` already split it.
61
+ */
62
+ export declare function selectOverBudget(entries: readonly TierEntry[], budgetBytes: number, live?: ReadonlySet<string>): string[];
14
63
  interface DatedDir {
15
64
  name: string;
16
65
  mtimeMs: number;
@@ -20,13 +69,38 @@ interface DatedDir {
20
69
  * remain. Oldest-first. Exported for testing (no IO).
21
70
  */
22
71
  export declare function selectPrunable(dirs: readonly DatedDir[], retention: number): string[];
72
+ /** What a sweep is allowed to delete. Every field optional: omitting one keeps the old behaviour. */
73
+ export interface RetentionOptions {
74
+ /** How many session directories survive the count bound. */
75
+ retention?: number;
76
+ /** The TOTAL the evidence tier may occupy. */
77
+ budgetBytes?: number;
78
+ /**
79
+ * The ids of sessions that are STILL OPEN. Neither bound may evict one.
80
+ *
81
+ * Without this both bounds could delete the journal of the session currently being written, and
82
+ * the long-running session was the FIRST candidate rather than the last: a directory's mtime is
83
+ * stamped when it is created and never moves again — appending to a file inside it advances the
84
+ * file's mtime, not the directory's — so an hour-long drive looks older than every short session
85
+ * that started after it. The journal then latched its directory as ensured, every later append
86
+ * failed ENOENT into a swallowed catch, and reads kept answering from the in-memory cache, so the
87
+ * session reported healthy with nothing reaching disk.
88
+ */
89
+ live?: ReadonlySet<string> | undefined;
90
+ }
23
91
  /**
24
92
  * Bound the journal on disk: keep the `retention` most-recent session directories under
25
93
  * `.reticle/sessions/`, remove the rest by mtime. Never throws — retention is best-effort maintenance,
26
94
  * so a stat/rm failure on one dir (or a missing sessions/ dir) is swallowed, never crashing a session.
27
- * Mirrors RunStore's amortized pruning; run on daemon start.
95
+ * Mirrors RunStore's amortized pruning; run on daemon start and at the end of every session.
96
+ *
97
+ * An open session is excluded from BOTH bounds — see `RetentionOptions.live`.
98
+ *
99
+ * ponytail: excluded via `keep`, so an open session is not COUNTED toward the retention cap either.
100
+ * The directory can therefore hold `retention` + (concurrently open sessions) entries, which is a
101
+ * handful. Count them separately only if somebody runs enough parallel sessions for it to matter.
28
102
  */
29
- export declare function pruneSessions(fs: FileSystemPort, root: string, retention?: number): Promise<void>;
103
+ export declare function pruneSessions(fs: FileSystemPort, root: string, options?: RetentionOptions): Promise<void>;
30
104
  /**
31
105
  * Bound `.reticle/visual/` — the largest thing in the workspace, and the only one with no delete
32
106
  * path at all.
@@ -42,6 +116,17 @@ export declare function pruneSessions(fs: FileSystemPort, root: string, retentio
42
116
  * `pruneSessions`: this is maintenance, and maintenance must never be the reason a daemon fails.
43
117
  */
44
118
  export declare function pruneVisualDiffs(fs: FileSystemPort, root: string, retention?: number): Promise<void>;
119
+ /**
120
+ * Hold the evidence tier of one `.reticle/` under a TOTAL byte budget, evicting oldest-first.
121
+ *
122
+ * The memory tier is not read, not counted and not touched: the maintainer's constraint is a cap
123
+ * that never stops memory and flows being created, so a full disk costs somebody their session
124
+ * journals and never their flows.
125
+ *
126
+ * Never throws, like everything else here. Retention is maintenance, and maintenance must never be
127
+ * the reason a daemon fails to come up or a session dies.
128
+ */
129
+ export declare function pruneEvidenceBudget(fs: FileSystemPort, root: string, budgetBytes?: number, live?: ReadonlySet<string>): Promise<void>;
45
130
  /**
46
131
  * Bound `.reticle/feedback/` — local copies of reports that were already sent.
47
132
  *
@@ -1,5 +1,7 @@
1
- import { join } from 'node:path';
1
+ import { basename, join } from 'node:path';
2
+ import { ReticleDir } from '@reticlehq/core';
2
3
  import { reticleDirPaths, visualDir } from '../../project/dir/reticle-dir.js';
4
+ import { TRANSIENT_DIRS } from './workspace-gitignore.js';
3
5
  /** Keep at most this many session journals on disk; older ones are pruned by recency. */
4
6
  export const DEFAULT_SESSION_RETENTION = 20;
5
7
  /**
@@ -13,6 +15,69 @@ export const DEFAULT_DIFF_RETENTION = 10;
13
15
  */
14
16
  export const DEFAULT_FEEDBACK_RETENTION = 20;
15
17
  const DIFF_SUFFIX = '.diff.png';
18
+ const PNG_SUFFIX = '.png';
19
+ /**
20
+ * The TOTAL the evidence tier of one `.reticle/` may occupy, across sessions, runs, overlay diffs
21
+ * and feedback copies together.
22
+ *
23
+ * Every bound above this line counts things, and a count cannot see a size: twenty session
24
+ * directories is twenty UNBOUNDED directories. A user's `.reticle/sessions` reached 8 GB with all
25
+ * three counts being honoured the whole time, because one drive against a chatty app appends
26
+ * response bodies and DOM text for as long as the drive lasts, and nothing was looking at the total.
27
+ *
28
+ * Why this number and not one an order of magnitude either side. An ordinary session journal is
29
+ * hundreds of kilobytes to a few megabytes; a long drive against an app with large JSON responses
30
+ * runs to tens. Twenty of the ordinary ones fit inside this budget outright, which is the working
31
+ * set somebody plausibly wants — the evidence a person reads is from the drive they are looking at
32
+ * and the two before it, and nobody has ever opened the twentieth. A tenth of this (~50 MB) is
33
+ * smaller than a single bad session, so the budget would evict the newest evidence while it was
34
+ * still being written and the count bound could never be reached; ten times it (~5 GB) is not a
35
+ * bound a laptop notices, and 5 GB of untouched journals is the same abandoned-Reticle report with
36
+ * a different number in it. This is roughly one node_modules: large enough to be invisible in a
37
+ * project checkout, small enough that it can never be the reason a disk fills.
38
+ *
39
+ * Both bounds stay, and they are independent on purpose. The count is the cheap guard — it needs one
40
+ * stat per entry and it keeps the directory listing readable. The budget is the guard that holds
41
+ * when the counts are all satisfied and the bytes are not, which is the only situation anybody has
42
+ * actually been hurt by. Either one alone leaves the other's failure unguarded.
43
+ */
44
+ export const DEFAULT_EVIDENCE_BUDGET_BYTES = 512 * 1024 * 1024;
45
+ /** No session is open. The default everywhere, so an existing caller keeps its old behaviour. */
46
+ const NO_LIVE_SESSIONS = new Set();
47
+ /**
48
+ * Is this entry the journal directory of a session that is STILL BEING WRITTEN?
49
+ *
50
+ * A session directory is named for its session id, so the id IS the basename. Scoped to the
51
+ * sessions tier on purpose: a run artifact or a diff that happens to share a session's name is not
52
+ * a journal, and nothing is holding it open.
53
+ */
54
+ function isOpenSession(entry, live) {
55
+ return ReticleDir.SESSIONS_SUBDIR === entry.under && live.has(basename(entry.path));
56
+ }
57
+ /**
58
+ * Pure: the paths to remove so the EVIDENCE tier fits in `budgetBytes`. Oldest first, memory-tier
59
+ * entries never selected and never counted. No IO — the call site stats and removes, exactly as
60
+ * `selectPrunable` and `pruneByRecency` already split it.
61
+ */
62
+ export function selectOverBudget(entries, budgetBytes, live = NO_LIVE_SESSIONS) {
63
+ const evidence = entries.filter((entry) => TRANSIENT_DIRS.includes(entry.under));
64
+ // Live sessions still COUNT — their bytes are on disk and the budget is about the disk. They are
65
+ // only excluded from the eviction list, so a session over budget on its own is left alone rather
66
+ // than deleted under its own writer.
67
+ let total = evidence.reduce((sum, entry) => sum + entry.sizeBytes, 0);
68
+ if (total <= budgetBytes)
69
+ return [];
70
+ const doomed = [];
71
+ for (const entry of [...evidence].sort((a, b) => a.mtimeMs - b.mtimeMs)) {
72
+ if (isOpenSession(entry, live))
73
+ continue;
74
+ if (total <= budgetBytes)
75
+ break;
76
+ doomed.push(entry.path);
77
+ total -= entry.sizeBytes;
78
+ }
79
+ return doomed;
80
+ }
16
81
  /** Where refused feedback reports are copied. Named here because the writer lives in another area. */
17
82
  const FEEDBACK_SUBDIR = 'feedback';
18
83
  /**
@@ -71,10 +136,21 @@ async function pruneByRecency(fs, dir, retention, keep = () => true) {
71
136
  * Bound the journal on disk: keep the `retention` most-recent session directories under
72
137
  * `.reticle/sessions/`, remove the rest by mtime. Never throws — retention is best-effort maintenance,
73
138
  * so a stat/rm failure on one dir (or a missing sessions/ dir) is swallowed, never crashing a session.
74
- * Mirrors RunStore's amortized pruning; run on daemon start.
139
+ * Mirrors RunStore's amortized pruning; run on daemon start and at the end of every session.
140
+ *
141
+ * An open session is excluded from BOTH bounds — see `RetentionOptions.live`.
142
+ *
143
+ * ponytail: excluded via `keep`, so an open session is not COUNTED toward the retention cap either.
144
+ * The directory can therefore hold `retention` + (concurrently open sessions) entries, which is a
145
+ * handful. Count them separately only if somebody runs enough parallel sessions for it to matter.
75
146
  */
76
- export async function pruneSessions(fs, root, retention = DEFAULT_SESSION_RETENTION) {
77
- await pruneByRecency(fs, reticleDirPaths(root).sessions, retention);
147
+ export async function pruneSessions(fs, root, options = {}) {
148
+ const live = options.live ?? NO_LIVE_SESSIONS;
149
+ const retention = options.retention ?? DEFAULT_SESSION_RETENTION;
150
+ await pruneByRecency(fs, reticleDirPaths(root).sessions, retention, (name) => !live.has(name));
151
+ // The tier total is swept from here because this is the one sweep that runs on BOTH the daemon's
152
+ // start path and the end of every session, and the end of a session is when the bytes arrive.
153
+ await pruneEvidenceBudget(fs, root, options.budgetBytes ?? DEFAULT_EVIDENCE_BUDGET_BYTES, live);
78
154
  }
79
155
  /**
80
156
  * Bound `.reticle/visual/` — the largest thing in the workspace, and the only one with no delete
@@ -91,20 +167,113 @@ export async function pruneSessions(fs, root, retention = DEFAULT_SESSION_RETENT
91
167
  * `pruneSessions`: this is maintenance, and maintenance must never be the reason a daemon fails.
92
168
  */
93
169
  export async function pruneVisualDiffs(fs, root, retention = DEFAULT_DIFF_RETENTION) {
170
+ for (const dir of await visualDirs(fs, root)) {
171
+ await pruneByRecency(fs, dir, retention, isDiff);
172
+ }
173
+ }
174
+ /** `visual/` plus its per-runtime subdirectories. Empty when nothing has been captured yet. */
175
+ async function visualDirs(fs, root) {
94
176
  const top = visualDir(root);
95
177
  const dirs = [top];
96
178
  try {
97
179
  // A per-runtime subdirectory is anything here that is not itself a PNG.
98
180
  for (const entry of await fs.readdir(top)) {
99
- if (!entry.endsWith('.png'))
181
+ if (!entry.endsWith(PNG_SUFFIX))
100
182
  dirs.push(join(top, entry));
101
183
  }
102
184
  }
103
185
  catch {
104
- return; // no visual dir yet
186
+ return [];
187
+ }
188
+ return dirs;
189
+ }
190
+ const isDiff = (name) => name.endsWith(DIFF_SUFFIX);
191
+ /**
192
+ * Bytes under `path`, summed recursively. A session journal is a DIRECTORY, and `stat` on one
193
+ * reports the dirent, not the megabytes of JSONL inside it.
194
+ *
195
+ * readdir-first because the port exposes no `isDirectory`: a readdir that fails is a file, and its
196
+ * own stat answers. Never throws — an unreadable entry contributes nothing, which can only make the
197
+ * sweep evict less than it should, never more.
198
+ */
199
+ async function sizeOf(fs, path) {
200
+ let names;
201
+ try {
202
+ names = await fs.readdir(path);
203
+ }
204
+ catch {
205
+ try {
206
+ return (await fs.stat(path)).size;
207
+ }
208
+ catch {
209
+ return 0;
210
+ }
211
+ }
212
+ let total = 0;
213
+ for (const name of names)
214
+ total += await sizeOf(fs, join(path, name));
215
+ return total;
216
+ }
217
+ /**
218
+ * Every evidence entry in this `.reticle/`, sized and dated. The IO half of the byte budget.
219
+ *
220
+ * `visual/` is the one evidence directory holding something that is not evidence — the PNG
221
+ * BASELINES a comparison means anything against — so the same `.diff.png` filter `pruneVisualDiffs`
222
+ * applies is applied here. The tier list is the coarse rule; that filter is the finer one, and
223
+ * deleting a baseline to save disk turns the next run's verdict into `baseline-missing`.
224
+ */
225
+ async function collectEvidence(fs, root) {
226
+ const entries = [];
227
+ for (const under of TRANSIENT_DIRS) {
228
+ const isVisual = ReticleDir.VISUAL_SUBDIR === under;
229
+ const dirs = isVisual ? await visualDirs(fs, root) : [join(root, under)];
230
+ for (const dir of dirs) {
231
+ let names;
232
+ try {
233
+ names = await fs.readdir(dir);
234
+ }
235
+ catch {
236
+ continue; // nothing written here yet
237
+ }
238
+ for (const name of names) {
239
+ if (isVisual && !isDiff(name))
240
+ continue;
241
+ const path = join(dir, name);
242
+ try {
243
+ const { mtimeMs } = await fs.stat(path);
244
+ entries.push({ path, under, sizeBytes: await sizeOf(fs, path), mtimeMs });
245
+ }
246
+ catch {
247
+ /* unreadable entry — skip */
248
+ }
249
+ }
250
+ }
251
+ }
252
+ return entries;
253
+ }
254
+ /**
255
+ * Hold the evidence tier of one `.reticle/` under a TOTAL byte budget, evicting oldest-first.
256
+ *
257
+ * The memory tier is not read, not counted and not touched: the maintainer's constraint is a cap
258
+ * that never stops memory and flows being created, so a full disk costs somebody their session
259
+ * journals and never their flows.
260
+ *
261
+ * Never throws, like everything else here. Retention is maintenance, and maintenance must never be
262
+ * the reason a daemon fails to come up or a session dies.
263
+ */
264
+ export async function pruneEvidenceBudget(fs, root, budgetBytes = DEFAULT_EVIDENCE_BUDGET_BYTES, live = NO_LIVE_SESSIONS) {
265
+ try {
266
+ for (const path of selectOverBudget(await collectEvidence(fs, root), budgetBytes, live)) {
267
+ try {
268
+ await fs.rm(path);
269
+ }
270
+ catch {
271
+ /* best-effort */
272
+ }
273
+ }
105
274
  }
106
- for (const dir of dirs) {
107
- await pruneByRecency(fs, dir, retention, (name) => name.endsWith(DIFF_SUFFIX));
275
+ catch {
276
+ /* maintenance never fails a daemon */
108
277
  }
109
278
  }
110
279
  /**
@@ -1,4 +1,13 @@
1
1
  import type { FileSystemPort } from '../../project/fs/fs-port.js';
2
+ /**
3
+ * Local directories, without the trailing slash the ignore file wants.
4
+ *
5
+ * Exported because this is also the EVIDENCE tier: the per-session churn that retention is allowed
6
+ * to delete. Read from here rather than restated next to the byte budget — the partition of what
7
+ * `.reticle/` holds already exists, is guarded as exhaustive, and a second copy of it would be the
8
+ * list that silently disagrees.
9
+ */
10
+ export declare const TRANSIENT_DIRS: readonly string[];
2
11
  /** Every name Reticle treats as machine-local, however it is spelled in the ignore file. */
3
12
  export declare const TRANSIENT_NAMES: readonly string[];
4
13
  /**
@@ -20,8 +20,15 @@ import { ReticleDir } from '@reticlehq/core';
20
20
  * or not that name has been hoisted yet.
21
21
  */
22
22
  const FEEDBACK_SUBDIR = 'feedback';
23
- /** Local directories, without the trailing slash the ignore file wants. */
24
- const TRANSIENT_DIRS = [
23
+ /**
24
+ * Local directories, without the trailing slash the ignore file wants.
25
+ *
26
+ * Exported because this is also the EVIDENCE tier: the per-session churn that retention is allowed
27
+ * to delete. Read from here rather than restated next to the byte budget — the partition of what
28
+ * `.reticle/` holds already exists, is guarded as exhaustive, and a second copy of it would be the
29
+ * list that silently disagrees.
30
+ */
31
+ export const TRANSIENT_DIRS = [
25
32
  ReticleDir.SESSIONS_SUBDIR,
26
33
  ReticleDir.RUNS_SUBDIR,
27
34
  ReticleDir.VISUAL_SUBDIR,
@@ -0,0 +1,3 @@
1
+ import { type ReticleEvent } from '@reticlehq/core';
2
+ /** True when this event is the page fetching Reticle's own code rather than doing anything. */
3
+ export declare function isSelfObservation(event: ReticleEvent): boolean;
@@ -0,0 +1,67 @@
1
+ import { EventType, urlForMatch } from '@reticlehq/core';
2
+ /**
3
+ * A request the page made for Reticle's OWN code, which the journal declines to keep.
4
+ *
5
+ * Measured over this repo's twenty recorded sessions: `net.request` is 64.6% of every journal byte
6
+ * and `net.detail` a further 17.6%, and the bulk of that is the browser fetching the SDK's module
7
+ * graph over and over as the dev server hot-reloads. Those bytes are Reticle watching itself arrive.
8
+ * They can never be evidence about the app under test, because the app did not ask for them — the
9
+ * instrumentation did, and it did so before the app had done anything worth verifying.
10
+ *
11
+ * The DOM side has drawn this line for a long time (`isReticleUi`), and the transport side draws it
12
+ * on the bridge socket's own path. This is the same line on the network channel.
13
+ *
14
+ * Identity, not resemblance. A request qualifies only when its PATH names our npm scope, which is
15
+ * the one thing about a URL that nobody else can be: `@reticlehq` is a registry scope we own, so a
16
+ * path segment under it is a file from one of our packages however it is being served — from
17
+ * `node_modules`, from a CDN, or through Vite's dep optimizer, which flattens the scope to
18
+ * `@reticlehq_browser.js`. A user's own file is never addressed that way.
19
+ *
20
+ * What this deliberately does NOT do is guess from shape. `/core/dist/index.js` and
21
+ * `/adapters/realm/browser/dist/index.js` are what this repo's own linked checkout serves, and
22
+ * matching those would collapse most of the bytes measured above — but they are ordinary paths that
23
+ * somebody else's app is entitled to serve, and a filter that drops a user's request to save our own
24
+ * bytes is the trade this project has already measured the cost of. So a source-linked checkout of
25
+ * Reticle itself keeps journaling its own SDK, and that is the correct side to be wrong on.
26
+ *
27
+ * Only the query string can carry a scope-shaped string that is really app data (a search for
28
+ * `@reticlehq/browser`), so the decision is taken on `pathname` alone.
29
+ */
30
+ /** The npm scope directory, as it appears when the package is served from a tree. */
31
+ const SCOPE_PATH = '/@reticlehq/';
32
+ /**
33
+ * The same scope after Vite's dependency optimizer flattens `@scope/name` to one filename. Anchored
34
+ * at a segment boundary so it cannot match in the middle of somebody's filename.
35
+ */
36
+ const SCOPE_FLATTENED = '/@reticlehq_';
37
+ /**
38
+ * Every event kind that carries a request URL — all of them, on purpose.
39
+ *
40
+ * `reconcileNet` pairs a NET_PENDING with its NET_REQUEST by id and reports an unpaired pending as a
41
+ * HUNG request. Dropping one half of a pair would therefore manufacture a finding out of a request
42
+ * that completed perfectly. The rule is a pure function of the URL, so both halves of a pair always
43
+ * get the same answer and no pair can be split.
44
+ */
45
+ const NET_TYPES = new Set([
46
+ EventType.NET_REQUEST,
47
+ EventType.NET_PENDING,
48
+ EventType.NET_DETAIL,
49
+ EventType.NET_STREAM,
50
+ ]);
51
+ /** A base for parsing relative URLs. Never dereferenced — only the path is read. */
52
+ const RELATIVE_BASE = 'http://reticle.invalid';
53
+ function pathOf(url) {
54
+ try {
55
+ return new URL(url, RELATIVE_BASE).pathname;
56
+ }
57
+ catch {
58
+ return url;
59
+ }
60
+ }
61
+ /** True when this event is the page fetching Reticle's own code rather than doing anything. */
62
+ export function isSelfObservation(event) {
63
+ if (!NET_TYPES.has(event.type))
64
+ return false;
65
+ const path = pathOf(urlForMatch(event.data));
66
+ return path.includes(SCOPE_PATH) || path.includes(SCOPE_FLATTENED);
67
+ }
@@ -1,7 +1,7 @@
1
1
  import { type DriveProgram, type TapeStep } from './drive-flow.js';
2
2
  import { type OnboardingStep } from '@reticlehq/core/telemetry';
3
3
  import type { AmbientCounts } from '@reticlehq/engine/window/ambient.js';
4
- import { type JournalAction } from '@reticlehq/core';
4
+ import { type ProjectId, type JournalAction } from '@reticlehq/core';
5
5
  import type { FileSystemPort } from '../project/fs/fs-port.js';
6
6
  /**
7
7
  * Session teardown: the durable half of ending a session. Two things must happen when a tab disconnects,
@@ -39,7 +39,7 @@ export interface SessionEndTarget {
39
39
  readJournalActions?(): Promise<JournalAction[]>;
40
40
  /** The project's own `.reticle`, so a run lands in the repo it is about. */
41
41
  readonly artifactRoot?: string | undefined;
42
- readonly projectId?: string | undefined;
42
+ readonly projectId?: ProjectId | undefined;
43
43
  }
44
44
  interface SessionEndDeps {
45
45
  fs: FileSystemPort;
@@ -76,7 +76,7 @@ interface SessionEndDeps {
76
76
  * this handler follows.
77
77
  */
78
78
  flows?: {
79
- save: (program: DriveProgram, annotations?: undefined, projectId?: string) => Promise<unknown>;
79
+ save: (program: DriveProgram, annotations?: undefined, projectId?: ProjectId) => Promise<unknown>;
80
80
  };
81
81
  /**
82
82
  * Called when a run artifact is written, so cloud sync can cycle instead of waiting for its timer.
@@ -84,6 +84,16 @@ interface SessionEndDeps {
84
84
  * Optional, like everything else here: absent simply means the next scheduled cycle picks it up.
85
85
  */
86
86
  onRunPersisted?: () => void;
87
+ /**
88
+ * The ids of the sessions still OPEN, read at each teardown so retention never deletes a journal
89
+ * that is still being written. A getter rather than a snapshot: this handler is built once at
90
+ * wiring and fires for every session for the life of the daemon.
91
+ *
92
+ * Injected, like the clock and the reporter — teardown needs the answer, not the registry.
93
+ * Optional so every existing construction keeps compiling, and absent means "none known", which
94
+ * is exactly the old behaviour.
95
+ */
96
+ liveSessionIds?: () => ReadonlySet<string>;
87
97
  }
88
98
  /**
89
99
  * Build the teardown handler the bridge fires when a session is removed. Flushes the journal first (so no