@1agh/maude 1.0.11 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (567) hide show
  1. package/README.md +3 -1
  2. package/apps/studio/acp/bridge.ts +18 -13
  3. package/apps/studio/acp/index.ts +43 -7
  4. package/apps/studio/acp/transcript-io.ts +394 -0
  5. package/apps/studio/acp/transcript.ts +103 -38
  6. package/apps/studio/annotations-layer.tsx +191 -116
  7. package/apps/studio/annotations-model.ts +39 -0
  8. package/apps/studio/annotations-sync.ts +50 -0
  9. package/apps/studio/api.ts +821 -55
  10. package/apps/studio/bin/_import-figma.mjs +57 -11
  11. package/apps/studio/bin/_seed-fixture.mjs +163 -0
  12. package/apps/studio/bin/annotate.mjs +3 -1
  13. package/apps/studio/bin/server-up.sh +17 -2
  14. package/apps/studio/canvas-edit.ts +391 -7
  15. package/apps/studio/canvas-imports.ts +89 -0
  16. package/apps/studio/canvas-lib.tsx +82 -18
  17. package/apps/studio/canvas-notice-message.ts +16 -0
  18. package/apps/studio/canvas-notifications.tsx +19 -0
  19. package/apps/studio/canvas-shell.tsx +57 -6
  20. package/apps/studio/client/app.jsx +943 -248
  21. package/apps/studio/client/apply-edit-request.ts +55 -0
  22. package/apps/studio/client/export-center.jsx +125 -67
  23. package/apps/studio/client/github.js +38 -1
  24. package/apps/studio/client/index-loader.ts +76 -0
  25. package/apps/studio/client/panels/CloudBar.jsx +52 -6
  26. package/apps/studio/client/panels/GitPanel.jsx +103 -0
  27. package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
  28. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
  29. package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
  30. package/apps/studio/client/panels/SyncPanel.jsx +238 -4
  31. package/apps/studio/client/panels/TeamProjects.jsx +376 -0
  32. package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
  33. package/apps/studio/client/photo-knobs.jsx +3 -0
  34. package/apps/studio/client/report-bug.jsx +19 -1
  35. package/apps/studio/client/share-dialog.jsx +91 -0
  36. package/apps/studio/client/share-link.js +94 -0
  37. package/apps/studio/client/styles/3-shell-maude.css +69 -18
  38. package/apps/studio/client/styles/4-components.css +44 -1
  39. package/apps/studio/client/tree-row-menu.jsx +48 -3
  40. package/apps/studio/client/whats-new.jsx +26 -32
  41. package/apps/studio/cloud/endpoints.ts +132 -0
  42. package/apps/studio/collab/index.ts +4 -0
  43. package/apps/studio/collab/persistence.ts +137 -17
  44. package/apps/studio/collab/registry.ts +19 -21
  45. package/apps/studio/collab/room.ts +13 -2
  46. package/apps/studio/context.ts +59 -0
  47. package/apps/studio/debug-bundle.ts +13 -0
  48. package/apps/studio/dist/client.bundle.js +1911 -1879
  49. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  50. package/apps/studio/dist/runtime/sonner.js +1 -0
  51. package/apps/studio/dist/styles.css +1 -1
  52. package/apps/studio/export-dialog.tsx +54 -0
  53. package/apps/studio/exporters/degraded.ts +19 -3
  54. package/apps/studio/exporters/ghostscript.ts +194 -0
  55. package/apps/studio/exporters/pdf-fonts.ts +261 -0
  56. package/apps/studio/exporters/pdf.ts +152 -3
  57. package/apps/studio/exporters/remote.ts +36 -1
  58. package/apps/studio/fs-watch.ts +45 -1
  59. package/apps/studio/http.ts +296 -19
  60. package/apps/studio/inspect.ts +19 -0
  61. package/apps/studio/managed-projects.ts +150 -0
  62. package/apps/studio/mem.ts +45 -9
  63. package/apps/studio/notifications.tsx +292 -0
  64. package/apps/studio/runtime-bundle.ts +2 -0
  65. package/apps/studio/server.ts +69 -6
  66. package/apps/studio/sync/accepted-cold-start.ts +225 -0
  67. package/apps/studio/sync/accepted-link.ts +320 -0
  68. package/apps/studio/sync/action-stage.ts +343 -0
  69. package/apps/studio/sync/agent.ts +104 -45
  70. package/apps/studio/sync/asset-push.ts +1 -47
  71. package/apps/studio/sync/cell-file-events.ts +3 -0
  72. package/apps/studio/sync/codec.ts +192 -34
  73. package/apps/studio/sync/cold-start-apply.ts +7 -0
  74. package/apps/studio/sync/cold-start.ts +118 -22
  75. package/apps/studio/sync/comment-identity.ts +68 -0
  76. package/apps/studio/sync/connection-state.ts +15 -1
  77. package/apps/studio/sync/ctl-provider.ts +15 -2
  78. package/apps/studio/sync/document-discovery.ts +81 -0
  79. package/apps/studio/sync/file-ledger.ts +117 -0
  80. package/apps/studio/sync/file-membership.ts +10 -2
  81. package/apps/studio/sync/file-plane.ts +926 -47
  82. package/apps/studio/sync/index.ts +1786 -36
  83. package/apps/studio/sync/journal-client.ts +13 -1
  84. package/apps/studio/sync/migrate-seed.ts +150 -22
  85. package/apps/studio/sync/poke.ts +4 -2
  86. package/apps/studio/sync/presentation.ts +311 -5
  87. package/apps/studio/sync/projection.ts +820 -23
  88. package/apps/studio/sync/repeated-module.ts +73 -0
  89. package/apps/studio/sync/retry-after.ts +169 -0
  90. package/apps/studio/sync/revision-barrier.ts +129 -0
  91. package/apps/studio/sync/seed-progress.ts +218 -0
  92. package/apps/studio/sync/seed-repair.ts +46 -0
  93. package/apps/studio/sync/source-merge.ts +100 -0
  94. package/apps/studio/sync/source-ops.ts +289 -0
  95. package/apps/studio/sync/source-recovery.ts +70 -0
  96. package/apps/studio/sync/source-validation.ts +56 -0
  97. package/apps/studio/sync/status.ts +201 -6
  98. package/apps/studio/sync/transaction-client.ts +460 -0
  99. package/apps/studio/sync/transport-error.ts +111 -0
  100. package/apps/studio/sync/writer-registry.ts +236 -0
  101. package/apps/studio/text-caret.ts +35 -0
  102. package/apps/studio/undo-hud.tsx +9 -87
  103. package/apps/studio/use-canvas-media-drop.tsx +4 -39
  104. package/apps/studio/whats-new.json +173 -0
  105. package/cli/bin/maude.mjs +2 -0
  106. package/cli/commands/codex.mjs +191 -0
  107. package/cli/commands/harness.mjs +1051 -0
  108. package/cli/commands/help.mjs +13 -0
  109. package/cli/lib/design-link.mjs +190 -2
  110. package/cli/lib/harness/capabilities.mjs +302 -0
  111. package/cli/lib/harness/codex-runtime.mjs +892 -0
  112. package/cli/lib/harness/compatibility.mjs +27 -0
  113. package/cli/lib/harness/discover-claude.mjs +1393 -0
  114. package/cli/lib/harness/managed-state.mjs +1296 -0
  115. package/cli/lib/harness/model.mjs +86 -0
  116. package/cli/lib/harness/secrets.mjs +390 -0
  117. package/cli/lib/harness/targets/codex.mjs +1141 -0
  118. package/cli/lib/harness/targets/opencode.mjs +807 -0
  119. package/cli/lib/harness/transaction.mjs +1099 -0
  120. package/cli/lib/hubs-config.mjs +35 -8
  121. package/cli/templates/harness/opencode/maude-projector.ts +58 -0
  122. package/docs/harness-capability-matrix.md +125 -0
  123. package/docs/harness-environment-projection.md +95 -0
  124. package/package.json +33 -9
  125. package/plugins/design/dependencies.json +3 -3
  126. package/plugins/design/templates/_shell.html +47 -0
  127. package/plugins/flow/.claude-plugin/config.schema.json +8 -0
  128. package/plugins/flow/dependencies.json +3 -3
  129. package/apps/studio/.ai/cache/_stats.json +0 -7
  130. package/apps/studio/bin/_agent-browser-safe.test.mjs +0 -165
  131. package/apps/studio/bin/_curl-local.test.mjs +0 -280
  132. package/apps/studio/bin/_pw-launch.test.mjs +0 -90
  133. package/apps/studio/bin/_smart-frames.test.mjs +0 -195
  134. package/apps/studio/bin/_transcribe.test.mjs +0 -80
  135. package/apps/studio/bun.lock +0 -928
  136. package/apps/studio/draw/test/animate.test.ts +0 -172
  137. package/apps/studio/draw/test/brush.test.ts +0 -213
  138. package/apps/studio/draw/test/composition.test.ts +0 -141
  139. package/apps/studio/draw/test/geometry.test.ts +0 -199
  140. package/apps/studio/draw/test/gradient.test.ts +0 -167
  141. package/apps/studio/draw/test/layout.test.ts +0 -120
  142. package/apps/studio/draw/test/morph.test.ts +0 -107
  143. package/apps/studio/draw/test/optimize.test.ts +0 -40
  144. package/apps/studio/draw/test/palette.test.ts +0 -123
  145. package/apps/studio/draw/test/primitives.test.ts +0 -129
  146. package/apps/studio/draw/test/serialize-animate.test.ts +0 -142
  147. package/apps/studio/draw/test/serialize.test.ts +0 -105
  148. package/apps/studio/figma/assets.test.ts +0 -464
  149. package/apps/studio/figma/client.test.ts +0 -395
  150. package/apps/studio/figma/codegen-client.test.ts +0 -276
  151. package/apps/studio/figma/codegen-fonts.test.ts +0 -103
  152. package/apps/studio/figma/codegen-values.test.ts +0 -179
  153. package/apps/studio/figma/comments-to-strokes.test.ts +0 -194
  154. package/apps/studio/figma/fig-decode.test.ts +0 -788
  155. package/apps/studio/figma/fig-differential.test.ts +0 -182
  156. package/apps/studio/figma/fig-translator.test.ts +0 -192
  157. package/apps/studio/figma/fig-vector.test.ts +0 -113
  158. package/apps/studio/figma/from-codegen.test.ts +0 -408
  159. package/apps/studio/figma/sanitize.test.ts +0 -325
  160. package/apps/studio/figma/tailwind-map.test.ts +0 -142
  161. package/apps/studio/figma/to-artboard.test.ts +0 -808
  162. package/apps/studio/figma/to-render.test.ts +0 -180
  163. package/apps/studio/figma/to-strokes-roundtrip.test.ts +0 -152
  164. package/apps/studio/figma/to-strokes.test.ts +0 -705
  165. package/apps/studio/figma/to-tokens.test.ts +0 -321
  166. package/apps/studio/figma/url.test.ts +0 -167
  167. package/apps/studio/footage/schema.test.ts +0 -287
  168. package/apps/studio/generation/adapters/groq.test.ts +0 -80
  169. package/apps/studio/generation/audio-library.test.ts +0 -95
  170. package/apps/studio/generation/captions.test.ts +0 -78
  171. package/apps/studio/generation/elevenlabs.test.ts +0 -223
  172. package/apps/studio/generation/gemini.test.ts +0 -350
  173. package/apps/studio/generation/jobs.test.ts +0 -128
  174. package/apps/studio/generation/keys.test.ts +0 -75
  175. package/apps/studio/generation/prefs.test.ts +0 -95
  176. package/apps/studio/generation/types.test.ts +0 -123
  177. package/apps/studio/generation/whisper-models.test.ts +0 -179
  178. package/apps/studio/test/_helpers.ts +0 -108
  179. package/apps/studio/test/acp-activity-endpoint.test.ts +0 -183
  180. package/apps/studio/test/acp-activity.test.ts +0 -154
  181. package/apps/studio/test/acp-ai-activity.test.ts +0 -182
  182. package/apps/studio/test/acp-attachment-serve.test.ts +0 -74
  183. package/apps/studio/test/acp-bootstrap-brief.test.ts +0 -178
  184. package/apps/studio/test/acp-branch-guard.test.ts +0 -123
  185. package/apps/studio/test/acp-bridge-lifetime.test.ts +0 -475
  186. package/apps/studio/test/acp-bridge.test.ts +0 -545
  187. package/apps/studio/test/acp-capabilities.test.ts +0 -123
  188. package/apps/studio/test/acp-caps-bridge.test.ts +0 -279
  189. package/apps/studio/test/acp-commands.test.ts +0 -119
  190. package/apps/studio/test/acp-elicitation-bridge.test.ts +0 -480
  191. package/apps/studio/test/acp-elicitation.test.ts +0 -251
  192. package/apps/studio/test/acp-env.test.ts +0 -95
  193. package/apps/studio/test/acp-mode-banner.test.ts +0 -45
  194. package/apps/studio/test/acp-origin-gate.test.ts +0 -160
  195. package/apps/studio/test/acp-permission-prompt.test.ts +0 -251
  196. package/apps/studio/test/acp-permission.test.ts +0 -278
  197. package/apps/studio/test/acp-plugin-bootstrap.test.ts +0 -89
  198. package/apps/studio/test/acp-session-allowed-tools.test.ts +0 -235
  199. package/apps/studio/test/acp-session-plugins.test.ts +0 -209
  200. package/apps/studio/test/acp-toolgroup.test.ts +0 -76
  201. package/apps/studio/test/acp-transcript-view.test.ts +0 -71
  202. package/apps/studio/test/acp-transcript.test.ts +0 -238
  203. package/apps/studio/test/acp-usage-bridge.test.ts +0 -141
  204. package/apps/studio/test/acp-usage.test.ts +0 -143
  205. package/apps/studio/test/acp-write-gate.test.ts +0 -323
  206. package/apps/studio/test/acp-write-scope.test.ts +0 -533
  207. package/apps/studio/test/active-state.test.ts +0 -186
  208. package/apps/studio/test/activity.test.ts +0 -189
  209. package/apps/studio/test/ai-activity.test.ts +0 -113
  210. package/apps/studio/test/annotate-write.test.ts +0 -1074
  211. package/apps/studio/test/annotation-strokes-command.test.ts +0 -166
  212. package/apps/studio/test/annotations-align.test.ts +0 -88
  213. package/apps/studio/test/annotations-api.test.ts +0 -145
  214. package/apps/studio/test/annotations-bindings.test.ts +0 -262
  215. package/apps/studio/test/annotations-draw-modifiers.test.ts +0 -206
  216. package/apps/studio/test/annotations-groups.test.ts +0 -231
  217. package/apps/studio/test/annotations-layer.test.ts +0 -1011
  218. package/apps/studio/test/annotations-roundtrip.test.ts +0 -663
  219. package/apps/studio/test/annotations-snap.test.ts +0 -135
  220. package/apps/studio/test/annotations-text-format.test.ts +0 -396
  221. package/apps/studio/test/artboard-activity-overlay.test.tsx +0 -56
  222. package/apps/studio/test/artboard-guides-overlay.test.tsx +0 -152
  223. package/apps/studio/test/artboard-hug-commit.test.ts +0 -36
  224. package/apps/studio/test/artboard-kinds.test.tsx +0 -83
  225. package/apps/studio/test/artboard-selection-attrs.test.ts +0 -69
  226. package/apps/studio/test/asset-api.test.ts +0 -149
  227. package/apps/studio/test/assets-s3.test.ts +0 -249
  228. package/apps/studio/test/binary-smoke.test.ts +0 -47
  229. package/apps/studio/test/boot-self-heal.test.ts +0 -87
  230. package/apps/studio/test/browse-posture.test.tsx +0 -107
  231. package/apps/studio/test/bundle-smoke.test.ts +0 -46
  232. package/apps/studio/test/camera-reveal.test.tsx +0 -173
  233. package/apps/studio/test/canvas-artifacts.test.ts +0 -125
  234. package/apps/studio/test/canvas-build-sandbox.test.ts +0 -69
  235. package/apps/studio/test/canvas-build.test.ts +0 -141
  236. package/apps/studio/test/canvas-create-api.test.ts +0 -676
  237. package/apps/studio/test/canvas-cursors.test.ts +0 -164
  238. package/apps/studio/test/canvas-edit.test.ts +0 -470
  239. package/apps/studio/test/canvas-freshness-deps.test.ts +0 -64
  240. package/apps/studio/test/canvas-header.test.ts +0 -127
  241. package/apps/studio/test/canvas-hide-chrome.test.ts +0 -88
  242. package/apps/studio/test/canvas-hmr-runtime.test.tsx +0 -114
  243. package/apps/studio/test/canvas-lib-inline.test.ts +0 -146
  244. package/apps/studio/test/canvas-lib-motion.test.ts +0 -131
  245. package/apps/studio/test/canvas-lib-resolver.test.ts +0 -111
  246. package/apps/studio/test/canvas-list-watch.test.ts +0 -388
  247. package/apps/studio/test/canvas-media-drop.test.ts +0 -187
  248. package/apps/studio/test/canvas-meta-api.test.ts +0 -707
  249. package/apps/studio/test/canvas-move-api.test.ts +0 -587
  250. package/apps/studio/test/canvas-origin-gate.test.ts +0 -417
  251. package/apps/studio/test/canvas-pipeline.test.ts +0 -180
  252. package/apps/studio/test/canvas-rects.test.ts +0 -198
  253. package/apps/studio/test/canvas-reorder.test.ts +0 -211
  254. package/apps/studio/test/canvas-route.test.ts +0 -179
  255. package/apps/studio/test/canvas-shell-base.test.ts +0 -107
  256. package/apps/studio/test/canvas-shell-build-error.test.ts +0 -52
  257. package/apps/studio/test/canvas-url.test.ts +0 -138
  258. package/apps/studio/test/canvas-wheel-delta.test.ts +0 -42
  259. package/apps/studio/test/canvas-zoom-floor.test.ts +0 -73
  260. package/apps/studio/test/capture-core.test.ts +0 -32
  261. package/apps/studio/test/capture-determinism-shape.test.ts +0 -135
  262. package/apps/studio/test/chat-attachments.test.ts +0 -117
  263. package/apps/studio/test/chat-context.test.ts +0 -129
  264. package/apps/studio/test/chat-markdown.test.tsx +0 -58
  265. package/apps/studio/test/clip-addressing.test.ts +0 -738
  266. package/apps/studio/test/clip-ops.test.ts +0 -922
  267. package/apps/studio/test/cloud-build.test.ts +0 -95
  268. package/apps/studio/test/cloud-connect-note.test.ts +0 -135
  269. package/apps/studio/test/cloud-endpoints.test.ts +0 -590
  270. package/apps/studio/test/cloud-history-hardening.test.ts +0 -165
  271. package/apps/studio/test/cloud-history-posture.test.ts +0 -230
  272. package/apps/studio/test/cloud-managed-save-surfaces.test.ts +0 -90
  273. package/apps/studio/test/cloud-renew.test.ts +0 -205
  274. package/apps/studio/test/cloud-session-role.test.ts +0 -161
  275. package/apps/studio/test/cloud-shell-surfaces.test.ts +0 -170
  276. package/apps/studio/test/cold-start-apply.test.ts +0 -303
  277. package/apps/studio/test/collab-annotations-bridge.test.ts +0 -55
  278. package/apps/studio/test/collab-awareness-bridge.test.ts +0 -223
  279. package/apps/studio/test/collab-bridge.test.ts +0 -79
  280. package/apps/studio/test/collab-loopback.test.ts +0 -63
  281. package/apps/studio/test/collab-origin-gate.test.ts +0 -323
  282. package/apps/studio/test/collab-protocol.test.ts +0 -146
  283. package/apps/studio/test/collab-readonly-gate.test.ts +0 -194
  284. package/apps/studio/test/collab-reseed-guard.test.ts +0 -78
  285. package/apps/studio/test/collab-room.test.ts +0 -188
  286. package/apps/studio/test/collab-session-survival.test.tsx +0 -176
  287. package/apps/studio/test/collab-stress.test.ts +0 -129
  288. package/apps/studio/test/comment-mount.test.ts +0 -97
  289. package/apps/studio/test/comment-relay-origin-gate.test.ts +0 -117
  290. package/apps/studio/test/comments-api.test.ts +0 -277
  291. package/apps/studio/test/comments-fs-rebroadcast.test.ts +0 -155
  292. package/apps/studio/test/comments-overlay.test.ts +0 -117
  293. package/apps/studio/test/compile-entry.test.ts +0 -133
  294. package/apps/studio/test/component-map.test.ts +0 -48
  295. package/apps/studio/test/config-projection.test.ts +0 -104
  296. package/apps/studio/test/config-reload.test.ts +0 -230
  297. package/apps/studio/test/config-version.test.ts +0 -88
  298. package/apps/studio/test/context-resolve-tokens.test.ts +0 -97
  299. package/apps/studio/test/convert-to-absolute.test.ts +0 -333
  300. package/apps/studio/test/credential-grammar.test.ts +0 -107
  301. package/apps/studio/test/csp-canvas-shell.test.ts +0 -62
  302. package/apps/studio/test/csrf-write-guard.test.ts +0 -141
  303. package/apps/studio/test/debug-bundle.test.ts +0 -105
  304. package/apps/studio/test/design-setup-readiness.test.ts +0 -155
  305. package/apps/studio/test/detach-component.test.ts +0 -94
  306. package/apps/studio/test/dns-rebinding-guard.test.ts +0 -101
  307. package/apps/studio/test/dom-selection.test.ts +0 -198
  308. package/apps/studio/test/dynamic-text-edit.test.ts +0 -162
  309. package/apps/studio/test/edit-css-occurrence.test.ts +0 -81
  310. package/apps/studio/test/edit-persistence.test.ts +0 -91
  311. package/apps/studio/test/edit-scope-api.test.ts +0 -119
  312. package/apps/studio/test/edit-source-command.test.ts +0 -142
  313. package/apps/studio/test/edit-suppress.test.ts +0 -170
  314. package/apps/studio/test/editing-presence.test.ts +0 -103
  315. package/apps/studio/test/element-resize.test.ts +0 -136
  316. package/apps/studio/test/element-structural-api.test.ts +0 -434
  317. package/apps/studio/test/element-structural-edit.test.ts +0 -750
  318. package/apps/studio/test/ensure-browser.test.ts +0 -92
  319. package/apps/studio/test/equal-spacing-detector.test.ts +0 -257
  320. package/apps/studio/test/export-assemble.test.ts +0 -53
  321. package/apps/studio/test/export-browser-lane.test.ts +0 -126
  322. package/apps/studio/test/export-capture-fidelity.test.ts +0 -146
  323. package/apps/studio/test/export-capture-hygiene.test.ts +0 -675
  324. package/apps/studio/test/export-center.test.tsx +0 -287
  325. package/apps/studio/test/export-e2e-lanes.test.ts +0 -857
  326. package/apps/studio/test/export-format-scope-coherence.test.ts +0 -89
  327. package/apps/studio/test/export-lane.test.ts +0 -245
  328. package/apps/studio/test/export-shim-multi-capture.test.ts +0 -83
  329. package/apps/studio/test/exporters/canva.test.ts +0 -63
  330. package/apps/studio/test/exporters/degraded-propagation.test.ts +0 -123
  331. package/apps/studio/test/exporters/endpoint.test.ts +0 -121
  332. package/apps/studio/test/exporters/history.test.ts +0 -107
  333. package/apps/studio/test/exporters/html.test.ts +0 -26
  334. package/apps/studio/test/exporters/jobs.test.ts +0 -342
  335. package/apps/studio/test/exporters/pdf.test.ts +0 -85
  336. package/apps/studio/test/exporters/png.test.ts +0 -103
  337. package/apps/studio/test/exporters/pptx-deck.test.ts +0 -112
  338. package/apps/studio/test/exporters/pptx.test.ts +0 -31
  339. package/apps/studio/test/exporters/pw-launch.test.ts +0 -49
  340. package/apps/studio/test/exporters/runtime.test.ts +0 -59
  341. package/apps/studio/test/exporters/scope.test.ts +0 -292
  342. package/apps/studio/test/exporters/svg.test.ts +0 -29
  343. package/apps/studio/test/exporters/unsupported-media.test.ts +0 -123
  344. package/apps/studio/test/exporters/zip.test.ts +0 -104
  345. package/apps/studio/test/fetch-asset-gate.test.ts +0 -189
  346. package/apps/studio/test/figjam-v3-model.test.ts +0 -342
  347. package/apps/studio/test/figma-explode.test.ts +0 -438
  348. package/apps/studio/test/figma-provenance.test.ts +0 -108
  349. package/apps/studio/test/figma-routes.test.ts +0 -294
  350. package/apps/studio/test/file-lock.test.ts +0 -84
  351. package/apps/studio/test/fixtures/backgrounding-claude-auth.sh +0 -12
  352. package/apps/studio/test/fixtures/fake-claude-auth.mjs +0 -23
  353. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +0 -1
  354. package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +0 -158
  355. package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +0 -44
  356. package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +0 -46
  357. package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +0 -42
  358. package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +0 -63
  359. package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +0 -51
  360. package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +0 -50
  361. package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +0 -45
  362. package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +0 -56
  363. package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +0 -37
  364. package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +0 -118
  365. package/apps/studio/test/fixtures/mock-acp-agent.mjs +0 -69
  366. package/apps/studio/test/fixtures/noisy-claude-auth.mjs +0 -27
  367. package/apps/studio/test/fixtures/perf-canvas.mjs +0 -201
  368. package/apps/studio/test/fixtures/phase-20-annotations.svg +0 -1
  369. package/apps/studio/test/fixtures/phase-21-annotations.svg +0 -1
  370. package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
  371. package/apps/studio/test/fixtures/video-comp-fixture.tsx +0 -82
  372. package/apps/studio/test/fixtures/wedged-claude-auth.sh +0 -11
  373. package/apps/studio/test/footage-store.test.ts +0 -100
  374. package/apps/studio/test/fs-mkdir-api.test.ts +0 -248
  375. package/apps/studio/test/fs-watch.test.ts +0 -41
  376. package/apps/studio/test/gemma-models.test.ts +0 -245
  377. package/apps/studio/test/generate-route.test.ts +0 -106
  378. package/apps/studio/test/git-api.test.ts +0 -0
  379. package/apps/studio/test/git-branches.test.ts +0 -341
  380. package/apps/studio/test/git-cloud-posture.test.ts +0 -59
  381. package/apps/studio/test/git-grouping.test.ts +0 -106
  382. package/apps/studio/test/git-lifecycle.test.ts +0 -100
  383. package/apps/studio/test/git-log-format.test.ts +0 -95
  384. package/apps/studio/test/git-safe-rel.test.ts +0 -132
  385. package/apps/studio/test/git-system-engine.test.ts +0 -135
  386. package/apps/studio/test/git-watch.test.ts +0 -97
  387. package/apps/studio/test/github-api.test.ts +0 -577
  388. package/apps/studio/test/grid-track-handles.test.ts +0 -160
  389. package/apps/studio/test/handoff-static-frames.test.ts +0 -215
  390. package/apps/studio/test/handoff.test.ts +0 -329
  391. package/apps/studio/test/history-rollback.test.ts +0 -88
  392. package/apps/studio/test/hmr-broadcast.test.ts +0 -236
  393. package/apps/studio/test/hmr-classify.test.ts +0 -87
  394. package/apps/studio/test/hub-link.test.ts +0 -80
  395. package/apps/studio/test/identity-cache.test.ts +0 -83
  396. package/apps/studio/test/import-asset-browser.test.ts +0 -64
  397. package/apps/studio/test/import-asset-route.test.ts +0 -71
  398. package/apps/studio/test/import-asset.test.ts +0 -427
  399. package/apps/studio/test/import-brand-browser.test.ts +0 -88
  400. package/apps/studio/test/import-brand-route.test.ts +0 -120
  401. package/apps/studio/test/import-brand.test.ts +0 -206
  402. package/apps/studio/test/import-figma.test.ts +0 -667
  403. package/apps/studio/test/import-tokens.test.ts +0 -722
  404. package/apps/studio/test/index-data-canvas-ds.test.ts +0 -97
  405. package/apps/studio/test/input-router.test.ts +0 -757
  406. package/apps/studio/test/inspect-script-syntax.test.ts +0 -53
  407. package/apps/studio/test/inspect-selections.test.ts +0 -219
  408. package/apps/studio/test/knob-props-authored.test.ts +0 -87
  409. package/apps/studio/test/layers-synthetic-groups.test.ts +0 -96
  410. package/apps/studio/test/locator.test.ts +0 -214
  411. package/apps/studio/test/marquee-overlay.test.ts +0 -94
  412. package/apps/studio/test/media-commit-chain.test.ts +0 -210
  413. package/apps/studio/test/mode-toggle.test.tsx +0 -226
  414. package/apps/studio/test/motion-sample.test.ts +0 -35
  415. package/apps/studio/test/move-artboards-command.test.ts +0 -108
  416. package/apps/studio/test/participants-chrome.test.ts +0 -65
  417. package/apps/studio/test/paths.test.ts +0 -88
  418. package/apps/studio/test/pdf-print-boxes.test.ts +0 -359
  419. package/apps/studio/test/peer-selection-follows-camera.test.tsx +0 -131
  420. package/apps/studio/test/perf-harness.ts +0 -193
  421. package/apps/studio/test/phase-3.6-smoke.test.ts +0 -79
  422. package/apps/studio/test/photo-bg-remove-validation.test.ts +0 -71
  423. package/apps/studio/test/photo-canvas-bundle.test.ts +0 -79
  424. package/apps/studio/test/photo-edit-api.test.ts +0 -201
  425. package/apps/studio/test/photo-filters.test.ts +0 -149
  426. package/apps/studio/test/photo-pipeline.test.ts +0 -106
  427. package/apps/studio/test/photo-taxonomy.test.ts +0 -96
  428. package/apps/studio/test/print-marks.test.ts +0 -113
  429. package/apps/studio/test/print-units.test.ts +0 -173
  430. package/apps/studio/test/read-annotations.test.ts +0 -727
  431. package/apps/studio/test/read-only-gate.test.ts +0 -188
  432. package/apps/studio/test/read-only-role.test.ts +0 -95
  433. package/apps/studio/test/readiness.test.ts +0 -337
  434. package/apps/studio/test/remote-ahead-behind-cache.test.ts +0 -89
  435. package/apps/studio/test/reorder-api.test.ts +0 -210
  436. package/apps/studio/test/repo-concurrency.test.ts +0 -146
  437. package/apps/studio/test/repo-lock.test.ts +0 -350
  438. package/apps/studio/test/report-proxy.test.ts +0 -125
  439. package/apps/studio/test/ripple.test.ts +0 -197
  440. package/apps/studio/test/runtime-bundle-error-mapping.test.ts +0 -43
  441. package/apps/studio/test/runtime-bundle.test.ts +0 -69
  442. package/apps/studio/test/sanitize-annotation-svg.test.ts +0 -238
  443. package/apps/studio/test/scaffold-design.test.ts +0 -122
  444. package/apps/studio/test/server-lifecycle.test.ts +0 -38
  445. package/apps/studio/test/session-runtime-state.test.ts +0 -226
  446. package/apps/studio/test/session-scope.test.ts +0 -100
  447. package/apps/studio/test/shared-doc-cell-pairing.test.ts +0 -642
  448. package/apps/studio/test/shared-doc-convergence.test.ts +0 -265
  449. package/apps/studio/test/shared-doc-foundation.test.ts +0 -63
  450. package/apps/studio/test/shared-doc-migrate.test.ts +0 -369
  451. package/apps/studio/test/shared-doc-projection.test.ts +0 -238
  452. package/apps/studio/test/shell-importmap.test.ts +0 -56
  453. package/apps/studio/test/sizing-mode.test.ts +0 -102
  454. package/apps/studio/test/slash-commands.test.ts +0 -117
  455. package/apps/studio/test/snap-distance-pill.test.ts +0 -68
  456. package/apps/studio/test/spacing-handles.test.ts +0 -138
  457. package/apps/studio/test/specimen-select.test.ts +0 -88
  458. package/apps/studio/test/sync-agent.test.ts +0 -660
  459. package/apps/studio/test/sync-annotations-cold-start.test.ts +0 -302
  460. package/apps/studio/test/sync-asset-push.test.ts +0 -931
  461. package/apps/studio/test/sync-atomic-write.test.ts +0 -62
  462. package/apps/studio/test/sync-attach-incremental.test.ts +0 -527
  463. package/apps/studio/test/sync-autocommit.test.ts +0 -537
  464. package/apps/studio/test/sync-canvas-path.test.ts +0 -200
  465. package/apps/studio/test/sync-cell-write-nudge.test.ts +0 -346
  466. package/apps/studio/test/sync-codec.test.ts +0 -230
  467. package/apps/studio/test/sync-cold-seed-dedup.test.ts +0 -187
  468. package/apps/studio/test/sync-cold-start.test.ts +0 -304
  469. package/apps/studio/test/sync-connect-honesty.test.ts +0 -114
  470. package/apps/studio/test/sync-connection-state.test.ts +0 -337
  471. package/apps/studio/test/sync-ctl-channel.test.ts +0 -508
  472. package/apps/studio/test/sync-decide-file.test.ts +0 -420
  473. package/apps/studio/test/sync-doc-name.test.ts +0 -281
  474. package/apps/studio/test/sync-echo-guard.test.ts +0 -96
  475. package/apps/studio/test/sync-file-ledger.test.ts +0 -334
  476. package/apps/studio/test/sync-file-membership.test.ts +0 -347
  477. package/apps/studio/test/sync-file-plane.test.ts +0 -976
  478. package/apps/studio/test/sync-file-pull.test.ts +0 -333
  479. package/apps/studio/test/sync-fresh-link-parity.test.ts +0 -267
  480. package/apps/studio/test/sync-fs-mirror.test.ts +0 -181
  481. package/apps/studio/test/sync-hardening.test.ts +0 -586
  482. package/apps/studio/test/sync-hub-listing.test.ts +0 -46
  483. package/apps/studio/test/sync-hubs-config.test.ts +0 -133
  484. package/apps/studio/test/sync-incident-replay.test.ts +0 -211
  485. package/apps/studio/test/sync-journal.test.ts +0 -176
  486. package/apps/studio/test/sync-meta-codec.test.ts +0 -199
  487. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +0 -98
  488. package/apps/studio/test/sync-move-retirement.test.ts +0 -231
  489. package/apps/studio/test/sync-panel-surface.test.ts +0 -153
  490. package/apps/studio/test/sync-path-pull.test.ts +0 -531
  491. package/apps/studio/test/sync-presentation.test.ts +0 -262
  492. package/apps/studio/test/sync-pull-budget.test.ts +0 -169
  493. package/apps/studio/test/sync-remote-docs.test.ts +0 -333
  494. package/apps/studio/test/sync-resync-routes.test.ts +0 -168
  495. package/apps/studio/test/sync-runtime.test.ts +0 -1675
  496. package/apps/studio/test/sync-seed-defers-to-hub.test.ts +0 -83
  497. package/apps/studio/test/sync-settings-routes.test.ts +0 -195
  498. package/apps/studio/test/sync-settings.test.ts +0 -151
  499. package/apps/studio/test/sync-status.test.ts +0 -261
  500. package/apps/studio/test/sync-supervisor.test.ts +0 -262
  501. package/apps/studio/test/sync-tombstone-apply.test.ts +0 -111
  502. package/apps/studio/test/sync-trash.test.ts +0 -132
  503. package/apps/studio/test/sync-two-peer-discovery.test.ts +0 -343
  504. package/apps/studio/test/sync-untrusted.test.ts +0 -103
  505. package/apps/studio/test/system-endpoint.test.ts +0 -144
  506. package/apps/studio/test/text-editability-stamp.test.ts +0 -131
  507. package/apps/studio/test/timeline-comp-target.test.ts +0 -139
  508. package/apps/studio/test/timeline-media-cache.test.ts +0 -41
  509. package/apps/studio/test/timeline-parse.test.ts +0 -184
  510. package/apps/studio/test/timeline-scale.test.ts +0 -65
  511. package/apps/studio/test/timeline-snap.test.ts +0 -85
  512. package/apps/studio/test/tool-palette-insert-anchor.test.ts +0 -86
  513. package/apps/studio/test/tool-palette.test.tsx +0 -55
  514. package/apps/studio/test/tour-overlay.test.tsx +0 -90
  515. package/apps/studio/test/trusted-request-host.test.ts +0 -66
  516. package/apps/studio/test/undo-sequence-byte-compare.test.ts +0 -211
  517. package/apps/studio/test/undo-stack.test.ts +0 -244
  518. package/apps/studio/test/use-agent-presence.test.tsx +0 -117
  519. package/apps/studio/test/use-annotation-resize.test.ts +0 -402
  520. package/apps/studio/test/use-annotation-selection.test.tsx +0 -77
  521. package/apps/studio/test/use-artboard-drag.test.ts +0 -324
  522. package/apps/studio/test/use-canvas-activity.test.tsx +0 -206
  523. package/apps/studio/test/use-collab.test.ts +0 -0
  524. package/apps/studio/test/use-cursor-modifiers.test.ts +0 -59
  525. package/apps/studio/test/use-keyboard-discipline.test.ts +0 -27
  526. package/apps/studio/test/use-selection-set.test.tsx +0 -166
  527. package/apps/studio/test/use-snap-guides.test.ts +0 -271
  528. package/apps/studio/test/use-tool-mode.test.tsx +0 -186
  529. package/apps/studio/test/use-undo-stack.test.tsx +0 -223
  530. package/apps/studio/test/video-asset.test.ts +0 -163
  531. package/apps/studio/test/video-comp-fixture.test.ts +0 -50
  532. package/apps/studio/test/video-comp.test.ts +0 -270
  533. package/apps/studio/test/video-encode-lib.test.ts +0 -63
  534. package/apps/studio/test/video-render-bridge.test.ts +0 -171
  535. package/apps/studio/test/whats-new.test.ts +0 -113
  536. package/apps/studio/test/whisper-setup.test.ts +0 -97
  537. package/apps/studio/test/workspace-containment.test.ts +0 -382
  538. package/apps/studio/test/workspace-signin.test.ts +0 -292
  539. package/apps/studio/test/ws-handshake.test.ts +0 -33
  540. package/cli/commands/cache.test.mjs +0 -166
  541. package/cli/commands/design-link.test.mjs +0 -419
  542. package/cli/commands/design.test.mjs +0 -240
  543. package/cli/commands/doctor.test.mjs +0 -237
  544. package/cli/commands/hub-workspace.test.mjs +0 -275
  545. package/cli/commands/hub.test.mjs +0 -225
  546. package/cli/commands/kg.test.mjs +0 -142
  547. package/cli/commands/preflight.test.mjs +0 -46
  548. package/cli/lib/argv.test.mjs +0 -46
  549. package/cli/lib/cache.test.mjs +0 -303
  550. package/cli/lib/cell-plan.test.mjs +0 -225
  551. package/cli/lib/config-lint.test.mjs +0 -117
  552. package/cli/lib/ddr-to-kgai.test.mjs +0 -118
  553. package/cli/lib/design-ownership.test.mjs +0 -329
  554. package/cli/lib/fetch-asset.test.mjs +0 -213
  555. package/cli/lib/figma-codegen-reachability.test.mjs +0 -104
  556. package/cli/lib/figma-import-controls.test.mjs +0 -70
  557. package/cli/lib/flow-design-integration.test.mjs +0 -165
  558. package/cli/lib/gitignore-block.test.mjs +0 -136
  559. package/cli/lib/gitignore-drift.test.mjs +0 -156
  560. package/cli/lib/hubs-config.test.mjs +0 -165
  561. package/cli/lib/pkg-root.test.mjs +0 -111
  562. package/cli/lib/plugin-cli-reachability.test.mjs +0 -57
  563. package/cli/lib/plugin-name-namespace.test.mjs +0 -71
  564. package/cli/lib/reconstruct-toolset.test.mjs +0 -194
  565. package/cli/lib/stack-detect.test.mjs +0 -121
  566. package/cli/lib/update-check.test.mjs +0 -32
  567. package/cli/lib/workspace-plan.test.mjs +0 -699
@@ -24,6 +24,8 @@ import {
24
24
  readFileSync,
25
25
  realpathSync,
26
26
  renameSync,
27
+ rmdirSync,
28
+ rmSync,
27
29
  writeFileSync,
28
30
  } from 'node:fs';
29
31
  import { readdir } from 'node:fs/promises';
@@ -36,6 +38,8 @@ import { Y_TYPES } from '../collab/persistence.ts';
36
38
  import type { Context, LinkedHub } from '../context.ts';
37
39
  import { createHistory } from '../history.ts';
38
40
  import { SYNTHETIC_FS_DELAY_MS } from '../hmr-broadcast.ts';
41
+ import { acceptedColdStart } from './accepted-cold-start.ts';
42
+ import { type AcceptedLink, createAcceptedLink } from './accepted-link.ts';
39
43
  import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
40
44
  import { isPushableAssetRel, pushAssets } from './asset-push.ts';
41
45
  import { atomicWrite } from './atomic-write.ts';
@@ -43,6 +47,7 @@ import { type CellPairing, resolveCellPairing, sanitizeForLog } from './cell-pai
43
47
  import {
44
48
  canvasPathFromDoc,
45
49
  clearMovedTo,
50
+ laneValueFromFile,
46
51
  movedToFromDoc,
47
52
  stampCanvasPath,
48
53
  stampMovedTo,
@@ -55,9 +60,10 @@ import {
55
60
  import { createCtlProvider } from './ctl-provider.ts';
56
61
  import { createRescanScheduler, diffCanvasSet, type RescanScheduler } from './discovery.ts';
57
62
  import { createDocNameResolver } from './doc-name.ts';
58
- import { createEchoGuard } from './echo-guard.ts';
63
+ import { createDocumentDiscovery } from './document-discovery.ts';
64
+ import { createEchoGuard, hashBytes } from './echo-guard.ts';
59
65
  import { createFileLedger } from './file-ledger.ts';
60
- import { createFilePlane } from './file-plane.ts';
66
+ import { createFilePlane, MAX_DORUCEKA_ROWS } from './file-plane.ts';
61
67
  import { type FilePullResult, pullFiles } from './file-pull.ts';
62
68
  import { createFsReader, type FsReader } from './fs-mirror.ts';
63
69
  import { type HubDocRow, hubHolds, indexHubDocs } from './hub-listing.ts';
@@ -80,6 +86,9 @@ import {
80
86
  stateDocumentGone,
81
87
  tombstonedSlugs,
82
88
  } from './remote-docs.ts';
89
+ import { createRevisionBarrier } from './revision-barrier.ts';
90
+ import { computeSeedProgress } from './seed-progress.ts';
91
+ import { replaySourceOp, type SourceOp } from './source-ops.ts';
83
92
  import { createSyncStatusStore, type SyncStatusStore } from './status.ts';
84
93
  import { quarantineCanvas } from './tombstone-apply.ts';
85
94
  import { writeUntrustedMarkers } from './untrusted.ts';
@@ -94,8 +103,38 @@ export interface SyncProvider {
94
103
  * the bridge.
95
104
  */
96
105
  readonly awareness?: Awareness;
97
- /** Resolves when the first hub sync handshake completes. */
98
- onceSynced(): Promise<void>;
106
+ /**
107
+ * Did this transaction origin come off the wire from the hub? Accepted
108
+ * revisions use it as a tripwire: under a fenced (read-only) connection any
109
+ * OTHER origin is a local write the hub will drop — a replica diverging.
110
+ * Optional: a test stub without it is simply not checked.
111
+ */
112
+ isRemoteOrigin?(origin: unknown): boolean;
113
+ /**
114
+ * May a local write to this document reach the hub right now? False before
115
+ * the handshake authenticates and whenever the hub admitted this connection
116
+ * read-only — a write made then is dropped by the hub while the local
117
+ * replica keeps it, which is a divergence nothing would ever repair.
118
+ * Optional: a provider without it is treated as writable.
119
+ */
120
+ isWritable?(): boolean;
121
+ /** The hub authenticated this connection (again) — scope may have changed. */
122
+ onAuthenticated?(cb: (scope: string) => void): () => void;
123
+ /** Out-of-band messages from the hub on this document's socket. */
124
+ onStateless?(cb: (payload: string) => void): () => void;
125
+ /**
126
+ * Resolves when the first hub sync handshake completes.
127
+ *
128
+ * `signal` lets a caller that gives up ALSO detach the underlying listener.
129
+ * Without it, every abandoned wait leaks a `synced` listener plus its closure
130
+ * for the life of the provider — bounded at one per slug while a wedged
131
+ * single-flight latch kept the caller from retrying, and UNBOUNDED the moment
132
+ * that latch was correctly fixed to release (verification review 2026-09-03,
133
+ * N2): one per slug per settle window per flap, all firing at once if a sync
134
+ * ever lands. Optional, so a test stub or a provider without the plumbing is
135
+ * unaffected.
136
+ */
137
+ onceSynced(signal?: AbortSignal): Promise<void>;
99
138
  /**
100
139
  * Subscribe to WS connection-status transitions (Phase 9 Task 8 offline
101
140
  * mode). Returns an unsubscribe fn. Optional — a test stub or a provider
@@ -113,9 +152,10 @@ export interface SyncProvider {
113
152
  /* ------------------------------------------------- auth-failure classification */
114
153
 
115
154
  /** DDR-102 — rejection classes the runtime distinguishes. `rate-limit` and
116
- * `generic` are transient (provider backoff keeps retrying); `not-authorized`
117
- * and `invalid-token` are permanent (retrying spams the hub bucket — destroy
118
- * the provider and re-probe on a slow timer instead). */
155
+ * `generic` are transient (the runtime re-authenticates the refused document
156
+ * after one hub window, paced and backed off — see `AUTH_TRANSIENT_RETRY_MS`);
157
+ * `not-authorized` and `invalid-token` are permanent (retrying spams the hub
158
+ * bucket — destroy the provider and re-probe on a slow timer instead). */
119
159
  export type AuthFailureClass = 'rate-limit' | 'not-authorized' | 'invalid-token' | 'generic';
120
160
 
121
161
  /** Map a raw hub rejection reason to a class. New hubs send distinct reasons
@@ -136,14 +176,54 @@ export function classifyAuthFailure(raw: string): AuthFailureClass {
136
176
 
137
177
  export const AUTH_WARN_DEBOUNCE_MS = 2_000;
138
178
  export const AUTH_REPROBE_MS = 5 * 60 * 1000;
179
+ /**
180
+ * How long a transiently-refused document (`rate-limit` / `generic`) waits
181
+ * before the runtime asks the hub again — one full hub window, since the
182
+ * refusal itself says "retry in up to 60s".
183
+ *
184
+ * The runtime owns this retry because nothing else will do it. These classes
185
+ * used to be left to "the provider's built-in backoff", which does not exist
186
+ * per document under DDR-102 multiplexing: a provider re-sends its token only
187
+ * when the SHARED socket opens, and a partial refusal never closes a socket
188
+ * that the documents which did authenticate keep busy. 16 of 112 canvases sat
189
+ * `auth-rejected` for the life of the process that way (RCA
190
+ * issue-sync-rate-limited-docs-never-retried, 2026-09-11).
191
+ *
192
+ * Repeat refusals of the same document double this, capped at the re-probe
193
+ * interval; a handshake that lands resets it.
194
+ */
195
+ export const AUTH_TRANSIENT_RETRY_MS = 60_000;
196
+ /** Random spread added to each transient retry, so peers refused in the same
197
+ * burst do not all come back on the window boundary together. */
198
+ export const AUTH_TRANSIENT_JITTER_MS = 15_000;
199
+ /**
200
+ * Most transient re-authentications inside any `AUTH_TRANSIENT_RETRY_MS` span.
201
+ * A quarter of the hub's default valid-token ceiling (600/min): a 16-document
202
+ * retry never notices it, and a 1 000-canvas project cannot turn its own
203
+ * recovery into the next burst (the F1 lesson, applied to this lane).
204
+ */
205
+ export const AUTH_TRANSIENT_BATCH = 150;
139
206
  export const BOOT_SETTLE_TIMEOUT_MS = 15_000;
140
207
  /** F1 — minimum wall-clock between renewal attempts. A burst of rejections
141
208
  * collapses to one renewal (single-flight); the NEXT burst waits this out. */
142
209
  export const RENEW_MIN_INTERVAL_MS = 60_000;
143
- /** F1 — consecutive successful renewals with no completed handshake in between
144
- * before the runtime stops renewing and lets the link surface as
145
- * refused/stalled. Small: a renewal that fixed the credential clears at least
146
- * one doc, so >0 useless renewals means the credential was never the cause. */
210
+ /**
211
+ * F1 — consecutive successful renewals with NO PROGRESS in between before the
212
+ * runtime stops renewing and lets the link surface as refused/stalled.
213
+ *
214
+ * The cap itself is load-bearing: it is what stopped a reproduced 2 342
215
+ * renewals/s storm (2026-08-10 security review). What changed on 2026-09-03 is
216
+ * the definition of "progress".
217
+ *
218
+ * It used to mean ONLY a completed doc handshake, on the reasoning that "a
219
+ * renewal that fixed the credential clears at least one doc". That is true
220
+ * exactly when the doc lane is the lane with work. During a long file seed
221
+ * against an already-converged doc lane (85/87 synced, no handshakes left to
222
+ * land) there is nothing to clear — so every legitimate pre-expiry renewal
223
+ * burned a slot, three of them exhausted the cap, and the runtime stopped
224
+ * renewing while the file plane was still transferring. A delivered FILE now
225
+ * counts too (`onProgress` on the file plane).
226
+ */
147
227
  export const RENEW_MAX_WITHOUT_PROGRESS = 3;
148
228
  /** F2 — setTimeout clamps delays above this (2^31-1) to 1 ms, turning a
149
229
  * far-future expiry into a tight renewal loop. Clamp before we hand it over. */
@@ -167,6 +247,45 @@ export const DISCOVERY_DEBOUNCE_MS = 400;
167
247
  */
168
248
  export const REMOTE_POLL_MS = 20_000;
169
249
 
250
+ /** Floor between two serve-log seed-progress lines. Long enough not to become
251
+ * the next thing that buries the log. */
252
+ export const SEED_PROGRESS_LOG_MS = 15_000;
253
+ /** How often the stall watchdog looks (issue #118). Cheap — one snapshot. */
254
+ export const STALL_CHECK_MS = 30_000;
255
+ /**
256
+ * "Socket connected, not one document synced" for this long is a stall, not a
257
+ * slow handshake. Comfortably above a cold cell's worst observed wake (a
258
+ * measured `/health` on a sleeping cell answered in 14 s) and above the boot
259
+ * settle ceiling, so a healthy-but-slow link is never interrupted.
260
+ */
261
+ export const STALL_RECONNECT_AFTER_MS = 3 * 60 * 1000;
262
+ /** Floor between watchdog-forced reconnects — a hub that is simply down must
263
+ * not be turned into a reconnect storm by its own silence. */
264
+ export const STALL_RECONNECT_MIN_MS = 5 * 60 * 1000;
265
+ /**
266
+ * Per-ATTEMPT deadline for the shared hub socket.
267
+ *
268
+ * The provider defaults to `timeout: 0` — no deadline at all — and its retry
269
+ * attempt resolves only on the first MESSAGE. A DO holding an upgrade open
270
+ * while a container boots therefore parks one attempt indefinitely, and
271
+ * `onClose` refuses to re-arm while a retry chain is live
272
+ * (`if (!this.cancelWebsocketRetry …)`), so the whole socket goes deaf. A
273
+ * finite attempt is what turns that into a retry.
274
+ */
275
+ /**
276
+ * Random spread added ONCE per runtime to the stall threshold.
277
+ *
278
+ * A cell sleeping (or a hub restarting) drops every peer's socket in the same
279
+ * instant, so without this every peer's threshold expires inside one
280
+ * `STALL_CHECK_MS` tick of every other and they all re-authenticate N documents
281
+ * at once — the DDR-102 2026-06-11 retry storm, re-armed automatically every
282
+ * few minutes (attacker review 2026-09-03, F6). The floor below backs off on
283
+ * repeat for the same reason.
284
+ */
285
+ export const STALL_JITTER_MS = 120 * 1000;
286
+ /** Ceiling for the backed-off floor between forced reconnects. */
287
+ export const STALL_RECONNECT_MAX_MS = 30 * 60 * 1000;
288
+
170
289
  /**
171
290
  * Settle window before a local write triggers a file-plane pass.
172
291
  *
@@ -176,6 +295,18 @@ export const REMOTE_POLL_MS = 20_000;
176
295
  */
177
296
  export const FILE_PASS_DEBOUNCE_MS = 400;
178
297
 
298
+ /**
299
+ * The floor between two file-plane passes — issue #109.
300
+ *
301
+ * The debounce coalesces a burst of local writes; it does nothing about a
302
+ * steady drip, and a hub's file-event channel is a steady drip by design
303
+ * ("cloud changes now arrive in seconds"). 400 ms of spacing times 200
304
+ * requests a pass is 30k requests a minute at a door that opens 600 times.
305
+ * Two seconds is still well inside "arrives in seconds" and puts the client's
306
+ * ceiling an order of magnitude below the server's instead of above it.
307
+ */
308
+ export const MIN_PASS_INTERVAL_MS = 2_000;
309
+
179
310
  /**
180
311
  * Settling delay for an OFF-SCHEDULE poll (reconnect).
181
312
  *
@@ -200,6 +331,11 @@ export const REMOTE_POLL_SOON_MS = 1_500;
200
331
  * edits arrive in seconds, bounded enough that spam buys almost nothing.
201
332
  */
202
333
  export const POKE_COOLDOWN_MS = REMOTE_POLL_MS / 2;
334
+ /** Accepted revisions: how soon, and how often at most, a poke pulls. */
335
+ export const ACCEPTED_POKE_DELAY_MS = 60;
336
+ /** T16 — how long the last tool write of an AI action gets to reach the stage. */
337
+ export const AI_ACTION_SETTLE_MS = 250;
338
+ export const ACCEPTED_POKE_COOLDOWN_MS = 1_000;
203
339
 
204
340
  /**
205
341
  * How many previously-unknown canvases one listing may land.
@@ -235,13 +371,13 @@ export function validExpiry(raw: unknown, nowMs: number = Date.now()): number |
235
371
 
236
372
  const AUTH_CLASS_HINT: Record<AuthFailureClass, string> = {
237
373
  'rate-limit':
238
- 'boot burst hit the hub rate limit — sync settles as providers back off; if persistent, raise HUB_CONN_RATE_LIMIT on the hub (DDR-102 hubs default to 600/min for valid tokens).',
374
+ 'refused on volume — retrying these in ~60 s, in paced batches (no restart needed). If this repeats every boot, the hub’s HUB_CONN_RATE_LIMIT is below this project’s size (DDR-102 hubs default to 600/min for valid tokens; every canvas authenticates separately).',
239
375
  'not-authorized':
240
376
  "the token's scope does not cover these canvases — mint a hub-wide token (`maude hub token generate --scope '*'` or an admin-UI invite) and re-link. Retries stopped; re-probing in 5 min.",
241
377
  'invalid-token':
242
378
  'the stored token was rejected — re-run `maude design link <url> --token …` on this machine. Retries stopped; re-probing in 5 min.',
243
379
  generic:
244
- 'the hub refused auth without a specific reason (older hub?) — check `maude design status` and the hub logs.',
380
+ 'the hub refused auth without a specific reason (older hub?) — retrying these in ~60 s; if it keeps refusing, check `maude design status` and the hub logs.',
245
381
  };
246
382
 
247
383
  /**
@@ -346,9 +482,120 @@ export interface SyncRuntime {
346
482
  * flag-off) — the caller then proceeds with the plain local move.
347
483
  */
348
484
  retireForMove(fromSlug: string, toRel: string): Promise<boolean>;
485
+ /**
486
+ * Accepted-revisions mode (DDR-241): propose one lane value of a synced
487
+ * canvas that a privileged API route produced (comments, annotations).
488
+ * `null` when the project is not in that mode or this runtime does not carry
489
+ * the canvas — the caller then writes the shared document as before.
490
+ */
491
+ proposeLane?(
492
+ slug: string,
493
+ lane: 'comments' | 'annotations' | 'meta',
494
+ text: string,
495
+ opts?: { baseText?: string; writeId?: string }
496
+ ): Promise<{ status: 'accepted' | 'rejected'; code?: string }> | null;
497
+ /**
498
+ * Accepted-revisions mode: a folder operation as ONE project action. `null`
499
+ * when the project is not in that mode; otherwise the (possibly queued)
500
+ * outcome. Canvases inside are moved/deleted by the hub, not one by one.
501
+ */
502
+ proposeFolder?(
503
+ op:
504
+ | { op: 'dir.create'; path: string }
505
+ | { op: 'dir.delete'; path: string }
506
+ | {
507
+ op: 'dir.move';
508
+ from: string;
509
+ to: string;
510
+ }
511
+ ): Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null;
512
+ /** True while the linked project is in accepted-revisions mode. */
513
+ acceptedMode?(): boolean;
514
+ /** Tripwire count: local writes that reached an accepted replica. */
515
+ acceptedWriteViolations?(): number;
516
+ /** Accepted revisions: the project's logical history (T27). Null when legacy. */
517
+ acceptedHistory?(q: {
518
+ limit?: number;
519
+ before?: number | null;
520
+ path?: string | null;
521
+ }): Promise<AcceptedHistoryRow[] | null>;
522
+ /** A canvas source (repo-relative path) as it stood at a revision. */
523
+ acceptedVersion?(repoRel: string, revision: number): Promise<string | null>;
524
+ /** Restore a canvas to a revision as a NEW action. */
525
+ acceptedRestore?(
526
+ repoRel: string,
527
+ revision: number
528
+ ): Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean } | null>;
529
+ /** Personal undo/redo of one of this actor's accepted actions (T28). */
530
+ acceptedUndo?(
531
+ actionId: string,
532
+ redo?: boolean
533
+ ): Promise<{
534
+ status: 'accepted' | 'rejected';
535
+ code?: string;
536
+ queued?: boolean;
537
+ actionId?: string;
538
+ } | null>;
539
+ /**
540
+ * T16 — an AI action begins: file changes tools make until it ends are
541
+ * published together as one action (see action-stage.ts). `key` names the
542
+ * participant (an agent turn, a /design:edit run).
543
+ */
544
+ beginAiAction?(key: string, label: string): void;
545
+ /** T16 — `done` publishes (when the last participant ends); `failed` holds. */
546
+ endAiAction?(
547
+ key: string,
548
+ outcome: 'done' | 'failed'
549
+ ): Promise<{ status: 'accepted' | 'rejected'; code?: string } | null>;
550
+ /**
551
+ * T19 — make the whole project available offline now: file-plane passes
552
+ * back to back (no pacing floor) until nothing is left to pull, or a pass
553
+ * stops making progress. Resolves with what came down and what could not.
554
+ */
555
+ prepareOffline?(): Promise<{
556
+ complete: boolean;
557
+ pulled: number;
558
+ passes: number;
559
+ failed: number;
560
+ } | null>;
561
+ /** T26 — the accepted action that carried this content of a canvas, if ours. */
562
+ acceptedActionForContent?(repoRel: string, content: string): string | null;
563
+ /** T28 — the two sides of a canvas's held source conflict. */
564
+ conflictVersions?(repoRel: string): { slug: string; mine: string | null; theirs: string } | null;
565
+ /**
566
+ * T28 — resolve it: `mine` proposes the local version on top of the
567
+ * project's (a new action), `theirs` takes the project's version.
568
+ */
569
+ resolveConflict?(
570
+ repoRel: string,
571
+ choice: 'mine' | 'theirs'
572
+ ): Promise<{ status: 'accepted' | 'rejected' | 'taken'; code?: string } | null>;
573
+ /** T16 — the person's decision on a held (unfinished) AI action. */
574
+ resolveAiAction?(choice: 'publish' | 'discard'): Promise<{
575
+ status: 'accepted' | 'rejected' | 'discarded';
576
+ code?: string;
577
+ canvases?: number;
578
+ } | null>;
579
+ }
580
+
581
+ export interface AcceptedHistoryRow {
582
+ revision: number;
583
+ actionId: string;
584
+ actor: string;
585
+ mine: boolean;
586
+ kind: string;
587
+ label: string | null;
588
+ committedAt: number;
589
+ undoes: string | null;
590
+ /** Design-root-relative canvas paths the action touched. */
591
+ canvases: string[];
349
592
  }
350
593
 
351
594
  export interface CreateSyncRuntimeOptions {
595
+ /** Transaction transport override (tests) — defaults to global fetch. */
596
+ transactionFetch?: typeof fetch;
597
+ /** Base backoff between re-sends of an unanswered proposal (tests). */
598
+ transactionRetryMs?: number;
352
599
  /** Override the HocuspocusProvider factory (test injection). */
353
600
  providerFactory?: ProviderFactory;
354
601
  /** Force-enable/disable adopt mode (overrides cfg.linkedHub.adopt). */
@@ -361,6 +608,22 @@ export interface CreateSyncRuntimeOptions {
361
608
  * unit tests that only exercise the file-sync path.
362
609
  */
363
610
  registry?: AwarenessRegistry;
611
+ /**
612
+ * Stall-watchdog timings + clock. TEST INJECTION ONLY (see `stallCheck`).
613
+ *
614
+ * The watchdog shipped with no behavioural coverage at all — three review
615
+ * rounds each found a HIGH inside it, and none of them could have been caught
616
+ * by a test, because nothing could reach it: it required the OWNED factory
617
+ * and real wall-clock minutes. This is the seam that makes its predicate,
618
+ * its jitter and its backoff assertable.
619
+ */
620
+ stall?: {
621
+ checkMs?: number;
622
+ afterMs?: number;
623
+ minMs?: number;
624
+ jitterMs?: number;
625
+ now?: () => number;
626
+ };
364
627
  /**
365
628
  * Override the offline-mode connection monitor (Task 8 test injection —
366
629
  * lets tests pass injectable timers/clock). Defaults to a real monitor that
@@ -398,6 +661,14 @@ export interface CreateSyncRuntimeOptions {
398
661
  renewMinIntervalMs?: number;
399
662
  /** F1 — consecutive no-progress renewals before giving up. Default RENEW_MAX_WITHOUT_PROGRESS. */
400
663
  renewMaxWithoutProgress?: number;
664
+ /** Base wait before a transient refusal is retried. Default AUTH_TRANSIENT_RETRY_MS. */
665
+ transientRetryMs?: number;
666
+ /** Max random spread per transient retry. Default AUTH_TRANSIENT_JITTER_MS. */
667
+ transientJitterMs?: number;
668
+ /** Transient re-auths allowed per `transientRetryMs` span. Default AUTH_TRANSIENT_BATCH. */
669
+ transientBatch?: number;
670
+ /** Jitter source (test injection). Default Math.random. */
671
+ random?: () => number;
401
672
  };
402
673
  }
403
674
 
@@ -636,8 +907,67 @@ export function createSyncRuntime(
636
907
  // registry can hand us the canvas's single doc. Flag OFF / no registry / a
637
908
  // minimal test registry without getDoc → the proven two-doc path, unchanged.
638
909
  const useSharedDoc = !!ctx.sharedDoc && typeof opts.registry?.getDoc === 'function';
910
+ // ---- ACCEPTED REVISIONS (DDR-241) ----------------------------------------
911
+ //
912
+ // Asked of the hub at start and on every poll — never assumed. In
913
+ // `transactions` mode this runtime writes NO shared document: every local
914
+ // change is a proposal through the durable outbox, and the documents change
915
+ // when the project publishes the accepted revision. Shared-doc only: the
916
+ // two-doc agent path has no proposal lane and stays legacy.
917
+ const acceptedLink: AcceptedLink | null = useSharedDoc
918
+ ? createAcceptedLink({
919
+ hubUrl: linkedHub.url,
920
+ token: () => token,
921
+ designRoot: ctx.paths.designRoot,
922
+ docNameFor: (slug) => docNameFor(slug),
923
+ fetchImpl: opts.transactionFetch,
924
+ retryMs: opts.transactionRetryMs,
925
+ onStats: (stats) => statusStore?.updateAccepted?.(stats),
926
+ onStage: (summary) => statusStore?.updateAiAction?.(summary),
927
+ })
928
+ : null;
929
+ const acceptedOn = (): boolean => acceptedLink?.on() === true;
930
+ /**
931
+ * T26 — which accepted action carried a given canvas content of ours, so the
932
+ * shell's Cmd+Z of an edit can undo THAT action (effect-aware) instead of
933
+ * swapping the whole file back. Bounded; keyed by slug + lane + content hash.
934
+ */
935
+ const acceptedByContent = new Map<string, string>();
936
+ const noteAcceptedContent = (slug: string, lane: string, value: string, actionId: string) => {
937
+ acceptedByContent.set(`${slug}|${lane}|${hashBytes(value)}`, actionId);
938
+ if (acceptedByContent.size > 500) {
939
+ const oldest = acceptedByContent.keys().next().value;
940
+ if (oldest !== undefined) acceptedByContent.delete(oldest);
941
+ }
942
+ };
943
+ /** T14 — one barrier for every projection: a revision shows whole. */
944
+ const revisionBarrier = createRevisionBarrier();
945
+ /** Canvases a folder action already moved/deleted — nothing more to propose for them. */
946
+ const coveredByFolderAction = new Set<string>();
947
+ /** Folder entries this peer has materialized from the project manifest. */
948
+ const knownProjectDirs = new Set<string>();
949
+ /** Local writes that reached an accepted replica (tripwire; should stay 0). */
950
+ let acceptedWriteViolations = 0;
951
+ /** The synced canvas at a repo-relative (or design-root-relative) path. */
952
+ const slugForRepoRel = (rel: string): string | null => {
953
+ const clean = String(rel).replace(/\\/g, '/').replace(/^\/+/, '');
954
+ for (const abs of [
955
+ path.join(ctx.paths.repoRoot, clean),
956
+ path.join(ctx.paths.designRoot, clean),
957
+ ]) {
958
+ for (const [slug, d] of descriptors) if (d.html === abs) return slug;
959
+ }
960
+ return null;
961
+ };
962
+ const describeOrigin = (origin: unknown): string => {
963
+ if (origin === null || origin === undefined) return String(origin);
964
+ if (typeof origin === 'string') return origin.slice(0, 40);
965
+ if (typeof origin === 'object') return (origin as object).constructor?.name ?? 'object';
966
+ return typeof origin;
967
+ };
639
968
  let fsReader: FsReader | null = null;
640
969
  let busUnsub: (() => void) | null = null;
970
+ const activityUnsubs: Array<() => void> = [];
641
971
  let started = false;
642
972
  let stopped = false;
643
973
  // ---- THE LEGACY PUSH CLIENT (journal-less hubs only) --------------------
@@ -769,11 +1099,56 @@ export function createSyncRuntime(
769
1099
  const pendingAuthWarn = new Map<AuthFailureClass, Set<string>>();
770
1100
  /** Latest rejection class per slug — feeds the boot summary detail. */
771
1101
  const rejectedReasons = new Map<string, AuthFailureClass>();
1102
+ /**
1103
+ * EVERY document the hub has refused this episode, in ANY class.
1104
+ *
1105
+ * `rejectedPermanent` holds only the two classes that destroy the provider;
1106
+ * `generic` (what every pre-DDR-102 hub sends) and `rate-limit` are absent
1107
+ * from it by design. Anything asking "has the hub refused this document?"
1108
+ * must ask HERE — reading `rejectedPermanent` for that question is how a
1109
+ * transient refusal got laundered into a green row (attacker review
1110
+ * 2026-09-03, F3). Emptied only by `clearRejection`, i.e. by a handshake the
1111
+ * hub actually completed.
1112
+ */
1113
+ const rejectedAny = new Set<string>();
772
1114
  /** Permanently-rejected docs awaiting a slow re-probe (provider destroyed). */
773
1115
  const rejectedPermanent = new Map<
774
1116
  string,
775
1117
  { canvas: CanvasDescriptor; canvasPaths: import('./agent.ts').CanvasSyncPaths; doc: Y.Doc }
776
1118
  >();
1119
+ /**
1120
+ * Transiently-refused docs (`rate-limit` / `generic`) awaiting the paced
1121
+ * retry — see `AUTH_TRANSIENT_RETRY_MS`. Unlike `rejectedPermanent` the
1122
+ * provider is KEPT until the retry swaps it (a refused provider sends nothing
1123
+ * more, so there is no storm to stop), and the retry acts only if `provider`
1124
+ * is still the live one: a release or another recovery path that replaced it
1125
+ * in the meantime owns the document now. `dueAt` is on the `renewNow` clock.
1126
+ */
1127
+ const rejectedTransient = new Map<
1128
+ string,
1129
+ {
1130
+ canvas: CanvasDescriptor;
1131
+ canvasPaths: import('./agent.ts').CanvasSyncPaths;
1132
+ doc: Y.Doc;
1133
+ provider: SyncProvider;
1134
+ dueAt: number;
1135
+ }
1136
+ >();
1137
+ /** Consecutive transient refusals per slug — the backoff exponent. Emptied
1138
+ * by `clearRejection`, i.e. by a handshake the hub completed. */
1139
+ const transientStrikes = new Map<string, number>();
1140
+ /** When each transient re-auth of the last window went out — the sliding
1141
+ * budget. Never longer than `transientBatch`. */
1142
+ const transientSpent: number[] = [];
1143
+ let transientRetryTimer: TimerHandle | null = null;
1144
+ /**
1145
+ * First-connect setup still OWED to a pulled canvas whose first handshake
1146
+ * has not landed (`connectCanvas` defers it — the body path is unknown until
1147
+ * the document arrives). Every reconnect passes it back in: without it a
1148
+ * refusal at that first handshake stranded the setup, and the hub's eventual
1149
+ * yes synced the document into memory while nothing ever wrote it to disk.
1150
+ */
1151
+ const owedSetups = new Map<string, (provider: SyncProvider) => void>();
777
1152
  let authWarnTimer: TimerHandle | null = null;
778
1153
  let reprobeTimer: TimerHandle | null = null;
779
1154
  let renewTimer: TimerHandle | null = null;
@@ -790,6 +1165,10 @@ export function createSyncRuntime(
790
1165
  const renewNow = opts.auth?.now ?? (() => Date.now());
791
1166
  const renewMinIntervalMs = opts.auth?.renewMinIntervalMs ?? RENEW_MIN_INTERVAL_MS;
792
1167
  const renewMaxWithoutProgress = opts.auth?.renewMaxWithoutProgress ?? RENEW_MAX_WITHOUT_PROGRESS;
1168
+ const transientRetryMs = opts.auth?.transientRetryMs ?? AUTH_TRANSIENT_RETRY_MS;
1169
+ const transientJitterMs = opts.auth?.transientJitterMs ?? AUTH_TRANSIENT_JITTER_MS;
1170
+ const transientBatch = Math.max(1, opts.auth?.transientBatch ?? AUTH_TRANSIENT_BATCH);
1171
+ const authRandom = opts.auth?.random ?? Math.random;
793
1172
  /** Wall-clock of the last renewal attempt (0 = never). The floor. */
794
1173
  let lastRenewAt = 0;
795
1174
  /** Successful renewals since the last completed handshake. The cap: a
@@ -797,6 +1176,14 @@ export function createSyncRuntime(
797
1176
  * anything — stop, and let the link surface as refused/stalled. Reset to 0
798
1177
  * at every `connected` doc (the handshake-success point). */
799
1178
  let renewalsSinceProgress = 0;
1179
+ /**
1180
+ * When a document last reached `connected` — the ONLY evidence that this link
1181
+ * is doing its job. Seeded at runtime construction so the stall watchdog
1182
+ * (`armStallWatchdog`) measures from "when we started trying", exactly like
1183
+ * `SyncStatusSnapshot.startedAt`, rather than from a promotion that may never
1184
+ * come. Moves on first connect, on re-probe and on reconnect re-promotion.
1185
+ */
1186
+ let lastPromotionAt = 0;
800
1187
  // Silent credential renewal (cloud). Absent under cell pairing — the cell's
801
1188
  // token arrives via its environment and is the hub's own to rotate.
802
1189
  const renewCredential = cellPairing
@@ -804,7 +1191,13 @@ export function createSyncRuntime(
804
1191
  : (opts.auth?.renewCredential ??
805
1192
  (async () => {
806
1193
  const r = await renewHubCredential(linkedHub.url);
807
- return r.ok ? { token: r.token, expiresAt: r.expiresAt } : null;
1194
+ if (r.ok) return { token: r.token, expiresAt: r.expiresAt };
1195
+ // The reason was computed and thrown away. `renewHubCredential`
1196
+ // distinguishes signed-out from revoked from removed-from-project from
1197
+ // unreachable — each a different sentence to a person — and every one of
1198
+ // them reached the log as the same silence. Say which.
1199
+ console.warn(`[sync] hub credential renewal declined: ${r.reason}`);
1200
+ return null;
808
1201
  }));
809
1202
  const settleTimers = new Set<TimerHandle>();
810
1203
  /** Pending synthetic `fs:any` emissions (cell pairing only), keyed by the
@@ -831,6 +1224,23 @@ export function createSyncRuntime(
831
1224
  let createdUnsub: (() => void) | null = null;
832
1225
  /** Periodic remote-document poll — the hub-side half of discovery. */
833
1226
  let remotePollTimer: ReturnType<typeof setInterval> | null = null;
1227
+ /** The stall watchdog's tick — see `stallCheck` in `start()`. */
1228
+ let stallTimer: ReturnType<typeof setInterval> | null = null;
1229
+ /** Wall-clock of the last watchdog-forced reconnect (0 = never). The floor
1230
+ * that keeps a silent hub from becoming a reconnect storm. */
1231
+ let lastForcedReconnectAt = 0;
1232
+ /** Forced reconnects so far — the exponent of the backing-off floor. */
1233
+ let forcedReconnects = 0;
1234
+ /** This runtime's slice of `STALL_JITTER_MS`, drawn once so peers that lost
1235
+ * their sockets together do not fire together. */
1236
+ const stallNow = opts.stall?.now ?? (() => Date.now());
1237
+ // Seeded from the same clock the watchdog reads, so "nothing has settled
1238
+ // since we started trying" is measured against a consistent origin.
1239
+ lastPromotionAt = stallNow();
1240
+ const stallCheckMs = opts.stall?.checkMs ?? STALL_CHECK_MS;
1241
+ const stallAfterMs = opts.stall?.afterMs ?? STALL_RECONNECT_AFTER_MS;
1242
+ const stallMinMs = opts.stall?.minMs ?? STALL_RECONNECT_MIN_MS;
1243
+ const stallJitterMs = opts.stall?.jitterMs ?? Math.floor(Math.random() * STALL_JITTER_MS);
834
1244
  /**
835
1245
  * The file-event control channel (Sync v2 Increment 2), when the hub
836
1246
  * advertises one. Null on a journal-less hub, in a cell (the child holds it
@@ -867,6 +1277,8 @@ export function createSyncRuntime(
867
1277
  let pokesSeen = 0;
868
1278
  /** Assigned by `start()`; the seam `pullRemoteNow()` and tests reach. */
869
1279
  let remotePull: (() => Promise<void>) | null = null;
1280
+ let documentDiscovery: ReturnType<typeof createDocumentDiscovery> | null = null;
1281
+ let documentDiscoveryUnsub: (() => void) | null = null;
870
1282
  /**
871
1283
  * Run a file-plane pass shortly, coalesced.
872
1284
  *
@@ -881,14 +1293,73 @@ export function createSyncRuntime(
881
1293
  if (stopped || filePassTimer !== null) return;
882
1294
  filePassTimer = setTimeout(() => {
883
1295
  filePassTimer = null;
884
- if (stopped || !filePlane) return;
885
- void filePlane
886
- .reconcile()
887
- .then((r) => planeResultSink?.(r))
888
- .catch((err) => console.error('[sync/files] pass failed:', err));
1296
+ void runPlanePass();
889
1297
  }, FILE_PASS_DEBOUNCE_MS);
890
1298
  filePassTimer.unref?.();
891
1299
  }
1300
+ /**
1301
+ * The ONE way a pass runs — issue #109.
1302
+ *
1303
+ * There were two entry points (this debounce and the 20 s poll) and no
1304
+ * mutual exclusion between them, so a busy file-event channel could start a
1305
+ * pass every 400 ms and overlap it with the poll's. Each pass is up to
1306
+ * `MAX_REQUESTS_PER_PASS` requests against a hub that meters per minute, so
1307
+ * the client's own ceiling was an order of magnitude above the server's and
1308
+ * it manufactured the rate limit it then failed on.
1309
+ *
1310
+ * A pass in flight absorbs the request (the running pass reads the same
1311
+ * disk and the same cursor, so a second one would decide the identical
1312
+ * work), and passes are floored `MIN_PASS_INTERVAL_MS` apart.
1313
+ */
1314
+ let planePassInFlight = false;
1315
+ let lastPlanePassAt = 0;
1316
+ let planePassWaiter: Promise<void> | null = null;
1317
+ let lastPlaneResult: import('./file-plane.ts').FilePlaneResult | null = null;
1318
+ async function runPlanePass(opts?: { floor?: boolean }): Promise<void> {
1319
+ // Captured, not re-read: `stop()` clears `filePlane`, and a pass that has
1320
+ // already decided to run must not dereference the field it was cleared to.
1321
+ const lane = filePlane;
1322
+ if (stopped || !lane) return;
1323
+ // A pass already running IS this pass: it reads the same disk and the same
1324
+ // cursor, so the caller waits for its answer rather than racing it. The
1325
+ // poll's `await` therefore still means "a pass has happened".
1326
+ if (planePassInFlight) return await (planePassWaiter ?? Promise.resolve());
1327
+ const since = Date.now() - lastPlanePassAt;
1328
+ // The floor governs POKES. The 20 s poll and the explicit `pullRemoteNow`
1329
+ // seam are already bounded by their own callers, and silently deferring a
1330
+ // caller that awaited a pass would be its own lie.
1331
+ if (opts?.floor !== false && since < MIN_PASS_INTERVAL_MS) {
1332
+ // Not dropped — deferred. A poke that arrives inside the floor still has
1333
+ // news, and losing it means a local edit waits for the next 20 s tick.
1334
+ schedulePlanePassAfter(MIN_PASS_INTERVAL_MS - since);
1335
+ return;
1336
+ }
1337
+ planePassInFlight = true;
1338
+ lastPlanePassAt = Date.now();
1339
+ const run = (async () => {
1340
+ try {
1341
+ const r = await lane.reconcile();
1342
+ lastPlaneResult = r;
1343
+ planeResultSink?.(r);
1344
+ } catch (err) {
1345
+ console.error('[sync/files] pass failed:', err);
1346
+ } finally {
1347
+ planePassInFlight = false;
1348
+ planePassWaiter = null;
1349
+ }
1350
+ })();
1351
+ planePassWaiter = run;
1352
+ await run;
1353
+ }
1354
+ /** Re-arm the debounce timer at a specific delay (the floor's deferral). */
1355
+ function schedulePlanePassAfter(ms: number): void {
1356
+ if (stopped || filePassTimer !== null) return;
1357
+ filePassTimer = setTimeout(() => {
1358
+ filePassTimer = null;
1359
+ void runPlanePass();
1360
+ }, ms);
1361
+ filePassTimer.unref?.();
1362
+ }
892
1363
  /** Assigned by `start()` so a pass reports into the same status the poll does. */
893
1364
  let planeResultSink: ((r: import('./file-plane.ts').FilePlaneResult) => void) | null = null;
894
1365
 
@@ -914,6 +1385,23 @@ export function createSyncRuntime(
914
1385
  */
915
1386
  function pollRemoteSoon(opts: { cooled?: boolean } = {}): void {
916
1387
  if (stopped || remotePollSoonTimer !== null) return;
1388
+ // ACCEPTED REVISIONS: a poke means "a revision was accepted", and the
1389
+ // canvas it created, moved or deleted — or the folder — must reach this
1390
+ // peer now, not on the next 20 s tick. Still bounded (one pass per
1391
+ // second, with a trailing pass so the last poke of a burst is honoured),
1392
+ // so a hub that pokes in a loop costs one cheap request a second.
1393
+ if (opts.cooled && acceptedOn()) {
1394
+ const since = Date.now() - lastPokePassAt;
1395
+ const wait = Math.max(ACCEPTED_POKE_DELAY_MS, ACCEPTED_POKE_COOLDOWN_MS - since);
1396
+ remotePollSoonTimer = setTimeout(() => {
1397
+ remotePollSoonTimer = null;
1398
+ if (stopped) return;
1399
+ lastPokePassAt = Date.now();
1400
+ void remotePull?.().catch(() => {});
1401
+ }, wait);
1402
+ remotePollSoonTimer.unref?.();
1403
+ return;
1404
+ }
917
1405
  if (opts.cooled) {
918
1406
  const since = Date.now() - lastPokePassAt;
919
1407
  if (since < POKE_COOLDOWN_MS) {
@@ -1191,7 +1679,17 @@ export function createSyncRuntime(
1191
1679
  console.log(
1192
1680
  `[sync/${slug}] canvas was moved on another machine — stale local copy parked in _trash/ (recoverable).`
1193
1681
  );
1194
- ctx.bus.emit('canvas-list-update');
1682
+ }
1683
+ // The destination is materialized and the old body is gone. Receivers
1684
+ // need the semantic move to retarget their open canvas, even if a local
1685
+ // watcher already noticed the two filesystem changes.
1686
+ if (movedTo && !existsSync(desc.html)) {
1687
+ ctx.bus.emit('canvas-list-update', {
1688
+ action: 'moved',
1689
+ fromRel: path.relative(ctx.paths.designRoot, desc.html).split(path.sep).join('/'),
1690
+ rel: movedTo.replace(/\\/g, '/'),
1691
+ fromSlug: slug,
1692
+ });
1195
1693
  }
1196
1694
  } catch (err) {
1197
1695
  // Quarantine is best-effort: the doc guards already made the file
@@ -1260,6 +1758,9 @@ export function createSyncRuntime(
1260
1758
  // payload would be a permanent "still syncing" the user can never clear.
1261
1759
  monitor?.forgetDoc(slug);
1262
1760
  rejectedPermanent.delete(slug);
1761
+ rejectedTransient.delete(slug);
1762
+ transientStrikes.delete(slug);
1763
+ owedSetups.delete(slug);
1263
1764
  rejectedReasons.delete(slug);
1264
1765
  return true;
1265
1766
  }
@@ -1276,6 +1777,28 @@ export function createSyncRuntime(
1276
1777
  async function retireForMove(fromSlug: string, toRel: string): Promise<boolean> {
1277
1778
  const provider = providers.get(fromSlug);
1278
1779
  if (!provider) return false;
1780
+ if (acceptedOn() && acceptedLink) {
1781
+ // The move is a PROJECT action: the hub retires the old document (with
1782
+ // the same `movedTo` stamp receivers already follow) and opens the new
1783
+ // one with the same content. Answered or queued, the local rename then
1784
+ // proceeds; a refusal (path taken, outside every group) keeps it.
1785
+ movingLocally.add(fromSlug);
1786
+ try {
1787
+ if (!coveredByFolderAction.delete(fromSlug)) {
1788
+ const r = await acceptedLink.moveDoc(fromSlug, toRel);
1789
+ if (r.status === 'rejected') {
1790
+ console.warn(`[sync/${fromSlug}] the project did not accept the move (${r.code})`);
1791
+ return false;
1792
+ }
1793
+ }
1794
+ retiredDocs.add(fromSlug);
1795
+ await releaseOne(fromSlug);
1796
+ console.log(`[sync/${fromSlug}] moved in the project → ${toRel}`);
1797
+ return true;
1798
+ } finally {
1799
+ movingLocally.delete(fromSlug);
1800
+ }
1801
+ }
1279
1802
  movingLocally.add(fromSlug);
1280
1803
  try {
1281
1804
  stampMovedTo(provider.document, toRel, ORIGINS.DISK_PROJECTION);
@@ -1301,6 +1824,243 @@ export function createSyncRuntime(
1301
1824
  }
1302
1825
  }
1303
1826
 
1827
+ // ---- project folders (accepted revisions) --------------------------------
1828
+ //
1829
+ // A folder is a manifest entry, so an EMPTY folder a peer created exists in
1830
+ // the project and appears here — no placeholder canvas. The hub is
1831
+ // untrusted (DDR-054): every path is re-checked against the declared canvas
1832
+ // groups and the real design root before a directory is made, and removal
1833
+ // only ever takes a folder that holds nothing but its `.gitkeep`.
1834
+ const projectDirsFile = path.join(ctx.paths.designRoot, '_state', 'project-dirs.json');
1835
+ try {
1836
+ const saved = JSON.parse(readFileSync(projectDirsFile, 'utf8')) as unknown;
1837
+ if (Array.isArray(saved))
1838
+ for (const d of saved) if (typeof d === 'string') knownProjectDirs.add(d);
1839
+ } catch {
1840
+ /* first run — nothing materialized yet */
1841
+ }
1842
+ const saveProjectDirs = (): void => {
1843
+ try {
1844
+ mkdirSync(path.dirname(projectDirsFile), { recursive: true });
1845
+ writeFileSync(projectDirsFile, JSON.stringify([...knownProjectDirs].sort()));
1846
+ } catch {
1847
+ /* best-effort — a lost file only delays one removal */
1848
+ }
1849
+ };
1850
+ const projectDirAbs = (rel: string): string | null => {
1851
+ if (typeof rel !== 'string' || !rel || rel.length > 512) return null;
1852
+ const parts = rel.split('/');
1853
+ if (parts.length > 16) return null;
1854
+ for (const part of parts) {
1855
+ if (!part || part === '.' || part === '..' || part.startsWith('_') || part.startsWith('.'))
1856
+ return null;
1857
+ if (/[\\\0:*?"<>|]/.test(part) || part.length > 255) return null;
1858
+ }
1859
+ const groups = (ctx.cfg.canvasGroups ?? []).map((g) => g.path.replace(/^\/+|\/+$/g, ''));
1860
+ if (!groups.some((g) => g && (rel === g || rel.startsWith(`${g}/`)))) return null;
1861
+ const abs = path.resolve(ctx.paths.designRoot, ...parts);
1862
+ const root = path.resolve(ctx.paths.designRoot);
1863
+ if (!abs.startsWith(`${root}${path.sep}`)) return null;
1864
+ const real = realpathOfDeepestExisting(abs);
1865
+ const realRoot = realpathOfDeepestExisting(root);
1866
+ if (real !== realRoot && !real.startsWith(`${realRoot}${path.sep}`)) return null;
1867
+ return abs;
1868
+ };
1869
+ const designRelPosix = ctx.paths.designRel.replace(/^\.\//, '').replace(/^\/+|\/+$/g, '');
1870
+ function applyProjectDirs(dirs: readonly string[]): void {
1871
+ const next = new Set(dirs);
1872
+ let changed = false;
1873
+ for (const rel of next) {
1874
+ if (knownProjectDirs.has(rel)) continue;
1875
+ const abs = projectDirAbs(rel);
1876
+ if (!abs) continue;
1877
+ try {
1878
+ if (!existsSync(abs)) {
1879
+ mkdirSync(abs, { recursive: true });
1880
+ writeFileSync(path.join(abs, '.gitkeep'), '');
1881
+ ctx.bus.emit('canvas-list-update', {
1882
+ action: 'mkdir',
1883
+ dir: path.posix.join(designRelPosix, rel),
1884
+ });
1885
+ }
1886
+ knownProjectDirs.add(rel);
1887
+ changed = true;
1888
+ } catch (err) {
1889
+ console.warn(`[sync] could not create project folder ${rel}:`, err);
1890
+ }
1891
+ }
1892
+ // Deepest first, so a removed parent is attempted after its children.
1893
+ const gone = [...knownProjectDirs]
1894
+ .filter((d) => !next.has(d))
1895
+ .sort((a, b) => b.length - a.length);
1896
+ for (const rel of gone) {
1897
+ const abs = projectDirAbs(rel);
1898
+ if (abs && existsSync(abs)) {
1899
+ let entries: string[] = [];
1900
+ try {
1901
+ entries = readdirSync(abs).filter((e) => e !== '.DS_Store');
1902
+ } catch {
1903
+ continue;
1904
+ }
1905
+ // Canvases inside leave through their own deletion/move first; until
1906
+ // then the folder stays and the next poll tries again.
1907
+ if (entries.some((e) => e !== '.gitkeep')) continue;
1908
+ try {
1909
+ for (const e of readdirSync(abs)) rmSync(path.join(abs, e), { force: true });
1910
+ rmdirSync(abs);
1911
+ ctx.bus.emit('canvas-list-update', {
1912
+ action: 'removed-folder',
1913
+ dir: path.posix.join(designRelPosix, rel),
1914
+ });
1915
+ } catch {
1916
+ continue;
1917
+ }
1918
+ }
1919
+ knownProjectDirs.delete(rel);
1920
+ changed = true;
1921
+ }
1922
+ if (changed) saveProjectDirs();
1923
+ }
1924
+
1925
+ /**
1926
+ * The document listing is the hub's STORAGE view: a row appears after
1927
+ * Hocuspocus persists the document, seconds after it exists. Under accepted
1928
+ * revisions the manifest is the project's own statement of what exists, so a
1929
+ * canvas accepted a moment ago is pulled on the next poll, not the one after
1930
+ * its row is written.
1931
+ */
1932
+ function withAcceptedDocs(
1933
+ listing: Awaited<ReturnType<typeof fetchRemoteListing>>
1934
+ ): Awaited<ReturnType<typeof fetchRemoteListing>> {
1935
+ const manifest = acceptedOn() ? acceptedLink?.manifest : null;
1936
+ if (!manifest) return listing;
1937
+ // A document the project RETIRED (moved away) is not a canvas to fetch,
1938
+ // even while its storage row lingers — its successor is in the manifest.
1939
+ const retired = new Set(manifest.docs.filter((d) => d.retired).map((d) => d.doc));
1940
+ const documents = (listing?.documents ?? []).filter((d) => !retired.has(d.name));
1941
+ const names = new Set(documents.map((d) => d.name));
1942
+ for (const d of manifest.docs) {
1943
+ if (d.retired || names.has(d.doc)) continue;
1944
+ documents.push({ name: d.doc, bytes: 1 });
1945
+ names.add(d.doc);
1946
+ }
1947
+ return { ...(listing ?? { tombstones: [] }), documents, tombstones: listing?.tombstones ?? [] };
1948
+ }
1949
+
1950
+ /**
1951
+ * Re-ask the save mode, and hand every held write back to its projection —
1952
+ * as a proposal when the project turned out to be in transactions mode, as
1953
+ * an ordinary import when the connection simply became writable.
1954
+ */
1955
+ let modeRefresh: Promise<void> | null = null;
1956
+ function refreshAcceptedMode(): Promise<void> {
1957
+ if (!acceptedLink) return Promise.resolve();
1958
+ if (modeRefresh) return modeRefresh;
1959
+ modeRefresh = acceptedLink
1960
+ .refresh()
1961
+ .then(() => {
1962
+ if (acceptedOn()) applyProjectDirs(acceptedLink.manifest?.dirs ?? []);
1963
+ for (const p of projections.values()) p.retryDeferred();
1964
+ })
1965
+ .finally(() => {
1966
+ modeRefresh = null;
1967
+ });
1968
+ return modeRefresh;
1969
+ }
1970
+
1971
+ /**
1972
+ * A folder the Maude UI made on this machine (it carries the `.gitkeep`
1973
+ * `createFolder` writes) that the project does not list yet — made while
1974
+ * the project was legacy, or while this machine was offline. It joins the
1975
+ * project as ONE action at cold start. Only additive: a folder the manifest
1976
+ * lists and this disk lacks is never proposed as a deletion from here.
1977
+ */
1978
+ function proposeLocalFolders(): void {
1979
+ if (!acceptedLink) return;
1980
+ const listed = new Set(acceptedLink.manifest?.dirs ?? []);
1981
+ const found: string[] = [];
1982
+ const walk = (abs: string, rel: string, depth: number) => {
1983
+ if (depth > 16 || found.length >= 200) return;
1984
+ let entries: import('node:fs').Dirent[];
1985
+ try {
1986
+ entries = readdirSync(abs, { withFileTypes: true });
1987
+ } catch {
1988
+ return;
1989
+ }
1990
+ for (const e of entries) {
1991
+ if (!e.isDirectory() || e.name.startsWith('_') || e.name.startsWith('.')) continue;
1992
+ const childRel = `${rel}/${e.name}`;
1993
+ const childAbs = path.join(abs, e.name);
1994
+ if (
1995
+ !listed.has(childRel) &&
1996
+ existsSync(path.join(childAbs, '.gitkeep')) &&
1997
+ projectDirAbs(childRel)
1998
+ ) {
1999
+ found.push(childRel);
2000
+ }
2001
+ walk(childAbs, childRel, depth + 1);
2002
+ }
2003
+ };
2004
+ for (const g of ctx.cfg.canvasGroups ?? []) {
2005
+ const rel = g.path.replace(/^\/+|\/+$/g, '');
2006
+ if (rel) walk(path.join(ctx.paths.designRoot, ...rel.split('/')), rel, 1);
2007
+ }
2008
+ if (found.length === 0) return;
2009
+ void acceptedLink.dirsCreate(found).then((r) => {
2010
+ if (r.status === 'accepted') {
2011
+ for (const d of found) knownProjectDirs.add(d);
2012
+ saveProjectDirs();
2013
+ console.log(`[sync] added ${found.length} local folder(s) to the project.`);
2014
+ }
2015
+ });
2016
+ }
2017
+
2018
+ function proposeFolder(
2019
+ op:
2020
+ | { op: 'dir.create'; path: string }
2021
+ | { op: 'dir.delete'; path: string }
2022
+ | {
2023
+ op: 'dir.move';
2024
+ from: string;
2025
+ to: string;
2026
+ }
2027
+ ): Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null {
2028
+ if (!acceptedOn() || !acceptedLink) return null;
2029
+ const root = op.op === 'dir.move' ? op.from : op.path;
2030
+ const covered: string[] = [];
2031
+ if (op.op !== 'dir.create') {
2032
+ for (const [slug, d] of descriptors) {
2033
+ const rel = path.relative(ctx.paths.designRoot, d.html).split(path.sep).join('/');
2034
+ if (rel === root || rel.startsWith(`${root}/`)) covered.push(slug);
2035
+ }
2036
+ for (const slug of covered) coveredByFolderAction.add(slug);
2037
+ }
2038
+ const answer =
2039
+ op.op === 'dir.create'
2040
+ ? acceptedLink.dirCreate(op.path)
2041
+ : op.op === 'dir.delete'
2042
+ ? acceptedLink.dirDelete(op.path)
2043
+ : acceptedLink.dirMove(op.from, op.to);
2044
+ return answer.then((r) => {
2045
+ if (r.status === 'rejected') {
2046
+ for (const slug of covered) coveredByFolderAction.delete(slug);
2047
+ return r;
2048
+ }
2049
+ const under = (d: string) => d === root || d.startsWith(`${root}/`);
2050
+ if (op.op === 'dir.create') knownProjectDirs.add(op.path);
2051
+ else {
2052
+ for (const d of [...knownProjectDirs]) {
2053
+ if (!under(d)) continue;
2054
+ knownProjectDirs.delete(d);
2055
+ if (op.op === 'dir.move') knownProjectDirs.add(`${op.to}${d.slice(op.from.length)}`);
2056
+ }
2057
+ if (op.op === 'dir.move') knownProjectDirs.add(op.to);
2058
+ }
2059
+ saveProjectDirs();
2060
+ return r;
2061
+ });
2062
+ }
2063
+
1304
2064
  async function start(): Promise<void> {
1305
2065
  if (started || stopped) return;
1306
2066
  started = true;
@@ -1355,7 +2115,24 @@ export function createSyncRuntime(
1355
2115
  // credential in place, so reading it at call time is what every other hub
1356
2116
  // call here does. Identical at boot; correct if start() ever re-runs after
1357
2117
  // a renewal (and it types, which `resolvedToken`'s `string | null` did not).
1358
- const remoteListing = await fetchRemoteListing(linkedHub.url, token);
2118
+ const remoteListingRaw = await fetchRemoteListing(linkedHub.url, token);
2119
+ if (acceptedLink) {
2120
+ await acceptedLink.refresh();
2121
+ if (acceptedOn()) {
2122
+ console.log(
2123
+ '[sync] this project saves through accepted revisions — local changes are proposed, never written into the shared document.'
2124
+ );
2125
+ // Work a previous run left unanswered goes first, in creation order —
2126
+ // before any cold start can propose something built on top of it.
2127
+ void acceptedLink.client.drainOutbox().then((results) => {
2128
+ if (results.length)
2129
+ console.log(`[sync/tx] resent ${results.length} unanswered change(s).`);
2130
+ });
2131
+ applyProjectDirs(acceptedLink.manifest?.dirs ?? []);
2132
+ proposeLocalFolders();
2133
+ }
2134
+ }
2135
+ const remoteListing = withAcceptedDocs(remoteListingRaw);
1359
2136
  // BOOT LEARNS THE DELETIONS BEFORE IT PULLS ANYTHING. The peer-side apply
1360
2137
  // lives further down (it needs the live descriptor map), so this boot pass
1361
2138
  // only has to make sure the pull does not fetch a canvas the project has
@@ -1606,6 +2383,9 @@ export function createSyncRuntime(
1606
2383
  broadcast: (payload) => ctx.bus.emit('sync:status', payload),
1607
2384
  });
1608
2385
  const store = statusStore;
2386
+ // An unfinished AI action restored from the last session (T16).
2387
+ const restoredStage = acceptedLink?.stage.summary();
2388
+ if (restoredStage) store.updateAiAction?.(restoredStage);
1609
2389
 
1610
2390
  // DDR-079 — TSX sync defaults ON, so every linked non-loopback project that
1611
2391
  // ships .tsx broadcasts the WebRTC/self-nav exfil residual (the sandbox
@@ -1664,6 +2444,28 @@ export function createSyncRuntime(
1664
2444
  });
1665
2445
  fsReader = reader;
1666
2446
 
2447
+ // Accepted revisions: an API source write announces itself before it
2448
+ // lands (`activity:suppress`, the same signal the activity rim uses).
2449
+ const projectionForRel = (rel: unknown): DocProjection | undefined => {
2450
+ if (typeof rel !== 'string' || !rel) return undefined;
2451
+ const abs = path.join(ctx.paths.designRoot, rel);
2452
+ for (const [slug, d] of descriptors) if (d.html === abs) return projections.get(slug);
2453
+ return undefined;
2454
+ };
2455
+ const unsubSuppress = ctx.bus.on('activity:suppress', (rel: unknown) => {
2456
+ if (acceptedOn()) projectionForRel(rel)?.noteLocalWrite();
2457
+ });
2458
+ // T24 — the UI operation behind that write (sync/source-ops).
2459
+ const unsubSourceOp = ctx.bus.on('source-op', (payload: unknown) => {
2460
+ const p = payload as { rel?: unknown; op?: SourceOp } | null;
2461
+ if (acceptedOn() && p?.op) projectionForRel(p.rel)?.noteSourceOp(p.op);
2462
+ });
2463
+ activityUnsubs.push(unsubSourceOp);
2464
+ const unsubUnsuppress = ctx.bus.on('activity:unsuppress', (rel: unknown) => {
2465
+ projectionForRel(rel)?.cancelLocalWrite();
2466
+ });
2467
+ activityUnsubs.push(unsubSuppress, unsubUnsuppress);
2468
+
1667
2469
  busUnsub = ctx.bus.on('fs:any', (rel: string) => {
1668
2470
  reader.notify(rel);
1669
2471
  // AN ASSET THAT APPEARS AFTER BOOT HAS TO GO UP NOW, NOT NEXT LAUNCH.
@@ -1776,7 +2578,12 @@ export function createSyncRuntime(
1776
2578
  rejectedPermanent.clear();
1777
2579
  for (const entry of entries) {
1778
2580
  mon.noteDocState(entry.canvas.slug, 'pending');
1779
- void connectCanvas(entry.canvas, entry.canvasPaths, entry.doc).catch((err) => {
2581
+ void connectCanvas(
2582
+ entry.canvas,
2583
+ entry.canvasPaths,
2584
+ entry.doc,
2585
+ owedSetups.get(entry.canvas.slug)
2586
+ ).catch((err) => {
1780
2587
  console.error(`[sync/${entry.canvas.slug}] re-probe failed:`, err);
1781
2588
  });
1782
2589
  }
@@ -1790,6 +2597,72 @@ export function createSyncRuntime(
1790
2597
  }, reprobeMs);
1791
2598
  };
1792
2599
 
2600
+ /**
2601
+ * Arm the transient lane's ONE timer (single-flight) for the earliest due
2602
+ * document — or, when the sliding budget is spent, for the moment its
2603
+ * oldest re-auth leaves the window — plus jitter.
2604
+ */
2605
+ const armTransientRetry = (): void => {
2606
+ if (transientRetryTimer !== null || stopped || rejectedTransient.size === 0) return;
2607
+ let at = Number.POSITIVE_INFINITY;
2608
+ for (const e of rejectedTransient.values()) at = Math.min(at, e.dueAt);
2609
+ const oldestSpend = transientSpent[0];
2610
+ if (transientSpent.length >= transientBatch && oldestSpend !== undefined) {
2611
+ at = Math.max(at, oldestSpend + transientRetryMs);
2612
+ }
2613
+ const delay = Math.max(0, at - renewNow()) + Math.floor(authRandom() * transientJitterMs);
2614
+ transientRetryTimer = authSetTimer(
2615
+ () => {
2616
+ transientRetryTimer = null;
2617
+ retryTransientNow();
2618
+ },
2619
+ Math.min(MAX_TIMER_DELAY_MS, delay)
2620
+ );
2621
+ };
2622
+
2623
+ /**
2624
+ * Re-authenticate the due transiently-refused documents — only those, never
2625
+ * the shared socket — up to the budget, oldest first; the rest wait for the
2626
+ * next arm. The swap is the re-probe's: destroy the refused provider and
2627
+ * reconnect on the SAME doc, so the agent and projection wiring carry over.
2628
+ * A provider attached to an already-open socket sends its token at once
2629
+ * (`HocuspocusProviderWebsocket.attach` → `provider.onOpen`).
2630
+ */
2631
+ const retryTransientNow = (): void => {
2632
+ if (stopped) return;
2633
+ const now = renewNow();
2634
+ while (transientSpent.length > 0 && now - (transientSpent[0] ?? now) >= transientRetryMs) {
2635
+ transientSpent.shift();
2636
+ }
2637
+ const due = [...rejectedTransient.entries()]
2638
+ .filter(([, e]) => e.dueAt <= now)
2639
+ .sort((x, y) => x[1].dueAt - y[1].dueAt)
2640
+ .slice(0, Math.max(0, transientBatch - transientSpent.length));
2641
+ for (const [slug, entry] of due) {
2642
+ rejectedTransient.delete(slug);
2643
+ if (providers.get(slug) !== entry.provider) continue;
2644
+ transientSpent.push(now);
2645
+ providers.delete(slug);
2646
+ try {
2647
+ entry.provider.destroy();
2648
+ } catch {
2649
+ /* best-effort */
2650
+ }
2651
+ mon.noteDocState(slug, 'pending');
2652
+ // Not a boot connect — a retry must never join the one-shot boot summary.
2653
+ void connectCanvas(
2654
+ entry.canvas,
2655
+ entry.canvasPaths,
2656
+ entry.doc,
2657
+ owedSetups.get(slug),
2658
+ false
2659
+ ).catch((err) => {
2660
+ console.error(`[sync/${slug}] transient re-auth failed:`, err);
2661
+ });
2662
+ }
2663
+ armTransientRetry();
2664
+ };
2665
+
1793
2666
  /**
1794
2667
  * Renew the hub credential in place — single-flight, silent, and never
1795
2668
  * worse than failure: an unrenewable credential leaves the stored one
@@ -1883,14 +2756,18 @@ export function createSyncRuntime(
1883
2756
  // hub's raw message).
1884
2757
  mon.noteDocState(canvas.slug, 'auth-rejected', reasonClass);
1885
2758
  rejectedReasons.set(canvas.slug, reasonClass);
2759
+ // Every class, including the transient ones that keep their provider —
2760
+ // see `rejectedAny`. This is the set the reconnect path consults.
2761
+ rejectedAny.add(canvas.slug);
1886
2762
  // Aggregate console output: ONE debounced warn for the whole burst.
1887
2763
  if (!pendingAuthWarn.has(reasonClass)) pendingAuthWarn.set(reasonClass, new Set());
1888
2764
  pendingAuthWarn.get(reasonClass)?.add(canvas.slug);
1889
2765
  if (authWarnTimer === null) authWarnTimer = authSetTimer(flushAuthWarn, warnDebounceMs);
1890
2766
  // Permanent classes: retrying only spams the hub (and its rate bucket) —
1891
2767
  // destroy the provider and re-probe on a slow timer. Transient classes
1892
- // (rate-limit / generic) keep the provider's built-in backoff.
2768
+ // (rate-limit / generic) get the paced retry below.
1893
2769
  if (reasonClass === 'not-authorized' || reasonClass === 'invalid-token') {
2770
+ rejectedTransient.delete(canvas.slug);
1894
2771
  if (!rejectedPermanent.has(canvas.slug)) {
1895
2772
  rejectedPermanent.set(canvas.slug, { canvas, canvasPaths, doc: provider.document });
1896
2773
  providers.delete(canvas.slug);
@@ -1912,6 +2789,31 @@ export function createSyncRuntime(
1912
2789
  if (renewed) reprobeNow();
1913
2790
  });
1914
2791
  }
2792
+ } else if (!rejectedTransient.has(canvas.slug)) {
2793
+ // THE HUB MAY WELL SAY YES NEXT WINDOW — BUT NOTHING WOULD ASK IT.
2794
+ //
2795
+ // These classes used to be left to "the provider's built-in backoff".
2796
+ // Under DDR-102 multiplexing that backoff is the SOCKET's reconnect
2797
+ // loop, and a partial refusal never closes a socket the other
2798
+ // documents keep healthy — so a refused document stayed refused until
2799
+ // the process ended. Queue it for the runtime's own retry instead: one
2800
+ // hub window out, doubling on each repeat (capped at the re-probe
2801
+ // interval), reset by a handshake that lands. The `has` guard keeps a
2802
+ // repeated event for one refusal from counting as a second strike.
2803
+ const strikes = (transientStrikes.get(canvas.slug) ?? 0) + 1;
2804
+ transientStrikes.set(canvas.slug, strikes);
2805
+ const wait = Math.min(
2806
+ Math.max(transientRetryMs, reprobeMs),
2807
+ transientRetryMs * 2 ** (strikes - 1)
2808
+ );
2809
+ rejectedTransient.set(canvas.slug, {
2810
+ canvas,
2811
+ canvasPaths,
2812
+ doc: provider.document,
2813
+ provider,
2814
+ dueAt: renewNow() + wait,
2815
+ });
2816
+ armTransientRetry();
1915
2817
  }
1916
2818
  };
1917
2819
 
@@ -1931,6 +2833,9 @@ export function createSyncRuntime(
1931
2833
  */
1932
2834
  const clearRejection = (slug: string): void => {
1933
2835
  rejectedPermanent.delete(slug);
2836
+ rejectedTransient.delete(slug);
2837
+ transientStrikes.delete(slug);
2838
+ rejectedAny.delete(slug);
1934
2839
  if (!rejectedReasons.delete(slug)) return;
1935
2840
  console.log(`[sync/${slug}] the hub accepted this document — clearing its refusal.`);
1936
2841
  };
@@ -1942,6 +2847,8 @@ export function createSyncRuntime(
1942
2847
  * the canvas its sync.
1943
2848
  */
1944
2849
  const stampFromLocalFile = (doc: Y.Doc, htmlAbs: string): void => {
2850
+ // The accepted replica carries the path the PROJECT gave the document.
2851
+ if (acceptedOn()) return;
1945
2852
  try {
1946
2853
  const rel = path.relative(ctx.paths.designRoot, htmlAbs).split(path.sep).join('/');
1947
2854
  if (rel && !rel.startsWith('..')) stampCanvasPath(doc, rel, ORIGINS.DISK_PROJECTION);
@@ -1994,7 +2901,7 @@ export function createSyncRuntime(
1994
2901
  canvas.html &&
1995
2902
  path.resolve(canvas.html) === movedAbs
1996
2903
  ) {
1997
- if (clearMovedTo(provider.document, ORIGINS.MIGRATION)) {
2904
+ if (!acceptedOn() && clearMovedTo(provider.document, ORIGINS.MIGRATION)) {
1998
2905
  console.log(
1999
2906
  `[sync/${canvas.slug}] this document is stamped as moved to its OWN path — clearing the stale retirement and keeping the canvas.`
2000
2907
  );
@@ -2018,7 +2925,28 @@ export function createSyncRuntime(
2018
2925
  mon.noteDocState(canvas.slug, 'pending');
2019
2926
  const projection = projections.get(canvas.slug);
2020
2927
  const agent = agents.get(canvas.slug);
2021
- if (projection) {
2928
+ if (projection && acceptedOn() && acceptedLink) {
2929
+ const docName = docNameFor(canvas.slug);
2930
+ let inProject = acceptedLink.manifest?.docs.some((d) => d.doc === docName && !d.retired);
2931
+ if (!inProject) {
2932
+ // A canvas adopted after boot: ask again before creating anything.
2933
+ await acceptedLink.refresh();
2934
+ inProject = acceptedLink.manifest?.docs.some((d) => d.doc === docName && !d.retired);
2935
+ }
2936
+ const rel = path.relative(ctx.paths.designRoot, canvas.html).split(path.sep).join('/');
2937
+ await acceptedColdStart({
2938
+ slug: canvas.slug,
2939
+ doc: provider.document,
2940
+ paths: canvasPaths,
2941
+ rel,
2942
+ inProject: inProject === true,
2943
+ projection,
2944
+ historyDir: path.join(ctx.paths.historyDir, canvas.slug),
2945
+ journal: journal ?? undefined,
2946
+ createDoc: (lanes) => acceptedLink.createDoc(canvas.slug, rel, lanes),
2947
+ });
2948
+ projection.reconcile();
2949
+ } else if (projection) {
2022
2950
  // Phase E (DDR-064 Task 9) — one-time authoritative seed BEFORE
2023
2951
  // materializing: escapes the duplication trap by picking ONE source
2024
2952
  // inside a MIGRATION transaction. DDR-102: body divergence now takes
@@ -2041,6 +2969,9 @@ export function createSyncRuntime(
2041
2969
  }
2042
2970
  },
2043
2971
  onConflict: (info) => store.addConflict(info),
2972
+ // An unmergeable local candidate: keep both, and let the projection
2973
+ // report and hold it from the shared base (T2).
2974
+ onHold: (base) => projection.adoptBase(base),
2044
2975
  hubHasState: (slug) => hubHolds(hubDocIndex, docNameFor(slug)),
2045
2976
  });
2046
2977
  if (result === 'local-adopt') {
@@ -2050,6 +2981,16 @@ export function createSyncRuntime(
2050
2981
  `[sync/${canvas.slug}] shared-doc: not seeding — the hub already holds this ` +
2051
2982
  'document; waiting for its state to arrive.'
2052
2983
  );
2984
+ } else if (result === 'conflict-merged') {
2985
+ console.log(
2986
+ `[sync/${canvas.slug}] shared-doc: merged a local edit with the project's newer ` +
2987
+ `version (both sides were in _history/${canvas.slug}/ first).`
2988
+ );
2989
+ } else if (result === 'conflict-held') {
2990
+ console.warn(
2991
+ `[sync/${canvas.slug}] shared-doc: a local edit overlaps the project's newer version — ` +
2992
+ 'both kept; the canvas waits for a resolving save.'
2993
+ );
2053
2994
  } else if (result === 'conflict-local-wins' || result === 'conflict-hub-wins') {
2054
2995
  console.warn(
2055
2996
  `[sync/${canvas.slug}] shared-doc: diverged — kept the ${
@@ -2079,6 +3020,8 @@ export function createSyncRuntime(
2079
3020
  // DDR-102 — honest status: the handshake + reconcile completed.
2080
3021
  mon.noteDocState(canvas.slug, 'connected');
2081
3022
  mon.noteSyncActivity(canvas.slug);
3023
+ lastPromotionAt = stallNow();
3024
+ forcedReconnects = 0;
2082
3025
  // F1 — a completed handshake IS progress: a renewal actually helped, so
2083
3026
  // the no-progress cap resets. Without this a healthy link that renews
2084
3027
  // legitimately every 12 h would burn one cap slot per renewal forever.
@@ -2108,6 +3051,122 @@ export function createSyncRuntime(
2108
3051
  }
2109
3052
  };
2110
3053
 
3054
+ /**
3055
+ * A DOCUMENT THAT RE-HANDSHAKES IS A SYNCED DOCUMENT AGAIN.
3056
+ *
3057
+ * The bug this closes (issue #118), stated plainly: `noteDocState(slug,
3058
+ * 'connected')` had exactly ONE call site — the post-handshake reconcile
3059
+ * inside `connectCanvas`, which runs at attach, adopt and auth-re-probe and
3060
+ * at no other time. A dropped socket demotes every `connected` document to
3061
+ * `pending` (`noteProviderStatus`, deliberately — a document whose socket is
3062
+ * gone is not a synced document), and NOTHING re-promoted it when the socket
3063
+ * came back. The demotion was therefore permanent for the life of the
3064
+ * runtime: observed live as `state:"online", docs:{synced:0, pending:85}`
3065
+ * fifteen minutes after a boot whose own log line read `85/87 synced`.
3066
+ *
3067
+ * Downstream that reads as the worst kind of wrong: `syncPresentation` sees
3068
+ * "connected to the hub, zero documents settled, and it has been like that a
3069
+ * while" and reports a STALL blamed on the credential — so the remedy the
3070
+ * product offered (reconnect the workspace) was the one thing that could not
3071
+ * help, and the Resync it prompts re-handshakes every document against a
3072
+ * cold cell and lands on `offline`.
3073
+ *
3074
+ * What this does NOT do is re-run `handleSynced`. That path carries
3075
+ * migrate-seed, cold-start divergence resolution and history snapshots — a
3076
+ * one-time authoritative seed, correctly named as such, and firing it for
3077
+ * every canvas on every reconnect would turn a network blip into a
3078
+ * conflict-resolution storm. The honest signal is narrower and sufficient:
3079
+ * the hub completed a sync handshake for THIS document, so it is connected
3080
+ * again. Nothing on disk is touched.
3081
+ *
3082
+ * Single-flight per slug — a flapping socket must not stack one pending
3083
+ * promise per transition — and every guard is re-checked AFTER the await,
3084
+ * because a runtime can stop, and a canvas can be released, while we wait.
3085
+ */
3086
+ const repromoting = new Set<string>();
3087
+ const repromoteOnReconnect = async (slug: string, provider: SyncProvider): Promise<void> => {
3088
+ if (stopped || repromoting.has(slug)) return;
3089
+ // REFUSED IS REFUSED, WHATEVER THE CLASS.
3090
+ //
3091
+ // This guard read `rejectedPermanent`, and that map holds only the two
3092
+ // PERMANENT classes — `generic` and `rate-limit` keep their provider
3093
+ // until the transient retry swaps it, and never enter it. `generic` is what
3094
+ // EVERY pre-DDR-102 hub sends. So a refusal in a transient class passed
3095
+ // straight through this guard and got overwritten with `connected`
3096
+ // (attacker review 2026-09-03, F3): the hub was dropping this document's
3097
+ // writes while the panel rendered it green, `docs.rejected` fell back to
3098
+ // 0, and the `refused` branch — plus the offline-long "git push as
3099
+ // backup" escalation — could never fire. `rejectedAny` is the honest set:
3100
+ // every class enters it, `clearRejection` is the only thing that empties
3101
+ // it, and it is checked on BOTH sides of the await because the refusal
3102
+ // that matters is the one that lands while we are waiting.
3103
+ if (rejectedAny.has(slug)) return;
3104
+ repromoting.add(slug);
3105
+ try {
3106
+ // BOUNDED, because `onceSynced()` can never settle.
3107
+ //
3108
+ // `handleAuthFailure` on a permanent class calls `provider.destroy()`,
3109
+ // and destroy emits `destroy`, never `synced` — so the promise below
3110
+ // stays pending for the life of the process, `finally` never runs, and
3111
+ // the slug stays latched in `repromoting` forever. Every later genuine
3112
+ // reconnect for that document would then return at the guard above:
3113
+ // issue #118 recreated per-document, permanently, by its own fix
3114
+ // (attacker review 2026-09-03, F4). `settleWait` already exists for
3115
+ // exactly this ("never hangs the summary on an auth-rejected provider
3116
+ // whose handshake never completes") — reuse it rather than invent a
3117
+ // second ceiling.
3118
+ //
3119
+ // `settleWait` resolves on the TIMEOUT as well as on success, so the
3120
+ // flag is what separates "the handshake landed" from "we stopped
3121
+ // waiting for it". Without it the timeout would promote the document.
3122
+ let landed = false;
3123
+ // The ceiling below stops us WAITING; the abort is what stops us
3124
+ // LISTENING. Skipping it leaked a `synced` listener + closure per
3125
+ // abandoned wait, forever (N2) — invisible while the latch bug meant we
3126
+ // never retried, unbounded once that was fixed.
3127
+ const giveUp = new AbortController();
3128
+ try {
3129
+ await settleWait(
3130
+ provider.onceSynced(giveUp.signal).then(() => {
3131
+ landed = true;
3132
+ })
3133
+ );
3134
+ } finally {
3135
+ giveUp.abort();
3136
+ }
3137
+ if (!landed || stopped) return;
3138
+ // Still ours, still not refused — either can have changed in flight.
3139
+ if (providers.get(slug) !== provider || rejectedAny.has(slug)) return;
3140
+ // ONE mutator, not two. `noteSyncActivity` promotes pending→connected
3141
+ // AND stamps the clock in a single emit, and it is the one that refuses
3142
+ // to resurrect an `auth-rejected` document — so it is both cheaper and
3143
+ // safer here than pairing it with `noteDocState`. Every emit is a
3144
+ // synchronous `_sync.json` write plus a fan-out to every open tab, and
3145
+ // `noteSyncActivity` has no change-dedupe of its own: on an 85-canvas
3146
+ // project the pair cost ~340 writes per hub-driven flap against ~170
3147
+ // for the demotion alone (defender F4 / attacker F5).
3148
+ //
3149
+ // Residual, stated rather than hidden: a completed handshake is not
3150
+ // proof that a byte was persisted, so a hub that flaps the socket and
3151
+ // completes empty sync steps can keep `lastSyncAt` fresh. That is
3152
+ // narrower than the socket-transition stamp this change removed from
3153
+ // `goOnline`, but it is the same shape, and it wants a separate
3154
+ // `noteHandshake()` the day the monitor grows one.
3155
+ mon.noteSyncActivity(slug);
3156
+ lastPromotionAt = stallNow();
3157
+ // A recovery that WORKED must not leave the floor ratcheted. Without
3158
+ // this, seven stall-and-recover cycles over a long session pin the
3159
+ // watchdog's floor at its 30-minute cap, so the next genuine stall waits
3160
+ // half an hour for help (verification review 2026-09-03, N3).
3161
+ forcedReconnects = 0;
3162
+ // A completed handshake is progress, exactly as it is on the first
3163
+ // connect — see the `renewalsSinceProgress` reset in `handleSynced`.
3164
+ renewalsSinceProgress = 0;
3165
+ } finally {
3166
+ repromoting.delete(slug);
3167
+ }
3168
+ };
3169
+
2111
3170
  /** onceSynced() with the boot-settle ceiling — never hangs the summary on
2112
3171
  * an auth-rejected provider (whose handshake never completes). */
2113
3172
  const settleWait = (p: Promise<void>): Promise<void> =>
@@ -2280,6 +3339,7 @@ export function createSyncRuntime(
2280
3339
  // the same order relative to each other.
2281
3340
  const deferSetup = !!setup && pulledSlugs.has(canvas.slug);
2282
3341
  if (!deferSetup) setup?.(provider);
3342
+ else if (setup) owedSetups.set(canvas.slug, setup);
2283
3343
 
2284
3344
  // Task 8 — feed this provider's WS status into the offline monitor.
2285
3345
  // Per-provider, so a socket that never dropped never triggers a poll.
@@ -2310,7 +3370,14 @@ export function createSyncRuntime(
2310
3370
  // wanted here: a genuine one-off reconnect (nothing poked
2311
3371
  // recently) runs immediately; a churn folds into the scheduled
2312
3372
  // tick, costing latency and never correctness.
2313
- if (s === 'connected' && wasDisconnected) pollRemoteSoon({ cooled: true });
3373
+ if (s === 'connected' && wasDisconnected) {
3374
+ pollRemoteSoon({ cooled: true });
3375
+ // …and re-establish this DOCUMENT's truth, not just the project's.
3376
+ // The poll above asks the hub what canvases exist; it says nothing
3377
+ // about whether THIS one is synced again, and for a long time
3378
+ // nothing did. See `repromoteOnReconnect`.
3379
+ void repromoteOnReconnect(canvas.slug, provider);
3380
+ }
2314
3381
  wasDisconnected = s !== 'connected';
2315
3382
  })
2316
3383
  );
@@ -2342,6 +3409,8 @@ export function createSyncRuntime(
2342
3409
  // Cold-start reconcile fires once the provider has hub state.
2343
3410
  const synced = provider.onceSynced().then(() => {
2344
3411
  if (deferSetup) {
3412
+ // Settled either way below — run, or the canvas is released.
3413
+ owedSetups.delete(canvas.slug);
2345
3414
  // Abandon before `setup?.()`, so no projection and no agent is ever
2346
3415
  // built for a canvas we are not going to place — nothing exists that
2347
3416
  // could flush the document onto the provisional path on the way out.
@@ -2416,6 +3485,50 @@ export function createSyncRuntime(
2416
3485
  paths: canvasPaths,
2417
3486
  echoGuard,
2418
3487
  journal: journal ?? undefined,
3488
+ historyDir: path.join(ctx.paths.historyDir, canvas.slug),
3489
+ waitForReconcile: true,
3490
+ ...(acceptedLink
3491
+ ? {
3492
+ accepted: acceptedLink.laneLink(canvas.slug),
3493
+ revisionBarrier,
3494
+ replayOp: (op: SourceOp, head: string) =>
3495
+ replaySourceOp(canvas.html, op, head),
3496
+ onAccepted: ({
3497
+ lane,
3498
+ value,
3499
+ actionId,
3500
+ }: {
3501
+ lane: string;
3502
+ value: string;
3503
+ actionId: string;
3504
+ }) => noteAcceptedContent(canvas.slug, lane, value, actionId),
3505
+ onRevisionApplied: (rev: number) => store.noteAppliedRevision?.(rev),
3506
+ }
3507
+ : {}),
3508
+ // A write the hub would drop is held, never made (see isWritable).
3509
+ ...(provider.isWritable
3510
+ ? {
3511
+ canWriteDoc: () => provider.isWritable?.() !== false,
3512
+ onWriteBlocked: () => void refreshAcceptedMode(),
3513
+ }
3514
+ : {}),
3515
+ onRecovered: () => store.clearSourceConflict(canvas.slug),
3516
+ onConflict: (info) => {
3517
+ store.addConflict(info);
3518
+ store.notice({
3519
+ id: `source-conflict-${canvas.slug}`,
3520
+ severity: 'warn',
3521
+ text: `Source sync blocked for ${canvas.slug}. The local file was kept because ${
3522
+ info.reason === 'invalid-source'
3523
+ ? 'the source has syntax errors or duplicate declarations'
3524
+ : info.reason === 'local-edit'
3525
+ ? 'a local edit overlaps an incoming change'
3526
+ : info.reason === 'merge-budget'
3527
+ ? 'the edit is too large to merge safely'
3528
+ : 'a recovery copy could not be saved'
3529
+ }. ${info.snapshotFailed ? 'Recovery could not be saved; check disk space and history write access.' : `Recovery copies are in _history/${canvas.slug}/sync-recovery/.`}`,
3530
+ });
3531
+ },
2419
3532
  // Cell pairing only — see the DocProjectionOptions.onWrote doc.
2420
3533
  // The synthetic event is delayed by the same margin the container
2421
3534
  // write bridge uses, so a watcher that DOES fire wins the race and
@@ -2455,6 +3568,55 @@ export function createSyncRuntime(
2455
3568
  // cleans up.)
2456
3569
  watchForRetirement(canvas.slug, provider.document);
2457
3570
 
3571
+ // THE HUB SAYS THE SAVE MODE CHANGED — on this document's own
3572
+ // socket, ahead of closing it, so the switch is learned before any
3573
+ // further local change is made (DDR-241 §7 switch ordering).
3574
+ if (acceptedLink && provider.onStateless) {
3575
+ noteDetach(
3576
+ statusDetaches,
3577
+ canvas.slug,
3578
+ provider.onStateless((payload) => {
3579
+ let msg: { type?: unknown; mode?: unknown } | null = null;
3580
+ try {
3581
+ msg = JSON.parse(payload);
3582
+ } catch {
3583
+ return;
3584
+ }
3585
+ if (msg?.type !== 'maude.mode') return;
3586
+ if (msg.mode === 'transactions' || msg.mode === 'legacy') {
3587
+ acceptedLink.noteMode(msg.mode);
3588
+ void refreshAcceptedMode();
3589
+ }
3590
+ })
3591
+ );
3592
+ }
3593
+ if (provider.onAuthenticated) {
3594
+ const slug = canvas.slug;
3595
+ noteDetach(
3596
+ statusDetaches,
3597
+ slug,
3598
+ provider.onAuthenticated(() => projections.get(slug)?.retryDeferred())
3599
+ );
3600
+ }
3601
+
3602
+ // ACCEPTED-REPLICA TRIPWIRE (DDR-241 §7, plan T6/T12). In
3603
+ // transactions mode the hub drops every update this connection
3604
+ // sends, so a local write here never reaches the project and the
3605
+ // replica silently diverges. Nothing is supposed to do it; this is
3606
+ // how we find out if something does.
3607
+ if (acceptedLink && provider.isRemoteOrigin) {
3608
+ const slug = canvas.slug;
3609
+ const onAny = (_u: Uint8Array, origin: unknown) => {
3610
+ if (!acceptedOn() || provider.isRemoteOrigin?.(origin)) return;
3611
+ acceptedWriteViolations += 1;
3612
+ console.error(
3613
+ `[sync/${slug}] a local write reached the accepted replica (origin: ${describeOrigin(origin)}) — it cannot reach the project.`
3614
+ );
3615
+ };
3616
+ provider.document.on('update', onAny);
3617
+ noteDetach(statusDetaches, slug, () => provider.document.off('update', onAny));
3618
+ }
3619
+
2458
3620
  // Count local edits (agent-origin doc updates) toward queuedOps while
2459
3621
  // the hub is unreachable — the banner's "N edits queued" figure. Under
2460
3622
  // sharedDoc there is no agent origin to key off (browser edits carry a
@@ -2665,6 +3827,17 @@ export function createSyncRuntime(
2665
3827
  if (typeof slug !== 'string' || !slug) return;
2666
3828
  if (revive) tombstoned.delete(slug);
2667
3829
  else tombstoned.add(slug);
3830
+ if (acceptedOn() && acceptedLink) {
3831
+ // A re-created canvas is proposed by its cold start (`doc.create`); a
3832
+ // deletion is ONE project action unless a folder action covered it.
3833
+ if (revive || coveredByFolderAction.delete(slug)) return;
3834
+ void acceptedLink.deleteDoc(slug).then((r) => {
3835
+ if (r.status === 'rejected' && r.code !== 'dependency-missing') {
3836
+ console.warn(`[sync] the project did not accept deleting ${slug} (${r.code})`);
3837
+ }
3838
+ });
3839
+ return;
3840
+ }
2668
3841
  void stateDocumentGone(linkedHub.url, token, docNameFor(slug), { revive }).then((ok) => {
2669
3842
  if (!ok) {
2670
3843
  console.warn(
@@ -2717,6 +3890,13 @@ export function createSyncRuntime(
2717
3890
  annotations: canvas.annotations,
2718
3891
  },
2719
3892
  });
3893
+ if (!existsSync(canvas.html)) {
3894
+ ctx.bus.emit('canvas-list-update', {
3895
+ action: 'removed',
3896
+ rel: path.relative(ctx.paths.designRoot, canvas.html).split(path.sep).join('/'),
3897
+ slug,
3898
+ });
3899
+ }
2720
3900
  }
2721
3901
  descriptors.delete(slug);
2722
3902
  }
@@ -2743,11 +3923,18 @@ export function createSyncRuntime(
2743
3923
  // not told the document exists.
2744
3924
  const pullRemoteOnce = async (): Promise<void> => {
2745
3925
  if (stopped) return;
3926
+ // Accepted revisions: the save mode can change under a running peer (the
3927
+ // hub fences every socket when it does), and folders are manifest
3928
+ // entries — neither is in the document listing.
3929
+ if (acceptedLink) {
3930
+ await acceptedLink.refresh();
3931
+ if (acceptedOn()) applyProjectDirs(acceptedLink.manifest?.dirs ?? []);
3932
+ }
2746
3933
  // Read `token` at call time: a silent renewal swaps it in place.
2747
- const listing = await fetchRemoteListing(linkedHub.url, token);
3934
+ const listing = withAcceptedDocs(await fetchRemoteListing(linkedHub.url, token));
2748
3935
  // null = unreachable, refused, or a hub without the route. Not an error
2749
3936
  // here any more than it is at boot — sync continues, we ask again later.
2750
- if (listing === null) return;
3937
+ if (stopped || listing === null) return;
2751
3938
  // ABSENCE BEFORE PRESENCE. A canvas the project deleted must leave before
2752
3939
  // the pull runs, or a slug that is tombstoned AND still listed (the window
2753
3940
  // between the tombstone and the row actually going) would be trashed and
@@ -2840,6 +4027,82 @@ export function createSyncRuntime(
2840
4027
  // Cumulative per boot — the Sync panel's one line. `synced` is the last
2841
4028
  // pass's converged count; `pulled`/`conflicts` accumulate.
2842
4029
  const fileTotals = { synced: 0, pulled: 0, conflicts: 0 };
4030
+ /** Previous progress, for `startedAt` continuity across ticks. */
4031
+ let lastSeedProgress: import('./seed-progress.ts').SeedProgress | null = null;
4032
+ /** When the last serve-log progress line went out, and what it said. */
4033
+ let lastProgressLogAt = 0;
4034
+ let lastProgressLine = '';
4035
+ /**
4036
+ * ONE LINE, PERIODICALLY, WHILE A SEED IS RUNNING.
4037
+ *
4038
+ * After boot the file plane printed NOTHING. Two runs of 14 and 6 minutes
4039
+ * that moved zero files were, from the terminal, indistinguishable from two
4040
+ * runs that were working — the only repeated line was a delete-breaker
4041
+ * warning that fired 47 times and buried everything else. The doc lane has
4042
+ * printed its one-shot summary since Phase 9; this is the file plane's.
4043
+ *
4044
+ * Rate-limited AND deduped: a converged project prints nothing at all, and
4045
+ * a stalled one prints the same line at most once every 15 s rather than
4046
+ * once per pass.
4047
+ */
4048
+ const reportSeedProgress = (
4049
+ p: import('./seed-progress.ts').SeedProgress,
4050
+ r: import('./file-plane.ts').FilePlaneResult
4051
+ ): void => {
4052
+ if (p.phase === 'converged') return;
4053
+ const blockedTotal = p.blocked.reduce((n, b) => n + b.count, 0);
4054
+ const bits = [`${p.delivered} / ${p.tracked} delivered`];
4055
+ if (p.remaining > 0) bits.push(`${p.remaining} waiting`);
4056
+ if (blockedTotal > 0) bits.push(`${blockedTotal} need attention`);
4057
+ if (r.backedOff) bits.push(`${r.backedOff} backing off`);
4058
+ let tail = '';
4059
+ if (r.rateLimited) {
4060
+ const secs = Math.max(1, Math.round((r.rateLimited.until - Date.now()) / 1000));
4061
+ const why =
4062
+ r.rateLimited.cause === 'unreachable'
4063
+ ? 'could not reach the workspace'
4064
+ : r.rateLimited.cause === 'quota'
4065
+ ? 'hourly upload allowance used up'
4066
+ : 'the workspace asked us to slow down';
4067
+ tail = ` · paused ${secs}s (${why})`;
4068
+ } else if (p.passCapped) {
4069
+ tail = ` · more next pass (${p.passCapped} ceiling)`;
4070
+ }
4071
+ const line = `[sync/files] ${bits.join(' · ')}${tail}`;
4072
+ const nowMs = Date.now();
4073
+ if (line === lastProgressLine && nowMs - lastProgressLogAt < SEED_PROGRESS_LOG_MS) return;
4074
+ if (nowMs - lastProgressLogAt < SEED_PROGRESS_LOG_MS) return;
4075
+ lastProgressLogAt = nowMs;
4076
+ lastProgressLine = line;
4077
+ console.log(line);
4078
+ };
4079
+ /** When the last pass ended, so a throughput sample has a window. */
4080
+ let lastPassEndedAt = 0;
4081
+ /**
4082
+ * Bytes that actually LANDED in the last pass, over the wall-clock since
4083
+ * the previous one.
4084
+ *
4085
+ * Bytes DELIVERED, never bytes sent. The 2026-09-03 misreading was exactly
4086
+ * this distinction: 616 MB left the machine across two runs and zero files
4087
+ * landed, so a rate computed from egress said "about ten minutes" when the
4088
+ * true answer was "never".
4089
+ */
4090
+ const throughputSample = (
4091
+ r: import('./file-plane.ts').FilePlaneResult
4092
+ ): { bytes: number; ms: number } | null => {
4093
+ const landed = r.pushed.length + r.pulled.length;
4094
+ const nowMs = Date.now();
4095
+ const ms = lastPassEndedAt > 0 ? nowMs - lastPassEndedAt : 0;
4096
+ lastPassEndedAt = nowMs;
4097
+ if (landed === 0 || ms <= 0) return null;
4098
+ const rows = fileLedger?.rows() ?? {};
4099
+ let bytes = 0;
4100
+ for (const rel of [...r.pushed, ...r.pulled]) {
4101
+ const size = rows[rel]?.size;
4102
+ if (Number.isFinite(size)) bytes += size as number;
4103
+ }
4104
+ return bytes > 0 ? { bytes, ms } : null;
4105
+ };
2843
4106
  const noteFilePull = (result: FilePullResult): void => {
2844
4107
  fileTotals.synced = result.skipped + result.pulled.length;
2845
4108
  fileTotals.pulled += result.pulled.length;
@@ -2893,12 +4156,75 @@ export function createSyncRuntime(
2893
4156
  'The project has asked to start over repeatedly, which is what a broken or hostile hub looks like. Nothing was overwritten. Sync retries by itself; if this persists, the hub needs looking at.',
2894
4157
  });
2895
4158
  }
4159
+ // THE DENOMINATOR, from the LEDGER — not from this pass.
4160
+ //
4161
+ // `fileTotals` below is derived from per-pass results, and a pass that
4162
+ // converges nothing is legitimately all zeros: that is why `_sync.json`
4163
+ // read `synced: 0, pushed: 0, pulled: 0` for twenty minutes while 2 961
4164
+ // ledger rows changed underneath it. The raw counters STAY beside this
4165
+ // (DDR-214: a panel derived from the same source it displays cannot be
4166
+ // cross-checked), but the progress a person reads comes from the source
4167
+ // that was correct the whole time.
4168
+ const progress = filePlane
4169
+ ? computeSeedProgress({
4170
+ rows: fileLedger?.rows() ?? {},
4171
+ now: Date.now(),
4172
+ pausedUntil: result.rateLimited?.until ?? null,
4173
+ pauseCause: result.rateLimited?.cause ?? null,
4174
+ ...(result.requestsExhausted
4175
+ ? { passCapped: 'requests' as const }
4176
+ : result.budgetExhausted
4177
+ ? { passCapped: 'bytes' as const }
4178
+ : {}),
4179
+ previous: lastSeedProgress,
4180
+ deliveredSince: throughputSample(result),
4181
+ })
4182
+ : null;
4183
+ if (progress) {
4184
+ lastSeedProgress = progress;
4185
+ reportSeedProgress(progress, result);
4186
+ }
2896
4187
  statusStore?.updateFiles?.({
2897
4188
  ...fileTotals,
2898
4189
  pushed: filePushed,
2899
- ...(filePlane ? { delivery: filePlane.doruceka() } : {}),
4190
+ ...(progress ? { progress } : {}),
4191
+ ...(filePlane
4192
+ ? {
4193
+ delivery: filePlane.doruceka(),
4194
+ ...(filePlane.dorucekaTotal() > MAX_DORUCEKA_ROWS
4195
+ ? { deliveryTruncated: filePlane.dorucekaTotal() - MAX_DORUCEKA_ROWS }
4196
+ : {}),
4197
+ }
4198
+ : {}),
2900
4199
  ...(held.length > 0 ? { held } : {}),
4200
+ // A FAILED TRANSFER IS NOT A SYNCED ONE (issue #109). These had no
4201
+ // field to land in, so a pass that refused every file still rendered
4202
+ // as `synced` and the only trace was the console lines below — on a
4203
+ // product whose premise is that nobody opens a terminal.
4204
+ ...(result.failed.length > 0 ? { failed: result.failed.length } : {}),
4205
+ ...(result.rateLimited
4206
+ ? {
4207
+ rateLimited: {
4208
+ until: result.rateLimited.until,
4209
+ waiting: result.rateLimited.waiting,
4210
+ },
4211
+ }
4212
+ : {}),
2901
4213
  });
4214
+ if (result.rateLimited) {
4215
+ // Once per boot (the store dedupes by id): a pause is not an error,
4216
+ // and the panel should say so in words rather than leave a person to
4217
+ // read a failure count and guess.
4218
+ statusStore?.notice?.({
4219
+ id: 'files-rate-limited',
4220
+ severity: 'info',
4221
+ text: `The workspace asked this machine to slow down, so file syncing is paused for a moment. ${
4222
+ result.rateLimited.waiting > 0
4223
+ ? `${result.rateLimited.waiting} file(s) are still on their way — nothing is lost, they arrive when the pause lifts.`
4224
+ : 'Nothing is lost; it resumes by itself.'
4225
+ }`,
4226
+ });
4227
+ }
2902
4228
  for (const f of result.failed) {
2903
4229
  console.warn(`[sync/files] ${f.rel}: ${f.reason}`);
2904
4230
  }
@@ -2927,8 +4253,10 @@ export function createSyncRuntime(
2927
4253
  // journal-less hubs, which the compat matrix keeps working through the
2928
4254
  // burn-down window.
2929
4255
  if (filePlane) {
2930
- const result = await filePlane.reconcile();
2931
- noteFilePlane(result);
4256
+ // Through the SAME door as the poke path, so the two can never overlap
4257
+ // (issue #109). `floor: false` — this caller is already bounded, and a
4258
+ // deferral would make an awaited pass silently not happen.
4259
+ await runPlanePass({ floor: false });
2932
4260
  return;
2933
4261
  }
2934
4262
  const result = await pullFiles({
@@ -2940,13 +4268,20 @@ export function createSyncRuntime(
2940
4268
  });
2941
4269
  noteFilePull(result);
2942
4270
  };
4271
+ documentDiscovery = createDocumentDiscovery({
4272
+ run: pullRemoteOnce,
4273
+ onError: (error) => console.error('[sync] document discovery failed:', error),
4274
+ });
4275
+ const discovery = documentDiscovery;
4276
+ documentDiscoveryUnsub = ctx.bus.on('sync:documents-changed', () => discovery.schedule());
2943
4277
  const pollRemote = (): void => {
2944
- void pullRemoteOnce()
4278
+ void discovery
4279
+ .flush()
2945
4280
  .then(() => pullFilesOnce())
2946
4281
  .catch((err) => console.error('[sync] remote poll failed:', err));
2947
4282
  };
2948
4283
  remotePull = async () => {
2949
- await pullRemoteOnce();
4284
+ await discovery.flush();
2950
4285
  await pullFilesOnce();
2951
4286
  };
2952
4287
  remotePollTimer = setInterval(pollRemote, REMOTE_POLL_MS);
@@ -2954,6 +4289,81 @@ export function createSyncRuntime(
2954
4289
  // dev server to refuse to exit.
2955
4290
  remotePollTimer.unref?.();
2956
4291
 
4292
+ // ── The stall watchdog (issue #118) ────────────────────────────────────
4293
+ //
4294
+ // WE CANNOT TRUST THE SOCKET TO NOTICE. `HocuspocusProviderWebsocket`
4295
+ // reports `connected` from the raw WS `open` event — before a token is
4296
+ // presented, before a byte comes back — and its own silence watchdog
4297
+ // (`checkConnection`) returns early while `lastMessageReceived === 0`,
4298
+ // which is precisely the state every fresh connection starts in. So a
4299
+ // socket that upgrades at the edge and then hears nothing (a cell woken
4300
+ // from `sleepAfter`, still restoring behind a DO that has accepted the
4301
+ // request) is PERMANENTLY "connected" with its watchdog switched off. No
4302
+ // close event, no retry, no reconnect — and therefore, before this, no
4303
+ // recovery short of the person pressing Resync.
4304
+ //
4305
+ // This is the liveness check the runtime should have owned all along: it
4306
+ // judges the link by whether any DOCUMENT has settled, which is the thing
4307
+ // the user actually wants, and it is the one signal a half-open socket
4308
+ // cannot fake. When nothing has settled for long enough, force the socket
4309
+ // down and back up; every provider re-authenticates on the new one and
4310
+ // `repromoteOnReconnect` collects the result.
4311
+ //
4312
+ // Deliberately narrow, because a false positive costs a full re-handshake
4313
+ // of every canvas:
4314
+ // • `state === 'online'` — offline already has its own recovery path.
4315
+ // • `synced === 0 && pending > 0` — some progress means it is working,
4316
+ // just slowly; this is only for the total stall.
4317
+ // • `rejected === 0` — a refusal is the auth lane's: renew + re-probe for
4318
+ // the permanent classes, the paced per-document retry for the
4319
+ // transient ones. Cycling the socket would re-authenticate EVERY
4320
+ // document to recover a few, and a volume refusal is exactly when
4321
+ // that burst hurts most.
4322
+ // • a floor between forced reconnects, so a hub that is simply down
4323
+ // cannot be turned into a reconnect storm by its own silence.
4324
+ // The socket-cycling capability, resolved once.
4325
+ //
4326
+ // Was `ownedFactory`, which is null whenever a factory is INJECTED — so the
4327
+ // watchdog silently no-op'd in every test that could have exercised it, and
4328
+ // its jitter and backoff had no coverage at all (verification review
4329
+ // 2026-09-03). An injected factory that offers `reconnect()` is just as
4330
+ // usable. The `typeof` check is also the N4 guard: `sockets` is a
4331
+ // `Map<string, any>` and the provider is a caret dependency, so a 4.x minor
4332
+ // that renames this would otherwise degrade to a watchdog that can never
4333
+ // recover, saying nothing.
4334
+ const reconnectable: { reconnect(): void } | null =
4335
+ ownedFactory ??
4336
+ (typeof (providerFactory as Partial<DisposableProviderFactory>).reconnect === 'function'
4337
+ ? (providerFactory as DisposableProviderFactory)
4338
+ : null);
4339
+ const stallCheck = (): void => {
4340
+ if (stopped || !reconnectable) return;
4341
+ const snap = mon.snapshot();
4342
+ const docs = snap.docs;
4343
+ if (snap.state !== 'online' || !docs) return;
4344
+ if (docs.synced > 0 || docs.pending === 0 || docs.rejected > 0) return;
4345
+ const now = stallNow();
4346
+ if (now - lastPromotionAt < stallAfterMs + stallJitterMs) return;
4347
+ // The floor DOUBLES per forced reconnect (5 → 10 → 20 → capped at 30 min).
4348
+ // A hub that is simply down would otherwise buy a fresh N-document auth
4349
+ // burst from every peer every five minutes, forever.
4350
+ const floor = Math.min(STALL_RECONNECT_MAX_MS, stallMinMs * 2 ** forcedReconnects);
4351
+ if (lastForcedReconnectAt !== 0 && now - lastForcedReconnectAt < floor) return;
4352
+ lastForcedReconnectAt = now;
4353
+ forcedReconnects += 1;
4354
+ console.warn(
4355
+ `[sync] the hub socket says connected but not one of ${docs.pending} document(s) has ` +
4356
+ `synced in ${Math.round((now - lastPromotionAt) / 60_000)} minute(s) — forcing a reconnect.`
4357
+ );
4358
+ try {
4359
+ reconnectable.reconnect();
4360
+ } catch (err) {
4361
+ console.warn(`[sync] forced reconnect failed: ${(err as Error).message}`);
4362
+ }
4363
+ };
4364
+ stallTimer = setInterval(stallCheck, stallCheckMs);
4365
+ stallTimer.unref?.();
4366
+
2957
4367
  // ── Sync v2 Increment 2 — the poke, desktop side (DDR-226 §4) ──────────
2958
4368
  //
2959
4369
  // CAPABILITY-GATED, and the gate is the compat matrix (§10, BINDING): a
@@ -2991,6 +4401,23 @@ export function createSyncRuntime(
2991
4401
  designRoot: ctx.paths.designRoot,
2992
4402
  hubUrl: linkedHub.url,
2993
4403
  token: () => token,
4404
+ // THE FILE PLANE CAN NOW ASK FOR A CREDENTIAL. It could not
4405
+ // before: renewal was reachable only from the doc lane's
4406
+ // WebSocket auth failure, so a token that expired mid-seed left
4407
+ // hundreds of files refused with nothing ever asking for a new
4408
+ // one (2026-09-03). Same single-flight entry point the doc lane
4409
+ // uses — never a second renewal path.
4410
+ onAuthFailure: () => {
4411
+ void renewCredentialNow();
4412
+ },
4413
+ // A DELIVERED FILE IS PROGRESS. `renewalsSinceProgress` counted
4414
+ // only doc handshakes, and a converged doc lane has none left to
4415
+ // land — so during a long seed the cap was reached and the
4416
+ // runtime stopped renewing while the file plane was still
4417
+ // working.
4418
+ onProgress: () => {
4419
+ renewalsSinceProgress = 0;
4420
+ },
2994
4421
  ledger: fileLedger,
2995
4422
  canvasGroups: ctx.cfg.canvasGroups,
2996
4423
  allowCodeModules,
@@ -3024,6 +4451,7 @@ export function createSyncRuntime(
3024
4451
  fileEventsCtl = createCtlProvider({
3025
4452
  url: linkedHub.url,
3026
4453
  token,
4454
+ onDocuments: () => documentDiscovery?.schedule(),
3027
4455
  onPoke: () => {
3028
4456
  // Reuses `pollRemoteSoon` rather than calling the file lanes
3029
4457
  // directly, for two reasons: it already coalesces a burst into
@@ -3064,6 +4492,8 @@ export function createSyncRuntime(
3064
4492
  async function stop(): Promise<void> {
3065
4493
  if (stopped) return;
3066
4494
  stopped = true;
4495
+ acceptedLink?.stop();
4496
+ revisionBarrier.stop();
3067
4497
  fileEventsProbe?.abort();
3068
4498
  fileEventsProbe = null;
3069
4499
  if (filePassTimer !== null) clearTimeout(filePassTimer);
@@ -3096,8 +4526,14 @@ export function createSyncRuntime(
3096
4526
  discoveryRescan = null;
3097
4527
  if (remotePollTimer !== null) clearInterval(remotePollTimer);
3098
4528
  remotePollTimer = null;
4529
+ if (stallTimer !== null) clearInterval(stallTimer);
4530
+ stallTimer = null;
3099
4531
  if (remotePollSoonTimer !== null) clearTimeout(remotePollSoonTimer);
3100
4532
  remotePollSoonTimer = null;
4533
+ documentDiscoveryUnsub?.();
4534
+ documentDiscoveryUnsub = null;
4535
+ documentDiscovery?.stop();
4536
+ documentDiscovery = null;
3101
4537
  remotePull = null;
3102
4538
  // A push pass that outlives its runtime keeps uploading a project the
3103
4539
  // person just closed — and `restart()` (the Resync button) calls stop() on
@@ -3136,6 +4572,10 @@ export function createSyncRuntime(
3136
4572
  authClearTimer(reprobeTimer);
3137
4573
  reprobeTimer = null;
3138
4574
  }
4575
+ if (transientRetryTimer !== null) {
4576
+ authClearTimer(transientRetryTimer);
4577
+ transientRetryTimer = null;
4578
+ }
3139
4579
  if (renewTimer !== null) {
3140
4580
  authClearTimer(renewTimer);
3141
4581
  renewTimer = null;
@@ -3145,8 +4585,13 @@ export function createSyncRuntime(
3145
4585
  for (const h of announceTimers.values()) clearTimeout(h);
3146
4586
  announceTimers.clear();
3147
4587
  rejectedPermanent.clear();
4588
+ rejectedTransient.clear();
4589
+ transientStrikes.clear();
4590
+ transientSpent.length = 0;
4591
+ owedSetups.clear();
3148
4592
  busUnsub?.();
3149
4593
  busUnsub = null;
4594
+ for (const u of activityUnsubs.splice(0)) u();
3150
4595
  fsReader?.stop();
3151
4596
  fsReader = null;
3152
4597
  for (const agent of agents.values()) {
@@ -3231,6 +4676,145 @@ export function createSyncRuntime(
3231
4676
  return true;
3232
4677
  },
3233
4678
  retireForMove: (fromSlug, toRel) => serializeMembership(() => retireForMove(fromSlug, toRel)),
4679
+ proposeLane: (slug, lane, text, o) => {
4680
+ if (!acceptedOn()) return null;
4681
+ const projection = projections.get(slug);
4682
+ if (!projection) return null;
4683
+ const value = laneValueFromFile(lane, text);
4684
+ if (value === null) return null;
4685
+ const base = o?.baseText === undefined ? undefined : laneValueFromFile(lane, o.baseText);
4686
+ return projection.proposeLane(lane, value, {
4687
+ ...(base !== undefined && base !== null ? { baseContent: base } : {}),
4688
+ ...(o?.writeId ? { writeId: o.writeId } : {}),
4689
+ });
4690
+ },
4691
+ proposeFolder,
4692
+ acceptedMode: acceptedOn,
4693
+ acceptedWriteViolations: () => acceptedWriteViolations,
4694
+ acceptedHistory: async (q) => {
4695
+ if (!acceptedOn() || !acceptedLink) return null;
4696
+ // Fresh, not the cached manifest: a canvas created since the last
4697
+ // bootstrap has history too, and a stale map named no entry for it.
4698
+ const manifest = (await acceptedLink.refresh().catch(() => null)) ?? acceptedLink.manifest;
4699
+ if (!manifest) return null;
4700
+ const pathOf = new Map(manifest.docs.map((d) => [d.doc, d.path]));
4701
+ let entry: string | null = null;
4702
+ if (q.path) {
4703
+ const slug = slugForRepoRel(q.path);
4704
+ const doc = slug ? docNameFor(slug) : null;
4705
+ entry = manifest.docs.find((d) => d.doc === doc)?.entry ?? null;
4706
+ if (!entry) return [];
4707
+ }
4708
+ const { history } = await acceptedLink.history({
4709
+ limit: Math.min(Math.max(q.limit ?? 50, 1), 200),
4710
+ before: q.before ?? null,
4711
+ entry,
4712
+ });
4713
+ const you = manifest.you?.actor ?? null;
4714
+ return history.map((a) => {
4715
+ const canvases = new Set<string>();
4716
+ for (const e of a.effects) {
4717
+ const p = e.afterPath ?? (e.doc ? pathOf.get(e.doc) : null) ?? e.beforePath;
4718
+ if (p && /\.(tsx|html)$/i.test(p)) canvases.add(p);
4719
+ }
4720
+ return {
4721
+ revision: a.revision,
4722
+ actionId: a.actionId,
4723
+ actor: a.actor,
4724
+ mine: you !== null && a.actor === you,
4725
+ kind: a.kind,
4726
+ label: a.label,
4727
+ committedAt: a.committedAt,
4728
+ undoes: a.undoes,
4729
+ canvases: [...canvases].sort(),
4730
+ };
4731
+ });
4732
+ },
4733
+ acceptedVersion: async (repoRel, revision) => {
4734
+ if (!acceptedOn() || !acceptedLink) return null;
4735
+ const slug = slugForRepoRel(repoRel);
4736
+ if (!slug) return null;
4737
+ const r = await acceptedLink.laneAt(slug, 'html', revision);
4738
+ return r.body || null;
4739
+ },
4740
+ acceptedRestore: async (repoRel, revision) => {
4741
+ if (!acceptedOn() || !acceptedLink) return null;
4742
+ const slug = slugForRepoRel(repoRel);
4743
+ if (!slug) return { status: 'rejected', code: 'unknown-canvas' };
4744
+ const name = path.basename(repoRel).replace(/\.(tsx|html)$/i, '');
4745
+ return acceptedLink.restore([slug], revision, `Restore ${name} to version ${revision}`);
4746
+ },
4747
+ acceptedUndo: async (actionId, redo = false) => {
4748
+ if (!acceptedOn() || !acceptedLink) return null;
4749
+ return acceptedLink.undo(actionId, redo);
4750
+ },
4751
+ prepareOffline: async () => {
4752
+ if (!filePlane) return null;
4753
+ let pulled = 0;
4754
+ let passes = 0;
4755
+ let failed = 0;
4756
+ for (; passes < 40 && !stopped; passes += 1) {
4757
+ lastPlaneResult = null;
4758
+ await runPlanePass({ floor: false });
4759
+ const r = lastPlaneResult as import('./file-plane.ts').FilePlaneResult | null;
4760
+ if (!r) break;
4761
+ pulled += r.pulled.length;
4762
+ failed = r.failed.length;
4763
+ if (r.pulled.length === 0 && !r.budgetExhausted) {
4764
+ return { complete: failed === 0, pulled, passes: passes + 1, failed };
4765
+ }
4766
+ }
4767
+ return { complete: false, pulled, passes, failed };
4768
+ },
4769
+ acceptedActionForContent: (repoRel, content) => {
4770
+ const slug = slugForRepoRel(repoRel);
4771
+ return slug ? (acceptedByContent.get(`${slug}|html|${hashBytes(content)}`) ?? null) : null;
4772
+ },
4773
+ conflictVersions: (repoRel) => {
4774
+ // A slug (the Sync panel's notice id) or a repo-relative canvas path.
4775
+ const slug = projections.has(repoRel) ? repoRel : slugForRepoRel(repoRel);
4776
+ const sides = slug ? projections.get(slug)?.conflictSides() : null;
4777
+ return slug && sides ? { slug, ...sides } : null;
4778
+ },
4779
+ resolveConflict: async (repoRel, choice) => {
4780
+ const slug = projections.has(repoRel) ? repoRel : slugForRepoRel(repoRel);
4781
+ const projection = slug ? projections.get(slug) : undefined;
4782
+ if (!slug || !projection) return null;
4783
+ const sides = projection.conflictSides();
4784
+ if (!sides) return null;
4785
+ if (choice === 'theirs') {
4786
+ projection.takeAccepted();
4787
+ return { status: 'taken' };
4788
+ }
4789
+ if (sides.mine === null) return { status: 'rejected', code: 'no-local-version' };
4790
+ // Mine, on top of the version that won — a new action, never a rewind.
4791
+ const r = await projection.proposeLane('html', sides.mine, { baseContent: sides.theirs });
4792
+ if (!r) return null;
4793
+ if (r.status === 'accepted') statusStore?.clearSourceConflict?.(slug);
4794
+ return { status: r.status, ...(r.code ? { code: r.code } : {}) };
4795
+ },
4796
+ beginAiAction: (key, label) => {
4797
+ if (!acceptedOn() || !acceptedLink) return;
4798
+ acceptedLink.stage.begin(key, label);
4799
+ },
4800
+ endAiAction: async (key, outcome) => {
4801
+ if (!acceptedLink) return null;
4802
+ // The watcher delivers the last tool write a beat after the tool
4803
+ // returns; let it land in the stage before the stage closes.
4804
+ await new Promise((r) => setTimeout(r, AI_ACTION_SETTLE_MS));
4805
+ await (discoveryRescan?.flush() ?? Promise.resolve()).catch(() => {});
4806
+ const r = await acceptedLink.stage.end(key, outcome);
4807
+ return r ? { status: r.status, ...(r.code ? { code: r.code } : {}) } : null;
4808
+ },
4809
+ resolveAiAction: async (choice) => {
4810
+ if (!acceptedLink || acceptedLink.stage.state !== 'held') return null;
4811
+ if (choice === 'discard') {
4812
+ const canvases = acceptedLink.stage.discard();
4813
+ return { status: 'discarded', canvases };
4814
+ }
4815
+ const r = await acceptedLink.stage.publish();
4816
+ return r ? { status: r.status, ...(r.code ? { code: r.code } : {}) } : null;
4817
+ },
3234
4818
  };
3235
4819
  }
3236
4820
 
@@ -3775,6 +5359,16 @@ export interface DisposableProviderFactory extends ProviderFactory {
3775
5359
  /** Destroy the shared HocuspocusProviderWebsocket(s). Call AFTER provider
3776
5360
  * destroys — a provider detach sends a Close message over the socket. */
3777
5361
  dispose(): void;
5362
+ /**
5363
+ * Take every shared socket down and bring it back up (issue #118).
5364
+ *
5365
+ * The escape hatch for a socket that reports `connected` and carries nothing
5366
+ * — see the stall watchdog in `start()`. Providers are NOT destroyed and are
5367
+ * NOT re-created: they stay attached across the cycle and re-authenticate on
5368
+ * the new socket, exactly as they do after any ordinary drop, so this costs a
5369
+ * handshake and never a re-attach.
5370
+ */
5371
+ reconnect(): void;
3778
5372
  }
3779
5373
 
3780
5374
  /**
@@ -3824,7 +5418,31 @@ export function createDefaultProviderFactory(
3824
5418
  const wsUrl = toWsUrl(args.url);
3825
5419
  let socket = sockets.get(wsUrl);
3826
5420
  if (!socket) {
3827
- socket = new mod.HocuspocusProviderWebsocket({ url: wsUrl });
5421
+ // CONFIGURED, not defaulted (issue #118). The socket used to be built
5422
+ // from the URL alone, which inherited `timeout: 0` — no per-attempt
5423
+ // deadline — and that is the property that let one parked connection
5424
+ // attempt silence the whole link for as long as the process lived. The
5425
+ // other two are pinned rather than inherited so a provider upgrade cannot
5426
+ // move them without someone noticing here.
5427
+ // NO `timeout`. It is tempting — the default is 0, meaning a connection
5428
+ // attempt has no deadline at all — and it is a TRAP: `@lifeomic/attempt`
5429
+ // handles a timeout by calling `reject` DIRECTLY, bypassing the `onError`
5430
+ // path that is the only thing which schedules another attempt. So the
5431
+ // deadline does not retry the attempt, it ENDS THE CHAIN — and since
5432
+ // every one of hocuspocus's `connect()` call sites floats the promise and
5433
+ // this process registers no `unhandledRejection` handler, a hub merely
5434
+ // being slow would take the dev server down (security review 2026-09-03,
5435
+ // F2). A hub slower than the deadline is the NORMAL case this whole fix
5436
+ // is about: a sleeping cell's `/health` measured 14 s, and a container
5437
+ // cold-boot is routinely worse.
5438
+ //
5439
+ // Liveness is the runtime's job instead — see the stall watchdog in
5440
+ // `start()`, which is a layer we control and can make safe.
5441
+ socket = new mod.HocuspocusProviderWebsocket({
5442
+ url: wsUrl,
5443
+ messageReconnectTimeout: 30_000,
5444
+ maxDelay: 30_000,
5445
+ });
3828
5446
  sockets.set(wsUrl, socket);
3829
5447
  }
3830
5448
  // Phase 9.2 (DDR-064) — attach to the shared room doc when the runtime
@@ -3848,11 +5466,71 @@ export function createDefaultProviderFactory(
3848
5466
  // With an injected websocketProvider the provider does NOT auto-attach
3849
5467
  // (manageSocket=false in @hocuspocus/provider 4.x) — attach explicitly.
3850
5468
  provider.attach();
5469
+
5470
+ // `provider.synced` IS NOT TRUSTWORTHY ACROSS A STATUS TRANSITION.
5471
+ //
5472
+ // A provider learns a socket died only from the `close` EVENT, and the
5473
+ // library itself does not always raise one: `checkConnection()`'s
5474
+ // force-close branch (`closeTries > 2`) CALLS `this.onClose({code:4408})`
5475
+ // directly, and `cleanupWebSocket()` has already detached the raw handlers
5476
+ // by then — so no `close` is emitted, no provider's `boundOnClose` runs,
5477
+ // and every provider keeps `isSynced === true` through the teardown. That
5478
+ // branch is reached by exactly the failure this whole change is about: a
5479
+ // link that WAS healthy and then went quiet for 30 s.
5480
+ //
5481
+ // It gets worse quietly. `set synced` is a no-op when the value is
5482
+ // unchanged, so a provider left stale-true does not emit `synced` on its
5483
+ // NEXT genuine handshake either — the flag is wrong in both directions.
5484
+ //
5485
+ // Before this change the staleness was inert (nothing re-promoted on it).
5486
+ // `repromoteOnReconnect` makes it authoritative: `onceSynced()`
5487
+ // short-circuits on that field, so it would resolve instantly and report a
5488
+ // handshake that never happened, for every document, then disarm the stall
5489
+ // watchdog through its own `docs.synced > 0` guard (verification review
5490
+ // 2026-09-03, NEW-1 — the same defect as N1, arriving through the library
5491
+ // instead of through us).
5492
+ //
5493
+ // So: model the reset the missing event would have done. Assigning `false`
5494
+ // only flips the flag (the setter emits on `true` alone), which is exactly
5495
+ // the `synced` half of `HocuspocusProvider.onClose()` and none of its
5496
+ // awareness side effects — those belong to the real close event, and still
5497
+ // run when there is one.
5498
+ const resetSyncedOnDrop = (evt: { status?: string }): void => {
5499
+ if (evt?.status && evt.status !== 'connected' && provider.synced) provider.synced = false;
5500
+ };
5501
+ socket.on('status', resetSyncedOnDrop);
5502
+
5503
+ let authedThisConnection = false;
5504
+ provider.on('authenticated', () => {
5505
+ authedThisConnection = true;
5506
+ });
5507
+ const forgetAuthOnDrop = (evt: { status?: string }) => {
5508
+ if (evt?.status !== 'connected') authedThisConnection = false;
5509
+ };
5510
+ socket.on('status', forgetAuthOnDrop);
3851
5511
  return {
3852
5512
  document,
3853
5513
  // HocuspocusProvider creates a hub-synced Awareness by default; expose it
3854
5514
  // so the runtime can bridge it to the collab Room (Task 5).
3855
5515
  awareness: provider.awareness as Awareness | undefined,
5516
+ isRemoteOrigin: (origin: unknown) => origin === provider,
5517
+ // Writable = authenticated read-write ON THIS CONNECTION. A scope left
5518
+ // over from before a drop says nothing about the hub now: a project
5519
+ // switched to accepted revisions while this peer was away re-admits it
5520
+ // read-only, and anything written in between would be dropped there.
5521
+ isWritable: () => authedThisConnection && provider.authorizedScope === 'read-write',
5522
+ onAuthenticated(cb: (scope: string) => void): () => void {
5523
+ const handler = (evt: { scope?: string }) => cb(String(evt?.scope ?? ''));
5524
+ provider.on('authenticated', handler);
5525
+ return () => provider.off('authenticated', handler);
5526
+ },
5527
+ onStateless(cb: (payload: string) => void): () => void {
5528
+ const handler = (evt: { payload?: string }) => {
5529
+ if (typeof evt?.payload === 'string') cb(evt.payload);
5530
+ };
5531
+ provider.on('stateless', handler);
5532
+ return () => provider.off('stateless', handler);
5533
+ },
3856
5534
  onStatus(cb: (status: ProviderStatus) => void): () => void {
3857
5535
  // The shared socket emits 'status' on every WS transition and every
3858
5536
  // ATTACHED provider re-emits it (forwardStatus), so per-provider
@@ -3875,20 +5553,35 @@ export function createDefaultProviderFactory(
3875
5553
  authFailedCbs.add(cb);
3876
5554
  return () => authFailedCbs.delete(cb);
3877
5555
  },
3878
- onceSynced(): Promise<void> {
5556
+ onceSynced(signal?: AbortSignal): Promise<void> {
3879
5557
  return new Promise<void>((resolve) => {
3880
5558
  if (provider.synced) {
3881
5559
  resolve();
3882
5560
  return;
3883
5561
  }
3884
5562
  const handler = () => {
3885
- provider.off('synced', handler);
5563
+ cleanup();
3886
5564
  resolve();
3887
5565
  };
5566
+ // Detach on abort as well as on success — see the interface doc. The
5567
+ // promise is deliberately NOT rejected: callers race it against their
5568
+ // own ceiling and read a separate flag, so a rejection here would be
5569
+ // an unhandled one at every site that stops waiting.
5570
+ const cleanup = () => {
5571
+ provider.off('synced', handler);
5572
+ signal?.removeEventListener('abort', cleanup);
5573
+ };
5574
+ if (signal?.aborted) {
5575
+ resolve();
5576
+ return;
5577
+ }
3888
5578
  provider.on('synced', handler);
5579
+ signal?.addEventListener('abort', cleanup, { once: true });
3889
5580
  });
3890
5581
  },
3891
5582
  destroy() {
5583
+ socket.off('status', resetSyncedOnDrop);
5584
+ socket.off('status', forgetAuthOnDrop);
3892
5585
  // Detaches from the shared socket (sends a per-document Close); the
3893
5586
  // socket itself is destroyed by dispose() after all providers.
3894
5587
  provider.destroy();
@@ -3907,6 +5600,63 @@ export function createDefaultProviderFactory(
3907
5600
  }
3908
5601
  sockets.clear();
3909
5602
  },
5603
+ reconnect(): void {
5604
+ for (const socket of sockets.values()) {
5605
+ try {
5606
+ // NOT `disconnect()` + `connect()` — that pair WEDGES THE SOCKET SHUT,
5607
+ // deterministically, in exactly the state this is called from
5608
+ // (security review 2026-09-03, F1). `disconnect()` sets
5609
+ // `shouldConnect = false` and closes the WS without touching
5610
+ // `status`; the close event is asynchronous, so the immediately
5611
+ // following `connect()` hits its own first guard —
5612
+ // `if (this.status === WebSocketStatus.Connected) return;` — which
5613
+ // sits ABOVE the line that would restore `shouldConnect`. When the
5614
+ // close finally lands, `onClose`'s re-arm reads
5615
+ // `!cancelWebsocketRetry && shouldConnect` → false, and nothing else
5616
+ // re-arms it. The recovery action would have turned a stall that
5617
+ // survives a restart into a link that never reconnects again.
5618
+ //
5619
+ // So take the path the library is actually built around: close the
5620
+ // raw socket and let its OWN close-driven reconnect run — byte for
5621
+ // byte what happens when the hub drops us, which is the best-tested
5622
+ // path in the provider. `shouldConnect` stays true, and
5623
+ // `cancelWebsocketRetry` is already clear (only `onOpen` clears it,
5624
+ // and a socket reporting `connected` has been through `onOpen`).
5625
+ //
5626
+ // `connect()` is the fallback for the no-live-socket case ONLY, where
5627
+ // status is not `Connected` and it therefore does not early-return.
5628
+ //
5629
+ // EMIT THE EVENT — do not call the handler.
5630
+ //
5631
+ // `HocuspocusProviderWebsocket.onClose()` looks like the right entry
5632
+ // point (`checkConnection()` calls it with this exact 4408 frame) and
5633
+ // calling it directly is WRONG in a way that is invisible until you
5634
+ // read who else listens. That method emits `status` and `disconnect`
5635
+ // — never `close`. Each attached PROVIDER learns about a drop through
5636
+ // `websocketProvider.on('close', boundOnClose)`, and
5637
+ // `HocuspocusProvider.onClose()` is the only thing that resets
5638
+ // `isAuthenticated` and `synced`. Skip the event and every provider
5639
+ // keeps `synced === true` across the reconnect; `set synced` is a
5640
+ // no-op when unchanged, so the NEXT real handshake emits nothing
5641
+ // either, and `onceSynced()` — which short-circuits on that field —
5642
+ // resolves instantly for all 85 documents against a socket that has
5643
+ // completed no handshake at all. The recovery would have manufactured
5644
+ // `docs.synced = 85` out of nothing, and then disarmed the watchdog
5645
+ // permanently through its own `docs.synced > 0` guard (verification
5646
+ // review 2026-09-03, N1).
5647
+ //
5648
+ // Emitting is what the raw socket's own handler does
5649
+ // (`onCloseHandler = (payload) => this.emit('close', {event: payload})`),
5650
+ // and the constructor registers `this.on('close', this.onClose)` —
5651
+ // so this runs every provider's reset AND the socket's own cleanup +
5652
+ // re-arm. THAT is "byte for byte what happens when the hub drops us";
5653
+ // the handler call only resembled it.
5654
+ socket.emit('close', { event: { code: 4408, reason: 'forced' } });
5655
+ } catch (err) {
5656
+ console.warn(`[sync] socket reconnect failed: ${(err as Error).message}`);
5657
+ }
5658
+ }
5659
+ },
3910
5660
  });
3911
5661
  }
3912
5662