the-campfire 0.1.0

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 (180) hide show
  1. package/bin/cli.ts +182 -0
  2. package/dist/apple-touch-icon.png +0 -0
  3. package/dist/assets/MermaidDiagram-By1J3Whj.js +2 -0
  4. package/dist/assets/_basePickBy-Dq6gnD-p.js +1 -0
  5. package/dist/assets/_baseUniq-SpgN6cGc.js +1 -0
  6. package/dist/assets/arc-D9SEA8dX.js +1 -0
  7. package/dist/assets/architectureDiagram-VXUJARFQ-CoTcwLeR.js +36 -0
  8. package/dist/assets/blockDiagram-VD42YOAC-Dvc63voV.js +122 -0
  9. package/dist/assets/c4Diagram-YG6GDRKO-tuDcG4_4.js +10 -0
  10. package/dist/assets/channel-CxhcHn7S.js +1 -0
  11. package/dist/assets/chunk-4BX2VUAB-x1hChYu9.js +1 -0
  12. package/dist/assets/chunk-55IACEB6-BX6HWnWP.js +1 -0
  13. package/dist/assets/chunk-B4BG7PRW-B4IlF03G.js +165 -0
  14. package/dist/assets/chunk-DI55MBZ5-TpYb5rLg.js +220 -0
  15. package/dist/assets/chunk-FMBD7UC4-DZX4LUPx.js +15 -0
  16. package/dist/assets/chunk-QN33PNHL-CuQs0NDY.js +1 -0
  17. package/dist/assets/chunk-QZHKN3VN-CygWwVx8.js +1 -0
  18. package/dist/assets/chunk-TZMSLE5B-CtI-IdKV.js +1 -0
  19. package/dist/assets/classDiagram-2ON5EDUG-BM0LptJF.js +1 -0
  20. package/dist/assets/classDiagram-v2-WZHVMYZB-BM0LptJF.js +1 -0
  21. package/dist/assets/clone-C6kt1rE7.js +1 -0
  22. package/dist/assets/cose-bilkent-S5V4N54A-PdySs991.js +1 -0
  23. package/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
  24. package/dist/assets/dagre-6UL2VRFP-J0SxEiEu.js +4 -0
  25. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  26. package/dist/assets/diagram-PSM6KHXK-CW0HLXaH.js +24 -0
  27. package/dist/assets/diagram-QEK2KX5R-DADHF-k4.js +43 -0
  28. package/dist/assets/diagram-S2PKOQOG-9S0-PC3S.js +24 -0
  29. package/dist/assets/erDiagram-Q2GNP2WA-BEHAiY_q.js +60 -0
  30. package/dist/assets/flowDiagram-NV44I4VS-Cu6n9Xnd.js +162 -0
  31. package/dist/assets/ganttDiagram-JELNMOA3-Cr_BHryu.js +267 -0
  32. package/dist/assets/gitGraphDiagram-V2S2FVAM-D5pT35v5.js +65 -0
  33. package/dist/assets/graph-DKRbrxcs.js +1 -0
  34. package/dist/assets/index-Byjg9zgI.css +32 -0
  35. package/dist/assets/index-Cwj3m0hT.js +284 -0
  36. package/dist/assets/infoDiagram-HS3SLOUP-BaJlrgKV.js +2 -0
  37. package/dist/assets/init-Gi6I4Gst.js +1 -0
  38. package/dist/assets/journeyDiagram-XKPGCS4Q-CXC4-DJv.js +139 -0
  39. package/dist/assets/kanban-definition-3W4ZIXB7-BtHBu9cx.js +89 -0
  40. package/dist/assets/katex-BlHpptmG.js +261 -0
  41. package/dist/assets/layout-DnxSwLDJ.js +1 -0
  42. package/dist/assets/linear-CWbbIT8l.js +1 -0
  43. package/dist/assets/mermaid.core-DXn1cFAK.js +256 -0
  44. package/dist/assets/mindmap-definition-VGOIOE7T-BR8br1hs.js +68 -0
  45. package/dist/assets/ordinal-BENe2yWM.js +1 -0
  46. package/dist/assets/pieDiagram-ADFJNKIX-B7RmXV7I.js +30 -0
  47. package/dist/assets/quadrantDiagram-AYHSOK5B-DK0FhyV_.js +7 -0
  48. package/dist/assets/requirementDiagram-UZGBJVZJ-3zAGxSpL.js +64 -0
  49. package/dist/assets/sankeyDiagram-TZEHDZUN-DZppKtjc.js +10 -0
  50. package/dist/assets/sequenceDiagram-WL72ISMW-D1x3r3wV.js +145 -0
  51. package/dist/assets/stateDiagram-FKZM4ZOC-2ByD08Vq.js +1 -0
  52. package/dist/assets/stateDiagram-v2-4FDKWEC3-Vm1cYjgV.js +1 -0
  53. package/dist/assets/timeline-definition-IT6M3QCI-l5S35biR.js +61 -0
  54. package/dist/assets/treemap-GDKQZRPO-DIPadPiF.js +162 -0
  55. package/dist/assets/xychartDiagram-PRI3JC2R-9GUvwS2b.js +7 -0
  56. package/dist/favicon.svg +3 -0
  57. package/dist/fonts/Geist-Variable.woff2 +0 -0
  58. package/dist/fonts/GeistMono-Variable.woff2 +0 -0
  59. package/dist/icon-192.png +0 -0
  60. package/dist/icon-512.png +0 -0
  61. package/dist/index.html +33 -0
  62. package/dist/logo-codex.svg +14 -0
  63. package/dist/logo.svg +3 -0
  64. package/dist/manifest.json +26 -0
  65. package/dist/og-image.png +0 -0
  66. package/dist/sw.js +130 -0
  67. package/package.json +92 -0
  68. package/server/ADAPTERS.md +121 -0
  69. package/server/adapter-registry-types.ts +28 -0
  70. package/server/adapter-registry.ts +293 -0
  71. package/server/adapter-types.ts +51 -0
  72. package/server/agent-executor.ts +249 -0
  73. package/server/agent-mcp-bridge.ts +148 -0
  74. package/server/agent-mcp-stdio.ts +171 -0
  75. package/server/agent-mcp-tools.ts +76 -0
  76. package/server/agent-store.ts +183 -0
  77. package/server/agent-types.ts +79 -0
  78. package/server/aider-adapter.ts +525 -0
  79. package/server/auth.ts +153 -0
  80. package/server/auto-namer.ts +132 -0
  81. package/server/capability-discovery.ts +431 -0
  82. package/server/claude-container-auth.ts +40 -0
  83. package/server/claude-stdio-adapter.ts +452 -0
  84. package/server/clawhub-export.ts +218 -0
  85. package/server/cli-launcher.ts +1613 -0
  86. package/server/codex-adapter.ts +1987 -0
  87. package/server/codex-container-auth.ts +40 -0
  88. package/server/codex-home.ts +26 -0
  89. package/server/collective-intelligence.ts +401 -0
  90. package/server/commands-discovery.ts +160 -0
  91. package/server/constants.ts +5 -0
  92. package/server/container-manager.ts +328 -0
  93. package/server/cron-scheduler.ts +243 -0
  94. package/server/cron-store.ts +149 -0
  95. package/server/cron-types.ts +63 -0
  96. package/server/deliberation-engine.ts +323 -0
  97. package/server/embedding.ts +89 -0
  98. package/server/env-manager.ts +146 -0
  99. package/server/environment-detector.ts +106 -0
  100. package/server/environment-rules.ts +106 -0
  101. package/server/gallery-store.ts +216 -0
  102. package/server/gallery-types.ts +52 -0
  103. package/server/gallery-votes.ts +101 -0
  104. package/server/git-utils.ts +384 -0
  105. package/server/github-pr.ts +379 -0
  106. package/server/goose-adapter.ts +929 -0
  107. package/server/image-pull-manager.ts +144 -0
  108. package/server/index.ts +363 -0
  109. package/server/linear-project-manager.ts +78 -0
  110. package/server/moltbook-client.ts +160 -0
  111. package/server/openclaw-adapter.ts +916 -0
  112. package/server/openclaw-channel/channel.ts +188 -0
  113. package/server/openclaw-channel/index.ts +68 -0
  114. package/server/openclaw-channel/package.json +19 -0
  115. package/server/openclaw-channel/types.ts +93 -0
  116. package/server/opencode-adapter.ts +951 -0
  117. package/server/openhands-adapter.ts +775 -0
  118. package/server/orchestrator-executor.ts +184 -0
  119. package/server/orchestrator-store.ts +106 -0
  120. package/server/orchestrator-types.ts +86 -0
  121. package/server/path-resolver.ts +168 -0
  122. package/server/pr-poller.ts +162 -0
  123. package/server/proactive-keepalive.ts +121 -0
  124. package/server/prompt-manager.ts +103 -0
  125. package/server/protocol-monitor.ts +197 -0
  126. package/server/race-controller.ts +295 -0
  127. package/server/race-store.ts +82 -0
  128. package/server/recorder.ts +359 -0
  129. package/server/recording-hub/compat-validator.ts +122 -0
  130. package/server/recording-hub/diagnostics.ts +289 -0
  131. package/server/recording-hub/hub-routes.ts +146 -0
  132. package/server/recording-hub/hub-store.ts +224 -0
  133. package/server/replay.ts +78 -0
  134. package/server/routes/adapter-routes.ts +31 -0
  135. package/server/routes/agent-mcp-routes.ts +35 -0
  136. package/server/routes/agent-routes.ts +185 -0
  137. package/server/routes/auth-routes.ts +101 -0
  138. package/server/routes/ci-routes.ts +165 -0
  139. package/server/routes/commands-routes.ts +94 -0
  140. package/server/routes/cron-routes.ts +100 -0
  141. package/server/routes/env-routes.ts +50 -0
  142. package/server/routes/folder-routes.ts +56 -0
  143. package/server/routes/fs-routes.ts +403 -0
  144. package/server/routes/gallery-routes.ts +410 -0
  145. package/server/routes/git-routes.ts +108 -0
  146. package/server/routes/index.ts +62 -0
  147. package/server/routes/linear-routes.ts +254 -0
  148. package/server/routes/monitor-routes.ts +11 -0
  149. package/server/routes/orchestrator-routes.ts +210 -0
  150. package/server/routes/prompt-routes.ts +47 -0
  151. package/server/routes/race-routes.ts +103 -0
  152. package/server/routes/recording-routes.ts +101 -0
  153. package/server/routes/route-deps.ts +32 -0
  154. package/server/routes/session-routes.ts +560 -0
  155. package/server/routes/settings-routes.ts +108 -0
  156. package/server/routes/skills-routes.ts +52 -0
  157. package/server/routes/system-routes.ts +243 -0
  158. package/server/routes/webhook-routes.ts +135 -0
  159. package/server/routes.ts +44 -0
  160. package/server/security-middleware.ts +100 -0
  161. package/server/semantic-memory.ts +389 -0
  162. package/server/service.ts +733 -0
  163. package/server/session-folders.ts +107 -0
  164. package/server/session-git-info.ts +89 -0
  165. package/server/session-linear-issues.ts +94 -0
  166. package/server/session-names.ts +67 -0
  167. package/server/session-store.ts +169 -0
  168. package/server/session-types.ts +415 -0
  169. package/server/settings-manager.ts +120 -0
  170. package/server/shared-context.ts +401 -0
  171. package/server/skills-manager.ts +250 -0
  172. package/server/sub-session-manager.ts +263 -0
  173. package/server/terminal-manager.ts +185 -0
  174. package/server/update-checker.ts +114 -0
  175. package/server/usage-limits.ts +192 -0
  176. package/server/webhook-manager.ts +228 -0
  177. package/server/webhook-store.ts +168 -0
  178. package/server/webhook-types.ts +66 -0
  179. package/server/worktree-tracker.ts +84 -0
  180. package/server/ws-bridge.ts +2322 -0
@@ -0,0 +1,1987 @@
1
+ /**
2
+ * Codex App-Server Adapter
3
+ *
4
+ * Translates between the Codex app-server JSON-RPC protocol (stdin/stdout)
5
+ * and Campfire's BrowserIncomingMessage/BrowserOutgoingMessage types.
6
+ *
7
+ * This allows the browser to be completely unaware of which backend is running —
8
+ * it sees the same message types regardless of whether Claude Code or Codex is
9
+ * the backend.
10
+ */
11
+
12
+ import { randomUUID } from "node:crypto";
13
+ import type { Subprocess } from "bun";
14
+ import type {
15
+ BrowserIncomingMessage,
16
+ BrowserOutgoingMessage,
17
+ SessionState,
18
+ PermissionRequest,
19
+ CLIResultMessage,
20
+ McpServerDetail,
21
+ McpServerConfig,
22
+ } from "./session-types.js";
23
+ import type { RecorderManager } from "./recorder.js";
24
+ import type { AgentAdapter } from "./adapter-types.js";
25
+
26
+ // ─── Codex JSON-RPC Types ─────────────────────────────────────────────────────
27
+
28
+ interface JsonRpcRequest {
29
+ method: string;
30
+ id: number;
31
+ params: Record<string, unknown>;
32
+ }
33
+
34
+ interface JsonRpcNotification {
35
+ method: string;
36
+ params: Record<string, unknown>;
37
+ }
38
+
39
+ interface JsonRpcResponse {
40
+ id: number;
41
+ result?: unknown;
42
+ error?: { code: number; message: string; data?: unknown };
43
+ }
44
+
45
+ type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse;
46
+
47
+ // Codex item types
48
+ interface CodexItem {
49
+ type: string;
50
+ id: string;
51
+ [key: string]: unknown;
52
+ }
53
+
54
+ /** Safely extract a string kind from a Codex file change entry.
55
+ * Codex may send kind as a string ("create") or as an object ({ type: "modify" }). */
56
+ function safeKind(kind: unknown): string {
57
+ if (typeof kind === "string") return kind;
58
+ if (kind && typeof kind === "object" && "type" in kind) {
59
+ const t = (kind as Record<string, unknown>).type;
60
+ if (typeof t === "string") return t;
61
+ }
62
+ return "modify";
63
+ }
64
+
65
+ interface CodexAgentMessageItem extends CodexItem {
66
+ type: "agentMessage";
67
+ text?: string;
68
+ }
69
+
70
+ interface CodexCommandExecutionItem extends CodexItem {
71
+ type: "commandExecution";
72
+ command: string | string[];
73
+ cwd?: string;
74
+ status: "inProgress" | "completed" | "failed" | "declined";
75
+ exitCode?: number;
76
+ durationMs?: number;
77
+ }
78
+
79
+ interface CodexFileChangeItem extends CodexItem {
80
+ type: "fileChange";
81
+ changes?: Array<{ path: string; kind: unknown; diff?: string }>;
82
+ status: "inProgress" | "completed" | "failed" | "declined";
83
+ }
84
+
85
+ interface CodexMcpToolCallItem extends CodexItem {
86
+ type: "mcpToolCall";
87
+ server: string;
88
+ tool: string;
89
+ status: "inProgress" | "completed" | "failed";
90
+ arguments?: Record<string, unknown>;
91
+ result?: string;
92
+ error?: string;
93
+ }
94
+
95
+ interface CodexWebSearchItem extends CodexItem {
96
+ type: "webSearch";
97
+ query?: string;
98
+ action?: { type: string; url?: string; pattern?: string };
99
+ }
100
+
101
+ interface CodexReasoningItem extends CodexItem {
102
+ type: "reasoning";
103
+ summary?: string;
104
+ content?: string;
105
+ }
106
+
107
+ interface CodexContextCompactionItem extends CodexItem {
108
+ type: "contextCompaction";
109
+ }
110
+
111
+ interface CodexMcpServerStatus {
112
+ name: string;
113
+ tools?: Record<string, { name?: string; annotations?: unknown }>;
114
+ authStatus?: "unsupported" | "notLoggedIn" | "bearerToken" | "oAuth";
115
+ }
116
+
117
+ interface CodexMcpStatusListResponse {
118
+ data?: CodexMcpServerStatus[];
119
+ nextCursor?: string | null;
120
+ }
121
+
122
+ // ─── Adapter Options ──────────────────────────────────────────────────────────
123
+
124
+ export interface CodexAdapterOptions {
125
+ model?: string;
126
+ cwd?: string;
127
+ approvalMode?: string;
128
+ sandbox?: "workspace-write" | "danger-full-access";
129
+ /** If provided, resume an existing thread instead of starting a new one. */
130
+ threadId?: string;
131
+ /** Optional recorder for raw message capture. */
132
+ recorder?: RecorderManager;
133
+ /** Reasoning effort for o-series models (low/medium/high). */
134
+ reasoningEffort?: "low" | "medium" | "high";
135
+ }
136
+
137
+ // ─── JSON-RPC Transport ───────────────────────────────────────────────────────
138
+
139
+ class JsonRpcTransport {
140
+ private nextId = 1;
141
+ private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
142
+ private notificationHandler: ((method: string, params: Record<string, unknown>) => void) | null = null;
143
+ private requestHandler: ((method: string, id: number, params: Record<string, unknown>) => void) | null = null;
144
+ private rawInCb: ((line: string) => void) | null = null;
145
+ private rawOutCb: ((data: string) => void) | null = null;
146
+ private writer: WritableStreamDefaultWriter<Uint8Array>;
147
+ private connected = true;
148
+ private buffer = "";
149
+
150
+ constructor(
151
+ stdin: WritableStream<Uint8Array> | { write(data: Uint8Array): number },
152
+ stdout: ReadableStream<Uint8Array>,
153
+ ) {
154
+ // Handle both Bun subprocess stdin types
155
+ let writable: WritableStream<Uint8Array>;
156
+ if ("write" in stdin && typeof stdin.write === "function") {
157
+ // Bun's subprocess stdin has a .write() method directly
158
+ writable = new WritableStream({
159
+ write(chunk) {
160
+ (stdin as { write(data: Uint8Array): number }).write(chunk);
161
+ },
162
+ });
163
+ } else {
164
+ writable = stdin as WritableStream<Uint8Array>;
165
+ }
166
+ // Acquire writer once and hold it — avoids "WritableStream is locked" race
167
+ // when concurrent async calls (e.g. rateLimits + turn/start) overlap.
168
+ this.writer = writable.getWriter();
169
+
170
+ this.readStdout(stdout);
171
+ }
172
+
173
+ private async readStdout(stdout: ReadableStream<Uint8Array>): Promise<void> {
174
+ const reader = stdout.getReader();
175
+ const decoder = new TextDecoder();
176
+ try {
177
+ while (true) {
178
+ const { done, value } = await reader.read();
179
+ if (done) break;
180
+ this.buffer += decoder.decode(value, { stream: true });
181
+ this.processBuffer();
182
+ }
183
+ } catch (err) {
184
+ console.error("[codex-adapter] stdout reader error:", err);
185
+ } finally {
186
+ this.connected = false;
187
+ // Reject all pending promises so callers don't hang indefinitely
188
+ // when the Codex process crashes or exits unexpectedly.
189
+ for (const [id, { reject }] of this.pending) {
190
+ reject(new Error("Transport closed"));
191
+ }
192
+ this.pending.clear();
193
+ }
194
+ }
195
+
196
+ private processBuffer(): void {
197
+ const lines = this.buffer.split("\n");
198
+ // Keep the last incomplete line in the buffer
199
+ this.buffer = lines.pop() || "";
200
+
201
+ for (const line of lines) {
202
+ const trimmed = line.trim();
203
+ if (!trimmed) continue;
204
+
205
+ // Record raw incoming line before parsing
206
+ this.rawInCb?.(trimmed);
207
+
208
+ let msg: JsonRpcMessage;
209
+ try {
210
+ msg = JSON.parse(trimmed);
211
+ } catch {
212
+ console.warn("[codex-adapter] Failed to parse JSON-RPC:", trimmed.substring(0, 200));
213
+ continue;
214
+ }
215
+
216
+ this.dispatch(msg);
217
+ }
218
+ }
219
+
220
+ private dispatch(msg: JsonRpcMessage): void {
221
+ if ("id" in msg && msg.id !== undefined) {
222
+ if ("method" in msg && msg.method) {
223
+ // This is a request FROM the server (e.g., approval request)
224
+ this.requestHandler?.(msg.method, msg.id as number, (msg as JsonRpcRequest).params || {});
225
+ } else {
226
+ // This is a response to one of our requests
227
+ const pending = this.pending.get(msg.id as number);
228
+ if (pending) {
229
+ this.pending.delete(msg.id as number);
230
+ const resp = msg as JsonRpcResponse;
231
+ if (resp.error) {
232
+ pending.reject(new Error(resp.error.message));
233
+ } else {
234
+ pending.resolve(resp.result);
235
+ }
236
+ }
237
+ }
238
+ } else if ("method" in msg) {
239
+ // Notification (no id)
240
+ this.notificationHandler?.(msg.method, (msg as JsonRpcNotification).params || {});
241
+ }
242
+ }
243
+
244
+ /** Send a request and wait for the matching response. */
245
+ async call(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
246
+ const id = this.nextId++;
247
+ return new Promise(async (resolve, reject) => {
248
+ this.pending.set(id, { resolve, reject });
249
+ const request = JSON.stringify({ method, id, params });
250
+ try {
251
+ await this.writeRaw(request + "\n");
252
+ } catch (err) {
253
+ this.pending.delete(id);
254
+ reject(err instanceof Error ? err : new Error(String(err)));
255
+ }
256
+ });
257
+ }
258
+
259
+ /** Send a notification (no response expected). */
260
+ async notify(method: string, params: Record<string, unknown> = {}): Promise<void> {
261
+ const notification = JSON.stringify({ method, params });
262
+ await this.writeRaw(notification + "\n");
263
+ }
264
+
265
+ /** Respond to a request from the server (e.g., approval). */
266
+ async respond(id: number, result: unknown): Promise<void> {
267
+ const response = JSON.stringify({ id, result });
268
+ await this.writeRaw(response + "\n");
269
+ }
270
+
271
+ /** Register handler for server-initiated notifications. */
272
+ onNotification(handler: (method: string, params: Record<string, unknown>) => void): void {
273
+ this.notificationHandler = handler;
274
+ }
275
+
276
+ /** Register handler for server-initiated requests (need a response). */
277
+ onRequest(handler: (method: string, id: number, params: Record<string, unknown>) => void): void {
278
+ this.requestHandler = handler;
279
+ }
280
+
281
+ isConnected(): boolean {
282
+ return this.connected;
283
+ }
284
+
285
+ /** Register callback for raw incoming lines (before JSON parse). */
286
+ onRawIncoming(cb: (line: string) => void): void {
287
+ this.rawInCb = cb;
288
+ }
289
+
290
+ /** Register callback for raw outgoing data (before write). */
291
+ onRawOutgoing(cb: (data: string) => void): void {
292
+ this.rawOutCb = cb;
293
+ }
294
+
295
+ private async writeRaw(data: string): Promise<void> {
296
+ if (!this.connected) {
297
+ throw new Error("Transport closed");
298
+ }
299
+ // Record raw outgoing data before writing
300
+ this.rawOutCb?.(data);
301
+ await this.writer.write(new TextEncoder().encode(data));
302
+ }
303
+ }
304
+
305
+ // ─── Codex Adapter ────────────────────────────────────────────────────────────
306
+
307
+ // ─── Codex Model Pricing (USD per token) ─────────────────────────────────────
308
+ // Source: https://openai.com/api/pricing/
309
+ // Prices are per-token (not per million). Cached input uses reduced rates.
310
+ const CODEX_MODEL_PRICING: Record<string, { input: number; cachedInput: number; output: number }> = {
311
+ "o3": { input: 2.00 / 1e6, cachedInput: 0.50 / 1e6, output: 8.00 / 1e6 },
312
+ "o3-pro": { input: 20.00 / 1e6, cachedInput: 20.00 / 1e6, output: 80.00 / 1e6 },
313
+ "o4-mini": { input: 1.10 / 1e6, cachedInput: 0.275 / 1e6, output: 4.40 / 1e6 },
314
+ "gpt-5": { input: 1.25 / 1e6, cachedInput: 0.125 / 1e6, output: 10.00 / 1e6 },
315
+ "gpt-5-codex": { input: 1.25 / 1e6, cachedInput: 0.125 / 1e6, output: 10.00 / 1e6 },
316
+ "gpt-5.2-codex": { input: 1.25 / 1e6, cachedInput: 0.125 / 1e6, output: 10.00 / 1e6 },
317
+ "gpt-5-mini": { input: 0.30 / 1e6, cachedInput: 0.03 / 1e6, output: 1.25 / 1e6 },
318
+ "gpt-4.1": { input: 2.00 / 1e6, cachedInput: 0.50 / 1e6, output: 8.00 / 1e6 },
319
+ "gpt-4.1-mini": { input: 0.40 / 1e6, cachedInput: 0.10 / 1e6, output: 1.60 / 1e6 },
320
+ "gpt-4.1-nano": { input: 0.10 / 1e6, cachedInput: 0.025 / 1e6, output: 0.40 / 1e6 },
321
+ "gpt-4o": { input: 2.50 / 1e6, cachedInput: 1.25 / 1e6, output: 10.00 / 1e6 },
322
+ "gpt-4o-mini": { input: 0.15 / 1e6, cachedInput: 0.075 / 1e6, output: 0.60 / 1e6 },
323
+ };
324
+
325
+ /** Look up pricing for a model, falling back to prefix matching then a default. */
326
+ function getCodexPricing(model: string): { input: number; cachedInput: number; output: number } {
327
+ const lower = model.toLowerCase();
328
+ // Exact match
329
+ if (CODEX_MODEL_PRICING[lower]) return CODEX_MODEL_PRICING[lower];
330
+ // Prefix match (handles dated versions like "gpt-5-codex-2025-09-23")
331
+ for (const [key, pricing] of Object.entries(CODEX_MODEL_PRICING)) {
332
+ if (lower.startsWith(key)) return pricing;
333
+ }
334
+ // Default: gpt-5-codex pricing as a reasonable fallback
335
+ return CODEX_MODEL_PRICING["gpt-5-codex"];
336
+ }
337
+
338
+ export class CodexAdapter implements AgentAdapter {
339
+ private transport: JsonRpcTransport;
340
+ private proc: Subprocess;
341
+ private sessionId: string;
342
+ private options: CodexAdapterOptions;
343
+
344
+ private browserMessageCb: ((msg: BrowserIncomingMessage) => void) | null = null;
345
+ private sessionMetaCb: ((meta: { cliSessionId?: string; model?: string; cwd?: string }) => void) | null = null;
346
+ private disconnectCb: (() => void) | null = null;
347
+ private initErrorCb: ((error: string) => void) | null = null;
348
+
349
+ // State
350
+ private threadId: string | null = null;
351
+ private currentTurnId: string | null = null;
352
+ private connected = false;
353
+ private initialized = false;
354
+ private initFailed = false;
355
+
356
+ // Streaming accumulator for agent messages
357
+ private streamingText = "";
358
+ private streamingItemId: string | null = null;
359
+
360
+ // Track command execution start times for progress indicator
361
+ private commandStartTimes = new Map<string, number>();
362
+
363
+ // Accumulate reasoning text by item ID so we can emit final thinking blocks.
364
+ private reasoningTextByItemId = new Map<string, string>();
365
+
366
+ // Track which item IDs we have already emitted a tool_use block for.
367
+ // When Codex auto-approves (approvalPolicy "never"), it may skip item/started
368
+ // and only send item/completed — we need to emit tool_use before tool_result.
369
+ private emittedToolUseIds = new Set<string>();
370
+
371
+ // Queue messages received before initialization completes
372
+ private pendingOutgoing: BrowserOutgoingMessage[] = [];
373
+
374
+ // Pending approval requests (Codex sends these as JSON-RPC requests with an id)
375
+ private pendingApprovals = new Map<string, number>(); // request_id -> JSON-RPC id
376
+
377
+ // Track request types that need different response formats
378
+ private pendingUserInputQuestionIds = new Map<string, string[]>(); // request_id -> ordered Codex question IDs
379
+ private pendingReviewDecisions = new Set<string>(); // request_ids that need ReviewDecision format
380
+ private pendingDynamicToolCalls = new Map<string, {
381
+ jsonRpcId: number;
382
+ callId: string;
383
+ toolName: string;
384
+ timeout: ReturnType<typeof setTimeout>;
385
+ }>(); // request_id -> pending dynamic tool call metadata
386
+
387
+ // Cumulative cost tracking (calculated from token usage + model pricing)
388
+ private cumulativeCostUsd = 0;
389
+ private cumulativeTurns = 0;
390
+ private lastTotalTokens: { input: number; output: number } = { input: 0, output: 0 };
391
+
392
+ // Codex account rate limits (fetched after init, updated via notification)
393
+ private _rateLimits: {
394
+ primary: { usedPercent: number; windowDurationMins: number; resetsAt: number } | null;
395
+ secondary: { usedPercent: number; windowDurationMins: number; resetsAt: number } | null;
396
+ } | null = null;
397
+ private static readonly DYNAMIC_TOOL_CALL_TIMEOUT_MS = 120_000;
398
+
399
+ constructor(proc: Subprocess, sessionId: string, options: CodexAdapterOptions = {}) {
400
+ this.proc = proc;
401
+ this.sessionId = sessionId;
402
+ this.options = options;
403
+
404
+ const stdout = proc.stdout;
405
+ const stdin = proc.stdin;
406
+ if (!stdout || !stdin || typeof stdout === "number" || typeof stdin === "number") {
407
+ throw new Error("Codex process must have stdio pipes");
408
+ }
409
+
410
+ this.transport = new JsonRpcTransport(
411
+ stdin as WritableStream<Uint8Array> | { write(data: Uint8Array): number },
412
+ stdout as ReadableStream<Uint8Array>,
413
+ );
414
+ this.transport.onNotification((method, params) => this.handleNotification(method, params));
415
+ this.transport.onRequest((method, id, params) => this.handleRequest(method, id, params));
416
+
417
+ // Wire raw message recording if a recorder is provided
418
+ if (options.recorder) {
419
+ const recorder = options.recorder;
420
+ const cwd = options.cwd || "";
421
+ this.transport.onRawIncoming((line) => {
422
+ recorder.record(sessionId, "in", line, "cli", "codex", cwd);
423
+ });
424
+ this.transport.onRawOutgoing((data) => {
425
+ recorder.record(sessionId, "out", data.trimEnd(), "cli", "codex", cwd);
426
+ });
427
+ }
428
+
429
+ // Monitor process exit
430
+ proc.exited.then(() => {
431
+ this.connected = false;
432
+ for (const pending of this.pendingDynamicToolCalls.values()) {
433
+ clearTimeout(pending.timeout);
434
+ }
435
+ this.pendingDynamicToolCalls.clear();
436
+ this.disconnectCb?.();
437
+ });
438
+
439
+ // Start initialization
440
+ this.initialize();
441
+ }
442
+
443
+ // ── Public API ──────────────────────────────────────────────────────────
444
+
445
+ getRateLimits() {
446
+ return this._rateLimits;
447
+ }
448
+
449
+ sendBrowserMessage(msg: BrowserOutgoingMessage): boolean {
450
+ // If initialization failed, reject all new messages
451
+ if (this.initFailed) {
452
+ return false;
453
+ }
454
+
455
+ // Queue messages if not yet initialized (init is async)
456
+ if (!this.initialized || !this.threadId) {
457
+ if (
458
+ msg.type === "user_message"
459
+ || msg.type === "permission_response"
460
+ || msg.type === "mcp_get_status"
461
+ || msg.type === "mcp_toggle"
462
+ || msg.type === "mcp_reconnect"
463
+ || msg.type === "mcp_set_servers"
464
+ ) {
465
+ console.log(`[codex-adapter] Queuing ${msg.type} — adapter not yet initialized`);
466
+ this.pendingOutgoing.push(msg);
467
+ return true; // accepted, will be sent after init
468
+ }
469
+ // Non-queueable messages are dropped if not connected
470
+ if (!this.connected) return false;
471
+ }
472
+
473
+ return this.dispatchOutgoing(msg);
474
+ }
475
+
476
+ private dispatchOutgoing(msg: BrowserOutgoingMessage): boolean {
477
+ switch (msg.type) {
478
+ case "user_message":
479
+ this.handleOutgoingUserMessage(msg);
480
+ return true;
481
+ case "permission_response":
482
+ this.handleOutgoingPermissionResponse(msg);
483
+ return true;
484
+ case "interrupt":
485
+ this.handleOutgoingInterrupt();
486
+ return true;
487
+ case "set_model":
488
+ console.warn("[codex-adapter] Runtime model switching not supported by Codex");
489
+ return false;
490
+ case "set_permission_mode":
491
+ console.warn("[codex-adapter] Runtime permission mode switching not supported by Codex");
492
+ return false;
493
+ case "mcp_get_status":
494
+ this.handleOutgoingMcpGetStatus();
495
+ return true;
496
+ case "mcp_toggle":
497
+ this.handleOutgoingMcpToggle(msg.serverName, msg.enabled);
498
+ return true;
499
+ case "mcp_reconnect":
500
+ this.handleOutgoingMcpReconnect();
501
+ return true;
502
+ case "mcp_set_servers":
503
+ this.handleOutgoingMcpSetServers(msg.servers);
504
+ return true;
505
+ default:
506
+ return false;
507
+ }
508
+ }
509
+
510
+ onBrowserMessage(cb: (msg: BrowserIncomingMessage) => void): void {
511
+ this.browserMessageCb = cb;
512
+ }
513
+
514
+ onSessionMeta(cb: (meta: { cliSessionId?: string; model?: string; cwd?: string }) => void): void {
515
+ this.sessionMetaCb = cb;
516
+ }
517
+
518
+ onDisconnect(cb: () => void): void {
519
+ this.disconnectCb = cb;
520
+ }
521
+
522
+ onInitError(cb: (error: string) => void): void {
523
+ this.initErrorCb = cb;
524
+ }
525
+
526
+ isConnected(): boolean {
527
+ return this.connected;
528
+ }
529
+
530
+ async disconnect(): Promise<void> {
531
+ this.connected = false;
532
+ try {
533
+ this.proc.kill("SIGTERM");
534
+ await Promise.race([
535
+ this.proc.exited,
536
+ new Promise((r) => setTimeout(r, 5000)),
537
+ ]);
538
+ } catch {}
539
+ }
540
+
541
+ getThreadId(): string | null {
542
+ return this.threadId;
543
+ }
544
+
545
+ getBackendSessionId(): string | null {
546
+ return this.threadId;
547
+ }
548
+
549
+ // ── Initialization ──────────────────────────────────────────────────────
550
+
551
+ private async initialize(): Promise<void> {
552
+ try {
553
+ // Step 1: Send initialize request
554
+ const result = await this.transport.call("initialize", {
555
+ clientInfo: {
556
+ name: "campfire",
557
+ title: "Campfire",
558
+ version: "1.0.0",
559
+ },
560
+ capabilities: {
561
+ experimentalApi: false,
562
+ },
563
+ }) as Record<string, unknown>;
564
+
565
+ // Step 2: Send initialized notification
566
+ await this.transport.notify("initialized", {});
567
+
568
+ this.connected = true;
569
+ this.initialized = true;
570
+
571
+ // Step 3: Start or resume a thread
572
+ if (this.options.threadId) {
573
+ // Resume an existing thread
574
+ const resumeResult = await this.transport.call("thread/resume", {
575
+ threadId: this.options.threadId,
576
+ model: this.options.model,
577
+ cwd: this.options.cwd,
578
+ approvalPolicy: this.mapApprovalPolicy(this.options.approvalMode),
579
+ sandbox: this.options.sandbox || this.mapSandboxPolicy(this.options.approvalMode),
580
+ ...(this.options.reasoningEffort ? { reasoningEffort: this.options.reasoningEffort } : {}),
581
+ }) as { thread: { id: string } };
582
+ this.threadId = resumeResult.thread.id;
583
+ } else {
584
+ // Start a new thread
585
+ const threadResult = await this.transport.call("thread/start", {
586
+ model: this.options.model,
587
+ cwd: this.options.cwd,
588
+ approvalPolicy: this.mapApprovalPolicy(this.options.approvalMode),
589
+ sandbox: this.options.sandbox || this.mapSandboxPolicy(this.options.approvalMode),
590
+ ...(this.options.reasoningEffort ? { reasoningEffort: this.options.reasoningEffort } : {}),
591
+ }) as { thread: { id: string } };
592
+ this.threadId = threadResult.thread.id;
593
+ }
594
+
595
+ // Notify session metadata
596
+ this.sessionMetaCb?.({
597
+ cliSessionId: this.threadId,
598
+ model: this.options.model,
599
+ cwd: this.options.cwd,
600
+ });
601
+
602
+ // Send session_init to browser
603
+ const state: SessionState = {
604
+ session_id: this.sessionId,
605
+ backend_type: "codex",
606
+ model: this.options.model || "",
607
+ cwd: this.options.cwd || "",
608
+ tools: [],
609
+ permissionMode: this.options.approvalMode || "suggest",
610
+ claude_code_version: "",
611
+ mcp_servers: [],
612
+ agents: [],
613
+ slash_commands: [],
614
+ skills: [],
615
+ total_cost_usd: 0,
616
+ num_turns: 0,
617
+ context_used_percent: 0,
618
+ is_compacting: false,
619
+ git_branch: "",
620
+ is_worktree: false,
621
+ repo_root: "",
622
+ git_ahead: 0,
623
+ git_behind: 0,
624
+ total_lines_added: 0,
625
+ total_lines_removed: 0,
626
+ total_duration_api_ms: 0,
627
+ };
628
+
629
+ this.emit({ type: "session_init", session: state });
630
+
631
+ // Fetch initial rate limits (non-blocking — don't fail init if this errors)
632
+ this.transport.call("account/rateLimits/read", {}).then((result) => {
633
+ this.updateRateLimits(result as Record<string, unknown>);
634
+ }).catch(() => { /* best-effort */ });
635
+
636
+ // Flush any messages that were queued during initialization
637
+ if (this.pendingOutgoing.length > 0) {
638
+ console.log(`[codex-adapter] Flushing ${this.pendingOutgoing.length} queued message(s)`);
639
+ const queued = this.pendingOutgoing.splice(0);
640
+ for (const msg of queued) {
641
+ this.dispatchOutgoing(msg);
642
+ }
643
+ }
644
+ } catch (err) {
645
+ const errorMsg = `Codex initialization failed: ${err}`;
646
+ console.error(`[codex-adapter] ${errorMsg}`);
647
+ this.initFailed = true;
648
+ this.connected = false;
649
+ // Discard any messages queued during the failed init attempt
650
+ this.pendingOutgoing.length = 0;
651
+ this.emit({ type: "error", message: errorMsg });
652
+ this.initErrorCb?.(errorMsg);
653
+ }
654
+ }
655
+
656
+ // ── Outgoing message handlers ───────────────────────────────────────────
657
+
658
+ private async handleOutgoingUserMessage(
659
+ msg: { type: "user_message"; content: string; images?: { media_type: string; data: string }[] },
660
+ ): Promise<void> {
661
+ if (!this.threadId) {
662
+ this.emit({ type: "error", message: "No Codex thread started yet" });
663
+ return;
664
+ }
665
+
666
+ const input: Array<{ type: string; text?: string; url?: string }> = [];
667
+
668
+ // Add images if present
669
+ if (msg.images?.length) {
670
+ for (const img of msg.images) {
671
+ input.push({
672
+ type: "image",
673
+ url: `data:${img.media_type};base64,${img.data}`,
674
+ });
675
+ }
676
+ }
677
+
678
+ // Add text
679
+ input.push({ type: "text", text: msg.content });
680
+
681
+ try {
682
+ const result = await this.transport.call("turn/start", {
683
+ threadId: this.threadId,
684
+ input,
685
+ cwd: this.options.cwd,
686
+ }) as { turn: { id: string } };
687
+
688
+ this.currentTurnId = result.turn.id;
689
+ } catch (err) {
690
+ this.emit({ type: "error", message: `Failed to start turn: ${err}` });
691
+ }
692
+ }
693
+
694
+ private async handleOutgoingPermissionResponse(
695
+ msg: { type: "permission_response"; request_id: string; behavior: "allow" | "deny"; updated_input?: Record<string, unknown> },
696
+ ): Promise<void> {
697
+ const jsonRpcId = this.pendingApprovals.get(msg.request_id);
698
+ if (jsonRpcId === undefined) {
699
+ console.warn(`[codex-adapter] No pending approval for request_id=${msg.request_id}`);
700
+ return;
701
+ }
702
+
703
+ // Dynamic tool calls (item/tool/call) require a DynamicToolCallResponse payload.
704
+ const pendingDynamic = this.pendingDynamicToolCalls.get(msg.request_id);
705
+ if (pendingDynamic) {
706
+ this.pendingDynamicToolCalls.delete(msg.request_id);
707
+ this.pendingApprovals.delete(msg.request_id);
708
+ clearTimeout(pendingDynamic.timeout);
709
+
710
+ const result = this.buildDynamicToolCallResponse(msg, pendingDynamic.toolName);
711
+ await this.transport.respond(jsonRpcId, result);
712
+ return;
713
+ }
714
+
715
+ this.pendingApprovals.delete(msg.request_id);
716
+
717
+ // User input requests (item/tool/requestUserInput) need ToolRequestUserInputResponse
718
+ const questionIds = this.pendingUserInputQuestionIds.get(msg.request_id);
719
+ if (questionIds) {
720
+ this.pendingUserInputQuestionIds.delete(msg.request_id);
721
+
722
+ if (msg.behavior === "deny") {
723
+ // Respond with empty answers on deny
724
+ await this.transport.respond(jsonRpcId, { answers: {} });
725
+ return;
726
+ }
727
+
728
+ // Convert browser answers (keyed by index "0","1",...) to Codex format (keyed by question ID)
729
+ const browserAnswers = msg.updated_input?.answers as Record<string, string> || {};
730
+ const codexAnswers: Record<string, { answers: string[] }> = {};
731
+ for (let i = 0; i < questionIds.length; i++) {
732
+ const answer = browserAnswers[String(i)];
733
+ if (answer !== undefined) {
734
+ codexAnswers[questionIds[i]] = { answers: [answer] };
735
+ }
736
+ }
737
+
738
+ await this.transport.respond(jsonRpcId, { answers: codexAnswers });
739
+ return;
740
+ }
741
+
742
+ // Review decisions (applyPatchApproval / execCommandApproval) need ReviewDecision
743
+ if (this.pendingReviewDecisions.has(msg.request_id)) {
744
+ this.pendingReviewDecisions.delete(msg.request_id);
745
+ const decision = msg.behavior === "allow" ? "approved" : "denied";
746
+ await this.transport.respond(jsonRpcId, { decision });
747
+ return;
748
+ }
749
+
750
+ // Standard item/*/requestApproval — uses accept/decline
751
+ const decision = msg.behavior === "allow" ? "accept" : "decline";
752
+ await this.transport.respond(jsonRpcId, { decision });
753
+ }
754
+
755
+ private async handleOutgoingInterrupt(): Promise<void> {
756
+ if (!this.threadId || !this.currentTurnId) return;
757
+
758
+ try {
759
+ await this.transport.call("turn/interrupt", {
760
+ threadId: this.threadId,
761
+ turnId: this.currentTurnId,
762
+ });
763
+ } catch (err) {
764
+ console.warn("[codex-adapter] Interrupt failed:", err);
765
+ }
766
+ }
767
+
768
+ private async handleOutgoingMcpGetStatus(): Promise<void> {
769
+ try {
770
+ const statusEntries = await this.listAllMcpServerStatuses();
771
+ const configMap = await this.readMcpServersConfig();
772
+
773
+ const names = new Set<string>([
774
+ ...statusEntries.map((s) => s.name),
775
+ ...Object.keys(configMap),
776
+ ]);
777
+
778
+ const statusByName = new Map(statusEntries.map((s) => [s.name, s]));
779
+ const servers: McpServerDetail[] = Array.from(names).sort().map((name) => {
780
+ const status = statusByName.get(name);
781
+ const config = this.toMcpServerConfig(configMap[name]);
782
+ const isEnabled = this.isMcpServerEnabled(configMap[name]);
783
+ const serverStatus: McpServerDetail["status"] =
784
+ !isEnabled
785
+ ? "disabled"
786
+ : (status?.authStatus === "notLoggedIn" ? "failed" : "connected");
787
+
788
+ return {
789
+ name,
790
+ status: serverStatus,
791
+ error: status?.authStatus === "notLoggedIn" ? "MCP server requires login" : undefined,
792
+ config,
793
+ scope: "user",
794
+ tools: this.mapMcpTools(status?.tools),
795
+ };
796
+ });
797
+
798
+ this.emit({ type: "mcp_status", servers });
799
+ } catch (err) {
800
+ this.emit({ type: "error", message: `Failed to get MCP status: ${err}` });
801
+ }
802
+ }
803
+
804
+ private async handleOutgoingMcpToggle(serverName: string, enabled: boolean): Promise<void> {
805
+ try {
806
+ if (serverName.includes(".")) {
807
+ throw new Error("Server names containing '.' are not supported for toggle");
808
+ }
809
+ await this.transport.call("config/value/write", {
810
+ keyPath: `mcp_servers.${serverName}.enabled`,
811
+ value: enabled,
812
+ mergeStrategy: "upsert",
813
+ });
814
+ await this.reloadMcpServers();
815
+ await this.handleOutgoingMcpGetStatus();
816
+ } catch (err) {
817
+ // Some existing configs may contain legacy/foreign fields (e.g. `transport`)
818
+ // that fail on reload when touched. If so, remove this server entry entirely.
819
+ const msg = String(err);
820
+ if (msg.includes("invalid transport")) {
821
+ try {
822
+ await this.transport.call("config/value/write", {
823
+ keyPath: `mcp_servers.${serverName}`,
824
+ value: null,
825
+ mergeStrategy: "replace",
826
+ });
827
+ await this.reloadMcpServers();
828
+ await this.handleOutgoingMcpGetStatus();
829
+ return;
830
+ } catch {
831
+ // fall through to user-visible error below
832
+ }
833
+ }
834
+ this.emit({ type: "error", message: `Failed to toggle MCP server "${serverName}": ${err}` });
835
+ }
836
+ }
837
+
838
+ private async handleOutgoingMcpReconnect(): Promise<void> {
839
+ try {
840
+ await this.reloadMcpServers();
841
+ await this.handleOutgoingMcpGetStatus();
842
+ } catch (err) {
843
+ this.emit({ type: "error", message: `Failed to reload MCP servers: ${err}` });
844
+ }
845
+ }
846
+
847
+ private async handleOutgoingMcpSetServers(servers: Record<string, McpServerConfig>): Promise<void> {
848
+ try {
849
+ const edits: Array<{ keyPath: string; value: Record<string, unknown>; mergeStrategy: "upsert" }> = [];
850
+ for (const [name, config] of Object.entries(servers)) {
851
+ if (name.includes(".")) {
852
+ throw new Error(`Server names containing '.' are not supported: ${name}`);
853
+ }
854
+ edits.push({
855
+ keyPath: `mcp_servers.${name}`,
856
+ value: this.fromMcpServerConfig(config),
857
+ mergeStrategy: "upsert",
858
+ });
859
+ }
860
+ if (edits.length > 0) {
861
+ await this.transport.call("config/batchWrite", {
862
+ edits,
863
+ });
864
+ }
865
+ await this.reloadMcpServers();
866
+ await this.handleOutgoingMcpGetStatus();
867
+ } catch (err) {
868
+ this.emit({ type: "error", message: `Failed to configure MCP servers: ${err}` });
869
+ }
870
+ }
871
+
872
+ // ── Incoming notification handlers ──────────────────────────────────────
873
+
874
+ private handleNotification(method: string, params: Record<string, unknown>): void {
875
+ // Debug: log all significant notifications to understand Codex event flow
876
+ if (method.startsWith("item/") || method.startsWith("turn/") || method.startsWith("thread/")) {
877
+ const item = params.item as { type?: string; id?: string } | undefined;
878
+ console.log(`[codex-adapter] ← ${method}${item ? ` type=${item.type} id=${item.id}` : ""}${!item && Object.keys(params).length > 0 ? ` keys=[${Object.keys(params).join(",")}]` : ""}`);
879
+ }
880
+
881
+ try {
882
+ switch (method) {
883
+ case "item/started":
884
+ this.handleItemStarted(params);
885
+ break;
886
+ case "item/agentMessage/delta":
887
+ this.handleAgentMessageDelta(params);
888
+ break;
889
+ case "item/commandExecution/outputDelta":
890
+ // Streaming command output — emit as tool_progress so the browser
891
+ // shows a live elapsed-time indicator while the command runs.
892
+ this.emitCommandProgress(params);
893
+ break;
894
+ case "item/fileChange/outputDelta":
895
+ // Streaming file change output. Same as above.
896
+ break;
897
+ case "item/reasoning/textDelta":
898
+ case "item/reasoning/summaryTextDelta":
899
+ case "item/reasoning/summaryPartAdded":
900
+ this.handleReasoningDelta(params);
901
+ break;
902
+ case "item/mcpToolCall/progress": {
903
+ // MCP tool call progress — map to tool_progress
904
+ const itemId = params.itemId as string | undefined;
905
+ const threadId = params.threadId as string | undefined;
906
+ if (itemId) {
907
+ this.emit({
908
+ type: "tool_progress",
909
+ tool_use_id: itemId,
910
+ tool_name: "mcp_tool_call",
911
+ elapsed_time_seconds: 0,
912
+ });
913
+ }
914
+ break;
915
+ }
916
+ case "item/plan/delta":
917
+ // Plan updates — could display in future.
918
+ break;
919
+ case "item/updated":
920
+ this.handleItemUpdated(params);
921
+ break;
922
+ case "item/completed":
923
+ this.handleItemCompleted(params);
924
+ break;
925
+ case "rawResponseItem/completed":
926
+ // Raw model response — internal, not needed for UI.
927
+ break;
928
+ case "turn/started":
929
+ // Turn started, nothing to emit
930
+ break;
931
+ case "turn/completed":
932
+ this.handleTurnCompleted(params);
933
+ break;
934
+ case "turn/plan/updated":
935
+ // Could emit as tool_progress or similar
936
+ break;
937
+ case "turn/diff/updated":
938
+ // Could show diff, but not needed for MVP
939
+ break;
940
+ case "thread/started":
941
+ // Thread started after init — nothing to emit.
942
+ break;
943
+ case "thread/tokenUsage/updated":
944
+ this.handleTokenUsageUpdated(params);
945
+ break;
946
+ case "account/updated":
947
+ case "account/login/completed":
948
+ // Auth events
949
+ break;
950
+ case "account/rateLimits/updated":
951
+ this.updateRateLimits(params);
952
+ break;
953
+ case "codex/event/stream_error": {
954
+ const msg = params.msg as { message?: string } | undefined;
955
+ if (msg?.message) {
956
+ console.log(`[codex-adapter] Stream error: ${msg.message}`);
957
+ }
958
+ break;
959
+ }
960
+ case "codex/event/error": {
961
+ const msg = params.msg as { message?: string } | undefined;
962
+ if (msg?.message) {
963
+ console.error(`[codex-adapter] Codex error: ${msg.message}`);
964
+ this.emit({ type: "error", message: msg.message });
965
+ }
966
+ break;
967
+ }
968
+ case "error": {
969
+ const errorMsg = (params as Record<string, unknown>).message || JSON.stringify(params);
970
+ console.warn(`[codex-adapter] Error notification:`, JSON.stringify(params).substring(0, 500));
971
+ this.emit({
972
+ type: "assistant",
973
+ message: {
974
+ id: `codex-error-${Date.now()}`,
975
+ type: "message",
976
+ role: "assistant",
977
+ model: this.options.model || "codex",
978
+ content: [{ type: "text", text: `Error: ${errorMsg}` }],
979
+ stop_reason: "error",
980
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
981
+ },
982
+ parent_tool_use_id: null,
983
+ timestamp: Date.now(),
984
+ });
985
+ break;
986
+ }
987
+ default:
988
+ // Unknown notification, log for debugging
989
+ if (!method.startsWith("account/") && !method.startsWith("codex/event/")) {
990
+ console.log(`[codex-adapter] Unhandled notification: ${method}`, JSON.stringify(params).substring(0, 200));
991
+ }
992
+ break;
993
+ }
994
+ } catch (err) {
995
+ console.error(`[codex-adapter] Error handling notification ${method}:`, err);
996
+ }
997
+ }
998
+
999
+ // ── Incoming request handlers (approval requests) ───────────────────────
1000
+
1001
+ private handleRequest(method: string, id: number, params: Record<string, unknown>): void {
1002
+ try {
1003
+ switch (method) {
1004
+ case "item/commandExecution/requestApproval":
1005
+ this.handleCommandApproval(id, params);
1006
+ break;
1007
+ case "item/fileChange/requestApproval":
1008
+ this.handleFileChangeApproval(id, params);
1009
+ break;
1010
+ case "item/mcpToolCall/requestApproval":
1011
+ this.handleMcpToolCallApproval(id, params);
1012
+ break;
1013
+ case "item/tool/call":
1014
+ this.handleDynamicToolCall(id, params);
1015
+ break;
1016
+ case "item/tool/requestUserInput":
1017
+ this.handleUserInputRequest(id, params);
1018
+ break;
1019
+ case "applyPatchApproval":
1020
+ this.handleApplyPatchApproval(id, params);
1021
+ break;
1022
+ case "execCommandApproval":
1023
+ this.handleExecCommandApproval(id, params);
1024
+ break;
1025
+ case "account/chatgptAuthTokens/refresh":
1026
+ console.warn("[codex-adapter] Auth token refresh not supported");
1027
+ this.transport.respond(id, { error: "not supported" });
1028
+ break;
1029
+ default:
1030
+ console.log(`[codex-adapter] Unhandled request: ${method}`);
1031
+ // Auto-accept unknown requests
1032
+ this.transport.respond(id, { decision: "accept" });
1033
+ break;
1034
+ }
1035
+ } catch (err) {
1036
+ console.error(`[codex-adapter] Error handling request ${method}:`, err);
1037
+ }
1038
+ }
1039
+
1040
+ private handleCommandApproval(jsonRpcId: number, params: Record<string, unknown>): void {
1041
+ const requestId = `codex-approval-${randomUUID()}`;
1042
+ this.pendingApprovals.set(requestId, jsonRpcId);
1043
+
1044
+ const command = params.command as string | string[] | undefined;
1045
+ const commandStr = params.parsedCmd as string || (Array.isArray(command) ? command.join(" ") : command) || "";
1046
+
1047
+ const perm: PermissionRequest = {
1048
+ request_id: requestId,
1049
+ tool_name: "Bash",
1050
+ input: {
1051
+ command: commandStr,
1052
+ cwd: params.cwd as string || this.options.cwd || "",
1053
+ },
1054
+ description: params.reason as string || `Execute: ${commandStr}`,
1055
+ tool_use_id: params.itemId as string || requestId,
1056
+ timestamp: Date.now(),
1057
+ };
1058
+
1059
+ this.emit({ type: "permission_request", request: perm });
1060
+ }
1061
+
1062
+ private handleFileChangeApproval(jsonRpcId: number, params: Record<string, unknown>): void {
1063
+ const requestId = `codex-approval-${randomUUID()}`;
1064
+ this.pendingApprovals.set(requestId, jsonRpcId);
1065
+
1066
+ // Extract file paths from changes array if available
1067
+ const changes = params.changes as Array<{ path?: string; kind?: string }> | undefined;
1068
+ const filePaths = changes?.map((c) => c.path).filter(Boolean) || [];
1069
+ const fileList = filePaths.length > 0 ? filePaths.join(", ") : undefined;
1070
+
1071
+ const perm: PermissionRequest = {
1072
+ request_id: requestId,
1073
+ tool_name: "Edit",
1074
+ input: {
1075
+ description: params.reason as string || "File changes pending approval",
1076
+ ...(filePaths.length > 0 && { file_paths: filePaths }),
1077
+ ...(changes && { changes }),
1078
+ },
1079
+ description: params.reason as string || (fileList ? `Codex wants to modify: ${fileList}` : "Codex wants to modify files"),
1080
+ tool_use_id: params.itemId as string || requestId,
1081
+ timestamp: Date.now(),
1082
+ };
1083
+
1084
+ this.emit({ type: "permission_request", request: perm });
1085
+ }
1086
+
1087
+ private handleMcpToolCallApproval(jsonRpcId: number, params: Record<string, unknown>): void {
1088
+ const requestId = `codex-approval-${randomUUID()}`;
1089
+ this.pendingApprovals.set(requestId, jsonRpcId);
1090
+
1091
+ const server = params.server as string || "unknown";
1092
+ const tool = params.tool as string || "unknown";
1093
+ const args = params.arguments as Record<string, unknown> || {};
1094
+
1095
+ const perm: PermissionRequest = {
1096
+ request_id: requestId,
1097
+ tool_name: `mcp:${server}:${tool}`,
1098
+ input: args,
1099
+ description: params.reason as string || `MCP tool call: ${server}/${tool}`,
1100
+ tool_use_id: params.itemId as string || requestId,
1101
+ timestamp: Date.now(),
1102
+ };
1103
+
1104
+ this.emit({ type: "permission_request", request: perm });
1105
+ }
1106
+
1107
+ private handleDynamicToolCall(jsonRpcId: number, params: Record<string, unknown>): void {
1108
+ const callId = params.callId as string || `dynamic-${randomUUID()}`;
1109
+ const toolName = params.tool as string || "unknown_dynamic_tool";
1110
+ const toolArgs = params.arguments as Record<string, unknown> || {};
1111
+ const requestId = `codex-dynamic-${randomUUID()}`;
1112
+
1113
+ console.log(`[codex-adapter] Dynamic tool call received: ${toolName} (callId=${callId})`);
1114
+
1115
+ // Emit tool_use so the browser sees this custom tool invocation.
1116
+ this.emitToolUseTracked(callId, `dynamic:${toolName}`, toolArgs);
1117
+
1118
+ this.pendingApprovals.set(requestId, jsonRpcId);
1119
+ const timeout = setTimeout(() => {
1120
+ this.resolveDynamicToolCallTimeout(requestId);
1121
+ }, CodexAdapter.DYNAMIC_TOOL_CALL_TIMEOUT_MS);
1122
+
1123
+ this.pendingDynamicToolCalls.set(requestId, {
1124
+ jsonRpcId,
1125
+ callId,
1126
+ toolName,
1127
+ timeout,
1128
+ });
1129
+
1130
+ const perm: PermissionRequest = {
1131
+ request_id: requestId,
1132
+ tool_name: `dynamic:${toolName}`,
1133
+ input: {
1134
+ ...toolArgs,
1135
+ call_id: callId,
1136
+ },
1137
+ description: `Custom tool call: ${toolName}`,
1138
+ tool_use_id: callId,
1139
+ timestamp: Date.now(),
1140
+ };
1141
+
1142
+ this.emit({ type: "permission_request", request: perm });
1143
+ }
1144
+
1145
+ private async resolveDynamicToolCallTimeout(requestId: string): Promise<void> {
1146
+ const pending = this.pendingDynamicToolCalls.get(requestId);
1147
+ if (!pending) return;
1148
+
1149
+ this.pendingDynamicToolCalls.delete(requestId);
1150
+ this.pendingApprovals.delete(requestId);
1151
+
1152
+ this.emitToolResult(
1153
+ pending.callId,
1154
+ `Dynamic tool "${pending.toolName}" timed out waiting for output.`,
1155
+ true,
1156
+ );
1157
+
1158
+ try {
1159
+ await this.transport.respond(pending.jsonRpcId, {
1160
+ contentItems: [{ type: "inputText", text: `Timed out waiting for dynamic tool output: ${pending.toolName}` }],
1161
+ success: false,
1162
+ });
1163
+ } catch (err) {
1164
+ console.warn(`[codex-adapter] Failed to send dynamic tool timeout response: ${err}`);
1165
+ }
1166
+ }
1167
+
1168
+ private buildDynamicToolCallResponse(
1169
+ msg: { behavior: "allow" | "deny"; updated_input?: Record<string, unknown> },
1170
+ toolName: string,
1171
+ ): { contentItems: unknown[]; success: boolean; structuredContent?: unknown } {
1172
+ if (msg.behavior === "deny") {
1173
+ return {
1174
+ contentItems: [{ type: "inputText", text: `Dynamic tool "${toolName}" was denied by user` }],
1175
+ success: false,
1176
+ };
1177
+ }
1178
+
1179
+ const rawContentItems = msg.updated_input?.contentItems;
1180
+ const contentItems = Array.isArray(rawContentItems) && rawContentItems.length > 0
1181
+ ? rawContentItems
1182
+ : [{ type: "inputText", text: String(msg.updated_input?.text || "Dynamic tool call completed") }];
1183
+
1184
+ const success = typeof msg.updated_input?.success === "boolean"
1185
+ ? msg.updated_input.success
1186
+ : true;
1187
+
1188
+ const structuredContent = msg.updated_input?.structuredContent;
1189
+
1190
+ return {
1191
+ contentItems,
1192
+ success,
1193
+ ...(structuredContent !== undefined ? { structuredContent } : {}),
1194
+ };
1195
+ }
1196
+
1197
+ private handleUserInputRequest(jsonRpcId: number, params: Record<string, unknown>): void {
1198
+ const requestId = `codex-userinput-${randomUUID()}`;
1199
+ this.pendingApprovals.set(requestId, jsonRpcId);
1200
+
1201
+ const questions = params.questions as Array<{
1202
+ id: string; header: string; question: string;
1203
+ isOther: boolean; isSecret: boolean;
1204
+ options: Array<{ label: string; description: string }> | null;
1205
+ }> || [];
1206
+
1207
+ // Store question IDs so we can map browser indices back to Codex IDs in the response
1208
+ this.pendingUserInputQuestionIds.set(requestId, questions.map((q) => q.id));
1209
+
1210
+ // Convert to our AskUserQuestion format (matches AskUserQuestionDisplay component)
1211
+ const perm: PermissionRequest = {
1212
+ request_id: requestId,
1213
+ tool_name: "AskUserQuestion",
1214
+ input: {
1215
+ questions: questions.map((q) => ({
1216
+ header: q.header,
1217
+ question: q.question,
1218
+ options: q.options?.map((o) => ({ label: o.label, description: o.description })) || [],
1219
+ })),
1220
+ },
1221
+ description: questions[0]?.question || "User input requested",
1222
+ tool_use_id: params.itemId as string || requestId,
1223
+ timestamp: Date.now(),
1224
+ };
1225
+
1226
+ this.emit({ type: "permission_request", request: perm });
1227
+ }
1228
+
1229
+ private handleApplyPatchApproval(jsonRpcId: number, params: Record<string, unknown>): void {
1230
+ const requestId = `codex-patch-${randomUUID()}`;
1231
+ this.pendingApprovals.set(requestId, jsonRpcId);
1232
+ this.pendingReviewDecisions.add(requestId);
1233
+
1234
+ const fileChanges = params.fileChanges as Record<string, unknown> || {};
1235
+ const filePaths = Object.keys(fileChanges);
1236
+ const reason = params.reason as string | null;
1237
+
1238
+ const perm: PermissionRequest = {
1239
+ request_id: requestId,
1240
+ tool_name: "Edit",
1241
+ input: {
1242
+ file_paths: filePaths,
1243
+ ...(reason && { reason }),
1244
+ },
1245
+ description: reason || (filePaths.length > 0
1246
+ ? `Codex wants to modify: ${filePaths.join(", ")}`
1247
+ : "Codex wants to modify files"),
1248
+ tool_use_id: params.callId as string || requestId,
1249
+ timestamp: Date.now(),
1250
+ };
1251
+
1252
+ this.emit({ type: "permission_request", request: perm });
1253
+ }
1254
+
1255
+ private handleExecCommandApproval(jsonRpcId: number, params: Record<string, unknown>): void {
1256
+ const requestId = `codex-exec-${randomUUID()}`;
1257
+ this.pendingApprovals.set(requestId, jsonRpcId);
1258
+ this.pendingReviewDecisions.add(requestId);
1259
+
1260
+ const command = params.command as string[] || [];
1261
+ const commandStr = command.join(" ");
1262
+ const cwd = params.cwd as string || this.options.cwd || "";
1263
+ const reason = params.reason as string | null;
1264
+
1265
+ const perm: PermissionRequest = {
1266
+ request_id: requestId,
1267
+ tool_name: "Bash",
1268
+ input: {
1269
+ command: commandStr,
1270
+ cwd,
1271
+ },
1272
+ description: reason || `Execute: ${commandStr}`,
1273
+ tool_use_id: params.callId as string || requestId,
1274
+ timestamp: Date.now(),
1275
+ };
1276
+
1277
+ this.emit({ type: "permission_request", request: perm });
1278
+ }
1279
+
1280
+ // ── Item event handlers ─────────────────────────────────────────────────
1281
+
1282
+ private handleItemStarted(params: Record<string, unknown>): void {
1283
+ const item = params.item as CodexItem;
1284
+ if (!item) return;
1285
+
1286
+ switch (item.type) {
1287
+ case "agentMessage":
1288
+ // Start streaming accumulation
1289
+ this.streamingItemId = item.id;
1290
+ this.streamingText = "";
1291
+ // Emit message_start stream event so the browser knows streaming began
1292
+ this.emit({
1293
+ type: "stream_event",
1294
+ event: {
1295
+ type: "message_start",
1296
+ message: {
1297
+ id: this.makeMessageId("agent", item.id),
1298
+ type: "message",
1299
+ role: "assistant",
1300
+ model: this.options.model || "",
1301
+ content: [],
1302
+ stop_reason: null,
1303
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
1304
+ },
1305
+ },
1306
+ parent_tool_use_id: null,
1307
+ });
1308
+ // Also emit content_block_start
1309
+ this.emit({
1310
+ type: "stream_event",
1311
+ event: {
1312
+ type: "content_block_start",
1313
+ index: 0,
1314
+ content_block: { type: "text", text: "" },
1315
+ },
1316
+ parent_tool_use_id: null,
1317
+ });
1318
+ break;
1319
+
1320
+ case "commandExecution": {
1321
+ const cmd = item as CodexCommandExecutionItem;
1322
+ const commandStr = Array.isArray(cmd.command) ? cmd.command.join(" ") : (cmd.command || "");
1323
+ this.commandStartTimes.set(item.id, Date.now());
1324
+ this.emitToolUseStart(item.id, "Bash", { command: commandStr });
1325
+ break;
1326
+ }
1327
+
1328
+ case "fileChange": {
1329
+ const fc = item as CodexFileChangeItem;
1330
+ const changes = fc.changes || [];
1331
+ const firstChange = changes[0];
1332
+ const toolName = safeKind(firstChange?.kind) === "create" ? "Write" : "Edit";
1333
+ const toolInput = {
1334
+ file_path: firstChange?.path || "",
1335
+ changes: changes.map((c) => ({ path: c.path, kind: safeKind(c.kind) })),
1336
+ };
1337
+ this.emitToolUseStart(item.id, toolName, toolInput);
1338
+ break;
1339
+ }
1340
+
1341
+ case "mcpToolCall": {
1342
+ const mcp = item as CodexMcpToolCallItem;
1343
+ this.emitToolUseStart(item.id, `mcp:${mcp.server}:${mcp.tool}`, mcp.arguments || {});
1344
+ break;
1345
+ }
1346
+
1347
+ case "webSearch": {
1348
+ const ws = item as CodexWebSearchItem;
1349
+ this.emitToolUseStart(item.id, "WebSearch", { query: ws.query || "" });
1350
+ break;
1351
+ }
1352
+
1353
+ case "reasoning": {
1354
+ const r = item as CodexReasoningItem;
1355
+ this.reasoningTextByItemId.set(item.id, r.summary || r.content || "");
1356
+ // Emit as thinking content block
1357
+ if (r.summary || r.content) {
1358
+ this.emit({
1359
+ type: "stream_event",
1360
+ event: {
1361
+ type: "content_block_start",
1362
+ index: 0,
1363
+ content_block: { type: "thinking", thinking: r.summary || r.content || "" },
1364
+ },
1365
+ parent_tool_use_id: null,
1366
+ });
1367
+ }
1368
+ break;
1369
+ }
1370
+
1371
+ case "contextCompaction":
1372
+ this.emit({ type: "status_change", status: "compacting" });
1373
+ break;
1374
+
1375
+ default:
1376
+ // userMessage is an echo of browser input and not needed in UI.
1377
+ if (item.type !== "userMessage") {
1378
+ console.log(`[codex-adapter] Unhandled item/started type: ${item.type}`, JSON.stringify(item).substring(0, 300));
1379
+ }
1380
+ break;
1381
+ }
1382
+ }
1383
+
1384
+ private handleReasoningDelta(params: Record<string, unknown>): void {
1385
+ const itemId = params.itemId as string | undefined;
1386
+ if (!itemId) return;
1387
+
1388
+ if (!this.reasoningTextByItemId.has(itemId)) {
1389
+ this.reasoningTextByItemId.set(itemId, "");
1390
+ }
1391
+
1392
+ const delta = params.delta as string | undefined;
1393
+ if (delta) {
1394
+ const current = this.reasoningTextByItemId.get(itemId) || "";
1395
+ this.reasoningTextByItemId.set(itemId, current + delta);
1396
+ }
1397
+ }
1398
+
1399
+ private handleAgentMessageDelta(params: Record<string, unknown>): void {
1400
+ const delta = params.delta as string;
1401
+ if (!delta) return;
1402
+
1403
+ this.streamingText += delta;
1404
+
1405
+ // Emit as content_block_delta (matches Claude's streaming format)
1406
+ this.emit({
1407
+ type: "stream_event",
1408
+ event: {
1409
+ type: "content_block_delta",
1410
+ index: 0,
1411
+ delta: { type: "text_delta", text: delta },
1412
+ },
1413
+ parent_tool_use_id: null,
1414
+ });
1415
+ }
1416
+
1417
+ private handleItemUpdated(params: Record<string, unknown>): void {
1418
+ // item/updated is a general update — currently we handle streaming via the specific delta events
1419
+ // Could handle status updates for command_execution / file_change items here
1420
+ }
1421
+
1422
+ private handleItemCompleted(params: Record<string, unknown>): void {
1423
+ const item = params.item as CodexItem;
1424
+ if (!item) return;
1425
+
1426
+ switch (item.type) {
1427
+ case "agentMessage": {
1428
+ const agentMsg = item as CodexAgentMessageItem;
1429
+ const text = agentMsg.text || this.streamingText;
1430
+
1431
+ // Emit message_stop for streaming
1432
+ this.emit({
1433
+ type: "stream_event",
1434
+ event: {
1435
+ type: "content_block_stop",
1436
+ index: 0,
1437
+ },
1438
+ parent_tool_use_id: null,
1439
+ });
1440
+ this.emit({
1441
+ type: "stream_event",
1442
+ event: {
1443
+ type: "message_delta",
1444
+ delta: { stop_reason: null }, // null, not "end_turn" — the turn may continue with tool calls
1445
+ usage: { output_tokens: 0 },
1446
+ },
1447
+ parent_tool_use_id: null,
1448
+ });
1449
+
1450
+ // Emit the full assistant message
1451
+ this.emit({
1452
+ type: "assistant",
1453
+ message: {
1454
+ id: this.makeMessageId("agent", item.id),
1455
+ type: "message",
1456
+ role: "assistant",
1457
+ model: this.options.model || "",
1458
+ content: [{ type: "text", text }],
1459
+ stop_reason: "end_turn",
1460
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
1461
+ },
1462
+ parent_tool_use_id: null,
1463
+ timestamp: Date.now(),
1464
+ });
1465
+
1466
+ // Reset streaming state
1467
+ this.streamingText = "";
1468
+ this.streamingItemId = null;
1469
+ break;
1470
+ }
1471
+
1472
+ case "commandExecution": {
1473
+ const cmd = item as CodexCommandExecutionItem;
1474
+ const commandStr = Array.isArray(cmd.command) ? cmd.command.join(" ") : (cmd.command || "");
1475
+ // Ensure tool_use was emitted (may be skipped when auto-approved)
1476
+ this.ensureToolUseEmitted(item.id, "Bash", { command: commandStr });
1477
+ // Clean up progress tracking
1478
+ this.commandStartTimes.delete(item.id);
1479
+ // Emit tool result
1480
+ const output = (item as Record<string, unknown>).stdout as string || "";
1481
+ const stderr = (item as Record<string, unknown>).stderr as string || "";
1482
+ const combinedOutput = [output, stderr].filter(Boolean).join("\n").trim();
1483
+ const exitCode = typeof cmd.exitCode === "number" ? cmd.exitCode : 0;
1484
+ const durationMs = typeof cmd.durationMs === "number" ? cmd.durationMs : undefined;
1485
+ const failed = cmd.status === "failed" || cmd.status === "declined" || exitCode !== 0;
1486
+
1487
+ // Keep successful no-output commands silent in the chat feed.
1488
+ if (!combinedOutput && !failed) {
1489
+ break;
1490
+ }
1491
+
1492
+ let resultText = combinedOutput;
1493
+ if (!resultText) {
1494
+ resultText = `Exit code: ${exitCode}`;
1495
+ } else if (exitCode !== 0) {
1496
+ resultText = `${resultText}\nExit code: ${exitCode}`;
1497
+ }
1498
+ // Append duration if available and significant (>100ms)
1499
+ if (durationMs !== undefined && durationMs >= 100) {
1500
+ const durationStr = durationMs >= 1000
1501
+ ? `${(durationMs / 1000).toFixed(1)}s`
1502
+ : `${durationMs}ms`;
1503
+ resultText = `${resultText}\n(${durationStr})`;
1504
+ }
1505
+
1506
+ this.emitToolResult(item.id, resultText, failed);
1507
+ break;
1508
+ }
1509
+
1510
+ case "fileChange": {
1511
+ const fc = item as CodexFileChangeItem;
1512
+ const changes = fc.changes || [];
1513
+ const firstChange = changes[0];
1514
+ const toolName = safeKind(firstChange?.kind) === "create" ? "Write" : "Edit";
1515
+ // Ensure tool_use was emitted
1516
+ this.ensureToolUseEmitted(item.id, toolName, {
1517
+ file_path: firstChange?.path || "",
1518
+ changes: changes.map((c) => ({ path: c.path, kind: safeKind(c.kind) })),
1519
+ });
1520
+ const summary = changes.map((c) => `${safeKind(c.kind)}: ${c.path}`).join("\n");
1521
+ this.emitToolResult(item.id, summary || "File changes applied", fc.status === "failed");
1522
+ break;
1523
+ }
1524
+
1525
+ case "mcpToolCall": {
1526
+ const mcp = item as CodexMcpToolCallItem;
1527
+ // Ensure tool_use was emitted
1528
+ this.ensureToolUseEmitted(item.id, `mcp:${mcp.server}:${mcp.tool}`, mcp.arguments || {});
1529
+ this.emitToolResult(item.id, mcp.result || mcp.error || "MCP tool call completed", mcp.status === "failed");
1530
+ break;
1531
+ }
1532
+
1533
+ case "webSearch": {
1534
+ const ws = item as CodexWebSearchItem;
1535
+ // Ensure tool_use was emitted
1536
+ this.ensureToolUseEmitted(item.id, "WebSearch", { query: ws.query || "" });
1537
+ this.emitToolResult(item.id, ws.action?.url || ws.query || "Web search completed", false);
1538
+ break;
1539
+ }
1540
+
1541
+ case "reasoning": {
1542
+ const r = item as CodexReasoningItem;
1543
+ const thinkingText = (
1544
+ this.reasoningTextByItemId.get(item.id)
1545
+ || r.summary
1546
+ || r.content
1547
+ || ""
1548
+ ).trim();
1549
+
1550
+ if (thinkingText) {
1551
+ this.emit({
1552
+ type: "assistant",
1553
+ message: {
1554
+ id: this.makeMessageId("reasoning", item.id),
1555
+ type: "message",
1556
+ role: "assistant",
1557
+ model: this.options.model || "",
1558
+ content: [{ type: "thinking", thinking: thinkingText }],
1559
+ stop_reason: null,
1560
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
1561
+ },
1562
+ parent_tool_use_id: null,
1563
+ timestamp: Date.now(),
1564
+ });
1565
+ }
1566
+
1567
+ this.reasoningTextByItemId.delete(item.id);
1568
+
1569
+ // Close the thinking content block that was opened in handleItemStarted
1570
+ this.emit({
1571
+ type: "stream_event",
1572
+ event: {
1573
+ type: "content_block_stop",
1574
+ index: 0,
1575
+ },
1576
+ parent_tool_use_id: null,
1577
+ });
1578
+ break;
1579
+ }
1580
+
1581
+ case "contextCompaction":
1582
+ this.emit({ type: "status_change", status: null });
1583
+ break;
1584
+
1585
+ default:
1586
+ if (item.type !== "userMessage") {
1587
+ console.log(`[codex-adapter] Unhandled item/completed type: ${item.type}`, JSON.stringify(item).substring(0, 300));
1588
+ }
1589
+ break;
1590
+ }
1591
+ }
1592
+
1593
+ private handleTurnCompleted(params: Record<string, unknown>): void {
1594
+ const turn = params.turn as { id: string; status: string; error?: { message: string } } | undefined;
1595
+ this.cumulativeTurns++;
1596
+
1597
+ // Synthesize a CLIResultMessage-like structure
1598
+ const result: CLIResultMessage = {
1599
+ type: "result",
1600
+ subtype: turn?.status === "completed" ? "success" : "error_during_execution",
1601
+ is_error: turn?.status !== "completed",
1602
+ result: turn?.error?.message,
1603
+ duration_ms: 0,
1604
+ duration_api_ms: 0,
1605
+ num_turns: this.cumulativeTurns,
1606
+ total_cost_usd: this.cumulativeCostUsd,
1607
+ stop_reason: turn?.status || "end_turn",
1608
+ usage: {
1609
+ input_tokens: this.lastTotalTokens.input,
1610
+ output_tokens: this.lastTotalTokens.output,
1611
+ cache_creation_input_tokens: 0,
1612
+ cache_read_input_tokens: 0,
1613
+ },
1614
+ uuid: randomUUID(),
1615
+ session_id: this.sessionId,
1616
+ };
1617
+
1618
+ this.emit({ type: "result", data: result });
1619
+ this.currentTurnId = null;
1620
+ }
1621
+
1622
+ private updateRateLimits(data: Record<string, unknown>): void {
1623
+ const rl = data?.rateLimits as Record<string, unknown> | undefined;
1624
+ if (!rl) return;
1625
+ this._rateLimits = {
1626
+ primary: rl.primary as { usedPercent: number; windowDurationMins: number; resetsAt: number } | null,
1627
+ secondary: rl.secondary as { usedPercent: number; windowDurationMins: number; resetsAt: number } | null,
1628
+ };
1629
+ // Forward rate limits to browser for UI display
1630
+ this.emit({
1631
+ type: "session_update",
1632
+ session: {
1633
+ codex_rate_limits: {
1634
+ primary: this._rateLimits.primary,
1635
+ secondary: this._rateLimits.secondary,
1636
+ },
1637
+ },
1638
+ });
1639
+ }
1640
+
1641
+ private handleTokenUsageUpdated(params: Record<string, unknown>): void {
1642
+ // Codex sends: { threadId, turnId, tokenUsage: {
1643
+ // total: { totalTokens, inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens },
1644
+ // last: { totalTokens, inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens },
1645
+ // modelContextWindow: 258400
1646
+ // }}
1647
+ // IMPORTANT: `total` is cumulative across all turns and can far exceed the context window.
1648
+ // `last` is the most recent turn — its inputTokens reflects what's actually in context.
1649
+ const tokenUsage = params.tokenUsage as Record<string, unknown> | undefined;
1650
+ if (!tokenUsage) return;
1651
+
1652
+ const total = tokenUsage.total as Record<string, number> | undefined;
1653
+ const last = tokenUsage.last as Record<string, number> | undefined;
1654
+ const contextWindow = tokenUsage.modelContextWindow as number | undefined;
1655
+
1656
+ const updates: Partial<SessionState> = {};
1657
+
1658
+ // Use last turn's input tokens for context usage — that's what's actually in the window
1659
+ if (last && contextWindow && contextWindow > 0) {
1660
+ const usedInContext = (last.inputTokens || 0) + (last.outputTokens || 0);
1661
+ const pct = Math.round((usedInContext / contextWindow) * 100);
1662
+ updates.context_used_percent = Math.max(0, Math.min(pct, 100));
1663
+ }
1664
+
1665
+ // Forward cumulative token breakdown for display in the UI
1666
+ if (total) {
1667
+ updates.codex_token_details = {
1668
+ inputTokens: total.inputTokens || 0,
1669
+ outputTokens: total.outputTokens || 0,
1670
+ cachedInputTokens: total.cachedInputTokens || 0,
1671
+ reasoningOutputTokens: total.reasoningOutputTokens || 0,
1672
+ modelContextWindow: contextWindow || 0,
1673
+ };
1674
+
1675
+ // Calculate cost from cumulative token delta
1676
+ const pricing = getCodexPricing(this.options.model || "");
1677
+ const totalInput = total.inputTokens || 0;
1678
+ const totalOutput = total.outputTokens || 0;
1679
+ const totalCached = total.cachedInputTokens || 0;
1680
+
1681
+ // Compute delta tokens since last update (Codex sends cumulative totals)
1682
+ const deltaInput = Math.max(0, totalInput - this.lastTotalTokens.input);
1683
+ const deltaOutput = Math.max(0, totalOutput - this.lastTotalTokens.output);
1684
+ this.lastTotalTokens = { input: totalInput, output: totalOutput };
1685
+
1686
+ // For the delta: split input into cached vs non-cached proportionally
1687
+ const cachedRatio = totalInput > 0 ? totalCached / totalInput : 0;
1688
+ const deltaCachedInput = Math.round(deltaInput * cachedRatio);
1689
+ const deltaFreshInput = deltaInput - deltaCachedInput;
1690
+
1691
+ const deltaCost =
1692
+ deltaFreshInput * pricing.input +
1693
+ deltaCachedInput * pricing.cachedInput +
1694
+ deltaOutput * pricing.output;
1695
+
1696
+ this.cumulativeCostUsd += deltaCost;
1697
+ updates.total_cost_usd = this.cumulativeCostUsd;
1698
+ }
1699
+
1700
+ if (Object.keys(updates).length > 0) {
1701
+ this.emit({
1702
+ type: "session_update",
1703
+ session: updates,
1704
+ });
1705
+ }
1706
+ }
1707
+
1708
+ // ── Command progress tracking ─────────────────────────────────────────
1709
+
1710
+ private emitCommandProgress(params: Record<string, unknown>): void {
1711
+ const itemId = params.itemId as string | undefined;
1712
+ if (!itemId) return;
1713
+ const startTime = this.commandStartTimes.get(itemId);
1714
+ const elapsed = startTime ? Math.round((Date.now() - startTime) / 1000) : 0;
1715
+ this.emit({
1716
+ type: "tool_progress",
1717
+ tool_use_id: itemId,
1718
+ tool_name: "Bash",
1719
+ elapsed_time_seconds: elapsed,
1720
+ });
1721
+ }
1722
+
1723
+ // ── Helpers ─────────────────────────────────────────────────────────────
1724
+
1725
+ private emit(msg: BrowserIncomingMessage): void {
1726
+ this.browserMessageCb?.(msg);
1727
+ }
1728
+
1729
+ /** Emit an assistant message with a tool_use content block (no tracking). */
1730
+ private emitToolUse(toolUseId: string, toolName: string, input: Record<string, unknown>): void {
1731
+ console.log(`[codex-adapter] Emitting tool_use: ${toolName} id=${toolUseId}`);
1732
+ this.emit({
1733
+ type: "assistant",
1734
+ message: {
1735
+ id: this.makeMessageId("tool_use", toolUseId),
1736
+ type: "message",
1737
+ role: "assistant",
1738
+ model: this.options.model || "",
1739
+ content: [
1740
+ {
1741
+ type: "tool_use",
1742
+ id: toolUseId,
1743
+ name: toolName,
1744
+ input,
1745
+ },
1746
+ ],
1747
+ stop_reason: null,
1748
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
1749
+ },
1750
+ parent_tool_use_id: null,
1751
+ timestamp: Date.now(),
1752
+ });
1753
+ }
1754
+
1755
+ /** Emit tool_use and track the ID so we don't double-emit. */
1756
+ private emitToolUseTracked(toolUseId: string, toolName: string, input: Record<string, unknown>): void {
1757
+ this.emittedToolUseIds.add(toolUseId);
1758
+ this.emitToolUse(toolUseId, toolName, input);
1759
+ }
1760
+
1761
+ /**
1762
+ * Emit a tool_use start sequence: stream_event content_block_start + assistant message.
1763
+ * This matches Claude Code's streaming pattern and ensures the frontend sees the tool block
1764
+ * even during active streaming.
1765
+ */
1766
+ private emitToolUseStart(toolUseId: string, toolName: string, input: Record<string, unknown>): void {
1767
+ // Emit stream event for tool_use start (matches Claude Code pattern)
1768
+ this.emit({
1769
+ type: "stream_event",
1770
+ event: {
1771
+ type: "content_block_start",
1772
+ index: 0,
1773
+ content_block: { type: "tool_use", id: toolUseId, name: toolName, input: {} },
1774
+ },
1775
+ parent_tool_use_id: null,
1776
+ });
1777
+ this.emitToolUseTracked(toolUseId, toolName, input);
1778
+ }
1779
+
1780
+ /** Emit tool_use only if item/started was never received for this ID. */
1781
+ private ensureToolUseEmitted(toolUseId: string, toolName: string, input: Record<string, unknown>): void {
1782
+ if (!this.emittedToolUseIds.has(toolUseId)) {
1783
+ console.log(`[codex-adapter] Backfilling tool_use for ${toolName} (id=${toolUseId}) — item/started was missing`);
1784
+ this.emitToolUseTracked(toolUseId, toolName, input);
1785
+ }
1786
+ }
1787
+
1788
+ /** Emit an assistant message with a tool_result content block. */
1789
+ private emitToolResult(toolUseId: string, content: unknown, isError: boolean): void {
1790
+ const safeContent = typeof content === "string" ? content : JSON.stringify(content);
1791
+ this.emit({
1792
+ type: "assistant",
1793
+ message: {
1794
+ id: this.makeMessageId("tool_result", toolUseId),
1795
+ type: "message",
1796
+ role: "assistant",
1797
+ model: this.options.model || "",
1798
+ content: [
1799
+ {
1800
+ type: "tool_result",
1801
+ tool_use_id: toolUseId,
1802
+ content: safeContent,
1803
+ is_error: isError,
1804
+ },
1805
+ ],
1806
+ stop_reason: null,
1807
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
1808
+ },
1809
+ parent_tool_use_id: null,
1810
+ timestamp: Date.now(),
1811
+ });
1812
+ }
1813
+
1814
+ private makeMessageId(kind: string, sourceId?: string): string {
1815
+ if (sourceId) return `codex-${kind}-${sourceId}`;
1816
+ return `codex-${kind}-${randomUUID()}`;
1817
+ }
1818
+
1819
+ private mapApprovalPolicy(mode?: string): string {
1820
+ switch (mode) {
1821
+ case "bypassPermissions":
1822
+ return "never";
1823
+ case "plan":
1824
+ case "acceptEdits":
1825
+ case "default":
1826
+ default:
1827
+ return "untrusted";
1828
+ }
1829
+ }
1830
+
1831
+ private mapSandboxPolicy(mode?: string): string {
1832
+ switch (mode) {
1833
+ case "bypassPermissions":
1834
+ return "danger-full-access";
1835
+ default:
1836
+ return "workspace-write";
1837
+ }
1838
+ }
1839
+
1840
+ private async listAllMcpServerStatuses(): Promise<CodexMcpServerStatus[]> {
1841
+ const out: CodexMcpServerStatus[] = [];
1842
+ let cursor: string | null = null;
1843
+ let page = 0;
1844
+
1845
+ while (page < 50) {
1846
+ const response = await this.transport.call("mcpServerStatus/list", {
1847
+ cursor,
1848
+ limit: 100,
1849
+ }) as CodexMcpStatusListResponse;
1850
+ if (Array.isArray(response.data)) {
1851
+ out.push(...response.data);
1852
+ }
1853
+ cursor = typeof response.nextCursor === "string" ? response.nextCursor : null;
1854
+ if (!cursor) break;
1855
+ page++;
1856
+ }
1857
+
1858
+ return out;
1859
+ }
1860
+
1861
+ private async readMcpServersConfig(): Promise<Record<string, unknown>> {
1862
+ const response = await this.transport.call("config/read", {}) as {
1863
+ config?: Record<string, unknown>;
1864
+ };
1865
+ const config = this.asRecord(response?.config) || {};
1866
+ return this.asRecord(config.mcp_servers) || {};
1867
+ }
1868
+
1869
+ private async reloadMcpServers(): Promise<void> {
1870
+ await this.transport.call("config/mcpServer/reload", {});
1871
+ }
1872
+
1873
+ private isMcpServerEnabled(value: unknown): boolean {
1874
+ const cfg = this.asRecord(value);
1875
+ if (!cfg) return true;
1876
+ return cfg.enabled !== false;
1877
+ }
1878
+
1879
+ private asRecord(value: unknown): Record<string, unknown> | null {
1880
+ return value && typeof value === "object" && !Array.isArray(value)
1881
+ ? value as Record<string, unknown>
1882
+ : null;
1883
+ }
1884
+
1885
+ private toMcpServerConfig(value: unknown): McpServerConfig {
1886
+ const cfg = this.asRecord(value) || {};
1887
+ const args = Array.isArray(cfg.args)
1888
+ ? cfg.args.filter((a): a is string => typeof a === "string")
1889
+ : undefined;
1890
+ const env = this.asRecord(cfg.env) as Record<string, string> | null;
1891
+
1892
+ let type: McpServerConfig["type"] = "sdk";
1893
+ if (cfg.type === "stdio" || cfg.type === "sse" || cfg.type === "http" || cfg.type === "sdk") {
1894
+ type = cfg.type;
1895
+ } else if (typeof cfg.command === "string") {
1896
+ type = "stdio";
1897
+ } else if (typeof cfg.url === "string") {
1898
+ type = "http";
1899
+ }
1900
+
1901
+ return {
1902
+ type,
1903
+ command: typeof cfg.command === "string" ? cfg.command : undefined,
1904
+ args,
1905
+ env: env || undefined,
1906
+ url: typeof cfg.url === "string" ? cfg.url : undefined,
1907
+ };
1908
+ }
1909
+
1910
+ private fromMcpServerConfig(config: McpServerConfig): Record<string, unknown> {
1911
+ const out: Record<string, unknown> = {};
1912
+ if (typeof config.command === "string") out.command = config.command;
1913
+ if (Array.isArray(config.args)) out.args = config.args;
1914
+ if (config.env) out.env = config.env;
1915
+ if (typeof config.url === "string") out.url = config.url;
1916
+ return out;
1917
+ }
1918
+
1919
+ private normalizeRawMcpServerConfig(value: unknown): Record<string, unknown> {
1920
+ const cfg = this.asRecord(value) || {};
1921
+ const out: Record<string, unknown> = {};
1922
+
1923
+ // Keep only fields supported by Codex raw MCP config schema
1924
+ if (typeof cfg.command === "string") out.command = cfg.command;
1925
+ if (Array.isArray(cfg.args)) out.args = cfg.args.filter((a) => typeof a === "string");
1926
+ if (typeof cfg.cwd === "string") out.cwd = cfg.cwd;
1927
+ if (typeof cfg.url === "string") out.url = cfg.url;
1928
+ if (typeof cfg.enabled === "boolean") out.enabled = cfg.enabled;
1929
+ if (typeof cfg.required === "boolean") out.required = cfg.required;
1930
+
1931
+ const env = this.asRecord(cfg.env);
1932
+ if (env) out.env = Object.fromEntries(
1933
+ Object.entries(env).filter(([, v]) => typeof v === "string"),
1934
+ );
1935
+
1936
+ const envHttpHeaders = this.asRecord(cfg.env_http_headers);
1937
+ if (envHttpHeaders) out.env_http_headers = Object.fromEntries(
1938
+ Object.entries(envHttpHeaders).filter(([, v]) => typeof v === "string"),
1939
+ );
1940
+
1941
+ const httpHeaders = this.asRecord(cfg.http_headers);
1942
+ if (httpHeaders) out.http_headers = Object.fromEntries(
1943
+ Object.entries(httpHeaders).filter(([, v]) => typeof v === "string"),
1944
+ );
1945
+
1946
+ const asStringArray = (arr: unknown): string[] | undefined =>
1947
+ Array.isArray(arr)
1948
+ ? arr.filter((x): x is string => typeof x === "string")
1949
+ : undefined;
1950
+
1951
+ const disabledTools = asStringArray(cfg.disabled_tools);
1952
+ if (disabledTools) out.disabled_tools = disabledTools;
1953
+ const enabledTools = asStringArray(cfg.enabled_tools);
1954
+ if (enabledTools) out.enabled_tools = enabledTools;
1955
+ const envVars = asStringArray(cfg.env_vars);
1956
+ if (envVars) out.env_vars = envVars;
1957
+ const scopes = asStringArray(cfg.scopes);
1958
+ if (scopes) out.scopes = scopes;
1959
+
1960
+ if (typeof cfg.startup_timeout_ms === "number") out.startup_timeout_ms = cfg.startup_timeout_ms;
1961
+ if (typeof cfg.startup_timeout_sec === "number") out.startup_timeout_sec = cfg.startup_timeout_sec;
1962
+ if (typeof cfg.tool_timeout_sec === "number") out.tool_timeout_sec = cfg.tool_timeout_sec;
1963
+ if (typeof cfg.bearer_token === "string") out.bearer_token = cfg.bearer_token;
1964
+ if (typeof cfg.bearer_token_env_var === "string") out.bearer_token_env_var = cfg.bearer_token_env_var;
1965
+
1966
+ return out;
1967
+ }
1968
+
1969
+ private mapMcpTools(
1970
+ tools: Record<string, { name?: string; annotations?: unknown }> | undefined,
1971
+ ): McpServerDetail["tools"] {
1972
+ if (!tools) return [];
1973
+ return Object.entries(tools).map(([key, tool]) => {
1974
+ const ann = this.asRecord(tool.annotations);
1975
+ const annotations = ann ? {
1976
+ readOnly: (ann.readOnly ?? ann.readOnlyHint) === true,
1977
+ destructive: (ann.destructive ?? ann.destructiveHint) === true,
1978
+ openWorld: (ann.openWorld ?? ann.openWorldHint) === true,
1979
+ } : undefined;
1980
+
1981
+ return {
1982
+ name: typeof tool.name === "string" ? tool.name : key,
1983
+ annotations,
1984
+ };
1985
+ });
1986
+ }
1987
+ }