@reticlehq/server 2.7.0 → 2.9.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 (357) hide show
  1. package/README.md +14 -5
  2. package/SKILL.md +130 -778
  3. package/dist/bridge/bridge.d.ts +23 -0
  4. package/dist/bridge/bridge.js +80 -2
  5. package/dist/bridge/bridge.test-harness.d.ts +5 -0
  6. package/dist/bridge/bridge.test-harness.js +6 -4
  7. package/dist/capsule/causal-summary.d.ts +36 -1
  8. package/dist/capsule/causal-summary.js +33 -7
  9. package/dist/capsule/divergence.js +7 -1
  10. package/dist/cli/chromium-hint.d.ts +33 -5
  11. package/dist/cli/chromium-hint.js +82 -12
  12. package/dist/cli/cli-doctor.js +65 -34
  13. package/dist/cli/cli-flow-commands.js +8 -8
  14. package/dist/cli/cli-kill.d.ts +58 -0
  15. package/dist/cli/cli-kill.js +162 -0
  16. package/dist/cli/cli-parse.d.ts +16 -1
  17. package/dist/cli/cli-parse.js +32 -9
  18. package/dist/cli/cli-port.d.ts +32 -0
  19. package/dist/cli/cli-port.js +82 -35
  20. package/dist/cli/cli-verify.d.ts +21 -0
  21. package/dist/cli/cli-verify.js +47 -3
  22. package/dist/cli/config-discovery.d.ts +35 -0
  23. package/dist/cli/config-discovery.js +163 -0
  24. package/dist/cli/doctor-daemon-line.js +4 -1
  25. package/dist/cli/doctor-project-line.d.ts +37 -0
  26. package/dist/cli/doctor-project-line.js +35 -0
  27. package/dist/cli/doctor-rows.d.ts +51 -0
  28. package/dist/cli/doctor-rows.js +54 -0
  29. package/dist/cli/doctor-sessions-line.d.ts +12 -0
  30. package/dist/cli/doctor-sessions-line.js +69 -0
  31. package/dist/cli/drive-attach.d.ts +99 -0
  32. package/dist/cli/drive-attach.js +203 -0
  33. package/dist/cli/drive-command.d.ts +13 -0
  34. package/dist/cli/drive-command.js +103 -0
  35. package/dist/cli/open-note.d.ts +20 -0
  36. package/dist/cli/open-note.js +37 -0
  37. package/dist/cli/port-holder.d.ts +10 -0
  38. package/dist/cli/port-holder.js +26 -2
  39. package/dist/cli/status-next-action.d.ts +37 -0
  40. package/dist/cli/status-next-action.js +61 -0
  41. package/dist/cli.js +119 -39
  42. package/dist/crawl/crawl-tools.js +12 -2
  43. package/dist/crawl/crawl.d.ts +13 -0
  44. package/dist/crawl/crawl.js +50 -2
  45. package/dist/events/contradictions.d.ts +46 -0
  46. package/dist/events/contradictions.js +35 -22
  47. package/dist/events/declared.d.ts +45 -0
  48. package/dist/events/declared.js +73 -0
  49. package/dist/events/echo-mismatch.d.ts +8 -2
  50. package/dist/events/echo-mismatch.js +33 -5
  51. package/dist/events/event-filters.d.ts +1 -1
  52. package/dist/events/event-filters.js +7 -4
  53. package/dist/events/predicate-eval.d.ts +17 -72
  54. package/dist/events/predicate-eval.js +117 -236
  55. package/dist/events/predicate-parse.js +31 -3
  56. package/dist/events/predicate-route.d.ts +32 -0
  57. package/dist/events/predicate-route.js +102 -0
  58. package/dist/events/predicate-schema.d.ts +99 -0
  59. package/dist/events/predicate-schema.js +309 -0
  60. package/dist/events/predicate.d.ts +8 -0
  61. package/dist/events/predicate.js +296 -22
  62. package/dist/flows/annotate-tools.js +3 -0
  63. package/dist/flows/annotate.js +18 -4
  64. package/dist/flows/coverage.d.ts +38 -5
  65. package/dist/flows/coverage.js +30 -4
  66. package/dist/flows/decision.d.ts +15 -0
  67. package/dist/flows/decision.js +1 -1
  68. package/dist/flows/flow-classify.js +1 -1
  69. package/dist/flows/flow-replay-run.js +8 -1
  70. package/dist/flows/flow-tools.js +21 -5
  71. package/dist/flows/heal-run.js +1 -1
  72. package/dist/flows/replay.js +3 -0
  73. package/dist/honesty/accepted-write.d.ts +3 -0
  74. package/dist/honesty/accepted-write.js +8 -3
  75. package/dist/honesty/already-true.js +15 -0
  76. package/dist/honesty/blind-spots.js +10 -2
  77. package/dist/honesty/honesty.d.ts +11 -0
  78. package/dist/honesty/honesty.js +1 -0
  79. package/dist/honesty/uncaptured-bodies.d.ts +4 -2
  80. package/dist/honesty/uncaptured-bodies.js +9 -2
  81. package/dist/honesty/unread-outcome.d.ts +8 -0
  82. package/dist/honesty/unread-outcome.js +36 -9
  83. package/dist/honesty/unsettled.d.ts +49 -0
  84. package/dist/honesty/unsettled.js +82 -0
  85. package/dist/honesty/verified.d.ts +36 -3
  86. package/dist/honesty/verified.js +54 -11
  87. package/dist/http-server.d.ts +19 -0
  88. package/dist/http-server.js +202 -4
  89. package/dist/impact/impact-recorder.d.ts +23 -0
  90. package/dist/impact/impact-recorder.js +76 -0
  91. package/dist/impact/impact-store.d.ts +47 -0
  92. package/dist/impact/impact-store.js +171 -0
  93. package/dist/index.d.ts +7 -0
  94. package/dist/index.js +47 -10
  95. package/dist/init/agent-rules.d.ts +36 -0
  96. package/dist/init/agent-rules.js +132 -22
  97. package/dist/init/astro-patch.d.ts +2 -1
  98. package/dist/init/astro-patch.js +38 -6
  99. package/dist/init/capabilities.d.ts +22 -0
  100. package/dist/init/capabilities.js +76 -0
  101. package/dist/init/confirm.d.ts +114 -0
  102. package/dist/init/confirm.js +165 -0
  103. package/dist/init/connect-steps.d.ts +2 -0
  104. package/dist/init/connect-steps.js +50 -0
  105. package/dist/init/cra.js +3 -2
  106. package/dist/init/csp-check.d.ts +38 -0
  107. package/dist/init/csp-check.js +92 -0
  108. package/dist/init/csp-doctor.d.ts +25 -0
  109. package/dist/init/csp-doctor.js +49 -0
  110. package/dist/init/cursor.js +6 -9
  111. package/dist/init/detect.d.ts +8 -0
  112. package/dist/init/detect.js +33 -3
  113. package/dist/init/mcp-clients.d.ts +1 -0
  114. package/dist/init/mcp-clients.js +51 -7
  115. package/dist/init/mcp.d.ts +15 -0
  116. package/dist/init/mcp.js +19 -0
  117. package/dist/init/non-js-project.js +29 -5
  118. package/dist/init/plan-framework.d.ts +10 -0
  119. package/dist/init/plan-framework.js +78 -16
  120. package/dist/init/plan.d.ts +47 -6
  121. package/dist/init/plan.js +228 -69
  122. package/dist/init/run.d.ts +24 -2
  123. package/dist/init/run.js +157 -45
  124. package/dist/init/slash-command.d.ts +1 -1
  125. package/dist/init/slash-command.js +54 -6
  126. package/dist/init/snippets.d.ts +45 -5
  127. package/dist/init/snippets.js +194 -42
  128. package/dist/init/vite-config.js +53 -5
  129. package/dist/input/network-mock-tools.d.ts +19 -0
  130. package/dist/input/network-mock-tools.js +37 -5
  131. package/dist/input/scroll-find.d.ts +6 -0
  132. package/dist/input/scroll-find.js +17 -2
  133. package/dist/input/scroll-tools.js +4 -0
  134. package/dist/journal/attach-journal.d.ts +9 -1
  135. package/dist/journal/attach-journal.js +13 -2
  136. package/dist/journal/journal-query.d.ts +13 -0
  137. package/dist/journal/journal-query.js +28 -3
  138. package/dist/journal/workspace-gitignore.d.ts +10 -0
  139. package/dist/journal/workspace-gitignore.js +59 -0
  140. package/dist/license/license.js +1 -1
  141. package/dist/mcp/mcp-proxy.d.ts +35 -30
  142. package/dist/mcp/mcp-proxy.js +306 -104
  143. package/dist/mcp/mcp.d.ts +10 -1
  144. package/dist/mcp/mcp.js +70 -34
  145. package/dist/mcp/proxy-handshake.d.ts +82 -1
  146. package/dist/mcp/proxy-handshake.js +109 -2
  147. package/dist/mcp/proxy-lifecycle.d.ts +11 -2
  148. package/dist/mcp/proxy-lifecycle.js +12 -3
  149. package/dist/mcp/proxy-log.d.ts +63 -0
  150. package/dist/mcp/proxy-log.js +132 -0
  151. package/dist/mcp/server-instructions.d.ts +17 -0
  152. package/dist/mcp/server-instructions.js +56 -0
  153. package/dist/mcp/shared-params.d.ts +27 -0
  154. package/dist/mcp/shared-params.js +36 -0
  155. package/dist/pool/browser-pool.d.ts +22 -2
  156. package/dist/pool/browser-pool.js +44 -4
  157. package/dist/project/learned-routes.d.ts +24 -0
  158. package/dist/project/learned-routes.js +63 -0
  159. package/dist/project/project-store.d.ts +6 -0
  160. package/dist/project/project-store.js +25 -1
  161. package/dist/project/reticle-dir.d.ts +2 -0
  162. package/dist/project/reticle-dir.js +1 -0
  163. package/dist/session/connection-memory.d.ts +59 -0
  164. package/dist/session/connection-memory.js +108 -0
  165. package/dist/session/dev-command.d.ts +26 -0
  166. package/dist/session/dev-command.js +89 -0
  167. package/dist/session/dev-server-probe.d.ts +68 -0
  168. package/dist/session/dev-server-probe.js +102 -7
  169. package/dist/session/last-act.d.ts +26 -0
  170. package/dist/session/last-act.js +35 -1
  171. package/dist/session/live-control-tools.js +42 -5
  172. package/dist/session/no-session-diagnosis.d.ts +67 -2
  173. package/dist/session/no-session-diagnosis.js +236 -27
  174. package/dist/session/no-session-next-action.d.ts +46 -0
  175. package/dist/session/no-session-next-action.js +85 -0
  176. package/dist/session/no-session-watch.d.ts +40 -1
  177. package/dist/session/no-session-watch.js +174 -8
  178. package/dist/session/pending-commands.d.ts +12 -0
  179. package/dist/session/pending-commands.js +13 -1
  180. package/dist/session/ref-provenance.d.ts +54 -0
  181. package/dist/session/ref-provenance.js +61 -0
  182. package/dist/session/session-lease.js +1 -1
  183. package/dist/session/session-manager.d.ts +4 -0
  184. package/dist/session/session-manager.js +35 -5
  185. package/dist/session/session.d.ts +10 -1
  186. package/dist/session/session.js +146 -5
  187. package/dist/telemetry/app-instrumented.d.ts +8 -0
  188. package/dist/telemetry/app-instrumented.js +10 -0
  189. package/dist/telemetry/automation-hint.d.ts +17 -0
  190. package/dist/telemetry/automation-hint.js +71 -0
  191. package/dist/telemetry/bug-found.d.ts +15 -17
  192. package/dist/telemetry/bug-found.js +52 -21
  193. package/dist/telemetry/cli-telemetry.js +7 -1
  194. package/dist/telemetry/daemon-telemetry.js +24 -0
  195. package/dist/telemetry/feedback.js +1 -1
  196. package/dist/telemetry/init-telemetry.js +9 -1
  197. package/dist/telemetry/install-source.d.ts +56 -0
  198. package/dist/telemetry/install-source.js +74 -0
  199. package/dist/telemetry/instrumentation-stall.d.ts +50 -0
  200. package/dist/telemetry/instrumentation-stall.js +90 -0
  201. package/dist/telemetry/run-telemetry.js +4 -1
  202. package/dist/telemetry/session-metrics.d.ts +8 -0
  203. package/dist/telemetry/session-metrics.js +15 -0
  204. package/dist/telemetry/telemetry.d.ts +7 -1
  205. package/dist/telemetry/telemetry.js +26 -2
  206. package/dist/telemetry/tool-refused.d.ts +19 -0
  207. package/dist/telemetry/tool-refused.js +55 -0
  208. package/dist/telemetry/verification-of.js +46 -7
  209. package/dist/temp-dir.d.ts +27 -0
  210. package/dist/temp-dir.js +46 -0
  211. package/dist/tools/act-tools.js +209 -28
  212. package/dist/tools/assert-grade.d.ts +1 -1
  213. package/dist/tools/assert-grade.js +27 -5
  214. package/dist/tools/assert-verdict.js +49 -7
  215. package/dist/tools/browser-tools.js +10 -0
  216. package/dist/tools/contract-tools.js +4 -0
  217. package/dist/tools/dynamic-tools.js +16 -7
  218. package/dist/tools/error-recovery.d.ts +10 -0
  219. package/dist/tools/error-recovery.js +73 -9
  220. package/dist/tools/feedback-invite.js +1 -1
  221. package/dist/tools/invoke-tool.js +159 -26
  222. package/dist/tools/lease-hint.d.ts +30 -8
  223. package/dist/tools/lease-hint.js +65 -14
  224. package/dist/tools/lease-tools.d.ts +40 -1
  225. package/dist/tools/lease-tools.js +181 -83
  226. package/dist/tools/navigate-result.js +1 -1
  227. package/dist/tools/observe-tools.js +10 -4
  228. package/dist/tools/observed-nothing.js +1 -1
  229. package/dist/tools/react-grace.d.ts +52 -0
  230. package/dist/tools/react-grace.js +90 -0
  231. package/dist/tools/read-tools.js +67 -5
  232. package/dist/tools/resolve-target.d.ts +33 -0
  233. package/dist/tools/resolve-target.js +53 -0
  234. package/dist/tools/settle-in-flight.d.ts +94 -0
  235. package/dist/tools/settle-in-flight.js +146 -0
  236. package/dist/tools/snapshot-delta.d.ts +10 -0
  237. package/dist/tools/snapshot-delta.js +194 -9
  238. package/dist/tools/tool-kit.d.ts +8 -0
  239. package/dist/tools/tool-surface.d.ts +60 -0
  240. package/dist/tools/tool-surface.js +86 -1
  241. package/dist/tools/tools.js +61 -8
  242. package/dist/tools/unadvertised-help.js +9 -6
  243. package/docs/README.md +55 -11
  244. package/docs/actions.mdx +120 -0
  245. package/docs/agent-cheatsheet.md +95 -29
  246. package/docs/architecture.md +52 -35
  247. package/docs/benchmarks.md +127 -75
  248. package/docs/best-practices.mdx +216 -0
  249. package/docs/capabilities.mdx +534 -0
  250. package/docs/cli/affected.mdx +53 -0
  251. package/docs/cli/capsules.mdx +70 -0
  252. package/docs/cli/cloud.mdx +153 -0
  253. package/docs/cli/doctor.mdx +109 -0
  254. package/docs/cli/drive.mdx +74 -0
  255. package/docs/cli/feedback.mdx +67 -0
  256. package/docs/cli/gate.mdx +64 -0
  257. package/docs/cli/help.mdx +94 -0
  258. package/docs/cli/hunt.mdx +73 -0
  259. package/docs/cli/identify.mdx +76 -0
  260. package/docs/cli/init.mdx +121 -0
  261. package/docs/cli/kill.mdx +91 -0
  262. package/docs/cli/license.mdx +48 -0
  263. package/docs/cli/mcp.mdx +61 -0
  264. package/docs/cli/open.mdx +74 -0
  265. package/docs/cli/restart.mdx +60 -0
  266. package/docs/cli/rollback.mdx +35 -0
  267. package/docs/cli/serve.mdx +69 -0
  268. package/docs/cli/status.mdx +88 -0
  269. package/docs/cli/stop.mdx +64 -0
  270. package/docs/cli/telemetry.mdx +57 -0
  271. package/docs/cli/update.mdx +52 -0
  272. package/docs/cli/verify.mdx +76 -0
  273. package/docs/cli/version.mdx +39 -0
  274. package/docs/cli/watch.mdx +62 -0
  275. package/docs/cli.mdx +241 -0
  276. package/docs/debugging.md +16 -10
  277. package/docs/deploy-checks.md +45 -12
  278. package/docs/desktop.mdx +384 -0
  279. package/docs/docs.json +364 -0
  280. package/docs/enterprise.md +22 -16
  281. package/docs/faq.mdx +102 -0
  282. package/docs/fixtures.md +13 -7
  283. package/docs/flows.md +29 -23
  284. package/docs/for-agents.md +71 -0
  285. package/docs/frameworks.mdx +238 -0
  286. package/docs/gate-plan.md +48 -42
  287. package/docs/gates.md +28 -22
  288. package/docs/getting-started.md +129 -77
  289. package/docs/human-control.md +24 -18
  290. package/docs/index.mdx +143 -0
  291. package/docs/install-agentic.mdx +224 -0
  292. package/docs/install-manual.mdx +196 -0
  293. package/docs/instrumentation.mdx +238 -0
  294. package/docs/integration-patterns.md +41 -35
  295. package/docs/local-registry.md +19 -13
  296. package/docs/multi-agent-testing.md +19 -13
  297. package/docs/packages/babel-plugin.mdx +79 -0
  298. package/docs/packages/browser.mdx +111 -0
  299. package/docs/packages/core.mdx +116 -0
  300. package/docs/packages/electron.mdx +85 -0
  301. package/docs/packages/eslint-plugin.mdx +86 -0
  302. package/docs/packages/next.mdx +89 -0
  303. package/docs/packages/react.mdx +91 -0
  304. package/docs/packages/server.mdx +98 -0
  305. package/docs/packages/tauri.mdx +63 -0
  306. package/docs/packages/test.mdx +119 -0
  307. package/docs/packages/vite-plugin.mdx +94 -0
  308. package/docs/packages.mdx +218 -0
  309. package/docs/platform-integration.md +35 -26
  310. package/docs/predicates.mdx +248 -0
  311. package/docs/quickstart.mdx +257 -0
  312. package/docs/recipes.mdx +282 -0
  313. package/docs/skill-file.mdx +107 -0
  314. package/docs/state-management.mdx +199 -0
  315. package/docs/style.css +14 -0
  316. package/docs/system-map.md +50 -33
  317. package/docs/telemetry-contract.md +106 -52
  318. package/docs/telemetry-events.mdx +475 -0
  319. package/docs/telemetry.md +63 -55
  320. package/docs/testing.md +44 -6
  321. package/docs/token-efficiency.md +37 -27
  322. package/docs/tools-act-and-wait.mdx +173 -0
  323. package/docs/tools-act-sequence.mdx +137 -0
  324. package/docs/tools-act.mdx +95 -0
  325. package/docs/tools-assert.mdx +129 -0
  326. package/docs/tools-console.mdx +63 -0
  327. package/docs/tools-inspect.mdx +88 -0
  328. package/docs/tools-navigate.mdx +99 -0
  329. package/docs/tools-network.mdx +119 -0
  330. package/docs/tools-observe.mdx +145 -0
  331. package/docs/tools-overview.mdx +160 -0
  332. package/docs/tools-query.mdx +126 -0
  333. package/docs/tools-session-and-feedback.mdx +112 -0
  334. package/docs/tools-sessions.mdx +94 -0
  335. package/docs/tools-snapshot.mdx +129 -0
  336. package/docs/tools-state.mdx +104 -0
  337. package/docs/tools-tools-and-run.mdx +156 -0
  338. package/docs/tools-wait-for.mdx +85 -0
  339. package/docs/troubleshooting.mdx +263 -0
  340. package/docs/usage.md +209 -165
  341. package/docs/vs-chrome-devtools-mcp.mdx +123 -0
  342. package/docs/vs-playwright-mcp.mdx +125 -0
  343. package/docs/vs-screenshots.mdx +137 -0
  344. package/docs/what-is-reticle.mdx +63 -0
  345. package/docs/why-reticle.mdx +109 -0
  346. package/package.json +5 -5
  347. package/docs/desktop-apps.md +0 -256
  348. package/docs/matrix/2.5.0/claude-code-machine.json +0 -19
  349. package/docs/matrix/2.5.0/codex-machine.json +0 -20
  350. package/docs/matrix/2.5.0/cursor-machine.json +0 -22
  351. package/docs/matrix/2.5.0/gemini-machine.json +0 -22
  352. package/docs/matrix/2.5.0/opencode-machine.json +0 -22
  353. package/docs/matrix/2.5.0/vscode-machine.json +0 -22
  354. package/docs/matrix/2.5.0/windsurf-machine.json +0 -22
  355. package/docs/matrix/MATRIX.md +0 -26
  356. package/docs/matrix/README.md +0 -59
  357. package/docs/mint.json +0 -61
@@ -11,6 +11,15 @@ export interface SharedServer {
11
11
  attachMcp(factory: () => McpServer): void;
12
12
  /** Register the JSON the daemon returns from GET /status (live sessions + health for `reticle status`). */
13
13
  attachStatus(provider: () => unknown): void;
14
+ /**
15
+ * Register the handler behind POST /drive: open a driveable browser at `url` and answer with the
16
+ * session it registered.
17
+ *
18
+ * This is how `reticle drive` stops fighting the daemon for the bridge port. The daemon already
19
+ * owns the browser plane, so driving is a request to it — see cli/drive-attach.ts. Left
20
+ * unattached, the route 404s, which is exactly what a newer CLI needs to hear from an older daemon.
21
+ */
22
+ attachDrive(provider: (url: string) => Promise<unknown>): void;
14
23
  /**
15
24
  * Register a callback fired when the AGENT presence changes — true when the first MCP client (any
16
25
  * agent: Codex/OpenCode/Claude/Hermes) connects, false when the last one disconnects. Agent-
@@ -18,6 +27,15 @@ export interface SharedServer {
18
27
  * "is an agent attached?". The daemon uses this to tell the panel "agent live" vs "agent stopped".
19
28
  */
20
29
  attachAgentPresence(cb: (connected: boolean) => void): void;
30
+ /**
31
+ * Tell every attached MCP proxy that this daemon is retiring, BEFORE the sockets go.
32
+ *
33
+ * The proxy sees a clean stream end whether the daemon shut down on schedule or died under it, so
34
+ * without this the two are one row and the transport-stability metric is dominated by the designed
35
+ * case. Separate from `close()` because it must run before the shutdown work rather than during it:
36
+ * `close()` is also the cleanup path of a start that failed, where there is nobody to tell.
37
+ */
38
+ announceShutdown(): void;
21
39
  close(): Promise<void>;
22
40
  }
23
41
  /**
@@ -32,3 +50,4 @@ export interface SharedServer {
32
50
  export declare function createSharedServer(options?: {
33
51
  token?: string;
34
52
  }): SharedServer;
53
+ export declare function driveSchemeRefusal(url: string): string | undefined;
@@ -1,6 +1,6 @@
1
1
  import * as http from 'node:http';
2
2
  import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
3
- import { MCP_SSE_PATH, MCP_MESSAGE_PATH, STATUS_PATH } from '@reticlehq/core';
3
+ import { MCP_SSE_PATH, MCP_MESSAGE_PATH, MCP_SHUTDOWN_EVENT, STATUS_PATH, DRIVE_PATH, } from '@reticlehq/core';
4
4
  import { getSessionMetrics } from './telemetry/session-metrics.js';
5
5
  import { noteAgentPeer, PEER_VERSION_PARAM, PEER_CONTRACT_PARAM } from './version/peer-announce.js';
6
6
  import { log } from './log.js';
@@ -18,8 +18,15 @@ import { isLoopbackHost, isLocalWebOrigin, isLoopbackPeer, requestToken, tokensM
18
18
  export function createSharedServer(options = {}) {
19
19
  let mcpFactory;
20
20
  let statusProvider;
21
+ let driveProvider;
21
22
  let agentPresence;
22
23
  const transports = new Map();
24
+ /**
25
+ * The raw response behind each SSE session, so the shutdown announcement can be written as an SSE
26
+ * frame the proxy reads and the agent never sees. `SSEServerTransport.send` only speaks JSON-RPC,
27
+ * and this message is addressed to the proxy rather than to the client behind it.
28
+ */
29
+ const sseStreams = new Map();
23
30
  const token = options.token;
24
31
  // The agent control plane (MCP transport) and /status carry the same trust as the browser WS: a
25
32
  // loopback peer is trusted (the local stdio proxy and `reticle status` always dial 127.0.0.1), but any
@@ -45,9 +52,23 @@ export function createSharedServer(options = {}) {
45
52
  };
46
53
  const httpServer = http.createServer((req, res) => {
47
54
  const rawUrl = req.url ?? '/';
48
- const url = new URL(rawUrl, 'http://localhost');
55
+ // Node's HTTP parser accepts request targets the WHATWG URL parser refuses (`//` among them), and
56
+ // a throw in a `request` listener is an uncaughtException — which the daemon answers by exiting,
57
+ // taking every agent and every browser session on this port with it, from whichever project. One
58
+ // malformed line on a loopback socket, and it lands BEFORE the authorization check below, so
59
+ // nothing had to be trusted to send it.
60
+ const url = parseRequestTarget(rawUrl);
61
+ if (null === url) {
62
+ log('http_bad_request_target', {});
63
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
64
+ res.end('bad request target');
65
+ return;
66
+ }
49
67
  const path = url.pathname;
50
- if (path === STATUS_PATH || path === MCP_SSE_PATH || path === MCP_MESSAGE_PATH) {
68
+ if (path === STATUS_PATH ||
69
+ path === DRIVE_PATH ||
70
+ path === MCP_SSE_PATH ||
71
+ path === MCP_MESSAGE_PATH) {
51
72
  if (!authorized(req, url)) {
52
73
  res.writeHead(401, { 'Content-Type': 'text/plain' });
53
74
  res.end('unauthorized');
@@ -60,6 +81,58 @@ export function createSharedServer(options = {}) {
60
81
  res.end(body);
61
82
  return;
62
83
  }
84
+ if ('POST' === req.method && path === DRIVE_PATH) {
85
+ if (driveProvider === undefined) {
86
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
87
+ res.end('not found');
88
+ return;
89
+ }
90
+ // A state-changing route must require a content type that a browser cannot send without a
91
+ // preflight. `text/plain`, `application/x-www-form-urlencoded` and `multipart/form-data` make
92
+ // a POST a CORS SIMPLE request: the browser sends it cross-origin with no preflight, and
93
+ // blocking the response afterwards does not un-drive the browser. Since loopback origins are
94
+ // authorized here by design, any page on any localhost port could otherwise reach this route
95
+ // by guessing ours, and drive a browser to a URL it chose.
96
+ //
97
+ // Requiring `application/json` forces a preflight, which the browser will not complete without
98
+ // our opt-in, and we do not opt in. One header check closes the whole class.
99
+ if (!isJsonRequest(req)) {
100
+ res.writeHead(415, { 'Content-Type': 'text/plain' });
101
+ res.end('expected content-type: application/json');
102
+ return;
103
+ }
104
+ const provider = driveProvider;
105
+ readBody(req)
106
+ .then(async (body) => {
107
+ const url = urlFromDriveRequest(body);
108
+ if (url === undefined) {
109
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
110
+ res.end('drive requires a url');
111
+ return;
112
+ }
113
+ // Before the browser is asked for anything. See driveSchemeRefusal.
114
+ const refusal = driveSchemeRefusal(url);
115
+ if (refusal !== undefined) {
116
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
117
+ res.end(refusal);
118
+ return;
119
+ }
120
+ // A refusal is an ANSWER. The caller is a foreground CLI waiting on this socket: a thrown
121
+ // handler that took the response down with it would hang `reticle drive` on a request
122
+ // that can never complete — the same shape of silence the raw EADDRINUSE bind had.
123
+ const payload = await provider(url).catch((err) => ({
124
+ error: err instanceof Error ? err.message : String(err),
125
+ }));
126
+ res.writeHead(200, { 'Content-Type': 'application/json' });
127
+ res.end(JSON.stringify(payload));
128
+ })
129
+ .catch((err) => {
130
+ log('drive_request_error', { error: err instanceof Error ? err.message : String(err) });
131
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
132
+ res.end('drive failed');
133
+ });
134
+ return;
135
+ }
63
136
  if ('GET' === req.method && path === MCP_SSE_PATH) {
64
137
  // The agent's MCP server announces itself here, on the connect it already makes. The daemon is
65
138
  // the single judge of skew, so this is where the third pair is decided — and unlike the CLI's
@@ -79,10 +152,12 @@ export function createSharedServer(options = {}) {
79
152
  const transport = new SSEServerTransport(MCP_MESSAGE_PATH, res);
80
153
  const sid = transport.sessionId;
81
154
  transports.set(sid, transport);
155
+ sseStreams.set(sid, res);
82
156
  if (1 === transports.size)
83
157
  agentPresence?.(true); // first agent attached
84
158
  res.on('close', () => {
85
159
  transports.delete(sid);
160
+ sseStreams.delete(sid);
86
161
  transport.close().catch(() => undefined);
87
162
  mcpServer.close().catch(() => undefined);
88
163
  log('mcp_client_disconnected', { sessionId: sid });
@@ -137,17 +212,33 @@ export function createSharedServer(options = {}) {
137
212
  function attachStatus(provider) {
138
213
  statusProvider = provider;
139
214
  }
215
+ function attachDrive(provider) {
216
+ driveProvider = provider;
217
+ }
140
218
  function attachAgentPresence(cb) {
141
219
  agentPresence = cb;
142
220
  }
143
221
  function attachMcp(factory) {
144
222
  mcpFactory = factory;
145
223
  }
224
+ function announceShutdown() {
225
+ for (const [sid, stream] of sseStreams) {
226
+ try {
227
+ stream.write(`event: ${MCP_SHUTDOWN_EVENT}\ndata: {}\n\n`);
228
+ }
229
+ catch {
230
+ // A stream we cannot write to is a proxy that already left. Announcing a shutdown must never
231
+ // be the reason a shutdown fails.
232
+ log('mcp_shutdown_announce_failed', { sessionId: sid });
233
+ }
234
+ }
235
+ }
146
236
  async function close() {
147
237
  for (const transport of transports.values()) {
148
238
  await transport.close();
149
239
  }
150
240
  transports.clear();
241
+ sseStreams.clear();
151
242
  await new Promise((resolve, reject) => {
152
243
  httpServer.close((err) => {
153
244
  // A server that never listened is already closed, and saying so is not an error worth
@@ -167,5 +258,112 @@ export function createSharedServer(options = {}) {
167
258
  });
168
259
  });
169
260
  }
170
- return { httpServer, attachMcp, attachStatus, attachAgentPresence, close };
261
+ return {
262
+ httpServer,
263
+ attachMcp,
264
+ attachStatus,
265
+ attachDrive,
266
+ attachAgentPresence,
267
+ announceShutdown,
268
+ close,
269
+ };
270
+ }
271
+ /** Cap on a drive request body. A url is short; anything larger is not one. */
272
+ const MAX_DRIVE_BODY_BYTES = 8 * 1024;
273
+ /** Collect a request body, refusing one too large to be what this route accepts. */
274
+ /**
275
+ * Is this a request a cross-origin page could NOT have sent without a preflight?
276
+ *
277
+ * The three CORS "simple" content types are the whole attack surface here, so anything that is not
278
+ * JSON is refused rather than sniffed. Parameters are allowed (`application/json; charset=utf-8`)
279
+ * because real clients send them.
280
+ */
281
+ const JSON_MEDIA_TYPE = 'application/json';
282
+ function isJsonRequest(req) {
283
+ // Read through the typed accessor rather than indexing the header bag: the index signature is
284
+ // `any`-shaped, and narrowing after an unsafe read is narrowing a lie.
285
+ const value = req.headersDistinct['content-type']?.[0];
286
+ if (undefined === value)
287
+ return false;
288
+ return JSON_MEDIA_TYPE === value.split(';')[0]?.trim().toLowerCase();
289
+ }
290
+ function readBody(req) {
291
+ return new Promise((resolve, reject) => {
292
+ let body = '';
293
+ req.setEncoding('utf8');
294
+ req.on('data', (chunk) => {
295
+ body += chunk;
296
+ if (body.length > MAX_DRIVE_BODY_BYTES) {
297
+ req.destroy();
298
+ reject(new Error('body too large'));
299
+ }
300
+ });
301
+ req.on('end', () => resolve(body));
302
+ req.on('error', reject);
303
+ });
304
+ }
305
+ /** The `url` out of a drive request body, or undefined when there isn't a usable one. Pure. */
306
+ /**
307
+ * The schemes the daemon will open in a browser it owns.
308
+ *
309
+ * `drive` exists to drive a running web app, and nothing in the product hands this route anything
310
+ * else — the desktop paths have their own runtimes and never come through here. So the allowlist
311
+ * costs nothing real and closes a class outright.
312
+ *
313
+ * Kept deliberately small. `file:` reads the disk through a browser context WE opened, `data:` is a
314
+ * page that came from nowhere, and `javascript:` is not a document at all. None of them is a thing
315
+ * anybody drives.
316
+ *
317
+ * Worth stating what this is NOT: the CORS defence on the route already keeps a web page off it,
318
+ * because a JSON content type forces a preflight nobody opts into. The caller reaching this check is
319
+ * a local process, which already has broader access than the check removes. It is hygiene about what
320
+ * the daemon will open on request, not a security boundary — and a refusal here should read as a
321
+ * refusal, not an alarm.
322
+ */
323
+ const DRIVEABLE_SCHEMES = ['http:', 'https:'];
324
+ /** The scheme of `url`, or undefined when it is not a URL at all. */
325
+ function schemeOf(url) {
326
+ try {
327
+ return new URL(url).protocol;
328
+ }
329
+ catch {
330
+ return undefined;
331
+ }
332
+ }
333
+ export function driveSchemeRefusal(url) {
334
+ const scheme = schemeOf(url);
335
+ if (scheme !== undefined && DRIVEABLE_SCHEMES.includes(scheme))
336
+ return undefined;
337
+ // Names what it got, because the caller is a CLI printing this to somebody who typed the url and
338
+ // needs to see which part of it was the problem.
339
+ return (`unsupported scheme ${scheme ?? '(unparseable url)'} — reticle drive opens a running web app, ` +
340
+ `so it accepts ${DRIVEABLE_SCHEMES.join(' and ')} only`);
341
+ }
342
+ function urlFromDriveRequest(body) {
343
+ let parsed;
344
+ try {
345
+ parsed = JSON.parse(body);
346
+ }
347
+ catch {
348
+ return undefined;
349
+ }
350
+ if (typeof parsed !== 'object' || null === parsed)
351
+ return undefined;
352
+ const url = parsed['url'];
353
+ return 'string' === typeof url && url.length > 0 ? url : undefined;
354
+ }
355
+ /**
356
+ * The one URL parse on the daemon's request path, and the only place allowed to do it.
357
+ *
358
+ * `null` for anything the parser refuses, so the caller answers rather than throwing. Exported for
359
+ * nothing — it is here, next to its single caller, because a second copy of this parse elsewhere in
360
+ * the handler would reintroduce the crash the guard exists to remove.
361
+ */
362
+ function parseRequestTarget(rawUrl) {
363
+ try {
364
+ return new URL(rawUrl, 'http://localhost');
365
+ }
366
+ catch {
367
+ return null;
368
+ }
171
369
  }
@@ -0,0 +1,23 @@
1
+ import { type ImpactCounts, type ImpactSnapshot } from '@reticlehq/core';
2
+ import { ImpactStore, type ImpactFoldMeta } from './impact-store.js';
3
+ /** Wire the recorder to a project. Idempotent; the first root wins for the daemon's lifetime. */
4
+ export declare function initImpact(opts: {
5
+ reticleRoot: string | undefined;
6
+ projectName?: string;
7
+ now?: () => number;
8
+ }): ImpactStore | undefined;
9
+ /** The live store, if one has been initialised. */
10
+ export declare function impactStore(): ImpactStore | undefined;
11
+ /** Test seam: drop the singleton so a suite can start from a clean record. */
12
+ export declare function resetImpactForTest(): void;
13
+ /** Record one delta, if the recorder is live. Never throws: stats must not break a tool call. */
14
+ export declare function recordImpact(delta: Partial<ImpactCounts>, meta?: ImpactFoldMeta): void;
15
+ export declare function impactSnapshot(): ImpactSnapshot | undefined;
16
+ /**
17
+ * What one tool call did, as counters.
18
+ *
19
+ * Pure and exported so the shape is testable without a dispatch: given a result and a duration, it
20
+ * says what the record should gain. `verified` is the only field that can mint a verdict, which is
21
+ * the same rule the product states to agents - everything else moves or reads the app.
22
+ */
23
+ export declare function deltaForToolResult(raw: unknown, durationMs: number, refused: boolean): Partial<ImpactCounts>;
@@ -0,0 +1,76 @@
1
+ import { Verified } from '@reticlehq/core';
2
+ import { estimateTokens } from '../session/output-budget.js';
3
+ import { ImpactStore } from './impact-store.js';
4
+ /**
5
+ * The daemon's single impact recorder.
6
+ *
7
+ * One store per daemon, created on the first call that knows the project's `.reticle` root. A
8
+ * module singleton for the same reason the tool counters are: the dispatch chokepoint has to be
9
+ * able to record without every tool threading a store through its arguments, and a second recorder
10
+ * would silently split the user's own history in half.
11
+ */
12
+ let store;
13
+ /** Wire the recorder to a project. Idempotent; the first root wins for the daemon's lifetime. */
14
+ export function initImpact(opts) {
15
+ // No root, no record. Programmatic callers and test doubles build their own deps and are not
16
+ // obliged to carry one, and a courtesy counter must never be the reason a tool call throws.
17
+ if (opts.reticleRoot === undefined || 0 === opts.reticleRoot.length)
18
+ return store;
19
+ store ??= new ImpactStore({ ...opts, reticleRoot: opts.reticleRoot });
20
+ return store;
21
+ }
22
+ /** The live store, if one has been initialised. */
23
+ export function impactStore() {
24
+ return store;
25
+ }
26
+ /** Test seam: drop the singleton so a suite can start from a clean record. */
27
+ export function resetImpactForTest() {
28
+ store = undefined;
29
+ }
30
+ /** Record one delta, if the recorder is live. Never throws: stats must not break a tool call. */
31
+ export function recordImpact(delta, meta = {}) {
32
+ try {
33
+ store?.record(delta, meta);
34
+ }
35
+ catch {
36
+ // Counting is a courtesy to the user; failing to count is not worth failing their call over.
37
+ }
38
+ }
39
+ export function impactSnapshot() {
40
+ return store?.snapshot();
41
+ }
42
+ /**
43
+ * What one tool call did, as counters.
44
+ *
45
+ * Pure and exported so the shape is testable without a dispatch: given a result and a duration, it
46
+ * says what the record should gain. `verified` is the only field that can mint a verdict, which is
47
+ * the same rule the product states to agents - everything else moves or reads the app.
48
+ */
49
+ export function deltaForToolResult(raw, durationMs, refused) {
50
+ const delta = {
51
+ calls: 1,
52
+ drivingMs: Math.max(0, Math.round(durationMs)),
53
+ tokensReturned: estimateTokens(JSON.stringify(raw) ?? ''),
54
+ };
55
+ if (refused) {
56
+ delta.refusals = 1;
57
+ return delta;
58
+ }
59
+ const verified = isRecord(raw) ? raw['verified'] : undefined;
60
+ if (verified === Verified.YES) {
61
+ delta.verdicts = 1;
62
+ delta.passed = 1;
63
+ }
64
+ else if (verified === Verified.NO) {
65
+ delta.verdicts = 1;
66
+ delta.failed = 1;
67
+ }
68
+ else if (verified === Verified.UNKNOWN) {
69
+ delta.verdicts = 1;
70
+ delta.unknown = 1;
71
+ }
72
+ return delta;
73
+ }
74
+ function isRecord(value) {
75
+ return 'object' === typeof value && null !== value;
76
+ }
@@ -0,0 +1,47 @@
1
+ import { type ImpactCounts, type ImpactScope, type ImpactSnapshot } from '@reticlehq/core';
2
+ export interface ImpactPaths {
3
+ project: string;
4
+ global: string;
5
+ }
6
+ /**
7
+ * `reticleRoot` is the project's own `.reticle` directory (what every other store here is handed);
8
+ * the global scope always lives beside the daemon's own state in `~/.reticle`.
9
+ */
10
+ export declare function impactPaths(reticleRoot: string): ImpactPaths;
11
+ /** Read a scope, tolerating absence and corruption: a broken file starts over rather than throwing. */
12
+ export declare function readScope(path: string, now: number): ImpactScope;
13
+ /** YYYY-MM-DD in local time - the day boundary a person recognises, not UTC's. */
14
+ export declare function isoDay(now: number): string;
15
+ /**
16
+ * What a fold knows beyond the counters.
17
+ *
18
+ * `runMs` is a SESSION's lifetime, and it only ever feeds the "longest run" record. It is not a
19
+ * counter: adding it to a total would answer a question nobody asked, and the record used to be fed
20
+ * a single tool call's duration instead, which made the report's superlative a number of
21
+ * milliseconds that never grew past one click.
22
+ */
23
+ export interface ImpactFoldMeta {
24
+ runMs?: number;
25
+ }
26
+ /** Fold one delta into a scope: totals, today's bucket, records and streak. Pure. */
27
+ export declare function applyDelta(scope: ImpactScope, delta: Partial<ImpactCounts>, now: number, meta?: ImpactFoldMeta): ImpactScope;
28
+ /**
29
+ * The live impact record for one project.
30
+ *
31
+ * Holds both scopes in memory, folds deltas in synchronously (so a reader always sees the truth),
32
+ * and flushes to disk on a debounce.
33
+ */
34
+ export declare class ImpactStore {
35
+ #private;
36
+ constructor(opts: {
37
+ reticleRoot: string;
38
+ projectName?: string;
39
+ now?: () => number;
40
+ });
41
+ /** Notified after every fold, so the HUD can be pushed a fresh snapshot. */
42
+ onChange(fn: () => void): void;
43
+ record(delta: Partial<ImpactCounts>, meta?: ImpactFoldMeta): void;
44
+ snapshot(): ImpactSnapshot;
45
+ /** Write now (process exit, tests). */
46
+ flush(): void;
47
+ }
@@ -0,0 +1,171 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { ReticleDir, IMPACT_DAILY_BUCKETS, IMPACT_SCHEMA_VERSION, ImpactScopeSchema, addImpactCounts, emptyImpactCounts, emptyImpactRecords, estimateImpactSavings, } from '@reticlehq/core';
5
+ /**
6
+ * Where Reticle keeps the record of what it has done for you.
7
+ *
8
+ * Two scopes, two files, both local and never uploaded: the project's own `.reticle/impact.json`,
9
+ * and a machine-wide `~/.reticle/impact.json` that answers "what has Reticle done for me overall"
10
+ * across every app you have instrumented.
11
+ *
12
+ * This is NOT telemetry. Telemetry answers our questions about the product and leaves the machine;
13
+ * this answers the user's question about their own work and never does.
14
+ */
15
+ /** Writes are debounced: a verification loop is 50-200 calls, and each one is a counter bump. */
16
+ const WRITE_DEBOUNCE_MS = 800;
17
+ /**
18
+ * `reticleRoot` is the project's own `.reticle` directory (what every other store here is handed);
19
+ * the global scope always lives beside the daemon's own state in `~/.reticle`.
20
+ */
21
+ export function impactPaths(reticleRoot) {
22
+ return {
23
+ project: join(reticleRoot, ReticleDir.IMPACT_FILE),
24
+ global: join(homedir(), ReticleDir.ROOT, ReticleDir.IMPACT_FILE),
25
+ };
26
+ }
27
+ function emptyScope(now) {
28
+ const counts = emptyImpactCounts();
29
+ return {
30
+ counts,
31
+ days: [],
32
+ records: emptyImpactRecords(),
33
+ savings: estimateImpactSavings(counts),
34
+ since: now,
35
+ };
36
+ }
37
+ /** Read a scope, tolerating absence and corruption: a broken file starts over rather than throwing. */
38
+ export function readScope(path, now) {
39
+ try {
40
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
41
+ const result = ImpactScopeSchema.safeParse(parsed);
42
+ return result.success ? result.data : emptyScope(now);
43
+ }
44
+ catch {
45
+ return emptyScope(now);
46
+ }
47
+ }
48
+ /**
49
+ * Write a scope atomically.
50
+ *
51
+ * tmp + rename, because two daemons can serve two apps in the same repo at once and a half-written
52
+ * counters file is one the next read discards - silently losing the whole history.
53
+ */
54
+ function writeScope(path, scope) {
55
+ try {
56
+ mkdirSync(dirname(path), { recursive: true });
57
+ const tmp = `${path}.${String(process.pid)}.tmp`;
58
+ writeFileSync(tmp, JSON.stringify(scope, null, 2), 'utf8');
59
+ renameSync(tmp, path);
60
+ }
61
+ catch {
62
+ // A stats file that cannot be written must never break a tool call. The counters stay in
63
+ // memory and the next write attempt carries them.
64
+ }
65
+ }
66
+ /** YYYY-MM-DD in local time - the day boundary a person recognises, not UTC's. */
67
+ export function isoDay(now) {
68
+ const d = new Date(now);
69
+ const month = String(d.getMonth() + 1).padStart(2, '0');
70
+ const day = String(d.getDate()).padStart(2, '0');
71
+ return `${String(d.getFullYear())}-${month}-${day}`;
72
+ }
73
+ /** Whether `b` is the calendar day right after `a` - the streak rule. */
74
+ function isNextDay(a, b) {
75
+ const prev = new Date(`${a}T00:00:00`);
76
+ const next = new Date(`${b}T00:00:00`);
77
+ return 1 === Math.round((next.getTime() - prev.getTime()) / 86_400_000);
78
+ }
79
+ /** Fold one delta into a scope: totals, today's bucket, records and streak. Pure. */
80
+ export function applyDelta(scope, delta, now, meta = {}) {
81
+ const today = isoDay(now);
82
+ const counts = addImpactCounts(scope.counts, delta);
83
+ const days = scope.days.slice();
84
+ const last = days[days.length - 1];
85
+ if (last !== undefined && last.date === today) {
86
+ days[days.length - 1] = { date: today, counts: addImpactCounts(last.counts, delta) };
87
+ }
88
+ else {
89
+ days.push({ date: today, counts: addImpactCounts(emptyImpactCounts(), delta) });
90
+ }
91
+ while (days.length > IMPACT_DAILY_BUCKETS)
92
+ days.shift();
93
+ const todayCounts = days[days.length - 1]?.counts ?? emptyImpactCounts();
94
+ const previousDay = days[days.length - 2];
95
+ const records = { ...scope.records };
96
+ records.bestVerdictDay = Math.max(records.bestVerdictDay, todayCounts.verdicts);
97
+ records.bestDefectDay = Math.max(records.bestDefectDay, todayCounts.failed);
98
+ if (last === undefined || last.date !== today) {
99
+ // A new day joins the streak only if it follows yesterday; otherwise it starts a new one.
100
+ records.streakDays =
101
+ previousDay !== undefined && isNextDay(previousDay.date, today) ? records.streakDays + 1 : 1;
102
+ }
103
+ else if (0 === records.streakDays) {
104
+ records.streakDays = 1;
105
+ }
106
+ records.bestStreakDays = Math.max(records.bestStreakDays, records.streakDays);
107
+ records.longestRunMs = Math.max(records.longestRunMs, meta.runMs ?? 0);
108
+ return { counts, days, records, savings: estimateImpactSavings(counts), since: scope.since };
109
+ }
110
+ /**
111
+ * The live impact record for one project.
112
+ *
113
+ * Holds both scopes in memory, folds deltas in synchronously (so a reader always sees the truth),
114
+ * and flushes to disk on a debounce.
115
+ */
116
+ export class ImpactStore {
117
+ #paths;
118
+ #now;
119
+ #projectName;
120
+ #project;
121
+ #global;
122
+ #timer;
123
+ #onChange;
124
+ constructor(opts) {
125
+ this.#paths = impactPaths(opts.reticleRoot);
126
+ this.#now = opts.now ?? (() => Date.now());
127
+ this.#projectName = opts.projectName;
128
+ const now = this.#now();
129
+ this.#project = readScope(this.#paths.project, now);
130
+ this.#global = readScope(this.#paths.global, now);
131
+ }
132
+ /** Notified after every fold, so the HUD can be pushed a fresh snapshot. */
133
+ onChange(fn) {
134
+ this.#onChange = fn;
135
+ }
136
+ record(delta, meta = {}) {
137
+ const now = this.#now();
138
+ this.#project = applyDelta(this.#project, delta, now, meta);
139
+ this.#global = applyDelta(this.#global, delta, now, meta);
140
+ this.#scheduleFlush();
141
+ this.#onChange?.();
142
+ }
143
+ snapshot() {
144
+ const snap = {
145
+ schemaVersion: IMPACT_SCHEMA_VERSION,
146
+ project: this.#project,
147
+ global: this.#global,
148
+ };
149
+ if (this.#projectName !== undefined)
150
+ snap.projectName = this.#projectName;
151
+ return snap;
152
+ }
153
+ /** Write now (process exit, tests). */
154
+ flush() {
155
+ if (this.#timer !== undefined) {
156
+ clearTimeout(this.#timer);
157
+ this.#timer = undefined;
158
+ }
159
+ writeScope(this.#paths.project, this.#project);
160
+ writeScope(this.#paths.global, this.#global);
161
+ }
162
+ #scheduleFlush() {
163
+ if (this.#timer !== undefined)
164
+ return;
165
+ this.#timer = setTimeout(() => {
166
+ this.#timer = undefined;
167
+ this.flush();
168
+ }, WRITE_DEBOUNCE_MS);
169
+ this.#timer.unref?.();
170
+ }
171
+ }
package/dist/index.d.ts CHANGED
@@ -123,6 +123,13 @@ export interface RunningServer {
123
123
  agentAttached?: () => boolean;
124
124
  /** The pairing token the bridge is enforcing (explicit, env, or auto-provisioned); undefined if none. */
125
125
  token?: string;
126
+ /**
127
+ * Tell every attached MCP proxy this daemon is retiring, before anything closes.
128
+ *
129
+ * Optional because only the daemon entry point serves MCP over SSE — `start()` speaks stdio, where
130
+ * the client owns the process and there is no stream to warn. See SharedServer.announceShutdown.
131
+ */
132
+ announceShutdown?: () => void;
126
133
  close: () => Promise<void>;
127
134
  }
128
135
  export { resolveBridgeSecurity } from './bridge/bridge-security.js';