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.
Files changed (116) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +20 -0
  3. package/abort-turn-barrier.mjs +1 -23
  4. package/account.mjs +1 -1437
  5. package/acp-client.mjs +1 -140
  6. package/agent-detect.mjs +1 -28
  7. package/agent-notify.mjs +1 -142
  8. package/agent-visibility.mjs +1 -67
  9. package/auth-store.mjs +1 -120
  10. package/bridge.mjs +1 -6266
  11. package/byok-detect.mjs +1 -126
  12. package/claude-command-catalog.mjs +1 -91
  13. package/claude-session.mjs +1 -1519
  14. package/code-event-contract.mjs +1 -118
  15. package/codex-app-server.mjs +1 -340
  16. package/codex-commands.mjs +1 -83
  17. package/codex-event-mapper.mjs +1 -232
  18. package/codex-images.mjs +1 -69
  19. package/codex-mcp-http.mjs +1 -131
  20. package/codex-session.mjs +1 -1295
  21. package/command-catalog.mjs +1 -118
  22. package/command-guidance.mjs +1 -8
  23. package/context-contract.mjs +1 -95
  24. package/context-windows.mjs +1 -107
  25. package/cross-terminal.mjs +1 -789
  26. package/cumulative-event-relay.mjs +1 -53
  27. package/design-edit.mjs +1 -424
  28. package/design-source-contract.mjs +1 -4
  29. package/direct-pair-room.mjs +1 -57
  30. package/dispatch-lease.mjs +1 -37
  31. package/dispatch-permission-cleanup.mjs +1 -86
  32. package/edit-diff.mjs +1 -136
  33. package/error-recovery.mjs +1 -50
  34. package/event-bounds.mjs +1 -121
  35. package/event-delivery-queue.mjs +1 -60
  36. package/event-id.mjs +1 -549
  37. package/evidence-citations.mjs +1 -50
  38. package/evidence-compact.mjs +1 -11
  39. package/flow-assembly.mjs +1 -196
  40. package/flow-budget.mjs +1 -84
  41. package/flow-conductor.mjs +1 -259
  42. package/flow-context-store.mjs +1 -387
  43. package/flow-host-revert.mjs +1 -42
  44. package/flow-models.mjs +1 -139
  45. package/flow-preview.mjs +1 -148
  46. package/flow-receipt.mjs +1 -122
  47. package/flow-redispatch.mjs +1 -72
  48. package/flow-review-gate.mjs +1 -402
  49. package/flow-review-reflect.mjs +1 -115
  50. package/flow-review.mjs +1 -152
  51. package/flow-scope-evidence.mjs +1 -117
  52. package/flow-skill-registry.mjs +1 -140
  53. package/flow-task-graph.mjs +1 -562
  54. package/flow-worktree.mjs +1 -71
  55. package/git-diff-report.mjs +1 -121
  56. package/hermes-delegation-guard.mjs +1 -14
  57. package/hermes-event-mapper.mjs +1 -194
  58. package/hermes-isolation.mjs +1 -61
  59. package/hermes-model-cache.mjs +1 -54
  60. package/hermes-policy.mjs +1 -92
  61. package/hermes-probe.mjs +1 -57
  62. package/hermes-session.mjs +1 -683
  63. package/hermes-setup.mjs +1 -167
  64. package/host-memory.mjs +1 -116
  65. package/interrupted-resume.mjs +1 -95
  66. package/keep-awake.mjs +1 -148
  67. package/key-shape.mjs +1 -49
  68. package/lane-continuation.mjs +1 -83
  69. package/lane-lifecycle.mjs +1 -189
  70. package/lane-worktree.mjs +1 -77
  71. package/launcher.mjs +1 -354
  72. package/mcp-flight-recorder.mjs +1 -79
  73. package/mockup-delivery.mjs +1 -57
  74. package/model-prices.mjs +1 -113
  75. package/package.json +13 -4
  76. package/pair-bus.mjs +1 -98
  77. package/pair-control-authority.mjs +1 -89
  78. package/past-work-search.mjs +1 -105
  79. package/plan-meters.mjs +1 -144
  80. package/presence.mjs +1 -191
  81. package/privacy-report.mjs +1 -108
  82. package/provider-resilience.mjs +1 -356
  83. package/provider.mjs +1 -133
  84. package/providers.mjs +1 -491
  85. package/publish-guard.mjs +2 -0
  86. package/publish-manifest.json +129 -0
  87. package/question-response.mjs +1 -58
  88. package/reap-terminal.mjs +1 -68
  89. package/recap.mjs +1 -297
  90. package/replay-transport.mjs +1 -64
  91. package/repo-search.mjs +1 -190
  92. package/review-check.mjs +1 -182
  93. package/runtime-contract.mjs +1 -93
  94. package/runtime-registry.mjs +1 -64
  95. package/runtime-session.mjs +1 -20
  96. package/scheduled-run-admission.mjs +1 -364
  97. package/scheduled-runs.mjs +1 -268
  98. package/sdk-admission.mjs +1 -9
  99. package/sdk-smoke.mjs +1 -61
  100. package/serve-consent.mjs +1 -118
  101. package/serve-dir.mjs +1 -40
  102. package/service.mjs +1 -877
  103. package/session-store.mjs +1 -484
  104. package/side-lane.mjs +1 -63
  105. package/supabase-key.mjs +1 -176
  106. package/supervisor-ready.mjs +1 -57
  107. package/switch-provider.mjs +1 -84
  108. package/terminal-name.mjs +1 -359
  109. package/terminal-row-reconcile.mjs +1 -70
  110. package/thinkpool-prompt-contracts.mjs +1 -85
  111. package/thinkpool-room-prompt.mjs +1 -187
  112. package/transcript-sanitize.mjs +1 -332
  113. package/turn-stall.mjs +1 -61
  114. package/update-gate.mjs +1 -53
  115. package/viewport.mjs +1 -810
  116. 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}}}}