thinkpool-pair 0.7.214 → 0.7.216
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 +107 -13
- package/codex-app-server.mjs +2 -1
- package/codex-session.mjs +17 -0
- package/package.json +2 -1
- package/side-lane.mjs +42 -0
- package/thinkpool-room-prompt.mjs +1 -0
package/bridge.mjs
CHANGED
|
@@ -104,6 +104,7 @@ import { armInterruptedResume, sendInterruptedContinue, supersedeInterruptedResu
|
|
|
104
104
|
import { turnInFlight } from './update-gate.mjs'
|
|
105
105
|
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
|
|
106
106
|
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, codexUsageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
|
|
107
|
+
import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantTextSince, sideContextBlock, sideSnapshot } from './side-lane.mjs'
|
|
107
108
|
import { planMeterLine } from './plan-meters.mjs'
|
|
108
109
|
import { makeThrottledTrack } from './presence.mjs'
|
|
109
110
|
import { resolveAnonKey, DEFAULT_SUPABASE_URL } from './supabase-key.mjs'
|
|
@@ -1045,7 +1046,7 @@ const announce = () => {
|
|
|
1045
1046
|
// laneStatusOf: authoritative busy/idle + last-action timestamp/age +
|
|
1046
1047
|
// STUCK/BLOCKED alert. The bridge owns the turn and permission state, so
|
|
1047
1048
|
// every roster consumer reads one status instead of reconstructing it.
|
|
1048
|
-
...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
|
|
1049
|
+
...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.sideParent ? { sideParent: s.sideParent, sideTask: s.sideTask || undefined, sideHandback: !!s.sideHandback } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
|
|
1049
1050
|
// provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
|
|
1050
1051
|
// (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
|
|
1051
1052
|
// built-in/default Claude path (no badge). Makes the lane's provider badge +
|
|
@@ -1542,7 +1543,7 @@ function worktreeSnapshot(cwd) {
|
|
|
1542
1543
|
// relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
|
|
1543
1544
|
// persist to the host file; tool calls round-trip through the perm card; the
|
|
1544
1545
|
// rolling log replays to joiners and survives bridge restarts (session-store).
|
|
1545
|
-
function openStructured({ id, runtime = 'claude', model, effort, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
|
|
1546
|
+
function openStructured({ id, runtime = 'claude', model, effort, resume, log, commands, mode, spawnedBy, sideParent, sideTask, pendingSideContexts, rolePrompt, flowSessionId, flowTaskKey, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
|
|
1546
1547
|
if (sessions.has(id)) return
|
|
1547
1548
|
runtime = runtime === 'codex' ? 'codex' : 'claude'
|
|
1548
1549
|
// No explicit mode → a sensible default per runtime (see defaultModeForRuntime):
|
|
@@ -1579,7 +1580,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
1579
1580
|
: (!provider || provider === 'anthropic')
|
|
1580
1581
|
? (laneModel || null)
|
|
1581
1582
|
: (laneModel || providerNameMap()[provider] || provider),
|
|
1582
|
-
provider: provider || null, spawnedBy: spawnedBy || undefined, flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null, managedWorktree: managedWorktree || null,
|
|
1583
|
+
provider: provider || null, spawnedBy: spawnedBy || undefined, sideParent: sideParent || undefined, sideTask: sideTask || undefined, pendingSideContexts: Array.isArray(pendingSideContexts) ? pendingSideContexts.filter(Boolean).slice(-4) : [], flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null, managedWorktree: managedWorktree || null,
|
|
1583
1584
|
// Stable creation order — persisted so a bridge restart restores tabs in the SAME
|
|
1584
1585
|
// order (not readdir/filesystem order). Legacy recs (no openedAt) derive it from the
|
|
1585
1586
|
// first transcript event ts, so even the first post-fix restart is ordered right.
|
|
@@ -1773,7 +1774,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
1773
1774
|
// restart. Without this, sessionData omitted it → on restart the resumed session
|
|
1774
1775
|
// re-launched on the host default (Opus) regardless of the last switch, and the
|
|
1775
1776
|
// switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
|
|
1776
|
-
const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, provider: entry.provider || null, spawnedBy: entry.spawnedBy, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null })
|
|
1777
|
+
const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, provider: entry.provider || null, spawnedBy: entry.spawnedBy, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null })
|
|
1777
1778
|
const persist = () => saveSession(room, id, sessionData())
|
|
1778
1779
|
// Synchronous flush of this session's record. Used on open (so a brand-new session
|
|
1779
1780
|
// has a file under its id BEFORE its first event — surviving a restart inside the
|
|
@@ -1936,7 +1937,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
1936
1937
|
// fork-bomb breaker, now one level deeper (2026-07-10 cascade-spawn-depth spec).
|
|
1937
1938
|
tool(
|
|
1938
1939
|
'spawn_terminal',
|
|
1939
|
-
'Open a NEW agent terminal (Claude or Codex) in this ThinkPool Code room — YOUR lane to use, not a sibling that is already working. The runtime defaults to inheriting this lane, or pass `runtime` to dispatch across runtimes.
|
|
1940
|
+
'Open a NEW agent terminal (Claude or Codex) in this ThinkPool Code room — YOUR lane to use, not a sibling that is already working. The runtime defaults to inheriting this lane, or pass `runtime` to dispatch across runtimes. Choose `model` explicitly from advertised metadata when practical: for Codex use Luna/Mini for mechanical work, Terra for ordinary feature/fix work, and Sol only for repeated-failure, architecture/conductor, or adversarial final-review work; never inherit Sol by default, and omit `model` for runtime fallback when the named tier is unavailable. Give the lane an initial `task`, collect its result later with read_terminal, then ALWAYS close_terminal it — a finished lane left open holds a slot against the room cap and blocks the next fan-out. By default the new lane INHERITS your current permission mode. ALWAYS prefer spawning a fresh lane over post_to_terminal into one that is already busy. Bounded by cascade depth, the room terminal cap, and per-turn limits.',
|
|
1940
1941
|
{
|
|
1941
1942
|
name: z.string().max(80).optional().describe('a short label for the new lane so the room (and you) can find it, e.g. "Research: topologies"'),
|
|
1942
1943
|
task: z.string().optional().describe('an initial task to hand the new lane immediately; omit to open it idle'),
|
|
@@ -2216,7 +2217,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
2216
2217
|
try { entry.mockupWatcher?.close() } catch { /* noop */ }
|
|
2217
2218
|
try { void entry.viewport?.stop()?.catch(() => {}) } catch { /* noop */ }
|
|
2218
2219
|
sessions.delete(id)
|
|
2219
|
-
openStructured({ id, runtime: entry.runtime, model, log: entry.log, commands: entry.commands, mode: entry.mode })
|
|
2220
|
+
openStructured({ id, runtime: entry.runtime, model, log: entry.log, commands: entry.commands, mode: entry.mode, provider: entry.provider, spawnedBy: entry.spawnedBy, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, rolePrompt: entry.rolePrompt, cwd: entry.cwd, managedWorktree: entry.managedWorktree })
|
|
2220
2221
|
return
|
|
2221
2222
|
}
|
|
2222
2223
|
// Stamp a wall-clock ts AND a stable cid on every transcript event before
|
|
@@ -2391,6 +2392,26 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
2391
2392
|
// _turnStart is cleared unconditionally so a stray post-settle event can't
|
|
2392
2393
|
// re-notify off a stale stamp.
|
|
2393
2394
|
if (evt.kind === 'result' || evt.kind === 'error') {
|
|
2395
|
+
if (entry.sideHandback) {
|
|
2396
|
+
const request = entry.sideHandback
|
|
2397
|
+
entry.sideHandback = null
|
|
2398
|
+
const parent = sessions.get(request.parentId)
|
|
2399
|
+
const successful = evt.kind === 'result' && (!evt.subtype || evt.subtype === 'success')
|
|
2400
|
+
const summary = successful ? (String(evt.resultText || '').trim() || assistantTextSince(entry.log, request.afterSeq)) : ''
|
|
2401
|
+
if (parent && summary) {
|
|
2402
|
+
const context = sideContextBlock({ sideId: id, sideName: termNames[id], task: entry.sideTask, summary })
|
|
2403
|
+
parent.pendingSideContexts = appendSideContext(parent.pendingSideContexts, context)
|
|
2404
|
+
const handoff = { kind: 'side-handback', sideId: id, sideName: termNames[id] || 'Side lane', task: entry.sideTask || '', text: summary, by: request.by }
|
|
2405
|
+
stampEvent(handoff); pushLog(parent, handoff); bcast('code-event', { term: request.parentId, evt: handoff })
|
|
2406
|
+
const done = { kind: 'control', text: 'Brought to main.', by: request.by }
|
|
2407
|
+
stampEvent(done); pushLog(entry, done); bcast('code-event', { term: id, evt: done })
|
|
2408
|
+
parent.flush?.()
|
|
2409
|
+
} else {
|
|
2410
|
+
const failed = { kind: 'control', text: parent ? 'Couldn’t prepare a handoff — try again.' : 'The main terminal is no longer available.', by: request.by }
|
|
2411
|
+
stampEvent(failed); pushLog(entry, failed); bcast('code-event', { term: id, evt: failed })
|
|
2412
|
+
}
|
|
2413
|
+
entry.flush?.(); announce()
|
|
2414
|
+
}
|
|
2394
2415
|
const startedAt = entry._turnStart
|
|
2395
2416
|
entry._turnStart = null
|
|
2396
2417
|
// A canceled/failed App Server request can settle the turn without the
|
|
@@ -2589,7 +2610,7 @@ function respawnStructured(id, provider) {
|
|
|
2589
2610
|
// openStructured seed from the TARGET provider's configured model, which is the
|
|
2590
2611
|
// only model this lane was ever asked for. A same-env model change never reaches
|
|
2591
2612
|
// here — that path is an in-place setModel (see provider-switch).
|
|
2592
|
-
const { runtime, log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt } = s
|
|
2613
|
+
const { runtime, log, commands, mode, spawnedBy, sideParent, sideTask, pendingSideContexts, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt } = s
|
|
2593
2614
|
// Context-carry (2026-07-08): a provider switch is not an SDK resume — the new backend
|
|
2594
2615
|
// starts blank mid-conversation. Synthesize a plain-text recap from the VISIBLE log NOW
|
|
2595
2616
|
// (before teardown) and hand it to the fresh session as its first turn so the agent
|
|
@@ -2608,7 +2629,7 @@ function respawnStructured(id, provider) {
|
|
|
2608
2629
|
// sessionData() (provider included) synchronously on open, so a bridge restart
|
|
2609
2630
|
// restores the lane on its CURRENT provider, not the original — and its next
|
|
2610
2631
|
// announce carries the new provider badge (additive {id,name} projection).
|
|
2611
|
-
openStructured({ id, runtime, provider, log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt, carryRecap })
|
|
2632
|
+
openStructured({ id, runtime, provider, log, commands, mode, spawnedBy, sideParent, sideTask, pendingSideContexts, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt, carryRecap })
|
|
2612
2633
|
return true
|
|
2613
2634
|
}
|
|
2614
2635
|
|
|
@@ -2660,7 +2681,7 @@ function endStructured(id) {
|
|
|
2660
2681
|
// Cascade-close its direct children (spawnedBy === this id — a real terminal id, not a
|
|
2661
2682
|
// `flow:<id>` marker, so flow lanes are untouched; maxHop:1 means no deeper recursion).
|
|
2662
2683
|
for (const [cid, ce] of [...sessions]) {
|
|
2663
|
-
if (ce.spawnedBy === id) endStructured(cid)
|
|
2684
|
+
if (ce.spawnedBy === id || ce.sideParent === id) endStructured(cid)
|
|
2664
2685
|
}
|
|
2665
2686
|
}
|
|
2666
2687
|
deleteSession(room, id) // remove <id>.json so loadAll() won't bring it back
|
|
@@ -2771,11 +2792,60 @@ channel
|
|
|
2771
2792
|
// previous terminal's model (and the first uses the provider default). Falls
|
|
2772
2793
|
// through to the SDK default when absent. Stamped on the entry so the announce
|
|
2773
2794
|
// carries it at open (chip shows the model immediately, not the branch).
|
|
2774
|
-
|
|
2795
|
+
let runtime = structuredRuntime(payload.cmd)
|
|
2796
|
+
let sideParent = null
|
|
2797
|
+
let parent = null
|
|
2798
|
+
if (payload.sideParent) {
|
|
2799
|
+
const requested = sessions.get(payload.sideParent)
|
|
2800
|
+
sideParent = requested?.sideParent || payload.sideParent
|
|
2801
|
+
parent = sessions.get(sideParent)
|
|
2802
|
+
if (!parent || !String(payload.sideTask || '').trim()) {
|
|
2803
|
+
bcast('side-open-failed', { id: payload.id, parent: sideParent, message: parent ? 'Add a task after /side.' : 'The main terminal is not live.' })
|
|
2804
|
+
return
|
|
2805
|
+
}
|
|
2806
|
+
runtime = parent.runtime || runtime
|
|
2807
|
+
if (payload.name) { termNames[payload.id] = String(payload.name).slice(0, 80); saveNames(room, termNames) }
|
|
2808
|
+
}
|
|
2775
2809
|
// Opening a tab is not work. Paint and announce it immediately, then cold-boot the
|
|
2776
2810
|
// expensive Claude/Codex runtime on its first real turn. This keeps a fresh terminal
|
|
2777
2811
|
// instant and prevents abandoned tabs from becoming permanent resident processes.
|
|
2778
|
-
openStructured({
|
|
2812
|
+
openStructured({
|
|
2813
|
+
id: payload.id,
|
|
2814
|
+
runtime,
|
|
2815
|
+
mode: parent?.mode || payload.mode,
|
|
2816
|
+
model: parent?.model || payload.model || undefined,
|
|
2817
|
+
provider: runtime === 'codex' ? undefined : (parent?.provider || payload.provider || undefined),
|
|
2818
|
+
cwd: parent ? (parent.cwd || process.cwd()) : undefined,
|
|
2819
|
+
sideParent: sideParent || undefined,
|
|
2820
|
+
sideTask: sideParent ? String(payload.sideTask).trim() : undefined,
|
|
2821
|
+
rolePrompt: sideParent ? SIDE_ROLE_PROMPT : undefined,
|
|
2822
|
+
defer: true,
|
|
2823
|
+
})
|
|
2824
|
+
if (sideParent) {
|
|
2825
|
+
const child = sessions.get(payload.id)
|
|
2826
|
+
const task = String(payload.sideTask).trim()
|
|
2827
|
+
const by = String(payload.by || 'A room member')
|
|
2828
|
+
const sideName = termNames[payload.id] || 'Side lane'
|
|
2829
|
+
if (!child) {
|
|
2830
|
+
bcast('side-open-failed', { id: payload.id, parent: sideParent, message: 'The side lane could not start.' })
|
|
2831
|
+
return
|
|
2832
|
+
}
|
|
2833
|
+
child.hop = 0
|
|
2834
|
+
const childEvent = { kind: 'you', text: task, by }
|
|
2835
|
+
stampEvent(childEvent); pushLog(child, childEvent); bcast('code-event', { term: payload.id, evt: childEvent })
|
|
2836
|
+
const parentEvent = { kind: 'side-started', sideId: payload.id, sideName, task, by }
|
|
2837
|
+
stampEvent(parentEvent); pushLog(parent, parentEvent); bcast('code-event', { term: sideParent, evt: parentEvent })
|
|
2838
|
+
const snapshot = sideSnapshot(parent.log.filter((event) => event.cid !== parentEvent.cid))
|
|
2839
|
+
const prompt = snapshot ? `${snapshot}\n\n--- side task ---\n${task}` : task
|
|
2840
|
+
try { child.session.sendTurn(prompt) } catch {
|
|
2841
|
+
bcast('side-open-failed', { id: payload.id, parent: sideParent, message: 'The side agent could not start.' })
|
|
2842
|
+
endStructured(payload.id)
|
|
2843
|
+
return
|
|
2844
|
+
}
|
|
2845
|
+
child.flush?.(); parent.flush?.(); announce()
|
|
2846
|
+
process.stderr.write(`\n ◆ ${by} opened side lane ${String(payload.id).slice(0, 8)} under ${String(sideParent).slice(0, 8)}.\n`)
|
|
2847
|
+
return
|
|
2848
|
+
}
|
|
2779
2849
|
process.stderr.write(`\n ◆ web opened a structured "${payload.cmd}" session (${payload.mode || 'default'}${payload.model ? `, model ${payload.model}` : ''}).\n`)
|
|
2780
2850
|
return
|
|
2781
2851
|
}
|
|
@@ -2924,6 +2994,27 @@ channel
|
|
|
2924
2994
|
if (payload?.host && payload.host !== name) return
|
|
2925
2995
|
openStructured({ id: payload?.id || randomUUID(), runtime: payload?.runtime, model: payload?.model, provider: payload?.runtime === 'codex' ? undefined : payload?.provider, resume: payload?.resume, mode: payload?.mode, defer: true })
|
|
2926
2996
|
})
|
|
2997
|
+
.on('broadcast', { event: 'side-handback-request' }, ({ payload }) => {
|
|
2998
|
+
const side = payload?.term && sessions.get(payload.term)
|
|
2999
|
+
if (!side?.sideParent || side.sideParent !== payload?.parent) return
|
|
3000
|
+
const by = String(payload?.by || 'A room member')
|
|
3001
|
+
if (side.sideHandback || side.session?.turnActive) {
|
|
3002
|
+
const busy = { kind: 'control', text: 'Finish or stop the current side-lane turn before bringing it to main.', by }
|
|
3003
|
+
stampEvent(busy); pushLog(side, busy); bcast('code-event', { term: payload.term, evt: busy })
|
|
3004
|
+
return
|
|
3005
|
+
}
|
|
3006
|
+
side.sideHandback = { parentId: side.sideParent, by, afterSeq: maxSeq(side.log) }
|
|
3007
|
+
const preparing = { kind: 'control', text: 'Preparing a compact handoff for main…', by }
|
|
3008
|
+
stampEvent(preparing); pushLog(side, preparing); bcast('code-event', { term: payload.term, evt: preparing })
|
|
3009
|
+
side.flush?.(); announce()
|
|
3010
|
+
try { side.session.sendTurn(SIDE_HANDOFF_PROMPT) }
|
|
3011
|
+
catch {
|
|
3012
|
+
side.sideHandback = null
|
|
3013
|
+
const failed = { kind: 'control', text: 'Couldn’t start the handoff — try again.', by }
|
|
3014
|
+
stampEvent(failed); pushLog(side, failed); bcast('code-event', { term: payload.term, evt: failed })
|
|
3015
|
+
side.flush?.(); announce()
|
|
3016
|
+
}
|
|
3017
|
+
})
|
|
2927
3018
|
.on('broadcast', { event: 'code-turn' }, async ({ payload }) => {
|
|
2928
3019
|
const s = payload?.term && sessions.get(payload.term)
|
|
2929
3020
|
if (!s || payload.text == null) return
|
|
@@ -3069,7 +3160,10 @@ channel
|
|
|
3069
3160
|
// — a separate recap turn would race + double-fire) and clear the flag so the init handler
|
|
3070
3161
|
// no-ops. The visible `you` echo is unchanged; the recap is context for the agent only.
|
|
3071
3162
|
let sendText = text
|
|
3072
|
-
|
|
3163
|
+
const carried = []
|
|
3164
|
+
if (s.pendingRecap) { carried.push(s.pendingRecap); s.pendingRecap = null }
|
|
3165
|
+
if (s.pendingSideContexts?.length) { carried.push(...s.pendingSideContexts); s.pendingSideContexts = [] }
|
|
3166
|
+
if (carried.length) { sendText = `${carried.join('\n\n')}\n\n--- the person's next message ---\n${text}`; s.flush?.() }
|
|
3073
3167
|
// The browser's `hostPath` is display metadata, not host authority. Rebuild
|
|
3074
3168
|
// image paths from the bridge-owned UPDIR + deterministic attachment fields,
|
|
3075
3169
|
// then cover the separate file-put/code-turn download race with a bounded
|
|
@@ -3312,7 +3406,7 @@ channel
|
|
|
3312
3406
|
// FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
|
|
3313
3407
|
// bridge restart: the conductor keeps its subagent-block + plan interception, and
|
|
3314
3408
|
// lanes keep their worktree cwd + the ability to mark done.
|
|
3315
|
-
openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, effort: rec.effort, provider: rec.runtime === 'codex' ? undefined : rec.provider || undefined, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, carryRecap: rec.carryRecap,
|
|
3409
|
+
openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, effort: rec.effort, provider: rec.runtime === 'codex' ? undefined : rec.provider || undefined, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, sideParent: rec.sideParent, sideTask: rec.sideTask, pendingSideContexts: rec.pendingSideContexts, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, carryRecap: rec.carryRecap,
|
|
3316
3410
|
// Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
|
|
3317
3411
|
// shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
|
|
3318
3412
|
// (mid-turn needs auto-resume; flow needs its lane live).
|
package/codex-app-server.mjs
CHANGED
|
@@ -214,13 +214,14 @@ export class CodexAppServerClient {
|
|
|
214
214
|
return id
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
async startTurn({ threadId, input, images, model, effort, approvalPolicy } = {}) {
|
|
217
|
+
async startTurn({ threadId, input, images, model, effort, approvalPolicy, collaborationMode } = {}) {
|
|
218
218
|
const result = await this.request('turn/start', {
|
|
219
219
|
threadId,
|
|
220
220
|
input: turnInput(input, images),
|
|
221
221
|
...(model ? { model } : {}),
|
|
222
222
|
...(effort ? { effort } : {}),
|
|
223
223
|
...(approvalPolicy ? { approvalPolicy } : {}),
|
|
224
|
+
...(collaborationMode ? { collaborationMode } : {}),
|
|
224
225
|
}, this.turnStartTimeoutMs)
|
|
225
226
|
const turnId = result?.turn?.id || result?.turnId
|
|
226
227
|
if (!turnId) throw new Error('Codex App Server did not return a turn id')
|
package/codex-session.mjs
CHANGED
|
@@ -46,6 +46,18 @@ export function codexConfigForMode(mode) {
|
|
|
46
46
|
return CODEX_MODE_CONFIG[mode] || CODEX_MODE_CONFIG.default
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
export function codexDefaultCollaborationMode(model, effort) {
|
|
50
|
+
if (!model) return null
|
|
51
|
+
return {
|
|
52
|
+
mode: 'default',
|
|
53
|
+
settings: {
|
|
54
|
+
model: String(model),
|
|
55
|
+
reasoning_effort: EFFORT_LEVELS.has(effort) ? effort : null,
|
|
56
|
+
developer_instructions: null,
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
49
61
|
/**
|
|
50
62
|
* The permission mode a FRESHLY-OPENED terminal gets when none was specified
|
|
51
63
|
* (the person clicked "+ New terminal" without picking a mode). Codex defaults
|
|
@@ -476,6 +488,10 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
476
488
|
model: activeModel,
|
|
477
489
|
sandbox,
|
|
478
490
|
approvalPolicy: modeConfig.approvalPolicy,
|
|
491
|
+
// Codex 0.144.1 gates request_user_input by INTERNAL collaboration
|
|
492
|
+
// mode. Enable it for Default mode on this thread without changing the
|
|
493
|
+
// independent ThinkPool sandbox / approval posture above.
|
|
494
|
+
config: { 'features.default_mode_request_user_input': true },
|
|
479
495
|
})
|
|
480
496
|
appServerThreadReady = true
|
|
481
497
|
mapper.push({ type: 'thread.started', thread_id: sessionId })
|
|
@@ -505,6 +521,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
505
521
|
model: activeModel,
|
|
506
522
|
effort: activeEffort,
|
|
507
523
|
approvalPolicy: modeConfig.approvalPolicy,
|
|
524
|
+
collaborationMode: codexDefaultCollaborationMode(activeModel, activeEffort),
|
|
508
525
|
})
|
|
509
526
|
const completed = await appServer.waitForTurn(activeTurnId)
|
|
510
527
|
const status = completed?.turn?.status
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.216",
|
|
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": {
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"recap.mjs",
|
|
31
31
|
"transcript-sanitize.mjs",
|
|
32
32
|
"session-store.mjs",
|
|
33
|
+
"side-lane.mjs",
|
|
33
34
|
"cross-terminal.mjs",
|
|
34
35
|
"interrupted-resume.mjs",
|
|
35
36
|
"flow-conductor.mjs",
|
package/side-lane.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { buildRecapFromLog } from './recap.mjs'
|
|
2
|
+
|
|
3
|
+
export const SIDE_RECAP_CAP = 12_000
|
|
4
|
+
|
|
5
|
+
export const SIDE_ROLE_PROMPT = `You are a ThinkPool side lane attached to a main terminal.
|
|
6
|
+
Focus first on reading, searching, comparing, and answering the side task. You are a full agent and share the main terminal's working directory, so coordinate through ThinkPool Peer before changing files that another lane may be editing. Do not make edits unless a person explicitly asks for them in this side lane. Keep the investigation self-contained; the main terminal receives nothing until a person chooses Bring to main.`
|
|
7
|
+
|
|
8
|
+
export const SIDE_HANDOFF_PROMPT = `Prepare a compact handoff for the main terminal now.
|
|
9
|
+
Return only the handoff. Include: conclusion, strongest evidence, files changed or artifacts produced, and any unresolved question or recommended next action. Do not continue the investigation and do not address the reader conversationally.`
|
|
10
|
+
|
|
11
|
+
export function sideSnapshot(log) {
|
|
12
|
+
return buildRecapFromLog(Array.isArray(log) ? log : [], SIDE_RECAP_CAP)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function assistantTextSince(log, afterSeq = 0) {
|
|
16
|
+
const chunks = []
|
|
17
|
+
for (const event of Array.isArray(log) ? log : []) {
|
|
18
|
+
if (event?.kind !== 'assistant') continue
|
|
19
|
+
if (Number.isFinite(afterSeq) && Number.isFinite(event.seq) && event.seq <= afterSeq) continue
|
|
20
|
+
for (const block of Array.isArray(event.blocks) ? event.blocks : []) {
|
|
21
|
+
if (block?.type === 'text' && String(block.text || '').trim()) chunks.push(String(block.text).trim())
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return chunks.join('\n\n').trim()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function sideContextBlock({ sideId, sideName, task, summary }) {
|
|
28
|
+
const label = String(sideName || 'Side lane').trim()
|
|
29
|
+
const ref = String(sideId || '').slice(0, 8)
|
|
30
|
+
const lines = [
|
|
31
|
+
`--- side-lane handoff: ${label}${ref ? ` (${ref})` : ''} ---`,
|
|
32
|
+
task ? `Task: ${String(task).trim()}` : '',
|
|
33
|
+
String(summary || '').trim(),
|
|
34
|
+
'--- end side-lane handoff ---',
|
|
35
|
+
]
|
|
36
|
+
return lines.filter(Boolean).join('\n')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function appendSideContext(contexts, context, cap = 4) {
|
|
40
|
+
if (!context) return Array.isArray(contexts) ? contexts.slice(-cap) : []
|
|
41
|
+
return [...(Array.isArray(contexts) ? contexts : []), context].slice(-cap)
|
|
42
|
+
}
|
|
@@ -15,5 +15,6 @@ export const THINKPOOL_REMOTE_DELIVERY_RULES = Object.freeze([
|
|
|
15
15
|
|
|
16
16
|
export const CODEX_THINKPOOL_FIRST_TURN_PREAMBLE = [
|
|
17
17
|
'ENVIRONMENT (authoritative): You are Codex running inside a ThinkPool Code room, driven live through the thinkpool-pair bridge by a user and possibly a partner. This is a shared remote workspace, not an ordinary local terminal session; act accordingly.',
|
|
18
|
+
'MODEL TIERING FOR DISPATCH: When calling spawn_terminal for Codex, explicitly choose from the available model metadata when practical: gpt-5.6-luna or gpt-5.4-mini for mechanical/scaffold/search/formatting work; gpt-5.6-terra for ordinary feature/fix work; gpt-5.6-sol only for trap-laden repeated failures, architecture/conductor work, or adversarial final review. Do not inherit or select Sol by default. If a named model is unavailable, use the nearest advertised lower tier or omit model and allow runtime fallback.',
|
|
18
19
|
...THINKPOOL_REMOTE_DELIVERY_RULES,
|
|
19
20
|
].join(' ')
|