thinkpool-pair 0.7.212 → 0.7.214

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/README.md CHANGED
@@ -51,6 +51,23 @@ Structured Claude sessions persist their scrollback + session id to
51
51
  `~/.thinkpool-pair/<ROOM>/` and **resume on restart** (live context if recent),
52
52
  so none of the above loses your place.
53
53
 
54
+ ### Codex App Server driver
55
+
56
+ Structured Codex lanes use the long-lived App Server transport (streaming prose,
57
+ mid-turn steering, shared approval and question cards, and native image inputs)
58
+ when the installed Codex CLI is in the bridge's tested protocol range. To force
59
+ the stable `codex exec --json` fallback, set the kill switch before starting the
60
+ bridge:
61
+
62
+ ```bash
63
+ export TP_CODEX_APP_SERVER=0
64
+ ```
65
+
66
+ The bridge performs the initialize handshake before creating a thread and falls
67
+ back to `codex exec` when App Server is disabled, outside the tested version
68
+ range, or cannot initialize before a turn. It never retries a prompt via the
69
+ fallback after `turn/start` has been accepted, preventing duplicate work.
70
+
54
71
  Your first agent runs **attached** — use it exactly like your normal terminal,
55
72
  every byte mirrors to the web. The web's **"+ New terminal"** spawns additional
56
73
  **headless** terminals here (same directory, same env), driven entirely from
package/bridge.mjs CHANGED
@@ -51,6 +51,7 @@ import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
51
51
  import { z } from 'zod'
52
52
  import { startClaudeSession } from './claude-session.mjs'
53
53
  import { startCodexSession, readCodexDefaultModel, readCodexModels, codexConfigForMode, defaultModeForRuntime } from './codex-session.mjs'
54
+ import { canonicalRoomFilePath, waitForNativeImages } from './codex-images.mjs'
54
55
  import { createManagedLaneWorktree, removeManagedLaneWorktree } from './lane-worktree.mjs'
55
56
  import { commandOnPath } from './agent-detect.mjs'
56
57
 
@@ -1044,7 +1045,7 @@ const announce = () => {
1044
1045
  // laneStatusOf: authoritative busy/idle + last-action timestamp/age +
1045
1046
  // STUCK/BLOCKED alert. The bridge owns the turn and permission state, so
1046
1047
  // every roster consumer reads one status instead of reconstructing it.
1047
- ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [] } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
1048
+ ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
1048
1049
  // provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
1049
1050
  // (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
1050
1051
  // built-in/default Claude path (no badge). Makes the lane's provider badge +
@@ -2243,6 +2244,11 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2243
2244
  const busyChanged = busyNow !== entry._busyAnn
2244
2245
  if (busyChanged) { if (busyNow) entry._turnStart = Date.now(); entry._busyAnn = busyNow }
2245
2246
  if (busyChanged || stalledChanged) announce() }
2247
+ // App Server steering is opt-in and can fall back to exec at runtime. Keep
2248
+ // the roster capability authoritative so the browser never immediate-sends
2249
+ // into an exec-only turn and loses its shared queue.
2250
+ { const canSteerNow = entry.runtime === 'codex' && (entry.session?.canSteer ?? false)
2251
+ if (canSteerNow !== entry._canSteerAnn) { entry._canSteerAnn = canSteerNow; announce() } }
2246
2252
  // Capture this session's current MODEL (resolved full id, with version — from
2247
2253
  // system/result `model` or usage `ctx.model`) and the SDK's supported-model
2248
2254
  // LIST (from the `models` event) so the announce carries them to EVERY device.
@@ -2346,7 +2352,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2346
2352
  // card) and is intentionally NOT chrome, so it persists + replays.
2347
2353
  // 'suggestion' is Claude Code's predicted-next-prompt ghost text — ephemeral
2348
2354
  // composer UI, broadcast to both clients but never logged/persisted/replayed.
2349
- const chrome = evt.kind === 'mode' || evt.kind === 'usage' || evt.kind === 'clear' || evt.kind === 'compact' || evt.kind === 'stalled' || evt.kind === 'suggestion'
2355
+ const chrome = evt.kind === 'mode' || evt.kind === 'usage' || evt.kind === 'clear' || evt.kind === 'compact' || evt.kind === 'stalled' || evt.kind === 'suggestion' || evt.kind === 'assistant_stream'
2350
2356
  // The compact turn settled with `entry.compacting` STILL TRUE — which, because the
2351
2357
  // `compaction` branch above clears the flag the moment the milestone lands, means
2352
2358
  // exactly one thing: NO compaction happened. Three ways to get here, all of which
@@ -2387,7 +2393,12 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2387
2393
  if (evt.kind === 'result' || evt.kind === 'error') {
2388
2394
  const startedAt = entry._turnStart
2389
2395
  entry._turnStart = null
2390
- entry.permNotifier?.clearAll()
2396
+ // A canceled/failed App Server request can settle the turn without the
2397
+ // browser ever answering its permission card. Resolve those promises
2398
+ // fail-closed and explicitly retract the stale cards on both clients.
2399
+ const stalePermissionIds = [...entry.pending.keys()]
2400
+ drainPending(entry)
2401
+ for (const pendingId of stalePermissionIds) bcast('code-perm', { term: id, id: pendingId, decision: 'deny', name: 'agent' })
2391
2402
  if (shouldNotifyTurnDone({ entry, subtype: evt.subtype, startedAt, now: Date.now(), minTurnMs: MIN_TURN_MS })) {
2392
2403
  persistAgentEvent({
2393
2404
  kind: 'turn-done',
@@ -2429,8 +2440,9 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2429
2440
  }
2430
2441
  // Keep the broadcast payload with the resolver so a reconnect can re-send it
2431
2442
  // (replay-request handler) — pending cards never enter the replayed event log.
2432
- const payload = { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan, questions: req.questions }
2433
- entry.pending.set(req.id, { resolve, payload })
2443
+ const payload = { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan, questions: req.questions, asker: req.asker, answerFormat: req.answerFormat, autoResolutionMs: req.autoResolutionMs }
2444
+ const pending = { resolve, payload, timer: null }
2445
+ entry.pending.set(req.id, pending)
2434
2446
  announce()
2435
2447
  bcast('code-perm-req', payload)
2436
2448
  // Slice 3 — arm the grace timer. Fires a "needs you" push only if the card is
@@ -2443,8 +2455,20 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2443
2455
  process.stderr.write(req.risk === 'plan'
2444
2456
  ? `\n ${A.mag}◆ plan ready — approve in the room.${A.rst}\n`
2445
2457
  : req.risk === 'ask'
2446
- ? `\n ${A.cyan}● Claude is asking: ${(req.questions || []).map((q) => q.question).join(' / ').slice(0, 100)} — answer in the room.${A.rst}\n`
2458
+ ? `\n ${A.cyan}● ${req.asker || (runtime === 'codex' ? 'Codex' : 'Claude')} is asking: ${(req.questions || []).map((q) => q.question).join(' / ').slice(0, 100)} — answer in the room.${A.rst}\n`
2447
2459
  : `\n ${A.yel}● permission: ${req.toolName}${argStr(req.input)} — approve in the room.${A.rst}\n`)
2460
+ const autoResolutionMs = Number(req.autoResolutionMs)
2461
+ if (req.risk === 'ask' && Number.isFinite(autoResolutionMs) && autoResolutionMs > 0) {
2462
+ pending.timer = setTimeout(() => {
2463
+ if (entry.pending.get(req.id) !== pending) return
2464
+ entry.pending.delete(req.id)
2465
+ entry.permNotifier?.resolve(req.id)
2466
+ bcast('code-perm', { term: id, id: req.id, decision: 'timeout', name: req.asker || 'agent' })
2467
+ try { resolve(pendingResolution(pending, { decision: 'timeout', answers: {} })) } catch { /* noop */ }
2468
+ announce()
2469
+ }, autoResolutionMs)
2470
+ pending.timer.unref?.()
2471
+ }
2448
2472
  }),
2449
2473
  })
2450
2474
  // A restored Codex lane may have a stale pre-0.7.206 meter persisted from the
@@ -2479,9 +2503,19 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2479
2503
  // leaves the SDK's PreToolUse hook (claude-session.mjs) awaiting our promise
2480
2504
  // forever — the structured turn freezes mid-flight. 'deny' is the fail-safe the
2481
2505
  // hook already degrades to, so draining to deny is always safe.
2506
+ function pendingResolution(pending, payload = {}) {
2507
+ const decision = payload.decision || 'deny'
2508
+ return pending?.payload?.answerFormat === 'codex'
2509
+ ? { decision, answers: payload.answers || {} }
2510
+ : decision
2511
+ }
2512
+
2482
2513
  function drainPending(s) {
2483
2514
  if (!s?.pending) return
2484
- for (const [, p] of s.pending) { try { p.resolve('deny') } catch { /* noop */ } }
2515
+ for (const [, p] of s.pending) {
2516
+ if (p?.timer) clearTimeout(p.timer)
2517
+ try { p.resolve(pendingResolution(p, { decision: 'deny' })) } catch { /* noop */ }
2518
+ }
2485
2519
  s.pending.clear()
2486
2520
  // Every card just died → retract any "needs you" banner they raised (Slice 3).
2487
2521
  s.permNotifier?.clearAll()
@@ -2502,6 +2536,7 @@ function allowPending(s) {
2502
2536
  // "run tools without asking", not "auto-approve my plan / answer my question" — leave
2503
2537
  // those for the human.
2504
2538
  if (p?.payload?.risk === 'plan' || p?.payload?.risk === 'ask') continue
2539
+ if (p?.timer) clearTimeout(p.timer)
2505
2540
  try { p.resolve('allow') } catch { /* noop */ }
2506
2541
  s.pending.delete(key)
2507
2542
  s.permNotifier?.resolve(key) // auto-approved → retract the "needs you" banner
@@ -2521,6 +2556,7 @@ function acceptEditsPending(s) {
2521
2556
  const risk = p?.payload?.risk
2522
2557
  if (risk === 'plan' || risk === 'ask' || risk === 'high') continue
2523
2558
  if (!ACCEPT_EDIT_TOOLS.has(tn)) continue
2559
+ if (p?.timer) clearTimeout(p.timer)
2524
2560
  try { p.resolve('allow') } catch { /* noop */ }
2525
2561
  s.pending.delete(key)
2526
2562
  s.permNotifier?.resolve(key) // auto-approved → retract the "needs you" banner
@@ -2868,12 +2904,13 @@ channel
2868
2904
  if (payload.host && payload.host !== name) return
2869
2905
  ;(async () => {
2870
2906
  try {
2907
+ const fp = canonicalRoomFilePath(payload, { updir: UPDIR })
2908
+ if (!fp) throw new Error('invalid attachment metadata')
2871
2909
  const r = await fetch(payload.url)
2872
2910
  if (!r.ok) throw new Error(`HTTP ${r.status}`)
2873
2911
  const buf = Buffer.from(await r.arrayBuffer())
2874
2912
  if (buf.length > FILE_MAX_BYTES) throw new Error('file too large')
2875
2913
  fs.mkdirSync(UPDIR, { recursive: true })
2876
- const fp = path.join(UPDIR, `${String(payload.id).slice(0, 8)}-${safeName(payload.name)}`)
2877
2914
  fs.writeFileSync(fp, buf)
2878
2915
  process.stderr.write(`\n ◆ file from the room: ${fp}\n`)
2879
2916
  bcast('file-done', { id: payload.id, path: fp.split(path.sep).join('/'), host: name })
@@ -3033,7 +3070,14 @@ channel
3033
3070
  // no-ops. The visible `you` echo is unchanged; the recap is context for the agent only.
3034
3071
  let sendText = text
3035
3072
  if (s.pendingRecap) { sendText = `${s.pendingRecap}\n\n--- the person's next message ---\n${text}`; s.pendingRecap = null; s.flush?.() }
3036
- s.session.sendTurn(sendText)
3073
+ // The browser's `hostPath` is display metadata, not host authority. Rebuild
3074
+ // image paths from the bridge-owned UPDIR + deterministic attachment fields,
3075
+ // then cover the separate file-put/code-turn download race with a bounded
3076
+ // readiness wait. A late image remains available through its quoted text path.
3077
+ const nativeImages = s.runtime === 'codex'
3078
+ ? await waitForNativeImages(payload.files, { updir: UPDIR })
3079
+ : []
3080
+ s.session.sendTurn(sendText, nativeImages.length ? { images: nativeImages } : undefined)
3037
3081
  echoYou()
3038
3082
  // Pool-order fix (2026-07-02): emit the "dispatched to agent" marker HERE, right after
3039
3083
  // the @pool you-line, so it inherits the NEXT seq (seqable('pool') is true) and orders
@@ -3056,7 +3100,13 @@ channel
3056
3100
  // use run/accept/keep (claude-session interprets). Default deny on missing.
3057
3101
  // Answered from a device → retract the "needs you" banner on every OTHER device
3058
3102
  // (permNotifier.resolve is a no-op unless its grace timer already fired).
3059
- if (p) { s.pending.delete(payload.id); s.permNotifier?.resolve(payload.id); p.resolve(payload.decision || 'deny'); announce() }
3103
+ if (p) {
3104
+ if (p.timer) clearTimeout(p.timer)
3105
+ s.pending.delete(payload.id)
3106
+ s.permNotifier?.resolve(payload.id)
3107
+ p.resolve(pendingResolution(p, payload))
3108
+ announce()
3109
+ }
3060
3110
  })
3061
3111
  .on('broadcast', { event: 'code-abort' }, ({ payload }) => {
3062
3112
  const s = payload?.term && sessions.get(payload.term)
@@ -0,0 +1,259 @@
1
+ // codex-app-server.mjs — defensive JSONL/JSON-RPC transport for Codex App Server.
2
+ //
3
+ // App Server is still experimental, so the bridge enables it only for the exact
4
+ // Codex version range whose protocol we have exercised. TP_CODEX_APP_SERVER=0 is
5
+ // the kill switch; `codex exec --json` remains the safe fallback.
6
+ import { execFileSync, spawn } from 'node:child_process'
7
+ import readline from 'node:readline'
8
+
9
+ export const CODEX_APP_SERVER_ENV = 'TP_CODEX_APP_SERVER'
10
+ export const MIN_CODEX_APP_SERVER_VERSION = Object.freeze([0, 144, 1])
11
+ export const MAX_CODEX_APP_SERVER_VERSION = Object.freeze([0, 145, 0])
12
+
13
+ const parseVersion = (raw) => {
14
+ const m = String(raw || '').match(/(?:codex-cli\s+)?(\d+)\.(\d+)\.(\d+)/i)
15
+ return m ? m.slice(1, 4).map(Number) : null
16
+ }
17
+
18
+ const atLeast = (actual, minimum) => {
19
+ for (let i = 0; i < 3; i++) {
20
+ if (actual[i] > minimum[i]) return true
21
+ if (actual[i] < minimum[i]) return false
22
+ }
23
+ return true
24
+ }
25
+
26
+ const before = (actual, maximum) => !atLeast(actual, maximum)
27
+
28
+ const transportError = (message, delivery = 'unknown') => {
29
+ const error = message instanceof Error ? message : new Error(String(message || 'Codex App Server transport failed'))
30
+ error.delivery = delivery
31
+ return error
32
+ }
33
+
34
+ export function codexAppServerGate({ env = process.env, execFile = execFileSync } = {}) {
35
+ const setting = String(env?.[CODEX_APP_SERVER_ENV] || '').trim().toLowerCase()
36
+ if (['0', 'false', 'off'].includes(setting)) {
37
+ return { enabled: false, reason: `${CODEX_APP_SERVER_ENV} disables App Server`, version: null }
38
+ }
39
+ if (setting && !['1', 'true', 'on'].includes(setting)) {
40
+ return { enabled: false, reason: `${CODEX_APP_SERVER_ENV} has an invalid value`, version: null }
41
+ }
42
+ try {
43
+ const raw = execFile('codex', ['--version'], { encoding: 'utf8', env })
44
+ const parsed = parseVersion(raw)
45
+ if (!parsed) return { enabled: false, reason: `unrecognized Codex version: ${String(raw).trim()}`, version: null }
46
+ const version = parsed.join('.')
47
+ if (!atLeast(parsed, MIN_CODEX_APP_SERVER_VERSION)) {
48
+ return { enabled: false, reason: `Codex ${version} is older than ${MIN_CODEX_APP_SERVER_VERSION.join('.')}`, version }
49
+ }
50
+ if (!before(parsed, MAX_CODEX_APP_SERVER_VERSION)) {
51
+ return { enabled: false, reason: `Codex ${version} is newer than the tested App Server protocol (<${MAX_CODEX_APP_SERVER_VERSION.join('.')})`, version }
52
+ }
53
+ return { enabled: true, reason: null, version }
54
+ } catch (error) {
55
+ return { enabled: false, reason: `could not read Codex version: ${error?.message || error}`, version: null }
56
+ }
57
+ }
58
+
59
+ export function buildCodexAppServerArgs({ providerConfig, mcpUrl, writableDirs = [] } = {}) {
60
+ const args = ['app-server', '--stdio']
61
+ if (providerConfig) args.push('-c', providerConfig)
62
+ if (mcpUrl) args.push('-c', `mcp_servers.thinkpool.url=${JSON.stringify(mcpUrl)}`)
63
+ if (writableDirs.length) args.push('-c', `sandbox_workspace_write.writable_roots=${JSON.stringify(writableDirs)}`)
64
+ return args
65
+ }
66
+
67
+ const rpcError = (message, code = -32000) => ({ code, message: String(message || 'App Server request failed') })
68
+ const turnInput = (text, images = []) => [
69
+ { type: 'text', text: String(text ?? '') },
70
+ ...images.filter(Boolean).map((imagePath) => ({ type: 'localImage', path: String(imagePath) })),
71
+ ]
72
+
73
+ /**
74
+ * Minimal App Server client. It deliberately accepts unknown notifications and
75
+ * fields: protocol additions must not take down an otherwise usable lane.
76
+ */
77
+ export class CodexAppServerClient {
78
+ constructor({ cwd, env, args = ['app-server', '--stdio'], spawnImpl = spawn, onNotification, onServerRequest, onClose, initializeTimeoutMs = 3000, threadTimeoutMs = 5000, turnStartTimeoutMs = 5000, controlTimeoutMs = 5000 } = {}) {
79
+ this.cwd = cwd
80
+ this.env = env
81
+ this.args = args
82
+ this.spawnImpl = spawnImpl
83
+ this.onNotification = onNotification
84
+ this.onServerRequest = onServerRequest
85
+ this.onClose = onClose
86
+ this.initializeTimeoutMs = initializeTimeoutMs
87
+ this.threadTimeoutMs = threadTimeoutMs
88
+ this.turnStartTimeoutMs = turnStartTimeoutMs
89
+ this.controlTimeoutMs = controlTimeoutMs
90
+ this.child = null
91
+ this.nextId = 1
92
+ this.pending = new Map()
93
+ this.turnWaiters = new Map()
94
+ this.completedTurns = new Map()
95
+ this.closedError = null
96
+ this.stderrTail = ''
97
+ }
98
+
99
+ get alive() { return !!this.child && !this.closedError }
100
+
101
+ async start() {
102
+ if (this.child) return
103
+ const child = this.spawnImpl('codex', this.args, { cwd: this.cwd, env: this.env, stdio: ['pipe', 'pipe', 'pipe'] })
104
+ this.child = child
105
+ const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity })
106
+ rl.on('line', (line) => this._onLine(line))
107
+ child.stderr?.on?.('data', (chunk) => {
108
+ this.stderrTail += String(chunk)
109
+ if (this.stderrTail.length > 1200) this.stderrTail = this.stderrTail.slice(-1200)
110
+ })
111
+ child.stdin?.on?.('error', (error) => this._close(error))
112
+ child.on('error', (error) => this._close(error))
113
+ child.on('close', (code) => this._close(new Error(`codex app-server exited ${code ?? 'unknown'}${this.stderrTail ? `: ${this.stderrTail.trim().slice(-300)}` : ''}`)))
114
+
115
+ await this.request('initialize', {
116
+ clientInfo: { name: 'thinkpool-pair', title: 'ThinkPool Code', version: '1' },
117
+ capabilities: { experimentalApi: true },
118
+ }, this.initializeTimeoutMs)
119
+ this.notify('initialized')
120
+ }
121
+
122
+ _write(message) {
123
+ if (!this.child?.stdin || this.closedError) throw this.closedError || new Error('codex app-server is not running')
124
+ this.child.stdin.write(`${JSON.stringify(message)}\n`)
125
+ }
126
+
127
+ request(method, params = {}, timeoutMs = 0) {
128
+ const id = this.nextId++
129
+ return new Promise((resolve, reject) => {
130
+ let timer = null
131
+ if (timeoutMs > 0) timer = setTimeout(() => {
132
+ this.pending.delete(id)
133
+ reject(transportError(`${method} timed out after ${timeoutMs}ms`, 'unknown'))
134
+ }, timeoutMs)
135
+ this.pending.set(id, { resolve, reject, timer, method })
136
+ try { this._write({ id, method, params }) }
137
+ catch (error) {
138
+ this.pending.delete(id)
139
+ if (timer) clearTimeout(timer)
140
+ reject(transportError(error, 'not_sent'))
141
+ }
142
+ })
143
+ }
144
+
145
+ notify(method, params) {
146
+ this._write(params === undefined ? { method } : { method, params })
147
+ }
148
+
149
+ respond(id, result) { this._write({ id, result }) }
150
+ respondError(id, error) { this._write({ id, error: rpcError(error?.message || error) }) }
151
+
152
+ _onLine(line) {
153
+ if (!String(line).trim()) return
154
+ let message
155
+ try { message = JSON.parse(line) } catch { return }
156
+ if (message.id != null && ('result' in message || 'error' in message) && !message.method) {
157
+ const pending = this.pending.get(message.id)
158
+ if (!pending) return
159
+ this.pending.delete(message.id)
160
+ if (pending.timer) clearTimeout(pending.timer)
161
+ if (message.error) {
162
+ const error = transportError(message.error.message || `${pending.method} failed`, 'rejected')
163
+ error.code = message.error.code
164
+ error.data = message.error.data
165
+ pending.reject(error)
166
+ }
167
+ else pending.resolve(message.result)
168
+ return
169
+ }
170
+ if (message.id != null && message.method) {
171
+ Promise.resolve().then(() => {
172
+ if (!this.onServerRequest) throw new Error(`Unsupported Codex App Server request: ${message.method}`)
173
+ return this.onServerRequest(message.method, message.params || {}, message.id)
174
+ })
175
+ .then((result) => { try { this.respond(message.id, result ?? {}) } catch { /* peer closed */ } })
176
+ .catch((error) => { try { this.respondError(message.id, error) } catch { /* peer closed */ } })
177
+ return
178
+ }
179
+ if (!message.method) return
180
+ if (message.method === 'turn/completed') {
181
+ const turn = message.params?.turn
182
+ const turnId = turn?.id
183
+ if (turnId) {
184
+ const waiter = this.turnWaiters.get(turnId)
185
+ if (waiter) { this.turnWaiters.delete(turnId); waiter.resolve(message.params) }
186
+ else {
187
+ this.completedTurns.set(turnId, message.params)
188
+ while (this.completedTurns.size > 32) this.completedTurns.delete(this.completedTurns.keys().next().value)
189
+ }
190
+ }
191
+ }
192
+ try { this.onNotification?.(message.method, message.params || {}) } catch { /* consumer isolation */ }
193
+ }
194
+
195
+ _close(error) {
196
+ if (this.closedError) return
197
+ this.closedError = transportError(error instanceof Error ? error : String(error || 'codex app-server closed'), 'unknown')
198
+ this.child = null
199
+ for (const { reject, timer } of this.pending.values()) { if (timer) clearTimeout(timer); reject(this.closedError) }
200
+ this.pending.clear()
201
+ for (const { reject } of this.turnWaiters.values()) reject(this.closedError)
202
+ this.turnWaiters.clear()
203
+ this.completedTurns.clear()
204
+ try { this.onClose?.(this.closedError) } catch { /* consumer isolation */ }
205
+ }
206
+
207
+ async startThread({ threadId, cwd, model, sandbox, approvalPolicy, config } = {}) {
208
+ const params = { cwd, model, sandbox, approvalPolicy, ...(config ? { config } : {}) }
209
+ const result = threadId
210
+ ? await this.request('thread/resume', { ...params, threadId }, this.threadTimeoutMs)
211
+ : await this.request('thread/start', params, this.threadTimeoutMs)
212
+ const id = result?.thread?.id || result?.threadId || threadId
213
+ if (!id) throw new Error('Codex App Server did not return a thread id')
214
+ return id
215
+ }
216
+
217
+ async startTurn({ threadId, input, images, model, effort, approvalPolicy } = {}) {
218
+ const result = await this.request('turn/start', {
219
+ threadId,
220
+ input: turnInput(input, images),
221
+ ...(model ? { model } : {}),
222
+ ...(effort ? { effort } : {}),
223
+ ...(approvalPolicy ? { approvalPolicy } : {}),
224
+ }, this.turnStartTimeoutMs)
225
+ const turnId = result?.turn?.id || result?.turnId
226
+ if (!turnId) throw new Error('Codex App Server did not return a turn id')
227
+ return turnId
228
+ }
229
+
230
+ waitForTurn(turnId) {
231
+ if (this.completedTurns.has(turnId)) {
232
+ const result = this.completedTurns.get(turnId)
233
+ this.completedTurns.delete(turnId)
234
+ return Promise.resolve(result)
235
+ }
236
+ if (this.turnWaiters.has(turnId)) return Promise.reject(new Error(`Already waiting for Codex turn ${turnId}`))
237
+ return new Promise((resolve, reject) => this.turnWaiters.set(turnId, { resolve, reject }))
238
+ }
239
+
240
+ async steer({ threadId, turnId, input, images } = {}) {
241
+ return this.request('turn/steer', {
242
+ threadId,
243
+ expectedTurnId: turnId,
244
+ input: turnInput(input, images),
245
+ }, this.controlTimeoutMs)
246
+ }
247
+
248
+ interrupt({ threadId, turnId } = {}) {
249
+ return this.request('turn/interrupt', { threadId, turnId }, this.controlTimeoutMs)
250
+ }
251
+
252
+ end() {
253
+ const child = this.child
254
+ this._close(new Error('codex app-server ended'))
255
+ if (child) { try { child.kill('SIGTERM') } catch { /* noop */ } }
256
+ }
257
+ }
258
+
259
+ export function createCodexAppServer(options) { return new CodexAppServerClient(options) }
@@ -19,15 +19,15 @@
19
19
  // turn.completed (usage) → result { subtype:'success', usage, model }
20
20
  // turn.failed / error → error { recoverable:true } (+ result on turn.failed)
21
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).
22
+ // G1 (stable exec path): agent_message is burst-on-completion (no per-token
23
+ // deltas). Tool activity DOES stream (item.started→item.completed). The opt-in
24
+ // App Server adapter emits item/agentMessage/delta directly and suppresses the
25
+ // completed-message duplicate before events reach this mapper.
26
26
  //
27
27
  // Risk: this module emits tool_use blocks with names ('Bash'/'Write'/'Edit') so
28
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).
29
+ // apply unchanged. The stable exec path is observe-only; the opt-in App Server
30
+ // path routes command/file approval requests through the shared room cards.
31
31
  import fs from 'node:fs'
32
32
 
33
33
  const safeMcpPart = (value) => String(value || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_')
@@ -66,7 +66,10 @@ export class CodexEventMapper {
66
66
  if (!ev || typeof ev !== 'object') return
67
67
  switch (ev.type) {
68
68
  case 'thread.started': {
69
- this._sessionId = this._sessionId || ev.thread_id || null
69
+ // /clear and permission-mode changes intentionally create a new native
70
+ // thread inside the same visible lane. Attribute subsequent usage and
71
+ // results to that new thread, never the previous one.
72
+ this._sessionId = ev.thread_id || this._sessionId || null
70
73
  if (!this._systemSent) {
71
74
  this._systemSent = true
72
75
  this._emit({ kind: 'system', sessionId: this._sessionId, model: this._model, commands: [] })
@@ -0,0 +1,69 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ const IMAGE_MEDIA_TYPE = /^image\/[a-z0-9.+-]+$/i
5
+ const SAFE_ID_PREFIX = /^[a-zA-Z0-9_-]{1,8}$/
6
+
7
+ export const safeRoomFileName = (name) => String(name || 'file').replace(/[^a-zA-Z0-9._-]/g, '_').slice(-80)
8
+
9
+ /**
10
+ * Rebuild the deterministic room-upload path from bridge-owned facts.
11
+ * `hostPath` is intentionally ignored: it is display metadata supplied by a
12
+ * browser and must never grant Codex access to an arbitrary host file.
13
+ */
14
+ export function canonicalRoomFilePath(file, { updir } = {}) {
15
+ if (!file || !updir) return null
16
+ const idPrefix = String(file.id || '').slice(0, 8)
17
+ if (!SAFE_ID_PREFIX.test(idPrefix)) return null
18
+ const root = path.resolve(String(updir))
19
+ const candidate = path.resolve(root, `${idPrefix}-${safeRoomFileName(file.name)}`)
20
+ return candidate.startsWith(root + path.sep) ? candidate : null
21
+ }
22
+
23
+ export function nativeImageCandidates(files, { updir } = {}) {
24
+ if (!Array.isArray(files)) return []
25
+ const seen = new Set()
26
+ const candidates = []
27
+ for (const file of files) {
28
+ if (!IMAGE_MEDIA_TYPE.test(String(file?.mediaType || ''))) continue
29
+ const candidate = canonicalRoomFilePath(file, { updir })
30
+ if (!candidate || seen.has(candidate)) continue
31
+ seen.add(candidate)
32
+ candidates.push(candidate)
33
+ }
34
+ return candidates
35
+ }
36
+
37
+ const fileReady = async (filePath) => {
38
+ try { return (await fs.promises.stat(filePath)).isFile() }
39
+ catch { return false }
40
+ }
41
+
42
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
43
+
44
+ /**
45
+ * `file-put` and `code-turn` are separate realtime broadcasts, so the turn can
46
+ * arrive while the signed download is still in flight. Wait briefly for only
47
+ * canonical image paths; timed-out images stay in the text prompt as ordinary
48
+ * quoted paths instead of failing or delaying the turn indefinitely.
49
+ */
50
+ export async function waitForNativeImages(files, {
51
+ updir,
52
+ timeoutMs = 2000,
53
+ pollMs = 40,
54
+ isReady = fileReady,
55
+ sleep = delay,
56
+ now = Date.now,
57
+ } = {}) {
58
+ const candidates = nativeImageCandidates(files, { updir })
59
+ if (!candidates.length) return []
60
+ const ready = new Set()
61
+ const deadline = now() + Math.max(0, timeoutMs)
62
+ while (true) {
63
+ const checks = await Promise.all(candidates.map(async (candidate) => [candidate, await isReady(candidate)]))
64
+ for (const [candidate, exists] of checks) if (exists) ready.add(candidate)
65
+ if (ready.size === candidates.length || now() >= deadline) break
66
+ await sleep(Math.min(Math.max(1, pollMs), Math.max(1, deadline - now())))
67
+ }
68
+ return candidates.filter((candidate) => ready.has(candidate))
69
+ }
package/codex-session.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  // codex-session.mjs — a structured Codex CLI session for the ThinkPool bridge.
2
2
  // Sibling to claude-session.mjs: SAME interface (startCodexSession →
3
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.
4
+ // bridge can spawn either runtime per lane (spec §"the seam"). Uses
5
+ // `codex exec --json` (OpenAI's openai-native agent loop) by default, with a
6
+ // version-gated App Server path for streaming + interactive control.
7
7
  //
8
8
  // WHY NOT a translator: Codex IS the agent loop (tools + sandbox); we spawn it
9
9
  // and map its --json event stream (codex-event-mapper.mjs). No API translation,
@@ -15,10 +15,10 @@
15
15
  // Codex thread so the new sandbox actually takes effect; the visible transcript
16
16
  // remains in ThinkPool.
17
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).
18
+ // DEFAULT ONE-SHOT-PER-TURN model: unlike Claude's long-lived query(), `codex
19
+ // exec` is one prompt → run → exit. Continuity uses the exact thread id captured
20
+ // for this lane. Compatible CLIs use one long-lived process by default;
21
+ // TP_CODEX_APP_SERVER=0 or initialization failure falls back to this path.
22
22
  //
23
23
  // GOTCHA (spike): `codex exec` blocks reading stdin when backgrounded
24
24
  // ("Reading additional input from stdin…"). Spawn with stdin set to /dev/null.
@@ -27,6 +27,7 @@ import fs from 'node:fs'
27
27
  import os from 'node:os'
28
28
  import path from 'node:path'
29
29
  import readline from 'node:readline'
30
+ import { buildCodexAppServerArgs, codexAppServerGate, createCodexAppServer } from './codex-app-server.mjs'
30
31
  import { CodexEventMapper } from './codex-event-mapper.mjs'
31
32
  import { startCodexMcpHttp } from './codex-mcp-http.mjs'
32
33
  import { CODEX_THINKPOOL_FIRST_TURN_PREAMBLE } from './thinkpool-room-prompt.mjs'
@@ -181,7 +182,7 @@ export function buildCodexPrompt({ text, rolePrompt, roomContext, firstTurn = fa
181
182
  return `<thinkpool-context>\n${additions.join('\n\n')}\n</thinkpool-context>\n\n${String(text ?? '')}`
182
183
  }
183
184
 
184
- export function buildCodexExecArgs({ sessionId, model, effort, sandbox, approvalPolicy = 'untrusted', providerConfig, mcpUrl, writableDirs = [], prompt }) {
185
+ export function buildCodexExecArgs({ sessionId, model, effort, sandbox, approvalPolicy = 'untrusted', providerConfig, mcpUrl, writableDirs = [], images = [], prompt }) {
185
186
  const safeSandbox = normalizeCodexSandbox(sandbox)
186
187
  const args = sessionId
187
188
  // `codex exec resume` has no --sandbox option and does NOT retain the fresh
@@ -206,11 +207,119 @@ export function buildCodexExecArgs({ sessionId, model, effort, sandbox, approval
206
207
  if (!sessionId) for (const dir of writableDirs) args.push('--add-dir', dir)
207
208
  // Per-lane override only: never mutate the person's global Codex config.
208
209
  if (mcpUrl) args.push('-c', `mcp_servers.thinkpool.url=${JSON.stringify(mcpUrl)}`)
210
+ // Both fresh `exec` and `exec resume` accept repeatable --image flags. Only
211
+ // bridge-derived absolute paths reach this layer; relative values are ignored
212
+ // so a future caller cannot silently change the child's path interpretation.
213
+ for (const imagePath of images) if (path.isAbsolute(String(imagePath || ''))) args.push('--image', String(imagePath))
209
214
  if (sessionId) args.push(sessionId)
210
215
  args.push(String(prompt ?? ''))
211
216
  return args
212
217
  }
213
218
 
219
+ const CODEX_DESTRUCTIVE = /\brm\s+\S|\brmdir\s+\S|\bgit\s+(push\s+(-f|--force)|reset\s+--hard|clean\s+-[a-z]*f)|\bdrop\s+(table|database)\b|\b(mkfs|dd)\b|\bsudo\b|>\s*\/dev\/|\bchmod\s+-R|\bchown\s+-R|\bkillall\b|\btruncate\b/i
220
+
221
+ export function codexApprovalCard(method, params = {}, item = null) {
222
+ const id = String(params.approvalId || params.itemId || `${params.turnId || 'turn'}:${method}`)
223
+ if (method === 'item/tool/requestUserInput') {
224
+ const questions = Array.isArray(params.questions) ? params.questions : []
225
+ return {
226
+ id,
227
+ toolName: 'AskUserQuestion',
228
+ input: { questions },
229
+ risk: 'ask',
230
+ questions,
231
+ asker: 'Codex',
232
+ answerFormat: 'codex',
233
+ ...(Number.isFinite(params.autoResolutionMs) && params.autoResolutionMs > 0
234
+ ? { autoResolutionMs: params.autoResolutionMs }
235
+ : {}),
236
+ }
237
+ }
238
+ if (method === 'item/commandExecution/requestApproval') {
239
+ const command = params.command || item?.command || ''
240
+ return {
241
+ id,
242
+ toolName: 'Bash',
243
+ input: { command, cwd: params.cwd || item?.cwd || '', ...(params.reason ? { reason: params.reason } : {}) },
244
+ risk: CODEX_DESTRUCTIVE.test(command) ? 'high' : 'medium',
245
+ }
246
+ }
247
+ if (method === 'item/fileChange/requestApproval') {
248
+ const changes = Array.isArray(item?.changes) ? item.changes : []
249
+ const first = changes[0] || {}
250
+ const toolName = changes.length > 1 ? 'MultiEdit' : first.kind === 'add' ? 'Write' : 'Edit'
251
+ return {
252
+ id,
253
+ toolName,
254
+ input: changes.length > 1
255
+ ? { edits: changes.map((change) => ({ file_path: change.path || '', path: change.path || '', kind: change.kind || 'update' })), ...(params.reason ? { reason: params.reason } : {}) }
256
+ : { file_path: first.path || params.grantRoot || '', path: first.path || params.grantRoot || '', kind: first.kind || 'update', ...(params.reason ? { reason: params.reason } : {}) },
257
+ risk: 'medium',
258
+ }
259
+ }
260
+ return null
261
+ }
262
+
263
+ export function codexApprovalResponse(decision) {
264
+ return { decision: decision === 'always' ? 'acceptForSession' : decision === 'allow' ? 'accept' : 'decline' }
265
+ }
266
+
267
+ function legacyQuestionAnswers(decision, questions) {
268
+ if (typeof decision !== 'string' || !decision.startsWith('answer:')) return {}
269
+ const text = decision.slice(7).trim()
270
+ if (!text || !questions.length) return {}
271
+ if (questions.length === 1) {
272
+ const question = questions[0]
273
+ const prefix = `${question.header || 'Q1'}: `
274
+ const value = text.startsWith(prefix) ? text.slice(prefix.length) : text
275
+ return { [String(question.id || 0)]: { answers: [value] } }
276
+ }
277
+ const answers = {}
278
+ questions.forEach((question, index) => {
279
+ const id = String(question.id || index)
280
+ const marker = `${index ? '; ' : ''}${question.header || `Q${index + 1}`}: `
281
+ const start = text.indexOf(marker)
282
+ if (start < 0) return
283
+ const valueStart = start + marker.length
284
+ const nextQuestion = questions[index + 1]
285
+ const nextMarker = nextQuestion ? `; ${nextQuestion.header || `Q${index + 2}`}: ` : null
286
+ const end = nextMarker ? text.indexOf(nextMarker, valueStart) : -1
287
+ const value = text.slice(valueStart, end < 0 ? undefined : end).trim()
288
+ if (value) answers[id] = { answers: [value] }
289
+ })
290
+ return answers
291
+ }
292
+
293
+ export function codexUserInputResponse(result, questions = []) {
294
+ const supplied = result && typeof result === 'object' ? result.answers : null
295
+ const answers = {}
296
+ if (supplied && typeof supplied === 'object') {
297
+ const allowed = new Set(questions.map((question, index) => String(question?.id || index)))
298
+ for (const [id, value] of Object.entries(supplied)) {
299
+ if (!allowed.has(String(id)) || !Array.isArray(value?.answers)) continue
300
+ const values = value.answers.map((answer) => String(answer)).filter(Boolean)
301
+ if (values.length) answers[String(id)] = { answers: values }
302
+ }
303
+ }
304
+ if (Object.keys(answers).length) return { answers }
305
+ const decision = result && typeof result === 'object' ? result.decision : result
306
+ return { answers: legacyQuestionAnswers(decision, questions) }
307
+ }
308
+
309
+ function appServerItemForMapper(item) {
310
+ if (!item || typeof item !== 'object') return item
311
+ const common = { ...item }
312
+ switch (item.type) {
313
+ case 'agentMessage': return { ...common, type: 'agent_message' }
314
+ case 'commandExecution': return { ...common, type: 'command_execution', aggregated_output: item.aggregatedOutput || '', exit_code: item.exitCode }
315
+ case 'fileChange': return { ...common, type: 'file_change' }
316
+ case 'webSearch': return { ...common, type: 'web_search' }
317
+ case 'mcpToolCall': return { ...common, type: 'mcp_tool_call', arguments: item.arguments || {}, result: item.result, error: item.error }
318
+ case 'reasoning': return { ...common, type: 'reasoning', text: [...(item.summary || []), ...(item.content || [])].join('\n') }
319
+ default: return common
320
+ }
321
+ }
322
+
214
323
  /**
215
324
  * Start a structured Codex session. Mirrors startClaudeSession's contract.
216
325
  * @param {object} o
@@ -220,10 +329,11 @@ export function buildCodexExecArgs({ sessionId, model, effort, sandbox, approval
220
329
  * @param {object} [o.env] env for the child (e.g. CODEX_API_KEY for BYOK)
221
330
  * @param {string} [o.sandbox] 'read-only' | 'workspace-write' | 'danger-full-access' (default workspace-write)
222
331
  * @param {(evt)=>void} o.onEvent receives normalized room-events
332
+ * @param {(req)=>Promise<string>} [o.requestPermission] shared room permission-card callback
223
333
  * @param {string} [o.providerConfig] optional -c overrides / provider block (M2: from the provider registry)
224
- * @returns {{ sendTurn(text), abort(), end(), readonly sessionId }}
334
+ * @returns {{ sendTurn(text, options?), abort(), end(), readonly sessionId }}
225
335
  */
226
- export function startCodexSession({ cwd, model, effort: initialEffort = 'high', resume, env, sandbox, mode = 'default', onEvent, providerConfig, rolePrompt, roomContext, mcpServers, prepareCwd = null }) {
336
+ export function startCodexSession({ cwd, model, effort: initialEffort = 'high', resume, env, sandbox, mode = 'default', onEvent, providerConfig, rolePrompt, roomContext, mcpServers, prepareCwd = null, requestPermission, appServerGate = codexAppServerGate, appServerFactory = createCodexAppServer, spawnImpl = spawn }) {
227
337
  let activeMode = CODEX_MODE_CONFIG[mode] ? mode : 'default'
228
338
  let modeConfig = codexConfigForMode(activeMode)
229
339
  sandbox = normalizeCodexSandbox(sandbox || modeConfig.sandbox)
@@ -239,6 +349,14 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
239
349
  let activeEffort = EFFORT_LEVELS.has(initialEffort) ? initialEffort : 'high'
240
350
  let latestUsageSnapshot = sessionId ? readCodexThreadUsage(sessionId) : null
241
351
  let mcpHttpPromise = null
352
+ const appServerGateResult = appServerGate({ env: childEnv })
353
+ let appServerDisabled = !appServerGateResult.enabled
354
+ let appServer = null
355
+ let appServerThreadReady = false
356
+ let activeTurnId = null
357
+ const appServerItems = new Map()
358
+ const streamedAgentItems = new Map() // item id → { cid, text }
359
+ let steerChain = Promise.resolve()
242
360
  // serialize turns: one exec at a time
243
361
  let chain = Promise.resolve()
244
362
  const queue = []
@@ -254,6 +372,9 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
254
372
  })
255
373
 
256
374
  const note = (text) => { try { onEvent?.({ kind: 'note', text }) } catch { /* noop */ } }
375
+ if (!['0', 'false', 'off'].includes(String(childEnv.TP_CODEX_APP_SERVER || '').trim().toLowerCase()) && !appServerGateResult.enabled) {
376
+ queueMicrotask(() => note(`Codex App Server disabled: ${appServerGateResult.reason}`))
377
+ }
257
378
 
258
379
  async function ensureMcpHttp() {
259
380
  const thinkpool = mcpServers?.thinkpool
@@ -262,7 +383,164 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
262
383
  return mcpHttpPromise
263
384
  }
264
385
 
265
- async function runExec(prompt) {
386
+ function appServerNotification(method, params = {}) {
387
+ if (method === 'turn/started' && params.turn?.id) {
388
+ activeTurnId = activeTurnId || params.turn.id
389
+ return
390
+ }
391
+ if (method === 'turn/plan/updated') {
392
+ const todos = (Array.isArray(params.plan) ? params.plan : []).map(({ step, status }) => ({
393
+ content: step || '',
394
+ activeForm: step || '',
395
+ status: status === 'inProgress' ? 'in_progress' : status === 'completed' ? 'completed' : 'pending',
396
+ })).filter((todo) => todo.content)
397
+ try { onEvent?.({ kind: 'assistant', blocks: [{ type: 'tool_use', id: `codex-plan:${params.turnId || 'active'}`, name: 'TodoWrite', input: { todos } }], parentToolUseId: null }) } catch { /* noop */ }
398
+ return
399
+ }
400
+ if (method === 'item/agentMessage/delta') {
401
+ if (!params.delta) return
402
+ const itemId = params.itemId || `turn:${params.turnId || activeTurnId || 'active'}`
403
+ const state = streamedAgentItems.get(itemId) || { cid: `codex-stream:${sessionId || 'thread'}:${itemId}`, text: '' }
404
+ state.text += String(params.delta)
405
+ streamedAgentItems.set(itemId, state)
406
+ // Cumulative transient snapshot. The bridge broadcasts this chrome event
407
+ // without assigning transcript seq/archive rows; room.jsx replaces the
408
+ // stable cid in place. item/completed emits one durable assistant row.
409
+ try { onEvent?.({ kind: 'assistant_stream', cid: state.cid, text: state.text, itemId, streaming: true }) } catch { /* noop */ }
410
+ return
411
+ }
412
+ if (method === 'item/started' || method === 'item/completed') {
413
+ const raw = params.item
414
+ if (!raw) return
415
+ if (method === 'item/started') appServerItems.set(raw.id, raw)
416
+ if (method === 'item/completed') {
417
+ appServerItems.set(raw.id, raw)
418
+ if (raw.type === 'agentMessage' && streamedAgentItems.has(raw.id)) {
419
+ const state = streamedAgentItems.get(raw.id)
420
+ streamedAgentItems.delete(raw.id)
421
+ try { onEvent?.({ kind: 'assistant', blocks: [{ type: 'text', text: raw.text || state.text }], parentToolUseId: null, replacesCid: state.cid }) } catch { /* noop */ }
422
+ return
423
+ }
424
+ }
425
+ mapper.push({ type: method === 'item/started' ? 'item.started' : 'item.completed', item: appServerItemForMapper(raw) })
426
+ return
427
+ }
428
+ if (method === 'error') {
429
+ const message = params.error?.message || params.message || 'codex app-server error'
430
+ mapper.push({ type: 'error', message })
431
+ }
432
+ }
433
+
434
+ async function appServerRequest(method, params = {}) {
435
+ const card = codexApprovalCard(method, params, appServerItems.get(params.itemId))
436
+ if (!card) throw new Error(`Unsupported Codex App Server request: ${method}`)
437
+ // Missing/broken room permission plumbing denies safely. App Server expects
438
+ // an ordinary response even for denial; an RPC error can strand the turn.
439
+ let decision = 'deny'
440
+ try { decision = await requestPermission?.(card) } catch { /* deny */ }
441
+ if (method === 'item/tool/requestUserInput') return codexUserInputResponse(decision, card.questions)
442
+ return codexApprovalResponse(decision)
443
+ }
444
+
445
+ async function ensureAppServer() {
446
+ if (appServerDisabled) return false
447
+ if (appServer?.alive === false) {
448
+ appServerThreadReady = false
449
+ appServerDisabled = true
450
+ return false
451
+ }
452
+ if (appServer && appServerThreadReady) return true
453
+ let peer
454
+ try { peer = await ensureMcpHttp() }
455
+ catch (error) { note(`Thinkpool peer tools unavailable: ${error?.message || error}`) }
456
+ try {
457
+ if (!appServer) {
458
+ appServer = appServerFactory({
459
+ cwd,
460
+ env: childEnv,
461
+ args: buildCodexAppServerArgs({ providerConfig, mcpUrl: peer?.url, writableDirs: publishAccess.writableDirs }),
462
+ onNotification: appServerNotification,
463
+ onServerRequest: appServerRequest,
464
+ onClose: (error) => {
465
+ if (ended || appServerDisabled) return
466
+ appServerThreadReady = false
467
+ appServerDisabled = true
468
+ note(`Codex App Server closed; later turns will use codex exec: ${error?.message || error}`)
469
+ },
470
+ })
471
+ await appServer.start()
472
+ }
473
+ sessionId = await appServer.startThread({
474
+ threadId: turnNo > 0 ? sessionId : null,
475
+ cwd,
476
+ model: activeModel,
477
+ sandbox,
478
+ approvalPolicy: modeConfig.approvalPolicy,
479
+ })
480
+ appServerThreadReady = true
481
+ mapper.push({ type: 'thread.started', thread_id: sessionId })
482
+ return true
483
+ } catch (error) {
484
+ // Fallback is safe only here: no turn/start request has been accepted, so
485
+ // there is no possibility of running the same prompt twice.
486
+ appServerDisabled = true
487
+ try { appServer?.end() } catch { /* noop */ }
488
+ appServer = null
489
+ appServerThreadReady = false
490
+ note(`Codex App Server unavailable; using codex exec: ${error?.message || error}`)
491
+ return false
492
+ }
493
+ }
494
+
495
+ async function runAppServer(prompt, options = {}) {
496
+ if (!await ensureAppServer()) return false
497
+ mapper.setUsageBaseline(sessionId ? readCodexThreadUsage(sessionId) : null)
498
+ aborted = false
499
+ turnActive = true
500
+ try {
501
+ activeTurnId = await appServer.startTurn({
502
+ threadId: sessionId,
503
+ input: prompt,
504
+ images: Array.isArray(options.images) ? options.images : [],
505
+ model: activeModel,
506
+ effort: activeEffort,
507
+ approvalPolicy: modeConfig.approvalPolicy,
508
+ })
509
+ const completed = await appServer.waitForTurn(activeTurnId)
510
+ const status = completed?.turn?.status
511
+ if (aborted || status === 'interrupted') {
512
+ try { onEvent?.({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: completed?.turn?.durationMs, denials: 0, resultText: null }) } catch { /* noop */ }
513
+ } else if (status === 'failed') {
514
+ mapper.push({ type: 'turn.failed', message: completed?.turn?.error?.message || 'codex turn failed' })
515
+ } else {
516
+ mapper.push({ type: 'turn.completed' })
517
+ }
518
+ } catch (error) {
519
+ // Once a started turn's transport fails we must not replay that prompt via
520
+ // exec (the model may already have acted). Disable App Server for later
521
+ // turns only; they can safely resume this thread through the stable driver.
522
+ appServerDisabled = true
523
+ appServerThreadReady = false
524
+ try { appServer?.end() } catch { /* noop */ }
525
+ appServer = null
526
+ if (!ended) mapper.push({ type: 'turn.failed', message: `codex app-server turn failed: ${error?.message || error}` })
527
+ } finally {
528
+ // A transport failure or interrupt can land after prose deltas but before
529
+ // item/completed. Preserve that partial prose as one durable row rather
530
+ // than leaving a replay-less streaming shell behind.
531
+ for (const state of streamedAgentItems.values()) {
532
+ if (!state.text) continue
533
+ try { onEvent?.({ kind: 'assistant', blocks: [{ type: 'text', text: state.text }], parentToolUseId: null, replacesCid: state.cid }) } catch { /* noop */ }
534
+ }
535
+ activeTurnId = null
536
+ turnActive = false
537
+ appServerItems.clear()
538
+ streamedAgentItems.clear()
539
+ }
540
+ return true
541
+ }
542
+
543
+ async function runExec(prompt, options = {}) {
266
544
  // First turn: fresh exec. Later turns: resume THIS lane's captured thread.
267
545
  // `--last` is process-global and can cross-wire two concurrently-active Codex
268
546
  // lanes, so it is never safe in a multi-lane bridge.
@@ -278,6 +556,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
278
556
  providerConfig,
279
557
  mcpUrl: peer?.url,
280
558
  writableDirs: publishAccess.writableDirs,
559
+ images: Array.isArray(options.images) ? options.images : [],
281
560
  prompt,
282
561
  })
283
562
 
@@ -294,7 +573,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
294
573
 
295
574
  aborted = false
296
575
  turnActive = true
297
- child = spawn('codex', args, opts)
576
+ child = spawnImpl('codex', args, opts)
298
577
  if (stdinFd != null) { try { fs.closeSync(stdinFd) } catch { /* child owns its duplicated fd */ } }
299
578
 
300
579
  const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity })
@@ -347,7 +626,9 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
347
626
  // Claude receives these through the Agent SDK's system-reminder path.
348
627
  // Codex is one-shot per turn, so give it the same lane identity and live
349
628
  // room awareness in a compact envelope before the person's text.
350
- await runExec(buildCodexPrompt({ text: next, rolePrompt, roomContext, firstTurn: turnNo === 0 }))
629
+ const prompt = buildCodexPrompt({ text: next.text, rolePrompt, roomContext, firstTurn: turnNo === 0 })
630
+ const usedAppServer = await runAppServer(prompt, next.options)
631
+ if (!usedAppServer) await runExec(prompt, next.options)
351
632
  turnNo++
352
633
  pump()
353
634
  })
@@ -357,13 +638,32 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
357
638
  get sessionId() { return sessionId },
358
639
  get usageSnapshot() { return latestUsageSnapshot },
359
640
  get turnActive() { return turnActive },
641
+ get canSteer() { return !!(appServer && appServer.alive !== false && appServerThreadReady && !appServerDisabled) },
360
642
  get started() { return turnNo > 0 || turnActive },
361
- sendTurn(text) {
643
+ sendTurn(text, options = {}) {
362
644
  if (ended) return false
363
645
  if (!turnActive && turnNo === 0 && prepareCwd) {
364
646
  try { cwd = prepareCwd() || cwd } catch { /* keep original cwd */ }
365
647
  }
366
- queue.push(text)
648
+ if (turnActive && appServer && activeTurnId) {
649
+ const prompt = buildCodexPrompt({ text, rolePrompt, roomContext })
650
+ const targetTurnId = activeTurnId
651
+ // Serialize authored follow-ups: parallel RPCs can complete out of order.
652
+ steerChain = steerChain.then(() => appServer.steer({ threadId: sessionId, turnId: targetTurnId, input: prompt, images: Array.isArray(options.images) ? options.images : [] }))
653
+ .catch((error) => {
654
+ // Retry only after an explicit server rejection or a synchronous
655
+ // pre-write failure. Timeout/close after write is delivery-uncertain;
656
+ // replaying it as a new turn can execute the same instruction twice.
657
+ if (error?.delivery === 'rejected' || error?.delivery === 'not_sent') {
658
+ queue.push({ text, options })
659
+ if (!turnActive && queue.length === 1) pump()
660
+ } else {
661
+ try { onEvent?.({ kind: 'error', message: 'Codex steering delivery is uncertain; the message was not replayed automatically.', recoverable: true }) } catch { /* noop */ }
662
+ }
663
+ })
664
+ return true
665
+ }
666
+ queue.push({ text, options })
367
667
  // start the pump if idle
368
668
  if (queue.length === 1) pump()
369
669
  return true
@@ -371,12 +671,14 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
371
671
  abort() {
372
672
  aborted = true
373
673
  queue.length = 0
674
+ if (appServer && activeTurnId) { void appServer.interrupt({ threadId: sessionId, turnId: activeTurnId }).catch(() => {}) ; return }
374
675
  if (child) { try { child.kill('SIGTERM') } catch { /* noop */ } ; setTimeout(() => { try { child?.kill('SIGKILL') } catch { /* noop */ } }, 1500) }
375
676
  },
376
677
  end() {
377
678
  ended = true
378
679
  queue.length = 0
379
680
  if (child) { try { child.kill() } catch { /* noop */ } }
681
+ try { appServer?.end() } catch { /* noop */ }
380
682
  void mcpHttpPromise?.then((h) => h.close()).catch(() => {})
381
683
  },
382
684
  setModel(nextModel) {
@@ -395,6 +697,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
395
697
  sessionId = null
396
698
  turnNo = 0
397
699
  latestUsageSnapshot = null
700
+ appServerThreadReady = false
398
701
  return true
399
702
  },
400
703
  setEffort(nextEffort) {
@@ -408,6 +711,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
408
711
  sessionId = null
409
712
  turnNo = 0
410
713
  latestUsageSnapshot = null
714
+ appServerThreadReady = false
411
715
  return true
412
716
  },
413
717
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.212",
3
+ "version": "0.7.214",
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": {
@@ -15,6 +15,8 @@
15
15
  "claude-session.mjs",
16
16
  "codex-session.mjs",
17
17
  "thinkpool-room-prompt.mjs",
18
+ "codex-app-server.mjs",
19
+ "codex-images.mjs",
18
20
  "codex-mcp-http.mjs",
19
21
  "lane-worktree.mjs",
20
22
  "codex-event-mapper.mjs",