@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.
@@ -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(),
@@ -0,0 +1,327 @@
1
+ import { CdpSession } from "./cdp-trace.js";
2
+ export const OMELETTE_TURN_SERVICE = 'anthropic.omelette.api.v1alpha.OmeletteService';
3
+ export const TURN_RPCS = ['Chat', 'RenewTurn', 'ReleaseTurn'];
4
+ function asRec(v) {
5
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
6
+ }
7
+ function num(v) {
8
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
9
+ }
10
+ function eventWallMs(params) {
11
+ const syntheticTraceTs = num(params.ts);
12
+ if (syntheticTraceTs !== null)
13
+ return syntheticTraceTs;
14
+ const wallTime = num(params.wallTime);
15
+ return wallTime !== null ? wallTime * 1000 : null;
16
+ }
17
+ function requestId(params) {
18
+ return typeof params.requestId === 'string' ? params.requestId : undefined;
19
+ }
20
+ export function turnRpcFromUrl(url) {
21
+ if (!url)
22
+ return null;
23
+ let path = url;
24
+ try {
25
+ path = new URL(url).pathname;
26
+ }
27
+ catch {
28
+ }
29
+ const escapedService = OMELETTE_TURN_SERVICE.replace(/\./g, '\\.');
30
+ const match = path.match(new RegExp(`(?:^|/)${escapedService}/(Chat|RenewTurn|ReleaseTurn)(?:$|[/?#])`));
31
+ if (!match?.[1])
32
+ return null;
33
+ return TURN_RPCS.includes(match[1]) ? match[1] : null;
34
+ }
35
+ export function observedRpcPathFromUrl(url) {
36
+ if (!url)
37
+ return null;
38
+ let path = url;
39
+ try {
40
+ path = new URL(url).pathname;
41
+ }
42
+ catch {
43
+ }
44
+ const serviceIdx = path.indexOf(`${OMELETTE_TURN_SERVICE}/`);
45
+ if (serviceIdx >= 0)
46
+ return path.slice(serviceIdx);
47
+ const idx = path.indexOf('OmeletteService/');
48
+ return idx >= 0 ? path.slice(idx) : null;
49
+ }
50
+ export function isTurnRpcUrl(url) {
51
+ return turnRpcFromUrl(url) !== null;
52
+ }
53
+ function urlFromParams(method, params) {
54
+ if (typeof params.requestUrl === 'string')
55
+ return params.requestUrl;
56
+ if (typeof params.url === 'string')
57
+ return params.url;
58
+ if (method === 'Network.requestWillBeSent') {
59
+ const req = asRec(params.request);
60
+ return typeof req.url === 'string' ? req.url : '';
61
+ }
62
+ if (method === 'Network.responseReceived') {
63
+ const resp = asRec(params.response);
64
+ return typeof resp.url === 'string' ? resp.url : '';
65
+ }
66
+ return '';
67
+ }
68
+ function isCriticalRpc(rpc) {
69
+ return rpc === 'Chat' || rpc === 'RenewTurn';
70
+ }
71
+ export function classifyEvent(method, rawParams, runStartTs) {
72
+ const params = asRec(rawParams);
73
+ const wallMs = eventWallMs(params);
74
+ if (wallMs !== null && wallMs < runStartTs)
75
+ return null;
76
+ const url = urlFromParams(method, params);
77
+ const rpc = turnRpcFromUrl(url);
78
+ if (method === 'Network.requestWillBeSent') {
79
+ if (rpc === 'Chat')
80
+ return { kind: 'chat-open', requestId: requestId(params) };
81
+ if (rpc === 'RenewTurn')
82
+ return { kind: 'heartbeat', requestId: requestId(params) };
83
+ if (rpc === 'ReleaseTurn')
84
+ return { kind: 'release', requestId: requestId(params) };
85
+ return null;
86
+ }
87
+ if (method === 'Network.dataReceived' && rpc === 'Chat') {
88
+ return { kind: 'chat-chunk', requestId: requestId(params) };
89
+ }
90
+ if (method === 'Network.responseReceived') {
91
+ const response = asRec(params.response);
92
+ const status = num(response.status);
93
+ if (status !== null && status >= 400 && isCriticalRpc(rpc)) {
94
+ return { kind: 'critical-error', rpc, status };
95
+ }
96
+ return null;
97
+ }
98
+ if (method === 'Network.loadingFailed' && isCriticalRpc(rpc)) {
99
+ return { kind: 'critical-error', rpc, status: 'failed' };
100
+ }
101
+ return null;
102
+ }
103
+ export class RunStateObserver extends CdpSession {
104
+ now;
105
+ currentState = 'idle';
106
+ runStartTs = 0;
107
+ lastActivity = 0;
108
+ priorRunSignals = 0;
109
+ terminalResult = null;
110
+ watchdog = null;
111
+ waiters = [];
112
+ requestUrls = new Map();
113
+ observedRpcPaths = new Set();
114
+ signalCounts = {
115
+ chatOpen: 0,
116
+ chatChunk: 0,
117
+ heartbeat: 0,
118
+ release: 0,
119
+ criticalError: 0,
120
+ observerLost: 0
121
+ };
122
+ closeCount = 0;
123
+ constructor(ws, target, opts = {}) {
124
+ super(ws, target, opts);
125
+ this.now = opts.now ?? (() => Date.now());
126
+ }
127
+ static async attach(opts = {}) {
128
+ if (typeof WebSocket === 'undefined')
129
+ return null;
130
+ try {
131
+ const { ws, target } = await RunStateObserver.connectTarget(opts);
132
+ const observer = new RunStateObserver(ws, target, opts);
133
+ await observer.start();
134
+ return observer;
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ }
140
+ async start() {
141
+ await this.enableDomains();
142
+ }
143
+ beginRun() {
144
+ if (this.terminalResult)
145
+ return;
146
+ this.runStartTs = this.now();
147
+ this.lastActivity = this.runStartTs;
148
+ this.priorRunSignals = 0;
149
+ this.requestUrls.clear();
150
+ this.observedRpcPaths.clear();
151
+ this.resetSignalCounts();
152
+ this.currentState = 'running';
153
+ }
154
+ get state() {
155
+ if (this.terminalResult)
156
+ return this.terminalResult.terminal;
157
+ return this.currentState;
158
+ }
159
+ awaitTerminal({ stallMs = 25_000, hardTimeoutMs = 20 * 60_000 } = {}) {
160
+ if (this.terminalResult)
161
+ return Promise.resolve(this.terminalResult);
162
+ this.armWatchdog(stallMs, hardTimeoutMs);
163
+ this.checkSilence(stallMs, hardTimeoutMs);
164
+ if (this.terminalResult)
165
+ return Promise.resolve(this.terminalResult);
166
+ return new Promise((resolve) => {
167
+ this.waiters.push(resolve);
168
+ });
169
+ }
170
+ close() {
171
+ this.clearWatchdog();
172
+ if (this.closeSocket())
173
+ this.closeCount++;
174
+ }
175
+ signalSummary() {
176
+ return {
177
+ ...this.signalCounts,
178
+ observedRpcPaths: [...this.observedRpcPaths].sort()
179
+ };
180
+ }
181
+ closeCountForTest() {
182
+ return this.closeCount;
183
+ }
184
+ consumeSignalForTest(signal) {
185
+ this.consumeSignal(signal);
186
+ }
187
+ socketGapForTest(detail = { reason: 'test' }) {
188
+ this.onSocketGap(detail);
189
+ }
190
+ tickForTest({ stallMs = 25_000, hardTimeoutMs = 20 * 60_000 } = {}) {
191
+ this.checkSilence(stallMs, hardTimeoutMs);
192
+ }
193
+ onEvent(method, rawParams, _sessionId) {
194
+ if (this.currentState === 'idle' && !this.terminalResult)
195
+ return;
196
+ const params = this.enrichParams(method, rawParams);
197
+ if (method === 'Network.responseReceived' || method === 'Network.loadingFailed') {
198
+ const id = requestId(params);
199
+ if (!id || !this.requestUrls.has(id))
200
+ return;
201
+ }
202
+ const signal = classifyEvent(method, params, this.runStartTs);
203
+ if (signal)
204
+ this.consumeSignal(signal);
205
+ }
206
+ onSocketGap(_detail) {
207
+ if (this.currentState === 'running' || this.currentState === 'stalled') {
208
+ this.consumeSignal({ kind: 'observer-lost' });
209
+ }
210
+ }
211
+ onSocketReconnectFailed() {
212
+ this.consumeSignal({ kind: 'observer-lost' });
213
+ }
214
+ enrichParams(method, rawParams) {
215
+ const params = { ...asRec(rawParams) };
216
+ const id = requestId(params);
217
+ if (method === 'Network.requestWillBeSent' && id) {
218
+ const wallMs = eventWallMs(params);
219
+ if (wallMs !== null && wallMs < this.runStartTs)
220
+ return params;
221
+ const req = asRec(params.request);
222
+ const url = typeof req.url === 'string' ? req.url : '';
223
+ if (url) {
224
+ this.requestUrls.set(id, url);
225
+ const rpcPath = observedRpcPathFromUrl(url);
226
+ if (rpcPath)
227
+ this.observedRpcPaths.add(rpcPath);
228
+ }
229
+ }
230
+ else if (id && !params.requestUrl) {
231
+ const url = this.requestUrls.get(id);
232
+ if (url)
233
+ params.requestUrl = url;
234
+ }
235
+ return params;
236
+ }
237
+ consumeSignal(signal) {
238
+ if (this.terminalResult)
239
+ return;
240
+ this.countSignal(signal);
241
+ if (signal.kind === 'observer-lost') {
242
+ this.latch('observer-lost');
243
+ return;
244
+ }
245
+ if (this.currentState === 'idle')
246
+ return;
247
+ if (signal.kind === 'chat-open' || signal.kind === 'chat-chunk' || signal.kind === 'heartbeat') {
248
+ this.lastActivity = this.now();
249
+ this.priorRunSignals++;
250
+ this.currentState = 'running';
251
+ return;
252
+ }
253
+ if (signal.kind === 'release') {
254
+ if (this.priorRunSignals === 0)
255
+ return;
256
+ this.latch('finished');
257
+ return;
258
+ }
259
+ if (signal.kind === 'critical-error') {
260
+ this.latch('blocked', `${signal.rpc} ${signal.status === 'failed' ? 'failed' : `HTTP ${signal.status}`}`);
261
+ }
262
+ }
263
+ countSignal(signal) {
264
+ if (signal.kind === 'chat-open')
265
+ this.signalCounts.chatOpen++;
266
+ else if (signal.kind === 'chat-chunk')
267
+ this.signalCounts.chatChunk++;
268
+ else if (signal.kind === 'heartbeat')
269
+ this.signalCounts.heartbeat++;
270
+ else if (signal.kind === 'release')
271
+ this.signalCounts.release++;
272
+ else if (signal.kind === 'critical-error')
273
+ this.signalCounts.criticalError++;
274
+ else if (signal.kind === 'observer-lost')
275
+ this.signalCounts.observerLost++;
276
+ }
277
+ resetSignalCounts() {
278
+ this.signalCounts.chatOpen = 0;
279
+ this.signalCounts.chatChunk = 0;
280
+ this.signalCounts.heartbeat = 0;
281
+ this.signalCounts.release = 0;
282
+ this.signalCounts.criticalError = 0;
283
+ this.signalCounts.observerLost = 0;
284
+ }
285
+ checkSilence(stallMs, hardTimeoutMs) {
286
+ if (this.terminalResult || this.currentState === 'idle')
287
+ return;
288
+ const now = this.now();
289
+ const silence = now - this.lastActivity;
290
+ const elapsed = now - this.runStartTs;
291
+ if (silence > hardTimeoutMs) {
292
+ this.latch('timeout', `silent for ${silence}ms`);
293
+ }
294
+ else if (elapsed > hardTimeoutMs) {
295
+ this.latch('timeout', `exceeded ${hardTimeoutMs}ms wall-clock budget (elapsed ${elapsed}ms)`);
296
+ }
297
+ else if (silence > stallMs) {
298
+ this.currentState = 'stalled';
299
+ }
300
+ }
301
+ armWatchdog(stallMs, hardTimeoutMs) {
302
+ if (this.watchdog)
303
+ return;
304
+ const intervalMs = Math.max(250, Math.min(1000, stallMs, hardTimeoutMs));
305
+ this.watchdog = setInterval(() => this.checkSilence(stallMs, hardTimeoutMs), intervalMs);
306
+ }
307
+ clearWatchdog() {
308
+ if (!this.watchdog)
309
+ return;
310
+ clearInterval(this.watchdog);
311
+ this.watchdog = null;
312
+ }
313
+ latch(terminal, reason) {
314
+ if (this.terminalResult)
315
+ return;
316
+ this.terminalResult = {
317
+ terminal,
318
+ elapsedMs: this.runStartTs === 0 ? 0 : Math.max(0, this.now() - this.runStartTs),
319
+ ...(reason ? { reason } : {})
320
+ };
321
+ this.clearWatchdog();
322
+ this.close();
323
+ const waiters = this.waiters.splice(0);
324
+ for (const resolve of waiters)
325
+ resolve(this.terminalResult);
326
+ }
327
+ }
@@ -217,6 +217,7 @@ async function main() {
217
217
  sessionNav = { target: probeUrl, landedOn: '', error: e.message };
218
218
  console.log(`[ci-health] canary navigation failed — ${e.message}; session anchors will fail loudly`);
219
219
  }
220
+ process.env.DESIGNER_TURN_RPC_CANARY ??= '1';
220
221
  sessionResults = await runHealth(browser, { phase: 'session' });
221
222
  }
222
223
  else {