@1agh/maude 0.36.1 → 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 (71) 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 +686 -35
  17. package/apps/studio/client/github.js +6 -0
  18. package/apps/studio/client/panels/ChatPanel.jsx +593 -31
  19. package/apps/studio/client/panels/CreateProject.jsx +57 -23
  20. package/apps/studio/client/panels/GitPanel.jsx +1 -1
  21. package/apps/studio/client/panels/IdentityBar.jsx +43 -2
  22. package/apps/studio/client/panels/OnboardingWizard.jsx +24 -21
  23. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +4 -4
  24. package/apps/studio/client/panels/acp-runtime.js +227 -70
  25. package/apps/studio/client/panels/chat-context.js +124 -0
  26. package/apps/studio/client/panels/slash-commands.js +147 -0
  27. package/apps/studio/client/styles/3-shell-maude.css +15 -0
  28. package/apps/studio/client/styles/6-acp-chat.css +244 -0
  29. package/apps/studio/client/tour/collab-tour.js +3 -3
  30. package/apps/studio/commands/reorder-command.ts +77 -0
  31. package/apps/studio/config.schema.json +6 -0
  32. package/apps/studio/dist/client.bundle.js +17 -13
  33. package/apps/studio/dist/styles.css +1 -1
  34. package/apps/studio/github/endpoints.ts +42 -0
  35. package/apps/studio/hmr-broadcast.ts +27 -19
  36. package/apps/studio/http.ts +183 -26
  37. package/apps/studio/inspect.ts +108 -1
  38. package/apps/studio/paths.ts +32 -0
  39. package/apps/studio/readiness.ts +79 -30
  40. package/apps/studio/scaffold-design.ts +95 -2
  41. package/apps/studio/server.ts +11 -2
  42. package/apps/studio/test/acp-activity.test.ts +154 -0
  43. package/apps/studio/test/acp-ai-activity.test.ts +182 -0
  44. package/apps/studio/test/acp-bootstrap-brief.test.ts +167 -0
  45. package/apps/studio/test/acp-commands.test.ts +108 -0
  46. package/apps/studio/test/acp-origin-gate.test.ts +64 -1
  47. package/apps/studio/test/acp-plugin-bootstrap.test.ts +89 -0
  48. package/apps/studio/test/acp-session-plugins.test.ts +132 -0
  49. package/apps/studio/test/acp-transcript.test.ts +53 -0
  50. package/apps/studio/test/active-state.test.ts +41 -0
  51. package/apps/studio/test/canvas-freshness-deps.test.ts +64 -0
  52. package/apps/studio/test/canvas-origin-gate.test.ts +10 -0
  53. package/apps/studio/test/canvas-reorder.test.ts +211 -0
  54. package/apps/studio/test/chat-context.test.ts +129 -0
  55. package/apps/studio/test/csrf-write-guard.test.ts +24 -0
  56. package/apps/studio/test/edit-suppress.test.ts +170 -0
  57. package/apps/studio/test/ensure-browser.test.ts +92 -0
  58. package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +44 -0
  59. package/apps/studio/test/hmr-broadcast.test.ts +44 -0
  60. package/apps/studio/test/inspect-selections.test.ts +219 -0
  61. package/apps/studio/test/paths.test.ts +57 -0
  62. package/apps/studio/test/readiness.test.ts +83 -13
  63. package/apps/studio/test/reorder-api.test.ts +210 -0
  64. package/apps/studio/test/slash-commands.test.ts +117 -0
  65. package/apps/studio/test/sync-agent.test.ts +9 -2
  66. package/apps/studio/undo-stack.ts +6 -0
  67. package/apps/studio/whats-new.json +63 -0
  68. package/apps/studio/ws.ts +37 -0
  69. package/cli/commands/design.mjs +1 -0
  70. package/package.json +11 -9
  71. package/plugins/design/dependencies.json +3 -3
@@ -107,6 +107,7 @@ export interface GitHubEndpoints {
107
107
  repos(): Promise<GitHubEndpointResult>;
108
108
  createRepo(body: unknown): Promise<GitHubEndpointResult>;
109
109
  createProject(body: unknown): Promise<GitHubEndpointResult>;
110
+ createLocalProject(body: unknown): Promise<GitHubEndpointResult>;
110
111
  invite(body: unknown): Promise<GitHubEndpointResult>;
111
112
  clone(body: unknown): Promise<GitHubEndpointResult>;
112
113
  initDesign(body: unknown): Promise<GitHubEndpointResult>;
@@ -346,6 +347,46 @@ export function createGitHubEndpoints(ctx: Context): GitHubEndpoints {
346
347
  });
347
348
  }
348
349
 
350
+ // "Just a local git repo, no GitHub remote" — same as createProject minus the
351
+ // network: mkdir + git init (defaultBranch main) + .design scaffold, NO token, NO
352
+ // createRepo, NO setRemote. The user can publish it later. Reuses the same slug +
353
+ // parent-dir validation as createProject, and the route is main-origin/loopback
354
+ // gated (http.ts) + absent from CANVAS_SAFE_API (dual-allowlist) like its sibling.
355
+ async function createLocalProject(body: unknown): Promise<GitHubEndpointResult> {
356
+ const b = (body ?? {}) as { name?: unknown; parentDir?: unknown };
357
+ const slug = slugifyRepoName(b.name);
358
+ if (!slug) return bad('Enter a project name (letters, numbers, hyphens).');
359
+ if (!validParentDir(b.parentDir))
360
+ return bad('Pick a folder on your computer to save the project in.');
361
+ const target = join(b.parentDir, slug);
362
+ if (existsSync(target)) {
363
+ return {
364
+ status: 409,
365
+ json: { ok: false, error: `A folder named “${slug}” already exists there.` },
366
+ };
367
+ }
368
+ const humanName = typeof b.name === 'string' ? b.name.trim() : slug;
369
+ try {
370
+ mkdirSync(target, { recursive: true });
371
+ await git.init({ fs, dir: target, defaultBranch: 'main' });
372
+ const s = scaffoldDesign(target, humanName);
373
+ if (!s.ok)
374
+ return {
375
+ status: 500,
376
+ json: { ok: false, error: s.error ?? 'Could not set up the project.' },
377
+ };
378
+ } catch (e) {
379
+ return {
380
+ status: 500,
381
+ json: {
382
+ ok: false,
383
+ error: e instanceof Error ? e.message : 'Could not set up the project.',
384
+ },
385
+ };
386
+ }
387
+ return { status: 200, json: { ok: true, path: target } };
388
+ }
389
+
349
390
  // Fallback for "open a folder/repo that isn't a Maude project yet" — scaffold a
350
391
  // bootable .design/ into an existing folder so it can open (no GitHub, no token).
351
392
  async function initDesign(body: unknown): Promise<GitHubEndpointResult> {
@@ -368,6 +409,7 @@ export function createGitHubEndpoints(ctx: Context): GitHubEndpoints {
368
409
  repos,
369
410
  createRepo: createRepoHandler,
370
411
  createProject,
412
+ createLocalProject,
371
413
  invite,
372
414
  clone,
373
415
  initDesign,
@@ -63,11 +63,29 @@ export function createHmrBroadcaster(
63
63
  broadcast: (msg: HmrMessage) => void
64
64
  ): HmrBroadcaster {
65
65
  let pending: ReturnType<typeof setTimeout> | null = null;
66
- let pendingMsg: HmrMessage | null = null;
66
+ // RC4 (rca/issue-canvas-hmr-optimistic-update-consistency) one pending
67
+ // message PER FILE, plus a shared bucket for file-less messages (`hard` is
68
+ // global). The old single-slot pendingMsg meant a <50 ms multi-file burst
69
+ // (an agent turn touching several canvases) broadcast only the LAST file:
70
+ // every other open canvas missed its reload and sat stale until a manual
71
+ // hard refresh.
72
+ const GLOBAL_KEY = '\0global'; // NUL prefix — can't collide with an on-disk rel path
73
+ const pendingByKey = new Map<string, HmrMessage>();
74
+ // meta is the lightest signal — it doesn't trigger a reload, just re-fetches
75
+ // the sidecar. CSS still ranks above it so a same-window CSS write wins over
76
+ // a meta echo; hard tops everything.
77
+ const rank: Record<HmrMessage['mode'], number> = { meta: 0, css: 1, module: 2, hard: 3 };
67
78
 
68
79
  function flush() {
69
- if (pendingMsg) broadcast(pendingMsg);
70
- pendingMsg = null;
80
+ // A pending `hard` supersedes the per-file queue — every open canvas does a
81
+ // full reload anyway, so the softer messages would be redundant churn.
82
+ const hard = pendingByKey.get(GLOBAL_KEY);
83
+ if (hard?.mode === 'hard') {
84
+ broadcast(hard);
85
+ } else {
86
+ for (const msg of pendingByKey.values()) broadcast(msg);
87
+ }
88
+ pendingByKey.clear();
71
89
  pending = null;
72
90
  }
73
91
 
@@ -80,21 +98,11 @@ export function createHmrBroadcaster(
80
98
  }
81
99
 
82
100
  function enqueue(msg: HmrMessage) {
83
- // Coalesce: hard > module > css. If a hard reload is queued, ignore any
84
- // softer follow-up within the debounce window.
85
- if (pendingMsg) {
86
- // meta is the lightest signal it doesn't trigger a reload, just
87
- // re-fetches the sidecar. CSS still ranks above it so a same-window
88
- // CSS write wins over a meta echo.
89
- const rank: Record<HmrMessage['mode'], number> = { meta: 0, css: 1, module: 2, hard: 3 };
90
- if (rank[msg.mode] < rank[pendingMsg.mode]) {
91
- // Keep the existing (harder) message; just refresh the timer.
92
- } else {
93
- pendingMsg = msg;
94
- }
95
- } else {
96
- pendingMsg = msg;
97
- }
101
+ const key = msg.mode === 'hard' ? GLOBAL_KEY : (msg.file ?? GLOBAL_KEY);
102
+ const prev = pendingByKey.get(key);
103
+ // Same-key coalescing keeps the strongest mode (refreshing the payload for
104
+ // equal rank, so the latest version token wins).
105
+ if (!prev || rank[msg.mode] >= rank[prev.mode]) pendingByKey.set(key, msg);
98
106
  if (pending) clearTimeout(pending);
99
107
  pending = setTimeout(flush, DEBOUNCE_MS);
100
108
  }
@@ -109,7 +117,7 @@ export function createHmrBroadcaster(
109
117
  offAny();
110
118
  if (pending) clearTimeout(pending);
111
119
  pending = null;
112
- pendingMsg = null;
120
+ pendingByKey.clear();
113
121
  },
114
122
  };
115
123
  }
@@ -126,7 +126,7 @@ export function cspForCanvasShell(html: string, mainOrigin?: string): string {
126
126
 
127
127
  /**
128
128
  * CSRF guard for the main-origin source-write routes (edit-css / edit-text /
129
- * edit-attr). Those routes are reachable only from the shell, which is
129
+ * edit-attr / reorder). Those routes are reachable only from the shell, which is
130
130
  * same-origin — but `readJson` enforces no `Content-Type`, so a cross-site page
131
131
  * could otherwise forge a `text/plain` CORS *simple-request* POST to
132
132
  * `http://localhost:<port>/_api/edit-*` (no preflight) and drive a write into
@@ -188,9 +188,10 @@ interface CanvasCacheEntry {
188
188
  sig: string;
189
189
  etag: string;
190
190
  js: string;
191
- /** Absolute paths of the relative `.css` imports inlined into this build
191
+ /** Absolute paths of the relative imports inlined into this build (`.css` +
192
+ * local `.tsx/.ts` modules, incl. unresolved extensionless candidates) —
192
193
  * re-statted each request to recompute `sig` without re-reading the source. */
193
- cssDeps: string[];
194
+ deps: string[];
194
195
  }
195
196
  const canvasCache = new Map<string, CanvasCacheEntry>();
196
197
 
@@ -214,34 +215,70 @@ const RUNTIME_BOOT_ID = `${Date.now().toString(36)}${Math.random().toString(36).
214
215
  // gets the stale bundle even after the HMR hard-reload. (DDR-067.)
215
216
  let CHROME_EPOCH = 0;
216
217
 
217
- /** Relative `.css` import specifiers in a canvas source → absolute paths (the
218
- * files Bun.build inlines). Bare / virtual specifiers (`@maude/…`, npm) are
219
- * skippedonly on-disk relative CSS affects the inlined output. Resolved
220
- * paths are clamped to `designRoot`: a canvas source is attacker-influenced
221
- * under linked mode (DDR-054), and these paths are `stat`-ed for mtime, so a
222
- * `../../../etc/…` specifier must not let the freshness probe reach outside the
223
- * design tree. Legit DS imports (`../../system/<ds>/…`) stay inside designRoot. */
224
- function cssDepsFromSource(source: string, canvasAbsPath: string, designRoot: string): string[] {
218
+ /** Relative import specifiers in a canvas source → absolute paths (the files
219
+ * Bun.build inlines): sibling `.css`, and RC6 of
220
+ * rca/issue-canvas-hmr-optimistic-update-consistencyimported local
221
+ * `.tsx/.ts/.jsx/.js` modules, which are inlined exactly like CSS (only
222
+ * RUNTIME_PACKAGES stay external, canvas-build.ts), so an edit to an imported
223
+ * sibling must bust the mtime-keyed cache the same way. Extensionless
224
+ * specifiers contribute EVERY resolution candidate (`.tsx`, `.ts`, …,
225
+ * `index.tsx`); missing candidates stat as 0, so a dep file APPEARING later
226
+ * also changes the signature. One level only — no transitive graph (the
227
+ * import-graph HMR re-emit is a tracked follow-up). Bare / virtual specifiers
228
+ * (`@maude/…`, npm) are skipped. Resolved paths are clamped to `designRoot`:
229
+ * a canvas source is attacker-influenced under linked mode (DDR-054), and
230
+ * these paths are `stat`-ed for mtime, so a `../../../etc/…` specifier must
231
+ * not let the freshness probe reach outside the design tree. Legit DS imports
232
+ * (`../../system/<ds>/…`) stay inside designRoot. */
233
+ export function localDepsFromSource(
234
+ source: string,
235
+ canvasAbsPath: string,
236
+ designRoot: string
237
+ ): string[] {
225
238
  const dir = dirname(canvasAbsPath);
226
239
  const root = resolve(designRoot);
227
240
  const deps: string[] = [];
228
- const re = /import\s+["']([^"']+\.css)["']/g;
229
- let m: RegExpExecArray | null = re.exec(source);
230
- while (m !== null) {
231
- if (m[1].startsWith('.')) {
232
- const abs = resolve(dir, m[1]);
233
- if (abs === root || abs.startsWith(root + sep)) deps.push(abs);
241
+ const seen = new Set<string>();
242
+ const push = (abs: string) => {
243
+ if (abs !== root && !abs.startsWith(root + sep)) return; // clamp (DDR-054)
244
+ if (seen.has(abs)) return;
245
+ seen.add(abs);
246
+ deps.push(abs);
247
+ };
248
+ const specRes = [
249
+ /\bimport\s+["']([^"']+)["']/g, // side-effect: import './x.css'
250
+ /\bfrom\s+["']([^"']+)["']/g, // import … from / export … from
251
+ /\bimport\(\s*["']([^"']+)["']\s*\)/g, // dynamic import('./x')
252
+ ];
253
+ for (const re of specRes) {
254
+ for (let m = re.exec(source); m !== null; m = re.exec(source)) {
255
+ const spec = m[1];
256
+ if (!spec?.startsWith('.')) continue;
257
+ const base = resolve(dir, spec);
258
+ if (/\.(css|tsx|ts|jsx|js)$/i.test(spec)) {
259
+ push(base);
260
+ } else {
261
+ for (const cand of [
262
+ `${base}.tsx`,
263
+ `${base}.ts`,
264
+ `${base}.jsx`,
265
+ `${base}.js`,
266
+ resolve(base, 'index.tsx'),
267
+ resolve(base, 'index.ts'),
268
+ ])
269
+ push(cand);
270
+ }
234
271
  }
235
- m = re.exec(source);
236
272
  }
237
273
  return deps;
238
274
  }
239
275
 
240
- /** mtime signature over the .tsx + its inlined CSS deps. A missing/unreadable
241
- * file contributes 0 — a delete is itself a change, so the signature differs. */
242
- function canvasFreshnessSig(tsxAbsPath: string, cssDeps: string[]): string {
276
+ /** mtime signature over the .tsx + every inlined relative dep (`.css` + local
277
+ * modules). A missing/unreadable file contributes 0 — a delete is itself a
278
+ * change (and a later create too), so the signature differs. */
279
+ function canvasFreshnessSig(tsxAbsPath: string, deps: string[]): string {
243
280
  const parts: string[] = [];
244
- for (const p of [tsxAbsPath, ...cssDeps]) {
281
+ for (const p of [tsxAbsPath, ...deps]) {
245
282
  const mt = Bun.file(p).lastModified;
246
283
  parts.push(`${p}@${Number.isFinite(mt) ? mt : 0}`);
247
284
  }
@@ -271,11 +308,11 @@ async function serveCanvasTsx(
271
308
  // HMR reload (mode:'module') re-served the stale inlined CSS — the edit only
272
309
  // surfaced once the .tsx itself changed (DDR-064 dogfooding finding).
273
310
  let cached = canvasCache.get(absPath);
274
- const sig = canvasFreshnessSig(absPath, cached?.cssDeps ?? []);
311
+ const sig = canvasFreshnessSig(absPath, cached?.deps ?? []);
275
312
 
276
313
  if (!cached || cached.sig !== sig) {
277
314
  const source = await file.text();
278
- const cssDeps = cssDepsFromSource(source, absPath, ctx.paths.designRoot);
315
+ const deps = localDepsFromSource(source, absPath, ctx.paths.designRoot);
279
316
  let result: Awaited<ReturnType<typeof buildCanvasModule>>;
280
317
  try {
281
318
  result = await buildCanvasModule(absPath, source, {
@@ -297,13 +334,13 @@ async function serveCanvasTsx(
297
334
  // Recompute the signature against the freshly-parsed deps — this very edit
298
335
  // may have added or removed a `.css` import.
299
336
  cached = {
300
- sig: canvasFreshnessSig(absPath, cssDeps),
337
+ sig: canvasFreshnessSig(absPath, deps),
301
338
  // Fold in the boot id (restart) + chrome epoch (live edit) so a chrome
302
339
  // change busts the browser's cached transpile even when the canvas source
303
340
  // (hence result.etag) is unchanged. See RUNTIME_BOOT_ID / CHROME_EPOCH.
304
341
  etag: `${result.etag}-${RUNTIME_BOOT_ID}-${CHROME_EPOCH}`,
305
342
  js: result.js,
306
- cssDeps,
343
+ deps,
307
344
  };
308
345
  canvasCache.set(absPath, cached);
309
346
  // Persist the locator map. Awaited so the inspector / Phase-12 layers
@@ -618,6 +655,42 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
618
655
  });
619
656
  },
620
657
 
658
+ // Phase 31 follow-up — persist an image pasted straight into the ACP composer
659
+ // (a clipboard screenshot has no path), returning an absolute path the chip
660
+ // expands to so Claude can Read it. MAIN-ORIGIN ONLY: sameOriginWrite CSRF gate
661
+ // + deliberately absent from CANVAS_SAFE_API + startCanvasServer routes, so the
662
+ // untrusted canvas iframe is 403'd. The disk caps live in api.saveChatAttachment
663
+ // (magic-byte sniff / 10 MB / content-addressed name / session write budget).
664
+ '/_api/acp/attachment': async (req: Request) => {
665
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
666
+ if (!sameOriginWrite(req))
667
+ return new Response('cross-origin write rejected', { status: 403 });
668
+ const declared = Number(req.headers.get('content-length') || '0');
669
+ if (Number.isFinite(declared) && declared > 10 * 1024 * 1024) {
670
+ return Response.json(
671
+ { ok: false, error: 'attachment exceeds the 10 MB cap' },
672
+ { status: 413, headers: { 'Cache-Control': 'no-store' } }
673
+ );
674
+ }
675
+ let bytes: Uint8Array;
676
+ try {
677
+ bytes = new Uint8Array(await req.arrayBuffer());
678
+ } catch {
679
+ return new Response('could not read request body', { status: 400 });
680
+ }
681
+ const result = await api.saveChatAttachment(bytes);
682
+ if (!result.ok) {
683
+ return Response.json(
684
+ { ok: false, error: result.error },
685
+ { status: result.status ?? 400, headers: { 'Cache-Control': 'no-store' } }
686
+ );
687
+ }
688
+ return Response.json(
689
+ { path: result.path },
690
+ { status: 201, headers: { 'Cache-Control': 'no-store' } }
691
+ );
692
+ },
693
+
621
694
  // Phase 9 Task 8 — offline-mode banner poll fallback. The linked-mode sync
622
695
  // runtime writes `_sync.json`; browser tabs also get live pushes over the
623
696
  // WS ('sync:status'). Returns `{ linked: false }` in solo mode.
@@ -1050,6 +1123,21 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1050
1123
  return gitJson(await githubApi.createProject(body));
1051
1124
  },
1052
1125
 
1126
+ // Create a NEW *local-only* project: mkdir + git init + .design scaffold, NO
1127
+ // GitHub / no token (the "just local git, no remote" path). Same main-origin +
1128
+ // loopback + POST CSRF gate as create-project, and MAIN-ORIGIN ONLY — absent
1129
+ // from CANVAS_SAFE_API + startCanvasServer routes (dual-allowlist), so the
1130
+ // untrusted canvas iframe can't drive disk writes.
1131
+ '/_api/project/create-local': async (req: Request) => {
1132
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1133
+ if (!sameOriginWrite(req))
1134
+ return new Response('cross-origin write rejected', { status: 403 });
1135
+ if (!isLoopbackHost(req.headers.get('host')))
1136
+ return new Response('local request required', { status: 403 });
1137
+ const body = await readJson<unknown>(req, 8 * 1024);
1138
+ return gitJson(await githubApi.createLocalProject(body));
1139
+ },
1140
+
1053
1141
  // Scaffold a bootable .design/ into an existing folder (the "open a non-Maude
1054
1142
  // repo → set it up?" fallback). No token / no GitHub — local FS only, but still
1055
1143
  // main-origin + loopback gated (it writes to disk).
@@ -1168,6 +1256,75 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1168
1256
  );
1169
1257
  },
1170
1258
 
1259
+ '/_api/reorder': async (req: Request) => {
1260
+ // Phase 12.1 (DDR-138) — node-move reorder. POST body
1261
+ // { canvas, id, refId, position } → moves the element with data-cd-id `id`
1262
+ // to `position` ('before'|'after'|'inside-start'|'inside-end') relative to
1263
+ // `refId` (reparent-capable) via api.reorder → moveElement. Snapshots the
1264
+ // pre-move source for /design:rollback. Same MAIN-ORIGIN-ONLY trust boundary
1265
+ // as /_api/edit-css + /_api/edit-text + /_api/edit-attr: intentionally absent
1266
+ // from CANVAS_SAFE_API + startCanvasServer's route allowlist (DDR-054) — the
1267
+ // untrusted canvas iframe requests a reorder over the dgn:* bus and the shell
1268
+ // (main origin) performs this privileged write.
1269
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1270
+ if (!sameOriginWrite(req))
1271
+ return new Response('cross-origin write rejected', { status: 403 });
1272
+ const body = await readJson<{
1273
+ canvas?: unknown;
1274
+ id?: unknown;
1275
+ refId?: unknown;
1276
+ position?: unknown;
1277
+ idIndex?: unknown;
1278
+ refIndex?: unknown;
1279
+ }>(req, 8 * 1024);
1280
+ if (!body) return new Response('body required', { status: 400 });
1281
+ const result = await api.reorder(body);
1282
+ if (!result.ok) {
1283
+ return Response.json(
1284
+ { ok: false, error: result.error },
1285
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1286
+ );
1287
+ }
1288
+ return Response.json(
1289
+ {
1290
+ ok: true,
1291
+ delta: result.delta,
1292
+ movedId: result.movedId,
1293
+ semanticId: result.semanticId,
1294
+ seq: result.seq,
1295
+ },
1296
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1297
+ );
1298
+ },
1299
+
1300
+ '/_api/reorder-revert': async (req: Request) => {
1301
+ // Phase 12.1 follow-up — Cmd+Z / Cmd+Shift+Z for a reorder. POST body
1302
+ // { canvas, seq, dir:'undo'|'redo' } → api.reorderRevert swaps the whole
1303
+ // file back to the logged {before|after} content (id-churn-proof; refuses
1304
+ // 409 when the canvas changed since). Same MAIN-ORIGIN-ONLY boundary as
1305
+ // /_api/reorder — NOT in CANVAS_SAFE_API nor startCanvasServer's routes;
1306
+ // the canvas undo stack requests it over the dgn bus, the shell writes.
1307
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1308
+ if (!sameOriginWrite(req))
1309
+ return new Response('cross-origin write rejected', { status: 403 });
1310
+ const body = await readJson<{ canvas?: unknown; seq?: unknown; dir?: unknown }>(
1311
+ req,
1312
+ 8 * 1024
1313
+ );
1314
+ if (!body) return new Response('body required', { status: 400 });
1315
+ const result = await api.reorderRevert(body);
1316
+ if (!result.ok) {
1317
+ return Response.json(
1318
+ { ok: false, error: result.error },
1319
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1320
+ );
1321
+ }
1322
+ return Response.json(
1323
+ { ok: true, dir: result.dir },
1324
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1325
+ );
1326
+ },
1327
+
1171
1328
  '/_api/asset': async (req: Request) => {
1172
1329
  // Phase 23 — binary image upload from the canvas (drag-drop / paste / the
1173
1330
  // a11y file picker). POST raw image bytes → content-addressed write under
@@ -1,6 +1,8 @@
1
1
  // Active-canvas state, selected-element tracking, and HTML injection
2
2
  // (inspector overlay + canvas runtime). See plan Task 7 + DDR-007.
3
3
 
4
+ import path from 'node:path';
5
+
4
6
  import type { Context } from './context.ts';
5
7
 
6
8
  export interface SelectedElement {
@@ -32,6 +34,19 @@ export interface SelectedElement {
32
34
  * derives it server-side from `file` (stripping `<designRoot>/` prefix + `.tsx`).
33
35
  */
34
36
  canvas?: string;
37
+ /**
38
+ * Canvas-file mtime (ms) at capture — the drift-gate stamp
39
+ * (feature-acp-context-hardening). `data-cd-id` is POSITIONAL, not content
40
+ * identity, so a selection restored after another agent edited the canvas
41
+ * must not be trusted blindly. 0 = mtime unavailable.
42
+ */
43
+ canvas_mtime?: number;
44
+ /**
45
+ * Set on restore-from-`selections` when the canvas changed since capture
46
+ * (mtime mismatch). Consumers re-anchor via `data-dc-element`/selector or
47
+ * degrade to canvas-wide — never trust the positional id when stale.
48
+ */
49
+ stale?: boolean;
35
50
  }
36
51
 
37
52
  /**
@@ -47,6 +62,16 @@ export interface ActiveState {
47
62
  active: string | null;
48
63
  open_tabs: string[];
49
64
  selected: SelectedValue;
65
+ /**
66
+ * Per-canvas selection memory, keyed by canvas slug
67
+ * (feature-acp-context-hardening). Additive: `selected` above stays the
68
+ * ACTIVE canvas's mirror, so every legacy reader (prep.sh SEL_VALID,
69
+ * /design:edit step 3, handoff tooling) keeps working unchanged — the same
70
+ * back-compat philosophy as the Phase 4.1 obj→arr widening. Non-active
71
+ * entries carry `html: ''` (size cap — locators survive, the 4000-char
72
+ * payload doesn't multiply across N canvases).
73
+ */
74
+ selections: Record<string, SelectedValue>;
50
75
  last_change: string | null;
51
76
  session_started: string;
52
77
  active_comments?: unknown[];
@@ -71,6 +96,7 @@ const NEW = (): ActiveState => ({
71
96
  active: null,
72
97
  open_tabs: [],
73
98
  selected: null,
99
+ selections: {},
74
100
  last_change: null,
75
101
  session_started: new Date().toISOString(),
76
102
  });
@@ -113,24 +139,85 @@ export function createInspect(
113
139
  const raw = await Bun.file(ctx.paths.activeFile).text();
114
140
  const prev = JSON.parse(raw);
115
141
  Object.assign(state, prev, { session_started: new Date().toISOString() });
142
+ // Pre-selections _active.json (or a hand-edited one) → keep the invariant.
143
+ if (!state.selections || typeof state.selections !== 'object') state.selections = {};
116
144
  } catch {
117
145
  // first boot
118
146
  }
119
147
  }
120
148
 
149
+ /** Canvas-file mtime for the drift-gate stamp. `file` is repoRoot-relative
150
+ * (designRel-prefixed, as the iframe reports it). Best-effort 0. Clamp to
151
+ * designRoot: `file` originates in the (untrusted, DDR-054) canvas via the
152
+ * origin-checked postMessage relay, and we `stat` it — a `../../../etc/…`
153
+ * must not turn this into a filesystem existence/mtime oracle (defender S1;
154
+ * mirrors localDepsFromSource's clamp). */
155
+ function mtimeFor(file: string): number {
156
+ try {
157
+ const rel = (file || '').replace(/^\/+/, '');
158
+ if (!rel) return 0;
159
+ const abs = path.resolve(ctx.paths.designRoot, path.relative(ctx.paths.designRel, rel));
160
+ const root = path.resolve(ctx.paths.designRoot);
161
+ if (abs !== root && !abs.startsWith(root + path.sep)) return 0;
162
+ const mt = Bun.file(abs).lastModified;
163
+ return Number.isFinite(mt) ? mt : 0;
164
+ } catch {
165
+ return 0;
166
+ }
167
+ }
168
+
169
+ /** Size cap for parked (non-active) selections — locators survive, the
170
+ * 4000-char outerHTML doesn't multiply across N canvases. */
171
+ function stripHtml(sel: SelectedValue): SelectedValue {
172
+ if (sel == null) return sel;
173
+ const strip = (e: SelectedElement): SelectedElement => ({ ...e, html: '' });
174
+ return Array.isArray(sel) ? sel.map(strip) : strip(sel);
175
+ }
176
+
177
+ /** Restore the incoming canvas's parked selection, drift-gated: a canvas
178
+ * edited since capture gets `stale: true` on every element (positional
179
+ * data-cd-id must not be trusted across another writer's edit). */
180
+ function restoreFor(file: string): SelectedValue {
181
+ const parked = state.selections[deriveCanvasSlug(file)];
182
+ if (parked == null) return null;
183
+ const current = mtimeFor(file);
184
+ const gate = (e: SelectedElement): SelectedElement =>
185
+ e.canvas_mtime && current && e.canvas_mtime !== current ? { ...e, stale: true } : e;
186
+ return Array.isArray(parked) ? parked.map(gate) : gate(parked);
187
+ }
188
+
121
189
  function setActive(file: string) {
122
190
  if (typeof file !== 'string') return;
123
191
  if (state.active === file) return;
192
+ // Park the outgoing canvas's selection (html-stripped) instead of losing
193
+ // it — the root fix for "switch canvas → agent loses my selection"
194
+ // (feature-acp-context-hardening).
195
+ if (state.active && state.selected != null) {
196
+ state.selections[deriveCanvasSlug(state.active)] = stripHtml(state.selected);
197
+ }
124
198
  state.active = file || null;
125
- state.selected = null;
199
+ state.selected = file ? restoreFor(file) : null;
126
200
  state.last_change = new Date().toISOString();
127
201
  scheduleSave();
128
202
  ctx.bus.emit('active', state.active);
203
+ // Clients (StatusBar, shell halo, chat context chip) must see the restored
204
+ // selection, not assume the pre-switch null.
205
+ ctx.bus.emit('selected', state.selected);
129
206
  }
130
207
 
131
208
  function setOpenTabs(tabs: string[]) {
132
209
  if (!Array.isArray(tabs)) return;
133
210
  state.open_tabs = tabs.filter((t): t is string => typeof t === 'string');
211
+ // GC selection memory for closed canvases — a closed tab's parked
212
+ // selection has no consumer and would otherwise accrete forever. Keep the
213
+ // CURRENT active canvas too: the single-canvas shell sends `tabs` with only
214
+ // the incoming canvas BEFORE `active` parks the outgoing one, so without
215
+ // this the outgoing canvas's memory would depend on message ordering.
216
+ const keep = new Set(state.open_tabs.map((t) => deriveCanvasSlug(t)));
217
+ if (state.active) keep.add(deriveCanvasSlug(state.active));
218
+ for (const slug of Object.keys(state.selections)) {
219
+ if (!keep.has(slug)) delete state.selections[slug];
220
+ }
134
221
  state.last_change = new Date().toISOString();
135
222
  scheduleSave();
136
223
  }
@@ -151,6 +238,7 @@ export function createInspect(
151
238
  html: String(sel.html || '').slice(0, 4000),
152
239
  ts: new Date().toISOString(),
153
240
  v,
241
+ canvas_mtime: mtimeFor(file),
154
242
  ...(id ? { id, canvas: deriveCanvasSlug(file) } : {}),
155
243
  };
156
244
  }
@@ -158,6 +246,9 @@ export function createInspect(
158
246
  function setSelected(sel: SetSelectedInput) {
159
247
  if (sel == null) {
160
248
  state.selected = null;
249
+ // Explicit deselect clears the active canvas's parked memory too — a
250
+ // deliberate act, not a context loss.
251
+ if (state.active) delete state.selections[deriveCanvasSlug(state.active)];
161
252
  } else if (Array.isArray(sel)) {
162
253
  const enriched = sel
163
254
  .filter(
@@ -175,6 +266,22 @@ export function createInspect(
175
266
  } else {
176
267
  state.selected = null;
177
268
  }
269
+ // Write-through into the per-canvas memory (full payload incl. html — this
270
+ // IS the active canvas's rich copy). Keyed by the ACTIVE canvas, and only
271
+ // when the selection's own file matches it: the client gates select posts to
272
+ // `e.source === activeWin` (app.jsx), but an ACTIVE untrusted canvas (a peer's
273
+ // canvas reviewed in hub mode, DDR-054) could still claim `file: <another
274
+ // trusted canvas>` and plant it into that canvas's slot for later delivery to
275
+ // the auto-approving agent. A selection can only legitimately belong to the
276
+ // canvas the user is looking at, so a mismatched `file` is a cross-canvas
277
+ // plant — drop the write-through (attacker Finding 2 residual).
278
+ if (state.selected != null && state.active) {
279
+ const first = Array.isArray(state.selected) ? state.selected[0] : state.selected;
280
+ const activeSlug = deriveCanvasSlug(state.active);
281
+ if (first?.file && deriveCanvasSlug(first.file) === activeSlug) {
282
+ state.selections[activeSlug] = state.selected;
283
+ }
284
+ }
178
285
  state.last_change = new Date().toISOString();
179
286
  scheduleSave();
180
287
  ctx.bus.emit('selected', state.selected);
@@ -48,6 +48,38 @@ export const CLIENT_DIR: string = join(DEV_SERVER_ROOT, 'client');
48
48
  /** `<DEV_SERVER_ROOT>/dist/runtime/` — pre-built /_canvas-runtime/*.js bundles. */
49
49
  export const RUNTIME_BUNDLES_DIR: string = join(DIST_DIR, 'runtime');
50
50
 
51
+ /**
52
+ * Absolute path to a bundled plugin's loadable tree (`commands/`, `agents/`,
53
+ * `skills/`, `hooks/`, `.claude-plugin/plugin.json`), or `null` when this layout
54
+ * doesn't ship it. Feeds the ACP session-scoped plugin auto-bootstrap
55
+ * (acp/plugin-bootstrap.ts → `_meta.claudeCode.options.plugins`, DDR-143).
56
+ *
57
+ * Resolved from DEV_SERVER_ROOT per DDR-045 (NEVER a local
58
+ * `dirname(fileURLToPath(import.meta.url))` — that's `/$bunfs/root` inside a
59
+ * compiled binary). Both the dev tree and the desktop `Resources/` bundle keep
60
+ * `plugins/` a sibling of `apps/studio/`, so `<root>/../../plugins/<p>` reaches
61
+ * it in either layout (`path.join` collapses the `..` segments):
62
+ * • dev tree: <repo>/apps/studio → <repo>/plugins/design
63
+ * • desktop Resources: …/Resources/apps/studio → …/Resources/plugins/design
64
+ *
65
+ * The npm tarball ships ONLY `plugins/<p>/templates` (+ dependencies.json;
66
+ * `plugins/flow/.claude-plugin/config.schema.json`) — NOT the plugin manifest
67
+ * (DDR-044 minimal surface). So we gate on `.claude-plugin/plugin.json`, not the
68
+ * dir: its ABSENCE under the npm-global / web-serve layout yields `null`, and
69
+ * the resolver skips injection there (those users have a terminal + the manual
70
+ * marketplace path). Only the dev tree and the staged desktop bundle carry it.
71
+ */
72
+ export function pluginDirFrom(devServerRoot: string, plugin: 'design' | 'flow'): string | null {
73
+ const dir = join(devServerRoot, '..', '..', 'plugins', plugin);
74
+ return existsSync(join(dir, '.claude-plugin', 'plugin.json')) ? dir : null;
75
+ }
76
+
77
+ /** Bundled `design` plugin tree, or `null` (npm/web layout). See {@link pluginDirFrom}. */
78
+ export const DESIGN_PLUGIN_DIR: string | null = pluginDirFrom(DEV_SERVER_ROOT, 'design');
79
+
80
+ /** Bundled `flow` plugin tree, or `null` (npm/web layout). See {@link pluginDirFrom}. */
81
+ export const FLOW_PLUGIN_DIR: string | null = pluginDirFrom(DEV_SERVER_ROOT, 'flow');
82
+
51
83
  /**
52
84
  * Whether we are running inside a `bun --compile` standalone binary
53
85
  * (true when `import.meta.url` resolves to bun's virtual filesystem).