@idl3/claude-control 0.4.1 → 1.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.
package/lib/auth.js CHANGED
@@ -15,6 +15,27 @@
15
15
  // header, so it keeps its own `?token=` mechanism (handled in server.js's
16
16
  // /term/ branch, not here).
17
17
 
18
+ import crypto from 'node:crypto';
19
+
20
+ /**
21
+ * Constant-time token equality. Digests both sides with SHA-256 before calling
22
+ * `crypto.timingSafeEqual` so the buffers are always the same length regardless
23
+ * of the candidate string (timingSafeEqual throws on length mismatch).
24
+ *
25
+ * Returns `false` — never throws — for null/undefined/empty candidates, which
26
+ * preserves the "open server when no token configured" contract used by every
27
+ * call site (they gate on `!configToken` before calling this).
28
+ *
29
+ * @param {string|null|undefined} candidate
30
+ * @param {string|null|undefined} expected
31
+ * @returns {boolean}
32
+ */
33
+ export function safeTokenEqual(candidate, expected) {
34
+ if (!candidate) return false;
35
+ const digest = (s) => crypto.createHash('sha256').update(String(s)).digest();
36
+ return crypto.timingSafeEqual(digest(candidate), digest(expected));
37
+ }
38
+
18
39
  // A dedicated subprotocol label the client always offers alongside the token,
19
40
  // so the server can select a non-secret protocol to echo back (some proxies /
20
41
  // strict clients want a selection) without ever reflecting the raw token.
@@ -47,7 +68,7 @@ export function tokenFromRequest(req) {
47
68
  */
48
69
  export function checkToken(req, configToken) {
49
70
  if (!configToken) return true;
50
- return tokenFromRequest(req) === configToken;
71
+ return safeTokenEqual(tokenFromRequest(req), configToken);
51
72
  }
52
73
 
53
74
  /**
@@ -77,5 +98,5 @@ export function parseWsProtocols(headerValue) {
77
98
  export function checkWsToken(req, configToken) {
78
99
  if (!configToken) return true;
79
100
  const offered = parseWsProtocols(req?.headers?.['sec-websocket-protocol']);
80
- return offered.includes(configToken);
101
+ return offered.some((o) => safeTokenEqual(o, configToken));
81
102
  }
package/lib/codex.js ADDED
@@ -0,0 +1,496 @@
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
+ // matchesProcess
56
+ //
57
+ // A pane is a Codex session when its process title is exactly "codex",
58
+ // a path ending in "/codex", or "codex" followed by a space (with flags).
59
+ // Does NOT match "codex-control" or version strings like "2.1.162".
60
+ // ---------------------------------------------------------------------------
61
+ export function matchesProcess(cmd) {
62
+ const c = String(cmd || '').trim();
63
+ return c === 'codex' || /(^|\/)codex$/.test(c) || /^codex\s/.test(c);
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // parseCodexRecord
68
+ //
69
+ // Parse one JSONL line from a Codex rollout file into a NormalizedMessage,
70
+ // or null when the line is not a displayable message record.
71
+ //
72
+ // CRITICAL de-dup: `event_msg/*` records duplicate content already in
73
+ // `response_item/*` — return null for all event_msg types to prevent
74
+ // double-render. Also null for turn_context, session_meta, and unknown types.
75
+ // ---------------------------------------------------------------------------
76
+ export function parseCodexRecord(line) {
77
+ const trimmed = line.trim();
78
+ if (!trimmed) return null;
79
+
80
+ let record;
81
+ try {
82
+ record = JSON.parse(trimmed);
83
+ } catch {
84
+ return null;
85
+ }
86
+
87
+ const t = record.type;
88
+ const p = record.payload || {};
89
+ const ts = record.timestamp ?? null;
90
+
91
+ // All event_msg, turn_context, and session_meta records are null.
92
+ if (t !== 'response_item') return null;
93
+
94
+ const subType = p.type;
95
+
96
+ // --- response_item/message ---
97
+ if (subType === 'message') {
98
+ const rawRole = p.role;
99
+ // developer = system/permissions injection; null it.
100
+ if (rawRole === 'developer') return null;
101
+ let role;
102
+ if (rawRole === 'assistant') role = 'assistant';
103
+ else if (rawRole === 'user') role = 'user';
104
+ else return null;
105
+
106
+ const blocks = [];
107
+ if (Array.isArray(p.content)) {
108
+ for (const item of p.content) {
109
+ const text = item?.text;
110
+ if (typeof text === 'string' && text) {
111
+ blocks.push({ kind: 'text', text });
112
+ }
113
+ }
114
+ }
115
+ if (blocks.length === 0) return null;
116
+
117
+ return {
118
+ uuid: record.id ?? p.id ?? null,
119
+ role,
120
+ ts,
121
+ blocks,
122
+ rawType: 'message',
123
+ };
124
+ }
125
+
126
+ // --- response_item/reasoning (encrypted) ---
127
+ if (subType === 'reasoning') {
128
+ return {
129
+ uuid: record.id ?? null,
130
+ role: 'assistant',
131
+ ts,
132
+ blocks: [{ kind: 'thinking', text: '[reasoning encrypted]' }],
133
+ rawType: 'reasoning',
134
+ };
135
+ }
136
+
137
+ // --- response_item/function_call (exec_command, etc.) ---
138
+ if (subType === 'function_call') {
139
+ let parsedArgs;
140
+ try {
141
+ parsedArgs = JSON.parse(p.arguments);
142
+ } catch {
143
+ parsedArgs = { raw: p.arguments };
144
+ }
145
+ return {
146
+ uuid: p.call_id ?? null,
147
+ role: 'assistant',
148
+ ts,
149
+ blocks: [
150
+ {
151
+ kind: 'tool_use',
152
+ id: p.call_id,
153
+ name: p.name || 'exec_command',
154
+ input: parsedArgs,
155
+ inputSummary: inputSummary(parsedArgs),
156
+ },
157
+ ],
158
+ rawType: 'function_call',
159
+ };
160
+ }
161
+
162
+ // --- response_item/function_call_output ---
163
+ if (subType === 'function_call_output') {
164
+ return {
165
+ uuid: p.call_id != null ? p.call_id + '-out' : null,
166
+ role: 'user',
167
+ ts,
168
+ blocks: [
169
+ {
170
+ kind: 'tool_result',
171
+ forId: p.call_id,
172
+ text: String(p.output ?? ''),
173
+ isError: false,
174
+ },
175
+ ],
176
+ rawType: 'function_call_output',
177
+ };
178
+ }
179
+
180
+ // --- response_item/custom_tool_call (apply_patch) ---
181
+ if (subType === 'custom_tool_call') {
182
+ // p.input is the raw patch text string.
183
+ const patchInput = { patch: p.input, status: p.status };
184
+ return {
185
+ uuid: p.call_id ?? null,
186
+ role: 'assistant',
187
+ ts,
188
+ blocks: [
189
+ {
190
+ kind: 'tool_use',
191
+ id: p.call_id,
192
+ name: p.name || 'apply_patch',
193
+ input: patchInput,
194
+ inputSummary: inputSummary(patchInput),
195
+ },
196
+ ],
197
+ rawType: 'custom_tool_call',
198
+ };
199
+ }
200
+
201
+ // --- response_item/custom_tool_call_output ---
202
+ if (subType === 'custom_tool_call_output') {
203
+ return {
204
+ uuid: p.call_id != null ? p.call_id + '-out' : null,
205
+ role: 'user',
206
+ ts,
207
+ blocks: [
208
+ {
209
+ kind: 'tool_result',
210
+ forId: p.call_id,
211
+ text: String(p.output ?? ''),
212
+ isError: false,
213
+ },
214
+ ],
215
+ rawType: 'custom_tool_call_output',
216
+ };
217
+ }
218
+
219
+ // All other response_item subtypes → null.
220
+ return null;
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // buildTranscriptIndex
225
+ //
226
+ // Scan recent Codex session date directories for rollout-*.jsonl files.
227
+ // Checks today and yesterday (by local date) to handle sessions that
228
+ // started near midnight.
229
+ //
230
+ // The `now` parameter is injected for testability — callers that do not
231
+ // care about the clock may omit it and get `new Date()`.
232
+ // ---------------------------------------------------------------------------
233
+ export async function buildTranscriptIndex({ codexSessionsRoot }, now = new Date()) {
234
+ const index = { byCwd: new Map() };
235
+
236
+ if (!codexSessionsRoot) return index;
237
+
238
+ // Compute the date dir path for a given Date using LOCAL date parts
239
+ // (Codex CLI uses local wall-clock date for its session directory names).
240
+ function datePath(d) {
241
+ const yyyy = String(d.getFullYear());
242
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
243
+ const dd = String(d.getDate()).padStart(2, '0');
244
+ return path.join(codexSessionsRoot, yyyy, mm, dd);
245
+ }
246
+
247
+ const today = datePath(now);
248
+ const yesterday = datePath(new Date(now.getTime() - 24 * 3600 * 1000));
249
+ // Dedup if equal (e.g. right at midnight boundary)
250
+ const dateDirs = today === yesterday ? [today] : [today, yesterday];
251
+
252
+ for (const dateDir of dateDirs) {
253
+ let files;
254
+ try {
255
+ files = await fs.readdir(dateDir);
256
+ } catch {
257
+ // Missing dir is normal — skip silently.
258
+ continue;
259
+ }
260
+
261
+ const rollouts = files.filter((f) => /^rollout-.*\.jsonl$/.test(f));
262
+
263
+ await Promise.all(
264
+ rollouts.map(async (filename) => {
265
+ const filePath = path.join(dateDir, filename);
266
+ try {
267
+ // Stat for mtime.
268
+ const stat = await fs.stat(filePath);
269
+ const mtime = stat.mtimeMs;
270
+
271
+ // Head-read only the first 65536 bytes to extract session_meta.
272
+ const buf = await readHead(filePath, 65536);
273
+ if (!buf || buf.length === 0) return;
274
+
275
+ const text = buf.toString('utf8');
276
+ const firstLine = text.split('\n')[0];
277
+ if (!firstLine || !firstLine.trim()) return;
278
+
279
+ let record;
280
+ try {
281
+ record = JSON.parse(firstLine.trim());
282
+ } catch {
283
+ return;
284
+ }
285
+
286
+ if (record.type !== 'session_meta') return;
287
+ const payload = record.payload || {};
288
+ if (typeof payload.cwd !== 'string' || !payload.cwd) return;
289
+
290
+ const discovered = {
291
+ cwd: payload.cwd,
292
+ sessionId: payload.id ?? null,
293
+ lastActivity: record.timestamp ?? null,
294
+ // session_meta has model_provider but no concrete model id.
295
+ model: null,
296
+ aiTitle: null,
297
+ customTitle: null,
298
+ transcriptPath: filePath,
299
+ mtime,
300
+ transcriptPending: false,
301
+ pendingToolUseId: null,
302
+ pendingQuestion: null,
303
+ agentType: 'codex',
304
+ };
305
+
306
+ // Newest mtime wins per cwd.
307
+ const existing = index.byCwd.get(payload.cwd);
308
+ if (!existing || mtime > existing.mtime) {
309
+ index.byCwd.set(payload.cwd, discovered);
310
+ }
311
+ } catch {
312
+ // Per-file resilience: skip malformed or unreadable files.
313
+ }
314
+ }),
315
+ );
316
+ }
317
+
318
+ // Return byCwd only — no byDir key. sessions.js merge loop guards `if (byDir)`.
319
+ return index;
320
+ }
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // detectPendingFromCapture
324
+ //
325
+ // Detect a Codex approval modal from a capture-pane dump.
326
+ // (P2 deferred wiring — implemented and tested here, not yet called by
327
+ // shared code. detectPendingFromCapture is the Codex-only pending channel.)
328
+ //
329
+ // Returns a shape describing the modal kind, header text, and available
330
+ // options.
331
+ //
332
+ // Headings recognized:
333
+ // "Would you like to run the following command?" → 'exec_command'
334
+ // "Would you like to make the following edits?" → 'apply_patch'
335
+ // "Do you trust the contents of this directory?" → 'directory_trust'
336
+ // ---------------------------------------------------------------------------
337
+ export function detectPendingFromCapture(capture) {
338
+ const noModal = { transcriptPending: false, pendingKind: null, header: null, options: [] };
339
+ if (!capture) return noModal;
340
+
341
+ const lines = capture.split('\n');
342
+
343
+ const headings = [
344
+ { text: 'Would you like to run the following command?', kind: 'exec_command' },
345
+ { text: 'Would you like to make the following edits?', kind: 'apply_patch' },
346
+ { text: 'Do you trust the contents of this directory?', kind: 'directory_trust' },
347
+ ];
348
+
349
+ let pendingKind = null;
350
+ let header = null;
351
+ let headingIdx = -1;
352
+
353
+ for (let i = 0; i < lines.length; i++) {
354
+ const trimmed = lines[i].trim();
355
+ for (const h of headings) {
356
+ if (trimmed === h.text) {
357
+ pendingKind = h.kind;
358
+ header = trimmed;
359
+ headingIdx = i;
360
+ break;
361
+ }
362
+ }
363
+ if (headingIdx !== -1) break;
364
+ }
365
+
366
+ if (!pendingKind) return noModal;
367
+
368
+ // Scan lines after the heading for option lines.
369
+ // Option line regex: /^\s*[›\s]\s*(\d+)\.\s+(.+?)(?:\s+\(([^)]+)\))?\s*$/
370
+ // U+203A = ›
371
+ const optionLineRegex = /^\s*[›\s]\s*(\d+)\.\s+(.+?)(?:\s+\(([^)]+)\))?\s*$/;
372
+ const footerHints = ['Press enter to confirm or esc to cancel', 'Press enter to continue'];
373
+
374
+ const options = [];
375
+ let seenOption = false;
376
+
377
+ for (let i = headingIdx + 1; i < lines.length; i++) {
378
+ const raw = lines[i];
379
+ const trimmed = raw.trim();
380
+
381
+ // Check footer hint — stop collecting after it.
382
+ if (footerHints.includes(trimmed)) break;
383
+
384
+ const m = optionLineRegex.exec(raw);
385
+ if (m) {
386
+ seenOption = true;
387
+ options.push({
388
+ n: Number(m[1]),
389
+ label: m[2].trim(),
390
+ shortcut: m[3] || null,
391
+ // Highlighted if the raw line contains the › character (U+203A).
392
+ highlighted: raw.includes('›'),
393
+ });
394
+ } else if (seenOption && trimmed && !m) {
395
+ // First non-blank, non-option line after at least one option was captured.
396
+ break;
397
+ }
398
+ }
399
+
400
+ if (options.length === 0) return noModal;
401
+
402
+ return { transcriptPending: true, pendingKind, header, options };
403
+ }
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // buildAnswerProgram
407
+ //
408
+ // Build the keystroke token array that answers the current Codex approval
409
+ // modal. Output is compatible with lib/answer.js tmux send-keys token format.
410
+ //
411
+ // Selections: first element of the first selection is a digit string or
412
+ // option label. Falls back to the highlighted option, then option 1.
413
+ // ---------------------------------------------------------------------------
414
+ export function buildAnswerProgram(pending, selections) {
415
+ const opts = pending?.options || [];
416
+ const sel = selections?.[0]?.[0];
417
+ let digit = null;
418
+ if (sel != null) {
419
+ if (/^\d+$/.test(String(sel))) digit = String(sel);
420
+ else {
421
+ const m = opts.find((o) => o.label === sel);
422
+ if (m) digit = String(m.n);
423
+ }
424
+ }
425
+ if (digit == null) {
426
+ const hl = opts.find((o) => o.highlighted);
427
+ digit = hl ? String(hl.n) : '1';
428
+ }
429
+ return [digit, 'Enter'];
430
+ }
431
+
432
+ // ---------------------------------------------------------------------------
433
+ // codexPendingToFrontend
434
+ //
435
+ // Map detectPendingFromCapture's output to the PanePrompt shape that
436
+ // parsePanePrompt returns, so the existing prompt-frame UI can render it
437
+ // with zero frontend type/component changes.
438
+ //
439
+ // Returns null when there is no active modal or no options.
440
+ // ---------------------------------------------------------------------------
441
+ export function codexPendingToFrontend(pending) {
442
+ if (!pending || !pending.transcriptPending || !pending.options || pending.options.length === 0) {
443
+ return null;
444
+ }
445
+ return {
446
+ question: pending.header,
447
+ options: pending.options.map((o) => ({
448
+ key: String(o.n),
449
+ label: o.label,
450
+ selected: !!o.highlighted,
451
+ })),
452
+ // Do NOT set multiSelect — Codex approvals are single-select radio.
453
+ };
454
+ }
455
+
456
+ // ---------------------------------------------------------------------------
457
+ // parseCodexPrompt
458
+ //
459
+ // Thin combinator: detect + map in one call. Used by startPromptPoller.
460
+ // ---------------------------------------------------------------------------
461
+ export function parseCodexPrompt(capture) {
462
+ return codexPendingToFrontend(detectPendingFromCapture(capture));
463
+ }
464
+
465
+ // ---------------------------------------------------------------------------
466
+ // buildSpawnCommand
467
+ //
468
+ // Build the spawn command for a new Codex session.
469
+ // Codex requires -C <cwd> to set the working directory (tmux -c alone is
470
+ // insufficient because Codex reads cwd from its own flag, not the shell env).
471
+ // ---------------------------------------------------------------------------
472
+ export function buildSpawnCommand({ cwd, bin = 'codex' } = {}) {
473
+ return { bin, args: ['-C', cwd] };
474
+ }
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // parseTuiStatus
478
+ //
479
+ // Parse model name from a Codex TUI header capture.
480
+ // The header contains: │ model: gpt-5.5 xhigh fast /model to change │
481
+ // Extracts the model identifier immediately after "model:" with optional whitespace.
482
+ // ctx% is not shown in the Codex TUI.
483
+ // ---------------------------------------------------------------------------
484
+ export function parseTuiStatus(capture) {
485
+ const m = /model:\s+(\S+)/.exec(capture || '');
486
+ return { ctxPct: null, model: m ? m[1] : null };
487
+ }
488
+
489
+ // ---------------------------------------------------------------------------
490
+ // prettyModel
491
+ //
492
+ // Codex model ids are already human-readable (e.g. "gpt-5.5").
493
+ // ---------------------------------------------------------------------------
494
+ export function prettyModel(modelId) {
495
+ return modelId || null;
496
+ }
package/lib/config.js CHANGED
@@ -23,6 +23,7 @@ import fs from 'node:fs';
23
23
  import path from 'node:path';
24
24
  import os from 'node:os';
25
25
  import { detectMachine, recommendMlxModel, recommendClaudeModel } from './models.js';
26
+ import { writeJsonAtomic } from './json-file.js';
26
27
 
27
28
  // Env lookup mirrors server.js: prefer CLAUDE_CONTROL_<X>, fall back to the
28
29
  // legacy COCKPIT_<X> so existing launchers keep working.
@@ -40,6 +41,7 @@ function configPath() {
40
41
  }
41
42
 
42
43
  const LAUNCH_MAX = 500;
44
+ const CODEX_LAUNCH_MAX = 500;
43
45
  const OPTIMIZE_MODEL_MAX = 200;
44
46
  const CLAUDE_BIN_MAX = 500;
45
47
  const MLX_MODEL_MAX = 200;
@@ -58,6 +60,8 @@ function defaults() {
58
60
  defaultCwd: os.homedir(),
59
61
  optimizeModel: recommendClaudeModel(),
60
62
  claudeBin: '',
63
+ codexLaunchCommand: 'codex',
64
+ codexBin: '',
61
65
  // Prompt-enhancer backend: 'mlx' (local model → claude → rules chain),
62
66
  // 'claude' (claude -p → rules), or 'rules' (deterministic, offline).
63
67
  optimizeBackend: 'mlx',
@@ -75,7 +79,7 @@ function defaults() {
75
79
  * Read the persisted config, merged over defaults. Never throws — a missing,
76
80
  * empty, or corrupt file falls back to defaults. Only known keys are surfaced.
77
81
  *
78
- * @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 }}
79
83
  */
80
84
  export function readConfig() {
81
85
  const base = defaults();
@@ -110,6 +114,14 @@ export function readConfig() {
110
114
  typeof parsed.claudeBin === 'string'
111
115
  ? parsed.claudeBin
112
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,
113
125
  optimizeBackend:
114
126
  typeof parsed.optimizeBackend === 'string' &&
115
127
  OPTIMIZE_BACKENDS.includes(parsed.optimizeBackend)
@@ -141,8 +153,8 @@ export function readConfig() {
141
153
  * - claudeBin: string ≤500 chars; empty string is allowed (means auto-resolve).
142
154
  * Existence is NOT verified at write time (path may differ across hosts).
143
155
  *
144
- * @param {{ launchCommand?: unknown, defaultCwd?: unknown, optimizeModel?: unknown, claudeBin?: unknown }} partial
145
- * @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
146
158
  */
147
159
  export function writeConfig(partial = {}) {
148
160
  const current = readConfig();
@@ -198,6 +210,28 @@ export function writeConfig(partial = {}) {
198
210
  next.claudeBin = bin;
199
211
  }
200
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
+
201
235
  if (partial.optimizeBackend !== undefined) {
202
236
  const b = partial.optimizeBackend;
203
237
  if (typeof b !== 'string' || !OPTIMIZE_BACKENDS.includes(b)) {
@@ -241,6 +275,6 @@ export function writeConfig(partial = {}) {
241
275
 
242
276
  const dir = dataDir();
243
277
  fs.mkdirSync(dir, { recursive: true });
244
- fs.writeFileSync(configPath(), JSON.stringify(next, null, 2), { mode: 0o600 });
278
+ writeJsonAtomic(configPath(), next, { mode: 0o600 });
245
279
  return next;
246
280
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * lib/json-file.js — atomic JSON file writes.
3
+ *
4
+ * writeJsonAtomic(filePath, obj, options?) serialises obj to JSON, writes it
5
+ * to a same-directory temp file, then renames the temp file over the
6
+ * destination. The rename is the commit point — a crash before the rename
7
+ * leaves the previous file intact; a crash after leaves the new file intact.
8
+ * A truncated in-progress write can never be observed by readers.
9
+ *
10
+ * The temp file is placed in the same directory as the destination so that
11
+ * the rename is guaranteed to be atomic (same filesystem, no cross-device
12
+ * move). On any error the temp file is unlinked before re-throwing.
13
+ */
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ /**
19
+ * Write obj as pretty-printed JSON to filePath atomically.
20
+ *
21
+ * @param {string} filePath - absolute or relative destination path
22
+ * @param {unknown} obj - value passed to JSON.stringify
23
+ * @param {{ mode?: number }} [options]
24
+ * @param {number} [options.mode=0o600] - file permission mode for the temp file
25
+ */
26
+ export function writeJsonAtomic(filePath, obj, { mode = 0o600 } = {}) {
27
+ const dir = path.dirname(filePath);
28
+ const tmp = `${filePath}.tmp`;
29
+ try {
30
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode });
31
+ fs.renameSync(tmp, filePath);
32
+ } catch (err) {
33
+ try {
34
+ fs.unlinkSync(tmp);
35
+ } catch {
36
+ // best-effort cleanup; ignore unlink errors
37
+ }
38
+ throw err;
39
+ }
40
+ }