@1agh/maude 1.2.0 → 1.3.1

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 (91) hide show
  1. package/README.md +3 -1
  2. package/apps/studio/acp/index.ts +43 -7
  3. package/apps/studio/annotations-layer.tsx +191 -116
  4. package/apps/studio/annotations-model.ts +39 -0
  5. package/apps/studio/annotations-sync.ts +50 -0
  6. package/apps/studio/api.ts +732 -38
  7. package/apps/studio/bin/annotate.mjs +3 -1
  8. package/apps/studio/bin/server-up.sh +17 -2
  9. package/apps/studio/canvas-build.ts +49 -8
  10. package/apps/studio/canvas-edit.ts +468 -7
  11. package/apps/studio/canvas-lib.tsx +82 -18
  12. package/apps/studio/canvas-list-watch.ts +33 -2
  13. package/apps/studio/canvas-notice-message.ts +16 -0
  14. package/apps/studio/canvas-notifications.tsx +19 -0
  15. package/apps/studio/canvas-shell.tsx +57 -6
  16. package/apps/studio/client/app.jsx +783 -245
  17. package/apps/studio/client/apply-edit-request.ts +55 -0
  18. package/apps/studio/client/canvas-url.js +39 -1
  19. package/apps/studio/client/export-center.jsx +111 -61
  20. package/apps/studio/client/github.js +38 -1
  21. package/apps/studio/client/index-loader.ts +76 -0
  22. package/apps/studio/client/panels/CloudBar.jsx +52 -6
  23. package/apps/studio/client/panels/GitPanel.jsx +103 -0
  24. package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
  25. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
  26. package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
  27. package/apps/studio/client/panels/SyncPanel.jsx +113 -0
  28. package/apps/studio/client/panels/TeamProjects.jsx +376 -0
  29. package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
  30. package/apps/studio/client/photo-knobs.jsx +3 -0
  31. package/apps/studio/client/share-dialog.jsx +91 -0
  32. package/apps/studio/client/share-link.js +94 -0
  33. package/apps/studio/client/styles/3-shell-maude.css +36 -18
  34. package/apps/studio/client/styles/4-components.css +18 -1
  35. package/apps/studio/client/tree-row-menu.jsx +48 -3
  36. package/apps/studio/client/whats-new.jsx +26 -32
  37. package/apps/studio/cloud/endpoints.ts +132 -0
  38. package/apps/studio/collab/awareness-bridge.ts +65 -14
  39. package/apps/studio/collab/index.ts +4 -0
  40. package/apps/studio/collab/persistence.ts +5 -1
  41. package/apps/studio/collab/registry.ts +9 -3
  42. package/apps/studio/collab/room.ts +13 -2
  43. package/apps/studio/context.ts +59 -0
  44. package/apps/studio/dist/client.bundle.js +1594 -1562
  45. package/apps/studio/dist/comment-mount.js +2 -2
  46. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  47. package/apps/studio/dist/runtime/sonner.js +1 -0
  48. package/apps/studio/dist/styles.css +1 -1
  49. package/apps/studio/git/accepted-guard.ts +36 -0
  50. package/apps/studio/hmr-broadcast.ts +34 -1
  51. package/apps/studio/http.ts +300 -18
  52. package/apps/studio/inspect.ts +19 -0
  53. package/apps/studio/managed-projects.ts +172 -0
  54. package/apps/studio/notifications.tsx +292 -0
  55. package/apps/studio/runtime-bundle.ts +2 -0
  56. package/apps/studio/server.ts +59 -3
  57. package/apps/studio/sync/accepted-cold-start.ts +225 -0
  58. package/apps/studio/sync/accepted-link.ts +320 -0
  59. package/apps/studio/sync/action-stage.ts +343 -0
  60. package/apps/studio/sync/agent.ts +12 -60
  61. package/apps/studio/sync/cell-file-events.ts +3 -0
  62. package/apps/studio/sync/codec.ts +73 -6
  63. package/apps/studio/sync/ctl-provider.ts +15 -2
  64. package/apps/studio/sync/document-discovery.ts +81 -0
  65. package/apps/studio/sync/file-membership.ts +10 -2
  66. package/apps/studio/sync/file-plane.ts +309 -26
  67. package/apps/studio/sync/index.ts +1174 -21
  68. package/apps/studio/sync/migrate-seed.ts +110 -19
  69. package/apps/studio/sync/poke.ts +4 -2
  70. package/apps/studio/sync/presentation.ts +315 -2
  71. package/apps/studio/sync/projection.ts +820 -23
  72. package/apps/studio/sync/repeated-module.ts +73 -0
  73. package/apps/studio/sync/revision-barrier.ts +129 -0
  74. package/apps/studio/sync/seed-repair.ts +46 -0
  75. package/apps/studio/sync/source-merge.ts +100 -0
  76. package/apps/studio/sync/source-ops.ts +289 -0
  77. package/apps/studio/sync/source-recovery.ts +70 -0
  78. package/apps/studio/sync/source-validation.ts +56 -0
  79. package/apps/studio/sync/status.ts +83 -1
  80. package/apps/studio/sync/transaction-client.ts +486 -0
  81. package/apps/studio/sync/writer-registry.ts +236 -0
  82. package/apps/studio/text-caret.ts +35 -0
  83. package/apps/studio/undo-hud.tsx +9 -87
  84. package/apps/studio/use-canvas-media-drop.tsx +4 -39
  85. package/apps/studio/use-tool-mode.tsx +44 -0
  86. package/apps/studio/whats-new.json +137 -0
  87. package/cli/lib/harness/codex-runtime.mjs +4 -1
  88. package/package.json +9 -8
  89. package/plugins/design/dependencies.json +3 -3
  90. package/plugins/design/templates/_shell.html +95 -7
  91. package/plugins/flow/dependencies.json +3 -3
@@ -24,6 +24,8 @@ import {
24
24
  readFileSync,
25
25
  realpathSync,
26
26
  renameSync,
27
+ rmdirSync,
28
+ rmSync,
27
29
  writeFileSync,
28
30
  } from 'node:fs';
29
31
  import { readdir } from 'node:fs/promises';
@@ -36,6 +38,8 @@ import { Y_TYPES } from '../collab/persistence.ts';
36
38
  import type { Context, LinkedHub } from '../context.ts';
37
39
  import { createHistory } from '../history.ts';
38
40
  import { SYNTHETIC_FS_DELAY_MS } from '../hmr-broadcast.ts';
41
+ import { acceptedColdStart } from './accepted-cold-start.ts';
42
+ import { type AcceptedLink, createAcceptedLink } from './accepted-link.ts';
39
43
  import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
40
44
  import { isPushableAssetRel, pushAssets } from './asset-push.ts';
41
45
  import { atomicWrite } from './atomic-write.ts';
@@ -43,6 +47,7 @@ import { type CellPairing, resolveCellPairing, sanitizeForLog } from './cell-pai
43
47
  import {
44
48
  canvasPathFromDoc,
45
49
  clearMovedTo,
50
+ laneValueFromFile,
46
51
  movedToFromDoc,
47
52
  stampCanvasPath,
48
53
  stampMovedTo,
@@ -55,7 +60,8 @@ import {
55
60
  import { createCtlProvider } from './ctl-provider.ts';
56
61
  import { createRescanScheduler, diffCanvasSet, type RescanScheduler } from './discovery.ts';
57
62
  import { createDocNameResolver } from './doc-name.ts';
58
- import { createEchoGuard } from './echo-guard.ts';
63
+ import { createDocumentDiscovery } from './document-discovery.ts';
64
+ import { createEchoGuard, hashBytes } from './echo-guard.ts';
59
65
  import { createFileLedger } from './file-ledger.ts';
60
66
  import { createFilePlane, MAX_DORUCEKA_ROWS } from './file-plane.ts';
61
67
  import { type FilePullResult, pullFiles } from './file-pull.ts';
@@ -80,7 +86,9 @@ import {
80
86
  stateDocumentGone,
81
87
  tombstonedSlugs,
82
88
  } from './remote-docs.ts';
89
+ import { createRevisionBarrier } from './revision-barrier.ts';
83
90
  import { computeSeedProgress } from './seed-progress.ts';
91
+ import { replaySourceOp, type SourceOp } from './source-ops.ts';
84
92
  import { createSyncStatusStore, type SyncStatusStore } from './status.ts';
85
93
  import { quarantineCanvas } from './tombstone-apply.ts';
86
94
  import { writeUntrustedMarkers } from './untrusted.ts';
@@ -95,6 +103,25 @@ export interface SyncProvider {
95
103
  * the bridge.
96
104
  */
97
105
  readonly awareness?: Awareness;
106
+ /**
107
+ * Did this transaction origin come off the wire from the hub? Accepted
108
+ * revisions use it as a tripwire: under a fenced (read-only) connection any
109
+ * OTHER origin is a local write the hub will drop — a replica diverging.
110
+ * Optional: a test stub without it is simply not checked.
111
+ */
112
+ isRemoteOrigin?(origin: unknown): boolean;
113
+ /**
114
+ * May a local write to this document reach the hub right now? False before
115
+ * the handshake authenticates and whenever the hub admitted this connection
116
+ * read-only — a write made then is dropped by the hub while the local
117
+ * replica keeps it, which is a divergence nothing would ever repair.
118
+ * Optional: a provider without it is treated as writable.
119
+ */
120
+ isWritable?(): boolean;
121
+ /** The hub authenticated this connection (again) — scope may have changed. */
122
+ onAuthenticated?(cb: (scope: string) => void): () => void;
123
+ /** Out-of-band messages from the hub on this document's socket. */
124
+ onStateless?(cb: (payload: string) => void): () => void;
98
125
  /**
99
126
  * Resolves when the first hub sync handshake completes.
100
127
  *
@@ -125,9 +152,10 @@ export interface SyncProvider {
125
152
  /* ------------------------------------------------- auth-failure classification */
126
153
 
127
154
  /** DDR-102 — rejection classes the runtime distinguishes. `rate-limit` and
128
- * `generic` are transient (provider backoff keeps retrying); `not-authorized`
129
- * and `invalid-token` are permanent (retrying spams the hub bucket — destroy
130
- * the provider and re-probe on a slow timer instead). */
155
+ * `generic` are transient (the runtime re-authenticates the refused document
156
+ * after one hub window, paced and backed off — see `AUTH_TRANSIENT_RETRY_MS`);
157
+ * `not-authorized` and `invalid-token` are permanent (retrying spams the hub
158
+ * bucket — destroy the provider and re-probe on a slow timer instead). */
131
159
  export type AuthFailureClass = 'rate-limit' | 'not-authorized' | 'invalid-token' | 'generic';
132
160
 
133
161
  /** Map a raw hub rejection reason to a class. New hubs send distinct reasons
@@ -148,6 +176,33 @@ export function classifyAuthFailure(raw: string): AuthFailureClass {
148
176
 
149
177
  export const AUTH_WARN_DEBOUNCE_MS = 2_000;
150
178
  export const AUTH_REPROBE_MS = 5 * 60 * 1000;
179
+ /**
180
+ * How long a transiently-refused document (`rate-limit` / `generic`) waits
181
+ * before the runtime asks the hub again — one full hub window, since the
182
+ * refusal itself says "retry in up to 60s".
183
+ *
184
+ * The runtime owns this retry because nothing else will do it. These classes
185
+ * used to be left to "the provider's built-in backoff", which does not exist
186
+ * per document under DDR-102 multiplexing: a provider re-sends its token only
187
+ * when the SHARED socket opens, and a partial refusal never closes a socket
188
+ * that the documents which did authenticate keep busy. 16 of 112 canvases sat
189
+ * `auth-rejected` for the life of the process that way (RCA
190
+ * issue-sync-rate-limited-docs-never-retried, 2026-09-11).
191
+ *
192
+ * Repeat refusals of the same document double this, capped at the re-probe
193
+ * interval; a handshake that lands resets it.
194
+ */
195
+ export const AUTH_TRANSIENT_RETRY_MS = 60_000;
196
+ /** Random spread added to each transient retry, so peers refused in the same
197
+ * burst do not all come back on the window boundary together. */
198
+ export const AUTH_TRANSIENT_JITTER_MS = 15_000;
199
+ /**
200
+ * Most transient re-authentications inside any `AUTH_TRANSIENT_RETRY_MS` span.
201
+ * A quarter of the hub's default valid-token ceiling (600/min): a 16-document
202
+ * retry never notices it, and a 1 000-canvas project cannot turn its own
203
+ * recovery into the next burst (the F1 lesson, applied to this lane).
204
+ */
205
+ export const AUTH_TRANSIENT_BATCH = 150;
151
206
  export const BOOT_SETTLE_TIMEOUT_MS = 15_000;
152
207
  /** F1 — minimum wall-clock between renewal attempts. A burst of rejections
153
208
  * collapses to one renewal (single-flight); the NEXT burst waits this out. */
@@ -276,6 +331,11 @@ export const REMOTE_POLL_SOON_MS = 1_500;
276
331
  * edits arrive in seconds, bounded enough that spam buys almost nothing.
277
332
  */
278
333
  export const POKE_COOLDOWN_MS = REMOTE_POLL_MS / 2;
334
+ /** Accepted revisions: how soon, and how often at most, a poke pulls. */
335
+ export const ACCEPTED_POKE_DELAY_MS = 60;
336
+ /** T16 — how long the last tool write of an AI action gets to reach the stage. */
337
+ export const AI_ACTION_SETTLE_MS = 250;
338
+ export const ACCEPTED_POKE_COOLDOWN_MS = 1_000;
279
339
 
280
340
  /**
281
341
  * How many previously-unknown canvases one listing may land.
@@ -311,13 +371,13 @@ export function validExpiry(raw: unknown, nowMs: number = Date.now()): number |
311
371
 
312
372
  const AUTH_CLASS_HINT: Record<AuthFailureClass, string> = {
313
373
  'rate-limit':
314
- 'boot burst hit the hub rate limit — sync settles as providers back off; if persistent, raise HUB_CONN_RATE_LIMIT on the hub (DDR-102 hubs default to 600/min for valid tokens).',
374
+ 'refused on volume — retrying these in ~60 s, in paced batches (no restart needed). If this repeats every boot, the hub’s HUB_CONN_RATE_LIMIT is below this project’s size (DDR-102 hubs default to 600/min for valid tokens; every canvas authenticates separately).',
315
375
  'not-authorized':
316
376
  "the token's scope does not cover these canvases — mint a hub-wide token (`maude hub token generate --scope '*'` or an admin-UI invite) and re-link. Retries stopped; re-probing in 5 min.",
317
377
  'invalid-token':
318
378
  'the stored token was rejected — re-run `maude design link <url> --token …` on this machine. Retries stopped; re-probing in 5 min.',
319
379
  generic:
320
- 'the hub refused auth without a specific reason (older hub?) — check `maude design status` and the hub logs.',
380
+ 'the hub refused auth without a specific reason (older hub?) — retrying these in ~60 s; if it keeps refusing, check `maude design status` and the hub logs.',
321
381
  };
322
382
 
323
383
  /**
@@ -422,9 +482,120 @@ export interface SyncRuntime {
422
482
  * flag-off) — the caller then proceeds with the plain local move.
423
483
  */
424
484
  retireForMove(fromSlug: string, toRel: string): Promise<boolean>;
485
+ /**
486
+ * Accepted-revisions mode (DDR-241): propose one lane value of a synced
487
+ * canvas that a privileged API route produced (comments, annotations).
488
+ * `null` when the project is not in that mode or this runtime does not carry
489
+ * the canvas — the caller then writes the shared document as before.
490
+ */
491
+ proposeLane?(
492
+ slug: string,
493
+ lane: 'comments' | 'annotations' | 'meta',
494
+ text: string,
495
+ opts?: { baseText?: string; writeId?: string }
496
+ ): Promise<{ status: 'accepted' | 'rejected'; code?: string }> | null;
497
+ /**
498
+ * Accepted-revisions mode: a folder operation as ONE project action. `null`
499
+ * when the project is not in that mode; otherwise the (possibly queued)
500
+ * outcome. Canvases inside are moved/deleted by the hub, not one by one.
501
+ */
502
+ proposeFolder?(
503
+ op:
504
+ | { op: 'dir.create'; path: string }
505
+ | { op: 'dir.delete'; path: string }
506
+ | {
507
+ op: 'dir.move';
508
+ from: string;
509
+ to: string;
510
+ }
511
+ ): Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null;
512
+ /** True while the linked project is in accepted-revisions mode. */
513
+ acceptedMode?(): boolean;
514
+ /** Tripwire count: local writes that reached an accepted replica. */
515
+ acceptedWriteViolations?(): number;
516
+ /** Accepted revisions: the project's logical history (T27). Null when legacy. */
517
+ acceptedHistory?(q: {
518
+ limit?: number;
519
+ before?: number | null;
520
+ path?: string | null;
521
+ }): Promise<AcceptedHistoryRow[] | null>;
522
+ /** A canvas source (repo-relative path) as it stood at a revision. */
523
+ acceptedVersion?(repoRel: string, revision: number): Promise<string | null>;
524
+ /** Restore a canvas to a revision as a NEW action. */
525
+ acceptedRestore?(
526
+ repoRel: string,
527
+ revision: number
528
+ ): Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean } | null>;
529
+ /** Personal undo/redo of one of this actor's accepted actions (T28). */
530
+ acceptedUndo?(
531
+ actionId: string,
532
+ redo?: boolean
533
+ ): Promise<{
534
+ status: 'accepted' | 'rejected';
535
+ code?: string;
536
+ queued?: boolean;
537
+ actionId?: string;
538
+ } | null>;
539
+ /**
540
+ * T16 — an AI action begins: file changes tools make until it ends are
541
+ * published together as one action (see action-stage.ts). `key` names the
542
+ * participant (an agent turn, a /design:edit run).
543
+ */
544
+ beginAiAction?(key: string, label: string): void;
545
+ /** T16 — `done` publishes (when the last participant ends); `failed` holds. */
546
+ endAiAction?(
547
+ key: string,
548
+ outcome: 'done' | 'failed'
549
+ ): Promise<{ status: 'accepted' | 'rejected'; code?: string } | null>;
550
+ /**
551
+ * T19 — make the whole project available offline now: file-plane passes
552
+ * back to back (no pacing floor) until nothing is left to pull, or a pass
553
+ * stops making progress. Resolves with what came down and what could not.
554
+ */
555
+ prepareOffline?(): Promise<{
556
+ complete: boolean;
557
+ pulled: number;
558
+ passes: number;
559
+ failed: number;
560
+ } | null>;
561
+ /** T26 — the accepted action that carried this content of a canvas, if ours. */
562
+ acceptedActionForContent?(repoRel: string, content: string): string | null;
563
+ /** T28 — the two sides of a canvas's held source conflict. */
564
+ conflictVersions?(repoRel: string): { slug: string; mine: string | null; theirs: string } | null;
565
+ /**
566
+ * T28 — resolve it: `mine` proposes the local version on top of the
567
+ * project's (a new action), `theirs` takes the project's version.
568
+ */
569
+ resolveConflict?(
570
+ repoRel: string,
571
+ choice: 'mine' | 'theirs'
572
+ ): Promise<{ status: 'accepted' | 'rejected' | 'taken'; code?: string } | null>;
573
+ /** T16 — the person's decision on a held (unfinished) AI action. */
574
+ resolveAiAction?(choice: 'publish' | 'discard'): Promise<{
575
+ status: 'accepted' | 'rejected' | 'discarded';
576
+ code?: string;
577
+ canvases?: number;
578
+ } | null>;
579
+ }
580
+
581
+ export interface AcceptedHistoryRow {
582
+ revision: number;
583
+ actionId: string;
584
+ actor: string;
585
+ mine: boolean;
586
+ kind: string;
587
+ label: string | null;
588
+ committedAt: number;
589
+ undoes: string | null;
590
+ /** Design-root-relative canvas paths the action touched. */
591
+ canvases: string[];
425
592
  }
426
593
 
427
594
  export interface CreateSyncRuntimeOptions {
595
+ /** Transaction transport override (tests) — defaults to global fetch. */
596
+ transactionFetch?: typeof fetch;
597
+ /** Base backoff between re-sends of an unanswered proposal (tests). */
598
+ transactionRetryMs?: number;
428
599
  /** Override the HocuspocusProvider factory (test injection). */
429
600
  providerFactory?: ProviderFactory;
430
601
  /** Force-enable/disable adopt mode (overrides cfg.linkedHub.adopt). */
@@ -490,6 +661,14 @@ export interface CreateSyncRuntimeOptions {
490
661
  renewMinIntervalMs?: number;
491
662
  /** F1 — consecutive no-progress renewals before giving up. Default RENEW_MAX_WITHOUT_PROGRESS. */
492
663
  renewMaxWithoutProgress?: number;
664
+ /** Base wait before a transient refusal is retried. Default AUTH_TRANSIENT_RETRY_MS. */
665
+ transientRetryMs?: number;
666
+ /** Max random spread per transient retry. Default AUTH_TRANSIENT_JITTER_MS. */
667
+ transientJitterMs?: number;
668
+ /** Transient re-auths allowed per `transientRetryMs` span. Default AUTH_TRANSIENT_BATCH. */
669
+ transientBatch?: number;
670
+ /** Jitter source (test injection). Default Math.random. */
671
+ random?: () => number;
493
672
  };
494
673
  }
495
674
 
@@ -728,8 +907,67 @@ export function createSyncRuntime(
728
907
  // registry can hand us the canvas's single doc. Flag OFF / no registry / a
729
908
  // minimal test registry without getDoc → the proven two-doc path, unchanged.
730
909
  const useSharedDoc = !!ctx.sharedDoc && typeof opts.registry?.getDoc === 'function';
910
+ // ---- ACCEPTED REVISIONS (DDR-241) ----------------------------------------
911
+ //
912
+ // Asked of the hub at start and on every poll — never assumed. In
913
+ // `transactions` mode this runtime writes NO shared document: every local
914
+ // change is a proposal through the durable outbox, and the documents change
915
+ // when the project publishes the accepted revision. Shared-doc only: the
916
+ // two-doc agent path has no proposal lane and stays legacy.
917
+ const acceptedLink: AcceptedLink | null = useSharedDoc
918
+ ? createAcceptedLink({
919
+ hubUrl: linkedHub.url,
920
+ token: () => token,
921
+ designRoot: ctx.paths.designRoot,
922
+ docNameFor: (slug) => docNameFor(slug),
923
+ fetchImpl: opts.transactionFetch,
924
+ retryMs: opts.transactionRetryMs,
925
+ onStats: (stats) => statusStore?.updateAccepted?.(stats),
926
+ onStage: (summary) => statusStore?.updateAiAction?.(summary),
927
+ })
928
+ : null;
929
+ const acceptedOn = (): boolean => acceptedLink?.on() === true;
930
+ /**
931
+ * T26 — which accepted action carried a given canvas content of ours, so the
932
+ * shell's Cmd+Z of an edit can undo THAT action (effect-aware) instead of
933
+ * swapping the whole file back. Bounded; keyed by slug + lane + content hash.
934
+ */
935
+ const acceptedByContent = new Map<string, string>();
936
+ const noteAcceptedContent = (slug: string, lane: string, value: string, actionId: string) => {
937
+ acceptedByContent.set(`${slug}|${lane}|${hashBytes(value)}`, actionId);
938
+ if (acceptedByContent.size > 500) {
939
+ const oldest = acceptedByContent.keys().next().value;
940
+ if (oldest !== undefined) acceptedByContent.delete(oldest);
941
+ }
942
+ };
943
+ /** T14 — one barrier for every projection: a revision shows whole. */
944
+ const revisionBarrier = createRevisionBarrier();
945
+ /** Canvases a folder action already moved/deleted — nothing more to propose for them. */
946
+ const coveredByFolderAction = new Set<string>();
947
+ /** Folder entries this peer has materialized from the project manifest. */
948
+ const knownProjectDirs = new Set<string>();
949
+ /** Local writes that reached an accepted replica (tripwire; should stay 0). */
950
+ let acceptedWriteViolations = 0;
951
+ /** The synced canvas at a repo-relative (or design-root-relative) path. */
952
+ const slugForRepoRel = (rel: string): string | null => {
953
+ const clean = String(rel).replace(/\\/g, '/').replace(/^\/+/, '');
954
+ for (const abs of [
955
+ path.join(ctx.paths.repoRoot, clean),
956
+ path.join(ctx.paths.designRoot, clean),
957
+ ]) {
958
+ for (const [slug, d] of descriptors) if (d.html === abs) return slug;
959
+ }
960
+ return null;
961
+ };
962
+ const describeOrigin = (origin: unknown): string => {
963
+ if (origin === null || origin === undefined) return String(origin);
964
+ if (typeof origin === 'string') return origin.slice(0, 40);
965
+ if (typeof origin === 'object') return (origin as object).constructor?.name ?? 'object';
966
+ return typeof origin;
967
+ };
731
968
  let fsReader: FsReader | null = null;
732
969
  let busUnsub: (() => void) | null = null;
970
+ const activityUnsubs: Array<() => void> = [];
733
971
  let started = false;
734
972
  let stopped = false;
735
973
  // ---- THE LEGACY PUSH CLIENT (journal-less hubs only) --------------------
@@ -878,6 +1116,39 @@ export function createSyncRuntime(
878
1116
  string,
879
1117
  { canvas: CanvasDescriptor; canvasPaths: import('./agent.ts').CanvasSyncPaths; doc: Y.Doc }
880
1118
  >();
1119
+ /**
1120
+ * Transiently-refused docs (`rate-limit` / `generic`) awaiting the paced
1121
+ * retry — see `AUTH_TRANSIENT_RETRY_MS`. Unlike `rejectedPermanent` the
1122
+ * provider is KEPT until the retry swaps it (a refused provider sends nothing
1123
+ * more, so there is no storm to stop), and the retry acts only if `provider`
1124
+ * is still the live one: a release or another recovery path that replaced it
1125
+ * in the meantime owns the document now. `dueAt` is on the `renewNow` clock.
1126
+ */
1127
+ const rejectedTransient = new Map<
1128
+ string,
1129
+ {
1130
+ canvas: CanvasDescriptor;
1131
+ canvasPaths: import('./agent.ts').CanvasSyncPaths;
1132
+ doc: Y.Doc;
1133
+ provider: SyncProvider;
1134
+ dueAt: number;
1135
+ }
1136
+ >();
1137
+ /** Consecutive transient refusals per slug — the backoff exponent. Emptied
1138
+ * by `clearRejection`, i.e. by a handshake the hub completed. */
1139
+ const transientStrikes = new Map<string, number>();
1140
+ /** When each transient re-auth of the last window went out — the sliding
1141
+ * budget. Never longer than `transientBatch`. */
1142
+ const transientSpent: number[] = [];
1143
+ let transientRetryTimer: TimerHandle | null = null;
1144
+ /**
1145
+ * First-connect setup still OWED to a pulled canvas whose first handshake
1146
+ * has not landed (`connectCanvas` defers it — the body path is unknown until
1147
+ * the document arrives). Every reconnect passes it back in: without it a
1148
+ * refusal at that first handshake stranded the setup, and the hub's eventual
1149
+ * yes synced the document into memory while nothing ever wrote it to disk.
1150
+ */
1151
+ const owedSetups = new Map<string, (provider: SyncProvider) => void>();
881
1152
  let authWarnTimer: TimerHandle | null = null;
882
1153
  let reprobeTimer: TimerHandle | null = null;
883
1154
  let renewTimer: TimerHandle | null = null;
@@ -894,6 +1165,10 @@ export function createSyncRuntime(
894
1165
  const renewNow = opts.auth?.now ?? (() => Date.now());
895
1166
  const renewMinIntervalMs = opts.auth?.renewMinIntervalMs ?? RENEW_MIN_INTERVAL_MS;
896
1167
  const renewMaxWithoutProgress = opts.auth?.renewMaxWithoutProgress ?? RENEW_MAX_WITHOUT_PROGRESS;
1168
+ const transientRetryMs = opts.auth?.transientRetryMs ?? AUTH_TRANSIENT_RETRY_MS;
1169
+ const transientJitterMs = opts.auth?.transientJitterMs ?? AUTH_TRANSIENT_JITTER_MS;
1170
+ const transientBatch = Math.max(1, opts.auth?.transientBatch ?? AUTH_TRANSIENT_BATCH);
1171
+ const authRandom = opts.auth?.random ?? Math.random;
897
1172
  /** Wall-clock of the last renewal attempt (0 = never). The floor. */
898
1173
  let lastRenewAt = 0;
899
1174
  /** Successful renewals since the last completed handshake. The cap: a
@@ -947,6 +1222,9 @@ export function createSyncRuntime(
947
1222
  /** The outbound delete-lane subscriptions — see `noteToHub`. */
948
1223
  let deletedUnsub: (() => void) | null = null;
949
1224
  let createdUnsub: (() => void) | null = null;
1225
+ /** Folders made outside the app while running — see `proposeLocalFolders`. */
1226
+ let foldersUnsub: (() => void) | null = null;
1227
+ let foldersTimer: ReturnType<typeof setTimeout> | null = null;
950
1228
  /** Periodic remote-document poll — the hub-side half of discovery. */
951
1229
  let remotePollTimer: ReturnType<typeof setInterval> | null = null;
952
1230
  /** The stall watchdog's tick — see `stallCheck` in `start()`. */
@@ -1002,6 +1280,8 @@ export function createSyncRuntime(
1002
1280
  let pokesSeen = 0;
1003
1281
  /** Assigned by `start()`; the seam `pullRemoteNow()` and tests reach. */
1004
1282
  let remotePull: (() => Promise<void>) | null = null;
1283
+ let documentDiscovery: ReturnType<typeof createDocumentDiscovery> | null = null;
1284
+ let documentDiscoveryUnsub: (() => void) | null = null;
1005
1285
  /**
1006
1286
  * Run a file-plane pass shortly, coalesced.
1007
1287
  *
@@ -1035,8 +1315,18 @@ export function createSyncRuntime(
1035
1315
  * work), and passes are floored `MIN_PASS_INTERVAL_MS` apart.
1036
1316
  */
1037
1317
  let planePassInFlight = false;
1318
+ /**
1319
+ * News that arrived WHILE a pass ran. The running pass may already have read
1320
+ * the disk and the hub's listing, so it cannot carry a file written — or a
1321
+ * row a poke announced — after that point; absorbing the request silently
1322
+ * parked such a file until the next 20 s tick (plan T17/L03: a new image
1323
+ * took 15–20 s to reach a teammate while an edit took one). One more pass,
1324
+ * floored like any other, answers it.
1325
+ */
1326
+ let planePassAgain = false;
1038
1327
  let lastPlanePassAt = 0;
1039
1328
  let planePassWaiter: Promise<void> | null = null;
1329
+ let lastPlaneResult: import('./file-plane.ts').FilePlaneResult | null = null;
1040
1330
  async function runPlanePass(opts?: { floor?: boolean }): Promise<void> {
1041
1331
  // Captured, not re-read: `stop()` clears `filePlane`, and a pass that has
1042
1332
  // already decided to run must not dereference the field it was cleared to.
@@ -1045,7 +1335,10 @@ export function createSyncRuntime(
1045
1335
  // A pass already running IS this pass: it reads the same disk and the same
1046
1336
  // cursor, so the caller waits for its answer rather than racing it. The
1047
1337
  // poll's `await` therefore still means "a pass has happened".
1048
- if (planePassInFlight) return await (planePassWaiter ?? Promise.resolve());
1338
+ if (planePassInFlight) {
1339
+ planePassAgain = true;
1340
+ return await (planePassWaiter ?? Promise.resolve());
1341
+ }
1049
1342
  const since = Date.now() - lastPlanePassAt;
1050
1343
  // The floor governs POKES. The 20 s poll and the explicit `pullRemoteNow`
1051
1344
  // seam are already bounded by their own callers, and silently deferring a
@@ -1061,12 +1354,17 @@ export function createSyncRuntime(
1061
1354
  const run = (async () => {
1062
1355
  try {
1063
1356
  const r = await lane.reconcile();
1357
+ lastPlaneResult = r;
1064
1358
  planeResultSink?.(r);
1065
1359
  } catch (err) {
1066
1360
  console.error('[sync/files] pass failed:', err);
1067
1361
  } finally {
1068
1362
  planePassInFlight = false;
1069
1363
  planePassWaiter = null;
1364
+ if (planePassAgain) {
1365
+ planePassAgain = false;
1366
+ schedulePlanePass();
1367
+ }
1070
1368
  }
1071
1369
  })();
1072
1370
  planePassWaiter = run;
@@ -1106,6 +1404,23 @@ export function createSyncRuntime(
1106
1404
  */
1107
1405
  function pollRemoteSoon(opts: { cooled?: boolean } = {}): void {
1108
1406
  if (stopped || remotePollSoonTimer !== null) return;
1407
+ // ACCEPTED REVISIONS: a poke means "a revision was accepted", and the
1408
+ // canvas it created, moved or deleted — or the folder — must reach this
1409
+ // peer now, not on the next 20 s tick. Still bounded (one pass per
1410
+ // second, with a trailing pass so the last poke of a burst is honoured),
1411
+ // so a hub that pokes in a loop costs one cheap request a second.
1412
+ if (opts.cooled && acceptedOn()) {
1413
+ const since = Date.now() - lastPokePassAt;
1414
+ const wait = Math.max(ACCEPTED_POKE_DELAY_MS, ACCEPTED_POKE_COOLDOWN_MS - since);
1415
+ remotePollSoonTimer = setTimeout(() => {
1416
+ remotePollSoonTimer = null;
1417
+ if (stopped) return;
1418
+ lastPokePassAt = Date.now();
1419
+ void remotePull?.().catch(() => {});
1420
+ }, wait);
1421
+ remotePollSoonTimer.unref?.();
1422
+ return;
1423
+ }
1109
1424
  if (opts.cooled) {
1110
1425
  const since = Date.now() - lastPokePassAt;
1111
1426
  if (since < POKE_COOLDOWN_MS) {
@@ -1383,7 +1698,17 @@ export function createSyncRuntime(
1383
1698
  console.log(
1384
1699
  `[sync/${slug}] canvas was moved on another machine — stale local copy parked in _trash/ (recoverable).`
1385
1700
  );
1386
- ctx.bus.emit('canvas-list-update');
1701
+ }
1702
+ // The destination is materialized and the old body is gone. Receivers
1703
+ // need the semantic move to retarget their open canvas, even if a local
1704
+ // watcher already noticed the two filesystem changes.
1705
+ if (movedTo && !existsSync(desc.html)) {
1706
+ ctx.bus.emit('canvas-list-update', {
1707
+ action: 'moved',
1708
+ fromRel: path.relative(ctx.paths.designRoot, desc.html).split(path.sep).join('/'),
1709
+ rel: movedTo.replace(/\\/g, '/'),
1710
+ fromSlug: slug,
1711
+ });
1387
1712
  }
1388
1713
  } catch (err) {
1389
1714
  // Quarantine is best-effort: the doc guards already made the file
@@ -1452,6 +1777,9 @@ export function createSyncRuntime(
1452
1777
  // payload would be a permanent "still syncing" the user can never clear.
1453
1778
  monitor?.forgetDoc(slug);
1454
1779
  rejectedPermanent.delete(slug);
1780
+ rejectedTransient.delete(slug);
1781
+ transientStrikes.delete(slug);
1782
+ owedSetups.delete(slug);
1455
1783
  rejectedReasons.delete(slug);
1456
1784
  return true;
1457
1785
  }
@@ -1468,6 +1796,28 @@ export function createSyncRuntime(
1468
1796
  async function retireForMove(fromSlug: string, toRel: string): Promise<boolean> {
1469
1797
  const provider = providers.get(fromSlug);
1470
1798
  if (!provider) return false;
1799
+ if (acceptedOn() && acceptedLink) {
1800
+ // The move is a PROJECT action: the hub retires the old document (with
1801
+ // the same `movedTo` stamp receivers already follow) and opens the new
1802
+ // one with the same content. Answered or queued, the local rename then
1803
+ // proceeds; a refusal (path taken, outside every group) keeps it.
1804
+ movingLocally.add(fromSlug);
1805
+ try {
1806
+ if (!coveredByFolderAction.delete(fromSlug)) {
1807
+ const r = await acceptedLink.moveDoc(fromSlug, toRel);
1808
+ if (r.status === 'rejected') {
1809
+ console.warn(`[sync/${fromSlug}] the project did not accept the move (${r.code})`);
1810
+ return false;
1811
+ }
1812
+ }
1813
+ retiredDocs.add(fromSlug);
1814
+ await releaseOne(fromSlug);
1815
+ console.log(`[sync/${fromSlug}] moved in the project → ${toRel}`);
1816
+ return true;
1817
+ } finally {
1818
+ movingLocally.delete(fromSlug);
1819
+ }
1820
+ }
1471
1821
  movingLocally.add(fromSlug);
1472
1822
  try {
1473
1823
  stampMovedTo(provider.document, toRel, ORIGINS.DISK_PROJECTION);
@@ -1493,6 +1843,292 @@ export function createSyncRuntime(
1493
1843
  }
1494
1844
  }
1495
1845
 
1846
+ // ---- project folders (accepted revisions) --------------------------------
1847
+ //
1848
+ // A folder is a manifest entry, so an EMPTY folder a peer created exists in
1849
+ // the project and appears here — no placeholder canvas. The hub is
1850
+ // untrusted (DDR-054): every path is re-checked against the declared canvas
1851
+ // groups and the real design root before a directory is made, and removal
1852
+ // only ever takes a folder that holds nothing but its `.gitkeep`.
1853
+ const projectDirsFile = path.join(ctx.paths.designRoot, '_state', 'project-dirs.json');
1854
+ try {
1855
+ const saved = JSON.parse(readFileSync(projectDirsFile, 'utf8')) as unknown;
1856
+ if (Array.isArray(saved))
1857
+ for (const d of saved) if (typeof d === 'string') knownProjectDirs.add(d);
1858
+ } catch {
1859
+ /* first run — nothing materialized yet */
1860
+ }
1861
+ const saveProjectDirs = (): void => {
1862
+ try {
1863
+ mkdirSync(path.dirname(projectDirsFile), { recursive: true });
1864
+ writeFileSync(projectDirsFile, JSON.stringify([...knownProjectDirs].sort()));
1865
+ } catch {
1866
+ /* best-effort — a lost file only delays one removal */
1867
+ }
1868
+ };
1869
+ const projectDirAbs = (rel: string): string | null => {
1870
+ if (typeof rel !== 'string' || !rel || rel.length > 512) return null;
1871
+ const parts = rel.split('/');
1872
+ if (parts.length > 16) return null;
1873
+ for (const part of parts) {
1874
+ if (!part || part === '.' || part === '..' || part.startsWith('_') || part.startsWith('.'))
1875
+ return null;
1876
+ if (/[\\\0:*?"<>|]/.test(part) || part.length > 255) return null;
1877
+ }
1878
+ const groups = (ctx.cfg.canvasGroups ?? []).map((g) => g.path.replace(/^\/+|\/+$/g, ''));
1879
+ if (!groups.some((g) => g && (rel === g || rel.startsWith(`${g}/`)))) return null;
1880
+ const abs = path.resolve(ctx.paths.designRoot, ...parts);
1881
+ const root = path.resolve(ctx.paths.designRoot);
1882
+ if (!abs.startsWith(`${root}${path.sep}`)) return null;
1883
+ const real = realpathOfDeepestExisting(abs);
1884
+ const realRoot = realpathOfDeepestExisting(root);
1885
+ if (real !== realRoot && !real.startsWith(`${realRoot}${path.sep}`)) return null;
1886
+ return abs;
1887
+ };
1888
+ const designRelPosix = ctx.paths.designRel.replace(/^\.\//, '').replace(/^\/+|\/+$/g, '');
1889
+ /**
1890
+ * A folder the project removed or moved away is taken off this disk only
1891
+ * once it is empty — but the canvases inside leave AFTER the pull that
1892
+ * learned of the action (their moves and deletions land through their own
1893
+ * documents a beat later). Waiting for the next poke or 20 s tick left the
1894
+ * old folder in a teammate's tree for up to 20 s (plan T31/L02). So a
1895
+ * deferred removal asks for another pass shortly, a bounded number of times.
1896
+ */
1897
+ let dirsRetryTimer: ReturnType<typeof setTimeout> | null = null;
1898
+ let dirsRetries = 0;
1899
+ const DIRS_RETRY_MS = 1_500;
1900
+ const DIRS_RETRY_MAX = 12;
1901
+ function retryDeferredDirs(): void {
1902
+ if (stopped || dirsRetryTimer !== null || dirsRetries >= DIRS_RETRY_MAX) return;
1903
+ dirsRetries += 1;
1904
+ dirsRetryTimer = setTimeout(() => {
1905
+ dirsRetryTimer = null;
1906
+ if (stopped || !acceptedOn()) return;
1907
+ if (documentDiscovery) documentDiscovery.schedule();
1908
+ else applyProjectDirs(acceptedLink?.manifest?.dirs ?? []);
1909
+ }, DIRS_RETRY_MS);
1910
+ dirsRetryTimer.unref?.();
1911
+ }
1912
+ function applyProjectDirs(dirs: readonly string[]): void {
1913
+ const next = new Set(dirs);
1914
+ let changed = false;
1915
+ let deferred = 0;
1916
+ for (const rel of next) {
1917
+ if (knownProjectDirs.has(rel)) continue;
1918
+ const abs = projectDirAbs(rel);
1919
+ if (!abs) continue;
1920
+ try {
1921
+ if (!existsSync(abs)) {
1922
+ mkdirSync(abs, { recursive: true });
1923
+ writeFileSync(path.join(abs, '.gitkeep'), '');
1924
+ ctx.bus.emit('canvas-list-update', {
1925
+ action: 'mkdir',
1926
+ dir: path.posix.join(designRelPosix, rel),
1927
+ });
1928
+ }
1929
+ knownProjectDirs.add(rel);
1930
+ changed = true;
1931
+ } catch (err) {
1932
+ console.warn(`[sync] could not create project folder ${rel}:`, err);
1933
+ }
1934
+ }
1935
+ // Deepest first, so a removed parent is attempted after its children.
1936
+ const gone = [...knownProjectDirs]
1937
+ .filter((d) => !next.has(d))
1938
+ .sort((a, b) => b.length - a.length);
1939
+ for (const rel of gone) {
1940
+ const abs = projectDirAbs(rel);
1941
+ if (abs && existsSync(abs)) {
1942
+ let entries: string[] = [];
1943
+ try {
1944
+ entries = readdirSync(abs).filter((e) => e !== '.DS_Store');
1945
+ } catch {
1946
+ continue;
1947
+ }
1948
+ // Canvases inside leave through their own deletion/move first; until
1949
+ // then the folder stays and a pass shortly tries again.
1950
+ if (entries.some((e) => e !== '.gitkeep')) {
1951
+ deferred += 1;
1952
+ continue;
1953
+ }
1954
+ try {
1955
+ for (const e of readdirSync(abs)) rmSync(path.join(abs, e), { force: true });
1956
+ rmdirSync(abs);
1957
+ ctx.bus.emit('canvas-list-update', {
1958
+ action: 'removed-folder',
1959
+ dir: path.posix.join(designRelPosix, rel),
1960
+ });
1961
+ } catch {
1962
+ continue;
1963
+ }
1964
+ }
1965
+ knownProjectDirs.delete(rel);
1966
+ changed = true;
1967
+ }
1968
+ if (changed) saveProjectDirs();
1969
+ if (deferred > 0) retryDeferredDirs();
1970
+ else dirsRetries = 0;
1971
+ }
1972
+
1973
+ /**
1974
+ * The document listing is the hub's STORAGE view: a row appears after
1975
+ * Hocuspocus persists the document, seconds after it exists. Under accepted
1976
+ * revisions the manifest is the project's own statement of what exists, so a
1977
+ * canvas accepted a moment ago is pulled on the next poll, not the one after
1978
+ * its row is written.
1979
+ */
1980
+ function withAcceptedDocs(
1981
+ listing: Awaited<ReturnType<typeof fetchRemoteListing>>
1982
+ ): Awaited<ReturnType<typeof fetchRemoteListing>> {
1983
+ const manifest = acceptedOn() ? acceptedLink?.manifest : null;
1984
+ if (!manifest) return listing;
1985
+ // A document the project RETIRED (moved away) is not a canvas to fetch,
1986
+ // even while its storage row lingers — its successor is in the manifest.
1987
+ const retired = new Set(manifest.docs.filter((d) => d.retired).map((d) => d.doc));
1988
+ const documents = (listing?.documents ?? []).filter((d) => !retired.has(d.name));
1989
+ const names = new Set(documents.map((d) => d.name));
1990
+ for (const d of manifest.docs) {
1991
+ if (d.retired || names.has(d.doc)) continue;
1992
+ documents.push({ name: d.doc, bytes: 1 });
1993
+ names.add(d.doc);
1994
+ }
1995
+ return { ...(listing ?? { tombstones: [] }), documents, tombstones: listing?.tombstones ?? [] };
1996
+ }
1997
+
1998
+ /**
1999
+ * Re-ask the save mode, and hand every held write back to its projection —
2000
+ * as a proposal when the project turned out to be in transactions mode, as
2001
+ * an ordinary import when the connection simply became writable.
2002
+ */
2003
+ let modeRefresh: Promise<void> | null = null;
2004
+ function refreshAcceptedMode(): Promise<void> {
2005
+ if (!acceptedLink) return Promise.resolve();
2006
+ if (modeRefresh) return modeRefresh;
2007
+ modeRefresh = acceptedLink
2008
+ .refresh()
2009
+ .then(() => {
2010
+ if (acceptedOn()) applyProjectDirs(acceptedLink.manifest?.dirs ?? []);
2011
+ for (const p of projections.values()) p.retryDeferred();
2012
+ })
2013
+ .finally(() => {
2014
+ modeRefresh = null;
2015
+ });
2016
+ return modeRefresh;
2017
+ }
2018
+
2019
+ /**
2020
+ * A folder the Maude UI made on this machine (it carries the `.gitkeep`
2021
+ * `createFolder` writes) that the project does not list yet — made while
2022
+ * the project was legacy, or while this machine was offline. It joins the
2023
+ * project as ONE action at cold start. Only additive: a folder the manifest
2024
+ * lists and this disk lacks is never proposed as a deletion from here.
2025
+ */
2026
+ let folderProposal: Promise<void> | null = null;
2027
+ let folderProposalAgain = false;
2028
+ function proposeLocalFolders(): void {
2029
+ if (!acceptedLink) return;
2030
+ // One proposal at a time: a second walk while the first is unanswered
2031
+ // would offer the same folders twice.
2032
+ if (folderProposal) {
2033
+ folderProposalAgain = true;
2034
+ return;
2035
+ }
2036
+ const listed = new Set([...(acceptedLink.manifest?.dirs ?? []), ...knownProjectDirs]);
2037
+ const found: string[] = [];
2038
+ const walk = (abs: string, rel: string, depth: number) => {
2039
+ if (depth > 16 || found.length >= 200) return;
2040
+ let entries: import('node:fs').Dirent[];
2041
+ try {
2042
+ entries = readdirSync(abs, { withFileTypes: true });
2043
+ } catch {
2044
+ return;
2045
+ }
2046
+ for (const e of entries) {
2047
+ if (!e.isDirectory() || e.name.startsWith('_') || e.name.startsWith('.')) continue;
2048
+ const childRel = `${rel}/${e.name}`;
2049
+ const childAbs = path.join(abs, e.name);
2050
+ if (
2051
+ !listed.has(childRel) &&
2052
+ existsSync(path.join(childAbs, '.gitkeep')) &&
2053
+ projectDirAbs(childRel)
2054
+ ) {
2055
+ found.push(childRel);
2056
+ }
2057
+ walk(childAbs, childRel, depth + 1);
2058
+ }
2059
+ };
2060
+ for (const g of ctx.cfg.canvasGroups ?? []) {
2061
+ const rel = g.path.replace(/^\/+|\/+$/g, '');
2062
+ if (rel) walk(path.join(ctx.paths.designRoot, ...rel.split('/')), rel, 1);
2063
+ }
2064
+ if (found.length === 0) return;
2065
+ folderProposal = acceptedLink
2066
+ .dirsCreate(found)
2067
+ .then((r) => {
2068
+ if (r.status === 'accepted') {
2069
+ for (const d of found) knownProjectDirs.add(d);
2070
+ saveProjectDirs();
2071
+ console.log(`[sync] added ${found.length} local folder(s) to the project.`);
2072
+ }
2073
+ })
2074
+ .catch(() => {
2075
+ /* the next folder event or cold start offers them again */
2076
+ })
2077
+ .finally(() => {
2078
+ folderProposal = null;
2079
+ if (folderProposalAgain) {
2080
+ folderProposalAgain = false;
2081
+ proposeLocalFolders();
2082
+ }
2083
+ });
2084
+ }
2085
+
2086
+ function proposeFolder(
2087
+ op:
2088
+ | { op: 'dir.create'; path: string }
2089
+ | { op: 'dir.delete'; path: string }
2090
+ | {
2091
+ op: 'dir.move';
2092
+ from: string;
2093
+ to: string;
2094
+ }
2095
+ ): Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null {
2096
+ if (!acceptedOn() || !acceptedLink) return null;
2097
+ const root = op.op === 'dir.move' ? op.from : op.path;
2098
+ const covered: string[] = [];
2099
+ if (op.op !== 'dir.create') {
2100
+ for (const [slug, d] of descriptors) {
2101
+ const rel = path.relative(ctx.paths.designRoot, d.html).split(path.sep).join('/');
2102
+ if (rel === root || rel.startsWith(`${root}/`)) covered.push(slug);
2103
+ }
2104
+ for (const slug of covered) coveredByFolderAction.add(slug);
2105
+ }
2106
+ const answer =
2107
+ op.op === 'dir.create'
2108
+ ? acceptedLink.dirCreate(op.path)
2109
+ : op.op === 'dir.delete'
2110
+ ? acceptedLink.dirDelete(op.path)
2111
+ : acceptedLink.dirMove(op.from, op.to);
2112
+ return answer.then((r) => {
2113
+ if (r.status === 'rejected') {
2114
+ for (const slug of covered) coveredByFolderAction.delete(slug);
2115
+ return r;
2116
+ }
2117
+ const under = (d: string) => d === root || d.startsWith(`${root}/`);
2118
+ if (op.op === 'dir.create') knownProjectDirs.add(op.path);
2119
+ else {
2120
+ for (const d of [...knownProjectDirs]) {
2121
+ if (!under(d)) continue;
2122
+ knownProjectDirs.delete(d);
2123
+ if (op.op === 'dir.move') knownProjectDirs.add(`${op.to}${d.slice(op.from.length)}`);
2124
+ }
2125
+ if (op.op === 'dir.move') knownProjectDirs.add(op.to);
2126
+ }
2127
+ saveProjectDirs();
2128
+ return r;
2129
+ });
2130
+ }
2131
+
1496
2132
  async function start(): Promise<void> {
1497
2133
  if (started || stopped) return;
1498
2134
  started = true;
@@ -1547,7 +2183,24 @@ export function createSyncRuntime(
1547
2183
  // credential in place, so reading it at call time is what every other hub
1548
2184
  // call here does. Identical at boot; correct if start() ever re-runs after
1549
2185
  // a renewal (and it types, which `resolvedToken`'s `string | null` did not).
1550
- const remoteListing = await fetchRemoteListing(linkedHub.url, token);
2186
+ const remoteListingRaw = await fetchRemoteListing(linkedHub.url, token);
2187
+ if (acceptedLink) {
2188
+ await acceptedLink.refresh();
2189
+ if (acceptedOn()) {
2190
+ console.log(
2191
+ '[sync] this project saves through accepted revisions — local changes are proposed, never written into the shared document.'
2192
+ );
2193
+ // Work a previous run left unanswered goes first, in creation order —
2194
+ // before any cold start can propose something built on top of it.
2195
+ void acceptedLink.client.drainOutbox().then((results) => {
2196
+ if (results.length)
2197
+ console.log(`[sync/tx] resent ${results.length} unanswered change(s).`);
2198
+ });
2199
+ applyProjectDirs(acceptedLink.manifest?.dirs ?? []);
2200
+ proposeLocalFolders();
2201
+ }
2202
+ }
2203
+ const remoteListing = withAcceptedDocs(remoteListingRaw);
1551
2204
  // BOOT LEARNS THE DELETIONS BEFORE IT PULLS ANYTHING. The peer-side apply
1552
2205
  // lives further down (it needs the live descriptor map), so this boot pass
1553
2206
  // only has to make sure the pull does not fetch a canvas the project has
@@ -1798,6 +2451,9 @@ export function createSyncRuntime(
1798
2451
  broadcast: (payload) => ctx.bus.emit('sync:status', payload),
1799
2452
  });
1800
2453
  const store = statusStore;
2454
+ // An unfinished AI action restored from the last session (T16).
2455
+ const restoredStage = acceptedLink?.stage.summary();
2456
+ if (restoredStage) store.updateAiAction?.(restoredStage);
1801
2457
 
1802
2458
  // DDR-079 — TSX sync defaults ON, so every linked non-loopback project that
1803
2459
  // ships .tsx broadcasts the WebRTC/self-nav exfil residual (the sandbox
@@ -1856,6 +2512,28 @@ export function createSyncRuntime(
1856
2512
  });
1857
2513
  fsReader = reader;
1858
2514
 
2515
+ // Accepted revisions: an API source write announces itself before it
2516
+ // lands (`activity:suppress`, the same signal the activity rim uses).
2517
+ const projectionForRel = (rel: unknown): DocProjection | undefined => {
2518
+ if (typeof rel !== 'string' || !rel) return undefined;
2519
+ const abs = path.join(ctx.paths.designRoot, rel);
2520
+ for (const [slug, d] of descriptors) if (d.html === abs) return projections.get(slug);
2521
+ return undefined;
2522
+ };
2523
+ const unsubSuppress = ctx.bus.on('activity:suppress', (rel: unknown) => {
2524
+ if (acceptedOn()) projectionForRel(rel)?.noteLocalWrite();
2525
+ });
2526
+ // T24 — the UI operation behind that write (sync/source-ops).
2527
+ const unsubSourceOp = ctx.bus.on('source-op', (payload: unknown) => {
2528
+ const p = payload as { rel?: unknown; op?: SourceOp } | null;
2529
+ if (acceptedOn() && p?.op) projectionForRel(p.rel)?.noteSourceOp(p.op);
2530
+ });
2531
+ activityUnsubs.push(unsubSourceOp);
2532
+ const unsubUnsuppress = ctx.bus.on('activity:unsuppress', (rel: unknown) => {
2533
+ projectionForRel(rel)?.cancelLocalWrite();
2534
+ });
2535
+ activityUnsubs.push(unsubSuppress, unsubUnsuppress);
2536
+
1859
2537
  busUnsub = ctx.bus.on('fs:any', (rel: string) => {
1860
2538
  reader.notify(rel);
1861
2539
  // AN ASSET THAT APPEARS AFTER BOOT HAS TO GO UP NOW, NOT NEXT LAUNCH.
@@ -1968,7 +2646,12 @@ export function createSyncRuntime(
1968
2646
  rejectedPermanent.clear();
1969
2647
  for (const entry of entries) {
1970
2648
  mon.noteDocState(entry.canvas.slug, 'pending');
1971
- void connectCanvas(entry.canvas, entry.canvasPaths, entry.doc).catch((err) => {
2649
+ void connectCanvas(
2650
+ entry.canvas,
2651
+ entry.canvasPaths,
2652
+ entry.doc,
2653
+ owedSetups.get(entry.canvas.slug)
2654
+ ).catch((err) => {
1972
2655
  console.error(`[sync/${entry.canvas.slug}] re-probe failed:`, err);
1973
2656
  });
1974
2657
  }
@@ -1982,6 +2665,72 @@ export function createSyncRuntime(
1982
2665
  }, reprobeMs);
1983
2666
  };
1984
2667
 
2668
+ /**
2669
+ * Arm the transient lane's ONE timer (single-flight) for the earliest due
2670
+ * document — or, when the sliding budget is spent, for the moment its
2671
+ * oldest re-auth leaves the window — plus jitter.
2672
+ */
2673
+ const armTransientRetry = (): void => {
2674
+ if (transientRetryTimer !== null || stopped || rejectedTransient.size === 0) return;
2675
+ let at = Number.POSITIVE_INFINITY;
2676
+ for (const e of rejectedTransient.values()) at = Math.min(at, e.dueAt);
2677
+ const oldestSpend = transientSpent[0];
2678
+ if (transientSpent.length >= transientBatch && oldestSpend !== undefined) {
2679
+ at = Math.max(at, oldestSpend + transientRetryMs);
2680
+ }
2681
+ const delay = Math.max(0, at - renewNow()) + Math.floor(authRandom() * transientJitterMs);
2682
+ transientRetryTimer = authSetTimer(
2683
+ () => {
2684
+ transientRetryTimer = null;
2685
+ retryTransientNow();
2686
+ },
2687
+ Math.min(MAX_TIMER_DELAY_MS, delay)
2688
+ );
2689
+ };
2690
+
2691
+ /**
2692
+ * Re-authenticate the due transiently-refused documents — only those, never
2693
+ * the shared socket — up to the budget, oldest first; the rest wait for the
2694
+ * next arm. The swap is the re-probe's: destroy the refused provider and
2695
+ * reconnect on the SAME doc, so the agent and projection wiring carry over.
2696
+ * A provider attached to an already-open socket sends its token at once
2697
+ * (`HocuspocusProviderWebsocket.attach` → `provider.onOpen`).
2698
+ */
2699
+ const retryTransientNow = (): void => {
2700
+ if (stopped) return;
2701
+ const now = renewNow();
2702
+ while (transientSpent.length > 0 && now - (transientSpent[0] ?? now) >= transientRetryMs) {
2703
+ transientSpent.shift();
2704
+ }
2705
+ const due = [...rejectedTransient.entries()]
2706
+ .filter(([, e]) => e.dueAt <= now)
2707
+ .sort((x, y) => x[1].dueAt - y[1].dueAt)
2708
+ .slice(0, Math.max(0, transientBatch - transientSpent.length));
2709
+ for (const [slug, entry] of due) {
2710
+ rejectedTransient.delete(slug);
2711
+ if (providers.get(slug) !== entry.provider) continue;
2712
+ transientSpent.push(now);
2713
+ providers.delete(slug);
2714
+ try {
2715
+ entry.provider.destroy();
2716
+ } catch {
2717
+ /* best-effort */
2718
+ }
2719
+ mon.noteDocState(slug, 'pending');
2720
+ // Not a boot connect — a retry must never join the one-shot boot summary.
2721
+ void connectCanvas(
2722
+ entry.canvas,
2723
+ entry.canvasPaths,
2724
+ entry.doc,
2725
+ owedSetups.get(slug),
2726
+ false
2727
+ ).catch((err) => {
2728
+ console.error(`[sync/${slug}] transient re-auth failed:`, err);
2729
+ });
2730
+ }
2731
+ armTransientRetry();
2732
+ };
2733
+
1985
2734
  /**
1986
2735
  * Renew the hub credential in place — single-flight, silent, and never
1987
2736
  * worse than failure: an unrenewable credential leaves the stored one
@@ -2084,8 +2833,9 @@ export function createSyncRuntime(
2084
2833
  if (authWarnTimer === null) authWarnTimer = authSetTimer(flushAuthWarn, warnDebounceMs);
2085
2834
  // Permanent classes: retrying only spams the hub (and its rate bucket) —
2086
2835
  // destroy the provider and re-probe on a slow timer. Transient classes
2087
- // (rate-limit / generic) keep the provider's built-in backoff.
2836
+ // (rate-limit / generic) get the paced retry below.
2088
2837
  if (reasonClass === 'not-authorized' || reasonClass === 'invalid-token') {
2838
+ rejectedTransient.delete(canvas.slug);
2089
2839
  if (!rejectedPermanent.has(canvas.slug)) {
2090
2840
  rejectedPermanent.set(canvas.slug, { canvas, canvasPaths, doc: provider.document });
2091
2841
  providers.delete(canvas.slug);
@@ -2107,6 +2857,31 @@ export function createSyncRuntime(
2107
2857
  if (renewed) reprobeNow();
2108
2858
  });
2109
2859
  }
2860
+ } else if (!rejectedTransient.has(canvas.slug)) {
2861
+ // THE HUB MAY WELL SAY YES NEXT WINDOW — BUT NOTHING WOULD ASK IT.
2862
+ //
2863
+ // These classes used to be left to "the provider's built-in backoff".
2864
+ // Under DDR-102 multiplexing that backoff is the SOCKET's reconnect
2865
+ // loop, and a partial refusal never closes a socket the other
2866
+ // documents keep healthy — so a refused document stayed refused until
2867
+ // the process ended. Queue it for the runtime's own retry instead: one
2868
+ // hub window out, doubling on each repeat (capped at the re-probe
2869
+ // interval), reset by a handshake that lands. The `has` guard keeps a
2870
+ // repeated event for one refusal from counting as a second strike.
2871
+ const strikes = (transientStrikes.get(canvas.slug) ?? 0) + 1;
2872
+ transientStrikes.set(canvas.slug, strikes);
2873
+ const wait = Math.min(
2874
+ Math.max(transientRetryMs, reprobeMs),
2875
+ transientRetryMs * 2 ** (strikes - 1)
2876
+ );
2877
+ rejectedTransient.set(canvas.slug, {
2878
+ canvas,
2879
+ canvasPaths,
2880
+ doc: provider.document,
2881
+ provider,
2882
+ dueAt: renewNow() + wait,
2883
+ });
2884
+ armTransientRetry();
2110
2885
  }
2111
2886
  };
2112
2887
 
@@ -2126,6 +2901,8 @@ export function createSyncRuntime(
2126
2901
  */
2127
2902
  const clearRejection = (slug: string): void => {
2128
2903
  rejectedPermanent.delete(slug);
2904
+ rejectedTransient.delete(slug);
2905
+ transientStrikes.delete(slug);
2129
2906
  rejectedAny.delete(slug);
2130
2907
  if (!rejectedReasons.delete(slug)) return;
2131
2908
  console.log(`[sync/${slug}] the hub accepted this document — clearing its refusal.`);
@@ -2138,6 +2915,8 @@ export function createSyncRuntime(
2138
2915
  * the canvas its sync.
2139
2916
  */
2140
2917
  const stampFromLocalFile = (doc: Y.Doc, htmlAbs: string): void => {
2918
+ // The accepted replica carries the path the PROJECT gave the document.
2919
+ if (acceptedOn()) return;
2141
2920
  try {
2142
2921
  const rel = path.relative(ctx.paths.designRoot, htmlAbs).split(path.sep).join('/');
2143
2922
  if (rel && !rel.startsWith('..')) stampCanvasPath(doc, rel, ORIGINS.DISK_PROJECTION);
@@ -2190,7 +2969,7 @@ export function createSyncRuntime(
2190
2969
  canvas.html &&
2191
2970
  path.resolve(canvas.html) === movedAbs
2192
2971
  ) {
2193
- if (clearMovedTo(provider.document, ORIGINS.MIGRATION)) {
2972
+ if (!acceptedOn() && clearMovedTo(provider.document, ORIGINS.MIGRATION)) {
2194
2973
  console.log(
2195
2974
  `[sync/${canvas.slug}] this document is stamped as moved to its OWN path — clearing the stale retirement and keeping the canvas.`
2196
2975
  );
@@ -2214,7 +2993,28 @@ export function createSyncRuntime(
2214
2993
  mon.noteDocState(canvas.slug, 'pending');
2215
2994
  const projection = projections.get(canvas.slug);
2216
2995
  const agent = agents.get(canvas.slug);
2217
- if (projection) {
2996
+ if (projection && acceptedOn() && acceptedLink) {
2997
+ const docName = docNameFor(canvas.slug);
2998
+ let inProject = acceptedLink.manifest?.docs.some((d) => d.doc === docName && !d.retired);
2999
+ if (!inProject) {
3000
+ // A canvas adopted after boot: ask again before creating anything.
3001
+ await acceptedLink.refresh();
3002
+ inProject = acceptedLink.manifest?.docs.some((d) => d.doc === docName && !d.retired);
3003
+ }
3004
+ const rel = path.relative(ctx.paths.designRoot, canvas.html).split(path.sep).join('/');
3005
+ await acceptedColdStart({
3006
+ slug: canvas.slug,
3007
+ doc: provider.document,
3008
+ paths: canvasPaths,
3009
+ rel,
3010
+ inProject: inProject === true,
3011
+ projection,
3012
+ historyDir: path.join(ctx.paths.historyDir, canvas.slug),
3013
+ journal: journal ?? undefined,
3014
+ createDoc: (lanes) => acceptedLink.createDoc(canvas.slug, rel, lanes),
3015
+ });
3016
+ projection.reconcile();
3017
+ } else if (projection) {
2218
3018
  // Phase E (DDR-064 Task 9) — one-time authoritative seed BEFORE
2219
3019
  // materializing: escapes the duplication trap by picking ONE source
2220
3020
  // inside a MIGRATION transaction. DDR-102: body divergence now takes
@@ -2237,6 +3037,9 @@ export function createSyncRuntime(
2237
3037
  }
2238
3038
  },
2239
3039
  onConflict: (info) => store.addConflict(info),
3040
+ // An unmergeable local candidate: keep both, and let the projection
3041
+ // report and hold it from the shared base (T2).
3042
+ onHold: (base) => projection.adoptBase(base),
2240
3043
  hubHasState: (slug) => hubHolds(hubDocIndex, docNameFor(slug)),
2241
3044
  });
2242
3045
  if (result === 'local-adopt') {
@@ -2246,6 +3049,16 @@ export function createSyncRuntime(
2246
3049
  `[sync/${canvas.slug}] shared-doc: not seeding — the hub already holds this ` +
2247
3050
  'document; waiting for its state to arrive.'
2248
3051
  );
3052
+ } else if (result === 'conflict-merged') {
3053
+ console.log(
3054
+ `[sync/${canvas.slug}] shared-doc: merged a local edit with the project's newer ` +
3055
+ `version (both sides were in _history/${canvas.slug}/ first).`
3056
+ );
3057
+ } else if (result === 'conflict-held') {
3058
+ console.warn(
3059
+ `[sync/${canvas.slug}] shared-doc: a local edit overlaps the project's newer version — ` +
3060
+ 'both kept; the canvas waits for a resolving save.'
3061
+ );
2249
3062
  } else if (result === 'conflict-local-wins' || result === 'conflict-hub-wins') {
2250
3063
  console.warn(
2251
3064
  `[sync/${canvas.slug}] shared-doc: diverged — kept the ${
@@ -2344,8 +3157,8 @@ export function createSyncRuntime(
2344
3157
  // REFUSED IS REFUSED, WHATEVER THE CLASS.
2345
3158
  //
2346
3159
  // 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
3160
+ // PERMANENT classes — `generic` and `rate-limit` keep their provider
3161
+ // until the transient retry swaps it, and never enter it. `generic` is what
2349
3162
  // EVERY pre-DDR-102 hub sends. So a refusal in a transient class passed
2350
3163
  // straight through this guard and got overwritten with `connected`
2351
3164
  // (attacker review 2026-09-03, F3): the hub was dropping this document's
@@ -2594,6 +3407,7 @@ export function createSyncRuntime(
2594
3407
  // the same order relative to each other.
2595
3408
  const deferSetup = !!setup && pulledSlugs.has(canvas.slug);
2596
3409
  if (!deferSetup) setup?.(provider);
3410
+ else if (setup) owedSetups.set(canvas.slug, setup);
2597
3411
 
2598
3412
  // Task 8 — feed this provider's WS status into the offline monitor.
2599
3413
  // Per-provider, so a socket that never dropped never triggers a poll.
@@ -2663,6 +3477,8 @@ export function createSyncRuntime(
2663
3477
  // Cold-start reconcile fires once the provider has hub state.
2664
3478
  const synced = provider.onceSynced().then(() => {
2665
3479
  if (deferSetup) {
3480
+ // Settled either way below — run, or the canvas is released.
3481
+ owedSetups.delete(canvas.slug);
2666
3482
  // Abandon before `setup?.()`, so no projection and no agent is ever
2667
3483
  // built for a canvas we are not going to place — nothing exists that
2668
3484
  // could flush the document onto the provisional path on the way out.
@@ -2737,13 +3553,65 @@ export function createSyncRuntime(
2737
3553
  paths: canvasPaths,
2738
3554
  echoGuard,
2739
3555
  journal: journal ?? undefined,
3556
+ historyDir: path.join(ctx.paths.historyDir, canvas.slug),
3557
+ waitForReconcile: true,
3558
+ ...(acceptedLink
3559
+ ? {
3560
+ accepted: acceptedLink.laneLink(canvas.slug),
3561
+ revisionBarrier,
3562
+ replayOp: (op: SourceOp, head: string) =>
3563
+ replaySourceOp(canvas.html, op, head),
3564
+ onAccepted: ({
3565
+ lane,
3566
+ value,
3567
+ actionId,
3568
+ }: {
3569
+ lane: string;
3570
+ value: string;
3571
+ actionId: string;
3572
+ }) => noteAcceptedContent(canvas.slug, lane, value, actionId),
3573
+ onRevisionApplied: (rev: number) => store.noteAppliedRevision?.(rev),
3574
+ }
3575
+ : {}),
3576
+ // A write the hub would drop is held, never made (see isWritable).
3577
+ ...(provider.isWritable
3578
+ ? {
3579
+ canWriteDoc: () => provider.isWritable?.() !== false,
3580
+ onWriteBlocked: () => void refreshAcceptedMode(),
3581
+ }
3582
+ : {}),
3583
+ onRecovered: () => store.clearSourceConflict(canvas.slug),
3584
+ onConflict: (info) => {
3585
+ store.addConflict(info);
3586
+ store.notice({
3587
+ id: `source-conflict-${canvas.slug}`,
3588
+ severity: 'warn',
3589
+ text: `Source sync blocked for ${canvas.slug}. The local file was kept because ${
3590
+ info.reason === 'invalid-source'
3591
+ ? 'the source has syntax errors or duplicate declarations'
3592
+ : info.reason === 'local-edit'
3593
+ ? 'a local edit overlaps an incoming change'
3594
+ : info.reason === 'merge-budget'
3595
+ ? 'the edit is too large to merge safely'
3596
+ : 'a recovery copy could not be saved'
3597
+ }. ${info.snapshotFailed ? 'Recovery could not be saved; check disk space and history write access.' : `Recovery copies are in _history/${canvas.slug}/sync-recovery/.`}`,
3598
+ });
3599
+ },
2740
3600
  // Cell pairing only — see the DocProjectionOptions.onWrote doc.
2741
3601
  // The synthetic event is delayed by the same margin the container
2742
3602
  // write bridge uses, so a watcher that DOES fire wins the race and
2743
3603
  // the HMR broadcaster's per-file coalescing collapses the pair
2744
3604
  // into one `canvas-hmr`. The projector's own echo guard drops the
2745
3605
  // resulting file→doc read, so this cannot loop.
2746
- ...(cellPairing ? { onWrote: announceWrite } : {}),
3606
+ onWrote: (abs: string) => {
3607
+ // Tell the canvas layer this write carries the PROJECT's
3608
+ // version — possibly a teammate's change merged under this
3609
+ // person's own — so an open canvas never discards its reload
3610
+ // as the echo of an optimistic edit (hmr-broadcast `remote`).
3611
+ const rel = path.relative(ctx.paths.designRoot, abs).split(path.sep).join('/');
3612
+ if (rel && !rel.startsWith('..')) ctx.bus.emit('sync:projected', rel);
3613
+ if (cellPairing) announceWrite(abs);
3614
+ },
2747
3615
  });
2748
3616
  projection.start();
2749
3617
  projections.set(canvas.slug, projection);
@@ -2776,6 +3644,55 @@ export function createSyncRuntime(
2776
3644
  // cleans up.)
2777
3645
  watchForRetirement(canvas.slug, provider.document);
2778
3646
 
3647
+ // THE HUB SAYS THE SAVE MODE CHANGED — on this document's own
3648
+ // socket, ahead of closing it, so the switch is learned before any
3649
+ // further local change is made (DDR-241 §7 switch ordering).
3650
+ if (acceptedLink && provider.onStateless) {
3651
+ noteDetach(
3652
+ statusDetaches,
3653
+ canvas.slug,
3654
+ provider.onStateless((payload) => {
3655
+ let msg: { type?: unknown; mode?: unknown } | null = null;
3656
+ try {
3657
+ msg = JSON.parse(payload);
3658
+ } catch {
3659
+ return;
3660
+ }
3661
+ if (msg?.type !== 'maude.mode') return;
3662
+ if (msg.mode === 'transactions' || msg.mode === 'legacy') {
3663
+ acceptedLink.noteMode(msg.mode);
3664
+ void refreshAcceptedMode();
3665
+ }
3666
+ })
3667
+ );
3668
+ }
3669
+ if (provider.onAuthenticated) {
3670
+ const slug = canvas.slug;
3671
+ noteDetach(
3672
+ statusDetaches,
3673
+ slug,
3674
+ provider.onAuthenticated(() => projections.get(slug)?.retryDeferred())
3675
+ );
3676
+ }
3677
+
3678
+ // ACCEPTED-REPLICA TRIPWIRE (DDR-241 §7, plan T6/T12). In
3679
+ // transactions mode the hub drops every update this connection
3680
+ // sends, so a local write here never reaches the project and the
3681
+ // replica silently diverges. Nothing is supposed to do it; this is
3682
+ // how we find out if something does.
3683
+ if (acceptedLink && provider.isRemoteOrigin) {
3684
+ const slug = canvas.slug;
3685
+ const onAny = (_u: Uint8Array, origin: unknown) => {
3686
+ if (!acceptedOn() || provider.isRemoteOrigin?.(origin)) return;
3687
+ acceptedWriteViolations += 1;
3688
+ console.error(
3689
+ `[sync/${slug}] a local write reached the accepted replica (origin: ${describeOrigin(origin)}) — it cannot reach the project.`
3690
+ );
3691
+ };
3692
+ provider.document.on('update', onAny);
3693
+ noteDetach(statusDetaches, slug, () => provider.document.off('update', onAny));
3694
+ }
3695
+
2779
3696
  // Count local edits (agent-origin doc updates) toward queuedOps while
2780
3697
  // the hub is unreachable — the banner's "N edits queued" figure. Under
2781
3698
  // sharedDoc there is no agent origin to key off (browser edits carry a
@@ -2977,6 +3894,18 @@ export function createSyncRuntime(
2977
3894
  });
2978
3895
  discoveryRescan = rescan;
2979
3896
  discoveryUnsub = ctx.bus.on('canvas-list-update', () => rescan.schedule());
3897
+ // A folder made OUTSIDE the app while it runs — an agent's `mkdir` plus
3898
+ // `.gitkeep`, Finder, a `git checkout` — joins the project the way a cold
3899
+ // start adds one: additively, never as a deletion. Without this it stayed
3900
+ // on this machine until the next launch.
3901
+ foldersUnsub = ctx.bus.on('fs:any', (rel: unknown) => {
3902
+ if (typeof rel !== 'string' || !rel.endsWith('/.gitkeep') || !acceptedOn()) return;
3903
+ if (foldersTimer) clearTimeout(foldersTimer);
3904
+ foldersTimer = setTimeout(() => {
3905
+ foldersTimer = null;
3906
+ if (acceptedOn()) proposeLocalFolders();
3907
+ }, 300);
3908
+ });
2980
3909
 
2981
3910
  // The OUTBOUND half of the delete lane. `api.ts` emits these two only from
2982
3911
  // its privileged create/delete routes, never from the filesystem watcher —
@@ -2986,6 +3915,17 @@ export function createSyncRuntime(
2986
3915
  if (typeof slug !== 'string' || !slug) return;
2987
3916
  if (revive) tombstoned.delete(slug);
2988
3917
  else tombstoned.add(slug);
3918
+ if (acceptedOn() && acceptedLink) {
3919
+ // A re-created canvas is proposed by its cold start (`doc.create`); a
3920
+ // deletion is ONE project action unless a folder action covered it.
3921
+ if (revive || coveredByFolderAction.delete(slug)) return;
3922
+ void acceptedLink.deleteDoc(slug).then((r) => {
3923
+ if (r.status === 'rejected' && r.code !== 'dependency-missing') {
3924
+ console.warn(`[sync] the project did not accept deleting ${slug} (${r.code})`);
3925
+ }
3926
+ });
3927
+ return;
3928
+ }
2989
3929
  void stateDocumentGone(linkedHub.url, token, docNameFor(slug), { revive }).then((ok) => {
2990
3930
  if (!ok) {
2991
3931
  console.warn(
@@ -3038,6 +3978,13 @@ export function createSyncRuntime(
3038
3978
  annotations: canvas.annotations,
3039
3979
  },
3040
3980
  });
3981
+ if (!existsSync(canvas.html)) {
3982
+ ctx.bus.emit('canvas-list-update', {
3983
+ action: 'removed',
3984
+ rel: path.relative(ctx.paths.designRoot, canvas.html).split(path.sep).join('/'),
3985
+ slug,
3986
+ });
3987
+ }
3041
3988
  }
3042
3989
  descriptors.delete(slug);
3043
3990
  }
@@ -3064,11 +4011,18 @@ export function createSyncRuntime(
3064
4011
  // not told the document exists.
3065
4012
  const pullRemoteOnce = async (): Promise<void> => {
3066
4013
  if (stopped) return;
4014
+ // Accepted revisions: the save mode can change under a running peer (the
4015
+ // hub fences every socket when it does), and folders are manifest
4016
+ // entries — neither is in the document listing.
4017
+ if (acceptedLink) {
4018
+ await acceptedLink.refresh();
4019
+ if (acceptedOn()) applyProjectDirs(acceptedLink.manifest?.dirs ?? []);
4020
+ }
3067
4021
  // Read `token` at call time: a silent renewal swaps it in place.
3068
- const listing = await fetchRemoteListing(linkedHub.url, token);
4022
+ const listing = withAcceptedDocs(await fetchRemoteListing(linkedHub.url, token));
3069
4023
  // null = unreachable, refused, or a hub without the route. Not an error
3070
4024
  // here any more than it is at boot — sync continues, we ask again later.
3071
- if (listing === null) return;
4025
+ if (stopped || listing === null) return;
3072
4026
  // ABSENCE BEFORE PRESENCE. A canvas the project deleted must leave before
3073
4027
  // the pull runs, or a slug that is tombstoned AND still listed (the window
3074
4028
  // between the tombstone and the row actually going) would be trashed and
@@ -3402,13 +4356,20 @@ export function createSyncRuntime(
3402
4356
  });
3403
4357
  noteFilePull(result);
3404
4358
  };
4359
+ documentDiscovery = createDocumentDiscovery({
4360
+ run: pullRemoteOnce,
4361
+ onError: (error) => console.error('[sync] document discovery failed:', error),
4362
+ });
4363
+ const discovery = documentDiscovery;
4364
+ documentDiscoveryUnsub = ctx.bus.on('sync:documents-changed', () => discovery.schedule());
3405
4365
  const pollRemote = (): void => {
3406
- void pullRemoteOnce()
4366
+ void discovery
4367
+ .flush()
3407
4368
  .then(() => pullFilesOnce())
3408
4369
  .catch((err) => console.error('[sync] remote poll failed:', err));
3409
4370
  };
3410
4371
  remotePull = async () => {
3411
- await pullRemoteOnce();
4372
+ await discovery.flush();
3412
4373
  await pullFilesOnce();
3413
4374
  };
3414
4375
  remotePollTimer = setInterval(pollRemote, REMOTE_POLL_MS);
@@ -3441,7 +4402,11 @@ export function createSyncRuntime(
3441
4402
  // • `state === 'online'` — offline already has its own recovery path.
3442
4403
  // • `synced === 0 && pending > 0` — some progress means it is working,
3443
4404
  // just slowly; this is only for the total stall.
3444
- // • `rejected === 0` — a refusal is the auth lane's (renew + re-probe).
4405
+ // • `rejected === 0` — a refusal is the auth lane's: renew + re-probe for
4406
+ // the permanent classes, the paced per-document retry for the
4407
+ // transient ones. Cycling the socket would re-authenticate EVERY
4408
+ // document to recover a few, and a volume refusal is exactly when
4409
+ // that burst hurts most.
3445
4410
  // • a floor between forced reconnects, so a hub that is simply down
3446
4411
  // cannot be turned into a reconnect storm by its own silence.
3447
4412
  // The socket-cycling capability, resolved once.
@@ -3574,6 +4539,7 @@ export function createSyncRuntime(
3574
4539
  fileEventsCtl = createCtlProvider({
3575
4540
  url: linkedHub.url,
3576
4541
  token,
4542
+ onDocuments: () => documentDiscovery?.schedule(),
3577
4543
  onPoke: () => {
3578
4544
  // Reuses `pollRemoteSoon` rather than calling the file lanes
3579
4545
  // directly, for two reasons: it already coalesces a burst into
@@ -3614,6 +4580,8 @@ export function createSyncRuntime(
3614
4580
  async function stop(): Promise<void> {
3615
4581
  if (stopped) return;
3616
4582
  stopped = true;
4583
+ acceptedLink?.stop();
4584
+ revisionBarrier.stop();
3617
4585
  fileEventsProbe?.abort();
3618
4586
  fileEventsProbe = null;
3619
4587
  if (filePassTimer !== null) clearTimeout(filePassTimer);
@@ -3638,6 +4606,12 @@ export function createSyncRuntime(
3638
4606
  attachOne = null;
3639
4607
  discoveryUnsub?.();
3640
4608
  discoveryUnsub = null;
4609
+ foldersUnsub?.();
4610
+ foldersUnsub = null;
4611
+ if (foldersTimer) clearTimeout(foldersTimer);
4612
+ foldersTimer = null;
4613
+ if (dirsRetryTimer) clearTimeout(dirsRetryTimer);
4614
+ dirsRetryTimer = null;
3641
4615
  deletedUnsub?.();
3642
4616
  deletedUnsub = null;
3643
4617
  createdUnsub?.();
@@ -3650,6 +4624,10 @@ export function createSyncRuntime(
3650
4624
  stallTimer = null;
3651
4625
  if (remotePollSoonTimer !== null) clearTimeout(remotePollSoonTimer);
3652
4626
  remotePollSoonTimer = null;
4627
+ documentDiscoveryUnsub?.();
4628
+ documentDiscoveryUnsub = null;
4629
+ documentDiscovery?.stop();
4630
+ documentDiscovery = null;
3653
4631
  remotePull = null;
3654
4632
  // A push pass that outlives its runtime keeps uploading a project the
3655
4633
  // person just closed — and `restart()` (the Resync button) calls stop() on
@@ -3688,6 +4666,10 @@ export function createSyncRuntime(
3688
4666
  authClearTimer(reprobeTimer);
3689
4667
  reprobeTimer = null;
3690
4668
  }
4669
+ if (transientRetryTimer !== null) {
4670
+ authClearTimer(transientRetryTimer);
4671
+ transientRetryTimer = null;
4672
+ }
3691
4673
  if (renewTimer !== null) {
3692
4674
  authClearTimer(renewTimer);
3693
4675
  renewTimer = null;
@@ -3697,8 +4679,13 @@ export function createSyncRuntime(
3697
4679
  for (const h of announceTimers.values()) clearTimeout(h);
3698
4680
  announceTimers.clear();
3699
4681
  rejectedPermanent.clear();
4682
+ rejectedTransient.clear();
4683
+ transientStrikes.clear();
4684
+ transientSpent.length = 0;
4685
+ owedSetups.clear();
3700
4686
  busUnsub?.();
3701
4687
  busUnsub = null;
4688
+ for (const u of activityUnsubs.splice(0)) u();
3702
4689
  fsReader?.stop();
3703
4690
  fsReader = null;
3704
4691
  for (const agent of agents.values()) {
@@ -3783,6 +4770,145 @@ export function createSyncRuntime(
3783
4770
  return true;
3784
4771
  },
3785
4772
  retireForMove: (fromSlug, toRel) => serializeMembership(() => retireForMove(fromSlug, toRel)),
4773
+ proposeLane: (slug, lane, text, o) => {
4774
+ if (!acceptedOn()) return null;
4775
+ const projection = projections.get(slug);
4776
+ if (!projection) return null;
4777
+ const value = laneValueFromFile(lane, text);
4778
+ if (value === null) return null;
4779
+ const base = o?.baseText === undefined ? undefined : laneValueFromFile(lane, o.baseText);
4780
+ return projection.proposeLane(lane, value, {
4781
+ ...(base !== undefined && base !== null ? { baseContent: base } : {}),
4782
+ ...(o?.writeId ? { writeId: o.writeId } : {}),
4783
+ });
4784
+ },
4785
+ proposeFolder,
4786
+ acceptedMode: acceptedOn,
4787
+ acceptedWriteViolations: () => acceptedWriteViolations,
4788
+ acceptedHistory: async (q) => {
4789
+ if (!acceptedOn() || !acceptedLink) return null;
4790
+ // Fresh, not the cached manifest: a canvas created since the last
4791
+ // bootstrap has history too, and a stale map named no entry for it.
4792
+ const manifest = (await acceptedLink.refresh().catch(() => null)) ?? acceptedLink.manifest;
4793
+ if (!manifest) return null;
4794
+ const pathOf = new Map(manifest.docs.map((d) => [d.doc, d.path]));
4795
+ let entry: string | null = null;
4796
+ if (q.path) {
4797
+ const slug = slugForRepoRel(q.path);
4798
+ const doc = slug ? docNameFor(slug) : null;
4799
+ entry = manifest.docs.find((d) => d.doc === doc)?.entry ?? null;
4800
+ if (!entry) return [];
4801
+ }
4802
+ const { history } = await acceptedLink.history({
4803
+ limit: Math.min(Math.max(q.limit ?? 50, 1), 200),
4804
+ before: q.before ?? null,
4805
+ entry,
4806
+ });
4807
+ const you = manifest.you?.actor ?? null;
4808
+ return history.map((a) => {
4809
+ const canvases = new Set<string>();
4810
+ for (const e of a.effects) {
4811
+ const p = e.afterPath ?? (e.doc ? pathOf.get(e.doc) : null) ?? e.beforePath;
4812
+ if (p && /\.(tsx|html)$/i.test(p)) canvases.add(p);
4813
+ }
4814
+ return {
4815
+ revision: a.revision,
4816
+ actionId: a.actionId,
4817
+ actor: a.actor,
4818
+ mine: you !== null && a.actor === you,
4819
+ kind: a.kind,
4820
+ label: a.label,
4821
+ committedAt: a.committedAt,
4822
+ undoes: a.undoes,
4823
+ canvases: [...canvases].sort(),
4824
+ };
4825
+ });
4826
+ },
4827
+ acceptedVersion: async (repoRel, revision) => {
4828
+ if (!acceptedOn() || !acceptedLink) return null;
4829
+ const slug = slugForRepoRel(repoRel);
4830
+ if (!slug) return null;
4831
+ const r = await acceptedLink.laneAt(slug, 'html', revision);
4832
+ return r.body || null;
4833
+ },
4834
+ acceptedRestore: async (repoRel, revision) => {
4835
+ if (!acceptedOn() || !acceptedLink) return null;
4836
+ const slug = slugForRepoRel(repoRel);
4837
+ if (!slug) return { status: 'rejected', code: 'unknown-canvas' };
4838
+ const name = path.basename(repoRel).replace(/\.(tsx|html)$/i, '');
4839
+ return acceptedLink.restore([slug], revision, `Restore ${name} to version ${revision}`);
4840
+ },
4841
+ acceptedUndo: async (actionId, redo = false) => {
4842
+ if (!acceptedOn() || !acceptedLink) return null;
4843
+ return acceptedLink.undo(actionId, redo);
4844
+ },
4845
+ prepareOffline: async () => {
4846
+ if (!filePlane) return null;
4847
+ let pulled = 0;
4848
+ let passes = 0;
4849
+ let failed = 0;
4850
+ for (; passes < 40 && !stopped; passes += 1) {
4851
+ lastPlaneResult = null;
4852
+ await runPlanePass({ floor: false });
4853
+ const r = lastPlaneResult as import('./file-plane.ts').FilePlaneResult | null;
4854
+ if (!r) break;
4855
+ pulled += r.pulled.length;
4856
+ failed = r.failed.length;
4857
+ if (r.pulled.length === 0 && !r.budgetExhausted) {
4858
+ return { complete: failed === 0, pulled, passes: passes + 1, failed };
4859
+ }
4860
+ }
4861
+ return { complete: false, pulled, passes, failed };
4862
+ },
4863
+ acceptedActionForContent: (repoRel, content) => {
4864
+ const slug = slugForRepoRel(repoRel);
4865
+ return slug ? (acceptedByContent.get(`${slug}|html|${hashBytes(content)}`) ?? null) : null;
4866
+ },
4867
+ conflictVersions: (repoRel) => {
4868
+ // A slug (the Sync panel's notice id) or a repo-relative canvas path.
4869
+ const slug = projections.has(repoRel) ? repoRel : slugForRepoRel(repoRel);
4870
+ const sides = slug ? projections.get(slug)?.conflictSides() : null;
4871
+ return slug && sides ? { slug, ...sides } : null;
4872
+ },
4873
+ resolveConflict: async (repoRel, choice) => {
4874
+ const slug = projections.has(repoRel) ? repoRel : slugForRepoRel(repoRel);
4875
+ const projection = slug ? projections.get(slug) : undefined;
4876
+ if (!slug || !projection) return null;
4877
+ const sides = projection.conflictSides();
4878
+ if (!sides) return null;
4879
+ if (choice === 'theirs') {
4880
+ projection.takeAccepted();
4881
+ return { status: 'taken' };
4882
+ }
4883
+ if (sides.mine === null) return { status: 'rejected', code: 'no-local-version' };
4884
+ // Mine, on top of the version that won — a new action, never a rewind.
4885
+ const r = await projection.proposeLane('html', sides.mine, { baseContent: sides.theirs });
4886
+ if (!r) return null;
4887
+ if (r.status === 'accepted') statusStore?.clearSourceConflict?.(slug);
4888
+ return { status: r.status, ...(r.code ? { code: r.code } : {}) };
4889
+ },
4890
+ beginAiAction: (key, label) => {
4891
+ if (!acceptedOn() || !acceptedLink) return;
4892
+ acceptedLink.stage.begin(key, label);
4893
+ },
4894
+ endAiAction: async (key, outcome) => {
4895
+ if (!acceptedLink) return null;
4896
+ // The watcher delivers the last tool write a beat after the tool
4897
+ // returns; let it land in the stage before the stage closes.
4898
+ await new Promise((r) => setTimeout(r, AI_ACTION_SETTLE_MS));
4899
+ await (discoveryRescan?.flush() ?? Promise.resolve()).catch(() => {});
4900
+ const r = await acceptedLink.stage.end(key, outcome);
4901
+ return r ? { status: r.status, ...(r.code ? { code: r.code } : {}) } : null;
4902
+ },
4903
+ resolveAiAction: async (choice) => {
4904
+ if (!acceptedLink || acceptedLink.stage.state !== 'held') return null;
4905
+ if (choice === 'discard') {
4906
+ const canvases = acceptedLink.stage.discard();
4907
+ return { status: 'discarded', canvases };
4908
+ }
4909
+ const r = await acceptedLink.stage.publish();
4910
+ return r ? { status: r.status, ...(r.code ? { code: r.code } : {}) } : null;
4911
+ },
3786
4912
  };
3787
4913
  }
3788
4914
 
@@ -4468,11 +5594,37 @@ export function createDefaultProviderFactory(
4468
5594
  };
4469
5595
  socket.on('status', resetSyncedOnDrop);
4470
5596
 
5597
+ let authedThisConnection = false;
5598
+ provider.on('authenticated', () => {
5599
+ authedThisConnection = true;
5600
+ });
5601
+ const forgetAuthOnDrop = (evt: { status?: string }) => {
5602
+ if (evt?.status !== 'connected') authedThisConnection = false;
5603
+ };
5604
+ socket.on('status', forgetAuthOnDrop);
4471
5605
  return {
4472
5606
  document,
4473
5607
  // HocuspocusProvider creates a hub-synced Awareness by default; expose it
4474
5608
  // so the runtime can bridge it to the collab Room (Task 5).
4475
5609
  awareness: provider.awareness as Awareness | undefined,
5610
+ isRemoteOrigin: (origin: unknown) => origin === provider,
5611
+ // Writable = authenticated read-write ON THIS CONNECTION. A scope left
5612
+ // over from before a drop says nothing about the hub now: a project
5613
+ // switched to accepted revisions while this peer was away re-admits it
5614
+ // read-only, and anything written in between would be dropped there.
5615
+ isWritable: () => authedThisConnection && provider.authorizedScope === 'read-write',
5616
+ onAuthenticated(cb: (scope: string) => void): () => void {
5617
+ const handler = (evt: { scope?: string }) => cb(String(evt?.scope ?? ''));
5618
+ provider.on('authenticated', handler);
5619
+ return () => provider.off('authenticated', handler);
5620
+ },
5621
+ onStateless(cb: (payload: string) => void): () => void {
5622
+ const handler = (evt: { payload?: string }) => {
5623
+ if (typeof evt?.payload === 'string') cb(evt.payload);
5624
+ };
5625
+ provider.on('stateless', handler);
5626
+ return () => provider.off('stateless', handler);
5627
+ },
4476
5628
  onStatus(cb: (status: ProviderStatus) => void): () => void {
4477
5629
  // The shared socket emits 'status' on every WS transition and every
4478
5630
  // ATTACHED provider re-emits it (forwardStatus), so per-provider
@@ -4523,6 +5675,7 @@ export function createDefaultProviderFactory(
4523
5675
  },
4524
5676
  destroy() {
4525
5677
  socket.off('status', resetSyncedOnDrop);
5678
+ socket.off('status', forgetAuthOnDrop);
4526
5679
  // Detaches from the shared socket (sends a per-document Close); the
4527
5680
  // socket itself is destroyed by dispose() after all providers.
4528
5681
  provider.destroy();