@mclawnet/codex-adapter 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.
@@ -0,0 +1,911 @@
1
+ import { execSync, spawn } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { createRequire } from "node:module";
5
+ import { homedir, platform } from "node:os";
6
+ import { dirname, join } from "node:path";
7
+ import { buildCodexSpawnArgs, cleanupBriefingFile } from "./codex-spawn-args.js";
8
+ import { createLogger, preview } from "@mclawnet/logger";
9
+ import { JsonRpcClient } from "./json-rpc-client.js";
10
+ import { buildApprovalReply, parseApprovalRequest, } from "./permission-mapper.js";
11
+ import { mapCodexFrame } from "./output-mapper.js";
12
+ import { CODEX_MODELS, CODEX_MODES } from "./catalog.js";
13
+ import { detectCodexInstall } from "./detect.js";
14
+ const log = createLogger({ module: "codex-adapter" });
15
+ const isWin = platform() === "win32";
16
+ /**
17
+ * Resolve the codex CLI binary the same way claude-adapter resolves claude:
18
+ * use the OS lookup tool (where/which), fall back to known install paths.
19
+ *
20
+ * Without this, Windows users hit two failure modes:
21
+ * 1. `spawn("codex", ...)` fails because Windows `CreateProcess` does NOT
22
+ * consult PATHEXT — only cmd.exe does — so a bare "codex" never finds
23
+ * the npm-installed `codex.cmd` / `codex.exe` shim.
24
+ * 2. Even if cmd.exe is on the way, post-CVE-2024-27980 Node refuses to
25
+ * spawn `.cmd` / `.bat` without `shell: true`.
26
+ */
27
+ function resolveCodexBin(explicit) {
28
+ if (explicit && explicit !== "codex")
29
+ return explicit;
30
+ const names = isWin ? ["codex.exe", "codex.cmd", "codex.bat"] : ["codex"];
31
+ const whichCmd = isWin ? "where" : "which";
32
+ for (const name of names) {
33
+ try {
34
+ const found = execSync(`${whichCmd} ${name}`, {
35
+ encoding: "utf-8",
36
+ // 1.5s is plenty for `where`/`which` on a healthy machine; the old
37
+ // 5s × 3 worst-case (15s blocking event loop) showed up on slow
38
+ // domain-joined Windows hosts / network-PATH setups.
39
+ timeout: 1500,
40
+ stdio: ["ignore", "pipe", "ignore"],
41
+ }).trim().split(/\r?\n/)[0];
42
+ if (found && existsSync(found))
43
+ return found;
44
+ }
45
+ catch {
46
+ /* not in PATH */
47
+ }
48
+ }
49
+ const home = homedir();
50
+ const fallbacks = isWin
51
+ ? [
52
+ join(home, "AppData", "Roaming", "npm", "codex.cmd"),
53
+ join(home, "AppData", "Roaming", "npm", "codex.exe"),
54
+ join(home, ".cargo", "bin", "codex.exe"),
55
+ join(home, ".local", "bin", "codex.exe"),
56
+ ]
57
+ : [
58
+ join(home, ".cargo", "bin", "codex"),
59
+ join(home, ".local", "bin", "codex"),
60
+ "/opt/homebrew/bin/codex",
61
+ "/usr/local/bin/codex",
62
+ ];
63
+ for (const p of fallbacks) {
64
+ if (existsSync(p))
65
+ return p;
66
+ }
67
+ return isWin ? "codex.cmd" : "codex";
68
+ }
69
+ /**
70
+ * Module-level cache for the resolved codex binary. Without this, every
71
+ * CodexAdapter instance (created per-session by the agent's adapter factory)
72
+ * pays the `where`/`which` cost again. On a Windows host where `where`
73
+ * times out (slow PATH, network drive, AV scan), 3 lookups × 1.5s would
74
+ * stall agent startup for every spawn.
75
+ *
76
+ * Cache is keyed on the override string so explicit per-instance overrides
77
+ * (e.g. tests) bypass the shared cache.
78
+ */
79
+ const resolvedCodexBinCache = new Map();
80
+ let resolveCodexBinCallCount = 0;
81
+ function getResolvedCodexBin(explicit) {
82
+ const key = explicit ?? "";
83
+ const hit = resolvedCodexBinCache.get(key);
84
+ if (hit)
85
+ return hit;
86
+ resolveCodexBinCallCount++;
87
+ const resolved = resolveCodexBin(explicit);
88
+ resolvedCodexBinCache.set(key, resolved);
89
+ return resolved;
90
+ }
91
+ /** Test-only: clear the resolved-bin cache between cases. */
92
+ export function __resetResolvedCodexBinCache() {
93
+ resolvedCodexBinCache.clear();
94
+ resolveCodexBinCallCount = 0;
95
+ }
96
+ /** Test-only: how many times resolveCodexBin actually ran (i.e. cache misses). */
97
+ export function __getResolveCodexBinCallCount() {
98
+ return resolveCodexBinCallCount;
99
+ }
100
+ /**
101
+ * Resolve how to spawn codex without invoking cmd.exe on Windows.
102
+ *
103
+ * codex.cmd is an npm shim that dispatches to the real Rust .exe (or in
104
+ * some installs, a node-shimmed cli.js). Spawning the .cmd directly:
105
+ * - requires `shell: true` post-CVE-2024-27980, which would also have to
106
+ * deal with cmd.exe's 8191-char command-line cap.
107
+ * - drops the child's stdio attachment in subtle ways through cmd.exe.
108
+ *
109
+ * So we parse the shim and spawn the underlying .exe (or `node cli.js`)
110
+ * directly. Mirrors claude-adapter's resolveSpawnTarget pattern.
111
+ */
112
+ export function resolveSpawnTarget(codexBin) {
113
+ // Re-read platform per call (instead of using the module-level `isWin`
114
+ // constant) so tests can flip `process.platform` to exercise Windows
115
+ // paths from a macOS / Linux CI. Mirrors claude-adapter's pattern.
116
+ const isWindows = platform() === "win32";
117
+ if (!isWindows || !codexBin.toLowerCase().endsWith(".cmd")) {
118
+ return { command: codexBin, prefixArgs: [] };
119
+ }
120
+ try {
121
+ const cmdContent = readFileSync(codexBin, "utf-8");
122
+ // codex shim typically points at a sibling .exe: e.g.
123
+ // "%~dp0\node_modules\@openai\codex\bin\codex.exe" %*
124
+ const exeMatch = cmdContent.match(/(?:%~?dp0%?)[\\/](.+?\.exe)/i);
125
+ if (exeMatch) {
126
+ const relParts = exeMatch[1].split(/[\\/]/);
127
+ const exePath = join(dirname(codexBin), ...relParts);
128
+ if (existsSync(exePath))
129
+ return { command: exePath, prefixArgs: [] };
130
+ }
131
+ // Fallback: npm-style `node cli.js` shim.
132
+ const jsMatch = cmdContent.match(/(?:%~?dp0%?)[\\/](.+?\.js)/i);
133
+ if (jsMatch) {
134
+ const relParts = jsMatch[1].split(/[\\/]/);
135
+ const cliJsPath = join(dirname(codexBin), ...relParts);
136
+ if (existsSync(cliJsPath)) {
137
+ return { command: process.execPath, prefixArgs: [cliJsPath] };
138
+ }
139
+ }
140
+ }
141
+ catch {
142
+ /* fall through */
143
+ }
144
+ // Last resort: invoke through cmd.exe. Loses the 8191-char cap protection
145
+ // but at least the process gets spawned. Args are short for codex so this
146
+ // is unlikely to bite.
147
+ return { command: codexBin, prefixArgs: [] };
148
+ }
149
+ const CLAWNET_CLIENT_INFO = {
150
+ name: "clawnet",
151
+ version: "0.1.0",
152
+ title: "ClawNet Agent",
153
+ };
154
+ /**
155
+ * Cap on the per-process `assistantTextSeen` map. Bounds memory growth
156
+ * from interrupted agentMessage items that never fire `item/completed`
157
+ * (a single very long session would otherwise leak forever now that
158
+ * `turn/completed` no longer wholesale-clears — see the comment on that
159
+ * branch in `handleNotification`).
160
+ *
161
+ * A single codex turn typically produces 1-2 agentMessage items; 64
162
+ * comfortably covers extreme cases without making eviction reachable
163
+ * during normal use. If the cap is exceeded, the OLDEST untouched entry
164
+ * is dropped (FIFO); a subsequent matching `item/completed` for that
165
+ * itemId would then see an empty accumulator and emit the full text,
166
+ * producing at most one duplicated message instead of an unbounded leak.
167
+ */
168
+ const ASSISTANT_TEXT_SEEN_MAX = 64;
169
+ /**
170
+ * Per-process state wrapper. Holds the JSON-RPC client + pending approval
171
+ * resolvers so `respondToPermission` can find the right server-initiated
172
+ * request id to reply to.
173
+ */
174
+ export class CodexProcess extends EventEmitter {
175
+ id;
176
+ workDir;
177
+ pid;
178
+ proc;
179
+ killed = false;
180
+ /** callId → JSON-RPC server-request id we still owe a result to. */
181
+ pendingApprovals = new Map();
182
+ /** callId → originating wire family (drives v2/legacy/mcp reply shape). */
183
+ approvalMethods = new Map();
184
+ /** Monotonic counter for approval resolver keys. */
185
+ nextResolverKey = 1;
186
+ /** Resolver registered by the rpc client for each pending approval. */
187
+ approvalResolvers = new Map();
188
+ rpc;
189
+ backendSessionId;
190
+ /**
191
+ * Thread id to resume on agent restart. When set, handshake() sends
192
+ * `thread/resume { threadId }` instead of `thread/start { cwd }`.
193
+ * Set from SpawnOptions.resumeId in CodexAdapter.spawn(). Must NOT be
194
+ * passed as `--resume` CLI flag — app-server rejects it.
195
+ */
196
+ resumeId;
197
+ /** True once `initialize` round-trip + `thread/start` (or `thread/resume`) have completed. */
198
+ handshakeComplete = false;
199
+ /** Inputs queued by send() while the handshake is still in flight. */
200
+ pendingInputs = [];
201
+ /**
202
+ * Per-itemId accumulator of assistant text already emitted via
203
+ * `item/agentMessage/delta` chunks. Consulted on `item/completed` with
204
+ * `item.type === "agentMessage"` to decide what (if anything) of the
205
+ * completed payload still needs to be emitted. Without this, codex
206
+ * delivers the same assistant text twice (deltas + final) and the UI
207
+ * renders "Hello worldHello world".
208
+ *
209
+ * Entries are cleared per item when the matching `item/completed` fires.
210
+ * We do NOT clear the whole map on `turn/completed`: codex's wire
211
+ * doesn't strictly guarantee that every item/completed precedes its
212
+ * parent turn/completed, and a trailing item/completed against an empty
213
+ * accumulator would re-emit the full text (reactivating the bug). To
214
+ * cap unbounded growth from interrupted turns that never fire
215
+ * completed, the map is size-bounded with FIFO eviction; in practice a
216
+ * single turn produces 1-2 agentMessage items so the cap is generous.
217
+ */
218
+ assistantTextSeen = new Map();
219
+ /** Temp file for briefing injection; cleaned up on kill(). */
220
+ briefingFile;
221
+ /**
222
+ * Set by CodexAdapter.spawn() so handshake() can surface real process-exit
223
+ * info if the codex CLI died (e.g. arg parse error). Without this, a dead
224
+ * process leads to a generic "handshake timeout" instead of the actual
225
+ * "error: unexpected argument …" stderr message.
226
+ *
227
+ * Default returns (null, "") for tests that construct CodexProcess
228
+ * directly without going through spawn() (e.g. attachRpc mock pipes).
229
+ * spawn() overrides these with real getters wired to the child process.
230
+ */
231
+ getExitInfo = () => null;
232
+ getStderr = () => "";
233
+ /** Allocate a resolver key. Monotonic; never reused. */
234
+ allocResolverKey() {
235
+ return this.nextResolverKey++;
236
+ }
237
+ constructor(sessionId, workDir, proc) {
238
+ super();
239
+ this.id = sessionId;
240
+ this.workDir = workDir;
241
+ this.proc = proc;
242
+ this.pid = proc?.pid;
243
+ }
244
+ async kill() {
245
+ this.killed = true;
246
+ // Drain any pending approval bookkeeping so long-running hubs don't leak
247
+ // entries on aborted sessions. Resolve any outstanding RPC promise with a
248
+ // "cancel"-equivalent so callers awaiting `respondToPermission` never hang.
249
+ for (const [, resolver] of this.approvalResolvers) {
250
+ try {
251
+ resolver({ decision: "abort" });
252
+ }
253
+ catch {
254
+ // ignore
255
+ }
256
+ }
257
+ this.pendingApprovals.clear();
258
+ this.approvalMethods.clear();
259
+ this.approvalResolvers.clear();
260
+ this.pendingInputs.length = 0;
261
+ this.assistantTextSeen.clear();
262
+ if (this.briefingFile) {
263
+ cleanupBriefingFile(this.briefingFile);
264
+ this.briefingFile = undefined;
265
+ }
266
+ if (this.proc && !this.proc.killed) {
267
+ try {
268
+ this.proc.kill("SIGTERM");
269
+ }
270
+ catch {
271
+ // ignore
272
+ }
273
+ }
274
+ }
275
+ isAlive() {
276
+ if (this.killed)
277
+ return false;
278
+ if (!this.proc)
279
+ return true;
280
+ return !this.proc.killed && this.proc.exitCode === null;
281
+ }
282
+ }
283
+ /**
284
+ * CodexAdapter — BackendAdapter wrapping `codex app-server --listen stdio://`.
285
+ *
286
+ * M3.S3 scope: spawn/stop/send/onOutput plus the permission flow
287
+ * (`onPermissionRequest` / `respondToPermission`). Resume + token-budget +
288
+ * MCP plumbing land in follow-up slices.
289
+ */
290
+ export class CodexAdapter {
291
+ type = "codex";
292
+ codexBin;
293
+ handshakeTimeoutMs;
294
+ detect;
295
+ constructor(options) {
296
+ this.codexBin = getResolvedCodexBin(options?.codexBin);
297
+ // Precedence: explicit constructor option > env var > 15s default. The
298
+ // env var (CLAWNET_CODEX_HANDSHAKE_TIMEOUT_MS) is the escape hatch for
299
+ // slow CI / cold-start machines where 15s isn't enough and the user
300
+ // can't easily inject a CodexAdapterOptions override.
301
+ const envTimeout = Number(process.env.CLAWNET_CODEX_HANDSHAKE_TIMEOUT_MS);
302
+ this.handshakeTimeoutMs =
303
+ options?.handshakeTimeoutMs ??
304
+ (Number.isFinite(envTimeout) && envTimeout > 0 ? envTimeout : 15000);
305
+ this.detect = options?.detect ?? detectCodexInstall;
306
+ }
307
+ async spawn(options) {
308
+ let cwd = options.workDir || process.cwd();
309
+ if (!existsSync(cwd))
310
+ cwd = homedir();
311
+ // Build args with MCP + briefing injection
312
+ const mcpServer = this.buildMcpServerConfig(options);
313
+ const { args, briefingFile, modeSource } = buildCodexSpawnArgs({
314
+ sessionId: options.sessionId,
315
+ resumeId: options.resumeId,
316
+ systemPrompt: options.systemPrompt,
317
+ mcpServer,
318
+ sandbox: options.sandbox,
319
+ model: options.model,
320
+ mode: options.mode,
321
+ });
322
+ if (options.mode && modeSource !== "options.mode") {
323
+ log.warn({ sessionId: options.sessionId, requestedMode: options.mode, modeSource }, "codex: invalid options.mode ignored, falling back");
324
+ }
325
+ log.info({ sessionId: options.sessionId, bin: this.codexBin, args, cwd, resumeId: options.resumeId }, "codex spawn: forking app-server");
326
+ const { command, prefixArgs } = resolveSpawnTarget(this.codexBin);
327
+ if (command !== this.codexBin) {
328
+ log.info({ sessionId: options.sessionId, bin: this.codexBin, spawnCmd: command, prefixArgs }, "codex spawn: using resolved shim target (windows .cmd bypass)");
329
+ }
330
+ const proc = spawn(command, [...prefixArgs, ...args], {
331
+ cwd,
332
+ stdio: ["pipe", "pipe", "pipe"],
333
+ env: { ...process.env, ...(options.env ?? {}) },
334
+ windowsHide: true,
335
+ });
336
+ // Capture exit state into a closure so handshake() can surface it if
337
+ // initialize / thread RPC times out. Without this guard a dead codex
338
+ // (e.g. CLI arg parse error → ~600ms after spawn) would leave the
339
+ // adapter waiting handshakeTimeoutMs (default 15s) with a generic
340
+ // "handshake timeout" message. With it, we report exit code + stderr.
341
+ const stderrChunks = [];
342
+ proc.stderr?.on("data", (chunk) => {
343
+ const text = chunk.toString("utf8").trim();
344
+ if (text) {
345
+ stderrChunks.push(text);
346
+ log.warn({ sessionId: options.sessionId, stderr: text }, "codex stderr");
347
+ }
348
+ });
349
+ let exitInfo = null;
350
+ // cp is built AFTER this Promise resolves, so capture a thunk and resolve
351
+ // it lazily. proc.on("exit") needs to re-emit on cp so the BackendAdapter
352
+ // contract's onExit/onError subscribers (session-manager) actually fire —
353
+ // without this re-emit, codex crashes silently never reach the swarm
354
+ // coordinator's handleRoleCrashed path and the queen stays unaware.
355
+ let createdCp;
356
+ // Shared emit helper: invoked both from the proc.on("exit") handler and
357
+ // the late-fire safety branch below. Centralizes the "emit exit + maybe
358
+ // emit error with stderr" contract so future changes can't drift.
359
+ const emitProcessExit = (cp, code, signal) => {
360
+ try {
361
+ cp.emit("exit", code);
362
+ }
363
+ catch { /* listener threw */ }
364
+ const isClean = code === 0 || signal === "SIGTERM";
365
+ if (isClean)
366
+ return;
367
+ // Surface stderr via "error" so the agent's onSessionError handler
368
+ // can include the real failure cause (e.g. "error: unexpected
369
+ // argument", codex auth missing) instead of a bare "exit code=1".
370
+ const stderr = stderrChunks.join("\n").trim().slice(0, 800);
371
+ const detail = stderr ? ` — stderr: ${stderr}` : "";
372
+ try {
373
+ cp.emit("error", new Error(`codex process exited (code=${code ?? "null"}, signal=${signal ?? "null"})${detail}`));
374
+ }
375
+ catch { /* listener threw */ }
376
+ };
377
+ proc.on("exit", (code, signal) => {
378
+ const isClean = code === 0 || signal === "SIGTERM";
379
+ const level = isClean ? "debug" : "warn";
380
+ log[level]({ sessionId: options.sessionId, code, signal }, "codex process exited");
381
+ if (!exitInfo)
382
+ exitInfo = { code, signal };
383
+ const cpRef = createdCp;
384
+ if (!cpRef)
385
+ return;
386
+ emitProcessExit(cpRef, code, signal);
387
+ });
388
+ await new Promise((resolve, reject) => {
389
+ const t = setTimeout(() => resolve(), 2000);
390
+ proc.on("error", (err) => {
391
+ clearTimeout(t);
392
+ reject(new Error(`Failed to spawn codex CLI: ${err.message}`));
393
+ });
394
+ proc.on("spawn", () => {
395
+ clearTimeout(t);
396
+ log.debug({ sessionId: options.sessionId, pid: proc.pid }, "codex spawn: process started");
397
+ resolve();
398
+ });
399
+ });
400
+ const cp = new CodexProcess(options.sessionId, cwd, proc);
401
+ cp.briefingFile = briefingFile;
402
+ cp.resumeId = options.resumeId;
403
+ // Expose process-exit signal to handshake so a timeout can report the
404
+ // real cause instead of a generic "timeout".
405
+ cp.getExitInfo = () => exitInfo;
406
+ cp.getStderr = () => stderrChunks.join("\n").slice(0, 800);
407
+ createdCp = cp;
408
+ // Late-fire safety: if the process exited BEFORE we got here (cp wasn't
409
+ // assigned yet so the proc.on("exit") path skipped the re-emit), surface
410
+ // it now via the same channel. setImmediate defers until session-manager
411
+ // has had a chance to subscribe (which happens synchronously after our
412
+ // returned cp lands).
413
+ if (exitInfo) {
414
+ const { code, signal } = exitInfo;
415
+ setImmediate(() => emitProcessExit(cp, code, signal));
416
+ }
417
+ this.wireRpc(cp);
418
+ return cp;
419
+ }
420
+ buildMcpServerConfig(options) {
421
+ // Resolve clawnet-mcp-server path. The package's "exports" map is a
422
+ // closed whitelist (only "." and "./package.json" are exported, plus
423
+ // "./server"). Resolving `@mclawnet/mcp-server/dist/server.js` directly
424
+ // throws ERR_PACKAGE_PATH_NOT_EXPORTED on modern Node, which is why
425
+ // packaged-release Windows hosts saw "not resolvable" while the dev
426
+ // monorepo path silently worked.
427
+ //
428
+ // Strategy: try the supported subpath export first ("./server"), then
429
+ // resolve via package.json + join, then fall back to the legacy direct
430
+ // path (still useful when "./server" isn't published yet but a hub
431
+ // installs an older mcp-server release), then dev-monorepo fallback.
432
+ let serverPath;
433
+ const require = createRequire(import.meta.url);
434
+ const tryResolve = (specifier) => {
435
+ try {
436
+ const p = require.resolve(specifier);
437
+ return p && existsSync(p) ? p : undefined;
438
+ }
439
+ catch {
440
+ return undefined;
441
+ }
442
+ };
443
+ serverPath = tryResolve("@mclawnet/mcp-server/server");
444
+ if (!serverPath) {
445
+ try {
446
+ const pkgPath = require.resolve("@mclawnet/mcp-server/package.json");
447
+ const candidate = join(dirname(pkgPath), "dist", "server.js");
448
+ if (existsSync(candidate))
449
+ serverPath = candidate;
450
+ }
451
+ catch {
452
+ /* not installed */
453
+ }
454
+ }
455
+ if (!serverPath) {
456
+ serverPath = tryResolve("@mclawnet/mcp-server/dist/server.js");
457
+ }
458
+ if (!serverPath) {
459
+ const devPath = join(import.meta.dirname ?? __dirname, "../../mcp-server/dist/server.js");
460
+ if (existsSync(devPath))
461
+ serverPath = devPath;
462
+ }
463
+ if (!serverPath) {
464
+ log.warn({ pkg: "@mclawnet/mcp-server" }, "codex: clawnet-mcp-server not resolvable, codex role will lack MCP tools");
465
+ return undefined;
466
+ }
467
+ log.debug({ serverPath }, "codex: resolved clawnet-mcp-server path");
468
+ const env = {};
469
+ if (options.workDir)
470
+ env.CLAWNET_WORK_DIR = options.workDir;
471
+ // mcp-server's server.ts:28 explicitly documents: CLAWNET_HOME is the
472
+ // user home (NO `.clawnet` suffix) — the server appends `.clawnet`
473
+ // itself via projectRoot(). Passing `~/.clawnet` here would yield
474
+ // `~/.clawnet/.clawnet/...` (double append) and break inbox/task layout.
475
+ const home = process.env.CLAWNET_HOME ?? homedir();
476
+ env.CLAWNET_HOME = home;
477
+ return { command: "node", args: [serverPath], env };
478
+ }
479
+ /**
480
+ * Bind a CodexProcess to a JSON-RPC duplex. Public so tests can inject a
481
+ * mocked stdin/stdout pair without spawning a real subprocess.
482
+ *
483
+ * Drives the v2 handshake automatically: `initialize` → `thread/start`.
484
+ * Once `thread/start` resolves, `cp.backendSessionId` is set and a
485
+ * `session_started` event fires so the agent's hub bridge can persist it.
486
+ */
487
+ attachRpc(cp, stdin, stdout) {
488
+ const rpc = new JsonRpcClient({
489
+ stdin: stdin,
490
+ stdout: stdout,
491
+ onRequest: (method, params) => this.handleServerRequest(cp, method, params),
492
+ onNotification: (method, params) => this.handleNotification(cp, method, params),
493
+ onMalformedLine: (line, err) => log.warn({ sessionId: cp.id, err: err.message, linePreview: line.slice(0, 200) }, "codex stdout: non-JSON line ignored"),
494
+ });
495
+ cp.rpc = rpc;
496
+ void this.handshake(cp);
497
+ }
498
+ async handshake(cp) {
499
+ if (!cp.rpc)
500
+ return;
501
+ const rpc = cp.rpc;
502
+ const resumeId = cp.resumeId;
503
+ log.info({ sessionId: cp.id, workDir: cp.workDir, resumeId }, "codex handshake: start");
504
+ // Wrap the whole handshake in a timeout so a dead codex process (e.g.
505
+ // killed by `--resume` CLI parse error, segfault, etc.) doesn't leave
506
+ // pendingInputs queued forever. Without this guard the swarm silently
507
+ // stalls — the user sees no error, just nothing happens.
508
+ //
509
+ // raceSettled is declared up here (before the IIFE) so the handshake
510
+ // success path can also check it. If timeout/death wins the race,
511
+ // handshakeAttempt may still complete later — without this guard it
512
+ // would mutate cp.backendSessionId, emit session_started, and flush
513
+ // pendingInputs against a process the caller already considers dead.
514
+ let raceSettled = false;
515
+ const handshakeAttempt = (async () => {
516
+ await rpc.request("initialize", { clientInfo: CLAWNET_CLIENT_INFO });
517
+ log.info({ sessionId: cp.id }, "codex handshake: initialize ok");
518
+ // Branch on resumeId — `codex app-server` rejects `--resume` CLI flag,
519
+ // so resume MUST happen at the JSON-RPC layer via `thread/resume`.
520
+ // `thread/start` on a resumed codex returns a NEW threadId (losing
521
+ // conversation continuity), which is why we have to branch here.
522
+ let threadId;
523
+ if (resumeId) {
524
+ const resumed = (await rpc.request("thread/resume", { threadId: resumeId }));
525
+ // Don't silently fall back to the input resumeId — if codex ever
526
+ // migrates the thread id (the protocol allows it) we'd persist a
527
+ // stale id and break the next restart. Treat absent thread id as a
528
+ // protocol error so the caller sees it and can re-spawn fresh.
529
+ threadId = resumed?.thread?.id ?? resumed?.threadId;
530
+ }
531
+ else {
532
+ // v2 `thread/start` returns { thread: { id, sessionId, ... }, ... }.
533
+ // The resumable identifier is `thread.id` — `sessionId` here is the
534
+ // tree-id shared by forked threads, not what `thread/resume` wants.
535
+ const started = (await rpc.request("thread/start", { cwd: cp.workDir }));
536
+ threadId = started?.thread?.id ?? started?.threadId;
537
+ }
538
+ if (!threadId) {
539
+ throw new Error(`codex handshake: ${resumeId ? "thread/resume" : "thread/start"} returned no threadId`);
540
+ }
541
+ // Race lost (timeout/death already won) → don't mutate state, don't
542
+ // emit session_started, don't flush queued inputs. The handshake
543
+ // error has already been reported via cp.emit("error") in the
544
+ // catch-block below.
545
+ if (raceSettled) {
546
+ log.warn({ sessionId: cp.id, threadId }, "codex handshake resolved AFTER race already lost — discarding result");
547
+ return;
548
+ }
549
+ cp.backendSessionId = threadId;
550
+ cp.handshakeComplete = true;
551
+ log.info({ sessionId: cp.id, threadId, queuedInputs: cp.pendingInputs.length }, "codex handshake: complete, flushing queued inputs");
552
+ cp.emit("session_started", { backendSessionId: threadId });
553
+ // Flush any inputs that arrived while the handshake was in flight.
554
+ const queued = cp.pendingInputs.splice(0);
555
+ for (const input of queued) {
556
+ this.dispatchTurn(cp, input);
557
+ }
558
+ })();
559
+ // Attach a no-op catch so handshakeAttempt rejecting AFTER losing the
560
+ // race (timeout or death already won) doesn't bubble as an unhandled
561
+ // promise rejection — node --unhandled-rejections=strict would crash.
562
+ // The real error already surfaced via cp.emit("error") above.
563
+ handshakeAttempt.catch(() => { });
564
+ let timeoutHandle;
565
+ let exitPollHandle;
566
+ // raceSettled is checked by the death-poll loop and the handshake
567
+ // success path. clearTimeout in finally{} alone is insufficient:
568
+ // poll() may be mid-execution when finally runs, scheduling the next
569
+ // setTimeout before clear; the success path could likewise resolve a
570
+ // late RPC after the race has been decided.
571
+ const timeoutPromise = new Promise((_, reject) => {
572
+ timeoutHandle = setTimeout(() => {
573
+ reject(new Error(`codex handshake timeout after ${this.handshakeTimeoutMs}ms (process may have died)`));
574
+ }, this.handshakeTimeoutMs);
575
+ });
576
+ // Process-death detector: poll exitInfo every 100ms; if codex exits
577
+ // mid-handshake (e.g. CLI parse error), reject early with the real
578
+ // stderr instead of waiting handshakeTimeoutMs (15s default) for the
579
+ // generic timeout message.
580
+ const deathPromise = new Promise((_, reject) => {
581
+ const poll = () => {
582
+ if (raceSettled)
583
+ return; // race already over — stop recursing
584
+ const exit = cp.getExitInfo();
585
+ if (exit) {
586
+ const stderr = cp.getStderr();
587
+ reject(new Error(`codex process died during handshake ` +
588
+ `(code=${exit.code}, signal=${exit.signal}). ` +
589
+ `stderr: ${stderr || "(empty)"}`));
590
+ return;
591
+ }
592
+ exitPollHandle = setTimeout(poll, 100);
593
+ };
594
+ poll();
595
+ });
596
+ try {
597
+ await Promise.race([handshakeAttempt, timeoutPromise, deathPromise]);
598
+ }
599
+ catch (err) {
600
+ log.error({ err, sessionId: cp.id }, "codex v2 handshake failed");
601
+ cp.emit("error", err);
602
+ }
603
+ finally {
604
+ raceSettled = true;
605
+ if (timeoutHandle)
606
+ clearTimeout(timeoutHandle);
607
+ if (exitPollHandle)
608
+ clearTimeout(exitPollHandle);
609
+ }
610
+ }
611
+ wireRpc(cp) {
612
+ const proc = cp.proc;
613
+ if (proc?.stdin && proc?.stdout) {
614
+ this.attachRpc(cp, proc.stdin, proc.stdout);
615
+ }
616
+ }
617
+ handleServerRequest(cp, method, params) {
618
+ const parsed = parseApprovalRequest(method, params);
619
+ if (!parsed) {
620
+ log.warn({ method }, "codex server-request: unrecognized method");
621
+ return Promise.resolve({ decision: "denied" });
622
+ }
623
+ const { req, wireFamily } = parsed;
624
+ return new Promise((resolve) => {
625
+ const resolverKey = cp.allocResolverKey();
626
+ cp.pendingApprovals.set(req.callId, resolverKey);
627
+ cp.approvalMethods.set(req.callId, wireFamily);
628
+ cp.approvalResolvers.set(resolverKey, (reply) => {
629
+ resolve(reply);
630
+ });
631
+ cp.emit("permission_request", req);
632
+ });
633
+ }
634
+ handleNotification(cp, method, params) {
635
+ // v2 thread lifecycle
636
+ if (method === "thread/started") {
637
+ // codex emits this as a *notification* during normal turns too; the
638
+ // primary session_started signal comes from thread/start's RPC result
639
+ // (see handshake()). Suppress the duplicate here so onSessionStarted
640
+ // listeners only fire once.
641
+ return;
642
+ }
643
+ if (method === "error") {
644
+ // codex `error` notifications carry the real failure cause (e.g.
645
+ // "Missing environment variable: COPILOT_API_KEY", upstream provider
646
+ // failure). Before this branch existed they degraded to {kind:"raw"} →
647
+ // normalize-backend-output dropped them silently → swarm coordinator
648
+ // never learned the role had failed → queen kept nudging a dead
649
+ // worker. Promote to cp.emit("error") so session-manager.onError →
650
+ // onSessionError fires.
651
+ const p = (params ?? {});
652
+ const message = p.error?.message ?? "codex emitted unspecified error";
653
+ log.warn({ sessionId: cp.id, params }, "codex error notification");
654
+ cp.emit("error", new Error(`codex: ${message}`));
655
+ // willRetry:false is codex saying "this turn is dead, no retry". Kill
656
+ // the process so proc.on("exit") fires → session-manager.onSessionExit
657
+ // → swarmCoordinator.handleRoleCrashed flips the role to crashed and
658
+ // wakes the queen. Leaving it alive would burn an idle process and
659
+ // continue queen nudge loops.
660
+ if (p.willRetry === false) {
661
+ void cp.kill().catch((err) => {
662
+ log.warn({ err, sessionId: cp.id }, "kill after fatal codex error failed");
663
+ });
664
+ }
665
+ return;
666
+ }
667
+ if (method === "thread/status/changed") {
668
+ // Codex pairs systemError status with an `error` notification that
669
+ // carries willRetry. We forward the status as onError so the swarm
670
+ // sees the failure signal immediately, but defer the kill decision to
671
+ // the paired `error` frame (it has the willRetry bit). Other status
672
+ // transitions (active, idle, running, …) are benign lifecycle events
673
+ // — silently consume them, otherwise mapCodexFrame would degrade them
674
+ // to {kind:"raw"} and spam log.warn for every normal turn.
675
+ const p = (params ?? {});
676
+ if (p.status?.type === "systemError") {
677
+ log.warn({ sessionId: cp.id, params }, "codex thread systemError");
678
+ cp.emit("error", new Error("codex: thread entered systemError"));
679
+ }
680
+ return;
681
+ }
682
+ if (method === "turn/completed" || method === "thread/turnComplete" || method === "turn/complete") {
683
+ log.info({ sessionId: cp.id, method }, "codex turn complete");
684
+ // Token usage is delivered via the separate
685
+ // `thread/tokenUsageUpdated` notification — the `turn` payload here
686
+ // does NOT carry a `usage` field per the v2 schema.
687
+ cp.emit("turn_complete", {
688
+ backendSessionId: cp.backendSessionId,
689
+ });
690
+ // Intentionally do NOT clear cp.assistantTextSeen here. A trailing
691
+ // item/completed (channel reorder, codex internal buffering) against
692
+ // an empty accumulator would re-emit the full agentMessage text and
693
+ // bring back the original duplication bug. Unbounded growth from
694
+ // interrupted turns that never fire item/completed is bounded by
695
+ // ASSISTANT_TEXT_SEEN_MAX + FIFO eviction in the delta branch below.
696
+ return;
697
+ }
698
+ if (method === "turn/started") {
699
+ log.info({ sessionId: cp.id }, "codex turn started");
700
+ return;
701
+ }
702
+ // Dedupe assistant text: codex streams chunks via
703
+ // `item/agentMessage/delta` AND then re-sends the same full text via
704
+ // `item/completed{ item.type: "agentMessage" }`. Without dedupe the UI
705
+ // events-reducer (which appends every `text` event to the open message)
706
+ // renders the message twice.
707
+ //
708
+ // Strategy: accumulate per-itemId delta text; on completed-agentMessage
709
+ // emit only the suffix the deltas didn't already cover. delta is treated
710
+ // as an optional streaming hint; completed is the source of truth.
711
+ if (method === "item/agentMessage/delta") {
712
+ const p = (params ?? {});
713
+ if (typeof p.itemId === "string" && typeof p.delta === "string") {
714
+ const prev = cp.assistantTextSeen.get(p.itemId) ?? "";
715
+ // FIFO eviction guard: cap the map so an interrupted turn that
716
+ // never fires item/completed for an open agentMessage can't leak
717
+ // unbounded entries over a long-lived session. Map iteration is
718
+ // insertion order, so deleting the first key drops the oldest.
719
+ // Only check size when inserting a NEW key — updates of an
720
+ // existing key don't grow the map.
721
+ if (!cp.assistantTextSeen.has(p.itemId) &&
722
+ cp.assistantTextSeen.size >= ASSISTANT_TEXT_SEEN_MAX) {
723
+ const oldest = cp.assistantTextSeen.keys().next().value;
724
+ if (oldest !== undefined)
725
+ cp.assistantTextSeen.delete(oldest);
726
+ }
727
+ cp.assistantTextSeen.set(p.itemId, prev + p.delta);
728
+ }
729
+ else {
730
+ // Telemetry: surface protocol drift instead of silently degrading
731
+ // to "no dedupe → full text duplicate" on the matching completed.
732
+ log.warn({ sessionId: cp.id, params: p }, "codex agentMessage delta: missing itemId/delta — dedupe will not apply to this item");
733
+ }
734
+ // Fall through to mapper, which emits {kind:"assistant_text", text:delta}.
735
+ }
736
+ else if (method === "item/completed") {
737
+ const p = (params ?? {});
738
+ const item = p.item;
739
+ if (item?.type === "agentMessage") {
740
+ // Always short-circuit for agentMessage — never fall through to
741
+ // mapper. Mapper has no per-itemId delta history and would re-emit
742
+ // the full text against an empty accumulator, reactivating the
743
+ // duplication bug under protocol drift (e.g. codex changes itemId
744
+ // type).
745
+ if (typeof item.id !== "string" || typeof item.text !== "string") {
746
+ log.warn({ sessionId: cp.id, item }, "codex agentMessage completed: malformed item.id/text — dropping to avoid duplicate emission");
747
+ return;
748
+ }
749
+ const seen = cp.assistantTextSeen.get(item.id) ?? "";
750
+ cp.assistantTextSeen.delete(item.id);
751
+ const full = item.text;
752
+ let suffix;
753
+ if (full === seen) {
754
+ // Deltas already covered the full text — drop the redundant frame.
755
+ return;
756
+ }
757
+ else if (seen.length > 0 && full.startsWith(seen)) {
758
+ // Deltas covered a prefix; emit only what's left.
759
+ suffix = full.slice(seen.length);
760
+ }
761
+ else {
762
+ // No deltas seen (resume / cached / short-circuit) OR the final
763
+ // text diverges from the streamed prefix (codex revised the
764
+ // response). In both cases the full completed text is the source
765
+ // of truth; emitting it preserves correctness even if the UI ends
766
+ // up showing both the partial draft and the final answer.
767
+ suffix = full;
768
+ if (seen.length > 0) {
769
+ log.warn({ sessionId: cp.id, itemId: item.id, seenLen: seen.length, fullLen: full.length }, "codex agentMessage: completed text diverges from accumulated deltas — emitting full");
770
+ }
771
+ }
772
+ if (suffix.length === 0)
773
+ return;
774
+ const out = { kind: "assistant_text", text: suffix };
775
+ this.logBackendOutput(cp.id, method, out);
776
+ cp.emit("output", out);
777
+ return;
778
+ }
779
+ // Non-agentMessage item/completed (commandExecution, fileChange,
780
+ // mcpToolCall, …) — fall through to mapper.
781
+ }
782
+ const out = mapCodexFrame({ method, params });
783
+ this.logBackendOutput(cp.id, method, out);
784
+ cp.emit("output", out);
785
+ }
786
+ /**
787
+ * Selective notification logging. The previous catch-all `log.debug("codex
788
+ * notification")` fired for every streaming `item/agentMessage/delta` chunk
789
+ * (many per turn), drowning the actually useful events. Instead, log only
790
+ * structural events at INFO and surface unrecognised methods as WARN so the
791
+ * `{kind:"raw"}` degradation isn't silent.
792
+ */
793
+ logBackendOutput(sessionId, method, out) {
794
+ // Streaming text deltas — too high-frequency to log per-chunk.
795
+ if (method === "item/agentMessage/delta")
796
+ return;
797
+ // Other token-usage / progress notifications without a structural meaning.
798
+ if (method === "thread/tokenUsageUpdated")
799
+ return;
800
+ switch (out.kind) {
801
+ case "tool_use":
802
+ log.info({ sessionId, callId: out.callId, tool: out.toolName, input: preview(out.input, 120) }, "codex tool_use");
803
+ return;
804
+ case "tool_result":
805
+ log.info({ sessionId, callId: out.callId, isError: out.isError, output: preview(out.output, 120) }, "codex tool_result");
806
+ return;
807
+ case "assistant_text":
808
+ log.debug({ sessionId, len: out.text.length, text: preview(out.text, 120) }, "codex assistant_text");
809
+ return;
810
+ case "raw":
811
+ log.warn({ sessionId, method, payload: preview(out.payload, 200) }, "codex unhandled frame");
812
+ return;
813
+ }
814
+ }
815
+ async stop(process) {
816
+ await process.kill();
817
+ }
818
+ send(process, input) {
819
+ if (!(process instanceof CodexProcess)) {
820
+ log.warn({ sessionId: process?.id, len: input.length }, "codex send: not a CodexProcess — input dropped");
821
+ return;
822
+ }
823
+ if (!process.rpc) {
824
+ log.warn({ sessionId: process.id, len: input.length }, "codex send: no rpc — input dropped");
825
+ return;
826
+ }
827
+ if (!process.backendSessionId) {
828
+ // Handshake still in flight: queue the input and let `handshake()`
829
+ // flush it once `thread/start` resolves. This avoids silent drops when
830
+ // a hub author moves from claude-adapter (no handshake) to codex.
831
+ process.pendingInputs.push(input);
832
+ log.info({ sessionId: process.id, queueDepth: process.pendingInputs.length, len: input.length }, "codex send: handshake pending, queued");
833
+ return;
834
+ }
835
+ log.info({ sessionId: process.id, threadId: process.backendSessionId, len: input.length }, "codex send: dispatching turn/start");
836
+ this.dispatchTurn(process, input);
837
+ }
838
+ dispatchTurn(process, input) {
839
+ if (!process.rpc || !process.backendSessionId)
840
+ return;
841
+ // codex v2: turn/start is a *request* (server returns a result when the
842
+ // turn is queued). We fire-and-forget here because the agent loop reacts
843
+ // to async `turn/completed` / `item/...` notifications, not to the
844
+ // request's result envelope.
845
+ void process.rpc
846
+ .request("turn/start", {
847
+ threadId: process.backendSessionId,
848
+ input: [{ type: "text", text: input }],
849
+ })
850
+ .then(() => {
851
+ log.info({ sessionId: process.id, threadId: process.backendSessionId }, "codex turn/start: accepted by server");
852
+ })
853
+ .catch((err) => {
854
+ log.warn({ err, sessionId: process.id }, "turn/start failed");
855
+ process.emit("error", err);
856
+ });
857
+ }
858
+ onOutput(process, handler) {
859
+ if (process instanceof CodexProcess)
860
+ process.on("output", handler);
861
+ }
862
+ onPermissionRequest(process, handler) {
863
+ if (process instanceof CodexProcess)
864
+ process.on("permission_request", handler);
865
+ }
866
+ async respondToPermission(process, decision) {
867
+ if (!(process instanceof CodexProcess))
868
+ return;
869
+ const key = process.pendingApprovals.get(decision.callId);
870
+ if (key === undefined) {
871
+ log.warn({ callId: decision.callId }, "respondToPermission: no pending approval");
872
+ return;
873
+ }
874
+ const family = process.approvalMethods.get(decision.callId) ?? "legacy";
875
+ const resolver = process.approvalResolvers.get(key);
876
+ process.pendingApprovals.delete(decision.callId);
877
+ process.approvalMethods.delete(decision.callId);
878
+ process.approvalResolvers.delete(key);
879
+ const reply = buildApprovalReply(family, decision.decision);
880
+ resolver?.(reply);
881
+ }
882
+ onTurnComplete(process, handler) {
883
+ if (process instanceof CodexProcess)
884
+ process.on("turn_complete", handler);
885
+ }
886
+ onSessionStarted(process, handler) {
887
+ if (process instanceof CodexProcess)
888
+ process.on("session_started", handler);
889
+ }
890
+ onError(process, handler) {
891
+ if (process instanceof CodexProcess)
892
+ process.on("error", handler);
893
+ }
894
+ onExit(process, handler) {
895
+ if (process instanceof CodexProcess)
896
+ process.on("exit", handler);
897
+ }
898
+ async getManifest() {
899
+ const det = await this.detect();
900
+ return {
901
+ kind: "codex",
902
+ installed: det.installed,
903
+ binaryPath: det.binaryPath,
904
+ version: det.version,
905
+ unavailableReason: det.reason,
906
+ models: det.installed ? CODEX_MODELS : [],
907
+ modes: det.installed ? CODEX_MODES : [],
908
+ };
909
+ }
910
+ }
911
+ //# sourceMappingURL=codex-adapter.js.map