@pro-vi/designer 0.3.10 → 0.3.11

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.
@@ -19,6 +19,9 @@ function sleep(ms) {
19
19
  return new Promise((r) => setTimeout(r, ms));
20
20
  }
21
21
  export async function ensureCdpUp() {
22
+ if (process.env.DESIGNER_CDP === '') {
23
+ throw new Error("CDP explicitly disabled (DESIGNER_CDP=''); using the agent-browser session-managed flow.");
24
+ }
22
25
  if (await isCdpUp())
23
26
  return;
24
27
  if (!fs.existsSync(PROFILE)) {
@@ -0,0 +1,509 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { ensureCdpUp } from "./cdp-ensure.js";
4
+ const DEFAULT_PORT = process.env.DESIGNER_CDP || '9222';
5
+ const DESIGN_URL_PATTERN = /^https:\/\/claude\.ai\/design/;
6
+ const REDACT_KEY_PATTERN = /^(cookie|set-cookie|authorization|proxy-authorization|x-api-key)$/i;
7
+ const STREAMABLE_RESOURCE_TYPES = new Set(['XHR', 'Fetch', 'EventSource']);
8
+ const AUTH_URL_DENYLIST = /\/(oauth|auth|login|logout|sign[_-]?in|sign[_-]?out|sign[_-]?up|register|token|sessions?|account|credential|password|mfa|totp|verify)\b/i;
9
+ export function scrubSecrets(s) {
10
+ return s
11
+ .replace(/eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, '[redacted-jwt]')
12
+ .replace(/\bsk-[A-Za-z0-9_-]{16,}\b/g, '[redacted-key]')
13
+ .replace(/(["']?(?:access[_-]?token|refresh[_-]?token|id[_-]?token|session[_-]?key|sessionKey|api[_-]?key|client[_-]?secret|secret|password|passwd|pwd)["']?\s*[:=]\s*["']?)[^"'&,;\s}]+/gi, '$1[redacted]')
14
+ .replace(/\bsessionKey=[^;"'\s&]+/gi, 'sessionKey=[redacted]')
15
+ .replace(/\b([Bb]earer)\s+[A-Za-z0-9._~+/=-]{12,}/g, '$1 [redacted]');
16
+ }
17
+ const PERSIST_METHODS = new Set([
18
+ 'Network.requestWillBeSent',
19
+ 'Network.responseReceived',
20
+ 'Network.dataReceived',
21
+ 'Network.loadingFinished',
22
+ 'Network.loadingFailed',
23
+ 'Network.requestServedFromCache',
24
+ 'Network.eventSourceMessageReceived',
25
+ 'Network.webSocketCreated',
26
+ 'Network.webSocketWillSendHandshakeRequest',
27
+ 'Network.webSocketHandshakeResponseReceived',
28
+ 'Network.webSocketFrameSent',
29
+ 'Network.webSocketFrameReceived',
30
+ 'Network.webSocketClosed',
31
+ 'Page.frameNavigated',
32
+ 'Page.frameStartedLoading',
33
+ 'Page.frameStoppedLoading',
34
+ 'Page.lifecycleEvent'
35
+ ]);
36
+ function asRec(v) {
37
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
38
+ }
39
+ function isCdpTarget(v) {
40
+ const r = asRec(v);
41
+ return (typeof r.id === 'string' &&
42
+ typeof r.type === 'string' &&
43
+ typeof r.title === 'string' &&
44
+ typeof r.url === 'string' &&
45
+ typeof r.webSocketDebuggerUrl === 'string');
46
+ }
47
+ export function redact(value) {
48
+ if (Array.isArray(value))
49
+ return value.map(redact);
50
+ if (value && typeof value === 'object') {
51
+ const out = {};
52
+ for (const [k, v] of Object.entries(value)) {
53
+ out[k] = REDACT_KEY_PATTERN.test(k) ? '[redacted]' : redact(v);
54
+ }
55
+ return out;
56
+ }
57
+ return value;
58
+ }
59
+ export async function listTargets(port = DEFAULT_PORT) {
60
+ const res = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(3000) });
61
+ if (!res.ok)
62
+ throw new Error(`CDP /json/list on :${port} returned ${res.status}`);
63
+ const body = await res.json();
64
+ if (!Array.isArray(body))
65
+ throw new Error(`CDP /json/list on :${port} returned a non-array payload`);
66
+ return body.filter(isCdpTarget);
67
+ }
68
+ export async function findDesignTarget({ port = DEFAULT_PORT, urlPattern = DESIGN_URL_PATTERN, preferUrlPrefix = null } = {}) {
69
+ const targets = await listTargets(port);
70
+ const candidates = targets.filter((t) => t.type === 'page' && urlPattern.test(t.url) && t.webSocketDebuggerUrl);
71
+ if (candidates.length === 0) {
72
+ throw new Error(`No page target matching ${urlPattern} on CDP :${port}. Open claude.ai/design first.`);
73
+ }
74
+ if (preferUrlPrefix) {
75
+ const preferred = candidates.find((t) => t.url.startsWith(preferUrlPrefix));
76
+ if (preferred)
77
+ return preferred;
78
+ }
79
+ const only = candidates[0];
80
+ if (candidates.length === 1 && only)
81
+ return only;
82
+ throw new Error(`Multiple design tabs match — pass --target-url to disambiguate:\n` +
83
+ candidates.map((t) => ` ${t.url}`).join('\n'));
84
+ }
85
+ export class CdpSession {
86
+ ws;
87
+ target;
88
+ sessionOpts;
89
+ stopped = false;
90
+ reconnects = 0;
91
+ pending = new Map();
92
+ socketClosed = false;
93
+ nextId = 0;
94
+ constructor(ws, target, opts = {}) {
95
+ this.ws = ws;
96
+ this.target = target;
97
+ this.sessionOpts = {
98
+ port: opts.port ?? DEFAULT_PORT,
99
+ urlPattern: opts.urlPattern ?? DESIGN_URL_PATTERN,
100
+ preferUrlPrefix: opts.preferUrlPrefix ?? null,
101
+ reconnect: opts.reconnect ?? true
102
+ };
103
+ this.wire(ws);
104
+ }
105
+ static async connectTarget(opts = {}) {
106
+ await ensureCdpUp();
107
+ const target = await findDesignTarget({
108
+ port: opts.port ?? DEFAULT_PORT,
109
+ urlPattern: opts.urlPattern,
110
+ preferUrlPrefix: opts.preferUrlPrefix ?? null
111
+ });
112
+ const ws = await this.openSocket(target.webSocketDebuggerUrl);
113
+ return { ws, target };
114
+ }
115
+ static openSocket(url) {
116
+ return new Promise((resolve, reject) => {
117
+ const ws = new WebSocket(url);
118
+ const onOpen = () => {
119
+ cleanup();
120
+ resolve(ws);
121
+ };
122
+ const onError = () => {
123
+ cleanup();
124
+ reject(new Error(`Failed to open CDP WebSocket ${url}`));
125
+ };
126
+ const cleanup = () => {
127
+ ws.removeEventListener('open', onOpen);
128
+ ws.removeEventListener('error', onError);
129
+ };
130
+ ws.addEventListener('open', onOpen);
131
+ ws.addEventListener('error', onError);
132
+ });
133
+ }
134
+ targetInfo() {
135
+ return { url: this.target.url, wsUrl: this.target.webSocketDebuggerUrl, port: this.sessionOpts.port };
136
+ }
137
+ async enableDomains() {
138
+ await this.send('Network.enable', { maxTotalBufferSize: 100_000_000, maxResourceBufferSize: 50_000_000 });
139
+ }
140
+ send(method, params, sessionId) {
141
+ const id = ++this.nextId;
142
+ const msg = { id, method };
143
+ if (params !== undefined)
144
+ msg.params = params;
145
+ if (sessionId)
146
+ msg.sessionId = sessionId;
147
+ return new Promise((resolve, reject) => {
148
+ this.pending.set(id, { resolve, reject });
149
+ try {
150
+ this.ws.send(JSON.stringify(msg));
151
+ }
152
+ catch (e) {
153
+ this.pending.delete(id);
154
+ reject(e instanceof Error ? e : new Error(String(e)));
155
+ }
156
+ });
157
+ }
158
+ close() {
159
+ this.closeSocket();
160
+ }
161
+ closeSocket() {
162
+ if (this.socketClosed)
163
+ return false;
164
+ this.socketClosed = true;
165
+ this.stopped = true;
166
+ this.rejectPending(new Error('CDP WebSocket closed'));
167
+ try {
168
+ this.ws.close();
169
+ }
170
+ catch {
171
+ }
172
+ return true;
173
+ }
174
+ onSocketGap(_detail) { }
175
+ onSocketReconnected(_target) { }
176
+ onSocketReconnectFailed(_detail) { }
177
+ wire(ws) {
178
+ ws.addEventListener('message', (ev) => {
179
+ this.onMessage(typeof ev.data === 'string' ? ev.data : String(ev.data));
180
+ });
181
+ ws.addEventListener('close', () => {
182
+ void this.handleClose();
183
+ });
184
+ }
185
+ onMessage(raw) {
186
+ let msg;
187
+ try {
188
+ msg = JSON.parse(raw);
189
+ }
190
+ catch {
191
+ return;
192
+ }
193
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg))
194
+ return;
195
+ const rec = msg;
196
+ if (typeof rec.id === 'number') {
197
+ const p = this.pending.get(rec.id);
198
+ if (!p)
199
+ return;
200
+ this.pending.delete(rec.id);
201
+ if (rec.error)
202
+ p.reject(new Error(`CDP ${JSON.stringify(rec.error)}`));
203
+ else
204
+ p.resolve(rec.result);
205
+ return;
206
+ }
207
+ const method = typeof rec.method === 'string' ? rec.method : '';
208
+ if (!method)
209
+ return;
210
+ this.onEvent(method, rec.params, typeof rec.sessionId === 'string' ? rec.sessionId : undefined);
211
+ }
212
+ rejectPending(error) {
213
+ for (const [, p] of this.pending)
214
+ p.reject(error);
215
+ this.pending.clear();
216
+ }
217
+ async handleClose() {
218
+ this.rejectPending(new Error('CDP WebSocket closed'));
219
+ if (this.stopped)
220
+ return;
221
+ this.onSocketGap({ reason: 'socket-closed' });
222
+ if (!this.sessionOpts.reconnect)
223
+ return;
224
+ for (let i = 0; i < 30 && !this.stopped; i++) {
225
+ await new Promise((r) => setTimeout(r, 1000));
226
+ try {
227
+ const target = await findDesignTarget({
228
+ port: this.sessionOpts.port,
229
+ urlPattern: this.sessionOpts.urlPattern,
230
+ preferUrlPrefix: this.sessionOpts.preferUrlPrefix
231
+ });
232
+ const ws = await CdpSession.openSocket(target.webSocketDebuggerUrl);
233
+ this.ws = ws;
234
+ this.target = target;
235
+ this.wire(ws);
236
+ await this.enableDomains();
237
+ this.reconnects++;
238
+ this.onSocketReconnected(target);
239
+ return;
240
+ }
241
+ catch {
242
+ }
243
+ }
244
+ if (!this.stopped)
245
+ this.onSocketReconnectFailed({ gaveUpAfterMs: 30_000 });
246
+ }
247
+ }
248
+ export class CdpTraceRecorder extends CdpSession {
249
+ opts;
250
+ out;
251
+ requests = new Map();
252
+ wsSocketUrls = new Map();
253
+ pendingBodies = new Set();
254
+ startedAt = Date.now();
255
+ total = 0;
256
+ bodyCaptures = 0;
257
+ ended = false;
258
+ byMethod = {};
259
+ droppedByMethod = {};
260
+ constructor(ws, target, opts) {
261
+ super(ws, target, opts);
262
+ this.opts = {
263
+ outFile: opts.outFile,
264
+ port: opts.port ?? DEFAULT_PORT,
265
+ urlPattern: opts.urlPattern ?? DESIGN_URL_PATTERN,
266
+ preferUrlPrefix: opts.preferUrlPrefix ?? null,
267
+ captureBodiesFor: opts.captureBodiesFor ?? /^https:\/\/claude\.ai\//,
268
+ maxBodyBytesPerRequest: opts.maxBodyBytesPerRequest ?? 2 * 1024 * 1024,
269
+ reconnect: opts.reconnect ?? true
270
+ };
271
+ fs.mkdirSync(path.dirname(opts.outFile), { recursive: true });
272
+ this.out = fs.createWriteStream(opts.outFile, { flags: 'a' });
273
+ this.out.on('error', () => { });
274
+ }
275
+ static async attach(opts) {
276
+ if (typeof WebSocket === 'undefined') {
277
+ throw new Error('Native WebSocket unavailable — cdp-trace requires Node >= 22.');
278
+ }
279
+ const { ws, target } = await CdpTraceRecorder.connectTarget(opts);
280
+ return new CdpTraceRecorder(ws, target, opts);
281
+ }
282
+ async start() {
283
+ this.startedAt = Date.now();
284
+ await this.enableDomains();
285
+ this.writeLine({
286
+ ts: Date.now(),
287
+ kind: 'recorder',
288
+ event: 'attach',
289
+ detail: { targetUrl: this.target.url, wsUrl: this.target.webSocketDebuggerUrl }
290
+ });
291
+ }
292
+ async enableDomains() {
293
+ await super.enableDomains();
294
+ await this.send('Page.enable');
295
+ await this.send('Page.setLifecycleEventsEnabled', { enabled: true });
296
+ }
297
+ marker(name, detail) {
298
+ this.writeLine({ ts: Date.now(), kind: 'recorder', event: 'marker', detail: { name, ...asRec(detail) } });
299
+ }
300
+ record(ev) {
301
+ this.writeLine({ ...ev, ts: ev.ts || Date.now() });
302
+ }
303
+ async stop() {
304
+ this.stopped = true;
305
+ await Promise.race([
306
+ Promise.allSettled([...this.pendingBodies]),
307
+ new Promise((r) => setTimeout(r, 3000))
308
+ ]);
309
+ this.writeLine({ ts: Date.now(), kind: 'recorder', event: 'detach' });
310
+ this.close();
311
+ this.ended = true;
312
+ await new Promise((resolve) => this.out.end(resolve));
313
+ return {
314
+ durationMs: Date.now() - this.startedAt,
315
+ total: this.total,
316
+ byMethod: this.byMethod,
317
+ droppedByMethod: this.droppedByMethod,
318
+ reconnects: this.reconnects,
319
+ bodyCaptures: this.bodyCaptures
320
+ };
321
+ }
322
+ onEvent(method, rawParams, sessionId) {
323
+ if (!PERSIST_METHODS.has(method)) {
324
+ this.droppedByMethod[method] = (this.droppedByMethod[method] || 0) + 1;
325
+ return;
326
+ }
327
+ const params = asRec(rawParams);
328
+ this.trackRequest(method, params);
329
+ const shaped = this.shapePayloads(method, params);
330
+ const ev = { ts: Date.now(), kind: 'cdp', method, params: redact(shaped) };
331
+ if (sessionId)
332
+ ev.sessionId = sessionId;
333
+ this.writeLine(ev);
334
+ this.byMethod[method] = (this.byMethod[method] || 0) + 1;
335
+ if (method === 'Network.responseReceived')
336
+ this.maybeStreamContent(params);
337
+ if (method === 'Network.loadingFinished')
338
+ this.maybeFetchBody(params);
339
+ }
340
+ trackRequest(method, params) {
341
+ const requestId = typeof params.requestId === 'string' ? params.requestId : null;
342
+ if (!requestId)
343
+ return;
344
+ if (method === 'Network.requestWillBeSent') {
345
+ const req = asRec(params.request);
346
+ this.requests.set(requestId, {
347
+ url: String(req.url || ''),
348
+ resourceType: typeof params.type === 'string' ? params.type : null,
349
+ mimeType: null,
350
+ streamed: false,
351
+ bodyBytes: 0,
352
+ bodyFetched: false
353
+ });
354
+ }
355
+ else if (method === 'Network.responseReceived') {
356
+ const info = this.requests.get(requestId);
357
+ if (info) {
358
+ const resp = asRec(params.response);
359
+ info.mimeType = typeof resp.mimeType === 'string' ? resp.mimeType : null;
360
+ if (typeof params.type === 'string')
361
+ info.resourceType = params.type;
362
+ }
363
+ }
364
+ else if (method === 'Network.webSocketCreated') {
365
+ this.wsSocketUrls.set(requestId, String(params.url || ''));
366
+ }
367
+ }
368
+ shapePayloads(method, params) {
369
+ if (method === 'Network.requestWillBeSent') {
370
+ const req = asRec(params.request);
371
+ if (typeof req.postData === 'string') {
372
+ if (!this.opts.captureBodiesFor.test(String(req.url || ''))) {
373
+ return { ...params, request: { ...req, postData: undefined, postDataBytes: req.postData.length } };
374
+ }
375
+ return { ...params, request: { ...req, postData: scrubSecrets(req.postData) } };
376
+ }
377
+ return params;
378
+ }
379
+ if (method === 'Network.webSocketFrameSent' || method === 'Network.webSocketFrameReceived') {
380
+ const requestId = String(params.requestId || '');
381
+ const socketUrl = this.wsSocketUrls.get(requestId) || '';
382
+ const resp = asRec(params.response);
383
+ if (typeof resp.payloadData === 'string') {
384
+ if (!this.opts.captureBodiesFor.test(socketUrl)) {
385
+ return { ...params, response: { ...resp, payloadData: undefined, payloadBytes: resp.payloadData.length } };
386
+ }
387
+ return { ...params, response: { ...resp, payloadData: scrubSecrets(resp.payloadData) } };
388
+ }
389
+ return params;
390
+ }
391
+ if (method === 'Network.dataReceived' && typeof params.data === 'string') {
392
+ const requestId = String(params.requestId || '');
393
+ const info = this.requests.get(requestId);
394
+ const bytes = Math.floor((params.data.length * 3) / 4);
395
+ if (info) {
396
+ if (info.bodyBytes + bytes > this.opts.maxBodyBytesPerRequest) {
397
+ return { ...params, data: undefined, dataDroppedBytes: bytes, truncated: true };
398
+ }
399
+ info.bodyBytes += bytes;
400
+ }
401
+ return params;
402
+ }
403
+ return params;
404
+ }
405
+ maybeStreamContent(params) {
406
+ const requestId = typeof params.requestId === 'string' ? params.requestId : null;
407
+ if (!requestId)
408
+ return;
409
+ const info = this.requests.get(requestId);
410
+ if (!info || info.streamed)
411
+ return;
412
+ if (!this.opts.captureBodiesFor.test(info.url))
413
+ return;
414
+ if (AUTH_URL_DENYLIST.test(info.url))
415
+ return;
416
+ if (!info.resourceType || !STREAMABLE_RESOURCE_TYPES.has(info.resourceType))
417
+ return;
418
+ const resp = asRec(params.response);
419
+ const headers = asRec(resp.headers);
420
+ const hasContentLength = Object.keys(headers).some((k) => k.toLowerCase() === 'content-length');
421
+ const isSse = info.mimeType === 'text/event-stream';
422
+ if (!isSse && hasContentLength)
423
+ return;
424
+ info.streamed = true;
425
+ const p = this.send('Network.streamResourceContent', { requestId })
426
+ .then((result) => {
427
+ const buffered = String(asRec(result).bufferedData || '');
428
+ const bytes = Math.floor((buffered.length * 3) / 4);
429
+ const truncated = bytes > this.opts.maxBodyBytesPerRequest;
430
+ info.bodyBytes += Math.min(bytes, this.opts.maxBodyBytesPerRequest);
431
+ this.bodyCaptures++;
432
+ this.writeLine({
433
+ ts: Date.now(),
434
+ kind: 'body',
435
+ requestId,
436
+ url: info.url,
437
+ source: 'streamBuffered',
438
+ base64: truncated ? buffered.slice(0, Math.ceil((this.opts.maxBodyBytesPerRequest * 4) / 3)) : buffered,
439
+ truncated,
440
+ bytes
441
+ });
442
+ })
443
+ .catch(() => {
444
+ info.streamed = false;
445
+ })
446
+ .finally(() => this.pendingBodies.delete(p));
447
+ this.pendingBodies.add(p);
448
+ }
449
+ maybeFetchBody(params) {
450
+ const requestId = typeof params.requestId === 'string' ? params.requestId : null;
451
+ if (!requestId || this.stopped)
452
+ return;
453
+ const info = this.requests.get(requestId);
454
+ if (!info || info.streamed || info.bodyFetched)
455
+ return;
456
+ if (!this.opts.captureBodiesFor.test(info.url))
457
+ return;
458
+ if (AUTH_URL_DENYLIST.test(info.url))
459
+ return;
460
+ if (!info.resourceType || !STREAMABLE_RESOURCE_TYPES.has(info.resourceType))
461
+ return;
462
+ info.bodyFetched = true;
463
+ const p = this.send('Network.getResponseBody', { requestId })
464
+ .then((result) => {
465
+ const r = asRec(result);
466
+ const isB64 = r.base64Encoded === true;
467
+ const body = isB64 ? String(r.body || '') : scrubSecrets(String(r.body || ''));
468
+ const bytes = isB64 ? Math.floor((body.length * 3) / 4) : body.length;
469
+ const truncated = bytes > this.opts.maxBodyBytesPerRequest;
470
+ const cap = isB64 ? Math.ceil((this.opts.maxBodyBytesPerRequest * 4) / 3) : this.opts.maxBodyBytesPerRequest;
471
+ this.bodyCaptures++;
472
+ this.writeLine({
473
+ ts: Date.now(),
474
+ kind: 'body',
475
+ requestId,
476
+ url: info.url,
477
+ source: 'getResponseBody',
478
+ base64: isB64 ? (truncated ? body.slice(0, cap) : body) : Buffer.from(truncated ? body.slice(0, cap) : body).toString('base64'),
479
+ truncated,
480
+ bytes
481
+ });
482
+ })
483
+ .catch((e) => {
484
+ this.writeLine({
485
+ ts: Date.now(),
486
+ kind: 'recorder',
487
+ event: 'error',
488
+ detail: { op: 'getResponseBody', requestId, url: info.url, message: e.message }
489
+ });
490
+ })
491
+ .finally(() => this.pendingBodies.delete(p));
492
+ this.pendingBodies.add(p);
493
+ }
494
+ onSocketGap() {
495
+ this.writeLine({ ts: Date.now(), kind: 'recorder', event: 'gap', detail: { reason: 'socket-closed' } });
496
+ }
497
+ onSocketReconnected(target) {
498
+ this.writeLine({ ts: Date.now(), kind: 'recorder', event: 'reconnect', detail: { targetUrl: target.url } });
499
+ }
500
+ onSocketReconnectFailed(detail) {
501
+ this.writeLine({ ts: Date.now(), kind: 'recorder', event: 'error', detail: { op: 'reconnect', ...detail } });
502
+ }
503
+ writeLine(ev) {
504
+ if (this.ended)
505
+ return;
506
+ this.out.write(JSON.stringify(ev) + '\n');
507
+ this.total++;
508
+ }
509
+ }
@@ -8,6 +8,7 @@ import { sessionDir, saveIteration } from "./artifact-store.js";
8
8
  import { upsertSession, appendHistory, getSession } from "./session-store.js";
9
9
  import { REPO_ROOT } from "./repo-root.js";
10
10
  import { ensureCdpUp } from "./cdp-ensure.js";
11
+ import { RunStateObserver } from "./run-state.js";
11
12
  const DESIGN_HOME = 'https://claude.ai/design';
12
13
  const FLAT_LAYOUT_SUFFIX = '\n\nFile layout: keep all generated files at the project root. No subfolders.';
13
14
  const DECISIVE_SUFFIX = '\n\nIf you would otherwise stop to ask clarifying questions, do not. Choose the most defensible answer for each axis yourself and proceed. Note your assumption in a one-line `<!-- assumed: ... -->` comment at the top of the relevant file so I can override on the next turn.';
@@ -241,16 +242,20 @@ export class DesignerController {
241
242
  return true;
242
243
  })()`);
243
244
  }
244
- async sendPrompt(prompt, { decisive = false } = {}) {
245
+ async sendPrompt(prompt, { decisive = false, onBeforeSubmit } = {}) {
245
246
  const before = await this.fetchServedHtml();
246
247
  this._preSendHtml = before.html;
247
248
  const effective = prompt + FLAT_LAYOUT_SUFFIX + (decisive ? DECISIVE_SUFFIX : '');
249
+ onBeforeSubmit?.();
248
250
  await this._submitPrompt(effective);
249
251
  const suffixApplied = decisive ? 'flat_layout+decisive' : 'flat_layout';
250
252
  appendHistory(this.key, { kind: 'prompt', prompt, suffixApplied });
251
253
  return { ok: true };
252
254
  }
253
255
  async waitForGenerationDone({ timeoutMs = 20 * 60_000, stabilityMs = 4000, pollMs = 1500 } = {}) {
256
+ return this._waitForGenerationDoneHtml({ timeoutMs, stabilityMs, pollMs });
257
+ }
258
+ async _waitForGenerationDoneHtml({ timeoutMs = 20 * 60_000, stabilityMs = 4000, pollMs = 1500 } = {}) {
254
259
  const start = Date.now();
255
260
  const preHtml = this._preSendHtml || '';
256
261
  let lastHtml = '';
@@ -286,6 +291,36 @@ export class DesignerController {
286
291
  }
287
292
  return { ok: false, error: 'timeout', elapsedMs: Date.now() - start };
288
293
  }
294
+ async _waitForGenerationDoneNetwork(observer, { timeoutMs = 20 * 60_000, stabilityMs = 4000, pollMs = 1500 } = {}) {
295
+ const terminal = await observer.awaitTerminal({ hardTimeoutMs: timeoutMs });
296
+ if (terminal.terminal === 'observer-lost') {
297
+ return { ok: false, error: 'observer-lost', elapsedMs: terminal.elapsedMs, reason: terminal.reason };
298
+ }
299
+ if (terminal.terminal === 'blocked') {
300
+ return { ok: false, error: 'blocked', elapsedMs: terminal.elapsedMs, reason: terminal.reason };
301
+ }
302
+ if (terminal.terminal === 'timeout') {
303
+ return { ok: false, error: 'stalled', elapsedMs: terminal.elapsedMs, reason: terminal.reason };
304
+ }
305
+ let { html, src } = await this.fetchServedHtml();
306
+ const preHtml = this._preSendHtml || '';
307
+ const settleDeadline = Date.now() + Math.min(timeoutMs, Math.max(stabilityMs, 12_000));
308
+ let stableSince = Date.now();
309
+ while (Date.now() < settleDeadline) {
310
+ await new Promise((r) => setTimeout(r, pollMs));
311
+ const next = await this.fetchServedHtml();
312
+ if (next.html !== html) {
313
+ html = next.html;
314
+ src = next.src;
315
+ stableSince = Date.now();
316
+ }
317
+ else if (html !== preHtml && Date.now() - stableSince >= stabilityMs) {
318
+ break;
319
+ }
320
+ }
321
+ const url = await this.currentUrl();
322
+ return { ok: true, elapsedMs: terminal.elapsedMs, url, iframeSrc: src, htmlBytes: html.length, html };
323
+ }
289
324
  async snapshotDesign({ html: knownHtml, iframeSrc: knownSrc } = {}) {
290
325
  const iframeSrc = knownSrc || (await this.getIframeSrc());
291
326
  let html = knownHtml ?? null;
@@ -318,8 +353,34 @@ export class DesignerController {
318
353
  await this.openFile(file);
319
354
  const preFiles = await this.listFiles().catch(() => []);
320
355
  const preChatCount = (await this.getChatTurns()).length;
321
- await this.sendPrompt(prompt, { decisive });
322
- const done = await this.waitForGenerationDone({ timeoutMs, stabilityMs });
356
+ const waitBudgetMs = timeoutMs ?? 20 * 60_000;
357
+ const cdpEnabled = (process.env.DESIGNER_CDP ?? '9222') !== '';
358
+ let observer = cdpEnabled
359
+ ? await RunStateObserver.attach({
360
+ preferUrlPrefix: (await this.currentUrl()).split('?')[0] || null
361
+ })
362
+ : null;
363
+ let done;
364
+ try {
365
+ await this.sendPrompt(prompt, { decisive, onBeforeSubmit: () => observer?.beginRun() });
366
+ if (observer) {
367
+ done = await this._waitForGenerationDoneNetwork(observer, { timeoutMs: waitBudgetMs, stabilityMs });
368
+ if (done.error === 'observer-lost') {
369
+ const fallback = await this._waitForGenerationDoneHtml({
370
+ timeoutMs: Math.max(1, waitBudgetMs - done.elapsedMs),
371
+ stabilityMs
372
+ });
373
+ done = { ...fallback, elapsedMs: done.elapsedMs + fallback.elapsedMs };
374
+ }
375
+ }
376
+ else {
377
+ done = await this._waitForGenerationDoneHtml({ timeoutMs: waitBudgetMs, stabilityMs });
378
+ }
379
+ }
380
+ finally {
381
+ observer?.close();
382
+ observer = null;
383
+ }
323
384
  const postFiles = await this.listFiles().catch(() => []);
324
385
  const postTurns = await this.getChatTurns();
325
386
  const lastTurn = postTurns[postTurns.length - 1];
@@ -332,8 +393,16 @@ export class DesignerController {
332
393
  const htmlHash = snap.html ? hashHex(snap.html) : null;
333
394
  const activeFile = extractFileParam(snap.url);
334
395
  let failureMode = null;
335
- if (!done.ok)
336
- failureMode = done.error === 'timeout' ? 'timeout' : 'unstable';
396
+ if (!done.ok) {
397
+ if (done.error === 'timeout')
398
+ failureMode = 'timeout';
399
+ else if (done.error === 'stalled')
400
+ failureMode = 'stalled';
401
+ else if (done.error === 'blocked')
402
+ failureMode = 'blocked';
403
+ else
404
+ failureMode = 'unstable';
405
+ }
337
406
  else if (snap.html === this._preSendHtml && newFiles.length === 0)
338
407
  failureMode = 'no_change';
339
408
  const fidelity = getSession(this.key)?.fidelity || null;
@@ -32,7 +32,7 @@ server.registerTool('designer_session', {
32
32
  }
33
33
  }, async ({ key, action = 'status', name, fidelity }) => textResult(await getController(key).session({ action, name, fidelity })));
34
34
  server.registerTool('designer_prompt', {
35
- description: "Modify the design. Sends a prompt you expect to change the served HTML (e.g., 'create a login screen', 'add a Remember-me checkbox'). Waits for HTML to change and stabilize. Returns slim metadata — NOT inline HTML (written to disk at htmlPath).\n\n**Default taste path: hand the human `url` from the return.** The URL is the live claude.ai/design surface — fully interactive, tweak sliders work, variant switcher works. Only reach for `designer tasting` when full-viewport comparison matters more than interactivity.\n\nAuto-appended to every prompt: an instruction to keep all generated files at the project root (no subfolders). The live MCP's file-list scrape is flat-only; subfolder-nested files are invisible until `designer_handoff`. If you need nested layouts, explicitly contradict this in your prompt.\n\nKey return fields:\n- url: live URL to show the human (default taste path)\n- done.failureMode: null | 'timeout' | 'unstable' | 'no_change' (no_change means Claude replied text-only did you want designer_ask?)\n- newFiles / removedFiles: diff vs pre-send\n- activeFile: what's currently rendered\n- htmlPath / screenshotPath: read these only if you need the content\n- chatReply: Claude's commentary",
35
+ description: "Modify the design. Sends a prompt you expect to change the served HTML (e.g., 'create a login screen', 'add a Remember-me checkbox'). Waits for Claude Design's turn-RPC completion signal, then fetches the served HTML once it settles; if the network observer is unavailable, falls back to the older HTML-stability wait. Returns slim metadata — NOT inline HTML (written to disk at htmlPath).\n\n**Default taste path: hand the human `url` from the return.** The URL is the live claude.ai/design surface — fully interactive, tweak sliders work, variant switcher works. Only reach for `designer tasting` when full-viewport comparison matters more than interactivity.\n\nAuto-appended to every prompt: an instruction to keep all generated files at the project root (no subfolders). The live MCP's file-list scrape is flat-only; subfolder-nested files are invisible until `designer_handoff`. If you need nested layouts, explicitly contradict this in your prompt.\n\nKey return fields:\n- url: live URL to show the human (default taste path)\n- done.failureMode: null | 'timeout' | 'unstable' | 'no_change' | 'stalled' | 'blocked' (no_change now reliably means Claude finished without changing served HTML, often a chat-only reply; stalled means turn RPCs went silent until the hard timeout; blocked means a critical turn RPC failed)\n- newFiles / removedFiles: diff vs pre-send\n- activeFile: what's currently rendered\n- htmlPath / screenshotPath: read these only if you need the content\n- chatReply: Claude's commentary",
36
36
  inputSchema: {
37
37
  key: z.string().optional(),
38
38
  prompt: z.string(),