thinkpool-pair 0.7.222 → 0.7.223

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, resolveDesignSource, resolveManifestDesignSource, restoreDesignSources, syncRealtimeAuth, validateDesignRequest } from './design-edit.mjs'
69
+ import { designPrompt, materializeDesignAsset, resolveDesignSource, resolveManifestDesignSource, restoreDesignSources, syncRealtimeAuth, validateDesignRequest } 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.
@@ -1284,7 +1284,11 @@ function pumpDesign(term) {
1284
1284
  designActive.set(term, active)
1285
1285
  designStatus({ previewId: next.record.previewId, requestId: next.request.cid, state: 'applying' })
1286
1286
  // Safe transcript summary: never include host paths or the full locator packet.
1287
- 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 }
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 }
1288
1292
  stampEvent(visible); pushLog(lane, visible); bcast('code-event', { term, evt: visible })
1289
1293
  try { lane.session.sendTurn(designPrompt({ record: next.record, request: next.request, by: next.by, restore: !!next.restoreRecord, priorRecord: next.restoreRecord })) }
1290
1294
  catch { finishDesign(term, 'failed', { message: 'The producing lane could not start the edit.' }) }
@@ -3978,8 +3982,8 @@ designChannel
3978
3982
  const ok = !!record && !!live && live.revision === record.revision && record.revision === payload?.revision && sessions.has(record.term)
3979
3983
  designChannel.send({ type: 'broadcast', event: 'design-capability-res', payload: { previewId: String(payload?.previewId || ''), revision: String(payload?.revision || ''), ok } })
3980
3984
  })
3981
- .on('broadcast', { event: 'design-apply' }, ({ payload }) => {
3982
- const verdict = validateDesignRequest(payload, designArtifacts)
3985
+ .on('broadcast', { event: 'design-apply' }, async ({ payload }) => {
3986
+ const verdict = validateDesignRequest(payload, designArtifacts, room)
3983
3987
  const previewId = String(payload?.previewId || '')
3984
3988
  const requestId = String(payload?.cid || '').slice(0, 80)
3985
3989
  if (!verdict.ok) {
@@ -3989,6 +3993,20 @@ designChannel
3989
3993
  if (!requestId || seenDesignRequests.has(requestId)) return
3990
3994
  seenDesignRequests.add(requestId)
3991
3995
  if (seenDesignRequests.size > 500) seenDesignRequests.delete(seenDesignRequests.values().next().value)
3996
+ if (verdict.request.mode === 'image') {
3997
+ const localPath = await materializeDesignAsset({
3998
+ webBase: WEB_BASE,
3999
+ token: codeAuthToken,
4000
+ room,
4001
+ asset: verdict.request.asset,
4002
+ requestId,
4003
+ })
4004
+ if (!localPath) {
4005
+ designStatus({ previewId, requestId, state: 'failed', message: 'The private image draft could not be opened.' })
4006
+ return
4007
+ }
4008
+ verdict.request.asset.localPath = localPath
4009
+ }
3992
4010
  const term = verdict.record.term
3993
4011
  const queue = designQueues.get(term) || []
3994
4012
  designQueues.set(term, queue)
package/design-edit.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import crypto from 'node:crypto'
2
2
  import fs from 'node:fs'
3
+ import os from 'node:os'
3
4
  import path from 'node:path'
4
5
 
5
6
  const MAX_SOURCE_BYTES = 2 * 1024 * 1024
@@ -110,11 +111,36 @@ function safeTarget(value) {
110
111
  name: clean(value.name, 240),
111
112
  text: clean(value.text, MAX_TEXT),
112
113
  canEditText: value.canEditText === true,
114
+ canReplaceImage: value.canReplaceImage === true,
115
+ nodeKey: clean(value.nodeKey, 80),
116
+ parentNodeKey: clean(value.parentNodeKey, 80),
113
117
  rect,
114
118
  }
115
119
  }
116
120
 
117
- export function validateDesignRequest(payload, registry) {
121
+ function safeMovement(value) {
122
+ if (!value || typeof value !== 'object') return null
123
+ const fromIndex = Number(value.fromIndex)
124
+ const toIndex = Number(value.toIndex)
125
+ if (![fromIndex, toIndex].every(Number.isSafeInteger) || fromIndex < 0 || toIndex < 0 || fromIndex > 499 || toIndex > 499) return null
126
+ return { fromIndex, toIndex }
127
+ }
128
+
129
+ function safeAsset(value, room) {
130
+ if (!value || typeof value !== 'object') return null
131
+ const asset = {
132
+ path: clean(value.path, 700),
133
+ name: clean(value.name, 240),
134
+ mediaType: clean(value.mediaType, 120).toLowerCase(),
135
+ size: Number(value.size),
136
+ }
137
+ const prefix = room ? `code/${String(room).toUpperCase()}/design-drafts/` : 'code/'
138
+ if (!asset.path.startsWith(prefix) || asset.path.split('/').includes('..') || !asset.mediaType.startsWith('image/')) return null
139
+ if (!Number.isSafeInteger(asset.size) || asset.size <= 0 || asset.size > 10 * 1024 * 1024) return null
140
+ return asset
141
+ }
142
+
143
+ export function validateDesignRequest(payload, registry, room = '') {
118
144
  if (!payload || typeof payload !== 'object') return { ok: false, error: 'invalid-request' }
119
145
  const previewId = clean(payload.previewId, 64)
120
146
  const record = registry.get(previewId)
@@ -122,12 +148,44 @@ export function validateDesignRequest(payload, registry) {
122
148
  if (clean(payload.revision, 64) !== record.revision) return { ok: false, error: 'stale-preview' }
123
149
  const target = safeTarget(payload.target)
124
150
  if (!target?.selector && !target?.xpath) return { ok: false, error: 'select-an-element' }
125
- const mode = payload.mode === 'text' ? 'text' : payload.mode === 'prompt' ? 'prompt' : null
151
+ const mode = ['text', 'prompt', 'move', 'image'].includes(payload.mode) ? payload.mode : null
126
152
  if (!mode) return { ok: false, error: 'invalid-mode' }
127
153
  const intent = clean(payload.intent, mode === 'text' ? MAX_TEXT : MAX_INTENT)
128
- if (!intent) return { ok: false, error: mode === 'text' ? 'enter-text' : 'describe-change' }
154
+ if (['text', 'prompt'].includes(mode) && !intent) return { ok: false, error: mode === 'text' ? 'enter-text' : 'describe-change' }
129
155
  if (mode === 'text' && !target.canEditText) return { ok: false, error: 'text-edit-not-safe' }
130
- return { ok: true, record, request: { previewId, revision: record.revision, mode, intent, target, cid: clean(payload.cid, 80) } }
156
+ const movement = mode === 'move' ? safeMovement(payload.movement) : null
157
+ if (mode === 'move' && (!movement || movement.fromIndex === movement.toIndex)) return { ok: false, error: 'invalid-move' }
158
+ const asset = mode === 'image' ? safeAsset(payload.asset, room) : null
159
+ if (mode === 'image' && (!target.canReplaceImage || !asset)) return { ok: false, error: 'invalid-image' }
160
+ return { ok: true, record, request: { previewId, revision: record.revision, mode, intent, target, movement, asset, cid: clean(payload.cid, 80) } }
161
+ }
162
+
163
+ export async function materializeDesignAsset({ webBase, token, room, asset, requestId, fetcher = fetch }) {
164
+ if (!webBase || !token || !room || !asset?.path || !requestId) return null
165
+ const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }
166
+ const signed = await fetcher(`${String(webBase).replace(/\/$/, '')}/api/code-attachment-url`, {
167
+ method: 'POST',
168
+ headers,
169
+ body: JSON.stringify({ code: room, path: asset.path }),
170
+ })
171
+ if (!signed.ok) return null
172
+ const { url } = await signed.json()
173
+ if (!url) return null
174
+ const response = await fetcher(url)
175
+ if (!response.ok) return null
176
+ const data = Buffer.from(await response.arrayBuffer())
177
+ if (!data.length || data.length > 10 * 1024 * 1024) return null
178
+ const ext = path.extname(asset.name).replace(/[^.a-zA-Z0-9]/g, '').slice(0, 9) || '.img'
179
+ const dir = path.join(os.tmpdir(), 'thinkpool-design-assets')
180
+ const file = path.join(dir, `${clean(requestId, 80)}${ext}`)
181
+ fs.mkdirSync(dir, { recursive: true })
182
+ 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(() => {})
188
+ return file
131
189
  }
132
190
 
133
191
  export function designPrompt({ record, request, by, restore = false, priorRecord = null }) {
@@ -136,6 +194,10 @@ export function designPrompt({ record, request, by, restore = false, priorRecord
136
194
  ? `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.`
137
195
  : request.mode === 'text'
138
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.`
139
201
  : `Apply this visual change to only the selected element unless the instruction explicitly requires its immediate context:\n${request.intent}`
140
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.`
141
203
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.222",
3
+ "version": "0.7.223",
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": {