thinkpool-pair 0.7.222 → 0.7.224
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 +30 -7
- package/codex-session.mjs +25 -2
- package/design-edit.mjs +66 -4
- package/package.json +1 -1
package/bridge.mjs
CHANGED
|
@@ -50,7 +50,7 @@ import { validateProviderSwitch, providerSwitchPlan, BUILTIN_PROVIDER } from './
|
|
|
50
50
|
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
|
|
51
51
|
import { z } from 'zod'
|
|
52
52
|
import { startClaudeSession } from './claude-session.mjs'
|
|
53
|
-
import { startCodexSession, readCodexDefaultModel, readCodexModels, codexConfigForMode, defaultModeForRuntime } from './codex-session.mjs'
|
|
53
|
+
import { startCodexSession, readCodexDefaultModel, readCodexModels, codexConfigForMode, codexThreadCanResume, defaultModeForRuntime } from './codex-session.mjs'
|
|
54
54
|
import { canonicalRoomFilePath, waitForNativeImages } from './codex-images.mjs'
|
|
55
55
|
import { createManagedLaneWorktree, removeManagedLaneWorktree } from './lane-worktree.mjs'
|
|
56
56
|
import { commandOnPath } from './agent-detect.mjs'
|
|
@@ -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.
|
|
@@ -103,7 +103,7 @@ const flowRedispatch = new Map()
|
|
|
103
103
|
// broadcasts; without persistent state the cap can never bite.
|
|
104
104
|
const flowBudgets = new Map()
|
|
105
105
|
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, laneStatusOf } from './cross-terminal.mjs'
|
|
106
|
-
import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce,
|
|
106
|
+
import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, supersedeInterruptedResume } from './interrupted-resume.mjs'
|
|
107
107
|
import { turnInFlight } from './update-gate.mjs'
|
|
108
108
|
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
|
|
109
109
|
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, codexUsageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
|
|
@@ -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
|
|
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.' }) }
|
|
@@ -1694,7 +1698,6 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
1694
1698
|
flowReviewRound: Number.isInteger(flowReviewRound) && flowReviewRound >= 0 ? flowReviewRound : 0,
|
|
1695
1699
|
dispatchBaseSha: dispatchBaseSha || null,
|
|
1696
1700
|
revertTarget: revertTarget || null,
|
|
1697
|
-
cwd: cwd || null, managedWorktree: managedWorktree || null,
|
|
1698
1701
|
// Stable creation order — persisted so a bridge restart restores tabs in the SAME
|
|
1699
1702
|
// order (not readdir/filesystem order). Legacy recs (no openedAt) derive it from the
|
|
1700
1703
|
// first transcript event ts, so even the first post-fix restart is ordered right.
|
|
@@ -3623,7 +3626,13 @@ channel
|
|
|
3623
3626
|
all.sort((a, b) => okey(a) - okey(b))
|
|
3624
3627
|
if (all.length) for (const rec of all) {
|
|
3625
3628
|
const wasInterrupted = restoredTurnOpen(rec.log || [])
|
|
3629
|
+
// Codex can persist a thread id before its rollout receives the
|
|
3630
|
+
// first metadata row. A stop followed immediately by a restart
|
|
3631
|
+
// leaves that file at zero bytes; both App Server and exec reject
|
|
3632
|
+
// it. Treat only that provably-empty case as non-resumable so the
|
|
3633
|
+
// existing fresh-thread recap recovery handles it.
|
|
3626
3634
|
const resumable = canResume(rec)
|
|
3635
|
+
&& (rec.runtime !== 'codex' || codexThreadCanResume(rec.sessionId))
|
|
3627
3636
|
const recoveryRecap = wasInterrupted ? buildRecapFromLog(rec.log || [], RECAP_CAP) : ''
|
|
3628
3637
|
// FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
|
|
3629
3638
|
// bridge restart: the conductor keeps its subagent-block + plan interception, and
|
|
@@ -3978,8 +3987,8 @@ designChannel
|
|
|
3978
3987
|
const ok = !!record && !!live && live.revision === record.revision && record.revision === payload?.revision && sessions.has(record.term)
|
|
3979
3988
|
designChannel.send({ type: 'broadcast', event: 'design-capability-res', payload: { previewId: String(payload?.previewId || ''), revision: String(payload?.revision || ''), ok } })
|
|
3980
3989
|
})
|
|
3981
|
-
.on('broadcast', { event: 'design-apply' }, ({ payload }) => {
|
|
3982
|
-
const verdict = validateDesignRequest(payload, designArtifacts)
|
|
3990
|
+
.on('broadcast', { event: 'design-apply' }, async ({ payload }) => {
|
|
3991
|
+
const verdict = validateDesignRequest(payload, designArtifacts, room)
|
|
3983
3992
|
const previewId = String(payload?.previewId || '')
|
|
3984
3993
|
const requestId = String(payload?.cid || '').slice(0, 80)
|
|
3985
3994
|
if (!verdict.ok) {
|
|
@@ -3989,6 +3998,20 @@ designChannel
|
|
|
3989
3998
|
if (!requestId || seenDesignRequests.has(requestId)) return
|
|
3990
3999
|
seenDesignRequests.add(requestId)
|
|
3991
4000
|
if (seenDesignRequests.size > 500) seenDesignRequests.delete(seenDesignRequests.values().next().value)
|
|
4001
|
+
if (verdict.request.mode === 'image') {
|
|
4002
|
+
const localPath = await materializeDesignAsset({
|
|
4003
|
+
webBase: WEB_BASE,
|
|
4004
|
+
token: codeAuthToken,
|
|
4005
|
+
room,
|
|
4006
|
+
asset: verdict.request.asset,
|
|
4007
|
+
requestId,
|
|
4008
|
+
})
|
|
4009
|
+
if (!localPath) {
|
|
4010
|
+
designStatus({ previewId, requestId, state: 'failed', message: 'The private image draft could not be opened.' })
|
|
4011
|
+
return
|
|
4012
|
+
}
|
|
4013
|
+
verdict.request.asset.localPath = localPath
|
|
4014
|
+
}
|
|
3992
4015
|
const term = verdict.record.term
|
|
3993
4016
|
const queue = designQueues.get(term) || []
|
|
3994
4017
|
designQueues.set(term, queue)
|
package/codex-session.mjs
CHANGED
|
@@ -123,6 +123,22 @@ function codexRolloutPath(sessionId, { home, fsImpl }) {
|
|
|
123
123
|
} catch { return null }
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// A stopped App Server turn can publish its thread id before Codex has written
|
|
127
|
+
// even the session_meta row. If the bridge restarts in that window, both
|
|
128
|
+
// thread/resume and `codex exec resume` reject the zero-byte rollout. Missing
|
|
129
|
+
// files remain "unknown" (Codex may resolve them through another store), but a
|
|
130
|
+
// rollout we can prove is empty is never resumable.
|
|
131
|
+
export function codexThreadCanResume(sessionId, {
|
|
132
|
+
home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'),
|
|
133
|
+
fsImpl = fs,
|
|
134
|
+
} = {}) {
|
|
135
|
+
if (!sessionId) return false
|
|
136
|
+
const file = codexRolloutPath(sessionId, { home, fsImpl })
|
|
137
|
+
if (!file) return true
|
|
138
|
+
try { return Number(fsImpl.statSync(file).size) > 0 }
|
|
139
|
+
catch { return true }
|
|
140
|
+
}
|
|
141
|
+
|
|
126
142
|
// `codex exec --json` exposes lifetime thread totals on `turn.completed`. Those
|
|
127
143
|
// totals are useful for billing, but they are NOT context occupancy: an agentic
|
|
128
144
|
// turn can make hundreds of cached model calls and process many context windows.
|
|
@@ -365,8 +381,12 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
365
381
|
sandbox = normalizeCodexSandbox(sandbox || modeConfig.sandbox)
|
|
366
382
|
const publishAccess = codexPublishAccess({ cwd, env: { ...process.env, ...(env || {}) } })
|
|
367
383
|
const childEnv = publishAccess.env
|
|
368
|
-
|
|
369
|
-
|
|
384
|
+
const requestedResume = resume || null
|
|
385
|
+
const resumeUsable = !requestedResume || codexThreadCanResume(requestedResume, {
|
|
386
|
+
home: childEnv.CODEX_HOME || path.join(os.homedir(), '.codex'),
|
|
387
|
+
})
|
|
388
|
+
let sessionId = resumeUsable ? requestedResume : null
|
|
389
|
+
let turnNo = sessionId ? 1 : 0
|
|
370
390
|
let child = null
|
|
371
391
|
let aborted = false
|
|
372
392
|
let ended = false
|
|
@@ -398,6 +418,9 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
398
418
|
})
|
|
399
419
|
|
|
400
420
|
const note = (text) => { try { onEvent?.({ kind: 'note', text }) } catch { /* noop */ } }
|
|
421
|
+
if (requestedResume && !resumeUsable) {
|
|
422
|
+
queueMicrotask(() => note('The stopped Codex turn had no resumable context; continuing in a fresh thread.'))
|
|
423
|
+
}
|
|
401
424
|
if (!['0', 'false', 'off'].includes(String(childEnv.TP_CODEX_APP_SERVER || '').trim().toLowerCase()) && !appServerGateResult.enabled) {
|
|
402
425
|
queueMicrotask(() => note(`Codex App Server disabled: ${appServerGateResult.reason}`))
|
|
403
426
|
}
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|