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 +19 -0
- package/bridge.mjs +31 -6
- package/claude-session.mjs +6 -0
- package/codex-event-mapper.mjs +20 -13
- package/codex-session.mjs +73 -10
- package/event-id.mjs +18 -0
- package/package.json +2 -1
- package/viewport.mjs +445 -0
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
|
|
2977
|
-
//
|
|
2978
|
-
//
|
|
2979
|
-
//
|
|
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(
|
|
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')
|
package/claude-session.mjs
CHANGED
|
@@ -486,6 +486,11 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
486
486
|
if (toolName === 'mcp__thinkpool__read_terminal') {
|
|
487
487
|
return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (read-only ThinkPool cross-terminal read).' } }
|
|
488
488
|
}
|
|
489
|
+
// Bridge-owned visual QA is constrained to the lane cwd + a loopback URL
|
|
490
|
+
// created by the bridge itself, so it does not need a room permission card.
|
|
491
|
+
if (/^mcp__thinkpool__preview_(start|capture|inspect|stop)$/.test(toolName)) {
|
|
492
|
+
return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (contained bridge-owned viewport preview).' } }
|
|
493
|
+
}
|
|
489
494
|
// FL-B1 — the Flow conductor submits its task-graph through this tool (replaces the
|
|
490
495
|
// deferred/hanging ExitPlanMode). It only broadcasts a plan for HUMAN approval — no FS or
|
|
491
496
|
// system effect — so auto-allow it (the conductor runs in plan mode, which would otherwise
|
|
@@ -699,6 +704,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
699
704
|
'Whenever you produce an HTML artifact (a demo, mockup, preview, report, or page) or surface ANY link meant to be opened or shared, NEVER hand the room a local path, a file:// URL, or a localhost/127.0.0.1 address. Publish it to a GitHub-shareable URL that renders in a browser — push the HTML to a GitHub repo and give its GitHub Pages URL (or an equivalent raw-HTML URL that actually renders, not raw.githubusercontent.com which serves HTML as plain text) — so anyone in the room can open it. The same applies to any other link you surface: it must be one the room can reach, not a host-only path.',
|
|
700
705
|
'If you cannot publish a shareable link, say so and ask how to proceed — do not fall back to handing over a local path.',
|
|
701
706
|
'SHOW YOUR WORK, do not just describe it: the room is watched live from a phone, where a wall of text is painful to read. Whenever you build, change, or fix anything visual — a UI, a page, an HTML artifact, a chart, a diagram, a rendered result — capture a screenshot and surface the PNG (for example, read the image file into your turn) so it appears inline in the room. The room auto-uploads any image you surface. Default to showing a picture of the result over narrating it; err toward more screenshots, not fewer.',
|
|
707
|
+
'BRIDGE VIEWPORTS: for a built web UI, use preview_start (default root: dist), then preview_capture for exact desktop 1440×900 + mobile 390×844 screenshots, and preview_inspect when DOM text or selector geometry helps. These tools run in the bridge, so they work even when your lane sandbox cannot bind localhost or launch Chrome. They only serve built files inside your own lane workspace; run the project build first. Stop the server with preview_stop when you are done.',
|
|
702
708
|
'HTML PREVIEWS: for any HTML you produce, give the room something it can actually open, by whichever path is available. (a) IN-ROOM PREVIEW: if a mockup render helper is present — one that writes a manifest into the directory named by $TP_MOCKUP_OUTBOX, such as the mockup-iterate render.sh script when the bridge was launched from the thinkpool repo — use it; it surfaces an inline card with desktop and mobile views that the room can expand. (b) SHAREABLE LINK: additionally or otherwise, publish the HTML to a GitHub Pages URL per the LINKS & ARTIFACTS rule above and post the link. Never leave an HTML artifact viewable only as a local path.',
|
|
703
709
|
'RUN IT, DO NOT ASK: verify your own work before calling it done. Actually run or serve what you changed, observe that it behaves correctly, and show the evidence in the room — a screenshot of the running result, the passing test output, or the real response — rather than telling the user "this should work, go test it." If you could not verify something, say exactly what is unverified. This room follows verify-before-claiming: runtime evidence you produced, not assertion.',
|
|
704
710
|
'CROSS-TERMINAL AWARENESS: this room may have other terminals open alongside yours — other agents working, or shells the people are driving. You have a READ-ONLY tool, read_terminal: call it with no arguments to list the other open terminals, or with a terminal ref/id/command to read that terminal\'s recent activity. Reach for it when your work depends on what another terminal is doing (e.g. someone says "see what the other terminal hit", or you need to coordinate with a sibling agent before acting). It only ever reads — it never changes another terminal. Identify a terminal by its NAME or its ref/id from the roster, never by an on-screen number like "Terminal 2" — those positional labels renumber when a terminal is closed, so they do not reliably point at a lane.',
|
package/codex-event-mapper.mjs
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
const
|
|
149
|
-
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
75
|
-
const hit =
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|