thinkpool-pair 0.7.228 → 0.7.229

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, 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.
@@ -1288,7 +1288,15 @@ function pumpDesign(term) {
1288
1288
  : next.request.mode === 'move' ? 'move element'
1289
1289
  : next.request.mode === 'image' ? 'replace image'
1290
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 }
1291
+ const batchCount = next.request.mode === 'batch' ? next.request.edits.length : 0
1292
+ const visible = {
1293
+ kind: 'you',
1294
+ text: batchCount
1295
+ ? `Design batch · ${batchCount} pooled edit${batchCount === 1 ? '' : 's'}`
1296
+ : `Design edit · ${next.request.target.name || next.request.target.tag}: ${action}`,
1297
+ cid: next.request.cid,
1298
+ by: next.by,
1299
+ }
1292
1300
  stampEvent(visible); pushLog(lane, visible); bcast('code-event', { term, evt: visible })
1293
1301
  try { lane.session.sendTurn(designPrompt({ record: next.record, request: next.request, by: next.by, restore: !!next.restoreRecord, priorRecord: next.restoreRecord })) }
1294
1302
  catch { finishDesign(term, 'failed', { message: 'The producing lane could not start the edit.' }) }
@@ -3991,39 +3999,48 @@ designChannel
3991
3999
  designChannel.send({ type: 'broadcast', event: 'design-capability-res', payload: { previewId: String(payload?.previewId || ''), revision: String(payload?.revision || ''), ok } })
3992
4000
  })
3993
4001
  .on('broadcast', { event: 'design-apply' }, async ({ payload }) => {
3994
- const verdict = validateDesignRequest(payload, designArtifacts, room)
4002
+ const verdict = validateDesignBatchRequest(payload, designArtifacts, room)
3995
4003
  const previewId = String(payload?.previewId || '')
3996
4004
  const requestId = String(payload?.cid || '').slice(0, 80)
3997
4005
  if (!verdict.ok) {
3998
- designStatus({ previewId, requestId, state: verdict.error === 'stale-preview' ? 'stale' : 'failed', message: verdict.error })
4006
+ const prefix = Number.isSafeInteger(verdict.editIndex) ? `Edit ${verdict.editIndex + 1}: ` : ''
4007
+ designStatus({ previewId, requestId, state: verdict.error === 'stale-preview' ? 'stale' : 'failed', message: `${prefix}${verdict.error}` })
3999
4008
  return
4000
4009
  }
4001
4010
  if (!requestId || seenDesignRequests.has(requestId)) return
4002
4011
  seenDesignRequests.add(requestId)
4003
4012
  if (seenDesignRequests.size > 500) seenDesignRequests.delete(seenDesignRequests.values().next().value)
4004
- if (verdict.request.mode === 'image') {
4013
+ const term = verdict.record.term
4014
+ const queue = designQueues.get(term) || []
4015
+ designQueues.set(term, queue)
4016
+ if (queue.length >= 8) {
4017
+ designStatus({ previewId, requestId, state: 'failed', message: 'Too many Design batches are queued for this lane.' })
4018
+ return
4019
+ }
4020
+ const edits = verdict.request.mode === 'batch' ? verdict.request.edits : [verdict.request]
4021
+ const materializedDrafts = []
4022
+ for (let index = 0; index < edits.length; index++) {
4023
+ const edit = edits[index]
4024
+ if (edit.mode !== 'image') continue
4005
4025
  const localPath = await materializeDesignAsset({
4006
4026
  webBase: WEB_BASE,
4007
4027
  token: codeAuthToken,
4008
4028
  room,
4009
- asset: verdict.request.asset,
4010
- requestId,
4029
+ asset: edit.asset,
4030
+ requestId: `${requestId}-${index + 1}`,
4031
+ cleanup: false,
4011
4032
  })
4012
4033
  if (!localPath) {
4013
- designStatus({ previewId, requestId, state: 'failed', message: 'The private image draft could not be opened.' })
4034
+ designStatus({ previewId, requestId, state: 'failed', message: `Edit ${index + 1}: The private image draft could not be opened.` })
4014
4035
  return
4015
4036
  }
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
4037
+ edit.asset.localPath = localPath
4038
+ materializedDrafts.push(edit.asset.path)
4024
4039
  }
4040
+ await Promise.all(materializedDrafts.map((assetPath) => deleteDesignAssetDraft({ webBase: WEB_BASE, token: codeAuthToken, room, path: assetPath })))
4025
4041
  queue.push({ ...verdict, by: 'A room member' })
4026
- designStatus({ previewId, requestId, state: 'queued' })
4042
+ const batchCount = verdict.request.mode === 'batch' ? verdict.request.edits.length : 1
4043
+ designStatus({ previewId, requestId, state: 'queued', message: `${batchCount} edit${batchCount === 1 ? '' : 's'} pooled to the producing lane.` })
4027
4044
  pumpDesign(term)
4028
4045
  })
4029
4046
  .on('broadcast', { event: 'design-restore' }, ({ payload }) => {
@@ -4044,7 +4061,7 @@ designChannel
4044
4061
  designStatus({ previewId, requestId, state: 'failed', message: 'Too many Design edits are queued for this lane.' })
4045
4062
  return
4046
4063
  }
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' })
4064
+ 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: 'A room member' })
4048
4065
  designStatus({ previewId, requestId, state: 'queued', message: 'Restore queued in the producing lane.' })
4049
4066
  pumpDesign(record.term)
4050
4067
  })
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,23 @@ 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
+
191
226
  export function designPrompt({ record, request, by, restore = false, priorRecord = null }) {
192
- const target = JSON.stringify(request.target, null, 2)
193
- const action = restore
227
+ const batch = request.mode === 'batch' ? request.edits : null
228
+ const work = restore
194
229
  ? `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.`
230
+ : batch
231
+ ? 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')
232
+ : `Selected element:\n${JSON.stringify(request.target, null, 2)}\n\n${designAction(request)}`
233
+ 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
234
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.228",
3
+ "version": "0.7.229",
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.',