@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 +1 -1
- package/dist/browser.js +12 -2
- package/dist/cdp-ensure.js +3 -0
- package/dist/cdp-trace.js +509 -0
- package/dist/designer-controller.js +74 -5
- package/dist/mcp-server.js +1 -1
- package/dist/run-state.js +327 -0
- package/dist/scripts/ci-health.js +1 -0
- package/dist/scripts/trace-analyze.js +366 -0
- package/dist/scripts/trace-spike.js +274 -0
- package/dist/setup.js +33 -2
- package/dist/ui-anchors.js +140 -2
- package/package.json +6 -4
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
|
|
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)
|
package/dist/cdp-ensure.js
CHANGED
|
@@ -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
|
-
|
|
322
|
-
const
|
|
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
|
-
|
|
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;
|