@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
@@ -68,17 +68,70 @@ import {
68
68
  } from './file-membership.ts';
69
69
  import { fetchJournal, type JournalEntry } from './journal-client.ts';
70
70
  import { createPullBudget } from './pull-budget.ts';
71
+ import { failureReason, isBackpressure, retryAfterMs } from './retry-after.ts';
72
+ import { classifyTransportError } from './transport-error.ts';
71
73
 
72
74
  /** How long to wait for one file's bytes. Generous — these run to videos. */
73
75
  const GET_TIMEOUT_MS = 120_000;
74
76
  const PUT_TIMEOUT_MS = 120_000;
75
77
 
76
- /** Refuse an implausible body rather than streaming it to disk. */
78
+ /** Refuse an implausible body rather than streaming it to disk. This is the
79
+ * PULL/receive cap and is deliberately generous — it bounds what we will
80
+ * accept, not what a hub will. */
77
81
  const MAX_FILE_BYTES = 512 * 1024 * 1024;
78
82
 
83
+ /**
84
+ * What the door accepts, until it tells us otherwise.
85
+ *
86
+ * Mirrors `MAX_FILE_BYTES` in `apps/hub/src/file-door.mjs`, and the two used to
87
+ * disagree by a factor of five: the client queued anything under 512 MB, the
88
+ * door refused anything over 95 MB, and nothing anywhere reconciled the two.
89
+ * The result was a 164.9 MB image and a 465.8 MB video re-uploaded on every
90
+ * pass of every boot, forever, each one burning a request slot and landing an
91
+ * anonymous `stuck`. This constant is only the FALLBACK — a hub that answers
92
+ * `/api/file-limits` is authoritative, because a hardcoded mirror of someone
93
+ * else's constant is the same trap one level down.
94
+ */
95
+ export const DEFAULT_HUB_MAX_FILE_BYTES = 95 * 1024 * 1024;
96
+
97
+ /**
98
+ * The narrowest ceiling we will believe from a hub, and the longest quota pause
99
+ * we will accept from one.
100
+ *
101
+ * DDR-054 makes the hub untrusted to peers, and `/api/file-limits` hands it two
102
+ * levers over this client that nothing else does: a `maxFileBytes` of 1 would
103
+ * push a whole project into the terminal `refused` state — files reported as
104
+ * permanently unsendable, which is a lie a peer should not be able to tell
105
+ * about your disk — and a far-future `quotaResetsAt` would park the lane for as
106
+ * long as it liked. Both are clamped to a range where an honest hub is
107
+ * unaffected and a hostile one buys a bounded delay at worst.
108
+ */
109
+ export const MIN_TRUSTED_MAX_FILE_BYTES = 1024 * 1024;
110
+
111
+ /** How long a `/api/file-limits` answer is believed before we ask again. Short
112
+ * relative to the hub's hourly quota window, so a reading can never strand a
113
+ * client for the rest of its life. */
114
+ export const HUB_LIMITS_TTL_MS = 10 * 60_000;
115
+ export const MAX_TRUSTED_QUOTA_PAUSE_MS = 2 * 60 * 60_000;
116
+
79
117
  /** How many files one pass will move. The remainder is the next pass's work. */
80
118
  const MAX_FILES_PER_PASS = 200;
81
119
 
120
+ /**
121
+ * How many HTTP REQUESTS one pass will issue. Issue #109.
122
+ *
123
+ * `MAX_FILES_PER_PASS` counts what MOVED, and `applyOne` returns false when a
124
+ * transfer fails — so under any refusal the counter stood still and the loop
125
+ * ran to the end of `work`, one request per path, with no ceiling at all. The
126
+ * cap stopped bounding the request rate at exactly the moment it was the only
127
+ * thing that could. Against a hub that meters per minute, that is not a client
128
+ * hitting a limit, it is a client manufacturing one.
129
+ *
130
+ * So the hard ceiling counts REQUESTS, which is the quantity the hub actually
131
+ * meters, and it is charged whether the answer was bytes or a refusal.
132
+ */
133
+ export const MAX_REQUESTS_PER_PASS = 200;
134
+
82
135
  /**
83
136
  * How many consecutive `reanchor` answers before we stop obeying them.
84
137
  *
@@ -168,6 +221,53 @@ export const DELETE_BUDGET_PER_WINDOW = 25;
168
221
  */
169
222
  export const DELETE_BREAKER_MIN_FOR_FRACTION = 2;
170
223
 
224
+ /**
225
+ * Consecutive unreachable transfers before the whole lane holds.
226
+ *
227
+ * Small, because the signal is unambiguous: five files in a row that could not
228
+ * open a socket is not five file problems. Larger would just mean more wasted
229
+ * requests before reaching the same conclusion.
230
+ */
231
+ export const UNREACHABLE_STREAK_LIMIT = 5;
232
+
233
+ /** How long the lane waits after deciding the peer is unreachable. Shorter than
234
+ * a metering pause — nobody asked for this one, so we should check back sooner. */
235
+ export const UNREACHABLE_HOLD_MS = 30_000;
236
+
237
+ /**
238
+ * How many delivery rows one payload carries.
239
+ *
240
+ * The rows are sorted actionable-first so the cap only ever truncates the
241
+ * already-aggregated healthy tail — a person looking for one broken file still
242
+ * finds it, and the total they should compare against comes from
243
+ * `progress.tracked` rather than from counting keys.
244
+ */
245
+ export const MAX_DORUCEKA_ROWS = 300;
246
+
247
+ /** The last delete-hold we logged, so the same hold is not re-announced every
248
+ * pass. Observed 47 times in a 72-line log, burying everything else. */
249
+ let lastDeleteHoldKey = '';
250
+
251
+ /** Sort key: what needs a person first. */
252
+ function deliveryRank(state: DeliveryState): number {
253
+ switch (state) {
254
+ case 'conflict':
255
+ return 0;
256
+ case 'refused':
257
+ return 1;
258
+ case 'stuck':
259
+ return 2;
260
+ case 'referenced-but-unoffered':
261
+ return 3;
262
+ case 'pushing':
263
+ return 4;
264
+ case 'local-only':
265
+ return 5;
266
+ default:
267
+ return 6;
268
+ }
269
+ }
270
+
171
271
  /** Walk depth ceiling — matches the classifier's own shape cap. */
172
272
  const MAX_WALK_DEPTH = 8;
173
273
 
@@ -187,6 +287,41 @@ export interface FilePlaneResult {
187
287
  reanchored: boolean;
188
288
  /** The pass stopped on the aggregate byte budget. */
189
289
  budgetExhausted?: true;
290
+ /**
291
+ * The hub asked us to slow down (429), so the WHOLE PLANE is held until
292
+ * `until` — not this file, not this pass.
293
+ *
294
+ * A rate limit is the one refusal where retrying is the thing making it
295
+ * worse, so it is the one refusal that must reach further than the request
296
+ * that met it. `waiting` is how many paths this pass had left to do, so a
297
+ * person is told the size of the pause rather than left reading a status
298
+ * line that says `synced`.
299
+ */
300
+ rateLimited?: {
301
+ until: number;
302
+ retryAfterMs: number;
303
+ waiting: number;
304
+ /**
305
+ * WHY the lane is holding — the two need different words.
306
+ *
307
+ * `hub-asked` is a peer metering us: nothing is wrong, wait. `unreachable`
308
+ * is a peer we could not reach at all, which is the shape a restarting
309
+ * cell produces — and telling a person "the workspace asked us to slow
310
+ * down" when in fact nothing answered is a status surface that lies
311
+ * (DDR-214). Absent reads as `hub-asked`, the pre-existing meaning.
312
+ */
313
+ cause?: 'hub-asked' | 'unreachable' | 'quota';
314
+ };
315
+ /** The pass stopped because it had spent its request ceiling. */
316
+ requestsExhausted?: true;
317
+ /** The door refused our credential; a renewal was requested and the pass ended. */
318
+ authRefused?: true;
319
+ /**
320
+ * Paths skipped this pass because they are inside their per-path backoff
321
+ * window. NOT a failure and NOT converged — work that is deliberately
322
+ * waiting, which is a third thing the counters had no way to say.
323
+ */
324
+ backedOff?: number;
190
325
  /** Files removed this pass — `parked` names the `_trash/` copy when there is one. */
191
326
  deleted: { rel: string; parked: string | null }[];
192
327
  /** A delete burst tripped a breaker; nothing was removed. */
@@ -228,6 +363,31 @@ export interface FilePlaneOptions {
228
363
  log?: Pick<Console, 'log' | 'warn'>;
229
364
  now?: () => number;
230
365
  maxPassBytes?: number;
366
+ /**
367
+ * The door refused our CREDENTIAL (401/403), not our request.
368
+ *
369
+ * The plane had no 401 handling at all: a refusal fell through the generic
370
+ * path and landed as a per-path reason `HTTP 401 — unauthorized`, so a
371
+ * credential that expired mid-seed produced hundreds of "stuck" files and
372
+ * NOTHING ever asked for a new one. Renewal was reachable only from the doc
373
+ * lane's WebSocket auth failure — and on a converged project the doc lane has
374
+ * no handshakes left to fail.
375
+ *
376
+ * Wired by `sync/index.ts` to the same single-flight `renewCredentialNow()`
377
+ * the doc lane uses. Never a second renewal path: that one already has the
378
+ * 60 s floor and the no-progress cap that stopped a reproduced 2 342
379
+ * renewals/s storm.
380
+ */
381
+ onAuthFailure?: (rel: string, status: number) => void;
382
+ /**
383
+ * A pass delivered at least one file.
384
+ *
385
+ * Feeds `renewalsSinceProgress` in `sync/index.ts`, whose cap otherwise
386
+ * counts only DOC handshakes — so during a long seed against an already
387
+ * converged doc lane the runtime stopped renewing after three renewals while
388
+ * the file plane was still working.
389
+ */
390
+ onProgress?: () => void;
231
391
  }
232
392
 
233
393
  interface LocalFile {
@@ -325,8 +485,11 @@ export function foldRemote(entries: JournalEntry[]): Map<string, JournalEntry> {
325
485
  export interface FilePlane {
326
486
  /** One full pass. Never throws. */
327
487
  reconcile(): Promise<FilePlaneResult>;
328
- /** Paths whose delivery state the panel should show. */
488
+ /** Paths whose delivery state the panel should show. BOUNDED — see
489
+ * `MAX_DORUCEKA_ROWS`; `dorucekaTotal()` is how many there really are. */
329
490
  doruceka(): Record<string, DeliveryState>;
491
+ /** How many rows the ledger holds, before the doručenka's cap. */
492
+ dorucekaTotal(): number;
330
493
  }
331
494
 
332
495
  export function createFilePlane(opts: FilePlaneOptions): FilePlane {
@@ -351,28 +514,226 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
351
514
 
352
515
  const auth = () => ({ authorization: `Bearer ${opts.token()}` });
353
516
 
517
+ /**
518
+ * A thrown transfer error, in words a PERSON can act on.
519
+ *
520
+ * These used to store `(err as Error).message` verbatim, which is Bun's
521
+ * wording, not ours — so 484 undelivered files in an 8.8 GB project were
522
+ * labelled "Was there a typo in the url or port?" while the real fault was a
523
+ * cell restarting in a loop (2026-09-03). The runtime's own text is kept, but
524
+ * in the log where a maintainer reads it, not in the panel where a customer
525
+ * does.
526
+ */
527
+ function transportFailure(err: unknown, rel: string, logger: typeof log): string {
528
+ const c = classifyTransportError(err);
529
+ logger.warn?.(`[sync/files] ${rel}: ${c.text} — ${c.raw}`);
530
+ if (c.class === 'unreachable') {
531
+ unreachableInARow += 1;
532
+ // A PEER THAT IS NOT THERE IS A WALL, not N separate file failures.
533
+ //
534
+ // Without this, a restarting cell drained the whole request ceiling into
535
+ // a closed socket every pass — 314 PUTs across 44 paths in 10 s — and
536
+ // every one of them came back as a per-file `stuck`, so the panel showed
537
+ // hundreds of broken files instead of one unreachable workspace.
538
+ if (unreachableInARow >= UNREACHABLE_STREAK_LIMIT && hitRateLimit === null) {
539
+ hitRateLimit = { retryAfterMs: UNREACHABLE_HOLD_MS, cause: 'unreachable' };
540
+ }
541
+ } else {
542
+ unreachableInARow = 0;
543
+ }
544
+ return c.text;
545
+ }
546
+
354
547
  /** Consecutive passes the hub answered `reanchor` to. Reset by any that did
355
548
  * not — and decayed by time once held (F-11), so a hold is a window, never
356
549
  * a brick. */
357
550
  let reanchorsInARow = 0;
358
551
  let reanchorHeldSince = 0;
359
552
 
553
+ /**
554
+ * When the plane may talk to the hub again. Issue #109.
555
+ *
556
+ * A 429 is the one refusal where the retry IS the cause: every re-request
557
+ * inside the window keeps the bucket empty, and the pass only advances its
558
+ * cursor on a clean run, so the identical work set came round every 400 ms.
559
+ * The hold therefore belongs to the PLANE, not to the file that met the
560
+ * wall — the same shape as the re-anchor hold above, and for the same
561
+ * reason: obedience to a hub has to be capped somewhere.
562
+ */
563
+ let rateLimitedUntil = 0;
564
+ /** Set when a refusal this pass was a 429; the pass stops on it. */
565
+ let hitRateLimit: {
566
+ retryAfterMs: number;
567
+ cause: 'hub-asked' | 'unreachable' | 'quota';
568
+ } | null = null;
569
+ /** Consecutive `unreachable` classifications this pass. */
570
+ let unreachableInARow = 0;
571
+ /** The door refused our credential this pass. */
572
+ let authRefused = false;
573
+ /** Bytes this pass has successfully pushed, charged against the window. */
574
+ let quotaSpentThisPass = 0;
575
+ /** The door's own ceilings, learned once per boot. Null until asked. */
576
+ let hubLimits: {
577
+ maxFileBytes: number;
578
+ quotaResetsAt?: number;
579
+ quotaUsed?: number;
580
+ quotaBytesPerWindow?: number;
581
+ } | null = null;
582
+ /** When the limits were last learned. 0 = never. */
583
+ let hubLimitsAt = 0;
584
+
585
+ /**
586
+ * Ask the door what it accepts. Once per boot, best-effort.
587
+ *
588
+ * A hub too old to answer (404) is not an error — it gets the fallback, which
589
+ * is what every hub did before this route existed.
590
+ */
591
+ async function ensureHubLimits(): Promise<void> {
592
+ // RE-ASK WHEN THEY GO STALE, and when the quota window should have rolled.
593
+ //
594
+ // Asking exactly once per boot was a serious bug: `quotaUsed` is a point-in-
595
+ // time reading, so a client that booted with the allowance nearly spent
596
+ // computed a headroom of ~0 and then refused every upload FOREVER — long
597
+ // after the hub's hourly window had reset — until someone restarted the
598
+ // process. A stale ceiling is a slow client; a stale quota reading is a
599
+ // client that has silently stopped.
600
+ const stale = hubLimitsAt === 0 || now() - hubLimitsAt > HUB_LIMITS_TTL_MS;
601
+ const windowRolled =
602
+ hubLimits?.quotaResetsAt !== undefined && now() >= (hubLimits.quotaResetsAt as number);
603
+ if (!stale && !windowRolled) return;
604
+ hubLimitsAt = now();
605
+ try {
606
+ const res = await fetchImpl(`${base}/api/file-limits`, {
607
+ headers: auth(),
608
+ signal: AbortSignal.timeout(GET_TIMEOUT_MS),
609
+ });
610
+ if (!res.ok) return;
611
+ const body = (await res.json()) as {
612
+ maxFileBytes?: unknown;
613
+ quotaResetsAt?: unknown;
614
+ quotaUsed?: unknown;
615
+ quotaBytesPerWindow?: unknown;
616
+ };
617
+ if (
618
+ Number.isFinite(body?.maxFileBytes) &&
619
+ (body.maxFileBytes as number) >= MIN_TRUSTED_MAX_FILE_BYTES
620
+ ) {
621
+ hubLimits = {
622
+ // Never BELOW our own floor, never above our own receive cap. A hub
623
+ // may be stricter than the default; it may not declare a project
624
+ // unsendable.
625
+ maxFileBytes: Math.min(body.maxFileBytes as number, MAX_FILE_BYTES),
626
+ ...(Number.isFinite(body?.quotaResetsAt)
627
+ ? { quotaResetsAt: body.quotaResetsAt as number }
628
+ : {}),
629
+ ...(Number.isFinite(body?.quotaUsed) ? { quotaUsed: body.quotaUsed as number } : {}),
630
+ ...(Number.isFinite(body?.quotaBytesPerWindow)
631
+ ? { quotaBytesPerWindow: body.quotaBytesPerWindow as number }
632
+ : {}),
633
+ };
634
+ }
635
+ } catch {
636
+ /* the fallback is the pre-existing behaviour */
637
+ }
638
+ }
639
+
640
+ /** The push ceiling in force right now. */
641
+ function pushCeiling(): number {
642
+ return hubLimits?.maxFileBytes ?? DEFAULT_HUB_MAX_FILE_BYTES;
643
+ }
644
+
645
+ /**
646
+ * Bytes still available inside the hub's current write window, or null when
647
+ * the hub did not say.
648
+ *
649
+ * PACING, NOT A NEW LIMIT — the door's quota stays authoritative. The point
650
+ * is to stop cleanly at the wall instead of driving into it: an 8.8 GB
651
+ * project against a 2 GiB/hour allowance spends at least two full windows,
652
+ * and discovering that as a 507 once per remaining file turns an ordinary,
653
+ * expected wait into hundreds of refusals.
654
+ */
655
+ function quotaHeadroom(): number | null {
656
+ const cap = hubLimits?.quotaBytesPerWindow;
657
+ const used = hubLimits?.quotaUsed;
658
+ if (!Number.isFinite(cap) || !Number.isFinite(used)) return null;
659
+ return Math.max(0, (cap as number) - (used as number));
660
+ }
661
+
662
+ /** Human bytes, for a message a person reads. */
663
+ function mb(n: number): string {
664
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
665
+ }
666
+ /** HTTP requests issued by THIS pass — the ceiling the hub actually meters. */
667
+ let requestsThisPass = 0;
668
+
669
+ /**
670
+ * Turn a refusal into words, and a 429 into the plane's hold.
671
+ *
672
+ * Every non-ok response on this lane comes through here, so there is one
673
+ * answer to "was that a fault or a wall" rather than one per call site.
674
+ */
675
+ async function refusal(res: Response): Promise<{
676
+ ok: false;
677
+ reason: string;
678
+ rateLimited?: true;
679
+ quotaExhausted?: true;
680
+ quotaResetsAt?: number;
681
+ }> {
682
+ // 507 — the per-token hourly WRITE QUOTA is spent (file-door.mjs
683
+ // `QUOTA_BYTES_PER_WINDOW`, 2 GiB). Not a fault and not a rate limit: the
684
+ // credential is fine, the file is fine, and the only answer is the next
685
+ // window. On an 8.8 GB project this is at least two windows of ordinary
686
+ // waiting, so it needs its own word or a person reads it as broken.
687
+ if (res.status === 507) {
688
+ const resetsAt = hubLimits?.quotaResetsAt;
689
+ return {
690
+ ok: false,
691
+ reason: "This project's upload allowance for the hour is used up",
692
+ quotaExhausted: true,
693
+ ...(Number.isFinite(resetsAt) ? { quotaResetsAt: resetsAt as number } : {}),
694
+ };
695
+ }
696
+ // OUR CREDENTIAL, NOT OUR REQUEST. Ask for a new one — once, through the
697
+ // lane that already has the rate discipline — and end the pass: every
698
+ // remaining request would be refused the same way.
699
+ if (res.status === 401 || res.status === 403) {
700
+ authRefused = true;
701
+ return { ok: false, reason: 'The workspace did not accept this connection' };
702
+ }
703
+ const reason = await failureReason(res);
704
+ // BACKPRESSURE, not just 429 — a cell that is starting answers 503 with a
705
+ // Retry-After, and firing this pass's remaining request budget into that
706
+ // window is how a slow start becomes a failed one.
707
+ if (!isBackpressure(res)) return { ok: false, reason };
708
+ const wait = retryAfterMs(res.headers?.get?.('retry-after') ?? null);
709
+ // The LONGEST wait any refusal this pass asked for. Coming back early is
710
+ // how a hold becomes another storm.
711
+ if (hitRateLimit === null || wait > hitRateLimit.retryAfterMs) {
712
+ hitRateLimit = { retryAfterMs: wait, cause: 'hub-asked' };
713
+ }
714
+ return { ok: false, reason, rateLimited: true };
715
+ }
716
+
360
717
  /** Fetch one file's bytes and verify them against the hash we were promised. */
361
718
  async function fetchVerified(
362
719
  rel: string,
363
720
  expectHash: string,
364
721
  ceiling: number
365
- ): Promise<{ ok: true; bytes: Uint8Array } | { ok: false; reason: string; overCap?: true }> {
722
+ ): Promise<
723
+ | { ok: true; bytes: Uint8Array }
724
+ | { ok: false; reason: string; overCap?: true; rateLimited?: true }
725
+ > {
366
726
  let res: Response;
727
+ requestsThisPass += 1;
367
728
  try {
368
729
  res = await fetchImpl(
369
730
  `${base}/_project-file/${rel.split('/').map(encodeURIComponent).join('/')}`,
370
731
  { headers: auth(), signal: AbortSignal.timeout(GET_TIMEOUT_MS) }
371
732
  );
372
733
  } catch (err) {
373
- return { ok: false, reason: (err as Error).message };
734
+ return { ok: false, reason: transportFailure(err, rel, log) };
374
735
  }
375
- if (!res.ok) return { ok: false, reason: `HTTP ${res.status}` };
736
+ if (!res.ok) return await refusal(res);
376
737
 
377
738
  // The cap is consulted BEFORE the body exists, and again as it arrives.
378
739
  // Buffering first and measuring after is not a cap at all: a hub answering
@@ -442,7 +803,7 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
442
803
  chunks.push(value);
443
804
  }
444
805
  } catch (err) {
445
- return { ok: false, reason: (err as Error).message };
806
+ return { ok: false, reason: transportFailure(err, '<response body>', log) };
446
807
  }
447
808
  const bytes = new Uint8Array(total);
448
809
  let at = 0;
@@ -608,17 +969,57 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
608
969
  ): Promise<
609
970
  | { ok: true; seq: number | null }
610
971
  | { ok: false; conflict: true; current: string | null }
611
- | { ok: false; conflict?: false; reason: string }
972
+ | {
973
+ ok: false;
974
+ conflict?: false;
975
+ reason: string;
976
+ rateLimited?: true;
977
+ tooLarge?: true;
978
+ quotaExhausted?: true;
979
+ quotaResetsAt?: number;
980
+ }
612
981
  > {
982
+ // PRE-FLIGHT AGAINST THE DOOR'S OWN CEILING, before a byte is read.
983
+ //
984
+ // `tooLarge` is TERMINAL: the returned reason is stored and the caller
985
+ // parks the path rather than re-queueing it. A file the door will never
986
+ // accept must stop costing a request slot every pass — that is what the
987
+ // 465.8 MB video did, on every boot, forever.
988
+ // PACE AGAINST THE WINDOW. `quotaSpentThisPass` is what we know we have
989
+ // added since the hub last told us where it stood.
990
+ const headroom = quotaHeadroom();
991
+ if (headroom !== null && quotaSpentThisPass + local.size > headroom) {
992
+ holdForQuota(hubLimits?.quotaResetsAt ?? null);
993
+ return {
994
+ ok: false,
995
+ quotaExhausted: true,
996
+ reason: "This project's upload allowance for the hour is used up",
997
+ ...(hubLimits?.quotaResetsAt ? { quotaResetsAt: hubLimits.quotaResetsAt } : {}),
998
+ };
999
+ }
1000
+ if (local.size > pushCeiling()) {
1001
+ return {
1002
+ ok: false,
1003
+ tooLarge: true,
1004
+ reason: `Too big for this workspace — ${mb(local.size)}, and the limit is ${mb(pushCeiling())}`,
1005
+ };
1006
+ }
613
1007
  let bytes: Uint8Array;
614
1008
  try {
615
1009
  bytes = readFileSync(local.abs);
616
1010
  } catch (err) {
617
- return { ok: false, reason: (err as Error).message };
1011
+ // A LOCAL DISK READ, not a transfer. Classifying it as a transport
1012
+ // failure would tell a person the workspace is unreachable when the file
1013
+ // is simply gone or unreadable — a wrong answer is worse than a vague one.
1014
+ log.warn?.(
1015
+ `[sync/files] ${local.rel}: could not read from disk — ${String((err as Error)?.message ?? err).slice(0, 200)}`
1016
+ );
1017
+ return { ok: false, reason: 'Could not read this file from disk' };
618
1018
  }
619
1019
  // In flight, so a journal row carrying this hash reads as our own echo
620
1020
  // rather than as a remote change we must react to.
621
1021
  ledger.outboxAdd(local.hash);
1022
+ requestsThisPass += 1;
622
1023
  try {
623
1024
  const res = await fetchImpl(
624
1025
  `${base}/api/file/${local.rel.split('/').map(encodeURIComponent).join('/')}`,
@@ -642,11 +1043,11 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
642
1043
  current: typeof body?.current === 'string' ? body.current : null,
643
1044
  };
644
1045
  }
645
- if (!res.ok) return { ok: false, reason: `HTTP ${res.status}` };
1046
+ if (!res.ok) return await refusal(res);
646
1047
  const body = (await res.json().catch(() => ({}))) as { seq?: unknown };
647
1048
  return { ok: true, seq: typeof body?.seq === 'number' ? body.seq : null };
648
1049
  } catch (err) {
649
- return { ok: false, reason: (err as Error).message };
1050
+ return { ok: false, reason: transportFailure(err, local.rel, log) };
650
1051
  } finally {
651
1052
  ledger.outboxDone(local.hash);
652
1053
  }
@@ -665,7 +1066,10 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
665
1066
  async function pushDelete(
666
1067
  rel: string,
667
1068
  expect: string | null
668
- ): Promise<{ ok: true } | { ok: false; conflict: true } | { ok: false; reason: string }> {
1069
+ ): Promise<
1070
+ { ok: true } | { ok: false; conflict: true } | { ok: false; reason: string; rateLimited?: true }
1071
+ > {
1072
+ requestsThisPass += 1;
669
1073
  try {
670
1074
  const res = await fetchImpl(
671
1075
  `${base}/api/file/${rel.split('/').map(encodeURIComponent).join('/')}`,
@@ -676,10 +1080,10 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
676
1080
  }
677
1081
  );
678
1082
  if (res.status === 409) return { ok: false, conflict: true };
679
- if (!res.ok) return { ok: false, reason: `HTTP ${res.status}` };
1083
+ if (!res.ok) return await refusal(res);
680
1084
  return { ok: true };
681
1085
  } catch (err) {
682
- return { ok: false, reason: (err as Error).message };
1086
+ return { ok: false, reason: transportFailure(err, rel, log) };
683
1087
  }
684
1088
  }
685
1089
 
@@ -711,6 +1115,55 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
711
1115
  }
712
1116
  }
713
1117
 
1118
+ /**
1119
+ * Arm the plane-wide hold if this pass met a 429, and say so on the result.
1120
+ *
1121
+ * Called on EVERY exit from a pass, including the early ones — a refusal on
1122
+ * the cursor read is the same wall as a refusal on a file, and the pass that
1123
+ * discovers it is usually the cheap one.
1124
+ */
1125
+ /**
1126
+ * The hourly write quota is spent. Hold the LANE until it resets.
1127
+ *
1128
+ * Distinct from a rate limit even though both pause: a rate limit is the hub
1129
+ * metering request RATE, a quota is a byte budget for a window. On an 8.8 GB
1130
+ * project against a 2 GiB/hour quota this is not an edge case — it is at
1131
+ * least two full windows of ordinary, expected waiting, and a person who is
1132
+ * not told that reads a stalled panel as a broken one.
1133
+ */
1134
+ function holdForQuota(resetsAt: number | null): void {
1135
+ // CLAMPED. `resetsAt` is hub-supplied; unclamped, a peer could name a reset
1136
+ // a year out and stop this machine syncing until someone restarted it.
1137
+ const raw = (resetsAt ?? now() + 3_600_000) - now();
1138
+ const wait = Math.min(MAX_TRUSTED_QUOTA_PAUSE_MS, Math.max(60_000, raw));
1139
+ if (hitRateLimit === null || wait > hitRateLimit.retryAfterMs) {
1140
+ hitRateLimit = { retryAfterMs: wait, cause: 'quota' };
1141
+ }
1142
+ }
1143
+
1144
+ function holdIfRateLimited(out: FilePlaneResult, waiting: number): FilePlaneResult {
1145
+ // Idempotent: the apply loop arms the hold itself (it is the only caller
1146
+ // that knows how many paths were left), and the pass tail then calls this
1147
+ // again as a backstop. Re-arming would push the window out and log twice.
1148
+ if (hitRateLimit === null || out.rateLimited) return out;
1149
+ const wait = hitRateLimit.retryAfterMs;
1150
+ const cause = hitRateLimit.cause;
1151
+ rateLimitedUntil = now() + wait;
1152
+ out.rateLimited = { until: rateLimitedUntil, retryAfterMs: wait, waiting, cause };
1153
+ const why =
1154
+ cause === 'unreachable'
1155
+ ? 'the project could not be reached'
1156
+ : cause === 'quota'
1157
+ ? "this project's upload allowance for the hour is used up"
1158
+ : 'the project is asking this machine to slow down (HTTP 429)';
1159
+ log.warn?.(
1160
+ `[sync/files] ${why}. Pausing the file lane for ${Math.round(wait / 1000)} s${
1161
+ waiting > 0 ? `; ${waiting} file(s) still to come` : ''
1162
+ }. Nothing is lost — they arrive when the pause lifts.`
1163
+ );
1164
+ return out;
1165
+ }
1166
+
714
1167
  async function reconcile(): Promise<FilePlaneResult> {
715
1168
  const out: FilePlaneResult = {
716
1169
  pulled: [],
@@ -723,6 +1176,30 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
723
1176
  reanchored: false,
724
1177
  };
725
1178
 
1179
+ // ── 0. Is the door shut? ─────────────────────────────────────────────
1180
+ //
1181
+ // BEFORE the journal read, because the journal read is a request too, and
1182
+ // it draws on the very bucket we are waiting to refill. A held pass that
1183
+ // still asks the hub for a page is not held.
1184
+ if (now() < rateLimitedUntil) {
1185
+ out.rateLimited = {
1186
+ until: rateLimitedUntil,
1187
+ retryAfterMs: rateLimitedUntil - now(),
1188
+ // The pass that armed the hold counted what it had LEFT; a held pass
1189
+ // has not looked, so it reports what the ledger still calls stuck.
1190
+ // Close enough to be useful, and honest about being a count of rows
1191
+ // rather than a promise about the next pass.
1192
+ waiting: Object.values(ledger.rows()).filter((r) => r.state === 'stuck').length,
1193
+ };
1194
+ return out;
1195
+ }
1196
+ hitRateLimit = null;
1197
+ unreachableInARow = 0;
1198
+ authRefused = false;
1199
+ quotaSpentThisPass = 0;
1200
+ await ensureHubLimits();
1201
+ requestsThisPass = 0;
1202
+
726
1203
  // ── 1. The hub's side ────────────────────────────────────────────────
727
1204
  const startedFrom = ledger.cursor();
728
1205
  let fullRead = startedFrom === 0;
@@ -732,10 +1209,13 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
732
1209
  since: startedFrom,
733
1210
  epoch: ledger.epoch(),
734
1211
  fetchImpl,
1212
+ onRefused: async (res) => {
1213
+ await refusal(res);
1214
+ },
735
1215
  });
736
1216
  // Unreachable / refused / journal-less: this pass does nothing, and the
737
1217
  // next one asks again. NEVER "nothing changed".
738
- if (page === null) return out;
1218
+ if (page === null) return holdIfRateLimited(out, 0);
739
1219
 
740
1220
  // Whether our ANCESTORS still describe the hub's log.
741
1221
  //
@@ -789,8 +1269,11 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
789
1269
  token: opts.token(),
790
1270
  since: 0,
791
1271
  fetchImpl,
1272
+ onRefused: async (res) => {
1273
+ await refusal(res);
1274
+ },
792
1275
  });
793
- if (page === null || page.reanchor) return out;
1276
+ if (page === null || page.reanchor) return holdIfRateLimited(out, 0);
794
1277
  }
795
1278
 
796
1279
  if (!out.reanchored) {
@@ -951,11 +1434,20 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
951
1434
  const hits = work.filter((w) => w.decision.action === action);
952
1435
  if (hits.length === 0 || !overBreaker(direction, hits.length)) continue;
953
1436
  const paths = hits.map((w) => w.rel).sort();
954
- log.warn?.(
955
- direction === 'out'
956
- ? `[sync/files] ${paths.length} of ${tracked} tracked files are gone from this machine — NOT telling the project. If that was a branch switch or a bad restore, nothing is lost; if you meant it, confirm the deletion.`
957
- : `[sync/files] the project wants to remove ${paths.length} of ${tracked} tracked files here — holding. Nothing was deleted.`
958
- );
1437
+ // ONCE PER DISTINCT HOLD, not once per pass.
1438
+ //
1439
+ // The `held` entry reaches the Sync panel on every pass regardless, so
1440
+ // this line is repetition rather than signal — and it repeated 47 times
1441
+ // in a 72-line log, burying every other line a person needed to see.
1442
+ const holdKey = `${direction}:${paths.join('|')}`;
1443
+ const alreadyAnnounced = lastDeleteHoldKey === holdKey;
1444
+ lastDeleteHoldKey = holdKey;
1445
+ if (!alreadyAnnounced)
1446
+ log.warn?.(
1447
+ direction === 'out'
1448
+ ? `[sync/files] ${paths.length} of ${tracked} tracked files are gone from this machine — NOT telling the project. If that was a branch switch or a bad restore, nothing is lost; if you meant it, confirm the deletion.`
1449
+ : `[sync/files] the project wants to remove ${paths.length} of ${tracked} tracked files here — holding. Nothing was deleted.`
1450
+ );
959
1451
  out.deleteHeld = { direction, count: paths.length, paths: paths.slice(0, 200) };
960
1452
  for (const w of hits) {
961
1453
  ledger.setState(w.rel, 'conflict', {
@@ -1017,10 +1509,18 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1017
1509
  });
1018
1510
 
1019
1511
  let moved = 0;
1512
+ let seen = 0;
1020
1513
  for (const item of work) {
1021
- if (moved >= MAX_FILES_PER_PASS) {
1514
+ // TWO CEILINGS, because they bound different things and only one of them
1515
+ // survives a bad day. `moved` bounds what LANDS — disk writes, parked
1516
+ // copies, ledger churn — and it is the one a healthy pass hits. Requests
1517
+ // bound what we ASK, and it is the one that matters when nothing is
1518
+ // landing, which is precisely when the first counter stops moving
1519
+ // (issue #109).
1520
+ if (moved >= MAX_FILES_PER_PASS || requestsThisPass >= MAX_REQUESTS_PER_PASS) {
1521
+ if (requestsThisPass >= MAX_REQUESTS_PER_PASS) out.requestsExhausted = true;
1022
1522
  log.warn?.(
1023
- `[sync/files] ${work.length - moved} more path(s) to reconcile; taking them next pass.`
1523
+ `[sync/files] ${work.length - seen} more path(s) to reconcile; taking them next pass.`
1024
1524
  );
1025
1525
  break;
1026
1526
  }
@@ -1028,9 +1528,63 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1028
1528
  out.budgetExhausted = true;
1029
1529
  break;
1030
1530
  }
1531
+ // A PATH INSIDE ITS BACKOFF WINDOW COSTS NOTHING.
1532
+ //
1533
+ // Checked BEFORE the ceilings deliberately: charging a skipped path
1534
+ // against `MAX_REQUESTS_PER_PASS` would let a handful of permanently
1535
+ // failing paths starve every healthy one — which is how two runs against
1536
+ // an 8.8 GB project moved zero files while sending 616 MB (2026-09-03).
1537
+ if (ledger.isBackedOff(item.rel, now())) {
1538
+ seen += 1;
1539
+ out.backedOff = (out.backedOff ?? 0) + 1;
1540
+ continue;
1541
+ }
1542
+ seen += 1;
1543
+ const before = {
1544
+ failed: out.failed.length,
1545
+ pushed: out.pushed.length,
1546
+ pulled: out.pulled.length,
1547
+ };
1031
1548
  const handled = await applyOne(item, out, budget);
1032
1549
  if (handled) moved += 1;
1550
+ // Arm or clear this path's window from what the pass actually did with
1551
+ // it. A rate limit is excluded: that is the DOOR's state, not this
1552
+ // path's, and punishing the file for it would push a whole project into
1553
+ // backoff for something none of its files did.
1554
+ if (hitRateLimit === null) {
1555
+ if (out.failed.length > before.failed) {
1556
+ const delay = ledger.noteAttemptFailed(item.rel, now());
1557
+ log.warn?.(
1558
+ `[sync/files] ${item.rel}: backing off ${Math.round(delay / 1000)}s before the next attempt`
1559
+ );
1560
+ } else if (
1561
+ out.pushed.length > before.pushed ||
1562
+ out.pulled.length > before.pulled ||
1563
+ handled
1564
+ ) {
1565
+ ledger.noteAttemptOk(item.rel);
1566
+ }
1567
+ }
1568
+ // A RATE LIMIT ENDS THE PASS. Every other refusal is about one file and
1569
+ // the next one is worth trying; this one is about the door, and the
1570
+ // remaining requests would do nothing but hold it shut for longer.
1571
+ if (authRefused) {
1572
+ // ONE notification for the whole pass. `renewCredentialNow()` is
1573
+ // single-flight with a 60 s floor, so 803 refused paths collapse to one
1574
+ // renewal — but there is no reason to call it 803 times to find out.
1575
+ opts.onAuthFailure?.(item.rel, 401);
1576
+ out.authRefused = true;
1577
+ log.warn?.(
1578
+ `[sync/files] the workspace did not accept this connection; asking for a fresh credential and stopping this pass (${work.length - seen} path(s) still to do).`
1579
+ );
1580
+ break;
1581
+ }
1582
+ if (hitRateLimit !== null) {
1583
+ holdIfRateLimited(out, work.length - seen);
1584
+ break;
1585
+ }
1033
1586
  }
1587
+ if (out.pushed.length > 0 || out.pulled.length > 0) opts.onProgress?.();
1034
1588
 
1035
1589
  // ── 5. Position ──────────────────────────────────────────────────────
1036
1590
  //
@@ -1065,7 +1619,10 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1065
1619
  DELETE_BUDGET_WINDOW_MS
1066
1620
  );
1067
1621
 
1068
- return out;
1622
+ // The backstop. The loop arms the hold itself so it can report how many
1623
+ // paths it had left; this catches a 429 met anywhere else in the pass —
1624
+ // and is idempotent, because arming a hold twice is arming it once.
1625
+ return holdIfRateLimited(out, 0);
1069
1626
  }
1070
1627
 
1071
1628
  async function applyOne(
@@ -1130,7 +1687,10 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1130
1687
  });
1131
1688
  out.conflicts.push({ rel, copy: copyRel });
1132
1689
  } catch (err) {
1133
- out.failed.push({ rel, reason: (err as Error).message });
1690
+ log.warn?.(
1691
+ `[sync/files] ${rel}: could not park the conflicting copy — ${String((err as Error)?.message ?? err).slice(0, 200)}`
1692
+ );
1693
+ out.failed.push({ rel, reason: 'Could not write the conflicting copy to disk' });
1134
1694
  }
1135
1695
  }
1136
1696
  return true;
@@ -1188,6 +1748,10 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1188
1748
  mtimeMs: here.mtimeMs,
1189
1749
  state: 'on-hub',
1190
1750
  });
1751
+ // Charge the window. The hub's own figure is authoritative and
1752
+ // refreshes next boot; this is what keeps a single long pass from
1753
+ // overrunning a window it was told the start of.
1754
+ quotaSpentThisPass += here.size;
1191
1755
  out.pushed.push(rel);
1192
1756
  return true;
1193
1757
  }
@@ -1201,6 +1765,27 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1201
1765
  out.conflicts.push({ rel, copy: null });
1202
1766
  return true;
1203
1767
  }
1768
+ // A WALL, NOT A FAILURE — and the two need different answers.
1769
+ //
1770
+ // `tooLarge` will never succeed however long we wait, and `quota` will
1771
+ // never succeed inside this window. Both used to land as an anonymous
1772
+ // `stuck` that the next pass re-attempted at full rate: that is how two
1773
+ // files (164.9 MB + 465.8 MB) spent every boot burning request slots,
1774
+ // and how the panel showed "stuck" for something no retry could fix.
1775
+ // `refused` is terminal for this pass and skipped by the backoff.
1776
+ if (res.tooLarge || res.quotaExhausted) {
1777
+ ledger.setState(rel, 'refused', {
1778
+ reason: res.reason,
1779
+ blockedClass: res.tooLarge ? 'too-large' : 'quota',
1780
+ });
1781
+ out.dropped.push({ rel, reason: res.reason });
1782
+ if (res.quotaExhausted) {
1783
+ // A quota is a WINDOW, so hold the whole lane until it resets
1784
+ // rather than discovering it once per remaining file.
1785
+ holdForQuota(res.quotaResetsAt ?? null);
1786
+ }
1787
+ return true;
1788
+ }
1204
1789
  out.failed.push({ rel, reason: res.reason });
1205
1790
  ledger.setState(rel, 'stuck', { reason: res.reason });
1206
1791
  return false;
@@ -1334,12 +1919,23 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1334
1919
  return {
1335
1920
  reconcile,
1336
1921
  doruceka() {
1922
+ // BOUNDED, ACTIONABLE-FIRST. This used to return one entry per ledger
1923
+ // row — 2 961 keys for a real project — and `status.ts` re-serialized the
1924
+ // whole object into `_sync.json` and every open tab on EVERY status
1925
+ // change. The cap only ever drops the healthy tail, so the row a person
1926
+ // is looking for is the one that survives. Same shape as
1927
+ // `items`/`itemsTruncated`, whose lesson this lane skipped.
1928
+ const all = Object.entries(ledger.rows()).map(
1929
+ ([rel, row]) => [rel, row.state ?? 'local-only'] as const
1930
+ );
1931
+ all.sort((a, b) => deliveryRank(a[1]) - deliveryRank(b[1]));
1337
1932
  const out: Record<string, DeliveryState> = {};
1338
- for (const [rel, row] of Object.entries(ledger.rows())) {
1339
- out[rel] = row.state ?? 'local-only';
1340
- }
1933
+ for (const [rel, state] of all.slice(0, MAX_DORUCEKA_ROWS)) out[rel] = state;
1341
1934
  return out;
1342
1935
  },
1936
+ dorucekaTotal() {
1937
+ return Object.keys(ledger.rows()).length;
1938
+ },
1343
1939
  };
1344
1940
  }
1345
1941