thinkpool-pair 0.7.364 → 0.7.366

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 (116) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +20 -0
  3. package/abort-turn-barrier.mjs +1 -23
  4. package/account.mjs +1 -1437
  5. package/acp-client.mjs +1 -140
  6. package/agent-detect.mjs +1 -28
  7. package/agent-notify.mjs +1 -142
  8. package/agent-visibility.mjs +1 -67
  9. package/auth-store.mjs +1 -120
  10. package/bridge.mjs +1 -6266
  11. package/byok-detect.mjs +1 -126
  12. package/claude-command-catalog.mjs +1 -91
  13. package/claude-session.mjs +1 -1519
  14. package/code-event-contract.mjs +1 -118
  15. package/codex-app-server.mjs +1 -340
  16. package/codex-commands.mjs +1 -83
  17. package/codex-event-mapper.mjs +1 -232
  18. package/codex-images.mjs +1 -69
  19. package/codex-mcp-http.mjs +1 -131
  20. package/codex-session.mjs +1 -1295
  21. package/command-catalog.mjs +1 -118
  22. package/command-guidance.mjs +1 -8
  23. package/context-contract.mjs +1 -95
  24. package/context-windows.mjs +1 -107
  25. package/cross-terminal.mjs +1 -789
  26. package/cumulative-event-relay.mjs +1 -53
  27. package/design-edit.mjs +1 -424
  28. package/design-source-contract.mjs +1 -4
  29. package/direct-pair-room.mjs +1 -57
  30. package/dispatch-lease.mjs +1 -37
  31. package/dispatch-permission-cleanup.mjs +1 -86
  32. package/edit-diff.mjs +1 -136
  33. package/error-recovery.mjs +1 -50
  34. package/event-bounds.mjs +1 -121
  35. package/event-delivery-queue.mjs +1 -60
  36. package/event-id.mjs +1 -549
  37. package/evidence-citations.mjs +1 -50
  38. package/evidence-compact.mjs +1 -11
  39. package/flow-assembly.mjs +1 -196
  40. package/flow-budget.mjs +1 -84
  41. package/flow-conductor.mjs +1 -259
  42. package/flow-context-store.mjs +1 -387
  43. package/flow-host-revert.mjs +1 -42
  44. package/flow-models.mjs +1 -139
  45. package/flow-preview.mjs +1 -148
  46. package/flow-receipt.mjs +1 -122
  47. package/flow-redispatch.mjs +1 -72
  48. package/flow-review-gate.mjs +1 -402
  49. package/flow-review-reflect.mjs +1 -115
  50. package/flow-review.mjs +1 -152
  51. package/flow-scope-evidence.mjs +1 -117
  52. package/flow-skill-registry.mjs +1 -140
  53. package/flow-task-graph.mjs +1 -562
  54. package/flow-worktree.mjs +1 -71
  55. package/git-diff-report.mjs +1 -121
  56. package/hermes-delegation-guard.mjs +1 -14
  57. package/hermes-event-mapper.mjs +1 -194
  58. package/hermes-isolation.mjs +1 -61
  59. package/hermes-model-cache.mjs +1 -54
  60. package/hermes-policy.mjs +1 -92
  61. package/hermes-probe.mjs +1 -57
  62. package/hermes-session.mjs +1 -683
  63. package/hermes-setup.mjs +1 -167
  64. package/host-memory.mjs +1 -116
  65. package/interrupted-resume.mjs +1 -95
  66. package/keep-awake.mjs +1 -148
  67. package/key-shape.mjs +1 -49
  68. package/lane-continuation.mjs +1 -83
  69. package/lane-lifecycle.mjs +1 -189
  70. package/lane-worktree.mjs +1 -77
  71. package/launcher.mjs +1 -354
  72. package/mcp-flight-recorder.mjs +1 -79
  73. package/mockup-delivery.mjs +1 -57
  74. package/model-prices.mjs +1 -113
  75. package/package.json +13 -4
  76. package/pair-bus.mjs +1 -98
  77. package/pair-control-authority.mjs +1 -89
  78. package/past-work-search.mjs +1 -105
  79. package/plan-meters.mjs +1 -144
  80. package/presence.mjs +1 -191
  81. package/privacy-report.mjs +1 -108
  82. package/provider-resilience.mjs +1 -356
  83. package/provider.mjs +1 -133
  84. package/providers.mjs +1 -491
  85. package/publish-guard.mjs +2 -0
  86. package/publish-manifest.json +129 -0
  87. package/question-response.mjs +1 -58
  88. package/reap-terminal.mjs +1 -68
  89. package/recap.mjs +1 -297
  90. package/replay-transport.mjs +1 -64
  91. package/repo-search.mjs +1 -190
  92. package/review-check.mjs +1 -182
  93. package/runtime-contract.mjs +1 -93
  94. package/runtime-registry.mjs +1 -64
  95. package/runtime-session.mjs +1 -20
  96. package/scheduled-run-admission.mjs +1 -364
  97. package/scheduled-runs.mjs +1 -268
  98. package/sdk-admission.mjs +1 -9
  99. package/sdk-smoke.mjs +1 -61
  100. package/serve-consent.mjs +1 -118
  101. package/serve-dir.mjs +1 -40
  102. package/service.mjs +1 -877
  103. package/session-store.mjs +1 -484
  104. package/side-lane.mjs +1 -63
  105. package/supabase-key.mjs +1 -176
  106. package/supervisor-ready.mjs +1 -57
  107. package/switch-provider.mjs +1 -84
  108. package/terminal-name.mjs +1 -359
  109. package/terminal-row-reconcile.mjs +1 -70
  110. package/thinkpool-prompt-contracts.mjs +1 -85
  111. package/thinkpool-room-prompt.mjs +1 -187
  112. package/transcript-sanitize.mjs +1 -332
  113. package/turn-stall.mjs +1 -61
  114. package/update-gate.mjs +1 -53
  115. package/viewport.mjs +1 -810
  116. package/worker-completion.mjs +1 -57
package/viewport.mjs CHANGED
@@ -1,810 +1 @@
1
- // Bridge-owned visual QA for ThinkPool Code lanes.
2
- //
3
- // Agent sandboxes should not need permission to bind localhost or launch a GUI
4
- // process. The bridge already owns a contained static preview server
5
- // (flow-preview.mjs), so this module adds the missing half: an exact-viewport,
6
- // dependency-free Chrome DevTools Protocol capture worker plus per-lane tools.
7
- //
8
- // Security boundary:
9
- // - preview roots must resolve inside the lane's own cwd (symlinks included)
10
- // - the browser only opens the loopback URL created by startPreview()
11
- // - no caller-supplied commands, executables, ports, hosts, or arbitrary URLs
12
-
13
- import { spawn } from 'node:child_process'
14
- import fs from 'node:fs'
15
- import fsp from 'node:fs/promises'
16
- import os from 'node:os'
17
- import path from 'node:path'
18
- import { randomUUID } from 'node:crypto'
19
- import { previews, startPreview } from './flow-preview.mjs'
20
- import {
21
- DESIGN_SOURCE_MAP_RELATIVE,
22
- MAX_DESIGN_SOURCE_MAP_BYTES,
23
- } from './design-source-contract.mjs'
24
-
25
- export const DEFAULT_VIEWPORTS = Object.freeze({
26
- desktop: Object.freeze({ width: 1440, height: 900 }),
27
- mobile: Object.freeze({ width: 390, height: 844 }),
28
- })
29
-
30
- const MAX_CAPTURE_HEIGHT = 20000
31
- const MAX_SETTLE_MS = 5000
32
- const AUTH_READY_TIMEOUT_MS = 15000
33
- const AUTH_FRESH_MARGIN_SEC = 30
34
- const PREVIEW_LOCAL_EXPIRY_EXTENSION_SEC = 3600
35
- const CDP_TIMEOUT_MS = 12000
36
- const MAX_PORTABLE_SNAPSHOT_BYTES = 1_900_000
37
-
38
- const isInside = (parent, child) => {
39
- const rel = path.relative(parent, child)
40
- return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
41
- }
42
-
43
- export async function resolveContainedRoot(workspaceRoot, requested = 'dist') {
44
- if (!workspaceRoot) throw new Error('A lane workspace is required.')
45
- if (path.isAbsolute(requested)) throw new Error('Preview root must be relative to the lane workspace.')
46
- const workspace = await fsp.realpath(path.resolve(workspaceRoot))
47
- const candidate = path.resolve(workspace, requested || 'dist')
48
- let resolved
49
- try { resolved = await fsp.realpath(candidate) }
50
- catch { throw new Error(`Preview root does not exist: ${requested || 'dist'}. Build the app first, or choose a directory containing index.html.`) }
51
- if (!isInside(workspace, resolved)) throw new Error('Preview root escapes the lane workspace.')
52
- const stat = await fsp.stat(resolved)
53
- if (!stat.isDirectory()) throw new Error('Preview root must be a directory.')
54
- if (!fs.existsSync(path.join(resolved, 'index.html'))) throw new Error(`No index.html found in preview root: ${requested || 'dist'}.`)
55
- return resolved
56
- }
57
-
58
- export async function resolvePreviewSourceMap(previewRoot) {
59
- if (!previewRoot) return null
60
- let root, sourceMap
61
- try {
62
- root = await fsp.realpath(previewRoot)
63
- sourceMap = await fsp.realpath(path.join(root, DESIGN_SOURCE_MAP_RELATIVE))
64
- const stat = await fsp.stat(sourceMap)
65
- if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_DESIGN_SOURCE_MAP_BYTES) return null
66
- } catch { return null }
67
- return isInside(root, sourceMap) ? sourceMap : null
68
- }
69
-
70
- export function normalizeRoute(value = '/') {
71
- const route = String(value || '/').trim()
72
- if (!route.startsWith('/') || route.startsWith('//')) throw new Error('Preview path must start with one "/" and cannot be a URL.')
73
- const parsed = new URL(route, 'http://127.0.0.1')
74
- if (parsed.origin !== 'http://127.0.0.1') throw new Error('Preview path cannot change the preview origin.')
75
- return `${parsed.pathname}${parsed.search}${parsed.hash}`
76
- }
77
-
78
- export function safeSlug(value = 'viewport') {
79
- return String(value || 'viewport')
80
- .toLowerCase()
81
- .replace(/[^a-z0-9]+/g, '-')
82
- .replace(/^-+|-+$/g, '')
83
- .slice(0, 56) || 'viewport'
84
- }
85
-
86
- export function isSignedOutRoomPreview(route, snapshot) {
87
- const parsed = new URL(normalizeRoute(route), 'http://127.0.0.1')
88
- if (!parsed.searchParams.get('r')) return false
89
- return /\bid\s*=\s*["']invitation-room-title["']/i.test(String(snapshot || ''))
90
- }
91
-
92
- export function previewArtifactIssue(route, capture) {
93
- const parsed = new URL(normalizeRoute(route), 'http://127.0.0.1')
94
- const routeIdentity = (url) => {
95
- if (typeof url !== 'string' || !url.trim()) return null
96
- try {
97
- const value = new URL(url, 'http://127.0.0.1')
98
- const pathname = value.pathname === '/' ? '/' : value.pathname.replace(/\/+$/, '')
99
- return `${pathname}${value.search}`
100
- } catch { return null }
101
- }
102
- const expectedRoute = routeIdentity(parsed.href)
103
- const protectedRoute = parsed.pathname === '/code' || parsed.pathname.startsWith('/code/') || parsed.searchParams.has('r')
104
- for (const state of [capture?.beforeState, capture?.afterState]) {
105
- if (!state) return 'The captured page did not report settled rendered state.'
106
- const actualRoute = routeIdentity(state.url)
107
- if (actualRoute && actualRoute !== expectedRoute) return `Preview navigated from ${expectedRoute} to ${actualRoute}.`
108
- if (state.signedOutInvite) return 'Preview resolved to the signed-out room invitation instead of the authenticated Code room.'
109
- if (state.visibleBoot) return 'Preview was still showing the application loading shell.'
110
- if (state.visibleLoading) return 'Preview was still showing an application loading state.'
111
- if (protectedRoute && state.prerenderGeo) return 'Preview resolved to the public marketing shell instead of the authenticated Code surface.'
112
- if (!state.hasBody || (state.textLength < 2 && state.meaningfulVisualCount < 1)) return 'Preview rendered an empty page.'
113
- }
114
- return null
115
- }
116
-
117
- export function routeNeedsPreviewAuth(route) {
118
- const parsed = new URL(normalizeRoute(route), 'http://127.0.0.1')
119
- return !!parsed.searchParams.get('r') || ['/settings', '/contacts', '/code/link'].includes(parsed.pathname)
120
- }
121
-
122
- function decodeJwtPayload(token) {
123
- try {
124
- const payload = String(token || '').split('.')[1]
125
- return payload ? JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) : null
126
- } catch { return null }
127
- }
128
-
129
- // Build the minimum Supabase local-storage session needed for a short-lived,
130
- // read-only visual capture. Never expose the bridge refresh token to Chrome.
131
- export function previewAuthBootstrap({ accessToken, supabaseUrl, nowSec = Math.floor(Date.now() / 1000), minTtlSec = 0 } = {}) {
132
- const claims = decodeJwtPayload(accessToken)
133
- let endpoint
134
- try { endpoint = new URL(supabaseUrl) } catch { return null }
135
- if (!accessToken || endpoint.protocol !== 'https:' || !claims?.sub || !claims?.exp || claims.exp - nowSec <= minTtlSec) return null
136
- const projectRef = endpoint.hostname.split('.')[0]
137
- if (!projectRef) return null
138
- return {
139
- storageKey: `sb-${projectRef}-auth-token`,
140
- allowedReadOrigins: [endpoint.origin],
141
- allowedSocketOrigins: [`wss://${endpoint.host}`],
142
- session: {
143
- access_token: accessToken,
144
- refresh_token: '',
145
- // auth-js refreshes 90s early. Extend only its browser-local marker; the
146
- // signed JWT keeps its real expiry and is still server-validated per read.
147
- expires_in: Math.max(1, claims.exp - nowSec) + PREVIEW_LOCAL_EXPIRY_EXTENSION_SEC,
148
- expires_at: claims.exp + PREVIEW_LOCAL_EXPIRY_EXTENSION_SEC,
149
- token_type: 'bearer',
150
- user: {
151
- id: claims.sub,
152
- aud: claims.aud || 'authenticated',
153
- role: claims.role || 'authenticated',
154
- email: claims.email || null,
155
- phone: claims.phone || '',
156
- app_metadata: claims.app_metadata || {},
157
- user_metadata: claims.user_metadata || {},
158
- identities: [],
159
- created_at: claims.created_at || new Date(nowSec * 1000).toISOString(),
160
- updated_at: new Date(nowSec * 1000).toISOString(),
161
- },
162
- },
163
- }
164
- }
165
-
166
- export function findBrowserExecutable({ env = process.env, platform = process.platform, exists = fs.existsSync } = {}) {
167
- const candidates = [
168
- env.TP_BROWSER_PATH,
169
- platform === 'darwin' && '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
170
- platform === 'darwin' && '/Applications/Chromium.app/Contents/MacOS/Chromium',
171
- platform === 'win32' && env.PROGRAMFILES && path.join(env.PROGRAMFILES, 'Google', 'Chrome', 'Application', 'chrome.exe'),
172
- platform === 'win32' && env['PROGRAMFILES(X86)'] && path.join(env['PROGRAMFILES(X86)'], 'Google', 'Chrome', 'Application', 'chrome.exe'),
173
- platform === 'linux' && '/usr/bin/google-chrome',
174
- platform === 'linux' && '/usr/bin/google-chrome-stable',
175
- platform === 'linux' && '/usr/bin/chromium',
176
- platform === 'linux' && '/usr/bin/chromium-browser',
177
- ].filter(Boolean)
178
- return candidates.find((candidate) => {
179
- try { return exists(candidate) } catch { return false }
180
- }) || null
181
- }
182
-
183
- class CdpPipe {
184
- constructor(child, timeoutMs = CDP_TIMEOUT_MS) {
185
- this.child = child
186
- this.timeoutMs = timeoutMs
187
- this.nextId = 1
188
- this.buffer = Buffer.alloc(0)
189
- this.pending = new Map()
190
- this.waiters = new Set()
191
- this.subscribers = new Set()
192
- child.stdio[4].on('data', (chunk) => this.onData(chunk))
193
- child.once('exit', (code, signal) => this.failAll(new Error(`Chrome exited (${signal || code || 'unknown'}).`)))
194
- child.once('error', (error) => this.failAll(error))
195
- }
196
-
197
- onData(chunk) {
198
- this.buffer = Buffer.concat([this.buffer, chunk])
199
- for (;;) {
200
- const end = this.buffer.indexOf(0)
201
- if (end < 0) break
202
- const raw = this.buffer.subarray(0, end).toString('utf8')
203
- this.buffer = this.buffer.subarray(end + 1)
204
- if (!raw) continue
205
- let message
206
- try { message = JSON.parse(raw) } catch { continue }
207
- if (message.id) {
208
- const pending = this.pending.get(message.id)
209
- if (!pending) continue
210
- clearTimeout(pending.timer)
211
- this.pending.delete(message.id)
212
- if (message.error) pending.reject(new Error(message.error.message || 'Chrome DevTools error'))
213
- else pending.resolve(message.result || {})
214
- continue
215
- }
216
- for (const waiter of [...this.waiters]) {
217
- if (waiter.method !== message.method) continue
218
- if (waiter.sessionId && waiter.sessionId !== message.sessionId) continue
219
- if (waiter.predicate && !waiter.predicate(message.params || {})) continue
220
- clearTimeout(waiter.timer)
221
- this.waiters.delete(waiter)
222
- waiter.resolve(message.params || {})
223
- }
224
- for (const subscriber of [...this.subscribers]) {
225
- if (subscriber.method !== message.method) continue
226
- if (subscriber.sessionId && subscriber.sessionId !== message.sessionId) continue
227
- try { subscriber.handler(message.params || {}) } catch { /* subscriber owns recovery */ }
228
- }
229
- }
230
- }
231
-
232
- failAll(error) {
233
- for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error) }
234
- this.pending.clear()
235
- for (const waiter of this.waiters) { clearTimeout(waiter.timer); waiter.reject(error) }
236
- this.waiters.clear()
237
- this.subscribers.clear()
238
- }
239
-
240
- send(method, params = {}, sessionId) {
241
- const id = this.nextId++
242
- return new Promise((resolve, reject) => {
243
- const timer = setTimeout(() => {
244
- this.pending.delete(id)
245
- reject(new Error(`Chrome timed out running ${method}.`))
246
- }, this.timeoutMs)
247
- this.pending.set(id, { resolve, reject, timer })
248
- const message = { id, method, params, ...(sessionId ? { sessionId } : {}) }
249
- this.child.stdio[3].write(`${JSON.stringify(message)}\0`)
250
- })
251
- }
252
-
253
- waitFor(method, sessionId, predicate) {
254
- return new Promise((resolve, reject) => {
255
- const waiter = { method, sessionId, predicate, resolve, reject, timer: null }
256
- waiter.timer = setTimeout(() => {
257
- this.waiters.delete(waiter)
258
- reject(new Error(`Chrome timed out waiting for ${method}.`))
259
- }, this.timeoutMs)
260
- this.waiters.add(waiter)
261
- })
262
- }
263
-
264
- subscribe(method, sessionId, handler) {
265
- const subscriber = { method, sessionId, handler }
266
- this.subscribers.add(subscriber)
267
- return () => this.subscribers.delete(subscriber)
268
- }
269
- }
270
-
271
- export class CdpBrowser {
272
- constructor({ executable, spawnImpl = spawn, timeoutMs = CDP_TIMEOUT_MS } = {}) {
273
- this.executable = executable || null
274
- this.spawnImpl = spawnImpl
275
- this.timeoutMs = timeoutMs
276
- this.child = null
277
- this.pipe = null
278
- this.profileDir = null
279
- this.starting = null
280
- this.stderr = ''
281
- }
282
-
283
- async ensureStarted() {
284
- if (this.child && this.pipe) return
285
- if (this.starting) return this.starting
286
- this.starting = this.start().finally(() => { this.starting = null })
287
- return this.starting
288
- }
289
-
290
- async start() {
291
- const executable = this.executable || findBrowserExecutable()
292
- if (!executable) throw new Error('No supported Chrome/Chromium executable found. Install Google Chrome or set TP_BROWSER_PATH on the bridge host.')
293
- this.profileDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'thinkpool-viewport-'))
294
- const args = [
295
- '--headless=new', '--remote-debugging-pipe', '--no-first-run', '--no-default-browser-check',
296
- '--disable-background-networking', '--disable-component-update', '--disable-sync',
297
- '--disable-extensions', '--disable-default-apps',
298
- '--metrics-recording-only', '--mute-audio', '--hide-scrollbars',
299
- `--user-data-dir=${this.profileDir}`, 'about:blank',
300
- ]
301
- const child = this.spawnImpl(executable, args, { stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'] })
302
- this.child = child
303
- child.stderr?.on('data', (chunk) => { this.stderr = (this.stderr + chunk.toString('utf8')).slice(-4000) })
304
- this.pipe = new CdpPipe(child, this.timeoutMs)
305
- try { await this.pipe.send('Browser.getVersion') }
306
- catch (error) {
307
- await this.close()
308
- const detail = this.stderr.trim().split('\n').slice(-2).join(' ')
309
- throw new Error(`Could not start Chrome for viewport capture: ${detail || error.message}`)
310
- }
311
- }
312
-
313
- async withPage({ url, viewport, waitMs = 300, auth = null }, fn) {
314
- await this.ensureStarted()
315
- const { targetId } = await this.pipe.send('Target.createTarget', { url: 'about:blank' })
316
- const { sessionId } = await this.pipe.send('Target.attachToTarget', { targetId, flatten: true })
317
- let unsubscribeRequests = null
318
- let unsubscribeResponses = null
319
- let unsubscribeExceptions = null
320
- const authResponses = []
321
- const localResponses = []
322
- const runtimeExceptions = []
323
- try {
324
- await this.pipe.send('Page.enable', {}, sessionId)
325
- await this.pipe.send('Runtime.enable', {}, sessionId)
326
- await this.pipe.send('Network.enable', {}, sessionId).catch(() => {})
327
- if (auth?.storageKey && auth?.session) {
328
- const source = `(() => { try { localStorage.setItem(${JSON.stringify(auth.storageKey)}, ${JSON.stringify(JSON.stringify(auth.session))}); window.__TP_READ_ONLY_PREVIEW__ = true; } catch {} })()`
329
- await this.pipe.send('Page.addScriptToEvaluateOnNewDocument', { source }, sessionId)
330
- }
331
- // The page is untrusted build output. Keep its network authority narrower
332
- // than the lane sandbox: same preview origin plus read-only requests to
333
- // the exact Supabase origin provided by the bridge auth context.
334
- const allowedOrigin = new URL(url).origin
335
- const allowedReadOrigins = new Set(auth?.allowedReadOrigins || [])
336
- const allowedSocketOrigins = new Set(auth?.allowedSocketOrigins || [])
337
- unsubscribeResponses = this.pipe.subscribe('Network.responseReceived', sessionId, (params) => {
338
- try {
339
- const responseUrl = new URL(params.response?.url || '')
340
- if (allowedReadOrigins.has(responseUrl.origin) && authResponses.length < 20) authResponses.push(`${params.response?.status || 0}:${responseUrl.pathname}`)
341
- if (responseUrl.origin === allowedOrigin && localResponses.length < 20) localResponses.push(`${params.response?.status || 0}:${responseUrl.pathname}`)
342
- } catch { /* diagnostics only */ }
343
- })
344
- unsubscribeExceptions = this.pipe.subscribe('Runtime.exceptionThrown', sessionId, (params) => {
345
- if (runtimeExceptions.length >= 5) return
346
- const detail = params.exceptionDetails?.exception?.description || params.exceptionDetails?.text || 'runtime error'
347
- runtimeExceptions.push(String(detail).split('\n')[0].slice(0, 180))
348
- })
349
- unsubscribeRequests = this.pipe.subscribe('Fetch.requestPaused', sessionId, (params) => {
350
- let allowed = false
351
- try {
352
- const requestUrl = new URL(params.request?.url || '')
353
- const requestMethod = String(params.request?.method || 'GET').toUpperCase()
354
- allowed = requestUrl.origin === allowedOrigin ||
355
- (allowedReadOrigins.has(requestUrl.origin) && ['GET', 'HEAD', 'OPTIONS'].includes(requestMethod)) ||
356
- (allowedSocketOrigins.has(requestUrl.origin) && requestMethod === 'GET')
357
- } catch { allowed = false }
358
- const method = allowed ? 'Fetch.continueRequest' : 'Fetch.failRequest'
359
- const request = allowed
360
- ? { requestId: params.requestId }
361
- : { requestId: params.requestId, errorReason: 'BlockedByClient' }
362
- void this.pipe.send(method, request, sessionId).catch(() => {})
363
- })
364
- await this.pipe.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] }, sessionId)
365
- await this.pipe.send('Network.setBypassServiceWorker', { bypass: true }, sessionId).catch(() => {})
366
- await this.pipe.send('Emulation.setDeviceMetricsOverride', {
367
- width: viewport.width, height: viewport.height, deviceScaleFactor: 1, mobile: false,
368
- screenWidth: viewport.width, screenHeight: viewport.height,
369
- }, sessionId)
370
- const loaded = this.pipe.waitFor('Page.loadEventFired', sessionId)
371
- const nav = await this.pipe.send('Page.navigate', { url }, sessionId)
372
- if (nav.errorText) throw new Error(`Preview navigation failed: ${nav.errorText}`)
373
- await loaded
374
- await this.pipe.send('Runtime.evaluate', {
375
- expression: 'document.fonts && document.fonts.ready', awaitPromise: true, returnByValue: true,
376
- }, sessionId).catch(() => {})
377
- if (auth) {
378
- const deadline = Date.now() + AUTH_READY_TIMEOUT_MS
379
- let ready = false
380
- let readySince = 0
381
- while (Date.now() < deadline) {
382
- const state = await this.pipe.send('Runtime.evaluate', {
383
- expression: `(() => {
384
- if (document.querySelector('#invitation-room-title')) return true;
385
- if (document.querySelector('#boot, .boot-loader')) return false;
386
- const room = document.querySelector('[data-tp-preview-room-status]');
387
- if (room?.dataset.tpPreviewRoomStatus === 'pending') return false;
388
- const loading = [...document.querySelectorAll('span')].some((node) => /^(loading session|restoring terminals…|connecting(?:…|\.\.\.))$/i.test((node.textContent || '').trim()));
389
- return !loading && (document.body?.innerText || '').trim().length > 1;
390
- })()`,
391
- returnByValue: true,
392
- }, sessionId)
393
- if (state.result?.value === true) {
394
- if (!readySince) readySince = Date.now()
395
- if (Date.now() - readySince >= 800) { ready = true; break }
396
- } else readySince = 0
397
- await new Promise((resolve) => setTimeout(resolve, 100))
398
- }
399
- if (!ready) {
400
- const diagnostic = await this.pipe.send('Runtime.evaluate', {
401
- expression: `(async () => {
402
- let locks = { held: [], pending: [] };
403
- try { locks = await navigator.locks.query(); } catch {}
404
- return {
405
- sessionPresent: !!localStorage.getItem(${JSON.stringify(auth.storageKey || '')}),
406
- inlineBoot: !!document.querySelector('#boot, .boot-loader'),
407
- loadingSession: [...document.querySelectorAll('span')].some((node) => (node.textContent || '').trim() === 'Loading session'),
408
- connecting: [...document.querySelectorAll('span')].some((node) => (node.textContent || '').trim().toLowerCase() === 'connecting…'),
409
- invitation: !!document.querySelector('#invitation-room-title'),
410
- roomStatus: document.querySelector('[data-tp-preview-room-status]')?.dataset.tpPreviewRoomStatus || null,
411
- heldLocks: locks.held?.length || 0,
412
- pendingLocks: locks.pending?.length || 0,
413
- };
414
- })()`,
415
- awaitPromise: true,
416
- returnByValue: true,
417
- }, sessionId).catch(() => ({ result: { value: {} } }))
418
- const d = diagnostic.result?.value || {}
419
- throw new Error(`Authenticated preview did not leave its loading state within 15 seconds (session=${d.sessionPresent ? 'present' : 'missing'}, inlineBoot=${d.inlineBoot ? 'yes' : 'no'}, loadingSession=${d.loadingSession ? 'yes' : 'no'}, connecting=${d.connecting ? 'yes' : 'no'}, roomStatus=${d.roomStatus || 'none'}, authLocks=${d.heldLocks || 0}/${d.pendingLocks || 0}, invitation=${d.invitation ? 'yes' : 'no'}, reads=${authResponses.join(',') || 'none'}, local=${localResponses.join(',') || 'none'}, runtime=${runtimeExceptions.join(' | ') || 'none'}); no screenshot was created.`)
420
- }
421
- }
422
- const settle = auth
423
- ? Math.min(300, Math.max(0, Number(waitMs) || 0))
424
- : Math.min(MAX_SETTLE_MS, Math.max(0, Number(waitMs) || 0))
425
- if (settle) await new Promise((resolve) => setTimeout(resolve, settle))
426
- return await fn({ pipe: this.pipe, sessionId })
427
- } finally {
428
- unsubscribeRequests?.()
429
- unsubscribeResponses?.()
430
- unsubscribeExceptions?.()
431
- await this.pipe.send('Target.closeTarget', { targetId }).catch(() => {})
432
- }
433
- }
434
-
435
- async renderedState(pipe, sessionId) {
436
- const expression = `(() => {
437
- const visible = (node) => {
438
- if (!node) return false;
439
- const style = getComputedStyle(node);
440
- const rect = node.getBoundingClientRect();
441
- return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity || 1) > 0 && rect.width > 0 && rect.height > 0;
442
- };
443
- const boot = document.querySelector('#boot');
444
- const meaningful = [...document.querySelectorAll('main,article,section,img,video,canvas,svg,[role="dialog"],[role="main"],[role="img"]')]
445
- .filter((node) => !node.closest('#boot') && visible(node));
446
- return {
447
- title: document.title || '', url: location.href,
448
- hasBody: !!document.body,
449
- textLength: (document.body?.innerText || '').trim().length,
450
- meaningfulVisualCount: meaningful.length,
451
- visibleBoot: visible(boot),
452
- visibleLoading: [...document.querySelectorAll('span')].some((node) => {
453
- const text = (node.textContent || '').trim();
454
- return visible(node) && /^(loading session|restoring terminals…|connecting(?:…|\.\.\.))$/i.test(text);
455
- }),
456
- signedOutInvite: !!document.querySelector('#invitation-room-title'),
457
- prerenderGeo: !!document.querySelector('[data-prerender="geo"]'),
458
- };
459
- })()`
460
- const result = await pipe.send('Runtime.evaluate', { expression, returnByValue: true }, sessionId)
461
- if (result.exceptionDetails) throw new Error('Could not inspect the rendered preview state.')
462
- return result.result?.value || null
463
- }
464
-
465
- async freezeCurrentPage(pipe, sessionId) {
466
- // Freeze the rendered DOM, not the build entry document. A Vite/React entry
467
- // depends on root-relative chunks and often boots to an empty shell in a
468
- // sandboxed srcDoc; the settled DOM plus same-origin CSS/assets is portable.
469
- const expression = `(async () => {
470
- const clone = document.documentElement.cloneNode(true);
471
- clone.querySelectorAll('script,link[rel="modulepreload"],link[rel="preload"]').forEach((node) => node.remove());
472
- clone.querySelectorAll('*').forEach((node) => {
473
- for (const attr of [...node.attributes]) if (/^on/i.test(attr.name)) node.removeAttribute(attr.name);
474
- if (node instanceof HTMLInputElement) node.removeAttribute('value');
475
- if (node instanceof HTMLTextAreaElement) node.textContent = '';
476
- });
477
- const css = [];
478
- for (const sheet of [...document.styleSheets]) {
479
- try { css.push([...sheet.cssRules].map((rule) => rule.cssText).join('\\n')); } catch {}
480
- }
481
- clone.querySelectorAll('style,link[rel="stylesheet"]').forEach((node) => node.remove());
482
- const head = clone.querySelector('head') || clone.insertBefore(document.createElement('head'), clone.firstChild);
483
- const style = document.createElement('style');
484
- style.setAttribute('data-thinkpool-snapshot', '');
485
- style.textContent = css.join('\\n');
486
- head.appendChild(style);
487
-
488
- const assetUrls = new Set();
489
- const remember = (raw) => {
490
- if (!raw || /^(data:|blob:|#)/i.test(raw)) return;
491
- try {
492
- const absolute = new URL(raw, location.href);
493
- if (absolute.origin === location.origin) assetUrls.add(absolute.href);
494
- } catch {}
495
- };
496
- clone.querySelectorAll('[src],[poster]').forEach((node) => {
497
- remember(node.getAttribute('src'));
498
- remember(node.getAttribute('poster'));
499
- });
500
- for (const match of style.textContent.matchAll(/url\\(\\s*(['"]?)([^'"\\)]+)\\1\\s*\\)/gi)) remember(match[2]);
501
-
502
- const replacements = new Map();
503
- let inlinedBytes = 0;
504
- for (const absolute of assetUrls) {
505
- try {
506
- const response = await fetch(absolute);
507
- const buffer = await response.arrayBuffer();
508
- if (!response.ok || buffer.byteLength > 512000 || inlinedBytes + buffer.byteLength > 1000000) continue;
509
- const bytes = new Uint8Array(buffer);
510
- let binary = '';
511
- for (let offset = 0; offset < bytes.length; offset += 0x8000) binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
512
- replacements.set(absolute, 'data:' + (response.headers.get('content-type') || 'application/octet-stream') + ';base64,' + btoa(binary));
513
- inlinedBytes += buffer.byteLength;
514
- } catch {}
515
- }
516
- const replaceAsset = (raw) => {
517
- try { return replacements.get(new URL(raw, location.href).href) || raw; } catch { return raw; }
518
- };
519
- clone.querySelectorAll('[src],[poster]').forEach((node) => {
520
- for (const name of ['src', 'poster']) if (node.hasAttribute(name)) node.setAttribute(name, replaceAsset(node.getAttribute(name)));
521
- node.removeAttribute('srcset');
522
- });
523
- style.textContent = style.textContent.replace(/url\\(\\s*(['"]?)([^'"\\)]+)\\1\\s*\\)/gi, (_all, quote, raw) => 'url("' + replaceAsset(raw) + '")');
524
- return '<!doctype html>\\n' + clone.outerHTML;
525
- })()`
526
- const result = await pipe.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, sessionId)
527
- if (result.exceptionDetails || typeof result.result?.value !== 'string') {
528
- const detail = result.exceptionDetails?.exception?.description || result.exceptionDetails?.text || result.result?.description
529
- throw new Error(`Could not freeze the rendered preview for Design${detail ? `: ${detail}` : '.'}`)
530
- }
531
- const html = result.result.value
532
- if (!html.trim() || Buffer.byteLength(html) > MAX_PORTABLE_SNAPSHOT_BYTES) throw new Error('The rendered preview is too large to make editable.')
533
- return html
534
- }
535
-
536
- async capture({ url, viewport, fullPage = true, waitMs = 300, includeSnapshot = false, auth = null }) {
537
- return this.withPage({ url, viewport, waitMs, auth }, async ({ pipe, sessionId }) => {
538
- const beforeState = await this.renderedState(pipe, sessionId)
539
- const metrics = await pipe.send('Page.getLayoutMetrics', {}, sessionId)
540
- const contentHeight = Math.ceil(metrics.cssContentSize?.height || viewport.height)
541
- const height = fullPage ? Math.min(MAX_CAPTURE_HEIGHT, Math.max(viewport.height, contentHeight)) : viewport.height
542
- const result = await pipe.send('Page.captureScreenshot', {
543
- format: 'png', fromSurface: true, captureBeyondViewport: true,
544
- clip: { x: 0, y: 0, width: viewport.width, height, scale: 1 },
545
- }, sessionId)
546
- const afterState = await this.renderedState(pipe, sessionId)
547
- const snapshot = includeSnapshot ? await this.freezeCurrentPage(pipe, sessionId) : null
548
- return { png: Buffer.from(result.data, 'base64'), width: viewport.width, height, contentHeight, capped: contentHeight > MAX_CAPTURE_HEIGHT, beforeState, afterState, snapshot }
549
- })
550
- }
551
-
552
- async snapshot({ url, viewport = DEFAULT_VIEWPORTS.desktop, waitMs = 300, auth = null }) {
553
- return this.withPage({ url, viewport, waitMs, auth }, ({ pipe, sessionId }) => this.freezeCurrentPage(pipe, sessionId))
554
- }
555
-
556
- async inspect({ url, viewport = DEFAULT_VIEWPORTS.mobile, selector, waitMs = 300, auth = null }) {
557
- return this.withPage({ url, viewport, waitMs, auth }, async ({ pipe, sessionId }) => {
558
- const expression = `(() => {
559
- const selector = ${JSON.stringify(selector || null)};
560
- const el = selector ? document.querySelector(selector) : null;
561
- const r = el && el.getBoundingClientRect();
562
- return {
563
- title: document.title, url: location.href,
564
- viewport: { width: innerWidth, height: innerHeight, dpr: devicePixelRatio },
565
- document: { width: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth || 0), height: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight || 0) },
566
- selector: selector ? { query: selector, found: !!el, tag: el?.tagName || null, text: el?.innerText?.slice(0, 2000) || '', rect: r ? { x:r.x, y:r.y, width:r.width, height:r.height } : null } : null,
567
- text: document.body?.innerText?.slice(0, 12000) || ''
568
- };
569
- })()`
570
- const result = await pipe.send('Runtime.evaluate', { expression, returnByValue: true }, sessionId)
571
- if (result.exceptionDetails) throw new Error('DOM inspection failed in the preview page.')
572
- return result.result?.value || {}
573
- })
574
- }
575
-
576
- async close() {
577
- const child = this.child
578
- const pipe = this.pipe
579
- this.child = null
580
- this.pipe = null
581
- if (pipe) await pipe.send('Browser.close').catch(() => {})
582
- if (child && child.exitCode == null) { try { child.kill('SIGTERM') } catch { /* already gone */ } }
583
- if (this.profileDir) await fsp.rm(this.profileDir, { recursive: true, force: true }).catch(() => {})
584
- this.profileDir = null
585
- }
586
- }
587
-
588
- export const sharedViewportBrowser = new CdpBrowser()
589
-
590
- export class ViewportManager {
591
- constructor({ workspaceRoot, ownerId, roomCode, outbox, browser = sharedViewportBrowser, startPreviewImpl = startPreview, designContext = null, authContext = null } = {}) {
592
- this.workspaceRoot = path.resolve(workspaceRoot || process.cwd())
593
- this.ownerId = ownerId || randomUUID()
594
- this.roomCode = typeof roomCode === 'string' && roomCode.trim() ? roomCode.trim() : null
595
- this.outbox = outbox || path.join(os.tmpdir(), 'thinkpool-viewport-captures', this.ownerId)
596
- this.browser = browser
597
- this.startPreviewImpl = startPreviewImpl
598
- this.designContext = typeof designContext === 'function' ? designContext : () => null
599
- this.authContext = typeof authContext === 'function' ? authContext : () => null
600
- this.previewId = `viewport:${this.ownerId}`
601
- this.preview = null
602
- this.root = null
603
- }
604
-
605
- setWorkspaceRoot(workspaceRoot) {
606
- if (!workspaceRoot) throw new Error('A lane workspace is required.')
607
- this.workspaceRoot = path.resolve(workspaceRoot)
608
- return this.workspaceRoot
609
- }
610
-
611
- async start({ root = 'dist' } = {}) {
612
- const resolved = await resolveContainedRoot(this.workspaceRoot, root)
613
- await this.stop()
614
- const preview = await this.startPreviewImpl({ dir: resolved, host: '127.0.0.1', port: 0, id: this.previewId })
615
- const parsed = new URL(preview.url)
616
- if (parsed.hostname !== '127.0.0.1' && parsed.hostname !== 'localhost') {
617
- await preview.stop?.()
618
- throw new Error('Preview server did not bind to loopback.')
619
- }
620
- this.preview = preview
621
- this.root = resolved
622
- return { url: preview.url, root: resolved }
623
- }
624
-
625
- requirePreview() {
626
- if (!this.preview?.url) throw new Error('No preview is running. Call preview_start after building the app.')
627
- return this.preview
628
- }
629
-
630
- defaultRoute() {
631
- return this.roomCode ? `/?r=${encodeURIComponent(this.roomCode)}` : '/'
632
- }
633
-
634
- resolveRoute(route) {
635
- return normalizeRoute(route ?? this.defaultRoute())
636
- }
637
-
638
- pageUrl(route) {
639
- const preview = this.requirePreview()
640
- return new URL(normalizeRoute(route), preview.url).href
641
- }
642
-
643
- async previewAuth(route) {
644
- if (!routeNeedsPreviewAuth(route)) return null
645
- let context = this.authContext() || {}
646
- if (!context.accessToken) return null
647
- const deadline = Date.now() + AUTH_READY_TIMEOUT_MS
648
- for (;;) {
649
- const auth = previewAuthBootstrap({ ...context, minTtlSec: AUTH_FRESH_MARGIN_SEC })
650
- if (auth) return auth
651
- if (Date.now() >= deadline) throw new Error('The bridge account token did not refresh in time for an authenticated preview; no screenshot was created.')
652
- await new Promise((resolve) => setTimeout(resolve, 250))
653
- context = this.authContext() || {}
654
- }
655
- }
656
-
657
- async capture({ route, viewports = 'both', title = 'Viewport capture', fullPage = true, waitMs = 300, card = false } = {}) {
658
- const normalizedRoute = this.resolveRoute(route)
659
- const url = this.pageUrl(normalizedRoute)
660
- const names = viewports === 'both' ? ['desktop', 'mobile'] : [viewports]
661
- if (names.some((name) => !DEFAULT_VIEWPORTS[name])) throw new Error('viewports must be "both", "desktop", or "mobile".')
662
- const slug = `${safeSlug(title)}-${Date.now().toString(36)}`
663
- const workspace = await fsp.realpath(this.workspaceRoot)
664
- const previewRoot = path.relative(workspace, this.root) || '.'
665
- const captureKey = JSON.stringify([previewRoot, normalizedRoute])
666
- const active = this.designContext() || null
667
- const correlation = active?.captureKey === captureKey
668
- ? { designRequestId: active.requestId, parentRevision: active.parentRevision }
669
- : {}
670
- // A correlated Design revision is already an explicit room deliverable;
671
- // ordinary verification still needs the caller to opt in with card=true.
672
- const cardRequested = card === true || !!correlation.designRequestId
673
- const captures = {}
674
- for (const name of names) {
675
- captures[name] = await this.browser.capture({
676
- url, viewport: DEFAULT_VIEWPORTS[name], fullPage, waitMs,
677
- includeSnapshot: cardRequested && name === (names.includes('desktop') ? 'desktop' : names[0]),
678
- auth: await this.previewAuth(normalizedRoute),
679
- })
680
- }
681
- // The screenshot and readiness state must come from the SAME page. The old
682
- // preflight loaded one page and then opened fresh pages for each PNG;
683
- // production proved the preflight could see marketing content while the
684
- // screenshot caught only the boot spinner.
685
- const cardReady = cardRequested && !!(captures.desktop && captures.mobile)
686
- if (cardReady || routeNeedsPreviewAuth(normalizedRoute)) {
687
- for (const [name, capture] of Object.entries(captures)) {
688
- const issue = previewArtifactIssue(normalizedRoute, capture)
689
- if (issue) throw new Error(`${name} preview rejected: ${issue} No screenshot or mockup card was created.`)
690
- }
691
- }
692
- const snapshot = captures.desktop?.snapshot || captures.mobile?.snapshot || null
693
- if (cardReady && (typeof snapshot !== 'string' || !snapshot.trim())) {
694
- throw new Error('The deliverable could not freeze its rendered source. No mockup card was created.')
695
- }
696
- await fsp.mkdir(this.outbox, { recursive: true })
697
- for (const [name, capture] of Object.entries(captures)) {
698
- capture.file = path.join(this.outbox, `${slug}--${name}.png`)
699
- await fsp.writeFile(capture.file, capture.png)
700
- }
701
- const snapshotPath = cardReady ? path.join(this.outbox, `${slug}--source.html`) : null
702
- if (snapshotPath) await fsp.writeFile(snapshotPath, snapshot)
703
- let manifestPath = null
704
- if (cardReady) {
705
- const sourceMap = await resolvePreviewSourceMap(this.root)
706
- const manifest = {
707
- slug, title: String(title || 'Viewport capture').slice(0, 120),
708
- desktop: captures.desktop.file, mobile: captures.mobile.file, ts: Date.now(),
709
- sourceKind: 'preview', deliveryIntent: 'card', readinessValidated: true,
710
- snapshot: snapshotPath, previewRoot, route: normalizedRoute, captureKey,
711
- ...(sourceMap ? { sourceMap } : {}),
712
- ...correlation,
713
- }
714
- manifestPath = path.join(this.outbox, `${slug}.json`)
715
- const tmp = `${manifestPath}.tmp`
716
- await fsp.writeFile(tmp, JSON.stringify(manifest))
717
- await fsp.rename(tmp, manifestPath)
718
- }
719
- return { url, route: normalizedRoute, slug, manifestPath, captures, snapshotPath, cardReady }
720
- }
721
-
722
- async inspect({ route, viewport = 'mobile', selector, waitMs = 300 } = {}) {
723
- if (!DEFAULT_VIEWPORTS[viewport]) throw new Error('viewport must be "desktop" or "mobile".')
724
- const normalizedRoute = this.resolveRoute(route)
725
- return this.browser.inspect({
726
- url: this.pageUrl(normalizedRoute), viewport: DEFAULT_VIEWPORTS[viewport], selector, waitMs,
727
- auth: await this.previewAuth(normalizedRoute),
728
- })
729
- }
730
-
731
- async stop() {
732
- const preview = this.preview || previews.get(this.previewId)
733
- this.preview = null
734
- this.root = null
735
- if (preview?.stop) await preview.stop()
736
- }
737
- }
738
-
739
- const textResult = (text) => ({ content: [{ type: 'text', text }] })
740
- const errorResult = (error) => textResult(`Viewport error: ${error?.message || error}`)
741
-
742
- export function createViewportTools({ tool, z, manager }) {
743
- return [
744
- tool(
745
- 'preview_start',
746
- 'Start a bridge-owned, read-only localhost preview for built files inside this lane workspace. Run the project build first. The default root is dist; pass another relative directory only when its index.html is the intended preview.',
747
- { root: z.string().max(240).optional().describe('relative directory inside this lane workspace; default: dist') },
748
- async (args) => {
749
- try {
750
- const started = await manager.start({ root: args?.root || 'dist' })
751
- return textResult(`Preview ready from ${path.relative(manager.workspaceRoot, started.root) || '.'}. Use preview_capture to see desktop/mobile, or preview_inspect for DOM text and geometry.`)
752
- } catch (error) { return errorResult(error) }
753
- },
754
- ),
755
- tool(
756
- 'preview_capture',
757
- 'Capture this lane preview in a fresh isolated browser at exact desktop (1440×900) and mobile (390×844) CSS viewports. Omit path to preview the current authenticated room; pass / explicitly for the public landing page. Defaults to both and full-page. Captures are inline verification evidence by default. Room and protected routes automatically use the bridge account as a read-only authenticated visual harness; external REST writes, presence, and room broadcasts are blocked, apart from non-mutating roster/transcript snapshot requests. Set card=true only for a user-requested mockup or visual deliverable that should remain editable in the transcript; complete desktop+mobile settled content is then required and the card arrives after the final response.',
758
- {
759
- path: z.string().max(500).optional().describe('route within the preview; omit for the current authenticated room, or pass / for the public landing page; never a full URL'),
760
- viewports: z.enum(['both', 'desktop', 'mobile']).optional(),
761
- title: z.string().max(120).optional(),
762
- fullPage: z.boolean().optional(),
763
- card: z.boolean().optional().describe('default false; true only for an intentional user-facing mockup/Design deliverable'),
764
- waitMs: z.number().int().min(0).max(MAX_SETTLE_MS).optional().describe('settle time after load, max 5000ms'),
765
- },
766
- async (args) => {
767
- try {
768
- const result = await manager.capture({
769
- route: args?.path, viewports: args?.viewports || 'both', title: args?.title || 'Viewport capture',
770
- fullPage: args?.fullPage !== false, waitMs: args?.waitMs ?? 300, card: args?.card === true,
771
- })
772
- const delivery = result.cardReady
773
- ? 'The complete room preview card is queued after your final response.'
774
- : 'This is verification evidence only; no room card was created.'
775
- const content = [{ type: 'text', text: `Captured ${Object.keys(result.captures).join(' + ')} for ${result.route}. ${delivery}` }]
776
- for (const [name, capture] of Object.entries(result.captures)) {
777
- content.push({ type: 'text', text: `${name}: ${capture.width}×${capture.height}${capture.capped ? ' (height capped)' : ''}` })
778
- content.push({ type: 'image', data: capture.png.toString('base64'), mimeType: 'image/png' })
779
- }
780
- return { content }
781
- } catch (error) { return errorResult(error) }
782
- },
783
- ),
784
- tool(
785
- 'preview_inspect',
786
- 'Inspect rendered DOM state in this lane preview at an exact desktop or mobile viewport. Omit path for the current authenticated room; pass / explicitly for the public landing page. Returns page text, document dimensions, and optional selector text/geometry without changing the page.',
787
- {
788
- path: z.string().max(500).optional().describe('route within the preview; omit for the current authenticated room, or pass / for the public landing page; never a full URL'),
789
- viewport: z.enum(['desktop', 'mobile']).optional(),
790
- selector: z.string().max(500).optional(),
791
- waitMs: z.number().int().min(0).max(MAX_SETTLE_MS).optional(),
792
- },
793
- async (args) => {
794
- try {
795
- const result = await manager.inspect({ route: args?.path, viewport: args?.viewport || 'mobile', selector: args?.selector, waitMs: args?.waitMs ?? 300 })
796
- return textResult(JSON.stringify(result, null, 2))
797
- } catch (error) { return errorResult(error) }
798
- },
799
- ),
800
- tool(
801
- 'preview_stop',
802
- 'Stop this lane\'s bridge-owned preview server and release its port.',
803
- {},
804
- async () => {
805
- try { await manager.stop(); return textResult('Preview stopped.') }
806
- catch (error) { return errorResult(error) }
807
- },
808
- ),
809
- ]
810
- }
1
+ import{spawn as e}from"node:child_process";import t from"node:fs";import r from"node:fs/promises";import o from"node:os";import n from"node:path";import{randomUUID as i}from"node:crypto";import{previews as s,startPreview as a}from"./flow-preview.mjs";import{DESIGN_SOURCE_MAP_RELATIVE as l,MAX_DESIGN_SOURCE_MAP_BYTES as c}from"./design-source-contract.mjs";export const DEFAULT_VIEWPORTS=Object.freeze({desktop:Object.freeze({width:1440,height:900}),mobile:Object.freeze({width:390,height:844})});const u=5e3,h=(e,t)=>{const r=n.relative(e,t);return""===r||!r.startsWith("..")&&!n.isAbsolute(r)};export async function resolveContainedRoot(e,o="dist"){if(!e)throw new Error("A lane workspace is required.");if(n.isAbsolute(o))throw new Error("Preview root must be relative to the lane workspace.");const i=await r.realpath(n.resolve(e)),s=n.resolve(i,o||"dist");let a;try{a=await r.realpath(s)}catch{throw new Error(`Preview root does not exist: ${o||"dist"}. Build the app first, or choose a directory containing index.html.`)}if(!h(i,a))throw new Error("Preview root escapes the lane workspace.");if(!(await r.stat(a)).isDirectory())throw new Error("Preview root must be a directory.");if(!t.existsSync(n.join(a,"index.html")))throw new Error(`No index.html found in preview root: ${o||"dist"}.`);return a}export async function resolvePreviewSourceMap(e){if(!e)return null;let t,o;try{t=await r.realpath(e),o=await r.realpath(n.join(t,l));const i=await r.stat(o);if(!i.isFile()||i.size<=0||i.size>c)return null}catch{return null}return h(t,o)?o:null}export function normalizeRoute(e="/"){const t=String(e||"/").trim();if(!t.startsWith("/")||t.startsWith("//"))throw new Error('Preview path must start with one "/" and cannot be a URL.');const r=new URL(t,"http://127.0.0.1");if("http://127.0.0.1"!==r.origin)throw new Error("Preview path cannot change the preview origin.");return`${r.pathname}${r.search}${r.hash}`}export function safeSlug(e="viewport"){return String(e||"viewport").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"").slice(0,56)||"viewport"}export function isSignedOutRoomPreview(e,t){return!!new URL(normalizeRoute(e),"http://127.0.0.1").searchParams.get("r")&&/\bid\s*=\s*["']invitation-room-title["']/i.test(String(t||""))}export function previewArtifactIssue(e,t){const r=new URL(normalizeRoute(e),"http://127.0.0.1"),o=e=>{if("string"!=typeof e||!e.trim())return null;try{const t=new URL(e,"http://127.0.0.1");return`${"/"===t.pathname?"/":t.pathname.replace(/\/+$/,"")}${t.search}`}catch{return null}},n=o(r.href),i="/code"===r.pathname||r.pathname.startsWith("/code/")||r.searchParams.has("r");for(const e of[t?.beforeState,t?.afterState]){if(!e)return"The captured page did not report settled rendered state.";const t=o(e.url);if(t&&t!==n)return`Preview navigated from ${n} to ${t}.`;if(e.signedOutInvite)return"Preview resolved to the signed-out room invitation instead of the authenticated Code room.";if(e.visibleBoot)return"Preview was still showing the application loading shell.";if(e.visibleLoading)return"Preview was still showing an application loading state.";if(i&&e.prerenderGeo)return"Preview resolved to the public marketing shell instead of the authenticated Code surface.";if(!e.hasBody||e.textLength<2&&e.meaningfulVisualCount<1)return"Preview rendered an empty page."}return null}export function routeNeedsPreviewAuth(e){const t=new URL(normalizeRoute(e),"http://127.0.0.1");return!!t.searchParams.get("r")||["/settings","/contacts","/code/link"].includes(t.pathname)}export function previewAuthBootstrap({accessToken:e,supabaseUrl:t,nowSec:r=Math.floor(Date.now()/1e3),minTtlSec:o=0}={}){const n=function(e){try{const t=String(e||"").split(".")[1];return t?JSON.parse(Buffer.from(t,"base64url").toString("utf8")):null}catch{return null}}(e);let i;try{i=new URL(t)}catch{return null}if(!e||"https:"!==i.protocol||!n?.sub||!n?.exp||n.exp-r<=o)return null;const s=i.hostname.split(".")[0];return s?{storageKey:`sb-${s}-auth-token`,allowedReadOrigins:[i.origin],allowedSocketOrigins:[`wss://${i.host}`],session:{access_token:e,refresh_token:"",expires_in:Math.max(1,n.exp-r)+3600,expires_at:n.exp+3600,token_type:"bearer",user:{id:n.sub,aud:n.aud||"authenticated",role:n.role||"authenticated",email:n.email||null,phone:n.phone||"",app_metadata:n.app_metadata||{},user_metadata:n.user_metadata||{},identities:[],created_at:n.created_at||new Date(1e3*r).toISOString(),updated_at:new Date(1e3*r).toISOString()}}}:null}export function findBrowserExecutable({env:e=process.env,platform:r=process.platform,exists:o=t.existsSync}={}){return[e.TP_BROWSER_PATH,"darwin"===r&&"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome","darwin"===r&&"/Applications/Chromium.app/Contents/MacOS/Chromium","win32"===r&&e.PROGRAMFILES&&n.join(e.PROGRAMFILES,"Google","Chrome","Application","chrome.exe"),"win32"===r&&e["PROGRAMFILES(X86)"]&&n.join(e["PROGRAMFILES(X86)"],"Google","Chrome","Application","chrome.exe"),"linux"===r&&"/usr/bin/google-chrome","linux"===r&&"/usr/bin/google-chrome-stable","linux"===r&&"/usr/bin/chromium","linux"===r&&"/usr/bin/chromium-browser"].filter(Boolean).find(e=>{try{return o(e)}catch{return!1}})||null}class d{constructor(e,t=12e3){this.child=e,this.timeoutMs=t,this.nextId=1,this.buffer=Buffer.alloc(0),this.pending=new Map,this.waiters=new Set,this.subscribers=new Set,e.stdio[4].on("data",e=>this.onData(e)),e.once("exit",(e,t)=>this.failAll(new Error(`Chrome exited (${t||e||"unknown"}).`))),e.once("error",e=>this.failAll(e))}onData(e){for(this.buffer=Buffer.concat([this.buffer,e]);;){const e=this.buffer.indexOf(0);if(e<0)break;const t=this.buffer.subarray(0,e).toString("utf8");if(this.buffer=this.buffer.subarray(e+1),!t)continue;let r;try{r=JSON.parse(t)}catch{continue}if(r.id){const e=this.pending.get(r.id);if(!e)continue;clearTimeout(e.timer),this.pending.delete(r.id),r.error?e.reject(new Error(r.error.message||"Chrome DevTools error")):e.resolve(r.result||{});continue}for(const e of[...this.waiters])e.method===r.method&&(e.sessionId&&e.sessionId!==r.sessionId||e.predicate&&!e.predicate(r.params||{})||(clearTimeout(e.timer),this.waiters.delete(e),e.resolve(r.params||{})));for(const e of[...this.subscribers])if(e.method===r.method&&(!e.sessionId||e.sessionId===r.sessionId))try{e.handler(r.params||{})}catch{}}}failAll(e){for(const t of this.pending.values())clearTimeout(t.timer),t.reject(e);this.pending.clear();for(const t of this.waiters)clearTimeout(t.timer),t.reject(e);this.waiters.clear(),this.subscribers.clear()}send(e,t={},r){const o=this.nextId++;return new Promise((n,i)=>{const s=setTimeout(()=>{this.pending.delete(o),i(new Error(`Chrome timed out running ${e}.`))},this.timeoutMs);this.pending.set(o,{resolve:n,reject:i,timer:s});const a={id:o,method:e,params:t,...r?{sessionId:r}:{}};this.child.stdio[3].write(`${JSON.stringify(a)}\0`)})}waitFor(e,t,r){return new Promise((o,n)=>{const i={method:e,sessionId:t,predicate:r,resolve:o,reject:n,timer:null};i.timer=setTimeout(()=>{this.waiters.delete(i),n(new Error(`Chrome timed out waiting for ${e}.`))},this.timeoutMs),this.waiters.add(i)})}subscribe(e,t,r){const o={method:e,sessionId:t,handler:r};return this.subscribers.add(o),()=>this.subscribers.delete(o)}}export class CdpBrowser{constructor({executable:t,spawnImpl:r=e,timeoutMs:o=12e3}={}){this.executable=t||null,this.spawnImpl=r,this.timeoutMs=o,this.child=null,this.pipe=null,this.profileDir=null,this.starting=null,this.stderr=""}async ensureStarted(){if(!this.child||!this.pipe)return this.starting||(this.starting=this.start().finally(()=>{this.starting=null})),this.starting}async start(){const e=this.executable||findBrowserExecutable();if(!e)throw new Error("No supported Chrome/Chromium executable found. Install Google Chrome or set TP_BROWSER_PATH on the bridge host.");this.profileDir=await r.mkdtemp(n.join(o.tmpdir(),"thinkpool-viewport-"));const t=["--headless=new","--remote-debugging-pipe","--no-first-run","--no-default-browser-check","--disable-background-networking","--disable-component-update","--disable-sync","--disable-extensions","--disable-default-apps","--metrics-recording-only","--mute-audio","--hide-scrollbars",`--user-data-dir=${this.profileDir}`,"about:blank"],i=this.spawnImpl(e,t,{stdio:["ignore","ignore","pipe","pipe","pipe"]});this.child=i,i.stderr?.on("data",e=>{this.stderr=(this.stderr+e.toString("utf8")).slice(-4e3)}),this.pipe=new d(i,this.timeoutMs);try{await this.pipe.send("Browser.getVersion")}catch(e){await this.close();const t=this.stderr.trim().split("\n").slice(-2).join(" ");throw new Error(`Could not start Chrome for viewport capture: ${t||e.message}`)}}async withPage({url:e,viewport:t,waitMs:r=300,auth:o=null},n){await this.ensureStarted();const{targetId:i}=await this.pipe.send("Target.createTarget",{url:"about:blank"}),{sessionId:s}=await this.pipe.send("Target.attachToTarget",{targetId:i,flatten:!0});let a=null,l=null,c=null;const h=[],d=[],p=[];try{if(await this.pipe.send("Page.enable",{},s),await this.pipe.send("Runtime.enable",{},s),await this.pipe.send("Network.enable",{},s).catch(()=>{}),o?.storageKey&&o?.session){const e=`(() => { try { localStorage.setItem(${JSON.stringify(o.storageKey)}, ${JSON.stringify(JSON.stringify(o.session))}); window.__TP_READ_ONLY_PREVIEW__ = true; } catch {} })()`;await this.pipe.send("Page.addScriptToEvaluateOnNewDocument",{source:e},s)}const i=new URL(e).origin,w=new Set(o?.allowedReadOrigins||[]),m=new Set(o?.allowedSocketOrigins||[]);l=this.pipe.subscribe("Network.responseReceived",s,e=>{try{const t=new URL(e.response?.url||"");w.has(t.origin)&&h.length<20&&h.push(`${e.response?.status||0}:${t.pathname}`),t.origin===i&&d.length<20&&d.push(`${e.response?.status||0}:${t.pathname}`)}catch{}}),c=this.pipe.subscribe("Runtime.exceptionThrown",s,e=>{if(p.length>=5)return;const t=e.exceptionDetails?.exception?.description||e.exceptionDetails?.text||"runtime error";p.push(String(t).split("\n")[0].slice(0,180))}),a=this.pipe.subscribe("Fetch.requestPaused",s,e=>{let t=!1;try{const r=new URL(e.request?.url||""),o=String(e.request?.method||"GET").toUpperCase();t=r.origin===i||w.has(r.origin)&&["GET","HEAD","OPTIONS"].includes(o)||m.has(r.origin)&&"GET"===o}catch{t=!1}const r=t?"Fetch.continueRequest":"Fetch.failRequest",o=t?{requestId:e.requestId}:{requestId:e.requestId,errorReason:"BlockedByClient"};this.pipe.send(r,o,s).catch(()=>{})}),await this.pipe.send("Fetch.enable",{patterns:[{urlPattern:"*",requestStage:"Request"}]},s),await this.pipe.send("Network.setBypassServiceWorker",{bypass:!0},s).catch(()=>{}),await this.pipe.send("Emulation.setDeviceMetricsOverride",{width:t.width,height:t.height,deviceScaleFactor:1,mobile:!1,screenWidth:t.width,screenHeight:t.height},s);const f=this.pipe.waitFor("Page.loadEventFired",s),g=await this.pipe.send("Page.navigate",{url:e},s);if(g.errorText)throw new Error(`Preview navigation failed: ${g.errorText}`);if(await f,await this.pipe.send("Runtime.evaluate",{expression:"document.fonts && document.fonts.ready",awaitPromise:!0,returnByValue:!0},s).catch(()=>{}),o){const e=Date.now()+15e3;let t=!1,r=0;for(;Date.now()<e;){const e=await this.pipe.send("Runtime.evaluate",{expression:"(() => {\n if (document.querySelector('#invitation-room-title')) return true;\n if (document.querySelector('#boot, .boot-loader')) return false;\n const room = document.querySelector('[data-tp-preview-room-status]');\n if (room?.dataset.tpPreviewRoomStatus === 'pending') return false;\n const loading = [...document.querySelectorAll('span')].some((node) => /^(loading session|restoring terminals…|connecting(?:…|...))$/i.test((node.textContent || '').trim()));\n return !loading && (document.body?.innerText || '').trim().length > 1;\n })()",returnByValue:!0},s);if(!0===e.result?.value){if(r||(r=Date.now()),Date.now()-r>=800){t=!0;break}}else r=0;await new Promise(e=>setTimeout(e,100))}if(!t){const e=await this.pipe.send("Runtime.evaluate",{expression:`(async () => {\n let locks = { held: [], pending: [] };\n try { locks = await navigator.locks.query(); } catch {}\n return {\n sessionPresent: !!localStorage.getItem(${JSON.stringify(o.storageKey||"")}),\n inlineBoot: !!document.querySelector('#boot, .boot-loader'),\n loadingSession: [...document.querySelectorAll('span')].some((node) => (node.textContent || '').trim() === 'Loading session'),\n connecting: [...document.querySelectorAll('span')].some((node) => (node.textContent || '').trim().toLowerCase() === 'connecting…'),\n invitation: !!document.querySelector('#invitation-room-title'),\n roomStatus: document.querySelector('[data-tp-preview-room-status]')?.dataset.tpPreviewRoomStatus || null,\n heldLocks: locks.held?.length || 0,\n pendingLocks: locks.pending?.length || 0,\n };\n })()`,awaitPromise:!0,returnByValue:!0},s).catch(()=>({result:{value:{}}})),t=e.result?.value||{};throw new Error(`Authenticated preview did not leave its loading state within 15 seconds (session=${t.sessionPresent?"present":"missing"}, inlineBoot=${t.inlineBoot?"yes":"no"}, loadingSession=${t.loadingSession?"yes":"no"}, connecting=${t.connecting?"yes":"no"}, roomStatus=${t.roomStatus||"none"}, authLocks=${t.heldLocks||0}/${t.pendingLocks||0}, invitation=${t.invitation?"yes":"no"}, reads=${h.join(",")||"none"}, local=${d.join(",")||"none"}, runtime=${p.join(" | ")||"none"}); no screenshot was created.`)}}const b=o?Math.min(300,Math.max(0,Number(r)||0)):Math.min(u,Math.max(0,Number(r)||0));return b&&await new Promise(e=>setTimeout(e,b)),await n({pipe:this.pipe,sessionId:s})}finally{a?.(),l?.(),c?.(),await this.pipe.send("Target.closeTarget",{targetId:i}).catch(()=>{})}}async renderedState(e,t){const r=await e.send("Runtime.evaluate",{expression:"(() => {\n const visible = (node) => {\n if (!node) return false;\n const style = getComputedStyle(node);\n const rect = node.getBoundingClientRect();\n return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity || 1) > 0 && rect.width > 0 && rect.height > 0;\n };\n const boot = document.querySelector('#boot');\n const meaningful = [...document.querySelectorAll('main,article,section,img,video,canvas,svg,[role=\"dialog\"],[role=\"main\"],[role=\"img\"]')]\n .filter((node) => !node.closest('#boot') && visible(node));\n return {\n title: document.title || '', url: location.href,\n hasBody: !!document.body,\n textLength: (document.body?.innerText || '').trim().length,\n meaningfulVisualCount: meaningful.length,\n visibleBoot: visible(boot),\n visibleLoading: [...document.querySelectorAll('span')].some((node) => {\n const text = (node.textContent || '').trim();\n return visible(node) && /^(loading session|restoring terminals…|connecting(?:…|...))$/i.test(text);\n }),\n signedOutInvite: !!document.querySelector('#invitation-room-title'),\n prerenderGeo: !!document.querySelector('[data-prerender=\"geo\"]'),\n };\n })()",returnByValue:!0},t);if(r.exceptionDetails)throw new Error("Could not inspect the rendered preview state.");return r.result?.value||null}async freezeCurrentPage(e,t){const r=await e.send("Runtime.evaluate",{expression:"(async () => {\n const clone = document.documentElement.cloneNode(true);\n clone.querySelectorAll('script,link[rel=\"modulepreload\"],link[rel=\"preload\"]').forEach((node) => node.remove());\n clone.querySelectorAll('*').forEach((node) => {\n for (const attr of [...node.attributes]) if (/^on/i.test(attr.name)) node.removeAttribute(attr.name);\n if (node instanceof HTMLInputElement) node.removeAttribute('value');\n if (node instanceof HTMLTextAreaElement) node.textContent = '';\n });\n const css = [];\n for (const sheet of [...document.styleSheets]) {\n try { css.push([...sheet.cssRules].map((rule) => rule.cssText).join('\\n')); } catch {}\n }\n clone.querySelectorAll('style,link[rel=\"stylesheet\"]').forEach((node) => node.remove());\n const head = clone.querySelector('head') || clone.insertBefore(document.createElement('head'), clone.firstChild);\n const style = document.createElement('style');\n style.setAttribute('data-thinkpool-snapshot', '');\n style.textContent = css.join('\\n');\n head.appendChild(style);\n\n const assetUrls = new Set();\n const remember = (raw) => {\n if (!raw || /^(data:|blob:|#)/i.test(raw)) return;\n try {\n const absolute = new URL(raw, location.href);\n if (absolute.origin === location.origin) assetUrls.add(absolute.href);\n } catch {}\n };\n clone.querySelectorAll('[src],[poster]').forEach((node) => {\n remember(node.getAttribute('src'));\n remember(node.getAttribute('poster'));\n });\n for (const match of style.textContent.matchAll(/url\\(\\s*(['\"]?)([^'\"\\)]+)\\1\\s*\\)/gi)) remember(match[2]);\n\n const replacements = new Map();\n let inlinedBytes = 0;\n for (const absolute of assetUrls) {\n try {\n const response = await fetch(absolute);\n const buffer = await response.arrayBuffer();\n if (!response.ok || buffer.byteLength > 512000 || inlinedBytes + buffer.byteLength > 1000000) continue;\n const bytes = new Uint8Array(buffer);\n let binary = '';\n for (let offset = 0; offset < bytes.length; offset += 0x8000) binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));\n replacements.set(absolute, 'data:' + (response.headers.get('content-type') || 'application/octet-stream') + ';base64,' + btoa(binary));\n inlinedBytes += buffer.byteLength;\n } catch {}\n }\n const replaceAsset = (raw) => {\n try { return replacements.get(new URL(raw, location.href).href) || raw; } catch { return raw; }\n };\n clone.querySelectorAll('[src],[poster]').forEach((node) => {\n for (const name of ['src', 'poster']) if (node.hasAttribute(name)) node.setAttribute(name, replaceAsset(node.getAttribute(name)));\n node.removeAttribute('srcset');\n });\n style.textContent = style.textContent.replace(/url\\(\\s*(['\"]?)([^'\"\\)]+)\\1\\s*\\)/gi, (_all, quote, raw) => 'url(\"' + replaceAsset(raw) + '\")');\n return '<!doctype html>\\n' + clone.outerHTML;\n })()",awaitPromise:!0,returnByValue:!0},t);if(r.exceptionDetails||"string"!=typeof r.result?.value){const e=r.exceptionDetails?.exception?.description||r.exceptionDetails?.text||r.result?.description;throw new Error("Could not freeze the rendered preview for Design"+(e?`: ${e}`:"."))}const o=r.result.value;if(!o.trim()||Buffer.byteLength(o)>19e5)throw new Error("The rendered preview is too large to make editable.");return o}async capture({url:e,viewport:t,fullPage:r=!0,waitMs:o=300,includeSnapshot:n=!1,auth:i=null}){return this.withPage({url:e,viewport:t,waitMs:o,auth:i},async({pipe:e,sessionId:o})=>{const i=await this.renderedState(e,o),s=await e.send("Page.getLayoutMetrics",{},o),a=Math.ceil(s.cssContentSize?.height||t.height),l=r?Math.min(2e4,Math.max(t.height,a)):t.height,c=await e.send("Page.captureScreenshot",{format:"png",fromSurface:!0,captureBeyondViewport:!0,clip:{x:0,y:0,width:t.width,height:l,scale:1}},o),u=await this.renderedState(e,o),h=n?await this.freezeCurrentPage(e,o):null;return{png:Buffer.from(c.data,"base64"),width:t.width,height:l,contentHeight:a,capped:a>2e4,beforeState:i,afterState:u,snapshot:h}})}async snapshot({url:e,viewport:t=DEFAULT_VIEWPORTS.desktop,waitMs:r=300,auth:o=null}){return this.withPage({url:e,viewport:t,waitMs:r,auth:o},({pipe:e,sessionId:t})=>this.freezeCurrentPage(e,t))}async inspect({url:e,viewport:t=DEFAULT_VIEWPORTS.mobile,selector:r,waitMs:o=300,auth:n=null}){return this.withPage({url:e,viewport:t,waitMs:o,auth:n},async({pipe:e,sessionId:t})=>{const o=`(() => {\n const selector = ${JSON.stringify(r||null)};\n const el = selector ? document.querySelector(selector) : null;\n const r = el && el.getBoundingClientRect();\n return {\n title: document.title, url: location.href,\n viewport: { width: innerWidth, height: innerHeight, dpr: devicePixelRatio },\n document: { width: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth || 0), height: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight || 0) },\n selector: selector ? { query: selector, found: !!el, tag: el?.tagName || null, text: el?.innerText?.slice(0, 2000) || '', rect: r ? { x:r.x, y:r.y, width:r.width, height:r.height } : null } : null,\n text: document.body?.innerText?.slice(0, 12000) || ''\n };\n })()`,n=await e.send("Runtime.evaluate",{expression:o,returnByValue:!0},t);if(n.exceptionDetails)throw new Error("DOM inspection failed in the preview page.");return n.result?.value||{}})}async close(){const e=this.child,t=this.pipe;if(this.child=null,this.pipe=null,t&&await t.send("Browser.close").catch(()=>{}),e&&null==e.exitCode)try{e.kill("SIGTERM")}catch{}this.profileDir&&await r.rm(this.profileDir,{recursive:!0,force:!0}).catch(()=>{}),this.profileDir=null}}export const sharedViewportBrowser=new CdpBrowser;export class ViewportManager{constructor({workspaceRoot:e,ownerId:t,roomCode:r,outbox:s,browser:l=sharedViewportBrowser,startPreviewImpl:c=a,designContext:u=null,authContext:h=null}={}){this.workspaceRoot=n.resolve(e||process.cwd()),this.ownerId=t||i(),this.roomCode="string"==typeof r&&r.trim()?r.trim():null,this.outbox=s||n.join(o.tmpdir(),"thinkpool-viewport-captures",this.ownerId),this.browser=l,this.startPreviewImpl=c,this.designContext="function"==typeof u?u:()=>null,this.authContext="function"==typeof h?h:()=>null,this.previewId=`viewport:${this.ownerId}`,this.preview=null,this.root=null}setWorkspaceRoot(e){if(!e)throw new Error("A lane workspace is required.");return this.workspaceRoot=n.resolve(e),this.workspaceRoot}async start({root:e="dist"}={}){const t=await resolveContainedRoot(this.workspaceRoot,e);await this.stop();const r=await this.startPreviewImpl({dir:t,host:"127.0.0.1",port:0,id:this.previewId}),o=new URL(r.url);if("127.0.0.1"!==o.hostname&&"localhost"!==o.hostname)throw await(r.stop?.()),new Error("Preview server did not bind to loopback.");return this.preview=r,this.root=t,{url:r.url,root:t}}requirePreview(){if(!this.preview?.url)throw new Error("No preview is running. Call preview_start after building the app.");return this.preview}defaultRoute(){return this.roomCode?`/?r=${encodeURIComponent(this.roomCode)}`:"/"}resolveRoute(e){return normalizeRoute(e??this.defaultRoute())}pageUrl(e){const t=this.requirePreview();return new URL(normalizeRoute(e),t.url).href}async previewAuth(e){if(!routeNeedsPreviewAuth(e))return null;let t=this.authContext()||{};if(!t.accessToken)return null;const r=Date.now()+15e3;for(;;){const e=previewAuthBootstrap({...t,minTtlSec:30});if(e)return e;if(Date.now()>=r)throw new Error("The bridge account token did not refresh in time for an authenticated preview; no screenshot was created.");await new Promise(e=>setTimeout(e,250)),t=this.authContext()||{}}}async capture({route:e,viewports:t="both",title:o="Viewport capture",fullPage:i=!0,waitMs:s=300,card:a=!1}={}){const l=this.resolveRoute(e),c=this.pageUrl(l),u="both"===t?["desktop","mobile"]:[t];if(u.some(e=>!DEFAULT_VIEWPORTS[e]))throw new Error('viewports must be "both", "desktop", or "mobile".');const h=`${safeSlug(o)}-${Date.now().toString(36)}`,d=await r.realpath(this.workspaceRoot),p=n.relative(d,this.root)||".",w=JSON.stringify([p,l]),m=this.designContext()||null,f=m?.captureKey===w?{designRequestId:m.requestId,parentRevision:m.parentRevision}:{},g=!0===a||!!f.designRequestId,b={};for(const e of u)b[e]=await this.browser.capture({url:c,viewport:DEFAULT_VIEWPORTS[e],fullPage:i,waitMs:s,includeSnapshot:g&&e===(u.includes("desktop")?"desktop":u[0]),auth:await this.previewAuth(l)});const v=g&&!(!b.desktop||!b.mobile);if(v||routeNeedsPreviewAuth(l))for(const[e,t]of Object.entries(b)){const r=previewArtifactIssue(l,t);if(r)throw new Error(`${e} preview rejected: ${r} No screenshot or mockup card was created.`)}const y=b.desktop?.snapshot||b.mobile?.snapshot||null;if(v&&("string"!=typeof y||!y.trim()))throw new Error("The deliverable could not freeze its rendered source. No mockup card was created.");await r.mkdir(this.outbox,{recursive:!0});for(const[e,t]of Object.entries(b))t.file=n.join(this.outbox,`${h}--${e}.png`),await r.writeFile(t.file,t.png);const x=v?n.join(this.outbox,`${h}--source.html`):null;x&&await r.writeFile(x,y);let S=null;if(v){const e=await resolvePreviewSourceMap(this.root),t={slug:h,title:String(o||"Viewport capture").slice(0,120),desktop:b.desktop.file,mobile:b.mobile.file,ts:Date.now(),sourceKind:"preview",deliveryIntent:"card",readinessValidated:!0,snapshot:x,previewRoot:p,route:l,captureKey:w,...e?{sourceMap:e}:{},...f};S=n.join(this.outbox,`${h}.json`);const i=`${S}.tmp`;await r.writeFile(i,JSON.stringify(t)),await r.rename(i,S)}return{url:c,route:l,slug:h,manifestPath:S,captures:b,snapshotPath:x,cardReady:v}}async inspect({route:e,viewport:t="mobile",selector:r,waitMs:o=300}={}){if(!DEFAULT_VIEWPORTS[t])throw new Error('viewport must be "desktop" or "mobile".');const n=this.resolveRoute(e);return this.browser.inspect({url:this.pageUrl(n),viewport:DEFAULT_VIEWPORTS[t],selector:r,waitMs:o,auth:await this.previewAuth(n)})}async stop(){const e=this.preview||s.get(this.previewId);this.preview=null,this.root=null,e?.stop&&await e.stop()}}const p=e=>({content:[{type:"text",text:e}]}),w=e=>p(`Viewport error: ${e?.message||e}`);export function createViewportTools({tool:e,z:t,manager:r}){return[e("preview_start","Start a bridge-owned, read-only localhost preview for built files inside this lane workspace. Run the project build first. The default root is dist; pass another relative directory only when its index.html is the intended preview.",{root:t.string().max(240).optional().describe("relative directory inside this lane workspace; default: dist")},async e=>{try{const t=await r.start({root:e?.root||"dist"});return p(`Preview ready from ${n.relative(r.workspaceRoot,t.root)||"."}. Use preview_capture to see desktop/mobile, or preview_inspect for DOM text and geometry.`)}catch(e){return w(e)}}),e("preview_capture","Capture this lane preview in a fresh isolated browser at exact desktop (1440×900) and mobile (390×844) CSS viewports. Omit path to preview the current authenticated room; pass / explicitly for the public landing page. Defaults to both and full-page. Captures are inline verification evidence by default. Room and protected routes automatically use the bridge account as a read-only authenticated visual harness; external REST writes, presence, and room broadcasts are blocked, apart from non-mutating roster/transcript snapshot requests. Set card=true only for a user-requested mockup or visual deliverable that should remain editable in the transcript; complete desktop+mobile settled content is then required and the card arrives after the final response.",{path:t.string().max(500).optional().describe("route within the preview; omit for the current authenticated room, or pass / for the public landing page; never a full URL"),viewports:t.enum(["both","desktop","mobile"]).optional(),title:t.string().max(120).optional(),fullPage:t.boolean().optional(),card:t.boolean().optional().describe("default false; true only for an intentional user-facing mockup/Design deliverable"),waitMs:t.number().int().min(0).max(u).optional().describe("settle time after load, max 5000ms")},async e=>{try{const t=await r.capture({route:e?.path,viewports:e?.viewports||"both",title:e?.title||"Viewport capture",fullPage:!1!==e?.fullPage,waitMs:e?.waitMs??300,card:!0===e?.card}),o=t.cardReady?"The complete room preview card is queued after your final response.":"This is verification evidence only; no room card was created.",n=[{type:"text",text:`Captured ${Object.keys(t.captures).join(" + ")} for ${t.route}. ${o}`}];for(const[e,r]of Object.entries(t.captures))n.push({type:"text",text:`${e}: ${r.width}×${r.height}${r.capped?" (height capped)":""}`}),n.push({type:"image",data:r.png.toString("base64"),mimeType:"image/png"});return{content:n}}catch(e){return w(e)}}),e("preview_inspect","Inspect rendered DOM state in this lane preview at an exact desktop or mobile viewport. Omit path for the current authenticated room; pass / explicitly for the public landing page. Returns page text, document dimensions, and optional selector text/geometry without changing the page.",{path:t.string().max(500).optional().describe("route within the preview; omit for the current authenticated room, or pass / for the public landing page; never a full URL"),viewport:t.enum(["desktop","mobile"]).optional(),selector:t.string().max(500).optional(),waitMs:t.number().int().min(0).max(u).optional()},async e=>{try{const t=await r.inspect({route:e?.path,viewport:e?.viewport||"mobile",selector:e?.selector,waitMs:e?.waitMs??300});return p(JSON.stringify(t,null,2))}catch(e){return w(e)}}),e("preview_stop","Stop this lane's bridge-owned preview server and release its port.",{},async()=>{try{return await r.stop(),p("Preview stopped.")}catch(e){return w(e)}})]}