thinkpool-pair 0.7.206 → 0.7.207

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,6 +56,25 @@ every byte mirrors to the web. The web's **"+ New terminal"** spawns additional
56
56
  **headless** terminals here (same directory, same env), driven entirely from
57
57
  the room. One bridge, many terminals.
58
58
 
59
+ ### Built-in viewport capture
60
+
61
+ Structured Claude and Codex lanes have bridge-owned visual QA tools, even when
62
+ their own sandbox cannot bind localhost or launch Chrome:
63
+
64
+ - `preview_start` serves a built directory inside that lane's workspace
65
+ (`dist` by default; it must contain `index.html`).
66
+ - `preview_capture` returns exact desktop (1440×900) and mobile (390×844)
67
+ screenshots to the agent and surfaces them in the room.
68
+ - `preview_inspect` returns rendered DOM text, document size, and optional
69
+ selector geometry at either viewport.
70
+ - `preview_stop` releases the preview port.
71
+
72
+ The bridge launches the host's Chrome/Chromium lazily. Set `TP_BROWSER_PATH`
73
+ if it is installed somewhere non-standard. Preview files are read-only, roots
74
+ cannot escape the lane workspace (including through symlinks), and page network
75
+ requests are restricted to the preview server's exact loopback origin. The
76
+ tools never execute a caller-supplied command or open an arbitrary URL.
77
+
59
78
  ## Run it in the cloud (remote host / VM / container)
60
79
 
61
80
  The bridge connects **outbound** to Supabase — no inbound ports, no public IP, no
package/bridge.mjs CHANGED
@@ -63,6 +63,7 @@ import { normalizePlanOutput, laneModelFor } from './flow-task-graph.mjs' // F
63
63
  import { writeLaneArtifact, digestSlice, appendDigest, resumeLane } from './flow-context-store.mjs'
64
64
  import { createFlowWorktree, worktreeSpec } from './flow-worktree.mjs'
65
65
  import { startPreview, stopAllPreviews, previews } from './flow-preview.mjs'
66
+ import { ViewportManager, createViewportTools, sharedViewportBrowser } from './viewport.mjs'
66
67
  // FL-M9 — per-lane preview servers leak (one per done lane, never stopped until shutdown).
67
68
  // Lane previews are keyed `lane:<flowId>:<laneId>`; stop a whole flow's set when it assembles
68
69
  // (the assembled preview supersedes them) or when a lane is reverted.
@@ -1747,6 +1748,9 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
1747
1748
  // they land on this session even after other terminals open/close.
1748
1749
  const mockupOutbox = ownerOutbox('sessions', id)
1749
1750
  entry.mockupWatcher = watchOutbox(mockupOutbox, () => id)
1751
+ // Visual QA runs in the bridge process, outside either agent runtime's sandbox.
1752
+ // It remains scoped to this lane's cwd and private mockup outbox.
1753
+ entry.viewport = new ViewportManager({ workspaceRoot: cwd || process.cwd(), ownerId: id, outbox: mockupOutbox })
1750
1754
  if (entry.log.length) process.stderr.write(`\n ◆ restored ${entry.log.length} prior events (${id.slice(0, 8)})${resume ? ' + resuming live context' : ''}.\n`)
1751
1755
  // Persist the permission mode alongside the transcript so a bridge restart
1752
1756
  // restores the session in the SAME mode (a bypass room stays bypass on resume).
@@ -1776,6 +1780,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
1776
1780
  name: 'thinkpool',
1777
1781
  version: '1.0.0',
1778
1782
  tools: [
1783
+ ...createViewportTools({ tool, z, manager: entry.viewport }),
1779
1784
  tool(
1780
1785
  'read_terminal',
1781
1786
  'Read-only view of ANOTHER terminal in this ThinkPool Code room (a sibling agent or a shell the people are using). Call with no arguments to list the other open terminals; call with `terminal` (a ref, id, or command from that list) to read its recent activity. It never changes another terminal — reading only. Use it when your work depends on what another terminal is doing.',
@@ -2199,6 +2204,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2199
2204
  process.stderr.write(`\n ◆ saved session expired — starting fresh (transcript kept).\n`)
2200
2205
  try { entry.session?.end() } catch { /* noop */ }
2201
2206
  try { entry.mockupWatcher?.close() } catch { /* noop */ }
2207
+ try { void entry.viewport?.stop()?.catch(() => {}) } catch { /* noop */ }
2202
2208
  sessions.delete(id)
2203
2209
  openStructured({ id, runtime: entry.runtime, model, log: entry.log, commands: entry.commands, mode: entry.mode })
2204
2210
  return
@@ -2544,6 +2550,7 @@ function respawnStructured(id, provider) {
2544
2550
  drainPending(s)
2545
2551
  try { s.session?.end() } catch { /* noop */ }
2546
2552
  try { s.mockupWatcher?.close() } catch { /* noop */ }
2553
+ try { void s.viewport?.stop()?.catch(() => {}) } catch { /* noop */ }
2547
2554
  sessions.delete(id) // openStructured early-returns if the id is still mapped
2548
2555
  // Re-open under the SAME id with the new provider env. openStructured persists
2549
2556
  // sessionData() (provider included) synchronously on open, so a bridge restart
@@ -2586,6 +2593,7 @@ function endStructured(id) {
2586
2593
  drainPending(s)
2587
2594
  try { s.session?.end() } catch { /* noop */ }
2588
2595
  try { s.mockupWatcher?.close() } catch { /* noop */ }
2596
+ try { void s.viewport?.stop()?.catch(() => {}) } catch { /* noop */ }
2589
2597
  sessions.delete(id)
2590
2598
  bcast('term-exit', { id })
2591
2599
  reapTerminalRow(id) // bridge-side reap: delete the code_terminals row ourselves — the
@@ -3709,6 +3717,7 @@ async function shutdown(code = 0, farewell = true) {
3709
3717
  setTimeout(() => process.exit(code), 1500)
3710
3718
  clearInterval(flushTimer)
3711
3719
  try { stopAllPreviews() } catch { /* Flow preview servers — best-effort close */ }
3720
+ try { void sharedViewportBrowser.close().catch(() => {}) } catch { /* bridge-owned Chrome — best-effort close */ }
3712
3721
  if (farewell) {
3713
3722
  try {
3714
3723
  const bye = Buffer.from('\r\n[ shared session ended ]\r\n', 'utf8').toString('base64')
@@ -486,6 +486,11 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
486
486
  if (toolName === 'mcp__thinkpool__read_terminal') {
487
487
  return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (read-only ThinkPool cross-terminal read).' } }
488
488
  }
489
+ // Bridge-owned visual QA is constrained to the lane cwd + a loopback URL
490
+ // created by the bridge itself, so it does not need a room permission card.
491
+ if (/^mcp__thinkpool__preview_(start|capture|inspect|stop)$/.test(toolName)) {
492
+ return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (contained bridge-owned viewport preview).' } }
493
+ }
489
494
  // FL-B1 — the Flow conductor submits its task-graph through this tool (replaces the
490
495
  // deferred/hanging ExitPlanMode). It only broadcasts a plan for HUMAN approval — no FS or
491
496
  // system effect — so auto-allow it (the conductor runs in plan mode, which would otherwise
@@ -699,6 +704,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
699
704
  'Whenever you produce an HTML artifact (a demo, mockup, preview, report, or page) or surface ANY link meant to be opened or shared, NEVER hand the room a local path, a file:// URL, or a localhost/127.0.0.1 address. Publish it to a GitHub-shareable URL that renders in a browser — push the HTML to a GitHub repo and give its GitHub Pages URL (or an equivalent raw-HTML URL that actually renders, not raw.githubusercontent.com which serves HTML as plain text) — so anyone in the room can open it. The same applies to any other link you surface: it must be one the room can reach, not a host-only path.',
700
705
  'If you cannot publish a shareable link, say so and ask how to proceed — do not fall back to handing over a local path.',
701
706
  'SHOW YOUR WORK, do not just describe it: the room is watched live from a phone, where a wall of text is painful to read. Whenever you build, change, or fix anything visual — a UI, a page, an HTML artifact, a chart, a diagram, a rendered result — capture a screenshot and surface the PNG (for example, read the image file into your turn) so it appears inline in the room. The room auto-uploads any image you surface. Default to showing a picture of the result over narrating it; err toward more screenshots, not fewer.',
707
+ 'BRIDGE VIEWPORTS: for a built web UI, use preview_start (default root: dist), then preview_capture for exact desktop 1440×900 + mobile 390×844 screenshots, and preview_inspect when DOM text or selector geometry helps. These tools run in the bridge, so they work even when your lane sandbox cannot bind localhost or launch Chrome. They only serve built files inside your own lane workspace; run the project build first. Stop the server with preview_stop when you are done.',
702
708
  'HTML PREVIEWS: for any HTML you produce, give the room something it can actually open, by whichever path is available. (a) IN-ROOM PREVIEW: if a mockup render helper is present — one that writes a manifest into the directory named by $TP_MOCKUP_OUTBOX, such as the mockup-iterate render.sh script when the bridge was launched from the thinkpool repo — use it; it surfaces an inline card with desktop and mobile views that the room can expand. (b) SHAREABLE LINK: additionally or otherwise, publish the HTML to a GitHub Pages URL per the LINKS & ARTIFACTS rule above and post the link. Never leave an HTML artifact viewable only as a local path.',
703
709
  'RUN IT, DO NOT ASK: verify your own work before calling it done. Actually run or serve what you changed, observe that it behaves correctly, and show the evidence in the room — a screenshot of the running result, the passing test output, or the real response — rather than telling the user "this should work, go test it." If you could not verify something, say exactly what is unverified. This room follows verify-before-claiming: runtime evidence you produced, not assertion.',
704
710
  'CROSS-TERMINAL AWARENESS: this room may have other terminals open alongside yours — other agents working, or shells the people are driving. You have a READ-ONLY tool, read_terminal: call it with no arguments to list the other open terminals, or with a terminal ref/id/command to read that terminal\'s recent activity. Reach for it when your work depends on what another terminal is doing (e.g. someone says "see what the other terminal hit", or you need to coordinate with a sibling agent before acting). It only ever reads — it never changes another terminal. Identify a terminal by its NAME or its ref/id from the roster, never by an on-screen number like "Terminal 2" — those positional labels renumber when a terminal is closed, so they do not reliably point at a lane.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.206",
3
+ "version": "0.7.207",
4
4
  "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,6 +33,7 @@
33
33
  "flow-worktree.mjs",
34
34
  "flow-task-graph.mjs",
35
35
  "flow-preview.mjs",
36
+ "viewport.mjs",
36
37
  "flow-review.mjs",
37
38
  "flow-review-gate.mjs",
38
39
  "flow-review-reflect.mjs",
package/viewport.mjs ADDED
@@ -0,0 +1,445 @@
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
+
21
+ export const DEFAULT_VIEWPORTS = Object.freeze({
22
+ desktop: Object.freeze({ width: 1440, height: 900 }),
23
+ mobile: Object.freeze({ width: 390, height: 844 }),
24
+ })
25
+
26
+ const MAX_CAPTURE_HEIGHT = 20000
27
+ const MAX_SETTLE_MS = 5000
28
+ const CDP_TIMEOUT_MS = 12000
29
+
30
+ const isInside = (parent, child) => {
31
+ const rel = path.relative(parent, child)
32
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
33
+ }
34
+
35
+ export async function resolveContainedRoot(workspaceRoot, requested = 'dist') {
36
+ if (!workspaceRoot) throw new Error('A lane workspace is required.')
37
+ if (path.isAbsolute(requested)) throw new Error('Preview root must be relative to the lane workspace.')
38
+ const workspace = await fsp.realpath(path.resolve(workspaceRoot))
39
+ const candidate = path.resolve(workspace, requested || 'dist')
40
+ let resolved
41
+ try { resolved = await fsp.realpath(candidate) }
42
+ catch { throw new Error(`Preview root does not exist: ${requested || 'dist'}. Build the app first, or choose a directory containing index.html.`) }
43
+ if (!isInside(workspace, resolved)) throw new Error('Preview root escapes the lane workspace.')
44
+ const stat = await fsp.stat(resolved)
45
+ if (!stat.isDirectory()) throw new Error('Preview root must be a directory.')
46
+ if (!fs.existsSync(path.join(resolved, 'index.html'))) throw new Error(`No index.html found in preview root: ${requested || 'dist'}.`)
47
+ return resolved
48
+ }
49
+
50
+ export function normalizeRoute(value = '/') {
51
+ const route = String(value || '/').trim()
52
+ if (!route.startsWith('/') || route.startsWith('//')) throw new Error('Preview path must start with one "/" and cannot be a URL.')
53
+ const parsed = new URL(route, 'http://127.0.0.1')
54
+ if (parsed.origin !== 'http://127.0.0.1') throw new Error('Preview path cannot change the preview origin.')
55
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`
56
+ }
57
+
58
+ export function safeSlug(value = 'viewport') {
59
+ return String(value || 'viewport')
60
+ .toLowerCase()
61
+ .replace(/[^a-z0-9]+/g, '-')
62
+ .replace(/^-+|-+$/g, '')
63
+ .slice(0, 56) || 'viewport'
64
+ }
65
+
66
+ export function findBrowserExecutable({ env = process.env, platform = process.platform, exists = fs.existsSync } = {}) {
67
+ const candidates = [
68
+ env.TP_BROWSER_PATH,
69
+ platform === 'darwin' && '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
70
+ platform === 'darwin' && '/Applications/Chromium.app/Contents/MacOS/Chromium',
71
+ platform === 'win32' && env.PROGRAMFILES && path.join(env.PROGRAMFILES, 'Google', 'Chrome', 'Application', 'chrome.exe'),
72
+ platform === 'win32' && env['PROGRAMFILES(X86)'] && path.join(env['PROGRAMFILES(X86)'], 'Google', 'Chrome', 'Application', 'chrome.exe'),
73
+ platform === 'linux' && '/usr/bin/google-chrome',
74
+ platform === 'linux' && '/usr/bin/google-chrome-stable',
75
+ platform === 'linux' && '/usr/bin/chromium',
76
+ platform === 'linux' && '/usr/bin/chromium-browser',
77
+ ].filter(Boolean)
78
+ return candidates.find((candidate) => {
79
+ try { return exists(candidate) } catch { return false }
80
+ }) || null
81
+ }
82
+
83
+ class CdpPipe {
84
+ constructor(child, timeoutMs = CDP_TIMEOUT_MS) {
85
+ this.child = child
86
+ this.timeoutMs = timeoutMs
87
+ this.nextId = 1
88
+ this.buffer = Buffer.alloc(0)
89
+ this.pending = new Map()
90
+ this.waiters = new Set()
91
+ this.subscribers = new Set()
92
+ child.stdio[4].on('data', (chunk) => this.onData(chunk))
93
+ child.once('exit', (code, signal) => this.failAll(new Error(`Chrome exited (${signal || code || 'unknown'}).`)))
94
+ child.once('error', (error) => this.failAll(error))
95
+ }
96
+
97
+ onData(chunk) {
98
+ this.buffer = Buffer.concat([this.buffer, chunk])
99
+ for (;;) {
100
+ const end = this.buffer.indexOf(0)
101
+ if (end < 0) break
102
+ const raw = this.buffer.subarray(0, end).toString('utf8')
103
+ this.buffer = this.buffer.subarray(end + 1)
104
+ if (!raw) continue
105
+ let message
106
+ try { message = JSON.parse(raw) } catch { continue }
107
+ if (message.id) {
108
+ const pending = this.pending.get(message.id)
109
+ if (!pending) continue
110
+ clearTimeout(pending.timer)
111
+ this.pending.delete(message.id)
112
+ if (message.error) pending.reject(new Error(message.error.message || 'Chrome DevTools error'))
113
+ else pending.resolve(message.result || {})
114
+ continue
115
+ }
116
+ for (const waiter of [...this.waiters]) {
117
+ if (waiter.method !== message.method) continue
118
+ if (waiter.sessionId && waiter.sessionId !== message.sessionId) continue
119
+ if (waiter.predicate && !waiter.predicate(message.params || {})) continue
120
+ clearTimeout(waiter.timer)
121
+ this.waiters.delete(waiter)
122
+ waiter.resolve(message.params || {})
123
+ }
124
+ for (const subscriber of [...this.subscribers]) {
125
+ if (subscriber.method !== message.method) continue
126
+ if (subscriber.sessionId && subscriber.sessionId !== message.sessionId) continue
127
+ try { subscriber.handler(message.params || {}) } catch { /* subscriber owns recovery */ }
128
+ }
129
+ }
130
+ }
131
+
132
+ failAll(error) {
133
+ for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error) }
134
+ this.pending.clear()
135
+ for (const waiter of this.waiters) { clearTimeout(waiter.timer); waiter.reject(error) }
136
+ this.waiters.clear()
137
+ this.subscribers.clear()
138
+ }
139
+
140
+ send(method, params = {}, sessionId) {
141
+ const id = this.nextId++
142
+ return new Promise((resolve, reject) => {
143
+ const timer = setTimeout(() => {
144
+ this.pending.delete(id)
145
+ reject(new Error(`Chrome timed out running ${method}.`))
146
+ }, this.timeoutMs)
147
+ this.pending.set(id, { resolve, reject, timer })
148
+ const message = { id, method, params, ...(sessionId ? { sessionId } : {}) }
149
+ this.child.stdio[3].write(`${JSON.stringify(message)}\0`)
150
+ })
151
+ }
152
+
153
+ waitFor(method, sessionId, predicate) {
154
+ return new Promise((resolve, reject) => {
155
+ const waiter = { method, sessionId, predicate, resolve, reject, timer: null }
156
+ waiter.timer = setTimeout(() => {
157
+ this.waiters.delete(waiter)
158
+ reject(new Error(`Chrome timed out waiting for ${method}.`))
159
+ }, this.timeoutMs)
160
+ this.waiters.add(waiter)
161
+ })
162
+ }
163
+
164
+ subscribe(method, sessionId, handler) {
165
+ const subscriber = { method, sessionId, handler }
166
+ this.subscribers.add(subscriber)
167
+ return () => this.subscribers.delete(subscriber)
168
+ }
169
+ }
170
+
171
+ export class CdpBrowser {
172
+ constructor({ executable, spawnImpl = spawn, timeoutMs = CDP_TIMEOUT_MS } = {}) {
173
+ this.executable = executable || null
174
+ this.spawnImpl = spawnImpl
175
+ this.timeoutMs = timeoutMs
176
+ this.child = null
177
+ this.pipe = null
178
+ this.profileDir = null
179
+ this.starting = null
180
+ this.stderr = ''
181
+ }
182
+
183
+ async ensureStarted() {
184
+ if (this.child && this.pipe) return
185
+ if (this.starting) return this.starting
186
+ this.starting = this.start().finally(() => { this.starting = null })
187
+ return this.starting
188
+ }
189
+
190
+ async start() {
191
+ const executable = this.executable || findBrowserExecutable()
192
+ if (!executable) throw new Error('No supported Chrome/Chromium executable found. Install Google Chrome or set TP_BROWSER_PATH on the bridge host.')
193
+ this.profileDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'thinkpool-viewport-'))
194
+ const args = [
195
+ '--headless=new', '--remote-debugging-pipe', '--no-first-run', '--no-default-browser-check',
196
+ '--disable-background-networking', '--disable-component-update', '--disable-sync',
197
+ '--disable-extensions', '--disable-default-apps',
198
+ '--metrics-recording-only', '--mute-audio', '--hide-scrollbars',
199
+ `--user-data-dir=${this.profileDir}`, 'about:blank',
200
+ ]
201
+ const child = this.spawnImpl(executable, args, { stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'] })
202
+ this.child = child
203
+ child.stderr?.on('data', (chunk) => { this.stderr = (this.stderr + chunk.toString('utf8')).slice(-4000) })
204
+ this.pipe = new CdpPipe(child, this.timeoutMs)
205
+ try { await this.pipe.send('Browser.getVersion') }
206
+ catch (error) {
207
+ await this.close()
208
+ const detail = this.stderr.trim().split('\n').slice(-2).join(' ')
209
+ throw new Error(`Could not start Chrome for viewport capture: ${detail || error.message}`)
210
+ }
211
+ }
212
+
213
+ async withPage({ url, viewport, waitMs = 300 }, fn) {
214
+ await this.ensureStarted()
215
+ const { targetId } = await this.pipe.send('Target.createTarget', { url: 'about:blank' })
216
+ const { sessionId } = await this.pipe.send('Target.attachToTarget', { targetId, flatten: true })
217
+ let unsubscribeRequests = null
218
+ try {
219
+ await this.pipe.send('Page.enable', {}, sessionId)
220
+ await this.pipe.send('Runtime.enable', {}, sessionId)
221
+ // The page is untrusted build output. Keep its network authority narrower
222
+ // than the lane sandbox: same preview origin only. This blocks internet,
223
+ // cloud metadata, and other localhost services while still allowing all
224
+ // JS/CSS/assets served from the selected build directory.
225
+ const allowedOrigin = new URL(url).origin
226
+ unsubscribeRequests = this.pipe.subscribe('Fetch.requestPaused', sessionId, (params) => {
227
+ let allowed = false
228
+ try { allowed = new URL(params.request?.url || '').origin === allowedOrigin } catch { allowed = false }
229
+ const method = allowed ? 'Fetch.continueRequest' : 'Fetch.failRequest'
230
+ const request = allowed
231
+ ? { requestId: params.requestId }
232
+ : { requestId: params.requestId, errorReason: 'BlockedByClient' }
233
+ void this.pipe.send(method, request, sessionId).catch(() => {})
234
+ })
235
+ await this.pipe.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] }, sessionId)
236
+ await this.pipe.send('Network.setBypassServiceWorker', { bypass: true }, sessionId).catch(() => {})
237
+ await this.pipe.send('Emulation.setDeviceMetricsOverride', {
238
+ width: viewport.width, height: viewport.height, deviceScaleFactor: 1, mobile: false,
239
+ screenWidth: viewport.width, screenHeight: viewport.height,
240
+ }, sessionId)
241
+ const loaded = this.pipe.waitFor('Page.loadEventFired', sessionId)
242
+ const nav = await this.pipe.send('Page.navigate', { url }, sessionId)
243
+ if (nav.errorText) throw new Error(`Preview navigation failed: ${nav.errorText}`)
244
+ await loaded
245
+ await this.pipe.send('Runtime.evaluate', {
246
+ expression: 'document.fonts && document.fonts.ready', awaitPromise: true, returnByValue: true,
247
+ }, sessionId).catch(() => {})
248
+ const settle = Math.min(MAX_SETTLE_MS, Math.max(0, Number(waitMs) || 0))
249
+ if (settle) await new Promise((resolve) => setTimeout(resolve, settle))
250
+ return await fn({ pipe: this.pipe, sessionId })
251
+ } finally {
252
+ unsubscribeRequests?.()
253
+ await this.pipe.send('Target.closeTarget', { targetId }).catch(() => {})
254
+ }
255
+ }
256
+
257
+ async capture({ url, viewport, fullPage = true, waitMs = 300 }) {
258
+ return this.withPage({ url, viewport, waitMs }, async ({ pipe, sessionId }) => {
259
+ const metrics = await pipe.send('Page.getLayoutMetrics', {}, sessionId)
260
+ const contentHeight = Math.ceil(metrics.cssContentSize?.height || viewport.height)
261
+ const height = fullPage ? Math.min(MAX_CAPTURE_HEIGHT, Math.max(viewport.height, contentHeight)) : viewport.height
262
+ const result = await pipe.send('Page.captureScreenshot', {
263
+ format: 'png', fromSurface: true, captureBeyondViewport: true,
264
+ clip: { x: 0, y: 0, width: viewport.width, height, scale: 1 },
265
+ }, sessionId)
266
+ return { png: Buffer.from(result.data, 'base64'), width: viewport.width, height, contentHeight, capped: contentHeight > MAX_CAPTURE_HEIGHT }
267
+ })
268
+ }
269
+
270
+ async inspect({ url, viewport = DEFAULT_VIEWPORTS.mobile, selector, waitMs = 300 }) {
271
+ return this.withPage({ url, viewport, waitMs }, async ({ pipe, sessionId }) => {
272
+ const expression = `(() => {
273
+ const selector = ${JSON.stringify(selector || null)};
274
+ const el = selector ? document.querySelector(selector) : null;
275
+ const r = el && el.getBoundingClientRect();
276
+ return {
277
+ title: document.title, url: location.href,
278
+ viewport: { width: innerWidth, height: innerHeight, dpr: devicePixelRatio },
279
+ document: { width: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth || 0), height: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight || 0) },
280
+ 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,
281
+ text: document.body?.innerText?.slice(0, 12000) || ''
282
+ };
283
+ })()`
284
+ const result = await pipe.send('Runtime.evaluate', { expression, returnByValue: true }, sessionId)
285
+ if (result.exceptionDetails) throw new Error('DOM inspection failed in the preview page.')
286
+ return result.result?.value || {}
287
+ })
288
+ }
289
+
290
+ async close() {
291
+ const child = this.child
292
+ const pipe = this.pipe
293
+ this.child = null
294
+ this.pipe = null
295
+ if (pipe) await pipe.send('Browser.close').catch(() => {})
296
+ if (child && child.exitCode == null) { try { child.kill('SIGTERM') } catch { /* already gone */ } }
297
+ if (this.profileDir) await fsp.rm(this.profileDir, { recursive: true, force: true }).catch(() => {})
298
+ this.profileDir = null
299
+ }
300
+ }
301
+
302
+ export const sharedViewportBrowser = new CdpBrowser()
303
+
304
+ export class ViewportManager {
305
+ constructor({ workspaceRoot, ownerId, outbox, browser = sharedViewportBrowser, startPreviewImpl = startPreview } = {}) {
306
+ this.workspaceRoot = path.resolve(workspaceRoot || process.cwd())
307
+ this.ownerId = ownerId || randomUUID()
308
+ this.outbox = outbox || path.join(os.tmpdir(), 'thinkpool-viewport-captures', this.ownerId)
309
+ this.browser = browser
310
+ this.startPreviewImpl = startPreviewImpl
311
+ this.previewId = `viewport:${this.ownerId}`
312
+ this.preview = null
313
+ this.root = null
314
+ }
315
+
316
+ async start({ root = 'dist' } = {}) {
317
+ const resolved = await resolveContainedRoot(this.workspaceRoot, root)
318
+ await this.stop()
319
+ const preview = await this.startPreviewImpl({ dir: resolved, host: '127.0.0.1', port: 0, id: this.previewId })
320
+ const parsed = new URL(preview.url)
321
+ if (parsed.hostname !== '127.0.0.1' && parsed.hostname !== 'localhost') {
322
+ await preview.stop?.()
323
+ throw new Error('Preview server did not bind to loopback.')
324
+ }
325
+ this.preview = preview
326
+ this.root = resolved
327
+ return { url: preview.url, root: resolved }
328
+ }
329
+
330
+ requirePreview() {
331
+ if (!this.preview?.url) throw new Error('No preview is running. Call preview_start after building the app.')
332
+ return this.preview
333
+ }
334
+
335
+ pageUrl(route) {
336
+ const preview = this.requirePreview()
337
+ return new URL(normalizeRoute(route), preview.url).href
338
+ }
339
+
340
+ async capture({ route = '/', viewports = 'both', title = 'Viewport capture', fullPage = true, waitMs = 300 } = {}) {
341
+ const url = this.pageUrl(route)
342
+ const names = viewports === 'both' ? ['desktop', 'mobile'] : [viewports]
343
+ if (names.some((name) => !DEFAULT_VIEWPORTS[name])) throw new Error('viewports must be "both", "desktop", or "mobile".')
344
+ await fsp.mkdir(this.outbox, { recursive: true })
345
+ const slug = `${safeSlug(title)}-${Date.now().toString(36)}`
346
+ const captures = {}
347
+ for (const name of names) {
348
+ const result = await this.browser.capture({ url, viewport: DEFAULT_VIEWPORTS[name], fullPage, waitMs })
349
+ const file = path.join(this.outbox, `${slug}--${name}.png`)
350
+ await fsp.writeFile(file, result.png)
351
+ captures[name] = { ...result, file }
352
+ }
353
+ const html = this.root && fs.existsSync(path.join(this.root, 'index.html')) ? path.join(this.root, 'index.html') : null
354
+ const manifest = {
355
+ slug, title: String(title || 'Viewport capture').slice(0, 120), html,
356
+ desktop: captures.desktop?.file || '', mobile: captures.mobile?.file || '', ts: Date.now(),
357
+ }
358
+ const manifestPath = path.join(this.outbox, `${slug}.json`)
359
+ const tmp = `${manifestPath}.tmp`
360
+ await fsp.writeFile(tmp, JSON.stringify(manifest))
361
+ await fsp.rename(tmp, manifestPath)
362
+ return { url, slug, manifestPath, captures }
363
+ }
364
+
365
+ inspect({ route = '/', viewport = 'mobile', selector, waitMs = 300 } = {}) {
366
+ if (!DEFAULT_VIEWPORTS[viewport]) throw new Error('viewport must be "desktop" or "mobile".')
367
+ return this.browser.inspect({ url: this.pageUrl(route), viewport: DEFAULT_VIEWPORTS[viewport], selector, waitMs })
368
+ }
369
+
370
+ async stop() {
371
+ const preview = this.preview || previews.get(this.previewId)
372
+ this.preview = null
373
+ this.root = null
374
+ if (preview?.stop) await preview.stop()
375
+ }
376
+ }
377
+
378
+ const textResult = (text) => ({ content: [{ type: 'text', text }] })
379
+ const errorResult = (error) => textResult(`Viewport error: ${error?.message || error}`)
380
+
381
+ export function createViewportTools({ tool, z, manager }) {
382
+ return [
383
+ tool(
384
+ 'preview_start',
385
+ '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.',
386
+ { root: z.string().max(240).optional().describe('relative directory inside this lane workspace; default: dist') },
387
+ async (args) => {
388
+ try {
389
+ const started = await manager.start({ root: args?.root || 'dist' })
390
+ 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.`)
391
+ } catch (error) { return errorResult(error) }
392
+ },
393
+ ),
394
+ tool(
395
+ 'preview_capture',
396
+ 'Capture this lane preview at exact desktop (1440×900) and mobile (390×844) CSS viewports. Defaults to both and full-page. Returns the PNGs to your vision context and also surfaces a mockup card in the ThinkPool room.',
397
+ {
398
+ path: z.string().max(500).optional().describe('route within the preview, e.g. / or /settings; never a full URL'),
399
+ viewports: z.enum(['both', 'desktop', 'mobile']).optional(),
400
+ title: z.string().max(120).optional(),
401
+ fullPage: z.boolean().optional(),
402
+ waitMs: z.number().int().min(0).max(MAX_SETTLE_MS).optional().describe('settle time after load, max 5000ms'),
403
+ },
404
+ async (args) => {
405
+ try {
406
+ const result = await manager.capture({
407
+ route: args?.path || '/', viewports: args?.viewports || 'both', title: args?.title || 'Viewport capture',
408
+ fullPage: args?.fullPage !== false, waitMs: args?.waitMs ?? 300,
409
+ })
410
+ const content = [{ type: 'text', text: `Captured ${Object.keys(result.captures).join(' + ')} for ${args?.path || '/'}. A room preview card is being delivered.` }]
411
+ for (const [name, capture] of Object.entries(result.captures)) {
412
+ content.push({ type: 'text', text: `${name}: ${capture.width}×${capture.height}${capture.capped ? ' (height capped)' : ''}` })
413
+ content.push({ type: 'image', data: capture.png.toString('base64'), mimeType: 'image/png' })
414
+ }
415
+ return { content }
416
+ } catch (error) { return errorResult(error) }
417
+ },
418
+ ),
419
+ tool(
420
+ 'preview_inspect',
421
+ 'Inspect rendered DOM state in this lane preview at an exact desktop or mobile viewport. Returns page text, document dimensions, and optional selector text/geometry without changing the page.',
422
+ {
423
+ path: z.string().max(500).optional().describe('route within the preview; never a full URL'),
424
+ viewport: z.enum(['desktop', 'mobile']).optional(),
425
+ selector: z.string().max(500).optional(),
426
+ waitMs: z.number().int().min(0).max(MAX_SETTLE_MS).optional(),
427
+ },
428
+ async (args) => {
429
+ try {
430
+ const result = await manager.inspect({ route: args?.path || '/', viewport: args?.viewport || 'mobile', selector: args?.selector, waitMs: args?.waitMs ?? 300 })
431
+ return textResult(JSON.stringify(result, null, 2))
432
+ } catch (error) { return errorResult(error) }
433
+ },
434
+ ),
435
+ tool(
436
+ 'preview_stop',
437
+ 'Stop this lane\'s bridge-owned preview server and release its port.',
438
+ {},
439
+ async () => {
440
+ try { await manager.stop(); return textResult('Preview stopped.') }
441
+ catch (error) { return errorResult(error) }
442
+ },
443
+ ),
444
+ ]
445
+ }