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 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, resolveDesignSource, resolveManifestDesignSource, restoreDesignSources, syncRealtimeAuth, validateDesignBatchRequest } from './design-edit.mjs'
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 bcast = (event, payload, ch = channel) => {
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).catch(() => { /* offline — replay covers it */ })
1083
+ return await ch.httpSend(event, payload)
1075
1084
  }
1076
- } catch { /* noop */ }
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: true })
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 && resolveDesignSource(current.sourcePath, current.workspaceRoot)
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
- if (correlated && dual && designRecord.sourcePath === active.record.sourcePath && revisionProvesChange) {
1546
- designRestore.set(designRecord.previewId, { priorRecord: active.record, request: active.request })
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({ workspaceRoot: cwd || process.cwd(), ownerId: id, outbox: mockupOutbox })
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 busyChanged = syncStructuredTurn(entry)
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
- for (const [id, t] of terms) {
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
- bcast('pty-replay', {
3649
- to: payload?.to ?? null, term: id,
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 = [...sessions.entries()].sort(([a], [b]) =>
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
- bcast('code-replay', { to: payload?.to ?? null, term: id, events: [], empty: true })
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 firstChunk = true
3710
- for (const events of chunks) {
3711
- bcast('code-replay', { to: payload?.to ?? null, term: id, events, ...(ahead ? { reset: true } : {}), ...(firstChunk && trimmedBefore != null ? { trimmedBefore } : {}), ...(firstChunk && s.lastUsage?.ctx ? { lastUsage: s.lastUsage } : {}) })
3712
- firstChunk = false
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
- for (const [, s] of sessions) {
3722
- for (const [, p] of s.pending) bcast('code-perm-req', p.payload)
3723
- }
3724
- announce()
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
- if (!events.length) { bcast('history-page', { to, term: id, events: [], hasMore: false }); return }
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, nativeImages.length ? { images: nativeImages } : undefined)
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 && resolveDesignSource(record.sourcePath, record.workspaceRoot)
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 && resolveDesignSource(record.sourcePath, record.workspaceRoot)
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
  }
@@ -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.289",
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": 8,
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": 3,
172
- "interactionPrompt": "DESIGN EDITING MODEL: a trusted source-backed mockup card offers Edit in Design. That explicit action—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, that lane edits the canonical authored HTML, and a successful desktop+mobile render advances the verified revision for the room. Never call a staged draft saved or live. A preview_capture card is visual evidence, not an editable Design artifact. For direction rounds, deliver every option as its own source-backed Design card; a multi-option gallery may accompany them for comparison but must never be the only editable artifact.",
173
- "turnReminder": "DESIGN ROUTE: authored HTML must produce a source-backed Thinkpool Design card 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 source-backed 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.",
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 url = this.pageUrl(route)
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
- // A preview root may be a multi-file build. Its index.html is not a portable
360
- // artifact: root-relative chunks cannot reliably boot when that text is later
361
- // injected into a sandboxed srcDoc (Windows Edge exposed the permanent loader).
362
- // preview_capture is therefore screenshot-only evidence. Editable mockups use
363
- // render.sh's explicit trusted `source` contract instead.
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 } = {}) {