thinkpool-pair 0.7.206 → 0.7.208

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.
@@ -97,7 +98,7 @@ const flowRedispatch = new Map()
97
98
  // wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
98
99
  // broadcasts; without persistent state the cap can never bite.
99
100
  const flowBudgets = new Map()
100
- import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint } from './cross-terminal.mjs'
101
+ import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, laneStatusOf } from './cross-terminal.mjs'
101
102
  import { armInterruptedResume, sendInterruptedContinue, supersedeInterruptedResume } from './interrupted-resume.mjs'
102
103
  import { turnInFlight } from './update-gate.mjs'
103
104
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
@@ -1033,18 +1034,17 @@ const announce = () => {
1033
1034
  // cols/rows: the PTY's one true size — web viewers render this grid and
1034
1035
  // scale it to their own page instead of voting to reflow it.
1035
1036
  terms: [
1036
- ...[...terms.entries()].map(([id, t]) => ({ id, cmd: t.cmd, alive: true, cols: t.term.cols, rows: t.term.rows, name: termNames[id] || undefined })),
1037
+ ...[...terms.entries()].map(([id, t]) => ({ id, cmd: t.cmd, alive: true, cols: t.term.cols, rows: t.term.rows, name: termNames[id] || undefined, ...laneStatusOf(t) })),
1037
1038
  // Structured sessions advertise kind:'structured' so the web renders the
1038
1039
  // reader (not xterm) and drives them with code-turn / code-perm. `mode` +
1039
1040
  // `name` ride along so a second device shows the live permission mode and
1040
1041
  // the chosen label instead of defaulting to Normal / unnamed. `spawned` (set
1041
1042
  // when an agent Dispatched this lane via spawn_terminal) lets the client keep
1042
1043
  // it OUT of the top tab strip and show it only in the Ensemble menu.
1043
- // busy: this structured session is mid-turn right now (the SDK's
1044
- // turnActive). Authoritative — the bridge owns the turn — so the /code
1045
- // dashboard reads "N agents working" off the roster instead of sniffing
1046
- // the live code-event stream (which a passive dashboard channel misses).
1047
- ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, busy: s.session?.turnActive ?? false, hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [] } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
1044
+ // laneStatusOf: authoritative busy/idle + last-action timestamp/age +
1045
+ // STUCK/BLOCKED alert. The bridge owns the turn and permission state, so
1046
+ // every roster consumer reads one status instead of reconstructing it.
1047
+ ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [] } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
1048
1048
  // provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
1049
1049
  // (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
1050
1050
  // built-in/default Claude path (no badge). Makes the lane's provider badge +
@@ -1095,6 +1095,7 @@ function raiseCard(entry, req) {
1095
1095
  if (!entry?.pending) return resolve('deny')
1096
1096
  const payload = { term: entry.id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk }
1097
1097
  entry.pending.set(req.id, { resolve, payload })
1098
+ announce()
1098
1099
  bcast('code-perm-req', payload)
1099
1100
  process.stderr.write(`\n ● incoming cross-room task for ${String(entry.id).slice(0, 8)} — approve in the room.\n`)
1100
1101
  })
@@ -1403,12 +1404,13 @@ function openTerm({ id, cmd, args = [], attached = false, cols, rows }) {
1403
1404
  rows: rows || (attached ? ad.rows : (PIN_ROWS || 32)),
1404
1405
  cwd: process.cwd(), env: { ...process.env, TP_MOCKUP_OUTBOX: mockupOutbox },
1405
1406
  })
1406
- const entry = { term, cmd, attached, scrollback: '', buf: '', mockupWatcher: watchOutbox(mockupOutbox, () => id) }
1407
+ const entry = { term, cmd, attached, scrollback: '', buf: '', lastActionAt: null, mockupWatcher: watchOutbox(mockupOutbox, () => id) }
1407
1408
  terms.set(id, entry)
1408
1409
  if (attached) attachedId = id
1409
1410
  term.onData(d => {
1410
1411
  if (attached) process.stdout.write(d)
1411
1412
  entry.buf += d
1413
+ entry.lastActionAt = Date.now()
1412
1414
  entry.scrollback = (entry.scrollback + d).slice(-SCROLLBACK_MAX)
1413
1415
  })
1414
1416
  term.onExit(() => {
@@ -1478,6 +1480,8 @@ function pushLog(entry, evt) {
1478
1480
  // A real ts makes ordering wall-clock — immune to eviction AND /clear seq resets.
1479
1481
  // stampEvent is idempotent, so the callers that already stamp are unaffected.
1480
1482
  stampEvent(evt)
1483
+ entry.lastActionAt = evt.ts
1484
+ entry.lastEvent = evt
1481
1485
  // Only TRANSCRIPT events consume a seq (seqable) — replay-chrome kinds the web
1482
1486
  // filters out (system/thinking_tokens/effort) ride the log seq-less so they don't
1483
1487
  // hole the client's applied sequence. See event-id.mjs NO_SEQ_KINDS.
@@ -1747,6 +1751,9 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
1747
1751
  // they land on this session even after other terminals open/close.
1748
1752
  const mockupOutbox = ownerOutbox('sessions', id)
1749
1753
  entry.mockupWatcher = watchOutbox(mockupOutbox, () => id)
1754
+ // Visual QA runs in the bridge process, outside either agent runtime's sandbox.
1755
+ // It remains scoped to this lane's cwd and private mockup outbox.
1756
+ entry.viewport = new ViewportManager({ workspaceRoot: cwd || process.cwd(), ownerId: id, outbox: mockupOutbox })
1750
1757
  if (entry.log.length) process.stderr.write(`\n ◆ restored ${entry.log.length} prior events (${id.slice(0, 8)})${resume ? ' + resuming live context' : ''}.\n`)
1751
1758
  // Persist the permission mode alongside the transcript so a bridge restart
1752
1759
  // restores the session in the SAME mode (a bypass room stays bypass on resume).
@@ -1776,9 +1783,10 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
1776
1783
  name: 'thinkpool',
1777
1784
  version: '1.0.0',
1778
1785
  tools: [
1786
+ ...createViewportTools({ tool, z, manager: entry.viewport }),
1779
1787
  tool(
1780
1788
  'read_terminal',
1781
- '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.',
1789
+ '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 every open terminal with busy/idle state, last-action age, and any STUCK/BLOCKED flag; call with `terminal` (a ref, id, or command from that list) to read its recent activity. It never changes another terminal — reading only. Use the one-read roster to identify a lane that needs attention before opening its transcript.',
1782
1790
  {
1783
1791
  terminal: z.string().optional().describe('ref, id, or command of the terminal to read; omit to list the open terminals'),
1784
1792
  lines: z.number().int().positive().max(PEEK.maxLines).optional().describe(`how many recent lines to return (default ${PEEK.defaultLines})`),
@@ -2199,6 +2207,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2199
2207
  process.stderr.write(`\n ◆ saved session expired — starting fresh (transcript kept).\n`)
2200
2208
  try { entry.session?.end() } catch { /* noop */ }
2201
2209
  try { entry.mockupWatcher?.close() } catch { /* noop */ }
2210
+ try { void entry.viewport?.stop()?.catch(() => {}) } catch { /* noop */ }
2202
2211
  sessions.delete(id)
2203
2212
  openStructured({ id, runtime: entry.runtime, model, log: entry.log, commands: entry.commands, mode: entry.mode })
2204
2213
  return
@@ -2210,6 +2219,10 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2210
2219
  // id, an event that arrives both live AND in a reconnect replay renders
2211
2220
  // twice (the 2026-06-19 duplicate-message bug). See event-id.mjs.
2212
2221
  stampEvent(evt)
2222
+ const stalledChanged = evt.kind === 'stalled' ? !entry.stalled : !!entry.stalled
2223
+ entry.stalled = evt.kind === 'stalled'
2224
+ entry.lastEvent = evt
2225
+ if (evt.kind !== 'stalled') entry.lastActionAt = evt.ts
2213
2226
  // Re-announce the roster on THIS session's busy EDGE — turn start (turnActive
2214
2227
  // flips true on the turn's first event) and turn end (false on result/error).
2215
2228
  // Edge-triggered: one announce per transition, not per token event. This is what
@@ -2221,7 +2234,9 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2221
2234
  // bridge sees "this lane just began working". `result`/`error` reads it to decide
2222
2235
  // whether the turn was long enough that nobody was watching (Slice 3).
2223
2236
  { const busyNow = entry.session?.turnActive ?? false
2224
- if (busyNow !== entry._busyAnn) { if (busyNow) entry._turnStart = Date.now(); entry._busyAnn = busyNow; announce() } }
2237
+ const busyChanged = busyNow !== entry._busyAnn
2238
+ if (busyChanged) { if (busyNow) entry._turnStart = Date.now(); entry._busyAnn = busyNow }
2239
+ if (busyChanged || stalledChanged) announce() }
2225
2240
  // Capture this session's current MODEL (resolved full id, with version — from
2226
2241
  // system/result `model` or usage `ctx.model`) and the SDK's supported-model
2227
2242
  // LIST (from the `models` event) so the announce carries them to EVERY device.
@@ -2410,6 +2425,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2410
2425
  // (replay-request handler) — pending cards never enter the replayed event log.
2411
2426
  const payload = { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan, questions: req.questions }
2412
2427
  entry.pending.set(req.id, { resolve, payload })
2428
+ announce()
2413
2429
  bcast('code-perm-req', payload)
2414
2430
  // Slice 3 — arm the grace timer. Fires a "needs you" push only if the card is
2415
2431
  // STILL unanswered 20s from now (you're not in the room). Every resolve path —
@@ -2544,6 +2560,7 @@ function respawnStructured(id, provider) {
2544
2560
  drainPending(s)
2545
2561
  try { s.session?.end() } catch { /* noop */ }
2546
2562
  try { s.mockupWatcher?.close() } catch { /* noop */ }
2563
+ try { void s.viewport?.stop()?.catch(() => {}) } catch { /* noop */ }
2547
2564
  sessions.delete(id) // openStructured early-returns if the id is still mapped
2548
2565
  // Re-open under the SAME id with the new provider env. openStructured persists
2549
2566
  // sessionData() (provider included) synchronously on open, so a bridge restart
@@ -2586,6 +2603,7 @@ function endStructured(id) {
2586
2603
  drainPending(s)
2587
2604
  try { s.session?.end() } catch { /* noop */ }
2588
2605
  try { s.mockupWatcher?.close() } catch { /* noop */ }
2606
+ try { void s.viewport?.stop()?.catch(() => {}) } catch { /* noop */ }
2589
2607
  sessions.delete(id)
2590
2608
  bcast('term-exit', { id })
2591
2609
  reapTerminalRow(id) // bridge-side reap: delete the code_terminals row ourselves — the
@@ -3032,11 +3050,11 @@ channel
3032
3050
  // use run/accept/keep (claude-session interprets). Default deny on missing.
3033
3051
  // Answered from a device → retract the "needs you" banner on every OTHER device
3034
3052
  // (permNotifier.resolve is a no-op unless its grace timer already fired).
3035
- if (p) { s.pending.delete(payload.id); s.permNotifier?.resolve(payload.id); p.resolve(payload.decision || 'deny') }
3053
+ if (p) { s.pending.delete(payload.id); s.permNotifier?.resolve(payload.id); p.resolve(payload.decision || 'deny'); announce() }
3036
3054
  })
3037
3055
  .on('broadcast', { event: 'code-abort' }, ({ payload }) => {
3038
3056
  const s = payload?.term && sessions.get(payload.term)
3039
- if (s) { drainPending(s); s.session.abort() } // settle any open permission card so the hook doesn't hang
3057
+ if (s) { drainPending(s); s.session.abort(); announce() } // settle any open permission card so the hook doesn't hang
3040
3058
  })
3041
3059
  .on('broadcast', { event: 'code-mode' }, ({ payload }) => {
3042
3060
  const s = payload?.term && sessions.get(payload.term)
@@ -3059,6 +3077,7 @@ channel
3059
3077
  if (payload.mode === 'bypassPermissions') allowPending(s)
3060
3078
  // Flipping to acceptEdits = "auto-approve writes" — clear the write cards already raised.
3061
3079
  else if (payload.mode === 'acceptEdits') acceptEditsPending(s)
3080
+ announce()
3062
3081
  }
3063
3082
  })
3064
3083
  .on('broadcast', { event: 'code-effort' }, ({ payload }) => {
@@ -3709,6 +3728,7 @@ async function shutdown(code = 0, farewell = true) {
3709
3728
  setTimeout(() => process.exit(code), 1500)
3710
3729
  clearInterval(flushTimer)
3711
3730
  try { stopAllPreviews() } catch { /* Flow preview servers — best-effort close */ }
3731
+ try { void sharedViewportBrowser.close().catch(() => {}) } catch { /* bridge-owned Chrome — best-effort close */ }
3712
3732
  if (farewell) {
3713
3733
  try {
3714
3734
  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.',
@@ -18,6 +18,76 @@ export const PEEK = {
18
18
  rosterPreview: 100, // last-line preview length in the roster listing
19
19
  }
20
20
 
21
+ // One shared lane-status classifier for every roster consumer: the bridge wire,
22
+ // read_terminal, and ROOM NOW. Timestamps stay absolute on the wire so clients can
23
+ // keep the displayed age current without a broadcast every second.
24
+ export const LANE_STATUS = {
25
+ stuckAfterMs: 90_000, // matches the structured-turn stall watchdog
26
+ repeatedFailureCount: 2,
27
+ failureWindow: 12,
28
+ }
29
+
30
+ const failureEvent = (e) => !!e && (
31
+ e.kind === 'error'
32
+ || (e.kind === 'tool_result' && e.isError)
33
+ || (e.kind === 'result' && e.subtype && e.subtype !== 'success')
34
+ )
35
+
36
+ const recentFailureCount = (log, limits = LANE_STATUS) => {
37
+ let failures = 0, seen = 0
38
+ for (let i = (Array.isArray(log) ? log.length : 0) - 1; i >= 0 && seen < limits.failureWindow; i--) {
39
+ const e = log[i]
40
+ if (!e || typeof e !== 'object') continue
41
+ seen++
42
+ // A clean completion or a fresh human task closes the prior failure streak.
43
+ if ((e.kind === 'result' && (!e.subtype || e.subtype === 'success')) || e.kind === 'you') break
44
+ if (failureEvent(e)) failures++
45
+ }
46
+ return failures
47
+ }
48
+
49
+ export const laneStatusOf = (entry = {}, now = Date.now(), limits = LANE_STATUS) => {
50
+ const baseLog = Array.isArray(entry?.log) ? entry.log : []
51
+ const log = baseLog.slice(-limits.failureWindow)
52
+ if (entry?.lastEvent && entry.lastEvent !== baseLog[baseLog.length - 1]
53
+ && (!entry.lastEvent.cid || entry.lastEvent.cid !== baseLog[baseLog.length - 1]?.cid)) log.push(entry.lastEvent)
54
+ let lastLogAt = null
55
+ for (let i = log.length - 1; i >= 0; i--) {
56
+ if (Number.isFinite(log[i]?.ts)) { lastLogAt = log[i].ts; break }
57
+ }
58
+ const lastActionAt = Number.isFinite(entry?.lastActionAt) ? entry.lastActionAt : (lastLogAt ?? null)
59
+ const lastActionAgeMs = lastActionAt == null ? null : Math.max(0, now - lastActionAt)
60
+ const busy = typeof entry?.busy === 'boolean' ? entry.busy : (entry?.session?.turnActive ?? false)
61
+ const pendingCount = entry?.pending instanceof Map
62
+ ? entry.pending.size
63
+ : Math.max(0, Number(entry?.pendingCount) || 0)
64
+ const failures = recentFailureCount(log, limits)
65
+
66
+ let statusFlag = null, statusReason = null
67
+ if (pendingCount > 0) {
68
+ statusFlag = 'BLOCKED'; statusReason = 'waiting for input'
69
+ } else if (entry?.stalled || (busy && !entry?.compacting && lastActionAgeMs != null && lastActionAgeMs >= limits.stuckAfterMs)) {
70
+ statusFlag = 'STUCK'; statusReason = 'no events while busy'
71
+ } else if (failures >= limits.repeatedFailureCount) {
72
+ statusFlag = 'BLOCKED'; statusReason = 'repeated errors'
73
+ }
74
+
75
+ return { busy: !!busy, lastActionAt, lastActionAgeMs, statusFlag, statusReason }
76
+ }
77
+
78
+ export const formatLaneAge = (ageMs) => {
79
+ if (!Number.isFinite(ageMs)) return 'no activity'
80
+ const s = Math.max(0, Math.floor(ageMs / 1000))
81
+ if (s < 60) return `${s}s ago`
82
+ const m = Math.floor(s / 60)
83
+ if (m < 60) return `${m}m ago`
84
+ const h = Math.floor(m / 60)
85
+ if (h < 24) return `${h}h ago`
86
+ return `${Math.floor(h / 24)}d ago`
87
+ }
88
+
89
+ const laneStatusText = (x) => `${x.busy ? 'busy' : 'idle'} · ${formatLaneAge(x.lastActionAgeMs)}${x.statusFlag ? ` · ${x.statusFlag}` : ''}`
90
+
21
91
  const clip = (s, cap) => {
22
92
  const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
23
93
  return t.length > cap ? t.slice(0, cap - 1) + '…' : t
@@ -67,16 +137,16 @@ export const siblingRef = (id) => String(id).slice(0, 8)
67
137
  // is the bridge's id→label map (termNames) — the HUMAN names people actually use
68
138
  // ("Pivot", "Grug"); without it the roster is just hashes and an agent can't map
69
139
  // "what is Pivot doing" to a lane.
70
- export const siblingsOf = ({ selfId, sessions, terms, names = {} } = {}) => {
140
+ export const siblingsOf = ({ selfId, sessions, terms, names = {}, now = Date.now() } = {}) => {
71
141
  const nm = (id) => (names && names[id]) || ''
72
142
  const sib = []
73
143
  for (const [id, s] of (sessions || new Map())) {
74
144
  if (id === selfId) continue
75
- sib.push({ id, ref: siblingRef(id), name: nm(id), kind: 'agent', cmd: s?.cmd || 'claude', log: Array.isArray(s?.log) ? s.log : [] })
145
+ sib.push({ id, ref: siblingRef(id), name: nm(id), kind: 'agent', cmd: s?.cmd || 'claude', log: Array.isArray(s?.log) ? s.log : [], ...laneStatusOf(s, now) })
76
146
  }
77
147
  for (const [id, t] of (terms || new Map())) {
78
148
  if (id === selfId) continue
79
- sib.push({ id, ref: siblingRef(id), name: nm(id), kind: 'shell', cmd: t?.cmd || 'shell', buf: t?.buf || '' })
149
+ sib.push({ id, ref: siblingRef(id), name: nm(id), kind: 'shell', cmd: t?.cmd || 'shell', buf: t?.buf || '', ...laneStatusOf(t, now) })
80
150
  }
81
151
  return sib
82
152
  }
@@ -121,9 +191,9 @@ export const resolveSibling = (siblings, terminal) => {
121
191
 
122
192
  const label = (x) => `${x.ref}${x.name ? ` · "${x.name}"` : ''}`
123
193
 
124
- export const formatPeek = ({ selfId, sessions, terms, names = {}, terminal, lines } = {}) => {
194
+ export const formatPeek = ({ selfId, sessions, terms, names = {}, terminal, lines, now = Date.now() } = {}) => {
125
195
  const n = Math.min(PEEK.maxLines, Math.max(1, Number(lines) || PEEK.defaultLines))
126
- const sib = siblingsOf({ selfId, sessions, terms, names })
196
+ const sib = siblingsOf({ selfId, sessions, terms, names, now })
127
197
  if (!sib.length) return 'No other terminals are open in this room right now.'
128
198
 
129
199
  if (!terminal) {
@@ -131,7 +201,7 @@ export const formatPeek = ({ selfId, sessions, terms, names = {}, terminal, line
131
201
  let preview
132
202
  if (x.kind === 'agent') { const ls = x.log.map(e => digestLogEvent(e, PEEK.rosterPreview)).filter(Boolean); preview = ls[ls.length - 1] || '(no activity yet)' }
133
203
  else { preview = cleanPtyTail(x.buf, 1)[0] || '(no output yet)' }
134
- return `- ${label(x)} · ${x.kind} · ${x.cmd} — ${clip(preview, PEEK.rosterPreview)}`
204
+ return `- ${label(x)} · ${x.kind} · ${x.cmd} · ${laneStatusText(x)} — ${clip(preview, PEEK.rosterPreview)}`
135
205
  })
136
206
  return `Other terminals in this room (call read_terminal with a ref or name to read one):\n${rows.join('\n')}`
137
207
  }
@@ -162,8 +232,8 @@ export const formatPeek = ({ selfId, sessions, terms, names = {}, terminal, line
162
232
  // when there is nothing to show (the reminder then carries no tail at all).
163
233
  // Spec ask (2026-07-02): "terminals need to be completely aware of the session."
164
234
  export const ROOMNOW = { laneCap: 8, previewLen: 70, wtCap: 10 }
165
- export const formatRoomNow = ({ selfId, sessions, terms, names = {}, worktrees = [] } = {}, limits = ROOMNOW) => {
166
- const sib = siblingsOf({ selfId, sessions, terms, names })
235
+ export const formatRoomNow = ({ selfId, sessions, terms, names = {}, worktrees = [], now = Date.now() } = {}, limits = ROOMNOW) => {
236
+ const sib = siblingsOf({ selfId, sessions, terms, names, now })
167
237
  const out = []
168
238
  if (sib.length) {
169
239
  const shown = sib.slice(0, limits.laneCap)
@@ -171,7 +241,7 @@ export const formatRoomNow = ({ selfId, sessions, terms, names = {}, worktrees =
171
241
  let preview
172
242
  if (x.kind === 'agent') { const ls = x.log.map(e => digestLogEvent(e, limits.previewLen)).filter(Boolean); preview = ls[ls.length - 1] || '(no activity yet)' }
173
243
  else preview = cleanPtyTail(x.buf, 1)[0] || '(no output yet)'
174
- return `- ${label(x)} · ${x.kind} — ${clip(preview, limits.previewLen)}`
244
+ return `- ${label(x)} · ${x.kind} · ${laneStatusText(x)} — ${clip(preview, limits.previewLen)}`
175
245
  })
176
246
  if (sib.length > shown.length) rows.push(`- … +${sib.length - shown.length} more (read_terminal lists all)`)
177
247
  out.push(`Other lanes in this room right now (read_terminal for detail):\n${rows.join('\n')}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.206",
3
+ "version": "0.7.208",
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
+ }