@reticlehq/server 2.12.0 → 2.13.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 (386) hide show
  1. package/SKILL.md +67 -109
  2. package/dist/bridge/auth-failure-reason.d.ts +6 -2
  3. package/dist/bridge/auth-failure-reason.js +15 -8
  4. package/dist/bridge/bridge.d.ts +18 -1
  5. package/dist/bridge/bridge.js +247 -164
  6. package/dist/bridge/pairing-token.d.ts +5 -0
  7. package/dist/bridge/pairing-token.js +28 -0
  8. package/dist/bridge/protocol-skew.d.ts +37 -0
  9. package/dist/bridge/protocol-skew.js +45 -0
  10. package/dist/capsule/capsule-store.d.ts +6 -6
  11. package/dist/capsule/causal-summary.js +7 -2
  12. package/dist/cli/cli-doctor.js +31 -1
  13. package/dist/cli/cli-launch.d.ts +11 -1
  14. package/dist/cli/cli-launch.js +59 -10
  15. package/dist/cli/cli-parse.d.ts +15 -1
  16. package/dist/cli/cli-parse.js +212 -8
  17. package/dist/cli/cli-verify.d.ts +1 -0
  18. package/dist/cli/cli-verify.js +3 -2
  19. package/dist/cli/cloud-cli.js +428 -259
  20. package/dist/cli/cloud-keystore.d.ts +37 -0
  21. package/dist/cli/cloud-keystore.js +97 -0
  22. package/dist/cli/cloud-kit.d.ts +37 -0
  23. package/dist/cli/cloud-kit.js +121 -0
  24. package/dist/cli/cloud-login.d.ts +17 -0
  25. package/dist/cli/cloud-login.js +183 -0
  26. package/dist/cli/cloud-session.d.ts +48 -0
  27. package/dist/cli/cloud-session.js +53 -0
  28. package/dist/cli/config-discovery.js +1 -0
  29. package/dist/cli/daemon-start-options.d.ts +32 -0
  30. package/dist/cli/daemon-start-options.js +38 -0
  31. package/dist/cli/device-flow.d.ts +76 -0
  32. package/dist/cli/device-flow.js +56 -0
  33. package/dist/cli/doctor-rows.d.ts +9 -0
  34. package/dist/cli/doctor-rows.js +9 -0
  35. package/dist/cli/drive-attach.d.ts +13 -0
  36. package/dist/cli/drive-attach.js +18 -0
  37. package/dist/cli/drive-command.js +7 -3
  38. package/dist/cli/mcp-command.js +51 -20
  39. package/dist/cli/project-name.d.ts +30 -0
  40. package/dist/cli/project-name.js +44 -0
  41. package/dist/cli/status-next-action.d.ts +14 -0
  42. package/dist/cli/status-next-action.js +4 -4
  43. package/dist/cli.d.ts +1 -0
  44. package/dist/cli.js +134 -80
  45. package/dist/cloud/cloud-config.d.ts +22 -1
  46. package/dist/cloud/cloud-config.js +64 -5
  47. package/dist/cloud/cloud-sync.d.ts +5 -5
  48. package/dist/cloud/cloud-sync.js +33 -2
  49. package/dist/cloud/sync-cycle.d.ts +110 -0
  50. package/dist/cloud/sync-cycle.js +270 -0
  51. package/dist/cloud/sync-daemon.d.ts +69 -0
  52. package/dist/cloud/sync-daemon.js +260 -0
  53. package/dist/cloud/sync-disk.d.ts +9 -0
  54. package/dist/cloud/sync-disk.js +191 -0
  55. package/dist/cloud/sync-hash.d.ts +2 -0
  56. package/dist/cloud/sync-hash.js +35 -0
  57. package/dist/crawl/crawl-tools.js +5 -3
  58. package/dist/crawl/crawl.d.ts +3 -0
  59. package/dist/crawl/crawl.js +5 -0
  60. package/dist/daemon/daemon-resolve.d.ts +43 -0
  61. package/dist/daemon/daemon-resolve.js +69 -0
  62. package/dist/daemon/dev-servers.d.ts +15 -0
  63. package/dist/daemon/dev-servers.js +68 -0
  64. package/dist/daemon/wake-decision.d.ts +32 -0
  65. package/dist/daemon/wake-decision.js +38 -0
  66. package/dist/domain/domain-tools.js +2 -2
  67. package/dist/events/contradictions.d.ts +18 -0
  68. package/dist/events/contradictions.js +134 -21
  69. package/dist/events/declared.d.ts +7 -4
  70. package/dist/events/declared.js +15 -6
  71. package/dist/events/echo-mismatch.js +64 -14
  72. package/dist/events/event-filters.d.ts +8 -0
  73. package/dist/events/event-filters.js +19 -2
  74. package/dist/events/predicate-eval.js +53 -5
  75. package/dist/events/predicate-precheck.d.ts +8 -0
  76. package/dist/events/predicate-precheck.js +41 -0
  77. package/dist/events/predicate-route.d.ts +14 -0
  78. package/dist/events/predicate-route.js +36 -8
  79. package/dist/events/predicate.d.ts +5 -0
  80. package/dist/events/predicate.js +37 -5
  81. package/dist/events/split-text-miss.d.ts +30 -0
  82. package/dist/events/split-text-miss.js +44 -0
  83. package/dist/flows/flake.d.ts +6 -6
  84. package/dist/flows/flow-memory-consult.d.ts +55 -0
  85. package/dist/flows/flow-memory-consult.js +38 -0
  86. package/dist/flows/flow-replay-run.d.ts +18 -7
  87. package/dist/flows/flow-replay-run.js +239 -22
  88. package/dist/flows/flow-replay.js +11 -2
  89. package/dist/flows/flow-step-runners.d.ts +8 -0
  90. package/dist/flows/flow-step-runners.js +8 -2
  91. package/dist/flows/flow-store-for-session.d.ts +31 -0
  92. package/dist/flows/flow-store-for-session.js +33 -0
  93. package/dist/flows/flow-tools.js +58 -32
  94. package/dist/flows/flows.d.ts +8 -0
  95. package/dist/flows/flows.js +468 -2
  96. package/dist/flows/heal-run.js +5 -2
  97. package/dist/flows/predicate-to-expect.d.ts +17 -12
  98. package/dist/flows/predicate-to-expect.js +30 -12
  99. package/dist/flows/recordings.d.ts +13 -1
  100. package/dist/flows/recordings.js +17 -2
  101. package/dist/flows/replay.d.ts +22 -2
  102. package/dist/flows/replay.js +28 -2
  103. package/dist/flows/verify-change-tools.js +5 -2
  104. package/dist/honesty/blind-spots.d.ts +55 -2
  105. package/dist/honesty/blind-spots.js +63 -3
  106. package/dist/honesty/honesty.d.ts +24 -0
  107. package/dist/honesty/honesty.js +25 -1
  108. package/dist/honesty/instrumentation-gaps.d.ts +9 -0
  109. package/dist/honesty/instrumentation-gaps.js +29 -2
  110. package/dist/honesty/verified.d.ts +2 -1
  111. package/dist/honesty/verified.js +16 -1
  112. package/dist/http-server.js +12 -0
  113. package/dist/impact/impact-recorder.d.ts +31 -8
  114. package/dist/impact/impact-recorder.js +104 -21
  115. package/dist/impact/impact-store.d.ts +15 -1
  116. package/dist/impact/impact-store.js +43 -2
  117. package/dist/index.js +113 -7
  118. package/dist/init/agent-rules.d.ts +13 -6
  119. package/dist/init/agent-rules.js +23 -12
  120. package/dist/init/astro-patch.d.ts +16 -0
  121. package/dist/init/astro-patch.js +28 -0
  122. package/dist/init/capabilities.d.ts +4 -1
  123. package/dist/init/capabilities.js +22 -2
  124. package/dist/init/confirm.d.ts +31 -7
  125. package/dist/init/confirm.js +48 -8
  126. package/dist/init/cra.d.ts +20 -4
  127. package/dist/init/cra.js +57 -17
  128. package/dist/init/csp-check.js +17 -2
  129. package/dist/init/csp-doctor.d.ts +8 -1
  130. package/dist/init/csp-doctor.js +8 -3
  131. package/dist/init/dev-script.d.ts +3 -1
  132. package/dist/init/dev-script.js +8 -5
  133. package/dist/init/format-generated.d.ts +18 -0
  134. package/dist/init/format-generated.js +39 -0
  135. package/dist/init/install-hint.d.ts +9 -0
  136. package/dist/init/install-hint.js +29 -0
  137. package/dist/init/mcp-clients.d.ts +7 -0
  138. package/dist/init/mcp-clients.js +7 -0
  139. package/dist/init/mcp.d.ts +1 -1
  140. package/dist/init/mcp.js +1 -1
  141. package/dist/init/node-io.js +11 -1
  142. package/dist/init/plan-framework.js +81 -45
  143. package/dist/init/plan.d.ts +20 -7
  144. package/dist/init/plan.js +20 -45
  145. package/dist/init/preflight.d.ts +28 -0
  146. package/dist/init/preflight.js +38 -0
  147. package/dist/init/run.d.ts +32 -9
  148. package/dist/init/run.js +141 -119
  149. package/dist/init/slash-command.d.ts +1 -1
  150. package/dist/init/snippets.js +41 -25
  151. package/dist/init/vite-config.d.ts +1 -1
  152. package/dist/init/vite-config.js +39 -25
  153. package/dist/init/workspace-apps.d.ts +17 -10
  154. package/dist/init/workspace-apps.js +110 -0
  155. package/dist/input/network-mock-tools.js +3 -6
  156. package/dist/input/real-input.js +25 -5
  157. package/dist/input/scroll-tools.js +4 -6
  158. package/dist/input/viewport-tools.js +5 -4
  159. package/dist/intent/inline-intent.d.ts +10 -19
  160. package/dist/intent/inline-intent.js +91 -1
  161. package/dist/intent/intent-shard-store.d.ts +72 -0
  162. package/dist/intent/intent-shard-store.js +203 -0
  163. package/dist/intent/intent-shard.d.ts +411 -0
  164. package/dist/intent/intent-shard.js +176 -0
  165. package/dist/intent/intent-store.d.ts +12 -0
  166. package/dist/intent/intent-store.js +21 -0
  167. package/dist/intent/intent-subject.d.ts +45 -0
  168. package/dist/intent/intent-subject.js +177 -0
  169. package/dist/intent/intent-tools.js +65 -2
  170. package/dist/intent/open-intents.d.ts +21 -0
  171. package/dist/intent/open-intents.js +15 -0
  172. package/dist/license/license-env.d.ts +29 -0
  173. package/dist/license/license-env.js +148 -0
  174. package/dist/loopback-agent.d.ts +31 -0
  175. package/dist/loopback-agent.js +57 -0
  176. package/dist/mcp/attach-memory.d.ts +18 -2
  177. package/dist/mcp/attach-memory.js +50 -7
  178. package/dist/mcp/client-identity.d.ts +31 -0
  179. package/dist/mcp/client-identity.js +51 -0
  180. package/dist/mcp/mcp-post-transport.d.ts +26 -0
  181. package/dist/mcp/mcp-post-transport.js +109 -0
  182. package/dist/mcp/mcp-proxy.d.ts +2 -1
  183. package/dist/mcp/mcp-proxy.js +52 -62
  184. package/dist/mcp/mcp.js +7 -1
  185. package/dist/mcp/proxy-backoff.d.ts +11 -0
  186. package/dist/mcp/proxy-backoff.js +13 -0
  187. package/dist/mcp/proxy-daemon-probe.d.ts +1 -0
  188. package/dist/mcp/proxy-daemon-probe.js +7 -1
  189. package/dist/mcp/proxy-handshake.d.ts +23 -0
  190. package/dist/mcp/proxy-handshake.js +31 -0
  191. package/dist/mcp/tool-catalog-cache.d.ts +8 -0
  192. package/dist/mcp/tool-catalog-cache.js +12 -0
  193. package/dist/memory/memory-tools.d.ts +2 -0
  194. package/dist/memory/memory-tools.js +82 -0
  195. package/dist/memory/project-memory.d.ts +34 -0
  196. package/dist/memory/project-memory.js +96 -0
  197. package/dist/oracles/propose-consequences.js +12 -3
  198. package/dist/pool/browser-pool.d.ts +63 -4
  199. package/dist/pool/browser-pool.js +113 -4
  200. package/dist/pool/playwright-launcher.js +9 -0
  201. package/dist/project/project-tools.js +3 -3
  202. package/dist/project/reticle-dir.d.ts +15 -2
  203. package/dist/project/reticle-dir.js +21 -4
  204. package/dist/runs/run-store.d.ts +10 -0
  205. package/dist/runs/run-store.js +10 -0
  206. package/dist/runs/verification-sync.d.ts +0 -11
  207. package/dist/runs/verification-sync.js +22 -1
  208. package/dist/runs/verify-server.d.ts +8 -1
  209. package/dist/runs/verify-server.js +14 -2
  210. package/dist/session/command-timeout.d.ts +5 -2
  211. package/dist/session/command-timeout.js +5 -4
  212. package/dist/session/lease-visibility.d.ts +67 -0
  213. package/dist/session/lease-visibility.js +76 -0
  214. package/dist/session/no-session-diagnosis.d.ts +23 -0
  215. package/dist/session/no-session-diagnosis.js +58 -17
  216. package/dist/session/no-session-next-action.d.ts +7 -0
  217. package/dist/session/no-session-next-action.js +42 -3
  218. package/dist/session/no-session-watch.d.ts +10 -6
  219. package/dist/session/no-session-watch.js +51 -19
  220. package/dist/session/pending-commands.d.ts +11 -1
  221. package/dist/session/pending-commands.js +12 -2
  222. package/dist/session/resolve-within.d.ts +34 -0
  223. package/dist/session/resolve-within.js +53 -0
  224. package/dist/session/session-info.d.ts +18 -0
  225. package/dist/session/session-info.js +11 -0
  226. package/dist/session/session-manager.d.ts +14 -0
  227. package/dist/session/session-manager.js +61 -3
  228. package/dist/session/session-mirror.d.ts +16 -0
  229. package/dist/session/session-mirror.js +22 -0
  230. package/dist/session/session-recommendation.d.ts +19 -5
  231. package/dist/session/session-recommendation.js +25 -9
  232. package/dist/session/session-successor.js +36 -1
  233. package/dist/session/session-tools.js +2 -2
  234. package/dist/session/session.d.ts +22 -2
  235. package/dist/session/session.js +87 -7
  236. package/dist/session/wall-clock.d.ts +12 -0
  237. package/dist/session/wall-clock.js +14 -0
  238. package/dist/setup/agent-configs.d.ts +96 -0
  239. package/dist/setup/agent-configs.js +333 -0
  240. package/dist/setup/agent-io.d.ts +9 -0
  241. package/dist/setup/agent-io.js +16 -0
  242. package/dist/setup/agent-writer.d.ts +51 -0
  243. package/dist/setup/agent-writer.js +115 -0
  244. package/dist/setup/approval-migration.d.ts +47 -0
  245. package/dist/setup/approval-migration.js +68 -0
  246. package/dist/setup/auto-approve.d.ts +85 -0
  247. package/dist/setup/auto-approve.js +150 -0
  248. package/dist/setup/bridge-port.d.ts +15 -0
  249. package/dist/setup/bridge-port.js +24 -0
  250. package/dist/setup/desktop-shape.d.ts +57 -0
  251. package/dist/setup/desktop-shape.js +63 -0
  252. package/dist/setup/dev-server-wait.d.ts +60 -0
  253. package/dist/setup/dev-server-wait.js +65 -0
  254. package/dist/setup/drive-agent.d.ts +71 -0
  255. package/dist/setup/drive-agent.js +171 -0
  256. package/dist/setup/drive-plan.d.ts +68 -0
  257. package/dist/setup/drive-plan.js +130 -0
  258. package/dist/setup/ensure-daemon.d.ts +29 -0
  259. package/dist/setup/ensure-daemon.js +58 -0
  260. package/dist/setup/init-runtime.d.ts +47 -0
  261. package/dist/setup/init-runtime.js +160 -0
  262. package/dist/setup/interrupt.d.ts +25 -0
  263. package/dist/setup/interrupt.js +40 -0
  264. package/dist/setup/license-key.d.ts +39 -0
  265. package/dist/setup/license-key.js +72 -0
  266. package/dist/setup/listeners.d.ts +37 -0
  267. package/dist/setup/listeners.js +85 -0
  268. package/dist/setup/node-effects.d.ts +60 -0
  269. package/dist/setup/node-effects.js +210 -0
  270. package/dist/setup/page-probe.d.ts +39 -0
  271. package/dist/setup/page-probe.js +49 -0
  272. package/dist/setup/relaunch.d.ts +36 -0
  273. package/dist/setup/relaunch.js +57 -0
  274. package/dist/setup/remaining-steps.d.ts +19 -0
  275. package/dist/setup/remaining-steps.js +31 -0
  276. package/dist/setup/run-setup.d.ts +99 -0
  277. package/dist/setup/run-setup.js +187 -0
  278. package/dist/setup/session-pick.d.ts +47 -0
  279. package/dist/setup/session-pick.js +51 -0
  280. package/dist/setup/setup-command.d.ts +45 -0
  281. package/dist/setup/setup-command.js +199 -0
  282. package/dist/setup/setup-options.d.ts +74 -0
  283. package/dist/setup/setup-options.js +67 -0
  284. package/dist/setup/transcripts.d.ts +16 -0
  285. package/dist/setup/transcripts.js +43 -0
  286. package/dist/status-payload.d.ts +14 -1
  287. package/dist/status-payload.js +23 -1
  288. package/dist/telemetry/daemon-telemetry.d.ts +8 -1
  289. package/dist/telemetry/daemon-telemetry.js +52 -5
  290. package/dist/telemetry/error-class.js +1 -1
  291. package/dist/telemetry/feedback-context.d.ts +9 -1
  292. package/dist/telemetry/feedback-context.js +60 -29
  293. package/dist/telemetry/feedback-local.d.ts +15 -0
  294. package/dist/telemetry/feedback-local.js +56 -0
  295. package/dist/telemetry/feedback.d.ts +11 -0
  296. package/dist/telemetry/feedback.js +19 -6
  297. package/dist/telemetry/init-telemetry.d.ts +2 -0
  298. package/dist/telemetry/init-telemetry.js +2 -0
  299. package/dist/telemetry/install-source.d.ts +14 -0
  300. package/dist/telemetry/install-source.js +30 -0
  301. package/dist/telemetry/license-activation.d.ts +7 -1
  302. package/dist/telemetry/license-activation.js +9 -5
  303. package/dist/telemetry/mcp-connection.js +7 -0
  304. package/dist/telemetry/project-profile.d.ts +12 -1
  305. package/dist/telemetry/project-profile.js +11 -6
  306. package/dist/telemetry/proxy-telemetry.d.ts +3 -0
  307. package/dist/telemetry/proxy-telemetry.js +21 -0
  308. package/dist/telemetry/session-metrics.d.ts +12 -0
  309. package/dist/telemetry/session-metrics.js +35 -1
  310. package/dist/telemetry/telemetry.js +17 -2
  311. package/dist/telemetry/tool-refused.d.ts +3 -1
  312. package/dist/telemetry/tool-refused.js +34 -2
  313. package/dist/tools/act-capsule.d.ts +10 -0
  314. package/dist/tools/act-capsule.js +8 -2
  315. package/dist/tools/act-danger.d.ts +2 -1
  316. package/dist/tools/act-danger.js +11 -3
  317. package/dist/tools/act-observation.d.ts +41 -0
  318. package/dist/tools/act-observation.js +36 -0
  319. package/dist/tools/act-preflight.d.ts +20 -0
  320. package/dist/tools/act-preflight.js +98 -0
  321. package/dist/tools/act-sequence-retry.d.ts +45 -0
  322. package/dist/tools/act-sequence-retry.js +58 -0
  323. package/dist/tools/act-sequence-tool.d.ts +13 -0
  324. package/dist/tools/act-sequence-tool.js +135 -0
  325. package/dist/tools/act-target.d.ts +14 -0
  326. package/dist/tools/act-target.js +47 -0
  327. package/dist/tools/act-tools.d.ts +21 -1
  328. package/dist/tools/act-tools.js +100 -213
  329. package/dist/tools/assert-verdict.js +22 -16
  330. package/dist/tools/browser-tools.js +6 -2
  331. package/dist/tools/dynamic-tools.js +24 -5
  332. package/dist/tools/error-recovery.d.ts +1 -0
  333. package/dist/tools/error-recovery.js +8 -0
  334. package/dist/tools/invoke-tool.js +59 -9
  335. package/dist/tools/lease-tools.d.ts +30 -4
  336. package/dist/tools/lease-tools.js +169 -5
  337. package/dist/tools/merged-name-redirect.d.ts +13 -0
  338. package/dist/tools/merged-name-redirect.js +22 -0
  339. package/dist/tools/numeric-bounds.d.ts +100 -0
  340. package/dist/tools/numeric-bounds.js +125 -0
  341. package/dist/tools/observe-tools.js +47 -40
  342. package/dist/tools/read-tools.js +28 -7
  343. package/dist/tools/real-input-attempt.d.ts +10 -0
  344. package/dist/tools/real-input-attempt.js +59 -1
  345. package/dist/tools/reconcile-tools.js +4 -4
  346. package/dist/tools/tool-kit.d.ts +12 -0
  347. package/dist/tools/tool-kit.js +6 -1
  348. package/dist/tools/tool-names.d.ts +2 -0
  349. package/dist/tools/tool-names.js +2 -0
  350. package/dist/tools/tools-helpers.d.ts +19 -0
  351. package/dist/tools/tools-helpers.js +46 -0
  352. package/dist/tools/tools.d.ts +2 -0
  353. package/dist/tools/tools.js +56 -8
  354. package/dist/update/reticle-deps.d.ts +9 -0
  355. package/dist/update/reticle-deps.js +27 -0
  356. package/dist/update/sdk-sync.d.ts +1 -2
  357. package/dist/update/sdk-sync.js +1 -21
  358. package/dist/update/update-nudge.d.ts +18 -0
  359. package/dist/update/update-nudge.js +20 -0
  360. package/dist/version/peer-announce.js +4 -2
  361. package/dist/version/sdk-fix.d.ts +47 -0
  362. package/dist/version/sdk-fix.js +127 -0
  363. package/dist/version/version-nudge.d.ts +14 -0
  364. package/dist/version/version-nudge.js +21 -0
  365. package/dist/version/version-skew.d.ts +38 -3
  366. package/dist/version/version-skew.js +83 -7
  367. package/dist/visual/visual-store.d.ts +6 -6
  368. package/dist/visual/visual-store.js +18 -16
  369. package/dist/visual/visual-tools.js +57 -12
  370. package/docs/cli/doctor.mdx +1 -0
  371. package/docs/cli/serve.mdx +4 -2
  372. package/docs/cli.mdx +7 -3
  373. package/docs/desktop.mdx +1 -1
  374. package/docs/docs.json +1 -1
  375. package/docs/getting-started.md +19 -3
  376. package/docs/index.mdx +9 -6
  377. package/docs/install-agentic.mdx +38 -12
  378. package/docs/install-manual.mdx +1 -1
  379. package/docs/packages.mdx +1 -1
  380. package/docs/skill-file.mdx +3 -3
  381. package/docs/telemetry-contract.md +108 -4
  382. package/docs/tools-act-sequence.mdx +16 -0
  383. package/docs/usage.md +1 -1
  384. package/package.json +4 -3
  385. package/dist/init/cursor.d.ts +0 -24
  386. package/dist/init/cursor.js +0 -80
@@ -0,0 +1,270 @@
1
+ /**
2
+ * One full conversation with the dashboard: ask, send the difference, collect what came back.
3
+ *
4
+ * ── WHY IT LOOKS LIKE THIS ─────────────────────────────────────────────────────────────────────
5
+ * `.reticle/` is the source of truth and always will be. Reticle has to work with no account and no
6
+ * network; a verification that blocks on an HTTP round trip is one somebody switches off. So the
7
+ * dashboard is a REPLICA, and this is the replication protocol:
8
+ *
9
+ * 1. ASK GET /v1/sync/status → run ids it already holds, a content hash per derived record
10
+ * 2. SEND POST /v1/sync → only unseen runs, only records whose hash moved
11
+ * 3. COLLECT GET /v1/sync/pull → decisions a human made there, since our cursor
12
+ * 4. APPLY write them to .reticle/issues.json so the HUD and the next run can see them
13
+ *
14
+ * ASK-THEN-SEND, not send-everything. A three-hour session rewrites impact.json on every tool call;
15
+ * uploading it each time would re-send the same megabytes dozens of times, and a sync that costs
16
+ * that much is one people turn off — which is the real failure, because a dashboard nobody syncs to
17
+ * is a dashboard nobody opens. When nothing has moved, a cycle sends nothing at all, and that is
18
+ * what makes running it on a timer affordable.
19
+ *
20
+ * HASHES, NOT TIMESTAMPS. Two machines comparing their own clocks is a guess: they drift, and a
21
+ * laptop that was asleep will cheerfully decide it is current. A content hash is an answer.
22
+ *
23
+ * BOTH DIRECTIONS, ALWAYS. The pull runs even when there is nothing to push — a quiet machine is
24
+ * precisely the one whose dashboard somebody has been triaging on, so skipping the pull when the
25
+ * push is empty would starve the direction that matters most.
26
+ *
27
+ * NOTHING IS DELETED LOCALLY ON SUCCESS. A lost response costs one redundant upload rather than a
28
+ * hole in the record, and the server dedupes by run id anyway.
29
+ *
30
+ * Every dependency is injected — the clock, the fetch, the reads and writes — because this is the
31
+ * one piece of Reticle that talks to a network AND to a disk, and it must be provable without either.
32
+ */
33
+ import { ReticleDir } from '@reticlehq/core';
34
+ import { hashPayload } from './sync-hash.js';
35
+ /** The server's own doors. Kept beside the code that calls them, like the other cloud paths. */
36
+ const SYNC_PATH = '/v1/sync';
37
+ const SYNC_STATUS_PATH = '/v1/sync/status';
38
+ const SYNC_PULL_PATH = '/v1/sync/pull';
39
+ /**
40
+ * The derived records that ride along with runs, and the file each one lives in.
41
+ *
42
+ * A list rather than three hand-written blocks so adding a fourth is one line and cannot be
43
+ * half-done — the bundle, the hashing and the reporting all walk this.
44
+ */
45
+ export const DERIVED_RECORDS = [
46
+ { kind: 'impact', file: ReticleDir.IMPACT_FILE },
47
+ { kind: 'flake', file: ReticleDir.FLAKE_FILE },
48
+ { kind: 'intent', file: ReticleDir.INTENT_FILE },
49
+ ];
50
+ const asJson = (text) => {
51
+ try {
52
+ return JSON.parse(text);
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ };
58
+ const isRecord = (v) => 'object' === typeof v && null !== v;
59
+ /**
60
+ * Run one cycle. Never throws: the local record is already safe on disk, and a sync that can take
61
+ * down the thing it is backing up is worse than no sync.
62
+ */
63
+ export async function runSyncCycle(deps) {
64
+ const empty = {
65
+ ok: false,
66
+ runsSent: 0,
67
+ runsRejected: [],
68
+ flowsSent: 0,
69
+ derivedSent: [],
70
+ pulled: 0,
71
+ morePending: false,
72
+ };
73
+ const call = async (path, init) => {
74
+ const res = await deps.request(`${deps.config.url}${path}`, {
75
+ method: init?.method ?? 'GET',
76
+ headers: {
77
+ 'content-type': 'application/json',
78
+ authorization: `Bearer ${deps.config.apiKey}`,
79
+ },
80
+ ...(init?.body === undefined ? {} : { body: init.body }),
81
+ });
82
+ return { status: res.status, json: asJson(res.text), text: res.text };
83
+ };
84
+ const nextState = { ...deps.state };
85
+ try {
86
+ // 1. ASK.
87
+ const status = await call(SYNC_STATUS_PATH);
88
+ if (200 !== status.status) {
89
+ const error = `status ${String(status.status)}: ${status.text.slice(0, 200)}`;
90
+ deps.sink.writeState({ ...nextState, lastError: error });
91
+ return { ...empty, error };
92
+ }
93
+ const held = isRecord(status.json) ? status.json : {};
94
+ /*
95
+ * Validated, not trusted. `new Set(someString)` builds a set of CHARACTERS, so a `knownRunIds`
96
+ * that arrived as a string made `known.has('a')` true and silently skipped any run whose id was
97
+ * one of those letters — the client deciding, on the server's malformed word, not to upload
98
+ * something the server does not have. Silent data loss is the one failure this protocol must
99
+ * not have, and an unreadable answer has to mean "it knows nothing", never "it knows this".
100
+ */
101
+ const known = new Set(Array.isArray(held.knownRunIds)
102
+ ? held.knownRunIds.filter((id) => 'string' === typeof id)
103
+ : []);
104
+ // Same rule for the hashes: only a string can equal a hash we computed, so anything else reads
105
+ // as "unknown" and the record is sent once. Re-sending costs a request; skipping costs the data.
106
+ const hashes = isRecord(held.stateHashes) ? held.stateHashes : {};
107
+ // 2. SEND — only what the server does not already have.
108
+ const allRuns = deps.source.runs();
109
+ const unsent = allRuns.filter((r) => !known.has(r.runId));
110
+ const derivedSent = [];
111
+ const bundle = {};
112
+ if (unsent.length > 0)
113
+ bundle['runs'] = unsent.map((r) => r.payload);
114
+ for (const { kind } of DERIVED_RECORDS) {
115
+ const payload = deps.source.derived(kind);
116
+ if (payload === undefined)
117
+ continue;
118
+ // The one comparison the whole protocol rests on. Same hash, do not send it.
119
+ if (hashPayload(payload) === hashes[kind])
120
+ continue;
121
+ bundle[kind] = payload;
122
+ derivedSent.push(kind);
123
+ }
124
+ // Flows are small and upserted by name, so they ride along whenever anything else does rather
125
+ // than earning a round trip of their own.
126
+ const flows = bundle['runs'] === undefined && 0 === derivedSent.length ? [] : deps.source.flows();
127
+ if (flows.length > 0)
128
+ bundle['flows'] = flows;
129
+ let runsSent = 0;
130
+ let flowsSent = 0;
131
+ let runsRejected = [];
132
+ if (Object.keys(bundle).length > 0) {
133
+ const pushed = await call(SYNC_PATH, { method: 'POST', body: JSON.stringify(bundle) });
134
+ if (200 !== pushed.status) {
135
+ const error = `sync ${String(pushed.status)}: ${pushed.text.slice(0, 200)}`;
136
+ deps.sink.writeState({ ...nextState, lastError: error });
137
+ return { ...empty, error };
138
+ }
139
+ const body = isRecord(pushed.json) ? pushed.json : {};
140
+ const runs = isRecord(body['runs']) ? body['runs'] : {};
141
+ const flowsPart = isRecord(body['flows']) ? body['flows'] : {};
142
+ runsSent = 'number' === typeof runs['accepted'] ? runs['accepted'] : 0;
143
+ flowsSent = 'number' === typeof flowsPart['accepted'] ? flowsPart['accepted'] : 0;
144
+ runsRejected = Array.isArray(runs['rejected'])
145
+ ? runs['rejected']
146
+ : [];
147
+ nextState.lastPushAt = deps.now();
148
+ }
149
+ // 3. COLLECT — always, even when there was nothing to send.
150
+ const query = nextState.cursor === undefined ? '' : `?since=${encodeURIComponent(nextState.cursor)}`;
151
+ const pull = await call(`${SYNC_PULL_PATH}${query}`);
152
+ if (200 !== pull.status) {
153
+ // The push already landed; report it rather than throwing the whole cycle away.
154
+ const error = `pull ${String(pull.status)}: ${pull.text.slice(0, 200)}`;
155
+ deps.sink.writeState({ ...nextState, lastError: error });
156
+ return { ...empty, ok: false, runsSent, flowsSent, derivedSent, runsRejected, error };
157
+ }
158
+ const pulled = isRecord(pull.json) ? pull.json : {};
159
+ const decisions = pulled.triage ?? [];
160
+ // 4. APPLY.
161
+ if (decisions.length > 0) {
162
+ const triage = {};
163
+ for (const d of decisions) {
164
+ triage[d.fingerprint] = {
165
+ status: d.status,
166
+ flowName: d.flowName ?? null,
167
+ title: d.title ?? '',
168
+ at: d.at,
169
+ };
170
+ }
171
+ deps.sink.writeIssues({ triage });
172
+ }
173
+ // The cursor is written even on an empty page: it is the server's, and it never goes backwards.
174
+ if ('string' === typeof pulled.cursor)
175
+ nextState.cursor = pulled.cursor;
176
+ nextState.lastPullAt = deps.now();
177
+ delete nextState.lastError;
178
+ deps.sink.writeState(nextState);
179
+ return {
180
+ ok: true,
181
+ runsSent,
182
+ runsRejected,
183
+ flowsSent,
184
+ derivedSent,
185
+ pulled: decisions.length,
186
+ morePending: true === pulled.more,
187
+ /*
188
+ * Nothing on disk at all, as opposed to nothing NEW. Computed from what the source offered
189
+ * before any cursor filtering, because a repo whose runs were all already pushed is healthy
190
+ * and a repo that has never recorded one is usually misconfigured.
191
+ */
192
+ localIsEmpty: 0 === allRuns.length &&
193
+ 0 === deps.source.flows().length &&
194
+ DERIVED_RECORDS.every(({ kind }) => deps.source.derived(kind) === undefined),
195
+ };
196
+ }
197
+ catch (error) {
198
+ // A network that is down is not an error condition for a local-first tool; it is Tuesday.
199
+ const message = describeTransportError(error, deps.config.url);
200
+ deps.sink.writeState({ ...nextState, lastError: message });
201
+ return { ...empty, error: message };
202
+ }
203
+ }
204
+ /**
205
+ * Say WHICH host failed and WHY.
206
+ *
207
+ * Node's fetch rejects with the bare string "fetch failed" and hides the real reason — a refused
208
+ * connection, a bad hostname, an expired certificate — one level down in `cause`. A machine that
209
+ * has quietly stopped syncing shows that string in `reticle whoami`, and on its own it is
210
+ * indistinguishable from every other network problem, so the person reading it learns nothing and
211
+ * checks nothing. Naming the origin and the underlying code turns it into one thing to look at.
212
+ */
213
+ function describeTransportError(error, url) {
214
+ if (!(error instanceof Error))
215
+ return String(error);
216
+ const cause = error.cause;
217
+ if (!(cause instanceof Error))
218
+ return `${error.message} (${url})`;
219
+ const code = cause.code;
220
+ // The code and the message are each sometimes empty — an aggregate DNS failure carries a code and
221
+ // no text, a TLS failure the reverse. Joined only when both are there, so the line never trails
222
+ // off into a dangling separator.
223
+ const parts = ['string' === typeof code && code.length > 0 ? code : '', cause.message].filter((part) => part.length > 0);
224
+ const detail = parts.join(': ');
225
+ return 0 === detail.length
226
+ ? `${error.message} (${url})`
227
+ : `${error.message} — ${detail} (${url})`;
228
+ }
229
+ /** One line a human can read, for the CLI and the daemon log. */
230
+ export function describeSync(report) {
231
+ if (report.error !== undefined)
232
+ return `sync failed — ${report.error}`;
233
+ const sent = [];
234
+ if (report.runsSent > 0)
235
+ sent.push(`${String(report.runsSent)} run(s)`);
236
+ if (report.flowsSent > 0)
237
+ sent.push(`${String(report.flowsSent)} flow(s)`);
238
+ if (report.derivedSent.length > 0)
239
+ sent.push(report.derivedSent.join(', '));
240
+ /*
241
+ * "Nothing to send" is a statement about the QUEUE, and it is false the moment the queue was full
242
+ * and the server threw it away. Built from what was accepted, the sentence used to read "nothing
243
+ * to send, 3 rejected" — something was very much sent, and the reader is told both that it was
244
+ * not and nothing about why.
245
+ */
246
+ const push = sent.length > 0
247
+ ? `sent ${sent.join(' + ')}`
248
+ : report.runsRejected.length > 0
249
+ ? 'nothing accepted'
250
+ : true === report.localIsEmpty
251
+ ? // Not the same statement as "nothing to send", which describes a repo that is simply up
252
+ // to date. This one has never recorded anything, which for a LINKED repo usually means
253
+ // the app announces no projectId and its runs are landing under a different root.
254
+ 'nothing recorded here yet — if this app has been driven, it is reporting somewhere else'
255
+ : 'nothing to send';
256
+ const pull = 0 === report.pulled
257
+ ? ''
258
+ : `, pulled ${String(report.pulled)} decision(s)${report.morePending ? ' (more waiting)' : ''}`;
259
+ /*
260
+ * One reason, not a count. A rejection count tells somebody they have a problem and nothing about
261
+ * which problem — and these arrive from a BACKGROUND daemon, so the summary line is often the only
262
+ * place anybody ever sees it. Rejections in one cycle almost always share a cause (a version skew
263
+ * refuses every payload the same way), so the first reason plus the count is the whole story
264
+ * without printing a line per run; `reticle sync` still lists them all.
265
+ */
266
+ const bad = 0 === report.runsRejected.length
267
+ ? ''
268
+ : `, ${String(report.runsRejected.length)} rejected — ${report.runsRejected[0]?.reason ?? 'no reason given'}`;
269
+ return `${push}${pull}${bad}`;
270
+ }
@@ -0,0 +1,69 @@
1
+ import { type SyncReport } from './sync-cycle.js';
2
+ import type { ProjectCloud } from './cloud-config.js';
3
+ /** How often the daemon cycles. See the note above on why this is a constant and not a curve. */
4
+ export declare const DAEMON_SYNC_INTERVAL_MS = 60000;
5
+ /**
6
+ * The cadence while something is actually happening.
7
+ *
8
+ * A fixed minute is the wrong shape for both states it has to cover. During a drive the ledger
9
+ * changes on every tool call and a minute of lag is what makes a dashboard feel dead; idle, a minute
10
+ * is already more often than nothing has changed deserves.
11
+ *
12
+ * So the interval follows the work: a cycle that MOVED something earns the fast rate, a cycle that
13
+ * moved nothing backs off to the slow one. No session plumbing and no new configuration — activity
14
+ * is inferred from what the last cycle actually sent, which is the only honest evidence available.
15
+ *
16
+ * Deliberately not per-tool-call. A push in the tool path would put the network in the agent's inner
17
+ * loop, and verification working with the network down is a promise this product makes.
18
+ */
19
+ export declare const DAEMON_SYNC_ACTIVE_INTERVAL_MS = 5000;
20
+ export interface SyncDaemonDeps {
21
+ reticleRoot: string;
22
+ /** Resolved per tick, not once: a repo linked while the daemon is alive starts syncing itself. */
23
+ cloud: () => Promise<ProjectCloud>;
24
+ /**
25
+ * Every OTHER `.reticle` root on this machine that might be linked.
26
+ *
27
+ * One daemon serves many projects — that is what `artifactRootFor` exists for — and this loop
28
+ * pushed exactly one of them: whichever directory the daemon happened to be started in. Every
29
+ * other linked repo went silent, and silent is indistinguishable from "nobody verified anything",
30
+ * which is the worst possible failure for a dashboard somebody is deciding budget on.
31
+ *
32
+ * Optional: a caller that does not supply it gets exactly the old single-root behaviour.
33
+ */
34
+ otherRoots?: () => Promise<readonly string[]>;
35
+ /** Resolve the link for a root that is not this daemon's own. Required to use `otherRoots`. */
36
+ cloudFor?: (root: string) => Promise<ProjectCloud>;
37
+ now?: () => number;
38
+ intervalMs?: number;
39
+ /** Injected for the test; the real one is `fetch`. */
40
+ request?: (url: string, init: {
41
+ method: string;
42
+ headers: Record<string, string>;
43
+ body?: string;
44
+ }) => Promise<{
45
+ status: number;
46
+ text: string;
47
+ }>;
48
+ }
49
+ export interface SyncDaemon {
50
+ /** Run one cycle now, whatever the timer is doing. Returns undefined when not linked. */
51
+ syncNow: () => Promise<SyncReport | undefined>;
52
+ /**
53
+ * Something worth shipping just landed — cycle soon rather than at the next tick.
54
+ *
55
+ * A verification RUN is the artifact people wait on, and making them wait up to a full interval
56
+ * for it is the difference between a dashboard that reflects the work and one that lags it. The
57
+ * counters keep the timer: they are a rolling aggregate, and a write per tool call to move a
58
+ * number nobody is watching that second is exactly the cost the interval exists to avoid.
59
+ *
60
+ * Coalescing, never additive: many nudges in a burst collapse into one cycle.
61
+ */
62
+ nudge: () => void;
63
+ stop: () => void;
64
+ }
65
+ /**
66
+ * Start the loop. Safe to call for an UNLINKED project: it resolves the link on every tick and does
67
+ * nothing until one appears, which is what lets `reticle link` take effect without a restart.
68
+ */
69
+ export declare function startSyncDaemon(deps: SyncDaemonDeps): SyncDaemon;
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Automatic sync, for the session nobody thinks about.
3
+ *
4
+ * Somebody runs Reticle all morning. Every tool call rewrites the impact record; every verification
5
+ * writes a run. If sync is a command a human types, the dashboard is stale for exactly as long as
6
+ * they are busy — which is precisely when the numbers on it would have been worth looking at.
7
+ *
8
+ * So the daemon does it. Not a new process, not a thing to remember: the daemon is already running
9
+ * for the whole session (it is what the agent talks to), so it is the only place where "sync while
10
+ * work is happening" costs nobody anything.
11
+ *
12
+ * ── THE THREE RULES THAT KEEP IT INVISIBLE ────────────────────────────────────────────────────
13
+ * QUIET WHEN IDLE. A cycle on an unchanged session is one small GET and nothing else. That is what
14
+ * makes a one-minute timer affordable; it is also why the timer is not adaptive — a scheme that
15
+ * "backs off when idle" is complexity paid for a cost that is already near zero.
16
+ *
17
+ * NEVER IN THE WAY. Nothing here is awaited by a tool call. A sync that can slow down or fail a
18
+ * verification would be a sync worth switching off, and the local record is authoritative anyway.
19
+ *
20
+ * SILENT UNLESS IT MATTERS. A failed cycle is logged once per NEW error, not once per minute: a
21
+ * laptop on a train would otherwise fill the log with the same line four hundred times and teach
22
+ * everyone to ignore it.
23
+ */
24
+ import { log } from '../log.js';
25
+ import { describeSync, runSyncCycle } from './sync-cycle.js';
26
+ import { diskSink, diskSource, readCloudState } from './sync-disk.js';
27
+ /** How often the daemon cycles. See the note above on why this is a constant and not a curve. */
28
+ export const DAEMON_SYNC_INTERVAL_MS = 60_000;
29
+ /**
30
+ * The cadence while something is actually happening.
31
+ *
32
+ * A fixed minute is the wrong shape for both states it has to cover. During a drive the ledger
33
+ * changes on every tool call and a minute of lag is what makes a dashboard feel dead; idle, a minute
34
+ * is already more often than nothing has changed deserves.
35
+ *
36
+ * So the interval follows the work: a cycle that MOVED something earns the fast rate, a cycle that
37
+ * moved nothing backs off to the slow one. No session plumbing and no new configuration — activity
38
+ * is inferred from what the last cycle actually sent, which is the only honest evidence available.
39
+ *
40
+ * Deliberately not per-tool-call. A push in the tool path would put the network in the agent's inner
41
+ * loop, and verification working with the network down is a promise this product makes.
42
+ */
43
+ export const DAEMON_SYNC_ACTIVE_INTERVAL_MS = 5_000;
44
+ /** Given to the first cycle so a freshly-started daemon does not race the session that woke it. */
45
+ const FIRST_CYCLE_DELAY_MS = 5_000;
46
+ /**
47
+ * How long a nudge waits before cycling.
48
+ *
49
+ * Long enough to COALESCE — a battery that writes six runs in a second must cause one cycle, not
50
+ * six — and short enough that finishing a verification and looking at the dashboard feels like one
51
+ * action. Not zero: a run artifact is renamed into place and the sibling records it implies are
52
+ * written just after, so cycling on the very first byte would ship a bundle that is missing them.
53
+ */
54
+ const NUDGE_DELAY_MS = 1_500;
55
+ const defaultRequest = async (url, init) => {
56
+ const res = await fetch(url, init);
57
+ return { status: res.status, text: await res.text() };
58
+ };
59
+ /**
60
+ * Start the loop. Safe to call for an UNLINKED project: it resolves the link on every tick and does
61
+ * nothing until one appears, which is what lets `reticle link` take effect without a restart.
62
+ */
63
+ export function startSyncDaemon(deps) {
64
+ const intervalMs = deps.intervalMs ?? DAEMON_SYNC_INTERVAL_MS;
65
+ // Never slower than the idle rate: a deployment that lengthens the interval means "sync less", and
66
+ // an active burst must not quietly re-introduce the cost it was lowering.
67
+ const activeIntervalMs = Math.min(DAEMON_SYNC_ACTIVE_INTERVAL_MS, intervalMs);
68
+ const now = deps.now ?? (() => Date.now());
69
+ const request = deps.request ?? defaultRequest;
70
+ let timer;
71
+ let stopped = false;
72
+ let running = false;
73
+ /** The last error already reported. Repeats are swallowed so an offline laptop stays quiet. */
74
+ let reportedError;
75
+ /**
76
+ * Whether the last cycle found a link — undefined until the first one has looked.
77
+ *
78
+ * An unlinked project used to sync nothing and say nothing, which is indistinguishable from a
79
+ * working sync until somebody notices the dashboard is hours behind. Observed for real: the
80
+ * daemon was started from a sibling repo that had never been linked, so every call for an entire
81
+ * session was recorded into THAT repo's ledger and pushed nowhere, and the first evidence was a
82
+ * stale number on a dashboard nobody had reason to distrust.
83
+ *
84
+ * Announced on the FIRST cycle and on every change after it, so `reticle link` mid-session says
85
+ * so too. Once per transition, never per tick — a line every minute is a line people stop reading.
86
+ */
87
+ let wasLinked;
88
+ /** One root's bundle. The state, source and sink are already per-root; only the caller was not. */
89
+ const pushRoot = async (root, cloud) => {
90
+ if (null === cloud.config)
91
+ return undefined;
92
+ const full = diskSource(root);
93
+ return runSyncCycle({
94
+ config: cloud.config,
95
+ source: {
96
+ runs: () => (cloud.policy.runs ? full.runs() : []),
97
+ flows: () => (cloud.policy.flows ? full.flows() : []),
98
+ derived: (kind) => (cloud.policy.memory ? full.derived(kind) : undefined),
99
+ },
100
+ sink: diskSink(root),
101
+ state: readCloudState(root),
102
+ now,
103
+ request,
104
+ });
105
+ };
106
+ /**
107
+ * Push every other linked root this machine knows about.
108
+ *
109
+ * Isolated per root on purpose. A revoked credential, a deleted directory or an unreachable
110
+ * self-hosted server in ONE repo is a fact about that repo; letting it throw would take the
111
+ * daemon's own push down with it and turn one broken link into a machine-wide outage.
112
+ */
113
+ const syncOtherRoots = async () => {
114
+ const roots = deps.otherRoots;
115
+ const cloudFor = deps.cloudFor;
116
+ if (roots === undefined || cloudFor === undefined)
117
+ return;
118
+ let list = [];
119
+ try {
120
+ list = await roots();
121
+ }
122
+ catch {
123
+ // Enumeration is best-effort: a registry that cannot be read must not stop this daemon
124
+ // syncing the root it is standing in.
125
+ return;
126
+ }
127
+ for (const root of list) {
128
+ if (root === deps.reticleRoot)
129
+ continue;
130
+ try {
131
+ const cloud = await cloudFor(root);
132
+ if (null === cloud.config)
133
+ continue;
134
+ const report = await pushRoot(root, cloud);
135
+ if (report === undefined)
136
+ continue;
137
+ if (report.error !== undefined) {
138
+ log('reticle_cloud_sync_failed', { root, error: report.error });
139
+ continue;
140
+ }
141
+ const moved = report.runsSent > 0 ||
142
+ report.flowsSent > 0 ||
143
+ report.derivedSent.length > 0 ||
144
+ report.pulled > 0;
145
+ // The root is NAMED here and not in the single-root log below, because with several repos
146
+ // reporting, "synced 3 runs" without a directory is not an answer to "synced from where".
147
+ if (moved)
148
+ log('reticle_cloud_synced', { root, summary: describeSync(report) });
149
+ }
150
+ catch (error) {
151
+ log('reticle_cloud_sync_failed', {
152
+ root,
153
+ error: error instanceof Error ? error.message : String(error),
154
+ });
155
+ }
156
+ }
157
+ };
158
+ const cycle = async () => {
159
+ // Overlap guard: a slow cycle must not have a second one started on top of it, or two bundles
160
+ // race and the cursor written by the loser silently rewinds the winner's progress.
161
+ if (running)
162
+ return undefined;
163
+ running = true;
164
+ try {
165
+ const cloud = await deps.cloud();
166
+ const linked = null !== cloud.config;
167
+ if (linked !== wasLinked) {
168
+ wasLinked = linked;
169
+ if (linked)
170
+ log('reticle_cloud_linked', {
171
+ projectId: cloud.projectId,
172
+ root: deps.reticleRoot,
173
+ });
174
+ // Names the ROOT, because the answer is almost always "the daemon is not where you think it
175
+ // is" — and a message that omits the directory sends people to check their key instead.
176
+ else
177
+ log('reticle_cloud_unlinked', {
178
+ root: deps.reticleRoot,
179
+ fix: 'run `reticle link` in this directory, or start the daemon in the linked one — nothing is being synced from here',
180
+ });
181
+ }
182
+ // Every OTHER linked root, first. Their failures are logged and never abort this daemon's own
183
+ // push: one repo whose credential was revoked must not silence the rest of the machine.
184
+ await syncOtherRoots();
185
+ if (null === cloud.config)
186
+ return undefined;
187
+ const report = await pushRoot(deps.reticleRoot, cloud);
188
+ // `pushRoot` returns undefined only for an unlinked root, which the guard above has ruled out.
189
+ if (report === undefined)
190
+ return undefined;
191
+ if (report.error !== undefined) {
192
+ if (report.error !== reportedError) {
193
+ reportedError = report.error;
194
+ log('reticle_cloud_sync_failed', { error: report.error });
195
+ }
196
+ }
197
+ else {
198
+ reportedError = undefined;
199
+ // Only when something actually moved. A per-minute "nothing to send" is noise that trains
200
+ // people to stop reading the log.
201
+ const moved = report.runsSent > 0 ||
202
+ report.flowsSent > 0 ||
203
+ report.derivedSent.length > 0 ||
204
+ report.pulled > 0;
205
+ if (moved)
206
+ log('reticle_cloud_synced', { summary: describeSync(report) });
207
+ /*
208
+ * A cycle that moved something means a drive is in progress, so the next one comes sooner;
209
+ * one that moved nothing means the machine is idle, so it backs straight off. The rate
210
+ * follows the work without needing to be told about sessions, and an idle laptop settles at
211
+ * the same cost it had before this existed.
212
+ */
213
+ nextDelay = moved ? activeIntervalMs : intervalMs;
214
+ }
215
+ return report;
216
+ }
217
+ catch (error) {
218
+ // Belt and braces: runSyncCycle already swallows, and a throw here would kill the timer.
219
+ const message = error instanceof Error ? error.message : String(error);
220
+ if (message !== reportedError) {
221
+ reportedError = message;
222
+ log('reticle_cloud_sync_failed', { error: message });
223
+ }
224
+ return undefined;
225
+ }
226
+ finally {
227
+ running = false;
228
+ }
229
+ };
230
+ /** The delay for the NEXT cycle: fast while the last one moved something, slow once it stops. */
231
+ let nextDelay = intervalMs;
232
+ const schedule = (delay) => {
233
+ if (stopped)
234
+ return;
235
+ // Replace, never stack. `nudge` and the interval both schedule, and leaving the old timer armed
236
+ // would let every nudge add a permanent extra cycle per minute for the life of the process.
237
+ if (timer !== undefined)
238
+ clearTimeout(timer);
239
+ timer = setTimeout(() => {
240
+ void cycle().finally(() => schedule(nextDelay));
241
+ }, delay);
242
+ // Unref'd: a pending sync must never be the reason a process refuses to exit.
243
+ timer.unref?.();
244
+ };
245
+ schedule(FIRST_CYCLE_DELAY_MS);
246
+ return {
247
+ syncNow: cycle,
248
+ nudge: () => {
249
+ // A cycle already in flight will not pick this up, so the nudge is still scheduled behind it
250
+ // rather than dropped — otherwise the run that arrived during a slow cycle waits a full tick.
251
+ schedule(NUDGE_DELAY_MS);
252
+ },
253
+ stop: () => {
254
+ stopped = true;
255
+ if (timer !== undefined)
256
+ clearTimeout(timer);
257
+ timer = undefined;
258
+ },
259
+ };
260
+ }
@@ -0,0 +1,9 @@
1
+ import type { CloudSyncState, PulledIssues, SyncSink, SyncSource } from './sync-cycle.js';
2
+ /** What the cycle reads, backed by a real `.reticle` directory. */
3
+ export declare function diskSource(reticleRoot: string): SyncSource;
4
+ /** What the cycle writes back into `.reticle/`. */
5
+ export declare function diskSink(reticleRoot: string): SyncSink;
6
+ /** This machine's side of the conversation, or a blank one on first run. */
7
+ export declare function readCloudState(reticleRoot: string): CloudSyncState;
8
+ /** The decisions pulled back from the dashboard, for the HUD and for the next run to read. */
9
+ export declare function readCloudIssues(reticleRoot: string): PulledIssues;