thinkpool-pair 0.7.227 → 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 +35 -18
- package/codex-event-mapper.mjs +10 -2
- package/design-edit.mjs +47 -16
- package/package.json +1 -1
- package/thinkpool-room-prompt.mjs +1 -1
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,
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
4034
|
+
designStatus({ previewId, requestId, state: 'failed', message: `Edit ${index + 1}: The private image draft could not be opened.` })
|
|
4014
4035
|
return
|
|
4015
4036
|
}
|
|
4016
|
-
|
|
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
|
-
|
|
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/codex-event-mapper.mjs
CHANGED
|
@@ -37,6 +37,14 @@ const resultText = (value) => {
|
|
|
37
37
|
if (value == null) return ''
|
|
38
38
|
try { return JSON.stringify(value) } catch { return String(value) }
|
|
39
39
|
}
|
|
40
|
+
// Codex uses both `message: string` and `message: { message: string }` across
|
|
41
|
+
// its exec/app-server error paths. Room events must keep `message` scalar: an
|
|
42
|
+
// object persists successfully but crashes React when that transcript replays.
|
|
43
|
+
const errorText = (value, fallback) => {
|
|
44
|
+
if (typeof value === 'string' && value.trim()) return value
|
|
45
|
+
if (value && typeof value === 'object' && typeof value.message === 'string' && value.message.trim()) return value.message
|
|
46
|
+
return resultText(value) || fallback
|
|
47
|
+
}
|
|
40
48
|
|
|
41
49
|
export class CodexEventMapper {
|
|
42
50
|
/**
|
|
@@ -187,13 +195,13 @@ export class CodexEventMapper {
|
|
|
187
195
|
}
|
|
188
196
|
|
|
189
197
|
case 'turn.failed': {
|
|
190
|
-
this._emit({ kind: 'error', message: ev.message
|
|
198
|
+
this._emit({ kind: 'error', message: errorText(ev.message ?? ev.error, 'codex turn failed'), recoverable: true })
|
|
191
199
|
this._emit({ kind: 'result', subtype: 'error_during_execution', sessionId: this._sessionId, model: this._model, costUsd: null, usage: null, numTurns: 1, durationMs: undefined, denials: 0, resultText: null })
|
|
192
200
|
return
|
|
193
201
|
}
|
|
194
202
|
|
|
195
203
|
case 'error':
|
|
196
|
-
this._emit({ kind: 'error', message: ev.message
|
|
204
|
+
this._emit({ kind: 'error', message: errorText(ev.message ?? ev.error, 'codex error'), recoverable: true })
|
|
197
205
|
return
|
|
198
206
|
|
|
199
207
|
default:
|
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
|
|
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
|
|
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
|
|
193
|
-
const
|
|
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
|
-
:
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
@@ -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.',
|