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 +17 -0
- package/bridge.mjs +60 -10
- package/codex-app-server.mjs +259 -0
- package/codex-event-mapper.mjs +10 -7
- package/codex-images.mjs +69 -0
- package/codex-session.mjs +319 -15
- package/package.json +3 -1
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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.
|
|
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) {
|
|
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) }
|
package/codex-event-mapper.mjs
CHANGED
|
@@ -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 (
|
|
23
|
-
// activity DOES stream (item.started→item.completed)
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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.
|
|
30
|
-
//
|
|
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
|
-
|
|
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: [] })
|
package/codex-images.mjs
ADDED
|
@@ -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").
|
|
5
|
-
// `codex exec --json` (OpenAI's openai-native agent loop)
|
|
6
|
-
//
|
|
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
|
|
19
|
-
// one prompt → run → exit.
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|