thinkpool-pair 0.7.364 → 0.7.366
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/LICENSE +18 -0
- package/README.md +20 -0
- package/abort-turn-barrier.mjs +1 -23
- package/account.mjs +1 -1437
- package/acp-client.mjs +1 -140
- package/agent-detect.mjs +1 -28
- package/agent-notify.mjs +1 -142
- package/agent-visibility.mjs +1 -67
- package/auth-store.mjs +1 -120
- package/bridge.mjs +1 -6266
- package/byok-detect.mjs +1 -126
- package/claude-command-catalog.mjs +1 -91
- package/claude-session.mjs +1 -1519
- package/code-event-contract.mjs +1 -118
- package/codex-app-server.mjs +1 -340
- package/codex-commands.mjs +1 -83
- package/codex-event-mapper.mjs +1 -232
- package/codex-images.mjs +1 -69
- package/codex-mcp-http.mjs +1 -131
- package/codex-session.mjs +1 -1295
- package/command-catalog.mjs +1 -118
- package/command-guidance.mjs +1 -8
- package/context-contract.mjs +1 -95
- package/context-windows.mjs +1 -107
- package/cross-terminal.mjs +1 -789
- package/cumulative-event-relay.mjs +1 -53
- package/design-edit.mjs +1 -424
- package/design-source-contract.mjs +1 -4
- package/direct-pair-room.mjs +1 -57
- package/dispatch-lease.mjs +1 -37
- package/dispatch-permission-cleanup.mjs +1 -86
- package/edit-diff.mjs +1 -136
- package/error-recovery.mjs +1 -50
- package/event-bounds.mjs +1 -121
- package/event-delivery-queue.mjs +1 -60
- package/event-id.mjs +1 -549
- package/evidence-citations.mjs +1 -50
- package/evidence-compact.mjs +1 -11
- package/flow-assembly.mjs +1 -196
- package/flow-budget.mjs +1 -84
- package/flow-conductor.mjs +1 -259
- package/flow-context-store.mjs +1 -387
- package/flow-host-revert.mjs +1 -42
- package/flow-models.mjs +1 -139
- package/flow-preview.mjs +1 -148
- package/flow-receipt.mjs +1 -122
- package/flow-redispatch.mjs +1 -72
- package/flow-review-gate.mjs +1 -402
- package/flow-review-reflect.mjs +1 -115
- package/flow-review.mjs +1 -152
- package/flow-scope-evidence.mjs +1 -117
- package/flow-skill-registry.mjs +1 -140
- package/flow-task-graph.mjs +1 -562
- package/flow-worktree.mjs +1 -71
- package/git-diff-report.mjs +1 -121
- package/hermes-delegation-guard.mjs +1 -14
- package/hermes-event-mapper.mjs +1 -194
- package/hermes-isolation.mjs +1 -61
- package/hermes-model-cache.mjs +1 -54
- package/hermes-policy.mjs +1 -92
- package/hermes-probe.mjs +1 -57
- package/hermes-session.mjs +1 -683
- package/hermes-setup.mjs +1 -167
- package/host-memory.mjs +1 -116
- package/interrupted-resume.mjs +1 -95
- package/keep-awake.mjs +1 -148
- package/key-shape.mjs +1 -49
- package/lane-continuation.mjs +1 -83
- package/lane-lifecycle.mjs +1 -189
- package/lane-worktree.mjs +1 -77
- package/launcher.mjs +1 -354
- package/mcp-flight-recorder.mjs +1 -79
- package/mockup-delivery.mjs +1 -57
- package/model-prices.mjs +1 -113
- package/package.json +13 -4
- package/pair-bus.mjs +1 -98
- package/pair-control-authority.mjs +1 -89
- package/past-work-search.mjs +1 -105
- package/plan-meters.mjs +1 -144
- package/presence.mjs +1 -191
- package/privacy-report.mjs +1 -108
- package/provider-resilience.mjs +1 -356
- package/provider.mjs +1 -133
- package/providers.mjs +1 -491
- package/publish-guard.mjs +2 -0
- package/publish-manifest.json +129 -0
- package/question-response.mjs +1 -58
- package/reap-terminal.mjs +1 -68
- package/recap.mjs +1 -297
- package/replay-transport.mjs +1 -64
- package/repo-search.mjs +1 -190
- package/review-check.mjs +1 -182
- package/runtime-contract.mjs +1 -93
- package/runtime-registry.mjs +1 -64
- package/runtime-session.mjs +1 -20
- package/scheduled-run-admission.mjs +1 -364
- package/scheduled-runs.mjs +1 -268
- package/sdk-admission.mjs +1 -9
- package/sdk-smoke.mjs +1 -61
- package/serve-consent.mjs +1 -118
- package/serve-dir.mjs +1 -40
- package/service.mjs +1 -877
- package/session-store.mjs +1 -484
- package/side-lane.mjs +1 -63
- package/supabase-key.mjs +1 -176
- package/supervisor-ready.mjs +1 -57
- package/switch-provider.mjs +1 -84
- package/terminal-name.mjs +1 -359
- package/terminal-row-reconcile.mjs +1 -70
- package/thinkpool-prompt-contracts.mjs +1 -85
- package/thinkpool-room-prompt.mjs +1 -187
- package/transcript-sanitize.mjs +1 -332
- package/turn-stall.mjs +1 -61
- package/update-gate.mjs +1 -53
- package/viewport.mjs +1 -810
- package/worker-completion.mjs +1 -57
package/codex-session.mjs
CHANGED
|
@@ -1,1295 +1 @@
|
|
|
1
|
-
// codex-session.mjs — a structured Codex CLI session for the ThinkPool bridge.
|
|
2
|
-
// Sibling to claude-session.mjs: SAME interface (startCodexSession →
|
|
3
|
-
// { sendTurn, abort, end, sessionId }) and SAME room-event contract, so the
|
|
4
|
-
// bridge can spawn either runtime per lane (spec §"the seam"). 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
|
-
//
|
|
8
|
-
// WHY NOT a translator: Codex IS the agent loop (tools + sandbox); we spawn it
|
|
9
|
-
// and map its --json event stream (codex-event-mapper.mjs). No API translation,
|
|
10
|
-
// no tool_use↔function-call loss. Auth = the HOST's `codex login` (ChatGPT,
|
|
11
|
-
// flat sub) by default; BYOK via env CODEX_API_KEY works too (exec-only).
|
|
12
|
-
//
|
|
13
|
-
// `codex exec` is non-interactive, so ThinkPool maps its shared permission modes
|
|
14
|
-
// onto Codex's native sandbox + approval policy. A mode change starts a fresh
|
|
15
|
-
// Codex thread so the new sandbox actually takes effect; the visible transcript
|
|
16
|
-
// remains in ThinkPool.
|
|
17
|
-
//
|
|
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
|
-
//
|
|
23
|
-
// GOTCHA (spike): `codex exec` blocks reading stdin when backgrounded
|
|
24
|
-
// ("Reading additional input from stdin…"). Spawn with stdin set to /dev/null.
|
|
25
|
-
import { execFileSync, spawn } from 'node:child_process'
|
|
26
|
-
import fs from 'node:fs'
|
|
27
|
-
import os from 'node:os'
|
|
28
|
-
import path from 'node:path'
|
|
29
|
-
import readline from 'node:readline'
|
|
30
|
-
import { buildCodexAppServerArgs, codexAppServerGate, createCodexAppServer } from './codex-app-server.mjs'
|
|
31
|
-
import { CodexEventMapper } from './codex-event-mapper.mjs'
|
|
32
|
-
import { startCodexMcpHttp } from './codex-mcp-http.mjs'
|
|
33
|
-
import { CODEX_THINKPOOL_FIRST_TURN_PREAMBLE, buildThinkPoolTurnGuidance, createRoomContextSelector, usesFullThinkPoolReminder } from './thinkpool-room-prompt.mjs'
|
|
34
|
-
import { questionAnswerResponse } from './question-response.mjs'
|
|
35
|
-
import { codexReviewTarget, isCodexCompactCommand } from './codex-commands.mjs'
|
|
36
|
-
import { createCumulativeEventRelay } from './cumulative-event-relay.mjs'
|
|
37
|
-
import { stallDecision, stallEvent } from './turn-stall.mjs'
|
|
38
|
-
|
|
39
|
-
const DEFAULT_SANDBOX = 'workspace-write'
|
|
40
|
-
const SAFE_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access'])
|
|
41
|
-
export const EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max'])
|
|
42
|
-
export const CODEX_MODE_CONFIG = {
|
|
43
|
-
default: { sandbox: 'workspace-write', approvalPolicy: 'untrusted' },
|
|
44
|
-
acceptEdits: { sandbox: 'workspace-write', approvalPolicy: 'on-request' },
|
|
45
|
-
plan: { sandbox: 'read-only', approvalPolicy: 'never' },
|
|
46
|
-
// Reviewer authority is structural, not a prompt convention. It may inspect
|
|
47
|
-
// the pinned snapshot and call the bridge-owned verdict/check tools, but it
|
|
48
|
-
// cannot mutate even its own worktree as an escape hatch.
|
|
49
|
-
review: { sandbox: 'read-only', approvalPolicy: 'never' },
|
|
50
|
-
bypassPermissions: { sandbox: 'danger-full-access', approvalPolicy: 'never' },
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function codexConfigForMode(mode) {
|
|
54
|
-
return CODEX_MODE_CONFIG[mode] || CODEX_MODE_CONFIG.default
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function codexDefaultCollaborationMode(model, effort) {
|
|
58
|
-
if (!model) return null
|
|
59
|
-
return {
|
|
60
|
-
mode: 'default',
|
|
61
|
-
settings: {
|
|
62
|
-
model: String(model),
|
|
63
|
-
reasoning_effort: EFFORT_LEVELS.has(effort) ? effort : null,
|
|
64
|
-
developer_instructions: null,
|
|
65
|
-
},
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* The permission mode a FRESHLY-OPENED terminal gets when none was specified
|
|
71
|
-
* (the person clicked "+ New terminal" without picking a mode). Codex defaults
|
|
72
|
-
* to bypassPermissions: its workspace-write sandbox has NO network, so a
|
|
73
|
-
* default-mode codex lane can't fetch / advance git metadata / push / publish
|
|
74
|
-
* (the 2026-07-13 "sandbox won't let it advance Git metadata" wall) and is
|
|
75
|
-
* hobbled out-of-the-box. bypass gives a person-opened codex terminal parity
|
|
76
|
-
* with how Claude lanes run. Claude keeps 'default' — its permission modes don't
|
|
77
|
-
* gate network. Explicit modes (a person's pick, resume's persisted entry.mode,
|
|
78
|
-
* a flow lane) always override this. spawn_terminal separately pins every worker
|
|
79
|
-
* to bypassPermissions as part of the autonomous-worker contract.
|
|
80
|
-
*/
|
|
81
|
-
export function defaultModeForRuntime(runtime) {
|
|
82
|
-
return runtime === 'codex' ? 'bypassPermissions' : 'default'
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function codexWritableDirsForSandbox(sandbox, writableDirs = []) {
|
|
86
|
-
return normalizeCodexSandbox(sandbox) === 'read-only' ? [] : writableDirs
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function codexExtraWritableDirsForMode(mode, sandbox, writableDirs = []) {
|
|
90
|
-
if (mode === 'review') return []
|
|
91
|
-
return codexWritableDirsForSandbox(sandbox, writableDirs)
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export function readCodexDefaultModel({ home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), fsImpl = fs } = {}) {
|
|
95
|
-
try {
|
|
96
|
-
const text = fsImpl.readFileSync(path.join(home, 'config.toml'), 'utf8')
|
|
97
|
-
// Only the top-level model controls the default runtime. Stop at the first
|
|
98
|
-
// table so a profile/provider's nested `model` cannot impersonate it.
|
|
99
|
-
const top = text.split(/^\s*\[/m, 1)[0]
|
|
100
|
-
const m = top.match(/^\s*model\s*=\s*["']([^"']+)["']\s*(?:#.*)?$/m)
|
|
101
|
-
return m?.[1]?.trim() || null
|
|
102
|
-
} catch { return null }
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export function readCodexModels({ home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), fsImpl = fs } = {}) {
|
|
106
|
-
try {
|
|
107
|
-
const data = JSON.parse(fsImpl.readFileSync(path.join(home, 'models_cache.json'), 'utf8'))
|
|
108
|
-
return (Array.isArray(data?.models) ? data.models : [])
|
|
109
|
-
.filter((m) => m?.slug && m.visibility !== 'hide')
|
|
110
|
-
.sort((a, b) => (Number(a.priority) || 999) - (Number(b.priority) || 999))
|
|
111
|
-
.map((m) => ({ value: m.slug, displayName: m.display_name || m.slug, description: m.description || '' }))
|
|
112
|
-
} catch { return [] }
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const rolloutPathCache = new Map()
|
|
116
|
-
|
|
117
|
-
function codexRolloutPath(sessionId, { home, fsImpl }) {
|
|
118
|
-
if (!sessionId) return null
|
|
119
|
-
const cacheKey = `${home}\0${sessionId}`
|
|
120
|
-
const cached = rolloutPathCache.get(cacheKey)
|
|
121
|
-
if (cached) return cached
|
|
122
|
-
const root = path.join(home, 'sessions')
|
|
123
|
-
try {
|
|
124
|
-
const suffix = `${sessionId}.jsonl`
|
|
125
|
-
const hit = fsImpl.readdirSync(root, { recursive: true, withFileTypes: true })
|
|
126
|
-
.find((entry) => entry.isFile() && entry.name.endsWith(suffix))
|
|
127
|
-
if (!hit) return null
|
|
128
|
-
const found = path.join(hit.parentPath || hit.path || root, hit.name)
|
|
129
|
-
rolloutPathCache.set(cacheKey, found)
|
|
130
|
-
return found
|
|
131
|
-
} catch { return null }
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// A stopped App Server turn can publish its thread id before Codex has written
|
|
135
|
-
// even the session_meta row. If the bridge restarts in that window, both
|
|
136
|
-
// thread/resume and `codex exec resume` reject the zero-byte rollout. Missing
|
|
137
|
-
// files remain "unknown" (Codex may resolve them through another store), but a
|
|
138
|
-
// rollout we can prove is empty is never resumable.
|
|
139
|
-
export function codexThreadCanResume(sessionId, {
|
|
140
|
-
home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'),
|
|
141
|
-
fsImpl = fs,
|
|
142
|
-
} = {}) {
|
|
143
|
-
if (!sessionId) return false
|
|
144
|
-
const file = codexRolloutPath(sessionId, { home, fsImpl })
|
|
145
|
-
if (!file) return true
|
|
146
|
-
try { return Number(fsImpl.statSync(file).size) > 0 }
|
|
147
|
-
catch { return true }
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// `codex exec --json` exposes lifetime thread totals on `turn.completed`. Those
|
|
151
|
-
// totals are useful for billing, but they are NOT context occupancy: an agentic
|
|
152
|
-
// turn can make hundreds of cached model calls and process many context windows.
|
|
153
|
-
// The native rollout's final token_count record carries both the exact last
|
|
154
|
-
// request and Codex's usable window, so read only a bounded tail of that file.
|
|
155
|
-
export function readCodexThreadUsage(sessionId, {
|
|
156
|
-
home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'),
|
|
157
|
-
fsImpl = fs,
|
|
158
|
-
tailBytes = 4 * 1024 * 1024,
|
|
159
|
-
} = {}) {
|
|
160
|
-
const file = codexRolloutPath(sessionId, { home, fsImpl })
|
|
161
|
-
if (!file) return null
|
|
162
|
-
let fd
|
|
163
|
-
try {
|
|
164
|
-
const size = Number(fsImpl.statSync(file).size) || 0
|
|
165
|
-
const length = Math.min(size, Math.max(64 * 1024, tailBytes))
|
|
166
|
-
const buf = Buffer.alloc(length)
|
|
167
|
-
fd = fsImpl.openSync(file, 'r')
|
|
168
|
-
fsImpl.readSync(fd, buf, 0, length, size - length)
|
|
169
|
-
const lines = buf.toString('utf8').split('\n')
|
|
170
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
171
|
-
let row
|
|
172
|
-
try { row = JSON.parse(lines[i]) } catch { continue }
|
|
173
|
-
if (row?.payload?.type !== 'token_count' || !row.payload.info) continue
|
|
174
|
-
const info = row.payload.info
|
|
175
|
-
const last = info.last_token_usage || {}
|
|
176
|
-
const total = info.total_token_usage || {}
|
|
177
|
-
const used = Number(last.total_tokens)
|
|
178
|
-
const max = Number(info.model_context_window)
|
|
179
|
-
if (!Number.isFinite(used) || used < 0 || !Number.isFinite(max) || max <= 0) return null
|
|
180
|
-
return {
|
|
181
|
-
context: { used: Math.floor(used), max: Math.floor(max) },
|
|
182
|
-
total: {
|
|
183
|
-
input_tokens: Math.max(0, Number(total.input_tokens) || 0),
|
|
184
|
-
cached_input_tokens: Math.max(0, Number(total.cached_input_tokens) || 0),
|
|
185
|
-
output_tokens: Math.max(0, Number(total.output_tokens) || 0),
|
|
186
|
-
},
|
|
187
|
-
rateLimits: row.payload.rate_limits || null,
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
} catch { return null }
|
|
191
|
-
finally { if (fd != null) { try { fsImpl.closeSync(fd) } catch { /* noop */ } } }
|
|
192
|
-
return null
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// App Server publishes the authoritative current-window count during native
|
|
196
|
-
// compaction (and while turns run). Its field names differ from the rollout
|
|
197
|
-
// token_count rows, so normalize both sources to the one snapshot shape used by
|
|
198
|
-
// CodexEventMapper and the bridge context meter.
|
|
199
|
-
export function codexUsageSnapshotFromAppServer(tokenUsage) {
|
|
200
|
-
const used = Number(tokenUsage?.last?.totalTokens)
|
|
201
|
-
const max = Number(tokenUsage?.modelContextWindow)
|
|
202
|
-
if (!Number.isFinite(used) || used < 0 || !Number.isFinite(max) || max <= 0) return null
|
|
203
|
-
const total = tokenUsage?.total || {}
|
|
204
|
-
return {
|
|
205
|
-
context: { used: Math.floor(used), max: Math.floor(max) },
|
|
206
|
-
total: {
|
|
207
|
-
input_tokens: Math.max(0, Number(total.inputTokens) || 0),
|
|
208
|
-
cached_input_tokens: Math.max(0, Number(total.cachedInputTokens) || 0),
|
|
209
|
-
output_tokens: Math.max(0, Number(total.outputTokens) || 0),
|
|
210
|
-
},
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export function normalizeCodexSandbox(value) {
|
|
215
|
-
return SAFE_SANDBOXES.has(value) ? value : DEFAULT_SANDBOX
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
export function codexPublishAccess({ cwd, env = process.env, tmpdir = os.tmpdir(), execFile = execFileSync } = {}) {
|
|
219
|
-
const writableDirs = []
|
|
220
|
-
try {
|
|
221
|
-
const raw = String(execFile('git', ['rev-parse', '--git-common-dir'], { cwd, encoding: 'utf8' }) || '').trim()
|
|
222
|
-
if (raw) {
|
|
223
|
-
const commonDir = path.resolve(cwd, raw)
|
|
224
|
-
const workspace = path.resolve(cwd)
|
|
225
|
-
if (commonDir !== workspace && !commonDir.startsWith(workspace + path.sep)) writableDirs.push(commonDir)
|
|
226
|
-
}
|
|
227
|
-
} catch { /* non-git cwd: Codex already uses --skip-git-repo-check */ }
|
|
228
|
-
return {
|
|
229
|
-
writableDirs,
|
|
230
|
-
env: {
|
|
231
|
-
...env,
|
|
232
|
-
// Auth still comes read-only from ~/.npmrc; only cache writes move to a
|
|
233
|
-
// lane-safe temp location.
|
|
234
|
-
NPM_CONFIG_CACHE: path.join(tmpdir, 'thinkpool-npm-cache'),
|
|
235
|
-
},
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
export const CODEX_ROOM_CASCADE_REMINDER = [
|
|
240
|
-
'THINKPOOL ROOM ROLE ENFORCEMENT: obey the authoritative TERMINAL ROLE above. A person-authored request for a new, separate, independent, top-level, or main terminal uses open_main_terminal — NEVER spawn_terminal — even for ordinary work or a handoff. If the wording includes new or separate terminal, main-terminal routing wins. Use spawn_terminal only for agent-decided bounded worker slices or when the person explicitly asks for a worker, sub-terminal, Ensemble lane, or delegated slice. If open_main_terminal is unavailable, say so; never substitute an Ensemble child. Leaf, worker, Side, and managed Flow lanes work directly and must not delegate. Never use hidden in-process subagents.',
|
|
241
|
-
].join(' ')
|
|
242
|
-
|
|
243
|
-
export function buildCodexPrompt({ text, terminalRolePrompt, rolePrompt, roomContext, firstTurn = false, promptIndex = 0, forceFullReminder = false }) {
|
|
244
|
-
const context = typeof roomContext === 'function' ? roomContext() : roomContext
|
|
245
|
-
const fullReminder = usesFullThinkPoolReminder({ promptIndex, forceFull: forceFullReminder })
|
|
246
|
-
const additions = [
|
|
247
|
-
firstTurn ? CODEX_THINKPOOL_FIRST_TURN_PREAMBLE : '',
|
|
248
|
-
fullReminder ? terminalRolePrompt : '',
|
|
249
|
-
buildThinkPoolTurnGuidance({ text, promptIndex, forceFull: forceFullReminder }),
|
|
250
|
-
fullReminder ? (rolePrompt || CODEX_ROOM_CASCADE_REMINDER) : '',
|
|
251
|
-
context,
|
|
252
|
-
].map((v) => String(v || '').trim()).filter(Boolean)
|
|
253
|
-
if (!additions.length) return String(text ?? '')
|
|
254
|
-
return `<thinkpool-context>\n${additions.join('\n\n')}\n</thinkpool-context>\n\n${String(text ?? '')}`
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
export function buildCodexExecArgs({ sessionId, model, effort, sandbox, approvalPolicy = 'untrusted', providerConfig, mcpUrl, writableDirs = [], images = [], prompt }) {
|
|
258
|
-
const safeSandbox = normalizeCodexSandbox(sandbox)
|
|
259
|
-
const args = sessionId
|
|
260
|
-
// `codex exec resume` has no --sandbox option and does NOT retain the fresh
|
|
261
|
-
// exec's sandbox: without an explicit override Codex falls back to its
|
|
262
|
-
// workspace profile (network=false). That silently removes the loopback
|
|
263
|
-
// ThinkPool MCP and blocks git metadata/network on turn two. Reassert the
|
|
264
|
-
// selected posture on every resume. Codex exposes a dedicated resume flag
|
|
265
|
-
// for full access; restricted modes ride the equivalent config override.
|
|
266
|
-
? [
|
|
267
|
-
'exec', 'resume', '--json', '--skip-git-repo-check',
|
|
268
|
-
...(safeSandbox === 'danger-full-access'
|
|
269
|
-
? ['--dangerously-bypass-approvals-and-sandbox']
|
|
270
|
-
: ['-c', `sandbox_mode="${safeSandbox}"`]),
|
|
271
|
-
]
|
|
272
|
-
: ['exec', '--json', '--sandbox', safeSandbox, '--skip-git-repo-check']
|
|
273
|
-
if (model) args.push('-m', model)
|
|
274
|
-
args.push('-c', `approval_policy="${approvalPolicy}"`)
|
|
275
|
-
if (EFFORT_LEVELS.has(effort)) args.push('-c', `model_reasoning_effort="${effort}"`)
|
|
276
|
-
if (providerConfig) args.push('-c', providerConfig)
|
|
277
|
-
// `resume` retains the roots selected by the fresh rollout and does not
|
|
278
|
-
// accept --add-dir itself. Supply these only when creating the thread.
|
|
279
|
-
if (!sessionId) for (const dir of writableDirs) args.push('--add-dir', dir)
|
|
280
|
-
// Per-lane override only: never mutate the person's global Codex config.
|
|
281
|
-
if (mcpUrl) {
|
|
282
|
-
args.push('-c', `mcp_servers.thinkpool.url=${JSON.stringify(mcpUrl)}`)
|
|
283
|
-
// Room tools are part of the ThinkPool execution contract, not optional
|
|
284
|
-
// enrichment. A failed handshake must abort this invocation so Codex
|
|
285
|
-
// cannot continue after spawn_terminal vanished from its callable set.
|
|
286
|
-
args.push('-c', 'mcp_servers.thinkpool.required=true')
|
|
287
|
-
}
|
|
288
|
-
// Both fresh `exec` and `exec resume` accept repeatable --image flags. Only
|
|
289
|
-
// bridge-derived absolute paths reach this layer; relative values are ignored
|
|
290
|
-
// so a future caller cannot silently change the child's path interpretation.
|
|
291
|
-
let hasNativeImages = false
|
|
292
|
-
for (const imagePath of images) {
|
|
293
|
-
if (!path.isAbsolute(String(imagePath || ''))) continue
|
|
294
|
-
args.push('--image', String(imagePath))
|
|
295
|
-
hasNativeImages = true
|
|
296
|
-
}
|
|
297
|
-
// Codex CLI 0.144.1 defines --image as a variadic option. Without an option
|
|
298
|
-
// terminator it consumes the positional prompt (and, on resume, the thread
|
|
299
|
-
// id), then exits because stdin is intentionally /dev/null in the bridge.
|
|
300
|
-
if (hasNativeImages) args.push('--')
|
|
301
|
-
if (sessionId) args.push(sessionId)
|
|
302
|
-
args.push(String(prompt ?? ''))
|
|
303
|
-
return args
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// `codex exec` can fail before it has created or resumed a native turn when a
|
|
307
|
-
// required MCP client loses the App Server -> exec handoff race. Retrying is
|
|
308
|
-
// safe only when stderr proves session initialization failed AND the JSONL
|
|
309
|
-
// stream contains no turn/item activity. A silent process or a generic exit is
|
|
310
|
-
// delivery-uncertain and must never replay the person's prompt automatically.
|
|
311
|
-
export function codexExecFailedBeforeTurn({ stderr = '', eventTypes = [] } = {}) {
|
|
312
|
-
const types = Array.isArray(eventTypes) ? eventTypes.map((type) => String(type || '')) : []
|
|
313
|
-
if (types.some((type) => type && type !== 'thread.started')) return false
|
|
314
|
-
const message = String(stderr || '')
|
|
315
|
-
return /timed out handshaking with MCP server/i.test(message)
|
|
316
|
-
|| (/thread\/resume failed/i.test(message) && /failed to initialize session/i.test(message))
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
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
|
|
320
|
-
|
|
321
|
-
export function codexApprovalCard(method, params = {}, item = null) {
|
|
322
|
-
const id = String(params.approvalId || params.itemId || `${params.turnId || 'turn'}:${method}`)
|
|
323
|
-
if (method === 'item/tool/requestUserInput') {
|
|
324
|
-
const questions = Array.isArray(params.questions) ? params.questions : []
|
|
325
|
-
return {
|
|
326
|
-
id,
|
|
327
|
-
toolName: 'AskUserQuestion',
|
|
328
|
-
input: { questions },
|
|
329
|
-
risk: 'ask',
|
|
330
|
-
questions,
|
|
331
|
-
asker: 'Codex',
|
|
332
|
-
answerFormat: 'codex',
|
|
333
|
-
...(Number.isFinite(params.autoResolutionMs) && params.autoResolutionMs > 0
|
|
334
|
-
? { autoResolutionMs: params.autoResolutionMs }
|
|
335
|
-
: {}),
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
if (method === 'item/commandExecution/requestApproval') {
|
|
339
|
-
const command = params.command || item?.command || ''
|
|
340
|
-
return {
|
|
341
|
-
id,
|
|
342
|
-
toolName: 'Bash',
|
|
343
|
-
input: { command, cwd: params.cwd || item?.cwd || '', ...(params.reason ? { reason: params.reason } : {}) },
|
|
344
|
-
risk: CODEX_DESTRUCTIVE.test(command) ? 'high' : 'medium',
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
if (method === 'item/fileChange/requestApproval') {
|
|
348
|
-
const changes = Array.isArray(item?.changes) ? item.changes : []
|
|
349
|
-
const first = changes[0] || {}
|
|
350
|
-
const toolName = changes.length > 1 ? 'MultiEdit' : first.kind === 'add' ? 'Write' : 'Edit'
|
|
351
|
-
return {
|
|
352
|
-
id,
|
|
353
|
-
toolName,
|
|
354
|
-
input: changes.length > 1
|
|
355
|
-
? { edits: changes.map((change) => ({ file_path: change.path || '', path: change.path || '', kind: change.kind || 'update' })), ...(params.reason ? { reason: params.reason } : {}) }
|
|
356
|
-
: { file_path: first.path || params.grantRoot || '', path: first.path || params.grantRoot || '', kind: first.kind || 'update', ...(params.reason ? { reason: params.reason } : {}) },
|
|
357
|
-
risk: 'medium',
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
return null
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
export function codexApprovalResponse(decision) {
|
|
364
|
-
return { decision: decision === 'always' ? 'acceptForSession' : decision === 'allow' ? 'accept' : 'decline' }
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
export function codexUserInputResponse(result, questions = []) {
|
|
368
|
-
return questionAnswerResponse(result, questions)
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function appServerItemForMapper(item) {
|
|
372
|
-
if (!item || typeof item !== 'object') return item
|
|
373
|
-
const common = { ...item }
|
|
374
|
-
switch (item.type) {
|
|
375
|
-
case 'agentMessage': return { ...common, type: 'agent_message' }
|
|
376
|
-
case 'commandExecution': return { ...common, type: 'command_execution', aggregated_output: item.aggregatedOutput || '', exit_code: item.exitCode }
|
|
377
|
-
case 'fileChange': return { ...common, type: 'file_change' }
|
|
378
|
-
case 'webSearch': return { ...common, type: 'web_search' }
|
|
379
|
-
case 'mcpToolCall': return { ...common, type: 'mcp_tool_call', arguments: item.arguments || {}, result: item.result, error: item.error }
|
|
380
|
-
case 'reasoning': return { ...common, type: 'reasoning', text: [...(item.summary || []), ...(item.content || [])].join('\n') }
|
|
381
|
-
default: return common
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* Start a structured Codex session. Mirrors startClaudeSession's contract.
|
|
387
|
-
* @param {object} o
|
|
388
|
-
* @param {string} o.cwd work directory for the codex exec (a git repo)
|
|
389
|
-
* @param {string} [o.model] model id (e.g. 'gpt-5.6-sol'); passed via -m
|
|
390
|
-
* @param {string} [o.resume] captured Codex thread id to resume after bridge restart
|
|
391
|
-
* @param {object} [o.env] env for the child (e.g. CODEX_API_KEY for BYOK)
|
|
392
|
-
* @param {string} [o.sandbox] 'read-only' | 'workspace-write' | 'danger-full-access' (default workspace-write)
|
|
393
|
-
* @param {(evt)=>void} o.onEvent receives normalized room-events
|
|
394
|
-
* @param {(req)=>Promise<string>} [o.requestPermission] shared room permission-card callback
|
|
395
|
-
* @param {string} [o.providerConfig] optional -c overrides / provider block (M2: from the provider registry)
|
|
396
|
-
* @returns {{ sendTurn(text, options?), abort(), end(), readonly sessionId }}
|
|
397
|
-
*/
|
|
398
|
-
export function startCodexSession({ cwd, model, effort: initialEffort = 'high', resume, env, sandbox, mode = 'default', onEvent, providerConfig, terminalRolePrompt, rolePrompt, roomContext, mcpServers, prepareCwd = null, requestPermission, appServerGate = codexAppServerGate, appServerFactory = createCodexAppServer, mcpHttpFactory = startCodexMcpHttp, spawnImpl = spawn, admitStart = null, stallOptions = {} }) {
|
|
399
|
-
let activeMode = CODEX_MODE_CONFIG[mode] ? mode : 'default'
|
|
400
|
-
let modeConfig = codexConfigForMode(activeMode)
|
|
401
|
-
sandbox = normalizeCodexSandbox(sandbox || modeConfig.sandbox)
|
|
402
|
-
const publishAccess = codexPublishAccess({ cwd, env: { ...process.env, ...(env || {}) } })
|
|
403
|
-
const childEnv = publishAccess.env
|
|
404
|
-
const requestedResume = resume || null
|
|
405
|
-
const resumeUsable = !requestedResume || codexThreadCanResume(requestedResume, {
|
|
406
|
-
home: childEnv.CODEX_HOME || path.join(os.homedir(), '.codex'),
|
|
407
|
-
})
|
|
408
|
-
let sessionId = resumeUsable ? requestedResume : null
|
|
409
|
-
let turnNo = sessionId ? 1 : 0
|
|
410
|
-
let userPromptNo = 0
|
|
411
|
-
let forceFullReminder = true
|
|
412
|
-
const selectRoomContext = createRoomContextSelector(roomContext)
|
|
413
|
-
let child = null
|
|
414
|
-
let aborted = false
|
|
415
|
-
let ended = false
|
|
416
|
-
let turnActive = false
|
|
417
|
-
// Codex can accept a room turn and then emit nothing while App Server is
|
|
418
|
-
// booting or an upstream model call is retrying. The room's generic roster
|
|
419
|
-
// clock can label that lane "stalled", but only the owning transport can
|
|
420
|
-
// surface an honest in-pane status and safely interrupt/replay the exact
|
|
421
|
-
// logical prompt. Keep this lifecycle parallel to Claude's shared watchdog.
|
|
422
|
-
const stallNow = typeof stallOptions.now === 'function' ? stallOptions.now : Date.now
|
|
423
|
-
const stallSetInterval = typeof stallOptions.setInterval === 'function' ? stallOptions.setInterval : setInterval
|
|
424
|
-
const stallClearInterval = typeof stallOptions.clearInterval === 'function' ? stallOptions.clearInterval : clearInterval
|
|
425
|
-
const STALL_MS = Number.isFinite(stallOptions.stallMs)
|
|
426
|
-
? Math.max(1, Number(stallOptions.stallMs))
|
|
427
|
-
: Math.max(30000, parseInt(childEnv.TP_STALL_MS, 10) || 90000)
|
|
428
|
-
const FORCE_STOP_MS = Number.isFinite(stallOptions.forceStopMs)
|
|
429
|
-
? Math.max(STALL_MS, Number(stallOptions.forceStopMs))
|
|
430
|
-
: Math.max(STALL_MS * 3, parseInt(childEnv.TP_FORCE_STOP_MS, 10) || 300000)
|
|
431
|
-
const STALL_INTERVAL_MS = Number.isFinite(stallOptions.intervalMs)
|
|
432
|
-
? Math.max(1, Number(stallOptions.intervalMs))
|
|
433
|
-
: 5000
|
|
434
|
-
let lastEvtTs = stallNow()
|
|
435
|
-
let stalledSent = false
|
|
436
|
-
let stallRetried = false
|
|
437
|
-
let stallRetryRequested = false
|
|
438
|
-
let stallGiveupRequested = false
|
|
439
|
-
// True only while the current logical attempt is still in bridge-owned
|
|
440
|
-
// bootstrap. It flips before writing a turn/start request or spawning exec.
|
|
441
|
-
// Absence of provider output is not proof that a prompt was not delivered.
|
|
442
|
-
let stallReplaySafe = true
|
|
443
|
-
let awaitingUser = 0
|
|
444
|
-
let stallTimer = null
|
|
445
|
-
let activeModel = model || null
|
|
446
|
-
let activeEffort = EFFORT_LEVELS.has(initialEffort) ? initialEffort : 'high'
|
|
447
|
-
let latestUsageSnapshot = sessionId ? readCodexThreadUsage(sessionId) : null
|
|
448
|
-
let mcpHttpPromise = null
|
|
449
|
-
const appServerGateResult = appServerGate({ env: childEnv })
|
|
450
|
-
let appServerDisabled = !appServerGateResult.enabled
|
|
451
|
-
let appServer = null
|
|
452
|
-
let appServerThreadReady = false
|
|
453
|
-
let activeTurnId = null
|
|
454
|
-
let compactStartWaiter = null
|
|
455
|
-
let compactActive = false
|
|
456
|
-
// App Server notifications are asynchronous to turn/completed. In production a
|
|
457
|
-
// stopped turn emitted its durable `aborted` boundary, then delivered a queued
|
|
458
|
-
// item/started 89ms later and a Bash result 159.9s after that. Without a native
|
|
459
|
-
// turn fence those late rows resurrected the lane after Stop. Keep a bounded set
|
|
460
|
-
// of closed native turns and accept turn-scoped traffic only for the one active id.
|
|
461
|
-
const closedAppServerTurnIds = new Set()
|
|
462
|
-
const appServerItems = new Map()
|
|
463
|
-
const streamedAgentItems = new Map() // item id → { cid, text }
|
|
464
|
-
let steerChain = Promise.resolve()
|
|
465
|
-
// serialize turns: one exec at a time
|
|
466
|
-
let chain = Promise.resolve()
|
|
467
|
-
const queue = []
|
|
468
|
-
|
|
469
|
-
// Synthetic watchdog chrome must not count as provider activity, otherwise
|
|
470
|
-
// its 90s status would reset the silence clock and postpone the 5m recovery.
|
|
471
|
-
// Everything on the normal relay path is real transport/session activity and
|
|
472
|
-
// immediately clears the one-shot stalled banner.
|
|
473
|
-
const emitRaw = createCumulativeEventRelay((event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } })
|
|
474
|
-
const touchActivity = () => { lastEvtTs = stallNow(); stalledSent = false }
|
|
475
|
-
const relayEvent = (event) => { touchActivity(); emitRaw(event) }
|
|
476
|
-
relayEvent.cancel = () => emitRaw.cancel()
|
|
477
|
-
const mapper = new CodexEventMapper({
|
|
478
|
-
onEvent: relayEvent,
|
|
479
|
-
model: model || null,
|
|
480
|
-
usageSnapshotForSession: (id) => {
|
|
481
|
-
const snapshot = readCodexThreadUsage(id)
|
|
482
|
-
if (snapshot) latestUsageSnapshot = snapshot
|
|
483
|
-
return snapshot || latestUsageSnapshot
|
|
484
|
-
},
|
|
485
|
-
})
|
|
486
|
-
|
|
487
|
-
// The bridge samples `session.turnActive` synchronously inside onEvent to
|
|
488
|
-
// announce the roster's busy edge. Codex transports report their terminal
|
|
489
|
-
// boundary before their surrounding promise/process cleanup runs, so clearing
|
|
490
|
-
// turnActive only in `finally`/`close` publishes one last stale `busy: true`
|
|
491
|
-
// announce with no later event to correct it. A tab refocus re-announcement
|
|
492
|
-
// eventually healed the roster, but until then the room stayed on "thinking".
|
|
493
|
-
// Settle at the event boundary itself for every Codex transport.
|
|
494
|
-
const pushMappedEvent = (event) => {
|
|
495
|
-
if (event?.type === 'turn.completed' || event?.type === 'turn.failed') turnActive = false
|
|
496
|
-
mapper.push(event)
|
|
497
|
-
}
|
|
498
|
-
const emitTurnBoundary = (event) => {
|
|
499
|
-
turnActive = false
|
|
500
|
-
relayEvent(event)
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
const armTurnLiveness = ({ retry = false } = {}) => {
|
|
504
|
-
turnActive = true
|
|
505
|
-
lastEvtTs = stallNow()
|
|
506
|
-
stalledSent = false
|
|
507
|
-
stallRetryRequested = false
|
|
508
|
-
stallGiveupRequested = false
|
|
509
|
-
stallReplaySafe = true
|
|
510
|
-
if (!retry) stallRetried = false
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
const note = (text) => relayEvent({ kind: 'note', text })
|
|
514
|
-
|
|
515
|
-
const closeAppServerTurn = (turnId = activeTurnId) => {
|
|
516
|
-
const id = String(turnId || '')
|
|
517
|
-
if (!id) return
|
|
518
|
-
closedAppServerTurnIds.add(id)
|
|
519
|
-
while (closedAppServerTurnIds.size > 64) closedAppServerTurnIds.delete(closedAppServerTurnIds.values().next().value)
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
const appServerTurnIsCurrent = (params = {}) => {
|
|
523
|
-
const turnId = String(params.turnId || '')
|
|
524
|
-
return !!turnId && !ended && !aborted && !closedAppServerTurnIds.has(turnId) && turnId === String(activeTurnId || '')
|
|
525
|
-
}
|
|
526
|
-
if (requestedResume && !resumeUsable) {
|
|
527
|
-
queueMicrotask(() => note('The stopped Codex turn had no resumable context; continuing in a fresh thread.'))
|
|
528
|
-
}
|
|
529
|
-
if (!['0', 'false', 'off'].includes(String(childEnv.TP_CODEX_APP_SERVER || '').trim().toLowerCase()) && !appServerGateResult.enabled) {
|
|
530
|
-
queueMicrotask(() => note(`Codex App Server disabled: ${appServerGateResult.reason}`))
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
async function ensureMcpHttp() {
|
|
534
|
-
const thinkpool = mcpServers?.thinkpool
|
|
535
|
-
if (!thinkpool) return null
|
|
536
|
-
if (!mcpHttpPromise) mcpHttpPromise = mcpHttpFactory({ sdkServer: thinkpool })
|
|
537
|
-
return mcpHttpPromise
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
function appServerNotification(method, params = {}) {
|
|
541
|
-
if (method === 'thread/tokenUsage/updated') {
|
|
542
|
-
if (params.threadId && sessionId && String(params.threadId) !== String(sessionId)) return
|
|
543
|
-
const snapshot = codexUsageSnapshotFromAppServer(params.tokenUsage)
|
|
544
|
-
if (!snapshot) return
|
|
545
|
-
latestUsageSnapshot = snapshot
|
|
546
|
-
// Ordinary turns publish one settled meter at turn/completed. Native
|
|
547
|
-
// compaction is not an ordinary sendTurn, so this notification is its only
|
|
548
|
-
// immediate post-summary meter and must reach every connected room client.
|
|
549
|
-
if (compactActive) {
|
|
550
|
-
const { used, max } = snapshot.context
|
|
551
|
-
relayEvent({ kind: 'usage', ctx: { used, max, pct: Math.min(100, Math.max(0, Math.round((used / max) * 100))), over: used > max, model: activeModel } })
|
|
552
|
-
}
|
|
553
|
-
return
|
|
554
|
-
}
|
|
555
|
-
if (method === 'turn/started' && params.turn?.id) {
|
|
556
|
-
const turnId = String(params.turn.id)
|
|
557
|
-
if (ended || aborted) { closeAppServerTurn(turnId); return }
|
|
558
|
-
if (compactStartWaiter) {
|
|
559
|
-
const waiter = compactStartWaiter
|
|
560
|
-
compactStartWaiter = null
|
|
561
|
-
waiter.resolve(turnId)
|
|
562
|
-
return
|
|
563
|
-
}
|
|
564
|
-
if (!activeTurnId) activeTurnId = turnId
|
|
565
|
-
touchActivity()
|
|
566
|
-
return
|
|
567
|
-
}
|
|
568
|
-
// Every mapped App Server notification below is turn-scoped in the tested
|
|
569
|
-
// protocol and carries turnId. Fail closed on missing, stale, completed, or
|
|
570
|
-
// interrupted ids so a provider's buffered stdout cannot reopen a closed turn.
|
|
571
|
-
if ((method === 'turn/plan/updated' || method === 'item/agentMessage/delta' || method === 'item/started' || method === 'item/completed' || method === 'error') && !appServerTurnIsCurrent(params)) return
|
|
572
|
-
touchActivity()
|
|
573
|
-
if (method === 'turn/plan/updated') {
|
|
574
|
-
const todos = (Array.isArray(params.plan) ? params.plan : []).map(({ step, status }) => ({
|
|
575
|
-
content: step || '',
|
|
576
|
-
activeForm: step || '',
|
|
577
|
-
status: status === 'inProgress' ? 'in_progress' : status === 'completed' ? 'completed' : 'pending',
|
|
578
|
-
})).filter((todo) => todo.content)
|
|
579
|
-
relayEvent({ kind: 'assistant', blocks: [{ type: 'tool_use', id: `codex-plan:${params.turnId || 'active'}`, name: 'TodoWrite', input: { todos } }], parentToolUseId: null })
|
|
580
|
-
return
|
|
581
|
-
}
|
|
582
|
-
if (method === 'item/agentMessage/delta') {
|
|
583
|
-
if (!params.delta) return
|
|
584
|
-
const itemId = params.itemId || `turn:${params.turnId || activeTurnId || 'active'}`
|
|
585
|
-
const state = streamedAgentItems.get(itemId) || { cid: `codex-stream:${sessionId || 'thread'}:${itemId}`, text: '' }
|
|
586
|
-
state.text += String(params.delta)
|
|
587
|
-
streamedAgentItems.set(itemId, state)
|
|
588
|
-
// Cumulative transient snapshot. The bridge broadcasts this chrome event
|
|
589
|
-
// without assigning transcript seq/archive rows; room.jsx replaces the
|
|
590
|
-
// stable cid in place. item/completed emits one durable assistant row.
|
|
591
|
-
relayEvent({ kind: 'assistant_stream', cid: state.cid, text: state.text, itemId, streaming: true })
|
|
592
|
-
return
|
|
593
|
-
}
|
|
594
|
-
if (method === 'item/started' || method === 'item/completed') {
|
|
595
|
-
const raw = params.item
|
|
596
|
-
if (!raw) return
|
|
597
|
-
if (method === 'item/started') appServerItems.set(raw.id, raw)
|
|
598
|
-
if (method === 'item/completed') {
|
|
599
|
-
appServerItems.set(raw.id, raw)
|
|
600
|
-
if (raw.type === 'agentMessage' && streamedAgentItems.has(raw.id)) {
|
|
601
|
-
const state = streamedAgentItems.get(raw.id)
|
|
602
|
-
streamedAgentItems.delete(raw.id)
|
|
603
|
-
relayEvent({ kind: 'assistant', blocks: [{ type: 'text', text: raw.text || state.text }], parentToolUseId: null, replacesCid: state.cid })
|
|
604
|
-
return
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
pushMappedEvent({ type: method === 'item/started' ? 'item.started' : 'item.completed', item: appServerItemForMapper(raw) })
|
|
608
|
-
return
|
|
609
|
-
}
|
|
610
|
-
if (method === 'error') {
|
|
611
|
-
const message = params.error?.message || params.message || 'codex app-server error'
|
|
612
|
-
pushMappedEvent({ type: 'error', message })
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
async function appServerRequest(method, params = {}) {
|
|
617
|
-
// A request racing Stop must never raise a fresh room card after the turn's
|
|
618
|
-
// durable boundary. Respond with the protocol's ordinary denial shape so the
|
|
619
|
-
// native runtime can settle without reopening any ThinkPool state.
|
|
620
|
-
if (!appServerTurnIsCurrent(params)) {
|
|
621
|
-
if (method === 'item/tool/requestUserInput') return { answers: {} }
|
|
622
|
-
return { decision: 'decline' }
|
|
623
|
-
}
|
|
624
|
-
const card = codexApprovalCard(method, params, appServerItems.get(params.itemId))
|
|
625
|
-
if (!card) throw new Error(`Unsupported Codex App Server request: ${method}`)
|
|
626
|
-
// Missing/broken room permission plumbing denies safely. App Server expects
|
|
627
|
-
// an ordinary response even for denial; an RPC error can strand the turn.
|
|
628
|
-
let decision = 'deny'
|
|
629
|
-
awaitingUser++
|
|
630
|
-
try { decision = await requestPermission?.(card) } catch { /* deny */ }
|
|
631
|
-
finally {
|
|
632
|
-
awaitingUser = Math.max(0, awaitingUser - 1)
|
|
633
|
-
// Give the resumed model call a fresh watchdog window. Human think time
|
|
634
|
-
// is intentionally excluded and must not force-stop the turn next tick.
|
|
635
|
-
touchActivity()
|
|
636
|
-
}
|
|
637
|
-
if (method === 'item/tool/requestUserInput') return codexUserInputResponse(decision, card.questions)
|
|
638
|
-
return codexApprovalResponse(decision)
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
async function ensureAppServer() {
|
|
642
|
-
if (appServerDisabled) return false
|
|
643
|
-
if (appServer?.alive === false) {
|
|
644
|
-
appServerThreadReady = false
|
|
645
|
-
appServerDisabled = true
|
|
646
|
-
return false
|
|
647
|
-
}
|
|
648
|
-
if (appServer && appServerThreadReady) return true
|
|
649
|
-
let peer
|
|
650
|
-
try { peer = await ensureMcpHttp() }
|
|
651
|
-
catch (error) { note(`Thinkpool peer tools unavailable: ${error?.message || error}`) }
|
|
652
|
-
try {
|
|
653
|
-
if (!appServer) {
|
|
654
|
-
appServer = appServerFactory({
|
|
655
|
-
cwd,
|
|
656
|
-
env: childEnv,
|
|
657
|
-
args: buildCodexAppServerArgs({ providerConfig, mcpUrl: peer?.url, writableDirs: codexExtraWritableDirsForMode(activeMode, sandbox, publishAccess.writableDirs) }),
|
|
658
|
-
onNotification: appServerNotification,
|
|
659
|
-
onServerRequest: appServerRequest,
|
|
660
|
-
onClose: (error) => {
|
|
661
|
-
if (ended || appServerDisabled) return
|
|
662
|
-
appServerThreadReady = false
|
|
663
|
-
appServerDisabled = true
|
|
664
|
-
note(`Codex App Server closed; later turns will use codex exec: ${error?.message || error}`)
|
|
665
|
-
},
|
|
666
|
-
})
|
|
667
|
-
await appServer.start()
|
|
668
|
-
touchActivity()
|
|
669
|
-
}
|
|
670
|
-
sessionId = await appServer.startThread({
|
|
671
|
-
threadId: turnNo > 0 ? sessionId : null,
|
|
672
|
-
cwd,
|
|
673
|
-
model: activeModel,
|
|
674
|
-
sandbox,
|
|
675
|
-
approvalPolicy: modeConfig.approvalPolicy,
|
|
676
|
-
// Codex 0.144.1 gates request_user_input by INTERNAL collaboration
|
|
677
|
-
// mode. Enable it for Default mode on this thread without changing the
|
|
678
|
-
// independent ThinkPool sandbox / approval posture above.
|
|
679
|
-
config: { 'features.default_mode_request_user_input': true },
|
|
680
|
-
})
|
|
681
|
-
touchActivity()
|
|
682
|
-
// App Server reports thread/start before its per-thread MCP clients finish
|
|
683
|
-
// initializing. Starting the first turn in that gap snapshots a toolset
|
|
684
|
-
// without ThinkPool even though the server becomes ready milliseconds later.
|
|
685
|
-
// Hold the turn boundary until the room MCP is actually ready; a timeout or
|
|
686
|
-
// startup failure drops into the required-MCP codex exec fallback below.
|
|
687
|
-
if (peer?.url) {
|
|
688
|
-
await appServer.waitForMcpServer({ threadId: sessionId, name: 'thinkpool' })
|
|
689
|
-
touchActivity()
|
|
690
|
-
}
|
|
691
|
-
appServerThreadReady = true
|
|
692
|
-
pushMappedEvent({ type: 'thread.started', thread_id: sessionId })
|
|
693
|
-
return true
|
|
694
|
-
} catch {
|
|
695
|
-
// Fallback is safe only here: no turn/start request has been accepted, so
|
|
696
|
-
// there is no possibility of running the same prompt twice.
|
|
697
|
-
appServerDisabled = true
|
|
698
|
-
try { appServer?.end() } catch { /* noop */ }
|
|
699
|
-
appServer = null
|
|
700
|
-
appServerThreadReady = false
|
|
701
|
-
return false
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
function stopStalledTransport({ force = false } = {}) {
|
|
706
|
-
const server = appServer
|
|
707
|
-
const turnId = activeTurnId
|
|
708
|
-
|
|
709
|
-
if (server && force) {
|
|
710
|
-
// A second stall is terminal. End the resident process, not merely its
|
|
711
|
-
// turn: an interrupt whose completion notification is itself wedged would
|
|
712
|
-
// otherwise leave `chain` unresolved and silently queue every later send.
|
|
713
|
-
closeAppServerTurn(turnId)
|
|
714
|
-
appServerDisabled = true
|
|
715
|
-
appServerThreadReady = false
|
|
716
|
-
try { server.end() } catch { /* noop */ }
|
|
717
|
-
if (appServer === server) appServer = null
|
|
718
|
-
} else if (server && turnId) {
|
|
719
|
-
// Fence the old native id before interrupting so buffered deltas/tool rows
|
|
720
|
-
// cannot cross into the retried logical turn.
|
|
721
|
-
closeAppServerTurn(turnId)
|
|
722
|
-
Promise.resolve(server.interrupt({ threadId: sessionId, turnId })).catch(() => {
|
|
723
|
-
// An interrupt rejected before delivery: tear down the uncertain control
|
|
724
|
-
// channel. The current pump will use the exec fallback for its one retry.
|
|
725
|
-
appServerDisabled = true
|
|
726
|
-
appServerThreadReady = false
|
|
727
|
-
try { server.end() } catch { /* noop */ }
|
|
728
|
-
if (appServer === server) appServer = null
|
|
729
|
-
})
|
|
730
|
-
} else if (server) {
|
|
731
|
-
// The stall happened during cold App Server / MCP bootstrap, before a
|
|
732
|
-
// native turn id existed. Closing that process makes the pending startup
|
|
733
|
-
// reject; ensureAppServer then returns false and the same prompt gets its
|
|
734
|
-
// bounded retry through codex exec.
|
|
735
|
-
appServerDisabled = true
|
|
736
|
-
appServerThreadReady = false
|
|
737
|
-
try { server.end() } catch { /* noop */ }
|
|
738
|
-
if (appServer === server) appServer = null
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
if (child) {
|
|
742
|
-
const stalledChild = child
|
|
743
|
-
try { stalledChild.kill('SIGTERM') } catch { /* noop */ }
|
|
744
|
-
setTimeout(() => {
|
|
745
|
-
if (child === stalledChild) { try { stalledChild.kill('SIGKILL') } catch { /* noop */ } }
|
|
746
|
-
}, 1500)
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
stallTimer = stallSetInterval(() => {
|
|
751
|
-
const quietMs = stallNow() - lastEvtTs
|
|
752
|
-
const action = stallDecision({
|
|
753
|
-
turnActive,
|
|
754
|
-
awaitingUser,
|
|
755
|
-
quietMs,
|
|
756
|
-
stallMs: STALL_MS,
|
|
757
|
-
forceStopMs: FORCE_STOP_MS,
|
|
758
|
-
stalledSent,
|
|
759
|
-
stallRetried,
|
|
760
|
-
})
|
|
761
|
-
if (action === 'none') return
|
|
762
|
-
|
|
763
|
-
// Do not emit the generic "retrying the turn" copy when native delivery is
|
|
764
|
-
// already uncertain; that would promise the exact unsafe action we refuse.
|
|
765
|
-
if (action === 'retry' && !stallReplaySafe) {
|
|
766
|
-
stallGiveupRequested = true
|
|
767
|
-
stallRetryRequested = false
|
|
768
|
-
emitRaw({ kind: 'error', recoverable: true, message: 'Codex stopped responding after the turn began. The transport was stopped, but the prompt was not replayed automatically.' })
|
|
769
|
-
emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: undefined, denials: 0, resultText: null })
|
|
770
|
-
stopStalledTransport({ force: true })
|
|
771
|
-
return
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
const event = stallEvent(action, quietMs)
|
|
775
|
-
if (event) emitRaw(event)
|
|
776
|
-
if (action === 'status') {
|
|
777
|
-
stalledSent = true
|
|
778
|
-
return
|
|
779
|
-
}
|
|
780
|
-
if (action === 'retry') {
|
|
781
|
-
// Preserve the prompt and retry budget in pump(); do not use the public
|
|
782
|
-
// abort flag because that intentionally discards queued work and emits a
|
|
783
|
-
// terminal aborted boundary.
|
|
784
|
-
stallRetried = true
|
|
785
|
-
stallRetryRequested = true
|
|
786
|
-
lastEvtTs = stallNow()
|
|
787
|
-
stalledSent = false
|
|
788
|
-
stopStalledTransport()
|
|
789
|
-
return
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
// The retry also went silent. Surface the durable terminal boundary now so
|
|
793
|
-
// the lane cannot remain busy if the native interrupt itself is wedged.
|
|
794
|
-
stallGiveupRequested = true
|
|
795
|
-
stallRetryRequested = false
|
|
796
|
-
queue.length = 0
|
|
797
|
-
emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: undefined, denials: 0, resultText: null })
|
|
798
|
-
stopStalledTransport({ force: true })
|
|
799
|
-
}, STALL_INTERVAL_MS)
|
|
800
|
-
stallTimer.unref?.()
|
|
801
|
-
|
|
802
|
-
async function runAppServer(prompt, options = {}) {
|
|
803
|
-
// Stop can win before the queued pump enters native startup. Do not launch a
|
|
804
|
-
// fresh App Server merely to discover the accepted room turn was cancelled.
|
|
805
|
-
if (ended || aborted || stallGiveupRequested) return true
|
|
806
|
-
if (!await ensureAppServer()) return false
|
|
807
|
-
// Stop can land while the cold App Server / MCP bootstrap is still awaiting.
|
|
808
|
-
// The accepted turn was already closed at the room boundary; never start it
|
|
809
|
-
// after bootstrap finally resolves.
|
|
810
|
-
if (ended || aborted || stallGiveupRequested) return true
|
|
811
|
-
mapper.setUsageBaseline(sessionId ? readCodexThreadUsage(sessionId) : null)
|
|
812
|
-
turnActive = true
|
|
813
|
-
try {
|
|
814
|
-
// startTurn writes the prompt-bearing RPC. From this edge onward delivery
|
|
815
|
-
// is at least uncertain, even if the response or first event never lands.
|
|
816
|
-
stallReplaySafe = false
|
|
817
|
-
activeTurnId = await appServer.startTurn({
|
|
818
|
-
threadId: sessionId,
|
|
819
|
-
input: prompt,
|
|
820
|
-
images: Array.isArray(options.images) ? options.images : [],
|
|
821
|
-
model: activeModel,
|
|
822
|
-
effort: activeEffort,
|
|
823
|
-
approvalPolicy: modeConfig.approvalPolicy,
|
|
824
|
-
collaborationMode: codexDefaultCollaborationMode(activeModel, activeEffort),
|
|
825
|
-
})
|
|
826
|
-
touchActivity()
|
|
827
|
-
// Stop may land after the RPC was written but before startTurn returns its
|
|
828
|
-
// id. abort() already closed the room boundary; interrupt the now-known
|
|
829
|
-
// native turn before waiting, and never let it continue invisibly.
|
|
830
|
-
if (aborted || ended) {
|
|
831
|
-
closeAppServerTurn(activeTurnId)
|
|
832
|
-
try { await appServer.interrupt({ threadId: sessionId, turnId: activeTurnId }) } catch { /* boundary is already closed */ }
|
|
833
|
-
return true
|
|
834
|
-
}
|
|
835
|
-
const completed = await appServer.waitForTurn(activeTurnId)
|
|
836
|
-
closeAppServerTurn(completed?.turn?.id || activeTurnId)
|
|
837
|
-
const status = completed?.turn?.status
|
|
838
|
-
if (aborted) {
|
|
839
|
-
emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: completed?.turn?.durationMs, denials: 0, resultText: null })
|
|
840
|
-
} else if (status === 'interrupted' && (stallRetryRequested || stallGiveupRequested)) {
|
|
841
|
-
// The watchdog owns this lifecycle: retry is re-armed by pump(), while
|
|
842
|
-
// giveup already emitted the one terminal boundary synchronously.
|
|
843
|
-
} else if (status === 'interrupted') {
|
|
844
|
-
emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: completed?.turn?.durationMs, denials: 0, resultText: null })
|
|
845
|
-
} else if (status === 'failed') {
|
|
846
|
-
stallRetryRequested = false
|
|
847
|
-
pushMappedEvent({ type: 'turn.failed', message: completed?.turn?.error?.message || 'codex turn failed' })
|
|
848
|
-
} else {
|
|
849
|
-
stallRetryRequested = false
|
|
850
|
-
pushMappedEvent({ type: 'turn.completed' })
|
|
851
|
-
}
|
|
852
|
-
} catch (error) {
|
|
853
|
-
// Once a started turn's transport fails we must not replay that prompt via
|
|
854
|
-
// exec (the model may already have acted). Disable App Server for later
|
|
855
|
-
// turns only; they can safely resume this thread through the stable driver.
|
|
856
|
-
appServerDisabled = true
|
|
857
|
-
appServerThreadReady = false
|
|
858
|
-
closeAppServerTurn(activeTurnId)
|
|
859
|
-
try { appServer?.end() } catch { /* noop */ }
|
|
860
|
-
appServer = null
|
|
861
|
-
if (!ended && !stallRetryRequested && !stallGiveupRequested) pushMappedEvent({ type: 'turn.failed', message: `codex app-server turn failed: ${error?.message || error}` })
|
|
862
|
-
} finally {
|
|
863
|
-
// A transport failure or interrupt can land after prose deltas but before
|
|
864
|
-
// item/completed. Preserve that partial prose as one durable row rather
|
|
865
|
-
// than leaving a replay-less streaming shell behind.
|
|
866
|
-
for (const state of streamedAgentItems.values()) {
|
|
867
|
-
if (!state.text) continue
|
|
868
|
-
relayEvent({ kind: 'assistant', blocks: [{ type: 'text', text: state.text }], parentToolUseId: null, replacesCid: state.cid })
|
|
869
|
-
}
|
|
870
|
-
activeTurnId = null
|
|
871
|
-
turnActive = false
|
|
872
|
-
appServerItems.clear()
|
|
873
|
-
streamedAgentItems.clear()
|
|
874
|
-
}
|
|
875
|
-
return true
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
async function runAppServerReview(commandText) {
|
|
879
|
-
if (ended || aborted || stallGiveupRequested) return true
|
|
880
|
-
if (!await ensureAppServer()) return false
|
|
881
|
-
if (ended || aborted || stallGiveupRequested) return true
|
|
882
|
-
mapper.setUsageBaseline(sessionId ? readCodexThreadUsage(sessionId) : null)
|
|
883
|
-
turnActive = true
|
|
884
|
-
try {
|
|
885
|
-
stallReplaySafe = false
|
|
886
|
-
const started = await appServer.startReview({ threadId: sessionId, target: codexReviewTarget(commandText) })
|
|
887
|
-
activeTurnId = started?.turn?.id || started?.turnId
|
|
888
|
-
if (!activeTurnId) throw new Error('Codex review/start returned no turn id')
|
|
889
|
-
touchActivity()
|
|
890
|
-
if (aborted || ended) {
|
|
891
|
-
closeAppServerTurn(activeTurnId)
|
|
892
|
-
try { await appServer.interrupt({ threadId: sessionId, turnId: activeTurnId }) } catch { /* boundary is already closed */ }
|
|
893
|
-
return true
|
|
894
|
-
}
|
|
895
|
-
const completed = await appServer.waitForTurn(activeTurnId)
|
|
896
|
-
closeAppServerTurn(completed?.turn?.id || activeTurnId)
|
|
897
|
-
const status = completed?.turn?.status
|
|
898
|
-
if (aborted) {
|
|
899
|
-
emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: completed?.turn?.durationMs, denials: 0, resultText: null })
|
|
900
|
-
} else if (status === 'interrupted' && (stallRetryRequested || stallGiveupRequested)) {
|
|
901
|
-
// Watchdog-owned interruption; pump decides retry vs terminal giveup.
|
|
902
|
-
} else if (status === 'interrupted') {
|
|
903
|
-
emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: completed?.turn?.durationMs, denials: 0, resultText: null })
|
|
904
|
-
} else if (status === 'failed') {
|
|
905
|
-
stallRetryRequested = false
|
|
906
|
-
pushMappedEvent({ type: 'turn.failed', message: completed?.turn?.error?.message || 'codex review failed' })
|
|
907
|
-
} else {
|
|
908
|
-
stallRetryRequested = false
|
|
909
|
-
pushMappedEvent({ type: 'turn.completed' })
|
|
910
|
-
}
|
|
911
|
-
} catch (error) {
|
|
912
|
-
appServerDisabled = true
|
|
913
|
-
appServerThreadReady = false
|
|
914
|
-
closeAppServerTurn(activeTurnId)
|
|
915
|
-
try { appServer?.end() } catch { /* noop */ }
|
|
916
|
-
appServer = null
|
|
917
|
-
if (!ended && !stallRetryRequested && !stallGiveupRequested) pushMappedEvent({ type: 'turn.failed', message: `codex review failed: ${error?.message || error}` })
|
|
918
|
-
} finally {
|
|
919
|
-
for (const state of streamedAgentItems.values()) {
|
|
920
|
-
if (!state.text) continue
|
|
921
|
-
relayEvent({ kind: 'assistant', blocks: [{ type: 'text', text: state.text }], parentToolUseId: null, replacesCid: state.cid })
|
|
922
|
-
}
|
|
923
|
-
activeTurnId = null
|
|
924
|
-
turnActive = false
|
|
925
|
-
appServerItems.clear()
|
|
926
|
-
streamedAgentItems.clear()
|
|
927
|
-
}
|
|
928
|
-
return true
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
async function runExec(prompt, options = {}) {
|
|
932
|
-
if (ended || aborted || stallGiveupRequested) return
|
|
933
|
-
// First turn: fresh exec. Later turns: resume THIS lane's captured thread.
|
|
934
|
-
// `--last` is process-global and can cross-wire two concurrently-active Codex
|
|
935
|
-
// lanes, so it is never safe in a multi-lane bridge.
|
|
936
|
-
let peer
|
|
937
|
-
try { peer = await ensureMcpHttp() }
|
|
938
|
-
catch (e) { note(`Thinkpool peer tools unavailable: ${e?.message || e}`) }
|
|
939
|
-
// Detach any App Server-owned MCP session before exec initializes its own.
|
|
940
|
-
// Cleanup of the old transport is deliberately asynchronous so a wedged
|
|
941
|
-
// close cannot consume Codex's 30-second required-MCP handshake window.
|
|
942
|
-
try { await peer?.handoff?.() }
|
|
943
|
-
catch (e) { note(`Thinkpool peer handoff failed: ${e?.message || e}`) }
|
|
944
|
-
const args = buildCodexExecArgs({
|
|
945
|
-
sessionId: turnNo === 0 ? null : sessionId,
|
|
946
|
-
model: activeModel,
|
|
947
|
-
effort: activeEffort,
|
|
948
|
-
sandbox,
|
|
949
|
-
approvalPolicy: modeConfig.approvalPolicy,
|
|
950
|
-
providerConfig,
|
|
951
|
-
mcpUrl: peer?.url,
|
|
952
|
-
writableDirs: codexExtraWritableDirsForMode(activeMode, sandbox, publishAccess.writableDirs),
|
|
953
|
-
images: Array.isArray(options.images) ? options.images : [],
|
|
954
|
-
prompt,
|
|
955
|
-
})
|
|
956
|
-
|
|
957
|
-
// Snapshot lifetime totals before the turn. The mapper subtracts this from
|
|
958
|
-
// the post-turn totals so result/accounting events contain this turn only.
|
|
959
|
-
mapper.setUsageBaseline(sessionId ? readCodexThreadUsage(sessionId) : null)
|
|
960
|
-
|
|
961
|
-
return new Promise((resolve) => {
|
|
962
|
-
// stdin: /dev/null (spike gotcha — codex blocks on stdin otherwise)
|
|
963
|
-
let stdinFd
|
|
964
|
-
try { stdinFd = fs.openSync('/dev/null', 'r') } catch { /* will inherit; best effort */ }
|
|
965
|
-
const opts = { cwd, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] }
|
|
966
|
-
if (stdinFd != null) opts.stdio = [stdinFd, 'pipe', 'pipe']
|
|
967
|
-
|
|
968
|
-
turnActive = true
|
|
969
|
-
stallReplaySafe = false
|
|
970
|
-
child = spawnImpl('codex', args, opts)
|
|
971
|
-
if (stdinFd != null) { try { fs.closeSync(stdinFd) } catch { /* child owns its duplicated fd */ } }
|
|
972
|
-
|
|
973
|
-
const nativeEventTypes = []
|
|
974
|
-
const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity })
|
|
975
|
-
rl.on('line', (line) => {
|
|
976
|
-
if (!line.trim()) return
|
|
977
|
-
let ev
|
|
978
|
-
try { ev = JSON.parse(line) } catch { return /* non-JSON progress line */
|
|
979
|
-
}
|
|
980
|
-
nativeEventTypes.push(ev.type)
|
|
981
|
-
if (ev.type === 'thread.started' && ev.thread_id) sessionId = sessionId || ev.thread_id
|
|
982
|
-
if (ev.type === 'turn.completed' || ev.type === 'turn.failed') {
|
|
983
|
-
sawTerminalResult = true
|
|
984
|
-
stallRetryRequested = false
|
|
985
|
-
}
|
|
986
|
-
pushMappedEvent(ev)
|
|
987
|
-
})
|
|
988
|
-
// stderr is codex's human progress channel; surface tail on failure only.
|
|
989
|
-
let stderrTail = ''
|
|
990
|
-
let sawTerminalResult = false
|
|
991
|
-
let settled = false
|
|
992
|
-
child.stderr.on('data', (d) => { stderrTail += d.toString(); if (stderrTail.length > 800) stderrTail = stderrTail.slice(-800) })
|
|
993
|
-
|
|
994
|
-
const finish = (code) => {
|
|
995
|
-
if (settled) return
|
|
996
|
-
settled = true
|
|
997
|
-
child = null
|
|
998
|
-
turnActive = false
|
|
999
|
-
const failureMessage = `codex exec exited ${code}${stderrTail ? ': ' + stderrTail.trim().slice(-300) : ''}`
|
|
1000
|
-
const preTurnInitFailure = code !== 0 && code != null && !sawTerminalResult && codexExecFailedBeforeTurn({
|
|
1001
|
-
stderr: stderrTail,
|
|
1002
|
-
eventTypes: nativeEventTypes,
|
|
1003
|
-
})
|
|
1004
|
-
if (preTurnInitFailure && !aborted && !stallGiveupRequested) {
|
|
1005
|
-
resolve({ preTurnInitFailure: true, message: failureMessage })
|
|
1006
|
-
return
|
|
1007
|
-
} else if ((stallRetryRequested || stallGiveupRequested) && !sawTerminalResult && !aborted) {
|
|
1008
|
-
// Watchdog interruption: pump either replays once or has already
|
|
1009
|
-
// published the terminal giveup boundary.
|
|
1010
|
-
} else if (aborted && !sawTerminalResult) {
|
|
1011
|
-
emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: undefined, denials: 0, resultText: null })
|
|
1012
|
-
} else if (!aborted && !sawTerminalResult && code !== 0 && code != null) {
|
|
1013
|
-
emitTurnBoundary({ kind: 'error', message: failureMessage, recoverable: true })
|
|
1014
|
-
}
|
|
1015
|
-
resolve({ preTurnInitFailure: false })
|
|
1016
|
-
}
|
|
1017
|
-
child.on('error', (e) => {
|
|
1018
|
-
if (settled) return
|
|
1019
|
-
settled = true
|
|
1020
|
-
child = null
|
|
1021
|
-
if (!stallRetryRequested && !stallGiveupRequested) emitTurnBoundary({ kind: 'error', message: `codex failed to start: ${e.message}`, recoverable: true })
|
|
1022
|
-
resolve({ preTurnInitFailure: false })
|
|
1023
|
-
})
|
|
1024
|
-
child.on('close', finish)
|
|
1025
|
-
})
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
function pump() {
|
|
1029
|
-
if (ended) return
|
|
1030
|
-
const next = queue.shift()
|
|
1031
|
-
if (!next) return
|
|
1032
|
-
chain = chain.then(async () => {
|
|
1033
|
-
armTurnLiveness()
|
|
1034
|
-
// runExec sees turnNo===0 on the FIRST turn (fresh exec); increment only
|
|
1035
|
-
// after, so subsequent turns resume this lane's captured thread id.
|
|
1036
|
-
// Claude receives these through the Agent SDK's system-reminder path.
|
|
1037
|
-
// Codex is one-shot per turn, so give it the same lane identity and live
|
|
1038
|
-
// room awareness in a compact envelope before the person's text.
|
|
1039
|
-
const fullReminder = usesFullThinkPoolReminder({ promptIndex: next.promptIndex, forceFull: next.forceFullReminder })
|
|
1040
|
-
const prompt = buildCodexPrompt({
|
|
1041
|
-
text: next.text,
|
|
1042
|
-
terminalRolePrompt,
|
|
1043
|
-
rolePrompt,
|
|
1044
|
-
roomContext: () => selectRoomContext({ force: fullReminder }),
|
|
1045
|
-
firstTurn: turnNo === 0,
|
|
1046
|
-
promptIndex: next.promptIndex,
|
|
1047
|
-
forceFullReminder: next.forceFullReminder,
|
|
1048
|
-
})
|
|
1049
|
-
const review = /^\s*\/review(?:\s|$)/i.test(String(next.text || ''))
|
|
1050
|
-
let retryAttempt = false
|
|
1051
|
-
let execInitRetryUsed = false
|
|
1052
|
-
while (!ended && !aborted && !stallGiveupRequested) {
|
|
1053
|
-
const usedAppServer = review
|
|
1054
|
-
? await runAppServerReview(next.text)
|
|
1055
|
-
: await runAppServer(prompt, next.options)
|
|
1056
|
-
if (!usedAppServer) {
|
|
1057
|
-
if (review) {
|
|
1058
|
-
stallRetryRequested = false
|
|
1059
|
-
emitTurnBoundary({ kind: 'error', message: 'Codex review is unavailable without the tested App Server runtime.', recoverable: true })
|
|
1060
|
-
} else {
|
|
1061
|
-
// A watchdog interruption during cold App Server startup has no
|
|
1062
|
-
// native turn to wait on. Treat the exec fallback itself as the one
|
|
1063
|
-
// retry rather than accidentally granting a third attempt.
|
|
1064
|
-
if (stallRetryRequested) {
|
|
1065
|
-
stallRetryRequested = false
|
|
1066
|
-
retryAttempt = true
|
|
1067
|
-
armTurnLiveness({ retry: true })
|
|
1068
|
-
emitRaw({ kind: 'note', text: 'retrying the stalled turn on a fresh connection' })
|
|
1069
|
-
}
|
|
1070
|
-
const outcome = await runExec(prompt, next.options)
|
|
1071
|
-
if (outcome?.preTurnInitFailure) {
|
|
1072
|
-
if (!execInitRetryUsed && !ended && !aborted && !stallGiveupRequested) {
|
|
1073
|
-
execInitRetryUsed = true
|
|
1074
|
-
armTurnLiveness({ retry: true })
|
|
1075
|
-
emitRaw({ kind: 'note', text: 'Codex initialization failed before the turn started; retrying once with a fresh ThinkPool connection.' })
|
|
1076
|
-
continue
|
|
1077
|
-
}
|
|
1078
|
-
emitTurnBoundary({ kind: 'error', message: outcome.message, recoverable: true })
|
|
1079
|
-
}
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
1082
|
-
if (stallRetryRequested && !retryAttempt && !stallGiveupRequested && !ended && !aborted) {
|
|
1083
|
-
stallRetryRequested = false
|
|
1084
|
-
retryAttempt = true
|
|
1085
|
-
armTurnLiveness({ retry: true })
|
|
1086
|
-
emitRaw({ kind: 'note', text: 'retrying the stalled turn on a fresh connection' })
|
|
1087
|
-
continue
|
|
1088
|
-
}
|
|
1089
|
-
break
|
|
1090
|
-
}
|
|
1091
|
-
turnNo++
|
|
1092
|
-
pump()
|
|
1093
|
-
})
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
return {
|
|
1097
|
-
get sessionId() { return sessionId },
|
|
1098
|
-
get usageSnapshot() { return latestUsageSnapshot },
|
|
1099
|
-
get turnActive() { return turnActive || compactActive },
|
|
1100
|
-
get canSteer() { return !!(appServer && appServer.alive !== false && appServerThreadReady && !appServerDisabled) },
|
|
1101
|
-
get started() { return turnNo > 0 || turnActive },
|
|
1102
|
-
sendTurn(text, options = {}) {
|
|
1103
|
-
if (ended) return false
|
|
1104
|
-
// App Server stays resident between turns. Only gate when an idle turn
|
|
1105
|
-
// would have to create a new host process (fresh/resumed cold lane or
|
|
1106
|
-
// exec fallback after App Server failure). Steering an existing process
|
|
1107
|
-
// and reading a restored transcript remain available under pressure.
|
|
1108
|
-
if (!turnActive && queue.length === 0 && !appServerThreadReady && !child) {
|
|
1109
|
-
const gate = typeof admitStart === 'function' ? admitStart() : { ok: true }
|
|
1110
|
-
if (gate?.ok === false) {
|
|
1111
|
-
relayEvent({ kind: 'error', message: gate.reason || 'Host memory is critically low. This Codex runtime was not started.', recoverable: true })
|
|
1112
|
-
return false
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
const promptIndex = userPromptNo++
|
|
1116
|
-
const thisTurnForceFull = forceFullReminder
|
|
1117
|
-
forceFullReminder = isCodexCompactCommand(text) || /^\s*\/(?:reset|clear)\b/i.test(String(text || ''))
|
|
1118
|
-
if (!turnActive && turnNo === 0 && prepareCwd) {
|
|
1119
|
-
try { cwd = prepareCwd() || cwd } catch { /* keep original cwd */ }
|
|
1120
|
-
}
|
|
1121
|
-
if (turnActive && appServer && activeTurnId) {
|
|
1122
|
-
// A delivered human steer is new work for the active model call; give it
|
|
1123
|
-
// a fresh quiet window before declaring the transport stalled.
|
|
1124
|
-
touchActivity()
|
|
1125
|
-
const fullReminder = usesFullThinkPoolReminder({ promptIndex, forceFull: thisTurnForceFull })
|
|
1126
|
-
const prompt = buildCodexPrompt({
|
|
1127
|
-
text,
|
|
1128
|
-
terminalRolePrompt,
|
|
1129
|
-
rolePrompt,
|
|
1130
|
-
roomContext: () => selectRoomContext({ force: fullReminder }),
|
|
1131
|
-
promptIndex,
|
|
1132
|
-
forceFullReminder: thisTurnForceFull,
|
|
1133
|
-
})
|
|
1134
|
-
const targetTurnId = activeTurnId
|
|
1135
|
-
// Serialize authored follow-ups: parallel RPCs can complete out of order.
|
|
1136
|
-
steerChain = steerChain.then(() => appServer.steer({ threadId: sessionId, turnId: targetTurnId, input: prompt, images: Array.isArray(options.images) ? options.images : [] }))
|
|
1137
|
-
.catch((error) => {
|
|
1138
|
-
// Retry only after an explicit server rejection or a synchronous
|
|
1139
|
-
// pre-write failure. Timeout/close after write is delivery-uncertain;
|
|
1140
|
-
// replaying it as a new turn can execute the same instruction twice.
|
|
1141
|
-
if (error?.delivery === 'rejected' || error?.delivery === 'not_sent') {
|
|
1142
|
-
queue.push({ text, options, promptIndex, forceFullReminder: thisTurnForceFull })
|
|
1143
|
-
if (!turnActive && queue.length === 1) pump()
|
|
1144
|
-
} else {
|
|
1145
|
-
relayEvent({ kind: 'error', message: 'Codex steering delivery is uncertain; the message was not replayed automatically.', recoverable: true })
|
|
1146
|
-
}
|
|
1147
|
-
})
|
|
1148
|
-
return true
|
|
1149
|
-
}
|
|
1150
|
-
queue.push({ text, options, promptIndex, forceFullReminder: thisTurnForceFull })
|
|
1151
|
-
// Match Claude and Hermes: accepting an idle turn claims the lane before
|
|
1152
|
-
// any cold App Server, MCP, or exec bootstrap. bridge.mjs announces
|
|
1153
|
-
// synchronously after sendTurn(), so this is the edge that makes Thinking,
|
|
1154
|
-
// Stop, and the tab spinner appear at dispatch time rather than at the
|
|
1155
|
-
// first provider event many seconds later.
|
|
1156
|
-
// Native compaction already owns that public busy edge. A human turn
|
|
1157
|
-
// accepted during it stays queued without flipping the ordinary-turn
|
|
1158
|
-
// latch; otherwise compactContext's post-bootstrap recheck mistakes the
|
|
1159
|
-
// queued turn for an active one and abandons a compaction it already owns.
|
|
1160
|
-
if (!turnActive && !compactActive) {
|
|
1161
|
-
aborted = false
|
|
1162
|
-
armTurnLiveness()
|
|
1163
|
-
}
|
|
1164
|
-
// start the pump if idle
|
|
1165
|
-
if (queue.length === 1 && !compactActive) pump()
|
|
1166
|
-
return true
|
|
1167
|
-
},
|
|
1168
|
-
abort() {
|
|
1169
|
-
const activeChain = chain
|
|
1170
|
-
const bootstrapOnly = turnActive && !activeTurnId && !child
|
|
1171
|
-
aborted = true
|
|
1172
|
-
stallRetryRequested = false
|
|
1173
|
-
stallGiveupRequested = false
|
|
1174
|
-
queue.length = 0
|
|
1175
|
-
closeAppServerTurn(activeTurnId)
|
|
1176
|
-
let interrupt = Promise.resolve()
|
|
1177
|
-
if (appServer && activeTurnId) interrupt = appServer.interrupt({ threadId: sessionId, turnId: activeTurnId }).catch(() => {})
|
|
1178
|
-
if (child) { try { child.kill('SIGTERM') } catch { /* noop */ } ; setTimeout(() => { try { child?.kill('SIGKILL') } catch { /* noop */ } }, 1500) }
|
|
1179
|
-
else if (bootstrapOnly) {
|
|
1180
|
-
// No native turn exists to produce a boundary. Close the optimistic
|
|
1181
|
-
// accepted turn exactly once and let runAppServer's post-bootstrap
|
|
1182
|
-
// aborted check discard the delayed startup.
|
|
1183
|
-
emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 0, durationMs: undefined, denials: 0, resultText: null })
|
|
1184
|
-
}
|
|
1185
|
-
// `code-turn` may already be queued behind Stop in Realtime. Resolve only
|
|
1186
|
-
// after the interrupted native turn's chain has fully unwound; accepting a
|
|
1187
|
-
// new turn earlier lets the old finally/result close the new lifecycle.
|
|
1188
|
-
return interrupt.then(() => activeChain).catch(() => activeChain).then(() => {})
|
|
1189
|
-
},
|
|
1190
|
-
end() {
|
|
1191
|
-
ended = true
|
|
1192
|
-
turnActive = false
|
|
1193
|
-
if (stallTimer) stallClearInterval(stallTimer)
|
|
1194
|
-
relayEvent.cancel()
|
|
1195
|
-
queue.length = 0
|
|
1196
|
-
closeAppServerTurn(activeTurnId)
|
|
1197
|
-
if (child) { try { child.kill() } catch { /* noop */ } }
|
|
1198
|
-
try { appServer?.end() } catch { /* noop */ }
|
|
1199
|
-
void mcpHttpPromise?.then((h) => h.close()).catch(() => {})
|
|
1200
|
-
},
|
|
1201
|
-
setModel(nextModel) {
|
|
1202
|
-
if (!nextModel || turnActive) return false
|
|
1203
|
-
activeModel = String(nextModel)
|
|
1204
|
-
mapper.setModel(activeModel)
|
|
1205
|
-
return true
|
|
1206
|
-
},
|
|
1207
|
-
setMode(nextMode) {
|
|
1208
|
-
if (turnActive || !CODEX_MODE_CONFIG[nextMode]) return false
|
|
1209
|
-
activeMode = nextMode
|
|
1210
|
-
modeConfig = codexConfigForMode(activeMode)
|
|
1211
|
-
sandbox = modeConfig.sandbox
|
|
1212
|
-
// A mode change deliberately starts a fresh native thread so no prior
|
|
1213
|
-
// rollout can carry stale permission state; ThinkPool keeps the lane log.
|
|
1214
|
-
sessionId = null
|
|
1215
|
-
turnNo = 0
|
|
1216
|
-
latestUsageSnapshot = null
|
|
1217
|
-
appServerThreadReady = false
|
|
1218
|
-
return true
|
|
1219
|
-
},
|
|
1220
|
-
setEffort(nextEffort) {
|
|
1221
|
-
if (turnActive || !EFFORT_LEVELS.has(nextEffort)) return false
|
|
1222
|
-
activeEffort = nextEffort
|
|
1223
|
-
relayEvent({ kind: 'effort', level: activeEffort })
|
|
1224
|
-
return true
|
|
1225
|
-
},
|
|
1226
|
-
clearContext() {
|
|
1227
|
-
if (turnActive) return false
|
|
1228
|
-
sessionId = null
|
|
1229
|
-
turnNo = 0
|
|
1230
|
-
latestUsageSnapshot = null
|
|
1231
|
-
appServerThreadReady = false
|
|
1232
|
-
return true
|
|
1233
|
-
},
|
|
1234
|
-
async compactContext() {
|
|
1235
|
-
if (turnActive || compactActive) return false
|
|
1236
|
-
// A Stop latch belongs to the turn it cancelled. sendTurn clears it when
|
|
1237
|
-
// accepting new work, but /compact enters through this direct control path.
|
|
1238
|
-
// Leaving it set makes appServerNotification discard the real
|
|
1239
|
-
// turn/started event, then the five-second waiter falsely declares failure
|
|
1240
|
-
// while Codex continues compacting the old thread in the background.
|
|
1241
|
-
aborted = false
|
|
1242
|
-
compactActive = true
|
|
1243
|
-
let startTimer = null
|
|
1244
|
-
let compactAccepted = false
|
|
1245
|
-
try {
|
|
1246
|
-
const readyAppServer = await ensureAppServer()
|
|
1247
|
-
// compactActive claims the lane before bootstrap, so sendTurn queues
|
|
1248
|
-
// behind us. An independently active ordinary turn still wins safely.
|
|
1249
|
-
if (!readyAppServer || turnActive) return false
|
|
1250
|
-
const compactTurn = new Promise((resolve, reject) => {
|
|
1251
|
-
startTimer = setTimeout(() => {
|
|
1252
|
-
compactStartWaiter = null
|
|
1253
|
-
reject(new Error('Codex did not start the compaction turn'))
|
|
1254
|
-
}, 5000)
|
|
1255
|
-
compactStartWaiter = { resolve, reject }
|
|
1256
|
-
})
|
|
1257
|
-
await appServer.compact({ threadId: sessionId })
|
|
1258
|
-
compactAccepted = true
|
|
1259
|
-
const turnId = await compactTurn
|
|
1260
|
-
if (startTimer) clearTimeout(startTimer)
|
|
1261
|
-
const completed = await appServer.waitForTurn(turnId)
|
|
1262
|
-
closeAppServerTurn(completed?.turn?.id || turnId)
|
|
1263
|
-
const status = completed?.turn?.status
|
|
1264
|
-
if (status === 'completed') return true
|
|
1265
|
-
throw new Error(completed?.turn?.error?.message || `compaction ${status || 'failed'}`)
|
|
1266
|
-
} catch (error) {
|
|
1267
|
-
// Once thread/compact/start was accepted, replaying via a bounded-recap
|
|
1268
|
-
// reset is unsafe: the native compaction may still complete (the exact
|
|
1269
|
-
// production failure this guard closes). Preserve the existing thread
|
|
1270
|
-
// and report uncertainty instead of executing a second compaction path.
|
|
1271
|
-
if (compactAccepted || !['rejected', 'not_sent'].includes(error?.delivery)) {
|
|
1272
|
-
note(`Native Codex compaction status is uncertain; existing context was preserved: ${error?.message || error}`)
|
|
1273
|
-
return null
|
|
1274
|
-
}
|
|
1275
|
-
note(`Native Codex compaction unavailable; using bounded recap fallback: ${error?.message || error}`)
|
|
1276
|
-
return false
|
|
1277
|
-
} finally {
|
|
1278
|
-
if (startTimer) clearTimeout(startTimer)
|
|
1279
|
-
compactStartWaiter = null
|
|
1280
|
-
compactActive = false
|
|
1281
|
-
// A human turn accepted while compaction owned the App Server is queued,
|
|
1282
|
-
// not started concurrently. Hand it to the ordinary serialized pump now.
|
|
1283
|
-
if (queue.length) pump()
|
|
1284
|
-
}
|
|
1285
|
-
},
|
|
1286
|
-
async accountUsage() {
|
|
1287
|
-
if (!await ensureAppServer()) return null
|
|
1288
|
-
const [usage, limits] = await Promise.all([
|
|
1289
|
-
appServer.accountUsage().catch(() => null),
|
|
1290
|
-
appServer.accountRateLimits().catch(() => null),
|
|
1291
|
-
])
|
|
1292
|
-
return { usage, limits }
|
|
1293
|
-
},
|
|
1294
|
-
}
|
|
1295
|
-
}
|
|
1
|
+
import{execFileSync as e,spawn as t}from"node:child_process";import r from"node:fs";import n from"node:os";import o from"node:path";import s from"node:readline";import{buildCodexAppServerArgs as a,codexAppServerGate as i,createCodexAppServer as l}from"./codex-app-server.mjs";import{CodexEventMapper as u}from"./codex-event-mapper.mjs";import{startCodexMcpHttp as d}from"./codex-mcp-http.mjs";import{CODEX_THINKPOOL_FIRST_TURN_PREAMBLE as c,buildThinkPoolTurnGuidance as p,createRoomContextSelector as m,usesFullThinkPoolReminder as f}from"./thinkpool-room-prompt.mjs";import{questionAnswerResponse as h}from"./question-response.mjs";import{codexReviewTarget as x,isCodexCompactCommand as g}from"./codex-commands.mjs";import{createCumulativeEventRelay as y}from"./cumulative-event-relay.mjs";import{stallDecision as v,stallEvent as b}from"./turn-stall.mjs";const w=new Set(["read-only","workspace-write","danger-full-access"]);export const EFFORT_LEVELS=new Set(["low","medium","high","xhigh","max"]);export const CODEX_MODE_CONFIG={default:{sandbox:"workspace-write",approvalPolicy:"untrusted"},acceptEdits:{sandbox:"workspace-write",approvalPolicy:"on-request"},plan:{sandbox:"read-only",approvalPolicy:"never"},review:{sandbox:"read-only",approvalPolicy:"never"},bypassPermissions:{sandbox:"danger-full-access",approvalPolicy:"never"}};export function codexConfigForMode(e){return CODEX_MODE_CONFIG[e]||CODEX_MODE_CONFIG.default}export function codexDefaultCollaborationMode(e,t){return e?{mode:"default",settings:{model:String(e),reasoning_effort:EFFORT_LEVELS.has(t)?t:null,developer_instructions:null}}:null}export function defaultModeForRuntime(e){return"codex"===e?"bypassPermissions":"default"}export function codexWritableDirsForSandbox(e,t=[]){return"read-only"===normalizeCodexSandbox(e)?[]:t}export function codexExtraWritableDirsForMode(e,t,r=[]){return"review"===e?[]:codexWritableDirsForSandbox(t,r)}export function readCodexDefaultModel({home:e=process.env.CODEX_HOME||o.join(n.homedir(),".codex"),fsImpl:t=r}={}){try{const r=t.readFileSync(o.join(e,"config.toml"),"utf8").split(/^\s*\[/m,1)[0].match(/^\s*model\s*=\s*["']([^"']+)["']\s*(?:#.*)?$/m);return r?.[1]?.trim()||null}catch{return null}}export function readCodexModels({home:e=process.env.CODEX_HOME||o.join(n.homedir(),".codex"),fsImpl:t=r}={}){try{const r=JSON.parse(t.readFileSync(o.join(e,"models_cache.json"),"utf8"));return(Array.isArray(r?.models)?r.models:[]).filter(e=>e?.slug&&"hide"!==e.visibility).sort((e,t)=>(Number(e.priority)||999)-(Number(t.priority)||999)).map(e=>({value:e.slug,displayName:e.display_name||e.slug,description:e.description||""}))}catch{return[]}}const k=new Map;function I(e,{home:t,fsImpl:r}){if(!e)return null;const n=`${t}\0${e}`,s=k.get(n);if(s)return s;const a=o.join(t,"sessions");try{const t=`${e}.jsonl`,s=r.readdirSync(a,{recursive:!0,withFileTypes:!0}).find(e=>e.isFile()&&e.name.endsWith(t));if(!s)return null;const i=o.join(s.parentPath||s.path||a,s.name);return k.set(n,i),i}catch{return null}}export function codexThreadCanResume(e,{home:t=process.env.CODEX_HOME||o.join(n.homedir(),".codex"),fsImpl:s=r}={}){if(!e)return!1;const a=I(e,{home:t,fsImpl:s});if(!a)return!0;try{return Number(s.statSync(a).size)>0}catch{return!0}}export function readCodexThreadUsage(e,{home:t=process.env.CODEX_HOME||o.join(n.homedir(),".codex"),fsImpl:s=r,tailBytes:a=4194304}={}){const i=I(e,{home:t,fsImpl:s});if(!i)return null;let l;try{const e=Number(s.statSync(i).size)||0,t=Math.min(e,Math.max(65536,a)),r=Buffer.alloc(t);l=s.openSync(i,"r"),s.readSync(l,r,0,t,e-t);const n=r.toString("utf8").split("\n");for(let e=n.length-1;e>=0;e--){let t;try{t=JSON.parse(n[e])}catch{continue}if("token_count"!==t?.payload?.type||!t.payload.info)continue;const r=t.payload.info,o=r.last_token_usage||{},s=r.total_token_usage||{},a=Number(o.total_tokens),i=Number(r.model_context_window);return!Number.isFinite(a)||a<0||!Number.isFinite(i)||i<=0?null:{context:{used:Math.floor(a),max:Math.floor(i)},total:{input_tokens:Math.max(0,Number(s.input_tokens)||0),cached_input_tokens:Math.max(0,Number(s.cached_input_tokens)||0),output_tokens:Math.max(0,Number(s.output_tokens)||0)},rateLimits:t.payload.rate_limits||null}}}catch{return null}finally{if(null!=l)try{s.closeSync(l)}catch{}}return null}export function codexUsageSnapshotFromAppServer(e){const t=Number(e?.last?.totalTokens),r=Number(e?.modelContextWindow);if(!Number.isFinite(t)||t<0||!Number.isFinite(r)||r<=0)return null;const n=e?.total||{};return{context:{used:Math.floor(t),max:Math.floor(r)},total:{input_tokens:Math.max(0,Number(n.inputTokens)||0),cached_input_tokens:Math.max(0,Number(n.cachedInputTokens)||0),output_tokens:Math.max(0,Number(n.outputTokens)||0)}}}export function normalizeCodexSandbox(e){return w.has(e)?e:"workspace-write"}export function codexPublishAccess({cwd:t,env:r=process.env,tmpdir:s=n.tmpdir(),execFile:a=e}={}){const i=[];try{const e=String(a("git",["rev-parse","--git-common-dir"],{cwd:t,encoding:"utf8"})||"").trim();if(e){const r=o.resolve(t,e),n=o.resolve(t);r===n||r.startsWith(n+o.sep)||i.push(r)}}catch{}return{writableDirs:i,env:{...r,NPM_CONFIG_CACHE:o.join(s,"thinkpool-npm-cache")}}}export const CODEX_ROOM_CASCADE_REMINDER=["THINKPOOL ROOM ROLE ENFORCEMENT: obey the authoritative TERMINAL ROLE above. A person-authored request for a new, separate, independent, top-level, or main terminal uses open_main_terminal — NEVER spawn_terminal — even for ordinary work or a handoff. If the wording includes new or separate terminal, main-terminal routing wins. Use spawn_terminal only for agent-decided bounded worker slices or when the person explicitly asks for a worker, sub-terminal, Ensemble lane, or delegated slice. If open_main_terminal is unavailable, say so; never substitute an Ensemble child. Leaf, worker, Side, and managed Flow lanes work directly and must not delegate. Never use hidden in-process subagents."].join(" ");export function buildCodexPrompt({text:e,terminalRolePrompt:t,rolePrompt:r,roomContext:n,firstTurn:o=!1,promptIndex:s=0,forceFullReminder:a=!1}){const i="function"==typeof n?n():n,l=f({promptIndex:s,forceFull:a}),u=[o?c:"",l?t:"",p({text:e,promptIndex:s,forceFull:a}),l?r||CODEX_ROOM_CASCADE_REMINDER:"",i].map(e=>String(e||"").trim()).filter(Boolean);return u.length?`<thinkpool-context>\n${u.join("\n\n")}\n</thinkpool-context>\n\n${String(e??"")}`:String(e??"")}export function buildCodexExecArgs({sessionId:e,model:t,effort:r,sandbox:n,approvalPolicy:s="untrusted",providerConfig:a,mcpUrl:i,writableDirs:l=[],images:u=[],prompt:d}){const c=normalizeCodexSandbox(n),p=e?["exec","resume","--json","--skip-git-repo-check",..."danger-full-access"===c?["--dangerously-bypass-approvals-and-sandbox"]:["-c",`sandbox_mode="${c}"`]]:["exec","--json","--sandbox",c,"--skip-git-repo-check"];if(t&&p.push("-m",t),p.push("-c",`approval_policy="${s}"`),EFFORT_LEVELS.has(r)&&p.push("-c",`model_reasoning_effort="${r}"`),a&&p.push("-c",a),!e)for(const e of l)p.push("--add-dir",e);i&&(p.push("-c",`mcp_servers.thinkpool.url=${JSON.stringify(i)}`),p.push("-c","mcp_servers.thinkpool.required=true"));let m=!1;for(const e of u)o.isAbsolute(String(e||""))&&(p.push("--image",String(e)),m=!0);return m&&p.push("--"),e&&p.push(e),p.push(String(d??"")),p}export function codexExecFailedBeforeTurn({stderr:e="",eventTypes:t=[]}={}){if((Array.isArray(t)?t.map(e=>String(e||"")):[]).some(e=>e&&"thread.started"!==e))return!1;const r=String(e||"");return/timed out handshaking with MCP server/i.test(r)||/thread\/resume failed/i.test(r)&&/failed to initialize session/i.test(r)}const _=/\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;export function codexApprovalCard(e,t={},r=null){const n=String(t.approvalId||t.itemId||`${t.turnId||"turn"}:${e}`);if("item/tool/requestUserInput"===e){const e=Array.isArray(t.questions)?t.questions:[];return{id:n,toolName:"AskUserQuestion",input:{questions:e},risk:"ask",questions:e,asker:"Codex",answerFormat:"codex",...Number.isFinite(t.autoResolutionMs)&&t.autoResolutionMs>0?{autoResolutionMs:t.autoResolutionMs}:{}}}if("item/commandExecution/requestApproval"===e){const e=t.command||r?.command||"";return{id:n,toolName:"Bash",input:{command:e,cwd:t.cwd||r?.cwd||"",...t.reason?{reason:t.reason}:{}},risk:_.test(e)?"high":"medium"}}if("item/fileChange/requestApproval"===e){const e=Array.isArray(r?.changes)?r.changes:[],o=e[0]||{};return{id:n,toolName:e.length>1?"MultiEdit":"add"===o.kind?"Write":"Edit",input:e.length>1?{edits:e.map(e=>({file_path:e.path||"",path:e.path||"",kind:e.kind||"update"})),...t.reason?{reason:t.reason}:{}}:{file_path:o.path||t.grantRoot||"",path:o.path||t.grantRoot||"",kind:o.kind||"update",...t.reason?{reason:t.reason}:{}},risk:"medium"}}return null}export function codexApprovalResponse(e){return{decision:"always"===e?"acceptForSession":"allow"===e?"accept":"decline"}}export function codexUserInputResponse(e,t=[]){return h(e,t)}function M(e){if(!e||"object"!=typeof e)return e;const t={...e};switch(e.type){case"agentMessage":return{...t,type:"agent_message"};case"commandExecution":return{...t,type:"command_execution",aggregated_output:e.aggregatedOutput||"",exit_code:e.exitCode};case"fileChange":return{...t,type:"file_change"};case"webSearch":return{...t,type:"web_search"};case"mcpToolCall":return{...t,type:"mcp_tool_call",arguments:e.arguments||{},result:e.result,error:e.error};case"reasoning":return{...t,type:"reasoning",text:[...e.summary||[],...e.content||[]].join("\n")};default:return t}}export function startCodexSession({cwd:e,model:c,effort:p="high",resume:h,env:w,sandbox:k,mode:I="default",onEvent:_,providerConfig:S,terminalRolePrompt:C,rolePrompt:E,roomContext:T,mcpServers:F,prepareCwd:O=null,requestPermission:R,appServerGate:N=i,appServerFactory:P=l,mcpHttpFactory:A=d,spawnImpl:U=t,admitStart:D=null,stallOptions:$={}}){let j=CODEX_MODE_CONFIG[I]?I:"default",L=codexConfigForMode(j);k=normalizeCodexSandbox(k||L.sandbox);const q=codexPublishAccess({cwd:e,env:{...process.env,...w||{}}}),X=q.env,G=h||null,z=!G||codexThreadCanResume(G,{home:X.CODEX_HOME||o.join(n.homedir(),".codex")});let W=z?G:null,B=W?1:0,H=0,V=!0;const J=m(T);let K=null,Q=!1,Y=!1,Z=!1;const ee="function"==typeof $.now?$.now:Date.now,te="function"==typeof $.setInterval?$.setInterval:setInterval,re="function"==typeof $.clearInterval?$.clearInterval:clearInterval,ne=Number.isFinite($.stallMs)?Math.max(1,Number($.stallMs)):Math.max(3e4,parseInt(X.TP_STALL_MS,10)||9e4),oe=Number.isFinite($.forceStopMs)?Math.max(ne,Number($.forceStopMs)):Math.max(3*ne,parseInt(X.TP_FORCE_STOP_MS,10)||3e5),se=Number.isFinite($.intervalMs)?Math.max(1,Number($.intervalMs)):5e3;let ae=ee(),ie=!1,le=!1,ue=!1,de=!1,ce=!0,pe=0,me=null,fe=c||null,he=EFFORT_LEVELS.has(p)?p:"high",xe=W?readCodexThreadUsage(W):null,ge=null;const ye=N({env:X});let ve=!ye.enabled,be=null,we=!1,ke=null,Ie=null,_e=!1;const Me=new Set,Se=new Map,Ce=new Map;let Ee=Promise.resolve(),Te=Promise.resolve();const Fe=[],Oe=y(e=>{try{_?.(e)}catch{}}),Re=()=>{ae=ee(),ie=!1},Ne=e=>{Re(),Oe(e)};Ne.cancel=()=>Oe.cancel();const Pe=new u({onEvent:Ne,model:c||null,usageSnapshotForSession:e=>{const t=readCodexThreadUsage(e);return t&&(xe=t),t||xe}}),Ae=e=>{"turn.completed"!==e?.type&&"turn.failed"!==e?.type||(Z=!1),Pe.push(e)},Ue=e=>{Z=!1,Ne(e)},De=({retry:e=!1}={})=>{Z=!0,ae=ee(),ie=!1,ue=!1,de=!1,ce=!0,e||(le=!1)},$e=e=>Ne({kind:"note",text:e}),je=(e=ke)=>{const t=String(e||"");if(t)for(Me.add(t);Me.size>64;)Me.delete(Me.values().next().value)},Le=(e={})=>{const t=String(e.turnId||"");return!(!t||Y||Q||Me.has(t)||t!==String(ke||""))};async function qe(){const e=F?.thinkpool;return e?(ge||(ge=A({sdkServer:e})),ge):null}function Xe(e,t={}){if("thread/tokenUsage/updated"===e){if(t.threadId&&W&&String(t.threadId)!==String(W))return;const e=codexUsageSnapshotFromAppServer(t.tokenUsage);if(!e)return;if(xe=e,_e){const{used:t,max:r}=e.context;Ne({kind:"usage",ctx:{used:t,max:r,pct:Math.min(100,Math.max(0,Math.round(t/r*100))),over:t>r,model:fe}})}return}if("turn/started"===e&&t.turn?.id){const e=String(t.turn.id);if(Y||Q)return void je(e);if(Ie){const t=Ie;return Ie=null,void t.resolve(e)}return ke||(ke=e),void Re()}if("turn/plan/updated"!==e&&"item/agentMessage/delta"!==e&&"item/started"!==e&&"item/completed"!==e&&"error"!==e||Le(t)){if(Re(),"turn/plan/updated"===e){const e=(Array.isArray(t.plan)?t.plan:[]).map(({step:e,status:t})=>({content:e||"",activeForm:e||"",status:"inProgress"===t?"in_progress":"completed"===t?"completed":"pending"})).filter(e=>e.content);return void Ne({kind:"assistant",blocks:[{type:"tool_use",id:`codex-plan:${t.turnId||"active"}`,name:"TodoWrite",input:{todos:e}}],parentToolUseId:null})}if("item/agentMessage/delta"===e){if(!t.delta)return;const e=t.itemId||`turn:${t.turnId||ke||"active"}`,r=Ce.get(e)||{cid:`codex-stream:${W||"thread"}:${e}`,text:""};return r.text+=String(t.delta),Ce.set(e,r),void Ne({kind:"assistant_stream",cid:r.cid,text:r.text,itemId:e,streaming:!0})}if("item/started"===e||"item/completed"===e){const r=t.item;if(!r)return;if("item/started"===e&&Se.set(r.id,r),"item/completed"===e&&(Se.set(r.id,r),"agentMessage"===r.type&&Ce.has(r.id))){const e=Ce.get(r.id);return Ce.delete(r.id),void Ne({kind:"assistant",blocks:[{type:"text",text:r.text||e.text}],parentToolUseId:null,replacesCid:e.cid})}return void Ae({type:"item/started"===e?"item.started":"item.completed",item:M(r)})}if("error"===e){const e=t.error?.message||t.message||"codex app-server error";Ae({type:"error",message:e})}}}async function Ge(e,t={}){if(!Le(t))return"item/tool/requestUserInput"===e?{answers:{}}:{decision:"decline"};const r=codexApprovalCard(e,t,Se.get(t.itemId));if(!r)throw new Error(`Unsupported Codex App Server request: ${e}`);let n="deny";pe++;try{n=await(R?.(r))}catch{}finally{pe=Math.max(0,pe-1),Re()}return"item/tool/requestUserInput"===e?codexUserInputResponse(n,r.questions):codexApprovalResponse(n)}async function ze(){if(ve)return!1;if(!1===be?.alive)return we=!1,ve=!0,!1;if(be&&we)return!0;let t;try{t=await qe()}catch(e){$e(`Thinkpool peer tools unavailable: ${e?.message||e}`)}try{return be||(be=P({cwd:e,env:X,args:a({providerConfig:S,mcpUrl:t?.url,writableDirs:codexExtraWritableDirsForMode(j,k,q.writableDirs)}),onNotification:Xe,onServerRequest:Ge,onClose:e=>{Y||ve||(we=!1,ve=!0,$e(`Codex App Server closed; later turns will use codex exec: ${e?.message||e}`))}}),await be.start(),Re()),W=await be.startThread({threadId:B>0?W:null,cwd:e,model:fe,sandbox:k,approvalPolicy:L.approvalPolicy,config:{"features.default_mode_request_user_input":!0}}),Re(),t?.url&&(await be.waitForMcpServer({threadId:W,name:"thinkpool"}),Re()),we=!0,Ae({type:"thread.started",thread_id:W}),!0}catch{ve=!0;try{be?.end()}catch{}return be=null,we=!1,!1}}function We({force:e=!1}={}){const t=be,r=ke;if(t&&e){je(r),ve=!0,we=!1;try{t.end()}catch{}be===t&&(be=null)}else if(t&&r)je(r),Promise.resolve(t.interrupt({threadId:W,turnId:r})).catch(()=>{ve=!0,we=!1;try{t.end()}catch{}be===t&&(be=null)});else if(t){ve=!0,we=!1;try{t.end()}catch{}be===t&&(be=null)}if(K){const e=K;try{e.kill("SIGTERM")}catch{}setTimeout(()=>{if(K===e)try{e.kill("SIGKILL")}catch{}},1500)}}async function Be(e,t={}){if(Y||Q||de)return!0;if(!await ze())return!1;if(Y||Q||de)return!0;Pe.setUsageBaseline(W?readCodexThreadUsage(W):null),Z=!0;try{if(ce=!1,ke=await be.startTurn({threadId:W,input:e,images:Array.isArray(t.images)?t.images:[],model:fe,effort:he,approvalPolicy:L.approvalPolicy,collaborationMode:codexDefaultCollaborationMode(fe,he)}),Re(),Q||Y){je(ke);try{await be.interrupt({threadId:W,turnId:ke})}catch{}return!0}const r=await be.waitForTurn(ke);je(r?.turn?.id||ke);const n=r?.turn?.status;Q?Ue({kind:"result",subtype:"aborted",sessionId:W,model:fe,costUsd:null,usage:null,numTurns:1,durationMs:r?.turn?.durationMs,denials:0,resultText:null}):"interrupted"===n&&(ue||de)||("interrupted"===n?Ue({kind:"result",subtype:"aborted",sessionId:W,model:fe,costUsd:null,usage:null,numTurns:1,durationMs:r?.turn?.durationMs,denials:0,resultText:null}):"failed"===n?(ue=!1,Ae({type:"turn.failed",message:r?.turn?.error?.message||"codex turn failed"})):(ue=!1,Ae({type:"turn.completed"})))}catch(e){ve=!0,we=!1,je(ke);try{be?.end()}catch{}be=null,Y||ue||de||Ae({type:"turn.failed",message:`codex app-server turn failed: ${e?.message||e}`})}finally{for(const e of Ce.values())e.text&&Ne({kind:"assistant",blocks:[{type:"text",text:e.text}],parentToolUseId:null,replacesCid:e.cid});ke=null,Z=!1,Se.clear(),Ce.clear()}return!0}async function He(e){if(Y||Q||de)return!0;if(!await ze())return!1;if(Y||Q||de)return!0;Pe.setUsageBaseline(W?readCodexThreadUsage(W):null),Z=!0;try{ce=!1;const t=await be.startReview({threadId:W,target:x(e)});if(ke=t?.turn?.id||t?.turnId,!ke)throw new Error("Codex review/start returned no turn id");if(Re(),Q||Y){je(ke);try{await be.interrupt({threadId:W,turnId:ke})}catch{}return!0}const r=await be.waitForTurn(ke);je(r?.turn?.id||ke);const n=r?.turn?.status;Q?Ue({kind:"result",subtype:"aborted",sessionId:W,model:fe,costUsd:null,usage:null,numTurns:1,durationMs:r?.turn?.durationMs,denials:0,resultText:null}):"interrupted"===n&&(ue||de)||("interrupted"===n?Ue({kind:"result",subtype:"aborted",sessionId:W,model:fe,costUsd:null,usage:null,numTurns:1,durationMs:r?.turn?.durationMs,denials:0,resultText:null}):"failed"===n?(ue=!1,Ae({type:"turn.failed",message:r?.turn?.error?.message||"codex review failed"})):(ue=!1,Ae({type:"turn.completed"})))}catch(e){ve=!0,we=!1,je(ke);try{be?.end()}catch{}be=null,Y||ue||de||Ae({type:"turn.failed",message:`codex review failed: ${e?.message||e}`})}finally{for(const e of Ce.values())e.text&&Ne({kind:"assistant",blocks:[{type:"text",text:e.text}],parentToolUseId:null,replacesCid:e.cid});ke=null,Z=!1,Se.clear(),Ce.clear()}return!0}async function Ve(t,n={}){if(Y||Q||de)return;let o;try{o=await qe()}catch(e){$e(`Thinkpool peer tools unavailable: ${e?.message||e}`)}try{await(o?.handoff?.())}catch(e){$e(`Thinkpool peer handoff failed: ${e?.message||e}`)}const a=buildCodexExecArgs({sessionId:0===B?null:W,model:fe,effort:he,sandbox:k,approvalPolicy:L.approvalPolicy,providerConfig:S,mcpUrl:o?.url,writableDirs:codexExtraWritableDirsForMode(j,k,q.writableDirs),images:Array.isArray(n.images)?n.images:[],prompt:t});return Pe.setUsageBaseline(W?readCodexThreadUsage(W):null),new Promise(t=>{let n;try{n=r.openSync("/dev/null","r")}catch{}const o={cwd:e,env:X,stdio:["ignore","pipe","pipe"]};if(null!=n&&(o.stdio=[n,"pipe","pipe"]),Z=!0,ce=!1,K=U("codex",a,o),null!=n)try{r.closeSync(n)}catch{}const i=[];s.createInterface({input:K.stdout,crlfDelay:1/0}).on("line",e=>{if(!e.trim())return;let t;try{t=JSON.parse(e)}catch{return}i.push(t.type),"thread.started"===t.type&&t.thread_id&&(W=W||t.thread_id),"turn.completed"!==t.type&&"turn.failed"!==t.type||(u=!0,ue=!1),Ae(t)});let l="",u=!1,d=!1;K.stderr.on("data",e=>{l+=e.toString(),l.length>800&&(l=l.slice(-800))}),K.on("error",e=>{d||(d=!0,K=null,ue||de||Ue({kind:"error",message:`codex failed to start: ${e.message}`,recoverable:!0}),t({preTurnInitFailure:!1}))}),K.on("close",e=>{if(d)return;d=!0,K=null,Z=!1;const r=`codex exec exited ${e}${l?": "+l.trim().slice(-300):""}`;0===e||null==e||u||!codexExecFailedBeforeTurn({stderr:l,eventTypes:i})||Q||de?((!ue&&!de||u||Q)&&(Q&&!u?Ue({kind:"result",subtype:"aborted",sessionId:W,model:fe,costUsd:null,usage:null,numTurns:1,durationMs:void 0,denials:0,resultText:null}):Q||u||0===e||null==e||Ue({kind:"error",message:r,recoverable:!0})),t({preTurnInitFailure:!1})):t({preTurnInitFailure:!0,message:r})})})}function Je(){if(Y)return;const e=Fe.shift();e&&(Te=Te.then(async()=>{De();const t=f({promptIndex:e.promptIndex,forceFull:e.forceFullReminder}),r=buildCodexPrompt({text:e.text,terminalRolePrompt:C,rolePrompt:E,roomContext:()=>J({force:t}),firstTurn:0===B,promptIndex:e.promptIndex,forceFullReminder:e.forceFullReminder}),n=/^\s*\/review(?:\s|$)/i.test(String(e.text||""));let o=!1,s=!1;for(;!Y&&!Q&&!de;){if(!(n?await He(e.text):await Be(r,e.options)))if(n)ue=!1,Ue({kind:"error",message:"Codex review is unavailable without the tested App Server runtime.",recoverable:!0});else{ue&&(ue=!1,o=!0,De({retry:!0}),Oe({kind:"note",text:"retrying the stalled turn on a fresh connection"}));const t=await Ve(r,e.options);if(t?.preTurnInitFailure){if(!(s||Y||Q||de)){s=!0,De({retry:!0}),Oe({kind:"note",text:"Codex initialization failed before the turn started; retrying once with a fresh ThinkPool connection."});continue}Ue({kind:"error",message:t.message,recoverable:!0})}}if(!ue||o||de||Y||Q)break;ue=!1,o=!0,De({retry:!0}),Oe({kind:"note",text:"retrying the stalled turn on a fresh connection"})}B++,Je()}))}return G&&!z&&queueMicrotask(()=>$e("The stopped Codex turn had no resumable context; continuing in a fresh thread.")),["0","false","off"].includes(String(X.TP_CODEX_APP_SERVER||"").trim().toLowerCase())||ye.enabled||queueMicrotask(()=>$e(`Codex App Server disabled: ${ye.reason}`)),me=te(()=>{const e=ee()-ae,t=v({turnActive:Z,awaitingUser:pe,quietMs:e,stallMs:ne,forceStopMs:oe,stalledSent:ie,stallRetried:le});if("none"===t)return;if("retry"===t&&!ce)return de=!0,ue=!1,Oe({kind:"error",recoverable:!0,message:"Codex stopped responding after the turn began. The transport was stopped, but the prompt was not replayed automatically."}),Ue({kind:"result",subtype:"aborted",sessionId:W,model:fe,costUsd:null,usage:null,numTurns:1,durationMs:void 0,denials:0,resultText:null}),void We({force:!0});const r=b(t,e);if(r&&Oe(r),"status"!==t){if("retry"===t)return le=!0,ue=!0,ae=ee(),ie=!1,void We();de=!0,ue=!1,Fe.length=0,Ue({kind:"result",subtype:"aborted",sessionId:W,model:fe,costUsd:null,usage:null,numTurns:1,durationMs:void 0,denials:0,resultText:null}),We({force:!0})}else ie=!0},se),me.unref?.(),{get sessionId(){return W},get usageSnapshot(){return xe},get turnActive(){return Z||_e},get canSteer(){return!(!be||!1===be.alive||!we||ve)},get started(){return B>0||Z},sendTurn(t,r={}){if(Y)return!1;if(!Z&&0===Fe.length&&!we&&!K){const e="function"==typeof D?D():{ok:!0};if(!1===e?.ok)return Ne({kind:"error",message:e.reason||"Host memory is critically low. This Codex runtime was not started.",recoverable:!0}),!1}const n=H++,o=V;if(V=g(t)||/^\s*\/(?:reset|clear)\b/i.test(String(t||"")),!Z&&0===B&&O)try{e=O()||e}catch{}if(Z&&be&&ke){Re();const e=f({promptIndex:n,forceFull:o}),s=buildCodexPrompt({text:t,terminalRolePrompt:C,rolePrompt:E,roomContext:()=>J({force:e}),promptIndex:n,forceFullReminder:o}),a=ke;return Ee=Ee.then(()=>be.steer({threadId:W,turnId:a,input:s,images:Array.isArray(r.images)?r.images:[]})).catch(e=>{"rejected"===e?.delivery||"not_sent"===e?.delivery?(Fe.push({text:t,options:r,promptIndex:n,forceFullReminder:o}),Z||1!==Fe.length||Je()):Ne({kind:"error",message:"Codex steering delivery is uncertain; the message was not replayed automatically.",recoverable:!0})}),!0}return Fe.push({text:t,options:r,promptIndex:n,forceFullReminder:o}),Z||_e||(Q=!1,De()),1!==Fe.length||_e||Je(),!0},abort(){const e=Te,t=Z&&!ke&&!K;Q=!0,ue=!1,de=!1,Fe.length=0,je(ke);let r=Promise.resolve();if(be&&ke&&(r=be.interrupt({threadId:W,turnId:ke}).catch(()=>{})),K){try{K.kill("SIGTERM")}catch{}setTimeout(()=>{try{K?.kill("SIGKILL")}catch{}},1500)}else t&&Ue({kind:"result",subtype:"aborted",sessionId:W,model:fe,costUsd:null,usage:null,numTurns:0,durationMs:void 0,denials:0,resultText:null});return r.then(()=>e).catch(()=>e).then(()=>{})},end(){if(Y=!0,Z=!1,me&&re(me),Ne.cancel(),Fe.length=0,je(ke),K)try{K.kill()}catch{}try{be?.end()}catch{}ge?.then(e=>e.close()).catch(()=>{})},setModel:e=>!(!e||Z||(fe=String(e),Pe.setModel(fe),0)),setMode:e=>!(Z||!CODEX_MODE_CONFIG[e]||(j=e,L=codexConfigForMode(j),k=L.sandbox,W=null,B=0,xe=null,we=!1,0)),setEffort:e=>!(Z||!EFFORT_LEVELS.has(e)||(he=e,Ne({kind:"effort",level:he}),0)),clearContext:()=>!Z&&(W=null,B=0,xe=null,we=!1,!0),async compactContext(){if(Z||_e)return!1;Q=!1,_e=!0;let e=null,t=!1;try{if(!await ze()||Z)return!1;const r=new Promise((t,r)=>{e=setTimeout(()=>{Ie=null,r(new Error("Codex did not start the compaction turn"))},5e3),Ie={resolve:t,reject:r}});await be.compact({threadId:W}),t=!0;const n=await r;e&&clearTimeout(e);const o=await be.waitForTurn(n);je(o?.turn?.id||n);const s=o?.turn?.status;if("completed"===s)return!0;throw new Error(o?.turn?.error?.message||`compaction ${s||"failed"}`)}catch(e){return t||!["rejected","not_sent"].includes(e?.delivery)?($e(`Native Codex compaction status is uncertain; existing context was preserved: ${e?.message||e}`),null):($e(`Native Codex compaction unavailable; using bounded recap fallback: ${e?.message||e}`),!1)}finally{e&&clearTimeout(e),Ie=null,_e=!1,Fe.length&&Je()}},async accountUsage(){if(!await ze())return null;const[e,t]=await Promise.all([be.accountUsage().catch(()=>null),be.accountRateLimits().catch(()=>null)]);return{usage:e,limits:t}}}}
|