heyhank 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 (199) hide show
  1. package/README.md +40 -0
  2. package/bin/cli.ts +168 -0
  3. package/bin/ctl.ts +528 -0
  4. package/bin/generate-token.ts +28 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
  7. package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
  8. package/dist/assets/CronManager-DDbz-yiT.js +1 -0
  9. package/dist/assets/HelpPage-DMfkzERp.js +1 -0
  10. package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
  11. package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
  12. package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
  13. package/dist/assets/Playground-Fc5cdc5p.js +109 -0
  14. package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
  15. package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
  16. package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
  17. package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
  18. package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
  19. package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
  20. package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
  21. package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
  22. package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
  23. package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
  24. package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
  25. package/dist/assets/index-C8M_PUmX.css +32 -0
  26. package/dist/assets/index-CEqZnThB.js +204 -0
  27. package/dist/assets/sw-register-LSSpj6RU.js +1 -0
  28. package/dist/assets/time-ago-B6r_l9u1.js +1 -0
  29. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  30. package/dist/favicon-32-original.png +0 -0
  31. package/dist/favicon-32.png +0 -0
  32. package/dist/favicon.ico +0 -0
  33. package/dist/favicon.svg +8 -0
  34. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  35. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  36. package/dist/heyhank-mascot-poster.png +0 -0
  37. package/dist/heyhank-mascot.mp4 +0 -0
  38. package/dist/heyhank-mascot.webm +0 -0
  39. package/dist/icon-192-original.png +0 -0
  40. package/dist/icon-192.png +0 -0
  41. package/dist/icon-512-original.png +0 -0
  42. package/dist/icon-512.png +0 -0
  43. package/dist/index.html +21 -0
  44. package/dist/logo-192.png +0 -0
  45. package/dist/logo-512.png +0 -0
  46. package/dist/logo-codex.svg +14 -0
  47. package/dist/logo-docker.svg +4 -0
  48. package/dist/logo-original.png +0 -0
  49. package/dist/logo.png +0 -0
  50. package/dist/logo.svg +14 -0
  51. package/dist/manifest.json +24 -0
  52. package/dist/push-sw.js +34 -0
  53. package/dist/sw.js +1 -0
  54. package/dist/workbox-d2a0910a.js +1 -0
  55. package/package.json +109 -0
  56. package/server/agent-cron-migrator.ts +85 -0
  57. package/server/agent-executor.ts +357 -0
  58. package/server/agent-store.ts +185 -0
  59. package/server/agent-timeout.ts +107 -0
  60. package/server/agent-types.ts +122 -0
  61. package/server/ai-validation-settings.ts +37 -0
  62. package/server/ai-validator.ts +181 -0
  63. package/server/anthropic-provider-migration.ts +48 -0
  64. package/server/assistant-store.ts +272 -0
  65. package/server/auth-manager.ts +150 -0
  66. package/server/auto-approve.ts +153 -0
  67. package/server/auto-namer.ts +36 -0
  68. package/server/backend-adapter.ts +54 -0
  69. package/server/cache-headers.ts +61 -0
  70. package/server/calendar-service.ts +434 -0
  71. package/server/claude-adapter.ts +889 -0
  72. package/server/claude-container-auth.ts +30 -0
  73. package/server/claude-session-discovery.ts +157 -0
  74. package/server/claude-session-history.ts +410 -0
  75. package/server/cli-launcher.ts +1303 -0
  76. package/server/codex-adapter.ts +3027 -0
  77. package/server/codex-container-auth.ts +24 -0
  78. package/server/codex-home.ts +27 -0
  79. package/server/codex-ws-proxy.cjs +226 -0
  80. package/server/commands-discovery.ts +81 -0
  81. package/server/constants.ts +7 -0
  82. package/server/container-manager.ts +1053 -0
  83. package/server/cost-tracker.ts +222 -0
  84. package/server/cron-scheduler.ts +243 -0
  85. package/server/cron-store.ts +148 -0
  86. package/server/cron-types.ts +63 -0
  87. package/server/email-service.ts +354 -0
  88. package/server/env-manager.ts +161 -0
  89. package/server/event-bus-types.ts +75 -0
  90. package/server/event-bus.ts +124 -0
  91. package/server/execution-store.ts +170 -0
  92. package/server/federation/node-connection.ts +190 -0
  93. package/server/federation/node-manager.ts +366 -0
  94. package/server/federation/node-store.ts +86 -0
  95. package/server/federation/node-types.ts +121 -0
  96. package/server/fs-utils.ts +15 -0
  97. package/server/git-utils.ts +421 -0
  98. package/server/github-pr.ts +379 -0
  99. package/server/google-media.ts +342 -0
  100. package/server/image-pull-manager.ts +279 -0
  101. package/server/index.ts +491 -0
  102. package/server/internal-ai.ts +237 -0
  103. package/server/kill-switch.ts +99 -0
  104. package/server/llm-providers.ts +342 -0
  105. package/server/logger.ts +259 -0
  106. package/server/mcp-registry.ts +401 -0
  107. package/server/message-bus.ts +271 -0
  108. package/server/message-delivery.ts +128 -0
  109. package/server/metrics-collector.ts +350 -0
  110. package/server/metrics-types.ts +108 -0
  111. package/server/middleware/managed-auth.ts +195 -0
  112. package/server/novnc-proxy.ts +99 -0
  113. package/server/path-resolver.ts +186 -0
  114. package/server/paths.ts +13 -0
  115. package/server/pr-poller.ts +162 -0
  116. package/server/prompt-manager.ts +211 -0
  117. package/server/protocol/claude-upstream/README.md +19 -0
  118. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  119. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  120. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  121. package/server/protocol/codex-upstream/README.md +18 -0
  122. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  123. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  124. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  125. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  126. package/server/protocol-monitor.ts +50 -0
  127. package/server/provider-manager.ts +111 -0
  128. package/server/provider-registry.ts +393 -0
  129. package/server/push-notifications.ts +221 -0
  130. package/server/recorder.ts +374 -0
  131. package/server/recording-hub/compat-validator.ts +284 -0
  132. package/server/recording-hub/diagnostics.ts +299 -0
  133. package/server/recording-hub/hub-config.ts +19 -0
  134. package/server/recording-hub/hub-routes.ts +236 -0
  135. package/server/recording-hub/hub-store.ts +265 -0
  136. package/server/recording-hub/replay-adapter.ts +207 -0
  137. package/server/relay-client.ts +320 -0
  138. package/server/reminder-scheduler.ts +38 -0
  139. package/server/replay.ts +78 -0
  140. package/server/routes/agent-routes.ts +264 -0
  141. package/server/routes/assistant-routes.ts +90 -0
  142. package/server/routes/cron-routes.ts +103 -0
  143. package/server/routes/env-routes.ts +95 -0
  144. package/server/routes/federation-routes.ts +76 -0
  145. package/server/routes/fs-routes.ts +622 -0
  146. package/server/routes/git-routes.ts +97 -0
  147. package/server/routes/llm-routes.ts +166 -0
  148. package/server/routes/media-routes.ts +135 -0
  149. package/server/routes/metrics-routes.ts +13 -0
  150. package/server/routes/platform-routes.ts +1379 -0
  151. package/server/routes/prompt-routes.ts +67 -0
  152. package/server/routes/provider-routes.ts +109 -0
  153. package/server/routes/sandbox-routes.ts +127 -0
  154. package/server/routes/settings-routes.ts +285 -0
  155. package/server/routes/skills-routes.ts +100 -0
  156. package/server/routes/socialmedia-routes.ts +208 -0
  157. package/server/routes/system-routes.ts +228 -0
  158. package/server/routes/tailscale-routes.ts +22 -0
  159. package/server/routes/telephony-routes.ts +259 -0
  160. package/server/routes.ts +1379 -0
  161. package/server/sandbox-manager.ts +168 -0
  162. package/server/service.ts +718 -0
  163. package/server/session-creation-service.ts +457 -0
  164. package/server/session-git-info.ts +104 -0
  165. package/server/session-names.ts +67 -0
  166. package/server/session-orchestrator.ts +824 -0
  167. package/server/session-state-machine.ts +207 -0
  168. package/server/session-store.ts +146 -0
  169. package/server/session-types.ts +511 -0
  170. package/server/settings-manager.ts +149 -0
  171. package/server/shared-context.ts +157 -0
  172. package/server/socialmedia/adapter.ts +15 -0
  173. package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
  174. package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
  175. package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
  176. package/server/socialmedia/manager.ts +227 -0
  177. package/server/socialmedia/store.ts +98 -0
  178. package/server/socialmedia/types.ts +89 -0
  179. package/server/tailscale-manager.ts +451 -0
  180. package/server/telephony/audio-bridge.ts +331 -0
  181. package/server/telephony/call-manager.ts +457 -0
  182. package/server/telephony/call-types.ts +108 -0
  183. package/server/telephony/telephony-store.ts +119 -0
  184. package/server/terminal-manager.ts +240 -0
  185. package/server/update-checker.ts +192 -0
  186. package/server/usage-limits.ts +225 -0
  187. package/server/web-push.d.ts +51 -0
  188. package/server/worktree-tracker.ts +84 -0
  189. package/server/ws-auth.ts +41 -0
  190. package/server/ws-bridge-browser-ingest.ts +72 -0
  191. package/server/ws-bridge-browser.ts +112 -0
  192. package/server/ws-bridge-cli-ingest.ts +81 -0
  193. package/server/ws-bridge-codex.ts +266 -0
  194. package/server/ws-bridge-controls.ts +20 -0
  195. package/server/ws-bridge-persist.ts +66 -0
  196. package/server/ws-bridge-publish.ts +79 -0
  197. package/server/ws-bridge-replay.ts +61 -0
  198. package/server/ws-bridge-types.ts +121 -0
  199. package/server/ws-bridge.ts +1240 -0
@@ -0,0 +1,3027 @@
1
+ /**
2
+ * Codex App-Server Adapter
3
+ *
4
+ * Translates between the Codex app-server JSON-RPC protocol (stdin/stdout)
5
+ * and HeyHank'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 { IBackendAdapter } from "./backend-adapter.js";
15
+ import type {
16
+ BrowserIncomingMessage,
17
+ BrowserOutgoingMessage,
18
+ SessionState,
19
+ PermissionRequest,
20
+ CLIResultMessage,
21
+ McpServerDetail,
22
+ McpServerConfig,
23
+ } from "./session-types.js";
24
+ import type { RecorderManager } from "./recorder.js";
25
+ import { reportProtocolDrift } from "./protocol-monitor.js";
26
+ import { log } from "./logger.js";
27
+
28
+ // ─── Codex JSON-RPC Types ─────────────────────────────────────────────────────
29
+
30
+ interface JsonRpcRequest {
31
+ method: string;
32
+ id: number;
33
+ params: Record<string, unknown>;
34
+ }
35
+
36
+ interface JsonRpcNotification {
37
+ method: string;
38
+ params: Record<string, unknown>;
39
+ }
40
+
41
+ interface JsonRpcResponse {
42
+ id: number;
43
+ result?: unknown;
44
+ error?: { code: number; message: string; data?: unknown };
45
+ }
46
+
47
+ type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse;
48
+
49
+ // Codex item types
50
+ interface CodexItem {
51
+ type: string;
52
+ id: string;
53
+ [key: string]: unknown;
54
+ }
55
+
56
+ /** Safely extract a string kind from a Codex file change entry.
57
+ * Codex may send kind as a string ("create") or as an object ({ type: "modify" }). */
58
+ function safeKind(kind: unknown): string {
59
+ if (typeof kind === "string") return kind;
60
+ if (kind && typeof kind === "object" && "type" in kind) {
61
+ const t = (kind as Record<string, unknown>).type;
62
+ if (typeof t === "string") return t;
63
+ }
64
+ return "modify";
65
+ }
66
+
67
+ interface CodexAgentMessageItem extends CodexItem {
68
+ type: "agentMessage";
69
+ text?: string;
70
+ }
71
+
72
+ interface CodexCommandExecutionItem extends CodexItem {
73
+ type: "commandExecution";
74
+ command: string | string[];
75
+ cwd?: string;
76
+ status: "inProgress" | "completed" | "failed" | "declined";
77
+ exitCode?: number;
78
+ durationMs?: number;
79
+ }
80
+
81
+ interface CodexFileChangeItem extends CodexItem {
82
+ type: "fileChange";
83
+ changes?: Array<{ path: string; kind: unknown; diff?: string }>;
84
+ status: "inProgress" | "completed" | "failed" | "declined";
85
+ }
86
+
87
+ interface CodexMcpToolCallItem extends CodexItem {
88
+ type: "mcpToolCall";
89
+ server: string;
90
+ tool: string;
91
+ status: "inProgress" | "completed" | "failed";
92
+ arguments?: Record<string, unknown>;
93
+ result?: string;
94
+ error?: string;
95
+ }
96
+
97
+ interface CodexWebSearchItem extends CodexItem {
98
+ type: "webSearch";
99
+ query?: string;
100
+ action?: { type: string; url?: string; pattern?: string };
101
+ }
102
+
103
+ interface CodexReasoningItem extends CodexItem {
104
+ type: "reasoning";
105
+ summary?: string;
106
+ content?: string;
107
+ }
108
+
109
+ interface CodexCollabAgentToolCallItem extends CodexItem {
110
+ type: "collabAgentToolCall";
111
+ tool: string;
112
+ status: "inProgress" | "completed" | "failed";
113
+ senderThreadId?: string;
114
+ receiverThreadIds?: string[];
115
+ prompt?: string | null;
116
+ agentsStates?: Record<string, unknown>;
117
+ }
118
+
119
+ interface PlanTodo {
120
+ content: string;
121
+ status: "pending" | "in_progress" | "completed";
122
+ activeForm?: string;
123
+ }
124
+
125
+ interface CodexMcpServerStatus {
126
+ name: string;
127
+ tools?: Record<string, { name?: string; annotations?: unknown }>;
128
+ authStatus?: "unsupported" | "notLoggedIn" | "bearerToken" | "oAuth";
129
+ }
130
+
131
+ interface CodexMcpStatusListResponse {
132
+ data?: CodexMcpServerStatus[];
133
+ nextCursor?: string | null;
134
+ }
135
+
136
+ // ─── Transport Interface ─────────────────────────────────────────────────────
137
+
138
+ /** Abstract transport for Codex JSON-RPC communication. */
139
+ export interface ICodexTransport {
140
+ call(method: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<unknown>;
141
+ notify(method: string, params?: Record<string, unknown>): Promise<void>;
142
+ respond(id: number, result: unknown): Promise<void>;
143
+ onNotification(handler: (method: string, params: Record<string, unknown>) => void): void;
144
+ onRequest(handler: (method: string, id: number, params: Record<string, unknown>) => void): void;
145
+ onRawIncoming(cb: (line: string) => void): void;
146
+ onRawOutgoing(cb: (data: string) => void): void;
147
+ onParseError(cb: (message: string) => void): void;
148
+ isConnected(): boolean;
149
+ }
150
+
151
+ /** Default RPC call timeout in milliseconds. */
152
+ const DEFAULT_RPC_TIMEOUT_MS = 60_000;
153
+
154
+ /** Per-method timeout overrides (ms). */
155
+ const RPC_METHOD_TIMEOUTS: Record<string, number> = {
156
+ "turn/start": 120_000,
157
+ "turn/interrupt": 15_000,
158
+ "codex/configureSession": 30_000,
159
+ "thread/start": 30_000,
160
+ "thread/resume": 30_000,
161
+ };
162
+
163
+ // ─── Adapter Options ──────────────────────────────────────────────────────────
164
+
165
+ export interface CodexAdapterOptions {
166
+ model?: string;
167
+ cwd?: string;
168
+ /** Runtime cwd for Codex RPC calls. Falls back to `cwd` when omitted. */
169
+ executionCwd?: string;
170
+ approvalMode?: string;
171
+ sandbox?: "workspace-write" | "danger-full-access";
172
+ /** If provided, resume an existing thread instead of starting a new one. */
173
+ threadId?: string;
174
+ /** Optional recorder for raw message capture. */
175
+ recorder?: RecorderManager;
176
+ /** Callback to kill the underlying process/connection on disconnect. */
177
+ killProcess?: () => Promise<void> | void;
178
+ /** Optional system prompt injected into thread/start as instructions. */
179
+ systemPrompt?: string;
180
+ }
181
+
182
+ // ─── Stdio JSON-RPC Transport ────────────────────────────────────────────────
183
+
184
+ export class StdioTransport implements ICodexTransport {
185
+ private nextId = 1;
186
+ private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
187
+ private pendingTimers = new Map<number, ReturnType<typeof setTimeout>>();
188
+ private notificationHandler: ((method: string, params: Record<string, unknown>) => void) | null = null;
189
+ private requestHandler: ((method: string, id: number, params: Record<string, unknown>) => void) | null = null;
190
+ private rawInCb: ((line: string) => void) | null = null;
191
+ private rawOutCb: ((data: string) => void) | null = null;
192
+ private parseErrorCb: ((message: string) => void) | null = null;
193
+ private writer: WritableStreamDefaultWriter<Uint8Array>;
194
+ private connected = true;
195
+ private buffer = "";
196
+ private protocolDriftSeen = new Set<string>();
197
+
198
+ constructor(
199
+ stdin: WritableStream<Uint8Array> | { write(data: Uint8Array): number },
200
+ stdout: ReadableStream<Uint8Array>,
201
+ private readonly sessionId = "unknown",
202
+ ) {
203
+ // Handle both Bun subprocess stdin types
204
+ let writable: WritableStream<Uint8Array>;
205
+ if ("write" in stdin && typeof stdin.write === "function") {
206
+ // Bun's subprocess stdin has a .write() method directly
207
+ writable = new WritableStream({
208
+ write(chunk) {
209
+ (stdin as { write(data: Uint8Array): number }).write(chunk);
210
+ },
211
+ });
212
+ } else {
213
+ writable = stdin as WritableStream<Uint8Array>;
214
+ }
215
+ // Acquire writer once and hold it — avoids "WritableStream is locked" race
216
+ // when concurrent async calls (e.g. rateLimits + turn/start) overlap.
217
+ this.writer = writable.getWriter();
218
+
219
+ this.readStdout(stdout);
220
+ }
221
+
222
+ private async readStdout(stdout: ReadableStream<Uint8Array>): Promise<void> {
223
+ const reader = stdout.getReader();
224
+ const decoder = new TextDecoder();
225
+ try {
226
+ while (true) {
227
+ const { done, value } = await reader.read();
228
+ if (done) break;
229
+ this.buffer += decoder.decode(value, { stream: true });
230
+ this.processBuffer();
231
+ }
232
+ } catch (err) {
233
+ log.error("codex-adapter", "stdout reader error", {
234
+ sessionId: this.sessionId,
235
+ error: err instanceof Error ? err.message : String(err),
236
+ });
237
+ } finally {
238
+ this.connected = false;
239
+ // Clear all pending RPC timers and reject promises so callers don't
240
+ // hang indefinitely when the Codex process crashes or exits.
241
+ for (const [, timer] of this.pendingTimers) {
242
+ clearTimeout(timer);
243
+ }
244
+ this.pendingTimers.clear();
245
+ for (const [, { reject }] of this.pending) {
246
+ reject(new Error("Transport closed"));
247
+ }
248
+ this.pending.clear();
249
+ }
250
+ }
251
+
252
+ private processBuffer(): void {
253
+ const lines = this.buffer.split("\n");
254
+ // Keep the last incomplete line in the buffer
255
+ this.buffer = lines.pop() || "";
256
+
257
+ for (const line of lines) {
258
+ const trimmed = line.trim();
259
+ if (!trimmed) continue;
260
+
261
+ // Record raw incoming line before parsing
262
+ this.rawInCb?.(trimmed);
263
+
264
+ let msg: JsonRpcMessage;
265
+ try {
266
+ msg = JSON.parse(trimmed);
267
+ } catch {
268
+ reportProtocolDrift(
269
+ this.protocolDriftSeen,
270
+ {
271
+ backend: "codex",
272
+ sessionId: this.sessionId,
273
+ direction: "incoming",
274
+ messageKind: "parse_error",
275
+ messageName: "json-rpc",
276
+ rawPreview: trimmed,
277
+ },
278
+ (message) => this.parseErrorCb?.(message),
279
+ );
280
+ continue;
281
+ }
282
+
283
+ this.dispatch(msg);
284
+ }
285
+ }
286
+
287
+ private dispatch(msg: JsonRpcMessage): void {
288
+ if ("id" in msg && msg.id !== undefined) {
289
+ if ("method" in msg && msg.method) {
290
+ // This is a request FROM the server (e.g., approval request)
291
+ this.requestHandler?.(msg.method, msg.id as number, (msg as JsonRpcRequest).params || {});
292
+ } else {
293
+ // This is a response to one of our requests
294
+ const msgId = msg.id as number;
295
+ const pending = this.pending.get(msgId);
296
+ if (pending) {
297
+ this.pending.delete(msgId);
298
+ const timer = this.pendingTimers.get(msgId);
299
+ if (timer) {
300
+ clearTimeout(timer);
301
+ this.pendingTimers.delete(msgId);
302
+ }
303
+ const resp = msg as JsonRpcResponse;
304
+ if (resp.error) {
305
+ const rpcErr = new Error(resp.error.message);
306
+ (rpcErr as unknown as Record<string, unknown>).code = resp.error.code;
307
+ pending.reject(rpcErr);
308
+ } else {
309
+ pending.resolve(resp.result);
310
+ }
311
+ }
312
+ }
313
+ } else if ("method" in msg) {
314
+ // When the WS proxy reconnects to Codex, all pending RPC calls are
315
+ // orphaned (Codex sees a fresh connection and won't respond to them).
316
+ // Reject them immediately so callers don't hang until timeout.
317
+ if ((msg as JsonRpcNotification).method === "companion/wsReconnected") {
318
+ const pendingCount = this.pending.size;
319
+ if (pendingCount > 0) {
320
+ console.warn(
321
+ `[codex-adapter] WS proxy reconnected — rejecting ${pendingCount} orphaned RPC call(s)`,
322
+ );
323
+ for (const [, timer] of this.pendingTimers) {
324
+ clearTimeout(timer);
325
+ }
326
+ this.pendingTimers.clear();
327
+ for (const [, { reject }] of this.pending) {
328
+ reject(new Error("Transport reconnected"));
329
+ }
330
+ this.pending.clear();
331
+ }
332
+ }
333
+ // Notification (no id)
334
+ this.notificationHandler?.(msg.method, (msg as JsonRpcNotification).params || {});
335
+ }
336
+ }
337
+
338
+ /** Send a request and wait for the matching response.
339
+ * Rejects with a timeout error if no response arrives within the deadline. */
340
+ async call(method: string, params: Record<string, unknown> = {}, timeoutMs?: number): Promise<unknown> {
341
+ const id = this.nextId++;
342
+ const effectiveTimeout = timeoutMs ?? RPC_METHOD_TIMEOUTS[method] ?? DEFAULT_RPC_TIMEOUT_MS;
343
+ return new Promise(async (resolve, reject) => {
344
+ const timer = setTimeout(() => {
345
+ this.pending.delete(id);
346
+ this.pendingTimers.delete(id);
347
+ reject(new Error(`RPC timeout: ${method} did not respond within ${effectiveTimeout}ms`));
348
+ }, effectiveTimeout);
349
+ this.pendingTimers.set(id, timer);
350
+ this.pending.set(id, { resolve, reject });
351
+ const request = JSON.stringify({ method, id, params });
352
+ try {
353
+ await this.writeRaw(request + "\n");
354
+ } catch (err) {
355
+ clearTimeout(timer);
356
+ this.pendingTimers.delete(id);
357
+ this.pending.delete(id);
358
+ reject(err instanceof Error ? err : new Error(String(err)));
359
+ }
360
+ });
361
+ }
362
+
363
+ /** Send a notification (no response expected). */
364
+ async notify(method: string, params: Record<string, unknown> = {}): Promise<void> {
365
+ const notification = JSON.stringify({ method, params });
366
+ await this.writeRaw(notification + "\n");
367
+ }
368
+
369
+ /** Respond to a request from the server (e.g., approval). */
370
+ async respond(id: number, result: unknown): Promise<void> {
371
+ const response = JSON.stringify({ id, result });
372
+ await this.writeRaw(response + "\n");
373
+ }
374
+
375
+ /** Register handler for server-initiated notifications. */
376
+ onNotification(handler: (method: string, params: Record<string, unknown>) => void): void {
377
+ this.notificationHandler = handler;
378
+ }
379
+
380
+ /** Register handler for server-initiated requests (need a response). */
381
+ onRequest(handler: (method: string, id: number, params: Record<string, unknown>) => void): void {
382
+ this.requestHandler = handler;
383
+ }
384
+
385
+ isConnected(): boolean {
386
+ return this.connected;
387
+ }
388
+
389
+ /** Register callback for raw incoming lines (before JSON parse). */
390
+ onRawIncoming(cb: (line: string) => void): void {
391
+ this.rawInCb = cb;
392
+ }
393
+
394
+ /** Register callback for raw outgoing data (before write). */
395
+ onRawOutgoing(cb: (data: string) => void): void {
396
+ this.rawOutCb = cb;
397
+ }
398
+
399
+ /** Register callback for parse error messages to surface to the browser. */
400
+ onParseError(cb: (message: string) => void): void {
401
+ this.parseErrorCb = cb;
402
+ }
403
+
404
+ private async writeRaw(data: string): Promise<void> {
405
+ if (!this.connected) {
406
+ throw new Error("Transport closed");
407
+ }
408
+ // Record raw outgoing data before writing
409
+ this.rawOutCb?.(data);
410
+ await this.writer.write(new TextEncoder().encode(data));
411
+ }
412
+ }
413
+
414
+ // ─── Codex Adapter ────────────────────────────────────────────────────────────
415
+
416
+ export class CodexAdapter implements IBackendAdapter {
417
+ private transport: ICodexTransport;
418
+ private sessionId: string;
419
+ private options: CodexAdapterOptions;
420
+
421
+ private browserMessageCb: ((msg: BrowserIncomingMessage) => void) | null = null;
422
+ private sessionMetaCb: ((meta: { cliSessionId?: string; model?: string; cwd?: string }) => void) | null = null;
423
+ private disconnectCb: (() => void) | null = null;
424
+ private initErrorCb: ((error: string) => void) | null = null;
425
+
426
+ // State
427
+ private threadId: string | null = null;
428
+ private currentTurnId: string | null = null;
429
+ private connected = false;
430
+ private initialized = false;
431
+ private initFailed = false;
432
+ private initInProgress = false;
433
+ /** Monotonically increasing epoch — incremented on every WS reconnect or
434
+ * resetForReconnect so that a stale in-flight initialize() can detect that
435
+ * a newer one has been triggered and bail out early. */
436
+ private initEpoch = 0;
437
+ /** Guard against multiple cleanupAndDisconnect() calls firing disconnectCb twice. */
438
+ private disconnectFired = false;
439
+
440
+ // Streaming accumulator for agent messages
441
+ private streamingText = "";
442
+ private streamingItemId: string | null = null;
443
+
444
+ // Track command execution start times for progress indicator
445
+ private commandStartTimes = new Map<string, number>();
446
+
447
+ // Track requested runtime mode for subsequent turns.
448
+ private currentPermissionMode: string;
449
+ private lastNonPlanPermissionMode: string;
450
+ private currentCollaborationModeKind: "default" | "plan";
451
+ // Track what we last sent to Codex so we only send on transitions.
452
+ // null = nothing sent yet (first turn needs to send if starting in plan).
453
+ private lastSentCollaborationModeKind: "default" | "plan" | null = null;
454
+
455
+ // Track Codex plan deltas and updates per turn (used by /plan).
456
+ private planDeltaByTurnId = new Map<string, string>();
457
+ private planUpdateCountByTurnId = new Map<string, number>();
458
+
459
+ // Accumulate reasoning text by item ID so we can emit final thinking blocks.
460
+ private reasoningTextByItemId = new Map<string, string>();
461
+
462
+ // Track which item IDs we have already emitted a tool_use block for.
463
+ // When Codex auto-approves (approvalPolicy "never"), it may skip item/started
464
+ // and only send item/completed — we need to emit tool_use before tool_result.
465
+ private emittedToolUseIds = new Set<string>();
466
+ // Receiver subagent thread ID -> parent collab tool_use ID.
467
+ private parentToolUseByThreadId = new Map<string, string>();
468
+
469
+ // Queue messages received before initialization completes
470
+ private pendingOutgoing: BrowserOutgoingMessage[] = [];
471
+ /** Number of consecutive reconnect-retries for the current user message. */
472
+ private reconnectRetryCount = 0;
473
+ /** Number of consecutive overload (-32001) retries for the current user message. */
474
+ private overloadRetryCount = 0;
475
+ private static readonly MAX_RECONNECT_RETRIES = 5;
476
+ /** Timer handle for the -32001 overload backoff retry, so we can cancel it on reconnect. */
477
+ private overloadRetryTimer: ReturnType<typeof setTimeout> | null = null;
478
+ /** The message captured in the overload retry timer closure, so it can be
479
+ * rescued to pendingOutgoing if the timer is cancelled by a reconnect. */
480
+ private overloadRetryMsg: BrowserOutgoingMessage | null = null;
481
+
482
+ // Pending approval requests (Codex sends these as JSON-RPC requests with an id)
483
+ private pendingApprovals = new Map<string, number>(); // request_id -> JSON-RPC id
484
+
485
+ // Track request types that need different response formats
486
+ private pendingUserInputQuestionIds = new Map<string, string[]>(); // request_id -> ordered Codex question IDs
487
+ private pendingReviewDecisions = new Set<string>(); // request_ids that need ReviewDecision format
488
+ private pendingExitPlanModeRequests = new Set<string>(); // request_ids for ExitPlanMode approvals
489
+ private pendingDynamicToolCalls = new Map<string, {
490
+ jsonRpcId: number;
491
+ callId: string;
492
+ toolName: string;
493
+ timeout: ReturnType<typeof setTimeout>;
494
+ }>(); // request_id -> pending dynamic tool call metadata
495
+
496
+ // Codex account rate limits (fetched after init, updated via notification)
497
+ private _rateLimits: {
498
+ primary: { usedPercent: number; windowDurationMins: number; resetsAt: number } | null;
499
+ secondary: { usedPercent: number; windowDurationMins: number; resetsAt: number } | null;
500
+ } | null = null;
501
+ private static readonly DYNAMIC_TOOL_CALL_TIMEOUT_MS = 120_000;
502
+ private protocolDriftSeen = new Set<string>();
503
+
504
+ private getExecutionCwd(): string {
505
+ return this.options.executionCwd || this.options.cwd || "";
506
+ }
507
+
508
+ /**
509
+ * Create a CodexAdapter.
510
+ * @param transportOrProc - Either a pre-built ICodexTransport or a Bun Subprocess
511
+ * (backward compat: when given a Subprocess, a StdioTransport is built from its pipes).
512
+ */
513
+ constructor(transportOrProc: ICodexTransport | Subprocess, sessionId: string, options: CodexAdapterOptions = {}) {
514
+ this.sessionId = sessionId;
515
+ this.options = options;
516
+ this.currentPermissionMode = options.approvalMode || "default";
517
+ this.lastNonPlanPermissionMode = this.currentPermissionMode === "plan"
518
+ ? "acceptEdits"
519
+ : this.currentPermissionMode;
520
+ this.currentCollaborationModeKind = this.currentPermissionMode === "plan"
521
+ ? "plan"
522
+ : "default";
523
+
524
+ // Determine whether we received a transport or a subprocess
525
+ if (this.isTransport(transportOrProc)) {
526
+ // Pre-built transport (e.g. WebSocketTransport)
527
+ this.transport = transportOrProc;
528
+ } else {
529
+ // Subprocess — build StdioTransport from its pipes (legacy path)
530
+ const proc = transportOrProc;
531
+ const stdout = proc.stdout;
532
+ const stdin = proc.stdin;
533
+ if (!stdout || !stdin || typeof stdout === "number" || typeof stdin === "number") {
534
+ throw new Error("Codex process must have stdio pipes");
535
+ }
536
+
537
+ this.transport = new StdioTransport(
538
+ stdin as WritableStream<Uint8Array> | { write(data: Uint8Array): number },
539
+ stdout as ReadableStream<Uint8Array>,
540
+ this.sessionId,
541
+ );
542
+
543
+ // Monitor process exit — when using a subprocess directly,
544
+ // set up the exit handler here. For transport-only mode,
545
+ // the caller provides killProcess and the transport's own
546
+ // close handling triggers disconnectCb.
547
+ if (!options.killProcess) {
548
+ options.killProcess = async () => {
549
+ try {
550
+ proc.kill("SIGTERM");
551
+ await Promise.race([
552
+ proc.exited,
553
+ new Promise((r) => setTimeout(r, 5000)),
554
+ ]);
555
+ } catch {}
556
+ };
557
+ }
558
+
559
+ proc.exited.then(() => {
560
+ this.cleanupAndDisconnect();
561
+ });
562
+ }
563
+
564
+ this.transport.onNotification((method, params) => this.handleNotification(method, params));
565
+ this.transport.onRequest((method, id, params) => this.handleRequest(method, id, params));
566
+
567
+ // Wire raw message recording if a recorder is provided
568
+ if (options.recorder) {
569
+ const recorder = options.recorder;
570
+ const cwd = options.cwd || "";
571
+ this.transport.onRawIncoming((line) => {
572
+ recorder.record(sessionId, "in", line, "cli", "codex", cwd);
573
+ });
574
+ this.transport.onRawOutgoing((data) => {
575
+ recorder.record(sessionId, "out", data.trimEnd(), "cli", "codex", cwd);
576
+ });
577
+ }
578
+
579
+ // Surface transport-level parse errors to the browser
580
+ this.transport.onParseError((message) => {
581
+ this.browserMessageCb?.({ type: "error", message });
582
+ });
583
+
584
+ // Start initialization
585
+ this.initialize();
586
+ }
587
+
588
+ /** Type guard: is the argument an ICodexTransport (vs a Subprocess)? */
589
+ private isTransport(obj: ICodexTransport | Subprocess): obj is ICodexTransport {
590
+ return typeof (obj as ICodexTransport).call === "function"
591
+ && typeof (obj as ICodexTransport).notify === "function"
592
+ && typeof (obj as ICodexTransport).respond === "function"
593
+ && typeof (obj as ICodexTransport).onNotification === "function";
594
+ }
595
+
596
+ /**
597
+ * Notify the adapter that the underlying transport has closed.
598
+ * Used by WebSocket transport mode — the launcher wires the WS close
599
+ * event to this method so the adapter can clean up and fire disconnectCb.
600
+ */
601
+ handleTransportClose(): void {
602
+ this.cleanupAndDisconnect();
603
+ }
604
+
605
+ /**
606
+ * Handle a WebSocket proxy reconnection event. The proxy reconnected
607
+ * to the Codex app-server after a transient drop (e.g. outbound queue
608
+ * overflow on the Codex side). Pending RPC calls were already rejected
609
+ * by the StdioTransport, but we need to:
610
+ * 1. Cancel any pending dynamic tool call timers
611
+ * 2. Cancel pending permissions (they're stale after reconnect)
612
+ * 3. Re-initialize the thread so we can accept new messages
613
+ */
614
+ private handleWsReconnected(): void {
615
+ console.log(`[codex-adapter] Session ${this.sessionId}: WS proxy reconnected to Codex`);
616
+
617
+ // Clean up pending dynamic tool calls (timers would fire stale errors)
618
+ for (const pending of this.pendingDynamicToolCalls.values()) {
619
+ clearTimeout(pending.timeout);
620
+ }
621
+ this.pendingDynamicToolCalls.clear();
622
+ this.pendingExitPlanModeRequests.clear();
623
+ // Emit permission_cancelled for each stale approval so the browser
624
+ // can dismiss its permission dialog (the bridge also clears its own
625
+ // pendingPermissions map when it sees these messages).
626
+ for (const [requestId] of this.pendingApprovals) {
627
+ this.emit({ type: "permission_cancelled", request_id: requestId });
628
+ }
629
+ this.pendingApprovals.clear();
630
+ this.pendingUserInputQuestionIds.clear();
631
+ this.pendingReviewDecisions.clear();
632
+
633
+ // If an agentMessage was actively streaming, emit a synthetic
634
+ // content_block_stop so the browser doesn't show an orphaned streaming
635
+ // block that never completes.
636
+ if (this.streamingItemId) {
637
+ this.emit({
638
+ type: "stream_event",
639
+ event: { type: "content_block_stop", index: 0 },
640
+ parent_tool_use_id: null,
641
+ });
642
+ this.emit({
643
+ type: "stream_event",
644
+ event: {
645
+ type: "message_delta",
646
+ delta: { stop_reason: "interrupted" },
647
+ usage: { output_tokens: 0 },
648
+ },
649
+ parent_tool_use_id: null,
650
+ });
651
+ }
652
+ this.streamingText = "";
653
+ this.streamingItemId = null;
654
+
655
+ // Clear stale per-item tracking state — after a reconnect, Codex starts
656
+ // fresh and won't reference old item/turn IDs. Keeping them wastes memory
657
+ // and risks stale lookups.
658
+ this.emittedToolUseIds.clear();
659
+ this.commandStartTimes.clear();
660
+ this.reasoningTextByItemId.clear();
661
+ this.parentToolUseByThreadId.clear();
662
+ this.planDeltaByTurnId.clear();
663
+ this.planUpdateCountByTurnId.clear();
664
+
665
+ // Clear the current turn — it's gone after reconnect
666
+ this.currentTurnId = null;
667
+ // Reset so the next turn/start re-sends collaborationMode (the server
668
+ // sees a fresh connection and won't have the previously-set mode).
669
+ this.lastSentCollaborationModeKind = null;
670
+ // NOTE: Do NOT reset reconnectRetryCount here. The rejection microtask
671
+ // from StdioTransport.dispatch() hasn't fired yet — resetting the counter
672
+ // would defeat the MAX_RECONNECT_RETRIES guard. The counter is reset on
673
+ // successful initialize() and turn/start instead.
674
+ //
675
+ // IMPORTANT: Do NOT clear pendingOutgoing here. The rejection microtask
676
+ // from the turn/start call hasn't fired yet. When it fires, the catch
677
+ // handler in handleOutgoingUserMessage will re-queue the user message.
678
+ // Clearing pendingOutgoing here would race with that microtask and lose
679
+ // the user's message. The queue is naturally drained by flushPendingOutgoing()
680
+ // after re-initialization completes.
681
+ // Rescue any message pending in the overload retry timer before cancelling.
682
+ if (this.overloadRetryTimer) {
683
+ if (this.overloadRetryMsg) {
684
+ this.pendingOutgoing.push(this.overloadRetryMsg);
685
+ this.overloadRetryMsg = null;
686
+ }
687
+ clearTimeout(this.overloadRetryTimer);
688
+ this.overloadRetryTimer = null;
689
+ }
690
+ this.overloadRetryCount = 0;
691
+
692
+ // After a WS reconnect, Codex requires a fresh initialize/initialized
693
+ // handshake before accepting turn/start, even if this adapter was already
694
+ // initialized before the drop.
695
+ // Bump the epoch so any in-flight initialize() from the previous cycle
696
+ // detects it has been superseded and bails out instead of racing.
697
+ this.initEpoch++;
698
+ this.initInProgress = false;
699
+ this.initialized = false;
700
+ this.initFailed = false;
701
+ if (!this.options.threadId && this.threadId) {
702
+ this.options.threadId = this.threadId;
703
+ }
704
+ this.initialize();
705
+ }
706
+
707
+ /**
708
+ * Clear pending timers, mark disconnected, and fire the disconnect callback.
709
+ * Shared by handleTransportClose, RPC timeout paths, and process exit handlers.
710
+ */
711
+ private cleanupAndDisconnect(): void {
712
+ this.connected = false;
713
+ this.overloadRetryMsg = null; // No rescue needed — session is being torn down
714
+ if (this.overloadRetryTimer) { clearTimeout(this.overloadRetryTimer); this.overloadRetryTimer = null; }
715
+ for (const pending of this.pendingDynamicToolCalls.values()) {
716
+ clearTimeout(pending.timeout);
717
+ }
718
+ this.pendingDynamicToolCalls.clear();
719
+ this.pendingExitPlanModeRequests.clear();
720
+ if (!this.disconnectFired) {
721
+ this.disconnectFired = true;
722
+ this.disconnectCb?.();
723
+ }
724
+ }
725
+
726
+ /**
727
+ * Reset the adapter so it can re-initialize after a transport reconnection.
728
+ * Called by the launcher when a new proxy/transport is established for the
729
+ * same session (e.g. after relaunch). The threadId is preserved so the
730
+ * adapter can resume the existing Codex thread.
731
+ */
732
+ resetForReconnect(newTransport: ICodexTransport): void {
733
+ this.transport = newTransport;
734
+ this.connected = false;
735
+ this.initialized = false;
736
+ this.initFailed = false;
737
+ // Bump epoch to invalidate any stale in-flight initialize() from the old transport.
738
+ this.initEpoch++;
739
+ this.initInProgress = false;
740
+ this.disconnectFired = false;
741
+
742
+ // Clean up stale approval and per-item state from the old transport.
743
+ // The new Codex process won't know about old request IDs.
744
+ for (const pending of this.pendingDynamicToolCalls.values()) {
745
+ clearTimeout(pending.timeout);
746
+ }
747
+ this.pendingDynamicToolCalls.clear();
748
+ this.pendingExitPlanModeRequests.clear();
749
+ for (const [requestId] of this.pendingApprovals) {
750
+ this.emit({ type: "permission_cancelled", request_id: requestId });
751
+ }
752
+ this.pendingApprovals.clear();
753
+ this.pendingUserInputQuestionIds.clear();
754
+ this.pendingReviewDecisions.clear();
755
+ this.emittedToolUseIds.clear();
756
+ this.commandStartTimes.clear();
757
+ this.reasoningTextByItemId.clear();
758
+ this.parentToolUseByThreadId.clear();
759
+ this.planDeltaByTurnId.clear();
760
+ this.planUpdateCountByTurnId.clear();
761
+ this.streamingText = "";
762
+ this.streamingItemId = null;
763
+ this.overloadRetryMsg = null; // Full relaunch — no rescue needed
764
+ if (this.overloadRetryTimer) { clearTimeout(this.overloadRetryTimer); this.overloadRetryTimer = null; }
765
+ this.overloadRetryCount = 0;
766
+ // Reset reconnect retry budget — this is a full relaunch with a new
767
+ // transport, not a transient WS proxy reconnect, so the budget should
768
+ // start fresh.
769
+ this.reconnectRetryCount = 0;
770
+ this.pendingOutgoing.length = 0;
771
+
772
+ // Re-wire handlers on the new transport
773
+ this.transport.onNotification((method, params) => this.handleNotification(method, params));
774
+ this.transport.onRequest((method, id, params) => this.handleRequest(method, id, params));
775
+
776
+ // Re-wire raw recording if recorder was provided
777
+ if (this.options.recorder) {
778
+ const recorder = this.options.recorder;
779
+ const cwd = this.options.cwd || "";
780
+ this.transport.onRawIncoming((line) => {
781
+ recorder.record(this.sessionId, "in", line, "cli", "codex", cwd);
782
+ });
783
+ this.transport.onRawOutgoing((data) => {
784
+ recorder.record(this.sessionId, "out", data.trimEnd(), "cli", "codex", cwd);
785
+ });
786
+ }
787
+
788
+ // Re-wire parse error surfacing
789
+ this.transport.onParseError((message) => {
790
+ this.browserMessageCb?.({ type: "error", message });
791
+ });
792
+
793
+ // Re-run initialization (which will resume the thread if threadId is set)
794
+ this.initialize();
795
+ }
796
+
797
+ // ── Public API ──────────────────────────────────────────────────────────
798
+
799
+ getRateLimits() {
800
+ return this._rateLimits;
801
+ }
802
+
803
+ /** IBackendAdapter.send() — unified entry point for browser-originated messages. */
804
+ send(msg: BrowserOutgoingMessage): boolean {
805
+ return this.sendBrowserMessage(msg);
806
+ }
807
+
808
+ /** @deprecated Use send() instead. Kept for backward compatibility during migration. */
809
+ sendBrowserMessage(msg: BrowserOutgoingMessage): boolean {
810
+ // If initialization failed, reject all new messages
811
+ if (this.initFailed) {
812
+ return false;
813
+ }
814
+
815
+ // Queue messages if not yet initialized (init is async)
816
+ if (!this.initialized || !this.threadId || this.initInProgress) {
817
+ if (
818
+ msg.type === "user_message"
819
+ || msg.type === "permission_response"
820
+ || msg.type === "mcp_get_status"
821
+ || msg.type === "mcp_toggle"
822
+ || msg.type === "mcp_reconnect"
823
+ || msg.type === "mcp_set_servers"
824
+ ) {
825
+ console.log(`[codex-adapter] Queuing ${msg.type} — adapter not yet initialized`);
826
+ this.pendingOutgoing.push(msg);
827
+ return true; // accepted, will be sent after init
828
+ }
829
+ // Non-queueable messages are dropped if not connected
830
+ if (!this.connected) return false;
831
+ }
832
+
833
+ // Guard against dispatching when transport is down (e.g. after proxy WS drop).
834
+ // Also trigger cleanup so the bridge sees the adapter as disconnected and
835
+ // stops trying to flush messages in a loop (proc.exited may not have fired yet).
836
+ if (!this.transport.isConnected()) {
837
+ console.warn(`[codex-adapter] Transport disconnected — cannot dispatch ${msg.type}`);
838
+ this.cleanupAndDisconnect();
839
+ return false;
840
+ }
841
+
842
+ // Drain any messages that were queued during init but not yet flushed
843
+ // (e.g. if the post-init flush found the transport temporarily unavailable
844
+ // but it recovered by the time the next message arrives).
845
+ this.flushPendingOutgoing();
846
+
847
+ return this.dispatchOutgoing(msg);
848
+ }
849
+
850
+ /**
851
+ * Drain any messages still sitting in the pendingOutgoing queue.
852
+ * Called both at the end of initialize() and as a safety net in
853
+ * sendBrowserMessage() — the latter covers edge cases where the
854
+ * post-init flush was skipped (e.g. transport was momentarily
855
+ * unavailable right after init completed in a Docker container).
856
+ */
857
+ private flushPendingOutgoing(): void {
858
+ if (this.pendingOutgoing.length === 0) return;
859
+ if (!this.initialized || !this.threadId || this.initInProgress) {
860
+ console.log(
861
+ `[codex-adapter] Session ${this.sessionId}: init not ready — keeping ${this.pendingOutgoing.length} message(s) queued`,
862
+ );
863
+ return;
864
+ }
865
+ if (!this.transport.isConnected()) {
866
+ console.warn(
867
+ `[codex-adapter] Session ${this.sessionId}: transport disconnected — keeping ${this.pendingOutgoing.length} message(s) queued`,
868
+ );
869
+ return;
870
+ }
871
+ console.log(
872
+ `[codex-adapter] Session ${this.sessionId}: flushing ${this.pendingOutgoing.length} queued message(s)`,
873
+ );
874
+ const queued = this.pendingOutgoing.splice(0);
875
+ for (const msg of queued) {
876
+ this.dispatchOutgoing(msg);
877
+ }
878
+ }
879
+
880
+ private dispatchOutgoing(msg: BrowserOutgoingMessage): boolean {
881
+ switch (msg.type) {
882
+ case "user_message":
883
+ this.handleOutgoingUserMessage(msg);
884
+ return true;
885
+ case "permission_response":
886
+ this.handleOutgoingPermissionResponse(msg);
887
+ return true;
888
+ case "interrupt":
889
+ this.handleOutgoingInterrupt();
890
+ return true;
891
+ case "set_model":
892
+ console.warn("[codex-adapter] Runtime model switching not supported by Codex");
893
+ return false;
894
+ case "set_permission_mode":
895
+ this.handleOutgoingSetPermissionMode(msg.mode);
896
+ return true;
897
+ case "mcp_get_status":
898
+ this.handleOutgoingMcpGetStatus();
899
+ return true;
900
+ case "mcp_toggle":
901
+ this.handleOutgoingMcpToggle(msg.serverName, msg.enabled);
902
+ return true;
903
+ case "mcp_reconnect":
904
+ this.handleOutgoingMcpReconnect();
905
+ return true;
906
+ case "mcp_set_servers":
907
+ this.handleOutgoingMcpSetServers(msg.servers);
908
+ return true;
909
+ default:
910
+ return false;
911
+ }
912
+ }
913
+
914
+ onBrowserMessage(cb: (msg: BrowserIncomingMessage) => void): void {
915
+ this.browserMessageCb = cb;
916
+ }
917
+
918
+ onSessionMeta(cb: (meta: { cliSessionId?: string; model?: string; cwd?: string }) => void): void {
919
+ this.sessionMetaCb = cb;
920
+ }
921
+
922
+ onDisconnect(cb: () => void): void {
923
+ this.disconnectCb = cb;
924
+ }
925
+
926
+ onInitError(cb: (error: string) => void): void {
927
+ this.initErrorCb = cb;
928
+ }
929
+
930
+ isConnected(): boolean {
931
+ return this.connected;
932
+ }
933
+
934
+ async disconnect(): Promise<void> {
935
+ this.connected = false;
936
+ if (this.options.killProcess) {
937
+ try {
938
+ await this.options.killProcess();
939
+ } catch {}
940
+ }
941
+ }
942
+
943
+ getThreadId(): string | null {
944
+ return this.threadId;
945
+ }
946
+
947
+ // ── Initialization ──────────────────────────────────────────────────────
948
+
949
+ /** Max retries for thread/start or thread/resume during initialization. */
950
+ private static readonly INIT_THREAD_MAX_RETRIES = 3;
951
+ private static readonly INIT_THREAD_RETRY_BASE_MS = 500;
952
+
953
+ private async initialize(): Promise<void> {
954
+ if (this.initInProgress) {
955
+ console.warn("[codex-adapter] initialize() called while already in progress — skipping");
956
+ return;
957
+ }
958
+ this.initInProgress = true;
959
+ // Snapshot the epoch at call time. If a WS reconnect or resetForReconnect
960
+ // bumps the epoch while we're awaiting async operations, this initialize()
961
+ // is stale and should abort to avoid racing with the newer call.
962
+ const myEpoch = this.initEpoch;
963
+
964
+ try {
965
+ // Step 1: Send initialize request
966
+ await this.transport.call("initialize", {
967
+ clientInfo: {
968
+ name: "maxxagent",
969
+ title: "HeyHank",
970
+ version: "1.0.0",
971
+ },
972
+ capabilities: {
973
+ experimentalApi: true,
974
+ },
975
+ }) as Record<string, unknown>;
976
+
977
+ // Bail if a newer init cycle superseded us while we were awaiting
978
+ if (myEpoch !== this.initEpoch) {
979
+ console.warn(`[codex-adapter] Session ${this.sessionId}: init epoch ${myEpoch} superseded by ${this.initEpoch}, aborting stale init`);
980
+ this.initInProgress = false;
981
+ return;
982
+ }
983
+
984
+ // Step 2: Send initialized notification
985
+ await this.transport.notify("initialized", {});
986
+
987
+ this.connected = true;
988
+
989
+ // Step 3: Start or resume a thread — retry with backoff on transient
990
+ // transport errors (e.g. proxy WS drops during handshake).
991
+ // Note: thread/start and thread/resume use `sandbox` (SandboxMode string),
992
+ // while turn/start uses `sandboxPolicy` (SandboxPolicy object) — these are
993
+ // different Codex API fields by design.
994
+ let threadStarted = false;
995
+ let lastThreadError: unknown;
996
+
997
+ for (let attempt = 0; attempt < CodexAdapter.INIT_THREAD_MAX_RETRIES; attempt++) {
998
+ // Bail out early if superseded by a newer init cycle
999
+ if (myEpoch !== this.initEpoch) {
1000
+ console.warn(`[codex-adapter] Session ${this.sessionId}: init epoch ${myEpoch} superseded during thread start, aborting`);
1001
+ this.initInProgress = false;
1002
+ return;
1003
+ }
1004
+ // Bail out early if the transport went away between retries
1005
+ if (!this.transport.isConnected()) {
1006
+ lastThreadError = new Error("Transport closed before thread start");
1007
+ break;
1008
+ }
1009
+
1010
+ try {
1011
+ if (this.options.threadId) {
1012
+ try {
1013
+ const resumeResult = await this.transport.call("thread/resume", {
1014
+ threadId: this.options.threadId,
1015
+ model: this.options.model,
1016
+ cwd: this.getExecutionCwd(),
1017
+ approvalPolicy: this.mapApprovalPolicy(this.currentPermissionMode),
1018
+ sandbox: this.options.sandbox || this.mapSandboxPolicy(this.currentPermissionMode),
1019
+ }) as { thread: { id: string } };
1020
+ this.threadId = resumeResult.thread.id;
1021
+ } catch (resumeErr) {
1022
+ // If resume fails with a non-transient error (e.g. "no rollout found"),
1023
+ // fall back to starting a fresh thread instead of failing entirely.
1024
+ const isTransport = resumeErr instanceof Error && resumeErr.message === "Transport closed";
1025
+ if (isTransport) throw resumeErr; // Let outer retry handle transient errors
1026
+ const resumeErrMsg = resumeErr instanceof Error ? resumeErr.message : String(resumeErr);
1027
+ console.warn(
1028
+ `[codex-adapter] thread/resume failed for ${this.sessionId} (threadId=${this.options.threadId}), falling back to thread/start: ${resumeErrMsg}`,
1029
+ );
1030
+ const freshResult = await this.transport.call("thread/start", {
1031
+ model: this.options.model,
1032
+ cwd: this.getExecutionCwd(),
1033
+ approvalPolicy: this.mapApprovalPolicy(this.currentPermissionMode),
1034
+ sandbox: this.options.sandbox || this.mapSandboxPolicy(this.currentPermissionMode),
1035
+ ...(this.options.systemPrompt ? { instructions: this.options.systemPrompt } : {}),
1036
+ }) as { thread: { id: string } };
1037
+ this.threadId = freshResult.thread.id;
1038
+ // Update options.threadId so subsequent resetForReconnect calls
1039
+ // attempt to resume this new thread, not the original stale one.
1040
+ this.options.threadId = freshResult.thread.id;
1041
+ // Notify the browser that context was lost — the conversation
1042
+ // history is still visible in the UI but Codex has no memory of it.
1043
+ this.emit({
1044
+ type: "error",
1045
+ message: `Session context could not be restored (${resumeErrMsg}). Started a fresh thread — Codex won't remember prior messages.`,
1046
+ });
1047
+ }
1048
+ } else {
1049
+ const threadResult = await this.transport.call("thread/start", {
1050
+ model: this.options.model,
1051
+ cwd: this.getExecutionCwd(),
1052
+ approvalPolicy: this.mapApprovalPolicy(this.currentPermissionMode),
1053
+ sandbox: this.options.sandbox || this.mapSandboxPolicy(this.currentPermissionMode),
1054
+ ...(this.options.systemPrompt ? { instructions: this.options.systemPrompt } : {}),
1055
+ }) as { thread: { id: string } };
1056
+ this.threadId = threadResult.thread.id;
1057
+ }
1058
+ threadStarted = true;
1059
+ break;
1060
+ } catch (threadErr) {
1061
+ lastThreadError = threadErr;
1062
+ const isTransportClosed = threadErr instanceof Error && threadErr.message === "Transport closed";
1063
+ if (!isTransportClosed || attempt >= CodexAdapter.INIT_THREAD_MAX_RETRIES - 1) {
1064
+ break; // Non-transient error or last attempt — give up
1065
+ }
1066
+ const delay = CodexAdapter.INIT_THREAD_RETRY_BASE_MS * Math.pow(2, attempt);
1067
+ console.warn(`[codex-adapter] thread start attempt ${attempt + 1} failed (Transport closed), retrying in ${delay}ms`);
1068
+ await new Promise((r) => setTimeout(r, delay));
1069
+ }
1070
+ }
1071
+
1072
+ if (!threadStarted) {
1073
+ throw lastThreadError || new Error("Failed to start thread");
1074
+ }
1075
+
1076
+ this.initialized = true;
1077
+ // Reset reconnect retry budget after successful initialization.
1078
+ // This covers the case where WS drops during init but the re-init
1079
+ // succeeds — without this, the counter would accumulate across
1080
+ // reconnect cycles and eventually trigger cleanupAndDisconnect().
1081
+ this.reconnectRetryCount = 0;
1082
+ console.log(`[codex-adapter] Session ${this.sessionId} initialized (threadId=${this.threadId})`);
1083
+
1084
+ // Notify session metadata
1085
+ this.sessionMetaCb?.({
1086
+ cliSessionId: this.threadId ?? undefined,
1087
+ model: this.options.model,
1088
+ cwd: this.options.cwd,
1089
+ });
1090
+
1091
+ // Send session_init to browser
1092
+ const state: SessionState = {
1093
+ session_id: this.sessionId,
1094
+ backend_type: "codex",
1095
+ model: this.options.model || "",
1096
+ cwd: this.options.cwd || "",
1097
+ tools: [],
1098
+ permissionMode: this.currentPermissionMode,
1099
+ claude_code_version: "",
1100
+ mcp_servers: [],
1101
+ agents: [],
1102
+ slash_commands: [],
1103
+ skills: [],
1104
+ total_cost_usd: 0,
1105
+ num_turns: 0,
1106
+ context_used_percent: 0,
1107
+ is_compacting: false,
1108
+ git_branch: "",
1109
+ is_worktree: false,
1110
+ is_containerized: false,
1111
+ repo_root: "",
1112
+ git_ahead: 0,
1113
+ git_behind: 0,
1114
+ total_lines_added: 0,
1115
+ total_lines_removed: 0,
1116
+ };
1117
+
1118
+ this.emit({ type: "session_init", session: state });
1119
+
1120
+ // Fetch initial rate limits (non-blocking — don't fail init if this errors)
1121
+ this.transport.call("account/rateLimits/read", {}).then((result) => {
1122
+ this.updateRateLimits(result as Record<string, unknown>);
1123
+ }).catch(() => { /* best-effort */ });
1124
+
1125
+ // Flush any messages that were queued during initialization, but only
1126
+ // if the transport is still connected (avoids immediate "Transport closed").
1127
+ this.initInProgress = false;
1128
+ this.flushPendingOutgoing();
1129
+ } catch (err) {
1130
+ // If a WS reconnection was detected mid-init, handleWsReconnected
1131
+ // already kicked off a fresh initialize(). Don't reset initInProgress
1132
+ // here — that would clobber the new initialize() call's flag.
1133
+ if (err instanceof Error && err.message === "Transport reconnected") {
1134
+ console.warn(`[codex-adapter] Session ${this.sessionId}: init interrupted by WS reconnection, re-init in progress`);
1135
+ return;
1136
+ }
1137
+ this.initInProgress = false;
1138
+ const errorMsg = `Codex initialization failed: ${err}`;
1139
+ console.error(`[codex-adapter] ${errorMsg}`);
1140
+ this.initFailed = true;
1141
+ this.connected = false;
1142
+ // Discard any messages queued during the failed init attempt
1143
+ if (this.overloadRetryTimer) { clearTimeout(this.overloadRetryTimer); this.overloadRetryTimer = null; }
1144
+ this.pendingOutgoing.length = 0;
1145
+ this.emit({ type: "error", message: errorMsg });
1146
+ this.initErrorCb?.(errorMsg);
1147
+ }
1148
+ }
1149
+
1150
+ // ── Outgoing message handlers ───────────────────────────────────────────
1151
+
1152
+ private async handleOutgoingUserMessage(
1153
+ msg: { type: "user_message"; content: string; images?: { media_type: string; data: string }[] },
1154
+ ): Promise<void> {
1155
+ if (!this.threadId) {
1156
+ this.emit({ type: "error", message: "No Codex thread started yet" });
1157
+ return;
1158
+ }
1159
+
1160
+ const input: Array<{ type: string; text?: string; url?: string }> = [];
1161
+
1162
+ // Add images if present
1163
+ if (msg.images?.length) {
1164
+ for (const img of msg.images) {
1165
+ input.push({
1166
+ type: "image",
1167
+ url: `data:${img.media_type};base64,${img.data}`,
1168
+ });
1169
+ }
1170
+ }
1171
+
1172
+ // Add text
1173
+ input.push({ type: "text", text: msg.content });
1174
+
1175
+ try {
1176
+ // Only send collaborationMode on mode transitions — sending it every turn
1177
+ // in "default" mode overrides approvalPolicy and re-enables permission prompts.
1178
+ // The server persists collaborationMode across turns, so we only need to send
1179
+ // it when switching (e.g. auto→plan or plan→auto).
1180
+ // approvalPolicy and sandboxPolicy are static ("never" / dangerFullAccess) so
1181
+ // resending them each turn is idempotent and ensures consistency if the server
1182
+ // resets state. collaborationMode is only sent on transitions (see below).
1183
+ const turnParams: Record<string, unknown> = {
1184
+ threadId: this.threadId,
1185
+ input,
1186
+ cwd: this.getExecutionCwd(),
1187
+ approvalPolicy: this.mapApprovalPolicy(this.currentPermissionMode),
1188
+ sandboxPolicy: this.mapSandboxPolicyObject(this.currentPermissionMode),
1189
+ };
1190
+ if (this.currentCollaborationModeKind !== this.lastSentCollaborationModeKind) {
1191
+ turnParams.collaborationMode = this.mapCollaborationMode(this.currentCollaborationModeKind);
1192
+ this.lastSentCollaborationModeKind = this.currentCollaborationModeKind;
1193
+ }
1194
+ const result = await this.transport.call("turn/start", turnParams) as { turn: { id: string } };
1195
+
1196
+ this.currentTurnId = result.turn.id;
1197
+ this.reconnectRetryCount = 0; // Reset on success
1198
+ this.overloadRetryCount = 0; // Reset overload budget on success
1199
+ } catch (err) {
1200
+ const errMsg = err instanceof Error ? err.message : String(err);
1201
+ if (errMsg === "Transport reconnected") {
1202
+ // The WS proxy reconnected mid-call — this is transient.
1203
+ // Retry up to MAX_RECONNECT_RETRIES times before giving up to
1204
+ // avoid an unbounded loop when the WS keeps dropping.
1205
+ this.reconnectRetryCount++;
1206
+ if (this.reconnectRetryCount > CodexAdapter.MAX_RECONNECT_RETRIES) {
1207
+ this.reconnectRetryCount = 0;
1208
+ this.emit({ type: "error", message: "Connection lost after multiple reconnects. Relaunching session..." });
1209
+ this.cleanupAndDisconnect();
1210
+ } else {
1211
+ this.emit({ type: "error", message: "Connection briefly interrupted. Retrying your message..." });
1212
+ // Prepend (not push) so the original message preserves ordering if
1213
+ // a new browser message arrived in the meantime. Guard against
1214
+ // duplicate re-queuing: if a message with the same client_msg_id is
1215
+ // already in the queue (from a prior reconnect cycle), skip the
1216
+ // unshift to avoid sending the same message to Codex multiple times.
1217
+ // Uses client_msg_id (stable unique ID per send) instead of content
1218
+ // comparison to avoid silently dropping legitimate repeat messages.
1219
+ const clientId = "client_msg_id" in msg ? msg.client_msg_id : undefined;
1220
+ const alreadyQueued = clientId != null
1221
+ && this.pendingOutgoing.some((m) => "client_msg_id" in m && m.client_msg_id === clientId);
1222
+ if (!alreadyQueued) {
1223
+ this.pendingOutgoing.unshift(msg);
1224
+ }
1225
+ this.flushPendingOutgoing();
1226
+ }
1227
+ } else if ((err as Record<string, unknown>)?.code === -32001) {
1228
+ // Codex server overloaded (channel capacity 128 exceeded) — transient,
1229
+ // retry after a short delay rather than relaunching the whole session.
1230
+ this.overloadRetryCount++;
1231
+ if (this.overloadRetryCount > CodexAdapter.MAX_RECONNECT_RETRIES) {
1232
+ this.overloadRetryCount = 0;
1233
+ this.emit({ type: "error", message: "Codex server overloaded after multiple retries. Relaunching session..." });
1234
+ this.cleanupAndDisconnect();
1235
+ } else {
1236
+ this.emit({ type: "error", message: "Codex server busy. Retrying your message..." });
1237
+ // Cancel any previous overload retry timer — we only need one active
1238
+ // retry at a time. Without this, consecutive -32001 errors would
1239
+ // schedule multiple timers and the counter-snapshot guard would
1240
+ // silently drop the earlier messages (Cubic review).
1241
+ if (this.overloadRetryTimer) clearTimeout(this.overloadRetryTimer);
1242
+ // Track the pending message so handleWsReconnected can rescue it
1243
+ // to pendingOutgoing if the timer is cancelled by a reconnect.
1244
+ this.overloadRetryMsg = msg;
1245
+ this.overloadRetryTimer = setTimeout(() => {
1246
+ this.overloadRetryTimer = null;
1247
+ this.overloadRetryMsg = null;
1248
+ // If a WS reconnect cleared everything, bail out.
1249
+ if (!this.initialized) return;
1250
+ this.pendingOutgoing.unshift(msg);
1251
+ this.flushPendingOutgoing();
1252
+ }, 1000 * this.overloadRetryCount); // Linear backoff: 1s, 2s, 3s...
1253
+ }
1254
+ } else if (errMsg.startsWith("RPC timeout")) {
1255
+ this.emit({ type: "error", message: "Codex is not responding. Relaunching session..." });
1256
+ this.cleanupAndDisconnect();
1257
+ } else if (errMsg === "Transport closed") {
1258
+ this.emit({ type: "error", message: "Connection to Codex lost. Relaunching session..." });
1259
+ this.cleanupAndDisconnect();
1260
+ } else {
1261
+ this.emit({ type: "error", message: `Failed to start turn: ${err}` });
1262
+ }
1263
+ }
1264
+ }
1265
+
1266
+ private async handleOutgoingPermissionResponse(
1267
+ msg: { type: "permission_response"; request_id: string; behavior: "allow" | "deny"; updated_input?: Record<string, unknown> },
1268
+ ): Promise<void> {
1269
+ const jsonRpcId = this.pendingApprovals.get(msg.request_id);
1270
+ if (jsonRpcId === undefined) {
1271
+ console.warn(`[codex-adapter] No pending approval for request_id=${msg.request_id}`);
1272
+ return;
1273
+ }
1274
+
1275
+ // Wrap all transport.respond() calls in try/catch — the transport may have
1276
+ // closed between when the user clicked allow/deny and when we send the
1277
+ // response. Without this, "Transport closed" rejects as unhandled promises
1278
+ // and can leave the session in an inconsistent state.
1279
+ try {
1280
+ // Dynamic tool calls (item/tool/call) require a DynamicToolCallResponse payload.
1281
+ const pendingDynamic = this.pendingDynamicToolCalls.get(msg.request_id);
1282
+ if (pendingDynamic) {
1283
+ this.pendingDynamicToolCalls.delete(msg.request_id);
1284
+ this.pendingApprovals.delete(msg.request_id);
1285
+ clearTimeout(pendingDynamic.timeout);
1286
+
1287
+ const result = this.buildDynamicToolCallResponse(msg, pendingDynamic.toolName);
1288
+ await this.transport.respond(jsonRpcId, result);
1289
+ return;
1290
+ }
1291
+
1292
+ // ExitPlanMode requests need DynamicToolCallResponse + collaboration mode update
1293
+ if (this.pendingExitPlanModeRequests.has(msg.request_id)) {
1294
+ this.pendingExitPlanModeRequests.delete(msg.request_id);
1295
+ this.pendingApprovals.delete(msg.request_id);
1296
+
1297
+ if (msg.behavior === "allow") {
1298
+ // Send the response first — only mutate local state if the transport
1299
+ // accepted it. Otherwise the browser would think plan mode is off
1300
+ // while Codex never received the approval (see Greptile review).
1301
+ await this.transport.respond(jsonRpcId, {
1302
+ contentItems: [{ type: "inputText", text: "Plan approved. Exiting plan mode." }],
1303
+ success: true,
1304
+ });
1305
+
1306
+ // Exit plan mode: switch collaboration mode back to default
1307
+ this.currentCollaborationModeKind = "default";
1308
+ this.currentPermissionMode = this.lastNonPlanPermissionMode;
1309
+ this.emit({
1310
+ type: "session_update",
1311
+ session: { permissionMode: this.currentPermissionMode },
1312
+ });
1313
+ } else {
1314
+ await this.transport.respond(jsonRpcId, {
1315
+ contentItems: [{ type: "inputText", text: "Plan denied by user." }],
1316
+ success: false,
1317
+ });
1318
+ }
1319
+ return;
1320
+ }
1321
+
1322
+ this.pendingApprovals.delete(msg.request_id);
1323
+
1324
+ // User input requests (item/tool/requestUserInput) need ToolRequestUserInputResponse
1325
+ const questionIds = this.pendingUserInputQuestionIds.get(msg.request_id);
1326
+ if (questionIds) {
1327
+ this.pendingUserInputQuestionIds.delete(msg.request_id);
1328
+
1329
+ if (msg.behavior === "deny") {
1330
+ // Respond with empty answers on deny
1331
+ await this.transport.respond(jsonRpcId, { answers: {} });
1332
+ return;
1333
+ }
1334
+
1335
+ // Convert browser answers (keyed by index "0","1",...) to Codex format (keyed by question ID)
1336
+ const browserAnswers = msg.updated_input?.answers as Record<string, string> || {};
1337
+ const codexAnswers: Record<string, { answers: string[] }> = {};
1338
+ for (let i = 0; i < questionIds.length; i++) {
1339
+ const answer = browserAnswers[String(i)];
1340
+ if (answer !== undefined) {
1341
+ codexAnswers[questionIds[i]] = { answers: [answer] };
1342
+ }
1343
+ }
1344
+
1345
+ await this.transport.respond(jsonRpcId, { answers: codexAnswers });
1346
+ return;
1347
+ }
1348
+
1349
+ // Review decisions (applyPatchApproval / execCommandApproval) need ReviewDecision
1350
+ if (this.pendingReviewDecisions.has(msg.request_id)) {
1351
+ this.pendingReviewDecisions.delete(msg.request_id);
1352
+ const decision = msg.behavior === "allow" ? "approved" : "denied";
1353
+ await this.transport.respond(jsonRpcId, { decision });
1354
+ return;
1355
+ }
1356
+
1357
+ // Standard item/*/requestApproval — uses accept/decline
1358
+ const decision = msg.behavior === "allow" ? "accept" : "decline";
1359
+ await this.transport.respond(jsonRpcId, { decision });
1360
+ } catch (err) {
1361
+ const errMsg = err instanceof Error ? err.message : String(err);
1362
+ if (errMsg === "Transport closed" || errMsg === "Transport reconnected") {
1363
+ console.warn(
1364
+ `[codex-adapter] Session ${this.sessionId}: permission response for ${msg.request_id} dropped (${errMsg})`,
1365
+ );
1366
+ // Transport is gone — the permission is moot. If the transport
1367
+ // reconnected, handleWsReconnected() already cancelled pending
1368
+ // approvals. If it closed, cleanupAndDisconnect() will fire.
1369
+ } else {
1370
+ console.error(`[codex-adapter] Session ${this.sessionId}: unexpected error sending permission response:`, err);
1371
+ }
1372
+ }
1373
+ }
1374
+
1375
+ private async handleOutgoingInterrupt(): Promise<void> {
1376
+ if (!this.threadId || !this.currentTurnId) return;
1377
+
1378
+ try {
1379
+ await this.transport.call("turn/interrupt", {
1380
+ threadId: this.threadId,
1381
+ turnId: this.currentTurnId,
1382
+ });
1383
+ } catch (err) {
1384
+ const errMsg = err instanceof Error ? err.message : String(err);
1385
+ if (errMsg.startsWith("RPC timeout")) {
1386
+ this.emit({ type: "error", message: "Codex is not responding to interrupt. Relaunching session..." });
1387
+ this.cleanupAndDisconnect();
1388
+ } else {
1389
+ console.warn("[codex-adapter] Interrupt failed:", err);
1390
+ }
1391
+ }
1392
+ }
1393
+
1394
+ private handleOutgoingSetPermissionMode(mode: string): void {
1395
+ const nextMode = mode === "default"
1396
+ ? this.lastNonPlanPermissionMode
1397
+ : mode;
1398
+
1399
+ this.currentPermissionMode = nextMode;
1400
+ if (nextMode === "plan") {
1401
+ this.currentCollaborationModeKind = "plan";
1402
+ } else {
1403
+ this.currentCollaborationModeKind = "default";
1404
+ this.lastNonPlanPermissionMode = nextMode;
1405
+ }
1406
+
1407
+ this.emit({
1408
+ type: "session_update",
1409
+ session: { permissionMode: this.currentPermissionMode },
1410
+ });
1411
+ }
1412
+
1413
+ private async handleOutgoingMcpGetStatus(): Promise<void> {
1414
+ try {
1415
+ const statusEntries = await this.listAllMcpServerStatuses();
1416
+ const configMap = await this.readMcpServersConfig();
1417
+
1418
+ const names = new Set<string>([
1419
+ ...statusEntries.map((s) => s.name),
1420
+ ...Object.keys(configMap),
1421
+ ]);
1422
+
1423
+ const statusByName = new Map(statusEntries.map((s) => [s.name, s]));
1424
+ const servers: McpServerDetail[] = Array.from(names).sort().map((name) => {
1425
+ const status = statusByName.get(name);
1426
+ const config = this.toMcpServerConfig(configMap[name]);
1427
+ const isEnabled = this.isMcpServerEnabled(configMap[name]);
1428
+ const serverStatus: McpServerDetail["status"] =
1429
+ !isEnabled
1430
+ ? "disabled"
1431
+ : (status?.authStatus === "notLoggedIn" ? "failed" : "connected");
1432
+
1433
+ return {
1434
+ name,
1435
+ status: serverStatus,
1436
+ error: status?.authStatus === "notLoggedIn" ? "MCP server requires login" : undefined,
1437
+ config,
1438
+ scope: "user",
1439
+ tools: this.mapMcpTools(status?.tools),
1440
+ };
1441
+ });
1442
+
1443
+ this.emit({ type: "mcp_status", servers });
1444
+ } catch (err) {
1445
+ const errMsg = err instanceof Error ? err.message : String(err);
1446
+ if (errMsg === "Transport closed") {
1447
+ // Transient disconnect (e.g. page refresh, WS proxy reconnection).
1448
+ // Trigger cleanup so the bridge sees the adapter as disconnected
1449
+ // immediately and can relaunch — same race fix as sendBrowserMessage.
1450
+ // No re-queue needed: the browser will re-send mcp_get_status when
1451
+ // the new adapter emits cli_connected after relaunch.
1452
+ console.log(`[codex-adapter] Session ${this.sessionId}: mcp_get_status failed (transport closed), triggering cleanup`);
1453
+ this.cleanupAndDisconnect();
1454
+ } else {
1455
+ this.emit({ type: "error", message: `Failed to get MCP status: ${err}` });
1456
+ }
1457
+ }
1458
+ }
1459
+
1460
+ private async handleOutgoingMcpToggle(serverName: string, enabled: boolean): Promise<void> {
1461
+ try {
1462
+ if (serverName.includes(".")) {
1463
+ throw new Error("Server names containing '.' are not supported for toggle");
1464
+ }
1465
+ await this.transport.call("config/value/write", {
1466
+ keyPath: `mcp_servers.${serverName}.enabled`,
1467
+ value: enabled,
1468
+ mergeStrategy: "upsert",
1469
+ });
1470
+ await this.reloadMcpServers();
1471
+ await this.handleOutgoingMcpGetStatus();
1472
+ } catch (err) {
1473
+ // Some existing configs may contain legacy/foreign fields (e.g. `transport`)
1474
+ // that fail on reload when touched. If so, remove this server entry entirely.
1475
+ const msg = String(err);
1476
+ if (msg.includes("invalid transport")) {
1477
+ try {
1478
+ await this.transport.call("config/value/write", {
1479
+ keyPath: `mcp_servers.${serverName}`,
1480
+ value: null,
1481
+ mergeStrategy: "replace",
1482
+ });
1483
+ await this.reloadMcpServers();
1484
+ await this.handleOutgoingMcpGetStatus();
1485
+ return;
1486
+ } catch {
1487
+ // fall through to user-visible error below
1488
+ }
1489
+ }
1490
+ this.emit({ type: "error", message: `Failed to toggle MCP server "${serverName}": ${err}` });
1491
+ }
1492
+ }
1493
+
1494
+ private async handleOutgoingMcpReconnect(): Promise<void> {
1495
+ try {
1496
+ await this.reloadMcpServers();
1497
+ await this.handleOutgoingMcpGetStatus();
1498
+ } catch (err) {
1499
+ this.emit({ type: "error", message: `Failed to reload MCP servers: ${err}` });
1500
+ }
1501
+ }
1502
+
1503
+ private async handleOutgoingMcpSetServers(servers: Record<string, McpServerConfig>): Promise<void> {
1504
+ try {
1505
+ const edits: Array<{ keyPath: string; value: Record<string, unknown>; mergeStrategy: "upsert" }> = [];
1506
+ for (const [name, config] of Object.entries(servers)) {
1507
+ if (name.includes(".")) {
1508
+ throw new Error(`Server names containing '.' are not supported: ${name}`);
1509
+ }
1510
+ edits.push({
1511
+ keyPath: `mcp_servers.${name}`,
1512
+ value: this.fromMcpServerConfig(config),
1513
+ mergeStrategy: "upsert",
1514
+ });
1515
+ }
1516
+ if (edits.length > 0) {
1517
+ await this.transport.call("config/batchWrite", {
1518
+ edits,
1519
+ });
1520
+ }
1521
+ await this.reloadMcpServers();
1522
+ await this.handleOutgoingMcpGetStatus();
1523
+ } catch (err) {
1524
+ this.emit({ type: "error", message: `Failed to configure MCP servers: ${err}` });
1525
+ }
1526
+ }
1527
+
1528
+ // ── Incoming notification handlers ──────────────────────────────────────
1529
+
1530
+ private handleNotification(method: string, params: Record<string, unknown>): void {
1531
+ // Debug: log all significant notifications to understand Codex event flow
1532
+ if (method.startsWith("item/") || method.startsWith("turn/") || method.startsWith("thread/")) {
1533
+ const item = params.item as { type?: string; id?: string } | undefined;
1534
+ console.log(`[codex-adapter] ← ${method}${item ? ` type=${item.type} id=${item.id}` : ""}${!item && Object.keys(params).length > 0 ? ` keys=[${Object.keys(params).join(",")}]` : ""}`);
1535
+ }
1536
+
1537
+ try {
1538
+ switch (method) {
1539
+ case "item/started":
1540
+ this.handleItemStarted(params);
1541
+ break;
1542
+ case "item/agentMessage/delta":
1543
+ this.handleAgentMessageDelta(params);
1544
+ break;
1545
+ case "item/commandExecution/outputDelta":
1546
+ // Streaming command output — emit as tool_progress so the browser
1547
+ // shows a live elapsed-time indicator while the command runs.
1548
+ this.emitCommandProgress(params);
1549
+ break;
1550
+ case "item/commandExecution/terminalInteraction":
1551
+ // Interactive terminal IO event (stdin prompt/tty exchange). Treat it
1552
+ // as command progress so the UI keeps the command block active.
1553
+ this.emitCommandProgress(params);
1554
+ break;
1555
+ case "item/fileChange/outputDelta":
1556
+ // Streaming file change output. Same as above.
1557
+ break;
1558
+ case "item/reasoning/textDelta":
1559
+ case "item/reasoning/delta":
1560
+ case "item/reasoning/summaryTextDelta":
1561
+ case "item/reasoning/summaryPartAdded":
1562
+ this.handleReasoningDelta(params);
1563
+ break;
1564
+ case "item/mcpToolCall/progress": {
1565
+ // MCP tool call progress — map to tool_progress
1566
+ const itemId = params.itemId as string | undefined;
1567
+ if (itemId) {
1568
+ this.emit({
1569
+ type: "tool_progress",
1570
+ tool_use_id: itemId,
1571
+ tool_name: "mcp_tool_call",
1572
+ elapsed_time_seconds: 0,
1573
+ });
1574
+ }
1575
+ break;
1576
+ }
1577
+ case "item/plan/delta":
1578
+ this.handlePlanDelta(params);
1579
+ break;
1580
+ case "item/updated":
1581
+ this.handleItemUpdated(params);
1582
+ break;
1583
+ case "item/completed":
1584
+ this.handleItemCompleted(params);
1585
+ break;
1586
+ case "rawResponseItem/completed":
1587
+ // Raw model response — internal, not needed for UI.
1588
+ break;
1589
+ case "turn/started":
1590
+ this.handleTurnStarted(params);
1591
+ break;
1592
+ case "turn/completed":
1593
+ this.handleTurnCompleted(params);
1594
+ break;
1595
+ case "turn/plan/updated":
1596
+ this.handleTurnPlanUpdated(params);
1597
+ break;
1598
+ case "turn/diff/updated":
1599
+ // Could show diff, but not needed for MVP
1600
+ break;
1601
+ case "thread/started":
1602
+ // Thread started after init — nothing to emit.
1603
+ break;
1604
+ case "thread/status/changed":
1605
+ this.handleThreadStatusChanged(params);
1606
+ break;
1607
+ case "thread/tokenUsage/updated":
1608
+ this.handleTokenUsageUpdated(params);
1609
+ break;
1610
+ case "account/updated":
1611
+ case "account/login/completed":
1612
+ // Auth events
1613
+ break;
1614
+ case "account/rateLimits/updated":
1615
+ this.updateRateLimits(params);
1616
+ break;
1617
+ // Legacy codex/event/* notifications forwarded by newer Codex runtimes.
1618
+ // token_count is still useful for metrics, but the streaming deltas are
1619
+ // often duplicated by canonical item/* deltas in the same session.
1620
+ // Ignore duplicated legacy streams to avoid double-emitting text.
1621
+ case "codex/event/token_count":
1622
+ this.handleLegacyTokenCount(params);
1623
+ break;
1624
+ case "codex/event/agent_message_delta":
1625
+ case "codex/event/agent_message_content_delta":
1626
+ case "codex/event/reasoning_content_delta":
1627
+ case "codex/event/agent_message":
1628
+ case "codex/event/item_started":
1629
+ case "codex/event/item_completed":
1630
+ case "codex/event/exec_command_begin":
1631
+ case "codex/event/exec_command_output_delta":
1632
+ case "codex/event/exec_command_end":
1633
+ case "codex/event/turn_diff":
1634
+ case "codex/event/terminal_interaction":
1635
+ case "codex/event/patch_apply_begin":
1636
+ case "codex/event/patch_apply_end":
1637
+ case "codex/event/user_message":
1638
+ case "codex/event/task_started":
1639
+ case "codex/event/task_complete":
1640
+ case "codex/event/mcp_startup_complete":
1641
+ case "codex/event/context_compacted":
1642
+ case "codex/event/agent_reasoning":
1643
+ case "codex/event/agent_reasoning_delta":
1644
+ case "codex/event/agent_reasoning_section_break":
1645
+ // Duplicates of canonical v2 events — silently ignore.
1646
+ break;
1647
+ case "codex/event/stream_error": {
1648
+ const msg = params.msg as { message?: string } | undefined;
1649
+ if (msg?.message) {
1650
+ console.log(`[codex-adapter] Stream error: ${msg.message}`);
1651
+ }
1652
+ break;
1653
+ }
1654
+ case "codex/event/error": {
1655
+ const msg = params.msg as { message?: string } | undefined;
1656
+ if (msg?.message) {
1657
+ console.error(`[codex-adapter] Codex error: ${msg.message}`);
1658
+ this.emit({ type: "error", message: msg.message });
1659
+ }
1660
+ break;
1661
+ }
1662
+ case "companion/wsReconnected":
1663
+ this.handleWsReconnected();
1664
+ break;
1665
+ default:
1666
+ this.reportProtocolDrift("notification", method, { payload: params });
1667
+ break;
1668
+ }
1669
+ } catch (err) {
1670
+ log.error("codex-adapter", `Error handling notification ${method}`, {
1671
+ sessionId: this.sessionId,
1672
+ method,
1673
+ error: err instanceof Error ? err.message : String(err),
1674
+ stack: err instanceof Error ? err.stack : undefined,
1675
+ });
1676
+ this.browserMessageCb?.({
1677
+ type: "error",
1678
+ message: `Codex notification handler crashed on "${method}". HeyHank may need an update.`,
1679
+ });
1680
+ }
1681
+ }
1682
+
1683
+ // ── Incoming request handlers (approval requests) ───────────────────────
1684
+
1685
+ private handleRequest(method: string, id: number, params: Record<string, unknown>): void {
1686
+ try {
1687
+ switch (method) {
1688
+ case "item/commandExecution/requestApproval":
1689
+ this.handleCommandApproval(id, params);
1690
+ break;
1691
+ case "item/fileChange/requestApproval":
1692
+ this.handleFileChangeApproval(id, params);
1693
+ break;
1694
+ case "item/mcpToolCall/requestApproval":
1695
+ this.handleMcpToolCallApproval(id, params);
1696
+ break;
1697
+ case "item/tool/call":
1698
+ if ((params as Record<string, unknown>).tool === "ExitPlanMode") {
1699
+ this.handleExitPlanModeRequest(id, params);
1700
+ } else {
1701
+ this.handleDynamicToolCall(id, params);
1702
+ }
1703
+ break;
1704
+ case "item/tool/requestUserInput":
1705
+ this.handleUserInputRequest(id, params);
1706
+ break;
1707
+ case "applyPatchApproval":
1708
+ this.handleApplyPatchApproval(id, params);
1709
+ break;
1710
+ case "execCommandApproval":
1711
+ this.handleExecCommandApproval(id, params);
1712
+ break;
1713
+ case "account/chatgptAuthTokens/refresh":
1714
+ console.warn("[codex-adapter] Auth token refresh not supported");
1715
+ this.transport.respond(id, { error: "not supported" });
1716
+ break;
1717
+ default:
1718
+ this.reportProtocolDrift("request", method, { payload: params, blockedForSafety: true });
1719
+ this.transport.respond(id, { error: `Unsupported Codex request method: ${method}` });
1720
+ break;
1721
+ }
1722
+ } catch (err) {
1723
+ log.error("codex-adapter", `Error handling request ${method}`, {
1724
+ sessionId: this.sessionId,
1725
+ method,
1726
+ error: err instanceof Error ? err.message : String(err),
1727
+ stack: err instanceof Error ? err.stack : undefined,
1728
+ });
1729
+ this.browserMessageCb?.({
1730
+ type: "error",
1731
+ message: `Codex request handler crashed on "${method}". HeyHank may need an update.`,
1732
+ });
1733
+ }
1734
+ }
1735
+
1736
+ private handleTurnStarted(params: Record<string, unknown>): void {
1737
+ const turn = this.asRecord(params.turn);
1738
+ const collab = this.asRecord(turn?.collaborationMode);
1739
+ const fromObject = collab?.mode;
1740
+ const fromFlat = turn?.collaborationModeKind;
1741
+ const kind = fromObject === "plan" || fromObject === "default"
1742
+ ? fromObject
1743
+ : (fromFlat === "plan" || fromFlat === "default" ? fromFlat : null);
1744
+
1745
+ if (!kind) return;
1746
+ this.currentCollaborationModeKind = kind;
1747
+ const nextMode = kind === "plan" ? "plan" : this.lastNonPlanPermissionMode;
1748
+ if (nextMode === this.currentPermissionMode) return;
1749
+
1750
+ this.currentPermissionMode = nextMode;
1751
+ this.emit({
1752
+ type: "session_update",
1753
+ session: { permissionMode: this.currentPermissionMode },
1754
+ });
1755
+ }
1756
+
1757
+ private handleCommandApproval(jsonRpcId: number, params: Record<string, unknown>): void {
1758
+ const requestId = `codex-approval-${randomUUID()}`;
1759
+ this.pendingApprovals.set(requestId, jsonRpcId);
1760
+
1761
+ const command = params.command as string | string[] | undefined;
1762
+ const commandStr = params.parsedCmd as string || (Array.isArray(command) ? command.join(" ") : command) || "";
1763
+
1764
+ const perm: PermissionRequest = {
1765
+ request_id: requestId,
1766
+ tool_name: "Bash",
1767
+ input: {
1768
+ command: commandStr,
1769
+ cwd: params.cwd as string || this.getExecutionCwd(),
1770
+ },
1771
+ description: params.reason as string || `Execute: ${commandStr}`,
1772
+ tool_use_id: params.itemId as string || requestId,
1773
+ timestamp: Date.now(),
1774
+ };
1775
+
1776
+ this.emit({ type: "permission_request", request: perm });
1777
+ }
1778
+
1779
+ private handleFileChangeApproval(jsonRpcId: number, params: Record<string, unknown>): void {
1780
+ const requestId = `codex-approval-${randomUUID()}`;
1781
+ this.pendingApprovals.set(requestId, jsonRpcId);
1782
+
1783
+ // Extract file paths from changes array if available
1784
+ const changes = params.changes as Array<{ path?: string; kind?: string }> | undefined;
1785
+ const filePaths = changes?.map((c) => c.path).filter(Boolean) || [];
1786
+ const fileList = filePaths.length > 0 ? filePaths.join(", ") : undefined;
1787
+
1788
+ const perm: PermissionRequest = {
1789
+ request_id: requestId,
1790
+ tool_name: "Edit",
1791
+ input: {
1792
+ description: params.reason as string || "File changes pending approval",
1793
+ ...(filePaths.length > 0 && { file_paths: filePaths }),
1794
+ ...(changes && { changes }),
1795
+ },
1796
+ description: params.reason as string || (fileList ? `Codex wants to modify: ${fileList}` : "Codex wants to modify files"),
1797
+ tool_use_id: params.itemId as string || requestId,
1798
+ timestamp: Date.now(),
1799
+ };
1800
+
1801
+ this.emit({ type: "permission_request", request: perm });
1802
+ }
1803
+
1804
+ private handleMcpToolCallApproval(jsonRpcId: number, params: Record<string, unknown>): void {
1805
+ const requestId = `codex-approval-${randomUUID()}`;
1806
+ this.pendingApprovals.set(requestId, jsonRpcId);
1807
+
1808
+ const server = params.server as string || "unknown";
1809
+ const tool = params.tool as string || "unknown";
1810
+ const args = params.arguments as Record<string, unknown> || {};
1811
+
1812
+ const perm: PermissionRequest = {
1813
+ request_id: requestId,
1814
+ tool_name: `mcp:${server}:${tool}`,
1815
+ input: args,
1816
+ description: params.reason as string || `MCP tool call: ${server}/${tool}`,
1817
+ tool_use_id: params.itemId as string || requestId,
1818
+ timestamp: Date.now(),
1819
+ };
1820
+
1821
+ this.emit({ type: "permission_request", request: perm });
1822
+ }
1823
+
1824
+ private handleDynamicToolCall(jsonRpcId: number, params: Record<string, unknown>): void {
1825
+ const callId = params.callId as string || `dynamic-${randomUUID()}`;
1826
+ const toolName = params.tool as string || "unknown_dynamic_tool";
1827
+ const toolArgs = params.arguments as Record<string, unknown> || {};
1828
+ const requestId = `codex-dynamic-${randomUUID()}`;
1829
+
1830
+ console.log(`[codex-adapter] Dynamic tool call received: ${toolName} (callId=${callId})`);
1831
+
1832
+ // Emit tool_use so the browser sees this custom tool invocation.
1833
+ this.emitToolUseTracked(callId, `dynamic:${toolName}`, toolArgs);
1834
+
1835
+ this.pendingApprovals.set(requestId, jsonRpcId);
1836
+ const timeout = setTimeout(() => {
1837
+ this.resolveDynamicToolCallTimeout(requestId);
1838
+ }, CodexAdapter.DYNAMIC_TOOL_CALL_TIMEOUT_MS);
1839
+
1840
+ this.pendingDynamicToolCalls.set(requestId, {
1841
+ jsonRpcId,
1842
+ callId,
1843
+ toolName,
1844
+ timeout,
1845
+ });
1846
+
1847
+ const perm: PermissionRequest = {
1848
+ request_id: requestId,
1849
+ tool_name: `dynamic:${toolName}`,
1850
+ input: {
1851
+ ...toolArgs,
1852
+ call_id: callId,
1853
+ },
1854
+ description: `Custom tool call: ${toolName}`,
1855
+ tool_use_id: callId,
1856
+ timestamp: Date.now(),
1857
+ };
1858
+
1859
+ this.emit({ type: "permission_request", request: perm });
1860
+ }
1861
+
1862
+ private async resolveDynamicToolCallTimeout(requestId: string): Promise<void> {
1863
+ const pending = this.pendingDynamicToolCalls.get(requestId);
1864
+ if (!pending) return;
1865
+
1866
+ this.pendingDynamicToolCalls.delete(requestId);
1867
+ this.pendingApprovals.delete(requestId);
1868
+
1869
+ this.emitToolResult(
1870
+ pending.callId,
1871
+ `Dynamic tool "${pending.toolName}" timed out waiting for output.`,
1872
+ true,
1873
+ );
1874
+
1875
+ try {
1876
+ await this.transport.respond(pending.jsonRpcId, {
1877
+ contentItems: [{ type: "inputText", text: `Timed out waiting for dynamic tool output: ${pending.toolName}` }],
1878
+ success: false,
1879
+ });
1880
+ } catch (err) {
1881
+ console.warn(`[codex-adapter] Failed to send dynamic tool timeout response: ${err}`);
1882
+ }
1883
+ }
1884
+
1885
+ private buildDynamicToolCallResponse(
1886
+ msg: { behavior: "allow" | "deny"; updated_input?: Record<string, unknown> },
1887
+ toolName: string,
1888
+ ): { contentItems: unknown[]; success: boolean; structuredContent?: unknown } {
1889
+ if (msg.behavior === "deny") {
1890
+ return {
1891
+ contentItems: [{ type: "inputText", text: `Dynamic tool "${toolName}" was denied by user` }],
1892
+ success: false,
1893
+ };
1894
+ }
1895
+
1896
+ const rawContentItems = msg.updated_input?.contentItems;
1897
+ const contentItems = Array.isArray(rawContentItems) && rawContentItems.length > 0
1898
+ ? rawContentItems
1899
+ : [{ type: "inputText", text: String(msg.updated_input?.text || "Dynamic tool call completed") }];
1900
+
1901
+ const success = typeof msg.updated_input?.success === "boolean"
1902
+ ? msg.updated_input.success
1903
+ : true;
1904
+
1905
+ const structuredContent = msg.updated_input?.structuredContent;
1906
+
1907
+ return {
1908
+ contentItems,
1909
+ success,
1910
+ ...(structuredContent !== undefined ? { structuredContent } : {}),
1911
+ };
1912
+ }
1913
+
1914
+ private handleUserInputRequest(jsonRpcId: number, params: Record<string, unknown>): void {
1915
+ const requestId = `codex-userinput-${randomUUID()}`;
1916
+ this.pendingApprovals.set(requestId, jsonRpcId);
1917
+
1918
+ const questions = params.questions as Array<{
1919
+ id: string; header: string; question: string;
1920
+ isOther: boolean; isSecret: boolean;
1921
+ options: Array<{ label: string; description: string }> | null;
1922
+ }> || [];
1923
+
1924
+ // Store question IDs so we can map browser indices back to Codex IDs in the response
1925
+ this.pendingUserInputQuestionIds.set(requestId, questions.map((q) => q.id));
1926
+
1927
+ // Convert to our AskUserQuestion format (matches AskUserQuestionDisplay component)
1928
+ const perm: PermissionRequest = {
1929
+ request_id: requestId,
1930
+ tool_name: "AskUserQuestion",
1931
+ input: {
1932
+ questions: questions.map((q) => ({
1933
+ header: q.header,
1934
+ question: q.question,
1935
+ options: q.options?.map((o) => ({ label: o.label, description: o.description })) || [],
1936
+ })),
1937
+ },
1938
+ description: questions[0]?.question || "User input requested",
1939
+ tool_use_id: params.itemId as string || requestId,
1940
+ timestamp: Date.now(),
1941
+ };
1942
+
1943
+ this.emit({ type: "permission_request", request: perm });
1944
+ }
1945
+
1946
+ private handleExitPlanModeRequest(jsonRpcId: number, params: Record<string, unknown>): void {
1947
+ const callId = params.callId as string || `exitplan-${randomUUID()}`;
1948
+ const toolArgs = params.arguments as Record<string, unknown> || {};
1949
+ const requestId = `codex-exitplan-${randomUUID()}`;
1950
+
1951
+ console.log(`[codex-adapter] ExitPlanMode request received (callId=${callId})`);
1952
+
1953
+ this.pendingApprovals.set(requestId, jsonRpcId);
1954
+ this.pendingExitPlanModeRequests.add(requestId);
1955
+
1956
+ // Emit tool_use with the bare "ExitPlanMode" name (no "dynamic:" prefix)
1957
+ this.emitToolUseTracked(callId, "ExitPlanMode", toolArgs);
1958
+
1959
+ // Build permission request with the format ExitPlanModeDisplay expects
1960
+ const perm: PermissionRequest = {
1961
+ request_id: requestId,
1962
+ tool_name: "ExitPlanMode",
1963
+ input: {
1964
+ plan: typeof toolArgs.plan === "string" ? toolArgs.plan : "",
1965
+ allowedPrompts: Array.isArray(toolArgs.allowedPrompts) ? toolArgs.allowedPrompts : [],
1966
+ },
1967
+ description: "Plan approval requested",
1968
+ tool_use_id: callId,
1969
+ timestamp: Date.now(),
1970
+ };
1971
+
1972
+ this.emit({ type: "permission_request", request: perm });
1973
+ }
1974
+
1975
+ private handleApplyPatchApproval(jsonRpcId: number, params: Record<string, unknown>): void {
1976
+ const requestId = `codex-patch-${randomUUID()}`;
1977
+ this.pendingApprovals.set(requestId, jsonRpcId);
1978
+ this.pendingReviewDecisions.add(requestId);
1979
+
1980
+ const fileChanges = params.fileChanges as Record<string, unknown> || {};
1981
+ const filePaths = Object.keys(fileChanges);
1982
+ const reason = params.reason as string | null;
1983
+
1984
+ const perm: PermissionRequest = {
1985
+ request_id: requestId,
1986
+ tool_name: "Edit",
1987
+ input: {
1988
+ file_paths: filePaths,
1989
+ ...(reason && { reason }),
1990
+ },
1991
+ description: reason || (filePaths.length > 0
1992
+ ? `Codex wants to modify: ${filePaths.join(", ")}`
1993
+ : "Codex wants to modify files"),
1994
+ tool_use_id: params.callId as string || requestId,
1995
+ timestamp: Date.now(),
1996
+ };
1997
+
1998
+ this.emit({ type: "permission_request", request: perm });
1999
+ }
2000
+
2001
+ private handleExecCommandApproval(jsonRpcId: number, params: Record<string, unknown>): void {
2002
+ const requestId = `codex-exec-${randomUUID()}`;
2003
+ this.pendingApprovals.set(requestId, jsonRpcId);
2004
+ this.pendingReviewDecisions.add(requestId);
2005
+
2006
+ const command = params.command as string[] || [];
2007
+ const commandStr = command.join(" ");
2008
+ const cwd = params.cwd as string || this.getExecutionCwd();
2009
+ const reason = params.reason as string | null;
2010
+
2011
+ const perm: PermissionRequest = {
2012
+ request_id: requestId,
2013
+ tool_name: "Bash",
2014
+ input: {
2015
+ command: commandStr,
2016
+ cwd,
2017
+ },
2018
+ description: reason || `Execute: ${commandStr}`,
2019
+ tool_use_id: params.callId as string || requestId,
2020
+ timestamp: Date.now(),
2021
+ };
2022
+
2023
+ this.emit({ type: "permission_request", request: perm });
2024
+ }
2025
+
2026
+ // ── Item event handlers ─────────────────────────────────────────────────
2027
+
2028
+ private handleItemStarted(params: Record<string, unknown>): void {
2029
+ const item = params.item as CodexItem;
2030
+ if (!item) return;
2031
+ const threadId = typeof params.threadId === "string" ? params.threadId : undefined;
2032
+ const parentToolUseId = this.getParentToolUseIdForThread(threadId);
2033
+
2034
+ switch (item.type) {
2035
+ case "agentMessage":
2036
+ // Start streaming accumulation
2037
+ this.streamingItemId = item.id;
2038
+ this.streamingText = "";
2039
+ // Emit message_start stream event so the browser knows streaming began
2040
+ this.emit({
2041
+ type: "stream_event",
2042
+ event: {
2043
+ type: "message_start",
2044
+ message: {
2045
+ id: this.makeMessageId("agent", item.id),
2046
+ type: "message",
2047
+ role: "assistant",
2048
+ model: this.options.model || "",
2049
+ content: [],
2050
+ stop_reason: null,
2051
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
2052
+ },
2053
+ },
2054
+ parent_tool_use_id: parentToolUseId,
2055
+ });
2056
+ // Also emit content_block_start
2057
+ this.emit({
2058
+ type: "stream_event",
2059
+ event: {
2060
+ type: "content_block_start",
2061
+ index: 0,
2062
+ content_block: { type: "text", text: "" },
2063
+ },
2064
+ parent_tool_use_id: parentToolUseId,
2065
+ });
2066
+ break;
2067
+
2068
+ case "commandExecution": {
2069
+ const cmd = item as CodexCommandExecutionItem;
2070
+ const commandStr = Array.isArray(cmd.command) ? cmd.command.join(" ") : (cmd.command || "");
2071
+ this.commandStartTimes.set(item.id, Date.now());
2072
+ this.emitToolUseStart(item.id, "Bash", { command: commandStr });
2073
+ break;
2074
+ }
2075
+
2076
+ case "fileChange": {
2077
+ const fc = item as CodexFileChangeItem;
2078
+ const changes = fc.changes || [];
2079
+ const firstChange = changes[0];
2080
+ const toolName = safeKind(firstChange?.kind) === "create" ? "Write" : "Edit";
2081
+ const toolInput = {
2082
+ file_path: firstChange?.path || "",
2083
+ changes: changes.map((c) => ({ path: c.path, kind: safeKind(c.kind) })),
2084
+ };
2085
+ this.emitToolUseStart(item.id, toolName, toolInput);
2086
+ break;
2087
+ }
2088
+
2089
+ case "mcpToolCall": {
2090
+ const mcp = item as CodexMcpToolCallItem;
2091
+ this.emitToolUseStart(item.id, `mcp:${mcp.server}:${mcp.tool}`, mcp.arguments || {});
2092
+ break;
2093
+ }
2094
+
2095
+ case "webSearch": {
2096
+ const ws = item as CodexWebSearchItem;
2097
+ this.emitToolUseStart(item.id, "WebSearch", { query: ws.query || "" });
2098
+ break;
2099
+ }
2100
+
2101
+ case "reasoning": {
2102
+ const r = item as CodexReasoningItem;
2103
+ const initialThinking = this.coerceReasoningText(r.summary) || this.coerceReasoningText(r.content);
2104
+ this.reasoningTextByItemId.set(item.id, initialThinking);
2105
+ // Emit as thinking content block
2106
+ if (initialThinking) {
2107
+ this.emit({
2108
+ type: "stream_event",
2109
+ event: {
2110
+ type: "content_block_start",
2111
+ index: 0,
2112
+ content_block: { type: "thinking", thinking: initialThinking },
2113
+ },
2114
+ parent_tool_use_id: null,
2115
+ });
2116
+ }
2117
+ break;
2118
+ }
2119
+
2120
+ case "contextCompaction":
2121
+ this.emit({ type: "status_change", status: "compacting" });
2122
+ break;
2123
+
2124
+ case "collabAgentToolCall": {
2125
+ const collab = item as CodexCollabAgentToolCallItem;
2126
+ const receiverThreadIds = Array.isArray(collab.receiverThreadIds)
2127
+ ? collab.receiverThreadIds.filter((id): id is string => typeof id === "string" && id.length > 0)
2128
+ : [];
2129
+ const prompt = typeof collab.prompt === "string" ? collab.prompt.trim() : "";
2130
+ const description = prompt
2131
+ || `${collab.tool || "agent"} (${receiverThreadIds.length || 1} agent${(receiverThreadIds.length || 1) === 1 ? "" : "s"})`;
2132
+ this.emitToolUseStart(item.id, "Task", {
2133
+ description,
2134
+ subagent_type: collab.tool || "codex-collab",
2135
+ codex_status: collab.status,
2136
+ sender_thread_id: collab.senderThreadId || null,
2137
+ receiver_thread_ids: receiverThreadIds,
2138
+ }, parentToolUseId);
2139
+ this.setSubagentThreadMappings(item.id, collab);
2140
+ this.emitAssistantText(
2141
+ `Started ${collab.tool || "collab"} for ${receiverThreadIds.length || 1} agent${(receiverThreadIds.length || 1) === 1 ? "" : "s"}.`,
2142
+ item.id,
2143
+ );
2144
+ break;
2145
+ }
2146
+
2147
+ default:
2148
+ // userMessage is an echo of browser input and not needed in UI.
2149
+ if (item.type !== "userMessage") {
2150
+ console.log(`[codex-adapter] Unhandled item/started type: ${item.type}`, JSON.stringify(item).substring(0, 300));
2151
+ }
2152
+ break;
2153
+ }
2154
+ }
2155
+
2156
+ private handleReasoningDelta(params: Record<string, unknown>): void {
2157
+ const itemId = params.itemId as string | undefined;
2158
+ if (!itemId) return;
2159
+
2160
+ if (!this.reasoningTextByItemId.has(itemId)) {
2161
+ this.reasoningTextByItemId.set(itemId, "");
2162
+ }
2163
+
2164
+ const delta = params.delta as string | undefined;
2165
+ if (delta) {
2166
+ const current = this.reasoningTextByItemId.get(itemId) || "";
2167
+ this.reasoningTextByItemId.set(itemId, current + delta);
2168
+ }
2169
+ }
2170
+
2171
+ private handlePlanDelta(params: Record<string, unknown>): void {
2172
+ const turnId = typeof params.turnId === "string" ? params.turnId : null;
2173
+ const delta = typeof params.delta === "string" ? params.delta : null;
2174
+ if (!turnId || !delta) return;
2175
+ const current = this.planDeltaByTurnId.get(turnId) || "";
2176
+ this.planDeltaByTurnId.set(turnId, current + delta);
2177
+ }
2178
+
2179
+ private handleTurnPlanUpdated(params: Record<string, unknown>): void {
2180
+ const turnObj = params.turn as Record<string, unknown> | undefined;
2181
+ const turnId = typeof params.turnId === "string"
2182
+ ? params.turnId
2183
+ : (typeof turnObj?.id === "string" ? turnObj.id : this.currentTurnId);
2184
+ if (!turnId) return;
2185
+
2186
+ const todos = this.extractPlanTodos(params, turnId);
2187
+ if (todos.length === 0) return;
2188
+
2189
+ const nextCount = (this.planUpdateCountByTurnId.get(turnId) || 0) + 1;
2190
+ this.planUpdateCountByTurnId.set(turnId, nextCount);
2191
+ const toolUseId = `codex-plan-${turnId}-${nextCount}`;
2192
+
2193
+ this.emitToolUseTracked(toolUseId, "TodoWrite", { todos });
2194
+ }
2195
+
2196
+ private handleThreadStatusChanged(params: Record<string, unknown>): void {
2197
+ const raw = params.status;
2198
+ const statusRaw = typeof raw === "string"
2199
+ ? raw
2200
+ : (raw && typeof raw === "object" && typeof (raw as Record<string, unknown>).type === "string")
2201
+ ? ((raw as Record<string, unknown>).type as string)
2202
+ : null;
2203
+ const status = statusRaw === "running" || statusRaw === "compacting"
2204
+ ? statusRaw
2205
+ : null;
2206
+ this.emit({ type: "status_change", status });
2207
+ }
2208
+
2209
+ private extractPlanTodos(params: Record<string, unknown>, turnId: string): PlanTodo[] {
2210
+ const directPlan = params.plan;
2211
+ const turnObj = params.turn as Record<string, unknown> | undefined;
2212
+ const nestedPlan = turnObj?.plan;
2213
+
2214
+ const fromPlanObject = this.extractPlanTodosFromUnknown(
2215
+ directPlan !== undefined ? directPlan : nestedPlan,
2216
+ );
2217
+ if (fromPlanObject.length > 0) {
2218
+ return fromPlanObject;
2219
+ }
2220
+
2221
+ const fallbackDelta = this.planDeltaByTurnId.get(turnId);
2222
+ if (!fallbackDelta) return [];
2223
+ return this.extractPlanTodosFromMarkdown(fallbackDelta);
2224
+ }
2225
+
2226
+ private extractPlanTodosFromUnknown(input: unknown): PlanTodo[] {
2227
+ if (typeof input === "string") {
2228
+ return this.extractPlanTodosFromMarkdown(input);
2229
+ }
2230
+
2231
+ if (!input || typeof input !== "object") {
2232
+ return [];
2233
+ }
2234
+
2235
+ const obj = input as Record<string, unknown>;
2236
+ const stepArrayCandidates = [
2237
+ obj.steps,
2238
+ obj.items,
2239
+ obj.planSteps,
2240
+ (obj.plan as Record<string, unknown> | undefined)?.steps,
2241
+ (obj.plan as Record<string, unknown> | undefined)?.items,
2242
+ ];
2243
+
2244
+ for (const candidate of stepArrayCandidates) {
2245
+ if (!Array.isArray(candidate)) continue;
2246
+ const todos: PlanTodo[] = [];
2247
+ for (const step of candidate) {
2248
+ if (typeof step === "string") {
2249
+ const trimmed = step.trim();
2250
+ if (trimmed) todos.push({ content: trimmed, status: "pending" });
2251
+ continue;
2252
+ }
2253
+ if (!step || typeof step !== "object") continue;
2254
+ const stepObj = step as Record<string, unknown>;
2255
+ const content = this.firstString(stepObj, ["content", "text", "title", "description", "step", "name"]);
2256
+ if (!content) continue;
2257
+ const status = this.normalizePlanStatus(this.firstString(stepObj, ["status", "state", "phase"]));
2258
+ const activeForm = this.firstString(stepObj, ["activeForm", "active_form", "inProgressText", "in_progress_text"]);
2259
+ todos.push({
2260
+ content,
2261
+ status,
2262
+ ...(activeForm ? { activeForm } : {}),
2263
+ });
2264
+ }
2265
+ if (todos.length > 0) return todos;
2266
+ }
2267
+
2268
+ const markdown = this.firstString(obj, ["markdown", "text", "content"]);
2269
+ if (markdown) {
2270
+ return this.extractPlanTodosFromMarkdown(markdown);
2271
+ }
2272
+
2273
+ return [];
2274
+ }
2275
+
2276
+ private extractPlanTodosFromMarkdown(markdown: string): PlanTodo[] {
2277
+ const todos: PlanTodo[] = [];
2278
+ for (const rawLine of markdown.split(/\r?\n/)) {
2279
+ const line = rawLine.trim();
2280
+ if (!line) continue;
2281
+
2282
+ let match = line.match(/^[-*]\s+\[(x|X|~|>| )\]\s+(.+)$/);
2283
+ if (match) {
2284
+ const marker = match[1].toLowerCase();
2285
+ const status = marker === "x" ? "completed"
2286
+ : (marker === "~" || marker === ">") ? "in_progress"
2287
+ : "pending";
2288
+ todos.push({ content: match[2].trim(), status });
2289
+ continue;
2290
+ }
2291
+
2292
+ match = line.match(/^[-*]\s+(.+)$/);
2293
+ if (match) {
2294
+ todos.push({ content: match[1].trim(), status: "pending" });
2295
+ continue;
2296
+ }
2297
+
2298
+ match = line.match(/^\d+\.\s+(.+)$/);
2299
+ if (match) {
2300
+ todos.push({ content: match[1].trim(), status: "pending" });
2301
+ }
2302
+ }
2303
+ return todos;
2304
+ }
2305
+
2306
+ private firstString(obj: Record<string, unknown>, keys: string[]): string | null {
2307
+ for (const key of keys) {
2308
+ const value = obj[key];
2309
+ if (typeof value === "string" && value.trim()) {
2310
+ return value.trim();
2311
+ }
2312
+ }
2313
+ return null;
2314
+ }
2315
+
2316
+ private coerceReasoningText(value: unknown): string {
2317
+ if (typeof value === "string") return value;
2318
+ if (Array.isArray(value)) {
2319
+ return value
2320
+ .map((entry) => this.coerceReasoningText(entry))
2321
+ .filter(Boolean)
2322
+ .join("\n");
2323
+ }
2324
+ if (value && typeof value === "object") {
2325
+ const obj = value as Record<string, unknown>;
2326
+ return this.firstString(obj, ["text", "content", "summary"]) || "";
2327
+ }
2328
+ return "";
2329
+ }
2330
+
2331
+ private normalizePlanStatus(statusRaw: string | null): "pending" | "in_progress" | "completed" {
2332
+ const status = (statusRaw || "").toLowerCase();
2333
+ if (
2334
+ status === "completed"
2335
+ || status === "done"
2336
+ || status === "complete"
2337
+ || status === "success"
2338
+ || status === "succeeded"
2339
+ ) {
2340
+ return "completed";
2341
+ }
2342
+ if (
2343
+ status === "in_progress"
2344
+ || status === "inprogress"
2345
+ || status === "active"
2346
+ || status === "running"
2347
+ || status === "current"
2348
+ ) {
2349
+ return "in_progress";
2350
+ }
2351
+ return "pending";
2352
+ }
2353
+
2354
+ private handleAgentMessageDelta(params: Record<string, unknown>): void {
2355
+ const delta = params.delta as string;
2356
+ if (!delta) return;
2357
+ const threadId = typeof params.threadId === "string" ? params.threadId : undefined;
2358
+ const parentToolUseId = this.getParentToolUseIdForThread(threadId);
2359
+
2360
+ this.streamingText += delta;
2361
+
2362
+ // Emit as content_block_delta (matches Claude's streaming format)
2363
+ this.emit({
2364
+ type: "stream_event",
2365
+ event: {
2366
+ type: "content_block_delta",
2367
+ index: 0,
2368
+ delta: { type: "text_delta", text: delta },
2369
+ },
2370
+ parent_tool_use_id: parentToolUseId,
2371
+ });
2372
+ }
2373
+
2374
+ private handleItemUpdated(_params: Record<string, unknown>): void {
2375
+ // item/updated is a general update — currently we handle streaming via the specific delta events
2376
+ // Could handle status updates for command_execution / file_change items here
2377
+ }
2378
+
2379
+ private handleItemCompleted(params: Record<string, unknown>): void {
2380
+ const item = params.item as CodexItem;
2381
+ if (!item) return;
2382
+ const threadId = typeof params.threadId === "string" ? params.threadId : undefined;
2383
+ const parentToolUseId = this.getParentToolUseIdForThread(threadId);
2384
+
2385
+ switch (item.type) {
2386
+ case "agentMessage": {
2387
+ const agentMsg = item as CodexAgentMessageItem;
2388
+ const text = agentMsg.text || this.streamingText;
2389
+
2390
+ // Emit message_stop for streaming
2391
+ this.emit({
2392
+ type: "stream_event",
2393
+ event: {
2394
+ type: "content_block_stop",
2395
+ index: 0,
2396
+ },
2397
+ parent_tool_use_id: parentToolUseId,
2398
+ });
2399
+ this.emit({
2400
+ type: "stream_event",
2401
+ event: {
2402
+ type: "message_delta",
2403
+ delta: { stop_reason: null }, // null, not "end_turn" — the turn may continue with tool calls
2404
+ usage: { output_tokens: 0 },
2405
+ },
2406
+ parent_tool_use_id: parentToolUseId,
2407
+ });
2408
+
2409
+ // Emit the full assistant message
2410
+ this.emit({
2411
+ type: "assistant",
2412
+ message: {
2413
+ id: this.makeMessageId("agent", item.id),
2414
+ type: "message",
2415
+ role: "assistant",
2416
+ model: this.options.model || "",
2417
+ content: [{ type: "text", text }],
2418
+ stop_reason: "end_turn",
2419
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
2420
+ },
2421
+ parent_tool_use_id: parentToolUseId,
2422
+ timestamp: Date.now(),
2423
+ });
2424
+
2425
+ // Reset streaming state
2426
+ this.streamingText = "";
2427
+ this.streamingItemId = null;
2428
+ break;
2429
+ }
2430
+
2431
+ case "commandExecution": {
2432
+ const cmd = item as CodexCommandExecutionItem;
2433
+ const commandStr = Array.isArray(cmd.command) ? cmd.command.join(" ") : (cmd.command || "");
2434
+ // Ensure tool_use was emitted (may be skipped when auto-approved)
2435
+ this.ensureToolUseEmitted(item.id, "Bash", { command: commandStr });
2436
+ // Clean up progress tracking
2437
+ this.commandStartTimes.delete(item.id);
2438
+ // Emit tool result
2439
+ const output = (item as Record<string, unknown>).stdout as string || "";
2440
+ const stderr = (item as Record<string, unknown>).stderr as string || "";
2441
+ const combinedOutput = [output, stderr].filter(Boolean).join("\n").trim();
2442
+ const exitCode = typeof cmd.exitCode === "number" ? cmd.exitCode : 0;
2443
+ const durationMs = typeof cmd.durationMs === "number" ? cmd.durationMs : undefined;
2444
+ const failed = cmd.status === "failed" || cmd.status === "declined" || exitCode !== 0;
2445
+
2446
+ // Keep successful no-output commands silent in the chat feed.
2447
+ if (!combinedOutput && !failed) {
2448
+ break;
2449
+ }
2450
+
2451
+ let resultText = combinedOutput;
2452
+ if (!resultText) {
2453
+ resultText = `Exit code: ${exitCode}`;
2454
+ } else if (exitCode !== 0) {
2455
+ resultText = `${resultText}\nExit code: ${exitCode}`;
2456
+ }
2457
+ // Append duration if available and significant (>100ms)
2458
+ if (durationMs !== undefined && durationMs >= 100) {
2459
+ const durationStr = durationMs >= 1000
2460
+ ? `${(durationMs / 1000).toFixed(1)}s`
2461
+ : `${durationMs}ms`;
2462
+ resultText = `${resultText}\n(${durationStr})`;
2463
+ }
2464
+
2465
+ this.emitToolResult(item.id, resultText, failed);
2466
+ break;
2467
+ }
2468
+
2469
+ case "fileChange": {
2470
+ const fc = item as CodexFileChangeItem;
2471
+ const changes = fc.changes || [];
2472
+ const firstChange = changes[0];
2473
+ const toolName = safeKind(firstChange?.kind) === "create" ? "Write" : "Edit";
2474
+ // Ensure tool_use was emitted
2475
+ this.ensureToolUseEmitted(item.id, toolName, {
2476
+ file_path: firstChange?.path || "",
2477
+ changes: changes.map((c) => ({ path: c.path, kind: safeKind(c.kind) })),
2478
+ });
2479
+ const summary = changes.map((c) => `${safeKind(c.kind)}: ${c.path}`).join("\n");
2480
+ this.emitToolResult(item.id, summary || "File changes applied", fc.status === "failed");
2481
+ break;
2482
+ }
2483
+
2484
+ case "mcpToolCall": {
2485
+ const mcp = item as CodexMcpToolCallItem;
2486
+ // Ensure tool_use was emitted
2487
+ this.ensureToolUseEmitted(item.id, `mcp:${mcp.server}:${mcp.tool}`, mcp.arguments || {});
2488
+ this.emitToolResult(item.id, mcp.result || mcp.error || "MCP tool call completed", mcp.status === "failed");
2489
+ break;
2490
+ }
2491
+
2492
+ case "webSearch": {
2493
+ const ws = item as CodexWebSearchItem;
2494
+ // Ensure tool_use was emitted
2495
+ this.ensureToolUseEmitted(item.id, "WebSearch", { query: ws.query || "" });
2496
+ this.emitToolResult(item.id, ws.action?.url || ws.query || "Web search completed", false);
2497
+ break;
2498
+ }
2499
+
2500
+ case "reasoning": {
2501
+ const r = item as CodexReasoningItem;
2502
+ const raw =
2503
+ this.reasoningTextByItemId.get(item.id)
2504
+ || this.coerceReasoningText(r.summary)
2505
+ || this.coerceReasoningText(r.content)
2506
+ || "";
2507
+ const thinkingText = (typeof raw === "string" ? raw : String(raw ?? "")).trim();
2508
+
2509
+ if (thinkingText) {
2510
+ this.emit({
2511
+ type: "assistant",
2512
+ message: {
2513
+ id: this.makeMessageId("reasoning", item.id),
2514
+ type: "message",
2515
+ role: "assistant",
2516
+ model: this.options.model || "",
2517
+ content: [{ type: "thinking", thinking: thinkingText }],
2518
+ stop_reason: null,
2519
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
2520
+ },
2521
+ parent_tool_use_id: null,
2522
+ timestamp: Date.now(),
2523
+ });
2524
+ }
2525
+
2526
+ this.reasoningTextByItemId.delete(item.id);
2527
+
2528
+ // Close the thinking content block that was opened in handleItemStarted
2529
+ this.emit({
2530
+ type: "stream_event",
2531
+ event: {
2532
+ type: "content_block_stop",
2533
+ index: 0,
2534
+ },
2535
+ parent_tool_use_id: null,
2536
+ });
2537
+ break;
2538
+ }
2539
+
2540
+ case "contextCompaction":
2541
+ this.emit({ type: "status_change", status: null });
2542
+ break;
2543
+
2544
+ case "collabAgentToolCall": {
2545
+ const collab = item as CodexCollabAgentToolCallItem;
2546
+ const receiverThreadIds = Array.isArray(collab.receiverThreadIds)
2547
+ ? collab.receiverThreadIds.filter((id): id is string => typeof id === "string" && id.length > 0)
2548
+ : [];
2549
+ this.ensureToolUseEmitted(item.id, "Task", {
2550
+ description: (typeof collab.prompt === "string" && collab.prompt.trim())
2551
+ || `${collab.tool || "agent"} (${receiverThreadIds.length || 1} agent${(receiverThreadIds.length || 1) === 1 ? "" : "s"})`,
2552
+ subagent_type: collab.tool || "codex-collab",
2553
+ codex_status: collab.status,
2554
+ sender_thread_id: collab.senderThreadId || null,
2555
+ receiver_thread_ids: receiverThreadIds,
2556
+ }, parentToolUseId);
2557
+ const isError = collab.status === "failed";
2558
+ const summary = this.summarizeCollabCall(collab);
2559
+ this.emitToolResult(item.id, summary, isError);
2560
+ this.emitAssistantText(summary, item.id);
2561
+ this.clearSubagentThreadMappings(collab);
2562
+ break;
2563
+ }
2564
+
2565
+ default:
2566
+ if (item.type !== "userMessage") {
2567
+ console.log(`[codex-adapter] Unhandled item/completed type: ${item.type}`, JSON.stringify(item).substring(0, 300));
2568
+ }
2569
+ break;
2570
+ }
2571
+ }
2572
+
2573
+ private handleTurnCompleted(params: Record<string, unknown>): void {
2574
+ const turn = params.turn as { id: string; status: string; error?: { message: string } } | undefined;
2575
+
2576
+ // Synthesize a CLIResultMessage-like structure
2577
+ const result: CLIResultMessage = {
2578
+ type: "result",
2579
+ subtype: turn?.status === "completed" ? "success" : "error_during_execution",
2580
+ is_error: turn?.status !== "completed",
2581
+ result: turn?.error?.message,
2582
+ duration_ms: 0,
2583
+ duration_api_ms: 0,
2584
+ num_turns: 1,
2585
+ total_cost_usd: 0,
2586
+ stop_reason: turn?.status || "end_turn",
2587
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
2588
+ uuid: randomUUID(),
2589
+ session_id: this.sessionId,
2590
+ };
2591
+
2592
+ this.emit({ type: "result", data: result });
2593
+
2594
+ // Clean up per-turn plan tracking now that the turn is complete.
2595
+ if (turn?.id) {
2596
+ this.planDeltaByTurnId.delete(turn.id);
2597
+ this.planUpdateCountByTurnId.delete(turn.id);
2598
+ }
2599
+ this.currentTurnId = null;
2600
+ }
2601
+
2602
+ private updateRateLimits(data: Record<string, unknown>): void {
2603
+ const rl = data?.rateLimits as Record<string, unknown> | undefined;
2604
+ if (!rl) return;
2605
+ const toEpochMs = (value: unknown): number => {
2606
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
2607
+ return 0;
2608
+ }
2609
+ return value < 1_000_000_000_000 ? value * 1000 : value;
2610
+ };
2611
+ const normalizeLimit = (raw: unknown): { usedPercent: number; windowDurationMins: number; resetsAt: number } | null => {
2612
+ if (!raw || typeof raw !== "object") return null;
2613
+ const limit = raw as Record<string, unknown>;
2614
+ return {
2615
+ usedPercent: typeof limit.usedPercent === "number" ? limit.usedPercent : 0,
2616
+ windowDurationMins: typeof limit.windowDurationMins === "number" ? limit.windowDurationMins : 0,
2617
+ resetsAt: toEpochMs(limit.resetsAt),
2618
+ };
2619
+ };
2620
+ this._rateLimits = {
2621
+ primary: normalizeLimit(rl.primary),
2622
+ secondary: normalizeLimit(rl.secondary),
2623
+ };
2624
+ // Forward rate limits to browser for UI display
2625
+ this.emit({
2626
+ type: "session_update",
2627
+ session: {
2628
+ codex_rate_limits: {
2629
+ primary: this._rateLimits.primary,
2630
+ secondary: this._rateLimits.secondary,
2631
+ },
2632
+ },
2633
+ });
2634
+ }
2635
+
2636
+ private handleTokenUsageUpdated(params: Record<string, unknown>): void {
2637
+ // Codex sends: { threadId, turnId, tokenUsage: {
2638
+ // total: { totalTokens, inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens },
2639
+ // last: { totalTokens, inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens },
2640
+ // modelContextWindow: 258400
2641
+ // }}
2642
+ // IMPORTANT: `total` is cumulative across all turns and can far exceed the context window.
2643
+ // `last` is the most recent turn — its inputTokens reflects what's actually in context.
2644
+ const tokenUsage = params.tokenUsage as Record<string, unknown> | undefined;
2645
+ if (!tokenUsage) return;
2646
+
2647
+ const total = tokenUsage.total as Record<string, number> | undefined;
2648
+ const last = tokenUsage.last as Record<string, number> | undefined;
2649
+ const contextWindow = tokenUsage.modelContextWindow as number | undefined;
2650
+
2651
+ const updates: Partial<SessionState> = {};
2652
+
2653
+ // Use last turn's input tokens for context usage — that's what's actually in the window
2654
+ if (last && contextWindow && contextWindow > 0) {
2655
+ const usedInContext = (last.inputTokens || 0) + (last.outputTokens || 0);
2656
+ const pct = Math.round((usedInContext / contextWindow) * 100);
2657
+ updates.context_used_percent = Math.max(0, Math.min(pct, 100));
2658
+ }
2659
+
2660
+ // Forward cumulative token breakdown for display in the UI
2661
+ if (total) {
2662
+ updates.codex_token_details = {
2663
+ inputTokens: total.inputTokens || 0,
2664
+ outputTokens: total.outputTokens || 0,
2665
+ cachedInputTokens: total.cachedInputTokens || 0,
2666
+ reasoningOutputTokens: total.reasoningOutputTokens || 0,
2667
+ modelContextWindow: contextWindow || 0,
2668
+ };
2669
+ }
2670
+
2671
+ if (Object.keys(updates).length > 0) {
2672
+ this.emit({
2673
+ type: "session_update",
2674
+ session: updates,
2675
+ });
2676
+ }
2677
+ }
2678
+
2679
+ // ── Legacy codex/event/* helpers ──────────────────────────────────────
2680
+
2681
+ private handleLegacyTokenCount(params: Record<string, unknown>): void {
2682
+ const msg = this.asRecord(params.msg);
2683
+ const info = this.asRecord(msg?.info);
2684
+ if (!info) return;
2685
+
2686
+ const toUsage = (raw: unknown): Record<string, number> => {
2687
+ const usage = this.asRecord(raw);
2688
+ if (!usage) {
2689
+ return { totalTokens: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0 };
2690
+ }
2691
+ return {
2692
+ totalTokens: Number(usage.total_tokens || 0),
2693
+ inputTokens: Number(usage.input_tokens || 0),
2694
+ cachedInputTokens: Number(usage.cached_input_tokens || 0),
2695
+ outputTokens: Number(usage.output_tokens || 0),
2696
+ reasoningOutputTokens: Number(usage.reasoning_output_tokens || 0),
2697
+ };
2698
+ };
2699
+
2700
+ this.handleTokenUsageUpdated({
2701
+ tokenUsage: {
2702
+ total: toUsage(info.total_token_usage),
2703
+ last: toUsage(info.last_token_usage),
2704
+ modelContextWindow: Number(info.model_context_window || 0),
2705
+ },
2706
+ });
2707
+ }
2708
+
2709
+ // ── Command progress tracking ─────────────────────────────────────────
2710
+
2711
+ private emitCommandProgress(params: Record<string, unknown>): void {
2712
+ const itemId = params.itemId as string | undefined;
2713
+ if (!itemId) return;
2714
+ const startTime = this.commandStartTimes.get(itemId);
2715
+ const elapsed = startTime ? Math.round((Date.now() - startTime) / 1000) : 0;
2716
+ this.emit({
2717
+ type: "tool_progress",
2718
+ tool_use_id: itemId,
2719
+ tool_name: "Bash",
2720
+ elapsed_time_seconds: elapsed,
2721
+ });
2722
+ }
2723
+
2724
+ // ── Helpers ─────────────────────────────────────────────────────────────
2725
+
2726
+ private emit(msg: BrowserIncomingMessage): void {
2727
+ this.browserMessageCb?.(msg);
2728
+ }
2729
+
2730
+ private reportProtocolDrift(
2731
+ messageKind: "notification" | "request",
2732
+ messageName: string,
2733
+ options?: { payload?: Record<string, unknown>; blockedForSafety?: boolean },
2734
+ ): void {
2735
+ reportProtocolDrift(
2736
+ this.protocolDriftSeen,
2737
+ {
2738
+ backend: "codex",
2739
+ sessionId: this.sessionId,
2740
+ direction: "incoming",
2741
+ messageKind,
2742
+ messageName,
2743
+ keys: options?.payload ? Object.keys(options.payload) : undefined,
2744
+ rawPreview: options?.payload ? JSON.stringify(options.payload) : undefined,
2745
+ blockedForSafety: options?.blockedForSafety,
2746
+ },
2747
+ (message) => this.emit({ type: "error", message }),
2748
+ );
2749
+ }
2750
+
2751
+ private getParentToolUseIdForThread(threadId?: string): string | null {
2752
+ if (!threadId) return null;
2753
+ return this.parentToolUseByThreadId.get(threadId) || null;
2754
+ }
2755
+
2756
+ private setSubagentThreadMappings(parentToolUseId: string, collab: CodexCollabAgentToolCallItem): void {
2757
+ const receiverThreadIds = Array.isArray(collab.receiverThreadIds)
2758
+ ? collab.receiverThreadIds
2759
+ : [];
2760
+ for (const receiverThreadId of receiverThreadIds) {
2761
+ if (typeof receiverThreadId === "string" && receiverThreadId.length > 0) {
2762
+ this.parentToolUseByThreadId.set(receiverThreadId, parentToolUseId);
2763
+ }
2764
+ }
2765
+ }
2766
+
2767
+ private clearSubagentThreadMappings(collab: CodexCollabAgentToolCallItem): void {
2768
+ const receiverThreadIds = Array.isArray(collab.receiverThreadIds)
2769
+ ? collab.receiverThreadIds
2770
+ : [];
2771
+ for (const receiverThreadId of receiverThreadIds) {
2772
+ if (typeof receiverThreadId === "string" && receiverThreadId.length > 0) {
2773
+ this.parentToolUseByThreadId.delete(receiverThreadId);
2774
+ }
2775
+ }
2776
+ }
2777
+
2778
+ private summarizeCollabCall(collab: CodexCollabAgentToolCallItem): string {
2779
+ const receiverCount = Array.isArray(collab.receiverThreadIds)
2780
+ ? collab.receiverThreadIds.filter((id): id is string => typeof id === "string" && id.length > 0).length
2781
+ : 0;
2782
+ const statusText = collab.status === "completed"
2783
+ ? "completed"
2784
+ : collab.status === "failed"
2785
+ ? "failed"
2786
+ : "running";
2787
+ const tool = collab.tool || "collab";
2788
+ const count = receiverCount || 1;
2789
+ return `${tool} ${statusText} for ${count} agent${count === 1 ? "" : "s"}`;
2790
+ }
2791
+
2792
+ private emitAssistantText(text: string, parentToolUseId: string | null): void {
2793
+ this.emit({
2794
+ type: "assistant",
2795
+ message: {
2796
+ id: this.makeMessageId("agent_text"),
2797
+ type: "message",
2798
+ role: "assistant",
2799
+ model: this.options.model || "",
2800
+ content: [{ type: "text", text }],
2801
+ stop_reason: null,
2802
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
2803
+ },
2804
+ parent_tool_use_id: parentToolUseId,
2805
+ timestamp: Date.now(),
2806
+ });
2807
+ }
2808
+
2809
+ /** Emit an assistant message with a tool_use content block (no tracking). */
2810
+ private emitToolUse(toolUseId: string, toolName: string, input: Record<string, unknown>, parentToolUseId: string | null = null): void {
2811
+ console.log(`[codex-adapter] Emitting tool_use: ${toolName} id=${toolUseId}`);
2812
+ this.emit({
2813
+ type: "assistant",
2814
+ message: {
2815
+ id: this.makeMessageId("tool_use", toolUseId),
2816
+ type: "message",
2817
+ role: "assistant",
2818
+ model: this.options.model || "",
2819
+ content: [
2820
+ {
2821
+ type: "tool_use",
2822
+ id: toolUseId,
2823
+ name: toolName,
2824
+ input,
2825
+ },
2826
+ ],
2827
+ stop_reason: null,
2828
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
2829
+ },
2830
+ parent_tool_use_id: parentToolUseId,
2831
+ timestamp: Date.now(),
2832
+ });
2833
+ }
2834
+
2835
+ /** Emit tool_use and track the ID so we don't double-emit. */
2836
+ private emitToolUseTracked(toolUseId: string, toolName: string, input: Record<string, unknown>, parentToolUseId: string | null = null): void {
2837
+ this.emittedToolUseIds.add(toolUseId);
2838
+ this.emitToolUse(toolUseId, toolName, input, parentToolUseId);
2839
+ }
2840
+
2841
+ /**
2842
+ * Emit a tool_use start sequence: stream_event content_block_start + assistant message.
2843
+ * This matches Claude Code's streaming pattern and ensures the frontend sees the tool block
2844
+ * even during active streaming.
2845
+ */
2846
+ private emitToolUseStart(
2847
+ toolUseId: string,
2848
+ toolName: string,
2849
+ input: Record<string, unknown>,
2850
+ parentToolUseId: string | null = null,
2851
+ ): void {
2852
+ // Emit stream event for tool_use start (matches Claude Code pattern)
2853
+ this.emit({
2854
+ type: "stream_event",
2855
+ event: {
2856
+ type: "content_block_start",
2857
+ index: 0,
2858
+ content_block: { type: "tool_use", id: toolUseId, name: toolName, input: {} },
2859
+ },
2860
+ parent_tool_use_id: parentToolUseId,
2861
+ });
2862
+ this.emitToolUseTracked(toolUseId, toolName, input, parentToolUseId);
2863
+ }
2864
+
2865
+ /** Emit tool_use only if item/started was never received for this ID. */
2866
+ private ensureToolUseEmitted(
2867
+ toolUseId: string,
2868
+ toolName: string,
2869
+ input: Record<string, unknown>,
2870
+ parentToolUseId: string | null = null,
2871
+ ): void {
2872
+ if (!this.emittedToolUseIds.has(toolUseId)) {
2873
+ console.log(`[codex-adapter] Backfilling tool_use for ${toolName} (id=${toolUseId}) — item/started was missing`);
2874
+ this.emitToolUseTracked(toolUseId, toolName, input, parentToolUseId);
2875
+ }
2876
+ }
2877
+
2878
+ /** Emit an assistant message with a tool_result content block. */
2879
+ private emitToolResult(toolUseId: string, content: unknown, isError: boolean): void {
2880
+ const safeContent = typeof content === "string" ? content : JSON.stringify(content);
2881
+ this.emit({
2882
+ type: "assistant",
2883
+ message: {
2884
+ id: this.makeMessageId("tool_result", toolUseId),
2885
+ type: "message",
2886
+ role: "assistant",
2887
+ model: this.options.model || "",
2888
+ content: [
2889
+ {
2890
+ type: "tool_result",
2891
+ tool_use_id: toolUseId,
2892
+ content: safeContent,
2893
+ is_error: isError,
2894
+ },
2895
+ ],
2896
+ stop_reason: null,
2897
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
2898
+ },
2899
+ parent_tool_use_id: null,
2900
+ timestamp: Date.now(),
2901
+ });
2902
+ }
2903
+
2904
+ private makeMessageId(kind: string, sourceId?: string): string {
2905
+ if (sourceId) return `codex-${kind}-${sourceId}`;
2906
+ return `codex-${kind}-${randomUUID()}`;
2907
+ }
2908
+
2909
+ private mapApprovalPolicy(_mode?: string): string {
2910
+ // Always "never" — the user never wants permission prompts in Codex,
2911
+ // regardless of whether the collaboration mode is Auto or Plan.
2912
+ return "never";
2913
+ }
2914
+
2915
+ private mapSandboxPolicy(_mode?: string): string {
2916
+ // Always full access — matches approvalPolicy: "never" for full autonomy.
2917
+ return "danger-full-access";
2918
+ }
2919
+
2920
+ /** Map permission mode to SandboxPolicy object (for turn/start's sandboxPolicy field). */
2921
+ private mapSandboxPolicyObject(_mode?: string): { type: string } {
2922
+ // Always full access — matches approvalPolicy: "never" for full autonomy.
2923
+ return { type: "dangerFullAccess" };
2924
+ }
2925
+
2926
+ private mapCollaborationMode(kind: "default" | "plan"): { mode: "default" | "plan"; settings: { model: string } } {
2927
+ return { mode: kind, settings: { model: this.options.model || "" } };
2928
+ }
2929
+
2930
+ private async listAllMcpServerStatuses(): Promise<CodexMcpServerStatus[]> {
2931
+ const out: CodexMcpServerStatus[] = [];
2932
+ let cursor: string | null = null;
2933
+ let page = 0;
2934
+
2935
+ while (page < 50) {
2936
+ const response = await this.transport.call("mcpServerStatus/list", {
2937
+ cursor,
2938
+ limit: 100,
2939
+ }) as CodexMcpStatusListResponse;
2940
+ if (Array.isArray(response.data)) {
2941
+ out.push(...response.data);
2942
+ }
2943
+ cursor = typeof response.nextCursor === "string" ? response.nextCursor : null;
2944
+ if (!cursor) break;
2945
+ page++;
2946
+ }
2947
+
2948
+ return out;
2949
+ }
2950
+
2951
+ private async readMcpServersConfig(): Promise<Record<string, unknown>> {
2952
+ const response = await this.transport.call("config/read", {}) as {
2953
+ config?: Record<string, unknown>;
2954
+ };
2955
+ const config = this.asRecord(response?.config) || {};
2956
+ return this.asRecord(config.mcp_servers) || {};
2957
+ }
2958
+
2959
+ private async reloadMcpServers(): Promise<void> {
2960
+ await this.transport.call("config/mcpServer/reload", {});
2961
+ }
2962
+
2963
+ private isMcpServerEnabled(value: unknown): boolean {
2964
+ const cfg = this.asRecord(value);
2965
+ if (!cfg) return true;
2966
+ return cfg.enabled !== false;
2967
+ }
2968
+
2969
+ private asRecord(value: unknown): Record<string, unknown> | null {
2970
+ return value && typeof value === "object" && !Array.isArray(value)
2971
+ ? value as Record<string, unknown>
2972
+ : null;
2973
+ }
2974
+
2975
+ private toMcpServerConfig(value: unknown): McpServerConfig {
2976
+ const cfg = this.asRecord(value) || {};
2977
+ const args = Array.isArray(cfg.args)
2978
+ ? cfg.args.filter((a): a is string => typeof a === "string")
2979
+ : undefined;
2980
+ const env = this.asRecord(cfg.env) as Record<string, string> | null;
2981
+
2982
+ let type: McpServerConfig["type"] = "sdk";
2983
+ if (cfg.type === "stdio" || cfg.type === "sse" || cfg.type === "http" || cfg.type === "sdk") {
2984
+ type = cfg.type;
2985
+ } else if (typeof cfg.command === "string") {
2986
+ type = "stdio";
2987
+ } else if (typeof cfg.url === "string") {
2988
+ type = "http";
2989
+ }
2990
+
2991
+ return {
2992
+ type,
2993
+ command: typeof cfg.command === "string" ? cfg.command : undefined,
2994
+ args,
2995
+ env: env || undefined,
2996
+ url: typeof cfg.url === "string" ? cfg.url : undefined,
2997
+ };
2998
+ }
2999
+
3000
+ private fromMcpServerConfig(config: McpServerConfig): Record<string, unknown> {
3001
+ const out: Record<string, unknown> = {};
3002
+ if (typeof config.command === "string") out.command = config.command;
3003
+ if (Array.isArray(config.args)) out.args = config.args;
3004
+ if (config.env) out.env = config.env;
3005
+ if (typeof config.url === "string") out.url = config.url;
3006
+ return out;
3007
+ }
3008
+
3009
+ private mapMcpTools(
3010
+ tools: Record<string, { name?: string; annotations?: unknown }> | undefined,
3011
+ ): McpServerDetail["tools"] {
3012
+ if (!tools) return [];
3013
+ return Object.entries(tools).map(([key, tool]) => {
3014
+ const ann = this.asRecord(tool.annotations);
3015
+ const annotations = ann ? {
3016
+ readOnly: (ann.readOnly ?? ann.readOnlyHint) === true,
3017
+ destructive: (ann.destructive ?? ann.destructiveHint) === true,
3018
+ openWorld: (ann.openWorld ?? ann.openWorldHint) === true,
3019
+ } : undefined;
3020
+
3021
+ return {
3022
+ name: typeof tool.name === "string" ? tool.name : key,
3023
+ annotations,
3024
+ };
3025
+ });
3026
+ }
3027
+ }