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
@@ -1,232 +1 @@
1
- // codex-event-mapper.mjs maps a Codex `codex exec --json` event stream into
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 'node:fs'
2
- import path from 'node:path'
3
-
4
- const IMAGE_MEDIA_TYPE = /^image\/[a-z0-9.+-]+$/i
5
- const SAFE_ID_PREFIX = /^[a-zA-Z0-9_-]{1,8}$/
6
-
7
- export const safeRoomFileName = (name) => String(name || 'file').replace(/[^a-zA-Z0-9._-]/g, '_').slice(-80)
8
-
9
- /**
10
- * Rebuild the deterministic room-upload path from bridge-owned facts.
11
- * `hostPath` is intentionally ignored: it is display metadata supplied by a
12
- * browser and must never grant Codex access to an arbitrary host file.
13
- */
14
- export function canonicalRoomFilePath(file, { updir } = {}) {
15
- if (!file || !updir) return null
16
- const idPrefix = String(file.id || '').slice(0, 8)
17
- if (!SAFE_ID_PREFIX.test(idPrefix)) return null
18
- const root = path.resolve(String(updir))
19
- const candidate = path.resolve(root, `${idPrefix}-${safeRoomFileName(file.name)}`)
20
- return candidate.startsWith(root + path.sep) ? candidate : null
21
- }
22
-
23
- export function nativeImageCandidates(files, { updir } = {}) {
24
- if (!Array.isArray(files)) return []
25
- const seen = new Set()
26
- const candidates = []
27
- for (const file of files) {
28
- if (!IMAGE_MEDIA_TYPE.test(String(file?.mediaType || ''))) continue
29
- const candidate = canonicalRoomFilePath(file, { updir })
30
- if (!candidate || seen.has(candidate)) continue
31
- seen.add(candidate)
32
- candidates.push(candidate)
33
- }
34
- return candidates
35
- }
36
-
37
- const fileReady = async (filePath) => {
38
- try { return (await fs.promises.stat(filePath)).isFile() }
39
- catch { return false }
40
- }
41
-
42
- const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
43
-
44
- /**
45
- * `file-put` and `code-turn` are separate realtime broadcasts, so the turn can
46
- * arrive while the signed download is still in flight. Wait briefly for only
47
- * canonical image paths; timed-out images stay in the text prompt as ordinary
48
- * quoted paths instead of failing or delaying the turn indefinitely.
49
- */
50
- export async function waitForNativeImages(files, {
51
- updir,
52
- timeoutMs = 2000,
53
- pollMs = 40,
54
- isReady = fileReady,
55
- sleep = delay,
56
- now = Date.now,
57
- } = {}) {
58
- const candidates = nativeImageCandidates(files, { updir })
59
- if (!candidates.length) return []
60
- const ready = new Set()
61
- const deadline = now() + Math.max(0, timeoutMs)
62
- while (true) {
63
- const checks = await Promise.all(candidates.map(async (candidate) => [candidate, await isReady(candidate)]))
64
- for (const [candidate, exists] of checks) if (exists) ready.add(candidate)
65
- if (ready.size === candidates.length || now() >= deadline) break
66
- await sleep(Math.min(Math.max(1, pollMs), Math.max(1, deadline - now())))
67
- }
68
- return candidates.filter((candidate) => ready.has(candidate))
69
- }
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))}
@@ -1,131 +1 @@
1
- // Expose one bridge-owned, in-process MCP server to a Codex CLI child.
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))])}}}