thinkpool-pair 0.7.205 → 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.
@@ -101,7 +102,7 @@ import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSP
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'
104
- import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
105
+ import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, codexUsageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
105
106
  import { planMeterLine } from './plan-meters.mjs'
106
107
  import { makeThrottledTrack } from './presence.mjs'
107
108
  import { resolveAnonKey, DEFAULT_SUPABASE_URL } from './supabase-key.mjs'
@@ -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
@@ -2425,6 +2431,20 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2425
2431
  : `\n ${A.yel}● permission: ${req.toolName}${argStr(req.input)} — approve in the room.${A.rst}\n`)
2426
2432
  }),
2427
2433
  })
2434
+ // A restored Codex lane may have a stale pre-0.7.206 meter persisted from the
2435
+ // cumulative `turn.completed` bug. Replace it synchronously from the native
2436
+ // rollout before announce/replay, so a reload is truthful even before the next
2437
+ // human turn completes.
2438
+ if (runtime === 'codex' && entry.session?.usageSnapshot?.context) {
2439
+ const { used, max } = entry.session.usageSnapshot.context
2440
+ entry.lastUsage = { kind: 'usage', ctx: {
2441
+ used,
2442
+ max,
2443
+ pct: Math.min(100, Math.max(0, Math.round((used / max) * 100))),
2444
+ over: used > max,
2445
+ model: entry.model,
2446
+ } }
2447
+ }
2428
2448
  // Brand-new session (no restored log): write its record now so a restart that lands
2429
2449
  // before the first debounced save still restores this id instead of dropping it (the
2430
2450
  // room would otherwise show a fresh empty terminal + strand the transcript).
@@ -2530,6 +2550,7 @@ function respawnStructured(id, provider) {
2530
2550
  drainPending(s)
2531
2551
  try { s.session?.end() } catch { /* noop */ }
2532
2552
  try { s.mockupWatcher?.close() } catch { /* noop */ }
2553
+ try { void s.viewport?.stop()?.catch(() => {}) } catch { /* noop */ }
2533
2554
  sessions.delete(id) // openStructured early-returns if the id is still mapped
2534
2555
  // Re-open under the SAME id with the new provider env. openStructured persists
2535
2556
  // sessionData() (provider included) synchronously on open, so a bridge restart
@@ -2572,6 +2593,7 @@ function endStructured(id) {
2572
2593
  drainPending(s)
2573
2594
  try { s.session?.end() } catch { /* noop */ }
2574
2595
  try { s.mockupWatcher?.close() } catch { /* noop */ }
2596
+ try { void s.viewport?.stop()?.catch(() => {}) } catch { /* noop */ }
2575
2597
  sessions.delete(id)
2576
2598
  bcast('term-exit', { id })
2577
2599
  reapTerminalRow(id) // bridge-side reap: delete the code_terminals row ourselves — the
@@ -2973,17 +2995,19 @@ channel
2973
2995
  s.session.sendTurn(text)
2974
2996
  return
2975
2997
  }
2976
- // /usage → report this session's token + cost totals WITHOUT burning an agent turn.
2977
- // Computed by REPLAYING the session's own retained result events (each completed turn
2978
- // persists kind:'result' with usage + costUsd + model — see onEvent). No new accumulator
2979
- // state: the log is durable, so this survives a bridge restart for free. Reported as ◆
2998
+ // /usage → report token totals WITHOUT burning an agent turn. Claude folds
2999
+ // retained per-turn result events; Codex reads its native rollout snapshot
3000
+ // because the CLI's public completion counters are lifetime thread totals.
3001
+ // Reported as ◆
2980
3002
  // control lines (like /model) so both members AND late-joiners see it. Two lines: the
2981
3003
  // token/cost report (instant, from the retained log), then the plan meters (5h + weekly
2982
3004
  // utilization + server-authoritative resets) once the OAuth usage endpoint answers. The
2983
3005
  // meter line is fired-and-forgotten so a slow/unreachable endpoint never stalls /usage;
2984
3006
  // it degrades to "plan meters unavailable" rather than printing an inferred reset.
2985
3007
  if (/^\/usage\s*$/.test(text)) {
2986
- ctlLine(usageReportLine(s.model, s.log))
3008
+ ctlLine(s.runtime === 'codex'
3009
+ ? codexUsageReportLine(s.model, s.session?.usageSnapshot)
3010
+ : usageReportLine(s.model, s.log))
2987
3011
  if (s.runtime !== 'codex') planMeterLine().then((l) => { if (l) ctlLine(l) }).catch(() => { /* meters are never load-bearing */ })
2988
3012
  return
2989
3013
  }
@@ -3693,6 +3717,7 @@ async function shutdown(code = 0, farewell = true) {
3693
3717
  setTimeout(() => process.exit(code), 1500)
3694
3718
  clearInterval(flushTimer)
3695
3719
  try { stopAllPreviews() } catch { /* Flow preview servers — best-effort close */ }
3720
+ try { void sharedViewportBrowser.close().catch(() => {}) } catch { /* bridge-owned Chrome — best-effort close */ }
3696
3721
  if (farewell) {
3697
3722
  try {
3698
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.',
@@ -42,7 +42,7 @@ export class CodexEventMapper {
42
42
  /**
43
43
  * @param {{onEvent:(evt)=>void, model?:string|null, sessionId?:string|null}} opts
44
44
  */
45
- constructor({ onEvent, model = null, sessionId = null, fsImpl = fs, contextWindowForModel = () => null }) {
45
+ constructor({ onEvent, model = null, sessionId = null, fsImpl = fs, usageSnapshotForSession = () => null }) {
46
46
  this._onEvent = onEvent
47
47
  this._model = model || null
48
48
  this._sessionId = sessionId || null
@@ -50,7 +50,8 @@ export class CodexEventMapper {
50
50
  this._fileBefore = new Map() // item.id → [{ path, kind, content }]
51
51
  this._fs = fsImpl
52
52
  this._systemSent = false
53
- this._contextWindowForModel = contextWindowForModel
53
+ this._usageSnapshotForSession = usageSnapshotForSession
54
+ this._usageBaseline = null
54
55
  }
55
56
 
56
57
  /** Emit a room-event; never let a consumer throw into the stream. */
@@ -58,6 +59,8 @@ export class CodexEventMapper {
58
59
 
59
60
  setModel(model) { this._model = model || null }
60
61
 
62
+ setUsageBaseline(snapshot) { this._usageBaseline = snapshot || null }
63
+
61
64
  /** Process one parsed Codex JSONL event. */
62
65
  push(ev) {
63
66
  if (!ev || typeof ev !== 'object') return
@@ -142,17 +145,22 @@ export class CodexEventMapper {
142
145
 
143
146
  case 'turn.completed': {
144
147
  const u = ev.usage || {}
145
- // Map Codex usage → Claude-shape usage (what event-id.mjs folds into cost).
146
- // reasoning_output_tokens are billed as output → fold in (conservative).
147
- const out = (Number(u.output_tokens) || 0) + (Number(u.reasoning_output_tokens) || 0)
148
- const totalInput = Number(u.input_tokens) || 0
149
- const cachedInput = Math.min(totalInput, Number(u.cached_input_tokens) || 0)
150
- let max = null
151
- try { max = Number(this._contextWindowForModel(this._model)) } catch { /* unknown catalog → omit meter */ }
152
- if (Number.isFinite(max) && max > 0) {
153
- const used = totalInput + out
148
+ let snapshot = null
149
+ try { snapshot = this._usageSnapshotForSession(this._sessionId) } catch { /* unreadable rollout → omit meter */ }
150
+ const used = Number(snapshot?.context?.used)
151
+ const max = Number(snapshot?.context?.max)
152
+ if (Number.isFinite(used) && used >= 0 && Number.isFinite(max) && max > 0) {
154
153
  this._emit({ kind: 'usage', ctx: { used, max, pct: Math.min(100, Math.max(0, Math.round((used / max) * 100))), over: used > max, model: this._model } })
155
154
  }
155
+ // Codex's turn.completed values are lifetime thread totals, not turn
156
+ // deltas. Prefer the native rollout totals and subtract the pre-turn
157
+ // baseline. A fresh thread has no baseline, so its totals equal turn 1.
158
+ const totals = snapshot?.total || u
159
+ const before = this._usageBaseline?.total || {}
160
+ const totalInput = Math.max(0, (Number(totals.input_tokens) || 0) - (Number(before.input_tokens) || 0))
161
+ const cachedInput = Math.min(totalInput, Math.max(0, (Number(totals.cached_input_tokens) || 0) - (Number(before.cached_input_tokens) || 0)))
162
+ const out = Math.max(0, (Number(totals.output_tokens) || 0) - (Number(before.output_tokens) || 0))
163
+ this._usageBaseline = snapshot || null
156
164
  this._emit({
157
165
  kind: 'result',
158
166
  subtype: 'success',
@@ -161,8 +169,7 @@ export class CodexEventMapper {
161
169
  costUsd: null,
162
170
  usage: {
163
171
  // Claude-shape fields are mutually exclusive buckets. Codex reports
164
- // cached_input_tokens as a subset of input_tokens, so subtract it here;
165
- // usageReportLine adds the buckets and must not double-count cache hits.
172
+ // cached_input_tokens as a subset of input_tokens, so subtract it.
166
173
  input_tokens: totalInput - cachedInput,
167
174
  cache_read_input_tokens: cachedInput,
168
175
  cache_creation_input_tokens: 0,
package/codex-session.mjs CHANGED
@@ -65,17 +65,68 @@ export function readCodexModels({ home = process.env.CODEX_HOME || path.join(os.
65
65
  } catch { return [] }
66
66
  }
67
67
 
68
- // The CLI's current model catalog is the authority for the runtime's usable model
69
- // window. Keep this separate from readCodexModels so the public announce shape stays
70
- // stable while the driver can emit a truthful context meter for every Codex model.
71
- export function readCodexContextWindow(model, { home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), fsImpl = fs } = {}) {
72
- if (!model) return null
68
+ const rolloutPathCache = new Map()
69
+
70
+ function codexRolloutPath(sessionId, { home, fsImpl }) {
71
+ if (!sessionId) return null
72
+ const cacheKey = `${home}\0${sessionId}`
73
+ const cached = rolloutPathCache.get(cacheKey)
74
+ if (cached) return cached
75
+ const root = path.join(home, 'sessions')
73
76
  try {
74
- const data = JSON.parse(fsImpl.readFileSync(path.join(home, 'models_cache.json'), 'utf8'))
75
- const hit = (Array.isArray(data?.models) ? data.models : []).find((m) => m?.slug === model)
76
- const max = Number(hit?.context_window)
77
- return Number.isFinite(max) && max > 0 ? Math.floor(max) : null
77
+ const suffix = `${sessionId}.jsonl`
78
+ const hit = fsImpl.readdirSync(root, { recursive: true, withFileTypes: true })
79
+ .find((entry) => entry.isFile() && entry.name.endsWith(suffix))
80
+ if (!hit) return null
81
+ const found = path.join(hit.parentPath || hit.path || root, hit.name)
82
+ rolloutPathCache.set(cacheKey, found)
83
+ return found
84
+ } catch { return null }
85
+ }
86
+
87
+ // `codex exec --json` exposes lifetime thread totals on `turn.completed`. Those
88
+ // totals are useful for billing, but they are NOT context occupancy: an agentic
89
+ // turn can make hundreds of cached model calls and process many context windows.
90
+ // The native rollout's final token_count record carries both the exact last
91
+ // request and Codex's usable window, so read only a bounded tail of that file.
92
+ export function readCodexThreadUsage(sessionId, {
93
+ home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'),
94
+ fsImpl = fs,
95
+ tailBytes = 4 * 1024 * 1024,
96
+ } = {}) {
97
+ const file = codexRolloutPath(sessionId, { home, fsImpl })
98
+ if (!file) return null
99
+ let fd
100
+ try {
101
+ const size = Number(fsImpl.statSync(file).size) || 0
102
+ const length = Math.min(size, Math.max(64 * 1024, tailBytes))
103
+ const buf = Buffer.alloc(length)
104
+ fd = fsImpl.openSync(file, 'r')
105
+ fsImpl.readSync(fd, buf, 0, length, size - length)
106
+ const lines = buf.toString('utf8').split('\n')
107
+ for (let i = lines.length - 1; i >= 0; i--) {
108
+ let row
109
+ try { row = JSON.parse(lines[i]) } catch { continue }
110
+ if (row?.payload?.type !== 'token_count' || !row.payload.info) continue
111
+ const info = row.payload.info
112
+ const last = info.last_token_usage || {}
113
+ const total = info.total_token_usage || {}
114
+ const used = Number(last.total_tokens)
115
+ const max = Number(info.model_context_window)
116
+ if (!Number.isFinite(used) || used < 0 || !Number.isFinite(max) || max <= 0) return null
117
+ return {
118
+ context: { used: Math.floor(used), max: Math.floor(max) },
119
+ total: {
120
+ input_tokens: Math.max(0, Number(total.input_tokens) || 0),
121
+ cached_input_tokens: Math.max(0, Number(total.cached_input_tokens) || 0),
122
+ output_tokens: Math.max(0, Number(total.output_tokens) || 0),
123
+ },
124
+ rateLimits: row.payload.rate_limits || null,
125
+ }
126
+ }
78
127
  } catch { return null }
128
+ finally { if (fd != null) { try { fsImpl.closeSync(fd) } catch { /* noop */ } } }
129
+ return null
79
130
  }
80
131
 
81
132
  export function normalizeCodexSandbox(value) {
@@ -158,6 +209,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
158
209
  let turnActive = false
159
210
  let activeModel = model || null
160
211
  let activeEffort = EFFORT_LEVELS.has(initialEffort) ? initialEffort : 'high'
212
+ let latestUsageSnapshot = sessionId ? readCodexThreadUsage(sessionId) : null
161
213
  let mcpHttpPromise = null
162
214
  // serialize turns: one exec at a time
163
215
  let chain = Promise.resolve()
@@ -166,7 +218,11 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
166
218
  const mapper = new CodexEventMapper({
167
219
  onEvent,
168
220
  model: model || null,
169
- contextWindowForModel: (id) => readCodexContextWindow(id),
221
+ usageSnapshotForSession: (id) => {
222
+ const snapshot = readCodexThreadUsage(id)
223
+ if (snapshot) latestUsageSnapshot = snapshot
224
+ return snapshot
225
+ },
170
226
  })
171
227
 
172
228
  const note = (text) => { try { onEvent?.({ kind: 'note', text }) } catch { /* noop */ } }
@@ -197,6 +253,10 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
197
253
  prompt,
198
254
  })
199
255
 
256
+ // Snapshot lifetime totals before the turn. The mapper subtracts this from
257
+ // the post-turn totals so result/accounting events contain this turn only.
258
+ mapper.setUsageBaseline(sessionId ? readCodexThreadUsage(sessionId) : null)
259
+
200
260
  return new Promise((resolve) => {
201
261
  // stdin: /dev/null (spike gotcha — codex blocks on stdin otherwise)
202
262
  let stdinFd
@@ -267,6 +327,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
267
327
 
268
328
  return {
269
329
  get sessionId() { return sessionId },
330
+ get usageSnapshot() { return latestUsageSnapshot },
270
331
  get turnActive() { return turnActive },
271
332
  get started() { return turnNo > 0 || turnActive },
272
333
  sendTurn(text) {
@@ -305,6 +366,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
305
366
  // next turn so the selected mode is real, while ThinkPool keeps the lane log.
306
367
  sessionId = null
307
368
  turnNo = 0
369
+ latestUsageSnapshot = null
308
370
  return true
309
371
  },
310
372
  setEffort(nextEffort) {
@@ -317,6 +379,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
317
379
  if (turnActive) return false
318
380
  sessionId = null
319
381
  turnNo = 0
382
+ latestUsageSnapshot = null
320
383
  return true
321
384
  },
322
385
  }
package/event-id.mjs CHANGED
@@ -284,6 +284,24 @@ export function usageReportLine (sessionModel, logEvents) {
284
284
  return `usage · ${turns} · ${perStr}${total}${cost}`
285
285
  }
286
286
 
287
+ // Codex exposes exact current-context and lifetime-thread counters in its native
288
+ // rollout telemetry. Keep this separate from usageReportLine: Claude result
289
+ // events are per turn, while Codex's public turn.completed counters are lifetime
290
+ // totals and cannot safely be folded from the retained log.
291
+ export function codexUsageReportLine (sessionModel, snapshot) {
292
+ const ctx = snapshot?.context
293
+ const total = snapshot?.total
294
+ if (!ctx || !total) return 'usage · no completed Codex turn yet'
295
+ const fmtTok = (n) => n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'k' : String(Math.round(n || 0))
296
+ const used = Math.max(0, Number(ctx.used) || 0)
297
+ const max = Math.max(0, Number(ctx.max) || 0)
298
+ const pct = max > 0 ? Math.min(100, Math.max(0, Math.round((used / max) * 100))) : 0
299
+ const input = Math.max(0, Number(total.input_tokens) || 0)
300
+ const cached = Math.min(input, Math.max(0, Number(total.cached_input_tokens) || 0))
301
+ const output = Math.max(0, Number(total.output_tokens) || 0)
302
+ return `usage · ${prettyModelId(sessionModel)} · context ${fmtTok(used)}/${fmtTok(max)} (${pct}%) · thread ${fmtTok(input)} processed (${fmtTok(cached)} cached) / ${fmtTok(output)} out`
303
+ }
304
+
287
305
  /* The context-carry recap moved to ./recap.mjs (ADR-0010 / S4, 2026-07-09).
288
306
  *
289
307
  * Why: the WEB CLIENT needs buildRecapFromLog to carry a lane's bridgeless
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.205",
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
+ }