@1agh/maude 1.0.11 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (506) hide show
  1. package/apps/studio/acp/bridge.ts +18 -13
  2. package/apps/studio/acp/transcript-io.ts +394 -0
  3. package/apps/studio/acp/transcript.ts +103 -38
  4. package/apps/studio/api.ts +139 -26
  5. package/apps/studio/bin/_import-figma.mjs +57 -11
  6. package/apps/studio/bin/_seed-fixture.mjs +163 -0
  7. package/apps/studio/canvas-imports.ts +89 -0
  8. package/apps/studio/client/app.jsx +257 -7
  9. package/apps/studio/client/export-center.jsx +15 -7
  10. package/apps/studio/client/panels/SyncPanel.jsx +125 -4
  11. package/apps/studio/client/report-bug.jsx +19 -1
  12. package/apps/studio/client/styles/3-shell-maude.css +34 -0
  13. package/apps/studio/client/styles/4-components.css +26 -0
  14. package/apps/studio/collab/persistence.ts +132 -16
  15. package/apps/studio/collab/registry.ts +10 -18
  16. package/apps/studio/debug-bundle.ts +13 -0
  17. package/apps/studio/dist/client.bundle.js +1128 -1128
  18. package/apps/studio/dist/styles.css +1 -1
  19. package/apps/studio/export-dialog.tsx +54 -0
  20. package/apps/studio/exporters/degraded.ts +19 -3
  21. package/apps/studio/exporters/ghostscript.ts +194 -0
  22. package/apps/studio/exporters/pdf-fonts.ts +261 -0
  23. package/apps/studio/exporters/pdf.ts +152 -3
  24. package/apps/studio/exporters/remote.ts +36 -1
  25. package/apps/studio/fs-watch.ts +45 -1
  26. package/apps/studio/http.ts +7 -1
  27. package/apps/studio/mem.ts +45 -9
  28. package/apps/studio/server.ts +11 -4
  29. package/apps/studio/sync/agent.ts +136 -29
  30. package/apps/studio/sync/asset-push.ts +1 -47
  31. package/apps/studio/sync/codec.ts +122 -31
  32. package/apps/studio/sync/cold-start-apply.ts +7 -0
  33. package/apps/studio/sync/cold-start.ts +118 -22
  34. package/apps/studio/sync/comment-identity.ts +68 -0
  35. package/apps/studio/sync/connection-state.ts +15 -1
  36. package/apps/studio/sync/file-ledger.ts +117 -0
  37. package/apps/studio/sync/file-plane.ts +623 -27
  38. package/apps/studio/sync/index.ts +711 -20
  39. package/apps/studio/sync/journal-client.ts +13 -1
  40. package/apps/studio/sync/migrate-seed.ts +40 -3
  41. package/apps/studio/sync/presentation.ts +26 -3
  42. package/apps/studio/sync/retry-after.ts +169 -0
  43. package/apps/studio/sync/seed-progress.ts +218 -0
  44. package/apps/studio/sync/status.ts +122 -5
  45. package/apps/studio/sync/transport-error.ts +111 -0
  46. package/apps/studio/whats-new.json +72 -0
  47. package/cli/bin/maude.mjs +2 -0
  48. package/cli/commands/codex.mjs +191 -0
  49. package/cli/commands/harness.mjs +1051 -0
  50. package/cli/commands/help.mjs +13 -0
  51. package/cli/lib/design-link.mjs +190 -2
  52. package/cli/lib/harness/capabilities.mjs +302 -0
  53. package/cli/lib/harness/codex-runtime.mjs +889 -0
  54. package/cli/lib/harness/compatibility.mjs +27 -0
  55. package/cli/lib/harness/discover-claude.mjs +1393 -0
  56. package/cli/lib/harness/managed-state.mjs +1296 -0
  57. package/cli/lib/harness/model.mjs +86 -0
  58. package/cli/lib/harness/secrets.mjs +390 -0
  59. package/cli/lib/harness/targets/codex.mjs +1141 -0
  60. package/cli/lib/harness/targets/opencode.mjs +807 -0
  61. package/cli/lib/harness/transaction.mjs +1099 -0
  62. package/cli/lib/hubs-config.mjs +35 -8
  63. package/cli/templates/harness/opencode/maude-projector.ts +58 -0
  64. package/docs/harness-capability-matrix.md +125 -0
  65. package/docs/harness-environment-projection.md +95 -0
  66. package/package.json +32 -9
  67. package/plugins/flow/.claude-plugin/config.schema.json +8 -0
  68. package/apps/studio/.ai/cache/_stats.json +0 -7
  69. package/apps/studio/bin/_agent-browser-safe.test.mjs +0 -165
  70. package/apps/studio/bin/_curl-local.test.mjs +0 -280
  71. package/apps/studio/bin/_pw-launch.test.mjs +0 -90
  72. package/apps/studio/bin/_smart-frames.test.mjs +0 -195
  73. package/apps/studio/bin/_transcribe.test.mjs +0 -80
  74. package/apps/studio/bun.lock +0 -928
  75. package/apps/studio/draw/test/animate.test.ts +0 -172
  76. package/apps/studio/draw/test/brush.test.ts +0 -213
  77. package/apps/studio/draw/test/composition.test.ts +0 -141
  78. package/apps/studio/draw/test/geometry.test.ts +0 -199
  79. package/apps/studio/draw/test/gradient.test.ts +0 -167
  80. package/apps/studio/draw/test/layout.test.ts +0 -120
  81. package/apps/studio/draw/test/morph.test.ts +0 -107
  82. package/apps/studio/draw/test/optimize.test.ts +0 -40
  83. package/apps/studio/draw/test/palette.test.ts +0 -123
  84. package/apps/studio/draw/test/primitives.test.ts +0 -129
  85. package/apps/studio/draw/test/serialize-animate.test.ts +0 -142
  86. package/apps/studio/draw/test/serialize.test.ts +0 -105
  87. package/apps/studio/figma/assets.test.ts +0 -464
  88. package/apps/studio/figma/client.test.ts +0 -395
  89. package/apps/studio/figma/codegen-client.test.ts +0 -276
  90. package/apps/studio/figma/codegen-fonts.test.ts +0 -103
  91. package/apps/studio/figma/codegen-values.test.ts +0 -179
  92. package/apps/studio/figma/comments-to-strokes.test.ts +0 -194
  93. package/apps/studio/figma/fig-decode.test.ts +0 -788
  94. package/apps/studio/figma/fig-differential.test.ts +0 -182
  95. package/apps/studio/figma/fig-translator.test.ts +0 -192
  96. package/apps/studio/figma/fig-vector.test.ts +0 -113
  97. package/apps/studio/figma/from-codegen.test.ts +0 -408
  98. package/apps/studio/figma/sanitize.test.ts +0 -325
  99. package/apps/studio/figma/tailwind-map.test.ts +0 -142
  100. package/apps/studio/figma/to-artboard.test.ts +0 -808
  101. package/apps/studio/figma/to-render.test.ts +0 -180
  102. package/apps/studio/figma/to-strokes-roundtrip.test.ts +0 -152
  103. package/apps/studio/figma/to-strokes.test.ts +0 -705
  104. package/apps/studio/figma/to-tokens.test.ts +0 -321
  105. package/apps/studio/figma/url.test.ts +0 -167
  106. package/apps/studio/footage/schema.test.ts +0 -287
  107. package/apps/studio/generation/adapters/groq.test.ts +0 -80
  108. package/apps/studio/generation/audio-library.test.ts +0 -95
  109. package/apps/studio/generation/captions.test.ts +0 -78
  110. package/apps/studio/generation/elevenlabs.test.ts +0 -223
  111. package/apps/studio/generation/gemini.test.ts +0 -350
  112. package/apps/studio/generation/jobs.test.ts +0 -128
  113. package/apps/studio/generation/keys.test.ts +0 -75
  114. package/apps/studio/generation/prefs.test.ts +0 -95
  115. package/apps/studio/generation/types.test.ts +0 -123
  116. package/apps/studio/generation/whisper-models.test.ts +0 -179
  117. package/apps/studio/test/_helpers.ts +0 -108
  118. package/apps/studio/test/acp-activity-endpoint.test.ts +0 -183
  119. package/apps/studio/test/acp-activity.test.ts +0 -154
  120. package/apps/studio/test/acp-ai-activity.test.ts +0 -182
  121. package/apps/studio/test/acp-attachment-serve.test.ts +0 -74
  122. package/apps/studio/test/acp-bootstrap-brief.test.ts +0 -178
  123. package/apps/studio/test/acp-branch-guard.test.ts +0 -123
  124. package/apps/studio/test/acp-bridge-lifetime.test.ts +0 -475
  125. package/apps/studio/test/acp-bridge.test.ts +0 -545
  126. package/apps/studio/test/acp-capabilities.test.ts +0 -123
  127. package/apps/studio/test/acp-caps-bridge.test.ts +0 -279
  128. package/apps/studio/test/acp-commands.test.ts +0 -119
  129. package/apps/studio/test/acp-elicitation-bridge.test.ts +0 -480
  130. package/apps/studio/test/acp-elicitation.test.ts +0 -251
  131. package/apps/studio/test/acp-env.test.ts +0 -95
  132. package/apps/studio/test/acp-mode-banner.test.ts +0 -45
  133. package/apps/studio/test/acp-origin-gate.test.ts +0 -160
  134. package/apps/studio/test/acp-permission-prompt.test.ts +0 -251
  135. package/apps/studio/test/acp-permission.test.ts +0 -278
  136. package/apps/studio/test/acp-plugin-bootstrap.test.ts +0 -89
  137. package/apps/studio/test/acp-session-allowed-tools.test.ts +0 -235
  138. package/apps/studio/test/acp-session-plugins.test.ts +0 -209
  139. package/apps/studio/test/acp-toolgroup.test.ts +0 -76
  140. package/apps/studio/test/acp-transcript-view.test.ts +0 -71
  141. package/apps/studio/test/acp-transcript.test.ts +0 -238
  142. package/apps/studio/test/acp-usage-bridge.test.ts +0 -141
  143. package/apps/studio/test/acp-usage.test.ts +0 -143
  144. package/apps/studio/test/acp-write-gate.test.ts +0 -323
  145. package/apps/studio/test/acp-write-scope.test.ts +0 -533
  146. package/apps/studio/test/active-state.test.ts +0 -186
  147. package/apps/studio/test/activity.test.ts +0 -189
  148. package/apps/studio/test/ai-activity.test.ts +0 -113
  149. package/apps/studio/test/annotate-write.test.ts +0 -1074
  150. package/apps/studio/test/annotation-strokes-command.test.ts +0 -166
  151. package/apps/studio/test/annotations-align.test.ts +0 -88
  152. package/apps/studio/test/annotations-api.test.ts +0 -145
  153. package/apps/studio/test/annotations-bindings.test.ts +0 -262
  154. package/apps/studio/test/annotations-draw-modifiers.test.ts +0 -206
  155. package/apps/studio/test/annotations-groups.test.ts +0 -231
  156. package/apps/studio/test/annotations-layer.test.ts +0 -1011
  157. package/apps/studio/test/annotations-roundtrip.test.ts +0 -663
  158. package/apps/studio/test/annotations-snap.test.ts +0 -135
  159. package/apps/studio/test/annotations-text-format.test.ts +0 -396
  160. package/apps/studio/test/artboard-activity-overlay.test.tsx +0 -56
  161. package/apps/studio/test/artboard-guides-overlay.test.tsx +0 -152
  162. package/apps/studio/test/artboard-hug-commit.test.ts +0 -36
  163. package/apps/studio/test/artboard-kinds.test.tsx +0 -83
  164. package/apps/studio/test/artboard-selection-attrs.test.ts +0 -69
  165. package/apps/studio/test/asset-api.test.ts +0 -149
  166. package/apps/studio/test/assets-s3.test.ts +0 -249
  167. package/apps/studio/test/binary-smoke.test.ts +0 -47
  168. package/apps/studio/test/boot-self-heal.test.ts +0 -87
  169. package/apps/studio/test/browse-posture.test.tsx +0 -107
  170. package/apps/studio/test/bundle-smoke.test.ts +0 -46
  171. package/apps/studio/test/camera-reveal.test.tsx +0 -173
  172. package/apps/studio/test/canvas-artifacts.test.ts +0 -125
  173. package/apps/studio/test/canvas-build-sandbox.test.ts +0 -69
  174. package/apps/studio/test/canvas-build.test.ts +0 -141
  175. package/apps/studio/test/canvas-create-api.test.ts +0 -676
  176. package/apps/studio/test/canvas-cursors.test.ts +0 -164
  177. package/apps/studio/test/canvas-edit.test.ts +0 -470
  178. package/apps/studio/test/canvas-freshness-deps.test.ts +0 -64
  179. package/apps/studio/test/canvas-header.test.ts +0 -127
  180. package/apps/studio/test/canvas-hide-chrome.test.ts +0 -88
  181. package/apps/studio/test/canvas-hmr-runtime.test.tsx +0 -114
  182. package/apps/studio/test/canvas-lib-inline.test.ts +0 -146
  183. package/apps/studio/test/canvas-lib-motion.test.ts +0 -131
  184. package/apps/studio/test/canvas-lib-resolver.test.ts +0 -111
  185. package/apps/studio/test/canvas-list-watch.test.ts +0 -388
  186. package/apps/studio/test/canvas-media-drop.test.ts +0 -187
  187. package/apps/studio/test/canvas-meta-api.test.ts +0 -707
  188. package/apps/studio/test/canvas-move-api.test.ts +0 -587
  189. package/apps/studio/test/canvas-origin-gate.test.ts +0 -417
  190. package/apps/studio/test/canvas-pipeline.test.ts +0 -180
  191. package/apps/studio/test/canvas-rects.test.ts +0 -198
  192. package/apps/studio/test/canvas-reorder.test.ts +0 -211
  193. package/apps/studio/test/canvas-route.test.ts +0 -179
  194. package/apps/studio/test/canvas-shell-base.test.ts +0 -107
  195. package/apps/studio/test/canvas-shell-build-error.test.ts +0 -52
  196. package/apps/studio/test/canvas-url.test.ts +0 -138
  197. package/apps/studio/test/canvas-wheel-delta.test.ts +0 -42
  198. package/apps/studio/test/canvas-zoom-floor.test.ts +0 -73
  199. package/apps/studio/test/capture-core.test.ts +0 -32
  200. package/apps/studio/test/capture-determinism-shape.test.ts +0 -135
  201. package/apps/studio/test/chat-attachments.test.ts +0 -117
  202. package/apps/studio/test/chat-context.test.ts +0 -129
  203. package/apps/studio/test/chat-markdown.test.tsx +0 -58
  204. package/apps/studio/test/clip-addressing.test.ts +0 -738
  205. package/apps/studio/test/clip-ops.test.ts +0 -922
  206. package/apps/studio/test/cloud-build.test.ts +0 -95
  207. package/apps/studio/test/cloud-connect-note.test.ts +0 -135
  208. package/apps/studio/test/cloud-endpoints.test.ts +0 -590
  209. package/apps/studio/test/cloud-history-hardening.test.ts +0 -165
  210. package/apps/studio/test/cloud-history-posture.test.ts +0 -230
  211. package/apps/studio/test/cloud-managed-save-surfaces.test.ts +0 -90
  212. package/apps/studio/test/cloud-renew.test.ts +0 -205
  213. package/apps/studio/test/cloud-session-role.test.ts +0 -161
  214. package/apps/studio/test/cloud-shell-surfaces.test.ts +0 -170
  215. package/apps/studio/test/cold-start-apply.test.ts +0 -303
  216. package/apps/studio/test/collab-annotations-bridge.test.ts +0 -55
  217. package/apps/studio/test/collab-awareness-bridge.test.ts +0 -223
  218. package/apps/studio/test/collab-bridge.test.ts +0 -79
  219. package/apps/studio/test/collab-loopback.test.ts +0 -63
  220. package/apps/studio/test/collab-origin-gate.test.ts +0 -323
  221. package/apps/studio/test/collab-protocol.test.ts +0 -146
  222. package/apps/studio/test/collab-readonly-gate.test.ts +0 -194
  223. package/apps/studio/test/collab-reseed-guard.test.ts +0 -78
  224. package/apps/studio/test/collab-room.test.ts +0 -188
  225. package/apps/studio/test/collab-session-survival.test.tsx +0 -176
  226. package/apps/studio/test/collab-stress.test.ts +0 -129
  227. package/apps/studio/test/comment-mount.test.ts +0 -97
  228. package/apps/studio/test/comment-relay-origin-gate.test.ts +0 -117
  229. package/apps/studio/test/comments-api.test.ts +0 -277
  230. package/apps/studio/test/comments-fs-rebroadcast.test.ts +0 -155
  231. package/apps/studio/test/comments-overlay.test.ts +0 -117
  232. package/apps/studio/test/compile-entry.test.ts +0 -133
  233. package/apps/studio/test/component-map.test.ts +0 -48
  234. package/apps/studio/test/config-projection.test.ts +0 -104
  235. package/apps/studio/test/config-reload.test.ts +0 -230
  236. package/apps/studio/test/config-version.test.ts +0 -88
  237. package/apps/studio/test/context-resolve-tokens.test.ts +0 -97
  238. package/apps/studio/test/convert-to-absolute.test.ts +0 -333
  239. package/apps/studio/test/credential-grammar.test.ts +0 -107
  240. package/apps/studio/test/csp-canvas-shell.test.ts +0 -62
  241. package/apps/studio/test/csrf-write-guard.test.ts +0 -141
  242. package/apps/studio/test/debug-bundle.test.ts +0 -105
  243. package/apps/studio/test/design-setup-readiness.test.ts +0 -155
  244. package/apps/studio/test/detach-component.test.ts +0 -94
  245. package/apps/studio/test/dns-rebinding-guard.test.ts +0 -101
  246. package/apps/studio/test/dom-selection.test.ts +0 -198
  247. package/apps/studio/test/dynamic-text-edit.test.ts +0 -162
  248. package/apps/studio/test/edit-css-occurrence.test.ts +0 -81
  249. package/apps/studio/test/edit-persistence.test.ts +0 -91
  250. package/apps/studio/test/edit-scope-api.test.ts +0 -119
  251. package/apps/studio/test/edit-source-command.test.ts +0 -142
  252. package/apps/studio/test/edit-suppress.test.ts +0 -170
  253. package/apps/studio/test/editing-presence.test.ts +0 -103
  254. package/apps/studio/test/element-resize.test.ts +0 -136
  255. package/apps/studio/test/element-structural-api.test.ts +0 -434
  256. package/apps/studio/test/element-structural-edit.test.ts +0 -750
  257. package/apps/studio/test/ensure-browser.test.ts +0 -92
  258. package/apps/studio/test/equal-spacing-detector.test.ts +0 -257
  259. package/apps/studio/test/export-assemble.test.ts +0 -53
  260. package/apps/studio/test/export-browser-lane.test.ts +0 -126
  261. package/apps/studio/test/export-capture-fidelity.test.ts +0 -146
  262. package/apps/studio/test/export-capture-hygiene.test.ts +0 -675
  263. package/apps/studio/test/export-center.test.tsx +0 -287
  264. package/apps/studio/test/export-e2e-lanes.test.ts +0 -857
  265. package/apps/studio/test/export-format-scope-coherence.test.ts +0 -89
  266. package/apps/studio/test/export-lane.test.ts +0 -245
  267. package/apps/studio/test/export-shim-multi-capture.test.ts +0 -83
  268. package/apps/studio/test/exporters/canva.test.ts +0 -63
  269. package/apps/studio/test/exporters/degraded-propagation.test.ts +0 -123
  270. package/apps/studio/test/exporters/endpoint.test.ts +0 -121
  271. package/apps/studio/test/exporters/history.test.ts +0 -107
  272. package/apps/studio/test/exporters/html.test.ts +0 -26
  273. package/apps/studio/test/exporters/jobs.test.ts +0 -342
  274. package/apps/studio/test/exporters/pdf.test.ts +0 -85
  275. package/apps/studio/test/exporters/png.test.ts +0 -103
  276. package/apps/studio/test/exporters/pptx-deck.test.ts +0 -112
  277. package/apps/studio/test/exporters/pptx.test.ts +0 -31
  278. package/apps/studio/test/exporters/pw-launch.test.ts +0 -49
  279. package/apps/studio/test/exporters/runtime.test.ts +0 -59
  280. package/apps/studio/test/exporters/scope.test.ts +0 -292
  281. package/apps/studio/test/exporters/svg.test.ts +0 -29
  282. package/apps/studio/test/exporters/unsupported-media.test.ts +0 -123
  283. package/apps/studio/test/exporters/zip.test.ts +0 -104
  284. package/apps/studio/test/fetch-asset-gate.test.ts +0 -189
  285. package/apps/studio/test/figjam-v3-model.test.ts +0 -342
  286. package/apps/studio/test/figma-explode.test.ts +0 -438
  287. package/apps/studio/test/figma-provenance.test.ts +0 -108
  288. package/apps/studio/test/figma-routes.test.ts +0 -294
  289. package/apps/studio/test/file-lock.test.ts +0 -84
  290. package/apps/studio/test/fixtures/backgrounding-claude-auth.sh +0 -12
  291. package/apps/studio/test/fixtures/fake-claude-auth.mjs +0 -23
  292. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +0 -1
  293. package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +0 -158
  294. package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +0 -44
  295. package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +0 -46
  296. package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +0 -42
  297. package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +0 -63
  298. package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +0 -51
  299. package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +0 -50
  300. package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +0 -45
  301. package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +0 -56
  302. package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +0 -37
  303. package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +0 -118
  304. package/apps/studio/test/fixtures/mock-acp-agent.mjs +0 -69
  305. package/apps/studio/test/fixtures/noisy-claude-auth.mjs +0 -27
  306. package/apps/studio/test/fixtures/perf-canvas.mjs +0 -201
  307. package/apps/studio/test/fixtures/phase-20-annotations.svg +0 -1
  308. package/apps/studio/test/fixtures/phase-21-annotations.svg +0 -1
  309. package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
  310. package/apps/studio/test/fixtures/video-comp-fixture.tsx +0 -82
  311. package/apps/studio/test/fixtures/wedged-claude-auth.sh +0 -11
  312. package/apps/studio/test/footage-store.test.ts +0 -100
  313. package/apps/studio/test/fs-mkdir-api.test.ts +0 -248
  314. package/apps/studio/test/fs-watch.test.ts +0 -41
  315. package/apps/studio/test/gemma-models.test.ts +0 -245
  316. package/apps/studio/test/generate-route.test.ts +0 -106
  317. package/apps/studio/test/git-api.test.ts +0 -0
  318. package/apps/studio/test/git-branches.test.ts +0 -341
  319. package/apps/studio/test/git-cloud-posture.test.ts +0 -59
  320. package/apps/studio/test/git-grouping.test.ts +0 -106
  321. package/apps/studio/test/git-lifecycle.test.ts +0 -100
  322. package/apps/studio/test/git-log-format.test.ts +0 -95
  323. package/apps/studio/test/git-safe-rel.test.ts +0 -132
  324. package/apps/studio/test/git-system-engine.test.ts +0 -135
  325. package/apps/studio/test/git-watch.test.ts +0 -97
  326. package/apps/studio/test/github-api.test.ts +0 -577
  327. package/apps/studio/test/grid-track-handles.test.ts +0 -160
  328. package/apps/studio/test/handoff-static-frames.test.ts +0 -215
  329. package/apps/studio/test/handoff.test.ts +0 -329
  330. package/apps/studio/test/history-rollback.test.ts +0 -88
  331. package/apps/studio/test/hmr-broadcast.test.ts +0 -236
  332. package/apps/studio/test/hmr-classify.test.ts +0 -87
  333. package/apps/studio/test/hub-link.test.ts +0 -80
  334. package/apps/studio/test/identity-cache.test.ts +0 -83
  335. package/apps/studio/test/import-asset-browser.test.ts +0 -64
  336. package/apps/studio/test/import-asset-route.test.ts +0 -71
  337. package/apps/studio/test/import-asset.test.ts +0 -427
  338. package/apps/studio/test/import-brand-browser.test.ts +0 -88
  339. package/apps/studio/test/import-brand-route.test.ts +0 -120
  340. package/apps/studio/test/import-brand.test.ts +0 -206
  341. package/apps/studio/test/import-figma.test.ts +0 -667
  342. package/apps/studio/test/import-tokens.test.ts +0 -722
  343. package/apps/studio/test/index-data-canvas-ds.test.ts +0 -97
  344. package/apps/studio/test/input-router.test.ts +0 -757
  345. package/apps/studio/test/inspect-script-syntax.test.ts +0 -53
  346. package/apps/studio/test/inspect-selections.test.ts +0 -219
  347. package/apps/studio/test/knob-props-authored.test.ts +0 -87
  348. package/apps/studio/test/layers-synthetic-groups.test.ts +0 -96
  349. package/apps/studio/test/locator.test.ts +0 -214
  350. package/apps/studio/test/marquee-overlay.test.ts +0 -94
  351. package/apps/studio/test/media-commit-chain.test.ts +0 -210
  352. package/apps/studio/test/mode-toggle.test.tsx +0 -226
  353. package/apps/studio/test/motion-sample.test.ts +0 -35
  354. package/apps/studio/test/move-artboards-command.test.ts +0 -108
  355. package/apps/studio/test/participants-chrome.test.ts +0 -65
  356. package/apps/studio/test/paths.test.ts +0 -88
  357. package/apps/studio/test/pdf-print-boxes.test.ts +0 -359
  358. package/apps/studio/test/peer-selection-follows-camera.test.tsx +0 -131
  359. package/apps/studio/test/perf-harness.ts +0 -193
  360. package/apps/studio/test/phase-3.6-smoke.test.ts +0 -79
  361. package/apps/studio/test/photo-bg-remove-validation.test.ts +0 -71
  362. package/apps/studio/test/photo-canvas-bundle.test.ts +0 -79
  363. package/apps/studio/test/photo-edit-api.test.ts +0 -201
  364. package/apps/studio/test/photo-filters.test.ts +0 -149
  365. package/apps/studio/test/photo-pipeline.test.ts +0 -106
  366. package/apps/studio/test/photo-taxonomy.test.ts +0 -96
  367. package/apps/studio/test/print-marks.test.ts +0 -113
  368. package/apps/studio/test/print-units.test.ts +0 -173
  369. package/apps/studio/test/read-annotations.test.ts +0 -727
  370. package/apps/studio/test/read-only-gate.test.ts +0 -188
  371. package/apps/studio/test/read-only-role.test.ts +0 -95
  372. package/apps/studio/test/readiness.test.ts +0 -337
  373. package/apps/studio/test/remote-ahead-behind-cache.test.ts +0 -89
  374. package/apps/studio/test/reorder-api.test.ts +0 -210
  375. package/apps/studio/test/repo-concurrency.test.ts +0 -146
  376. package/apps/studio/test/repo-lock.test.ts +0 -350
  377. package/apps/studio/test/report-proxy.test.ts +0 -125
  378. package/apps/studio/test/ripple.test.ts +0 -197
  379. package/apps/studio/test/runtime-bundle-error-mapping.test.ts +0 -43
  380. package/apps/studio/test/runtime-bundle.test.ts +0 -69
  381. package/apps/studio/test/sanitize-annotation-svg.test.ts +0 -238
  382. package/apps/studio/test/scaffold-design.test.ts +0 -122
  383. package/apps/studio/test/server-lifecycle.test.ts +0 -38
  384. package/apps/studio/test/session-runtime-state.test.ts +0 -226
  385. package/apps/studio/test/session-scope.test.ts +0 -100
  386. package/apps/studio/test/shared-doc-cell-pairing.test.ts +0 -642
  387. package/apps/studio/test/shared-doc-convergence.test.ts +0 -265
  388. package/apps/studio/test/shared-doc-foundation.test.ts +0 -63
  389. package/apps/studio/test/shared-doc-migrate.test.ts +0 -369
  390. package/apps/studio/test/shared-doc-projection.test.ts +0 -238
  391. package/apps/studio/test/shell-importmap.test.ts +0 -56
  392. package/apps/studio/test/sizing-mode.test.ts +0 -102
  393. package/apps/studio/test/slash-commands.test.ts +0 -117
  394. package/apps/studio/test/snap-distance-pill.test.ts +0 -68
  395. package/apps/studio/test/spacing-handles.test.ts +0 -138
  396. package/apps/studio/test/specimen-select.test.ts +0 -88
  397. package/apps/studio/test/sync-agent.test.ts +0 -660
  398. package/apps/studio/test/sync-annotations-cold-start.test.ts +0 -302
  399. package/apps/studio/test/sync-asset-push.test.ts +0 -931
  400. package/apps/studio/test/sync-atomic-write.test.ts +0 -62
  401. package/apps/studio/test/sync-attach-incremental.test.ts +0 -527
  402. package/apps/studio/test/sync-autocommit.test.ts +0 -537
  403. package/apps/studio/test/sync-canvas-path.test.ts +0 -200
  404. package/apps/studio/test/sync-cell-write-nudge.test.ts +0 -346
  405. package/apps/studio/test/sync-codec.test.ts +0 -230
  406. package/apps/studio/test/sync-cold-seed-dedup.test.ts +0 -187
  407. package/apps/studio/test/sync-cold-start.test.ts +0 -304
  408. package/apps/studio/test/sync-connect-honesty.test.ts +0 -114
  409. package/apps/studio/test/sync-connection-state.test.ts +0 -337
  410. package/apps/studio/test/sync-ctl-channel.test.ts +0 -508
  411. package/apps/studio/test/sync-decide-file.test.ts +0 -420
  412. package/apps/studio/test/sync-doc-name.test.ts +0 -281
  413. package/apps/studio/test/sync-echo-guard.test.ts +0 -96
  414. package/apps/studio/test/sync-file-ledger.test.ts +0 -334
  415. package/apps/studio/test/sync-file-membership.test.ts +0 -347
  416. package/apps/studio/test/sync-file-plane.test.ts +0 -976
  417. package/apps/studio/test/sync-file-pull.test.ts +0 -333
  418. package/apps/studio/test/sync-fresh-link-parity.test.ts +0 -267
  419. package/apps/studio/test/sync-fs-mirror.test.ts +0 -181
  420. package/apps/studio/test/sync-hardening.test.ts +0 -586
  421. package/apps/studio/test/sync-hub-listing.test.ts +0 -46
  422. package/apps/studio/test/sync-hubs-config.test.ts +0 -133
  423. package/apps/studio/test/sync-incident-replay.test.ts +0 -211
  424. package/apps/studio/test/sync-journal.test.ts +0 -176
  425. package/apps/studio/test/sync-meta-codec.test.ts +0 -199
  426. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +0 -98
  427. package/apps/studio/test/sync-move-retirement.test.ts +0 -231
  428. package/apps/studio/test/sync-panel-surface.test.ts +0 -153
  429. package/apps/studio/test/sync-path-pull.test.ts +0 -531
  430. package/apps/studio/test/sync-presentation.test.ts +0 -262
  431. package/apps/studio/test/sync-pull-budget.test.ts +0 -169
  432. package/apps/studio/test/sync-remote-docs.test.ts +0 -333
  433. package/apps/studio/test/sync-resync-routes.test.ts +0 -168
  434. package/apps/studio/test/sync-runtime.test.ts +0 -1675
  435. package/apps/studio/test/sync-seed-defers-to-hub.test.ts +0 -83
  436. package/apps/studio/test/sync-settings-routes.test.ts +0 -195
  437. package/apps/studio/test/sync-settings.test.ts +0 -151
  438. package/apps/studio/test/sync-status.test.ts +0 -261
  439. package/apps/studio/test/sync-supervisor.test.ts +0 -262
  440. package/apps/studio/test/sync-tombstone-apply.test.ts +0 -111
  441. package/apps/studio/test/sync-trash.test.ts +0 -132
  442. package/apps/studio/test/sync-two-peer-discovery.test.ts +0 -343
  443. package/apps/studio/test/sync-untrusted.test.ts +0 -103
  444. package/apps/studio/test/system-endpoint.test.ts +0 -144
  445. package/apps/studio/test/text-editability-stamp.test.ts +0 -131
  446. package/apps/studio/test/timeline-comp-target.test.ts +0 -139
  447. package/apps/studio/test/timeline-media-cache.test.ts +0 -41
  448. package/apps/studio/test/timeline-parse.test.ts +0 -184
  449. package/apps/studio/test/timeline-scale.test.ts +0 -65
  450. package/apps/studio/test/timeline-snap.test.ts +0 -85
  451. package/apps/studio/test/tool-palette-insert-anchor.test.ts +0 -86
  452. package/apps/studio/test/tool-palette.test.tsx +0 -55
  453. package/apps/studio/test/tour-overlay.test.tsx +0 -90
  454. package/apps/studio/test/trusted-request-host.test.ts +0 -66
  455. package/apps/studio/test/undo-sequence-byte-compare.test.ts +0 -211
  456. package/apps/studio/test/undo-stack.test.ts +0 -244
  457. package/apps/studio/test/use-agent-presence.test.tsx +0 -117
  458. package/apps/studio/test/use-annotation-resize.test.ts +0 -402
  459. package/apps/studio/test/use-annotation-selection.test.tsx +0 -77
  460. package/apps/studio/test/use-artboard-drag.test.ts +0 -324
  461. package/apps/studio/test/use-canvas-activity.test.tsx +0 -206
  462. package/apps/studio/test/use-collab.test.ts +0 -0
  463. package/apps/studio/test/use-cursor-modifiers.test.ts +0 -59
  464. package/apps/studio/test/use-keyboard-discipline.test.ts +0 -27
  465. package/apps/studio/test/use-selection-set.test.tsx +0 -166
  466. package/apps/studio/test/use-snap-guides.test.ts +0 -271
  467. package/apps/studio/test/use-tool-mode.test.tsx +0 -186
  468. package/apps/studio/test/use-undo-stack.test.tsx +0 -223
  469. package/apps/studio/test/video-asset.test.ts +0 -163
  470. package/apps/studio/test/video-comp-fixture.test.ts +0 -50
  471. package/apps/studio/test/video-comp.test.ts +0 -270
  472. package/apps/studio/test/video-encode-lib.test.ts +0 -63
  473. package/apps/studio/test/video-render-bridge.test.ts +0 -171
  474. package/apps/studio/test/whats-new.test.ts +0 -113
  475. package/apps/studio/test/whisper-setup.test.ts +0 -97
  476. package/apps/studio/test/workspace-containment.test.ts +0 -382
  477. package/apps/studio/test/workspace-signin.test.ts +0 -292
  478. package/apps/studio/test/ws-handshake.test.ts +0 -33
  479. package/cli/commands/cache.test.mjs +0 -166
  480. package/cli/commands/design-link.test.mjs +0 -419
  481. package/cli/commands/design.test.mjs +0 -240
  482. package/cli/commands/doctor.test.mjs +0 -237
  483. package/cli/commands/hub-workspace.test.mjs +0 -275
  484. package/cli/commands/hub.test.mjs +0 -225
  485. package/cli/commands/kg.test.mjs +0 -142
  486. package/cli/commands/preflight.test.mjs +0 -46
  487. package/cli/lib/argv.test.mjs +0 -46
  488. package/cli/lib/cache.test.mjs +0 -303
  489. package/cli/lib/cell-plan.test.mjs +0 -225
  490. package/cli/lib/config-lint.test.mjs +0 -117
  491. package/cli/lib/ddr-to-kgai.test.mjs +0 -118
  492. package/cli/lib/design-ownership.test.mjs +0 -329
  493. package/cli/lib/fetch-asset.test.mjs +0 -213
  494. package/cli/lib/figma-codegen-reachability.test.mjs +0 -104
  495. package/cli/lib/figma-import-controls.test.mjs +0 -70
  496. package/cli/lib/flow-design-integration.test.mjs +0 -165
  497. package/cli/lib/gitignore-block.test.mjs +0 -136
  498. package/cli/lib/gitignore-drift.test.mjs +0 -156
  499. package/cli/lib/hubs-config.test.mjs +0 -165
  500. package/cli/lib/pkg-root.test.mjs +0 -111
  501. package/cli/lib/plugin-cli-reachability.test.mjs +0 -57
  502. package/cli/lib/plugin-name-namespace.test.mjs +0 -71
  503. package/cli/lib/reconstruct-toolset.test.mjs +0 -194
  504. package/cli/lib/stack-detect.test.mjs +0 -121
  505. package/cli/lib/update-check.test.mjs +0 -32
  506. package/cli/lib/workspace-plan.test.mjs +0 -699
@@ -57,7 +57,7 @@ import { createRescanScheduler, diffCanvasSet, type RescanScheduler } from './di
57
57
  import { createDocNameResolver } from './doc-name.ts';
58
58
  import { createEchoGuard } from './echo-guard.ts';
59
59
  import { createFileLedger } from './file-ledger.ts';
60
- import { createFilePlane } from './file-plane.ts';
60
+ import { createFilePlane, MAX_DORUCEKA_ROWS } from './file-plane.ts';
61
61
  import { type FilePullResult, pullFiles } from './file-pull.ts';
62
62
  import { createFsReader, type FsReader } from './fs-mirror.ts';
63
63
  import { type HubDocRow, hubHolds, indexHubDocs } from './hub-listing.ts';
@@ -80,6 +80,7 @@ import {
80
80
  stateDocumentGone,
81
81
  tombstonedSlugs,
82
82
  } from './remote-docs.ts';
83
+ import { computeSeedProgress } from './seed-progress.ts';
83
84
  import { createSyncStatusStore, type SyncStatusStore } from './status.ts';
84
85
  import { quarantineCanvas } from './tombstone-apply.ts';
85
86
  import { writeUntrustedMarkers } from './untrusted.ts';
@@ -94,8 +95,19 @@ export interface SyncProvider {
94
95
  * the bridge.
95
96
  */
96
97
  readonly awareness?: Awareness;
97
- /** Resolves when the first hub sync handshake completes. */
98
- onceSynced(): Promise<void>;
98
+ /**
99
+ * Resolves when the first hub sync handshake completes.
100
+ *
101
+ * `signal` lets a caller that gives up ALSO detach the underlying listener.
102
+ * Without it, every abandoned wait leaks a `synced` listener plus its closure
103
+ * for the life of the provider — bounded at one per slug while a wedged
104
+ * single-flight latch kept the caller from retrying, and UNBOUNDED the moment
105
+ * that latch was correctly fixed to release (verification review 2026-09-03,
106
+ * N2): one per slug per settle window per flap, all firing at once if a sync
107
+ * ever lands. Optional, so a test stub or a provider without the plumbing is
108
+ * unaffected.
109
+ */
110
+ onceSynced(signal?: AbortSignal): Promise<void>;
99
111
  /**
100
112
  * Subscribe to WS connection-status transitions (Phase 9 Task 8 offline
101
113
  * mode). Returns an unsubscribe fn. Optional — a test stub or a provider
@@ -140,10 +152,23 @@ export const BOOT_SETTLE_TIMEOUT_MS = 15_000;
140
152
  /** F1 — minimum wall-clock between renewal attempts. A burst of rejections
141
153
  * collapses to one renewal (single-flight); the NEXT burst waits this out. */
142
154
  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. */
155
+ /**
156
+ * F1 — consecutive successful renewals with NO PROGRESS in between before the
157
+ * runtime stops renewing and lets the link surface as refused/stalled.
158
+ *
159
+ * The cap itself is load-bearing: it is what stopped a reproduced 2 342
160
+ * renewals/s storm (2026-08-10 security review). What changed on 2026-09-03 is
161
+ * the definition of "progress".
162
+ *
163
+ * It used to mean ONLY a completed doc handshake, on the reasoning that "a
164
+ * renewal that fixed the credential clears at least one doc". That is true
165
+ * exactly when the doc lane is the lane with work. During a long file seed
166
+ * against an already-converged doc lane (85/87 synced, no handshakes left to
167
+ * land) there is nothing to clear — so every legitimate pre-expiry renewal
168
+ * burned a slot, three of them exhausted the cap, and the runtime stopped
169
+ * renewing while the file plane was still transferring. A delivered FILE now
170
+ * counts too (`onProgress` on the file plane).
171
+ */
147
172
  export const RENEW_MAX_WITHOUT_PROGRESS = 3;
148
173
  /** F2 — setTimeout clamps delays above this (2^31-1) to 1 ms, turning a
149
174
  * far-future expiry into a tight renewal loop. Clamp before we hand it over. */
@@ -167,6 +192,45 @@ export const DISCOVERY_DEBOUNCE_MS = 400;
167
192
  */
168
193
  export const REMOTE_POLL_MS = 20_000;
169
194
 
195
+ /** Floor between two serve-log seed-progress lines. Long enough not to become
196
+ * the next thing that buries the log. */
197
+ export const SEED_PROGRESS_LOG_MS = 15_000;
198
+ /** How often the stall watchdog looks (issue #118). Cheap — one snapshot. */
199
+ export const STALL_CHECK_MS = 30_000;
200
+ /**
201
+ * "Socket connected, not one document synced" for this long is a stall, not a
202
+ * slow handshake. Comfortably above a cold cell's worst observed wake (a
203
+ * measured `/health` on a sleeping cell answered in 14 s) and above the boot
204
+ * settle ceiling, so a healthy-but-slow link is never interrupted.
205
+ */
206
+ export const STALL_RECONNECT_AFTER_MS = 3 * 60 * 1000;
207
+ /** Floor between watchdog-forced reconnects — a hub that is simply down must
208
+ * not be turned into a reconnect storm by its own silence. */
209
+ export const STALL_RECONNECT_MIN_MS = 5 * 60 * 1000;
210
+ /**
211
+ * Per-ATTEMPT deadline for the shared hub socket.
212
+ *
213
+ * The provider defaults to `timeout: 0` — no deadline at all — and its retry
214
+ * attempt resolves only on the first MESSAGE. A DO holding an upgrade open
215
+ * while a container boots therefore parks one attempt indefinitely, and
216
+ * `onClose` refuses to re-arm while a retry chain is live
217
+ * (`if (!this.cancelWebsocketRetry …)`), so the whole socket goes deaf. A
218
+ * finite attempt is what turns that into a retry.
219
+ */
220
+ /**
221
+ * Random spread added ONCE per runtime to the stall threshold.
222
+ *
223
+ * A cell sleeping (or a hub restarting) drops every peer's socket in the same
224
+ * instant, so without this every peer's threshold expires inside one
225
+ * `STALL_CHECK_MS` tick of every other and they all re-authenticate N documents
226
+ * at once — the DDR-102 2026-06-11 retry storm, re-armed automatically every
227
+ * few minutes (attacker review 2026-09-03, F6). The floor below backs off on
228
+ * repeat for the same reason.
229
+ */
230
+ export const STALL_JITTER_MS = 120 * 1000;
231
+ /** Ceiling for the backed-off floor between forced reconnects. */
232
+ export const STALL_RECONNECT_MAX_MS = 30 * 60 * 1000;
233
+
170
234
  /**
171
235
  * Settle window before a local write triggers a file-plane pass.
172
236
  *
@@ -176,6 +240,18 @@ export const REMOTE_POLL_MS = 20_000;
176
240
  */
177
241
  export const FILE_PASS_DEBOUNCE_MS = 400;
178
242
 
243
+ /**
244
+ * The floor between two file-plane passes — issue #109.
245
+ *
246
+ * The debounce coalesces a burst of local writes; it does nothing about a
247
+ * steady drip, and a hub's file-event channel is a steady drip by design
248
+ * ("cloud changes now arrive in seconds"). 400 ms of spacing times 200
249
+ * requests a pass is 30k requests a minute at a door that opens 600 times.
250
+ * Two seconds is still well inside "arrives in seconds" and puts the client's
251
+ * ceiling an order of magnitude below the server's instead of above it.
252
+ */
253
+ export const MIN_PASS_INTERVAL_MS = 2_000;
254
+
179
255
  /**
180
256
  * Settling delay for an OFF-SCHEDULE poll (reconnect).
181
257
  *
@@ -361,6 +437,22 @@ export interface CreateSyncRuntimeOptions {
361
437
  * unit tests that only exercise the file-sync path.
362
438
  */
363
439
  registry?: AwarenessRegistry;
440
+ /**
441
+ * Stall-watchdog timings + clock. TEST INJECTION ONLY (see `stallCheck`).
442
+ *
443
+ * The watchdog shipped with no behavioural coverage at all — three review
444
+ * rounds each found a HIGH inside it, and none of them could have been caught
445
+ * by a test, because nothing could reach it: it required the OWNED factory
446
+ * and real wall-clock minutes. This is the seam that makes its predicate,
447
+ * its jitter and its backoff assertable.
448
+ */
449
+ stall?: {
450
+ checkMs?: number;
451
+ afterMs?: number;
452
+ minMs?: number;
453
+ jitterMs?: number;
454
+ now?: () => number;
455
+ };
364
456
  /**
365
457
  * Override the offline-mode connection monitor (Task 8 test injection —
366
458
  * lets tests pass injectable timers/clock). Defaults to a real monitor that
@@ -769,6 +861,18 @@ export function createSyncRuntime(
769
861
  const pendingAuthWarn = new Map<AuthFailureClass, Set<string>>();
770
862
  /** Latest rejection class per slug — feeds the boot summary detail. */
771
863
  const rejectedReasons = new Map<string, AuthFailureClass>();
864
+ /**
865
+ * EVERY document the hub has refused this episode, in ANY class.
866
+ *
867
+ * `rejectedPermanent` holds only the two classes that destroy the provider;
868
+ * `generic` (what every pre-DDR-102 hub sends) and `rate-limit` are absent
869
+ * from it by design. Anything asking "has the hub refused this document?"
870
+ * must ask HERE — reading `rejectedPermanent` for that question is how a
871
+ * transient refusal got laundered into a green row (attacker review
872
+ * 2026-09-03, F3). Emptied only by `clearRejection`, i.e. by a handshake the
873
+ * hub actually completed.
874
+ */
875
+ const rejectedAny = new Set<string>();
772
876
  /** Permanently-rejected docs awaiting a slow re-probe (provider destroyed). */
773
877
  const rejectedPermanent = new Map<
774
878
  string,
@@ -797,6 +901,14 @@ export function createSyncRuntime(
797
901
  * anything — stop, and let the link surface as refused/stalled. Reset to 0
798
902
  * at every `connected` doc (the handshake-success point). */
799
903
  let renewalsSinceProgress = 0;
904
+ /**
905
+ * When a document last reached `connected` — the ONLY evidence that this link
906
+ * is doing its job. Seeded at runtime construction so the stall watchdog
907
+ * (`armStallWatchdog`) measures from "when we started trying", exactly like
908
+ * `SyncStatusSnapshot.startedAt`, rather than from a promotion that may never
909
+ * come. Moves on first connect, on re-probe and on reconnect re-promotion.
910
+ */
911
+ let lastPromotionAt = 0;
800
912
  // Silent credential renewal (cloud). Absent under cell pairing — the cell's
801
913
  // token arrives via its environment and is the hub's own to rotate.
802
914
  const renewCredential = cellPairing
@@ -804,7 +916,13 @@ export function createSyncRuntime(
804
916
  : (opts.auth?.renewCredential ??
805
917
  (async () => {
806
918
  const r = await renewHubCredential(linkedHub.url);
807
- return r.ok ? { token: r.token, expiresAt: r.expiresAt } : null;
919
+ if (r.ok) return { token: r.token, expiresAt: r.expiresAt };
920
+ // The reason was computed and thrown away. `renewHubCredential`
921
+ // distinguishes signed-out from revoked from removed-from-project from
922
+ // unreachable — each a different sentence to a person — and every one of
923
+ // them reached the log as the same silence. Say which.
924
+ console.warn(`[sync] hub credential renewal declined: ${r.reason}`);
925
+ return null;
808
926
  }));
809
927
  const settleTimers = new Set<TimerHandle>();
810
928
  /** Pending synthetic `fs:any` emissions (cell pairing only), keyed by the
@@ -831,6 +949,23 @@ export function createSyncRuntime(
831
949
  let createdUnsub: (() => void) | null = null;
832
950
  /** Periodic remote-document poll — the hub-side half of discovery. */
833
951
  let remotePollTimer: ReturnType<typeof setInterval> | null = null;
952
+ /** The stall watchdog's tick — see `stallCheck` in `start()`. */
953
+ let stallTimer: ReturnType<typeof setInterval> | null = null;
954
+ /** Wall-clock of the last watchdog-forced reconnect (0 = never). The floor
955
+ * that keeps a silent hub from becoming a reconnect storm. */
956
+ let lastForcedReconnectAt = 0;
957
+ /** Forced reconnects so far — the exponent of the backing-off floor. */
958
+ let forcedReconnects = 0;
959
+ /** This runtime's slice of `STALL_JITTER_MS`, drawn once so peers that lost
960
+ * their sockets together do not fire together. */
961
+ const stallNow = opts.stall?.now ?? (() => Date.now());
962
+ // Seeded from the same clock the watchdog reads, so "nothing has settled
963
+ // since we started trying" is measured against a consistent origin.
964
+ lastPromotionAt = stallNow();
965
+ const stallCheckMs = opts.stall?.checkMs ?? STALL_CHECK_MS;
966
+ const stallAfterMs = opts.stall?.afterMs ?? STALL_RECONNECT_AFTER_MS;
967
+ const stallMinMs = opts.stall?.minMs ?? STALL_RECONNECT_MIN_MS;
968
+ const stallJitterMs = opts.stall?.jitterMs ?? Math.floor(Math.random() * STALL_JITTER_MS);
834
969
  /**
835
970
  * The file-event control channel (Sync v2 Increment 2), when the hub
836
971
  * advertises one. Null on a journal-less hub, in a cell (the child holds it
@@ -881,14 +1016,71 @@ export function createSyncRuntime(
881
1016
  if (stopped || filePassTimer !== null) return;
882
1017
  filePassTimer = setTimeout(() => {
883
1018
  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));
1019
+ void runPlanePass();
889
1020
  }, FILE_PASS_DEBOUNCE_MS);
890
1021
  filePassTimer.unref?.();
891
1022
  }
1023
+ /**
1024
+ * The ONE way a pass runs — issue #109.
1025
+ *
1026
+ * There were two entry points (this debounce and the 20 s poll) and no
1027
+ * mutual exclusion between them, so a busy file-event channel could start a
1028
+ * pass every 400 ms and overlap it with the poll's. Each pass is up to
1029
+ * `MAX_REQUESTS_PER_PASS` requests against a hub that meters per minute, so
1030
+ * the client's own ceiling was an order of magnitude above the server's and
1031
+ * it manufactured the rate limit it then failed on.
1032
+ *
1033
+ * A pass in flight absorbs the request (the running pass reads the same
1034
+ * disk and the same cursor, so a second one would decide the identical
1035
+ * work), and passes are floored `MIN_PASS_INTERVAL_MS` apart.
1036
+ */
1037
+ let planePassInFlight = false;
1038
+ let lastPlanePassAt = 0;
1039
+ let planePassWaiter: Promise<void> | null = null;
1040
+ async function runPlanePass(opts?: { floor?: boolean }): Promise<void> {
1041
+ // Captured, not re-read: `stop()` clears `filePlane`, and a pass that has
1042
+ // already decided to run must not dereference the field it was cleared to.
1043
+ const lane = filePlane;
1044
+ if (stopped || !lane) return;
1045
+ // A pass already running IS this pass: it reads the same disk and the same
1046
+ // cursor, so the caller waits for its answer rather than racing it. The
1047
+ // poll's `await` therefore still means "a pass has happened".
1048
+ if (planePassInFlight) return await (planePassWaiter ?? Promise.resolve());
1049
+ const since = Date.now() - lastPlanePassAt;
1050
+ // The floor governs POKES. The 20 s poll and the explicit `pullRemoteNow`
1051
+ // seam are already bounded by their own callers, and silently deferring a
1052
+ // caller that awaited a pass would be its own lie.
1053
+ if (opts?.floor !== false && since < MIN_PASS_INTERVAL_MS) {
1054
+ // Not dropped — deferred. A poke that arrives inside the floor still has
1055
+ // news, and losing it means a local edit waits for the next 20 s tick.
1056
+ schedulePlanePassAfter(MIN_PASS_INTERVAL_MS - since);
1057
+ return;
1058
+ }
1059
+ planePassInFlight = true;
1060
+ lastPlanePassAt = Date.now();
1061
+ const run = (async () => {
1062
+ try {
1063
+ const r = await lane.reconcile();
1064
+ planeResultSink?.(r);
1065
+ } catch (err) {
1066
+ console.error('[sync/files] pass failed:', err);
1067
+ } finally {
1068
+ planePassInFlight = false;
1069
+ planePassWaiter = null;
1070
+ }
1071
+ })();
1072
+ planePassWaiter = run;
1073
+ await run;
1074
+ }
1075
+ /** Re-arm the debounce timer at a specific delay (the floor's deferral). */
1076
+ function schedulePlanePassAfter(ms: number): void {
1077
+ if (stopped || filePassTimer !== null) return;
1078
+ filePassTimer = setTimeout(() => {
1079
+ filePassTimer = null;
1080
+ void runPlanePass();
1081
+ }, ms);
1082
+ filePassTimer.unref?.();
1083
+ }
892
1084
  /** Assigned by `start()` so a pass reports into the same status the poll does. */
893
1085
  let planeResultSink: ((r: import('./file-plane.ts').FilePlaneResult) => void) | null = null;
894
1086
 
@@ -1883,6 +2075,9 @@ export function createSyncRuntime(
1883
2075
  // hub's raw message).
1884
2076
  mon.noteDocState(canvas.slug, 'auth-rejected', reasonClass);
1885
2077
  rejectedReasons.set(canvas.slug, reasonClass);
2078
+ // Every class, including the transient ones that keep their provider —
2079
+ // see `rejectedAny`. This is the set the reconnect path consults.
2080
+ rejectedAny.add(canvas.slug);
1886
2081
  // Aggregate console output: ONE debounced warn for the whole burst.
1887
2082
  if (!pendingAuthWarn.has(reasonClass)) pendingAuthWarn.set(reasonClass, new Set());
1888
2083
  pendingAuthWarn.get(reasonClass)?.add(canvas.slug);
@@ -1931,6 +2126,7 @@ export function createSyncRuntime(
1931
2126
  */
1932
2127
  const clearRejection = (slug: string): void => {
1933
2128
  rejectedPermanent.delete(slug);
2129
+ rejectedAny.delete(slug);
1934
2130
  if (!rejectedReasons.delete(slug)) return;
1935
2131
  console.log(`[sync/${slug}] the hub accepted this document — clearing its refusal.`);
1936
2132
  };
@@ -2079,6 +2275,8 @@ export function createSyncRuntime(
2079
2275
  // DDR-102 — honest status: the handshake + reconcile completed.
2080
2276
  mon.noteDocState(canvas.slug, 'connected');
2081
2277
  mon.noteSyncActivity(canvas.slug);
2278
+ lastPromotionAt = stallNow();
2279
+ forcedReconnects = 0;
2082
2280
  // F1 — a completed handshake IS progress: a renewal actually helped, so
2083
2281
  // the no-progress cap resets. Without this a healthy link that renews
2084
2282
  // legitimately every 12 h would burn one cap slot per renewal forever.
@@ -2108,6 +2306,122 @@ export function createSyncRuntime(
2108
2306
  }
2109
2307
  };
2110
2308
 
2309
+ /**
2310
+ * A DOCUMENT THAT RE-HANDSHAKES IS A SYNCED DOCUMENT AGAIN.
2311
+ *
2312
+ * The bug this closes (issue #118), stated plainly: `noteDocState(slug,
2313
+ * 'connected')` had exactly ONE call site — the post-handshake reconcile
2314
+ * inside `connectCanvas`, which runs at attach, adopt and auth-re-probe and
2315
+ * at no other time. A dropped socket demotes every `connected` document to
2316
+ * `pending` (`noteProviderStatus`, deliberately — a document whose socket is
2317
+ * gone is not a synced document), and NOTHING re-promoted it when the socket
2318
+ * came back. The demotion was therefore permanent for the life of the
2319
+ * runtime: observed live as `state:"online", docs:{synced:0, pending:85}`
2320
+ * fifteen minutes after a boot whose own log line read `85/87 synced`.
2321
+ *
2322
+ * Downstream that reads as the worst kind of wrong: `syncPresentation` sees
2323
+ * "connected to the hub, zero documents settled, and it has been like that a
2324
+ * while" and reports a STALL blamed on the credential — so the remedy the
2325
+ * product offered (reconnect the workspace) was the one thing that could not
2326
+ * help, and the Resync it prompts re-handshakes every document against a
2327
+ * cold cell and lands on `offline`.
2328
+ *
2329
+ * What this does NOT do is re-run `handleSynced`. That path carries
2330
+ * migrate-seed, cold-start divergence resolution and history snapshots — a
2331
+ * one-time authoritative seed, correctly named as such, and firing it for
2332
+ * every canvas on every reconnect would turn a network blip into a
2333
+ * conflict-resolution storm. The honest signal is narrower and sufficient:
2334
+ * the hub completed a sync handshake for THIS document, so it is connected
2335
+ * again. Nothing on disk is touched.
2336
+ *
2337
+ * Single-flight per slug — a flapping socket must not stack one pending
2338
+ * promise per transition — and every guard is re-checked AFTER the await,
2339
+ * because a runtime can stop, and a canvas can be released, while we wait.
2340
+ */
2341
+ const repromoting = new Set<string>();
2342
+ const repromoteOnReconnect = async (slug: string, provider: SyncProvider): Promise<void> => {
2343
+ if (stopped || repromoting.has(slug)) return;
2344
+ // REFUSED IS REFUSED, WHATEVER THE CLASS.
2345
+ //
2346
+ // This guard read `rejectedPermanent`, and that map holds only the two
2347
+ // PERMANENT classes — `handleAuthFailure` puts `generic` and `rate-limit`
2348
+ // nowhere (they keep their provider and its backoff). `generic` is what
2349
+ // EVERY pre-DDR-102 hub sends. So a refusal in a transient class passed
2350
+ // straight through this guard and got overwritten with `connected`
2351
+ // (attacker review 2026-09-03, F3): the hub was dropping this document's
2352
+ // writes while the panel rendered it green, `docs.rejected` fell back to
2353
+ // 0, and the `refused` branch — plus the offline-long "git push as
2354
+ // backup" escalation — could never fire. `rejectedAny` is the honest set:
2355
+ // every class enters it, `clearRejection` is the only thing that empties
2356
+ // it, and it is checked on BOTH sides of the await because the refusal
2357
+ // that matters is the one that lands while we are waiting.
2358
+ if (rejectedAny.has(slug)) return;
2359
+ repromoting.add(slug);
2360
+ try {
2361
+ // BOUNDED, because `onceSynced()` can never settle.
2362
+ //
2363
+ // `handleAuthFailure` on a permanent class calls `provider.destroy()`,
2364
+ // and destroy emits `destroy`, never `synced` — so the promise below
2365
+ // stays pending for the life of the process, `finally` never runs, and
2366
+ // the slug stays latched in `repromoting` forever. Every later genuine
2367
+ // reconnect for that document would then return at the guard above:
2368
+ // issue #118 recreated per-document, permanently, by its own fix
2369
+ // (attacker review 2026-09-03, F4). `settleWait` already exists for
2370
+ // exactly this ("never hangs the summary on an auth-rejected provider
2371
+ // whose handshake never completes") — reuse it rather than invent a
2372
+ // second ceiling.
2373
+ //
2374
+ // `settleWait` resolves on the TIMEOUT as well as on success, so the
2375
+ // flag is what separates "the handshake landed" from "we stopped
2376
+ // waiting for it". Without it the timeout would promote the document.
2377
+ let landed = false;
2378
+ // The ceiling below stops us WAITING; the abort is what stops us
2379
+ // LISTENING. Skipping it leaked a `synced` listener + closure per
2380
+ // abandoned wait, forever (N2) — invisible while the latch bug meant we
2381
+ // never retried, unbounded once that was fixed.
2382
+ const giveUp = new AbortController();
2383
+ try {
2384
+ await settleWait(
2385
+ provider.onceSynced(giveUp.signal).then(() => {
2386
+ landed = true;
2387
+ })
2388
+ );
2389
+ } finally {
2390
+ giveUp.abort();
2391
+ }
2392
+ if (!landed || stopped) return;
2393
+ // Still ours, still not refused — either can have changed in flight.
2394
+ if (providers.get(slug) !== provider || rejectedAny.has(slug)) return;
2395
+ // ONE mutator, not two. `noteSyncActivity` promotes pending→connected
2396
+ // AND stamps the clock in a single emit, and it is the one that refuses
2397
+ // to resurrect an `auth-rejected` document — so it is both cheaper and
2398
+ // safer here than pairing it with `noteDocState`. Every emit is a
2399
+ // synchronous `_sync.json` write plus a fan-out to every open tab, and
2400
+ // `noteSyncActivity` has no change-dedupe of its own: on an 85-canvas
2401
+ // project the pair cost ~340 writes per hub-driven flap against ~170
2402
+ // for the demotion alone (defender F4 / attacker F5).
2403
+ //
2404
+ // Residual, stated rather than hidden: a completed handshake is not
2405
+ // proof that a byte was persisted, so a hub that flaps the socket and
2406
+ // completes empty sync steps can keep `lastSyncAt` fresh. That is
2407
+ // narrower than the socket-transition stamp this change removed from
2408
+ // `goOnline`, but it is the same shape, and it wants a separate
2409
+ // `noteHandshake()` the day the monitor grows one.
2410
+ mon.noteSyncActivity(slug);
2411
+ lastPromotionAt = stallNow();
2412
+ // A recovery that WORKED must not leave the floor ratcheted. Without
2413
+ // this, seven stall-and-recover cycles over a long session pin the
2414
+ // watchdog's floor at its 30-minute cap, so the next genuine stall waits
2415
+ // half an hour for help (verification review 2026-09-03, N3).
2416
+ forcedReconnects = 0;
2417
+ // A completed handshake is progress, exactly as it is on the first
2418
+ // connect — see the `renewalsSinceProgress` reset in `handleSynced`.
2419
+ renewalsSinceProgress = 0;
2420
+ } finally {
2421
+ repromoting.delete(slug);
2422
+ }
2423
+ };
2424
+
2111
2425
  /** onceSynced() with the boot-settle ceiling — never hangs the summary on
2112
2426
  * an auth-rejected provider (whose handshake never completes). */
2113
2427
  const settleWait = (p: Promise<void>): Promise<void> =>
@@ -2310,7 +2624,14 @@ export function createSyncRuntime(
2310
2624
  // wanted here: a genuine one-off reconnect (nothing poked
2311
2625
  // recently) runs immediately; a churn folds into the scheduled
2312
2626
  // tick, costing latency and never correctness.
2313
- if (s === 'connected' && wasDisconnected) pollRemoteSoon({ cooled: true });
2627
+ if (s === 'connected' && wasDisconnected) {
2628
+ pollRemoteSoon({ cooled: true });
2629
+ // …and re-establish this DOCUMENT's truth, not just the project's.
2630
+ // The poll above asks the hub what canvases exist; it says nothing
2631
+ // about whether THIS one is synced again, and for a long time
2632
+ // nothing did. See `repromoteOnReconnect`.
2633
+ void repromoteOnReconnect(canvas.slug, provider);
2634
+ }
2314
2635
  wasDisconnected = s !== 'connected';
2315
2636
  })
2316
2637
  );
@@ -2840,6 +3161,82 @@ export function createSyncRuntime(
2840
3161
  // Cumulative per boot — the Sync panel's one line. `synced` is the last
2841
3162
  // pass's converged count; `pulled`/`conflicts` accumulate.
2842
3163
  const fileTotals = { synced: 0, pulled: 0, conflicts: 0 };
3164
+ /** Previous progress, for `startedAt` continuity across ticks. */
3165
+ let lastSeedProgress: import('./seed-progress.ts').SeedProgress | null = null;
3166
+ /** When the last serve-log progress line went out, and what it said. */
3167
+ let lastProgressLogAt = 0;
3168
+ let lastProgressLine = '';
3169
+ /**
3170
+ * ONE LINE, PERIODICALLY, WHILE A SEED IS RUNNING.
3171
+ *
3172
+ * After boot the file plane printed NOTHING. Two runs of 14 and 6 minutes
3173
+ * that moved zero files were, from the terminal, indistinguishable from two
3174
+ * runs that were working — the only repeated line was a delete-breaker
3175
+ * warning that fired 47 times and buried everything else. The doc lane has
3176
+ * printed its one-shot summary since Phase 9; this is the file plane's.
3177
+ *
3178
+ * Rate-limited AND deduped: a converged project prints nothing at all, and
3179
+ * a stalled one prints the same line at most once every 15 s rather than
3180
+ * once per pass.
3181
+ */
3182
+ const reportSeedProgress = (
3183
+ p: import('./seed-progress.ts').SeedProgress,
3184
+ r: import('./file-plane.ts').FilePlaneResult
3185
+ ): void => {
3186
+ if (p.phase === 'converged') return;
3187
+ const blockedTotal = p.blocked.reduce((n, b) => n + b.count, 0);
3188
+ const bits = [`${p.delivered} / ${p.tracked} delivered`];
3189
+ if (p.remaining > 0) bits.push(`${p.remaining} waiting`);
3190
+ if (blockedTotal > 0) bits.push(`${blockedTotal} need attention`);
3191
+ if (r.backedOff) bits.push(`${r.backedOff} backing off`);
3192
+ let tail = '';
3193
+ if (r.rateLimited) {
3194
+ const secs = Math.max(1, Math.round((r.rateLimited.until - Date.now()) / 1000));
3195
+ const why =
3196
+ r.rateLimited.cause === 'unreachable'
3197
+ ? 'could not reach the workspace'
3198
+ : r.rateLimited.cause === 'quota'
3199
+ ? 'hourly upload allowance used up'
3200
+ : 'the workspace asked us to slow down';
3201
+ tail = ` · paused ${secs}s (${why})`;
3202
+ } else if (p.passCapped) {
3203
+ tail = ` · more next pass (${p.passCapped} ceiling)`;
3204
+ }
3205
+ const line = `[sync/files] ${bits.join(' · ')}${tail}`;
3206
+ const nowMs = Date.now();
3207
+ if (line === lastProgressLine && nowMs - lastProgressLogAt < SEED_PROGRESS_LOG_MS) return;
3208
+ if (nowMs - lastProgressLogAt < SEED_PROGRESS_LOG_MS) return;
3209
+ lastProgressLogAt = nowMs;
3210
+ lastProgressLine = line;
3211
+ console.log(line);
3212
+ };
3213
+ /** When the last pass ended, so a throughput sample has a window. */
3214
+ let lastPassEndedAt = 0;
3215
+ /**
3216
+ * Bytes that actually LANDED in the last pass, over the wall-clock since
3217
+ * the previous one.
3218
+ *
3219
+ * Bytes DELIVERED, never bytes sent. The 2026-09-03 misreading was exactly
3220
+ * this distinction: 616 MB left the machine across two runs and zero files
3221
+ * landed, so a rate computed from egress said "about ten minutes" when the
3222
+ * true answer was "never".
3223
+ */
3224
+ const throughputSample = (
3225
+ r: import('./file-plane.ts').FilePlaneResult
3226
+ ): { bytes: number; ms: number } | null => {
3227
+ const landed = r.pushed.length + r.pulled.length;
3228
+ const nowMs = Date.now();
3229
+ const ms = lastPassEndedAt > 0 ? nowMs - lastPassEndedAt : 0;
3230
+ lastPassEndedAt = nowMs;
3231
+ if (landed === 0 || ms <= 0) return null;
3232
+ const rows = fileLedger?.rows() ?? {};
3233
+ let bytes = 0;
3234
+ for (const rel of [...r.pushed, ...r.pulled]) {
3235
+ const size = rows[rel]?.size;
3236
+ if (Number.isFinite(size)) bytes += size as number;
3237
+ }
3238
+ return bytes > 0 ? { bytes, ms } : null;
3239
+ };
2843
3240
  const noteFilePull = (result: FilePullResult): void => {
2844
3241
  fileTotals.synced = result.skipped + result.pulled.length;
2845
3242
  fileTotals.pulled += result.pulled.length;
@@ -2893,12 +3290,75 @@ export function createSyncRuntime(
2893
3290
  '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
3291
  });
2895
3292
  }
3293
+ // THE DENOMINATOR, from the LEDGER — not from this pass.
3294
+ //
3295
+ // `fileTotals` below is derived from per-pass results, and a pass that
3296
+ // converges nothing is legitimately all zeros: that is why `_sync.json`
3297
+ // read `synced: 0, pushed: 0, pulled: 0` for twenty minutes while 2 961
3298
+ // ledger rows changed underneath it. The raw counters STAY beside this
3299
+ // (DDR-214: a panel derived from the same source it displays cannot be
3300
+ // cross-checked), but the progress a person reads comes from the source
3301
+ // that was correct the whole time.
3302
+ const progress = filePlane
3303
+ ? computeSeedProgress({
3304
+ rows: fileLedger?.rows() ?? {},
3305
+ now: Date.now(),
3306
+ pausedUntil: result.rateLimited?.until ?? null,
3307
+ pauseCause: result.rateLimited?.cause ?? null,
3308
+ ...(result.requestsExhausted
3309
+ ? { passCapped: 'requests' as const }
3310
+ : result.budgetExhausted
3311
+ ? { passCapped: 'bytes' as const }
3312
+ : {}),
3313
+ previous: lastSeedProgress,
3314
+ deliveredSince: throughputSample(result),
3315
+ })
3316
+ : null;
3317
+ if (progress) {
3318
+ lastSeedProgress = progress;
3319
+ reportSeedProgress(progress, result);
3320
+ }
2896
3321
  statusStore?.updateFiles?.({
2897
3322
  ...fileTotals,
2898
3323
  pushed: filePushed,
2899
- ...(filePlane ? { delivery: filePlane.doruceka() } : {}),
3324
+ ...(progress ? { progress } : {}),
3325
+ ...(filePlane
3326
+ ? {
3327
+ delivery: filePlane.doruceka(),
3328
+ ...(filePlane.dorucekaTotal() > MAX_DORUCEKA_ROWS
3329
+ ? { deliveryTruncated: filePlane.dorucekaTotal() - MAX_DORUCEKA_ROWS }
3330
+ : {}),
3331
+ }
3332
+ : {}),
2900
3333
  ...(held.length > 0 ? { held } : {}),
3334
+ // A FAILED TRANSFER IS NOT A SYNCED ONE (issue #109). These had no
3335
+ // field to land in, so a pass that refused every file still rendered
3336
+ // as `synced` and the only trace was the console lines below — on a
3337
+ // product whose premise is that nobody opens a terminal.
3338
+ ...(result.failed.length > 0 ? { failed: result.failed.length } : {}),
3339
+ ...(result.rateLimited
3340
+ ? {
3341
+ rateLimited: {
3342
+ until: result.rateLimited.until,
3343
+ waiting: result.rateLimited.waiting,
3344
+ },
3345
+ }
3346
+ : {}),
2901
3347
  });
3348
+ if (result.rateLimited) {
3349
+ // Once per boot (the store dedupes by id): a pause is not an error,
3350
+ // and the panel should say so in words rather than leave a person to
3351
+ // read a failure count and guess.
3352
+ statusStore?.notice?.({
3353
+ id: 'files-rate-limited',
3354
+ severity: 'info',
3355
+ text: `The workspace asked this machine to slow down, so file syncing is paused for a moment. ${
3356
+ result.rateLimited.waiting > 0
3357
+ ? `${result.rateLimited.waiting} file(s) are still on their way — nothing is lost, they arrive when the pause lifts.`
3358
+ : 'Nothing is lost; it resumes by itself.'
3359
+ }`,
3360
+ });
3361
+ }
2902
3362
  for (const f of result.failed) {
2903
3363
  console.warn(`[sync/files] ${f.rel}: ${f.reason}`);
2904
3364
  }
@@ -2927,8 +3387,10 @@ export function createSyncRuntime(
2927
3387
  // journal-less hubs, which the compat matrix keeps working through the
2928
3388
  // burn-down window.
2929
3389
  if (filePlane) {
2930
- const result = await filePlane.reconcile();
2931
- noteFilePlane(result);
3390
+ // Through the SAME door as the poke path, so the two can never overlap
3391
+ // (issue #109). `floor: false` — this caller is already bounded, and a
3392
+ // deferral would make an awaited pass silently not happen.
3393
+ await runPlanePass({ floor: false });
2932
3394
  return;
2933
3395
  }
2934
3396
  const result = await pullFiles({
@@ -2954,6 +3416,77 @@ export function createSyncRuntime(
2954
3416
  // dev server to refuse to exit.
2955
3417
  remotePollTimer.unref?.();
2956
3418
 
3419
+ // ── The stall watchdog (issue #118) ────────────────────────────────────
3420
+ //
3421
+ // WE CANNOT TRUST THE SOCKET TO NOTICE. `HocuspocusProviderWebsocket`
3422
+ // reports `connected` from the raw WS `open` event — before a token is
3423
+ // presented, before a byte comes back — and its own silence watchdog
3424
+ // (`checkConnection`) returns early while `lastMessageReceived === 0`,
3425
+ // which is precisely the state every fresh connection starts in. So a
3426
+ // socket that upgrades at the edge and then hears nothing (a cell woken
3427
+ // from `sleepAfter`, still restoring behind a DO that has accepted the
3428
+ // request) is PERMANENTLY "connected" with its watchdog switched off. No
3429
+ // close event, no retry, no reconnect — and therefore, before this, no
3430
+ // recovery short of the person pressing Resync.
3431
+ //
3432
+ // This is the liveness check the runtime should have owned all along: it
3433
+ // judges the link by whether any DOCUMENT has settled, which is the thing
3434
+ // the user actually wants, and it is the one signal a half-open socket
3435
+ // cannot fake. When nothing has settled for long enough, force the socket
3436
+ // down and back up; every provider re-authenticates on the new one and
3437
+ // `repromoteOnReconnect` collects the result.
3438
+ //
3439
+ // Deliberately narrow, because a false positive costs a full re-handshake
3440
+ // of every canvas:
3441
+ // • `state === 'online'` — offline already has its own recovery path.
3442
+ // • `synced === 0 && pending > 0` — some progress means it is working,
3443
+ // just slowly; this is only for the total stall.
3444
+ // • `rejected === 0` — a refusal is the auth lane's (renew + re-probe).
3445
+ // • a floor between forced reconnects, so a hub that is simply down
3446
+ // cannot be turned into a reconnect storm by its own silence.
3447
+ // The socket-cycling capability, resolved once.
3448
+ //
3449
+ // Was `ownedFactory`, which is null whenever a factory is INJECTED — so the
3450
+ // watchdog silently no-op'd in every test that could have exercised it, and
3451
+ // its jitter and backoff had no coverage at all (verification review
3452
+ // 2026-09-03). An injected factory that offers `reconnect()` is just as
3453
+ // usable. The `typeof` check is also the N4 guard: `sockets` is a
3454
+ // `Map<string, any>` and the provider is a caret dependency, so a 4.x minor
3455
+ // that renames this would otherwise degrade to a watchdog that can never
3456
+ // recover, saying nothing.
3457
+ const reconnectable: { reconnect(): void } | null =
3458
+ ownedFactory ??
3459
+ (typeof (providerFactory as Partial<DisposableProviderFactory>).reconnect === 'function'
3460
+ ? (providerFactory as DisposableProviderFactory)
3461
+ : null);
3462
+ const stallCheck = (): void => {
3463
+ if (stopped || !reconnectable) return;
3464
+ const snap = mon.snapshot();
3465
+ const docs = snap.docs;
3466
+ if (snap.state !== 'online' || !docs) return;
3467
+ if (docs.synced > 0 || docs.pending === 0 || docs.rejected > 0) return;
3468
+ const now = stallNow();
3469
+ if (now - lastPromotionAt < stallAfterMs + stallJitterMs) return;
3470
+ // The floor DOUBLES per forced reconnect (5 → 10 → 20 → capped at 30 min).
3471
+ // A hub that is simply down would otherwise buy a fresh N-document auth
3472
+ // burst from every peer every five minutes, forever.
3473
+ const floor = Math.min(STALL_RECONNECT_MAX_MS, stallMinMs * 2 ** forcedReconnects);
3474
+ if (lastForcedReconnectAt !== 0 && now - lastForcedReconnectAt < floor) return;
3475
+ lastForcedReconnectAt = now;
3476
+ forcedReconnects += 1;
3477
+ console.warn(
3478
+ `[sync] the hub socket says connected but not one of ${docs.pending} document(s) has ` +
3479
+ `synced in ${Math.round((now - lastPromotionAt) / 60_000)} minute(s) — forcing a reconnect.`
3480
+ );
3481
+ try {
3482
+ reconnectable.reconnect();
3483
+ } catch (err) {
3484
+ console.warn(`[sync] forced reconnect failed: ${(err as Error).message}`);
3485
+ }
3486
+ };
3487
+ stallTimer = setInterval(stallCheck, stallCheckMs);
3488
+ stallTimer.unref?.();
3489
+
2957
3490
  // ── Sync v2 Increment 2 — the poke, desktop side (DDR-226 §4) ──────────
2958
3491
  //
2959
3492
  // CAPABILITY-GATED, and the gate is the compat matrix (§10, BINDING): a
@@ -2991,6 +3524,23 @@ export function createSyncRuntime(
2991
3524
  designRoot: ctx.paths.designRoot,
2992
3525
  hubUrl: linkedHub.url,
2993
3526
  token: () => token,
3527
+ // THE FILE PLANE CAN NOW ASK FOR A CREDENTIAL. It could not
3528
+ // before: renewal was reachable only from the doc lane's
3529
+ // WebSocket auth failure, so a token that expired mid-seed left
3530
+ // hundreds of files refused with nothing ever asking for a new
3531
+ // one (2026-09-03). Same single-flight entry point the doc lane
3532
+ // uses — never a second renewal path.
3533
+ onAuthFailure: () => {
3534
+ void renewCredentialNow();
3535
+ },
3536
+ // A DELIVERED FILE IS PROGRESS. `renewalsSinceProgress` counted
3537
+ // only doc handshakes, and a converged doc lane has none left to
3538
+ // land — so during a long seed the cap was reached and the
3539
+ // runtime stopped renewing while the file plane was still
3540
+ // working.
3541
+ onProgress: () => {
3542
+ renewalsSinceProgress = 0;
3543
+ },
2994
3544
  ledger: fileLedger,
2995
3545
  canvasGroups: ctx.cfg.canvasGroups,
2996
3546
  allowCodeModules,
@@ -3096,6 +3646,8 @@ export function createSyncRuntime(
3096
3646
  discoveryRescan = null;
3097
3647
  if (remotePollTimer !== null) clearInterval(remotePollTimer);
3098
3648
  remotePollTimer = null;
3649
+ if (stallTimer !== null) clearInterval(stallTimer);
3650
+ stallTimer = null;
3099
3651
  if (remotePollSoonTimer !== null) clearTimeout(remotePollSoonTimer);
3100
3652
  remotePollSoonTimer = null;
3101
3653
  remotePull = null;
@@ -3775,6 +4327,16 @@ export interface DisposableProviderFactory extends ProviderFactory {
3775
4327
  /** Destroy the shared HocuspocusProviderWebsocket(s). Call AFTER provider
3776
4328
  * destroys — a provider detach sends a Close message over the socket. */
3777
4329
  dispose(): void;
4330
+ /**
4331
+ * Take every shared socket down and bring it back up (issue #118).
4332
+ *
4333
+ * The escape hatch for a socket that reports `connected` and carries nothing
4334
+ * — see the stall watchdog in `start()`. Providers are NOT destroyed and are
4335
+ * NOT re-created: they stay attached across the cycle and re-authenticate on
4336
+ * the new socket, exactly as they do after any ordinary drop, so this costs a
4337
+ * handshake and never a re-attach.
4338
+ */
4339
+ reconnect(): void;
3778
4340
  }
3779
4341
 
3780
4342
  /**
@@ -3824,7 +4386,31 @@ export function createDefaultProviderFactory(
3824
4386
  const wsUrl = toWsUrl(args.url);
3825
4387
  let socket = sockets.get(wsUrl);
3826
4388
  if (!socket) {
3827
- socket = new mod.HocuspocusProviderWebsocket({ url: wsUrl });
4389
+ // CONFIGURED, not defaulted (issue #118). The socket used to be built
4390
+ // from the URL alone, which inherited `timeout: 0` — no per-attempt
4391
+ // deadline — and that is the property that let one parked connection
4392
+ // attempt silence the whole link for as long as the process lived. The
4393
+ // other two are pinned rather than inherited so a provider upgrade cannot
4394
+ // move them without someone noticing here.
4395
+ // NO `timeout`. It is tempting — the default is 0, meaning a connection
4396
+ // attempt has no deadline at all — and it is a TRAP: `@lifeomic/attempt`
4397
+ // handles a timeout by calling `reject` DIRECTLY, bypassing the `onError`
4398
+ // path that is the only thing which schedules another attempt. So the
4399
+ // deadline does not retry the attempt, it ENDS THE CHAIN — and since
4400
+ // every one of hocuspocus's `connect()` call sites floats the promise and
4401
+ // this process registers no `unhandledRejection` handler, a hub merely
4402
+ // being slow would take the dev server down (security review 2026-09-03,
4403
+ // F2). A hub slower than the deadline is the NORMAL case this whole fix
4404
+ // is about: a sleeping cell's `/health` measured 14 s, and a container
4405
+ // cold-boot is routinely worse.
4406
+ //
4407
+ // Liveness is the runtime's job instead — see the stall watchdog in
4408
+ // `start()`, which is a layer we control and can make safe.
4409
+ socket = new mod.HocuspocusProviderWebsocket({
4410
+ url: wsUrl,
4411
+ messageReconnectTimeout: 30_000,
4412
+ maxDelay: 30_000,
4413
+ });
3828
4414
  sockets.set(wsUrl, socket);
3829
4415
  }
3830
4416
  // Phase 9.2 (DDR-064) — attach to the shared room doc when the runtime
@@ -3848,6 +4434,40 @@ export function createDefaultProviderFactory(
3848
4434
  // With an injected websocketProvider the provider does NOT auto-attach
3849
4435
  // (manageSocket=false in @hocuspocus/provider 4.x) — attach explicitly.
3850
4436
  provider.attach();
4437
+
4438
+ // `provider.synced` IS NOT TRUSTWORTHY ACROSS A STATUS TRANSITION.
4439
+ //
4440
+ // A provider learns a socket died only from the `close` EVENT, and the
4441
+ // library itself does not always raise one: `checkConnection()`'s
4442
+ // force-close branch (`closeTries > 2`) CALLS `this.onClose({code:4408})`
4443
+ // directly, and `cleanupWebSocket()` has already detached the raw handlers
4444
+ // by then — so no `close` is emitted, no provider's `boundOnClose` runs,
4445
+ // and every provider keeps `isSynced === true` through the teardown. That
4446
+ // branch is reached by exactly the failure this whole change is about: a
4447
+ // link that WAS healthy and then went quiet for 30 s.
4448
+ //
4449
+ // It gets worse quietly. `set synced` is a no-op when the value is
4450
+ // unchanged, so a provider left stale-true does not emit `synced` on its
4451
+ // NEXT genuine handshake either — the flag is wrong in both directions.
4452
+ //
4453
+ // Before this change the staleness was inert (nothing re-promoted on it).
4454
+ // `repromoteOnReconnect` makes it authoritative: `onceSynced()`
4455
+ // short-circuits on that field, so it would resolve instantly and report a
4456
+ // handshake that never happened, for every document, then disarm the stall
4457
+ // watchdog through its own `docs.synced > 0` guard (verification review
4458
+ // 2026-09-03, NEW-1 — the same defect as N1, arriving through the library
4459
+ // instead of through us).
4460
+ //
4461
+ // So: model the reset the missing event would have done. Assigning `false`
4462
+ // only flips the flag (the setter emits on `true` alone), which is exactly
4463
+ // the `synced` half of `HocuspocusProvider.onClose()` and none of its
4464
+ // awareness side effects — those belong to the real close event, and still
4465
+ // run when there is one.
4466
+ const resetSyncedOnDrop = (evt: { status?: string }): void => {
4467
+ if (evt?.status && evt.status !== 'connected' && provider.synced) provider.synced = false;
4468
+ };
4469
+ socket.on('status', resetSyncedOnDrop);
4470
+
3851
4471
  return {
3852
4472
  document,
3853
4473
  // HocuspocusProvider creates a hub-synced Awareness by default; expose it
@@ -3875,20 +4495,34 @@ export function createDefaultProviderFactory(
3875
4495
  authFailedCbs.add(cb);
3876
4496
  return () => authFailedCbs.delete(cb);
3877
4497
  },
3878
- onceSynced(): Promise<void> {
4498
+ onceSynced(signal?: AbortSignal): Promise<void> {
3879
4499
  return new Promise<void>((resolve) => {
3880
4500
  if (provider.synced) {
3881
4501
  resolve();
3882
4502
  return;
3883
4503
  }
3884
4504
  const handler = () => {
3885
- provider.off('synced', handler);
4505
+ cleanup();
3886
4506
  resolve();
3887
4507
  };
4508
+ // Detach on abort as well as on success — see the interface doc. The
4509
+ // promise is deliberately NOT rejected: callers race it against their
4510
+ // own ceiling and read a separate flag, so a rejection here would be
4511
+ // an unhandled one at every site that stops waiting.
4512
+ const cleanup = () => {
4513
+ provider.off('synced', handler);
4514
+ signal?.removeEventListener('abort', cleanup);
4515
+ };
4516
+ if (signal?.aborted) {
4517
+ resolve();
4518
+ return;
4519
+ }
3888
4520
  provider.on('synced', handler);
4521
+ signal?.addEventListener('abort', cleanup, { once: true });
3889
4522
  });
3890
4523
  },
3891
4524
  destroy() {
4525
+ socket.off('status', resetSyncedOnDrop);
3892
4526
  // Detaches from the shared socket (sends a per-document Close); the
3893
4527
  // socket itself is destroyed by dispose() after all providers.
3894
4528
  provider.destroy();
@@ -3907,6 +4541,63 @@ export function createDefaultProviderFactory(
3907
4541
  }
3908
4542
  sockets.clear();
3909
4543
  },
4544
+ reconnect(): void {
4545
+ for (const socket of sockets.values()) {
4546
+ try {
4547
+ // NOT `disconnect()` + `connect()` — that pair WEDGES THE SOCKET SHUT,
4548
+ // deterministically, in exactly the state this is called from
4549
+ // (security review 2026-09-03, F1). `disconnect()` sets
4550
+ // `shouldConnect = false` and closes the WS without touching
4551
+ // `status`; the close event is asynchronous, so the immediately
4552
+ // following `connect()` hits its own first guard —
4553
+ // `if (this.status === WebSocketStatus.Connected) return;` — which
4554
+ // sits ABOVE the line that would restore `shouldConnect`. When the
4555
+ // close finally lands, `onClose`'s re-arm reads
4556
+ // `!cancelWebsocketRetry && shouldConnect` → false, and nothing else
4557
+ // re-arms it. The recovery action would have turned a stall that
4558
+ // survives a restart into a link that never reconnects again.
4559
+ //
4560
+ // So take the path the library is actually built around: close the
4561
+ // raw socket and let its OWN close-driven reconnect run — byte for
4562
+ // byte what happens when the hub drops us, which is the best-tested
4563
+ // path in the provider. `shouldConnect` stays true, and
4564
+ // `cancelWebsocketRetry` is already clear (only `onOpen` clears it,
4565
+ // and a socket reporting `connected` has been through `onOpen`).
4566
+ //
4567
+ // `connect()` is the fallback for the no-live-socket case ONLY, where
4568
+ // status is not `Connected` and it therefore does not early-return.
4569
+ //
4570
+ // EMIT THE EVENT — do not call the handler.
4571
+ //
4572
+ // `HocuspocusProviderWebsocket.onClose()` looks like the right entry
4573
+ // point (`checkConnection()` calls it with this exact 4408 frame) and
4574
+ // calling it directly is WRONG in a way that is invisible until you
4575
+ // read who else listens. That method emits `status` and `disconnect`
4576
+ // — never `close`. Each attached PROVIDER learns about a drop through
4577
+ // `websocketProvider.on('close', boundOnClose)`, and
4578
+ // `HocuspocusProvider.onClose()` is the only thing that resets
4579
+ // `isAuthenticated` and `synced`. Skip the event and every provider
4580
+ // keeps `synced === true` across the reconnect; `set synced` is a
4581
+ // no-op when unchanged, so the NEXT real handshake emits nothing
4582
+ // either, and `onceSynced()` — which short-circuits on that field —
4583
+ // resolves instantly for all 85 documents against a socket that has
4584
+ // completed no handshake at all. The recovery would have manufactured
4585
+ // `docs.synced = 85` out of nothing, and then disarmed the watchdog
4586
+ // permanently through its own `docs.synced > 0` guard (verification
4587
+ // review 2026-09-03, N1).
4588
+ //
4589
+ // Emitting is what the raw socket's own handler does
4590
+ // (`onCloseHandler = (payload) => this.emit('close', {event: payload})`),
4591
+ // and the constructor registers `this.on('close', this.onClose)` —
4592
+ // so this runs every provider's reset AND the socket's own cleanup +
4593
+ // re-arm. THAT is "byte for byte what happens when the hub drops us";
4594
+ // the handler call only resembled it.
4595
+ socket.emit('close', { event: { code: 4408, reason: 'forced' } });
4596
+ } catch (err) {
4597
+ console.warn(`[sync] socket reconnect failed: ${(err as Error).message}`);
4598
+ }
4599
+ }
4600
+ },
3910
4601
  });
3911
4602
  }
3912
4603