@1agh/maude 0.37.0 → 0.38.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 (61) hide show
  1. package/README.md +2 -0
  2. package/apps/studio/acp/bootstrap-brief.ts +93 -0
  3. package/apps/studio/acp/bridge.ts +114 -1
  4. package/apps/studio/acp/index.ts +184 -21
  5. package/apps/studio/acp/plugin-bootstrap.ts +115 -0
  6. package/apps/studio/acp/transcript.ts +36 -3
  7. package/apps/studio/activity.ts +45 -0
  8. package/apps/studio/api.ts +265 -47
  9. package/apps/studio/bin/_ensure-browser.mjs +305 -0
  10. package/apps/studio/bin/ensure-browser.sh +26 -0
  11. package/apps/studio/bin/screenshot.sh +33 -8
  12. package/apps/studio/bin/smoke.sh +46 -0
  13. package/apps/studio/canvas-edit.ts +422 -6
  14. package/apps/studio/canvas-lib.tsx +48 -0
  15. package/apps/studio/canvas-shell.tsx +684 -12
  16. package/apps/studio/client/app.jsx +683 -33
  17. package/apps/studio/client/panels/ChatPanel.jsx +593 -31
  18. package/apps/studio/client/panels/acp-runtime.js +227 -70
  19. package/apps/studio/client/panels/chat-context.js +124 -0
  20. package/apps/studio/client/panels/slash-commands.js +147 -0
  21. package/apps/studio/client/styles/3-shell-maude.css +15 -0
  22. package/apps/studio/client/styles/6-acp-chat.css +244 -0
  23. package/apps/studio/commands/reorder-command.ts +77 -0
  24. package/apps/studio/config.schema.json +6 -0
  25. package/apps/studio/dist/client.bundle.js +25 -53617
  26. package/apps/studio/dist/styles.css +1 -1
  27. package/apps/studio/hmr-broadcast.ts +27 -19
  28. package/apps/studio/http.ts +168 -26
  29. package/apps/studio/inspect.ts +108 -1
  30. package/apps/studio/paths.ts +32 -0
  31. package/apps/studio/readiness.ts +79 -30
  32. package/apps/studio/server.ts +11 -2
  33. package/apps/studio/test/acp-activity.test.ts +154 -0
  34. package/apps/studio/test/acp-ai-activity.test.ts +182 -0
  35. package/apps/studio/test/acp-bootstrap-brief.test.ts +167 -0
  36. package/apps/studio/test/acp-commands.test.ts +108 -0
  37. package/apps/studio/test/acp-origin-gate.test.ts +64 -1
  38. package/apps/studio/test/acp-plugin-bootstrap.test.ts +89 -0
  39. package/apps/studio/test/acp-session-plugins.test.ts +132 -0
  40. package/apps/studio/test/acp-transcript.test.ts +53 -0
  41. package/apps/studio/test/active-state.test.ts +41 -0
  42. package/apps/studio/test/canvas-freshness-deps.test.ts +64 -0
  43. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  44. package/apps/studio/test/canvas-reorder.test.ts +211 -0
  45. package/apps/studio/test/chat-context.test.ts +129 -0
  46. package/apps/studio/test/csrf-write-guard.test.ts +24 -0
  47. package/apps/studio/test/edit-suppress.test.ts +170 -0
  48. package/apps/studio/test/ensure-browser.test.ts +92 -0
  49. package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +44 -0
  50. package/apps/studio/test/hmr-broadcast.test.ts +44 -0
  51. package/apps/studio/test/inspect-selections.test.ts +219 -0
  52. package/apps/studio/test/paths.test.ts +57 -0
  53. package/apps/studio/test/readiness.test.ts +83 -13
  54. package/apps/studio/test/reorder-api.test.ts +210 -0
  55. package/apps/studio/test/slash-commands.test.ts +117 -0
  56. package/apps/studio/undo-stack.ts +6 -0
  57. package/apps/studio/whats-new.json +45 -0
  58. package/apps/studio/ws.ts +37 -0
  59. package/cli/commands/design.mjs +1 -0
  60. package/package.json +8 -8
  61. package/plugins/design/dependencies.json +3 -3
@@ -0,0 +1,210 @@
1
+ // POST /_api/reorder round-trip (DDR-138, phase-12.1). Boots a real server,
2
+ // serves a canvas (which injects data-cd-id + writes _locator.json), then drives
3
+ // a node-move reorder through the HTTP layer and asserts: the source reorders on
4
+ // disk, the response carries the recomputed movedId, a pre-reorder history
5
+ // snapshot is written (so /design:rollback can undo it), and a cross-origin POST
6
+ // is CSRF-rejected. Route unreachability from the canvas origin is proven
7
+ // separately in canvas-origin-gate.test.ts.
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
14
+
15
+ const CANVAS_SRC = `export default function List() {
16
+ return (
17
+ <section>
18
+ <div>A</div>
19
+ <div>B</div>
20
+ <div>C</div>
21
+ </section>
22
+ );
23
+ }
24
+ `;
25
+
26
+ /** After a GET of the canvas, read _locator.json and return the div ids ordered
27
+ * by source line (== authored order A, B, C). */
28
+ async function divIdsByLine(main: string, designRoot: string): Promise<string[]> {
29
+ // Serving the canvas triggers pass-1 id injection + _locator.json write.
30
+ const r = await fetch(`${main}/.design/ui/List.tsx`, { signal: AbortSignal.timeout(2000) });
31
+ expect(r.status).toBe(200);
32
+ const locator = JSON.parse(readFileSync(join(designRoot, '_locator.json'), 'utf8'));
33
+ const map = locator['ui/List'] as Record<string, { line: number; jsxPath: string[] }>;
34
+ return Object.entries(map)
35
+ .filter(([, e]) => e.jsxPath[e.jsxPath.length - 1] === 'div')
36
+ .sort((a, b) => a[1].line - b[1].line)
37
+ .map(([id]) => id);
38
+ }
39
+
40
+ /** The visible letter order (A/B/C) in the on-disk source — reflects sibling order. */
41
+ function letterOrder(src: string): string[] {
42
+ return [...src.matchAll(/>([ABC])</g)].map((m) => m[1] as string);
43
+ }
44
+
45
+ describe('POST /_api/reorder — HTTP round-trip', () => {
46
+ test('moves a sibling on disk, returns movedId, snapshots pre-reorder, CSRF-guards', async () => {
47
+ const { root, designRoot } = makeSandbox();
48
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
49
+ const canvasPath = join(designRoot, 'ui', 'List.tsx');
50
+ writeFileSync(canvasPath, CANVAS_SRC);
51
+
52
+ const port = nextPort();
53
+ const main = `http://localhost:${port}`;
54
+ const proc = await bootServer(root, port);
55
+ try {
56
+ const [aId, , cId] = await divIdsByLine(main, designRoot);
57
+
58
+ // Move A after C → order becomes B, C, A.
59
+ const res = await fetch(`${main}/_api/reorder`, {
60
+ method: 'POST',
61
+ // No Origin header → same-origin/programmatic; sameOriginWrite allows it.
62
+ body: JSON.stringify({ canvas: 'ui/List', id: aId, refId: cId, position: 'after' }),
63
+ signal: AbortSignal.timeout(2000),
64
+ });
65
+ expect(res.status).toBe(200);
66
+ const json = (await res.json()) as {
67
+ ok: boolean;
68
+ movedId: string | null;
69
+ semanticId: string | null;
70
+ };
71
+ expect(json.ok).toBe(true);
72
+ expect(json.movedId).toMatch(/^[0-9a-f]{8}$/);
73
+
74
+ // Source reordered on disk.
75
+ expect(letterOrder(readFileSync(canvasPath, 'utf8'))).toEqual(['B', 'C', 'A']);
76
+
77
+ // A pre-reorder snapshot landed under _history/ui-list/ (rollback path).
78
+ const histDir = join(designRoot, '_history', 'ui-list');
79
+ expect(existsSync(histDir)).toBe(true);
80
+ const metas = readdirSync(histDir)
81
+ .filter((f) => f.endsWith('.json'))
82
+ .map((f) => JSON.parse(readFileSync(join(histDir, f), 'utf8')) as { reason: string });
83
+ expect(metas.some((m) => m.reason === 'pre-reorder')).toBe(true);
84
+
85
+ // Cross-origin POST is CSRF-rejected (403), leaving the file untouched.
86
+ const forged = await fetch(`${main}/_api/reorder`, {
87
+ method: 'POST',
88
+ headers: { origin: 'http://evil.example' },
89
+ body: JSON.stringify({ canvas: 'ui/List', id: cId, refId: aId, position: 'before' }),
90
+ signal: AbortSignal.timeout(2000),
91
+ });
92
+ expect(forged.status).toBe(403);
93
+ expect(letterOrder(readFileSync(canvasPath, 'utf8'))).toEqual(['B', 'C', 'A']);
94
+
95
+ // A self-move is a 422 refusal (guardrail), not a 500.
96
+ const bad = await fetch(`${main}/_api/reorder`, {
97
+ method: 'POST',
98
+ body: JSON.stringify({ canvas: 'ui/List', id: aId, refId: aId, position: 'after' }),
99
+ signal: AbortSignal.timeout(2000),
100
+ });
101
+ expect(bad.status).toBe(422);
102
+ } finally {
103
+ await killProc(proc);
104
+ }
105
+ });
106
+ });
107
+
108
+ describe('POST /_api/reorder-revert — Cmd+Z round-trip', () => {
109
+ test('undo restores the pre-reorder source, redo re-applies, stale content 409s', async () => {
110
+ const { root, designRoot } = makeSandbox();
111
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
112
+ const canvasPath = join(designRoot, 'ui', 'List.tsx');
113
+ writeFileSync(canvasPath, CANVAS_SRC);
114
+
115
+ const port = nextPort();
116
+ const main = `http://localhost:${port}`;
117
+ const proc = await bootServer(root, port);
118
+ try {
119
+ const [aId, , cId] = await divIdsByLine(main, designRoot);
120
+
121
+ // Reorder A after C → B, C, A; the response carries the revert-log seq.
122
+ const res = await fetch(`${main}/_api/reorder`, {
123
+ method: 'POST',
124
+ body: JSON.stringify({ canvas: 'ui/List', id: aId, refId: cId, position: 'after' }),
125
+ signal: AbortSignal.timeout(2000),
126
+ });
127
+ const json = (await res.json()) as { ok: boolean; seq: number };
128
+ expect(json.ok).toBe(true);
129
+ expect(typeof json.seq).toBe('number');
130
+ expect(letterOrder(readFileSync(canvasPath, 'utf8'))).toEqual(['B', 'C', 'A']);
131
+
132
+ // Undo → back to A, B, C.
133
+ const undo = await fetch(`${main}/_api/reorder-revert`, {
134
+ method: 'POST',
135
+ body: JSON.stringify({ canvas: 'ui/List', seq: json.seq, dir: 'undo' }),
136
+ signal: AbortSignal.timeout(2000),
137
+ });
138
+ expect(undo.status).toBe(200);
139
+ expect(letterOrder(readFileSync(canvasPath, 'utf8'))).toEqual(['A', 'B', 'C']);
140
+
141
+ // Redo → B, C, A again.
142
+ const redo = await fetch(`${main}/_api/reorder-revert`, {
143
+ method: 'POST',
144
+ body: JSON.stringify({ canvas: 'ui/List', seq: json.seq, dir: 'redo' }),
145
+ signal: AbortSignal.timeout(2000),
146
+ });
147
+ expect(redo.status).toBe(200);
148
+ expect(letterOrder(readFileSync(canvasPath, 'utf8'))).toEqual(['B', 'C', 'A']);
149
+
150
+ // External edit → undo refuses with 409 and leaves the file alone.
151
+ const edited = readFileSync(canvasPath, 'utf8').replace('>B<', '>B!<');
152
+ writeFileSync(canvasPath, edited);
153
+ const stale = await fetch(`${main}/_api/reorder-revert`, {
154
+ method: 'POST',
155
+ body: JSON.stringify({ canvas: 'ui/List', seq: json.seq, dir: 'undo' }),
156
+ signal: AbortSignal.timeout(2000),
157
+ });
158
+ expect(stale.status).toBe(409);
159
+ expect(readFileSync(canvasPath, 'utf8')).toBe(edited);
160
+
161
+ // Unknown seq → 404; forged cross-origin POST → 403.
162
+ const missing = await fetch(`${main}/_api/reorder-revert`, {
163
+ method: 'POST',
164
+ body: JSON.stringify({ canvas: 'ui/List', seq: 99999, dir: 'undo' }),
165
+ signal: AbortSignal.timeout(2000),
166
+ });
167
+ expect(missing.status).toBe(404);
168
+ const forged = await fetch(`${main}/_api/reorder-revert`, {
169
+ method: 'POST',
170
+ headers: { origin: 'http://evil.example' },
171
+ body: JSON.stringify({ canvas: 'ui/List', seq: json.seq, dir: 'undo' }),
172
+ signal: AbortSignal.timeout(2000),
173
+ });
174
+ expect(forged.status).toBe(403);
175
+ } finally {
176
+ await killProc(proc);
177
+ }
178
+ });
179
+ });
180
+
181
+ // Adversarial F3 (DDR-139): a REJECTED reorder must NOT deposit a _history
182
+ // snapshot — the pre-move snapshot + revert-log are written only on a confirmed
183
+ // write, so a stream of failing requests can't disk-fill _history.
184
+ describe('POST /_api/reorder — a rejected move writes no snapshot', () => {
185
+ test('an unknown-refId reorder 422s and leaves _history empty', async () => {
186
+ const { root, designRoot } = makeSandbox();
187
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
188
+ writeFileSync(join(designRoot, 'ui', 'List.tsx'), CANVAS_SRC);
189
+ const port = nextPort();
190
+ const main = `http://localhost:${port}`;
191
+ const proc = await bootServer(root, port);
192
+ try {
193
+ const [aId] = await divIdsByLine(main, designRoot);
194
+ const bad = await fetch(`${main}/_api/reorder`, {
195
+ method: 'POST',
196
+ body: JSON.stringify({ canvas: 'ui/List', id: aId, refId: 'deadbeef', position: 'after' }),
197
+ signal: AbortSignal.timeout(2000),
198
+ });
199
+ expect(bad.status).toBe(422); // refId not found → moveElement throws
200
+ // No pre-reorder snapshot was written (the move never landed).
201
+ const histDir = join(designRoot, '_history', 'ui-list');
202
+ const snaps = existsSync(histDir)
203
+ ? readdirSync(histDir).filter((f) => f.endsWith('.json'))
204
+ : [];
205
+ expect(snaps.length).toBe(0);
206
+ } finally {
207
+ await killProc(proc);
208
+ }
209
+ });
210
+ });
@@ -0,0 +1,117 @@
1
+ // Unit tests for the pure command model behind the ACP composer autocomplete +
2
+ // inline pill (client/panels/slash-commands.js). No DOM — importable directly.
3
+
4
+ import { describe, expect, test } from 'bun:test';
5
+
6
+ import {
7
+ buildCommandModel,
8
+ filterCommands,
9
+ groupOf,
10
+ matchLeadingCommand,
11
+ normalizeName,
12
+ STATIC_COMMANDS,
13
+ } from '../client/panels/slash-commands.js';
14
+
15
+ describe('normalizeName', () => {
16
+ test('strips a leading slash, lowercases, trims', () => {
17
+ expect(normalizeName('/design:Edit')).toBe('design:edit');
18
+ expect(normalizeName(' FLOW:Plan ')).toBe('flow:plan');
19
+ expect(normalizeName('design:edit')).toBe('design:edit');
20
+ });
21
+ test('is empty-safe', () => {
22
+ expect(normalizeName('')).toBe('');
23
+ expect(normalizeName(null as unknown as string)).toBe('');
24
+ expect(normalizeName(undefined as unknown as string)).toBe('');
25
+ });
26
+ });
27
+
28
+ describe('groupOf', () => {
29
+ test('extracts the plugin prefix', () => {
30
+ expect(groupOf('design:edit')).toBe('design');
31
+ expect(groupOf('flow:bug-fix')).toBe('flow');
32
+ expect(groupOf('bare')).toBe('other');
33
+ });
34
+ });
35
+
36
+ describe('matchLeadingCommand', () => {
37
+ test('typing mode: leading slash token with no space', () => {
38
+ expect(matchLeadingCommand('/')).toEqual({ token: '', full: null, typing: true });
39
+ expect(matchLeadingCommand('/desi')).toEqual({ token: 'desi', full: null, typing: true });
40
+ expect(matchLeadingCommand('/design:edit')).toEqual({
41
+ token: 'design:edit',
42
+ full: null,
43
+ typing: true,
44
+ });
45
+ // Leading whitespace is tolerated (trimmed).
46
+ expect(matchLeadingCommand(' /design')).toEqual({
47
+ token: 'design',
48
+ full: null,
49
+ typing: true,
50
+ });
51
+ });
52
+ test('highlight mode: command followed by a space', () => {
53
+ expect(matchLeadingCommand('/design:edit make it bolder')).toEqual({
54
+ token: 'design:edit',
55
+ full: '/design:edit',
56
+ typing: false,
57
+ });
58
+ expect(matchLeadingCommand('/flow:quick ')).toEqual({
59
+ token: 'flow:quick',
60
+ full: '/flow:quick',
61
+ typing: false,
62
+ });
63
+ });
64
+ test('non-command text returns null', () => {
65
+ expect(matchLeadingCommand('make the button bigger')).toBeNull();
66
+ expect(matchLeadingCommand('email me at a/b')).toBeNull(); // mid-string slash is not a command
67
+ expect(matchLeadingCommand('')).toBeNull();
68
+ });
69
+ });
70
+
71
+ describe('buildCommandModel', () => {
72
+ test('cold (no live list) → static keys are the optimistic exists-set', () => {
73
+ const { all, existsSet } = buildCommandModel(STATIC_COMMANDS, []);
74
+ expect(all.length).toBe(STATIC_COMMANDS.length);
75
+ expect(existsSet.has('design:edit')).toBe(true);
76
+ expect(existsSet.has('design:new')).toBe(true);
77
+ // every static command is present + flagged not-live
78
+ expect(all.every((c) => c.live === false)).toBe(true);
79
+ });
80
+
81
+ test('warm (live list) → live names are the strict authority, and demote unknowns', () => {
82
+ const live = [
83
+ { name: 'design:edit', description: 'live desc' },
84
+ { name: '/design:new', description: '' }, // slash + separator variance tolerated
85
+ { name: 'user:custom', description: 'a user command' },
86
+ ];
87
+ const { all, existsSet } = buildCommandModel(STATIC_COMMANDS, live);
88
+ // exists-set is strictly the live set
89
+ expect(existsSet.has('design:edit')).toBe(true);
90
+ expect(existsSet.has('design:new')).toBe(true);
91
+ expect(existsSet.has('user:custom')).toBe(true);
92
+ // a shipped static command NOT in the live set is no longer "exists"
93
+ expect(existsSet.has('design:critic')).toBe(false);
94
+ // live-only command is added to the union
95
+ expect(all.find((c) => c.name === 'user:custom')?.live).toBe(true);
96
+ // static command present in live is marked live
97
+ expect(all.find((c) => c.name === 'design:edit')?.live).toBe(true);
98
+ });
99
+ });
100
+
101
+ describe('filterCommands', () => {
102
+ test('empty token returns the capped list', () => {
103
+ const { all } = buildCommandModel(STATIC_COMMANDS, []);
104
+ expect(filterCommands(all, '', 5).length).toBe(5);
105
+ });
106
+ test('prefix matches rank before substring matches', () => {
107
+ const { all } = buildCommandModel(STATIC_COMMANDS, []);
108
+ const res = filterCommands(all, 'design:', 20);
109
+ expect(res.length).toBeGreaterThan(0);
110
+ expect(res.every((c) => c.name.startsWith('design:'))).toBe(true);
111
+ });
112
+ test('matches on description substring too', () => {
113
+ const { all } = buildCommandModel(STATIC_COMMANDS, []);
114
+ const res = filterCommands(all, 'critic', 20);
115
+ expect(res.some((c) => c.name === 'design:critic')).toBe(true);
116
+ });
117
+ });
@@ -81,6 +81,12 @@ export interface CommandSinks {
81
81
  * snapshot). See `commands/edit-source-command.ts`.
82
82
  */
83
83
  editSourceApplyFn?: unknown;
84
+ /**
85
+ * Wired by `CanvasShell`. Reverts/re-applies a Phase 12.1 element reorder by
86
+ * posting `dgn:'reorder-revert'` to the parent shell, which owns the
87
+ * main-origin-only `/_api/reorder-revert` write. See `commands/reorder-command.ts`.
88
+ */
89
+ reorderRevertFn?: unknown;
84
90
  }
85
91
 
86
92
  export type CommandBuilder<P = unknown> = (
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "acp-context-hardening",
6
+ "version": "0.38.0",
7
+ "date": "2026-07-03",
8
+ "kind": "feature",
9
+ "title": "The Assistant keeps your canvas in view",
10
+ "summary": "Selection now sticks per canvas: pick an element, switch to another canvas and back, and it's still selected — no more losing your place. Every message you send to the Assistant carries the canvas and element you had chosen, frozen at send time and shown as a chip above the box, so switching canvases while Claude works never leaves it guessing what you meant. And a fresh chat already knows it's running inside the Maude studio, so you can just say \"make this bigger\" and go.",
11
+ "surface": "design-ui"
12
+ },
13
+ {
14
+ "id": "acp-composer-paste-and-send",
15
+ "version": "0.38.0",
16
+ "date": "2026-07-03",
17
+ "kind": "feature",
18
+ "title": "Paste, copy, and send in the Assistant",
19
+ "summary": "Copy and paste now work in the desktop app, and Enter sends your message (Shift+Enter for a new line). Paste a file path, a link, or an image straight from your clipboard and it becomes a tidy chip — with a line underneath showing exactly what each chip will send, so nothing hidden reaches Claude. A pasted screenshot is saved with the chat and Claude reads it on send.",
20
+ "surface": "design-ui"
21
+ },
22
+ {
23
+ "id": "slash-command-autocomplete",
24
+ "version": "0.38.0",
25
+ "date": "2026-07-03",
26
+ "kind": "feature",
27
+ "title": "Slash-command autocomplete in the Assistant",
28
+ "summary": "In the Assistant chat, type \"/\" to see the design and flow commands your Claude actually has — filter as you type, pick with the keyboard or a click (it prefills, never sends). A command that exists lights up as a pill right inside the box, so you don't have to remember the exact names.",
29
+ "surface": "design-ui"
30
+ },
31
+ {
32
+ "id": "drag-to-reorder",
33
+ "version": "0.38.0",
34
+ "date": "2026-07-03",
35
+ "kind": "feature",
36
+ "title": "Drag to reorder elements",
37
+ "summary": "Rearrange a canvas by hand. Drag the element itself on the canvas — drop on the top/left half to go above, the bottom/right half to go below, or the middle of a box to drop inside it — and the layout reflows LIVE while you hold, Figma-style, with the neighbours gliding into place; Esc puts it back. Or drag in the Layers panel, which now mirrors the canvas both ways as you move. Even instances of the same reusable component (a board's columns, repeated cards) reorder now. Every reorder rewrites your .tsx source and undoes with ⌘Z / redoes with ⌘⇧Z. Keyboard: with the panel focused, ↑/↓ walk the selection, Alt+↑/↓ move within the parent, Alt+Shift+↑/↓ move across.",
38
+ "surface": "design-ui"
39
+ },
4
40
  {
5
41
  "id": "new-project-menu-and-local",
6
42
  "version": "0.37.0",
@@ -386,6 +422,15 @@
386
422
  "title": "Self-hostable hub + live file sync",
387
423
  "summary": "Run your own Maude hub and co-edit canvases with bidirectional file sync, offline-aware status, and conflict handling.",
388
424
  "surface": "design-ui"
425
+ },
426
+ {
427
+ "id": "zero-install-desktop-design",
428
+ "version": "0.38.0",
429
+ "date": "2026-07-03",
430
+ "kind": "feature",
431
+ "title": "Design just works in the desktop chat",
432
+ "summary": "With only Claude Code installed, /design:* commands auto-load in the chat (nothing to install), and design critics can now see your artboards — a screenshot engine is bundled and provisions its browser on first use.",
433
+ "surface": "design-ui"
389
434
  }
390
435
  ]
391
436
  }
package/apps/studio/ws.ts CHANGED
@@ -81,6 +81,43 @@ export function isLoopbackHost(host: string | null): boolean {
81
81
  return h === '127.0.0.1' || h === '::1' || h === 'localhost';
82
82
  }
83
83
 
84
+ /** Port component of a `host[:port]` / `[::1]:port` string ('' if default-port). */
85
+ function hostPort(host: string): string {
86
+ const h = host.trim();
87
+ if (h.startsWith('[')) {
88
+ const close = h.indexOf(']');
89
+ return close !== -1 ? h.slice(close + 1).replace(/^:/, '') : '';
90
+ }
91
+ const colon = h.lastIndexOf(':');
92
+ return colon !== -1 ? h.slice(colon + 1) : '';
93
+ }
94
+
95
+ /**
96
+ * CSWSH gate for the privileged ACP WebSocket. Loopback-Host is NOT enough: a
97
+ * WS handshake bypasses the same-origin policy, so a cross-origin drive-by page
98
+ * (`http://evil.com`, or the user's own `http://localhost:3000` dev server) can
99
+ * open `ws://localhost:<port>/_ws/acp` — and the bridge spawns the user's
100
+ * `claude` + drives file edits. A browser ALWAYS sends `Origin` on a cross-doc
101
+ * WS connect; a non-browser client (CLI / tests) omits it. Allow when Origin is
102
+ * absent, or when it is a loopback host on the SAME port as the request — which
103
+ * is exactly what the native panel and a same-origin `maude design serve` tab
104
+ * send (both navigate to `http://localhost:<serverport>`). Reject everything
105
+ * else. Mirrors the `sameOriginWrite` (http.ts) discipline for the WS upgrade.
106
+ */
107
+ export function isSameOriginWs(req: Request): boolean {
108
+ const origin = req.headers.get('origin');
109
+ if (!origin) return true; // non-browser (CLI / tests) — browsers send Origin on WS
110
+ let originHost: string;
111
+ try {
112
+ originHost = new URL(origin).host; // host[:port]; `Origin: null` → throws → reject
113
+ } catch {
114
+ return false;
115
+ }
116
+ const reqHost = req.headers.get('host');
117
+ if (!isLoopbackHost(originHost) || !isLoopbackHost(reqHost)) return false;
118
+ return hostPort(originHost) === hostPort(reqHost ?? '');
119
+ }
120
+
84
121
  export interface Ws {
85
122
  handler: WebSocketHandler<WsData>;
86
123
  broadcast(payload: unknown): void;
@@ -44,6 +44,7 @@ const BIN_VERBS = new Set([
44
44
  'read-annotations',
45
45
  'annotate',
46
46
  'chat-open',
47
+ 'ensure-browser',
47
48
  ]);
48
49
 
49
50
  // Bin verbs that boot the dev-server (directly, or by shelling into server-up.sh).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -50,13 +50,13 @@
50
50
  "test:e2e:desktop:onboarding": "pnpm --filter @maude/desktop-e2e e2e:onboarding"
51
51
  },
52
52
  "optionalDependencies": {
53
- "@1agh/maude-darwin-arm64": "0.37.0",
54
- "@1agh/maude-darwin-x64": "0.37.0",
55
- "@1agh/maude-linux-arm64": "0.37.0",
56
- "@1agh/maude-linux-arm64-musl": "0.37.0",
57
- "@1agh/maude-linux-x64": "0.37.0",
58
- "@1agh/maude-linux-x64-musl": "0.37.0",
59
- "@1agh/maude-win32-x64": "0.37.0"
53
+ "@1agh/maude-darwin-arm64": "0.38.0",
54
+ "@1agh/maude-darwin-x64": "0.38.0",
55
+ "@1agh/maude-linux-arm64": "0.38.0",
56
+ "@1agh/maude-linux-arm64-musl": "0.38.0",
57
+ "@1agh/maude-linux-x64": "0.38.0",
58
+ "@1agh/maude-linux-x64-musl": "0.38.0",
59
+ "@1agh/maude-win32-x64": "0.38.0"
60
60
  },
61
61
  "files": [
62
62
  "cli",
@@ -65,10 +65,10 @@
65
65
  "expectExit": 0
66
66
  },
67
67
  "install": {
68
- "preferred": "npm i -g @anthropic-ai/agent-browser"
68
+ "preferred": "npm i -g agent-browser"
69
69
  },
70
70
  "autoInstall": true,
71
- "fallbackBehavior": "Screenshot helpers fall back to `npx playwright` (slower, less reliable). /design:smoke and several critics need agent-browser for full coverage.",
71
+ "fallbackBehavior": "Screenshot helpers fall back to `npx playwright` (slower, less reliable). /design:smoke and several critics need agent-browser for full coverage. On the Maude DESKTOP app it is BUNDLED (externalBin, DDR-144) and its Chromium engine is provisioned as chrome-headless-shell by `maude design ensure-browser` — screenshots work zero-install there; this soft/manual note applies to the web `maude design serve` + CLI paths.",
72
72
  "usedBy": [
73
73
  "commands/screenshot.md",
74
74
  "commands/new.md",
@@ -80,7 +80,7 @@
80
80
  "agents/graphic-design-critic.md",
81
81
  "agents/a11y-critic.md"
82
82
  ],
83
- "docsUrl": "https://github.com/anthropics/agent-browser"
83
+ "docsUrl": "https://github.com/vercel-labs/agent-browser"
84
84
  },
85
85
  {
86
86
  "id": "playwright",