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 +10 -0
- package/bridge.mjs +46 -18
- package/codex-event-mapper.mjs +145 -0
- package/codex-session.mjs +228 -0
- package/package.json +3 -1
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
|
|
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 =
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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",
|