@1agh/maude 0.22.2 → 0.23.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 (44) hide show
  1. package/cli/commands/design-link.test.mjs +53 -1
  2. package/cli/commands/hub.test.mjs +10 -9
  3. package/cli/lib/design-link.mjs +154 -7
  4. package/cli/lib/hubs-config.mjs +42 -4
  5. package/package.json +8 -8
  6. package/plugins/design/dev-server/build.ts +69 -3
  7. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  8. package/plugins/design/dev-server/canvas-lib.tsx +22 -6
  9. package/plugins/design/dev-server/canvas-shell.tsx +25 -226
  10. package/plugins/design/dev-server/client/app.jsx +37 -15
  11. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  12. package/plugins/design/dev-server/collab/registry.ts +51 -0
  13. package/plugins/design/dev-server/config.schema.json +20 -0
  14. package/plugins/design/dev-server/context.ts +7 -0
  15. package/plugins/design/dev-server/dist/client.bundle.js +21 -12
  16. package/plugins/design/dev-server/dist/comment-mount.js +1801 -0
  17. package/plugins/design/dev-server/dom-selection.ts +156 -0
  18. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  19. package/plugins/design/dev-server/input-router.tsx +99 -61
  20. package/plugins/design/dev-server/server.ts +18 -0
  21. package/plugins/design/dev-server/sync/agent.ts +323 -0
  22. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  23. package/plugins/design/dev-server/sync/codec.ts +169 -0
  24. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  25. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  26. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  27. package/plugins/design/dev-server/sync/index.ts +474 -0
  28. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  29. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  30. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  31. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  32. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  33. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  34. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  35. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  36. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  37. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  38. package/plugins/design/dev-server/test/sync-runtime.test.ts +285 -0
  39. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  40. package/plugins/design/dev-server/use-collab.tsx +157 -13
  41. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  42. package/plugins/design/dev-server/use-tool-mode.tsx +12 -0
  43. package/plugins/design/templates/_shell.html +15 -5
  44. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -0
@@ -0,0 +1,156 @@
1
+ /**
2
+ * @file dom-selection.ts — selection-from-DOM helpers (leaf module)
3
+ * @scope plugins/design/dev-server/dom-selection.ts
4
+ * @purpose Pure DOM → Selection builders shared by the canvas chrome
5
+ * (canvas-shell.tsx) and the shell-owned comment mount layer
6
+ * (canvas-comment-mount.tsx). Lives in its own leaf module — no
7
+ * React, no canvas-lib import — so both consumers can lift the
8
+ * same `hoverTargetToSelection` / `deriveFile` logic without a
9
+ * cycle and without bundling the heavy DesignCanvas tree into the
10
+ * lite comment mount.
11
+ */
12
+
13
+ import type { HoverTarget } from './input-router.tsx';
14
+ import type { Selection } from './use-selection-set.tsx';
15
+
16
+ /**
17
+ * Canvas file path for the current page. Under the mount harness the page is
18
+ * `/_canvas-shell.html?canvas=<rel>&designRel=<root>`; for legacy `.html`
19
+ * mocks it's the served file path itself.
20
+ */
21
+ export function deriveFile(): string | undefined {
22
+ if (typeof window === 'undefined') return undefined;
23
+ try {
24
+ const p = window.location.pathname;
25
+ if (p === '/_canvas-shell.html' || p === '/_canvas-shell') {
26
+ const qs = new URLSearchParams(window.location.search);
27
+ const canvas = qs.get('canvas') ?? '';
28
+ const designRel = (qs.get('designRel') ?? '.design').replace(/^\/+|\/+$/g, '');
29
+ return canvas ? `${designRel}/${canvas}` : undefined;
30
+ }
31
+ return decodeURIComponent(p).replace(/^\//, '');
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ export function realClasses(el: Element | null): string {
38
+ if (!el) return '';
39
+ return (el.getAttribute('class') ?? '')
40
+ .trim()
41
+ .split(/\s+/)
42
+ .filter((c) => c && !c.startsWith('dgn-') && !c.startsWith('dc-cv-'))
43
+ .join(' ');
44
+ }
45
+
46
+ export function shortText(el: Element | null, max: number): string {
47
+ if (!el) return '';
48
+ const t = ((el as HTMLElement).innerText || el.textContent || '').replace(/\s+/g, ' ').trim();
49
+ return t.length > max ? `${t.slice(0, max - 1)}…` : t;
50
+ }
51
+
52
+ export function cssPath(el: Element | null): string {
53
+ if (!el) return '';
54
+ const path: string[] = [];
55
+ let cur: Element | null = el;
56
+ while (cur && cur.nodeType === 1 && path.length < 8) {
57
+ const dscEl = cur.getAttribute?.('data-dc-element');
58
+ if (dscEl) {
59
+ path.unshift(`[data-dc-element="${dscEl}"]`);
60
+ break;
61
+ }
62
+ const dscSc = cur.getAttribute?.('data-dc-screen');
63
+ if (dscSc) {
64
+ path.unshift(`[data-dc-screen="${dscSc}"]`);
65
+ break;
66
+ }
67
+ let sel = cur.nodeName.toLowerCase();
68
+ if (cur.id) {
69
+ sel = `#${cur.id}`;
70
+ path.unshift(sel);
71
+ break;
72
+ }
73
+ const cls = realClasses(cur).split(/\s+/).filter(Boolean).slice(0, 2);
74
+ if (cls.length) sel += `.${cls.join('.')}`;
75
+ let sib = 1;
76
+ let n: Element | null = cur.previousElementSibling;
77
+ while (n) {
78
+ sib++;
79
+ n = n.previousElementSibling;
80
+ }
81
+ sel += `:nth-child(${sib})`;
82
+ path.unshift(sel);
83
+ cur = cur.parentElement;
84
+ }
85
+ return path.join(' > ');
86
+ }
87
+
88
+ export function domPath(el: Element | null): string[] {
89
+ const hops: string[] = [];
90
+ let cur = el;
91
+ while (cur && cur.nodeType === 1 && hops.length < 8) {
92
+ let label = cur.nodeName.toLowerCase();
93
+ const dEl = cur.getAttribute?.('data-dc-element');
94
+ const dSc = cur.getAttribute?.('data-dc-screen');
95
+ if (dEl) label += `[data-dc-element="${dEl}"]`;
96
+ else if (dSc) label += `[data-dc-screen="${dSc}"]`;
97
+ else if (cur.id) label += `#${cur.id}`;
98
+ const cls = realClasses(cur).split(/\s+/).filter(Boolean).slice(0, 2);
99
+ if (cls.length && !dEl && !dSc) label += `.${cls.join('.')}`;
100
+ hops.unshift(label);
101
+ cur = cur.parentElement;
102
+ }
103
+ return hops;
104
+ }
105
+
106
+ export function cssEscape(s: string): string {
107
+ // Minimal CSS.escape polyfill — only handles chars actually present in
108
+ // pipeline-stamped IDs (alphanumerics + `-` + `_`).
109
+ return s.replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c}`);
110
+ }
111
+
112
+ /**
113
+ * Build the wire-shape `Selection` for a resolved hover target. `file`
114
+ * defaults to `deriveFile()`; the comment mount layer passes it explicitly
115
+ * so all three consumers (router, overlay, mount) agree on the same key.
116
+ */
117
+ export function hoverTargetToSelection(target: HoverTarget, file?: string): Selection {
118
+ const el = target.el;
119
+ const rect =
120
+ el && (el as HTMLElement).getBoundingClientRect
121
+ ? (el as HTMLElement).getBoundingClientRect()
122
+ : null;
123
+ // `cdId` is the hit element's OWN data-cd-id (deep mode); resolver never
124
+ // climbs to an ancestor. Falls back to cssPath of the hit when no stable
125
+ // anchor exists.
126
+ const cdId = target.cdId;
127
+ // Selector resolution order:
128
+ // 1. data-cd-id anchor — stable pipeline-stamped id (preferred).
129
+ // 2. data-dc-screen — chrome click promoted to whole-artboard select
130
+ // (T24.5 G8 multi-artboard gesture).
131
+ // 3. cssPath of the hit — last-resort path string.
132
+ const selector = cdId
133
+ ? `[data-cd-id="${cdId}"]`
134
+ : !cdId && target.artboardId
135
+ ? `[data-dc-screen="${target.artboardId}"]`
136
+ : cssPath(el);
137
+ return {
138
+ file: file ?? deriveFile(),
139
+ id: cdId ?? undefined,
140
+ selector,
141
+ artboardId: target.artboardId,
142
+ tag: el?.tagName.toLowerCase() ?? '',
143
+ classes: realClasses(el),
144
+ text: shortText(el, 240),
145
+ dom_path: domPath(el),
146
+ bounds: rect
147
+ ? {
148
+ x: Math.round(rect.left),
149
+ y: Math.round(rect.top),
150
+ w: Math.round(rect.width),
151
+ h: Math.round(rect.height),
152
+ }
153
+ : null,
154
+ html: el ? (el.outerHTML ?? '').slice(0, 4000) : '',
155
+ };
156
+ }
@@ -17,6 +17,7 @@
17
17
  // loaded via the importmap + Bun.build-produced ESM — there's no React Fast
18
18
  // Refresh runtime to register with. Full-reload is the reliable path.
19
19
 
20
+ import { existsSync } from 'node:fs';
20
21
  import path from 'node:path';
21
22
 
22
23
  import type { Context } from './context.ts';
@@ -64,26 +65,11 @@ export function createHmrBroadcaster(
64
65
  }
65
66
 
66
67
  function classify(filename: string): HmrMessage | null {
67
- const rel = filename.replace(/\\/g, '/');
68
- const ext = path.extname(rel).toLowerCase();
69
- const version = Date.now();
70
- if (rel.startsWith('_lib/')) {
71
- return { type: 'canvas-hmr', mode: 'hard', scope: 'lib', version };
72
- }
73
- if (ext === '.css') {
74
- return { type: 'canvas-hmr', mode: 'css', file: rel, version, scope: 'canvas' };
75
- }
76
- // Phase 8 — canvas-meta sidecar (`<base>.meta.json`) carries the
77
- // artboard layout / viewport. Emit a `meta` mode so foreign tabs can
78
- // re-fetch + re-apply the layout WITHOUT a full reload (which would
79
- // lose React state like tool mode, undo stack, scroll position).
80
- if (rel.endsWith('.meta.json')) {
81
- return { type: 'canvas-hmr', mode: 'meta', file: rel, version, scope: 'canvas' };
82
- }
83
- if (ext === '.tsx' || ext === '.jsx' || ext === '.ts' || ext === '.js') {
84
- return { type: 'canvas-hmr', mode: 'module', file: rel, version, scope: 'canvas' };
85
- }
86
- return null;
68
+ return classifyChange(filename, (cssRel) => {
69
+ const root = ctx.paths?.designRoot;
70
+ if (!root) return false; // no root resolved → can't probe; keep css swap
71
+ return existsSync(path.join(root, cssRel.replace(/\.css$/i, '.tsx')));
72
+ });
87
73
  }
88
74
 
89
75
  function enqueue(msg: HmrMessage) {
@@ -125,3 +111,48 @@ export function createHmrBroadcaster(
125
111
  // Helpers — exported for tests.
126
112
 
127
113
  export const HMR_DEBOUNCE_MS = DEBOUNCE_MS;
114
+
115
+ /**
116
+ * Classify a changed design-root-relative path into an HMR message. Pure +
117
+ * fs-injected (`hasSiblingTsx`) so it unit-tests without touching disk.
118
+ *
119
+ * CSS routing is the load-bearing part. A canvas/specimen sibling stylesheet
120
+ * (e.g. `system/x/preview/motion.css` next to `motion.tsx`, pulled in via
121
+ * `import './motion.css'`) is INLINED into the built module as a `<style>` tag
122
+ * by canvas-build.ts — there is NO `<link>` for it. The iframe's `mode:'css'`
123
+ * handler swaps `<link href>` only, so a link swap for inlined CSS is a silent
124
+ * no-op and the edit never reaches the browser (the bug this fixes). For those
125
+ * we emit `mode:'module'` keyed on the sibling `.tsx`, so the open canvas
126
+ * reloads and re-inlines the fresh CSS via the existing module-reload path.
127
+ * Link-mounted CSS (DS tokens, `_components.css`, `_layout.css` — no sibling
128
+ * `.tsx`) keeps the fast, state-preserving css swap.
129
+ */
130
+ export function classifyChange(
131
+ filename: string,
132
+ hasSiblingTsx: (cssRel: string) => boolean
133
+ ): HmrMessage | null {
134
+ const rel = filename.replace(/\\/g, '/');
135
+ const ext = path.extname(rel).toLowerCase();
136
+ const version = Date.now();
137
+ if (rel.startsWith('_lib/')) {
138
+ return { type: 'canvas-hmr', mode: 'hard', scope: 'lib', version };
139
+ }
140
+ if (ext === '.css') {
141
+ if (hasSiblingTsx(rel)) {
142
+ const siblingTsx = rel.replace(/\.css$/i, '.tsx');
143
+ return { type: 'canvas-hmr', mode: 'module', file: siblingTsx, version, scope: 'canvas' };
144
+ }
145
+ return { type: 'canvas-hmr', mode: 'css', file: rel, version, scope: 'canvas' };
146
+ }
147
+ // Phase 8 — canvas-meta sidecar (`<base>.meta.json`) carries the
148
+ // artboard layout / viewport. Emit a `meta` mode so foreign tabs can
149
+ // re-fetch + re-apply the layout WITHOUT a full reload (which would
150
+ // lose React state like tool mode, undo stack, scroll position).
151
+ if (rel.endsWith('.meta.json')) {
152
+ return { type: 'canvas-hmr', mode: 'meta', file: rel, version, scope: 'canvas' };
153
+ }
154
+ if (ext === '.tsx' || ext === '.jsx' || ext === '.ts' || ext === '.js') {
155
+ return { type: 'canvas-hmr', mode: 'module', file: rel, version, scope: 'canvas' };
156
+ }
157
+ return null;
158
+ }
@@ -268,6 +268,16 @@ export interface UseInputRouterOptions {
268
268
  callbacks: RouterCallbacks;
269
269
  /** When false, listeners are not attached. Defaults to true. */
270
270
  enabled?: boolean;
271
+ /**
272
+ * Allowlist of action kinds this router is permitted to CLAIM (preventDefault
273
+ * + stopImmediatePropagation + dispatch). Any classified action outside the
274
+ * set is downgraded to `no-op` so it propagates untouched to other listeners.
275
+ * Omit to claim everything (the default — used by the full DesignCanvas
276
+ * router). The shell-owned comment mount layer passes a narrow set so it can
277
+ * coexist as an ANCESTOR capture-listener over a UI canvas's own router
278
+ * without swallowing select / context-menu / undo gestures it doesn't own.
279
+ */
280
+ claimableActions?: ReadonlySet<RouterAction['kind']>;
271
281
  }
272
282
 
273
283
  export function isEditableTarget(t: EventTarget | null): boolean {
@@ -295,13 +305,21 @@ export function isOverlayTarget(t: EventTarget | null): boolean {
295
305
  }
296
306
 
297
307
  export function useInputRouter(opts: UseInputRouterOptions): void {
298
- const { hostRef, getActiveTool, isSpaceHeld, callbacks, enabled = true } = opts;
308
+ const { hostRef, getActiveTool, isSpaceHeld, callbacks, enabled = true, claimableActions } = opts;
299
309
 
300
310
  useEffect(() => {
301
311
  if (!enabled) return;
302
312
  const host = hostRef.current;
303
313
  if (!host) return;
304
314
 
315
+ // Downgrade any action this router isn't permitted to claim to no-op so it
316
+ // propagates untouched (no preventDefault / no dispatch). Identity pass-
317
+ // through when no allowlist is configured.
318
+ const claim = (action: RouterAction): RouterAction =>
319
+ claimableActions && action.kind !== 'no-op' && !claimableActions.has(action.kind)
320
+ ? { kind: 'no-op' }
321
+ : action;
322
+
305
323
  const dispatch = (action: RouterAction): void => {
306
324
  switch (action.kind) {
307
325
  case 'hover':
@@ -334,18 +352,20 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
334
352
  };
335
353
 
336
354
  const onPointerMove = (e: PointerEvent): void => {
337
- const action = classify({
338
- type: 'pointermove',
339
- button: e.button,
340
- metaKey: e.metaKey,
341
- ctrlKey: e.ctrlKey,
342
- shiftKey: e.shiftKey,
343
- altKey: e.altKey,
344
- clientX: e.clientX,
345
- clientY: e.clientY,
346
- spaceHeld: isSpaceHeld?.() ?? false,
347
- activeTool: getActiveTool(),
348
- });
355
+ const action = claim(
356
+ classify({
357
+ type: 'pointermove',
358
+ button: e.button,
359
+ metaKey: e.metaKey,
360
+ ctrlKey: e.ctrlKey,
361
+ shiftKey: e.shiftKey,
362
+ altKey: e.altKey,
363
+ clientX: e.clientX,
364
+ clientY: e.clientY,
365
+ spaceHeld: isSpaceHeld?.() ?? false,
366
+ activeTool: getActiveTool(),
367
+ })
368
+ );
349
369
  dispatch(action);
350
370
  };
351
371
 
@@ -354,18 +374,20 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
354
374
  // their own clicks. The router is in capture phase, so we have to
355
375
  // bail HERE before classify can claim the event.
356
376
  if (isOverlayTarget(e.target)) return;
357
- const action = classify({
358
- type: 'pointerdown',
359
- button: e.button,
360
- metaKey: e.metaKey,
361
- ctrlKey: e.ctrlKey,
362
- shiftKey: e.shiftKey,
363
- altKey: e.altKey,
364
- clientX: e.clientX,
365
- clientY: e.clientY,
366
- spaceHeld: isSpaceHeld?.() ?? false,
367
- activeTool: getActiveTool(),
368
- });
377
+ const action = claim(
378
+ classify({
379
+ type: 'pointerdown',
380
+ button: e.button,
381
+ metaKey: e.metaKey,
382
+ ctrlKey: e.ctrlKey,
383
+ shiftKey: e.shiftKey,
384
+ altKey: e.altKey,
385
+ clientX: e.clientX,
386
+ clientY: e.clientY,
387
+ spaceHeld: isSpaceHeld?.() ?? false,
388
+ activeTool: getActiveTool(),
389
+ })
390
+ );
369
391
  if (action.kind !== 'no-op') {
370
392
  // Suppress native behavior on every event the router claims —
371
393
  // button presses don't fire, inputs don't focus, the canvas
@@ -386,18 +408,20 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
386
408
  */
387
409
  const onMouseDown = (e: MouseEvent): void => {
388
410
  if (isOverlayTarget(e.target)) return;
389
- const action = classify({
390
- type: 'pointerdown',
391
- button: e.button,
392
- metaKey: e.metaKey,
393
- ctrlKey: e.ctrlKey,
394
- shiftKey: e.shiftKey,
395
- altKey: e.altKey,
396
- clientX: e.clientX,
397
- clientY: e.clientY,
398
- spaceHeld: isSpaceHeld?.() ?? false,
399
- activeTool: getActiveTool(),
400
- });
411
+ const action = claim(
412
+ classify({
413
+ type: 'pointerdown',
414
+ button: e.button,
415
+ metaKey: e.metaKey,
416
+ ctrlKey: e.ctrlKey,
417
+ shiftKey: e.shiftKey,
418
+ altKey: e.altKey,
419
+ clientX: e.clientX,
420
+ clientY: e.clientY,
421
+ spaceHeld: isSpaceHeld?.() ?? false,
422
+ activeTool: getActiveTool(),
423
+ })
424
+ );
401
425
  if (action.kind !== 'no-op') {
402
426
  e.preventDefault();
403
427
  e.stopImmediatePropagation();
@@ -414,41 +438,55 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
414
438
  if (isOverlayTarget(e.target)) return;
415
439
  const tool = getActiveTool();
416
440
  const mod = e.metaKey || e.ctrlKey;
417
- const wouldRoute =
418
- tool === 'comment' || (tool === 'move' && mod && e.button === 0) || e.button === 2;
419
- if (wouldRoute) {
441
+ // Map the click to the action kind the matching pointerdown would have
442
+ // produced, then honor the claim allowlist so a scoped router (the
443
+ // comment mount layer) doesn't suppress clicks it never claimed.
444
+ const wouldRouteKind: RouterAction['kind'] | null =
445
+ tool === 'comment'
446
+ ? 'drop-comment'
447
+ : tool === 'move' && mod && e.button === 0
448
+ ? 'select'
449
+ : e.button === 2
450
+ ? 'context-menu'
451
+ : null;
452
+ if (wouldRouteKind && (!claimableActions || claimableActions.has(wouldRouteKind))) {
420
453
  e.preventDefault();
421
454
  e.stopImmediatePropagation();
422
455
  }
423
456
  };
424
457
 
425
458
  const onContextMenu = (e: MouseEvent): void => {
459
+ const action = claim(
460
+ classify({
461
+ type: 'contextmenu',
462
+ clientX: e.clientX,
463
+ clientY: e.clientY,
464
+ metaKey: e.metaKey,
465
+ ctrlKey: e.ctrlKey,
466
+ shiftKey: e.shiftKey,
467
+ altKey: e.altKey,
468
+ activeTool: getActiveTool(),
469
+ })
470
+ );
471
+ if (action.kind === 'no-op') return; // not ours to claim — let it bubble
426
472
  e.preventDefault();
427
473
  e.stopImmediatePropagation();
428
- const action = classify({
429
- type: 'contextmenu',
430
- clientX: e.clientX,
431
- clientY: e.clientY,
432
- metaKey: e.metaKey,
433
- ctrlKey: e.ctrlKey,
434
- shiftKey: e.shiftKey,
435
- altKey: e.altKey,
436
- activeTool: getActiveTool(),
437
- });
438
474
  dispatch(action);
439
475
  };
440
476
 
441
477
  const onKeyDown = (e: KeyboardEvent): void => {
442
- const action = classify({
443
- type: 'keydown',
444
- key: e.key,
445
- metaKey: e.metaKey,
446
- ctrlKey: e.ctrlKey,
447
- shiftKey: e.shiftKey,
448
- altKey: e.altKey,
449
- isEditable: isEditableTarget(e.target),
450
- activeTool: getActiveTool(),
451
- });
478
+ const action = claim(
479
+ classify({
480
+ type: 'keydown',
481
+ key: e.key,
482
+ metaKey: e.metaKey,
483
+ ctrlKey: e.ctrlKey,
484
+ shiftKey: e.shiftKey,
485
+ altKey: e.altKey,
486
+ isEditable: isEditableTarget(e.target),
487
+ activeTool: getActiveTool(),
488
+ })
489
+ );
452
490
  if (
453
491
  action.kind === 'tool' ||
454
492
  action.kind === 'escape' ||
@@ -486,7 +524,7 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
486
524
  } as EventListenerOptions);
487
525
  doc.removeEventListener('keydown', onKeyDown, true);
488
526
  };
489
- }, [enabled, hostRef, getActiveTool, isSpaceHeld, callbacks]);
527
+ }, [enabled, hostRef, getActiveTool, isSpaceHeld, callbacks, claimableActions]);
490
528
  }
491
529
 
492
530
  // ─────────────────────────────────────────────────────────────────────────────
@@ -26,6 +26,7 @@ import { createFsWatch } from './fs-watch.ts';
26
26
  import { createHttp } from './http.ts';
27
27
  import { createInspect } from './inspect.ts';
28
28
  import { startHeapWatch } from './mem.ts';
29
+ import { createSyncRuntime } from './sync/index.ts';
29
30
  import { type WsData, createWs, isLoopbackHost, parseCollabSlug } from './ws.ts';
30
31
 
31
32
  // Phase 19 / DDR-044 — covers the marketplace-cache-install gap where
@@ -199,6 +200,18 @@ await Bun.write(
199
200
  fsWatch.start();
200
201
  startHeapWatch();
201
202
 
203
+ // Phase 9 Task 4 — bidirectional sync agent. No-op when the project isn't
204
+ // linked to a hub (`.design/config.json` has no `linkedHub` field). Kicked
205
+ // off after fsWatch so the agent's bus subscription receives every fs event.
206
+ const syncRuntime = createSyncRuntime(ctx, collab ? { registry: collab.registry } : {});
207
+ if (syncRuntime) {
208
+ try {
209
+ await syncRuntime.start();
210
+ } catch (err) {
211
+ console.error('[sync] startup failed — continuing in solo mode:', err);
212
+ }
213
+ }
214
+
202
215
  const url = `http://localhost:${server.port}`;
203
216
  console.log(`\n ${ctx.projectLabel} — local browser`);
204
217
  console.log(' ─────────────────────────────');
@@ -219,6 +232,11 @@ if (!process.env.NO_OPEN) {
219
232
  async function shutdown() {
220
233
  console.log('\n Stopping…');
221
234
  fsWatch.stop();
235
+ try {
236
+ if (syncRuntime) await syncRuntime.stop();
237
+ } catch {
238
+ /* best-effort — provider sockets will be closed by process exit anyway */
239
+ }
222
240
  try {
223
241
  if (collab) await collab.registry.destroyAll();
224
242
  } catch {