@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.
- package/README.md +2 -0
- package/apps/studio/acp/bootstrap-brief.ts +93 -0
- package/apps/studio/acp/bridge.ts +114 -1
- package/apps/studio/acp/index.ts +184 -21
- package/apps/studio/acp/plugin-bootstrap.ts +115 -0
- package/apps/studio/acp/transcript.ts +36 -3
- package/apps/studio/activity.ts +45 -0
- package/apps/studio/api.ts +265 -47
- package/apps/studio/bin/_ensure-browser.mjs +305 -0
- package/apps/studio/bin/ensure-browser.sh +26 -0
- package/apps/studio/bin/screenshot.sh +33 -8
- package/apps/studio/bin/smoke.sh +46 -0
- package/apps/studio/canvas-edit.ts +422 -6
- package/apps/studio/canvas-lib.tsx +48 -0
- package/apps/studio/canvas-shell.tsx +684 -12
- package/apps/studio/client/app.jsx +683 -33
- package/apps/studio/client/panels/ChatPanel.jsx +593 -31
- package/apps/studio/client/panels/acp-runtime.js +227 -70
- package/apps/studio/client/panels/chat-context.js +124 -0
- package/apps/studio/client/panels/slash-commands.js +147 -0
- package/apps/studio/client/styles/3-shell-maude.css +15 -0
- package/apps/studio/client/styles/6-acp-chat.css +244 -0
- package/apps/studio/commands/reorder-command.ts +77 -0
- package/apps/studio/config.schema.json +6 -0
- package/apps/studio/dist/client.bundle.js +25 -53617
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/hmr-broadcast.ts +27 -19
- package/apps/studio/http.ts +168 -26
- package/apps/studio/inspect.ts +108 -1
- package/apps/studio/paths.ts +32 -0
- package/apps/studio/readiness.ts +79 -30
- package/apps/studio/server.ts +11 -2
- package/apps/studio/test/acp-activity.test.ts +154 -0
- package/apps/studio/test/acp-ai-activity.test.ts +182 -0
- package/apps/studio/test/acp-bootstrap-brief.test.ts +167 -0
- package/apps/studio/test/acp-commands.test.ts +108 -0
- package/apps/studio/test/acp-origin-gate.test.ts +64 -1
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +89 -0
- package/apps/studio/test/acp-session-plugins.test.ts +132 -0
- package/apps/studio/test/acp-transcript.test.ts +53 -0
- package/apps/studio/test/active-state.test.ts +41 -0
- package/apps/studio/test/canvas-freshness-deps.test.ts +64 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/canvas-reorder.test.ts +211 -0
- package/apps/studio/test/chat-context.test.ts +129 -0
- package/apps/studio/test/csrf-write-guard.test.ts +24 -0
- package/apps/studio/test/edit-suppress.test.ts +170 -0
- package/apps/studio/test/ensure-browser.test.ts +92 -0
- package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +44 -0
- package/apps/studio/test/hmr-broadcast.test.ts +44 -0
- package/apps/studio/test/inspect-selections.test.ts +219 -0
- package/apps/studio/test/paths.test.ts +57 -0
- package/apps/studio/test/readiness.test.ts +83 -13
- package/apps/studio/test/reorder-api.test.ts +210 -0
- package/apps/studio/test/slash-commands.test.ts +117 -0
- package/apps/studio/undo-stack.ts +6 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +37 -0
- package/cli/commands/design.mjs +1 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +3 -3
|
@@ -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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
120
|
+
pendingByKey.clear();
|
|
113
121
|
},
|
|
114
122
|
};
|
|
115
123
|
}
|
package/apps/studio/http.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
|
|
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-consistency — imported 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
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (
|
|
232
|
-
|
|
233
|
-
|
|
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 +
|
|
241
|
-
* file contributes 0 — a delete is itself a
|
|
242
|
-
|
|
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, ...
|
|
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?.
|
|
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
|
|
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,
|
|
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
|
-
|
|
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.
|
|
@@ -1183,6 +1256,75 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1183
1256
|
);
|
|
1184
1257
|
},
|
|
1185
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
|
+
|
|
1186
1328
|
'/_api/asset': async (req: Request) => {
|
|
1187
1329
|
// Phase 23 — binary image upload from the canvas (drag-drop / paste / the
|
|
1188
1330
|
// a11y file picker). POST raw image bytes → content-addressed write under
|
package/apps/studio/inspect.ts
CHANGED
|
@@ -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);
|
package/apps/studio/paths.ts
CHANGED
|
@@ -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).
|