@selvakumaresra/specship 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +11 -1
  2. package/commands/ss-brainstorm.md +68 -0
  3. package/commands/ss-design-implement.md +5 -0
  4. package/commands/ss-design-loop.md +125 -0
  5. package/dist/analytics/specship-impact.d.ts +72 -0
  6. package/dist/analytics/specship-impact.d.ts.map +1 -0
  7. package/dist/analytics/specship-impact.js +216 -0
  8. package/dist/analytics/specship-impact.js.map +1 -0
  9. package/dist/bin/specship.js +70 -4
  10. package/dist/bin/specship.js.map +1 -1
  11. package/dist/db/migrations.d.ts +1 -1
  12. package/dist/db/migrations.d.ts.map +1 -1
  13. package/dist/db/migrations.js +15 -1
  14. package/dist/db/migrations.js.map +1 -1
  15. package/dist/db/schema.sql +8 -0
  16. package/dist/designer/artifact-store.js +54 -0
  17. package/dist/designer/browser.js +141 -0
  18. package/dist/designer/cdp-ensure.js +60 -0
  19. package/dist/designer/cdp-env.js +18 -0
  20. package/dist/designer/cdp-trace.js +599 -0
  21. package/dist/designer/cross-platform.js +74 -0
  22. package/dist/designer/designer-controller.js +1413 -0
  23. package/dist/designer/file-panel.js +39 -0
  24. package/dist/designer/interstitials.js +97 -0
  25. package/dist/designer/oopif-reader.js +176 -0
  26. package/dist/designer/package-meta.js +18 -0
  27. package/dist/designer/preview-host.js +50 -0
  28. package/dist/designer/repo-root.js +31 -0
  29. package/dist/designer/run-state.js +353 -0
  30. package/dist/designer/session-store.js +59 -0
  31. package/dist/designer/ui-anchors.js +651 -0
  32. package/dist/index.d.ts +27 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +48 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/installer/index.d.ts +7 -2
  37. package/dist/installer/index.d.ts.map +1 -1
  38. package/dist/installer/index.js +3 -2
  39. package/dist/installer/index.js.map +1 -1
  40. package/dist/installer/instructions-template.d.ts +17 -0
  41. package/dist/installer/instructions-template.d.ts.map +1 -1
  42. package/dist/installer/instructions-template.js +31 -1
  43. package/dist/installer/instructions-template.js.map +1 -1
  44. package/dist/installer/targets/claude.d.ts +19 -0
  45. package/dist/installer/targets/claude.d.ts.map +1 -1
  46. package/dist/installer/targets/claude.js +100 -1
  47. package/dist/installer/targets/claude.js.map +1 -1
  48. package/dist/installer/targets/shared.d.ts +14 -0
  49. package/dist/installer/targets/shared.d.ts.map +1 -1
  50. package/dist/installer/targets/shared.js +49 -0
  51. package/dist/installer/targets/shared.js.map +1 -1
  52. package/dist/installer/targets/types.d.ts +8 -0
  53. package/dist/installer/targets/types.d.ts.map +1 -1
  54. package/dist/mcp/designer-tools.d.ts +33 -0
  55. package/dist/mcp/designer-tools.d.ts.map +1 -0
  56. package/dist/mcp/designer-tools.js +313 -0
  57. package/dist/mcp/designer-tools.js.map +1 -0
  58. package/dist/mcp/tools.d.ts.map +1 -1
  59. package/dist/mcp/tools.js +22 -1
  60. package/dist/mcp/tools.js.map +1 -1
  61. package/dist/server/ingest/impact-backfill.js +69 -0
  62. package/dist/server/ingest/impact-query.js +343 -0
  63. package/dist/server/ingest/index.js +2 -1
  64. package/dist/server/ingest/ingestor.js +41 -6
  65. package/dist/server/ingest/specship-classify.js +153 -0
  66. package/dist/server/routes/claude.js +32 -0
  67. package/dist/server/routes/spec.js +94 -0
  68. package/dist/server/server.js +26 -2
  69. package/dist/web/{chunk-JN6W7HCN.js → chunk-45QHGCB4.js} +1 -1
  70. package/dist/web/{chunk-RAAMPHPJ.js → chunk-A5R3MJMO.js} +1 -1
  71. package/dist/web/{chunk-2DHIGIOI.js → chunk-ASZ77FMZ.js} +1 -1
  72. package/dist/web/chunk-D5OCNEJA.js +2 -0
  73. package/dist/web/{chunk-3SEJX2BK.js → chunk-FHZHD2ZG.js} +1 -1
  74. package/dist/web/chunk-GR72OOCN.js +1 -0
  75. package/dist/web/{chunk-YAWCRPHV.js → chunk-NZEZCT65.js} +1 -1
  76. package/dist/web/chunk-O7434ZMN.js +1 -0
  77. package/dist/web/chunk-ODX6CT3I.js +6 -0
  78. package/dist/web/chunk-RASJHUXS.js +1 -0
  79. package/dist/web/chunk-TQ3P2QZO.js +1 -0
  80. package/dist/web/{chunk-BCZM5AXU.js → chunk-UBOZGQNK.js} +1 -1
  81. package/dist/web/chunk-WCHGDXWC.js +1 -0
  82. package/dist/web/{chunk-BPECIDVO.js → chunk-WCKHQIYN.js} +1 -1
  83. package/dist/web/{chunk-JFYVCXK3.js → chunk-WLIMNDS3.js} +1 -1
  84. package/dist/web/{chunk-LV4G6QFG.js → chunk-YAMRN47K.js} +1 -1
  85. package/dist/web/index.html +1 -1
  86. package/dist/web/main-X2KCYXZ4.js +1 -0
  87. package/dist/web/sw.js +69 -0
  88. package/dist/workflows/defaults/claude-design-implement.yaml +138 -49
  89. package/hooks/hooks.json +11 -0
  90. package/package.json +7 -3
  91. package/selectors.json +41 -0
  92. package/dist/web/chunk-2OKMB4KX.js +0 -2
  93. package/dist/web/chunk-4N5DWG46.js +0 -1
  94. package/dist/web/chunk-DA6SNNAF.js +0 -1
  95. package/dist/web/chunk-JT7P3DEK.js +0 -6
  96. package/dist/web/chunk-TWXZK6XM.js +0 -1
  97. package/dist/web/main-WVI3YTDU.js +0 -1
@@ -0,0 +1,1413 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DesignerController = exports.SESSION_URL_RE = void 0;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const node_os_1 = __importDefault(require("node:os"));
10
+ const node_crypto_1 = __importDefault(require("node:crypto"));
11
+ const browser_1 = require("./browser");
12
+ const artifact_store_1 = require("./artifact-store");
13
+ const session_store_1 = require("./session-store");
14
+ const repo_root_1 = require("./repo-root");
15
+ const cdp_ensure_1 = require("./cdp-ensure");
16
+ const run_state_1 = require("./run-state");
17
+ const oopif_reader_1 = require("./oopif-reader");
18
+ const preview_host_1 = require("./preview-host");
19
+ const cdp_env_1 = require("./cdp-env");
20
+ const interstitials_1 = require("./interstitials");
21
+ const file_panel_1 = require("./file-panel");
22
+ const fflate_1 = require("fflate");
23
+ const DESIGN_HOME = 'https://claude.ai/design';
24
+ // A claude.ai/design session URL: /design/p/<uuid>. Capture group 1 is the
25
+ // project id. Used by isInSession()-style checks and `adopt` (binding an
26
+ // already-open project tab to a key, bypassing the create-flow home).
27
+ exports.SESSION_URL_RE = /^https:\/\/claude\.ai\/design\/p\/([a-f0-9-]+)/i;
28
+ // Appended to every designer_prompt payload. The live MCP surface
29
+ // (listFiles / openFile / newFiles diff) scrapes a flat root from the
30
+ // file panel; files nested under folders stay invisible until handoff.
31
+ // Enforcing flat layout here keeps the live flow honest. Users who genuinely
32
+ // want nested layouts should explicitly contradict this in their prompt and
33
+ // rely on `designer_handoff` for authoritative file access.
34
+ const FLAT_LAYOUT_SUFFIX = '\n\nFile layout: keep all generated files at the project root. No subfolders.';
35
+ 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.';
36
+ function loadSelectors() {
37
+ const base = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(repo_root_1.REPO_ROOT, 'selectors.json'), 'utf8'));
38
+ const overridePath = node_path_1.default.join(node_os_1.default.homedir(), '.designer', 'selectors.override.json');
39
+ if (node_fs_1.default.existsSync(overridePath)) {
40
+ try {
41
+ return deepMerge(base, JSON.parse(node_fs_1.default.readFileSync(overridePath, 'utf8')));
42
+ }
43
+ catch (e) {
44
+ console.warn(`[designer] failed to parse ${overridePath}: ${e.message}`);
45
+ }
46
+ }
47
+ return base;
48
+ }
49
+ function deepMerge(a, b) {
50
+ if (Array.isArray(a) || Array.isArray(b))
51
+ return b ?? a;
52
+ if (typeof a !== 'object' || typeof b !== 'object' || !a || !b)
53
+ return b ?? a;
54
+ const out = { ...a };
55
+ for (const k of Object.keys(b))
56
+ out[k] = deepMerge(a[k], b[k]);
57
+ return out;
58
+ }
59
+ class DesignerController {
60
+ key;
61
+ selectors;
62
+ browser;
63
+ _preSendHtml = '';
64
+ constructor({ key, headed = true } = {}) {
65
+ this.key = key || 'default';
66
+ this.selectors = loadSelectors();
67
+ this.browser = (0, browser_1.createBrowser)({ session: `designer-${this.key}`, headed });
68
+ }
69
+ async currentUrl() {
70
+ return (await this.browser.url().catch(() => '')) || '';
71
+ }
72
+ async isOnHome() {
73
+ const u = await this.currentUrl();
74
+ return /\/design\/?$/.test(u) || u.endsWith('/design');
75
+ }
76
+ async isInSession() {
77
+ const u = await this.currentUrl();
78
+ return /\/design\/p\/[a-f0-9-]+/i.test(u);
79
+ }
80
+ async getStatus() {
81
+ const stored = (0, session_store_1.getSession)(this.key);
82
+ const url = await this.currentUrl();
83
+ const inSession = /\/design\/p\/[a-f0-9-]+/i.test(url);
84
+ const availableFiles = inSession ? await this.listFiles().catch(() => []) : [];
85
+ const awaitingClarification = inSession ? await this.detectAwaitingClarification() : false;
86
+ return {
87
+ key: this.key,
88
+ stored,
89
+ currentUrl: url,
90
+ inSession,
91
+ onHome: /\/design\/?$/.test(url) || url.endsWith('/design'),
92
+ availableFiles,
93
+ awaitingClarification
94
+ };
95
+ }
96
+ // Heuristic only. The questions popover is ephemeral, but the teaser text stays
97
+ // in the chat turn body. If the most recent turn is from Claude and contains the
98
+ // teaser, treat the session as blocked on a clarification. Returns false if the
99
+ // page is mid-stream (we'd race the textnode walk against React's commit phase).
100
+ async detectAwaitingClarification() {
101
+ const turns = await this.getChatTurns().catch(() => []);
102
+ if (turns.length === 0)
103
+ return false;
104
+ const last = turns[turns.length - 1];
105
+ if (!last || last.role !== 'assistant')
106
+ return false;
107
+ return /Claude has some questions/i.test(last.text);
108
+ }
109
+ async session({ action = 'status', name, fidelity = 'wireframe' } = {}) {
110
+ if (action === 'status')
111
+ return this.getStatus();
112
+ if (action === 'ensure_ready') {
113
+ const r = await this.ensureReady();
114
+ return { ...r, status: await this.getStatus() };
115
+ }
116
+ if (action === 'clear') {
117
+ // clearInterstitials acts on the currently-bound CDP tab; in a multi-key
118
+ // workflow that may be a DIFFERENT project. Select THIS key's stored tab
119
+ // first (scoped, activate-only — no navigation, so it won't hijack another
120
+ // key's tab) so the clear targets the requested session (PR #77 Codex P2).
121
+ if ((0, cdp_env_1.isCdpEnabled)())
122
+ await (0, cdp_ensure_1.ensureCdpUp)();
123
+ const picked = await this.selectMatchingTab().catch(() => ({ matched: false, candidates: 0 }));
124
+ // candidates===0 means NO tab matches this key — selectMatchingTab didn't
125
+ // activate anything, so the browser is still bound to whatever was active.
126
+ // Refuse rather than clear (click/reload) an unrelated key's tab (PR #77
127
+ // Codex P2). candidates>0 means this key's tab is bound (matched, or
128
+ // present-but-masked by the very interstitial we're here to clear) → proceed.
129
+ if (picked.candidates === 0) {
130
+ const report = { ok: true, handled: [], blocked: null };
131
+ return { ...report, matched: false, note: 'no live tab matches this key — nothing to clear', status: await this.getStatus() };
132
+ }
133
+ const r = await this.clearInterstitials();
134
+ return { ...r, status: await this.getStatus() };
135
+ }
136
+ if (action === 'resume') {
137
+ const stored = (0, session_store_1.getSession)(this.key);
138
+ if (!stored?.designUrl)
139
+ throw new Error(`No stored session for key=${this.key}. Use action='create' with a name.`);
140
+ const r = await this.resumeSession();
141
+ return { ...r, status: await this.getStatus() };
142
+ }
143
+ if (action === 'create') {
144
+ if (!name)
145
+ throw new Error("action='create' requires a name.");
146
+ const r = await this.createSession(name, fidelity);
147
+ return { ...r, status: await this.getStatus() };
148
+ }
149
+ if (action === 'adopt') {
150
+ const r = await this.adoptSession(name);
151
+ return { ...r, status: await this.getStatus() };
152
+ }
153
+ throw new Error(`Unknown action: ${action}`);
154
+ }
155
+ // Bind a project you opened by hand (a live /design/p/<uuid> tab) to this
156
+ // key — the supported path around the redesigned creation-cards home, whose
157
+ // anchors drift wholesale (issue #61). `name` is optional metadata only.
158
+ //
159
+ // Safety (PR #66 review): adopt must never silently bind the WRONG project.
160
+ // With more than one /design/p/<uuid> tab open (normal during parallel --key
161
+ // work), there's no key↔tab correlation to pick the right one, so refuse and
162
+ // list them rather than guess by active-first. We also bind from the VALIDATED
163
+ // candidate URL, not a currentUrl() re-read after activateTab (which could race
164
+ // to a different tab).
165
+ async adoptSession(name) {
166
+ await (0, cdp_ensure_1.ensureCdpUp)();
167
+ const candidates = await this.candidateTabs((u) => exports.SESSION_URL_RE.test(u));
168
+ if (candidates.length > 1) {
169
+ const list = candidates.map((t) => ` - ${t.url}`).join('\n');
170
+ throw new Error(`adopt can't choose among ${candidates.length} open /design/p/<uuid> tabs:\n${list}\n` +
171
+ `Leave only the target project open (close the others), then retry — adopt won't guess which one this key (${this.key}) means.`);
172
+ }
173
+ // Use the validated candidate URL; fall back to the already-bound tab when no
174
+ // dedicated session tab is open (agent-browser may already be on a /p/ URL).
175
+ const top = candidates[0];
176
+ const url = top?.url || (await this.currentUrl());
177
+ const m = url.match(exports.SESSION_URL_RE);
178
+ if (!m) {
179
+ throw new Error(`No /design/p/<uuid> tab to adopt — open a project by hand in the CDP-attached Chrome first. current url=${url || 'none'}`);
180
+ }
181
+ // Bind agent-browser to the adopted tab for subsequent prompt/handoff. If
182
+ // activation races or fails, the stored designUrl (from the validated URL
183
+ // above) is still correct — ensureReady re-binds by it later.
184
+ if (top)
185
+ await this.browser.activateTab(top.index).catch(() => null);
186
+ const designUrl = url.split('?')[0] || url;
187
+ const uuid = m[1] ?? '';
188
+ (0, session_store_1.upsertSession)(this.key, { designUrl, lastUrl: url, ...(name ? { name } : {}) });
189
+ (0, session_store_1.appendHistory)(this.key, { kind: 'session_adopt', url: designUrl, ...(name ? { name } : {}) });
190
+ return { ok: true, url: designUrl, uuid, adopted: true, ...(name ? { name } : {}) };
191
+ }
192
+ // Page tabs whose URL satisfies `match`, ordered active-first then by index
193
+ // ascending — the candidate ordering both adoptSession and selectMatchingTab
194
+ // rely on. Degrades to [] if the CDP tabs() call fails.
195
+ async candidateTabs(match) {
196
+ const tabs = await this.browser.tabs().catch(() => []);
197
+ return tabs
198
+ .filter((t) => t.type === 'page' && t.url && match(t.url))
199
+ .sort((a, b) => Number(b.active) - Number(a.active) || a.index - b.index);
200
+ }
201
+ // Pick the live claude.ai/design tab among possibly many CDP pages, switch
202
+ // agent-browser's binding to it, and verify readiness via DOM anchors.
203
+ // Returns the count of candidates considered (for error messaging).
204
+ async selectMatchingTab() {
205
+ const stored = (0, session_store_1.getSession)(this.key);
206
+ const targetRoot = stored?.designUrl?.split('?')[0];
207
+ const candidates = await this.candidateTabs((u) => targetRoot ? u.startsWith(targetRoot) : /^https:\/\/claude\.ai\/design(\/|$|\?)/.test(u));
208
+ if (candidates.length === 0)
209
+ return { matched: false, candidates: 0 };
210
+ for (const cand of candidates) {
211
+ await this.browser.activateTab(cand.index).catch(() => null);
212
+ const composerOk = await this.browser.isVisible(this.selectors.composer.promptTextarea).catch(() => false);
213
+ const homeOk = this.selectors.login.signedInIndicator
214
+ ? await this.browser.isVisible(this.selectors.login.signedInIndicator).catch(() => false)
215
+ : false;
216
+ if (composerOk || homeOk) {
217
+ (0, session_store_1.upsertSession)(this.key, { lastUrl: await this.currentUrl() });
218
+ return { matched: true, candidates: candidates.length };
219
+ }
220
+ }
221
+ return { matched: false, candidates: candidates.length };
222
+ }
223
+ // --- interstitial pre-flight (see interstitials.ts) -----------------------
224
+ // claude.ai/design interrupts the flow with content-only overlays that carry
225
+ // no data-testid: the 495k-token "Continue here" banner, a transient "Something
226
+ // went wrong" page, and the Cloudflare bot-check. Verbs run clearInterstitials()
227
+ // through ensureReady so these don't silently stall automation or get misread
228
+ // as a finished / context-ceilinged generation.
229
+ // Read the page content the classifier needs in a single eval, via the shared
230
+ // INTERSTITIAL_PROBE_EXPR (same shape as the CI diagnostic). Returns null when
231
+ // the read FAILS — distinct from a successfully-read clear page — so callers
232
+ // never mistake "couldn't read" for "no interstitial" (review #5a).
233
+ async _probeInterstitial() {
234
+ return this.browser.evalValue(interstitials_1.INTERSTITIAL_PROBE_EXPR).catch(() => null);
235
+ }
236
+ // The configured token-banner button text, threaded into every classify call so
237
+ // detection and the click stay on one source of truth (review #3b).
238
+ get _classifyOpts() {
239
+ return { continueHere: this.selectors.interstitials?.continueHere };
240
+ }
241
+ // Classify the page now. Returns null on an unreadable page (probe failure) OR
242
+ // a clear page — callers that must distinguish the two re-probe explicitly.
243
+ async _classifyNow() {
244
+ const probe = await this._probeInterstitial();
245
+ return probe ? (0, interstitials_1.classifyInterstitial)(probe, this._classifyOpts) : null;
246
+ }
247
+ // Detect and clear interstitials on the currently-bound tab. Loops because
248
+ // clearing one can reveal another (a reload can land back on the token banner),
249
+ // and each action re-probes to CONFIRM before counting it handled. Blocking
250
+ // kinds (cloudflare, transient-error) that survive are reported `blocked`; the
251
+ // token banner is non-blocking (the shell stays usable) so it never blocks a
252
+ // verb even if its button can't be clicked (review #3 / #6). In CDP mode,
253
+ // ensureCdpUp first so the standalone `designer clear` fails loud on a dead
254
+ // Chrome instead of a false recovery (review #5b) — but GATE it on isCdpEnabled
255
+ // so the documented DESIGNER_CDP='' opt-out (where ensureCdpUp throws by design)
256
+ // still works: the probe/click/reload run over agent-browser, not CDP, so the
257
+ // clear itself needs no CDP. Without this gate, the createSession pre-flight
258
+ // would break `create` in the opt-out flow (PR #77 Codex P2).
259
+ async clearInterstitials({ maxPasses = 4, cloudflareWaitMs = 25_000, pollMs = 1500 } = {}) {
260
+ if ((0, cdp_env_1.isCdpEnabled)())
261
+ await (0, cdp_ensure_1.ensureCdpUp)();
262
+ const handled = [];
263
+ for (let pass = 0; pass < maxPasses; pass++) {
264
+ const kind = await this._classifyNow();
265
+ if (!kind)
266
+ return { ok: true, handled, blocked: null };
267
+ const action = (0, interstitials_1.plannedAction)(kind);
268
+ if (action === 'click-continue') {
269
+ // Benign banner: try to dismiss, but never block the verb on it.
270
+ const text = this.selectors.interstitials?.continueHere || interstitials_1.CONTINUE_HERE_TEXT;
271
+ const clicked = await this._clickButtonByText(new RegExp(`^${escapeRegExp(text)}$`, 'i')).catch(() => false);
272
+ if (clicked) {
273
+ await new Promise((r) => setTimeout(r, 600));
274
+ if ((await this._classifyNow()) !== kind) {
275
+ handled.push(kind);
276
+ (0, session_store_1.appendHistory)(this.key, { kind: 'interstitial', interstitial: kind, action });
277
+ continue; // cleared — loop to catch any newly-revealed interstitial
278
+ }
279
+ }
280
+ (0, session_store_1.appendHistory)(this.key, {
281
+ kind: 'interstitial',
282
+ interstitial: kind,
283
+ action: clicked ? 'uncleared-nonblocking' : 'continue-button-missing'
284
+ });
285
+ return { ok: true, handled, blocked: null };
286
+ }
287
+ if (action === 'reload') {
288
+ const u = await this.currentUrl();
289
+ if (!u)
290
+ break; // can't reload an unknown URL (review #4) — fall to residual
291
+ (0, session_store_1.appendHistory)(this.key, { kind: 'interstitial', interstitial: kind, action });
292
+ await this.browser.open(u).catch(() => null);
293
+ // 'load', not 'networkidle' — the SPA's persistent connections never go
294
+ // idle, so networkidle would burn the full timeout each pass (review #4).
295
+ await this.browser.waitLoad('load').catch(() => null);
296
+ await new Promise((r) => setTimeout(r, 800));
297
+ continue; // confirm on the next pass's probe
298
+ }
299
+ if (action === 'await-human') {
300
+ // Cloudflare can't be solved programmatically; wait for it to self-clear
301
+ // before declaring it blocked — it frequently resolves on its own.
302
+ const cleared = await this._waitForInterstitialClear(kind, cloudflareWaitMs, pollMs);
303
+ (0, session_store_1.appendHistory)(this.key, { kind: 'interstitial', interstitial: kind, action: cleared ? 'cleared-after-wait' : 'blocked' });
304
+ if (cleared) {
305
+ handled.push(kind);
306
+ continue;
307
+ }
308
+ return { ok: false, handled, blocked: kind };
309
+ }
310
+ // Exhaustiveness: a new InterstitialAction must be handled above, not fall
311
+ // silently into one of the branches (review below-gate).
312
+ const _exhaustive = action;
313
+ return _exhaustive;
314
+ }
315
+ // maxPasses exhausted (e.g. a transient error that survived every reload).
316
+ const residual = await this._classifyNow();
317
+ if (residual && (0, interstitials_1.isBlockingInterstitial)(residual))
318
+ return { ok: false, handled, blocked: residual };
319
+ return { ok: true, handled, blocked: null };
320
+ }
321
+ async _waitForInterstitialClear(kind, timeoutMs, pollMs) {
322
+ const start = Date.now();
323
+ while (Date.now() - start < timeoutMs) {
324
+ await new Promise((r) => setTimeout(r, pollMs));
325
+ const probe = await this._probeInterstitial();
326
+ // A FAILED read (null probe) is NOT "cleared" — keep waiting (review #5a).
327
+ // Only a successful read that classifies as a different kind (or clear)
328
+ // means the challenge is gone; the outer loop handles whatever's now on top.
329
+ if (probe && (0, interstitials_1.classifyInterstitial)(probe, this._classifyOpts) !== kind) {
330
+ return true;
331
+ }
332
+ }
333
+ return false;
334
+ }
335
+ _interstitialError(kind, candidates) {
336
+ const suffix = candidates > 0 ? ` (checked ${candidates} tab(s))` : '';
337
+ if (kind === 'cloudflare') {
338
+ return new Error(`Cloudflare bot-check is up on claude.ai/design and didn't clear${suffix}. ` +
339
+ `Solve it in the CDP-attached Chrome, then retry.`);
340
+ }
341
+ return new Error(`Unresolved interstitial '${kind}' on claude.ai/design${suffix}.`);
342
+ }
343
+ async ensureReady() {
344
+ await (0, cdp_ensure_1.ensureCdpUp)();
345
+ const picked = await this.selectMatchingTab();
346
+ if (picked.matched) {
347
+ // The token banner leaves the composer visible, so a tab can match with an
348
+ // interstitial still up — clear it before any verb runs against the page.
349
+ const interstitials = await this.clearInterstitials();
350
+ if (interstitials.blocked)
351
+ throw this._interstitialError(interstitials.blocked, picked.candidates);
352
+ return { ok: true, url: await this.currentUrl(), inSession: await this.isInSession(), interstitials };
353
+ }
354
+ // selectMatchingTab matches on a visible composer/home anchor — but a
355
+ // transient-error or Cloudflare overlay HIDES those anchors, so a real design
356
+ // tab can be masked. Before falling back to opening home, activate the best
357
+ // design tab and try clearing; a successful clear re-exposes the anchors.
358
+ //
359
+ // SCOPE to the stored project (mirror selectMatchingTab): an unscoped /design
360
+ // filter would activate the lowest-index design tab — potentially an UNRELATED
361
+ // project — and a later clear/fall-through could silently bind this key to it
362
+ // (review #2, cross-project contamination). Only widen to any /design tab when
363
+ // this key has no stored project to be wrong about.
364
+ const recoveryRoot = (0, session_store_1.getSession)(this.key)?.designUrl?.split('?')[0];
365
+ const designTabs = await this.candidateTabs((u) => recoveryRoot ? u.startsWith(recoveryRoot) : /^https:\/\/claude\.ai\/design(\/|$|\?)/.test(u));
366
+ const recoveryTab = designTabs[0];
367
+ if (recoveryTab) {
368
+ await this.browser.activateTab(recoveryTab.index).catch(() => null);
369
+ const report = await this.clearInterstitials();
370
+ if (report.blocked)
371
+ throw this._interstitialError(report.blocked, designTabs.length);
372
+ if (report.handled.length > 0) {
373
+ const retry = await this.selectMatchingTab();
374
+ if (retry.matched) {
375
+ return { ok: true, url: await this.currentUrl(), inSession: await this.isInSession(), interstitials: report };
376
+ }
377
+ }
378
+ }
379
+ // No live design tab matched. Fall back to opening home and re-checking.
380
+ if (picked.candidates === 0) {
381
+ const u = await this.currentUrl();
382
+ if (!/claude\.ai\/design/.test(u)) {
383
+ await this.browser.open(DESIGN_HOME);
384
+ await this.browser.waitLoad('networkidle').catch(() => null);
385
+ }
386
+ }
387
+ const interstitials = await this.clearInterstitials();
388
+ if (interstitials.blocked)
389
+ throw this._interstitialError(interstitials.blocked, picked.candidates);
390
+ const homeOk = this.selectors.login.signedInIndicator
391
+ ? await this.browser.isVisible(this.selectors.login.signedInIndicator).catch(() => false)
392
+ : false;
393
+ const sessionOk = await this.browser.isVisible(this.selectors.composer.promptTextarea).catch(() => false);
394
+ if (!homeOk && !sessionOk) {
395
+ const suffix = picked.candidates > 0 ? ` (checked ${picked.candidates} tab(s))` : '';
396
+ throw new Error(`Not signed in to claude.ai/design, or on an unrecognized page${suffix}. Sign in in the CDP-attached Chrome.`);
397
+ }
398
+ (0, session_store_1.upsertSession)(this.key, { lastUrl: await this.currentUrl() });
399
+ return { ok: true, url: await this.currentUrl(), inSession: await this.isInSession(), interstitials };
400
+ }
401
+ async createSession(name, fidelity = 'wireframe', { timeoutMs = 20 * 60_000, stabilityMs = 4000 } = {}) {
402
+ // 2026-06 redesign (#61): the home is composer-driven. There's no longer a
403
+ // project-name input or a wireframe/high-fi toggle — you seed an intent in
404
+ // the chat composer (`home.creator`, the same data-testid as the in-session
405
+ // composer) and click "Start project" (`home.createButton`, the same
406
+ // data-testid as the in-session send button). So `name` becomes the seed
407
+ // prompt. The redesign removed the wireframe/high-fi toggle, so `fidelity` is
408
+ // folded into the seed as a directive (and still stored) — otherwise highfi
409
+ // and wireframe creates would behave identically while the session claimed a
410
+ // fidelity that was never applied (#66 review). The creation-type cards
411
+ // (Slides / Prototype / Product wireframe / …) are text-only buttons left as a
412
+ // follow-up. Verified live against the redesigned home.
413
+ //
414
+ // `name` is the composer seed, so it must be non-empty — a whitespace-only
415
+ // name leaves the send button disabled and would otherwise spin the full
416
+ // navigation poll before failing with a misleading message.
417
+ if (!name?.trim())
418
+ throw new Error('create requires a non-empty name (used as the project seed prompt).');
419
+ await this.browser.open(DESIGN_HOME);
420
+ await this.browser.waitLoad('networkidle').catch(() => null);
421
+ // createSession opens home directly (not via ensureReady), so run the same
422
+ // interstitial pre-flight — a Cloudflare check or transient error on home
423
+ // would otherwise stall waitFor(creator) with a misleading timeout.
424
+ const interstitials = await this.clearInterstitials();
425
+ if (interstitials.blocked)
426
+ throw this._interstitialError(interstitials.blocked, 0);
427
+ await this.browser.waitFor(this.selectors.home.creator);
428
+ const fidelityHint = fidelity === 'highfi'
429
+ ? '\n\nBuild this as a high-fidelity, visually polished design.'
430
+ : '\n\nBuild this as a low-fidelity wireframe.';
431
+ // The seed IS the first generation now, so apply the same flat-layout contract
432
+ // sendPrompt() appends to every prompt — otherwise the create run can produce
433
+ // nested folders the flat live file-list/openFile scrape can't see (#66 review).
434
+ const seed = name + fidelityHint + FLAT_LAYOUT_SUFFIX;
435
+ this._preSendHtml = '';
436
+ // The composer-create kicks off a real generation. Return only once it has
437
+ // settled, using the same network-first completion signal as iterate(), so the
438
+ // documented next step (`designer prompt`) can't interleave with the create run
439
+ // (#66 review). Honors the DESIGNER_CDP='' opt-out: with no observer we can't
440
+ // wait reliably (the HTML waiter is degraded under the bootstrap iframe), so we
441
+ // navigate and proceed best-effort — the next prompt's send-enable wait resyncs.
442
+ //
443
+ // Bind the observer to THIS exact home tab (findDesignTarget exact-matches the
444
+ // URL). The home URL is a prefix of every /design/p/<uuid> tab, so a loose
445
+ // prefix could otherwise bind to a different project's tab in multi-tab/
446
+ // parallel-key workflows (#66). The tab keeps its CDP target across the SPA
447
+ // navigation to /p/, so the observer follows it.
448
+ const cdpEnabled = (0, cdp_env_1.isCdpEnabled)();
449
+ const homeUrl = await this.currentUrl();
450
+ let observer = cdpEnabled
451
+ ? await run_state_1.RunStateObserver.attach({ preferUrlPrefix: homeUrl })
452
+ : null;
453
+ try {
454
+ observer?.beginRun();
455
+ // Reuse the battle-tested composer fill+submit (contenteditable ProseMirror;
456
+ // waits for the send button to enable before clicking "Start project").
457
+ await this._submitPrompt(seed);
458
+ let inSession = false;
459
+ for (let i = 0; i < 60; i++) {
460
+ await new Promise((r) => setTimeout(r, 500));
461
+ if ((inSession = await this.isInSession()))
462
+ break;
463
+ }
464
+ if (!inSession)
465
+ throw new Error('Project creation did not navigate to a /p/ url in time.');
466
+ // Wait for the seed generation to finish. Tolerate observer-lost/timeout —
467
+ // the project exists either way; don't fail create over an imperfect wait.
468
+ if (observer)
469
+ await this._waitForGenerationDoneNetwork(observer, { timeoutMs, stabilityMs }).catch(() => null);
470
+ }
471
+ finally {
472
+ observer?.close();
473
+ observer = null;
474
+ }
475
+ const url = await this.currentUrl();
476
+ (0, session_store_1.upsertSession)(this.key, { designUrl: url, name, fidelity, lastUrl: url });
477
+ (0, session_store_1.appendHistory)(this.key, { kind: 'session_create', name, fidelity, url });
478
+ return { ok: true, url, name, fidelity };
479
+ }
480
+ async resumeSession() {
481
+ const stored = (0, session_store_1.getSession)(this.key);
482
+ if (!stored?.designUrl)
483
+ throw new Error(`No designUrl stored for key=${this.key}. Create one first.`);
484
+ await this.browser.open(stored.designUrl);
485
+ await this.browser.waitLoad('networkidle').catch(() => null);
486
+ return { ok: true, url: stored.designUrl };
487
+ }
488
+ async _submitPrompt(prompt) {
489
+ const { promptTextarea, sendButton } = this.selectors.composer;
490
+ await this.browser.waitFor(promptTextarea);
491
+ // The composer has shipped as both a React-controlled <textarea> and a
492
+ // ProseMirror contenteditable <div> — branch on what's actually there.
493
+ await this.browser.evalValue(`(() => {
494
+ const el = document.querySelector(${JSON.stringify(promptTextarea)});
495
+ if (!el) throw new Error('composer input not found');
496
+ const text = ${JSON.stringify(prompt)};
497
+ if (el instanceof HTMLTextAreaElement) {
498
+ // Bypass React's value ownership via the native setter, then fire a
499
+ // bubbling input event.
500
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
501
+ setter.call(el, text);
502
+ el.dispatchEvent(new Event('input', { bubbles: true }));
503
+ el.focus();
504
+ return true;
505
+ }
506
+ if (el.isContentEditable) {
507
+ // Deliver the text as a synthetic paste so the editor's own paste
508
+ // pipeline updates its internal state; execCommand('insertText')
509
+ // flattens multi-line prompts into one paragraph.
510
+ el.focus();
511
+ const sel = window.getSelection();
512
+ const range = document.createRange();
513
+ range.selectNodeContents(el);
514
+ sel.removeAllRanges();
515
+ sel.addRange(range);
516
+ const dt = new DataTransfer();
517
+ dt.setData('text/plain', text);
518
+ const unhandled = el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
519
+ if (unhandled) {
520
+ // No editor intercepted the paste — plain contenteditable fallback.
521
+ document.execCommand('insertText', false, text);
522
+ }
523
+ return true;
524
+ }
525
+ throw new Error('composer input is neither textarea nor contenteditable: ' + el.tagName);
526
+ })()`);
527
+ for (let i = 0; i < 30; i++) {
528
+ await new Promise((r) => setTimeout(r, 150));
529
+ const disabled = await this.browser.evalValue(`(() => { const b = document.querySelector(${JSON.stringify(sendButton)}); return !b || b.disabled || b.getAttribute('aria-disabled') === 'true'; })()`);
530
+ if (!disabled)
531
+ break;
532
+ }
533
+ await this.browser.evalValue(`(() => {
534
+ const b = document.querySelector(${JSON.stringify(sendButton)});
535
+ if (!b) throw new Error('send button not found');
536
+ b.click();
537
+ return true;
538
+ })()`);
539
+ }
540
+ async sendPrompt(prompt, { decisive = false, onBeforeSubmit } = {}) {
541
+ const before = await this.fetchServedHtml();
542
+ this._preSendHtml = before.html;
543
+ const effective = prompt + FLAT_LAYOUT_SUFFIX + (decisive ? DECISIVE_SUFFIX : '');
544
+ onBeforeSubmit?.();
545
+ await this._submitPrompt(effective);
546
+ const suffixApplied = decisive ? 'flat_layout+decisive' : 'flat_layout';
547
+ (0, session_store_1.appendHistory)(this.key, { kind: 'prompt', prompt, suffixApplied });
548
+ return { ok: true };
549
+ }
550
+ async waitForGenerationDone({ timeoutMs = 20 * 60_000, stabilityMs = 4000, pollMs = 1500 } = {}) {
551
+ return this._waitForGenerationDoneHtml({ timeoutMs, stabilityMs, pollMs });
552
+ }
553
+ async _waitForGenerationDoneHtml({ timeoutMs = 20 * 60_000, stabilityMs = 4000, pollMs = 1500 } = {}) {
554
+ // One reader for the whole poll loop (reused per poll), not one per poll (#67).
555
+ return this.withPreviewReader(async (readServed) => {
556
+ const start = Date.now();
557
+ const preHtml = this._preSendHtml || '';
558
+ let lastHtml = '';
559
+ let lastLen = -1;
560
+ let stableSince = 0;
561
+ let sawChange = false;
562
+ while (Date.now() - start < timeoutMs) {
563
+ const { html, src } = await readServed();
564
+ const len = html.length;
565
+ if (!preHtml) {
566
+ if (len > 0)
567
+ sawChange = true;
568
+ }
569
+ else if (html && html !== preHtml) {
570
+ sawChange = true;
571
+ }
572
+ if (sawChange) {
573
+ if (len === lastLen && html === lastHtml) {
574
+ if (!stableSince)
575
+ stableSince = Date.now();
576
+ if (Date.now() - stableSince > stabilityMs) {
577
+ const url = await this.currentUrl();
578
+ return { ok: true, elapsedMs: Date.now() - start, url, iframeSrc: src, htmlBytes: len, html };
579
+ }
580
+ }
581
+ else {
582
+ stableSince = 0;
583
+ }
584
+ }
585
+ lastHtml = html;
586
+ lastLen = len;
587
+ await new Promise((r) => setTimeout(r, pollMs));
588
+ }
589
+ return { ok: false, error: 'timeout', elapsedMs: Date.now() - start };
590
+ });
591
+ }
592
+ async _waitForGenerationDoneNetwork(observer, { timeoutMs = 20 * 60_000, stabilityMs = 4000, pollMs = 1500 } = {}) {
593
+ const terminal = await observer.awaitTerminal({ hardTimeoutMs: timeoutMs });
594
+ if (terminal.terminal === 'observer-lost') {
595
+ return { ok: false, error: 'observer-lost', elapsedMs: terminal.elapsedMs, reason: terminal.reason };
596
+ }
597
+ if (terminal.terminal === 'blocked') {
598
+ return { ok: false, error: 'blocked', elapsedMs: terminal.elapsedMs, reason: terminal.reason };
599
+ }
600
+ if (terminal.terminal === 'timeout') {
601
+ return { ok: false, error: 'stalled', elapsedMs: terminal.elapsedMs, reason: terminal.reason };
602
+ }
603
+ // ReleaseTurn can lead served-HTML readiness by up to ~10s on small edits —
604
+ // the preview keeps propagating after the turn completes (trace findings:
605
+ // ReleaseTurn led HTML byte-stability by 5–10s on edit/tweak runs). So poll
606
+ // a bounded window for the preview to byte-stabilize instead of trusting the
607
+ // first fetch. Crucially we do NOT require a change from _preSendHtml: a
608
+ // chat-only run legitimately keeps it, and forcing a change would reintroduce
609
+ // the timeout blind spot this observer exists to fix.
610
+ // One reader for the whole settle loop (reused per poll), not one per poll (#67).
611
+ return this.withPreviewReader(async (readServed) => {
612
+ let { html, src } = await readServed();
613
+ const preHtml = this._preSendHtml || '';
614
+ const settleDeadline = Date.now() + Math.min(timeoutMs, Math.max(stabilityMs, 12_000));
615
+ let stableSince = Date.now();
616
+ while (Date.now() < settleDeadline) {
617
+ await new Promise((r) => setTimeout(r, pollMs));
618
+ const next = await readServed();
619
+ if (next.html !== html) {
620
+ html = next.html;
621
+ src = next.src;
622
+ stableSince = Date.now();
623
+ }
624
+ else if (html !== preHtml && Date.now() - stableSince >= stabilityMs) {
625
+ break; // changed and now byte-stable for stabilityMs → settled
626
+ }
627
+ }
628
+ const url = await this.currentUrl();
629
+ return { ok: true, elapsedMs: terminal.elapsedMs, url, iframeSrc: src, htmlBytes: html.length, html };
630
+ });
631
+ }
632
+ async snapshotDesign({ html: knownHtml, iframeSrc: knownSrc } = {}) {
633
+ let iframeSrc = knownSrc || (await this.getIframeSrc());
634
+ let html = knownHtml ?? null;
635
+ if (html == null && (0, preview_host_1.isPreviewIframeSrc)(iframeSrc)) {
636
+ // Route through fetchServedHtml (OOPIF capture in the bootstrap regime, node
637
+ // fetch otherwise) so the snapshot command and iterate()'s post-gen snapshot
638
+ // get real HTML. NOTE: `iframeSrc` here is the iframe ELEMENT's src (the
639
+ // `_bootstrap` loader); the captured `html` is the OOPIF document
640
+ // (`/serve/<filename>`). They are intentionally not the same URL — `src` is
641
+ // the element locator, not a fetchable handle for `html` (#67 review #5).
642
+ const served = await this.fetchServedHtml();
643
+ if (served.html)
644
+ html = served.html;
645
+ if (served.src)
646
+ iframeSrc = served.src;
647
+ }
648
+ const dir = (0, artifact_store_1.sessionDir)(this.key);
649
+ const shotPath = node_path_1.default.join(dir, `shot-${Date.now()}.png`);
650
+ const shotOk = await this.browser
651
+ .screenshot(shotPath, { full: true })
652
+ .then(() => true)
653
+ .catch(() => false);
654
+ const url = await this.currentUrl();
655
+ return { html, screenshotPath: shotOk ? shotPath : null, url, iframeSrc };
656
+ }
657
+ async _ensureInSession() {
658
+ await this.ensureReady();
659
+ if (await this.isInSession())
660
+ return;
661
+ const stored = (0, session_store_1.getSession)(this.key);
662
+ if (!stored?.designUrl)
663
+ throw new Error(`No active session for key=${this.key}. Call createSession first.`);
664
+ await this.resumeSession();
665
+ // ensureReady's pre-flight cleared the home/current page, but this cold-start
666
+ // just navigated to the stored project — an interstitial on the PROJECT page
667
+ // itself (token banner, transient error, Cloudflare) would otherwise reach the
668
+ // verb that called us. Clear again on the resumed page (PR #77 Codex P2).
669
+ const interstitials = await this.clearInterstitials();
670
+ if (interstitials.blocked)
671
+ throw this._interstitialError(interstitials.blocked, 1);
672
+ }
673
+ async iterate(prompt, { file, timeoutMs, stabilityMs, decisive } = {}) {
674
+ await this._ensureInSession();
675
+ if (file)
676
+ await this.openFile(file);
677
+ const preFiles = await this.listFiles().catch(() => []);
678
+ const preChatCount = (await this.getChatTurns()).length;
679
+ const waitBudgetMs = timeoutMs ?? 20 * 60_000;
680
+ // Honor the documented CDP opt-out: DESIGNER_CDP='' means "use the
681
+ // agent-browser session-managed flow" (browser.ts resolves it the same way
682
+ // via ??). Attaching the observer would otherwise route an opted-out user
683
+ // through the CDP layer, which resolves '' to :9222 and can auto-launch the
684
+ // debug Chrome (ensureCdpUp). When disabled, fall through to the HTML waiter.
685
+ const cdpEnabled = (0, cdp_env_1.isCdpEnabled)();
686
+ let observer = cdpEnabled
687
+ ? await run_state_1.RunStateObserver.attach({
688
+ preferUrlPrefix: (await this.currentUrl()).split('?')[0] || null
689
+ })
690
+ : null;
691
+ let done;
692
+ try {
693
+ await this.sendPrompt(prompt, { decisive, onBeforeSubmit: () => observer?.beginRun() });
694
+ if (observer) {
695
+ done = await this._waitForGenerationDoneNetwork(observer, { timeoutMs: waitBudgetMs, stabilityMs });
696
+ if (done.error === 'observer-lost') {
697
+ const fallback = await this._waitForGenerationDoneHtml({
698
+ timeoutMs: Math.max(1, waitBudgetMs - done.elapsedMs),
699
+ stabilityMs
700
+ });
701
+ done = { ...fallback, elapsedMs: done.elapsedMs + fallback.elapsedMs };
702
+ }
703
+ }
704
+ else {
705
+ done = await this._waitForGenerationDoneHtml({ timeoutMs: waitBudgetMs, stabilityMs });
706
+ }
707
+ }
708
+ finally {
709
+ observer?.close();
710
+ observer = null;
711
+ }
712
+ const postFiles = await this.listFiles().catch(() => []);
713
+ const postTurns = await this.getChatTurns();
714
+ const lastTurn = postTurns[postTurns.length - 1];
715
+ const chatReply = postTurns.length > preChatCount && lastTurn && lastTurn.role === 'assistant'
716
+ ? lastTurn.text.replace(/^Claude(?:\n+)?/, '').trim()
717
+ : null;
718
+ const newFiles = postFiles.filter((f) => !preFiles.includes(f));
719
+ const removedFiles = preFiles.filter((f) => !postFiles.includes(f));
720
+ const snap = await this.snapshotDesign({ html: done.html, iframeSrc: done.iframeSrc });
721
+ const htmlHash = snap.html ? hashHex(snap.html) : null;
722
+ const activeFile = extractFileParam(snap.url);
723
+ let failureMode = null;
724
+ if (!done.ok) {
725
+ if (done.error === 'timeout')
726
+ failureMode = 'timeout';
727
+ else if (done.error === 'stalled')
728
+ failureMode = 'stalled';
729
+ else if (done.error === 'blocked')
730
+ failureMode = 'blocked';
731
+ else
732
+ failureMode = 'unstable';
733
+ }
734
+ else if (snap.html && snap.html === this._preSendHtml && newFiles.length === 0)
735
+ failureMode = 'no_change';
736
+ const fidelity = (0, session_store_1.getSession)(this.key)?.fidelity || null;
737
+ const record = (0, artifact_store_1.saveIteration)(this.key, {
738
+ prompt,
739
+ fidelity,
740
+ html: snap.html,
741
+ screenshotPath: snap.screenshotPath,
742
+ url: snap.url,
743
+ meta: { done: { ok: done.ok, elapsedMs: done.elapsedMs }, failureMode, activeFile, newFiles, htmlHash }
744
+ });
745
+ (0, session_store_1.appendHistory)(this.key, { kind: 'iteration', record: record.files, newFiles });
746
+ return {
747
+ done: { ok: done.ok, elapsedMs: done.elapsedMs, failureMode },
748
+ changed: !!(snap.html && snap.html !== this._preSendHtml) || newFiles.length > 0,
749
+ url: snap.url,
750
+ activeFile,
751
+ newFiles,
752
+ removedFiles,
753
+ htmlPath: record.files.html || null,
754
+ screenshotPath: record.files.screenshot || null,
755
+ htmlBytes: snap.html ? snap.html.length : 0,
756
+ htmlHash,
757
+ chatReply
758
+ };
759
+ }
760
+ async listProjects() {
761
+ await this.browser.open(DESIGN_HOME);
762
+ await this.browser.waitLoad('networkidle').catch(() => null);
763
+ await this.browser.waitFor(this.selectors.home.projectsList).catch(() => null);
764
+ const json = await this.browser.evalValue(`(() => {
765
+ // 2026-06 redesign (#61): there's no project-card data-testid. Each
766
+ // project is an <a href="/design/p/<uuid>"> with the project name as its
767
+ // text; dedupe by uuid (a card can wrap more than one anchor).
768
+ const links = Array.from(document.querySelectorAll('a[href*="/design/p/"]'));
769
+ const seen = new Set();
770
+ const out = [];
771
+ for (const a of links) {
772
+ const href = a.href || a.getAttribute('href') || '';
773
+ const m = href.match(/\\/design\\/p\\/([a-f0-9-]+)/i);
774
+ if (!m || seen.has(m[1])) continue;
775
+ seen.add(m[1]);
776
+ out.push({ name: (a.textContent || '').trim() || null, sub: null, url: href });
777
+ }
778
+ return out;
779
+ })()`).catch(() => []);
780
+ return Array.isArray(json) ? json : [];
781
+ }
782
+ async listFiles() {
783
+ const { files } = await this.listFilesDetailed();
784
+ return files;
785
+ }
786
+ // Returns top-level files + whether folders were detected in the panel.
787
+ // The live panel shows folders collapsed and doesn't expose an API we can
788
+ // auth against (/files endpoint is 401, no aria-expanded on rows, clicks
789
+ // don't expand programmatically). When folders are present, the caller
790
+ // should fall back to designer_handoff for an authoritative list.
791
+ async listFilesDetailed() {
792
+ // Navigate to THIS key's project if we're not already there. Being in
793
+ // any /p/ session isn't enough — a different key's files would be
794
+ // returned against the currently-visible project by mistake.
795
+ const stored = (0, session_store_1.getSession)(this.key);
796
+ const currentUrl = await this.currentUrl();
797
+ const targetRoot = stored?.designUrl?.split('?')[0];
798
+ const currentRoot = currentUrl.split('?')[0];
799
+ if (!targetRoot) {
800
+ throw new Error(`No designUrl stored for key=${this.key}. createSession or resumeSession first.`);
801
+ }
802
+ if (currentRoot !== targetRoot) {
803
+ await this.browser.open(stored.designUrl);
804
+ await this.browser.waitLoad('networkidle').catch(() => null);
805
+ await new Promise((r) => setTimeout(r, 1500));
806
+ }
807
+ // Open the Design Files dialog to get the richer file/folder listing, via the
808
+ // shared idempotent opener (file-panel.ts) — the SAME expression the
809
+ // session.fileListScrape health anchor uses, so the probe can't pass while
810
+ // this silently no-ops. It clicks the label (React root delegation; the old
811
+ // walk-up-for-non-null-.onclick never fired) and is OPEN-ONLY so the before/
812
+ // after listFiles() calls in iterate() don't toggle it shut mid-run (PR #77
813
+ // Codex P2).
814
+ await this.browser.evalValue(file_panel_1.OPEN_FILES_PANEL_EXPR).catch(() => null);
815
+ await new Promise((r) => setTimeout(r, 600));
816
+ const result = await this.browser.evalValue(`(() => {
817
+ // Walk all text nodes — Claude's file panel wraps filenames in styled-
818
+ // component <div>s whose class hashes change across deploys. Tag-based
819
+ // scraping misses them; text-node walking is resilient.
820
+ const seen = new Set();
821
+ const files = [];
822
+ let designFilesLabelVisible = false;
823
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
824
+ let node;
825
+ while ((node = walker.nextNode())) {
826
+ const t = (node.textContent || '').trim();
827
+ if (t === 'Design Files') designFilesLabelVisible = true;
828
+ if (!/^[A-Za-z0-9 _.()\\-]+\\.(html|js|css|jsx|tsx|ts|md|json|svg)$/i.test(t)) continue;
829
+ if (t.length > 80 || seen.has(t)) continue;
830
+ seen.add(t);
831
+ files.push(t);
832
+ }
833
+ // Folders: rows whose sibling text is 'Folder' (a Claude-side label).
834
+ // Still tag-based since folder rows are structurally different —
835
+ // revisit if this breaks.
836
+ const folderSet = new Set();
837
+ const divs = Array.from(document.querySelectorAll('div'));
838
+ for (const d of divs) {
839
+ if (d.onclick === null) continue;
840
+ const lines = (d.innerText || '').trim().split('\\n').map((l) => l.trim());
841
+ if (lines.length >= 2 && lines[1] === 'Folder' && lines[0] && lines[0].length < 40) {
842
+ folderSet.add(lines[0]);
843
+ }
844
+ }
845
+ return { files, folders: Array.from(folderSet), designFilesLabelVisible };
846
+ })()`).catch(() => ({ files: [], folders: [], designFilesLabelVisible: false }));
847
+ const files = Array.isArray(result.files) ? result.files : [];
848
+ const folders = Array.isArray(result.folders) ? result.folders : [];
849
+ // Empty rail under a visible "Design Files" label means we scraped the
850
+ // wrong tab or the panel didn't open — don't tell callers it's truth.
851
+ const emptyButLabelVisible = files.length === 0 && result.designFilesLabelVisible === true;
852
+ return {
853
+ files,
854
+ folders,
855
+ authoritative: !emptyButLabelVisible && folders.length === 0
856
+ };
857
+ }
858
+ async openFile(filename) {
859
+ const stored = (0, session_store_1.getSession)(this.key);
860
+ const baseUrl = stored?.designUrl || (await this.currentUrl()).split('?')[0] || '';
861
+ if (!/\/design\/p\//.test(baseUrl))
862
+ throw new Error('No project open for this key.');
863
+ const wanted = encodeURIComponent(filename);
864
+ const fileParamOf = (u) => {
865
+ try {
866
+ return new URL(u).searchParams.get('file');
867
+ }
868
+ catch {
869
+ return null;
870
+ }
871
+ };
872
+ const targetRoot = baseUrl.split('?')[0];
873
+ // Already on THIS project's tab showing the requested file with a live
874
+ // preview — no swap needed (re-opening the same file, e.g. repeated
875
+ // `prompt --file X`). Must compare the project root too, not just the file
876
+ // param: with parallel keys, Chrome can be on project B's tab with the same
877
+ // ?file=index.html, and skipping the open would silently target B (#66).
878
+ const curUrl = await this.currentUrl();
879
+ const before = await this.getIframeSrc();
880
+ if (curUrl.split('?')[0] === targetRoot && fileParamOf(curUrl) === filename && (0, preview_host_1.isPreviewIframeSrc)(before)) {
881
+ return { ok: true, file: filename, url: curUrl };
882
+ }
883
+ const target = `${targetRoot}?file=${wanted}`;
884
+ await this.browser.open(target);
885
+ // Readiness across two UI generations — and the file-switch false-positive
886
+ // Codex flagged on #66:
887
+ // - legacy: the signed iframe src embedded the filename (src.includes(wanted)).
888
+ // - current (issue #61): EVERY file is served from the same per-project
889
+ // <uuid>.claudeusercontent.com/_bootstrap src — the filename is not in the
890
+ // src and there is no active-file DOM marker (verified live). So a present
891
+ // claudeusercontent iframe alone is NOT proof the requested file rendered:
892
+ // on a switch A→B the URL updates before React swaps, so the caller would
893
+ // otherwise be handed A's still-mounted preview while asking for B.
894
+ // Switching tears the iframe down (src → '') and remounts it (~1.2s). Require
895
+ // that teardown + restabilize, plus the URL carrying the requested file, before
896
+ // declaring success. `before === ''` means nothing was mounted (no stale preview
897
+ // to clear). Only HTML renders in the iframe; .css/.md/.js settle to an empty
898
+ // preview, so for those a torn-down-then-stable-empty state is the success signal.
899
+ const expectsPreview = /\.html?$/i.test(filename);
900
+ let sawTeardown = before === '';
901
+ let lastSrc = before;
902
+ for (let i = 0; i < 40; i++) {
903
+ await new Promise((r) => setTimeout(r, 250));
904
+ const src = await this.getIframeSrc();
905
+ if (src.includes(wanted))
906
+ return { ok: true, file: filename, url: await this.currentUrl() };
907
+ if (src !== before)
908
+ sawTeardown = true;
909
+ const url = await this.currentUrl();
910
+ if (fileParamOf(url) === filename && sawTeardown && src === lastSrc) {
911
+ if (expectsPreview && (0, preview_host_1.isPreviewIframeSrc)(src))
912
+ return { ok: true, file: filename, url };
913
+ if (!expectsPreview && src === '')
914
+ return { ok: true, file: filename, url };
915
+ }
916
+ lastSrc = src;
917
+ }
918
+ // Window exhausted. For NON-HTML files an empty preview is the legitimate
919
+ // end-state, so accept once the URL took the requested file and the prior
920
+ // preview cleared. For HTML, never settling on a claudeusercontent iframe is a
921
+ // real failure — do NOT mask a non-rendering preview (a soft ok here would
922
+ // hand callers empty/stale content); fail loud with iframe-swap-timeout.
923
+ const url = await this.currentUrl();
924
+ if (!expectsPreview && fileParamOf(url) === filename && sawTeardown) {
925
+ return { ok: true, file: filename, url };
926
+ }
927
+ return { ok: false, error: 'iframe-swap-timeout', file: filename, url };
928
+ }
929
+ async fetchFile(filename) {
930
+ const swap = await this.openFile(filename);
931
+ if (!swap.ok)
932
+ return { ok: false, error: swap.error, file: filename, html: '', htmlBytes: 0 };
933
+ const { html, src } = await this.fetchServedHtml();
934
+ return { ok: true, file: filename, iframeSrc: src, html, htmlBytes: html.length };
935
+ }
936
+ async getChatTurns() {
937
+ return ((await this.browser
938
+ .evalValue(`(() => {
939
+ const c = document.querySelector('[data-testid="chat-messages"]');
940
+ const inner = c && c.children[0];
941
+ if (!inner) return [];
942
+ return Array.from(inner.children).map((d) => {
943
+ const txt = (d.innerText || '').trim();
944
+ // Role signal: Claude's replies carry a feedback widget
945
+ // ([data-msgfb], thumbs up/down) and user turns don't. The 2026-06
946
+ // chat DOM dropped the "Claude"/"You" text prefixes the old check
947
+ // keyed off (kept as a fallback for older builds). In this two-party
948
+ // chat a non-assistant turn is the human, so default to 'user'.
949
+ const isAssistant = !!d.querySelector('[data-msgfb]') || /^Claude(\\n|$)/.test(txt);
950
+ return { role: isAssistant ? 'assistant' : 'user', text: txt };
951
+ });
952
+ })()`)
953
+ .catch(() => [])) || []);
954
+ }
955
+ async ask(prompt, { file, timeoutMs = 5 * 60_000, stabilityMs = 2500, pollMs = 1000 } = {}) {
956
+ await this._ensureInSession();
957
+ if (file)
958
+ await this.openFile(file);
959
+ const beforeCount = (await this.getChatTurns()).length;
960
+ await this._submitPrompt(prompt);
961
+ (0, session_store_1.appendHistory)(this.key, { kind: 'ask', prompt });
962
+ const start = Date.now();
963
+ let lastText = '';
964
+ let stableSince = 0;
965
+ while (Date.now() - start < timeoutMs) {
966
+ const turns = await this.getChatTurns();
967
+ if (turns.length >= beforeCount + 2) {
968
+ const last = turns[turns.length - 1];
969
+ if (last && last.role === 'assistant') {
970
+ if (last.text === lastText && last.text.length > 0) {
971
+ if (!stableSince)
972
+ stableSince = Date.now();
973
+ if (Date.now() - stableSince > stabilityMs) {
974
+ const reply = last.text
975
+ .replace(/^Claude(?:\n+)?/, '')
976
+ .replace(/^(?:Searching|Reading|Thinking)\s*\n+/i, '')
977
+ .trim();
978
+ (0, session_store_1.appendHistory)(this.key, { kind: 'ask_reply', textBytes: reply.length });
979
+ return { ok: true, elapsedMs: Date.now() - start, reply, failureMode: null };
980
+ }
981
+ }
982
+ else {
983
+ stableSince = 0;
984
+ lastText = last.text;
985
+ }
986
+ }
987
+ }
988
+ await new Promise((r) => setTimeout(r, pollMs));
989
+ }
990
+ return { ok: false, elapsedMs: Date.now() - start, reply: null, failureMode: 'timeout' };
991
+ }
992
+ async getIframeSrc() {
993
+ const src = await this.browser
994
+ .evalValue(`(() => { const el = document.querySelector(${JSON.stringify(this.selectors.preview.iframeOrContainer)}); return (el && el.src) || ''; })()`)
995
+ .catch(() => '');
996
+ return src || '';
997
+ }
998
+ // Node-side fetch of the preview src. The LEGACY signed-token regime
999
+ // (`claudeusercontent.com/...?t=<token>`) authorizes this fetch and returns
1000
+ // the file's real rendered HTML. The 2026-06 bootstrap regime does NOT (see
1001
+ // fetchServedHtml) — there the fetch returns the same ~1.1KB unauthenticated
1002
+ // loader shell for every file, so this is only the fallback floor.
1003
+ async _fetchServedHtmlNode(src) {
1004
+ try {
1005
+ const res = await fetch(src, { headers: { Accept: 'text/html' } });
1006
+ if (!res.ok)
1007
+ return { src, html: '' };
1008
+ return { src, html: await res.text() };
1009
+ }
1010
+ catch {
1011
+ return { src, html: '' };
1012
+ }
1013
+ }
1014
+ // Reads the design preview's served HTML. The preview iframe addressing has
1015
+ // two regimes (preview-host.ts/previewIframeVariant):
1016
+ //
1017
+ // - signed-token (legacy): a node fetch of the `?t=<token>` URL is
1018
+ // authorized and returns the file's real rendered HTML — keep it.
1019
+ // - bootstrap-subdomain (2026-06, issue #61): the src is a filename-agnostic
1020
+ // `<uuid>.claudeusercontent.com/_bootstrap` with NO token. A node fetch
1021
+ // (no claude.ai cookies) returns the same ~1.1KB unauthenticated loader
1022
+ // shell for EVERY file — never the rendered HTML. The rendered DOM lives
1023
+ // only inside the cross-origin out-of-process iframe (OOPIF), which the
1024
+ // parent page JS can't read. So read it over CDP via OopifHtmlReader
1025
+ // (review #4, live-verified). Honors the DESIGNER_CDP='' opt-out and
1026
+ // degrades to the node fetch on any failure, so every existing caller
1027
+ // (snapshot, iterate's post-gen snapshot, the no_change signal, and the
1028
+ // _waitForGenerationDoneHtml fallback) behaves at least as before.
1029
+ //
1030
+ // CONTRACT: "served HTML" here is the preview's RENDERED DOM (outerHTML in the
1031
+ // bootstrap regime; the served response body in the signed-token regime), NOT
1032
+ // the on-disk source file. For the byte-stability / no_change / snapshot uses
1033
+ // that is exactly right (they care what the preview shows). A caller that needs
1034
+ // authoritative file SOURCE must use `handoff` (the Share/Export bundle), not
1035
+ // this. Returns html:'' (never the loader shell) when no real HTML is readable.
1036
+ async fetchServedHtml(sharedReader) {
1037
+ const src = await this.getIframeSrc();
1038
+ if (!src || !(0, preview_host_1.isPreviewIframeSrc)(src))
1039
+ return { src: '', html: '' };
1040
+ const variant = (0, preview_host_1.previewIframeVariant)(src);
1041
+ if (variant === 'bootstrap-subdomain') {
1042
+ // A node fetch of a bootstrap src returns ONLY the ~1.1KB loader shell,
1043
+ // never the file — so the OOPIF read is the only real source here. On any
1044
+ // failure (or the DESIGNER_CDP='' opt-out) return EMPTY, never the shell, so
1045
+ // callers (snapshot, the no_change signal, the byte-stability settle) treat
1046
+ // it as "no sample" instead of byte-comparing or saving a loader as the
1047
+ // captured artifact (#67 review).
1048
+ const cdpEnabled = (0, cdp_env_1.isCdpEnabled)();
1049
+ if (!cdpEnabled)
1050
+ return { src, html: '' };
1051
+ // A poll loop passes a shared reader (attached once via withPreviewReader)
1052
+ // to amortize the WS-open/connect cost across polls and avoid a connect
1053
+ // storm (#67 review perf); a live reader reuses in ~8ms/read. One-shot
1054
+ // callers pass nothing → attach-and-close a fresh reader here.
1055
+ if (sharedReader) {
1056
+ const html = await sharedReader.readPreviewHtml().catch(() => null);
1057
+ return { src, html: html || '' };
1058
+ }
1059
+ const reader = await this.attachPreviewReader();
1060
+ if (reader) {
1061
+ try {
1062
+ const html = await reader.readPreviewHtml().catch(() => null);
1063
+ if (html)
1064
+ return { src, html };
1065
+ }
1066
+ finally {
1067
+ reader.close();
1068
+ }
1069
+ }
1070
+ return { src, html: '' };
1071
+ }
1072
+ // signed-token / 'other': the node fetch is authoritative (real rendered HTML).
1073
+ return this._fetchServedHtmlNode(src);
1074
+ }
1075
+ // Attach ONE OopifHtmlReader for the duration of a served-HTML poll loop and
1076
+ // reuse it per poll (each readPreviewHtml re-arms in ~8ms), instead of opening a
1077
+ // fresh CDP socket per poll — amortizes the WS-open/connect cost and avoids the
1078
+ // connect storm on long settle/fallback loops (#67 review perf). The reader is
1079
+ // best-effort: if attach fails or the regime isn't bootstrap, fetchServedHtml
1080
+ // falls back to its own per-call path. Closed in finally.
1081
+ async withPreviewReader(run) {
1082
+ const reader = (0, cdp_env_1.isCdpEnabled)() ? await this.attachPreviewReader() : null;
1083
+ try {
1084
+ return await run(() => this.fetchServedHtml(reader));
1085
+ }
1086
+ finally {
1087
+ reader?.close();
1088
+ }
1089
+ }
1090
+ // Attach an OopifHtmlReader bound to the current tab. The FULL current URL
1091
+ // (with ?file=) is passed so findDesignTarget exact-matches the agent-browser-
1092
+ // driven tab — two same-project tabs on different files must not cross-bind
1093
+ // (#67 review). Degrades to null on any failure (caller falls back to the node
1094
+ // fetch / treats it as no sample).
1095
+ async attachPreviewReader() {
1096
+ return oopif_reader_1.OopifHtmlReader.attach({ preferUrlPrefix: (await this.currentUrl()) || null }).catch(() => null);
1097
+ }
1098
+ // Fetch the project's export zip via the authenticated, same-origin endpoint
1099
+ // the Share→Export "Download" button hits — `/design/v1/design/projects/<id>
1100
+ // /download` (returns application/zip). The 2026-06-21 redesign removed the old
1101
+ // public `api.anthropic.com/v1/design/h/<id>` tar.gz URL and replaced it with a
1102
+ // browser download that needs a trusted gesture CDP can't fire — but the bytes
1103
+ // come from a plain GET. We do it IN-PAGE (auth + Cloudflare just work there; a
1104
+ // node-side fetch with copied cookies 403s) and transfer the bytes out as
1105
+ // base64. Throws on non-200 / non-zip so the caller surfaces a clear failure.
1106
+ async _downloadProjectZip(projectId) {
1107
+ // Origin guard: the in-page fetch is same-origin, so if the bound tab has
1108
+ // drifted off claude.ai (tab drift is real — it bit this very session) the
1109
+ // '/design/v1/...' path would resolve against the wrong app and 404. Refuse
1110
+ // rather than return junk.
1111
+ const url = await this.currentUrl();
1112
+ if (!/^https:\/\/claude\.ai\/design\//.test(url)) {
1113
+ throw new Error(`Active tab is not on claude.ai/design (${url || 'unknown'}) — refusing to fetch the export from the wrong origin.`);
1114
+ }
1115
+ // In-page authed GET with an abort deadline; returns {status, bytes, b64} or
1116
+ // {status, err}. Bytes are carried out as chunked base64 (byte-exact); the
1117
+ // server byte count comes back too so we can detect a truncated transfer.
1118
+ const expr = (timeoutMs) => `(async () => {
1119
+ const ctrl = new AbortController();
1120
+ const to = setTimeout(() => ctrl.abort(), ${timeoutMs});
1121
+ try {
1122
+ const r = await fetch('/design/v1/design/projects/' + ${JSON.stringify(projectId)} + '/download', { headers: { Accept: '*/*' }, signal: ctrl.signal });
1123
+ if (!r.ok) return { status: r.status };
1124
+ const bytes = new Uint8Array(await r.arrayBuffer());
1125
+ let bin = '';
1126
+ const CH = 0x8000;
1127
+ for (let i = 0; i < bytes.length; i += CH) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CH));
1128
+ return { status: 200, bytes: bytes.length, b64: btoa(bin) };
1129
+ } catch (e) { return { status: 0, err: String((e && e.message) || e) }; }
1130
+ finally { clearTimeout(to); }
1131
+ })()`;
1132
+ // Bounded retry: fail fast on auth/not-found, retry transient (abort/0/429/5xx).
1133
+ let lastErr = 'unknown';
1134
+ for (let attempt = 0; attempt < 3; attempt++) {
1135
+ const res = await this.browser
1136
+ .evalValue(expr(25_000))
1137
+ .catch((e) => ({ status: 0, err: String(e?.message || e) }));
1138
+ if (res.status === 200 && typeof res.b64 === 'string') {
1139
+ const buf = Buffer.from(res.b64, 'base64');
1140
+ if (typeof res.bytes === 'number' && buf.length !== res.bytes) {
1141
+ lastErr = `truncated transfer (${buf.length} of ${res.bytes} bytes)`; // retry
1142
+ }
1143
+ else if (buf.length < 100 || buf.subarray(0, 2).toString('latin1') !== 'PK') {
1144
+ throw new Error(`Project download is not a zip (${buf.length} bytes).`);
1145
+ }
1146
+ else {
1147
+ return buf;
1148
+ }
1149
+ }
1150
+ else {
1151
+ lastErr = res.err ? res.err : `HTTP ${res.status}`;
1152
+ if (res.status === 401 || res.status === 403 || res.status === 404) {
1153
+ throw new Error(`Project download failed (HTTP ${res.status}). Are you signed in to claude.ai/design?`);
1154
+ }
1155
+ }
1156
+ if (attempt < 2)
1157
+ await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
1158
+ }
1159
+ throw new Error(`Project download failed after 3 attempts: ${lastErr}.`);
1160
+ }
1161
+ // Capture the FULL chat transcript despite virtualization (claude.ai mounts only
1162
+ // the visible rows). Scroll the chat history top→bottom, accumulating turns keyed
1163
+ // by data-index so rows that unmount as we scroll are still kept. Best-effort: if
1164
+ // the scroll container isn't found, degrades to the visible window.
1165
+ async _collectChatTurns() {
1166
+ const collected = new Map();
1167
+ const scrape = async () => {
1168
+ const rows = await this.browser
1169
+ .evalValue(`(() => {
1170
+ const c = document.querySelector('[data-testid="chat-messages"]');
1171
+ const inner = c && c.children[0];
1172
+ if (!inner) return [];
1173
+ return Array.from(inner.children).map((d) => {
1174
+ const idx = parseInt(d.getAttribute('data-index') || '-1', 10);
1175
+ const txt = (d.innerText || '').trim();
1176
+ const isAssistant = !!d.querySelector('[data-msgfb]') || /^Claude(\\n|$)/.test(txt);
1177
+ return { idx, role: isAssistant ? 'assistant' : 'user', text: txt };
1178
+ });
1179
+ })()`)
1180
+ .catch(() => []);
1181
+ for (const r of rows)
1182
+ if (r.idx >= 0 && r.text)
1183
+ collected.set(r.idx, { role: r.role, text: r.text });
1184
+ };
1185
+ const scroll = (dir) => this.browser
1186
+ .evalValue(`(() => {
1187
+ let s = document.querySelector('[data-testid="chat-messages"]');
1188
+ for (let i = 0; i < 8 && s; i++) { if (s.scrollHeight > s.clientHeight + 4) break; s = s.parentElement; }
1189
+ if (!s) return -1;
1190
+ if (${JSON.stringify(dir)} === 'top') s.scrollTop = 0; else s.scrollTop = Math.min(s.scrollTop + s.clientHeight, s.scrollHeight);
1191
+ return s.scrollTop;
1192
+ })()`)
1193
+ .catch(() => -1);
1194
+ await scroll('top');
1195
+ await new Promise((r) => setTimeout(r, 400));
1196
+ let lastTop = -2;
1197
+ let stable = 0;
1198
+ for (let i = 0; i < 40; i++) {
1199
+ await scrape();
1200
+ const top = await scroll('down');
1201
+ if (top < 0)
1202
+ break; // no scroller — single visible-window pass
1203
+ await new Promise((r) => setTimeout(r, 250));
1204
+ if (top === lastTop) {
1205
+ if (++stable >= 2)
1206
+ break;
1207
+ }
1208
+ else {
1209
+ stable = 0;
1210
+ lastTop = top;
1211
+ }
1212
+ }
1213
+ await scrape();
1214
+ return [...collected.entries()].sort((a, b) => a[0] - b[0]).map(([, t]) => t);
1215
+ }
1216
+ async handoff({ openFile } = {}) {
1217
+ await this._ensureInSession();
1218
+ if (openFile)
1219
+ await this.openFile(openFile);
1220
+ const baseUrl = (0, session_store_1.getSession)(this.key)?.designUrl || (await this.currentUrl());
1221
+ const m = baseUrl.match(exports.SESSION_URL_RE);
1222
+ if (!m || !m[1])
1223
+ throw new Error(`No /design/p/<uuid> project bound to key=${this.key} to hand off.`);
1224
+ const projectId = m[1];
1225
+ const projectUrl = baseUrl.split('?')[0] ?? baseUrl;
1226
+ // Cross-project guard: the in-page chat scrape AND the export fetch run on the
1227
+ // ACTIVE tab. _ensureInSession returns early on any /design/p/ tab and tab
1228
+ // drift is real, so the active tab can be a DIFFERENT project than the bound
1229
+ // one — which would pair project B's chat with project A's files. Pin the tab
1230
+ // to the bound project first so both come from one project.
1231
+ const curId = (await this.currentUrl()).match(exports.SESSION_URL_RE)?.[1];
1232
+ if (curId !== projectId)
1233
+ await this.resumeSession();
1234
+ // The export zip dropped the README + chat transcript the old tar.gz carried,
1235
+ // so regenerate the decision record from the live chat (virtualization-aware).
1236
+ const turns = await this._collectChatTurns().catch(() => []);
1237
+ const decisionRecord = renderDecisionRecord(turns, projectId, projectUrl);
1238
+ const zip = await this._downloadProjectZip(projectId);
1239
+ // Build the bundle atomically: assemble in a temp dir, rename into place only
1240
+ // on full success, so a crash/partial extract never leaves a half-built bundle
1241
+ // that `tasting` would pick up as the latest complete handoff.
1242
+ const dir = (0, artifact_store_1.sessionDir)(this.key);
1243
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
1244
+ const bundleDir = node_path_1.default.join(dir, `handoff-${stamp}`);
1245
+ const tmpDir = node_path_1.default.join(dir, `.handoff-${stamp}.tmp`);
1246
+ const tmpProject = node_path_1.default.join(tmpDir, 'project');
1247
+ node_fs_1.default.mkdirSync(tmpProject, { recursive: true });
1248
+ try {
1249
+ // Extract in-process (no external `unzip` — absent on Windows / minimal CI).
1250
+ const entries = (0, fflate_1.unzipSync)(new Uint8Array(zip));
1251
+ let extracted = 0;
1252
+ for (const [name, data] of Object.entries(entries)) {
1253
+ if (!name || name.endsWith('/'))
1254
+ continue;
1255
+ const dest = node_path_1.default.join(tmpProject, name);
1256
+ if (dest !== tmpProject && !dest.startsWith(tmpProject + node_path_1.default.sep))
1257
+ continue; // zip-slip guard
1258
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(dest), { recursive: true });
1259
+ node_fs_1.default.writeFileSync(dest, Buffer.from(data));
1260
+ extracted++;
1261
+ }
1262
+ if (extracted === 0)
1263
+ throw new Error('export zip contained no files');
1264
+ node_fs_1.default.writeFileSync(node_path_1.default.join(tmpDir, 'bundle.zip'), zip);
1265
+ node_fs_1.default.writeFileSync(node_path_1.default.join(tmpDir, 'decision-record.md'), decisionRecord);
1266
+ const repaired = repairEmDashLinks(tmpProject);
1267
+ node_fs_1.default.renameSync(tmpDir, bundleDir); // commit
1268
+ const projectDir = node_path_1.default.join(bundleDir, 'project');
1269
+ // Design inventory = project/ only (the zip + record aren't design files).
1270
+ const files = listAllFiles(projectDir).map((p) => node_path_1.default.relative(bundleDir, p));
1271
+ (0, session_store_1.appendHistory)(this.key, { kind: 'handoff', projectId, bundleDir, fileCount: files.length, turns: turns.length, repaired });
1272
+ return {
1273
+ ok: true,
1274
+ projectId,
1275
+ projectUrl,
1276
+ bundleDir,
1277
+ slugDir: bundleDir,
1278
+ projectDir,
1279
+ decisionRecordPath: node_path_1.default.join(bundleDir, 'decision-record.md'),
1280
+ decisionRecordBytes: Buffer.byteLength(decisionRecord),
1281
+ decisionRecordTurns: turns.length,
1282
+ decisionRecordEmpty: turns.length === 0,
1283
+ zipPath: node_path_1.default.join(bundleDir, 'bundle.zip'),
1284
+ zipBytes: zip.length,
1285
+ files,
1286
+ repaired
1287
+ };
1288
+ }
1289
+ catch (e) {
1290
+ node_fs_1.default.rmSync(tmpDir, { recursive: true, force: true });
1291
+ throw e;
1292
+ }
1293
+ }
1294
+ async _clickButtonByText(pattern) {
1295
+ const re = pattern instanceof RegExp ? pattern : new RegExp(pattern);
1296
+ return this.browser.evalValue(`(() => {
1297
+ const re = new RegExp(${JSON.stringify(re.source)}, ${JSON.stringify(re.flags)});
1298
+ const btn = Array.from(document.querySelectorAll('button')).find(b => re.test((b.textContent || '').trim()));
1299
+ if (!btn) throw new Error('button not found: ' + ${JSON.stringify(re.source)});
1300
+ btn.click();
1301
+ return true;
1302
+ })()`);
1303
+ }
1304
+ async close() {
1305
+ await this.browser.close().catch(() => null);
1306
+ }
1307
+ }
1308
+ exports.DesignerController = DesignerController;
1309
+ function hashHex(s) {
1310
+ return node_crypto_1.default.createHash('sha256').update(s).digest('hex').slice(0, 16);
1311
+ }
1312
+ function escapeRegExp(s) {
1313
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1314
+ }
1315
+ // The export zip no longer ships the README + chat transcript the old tar.gz
1316
+ // did, so reconstruct a decision record from the live chat turns (every prompt +
1317
+ // reply, verbatim) — the "why" a coding agent needs alongside the files.
1318
+ function renderDecisionRecord(turns, projectId, projectUrl) {
1319
+ // getChatTurns role-detects off the turn text starting with "Claude"/"You";
1320
+ // the current chat DOM dropped those text labels (turns carry only data-index),
1321
+ // so roles can come back all-'unknown'. Don't mislabel them "Note" — fall back
1322
+ // to sequential "Turn N" and flag the gap. (Role attribution is a separate
1323
+ // getChatTurns drift, tracked independently.)
1324
+ const attributed = turns.some((t) => t.role !== 'unknown');
1325
+ const out = [
1326
+ '# Design handoff — decision record',
1327
+ '',
1328
+ `Project: ${projectUrl}`,
1329
+ `Project ID: ${projectId}`,
1330
+ `Captured: ${new Date().toISOString()}`,
1331
+ ''
1332
+ ];
1333
+ if (!turns.length) {
1334
+ out.push('## Conversation', '', '_(no chat turns captured)_');
1335
+ return out.join('\n');
1336
+ }
1337
+ out.push('## Conversation (verbatim — the decisions behind the design)');
1338
+ if (!attributed) {
1339
+ out.push('', '_Speaker labels unavailable (claude.ai dropped role markers from the chat DOM); turns are shown in order._');
1340
+ }
1341
+ out.push('');
1342
+ turns.forEach((t, i) => {
1343
+ const role = t.role === 'assistant' ? 'Claude' : t.role === 'user' ? 'You' : `Turn ${i + 1}`;
1344
+ out.push(`### ${role}`, '', t.text.trim(), '');
1345
+ });
1346
+ return out.join('\n');
1347
+ }
1348
+ function extractFileParam(url) {
1349
+ try {
1350
+ return new URL(url).searchParams.get('file');
1351
+ }
1352
+ catch {
1353
+ return null;
1354
+ }
1355
+ }
1356
+ // Claude's handoff pipeline (as of 2026-04) writes em-dashes (—, U+2014) into
1357
+ // the index.html hrefs but saves on-disk filenames with regular hyphens (-).
1358
+ // We detect this mismatch and rename files to match the hrefs. Safe if fixed
1359
+ // upstream: if the href already resolves, we leave everything alone.
1360
+ function repairEmDashLinks(projectDir) {
1361
+ const report = { renamed: [], skipped: [] };
1362
+ if (!node_fs_1.default.existsSync(projectDir))
1363
+ return report;
1364
+ const indexPath = node_path_1.default.join(projectDir, 'index.html');
1365
+ if (!node_fs_1.default.existsSync(indexPath))
1366
+ return report;
1367
+ const indexHtml = node_fs_1.default.readFileSync(indexPath, 'utf8');
1368
+ const hrefs = new Set();
1369
+ for (const m of indexHtml.matchAll(/href="([^"#?]+\.html)"/g)) {
1370
+ const raw = m[1];
1371
+ if (!raw)
1372
+ continue;
1373
+ try {
1374
+ hrefs.add(decodeURIComponent(raw));
1375
+ }
1376
+ catch {
1377
+ hrefs.add(raw);
1378
+ }
1379
+ }
1380
+ for (const wanted of hrefs) {
1381
+ const wantedPath = node_path_1.default.join(projectDir, wanted);
1382
+ if (node_fs_1.default.existsSync(wantedPath))
1383
+ continue;
1384
+ const candidate = wanted.replace(/\u2014/g, '-').replace(/\s-\s/g, ' - ');
1385
+ const candidatePath = node_path_1.default.join(projectDir, candidate);
1386
+ if (candidate !== wanted && node_fs_1.default.existsSync(candidatePath)) {
1387
+ node_fs_1.default.renameSync(candidatePath, wantedPath);
1388
+ report.renamed.push({ from: candidate, to: wanted });
1389
+ }
1390
+ else {
1391
+ report.skipped.push(wanted);
1392
+ }
1393
+ }
1394
+ return report;
1395
+ }
1396
+ function listAllFiles(root) {
1397
+ const out = [];
1398
+ const stack = [root];
1399
+ while (stack.length) {
1400
+ const cur = stack.pop();
1401
+ if (!cur)
1402
+ continue;
1403
+ for (const entry of node_fs_1.default.readdirSync(cur)) {
1404
+ const p = node_path_1.default.join(cur, entry);
1405
+ const st = node_fs_1.default.statSync(p);
1406
+ if (st.isDirectory())
1407
+ stack.push(p);
1408
+ else
1409
+ out.push(p);
1410
+ }
1411
+ }
1412
+ return out;
1413
+ }