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 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 { paths } = await res.json()
1282
- bcast('code-event', { term, evt: { kind: 'mockup', __struct: true, cid, term, slug: m.slug, title: m.title || m.slug, ts: m.ts, paths } })
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') { codeAuthToken = m.accessToken || null; refreshOwnerPlan() }
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))
@@ -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.216",
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",