thinkpool-pair 0.7.306 → 0.7.308

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/account.mjs CHANGED
@@ -28,6 +28,22 @@ const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./pack
28
28
 
29
29
  const BRIDGE = fileURLToPath(new URL('./bridge.mjs', import.meta.url))
30
30
 
31
+ // Startup can spend several seconds waiting on the cross-process refresh lock or
32
+ // backing off after a transient auth failure. Those sleeps are intentionally unref'd
33
+ // once the supervisor is running, but before Realtime + the 15s tick exist they leave
34
+ // an unresolved top-level await as Node's only work; Node 22 exits 13 with
35
+ // "Detected unsettled top-level await". Hold one referenced timer only for bootstrap,
36
+ // then release it as soon as the supervisor's real lifetime interval exists.
37
+ export function createStartupAnchor({ set = setInterval, clear = clearInterval } = {}) {
38
+ const timer = set(() => {}, 60_000)
39
+ let released = false
40
+ return () => {
41
+ if (released) return
42
+ released = true
43
+ clear(timer)
44
+ }
45
+ }
46
+
31
47
  // ── single-instance lock ────────────────────────────────────────────────────
32
48
  // Two account supervisors on one machine share ONE ~/.thinkpool-pair/auth.json
33
49
  // refresh token and race its rotation — which on 2026-06-17 deleted a live Pro
@@ -360,6 +376,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
360
376
  console.error('\n ◇ An account bridge is already running on this machine\n (~/.thinkpool-pair/account.lock). Not starting a second — two would race\n your saved login. Stop the other one first, or share a single room with\n `npx thinkpool-pair <ROOM>`.\n')
361
377
  process.exit(0)
362
378
  }
379
+ const releaseStartupAnchor = createStartupAnchor()
363
380
  // F2: NEVER exit(1) on a refresh failure. The old code exited 1 here, and launchd
364
381
  // KeepAlive respawned us within seconds → refresh again → the 16× crash storm (and,
365
382
  // worse, hammering the refresh path burns rotations and can revoke the token family).
@@ -1043,6 +1060,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1043
1060
 
1044
1061
  await tick()
1045
1062
  const iv = setInterval(tick, 15000)
1063
+ releaseStartupAnchor()
1046
1064
  const updateTimers = [] // auto-update poll/apply timers — cleared in stop() so none fire post-exit
1047
1065
  let stopping = false
1048
1066
 
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
 
@@ -248,7 +249,12 @@ if (argv[0] === 'install-service' || argv[0] === 'uninstall-service') {
248
249
  // always-on service); --auto-update opts into @latest tracking. (2026-06-22)
249
250
  const autoUpdate = argv.includes('--auto-update')
250
251
  const svc = await import('./service.mjs')
251
- if (argv[0] === 'install-service') svc.installService(svcRoom, svcCmd, { autoUpdate })
252
+ if (argv[0] === 'install-service') {
253
+ const ok = autoUpdate
254
+ ? svc.installService(svcRoom, svcCmd, { autoUpdate })
255
+ : await svc.installAndConfirmService(svcRoom, svcCmd)
256
+ process.exit(ok === false ? 1 : 0)
257
+ }
252
258
  else svc.uninstallService(svcRoom)
253
259
  process.exit(0)
254
260
  }
@@ -349,10 +355,9 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
349
355
  child.on('exit', (code) => process.exit(code == null ? 0 : code))
350
356
  }),
351
357
  serveAccountForeground: async () => { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) },
352
- // install / uninstall are NOT terminal — they do their work, print their notes,
353
- // and return to the menu. A confirmed update closes the launcher instead: its
354
- // in-memory package version is stale while the new service runs independently.
355
- installService: ({ room = null, agentCmd } = {}) => { svc.installService(room, agentCmd ? [agentCmd] : []) },
358
+ // Confirmed installs/updates close the launcher: the service is independently
359
+ // running and another menu cycle can only race it. Failures return to the menu.
360
+ installService: ({ room = null, agentCmd } = {}) => svc.installAndConfirmService(room, agentCmd ? [agentCmd] : []),
356
361
  uninstallService: ({ room = null } = {}) => { svc.uninstallService(room) },
357
362
  restartService: ({ room = null } = {}) => { svc.restartService(room) },
358
363
  // The menu waits for OS-level proof before claiming success. In-service web
@@ -1549,37 +1554,49 @@ function pumpDesign(term) {
1549
1554
  // landed in *is* its provenance. (Before: a per-room outbox + a "first session
1550
1555
  // in the Map" guess meant closing the generating terminal moved the cards to
1551
1556
  // whatever terminal was now first — they leaked into Terminal 1.)
1552
- const handleManifest = async (box, file, term, trustedDesignSource = false) => {
1557
+ const writeMockupReceipt = (box, slug, ok, status) => {
1558
+ try {
1559
+ const rc = path.join(box, `${slug}.receipt.json`)
1560
+ const tmp = `${rc}.tmp`
1561
+ fs.writeFileSync(tmp, JSON.stringify({ ok, status, ts: Date.now() }))
1562
+ fs.renameSync(tmp, rc)
1563
+ } catch { /* receipts are best-effort */ }
1564
+ }
1565
+
1566
+ const prepareManifest = (box, file, term, trustedDesignSource = false) => {
1553
1567
  // Ignore our own delivery receipts — writing <slug>.receipt.json into the
1554
1568
  // watched outbox re-fires fs.watch; without this guard it would re-trigger
1555
1569
  // handleManifest in a loop (it still ends in .json). Must come first.
1556
- if (!file || file.endsWith('.receipt.json') || !file.endsWith('.json')) return
1570
+ if (!file || file.endsWith('.receipt.json') || !file.endsWith('.json')) return null
1557
1571
  const full = path.join(box, file)
1558
1572
  let stat
1559
- try { stat = fs.statSync(full) } catch { return } // tmp/removed mid-write
1573
+ try { stat = fs.statSync(full) } catch { return null } // tmp/removed mid-write
1560
1574
  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
1575
  const seenKey = `${term}/${slug}`
1573
- if (mockupSeen.get(seenKey) === stat.mtimeMs) return // already handled
1576
+ if (mockupSeen.get(seenKey) === stat.mtimeMs) return null // already handled
1574
1577
  mockupSeen.set(seenKey, stat.mtimeMs)
1575
- if (!term) return // nothing to attach the card to yet
1578
+ if (!term) return null // nothing to attach the card to yet
1576
1579
  let m
1577
- try { m = JSON.parse(fs.readFileSync(full, 'utf8')) } catch { return }
1578
- if (!m?.slug) return
1580
+ try { m = JSON.parse(fs.readFileSync(full, 'utf8')) } catch { return null }
1581
+ if (!m?.slug) return null
1582
+ if (!completeMockupManifest(m, { isReadyFile: (asset) => {
1583
+ try { const ready = fs.statSync(asset); return ready.isFile() && ready.size > 0 } catch { return false }
1584
+ } })) {
1585
+ writeMockupReceipt(box, slug, false, 'incomplete')
1586
+ process.stderr.write(`\n ◇ mockup "${m.title || m.slug}" is incomplete — desktop, mobile, and source are all required.\n`)
1587
+ return null
1588
+ }
1589
+ return { box, file, term, trustedDesignSource, slug, m }
1590
+ }
1591
+
1592
+ const handleManifest = async ({ box, term, trustedDesignSource = false, slug, m }) => {
1579
1593
  const producer = trustedDesignSource ? sessions.get(term) : null
1580
1594
  const designRecord = producer
1581
1595
  ? resolveManifestDesignSource(m, producer.cwd || process.cwd(), { box })
1582
1596
  : null
1597
+ const displaySource = designRecord
1598
+ ? resolveManifestDisplaySource(m, designRecord, { box })
1599
+ : null
1583
1600
  // 2026-07-07: these used to swallow read errors silently — a transient
1584
1601
  // unreadable file (race with the render script, permissions, mid-write)
1585
1602
  // meant the manifest still POSTed with that field missing, and nothing
@@ -1594,6 +1611,15 @@ const handleManifest = async (box, file, term, trustedDesignSource = false) => {
1594
1611
  catch (e) { if (p) process.stderr.write(`\n ◇ mockup asset unreadable: ${p} (${e?.code || e?.message || e})\n`); return null }
1595
1612
  }
1596
1613
  const cid = randomUUID()
1614
+ // The card belongs after the final agent response, not at render time. This
1615
+ // timestamp is also persisted by /api/code-mockup, so reload ordering matches
1616
+ // the live transcript instead of jumping the card back above the answer.
1617
+ const deliveryTs = Date.now()
1618
+ if (designRecord && !displaySource) {
1619
+ writeMockupReceipt(box, slug, false, 'invalid-snapshot')
1620
+ process.stderr.write(`\n ◇ mockup "${m.slug}" rendered snapshot was not trusted.\n`)
1621
+ return
1622
+ }
1597
1623
  try {
1598
1624
  const mockupHeaders = { 'Content-Type': 'application/json' }
1599
1625
  // Authenticate as the owner so the server can attribute the persisted row to a
@@ -1604,19 +1630,19 @@ const handleManifest = async (box, file, term, trustedDesignSource = false) => {
1604
1630
  method: 'POST',
1605
1631
  headers: mockupHeaders,
1606
1632
  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),
1633
+ code: room, slug: m.slug, title: m.title, cid, ts: deliveryTs, term,
1634
+ desktopPng: readB64(m.desktop), mobilePng: readB64(m.mobile), html: designRecord ? displaySource : readTxt(m.html),
1609
1635
  ...(designRecord ? { previewId: designRecord.previewId, revision: designRecord.revision, sourceKnown: true } : {}),
1610
1636
  }),
1611
1637
  })
1612
1638
  if (!res.ok) {
1613
- writeReceipt(false, res.status)
1639
+ writeMockupReceipt(box, slug, false, res.status)
1614
1640
  process.stderr.write(`\n ◇ mockup "${m.slug}" upload failed (${res.status}).\n`); return
1615
1641
  }
1616
1642
  const uploaded = await res.json()
1617
1643
  const { paths } = uploaded
1618
1644
  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,
1645
+ const artifact = { kind: 'mockup', __struct: true, cid, term, slug: m.slug, title: m.title || m.slug, ts: deliveryTs, paths,
1620
1646
  ...(designAccepted ? { previewId: designRecord.previewId, revision: designRecord.revision, sourceKnown: true } : {}) }
1621
1647
  if (designAccepted) {
1622
1648
  Object.assign(designRecord, { term, slug: m.slug, title: m.title || m.slug, desktop: m.desktop, mobile: m.mobile })
@@ -1642,19 +1668,35 @@ const handleManifest = async (box, file, term, trustedDesignSource = false) => {
1642
1668
  }
1643
1669
  }
1644
1670
  bcast('code-event', { term, evt: artifact })
1645
- writeReceipt(true, 200)
1671
+ writeMockupReceipt(box, slug, true, 200)
1646
1672
  process.stderr.write(`\n ◆ mockup "${m.title || m.slug}" pushed to the room.\n`)
1647
1673
  } catch (e) {
1648
- writeReceipt(false, 'error')
1674
+ writeMockupReceipt(box, slug, false, 'error')
1649
1675
  process.stderr.write(`\n ◇ mockup "${m.slug}" send failed: ${e?.message || e}\n`)
1650
1676
  }
1651
1677
  }
1678
+
1679
+ const mockupDeliveries = new MockupDeliveryQueue({ deliver: handleManifest })
1680
+
1681
+ function receiveManifest(box, file, term, trustedDesignSource = false) {
1682
+ const item = prepareManifest(box, file, term, trustedDesignSource)
1683
+ if (!item) return
1684
+ const lane = sessions.get(term)
1685
+ const active = !!lane && (lane._busyAnn === true || lane.session?.turnActive === true)
1686
+ const accepted = mockupDeliveries.enqueue(term, item, { active })
1687
+ if (accepted.queued) {
1688
+ writeMockupReceipt(box, item.slug, true, 'queued')
1689
+ process.stderr.write(`\n ◆ mockup "${item.m.title || item.m.slug}" ready — queued after the final response.\n`)
1690
+ } else {
1691
+ accepted.delivery.catch(() => {})
1692
+ }
1693
+ }
1652
1694
  // Watch one outbox dir; `ownerFn()` resolves which terminal owns a manifest
1653
1695
  // dropped there (lazily, per event). Returns the FSWatcher so per-session/term
1654
1696
  // watchers can be torn down when their owner ends. Safe to call repeatedly.
1655
1697
  function watchOutbox(box, ownerFn, trustedDesignSource = false) {
1656
1698
  try { fs.mkdirSync(box, { recursive: true }) } catch { return null }
1657
- try { return fs.watch(box, (_evt, file) => { handleManifest(box, file, ownerFn(), trustedDesignSource).catch(() => {}) }) }
1699
+ try { return fs.watch(box, (_evt, file) => receiveManifest(box, file, ownerFn(), trustedDesignSource)) }
1658
1700
  catch { return null } // fs.watch unsupported — mockups simply won't auto-surface
1659
1701
  }
1660
1702
  // A per-owner outbox lives at MOCKUP_OUTBOX/<kind>/<id> and is handed to that
@@ -3053,6 +3095,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3053
3095
  // after the final queued boundary.
3054
3096
  const terminalBoundary = !continuesQueued && evt.kind === 'result'
3055
3097
  const busyChanged = terminalBoundary ? settleLaneBusy(entry) : syncStructuredTurn(entry)
3098
+ const mockupDeliveryBoundary = isMockupDeliveryBoundary(evt, { turnActive: entry.session?.turnActive === true })
3056
3099
  stampStructuredTurn(entry, evt)
3057
3100
  stampEvent(evt)
3058
3101
  const stalledChanged = evt.kind === 'stalled' ? !entry.stalled : !!entry.stalled
@@ -3306,6 +3349,12 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3306
3349
  bcast('code-event', { term: id, evt: e })
3307
3350
  printLocal(e)
3308
3351
  if (!chrome) persist()
3352
+ // Result/error has now crossed the same ordered emission boundary as the
3353
+ // final assistant text (including the image queue). Only now may ready
3354
+ // mockups be uploaded, persisted, and broadcast below that answer.
3355
+ if (mockupDeliveryBoundary) {
3356
+ queueMicrotask(() => { mockupDeliveries.flush(id).catch(() => {}) })
3357
+ }
3309
3358
  }
3310
3359
  // A tool_result carrying an inline base64 image can't ride a broadcast frame
3311
3360
  // (live OR replay). Lift it to Storage FIRST, then emit the URL-only event —
@@ -3542,6 +3591,7 @@ function endStructured(id) {
3542
3591
  const s = sessions.get(id)
3543
3592
  if (s) {
3544
3593
  s.imageQueue?.close()
3594
+ mockupDeliveries.clear(id)
3545
3595
  drainPending(s)
3546
3596
  try { s.session?.end() } catch { /* noop */ }
3547
3597
  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)
package/launcher.mjs CHANGED
@@ -277,7 +277,13 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
277
277
  const pick = items[await askChoice('choose', items)]
278
278
  if (pick.key === 'quit') return
279
279
  if (pick.key === 'serve') { if (await ensureLoggedIn()) await actions.serveAccountForeground() }
280
- else if (pick.key === 'service') { if (await ensureLoggedIn()) { await actions.installService({ room: null }); resync() } }
280
+ else if (pick.key === 'service') {
281
+ if (await ensureLoggedIn()) {
282
+ const installed = await actions.installService({ room: null })
283
+ if (installed === true) return
284
+ resync()
285
+ }
286
+ }
281
287
  else if (pick.key === 'restart') {
282
288
  const updated = await actions.restartUpdateService({ room: null })
283
289
  // The launcher process cannot update its own in-memory VERSION. Returning to
@@ -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.308",
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",
package/service.mjs CHANGED
@@ -456,6 +456,65 @@ export function installService(room, cmdArgs = [], { autoUpdate = false, version
456
456
  return true
457
457
  }
458
458
 
459
+ // Interactive installs must not present an armed launchd helper as a completed
460
+ // background service. installService() intentionally returns after staging because
461
+ // an in-service update may tear down its caller; this menu/CLI primitive waits for the
462
+ // helper receipt AND the exact live runtime before returning success.
463
+ export async function installAndConfirmService(room, cmdArgs = [], {
464
+ platform = process.platform,
465
+ version = VERSION,
466
+ staleProof = true,
467
+ install = installService,
468
+ exec = execSync,
469
+ snapshot = serviceRuntimeSnapshot,
470
+ readStatus = () => {
471
+ try { return JSON.parse(fs.readFileSync(path.join(os.homedir(), '.thinkpool-pair', 'update-status.json'), 'utf8')) } catch { return null }
472
+ },
473
+ now = Date.now,
474
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
475
+ timeoutMs = 55000,
476
+ pollMs = 250,
477
+ stderr = process.stderr,
478
+ } = {}) {
479
+ if (install(room, cmdArgs, { version, staleProof }) === false) return false
480
+
481
+ const confirmation = () => {
482
+ const live = snapshot(room, { platform, exec })
483
+ if (live?.version === version) {
484
+ stderr.write(` ✓ bridge install confirmed — running v${version}.\n`)
485
+ return true
486
+ }
487
+ return false
488
+ }
489
+
490
+ // systemd's restart is synchronous. Windows cannot prove a live Startup process,
491
+ // so this exact-runtime confirmation is used only where a service manager exists.
492
+ if (platform !== 'darwin') {
493
+ if (platform === 'win32') return true
494
+ const confirmed = confirmation()
495
+ if (!confirmed) stderr.write(` ⚠ install v${version} was not confirmed: the service manager cannot prove that runtime is running.\n`)
496
+ return confirmed
497
+ }
498
+
499
+ const deadline = now() + Math.max(0, Number(timeoutMs) || 0)
500
+ const expectedTarget = label(room)
501
+ for (;;) {
502
+ const status = readStatus()
503
+ if (status && typeof status === 'object' && status.target === expectedTarget && status.version === version) {
504
+ if (status.ok === false) {
505
+ stderr.write(` ⚠ install v${version} was not confirmed: ${status.error || 'the launchd handoff failed'}.\n`)
506
+ return false
507
+ }
508
+ if (status.ok === true && confirmation()) return true
509
+ }
510
+ if (now() >= deadline) {
511
+ stderr.write(` ⚠ install v${version} timed out waiting for launchd confirmation; no running service was proven.\n`)
512
+ return false
513
+ }
514
+ await sleep(Math.max(1, Number(pollMs) || 1))
515
+ }
516
+ }
517
+
459
518
  // Restart an already-installed boot-persistent service. launchd/systemd relaunch
460
519
  // the stable runtime (or legacy auto-update npx command), so active sessions resume
461
520
  // from disk. Use it to recover a wedged bridge or re-read provider.json.
@@ -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' })