thinkpool-pair 0.7.306 → 0.7.307

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,7 +81,8 @@ their own sandbox cannot bind localhost or launch Chrome:
81
81
  - `preview_start` serves a built directory inside that lane's workspace
82
82
  (`dist` by default; it must contain `index.html`).
83
83
  - `preview_capture` returns exact desktop (1440×900) and mobile (390×844)
84
- screenshots to the agent and surfaces them in the room.
84
+ screenshots to the agent. A complete pair is queued as a room card after the
85
+ agent's final response; a single-viewport capture remains tool evidence only.
85
86
  - `preview_inspect` returns rendered DOM text, document size, and optional
86
87
  selector geometry at either viewport.
87
88
  - `preview_stop` releases the preview port.
package/bridge.mjs CHANGED
@@ -77,7 +77,7 @@ import { writeLaneArtifact, digestSlice, appendDigest, resumeLane } from './flow
77
77
  import { createFlowWorktree, worktreeSpec } from './flow-worktree.mjs'
78
78
  import { startPreview, stopAllPreviews, previews } from './flow-preview.mjs'
79
79
  import { ViewportManager, createViewportTools, sharedViewportBrowser } from './viewport.mjs'
80
- import { deleteDesignAssetDraft, designPrompt, designTranscript, materializeDesignAsset, refreshDesignSource, resolveManifestDesignSource, restoreDesignSources, syncRealtimeAuth, validateDesignBatchRequest } from './design-edit.mjs'
80
+ import { deleteDesignAssetDraft, designPrompt, designTranscript, materializeDesignAsset, refreshDesignSource, resolveManifestDesignSource, resolveManifestDisplaySource, restoreDesignSources, syncRealtimeAuth, validateDesignBatchRequest } from './design-edit.mjs'
81
81
  // FL-M9 — per-lane preview servers leak (one per done lane, never stopped until shutdown).
82
82
  // Lane previews are keyed `lane:<flowId>:<laneId>`; stop a whole flow's set when it assembles
83
83
  // (the assembled preview supersedes them) or when a lane is reverted.
@@ -128,6 +128,7 @@ import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantText
128
128
  import { planMeterLine } from './plan-meters.mjs'
129
129
  import { priceForModel } from './model-prices.mjs'
130
130
  import { makeThrottledTrack } from './presence.mjs'
131
+ import { MockupDeliveryQueue, completeMockupManifest, isMockupDeliveryBoundary } from './mockup-delivery.mjs'
131
132
  import { resolveAnonKey, DEFAULT_SUPABASE_URL } from './supabase-key.mjs'
132
133
  import { buildTerminalRolePrompt, HERMES_VISIBLE_WORKER_FALLBACK_RULE, THINKPOOL_PROMPT_BUNDLE } from './thinkpool-room-prompt.mjs'
133
134
 
@@ -1549,37 +1550,49 @@ function pumpDesign(term) {
1549
1550
  // landed in *is* its provenance. (Before: a per-room outbox + a "first session
1550
1551
  // in the Map" guess meant closing the generating terminal moved the cards to
1551
1552
  // whatever terminal was now first — they leaked into Terminal 1.)
1552
- const handleManifest = async (box, file, term, trustedDesignSource = false) => {
1553
+ const writeMockupReceipt = (box, slug, ok, status) => {
1554
+ try {
1555
+ const rc = path.join(box, `${slug}.receipt.json`)
1556
+ const tmp = `${rc}.tmp`
1557
+ fs.writeFileSync(tmp, JSON.stringify({ ok, status, ts: Date.now() }))
1558
+ fs.renameSync(tmp, rc)
1559
+ } catch { /* receipts are best-effort */ }
1560
+ }
1561
+
1562
+ const prepareManifest = (box, file, term, trustedDesignSource = false) => {
1553
1563
  // Ignore our own delivery receipts — writing <slug>.receipt.json into the
1554
1564
  // watched outbox re-fires fs.watch; without this guard it would re-trigger
1555
1565
  // handleManifest in a loop (it still ends in .json). Must come first.
1556
- if (!file || file.endsWith('.receipt.json') || !file.endsWith('.json')) return
1566
+ if (!file || file.endsWith('.receipt.json') || !file.endsWith('.json')) return null
1557
1567
  const full = path.join(box, file)
1558
1568
  let stat
1559
- try { stat = fs.statSync(full) } catch { return } // tmp/removed mid-write
1569
+ try { stat = fs.statSync(full) } catch { return null } // tmp/removed mid-write
1560
1570
  const slug = file.replace(/\.json$/, '')
1561
- // Delivery receipt (atomic tmp+mv) so render.sh can confirm the card actually
1562
- // reached the room — written on success AND failure. Best-effort: a receipt
1563
- // that can't be written just leaves render.sh at "delivery unconfirmed".
1564
- const writeReceipt = (ok, status) => {
1565
- try {
1566
- const rc = path.join(box, `${slug}.receipt.json`)
1567
- const tmp = `${rc}.tmp`
1568
- fs.writeFileSync(tmp, JSON.stringify({ ok, status, ts: Date.now() }))
1569
- fs.renameSync(tmp, rc)
1570
- } catch { /* receipts are best-effort */ }
1571
- }
1572
1571
  const seenKey = `${term}/${slug}`
1573
- if (mockupSeen.get(seenKey) === stat.mtimeMs) return // already handled
1572
+ if (mockupSeen.get(seenKey) === stat.mtimeMs) return null // already handled
1574
1573
  mockupSeen.set(seenKey, stat.mtimeMs)
1575
- if (!term) return // nothing to attach the card to yet
1574
+ if (!term) return null // nothing to attach the card to yet
1576
1575
  let m
1577
- try { m = JSON.parse(fs.readFileSync(full, 'utf8')) } catch { return }
1578
- if (!m?.slug) return
1576
+ try { m = JSON.parse(fs.readFileSync(full, 'utf8')) } catch { return null }
1577
+ if (!m?.slug) return null
1578
+ if (!completeMockupManifest(m, { isReadyFile: (asset) => {
1579
+ try { const ready = fs.statSync(asset); return ready.isFile() && ready.size > 0 } catch { return false }
1580
+ } })) {
1581
+ writeMockupReceipt(box, slug, false, 'incomplete')
1582
+ process.stderr.write(`\n ◇ mockup "${m.title || m.slug}" is incomplete — desktop, mobile, and source are all required.\n`)
1583
+ return null
1584
+ }
1585
+ return { box, file, term, trustedDesignSource, slug, m }
1586
+ }
1587
+
1588
+ const handleManifest = async ({ box, term, trustedDesignSource = false, slug, m }) => {
1579
1589
  const producer = trustedDesignSource ? sessions.get(term) : null
1580
1590
  const designRecord = producer
1581
1591
  ? resolveManifestDesignSource(m, producer.cwd || process.cwd(), { box })
1582
1592
  : null
1593
+ const displaySource = designRecord
1594
+ ? resolveManifestDisplaySource(m, designRecord, { box })
1595
+ : null
1583
1596
  // 2026-07-07: these used to swallow read errors silently — a transient
1584
1597
  // unreadable file (race with the render script, permissions, mid-write)
1585
1598
  // meant the manifest still POSTed with that field missing, and nothing
@@ -1594,6 +1607,15 @@ const handleManifest = async (box, file, term, trustedDesignSource = false) => {
1594
1607
  catch (e) { if (p) process.stderr.write(`\n ◇ mockup asset unreadable: ${p} (${e?.code || e?.message || e})\n`); return null }
1595
1608
  }
1596
1609
  const cid = randomUUID()
1610
+ // The card belongs after the final agent response, not at render time. This
1611
+ // timestamp is also persisted by /api/code-mockup, so reload ordering matches
1612
+ // the live transcript instead of jumping the card back above the answer.
1613
+ const deliveryTs = Date.now()
1614
+ if (designRecord && !displaySource) {
1615
+ writeMockupReceipt(box, slug, false, 'invalid-snapshot')
1616
+ process.stderr.write(`\n ◇ mockup "${m.slug}" rendered snapshot was not trusted.\n`)
1617
+ return
1618
+ }
1597
1619
  try {
1598
1620
  const mockupHeaders = { 'Content-Type': 'application/json' }
1599
1621
  // Authenticate as the owner so the server can attribute the persisted row to a
@@ -1604,19 +1626,19 @@ const handleManifest = async (box, file, term, trustedDesignSource = false) => {
1604
1626
  method: 'POST',
1605
1627
  headers: mockupHeaders,
1606
1628
  body: JSON.stringify({
1607
- code: room, slug: m.slug, title: m.title, cid, ts: m.ts, term,
1608
- desktopPng: readB64(m.desktop), mobilePng: readB64(m.mobile), html: designRecord ? designRecord.source : readTxt(m.html),
1629
+ code: room, slug: m.slug, title: m.title, cid, ts: deliveryTs, term,
1630
+ desktopPng: readB64(m.desktop), mobilePng: readB64(m.mobile), html: designRecord ? displaySource : readTxt(m.html),
1609
1631
  ...(designRecord ? { previewId: designRecord.previewId, revision: designRecord.revision, sourceKnown: true } : {}),
1610
1632
  }),
1611
1633
  })
1612
1634
  if (!res.ok) {
1613
- writeReceipt(false, res.status)
1635
+ writeMockupReceipt(box, slug, false, res.status)
1614
1636
  process.stderr.write(`\n ◇ mockup "${m.slug}" upload failed (${res.status}).\n`); return
1615
1637
  }
1616
1638
  const uploaded = await res.json()
1617
1639
  const { paths } = uploaded
1618
1640
  const designAccepted = !!(designRecord && uploaded?.payload?.sourceKnown)
1619
- const artifact = { kind: 'mockup', __struct: true, cid, term, slug: m.slug, title: m.title || m.slug, ts: m.ts, paths,
1641
+ const artifact = { kind: 'mockup', __struct: true, cid, term, slug: m.slug, title: m.title || m.slug, ts: deliveryTs, paths,
1620
1642
  ...(designAccepted ? { previewId: designRecord.previewId, revision: designRecord.revision, sourceKnown: true } : {}) }
1621
1643
  if (designAccepted) {
1622
1644
  Object.assign(designRecord, { term, slug: m.slug, title: m.title || m.slug, desktop: m.desktop, mobile: m.mobile })
@@ -1642,19 +1664,35 @@ const handleManifest = async (box, file, term, trustedDesignSource = false) => {
1642
1664
  }
1643
1665
  }
1644
1666
  bcast('code-event', { term, evt: artifact })
1645
- writeReceipt(true, 200)
1667
+ writeMockupReceipt(box, slug, true, 200)
1646
1668
  process.stderr.write(`\n ◆ mockup "${m.title || m.slug}" pushed to the room.\n`)
1647
1669
  } catch (e) {
1648
- writeReceipt(false, 'error')
1670
+ writeMockupReceipt(box, slug, false, 'error')
1649
1671
  process.stderr.write(`\n ◇ mockup "${m.slug}" send failed: ${e?.message || e}\n`)
1650
1672
  }
1651
1673
  }
1674
+
1675
+ const mockupDeliveries = new MockupDeliveryQueue({ deliver: handleManifest })
1676
+
1677
+ function receiveManifest(box, file, term, trustedDesignSource = false) {
1678
+ const item = prepareManifest(box, file, term, trustedDesignSource)
1679
+ if (!item) return
1680
+ const lane = sessions.get(term)
1681
+ const active = !!lane && (lane._busyAnn === true || lane.session?.turnActive === true)
1682
+ const accepted = mockupDeliveries.enqueue(term, item, { active })
1683
+ if (accepted.queued) {
1684
+ writeMockupReceipt(box, item.slug, true, 'queued')
1685
+ process.stderr.write(`\n ◆ mockup "${item.m.title || item.m.slug}" ready — queued after the final response.\n`)
1686
+ } else {
1687
+ accepted.delivery.catch(() => {})
1688
+ }
1689
+ }
1652
1690
  // Watch one outbox dir; `ownerFn()` resolves which terminal owns a manifest
1653
1691
  // dropped there (lazily, per event). Returns the FSWatcher so per-session/term
1654
1692
  // watchers can be torn down when their owner ends. Safe to call repeatedly.
1655
1693
  function watchOutbox(box, ownerFn, trustedDesignSource = false) {
1656
1694
  try { fs.mkdirSync(box, { recursive: true }) } catch { return null }
1657
- try { return fs.watch(box, (_evt, file) => { handleManifest(box, file, ownerFn(), trustedDesignSource).catch(() => {}) }) }
1695
+ try { return fs.watch(box, (_evt, file) => receiveManifest(box, file, ownerFn(), trustedDesignSource)) }
1658
1696
  catch { return null } // fs.watch unsupported — mockups simply won't auto-surface
1659
1697
  }
1660
1698
  // A per-owner outbox lives at MOCKUP_OUTBOX/<kind>/<id> and is handed to that
@@ -3053,6 +3091,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3053
3091
  // after the final queued boundary.
3054
3092
  const terminalBoundary = !continuesQueued && evt.kind === 'result'
3055
3093
  const busyChanged = terminalBoundary ? settleLaneBusy(entry) : syncStructuredTurn(entry)
3094
+ const mockupDeliveryBoundary = isMockupDeliveryBoundary(evt, { turnActive: entry.session?.turnActive === true })
3056
3095
  stampStructuredTurn(entry, evt)
3057
3096
  stampEvent(evt)
3058
3097
  const stalledChanged = evt.kind === 'stalled' ? !entry.stalled : !!entry.stalled
@@ -3306,6 +3345,12 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3306
3345
  bcast('code-event', { term: id, evt: e })
3307
3346
  printLocal(e)
3308
3347
  if (!chrome) persist()
3348
+ // Result/error has now crossed the same ordered emission boundary as the
3349
+ // final assistant text (including the image queue). Only now may ready
3350
+ // mockups be uploaded, persisted, and broadcast below that answer.
3351
+ if (mockupDeliveryBoundary) {
3352
+ queueMicrotask(() => { mockupDeliveries.flush(id).catch(() => {}) })
3353
+ }
3309
3354
  }
3310
3355
  // A tool_result carrying an inline base64 image can't ride a broadcast frame
3311
3356
  // (live OR replay). Lift it to Storage FIRST, then emit the URL-only event —
@@ -3542,6 +3587,7 @@ function endStructured(id) {
3542
3587
  const s = sessions.get(id)
3543
3588
  if (s) {
3544
3589
  s.imageQueue?.close()
3590
+ mockupDeliveries.clear(id)
3545
3591
  drainPending(s)
3546
3592
  try { s.session?.end() } catch { /* noop */ }
3547
3593
  try { s.mockupWatcher?.close() } catch { /* noop */ }
package/design-edit.mjs CHANGED
@@ -84,6 +84,25 @@ export function resolveManifestDesignSource(manifest, workspaceRoot, { box } = {
84
84
  return resolveDesignSource(manifest.source || manifest.html, workspaceRoot)
85
85
  }
86
86
 
87
+ // Authored mockups keep two representations: the canonical workspace source the
88
+ // producing lane edits, and a settled inert DOM snapshot the Design viewer shows.
89
+ // Snapshot paths are accepted only from this lane's private outbox so a crafted
90
+ // manifest cannot upload an arbitrary host file as room-visible HTML.
91
+ export function resolveManifestDisplaySource(manifest, record, { box } = {}) {
92
+ if (!record) return null
93
+ if (record.sourceKind === 'preview' || !manifest?.snapshot) return record.source
94
+ let snapshotPath, outbox
95
+ try {
96
+ snapshotPath = fs.realpathSync(manifest.snapshot)
97
+ outbox = fs.realpathSync(box)
98
+ const stat = fs.statSync(snapshotPath)
99
+ if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES) return null
100
+ } catch { return null }
101
+ const rel = path.relative(outbox, snapshotPath)
102
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel) || path.extname(snapshotPath).toLowerCase() !== '.html') return null
103
+ return fs.readFileSync(snapshotPath, 'utf8')
104
+ }
105
+
87
106
  export function refreshDesignSource(record) {
88
107
  if (!record) return null
89
108
  if (record.sourceKind !== 'preview') return resolveDesignSource(record.sourcePath, record.workspaceRoot)
@@ -0,0 +1,50 @@
1
+ // Turn-scoped delivery boundary for room mockups.
2
+ //
3
+ // A render can finish several tool calls before the agent's final answer. Sending
4
+ // its card immediately makes the answer land below it, forcing a phone user to
5
+ // scroll back up. Keep active-turn artifacts private until the transcript's real
6
+ // terminal boundary has been emitted, then deliver them in render order.
7
+ export function completeMockupManifest(manifest, { isReadyFile } = {}) {
8
+ if (typeof isReadyFile !== 'function') throw new TypeError('completeMockupManifest requires isReadyFile')
9
+ const files = [manifest?.desktop, manifest?.mobile, manifest?.snapshot || manifest?.source || manifest?.html]
10
+ return files.every((file) => !!file && isReadyFile(file))
11
+ }
12
+
13
+ export function isMockupDeliveryBoundary(event, { turnActive = false } = {}) {
14
+ if (!event || event.continuesQueued) return false
15
+ if (event.kind === 'result') return true
16
+ return event.kind === 'error' && !turnActive
17
+ }
18
+
19
+ export class MockupDeliveryQueue {
20
+ constructor({ deliver } = {}) {
21
+ if (typeof deliver !== 'function') throw new TypeError('MockupDeliveryQueue requires deliver')
22
+ this.deliver = deliver
23
+ this.pending = new Map()
24
+ }
25
+
26
+ enqueue(owner, item, { active = false } = {}) {
27
+ if (!owner || !active) return { queued: false, delivery: Promise.resolve().then(() => this.deliver(item)) }
28
+ const queue = this.pending.get(owner) || []
29
+ queue.push(item)
30
+ this.pending.set(owner, queue)
31
+ return { queued: true, delivery: null }
32
+ }
33
+
34
+ count(owner) {
35
+ return (this.pending.get(owner) || []).length
36
+ }
37
+
38
+ async flush(owner) {
39
+ const queue = this.pending.get(owner) || []
40
+ this.pending.delete(owner)
41
+ for (const item of queue) await this.deliver(item)
42
+ return queue.length
43
+ }
44
+
45
+ clear(owner) {
46
+ const count = this.count(owner)
47
+ this.pending.delete(owner)
48
+ return count
49
+ }
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.306",
3
+ "version": "0.7.307",
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": {
@@ -69,6 +69,7 @@
69
69
  "flow-models.mjs",
70
70
  "flow-host-revert.mjs",
71
71
  "flow-preview.mjs",
72
+ "mockup-delivery.mjs",
72
73
  "viewport.mjs",
73
74
  "design-edit.mjs",
74
75
  "flow-review.mjs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": 10,
3
+ "bundleVersion": 11,
4
4
  "contracts": [
5
5
  {
6
6
  "id": "room-coordination",
@@ -88,13 +88,13 @@
88
88
  },
89
89
  {
90
90
  "id": "visual-proof",
91
- "version": 2,
91
+ "version": 3,
92
92
  "routes": [
93
93
  {
94
94
  "id": "visual-proof",
95
95
  "tools": ["preview_start", "preview_capture", "preview_inspect", "preview_stop"],
96
96
  "trigger": "\\b(ui|ux|visual|design|frontend|html|css|page|route|mockup|screenshot|responsive|desktop|mobile|preview)\\b",
97
- "prompt": "For built visual work, run the build, use preview_start, preview_capture at desktop and mobile, preview_inspect when rendered state matters, and preview_stop when finished. preview_capture uses a fresh isolated browser, so authenticated app routes require an authenticated visual harness; if a room route resolves to the signed-out invitation, the capture is rejected and no mockup card is created. Surface PNG evidence only when no interactive source-backed Design card is displayed."
97
+ "prompt": "For built visual work, run the build, use preview_start, preview_capture at desktop and mobile, preview_inspect when rendered state matters, and preview_stop when finished. preview_capture uses a fresh isolated browser, so authenticated app routes require an authenticated visual harness; if a room route resolves to the signed-out invitation, the capture is rejected and no mockup card is created. A room card requires complete desktop and mobile evidence and is delivered after the final agent response; partial captures remain tool evidence only. Surface PNG evidence only when no interactive source-backed Design card is displayed."
98
98
  }
99
99
  ],
100
100
  "impact": [
@@ -168,9 +168,9 @@
168
168
  },
169
169
  {
170
170
  "id": "design-workspace",
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.",
171
+ "version": 5,
172
+ "interactionPrompt": "DESIGN EDITING MODEL: every complete 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 authored 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. When a person asks for many mockups or options that can share a surface, prefer one source-backed multi-option board or gallery in one file and one card so they can compare together; create separate files/cards only when the person explicitly requests independently editable artifacts or the options cannot be represented faithfully together.",
173
+ "turnReminder": "DESIGN ROUTE: preview_capture and authored HTML must produce editable Thinkpool Design cards with verified desktop and mobile renders. Cards are delivered after the final agent response so they remain the newest transcript item. 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 large direction sets, consolidate compatible options into one source-backed comparison board/gallery and one card; use separate files/cards only when independent editing is explicitly requested or technically necessary. 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
@@ -458,17 +458,24 @@ export class ViewportManager {
458
458
  const correlation = active?.captureKey === captureKey
459
459
  ? { designRequestId: active.requestId, parentRevision: active.parentRevision }
460
460
  : {}
461
- const manifest = {
462
- slug, title: String(title || 'Viewport capture').slice(0, 120),
463
- desktop: captures.desktop?.file || '', mobile: captures.mobile?.file || '', ts: Date.now(),
464
- sourceKind: 'preview', snapshot: snapshotPath, previewRoot, route: normalizedRoute, captureKey,
465
- ...correlation,
461
+ // A room card is a finished artifact, not a partial verification frame. A
462
+ // one-viewport request still returns useful MCP image evidence, but only the
463
+ // exact desktop+mobile pair gets a manifest and enters the turn-final queue.
464
+ const cardReady = !!(captures.desktop && captures.mobile)
465
+ let manifestPath = null
466
+ if (cardReady) {
467
+ const manifest = {
468
+ slug, title: String(title || 'Viewport capture').slice(0, 120),
469
+ desktop: captures.desktop.file, mobile: captures.mobile.file, ts: Date.now(),
470
+ sourceKind: 'preview', snapshot: snapshotPath, previewRoot, route: normalizedRoute, captureKey,
471
+ ...correlation,
472
+ }
473
+ manifestPath = path.join(this.outbox, `${slug}.json`)
474
+ const tmp = `${manifestPath}.tmp`
475
+ await fsp.writeFile(tmp, JSON.stringify(manifest))
476
+ await fsp.rename(tmp, manifestPath)
466
477
  }
467
- const manifestPath = path.join(this.outbox, `${slug}.json`)
468
- const tmp = `${manifestPath}.tmp`
469
- await fsp.writeFile(tmp, JSON.stringify(manifest))
470
- await fsp.rename(tmp, manifestPath)
471
- return { url, slug, manifestPath, captures, snapshotPath }
478
+ return { url, slug, manifestPath, captures, snapshotPath, cardReady }
472
479
  }
473
480
 
474
481
  inspect({ route = '/', viewport = 'mobile', selector, waitMs = 300 } = {}) {
@@ -502,7 +509,7 @@ export function createViewportTools({ tool, z, manager }) {
502
509
  ),
503
510
  tool(
504
511
  'preview_capture',
505
- 'Capture this lane preview in a fresh isolated browser at exact desktop (1440×900) and mobile (390×844) CSS viewports. Defaults to both and full-page. Authenticated app routes require an authenticated visual harness; a room route that resolves to the signed-out invitation is rejected. Successful captures return PNGs to your vision context and surface a mockup card in the ThinkPool room.',
512
+ 'Capture this lane preview in a fresh isolated browser at exact desktop (1440×900) and mobile (390×844) CSS viewports. Defaults to both and full-page. Authenticated app routes require an authenticated visual harness; a room route that resolves to the signed-out invitation is rejected. Captures always return PNG evidence; only a complete desktop+mobile capture surfaces a mockup card, after the agent final response.',
506
513
  {
507
514
  path: z.string().max(500).optional().describe('route within the preview, e.g. / or /settings; never a full URL'),
508
515
  viewports: z.enum(['both', 'desktop', 'mobile']).optional(),
@@ -516,7 +523,10 @@ export function createViewportTools({ tool, z, manager }) {
516
523
  route: args?.path || '/', viewports: args?.viewports || 'both', title: args?.title || 'Viewport capture',
517
524
  fullPage: args?.fullPage !== false, waitMs: args?.waitMs ?? 300,
518
525
  })
519
- const content = [{ type: 'text', text: `Captured ${Object.keys(result.captures).join(' + ')} for ${args?.path || '/'}. A room preview card is being delivered.` }]
526
+ const delivery = result.cardReady
527
+ ? 'The complete room preview card is queued after your final response.'
528
+ : 'This is partial viewport evidence only; no room card was created.'
529
+ const content = [{ type: 'text', text: `Captured ${Object.keys(result.captures).join(' + ')} for ${args?.path || '/'}. ${delivery}` }]
520
530
  for (const [name, capture] of Object.entries(result.captures)) {
521
531
  content.push({ type: 'text', text: `${name}: ${capture.width}×${capture.height}${capture.capped ? ' (height capped)' : ''}` })
522
532
  content.push({ type: 'image', data: capture.png.toString('base64'), mimeType: 'image/png' })