@idl3/claude-control 1.0.1 → 1.3.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.
package/lib/codex.js ADDED
@@ -0,0 +1,597 @@
1
+ // lib/codex.js — Codex CLI support (flat named-export module).
2
+ //
3
+ // Handles transcript discovery from ~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl,
4
+ // JSONL line parsing for the Codex event stream schema, TUI status extraction,
5
+ // and approval-modal detection/answering.
6
+ //
7
+ // This module must NOT import from lib/agents/index.js.
8
+
9
+ import fs from 'node:fs/promises';
10
+ import path from 'node:path';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // inputSummary — intentionally duplicated from lib/transcript.js (not exported
14
+ // there). Produces a short human-readable summary of a tool_use input object.
15
+ // Keep in sync with the canonical copy in lib/transcript.js:103-115.
16
+ // ---------------------------------------------------------------------------
17
+ function inputSummary(input) {
18
+ if (input == null) return '';
19
+ let s;
20
+ try {
21
+ s = JSON.stringify(input);
22
+ } catch {
23
+ s = String(input);
24
+ }
25
+ // Collapse newlines/tabs to spaces, then truncate.
26
+ s = s.replace(/[\r\n\t]+/g, ' ');
27
+ if (s.length > 120) s = s.slice(0, 117) + '...';
28
+ return s;
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // readHead — read the first maxBytes of a file without loading it all.
33
+ // Mirrors the readTail helper in lib/sessions.js but reads from offset 0.
34
+ // Never throws — returns null on any error.
35
+ // ---------------------------------------------------------------------------
36
+ async function readHead(filePath, maxBytes) {
37
+ let fh;
38
+ try {
39
+ fh = await fs.open(filePath, 'r');
40
+ const stat = await fh.stat();
41
+ const size = stat.size;
42
+ if (size === 0) return Buffer.alloc(0);
43
+ const readSize = Math.min(size, maxBytes);
44
+ const buf = Buffer.allocUnsafe(readSize);
45
+ const { bytesRead } = await fh.read(buf, 0, readSize, 0);
46
+ return buf.subarray(0, bytesRead);
47
+ } catch {
48
+ return null;
49
+ } finally {
50
+ if (fh) await fh.close().catch(() => {});
51
+ }
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // readTail — read the LAST maxBytes of a file without loading it all.
56
+ // Mirrors readHead but reads from offset max(0, size - maxBytes).
57
+ // Never throws — returns null on any error.
58
+ // ---------------------------------------------------------------------------
59
+ async function readTail(filePath, maxBytes) {
60
+ let fh;
61
+ try {
62
+ fh = await fs.open(filePath, 'r');
63
+ const stat = await fh.stat();
64
+ const size = stat.size;
65
+ if (size === 0) return Buffer.alloc(0);
66
+ const readSize = Math.min(size, maxBytes);
67
+ const offset = Math.max(0, size - maxBytes);
68
+ const buf = Buffer.allocUnsafe(readSize);
69
+ const { bytesRead } = await fh.read(buf, 0, readSize, offset);
70
+ return buf.subarray(0, bytesRead);
71
+ } catch {
72
+ return null;
73
+ } finally {
74
+ if (fh) await fh.close().catch(() => {});
75
+ }
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // extractUsageFromTail — given a text blob, scan lines from the END and
80
+ // return the newest token_count event_msg's primary rate-limit data.
81
+ //
82
+ // Returns { usagePct, usageWindowMin } where usagePct is the primary
83
+ // used_percent (number) and usageWindowMin is the primary window_minutes
84
+ // (number). Returns null if no valid token_count line is found.
85
+ // ---------------------------------------------------------------------------
86
+ export function extractUsageFromTail(text) {
87
+ if (!text) return null;
88
+ const lines = text.split('\n');
89
+ // Iterate from the end — newest first.
90
+ for (let i = lines.length - 1; i >= 0; i--) {
91
+ const line = lines[i].trim();
92
+ if (!line) continue;
93
+ let rec;
94
+ try {
95
+ rec = JSON.parse(line);
96
+ } catch {
97
+ continue;
98
+ }
99
+ if (rec.type !== 'event_msg') continue;
100
+ if (rec.payload?.type !== 'token_count') continue;
101
+ const primary = rec.payload?.rate_limits?.primary;
102
+ if (primary == null) continue;
103
+ const usagePct = primary.used_percent;
104
+ const usageWindowMin = primary.window_minutes;
105
+ if (typeof usagePct !== 'number' || typeof usageWindowMin !== 'number') continue;
106
+ return { usagePct, usageWindowMin };
107
+ }
108
+ return null;
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // matchesProcess
113
+ //
114
+ // A pane is a Codex session when its process title is exactly "codex",
115
+ // a path ending in "/codex", or "codex" followed by a space (with flags).
116
+ // Does NOT match "codex-control" or version strings like "2.1.162".
117
+ // ---------------------------------------------------------------------------
118
+ export function matchesProcess(cmd) {
119
+ const c = String(cmd || '').trim();
120
+ return c === 'codex' || /(^|\/)codex$/.test(c) || /^codex\s/.test(c);
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // parseCodexRecord
125
+ //
126
+ // Parse one JSONL line from a Codex rollout file into a NormalizedMessage,
127
+ // or null when the line is not a displayable message record.
128
+ //
129
+ // CRITICAL de-dup: `event_msg/*` records duplicate content already in
130
+ // `response_item/*` — return null for all event_msg types to prevent
131
+ // double-render. Also null for turn_context, session_meta, and unknown types.
132
+ // ---------------------------------------------------------------------------
133
+ export function parseCodexRecord(line) {
134
+ const trimmed = line.trim();
135
+ if (!trimmed) return null;
136
+
137
+ let record;
138
+ try {
139
+ record = JSON.parse(trimmed);
140
+ } catch {
141
+ return null;
142
+ }
143
+
144
+ const t = record.type;
145
+ const p = record.payload || {};
146
+ const ts = record.timestamp ?? null;
147
+
148
+ // All event_msg, turn_context, and session_meta records are null.
149
+ if (t !== 'response_item') return null;
150
+
151
+ const subType = p.type;
152
+
153
+ // --- response_item/message ---
154
+ if (subType === 'message') {
155
+ const rawRole = p.role;
156
+ // developer = system/permissions injection; null it.
157
+ if (rawRole === 'developer') return null;
158
+ let role;
159
+ if (rawRole === 'assistant') role = 'assistant';
160
+ else if (rawRole === 'user') role = 'user';
161
+ else return null;
162
+
163
+ const blocks = [];
164
+ if (Array.isArray(p.content)) {
165
+ for (const item of p.content) {
166
+ const text = item?.text;
167
+ if (typeof text === 'string' && text) {
168
+ blocks.push({ kind: 'text', text });
169
+ }
170
+ }
171
+ }
172
+ if (blocks.length === 0) return null;
173
+
174
+ return {
175
+ uuid: record.id ?? p.id ?? null,
176
+ role,
177
+ ts,
178
+ blocks,
179
+ rawType: 'message',
180
+ };
181
+ }
182
+
183
+ // --- response_item/reasoning (encrypted) ---
184
+ if (subType === 'reasoning') {
185
+ return {
186
+ uuid: record.id ?? null,
187
+ role: 'assistant',
188
+ ts,
189
+ blocks: [{ kind: 'thinking', text: '[reasoning encrypted]' }],
190
+ rawType: 'reasoning',
191
+ };
192
+ }
193
+
194
+ // --- response_item/function_call (exec_command, etc.) ---
195
+ if (subType === 'function_call') {
196
+ let parsedArgs;
197
+ try {
198
+ parsedArgs = JSON.parse(p.arguments);
199
+ } catch {
200
+ parsedArgs = { raw: p.arguments };
201
+ }
202
+ return {
203
+ uuid: p.call_id ?? null,
204
+ role: 'assistant',
205
+ ts,
206
+ blocks: [
207
+ {
208
+ kind: 'tool_use',
209
+ id: p.call_id,
210
+ name: p.name || 'exec_command',
211
+ input: parsedArgs,
212
+ inputSummary: inputSummary(parsedArgs),
213
+ },
214
+ ],
215
+ rawType: 'function_call',
216
+ };
217
+ }
218
+
219
+ // --- response_item/function_call_output ---
220
+ if (subType === 'function_call_output') {
221
+ return {
222
+ uuid: p.call_id != null ? p.call_id + '-out' : null,
223
+ role: 'user',
224
+ ts,
225
+ blocks: [
226
+ {
227
+ kind: 'tool_result',
228
+ forId: p.call_id,
229
+ text: String(p.output ?? ''),
230
+ isError: false,
231
+ },
232
+ ],
233
+ rawType: 'function_call_output',
234
+ };
235
+ }
236
+
237
+ // --- response_item/custom_tool_call (apply_patch) ---
238
+ if (subType === 'custom_tool_call') {
239
+ // p.input is the raw patch text string.
240
+ const patchInput = { patch: p.input, status: p.status };
241
+ return {
242
+ uuid: p.call_id ?? null,
243
+ role: 'assistant',
244
+ ts,
245
+ blocks: [
246
+ {
247
+ kind: 'tool_use',
248
+ id: p.call_id,
249
+ name: p.name || 'apply_patch',
250
+ input: patchInput,
251
+ inputSummary: inputSummary(patchInput),
252
+ },
253
+ ],
254
+ rawType: 'custom_tool_call',
255
+ };
256
+ }
257
+
258
+ // --- response_item/custom_tool_call_output ---
259
+ if (subType === 'custom_tool_call_output') {
260
+ return {
261
+ uuid: p.call_id != null ? p.call_id + '-out' : null,
262
+ role: 'user',
263
+ ts,
264
+ blocks: [
265
+ {
266
+ kind: 'tool_result',
267
+ forId: p.call_id,
268
+ text: String(p.output ?? ''),
269
+ isError: false,
270
+ },
271
+ ],
272
+ rawType: 'custom_tool_call_output',
273
+ };
274
+ }
275
+
276
+ // All other response_item subtypes → null.
277
+ return null;
278
+ }
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // buildTranscriptIndex
282
+ //
283
+ // Scan recent Codex session date directories for rollout-*.jsonl files.
284
+ // Checks today and yesterday (by local date) to handle sessions that
285
+ // started near midnight.
286
+ //
287
+ // The `now` parameter is injected for testability — callers that do not
288
+ // care about the clock may omit it and get `new Date()`.
289
+ // ---------------------------------------------------------------------------
290
+ export async function buildTranscriptIndex({ codexSessionsRoot }, now = new Date()) {
291
+ const index = { byCwd: new Map() };
292
+
293
+ if (!codexSessionsRoot) return index;
294
+
295
+ // Compute the date dir path for a given Date using LOCAL date parts
296
+ // (Codex CLI uses local wall-clock date for its session directory names).
297
+ function datePath(d) {
298
+ const yyyy = String(d.getFullYear());
299
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
300
+ const dd = String(d.getDate()).padStart(2, '0');
301
+ return path.join(codexSessionsRoot, yyyy, mm, dd);
302
+ }
303
+
304
+ const today = datePath(now);
305
+ const yesterday = datePath(new Date(now.getTime() - 24 * 3600 * 1000));
306
+ // Dedup if equal (e.g. right at midnight boundary)
307
+ const dateDirs = today === yesterday ? [today] : [today, yesterday];
308
+
309
+ for (const dateDir of dateDirs) {
310
+ let files;
311
+ try {
312
+ files = await fs.readdir(dateDir);
313
+ } catch {
314
+ // Missing dir is normal — skip silently.
315
+ continue;
316
+ }
317
+
318
+ const rollouts = files.filter((f) => /^rollout-.*\.jsonl$/.test(f));
319
+
320
+ await Promise.all(
321
+ rollouts.map(async (filename) => {
322
+ const filePath = path.join(dateDir, filename);
323
+ try {
324
+ // Stat for mtime.
325
+ const stat = await fs.stat(filePath);
326
+ const mtime = stat.mtimeMs;
327
+
328
+ // Head-read only the first 65536 bytes to extract session_meta.
329
+ const buf = await readHead(filePath, 65536);
330
+ if (!buf || buf.length === 0) return;
331
+
332
+ const text = buf.toString('utf8');
333
+ const firstLine = text.split('\n')[0];
334
+ if (!firstLine || !firstLine.trim()) return;
335
+
336
+ let record;
337
+ try {
338
+ record = JSON.parse(firstLine.trim());
339
+ } catch {
340
+ return;
341
+ }
342
+
343
+ if (record.type !== 'session_meta') return;
344
+ const payload = record.payload || {};
345
+ if (typeof payload.cwd !== 'string' || !payload.cwd) return;
346
+
347
+ const lastActivity = record.timestamp ?? null;
348
+ const lastActivityMs = lastActivity ? (Date.parse(lastActivity) || null) : null;
349
+
350
+ // Tail-read for rate-limit usage (token_count events appear throughout).
351
+ let usagePct = null;
352
+ let usageWindowMin = null;
353
+ const tailBuf = await readTail(filePath, 32768);
354
+ if (tailBuf && tailBuf.length > 0) {
355
+ const tailText = tailBuf.toString('utf8');
356
+ const usage = extractUsageFromTail(tailText);
357
+ if (usage) {
358
+ usagePct = usage.usagePct;
359
+ usageWindowMin = usage.usageWindowMin;
360
+ }
361
+ }
362
+
363
+ const discovered = {
364
+ cwd: payload.cwd,
365
+ sessionId: payload.id ?? null,
366
+ lastActivity,
367
+ lastActivityMs,
368
+ // session_meta has model_provider but no concrete model id.
369
+ model: null,
370
+ aiTitle: null,
371
+ customTitle: null,
372
+ transcriptPath: filePath,
373
+ mtime,
374
+ transcriptPending: false,
375
+ pendingToolUseId: null,
376
+ pendingQuestion: null,
377
+ agentType: 'codex',
378
+ usagePct,
379
+ usageWindowMin,
380
+ };
381
+
382
+ // Newest mtime wins per cwd.
383
+ const existing = index.byCwd.get(payload.cwd);
384
+ if (!existing || mtime > existing.mtime) {
385
+ index.byCwd.set(payload.cwd, discovered);
386
+ }
387
+ } catch {
388
+ // Per-file resilience: skip malformed or unreadable files.
389
+ }
390
+ }),
391
+ );
392
+ }
393
+
394
+ // Return byCwd only — no byDir key. sessions.js merge loop guards `if (byDir)`.
395
+ return index;
396
+ }
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // detectPendingFromCapture
400
+ //
401
+ // Detect a Codex approval modal from a capture-pane dump.
402
+ // (P2 deferred wiring — implemented and tested here, not yet called by
403
+ // shared code. detectPendingFromCapture is the Codex-only pending channel.)
404
+ //
405
+ // Returns a shape describing the modal kind, header text, and available
406
+ // options.
407
+ //
408
+ // Headings recognized:
409
+ // "Would you like to run the following command?" → 'exec_command'
410
+ // "Would you like to make the following edits?" → 'apply_patch'
411
+ // "Do you trust the contents of this directory?" → 'directory_trust'
412
+ // ---------------------------------------------------------------------------
413
+ export function detectPendingFromCapture(capture) {
414
+ const noModal = { transcriptPending: false, pendingKind: null, header: null, options: [] };
415
+ if (!capture) return noModal;
416
+
417
+ const lines = capture.split('\n');
418
+
419
+ const headings = [
420
+ { text: 'Would you like to run the following command?', kind: 'exec_command' },
421
+ { text: 'Would you like to make the following edits?', kind: 'apply_patch' },
422
+ { text: 'Do you trust the contents of this directory?', kind: 'directory_trust' },
423
+ ];
424
+
425
+ let pendingKind = null;
426
+ let header = null;
427
+ let headingIdx = -1;
428
+
429
+ for (let i = 0; i < lines.length; i++) {
430
+ const trimmed = lines[i].trim();
431
+ for (const h of headings) {
432
+ if (trimmed === h.text) {
433
+ pendingKind = h.kind;
434
+ header = trimmed;
435
+ headingIdx = i;
436
+ break;
437
+ }
438
+ }
439
+ if (headingIdx !== -1) break;
440
+ }
441
+
442
+ if (!pendingKind) return noModal;
443
+
444
+ // Scan lines after the heading for option lines.
445
+ // Option line regex: /^\s*[›\s]\s*(\d+)\.\s+(.+?)(?:\s+\(([^)]+)\))?\s*$/
446
+ // U+203A = ›
447
+ const optionLineRegex = /^\s*[›\s]\s*(\d+)\.\s+(.+?)(?:\s+\(([^)]+)\))?\s*$/;
448
+ const footerHints = ['Press enter to confirm or esc to cancel', 'Press enter to continue'];
449
+
450
+ const options = [];
451
+ let seenOption = false;
452
+
453
+ for (let i = headingIdx + 1; i < lines.length; i++) {
454
+ const raw = lines[i];
455
+ const trimmed = raw.trim();
456
+
457
+ // Check footer hint — stop collecting after it.
458
+ if (footerHints.includes(trimmed)) break;
459
+
460
+ const m = optionLineRegex.exec(raw);
461
+ if (m) {
462
+ seenOption = true;
463
+ options.push({
464
+ n: Number(m[1]),
465
+ label: m[2].trim(),
466
+ shortcut: m[3] || null,
467
+ // Highlighted if the raw line contains the › character (U+203A).
468
+ highlighted: raw.includes('›'),
469
+ });
470
+ } else if (seenOption && trimmed && !m) {
471
+ // First non-blank, non-option line after at least one option was captured.
472
+ break;
473
+ }
474
+ }
475
+
476
+ if (options.length === 0) return noModal;
477
+
478
+ return { transcriptPending: true, pendingKind, header, options };
479
+ }
480
+
481
+ // ---------------------------------------------------------------------------
482
+ // buildAnswerProgram
483
+ //
484
+ // Build the keystroke token array that answers the current Codex approval
485
+ // modal. Output is compatible with lib/answer.js tmux send-keys token format.
486
+ //
487
+ // Selections: first element of the first selection is a digit string or
488
+ // option label. Falls back to the highlighted option, then option 1.
489
+ // ---------------------------------------------------------------------------
490
+ export function buildAnswerProgram(pending, selections) {
491
+ const opts = pending?.options || [];
492
+ const sel = selections?.[0]?.[0];
493
+ let digit = null;
494
+ if (sel != null) {
495
+ if (/^\d+$/.test(String(sel))) digit = String(sel);
496
+ else {
497
+ const m = opts.find((o) => o.label === sel);
498
+ if (m) digit = String(m.n);
499
+ }
500
+ }
501
+ if (digit == null) {
502
+ const hl = opts.find((o) => o.highlighted);
503
+ digit = hl ? String(hl.n) : '1';
504
+ }
505
+ return [digit, 'Enter'];
506
+ }
507
+
508
+ // ---------------------------------------------------------------------------
509
+ // codexPendingToFrontend
510
+ //
511
+ // Map detectPendingFromCapture's output to the PanePrompt shape that
512
+ // parsePanePrompt returns, so the existing prompt-frame UI can render it
513
+ // with zero frontend type/component changes.
514
+ //
515
+ // Returns null when there is no active modal or no options.
516
+ // ---------------------------------------------------------------------------
517
+ export function codexPendingToFrontend(pending) {
518
+ if (!pending || !pending.transcriptPending || !pending.options || pending.options.length === 0) {
519
+ return null;
520
+ }
521
+ return {
522
+ question: pending.header,
523
+ options: pending.options.map((o) => ({
524
+ key: String(o.n),
525
+ label: o.label,
526
+ selected: !!o.highlighted,
527
+ })),
528
+ // Do NOT set multiSelect — Codex approvals are single-select radio.
529
+ };
530
+ }
531
+
532
+ // ---------------------------------------------------------------------------
533
+ // parseCodexPrompt
534
+ //
535
+ // Thin combinator: detect + map in one call. Used by startPromptPoller.
536
+ // ---------------------------------------------------------------------------
537
+ export function parseCodexPrompt(capture) {
538
+ return codexPendingToFrontend(detectPendingFromCapture(capture));
539
+ }
540
+
541
+ // ---------------------------------------------------------------------------
542
+ // buildSpawnCommand
543
+ //
544
+ // Build the spawn command for a new Codex session.
545
+ // Codex requires -C <cwd> to set the working directory (tmux -c alone is
546
+ // insufficient because Codex reads cwd from its own flag, not the shell env).
547
+ // ---------------------------------------------------------------------------
548
+ export function buildSpawnCommand({ cwd, bin = 'codex' } = {}) {
549
+ return { bin, args: ['-C', cwd] };
550
+ }
551
+
552
+ // ---------------------------------------------------------------------------
553
+ // parseTuiStatus
554
+ //
555
+ // Parse model name from a Codex TUI header capture.
556
+ // The header contains: │ model: gpt-5.5 xhigh fast /model to change │
557
+ // Captures model + effort token (e.g. "gpt-5.5 xhigh") so the rail shows
558
+ // both the model name and the reasoning effort setting.
559
+ // ctx% is not shown in the Codex TUI.
560
+ // ---------------------------------------------------------------------------
561
+ export function parseTuiStatus(capture) {
562
+ const text = capture || '';
563
+ // Match model name + optional effort token (e.g. "gpt-5.5 xhigh").
564
+ // The header line looks like: "model: gpt-5.5 xhigh fast /model to change"
565
+ // We capture the first token (model) and an optional second token (effort),
566
+ // stopping before known non-effort tokens: "fast", "slow", "/model".
567
+ const EFFORT_TOKENS = new Set(['xhigh', 'high', 'medium', 'low']);
568
+ let model = null;
569
+ // (1) Top header box (visible at session start, before output scrolls it off):
570
+ // "model: gpt-5.5 xhigh fast /model to change"
571
+ const header = /model:\s+(\S+)(?:\s+(\S+))?/.exec(text);
572
+ if (header) {
573
+ model = EFFORT_TOKENS.has((header[2] || '').toLowerCase())
574
+ ? `${header[1]} ${header[2]}`
575
+ : header[1];
576
+ }
577
+ // (2) Persistent footer status line (always at the bottom, which is what the
578
+ // 8-line ctx-poll capture actually sees): "gpt-5.5 xhigh Fast · <cwd>".
579
+ // Capture model + optional effort, then the speed word, then the " · " cwd
580
+ // separator. Used only when the header isn't in view.
581
+ if (!model) {
582
+ const footer = /^\s*([\w.\-]+)(?:\s+(xhigh|high|medium|low))?\s+\S+\s+·\s/m.exec(text);
583
+ if (footer) model = footer[2] ? `${footer[1]} ${footer[2]}` : footer[1];
584
+ }
585
+ // Codex prints "• Working (<N>s • esc to interrupt)" while generating.
586
+ const working = /esc to interrupt/.test(text) || /Working \(/.test(text);
587
+ return { ctxPct: null, model, working };
588
+ }
589
+
590
+ // ---------------------------------------------------------------------------
591
+ // prettyModel
592
+ //
593
+ // Codex model ids are already human-readable (e.g. "gpt-5.5").
594
+ // ---------------------------------------------------------------------------
595
+ export function prettyModel(modelId) {
596
+ return modelId || null;
597
+ }
package/lib/config.js CHANGED
@@ -41,6 +41,7 @@ function configPath() {
41
41
  }
42
42
 
43
43
  const LAUNCH_MAX = 500;
44
+ const CODEX_LAUNCH_MAX = 500;
44
45
  const OPTIMIZE_MODEL_MAX = 200;
45
46
  const CLAUDE_BIN_MAX = 500;
46
47
  const MLX_MODEL_MAX = 200;
@@ -59,6 +60,8 @@ function defaults() {
59
60
  defaultCwd: os.homedir(),
60
61
  optimizeModel: recommendClaudeModel(),
61
62
  claudeBin: '',
63
+ codexLaunchCommand: 'codex',
64
+ codexBin: '',
62
65
  // Prompt-enhancer backend: 'mlx' (local model → claude → rules chain),
63
66
  // 'claude' (claude -p → rules), or 'rules' (deterministic, offline).
64
67
  optimizeBackend: 'mlx',
@@ -76,7 +79,7 @@ function defaults() {
76
79
  * Read the persisted config, merged over defaults. Never throws — a missing,
77
80
  * empty, or corrupt file falls back to defaults. Only known keys are surfaced.
78
81
  *
79
- * @returns {{ launchCommand: string, defaultCwd: string, optimizeModel: string, claudeBin: string, optimizeBackend: string, mlxModel: string, transcriptFontSize: number, externalFontSize: number }}
82
+ * @returns {{ launchCommand: string, defaultCwd: string, optimizeModel: string, claudeBin: string, codexLaunchCommand: string, codexBin: string, optimizeBackend: string, mlxModel: string, transcriptFontSize: number, externalFontSize: number }}
80
83
  */
81
84
  export function readConfig() {
82
85
  const base = defaults();
@@ -111,6 +114,14 @@ export function readConfig() {
111
114
  typeof parsed.claudeBin === 'string'
112
115
  ? parsed.claudeBin
113
116
  : base.claudeBin,
117
+ codexLaunchCommand:
118
+ typeof parsed.codexLaunchCommand === 'string' && parsed.codexLaunchCommand.trim()
119
+ ? parsed.codexLaunchCommand
120
+ : base.codexLaunchCommand,
121
+ codexBin:
122
+ typeof parsed.codexBin === 'string'
123
+ ? parsed.codexBin
124
+ : base.codexBin,
114
125
  optimizeBackend:
115
126
  typeof parsed.optimizeBackend === 'string' &&
116
127
  OPTIMIZE_BACKENDS.includes(parsed.optimizeBackend)
@@ -142,8 +153,8 @@ export function readConfig() {
142
153
  * - claudeBin: string ≤500 chars; empty string is allowed (means auto-resolve).
143
154
  * Existence is NOT verified at write time (path may differ across hosts).
144
155
  *
145
- * @param {{ launchCommand?: unknown, defaultCwd?: unknown, optimizeModel?: unknown, claudeBin?: unknown }} partial
146
- * @returns {{ launchCommand: string, defaultCwd: string, optimizeModel: string, claudeBin: string, optimizeBackend: string, mlxModel: string }} the saved config
156
+ * @param {{ launchCommand?: unknown, defaultCwd?: unknown, optimizeModel?: unknown, claudeBin?: unknown, codexLaunchCommand?: unknown, codexBin?: unknown }} partial
157
+ * @returns {{ launchCommand: string, defaultCwd: string, optimizeModel: string, claudeBin: string, codexLaunchCommand: string, codexBin: string, optimizeBackend: string, mlxModel: string }} the saved config
147
158
  */
148
159
  export function writeConfig(partial = {}) {
149
160
  const current = readConfig();
@@ -199,6 +210,28 @@ export function writeConfig(partial = {}) {
199
210
  next.claudeBin = bin;
200
211
  }
201
212
 
213
+ if (partial.codexLaunchCommand !== undefined) {
214
+ const cmd = partial.codexLaunchCommand;
215
+ if (typeof cmd !== 'string' || !cmd.trim()) {
216
+ throw new Error('codexLaunchCommand must be a non-empty string');
217
+ }
218
+ if (cmd.length > CODEX_LAUNCH_MAX) {
219
+ throw new Error(`codexLaunchCommand must be ≤${CODEX_LAUNCH_MAX} characters`);
220
+ }
221
+ next.codexLaunchCommand = cmd;
222
+ }
223
+
224
+ if (partial.codexBin !== undefined) {
225
+ const bin = partial.codexBin;
226
+ if (typeof bin !== 'string') {
227
+ throw new Error('codexBin must be a string');
228
+ }
229
+ if (bin.length > CLAUDE_BIN_MAX) {
230
+ throw new Error(`codexBin must be ≤${CLAUDE_BIN_MAX} characters`);
231
+ }
232
+ next.codexBin = bin;
233
+ }
234
+
202
235
  if (partial.optimizeBackend !== undefined) {
203
236
  const b = partial.optimizeBackend;
204
237
  if (typeof b !== 'string' || !OPTIMIZE_BACKENDS.includes(b)) {