thinkpool-pair 0.7.194 → 0.7.197
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 +42 -12
- package/claude-session.mjs +2 -2
- package/codex-mcp-http.mjs +59 -0
- package/codex-session.mjs +83 -15
- package/lane-worktree.mjs +40 -0
- package/package.json +4 -1
- package/service.mjs +10 -4
package/bridge.mjs
CHANGED
|
@@ -50,7 +50,10 @@ import { validateProviderSwitch, providerSwitchPlan, BUILTIN_PROVIDER } from './
|
|
|
50
50
|
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
|
|
51
51
|
import { z } from 'zod'
|
|
52
52
|
import { startClaudeSession } from './claude-session.mjs'
|
|
53
|
-
import { startCodexSession, readCodexDefaultModel, readCodexModels } from './codex-session.mjs'
|
|
53
|
+
import { startCodexSession, readCodexDefaultModel, readCodexModels, codexConfigForMode } from './codex-session.mjs'
|
|
54
|
+
import { createManagedLaneWorktree, removeManagedLaneWorktree } from './lane-worktree.mjs'
|
|
55
|
+
|
|
56
|
+
const STRUCTURED_MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
|
|
54
57
|
import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, buildConductorEnv, assembleCrossWaveContext, buildLanePrompt } from './flow-conductor.mjs'
|
|
55
58
|
import { normalizePlanOutput, laneModelFor } from './flow-task-graph.mjs' // FL-B1 — validate the conductor's submit_flow_plan task-graph; laneModelFor — per-slice model tier (2026-07-03-flow-lane-model-tiers)
|
|
56
59
|
// S1 (context-offload) — durable digest store. mark_flow_done digests a closed slice in;
|
|
@@ -1050,7 +1053,7 @@ const announce = () => {
|
|
|
1050
1053
|
// turnActive). Authoritative — the bridge owns the turn — so the /code
|
|
1051
1054
|
// dashboard reads "N agents working" off the roster instead of sniffing
|
|
1052
1055
|
// the live code-event stream (which a passive dashboard channel misses).
|
|
1053
|
-
...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, busy: s.session?.turnActive ?? false, commands: s.commands, mode: s.mode || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.runtime === 'codex' ? { approvalPolicy:
|
|
1056
|
+
...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, busy: s.session?.turnActive ?? false, commands: s.commands, mode: s.mode || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [] } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
|
|
1054
1057
|
// provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
|
|
1055
1058
|
// (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
|
|
1056
1059
|
// built-in/default Claude path (no badge). Makes the lane's provider badge +
|
|
@@ -1543,10 +1546,10 @@ function worktreeSnapshot(cwd) {
|
|
|
1543
1546
|
// relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
|
|
1544
1547
|
// persist to the host file; tool calls round-trip through the perm card; the
|
|
1545
1548
|
// rolling log replays to joiners and survives bridge restarts (session-store).
|
|
1546
|
-
function openStructured({ id, runtime = 'claude', model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
|
|
1549
|
+
function openStructured({ id, runtime = 'claude', model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
|
|
1547
1550
|
if (sessions.has(id)) return
|
|
1548
1551
|
runtime = runtime === 'codex' ? 'codex' : 'claude'
|
|
1549
|
-
|
|
1552
|
+
mode = STRUCTURED_MODES.has(mode) ? mode : 'default'
|
|
1550
1553
|
// The lane's EFFECTIVE model — computed ONCE and used for BOTH the truthful display
|
|
1551
1554
|
// label (entry.model) and the SDK's `model` option below. These used to be computed
|
|
1552
1555
|
// separately: the label resolved the provider's configured model while the SDK got the
|
|
@@ -1573,7 +1576,7 @@ function openStructured({ id, runtime = 'claude', model, resume, log, commands,
|
|
|
1573
1576
|
: (!provider || provider === 'anthropic')
|
|
1574
1577
|
? (laneModel || null)
|
|
1575
1578
|
: (laneModel || providerNameMap()[provider] || provider),
|
|
1576
|
-
provider: provider || null, spawnedBy: spawnedBy || undefined, flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null,
|
|
1579
|
+
provider: provider || null, spawnedBy: spawnedBy || undefined, flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null, managedWorktree: managedWorktree || null,
|
|
1577
1580
|
// Stable creation order — persisted so a bridge restart restores tabs in the SAME
|
|
1578
1581
|
// order (not readdir/filesystem order). Legacy recs (no openedAt) derive it from the
|
|
1579
1582
|
// first transcript event ts, so even the first post-fix restart is ordered right.
|
|
@@ -1764,7 +1767,7 @@ function openStructured({ id, runtime = 'claude', model, resume, log, commands,
|
|
|
1764
1767
|
// restart. Without this, sessionData omitted it → on restart the resumed session
|
|
1765
1768
|
// re-launched on the host default (Opus) regardless of the last switch, and the
|
|
1766
1769
|
// switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
|
|
1767
|
-
const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, model: entry.model || null, provider: entry.provider || null, spawnedBy: entry.spawnedBy, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null })
|
|
1770
|
+
const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, 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 })
|
|
1768
1771
|
const persist = () => saveSession(room, id, sessionData())
|
|
1769
1772
|
// Synchronous flush of this session's record. Used on open (so a brand-new session
|
|
1770
1773
|
// has a file under its id BEFORE its first event — surviving a restart inside the
|
|
@@ -1926,11 +1929,11 @@ function openStructured({ id, runtime = 'claude', model, resume, log, commands,
|
|
|
1926
1929
|
// fork-bomb breaker, now one level deeper (2026-07-10 cascade-spawn-depth spec).
|
|
1927
1930
|
tool(
|
|
1928
1931
|
'spawn_terminal',
|
|
1929
|
-
'Open a NEW agent terminal (
|
|
1932
|
+
'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. Use it to fan work out or run a cascade: give it 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.',
|
|
1930
1933
|
{
|
|
1931
1934
|
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"'),
|
|
1932
1935
|
task: z.string().optional().describe('an initial task to hand the new lane immediately; omit to open it idle'),
|
|
1933
|
-
model: z.string().optional().describe('optional model for the
|
|
1936
|
+
model: z.string().optional().describe('optional model for the chosen runtime, e.g. opus / sonnet / gpt-5.6-sol'),
|
|
1934
1937
|
runtime: z.enum(['claude', 'codex']).optional().describe('agent runtime for the new lane; defaults to inheriting this lane'),
|
|
1935
1938
|
provider: z.string().optional().describe('optional registered LLM provider id to run this lane on (from the account\'s provider registry); omit for the default Claude/Anthropic path'),
|
|
1936
1939
|
mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan']).optional().describe('permission mode for the new lane; defaults to inheriting YOUR current mode. Raising a lane to bypassPermissions from a non-bypass lane asks the room to confirm once.'),
|
|
@@ -2112,6 +2115,21 @@ function openStructured({ id, runtime = 'claude', model, resume, log, commands,
|
|
|
2112
2115
|
// ANTHROPIC_MODEL supplied by resolveProviderEnv() in `env` below, so an inherited
|
|
2113
2116
|
// Claude id here defeats the whole per-provider env. undefined → option omitted.
|
|
2114
2117
|
cwd: cwd || process.cwd(), model: laneModel, resume, mode, rolePrompt,
|
|
2118
|
+
// Idle ordinary tabs create nothing. Immediately before the FIRST real
|
|
2119
|
+
// turn (human prompt, dispatch, or cascade task), the runtime calls this
|
|
2120
|
+
// while still cold; subsequent turns reuse the persisted worktree.
|
|
2121
|
+
prepareCwd: (!cwd && !flowSessionId && !flowTaskKey) ? () => {
|
|
2122
|
+
if (entry.managedWorktree?.dir) return entry.managedWorktree.dir
|
|
2123
|
+
try {
|
|
2124
|
+
entry.managedWorktree = createManagedLaneWorktree({ terminalId: id, cwd: process.cwd() })
|
|
2125
|
+
entry.cwd = entry.managedWorktree.dir
|
|
2126
|
+
try { entry.flush?.() } catch { /* first turn may precede flush wiring */ }
|
|
2127
|
+
return entry.cwd
|
|
2128
|
+
} catch (error) {
|
|
2129
|
+
process.stderr.write(`\n ◇ terminal worktree unavailable (${String(id).slice(0, 8)}): ${error?.message || error}; using bridge checkout.\n`)
|
|
2130
|
+
return cwd || process.cwd()
|
|
2131
|
+
}
|
|
2132
|
+
} : null,
|
|
2115
2133
|
// Haiku composer-prefill is an Anthropic-subscription freebie. This lane's env carries a
|
|
2116
2134
|
// CUSTOM provider's base URL + auth token, so on a non-anthropic lane that call would bill
|
|
2117
2135
|
// the user's own BYOK key for a feature they didn't ask for (and 400 on strict providers).
|
|
@@ -2127,7 +2145,7 @@ function openStructured({ id, runtime = 'claude', model, resume, log, commands,
|
|
|
2127
2145
|
},
|
|
2128
2146
|
// defer: a restored-idle terminal cold-boots its query only on first use — restore is
|
|
2129
2147
|
// then instant for display, and only used terminals pay the ~50s MCP boot (Max, 2026-07-02).
|
|
2130
|
-
lazy: !!defer,
|
|
2148
|
+
lazy: !!defer || (!cwd && !flowSessionId && !flowTaskKey),
|
|
2131
2149
|
reviewGate,
|
|
2132
2150
|
// A Flow CONDUCTOR (flowSessionId set, no flowTaskKey) must DECOMPOSE, not explore via
|
|
2133
2151
|
// a fan-out of Task/Explore subagents, and must not build. Hard-block Task/Bash/Write for
|
|
@@ -2564,6 +2582,10 @@ function endStructured(id) {
|
|
|
2564
2582
|
reapTerminalRow(id) // bridge-side reap: delete the code_terminals row ourselves — the
|
|
2565
2583
|
// client reap (#205) evaporates if no fresh tab caught the broadcast
|
|
2566
2584
|
fireImageCleanup(id) // drop this term's screenshots from Storage (COGS)
|
|
2585
|
+
// A deliberate terminal deletion owns cleanup of a bridge-created ordinary
|
|
2586
|
+
// lane worktree. Flow worktrees have their separate assembly/revert lifecycle,
|
|
2587
|
+
// and arbitrary cwd values never authorize deletion.
|
|
2588
|
+
try { removeManagedLaneWorktree({ terminalId: id, managedWorktree: s.managedWorktree }) } catch { /* best-effort; never strand terminal deletion */ }
|
|
2567
2589
|
// EN-m3 — closing a spawner ORPHANS the lanes it spawned (only that spawner could
|
|
2568
2590
|
// close_terminal them; they can never be closed otherwise + keep occupying the caps).
|
|
2569
2591
|
// Cascade-close its direct children (spawnedBy === this id — a real terminal id, not a
|
|
@@ -2963,9 +2985,17 @@ channel
|
|
|
2963
2985
|
})
|
|
2964
2986
|
.on('broadcast', { event: 'code-mode' }, ({ payload }) => {
|
|
2965
2987
|
const s = payload?.term && sessions.get(payload.term)
|
|
2966
|
-
if (s) {
|
|
2988
|
+
if (s && STRUCTURED_MODES.has(payload.mode)) {
|
|
2967
2989
|
if (s.runtime === 'codex') {
|
|
2968
|
-
s.mode
|
|
2990
|
+
if (!s.session.setMode(payload.mode)) {
|
|
2991
|
+
const evt = { kind: 'control', text: 'finish or stop the current Codex turn before switching permissions' }
|
|
2992
|
+
pushLog(s, evt)
|
|
2993
|
+
bcast('code-event', { term: payload.term, evt })
|
|
2994
|
+
announce()
|
|
2995
|
+
return
|
|
2996
|
+
}
|
|
2997
|
+
s.mode = payload.mode
|
|
2998
|
+
s.flush?.()
|
|
2969
2999
|
announce()
|
|
2970
3000
|
return
|
|
2971
3001
|
}
|
|
@@ -3135,7 +3165,7 @@ channel
|
|
|
3135
3165
|
// FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
|
|
3136
3166
|
// bridge restart: the conductor keeps its subagent-block + plan interception, and
|
|
3137
3167
|
// lanes keep their worktree cwd + the ability to mark done.
|
|
3138
|
-
openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, 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, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage,
|
|
3168
|
+
openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, 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,
|
|
3139
3169
|
// Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
|
|
3140
3170
|
// shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
|
|
3141
3171
|
// (mid-turn needs auto-resume; flow needs its lane live).
|
package/claude-session.mjs
CHANGED
|
@@ -205,7 +205,7 @@ const TP_ROOM_REMINDER = [
|
|
|
205
205
|
'Verify before claiming done — show runtime evidence you produced, not "should work, go test it".',
|
|
206
206
|
].join(' ')
|
|
207
207
|
|
|
208
|
-
export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, didSpawnTarget = null, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null, lazy = false, roomContext = null, suggest = true }) {
|
|
208
|
+
export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, didSpawnTarget = null, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null, lazy = false, roomContext = null, suggest = true, prepareCwd = null }) {
|
|
209
209
|
// Per-turn reminder + live ROOM NOW tail. roomContext (bridge-supplied) returns the
|
|
210
210
|
// room's CURRENT state — sibling lanes, active git worktrees — or null. The static
|
|
211
211
|
// rules keep the agent aware of the room's FEATURES; the live tail keeps it aware of
|
|
@@ -1148,7 +1148,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
1148
1148
|
return {
|
|
1149
1149
|
// A lazy (restored-idle) session cold-boots the query on its first turn. input.push
|
|
1150
1150
|
// is queue-backed, so the pushed turn buffers and runs once the query is ready.
|
|
1151
|
-
sendTurn(text) { if (!closed) { if (!started) runQuery(); turnActive = true; sawSuggestion = false; lastTurnText = String(text); if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false; stallRetried = false; forceStopped = false;
|
|
1151
|
+
sendTurn(text) { if (!closed) { if (!started && prepareCwd) { try { const next = prepareCwd(); if (next) { cwd = next; opts.cwd = next } } catch { /* keep original cwd */ } } if (!started) runQuery(); turnActive = true; sawSuggestion = false; lastTurnText = String(text); if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false; stallRetried = false; forceStopped = false;
|
|
1152
1152
|
const t = String(text)
|
|
1153
1153
|
// Arm the compaction window BEFORE the push: from here until the `compaction` milestone
|
|
1154
1154
|
// (or the turn's result) the SDK is allowed to be silent for minutes without the stall
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Expose one bridge-owned, in-process MCP server to a Codex CLI child.
|
|
2
|
+
// Claude's Agent SDK consumes createSdkMcpServer() directly; Codex is a
|
|
3
|
+
// separate process and needs a transport. This private loopback endpoint keeps
|
|
4
|
+
// the handlers in the bridge, preserving the same room registry and gates.
|
|
5
|
+
import http from 'node:http'
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
8
|
+
|
|
9
|
+
export async function startCodexMcpHttp({ sdkServer, host = '127.0.0.1' } = {}) {
|
|
10
|
+
const instance = sdkServer?.instance
|
|
11
|
+
if (!instance?.connect) throw new TypeError('Codex MCP requires an SDK MCP server instance')
|
|
12
|
+
|
|
13
|
+
const secretPath = `/mcp/${randomUUID()}`
|
|
14
|
+
// Stateful sessions (not stateless): a stateless transport (sessionIdGenerator
|
|
15
|
+
// undefined) 500s on the `notifications/initialized` POST that every MCP client
|
|
16
|
+
// sends right after initialize — it has no session to attach the notification to.
|
|
17
|
+
// Each connecting Codex process gets its own session id; the bridge owns one
|
|
18
|
+
// server per lane, so the in-memory session map is bounded by the live lanes.
|
|
19
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() })
|
|
20
|
+
await instance.connect(transport)
|
|
21
|
+
|
|
22
|
+
const server = http.createServer(async (req, res) => {
|
|
23
|
+
if (req.url !== secretPath) {
|
|
24
|
+
res.writeHead(404).end('not found')
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
await transport.handleRequest(req, res)
|
|
29
|
+
} catch {
|
|
30
|
+
if (!res.headersSent) res.writeHead(500)
|
|
31
|
+
res.end()
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
await new Promise((resolve, reject) => {
|
|
37
|
+
server.once('error', reject)
|
|
38
|
+
server.listen(0, host, resolve)
|
|
39
|
+
})
|
|
40
|
+
} catch (error) {
|
|
41
|
+
await transport.close().catch(() => {})
|
|
42
|
+
throw error
|
|
43
|
+
}
|
|
44
|
+
const address = server.address()
|
|
45
|
+
const url = `http://${host}:${address.port}${secretPath}`
|
|
46
|
+
|
|
47
|
+
let closed = false
|
|
48
|
+
return {
|
|
49
|
+
url,
|
|
50
|
+
async close() {
|
|
51
|
+
if (closed) return
|
|
52
|
+
closed = true
|
|
53
|
+
await Promise.allSettled([
|
|
54
|
+
transport.close(),
|
|
55
|
+
new Promise((resolve) => server.close(resolve)),
|
|
56
|
+
])
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
package/codex-session.mjs
CHANGED
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
// no tool_use↔function-call loss. Auth = the HOST's `codex login` (ChatGPT,
|
|
11
11
|
// flat sub) by default; BYOK via env CODEX_API_KEY works too (exec-only).
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
13
|
+
// `codex exec` is non-interactive, so ThinkPool maps its shared permission modes
|
|
14
|
+
// onto Codex's native sandbox + approval policy. A mode change starts a fresh
|
|
15
|
+
// Codex thread so the new sandbox actually takes effect; the visible transcript
|
|
16
|
+
// remains in ThinkPool.
|
|
17
17
|
//
|
|
18
18
|
// ONE-SHOT-PER-TURN model: unlike Claude's long-lived query(), `codex exec` is
|
|
19
19
|
// one prompt → run → exit. sendTurn spawns an exec; continuity across turns uses
|
|
@@ -22,15 +22,26 @@
|
|
|
22
22
|
//
|
|
23
23
|
// GOTCHA (spike): `codex exec` blocks reading stdin when backgrounded
|
|
24
24
|
// ("Reading additional input from stdin…"). Spawn with stdin set to /dev/null.
|
|
25
|
-
import { spawn } from 'node:child_process'
|
|
25
|
+
import { execFileSync, spawn } from 'node:child_process'
|
|
26
26
|
import fs from 'node:fs'
|
|
27
27
|
import os from 'node:os'
|
|
28
28
|
import path from 'node:path'
|
|
29
29
|
import readline from 'node:readline'
|
|
30
30
|
import { CodexEventMapper } from './codex-event-mapper.mjs'
|
|
31
|
+
import { startCodexMcpHttp } from './codex-mcp-http.mjs'
|
|
31
32
|
|
|
32
33
|
const DEFAULT_SANDBOX = 'workspace-write'
|
|
33
|
-
const SAFE_SANDBOXES = new Set(['read-only', 'workspace-write'])
|
|
34
|
+
const SAFE_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access'])
|
|
35
|
+
export const CODEX_MODE_CONFIG = {
|
|
36
|
+
default: { sandbox: 'workspace-write', approvalPolicy: 'untrusted' },
|
|
37
|
+
acceptEdits: { sandbox: 'workspace-write', approvalPolicy: 'on-request' },
|
|
38
|
+
plan: { sandbox: 'read-only', approvalPolicy: 'never' },
|
|
39
|
+
bypassPermissions: { sandbox: 'danger-full-access', approvalPolicy: 'never' },
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function codexConfigForMode(mode) {
|
|
43
|
+
return CODEX_MODE_CONFIG[mode] || CODEX_MODE_CONFIG.default
|
|
44
|
+
}
|
|
34
45
|
|
|
35
46
|
export function readCodexDefaultModel({ home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), fsImpl = fs } = {}) {
|
|
36
47
|
try {
|
|
@@ -57,6 +68,27 @@ export function normalizeCodexSandbox(value) {
|
|
|
57
68
|
return SAFE_SANDBOXES.has(value) ? value : DEFAULT_SANDBOX
|
|
58
69
|
}
|
|
59
70
|
|
|
71
|
+
export function codexPublishAccess({ cwd, env = process.env, tmpdir = os.tmpdir(), execFile = execFileSync } = {}) {
|
|
72
|
+
const writableDirs = []
|
|
73
|
+
try {
|
|
74
|
+
const raw = String(execFile('git', ['rev-parse', '--git-common-dir'], { cwd, encoding: 'utf8' }) || '').trim()
|
|
75
|
+
if (raw) {
|
|
76
|
+
const commonDir = path.resolve(cwd, raw)
|
|
77
|
+
const workspace = path.resolve(cwd)
|
|
78
|
+
if (commonDir !== workspace && !commonDir.startsWith(workspace + path.sep)) writableDirs.push(commonDir)
|
|
79
|
+
}
|
|
80
|
+
} catch { /* non-git cwd: Codex already uses --skip-git-repo-check */ }
|
|
81
|
+
return {
|
|
82
|
+
writableDirs,
|
|
83
|
+
env: {
|
|
84
|
+
...env,
|
|
85
|
+
// Auth still comes read-only from ~/.npmrc; only cache writes move to a
|
|
86
|
+
// lane-safe temp location.
|
|
87
|
+
NPM_CONFIG_CACHE: path.join(tmpdir, 'thinkpool-npm-cache'),
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
60
92
|
export function buildCodexPrompt({ text, rolePrompt, roomContext }) {
|
|
61
93
|
const context = typeof roomContext === 'function' ? roomContext() : roomContext
|
|
62
94
|
const additions = [rolePrompt, context].map((v) => String(v || '').trim()).filter(Boolean)
|
|
@@ -64,7 +96,7 @@ export function buildCodexPrompt({ text, rolePrompt, roomContext }) {
|
|
|
64
96
|
return `<thinkpool-context>\n${additions.join('\n\n')}\n</thinkpool-context>\n\n${String(text ?? '')}`
|
|
65
97
|
}
|
|
66
98
|
|
|
67
|
-
export function buildCodexExecArgs({ sessionId, model, sandbox, providerConfig, prompt }) {
|
|
99
|
+
export function buildCodexExecArgs({ sessionId, model, sandbox, approvalPolicy = 'untrusted', providerConfig, mcpUrl, writableDirs = [], prompt }) {
|
|
68
100
|
const safeSandbox = normalizeCodexSandbox(sandbox)
|
|
69
101
|
const args = sessionId
|
|
70
102
|
// `codex exec resume` deliberately has no --sandbox option. The resumed
|
|
@@ -73,7 +105,13 @@ export function buildCodexExecArgs({ sessionId, model, sandbox, providerConfig,
|
|
|
73
105
|
? ['exec', 'resume', '--json', '--skip-git-repo-check']
|
|
74
106
|
: ['exec', '--json', '--sandbox', safeSandbox, '--skip-git-repo-check']
|
|
75
107
|
if (model) args.push('-m', model)
|
|
108
|
+
args.push('-c', `approval_policy="${approvalPolicy}"`)
|
|
76
109
|
if (providerConfig) args.push('-c', providerConfig)
|
|
110
|
+
// `resume` retains the roots selected by the fresh rollout and does not
|
|
111
|
+
// accept --add-dir itself. Supply these only when creating the thread.
|
|
112
|
+
if (!sessionId) for (const dir of writableDirs) args.push('--add-dir', dir)
|
|
113
|
+
// Per-lane override only: never mutate the person's global Codex config.
|
|
114
|
+
if (mcpUrl) args.push('-c', `mcp_servers.thinkpool.url=${JSON.stringify(mcpUrl)}`)
|
|
77
115
|
if (sessionId) args.push(sessionId)
|
|
78
116
|
args.push(String(prompt ?? ''))
|
|
79
117
|
return args
|
|
@@ -91,9 +129,12 @@ export function buildCodexExecArgs({ sessionId, model, sandbox, providerConfig,
|
|
|
91
129
|
* @param {string} [o.providerConfig] optional -c overrides / provider block (M2: from the provider registry)
|
|
92
130
|
* @returns {{ sendTurn(text), abort(), end(), readonly sessionId }}
|
|
93
131
|
*/
|
|
94
|
-
export function startCodexSession({ cwd, model, resume, env, sandbox =
|
|
95
|
-
|
|
96
|
-
|
|
132
|
+
export function startCodexSession({ cwd, model, resume, env, sandbox, mode = 'default', onEvent, providerConfig, rolePrompt, roomContext, mcpServers, prepareCwd = null }) {
|
|
133
|
+
let activeMode = CODEX_MODE_CONFIG[mode] ? mode : 'default'
|
|
134
|
+
let modeConfig = codexConfigForMode(activeMode)
|
|
135
|
+
sandbox = normalizeCodexSandbox(sandbox || modeConfig.sandbox)
|
|
136
|
+
const publishAccess = codexPublishAccess({ cwd, env: { ...process.env, ...(env || {}) } })
|
|
137
|
+
const childEnv = publishAccess.env
|
|
97
138
|
let sessionId = resume || null
|
|
98
139
|
let turnNo = resume ? 1 : 0
|
|
99
140
|
let child = null
|
|
@@ -101,21 +142,37 @@ export function startCodexSession({ cwd, model, resume, env, sandbox = DEFAULT_S
|
|
|
101
142
|
let ended = false
|
|
102
143
|
let turnActive = false
|
|
103
144
|
let activeModel = model || null
|
|
145
|
+
let mcpHttpPromise = null
|
|
104
146
|
// serialize turns: one exec at a time
|
|
105
147
|
let chain = Promise.resolve()
|
|
106
148
|
const queue = []
|
|
107
149
|
|
|
108
150
|
const mapper = new CodexEventMapper({ onEvent, model: model || null })
|
|
109
151
|
|
|
110
|
-
|
|
152
|
+
const note = (text) => { try { onEvent?.({ kind: 'note', text }) } catch { /* noop */ } }
|
|
153
|
+
|
|
154
|
+
async function ensureMcpHttp() {
|
|
155
|
+
const thinkpool = mcpServers?.thinkpool
|
|
156
|
+
if (!thinkpool) return null
|
|
157
|
+
if (!mcpHttpPromise) mcpHttpPromise = startCodexMcpHttp({ sdkServer: thinkpool })
|
|
158
|
+
return mcpHttpPromise
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function runExec(prompt) {
|
|
111
162
|
// First turn: fresh exec. Later turns: resume THIS lane's captured thread.
|
|
112
163
|
// `--last` is process-global and can cross-wire two concurrently-active Codex
|
|
113
164
|
// lanes, so it is never safe in a multi-lane bridge.
|
|
165
|
+
let peer
|
|
166
|
+
try { peer = await ensureMcpHttp() }
|
|
167
|
+
catch (e) { note(`Thinkpool peer tools unavailable: ${e?.message || e}`) }
|
|
114
168
|
const args = buildCodexExecArgs({
|
|
115
169
|
sessionId: turnNo === 0 ? null : sessionId,
|
|
116
170
|
model: activeModel,
|
|
117
171
|
sandbox,
|
|
172
|
+
approvalPolicy: modeConfig.approvalPolicy,
|
|
118
173
|
providerConfig,
|
|
174
|
+
mcpUrl: peer?.url,
|
|
175
|
+
writableDirs: publishAccess.writableDirs,
|
|
119
176
|
prompt,
|
|
120
177
|
})
|
|
121
178
|
|
|
@@ -193,6 +250,9 @@ export function startCodexSession({ cwd, model, resume, env, sandbox = DEFAULT_S
|
|
|
193
250
|
get started() { return turnNo > 0 || turnActive },
|
|
194
251
|
sendTurn(text) {
|
|
195
252
|
if (ended) return false
|
|
253
|
+
if (!turnActive && turnNo === 0 && prepareCwd) {
|
|
254
|
+
try { cwd = prepareCwd() || cwd } catch { /* keep original cwd */ }
|
|
255
|
+
}
|
|
196
256
|
queue.push(text)
|
|
197
257
|
// start the pump if idle
|
|
198
258
|
if (queue.length === 1) pump()
|
|
@@ -207,6 +267,7 @@ export function startCodexSession({ cwd, model, resume, env, sandbox = DEFAULT_S
|
|
|
207
267
|
ended = true
|
|
208
268
|
queue.length = 0
|
|
209
269
|
if (child) { try { child.kill() } catch { /* noop */ } }
|
|
270
|
+
void mcpHttpPromise?.then((h) => h.close()).catch(() => {})
|
|
210
271
|
},
|
|
211
272
|
setModel(nextModel) {
|
|
212
273
|
if (!nextModel || turnActive) return false
|
|
@@ -214,10 +275,17 @@ export function startCodexSession({ cwd, model, resume, env, sandbox = DEFAULT_S
|
|
|
214
275
|
mapper.setModel(activeModel)
|
|
215
276
|
return true
|
|
216
277
|
},
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
278
|
+
setMode(nextMode) {
|
|
279
|
+
if (turnActive || !CODEX_MODE_CONFIG[nextMode]) return false
|
|
280
|
+
activeMode = nextMode
|
|
281
|
+
modeConfig = codexConfigForMode(activeMode)
|
|
282
|
+
sandbox = modeConfig.sandbox
|
|
283
|
+
// Resume retains the original sandbox. Start a fresh native thread on the
|
|
284
|
+
// next turn so the selected mode is real, while ThinkPool keeps the lane log.
|
|
285
|
+
sessionId = null
|
|
286
|
+
turnNo = 0
|
|
287
|
+
return true
|
|
288
|
+
},
|
|
221
289
|
clearContext() {
|
|
222
290
|
if (turnActive) return false
|
|
223
291
|
sessionId = null
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Lifecycle for ordinary bridge-created terminal worktrees. Deletion authority
|
|
2
|
+
// is the persisted managedWorktree record, never a lane's arbitrary cwd.
|
|
3
|
+
import { execFileSync } from 'node:child_process'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
|
|
7
|
+
export function createManagedLaneWorktree({ terminalId, cwd = process.cwd(), git = runGit, fsImpl = fs } = {}) {
|
|
8
|
+
const id = String(terminalId || '')
|
|
9
|
+
const short = id.replace(/[^a-zA-Z0-9]/g, '').slice(0, 8)
|
|
10
|
+
if (!short) throw new Error('terminal id required for managed worktree')
|
|
11
|
+
const root = String(git(['rev-parse', '--show-toplevel'], cwd) || '').trim()
|
|
12
|
+
if (!root) throw new Error('not a git repository')
|
|
13
|
+
const dir = path.join(root, '.thinkpool', 'worktrees', `terminal-${short}`)
|
|
14
|
+
const branch = `thinkpool/terminal/${short}`
|
|
15
|
+
fsImpl.mkdirSync(path.dirname(dir), { recursive: true })
|
|
16
|
+
git(['worktree', 'add', '-b', branch, dir, 'HEAD'], root)
|
|
17
|
+
return { terminalId: id, root, dir, branch }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function removeManagedLaneWorktree({ terminalId, managedWorktree, git = runGit } = {}) {
|
|
21
|
+
const id = String(terminalId || '')
|
|
22
|
+
const rec = managedWorktree && typeof managedWorktree === 'object' ? managedWorktree : null
|
|
23
|
+
if (!id || !rec || rec.terminalId !== id) return { removed: false, reason: 'not managed by this terminal' }
|
|
24
|
+
|
|
25
|
+
const root = path.resolve(String(rec.root || ''))
|
|
26
|
+
const dir = path.resolve(String(rec.dir || ''))
|
|
27
|
+
const branch = String(rec.branch || '')
|
|
28
|
+
const ownedRoot = path.join(root, '.thinkpool', 'worktrees')
|
|
29
|
+
const short = id.replace(/[^a-zA-Z0-9]/g, '').slice(0, 8)
|
|
30
|
+
if (!root || dir === ownedRoot || !dir.startsWith(ownedRoot + path.sep)) return { removed: false, reason: 'path outside managed worktrees' }
|
|
31
|
+
if (!short || !path.basename(dir).includes(short) || !branch.startsWith(`thinkpool/terminal/${short}`)) return { removed: false, reason: 'ownership marker mismatch' }
|
|
32
|
+
|
|
33
|
+
try { git(['worktree', 'remove', dir, '--force'], root) } catch { /* already absent */ }
|
|
34
|
+
try { git(['branch', '-D', branch], root) } catch { /* already absent */ }
|
|
35
|
+
return { removed: true, dir, branch }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function runGit(args, cwd) {
|
|
39
|
+
return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' })
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.197",
|
|
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": {
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
"context-windows.mjs",
|
|
15
15
|
"claude-session.mjs",
|
|
16
16
|
"codex-session.mjs",
|
|
17
|
+
"codex-mcp-http.mjs",
|
|
18
|
+
"lane-worktree.mjs",
|
|
17
19
|
"codex-event-mapper.mjs",
|
|
18
20
|
"turn-stall.mjs",
|
|
19
21
|
"update-gate.mjs",
|
|
@@ -58,6 +60,7 @@
|
|
|
58
60
|
},
|
|
59
61
|
"dependencies": {
|
|
60
62
|
"@anthropic-ai/claude-agent-sdk": "^0.3.198",
|
|
63
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
61
64
|
"@supabase/supabase-js": "^2.45.0",
|
|
62
65
|
"zod": "^4.4.3"
|
|
63
66
|
},
|
package/service.mjs
CHANGED
|
@@ -234,12 +234,18 @@ WantedBy=default.target
|
|
|
234
234
|
// Blocking sleep (installService runs synchronously from the interactive menu; the
|
|
235
235
|
// user is watching, so we WANT to wait + report the real outcome, not fire-and-forget).
|
|
236
236
|
const sleepMs = (ms) => { try { execSync(`sleep ${(ms / 1000).toFixed(2)}`) } catch { /* noop */ } }
|
|
237
|
-
//
|
|
238
|
-
//
|
|
237
|
+
// Parse both legacy npx commands (`thinkpool-pair@<ver>`) and stable runtime commands
|
|
238
|
+
// (`.../runtimes/<ver>/...`).
|
|
239
|
+
export function parseRunningPairVersions(out) {
|
|
240
|
+
const versions = String(out).match(/(?:thinkpool-pair@|runtimes\/)(\d+\.\d+\.\d+)/g) || []
|
|
241
|
+
return new Set(versions.map((s) => s.match(/\d+\.\d+\.\d+/)?.[0]).filter(Boolean))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Which thinkpool-pair versions are running right now. Returns a Set of versions.
|
|
239
245
|
const runningPairVersions = () => {
|
|
240
246
|
try {
|
|
241
|
-
const out = execSync(
|
|
242
|
-
return
|
|
247
|
+
const out = execSync('ps ax -o command= 2>/dev/null', { encoding: 'utf8', shell: '/bin/bash' })
|
|
248
|
+
return parseRunningPairVersions(out)
|
|
243
249
|
} catch { return new Set() }
|
|
244
250
|
}
|
|
245
251
|
const darwinSvcLoaded = (lbl) => { try { execSync(`launchctl print gui/$(id -u)/${lbl}`, { stdio: 'ignore' }); return true } catch { return false } }
|