thinkpool-pair 0.7.221 → 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 +249 -135
- package/codex-session.mjs +17 -3
- package/design-edit.mjs +66 -4
- package/flow-conductor.mjs +13 -1
- package/flow-host-revert.mjs +42 -0
- package/flow-models.mjs +77 -0
- package/flow-review.mjs +7 -0
- package/flow-task-graph.mjs +21 -0
- package/interrupted-resume.mjs +52 -1
- package/lane-worktree.mjs +16 -1
- package/package.json +3 -1
package/codex-session.mjs
CHANGED
|
@@ -39,6 +39,7 @@ export const CODEX_MODE_CONFIG = {
|
|
|
39
39
|
default: { sandbox: 'workspace-write', approvalPolicy: 'untrusted' },
|
|
40
40
|
acceptEdits: { sandbox: 'workspace-write', approvalPolicy: 'on-request' },
|
|
41
41
|
plan: { sandbox: 'read-only', approvalPolicy: 'never' },
|
|
42
|
+
review: { sandbox: 'workspace-write', approvalPolicy: 'never' },
|
|
42
43
|
bypassPermissions: { sandbox: 'danger-full-access', approvalPolicy: 'never' },
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -73,6 +74,15 @@ export function defaultModeForRuntime(runtime) {
|
|
|
73
74
|
return runtime === 'codex' ? 'bypassPermissions' : 'default'
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
export function codexWritableDirsForSandbox(sandbox, writableDirs = []) {
|
|
78
|
+
return normalizeCodexSandbox(sandbox) === 'read-only' ? [] : writableDirs
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function codexExtraWritableDirsForMode(mode, sandbox, writableDirs = []) {
|
|
82
|
+
if (mode === 'review') return []
|
|
83
|
+
return codexWritableDirsForSandbox(sandbox, writableDirs)
|
|
84
|
+
}
|
|
85
|
+
|
|
76
86
|
export function readCodexDefaultModel({ home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), fsImpl = fs } = {}) {
|
|
77
87
|
try {
|
|
78
88
|
const text = fsImpl.readFileSync(path.join(home, 'config.toml'), 'utf8')
|
|
@@ -183,11 +193,15 @@ export function codexPublishAccess({ cwd, env = process.env, tmpdir = os.tmpdir(
|
|
|
183
193
|
}
|
|
184
194
|
}
|
|
185
195
|
|
|
196
|
+
export const CODEX_ROOM_CASCADE_REMINDER = [
|
|
197
|
+
'THINKPOOL ROOM WORKFLOW: delegated work belongs in visible spawn_terminal lanes, never hidden in-process subagents. Each lane owns its own worktree. For a genuinely decomposable task, state a short plan in chat, use sliceType scaffold for mechanical work, feature/fix for builders, and review for adversarial verification; review must use a balanced model tier. Read results, verify the integrated outcome, and close every lane you spawned so it does not consume room capacity.',
|
|
198
|
+
].join(' ')
|
|
199
|
+
|
|
186
200
|
export function buildCodexPrompt({ text, rolePrompt, roomContext, firstTurn = false }) {
|
|
187
201
|
const context = typeof roomContext === 'function' ? roomContext() : roomContext
|
|
188
202
|
const additions = [
|
|
189
203
|
firstTurn ? CODEX_THINKPOOL_FIRST_TURN_PREAMBLE : '',
|
|
190
|
-
rolePrompt,
|
|
204
|
+
rolePrompt || CODEX_ROOM_CASCADE_REMINDER,
|
|
191
205
|
context,
|
|
192
206
|
].map((v) => String(v || '').trim()).filter(Boolean)
|
|
193
207
|
if (!additions.length) return String(text ?? '')
|
|
@@ -470,7 +484,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
470
484
|
appServer = appServerFactory({
|
|
471
485
|
cwd,
|
|
472
486
|
env: childEnv,
|
|
473
|
-
args: buildCodexAppServerArgs({ providerConfig, mcpUrl: peer?.url, writableDirs: publishAccess.writableDirs }),
|
|
487
|
+
args: buildCodexAppServerArgs({ providerConfig, mcpUrl: peer?.url, writableDirs: codexExtraWritableDirsForMode(activeMode, sandbox, publishAccess.writableDirs) }),
|
|
474
488
|
onNotification: appServerNotification,
|
|
475
489
|
onServerRequest: appServerRequest,
|
|
476
490
|
onClose: (error) => {
|
|
@@ -572,7 +586,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
572
586
|
approvalPolicy: modeConfig.approvalPolicy,
|
|
573
587
|
providerConfig,
|
|
574
588
|
mcpUrl: peer?.url,
|
|
575
|
-
writableDirs: publishAccess.writableDirs,
|
|
589
|
+
writableDirs: codexExtraWritableDirsForMode(activeMode, sandbox, publishAccess.writableDirs),
|
|
576
590
|
images: Array.isArray(options.images) ? options.images : [],
|
|
577
591
|
prompt,
|
|
578
592
|
})
|
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
|
}
|
package/flow-conductor.mjs
CHANGED
|
@@ -63,6 +63,12 @@ export const FLOW_CONDUCTOR_PROMPT = [
|
|
|
63
63
|
'MODE-AWARE. The user picked a mode — steer (watch every lane live), guide (DEFAULT: approve this plan, then review at phase boundaries), or autopilot (full-auto with a budget cap + adaptive escalation that pulls them in only if a lane stalls). Produce the full plan now regardless; the mode changes how much the human intervenes later, not how you decompose.',
|
|
64
64
|
].join(' ')
|
|
65
65
|
|
|
66
|
+
export const FLOW_CODEX_CONDUCTOR_PROMPT = [
|
|
67
|
+
'THINKPOOL FLOW CONDUCTOR — you are decomposition-only. Do not build, edit files, run commands, or spawn agents. Read only when needed to make the decomposition concrete.',
|
|
68
|
+
'Turn the request into a small acyclic graph of independently runnable slices with disjoint file ownership. Each task needs key, title, scope, acceptance, deps, and sliceType (scaffold, feature, fix, or review). Review is intelligence-sensitive and must never be assigned a cheap/mechanical tier. Every review task must depend on exactly ONE builder task; express builder -> review -> downstream explicitly in the DAG.',
|
|
69
|
+
'Submit the finished graph by calling the ThinkPool submit_flow_plan MCP tool with one JSON object encoded in its plan argument. Do not write FLOW_PLAN.json and do not use ExitPlanMode. The MCP submission is your only completion path; after it succeeds, stop and wait for the room.',
|
|
70
|
+
].join(' ')
|
|
71
|
+
|
|
66
72
|
// Env vars handed to a conductor session via startClaudeSession({ env }). The conductor
|
|
67
73
|
// reads these to know which Flow run it owns + what mode the human picked.
|
|
68
74
|
export function buildConductorEnv ({ flowSessionId, mode, budgetCapAutopilot = null }) {
|
|
@@ -98,6 +104,13 @@ export const FLOW_LANE_PROMPT = [
|
|
|
98
104
|
'BE RIGOROUS, NOT VIBES. Trace the data path, root-cause before fixing, verify before claiming done. A lane that ships a guess costs the whole ensemble.',
|
|
99
105
|
].join(' ')
|
|
100
106
|
|
|
107
|
+
export const FLOW_CODEX_LANE_PROMPT = [
|
|
108
|
+
'THINKPOOL FLOW — you are one builder lane in an ensemble. The conductor gives you one runnable slice with disjoint scope. Edit only that scope and never touch another lane or worktree.',
|
|
109
|
+
'Done means the slice runs and its acceptance proof has been observed. Diagnose before changing code, verify with the real command or behavior, and make one focused git commit in your assigned worktree. Do not push.',
|
|
110
|
+
'After the commit and verification succeed, call the ThinkPool mark_flow_done MCP tool exactly once. Do not write FLOW_DONE. The MCP call is the completion signal that records your commit and unblocks dependent slices.',
|
|
111
|
+
'Do not spawn more lanes. If the scope is unsafe or a dependency is missing, report the blocker instead of editing outside your ownership.',
|
|
112
|
+
].join(' ')
|
|
113
|
+
|
|
101
114
|
// Env vars handed to a lane session so it knows its Flow run + which task it owns.
|
|
102
115
|
export function buildLaneEnv ({ flowSessionId, taskKey, laneId }) {
|
|
103
116
|
return {
|
|
@@ -238,4 +251,3 @@ export function activateLaneSkill (name, { publicDir, customDir, fs } = {}) {
|
|
|
238
251
|
const dirs = publicDir === undefined && customDir === undefined ? flowSkillDirs() : { publicDir, customDir }
|
|
239
252
|
return skillLoadBody(name, { ...dirs, ...(fs ? { fs } : {}) })
|
|
240
253
|
}
|
|
241
|
-
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Host-owned Flow revert lifecycle. Supabase broadcast channels use self:false,
|
|
2
|
+
// so a reviewer on this bridge must call this handler directly; remote broadcasts
|
|
3
|
+
// delegate to the same function.
|
|
4
|
+
export async function executeFlowRevert({
|
|
5
|
+
payload,
|
|
6
|
+
bridgeName,
|
|
7
|
+
lanes = [],
|
|
8
|
+
prepareLane,
|
|
9
|
+
rememberRedispatch,
|
|
10
|
+
endLane,
|
|
11
|
+
stopPreview,
|
|
12
|
+
revert,
|
|
13
|
+
broadcastReverted,
|
|
14
|
+
wait = () => Promise.resolve(),
|
|
15
|
+
onPrepError = () => {},
|
|
16
|
+
} = {}) {
|
|
17
|
+
if (!payload?.flowId || !payload?.taskKey) return { ok: false, ignored: 'invalid' }
|
|
18
|
+
if (payload.host && payload.host !== bridgeName) return { ok: false, ignored: 'wrong-host' }
|
|
19
|
+
let killed = false
|
|
20
|
+
for (const [laneId, entry] of lanes) {
|
|
21
|
+
if (entry?.flowSessionId !== payload.flowId || entry?.flowTaskKey !== payload.taskKey || entry?.flowDone) continue
|
|
22
|
+
try {
|
|
23
|
+
const prepared = prepareLane?.(entry, payload)
|
|
24
|
+
if (prepared) rememberRedispatch?.(prepared, entry, payload)
|
|
25
|
+
} catch (error) { onPrepError(error, entry, payload) }
|
|
26
|
+
entry.flowDone = true
|
|
27
|
+
endLane?.(laneId, entry)
|
|
28
|
+
stopPreview?.(payload.flowId, laneId)
|
|
29
|
+
killed = true
|
|
30
|
+
}
|
|
31
|
+
if (killed) await wait()
|
|
32
|
+
const reverted = await revert(payload)
|
|
33
|
+
const result = {
|
|
34
|
+
ok: true,
|
|
35
|
+
flowId: payload.flowId,
|
|
36
|
+
taskKey: payload.taskKey,
|
|
37
|
+
reviewTaskKey: payload.reviewTaskKey || null,
|
|
38
|
+
branch: reverted?.branch || null,
|
|
39
|
+
}
|
|
40
|
+
await broadcastReverted(result)
|
|
41
|
+
return result
|
|
42
|
+
}
|
package/flow-models.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Runtime-aware model routing for Flow and ordinary room cascades.
|
|
2
|
+
// Pure over the supplied environment/catalog so the bridge never sends a model slug
|
|
3
|
+
// that the selected runtime did not advertise.
|
|
4
|
+
|
|
5
|
+
const CLAUDE_TIERS = {
|
|
6
|
+
scaffold: 'sonnet',
|
|
7
|
+
feature: 'opus',
|
|
8
|
+
fix: 'opus',
|
|
9
|
+
review: 'opus',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const CODEX_SCAFFOLD = ['gpt-5.6-luna', 'gpt-5.4-mini', 'gpt-5.3-codex-spark']
|
|
13
|
+
const CODEX_BALANCED = ['gpt-5.6-terra', 'gpt-5.4']
|
|
14
|
+
|
|
15
|
+
export function normalizeFlowRuntime(runtime, fallback = null) {
|
|
16
|
+
if (runtime === 'claude' || runtime === 'codex') return runtime
|
|
17
|
+
return fallback === 'claude' || fallback === 'codex' ? fallback : null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function modelCatalogValues(catalog = []) {
|
|
21
|
+
return new Set((Array.isArray(catalog) ? catalog : []).map((model) => (
|
|
22
|
+
typeof model === 'string' ? model : model?.value || model?.slug
|
|
23
|
+
)).filter(Boolean))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function firstVisible(candidates, catalog) {
|
|
27
|
+
const visible = modelCatalogValues(catalog)
|
|
28
|
+
for (const candidate of candidates) if (visible.has(candidate)) return candidate
|
|
29
|
+
if (candidates === CODEX_SCAFFOLD) {
|
|
30
|
+
for (const model of visible) if (/spark/i.test(model)) return model
|
|
31
|
+
}
|
|
32
|
+
return undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function flowLaneModelFor({ sliceType, runtime = 'claude', catalog = [], env = process.env } = {}) {
|
|
36
|
+
if (normalizeFlowRuntime(runtime, 'claude') === 'codex') {
|
|
37
|
+
const override = env.TP_FLOW_CODEX_LANE_MODEL
|
|
38
|
+
if (override === 'inherit') return undefined
|
|
39
|
+
if (override) return modelCatalogValues(catalog).has(override) ? override : undefined
|
|
40
|
+
return firstVisible(sliceType === 'scaffold' ? CODEX_SCAFFOLD : CODEX_BALANCED, catalog)
|
|
41
|
+
}
|
|
42
|
+
const override = env.TP_FLOW_CLAUDE_LANE_MODEL || env.TP_FLOW_LANE_MODEL
|
|
43
|
+
if (override === 'inherit') return undefined
|
|
44
|
+
if (override) return override
|
|
45
|
+
return CLAUDE_TIERS[sliceType] || 'opus'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function flowConductorModelFor({ runtime = 'claude', originModel, catalog = [], env = process.env } = {}) {
|
|
49
|
+
if (normalizeFlowRuntime(runtime, 'claude') === 'codex') {
|
|
50
|
+
const override = env.TP_FLOW_CODEX_CONDUCTOR_MODEL
|
|
51
|
+
if (override === 'inherit') return undefined
|
|
52
|
+
const visible = modelCatalogValues(catalog)
|
|
53
|
+
if (override) return visible.has(override) ? override : undefined
|
|
54
|
+
return originModel && visible.has(originModel) ? originModel : undefined
|
|
55
|
+
}
|
|
56
|
+
const override = env.TP_FLOW_CLAUDE_CONDUCTOR_MODEL || env.TP_FLOW_CONDUCTOR_MODEL
|
|
57
|
+
if (override === 'inherit') return undefined
|
|
58
|
+
// Claude non-regression: the conductor historically inherited the host default,
|
|
59
|
+
// not the invoking lane's selected model.
|
|
60
|
+
return override || undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function spawnedLaneModelFor({ explicitModel, sliceType, runtime = 'claude', catalog = [], env = process.env } = {}) {
|
|
64
|
+
return explicitModel || flowLaneModelFor({ sliceType, runtime, catalog, env })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function resolveCodexModel(requested, catalog = []) {
|
|
68
|
+
if (!requested) return undefined
|
|
69
|
+
return modelCatalogValues(catalog).has(requested) ? requested : undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function assertRuntimeModelCompatible({ runtime = 'claude', provider = null, model } = {}) {
|
|
73
|
+
if (runtime === 'claude' && (!provider || provider === 'anthropic') && /^gpt-/i.test(String(model || ''))) {
|
|
74
|
+
throw new Error(`Codex model ${JSON.stringify(model)} cannot run on the built-in Claude provider`)
|
|
75
|
+
}
|
|
76
|
+
return true
|
|
77
|
+
}
|
package/flow-review.mjs
CHANGED
|
@@ -32,6 +32,13 @@ export const FLOW_REVIEWER_PROMPT = [
|
|
|
32
32
|
'EMIT YOUR VERDICT BY WRITING FLOW_REVIEW.json. Do NOT call mark_flow_done or ExitPlanMode (they hang here). As your LAST action, use the Write tool with file_path "FLOW_REVIEW.json" and content = a single JSON object { "pass": <boolean>, "reasons": ["<specific finding>", ...], "taskKey": "<the slice you reviewed>", "exhausted": <boolean, optional — true only when you have nothing left to check> }. `pass: false` reverts the reviewed slice so it rebuilds. The content of FLOW_REVIEW.json must be ONLY that JSON object (no prose, no markdown fences). Do not edit the slice — you review, you do not fix.',
|
|
33
33
|
].join(' ')
|
|
34
34
|
|
|
35
|
+
export const FLOW_CODEX_REVIEWER_PROMPT = [
|
|
36
|
+
'THINKPOOL FLOW REVIEW — you are a non-mutating adversarial reviewer. Your native sandbox lets you write only inside your OWN disposable reviewer worktree. The reviewed builder worktree is outside that writable root: never edit, commit, revert, install into, or repair it.',
|
|
37
|
+
'For any install/build/test that produces files, copy the reviewed source into a scratch directory under your own current worktree first, then run the write-producing command against that scratch copy. Read-only inspection may target the reviewed worktree directly.',
|
|
38
|
+
'Independently reproduce every acceptance criterion and hunt edge cases, reloads, repeated actions, concurrency, malformed input, and error paths. A green happy path is only the floor. If a claim cannot be reproduced, reject it with the exact command/output.',
|
|
39
|
+
'Submit each review round by calling the ThinkPool submit_flow_review MCP tool with a JSON verdict: {"pass":boolean,"reasons":["specific evidence"],"taskKey":"allowed reviewed task","exhausted":boolean}. Do not write FLOW_REVIEW.json and do not call mark_flow_done. A pass without exhausted=true may trigger another bounded hunt round; a failure triggers the room revert path.',
|
|
40
|
+
].join(' ')
|
|
41
|
+
|
|
35
42
|
// Parse a reviewer's raw verdict. Accepts an object OR a JSON string (optionally
|
|
36
43
|
// ```json-fenced). Tolerant of the fence the model sometimes wraps; throws on garbage.
|
|
37
44
|
// Returns { pass, reasons } — pass coerced to boolean, reasons normalized to string[].
|
package/flow-task-graph.mjs
CHANGED
|
@@ -173,6 +173,27 @@ export function normalizePlanOutput (raw) {
|
|
|
173
173
|
return { summary, tasks }
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
// Codex review protocol deliberately maps one reviewer lane to one builder target.
|
|
177
|
+
// Claude's legacy Flow plans may review several deps and remain unchanged.
|
|
178
|
+
export function validatePlanForRuntime (plan, runtime = 'claude') {
|
|
179
|
+
if (runtime !== 'codex') return plan
|
|
180
|
+
for (const task of plan?.tasks || []) {
|
|
181
|
+
if (task.sliceType === SLICE_TYPE.review && task.deps.length !== 1) {
|
|
182
|
+
throw new Error(`Codex review task "${task.key}" must depend on exactly one builder task`)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return plan
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function validReviewTargetShape (task, runtime = 'claude') {
|
|
189
|
+
if (!task || task.slice_type !== SLICE_TYPE.review) return true
|
|
190
|
+
return runtime !== 'codex' || (Array.isArray(task.deps) && task.deps.length === 1)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function legacyBuilderCompletionAllowed ({ runtime, flowRole, eventKind, eventSubtype, interrupted = false } = {}) {
|
|
194
|
+
return runtime === 'claude' && flowRole === 'builder' && !interrupted && eventKind === 'result' && eventSubtype === 'success'
|
|
195
|
+
}
|
|
196
|
+
|
|
176
197
|
// ── Flow lane model tiers ───────────────────────────────────────────────────
|
|
177
198
|
// Spec: docs/specs/2026-07-03-flow-lane-model-tiers.md
|
|
178
199
|
// The conductor thinks; the worker lanes type. Running every lane on the same
|
package/interrupted-resume.mjs
CHANGED
|
@@ -11,6 +11,27 @@ export function sendInterruptedContinue(entry) {
|
|
|
11
11
|
catch { return false }
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
// A text recap is the fallback when the native thread cannot be resumed. Claude
|
|
15
|
+
// already has a live init event to trigger this; Codex does not -- sending the
|
|
16
|
+
// recap is what starts its fresh exec process. Consume atomically, but restore it
|
|
17
|
+
// if delivery was refused so the next human turn can still prepend the context.
|
|
18
|
+
export function dispatchPendingRecap(entry) {
|
|
19
|
+
const recap = typeof entry?.pendingRecap === 'string' ? entry.pendingRecap : ''
|
|
20
|
+
if (!recap.trim()) return false
|
|
21
|
+
entry.pendingRecap = null
|
|
22
|
+
try { entry.flush?.() } catch { /* best effort */ }
|
|
23
|
+
try {
|
|
24
|
+
if (entry.session?.sendTurn?.(recap) === false) {
|
|
25
|
+
entry.pendingRecap = recap
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
return true
|
|
29
|
+
} catch {
|
|
30
|
+
entry.pendingRecap = recap
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
14
35
|
export function armInterruptedResume(entry) {
|
|
15
36
|
if (!entry?.session) return 'unavailable'
|
|
16
37
|
if (entry.runtime === 'codex') return sendInterruptedContinue(entry) ? 'sent' : 'unavailable'
|
|
@@ -18,6 +39,37 @@ export function armInterruptedResume(entry) {
|
|
|
18
39
|
return 'armed'
|
|
19
40
|
}
|
|
20
41
|
|
|
42
|
+
export function recoverInterruptedTurn(entry, { resumable = false, recap = '' } = {}) {
|
|
43
|
+
if (!entry?.session) return 'unavailable'
|
|
44
|
+
if (resumable) return armInterruptedResume(entry)
|
|
45
|
+
if (!String(recap || '').trim()) return 'unavailable'
|
|
46
|
+
entry.pendingRecap = String(recap)
|
|
47
|
+
if (entry.runtime === 'codex') return dispatchPendingRecap(entry) ? 'recap-sent' : 'unavailable'
|
|
48
|
+
return 'recap-armed'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isMissingResumeError(message) {
|
|
52
|
+
return /No conversation found|no rollout found for thread id|thread\/resume[^\n]*failed[^\n]*no rollout/i.test(String(message || ''))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Bound the stale-native-thread self-heal at the ownership seam. The old entry is
|
|
56
|
+
// marked before teardown/reopen so duplicate error delivery can never fork another
|
|
57
|
+
// process. `reopen` returns the new structured entry; Codex consumes the recap
|
|
58
|
+
// immediately because a fresh exec has no idle init event.
|
|
59
|
+
export function recoverMissingResumeOnce(entry, { message, recap = '', reopen } = {}) {
|
|
60
|
+
if (!entry || entry.recovered || !isMissingResumeError(message)) return { recovered: false, delivery: 'ignored' }
|
|
61
|
+
entry.recovered = true
|
|
62
|
+
try { entry.session?.end?.() } catch { /* already dead */ }
|
|
63
|
+
let recovered = null
|
|
64
|
+
try { recovered = reopen?.(String(recap || '')) || null } catch { /* caller surfaces reopen failure */ }
|
|
65
|
+
if (!recovered) return { recovered: true, delivery: 'unavailable', entry: null }
|
|
66
|
+
return {
|
|
67
|
+
recovered: true,
|
|
68
|
+
delivery: recoverInterruptedTurn(recovered, { resumable: false, recap }),
|
|
69
|
+
entry: recovered,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
21
73
|
// A real person speaking always wins the race. Otherwise a human turn that starts a
|
|
22
74
|
// cold Claude/Codex runtime can trigger its init event and enqueue a stale second
|
|
23
75
|
// `continue` behind the person's actual request.
|
|
@@ -26,4 +78,3 @@ export function supersedeInterruptedResume(entry) {
|
|
|
26
78
|
entry.pendingAutoResume = false
|
|
27
79
|
return true
|
|
28
80
|
}
|
|
29
|
-
|
package/lane-worktree.mjs
CHANGED
|
@@ -12,11 +12,26 @@ export function createManagedLaneWorktree({ terminalId, cwd = process.cwd(), git
|
|
|
12
12
|
if (!root) throw new Error('not a git repository')
|
|
13
13
|
const dir = path.join(root, '.thinkpool', 'worktrees', `terminal-${short}`)
|
|
14
14
|
const branch = `thinkpool/terminal/${short}`
|
|
15
|
+
const base = resolveLaneBase({ root, git })
|
|
15
16
|
fsImpl.mkdirSync(path.dirname(dir), { recursive: true })
|
|
16
|
-
git(['worktree', 'add', '-b', branch, dir,
|
|
17
|
+
git(['worktree', 'add', '-b', branch, dir, base], root)
|
|
17
18
|
return { terminalId: id, root, dir, branch }
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
// Ordinary lanes should start from the repository's upstream default branch, not
|
|
22
|
+
// whichever parked/stale branch happens to be checked out in the bridge process.
|
|
23
|
+
// Repositories without a remote remain useful: fall back through conventional local
|
|
24
|
+
// defaults and finally HEAD.
|
|
25
|
+
function resolveLaneBase({ root, git }) {
|
|
26
|
+
for (const candidate of ['origin/HEAD', 'origin/main', 'origin/master', 'main', 'master', 'HEAD']) {
|
|
27
|
+
try {
|
|
28
|
+
const resolved = String(git(['rev-parse', '--verify', '--quiet', candidate], root) || '').trim()
|
|
29
|
+
if (resolved) return candidate
|
|
30
|
+
} catch { /* try the next base */ }
|
|
31
|
+
}
|
|
32
|
+
return 'HEAD'
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
export function removeManagedLaneWorktree({ terminalId, managedWorktree, git = runGit } = {}) {
|
|
21
36
|
const id = String(terminalId || '')
|
|
22
37
|
const rec = managedWorktree && typeof managedWorktree === 'object' ? managedWorktree : null
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
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": {
|
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
"flow-conductor.mjs",
|
|
37
37
|
"flow-worktree.mjs",
|
|
38
38
|
"flow-task-graph.mjs",
|
|
39
|
+
"flow-models.mjs",
|
|
40
|
+
"flow-host-revert.mjs",
|
|
39
41
|
"flow-preview.mjs",
|
|
40
42
|
"viewport.mjs",
|
|
41
43
|
"design-edit.mjs",
|