@1agh/maude 0.55.0 → 0.56.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 (47) hide show
  1. package/apps/studio/bin/_smart-frames.mjs +187 -20
  2. package/apps/studio/bin/_smart-frames.test.mjs +59 -4
  3. package/apps/studio/bin/_transcribe.mjs +40 -3
  4. package/apps/studio/bin/smoke.sh +7 -1
  5. package/apps/studio/client/app.jsx +31 -3
  6. package/apps/studio/client/panels/CloudBar.jsx +44 -5
  7. package/apps/studio/client/panels/GitPanel.jsx +38 -25
  8. package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
  9. package/apps/studio/client/styles/4-components.css +72 -0
  10. package/apps/studio/cloud/endpoints.ts +56 -3
  11. package/apps/studio/collab/persistence.ts +29 -2
  12. package/apps/studio/config.schema.json +3 -3
  13. package/apps/studio/context.ts +41 -0
  14. package/apps/studio/dist/client.bundle.js +1464 -1464
  15. package/apps/studio/dist/comment-mount.js +2 -2
  16. package/apps/studio/dist/styles.css +1 -1
  17. package/apps/studio/generation/gemma-models.ts +312 -14
  18. package/apps/studio/generation/prefs.ts +7 -2
  19. package/apps/studio/generation/runtime-probe.ts +50 -0
  20. package/apps/studio/generation/whisper-models.ts +124 -0
  21. package/apps/studio/hmr-broadcast.ts +67 -0
  22. package/apps/studio/http.ts +196 -109
  23. package/apps/studio/input-router.tsx +55 -2
  24. package/apps/studio/server.ts +11 -9
  25. package/apps/studio/sync/autocommit.ts +61 -2
  26. package/apps/studio/sync/cell-pairing.ts +174 -0
  27. package/apps/studio/sync/codec.ts +11 -5
  28. package/apps/studio/sync/index.ts +239 -26
  29. package/apps/studio/sync/limits.ts +49 -0
  30. package/apps/studio/sync/loopback.ts +21 -0
  31. package/apps/studio/sync/projection.ts +47 -12
  32. package/apps/studio/sync/supervisor.ts +178 -0
  33. package/apps/studio/test/cloud-endpoints.test.ts +95 -0
  34. package/apps/studio/test/csrf-write-guard.test.ts +19 -2
  35. package/apps/studio/test/gemma-models.test.ts +245 -0
  36. package/apps/studio/test/hmr-broadcast.test.ts +57 -1
  37. package/apps/studio/test/input-router.test.ts +95 -0
  38. package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
  39. package/apps/studio/test/sync-autocommit.test.ts +47 -0
  40. package/apps/studio/test/sync-supervisor.test.ts +212 -0
  41. package/apps/studio/test/trusted-request-host.test.ts +66 -0
  42. package/apps/studio/test/whisper-setup.test.ts +97 -0
  43. package/apps/studio/whats-new.json +27 -0
  44. package/apps/studio/ws.ts +9 -1
  45. package/cli/commands/kg.mjs +9 -2
  46. package/package.json +8 -8
  47. package/plugins/design/dependencies.json +21 -3
@@ -407,6 +407,45 @@ export function isOverlayTarget(t: EventTarget | null): boolean {
407
407
  );
408
408
  }
409
409
 
410
+ /**
411
+ * Artboard "chrome" — inside an artboard (`[data-dc-screen]`) but OUTSIDE its
412
+ * `.dc-artboard-body` content box, i.e. the label strip + border that
413
+ * `use-artboard-drag.tsx` binds its own bubble-phase `onPointerDown` to
414
+ * (`bindHandle()`, spread directly on the `.dc-artboard` article). The router
415
+ * runs in the CAPTURE phase ahead of that handler; claiming a bare press here
416
+ * — same as any other chrome hit, the router still selects the artboard —
417
+ * used to `stopImmediatePropagation` the native event before it ever reached
418
+ * the drag hook's listener, so no artboard could be repositioned by its
419
+ * header at all (issue-71). A modifier-held press is not exempted: the drag
420
+ * hook itself ignores Cmd/Ctrl (reserved for deep-select), so nothing
421
+ * downstream needs the raw event in that case.
422
+ */
423
+ export function isArtboardDragChrome(t: EventTarget | null): boolean {
424
+ if (!t || typeof (t as Element).closest !== 'function') return false;
425
+ const el = t as Element;
426
+ return !!el.closest('[data-dc-screen]') && !el.closest('.dc-artboard-body');
427
+ }
428
+
429
+ /**
430
+ * Should the router's pointerdown/mousedown handler skip
431
+ * `stopImmediatePropagation` so the event still reaches use-artboard-drag.tsx?
432
+ * Pulled out of the two near-identical handlers below so the decision itself
433
+ * — not just `isArtboardDragChrome`'s target test — is covered by a DOM-free
434
+ * unit test (issue-71).
435
+ */
436
+ export function yieldsToArtboardDrag(
437
+ action: RouterAction,
438
+ target: EventTarget | null,
439
+ modifiers: { metaKey: boolean; ctrlKey: boolean }
440
+ ): boolean {
441
+ return (
442
+ action.kind === 'select' &&
443
+ !modifiers.metaKey &&
444
+ !modifiers.ctrlKey &&
445
+ isArtboardDragChrome(target)
446
+ );
447
+ }
448
+
410
449
  export function useInputRouter(opts: UseInputRouterOptions): void {
411
450
  const { hostRef, getActiveTool, isSpaceHeld, callbacks, enabled = true, claimableActions } = opts;
412
451
 
@@ -496,13 +535,22 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
496
535
  activeTool: getActiveTool(),
497
536
  })
498
537
  );
538
+ // issue-71 — a bare press on an artboard's own drag chrome must still
539
+ // reach that artboard's `onPointerDown` (use-artboard-drag.tsx) so its
540
+ // pending→dragging state machine can arm, or no artboard can ever be
541
+ // repositioned by its header. The router still SELECTS (dispatch below
542
+ // is unconditional) and still preventDefaults the browser's native
543
+ // click-drag text-selection — it only skips stopImmediatePropagation,
544
+ // which is the one call that would keep the event from ever reaching
545
+ // the bubble-phase listener bound to the very element it targeted.
546
+ const yields = yieldsToArtboardDrag(action, e.target, e);
499
547
  if (action.kind !== 'no-op') {
500
548
  // Suppress native behavior on every event the router claims —
501
549
  // button presses don't fire, inputs don't focus, the canvas
502
550
  // content's own click handlers don't run. The router lives in
503
551
  // capture phase so this fires before descendants.
504
552
  e.preventDefault();
505
- e.stopImmediatePropagation();
553
+ if (!yields) e.stopImmediatePropagation();
506
554
  }
507
555
  dispatch(action);
508
556
  };
@@ -531,9 +579,14 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
531
579
  activeTool: getActiveTool(),
532
580
  })
533
581
  );
582
+ // issue-71 — same exemption as onPointerDown above: preventDefault still
583
+ // runs (blocks the native click-drag text-selection an un-prevented
584
+ // mousedown would start), but stopImmediatePropagation is skipped so the
585
+ // paired pointerdown reaching use-artboard-drag.tsx isn't undone here.
586
+ const yields = yieldsToArtboardDrag(action, e.target, e);
534
587
  if (action.kind !== 'no-op') {
535
588
  e.preventDefault();
536
- e.stopImmediatePropagation();
589
+ if (!yields) e.stopImmediatePropagation();
537
590
  // preventDefault above stops mousedown's default FOCUS, so a Cmd-click
538
591
  // select would leave the iframe unfocused and every in-canvas keyboard
539
592
  // shortcut (arrow-nudge, Cmd+D, copy/paste-style, Delete) dead until a
@@ -34,7 +34,7 @@ import { createHttp } from './http.ts';
34
34
  import { createInspectRegistry } from './inspect.ts';
35
35
  import { startHeapWatch } from './mem.ts';
36
36
  import { normalizeSessionKey, runInSession, SESSION_HEADER } from './session-scope.ts';
37
- import { createSyncRuntime } from './sync/index.ts';
37
+ import { createSyncSupervisor } from './sync/supervisor.ts';
38
38
  import {
39
39
  assertContainment,
40
40
  isForbiddenRoute,
@@ -680,13 +680,15 @@ ctx.bus.on('fs:json', (rel: string) => {
680
680
  // Phase 9 Task 4 — bidirectional sync agent. No-op when the project isn't
681
681
  // linked to a hub (`.design/config.json` has no `linkedHub` field). Kicked
682
682
  // off after fsWatch so the agent's bus subscription receives every fs event.
683
- const syncRuntime = createSyncRuntime(ctx, collab ? { registry: collab.registry } : {});
684
- if (syncRuntime) {
685
- try {
686
- await syncRuntime.start();
687
- } catch (err) {
688
- console.error('[sync] startup failed — continuing in solo mode:', err);
689
- }
683
+ // Owned by a supervisor rather than started inline: linking a project from the
684
+ // cloud panel cycles the runtime in place (ctx.syncControl), so pressing
685
+ // Connect starts syncing instead of printing "restart the studio server".
686
+ const syncRuntime = createSyncSupervisor(ctx, collab ? { registry: collab.registry } : {});
687
+ ctx.syncControl = syncRuntime;
688
+ try {
689
+ await syncRuntime.start();
690
+ } catch (err) {
691
+ console.error('[sync] startup failed — continuing in solo mode:', err);
690
692
  }
691
693
 
692
694
  const url = `http://localhost:${server.port}`;
@@ -750,7 +752,7 @@ async function shutdown() {
750
752
  /* timer cleanup is best-effort */
751
753
  }
752
754
  try {
753
- if (syncRuntime) await syncRuntime.stop();
755
+ await syncRuntime.stop();
754
756
  } catch {
755
757
  /* best-effort — provider sockets will be closed by process exit anyway */
756
758
  }
@@ -32,6 +32,16 @@ import { withRepoLock } from '../git/repo-lock.ts';
32
32
  /** How long the tree must be quiet before a commit fires. */
33
33
  const DEFAULT_DEBOUNCE_MS = 3000;
34
34
 
35
+ /**
36
+ * The ceiling on that quiescence — how long a PENDING batch may be deferred by
37
+ * a continuing stream of notes before it commits anyway. Without it, quiescence
38
+ * batching has no upper bound and a steady note cadence defers history forever
39
+ * (see `note`). Generous enough that ordinary typing still coalesces into one
40
+ * commit, small enough that a cell's ephemeral disk is never far ahead of its
41
+ * history.
42
+ */
43
+ const DEFAULT_MAX_DEBOUNCE_MS = 15_000;
44
+
35
45
  export interface GitRunResult {
36
46
  code: number;
37
47
  stdout: string;
@@ -53,6 +63,12 @@ export interface AutoCommitOptions {
53
63
  run: GitRunner;
54
64
  /** Quiescence window. */
55
65
  debounceMs?: number;
66
+ /**
67
+ * Ceiling on that quiescence: a pending batch commits after at most this long
68
+ * from its first note, however many notes keep arriving. Bounds the
69
+ * "steady stream defers history forever" case — see `note`.
70
+ */
71
+ maxDebounceMs?: number;
56
72
  /** Committer identity — the machine, never the human. */
57
73
  bot?: EditAttribution;
58
74
  log?: Pick<Console, 'warn' | 'error' | 'log'>;
@@ -154,6 +170,7 @@ export function createAutoCommit(opts: AutoCommitOptions): AutoCommit {
154
170
  repoRoot,
155
171
  run,
156
172
  debounceMs = DEFAULT_DEBOUNCE_MS,
173
+ maxDebounceMs = DEFAULT_MAX_DEBOUNCE_MS,
157
174
  bot = DEFAULT_BOT,
158
175
  log = console,
159
176
  } = opts;
@@ -163,9 +180,13 @@ export function createAutoCommit(opts: AutoCommitOptions): AutoCommit {
163
180
  let timer: ReturnType<typeof setTimeout> | null = null;
164
181
  let inFlight: Promise<CommitOutcome | null> | null = null;
165
182
  let stopped = false;
183
+ /** When the currently-pending batch received its first note (see `note`). */
184
+ let batchStartedAt = 0;
166
185
 
167
186
  function note(relPath: string, who?: EditAttribution | null): void {
168
187
  if (stopped) return;
188
+ // Start of a batch — the clock the ceiling below is measured against.
189
+ if (touched.size === 0) batchStartedAt = Date.now();
169
190
  touched.add(relPath);
170
191
  // Last writer wins for attribution. A commit that coalesced two people's
171
192
  // edits can only name one author; the message body names them, and the
@@ -173,6 +194,23 @@ export function createAutoCommit(opts: AutoCommitOptions): AutoCommit {
173
194
  // that keeps this history readable.
174
195
  if (who) author = who;
175
196
  if (timer) clearTimeout(timer);
197
+ // QUIESCENCE, BUT WITH A CEILING. Resetting the timer on every note is what
198
+ // makes a typing session one commit instead of four hundred — and, alone,
199
+ // it means a steady stream of notes defers the commit forever. That is not
200
+ // hypothetical here: the hub notes on every stored document, Hocuspocus
201
+ // stores on its own ~2 s debounce, and anyone who can drive doc updates at
202
+ // a cadence under `debounceMs` — including a VIEWER, who deliberately holds
203
+ // the `comment` capability — can hold a project's history open indefinitely
204
+ // while `/health` stays green. Before the commit set stopped being "files
205
+ // we just wrote", a no-write store event returned before reaching here, so
206
+ // the reachable cadence was bounded by real edits.
207
+ //
208
+ // So: still coalesce, but never defer a pending batch past `maxDebounceMs`
209
+ // from its first note. Worst-case commit latency becomes bounded and small,
210
+ // and the batching behaviour for ordinary typing is unchanged (a human
211
+ // pauses long before the ceiling).
212
+ const sinceBatchStart = Date.now() - batchStartedAt;
213
+ const delay = Math.max(0, Math.min(debounceMs, maxDebounceMs - sinceBatchStart));
176
214
  timer = setTimeout(() => {
177
215
  timer = null;
178
216
  // `.catch` and not just `void`: a discarded rejection here is an
@@ -180,7 +218,7 @@ export function createAutoCommit(opts: AutoCommitOptions): AutoCommit {
180
218
  flush().catch((err) => {
181
219
  log.warn?.(`[autocommit] flush failed: ${err instanceof Error ? err.message : err}`);
182
220
  });
183
- }, debounceMs);
221
+ }, delay);
184
222
  }
185
223
 
186
224
  async function flush(): Promise<CommitOutcome | null> {
@@ -242,12 +280,33 @@ export function createAutoCommit(opts: AutoCommitOptions): AutoCommit {
242
280
  const add = await run(['add', '--', ...files], { cwd: repoRoot });
243
281
  if (add.code !== 0) {
244
282
  log.warn?.(`[autocommit] git add failed: ${add.stderr.trim()}`);
283
+ // RE-QUEUE, exactly as the `commit` branch below does. `touched` was
284
+ // cleared by `flush()` before we got here, so returning without this
285
+ // drops the whole coalesced batch — including other people's edits in
286
+ // the same window — from history, permanently and silently.
287
+ // One bad pathspec is enough: `git add -- <path that vanished>` exits
288
+ // 128, and paths are now noted on the strength of a read taken up to
289
+ // `debounceMs` earlier, for files whose lifetime this process does not
290
+ // own (a canvas deleted inside the quiescence window).
291
+ for (const f of files) touched.add(f);
245
292
  return { ok: false, reason: 'git-failed', detail: add.stderr.trim(), files };
246
293
  }
247
294
 
248
295
  // Nothing staged ⇒ the write was a no-op (an echo, or identical bytes).
249
296
  // Not an error, and committing an empty change would be noise.
250
- const staged = await run(['diff', '--cached', '--name-only'], { cwd: repoRoot });
297
+ //
298
+ // SCOPED TO `files`, and that pathspec is load-bearing. The question is
299
+ // "did OUR paths stage anything", and an unrelated entry sitting in the
300
+ // index answers it wrongly — the probe says yes, then `commit --only --
301
+ // <our unchanged paths>` exits non-zero with "no changes added to commit".
302
+ // `touched` re-queues but nothing re-arms the timer, so the agent wedges
303
+ // and fails identically forever while `/health` keeps answering 200.
304
+ // Reachable since the commit set stopped being "files we just wrote": a
305
+ // batch of exclusively-unchanged paths is now routine under cell pairing,
306
+ // where the studio child's projector writes the bytes first.
307
+ const staged = await run(['diff', '--cached', '--name-only', '--', ...files], {
308
+ cwd: repoRoot,
309
+ });
251
310
  if (staged.code === 0 && staged.stdout.trim() === '') {
252
311
  return { ok: false, reason: 'nothing-to-commit', files };
253
312
  }
@@ -0,0 +1,174 @@
1
+ // Desktop ↔ cloud live pairing, variant C2 — the ONE exception to "a cell does
2
+ // not sync" (DDR-209 / Cloud Phase 27 D2).
3
+ //
4
+ // THE PROBLEM THIS OPENS A DOOR FOR. A cell holds two disjoint Y.Doc worlds: the
5
+ // hub's Hocuspocus documents (which the DESKTOP syncs to, SQLite-backed) and the
6
+ // studio child's own collab rooms (which BROWSERS join, in-memory). Nothing
7
+ // crosses. Two people on the same project — one in the app, one in a tab — never
8
+ // see each other's cursors and never see each other's edits, which is the
9
+ // headline of the cloud product.
10
+ //
11
+ // WHY THE DOOR IS SHAPED LIKE THIS. DDR-209 forbids the studio child from
12
+ // syncing out, for two named reasons, and BOTH are preserved literally here
13
+ // rather than argued away:
14
+ //
15
+ // 1. "dial OUT from the cell to a third-party hub carrying the project's
16
+ // canvases" — so the URL must be LOOPBACK. Not "usually loopback", not
17
+ // "loopback in production": the resolver refuses anything else, and the
18
+ // tenant's own `.design/config.json linkedHub.url` is never read for it.
19
+ // A cell talking to 127.0.0.1 is a process talking to itself.
20
+ // 2. "start a SECOND autocommit over the working tree the hub is already
21
+ // committing" — so autocommit must be explicitly DISABLED, by a flag the
22
+ // caller has to set on purpose. Default-absent means refuse.
23
+ //
24
+ // A third condition is ours, not DDR-209's, and it narrows further: shared-doc
25
+ // must be ON. Without it the loopback provider would open a SECOND Y.Doc per
26
+ // canvas beside the room's — the two-doc world this pairing exists to collapse —
27
+ // and its agent would become a second doc→file writer for no benefit at all.
28
+ //
29
+ // WHAT IS TAKEN FROM THE TENANT'S CONFIG, AND WHY IT MUST BE. The URL and the
30
+ // credential come from the environment (the hub minted them for its own child).
31
+ // The WORKSPACE ID does not: it comes from `linkedHub.workspaceId`, the tenant's
32
+ // own file, because it decides the DOCUMENT NAME on the wire (DDR-192 §5). The
33
+ // desktop resolves it from that same field. If the cell derived a different one,
34
+ // the browser and the desktop would sync to two differently-named documents and
35
+ // converge on nothing — this whole feature, silently doing exactly what it did
36
+ // before. Honouring the tenant's `url` is a dial-out; honouring their
37
+ // `workspaceId` is the only way the two ends can meet.
38
+
39
+ import { isLoopbackHost } from './loopback.ts';
40
+
41
+ /** Everything the runtime needs to open the loopback provider. */
42
+ export interface CellPairing {
43
+ /** Always a loopback http(s) URL — asserted, never assumed. */
44
+ url: string;
45
+ /** The hub-minted, cell-derived credential. Never a user's own token. */
46
+ token: string;
47
+ }
48
+
49
+ /** Why pairing is off, when the operator asked for it and it did not engage. */
50
+ export type CellPairingRefusal =
51
+ | 'not-workspace-mode'
52
+ | 'not-requested'
53
+ | 'no-url'
54
+ | 'not-loopback'
55
+ | 'no-token'
56
+ | 'autocommit-enabled'
57
+ | 'shared-doc-off';
58
+
59
+ export interface CellPairingVerdict {
60
+ pairing: CellPairing | null;
61
+ /** Present whenever `pairing` is null. */
62
+ refusal?: CellPairingRefusal;
63
+ /** One sentence for the log — populated only when the operator ASKED for
64
+ * pairing and we refused, so a silent misconfiguration is impossible. */
65
+ detail?: string;
66
+ }
67
+
68
+ /** Env truthiness, matching `server.ts`'s MAUDE_SHARED_DOC parsing exactly. */
69
+ function on(value: string | undefined): boolean {
70
+ return /^(1|true|on|yes)$/i.test(value ?? '');
71
+ }
72
+
73
+ /**
74
+ * Strip control characters and cap length before a value reaches a log line.
75
+ *
76
+ * Every refusal `detail` this module builds interpolates `MAUDE_LOOPBACK_SYNC_URL`
77
+ * verbatim, and today that env var is hub-set only — no tenant-reachable input
78
+ * writes it. But this feature's entire threat model rests on "the URL is
79
+ * provably loopback," so the log line proving that should not be the one place
80
+ * that still trusts the string unsanitized. Mirrors the codebase's own
81
+ * precedent (`sanitizeForLog` in `apps/hub/src/server.mjs`) rather than
82
+ * inventing a second scrub shape.
83
+ */
84
+ export function sanitizeForLog(value: string): string {
85
+ let out = '';
86
+ const s = value.slice(0, 256);
87
+ for (let i = 0; i < s.length; i++) {
88
+ const code = s.charCodeAt(i);
89
+ out += code < 0x20 || code === 0x7f ? '·' : s[i];
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /**
95
+ * Resolve the cell pairing from the environment, or explain the refusal.
96
+ *
97
+ * Every condition is a hard gate. There is no "warn and continue" branch on
98
+ * purpose: the failure mode of a partial pairing (a provider that dials out, or
99
+ * a second committer) is worse than no pairing at all, and no pairing is exactly
100
+ * what shipped before this feature.
101
+ */
102
+ export function resolveCellPairing(
103
+ env: Record<string, string | undefined> = process.env
104
+ ): CellPairingVerdict {
105
+ if (env.MAUDE_WORKSPACE_MODE !== '1') {
106
+ return { pairing: null, refusal: 'not-workspace-mode' };
107
+ }
108
+ if (!on(env.MAUDE_CELL_PAIRING)) {
109
+ return { pairing: null, refusal: 'not-requested' };
110
+ }
111
+
112
+ // From here on the operator ASKED for pairing, so every refusal gets a
113
+ // sentence — a cell that quietly declines is the bug this feature is fixing.
114
+ const url = (env.MAUDE_LOOPBACK_SYNC_URL ?? '').trim();
115
+ if (!url) {
116
+ return {
117
+ pairing: null,
118
+ refusal: 'no-url',
119
+ detail: 'MAUDE_CELL_PAIRING is set but MAUDE_LOOPBACK_SYNC_URL is empty.',
120
+ };
121
+ }
122
+ let parsed: URL;
123
+ try {
124
+ parsed = new URL(url);
125
+ } catch {
126
+ return {
127
+ pairing: null,
128
+ refusal: 'not-loopback',
129
+ detail: `MAUDE_LOOPBACK_SYNC_URL is not a URL: ${sanitizeForLog(url)}`,
130
+ };
131
+ }
132
+ if (!isLoopbackHost(parsed.hostname)) {
133
+ return {
134
+ pairing: null,
135
+ refusal: 'not-loopback',
136
+ detail:
137
+ `refusing MAUDE_LOOPBACK_SYNC_URL ${sanitizeForLog(url)} — a cell syncs to ITSELF or to nothing. ` +
138
+ 'Only 127.0.0.1 / localhost / ::1 are accepted (DDR-209: no dial-out).',
139
+ };
140
+ }
141
+
142
+ const token = (env.MAUDE_LOOPBACK_SYNC_TOKEN ?? '').trim();
143
+ if (!token) {
144
+ return {
145
+ pairing: null,
146
+ refusal: 'no-token',
147
+ detail: 'MAUDE_CELL_PAIRING is set but MAUDE_LOOPBACK_SYNC_TOKEN is empty.',
148
+ };
149
+ }
150
+
151
+ if (!on(env.MAUDE_SYNC_NO_AUTOCOMMIT)) {
152
+ return {
153
+ pairing: null,
154
+ refusal: 'autocommit-enabled',
155
+ detail:
156
+ 'refusing to pair without MAUDE_SYNC_NO_AUTOCOMMIT=1 — the hub is the sole committer ' +
157
+ 'in a cell, and a second autocommit over the same working tree is the exact ' +
158
+ 'duplication DDR-209 exists to prevent.',
159
+ };
160
+ }
161
+
162
+ if (!on(env.MAUDE_SHARED_DOC)) {
163
+ return {
164
+ pairing: null,
165
+ refusal: 'shared-doc-off',
166
+ detail:
167
+ 'refusing to pair without MAUDE_SHARED_DOC=1 — pairing IS the single-shared-doc ' +
168
+ 'model (DDR-064). Without it the loopback provider would open a second Y.Doc per ' +
169
+ 'canvas beside the browser room, which is the two-doc world pairing exists to end.',
170
+ };
171
+ }
172
+
173
+ return { pairing: { url, token } };
174
+ }
@@ -26,6 +26,13 @@ import { hostname } from 'node:os';
26
26
  import type * as Y from 'yjs';
27
27
 
28
28
  import { Y_TYPES } from '../collab/persistence.ts';
29
+ import {
30
+ MAX_ANNOTATIONS_BYTES,
31
+ MAX_COMMENTS_BYTES,
32
+ MAX_CSS_BYTES,
33
+ MAX_HTML_BYTES,
34
+ MAX_META_BYTES,
35
+ } from './limits.ts';
29
36
 
30
37
  /**
31
38
  * Y.Doc shared-type names introduced by Task 4. Distinct namespace from
@@ -59,12 +66,11 @@ export const Y_SYNC_TYPES = {
59
66
  * has no upstream-enforced size cap; the codec is the consumer's guard.
60
67
  * Mirrors the existing /_api/annotations 1 MB cap (api.ts) so the sync path
61
68
  * doesn't bypass the HTTP-layer guard.
69
+ *
70
+ * Defined in `limits.ts` (a leaf) and re-exported here so every existing
71
+ * importer is unchanged — see that file for why they had to move.
62
72
  */
63
- export const MAX_HTML_BYTES = 4 * 1024 * 1024;
64
- export const MAX_COMMENTS_BYTES = 1 * 1024 * 1024;
65
- export const MAX_ANNOTATIONS_BYTES = 1 * 1024 * 1024;
66
- export const MAX_META_BYTES = 1 * 1024 * 1024;
67
- export const MAX_CSS_BYTES = 4 * 1024 * 1024;
73
+ export { MAX_ANNOTATIONS_BYTES, MAX_COMMENTS_BYTES, MAX_CSS_BYTES, MAX_HTML_BYTES, MAX_META_BYTES };
68
74
 
69
75
  function byteLengthUtf8(s: string): number {
70
76
  return Buffer.byteLength(s, 'utf8');