@idl3/claude-control 1.0.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/codex.js +496 -0
- package/lib/config.js +36 -3
- package/lib/sessions.js +70 -10
- package/lib/transcript.js +5 -4
- package/package.json +1 -1
- package/server.js +124 -13
- package/web/dist/assets/{core-CEtbx-dx.js → core-CpT6tRRG.js} +1 -1
- package/web/dist/assets/index-CjOcrKRX.css +1 -0
- package/web/dist/assets/index-CxhR0MPg.js +103 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CjJtW-Kv.css +0 -1
- package/web/dist/assets/index-DFru8Gzx.js +0 -103
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
|
@@ -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)) {
|