thinkpool-pair 0.7.206 → 0.7.208
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/bridge.mjs +32 -12
- package/claude-session.mjs +6 -0
- package/cross-terminal.mjs +79 -9
- 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.
|
|
@@ -97,7 +98,7 @@ const flowRedispatch = new Map()
|
|
|
97
98
|
// wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
|
|
98
99
|
// broadcasts; without persistent state the cap can never bite.
|
|
99
100
|
const flowBudgets = new Map()
|
|
100
|
-
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint } from './cross-terminal.mjs'
|
|
101
|
+
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, laneStatusOf } from './cross-terminal.mjs'
|
|
101
102
|
import { armInterruptedResume, sendInterruptedContinue, supersedeInterruptedResume } from './interrupted-resume.mjs'
|
|
102
103
|
import { turnInFlight } from './update-gate.mjs'
|
|
103
104
|
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
|
|
@@ -1033,18 +1034,17 @@ const announce = () => {
|
|
|
1033
1034
|
// cols/rows: the PTY's one true size — web viewers render this grid and
|
|
1034
1035
|
// scale it to their own page instead of voting to reflow it.
|
|
1035
1036
|
terms: [
|
|
1036
|
-
...[...terms.entries()].map(([id, t]) => ({ id, cmd: t.cmd, alive: true, cols: t.term.cols, rows: t.term.rows, name: termNames[id] || undefined })),
|
|
1037
|
+
...[...terms.entries()].map(([id, t]) => ({ id, cmd: t.cmd, alive: true, cols: t.term.cols, rows: t.term.rows, name: termNames[id] || undefined, ...laneStatusOf(t) })),
|
|
1037
1038
|
// Structured sessions advertise kind:'structured' so the web renders the
|
|
1038
1039
|
// reader (not xterm) and drives them with code-turn / code-perm. `mode` +
|
|
1039
1040
|
// `name` ride along so a second device shows the live permission mode and
|
|
1040
1041
|
// the chosen label instead of defaulting to Normal / unnamed. `spawned` (set
|
|
1041
1042
|
// when an agent Dispatched this lane via spawn_terminal) lets the client keep
|
|
1042
1043
|
// it OUT of the top tab strip and show it only in the Ensemble menu.
|
|
1043
|
-
//
|
|
1044
|
-
//
|
|
1045
|
-
//
|
|
1046
|
-
|
|
1047
|
-
...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, busy: s.session?.turnActive ?? false, hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [] } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
|
|
1044
|
+
// laneStatusOf: authoritative busy/idle + last-action timestamp/age +
|
|
1045
|
+
// STUCK/BLOCKED alert. The bridge owns the turn and permission state, so
|
|
1046
|
+
// every roster consumer reads one status instead of reconstructing it.
|
|
1047
|
+
...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [] } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
|
|
1048
1048
|
// provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
|
|
1049
1049
|
// (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
|
|
1050
1050
|
// built-in/default Claude path (no badge). Makes the lane's provider badge +
|
|
@@ -1095,6 +1095,7 @@ function raiseCard(entry, req) {
|
|
|
1095
1095
|
if (!entry?.pending) return resolve('deny')
|
|
1096
1096
|
const payload = { term: entry.id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk }
|
|
1097
1097
|
entry.pending.set(req.id, { resolve, payload })
|
|
1098
|
+
announce()
|
|
1098
1099
|
bcast('code-perm-req', payload)
|
|
1099
1100
|
process.stderr.write(`\n ● incoming cross-room task for ${String(entry.id).slice(0, 8)} — approve in the room.\n`)
|
|
1100
1101
|
})
|
|
@@ -1403,12 +1404,13 @@ function openTerm({ id, cmd, args = [], attached = false, cols, rows }) {
|
|
|
1403
1404
|
rows: rows || (attached ? ad.rows : (PIN_ROWS || 32)),
|
|
1404
1405
|
cwd: process.cwd(), env: { ...process.env, TP_MOCKUP_OUTBOX: mockupOutbox },
|
|
1405
1406
|
})
|
|
1406
|
-
const entry = { term, cmd, attached, scrollback: '', buf: '', mockupWatcher: watchOutbox(mockupOutbox, () => id) }
|
|
1407
|
+
const entry = { term, cmd, attached, scrollback: '', buf: '', lastActionAt: null, mockupWatcher: watchOutbox(mockupOutbox, () => id) }
|
|
1407
1408
|
terms.set(id, entry)
|
|
1408
1409
|
if (attached) attachedId = id
|
|
1409
1410
|
term.onData(d => {
|
|
1410
1411
|
if (attached) process.stdout.write(d)
|
|
1411
1412
|
entry.buf += d
|
|
1413
|
+
entry.lastActionAt = Date.now()
|
|
1412
1414
|
entry.scrollback = (entry.scrollback + d).slice(-SCROLLBACK_MAX)
|
|
1413
1415
|
})
|
|
1414
1416
|
term.onExit(() => {
|
|
@@ -1478,6 +1480,8 @@ function pushLog(entry, evt) {
|
|
|
1478
1480
|
// A real ts makes ordering wall-clock — immune to eviction AND /clear seq resets.
|
|
1479
1481
|
// stampEvent is idempotent, so the callers that already stamp are unaffected.
|
|
1480
1482
|
stampEvent(evt)
|
|
1483
|
+
entry.lastActionAt = evt.ts
|
|
1484
|
+
entry.lastEvent = evt
|
|
1481
1485
|
// Only TRANSCRIPT events consume a seq (seqable) — replay-chrome kinds the web
|
|
1482
1486
|
// filters out (system/thinking_tokens/effort) ride the log seq-less so they don't
|
|
1483
1487
|
// hole the client's applied sequence. See event-id.mjs NO_SEQ_KINDS.
|
|
@@ -1747,6 +1751,9 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
1747
1751
|
// they land on this session even after other terminals open/close.
|
|
1748
1752
|
const mockupOutbox = ownerOutbox('sessions', id)
|
|
1749
1753
|
entry.mockupWatcher = watchOutbox(mockupOutbox, () => id)
|
|
1754
|
+
// Visual QA runs in the bridge process, outside either agent runtime's sandbox.
|
|
1755
|
+
// It remains scoped to this lane's cwd and private mockup outbox.
|
|
1756
|
+
entry.viewport = new ViewportManager({ workspaceRoot: cwd || process.cwd(), ownerId: id, outbox: mockupOutbox })
|
|
1750
1757
|
if (entry.log.length) process.stderr.write(`\n ◆ restored ${entry.log.length} prior events (${id.slice(0, 8)})${resume ? ' + resuming live context' : ''}.\n`)
|
|
1751
1758
|
// Persist the permission mode alongside the transcript so a bridge restart
|
|
1752
1759
|
// restores the session in the SAME mode (a bypass room stays bypass on resume).
|
|
@@ -1776,9 +1783,10 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
1776
1783
|
name: 'thinkpool',
|
|
1777
1784
|
version: '1.0.0',
|
|
1778
1785
|
tools: [
|
|
1786
|
+
...createViewportTools({ tool, z, manager: entry.viewport }),
|
|
1779
1787
|
tool(
|
|
1780
1788
|
'read_terminal',
|
|
1781
|
-
'Read-only view of ANOTHER terminal in this ThinkPool Code room (a sibling agent or a shell the people are using). Call with no arguments to list
|
|
1789
|
+
'Read-only view of ANOTHER terminal in this ThinkPool Code room (a sibling agent or a shell the people are using). Call with no arguments to list every open terminal with busy/idle state, last-action age, and any STUCK/BLOCKED flag; call with `terminal` (a ref, id, or command from that list) to read its recent activity. It never changes another terminal — reading only. Use the one-read roster to identify a lane that needs attention before opening its transcript.',
|
|
1782
1790
|
{
|
|
1783
1791
|
terminal: z.string().optional().describe('ref, id, or command of the terminal to read; omit to list the open terminals'),
|
|
1784
1792
|
lines: z.number().int().positive().max(PEEK.maxLines).optional().describe(`how many recent lines to return (default ${PEEK.defaultLines})`),
|
|
@@ -2199,6 +2207,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
2199
2207
|
process.stderr.write(`\n ◆ saved session expired — starting fresh (transcript kept).\n`)
|
|
2200
2208
|
try { entry.session?.end() } catch { /* noop */ }
|
|
2201
2209
|
try { entry.mockupWatcher?.close() } catch { /* noop */ }
|
|
2210
|
+
try { void entry.viewport?.stop()?.catch(() => {}) } catch { /* noop */ }
|
|
2202
2211
|
sessions.delete(id)
|
|
2203
2212
|
openStructured({ id, runtime: entry.runtime, model, log: entry.log, commands: entry.commands, mode: entry.mode })
|
|
2204
2213
|
return
|
|
@@ -2210,6 +2219,10 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
2210
2219
|
// id, an event that arrives both live AND in a reconnect replay renders
|
|
2211
2220
|
// twice (the 2026-06-19 duplicate-message bug). See event-id.mjs.
|
|
2212
2221
|
stampEvent(evt)
|
|
2222
|
+
const stalledChanged = evt.kind === 'stalled' ? !entry.stalled : !!entry.stalled
|
|
2223
|
+
entry.stalled = evt.kind === 'stalled'
|
|
2224
|
+
entry.lastEvent = evt
|
|
2225
|
+
if (evt.kind !== 'stalled') entry.lastActionAt = evt.ts
|
|
2213
2226
|
// Re-announce the roster on THIS session's busy EDGE — turn start (turnActive
|
|
2214
2227
|
// flips true on the turn's first event) and turn end (false on result/error).
|
|
2215
2228
|
// Edge-triggered: one announce per transition, not per token event. This is what
|
|
@@ -2221,7 +2234,9 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
2221
2234
|
// bridge sees "this lane just began working". `result`/`error` reads it to decide
|
|
2222
2235
|
// whether the turn was long enough that nobody was watching (Slice 3).
|
|
2223
2236
|
{ const busyNow = entry.session?.turnActive ?? false
|
|
2224
|
-
|
|
2237
|
+
const busyChanged = busyNow !== entry._busyAnn
|
|
2238
|
+
if (busyChanged) { if (busyNow) entry._turnStart = Date.now(); entry._busyAnn = busyNow }
|
|
2239
|
+
if (busyChanged || stalledChanged) announce() }
|
|
2225
2240
|
// Capture this session's current MODEL (resolved full id, with version — from
|
|
2226
2241
|
// system/result `model` or usage `ctx.model`) and the SDK's supported-model
|
|
2227
2242
|
// LIST (from the `models` event) so the announce carries them to EVERY device.
|
|
@@ -2410,6 +2425,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
2410
2425
|
// (replay-request handler) — pending cards never enter the replayed event log.
|
|
2411
2426
|
const payload = { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan, questions: req.questions }
|
|
2412
2427
|
entry.pending.set(req.id, { resolve, payload })
|
|
2428
|
+
announce()
|
|
2413
2429
|
bcast('code-perm-req', payload)
|
|
2414
2430
|
// Slice 3 — arm the grace timer. Fires a "needs you" push only if the card is
|
|
2415
2431
|
// STILL unanswered 20s from now (you're not in the room). Every resolve path —
|
|
@@ -2544,6 +2560,7 @@ function respawnStructured(id, provider) {
|
|
|
2544
2560
|
drainPending(s)
|
|
2545
2561
|
try { s.session?.end() } catch { /* noop */ }
|
|
2546
2562
|
try { s.mockupWatcher?.close() } catch { /* noop */ }
|
|
2563
|
+
try { void s.viewport?.stop()?.catch(() => {}) } catch { /* noop */ }
|
|
2547
2564
|
sessions.delete(id) // openStructured early-returns if the id is still mapped
|
|
2548
2565
|
// Re-open under the SAME id with the new provider env. openStructured persists
|
|
2549
2566
|
// sessionData() (provider included) synchronously on open, so a bridge restart
|
|
@@ -2586,6 +2603,7 @@ function endStructured(id) {
|
|
|
2586
2603
|
drainPending(s)
|
|
2587
2604
|
try { s.session?.end() } catch { /* noop */ }
|
|
2588
2605
|
try { s.mockupWatcher?.close() } catch { /* noop */ }
|
|
2606
|
+
try { void s.viewport?.stop()?.catch(() => {}) } catch { /* noop */ }
|
|
2589
2607
|
sessions.delete(id)
|
|
2590
2608
|
bcast('term-exit', { id })
|
|
2591
2609
|
reapTerminalRow(id) // bridge-side reap: delete the code_terminals row ourselves — the
|
|
@@ -3032,11 +3050,11 @@ channel
|
|
|
3032
3050
|
// use run/accept/keep (claude-session interprets). Default deny on missing.
|
|
3033
3051
|
// Answered from a device → retract the "needs you" banner on every OTHER device
|
|
3034
3052
|
// (permNotifier.resolve is a no-op unless its grace timer already fired).
|
|
3035
|
-
if (p) { s.pending.delete(payload.id); s.permNotifier?.resolve(payload.id); p.resolve(payload.decision || 'deny') }
|
|
3053
|
+
if (p) { s.pending.delete(payload.id); s.permNotifier?.resolve(payload.id); p.resolve(payload.decision || 'deny'); announce() }
|
|
3036
3054
|
})
|
|
3037
3055
|
.on('broadcast', { event: 'code-abort' }, ({ payload }) => {
|
|
3038
3056
|
const s = payload?.term && sessions.get(payload.term)
|
|
3039
|
-
if (s) { drainPending(s); s.session.abort() } // settle any open permission card so the hook doesn't hang
|
|
3057
|
+
if (s) { drainPending(s); s.session.abort(); announce() } // settle any open permission card so the hook doesn't hang
|
|
3040
3058
|
})
|
|
3041
3059
|
.on('broadcast', { event: 'code-mode' }, ({ payload }) => {
|
|
3042
3060
|
const s = payload?.term && sessions.get(payload.term)
|
|
@@ -3059,6 +3077,7 @@ channel
|
|
|
3059
3077
|
if (payload.mode === 'bypassPermissions') allowPending(s)
|
|
3060
3078
|
// Flipping to acceptEdits = "auto-approve writes" — clear the write cards already raised.
|
|
3061
3079
|
else if (payload.mode === 'acceptEdits') acceptEditsPending(s)
|
|
3080
|
+
announce()
|
|
3062
3081
|
}
|
|
3063
3082
|
})
|
|
3064
3083
|
.on('broadcast', { event: 'code-effort' }, ({ payload }) => {
|
|
@@ -3709,6 +3728,7 @@ async function shutdown(code = 0, farewell = true) {
|
|
|
3709
3728
|
setTimeout(() => process.exit(code), 1500)
|
|
3710
3729
|
clearInterval(flushTimer)
|
|
3711
3730
|
try { stopAllPreviews() } catch { /* Flow preview servers — best-effort close */ }
|
|
3731
|
+
try { void sharedViewportBrowser.close().catch(() => {}) } catch { /* bridge-owned Chrome — best-effort close */ }
|
|
3712
3732
|
if (farewell) {
|
|
3713
3733
|
try {
|
|
3714
3734
|
const bye = Buffer.from('\r\n[ shared session ended ]\r\n', 'utf8').toString('base64')
|
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/cross-terminal.mjs
CHANGED
|
@@ -18,6 +18,76 @@ export const PEEK = {
|
|
|
18
18
|
rosterPreview: 100, // last-line preview length in the roster listing
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// One shared lane-status classifier for every roster consumer: the bridge wire,
|
|
22
|
+
// read_terminal, and ROOM NOW. Timestamps stay absolute on the wire so clients can
|
|
23
|
+
// keep the displayed age current without a broadcast every second.
|
|
24
|
+
export const LANE_STATUS = {
|
|
25
|
+
stuckAfterMs: 90_000, // matches the structured-turn stall watchdog
|
|
26
|
+
repeatedFailureCount: 2,
|
|
27
|
+
failureWindow: 12,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const failureEvent = (e) => !!e && (
|
|
31
|
+
e.kind === 'error'
|
|
32
|
+
|| (e.kind === 'tool_result' && e.isError)
|
|
33
|
+
|| (e.kind === 'result' && e.subtype && e.subtype !== 'success')
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const recentFailureCount = (log, limits = LANE_STATUS) => {
|
|
37
|
+
let failures = 0, seen = 0
|
|
38
|
+
for (let i = (Array.isArray(log) ? log.length : 0) - 1; i >= 0 && seen < limits.failureWindow; i--) {
|
|
39
|
+
const e = log[i]
|
|
40
|
+
if (!e || typeof e !== 'object') continue
|
|
41
|
+
seen++
|
|
42
|
+
// A clean completion or a fresh human task closes the prior failure streak.
|
|
43
|
+
if ((e.kind === 'result' && (!e.subtype || e.subtype === 'success')) || e.kind === 'you') break
|
|
44
|
+
if (failureEvent(e)) failures++
|
|
45
|
+
}
|
|
46
|
+
return failures
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const laneStatusOf = (entry = {}, now = Date.now(), limits = LANE_STATUS) => {
|
|
50
|
+
const baseLog = Array.isArray(entry?.log) ? entry.log : []
|
|
51
|
+
const log = baseLog.slice(-limits.failureWindow)
|
|
52
|
+
if (entry?.lastEvent && entry.lastEvent !== baseLog[baseLog.length - 1]
|
|
53
|
+
&& (!entry.lastEvent.cid || entry.lastEvent.cid !== baseLog[baseLog.length - 1]?.cid)) log.push(entry.lastEvent)
|
|
54
|
+
let lastLogAt = null
|
|
55
|
+
for (let i = log.length - 1; i >= 0; i--) {
|
|
56
|
+
if (Number.isFinite(log[i]?.ts)) { lastLogAt = log[i].ts; break }
|
|
57
|
+
}
|
|
58
|
+
const lastActionAt = Number.isFinite(entry?.lastActionAt) ? entry.lastActionAt : (lastLogAt ?? null)
|
|
59
|
+
const lastActionAgeMs = lastActionAt == null ? null : Math.max(0, now - lastActionAt)
|
|
60
|
+
const busy = typeof entry?.busy === 'boolean' ? entry.busy : (entry?.session?.turnActive ?? false)
|
|
61
|
+
const pendingCount = entry?.pending instanceof Map
|
|
62
|
+
? entry.pending.size
|
|
63
|
+
: Math.max(0, Number(entry?.pendingCount) || 0)
|
|
64
|
+
const failures = recentFailureCount(log, limits)
|
|
65
|
+
|
|
66
|
+
let statusFlag = null, statusReason = null
|
|
67
|
+
if (pendingCount > 0) {
|
|
68
|
+
statusFlag = 'BLOCKED'; statusReason = 'waiting for input'
|
|
69
|
+
} else if (entry?.stalled || (busy && !entry?.compacting && lastActionAgeMs != null && lastActionAgeMs >= limits.stuckAfterMs)) {
|
|
70
|
+
statusFlag = 'STUCK'; statusReason = 'no events while busy'
|
|
71
|
+
} else if (failures >= limits.repeatedFailureCount) {
|
|
72
|
+
statusFlag = 'BLOCKED'; statusReason = 'repeated errors'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { busy: !!busy, lastActionAt, lastActionAgeMs, statusFlag, statusReason }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const formatLaneAge = (ageMs) => {
|
|
79
|
+
if (!Number.isFinite(ageMs)) return 'no activity'
|
|
80
|
+
const s = Math.max(0, Math.floor(ageMs / 1000))
|
|
81
|
+
if (s < 60) return `${s}s ago`
|
|
82
|
+
const m = Math.floor(s / 60)
|
|
83
|
+
if (m < 60) return `${m}m ago`
|
|
84
|
+
const h = Math.floor(m / 60)
|
|
85
|
+
if (h < 24) return `${h}h ago`
|
|
86
|
+
return `${Math.floor(h / 24)}d ago`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const laneStatusText = (x) => `${x.busy ? 'busy' : 'idle'} · ${formatLaneAge(x.lastActionAgeMs)}${x.statusFlag ? ` · ${x.statusFlag}` : ''}`
|
|
90
|
+
|
|
21
91
|
const clip = (s, cap) => {
|
|
22
92
|
const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
|
|
23
93
|
return t.length > cap ? t.slice(0, cap - 1) + '…' : t
|
|
@@ -67,16 +137,16 @@ export const siblingRef = (id) => String(id).slice(0, 8)
|
|
|
67
137
|
// is the bridge's id→label map (termNames) — the HUMAN names people actually use
|
|
68
138
|
// ("Pivot", "Grug"); without it the roster is just hashes and an agent can't map
|
|
69
139
|
// "what is Pivot doing" to a lane.
|
|
70
|
-
export const siblingsOf = ({ selfId, sessions, terms, names = {} } = {}) => {
|
|
140
|
+
export const siblingsOf = ({ selfId, sessions, terms, names = {}, now = Date.now() } = {}) => {
|
|
71
141
|
const nm = (id) => (names && names[id]) || ''
|
|
72
142
|
const sib = []
|
|
73
143
|
for (const [id, s] of (sessions || new Map())) {
|
|
74
144
|
if (id === selfId) continue
|
|
75
|
-
sib.push({ id, ref: siblingRef(id), name: nm(id), kind: 'agent', cmd: s?.cmd || 'claude', log: Array.isArray(s?.log) ? s.log : [] })
|
|
145
|
+
sib.push({ id, ref: siblingRef(id), name: nm(id), kind: 'agent', cmd: s?.cmd || 'claude', log: Array.isArray(s?.log) ? s.log : [], ...laneStatusOf(s, now) })
|
|
76
146
|
}
|
|
77
147
|
for (const [id, t] of (terms || new Map())) {
|
|
78
148
|
if (id === selfId) continue
|
|
79
|
-
sib.push({ id, ref: siblingRef(id), name: nm(id), kind: 'shell', cmd: t?.cmd || 'shell', buf: t?.buf || '' })
|
|
149
|
+
sib.push({ id, ref: siblingRef(id), name: nm(id), kind: 'shell', cmd: t?.cmd || 'shell', buf: t?.buf || '', ...laneStatusOf(t, now) })
|
|
80
150
|
}
|
|
81
151
|
return sib
|
|
82
152
|
}
|
|
@@ -121,9 +191,9 @@ export const resolveSibling = (siblings, terminal) => {
|
|
|
121
191
|
|
|
122
192
|
const label = (x) => `${x.ref}${x.name ? ` · "${x.name}"` : ''}`
|
|
123
193
|
|
|
124
|
-
export const formatPeek = ({ selfId, sessions, terms, names = {}, terminal, lines } = {}) => {
|
|
194
|
+
export const formatPeek = ({ selfId, sessions, terms, names = {}, terminal, lines, now = Date.now() } = {}) => {
|
|
125
195
|
const n = Math.min(PEEK.maxLines, Math.max(1, Number(lines) || PEEK.defaultLines))
|
|
126
|
-
const sib = siblingsOf({ selfId, sessions, terms, names })
|
|
196
|
+
const sib = siblingsOf({ selfId, sessions, terms, names, now })
|
|
127
197
|
if (!sib.length) return 'No other terminals are open in this room right now.'
|
|
128
198
|
|
|
129
199
|
if (!terminal) {
|
|
@@ -131,7 +201,7 @@ export const formatPeek = ({ selfId, sessions, terms, names = {}, terminal, line
|
|
|
131
201
|
let preview
|
|
132
202
|
if (x.kind === 'agent') { const ls = x.log.map(e => digestLogEvent(e, PEEK.rosterPreview)).filter(Boolean); preview = ls[ls.length - 1] || '(no activity yet)' }
|
|
133
203
|
else { preview = cleanPtyTail(x.buf, 1)[0] || '(no output yet)' }
|
|
134
|
-
return `- ${label(x)} · ${x.kind} · ${x.cmd} — ${clip(preview, PEEK.rosterPreview)}`
|
|
204
|
+
return `- ${label(x)} · ${x.kind} · ${x.cmd} · ${laneStatusText(x)} — ${clip(preview, PEEK.rosterPreview)}`
|
|
135
205
|
})
|
|
136
206
|
return `Other terminals in this room (call read_terminal with a ref or name to read one):\n${rows.join('\n')}`
|
|
137
207
|
}
|
|
@@ -162,8 +232,8 @@ export const formatPeek = ({ selfId, sessions, terms, names = {}, terminal, line
|
|
|
162
232
|
// when there is nothing to show (the reminder then carries no tail at all).
|
|
163
233
|
// Spec ask (2026-07-02): "terminals need to be completely aware of the session."
|
|
164
234
|
export const ROOMNOW = { laneCap: 8, previewLen: 70, wtCap: 10 }
|
|
165
|
-
export const formatRoomNow = ({ selfId, sessions, terms, names = {}, worktrees = [] } = {}, limits = ROOMNOW) => {
|
|
166
|
-
const sib = siblingsOf({ selfId, sessions, terms, names })
|
|
235
|
+
export const formatRoomNow = ({ selfId, sessions, terms, names = {}, worktrees = [], now = Date.now() } = {}, limits = ROOMNOW) => {
|
|
236
|
+
const sib = siblingsOf({ selfId, sessions, terms, names, now })
|
|
167
237
|
const out = []
|
|
168
238
|
if (sib.length) {
|
|
169
239
|
const shown = sib.slice(0, limits.laneCap)
|
|
@@ -171,7 +241,7 @@ export const formatRoomNow = ({ selfId, sessions, terms, names = {}, worktrees =
|
|
|
171
241
|
let preview
|
|
172
242
|
if (x.kind === 'agent') { const ls = x.log.map(e => digestLogEvent(e, limits.previewLen)).filter(Boolean); preview = ls[ls.length - 1] || '(no activity yet)' }
|
|
173
243
|
else preview = cleanPtyTail(x.buf, 1)[0] || '(no output yet)'
|
|
174
|
-
return `- ${label(x)} · ${x.kind} — ${clip(preview, limits.previewLen)}`
|
|
244
|
+
return `- ${label(x)} · ${x.kind} · ${laneStatusText(x)} — ${clip(preview, limits.previewLen)}`
|
|
175
245
|
})
|
|
176
246
|
if (sib.length > shown.length) rows.push(`- … +${sib.length - shown.length} more (read_terminal lists all)`)
|
|
177
247
|
out.push(`Other lanes in this room right now (read_terminal for detail):\n${rows.join('\n')}`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.208",
|
|
4
4
|
"description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"flow-worktree.mjs",
|
|
34
34
|
"flow-task-graph.mjs",
|
|
35
35
|
"flow-preview.mjs",
|
|
36
|
+
"viewport.mjs",
|
|
36
37
|
"flow-review.mjs",
|
|
37
38
|
"flow-review-gate.mjs",
|
|
38
39
|
"flow-review-reflect.mjs",
|
package/viewport.mjs
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
// Bridge-owned visual QA for ThinkPool Code lanes.
|
|
2
|
+
//
|
|
3
|
+
// Agent sandboxes should not need permission to bind localhost or launch a GUI
|
|
4
|
+
// process. The bridge already owns a contained static preview server
|
|
5
|
+
// (flow-preview.mjs), so this module adds the missing half: an exact-viewport,
|
|
6
|
+
// dependency-free Chrome DevTools Protocol capture worker plus per-lane tools.
|
|
7
|
+
//
|
|
8
|
+
// Security boundary:
|
|
9
|
+
// - preview roots must resolve inside the lane's own cwd (symlinks included)
|
|
10
|
+
// - the browser only opens the loopback URL created by startPreview()
|
|
11
|
+
// - no caller-supplied commands, executables, ports, hosts, or arbitrary URLs
|
|
12
|
+
|
|
13
|
+
import { spawn } from 'node:child_process'
|
|
14
|
+
import fs from 'node:fs'
|
|
15
|
+
import fsp from 'node:fs/promises'
|
|
16
|
+
import os from 'node:os'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import { randomUUID } from 'node:crypto'
|
|
19
|
+
import { previews, startPreview } from './flow-preview.mjs'
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_VIEWPORTS = Object.freeze({
|
|
22
|
+
desktop: Object.freeze({ width: 1440, height: 900 }),
|
|
23
|
+
mobile: Object.freeze({ width: 390, height: 844 }),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const MAX_CAPTURE_HEIGHT = 20000
|
|
27
|
+
const MAX_SETTLE_MS = 5000
|
|
28
|
+
const CDP_TIMEOUT_MS = 12000
|
|
29
|
+
|
|
30
|
+
const isInside = (parent, child) => {
|
|
31
|
+
const rel = path.relative(parent, child)
|
|
32
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function resolveContainedRoot(workspaceRoot, requested = 'dist') {
|
|
36
|
+
if (!workspaceRoot) throw new Error('A lane workspace is required.')
|
|
37
|
+
if (path.isAbsolute(requested)) throw new Error('Preview root must be relative to the lane workspace.')
|
|
38
|
+
const workspace = await fsp.realpath(path.resolve(workspaceRoot))
|
|
39
|
+
const candidate = path.resolve(workspace, requested || 'dist')
|
|
40
|
+
let resolved
|
|
41
|
+
try { resolved = await fsp.realpath(candidate) }
|
|
42
|
+
catch { throw new Error(`Preview root does not exist: ${requested || 'dist'}. Build the app first, or choose a directory containing index.html.`) }
|
|
43
|
+
if (!isInside(workspace, resolved)) throw new Error('Preview root escapes the lane workspace.')
|
|
44
|
+
const stat = await fsp.stat(resolved)
|
|
45
|
+
if (!stat.isDirectory()) throw new Error('Preview root must be a directory.')
|
|
46
|
+
if (!fs.existsSync(path.join(resolved, 'index.html'))) throw new Error(`No index.html found in preview root: ${requested || 'dist'}.`)
|
|
47
|
+
return resolved
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function normalizeRoute(value = '/') {
|
|
51
|
+
const route = String(value || '/').trim()
|
|
52
|
+
if (!route.startsWith('/') || route.startsWith('//')) throw new Error('Preview path must start with one "/" and cannot be a URL.')
|
|
53
|
+
const parsed = new URL(route, 'http://127.0.0.1')
|
|
54
|
+
if (parsed.origin !== 'http://127.0.0.1') throw new Error('Preview path cannot change the preview origin.')
|
|
55
|
+
return `${parsed.pathname}${parsed.search}${parsed.hash}`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function safeSlug(value = 'viewport') {
|
|
59
|
+
return String(value || 'viewport')
|
|
60
|
+
.toLowerCase()
|
|
61
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
62
|
+
.replace(/^-+|-+$/g, '')
|
|
63
|
+
.slice(0, 56) || 'viewport'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function findBrowserExecutable({ env = process.env, platform = process.platform, exists = fs.existsSync } = {}) {
|
|
67
|
+
const candidates = [
|
|
68
|
+
env.TP_BROWSER_PATH,
|
|
69
|
+
platform === 'darwin' && '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
70
|
+
platform === 'darwin' && '/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
71
|
+
platform === 'win32' && env.PROGRAMFILES && path.join(env.PROGRAMFILES, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
72
|
+
platform === 'win32' && env['PROGRAMFILES(X86)'] && path.join(env['PROGRAMFILES(X86)'], 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
73
|
+
platform === 'linux' && '/usr/bin/google-chrome',
|
|
74
|
+
platform === 'linux' && '/usr/bin/google-chrome-stable',
|
|
75
|
+
platform === 'linux' && '/usr/bin/chromium',
|
|
76
|
+
platform === 'linux' && '/usr/bin/chromium-browser',
|
|
77
|
+
].filter(Boolean)
|
|
78
|
+
return candidates.find((candidate) => {
|
|
79
|
+
try { return exists(candidate) } catch { return false }
|
|
80
|
+
}) || null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
class CdpPipe {
|
|
84
|
+
constructor(child, timeoutMs = CDP_TIMEOUT_MS) {
|
|
85
|
+
this.child = child
|
|
86
|
+
this.timeoutMs = timeoutMs
|
|
87
|
+
this.nextId = 1
|
|
88
|
+
this.buffer = Buffer.alloc(0)
|
|
89
|
+
this.pending = new Map()
|
|
90
|
+
this.waiters = new Set()
|
|
91
|
+
this.subscribers = new Set()
|
|
92
|
+
child.stdio[4].on('data', (chunk) => this.onData(chunk))
|
|
93
|
+
child.once('exit', (code, signal) => this.failAll(new Error(`Chrome exited (${signal || code || 'unknown'}).`)))
|
|
94
|
+
child.once('error', (error) => this.failAll(error))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
onData(chunk) {
|
|
98
|
+
this.buffer = Buffer.concat([this.buffer, chunk])
|
|
99
|
+
for (;;) {
|
|
100
|
+
const end = this.buffer.indexOf(0)
|
|
101
|
+
if (end < 0) break
|
|
102
|
+
const raw = this.buffer.subarray(0, end).toString('utf8')
|
|
103
|
+
this.buffer = this.buffer.subarray(end + 1)
|
|
104
|
+
if (!raw) continue
|
|
105
|
+
let message
|
|
106
|
+
try { message = JSON.parse(raw) } catch { continue }
|
|
107
|
+
if (message.id) {
|
|
108
|
+
const pending = this.pending.get(message.id)
|
|
109
|
+
if (!pending) continue
|
|
110
|
+
clearTimeout(pending.timer)
|
|
111
|
+
this.pending.delete(message.id)
|
|
112
|
+
if (message.error) pending.reject(new Error(message.error.message || 'Chrome DevTools error'))
|
|
113
|
+
else pending.resolve(message.result || {})
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
for (const waiter of [...this.waiters]) {
|
|
117
|
+
if (waiter.method !== message.method) continue
|
|
118
|
+
if (waiter.sessionId && waiter.sessionId !== message.sessionId) continue
|
|
119
|
+
if (waiter.predicate && !waiter.predicate(message.params || {})) continue
|
|
120
|
+
clearTimeout(waiter.timer)
|
|
121
|
+
this.waiters.delete(waiter)
|
|
122
|
+
waiter.resolve(message.params || {})
|
|
123
|
+
}
|
|
124
|
+
for (const subscriber of [...this.subscribers]) {
|
|
125
|
+
if (subscriber.method !== message.method) continue
|
|
126
|
+
if (subscriber.sessionId && subscriber.sessionId !== message.sessionId) continue
|
|
127
|
+
try { subscriber.handler(message.params || {}) } catch { /* subscriber owns recovery */ }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
failAll(error) {
|
|
133
|
+
for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error) }
|
|
134
|
+
this.pending.clear()
|
|
135
|
+
for (const waiter of this.waiters) { clearTimeout(waiter.timer); waiter.reject(error) }
|
|
136
|
+
this.waiters.clear()
|
|
137
|
+
this.subscribers.clear()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
send(method, params = {}, sessionId) {
|
|
141
|
+
const id = this.nextId++
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const timer = setTimeout(() => {
|
|
144
|
+
this.pending.delete(id)
|
|
145
|
+
reject(new Error(`Chrome timed out running ${method}.`))
|
|
146
|
+
}, this.timeoutMs)
|
|
147
|
+
this.pending.set(id, { resolve, reject, timer })
|
|
148
|
+
const message = { id, method, params, ...(sessionId ? { sessionId } : {}) }
|
|
149
|
+
this.child.stdio[3].write(`${JSON.stringify(message)}\0`)
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
waitFor(method, sessionId, predicate) {
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
const waiter = { method, sessionId, predicate, resolve, reject, timer: null }
|
|
156
|
+
waiter.timer = setTimeout(() => {
|
|
157
|
+
this.waiters.delete(waiter)
|
|
158
|
+
reject(new Error(`Chrome timed out waiting for ${method}.`))
|
|
159
|
+
}, this.timeoutMs)
|
|
160
|
+
this.waiters.add(waiter)
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
subscribe(method, sessionId, handler) {
|
|
165
|
+
const subscriber = { method, sessionId, handler }
|
|
166
|
+
this.subscribers.add(subscriber)
|
|
167
|
+
return () => this.subscribers.delete(subscriber)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export class CdpBrowser {
|
|
172
|
+
constructor({ executable, spawnImpl = spawn, timeoutMs = CDP_TIMEOUT_MS } = {}) {
|
|
173
|
+
this.executable = executable || null
|
|
174
|
+
this.spawnImpl = spawnImpl
|
|
175
|
+
this.timeoutMs = timeoutMs
|
|
176
|
+
this.child = null
|
|
177
|
+
this.pipe = null
|
|
178
|
+
this.profileDir = null
|
|
179
|
+
this.starting = null
|
|
180
|
+
this.stderr = ''
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async ensureStarted() {
|
|
184
|
+
if (this.child && this.pipe) return
|
|
185
|
+
if (this.starting) return this.starting
|
|
186
|
+
this.starting = this.start().finally(() => { this.starting = null })
|
|
187
|
+
return this.starting
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async start() {
|
|
191
|
+
const executable = this.executable || findBrowserExecutable()
|
|
192
|
+
if (!executable) throw new Error('No supported Chrome/Chromium executable found. Install Google Chrome or set TP_BROWSER_PATH on the bridge host.')
|
|
193
|
+
this.profileDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'thinkpool-viewport-'))
|
|
194
|
+
const args = [
|
|
195
|
+
'--headless=new', '--remote-debugging-pipe', '--no-first-run', '--no-default-browser-check',
|
|
196
|
+
'--disable-background-networking', '--disable-component-update', '--disable-sync',
|
|
197
|
+
'--disable-extensions', '--disable-default-apps',
|
|
198
|
+
'--metrics-recording-only', '--mute-audio', '--hide-scrollbars',
|
|
199
|
+
`--user-data-dir=${this.profileDir}`, 'about:blank',
|
|
200
|
+
]
|
|
201
|
+
const child = this.spawnImpl(executable, args, { stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'] })
|
|
202
|
+
this.child = child
|
|
203
|
+
child.stderr?.on('data', (chunk) => { this.stderr = (this.stderr + chunk.toString('utf8')).slice(-4000) })
|
|
204
|
+
this.pipe = new CdpPipe(child, this.timeoutMs)
|
|
205
|
+
try { await this.pipe.send('Browser.getVersion') }
|
|
206
|
+
catch (error) {
|
|
207
|
+
await this.close()
|
|
208
|
+
const detail = this.stderr.trim().split('\n').slice(-2).join(' ')
|
|
209
|
+
throw new Error(`Could not start Chrome for viewport capture: ${detail || error.message}`)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async withPage({ url, viewport, waitMs = 300 }, fn) {
|
|
214
|
+
await this.ensureStarted()
|
|
215
|
+
const { targetId } = await this.pipe.send('Target.createTarget', { url: 'about:blank' })
|
|
216
|
+
const { sessionId } = await this.pipe.send('Target.attachToTarget', { targetId, flatten: true })
|
|
217
|
+
let unsubscribeRequests = null
|
|
218
|
+
try {
|
|
219
|
+
await this.pipe.send('Page.enable', {}, sessionId)
|
|
220
|
+
await this.pipe.send('Runtime.enable', {}, sessionId)
|
|
221
|
+
// The page is untrusted build output. Keep its network authority narrower
|
|
222
|
+
// than the lane sandbox: same preview origin only. This blocks internet,
|
|
223
|
+
// cloud metadata, and other localhost services while still allowing all
|
|
224
|
+
// JS/CSS/assets served from the selected build directory.
|
|
225
|
+
const allowedOrigin = new URL(url).origin
|
|
226
|
+
unsubscribeRequests = this.pipe.subscribe('Fetch.requestPaused', sessionId, (params) => {
|
|
227
|
+
let allowed = false
|
|
228
|
+
try { allowed = new URL(params.request?.url || '').origin === allowedOrigin } catch { allowed = false }
|
|
229
|
+
const method = allowed ? 'Fetch.continueRequest' : 'Fetch.failRequest'
|
|
230
|
+
const request = allowed
|
|
231
|
+
? { requestId: params.requestId }
|
|
232
|
+
: { requestId: params.requestId, errorReason: 'BlockedByClient' }
|
|
233
|
+
void this.pipe.send(method, request, sessionId).catch(() => {})
|
|
234
|
+
})
|
|
235
|
+
await this.pipe.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] }, sessionId)
|
|
236
|
+
await this.pipe.send('Network.setBypassServiceWorker', { bypass: true }, sessionId).catch(() => {})
|
|
237
|
+
await this.pipe.send('Emulation.setDeviceMetricsOverride', {
|
|
238
|
+
width: viewport.width, height: viewport.height, deviceScaleFactor: 1, mobile: false,
|
|
239
|
+
screenWidth: viewport.width, screenHeight: viewport.height,
|
|
240
|
+
}, sessionId)
|
|
241
|
+
const loaded = this.pipe.waitFor('Page.loadEventFired', sessionId)
|
|
242
|
+
const nav = await this.pipe.send('Page.navigate', { url }, sessionId)
|
|
243
|
+
if (nav.errorText) throw new Error(`Preview navigation failed: ${nav.errorText}`)
|
|
244
|
+
await loaded
|
|
245
|
+
await this.pipe.send('Runtime.evaluate', {
|
|
246
|
+
expression: 'document.fonts && document.fonts.ready', awaitPromise: true, returnByValue: true,
|
|
247
|
+
}, sessionId).catch(() => {})
|
|
248
|
+
const settle = Math.min(MAX_SETTLE_MS, Math.max(0, Number(waitMs) || 0))
|
|
249
|
+
if (settle) await new Promise((resolve) => setTimeout(resolve, settle))
|
|
250
|
+
return await fn({ pipe: this.pipe, sessionId })
|
|
251
|
+
} finally {
|
|
252
|
+
unsubscribeRequests?.()
|
|
253
|
+
await this.pipe.send('Target.closeTarget', { targetId }).catch(() => {})
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async capture({ url, viewport, fullPage = true, waitMs = 300 }) {
|
|
258
|
+
return this.withPage({ url, viewport, waitMs }, async ({ pipe, sessionId }) => {
|
|
259
|
+
const metrics = await pipe.send('Page.getLayoutMetrics', {}, sessionId)
|
|
260
|
+
const contentHeight = Math.ceil(metrics.cssContentSize?.height || viewport.height)
|
|
261
|
+
const height = fullPage ? Math.min(MAX_CAPTURE_HEIGHT, Math.max(viewport.height, contentHeight)) : viewport.height
|
|
262
|
+
const result = await pipe.send('Page.captureScreenshot', {
|
|
263
|
+
format: 'png', fromSurface: true, captureBeyondViewport: true,
|
|
264
|
+
clip: { x: 0, y: 0, width: viewport.width, height, scale: 1 },
|
|
265
|
+
}, sessionId)
|
|
266
|
+
return { png: Buffer.from(result.data, 'base64'), width: viewport.width, height, contentHeight, capped: contentHeight > MAX_CAPTURE_HEIGHT }
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async inspect({ url, viewport = DEFAULT_VIEWPORTS.mobile, selector, waitMs = 300 }) {
|
|
271
|
+
return this.withPage({ url, viewport, waitMs }, async ({ pipe, sessionId }) => {
|
|
272
|
+
const expression = `(() => {
|
|
273
|
+
const selector = ${JSON.stringify(selector || null)};
|
|
274
|
+
const el = selector ? document.querySelector(selector) : null;
|
|
275
|
+
const r = el && el.getBoundingClientRect();
|
|
276
|
+
return {
|
|
277
|
+
title: document.title, url: location.href,
|
|
278
|
+
viewport: { width: innerWidth, height: innerHeight, dpr: devicePixelRatio },
|
|
279
|
+
document: { width: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth || 0), height: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight || 0) },
|
|
280
|
+
selector: selector ? { query: selector, found: !!el, tag: el?.tagName || null, text: el?.innerText?.slice(0, 2000) || '', rect: r ? { x:r.x, y:r.y, width:r.width, height:r.height } : null } : null,
|
|
281
|
+
text: document.body?.innerText?.slice(0, 12000) || ''
|
|
282
|
+
};
|
|
283
|
+
})()`
|
|
284
|
+
const result = await pipe.send('Runtime.evaluate', { expression, returnByValue: true }, sessionId)
|
|
285
|
+
if (result.exceptionDetails) throw new Error('DOM inspection failed in the preview page.')
|
|
286
|
+
return result.result?.value || {}
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async close() {
|
|
291
|
+
const child = this.child
|
|
292
|
+
const pipe = this.pipe
|
|
293
|
+
this.child = null
|
|
294
|
+
this.pipe = null
|
|
295
|
+
if (pipe) await pipe.send('Browser.close').catch(() => {})
|
|
296
|
+
if (child && child.exitCode == null) { try { child.kill('SIGTERM') } catch { /* already gone */ } }
|
|
297
|
+
if (this.profileDir) await fsp.rm(this.profileDir, { recursive: true, force: true }).catch(() => {})
|
|
298
|
+
this.profileDir = null
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export const sharedViewportBrowser = new CdpBrowser()
|
|
303
|
+
|
|
304
|
+
export class ViewportManager {
|
|
305
|
+
constructor({ workspaceRoot, ownerId, outbox, browser = sharedViewportBrowser, startPreviewImpl = startPreview } = {}) {
|
|
306
|
+
this.workspaceRoot = path.resolve(workspaceRoot || process.cwd())
|
|
307
|
+
this.ownerId = ownerId || randomUUID()
|
|
308
|
+
this.outbox = outbox || path.join(os.tmpdir(), 'thinkpool-viewport-captures', this.ownerId)
|
|
309
|
+
this.browser = browser
|
|
310
|
+
this.startPreviewImpl = startPreviewImpl
|
|
311
|
+
this.previewId = `viewport:${this.ownerId}`
|
|
312
|
+
this.preview = null
|
|
313
|
+
this.root = null
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async start({ root = 'dist' } = {}) {
|
|
317
|
+
const resolved = await resolveContainedRoot(this.workspaceRoot, root)
|
|
318
|
+
await this.stop()
|
|
319
|
+
const preview = await this.startPreviewImpl({ dir: resolved, host: '127.0.0.1', port: 0, id: this.previewId })
|
|
320
|
+
const parsed = new URL(preview.url)
|
|
321
|
+
if (parsed.hostname !== '127.0.0.1' && parsed.hostname !== 'localhost') {
|
|
322
|
+
await preview.stop?.()
|
|
323
|
+
throw new Error('Preview server did not bind to loopback.')
|
|
324
|
+
}
|
|
325
|
+
this.preview = preview
|
|
326
|
+
this.root = resolved
|
|
327
|
+
return { url: preview.url, root: resolved }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
requirePreview() {
|
|
331
|
+
if (!this.preview?.url) throw new Error('No preview is running. Call preview_start after building the app.')
|
|
332
|
+
return this.preview
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
pageUrl(route) {
|
|
336
|
+
const preview = this.requirePreview()
|
|
337
|
+
return new URL(normalizeRoute(route), preview.url).href
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async capture({ route = '/', viewports = 'both', title = 'Viewport capture', fullPage = true, waitMs = 300 } = {}) {
|
|
341
|
+
const url = this.pageUrl(route)
|
|
342
|
+
const names = viewports === 'both' ? ['desktop', 'mobile'] : [viewports]
|
|
343
|
+
if (names.some((name) => !DEFAULT_VIEWPORTS[name])) throw new Error('viewports must be "both", "desktop", or "mobile".')
|
|
344
|
+
await fsp.mkdir(this.outbox, { recursive: true })
|
|
345
|
+
const slug = `${safeSlug(title)}-${Date.now().toString(36)}`
|
|
346
|
+
const captures = {}
|
|
347
|
+
for (const name of names) {
|
|
348
|
+
const result = await this.browser.capture({ url, viewport: DEFAULT_VIEWPORTS[name], fullPage, waitMs })
|
|
349
|
+
const file = path.join(this.outbox, `${slug}--${name}.png`)
|
|
350
|
+
await fsp.writeFile(file, result.png)
|
|
351
|
+
captures[name] = { ...result, file }
|
|
352
|
+
}
|
|
353
|
+
const html = this.root && fs.existsSync(path.join(this.root, 'index.html')) ? path.join(this.root, 'index.html') : null
|
|
354
|
+
const manifest = {
|
|
355
|
+
slug, title: String(title || 'Viewport capture').slice(0, 120), html,
|
|
356
|
+
desktop: captures.desktop?.file || '', mobile: captures.mobile?.file || '', ts: Date.now(),
|
|
357
|
+
}
|
|
358
|
+
const manifestPath = path.join(this.outbox, `${slug}.json`)
|
|
359
|
+
const tmp = `${manifestPath}.tmp`
|
|
360
|
+
await fsp.writeFile(tmp, JSON.stringify(manifest))
|
|
361
|
+
await fsp.rename(tmp, manifestPath)
|
|
362
|
+
return { url, slug, manifestPath, captures }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
inspect({ route = '/', viewport = 'mobile', selector, waitMs = 300 } = {}) {
|
|
366
|
+
if (!DEFAULT_VIEWPORTS[viewport]) throw new Error('viewport must be "desktop" or "mobile".')
|
|
367
|
+
return this.browser.inspect({ url: this.pageUrl(route), viewport: DEFAULT_VIEWPORTS[viewport], selector, waitMs })
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async stop() {
|
|
371
|
+
const preview = this.preview || previews.get(this.previewId)
|
|
372
|
+
this.preview = null
|
|
373
|
+
this.root = null
|
|
374
|
+
if (preview?.stop) await preview.stop()
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const textResult = (text) => ({ content: [{ type: 'text', text }] })
|
|
379
|
+
const errorResult = (error) => textResult(`Viewport error: ${error?.message || error}`)
|
|
380
|
+
|
|
381
|
+
export function createViewportTools({ tool, z, manager }) {
|
|
382
|
+
return [
|
|
383
|
+
tool(
|
|
384
|
+
'preview_start',
|
|
385
|
+
'Start a bridge-owned, read-only localhost preview for built files inside this lane workspace. Run the project build first. The default root is dist; pass another relative directory only when its index.html is the intended preview.',
|
|
386
|
+
{ root: z.string().max(240).optional().describe('relative directory inside this lane workspace; default: dist') },
|
|
387
|
+
async (args) => {
|
|
388
|
+
try {
|
|
389
|
+
const started = await manager.start({ root: args?.root || 'dist' })
|
|
390
|
+
return textResult(`Preview ready from ${path.relative(manager.workspaceRoot, started.root) || '.'}. Use preview_capture to see desktop/mobile, or preview_inspect for DOM text and geometry.`)
|
|
391
|
+
} catch (error) { return errorResult(error) }
|
|
392
|
+
},
|
|
393
|
+
),
|
|
394
|
+
tool(
|
|
395
|
+
'preview_capture',
|
|
396
|
+
'Capture this lane preview at exact desktop (1440×900) and mobile (390×844) CSS viewports. Defaults to both and full-page. Returns the PNGs to your vision context and also surfaces a mockup card in the ThinkPool room.',
|
|
397
|
+
{
|
|
398
|
+
path: z.string().max(500).optional().describe('route within the preview, e.g. / or /settings; never a full URL'),
|
|
399
|
+
viewports: z.enum(['both', 'desktop', 'mobile']).optional(),
|
|
400
|
+
title: z.string().max(120).optional(),
|
|
401
|
+
fullPage: z.boolean().optional(),
|
|
402
|
+
waitMs: z.number().int().min(0).max(MAX_SETTLE_MS).optional().describe('settle time after load, max 5000ms'),
|
|
403
|
+
},
|
|
404
|
+
async (args) => {
|
|
405
|
+
try {
|
|
406
|
+
const result = await manager.capture({
|
|
407
|
+
route: args?.path || '/', viewports: args?.viewports || 'both', title: args?.title || 'Viewport capture',
|
|
408
|
+
fullPage: args?.fullPage !== false, waitMs: args?.waitMs ?? 300,
|
|
409
|
+
})
|
|
410
|
+
const content = [{ type: 'text', text: `Captured ${Object.keys(result.captures).join(' + ')} for ${args?.path || '/'}. A room preview card is being delivered.` }]
|
|
411
|
+
for (const [name, capture] of Object.entries(result.captures)) {
|
|
412
|
+
content.push({ type: 'text', text: `${name}: ${capture.width}×${capture.height}${capture.capped ? ' (height capped)' : ''}` })
|
|
413
|
+
content.push({ type: 'image', data: capture.png.toString('base64'), mimeType: 'image/png' })
|
|
414
|
+
}
|
|
415
|
+
return { content }
|
|
416
|
+
} catch (error) { return errorResult(error) }
|
|
417
|
+
},
|
|
418
|
+
),
|
|
419
|
+
tool(
|
|
420
|
+
'preview_inspect',
|
|
421
|
+
'Inspect rendered DOM state in this lane preview at an exact desktop or mobile viewport. Returns page text, document dimensions, and optional selector text/geometry without changing the page.',
|
|
422
|
+
{
|
|
423
|
+
path: z.string().max(500).optional().describe('route within the preview; never a full URL'),
|
|
424
|
+
viewport: z.enum(['desktop', 'mobile']).optional(),
|
|
425
|
+
selector: z.string().max(500).optional(),
|
|
426
|
+
waitMs: z.number().int().min(0).max(MAX_SETTLE_MS).optional(),
|
|
427
|
+
},
|
|
428
|
+
async (args) => {
|
|
429
|
+
try {
|
|
430
|
+
const result = await manager.inspect({ route: args?.path || '/', viewport: args?.viewport || 'mobile', selector: args?.selector, waitMs: args?.waitMs ?? 300 })
|
|
431
|
+
return textResult(JSON.stringify(result, null, 2))
|
|
432
|
+
} catch (error) { return errorResult(error) }
|
|
433
|
+
},
|
|
434
|
+
),
|
|
435
|
+
tool(
|
|
436
|
+
'preview_stop',
|
|
437
|
+
'Stop this lane\'s bridge-owned preview server and release its port.',
|
|
438
|
+
{},
|
|
439
|
+
async () => {
|
|
440
|
+
try { await manager.stop(); return textResult('Preview stopped.') }
|
|
441
|
+
catch (error) { return errorResult(error) }
|
|
442
|
+
},
|
|
443
|
+
),
|
|
444
|
+
]
|
|
445
|
+
}
|