thinkpool-pair 0.7.228 → 0.7.230

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
@@ -66,7 +66,7 @@ import { writeLaneArtifact, digestSlice, appendDigest, resumeLane } from './flow
66
66
  import { createFlowWorktree, worktreeSpec } from './flow-worktree.mjs'
67
67
  import { startPreview, stopAllPreviews, previews } from './flow-preview.mjs'
68
68
  import { ViewportManager, createViewportTools, sharedViewportBrowser } from './viewport.mjs'
69
- import { designPrompt, materializeDesignAsset, resolveDesignSource, resolveManifestDesignSource, restoreDesignSources, syncRealtimeAuth, validateDesignRequest } from './design-edit.mjs'
69
+ import { deleteDesignAssetDraft, designPrompt, designTranscript, materializeDesignAsset, resolveDesignSource, resolveManifestDesignSource, restoreDesignSources, syncRealtimeAuth, validateDesignBatchRequest } from './design-edit.mjs'
70
70
  // FL-M9 — per-lane preview servers leak (one per done lane, never stopped until shutdown).
71
71
  // Lane previews are keyed `lane:<flowId>:<laneId>`; stop a whole flow's set when it assembles
72
72
  // (the assembled preview supersedes them) or when a lane is reverted.
@@ -1283,12 +1283,14 @@ function pumpDesign(term) {
1283
1283
  const active = { ...next, resultOk: false, proof: null, timer: null }
1284
1284
  designActive.set(term, active)
1285
1285
  designStatus({ previewId: next.record.previewId, requestId: next.request.cid, state: 'applying' })
1286
- // Safe transcript summary: never include host paths or the full locator packet.
1287
- const action = next.request.mode === 'text' ? 'replace text'
1288
- : next.request.mode === 'move' ? 'move element'
1289
- : next.request.mode === 'image' ? 'replace image'
1290
- : next.request.intent
1291
- const visible = { kind: 'you', text: `Design edit · ${next.request.target.name || next.request.target.tag}: ${action}`, cid: next.request.cid, by: next.by }
1286
+ // Complete room-visible instructions, without host paths or locator packets.
1287
+ const visible = {
1288
+ kind: 'you',
1289
+ text: designTranscript(next.request, { restore: !!next.restoreRecord }),
1290
+ cid: next.request.cid,
1291
+ by: next.by,
1292
+ design: true,
1293
+ }
1292
1294
  stampEvent(visible); pushLog(lane, visible); bcast('code-event', { term, evt: visible })
1293
1295
  try { lane.session.sendTurn(designPrompt({ record: next.record, request: next.request, by: next.by, restore: !!next.restoreRecord, priorRecord: next.restoreRecord })) }
1294
1296
  catch { finishDesign(term, 'failed', { message: 'The producing lane could not start the edit.' }) }
@@ -3991,39 +3993,49 @@ designChannel
3991
3993
  designChannel.send({ type: 'broadcast', event: 'design-capability-res', payload: { previewId: String(payload?.previewId || ''), revision: String(payload?.revision || ''), ok } })
3992
3994
  })
3993
3995
  .on('broadcast', { event: 'design-apply' }, async ({ payload }) => {
3994
- const verdict = validateDesignRequest(payload, designArtifacts, room)
3996
+ const verdict = validateDesignBatchRequest(payload, designArtifacts, room)
3995
3997
  const previewId = String(payload?.previewId || '')
3996
3998
  const requestId = String(payload?.cid || '').slice(0, 80)
3997
3999
  if (!verdict.ok) {
3998
- designStatus({ previewId, requestId, state: verdict.error === 'stale-preview' ? 'stale' : 'failed', message: verdict.error })
4000
+ const prefix = Number.isSafeInteger(verdict.editIndex) ? `Edit ${verdict.editIndex + 1}: ` : ''
4001
+ designStatus({ previewId, requestId, state: verdict.error === 'stale-preview' ? 'stale' : 'failed', message: `${prefix}${verdict.error}` })
3999
4002
  return
4000
4003
  }
4001
4004
  if (!requestId || seenDesignRequests.has(requestId)) return
4002
4005
  seenDesignRequests.add(requestId)
4003
4006
  if (seenDesignRequests.size > 500) seenDesignRequests.delete(seenDesignRequests.values().next().value)
4004
- if (verdict.request.mode === 'image') {
4007
+ const term = verdict.record.term
4008
+ const queue = designQueues.get(term) || []
4009
+ designQueues.set(term, queue)
4010
+ if (queue.length >= 8) {
4011
+ designStatus({ previewId, requestId, state: 'failed', message: 'Too many Design batches are queued for this lane.' })
4012
+ return
4013
+ }
4014
+ const edits = verdict.request.mode === 'batch' ? verdict.request.edits : [verdict.request]
4015
+ const materializedDrafts = []
4016
+ for (let index = 0; index < edits.length; index++) {
4017
+ const edit = edits[index]
4018
+ if (edit.mode !== 'image') continue
4005
4019
  const localPath = await materializeDesignAsset({
4006
4020
  webBase: WEB_BASE,
4007
4021
  token: codeAuthToken,
4008
4022
  room,
4009
- asset: verdict.request.asset,
4010
- requestId,
4023
+ asset: edit.asset,
4024
+ requestId: `${requestId}-${index + 1}`,
4025
+ cleanup: false,
4011
4026
  })
4012
4027
  if (!localPath) {
4013
- designStatus({ previewId, requestId, state: 'failed', message: 'The private image draft could not be opened.' })
4028
+ designStatus({ previewId, requestId, state: 'failed', message: `Edit ${index + 1}: The private image draft could not be opened.` })
4014
4029
  return
4015
4030
  }
4016
- verdict.request.asset.localPath = localPath
4017
- }
4018
- const term = verdict.record.term
4019
- const queue = designQueues.get(term) || []
4020
- designQueues.set(term, queue)
4021
- if (queue.length >= 8) {
4022
- designStatus({ previewId, requestId, state: 'failed', message: 'Too many Design edits are queued for this lane.' })
4023
- return
4031
+ edit.asset.localPath = localPath
4032
+ materializedDrafts.push(edit.asset.path)
4024
4033
  }
4025
- queue.push({ ...verdict, by: 'A room member' })
4026
- designStatus({ previewId, requestId, state: 'queued' })
4034
+ await Promise.all(materializedDrafts.map((assetPath) => deleteDesignAssetDraft({ webBase: WEB_BASE, token: codeAuthToken, room, path: assetPath })))
4035
+ const by = String(payload?.by || 'A room member').replace(/\0/g, '').trim().slice(0, 120) || 'A room member'
4036
+ queue.push({ ...verdict, by })
4037
+ const batchCount = verdict.request.mode === 'batch' ? verdict.request.edits.length : 1
4038
+ designStatus({ previewId, requestId, state: 'queued', message: `${batchCount} edit${batchCount === 1 ? '' : 's'} pooled to the producing lane.` })
4027
4039
  pumpDesign(term)
4028
4040
  })
4029
4041
  .on('broadcast', { event: 'design-restore' }, ({ payload }) => {
@@ -4044,7 +4056,8 @@ designChannel
4044
4056
  designStatus({ previewId, requestId, state: 'failed', message: 'Too many Design edits are queued for this lane.' })
4045
4057
  return
4046
4058
  }
4047
- 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' })
4059
+ const by = String(payload?.by || 'A room member').replace(/\0/g, '').trim().slice(0, 120) || 'A room member'
4060
+ queue.push({ record, restoreRecord: restore.priorRecord, request: { previewId, revision: record.revision, mode: 'restore', intent: 'restore previous verified revision', target: restore.request.target || { selector: 'html', tag: 'artifact', name: `${restore.request.edits?.length || 1} pooled edits` }, cid: requestId }, by })
4048
4061
  designStatus({ previewId, requestId, state: 'queued', message: 'Restore queued in the producing lane.' })
4049
4062
  pumpDesign(record.term)
4050
4063
  })
package/design-edit.mjs CHANGED
@@ -6,6 +6,7 @@ import path from 'node:path'
6
6
  const MAX_SOURCE_BYTES = 2 * 1024 * 1024
7
7
  const MAX_INTENT = 2_000
8
8
  const MAX_TEXT = 4_000
9
+ const MAX_BATCH_EDITS = 8
9
10
 
10
11
  const clean = (value, max) => String(value ?? '').replace(/\0/g, '').trim().slice(0, max)
11
12
 
@@ -160,7 +161,38 @@ export function validateDesignRequest(payload, registry, room = '') {
160
161
  return { ok: true, record, request: { previewId, revision: record.revision, mode, intent, target, movement, asset, cid: clean(payload.cid, 80) } }
161
162
  }
162
163
 
163
- export async function materializeDesignAsset({ webBase, token, room, asset, requestId, fetcher = fetch }) {
164
+ export function validateDesignBatchRequest(payload, registry, room = '') {
165
+ if (!payload || typeof payload !== 'object' || !Array.isArray(payload.edits)) return validateDesignRequest(payload, registry, room)
166
+ if (payload.edits.length < 1 || payload.edits.length > MAX_BATCH_EDITS) return { ok: false, error: 'invalid-batch' }
167
+ const previewId = clean(payload.previewId, 64)
168
+ const revision = clean(payload.revision, 64)
169
+ const cid = clean(payload.cid, 80)
170
+ const edits = []
171
+ const targets = new Set()
172
+ let record = null
173
+ for (let index = 0; index < payload.edits.length; index++) {
174
+ const verdict = validateDesignRequest({ ...payload.edits[index], previewId, revision, cid }, registry, room)
175
+ if (!verdict.ok) return { ...verdict, editIndex: index }
176
+ const targetKey = verdict.request.target.nodeKey || verdict.request.target.selector || verdict.request.target.xpath
177
+ if (targets.has(targetKey)) return { ok: false, error: 'duplicate-target', editIndex: index }
178
+ targets.add(targetKey)
179
+ record = verdict.record
180
+ edits.push(verdict.request)
181
+ }
182
+ return { ok: true, record, request: { previewId, revision: record.revision, mode: 'batch', edits, cid } }
183
+ }
184
+
185
+ export async function deleteDesignAssetDraft({ webBase, token, room, path: assetPath, fetcher = fetch }) {
186
+ if (!webBase || !token || !room || !assetPath) return false
187
+ const response = await fetcher(`${String(webBase).replace(/\/$/, '')}/api/code-design-draft`, {
188
+ method: 'DELETE',
189
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
190
+ body: JSON.stringify({ code: room, path: assetPath }),
191
+ }).catch(() => null)
192
+ return !!response?.ok
193
+ }
194
+
195
+ export async function materializeDesignAsset({ webBase, token, room, asset, requestId, cleanup = true, fetcher = fetch }) {
164
196
  if (!webBase || !token || !room || !asset?.path || !requestId) return null
165
197
  const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }
166
198
  const signed = await fetcher(`${String(webBase).replace(/\/$/, '')}/api/code-attachment-url`, {
@@ -180,24 +212,49 @@ export async function materializeDesignAsset({ webBase, token, room, asset, requ
180
212
  const file = path.join(dir, `${clean(requestId, 80)}${ext}`)
181
213
  fs.mkdirSync(dir, { recursive: true })
182
214
  fs.writeFileSync(file, data)
183
- await fetcher(`${String(webBase).replace(/\/$/, '')}/api/code-design-draft`, {
184
- method: 'DELETE',
185
- headers,
186
- body: JSON.stringify({ code: room, path: asset.path }),
187
- }).catch(() => {})
215
+ if (cleanup) await deleteDesignAssetDraft({ webBase, token, room, path: asset.path, fetcher })
188
216
  return file
189
217
  }
190
218
 
219
+ function designAction(request) {
220
+ if (request.mode === 'text') return `Replace only this element's visible text with:\n${request.intent}`
221
+ if (request.mode === 'move') return `Move only this selected element within its current direct parent, from sibling position ${request.movement.fromIndex + 1} to ${request.movement.toIndex + 1}. Preserve the parent, responsive behavior, semantics, and every other sibling.`
222
+ if (request.mode === 'image') return `Replace only this selected image's content with the uploaded image at this bridge-local temporary path:\n${request.asset.localPath}\nCopy it into an appropriate durable project asset location. Preserve the existing image frame, dimensions, border radius, object-fit behavior, responsive rules, and surrounding layout.`
223
+ return `Apply this visual change to only the selected element unless the instruction explicitly requires its immediate context:\n${request.intent}`
224
+ }
225
+
226
+ const designModeLabel = (mode) => ({ text: 'Text', prompt: 'Prompt', move: 'Move', image: 'Image' })[mode] || 'Edit'
227
+
228
+ function visibleDesignAction(request) {
229
+ if (request.mode === 'text') return `Replacement text:\n${clean(request.intent, MAX_TEXT)}`
230
+ if (request.mode === 'prompt') return `Instruction:\n${clean(request.intent, MAX_INTENT)}`
231
+ if (request.mode === 'move') return `Move from position ${request.movement.fromIndex + 1} to position ${request.movement.toIndex + 1}.`
232
+ if (request.mode === 'image') return `Replacement image: ${clean(request.asset?.name || 'uploaded image', 240)}`
233
+ return ''
234
+ }
235
+
236
+ // Complete, room-safe transcript text for the visible Design dispatch. Keep the
237
+ // user instructions and order; omit host-only paths and locator packets.
238
+ export function designTranscript(request, { restore = false } = {}) {
239
+ if (restore) return 'Thinkpool Design · restore the previous verified revision'
240
+ const edits = request?.mode === 'batch' ? request.edits : [request]
241
+ const valid = Array.isArray(edits) ? edits.filter(Boolean).slice(0, MAX_BATCH_EDITS) : []
242
+ const heading = request?.mode === 'batch'
243
+ ? `Thinkpool Design · ${valid.length} pooled edit${valid.length === 1 ? '' : 's'}`
244
+ : 'Thinkpool Design · 1 edit'
245
+ const details = valid.map((edit, index) => {
246
+ const target = clean(edit.target?.name || edit.target?.tag || 'element', 240)
247
+ return `${index + 1}. ${designModeLabel(edit.mode)} · ${target}\n${visibleDesignAction(edit)}`
248
+ })
249
+ return [heading, ...details].join('\n\n')
250
+ }
251
+
191
252
  export function designPrompt({ record, request, by, restore = false, priorRecord = null }) {
192
- const target = JSON.stringify(request.target, null, 2)
193
- const action = restore
253
+ const batch = request.mode === 'batch' ? request.edits : null
254
+ const work = restore
194
255
  ? `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.`
195
- : request.mode === 'text'
196
- ? `Replace only this element's visible text with:\n${request.intent}`
197
- : request.mode === 'move'
198
- ? `Move only this selected element within its current direct parent, from sibling position ${request.movement.fromIndex + 1} to ${request.movement.toIndex + 1}. Preserve the parent, responsive behavior, semantics, and every other sibling.`
199
- : request.mode === 'image'
200
- ? `Replace only this selected image's content with the uploaded image at this bridge-local temporary path:\n${request.asset.localPath}\nCopy it into an appropriate durable project asset location. Preserve the existing image frame, dimensions, border radius, object-fit behavior, responsive rules, and surrounding layout.`
201
- : `Apply this visual change to only the selected element unless the instruction explicitly requires its immediate context:\n${request.intent}`
202
- 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.`
256
+ : batch
257
+ ? batch.map((edit, index) => `Edit ${index + 1} of ${batch.length} · ${edit.mode}\nSelected element:\n${JSON.stringify(edit.target, null, 2)}\n\n${designAction(edit)}`).join('\n\n---\n\n')
258
+ : `Selected element:\n${JSON.stringify(request.target, null, 2)}\n\n${designAction(request)}`
259
+ 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' : batch ? `${batch.length} queued edits in one batch` : request.mode}\n\n${work}\n\nRules:\n- Re-read the exact artifact source and confirm its revision/content still matches every selected element before editing.\n${batch ? '- Apply the queued edits in order in one source pass, then render once after the full batch.' : '- Apply only the requested change, then render the result once.'}\n- Edit the canonical source above; do not search for or edit a different plausible file.\n- Preserve the represented product page faithfully: keep its real copy, structure, fonts, assets, spacing, and responsive behavior except for the selected edits. Never replace it with generic mockup content.\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 any target is stale or ambiguous, stop without editing and explain that the person must reselect it.`
203
260
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.228",
3
+ "version": "0.7.230",
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": {
@@ -3,7 +3,7 @@
3
3
  // the first human turn in each fresh native thread. Keep the runtime-specific
4
4
  // orchestration rules in their own drivers — these are the cross-runtime truths
5
5
  // about where the people are and how finished work must reach them.
6
- export const THINKPOOL_DESIGN_DELIVERY_RULE = 'THINKPOOL DESIGN (mandatory for HTML): save the editable HTML source in the workspace, then use the $TP_MOCKUP_OUTBOX render helper so the room receives a Thinkpool Design card with both desktop (1440×900) and mobile (390×844) previews. Never paste raw HTML into chat, send an .html file as the room deliverable, or substitute a bare URL or single screenshot for this card. A shareable browser URL may accompany the card, but never replaces it.'
6
+ export const THINKPOOL_DESIGN_DELIVERY_RULE = 'THINKPOOL DESIGN (mandatory for HTML): save the editable HTML source in the workspace, then use the $TP_MOCKUP_OUTBOX render helper so the room receives a Thinkpool Design card with both desktop (1440×900) and mobile (390×844) previews. When the requested Design surface is an existing product page or route, first build and capture the actual route at both viewports, then read its current source, styles, copy, fonts, and assets. Derive the editable Design artifact from that evidence and compare both artifact captures against the real route before delivery. Preserve the real page faithfully except for explicitly proposed edits—never hand-recreate it from memory, simplify it, replace it with generic mockup content, or label an approximation as the product. If a faithful editable artifact cannot be produced, surface the actual route captures and say that Design editing is unavailable for that surface. Never paste raw HTML into chat, send an .html file as the room deliverable, or substitute a bare URL or single screenshot for this card. A shareable browser URL may accompany the card, but never replaces it.'
7
7
 
8
8
  export const THINKPOOL_REMOTE_DELIVERY_RULES = Object.freeze([
9
9
  'REMOTE USER (authoritative default for ThinkPool Code rooms): unless a person explicitly says they are at the host machine, assume everyone driving this room is remote — possibly on a phone — with no terminal and no access to the host filesystem. Never ask them to run a local command, open a local file, or "go check" something on the machine. Anything host-side, you run yourself and show the result in the room. Only suggest actions they can actually do from the room UI or a browser.',