@pro-vi/designer 0.3.9 → 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.
package/README.md CHANGED
@@ -118,7 +118,7 @@ Writes `tasting.html` with variant tabs + 1/2/3 shortcuts + persistent notes, se
118
118
  ## Operations
119
119
 
120
120
  - `designer doctor` — diagnose setup state. Exits 2 on failure.
121
- - `designer health [--json]` — probe 17 UI anchors. Wire into cron to catch claude.ai UI regressions.
121
+ - `designer health [--json]` — probe every UI anchor designer depends on. Wire into cron to catch claude.ai UI regressions.
122
122
  - **Daily CI** in `.github/workflows/`: `daily-health.yml` runs the auth-required UI probe on a self-hosted macOS runner once per day; `ci.yml` typechecks + builds + does a Docker clean-room install smoke on every PR; `release-please.yml` opens a release PR on conventional commits, merging it tags + publishes via `release-publish.yml`. Selector regressions land as auto-opened PRs under the `selectors-drift` label.
123
123
 
124
124
  ## Known quirks
package/dist/browser.js CHANGED
@@ -12,9 +12,10 @@ export function createBrowser({ session = DEFAULT_SESSION, headed = true, timeou
12
12
  function connectFlags() {
13
13
  if (!cdp)
14
14
  return [];
15
+ const scope = ['--session', `designer-cdp-${cdp.replace(/[^a-zA-Z0-9.-]/g, '_')}`];
15
16
  if (cdp === 'auto' || cdp === '1' || cdp === 'true')
16
- return ['--auto-connect'];
17
- return ['--cdp', cdp];
17
+ return [...scope, '--auto-connect'];
18
+ return [...scope, '--cdp', cdp];
18
19
  }
19
20
  function run(args, { input, parseJson = false } = {}) {
20
21
  return new Promise((resolve, reject) => {
@@ -64,6 +65,15 @@ export function createBrowser({ session = DEFAULT_SESSION, headed = true, timeou
64
65
  activateTab: async (index) => {
65
66
  await run(['tab', String(index)]);
66
67
  },
68
+ reload: () => run(['reload']),
69
+ cookies: async () => {
70
+ const out = await run(['cookies', 'get', '--json']);
71
+ const env = JSON.parse(out);
72
+ if (env.success === false) {
73
+ throw new Error(`agent-browser cookies get failed: ${JSON.stringify(env.error)}`);
74
+ }
75
+ return env.data?.cookies ?? [];
76
+ },
67
77
  snapshot: ({ interactive = true, scope } = {}) => {
68
78
  const args = ['snapshot', '--json'];
69
79
  if (interactive)
@@ -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;