thinkpool-pair 0.7.289 → 0.7.291
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 +97 -35
- package/design-edit.mjs +39 -2
- package/hermes-session.mjs +3 -1
- package/package.json +2 -1
- package/replay-transport.mjs +64 -0
- package/thinkpool-capabilities.json +4 -4
- package/viewport.mjs +92 -8
package/bridge.mjs
CHANGED
|
@@ -76,7 +76,7 @@ import { writeLaneArtifact, digestSlice, appendDigest, resumeLane } from './flow
|
|
|
76
76
|
import { createFlowWorktree, worktreeSpec } from './flow-worktree.mjs'
|
|
77
77
|
import { startPreview, stopAllPreviews, previews } from './flow-preview.mjs'
|
|
78
78
|
import { ViewportManager, createViewportTools, sharedViewportBrowser } from './viewport.mjs'
|
|
79
|
-
import { deleteDesignAssetDraft, designPrompt, designTranscript, materializeDesignAsset,
|
|
79
|
+
import { deleteDesignAssetDraft, designPrompt, designTranscript, materializeDesignAsset, refreshDesignSource, resolveManifestDesignSource, restoreDesignSources, syncRealtimeAuth, validateDesignBatchRequest } from './design-edit.mjs'
|
|
80
80
|
// FL-M9 — per-lane preview servers leak (one per done lane, never stopped until shutdown).
|
|
81
81
|
// Lane previews are keyed `lane:<flowId>:<laneId>`; stop a whole flow's set when it assembles
|
|
82
82
|
// (the assembled preview supersedes them) or when a lane is reverted.
|
|
@@ -120,6 +120,7 @@ import { supersedeDispatchLease } from './dispatch-lease.mjs'
|
|
|
120
120
|
import { turnInFlight } from './update-gate.mjs'
|
|
121
121
|
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
|
|
122
122
|
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, ImageEventQueue, imageQueueConfig, uploadCodeImage as uploadCodeImageRequest, usageReportLine, codexUsageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
|
|
123
|
+
import { createLatestReplayPump, requestedReplayIds } from './replay-transport.mjs'
|
|
123
124
|
import { classifyCodeEvent, unknownCodeEventNotice } from './code-event-contract.mjs'
|
|
124
125
|
import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
|
|
125
126
|
import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantTextSince, sideContextBlock, sideSnapshot } from './side-lane.mjs'
|
|
@@ -574,6 +575,7 @@ if (process.stdin.isTTY && !headless && process.env.THINKPOOL_PAIR_AUTOUPDATE !=
|
|
|
574
575
|
}
|
|
575
576
|
const name = process.env.TP_NAME || os.userInfo().username || 'host'
|
|
576
577
|
const BRIDGE_ID = (randomUUID?.().slice(0,8)) || 'bridgexx'
|
|
578
|
+
const BRIDGE_STARTED_AT = Date.now()
|
|
577
579
|
// host: this machine's short label — os.hostname() with any DNS/domain suffix
|
|
578
580
|
// stripped and capped ~24 chars. A /code room can be served by different bridges
|
|
579
581
|
// over time (Max's Mac vs Conrad's Linux box); the client uses this to show WHICH
|
|
@@ -957,6 +959,13 @@ function beginStructuredTurn(entry, now = Date.now()) {
|
|
|
957
959
|
return true
|
|
958
960
|
}
|
|
959
961
|
|
|
962
|
+
function advanceStructuredTurn(entry, now = Date.now()) {
|
|
963
|
+
entry._turnRev = (Number(entry._turnRev) || 0) + 1
|
|
964
|
+
entry._turnStart = now
|
|
965
|
+
entry._busyAnn = true
|
|
966
|
+
return true
|
|
967
|
+
}
|
|
968
|
+
|
|
960
969
|
function stampStructuredTurn(entry, event) {
|
|
961
970
|
if (event && event.turnRev == null && Number(entry?._turnRev) > 0) event.turnRev = entry._turnRev
|
|
962
971
|
return event
|
|
@@ -1064,17 +1073,21 @@ const defendFrame = (event, payload) => {
|
|
|
1064
1073
|
return p
|
|
1065
1074
|
}
|
|
1066
1075
|
|
|
1067
|
-
const
|
|
1076
|
+
const bcastAwait = async (event, payload, ch = channel) => {
|
|
1068
1077
|
if (event === 'pty-out' || event === 'code-event') lastActivity = Date.now()
|
|
1069
1078
|
payload = defendFrame(event, payload)
|
|
1070
1079
|
try {
|
|
1071
1080
|
if (ch.channelAdapter?.canPush?.() ?? true) {
|
|
1072
|
-
ch.send({ type: 'broadcast', event, payload })
|
|
1081
|
+
return await ch.send({ type: 'broadcast', event, payload })
|
|
1073
1082
|
} else {
|
|
1074
|
-
ch.httpSend(event, payload)
|
|
1083
|
+
return await ch.httpSend(event, payload)
|
|
1075
1084
|
}
|
|
1076
|
-
} catch { /*
|
|
1085
|
+
} catch { return null /* offline — replay covers it */ }
|
|
1077
1086
|
}
|
|
1087
|
+
const bcast = (event, payload, ch = channel) => { void bcastAwait(event, payload, ch) }
|
|
1088
|
+
const replayPump = createLatestReplayPump({
|
|
1089
|
+
send: ({ event, payload }) => bcastAwait(event, payload),
|
|
1090
|
+
})
|
|
1078
1091
|
|
|
1079
1092
|
// Per-room terminal display names (id -> label), set by the web's `term-rename`.
|
|
1080
1093
|
// Persisted on the host so a rename is cross-device + survives a bridge restart;
|
|
@@ -1093,7 +1106,7 @@ const announce = () => {
|
|
|
1093
1106
|
const provNames = providerNameMap()
|
|
1094
1107
|
const rev = ++announceRev
|
|
1095
1108
|
return bcast('bridge', {
|
|
1096
|
-
v: 2, name, bridge_id: BRIDGE_ID, rev, repo: repoLabel, branch: readBranch(),
|
|
1109
|
+
v: 2, name, bridge_id: BRIDGE_ID, started_at: BRIDGE_STARTED_AT, rev, repo: repoLabel, branch: readBranch(),
|
|
1097
1110
|
// sdkWarn: the auto-pulled agent SDK failed its boot compatibility smoke test —
|
|
1098
1111
|
// surfaced so the room can show a banner (turns may misbehave; pin a good SDK).
|
|
1099
1112
|
...(sdkStatus.ok === false ? { sdkWarn: `${sdkStatus.version}: ${sdkStatus.reason}` } : {}),
|
|
@@ -1415,7 +1428,7 @@ const finishDesign = (term, state, extra = {}) => {
|
|
|
1415
1428
|
const maybeVerifyDesign = (term) => {
|
|
1416
1429
|
const active = designActive.get(term)
|
|
1417
1430
|
if (!active?.resultOk || !active.proof) return
|
|
1418
|
-
finishDesign(term, 'verified-live', { message: 'Verified at desktop and mobile.', artifact: active.proof, canRestore:
|
|
1431
|
+
finishDesign(term, 'verified-live', { message: 'Verified at desktop and mobile.', artifact: active.proof, canRestore: active.record.sourceKind !== 'preview' })
|
|
1419
1432
|
}
|
|
1420
1433
|
|
|
1421
1434
|
function pumpDesign(term) {
|
|
@@ -1433,7 +1446,7 @@ function pumpDesign(term) {
|
|
|
1433
1446
|
if (lane.session?.turnActive) return
|
|
1434
1447
|
queue.shift()
|
|
1435
1448
|
const current = designArtifacts.get(next.record.previewId)
|
|
1436
|
-
const live = current &&
|
|
1449
|
+
const live = current && refreshDesignSource(current)
|
|
1437
1450
|
if (!current || !live || live.revision !== next.record.revision) {
|
|
1438
1451
|
designStatus({ previewId: next.record.previewId, requestId: next.request.cid, state: 'stale', message: 'The artifact changed. Reselect the element.' })
|
|
1439
1452
|
return pumpDesign(term)
|
|
@@ -1488,7 +1501,7 @@ const handleManifest = async (box, file, term, trustedDesignSource = false) => {
|
|
|
1488
1501
|
if (!m?.slug) return
|
|
1489
1502
|
const producer = trustedDesignSource ? sessions.get(term) : null
|
|
1490
1503
|
const designRecord = producer
|
|
1491
|
-
? resolveManifestDesignSource(m, producer.cwd || process.cwd())
|
|
1504
|
+
? resolveManifestDesignSource(m, producer.cwd || process.cwd(), { box })
|
|
1492
1505
|
: null
|
|
1493
1506
|
// 2026-07-07: these used to swallow read errors silently — a transient
|
|
1494
1507
|
// unreadable file (race with the render script, permissions, mid-write)
|
|
@@ -1542,8 +1555,11 @@ const handleManifest = async (box, file, term, trustedDesignSource = false) => {
|
|
|
1542
1555
|
const dual = !!(paths?.html && paths?.desktop && paths?.mobile && m.desktop && m.mobile)
|
|
1543
1556
|
const expectedRevision = active?.restoreRecord?.revision
|
|
1544
1557
|
const revisionProvesChange = expectedRevision ? designRecord.revision === expectedRevision : designRecord.revision !== active?.record?.revision
|
|
1545
|
-
|
|
1546
|
-
|
|
1558
|
+
const sameTarget = active?.record?.sourceKind === 'preview'
|
|
1559
|
+
? designRecord.sourceKind === 'preview' && designRecord.captureKey === active.record.captureKey
|
|
1560
|
+
: designRecord.sourcePath === active?.record?.sourcePath
|
|
1561
|
+
if (correlated && dual && sameTarget && revisionProvesChange) {
|
|
1562
|
+
if (active.record.sourceKind !== 'preview') designRestore.set(designRecord.previewId, { priorRecord: active.record, request: active.request })
|
|
1547
1563
|
active.proof = artifact
|
|
1548
1564
|
maybeVerifyDesign(term)
|
|
1549
1565
|
}
|
|
@@ -2126,7 +2142,14 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2126
2142
|
if (restoredDesigns.length) process.stderr.write(`\n ◆ restored ${restoredDesigns.length} Thinkpool Design artifact${restoredDesigns.length === 1 ? '' : 's'} (${id.slice(0, 8)}).\n`)
|
|
2127
2143
|
// Visual QA runs in the bridge process, outside either agent runtime's sandbox.
|
|
2128
2144
|
// It remains scoped to this lane's cwd and private mockup outbox.
|
|
2129
|
-
entry.viewport = new ViewportManager({
|
|
2145
|
+
entry.viewport = new ViewportManager({
|
|
2146
|
+
workspaceRoot: cwd || process.cwd(), ownerId: id, outbox: mockupOutbox,
|
|
2147
|
+
designContext: () => {
|
|
2148
|
+
const active = designActive.get(id)
|
|
2149
|
+
if (!active || active.record.sourceKind !== 'preview') return null
|
|
2150
|
+
return { requestId: active.request.cid, parentRevision: active.record.revision, captureKey: active.record.captureKey }
|
|
2151
|
+
},
|
|
2152
|
+
})
|
|
2130
2153
|
if (entry.log.length) process.stderr.write(`\n ◆ restored ${entry.log.length} prior events (${id.slice(0, 8)})${resume ? ' + resuming live context' : ''}.\n`)
|
|
2131
2154
|
// Persist the permission mode alongside the transcript so a bridge restart
|
|
2132
2155
|
// restores the session in the SAME mode (a bypass room stays bypass on resume).
|
|
@@ -2866,6 +2889,20 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2866
2889
|
// per-machine default (provider.mjs/applyProviderEnv). null for built-in/unknown → the
|
|
2867
2890
|
// default Claude env is left exactly as-is (unchanged path).
|
|
2868
2891
|
env: { ...process.env, ...buildConductorEnv({ flowSessionId, mode }), ...(resolveProviderEnv(provider) || {}), TP_MOCKUP_OUTBOX: mockupOutbox },
|
|
2892
|
+
onTurnStart: (options = {}) => {
|
|
2893
|
+
// Hermes promotes /queue items internally, without a second code-turn.
|
|
2894
|
+
// Advance the lifecycle before its first output and publish the deferred
|
|
2895
|
+
// human line under the new turn revision, keeping all viewers converged.
|
|
2896
|
+
if (entry._busyAnn === true) advanceStructuredTurn(entry)
|
|
2897
|
+
else beginStructuredTurn(entry)
|
|
2898
|
+
const queuedEcho = options._thinkpoolQueuedEcho
|
|
2899
|
+
if (queuedEcho) {
|
|
2900
|
+
const evt = { kind: 'you', ...queuedEcho }
|
|
2901
|
+
pushLog(entry, evt)
|
|
2902
|
+
bcast('code-event', { term: id, evt })
|
|
2903
|
+
}
|
|
2904
|
+
announce()
|
|
2905
|
+
},
|
|
2869
2906
|
onEvent: (evt) => {
|
|
2870
2907
|
if (!classifyCodeEvent(evt).known) {
|
|
2871
2908
|
// Never archive or replay an unknown provider payload as a known fact. Do
|
|
@@ -2923,7 +2960,9 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2923
2960
|
// replay-union dedupes ONLY by cid, and SDK events carry none — without an
|
|
2924
2961
|
// id, an event that arrives both live AND in a reconnect replay renders
|
|
2925
2962
|
// twice (the 2026-06-19 duplicate-message bug). See event-id.mjs.
|
|
2926
|
-
const
|
|
2963
|
+
const continuesQueued = runtime === 'hermes' && (evt.kind === 'result' || evt.kind === 'error') && (entry.session?.queuedDepth || 0) > 0
|
|
2964
|
+
if (continuesQueued) evt.continuesQueued = true
|
|
2965
|
+
const busyChanged = continuesQueued ? false : syncStructuredTurn(entry)
|
|
2927
2966
|
stampStructuredTurn(entry, evt)
|
|
2928
2967
|
stampEvent(evt)
|
|
2929
2968
|
const stalledChanged = evt.kind === 'stalled' ? !entry.stalled : !!entry.stalled
|
|
@@ -3641,14 +3680,17 @@ channel
|
|
|
3641
3680
|
// Flush pending bytes first so the replay is complete up to "now"; both
|
|
3642
3681
|
// ride the same ordered socket, so the client sees replay-then-live.
|
|
3643
3682
|
flushAll()
|
|
3644
|
-
|
|
3683
|
+
const to = payload?.to ?? null
|
|
3684
|
+
const requestId = String(payload?.requestId || randomUUID?.() || `${Date.now()}`)
|
|
3685
|
+
const frames = []
|
|
3686
|
+
for (const [id, t] of requestedReplayIds(terms, payload?.terms, payload?.priority)) {
|
|
3645
3687
|
if (!t.scrollback) continue
|
|
3646
3688
|
// Cap scrollback too — a huge PTY buffer would blow the same frame limit.
|
|
3647
3689
|
const sb = t.scrollback.length > 100000 ? t.scrollback.slice(-100000) : t.scrollback
|
|
3648
|
-
|
|
3649
|
-
to
|
|
3690
|
+
frames.push({ event: 'pty-replay', payload: {
|
|
3691
|
+
to, term: id, requestId,
|
|
3650
3692
|
b64: Buffer.from(sb, 'utf8').toString('base64'),
|
|
3651
|
-
})
|
|
3693
|
+
} })
|
|
3652
3694
|
}
|
|
3653
3695
|
// Structured sessions replay their event log (reader rebuilds from it).
|
|
3654
3696
|
// C1 (RT-2): send only the tail past the client's per-term cursor (seqHi) when
|
|
@@ -3659,14 +3701,13 @@ channel
|
|
|
3659
3701
|
// cursor) still gets the full log.
|
|
3660
3702
|
// Hydrate the viewed lane first. Other terminals still warm in the background,
|
|
3661
3703
|
// but cannot queue ahead of the transcript the person is waiting to see.
|
|
3662
|
-
const replaySessions =
|
|
3663
|
-
a === payload?.priority ? -1 : b === payload?.priority ? 1 : 0)
|
|
3704
|
+
const replaySessions = requestedReplayIds(sessions, payload?.terms, payload?.priority)
|
|
3664
3705
|
for (const [id, s] of replaySessions) {
|
|
3665
3706
|
// An explicit empty replay is an acknowledgement, not data. It lets mixed
|
|
3666
3707
|
// clients clear their loading cover even if they missed the announce's
|
|
3667
3708
|
// hasTranscript:false state.
|
|
3668
3709
|
if (!s.log.length) {
|
|
3669
|
-
|
|
3710
|
+
frames.push({ event: 'code-replay', payload: { to, term: id, requestId, chunkIndex: 0, chunkCount: 1, events: [], empty: true } })
|
|
3670
3711
|
continue
|
|
3671
3712
|
}
|
|
3672
3713
|
const from = Number(payload?.cursors?.[id]) || 0
|
|
@@ -3706,10 +3747,14 @@ channel
|
|
|
3706
3747
|
// trimmedBefore rides the FIRST (oldest) chunk only — the later chunks are contiguous
|
|
3707
3748
|
// with it, so a stamp there would read as a second, phantom gap. Same rule as
|
|
3708
3749
|
// lastUsage above and hasMore on history-page below.
|
|
3709
|
-
let
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3750
|
+
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
3751
|
+
const events = chunks[chunkIndex]
|
|
3752
|
+
frames.push({ event: 'code-replay', payload: {
|
|
3753
|
+
to, term: id, requestId, chunkIndex, chunkCount: chunks.length, events,
|
|
3754
|
+
...(ahead ? { reset: true } : {}),
|
|
3755
|
+
...(chunkIndex === 0 && trimmedBefore != null ? { trimmedBefore } : {}),
|
|
3756
|
+
...(chunkIndex === 0 && s.lastUsage?.ctx ? { lastUsage: s.lastUsage } : {}),
|
|
3757
|
+
} })
|
|
3713
3758
|
}
|
|
3714
3759
|
}
|
|
3715
3760
|
// Re-send any still-pending permission/question cards. They ride a one-shot
|
|
@@ -3718,10 +3763,15 @@ channel
|
|
|
3718
3763
|
// (unanswered) tool row — the AskUserQuestion "vanished on reconnect" bug. Only
|
|
3719
3764
|
// truly-unresolved cards remain in `pending` (resolved ones are deleted), and
|
|
3720
3765
|
// the client dedupes code-perm-req by id, so this can't resurrect an answered one.
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3766
|
+
// One awaited frame per event-loop turn. A newer request from this viewer
|
|
3767
|
+
// replaces every unsent frame from the older one, so visibility/online races
|
|
3768
|
+
// cannot multiply a cold replay into several concurrent megabyte bursts.
|
|
3769
|
+
replayPump.enqueue(to || '*', frames, async () => {
|
|
3770
|
+
for (const [, s] of sessions) {
|
|
3771
|
+
for (const [, p] of s.pending) await bcastAwait('code-perm-req', p.payload)
|
|
3772
|
+
}
|
|
3773
|
+
announce()
|
|
3774
|
+
})
|
|
3725
3775
|
})
|
|
3726
3776
|
// "Load earlier history": serve an OLDER page from the durable JSONL archive (the
|
|
3727
3777
|
// events the in-memory 2000-cap evicted). beforeSeq anchors the page at the client's
|
|
@@ -3740,11 +3790,12 @@ channel
|
|
|
3740
3790
|
if (!servesHistoryPage({ payloadHost: payload?.host, bridgeName: name, hasSession: sessions.has(id), hasArchive: hasDurableArchive(room, id) })) return
|
|
3741
3791
|
const { events, hasMore } = readDurablePage(room, id, Number(payload?.beforeSeq) || null, 200)
|
|
3742
3792
|
const to = payload?.to ?? null
|
|
3743
|
-
|
|
3793
|
+
const requestId = String(payload?.requestId || randomUUID?.() || `${Date.now()}`)
|
|
3794
|
+
if (!events.length) { bcast('history-page', { to, term: id, requestId, chunkIndex: 0, chunkCount: 1, events: [], hasMore: false }); return }
|
|
3744
3795
|
const chunks = chunkReplayEvents(events.map((e) => boundEventForBroadcast(e)))
|
|
3745
3796
|
// hasMore rides only the FIRST (oldest) chunk so the client sets the floor flag once
|
|
3746
3797
|
// from the true page boundary; later chunks are just more of the same page.
|
|
3747
|
-
chunks.forEach((evs, i) => bcast('history-page', { to, term: id, events: evs, hasMore: i === 0 ? hasMore : true }))
|
|
3798
|
+
chunks.forEach((evs, i) => bcast('history-page', { to, term: id, requestId, chunkIndex: i, chunkCount: chunks.length, events: evs, hasMore: i === 0 ? hasMore : true }))
|
|
3748
3799
|
})
|
|
3749
3800
|
.on('broadcast', { event: 'file-put' }, ({ payload }) => {
|
|
3750
3801
|
if (!payload?.id || !payload?.url) return
|
|
@@ -4020,13 +4071,24 @@ channel
|
|
|
4020
4071
|
const nativeImages = s.runtime === 'codex' || s.runtime === 'hermes'
|
|
4021
4072
|
? await waitForNativeImages(payload.files, { updir: UPDIR })
|
|
4022
4073
|
: []
|
|
4074
|
+
const deferHermesQueueEcho = s.runtime === 'hermes' && s.session.turnActive && /^\s*\/queue\s+/i.test(text)
|
|
4075
|
+
const turnOptions = {
|
|
4076
|
+
...(nativeImages.length ? { images: nativeImages } : {}),
|
|
4077
|
+
...(deferHermesQueueEcho && !payload.silent ? { _thinkpoolQueuedEcho: {
|
|
4078
|
+
text: payload.body != null ? String(payload.body) : text,
|
|
4079
|
+
cid: payload.cid,
|
|
4080
|
+
by: payload.by,
|
|
4081
|
+
...(Array.isArray(payload.files) && payload.files.length ? { files: payload.files } : {}),
|
|
4082
|
+
...(Array.isArray(payload.pastes) && payload.pastes.length ? { pastes: payload.pastes } : {}),
|
|
4083
|
+
} } : {}),
|
|
4084
|
+
}
|
|
4023
4085
|
// Sample the runtime before dispatch so a missed prior falling edge cannot
|
|
4024
4086
|
// make this genuinely new turn inherit the previous turn's revision.
|
|
4025
4087
|
syncStructuredTurn(s)
|
|
4026
|
-
const accepted = s.session.sendTurn(sendText,
|
|
4088
|
+
const accepted = s.session.sendTurn(sendText, Object.keys(turnOptions).length ? turnOptions : undefined)
|
|
4027
4089
|
if (accepted === false) syncStructuredTurn(s)
|
|
4028
4090
|
else beginStructuredTurn(s)
|
|
4029
|
-
echoYou()
|
|
4091
|
+
if (!deferHermesQueueEcho || accepted === false) echoYou()
|
|
4030
4092
|
if (accepted === false) {
|
|
4031
4093
|
// A runtime that did not accept a turn must still close the optimistic
|
|
4032
4094
|
// user-line lifecycle. Without this boundary the client truthfully shows
|
|
@@ -4302,7 +4364,7 @@ channel
|
|
|
4302
4364
|
.subscribe(async status => {
|
|
4303
4365
|
if (status === 'SUBSCRIBED') {
|
|
4304
4366
|
realtimeHealthy = true; brokenSince = 0
|
|
4305
|
-
trackPresence({ name, role: 'bridge', bridge_id: BRIDGE_ID })
|
|
4367
|
+
trackPresence({ name, role: 'bridge', bridge_id: BRIDGE_ID, started_at: BRIDGE_STARTED_AT })
|
|
4306
4368
|
const startCmd = attachedCmd || autoAgent // autoAgent: account-mode auto-open (headless)
|
|
4307
4369
|
if (startCmd && !terms.size && !sessions.size) {
|
|
4308
4370
|
// Claude + structured mode → Agent SDK session; everything else → PTY.
|
|
@@ -4704,7 +4766,7 @@ const seenDesignRequests = new Set()
|
|
|
4704
4766
|
designChannel
|
|
4705
4767
|
.on('broadcast', { event: 'design-capability' }, ({ payload }) => {
|
|
4706
4768
|
const record = payload?.previewId && designArtifacts.get(String(payload.previewId))
|
|
4707
|
-
const live = record &&
|
|
4769
|
+
const live = record && refreshDesignSource(record)
|
|
4708
4770
|
const ok = !!record && !!live && live.revision === record.revision && record.revision === payload?.revision && sessions.has(record.term)
|
|
4709
4771
|
designChannel.send({ type: 'broadcast', event: 'design-capability-res', payload: { previewId: String(payload?.previewId || ''), revision: String(payload?.revision || ''), ok } })
|
|
4710
4772
|
})
|
|
@@ -4759,7 +4821,7 @@ designChannel
|
|
|
4759
4821
|
const requestId = String(payload?.cid || '').slice(0, 80)
|
|
4760
4822
|
const record = designArtifacts.get(previewId)
|
|
4761
4823
|
const restore = designRestore.get(previewId)
|
|
4762
|
-
const live = record &&
|
|
4824
|
+
const live = record && refreshDesignSource(record)
|
|
4763
4825
|
if (!requestId || !record || !restore?.priorRecord?.backupPath || !live || live.revision !== record.revision || payload?.revision !== record.revision) {
|
|
4764
4826
|
designStatus({ previewId, requestId, state: 'stale', message: 'That verified revision can no longer be restored safely.' })
|
|
4765
4827
|
return
|
package/design-edit.mjs
CHANGED
|
@@ -50,15 +50,49 @@ export function resolveDesignSource(file, workspaceRoot) {
|
|
|
50
50
|
return { previewId, revision, sourcePath, workspaceRoot: root, source }
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
export function resolvePreviewDesignSource(manifest, workspaceRoot, box) {
|
|
54
|
+
if (!manifest || manifest.sourceKind !== 'preview' || !manifest.snapshot || !workspaceRoot || !box) return null
|
|
55
|
+
let sourcePath, root, outbox
|
|
56
|
+
try {
|
|
57
|
+
sourcePath = fs.realpathSync(manifest.snapshot)
|
|
58
|
+
root = fs.realpathSync(workspaceRoot)
|
|
59
|
+
outbox = fs.realpathSync(box)
|
|
60
|
+
const stat = fs.statSync(sourcePath)
|
|
61
|
+
if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES) return null
|
|
62
|
+
} catch { return null }
|
|
63
|
+
const rel = path.relative(outbox, sourcePath)
|
|
64
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel) || path.extname(sourcePath).toLowerCase() !== '.html') return null
|
|
65
|
+
const route = clean(manifest.route || '/', 500)
|
|
66
|
+
const previewRoot = clean(manifest.previewRoot || 'dist', 500)
|
|
67
|
+
const captureKey = clean(manifest.captureKey, 1000)
|
|
68
|
+
if (!route.startsWith('/') || !captureKey || captureKey !== JSON.stringify([previewRoot, route])) return null
|
|
69
|
+
const source = fs.readFileSync(sourcePath, 'utf8')
|
|
70
|
+
const revision = sourceRevision(source)
|
|
71
|
+
const previewId = crypto.createHash('sha256')
|
|
72
|
+
.update(`${root}\0${captureKey}\0${revision}`)
|
|
73
|
+
.digest('base64url').slice(0, 32)
|
|
74
|
+
return { sourceKind: 'preview', previewId, revision, sourcePath, workspaceRoot: root, source, outbox, route, previewRoot, captureKey }
|
|
75
|
+
}
|
|
76
|
+
|
|
53
77
|
// Older lane worktrees predate the explicit `source` manifest field and emit the
|
|
54
78
|
// authored mockup path as `html` only. Preserve the explicit field when present;
|
|
55
79
|
// otherwise let the same containment/generated-output checks decide whether the
|
|
56
80
|
// HTML path is canonical editable source or view-only render evidence.
|
|
57
|
-
export function resolveManifestDesignSource(manifest, workspaceRoot) {
|
|
81
|
+
export function resolveManifestDesignSource(manifest, workspaceRoot, { box } = {}) {
|
|
58
82
|
if (!manifest || typeof manifest !== 'object') return null
|
|
83
|
+
if (manifest.sourceKind === 'preview') return resolvePreviewDesignSource(manifest, workspaceRoot, box)
|
|
59
84
|
return resolveDesignSource(manifest.source || manifest.html, workspaceRoot)
|
|
60
85
|
}
|
|
61
86
|
|
|
87
|
+
export function refreshDesignSource(record) {
|
|
88
|
+
if (!record) return null
|
|
89
|
+
if (record.sourceKind !== 'preview') return resolveDesignSource(record.sourcePath, record.workspaceRoot)
|
|
90
|
+
return resolvePreviewDesignSource({
|
|
91
|
+
sourceKind: 'preview', snapshot: record.sourcePath, route: record.route,
|
|
92
|
+
previewRoot: record.previewRoot, captureKey: record.captureKey,
|
|
93
|
+
}, record.workspaceRoot, record.outbox)
|
|
94
|
+
}
|
|
95
|
+
|
|
62
96
|
// Design provenance is intentionally host-only, but it must survive a bridge
|
|
63
97
|
// restart. Rebuild the in-memory registry from manifests already written by the
|
|
64
98
|
// trusted lane render workflow; do not re-upload or re-broadcast old cards.
|
|
@@ -79,7 +113,7 @@ export function restoreDesignSources(box, workspaceRoot, term, limit = 100) {
|
|
|
79
113
|
for (const { file } of files) {
|
|
80
114
|
try {
|
|
81
115
|
const manifest = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
82
|
-
const record = resolveManifestDesignSource(manifest, workspaceRoot)
|
|
116
|
+
const record = resolveManifestDesignSource(manifest, workspaceRoot, { box })
|
|
83
117
|
if (!record) continue
|
|
84
118
|
Object.assign(record, {
|
|
85
119
|
term,
|
|
@@ -256,5 +290,8 @@ export function designPrompt({ record, request, by, restore = false, priorRecord
|
|
|
256
290
|
: batch
|
|
257
291
|
? batch.map((edit, index) => `Edit ${index + 1} of ${batch.length} · ${edit.mode}\nSelected element:\n${JSON.stringify(edit.target, null, 2)}\n\n${designAction(edit)}`).join('\n\n---\n\n')
|
|
258
292
|
: `Selected element:\n${JSON.stringify(request.target, null, 2)}\n\n${designAction(request)}`
|
|
293
|
+
if (record.sourceKind === 'preview') {
|
|
294
|
+
return `A room member used ThinkPool Design Mode on a rendered application preview. This is an authorized source edit in your current workspace.\n\nRendered snapshot (read-only evidence): ${record.sourcePath}\nApplication workspace: ${record.workspaceRoot}\nPreview root: ${record.previewRoot}\nPreview route: ${record.route}\nExpected snapshot revision: ${record.revision}\nRequested by: ${clean(by || 'A room member', 120)}\nOperation: ${batch ? `${batch.length} queued edits in one batch` : request.mode}\n\n${work}\n\nRules:\n- Re-read the rendered snapshot and confirm the selected element still matches before editing.\n- Do not edit the snapshot. Locate the represented element in the application source inside the workspace and make the smallest source change that produces the requested result.\n- If the selector, text, or surrounding structure does not identify one application source location unambiguously, stop without editing and explain that the person must reselect it.\n${batch ? '- Apply the queued edits in order in one source pass, then build and capture once after the full batch.' : '- Apply only the requested change, then build and capture once.'}\n- Preserve the product page faithfully: keep its real copy, structure, fonts, assets, spacing, responsive behavior, and unrelated source unchanged.\n- Run the project build, then use preview_start with root ${record.previewRoot} and preview_capture with path ${record.route}, both viewports, and title ${JSON.stringify(record.title || record.slug || 'Design preview')}. The bridge correlates that capture to this request.\n- Do not claim the edit is verified until the room receives the new desktop and mobile capture.\n- If the build or capture fails, report the failure instead of substituting a screenshot-only result.`
|
|
295
|
+
}
|
|
259
296
|
return `A room member used ThinkPool Design Mode on the artifact below. This is an authorized source edit in your current workspace.\n\nArtifact source: ${record.sourcePath}\nWorkspace: ${record.workspaceRoot}\nExpected source revision: ${record.revision}\nRequested by: ${clean(by || 'A room member', 120)}\nOperation: ${restore ? 'restore the immediately previous verified Design Mode revision' : batch ? `${batch.length} queued edits in one batch` : request.mode}\n\n${work}\n\nRules:\n- Re-read the exact artifact source and confirm its revision/content still matches every selected element before editing.\n${batch ? '- Apply the queued edits in order in one source pass, then render once after the full batch.' : '- Apply only the requested change, then render the result once.'}\n- Edit the canonical source above; do not search for or edit a different plausible file.\n- Preserve the represented product page faithfully: keep its real copy, structure, fonts, assets, spacing, and responsive behavior except for the selected edits. Never replace it with generic mockup content.\n- Preserve unrelated content and styling.\n- Use the existing mockup render workflow to produce both desktop and mobile captures after the edit. Run it with TP_DESIGN_REQUEST_ID=${request.cid} and TP_DESIGN_PARENT_REVISION=${record.revision} in the command environment so the proof correlates to this request.\n- Do not claim this is live until both captures succeed and the room receives the manifest.\n- If any target is stale or ambiguous, stop without editing and explain that the person must reselect it.`
|
|
260
297
|
}
|
package/hermes-session.mjs
CHANGED
|
@@ -68,7 +68,7 @@ export function startHermesSession({
|
|
|
68
68
|
command = HERMES_COMMAND, args = ['acp'], clientFactory = createAcpClient,
|
|
69
69
|
mcpHttpFactory = startCodexMcpHttp, lazy = false, hermesRole = null,
|
|
70
70
|
crossPostGate = null, didSpawnTarget = null, crossRoomPostGate = null, effort = 'high',
|
|
71
|
-
admitStart = null,
|
|
71
|
+
admitStart = null, onTurnStart = null,
|
|
72
72
|
} = {}) {
|
|
73
73
|
let activeCwd = cwd
|
|
74
74
|
const requestedModel = model || null
|
|
@@ -503,6 +503,7 @@ export function startHermesSession({
|
|
|
503
503
|
const next = queuedTurns.shift()
|
|
504
504
|
const turnId = ++activeTurnId
|
|
505
505
|
turnActive = true
|
|
506
|
+
try { onTurnStart?.(next.options || {}) } catch { /* observer cannot break the FIFO */ }
|
|
506
507
|
try { await runPrompt(next.text, next.options, { steering: false, turnId, promptIndex: next.promptIndex, forceFull: next.forceFull }) }
|
|
507
508
|
catch (error) { turnActive = false; emit({ kind: 'error', message: `Hermes queued turn failed: ${error?.message || error}`, recoverable: true }) }
|
|
508
509
|
}
|
|
@@ -514,6 +515,7 @@ export function startHermesSession({
|
|
|
514
515
|
return {
|
|
515
516
|
get sessionId() { return sessionId },
|
|
516
517
|
get turnActive() { return turnActive },
|
|
518
|
+
get queuedDepth() { return queuedTurns.length },
|
|
517
519
|
get canSteer() { return !!client?.alive },
|
|
518
520
|
get started() { return started },
|
|
519
521
|
get models() { return [] },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.291",
|
|
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": {
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"pair-control-authority.mjs",
|
|
50
50
|
"event-id.mjs",
|
|
51
51
|
"event-bounds.mjs",
|
|
52
|
+
"replay-transport.mjs",
|
|
52
53
|
"plan-meters.mjs",
|
|
53
54
|
"recap.mjs",
|
|
54
55
|
"transcript-sanitize.mjs",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Replay transport policy. Reconnects are allowed to supersede older work for
|
|
2
|
+
// the same viewer, and frames are deliberately paced so live events and Stop /
|
|
3
|
+
// permission traffic are not trapped behind a multi-megabyte replay burst.
|
|
4
|
+
|
|
5
|
+
export function requestedReplayIds(entries, terms, priority) {
|
|
6
|
+
const requested = Array.isArray(terms) ? new Set(terms.filter(Boolean)) : null
|
|
7
|
+
return [...entries]
|
|
8
|
+
.filter(([id]) => !requested || requested.has(id))
|
|
9
|
+
.sort(([a], [b]) => a === priority ? -1 : b === priority ? 1 : 0)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const defaultYield = () => new Promise((resolve) => setTimeout(resolve, 0))
|
|
13
|
+
|
|
14
|
+
export function createLatestReplayPump({ send, yieldTurn = defaultYield } = {}) {
|
|
15
|
+
if (typeof send !== 'function') throw new TypeError('replay pump requires send(frame)')
|
|
16
|
+
const viewers = new Map()
|
|
17
|
+
|
|
18
|
+
const run = async (viewer, state) => {
|
|
19
|
+
if (state.running) return
|
|
20
|
+
state.running = true
|
|
21
|
+
try {
|
|
22
|
+
while (state.frames.length) {
|
|
23
|
+
const generation = state.generation
|
|
24
|
+
const frame = state.frames.shift()
|
|
25
|
+
await send(frame)
|
|
26
|
+
await yieldTurn()
|
|
27
|
+
// enqueue() replaces the remaining frames. The send already in flight is
|
|
28
|
+
// allowed to finish; nothing older can start after the replacement.
|
|
29
|
+
if (generation !== state.generation) continue
|
|
30
|
+
}
|
|
31
|
+
const done = state.onDone
|
|
32
|
+
state.onDone = null
|
|
33
|
+
if (done) await done()
|
|
34
|
+
} finally {
|
|
35
|
+
state.running = false
|
|
36
|
+
if (state.frames.length) void run(viewer, state)
|
|
37
|
+
else if (viewers.get(viewer) === state) viewers.delete(viewer)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
enqueue(viewer, frames, onDone = null) {
|
|
43
|
+
const key = String(viewer || '*')
|
|
44
|
+
const state = viewers.get(key) || { generation: 0, frames: [], onDone: null, running: false }
|
|
45
|
+
state.generation += 1
|
|
46
|
+
state.frames = Array.isArray(frames) ? [...frames] : []
|
|
47
|
+
state.onDone = typeof onDone === 'function' ? onDone : null
|
|
48
|
+
viewers.set(key, state)
|
|
49
|
+
void run(key, state)
|
|
50
|
+
return state.generation
|
|
51
|
+
},
|
|
52
|
+
cancel(viewer) {
|
|
53
|
+
const state = viewers.get(String(viewer || '*'))
|
|
54
|
+
if (!state) return false
|
|
55
|
+
state.generation += 1
|
|
56
|
+
state.frames = []
|
|
57
|
+
state.onDone = null
|
|
58
|
+
return true
|
|
59
|
+
},
|
|
60
|
+
pending(viewer) {
|
|
61
|
+
return viewers.get(String(viewer || '*'))?.frames.length || 0
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"bundleVersion":
|
|
3
|
+
"bundleVersion": 9,
|
|
4
4
|
"contracts": [
|
|
5
5
|
{
|
|
6
6
|
"id": "room-coordination",
|
|
@@ -168,9 +168,9 @@
|
|
|
168
168
|
},
|
|
169
169
|
{
|
|
170
170
|
"id": "design-workspace",
|
|
171
|
-
"version":
|
|
172
|
-
"interactionPrompt": "DESIGN EDITING MODEL:
|
|
173
|
-
"turnReminder": "DESIGN ROUTE: authored HTML must produce
|
|
171
|
+
"version": 4,
|
|
172
|
+
"interactionPrompt": "DESIGN EDITING MODEL: every bridge preview_capture card and every trusted source-backed mockup card offers Edit in Design. The bridge freezes a safe rendered-DOM snapshot for application previews; Design edits are then applied by the producing lane to the real application source and verified by a fresh correlated desktop+mobile capture. For authored HTML, the producing lane edits the canonical HTML source directly. Edit in Design—not ordinary Preview or unrelated artifact delivery—arms a persistent room-level Design workspace, opens the artifact directly in editable mode, and adds Design · Page name beneath the producing terminal in its Ensemble row for both partners; it creates no terminal, agent runtime, or worker slot. The virtual lane can be closed from that Ensemble row without deleting the artifact. Once armed, the Design lane reopens the active artifact at its latest verified revision without another agent turn, history search, HTML/path request, or re-selection. Design is the default canvas there and Preview is only a temporary interaction mode. Direct text, supported movement, and selected-image changes auto-stage as optimistic drafts in the bounded changes queue; queued edits can be reopened, revised, or removed before Apply. Apply changes sends the ordered batch once to the producing lane, and a successful correlated desktop+mobile render advances the verified revision for the room. Never call a staged draft saved or live. For direction rounds, deliver every option as its own Design card; a multi-option gallery may accompany the cards for comparison but must never be the only editable artifact.",
|
|
173
|
+
"turnReminder": "DESIGN ROUTE: preview_capture and authored HTML must produce editable Thinkpool Design cards with verified desktop and mobile renders. Edit in Design explicitly arms the persistent Design workspace and adds its virtual Design · Page beneath the producing terminal in the Ensemble row; Preview alone does not arm it. For direction rounds, deliver every option as a separate Design card; gallery navigation is not a substitute for separately editable artifacts. Do not duplicate the card renders as inline PNGs. Use inline PNG evidence only when no interactive Design artifact is available.",
|
|
174
174
|
"impact": [
|
|
175
175
|
{"path": "src/pages/code/design/"},
|
|
176
176
|
{"path": "src/pages/code/structured.jsx", "diffPattern": "Edit in Design|openMockup|tp-mockup-view|sourceKnown"},
|
package/viewport.mjs
CHANGED
|
@@ -26,6 +26,7 @@ export const DEFAULT_VIEWPORTS = Object.freeze({
|
|
|
26
26
|
const MAX_CAPTURE_HEIGHT = 20000
|
|
27
27
|
const MAX_SETTLE_MS = 5000
|
|
28
28
|
const CDP_TIMEOUT_MS = 12000
|
|
29
|
+
const MAX_PORTABLE_SNAPSHOT_BYTES = 1_900_000
|
|
29
30
|
|
|
30
31
|
const isInside = (parent, child) => {
|
|
31
32
|
const rel = path.relative(parent, child)
|
|
@@ -267,6 +268,79 @@ export class CdpBrowser {
|
|
|
267
268
|
})
|
|
268
269
|
}
|
|
269
270
|
|
|
271
|
+
async snapshot({ url, viewport = DEFAULT_VIEWPORTS.desktop, waitMs = 300 }) {
|
|
272
|
+
return this.withPage({ url, viewport, waitMs }, async ({ pipe, sessionId }) => {
|
|
273
|
+
// Freeze the rendered DOM, not the build entry document. A Vite/React entry
|
|
274
|
+
// depends on root-relative chunks and often boots to an empty shell in a
|
|
275
|
+
// sandboxed srcDoc; the settled DOM plus same-origin CSS/assets is portable.
|
|
276
|
+
const expression = `(async () => {
|
|
277
|
+
const clone = document.documentElement.cloneNode(true);
|
|
278
|
+
clone.querySelectorAll('script,link[rel="modulepreload"],link[rel="preload"]').forEach((node) => node.remove());
|
|
279
|
+
clone.querySelectorAll('*').forEach((node) => {
|
|
280
|
+
for (const attr of [...node.attributes]) if (/^on/i.test(attr.name)) node.removeAttribute(attr.name);
|
|
281
|
+
if (node instanceof HTMLInputElement) node.removeAttribute('value');
|
|
282
|
+
if (node instanceof HTMLTextAreaElement) node.textContent = '';
|
|
283
|
+
});
|
|
284
|
+
const css = [];
|
|
285
|
+
for (const sheet of [...document.styleSheets]) {
|
|
286
|
+
try { css.push([...sheet.cssRules].map((rule) => rule.cssText).join('\\n')); } catch {}
|
|
287
|
+
}
|
|
288
|
+
clone.querySelectorAll('style,link[rel="stylesheet"]').forEach((node) => node.remove());
|
|
289
|
+
const head = clone.querySelector('head') || clone.insertBefore(document.createElement('head'), clone.firstChild);
|
|
290
|
+
const style = document.createElement('style');
|
|
291
|
+
style.setAttribute('data-thinkpool-snapshot', '');
|
|
292
|
+
style.textContent = css.join('\\n');
|
|
293
|
+
head.appendChild(style);
|
|
294
|
+
|
|
295
|
+
const assetUrls = new Set();
|
|
296
|
+
const remember = (raw) => {
|
|
297
|
+
if (!raw || /^(data:|blob:|#)/i.test(raw)) return;
|
|
298
|
+
try {
|
|
299
|
+
const absolute = new URL(raw, location.href);
|
|
300
|
+
if (absolute.origin === location.origin) assetUrls.add(absolute.href);
|
|
301
|
+
} catch {}
|
|
302
|
+
};
|
|
303
|
+
clone.querySelectorAll('[src],[poster]').forEach((node) => {
|
|
304
|
+
remember(node.getAttribute('src'));
|
|
305
|
+
remember(node.getAttribute('poster'));
|
|
306
|
+
});
|
|
307
|
+
for (const match of style.textContent.matchAll(/url\\(\\s*(['"]?)([^'"\\)]+)\\1\\s*\\)/gi)) remember(match[2]);
|
|
308
|
+
|
|
309
|
+
const replacements = new Map();
|
|
310
|
+
let inlinedBytes = 0;
|
|
311
|
+
for (const absolute of assetUrls) {
|
|
312
|
+
try {
|
|
313
|
+
const response = await fetch(absolute);
|
|
314
|
+
const buffer = await response.arrayBuffer();
|
|
315
|
+
if (!response.ok || buffer.byteLength > 512000 || inlinedBytes + buffer.byteLength > 1000000) continue;
|
|
316
|
+
const bytes = new Uint8Array(buffer);
|
|
317
|
+
let binary = '';
|
|
318
|
+
for (let offset = 0; offset < bytes.length; offset += 0x8000) binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
|
319
|
+
replacements.set(absolute, 'data:' + (response.headers.get('content-type') || 'application/octet-stream') + ';base64,' + btoa(binary));
|
|
320
|
+
inlinedBytes += buffer.byteLength;
|
|
321
|
+
} catch {}
|
|
322
|
+
}
|
|
323
|
+
const replaceAsset = (raw) => {
|
|
324
|
+
try { return replacements.get(new URL(raw, location.href).href) || raw; } catch { return raw; }
|
|
325
|
+
};
|
|
326
|
+
clone.querySelectorAll('[src],[poster]').forEach((node) => {
|
|
327
|
+
for (const name of ['src', 'poster']) if (node.hasAttribute(name)) node.setAttribute(name, replaceAsset(node.getAttribute(name)));
|
|
328
|
+
node.removeAttribute('srcset');
|
|
329
|
+
});
|
|
330
|
+
style.textContent = style.textContent.replace(/url\\(\\s*(['"]?)([^'"\\)]+)\\1\\s*\\)/gi, (_all, quote, raw) => 'url("' + replaceAsset(raw) + '")');
|
|
331
|
+
return '<!doctype html>\\n' + clone.outerHTML;
|
|
332
|
+
})()`
|
|
333
|
+
const result = await pipe.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, sessionId)
|
|
334
|
+
if (result.exceptionDetails || typeof result.result?.value !== 'string') {
|
|
335
|
+
const detail = result.exceptionDetails?.exception?.description || result.exceptionDetails?.text || result.result?.description
|
|
336
|
+
throw new Error(`Could not freeze the rendered preview for Design${detail ? `: ${detail}` : '.'}`)
|
|
337
|
+
}
|
|
338
|
+
const html = result.result.value
|
|
339
|
+
if (!html.trim() || Buffer.byteLength(html) > MAX_PORTABLE_SNAPSHOT_BYTES) throw new Error('The rendered preview is too large to make editable.')
|
|
340
|
+
return html
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
|
|
270
344
|
async inspect({ url, viewport = DEFAULT_VIEWPORTS.mobile, selector, waitMs = 300 }) {
|
|
271
345
|
return this.withPage({ url, viewport, waitMs }, async ({ pipe, sessionId }) => {
|
|
272
346
|
const expression = `(() => {
|
|
@@ -302,12 +376,13 @@ export class CdpBrowser {
|
|
|
302
376
|
export const sharedViewportBrowser = new CdpBrowser()
|
|
303
377
|
|
|
304
378
|
export class ViewportManager {
|
|
305
|
-
constructor({ workspaceRoot, ownerId, outbox, browser = sharedViewportBrowser, startPreviewImpl = startPreview } = {}) {
|
|
379
|
+
constructor({ workspaceRoot, ownerId, outbox, browser = sharedViewportBrowser, startPreviewImpl = startPreview, designContext = null } = {}) {
|
|
306
380
|
this.workspaceRoot = path.resolve(workspaceRoot || process.cwd())
|
|
307
381
|
this.ownerId = ownerId || randomUUID()
|
|
308
382
|
this.outbox = outbox || path.join(os.tmpdir(), 'thinkpool-viewport-captures', this.ownerId)
|
|
309
383
|
this.browser = browser
|
|
310
384
|
this.startPreviewImpl = startPreviewImpl
|
|
385
|
+
this.designContext = typeof designContext === 'function' ? designContext : () => null
|
|
311
386
|
this.previewId = `viewport:${this.ownerId}`
|
|
312
387
|
this.preview = null
|
|
313
388
|
this.root = null
|
|
@@ -344,7 +419,8 @@ export class ViewportManager {
|
|
|
344
419
|
}
|
|
345
420
|
|
|
346
421
|
async capture({ route = '/', viewports = 'both', title = 'Viewport capture', fullPage = true, waitMs = 300 } = {}) {
|
|
347
|
-
const
|
|
422
|
+
const normalizedRoute = normalizeRoute(route)
|
|
423
|
+
const url = this.pageUrl(normalizedRoute)
|
|
348
424
|
const names = viewports === 'both' ? ['desktop', 'mobile'] : [viewports]
|
|
349
425
|
if (names.some((name) => !DEFAULT_VIEWPORTS[name])) throw new Error('viewports must be "both", "desktop", or "mobile".')
|
|
350
426
|
await fsp.mkdir(this.outbox, { recursive: true })
|
|
@@ -356,20 +432,28 @@ export class ViewportManager {
|
|
|
356
432
|
await fsp.writeFile(file, result.png)
|
|
357
433
|
captures[name] = { ...result, file }
|
|
358
434
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
435
|
+
const snapshotViewport = names.includes('desktop') ? DEFAULT_VIEWPORTS.desktop : DEFAULT_VIEWPORTS[names[0]]
|
|
436
|
+
const snapshot = await this.browser.snapshot({ url, viewport: snapshotViewport, waitMs })
|
|
437
|
+
const snapshotPath = path.join(this.outbox, `${slug}--source.html`)
|
|
438
|
+
await fsp.writeFile(snapshotPath, snapshot)
|
|
439
|
+
const workspace = await fsp.realpath(this.workspaceRoot)
|
|
440
|
+
const previewRoot = path.relative(workspace, this.root) || '.'
|
|
441
|
+
const captureKey = JSON.stringify([previewRoot, normalizedRoute])
|
|
442
|
+
const active = this.designContext() || null
|
|
443
|
+
const correlation = active?.captureKey === captureKey
|
|
444
|
+
? { designRequestId: active.requestId, parentRevision: active.parentRevision }
|
|
445
|
+
: {}
|
|
364
446
|
const manifest = {
|
|
365
447
|
slug, title: String(title || 'Viewport capture').slice(0, 120),
|
|
366
448
|
desktop: captures.desktop?.file || '', mobile: captures.mobile?.file || '', ts: Date.now(),
|
|
449
|
+
sourceKind: 'preview', snapshot: snapshotPath, previewRoot, route: normalizedRoute, captureKey,
|
|
450
|
+
...correlation,
|
|
367
451
|
}
|
|
368
452
|
const manifestPath = path.join(this.outbox, `${slug}.json`)
|
|
369
453
|
const tmp = `${manifestPath}.tmp`
|
|
370
454
|
await fsp.writeFile(tmp, JSON.stringify(manifest))
|
|
371
455
|
await fsp.rename(tmp, manifestPath)
|
|
372
|
-
return { url, slug, manifestPath, captures }
|
|
456
|
+
return { url, slug, manifestPath, captures, snapshotPath }
|
|
373
457
|
}
|
|
374
458
|
|
|
375
459
|
inspect({ route = '/', viewport = 'mobile', selector, waitMs = 300 } = {}) {
|