thinkpool-pair 0.7.364 → 0.7.366
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -0
- package/README.md +20 -0
- package/abort-turn-barrier.mjs +1 -23
- package/account.mjs +1 -1437
- package/acp-client.mjs +1 -140
- package/agent-detect.mjs +1 -28
- package/agent-notify.mjs +1 -142
- package/agent-visibility.mjs +1 -67
- package/auth-store.mjs +1 -120
- package/bridge.mjs +1 -6266
- package/byok-detect.mjs +1 -126
- package/claude-command-catalog.mjs +1 -91
- package/claude-session.mjs +1 -1519
- package/code-event-contract.mjs +1 -118
- package/codex-app-server.mjs +1 -340
- package/codex-commands.mjs +1 -83
- package/codex-event-mapper.mjs +1 -232
- package/codex-images.mjs +1 -69
- package/codex-mcp-http.mjs +1 -131
- package/codex-session.mjs +1 -1295
- package/command-catalog.mjs +1 -118
- package/command-guidance.mjs +1 -8
- package/context-contract.mjs +1 -95
- package/context-windows.mjs +1 -107
- package/cross-terminal.mjs +1 -789
- package/cumulative-event-relay.mjs +1 -53
- package/design-edit.mjs +1 -424
- package/design-source-contract.mjs +1 -4
- package/direct-pair-room.mjs +1 -57
- package/dispatch-lease.mjs +1 -37
- package/dispatch-permission-cleanup.mjs +1 -86
- package/edit-diff.mjs +1 -136
- package/error-recovery.mjs +1 -50
- package/event-bounds.mjs +1 -121
- package/event-delivery-queue.mjs +1 -60
- package/event-id.mjs +1 -549
- package/evidence-citations.mjs +1 -50
- package/evidence-compact.mjs +1 -11
- package/flow-assembly.mjs +1 -196
- package/flow-budget.mjs +1 -84
- package/flow-conductor.mjs +1 -259
- package/flow-context-store.mjs +1 -387
- package/flow-host-revert.mjs +1 -42
- package/flow-models.mjs +1 -139
- package/flow-preview.mjs +1 -148
- package/flow-receipt.mjs +1 -122
- package/flow-redispatch.mjs +1 -72
- package/flow-review-gate.mjs +1 -402
- package/flow-review-reflect.mjs +1 -115
- package/flow-review.mjs +1 -152
- package/flow-scope-evidence.mjs +1 -117
- package/flow-skill-registry.mjs +1 -140
- package/flow-task-graph.mjs +1 -562
- package/flow-worktree.mjs +1 -71
- package/git-diff-report.mjs +1 -121
- package/hermes-delegation-guard.mjs +1 -14
- package/hermes-event-mapper.mjs +1 -194
- package/hermes-isolation.mjs +1 -61
- package/hermes-model-cache.mjs +1 -54
- package/hermes-policy.mjs +1 -92
- package/hermes-probe.mjs +1 -57
- package/hermes-session.mjs +1 -683
- package/hermes-setup.mjs +1 -167
- package/host-memory.mjs +1 -116
- package/interrupted-resume.mjs +1 -95
- package/keep-awake.mjs +1 -148
- package/key-shape.mjs +1 -49
- package/lane-continuation.mjs +1 -83
- package/lane-lifecycle.mjs +1 -189
- package/lane-worktree.mjs +1 -77
- package/launcher.mjs +1 -354
- package/mcp-flight-recorder.mjs +1 -79
- package/mockup-delivery.mjs +1 -57
- package/model-prices.mjs +1 -113
- package/package.json +13 -4
- package/pair-bus.mjs +1 -98
- package/pair-control-authority.mjs +1 -89
- package/past-work-search.mjs +1 -105
- package/plan-meters.mjs +1 -144
- package/presence.mjs +1 -191
- package/privacy-report.mjs +1 -108
- package/provider-resilience.mjs +1 -356
- package/provider.mjs +1 -133
- package/providers.mjs +1 -491
- package/publish-guard.mjs +2 -0
- package/publish-manifest.json +129 -0
- package/question-response.mjs +1 -58
- package/reap-terminal.mjs +1 -68
- package/recap.mjs +1 -297
- package/replay-transport.mjs +1 -64
- package/repo-search.mjs +1 -190
- package/review-check.mjs +1 -182
- package/runtime-contract.mjs +1 -93
- package/runtime-registry.mjs +1 -64
- package/runtime-session.mjs +1 -20
- package/scheduled-run-admission.mjs +1 -364
- package/scheduled-runs.mjs +1 -268
- package/sdk-admission.mjs +1 -9
- package/sdk-smoke.mjs +1 -61
- package/serve-consent.mjs +1 -118
- package/serve-dir.mjs +1 -40
- package/service.mjs +1 -877
- package/session-store.mjs +1 -484
- package/side-lane.mjs +1 -63
- package/supabase-key.mjs +1 -176
- package/supervisor-ready.mjs +1 -57
- package/switch-provider.mjs +1 -84
- package/terminal-name.mjs +1 -359
- package/terminal-row-reconcile.mjs +1 -70
- package/thinkpool-prompt-contracts.mjs +1 -85
- package/thinkpool-room-prompt.mjs +1 -187
- package/transcript-sanitize.mjs +1 -332
- package/turn-stall.mjs +1 -61
- package/update-gate.mjs +1 -53
- package/viewport.mjs +1 -810
- package/worker-completion.mjs +1 -57
package/codex-event-mapper.mjs
CHANGED
|
@@ -1,232 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// the ThinkPool bridge's runtime-agnostic room-event contract (the SAME kinds
|
|
3
|
-
// claude-session.mjs emits). Pure logic over a sequential event stream: the
|
|
4
|
-
// driver (codex-session.mjs) feeds it parsed JSONL events, it calls onEvent
|
|
5
|
-
// with normalized events. Spec: docs/specs/2026-07-12-codex-driver.md.
|
|
6
|
-
//
|
|
7
|
-
// WHY A SEPARATE RUNTIME, NOT A TRANSLATOR (see spec §Why): Codex is itself an
|
|
8
|
-
// openai-native agent loop (tools + sandbox); we map its OUTPUT, we do not
|
|
9
|
-
// re-implement the loop or translate APIs. This keeps each runtime on its
|
|
10
|
-
// maker-tuned loop with no tool_use↔function-call loss.
|
|
11
|
-
//
|
|
12
|
-
// Codex item taxonomy (live capture: bridge/fixtures/codex-read-write.jsonl):
|
|
13
|
-
// agent_message → assistant { blocks:[{type:'text',text}] }
|
|
14
|
-
// reasoning → assistant { blocks:[{type:'thinking',text}] }
|
|
15
|
-
// command_execution start → assistant { blocks:[{type:'tool_use',name:'Bash',input:{command}}] }
|
|
16
|
-
// command_execution done → tool_result { toolUseId, content, isError, durationMs }
|
|
17
|
-
// file_change start → assistant { blocks:[{type:'tool_use',name:'Write'|'Edit',input:{path}}] }
|
|
18
|
-
// file_change done → tool_result
|
|
19
|
-
// turn.completed (usage) → result { subtype:'success', usage, model }
|
|
20
|
-
// turn.failed / error → error { recoverable:true } (+ result on turn.failed)
|
|
21
|
-
//
|
|
22
|
-
// G1 (stable exec path): agent_message is burst-on-completion (no per-token
|
|
23
|
-
// deltas). Tool activity DOES stream (item.started→item.completed). The opt-in
|
|
24
|
-
// App Server adapter emits item/agentMessage/delta directly and suppresses the
|
|
25
|
-
// completed-message duplicate before events reach this mapper.
|
|
26
|
-
//
|
|
27
|
-
// Risk: this module emits tool_use blocks with names ('Bash'/'Write'/'Edit') so
|
|
28
|
-
// the existing risk classifier (claude-session.classifyRisk) and room rendering
|
|
29
|
-
// apply unchanged. The stable exec path is observe-only; the opt-in App Server
|
|
30
|
-
// path routes command/file approval requests through the shared room cards.
|
|
31
|
-
import fs from 'node:fs'
|
|
32
|
-
import { CODEX_COMMAND_CATALOG } from './codex-commands.mjs'
|
|
33
|
-
import { normalizeEditKind, summarizeTextDiff } from './edit-diff.mjs'
|
|
34
|
-
import { evidenceForToolResult } from './evidence-citations.mjs'
|
|
35
|
-
|
|
36
|
-
const safeMcpPart = (value) => String(value || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
37
|
-
const mcpToolName = (item) => `mcp__${safeMcpPart(item?.server)}__${safeMcpPart(item?.tool)}`
|
|
38
|
-
const webSearchQuery = (item) => item?.query || item?.action?.query || item?.action?.url || ''
|
|
39
|
-
const resultText = (value) => {
|
|
40
|
-
if (typeof value === 'string') return value
|
|
41
|
-
if (value == null) return ''
|
|
42
|
-
try { return JSON.stringify(value) } catch { return String(value) }
|
|
43
|
-
}
|
|
44
|
-
// Codex uses both `message: string` and `message: { message: string }` across
|
|
45
|
-
// its exec/app-server error paths. Room events must keep `message` scalar: an
|
|
46
|
-
// object persists successfully but crashes React when that transcript replays.
|
|
47
|
-
const errorText = (value, fallback) => {
|
|
48
|
-
if (typeof value === 'string' && value.trim()) return value
|
|
49
|
-
if (value && typeof value === 'object' && typeof value.message === 'string' && value.message.trim()) return value.message
|
|
50
|
-
return resultText(value) || fallback
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export class CodexEventMapper {
|
|
54
|
-
/**
|
|
55
|
-
* @param {{onEvent:(evt)=>void, model?:string|null, sessionId?:string|null}} opts
|
|
56
|
-
*/
|
|
57
|
-
constructor({ onEvent, model = null, sessionId = null, fsImpl = fs, usageSnapshotForSession = () => null }) {
|
|
58
|
-
this._onEvent = onEvent
|
|
59
|
-
this._model = model || null
|
|
60
|
-
this._sessionId = sessionId || null
|
|
61
|
-
this._starts = new Map() // item.id → startMs (for durationMs)
|
|
62
|
-
this._fileBefore = new Map() // item.id → [{ path, kind, content }]
|
|
63
|
-
this._fs = fsImpl
|
|
64
|
-
this._systemSent = false
|
|
65
|
-
this._usageSnapshotForSession = usageSnapshotForSession
|
|
66
|
-
this._usageBaseline = null
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Emit a room-event; never let a consumer throw into the stream. */
|
|
70
|
-
_emit(evt) { try { this._onEvent?.(evt) } catch { /* noop */ } }
|
|
71
|
-
|
|
72
|
-
setModel(model) { this._model = model || null }
|
|
73
|
-
|
|
74
|
-
setUsageBaseline(snapshot) { this._usageBaseline = snapshot || null }
|
|
75
|
-
|
|
76
|
-
/** Process one parsed Codex JSONL event. */
|
|
77
|
-
push(ev) {
|
|
78
|
-
if (!ev || typeof ev !== 'object') return
|
|
79
|
-
switch (ev.type) {
|
|
80
|
-
case 'thread.started': {
|
|
81
|
-
// /clear and permission-mode changes intentionally create a new native
|
|
82
|
-
// thread inside the same visible lane. Attribute subsequent usage and
|
|
83
|
-
// results to that new thread, never the previous one.
|
|
84
|
-
this._sessionId = ev.thread_id || this._sessionId || null
|
|
85
|
-
if (!this._systemSent) {
|
|
86
|
-
this._systemSent = true
|
|
87
|
-
// Codex App Server 0.144.1 has no slash-command discovery endpoint.
|
|
88
|
-
// Advertise only the explicit room-supported catalog, never guessed
|
|
89
|
-
// interactive-TUI commands.
|
|
90
|
-
this._emit({ kind: 'system', sessionId: this._sessionId, model: this._model, commands: CODEX_COMMAND_CATALOG })
|
|
91
|
-
}
|
|
92
|
-
return
|
|
93
|
-
}
|
|
94
|
-
case 'turn.started':
|
|
95
|
-
return
|
|
96
|
-
|
|
97
|
-
case 'item.started': {
|
|
98
|
-
const it = ev.item
|
|
99
|
-
if (!it) return
|
|
100
|
-
this._starts.set(it.id, Date.now())
|
|
101
|
-
if (it.type === 'command_execution') {
|
|
102
|
-
this._emit({ kind: 'assistant', blocks: [{ type: 'tool_use', id: it.id, name: 'Bash', input: { command: it.command || '' } }], parentToolUseId: null })
|
|
103
|
-
} else if (it.type === 'file_change') {
|
|
104
|
-
const before = (it.changes || []).map((c) => {
|
|
105
|
-
const kind = normalizeEditKind(c.kind)
|
|
106
|
-
let content = ''
|
|
107
|
-
if (kind !== 'add' && c.path) {
|
|
108
|
-
try { content = this._fs.readFileSync(c.path, 'utf8') } catch { /* missing/unreadable → empty */ }
|
|
109
|
-
}
|
|
110
|
-
return { path: c.path || '', kind, content }
|
|
111
|
-
})
|
|
112
|
-
this._fileBefore.set(it.id, before)
|
|
113
|
-
} else if (it.type === 'web_search') {
|
|
114
|
-
this._emit({ kind: 'assistant', blocks: [{ type: 'tool_use', id: it.id, name: 'WebSearch', input: { query: webSearchQuery(it) } }], parentToolUseId: null })
|
|
115
|
-
} else if (it.type === 'mcp_tool_call') {
|
|
116
|
-
this._emit({ kind: 'assistant', blocks: [{ type: 'tool_use', id: it.id, name: mcpToolName(it), input: it.arguments || {} }], parentToolUseId: null })
|
|
117
|
-
}
|
|
118
|
-
// agent_message / reasoning have no item.started (burst on completion).
|
|
119
|
-
return
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
case 'item.completed': {
|
|
123
|
-
const it = ev.item
|
|
124
|
-
if (!it) return
|
|
125
|
-
const start = this._starts.get(it.id)
|
|
126
|
-
const durationMs = start != null ? Date.now() - start : undefined
|
|
127
|
-
this._starts.delete(it.id)
|
|
128
|
-
|
|
129
|
-
if (it.type === 'agent_message') {
|
|
130
|
-
this._emit({ kind: 'assistant', blocks: [{ type: 'text', text: it.text || '' }], parentToolUseId: null })
|
|
131
|
-
} else if (it.type === 'reasoning') {
|
|
132
|
-
this._emit({ kind: 'assistant', blocks: [{ type: 'thinking', text: it.text || '', reasoningKind: 'summary' }], parentToolUseId: null })
|
|
133
|
-
} else if (it.type === 'command_execution') {
|
|
134
|
-
const bad = it.exit_code != null ? it.exit_code !== 0 : false
|
|
135
|
-
this._emit({ kind: 'tool_result', toolUseId: it.id, content: [{ type: 'text', text: it.aggregated_output || '' }], isError: bad, durationMs, parentToolUseId: null })
|
|
136
|
-
} else if (it.type === 'file_change') {
|
|
137
|
-
const before = this._fileBefore.get(it.id) || (it.changes || []).map((c) => ({ path: c.path || '', kind: c.kind || 'update', content: '' }))
|
|
138
|
-
this._fileBefore.delete(it.id)
|
|
139
|
-
const edits = before.map((c) => {
|
|
140
|
-
let next = ''
|
|
141
|
-
if (c.kind !== 'delete' && c.path) {
|
|
142
|
-
try { next = this._fs.readFileSync(c.path, 'utf8') } catch { /* deleted/unreadable → empty */ }
|
|
143
|
-
}
|
|
144
|
-
const summary = summarizeTextDiff(c.content, next, { context: 2, maxLines: 60 })
|
|
145
|
-
return {
|
|
146
|
-
file_path: c.path,
|
|
147
|
-
path: c.path,
|
|
148
|
-
kind: c.kind,
|
|
149
|
-
added: summary.added,
|
|
150
|
-
removed: summary.removed,
|
|
151
|
-
diff_preview: summary.preview,
|
|
152
|
-
old_string: c.content,
|
|
153
|
-
new_string: next,
|
|
154
|
-
}
|
|
155
|
-
})
|
|
156
|
-
const first = edits[0] || { file_path: '', path: '', old_string: '', new_string: '', kind: 'update' }
|
|
157
|
-
const name = edits.length > 1 ? 'MultiEdit' : first.kind === 'add' ? 'Write' : 'Edit'
|
|
158
|
-
const input = name === 'MultiEdit'
|
|
159
|
-
? { edits }
|
|
160
|
-
: name === 'Write'
|
|
161
|
-
? { file_path: first.file_path, path: first.path, content: first.new_string }
|
|
162
|
-
: first
|
|
163
|
-
this._emit({ kind: 'assistant', blocks: [{ type: 'tool_use', id: it.id, name, input }], parentToolUseId: null })
|
|
164
|
-
this._emit({ kind: 'tool_result', toolUseId: it.id, content: [{ type: 'text', text: `${name === 'Write' ? 'wrote' : 'edited'} ${first.path}` }], isError: false, durationMs, parentToolUseId: null })
|
|
165
|
-
} else if (it.type === 'web_search') {
|
|
166
|
-
const query = webSearchQuery(it)
|
|
167
|
-
this._emit({ kind: 'tool_result', toolUseId: it.id, toolInput: { query }, content: [{ type: 'text', text: query || 'search completed' }], isError: false, durationMs, parentToolUseId: null })
|
|
168
|
-
} else if (it.type === 'mcp_tool_call') {
|
|
169
|
-
const content = [{ type: 'text', text: it.error?.message || resultText(it.result) }]
|
|
170
|
-
const evidence = evidenceForToolResult(mcpToolName(it), content)
|
|
171
|
-
this._emit({ kind: 'tool_result', toolUseId: it.id, content, ...(evidence ? { evidence } : {}), isError: it.status === 'failed' || !!it.error, durationMs, parentToolUseId: null })
|
|
172
|
-
}
|
|
173
|
-
// plan_update remains an additive future event shape. Unknown
|
|
174
|
-
// items are ignored rather than risking a malformed transcript row.
|
|
175
|
-
return
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
case 'turn.completed': {
|
|
179
|
-
const u = ev.usage || {}
|
|
180
|
-
let snapshot = null
|
|
181
|
-
try { snapshot = this._usageSnapshotForSession(this._sessionId) } catch { /* unreadable rollout → omit meter */ }
|
|
182
|
-
const used = Number(snapshot?.context?.used)
|
|
183
|
-
const max = Number(snapshot?.context?.max)
|
|
184
|
-
if (Number.isFinite(used) && used >= 0 && Number.isFinite(max) && max > 0) {
|
|
185
|
-
this._emit({ kind: 'usage', ctx: { used, max, pct: Math.min(100, Math.max(0, Math.round((used / max) * 100))), over: used > max, model: this._model } })
|
|
186
|
-
}
|
|
187
|
-
// Codex's turn.completed values are lifetime thread totals, not turn
|
|
188
|
-
// deltas. Prefer the native rollout totals and subtract the pre-turn
|
|
189
|
-
// baseline. A fresh thread has no baseline, so its totals equal turn 1.
|
|
190
|
-
const totals = snapshot?.total || u
|
|
191
|
-
const before = this._usageBaseline?.total || {}
|
|
192
|
-
const totalInput = Math.max(0, (Number(totals.input_tokens) || 0) - (Number(before.input_tokens) || 0))
|
|
193
|
-
const cachedInput = Math.min(totalInput, Math.max(0, (Number(totals.cached_input_tokens) || 0) - (Number(before.cached_input_tokens) || 0)))
|
|
194
|
-
const out = Math.max(0, (Number(totals.output_tokens) || 0) - (Number(before.output_tokens) || 0))
|
|
195
|
-
this._usageBaseline = snapshot || null
|
|
196
|
-
this._emit({
|
|
197
|
-
kind: 'result',
|
|
198
|
-
subtype: 'success',
|
|
199
|
-
sessionId: this._sessionId,
|
|
200
|
-
model: this._model,
|
|
201
|
-
costUsd: null,
|
|
202
|
-
usage: {
|
|
203
|
-
// Claude-shape fields are mutually exclusive buckets. Codex reports
|
|
204
|
-
// cached_input_tokens as a subset of input_tokens, so subtract it.
|
|
205
|
-
input_tokens: totalInput - cachedInput,
|
|
206
|
-
cache_read_input_tokens: cachedInput,
|
|
207
|
-
cache_creation_input_tokens: 0,
|
|
208
|
-
output_tokens: out,
|
|
209
|
-
},
|
|
210
|
-
numTurns: 1,
|
|
211
|
-
durationMs: undefined,
|
|
212
|
-
denials: 0,
|
|
213
|
-
resultText: null,
|
|
214
|
-
})
|
|
215
|
-
return
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
case 'turn.failed': {
|
|
219
|
-
this._emit({ kind: 'error', message: errorText(ev.message ?? ev.error, 'codex turn failed'), recoverable: true })
|
|
220
|
-
this._emit({ kind: 'result', subtype: 'error_during_execution', sessionId: this._sessionId, model: this._model, costUsd: null, usage: null, numTurns: 1, durationMs: undefined, denials: 0, resultText: null })
|
|
221
|
-
return
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
case 'error':
|
|
225
|
-
this._emit({ kind: 'error', message: errorText(ev.message ?? ev.error, 'codex error'), recoverable: true })
|
|
226
|
-
return
|
|
227
|
-
|
|
228
|
-
default:
|
|
229
|
-
return
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
1
|
+
import t from"node:fs";import{CODEX_COMMAND_CATALOG as e}from"./codex-commands.mjs";import{normalizeEditKind as s,summarizeTextDiff as i}from"./edit-diff.mjs";import{evidenceForToolResult as n}from"./evidence-citations.mjs";const o=t=>String(t||"unknown").replace(/[^a-zA-Z0-9_-]/g,"_"),r=t=>`mcp__${o(t?.server)}__${o(t?.tool)}`,a=t=>t?.query||t?.action?.query||t?.action?.url||"",l=t=>{if("string"==typeof t)return t;if(null==t)return"";try{return JSON.stringify(t)}catch{return String(t)}},d=(t,e)=>"string"==typeof t&&t.trim()?t:t&&"object"==typeof t&&"string"==typeof t.message&&t.message.trim()?t.message:l(t)||e;export class CodexEventMapper{constructor({onEvent:e,model:s=null,sessionId:i=null,fsImpl:n=t,usageSnapshotForSession:o=()=>null}){this._onEvent=e,this._model=s||null,this._sessionId=i||null,this._starts=new Map,this._fileBefore=new Map,this._fs=n,this._systemSent=!1,this._usageSnapshotForSession=o,this._usageBaseline=null}_emit(t){try{this._onEvent?.(t)}catch{}}setModel(t){this._model=t||null}setUsageBaseline(t){this._usageBaseline=t||null}push(t){if(t&&"object"==typeof t)switch(t.type){case"thread.started":return this._sessionId=t.thread_id||this._sessionId||null,void(this._systemSent||(this._systemSent=!0,this._emit({kind:"system",sessionId:this._sessionId,model:this._model,commands:e})));case"turn.started":default:return;case"item.started":{const e=t.item;if(!e)return;if(this._starts.set(e.id,Date.now()),"command_execution"===e.type)this._emit({kind:"assistant",blocks:[{type:"tool_use",id:e.id,name:"Bash",input:{command:e.command||""}}],parentToolUseId:null});else if("file_change"===e.type){const t=(e.changes||[]).map(t=>{const e=s(t.kind);let i="";if("add"!==e&&t.path)try{i=this._fs.readFileSync(t.path,"utf8")}catch{}return{path:t.path||"",kind:e,content:i}});this._fileBefore.set(e.id,t)}else"web_search"===e.type?this._emit({kind:"assistant",blocks:[{type:"tool_use",id:e.id,name:"WebSearch",input:{query:a(e)}}],parentToolUseId:null}):"mcp_tool_call"===e.type&&this._emit({kind:"assistant",blocks:[{type:"tool_use",id:e.id,name:r(e),input:e.arguments||{}}],parentToolUseId:null});return}case"item.completed":{const e=t.item;if(!e)return;const s=this._starts.get(e.id),o=null!=s?Date.now()-s:void 0;if(this._starts.delete(e.id),"agent_message"===e.type)this._emit({kind:"assistant",blocks:[{type:"text",text:e.text||""}],parentToolUseId:null});else if("reasoning"===e.type)this._emit({kind:"assistant",blocks:[{type:"thinking",text:e.text||"",reasoningKind:"summary"}],parentToolUseId:null});else if("command_execution"===e.type){const t=null!=e.exit_code&&0!==e.exit_code;this._emit({kind:"tool_result",toolUseId:e.id,content:[{type:"text",text:e.aggregated_output||""}],isError:t,durationMs:o,parentToolUseId:null})}else if("file_change"===e.type){const t=this._fileBefore.get(e.id)||(e.changes||[]).map(t=>({path:t.path||"",kind:t.kind||"update",content:""}));this._fileBefore.delete(e.id);const s=t.map(t=>{let e="";if("delete"!==t.kind&&t.path)try{e=this._fs.readFileSync(t.path,"utf8")}catch{}const s=i(t.content,e,{context:2,maxLines:60});return{file_path:t.path,path:t.path,kind:t.kind,added:s.added,removed:s.removed,diff_preview:s.preview,old_string:t.content,new_string:e}}),n=s[0]||{file_path:"",path:"",old_string:"",new_string:"",kind:"update"},r=s.length>1?"MultiEdit":"add"===n.kind?"Write":"Edit",a="MultiEdit"===r?{edits:s}:"Write"===r?{file_path:n.file_path,path:n.path,content:n.new_string}:n;this._emit({kind:"assistant",blocks:[{type:"tool_use",id:e.id,name:r,input:a}],parentToolUseId:null}),this._emit({kind:"tool_result",toolUseId:e.id,content:[{type:"text",text:`${"Write"===r?"wrote":"edited"} ${n.path}`}],isError:!1,durationMs:o,parentToolUseId:null})}else if("web_search"===e.type){const t=a(e);this._emit({kind:"tool_result",toolUseId:e.id,toolInput:{query:t},content:[{type:"text",text:t||"search completed"}],isError:!1,durationMs:o,parentToolUseId:null})}else if("mcp_tool_call"===e.type){const t=[{type:"text",text:e.error?.message||l(e.result)}],s=n(r(e),t);this._emit({kind:"tool_result",toolUseId:e.id,content:t,...s?{evidence:s}:{},isError:"failed"===e.status||!!e.error,durationMs:o,parentToolUseId:null})}return}case"turn.completed":{const e=t.usage||{};let s=null;try{s=this._usageSnapshotForSession(this._sessionId)}catch{}const i=Number(s?.context?.used),n=Number(s?.context?.max);Number.isFinite(i)&&i>=0&&Number.isFinite(n)&&n>0&&this._emit({kind:"usage",ctx:{used:i,max:n,pct:Math.min(100,Math.max(0,Math.round(i/n*100))),over:i>n,model:this._model}});const o=s?.total||e,r=this._usageBaseline?.total||{},a=Math.max(0,(Number(o.input_tokens)||0)-(Number(r.input_tokens)||0)),l=Math.min(a,Math.max(0,(Number(o.cached_input_tokens)||0)-(Number(r.cached_input_tokens)||0))),d=Math.max(0,(Number(o.output_tokens)||0)-(Number(r.output_tokens)||0));return this._usageBaseline=s||null,void this._emit({kind:"result",subtype:"success",sessionId:this._sessionId,model:this._model,costUsd:null,usage:{input_tokens:a-l,cache_read_input_tokens:l,cache_creation_input_tokens:0,output_tokens:d},numTurns:1,durationMs:void 0,denials:0,resultText:null})}case"turn.failed":return this._emit({kind:"error",message:d(t.message??t.error,"codex turn failed"),recoverable:!0}),void this._emit({kind:"result",subtype:"error_during_execution",sessionId:this._sessionId,model:this._model,costUsd:null,usage:null,numTurns:1,durationMs:void 0,denials:0,resultText:null});case"error":return void this._emit({kind:"error",message:d(t.message??t.error,"codex error"),recoverable:!0})}}}
|
package/codex-images.mjs
CHANGED
|
@@ -1,69 +1 @@
|
|
|
1
|
-
import fs from
|
|
2
|
-
import path from 'node:path'
|
|
3
|
-
|
|
4
|
-
const IMAGE_MEDIA_TYPE = /^image\/[a-z0-9.+-]+$/i
|
|
5
|
-
const SAFE_ID_PREFIX = /^[a-zA-Z0-9_-]{1,8}$/
|
|
6
|
-
|
|
7
|
-
export const safeRoomFileName = (name) => String(name || 'file').replace(/[^a-zA-Z0-9._-]/g, '_').slice(-80)
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Rebuild the deterministic room-upload path from bridge-owned facts.
|
|
11
|
-
* `hostPath` is intentionally ignored: it is display metadata supplied by a
|
|
12
|
-
* browser and must never grant Codex access to an arbitrary host file.
|
|
13
|
-
*/
|
|
14
|
-
export function canonicalRoomFilePath(file, { updir } = {}) {
|
|
15
|
-
if (!file || !updir) return null
|
|
16
|
-
const idPrefix = String(file.id || '').slice(0, 8)
|
|
17
|
-
if (!SAFE_ID_PREFIX.test(idPrefix)) return null
|
|
18
|
-
const root = path.resolve(String(updir))
|
|
19
|
-
const candidate = path.resolve(root, `${idPrefix}-${safeRoomFileName(file.name)}`)
|
|
20
|
-
return candidate.startsWith(root + path.sep) ? candidate : null
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function nativeImageCandidates(files, { updir } = {}) {
|
|
24
|
-
if (!Array.isArray(files)) return []
|
|
25
|
-
const seen = new Set()
|
|
26
|
-
const candidates = []
|
|
27
|
-
for (const file of files) {
|
|
28
|
-
if (!IMAGE_MEDIA_TYPE.test(String(file?.mediaType || ''))) continue
|
|
29
|
-
const candidate = canonicalRoomFilePath(file, { updir })
|
|
30
|
-
if (!candidate || seen.has(candidate)) continue
|
|
31
|
-
seen.add(candidate)
|
|
32
|
-
candidates.push(candidate)
|
|
33
|
-
}
|
|
34
|
-
return candidates
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const fileReady = async (filePath) => {
|
|
38
|
-
try { return (await fs.promises.stat(filePath)).isFile() }
|
|
39
|
-
catch { return false }
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* `file-put` and `code-turn` are separate realtime broadcasts, so the turn can
|
|
46
|
-
* arrive while the signed download is still in flight. Wait briefly for only
|
|
47
|
-
* canonical image paths; timed-out images stay in the text prompt as ordinary
|
|
48
|
-
* quoted paths instead of failing or delaying the turn indefinitely.
|
|
49
|
-
*/
|
|
50
|
-
export async function waitForNativeImages(files, {
|
|
51
|
-
updir,
|
|
52
|
-
timeoutMs = 2000,
|
|
53
|
-
pollMs = 40,
|
|
54
|
-
isReady = fileReady,
|
|
55
|
-
sleep = delay,
|
|
56
|
-
now = Date.now,
|
|
57
|
-
} = {}) {
|
|
58
|
-
const candidates = nativeImageCandidates(files, { updir })
|
|
59
|
-
if (!candidates.length) return []
|
|
60
|
-
const ready = new Set()
|
|
61
|
-
const deadline = now() + Math.max(0, timeoutMs)
|
|
62
|
-
while (true) {
|
|
63
|
-
const checks = await Promise.all(candidates.map(async (candidate) => [candidate, await isReady(candidate)]))
|
|
64
|
-
for (const [candidate, exists] of checks) if (exists) ready.add(candidate)
|
|
65
|
-
if (ready.size === candidates.length || now() >= deadline) break
|
|
66
|
-
await sleep(Math.min(Math.max(1, pollMs), Math.max(1, deadline - now())))
|
|
67
|
-
}
|
|
68
|
-
return candidates.filter((candidate) => ready.has(candidate))
|
|
69
|
-
}
|
|
1
|
+
import t from"node:fs";import e from"node:path";const a=/^image\/[a-z0-9.+-]+$/i,i=/^[a-zA-Z0-9_-]{1,8}$/;export const safeRoomFileName=t=>String(t||"file").replace(/[^a-zA-Z0-9._-]/g,"_").slice(-80);export function canonicalRoomFilePath(t,{updir:a}={}){if(!t||!a)return null;const n=String(t.id||"").slice(0,8);if(!i.test(n))return null;const r=e.resolve(String(a)),o=e.resolve(r,`${n}-${s=t.name,String(s||"file").replace(/[^a-zA-Z0-9._-]/g,"_").slice(-80)}`);var s;return o.startsWith(r+e.sep)?o:null}export function nativeImageCandidates(t,{updir:e}={}){if(!Array.isArray(t))return[];const i=new Set,n=[];for(const r of t){if(!a.test(String(r?.mediaType||"")))continue;const t=canonicalRoomFilePath(r,{updir:e});t&&!i.has(t)&&(i.add(t),n.push(t))}return n}const n=async e=>{try{return(await t.promises.stat(e)).isFile()}catch{return!1}},r=t=>new Promise(e=>setTimeout(e,t));export async function waitForNativeImages(t,{updir:e,timeoutMs:a=2e3,pollMs:i=40,isReady:o=n,sleep:s=r,now:c=Date.now}={}){const l=nativeImageCandidates(t,{updir:e});if(!l.length)return[];const m=new Set,u=c()+Math.max(0,a);for(;;){const t=await Promise.all(l.map(async t=>[t,await o(t)]));for(const[e,a]of t)a&&m.add(e);if(m.size===l.length||c()>=u)break;await s(Math.min(Math.max(1,i),Math.max(1,u-c())))}return l.filter(t=>m.has(t))}
|
package/codex-mcp-http.mjs
CHANGED
|
@@ -1,131 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// Claude's Agent SDK consumes createSdkMcpServer() directly; Codex is a
|
|
3
|
-
// separate process and needs a transport. This private loopback endpoint keeps
|
|
4
|
-
// the handlers in the bridge, preserving the same room registry and gates.
|
|
5
|
-
import http from 'node:http'
|
|
6
|
-
import { randomUUID } from 'node:crypto'
|
|
7
|
-
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
8
|
-
|
|
9
|
-
export function withMcpSessionFactory(createSessionServer) {
|
|
10
|
-
if (typeof createSessionServer !== 'function') throw new TypeError('MCP session factory must be a function')
|
|
11
|
-
return Object.assign(createSessionServer(), { createSessionServer })
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export async function startCodexMcpHttp({ sdkServer, host = '127.0.0.1' } = {}) {
|
|
15
|
-
const createSessionServer = sdkServer?.createSessionServer
|
|
16
|
-
const initialInstance = sdkServer?.instance
|
|
17
|
-
if (!initialInstance?.connect || typeof createSessionServer !== 'function') {
|
|
18
|
-
throw new TypeError('Codex MCP requires an SDK MCP server and createSessionServer factory')
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const secretPath = `/mcp/${randomUUID()}`
|
|
22
|
-
// Stateful sessions are required: this SDK's stateless transport rejects the
|
|
23
|
-
// `notifications/initialized` POST sent by every MCP client. A transport is
|
|
24
|
-
// single-initialize, though, while Codex App Server -> exec fallback and Hermes
|
|
25
|
-
// crash recovery both start a new client against the same lane endpoint.
|
|
26
|
-
// Keep exactly one active child session per lane. A new initialize atomically
|
|
27
|
-
// retires the old server+transport before receiving its own isolated instance.
|
|
28
|
-
let active = null
|
|
29
|
-
let handoffChain = Promise.resolve()
|
|
30
|
-
|
|
31
|
-
const retireActive = () => {
|
|
32
|
-
const current = active
|
|
33
|
-
active = null
|
|
34
|
-
if (!current) return Promise.resolve()
|
|
35
|
-
// Detach first, then clean up in the background. In production an App
|
|
36
|
-
// Server MCP transport remained stuck in close while its watchdog fallback
|
|
37
|
-
// was already waiting to initialize `codex exec`; awaiting this cleanup
|
|
38
|
-
// consumed the replacement client's entire 30-second handshake budget.
|
|
39
|
-
return Promise.allSettled([
|
|
40
|
-
current.instance.close(),
|
|
41
|
-
current.transport.close(),
|
|
42
|
-
])
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const beginSession = async () => {
|
|
46
|
-
void retireActive()
|
|
47
|
-
const next = createSessionServer()
|
|
48
|
-
const instance = next?.instance
|
|
49
|
-
if (!instance?.connect) throw new TypeError('Codex MCP session factory did not return an SDK MCP server instance')
|
|
50
|
-
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() })
|
|
51
|
-
await instance.connect(transport)
|
|
52
|
-
active = { instance, transport }
|
|
53
|
-
return active
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const serializeHandoff = (task) => {
|
|
57
|
-
const run = handoffChain.then(task, task)
|
|
58
|
-
handoffChain = run.catch(() => {})
|
|
59
|
-
return run
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const server = http.createServer(async (req, res) => {
|
|
63
|
-
if (req.url !== secretPath) {
|
|
64
|
-
res.writeHead(404).end('not found')
|
|
65
|
-
return
|
|
66
|
-
}
|
|
67
|
-
try {
|
|
68
|
-
const sessionId = req.headers['mcp-session-id']
|
|
69
|
-
let target = active
|
|
70
|
-
if (!sessionId) {
|
|
71
|
-
if (req.method !== 'POST') {
|
|
72
|
-
res.writeHead(400).end('missing session')
|
|
73
|
-
return
|
|
74
|
-
}
|
|
75
|
-
// Serialize replacement initialize requests through their response
|
|
76
|
-
// boundary. This removes the window where two simultaneous children
|
|
77
|
-
// both observe no active session and orphan each other's transport.
|
|
78
|
-
await serializeHandoff(async () => {
|
|
79
|
-
target = await beginSession()
|
|
80
|
-
await target.transport.handleRequest(req, res)
|
|
81
|
-
})
|
|
82
|
-
return
|
|
83
|
-
} else if (!target?.transport?.sessionId || sessionId !== target.transport.sessionId) {
|
|
84
|
-
res.writeHead(404).end('session not found')
|
|
85
|
-
return
|
|
86
|
-
}
|
|
87
|
-
await target.transport.handleRequest(req, res)
|
|
88
|
-
} catch {
|
|
89
|
-
if (!res.headersSent) res.writeHead(500)
|
|
90
|
-
res.end()
|
|
91
|
-
}
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
try {
|
|
95
|
-
await new Promise((resolve, reject) => {
|
|
96
|
-
server.once('error', reject)
|
|
97
|
-
server.listen(0, host, resolve)
|
|
98
|
-
})
|
|
99
|
-
} catch (error) {
|
|
100
|
-
await retireActive()
|
|
101
|
-
throw error
|
|
102
|
-
}
|
|
103
|
-
const address = server.address()
|
|
104
|
-
const url = `http://${host}:${address.port}${secretPath}`
|
|
105
|
-
|
|
106
|
-
let closed = false
|
|
107
|
-
return {
|
|
108
|
-
url,
|
|
109
|
-
async handoff() {
|
|
110
|
-
if (closed) return
|
|
111
|
-
await serializeHandoff(async () => { void retireActive() })
|
|
112
|
-
},
|
|
113
|
-
async close() {
|
|
114
|
-
if (closed) return
|
|
115
|
-
closed = true
|
|
116
|
-
const closing = retireActive()
|
|
117
|
-
// A retired SDK server may itself be wedged. Closing the loopback listener
|
|
118
|
-
// must not keep the bridge process alive indefinitely on shutdown.
|
|
119
|
-
await Promise.allSettled([
|
|
120
|
-
Promise.race([
|
|
121
|
-
closing,
|
|
122
|
-
new Promise((resolve) => {
|
|
123
|
-
const timer = setTimeout(resolve, 1000)
|
|
124
|
-
timer.unref?.()
|
|
125
|
-
}),
|
|
126
|
-
]),
|
|
127
|
-
new Promise((resolve) => server.close(resolve)),
|
|
128
|
-
])
|
|
129
|
-
},
|
|
130
|
-
}
|
|
131
|
-
}
|
|
1
|
+
import e from"node:http";import{randomUUID as t}from"node:crypto";import{StreamableHTTPServerTransport as r}from"@modelcontextprotocol/sdk/server/streamableHttp.js";export function withMcpSessionFactory(e){if("function"!=typeof e)throw new TypeError("MCP session factory must be a function");return Object.assign(e(),{createSessionServer:e})}export async function startCodexMcpHttp({sdkServer:n,host:o="127.0.0.1"}={}){const s=n?.createSessionServer,a=n?.instance;if(!a?.connect||"function"!=typeof s)throw new TypeError("Codex MCP requires an SDK MCP server and createSessionServer factory");const i=`/mcp/${t()}`;let c=null,d=Promise.resolve();const l=()=>{const e=c;return c=null,e?Promise.allSettled([e.instance.close(),e.transport.close()]):Promise.resolve()},p=e=>{const t=d.then(e,e);return d=t.catch(()=>{}),t},u=e.createServer(async(e,n)=>{if(e.url===i)try{const o=e.headers["mcp-session-id"];let a=c;if(!o)return"POST"!==e.method?void n.writeHead(400).end("missing session"):void await p(async()=>{a=await(async()=>{l();const e=s(),n=e?.instance;if(!n?.connect)throw new TypeError("Codex MCP session factory did not return an SDK MCP server instance");const o=new r({sessionIdGenerator:()=>t()});return await n.connect(o),c={instance:n,transport:o},c})(),await a.transport.handleRequest(e,n)});if(!a?.transport?.sessionId||o!==a.transport.sessionId)return void n.writeHead(404).end("session not found");await a.transport.handleRequest(e,n)}catch{n.headersSent||n.writeHead(500),n.end()}else n.writeHead(404).end("not found")});try{await new Promise((e,t)=>{u.once("error",t),u.listen(0,o,e)})}catch(e){throw await l(),e}const f=u.address(),w=`http://${o}:${f.port}${i}`;let m=!1;return{url:w,async handoff(){m||await p(async()=>{l()})},async close(){if(m)return;m=!0;const e=l();await Promise.allSettled([Promise.race([e,new Promise(e=>{const t=setTimeout(e,1e3);t.unref?.()})]),new Promise(e=>u.close(e))])}}}
|