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 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 { 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 } })
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))
@@ -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.216",
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",