@1agh/maude 0.29.0 → 0.31.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 (162) hide show
  1. package/README.md +6 -6
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
@@ -1,7 +1,6 @@
1
1
  // Active-canvas state, selected-element tracking, and HTML injection
2
2
  // (inspector overlay + canvas runtime). See plan Task 7 + DDR-007.
3
3
 
4
- import path from 'node:path';
5
4
  import type { Context } from './context.ts';
6
5
 
7
6
  export interface SelectedElement {
@@ -356,16 +355,31 @@ const INSPECTOR_SCRIPT = `
356
355
  new MutationObserver(function(){ startTick(); }).observe(document.documentElement, { subtree: true, attributes: true, attributeFilter: ['style', 'class'], childList: true });
357
356
  }
358
357
 
359
- // ⌘K / Ctrl+K command palette — keydown fired inside the canvas iframe never
360
- // reaches the shell's window-scoped listener (iframe keyboard isolation), so
361
- // the palette wouldn't open while focus is in the canvas. Forward the chord to
362
- // the parent; the shell toggles the palette (mirrors its own handler, which
363
- // fires "even in inputs"). Capture phase so canvas-lib's pan/zoom keydown
364
- // handler can't swallow it first.
358
+ // Shell chords — keydown fired inside the canvas iframe never reaches the
359
+ // shell's window-scoped listener (iframe keyboard isolation), so each shell
360
+ // chord must be forwarded to the parent. preventDefault is load-bearing for
361
+ // ⌘R: without it the BROWSER reloads the whole shell while focus is in the
362
+ // canvas (the advertised behavior is "reload the active canvas"). Capture
363
+ // phase so canvas-lib's pan/zoom keydown handler can't swallow them first.
365
364
  document.addEventListener('keydown', function(e) {
366
- if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
365
+ if (!(e.metaKey || e.ctrlKey)) return;
366
+ var k = (e.key || '').toLowerCase();
367
+ if (k === 'k' && !e.shiftKey) {
367
368
  e.preventDefault();
368
369
  try { window.parent.postMessage({ dgn: 'toggle-palette' }, '*'); } catch (err) {}
370
+ return;
371
+ }
372
+ if (k === 'r' && !e.shiftKey) {
373
+ e.preventDefault();
374
+ try { window.parent.postMessage({ dgn: 'shell-shortcut', id: 'reload' }, '*'); } catch (err) {}
375
+ return;
376
+ }
377
+ if (e.shiftKey) {
378
+ var id = k === 'i' ? 'inspector' : k === 'm' ? 'comments' : k === 'e' ? 'export' : k === 'h' ? 'handoff' : null;
379
+ if (id) {
380
+ e.preventDefault();
381
+ try { window.parent.postMessage({ dgn: 'shell-shortcut', id: id }, '*'); } catch (err) {}
382
+ }
369
383
  }
370
384
  }, true);
371
385
 
@@ -389,6 +403,21 @@ const INSPECTOR_SCRIPT = `
389
403
  if (t) t.scrollIntoView({ behavior: 'smooth', block: 'center' });
390
404
  } catch (e) {}
391
405
  }
406
+ } else if (m.dgn === 'theme') {
407
+ // Catch the chrome-theme seed the shell posts in reply to our 'loaded'
408
+ // ping (sent below). THIS listener registers during page-load — before
409
+ // the React canvas-shell mounts and adds its OWN 'dgn:theme' listener —
410
+ // so without this branch the shell's reply lands in the gap between
411
+ // page-load and React-mount and is dropped, leaving the canvas stuck on
412
+ // its hardcoded dark default until a manual theme toggle re-broadcasts
413
+ // it (the recurring "open a canvas under a light shell → canvas chrome is
414
+ // dark; toggle twice to fix; next canvas reverts" bug). We set the same
415
+ // attribute the canvas-shell chrome reads (data-maude-theme on <html>);
416
+ // canvas-shell's default-dark guard then sees the value already present
417
+ // and won't override it, and its own listener takes over live toggles.
418
+ if (m.theme === 'light' || m.theme === 'dark') {
419
+ try { document.documentElement.setAttribute('data-maude-theme', m.theme); } catch (e) {}
420
+ }
392
421
  }
393
422
  /* force-clear is now consumed by canvas-shell.tsx — the inspector
394
423
  overlay has no per-element selection state to clear anymore. */
@@ -14,7 +14,7 @@
14
14
  * own avatar to release. tldraw-style.
15
15
  */
16
16
 
17
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
17
+ import { useCallback, useEffect, useRef, useState } from 'react';
18
18
 
19
19
  import { useViewportControllerContext } from './canvas-lib.tsx';
20
20
  import { type AgentPresence, useAgentPresence } from './use-agent-presence.tsx';
@@ -139,6 +139,35 @@ const CHROME_CSS = `
139
139
  white-space: normal;
140
140
  max-width: 200px;
141
141
  }
142
+ /* Phase 30 / DDR-120 — soft editing-presence. A peer (or a bridged agent)
143
+ actively editing this canvas gets a gently-pulsing ring + a ✎ marker in the
144
+ peer's OWN identity hue (--dc-edit-ring, set inline from peer.color) so the
145
+ agent pulses violet, not the indigo accent. A heads-up, never a lock. */
146
+ .dc-participant--editing { animation: dc-participant-edit-pulse 1.8s ease-in-out infinite; }
147
+ @keyframes dc-participant-edit-pulse {
148
+ 0%, 100% { box-shadow: 0 1px 2px rgba(0,0,0,0.08), 0 0 0 2px var(--dc-edit-ring, var(--maude-hud-accent, oklch(56% 0.170 50))); }
149
+ 50% { box-shadow: 0 1px 2px rgba(0,0,0,0.08), 0 0 0 4px color-mix(in oklab, var(--dc-edit-ring, var(--maude-hud-accent, oklch(56% 0.170 50))) 45%, transparent); }
150
+ }
151
+ .dc-participant-edit-marker {
152
+ position: absolute;
153
+ right: -3px;
154
+ top: -3px;
155
+ width: 13px;
156
+ height: 13px;
157
+ border-radius: 50%;
158
+ background: var(--maude-chrome-bg-0, #fff);
159
+ color: var(--dc-edit-ring, var(--maude-hud-accent, oklch(56% 0.170 50)));
160
+ display: inline-flex;
161
+ align-items: center;
162
+ justify-content: center;
163
+ font-size: 8px;
164
+ line-height: 1;
165
+ box-shadow: 0 0 0 1px rgba(0,0,0,0.14);
166
+ pointer-events: none;
167
+ }
168
+ @media (prefers-reduced-motion: reduce) {
169
+ .dc-participant--editing { animation: none; box-shadow: 0 1px 2px rgba(0,0,0,0.08), 0 0 0 2px var(--dc-edit-ring, var(--maude-hud-accent, oklch(56% 0.170 50))); }
170
+ }
142
171
  `.trim();
143
172
 
144
173
  function ensureChromeStyles(): void {
@@ -150,6 +179,17 @@ function ensureChromeStyles(): void {
150
179
  document.head.appendChild(s);
151
180
  }
152
181
 
182
+ /**
183
+ * Phase 30 / DDR-120 — true when a foreign peer is THIS machine's own
184
+ * server-side editing echo: a peer carrying my own name with editing set but
185
+ * no cursor/selection. That's the room's projection of my own agent/inspector
186
+ * edit (which I already see via the local ai-activity avatar), so I skip it. A
187
+ * remote peer never matches (different name), so they still get the heads-up.
188
+ */
189
+ function isOwnEditingEcho(peer: ForeignAwareness, myName: string | undefined): boolean {
190
+ return peer.name === myName && !!peer.editing && !peer.cursor && !peer.selection;
191
+ }
192
+
153
193
  function initialsFor(name: string): string {
154
194
  const cleaned = name.trim();
155
195
  if (!cleaned) return '?';
@@ -169,6 +209,7 @@ interface AvatarProps {
169
209
  function Avatar({ peer, isFollowing, onToggleFollow }: AvatarProps): JSX.Element {
170
210
  const [open, setOpen] = useState(false);
171
211
  const rootRef = useRef<HTMLDivElement | null>(null);
212
+ const editing = !!peer.editing;
172
213
 
173
214
  useEffect(() => {
174
215
  if (!open) return;
@@ -180,11 +221,16 @@ function Avatar({ peer, isFollowing, onToggleFollow }: AvatarProps): JSX.Element
180
221
  return () => document.removeEventListener('mousedown', onDocClick);
181
222
  }, [open]);
182
223
 
224
+ const cls = `dc-participant${isFollowing ? ' dc-participant--following' : ''}${editing ? ' dc-participant--editing' : ''}`;
225
+ // `--dc-edit-ring` drives the editing pulse/marker off the peer's own hue.
226
+ const style = (
227
+ editing ? { background: peer.color, '--dc-edit-ring': peer.color } : { background: peer.color }
228
+ ) as React.CSSProperties;
183
229
  return (
184
230
  <div
185
231
  ref={rootRef}
186
- className={`dc-participant${isFollowing ? ' dc-participant--following' : ''}`}
187
- style={{ background: peer.color }}
232
+ className={cls}
233
+ style={style}
188
234
  onClick={() => setOpen((v) => !v)}
189
235
  onKeyDown={(e) => {
190
236
  if (e.key === 'Enter' || e.key === ' ') {
@@ -192,19 +238,22 @@ function Avatar({ peer, isFollowing, onToggleFollow }: AvatarProps): JSX.Element
192
238
  setOpen((v) => !v);
193
239
  }
194
240
  }}
195
- // biome-ignore lint/a11y/useSemanticElements: 28px round chip needs raw <div> + inline bg + custom focus ring; <button> reset is more code than the rule saves.
196
241
  role="button"
197
242
  tabIndex={0}
198
- title={peer.name}
199
- aria-label={peer.name}
243
+ title={editing ? `${peer.name} — editing this canvas` : peer.name}
244
+ aria-label={editing ? `${peer.name}, editing this canvas` : peer.name}
200
245
  aria-expanded={open}
201
246
  >
202
247
  {initialsFor(peer.name)}
248
+ {editing && (
249
+ <span className="dc-participant-edit-marker" aria-hidden="true">
250
+
251
+ </span>
252
+ )}
203
253
  {open && (
204
- // biome-ignore lint/a11y/useKeyWithClickEvents: stop-propagation wrapper; keyboard focus stays in the button child.
205
- // biome-ignore lint/a11y/useSemanticElements: popover wrapper carries no semantic role; the inner <button> is the actionable element.
206
254
  <div className="dc-participant-popover" onClick={(e) => e.stopPropagation()}>
207
255
  <div className="dc-participant-popover__name">{peer.name}</div>
256
+ {editing && <div className="dc-participant-popover__sub">Editing this canvas</div>}
208
257
  <button
209
258
  type="button"
210
259
  className={`dc-participant-popover__btn${isFollowing ? ' dc-participant-popover__btn--stop' : ''}`}
@@ -250,7 +299,6 @@ function AgentAvatar({ agent }: { agent: AgentPresence }): JSX.Element {
250
299
  setOpen((v) => !v);
251
300
  }
252
301
  }}
253
- // biome-ignore lint/a11y/useSemanticElements: matches the peer Avatar chip pattern.
254
302
  role="button"
255
303
  tabIndex={0}
256
304
  title={`${agent.name} (AI agent)`}
@@ -262,8 +310,6 @@ function AgentAvatar({ agent }: { agent: AgentPresence }): JSX.Element {
262
310
 
263
311
  </span>
264
312
  {open && (
265
- // biome-ignore lint/a11y/useKeyWithClickEvents: stop-propagation wrapper; no actionable child.
266
- // biome-ignore lint/a11y/useSemanticElements: popover wrapper carries no semantic role.
267
313
  <div className="dc-participant-popover" onClick={(e) => e.stopPropagation()}>
268
314
  <div className="dc-participant-popover__name">{agent.name}</div>
269
315
  <div className="dc-participant-popover__sub">AI agent · {agent.author}</div>
@@ -324,13 +370,21 @@ export function ParticipantsChrome(): JSX.Element | null {
324
370
  controller.setViewport(v);
325
371
  }, [controller, followTarget, peers]);
326
372
 
327
- // Render whenever there's a human peer OR an active agent (so an agent shows
328
- // even with no human collaborators connected).
329
- if (peers.length === 0 && !agent) return null;
373
+ // Phase 30 / DDR-120 drop the authoring machine's own server-side editing
374
+ // echo: a foreign peer carrying MY name with editing set but no cursor/
375
+ // selection is the room's projection of my own agent/inspector edit, which I
376
+ // already see via the local ai-activity avatar. A remote peer never matches
377
+ // (different name), so they still get the "X is editing" heads-up.
378
+ const myName = collab?.myName;
379
+ const visiblePeers = peers.filter((p) => !isOwnEditingEcho(p, myName));
380
+
381
+ // Render whenever there's a visible human peer OR an active agent (so an agent
382
+ // shows even with no human collaborators connected).
383
+ if (visiblePeers.length === 0 && !agent) return null;
330
384
 
331
385
  return (
332
386
  <div className="dc-participants" aria-label="Active collaborators">
333
- {peers.map((p) => (
387
+ {visiblePeers.map((p) => (
334
388
  <Avatar
335
389
  key={p.clientID}
336
390
  peer={p}
@@ -343,4 +397,4 @@ export function ParticipantsChrome(): JSX.Element | null {
343
397
  );
344
398
  }
345
399
 
346
- export { initialsFor };
400
+ export { initialsFor, isOwnEditingEcho };
@@ -82,6 +82,18 @@ function isDevServerDir(dir: string): boolean {
82
82
  }
83
83
 
84
84
  function resolveDevServerRoot(): string {
85
+ // (0) Explicit override. Used when the runtime can't be found by walk-up — e.g.
86
+ // the Tauri desktop bundle (DDR-106) ships apps/studio/ as an app resource and
87
+ // sets MAUDE_DEV_SERVER_ROOT to it, because the sidecar binary sits alone in
88
+ // Maude.app/Contents/MacOS/ with no apps/studio/ up-tree (the bundle ships the
89
+ // full source, so `http.ts` is present at the resource root).
90
+ const override = process.env.MAUDE_DEV_SERVER_ROOT;
91
+ // Require the same `http.ts` anchor walk-up uses (not merely a `dist/` dir) so a
92
+ // planted dist-only directory can't hijack the runtime root (security review F4).
93
+ if (override && !isVirtualBunfsPath(override) && isDevServerDir(override)) {
94
+ return override;
95
+ }
96
+
85
97
  // (1) Dev mode: import.meta.url is a real file:// path AND lands in the
86
98
  // dev-server dir. Common case for `bun run server.ts`, tests, etc.
87
99
  const importDir = getImportMetaDir();
@@ -0,0 +1,57 @@
1
+ // scaffold-design.ts — write a minimal, BOOTABLE `.design/` into a folder (Phase 28).
2
+ //
3
+ // Used by two native-app flows: "New project" (create a GitHub repo → init a local
4
+ // project) and the "open a repo with no Maude design system" fallback ("set it up?").
5
+ // It deliberately scaffolds only the MINIMUM the dev-server needs to boot (per
6
+ // context.ts the only hard requirement is a `.design/` dir; config needs just
7
+ // `name` + `designRoot`). A real design system is still created by /design:setup-ds
8
+ // (agent-driven) — this just gets the project to open instead of crash-looping.
9
+
10
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
11
+ import { basename, join } from 'node:path';
12
+
13
+ const CONFIG_SCHEMA =
14
+ 'https://raw.githubusercontent.com/1aGh/maude/main/apps/studio/config.schema.json';
15
+
16
+ /** Whether `dir` is already a Maude project (has `.design/config.json`). */
17
+ export function hasDesign(dir: string): boolean {
18
+ return existsSync(join(dir, '.design', 'config.json'));
19
+ }
20
+
21
+ export interface ScaffoldResult {
22
+ ok: boolean;
23
+ error?: string;
24
+ }
25
+
26
+ /** Scaffold a minimal bootable `.design/` (no design system yet). Refuses to
27
+ * clobber an existing project. `name` is the human project label. */
28
+ export function scaffoldDesign(dir: string, name?: string): ScaffoldResult {
29
+ const designDir = join(dir, '.design');
30
+ const configPath = join(designDir, 'config.json');
31
+ if (existsSync(configPath)) {
32
+ return { ok: false, error: 'This folder is already a Maude project.' };
33
+ }
34
+ const projectName = (name && name.trim()) || basename(dir) || 'Untitled';
35
+ try {
36
+ mkdirSync(join(designDir, 'ui'), { recursive: true });
37
+ mkdirSync(join(designDir, 'system'), { recursive: true });
38
+ const config = {
39
+ $schema: CONFIG_SCHEMA,
40
+ name: projectName,
41
+ designRoot: '.design',
42
+ canvasGroups: [
43
+ { label: 'Design system', path: 'system' },
44
+ { label: 'UI kit', path: 'ui' },
45
+ ],
46
+ designSystems: [],
47
+ completenessProfile: 'standard',
48
+ };
49
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
50
+ // Keep the empty group dirs in git so a fresh clone still has the structure.
51
+ writeFileSync(join(designDir, 'ui', '.gitkeep'), '', 'utf8');
52
+ writeFileSync(join(designDir, 'system', '.gitkeep'), '', 'utf8');
53
+ return { ok: true };
54
+ } catch (e) {
55
+ return { ok: false, error: e instanceof Error ? e.message : 'Could not set up the project.' };
56
+ }
57
+ }
@@ -16,14 +16,17 @@
16
16
 
17
17
  import { spawn } from 'node:child_process';
18
18
 
19
+ import { createAcp } from './acp/index.ts';
19
20
  import { createActivity } from './activity.ts';
20
21
  import { createApi } from './api.ts';
21
22
  import { bootSelfHeal } from './boot-self-heal.ts';
22
- import { createAiActivity } from './collab/ai-activity.ts';
23
+ import { createCanvasListWatch } from './canvas-list-watch.ts';
24
+ import { type AiActivityEntry, createAiActivity } from './collab/ai-activity.ts';
23
25
  import { createGitLifecycle } from './collab/git-lifecycle.ts';
24
26
  import { createCollab } from './collab/index.ts';
25
27
  import { createContext } from './context.ts';
26
28
  import { createFsWatch } from './fs-watch.ts';
29
+ import { createGitWatch } from './git/watch.ts';
27
30
  import { createHttp } from './http.ts';
28
31
  import { createInspect } from './inspect.ts';
29
32
  import { startHeapWatch } from './mem.ts';
@@ -77,11 +80,28 @@ await inspect.load();
77
80
 
78
81
  collab = createCollab(ctx, api);
79
82
  const aiActivity = createAiActivity(ctx);
83
+
84
+ // Phase 30 — bridge agent `ai-activity` onto the per-canvas room awareness so a
85
+ // remote peer sees "X is editing" cross-machine. The `ai-activity` bus event is
86
+ // loopback-only (inspector WS); awareness is the one channel that crosses the
87
+ // hub. Soft heads-up — projected onto the room's own awareness slot, cleared
88
+ // when the activity ends/expires. No-op when no room is live for the slug.
89
+ ctx.bus.on('ai-activity', (payload: { file: string; entry: AiActivityEntry | null }) => {
90
+ if (!collab) return;
91
+ const slug = api.fileSlug(payload.file);
92
+ collab.registry.setAgentEditing(
93
+ slug,
94
+ payload.entry ? { name: payload.entry.author, since: payload.entry.startedAt } : null
95
+ );
96
+ });
80
97
  const gitLifecycle = createGitLifecycle(ctx, collab.registry);
81
98
  // Phase 13 / DDR-029 — fs-watch-driven canvas activity overlay. Subscribes to
82
99
  // `fs:any` and emits `activity:change`; ws.ts forwards it to canvas iframes.
83
100
  const activity = createActivity(ctx);
84
- const ws = createWs(ctx, api, inspect, collab, activity);
101
+ // Phase 31 (DDR-123) ACP chat bridge manager. Owns one claude-agent-acp
102
+ // subprocess per /_ws/acp socket; main-origin + loopback only (wired below).
103
+ const acp = createAcp(ctx);
104
+ const ws = createWs(ctx, api, inspect, collab, activity, acp);
85
105
  const http = createHttp(ctx, api, inspect, aiActivity);
86
106
  const fsWatch = createFsWatch(ctx);
87
107
 
@@ -141,6 +161,26 @@ function startServer(port: number): BunServer {
141
161
  return new Response('Upgrade failed', { status: 400 });
142
162
  }
143
163
 
164
+ // Phase 31 (DDR-123) — ACP chat bridge WS. Main origin ONLY (this server,
165
+ // never startCanvasServer) and loopback-guarded like collab: the bridge
166
+ // spawns the user's `claude` and can drive file edits, so the untrusted
167
+ // canvas origin and remote hosts must never reach it. Checked BEFORE the
168
+ // generic `/_ws` inspector branch since `/_ws/acp`.startsWith('/_ws').
169
+ if (pathname === '/_ws/acp') {
170
+ if (!isLoopbackHost(req.headers.get('host'))) {
171
+ return new Response('ACP chat is loopback-only', { status: 403 });
172
+ }
173
+ const ok = srv.upgrade(req, {
174
+ data: {
175
+ id: crypto.randomUUID(),
176
+ remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
177
+ kind: 'acp',
178
+ },
179
+ });
180
+ if (ok) return undefined as unknown as Response;
181
+ return new Response('Upgrade failed', { status: 400 });
182
+ }
183
+
144
184
  // Legacy inspector WS — JSON frames, designer-facing live tab state.
145
185
  if (pathname.startsWith('/_ws')) {
146
186
  const ok = srv.upgrade(req, {
@@ -294,8 +334,11 @@ let server: BunServer;
294
334
 
295
335
  // T2 (9.1-A) — advertise the main origin so the canvas origin's CSP can
296
336
  // allowlist it in `frame-ancestors` (the legit embedder). Must be set before
297
- // the canvas listener serves its first shell.
298
- ctx.mainOrigin = `http://localhost:${server.port}`;
337
+ // the canvas listener serves its first shell. Both loopback host spellings are
338
+ // listed: the server binds 127.0.0.1, so a user who opens the printed URL as
339
+ // `127.0.0.1:<port>` is the same legit embedder — with only `localhost` allowed
340
+ // the canvas iframe was silently refused (blank sad-page, no error anywhere).
341
+ ctx.mainOrigin = `http://localhost:${server.port} http://127.0.0.1:${server.port}`;
299
342
 
300
343
  // T2 (9.1-A) — segregated canvas-content origin. ON BY DEFAULT (opt-OUT) since
301
344
  // phase-9.1: a second listener binds an OS-assigned free port, advertised as
@@ -336,6 +379,19 @@ await Bun.write(
336
379
  fsWatch.start();
337
380
  startHeapWatch();
338
381
 
382
+ // Phase 27 Task 5 (E2) — live dirty-state. Subscribes to `fs:any` (after
383
+ // fsWatch.start so it sees every event) and broadcasts `git-status` to the shell
384
+ // on each versionable change. No-op for a non-git project (gitStatus → repo:false).
385
+ const gitWatch = createGitWatch(ctx);
386
+
387
+ // Phase 31 follow-up — external-canvas list watcher. Subscribes to `fs:any` and
388
+ // emits `canvas-list-update` when a canvas file appears/disappears on disk from
389
+ // OUTSIDE the dev-server (ACP agent `/design:new`, agent Write, git checkout),
390
+ // so the browser file tree refreshes without a reload — the symmetric
391
+ // counterpart to api.ts's create/delete emit. RCA:
392
+ // `.ai/logs/rca/issue-acp-new-canvas-not-in-filetree.md`.
393
+ const canvasListWatch = createCanvasListWatch(ctx);
394
+
339
395
  // Phase 9 Task 4 — bidirectional sync agent. No-op when the project isn't
340
396
  // linked to a hub (`.design/config.json` has no `linkedHub` field). Kicked
341
397
  // off after fsWatch so the agent's bus subscription receives every fs event.
@@ -368,6 +424,16 @@ if (!process.env.NO_OPEN) {
368
424
  async function shutdown() {
369
425
  console.log('\n Stopping…');
370
426
  fsWatch.stop();
427
+ try {
428
+ gitWatch.stop();
429
+ } catch {
430
+ /* timer cleanup is best-effort */
431
+ }
432
+ try {
433
+ canvasListWatch.stop();
434
+ } catch {
435
+ /* timer cleanup is best-effort */
436
+ }
371
437
  try {
372
438
  activity.stop();
373
439
  } catch {