thinkpool-pair 0.7.314 → 0.7.316
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/bridge.mjs +82 -25
- package/flow-worktree.mjs +8 -3
- package/lane-worktree.mjs +22 -0
- package/package.json +1 -1
- package/thinkpool-capabilities.json +3 -3
- package/viewport.mjs +170 -18
package/bridge.mjs
CHANGED
|
@@ -1002,6 +1002,38 @@ function advanceStructuredTurn(entry, now = Date.now()) {
|
|
|
1002
1002
|
return true
|
|
1003
1003
|
}
|
|
1004
1004
|
|
|
1005
|
+
// Every bridge-authored task must cross the same lifecycle boundary as a
|
|
1006
|
+
// browser-authored code-turn. Runtime adapters claim `turnActive` synchronously,
|
|
1007
|
+
// but the roster deliberately trusts `_busyAnn`; calling sendTurn directly can
|
|
1008
|
+
// therefore leave a real turn painted idle until its first provider event.
|
|
1009
|
+
// Keep send + revision + visible echo + rejection boundary + announce atomic so
|
|
1010
|
+
// cross-posts, spawned tasks and other non-composer ingress cannot drift again.
|
|
1011
|
+
function dispatchStructuredTurn(entry, text, {
|
|
1012
|
+
options,
|
|
1013
|
+
visibleEvent,
|
|
1014
|
+
rejectionMessage = 'The agent did not accept this turn; retry after the lane is available.',
|
|
1015
|
+
} = {}) {
|
|
1016
|
+
if (!entry?.session) return { accepted: false, error: new Error('session unavailable') }
|
|
1017
|
+
syncStructuredTurn(entry)
|
|
1018
|
+
let accepted = false
|
|
1019
|
+
let error = null
|
|
1020
|
+
try { accepted = entry.session.sendTurn(text, options) !== false }
|
|
1021
|
+
catch (cause) { error = cause }
|
|
1022
|
+
if (accepted) beginStructuredTurn(entry)
|
|
1023
|
+
else syncStructuredTurn(entry)
|
|
1024
|
+
if (visibleEvent) {
|
|
1025
|
+
pushLog(entry, visibleEvent)
|
|
1026
|
+
bcast('code-event', { term: entry.id, evt: visibleEvent })
|
|
1027
|
+
}
|
|
1028
|
+
if (!accepted) {
|
|
1029
|
+
const failed = { kind: 'error', message: rejectionMessage, recoverable: true }
|
|
1030
|
+
pushLog(entry, failed)
|
|
1031
|
+
bcast('code-event', { term: entry.id, evt: failed })
|
|
1032
|
+
}
|
|
1033
|
+
announce()
|
|
1034
|
+
return { accepted, error }
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1005
1037
|
function dispatchPendingSideContexts(entry) {
|
|
1006
1038
|
if (!entry?.pendingSideContexts?.length || typeof entry.session?.sendTurn !== 'function') return false
|
|
1007
1039
|
const outcome = dispatchSideContexts({
|
|
@@ -1436,8 +1468,13 @@ async function receiveCrossRoomPost({ fromRoom, fromHost, fromTerminalName, text
|
|
|
1436
1468
|
const msg = `[From: room ${fromLabel} — relayed via the ThinkPool cross-room Ensemble, approved by a person in this room]\n${body}`
|
|
1437
1469
|
const evt = { kind: 'you', text: msg, by: `room ${fromRoom}`, crosspost: true, relaySourceName: String(fromTerminalName || '').trim().slice(0, 80) || undefined }
|
|
1438
1470
|
autoNameTerminal(targetId, body)
|
|
1439
|
-
|
|
1440
|
-
|
|
1471
|
+
const dispatched = dispatchStructuredTurn(te, msg, {
|
|
1472
|
+
visibleEvent: evt,
|
|
1473
|
+
rejectionMessage: 'The incoming cross-room task was not accepted; retry after the lane is available.',
|
|
1474
|
+
})
|
|
1475
|
+
if (!dispatched.accepted) return { error: dispatched.error
|
|
1476
|
+
? `Could not deliver to room ${room} — the lane may have just closed.`
|
|
1477
|
+
: `Could not deliver to room ${room} — the lane refused the turn (check its visible host-memory/runtime error).` }
|
|
1441
1478
|
return { ok: true, ref: String(targetId).slice(0, 8) }
|
|
1442
1479
|
}
|
|
1443
1480
|
|
|
@@ -2252,13 +2289,11 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2252
2289
|
// No revert, no verdict broadcast, no markFlowDone — the loop stays open.
|
|
2253
2290
|
if (!decision.stop) {
|
|
2254
2291
|
const next = round + 1
|
|
2255
|
-
|
|
2256
|
-
entry.
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
)
|
|
2261
|
-
} catch { /* lane may have closed mid-verdict */ }
|
|
2292
|
+
dispatchStructuredTurn(entry,
|
|
2293
|
+
entry.runtime === 'codex'
|
|
2294
|
+
? `[Flow review — round ${next}/${REVIEW_DEFAULTS.maxRounds}] The happy path held, but you have NOT reported your checks exhausted. Dig one more round, then call submit_flow_review again — pass:false with specific evidence if you break it, or pass:true AND exhausted:true only when nothing remains to check.`
|
|
2295
|
+
: `[Flow review — round ${next}/${REVIEW_DEFAULTS.maxRounds}] The happy path held, but you have NOT reported your checks exhausted and you are under both the round and budget ceilings. Dig one more round: hunt the edge cases, the reload, the second click, concurrent use, the error path — the places the builder didn't. Then re-Write FLOW_REVIEW.json — pass:false with a specific reason if you break it, or pass:true AND exhausted:true if you genuinely have nothing left to check.`,
|
|
2296
|
+
{ rejectionMessage: 'The next Flow review round was not accepted; retry after the lane is available.' })
|
|
2262
2297
|
process.stderr.write(`\n ${A.dim}◆ review round ${round} inconclusive — digging again (${next}/${REVIEW_DEFAULTS.maxRounds}) on ${target || entry.flowTaskKey}${A.rst}\n`)
|
|
2263
2298
|
return { ok: true, message: `Round ${round} recorded (pass, not yet exhausted). Keep hunting — asked you for round ${next}/${REVIEW_DEFAULTS.maxRounds}.` }
|
|
2264
2299
|
}
|
|
@@ -2362,6 +2397,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2362
2397
|
// It remains scoped to this lane's cwd and private mockup outbox.
|
|
2363
2398
|
entry.viewport = new ViewportManager({
|
|
2364
2399
|
workspaceRoot: cwd || process.cwd(), ownerId: id, outbox: mockupOutbox,
|
|
2400
|
+
authContext: () => ({ accessToken: codeAuthToken, supabaseUrl: SUPABASE_URL }),
|
|
2365
2401
|
designContext: () => {
|
|
2366
2402
|
const active = designActive.get(id)
|
|
2367
2403
|
if (!active || active.record.sourceKind !== 'preview') return null
|
|
@@ -2672,10 +2708,13 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2672
2708
|
// arrived (rides the existing code-event 'you' path — no new topic).
|
|
2673
2709
|
const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
|
|
2674
2710
|
autoNameTerminal(target.id, args.text)
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2711
|
+
const dispatched = dispatchStructuredTurn(te, msg, {
|
|
2712
|
+
visibleEvent: evt,
|
|
2713
|
+
rejectionMessage: 'The cross-terminal task was not accepted; retry after the lane is available.',
|
|
2714
|
+
})
|
|
2715
|
+
if (!dispatched.accepted) return okText(dispatched.error
|
|
2716
|
+
? `Could not deliver to terminal ${target.ref} — it may have just closed.`
|
|
2717
|
+
: `Could not deliver to terminal ${target.ref} — it refused the turn. Check its visible host-memory/runtime error.`)
|
|
2679
2718
|
return okText(`Delivered to terminal ${target.ref} (${target.cmd}). It will respond in its own lane; check back with read_terminal.`)
|
|
2680
2719
|
},
|
|
2681
2720
|
)] : []),
|
|
@@ -2726,9 +2765,13 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2726
2765
|
const msg = `[Task from main terminal ${fromRef}'s agent — opened as an independent MAIN CASCADE CONDUCTOR terminal, not an Ensemble child]\n${String(args.task).trim()}`
|
|
2727
2766
|
const evt = { kind: 'you', text: msg, by: `main terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
|
|
2728
2767
|
autoNameTerminal(newId, args.task)
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2768
|
+
const dispatched = dispatchStructuredTurn(conductor, msg, {
|
|
2769
|
+
visibleEvent: evt,
|
|
2770
|
+
rejectionMessage: 'The initial conductor task was not accepted; retry after the lane is available.',
|
|
2771
|
+
})
|
|
2772
|
+
if (!dispatched.accepted) return okText(dispatched.error
|
|
2773
|
+
? `Opened main conductor ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but it may still be starting — the initial task could not be delivered.`
|
|
2774
|
+
: `Opened main conductor ${newRef}, but host pressure prevented its runtime from starting. Existing lanes remain connected; free memory and retry the task.`)
|
|
2732
2775
|
return okText(`Opened independent main Cascade conductor ${newRef}${args?.name ? ` ("${args.name}")` : ''} and handed it the task. It is a main terminal, not an Ensemble lane.`)
|
|
2733
2776
|
},
|
|
2734
2777
|
)] : []),
|
|
@@ -2846,8 +2889,13 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2846
2889
|
const msg = `[Task from terminal ${fromRef}'s agent — relayed via ThinkPool Ensemble; you are its WORKER SUB-TERMINAL, never a main terminal or Cascade conductor]\n${args.task}${reviewTarget}`
|
|
2847
2890
|
const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
|
|
2848
2891
|
autoNameTerminal(newId, args.task)
|
|
2849
|
-
|
|
2850
|
-
|
|
2892
|
+
const dispatched = dispatchStructuredTurn(ne, msg, {
|
|
2893
|
+
visibleEvent: evt,
|
|
2894
|
+
rejectionMessage: 'The initial worker task was not accepted; retry after the lane is available.',
|
|
2895
|
+
})
|
|
2896
|
+
if (!dispatched.accepted) return okText(dispatched.error
|
|
2897
|
+
? `Opened lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but it may still be starting — could not hand off the task. Try post_to_terminal shortly.`
|
|
2898
|
+
: `Opened lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but host pressure prevented its runtime from accepting the task. Existing lanes remain connected; free memory and retry.`)
|
|
2851
2899
|
return okText(`Opened agent lane ${newRef}${args?.name ? ` ("${args.name}")` : ''} and handed it the task. It runs in its own lane — check back with read_terminal, then close_terminal when done.`)
|
|
2852
2900
|
}
|
|
2853
2901
|
return okText(`Opened idle agent lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}. Hand it work with post_to_terminal, or a person can type into it.`)
|
|
@@ -3896,12 +3944,15 @@ channel
|
|
|
3896
3944
|
}
|
|
3897
3945
|
child.hop = 0
|
|
3898
3946
|
const childEvent = { kind: 'you', text: task, by }
|
|
3899
|
-
stampEvent(childEvent); pushLog(child, childEvent); bcast('code-event', { term: payload.id, evt: childEvent })
|
|
3900
3947
|
const parentEvent = { kind: 'side-started', sideId: payload.id, sideName, task, by }
|
|
3901
|
-
|
|
3948
|
+
pushLog(parent, parentEvent); bcast('code-event', { term: sideParent, evt: parentEvent })
|
|
3902
3949
|
const snapshot = sideSnapshot(parent.log.filter((event) => event.cid !== parentEvent.cid))
|
|
3903
3950
|
const prompt = snapshot ? `${snapshot}\n\n--- side task ---\n${task}` : task
|
|
3904
|
-
|
|
3951
|
+
const dispatched = dispatchStructuredTurn(child, prompt, {
|
|
3952
|
+
visibleEvent: childEvent,
|
|
3953
|
+
rejectionMessage: 'The side-lane task was not accepted; retry after the lane is available.',
|
|
3954
|
+
})
|
|
3955
|
+
if (!dispatched.accepted) {
|
|
3905
3956
|
bcast('side-open-failed', { id: payload.id, parent: sideParent, message: 'The side agent could not start.' })
|
|
3906
3957
|
endStructured(payload.id)
|
|
3907
3958
|
return
|
|
@@ -4083,8 +4134,10 @@ channel
|
|
|
4083
4134
|
const preparing = { kind: 'control', text: 'Preparing a compact handoff for main…', by }
|
|
4084
4135
|
stampEvent(preparing); pushLog(side, preparing); bcast('code-event', { term: payload.term, evt: preparing })
|
|
4085
4136
|
side.flush?.(); announce()
|
|
4086
|
-
|
|
4087
|
-
|
|
4137
|
+
const dispatched = dispatchStructuredTurn(side, SIDE_HANDOFF_PROMPT, {
|
|
4138
|
+
rejectionMessage: 'The side-lane handoff was not accepted; retry after the lane is available.',
|
|
4139
|
+
})
|
|
4140
|
+
if (!dispatched.accepted) {
|
|
4088
4141
|
side.sideHandback = null
|
|
4089
4142
|
const failed = { kind: 'control', text: 'Couldn’t start the handoff — try again.', by }
|
|
4090
4143
|
stampEvent(failed); pushLog(side, failed); bcast('code-event', { term: payload.term, evt: failed })
|
|
@@ -4805,7 +4858,9 @@ flowChannel
|
|
|
4805
4858
|
// to pin a cheaper/smarter conductor. Lanes get tiered below via laneModelFor.
|
|
4806
4859
|
openStructured({ id: cid, runtime: flowRuntime, model: conductorModel, mode: flowRuntime === 'codex' ? 'plan' : 'default', rolePrompt: flowRuntime === 'claude' ? FLOW_CONDUCTOR_PROMPT : FLOW_CODEX_CONDUCTOR_PROMPT, flowSessionId: payload.flowId, flowRole: 'conductor', spawnedBy: `flow:${payload.flowId}` })
|
|
4807
4860
|
const ce = sessions.get(cid)
|
|
4808
|
-
if (ce?.session)
|
|
4861
|
+
if (ce?.session) dispatchStructuredTurn(ce, payload.prompt || '', {
|
|
4862
|
+
rejectionMessage: 'The Flow conductor task was not accepted; retry after the lane is available.',
|
|
4863
|
+
})
|
|
4809
4864
|
process.stderr.write(`\n ${A.mag}◆ flow conductor launched — flow ${String(payload.flowId).slice(0, 8)} (plan mode).${A.rst}\n`)
|
|
4810
4865
|
announce()
|
|
4811
4866
|
})
|
|
@@ -4952,7 +5007,9 @@ flowChannel
|
|
|
4952
5007
|
`\nProject (context): ${payload.flowPrompt || ''}\n\n` +
|
|
4953
5008
|
`Build your slice. Own only your files. Done = it runs + meets acceptance. Commit when done.` +
|
|
4954
5009
|
(flowRuntime === 'codex' || flowRuntime === 'hermes' ? ` Then call the ThinkPool mark_flow_done MCP tool; do not write FLOW_DONE.` : '')
|
|
4955
|
-
|
|
5010
|
+
dispatchStructuredTurn(le, spec, {
|
|
5011
|
+
rejectionMessage: `Flow slice ${t.task_key} was not accepted; retry after the lane is available.`,
|
|
5012
|
+
})
|
|
4956
5013
|
}
|
|
4957
5014
|
}
|
|
4958
5015
|
assignments.push({ task_key: t.task_key, laneId })
|
package/flow-worktree.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { execFileSync } from 'node:child_process'
|
|
12
12
|
import path from 'node:path'
|
|
13
13
|
import fs from 'node:fs'
|
|
14
|
+
import { linkLocalEnvIntoWorktree } from './lane-worktree.mjs'
|
|
14
15
|
|
|
15
16
|
const ROOT = process.cwd() // the bridge's checkout (the shared main repo root)
|
|
16
17
|
|
|
@@ -33,10 +34,13 @@ export function worktreeSpec ({ flowId, taskKey, root = ROOT }) {
|
|
|
33
34
|
// fresh `git init` checkout), and the dispatch loop swallowed the throw → NO lane ever
|
|
34
35
|
// spawned. Resolve the first ref that actually exists: caller's base → origin/main →
|
|
35
36
|
// origin/HEAD → main → master → HEAD.
|
|
36
|
-
export function createFlowWorktree ({ flowId, taskKey, base = null, root = ROOT, git = runGit }) {
|
|
37
|
+
export function createFlowWorktree ({ flowId, taskKey, base = null, root = ROOT, git = runGit, fsImpl = fs }) {
|
|
37
38
|
const { branch, dir, wtRoot } = worktreeSpec({ flowId, taskKey, root })
|
|
38
|
-
if (
|
|
39
|
-
|
|
39
|
+
if (fsImpl.existsSync(path.join(dir, '.git'))) {
|
|
40
|
+
linkLocalEnvIntoWorktree({ root, dir, fsImpl })
|
|
41
|
+
return { dir, branch, created: false }
|
|
42
|
+
}
|
|
43
|
+
fsImpl.mkdirSync(wtRoot, { recursive: true })
|
|
40
44
|
let ref = base
|
|
41
45
|
if (!ref) {
|
|
42
46
|
for (const cand of ['origin/main', 'origin/HEAD', 'main', 'master', 'HEAD']) {
|
|
@@ -50,6 +54,7 @@ export function createFlowWorktree ({ flowId, taskKey, base = null, root = ROOT,
|
|
|
50
54
|
// Branch already exists (a prior dispatch of this task) — check it out instead.
|
|
51
55
|
git(['worktree', 'add', dir, branch], root)
|
|
52
56
|
}
|
|
57
|
+
linkLocalEnvIntoWorktree({ root, dir, fsImpl })
|
|
53
58
|
return { dir, branch, created: true }
|
|
54
59
|
}
|
|
55
60
|
|
package/lane-worktree.mjs
CHANGED
|
@@ -4,6 +4,27 @@ import { execFileSync } from 'node:child_process'
|
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import fs from 'node:fs'
|
|
6
6
|
|
|
7
|
+
const LOCAL_ENV_FILES = ['.env.local', '.env.development.local']
|
|
8
|
+
|
|
9
|
+
// Git deliberately omits local env files from worktrees. That is correct for
|
|
10
|
+
// version control but wrong for an isolated lane that must build/preview the
|
|
11
|
+
// same app as the main checkout: Vite otherwise compiles `undefined` public
|
|
12
|
+
// client config and the preview dies before React mounts. Link, never copy, the
|
|
13
|
+
// main checkout's existing ignored env files. Missing files are a clean no-op.
|
|
14
|
+
export function linkLocalEnvIntoWorktree({ root, dir, fsImpl = fs } = {}) {
|
|
15
|
+
const linked = []
|
|
16
|
+
for (const name of LOCAL_ENV_FILES) {
|
|
17
|
+
const source = path.join(root, name)
|
|
18
|
+
const target = path.join(dir, name)
|
|
19
|
+
try {
|
|
20
|
+
if (!fsImpl.existsSync?.(source) || fsImpl.existsSync?.(target)) continue
|
|
21
|
+
fsImpl.symlinkSync?.(source, target)
|
|
22
|
+
linked.push(name)
|
|
23
|
+
} catch { /* local env is optional; worktree creation must still succeed */ }
|
|
24
|
+
}
|
|
25
|
+
return linked
|
|
26
|
+
}
|
|
27
|
+
|
|
7
28
|
export function createManagedLaneWorktree({ terminalId, cwd = process.cwd(), git = runGit, fsImpl = fs } = {}) {
|
|
8
29
|
const id = String(terminalId || '')
|
|
9
30
|
const short = id.replace(/[^a-zA-Z0-9]/g, '').slice(0, 8)
|
|
@@ -15,6 +36,7 @@ export function createManagedLaneWorktree({ terminalId, cwd = process.cwd(), git
|
|
|
15
36
|
const base = resolveLaneBase({ root, git })
|
|
16
37
|
fsImpl.mkdirSync(path.dirname(dir), { recursive: true })
|
|
17
38
|
git(['worktree', 'add', '-b', branch, dir, base], root)
|
|
39
|
+
linkLocalEnvIntoWorktree({ root, dir, fsImpl })
|
|
18
40
|
return { terminalId: id, root, dir, branch }
|
|
19
41
|
}
|
|
20
42
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"bundleVersion":
|
|
3
|
+
"bundleVersion": 13,
|
|
4
4
|
"contracts": [
|
|
5
5
|
{
|
|
6
6
|
"id": "room-coordination",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
"id": "work-routing",
|
|
44
|
-
"version":
|
|
44
|
+
"version": 5,
|
|
45
45
|
"providerRoutingContract": "A Claude lane may select a connected Anthropic-compatible provider by durable id, unique display name, or unique configured model. Unknown or ambiguous references fail closed and must never fall through to built-in Claude.",
|
|
46
46
|
"openerParityContract": "Every top-level terminal may use spawn_terminal or open_main_terminal to open every supported structured runtime: Claude, Codex, or Hermes. Runtime-specific provider/model validation remains authoritative and occurs before inference.",
|
|
47
47
|
"routes": [
|
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
"id": "visual-proof",
|
|
95
95
|
"tools": ["preview_start", "preview_capture", "preview_inspect", "preview_stop"],
|
|
96
96
|
"trigger": "\\b(ui|ux|visual|design|frontend|html|css|page|route|mockup|screenshot|responsive|desktop|mobile|preview)\\b",
|
|
97
|
-
"prompt": "For built visual work, run the build, use preview_start, preview_capture at desktop and mobile, preview_inspect when rendered state matters, and preview_stop when finished. preview_capture uses a fresh isolated browser and is inline verification evidence by default; it must not create a transcript card. Set card=true only when the person asked for a mockup/Design artifact or the visual itself is an intentional deliverable that should remain editable in the transcript. A card requires settled meaningful content at both desktop and mobile, rejects loading/public-auth fallback shells, and is delivered after the final agent response.
|
|
97
|
+
"prompt": "For built visual work, run the build, use preview_start, preview_capture at desktop and mobile, preview_inspect when rendered state matters, and preview_stop when finished. preview_capture uses a fresh isolated browser and is inline verification evidence by default; it must not create a transcript card. Set card=true only when the person asked for a mockup/Design artifact or the visual itself is an intentional deliverable that should remain editable in the transcript. A card requires settled meaningful content at both desktop and mobile, rejects loading/public-auth fallback shells, and is delivered after the final agent response. Room and protected routes automatically use the bridge account as a read-only authenticated visual harness; external REST writes, presence, ordinary room broadcasts, and refresh-token access are blocked, while non-mutating roster/transcript snapshot requests are allowed. Surface PNG evidence only when no interactive source-backed Design card is displayed."
|
|
98
98
|
}
|
|
99
99
|
],
|
|
100
100
|
"impact": [
|
package/viewport.mjs
CHANGED
|
@@ -25,6 +25,9 @@ export const DEFAULT_VIEWPORTS = Object.freeze({
|
|
|
25
25
|
|
|
26
26
|
const MAX_CAPTURE_HEIGHT = 20000
|
|
27
27
|
const MAX_SETTLE_MS = 5000
|
|
28
|
+
const AUTH_READY_TIMEOUT_MS = 15000
|
|
29
|
+
const AUTH_FRESH_MARGIN_SEC = 30
|
|
30
|
+
const PREVIEW_LOCAL_EXPIRY_EXTENSION_SEC = 3600
|
|
28
31
|
const CDP_TIMEOUT_MS = 12000
|
|
29
32
|
const MAX_PORTABLE_SNAPSHOT_BYTES = 1_900_000
|
|
30
33
|
|
|
@@ -88,12 +91,62 @@ export function previewArtifactIssue(route, capture) {
|
|
|
88
91
|
if (actualRoute && actualRoute !== expectedRoute) return `Preview navigated from ${expectedRoute} to ${actualRoute}.`
|
|
89
92
|
if (state.signedOutInvite) return 'Preview resolved to the signed-out room invitation instead of the authenticated Code room.'
|
|
90
93
|
if (state.visibleBoot) return 'Preview was still showing the application loading shell.'
|
|
94
|
+
if (state.visibleLoading) return 'Preview was still showing an application loading state.'
|
|
91
95
|
if (protectedRoute && state.prerenderGeo) return 'Preview resolved to the public marketing shell instead of the authenticated Code surface.'
|
|
92
96
|
if (!state.hasBody || (state.textLength < 2 && state.meaningfulVisualCount < 1)) return 'Preview rendered an empty page.'
|
|
93
97
|
}
|
|
94
98
|
return null
|
|
95
99
|
}
|
|
96
100
|
|
|
101
|
+
export function routeNeedsPreviewAuth(route) {
|
|
102
|
+
const parsed = new URL(normalizeRoute(route), 'http://127.0.0.1')
|
|
103
|
+
return !!parsed.searchParams.get('r') || ['/settings', '/contacts', '/code/link'].includes(parsed.pathname)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function decodeJwtPayload(token) {
|
|
107
|
+
try {
|
|
108
|
+
const payload = String(token || '').split('.')[1]
|
|
109
|
+
return payload ? JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) : null
|
|
110
|
+
} catch { return null }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Build the minimum Supabase local-storage session needed for a short-lived,
|
|
114
|
+
// read-only visual capture. Never expose the bridge refresh token to Chrome.
|
|
115
|
+
export function previewAuthBootstrap({ accessToken, supabaseUrl, nowSec = Math.floor(Date.now() / 1000), minTtlSec = 0 } = {}) {
|
|
116
|
+
const claims = decodeJwtPayload(accessToken)
|
|
117
|
+
let endpoint
|
|
118
|
+
try { endpoint = new URL(supabaseUrl) } catch { return null }
|
|
119
|
+
if (!accessToken || endpoint.protocol !== 'https:' || !claims?.sub || !claims?.exp || claims.exp - nowSec <= minTtlSec) return null
|
|
120
|
+
const projectRef = endpoint.hostname.split('.')[0]
|
|
121
|
+
if (!projectRef) return null
|
|
122
|
+
return {
|
|
123
|
+
storageKey: `sb-${projectRef}-auth-token`,
|
|
124
|
+
allowedReadOrigins: [endpoint.origin],
|
|
125
|
+
allowedSocketOrigins: [`wss://${endpoint.host}`],
|
|
126
|
+
session: {
|
|
127
|
+
access_token: accessToken,
|
|
128
|
+
refresh_token: '',
|
|
129
|
+
// auth-js refreshes 90s early. Extend only its browser-local marker; the
|
|
130
|
+
// signed JWT keeps its real expiry and is still server-validated per read.
|
|
131
|
+
expires_in: Math.max(1, claims.exp - nowSec) + PREVIEW_LOCAL_EXPIRY_EXTENSION_SEC,
|
|
132
|
+
expires_at: claims.exp + PREVIEW_LOCAL_EXPIRY_EXTENSION_SEC,
|
|
133
|
+
token_type: 'bearer',
|
|
134
|
+
user: {
|
|
135
|
+
id: claims.sub,
|
|
136
|
+
aud: claims.aud || 'authenticated',
|
|
137
|
+
role: claims.role || 'authenticated',
|
|
138
|
+
email: claims.email || null,
|
|
139
|
+
phone: claims.phone || '',
|
|
140
|
+
app_metadata: claims.app_metadata || {},
|
|
141
|
+
user_metadata: claims.user_metadata || {},
|
|
142
|
+
identities: [],
|
|
143
|
+
created_at: claims.created_at || new Date(nowSec * 1000).toISOString(),
|
|
144
|
+
updated_at: new Date(nowSec * 1000).toISOString(),
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
97
150
|
export function findBrowserExecutable({ env = process.env, platform = process.platform, exists = fs.existsSync } = {}) {
|
|
98
151
|
const candidates = [
|
|
99
152
|
env.TP_BROWSER_PATH,
|
|
@@ -241,22 +294,51 @@ export class CdpBrowser {
|
|
|
241
294
|
}
|
|
242
295
|
}
|
|
243
296
|
|
|
244
|
-
async withPage({ url, viewport, waitMs = 300 }, fn) {
|
|
297
|
+
async withPage({ url, viewport, waitMs = 300, auth = null }, fn) {
|
|
245
298
|
await this.ensureStarted()
|
|
246
299
|
const { targetId } = await this.pipe.send('Target.createTarget', { url: 'about:blank' })
|
|
247
300
|
const { sessionId } = await this.pipe.send('Target.attachToTarget', { targetId, flatten: true })
|
|
248
301
|
let unsubscribeRequests = null
|
|
302
|
+
let unsubscribeResponses = null
|
|
303
|
+
let unsubscribeExceptions = null
|
|
304
|
+
const authResponses = []
|
|
305
|
+
const localResponses = []
|
|
306
|
+
const runtimeExceptions = []
|
|
249
307
|
try {
|
|
250
308
|
await this.pipe.send('Page.enable', {}, sessionId)
|
|
251
309
|
await this.pipe.send('Runtime.enable', {}, sessionId)
|
|
310
|
+
await this.pipe.send('Network.enable', {}, sessionId).catch(() => {})
|
|
311
|
+
if (auth?.storageKey && auth?.session) {
|
|
312
|
+
const source = `(() => { try { localStorage.setItem(${JSON.stringify(auth.storageKey)}, ${JSON.stringify(JSON.stringify(auth.session))}); window.__TP_READ_ONLY_PREVIEW__ = true; } catch {} })()`
|
|
313
|
+
await this.pipe.send('Page.addScriptToEvaluateOnNewDocument', { source }, sessionId)
|
|
314
|
+
}
|
|
252
315
|
// The page is untrusted build output. Keep its network authority narrower
|
|
253
|
-
// than the lane sandbox: same preview origin only
|
|
254
|
-
//
|
|
255
|
-
// JS/CSS/assets served from the selected build directory.
|
|
316
|
+
// than the lane sandbox: same preview origin plus read-only requests to
|
|
317
|
+
// the exact Supabase origin provided by the bridge auth context.
|
|
256
318
|
const allowedOrigin = new URL(url).origin
|
|
319
|
+
const allowedReadOrigins = new Set(auth?.allowedReadOrigins || [])
|
|
320
|
+
const allowedSocketOrigins = new Set(auth?.allowedSocketOrigins || [])
|
|
321
|
+
unsubscribeResponses = this.pipe.subscribe('Network.responseReceived', sessionId, (params) => {
|
|
322
|
+
try {
|
|
323
|
+
const responseUrl = new URL(params.response?.url || '')
|
|
324
|
+
if (allowedReadOrigins.has(responseUrl.origin) && authResponses.length < 20) authResponses.push(`${params.response?.status || 0}:${responseUrl.pathname}`)
|
|
325
|
+
if (responseUrl.origin === allowedOrigin && localResponses.length < 20) localResponses.push(`${params.response?.status || 0}:${responseUrl.pathname}`)
|
|
326
|
+
} catch { /* diagnostics only */ }
|
|
327
|
+
})
|
|
328
|
+
unsubscribeExceptions = this.pipe.subscribe('Runtime.exceptionThrown', sessionId, (params) => {
|
|
329
|
+
if (runtimeExceptions.length >= 5) return
|
|
330
|
+
const detail = params.exceptionDetails?.exception?.description || params.exceptionDetails?.text || 'runtime error'
|
|
331
|
+
runtimeExceptions.push(String(detail).split('\n')[0].slice(0, 180))
|
|
332
|
+
})
|
|
257
333
|
unsubscribeRequests = this.pipe.subscribe('Fetch.requestPaused', sessionId, (params) => {
|
|
258
334
|
let allowed = false
|
|
259
|
-
try {
|
|
335
|
+
try {
|
|
336
|
+
const requestUrl = new URL(params.request?.url || '')
|
|
337
|
+
const requestMethod = String(params.request?.method || 'GET').toUpperCase()
|
|
338
|
+
allowed = requestUrl.origin === allowedOrigin ||
|
|
339
|
+
(allowedReadOrigins.has(requestUrl.origin) && ['GET', 'HEAD', 'OPTIONS'].includes(requestMethod)) ||
|
|
340
|
+
(allowedSocketOrigins.has(requestUrl.origin) && requestMethod === 'GET')
|
|
341
|
+
} catch { allowed = false }
|
|
260
342
|
const method = allowed ? 'Fetch.continueRequest' : 'Fetch.failRequest'
|
|
261
343
|
const request = allowed
|
|
262
344
|
? { requestId: params.requestId }
|
|
@@ -276,11 +358,57 @@ export class CdpBrowser {
|
|
|
276
358
|
await this.pipe.send('Runtime.evaluate', {
|
|
277
359
|
expression: 'document.fonts && document.fonts.ready', awaitPromise: true, returnByValue: true,
|
|
278
360
|
}, sessionId).catch(() => {})
|
|
279
|
-
|
|
361
|
+
if (auth) {
|
|
362
|
+
const deadline = Date.now() + AUTH_READY_TIMEOUT_MS
|
|
363
|
+
let ready = false
|
|
364
|
+
let readySince = 0
|
|
365
|
+
while (Date.now() < deadline) {
|
|
366
|
+
const state = await this.pipe.send('Runtime.evaluate', {
|
|
367
|
+
expression: `(() => {
|
|
368
|
+
if (document.querySelector('#invitation-room-title')) return true;
|
|
369
|
+
if (document.querySelector('#boot, .boot-loader')) return false;
|
|
370
|
+
const loading = [...document.querySelectorAll('span')].some((node) => /^(loading session|restoring terminals…|connecting(?:…|\.\.\.))$/i.test((node.textContent || '').trim()));
|
|
371
|
+
return !loading && (document.body?.innerText || '').trim().length > 1;
|
|
372
|
+
})()`,
|
|
373
|
+
returnByValue: true,
|
|
374
|
+
}, sessionId)
|
|
375
|
+
if (state.result?.value === true) {
|
|
376
|
+
if (!readySince) readySince = Date.now()
|
|
377
|
+
if (Date.now() - readySince >= 800) { ready = true; break }
|
|
378
|
+
} else readySince = 0
|
|
379
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
380
|
+
}
|
|
381
|
+
if (!ready) {
|
|
382
|
+
const diagnostic = await this.pipe.send('Runtime.evaluate', {
|
|
383
|
+
expression: `(async () => {
|
|
384
|
+
let locks = { held: [], pending: [] };
|
|
385
|
+
try { locks = await navigator.locks.query(); } catch {}
|
|
386
|
+
return {
|
|
387
|
+
sessionPresent: !!localStorage.getItem(${JSON.stringify(auth.storageKey || '')}),
|
|
388
|
+
inlineBoot: !!document.querySelector('#boot, .boot-loader'),
|
|
389
|
+
loadingSession: [...document.querySelectorAll('span')].some((node) => (node.textContent || '').trim() === 'Loading session'),
|
|
390
|
+
connecting: [...document.querySelectorAll('span')].some((node) => (node.textContent || '').trim().toLowerCase() === 'connecting…'),
|
|
391
|
+
invitation: !!document.querySelector('#invitation-room-title'),
|
|
392
|
+
heldLocks: locks.held?.length || 0,
|
|
393
|
+
pendingLocks: locks.pending?.length || 0,
|
|
394
|
+
};
|
|
395
|
+
})()`,
|
|
396
|
+
awaitPromise: true,
|
|
397
|
+
returnByValue: true,
|
|
398
|
+
}, sessionId).catch(() => ({ result: { value: {} } }))
|
|
399
|
+
const d = diagnostic.result?.value || {}
|
|
400
|
+
throw new Error(`Authenticated preview did not leave its loading state within 15 seconds (session=${d.sessionPresent ? 'present' : 'missing'}, inlineBoot=${d.inlineBoot ? 'yes' : 'no'}, loadingSession=${d.loadingSession ? 'yes' : 'no'}, connecting=${d.connecting ? 'yes' : 'no'}, authLocks=${d.heldLocks || 0}/${d.pendingLocks || 0}, invitation=${d.invitation ? 'yes' : 'no'}, reads=${authResponses.join(',') || 'none'}, local=${localResponses.join(',') || 'none'}, runtime=${runtimeExceptions.join(' | ') || 'none'}); no screenshot was created.`)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const settle = auth
|
|
404
|
+
? Math.min(300, Math.max(0, Number(waitMs) || 0))
|
|
405
|
+
: Math.min(MAX_SETTLE_MS, Math.max(0, Number(waitMs) || 0))
|
|
280
406
|
if (settle) await new Promise((resolve) => setTimeout(resolve, settle))
|
|
281
407
|
return await fn({ pipe: this.pipe, sessionId })
|
|
282
408
|
} finally {
|
|
283
409
|
unsubscribeRequests?.()
|
|
410
|
+
unsubscribeResponses?.()
|
|
411
|
+
unsubscribeExceptions?.()
|
|
284
412
|
await this.pipe.send('Target.closeTarget', { targetId }).catch(() => {})
|
|
285
413
|
}
|
|
286
414
|
}
|
|
@@ -302,6 +430,10 @@ export class CdpBrowser {
|
|
|
302
430
|
textLength: (document.body?.innerText || '').trim().length,
|
|
303
431
|
meaningfulVisualCount: meaningful.length,
|
|
304
432
|
visibleBoot: visible(boot),
|
|
433
|
+
visibleLoading: [...document.querySelectorAll('span')].some((node) => {
|
|
434
|
+
const text = (node.textContent || '').trim();
|
|
435
|
+
return visible(node) && /^(loading session|restoring terminals…|connecting(?:…|\.\.\.))$/i.test(text);
|
|
436
|
+
}),
|
|
305
437
|
signedOutInvite: !!document.querySelector('#invitation-room-title'),
|
|
306
438
|
prerenderGeo: !!document.querySelector('[data-prerender="geo"]'),
|
|
307
439
|
};
|
|
@@ -382,8 +514,8 @@ export class CdpBrowser {
|
|
|
382
514
|
return html
|
|
383
515
|
}
|
|
384
516
|
|
|
385
|
-
async capture({ url, viewport, fullPage = true, waitMs = 300, includeSnapshot = false }) {
|
|
386
|
-
return this.withPage({ url, viewport, waitMs }, async ({ pipe, sessionId }) => {
|
|
517
|
+
async capture({ url, viewport, fullPage = true, waitMs = 300, includeSnapshot = false, auth = null }) {
|
|
518
|
+
return this.withPage({ url, viewport, waitMs, auth }, async ({ pipe, sessionId }) => {
|
|
387
519
|
const beforeState = await this.renderedState(pipe, sessionId)
|
|
388
520
|
const metrics = await pipe.send('Page.getLayoutMetrics', {}, sessionId)
|
|
389
521
|
const contentHeight = Math.ceil(metrics.cssContentSize?.height || viewport.height)
|
|
@@ -398,12 +530,12 @@ export class CdpBrowser {
|
|
|
398
530
|
})
|
|
399
531
|
}
|
|
400
532
|
|
|
401
|
-
async snapshot({ url, viewport = DEFAULT_VIEWPORTS.desktop, waitMs = 300 }) {
|
|
402
|
-
return this.withPage({ url, viewport, waitMs }, ({ pipe, sessionId }) => this.freezeCurrentPage(pipe, sessionId))
|
|
533
|
+
async snapshot({ url, viewport = DEFAULT_VIEWPORTS.desktop, waitMs = 300, auth = null }) {
|
|
534
|
+
return this.withPage({ url, viewport, waitMs, auth }, ({ pipe, sessionId }) => this.freezeCurrentPage(pipe, sessionId))
|
|
403
535
|
}
|
|
404
536
|
|
|
405
|
-
async inspect({ url, viewport = DEFAULT_VIEWPORTS.mobile, selector, waitMs = 300 }) {
|
|
406
|
-
return this.withPage({ url, viewport, waitMs }, async ({ pipe, sessionId }) => {
|
|
537
|
+
async inspect({ url, viewport = DEFAULT_VIEWPORTS.mobile, selector, waitMs = 300, auth = null }) {
|
|
538
|
+
return this.withPage({ url, viewport, waitMs, auth }, async ({ pipe, sessionId }) => {
|
|
407
539
|
const expression = `(() => {
|
|
408
540
|
const selector = ${JSON.stringify(selector || null)};
|
|
409
541
|
const el = selector ? document.querySelector(selector) : null;
|
|
@@ -437,13 +569,14 @@ export class CdpBrowser {
|
|
|
437
569
|
export const sharedViewportBrowser = new CdpBrowser()
|
|
438
570
|
|
|
439
571
|
export class ViewportManager {
|
|
440
|
-
constructor({ workspaceRoot, ownerId, outbox, browser = sharedViewportBrowser, startPreviewImpl = startPreview, designContext = null } = {}) {
|
|
572
|
+
constructor({ workspaceRoot, ownerId, outbox, browser = sharedViewportBrowser, startPreviewImpl = startPreview, designContext = null, authContext = null } = {}) {
|
|
441
573
|
this.workspaceRoot = path.resolve(workspaceRoot || process.cwd())
|
|
442
574
|
this.ownerId = ownerId || randomUUID()
|
|
443
575
|
this.outbox = outbox || path.join(os.tmpdir(), 'thinkpool-viewport-captures', this.ownerId)
|
|
444
576
|
this.browser = browser
|
|
445
577
|
this.startPreviewImpl = startPreviewImpl
|
|
446
578
|
this.designContext = typeof designContext === 'function' ? designContext : () => null
|
|
579
|
+
this.authContext = typeof authContext === 'function' ? authContext : () => null
|
|
447
580
|
this.previewId = `viewport:${this.ownerId}`
|
|
448
581
|
this.preview = null
|
|
449
582
|
this.root = null
|
|
@@ -479,6 +612,20 @@ export class ViewportManager {
|
|
|
479
612
|
return new URL(normalizeRoute(route), preview.url).href
|
|
480
613
|
}
|
|
481
614
|
|
|
615
|
+
async previewAuth(route) {
|
|
616
|
+
if (!routeNeedsPreviewAuth(route)) return null
|
|
617
|
+
let context = this.authContext() || {}
|
|
618
|
+
if (!context.accessToken) return null
|
|
619
|
+
const deadline = Date.now() + AUTH_READY_TIMEOUT_MS
|
|
620
|
+
for (;;) {
|
|
621
|
+
const auth = previewAuthBootstrap({ ...context, minTtlSec: AUTH_FRESH_MARGIN_SEC })
|
|
622
|
+
if (auth) return auth
|
|
623
|
+
if (Date.now() >= deadline) throw new Error('The bridge account token did not refresh in time for an authenticated preview; no screenshot was created.')
|
|
624
|
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
625
|
+
context = this.authContext() || {}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
482
629
|
async capture({ route = '/', viewports = 'both', title = 'Viewport capture', fullPage = true, waitMs = 300, card = false } = {}) {
|
|
483
630
|
const normalizedRoute = normalizeRoute(route)
|
|
484
631
|
const url = this.pageUrl(normalizedRoute)
|
|
@@ -500,6 +647,7 @@ export class ViewportManager {
|
|
|
500
647
|
captures[name] = await this.browser.capture({
|
|
501
648
|
url, viewport: DEFAULT_VIEWPORTS[name], fullPage, waitMs,
|
|
502
649
|
includeSnapshot: cardRequested && name === (names.includes('desktop') ? 'desktop' : names[0]),
|
|
650
|
+
auth: await this.previewAuth(normalizedRoute),
|
|
503
651
|
})
|
|
504
652
|
}
|
|
505
653
|
// The screenshot and readiness state must come from the SAME page. The old
|
|
@@ -507,10 +655,10 @@ export class ViewportManager {
|
|
|
507
655
|
// production proved the preflight could see marketing content while the
|
|
508
656
|
// screenshot caught only the boot spinner.
|
|
509
657
|
const cardReady = cardRequested && !!(captures.desktop && captures.mobile)
|
|
510
|
-
if (cardReady) {
|
|
658
|
+
if (cardReady || routeNeedsPreviewAuth(normalizedRoute)) {
|
|
511
659
|
for (const [name, capture] of Object.entries(captures)) {
|
|
512
660
|
const issue = previewArtifactIssue(normalizedRoute, capture)
|
|
513
|
-
if (issue) throw new Error(`${name}
|
|
661
|
+
if (issue) throw new Error(`${name} preview rejected: ${issue} No screenshot or mockup card was created.`)
|
|
514
662
|
}
|
|
515
663
|
}
|
|
516
664
|
const snapshot = captures.desktop?.snapshot || captures.mobile?.snapshot || null
|
|
@@ -541,9 +689,13 @@ export class ViewportManager {
|
|
|
541
689
|
return { url, slug, manifestPath, captures, snapshotPath, cardReady }
|
|
542
690
|
}
|
|
543
691
|
|
|
544
|
-
inspect({ route = '/', viewport = 'mobile', selector, waitMs = 300 } = {}) {
|
|
692
|
+
async inspect({ route = '/', viewport = 'mobile', selector, waitMs = 300 } = {}) {
|
|
545
693
|
if (!DEFAULT_VIEWPORTS[viewport]) throw new Error('viewport must be "desktop" or "mobile".')
|
|
546
|
-
|
|
694
|
+
const normalizedRoute = normalizeRoute(route)
|
|
695
|
+
return this.browser.inspect({
|
|
696
|
+
url: this.pageUrl(normalizedRoute), viewport: DEFAULT_VIEWPORTS[viewport], selector, waitMs,
|
|
697
|
+
auth: await this.previewAuth(normalizedRoute),
|
|
698
|
+
})
|
|
547
699
|
}
|
|
548
700
|
|
|
549
701
|
async stop() {
|
|
@@ -572,7 +724,7 @@ export function createViewportTools({ tool, z, manager }) {
|
|
|
572
724
|
),
|
|
573
725
|
tool(
|
|
574
726
|
'preview_capture',
|
|
575
|
-
'Capture this lane preview in a fresh isolated browser at exact desktop (1440×900) and mobile (390×844) CSS viewports. Defaults to both and full-page. Captures are inline verification evidence by default. Set card=true only for a user-requested mockup or visual deliverable that should remain editable in the transcript; complete desktop+mobile settled content is then required and the card arrives after the final response.
|
|
727
|
+
'Capture this lane preview in a fresh isolated browser at exact desktop (1440×900) and mobile (390×844) CSS viewports. Defaults to both and full-page. Captures are inline verification evidence by default. Room and protected routes automatically use the bridge account as a read-only authenticated visual harness; external REST writes, presence, and room broadcasts are blocked, apart from non-mutating roster/transcript snapshot requests. Set card=true only for a user-requested mockup or visual deliverable that should remain editable in the transcript; complete desktop+mobile settled content is then required and the card arrives after the final response.',
|
|
576
728
|
{
|
|
577
729
|
path: z.string().max(500).optional().describe('route within the preview, e.g. / or /settings; never a full URL'),
|
|
578
730
|
viewports: z.enum(['both', 'desktop', 'mobile']).optional(),
|