thinkpool-pair 0.7.311 → 0.7.313

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,8 +81,11 @@ 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. A complete pair is queued as a room card after the
85
- agent's final response; a single-viewport capture remains tool evidence only.
84
+ screenshots to the agent as verification evidence. It creates no transcript
85
+ artifact by default. Pass `card: true` only for an intentional user-facing
86
+ mockup/Design deliverable; a settled complete pair is then queued after the
87
+ agent's final response. Loading, empty, and public-auth fallback shells are
88
+ rejected before a card can be created.
86
89
  - `preview_inspect` returns rendered DOM text, document size, and optional
87
90
  selector geometry at either viewport.
88
91
  - `preview_stop` releases the preview port.
package/bridge.mjs CHANGED
@@ -55,6 +55,8 @@ import { readCodexDefaultModel, readCodexModels, codexConfigForMode, codexThread
55
55
  import { codexAccountUsageLine, codexCreditsReportLine, codexLimitReportLine } from './codex-commands.mjs'
56
56
  import { withMcpSessionFactory } from './codex-mcp-http.mjs'
57
57
  import { startStructuredSession } from './runtime-session.mjs'
58
+ import { claudeOneShot } from './claude-session.mjs'
59
+ import { fallbackTerminalName, suggestTerminalName } from './terminal-name.mjs'
58
60
  import { defaultStructuredMode, normalizeStructuredEffort, shouldDeferStructuredRuntime, structuredModeForSlice, structuredModeLocked, structuredModesForLane, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
59
61
  import { commandCatalogForRuntime, commandHelpLine, reconcileCommandCatalog } from './command-catalog.mjs'
60
62
  import { probeHermesRuntime } from './hermes-probe.mjs'
@@ -1160,6 +1162,9 @@ const replayPump = createLatestReplayPump({
1160
1162
  // Persisted on the host so a rename is cross-device + survives a bridge restart;
1161
1163
  // every announce carries them so a late-joining or second device sees them too.
1162
1164
  const termNames = loadNames(room)
1165
+ const manualNameTouched = new Set()
1166
+ const autoNameAttempts = new Set()
1167
+ const autoNames = new Map()
1163
1168
  // The SDK's supported-model LIST (value/displayName/description), captured from
1164
1169
  // any session's `models` event. Announced room-level so EVERY device gets it on
1165
1170
  // connect — not just the one that saw the one-shot event (the picker fell back to
@@ -1254,6 +1259,91 @@ const announce = () => {
1254
1259
  ],
1255
1260
  }) }
1256
1261
 
1262
+ const uniqueTerminalName = (id, candidate) => {
1263
+ const used = new Set(Object.entries(termNames)
1264
+ .filter(([otherId]) => otherId !== id)
1265
+ .map(([, label]) => String(label).toLowerCase()))
1266
+ if (!used.has(candidate.toLowerCase())) return candidate
1267
+ for (let n = 2; n < 100; n++) {
1268
+ const suffix = ` ${n}`
1269
+ const next = `${candidate.slice(0, 48 - suffix.length).trim()}${suffix}`
1270
+ if (!used.has(next.toLowerCase())) return next
1271
+ }
1272
+ return candidate
1273
+ }
1274
+
1275
+ // Best-effort row convergence: the bridge map updates the room immediately and
1276
+ // survives this host's restarts; the row carries the generated name to another host.
1277
+ const persistAutoTerminalName = async (id, label, previous, retry = true) => {
1278
+ if (!codeAuthToken || !id || !label) return
1279
+ try {
1280
+ // Compare-and-set: an in-flight generated write must never overwrite a
1281
+ // manual rename that won just before this request reached Postgres.
1282
+ const expected = previous
1283
+ ? `&name=eq.${encodeURIComponent(previous)}`
1284
+ : `&or=${encodeURIComponent('(name.is.null,name.eq.Terminal)')}`
1285
+ const response = await fetch(`${SUPABASE_URL}/rest/v1/code_terminals?id=eq.${encodeURIComponent(id)}${expected}&select=id`, {
1286
+ method: 'PATCH',
1287
+ headers: {
1288
+ apikey: SUPABASE_ANON,
1289
+ Authorization: `Bearer ${codeAuthToken}`,
1290
+ 'Content-Type': 'application/json',
1291
+ Prefer: 'return=representation',
1292
+ },
1293
+ body: JSON.stringify({ name: label }),
1294
+ })
1295
+ const rows = response.ok ? await response.json().catch(() => []) : []
1296
+ // Bridge-created lanes can receive work before the browser inserts their row.
1297
+ if (retry && (!response.ok || !Array.isArray(rows) || rows.length === 0)) {
1298
+ const timer = setTimeout(() => { void persistAutoTerminalName(id, label, previous, false) }, 1500)
1299
+ timer.unref?.()
1300
+ }
1301
+ } catch {
1302
+ if (retry) {
1303
+ const timer = setTimeout(() => { void persistAutoTerminalName(id, label, previous, false) }, 1500)
1304
+ timer.unref?.()
1305
+ }
1306
+ }
1307
+ }
1308
+
1309
+ const applyAutoTerminalName = (id, candidate) => {
1310
+ if (!candidate || manualNameTouched.has(id)) return false
1311
+ const currentAuto = autoNames.get(id)
1312
+ if (termNames[id] && termNames[id] !== currentAuto) return false
1313
+ const previous = termNames[id] || null
1314
+ const label = uniqueTerminalName(id, candidate)
1315
+ if (termNames[id] === label) return true
1316
+ termNames[id] = label
1317
+ autoNames.set(id, label)
1318
+ saveNames(room, termNames)
1319
+ void persistAutoTerminalName(id, label, previous)
1320
+ announce()
1321
+ return true
1322
+ }
1323
+
1324
+ // First real task only. A zero-token title appears immediately. Built-in Claude
1325
+ // may refine it through the existing raw Haiku path; other runtimes stay local so
1326
+ // a Codex/Hermes/custom-provider prompt is never leaked across providers.
1327
+ const autoNameTerminal = (id, text) => {
1328
+ const entry = sessions.get(id)
1329
+ if (!entry || termNames[id] || manualNameTouched.has(id) || autoNameAttempts.has(id)) return
1330
+ if (entry.log?.some((event) => event?.kind === 'you')) return
1331
+ const fallback = fallbackTerminalName(text)
1332
+ if (!fallback) return
1333
+ autoNameAttempts.add(id)
1334
+ applyAutoTerminalName(id, fallback)
1335
+ const canUseHaiku = entry.runtime === 'claude'
1336
+ && (!entry.provider || entry.provider === 'anthropic')
1337
+ && hostMemoryAdmission('name this terminal').ok
1338
+ if (!canUseHaiku) return
1339
+ const context = `Repository: ${repoLabel}. Existing terminal names: ${Object.values(termNames).filter(Boolean).slice(-8).join(', ') || 'none'}.`
1340
+ void suggestTerminalName({
1341
+ text,
1342
+ context,
1343
+ generate: (prompt) => claudeOneShot({ prompt, cwd: entry.cwd || process.cwd(), env: process.env, timeoutMs: 8000 }),
1344
+ }).then((candidate) => applyAutoTerminalName(id, candidate))
1345
+ }
1346
+
1257
1347
  // (cross-person grantee yield removed 2026-07-06 — owner-only serving now; the owner's
1258
1348
  // bridge never stands down. See docs/specs/2026-07-06-remove-cross-person-serve.md.)
1259
1349
 
@@ -1345,6 +1435,7 @@ async function receiveCrossRoomPost({ fromRoom, fromHost, fromTerminalName, text
1345
1435
  te.roomHop = 1; te.hop = (te.hop || 0) + 1; te.peekCount = 0; te.postCount = 0; te.pairPeekCount = 0; te.crossRoomPostCount = 0
1346
1436
  const msg = `[From: room ${fromLabel} — relayed via the ThinkPool cross-room Ensemble, approved by a person in this room]\n${body}`
1347
1437
  const evt = { kind: 'you', text: msg, by: `room ${fromRoom}`, crosspost: true, relaySourceName: String(fromTerminalName || '').trim().slice(0, 80) || undefined }
1438
+ autoNameTerminal(targetId, body)
1348
1439
  stampEvent(evt); pushLog(te, evt); bcast('code-event', { term: targetId, evt })
1349
1440
  try { if (te.session.sendTurn(msg) === false) return { error: `Could not deliver to room ${room} — the lane refused the turn (check its visible host-memory/runtime error).` } } catch { return { error: `Could not deliver to room ${room} — the lane may have just closed.` } }
1350
1441
  return { ok: true, ref: String(targetId).slice(0, 8) }
@@ -1684,10 +1775,13 @@ function receiveManifest(box, file, term, trustedDesignSource = false) {
1684
1775
  if (!item) return
1685
1776
  const lane = sessions.get(term)
1686
1777
  const active = !!lane && (lane._busyAnn === true || lane.session?.turnActive === true)
1687
- const accepted = mockupDeliveries.enqueue(term, item, { active })
1778
+ const deliveryKey = item.m.sourceKind === 'preview'
1779
+ ? `preview:${item.m.captureKey || item.m.route || item.m.slug}:${item.m.title || ''}`
1780
+ : `authored:${item.m.slug}`
1781
+ const accepted = mockupDeliveries.enqueue(term, item, { active, key: deliveryKey })
1688
1782
  if (accepted.queued) {
1689
1783
  writeMockupReceipt(box, item.slug, true, 'queued')
1690
- process.stderr.write(`\n ◆ mockup "${item.m.title || item.m.slug}" ready — queued after the final response.\n`)
1784
+ process.stderr.write(`\n ◆ mockup "${item.m.title || item.m.slug}" ready — ${accepted.replaced ? 'replaced an earlier queued render' : 'queued after the final response'}.\n`)
1691
1785
  } else {
1692
1786
  accepted.delivery.catch(() => {})
1693
1787
  }
@@ -2577,6 +2671,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2577
2671
  // Echo the injected prompt into the TARGET lane so both people see what
2578
2672
  // arrived (rides the existing code-event 'you' path — no new topic).
2579
2673
  const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
2674
+ autoNameTerminal(target.id, args.text)
2580
2675
  stampEvent(evt)
2581
2676
  pushLog(te, evt)
2582
2677
  bcast('code-event', { term: target.id, evt })
@@ -2630,6 +2725,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2630
2725
  announce()
2631
2726
  const msg = `[Task from main terminal ${fromRef}'s agent — opened as an independent MAIN CASCADE CONDUCTOR terminal, not an Ensemble child]\n${String(args.task).trim()}`
2632
2727
  const evt = { kind: 'you', text: msg, by: `main terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
2728
+ autoNameTerminal(newId, args.task)
2633
2729
  stampEvent(evt); pushLog(conductor, evt); bcast('code-event', { term: newId, evt })
2634
2730
  try { if (conductor.session.sendTurn(msg) === false) return okText(`Opened main conductor ${newRef}, but host pressure prevented its runtime from starting. Existing lanes remain connected; free memory and retry the task.`) }
2635
2731
  catch { return okText(`Opened main conductor ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but it may still be starting — the initial task could not be delivered.`) }
@@ -2749,6 +2845,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2749
2845
  : ''
2750
2846
  const msg = `[Task from terminal ${fromRef}'s agent — relayed via ThinkPool Ensemble; you are its WORKER SUB-TERMINAL, never a main terminal or Cascade conductor]\n${args.task}${reviewTarget}`
2751
2847
  const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
2848
+ autoNameTerminal(newId, args.task)
2752
2849
  stampEvent(evt); pushLog(ne, evt); bcast('code-event', { term: newId, evt })
2753
2850
  try { if (ne.session.sendTurn(msg) === false) return okText(`Opened lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but host pressure prevented its runtime from accepting the task. Existing lanes remain connected; free memory and retry.`) } catch { return okText(`Opened lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but it may still be starting — could not hand off the task. Try post_to_terminal shortly.`) }
2754
2851
  return okText(`Opened agent lane ${newRef}${args?.name ? ` ("${args.name}")` : ''} and handed it the task. It runs in its own lane — check back with read_terminal, then close_terminal when done.`)
@@ -4241,6 +4338,7 @@ channel
4241
4338
  // Sample the runtime before dispatch so a missed prior falling edge cannot
4242
4339
  // make this genuinely new turn inherit the previous turn's revision.
4243
4340
  syncStructuredTurn(s)
4341
+ autoNameTerminal(payload.term, payload.body != null ? String(payload.body) : text)
4244
4342
  const accepted = s.session.sendTurn(sendText, Object.keys(turnOptions).length ? turnOptions : undefined)
4245
4343
  if (accepted === false) syncStructuredTurn(s)
4246
4344
  else beginStructuredTurn(s)
@@ -4410,6 +4508,8 @@ channel
4410
4508
  // LATER (or a second machine) — those only ever see the announce.
4411
4509
  .on('broadcast', { event: 'term-rename' }, ({ payload }) => {
4412
4510
  if (!payload?.id) return
4511
+ manualNameTouched.add(payload.id)
4512
+ autoNames.delete(payload.id)
4413
4513
  if (payload.name) termNames[payload.id] = String(payload.name).slice(0, 80)
4414
4514
  else delete termNames[payload.id]
4415
4515
  saveNames(room, termNames)
@@ -27,10 +27,42 @@ import { stallDecision, stallEvent, isCompactTurn } from './turn-stall.mjs'
27
27
 
28
28
  // The caret-pulled SDK's real version (^0.3.x auto-upgrades on restart). Resolved
29
29
  // once at import by walking up from the package entry to its own package.json.
30
+ const req = createRequire(import.meta.url)
31
+
32
+ // Shared raw one-shot for tiny bridge-owned inference jobs. No settings, skills,
33
+ // MCP servers, or repo instructions are loaded; callers provide the model + prompt.
34
+ export async function claudeOneShot({ prompt, model = 'claude-haiku-4-5', cwd, env, timeoutMs = 8000 } = {}) {
35
+ const abortController = new AbortController()
36
+ const timer = setTimeout(() => { try { abortController.abort() } catch { /* noop */ } }, timeoutMs)
37
+ try {
38
+ const result = query({
39
+ prompt,
40
+ options: {
41
+ model,
42
+ ...(cwd ? { cwd } : {}),
43
+ env,
44
+ maxTurns: 1,
45
+ permissionMode: 'bypassPermissions',
46
+ settingSources: [],
47
+ strictMcpConfig: true,
48
+ mcpServers: {},
49
+ abortController,
50
+ },
51
+ })
52
+ let out = ''
53
+ for await (const message of result) {
54
+ if (message.type === 'assistant') {
55
+ for (const block of (message.message?.content || [])) if (block.type === 'text') out += block.text
56
+ }
57
+ if (message.type === 'result') break
58
+ }
59
+ return out.trim()
60
+ } finally { clearTimeout(timer) }
61
+ }
62
+
30
63
  // Named in the [SDK-REGRESSION] guard below so a silent gate-change is attributable.
31
64
  const SDK_VERSION = (() => {
32
65
  try {
33
- const req = createRequire(import.meta.url)
34
66
  let d = dirname(req.resolve('@anthropic-ai/claude-agent-sdk'))
35
67
  for (let i = 0; i < 8; i++) {
36
68
  try { const p = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')); if (p.name === '@anthropic-ai/claude-agent-sdk') return p.version } catch { /* keep walking */ }
@@ -788,35 +820,17 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
788
820
  if (!suggest) return
789
821
  const seed = (lastAssistantText || '').trim().slice(-1500)
790
822
  if (!seed || closed) return
791
- const ac2 = new AbortController()
792
- const t = setTimeout(() => { try { ac2.abort() } catch { /* noop */ } }, 8000)
793
823
  try {
794
- const hq = query({
824
+ let out = await claudeOneShot({
795
825
  prompt: `You are predicting the user's NEXT chat message in a live coding session, to prefill their composer. Given the assistant's latest reply below, output the single most likely next user message — short and natural (often just "proceed", "go with option A", "yes do that", or a brief follow-up). One line, <=12 words, imperative, no preamble, no quotes, no markdown.\n\nAssistant's latest reply:\n"""\n${seed}\n"""\n\nNext user message:`,
796
- options: {
797
- model: 'claude-haiku-4-5',
798
- ...(cwd ? { cwd } : {}),
799
- env: opts.env,
800
- maxTurns: 1,
801
- permissionMode: 'bypassPermissions',
802
- settingSources: [], // no CLAUDE.md / commands / agents — raw call
803
- strictMcpConfig: true,
804
- mcpServers: {}, // no MCP servers — fast cold start
805
- abortController: ac2,
806
- },
826
+ model: 'claude-haiku-4-5', cwd, env: opts.env, timeoutMs: 8000,
807
827
  })
808
- let out = ''
809
- for await (const mm of hq) {
810
- if (mm.type === 'assistant') for (const b of (mm.message?.content || [])) if (b.type === 'text') out += b.text
811
- if (mm.type === 'result') break
812
- }
813
828
  out = out.trim().split('\n')[0].replace(/^["'`]+|["'`]+$/g, '').trim().slice(0, 140)
814
829
  if (out && !sawSuggestion && !closed && !/^(sure|of course|certainly|let me know|i can help|happy to)\b/i.test(out)) {
815
830
  emit({ kind: 'suggestion', text: out, source: 'haiku' })
816
831
  process.stderr.write(` ◆ [suggestion] haiku fallback: ${JSON.stringify(out)}\n`)
817
832
  }
818
833
  } catch { /* fallback failed (rate limit / abort / model error) — silent */ }
819
- finally { clearTimeout(t) }
820
834
  }
821
835
 
822
836
  const admitColdStart = () => {
@@ -266,7 +266,7 @@ export const nativeClaudeProviderAccessFailed = (events) => {
266
266
  let text = ''
267
267
  try { text = typeof events === 'string' ? events : JSON.stringify(events || '') }
268
268
  catch { text = String(events || '') }
269
- return /organization has disabled (?:claude )?subscription access|(?:claude )?subscription access (?:has been |is )?disabled|invalid anthropic (?:api )?key|invalid x-api-key|not logged in to claude|(?:run|use) \/login[^\n]{0,80}claude|no valid anthropic (?:api )?key/i.test(text)
269
+ return /organization has disabled (?:claude(?: subscription access)?|subscription access)|(?:claude )?subscription access (?:has been |is )?disabled|invalid anthropic (?:api )?key|invalid x-api-key|not logged in to claude|(?:run|use) \/login[^\n]{0,80}claude|no valid anthropic (?:api )?key/i.test(text)
270
270
  }
271
271
 
272
272
  const HERMES_CLAUDE_REVIEW_PREFERENCE = Object.freeze([
@@ -6,6 +6,7 @@
6
6
  // terminal boundary has been emitted, then deliver them in render order.
7
7
  export function completeMockupManifest(manifest, { isReadyFile } = {}) {
8
8
  if (typeof isReadyFile !== 'function') throw new TypeError('completeMockupManifest requires isReadyFile')
9
+ if (manifest?.sourceKind === 'preview' && (manifest.deliveryIntent !== 'card' || manifest.readinessValidated !== true)) return false
9
10
  const files = [manifest?.desktop, manifest?.mobile, manifest?.snapshot || manifest?.source || manifest?.html]
10
11
  return files.every((file) => !!file && isReadyFile(file))
11
12
  }
@@ -23,12 +24,18 @@ export class MockupDeliveryQueue {
23
24
  this.pending = new Map()
24
25
  }
25
26
 
26
- enqueue(owner, item, { active = false } = {}) {
27
+ enqueue(owner, item, { active = false, key = null } = {}) {
27
28
  if (!owner || !active) return { queued: false, delivery: Promise.resolve().then(() => this.deliver(item)) }
28
29
  const queue = this.pending.get(owner) || []
29
- queue.push(item)
30
+ const existing = key == null ? -1 : queue.findIndex((entry) => entry.key === key)
31
+ if (existing >= 0) {
32
+ queue[existing] = { item, key }
33
+ this.pending.set(owner, queue)
34
+ return { queued: true, replaced: true, delivery: null }
35
+ }
36
+ queue.push({ item, key })
30
37
  this.pending.set(owner, queue)
31
- return { queued: true, delivery: null }
38
+ return { queued: true, replaced: false, delivery: null }
32
39
  }
33
40
 
34
41
  count(owner) {
@@ -38,7 +45,7 @@ export class MockupDeliveryQueue {
38
45
  async flush(owner) {
39
46
  const queue = this.pending.get(owner) || []
40
47
  this.pending.delete(owner)
41
- for (const item of queue) await this.deliver(item)
48
+ for (const entry of queue) await this.deliver(entry.item)
42
49
  return queue.length
43
50
  }
44
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.311",
3
+ "version": "0.7.313",
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": {
@@ -18,6 +18,7 @@
18
18
  "byok-detect.mjs",
19
19
  "context-windows.mjs",
20
20
  "claude-session.mjs",
21
+ "terminal-name.mjs",
21
22
  "claude-command-catalog.mjs",
22
23
  "codex-session.mjs",
23
24
  "question-response.mjs",
@@ -0,0 +1,54 @@
1
+ import { nativeClaudeProviderAccessFailed } from './cross-terminal.mjs'
2
+
3
+ const SKIP = new Set([
4
+ 'a', 'an', 'and', 'are', 'at', 'be', 'can', 'could', 'for', 'from', 'how', 'i',
5
+ 'in', 'is', 'it', 'just', 'like', 'maybe', 'me', 'my', 'of', 'on', 'or', 'our',
6
+ 'please', 'something', 'that', 'the', 'this', 'to', 'we', 'with', 'would', 'you',
7
+ ])
8
+
9
+ const GENERIC = /^(?:new )?(?:agent |coding )?(?:terminal|task|lane|session|work)$/i
10
+
11
+ export function cleanTerminalName(value) {
12
+ let name = String(value || '')
13
+ .trim()
14
+ .split(/\r?\n/, 1)[0]
15
+ .replace(/^\s*(?:[-*#>]+|title\s*:?)\s*/i, '')
16
+ .replace(/^["'`]+|["'`]+$/g, '')
17
+ .replace(/[^\p{L}\p{N}+#&.' -]+/gu, ' ')
18
+ .replace(/\s+/g, ' ')
19
+ .trim()
20
+ if (!name || GENERIC.test(name) || nativeClaudeProviderAccessFailed(name)) return null
21
+ if (/^(?:error|failed|sorry|unable|i (?:cannot|can't)|rate limit|request failed)\b/i.test(name)) return null
22
+ if (name.length > 48) name = name.slice(0, 48).replace(/\s+\S*$/, '').trim()
23
+ return name || null
24
+ }
25
+
26
+ export function fallbackTerminalName(text) {
27
+ const body = String(text || '')
28
+ .replace(/^\s*\[[^\]\n]{1,240}\]\s*/g, '')
29
+ .replace(/^\s*(?:hey|hi|okay|ok|so)\b[,:!]?\s*/i, '')
30
+ .replace(/^\s*(?:can|could|would|will)\s+you\s+/i, '')
31
+ .replace(/^\s*(?:how about|i (?:do not|don't) know|i guess)\s+/i, '')
32
+ .replace(/[`*_>#()[\]{}]/g, ' ')
33
+ .slice(0, 600)
34
+ const words = body.match(/[\p{L}\p{N}][\p{L}\p{N}+#.'-]*/gu) || []
35
+ const useful = words.filter((word) => !SKIP.has(word.toLowerCase()) && !/^https?$/i.test(word))
36
+ if (!useful.length) return null
37
+ const title = useful.slice(0, 5).map((word) => {
38
+ if (/^[A-Z\d+#.-]{2,}$/.test(word) || /\d/.test(word)) return word
39
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
40
+ }).join(' ')
41
+ return cleanTerminalName(title)
42
+ }
43
+
44
+ export async function suggestTerminalName({ text, context = '', generate } = {}) {
45
+ const fallback = fallbackTerminalName(text)
46
+ if (!fallback || typeof generate !== 'function') return fallback
47
+ const prompt = [
48
+ 'Name one terminal lane from its first task. Output only a distinctive 2-5 word title, title case, at most 40 characters. Describe the concrete work, not the person or model. Never output “Terminal”, “Task”, “Session”, or a numbered label.',
49
+ context ? `Room context: ${String(context).slice(0, 500)}` : '',
50
+ `First task:\n${String(text || '').slice(0, 1600)}`,
51
+ ].filter(Boolean).join('\n\n')
52
+ try { return cleanTerminalName(await generate(prompt)) || fallback }
53
+ catch { return fallback }
54
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": 11,
3
+ "bundleVersion": 12,
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": 3,
91
+ "version": 4,
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. 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."
97
+ "prompt": "For built visual work, run the build, use preview_start, preview_capture at desktop and mobile, preview_inspect when rendered state matters, and preview_stop when finished. preview_capture uses a fresh isolated browser and is inline verification evidence by default; it must not create a transcript card. Set card=true only when the person asked for a mockup/Design artifact or the visual itself is an intentional deliverable that should remain editable in the transcript. A card requires settled meaningful content at both desktop and mobile, rejects loading/public-auth fallback shells, and is delivered after the final agent response. Authenticated app routes require an authenticated visual harness. 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": 5,
171
+ "version": 6,
172
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.",
173
+ "turnReminder": "DESIGN ROUTE: intentional application-preview deliverables use preview_capture with card=true; correlated Design recaptures are recognized automatically. Authored HTML uses the source-backed render helper. Both must produce editable Thinkpool Design cards with verified desktop and mobile renders. Ordinary verification captures stay inline evidence and must not create cards. 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"},
@@ -41,7 +41,7 @@ export const THINKPOOL_REMOTE_DELIVERY_RULES = Object.freeze([
41
41
  'LINKS & ARTIFACTS: a local filesystem path, file:// URL, localhost/127.0.0.1 address, or host-only preview is useless to a remote room. Every link you surface must be reachable by the people in the room.',
42
42
  'Whenever you produce HTML or shareable markup — a demo, mockup, preview, report, or page — publish it to a browser-renderable GitHub-shareable URL, normally GitHub Pages (or an equivalent URL that actually renders; raw.githubusercontent.com serves HTML as plain text). Give the room that shareable URL in addition to the Thinkpool Design card, never instead of it and never only as a local path. If you cannot publish it, say so instead of falling back to a host-only path.',
43
43
  'SHOW VISUAL WORK: whenever you build, change, or fix anything visual, show the result in the room. A source-backed Thinkpool Design card/popup is already the interactive visible result, so do not additionally surface duplicate PNGs. When no interactive Design artifact is available, capture and surface desktop/mobile PNG evidence inline.',
44
- 'BRIDGE PREVIEWS: for a built web UI, use preview_start (default root: dist), then preview_capture for exact desktop 1440x900 and mobile 390x844 PNGs, and preview_inspect when DOM text or selector geometry helps. Run the project build first and stop the preview server with preview_stop when done.',
44
+ 'BRIDGE PREVIEWS: for a built web UI, use preview_start (default root: dist), then preview_capture for exact desktop 1440x900 and mobile 390x844 PNGs, and preview_inspect when DOM text or selector geometry helps. Run the project build first and stop the preview server with preview_stop when done. Captures are verification evidence by default; set card=true only for an intentional user-facing mockup or Design deliverable, never merely because a turn changed visual code.',
45
45
  THINKPOOL_DESIGN_DELIVERY_RULE,
46
46
  'VERIFY BEFORE CLAIMING: run or serve what you changed, observe it, and show the evidence in the room — the PNG, passing test output, or real response. If something could not be verified, say exactly what remains unverified.',
47
47
  ])
package/viewport.mjs CHANGED
@@ -70,6 +70,30 @@ export function isSignedOutRoomPreview(route, snapshot) {
70
70
  return /\bid\s*=\s*["']invitation-room-title["']/i.test(String(snapshot || ''))
71
71
  }
72
72
 
73
+ export function previewArtifactIssue(route, capture) {
74
+ const parsed = new URL(normalizeRoute(route), 'http://127.0.0.1')
75
+ const routeIdentity = (url) => {
76
+ if (typeof url !== 'string' || !url.trim()) return null
77
+ try {
78
+ const value = new URL(url, 'http://127.0.0.1')
79
+ const pathname = value.pathname === '/' ? '/' : value.pathname.replace(/\/+$/, '')
80
+ return `${pathname}${value.search}`
81
+ } catch { return null }
82
+ }
83
+ const expectedRoute = routeIdentity(parsed.href)
84
+ const protectedRoute = parsed.pathname === '/code' || parsed.pathname.startsWith('/code/') || parsed.searchParams.has('r')
85
+ for (const state of [capture?.beforeState, capture?.afterState]) {
86
+ if (!state) return 'The captured page did not report settled rendered state.'
87
+ const actualRoute = routeIdentity(state.url)
88
+ if (actualRoute && actualRoute !== expectedRoute) return `Preview navigated from ${expectedRoute} to ${actualRoute}.`
89
+ if (state.signedOutInvite) return 'Preview resolved to the signed-out room invitation instead of the authenticated Code room.'
90
+ if (state.visibleBoot) return 'Preview was still showing the application loading shell.'
91
+ if (protectedRoute && state.prerenderGeo) return 'Preview resolved to the public marketing shell instead of the authenticated Code surface.'
92
+ if (!state.hasBody || (state.textLength < 2 && state.meaningfulVisualCount < 1)) return 'Preview rendered an empty page.'
93
+ }
94
+ return null
95
+ }
96
+
73
97
  export function findBrowserExecutable({ env = process.env, platform = process.platform, exists = fs.existsSync } = {}) {
74
98
  const candidates = [
75
99
  env.TP_BROWSER_PATH,
@@ -261,8 +285,106 @@ export class CdpBrowser {
261
285
  }
262
286
  }
263
287
 
264
- async capture({ url, viewport, fullPage = true, waitMs = 300 }) {
288
+ async renderedState(pipe, sessionId) {
289
+ const expression = `(() => {
290
+ const visible = (node) => {
291
+ if (!node) return false;
292
+ const style = getComputedStyle(node);
293
+ const rect = node.getBoundingClientRect();
294
+ return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity || 1) > 0 && rect.width > 0 && rect.height > 0;
295
+ };
296
+ const boot = document.querySelector('#boot');
297
+ const meaningful = [...document.querySelectorAll('main,article,section,img,video,canvas,svg,[role="dialog"],[role="main"],[role="img"]')]
298
+ .filter((node) => !node.closest('#boot') && visible(node));
299
+ return {
300
+ title: document.title || '', url: location.href,
301
+ hasBody: !!document.body,
302
+ textLength: (document.body?.innerText || '').trim().length,
303
+ meaningfulVisualCount: meaningful.length,
304
+ visibleBoot: visible(boot),
305
+ signedOutInvite: !!document.querySelector('#invitation-room-title'),
306
+ prerenderGeo: !!document.querySelector('[data-prerender="geo"]'),
307
+ };
308
+ })()`
309
+ const result = await pipe.send('Runtime.evaluate', { expression, returnByValue: true }, sessionId)
310
+ if (result.exceptionDetails) throw new Error('Could not inspect the rendered preview state.')
311
+ return result.result?.value || null
312
+ }
313
+
314
+ async freezeCurrentPage(pipe, sessionId) {
315
+ // Freeze the rendered DOM, not the build entry document. A Vite/React entry
316
+ // depends on root-relative chunks and often boots to an empty shell in a
317
+ // sandboxed srcDoc; the settled DOM plus same-origin CSS/assets is portable.
318
+ const expression = `(async () => {
319
+ const clone = document.documentElement.cloneNode(true);
320
+ clone.querySelectorAll('script,link[rel="modulepreload"],link[rel="preload"]').forEach((node) => node.remove());
321
+ clone.querySelectorAll('*').forEach((node) => {
322
+ for (const attr of [...node.attributes]) if (/^on/i.test(attr.name)) node.removeAttribute(attr.name);
323
+ if (node instanceof HTMLInputElement) node.removeAttribute('value');
324
+ if (node instanceof HTMLTextAreaElement) node.textContent = '';
325
+ });
326
+ const css = [];
327
+ for (const sheet of [...document.styleSheets]) {
328
+ try { css.push([...sheet.cssRules].map((rule) => rule.cssText).join('\\n')); } catch {}
329
+ }
330
+ clone.querySelectorAll('style,link[rel="stylesheet"]').forEach((node) => node.remove());
331
+ const head = clone.querySelector('head') || clone.insertBefore(document.createElement('head'), clone.firstChild);
332
+ const style = document.createElement('style');
333
+ style.setAttribute('data-thinkpool-snapshot', '');
334
+ style.textContent = css.join('\\n');
335
+ head.appendChild(style);
336
+
337
+ const assetUrls = new Set();
338
+ const remember = (raw) => {
339
+ if (!raw || /^(data:|blob:|#)/i.test(raw)) return;
340
+ try {
341
+ const absolute = new URL(raw, location.href);
342
+ if (absolute.origin === location.origin) assetUrls.add(absolute.href);
343
+ } catch {}
344
+ };
345
+ clone.querySelectorAll('[src],[poster]').forEach((node) => {
346
+ remember(node.getAttribute('src'));
347
+ remember(node.getAttribute('poster'));
348
+ });
349
+ for (const match of style.textContent.matchAll(/url\\(\\s*(['"]?)([^'"\\)]+)\\1\\s*\\)/gi)) remember(match[2]);
350
+
351
+ const replacements = new Map();
352
+ let inlinedBytes = 0;
353
+ for (const absolute of assetUrls) {
354
+ try {
355
+ const response = await fetch(absolute);
356
+ const buffer = await response.arrayBuffer();
357
+ if (!response.ok || buffer.byteLength > 512000 || inlinedBytes + buffer.byteLength > 1000000) continue;
358
+ const bytes = new Uint8Array(buffer);
359
+ let binary = '';
360
+ for (let offset = 0; offset < bytes.length; offset += 0x8000) binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
361
+ replacements.set(absolute, 'data:' + (response.headers.get('content-type') || 'application/octet-stream') + ';base64,' + btoa(binary));
362
+ inlinedBytes += buffer.byteLength;
363
+ } catch {}
364
+ }
365
+ const replaceAsset = (raw) => {
366
+ try { return replacements.get(new URL(raw, location.href).href) || raw; } catch { return raw; }
367
+ };
368
+ clone.querySelectorAll('[src],[poster]').forEach((node) => {
369
+ for (const name of ['src', 'poster']) if (node.hasAttribute(name)) node.setAttribute(name, replaceAsset(node.getAttribute(name)));
370
+ node.removeAttribute('srcset');
371
+ });
372
+ style.textContent = style.textContent.replace(/url\\(\\s*(['"]?)([^'"\\)]+)\\1\\s*\\)/gi, (_all, quote, raw) => 'url("' + replaceAsset(raw) + '")');
373
+ return '<!doctype html>\\n' + clone.outerHTML;
374
+ })()`
375
+ const result = await pipe.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, sessionId)
376
+ if (result.exceptionDetails || typeof result.result?.value !== 'string') {
377
+ const detail = result.exceptionDetails?.exception?.description || result.exceptionDetails?.text || result.result?.description
378
+ throw new Error(`Could not freeze the rendered preview for Design${detail ? `: ${detail}` : '.'}`)
379
+ }
380
+ const html = result.result.value
381
+ if (!html.trim() || Buffer.byteLength(html) > MAX_PORTABLE_SNAPSHOT_BYTES) throw new Error('The rendered preview is too large to make editable.')
382
+ return html
383
+ }
384
+
385
+ async capture({ url, viewport, fullPage = true, waitMs = 300, includeSnapshot = false }) {
265
386
  return this.withPage({ url, viewport, waitMs }, async ({ pipe, sessionId }) => {
387
+ const beforeState = await this.renderedState(pipe, sessionId)
266
388
  const metrics = await pipe.send('Page.getLayoutMetrics', {}, sessionId)
267
389
  const contentHeight = Math.ceil(metrics.cssContentSize?.height || viewport.height)
268
390
  const height = fullPage ? Math.min(MAX_CAPTURE_HEIGHT, Math.max(viewport.height, contentHeight)) : viewport.height
@@ -270,81 +392,14 @@ export class CdpBrowser {
270
392
  format: 'png', fromSurface: true, captureBeyondViewport: true,
271
393
  clip: { x: 0, y: 0, width: viewport.width, height, scale: 1 },
272
394
  }, sessionId)
273
- return { png: Buffer.from(result.data, 'base64'), width: viewport.width, height, contentHeight, capped: contentHeight > MAX_CAPTURE_HEIGHT }
395
+ const afterState = await this.renderedState(pipe, sessionId)
396
+ const snapshot = includeSnapshot ? await this.freezeCurrentPage(pipe, sessionId) : null
397
+ return { png: Buffer.from(result.data, 'base64'), width: viewport.width, height, contentHeight, capped: contentHeight > MAX_CAPTURE_HEIGHT, beforeState, afterState, snapshot }
274
398
  })
275
399
  }
276
400
 
277
401
  async snapshot({ url, viewport = DEFAULT_VIEWPORTS.desktop, waitMs = 300 }) {
278
- return this.withPage({ url, viewport, waitMs }, async ({ pipe, sessionId }) => {
279
- // Freeze the rendered DOM, not the build entry document. A Vite/React entry
280
- // depends on root-relative chunks and often boots to an empty shell in a
281
- // sandboxed srcDoc; the settled DOM plus same-origin CSS/assets is portable.
282
- const expression = `(async () => {
283
- const clone = document.documentElement.cloneNode(true);
284
- clone.querySelectorAll('script,link[rel="modulepreload"],link[rel="preload"]').forEach((node) => node.remove());
285
- clone.querySelectorAll('*').forEach((node) => {
286
- for (const attr of [...node.attributes]) if (/^on/i.test(attr.name)) node.removeAttribute(attr.name);
287
- if (node instanceof HTMLInputElement) node.removeAttribute('value');
288
- if (node instanceof HTMLTextAreaElement) node.textContent = '';
289
- });
290
- const css = [];
291
- for (const sheet of [...document.styleSheets]) {
292
- try { css.push([...sheet.cssRules].map((rule) => rule.cssText).join('\\n')); } catch {}
293
- }
294
- clone.querySelectorAll('style,link[rel="stylesheet"]').forEach((node) => node.remove());
295
- const head = clone.querySelector('head') || clone.insertBefore(document.createElement('head'), clone.firstChild);
296
- const style = document.createElement('style');
297
- style.setAttribute('data-thinkpool-snapshot', '');
298
- style.textContent = css.join('\\n');
299
- head.appendChild(style);
300
-
301
- const assetUrls = new Set();
302
- const remember = (raw) => {
303
- if (!raw || /^(data:|blob:|#)/i.test(raw)) return;
304
- try {
305
- const absolute = new URL(raw, location.href);
306
- if (absolute.origin === location.origin) assetUrls.add(absolute.href);
307
- } catch {}
308
- };
309
- clone.querySelectorAll('[src],[poster]').forEach((node) => {
310
- remember(node.getAttribute('src'));
311
- remember(node.getAttribute('poster'));
312
- });
313
- for (const match of style.textContent.matchAll(/url\\(\\s*(['"]?)([^'"\\)]+)\\1\\s*\\)/gi)) remember(match[2]);
314
-
315
- const replacements = new Map();
316
- let inlinedBytes = 0;
317
- for (const absolute of assetUrls) {
318
- try {
319
- const response = await fetch(absolute);
320
- const buffer = await response.arrayBuffer();
321
- if (!response.ok || buffer.byteLength > 512000 || inlinedBytes + buffer.byteLength > 1000000) continue;
322
- const bytes = new Uint8Array(buffer);
323
- let binary = '';
324
- for (let offset = 0; offset < bytes.length; offset += 0x8000) binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
325
- replacements.set(absolute, 'data:' + (response.headers.get('content-type') || 'application/octet-stream') + ';base64,' + btoa(binary));
326
- inlinedBytes += buffer.byteLength;
327
- } catch {}
328
- }
329
- const replaceAsset = (raw) => {
330
- try { return replacements.get(new URL(raw, location.href).href) || raw; } catch { return raw; }
331
- };
332
- clone.querySelectorAll('[src],[poster]').forEach((node) => {
333
- for (const name of ['src', 'poster']) if (node.hasAttribute(name)) node.setAttribute(name, replaceAsset(node.getAttribute(name)));
334
- node.removeAttribute('srcset');
335
- });
336
- style.textContent = style.textContent.replace(/url\\(\\s*(['"]?)([^'"\\)]+)\\1\\s*\\)/gi, (_all, quote, raw) => 'url("' + replaceAsset(raw) + '")');
337
- return '<!doctype html>\\n' + clone.outerHTML;
338
- })()`
339
- const result = await pipe.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, sessionId)
340
- if (result.exceptionDetails || typeof result.result?.value !== 'string') {
341
- const detail = result.exceptionDetails?.exception?.description || result.exceptionDetails?.text || result.result?.description
342
- throw new Error(`Could not freeze the rendered preview for Design${detail ? `: ${detail}` : '.'}`)
343
- }
344
- const html = result.result.value
345
- if (!html.trim() || Buffer.byteLength(html) > MAX_PORTABLE_SNAPSHOT_BYTES) throw new Error('The rendered preview is too large to make editable.')
346
- return html
347
- })
402
+ return this.withPage({ url, viewport, waitMs }, ({ pipe, sessionId }) => this.freezeCurrentPage(pipe, sessionId))
348
403
  }
349
404
 
350
405
  async inspect({ url, viewport = DEFAULT_VIEWPORTS.mobile, selector, waitMs = 300 }) {
@@ -424,33 +479,12 @@ export class ViewportManager {
424
479
  return new URL(normalizeRoute(route), preview.url).href
425
480
  }
426
481
 
427
- async capture({ route = '/', viewports = 'both', title = 'Viewport capture', fullPage = true, waitMs = 300 } = {}) {
482
+ async capture({ route = '/', viewports = 'both', title = 'Viewport capture', fullPage = true, waitMs = 300, card = false } = {}) {
428
483
  const normalizedRoute = normalizeRoute(route)
429
484
  const url = this.pageUrl(normalizedRoute)
430
485
  const names = viewports === 'both' ? ['desktop', 'mobile'] : [viewports]
431
486
  if (names.some((name) => !DEFAULT_VIEWPORTS[name])) throw new Error('viewports must be "both", "desktop", or "mobile".')
432
- await fsp.mkdir(this.outbox, { recursive: true })
433
487
  const slug = `${safeSlug(title)}-${Date.now().toString(36)}`
434
- // A preview browser is deliberately isolated and cannot inherit the room
435
- // member's Supabase session. Catch the production failure mode where an
436
- // agent requests /?r=ROOM expecting Code, but actually renders the public
437
- // invitation landing and publishes it under an unrelated feature title.
438
- // Validate before writing screenshots or a manifest so no misleading card
439
- // can enter the room.
440
- const snapshotViewport = names.includes('desktop') ? DEFAULT_VIEWPORTS.desktop : DEFAULT_VIEWPORTS[names[0]]
441
- const snapshot = await this.browser.snapshot({ url, viewport: snapshotViewport, waitMs })
442
- if (isSignedOutRoomPreview(normalizedRoute, snapshot)) {
443
- throw new Error('Preview resolved to the signed-out room invitation instead of the authenticated Code room. preview_capture uses a fresh isolated browser and cannot inherit a person\'s login. Use an authenticated visual harness or signed-in browser evidence; no mockup card was created.')
444
- }
445
- const captures = {}
446
- for (const name of names) {
447
- const result = await this.browser.capture({ url, viewport: DEFAULT_VIEWPORTS[name], fullPage, waitMs })
448
- const file = path.join(this.outbox, `${slug}--${name}.png`)
449
- await fsp.writeFile(file, result.png)
450
- captures[name] = { ...result, file }
451
- }
452
- const snapshotPath = path.join(this.outbox, `${slug}--source.html`)
453
- await fsp.writeFile(snapshotPath, snapshot)
454
488
  const workspace = await fsp.realpath(this.workspaceRoot)
455
489
  const previewRoot = path.relative(workspace, this.root) || '.'
456
490
  const captureKey = JSON.stringify([previewRoot, normalizedRoute])
@@ -458,16 +492,45 @@ export class ViewportManager {
458
492
  const correlation = active?.captureKey === captureKey
459
493
  ? { designRequestId: active.requestId, parentRevision: active.parentRevision }
460
494
  : {}
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)
495
+ // A correlated Design revision is already an explicit room deliverable;
496
+ // ordinary verification still needs the caller to opt in with card=true.
497
+ const cardRequested = card === true || !!correlation.designRequestId
498
+ const captures = {}
499
+ for (const name of names) {
500
+ captures[name] = await this.browser.capture({
501
+ url, viewport: DEFAULT_VIEWPORTS[name], fullPage, waitMs,
502
+ includeSnapshot: cardRequested && name === (names.includes('desktop') ? 'desktop' : names[0]),
503
+ })
504
+ }
505
+ // The screenshot and readiness state must come from the SAME page. The old
506
+ // preflight loaded one page and then opened fresh pages for each PNG;
507
+ // production proved the preflight could see marketing content while the
508
+ // screenshot caught only the boot spinner.
509
+ const cardReady = cardRequested && !!(captures.desktop && captures.mobile)
510
+ if (cardReady) {
511
+ for (const [name, capture] of Object.entries(captures)) {
512
+ const issue = previewArtifactIssue(normalizedRoute, capture)
513
+ if (issue) throw new Error(`${name} deliverable rejected: ${issue} No mockup card was created.`)
514
+ }
515
+ }
516
+ const snapshot = captures.desktop?.snapshot || captures.mobile?.snapshot || null
517
+ if (cardReady && (typeof snapshot !== 'string' || !snapshot.trim())) {
518
+ throw new Error('The deliverable could not freeze its rendered source. No mockup card was created.')
519
+ }
520
+ await fsp.mkdir(this.outbox, { recursive: true })
521
+ for (const [name, capture] of Object.entries(captures)) {
522
+ capture.file = path.join(this.outbox, `${slug}--${name}.png`)
523
+ await fsp.writeFile(capture.file, capture.png)
524
+ }
525
+ const snapshotPath = cardReady ? path.join(this.outbox, `${slug}--source.html`) : null
526
+ if (snapshotPath) await fsp.writeFile(snapshotPath, snapshot)
465
527
  let manifestPath = null
466
528
  if (cardReady) {
467
529
  const manifest = {
468
530
  slug, title: String(title || 'Viewport capture').slice(0, 120),
469
531
  desktop: captures.desktop.file, mobile: captures.mobile.file, ts: Date.now(),
470
- sourceKind: 'preview', snapshot: snapshotPath, previewRoot, route: normalizedRoute, captureKey,
532
+ sourceKind: 'preview', deliveryIntent: 'card', readinessValidated: true,
533
+ snapshot: snapshotPath, previewRoot, route: normalizedRoute, captureKey,
471
534
  ...correlation,
472
535
  }
473
536
  manifestPath = path.join(this.outbox, `${slug}.json`)
@@ -509,23 +572,24 @@ export function createViewportTools({ tool, z, manager }) {
509
572
  ),
510
573
  tool(
511
574
  'preview_capture',
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.',
575
+ 'Capture this lane preview in a fresh isolated browser at exact desktop (1440×900) and mobile (390×844) CSS viewports. Defaults to both and full-page. Captures are inline verification evidence by default. Set card=true only for a user-requested mockup or visual deliverable that should remain editable in the transcript; complete desktop+mobile settled content is then required and the card arrives after the final response. Authenticated app routes require an authenticated visual harness.',
513
576
  {
514
577
  path: z.string().max(500).optional().describe('route within the preview, e.g. / or /settings; never a full URL'),
515
578
  viewports: z.enum(['both', 'desktop', 'mobile']).optional(),
516
579
  title: z.string().max(120).optional(),
517
580
  fullPage: z.boolean().optional(),
581
+ card: z.boolean().optional().describe('default false; true only for an intentional user-facing mockup/Design deliverable'),
518
582
  waitMs: z.number().int().min(0).max(MAX_SETTLE_MS).optional().describe('settle time after load, max 5000ms'),
519
583
  },
520
584
  async (args) => {
521
585
  try {
522
586
  const result = await manager.capture({
523
587
  route: args?.path || '/', viewports: args?.viewports || 'both', title: args?.title || 'Viewport capture',
524
- fullPage: args?.fullPage !== false, waitMs: args?.waitMs ?? 300,
588
+ fullPage: args?.fullPage !== false, waitMs: args?.waitMs ?? 300, card: args?.card === true,
525
589
  })
526
590
  const delivery = result.cardReady
527
591
  ? 'The complete room preview card is queued after your final response.'
528
- : 'This is partial viewport evidence only; no room card was created.'
592
+ : 'This is verification evidence only; no room card was created.'
529
593
  const content = [{ type: 'text', text: `Captured ${Object.keys(result.captures).join(' + ')} for ${args?.path || '/'}. ${delivery}` }]
530
594
  for (const [name, capture] of Object.entries(result.captures)) {
531
595
  content.push({ type: 'text', text: `${name}: ${capture.width}×${capture.height}${capture.capped ? ' (height capped)' : ''}` })