@1agh/maude 0.30.0 → 0.32.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 (93) hide show
  1. package/README.md +5 -5
  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/annotations-layer.tsx +6 -1
  8. package/apps/studio/api.ts +225 -66
  9. package/apps/studio/bin/chat-open.sh +44 -0
  10. package/apps/studio/canvas-lib.tsx +112 -19
  11. package/apps/studio/canvas-list-watch.ts +177 -0
  12. package/apps/studio/canvas-shell.tsx +22 -2
  13. package/apps/studio/client/app.jsx +788 -26
  14. package/apps/studio/client/canvas-url.js +5 -0
  15. package/apps/studio/client/github.js +99 -0
  16. package/apps/studio/client/panels/ChatPanel.jsx +796 -0
  17. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  18. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  19. package/apps/studio/client/panels/DiffView.jsx +590 -0
  20. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  21. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  22. package/apps/studio/client/panels/OnboardingWizard.jsx +598 -0
  23. package/apps/studio/client/panels/ReadinessList.jsx +189 -0
  24. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  25. package/apps/studio/client/panels/acp-runtime.js +286 -0
  26. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  27. package/apps/studio/client/panels/git-grouping.js +86 -0
  28. package/apps/studio/client/styles/0-reset.css +4 -0
  29. package/apps/studio/client/styles/3-shell-maude.css +625 -3
  30. package/apps/studio/client/styles/5-maude-overrides.css +25 -0
  31. package/apps/studio/client/styles/6-acp-chat.css +821 -0
  32. package/apps/studio/client/styles/_index.css +2 -0
  33. package/apps/studio/client/tour/collab-tour.js +61 -0
  34. package/apps/studio/client/tour/overlay.jsx +11 -1
  35. package/apps/studio/client/whats-new.jsx +25 -10
  36. package/apps/studio/collab/registry.ts +13 -0
  37. package/apps/studio/collab/room.ts +36 -0
  38. package/apps/studio/cursors-overlay.tsx +17 -1
  39. package/apps/studio/dist/client.bundle.js +29097 -1534
  40. package/apps/studio/dist/comment-mount.js +4 -2
  41. package/apps/studio/dist/styles.css +5816 -1614
  42. package/apps/studio/git/endpoints.ts +338 -0
  43. package/apps/studio/git/service.ts +1334 -0
  44. package/apps/studio/git/watch.ts +97 -0
  45. package/apps/studio/github/endpoints.ts +358 -0
  46. package/apps/studio/github/service.ts +231 -0
  47. package/apps/studio/github/token.ts +53 -0
  48. package/apps/studio/hmr-broadcast.ts +9 -2
  49. package/apps/studio/http.ts +399 -1
  50. package/apps/studio/participants-chrome.tsx +69 -9
  51. package/apps/studio/paths.ts +12 -0
  52. package/apps/studio/readiness.ts +220 -0
  53. package/apps/studio/scaffold-design.ts +57 -0
  54. package/apps/studio/server.ts +65 -2
  55. package/apps/studio/sync/agent.ts +81 -1
  56. package/apps/studio/sync/codec.ts +24 -0
  57. package/apps/studio/sync/cold-start.ts +40 -0
  58. package/apps/studio/sync/hub-link.ts +137 -0
  59. package/apps/studio/test/acp-bridge.test.ts +127 -0
  60. package/apps/studio/test/acp-env.test.ts +65 -0
  61. package/apps/studio/test/acp-origin-gate.test.ts +95 -0
  62. package/apps/studio/test/acp-transcript.test.ts +112 -0
  63. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  64. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  65. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  66. package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
  67. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  68. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  69. package/apps/studio/test/csrf-write-guard.test.ts +26 -0
  70. package/apps/studio/test/editing-presence.test.ts +103 -0
  71. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  72. package/apps/studio/test/git-api.test.ts +0 -0
  73. package/apps/studio/test/git-branches.test.ts +106 -0
  74. package/apps/studio/test/git-grouping.test.ts +106 -0
  75. package/apps/studio/test/git-watch.test.ts +97 -0
  76. package/apps/studio/test/github-api.test.ts +465 -0
  77. package/apps/studio/test/hub-link.test.ts +69 -0
  78. package/apps/studio/test/participants-chrome.test.ts +36 -1
  79. package/apps/studio/test/readiness.test.ts +127 -0
  80. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  81. package/apps/studio/test/sync-cold-start.test.ts +61 -1
  82. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  83. package/apps/studio/tool-palette.tsx +18 -9
  84. package/apps/studio/use-chrome-visibility.tsx +66 -0
  85. package/apps/studio/use-collab.tsx +414 -187
  86. package/apps/studio/whats-new.json +82 -0
  87. package/apps/studio/ws.ts +44 -1
  88. package/cli/commands/design.mjs +1 -0
  89. package/cli/lib/gitignore-block.mjs +16 -3
  90. package/cli/lib/gitignore-block.test.mjs +13 -1
  91. package/package.json +11 -9
  92. package/plugins/design/dependencies.json +17 -0
  93. package/plugins/design/templates/_shell.html +30 -8
@@ -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 === ' ') {
@@ -194,14 +240,20 @@ function Avatar({ peer, isFollowing, onToggleFollow }: AvatarProps): JSX.Element
194
240
  }}
195
241
  role="button"
196
242
  tabIndex={0}
197
- title={peer.name}
198
- 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}
199
245
  aria-expanded={open}
200
246
  >
201
247
  {initialsFor(peer.name)}
248
+ {editing && (
249
+ <span className="dc-participant-edit-marker" aria-hidden="true">
250
+
251
+ </span>
252
+ )}
202
253
  {open && (
203
254
  <div className="dc-participant-popover" onClick={(e) => e.stopPropagation()}>
204
255
  <div className="dc-participant-popover__name">{peer.name}</div>
256
+ {editing && <div className="dc-participant-popover__sub">Editing this canvas</div>}
205
257
  <button
206
258
  type="button"
207
259
  className={`dc-participant-popover__btn${isFollowing ? ' dc-participant-popover__btn--stop' : ''}`}
@@ -318,13 +370,21 @@ export function ParticipantsChrome(): JSX.Element | null {
318
370
  controller.setViewport(v);
319
371
  }, [controller, followTarget, peers]);
320
372
 
321
- // Render whenever there's a human peer OR an active agent (so an agent shows
322
- // even with no human collaborators connected).
323
- 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;
324
384
 
325
385
  return (
326
386
  <div className="dc-participants" aria-label="Active collaborators">
327
- {peers.map((p) => (
387
+ {visiblePeers.map((p) => (
328
388
  <Avatar
329
389
  key={p.clientID}
330
390
  peer={p}
@@ -337,4 +397,4 @@ export function ParticipantsChrome(): JSX.Element | null {
337
397
  );
338
398
  }
339
399
 
340
- 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,220 @@
1
+ // First-open AI-editing readiness probe (DDR-128). Backs `GET /_api/preflight`.
2
+ //
3
+ // Read-only: it reports which pieces of the AI-editing chain are present — the
4
+ // `claude` CLI, the `maude` CLI, the maude marketplace + plugins registered in the
5
+ // paired Claude Code, and the optional `agent-browser` — with per-item remediation.
6
+ // It NEVER installs, links, or mutates anything (DDR-128 detect-and-guide posture).
7
+ //
8
+ // PATH accuracy: in the packaged `.app` the sidecar's PATH is corrected at the Rust
9
+ // boundary (apps/desktop/.../sidecar.rs, DDR-128), so `Bun.which` is accurate here;
10
+ // `resolveOnPath` keeps a login-shell fallback as defense-in-depth for an unusual
11
+ // shell config the Rust resolution missed. Under `maude design serve` the terminal
12
+ // PATH is already correct.
13
+
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { join } from 'node:path';
17
+
18
+ import { resolveClaudePath } from './acp/probe.ts';
19
+
20
+ export type ReadinessStatus = 'present' | 'missing' | 'unknown';
21
+
22
+ export interface ReadinessItem {
23
+ id: 'claude' | 'maude' | 'plugins' | 'agent-browser';
24
+ /** Short human label for the row. */
25
+ label: string;
26
+ /** Required items gate `ready`; optional ones never block it. */
27
+ required: boolean;
28
+ status: ReadinessStatus;
29
+ /** One-line current-state description. */
30
+ detail: string;
31
+ /** Copy-paste-able fix, present only when the item is not satisfied. */
32
+ remediation?: string;
33
+ }
34
+
35
+ export interface ReadinessReport {
36
+ /** True when every REQUIRED item is `present`. */
37
+ ready: boolean;
38
+ items: ReadinessItem[];
39
+ }
40
+
41
+ /**
42
+ * Resolve a binary on PATH. `Bun.which` first (accurate once the sidecar PATH is
43
+ * Rust-corrected, and always correct under a terminal launch); a login-shell
44
+ * fallback recovers a binary the app env can't see when the Rust resolution missed.
45
+ * Unix-only fallback (Windows GUI apps inherit the user PATH). Returns an absolute
46
+ * path or null. `bin` is always a hardcoded literal — never user input.
47
+ *
48
+ * Async + `Bun.spawn` (not `spawnSync`): the fallback shells out for up to 5 s, so a
49
+ * synchronous spawn would block the single-threaded dev-server event loop for the
50
+ * whole probe — a real freeze on a fresh machine where the binary is missing
51
+ * (DDR-128 hardening, ethical-hacker F1). Awaiting yields the loop; the 5 s kill
52
+ * bounds a misconfigured rc.
53
+ */
54
+ export async function resolveOnPath(bin: string): Promise<string | null> {
55
+ const direct = Bun.which(bin);
56
+ if (direct) return direct;
57
+ if (process.platform === 'win32') return null;
58
+ try {
59
+ const shell = process.env.SHELL || '/bin/sh';
60
+ const proc = Bun.spawn([shell, '-ilc', `command -v ${bin} 2>/dev/null`], {
61
+ stdin: 'ignore',
62
+ stdout: 'pipe',
63
+ stderr: 'ignore',
64
+ });
65
+ const killer = setTimeout(() => {
66
+ try {
67
+ proc.kill(9);
68
+ } catch {
69
+ /* already gone */
70
+ }
71
+ }, 5000);
72
+ let out: string;
73
+ try {
74
+ out = await new Response(proc.stdout).text();
75
+ } finally {
76
+ clearTimeout(killer);
77
+ }
78
+ // Instant-prompt frameworks (powerlevel10k) can print to stdout on interactive
79
+ // start, so take the last line that is an absolute path to a real file.
80
+ const hit = out
81
+ .split('\n')
82
+ .map((l) => l.trim())
83
+ .reverse()
84
+ .find((l) => l.startsWith('/') && existsSync(l));
85
+ return hit ?? null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /** Claude Code's config dir — relocatable via `CLAUDE_CONFIG_DIR` (its own contract). */
92
+ function claudeConfigDir(): string {
93
+ return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
94
+ }
95
+
96
+ function readJson<T = unknown>(path: string): T | null {
97
+ try {
98
+ return JSON.parse(readFileSync(path, 'utf8')) as T;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ interface PluginScan {
105
+ /** 'unknown' when the registry couldn't be read (Claude Code's internal contract). */
106
+ status: 'present' | 'unknown';
107
+ marketplace: boolean;
108
+ design: boolean;
109
+ flow: boolean;
110
+ }
111
+
112
+ /**
113
+ * Read-only scan of Claude Code's plugin registry for the maude marketplace
114
+ * (`repo: 1aGh/maude`) and the `design@maude` / `flow@maude` plugins. `readFileSync`
115
+ * follows symlinks (a dev's `~/.claude` is symlinked into Dotfiles). Never writes,
116
+ * never throws — an unrecognized layout yields `status: 'unknown'`.
117
+ */
118
+ function scanPlugins(): PluginScan {
119
+ const dir = claudeConfigDir();
120
+ const markets = readJson<Record<string, { source?: { repo?: string } }>>(
121
+ join(dir, 'plugins', 'known_marketplaces.json')
122
+ );
123
+ const installed = readJson<{ plugins?: Record<string, unknown> }>(
124
+ join(dir, 'plugins', 'installed_plugins.json')
125
+ );
126
+ if (!markets && !installed) {
127
+ return { status: 'unknown', marketplace: false, design: false, flow: false };
128
+ }
129
+ const marketplace =
130
+ !!markets &&
131
+ Object.values(markets).some(
132
+ (m) => String(m?.source?.repo ?? '').toLowerCase() === '1agh/maude'
133
+ );
134
+ const plugins = installed?.plugins ?? {};
135
+ const has = (key: string): boolean => {
136
+ const v = plugins[key];
137
+ return Array.isArray(v) ? v.length > 0 : !!v;
138
+ };
139
+ return { status: 'present', marketplace, design: has('design@maude'), flow: has('flow@maude') };
140
+ }
141
+
142
+ /**
143
+ * Side-effect-free readiness report. The three binary probes run concurrently so a
144
+ * fresh machine (where the login-shell fallback fires) resolves in ~one shell round
145
+ * trip, not three sequential ones. The backing route gates cross-origin callers
146
+ * (DDR-128 hardening) so this can't be turned into a spawn-storm from a drive-by page.
147
+ */
148
+ export async function probeReadiness(): Promise<ReadinessReport> {
149
+ const [claude, maude, agentBrowser] = await Promise.all([
150
+ (async () => resolveClaudePath() ?? (await resolveOnPath('claude')))(),
151
+ resolveOnPath('maude'),
152
+ resolveOnPath('agent-browser'),
153
+ ]);
154
+
155
+ const items: ReadinessItem[] = [];
156
+
157
+ items.push({
158
+ id: 'claude',
159
+ label: 'Claude Code (the `claude` CLI)',
160
+ required: true,
161
+ status: claude ? 'present' : 'missing',
162
+ detail: claude
163
+ ? 'Installed — AI editing drives it on your own Pro/Max subscription.'
164
+ : 'Not found on PATH.',
165
+ remediation: claude
166
+ ? undefined
167
+ : 'Install Claude Code, then run `claude` and `/login` once. AI editing runs on your own Pro/Max subscription — never API billing.',
168
+ });
169
+
170
+ items.push({
171
+ id: 'maude',
172
+ label: 'maude CLI',
173
+ required: true,
174
+ status: maude ? 'present' : 'missing',
175
+ detail: maude ? 'On PATH — `/design:edit` can reach its helpers.' : 'Not found on PATH.',
176
+ remediation: maude
177
+ ? undefined
178
+ : 'Install it: `npm i -g @1agh/maude`. `/design:edit` shells out to `maude design …`, so it must be on PATH.',
179
+ });
180
+
181
+ const scan = scanPlugins();
182
+ const pluginsPresent = scan.design && scan.flow;
183
+ const pluginStatus: ReadinessStatus =
184
+ scan.status === 'unknown' ? 'unknown' : pluginsPresent ? 'present' : 'missing';
185
+ const missing = [!scan.design && 'design@maude', !scan.flow && 'flow@maude']
186
+ .filter(Boolean)
187
+ .join(' + ');
188
+ items.push({
189
+ id: 'plugins',
190
+ label: 'Maude plugins in Claude Code',
191
+ required: true,
192
+ status: pluginStatus,
193
+ detail:
194
+ scan.status === 'unknown'
195
+ ? "Couldn't read Claude Code's plugin registry — check it manually."
196
+ : pluginsPresent
197
+ ? 'design@maude + flow@maude are installed.'
198
+ : `Missing: ${missing}${scan.marketplace ? '' : ' (and the maude marketplace)'}.`,
199
+ remediation:
200
+ pluginStatus === 'present'
201
+ ? undefined
202
+ : 'In Claude Code: `/plugin marketplace add 1aGh/maude`, then `/plugin install design@maude` and `/plugin install flow@maude`.',
203
+ });
204
+
205
+ items.push({
206
+ id: 'agent-browser',
207
+ label: 'agent-browser (optional)',
208
+ required: false,
209
+ status: agentBrowser ? 'present' : 'missing',
210
+ detail: agentBrowser
211
+ ? 'Installed — screenshot evidence during edits.'
212
+ : 'Optional — richer screenshot evidence during edits.',
213
+ remediation: agentBrowser
214
+ ? undefined
215
+ : 'Optional. Install `agent-browser` for screenshot evidence during `/design:edit`.',
216
+ });
217
+
218
+ const ready = items.filter((i) => i.required).every((i) => i.status === 'present');
219
+ return { ready, items };
220
+ }
@@ -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, {
@@ -339,6 +379,19 @@ await Bun.write(
339
379
  fsWatch.start();
340
380
  startHeapWatch();
341
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
+
342
395
  // Phase 9 Task 4 — bidirectional sync agent. No-op when the project isn't
343
396
  // linked to a hub (`.design/config.json` has no `linkedHub` field). Kicked
344
397
  // off after fsWatch so the agent's bus subscription receives every fs event.
@@ -371,6 +424,16 @@ if (!process.env.NO_OPEN) {
371
424
  async function shutdown() {
372
425
  console.log('\n Stopping…');
373
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
+ }
374
437
  try {
375
438
  activity.stop();
376
439
  } catch {