thinkpool-pair 0.7.196 → 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 CHANGED
@@ -51,6 +51,7 @@ import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
51
51
  import { z } from 'zod'
52
52
  import { startClaudeSession } from './claude-session.mjs'
53
53
  import { startCodexSession, readCodexDefaultModel, readCodexModels, codexConfigForMode } from './codex-session.mjs'
54
+ import { createManagedLaneWorktree, removeManagedLaneWorktree } from './lane-worktree.mjs'
54
55
 
55
56
  const STRUCTURED_MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
56
57
  import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, buildConductorEnv, assembleCrossWaveContext, buildLanePrompt } from './flow-conductor.mjs'
@@ -1545,7 +1546,7 @@ function worktreeSnapshot(cwd) {
1545
1546
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
1546
1547
  // persist to the host file; tool calls round-trip through the perm card; the
1547
1548
  // rolling log replays to joiners and survives bridge restarts (session-store).
1548
- 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 }) {
1549
1550
  if (sessions.has(id)) return
1550
1551
  runtime = runtime === 'codex' ? 'codex' : 'claude'
1551
1552
  mode = STRUCTURED_MODES.has(mode) ? mode : 'default'
@@ -1575,7 +1576,7 @@ function openStructured({ id, runtime = 'claude', model, resume, log, commands,
1575
1576
  : (!provider || provider === 'anthropic')
1576
1577
  ? (laneModel || null)
1577
1578
  : (laneModel || providerNameMap()[provider] || provider),
1578
- 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,
1579
1580
  // Stable creation order — persisted so a bridge restart restores tabs in the SAME
1580
1581
  // order (not readdir/filesystem order). Legacy recs (no openedAt) derive it from the
1581
1582
  // first transcript event ts, so even the first post-fix restart is ordered right.
@@ -1766,7 +1767,7 @@ function openStructured({ id, runtime = 'claude', model, resume, log, commands,
1766
1767
  // restart. Without this, sessionData omitted it → on restart the resumed session
1767
1768
  // re-launched on the host default (Opus) regardless of the last switch, and the
1768
1769
  // switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
1769
- 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 })
1770
1771
  const persist = () => saveSession(room, id, sessionData())
1771
1772
  // Synchronous flush of this session's record. Used on open (so a brand-new session
1772
1773
  // has a file under its id BEFORE its first event — surviving a restart inside the
@@ -1928,11 +1929,11 @@ function openStructured({ id, runtime = 'claude', model, resume, log, commands,
1928
1929
  // fork-bomb breaker, now one level deeper (2026-07-10 cascade-spawn-depth spec).
1929
1930
  tool(
1930
1931
  'spawn_terminal',
1931
- 'Open a NEW agent terminal (a fresh Claude lane) in this ThinkPool Code room — YOUR lane to use, not a sibling that is already working. Use it to fan work out: give it an initial `task` and it runs in parallel in its own lane; 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 — so if you are in bypass, it runs autonomously with no per-action clicking; pass `mode` to override. ALWAYS prefer spawning a fresh lane over post_to_terminal into one that is already busy. Tier guidance: default worker lanes to opus; use sonnet for mechanical work (renames, boilerplate, doc sweeps); reserve your own model for planning/review. Bounded by the room terminal cap and a per-turn limit.',
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.',
1932
1933
  {
1933
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"'),
1934
1935
  task: z.string().optional().describe('an initial task to hand the new lane immediately; omit to open it idle'),
1935
- model: z.string().optional().describe('optional model for the lane, e.g. opus / sonnet / haiku'),
1936
+ model: z.string().optional().describe('optional model for the chosen runtime, e.g. opus / sonnet / gpt-5.6-sol'),
1936
1937
  runtime: z.enum(['claude', 'codex']).optional().describe('agent runtime for the new lane; defaults to inheriting this lane'),
1937
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'),
1938
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.'),
@@ -2114,6 +2115,21 @@ function openStructured({ id, runtime = 'claude', model, resume, log, commands,
2114
2115
  // ANTHROPIC_MODEL supplied by resolveProviderEnv() in `env` below, so an inherited
2115
2116
  // Claude id here defeats the whole per-provider env. undefined → option omitted.
2116
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,
2117
2133
  // Haiku composer-prefill is an Anthropic-subscription freebie. This lane's env carries a
2118
2134
  // CUSTOM provider's base URL + auth token, so on a non-anthropic lane that call would bill
2119
2135
  // the user's own BYOK key for a feature they didn't ask for (and 400 on strict providers).
@@ -2129,7 +2145,7 @@ function openStructured({ id, runtime = 'claude', model, resume, log, commands,
2129
2145
  },
2130
2146
  // defer: a restored-idle terminal cold-boots its query only on first use — restore is
2131
2147
  // then instant for display, and only used terminals pay the ~50s MCP boot (Max, 2026-07-02).
2132
- lazy: !!defer,
2148
+ lazy: !!defer || (!cwd && !flowSessionId && !flowTaskKey),
2133
2149
  reviewGate,
2134
2150
  // A Flow CONDUCTOR (flowSessionId set, no flowTaskKey) must DECOMPOSE, not explore via
2135
2151
  // a fan-out of Task/Explore subagents, and must not build. Hard-block Task/Bash/Write for
@@ -2566,6 +2582,10 @@ function endStructured(id) {
2566
2582
  reapTerminalRow(id) // bridge-side reap: delete the code_terminals row ourselves — the
2567
2583
  // client reap (#205) evaporates if no fresh tab caught the broadcast
2568
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 */ }
2569
2589
  // EN-m3 — closing a spawner ORPHANS the lanes it spawned (only that spawner could
2570
2590
  // close_terminal them; they can never be closed otherwise + keep occupying the caps).
2571
2591
  // Cascade-close its direct children (spawnedBy === this id — a real terminal id, not a
@@ -3145,7 +3165,7 @@ channel
3145
3165
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
3146
3166
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
3147
3167
  // lanes keep their worktree cwd + the ability to mark done.
3148
- 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,
3149
3169
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
3150
3170
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
3151
3171
  // (mid-turn needs auto-resume; flow needs its lane live).
@@ -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
@@ -22,12 +22,13 @@
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
34
  const SAFE_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access'])
@@ -67,6 +68,27 @@ export function normalizeCodexSandbox(value) {
67
68
  return SAFE_SANDBOXES.has(value) ? value : DEFAULT_SANDBOX
68
69
  }
69
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
+
70
92
  export function buildCodexPrompt({ text, rolePrompt, roomContext }) {
71
93
  const context = typeof roomContext === 'function' ? roomContext() : roomContext
72
94
  const additions = [rolePrompt, context].map((v) => String(v || '').trim()).filter(Boolean)
@@ -74,7 +96,7 @@ export function buildCodexPrompt({ text, rolePrompt, roomContext }) {
74
96
  return `<thinkpool-context>\n${additions.join('\n\n')}\n</thinkpool-context>\n\n${String(text ?? '')}`
75
97
  }
76
98
 
77
- export function buildCodexExecArgs({ sessionId, model, sandbox, approvalPolicy = 'untrusted', providerConfig, prompt }) {
99
+ export function buildCodexExecArgs({ sessionId, model, sandbox, approvalPolicy = 'untrusted', providerConfig, mcpUrl, writableDirs = [], prompt }) {
78
100
  const safeSandbox = normalizeCodexSandbox(sandbox)
79
101
  const args = sessionId
80
102
  // `codex exec resume` deliberately has no --sandbox option. The resumed
@@ -85,6 +107,11 @@ export function buildCodexExecArgs({ sessionId, model, sandbox, approvalPolicy =
85
107
  if (model) args.push('-m', model)
86
108
  args.push('-c', `approval_policy="${approvalPolicy}"`)
87
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)}`)
88
115
  if (sessionId) args.push(sessionId)
89
116
  args.push(String(prompt ?? ''))
90
117
  return args
@@ -102,11 +129,12 @@ export function buildCodexExecArgs({ sessionId, model, sandbox, approvalPolicy =
102
129
  * @param {string} [o.providerConfig] optional -c overrides / provider block (M2: from the provider registry)
103
130
  * @returns {{ sendTurn(text), abort(), end(), readonly sessionId }}
104
131
  */
105
- export function startCodexSession({ cwd, model, resume, env, sandbox, mode = 'default', onEvent, providerConfig, rolePrompt, roomContext }) {
132
+ export function startCodexSession({ cwd, model, resume, env, sandbox, mode = 'default', onEvent, providerConfig, rolePrompt, roomContext, mcpServers, prepareCwd = null }) {
106
133
  let activeMode = CODEX_MODE_CONFIG[mode] ? mode : 'default'
107
134
  let modeConfig = codexConfigForMode(activeMode)
108
135
  sandbox = normalizeCodexSandbox(sandbox || modeConfig.sandbox)
109
- const childEnv = { ...process.env, ...(env || {}) }
136
+ const publishAccess = codexPublishAccess({ cwd, env: { ...process.env, ...(env || {}) } })
137
+ const childEnv = publishAccess.env
110
138
  let sessionId = resume || null
111
139
  let turnNo = resume ? 1 : 0
112
140
  let child = null
@@ -114,22 +142,37 @@ export function startCodexSession({ cwd, model, resume, env, sandbox, mode = 'de
114
142
  let ended = false
115
143
  let turnActive = false
116
144
  let activeModel = model || null
145
+ let mcpHttpPromise = null
117
146
  // serialize turns: one exec at a time
118
147
  let chain = Promise.resolve()
119
148
  const queue = []
120
149
 
121
150
  const mapper = new CodexEventMapper({ onEvent, model: model || null })
122
151
 
123
- function runExec(prompt) {
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) {
124
162
  // First turn: fresh exec. Later turns: resume THIS lane's captured thread.
125
163
  // `--last` is process-global and can cross-wire two concurrently-active Codex
126
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}`) }
127
168
  const args = buildCodexExecArgs({
128
169
  sessionId: turnNo === 0 ? null : sessionId,
129
170
  model: activeModel,
130
171
  sandbox,
131
172
  approvalPolicy: modeConfig.approvalPolicy,
132
173
  providerConfig,
174
+ mcpUrl: peer?.url,
175
+ writableDirs: publishAccess.writableDirs,
133
176
  prompt,
134
177
  })
135
178
 
@@ -207,6 +250,9 @@ export function startCodexSession({ cwd, model, resume, env, sandbox, mode = 'de
207
250
  get started() { return turnNo > 0 || turnActive },
208
251
  sendTurn(text) {
209
252
  if (ended) return false
253
+ if (!turnActive && turnNo === 0 && prepareCwd) {
254
+ try { cwd = prepareCwd() || cwd } catch { /* keep original cwd */ }
255
+ }
210
256
  queue.push(text)
211
257
  // start the pump if idle
212
258
  if (queue.length === 1) pump()
@@ -221,6 +267,7 @@ export function startCodexSession({ cwd, model, resume, env, sandbox, mode = 'de
221
267
  ended = true
222
268
  queue.length = 0
223
269
  if (child) { try { child.kill() } catch { /* noop */ } }
270
+ void mcpHttpPromise?.then((h) => h.close()).catch(() => {})
224
271
  },
225
272
  setModel(nextModel) {
226
273
  if (!nextModel || turnActive) return false
@@ -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.196",
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
  },