thinkpool-pair 0.7.190 → 0.7.192

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/account.mjs CHANGED
@@ -102,6 +102,10 @@ export async function runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) {
102
102
  const poll = async () => {
103
103
  if (Date.now() - started > 5 * 60 * 1000) { process.stderr.write('\n ◇ link timed out — run `npx thinkpool-pair login` again.\n'); process.exit(1) }
104
104
  try {
105
+ // A discarded `error` costs one tick — the loop retries in a second, and a
106
+ // permanent failure still lands on the "link timed out" branch above rather than
107
+ // hanging. Nothing is persisted here, so nothing can be silently lost.
108
+ // fail-open-ok: read-only poll; a lost tick retries, and permanent failure times out loudly
105
109
  const { data: r } = await sb.rpc('claim_device_code', { p_device_code: device_code })
106
110
  if (r?.status === 'approved' && r.session?.refresh_token) {
107
111
  saveAuth(r.session)
@@ -1086,6 +1090,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1086
1090
  for (const { channel } of pairChannels.values()) { try { sb.removeChannel(channel) } catch { /* noop */ } } // tear down pair buses (Tier 2/3)
1087
1091
  // Flush the presence LEAVE before exiting so the dashboard flips to
1088
1092
  // "not connected" in realtime (don't fire-and-forget the untrack).
1093
+ //
1094
+ // Shutdown teardown; the process is already exiting. A lost `release_bridge` leaves
1095
+ // the claim to expire on its own TTL — degraded (the dashboard shows "connected"
1096
+ // until it lapses), never lost data. No caller remains to surface an error to, and a
1097
+ // throw here would skip process.exit.
1098
+ // fail-open-ok: exit-path teardown; a lost release_bridge expires on its own TTL
1089
1099
  ;(async () => { try { if (claimHeld) await sb.rpc('release_bridge', { p_bridge_id: BRIDGE_ID }) } catch { /* noop */ } try { await acct.untrack() } catch { /* noop */ } try { await sb.removeChannel(acct) } catch { /* noop */ } process.exit(code) })()
1090
1100
  setTimeout(() => process.exit(code), 1500) // hard backstop if the flush hangs
1091
1101
  }
package/bridge.mjs CHANGED
@@ -50,6 +50,7 @@ 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
54
  import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, buildConductorEnv, assembleCrossWaveContext, buildLanePrompt } from './flow-conductor.mjs'
54
55
  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)
55
56
  // S1 (context-offload) — durable digest store. mark_flow_done digests a closed slice in;
@@ -456,7 +457,11 @@ const STRUCTURED = process.env.TP_STRUCTURED === '1' || argv.includes('--structu
456
457
  // Claude Code runs as a STRUCTURED Agent-SDK session by default now (the picked
457
458
  // design — structured reader, risk-tiered permissions). Other CLIs keep the PTY
458
459
  // relay. TP_PTY=1 forces claude back to the raw PTY if ever needed.
459
- const wantStructured = (cmd) => /(^|[/\\])claude$/.test(cmd || '') && process.env.TP_PTY !== '1'
460
+ const structuredRuntime = (cmd) => {
461
+ const bin = path.basename(String(cmd || '')).toLowerCase()
462
+ return bin === 'codex' ? 'codex' : bin === 'claude' ? 'claude' : null
463
+ }
464
+ const wantStructured = (cmd) => !!structuredRuntime(cmd) && process.env.TP_PTY !== '1'
460
465
  const installedAgents = KNOWN_AGENTS.filter(a => onPath(a.cmd))
461
466
  const dashIdx = argv.indexOf('--')
462
467
  // Own flags are only read from BEFORE `--`; after it, every token belongs
@@ -1065,7 +1070,7 @@ const announce = () => {
1065
1070
  // turnActive). Authoritative — the bridge owns the turn — so the /code
1066
1071
  // dashboard reads "N agents working" off the roster instead of sniffing
1067
1072
  // the live code-event stream (which a passive dashboard channel misses).
1068
- ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', alive: true, busy: s.session?.turnActive ?? false, commands: s.commands, mode: s.mode || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
1073
+ ...[...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: 'never', 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' } : {}),
1069
1074
  // provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
1070
1075
  // (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
1071
1076
  // built-in/default Claude path (no badge). Makes the lane's provider badge +
@@ -1558,8 +1563,10 @@ function worktreeSnapshot(cwd) {
1558
1563
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
1559
1564
  // persist to the host file; tool calls round-trip through the perm card; the
1560
1565
  // rolling log replays to joiners and survives bridge restarts (session-store).
1561
- function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
1566
+ function openStructured({ id, runtime = 'claude', model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
1562
1567
  if (sessions.has(id)) return
1568
+ runtime = runtime === 'codex' ? 'codex' : 'claude'
1569
+ if (runtime === 'codex') mode = 'bypassPermissions'
1563
1570
  // The lane's EFFECTIVE model — computed ONCE and used for BOTH the truthful display
1564
1571
  // label (entry.model) and the SDK's `model` option below. These used to be computed
1565
1572
  // separately: the label resolved the provider's configured model while the SDK got the
@@ -1567,11 +1574,13 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1567
1574
  // lane opened while focused on Fable therefore showed `glm-4.6` and sent
1568
1575
  // `claude-fable-5` → 400 [1211][Unknown Model]. One value, one truth.
1569
1576
  // undefined → omit the SDK option entirely and let the provider env/endpoint default.
1570
- const laneModel = effectiveLaneModel({ provider, model, configuredModel: resolveProviderEnv(provider)?.ANTHROPIC_MODEL })
1577
+ const laneModel = runtime === 'codex'
1578
+ ? (model || readCodexDefaultModel() || undefined)
1579
+ : effectiveLaneModel({ provider, model, configuredModel: resolveProviderEnv(provider)?.ANTHROPIC_MODEL })
1571
1580
  // spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
1572
1581
  // session store so the Ensemble flag survives a bridge restart (else a respin
1573
1582
  // stripped it and the lane reverted to a plain tab — the t6 "no chip" bug).
1574
- const entry = { cmd: 'claude', 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,
1583
+ 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, models: runtime === 'codex' ? readCodexModels() : undefined,
1575
1584
  // model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
1576
1585
  // chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
1577
1586
  // registered provider the SDK id is impersonated (see the onEvent guard below), so
@@ -1579,7 +1588,9 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1579
1588
  // No configured model → the provider's NAME, never a false Claude id (an inherited
1580
1589
  // claudeish id was dropped upstream by effectiveLaneModel; it must not sneak back in as a
1581
1590
  // label fallback here). Built-in Anthropic keeps the passed model.
1582
- model: (!provider || provider === 'anthropic')
1591
+ model: runtime === 'codex'
1592
+ ? (laneModel || null)
1593
+ : (!provider || provider === 'anthropic')
1583
1594
  ? (laneModel || null)
1584
1595
  : (laneModel || providerNameMap()[provider] || provider),
1585
1596
  provider: provider || null, spawnedBy: spawnedBy || undefined, flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null,
@@ -1773,7 +1784,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1773
1784
  // restart. Without this, sessionData omitted it → on restart the resumed session
1774
1785
  // re-launched on the host default (Opus) regardless of the last switch, and the
1775
1786
  // switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
1776
- const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, 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 })
1787
+ 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 })
1777
1788
  const persist = () => saveSession(room, id, sessionData())
1778
1789
  // Synchronous flush of this session's record. Used on open (so a brand-new session
1779
1790
  // has a file under its id BEFORE its first event — surviving a restart inside the
@@ -1940,11 +1951,14 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1940
1951
  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"'),
1941
1952
  task: z.string().optional().describe('an initial task to hand the new lane immediately; omit to open it idle'),
1942
1953
  model: z.string().optional().describe('optional model for the lane, e.g. opus / sonnet / haiku'),
1954
+ runtime: z.enum(['claude', 'codex']).optional().describe('agent runtime for the new lane; defaults to inheriting this lane'),
1943
1955
  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'),
1944
1956
  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.'),
1945
1957
  },
1946
1958
  async (args) => {
1947
1959
  const okText = (t) => ({ content: [{ type: 'text', text: t }] })
1960
+ const childRuntime = args?.runtime || entry.runtime || 'claude'
1961
+ if (childRuntime === 'codex' && args?.provider) return okText('A Codex lane uses its Codex/OpenAI login in v1; custom bridge providers are not wired to Codex yet. Omit provider or spawn a Claude lane for that provider.')
1948
1962
  const now = Date.now()
1949
1963
  const gate = spawnDecision({
1950
1964
  hop: entry.hop || 0,
@@ -1979,7 +1993,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1979
1993
  // plan-mode lane would stall on ExitPlanMode cards. An explicit args.mode still wins;
1980
1994
  // otherwise inherit, but fall plan → default (an autonomous worker doesn't plan-gate).
1981
1995
  const childMode = args?.mode || (entry.mode === 'plan' ? 'default' : entry.mode)
1982
- openStructured({ id: newId, model: args?.model, provider: args?.provider, mode: childMode })
1996
+ openStructured({ id: newId, runtime: childRuntime, model: args?.model, provider: args?.provider, mode: childMode })
1983
1997
  const ne = sessions.get(newId)
1984
1998
  if (!ne) { if (args?.name) { delete termNames[newId]; saveNames(room, termNames) } return okText('Could not open a new lane — the terminal cap may have just been reached. Close one and retry.') }
1985
1999
  ne.spawnedBy = id // ownership: only the spawner may close_terminal it
@@ -2113,7 +2127,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
2113
2127
  // on the init `system` event (see onEvent) — a sendTurn before the input stream is consumed
2114
2128
  // is silently lost (the 2026-07-02 auto-resume bug). Empty string → nothing to carry.
2115
2129
  entry.pendingRecap = (typeof carryRecap === 'string' && carryRecap.trim()) ? carryRecap : null
2116
- entry.session = startClaudeSession({
2130
+ entry.session = (runtime === 'codex' ? startCodexSession : startClaudeSession)({
2117
2131
  // laneModel, NOT the raw `model` param: the SDK's `model` option OVERRIDES the
2118
2132
  // ANTHROPIC_MODEL supplied by resolveProviderEnv() in `env` below, so an inherited
2119
2133
  // Claude id here defeats the whole per-provider env. undefined → option omitted.
@@ -2195,7 +2209,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
2195
2209
  try { entry.session?.end() } catch { /* noop */ }
2196
2210
  try { entry.mockupWatcher?.close() } catch { /* noop */ }
2197
2211
  sessions.delete(id)
2198
- openStructured({ id, model, log: entry.log, commands: entry.commands, mode: entry.mode })
2212
+ openStructured({ id, runtime: entry.runtime, model, log: entry.log, commands: entry.commands, mode: entry.mode })
2199
2213
  return
2200
2214
  }
2201
2215
  // Stamp a wall-clock ts AND a stable cid on every transcript event before
@@ -2510,7 +2524,7 @@ function respawnStructured(id, provider) {
2510
2524
  // openStructured seed from the TARGET provider's configured model, which is the
2511
2525
  // only model this lane was ever asked for. A same-env model change never reaches
2512
2526
  // here — that path is an in-place setModel (see provider-switch).
2513
- const { log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt } = s
2527
+ const { runtime, log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt } = s
2514
2528
  // Context-carry (2026-07-08): a provider switch is not an SDK resume — the new backend
2515
2529
  // starts blank mid-conversation. Synthesize a plain-text recap from the VISIBLE log NOW
2516
2530
  // (before teardown) and hand it to the fresh session as its first turn so the agent
@@ -2528,7 +2542,7 @@ function respawnStructured(id, provider) {
2528
2542
  // sessionData() (provider included) synchronously on open, so a bridge restart
2529
2543
  // restores the lane on its CURRENT provider, not the original — and its next
2530
2544
  // announce carries the new provider badge (additive {id,name} projection).
2531
- openStructured({ id, provider, log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt, carryRecap })
2545
+ openStructured({ id, runtime, provider, log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt, carryRecap })
2532
2546
  return true
2533
2547
  }
2534
2548
 
@@ -2686,7 +2700,8 @@ channel
2686
2700
  // previous terminal's model (and the first uses the provider default). Falls
2687
2701
  // through to the SDK default when absent. Stamped on the entry so the announce
2688
2702
  // carries it at open (chip shows the model immediately, not the branch).
2689
- openStructured({ id: payload.id, mode: payload.mode, model: payload.model || undefined, provider: payload.provider || undefined })
2703
+ const runtime = structuredRuntime(payload.cmd)
2704
+ openStructured({ id: payload.id, runtime, mode: payload.mode, model: payload.model || undefined, provider: runtime === 'codex' ? undefined : payload.provider || undefined })
2690
2705
  process.stderr.write(`\n ◆ web opened a structured "${payload.cmd}" session (${payload.mode || 'default'}${payload.model ? `, model ${payload.model}` : ''}).\n`)
2691
2706
  return
2692
2707
  }
@@ -2822,7 +2837,7 @@ channel
2822
2837
  // ── structured-session control (Phase 2) ──
2823
2838
  .on('broadcast', { event: 'code-open' }, ({ payload }) => {
2824
2839
  if (payload?.host && payload.host !== name) return
2825
- openStructured({ id: payload?.id || randomUUID(), model: payload?.model, provider: payload?.provider, resume: payload?.resume, mode: payload?.mode })
2840
+ openStructured({ id: payload?.id || randomUUID(), runtime: payload?.runtime, model: payload?.model, provider: payload?.runtime === 'codex' ? undefined : payload?.provider, resume: payload?.resume, mode: payload?.mode })
2826
2841
  })
2827
2842
  .on('broadcast', { event: 'code-turn' }, async ({ payload }) => {
2828
2843
  const s = payload?.term && sessions.get(payload.term)
@@ -2864,7 +2879,10 @@ channel
2864
2879
  const mm = text.match(/^\/model\b\s*(\S+)?/)
2865
2880
  if (mm) {
2866
2881
  if (mm[1]) {
2867
- s.session.setModel(mm[1])
2882
+ if (s.session.setModel(mm[1]) === false) {
2883
+ ctlLine(s.runtime === 'codex' ? 'finish or stop the current Codex turn before switching models' : 'model switch unavailable right now')
2884
+ return
2885
+ }
2868
2886
  // Reflect the switch in the announce IMMEDIATELY (before any turn). The SDK
2869
2887
  // only echoes the resolved full id on the next turn, and meta/ctx model go
2870
2888
  // stale until then, so without this the chip snapped back to the old model
@@ -2885,7 +2903,8 @@ channel
2885
2903
  }
2886
2904
  if (/^\/clear\s*$/.test(text)) {
2887
2905
  s.pendingRecap = null // /clear means forget context — drop any un-fired carry recap
2888
- s.session.sendTurn(text)
2906
+ if (s.runtime === 'codex') s.session.clearContext?.()
2907
+ else s.session.sendTurn(text)
2889
2908
  // The seq reset is load-bearing on BOTH ends: the client's clear handler sets
2890
2909
  // seqHi = 0 (room.jsx), so the counter must restart with it. The durable archive
2891
2910
  // is append-only and keeps the pre-clear era under colliding seqs — session-store's
@@ -2903,6 +2922,10 @@ channel
2903
2922
  // track so onEvent clears it + attributes the recap card when the SDK turn finishes.
2904
2923
  // No persisted ctl line — the live indicator + the CompactionCard are the record.
2905
2924
  if (/^\/compact\b/.test(text)) {
2925
+ if (s.runtime === 'codex') {
2926
+ ctlLine('Codex manages context compaction automatically')
2927
+ return
2928
+ }
2906
2929
  s.compacting = true
2907
2930
  s.compactBy = payload.by
2908
2931
  bcast('code-event', { term: payload.term, evt: { kind: 'compact', status: 'start', ts: Date.now() } })
@@ -2961,6 +2984,11 @@ channel
2961
2984
  .on('broadcast', { event: 'code-mode' }, ({ payload }) => {
2962
2985
  const s = payload?.term && sessions.get(payload.term)
2963
2986
  if (s) {
2987
+ if (s.runtime === 'codex') {
2988
+ s.mode = 'bypassPermissions'
2989
+ announce()
2990
+ return
2991
+ }
2964
2992
  s.mode = payload.mode; s.session.setMode(payload.mode) // track for persist/restore
2965
2993
  // Flipping to bypass = "stop asking, including the cards this turn already raised."
2966
2994
  if (payload.mode === 'bypassPermissions') allowPending(s)
@@ -3127,7 +3155,7 @@ channel
3127
3155
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
3128
3156
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
3129
3157
  // lanes keep their worktree cwd + the ability to mark done.
3130
- openStructured({ id: rec.id, model: rec.model || undefined, provider: 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,
3158
+ 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,
3131
3159
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
3132
3160
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
3133
3161
  // (mid-turn needs auto-resume; flow needs its lane live).
@@ -3174,7 +3202,7 @@ channel
3174
3202
  // fresh random id, so openStructured's id-dedup couldn't catch it. The
3175
3203
  // saved-session restore above still runs unconditionally, so a bridge
3176
3204
  // restart resumes backgrounded sessions regardless of this guard.
3177
- else if (attachedCmd) openStructured({ id: randomUUID() })
3205
+ else if (attachedCmd) openStructured({ id: randomUUID(), runtime: structuredRuntime(attachedCmd) })
3178
3206
  // Background-warm the lazily-restored idle terminals shortly after restore, so
3179
3207
  // they're ready before the user clicks them (no first-turn penalty). Delayed so the
3180
3208
  // eagerly-booted (mid-turn / auto-resumed) sessions get the machine to themselves first.
@@ -0,0 +1,145 @@
1
+ // codex-event-mapper.mjs — maps a Codex `codex exec --json` event stream into
2
+ // the ThinkPool bridge's runtime-agnostic room-event contract (the SAME kinds
3
+ // claude-session.mjs emits). Pure logic over a sequential event stream: the
4
+ // driver (codex-session.mjs) feeds it parsed JSONL events, it calls onEvent
5
+ // with normalized events. Spec: docs/specs/2026-07-12-codex-driver.md.
6
+ //
7
+ // WHY A SEPARATE RUNTIME, NOT A TRANSLATOR (see spec §Why): Codex is itself an
8
+ // openai-native agent loop (tools + sandbox); we map its OUTPUT, we do not
9
+ // re-implement the loop or translate APIs. This keeps each runtime on its
10
+ // maker-tuned loop with no tool_use↔function-call loss.
11
+ //
12
+ // Codex item taxonomy (live capture: bridge/fixtures/codex-read-write.jsonl):
13
+ // agent_message → assistant { blocks:[{type:'text',text}] }
14
+ // reasoning → assistant { blocks:[{type:'thinking',text}] }
15
+ // command_execution start → assistant { blocks:[{type:'tool_use',name:'Bash',input:{command}}] }
16
+ // command_execution done → tool_result { toolUseId, content, isError, durationMs }
17
+ // file_change start → assistant { blocks:[{type:'tool_use',name:'Write'|'Edit',input:{path}}] }
18
+ // file_change done → tool_result
19
+ // turn.completed (usage) → result { subtype:'success', usage, model }
20
+ // turn.failed / error → error { recoverable:true } (+ result on turn.failed)
21
+ //
22
+ // G1 (spike): agent_message is burst-on-completion (no per-token deltas). Tool
23
+ // activity DOES stream (item.started→item.completed), so an active turn still
24
+ // shows live progress; only the final prose pops in. The driver adds a
25
+ // "composing" indicator to cover the gap (see codex-session.mjs).
26
+ //
27
+ // Risk: this module emits tool_use blocks with names ('Bash'/'Write'/'Edit') so
28
+ // the existing risk classifier (claude-session.classifyRisk) and room rendering
29
+ // apply unchanged. Codex v1 is observe-only (no per-tool card) — classification
30
+ // is for display accent + future gating, not blocking (spec §tradeoff).
31
+
32
+ export class CodexEventMapper {
33
+ /**
34
+ * @param {{onEvent:(evt)=>void, model?:string|null, sessionId?:string|null}} opts
35
+ */
36
+ constructor({ onEvent, model = null, sessionId = null }) {
37
+ this._onEvent = onEvent
38
+ this._model = model || null
39
+ this._sessionId = sessionId || null
40
+ this._starts = new Map() // item.id → startMs (for durationMs)
41
+ this._systemSent = false
42
+ }
43
+
44
+ /** Emit a room-event; never let a consumer throw into the stream. */
45
+ _emit(evt) { try { this._onEvent?.(evt) } catch { /* noop */ } }
46
+
47
+ setModel(model) { this._model = model || null }
48
+
49
+ /** Process one parsed Codex JSONL event. */
50
+ push(ev) {
51
+ if (!ev || typeof ev !== 'object') return
52
+ switch (ev.type) {
53
+ case 'thread.started': {
54
+ this._sessionId = this._sessionId || ev.thread_id || null
55
+ if (!this._systemSent) {
56
+ this._systemSent = true
57
+ this._emit({ kind: 'system', sessionId: this._sessionId, model: this._model, commands: [] })
58
+ }
59
+ return
60
+ }
61
+ case 'turn.started':
62
+ return
63
+
64
+ case 'item.started': {
65
+ const it = ev.item
66
+ if (!it) return
67
+ this._starts.set(it.id, Date.now())
68
+ if (it.type === 'command_execution') {
69
+ this._emit({ kind: 'assistant', blocks: [{ type: 'tool_use', id: it.id, name: 'Bash', input: { command: it.command || '' } }], parentToolUseId: null })
70
+ } else if (it.type === 'file_change') {
71
+ const c = (it.changes && it.changes[0]) || {}
72
+ this._emit({ kind: 'assistant', blocks: [{ type: 'tool_use', id: it.id, name: c.kind === 'add' ? 'Write' : 'Edit', input: { path: c.path || '' } }], parentToolUseId: null })
73
+ } else if (it.type === 'web_search') {
74
+ this._emit({ kind: 'assistant', blocks: [{ type: 'tool_use', id: it.id, name: 'WebSearch', input: { query: it.query || '' } }], parentToolUseId: null })
75
+ }
76
+ // agent_message / reasoning have no item.started (burst on completion).
77
+ return
78
+ }
79
+
80
+ case 'item.completed': {
81
+ const it = ev.item
82
+ if (!it) return
83
+ const start = this._starts.get(it.id)
84
+ const durationMs = start != null ? Date.now() - start : undefined
85
+ this._starts.delete(it.id)
86
+
87
+ if (it.type === 'agent_message') {
88
+ this._emit({ kind: 'assistant', blocks: [{ type: 'text', text: it.text || '' }], parentToolUseId: null })
89
+ } else if (it.type === 'reasoning') {
90
+ this._emit({ kind: 'assistant', blocks: [{ type: 'thinking', text: it.text || '' }], parentToolUseId: null })
91
+ } else if (it.type === 'command_execution') {
92
+ const bad = it.exit_code != null ? it.exit_code !== 0 : false
93
+ this._emit({ kind: 'tool_result', toolUseId: it.id, content: [{ type: 'text', text: it.aggregated_output || '' }], isError: bad, durationMs, parentToolUseId: null })
94
+ } else if (it.type === 'file_change') {
95
+ const c = (it.changes && it.changes[0]) || {}
96
+ const verb = c.kind === 'add' ? 'wrote' : 'edited'
97
+ this._emit({ kind: 'tool_result', toolUseId: it.id, content: [{ type: 'text', text: `${verb} ${c.path || ''}` }], isError: false, durationMs, parentToolUseId: null })
98
+ } else if (it.type === 'web_search') {
99
+ this._emit({ kind: 'tool_result', toolUseId: it.id, content: [{ type: 'text', text: it.query || 'search completed' }], isError: false, durationMs, parentToolUseId: null })
100
+ }
101
+ // mcp_tool_call / plan_update remain additive future event shapes. Unknown
102
+ // items are ignored rather than risking a malformed transcript row.
103
+ return
104
+ }
105
+
106
+ case 'turn.completed': {
107
+ const u = ev.usage || {}
108
+ // Map Codex usage → Claude-shape usage (what event-id.mjs folds into cost).
109
+ // reasoning_output_tokens are billed as output → fold in (conservative).
110
+ const out = (u.output_tokens || 0) + (u.reasoning_output_tokens || 0)
111
+ this._emit({
112
+ kind: 'result',
113
+ subtype: 'success',
114
+ sessionId: this._sessionId,
115
+ model: this._model,
116
+ costUsd: null,
117
+ usage: {
118
+ input_tokens: u.input_tokens || 0,
119
+ cache_read_input_tokens: u.cached_input_tokens || 0,
120
+ cache_creation_input_tokens: 0,
121
+ output_tokens: out,
122
+ },
123
+ numTurns: 1,
124
+ durationMs: undefined,
125
+ denials: 0,
126
+ resultText: null,
127
+ })
128
+ return
129
+ }
130
+
131
+ case 'turn.failed': {
132
+ this._emit({ kind: 'error', message: ev.message || ev.error || 'codex turn failed', recoverable: true })
133
+ this._emit({ kind: 'result', subtype: 'error_during_execution', sessionId: this._sessionId, model: this._model, costUsd: null, usage: null, numTurns: 1, durationMs: undefined, denials: 0, resultText: null })
134
+ return
135
+ }
136
+
137
+ case 'error':
138
+ this._emit({ kind: 'error', message: ev.message || ev.error || 'codex error', recoverable: true })
139
+ return
140
+
141
+ default:
142
+ return
143
+ }
144
+ }
145
+ }
@@ -0,0 +1,228 @@
1
+ // codex-session.mjs — a structured Codex CLI session for the ThinkPool bridge.
2
+ // Sibling to claude-session.mjs: SAME interface (startCodexSession →
3
+ // { sendTurn, abort, end, sessionId }) and SAME room-event contract, so the
4
+ // bridge can spawn either runtime per lane (spec §"the seam"). Wraps
5
+ // `codex exec --json` (OpenAI's openai-native agent loop) instead of the
6
+ // Anthropic Agent SDK. Spec: docs/specs/2026-07-12-codex-driver.md.
7
+ //
8
+ // WHY NOT a translator: Codex IS the agent loop (tools + sandbox); we spawn it
9
+ // and map its --json event stream (codex-event-mapper.mjs). No API translation,
10
+ // no tool_use↔function-call loss. Auth = the HOST's `codex login` (ChatGPT,
11
+ // flat sub) by default; BYOK via env CODEX_API_KEY works too (exec-only).
12
+ //
13
+ // v1 TRADEOFF (spec §tradeoff, G3): `codex exec` is non-interactive — fixed
14
+ // --sandbox, NO per-tool human gate. The room OBSERVES each command/file-change
15
+ // live (item.started→item.completed) but cannot approve/deny mid-run. Codex's
16
+ // own sandbox governs safety. Default sandbox: workspace-write.
17
+ //
18
+ // ONE-SHOT-PER-TURN model: unlike Claude's long-lived query(), `codex exec` is
19
+ // one prompt → run → exit. sendTurn spawns an exec; continuity across turns uses
20
+ // the exact thread id captured for this lane. Turns are serialized (one exec at
21
+ // a time per lane).
22
+ //
23
+ // GOTCHA (spike): `codex exec` blocks reading stdin when backgrounded
24
+ // ("Reading additional input from stdin…"). Spawn with stdin set to /dev/null.
25
+ import { spawn } from 'node:child_process'
26
+ import fs from 'node:fs'
27
+ import os from 'node:os'
28
+ import path from 'node:path'
29
+ import readline from 'node:readline'
30
+ import { CodexEventMapper } from './codex-event-mapper.mjs'
31
+
32
+ const DEFAULT_SANDBOX = 'workspace-write'
33
+ const SAFE_SANDBOXES = new Set(['read-only', 'workspace-write'])
34
+
35
+ export function readCodexDefaultModel({ home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), fsImpl = fs } = {}) {
36
+ try {
37
+ const text = fsImpl.readFileSync(path.join(home, 'config.toml'), 'utf8')
38
+ // Only the top-level model controls the default runtime. Stop at the first
39
+ // table so a profile/provider's nested `model` cannot impersonate it.
40
+ const top = text.split(/^\s*\[/m, 1)[0]
41
+ const m = top.match(/^\s*model\s*=\s*["']([^"']+)["']\s*(?:#.*)?$/m)
42
+ return m?.[1]?.trim() || null
43
+ } catch { return null }
44
+ }
45
+
46
+ export function readCodexModels({ home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), fsImpl = fs } = {}) {
47
+ try {
48
+ const data = JSON.parse(fsImpl.readFileSync(path.join(home, 'models_cache.json'), 'utf8'))
49
+ return (Array.isArray(data?.models) ? data.models : [])
50
+ .filter((m) => m?.slug && m.visibility !== 'hide')
51
+ .sort((a, b) => (Number(a.priority) || 999) - (Number(b.priority) || 999))
52
+ .map((m) => ({ value: m.slug, displayName: m.display_name || m.slug, description: m.description || '' }))
53
+ } catch { return [] }
54
+ }
55
+
56
+ export function normalizeCodexSandbox(value) {
57
+ return SAFE_SANDBOXES.has(value) ? value : DEFAULT_SANDBOX
58
+ }
59
+
60
+ export function buildCodexPrompt({ text, rolePrompt, roomContext }) {
61
+ const context = typeof roomContext === 'function' ? roomContext() : roomContext
62
+ const additions = [rolePrompt, context].map((v) => String(v || '').trim()).filter(Boolean)
63
+ if (!additions.length) return String(text ?? '')
64
+ return `<thinkpool-context>\n${additions.join('\n\n')}\n</thinkpool-context>\n\n${String(text ?? '')}`
65
+ }
66
+
67
+ export function buildCodexExecArgs({ sessionId, model, sandbox, providerConfig, prompt }) {
68
+ const safeSandbox = normalizeCodexSandbox(sandbox)
69
+ const args = sessionId
70
+ // `codex exec resume` deliberately has no --sandbox option. The resumed
71
+ // rollout retains the sandbox selected on its fresh first turn. Passing the
72
+ // fresh-only flag here makes current Codex exit 2 before doing any work.
73
+ ? ['exec', 'resume', '--json', '--skip-git-repo-check']
74
+ : ['exec', '--json', '--sandbox', safeSandbox, '--skip-git-repo-check']
75
+ if (model) args.push('-m', model)
76
+ if (providerConfig) args.push('-c', providerConfig)
77
+ if (sessionId) args.push(sessionId)
78
+ args.push(String(prompt ?? ''))
79
+ return args
80
+ }
81
+
82
+ /**
83
+ * Start a structured Codex session. Mirrors startClaudeSession's contract.
84
+ * @param {object} o
85
+ * @param {string} o.cwd work directory for the codex exec (a git repo)
86
+ * @param {string} [o.model] model id (e.g. 'gpt-5.6-sol'); passed via -m
87
+ * @param {string} [o.resume] captured Codex thread id to resume after bridge restart
88
+ * @param {object} [o.env] env for the child (e.g. CODEX_API_KEY for BYOK)
89
+ * @param {string} [o.sandbox] 'read-only' | 'workspace-write' | 'danger-full-access' (default workspace-write)
90
+ * @param {(evt)=>void} o.onEvent receives normalized room-events
91
+ * @param {string} [o.providerConfig] optional -c overrides / provider block (M2: from the provider registry)
92
+ * @returns {{ sendTurn(text), abort(), end(), readonly sessionId }}
93
+ */
94
+ export function startCodexSession({ cwd, model, resume, env, sandbox = DEFAULT_SANDBOX, onEvent, providerConfig, rolePrompt, roomContext }) {
95
+ sandbox = normalizeCodexSandbox(sandbox)
96
+ const childEnv = { ...process.env, ...(env || {}) }
97
+ let sessionId = resume || null
98
+ let turnNo = resume ? 1 : 0
99
+ let child = null
100
+ let aborted = false
101
+ let ended = false
102
+ let turnActive = false
103
+ let activeModel = model || null
104
+ // serialize turns: one exec at a time
105
+ let chain = Promise.resolve()
106
+ const queue = []
107
+
108
+ const mapper = new CodexEventMapper({ onEvent, model: model || null })
109
+
110
+ function runExec(prompt) {
111
+ // First turn: fresh exec. Later turns: resume THIS lane's captured thread.
112
+ // `--last` is process-global and can cross-wire two concurrently-active Codex
113
+ // lanes, so it is never safe in a multi-lane bridge.
114
+ const args = buildCodexExecArgs({
115
+ sessionId: turnNo === 0 ? null : sessionId,
116
+ model: activeModel,
117
+ sandbox,
118
+ providerConfig,
119
+ prompt,
120
+ })
121
+
122
+ return new Promise((resolve) => {
123
+ // stdin: /dev/null (spike gotcha — codex blocks on stdin otherwise)
124
+ let stdinFd
125
+ try { stdinFd = fs.openSync('/dev/null', 'r') } catch { /* will inherit; best effort */ }
126
+ const opts = { cwd, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] }
127
+ if (stdinFd != null) opts.stdio = [stdinFd, 'pipe', 'pipe']
128
+
129
+ aborted = false
130
+ turnActive = true
131
+ child = spawn('codex', args, opts)
132
+ if (stdinFd != null) { try { fs.closeSync(stdinFd) } catch { /* child owns its duplicated fd */ } }
133
+
134
+ const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity })
135
+ rl.on('line', (line) => {
136
+ if (!line.trim()) return
137
+ let ev
138
+ try { ev = JSON.parse(line) } catch { return /* non-JSON progress line */
139
+ }
140
+ if (ev.type === 'thread.started' && ev.thread_id) sessionId = sessionId || ev.thread_id
141
+ if (ev.type === 'turn.completed' || ev.type === 'turn.failed') sawTerminalResult = true
142
+ mapper.push(ev)
143
+ })
144
+ // stderr is codex's human progress channel; surface tail on failure only.
145
+ let stderrTail = ''
146
+ let sawTerminalResult = false
147
+ let settled = false
148
+ child.stderr.on('data', (d) => { stderrTail += d.toString(); if (stderrTail.length > 800) stderrTail = stderrTail.slice(-800) })
149
+
150
+ const finish = (code) => {
151
+ if (settled) return
152
+ settled = true
153
+ child = null
154
+ turnActive = false
155
+ if (aborted && !sawTerminalResult) {
156
+ try { onEvent?.({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: undefined, denials: 0, resultText: null }) } catch { /* noop */ }
157
+ } else if (!aborted && !sawTerminalResult && code !== 0 && code != null) {
158
+ try { onEvent?.({ kind: 'error', message: `codex exec exited ${code}${stderrTail ? ': ' + stderrTail.trim().slice(-300) : ''}`, recoverable: true }) } catch { /* noop */ }
159
+ }
160
+ resolve()
161
+ }
162
+ child.on('error', (e) => {
163
+ if (settled) return
164
+ settled = true
165
+ try { onEvent?.({ kind: 'error', message: `codex failed to start: ${e.message}`, recoverable: true }) } catch { /* noop */ }
166
+ child = null
167
+ turnActive = false
168
+ resolve()
169
+ })
170
+ child.on('close', finish)
171
+ })
172
+ }
173
+
174
+ function pump() {
175
+ if (ended) return
176
+ const next = queue.shift()
177
+ if (!next) return
178
+ chain = chain.then(async () => {
179
+ // runExec sees turnNo===0 on the FIRST turn (fresh exec); increment only
180
+ // after, so subsequent turns resume this lane's captured thread id.
181
+ // Claude receives these through the Agent SDK's system-reminder path.
182
+ // Codex is one-shot per turn, so give it the same lane identity and live
183
+ // room awareness in a compact envelope before the person's text.
184
+ await runExec(buildCodexPrompt({ text: next, rolePrompt, roomContext }))
185
+ turnNo++
186
+ pump()
187
+ })
188
+ }
189
+
190
+ return {
191
+ get sessionId() { return sessionId },
192
+ get turnActive() { return turnActive },
193
+ get started() { return turnNo > 0 || turnActive },
194
+ sendTurn(text) {
195
+ if (ended) return false
196
+ queue.push(text)
197
+ // start the pump if idle
198
+ if (queue.length === 1) pump()
199
+ return true
200
+ },
201
+ abort() {
202
+ aborted = true
203
+ queue.length = 0
204
+ if (child) { try { child.kill('SIGTERM') } catch { /* noop */ } ; setTimeout(() => { try { child?.kill('SIGKILL') } catch { /* noop */ } }, 1500) }
205
+ },
206
+ end() {
207
+ ended = true
208
+ queue.length = 0
209
+ if (child) { try { child.kill() } catch { /* noop */ } }
210
+ },
211
+ setModel(nextModel) {
212
+ if (!nextModel || turnActive) return false
213
+ activeModel = String(nextModel)
214
+ mapper.setModel(activeModel)
215
+ return true
216
+ },
217
+ // Codex v1 has a fixed sandbox for the lifetime of the lane. The bridge may
218
+ // still call setMode from shared chrome; report unsupported rather than
219
+ // pretending the Claude permission mode changed Codex's sandbox.
220
+ setMode() { return false },
221
+ clearContext() {
222
+ if (turnActive) return false
223
+ sessionId = null
224
+ turnNo = 0
225
+ return true
226
+ },
227
+ }
228
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.190",
3
+ "version": "0.7.192",
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,6 +13,8 @@
13
13
  "byok-detect.mjs",
14
14
  "context-windows.mjs",
15
15
  "claude-session.mjs",
16
+ "codex-session.mjs",
17
+ "codex-event-mapper.mjs",
16
18
  "turn-stall.mjs",
17
19
  "update-gate.mjs",
18
20
  "agent-notify.mjs",