thinkpool-pair 0.7.216 → 0.7.218
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/bridge.mjs +197 -8
- package/design-edit.mjs +95 -0
- package/package.json +2 -1
package/bridge.mjs
CHANGED
|
@@ -65,6 +65,7 @@ import { writeLaneArtifact, digestSlice, appendDigest, resumeLane } from './flow
|
|
|
65
65
|
import { createFlowWorktree, worktreeSpec } from './flow-worktree.mjs'
|
|
66
66
|
import { startPreview, stopAllPreviews, previews } from './flow-preview.mjs'
|
|
67
67
|
import { ViewportManager, createViewportTools, sharedViewportBrowser } from './viewport.mjs'
|
|
68
|
+
import { designPrompt, resolveDesignSource, syncRealtimeAuth, validateDesignRequest } from './design-edit.mjs'
|
|
68
69
|
// FL-M9 — per-lane preview servers leak (one per done lane, never stopped until shutdown).
|
|
69
70
|
// Lane previews are keyed `lane:<flowId>:<laneId>`; stop a whole flow's set when it assembles
|
|
70
71
|
// (the assembled preview supersedes them) or when a lane is reverted.
|
|
@@ -738,6 +739,13 @@ let myServeUid = null // this bridge's authed uid (null = anon)
|
|
|
738
739
|
}
|
|
739
740
|
}
|
|
740
741
|
|
|
742
|
+
// Private realtime topics authorize the JWT carried by the socket at join time.
|
|
743
|
+
// Supabase's REST auth helpers do not propagate codeAuthToken to Realtime, so pin
|
|
744
|
+
// it before constructing any channel. A direct/anonymous bridge stays on anon.
|
|
745
|
+
if (!await syncRealtimeAuth(supabase, codeAuthToken, SUPABASE_ANON) && codeAuthToken) {
|
|
746
|
+
process.stderr.write('\n ⚠ realtime auth could not be initialized; private bridge features are unavailable.\n')
|
|
747
|
+
}
|
|
748
|
+
|
|
741
749
|
refreshOwnerPlan() // fire-and-forget on boot (codeAuthToken now set from env OR the stored login)
|
|
742
750
|
|
|
743
751
|
// ── Pre-flight budget caps — spec docs/specs/2026-07-01-budget-caps.md (nexos steal #1).
|
|
@@ -838,6 +846,12 @@ const flowChannel = supabase.channel(`tpflow:${room}`, {
|
|
|
838
846
|
config: { broadcast: { self: false } },
|
|
839
847
|
})
|
|
840
848
|
|
|
849
|
+
// Source-changing Design Mode control never rides the public tpcode topic.
|
|
850
|
+
// Membership RLS on realtime.messages gates join, receive, and send.
|
|
851
|
+
const designChannel = supabase.channel(`tpdesign:${room}`, {
|
|
852
|
+
config: { private: true, broadcast: { self: false } },
|
|
853
|
+
})
|
|
854
|
+
|
|
841
855
|
// ── terminal registry ──────────────────────────────────────────────
|
|
842
856
|
// id → { term (pty), cmd, attached, scrollback, buf }
|
|
843
857
|
const terms = new Map()
|
|
@@ -1213,13 +1227,73 @@ try {
|
|
|
1213
1227
|
} catch { /* mockups stay local if the outbox can't be made */ }
|
|
1214
1228
|
|
|
1215
1229
|
const mockupSeen = new Map() // `${term}/${slug}` → mtimeMs of the last manifest processed
|
|
1230
|
+
// Host-only provenance and edit coordination. The browser receives opaque ids
|
|
1231
|
+
// and content hashes only; source/cwd never leave this process.
|
|
1232
|
+
const designArtifacts = new Map() // previewId → trusted source record
|
|
1233
|
+
const designQueues = new Map() // producer term → validated requests
|
|
1234
|
+
const designActive = new Map() // producer term → request awaiting result + proof
|
|
1235
|
+
const designRestore = new Map() // current previewId → immediately previous verified record/edit
|
|
1236
|
+
const designStatus = (payload) => designChannel.send({
|
|
1237
|
+
type: 'broadcast', event: 'design-status', payload: { ts: Date.now(), ...payload },
|
|
1238
|
+
})
|
|
1239
|
+
|
|
1240
|
+
const finishDesign = (term, state, extra = {}) => {
|
|
1241
|
+
const active = designActive.get(term)
|
|
1242
|
+
if (!active) return
|
|
1243
|
+
if (active.timer) clearTimeout(active.timer)
|
|
1244
|
+
designActive.delete(term)
|
|
1245
|
+
designStatus({ previewId: active.record.previewId, requestId: active.request.cid, state, ...extra })
|
|
1246
|
+
// An element locator belongs to one DOM revision. Never guess queued targets
|
|
1247
|
+
// forward after a successful edit; both people must reselect on the new render.
|
|
1248
|
+
if (state === 'verified-live') {
|
|
1249
|
+
const queue = designQueues.get(term) || []
|
|
1250
|
+
for (const stale of queue.splice(0)) designStatus({ previewId: stale.record.previewId, requestId: stale.request.cid, state: 'stale', message: 'The artifact changed. Reselect the element on the new revision.' })
|
|
1251
|
+
}
|
|
1252
|
+
queueMicrotask(() => pumpDesign(term))
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const maybeVerifyDesign = (term) => {
|
|
1256
|
+
const active = designActive.get(term)
|
|
1257
|
+
if (!active?.resultOk || !active.proof) return
|
|
1258
|
+
finishDesign(term, 'verified-live', { message: 'Verified at desktop and mobile.', artifact: active.proof, canRestore: true })
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function pumpDesign(term) {
|
|
1262
|
+
if (designActive.has(term)) return
|
|
1263
|
+
const queue = designQueues.get(term) || []
|
|
1264
|
+
const next = queue[0]
|
|
1265
|
+
if (!next) return
|
|
1266
|
+
const lane = sessions.get(term)
|
|
1267
|
+
if (!lane) {
|
|
1268
|
+
queue.shift()
|
|
1269
|
+
designStatus({ previewId: next.record.previewId, requestId: next.request.cid, state: 'needs-lane', message: 'The producing lane is no longer available.' })
|
|
1270
|
+
return pumpDesign(term)
|
|
1271
|
+
}
|
|
1272
|
+
// sendTurn steers a busy Codex App Server turn; wait for the real idle edge.
|
|
1273
|
+
if (lane.session?.turnActive) return
|
|
1274
|
+
queue.shift()
|
|
1275
|
+
const current = designArtifacts.get(next.record.previewId)
|
|
1276
|
+
const live = current && resolveDesignSource(current.sourcePath, current.workspaceRoot)
|
|
1277
|
+
if (!current || !live || live.revision !== next.record.revision) {
|
|
1278
|
+
designStatus({ previewId: next.record.previewId, requestId: next.request.cid, state: 'stale', message: 'The artifact changed. Reselect the element.' })
|
|
1279
|
+
return pumpDesign(term)
|
|
1280
|
+
}
|
|
1281
|
+
const active = { ...next, resultOk: false, proof: null, timer: null }
|
|
1282
|
+
designActive.set(term, active)
|
|
1283
|
+
designStatus({ previewId: next.record.previewId, requestId: next.request.cid, state: 'applying' })
|
|
1284
|
+
// Safe transcript summary: never include host paths or the full locator packet.
|
|
1285
|
+
const visible = { kind: 'you', text: `Design edit · ${next.request.target.name || next.request.target.tag}: ${next.request.mode === 'text' ? 'replace text' : next.request.intent}`, cid: next.request.cid, by: next.by }
|
|
1286
|
+
stampEvent(visible); pushLog(lane, visible); bcast('code-event', { term, evt: visible })
|
|
1287
|
+
try { lane.session.sendTurn(designPrompt({ record: next.record, request: next.request, by: next.by, restore: !!next.restoreRecord, priorRecord: next.restoreRecord })) }
|
|
1288
|
+
catch { finishDesign(term, 'failed', { message: 'The producing lane could not start the edit.' }) }
|
|
1289
|
+
}
|
|
1216
1290
|
// Push one manifest file into the room, attributed to `term`. The owner is
|
|
1217
1291
|
// resolved by the WATCHER, not guessed here — each session/terminal writes to
|
|
1218
1292
|
// its OWN outbox (handed in via TP_MOCKUP_OUTBOX), so the directory the file
|
|
1219
1293
|
// landed in *is* its provenance. (Before: a per-room outbox + a "first session
|
|
1220
1294
|
// in the Map" guess meant closing the generating terminal moved the cards to
|
|
1221
1295
|
// whatever terminal was now first — they leaked into Terminal 1.)
|
|
1222
|
-
const handleManifest = async (box, file, term) => {
|
|
1296
|
+
const handleManifest = async (box, file, term, trustedDesignSource = false) => {
|
|
1223
1297
|
// Ignore our own delivery receipts — writing <slug>.receipt.json into the
|
|
1224
1298
|
// watched outbox re-fires fs.watch; without this guard it would re-trigger
|
|
1225
1299
|
// handleManifest in a loop (it still ends in .json). Must come first.
|
|
@@ -1246,6 +1320,10 @@ const handleManifest = async (box, file, term) => {
|
|
|
1246
1320
|
let m
|
|
1247
1321
|
try { m = JSON.parse(fs.readFileSync(full, 'utf8')) } catch { return }
|
|
1248
1322
|
if (!m?.slug) return
|
|
1323
|
+
const producer = trustedDesignSource ? sessions.get(term) : null
|
|
1324
|
+
const designRecord = producer && m.source
|
|
1325
|
+
? resolveDesignSource(m.source, producer.cwd || process.cwd())
|
|
1326
|
+
: null
|
|
1249
1327
|
// 2026-07-07: these used to swallow read errors silently — a transient
|
|
1250
1328
|
// unreadable file (race with the render script, permissions, mid-write)
|
|
1251
1329
|
// meant the manifest still POSTed with that field missing, and nothing
|
|
@@ -1271,15 +1349,40 @@ const handleManifest = async (box, file, term) => {
|
|
|
1271
1349
|
headers: mockupHeaders,
|
|
1272
1350
|
body: JSON.stringify({
|
|
1273
1351
|
code: room, slug: m.slug, title: m.title, cid, ts: m.ts, term,
|
|
1274
|
-
desktopPng: readB64(m.desktop), mobilePng: readB64(m.mobile), html: readTxt(m.html),
|
|
1352
|
+
desktopPng: readB64(m.desktop), mobilePng: readB64(m.mobile), html: designRecord ? designRecord.source : readTxt(m.html),
|
|
1353
|
+
...(designRecord ? { previewId: designRecord.previewId, revision: designRecord.revision, sourceKnown: true } : {}),
|
|
1275
1354
|
}),
|
|
1276
1355
|
})
|
|
1277
1356
|
if (!res.ok) {
|
|
1278
1357
|
writeReceipt(false, res.status)
|
|
1279
1358
|
process.stderr.write(`\n ◇ mockup "${m.slug}" upload failed (${res.status}).\n`); return
|
|
1280
1359
|
}
|
|
1281
|
-
const
|
|
1282
|
-
|
|
1360
|
+
const uploaded = await res.json()
|
|
1361
|
+
const { paths } = uploaded
|
|
1362
|
+
const designAccepted = !!(designRecord && uploaded?.payload?.sourceKnown)
|
|
1363
|
+
const artifact = { kind: 'mockup', __struct: true, cid, term, slug: m.slug, title: m.title || m.slug, ts: m.ts, paths,
|
|
1364
|
+
...(designAccepted ? { previewId: designRecord.previewId, revision: designRecord.revision, sourceKnown: true } : {}) }
|
|
1365
|
+
if (designAccepted) {
|
|
1366
|
+
Object.assign(designRecord, { term, slug: m.slug, title: m.title || m.slug, desktop: m.desktop, mobile: m.mobile })
|
|
1367
|
+
try {
|
|
1368
|
+
const backupDir = path.join(box, '.design-revisions')
|
|
1369
|
+
fs.mkdirSync(backupDir, { recursive: true })
|
|
1370
|
+
designRecord.backupPath = path.join(backupDir, `${designRecord.revision}.html`)
|
|
1371
|
+
if (!fs.existsSync(designRecord.backupPath)) fs.writeFileSync(designRecord.backupPath, designRecord.source)
|
|
1372
|
+
} catch { /* restore stays unavailable if the host backup cannot be written */ }
|
|
1373
|
+
designArtifacts.set(designRecord.previewId, designRecord)
|
|
1374
|
+
const active = designActive.get(term)
|
|
1375
|
+
const correlated = active && m.designRequestId === active.request.cid && m.parentRevision === active.record.revision
|
|
1376
|
+
const dual = !!(paths?.html && paths?.desktop && paths?.mobile && m.desktop && m.mobile)
|
|
1377
|
+
const expectedRevision = active?.restoreRecord?.revision
|
|
1378
|
+
const revisionProvesChange = expectedRevision ? designRecord.revision === expectedRevision : designRecord.revision !== active?.record?.revision
|
|
1379
|
+
if (correlated && dual && designRecord.sourcePath === active.record.sourcePath && revisionProvesChange) {
|
|
1380
|
+
designRestore.set(designRecord.previewId, { priorRecord: active.record, request: active.request })
|
|
1381
|
+
active.proof = artifact
|
|
1382
|
+
maybeVerifyDesign(term)
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
bcast('code-event', { term, evt: artifact })
|
|
1283
1386
|
writeReceipt(true, 200)
|
|
1284
1387
|
process.stderr.write(`\n ◆ mockup "${m.title || m.slug}" pushed to the room.\n`)
|
|
1285
1388
|
} catch (e) {
|
|
@@ -1290,9 +1393,9 @@ const handleManifest = async (box, file, term) => {
|
|
|
1290
1393
|
// Watch one outbox dir; `ownerFn()` resolves which terminal owns a manifest
|
|
1291
1394
|
// dropped there (lazily, per event). Returns the FSWatcher so per-session/term
|
|
1292
1395
|
// watchers can be torn down when their owner ends. Safe to call repeatedly.
|
|
1293
|
-
function watchOutbox(box, ownerFn) {
|
|
1396
|
+
function watchOutbox(box, ownerFn, trustedDesignSource = false) {
|
|
1294
1397
|
try { fs.mkdirSync(box, { recursive: true }) } catch { return null }
|
|
1295
|
-
try { return fs.watch(box, (_evt, file) => { handleManifest(box, file, ownerFn()).catch(() => {}) }) }
|
|
1398
|
+
try { return fs.watch(box, (_evt, file) => { handleManifest(box, file, ownerFn(), trustedDesignSource).catch(() => {}) }) }
|
|
1296
1399
|
catch { return null } // fs.watch unsupported — mockups simply won't auto-surface
|
|
1297
1400
|
}
|
|
1298
1401
|
// A per-owner outbox lives at MOCKUP_OUTBOX/<kind>/<id> and is handed to that
|
|
@@ -1758,7 +1861,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
1758
1861
|
// TP_MOCKUP_OUTBOX below) and the watcher attributes every card to THIS id, so
|
|
1759
1862
|
// they land on this session even after other terminals open/close.
|
|
1760
1863
|
const mockupOutbox = ownerOutbox('sessions', id)
|
|
1761
|
-
entry.mockupWatcher = watchOutbox(mockupOutbox, () => id)
|
|
1864
|
+
entry.mockupWatcher = watchOutbox(mockupOutbox, () => id, true)
|
|
1762
1865
|
// Visual QA runs in the bridge process, outside either agent runtime's sandbox.
|
|
1763
1866
|
// It remains scoped to this lane's cwd and private mockup outbox.
|
|
1764
1867
|
entry.viewport = new ViewportManager({ workspaceRoot: cwd || process.cwd(), ownerId: id, outbox: mockupOutbox })
|
|
@@ -2392,6 +2495,20 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
2392
2495
|
// _turnStart is cleared unconditionally so a stray post-settle event can't
|
|
2393
2496
|
// re-notify off a stale stamp.
|
|
2394
2497
|
if (evt.kind === 'result' || evt.kind === 'error') {
|
|
2498
|
+
const edit = designActive.get(id)
|
|
2499
|
+
if (edit) {
|
|
2500
|
+
const successful = evt.kind === 'result' && (!evt.subtype || evt.subtype === 'success')
|
|
2501
|
+
if (!successful) finishDesign(id, 'failed', { message: 'The producing lane did not complete the edit.' })
|
|
2502
|
+
else {
|
|
2503
|
+
edit.resultOk = true
|
|
2504
|
+
designStatus({ previewId: edit.record.previewId, requestId: edit.request.cid, state: 'verifying', message: 'Waiting for correlated desktop and mobile renders…' })
|
|
2505
|
+
edit.timer = setTimeout(() => finishDesign(id, 'failed', { message: 'The edit finished without a correlated desktop and mobile render.' }), 120_000)
|
|
2506
|
+
edit.timer.unref?.()
|
|
2507
|
+
maybeVerifyDesign(id)
|
|
2508
|
+
}
|
|
2509
|
+
} else if ((designQueues.get(id) || []).length) {
|
|
2510
|
+
queueMicrotask(() => pumpDesign(id))
|
|
2511
|
+
}
|
|
2395
2512
|
if (entry.sideHandback) {
|
|
2396
2513
|
const request = entry.sideHandback
|
|
2397
2514
|
entry.sideHandback = null
|
|
@@ -2667,6 +2784,13 @@ function endStructured(id) {
|
|
|
2667
2784
|
try { s.session?.end() } catch { /* noop */ }
|
|
2668
2785
|
try { s.mockupWatcher?.close() } catch { /* noop */ }
|
|
2669
2786
|
try { void s.viewport?.stop()?.catch(() => {}) } catch { /* noop */ }
|
|
2787
|
+
const activeDesign = designActive.get(id)
|
|
2788
|
+
if (activeDesign?.timer) clearTimeout(activeDesign.timer)
|
|
2789
|
+
designActive.delete(id)
|
|
2790
|
+
designQueues.delete(id)
|
|
2791
|
+
for (const [previewId, record] of designArtifacts) if (record.term === id) designArtifacts.delete(previewId)
|
|
2792
|
+
for (const [previewId, restore] of designRestore) if (restore.priorRecord?.term === id) designRestore.delete(previewId)
|
|
2793
|
+
try { fs.rmSync(ownerOutbox('sessions', id), { recursive: true, force: true }) } catch { /* best-effort host artifact cleanup */ }
|
|
2670
2794
|
sessions.delete(id)
|
|
2671
2795
|
bcast('term-exit', { id })
|
|
2672
2796
|
reapTerminalRow(id) // bridge-side reap: delete the code_terminals row ourselves — the
|
|
@@ -3745,6 +3869,64 @@ flowChannel
|
|
|
3745
3869
|
}
|
|
3746
3870
|
})
|
|
3747
3871
|
|
|
3872
|
+
// Design control is a separate PRIVATE channel. The browser cannot target a
|
|
3873
|
+
// lane or path: both are derived from the host-only artifact registry.
|
|
3874
|
+
const seenDesignRequests = new Set()
|
|
3875
|
+
designChannel
|
|
3876
|
+
.on('broadcast', { event: 'design-capability' }, ({ payload }) => {
|
|
3877
|
+
const record = payload?.previewId && designArtifacts.get(String(payload.previewId))
|
|
3878
|
+
const live = record && resolveDesignSource(record.sourcePath, record.workspaceRoot)
|
|
3879
|
+
const ok = !!record && !!live && live.revision === record.revision && record.revision === payload?.revision && sessions.has(record.term)
|
|
3880
|
+
designChannel.send({ type: 'broadcast', event: 'design-capability-res', payload: { previewId: String(payload?.previewId || ''), revision: String(payload?.revision || ''), ok } })
|
|
3881
|
+
})
|
|
3882
|
+
.on('broadcast', { event: 'design-apply' }, ({ payload }) => {
|
|
3883
|
+
const verdict = validateDesignRequest(payload, designArtifacts)
|
|
3884
|
+
const previewId = String(payload?.previewId || '')
|
|
3885
|
+
const requestId = String(payload?.cid || '').slice(0, 80)
|
|
3886
|
+
if (!verdict.ok) {
|
|
3887
|
+
designStatus({ previewId, requestId, state: verdict.error === 'stale-preview' ? 'stale' : 'failed', message: verdict.error })
|
|
3888
|
+
return
|
|
3889
|
+
}
|
|
3890
|
+
if (!requestId || seenDesignRequests.has(requestId)) return
|
|
3891
|
+
seenDesignRequests.add(requestId)
|
|
3892
|
+
if (seenDesignRequests.size > 500) seenDesignRequests.delete(seenDesignRequests.values().next().value)
|
|
3893
|
+
const term = verdict.record.term
|
|
3894
|
+
const queue = designQueues.get(term) || []
|
|
3895
|
+
designQueues.set(term, queue)
|
|
3896
|
+
if (queue.length >= 8) {
|
|
3897
|
+
designStatus({ previewId, requestId, state: 'failed', message: 'Too many Design edits are queued for this lane.' })
|
|
3898
|
+
return
|
|
3899
|
+
}
|
|
3900
|
+
queue.push({ ...verdict, by: 'A room member' })
|
|
3901
|
+
designStatus({ previewId, requestId, state: 'queued' })
|
|
3902
|
+
pumpDesign(term)
|
|
3903
|
+
})
|
|
3904
|
+
.on('broadcast', { event: 'design-restore' }, ({ payload }) => {
|
|
3905
|
+
const previewId = String(payload?.previewId || '')
|
|
3906
|
+
const requestId = String(payload?.cid || '').slice(0, 80)
|
|
3907
|
+
const record = designArtifacts.get(previewId)
|
|
3908
|
+
const restore = designRestore.get(previewId)
|
|
3909
|
+
const live = record && resolveDesignSource(record.sourcePath, record.workspaceRoot)
|
|
3910
|
+
if (!requestId || !record || !restore?.priorRecord?.backupPath || !live || live.revision !== record.revision || payload?.revision !== record.revision) {
|
|
3911
|
+
designStatus({ previewId, requestId, state: 'stale', message: 'That verified revision can no longer be restored safely.' })
|
|
3912
|
+
return
|
|
3913
|
+
}
|
|
3914
|
+
if (seenDesignRequests.has(requestId)) return
|
|
3915
|
+
seenDesignRequests.add(requestId)
|
|
3916
|
+
const queue = designQueues.get(record.term) || []
|
|
3917
|
+
designQueues.set(record.term, queue)
|
|
3918
|
+
if (queue.length >= 8) {
|
|
3919
|
+
designStatus({ previewId, requestId, state: 'failed', message: 'Too many Design edits are queued for this lane.' })
|
|
3920
|
+
return
|
|
3921
|
+
}
|
|
3922
|
+
queue.push({ record, restoreRecord: restore.priorRecord, request: { previewId, revision: record.revision, mode: 'restore', intent: 'restore previous verified revision', target: restore.request.target, cid: requestId }, by: 'A room member' })
|
|
3923
|
+
designStatus({ previewId, requestId, state: 'queued', message: 'Restore queued in the producing lane.' })
|
|
3924
|
+
pumpDesign(record.term)
|
|
3925
|
+
})
|
|
3926
|
+
.subscribe((status) => {
|
|
3927
|
+
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') process.stderr.write(`\n ⚠ design realtime ${status} (tpdesign:${room}).\n`)
|
|
3928
|
+
})
|
|
3929
|
+
|
|
3748
3930
|
// Watchdog — if the realtime channel is stuck (never connected, or dropped and
|
|
3749
3931
|
// not recovered) for >60s, exit non-zero so a supervisor/launchd restarts a
|
|
3750
3932
|
// clean process. Healthy long-lived connections never trip this (brokenSince=0).
|
|
@@ -3839,7 +4021,13 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
|
3839
4021
|
if (m.t === 'update-available') surfaceUpdate(m.version)
|
|
3840
4022
|
// Supervisor rotated the owner JWT (account.mjs scheduleTokenRefresh) — adopt
|
|
3841
4023
|
// it so our authed writes (code-mockup) never go stale on a long session (L16).
|
|
3842
|
-
else if (m.t === 'token') {
|
|
4024
|
+
else if (m.t === 'token') {
|
|
4025
|
+
codeAuthToken = m.accessToken || null
|
|
4026
|
+
syncRealtimeAuth(supabase, codeAuthToken, SUPABASE_ANON).then((ok) => {
|
|
4027
|
+
if (!ok && codeAuthToken) process.stderr.write('\n ⚠ realtime auth refresh failed; private bridge features may need a reconnect.\n')
|
|
4028
|
+
})
|
|
4029
|
+
refreshOwnerPlan()
|
|
4030
|
+
}
|
|
3843
4031
|
})
|
|
3844
4032
|
setInterval(() => { try { process.send({ t: 'idle', idle: idleNow(), between: betweenTurns(), webPeer: webPeerPresent() }) } catch { /* parent gone */ } }, 5000).unref()
|
|
3845
4033
|
} else if (VERSION) {
|
|
@@ -3904,6 +4092,7 @@ async function shutdown(code = 0, farewell = true) {
|
|
|
3904
4092
|
// topic (no presence to untrack, but the subscription + socket join outlived the
|
|
3905
4093
|
// process teardown). Close it too so shutdown is symmetric.
|
|
3906
4094
|
try { await supabase.removeChannel(flowChannel) } catch { /* noop */ }
|
|
4095
|
+
try { await supabase.removeChannel(designChannel) } catch { /* noop */ }
|
|
3907
4096
|
setTimeout(() => process.exit(code), 250) // grace for the leave/close frames to flush
|
|
3908
4097
|
}
|
|
3909
4098
|
process.on('SIGINT', () => shutdown(0))
|
package/design-edit.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
const MAX_SOURCE_BYTES = 2 * 1024 * 1024
|
|
6
|
+
const MAX_INTENT = 2_000
|
|
7
|
+
const MAX_TEXT = 4_000
|
|
8
|
+
|
|
9
|
+
const clean = (value, max) => String(value ?? '').replace(/\0/g, '').trim().slice(0, max)
|
|
10
|
+
|
|
11
|
+
// supabase-js does not copy the Auth session into an already-created Realtime
|
|
12
|
+
// client. Private tpdesign:* joins therefore need the bridge owner's JWT pinned
|
|
13
|
+
// explicitly, both at boot and whenever the account supervisor rotates it.
|
|
14
|
+
export async function syncRealtimeAuth(client, accessToken, fallbackToken = null) {
|
|
15
|
+
const token = accessToken || fallbackToken
|
|
16
|
+
if (!token || typeof client?.realtime?.setAuth !== 'function') return false
|
|
17
|
+
try {
|
|
18
|
+
await client.realtime.setAuth(token)
|
|
19
|
+
return true
|
|
20
|
+
} catch {
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function sourceRevision(source) {
|
|
26
|
+
return crypto.createHash('sha256').update(String(source)).digest('hex').slice(0, 24)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveDesignSource(file, workspaceRoot) {
|
|
30
|
+
if (!file || !workspaceRoot) return null
|
|
31
|
+
let sourcePath, root
|
|
32
|
+
try {
|
|
33
|
+
sourcePath = fs.realpathSync(file)
|
|
34
|
+
root = fs.realpathSync(workspaceRoot)
|
|
35
|
+
const stat = fs.statSync(sourcePath)
|
|
36
|
+
if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES) return null
|
|
37
|
+
} catch { return null }
|
|
38
|
+
const rel = path.relative(root, sourcePath)
|
|
39
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel) || path.extname(sourcePath).toLowerCase() !== '.html') return null
|
|
40
|
+
// Generated bundles are evidence, not editable source. The agent may only receive
|
|
41
|
+
// an exact authored HTML artifact; it must never be pointed at dist output.
|
|
42
|
+
if (rel.split(path.sep).some((part) => ['dist', 'build', '.next', '.git', 'coverage', 'node_modules'].includes(part))) return null
|
|
43
|
+
const source = fs.readFileSync(sourcePath, 'utf8')
|
|
44
|
+
const revision = sourceRevision(source)
|
|
45
|
+
const previewId = crypto.createHash('sha256')
|
|
46
|
+
.update(`${sourcePath}\0${revision}`)
|
|
47
|
+
.digest('base64url').slice(0, 32)
|
|
48
|
+
return { previewId, revision, sourcePath, workspaceRoot: root, source }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function safeTarget(value) {
|
|
52
|
+
if (!value || typeof value !== 'object') return null
|
|
53
|
+
const rect = value.rect && typeof value.rect === 'object' ? {
|
|
54
|
+
x: Number(value.rect.x) || 0,
|
|
55
|
+
y: Number(value.rect.y) || 0,
|
|
56
|
+
width: Math.max(0, Number(value.rect.width) || 0),
|
|
57
|
+
height: Math.max(0, Number(value.rect.height) || 0),
|
|
58
|
+
} : null
|
|
59
|
+
return {
|
|
60
|
+
selector: clean(value.selector, 600),
|
|
61
|
+
xpath: clean(value.xpath, 600),
|
|
62
|
+
tag: clean(value.tag, 40).toLowerCase(),
|
|
63
|
+
role: clean(value.role, 80),
|
|
64
|
+
name: clean(value.name, 240),
|
|
65
|
+
text: clean(value.text, MAX_TEXT),
|
|
66
|
+
canEditText: value.canEditText === true,
|
|
67
|
+
rect,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function validateDesignRequest(payload, registry) {
|
|
72
|
+
if (!payload || typeof payload !== 'object') return { ok: false, error: 'invalid-request' }
|
|
73
|
+
const previewId = clean(payload.previewId, 64)
|
|
74
|
+
const record = registry.get(previewId)
|
|
75
|
+
if (!record) return { ok: false, error: 'preview-unavailable' }
|
|
76
|
+
if (clean(payload.revision, 64) !== record.revision) return { ok: false, error: 'stale-preview' }
|
|
77
|
+
const target = safeTarget(payload.target)
|
|
78
|
+
if (!target?.selector && !target?.xpath) return { ok: false, error: 'select-an-element' }
|
|
79
|
+
const mode = payload.mode === 'text' ? 'text' : payload.mode === 'prompt' ? 'prompt' : null
|
|
80
|
+
if (!mode) return { ok: false, error: 'invalid-mode' }
|
|
81
|
+
const intent = clean(payload.intent, mode === 'text' ? MAX_TEXT : MAX_INTENT)
|
|
82
|
+
if (!intent) return { ok: false, error: mode === 'text' ? 'enter-text' : 'describe-change' }
|
|
83
|
+
if (mode === 'text' && !target.canEditText) return { ok: false, error: 'text-edit-not-safe' }
|
|
84
|
+
return { ok: true, record, request: { previewId, revision: record.revision, mode, intent, target, cid: clean(payload.cid, 80) } }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function designPrompt({ record, request, by, restore = false, priorRecord = null }) {
|
|
88
|
+
const target = JSON.stringify(request.target, null, 2)
|
|
89
|
+
const action = restore
|
|
90
|
+
? `Restore only the immediately previous Design Mode change by comparing the current artifact with this bridge-created verified backup: ${priorRecord?.backupPath || '(backup unavailable)'}. Do not overwrite unrelated changes.`
|
|
91
|
+
: request.mode === 'text'
|
|
92
|
+
? `Replace only this element's visible text with:\n${request.intent}`
|
|
93
|
+
: `Apply this visual change to only the selected element unless the instruction explicitly requires its immediate context:\n${request.intent}`
|
|
94
|
+
return `A room member used ThinkPool Design Mode on the artifact below. This is an authorized source edit in your current workspace.\n\nArtifact source: ${record.sourcePath}\nWorkspace: ${record.workspaceRoot}\nExpected source revision: ${record.revision}\nRequested by: ${clean(by || 'A room member', 120)}\nOperation: ${restore ? 'restore the immediately previous verified Design Mode revision' : request.mode}\n\nSelected element:\n${target}\n\n${action}\n\nRules:\n- Re-read the exact artifact source and confirm its revision/content still matches the selected element before editing.\n- Edit the canonical source above; do not search for or edit a different plausible file.\n- Preserve unrelated content and styling.\n- Use the existing mockup render workflow to produce both desktop and mobile captures after the edit. Run it with TP_DESIGN_REQUEST_ID=${request.cid} and TP_DESIGN_PARENT_REVISION=${record.revision} in the command environment so the proof correlates to this request.\n- Do not claim this is live until both captures succeed and the room receives the manifest.\n- If the target is stale or ambiguous, stop without editing and explain that the person must reselect it.`
|
|
95
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.218",
|
|
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": {
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"flow-task-graph.mjs",
|
|
39
39
|
"flow-preview.mjs",
|
|
40
40
|
"viewport.mjs",
|
|
41
|
+
"design-edit.mjs",
|
|
41
42
|
"flow-review.mjs",
|
|
42
43
|
"flow-review-gate.mjs",
|
|
43
44
|
"flow-review-reflect.mjs",
|