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 +2 -1
- package/account.mjs +18 -0
- package/bridge.mjs +81 -31
- package/design-edit.mjs +19 -0
- package/launcher.mjs +7 -1
- package/mockup-delivery.mjs +50 -0
- package/package.json +2 -1
- package/service.mjs +59 -0
- package/thinkpool-capabilities.json +6 -6
- package/viewport.mjs +22 -12
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
|
|
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')
|
|
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
|
-
//
|
|
353
|
-
// and
|
|
354
|
-
|
|
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
|
|
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 }
|
|
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
|
|
1576
|
+
if (mockupSeen.get(seenKey) === stat.mtimeMs) return null // already handled
|
|
1574
1577
|
mockupSeen.set(seenKey, stat.mtimeMs)
|
|
1575
|
-
if (!term) return
|
|
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:
|
|
1608
|
-
desktopPng: readB64(m.desktop), mobilePng: readB64(m.mobile), html: designRecord ?
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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') {
|
|
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.
|
|
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":
|
|
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":
|
|
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":
|
|
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.
|
|
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
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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' })
|