thinkpool-pair 0.7.305 → 0.7.307
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/bridge.mjs +72 -26
- package/design-edit.mjs +19 -0
- package/flow-models.mjs +4 -3
- package/hermes-acp-bootstrap.py +18 -0
- package/hermes-probe.mjs +4 -0
- package/hermes-setup.mjs +10 -0
- package/mockup-delivery.mjs +50 -0
- package/package.json +2 -1
- 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/bridge.mjs
CHANGED
|
@@ -77,7 +77,7 @@ import { writeLaneArtifact, digestSlice, appendDigest, resumeLane } from './flow
|
|
|
77
77
|
import { createFlowWorktree, worktreeSpec } from './flow-worktree.mjs'
|
|
78
78
|
import { startPreview, stopAllPreviews, previews } from './flow-preview.mjs'
|
|
79
79
|
import { ViewportManager, createViewportTools, sharedViewportBrowser } from './viewport.mjs'
|
|
80
|
-
import { deleteDesignAssetDraft, designPrompt, designTranscript, materializeDesignAsset, refreshDesignSource, resolveManifestDesignSource, restoreDesignSources, syncRealtimeAuth, validateDesignBatchRequest } from './design-edit.mjs'
|
|
80
|
+
import { deleteDesignAssetDraft, designPrompt, designTranscript, materializeDesignAsset, refreshDesignSource, resolveManifestDesignSource, resolveManifestDisplaySource, restoreDesignSources, syncRealtimeAuth, validateDesignBatchRequest } from './design-edit.mjs'
|
|
81
81
|
// FL-M9 — per-lane preview servers leak (one per done lane, never stopped until shutdown).
|
|
82
82
|
// Lane previews are keyed `lane:<flowId>:<laneId>`; stop a whole flow's set when it assembles
|
|
83
83
|
// (the assembled preview supersedes them) or when a lane is reverted.
|
|
@@ -128,6 +128,7 @@ import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantText
|
|
|
128
128
|
import { planMeterLine } from './plan-meters.mjs'
|
|
129
129
|
import { priceForModel } from './model-prices.mjs'
|
|
130
130
|
import { makeThrottledTrack } from './presence.mjs'
|
|
131
|
+
import { MockupDeliveryQueue, completeMockupManifest, isMockupDeliveryBoundary } from './mockup-delivery.mjs'
|
|
131
132
|
import { resolveAnonKey, DEFAULT_SUPABASE_URL } from './supabase-key.mjs'
|
|
132
133
|
import { buildTerminalRolePrompt, HERMES_VISIBLE_WORKER_FALLBACK_RULE, THINKPOOL_PROMPT_BUNDLE } from './thinkpool-room-prompt.mjs'
|
|
133
134
|
|
|
@@ -1549,37 +1550,49 @@ function pumpDesign(term) {
|
|
|
1549
1550
|
// landed in *is* its provenance. (Before: a per-room outbox + a "first session
|
|
1550
1551
|
// in the Map" guess meant closing the generating terminal moved the cards to
|
|
1551
1552
|
// whatever terminal was now first — they leaked into Terminal 1.)
|
|
1552
|
-
const
|
|
1553
|
+
const writeMockupReceipt = (box, slug, ok, status) => {
|
|
1554
|
+
try {
|
|
1555
|
+
const rc = path.join(box, `${slug}.receipt.json`)
|
|
1556
|
+
const tmp = `${rc}.tmp`
|
|
1557
|
+
fs.writeFileSync(tmp, JSON.stringify({ ok, status, ts: Date.now() }))
|
|
1558
|
+
fs.renameSync(tmp, rc)
|
|
1559
|
+
} catch { /* receipts are best-effort */ }
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
const prepareManifest = (box, file, term, trustedDesignSource = false) => {
|
|
1553
1563
|
// Ignore our own delivery receipts — writing <slug>.receipt.json into the
|
|
1554
1564
|
// watched outbox re-fires fs.watch; without this guard it would re-trigger
|
|
1555
1565
|
// handleManifest in a loop (it still ends in .json). Must come first.
|
|
1556
|
-
if (!file || file.endsWith('.receipt.json') || !file.endsWith('.json')) return
|
|
1566
|
+
if (!file || file.endsWith('.receipt.json') || !file.endsWith('.json')) return null
|
|
1557
1567
|
const full = path.join(box, file)
|
|
1558
1568
|
let stat
|
|
1559
|
-
try { stat = fs.statSync(full) } catch { return }
|
|
1569
|
+
try { stat = fs.statSync(full) } catch { return null } // tmp/removed mid-write
|
|
1560
1570
|
const slug = file.replace(/\.json$/, '')
|
|
1561
|
-
// Delivery receipt (atomic tmp+mv) so render.sh can confirm the card actually
|
|
1562
|
-
// reached the room — written on success AND failure. Best-effort: a receipt
|
|
1563
|
-
// that can't be written just leaves render.sh at "delivery unconfirmed".
|
|
1564
|
-
const writeReceipt = (ok, status) => {
|
|
1565
|
-
try {
|
|
1566
|
-
const rc = path.join(box, `${slug}.receipt.json`)
|
|
1567
|
-
const tmp = `${rc}.tmp`
|
|
1568
|
-
fs.writeFileSync(tmp, JSON.stringify({ ok, status, ts: Date.now() }))
|
|
1569
|
-
fs.renameSync(tmp, rc)
|
|
1570
|
-
} catch { /* receipts are best-effort */ }
|
|
1571
|
-
}
|
|
1572
1571
|
const seenKey = `${term}/${slug}`
|
|
1573
|
-
if (mockupSeen.get(seenKey) === stat.mtimeMs) return
|
|
1572
|
+
if (mockupSeen.get(seenKey) === stat.mtimeMs) return null // already handled
|
|
1574
1573
|
mockupSeen.set(seenKey, stat.mtimeMs)
|
|
1575
|
-
if (!term) return
|
|
1574
|
+
if (!term) return null // nothing to attach the card to yet
|
|
1576
1575
|
let m
|
|
1577
|
-
try { m = JSON.parse(fs.readFileSync(full, 'utf8')) } catch { return }
|
|
1578
|
-
if (!m?.slug) return
|
|
1576
|
+
try { m = JSON.parse(fs.readFileSync(full, 'utf8')) } catch { return null }
|
|
1577
|
+
if (!m?.slug) return null
|
|
1578
|
+
if (!completeMockupManifest(m, { isReadyFile: (asset) => {
|
|
1579
|
+
try { const ready = fs.statSync(asset); return ready.isFile() && ready.size > 0 } catch { return false }
|
|
1580
|
+
} })) {
|
|
1581
|
+
writeMockupReceipt(box, slug, false, 'incomplete')
|
|
1582
|
+
process.stderr.write(`\n ◇ mockup "${m.title || m.slug}" is incomplete — desktop, mobile, and source are all required.\n`)
|
|
1583
|
+
return null
|
|
1584
|
+
}
|
|
1585
|
+
return { box, file, term, trustedDesignSource, slug, m }
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
const handleManifest = async ({ box, term, trustedDesignSource = false, slug, m }) => {
|
|
1579
1589
|
const producer = trustedDesignSource ? sessions.get(term) : null
|
|
1580
1590
|
const designRecord = producer
|
|
1581
1591
|
? resolveManifestDesignSource(m, producer.cwd || process.cwd(), { box })
|
|
1582
1592
|
: null
|
|
1593
|
+
const displaySource = designRecord
|
|
1594
|
+
? resolveManifestDisplaySource(m, designRecord, { box })
|
|
1595
|
+
: null
|
|
1583
1596
|
// 2026-07-07: these used to swallow read errors silently — a transient
|
|
1584
1597
|
// unreadable file (race with the render script, permissions, mid-write)
|
|
1585
1598
|
// meant the manifest still POSTed with that field missing, and nothing
|
|
@@ -1594,6 +1607,15 @@ const handleManifest = async (box, file, term, trustedDesignSource = false) => {
|
|
|
1594
1607
|
catch (e) { if (p) process.stderr.write(`\n ◇ mockup asset unreadable: ${p} (${e?.code || e?.message || e})\n`); return null }
|
|
1595
1608
|
}
|
|
1596
1609
|
const cid = randomUUID()
|
|
1610
|
+
// The card belongs after the final agent response, not at render time. This
|
|
1611
|
+
// timestamp is also persisted by /api/code-mockup, so reload ordering matches
|
|
1612
|
+
// the live transcript instead of jumping the card back above the answer.
|
|
1613
|
+
const deliveryTs = Date.now()
|
|
1614
|
+
if (designRecord && !displaySource) {
|
|
1615
|
+
writeMockupReceipt(box, slug, false, 'invalid-snapshot')
|
|
1616
|
+
process.stderr.write(`\n ◇ mockup "${m.slug}" rendered snapshot was not trusted.\n`)
|
|
1617
|
+
return
|
|
1618
|
+
}
|
|
1597
1619
|
try {
|
|
1598
1620
|
const mockupHeaders = { 'Content-Type': 'application/json' }
|
|
1599
1621
|
// Authenticate as the owner so the server can attribute the persisted row to a
|
|
@@ -1604,19 +1626,19 @@ const handleManifest = async (box, file, term, trustedDesignSource = false) => {
|
|
|
1604
1626
|
method: 'POST',
|
|
1605
1627
|
headers: mockupHeaders,
|
|
1606
1628
|
body: JSON.stringify({
|
|
1607
|
-
code: room, slug: m.slug, title: m.title, cid, ts:
|
|
1608
|
-
desktopPng: readB64(m.desktop), mobilePng: readB64(m.mobile), html: designRecord ?
|
|
1629
|
+
code: room, slug: m.slug, title: m.title, cid, ts: deliveryTs, term,
|
|
1630
|
+
desktopPng: readB64(m.desktop), mobilePng: readB64(m.mobile), html: designRecord ? displaySource : readTxt(m.html),
|
|
1609
1631
|
...(designRecord ? { previewId: designRecord.previewId, revision: designRecord.revision, sourceKnown: true } : {}),
|
|
1610
1632
|
}),
|
|
1611
1633
|
})
|
|
1612
1634
|
if (!res.ok) {
|
|
1613
|
-
|
|
1635
|
+
writeMockupReceipt(box, slug, false, res.status)
|
|
1614
1636
|
process.stderr.write(`\n ◇ mockup "${m.slug}" upload failed (${res.status}).\n`); return
|
|
1615
1637
|
}
|
|
1616
1638
|
const uploaded = await res.json()
|
|
1617
1639
|
const { paths } = uploaded
|
|
1618
1640
|
const designAccepted = !!(designRecord && uploaded?.payload?.sourceKnown)
|
|
1619
|
-
const artifact = { kind: 'mockup', __struct: true, cid, term, slug: m.slug, title: m.title || m.slug, ts:
|
|
1641
|
+
const artifact = { kind: 'mockup', __struct: true, cid, term, slug: m.slug, title: m.title || m.slug, ts: deliveryTs, paths,
|
|
1620
1642
|
...(designAccepted ? { previewId: designRecord.previewId, revision: designRecord.revision, sourceKnown: true } : {}) }
|
|
1621
1643
|
if (designAccepted) {
|
|
1622
1644
|
Object.assign(designRecord, { term, slug: m.slug, title: m.title || m.slug, desktop: m.desktop, mobile: m.mobile })
|
|
@@ -1642,19 +1664,35 @@ const handleManifest = async (box, file, term, trustedDesignSource = false) => {
|
|
|
1642
1664
|
}
|
|
1643
1665
|
}
|
|
1644
1666
|
bcast('code-event', { term, evt: artifact })
|
|
1645
|
-
|
|
1667
|
+
writeMockupReceipt(box, slug, true, 200)
|
|
1646
1668
|
process.stderr.write(`\n ◆ mockup "${m.title || m.slug}" pushed to the room.\n`)
|
|
1647
1669
|
} catch (e) {
|
|
1648
|
-
|
|
1670
|
+
writeMockupReceipt(box, slug, false, 'error')
|
|
1649
1671
|
process.stderr.write(`\n ◇ mockup "${m.slug}" send failed: ${e?.message || e}\n`)
|
|
1650
1672
|
}
|
|
1651
1673
|
}
|
|
1674
|
+
|
|
1675
|
+
const mockupDeliveries = new MockupDeliveryQueue({ deliver: handleManifest })
|
|
1676
|
+
|
|
1677
|
+
function receiveManifest(box, file, term, trustedDesignSource = false) {
|
|
1678
|
+
const item = prepareManifest(box, file, term, trustedDesignSource)
|
|
1679
|
+
if (!item) return
|
|
1680
|
+
const lane = sessions.get(term)
|
|
1681
|
+
const active = !!lane && (lane._busyAnn === true || lane.session?.turnActive === true)
|
|
1682
|
+
const accepted = mockupDeliveries.enqueue(term, item, { active })
|
|
1683
|
+
if (accepted.queued) {
|
|
1684
|
+
writeMockupReceipt(box, item.slug, true, 'queued')
|
|
1685
|
+
process.stderr.write(`\n ◆ mockup "${item.m.title || item.m.slug}" ready — queued after the final response.\n`)
|
|
1686
|
+
} else {
|
|
1687
|
+
accepted.delivery.catch(() => {})
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1652
1690
|
// Watch one outbox dir; `ownerFn()` resolves which terminal owns a manifest
|
|
1653
1691
|
// dropped there (lazily, per event). Returns the FSWatcher so per-session/term
|
|
1654
1692
|
// watchers can be torn down when their owner ends. Safe to call repeatedly.
|
|
1655
1693
|
function watchOutbox(box, ownerFn, trustedDesignSource = false) {
|
|
1656
1694
|
try { fs.mkdirSync(box, { recursive: true }) } catch { return null }
|
|
1657
|
-
try { return fs.watch(box, (_evt, file) =>
|
|
1695
|
+
try { return fs.watch(box, (_evt, file) => receiveManifest(box, file, ownerFn(), trustedDesignSource)) }
|
|
1658
1696
|
catch { return null } // fs.watch unsupported — mockups simply won't auto-surface
|
|
1659
1697
|
}
|
|
1660
1698
|
// A per-owner outbox lives at MOCKUP_OUTBOX/<kind>/<id> and is handed to that
|
|
@@ -3053,6 +3091,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
3053
3091
|
// after the final queued boundary.
|
|
3054
3092
|
const terminalBoundary = !continuesQueued && evt.kind === 'result'
|
|
3055
3093
|
const busyChanged = terminalBoundary ? settleLaneBusy(entry) : syncStructuredTurn(entry)
|
|
3094
|
+
const mockupDeliveryBoundary = isMockupDeliveryBoundary(evt, { turnActive: entry.session?.turnActive === true })
|
|
3056
3095
|
stampStructuredTurn(entry, evt)
|
|
3057
3096
|
stampEvent(evt)
|
|
3058
3097
|
const stalledChanged = evt.kind === 'stalled' ? !entry.stalled : !!entry.stalled
|
|
@@ -3306,6 +3345,12 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
3306
3345
|
bcast('code-event', { term: id, evt: e })
|
|
3307
3346
|
printLocal(e)
|
|
3308
3347
|
if (!chrome) persist()
|
|
3348
|
+
// Result/error has now crossed the same ordered emission boundary as the
|
|
3349
|
+
// final assistant text (including the image queue). Only now may ready
|
|
3350
|
+
// mockups be uploaded, persisted, and broadcast below that answer.
|
|
3351
|
+
if (mockupDeliveryBoundary) {
|
|
3352
|
+
queueMicrotask(() => { mockupDeliveries.flush(id).catch(() => {}) })
|
|
3353
|
+
}
|
|
3309
3354
|
}
|
|
3310
3355
|
// A tool_result carrying an inline base64 image can't ride a broadcast frame
|
|
3311
3356
|
// (live OR replay). Lift it to Storage FIRST, then emit the URL-only event —
|
|
@@ -3542,6 +3587,7 @@ function endStructured(id) {
|
|
|
3542
3587
|
const s = sessions.get(id)
|
|
3543
3588
|
if (s) {
|
|
3544
3589
|
s.imageQueue?.close()
|
|
3590
|
+
mockupDeliveries.clear(id)
|
|
3545
3591
|
drainPending(s)
|
|
3546
3592
|
try { s.session?.end() } catch { /* noop */ }
|
|
3547
3593
|
try { s.mockupWatcher?.close() } catch { /* noop */ }
|
package/design-edit.mjs
CHANGED
|
@@ -84,6 +84,25 @@ export function resolveManifestDesignSource(manifest, workspaceRoot, { box } = {
|
|
|
84
84
|
return resolveDesignSource(manifest.source || manifest.html, workspaceRoot)
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// Authored mockups keep two representations: the canonical workspace source the
|
|
88
|
+
// producing lane edits, and a settled inert DOM snapshot the Design viewer shows.
|
|
89
|
+
// Snapshot paths are accepted only from this lane's private outbox so a crafted
|
|
90
|
+
// manifest cannot upload an arbitrary host file as room-visible HTML.
|
|
91
|
+
export function resolveManifestDisplaySource(manifest, record, { box } = {}) {
|
|
92
|
+
if (!record) return null
|
|
93
|
+
if (record.sourceKind === 'preview' || !manifest?.snapshot) return record.source
|
|
94
|
+
let snapshotPath, outbox
|
|
95
|
+
try {
|
|
96
|
+
snapshotPath = fs.realpathSync(manifest.snapshot)
|
|
97
|
+
outbox = fs.realpathSync(box)
|
|
98
|
+
const stat = fs.statSync(snapshotPath)
|
|
99
|
+
if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES) return null
|
|
100
|
+
} catch { return null }
|
|
101
|
+
const rel = path.relative(outbox, snapshotPath)
|
|
102
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel) || path.extname(snapshotPath).toLowerCase() !== '.html') return null
|
|
103
|
+
return fs.readFileSync(snapshotPath, 'utf8')
|
|
104
|
+
}
|
|
105
|
+
|
|
87
106
|
export function refreshDesignSource(record) {
|
|
88
107
|
if (!record) return null
|
|
89
108
|
if (record.sourceKind !== 'preview') return resolveDesignSource(record.sourcePath, record.workspaceRoot)
|
package/flow-models.mjs
CHANGED
|
@@ -28,6 +28,7 @@ export function modelCatalogValues(catalog = []) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
const exactHermesModelId = (value) => /^[A-Za-z0-9._-]+:[A-Za-z0-9._-]+\/[A-Za-z0-9._:/-]+$/.test(value)
|
|
31
|
+
const exactNousModelId = (value) => /^nous:/i.test(value) && exactHermesModelId(value)
|
|
31
32
|
|
|
32
33
|
const expandHermesModelAlias = (value) => {
|
|
33
34
|
const named = HERMES_MODEL_ALIASES[value.toLowerCase()]
|
|
@@ -49,15 +50,15 @@ export function resolveHermesOpenModel(model, catalog = [], { strictCatalog = fa
|
|
|
49
50
|
const requested = String(model || '').trim()
|
|
50
51
|
if (!requested) return { ok: true, model: undefined, validatedBy: 'default' }
|
|
51
52
|
const values = [...modelCatalogValues(catalog)]
|
|
52
|
-
if (values.includes(requested)) return { ok: true, model: requested, validatedBy: 'catalog' }
|
|
53
|
+
if (values.includes(requested) && exactNousModelId(requested)) return { ok: true, model: requested, validatedBy: 'catalog' }
|
|
53
54
|
|
|
54
55
|
const expanded = expandHermesModelAlias(requested)
|
|
55
56
|
const normalized = (value) => String(value).toLowerCase().replace(/[^a-z0-9]+/g, '')
|
|
56
57
|
const requestedKey = normalized(expanded).replace(/^nous/, '')
|
|
57
58
|
const aliases = values.filter((value) => normalized(value).replace(/^nous/, '') === requestedKey)
|
|
58
|
-
if (aliases.length === 1) return { ok: true, model: aliases[0], validatedBy: 'catalog' }
|
|
59
|
+
if (aliases.length === 1 && exactNousModelId(aliases[0])) return { ok: true, model: aliases[0], validatedBy: 'catalog' }
|
|
59
60
|
if (strictCatalog && values.length) return { ok: false, error: `Could not open a Hermes terminal on ${JSON.stringify(requested)} — that model is not in this session's ACP catalog.` }
|
|
60
|
-
if (!
|
|
61
|
+
if (!exactNousModelId(expanded)) return { ok: false, error: `Could not resolve Hermes model ${JSON.stringify(requested)}. ThinkPool Hermes only routes through Nous Portal; use a full ACP model ID such as "nous:z-ai/glm-5.2".` }
|
|
61
62
|
return { ok: true, model: expanded, validatedBy: 'child-runtime' }
|
|
62
63
|
}
|
|
63
64
|
|
package/hermes-acp-bootstrap.py
CHANGED
|
@@ -148,6 +148,21 @@ acp_adapter.session._expand_acp_enabled_toolsets = constrained_expand
|
|
|
148
148
|
|
|
149
149
|
import acp_adapter.server
|
|
150
150
|
|
|
151
|
+
# ThinkPool Hermes is a Nous Portal runtime, never a generic provider shell.
|
|
152
|
+
# The profile probe owns the first boundary; this process-local guard is the
|
|
153
|
+
# final defense against profile drift or a future ACP reconstruction bypass.
|
|
154
|
+
_build_model_state = acp_adapter.server.HermesACPAgent._build_model_state
|
|
155
|
+
def nous_only_model_state(self, state):
|
|
156
|
+
provider = str(getattr(getattr(state, "agent", None), "provider", "") or "").strip().lower()
|
|
157
|
+
if provider != "nous":
|
|
158
|
+
raise RuntimeError("ThinkPool Hermes requires the Nous Portal provider")
|
|
159
|
+
result = _build_model_state(self, state)
|
|
160
|
+
for item in list(getattr(result, "available_models", None) or []):
|
|
161
|
+
if not str(getattr(item, "model_id", "") or "").lower().startswith("nous:"):
|
|
162
|
+
raise RuntimeError("ThinkPool Hermes received a non-Nous ACP model")
|
|
163
|
+
return result
|
|
164
|
+
acp_adapter.server.HermesACPAgent._build_model_state = nous_only_model_state
|
|
165
|
+
|
|
151
166
|
# ThinkPool-only commands live in this process patch rather than in Hermes'
|
|
152
167
|
# profile. They are intentionally narrow: no identity, shared configuration,
|
|
153
168
|
# account tokens, or lifecycle/admin controls enter the room surface.
|
|
@@ -413,6 +428,9 @@ acp_adapter.server.HermesACPAgent._register_session_mcp_servers = constrained_re
|
|
|
413
428
|
# the replacement has re-registered and passed the same exact policy check.
|
|
414
429
|
_set_model = acp_adapter.server.HermesACPAgent.set_session_model
|
|
415
430
|
async def constrained_set_model(self, model_id, session_id, **kwargs):
|
|
431
|
+
rendered_model_id = str(model_id or "").strip().lower()
|
|
432
|
+
if ":" in rendered_model_id and not rendered_model_id.startswith("nous:"):
|
|
433
|
+
raise RuntimeError("ThinkPool Hermes only switches models through Nous Portal")
|
|
416
434
|
state = self.session_manager.get_session(session_id)
|
|
417
435
|
if state is None:
|
|
418
436
|
return await _set_model(self, model_id, session_id, **kwargs)
|
package/hermes-probe.mjs
CHANGED
|
@@ -6,6 +6,7 @@ const clean = (value) => String(value || '').replace(/[\r\n]+/g, ' ').trim()
|
|
|
6
6
|
|
|
7
7
|
const INSTALL = /Install directory:\s*(.+?)(?:\r?\n|$)/i
|
|
8
8
|
const PROFILE = /Config:\s*(.+?)(?:\r?\n|$)/i
|
|
9
|
+
const NOUS_PROVIDER = /Model:\s*\{[^\r\n]*['"]provider['"]\s*:\s*['"]nous['"][^\r\n]*\}/i
|
|
9
10
|
|
|
10
11
|
// Resolve the installed venv and isolated profile once, then launch ACP through
|
|
11
12
|
// bridge-owned code. `thinkpool` is only queried for inventory; it is never the
|
|
@@ -21,6 +22,7 @@ export function resolveHermesAcpRuntime({ command = 'thinkpool', execFile = exec
|
|
|
21
22
|
// `thinkpool` is the dedicated profile wrapper on supported installs. Its
|
|
22
23
|
// config output is evidence, not the ACP launch path.
|
|
23
24
|
const configOutput = execFile(command, ['config', 'show'], { encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
25
|
+
if (!NOUS_PROVIDER.test(String(configOutput))) throw new Error('ThinkPool Hermes profile is not pinned to Nous Portal')
|
|
24
26
|
const config = clean(String(configOutput).match(PROFILE)?.[1])
|
|
25
27
|
const profile = config ? path.dirname(config) : ''
|
|
26
28
|
if (!profile || !path.isAbsolute(profile) || path.basename(profile) !== 'thinkpool') throw new Error('Hermes did not report the isolated thinkpool profile')
|
|
@@ -34,6 +36,8 @@ export function probeHermesRuntime({ command = 'thinkpool', prefixArgs = [], exe
|
|
|
34
36
|
try {
|
|
35
37
|
const versionOutput = execFile(command, [...prefixArgs, '--version'], { encoding: 'utf8', env, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
36
38
|
const version = clean(versionOutput).match(/Hermes Agent v([^\s]+)/i)?.[1] || null
|
|
39
|
+
const configOutput = execFile(command, [...prefixArgs, 'config', 'show'], { encoding: 'utf8', env, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
40
|
+
if (!NOUS_PROVIDER.test(String(configOutput))) return { available: false, version, reason: 'ThinkPool Hermes profile is not pinned to Nous Portal' }
|
|
37
41
|
execFile(command, [...prefixArgs, 'acp', '--check'], { encoding: 'utf8', env, timeout: 15_000, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
38
42
|
const hooksOutput = execFile(command, [...prefixArgs, 'hooks', 'list'], { encoding: 'utf8', env, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
39
43
|
const doctorOutput = execFile(command, [...prefixArgs, 'hooks', 'doctor'], { encoding: 'utf8', env, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
|
package/hermes-setup.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import YAML from 'yaml'
|
|
|
6
6
|
import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
7
7
|
|
|
8
8
|
export const HERMES_PROFILE = 'thinkpool'
|
|
9
|
+
export const HERMES_PROVIDER = 'nous'
|
|
10
|
+
export const HERMES_DEFAULT_MODEL = 'openai/gpt-5.6-luna'
|
|
9
11
|
export const HERMES_GUARD_FILENAME = 'hermes-delegation-guard.mjs'
|
|
10
12
|
|
|
11
13
|
const clean = (value) => String(value || '').replace(/[\r\n]+/g, ' ').trim()
|
|
@@ -112,6 +114,14 @@ export function setupHermesRuntime({
|
|
|
112
114
|
config = parsed || {}
|
|
113
115
|
}
|
|
114
116
|
if (config.hooks != null && !isObject(config.hooks)) throw new Error('Hermes thinkpool hooks config is not a mapping; refusing to overwrite it')
|
|
117
|
+
if (config.model != null && !isObject(config.model)) throw new Error('Hermes thinkpool model config is not a mapping; refusing to overwrite it')
|
|
118
|
+
const model = config.model ||= {}
|
|
119
|
+
model.provider = HERMES_PROVIDER
|
|
120
|
+
model.default = HERMES_DEFAULT_MODEL
|
|
121
|
+
// Provider-specific transport overrides from a cloned profile must never
|
|
122
|
+
// survive into ThinkPool's Nous-only runtime.
|
|
123
|
+
delete model.base_url
|
|
124
|
+
delete model.api_mode
|
|
115
125
|
const hooks = config.hooks ||= {}
|
|
116
126
|
if (hooks.pre_tool_call != null && !Array.isArray(hooks.pre_tool_call)) throw new Error('Hermes pre_tool_call hooks are not a list; refusing to overwrite them')
|
|
117
127
|
const prior = hooks.pre_tool_call || []
|
|
@@ -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.307",
|
|
4
4
|
"description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
"flow-models.mjs",
|
|
70
70
|
"flow-host-revert.mjs",
|
|
71
71
|
"flow-preview.mjs",
|
|
72
|
+
"mockup-delivery.mjs",
|
|
72
73
|
"viewport.mjs",
|
|
73
74
|
"design-edit.mjs",
|
|
74
75
|
"flow-review.mjs",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"bundleVersion":
|
|
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' })
|