thinkpool-pair 0.7.237 → 0.7.239

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
@@ -50,8 +50,9 @@ 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 { readCodexDefaultModel, readCodexModels, codexConfigForMode, codexThreadCanResume } from './codex-session.mjs'
53
+ import { withMcpSessionFactory } from './codex-mcp-http.mjs'
53
54
  import { startStructuredSession } from './runtime-session.mjs'
54
- import { defaultStructuredMode, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
55
+ import { defaultStructuredMode, shouldDeferStructuredRuntime, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
55
56
  import { probeHermesRuntime } from './hermes-probe.mjs'
56
57
  import { canonicalRoomFilePath, waitForNativeImages } from './codex-images.mjs'
57
58
  import { createManagedLaneWorktree, removeManagedLaneWorktree } from './lane-worktree.mjs'
@@ -1694,7 +1695,7 @@ function worktreeSnapshot(cwd) {
1694
1695
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
1695
1696
  // persist to the host file; tool calls round-trip through the perm card; the
1696
1697
  // rolling log replays to joiners and survives bridge restarts (session-store).
1697
- function openStructured({ id, runtime = 'claude', model, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, rolePrompt, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
1698
+ function openStructured({ id, runtime = 'claude', model, models, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, rolePrompt, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
1698
1699
  if (sessions.has(id)) return
1699
1700
  runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
1700
1701
  // No explicit mode → a sensible default per runtime (see defaultModeForRuntime):
@@ -1733,7 +1734,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
1733
1734
  ? spawnDepth
1734
1735
  : (sideParent || (spawnedBy && !String(spawnedBy).startsWith('flow:')) ? 1 : 0)
1735
1736
  const initialHop = Number.isInteger(hop) && hop >= 0 ? hop : structuralDepth
1736
- const entry = { cmd: runtime, runtime, kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode, effort, models: runtime === 'codex' ? codexModels : undefined,
1737
+ const entry = { cmd: runtime, runtime, kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes' && Array.isArray(models) ? models : undefined,
1737
1738
  // model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
1738
1739
  // chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
1739
1740
  // registered provider the SDK id is impersonated (see the onEvent guard below), so
@@ -1996,7 +1997,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
1996
1997
  // restart. Without this, sessionData omitted it → on restart the resumed session
1997
1998
  // re-launched on the host default (Opus) regardless of the last switch, and the
1998
1999
  // switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
1999
- 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, spawnDepth: entry.spawnDepth || 0, cascadeRole: entry.cascadeRole || null, hop: entry.hop || 0, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewRound: entry.flowReviewRound || 0, dispatchBaseSha: entry.dispatchBaseSha || null, 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 })
2000
+ 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, models: entry.runtime === 'hermes' ? (entry.models || []) : undefined, provider: entry.provider || null, spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth || 0, cascadeRole: entry.cascadeRole || null, hop: entry.hop || 0, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewRound: entry.flowReviewRound || 0, dispatchBaseSha: entry.dispatchBaseSha || null, 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 })
2000
2001
  const persist = () => saveSession(room, id, sessionData())
2001
2002
  // Synchronous flush of this session's record. Used on open (so a brand-new session
2002
2003
  // has a file under its id BEFORE its first event — surviving a restart inside the
@@ -2052,7 +2053,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2052
2053
  // own `id` (excluded) + `entry` (per-turn call counter, reset by the code-turn
2053
2054
  // handler). Auto-allowed by the PreToolUse gate (mcp__thinkpool__ prefix).
2054
2055
  // Spec: docs/specs/2026-06-28-cross-terminal-awareness.md
2055
- const peekServer = createSdkMcpServer({
2056
+ const createPeekServer = () => createSdkMcpServer({
2056
2057
  name: 'thinkpool',
2057
2058
  version: '1.0.0',
2058
2059
  tools: [
@@ -2420,6 +2421,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2420
2421
  )] : []),
2421
2422
  ],
2422
2423
  })
2424
+ const peekServer = withMcpSessionFactory(createPeekServer)
2423
2425
  // S5 (slice 1b) — the review-lane write-block, wired into the live PreToolUse path.
2424
2426
  // Null for builder lanes (no reviewSliceRoots) → their toolset is UNCHANGED. For a review
2425
2427
  // lane, deny a mutating tool call if it targets ANY reviewed slice's worktree, allowing the
@@ -2486,7 +2488,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2486
2488
  },
2487
2489
  // defer: a restored-idle terminal cold-boots its query only on first use — restore is
2488
2490
  // then instant for display, and only used terminals pay the ~50s MCP boot (Max, 2026-07-02).
2489
- lazy: !!defer || (!cwd && !flowSessionId && !flowTaskKey),
2491
+ lazy: shouldDeferStructuredRuntime({ runtime, defer, cwd, flowSessionId, flowTaskKey, models: entry.models }),
2490
2492
  reviewGate,
2491
2493
  // A Flow CONDUCTOR (flowSessionId set, no flowTaskKey) must DECOMPOSE, not explore via
2492
2494
  // a fan-out of Task/Explore subagents, and must not build. Hard-block Task/Bash/Write for
@@ -3808,7 +3810,7 @@ channel
3808
3810
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
3809
3811
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
3810
3812
  // lanes keep their worktree cwd + the ability to mark done.
3811
- openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, effort: rec.effort, provider: rec.runtime === 'claude' ? rec.provider || undefined : undefined, resume: resumable ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, spawnDepth: rec.spawnDepth, cascadeRole: rec.cascadeRole, hop: rec.hop, sideParent: rec.sideParent, sideTask: rec.sideTask, pendingSideContexts: rec.pendingSideContexts, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, flowReviewRound: rec.flowReviewRound, dispatchBaseSha: rec.dispatchBaseSha, revertTarget: rec.revertTarget, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap,
3813
+ openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, models: rec.models, effort: rec.effort, provider: rec.runtime === 'claude' ? rec.provider || undefined : undefined, resume: resumable ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, spawnDepth: rec.spawnDepth, cascadeRole: rec.cascadeRole, hop: rec.hop, sideParent: rec.sideParent, sideTask: rec.sideTask, pendingSideContexts: rec.pendingSideContexts, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, flowReviewRound: rec.flowReviewRound, dispatchBaseSha: rec.dispatchBaseSha, revertTarget: rec.revertTarget, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap,
3812
3814
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
3813
3815
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
3814
3816
  // (mid-turn needs auto-resume; flow needs its lane live).
@@ -6,18 +6,47 @@ import http from 'node:http'
6
6
  import { randomUUID } from 'node:crypto'
7
7
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
8
8
 
9
+ export function withMcpSessionFactory(createSessionServer) {
10
+ if (typeof createSessionServer !== 'function') throw new TypeError('MCP session factory must be a function')
11
+ return Object.assign(createSessionServer(), { createSessionServer })
12
+ }
13
+
9
14
  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')
15
+ const createSessionServer = sdkServer?.createSessionServer
16
+ const initialInstance = sdkServer?.instance
17
+ if (!initialInstance?.connect || typeof createSessionServer !== 'function') {
18
+ throw new TypeError('Codex MCP requires an SDK MCP server and createSessionServer factory')
19
+ }
12
20
 
13
21
  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)
22
+ // Stateful sessions are required: this SDK's stateless transport rejects the
23
+ // `notifications/initialized` POST sent by every MCP client. A transport is
24
+ // single-initialize, though, while Codex App Server -> exec fallback and Hermes
25
+ // crash recovery both start a new client against the same lane endpoint.
26
+ // Keep exactly one active child session per lane. A new initialize atomically
27
+ // retires the old server+transport before receiving its own isolated instance.
28
+ let active = null
29
+
30
+ const retireActive = async () => {
31
+ const current = active
32
+ active = null
33
+ if (!current) return
34
+ await Promise.allSettled([
35
+ current.instance.close(),
36
+ current.transport.close(),
37
+ ])
38
+ }
39
+
40
+ const beginSession = async () => {
41
+ await retireActive()
42
+ const next = createSessionServer()
43
+ const instance = next?.instance
44
+ if (!instance?.connect) throw new TypeError('Codex MCP session factory did not return an SDK MCP server instance')
45
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() })
46
+ await instance.connect(transport)
47
+ active = { instance, transport }
48
+ return active
49
+ }
21
50
 
22
51
  const server = http.createServer(async (req, res) => {
23
52
  if (req.url !== secretPath) {
@@ -25,7 +54,19 @@ export async function startCodexMcpHttp({ sdkServer, host = '127.0.0.1' } = {})
25
54
  return
26
55
  }
27
56
  try {
28
- await transport.handleRequest(req, res)
57
+ const sessionId = req.headers['mcp-session-id']
58
+ let target = active
59
+ if (!sessionId) {
60
+ if (req.method !== 'POST') {
61
+ res.writeHead(400).end('missing session')
62
+ return
63
+ }
64
+ target = await beginSession()
65
+ } else if (!target?.transport?.sessionId || sessionId !== target.transport.sessionId) {
66
+ res.writeHead(404).end('session not found')
67
+ return
68
+ }
69
+ await target.transport.handleRequest(req, res)
29
70
  } catch {
30
71
  if (!res.headersSent) res.writeHead(500)
31
72
  res.end()
@@ -38,7 +79,7 @@ export async function startCodexMcpHttp({ sdkServer, host = '127.0.0.1' } = {})
38
79
  server.listen(0, host, resolve)
39
80
  })
40
81
  } catch (error) {
41
- await transport.close().catch(() => {})
82
+ await retireActive()
42
83
  throw error
43
84
  }
44
85
  const address = server.address()
@@ -51,7 +92,7 @@ export async function startCodexMcpHttp({ sdkServer, host = '127.0.0.1' } = {})
51
92
  if (closed) return
52
93
  closed = true
53
94
  await Promise.allSettled([
54
- transport.close(),
95
+ retireActive(),
55
96
  new Promise((resolve) => server.close(resolve)),
56
97
  ])
57
98
  },
@@ -4,6 +4,7 @@ import path from 'node:path'
4
4
  import { randomUUID } from 'node:crypto'
5
5
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
6
6
  import { startHermesSession } from './hermes-session.mjs'
7
+ import { withMcpSessionFactory } from './codex-mcp-http.mjs'
7
8
 
8
9
  const waitFor = (predicate, timeoutMs = 25_000) => new Promise((resolve, reject) => {
9
10
  const started = Date.now()
@@ -36,10 +37,11 @@ export async function verifyHermesMcpIsolation({ rounds = 2, sessionFactory = st
36
37
  for (let i = 0; i < 2; i++) {
37
38
  const cwd = path.join(root, i === 0 ? 'alpha' : 'beta')
38
39
  fs.mkdirSync(cwd)
39
- const sdkServer = createSdkMcpServer({
40
+ const createMarkerServer = () => createSdkMcpServer({
40
41
  name: 'thinkpool', version: '1.0.0',
41
42
  tools: [tool(markers[i], 'Synthetic ThinkPool lane-isolation marker.', {}, async () => ({ content: [{ type: 'text', text: markers[i] }] }))],
42
43
  })
44
+ const sdkServer = withMcpSessionFactory(createMarkerServer)
43
45
  sessions.push(sessionFactory({ cwd, lazy: true, mcpServers: { thinkpool: sdkServer }, onEvent: (event) => events[i].push(event) }))
44
46
  }
45
47
  sessions.forEach((session) => session.sendTurn('/tools'))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.237",
3
+ "version": "0.7.239",
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": {
@@ -13,7 +13,7 @@ const RUNTIMES = Object.freeze({
13
13
  }),
14
14
  hermes: Object.freeze({
15
15
  id: 'hermes', command: 'thinkpool', label: 'Hermes Agent', protocol: 'acp',
16
- structured: true, flow: false, canSteer: true, images: true, nativeModelCatalog: true, effortControl: false, defaultMode: 'default',
16
+ structured: true, flow: false, canSteer: true, images: true, nativeModelCatalog: true, catalogRequiresSession: true, effortControl: false, defaultMode: 'default',
17
17
  modes: Object.freeze(['default', 'acceptEdits']),
18
18
  beta: true,
19
19
  }),
@@ -28,3 +28,12 @@ export const structuredRuntimeForCommand = (command) => {
28
28
  export const defaultStructuredMode = (runtime) => structuredRuntimeMetadata(runtime)?.defaultMode || 'default'
29
29
  export const structuredRuntimeSupportsMode = (runtime, mode) => structuredRuntimeMetadata(runtime)?.modes?.includes(mode) === true
30
30
  export const structuredRuntimeSupportsFlow = (runtime) => structuredRuntimeMetadata(runtime)?.flow === true
31
+ export const shouldDeferStructuredRuntime = ({ runtime, defer, cwd, flowSessionId, flowTaskKey, models } = {}) => {
32
+ const metadata = structuredRuntimeMetadata(runtime)
33
+ // Some ACP agents publish their model catalog only from session/new or
34
+ // session/resume. A brand-new lane must perform that zero-inference handshake
35
+ // before its first prompt or the model picker has no truthful choices to show.
36
+ // Once the exact catalog is persisted, the normal lazy-start policy resumes.
37
+ if (metadata?.catalogRequiresSession && !(Array.isArray(models) && models.length)) return false
38
+ return !!defer || (!cwd && !flowSessionId && !flowTaskKey)
39
+ }