@1agh/maude 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) 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 +689 -36
  7. package/apps/studio/bin/annotate.mjs +3 -1
  8. package/apps/studio/bin/server-up.sh +17 -2
  9. package/apps/studio/canvas-edit.ts +391 -7
  10. package/apps/studio/canvas-lib.tsx +82 -18
  11. package/apps/studio/canvas-notice-message.ts +16 -0
  12. package/apps/studio/canvas-notifications.tsx +19 -0
  13. package/apps/studio/canvas-shell.tsx +57 -6
  14. package/apps/studio/client/app.jsx +686 -241
  15. package/apps/studio/client/apply-edit-request.ts +55 -0
  16. package/apps/studio/client/export-center.jsx +111 -61
  17. package/apps/studio/client/github.js +38 -1
  18. package/apps/studio/client/index-loader.ts +76 -0
  19. package/apps/studio/client/panels/CloudBar.jsx +52 -6
  20. package/apps/studio/client/panels/GitPanel.jsx +103 -0
  21. package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
  22. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
  23. package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
  24. package/apps/studio/client/panels/SyncPanel.jsx +113 -0
  25. package/apps/studio/client/panels/TeamProjects.jsx +376 -0
  26. package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
  27. package/apps/studio/client/photo-knobs.jsx +3 -0
  28. package/apps/studio/client/share-dialog.jsx +91 -0
  29. package/apps/studio/client/share-link.js +94 -0
  30. package/apps/studio/client/styles/3-shell-maude.css +35 -18
  31. package/apps/studio/client/styles/4-components.css +18 -1
  32. package/apps/studio/client/tree-row-menu.jsx +48 -3
  33. package/apps/studio/client/whats-new.jsx +26 -32
  34. package/apps/studio/cloud/endpoints.ts +132 -0
  35. package/apps/studio/collab/index.ts +4 -0
  36. package/apps/studio/collab/persistence.ts +5 -1
  37. package/apps/studio/collab/registry.ts +9 -3
  38. package/apps/studio/collab/room.ts +13 -2
  39. package/apps/studio/context.ts +59 -0
  40. package/apps/studio/dist/client.bundle.js +1586 -1554
  41. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  42. package/apps/studio/dist/runtime/sonner.js +1 -0
  43. package/apps/studio/dist/styles.css +1 -1
  44. package/apps/studio/http.ts +289 -18
  45. package/apps/studio/inspect.ts +19 -0
  46. package/apps/studio/managed-projects.ts +150 -0
  47. package/apps/studio/notifications.tsx +292 -0
  48. package/apps/studio/runtime-bundle.ts +2 -0
  49. package/apps/studio/server.ts +59 -3
  50. package/apps/studio/sync/accepted-cold-start.ts +225 -0
  51. package/apps/studio/sync/accepted-link.ts +320 -0
  52. package/apps/studio/sync/action-stage.ts +343 -0
  53. package/apps/studio/sync/agent.ts +12 -60
  54. package/apps/studio/sync/cell-file-events.ts +3 -0
  55. package/apps/studio/sync/codec.ts +73 -6
  56. package/apps/studio/sync/ctl-provider.ts +15 -2
  57. package/apps/studio/sync/document-discovery.ts +81 -0
  58. package/apps/studio/sync/file-membership.ts +10 -2
  59. package/apps/studio/sync/file-plane.ts +309 -26
  60. package/apps/studio/sync/index.ts +1078 -19
  61. package/apps/studio/sync/migrate-seed.ts +110 -19
  62. package/apps/studio/sync/poke.ts +4 -2
  63. package/apps/studio/sync/presentation.ts +285 -2
  64. package/apps/studio/sync/projection.ts +820 -23
  65. package/apps/studio/sync/repeated-module.ts +73 -0
  66. package/apps/studio/sync/revision-barrier.ts +129 -0
  67. package/apps/studio/sync/seed-repair.ts +46 -0
  68. package/apps/studio/sync/source-merge.ts +100 -0
  69. package/apps/studio/sync/source-ops.ts +289 -0
  70. package/apps/studio/sync/source-recovery.ts +70 -0
  71. package/apps/studio/sync/source-validation.ts +56 -0
  72. package/apps/studio/sync/status.ts +79 -1
  73. package/apps/studio/sync/transaction-client.ts +460 -0
  74. package/apps/studio/sync/writer-registry.ts +236 -0
  75. package/apps/studio/text-caret.ts +35 -0
  76. package/apps/studio/undo-hud.tsx +9 -87
  77. package/apps/studio/use-canvas-media-drop.tsx +4 -39
  78. package/apps/studio/whats-new.json +101 -0
  79. package/cli/lib/harness/codex-runtime.mjs +4 -1
  80. package/package.json +9 -8
  81. package/plugins/design/dependencies.json +3 -3
  82. package/plugins/design/templates/_shell.html +47 -0
  83. 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
@@ -1002,6 +1277,8 @@ export function createSyncRuntime(
1002
1277
  let pokesSeen = 0;
1003
1278
  /** Assigned by `start()`; the seam `pullRemoteNow()` and tests reach. */
1004
1279
  let remotePull: (() => Promise<void>) | null = null;
1280
+ let documentDiscovery: ReturnType<typeof createDocumentDiscovery> | null = null;
1281
+ let documentDiscoveryUnsub: (() => void) | null = null;
1005
1282
  /**
1006
1283
  * Run a file-plane pass shortly, coalesced.
1007
1284
  *
@@ -1037,6 +1314,7 @@ export function createSyncRuntime(
1037
1314
  let planePassInFlight = false;
1038
1315
  let lastPlanePassAt = 0;
1039
1316
  let planePassWaiter: Promise<void> | null = null;
1317
+ let lastPlaneResult: import('./file-plane.ts').FilePlaneResult | null = null;
1040
1318
  async function runPlanePass(opts?: { floor?: boolean }): Promise<void> {
1041
1319
  // Captured, not re-read: `stop()` clears `filePlane`, and a pass that has
1042
1320
  // already decided to run must not dereference the field it was cleared to.
@@ -1061,6 +1339,7 @@ export function createSyncRuntime(
1061
1339
  const run = (async () => {
1062
1340
  try {
1063
1341
  const r = await lane.reconcile();
1342
+ lastPlaneResult = r;
1064
1343
  planeResultSink?.(r);
1065
1344
  } catch (err) {
1066
1345
  console.error('[sync/files] pass failed:', err);
@@ -1106,6 +1385,23 @@ export function createSyncRuntime(
1106
1385
  */
1107
1386
  function pollRemoteSoon(opts: { cooled?: boolean } = {}): void {
1108
1387
  if (stopped || remotePollSoonTimer !== null) return;
1388
+ // ACCEPTED REVISIONS: a poke means "a revision was accepted", and the
1389
+ // canvas it created, moved or deleted — or the folder — must reach this
1390
+ // peer now, not on the next 20 s tick. Still bounded (one pass per
1391
+ // second, with a trailing pass so the last poke of a burst is honoured),
1392
+ // so a hub that pokes in a loop costs one cheap request a second.
1393
+ if (opts.cooled && acceptedOn()) {
1394
+ const since = Date.now() - lastPokePassAt;
1395
+ const wait = Math.max(ACCEPTED_POKE_DELAY_MS, ACCEPTED_POKE_COOLDOWN_MS - since);
1396
+ remotePollSoonTimer = setTimeout(() => {
1397
+ remotePollSoonTimer = null;
1398
+ if (stopped) return;
1399
+ lastPokePassAt = Date.now();
1400
+ void remotePull?.().catch(() => {});
1401
+ }, wait);
1402
+ remotePollSoonTimer.unref?.();
1403
+ return;
1404
+ }
1109
1405
  if (opts.cooled) {
1110
1406
  const since = Date.now() - lastPokePassAt;
1111
1407
  if (since < POKE_COOLDOWN_MS) {
@@ -1383,7 +1679,17 @@ export function createSyncRuntime(
1383
1679
  console.log(
1384
1680
  `[sync/${slug}] canvas was moved on another machine — stale local copy parked in _trash/ (recoverable).`
1385
1681
  );
1386
- ctx.bus.emit('canvas-list-update');
1682
+ }
1683
+ // The destination is materialized and the old body is gone. Receivers
1684
+ // need the semantic move to retarget their open canvas, even if a local
1685
+ // watcher already noticed the two filesystem changes.
1686
+ if (movedTo && !existsSync(desc.html)) {
1687
+ ctx.bus.emit('canvas-list-update', {
1688
+ action: 'moved',
1689
+ fromRel: path.relative(ctx.paths.designRoot, desc.html).split(path.sep).join('/'),
1690
+ rel: movedTo.replace(/\\/g, '/'),
1691
+ fromSlug: slug,
1692
+ });
1387
1693
  }
1388
1694
  } catch (err) {
1389
1695
  // Quarantine is best-effort: the doc guards already made the file
@@ -1452,6 +1758,9 @@ export function createSyncRuntime(
1452
1758
  // payload would be a permanent "still syncing" the user can never clear.
1453
1759
  monitor?.forgetDoc(slug);
1454
1760
  rejectedPermanent.delete(slug);
1761
+ rejectedTransient.delete(slug);
1762
+ transientStrikes.delete(slug);
1763
+ owedSetups.delete(slug);
1455
1764
  rejectedReasons.delete(slug);
1456
1765
  return true;
1457
1766
  }
@@ -1468,6 +1777,28 @@ export function createSyncRuntime(
1468
1777
  async function retireForMove(fromSlug: string, toRel: string): Promise<boolean> {
1469
1778
  const provider = providers.get(fromSlug);
1470
1779
  if (!provider) return false;
1780
+ if (acceptedOn() && acceptedLink) {
1781
+ // The move is a PROJECT action: the hub retires the old document (with
1782
+ // the same `movedTo` stamp receivers already follow) and opens the new
1783
+ // one with the same content. Answered or queued, the local rename then
1784
+ // proceeds; a refusal (path taken, outside every group) keeps it.
1785
+ movingLocally.add(fromSlug);
1786
+ try {
1787
+ if (!coveredByFolderAction.delete(fromSlug)) {
1788
+ const r = await acceptedLink.moveDoc(fromSlug, toRel);
1789
+ if (r.status === 'rejected') {
1790
+ console.warn(`[sync/${fromSlug}] the project did not accept the move (${r.code})`);
1791
+ return false;
1792
+ }
1793
+ }
1794
+ retiredDocs.add(fromSlug);
1795
+ await releaseOne(fromSlug);
1796
+ console.log(`[sync/${fromSlug}] moved in the project → ${toRel}`);
1797
+ return true;
1798
+ } finally {
1799
+ movingLocally.delete(fromSlug);
1800
+ }
1801
+ }
1471
1802
  movingLocally.add(fromSlug);
1472
1803
  try {
1473
1804
  stampMovedTo(provider.document, toRel, ORIGINS.DISK_PROJECTION);
@@ -1493,6 +1824,243 @@ export function createSyncRuntime(
1493
1824
  }
1494
1825
  }
1495
1826
 
1827
+ // ---- project folders (accepted revisions) --------------------------------
1828
+ //
1829
+ // A folder is a manifest entry, so an EMPTY folder a peer created exists in
1830
+ // the project and appears here — no placeholder canvas. The hub is
1831
+ // untrusted (DDR-054): every path is re-checked against the declared canvas
1832
+ // groups and the real design root before a directory is made, and removal
1833
+ // only ever takes a folder that holds nothing but its `.gitkeep`.
1834
+ const projectDirsFile = path.join(ctx.paths.designRoot, '_state', 'project-dirs.json');
1835
+ try {
1836
+ const saved = JSON.parse(readFileSync(projectDirsFile, 'utf8')) as unknown;
1837
+ if (Array.isArray(saved))
1838
+ for (const d of saved) if (typeof d === 'string') knownProjectDirs.add(d);
1839
+ } catch {
1840
+ /* first run — nothing materialized yet */
1841
+ }
1842
+ const saveProjectDirs = (): void => {
1843
+ try {
1844
+ mkdirSync(path.dirname(projectDirsFile), { recursive: true });
1845
+ writeFileSync(projectDirsFile, JSON.stringify([...knownProjectDirs].sort()));
1846
+ } catch {
1847
+ /* best-effort — a lost file only delays one removal */
1848
+ }
1849
+ };
1850
+ const projectDirAbs = (rel: string): string | null => {
1851
+ if (typeof rel !== 'string' || !rel || rel.length > 512) return null;
1852
+ const parts = rel.split('/');
1853
+ if (parts.length > 16) return null;
1854
+ for (const part of parts) {
1855
+ if (!part || part === '.' || part === '..' || part.startsWith('_') || part.startsWith('.'))
1856
+ return null;
1857
+ if (/[\\\0:*?"<>|]/.test(part) || part.length > 255) return null;
1858
+ }
1859
+ const groups = (ctx.cfg.canvasGroups ?? []).map((g) => g.path.replace(/^\/+|\/+$/g, ''));
1860
+ if (!groups.some((g) => g && (rel === g || rel.startsWith(`${g}/`)))) return null;
1861
+ const abs = path.resolve(ctx.paths.designRoot, ...parts);
1862
+ const root = path.resolve(ctx.paths.designRoot);
1863
+ if (!abs.startsWith(`${root}${path.sep}`)) return null;
1864
+ const real = realpathOfDeepestExisting(abs);
1865
+ const realRoot = realpathOfDeepestExisting(root);
1866
+ if (real !== realRoot && !real.startsWith(`${realRoot}${path.sep}`)) return null;
1867
+ return abs;
1868
+ };
1869
+ const designRelPosix = ctx.paths.designRel.replace(/^\.\//, '').replace(/^\/+|\/+$/g, '');
1870
+ function applyProjectDirs(dirs: readonly string[]): void {
1871
+ const next = new Set(dirs);
1872
+ let changed = false;
1873
+ for (const rel of next) {
1874
+ if (knownProjectDirs.has(rel)) continue;
1875
+ const abs = projectDirAbs(rel);
1876
+ if (!abs) continue;
1877
+ try {
1878
+ if (!existsSync(abs)) {
1879
+ mkdirSync(abs, { recursive: true });
1880
+ writeFileSync(path.join(abs, '.gitkeep'), '');
1881
+ ctx.bus.emit('canvas-list-update', {
1882
+ action: 'mkdir',
1883
+ dir: path.posix.join(designRelPosix, rel),
1884
+ });
1885
+ }
1886
+ knownProjectDirs.add(rel);
1887
+ changed = true;
1888
+ } catch (err) {
1889
+ console.warn(`[sync] could not create project folder ${rel}:`, err);
1890
+ }
1891
+ }
1892
+ // Deepest first, so a removed parent is attempted after its children.
1893
+ const gone = [...knownProjectDirs]
1894
+ .filter((d) => !next.has(d))
1895
+ .sort((a, b) => b.length - a.length);
1896
+ for (const rel of gone) {
1897
+ const abs = projectDirAbs(rel);
1898
+ if (abs && existsSync(abs)) {
1899
+ let entries: string[] = [];
1900
+ try {
1901
+ entries = readdirSync(abs).filter((e) => e !== '.DS_Store');
1902
+ } catch {
1903
+ continue;
1904
+ }
1905
+ // Canvases inside leave through their own deletion/move first; until
1906
+ // then the folder stays and the next poll tries again.
1907
+ if (entries.some((e) => e !== '.gitkeep')) continue;
1908
+ try {
1909
+ for (const e of readdirSync(abs)) rmSync(path.join(abs, e), { force: true });
1910
+ rmdirSync(abs);
1911
+ ctx.bus.emit('canvas-list-update', {
1912
+ action: 'removed-folder',
1913
+ dir: path.posix.join(designRelPosix, rel),
1914
+ });
1915
+ } catch {
1916
+ continue;
1917
+ }
1918
+ }
1919
+ knownProjectDirs.delete(rel);
1920
+ changed = true;
1921
+ }
1922
+ if (changed) saveProjectDirs();
1923
+ }
1924
+
1925
+ /**
1926
+ * The document listing is the hub's STORAGE view: a row appears after
1927
+ * Hocuspocus persists the document, seconds after it exists. Under accepted
1928
+ * revisions the manifest is the project's own statement of what exists, so a
1929
+ * canvas accepted a moment ago is pulled on the next poll, not the one after
1930
+ * its row is written.
1931
+ */
1932
+ function withAcceptedDocs(
1933
+ listing: Awaited<ReturnType<typeof fetchRemoteListing>>
1934
+ ): Awaited<ReturnType<typeof fetchRemoteListing>> {
1935
+ const manifest = acceptedOn() ? acceptedLink?.manifest : null;
1936
+ if (!manifest) return listing;
1937
+ // A document the project RETIRED (moved away) is not a canvas to fetch,
1938
+ // even while its storage row lingers — its successor is in the manifest.
1939
+ const retired = new Set(manifest.docs.filter((d) => d.retired).map((d) => d.doc));
1940
+ const documents = (listing?.documents ?? []).filter((d) => !retired.has(d.name));
1941
+ const names = new Set(documents.map((d) => d.name));
1942
+ for (const d of manifest.docs) {
1943
+ if (d.retired || names.has(d.doc)) continue;
1944
+ documents.push({ name: d.doc, bytes: 1 });
1945
+ names.add(d.doc);
1946
+ }
1947
+ return { ...(listing ?? { tombstones: [] }), documents, tombstones: listing?.tombstones ?? [] };
1948
+ }
1949
+
1950
+ /**
1951
+ * Re-ask the save mode, and hand every held write back to its projection —
1952
+ * as a proposal when the project turned out to be in transactions mode, as
1953
+ * an ordinary import when the connection simply became writable.
1954
+ */
1955
+ let modeRefresh: Promise<void> | null = null;
1956
+ function refreshAcceptedMode(): Promise<void> {
1957
+ if (!acceptedLink) return Promise.resolve();
1958
+ if (modeRefresh) return modeRefresh;
1959
+ modeRefresh = acceptedLink
1960
+ .refresh()
1961
+ .then(() => {
1962
+ if (acceptedOn()) applyProjectDirs(acceptedLink.manifest?.dirs ?? []);
1963
+ for (const p of projections.values()) p.retryDeferred();
1964
+ })
1965
+ .finally(() => {
1966
+ modeRefresh = null;
1967
+ });
1968
+ return modeRefresh;
1969
+ }
1970
+
1971
+ /**
1972
+ * A folder the Maude UI made on this machine (it carries the `.gitkeep`
1973
+ * `createFolder` writes) that the project does not list yet — made while
1974
+ * the project was legacy, or while this machine was offline. It joins the
1975
+ * project as ONE action at cold start. Only additive: a folder the manifest
1976
+ * lists and this disk lacks is never proposed as a deletion from here.
1977
+ */
1978
+ function proposeLocalFolders(): void {
1979
+ if (!acceptedLink) return;
1980
+ const listed = new Set(acceptedLink.manifest?.dirs ?? []);
1981
+ const found: string[] = [];
1982
+ const walk = (abs: string, rel: string, depth: number) => {
1983
+ if (depth > 16 || found.length >= 200) return;
1984
+ let entries: import('node:fs').Dirent[];
1985
+ try {
1986
+ entries = readdirSync(abs, { withFileTypes: true });
1987
+ } catch {
1988
+ return;
1989
+ }
1990
+ for (const e of entries) {
1991
+ if (!e.isDirectory() || e.name.startsWith('_') || e.name.startsWith('.')) continue;
1992
+ const childRel = `${rel}/${e.name}`;
1993
+ const childAbs = path.join(abs, e.name);
1994
+ if (
1995
+ !listed.has(childRel) &&
1996
+ existsSync(path.join(childAbs, '.gitkeep')) &&
1997
+ projectDirAbs(childRel)
1998
+ ) {
1999
+ found.push(childRel);
2000
+ }
2001
+ walk(childAbs, childRel, depth + 1);
2002
+ }
2003
+ };
2004
+ for (const g of ctx.cfg.canvasGroups ?? []) {
2005
+ const rel = g.path.replace(/^\/+|\/+$/g, '');
2006
+ if (rel) walk(path.join(ctx.paths.designRoot, ...rel.split('/')), rel, 1);
2007
+ }
2008
+ if (found.length === 0) return;
2009
+ void acceptedLink.dirsCreate(found).then((r) => {
2010
+ if (r.status === 'accepted') {
2011
+ for (const d of found) knownProjectDirs.add(d);
2012
+ saveProjectDirs();
2013
+ console.log(`[sync] added ${found.length} local folder(s) to the project.`);
2014
+ }
2015
+ });
2016
+ }
2017
+
2018
+ function proposeFolder(
2019
+ op:
2020
+ | { op: 'dir.create'; path: string }
2021
+ | { op: 'dir.delete'; path: string }
2022
+ | {
2023
+ op: 'dir.move';
2024
+ from: string;
2025
+ to: string;
2026
+ }
2027
+ ): Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null {
2028
+ if (!acceptedOn() || !acceptedLink) return null;
2029
+ const root = op.op === 'dir.move' ? op.from : op.path;
2030
+ const covered: string[] = [];
2031
+ if (op.op !== 'dir.create') {
2032
+ for (const [slug, d] of descriptors) {
2033
+ const rel = path.relative(ctx.paths.designRoot, d.html).split(path.sep).join('/');
2034
+ if (rel === root || rel.startsWith(`${root}/`)) covered.push(slug);
2035
+ }
2036
+ for (const slug of covered) coveredByFolderAction.add(slug);
2037
+ }
2038
+ const answer =
2039
+ op.op === 'dir.create'
2040
+ ? acceptedLink.dirCreate(op.path)
2041
+ : op.op === 'dir.delete'
2042
+ ? acceptedLink.dirDelete(op.path)
2043
+ : acceptedLink.dirMove(op.from, op.to);
2044
+ return answer.then((r) => {
2045
+ if (r.status === 'rejected') {
2046
+ for (const slug of covered) coveredByFolderAction.delete(slug);
2047
+ return r;
2048
+ }
2049
+ const under = (d: string) => d === root || d.startsWith(`${root}/`);
2050
+ if (op.op === 'dir.create') knownProjectDirs.add(op.path);
2051
+ else {
2052
+ for (const d of [...knownProjectDirs]) {
2053
+ if (!under(d)) continue;
2054
+ knownProjectDirs.delete(d);
2055
+ if (op.op === 'dir.move') knownProjectDirs.add(`${op.to}${d.slice(op.from.length)}`);
2056
+ }
2057
+ if (op.op === 'dir.move') knownProjectDirs.add(op.to);
2058
+ }
2059
+ saveProjectDirs();
2060
+ return r;
2061
+ });
2062
+ }
2063
+
1496
2064
  async function start(): Promise<void> {
1497
2065
  if (started || stopped) return;
1498
2066
  started = true;
@@ -1547,7 +2115,24 @@ export function createSyncRuntime(
1547
2115
  // credential in place, so reading it at call time is what every other hub
1548
2116
  // call here does. Identical at boot; correct if start() ever re-runs after
1549
2117
  // a renewal (and it types, which `resolvedToken`'s `string | null` did not).
1550
- const remoteListing = await fetchRemoteListing(linkedHub.url, token);
2118
+ const remoteListingRaw = await fetchRemoteListing(linkedHub.url, token);
2119
+ if (acceptedLink) {
2120
+ await acceptedLink.refresh();
2121
+ if (acceptedOn()) {
2122
+ console.log(
2123
+ '[sync] this project saves through accepted revisions — local changes are proposed, never written into the shared document.'
2124
+ );
2125
+ // Work a previous run left unanswered goes first, in creation order —
2126
+ // before any cold start can propose something built on top of it.
2127
+ void acceptedLink.client.drainOutbox().then((results) => {
2128
+ if (results.length)
2129
+ console.log(`[sync/tx] resent ${results.length} unanswered change(s).`);
2130
+ });
2131
+ applyProjectDirs(acceptedLink.manifest?.dirs ?? []);
2132
+ proposeLocalFolders();
2133
+ }
2134
+ }
2135
+ const remoteListing = withAcceptedDocs(remoteListingRaw);
1551
2136
  // BOOT LEARNS THE DELETIONS BEFORE IT PULLS ANYTHING. The peer-side apply
1552
2137
  // lives further down (it needs the live descriptor map), so this boot pass
1553
2138
  // only has to make sure the pull does not fetch a canvas the project has
@@ -1798,6 +2383,9 @@ export function createSyncRuntime(
1798
2383
  broadcast: (payload) => ctx.bus.emit('sync:status', payload),
1799
2384
  });
1800
2385
  const store = statusStore;
2386
+ // An unfinished AI action restored from the last session (T16).
2387
+ const restoredStage = acceptedLink?.stage.summary();
2388
+ if (restoredStage) store.updateAiAction?.(restoredStage);
1801
2389
 
1802
2390
  // DDR-079 — TSX sync defaults ON, so every linked non-loopback project that
1803
2391
  // ships .tsx broadcasts the WebRTC/self-nav exfil residual (the sandbox
@@ -1856,6 +2444,28 @@ export function createSyncRuntime(
1856
2444
  });
1857
2445
  fsReader = reader;
1858
2446
 
2447
+ // Accepted revisions: an API source write announces itself before it
2448
+ // lands (`activity:suppress`, the same signal the activity rim uses).
2449
+ const projectionForRel = (rel: unknown): DocProjection | undefined => {
2450
+ if (typeof rel !== 'string' || !rel) return undefined;
2451
+ const abs = path.join(ctx.paths.designRoot, rel);
2452
+ for (const [slug, d] of descriptors) if (d.html === abs) return projections.get(slug);
2453
+ return undefined;
2454
+ };
2455
+ const unsubSuppress = ctx.bus.on('activity:suppress', (rel: unknown) => {
2456
+ if (acceptedOn()) projectionForRel(rel)?.noteLocalWrite();
2457
+ });
2458
+ // T24 — the UI operation behind that write (sync/source-ops).
2459
+ const unsubSourceOp = ctx.bus.on('source-op', (payload: unknown) => {
2460
+ const p = payload as { rel?: unknown; op?: SourceOp } | null;
2461
+ if (acceptedOn() && p?.op) projectionForRel(p.rel)?.noteSourceOp(p.op);
2462
+ });
2463
+ activityUnsubs.push(unsubSourceOp);
2464
+ const unsubUnsuppress = ctx.bus.on('activity:unsuppress', (rel: unknown) => {
2465
+ projectionForRel(rel)?.cancelLocalWrite();
2466
+ });
2467
+ activityUnsubs.push(unsubSuppress, unsubUnsuppress);
2468
+
1859
2469
  busUnsub = ctx.bus.on('fs:any', (rel: string) => {
1860
2470
  reader.notify(rel);
1861
2471
  // AN ASSET THAT APPEARS AFTER BOOT HAS TO GO UP NOW, NOT NEXT LAUNCH.
@@ -1968,7 +2578,12 @@ export function createSyncRuntime(
1968
2578
  rejectedPermanent.clear();
1969
2579
  for (const entry of entries) {
1970
2580
  mon.noteDocState(entry.canvas.slug, 'pending');
1971
- void connectCanvas(entry.canvas, entry.canvasPaths, entry.doc).catch((err) => {
2581
+ void connectCanvas(
2582
+ entry.canvas,
2583
+ entry.canvasPaths,
2584
+ entry.doc,
2585
+ owedSetups.get(entry.canvas.slug)
2586
+ ).catch((err) => {
1972
2587
  console.error(`[sync/${entry.canvas.slug}] re-probe failed:`, err);
1973
2588
  });
1974
2589
  }
@@ -1982,6 +2597,72 @@ export function createSyncRuntime(
1982
2597
  }, reprobeMs);
1983
2598
  };
1984
2599
 
2600
+ /**
2601
+ * Arm the transient lane's ONE timer (single-flight) for the earliest due
2602
+ * document — or, when the sliding budget is spent, for the moment its
2603
+ * oldest re-auth leaves the window — plus jitter.
2604
+ */
2605
+ const armTransientRetry = (): void => {
2606
+ if (transientRetryTimer !== null || stopped || rejectedTransient.size === 0) return;
2607
+ let at = Number.POSITIVE_INFINITY;
2608
+ for (const e of rejectedTransient.values()) at = Math.min(at, e.dueAt);
2609
+ const oldestSpend = transientSpent[0];
2610
+ if (transientSpent.length >= transientBatch && oldestSpend !== undefined) {
2611
+ at = Math.max(at, oldestSpend + transientRetryMs);
2612
+ }
2613
+ const delay = Math.max(0, at - renewNow()) + Math.floor(authRandom() * transientJitterMs);
2614
+ transientRetryTimer = authSetTimer(
2615
+ () => {
2616
+ transientRetryTimer = null;
2617
+ retryTransientNow();
2618
+ },
2619
+ Math.min(MAX_TIMER_DELAY_MS, delay)
2620
+ );
2621
+ };
2622
+
2623
+ /**
2624
+ * Re-authenticate the due transiently-refused documents — only those, never
2625
+ * the shared socket — up to the budget, oldest first; the rest wait for the
2626
+ * next arm. The swap is the re-probe's: destroy the refused provider and
2627
+ * reconnect on the SAME doc, so the agent and projection wiring carry over.
2628
+ * A provider attached to an already-open socket sends its token at once
2629
+ * (`HocuspocusProviderWebsocket.attach` → `provider.onOpen`).
2630
+ */
2631
+ const retryTransientNow = (): void => {
2632
+ if (stopped) return;
2633
+ const now = renewNow();
2634
+ while (transientSpent.length > 0 && now - (transientSpent[0] ?? now) >= transientRetryMs) {
2635
+ transientSpent.shift();
2636
+ }
2637
+ const due = [...rejectedTransient.entries()]
2638
+ .filter(([, e]) => e.dueAt <= now)
2639
+ .sort((x, y) => x[1].dueAt - y[1].dueAt)
2640
+ .slice(0, Math.max(0, transientBatch - transientSpent.length));
2641
+ for (const [slug, entry] of due) {
2642
+ rejectedTransient.delete(slug);
2643
+ if (providers.get(slug) !== entry.provider) continue;
2644
+ transientSpent.push(now);
2645
+ providers.delete(slug);
2646
+ try {
2647
+ entry.provider.destroy();
2648
+ } catch {
2649
+ /* best-effort */
2650
+ }
2651
+ mon.noteDocState(slug, 'pending');
2652
+ // Not a boot connect — a retry must never join the one-shot boot summary.
2653
+ void connectCanvas(
2654
+ entry.canvas,
2655
+ entry.canvasPaths,
2656
+ entry.doc,
2657
+ owedSetups.get(slug),
2658
+ false
2659
+ ).catch((err) => {
2660
+ console.error(`[sync/${slug}] transient re-auth failed:`, err);
2661
+ });
2662
+ }
2663
+ armTransientRetry();
2664
+ };
2665
+
1985
2666
  /**
1986
2667
  * Renew the hub credential in place — single-flight, silent, and never
1987
2668
  * worse than failure: an unrenewable credential leaves the stored one
@@ -2084,8 +2765,9 @@ export function createSyncRuntime(
2084
2765
  if (authWarnTimer === null) authWarnTimer = authSetTimer(flushAuthWarn, warnDebounceMs);
2085
2766
  // Permanent classes: retrying only spams the hub (and its rate bucket) —
2086
2767
  // destroy the provider and re-probe on a slow timer. Transient classes
2087
- // (rate-limit / generic) keep the provider's built-in backoff.
2768
+ // (rate-limit / generic) get the paced retry below.
2088
2769
  if (reasonClass === 'not-authorized' || reasonClass === 'invalid-token') {
2770
+ rejectedTransient.delete(canvas.slug);
2089
2771
  if (!rejectedPermanent.has(canvas.slug)) {
2090
2772
  rejectedPermanent.set(canvas.slug, { canvas, canvasPaths, doc: provider.document });
2091
2773
  providers.delete(canvas.slug);
@@ -2107,6 +2789,31 @@ export function createSyncRuntime(
2107
2789
  if (renewed) reprobeNow();
2108
2790
  });
2109
2791
  }
2792
+ } else if (!rejectedTransient.has(canvas.slug)) {
2793
+ // THE HUB MAY WELL SAY YES NEXT WINDOW — BUT NOTHING WOULD ASK IT.
2794
+ //
2795
+ // These classes used to be left to "the provider's built-in backoff".
2796
+ // Under DDR-102 multiplexing that backoff is the SOCKET's reconnect
2797
+ // loop, and a partial refusal never closes a socket the other
2798
+ // documents keep healthy — so a refused document stayed refused until
2799
+ // the process ended. Queue it for the runtime's own retry instead: one
2800
+ // hub window out, doubling on each repeat (capped at the re-probe
2801
+ // interval), reset by a handshake that lands. The `has` guard keeps a
2802
+ // repeated event for one refusal from counting as a second strike.
2803
+ const strikes = (transientStrikes.get(canvas.slug) ?? 0) + 1;
2804
+ transientStrikes.set(canvas.slug, strikes);
2805
+ const wait = Math.min(
2806
+ Math.max(transientRetryMs, reprobeMs),
2807
+ transientRetryMs * 2 ** (strikes - 1)
2808
+ );
2809
+ rejectedTransient.set(canvas.slug, {
2810
+ canvas,
2811
+ canvasPaths,
2812
+ doc: provider.document,
2813
+ provider,
2814
+ dueAt: renewNow() + wait,
2815
+ });
2816
+ armTransientRetry();
2110
2817
  }
2111
2818
  };
2112
2819
 
@@ -2126,6 +2833,8 @@ export function createSyncRuntime(
2126
2833
  */
2127
2834
  const clearRejection = (slug: string): void => {
2128
2835
  rejectedPermanent.delete(slug);
2836
+ rejectedTransient.delete(slug);
2837
+ transientStrikes.delete(slug);
2129
2838
  rejectedAny.delete(slug);
2130
2839
  if (!rejectedReasons.delete(slug)) return;
2131
2840
  console.log(`[sync/${slug}] the hub accepted this document — clearing its refusal.`);
@@ -2138,6 +2847,8 @@ export function createSyncRuntime(
2138
2847
  * the canvas its sync.
2139
2848
  */
2140
2849
  const stampFromLocalFile = (doc: Y.Doc, htmlAbs: string): void => {
2850
+ // The accepted replica carries the path the PROJECT gave the document.
2851
+ if (acceptedOn()) return;
2141
2852
  try {
2142
2853
  const rel = path.relative(ctx.paths.designRoot, htmlAbs).split(path.sep).join('/');
2143
2854
  if (rel && !rel.startsWith('..')) stampCanvasPath(doc, rel, ORIGINS.DISK_PROJECTION);
@@ -2190,7 +2901,7 @@ export function createSyncRuntime(
2190
2901
  canvas.html &&
2191
2902
  path.resolve(canvas.html) === movedAbs
2192
2903
  ) {
2193
- if (clearMovedTo(provider.document, ORIGINS.MIGRATION)) {
2904
+ if (!acceptedOn() && clearMovedTo(provider.document, ORIGINS.MIGRATION)) {
2194
2905
  console.log(
2195
2906
  `[sync/${canvas.slug}] this document is stamped as moved to its OWN path — clearing the stale retirement and keeping the canvas.`
2196
2907
  );
@@ -2214,7 +2925,28 @@ export function createSyncRuntime(
2214
2925
  mon.noteDocState(canvas.slug, 'pending');
2215
2926
  const projection = projections.get(canvas.slug);
2216
2927
  const agent = agents.get(canvas.slug);
2217
- if (projection) {
2928
+ if (projection && acceptedOn() && acceptedLink) {
2929
+ const docName = docNameFor(canvas.slug);
2930
+ let inProject = acceptedLink.manifest?.docs.some((d) => d.doc === docName && !d.retired);
2931
+ if (!inProject) {
2932
+ // A canvas adopted after boot: ask again before creating anything.
2933
+ await acceptedLink.refresh();
2934
+ inProject = acceptedLink.manifest?.docs.some((d) => d.doc === docName && !d.retired);
2935
+ }
2936
+ const rel = path.relative(ctx.paths.designRoot, canvas.html).split(path.sep).join('/');
2937
+ await acceptedColdStart({
2938
+ slug: canvas.slug,
2939
+ doc: provider.document,
2940
+ paths: canvasPaths,
2941
+ rel,
2942
+ inProject: inProject === true,
2943
+ projection,
2944
+ historyDir: path.join(ctx.paths.historyDir, canvas.slug),
2945
+ journal: journal ?? undefined,
2946
+ createDoc: (lanes) => acceptedLink.createDoc(canvas.slug, rel, lanes),
2947
+ });
2948
+ projection.reconcile();
2949
+ } else if (projection) {
2218
2950
  // Phase E (DDR-064 Task 9) — one-time authoritative seed BEFORE
2219
2951
  // materializing: escapes the duplication trap by picking ONE source
2220
2952
  // inside a MIGRATION transaction. DDR-102: body divergence now takes
@@ -2237,6 +2969,9 @@ export function createSyncRuntime(
2237
2969
  }
2238
2970
  },
2239
2971
  onConflict: (info) => store.addConflict(info),
2972
+ // An unmergeable local candidate: keep both, and let the projection
2973
+ // report and hold it from the shared base (T2).
2974
+ onHold: (base) => projection.adoptBase(base),
2240
2975
  hubHasState: (slug) => hubHolds(hubDocIndex, docNameFor(slug)),
2241
2976
  });
2242
2977
  if (result === 'local-adopt') {
@@ -2246,6 +2981,16 @@ export function createSyncRuntime(
2246
2981
  `[sync/${canvas.slug}] shared-doc: not seeding — the hub already holds this ` +
2247
2982
  'document; waiting for its state to arrive.'
2248
2983
  );
2984
+ } else if (result === 'conflict-merged') {
2985
+ console.log(
2986
+ `[sync/${canvas.slug}] shared-doc: merged a local edit with the project's newer ` +
2987
+ `version (both sides were in _history/${canvas.slug}/ first).`
2988
+ );
2989
+ } else if (result === 'conflict-held') {
2990
+ console.warn(
2991
+ `[sync/${canvas.slug}] shared-doc: a local edit overlaps the project's newer version — ` +
2992
+ 'both kept; the canvas waits for a resolving save.'
2993
+ );
2249
2994
  } else if (result === 'conflict-local-wins' || result === 'conflict-hub-wins') {
2250
2995
  console.warn(
2251
2996
  `[sync/${canvas.slug}] shared-doc: diverged — kept the ${
@@ -2344,8 +3089,8 @@ export function createSyncRuntime(
2344
3089
  // REFUSED IS REFUSED, WHATEVER THE CLASS.
2345
3090
  //
2346
3091
  // 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
3092
+ // PERMANENT classes — `generic` and `rate-limit` keep their provider
3093
+ // until the transient retry swaps it, and never enter it. `generic` is what
2349
3094
  // EVERY pre-DDR-102 hub sends. So a refusal in a transient class passed
2350
3095
  // straight through this guard and got overwritten with `connected`
2351
3096
  // (attacker review 2026-09-03, F3): the hub was dropping this document's
@@ -2594,6 +3339,7 @@ export function createSyncRuntime(
2594
3339
  // the same order relative to each other.
2595
3340
  const deferSetup = !!setup && pulledSlugs.has(canvas.slug);
2596
3341
  if (!deferSetup) setup?.(provider);
3342
+ else if (setup) owedSetups.set(canvas.slug, setup);
2597
3343
 
2598
3344
  // Task 8 — feed this provider's WS status into the offline monitor.
2599
3345
  // Per-provider, so a socket that never dropped never triggers a poll.
@@ -2663,6 +3409,8 @@ export function createSyncRuntime(
2663
3409
  // Cold-start reconcile fires once the provider has hub state.
2664
3410
  const synced = provider.onceSynced().then(() => {
2665
3411
  if (deferSetup) {
3412
+ // Settled either way below — run, or the canvas is released.
3413
+ owedSetups.delete(canvas.slug);
2666
3414
  // Abandon before `setup?.()`, so no projection and no agent is ever
2667
3415
  // built for a canvas we are not going to place — nothing exists that
2668
3416
  // could flush the document onto the provisional path on the way out.
@@ -2737,6 +3485,50 @@ export function createSyncRuntime(
2737
3485
  paths: canvasPaths,
2738
3486
  echoGuard,
2739
3487
  journal: journal ?? undefined,
3488
+ historyDir: path.join(ctx.paths.historyDir, canvas.slug),
3489
+ waitForReconcile: true,
3490
+ ...(acceptedLink
3491
+ ? {
3492
+ accepted: acceptedLink.laneLink(canvas.slug),
3493
+ revisionBarrier,
3494
+ replayOp: (op: SourceOp, head: string) =>
3495
+ replaySourceOp(canvas.html, op, head),
3496
+ onAccepted: ({
3497
+ lane,
3498
+ value,
3499
+ actionId,
3500
+ }: {
3501
+ lane: string;
3502
+ value: string;
3503
+ actionId: string;
3504
+ }) => noteAcceptedContent(canvas.slug, lane, value, actionId),
3505
+ onRevisionApplied: (rev: number) => store.noteAppliedRevision?.(rev),
3506
+ }
3507
+ : {}),
3508
+ // A write the hub would drop is held, never made (see isWritable).
3509
+ ...(provider.isWritable
3510
+ ? {
3511
+ canWriteDoc: () => provider.isWritable?.() !== false,
3512
+ onWriteBlocked: () => void refreshAcceptedMode(),
3513
+ }
3514
+ : {}),
3515
+ onRecovered: () => store.clearSourceConflict(canvas.slug),
3516
+ onConflict: (info) => {
3517
+ store.addConflict(info);
3518
+ store.notice({
3519
+ id: `source-conflict-${canvas.slug}`,
3520
+ severity: 'warn',
3521
+ text: `Source sync blocked for ${canvas.slug}. The local file was kept because ${
3522
+ info.reason === 'invalid-source'
3523
+ ? 'the source has syntax errors or duplicate declarations'
3524
+ : info.reason === 'local-edit'
3525
+ ? 'a local edit overlaps an incoming change'
3526
+ : info.reason === 'merge-budget'
3527
+ ? 'the edit is too large to merge safely'
3528
+ : 'a recovery copy could not be saved'
3529
+ }. ${info.snapshotFailed ? 'Recovery could not be saved; check disk space and history write access.' : `Recovery copies are in _history/${canvas.slug}/sync-recovery/.`}`,
3530
+ });
3531
+ },
2740
3532
  // Cell pairing only — see the DocProjectionOptions.onWrote doc.
2741
3533
  // The synthetic event is delayed by the same margin the container
2742
3534
  // write bridge uses, so a watcher that DOES fire wins the race and
@@ -2776,6 +3568,55 @@ export function createSyncRuntime(
2776
3568
  // cleans up.)
2777
3569
  watchForRetirement(canvas.slug, provider.document);
2778
3570
 
3571
+ // THE HUB SAYS THE SAVE MODE CHANGED — on this document's own
3572
+ // socket, ahead of closing it, so the switch is learned before any
3573
+ // further local change is made (DDR-241 §7 switch ordering).
3574
+ if (acceptedLink && provider.onStateless) {
3575
+ noteDetach(
3576
+ statusDetaches,
3577
+ canvas.slug,
3578
+ provider.onStateless((payload) => {
3579
+ let msg: { type?: unknown; mode?: unknown } | null = null;
3580
+ try {
3581
+ msg = JSON.parse(payload);
3582
+ } catch {
3583
+ return;
3584
+ }
3585
+ if (msg?.type !== 'maude.mode') return;
3586
+ if (msg.mode === 'transactions' || msg.mode === 'legacy') {
3587
+ acceptedLink.noteMode(msg.mode);
3588
+ void refreshAcceptedMode();
3589
+ }
3590
+ })
3591
+ );
3592
+ }
3593
+ if (provider.onAuthenticated) {
3594
+ const slug = canvas.slug;
3595
+ noteDetach(
3596
+ statusDetaches,
3597
+ slug,
3598
+ provider.onAuthenticated(() => projections.get(slug)?.retryDeferred())
3599
+ );
3600
+ }
3601
+
3602
+ // ACCEPTED-REPLICA TRIPWIRE (DDR-241 §7, plan T6/T12). In
3603
+ // transactions mode the hub drops every update this connection
3604
+ // sends, so a local write here never reaches the project and the
3605
+ // replica silently diverges. Nothing is supposed to do it; this is
3606
+ // how we find out if something does.
3607
+ if (acceptedLink && provider.isRemoteOrigin) {
3608
+ const slug = canvas.slug;
3609
+ const onAny = (_u: Uint8Array, origin: unknown) => {
3610
+ if (!acceptedOn() || provider.isRemoteOrigin?.(origin)) return;
3611
+ acceptedWriteViolations += 1;
3612
+ console.error(
3613
+ `[sync/${slug}] a local write reached the accepted replica (origin: ${describeOrigin(origin)}) — it cannot reach the project.`
3614
+ );
3615
+ };
3616
+ provider.document.on('update', onAny);
3617
+ noteDetach(statusDetaches, slug, () => provider.document.off('update', onAny));
3618
+ }
3619
+
2779
3620
  // Count local edits (agent-origin doc updates) toward queuedOps while
2780
3621
  // the hub is unreachable — the banner's "N edits queued" figure. Under
2781
3622
  // sharedDoc there is no agent origin to key off (browser edits carry a
@@ -2986,6 +3827,17 @@ export function createSyncRuntime(
2986
3827
  if (typeof slug !== 'string' || !slug) return;
2987
3828
  if (revive) tombstoned.delete(slug);
2988
3829
  else tombstoned.add(slug);
3830
+ if (acceptedOn() && acceptedLink) {
3831
+ // A re-created canvas is proposed by its cold start (`doc.create`); a
3832
+ // deletion is ONE project action unless a folder action covered it.
3833
+ if (revive || coveredByFolderAction.delete(slug)) return;
3834
+ void acceptedLink.deleteDoc(slug).then((r) => {
3835
+ if (r.status === 'rejected' && r.code !== 'dependency-missing') {
3836
+ console.warn(`[sync] the project did not accept deleting ${slug} (${r.code})`);
3837
+ }
3838
+ });
3839
+ return;
3840
+ }
2989
3841
  void stateDocumentGone(linkedHub.url, token, docNameFor(slug), { revive }).then((ok) => {
2990
3842
  if (!ok) {
2991
3843
  console.warn(
@@ -3038,6 +3890,13 @@ export function createSyncRuntime(
3038
3890
  annotations: canvas.annotations,
3039
3891
  },
3040
3892
  });
3893
+ if (!existsSync(canvas.html)) {
3894
+ ctx.bus.emit('canvas-list-update', {
3895
+ action: 'removed',
3896
+ rel: path.relative(ctx.paths.designRoot, canvas.html).split(path.sep).join('/'),
3897
+ slug,
3898
+ });
3899
+ }
3041
3900
  }
3042
3901
  descriptors.delete(slug);
3043
3902
  }
@@ -3064,11 +3923,18 @@ export function createSyncRuntime(
3064
3923
  // not told the document exists.
3065
3924
  const pullRemoteOnce = async (): Promise<void> => {
3066
3925
  if (stopped) return;
3926
+ // Accepted revisions: the save mode can change under a running peer (the
3927
+ // hub fences every socket when it does), and folders are manifest
3928
+ // entries — neither is in the document listing.
3929
+ if (acceptedLink) {
3930
+ await acceptedLink.refresh();
3931
+ if (acceptedOn()) applyProjectDirs(acceptedLink.manifest?.dirs ?? []);
3932
+ }
3067
3933
  // Read `token` at call time: a silent renewal swaps it in place.
3068
- const listing = await fetchRemoteListing(linkedHub.url, token);
3934
+ const listing = withAcceptedDocs(await fetchRemoteListing(linkedHub.url, token));
3069
3935
  // null = unreachable, refused, or a hub without the route. Not an error
3070
3936
  // here any more than it is at boot — sync continues, we ask again later.
3071
- if (listing === null) return;
3937
+ if (stopped || listing === null) return;
3072
3938
  // ABSENCE BEFORE PRESENCE. A canvas the project deleted must leave before
3073
3939
  // the pull runs, or a slug that is tombstoned AND still listed (the window
3074
3940
  // between the tombstone and the row actually going) would be trashed and
@@ -3402,13 +4268,20 @@ export function createSyncRuntime(
3402
4268
  });
3403
4269
  noteFilePull(result);
3404
4270
  };
4271
+ documentDiscovery = createDocumentDiscovery({
4272
+ run: pullRemoteOnce,
4273
+ onError: (error) => console.error('[sync] document discovery failed:', error),
4274
+ });
4275
+ const discovery = documentDiscovery;
4276
+ documentDiscoveryUnsub = ctx.bus.on('sync:documents-changed', () => discovery.schedule());
3405
4277
  const pollRemote = (): void => {
3406
- void pullRemoteOnce()
4278
+ void discovery
4279
+ .flush()
3407
4280
  .then(() => pullFilesOnce())
3408
4281
  .catch((err) => console.error('[sync] remote poll failed:', err));
3409
4282
  };
3410
4283
  remotePull = async () => {
3411
- await pullRemoteOnce();
4284
+ await discovery.flush();
3412
4285
  await pullFilesOnce();
3413
4286
  };
3414
4287
  remotePollTimer = setInterval(pollRemote, REMOTE_POLL_MS);
@@ -3441,7 +4314,11 @@ export function createSyncRuntime(
3441
4314
  // • `state === 'online'` — offline already has its own recovery path.
3442
4315
  // • `synced === 0 && pending > 0` — some progress means it is working,
3443
4316
  // just slowly; this is only for the total stall.
3444
- // • `rejected === 0` — a refusal is the auth lane's (renew + re-probe).
4317
+ // • `rejected === 0` — a refusal is the auth lane's: renew + re-probe for
4318
+ // the permanent classes, the paced per-document retry for the
4319
+ // transient ones. Cycling the socket would re-authenticate EVERY
4320
+ // document to recover a few, and a volume refusal is exactly when
4321
+ // that burst hurts most.
3445
4322
  // • a floor between forced reconnects, so a hub that is simply down
3446
4323
  // cannot be turned into a reconnect storm by its own silence.
3447
4324
  // The socket-cycling capability, resolved once.
@@ -3574,6 +4451,7 @@ export function createSyncRuntime(
3574
4451
  fileEventsCtl = createCtlProvider({
3575
4452
  url: linkedHub.url,
3576
4453
  token,
4454
+ onDocuments: () => documentDiscovery?.schedule(),
3577
4455
  onPoke: () => {
3578
4456
  // Reuses `pollRemoteSoon` rather than calling the file lanes
3579
4457
  // directly, for two reasons: it already coalesces a burst into
@@ -3614,6 +4492,8 @@ export function createSyncRuntime(
3614
4492
  async function stop(): Promise<void> {
3615
4493
  if (stopped) return;
3616
4494
  stopped = true;
4495
+ acceptedLink?.stop();
4496
+ revisionBarrier.stop();
3617
4497
  fileEventsProbe?.abort();
3618
4498
  fileEventsProbe = null;
3619
4499
  if (filePassTimer !== null) clearTimeout(filePassTimer);
@@ -3650,6 +4530,10 @@ export function createSyncRuntime(
3650
4530
  stallTimer = null;
3651
4531
  if (remotePollSoonTimer !== null) clearTimeout(remotePollSoonTimer);
3652
4532
  remotePollSoonTimer = null;
4533
+ documentDiscoveryUnsub?.();
4534
+ documentDiscoveryUnsub = null;
4535
+ documentDiscovery?.stop();
4536
+ documentDiscovery = null;
3653
4537
  remotePull = null;
3654
4538
  // A push pass that outlives its runtime keeps uploading a project the
3655
4539
  // person just closed — and `restart()` (the Resync button) calls stop() on
@@ -3688,6 +4572,10 @@ export function createSyncRuntime(
3688
4572
  authClearTimer(reprobeTimer);
3689
4573
  reprobeTimer = null;
3690
4574
  }
4575
+ if (transientRetryTimer !== null) {
4576
+ authClearTimer(transientRetryTimer);
4577
+ transientRetryTimer = null;
4578
+ }
3691
4579
  if (renewTimer !== null) {
3692
4580
  authClearTimer(renewTimer);
3693
4581
  renewTimer = null;
@@ -3697,8 +4585,13 @@ export function createSyncRuntime(
3697
4585
  for (const h of announceTimers.values()) clearTimeout(h);
3698
4586
  announceTimers.clear();
3699
4587
  rejectedPermanent.clear();
4588
+ rejectedTransient.clear();
4589
+ transientStrikes.clear();
4590
+ transientSpent.length = 0;
4591
+ owedSetups.clear();
3700
4592
  busUnsub?.();
3701
4593
  busUnsub = null;
4594
+ for (const u of activityUnsubs.splice(0)) u();
3702
4595
  fsReader?.stop();
3703
4596
  fsReader = null;
3704
4597
  for (const agent of agents.values()) {
@@ -3783,6 +4676,145 @@ export function createSyncRuntime(
3783
4676
  return true;
3784
4677
  },
3785
4678
  retireForMove: (fromSlug, toRel) => serializeMembership(() => retireForMove(fromSlug, toRel)),
4679
+ proposeLane: (slug, lane, text, o) => {
4680
+ if (!acceptedOn()) return null;
4681
+ const projection = projections.get(slug);
4682
+ if (!projection) return null;
4683
+ const value = laneValueFromFile(lane, text);
4684
+ if (value === null) return null;
4685
+ const base = o?.baseText === undefined ? undefined : laneValueFromFile(lane, o.baseText);
4686
+ return projection.proposeLane(lane, value, {
4687
+ ...(base !== undefined && base !== null ? { baseContent: base } : {}),
4688
+ ...(o?.writeId ? { writeId: o.writeId } : {}),
4689
+ });
4690
+ },
4691
+ proposeFolder,
4692
+ acceptedMode: acceptedOn,
4693
+ acceptedWriteViolations: () => acceptedWriteViolations,
4694
+ acceptedHistory: async (q) => {
4695
+ if (!acceptedOn() || !acceptedLink) return null;
4696
+ // Fresh, not the cached manifest: a canvas created since the last
4697
+ // bootstrap has history too, and a stale map named no entry for it.
4698
+ const manifest = (await acceptedLink.refresh().catch(() => null)) ?? acceptedLink.manifest;
4699
+ if (!manifest) return null;
4700
+ const pathOf = new Map(manifest.docs.map((d) => [d.doc, d.path]));
4701
+ let entry: string | null = null;
4702
+ if (q.path) {
4703
+ const slug = slugForRepoRel(q.path);
4704
+ const doc = slug ? docNameFor(slug) : null;
4705
+ entry = manifest.docs.find((d) => d.doc === doc)?.entry ?? null;
4706
+ if (!entry) return [];
4707
+ }
4708
+ const { history } = await acceptedLink.history({
4709
+ limit: Math.min(Math.max(q.limit ?? 50, 1), 200),
4710
+ before: q.before ?? null,
4711
+ entry,
4712
+ });
4713
+ const you = manifest.you?.actor ?? null;
4714
+ return history.map((a) => {
4715
+ const canvases = new Set<string>();
4716
+ for (const e of a.effects) {
4717
+ const p = e.afterPath ?? (e.doc ? pathOf.get(e.doc) : null) ?? e.beforePath;
4718
+ if (p && /\.(tsx|html)$/i.test(p)) canvases.add(p);
4719
+ }
4720
+ return {
4721
+ revision: a.revision,
4722
+ actionId: a.actionId,
4723
+ actor: a.actor,
4724
+ mine: you !== null && a.actor === you,
4725
+ kind: a.kind,
4726
+ label: a.label,
4727
+ committedAt: a.committedAt,
4728
+ undoes: a.undoes,
4729
+ canvases: [...canvases].sort(),
4730
+ };
4731
+ });
4732
+ },
4733
+ acceptedVersion: async (repoRel, revision) => {
4734
+ if (!acceptedOn() || !acceptedLink) return null;
4735
+ const slug = slugForRepoRel(repoRel);
4736
+ if (!slug) return null;
4737
+ const r = await acceptedLink.laneAt(slug, 'html', revision);
4738
+ return r.body || null;
4739
+ },
4740
+ acceptedRestore: async (repoRel, revision) => {
4741
+ if (!acceptedOn() || !acceptedLink) return null;
4742
+ const slug = slugForRepoRel(repoRel);
4743
+ if (!slug) return { status: 'rejected', code: 'unknown-canvas' };
4744
+ const name = path.basename(repoRel).replace(/\.(tsx|html)$/i, '');
4745
+ return acceptedLink.restore([slug], revision, `Restore ${name} to version ${revision}`);
4746
+ },
4747
+ acceptedUndo: async (actionId, redo = false) => {
4748
+ if (!acceptedOn() || !acceptedLink) return null;
4749
+ return acceptedLink.undo(actionId, redo);
4750
+ },
4751
+ prepareOffline: async () => {
4752
+ if (!filePlane) return null;
4753
+ let pulled = 0;
4754
+ let passes = 0;
4755
+ let failed = 0;
4756
+ for (; passes < 40 && !stopped; passes += 1) {
4757
+ lastPlaneResult = null;
4758
+ await runPlanePass({ floor: false });
4759
+ const r = lastPlaneResult as import('./file-plane.ts').FilePlaneResult | null;
4760
+ if (!r) break;
4761
+ pulled += r.pulled.length;
4762
+ failed = r.failed.length;
4763
+ if (r.pulled.length === 0 && !r.budgetExhausted) {
4764
+ return { complete: failed === 0, pulled, passes: passes + 1, failed };
4765
+ }
4766
+ }
4767
+ return { complete: false, pulled, passes, failed };
4768
+ },
4769
+ acceptedActionForContent: (repoRel, content) => {
4770
+ const slug = slugForRepoRel(repoRel);
4771
+ return slug ? (acceptedByContent.get(`${slug}|html|${hashBytes(content)}`) ?? null) : null;
4772
+ },
4773
+ conflictVersions: (repoRel) => {
4774
+ // A slug (the Sync panel's notice id) or a repo-relative canvas path.
4775
+ const slug = projections.has(repoRel) ? repoRel : slugForRepoRel(repoRel);
4776
+ const sides = slug ? projections.get(slug)?.conflictSides() : null;
4777
+ return slug && sides ? { slug, ...sides } : null;
4778
+ },
4779
+ resolveConflict: async (repoRel, choice) => {
4780
+ const slug = projections.has(repoRel) ? repoRel : slugForRepoRel(repoRel);
4781
+ const projection = slug ? projections.get(slug) : undefined;
4782
+ if (!slug || !projection) return null;
4783
+ const sides = projection.conflictSides();
4784
+ if (!sides) return null;
4785
+ if (choice === 'theirs') {
4786
+ projection.takeAccepted();
4787
+ return { status: 'taken' };
4788
+ }
4789
+ if (sides.mine === null) return { status: 'rejected', code: 'no-local-version' };
4790
+ // Mine, on top of the version that won — a new action, never a rewind.
4791
+ const r = await projection.proposeLane('html', sides.mine, { baseContent: sides.theirs });
4792
+ if (!r) return null;
4793
+ if (r.status === 'accepted') statusStore?.clearSourceConflict?.(slug);
4794
+ return { status: r.status, ...(r.code ? { code: r.code } : {}) };
4795
+ },
4796
+ beginAiAction: (key, label) => {
4797
+ if (!acceptedOn() || !acceptedLink) return;
4798
+ acceptedLink.stage.begin(key, label);
4799
+ },
4800
+ endAiAction: async (key, outcome) => {
4801
+ if (!acceptedLink) return null;
4802
+ // The watcher delivers the last tool write a beat after the tool
4803
+ // returns; let it land in the stage before the stage closes.
4804
+ await new Promise((r) => setTimeout(r, AI_ACTION_SETTLE_MS));
4805
+ await (discoveryRescan?.flush() ?? Promise.resolve()).catch(() => {});
4806
+ const r = await acceptedLink.stage.end(key, outcome);
4807
+ return r ? { status: r.status, ...(r.code ? { code: r.code } : {}) } : null;
4808
+ },
4809
+ resolveAiAction: async (choice) => {
4810
+ if (!acceptedLink || acceptedLink.stage.state !== 'held') return null;
4811
+ if (choice === 'discard') {
4812
+ const canvases = acceptedLink.stage.discard();
4813
+ return { status: 'discarded', canvases };
4814
+ }
4815
+ const r = await acceptedLink.stage.publish();
4816
+ return r ? { status: r.status, ...(r.code ? { code: r.code } : {}) } : null;
4817
+ },
3786
4818
  };
3787
4819
  }
3788
4820
 
@@ -4468,11 +5500,37 @@ export function createDefaultProviderFactory(
4468
5500
  };
4469
5501
  socket.on('status', resetSyncedOnDrop);
4470
5502
 
5503
+ let authedThisConnection = false;
5504
+ provider.on('authenticated', () => {
5505
+ authedThisConnection = true;
5506
+ });
5507
+ const forgetAuthOnDrop = (evt: { status?: string }) => {
5508
+ if (evt?.status !== 'connected') authedThisConnection = false;
5509
+ };
5510
+ socket.on('status', forgetAuthOnDrop);
4471
5511
  return {
4472
5512
  document,
4473
5513
  // HocuspocusProvider creates a hub-synced Awareness by default; expose it
4474
5514
  // so the runtime can bridge it to the collab Room (Task 5).
4475
5515
  awareness: provider.awareness as Awareness | undefined,
5516
+ isRemoteOrigin: (origin: unknown) => origin === provider,
5517
+ // Writable = authenticated read-write ON THIS CONNECTION. A scope left
5518
+ // over from before a drop says nothing about the hub now: a project
5519
+ // switched to accepted revisions while this peer was away re-admits it
5520
+ // read-only, and anything written in between would be dropped there.
5521
+ isWritable: () => authedThisConnection && provider.authorizedScope === 'read-write',
5522
+ onAuthenticated(cb: (scope: string) => void): () => void {
5523
+ const handler = (evt: { scope?: string }) => cb(String(evt?.scope ?? ''));
5524
+ provider.on('authenticated', handler);
5525
+ return () => provider.off('authenticated', handler);
5526
+ },
5527
+ onStateless(cb: (payload: string) => void): () => void {
5528
+ const handler = (evt: { payload?: string }) => {
5529
+ if (typeof evt?.payload === 'string') cb(evt.payload);
5530
+ };
5531
+ provider.on('stateless', handler);
5532
+ return () => provider.off('stateless', handler);
5533
+ },
4476
5534
  onStatus(cb: (status: ProviderStatus) => void): () => void {
4477
5535
  // The shared socket emits 'status' on every WS transition and every
4478
5536
  // ATTACHED provider re-emits it (forwardStatus), so per-provider
@@ -4523,6 +5581,7 @@ export function createDefaultProviderFactory(
4523
5581
  },
4524
5582
  destroy() {
4525
5583
  socket.off('status', resetSyncedOnDrop);
5584
+ socket.off('status', forgetAuthOnDrop);
4526
5585
  // Detaches from the shared socket (sends a per-document Close); the
4527
5586
  // socket itself is destroyed by dispose() after all providers.
4528
5587
  provider.destroy();