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/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
- 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
  }
@@ -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
+ }
@@ -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[].
@@ -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
@@ -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, 'HEAD'], root)
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.221",
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",