thinkpool-pair 0.7.216 → 0.7.217
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 +183 -7
- package/design-edit.mjs +81 -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, 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.
|
|
@@ -838,6 +839,12 @@ const flowChannel = supabase.channel(`tpflow:${room}`, {
|
|
|
838
839
|
config: { broadcast: { self: false } },
|
|
839
840
|
})
|
|
840
841
|
|
|
842
|
+
// Source-changing Design Mode control never rides the public tpcode topic.
|
|
843
|
+
// Membership RLS on realtime.messages gates join, receive, and send.
|
|
844
|
+
const designChannel = supabase.channel(`tpdesign:${room}`, {
|
|
845
|
+
config: { private: true, broadcast: { self: false } },
|
|
846
|
+
})
|
|
847
|
+
|
|
841
848
|
// ── terminal registry ──────────────────────────────────────────────
|
|
842
849
|
// id → { term (pty), cmd, attached, scrollback, buf }
|
|
843
850
|
const terms = new Map()
|
|
@@ -1213,13 +1220,73 @@ try {
|
|
|
1213
1220
|
} catch { /* mockups stay local if the outbox can't be made */ }
|
|
1214
1221
|
|
|
1215
1222
|
const mockupSeen = new Map() // `${term}/${slug}` → mtimeMs of the last manifest processed
|
|
1223
|
+
// Host-only provenance and edit coordination. The browser receives opaque ids
|
|
1224
|
+
// and content hashes only; source/cwd never leave this process.
|
|
1225
|
+
const designArtifacts = new Map() // previewId → trusted source record
|
|
1226
|
+
const designQueues = new Map() // producer term → validated requests
|
|
1227
|
+
const designActive = new Map() // producer term → request awaiting result + proof
|
|
1228
|
+
const designRestore = new Map() // current previewId → immediately previous verified record/edit
|
|
1229
|
+
const designStatus = (payload) => designChannel.send({
|
|
1230
|
+
type: 'broadcast', event: 'design-status', payload: { ts: Date.now(), ...payload },
|
|
1231
|
+
})
|
|
1232
|
+
|
|
1233
|
+
const finishDesign = (term, state, extra = {}) => {
|
|
1234
|
+
const active = designActive.get(term)
|
|
1235
|
+
if (!active) return
|
|
1236
|
+
if (active.timer) clearTimeout(active.timer)
|
|
1237
|
+
designActive.delete(term)
|
|
1238
|
+
designStatus({ previewId: active.record.previewId, requestId: active.request.cid, state, ...extra })
|
|
1239
|
+
// An element locator belongs to one DOM revision. Never guess queued targets
|
|
1240
|
+
// forward after a successful edit; both people must reselect on the new render.
|
|
1241
|
+
if (state === 'verified-live') {
|
|
1242
|
+
const queue = designQueues.get(term) || []
|
|
1243
|
+
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.' })
|
|
1244
|
+
}
|
|
1245
|
+
queueMicrotask(() => pumpDesign(term))
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
const maybeVerifyDesign = (term) => {
|
|
1249
|
+
const active = designActive.get(term)
|
|
1250
|
+
if (!active?.resultOk || !active.proof) return
|
|
1251
|
+
finishDesign(term, 'verified-live', { message: 'Verified at desktop and mobile.', artifact: active.proof, canRestore: true })
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function pumpDesign(term) {
|
|
1255
|
+
if (designActive.has(term)) return
|
|
1256
|
+
const queue = designQueues.get(term) || []
|
|
1257
|
+
const next = queue[0]
|
|
1258
|
+
if (!next) return
|
|
1259
|
+
const lane = sessions.get(term)
|
|
1260
|
+
if (!lane) {
|
|
1261
|
+
queue.shift()
|
|
1262
|
+
designStatus({ previewId: next.record.previewId, requestId: next.request.cid, state: 'needs-lane', message: 'The producing lane is no longer available.' })
|
|
1263
|
+
return pumpDesign(term)
|
|
1264
|
+
}
|
|
1265
|
+
// sendTurn steers a busy Codex App Server turn; wait for the real idle edge.
|
|
1266
|
+
if (lane.session?.turnActive) return
|
|
1267
|
+
queue.shift()
|
|
1268
|
+
const current = designArtifacts.get(next.record.previewId)
|
|
1269
|
+
const live = current && resolveDesignSource(current.sourcePath, current.workspaceRoot)
|
|
1270
|
+
if (!current || !live || live.revision !== next.record.revision) {
|
|
1271
|
+
designStatus({ previewId: next.record.previewId, requestId: next.request.cid, state: 'stale', message: 'The artifact changed. Reselect the element.' })
|
|
1272
|
+
return pumpDesign(term)
|
|
1273
|
+
}
|
|
1274
|
+
const active = { ...next, resultOk: false, proof: null, timer: null }
|
|
1275
|
+
designActive.set(term, active)
|
|
1276
|
+
designStatus({ previewId: next.record.previewId, requestId: next.request.cid, state: 'applying' })
|
|
1277
|
+
// Safe transcript summary: never include host paths or the full locator packet.
|
|
1278
|
+
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 }
|
|
1279
|
+
stampEvent(visible); pushLog(lane, visible); bcast('code-event', { term, evt: visible })
|
|
1280
|
+
try { lane.session.sendTurn(designPrompt({ record: next.record, request: next.request, by: next.by, restore: !!next.restoreRecord, priorRecord: next.restoreRecord })) }
|
|
1281
|
+
catch { finishDesign(term, 'failed', { message: 'The producing lane could not start the edit.' }) }
|
|
1282
|
+
}
|
|
1216
1283
|
// Push one manifest file into the room, attributed to `term`. The owner is
|
|
1217
1284
|
// resolved by the WATCHER, not guessed here — each session/terminal writes to
|
|
1218
1285
|
// its OWN outbox (handed in via TP_MOCKUP_OUTBOX), so the directory the file
|
|
1219
1286
|
// landed in *is* its provenance. (Before: a per-room outbox + a "first session
|
|
1220
1287
|
// in the Map" guess meant closing the generating terminal moved the cards to
|
|
1221
1288
|
// whatever terminal was now first — they leaked into Terminal 1.)
|
|
1222
|
-
const handleManifest = async (box, file, term) => {
|
|
1289
|
+
const handleManifest = async (box, file, term, trustedDesignSource = false) => {
|
|
1223
1290
|
// Ignore our own delivery receipts — writing <slug>.receipt.json into the
|
|
1224
1291
|
// watched outbox re-fires fs.watch; without this guard it would re-trigger
|
|
1225
1292
|
// handleManifest in a loop (it still ends in .json). Must come first.
|
|
@@ -1246,6 +1313,10 @@ const handleManifest = async (box, file, term) => {
|
|
|
1246
1313
|
let m
|
|
1247
1314
|
try { m = JSON.parse(fs.readFileSync(full, 'utf8')) } catch { return }
|
|
1248
1315
|
if (!m?.slug) return
|
|
1316
|
+
const producer = trustedDesignSource ? sessions.get(term) : null
|
|
1317
|
+
const designRecord = producer && m.source
|
|
1318
|
+
? resolveDesignSource(m.source, producer.cwd || process.cwd())
|
|
1319
|
+
: null
|
|
1249
1320
|
// 2026-07-07: these used to swallow read errors silently — a transient
|
|
1250
1321
|
// unreadable file (race with the render script, permissions, mid-write)
|
|
1251
1322
|
// meant the manifest still POSTed with that field missing, and nothing
|
|
@@ -1271,15 +1342,40 @@ const handleManifest = async (box, file, term) => {
|
|
|
1271
1342
|
headers: mockupHeaders,
|
|
1272
1343
|
body: JSON.stringify({
|
|
1273
1344
|
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),
|
|
1345
|
+
desktopPng: readB64(m.desktop), mobilePng: readB64(m.mobile), html: designRecord ? designRecord.source : readTxt(m.html),
|
|
1346
|
+
...(designRecord ? { previewId: designRecord.previewId, revision: designRecord.revision, sourceKnown: true } : {}),
|
|
1275
1347
|
}),
|
|
1276
1348
|
})
|
|
1277
1349
|
if (!res.ok) {
|
|
1278
1350
|
writeReceipt(false, res.status)
|
|
1279
1351
|
process.stderr.write(`\n ◇ mockup "${m.slug}" upload failed (${res.status}).\n`); return
|
|
1280
1352
|
}
|
|
1281
|
-
const
|
|
1282
|
-
|
|
1353
|
+
const uploaded = await res.json()
|
|
1354
|
+
const { paths } = uploaded
|
|
1355
|
+
const designAccepted = !!(designRecord && uploaded?.payload?.sourceKnown)
|
|
1356
|
+
const artifact = { kind: 'mockup', __struct: true, cid, term, slug: m.slug, title: m.title || m.slug, ts: m.ts, paths,
|
|
1357
|
+
...(designAccepted ? { previewId: designRecord.previewId, revision: designRecord.revision, sourceKnown: true } : {}) }
|
|
1358
|
+
if (designAccepted) {
|
|
1359
|
+
Object.assign(designRecord, { term, slug: m.slug, title: m.title || m.slug, desktop: m.desktop, mobile: m.mobile })
|
|
1360
|
+
try {
|
|
1361
|
+
const backupDir = path.join(box, '.design-revisions')
|
|
1362
|
+
fs.mkdirSync(backupDir, { recursive: true })
|
|
1363
|
+
designRecord.backupPath = path.join(backupDir, `${designRecord.revision}.html`)
|
|
1364
|
+
if (!fs.existsSync(designRecord.backupPath)) fs.writeFileSync(designRecord.backupPath, designRecord.source)
|
|
1365
|
+
} catch { /* restore stays unavailable if the host backup cannot be written */ }
|
|
1366
|
+
designArtifacts.set(designRecord.previewId, designRecord)
|
|
1367
|
+
const active = designActive.get(term)
|
|
1368
|
+
const correlated = active && m.designRequestId === active.request.cid && m.parentRevision === active.record.revision
|
|
1369
|
+
const dual = !!(paths?.html && paths?.desktop && paths?.mobile && m.desktop && m.mobile)
|
|
1370
|
+
const expectedRevision = active?.restoreRecord?.revision
|
|
1371
|
+
const revisionProvesChange = expectedRevision ? designRecord.revision === expectedRevision : designRecord.revision !== active?.record?.revision
|
|
1372
|
+
if (correlated && dual && designRecord.sourcePath === active.record.sourcePath && revisionProvesChange) {
|
|
1373
|
+
designRestore.set(designRecord.previewId, { priorRecord: active.record, request: active.request })
|
|
1374
|
+
active.proof = artifact
|
|
1375
|
+
maybeVerifyDesign(term)
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
bcast('code-event', { term, evt: artifact })
|
|
1283
1379
|
writeReceipt(true, 200)
|
|
1284
1380
|
process.stderr.write(`\n ◆ mockup "${m.title || m.slug}" pushed to the room.\n`)
|
|
1285
1381
|
} catch (e) {
|
|
@@ -1290,9 +1386,9 @@ const handleManifest = async (box, file, term) => {
|
|
|
1290
1386
|
// Watch one outbox dir; `ownerFn()` resolves which terminal owns a manifest
|
|
1291
1387
|
// dropped there (lazily, per event). Returns the FSWatcher so per-session/term
|
|
1292
1388
|
// watchers can be torn down when their owner ends. Safe to call repeatedly.
|
|
1293
|
-
function watchOutbox(box, ownerFn) {
|
|
1389
|
+
function watchOutbox(box, ownerFn, trustedDesignSource = false) {
|
|
1294
1390
|
try { fs.mkdirSync(box, { recursive: true }) } catch { return null }
|
|
1295
|
-
try { return fs.watch(box, (_evt, file) => { handleManifest(box, file, ownerFn()).catch(() => {}) }) }
|
|
1391
|
+
try { return fs.watch(box, (_evt, file) => { handleManifest(box, file, ownerFn(), trustedDesignSource).catch(() => {}) }) }
|
|
1296
1392
|
catch { return null } // fs.watch unsupported — mockups simply won't auto-surface
|
|
1297
1393
|
}
|
|
1298
1394
|
// A per-owner outbox lives at MOCKUP_OUTBOX/<kind>/<id> and is handed to that
|
|
@@ -1758,7 +1854,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
1758
1854
|
// TP_MOCKUP_OUTBOX below) and the watcher attributes every card to THIS id, so
|
|
1759
1855
|
// they land on this session even after other terminals open/close.
|
|
1760
1856
|
const mockupOutbox = ownerOutbox('sessions', id)
|
|
1761
|
-
entry.mockupWatcher = watchOutbox(mockupOutbox, () => id)
|
|
1857
|
+
entry.mockupWatcher = watchOutbox(mockupOutbox, () => id, true)
|
|
1762
1858
|
// Visual QA runs in the bridge process, outside either agent runtime's sandbox.
|
|
1763
1859
|
// It remains scoped to this lane's cwd and private mockup outbox.
|
|
1764
1860
|
entry.viewport = new ViewportManager({ workspaceRoot: cwd || process.cwd(), ownerId: id, outbox: mockupOutbox })
|
|
@@ -2392,6 +2488,20 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
2392
2488
|
// _turnStart is cleared unconditionally so a stray post-settle event can't
|
|
2393
2489
|
// re-notify off a stale stamp.
|
|
2394
2490
|
if (evt.kind === 'result' || evt.kind === 'error') {
|
|
2491
|
+
const edit = designActive.get(id)
|
|
2492
|
+
if (edit) {
|
|
2493
|
+
const successful = evt.kind === 'result' && (!evt.subtype || evt.subtype === 'success')
|
|
2494
|
+
if (!successful) finishDesign(id, 'failed', { message: 'The producing lane did not complete the edit.' })
|
|
2495
|
+
else {
|
|
2496
|
+
edit.resultOk = true
|
|
2497
|
+
designStatus({ previewId: edit.record.previewId, requestId: edit.request.cid, state: 'verifying', message: 'Waiting for correlated desktop and mobile renders…' })
|
|
2498
|
+
edit.timer = setTimeout(() => finishDesign(id, 'failed', { message: 'The edit finished without a correlated desktop and mobile render.' }), 120_000)
|
|
2499
|
+
edit.timer.unref?.()
|
|
2500
|
+
maybeVerifyDesign(id)
|
|
2501
|
+
}
|
|
2502
|
+
} else if ((designQueues.get(id) || []).length) {
|
|
2503
|
+
queueMicrotask(() => pumpDesign(id))
|
|
2504
|
+
}
|
|
2395
2505
|
if (entry.sideHandback) {
|
|
2396
2506
|
const request = entry.sideHandback
|
|
2397
2507
|
entry.sideHandback = null
|
|
@@ -2667,6 +2777,13 @@ function endStructured(id) {
|
|
|
2667
2777
|
try { s.session?.end() } catch { /* noop */ }
|
|
2668
2778
|
try { s.mockupWatcher?.close() } catch { /* noop */ }
|
|
2669
2779
|
try { void s.viewport?.stop()?.catch(() => {}) } catch { /* noop */ }
|
|
2780
|
+
const activeDesign = designActive.get(id)
|
|
2781
|
+
if (activeDesign?.timer) clearTimeout(activeDesign.timer)
|
|
2782
|
+
designActive.delete(id)
|
|
2783
|
+
designQueues.delete(id)
|
|
2784
|
+
for (const [previewId, record] of designArtifacts) if (record.term === id) designArtifacts.delete(previewId)
|
|
2785
|
+
for (const [previewId, restore] of designRestore) if (restore.priorRecord?.term === id) designRestore.delete(previewId)
|
|
2786
|
+
try { fs.rmSync(ownerOutbox('sessions', id), { recursive: true, force: true }) } catch { /* best-effort host artifact cleanup */ }
|
|
2670
2787
|
sessions.delete(id)
|
|
2671
2788
|
bcast('term-exit', { id })
|
|
2672
2789
|
reapTerminalRow(id) // bridge-side reap: delete the code_terminals row ourselves — the
|
|
@@ -3745,6 +3862,64 @@ flowChannel
|
|
|
3745
3862
|
}
|
|
3746
3863
|
})
|
|
3747
3864
|
|
|
3865
|
+
// Design control is a separate PRIVATE channel. The browser cannot target a
|
|
3866
|
+
// lane or path: both are derived from the host-only artifact registry.
|
|
3867
|
+
const seenDesignRequests = new Set()
|
|
3868
|
+
designChannel
|
|
3869
|
+
.on('broadcast', { event: 'design-capability' }, ({ payload }) => {
|
|
3870
|
+
const record = payload?.previewId && designArtifacts.get(String(payload.previewId))
|
|
3871
|
+
const live = record && resolveDesignSource(record.sourcePath, record.workspaceRoot)
|
|
3872
|
+
const ok = !!record && !!live && live.revision === record.revision && record.revision === payload?.revision && sessions.has(record.term)
|
|
3873
|
+
designChannel.send({ type: 'broadcast', event: 'design-capability-res', payload: { previewId: String(payload?.previewId || ''), revision: String(payload?.revision || ''), ok } })
|
|
3874
|
+
})
|
|
3875
|
+
.on('broadcast', { event: 'design-apply' }, ({ payload }) => {
|
|
3876
|
+
const verdict = validateDesignRequest(payload, designArtifacts)
|
|
3877
|
+
const previewId = String(payload?.previewId || '')
|
|
3878
|
+
const requestId = String(payload?.cid || '').slice(0, 80)
|
|
3879
|
+
if (!verdict.ok) {
|
|
3880
|
+
designStatus({ previewId, requestId, state: verdict.error === 'stale-preview' ? 'stale' : 'failed', message: verdict.error })
|
|
3881
|
+
return
|
|
3882
|
+
}
|
|
3883
|
+
if (!requestId || seenDesignRequests.has(requestId)) return
|
|
3884
|
+
seenDesignRequests.add(requestId)
|
|
3885
|
+
if (seenDesignRequests.size > 500) seenDesignRequests.delete(seenDesignRequests.values().next().value)
|
|
3886
|
+
const term = verdict.record.term
|
|
3887
|
+
const queue = designQueues.get(term) || []
|
|
3888
|
+
designQueues.set(term, queue)
|
|
3889
|
+
if (queue.length >= 8) {
|
|
3890
|
+
designStatus({ previewId, requestId, state: 'failed', message: 'Too many Design edits are queued for this lane.' })
|
|
3891
|
+
return
|
|
3892
|
+
}
|
|
3893
|
+
queue.push({ ...verdict, by: 'A room member' })
|
|
3894
|
+
designStatus({ previewId, requestId, state: 'queued' })
|
|
3895
|
+
pumpDesign(term)
|
|
3896
|
+
})
|
|
3897
|
+
.on('broadcast', { event: 'design-restore' }, ({ payload }) => {
|
|
3898
|
+
const previewId = String(payload?.previewId || '')
|
|
3899
|
+
const requestId = String(payload?.cid || '').slice(0, 80)
|
|
3900
|
+
const record = designArtifacts.get(previewId)
|
|
3901
|
+
const restore = designRestore.get(previewId)
|
|
3902
|
+
const live = record && resolveDesignSource(record.sourcePath, record.workspaceRoot)
|
|
3903
|
+
if (!requestId || !record || !restore?.priorRecord?.backupPath || !live || live.revision !== record.revision || payload?.revision !== record.revision) {
|
|
3904
|
+
designStatus({ previewId, requestId, state: 'stale', message: 'That verified revision can no longer be restored safely.' })
|
|
3905
|
+
return
|
|
3906
|
+
}
|
|
3907
|
+
if (seenDesignRequests.has(requestId)) return
|
|
3908
|
+
seenDesignRequests.add(requestId)
|
|
3909
|
+
const queue = designQueues.get(record.term) || []
|
|
3910
|
+
designQueues.set(record.term, queue)
|
|
3911
|
+
if (queue.length >= 8) {
|
|
3912
|
+
designStatus({ previewId, requestId, state: 'failed', message: 'Too many Design edits are queued for this lane.' })
|
|
3913
|
+
return
|
|
3914
|
+
}
|
|
3915
|
+
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' })
|
|
3916
|
+
designStatus({ previewId, requestId, state: 'queued', message: 'Restore queued in the producing lane.' })
|
|
3917
|
+
pumpDesign(record.term)
|
|
3918
|
+
})
|
|
3919
|
+
.subscribe((status) => {
|
|
3920
|
+
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') process.stderr.write(`\n ⚠ design realtime ${status} (tpdesign:${room}).\n`)
|
|
3921
|
+
})
|
|
3922
|
+
|
|
3748
3923
|
// Watchdog — if the realtime channel is stuck (never connected, or dropped and
|
|
3749
3924
|
// not recovered) for >60s, exit non-zero so a supervisor/launchd restarts a
|
|
3750
3925
|
// clean process. Healthy long-lived connections never trip this (brokenSince=0).
|
|
@@ -3904,6 +4079,7 @@ async function shutdown(code = 0, farewell = true) {
|
|
|
3904
4079
|
// topic (no presence to untrack, but the subscription + socket join outlived the
|
|
3905
4080
|
// process teardown). Close it too so shutdown is symmetric.
|
|
3906
4081
|
try { await supabase.removeChannel(flowChannel) } catch { /* noop */ }
|
|
4082
|
+
try { await supabase.removeChannel(designChannel) } catch { /* noop */ }
|
|
3907
4083
|
setTimeout(() => process.exit(code), 250) // grace for the leave/close frames to flush
|
|
3908
4084
|
}
|
|
3909
4085
|
process.on('SIGINT', () => shutdown(0))
|
package/design-edit.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
export function sourceRevision(source) {
|
|
12
|
+
return crypto.createHash('sha256').update(String(source)).digest('hex').slice(0, 24)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveDesignSource(file, workspaceRoot) {
|
|
16
|
+
if (!file || !workspaceRoot) return null
|
|
17
|
+
let sourcePath, root
|
|
18
|
+
try {
|
|
19
|
+
sourcePath = fs.realpathSync(file)
|
|
20
|
+
root = fs.realpathSync(workspaceRoot)
|
|
21
|
+
const stat = fs.statSync(sourcePath)
|
|
22
|
+
if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES) return null
|
|
23
|
+
} catch { return null }
|
|
24
|
+
const rel = path.relative(root, sourcePath)
|
|
25
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel) || path.extname(sourcePath).toLowerCase() !== '.html') return null
|
|
26
|
+
// Generated bundles are evidence, not editable source. The agent may only receive
|
|
27
|
+
// an exact authored HTML artifact; it must never be pointed at dist output.
|
|
28
|
+
if (rel.split(path.sep).some((part) => ['dist', 'build', '.next', '.git', 'coverage', 'node_modules'].includes(part))) return null
|
|
29
|
+
const source = fs.readFileSync(sourcePath, 'utf8')
|
|
30
|
+
const revision = sourceRevision(source)
|
|
31
|
+
const previewId = crypto.createHash('sha256')
|
|
32
|
+
.update(`${sourcePath}\0${revision}`)
|
|
33
|
+
.digest('base64url').slice(0, 32)
|
|
34
|
+
return { previewId, revision, sourcePath, workspaceRoot: root, source }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function safeTarget(value) {
|
|
38
|
+
if (!value || typeof value !== 'object') return null
|
|
39
|
+
const rect = value.rect && typeof value.rect === 'object' ? {
|
|
40
|
+
x: Number(value.rect.x) || 0,
|
|
41
|
+
y: Number(value.rect.y) || 0,
|
|
42
|
+
width: Math.max(0, Number(value.rect.width) || 0),
|
|
43
|
+
height: Math.max(0, Number(value.rect.height) || 0),
|
|
44
|
+
} : null
|
|
45
|
+
return {
|
|
46
|
+
selector: clean(value.selector, 600),
|
|
47
|
+
xpath: clean(value.xpath, 600),
|
|
48
|
+
tag: clean(value.tag, 40).toLowerCase(),
|
|
49
|
+
role: clean(value.role, 80),
|
|
50
|
+
name: clean(value.name, 240),
|
|
51
|
+
text: clean(value.text, MAX_TEXT),
|
|
52
|
+
canEditText: value.canEditText === true,
|
|
53
|
+
rect,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function validateDesignRequest(payload, registry) {
|
|
58
|
+
if (!payload || typeof payload !== 'object') return { ok: false, error: 'invalid-request' }
|
|
59
|
+
const previewId = clean(payload.previewId, 64)
|
|
60
|
+
const record = registry.get(previewId)
|
|
61
|
+
if (!record) return { ok: false, error: 'preview-unavailable' }
|
|
62
|
+
if (clean(payload.revision, 64) !== record.revision) return { ok: false, error: 'stale-preview' }
|
|
63
|
+
const target = safeTarget(payload.target)
|
|
64
|
+
if (!target?.selector && !target?.xpath) return { ok: false, error: 'select-an-element' }
|
|
65
|
+
const mode = payload.mode === 'text' ? 'text' : payload.mode === 'prompt' ? 'prompt' : null
|
|
66
|
+
if (!mode) return { ok: false, error: 'invalid-mode' }
|
|
67
|
+
const intent = clean(payload.intent, mode === 'text' ? MAX_TEXT : MAX_INTENT)
|
|
68
|
+
if (!intent) return { ok: false, error: mode === 'text' ? 'enter-text' : 'describe-change' }
|
|
69
|
+
if (mode === 'text' && !target.canEditText) return { ok: false, error: 'text-edit-not-safe' }
|
|
70
|
+
return { ok: true, record, request: { previewId, revision: record.revision, mode, intent, target, cid: clean(payload.cid, 80) } }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function designPrompt({ record, request, by, restore = false, priorRecord = null }) {
|
|
74
|
+
const target = JSON.stringify(request.target, null, 2)
|
|
75
|
+
const action = restore
|
|
76
|
+
? `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.`
|
|
77
|
+
: request.mode === 'text'
|
|
78
|
+
? `Replace only this element's visible text with:\n${request.intent}`
|
|
79
|
+
: `Apply this visual change to only the selected element unless the instruction explicitly requires its immediate context:\n${request.intent}`
|
|
80
|
+
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.`
|
|
81
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.217",
|
|
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",
|