@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,115 @@
1
+ // ACP session-scoped plugin auto-bootstrap (DDR-143).
2
+ //
3
+ // A Maude Desktop user with only Claude Code installed should get `/design:*`
4
+ // working in the chat panel with ZERO manual install — no `npm i`, no `/plugin
5
+ // marketplace add`, no `/reload-plugins`. (`/flow:*` auto-load is intentionally
6
+ // disabled for now — 2026-07-03 — the chat ships design-only; the plumbing below
7
+ // stays wired so restoring flow is a one-line change in computeSessionPlugins.)
8
+ // We achieve that by handing
9
+ // the ACP-spawned `claude` a session-scoped local plugin dir through the same
10
+ // `_meta` seam the bootstrap brief uses: `_meta.claudeCode.options.plugins`.
11
+ // The adapter spreads that into the SDK `query()` options (verified live —
12
+ // Task-1 spike: an injected local plugin's command appears in the session's
13
+ // `available_commands_update` with no install), and the SDK loads its commands/
14
+ // agents/skills/hooks for that session only. NOTHING is written to `~/.claude`,
15
+ // no package manager runs, no network is touched (DDR-126/128) — it's reversible
16
+ // and per-session by construction.
17
+ //
18
+ // Three gates, ALL must hold for a plugin to be injected:
19
+ // 1. NATIVE / BUNDLE context (guard #4, DDR-119/123). The web `maude design
20
+ // serve` (npm) path ships no plugin manifest (DDR-044) and its users have a
21
+ // terminal — never inject there. The signal is the manifest's presence on
22
+ // disk (DESIGN_PLUGIN_DIR/FLOW_PLUGIN_DIR non-null), which is true ONLY in
23
+ // the desktop Resources bundle or the dev tree; plus the desktop sidecar's
24
+ // MAUDE_DEV_SERVER_ROOT as an explicit stricter marker.
25
+ // 2. BUNDLED FILES present — the per-plugin dir resolved (Task 2). Null on the
26
+ // npm/web layout ⇒ skip that plugin.
27
+ // 3. NO-OP for power users (guard #3, hard). If the plugin is already installed
28
+ // via the marketplace OR enabled in the user's settings.json, it loads
29
+ // NATIVELY under the adapter's `settingSources:user` — injecting the bundled
30
+ // copy on top would double-register commands + risk version skew. So a
31
+ // `scanPlugins()` hit ⇒ skip that plugin. A pristine ~/.claude (the target
32
+ // non-power-user) reads as not-installed ⇒ inject.
33
+
34
+ import { DESIGN_PLUGIN_DIR, FLOW_PLUGIN_DIR } from '../paths.ts';
35
+ import { type PluginScan, scanPlugins } from '../readiness.ts';
36
+
37
+ /**
38
+ * SDK plugin-load config (`@anthropic-ai/claude-agent-sdk` `SdkPluginConfig`).
39
+ * Defined locally: that SDK is a TRANSITIVE dep (reached only through the
40
+ * adapter), so we don't import its type surface directly. Shape pinned to
41
+ * sdk.d.ts:3766 — an adapter/SDK bump that changes it is caught by the
42
+ * presence-test in test/acp-session-plugins.test.ts.
43
+ */
44
+ export interface SdkPluginConfig {
45
+ type: 'local';
46
+ path: string;
47
+ /** The SDK host (this bridge) owns MCP connections — don't read the plugin's .mcp.json. */
48
+ skipMcpDiscovery?: boolean;
49
+ }
50
+
51
+ export interface SessionPluginDeps {
52
+ /** Desktop bundle or dev tree; false on the npm web-serve path (guard #4). */
53
+ native: boolean;
54
+ /** Bundled `design` plugin dir, or null (npm/web layout). */
55
+ designDir: string | null;
56
+ /** Bundled `flow` plugin dir, or null (npm/web layout). */
57
+ flowDir: string | null;
58
+ /** ~/.claude registry+settings scan — a present plugin loads natively (no-op). */
59
+ scan: Pick<PluginScan, 'design' | 'flow'>;
60
+ }
61
+
62
+ /**
63
+ * Pure resolver — returns the `SdkPluginConfig[]` to inject (possibly empty).
64
+ * Exported for unit tests; the real wiring is {@link resolveSessionPlugins}.
65
+ */
66
+ export function computeSessionPlugins(deps: SessionPluginDeps): SdkPluginConfig[] {
67
+ if (!deps.native) return [];
68
+ const out: SdkPluginConfig[] = [];
69
+ const add = (dir: string | null, alreadyPresent: boolean): void => {
70
+ // dir === null → not bundled in this layout (web/npm) → skip.
71
+ // alreadyPresent → loads natively → skip (no double-registration / skew).
72
+ if (dir && !alreadyPresent) out.push({ type: 'local', path: dir, skipMcpDiscovery: true });
73
+ };
74
+ add(deps.designDir, deps.scan.design);
75
+ // `/flow` auto-load is intentionally OFF for now (2026-07-03) — the chat ships
76
+ // design-only. `deps.flowDir` / `deps.scan.flow` stay resolved (harmless) so
77
+ // restoring it is a one-liner: re-add `add(deps.flowDir, deps.scan.flow)`.
78
+ return out;
79
+ }
80
+
81
+ /**
82
+ * Native/bundle context — true in the packaged desktop app (sidecar sets
83
+ * MAUDE_DEV_SERVER_ROOT, DDR-106) OR the dev tree (a bundled plugin manifest
84
+ * resolves on disk, so the feature is dogfoodable without building the `.app`).
85
+ * False on the npm `maude design serve` path: no MAUDE_DEV_SERVER_ROOT and no
86
+ * staged manifest (DDR-044) ⇒ both plugin dirs null.
87
+ *
88
+ * `MAUDE_NO_PLUGIN_BOOTSTRAP=1` is a hard opt-out: it reverts to the DDR-128
89
+ * detect-and-guide posture (no injection, the readiness `plugins` row shows the
90
+ * manual marketplace remediation). For a power user who wants to force the manual
91
+ * path, a locked-down deployment, or test isolation.
92
+ */
93
+ export function isNativePluginContext(): boolean {
94
+ if (process.env.MAUDE_NO_PLUGIN_BOOTSTRAP === '1') return false;
95
+ return (
96
+ !!process.env.MAUDE_DEV_SERVER_ROOT || DESIGN_PLUGIN_DIR !== null || FLOW_PLUGIN_DIR !== null
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Resolve the session-scoped plugins to inject for a fresh ACP session. Wires the
102
+ * real deps (native context, bundled dirs, live ~/.claude scan) into
103
+ * {@link computeSessionPlugins}. Empty array ⇒ inject nothing (the power-user /
104
+ * web no-op). Recomputed per bridge construction; stable across an adapter
105
+ * re-spawn because it's carried on the bridge's readonly options.
106
+ */
107
+ export function resolveSessionPlugins(): SdkPluginConfig[] {
108
+ const scan = scanPlugins();
109
+ return computeSessionPlugins({
110
+ native: isNativePluginContext(),
111
+ designDir: DESIGN_PLUGIN_DIR,
112
+ flowDir: FLOW_PLUGIN_DIR,
113
+ scan: { design: scan.design, flow: scan.flow },
114
+ });
115
+ }
@@ -44,10 +44,12 @@ function readLines(file: string): Array<Record<string, unknown>> {
44
44
  }
45
45
  }
46
46
 
47
- /** First user line, truncated — the chat's display title. */
47
+ /** First user line, truncated — the chat's display title. Context-attachment
48
+ * lines are stripped first, so a chat never titles itself `[maude-context …`
49
+ * (the 2026-07-03 dogfood finding). */
48
50
  function deriveTitle(lines: Array<Record<string, unknown>>): string {
49
51
  const firstUser = lines.find((l) => l.role === 'user' && typeof l.text === 'string');
50
- const text = (firstUser?.text as string) ?? '';
52
+ const text = stripContextBlock((firstUser?.text as string) ?? '');
51
53
  const trimmed = text.replace(/\s+/g, ' ').trim();
52
54
  return trimmed ? trimmed.slice(0, 60) : 'New chat';
53
55
  }
@@ -85,6 +87,34 @@ export function deleteChat(designRoot: string, chatId: string): boolean {
85
87
  }
86
88
  }
87
89
 
90
+ /**
91
+ * UI-projection strip of the frozen chat-context a send attaches
92
+ * (feature-acp-context-hardening). PROJECTION ONLY — the on-disk jsonl keeps
93
+ * the raw prompt (it's the audit record of what steered the auto-approving
94
+ * agent); the rendered bubble/title shows just what the user typed. Handles
95
+ * both formats: the current TRAILING `[maude-context …]` (+ `[selected: …]`)
96
+ * bracket lines, and the legacy leading `<maude-context>` fence (2026-07-02,
97
+ * one release window). `role:'bootstrap'` brief entries are skipped by this
98
+ * reader by construction (only user/stop/agent roles are consumed below).
99
+ */
100
+ function stripContextBlock(text: string): string {
101
+ let out = text;
102
+ if (out.startsWith('<maude-context')) {
103
+ const end = out.indexOf('</maude-context>');
104
+ if (end !== -1) out = out.slice(end + '</maude-context>'.length).replace(/^\s+/, '');
105
+ }
106
+ const i = out.indexOf('\n[maude-context ');
107
+ if (i !== -1) {
108
+ const tail = out.slice(i + 1);
109
+ // Only strip when the remainder is EXACTLY the context block (all lines are
110
+ // bracket lines) — a user merely quoting `[maude-context …]` mid-text stays.
111
+ if (/^\[maude-context [^\n]*\](\n\[selected:[^\n]*\])*\s*$/.test(tail)) {
112
+ out = out.slice(0, i).replace(/\s+$/, '');
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+
88
118
  /**
89
119
  * Convert the raw transcript into clean per-turn messages: user lines become
90
120
  * user messages; the agent updates between them aggregate into one assistant
@@ -108,7 +138,10 @@ export function readChatMessages(designRoot: string, chatId: string): ChatMessag
108
138
  for (const line of lines) {
109
139
  if (line.role === 'user' && typeof line.text === 'string') {
110
140
  flush();
111
- messages.push({ role: 'user', parts: [{ type: 'text', text: line.text }] });
141
+ messages.push({
142
+ role: 'user',
143
+ parts: [{ type: 'text', text: stripContextBlock(line.text) }],
144
+ });
112
145
  continue;
113
146
  }
114
147
  if (line.role === 'stop') {
@@ -19,6 +19,9 @@ import type { Context } from './context.ts';
19
19
  /** Idle debounce — fs silence longer than this flips a file `active` → `idle`. */
20
20
  export const ACTIVITY_IDLE_MS = 3000;
21
21
 
22
+ /** Default user-write suppression window (see ActivityOptions.suppressTtlMs). */
23
+ export const SUPPRESS_TTL_MS = 2500;
24
+
22
25
  /** LRU cap on the per-file text stash used by the Task 7 region diff. */
23
26
  const STASH_CAP = 50;
24
27
 
@@ -104,6 +107,8 @@ export interface ActivityOptions {
104
107
  idleMs?: number;
105
108
  /** Disable the Task 7 per-artboard diff (file-level only). Default: enabled. */
106
109
  diff?: boolean;
110
+ /** Override the user-write suppression window (tests pass a small value). */
111
+ suppressTtlMs?: number;
107
112
  }
108
113
 
109
114
  export function createActivity(ctx: Context, opts: ActivityOptions = {}): Activity {
@@ -114,6 +119,18 @@ export function createActivity(ctx: Context, opts: ActivityOptions = {}): Activi
114
119
  const idleTimers = new Map<string, ReturnType<typeof setTimeout>>();
115
120
  // Last-seen file text, for the Task 7 region diff. Insertion-ordered Map = LRU.
116
121
  const lastText = new Map<string, string>();
122
+ // Phase 12.1 — writes the USER just made through the shell (drag / keyboard
123
+ // reorder, inspector CSS/text/attr edits + their undo/redo) must NOT light the
124
+ // "editing" rim: the overlay means "an agent works here" (DDR-029), and
125
+ // flashing it on the user's own direct manipulation reads as interference.
126
+ // The write path emits `activity:suppress` with the designRoot-relative path
127
+ // right before writing; EVERY fs:any for that file inside the TTL window is
128
+ // swallowed (RC2, rca/issue-canvas-hmr-optimistic-update-consistency — the
129
+ // old one-shot delete let the 2nd event of a same-file write burst through:
130
+ // slider drags and reorder spam produce several debounced fs:any per arm).
131
+ // The TTL bounds the mute so a crashed write can't silence a file forever.
132
+ const suppressTtlMs = opts.suppressTtlMs ?? SUPPRESS_TTL_MS;
133
+ const suppressed = new Map<string, number>();
117
134
 
118
135
  function emit(file: string) {
119
136
  const e = state[file];
@@ -191,15 +208,43 @@ export function createActivity(ctx: Context, opts: ActivityOptions = {}): Activi
191
208
 
192
209
  const off = ctx.bus.on('fs:any', (rel: string) => {
193
210
  if (!isCanvasFile(rel)) return;
211
+ const until = suppressed.get(rel);
212
+ if (until != null) {
213
+ if (Date.now() < until) {
214
+ // User-originated write window — keep the diff baseline fresh so the
215
+ // NEXT (agent) edit still region-scopes correctly, but skip the rim.
216
+ // The entry stays armed until the TTL expires or the writer disarms.
217
+ if (diffEnabled) void refineArtboards(rel);
218
+ return;
219
+ }
220
+ suppressed.delete(rel); // expired — clean up and fall through to mark()
221
+ }
194
222
  mark(rel);
195
223
  if (diffEnabled) void refineArtboards(rel);
196
224
  });
197
225
 
226
+ const offSuppress = ctx.bus.on('activity:suppress', (rel: string) => {
227
+ if (typeof rel !== 'string' || !rel) return;
228
+ suppressed.set(rel.replace(/\\/g, '/'), Date.now() + suppressTtlMs);
229
+ });
230
+
231
+ // The write path arms `activity:suppress` BEFORE the write (so it catches the
232
+ // debounced fs:any). If the write turns out to be a no-op or throws, it disarms
233
+ // via this event — otherwise a failed/spam reorder would swallow the NEXT
234
+ // genuine (agent / synced-peer) edit's rim (adversarial F2).
235
+ const offUnsuppress = ctx.bus.on('activity:unsuppress', (rel: string) => {
236
+ if (typeof rel !== 'string' || !rel) return;
237
+ suppressed.delete(rel.replace(/\\/g, '/'));
238
+ });
239
+
198
240
  function stop() {
199
241
  off();
242
+ offSuppress();
243
+ offUnsuppress();
200
244
  for (const t of idleTimers.values()) clearTimeout(t);
201
245
  idleTimers.clear();
202
246
  lastText.clear();
247
+ suppressed.clear();
203
248
  }
204
249
 
205
250
  return { state, mark, stop };
@@ -10,10 +10,13 @@ import { renderBriefBoard, validateCanvasName } from './canvas-create.ts';
10
10
  import {
11
11
  CanvasEditError,
12
12
  editAttribute,
13
+ type MovePosition,
14
+ moveElement,
13
15
  removeAttribute,
14
16
  editText as runEditText,
15
17
  } from './canvas-edit.ts';
16
18
  import type { Context } from './context.ts';
19
+ import { createHistory } from './history.ts';
17
20
 
18
21
  // Directories that never hold user-facing canvases. Exported so the
19
22
  // external-canvas watcher (`canvas-list-watch.ts`) shares one source instead of
@@ -180,6 +183,21 @@ export type EditOpResult =
180
183
  | { ok: true; delta: number }
181
184
  | { ok: false; status: number; error: string };
182
185
 
186
+ /**
187
+ * Phase 12.1 (DDR-138) — result of a node-move reorder. Carries the re-settle
188
+ * hints the client uses to re-select the moved element through the positional
189
+ * `data-cd-id` churn: `movedId` (recomputed positional id == the post-reload DOM
190
+ * id, best-effort) and `semanticId` (the moved element's `data-dc-element`, which
191
+ * survives the move verbatim — the reliable key when present).
192
+ */
193
+ export type ReorderOpResult =
194
+ | { ok: true; delta: number; movedId: string | null; semanticId: string | null; seq: number }
195
+ | { ok: false; status: number; error: string };
196
+
197
+ export type ReorderRevertResult =
198
+ | { ok: true; dir: 'undo' | 'redo' }
199
+ | { ok: false; status: number; error: string };
200
+
183
201
  export interface Api {
184
202
  // File tree
185
203
  fileSlug(file: string): string;
@@ -221,6 +239,9 @@ export interface Api {
221
239
  saveAnnotations(file: string, svg: string): Promise<boolean>;
222
240
  // Phase 23 — content-addressed binary image write (drag-drop / paste / picker)
223
241
  saveAsset(bytes: Uint8Array): Promise<SaveAssetResult>;
242
+ // Persist a clipboard-pasted ACP composer image → runtime `_chat/attachments/`,
243
+ // returns an absolute path (Phase 31 follow-up — POST /_api/acp/attachment).
244
+ saveChatAttachment(bytes: Uint8Array): Promise<SaveAssetResult>;
224
245
  // Create a blank brief board from the browser (Phase 22 — POST /_api/canvas)
225
246
  createCanvas(input: {
226
247
  name?: unknown;
@@ -248,6 +269,24 @@ export interface Api {
248
269
  attr?: unknown;
249
270
  value?: unknown;
250
271
  }): Promise<EditOpResult>;
272
+ // Phase 12.1 (DDR-138) — node-move reorder (POST /_api/reorder). Main-origin
273
+ // only. Moves the element with data-cd-id `id` to `position` relative to
274
+ // `refId` (reparent-capable), snapshotting pre-move for /design:rollback.
275
+ reorder(input: {
276
+ canvas?: unknown;
277
+ id?: unknown;
278
+ refId?: unknown;
279
+ position?: unknown;
280
+ }): Promise<ReorderOpResult>;
281
+ // Undo/redo a prior reorder by seq (Cmd+Z from the canvas undo stack). Whole-
282
+ // file content swap from the in-memory revert log — immune to the positional
283
+ // data-cd-id churn a reorder causes (inverse-descriptor undo would go stale).
284
+ // Refuses (409) when the canvas changed since the reorder (external edit).
285
+ reorderRevert(input: {
286
+ canvas?: unknown;
287
+ seq?: unknown;
288
+ dir?: unknown;
289
+ }): Promise<ReorderRevertResult>;
251
290
  // Aggregate data
252
291
  buildIndexData(): Promise<unknown>;
253
292
  buildSystemData(dsName?: string | null): Promise<unknown>;
@@ -355,6 +394,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
355
394
  const onAnnotationsChanged = hooks.onAnnotationsChanged;
356
395
  const { paths, cfg } = ctx;
357
396
 
397
+ // Phase 12.1 — in-memory reorder revert log (Cmd+Z for drag/keyboard moves).
398
+ // Whole-file {before, after} per reorder, keyed by a monotonic seq the client
399
+ // stores in its undo record. Ephemeral by design: a server restart drops it
400
+ // (undo answers 404 and the canvas stack entry is a no-op, honest failure).
401
+ const REORDER_LOG_CAP = 50;
402
+ const reorderLog = new Map<number, { abs: string; before: string; after: string }>();
403
+ let reorderSeq = 0;
404
+
358
405
  function fileSlug(file: string): string {
359
406
  return canvasSlugFromRel(file, paths.designRel);
360
407
  }
@@ -984,6 +1031,56 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
984
1031
  return { ok: true, path: `assets/${name}` };
985
1032
  }
986
1033
 
1034
+ // Phase 31 follow-up — persist an image pasted straight from the clipboard into
1035
+ // the ACP composer (a screenshot has no path yet), so the chip can point Claude
1036
+ // at a real file to Read. Sibling of saveAsset, but:
1037
+ // • writes under the RUNTIME `_chat/attachments/` (gitignored — DDR-115), not
1038
+ // the versioned `assets/`; pasted screenshots are ephemeral, not canvas media.
1039
+ // • returns an ABSOLUTE path — Claude runs with its own cwd, so a project-
1040
+ // relative string could miss; an absolute path always resolves.
1041
+ // Same load-bearing caps as saveAsset (magic-byte sniff → no SVG/script, 10 MB,
1042
+ // content-addressed name, shared session write budget). MAIN-ORIGIN ONLY at the
1043
+ // route layer (the untrusted canvas can't reach it).
1044
+ async function saveChatAttachment(bytes: Uint8Array): Promise<SaveAssetResult> {
1045
+ if (!bytes || bytes.length === 0) return { ok: false, status: 400, error: 'empty body' };
1046
+ if (bytes.length > ASSET_MAX_BYTES) {
1047
+ return { ok: false, status: 413, error: 'attachment exceeds the 10 MB cap' };
1048
+ }
1049
+ const kind = sniffImageType(bytes);
1050
+ if (!kind) {
1051
+ return {
1052
+ ok: false,
1053
+ status: 415,
1054
+ error: 'unsupported image type — png/jpeg/gif/webp only (SVG rejected)',
1055
+ };
1056
+ }
1057
+ const sha8 = crypto.createHash('sha256').update(bytes).digest('hex').slice(0, 8);
1058
+ const name = `${sha8}.${kind}`;
1059
+ const dir = path.join(paths.designRoot, '_chat', 'attachments');
1060
+ const fileAbs = path.join(dir, name);
1061
+ // Containment backstop — the name is content-addressed (no user input), assert anyway.
1062
+ if (path.resolve(fileAbs) !== path.join(path.resolve(dir), name)) {
1063
+ return { ok: false, status: 400, error: 'resolved attachment path escapes _chat dir' };
1064
+ }
1065
+ try {
1066
+ if (!(await Bun.file(fileAbs).exists())) {
1067
+ if (assetBytesWritten + bytes.length > ASSET_SESSION_BUDGET) {
1068
+ return {
1069
+ ok: false,
1070
+ status: 429,
1071
+ error: 'asset write budget exceeded for this server session',
1072
+ };
1073
+ }
1074
+ await mkdir(dir, { recursive: true });
1075
+ await Bun.write(fileAbs, bytes);
1076
+ assetBytesWritten += bytes.length;
1077
+ }
1078
+ } catch (err) {
1079
+ return { ok: false, status: 500, error: err instanceof Error ? err.message : 'write failed' };
1080
+ }
1081
+ return { ok: true, path: fileAbs };
1082
+ }
1083
+
987
1084
  // Phase 22 — create a blank brief board from the browser file tree. Wired ONLY
988
1085
  // on the main origin (server.ts startMainServer); the segregated canvas origin
989
1086
  // (DDR-054) never exposes this — an untrusted canvas iframe must not be able to
@@ -1242,6 +1339,36 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1242
1339
  // 8-hex lowercase, the shape `computeId` (canvas-edit.ts) stamps on data-cd-id.
1243
1340
  const CD_ID_RE = /^[0-9a-f]{8}$/;
1244
1341
 
1342
+ // RC1 (rca/issue-canvas-hmr-optimistic-update-consistency) — every inline
1343
+ // edit is USER-originated (inspector CSS knobs, inline text, attr panel, and
1344
+ // their undo/redo which re-POST the same endpoints), so it must not light the
1345
+ // "agent works here" rim. Mirror the reorder pattern (below): arm the
1346
+ // suppression BEFORE the write so it catches the debounced fs:any, disarm on
1347
+ // a no-op (delta 0 = no write) or throw so a failed edit can't mute the next
1348
+ // genuine agent edit's rim.
1349
+ async function suppressedEdit(
1350
+ abs: string,
1351
+ run: () => Promise<{ delta: number; changed?: boolean }>,
1352
+ errLabel: string
1353
+ ): Promise<EditOpResult> {
1354
+ const rel = path.relative(paths.designRoot, abs);
1355
+ ctx.bus.emit('activity:suppress', rel);
1356
+ try {
1357
+ const res = await run();
1358
+ // `changed === false` = the op collapsed to a no-op and nothing hit disk —
1359
+ // NOT `delta === 0`, which an equal-length replacement also produces.
1360
+ if (res.changed === false) ctx.bus.emit('activity:unsuppress', rel);
1361
+ return { ok: true, delta: res.delta };
1362
+ } catch (err) {
1363
+ ctx.bus.emit('activity:unsuppress', rel);
1364
+ return {
1365
+ ok: false,
1366
+ status: 422,
1367
+ error: err instanceof CanvasEditError ? err.message : errLabel,
1368
+ };
1369
+ }
1370
+ }
1371
+
1245
1372
  async function editCss(input: {
1246
1373
  canvas?: unknown;
1247
1374
  id?: unknown;
@@ -1265,16 +1392,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1265
1392
  // Phase 12.3 — `reset: true` REMOVES the inline property (back to class /
1266
1393
  // inherited). No value needed; a missing key is a no-op (delta 0).
1267
1394
  if (input.reset === true) {
1268
- try {
1269
- const res = await removeAttribute(r.abs, id, `style.${camel}`);
1270
- return { ok: true, delta: res.delta };
1271
- } catch (err) {
1272
- return {
1273
- ok: false,
1274
- status: 422,
1275
- error: err instanceof CanvasEditError ? err.message : 'reset failed',
1276
- };
1277
- }
1395
+ return suppressedEdit(
1396
+ r.abs,
1397
+ () => removeAttribute(r.abs, id, `style.${camel}`),
1398
+ 'reset failed'
1399
+ );
1278
1400
  }
1279
1401
  const value = typeof input.value === 'string' ? input.value : '';
1280
1402
  if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
@@ -1283,16 +1405,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1283
1405
  // quotes/backslashes/newlines so it can never break out of the string, and
1284
1406
  // React accepts string values for every style prop — so `var(--accent)`,
1285
1407
  // `#fff`, `8px`, `700`, `1.5` all ride verbatim.
1286
- try {
1287
- const res = await editAttribute(r.abs, id, `style.${camel}`, JSON.stringify(value));
1288
- return { ok: true, delta: res.delta };
1289
- } catch (err) {
1290
- return {
1291
- ok: false,
1292
- status: 422,
1293
- error: err instanceof CanvasEditError ? err.message : 'edit failed',
1294
- };
1295
- }
1408
+ return suppressedEdit(
1409
+ r.abs,
1410
+ () => editAttribute(r.abs, id, `style.${camel}`, JSON.stringify(value)),
1411
+ 'edit failed'
1412
+ );
1296
1413
  }
1297
1414
 
1298
1415
  async function editText(input: {
@@ -1306,16 +1423,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1306
1423
  if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
1307
1424
  if (typeof input.text !== 'string') return { ok: false, status: 400, error: 'text required' };
1308
1425
  if (input.text.length > 5000) return { ok: false, status: 413, error: 'text too long' };
1309
- try {
1310
- const res = await runEditText(r.abs, id, input.text);
1311
- return { ok: true, delta: res.delta };
1312
- } catch (err) {
1313
- return {
1314
- ok: false,
1315
- status: 422,
1316
- error: err instanceof CanvasEditError ? err.message : 'edit failed',
1317
- };
1318
- }
1426
+ const text = input.text;
1427
+ return suppressedEdit(r.abs, () => runEditText(r.abs, id, text), 'edit failed');
1319
1428
  }
1320
1429
 
1321
1430
  async function editAttr(input: {
@@ -1344,36 +1453,142 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1344
1453
  }
1345
1454
  // Phase 12.3 — `reset: true` REMOVES the custom attribute. No-op if absent.
1346
1455
  if (input.reset === true) {
1347
- try {
1348
- const res = await removeAttribute(r.abs, id, attr);
1349
- return { ok: true, delta: res.delta };
1350
- } catch (err) {
1351
- return {
1352
- ok: false,
1353
- status: 422,
1354
- error: err instanceof CanvasEditError ? err.message : 'reset failed',
1355
- };
1356
- }
1456
+ return suppressedEdit(r.abs, () => removeAttribute(r.abs, id, attr), 'reset failed');
1357
1457
  }
1358
1458
  const value = typeof input.value === 'string' ? input.value : '';
1359
1459
  if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
1360
1460
  if (value.length > 256) return { ok: false, status: 413, error: 'value too long' };
1461
+ // Non-`style.` attr name → editAttribute writes a plain quoted JSX attribute.
1462
+ // Pass the value RAW: editStringAttr quotes/escapes it itself (JSON.stringify
1463
+ // on replace, escapeAttr on insert) — pre-stringifying here double-encoded
1464
+ // the value (`data-x="\"ok\""`; knob-smoke finding, 2026-06-12).
1465
+ return suppressedEdit(r.abs, () => editAttribute(r.abs, id, attr, value), 'edit failed');
1466
+ }
1467
+
1468
+ // Phase 12.1 (DDR-138) — snapshot stack so a reorder is undoable via
1469
+ // /design:rollback (a positional move has no value-inverse the edit-source
1470
+ // undo command can re-apply, so it rides `_history` instead).
1471
+ const history = createHistory(ctx);
1472
+
1473
+ const MOVE_POSITIONS = new Set(['before', 'after', 'inside-start', 'inside-end']);
1474
+
1475
+ async function reorder(input: {
1476
+ canvas?: unknown;
1477
+ id?: unknown;
1478
+ refId?: unknown;
1479
+ position?: unknown;
1480
+ idIndex?: unknown;
1481
+ refIndex?: unknown;
1482
+ }): Promise<ReorderOpResult> {
1483
+ const r = resolveCanvasAbs(input.canvas);
1484
+ if (!r.ok) return r;
1485
+ const id = typeof input.id === 'string' ? input.id.trim() : '';
1486
+ if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
1487
+ const refId = typeof input.refId === 'string' ? input.refId.trim() : '';
1488
+ if (!CD_ID_RE.test(refId)) {
1489
+ return { ok: false, status: 400, error: 'invalid reference data-cd-id' };
1490
+ }
1491
+ const position = input.position;
1492
+ if (typeof position !== 'string' || !MOVE_POSITIONS.has(position)) {
1493
+ return { ok: false, status: 400, error: 'invalid position' };
1494
+ }
1495
+ // Occurrence index of a reused-component instance (which rendered copy). 0/absent
1496
+ // for a normal element; moveElement maps it to the parent USAGE.
1497
+ const idIndex = Number.isInteger(input.idIndex) ? (input.idIndex as number) : undefined;
1498
+ const refIndex = Number.isInteger(input.refIndex) ? (input.refIndex as number) : undefined;
1499
+ // A same-id move CAN be valid now: two instances of the same reused component
1500
+ // resolve to DIFFERENT usage elements. Only bail when they're the same instance.
1501
+ if (id === refId && (idIndex ?? 0) === (refIndex ?? 0)) {
1502
+ return { ok: false, status: 422, error: 'cannot move an element relative to itself' };
1503
+ }
1504
+ const rel = path.relative(paths.designRoot, r.abs);
1505
+ // Arm the "user did this — don't light the agent-editing rim" suppression
1506
+ // BEFORE the write so it catches the debounced fs:any. It's disarmed below if
1507
+ // the move turns out to be a no-op or throws (adversarial F2 — else a
1508
+ // failed/spam reorder would swallow the next genuine edit's rim).
1509
+ ctx.bus.emit('activity:suppress', rel);
1361
1510
  try {
1362
- // Non-`style.` attr name → editAttribute writes a plain quoted JSX attribute.
1363
- // Pass the value RAW: editStringAttr quotes/escapes it itself (JSON.stringify
1364
- // on replace, escapeAttr on insert) — pre-stringifying here double-encoded
1365
- // the value (`data-x="\"ok\""`; knob-smoke finding, 2026-06-12).
1366
- const res = await editAttribute(r.abs, id, attr, value);
1367
- return { ok: true, delta: res.delta };
1511
+ const before = await Bun.file(r.abs).text();
1512
+ const res = await moveElement(r.abs, id, refId, position as MovePosition, idIndex, refIndex);
1513
+ const after = await Bun.file(r.abs).text();
1514
+ if (after === before) {
1515
+ // No-op move (the reparse-clean edit collapsed to the same source) — no
1516
+ // write happened, so disarm the rim suppression and DON'T snapshot/log
1517
+ // (adversarial F2 + F3: never deposit a _history blob for a non-write).
1518
+ ctx.bus.emit('activity:unsuppress', rel);
1519
+ return { ok: true, delta: 0, movedId: res.movedId, semanticId: res.semanticId, seq: 0 };
1520
+ }
1521
+ // A real write landed. Snapshot the pre-move source for /design:rollback
1522
+ // (RELATIVE path → `_history/<slug>/`; the absolute path would mangle the
1523
+ // slug slug.sh can't find) — best-effort. Log {before, after} under a seq so
1524
+ // Cmd+Z can revert by whole-file swap (inverse descriptors go stale as
1525
+ // positional ids churn).
1526
+ try {
1527
+ await history.writeSnapshot(rel, before, 'pre-reorder');
1528
+ } catch {
1529
+ /* snapshot is a safety net, not a gate */
1530
+ }
1531
+ const seq = ++reorderSeq;
1532
+ reorderLog.set(seq, { abs: r.abs, before, after });
1533
+ while (reorderLog.size > REORDER_LOG_CAP) {
1534
+ const oldest = reorderLog.keys().next().value;
1535
+ if (oldest === undefined) break;
1536
+ reorderLog.delete(oldest);
1537
+ }
1538
+ return { ok: true, delta: res.delta, movedId: res.movedId, semanticId: res.semanticId, seq };
1368
1539
  } catch (err) {
1540
+ ctx.bus.emit('activity:unsuppress', rel); // move threw, no write — disarm
1369
1541
  return {
1370
1542
  ok: false,
1371
1543
  status: 422,
1372
- error: err instanceof CanvasEditError ? err.message : 'edit failed',
1544
+ error: err instanceof CanvasEditError ? err.message : 'reorder failed',
1373
1545
  };
1374
1546
  }
1375
1547
  }
1376
1548
 
1549
+ async function reorderRevert(input: {
1550
+ canvas?: unknown;
1551
+ seq?: unknown;
1552
+ dir?: unknown;
1553
+ }): Promise<ReorderRevertResult> {
1554
+ const r = resolveCanvasAbs(input.canvas);
1555
+ if (!r.ok) return r;
1556
+ const dir = input.dir;
1557
+ if (dir !== 'undo' && dir !== 'redo') return { ok: false, status: 400, error: 'invalid dir' };
1558
+ const seq = typeof input.seq === 'number' ? input.seq : Number.NaN;
1559
+ const entry = Number.isInteger(seq) ? reorderLog.get(seq) : undefined;
1560
+ if (!entry || entry.abs !== r.abs) {
1561
+ return {
1562
+ ok: false,
1563
+ status: 404,
1564
+ error: 'reorder not found (server restarted or log rotated)',
1565
+ };
1566
+ }
1567
+ try {
1568
+ const current = await Bun.file(r.abs).text();
1569
+ const expect = dir === 'undo' ? entry.after : entry.before;
1570
+ const write = dir === 'undo' ? entry.before : entry.after;
1571
+ if (current !== expect) {
1572
+ return {
1573
+ ok: false,
1574
+ status: 409,
1575
+ error: 'canvas changed since this reorder — undo skipped',
1576
+ };
1577
+ }
1578
+ const rel = path.relative(paths.designRoot, r.abs);
1579
+ ctx.bus.emit('activity:suppress', rel);
1580
+ try {
1581
+ await Bun.write(r.abs, write);
1582
+ } catch (e) {
1583
+ ctx.bus.emit('activity:unsuppress', rel); // write failed — disarm the rim suppression
1584
+ throw e;
1585
+ }
1586
+ return { ok: true, dir };
1587
+ } catch {
1588
+ return { ok: false, status: 500, error: 'revert failed' };
1589
+ }
1590
+ }
1591
+
1377
1592
  async function saveCanvasState(file: string, state: Record<string, unknown>) {
1378
1593
  if (!state || typeof state !== 'object') return;
1379
1594
  const safe: Record<string, unknown> = {};
@@ -1778,11 +1993,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1778
1993
  loadAnnotations,
1779
1994
  saveAnnotations,
1780
1995
  saveAsset,
1996
+ saveChatAttachment,
1781
1997
  createCanvas,
1782
1998
  deleteCanvas,
1783
1999
  editCss,
1784
2000
  editText,
1785
2001
  editAttr,
2002
+ reorder,
2003
+ reorderRevert,
1786
2004
  buildIndexData,
1787
2005
  buildSystemData,
1788
2006
  loadExportHistory,