@idl3/claude-control 1.1.0 → 1.4.3
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/bin/claude-print-bridge.mjs +247 -0
- package/lib/claude-print.js +352 -0
- package/lib/codex-rpc.js +719 -0
- package/lib/codex.js +639 -74
- package/lib/pane-registry.js +58 -10
- package/lib/prompt.js +60 -21
- package/lib/sessions.js +300 -63
- package/lib/subagents.js +113 -0
- package/lib/tmux.js +68 -11
- package/lib/transcribe.js +1 -1
- package/lib/tui.js +10 -3
- package/lib/version.js +44 -8
- package/package.json +1 -1
- package/server.js +561 -36
- package/web/dist/assets/{core-CpT6tRRG.js → core-BPDebW1g.js} +1 -1
- package/web/dist/assets/index-B3rIEzoc.css +1 -0
- package/web/dist/assets/index-DIwGyVZ7.js +104 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CjOcrKRX.css +0 -1
- package/web/dist/assets/index-CxhR0MPg.js +0 -103
package/lib/codex.js
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
import fs from 'node:fs/promises';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
+
import { execFile as _execFile } from 'node:child_process';
|
|
12
|
+
import { promisify } from 'node:util';
|
|
13
|
+
|
|
14
|
+
const execFile = promisify(_execFile);
|
|
11
15
|
|
|
12
16
|
// ---------------------------------------------------------------------------
|
|
13
17
|
// inputSummary — intentionally duplicated from lib/transcript.js (not exported
|
|
@@ -51,6 +55,63 @@ async function readHead(filePath, maxBytes) {
|
|
|
51
55
|
}
|
|
52
56
|
}
|
|
53
57
|
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// readTail — read the LAST maxBytes of a file without loading it all.
|
|
60
|
+
// Mirrors readHead but reads from offset max(0, size - maxBytes).
|
|
61
|
+
// Never throws — returns null on any error.
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
async function readTail(filePath, maxBytes) {
|
|
64
|
+
let fh;
|
|
65
|
+
try {
|
|
66
|
+
fh = await fs.open(filePath, 'r');
|
|
67
|
+
const stat = await fh.stat();
|
|
68
|
+
const size = stat.size;
|
|
69
|
+
if (size === 0) return Buffer.alloc(0);
|
|
70
|
+
const readSize = Math.min(size, maxBytes);
|
|
71
|
+
const offset = Math.max(0, size - maxBytes);
|
|
72
|
+
const buf = Buffer.allocUnsafe(readSize);
|
|
73
|
+
const { bytesRead } = await fh.read(buf, 0, readSize, offset);
|
|
74
|
+
return buf.subarray(0, bytesRead);
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
} finally {
|
|
78
|
+
if (fh) await fh.close().catch(() => {});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// extractUsageFromTail — given a text blob, scan lines from the END and
|
|
84
|
+
// return the newest token_count event_msg's primary rate-limit data.
|
|
85
|
+
//
|
|
86
|
+
// Returns { usagePct, usageWindowMin } where usagePct is the primary
|
|
87
|
+
// used_percent (number) and usageWindowMin is the primary window_minutes
|
|
88
|
+
// (number). Returns null if no valid token_count line is found.
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
export function extractUsageFromTail(text) {
|
|
91
|
+
if (!text) return null;
|
|
92
|
+
const lines = text.split('\n');
|
|
93
|
+
// Iterate from the end — newest first.
|
|
94
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
95
|
+
const line = lines[i].trim();
|
|
96
|
+
if (!line) continue;
|
|
97
|
+
let rec;
|
|
98
|
+
try {
|
|
99
|
+
rec = JSON.parse(line);
|
|
100
|
+
} catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (rec.type !== 'event_msg') continue;
|
|
104
|
+
if (rec.payload?.type !== 'token_count') continue;
|
|
105
|
+
const primary = rec.payload?.rate_limits?.primary;
|
|
106
|
+
if (primary == null) continue;
|
|
107
|
+
const usagePct = primary.used_percent;
|
|
108
|
+
const usageWindowMin = primary.window_minutes;
|
|
109
|
+
if (typeof usagePct !== 'number' || typeof usageWindowMin !== 'number') continue;
|
|
110
|
+
return { usagePct, usageWindowMin };
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
54
115
|
// ---------------------------------------------------------------------------
|
|
55
116
|
// matchesProcess
|
|
56
117
|
//
|
|
@@ -58,12 +119,194 @@ async function readHead(filePath, maxBytes) {
|
|
|
58
119
|
// a path ending in "/codex", or "codex" followed by a space (with flags).
|
|
59
120
|
// Does NOT match "codex-control" or version strings like "2.1.162".
|
|
60
121
|
// ---------------------------------------------------------------------------
|
|
61
|
-
export function
|
|
122
|
+
export function processMatchKind(cmd) {
|
|
62
123
|
const c = String(cmd || '').trim();
|
|
63
|
-
|
|
124
|
+
if (!c) return null;
|
|
125
|
+
const parts = c.split(/\s+/).filter(Boolean);
|
|
126
|
+
const basename = (s) => String(s || '').replace(/\\/g, '/').split('/').pop();
|
|
127
|
+
if (basename(parts[0]) === 'codex') return 'direct';
|
|
128
|
+
if (basename(parts[0]) === 'node' && basename(parts[1]) === 'codex') return 'node-wrapper';
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function matchesProcess(cmd) {
|
|
133
|
+
return processMatchKind(cmd) !== null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const SUBAGENT_NOTIFICATION_OPEN = '<subagent_notification>';
|
|
137
|
+
const SUBAGENT_NOTIFICATION_CLOSE = '</subagent_notification>';
|
|
138
|
+
|
|
139
|
+
function isCodexSubagentNotificationText(text) {
|
|
140
|
+
if (typeof text !== 'string') return false;
|
|
141
|
+
const trimmed = text.trim();
|
|
142
|
+
return trimmed.startsWith(SUBAGENT_NOTIFICATION_OPEN) && trimmed.endsWith(SUBAGENT_NOTIFICATION_CLOSE);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function statusValueText(value) {
|
|
146
|
+
if (value == null) return null;
|
|
147
|
+
if (typeof value === 'string') return value;
|
|
148
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
149
|
+
try {
|
|
150
|
+
return JSON.stringify(value);
|
|
151
|
+
} catch {
|
|
152
|
+
return String(value);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function statusKindFrom(status, candidates) {
|
|
157
|
+
for (const key of candidates) {
|
|
158
|
+
if (Object.hasOwn(status, key)) return key;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function agentIdFromPath(agentPath) {
|
|
164
|
+
const parts = agentPath.split('/').filter(Boolean);
|
|
165
|
+
return parts[parts.length - 1] || agentPath;
|
|
64
166
|
}
|
|
65
167
|
|
|
66
168
|
// ---------------------------------------------------------------------------
|
|
169
|
+
// parseCodexSubagentNotification
|
|
170
|
+
//
|
|
171
|
+
// Parse the exact Codex wrapper emitted into rollout transcripts when a
|
|
172
|
+
// sub-agent status is delivered as message text.
|
|
173
|
+
//
|
|
174
|
+
// Returns a normalized update:
|
|
175
|
+
// {
|
|
176
|
+
// agentId, agentPath,
|
|
177
|
+
// status: 'running' | 'done',
|
|
178
|
+
// state: 'running' | 'completed' | 'error',
|
|
179
|
+
// statusKind, result, error, rawStatus, raw
|
|
180
|
+
// }
|
|
181
|
+
// or null when text is not the exact wrapper or the JSON shape is unusable.
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
export function parseCodexSubagentNotification(text) {
|
|
184
|
+
if (!isCodexSubagentNotificationText(text)) return null;
|
|
185
|
+
|
|
186
|
+
const trimmed = text.trim();
|
|
187
|
+
const jsonText = trimmed
|
|
188
|
+
.slice(SUBAGENT_NOTIFICATION_OPEN.length, trimmed.length - SUBAGENT_NOTIFICATION_CLOSE.length)
|
|
189
|
+
.trim();
|
|
190
|
+
if (!jsonText) return null;
|
|
191
|
+
|
|
192
|
+
let raw;
|
|
193
|
+
try {
|
|
194
|
+
raw = JSON.parse(jsonText);
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
200
|
+
const agentPath = raw.agent_path;
|
|
201
|
+
if (typeof agentPath !== 'string' || !agentPath) return null;
|
|
202
|
+
|
|
203
|
+
const rawStatus = raw.status;
|
|
204
|
+
if (!rawStatus || typeof rawStatus !== 'object' || Array.isArray(rawStatus)) return null;
|
|
205
|
+
|
|
206
|
+
const completedKind = statusKindFrom(rawStatus, ['completed', 'complete', 'done', 'finished']);
|
|
207
|
+
if (completedKind) {
|
|
208
|
+
return {
|
|
209
|
+
agentId: agentIdFromPath(agentPath),
|
|
210
|
+
agentPath,
|
|
211
|
+
status: 'done',
|
|
212
|
+
state: 'completed',
|
|
213
|
+
statusKind: completedKind,
|
|
214
|
+
result: statusValueText(rawStatus[completedKind]),
|
|
215
|
+
error: null,
|
|
216
|
+
rawStatus,
|
|
217
|
+
raw,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const errorKind = statusKindFrom(rawStatus, [
|
|
222
|
+
'failed',
|
|
223
|
+
'error',
|
|
224
|
+
'errored',
|
|
225
|
+
'cancelled',
|
|
226
|
+
'canceled',
|
|
227
|
+
'timed_out',
|
|
228
|
+
'timeout',
|
|
229
|
+
]);
|
|
230
|
+
if (errorKind) {
|
|
231
|
+
return {
|
|
232
|
+
agentId: agentIdFromPath(agentPath),
|
|
233
|
+
agentPath,
|
|
234
|
+
status: 'done',
|
|
235
|
+
state: 'error',
|
|
236
|
+
statusKind: errorKind,
|
|
237
|
+
result: null,
|
|
238
|
+
error: statusValueText(rawStatus[errorKind]),
|
|
239
|
+
rawStatus,
|
|
240
|
+
raw,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const runningKind = statusKindFrom(rawStatus, ['running', 'started', 'in_progress', 'pending']);
|
|
245
|
+
if (runningKind) {
|
|
246
|
+
return {
|
|
247
|
+
agentId: agentIdFromPath(agentPath),
|
|
248
|
+
agentPath,
|
|
249
|
+
status: 'running',
|
|
250
|
+
state: 'running',
|
|
251
|
+
statusKind: runningKind,
|
|
252
|
+
result: null,
|
|
253
|
+
error: null,
|
|
254
|
+
rawStatus,
|
|
255
|
+
raw,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function parseCodexSubagentNotificationRecord(line) {
|
|
263
|
+
const trimmed = String(line || '').trim();
|
|
264
|
+
if (!trimmed) return null;
|
|
265
|
+
let record;
|
|
266
|
+
try {
|
|
267
|
+
record = JSON.parse(trimmed);
|
|
268
|
+
} catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
if (record?.type !== 'response_item') return null;
|
|
272
|
+
const p = record.payload || {};
|
|
273
|
+
if (p.type !== 'message' || !Array.isArray(p.content)) return null;
|
|
274
|
+
for (const item of p.content) {
|
|
275
|
+
const update = parseCodexSubagentNotification(item?.text);
|
|
276
|
+
if (update) {
|
|
277
|
+
return {
|
|
278
|
+
...update,
|
|
279
|
+
ts: record.timestamp ?? null,
|
|
280
|
+
role: p.role ?? null,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
/**
|
|
289
|
+
* Decode a Codex reasoning `summary` into plain text. The shape varies by Codex
|
|
290
|
+
* version: an array of strings, an array of `{ text }` / `{ summary }` objects,
|
|
291
|
+
* or a bare string. Returns the joined, trimmed text, or '' when nothing usable.
|
|
292
|
+
* @param {unknown} summary
|
|
293
|
+
* @returns {string}
|
|
294
|
+
*/
|
|
295
|
+
function decodeReasoningSummary(summary) {
|
|
296
|
+
if (typeof summary === 'string') return summary.trim();
|
|
297
|
+
if (!Array.isArray(summary)) return '';
|
|
298
|
+
const parts = summary
|
|
299
|
+
.map((item) => {
|
|
300
|
+
if (typeof item === 'string') return item;
|
|
301
|
+
if (item && typeof item === 'object') {
|
|
302
|
+
return item.text ?? item.summary ?? '';
|
|
303
|
+
}
|
|
304
|
+
return '';
|
|
305
|
+
})
|
|
306
|
+
.filter(Boolean);
|
|
307
|
+
return parts.join('\n\n').trim();
|
|
308
|
+
}
|
|
309
|
+
|
|
67
310
|
// parseCodexRecord
|
|
68
311
|
//
|
|
69
312
|
// Parse one JSONL line from a Codex rollout file into a NormalizedMessage,
|
|
@@ -107,6 +350,7 @@ export function parseCodexRecord(line) {
|
|
|
107
350
|
if (Array.isArray(p.content)) {
|
|
108
351
|
for (const item of p.content) {
|
|
109
352
|
const text = item?.text;
|
|
353
|
+
if (isCodexSubagentNotificationText(text)) continue;
|
|
110
354
|
if (typeof text === 'string' && text) {
|
|
111
355
|
blocks.push({ kind: 'text', text });
|
|
112
356
|
}
|
|
@@ -123,13 +367,19 @@ export function parseCodexRecord(line) {
|
|
|
123
367
|
};
|
|
124
368
|
}
|
|
125
369
|
|
|
126
|
-
// --- response_item/reasoning
|
|
370
|
+
// --- response_item/reasoning ---
|
|
371
|
+
// Codex emits a `summary` array of reasoning parts (the human-readable gist;
|
|
372
|
+
// the full chain is encrypted in `encrypted_content`, which we never get). Show
|
|
373
|
+
// the decoded summary as a thinking block; if there's nothing readable, hide
|
|
374
|
+
// the block entirely rather than render a useless "[reasoning encrypted]".
|
|
127
375
|
if (subType === 'reasoning') {
|
|
376
|
+
const summary = decodeReasoningSummary(p.summary);
|
|
377
|
+
if (!summary) return null;
|
|
128
378
|
return {
|
|
129
379
|
uuid: record.id ?? null,
|
|
130
380
|
role: 'assistant',
|
|
131
381
|
ts,
|
|
132
|
-
blocks: [{ kind: 'thinking', text:
|
|
382
|
+
blocks: [{ kind: 'thinking', text: summary }],
|
|
133
383
|
rawType: 'reasoning',
|
|
134
384
|
};
|
|
135
385
|
}
|
|
@@ -220,6 +470,193 @@ export function parseCodexRecord(line) {
|
|
|
220
470
|
return null;
|
|
221
471
|
}
|
|
222
472
|
|
|
473
|
+
export async function readCodexTranscriptRecord(filePath) {
|
|
474
|
+
let stat;
|
|
475
|
+
try {
|
|
476
|
+
stat = await fs.stat(filePath);
|
|
477
|
+
} catch {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
const mtime = stat.mtimeMs;
|
|
481
|
+
|
|
482
|
+
// Head-read only the first 65536 bytes to extract session_meta.
|
|
483
|
+
const buf = await readHead(filePath, 65536);
|
|
484
|
+
if (!buf || buf.length === 0) return null;
|
|
485
|
+
|
|
486
|
+
const text = buf.toString('utf8');
|
|
487
|
+
const firstLine = text.split('\n')[0];
|
|
488
|
+
if (!firstLine || !firstLine.trim()) return null;
|
|
489
|
+
|
|
490
|
+
let record;
|
|
491
|
+
try {
|
|
492
|
+
record = JSON.parse(firstLine.trim());
|
|
493
|
+
} catch {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (record.type !== 'session_meta') return null;
|
|
498
|
+
const payload = record.payload || {};
|
|
499
|
+
if (typeof payload.cwd !== 'string' || !payload.cwd) return null;
|
|
500
|
+
|
|
501
|
+
const lastActivity = record.timestamp ?? null;
|
|
502
|
+
const lastActivityMs = lastActivity ? (Date.parse(lastActivity) || null) : null;
|
|
503
|
+
|
|
504
|
+
// Tail-read for rate-limit usage (token_count events appear throughout).
|
|
505
|
+
let usagePct = null;
|
|
506
|
+
let usageWindowMin = null;
|
|
507
|
+
const tailBuf = await readTail(filePath, 32768);
|
|
508
|
+
if (tailBuf && tailBuf.length > 0) {
|
|
509
|
+
const tailText = tailBuf.toString('utf8');
|
|
510
|
+
const usage = extractUsageFromTail(tailText);
|
|
511
|
+
if (usage) {
|
|
512
|
+
usagePct = usage.usagePct;
|
|
513
|
+
usageWindowMin = usage.usageWindowMin;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
cwd: payload.cwd,
|
|
519
|
+
sessionId: payload.id ?? null,
|
|
520
|
+
lastActivity,
|
|
521
|
+
lastActivityMs,
|
|
522
|
+
// session_meta has model_provider but no concrete model id.
|
|
523
|
+
model: null,
|
|
524
|
+
aiTitle: null,
|
|
525
|
+
customTitle: null,
|
|
526
|
+
transcriptPath: filePath,
|
|
527
|
+
mtime,
|
|
528
|
+
transcriptPending: false,
|
|
529
|
+
pendingToolUseId: null,
|
|
530
|
+
pendingQuestion: null,
|
|
531
|
+
agentType: 'codex',
|
|
532
|
+
usagePct,
|
|
533
|
+
usageWindowMin,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
// readRolloutMeta
|
|
539
|
+
//
|
|
540
|
+
// Read a single rollout .jsonl file and return the discovered record object,
|
|
541
|
+
// or null on any failure (missing file, empty, non-session_meta first line,
|
|
542
|
+
// missing cwd).
|
|
543
|
+
//
|
|
544
|
+
// This is the per-file parsing logic extracted from buildTranscriptIndex so
|
|
545
|
+
// it can be called directly by findOpenRollout-based binding (lsof path).
|
|
546
|
+
// Unlike the loop inside buildTranscriptIndex, this function does NOT apply
|
|
547
|
+
// the ACTIVE_WINDOW mtime gate — a live file found via lsof must always be
|
|
548
|
+
// parsed regardless of mtime staleness.
|
|
549
|
+
// ---------------------------------------------------------------------------
|
|
550
|
+
export async function readRolloutMeta(filePath, now = new Date()) {
|
|
551
|
+
try {
|
|
552
|
+
const stat = await fs.stat(filePath);
|
|
553
|
+
const mtime = stat.mtimeMs;
|
|
554
|
+
|
|
555
|
+
const buf = await readHead(filePath, 65536);
|
|
556
|
+
if (!buf || buf.length === 0) return null;
|
|
557
|
+
|
|
558
|
+
const text = buf.toString('utf8');
|
|
559
|
+
const firstLine = text.split('\n')[0];
|
|
560
|
+
if (!firstLine || !firstLine.trim()) return null;
|
|
561
|
+
|
|
562
|
+
let record;
|
|
563
|
+
try {
|
|
564
|
+
record = JSON.parse(firstLine.trim());
|
|
565
|
+
} catch {
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (record.type !== 'session_meta') return null;
|
|
570
|
+
const payload = record.payload || {};
|
|
571
|
+
if (typeof payload.cwd !== 'string' || !payload.cwd) return null;
|
|
572
|
+
|
|
573
|
+
const lastActivity = record.timestamp ?? null;
|
|
574
|
+
const lastActivityMs = lastActivity ? (Date.parse(lastActivity) || null) : null;
|
|
575
|
+
|
|
576
|
+
let usagePct = null;
|
|
577
|
+
let usageWindowMin = null;
|
|
578
|
+
const tailBuf = await readTail(filePath, 32768);
|
|
579
|
+
if (tailBuf && tailBuf.length > 0) {
|
|
580
|
+
const tailText = tailBuf.toString('utf8');
|
|
581
|
+
const usage = extractUsageFromTail(tailText);
|
|
582
|
+
if (usage) {
|
|
583
|
+
usagePct = usage.usagePct;
|
|
584
|
+
usageWindowMin = usage.usageWindowMin;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return {
|
|
589
|
+
cwd: payload.cwd,
|
|
590
|
+
sessionId: payload.id ?? null,
|
|
591
|
+
lastActivity,
|
|
592
|
+
lastActivityMs,
|
|
593
|
+
model: null,
|
|
594
|
+
aiTitle: null,
|
|
595
|
+
customTitle: null,
|
|
596
|
+
transcriptPath: filePath,
|
|
597
|
+
mtime,
|
|
598
|
+
transcriptPending: false,
|
|
599
|
+
pendingToolUseId: null,
|
|
600
|
+
pendingQuestion: null,
|
|
601
|
+
agentType: 'codex',
|
|
602
|
+
usagePct,
|
|
603
|
+
usageWindowMin,
|
|
604
|
+
};
|
|
605
|
+
} catch {
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ---------------------------------------------------------------------------
|
|
611
|
+
// parseLsofRollout
|
|
612
|
+
//
|
|
613
|
+
// Pure parser for `lsof -Fn` stdout output. Returns the first open file path
|
|
614
|
+
// that matches a Codex rollout pattern (/rollout-*.jsonl), or null if none.
|
|
615
|
+
//
|
|
616
|
+
// lsof -Fn output format:
|
|
617
|
+
// p<pid>
|
|
618
|
+
// f<fd>
|
|
619
|
+
// n<path>
|
|
620
|
+
// ...
|
|
621
|
+
//
|
|
622
|
+
// We look only at lines starting with 'n' whose remainder ends with
|
|
623
|
+
// /rollout-<something>.jsonl.
|
|
624
|
+
// ---------------------------------------------------------------------------
|
|
625
|
+
export function parseLsofRollout(stdout) {
|
|
626
|
+
if (!stdout) return null;
|
|
627
|
+
const lines = stdout.split('\n');
|
|
628
|
+
for (const line of lines) {
|
|
629
|
+
if (!line.startsWith('n')) continue;
|
|
630
|
+
const filePath = line.slice(1);
|
|
631
|
+
if (/\/rollout-[^/]*\.jsonl$/.test(filePath)) return filePath;
|
|
632
|
+
}
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ---------------------------------------------------------------------------
|
|
637
|
+
// findOpenRollout
|
|
638
|
+
//
|
|
639
|
+
// Given a codex process pid, run `lsof -p <pid> -Fn` and return the path of
|
|
640
|
+
// the rollout .jsonl file the process has open, or null if not found / any
|
|
641
|
+
// error / pid is null/invalid.
|
|
642
|
+
//
|
|
643
|
+
// Best-effort: any lsof failure (timeout, non-zero exit, ENOENT, etc.)
|
|
644
|
+
// returns null and never throws — the caller falls back to the heuristic.
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
646
|
+
export async function findOpenRollout(pid) {
|
|
647
|
+
if (pid == null || typeof pid !== 'number' || !Number.isFinite(pid) || pid <= 0) return null;
|
|
648
|
+
try {
|
|
649
|
+
const { stdout } = await execFile(
|
|
650
|
+
'/usr/sbin/lsof',
|
|
651
|
+
['-p', String(pid), '-Fn'],
|
|
652
|
+
{ timeout: 2000 },
|
|
653
|
+
);
|
|
654
|
+
return parseLsofRollout(stdout);
|
|
655
|
+
} catch {
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
223
660
|
// ---------------------------------------------------------------------------
|
|
224
661
|
// buildTranscriptIndex
|
|
225
662
|
//
|
|
@@ -231,7 +668,7 @@ export function parseCodexRecord(line) {
|
|
|
231
668
|
// care about the clock may omit it and get `new Date()`.
|
|
232
669
|
// ---------------------------------------------------------------------------
|
|
233
670
|
export async function buildTranscriptIndex({ codexSessionsRoot }, now = new Date()) {
|
|
234
|
-
const index = { byCwd: new Map() };
|
|
671
|
+
const index = { byCwd: new Map(), byPath: new Map(), bySessionId: new Map() };
|
|
235
672
|
|
|
236
673
|
if (!codexSessionsRoot) return index;
|
|
237
674
|
|
|
@@ -244,10 +681,26 @@ export async function buildTranscriptIndex({ codexSessionsRoot }, now = new Date
|
|
|
244
681
|
return path.join(codexSessionsRoot, yyyy, mm, dd);
|
|
245
682
|
}
|
|
246
683
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
//
|
|
250
|
-
|
|
684
|
+
// Codex appends to ONE rollout file per session, stored under its START-date
|
|
685
|
+
// dir — so a long-running session's file stays in an old date dir while its
|
|
686
|
+
// mtime keeps advancing. Scanning only today+yesterday therefore loses any
|
|
687
|
+
// session that started >1 day ago but is still active (it vanishes from the
|
|
688
|
+
// UI). Scan the last LOOKBACK_DAYS date dirs (cheap readdir + stat), but only
|
|
689
|
+
// parse rollouts whose mtime is recent (ACTIVE_WINDOW) so we never head/tail-
|
|
690
|
+
// read the thousands of dead rollouts that accumulate over time.
|
|
691
|
+
// ponytail: 14-day start-age ceiling — a codex session running continuously
|
|
692
|
+
// for >14 days would need a wider window; widen LOOKBACK_DAYS if that happens.
|
|
693
|
+
const LOOKBACK_DAYS = 14;
|
|
694
|
+
const ACTIVE_WINDOW_MS = 3 * 24 * 3600 * 1000;
|
|
695
|
+
const dateDirs = [];
|
|
696
|
+
const seenDirs = new Set();
|
|
697
|
+
for (let i = 0; i < LOOKBACK_DAYS; i++) {
|
|
698
|
+
const dp = datePath(new Date(now.getTime() - i * 24 * 3600 * 1000));
|
|
699
|
+
if (!seenDirs.has(dp)) {
|
|
700
|
+
seenDirs.add(dp);
|
|
701
|
+
dateDirs.push(dp);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
251
704
|
|
|
252
705
|
for (const dateDir of dateDirs) {
|
|
253
706
|
let files;
|
|
@@ -264,49 +717,30 @@ export async function buildTranscriptIndex({ codexSessionsRoot }, now = new Date
|
|
|
264
717
|
rollouts.map(async (filename) => {
|
|
265
718
|
const filePath = path.join(dateDir, filename);
|
|
266
719
|
try {
|
|
267
|
-
// Stat for mtime.
|
|
720
|
+
// Stat for mtime gate (active window check).
|
|
268
721
|
const stat = await fs.stat(filePath);
|
|
269
722
|
const mtime = stat.mtimeMs;
|
|
270
723
|
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const firstLine = text.split('\n')[0];
|
|
277
|
-
if (!firstLine || !firstLine.trim()) return;
|
|
724
|
+
// Skip dead sessions: only parse rollouts touched within ACTIVE_WINDOW.
|
|
725
|
+
// This keeps the expensive head/tail reads bounded to live sessions even
|
|
726
|
+
// though we now scan many more date dirs. (now - mtime can be negative
|
|
727
|
+
// under an injected test clock — treated as active, never skipped.)
|
|
728
|
+
if (now.getTime() - mtime > ACTIVE_WINDOW_MS) return;
|
|
278
729
|
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
};
|
|
730
|
+
const discovered = await readCodexTranscriptRecord(filePath);
|
|
731
|
+
if (!discovered) return;
|
|
305
732
|
|
|
306
733
|
// Newest mtime wins per cwd.
|
|
307
|
-
const existing = index.byCwd.get(
|
|
308
|
-
if (!existing || mtime > existing.mtime) {
|
|
309
|
-
index.byCwd.set(
|
|
734
|
+
const existing = index.byCwd.get(discovered.cwd);
|
|
735
|
+
if (!existing || discovered.mtime > existing.mtime) {
|
|
736
|
+
index.byCwd.set(discovered.cwd, discovered);
|
|
737
|
+
}
|
|
738
|
+
index.byPath.set(discovered.transcriptPath, discovered);
|
|
739
|
+
if (discovered.sessionId) {
|
|
740
|
+
const byId = index.bySessionId.get(discovered.sessionId);
|
|
741
|
+
if (!byId || discovered.mtime > byId.mtime) {
|
|
742
|
+
index.bySessionId.set(discovered.sessionId, discovered);
|
|
743
|
+
}
|
|
310
744
|
}
|
|
311
745
|
} catch {
|
|
312
746
|
// Per-file resilience: skip malformed or unreadable files.
|
|
@@ -315,7 +749,6 @@ export async function buildTranscriptIndex({ codexSessionsRoot }, now = new Date
|
|
|
315
749
|
);
|
|
316
750
|
}
|
|
317
751
|
|
|
318
|
-
// Return byCwd only — no byDir key. sessions.js merge loop guards `if (byDir)`.
|
|
319
752
|
return index;
|
|
320
753
|
}
|
|
321
754
|
|
|
@@ -340,6 +773,12 @@ export function detectPendingFromCapture(capture) {
|
|
|
340
773
|
|
|
341
774
|
const lines = capture.split('\n');
|
|
342
775
|
|
|
776
|
+
// Build a whitespace-free concatenation of all lines for wrap-tolerant heading
|
|
777
|
+
// matching. A narrow pane may break mid-word (e.g. "follo" + "wing"), so joining
|
|
778
|
+
// with a space would produce "follo wing" — not matching "following". Instead we
|
|
779
|
+
// strip all whitespace from both the candidate and the heading before comparing.
|
|
780
|
+
const dewrapped = lines.join('').replace(/\s+/g, '');
|
|
781
|
+
|
|
343
782
|
const headings = [
|
|
344
783
|
{ text: 'Would you like to run the following command?', kind: 'exec_command' },
|
|
345
784
|
{ text: 'Would you like to make the following edits?', kind: 'apply_patch' },
|
|
@@ -350,12 +789,13 @@ export function detectPendingFromCapture(capture) {
|
|
|
350
789
|
let header = null;
|
|
351
790
|
let headingIdx = -1;
|
|
352
791
|
|
|
792
|
+
// First try per-line exact match (fast path, no allocation).
|
|
353
793
|
for (let i = 0; i < lines.length; i++) {
|
|
354
794
|
const trimmed = lines[i].trim();
|
|
355
795
|
for (const h of headings) {
|
|
356
796
|
if (trimmed === h.text) {
|
|
357
797
|
pendingKind = h.kind;
|
|
358
|
-
header =
|
|
798
|
+
header = h.text;
|
|
359
799
|
headingIdx = i;
|
|
360
800
|
break;
|
|
361
801
|
}
|
|
@@ -363,38 +803,129 @@ export function detectPendingFromCapture(capture) {
|
|
|
363
803
|
if (headingIdx !== -1) break;
|
|
364
804
|
}
|
|
365
805
|
|
|
366
|
-
|
|
806
|
+
// If exact per-line match failed, try wrap-tolerant match by comparing
|
|
807
|
+
// whitespace-stripped strings. A narrow pane can break mid-word (e.g.
|
|
808
|
+
// "follo" + "wing"), so we strip all whitespace from both the candidate
|
|
809
|
+
// and the heading before comparing, then use the canonical heading text.
|
|
810
|
+
if (!pendingKind) {
|
|
811
|
+
for (const h of headings) {
|
|
812
|
+
const headingStripped = h.text.replace(/\s+/g, '');
|
|
813
|
+
if (dewrapped.includes(headingStripped)) {
|
|
814
|
+
pendingKind = h.kind;
|
|
815
|
+
header = h.text;
|
|
816
|
+
// Locate the line that starts the heading by finding the first line
|
|
817
|
+
// that contains the opening word(s). The heading start line is the
|
|
818
|
+
// anchor from which we begin option scanning (after the heading block).
|
|
819
|
+
const firstWord = h.text.split(' ')[0];
|
|
820
|
+
for (let i = 0; i < lines.length; i++) {
|
|
821
|
+
if (lines[i].includes(firstWord)) {
|
|
822
|
+
headingIdx = i;
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
break;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
367
830
|
|
|
368
|
-
// Scan lines after the heading for option lines.
|
|
369
831
|
// Option line regex: /^\s*[›\s]\s*(\d+)\.\s+(.+?)(?:\s+\(([^)]+)\))?\s*$/
|
|
370
832
|
// U+203A = ›
|
|
371
833
|
const optionLineRegex = /^\s*[›\s]\s*(\d+)\.\s+(.+?)(?:\s+\(([^)]+)\))?\s*$/;
|
|
372
|
-
|
|
834
|
+
|
|
835
|
+
// Footer detection — prefix-based so wrapped footers still stop collection.
|
|
836
|
+
const isFooterLine = (line) => {
|
|
837
|
+
const t = line.trim();
|
|
838
|
+
return (
|
|
839
|
+
t.startsWith('Press enter to confirm or esc to') ||
|
|
840
|
+
t.startsWith('Press enter to continue')
|
|
841
|
+
);
|
|
842
|
+
};
|
|
373
843
|
|
|
374
844
|
const options = [];
|
|
375
845
|
let seenOption = false;
|
|
376
846
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
847
|
+
if (pendingKind && headingIdx !== -1) {
|
|
848
|
+
for (let i = headingIdx + 1; i < lines.length; i++) {
|
|
849
|
+
const raw = lines[i];
|
|
850
|
+
const trimmed = raw.trim();
|
|
851
|
+
|
|
852
|
+
// Check footer hint — stop collecting after it.
|
|
853
|
+
if (isFooterLine(raw)) break;
|
|
854
|
+
|
|
855
|
+
const m = optionLineRegex.exec(raw);
|
|
856
|
+
if (m) {
|
|
857
|
+
seenOption = true;
|
|
858
|
+
options.push({
|
|
859
|
+
n: Number(m[1]),
|
|
860
|
+
label: m[2].trim(),
|
|
861
|
+
shortcut: m[3] || null,
|
|
862
|
+
// Highlighted if the raw line contains the › character (U+203A).
|
|
863
|
+
highlighted: raw.includes('›'),
|
|
864
|
+
});
|
|
865
|
+
} else if (seenOption && trimmed && !m) {
|
|
866
|
+
// First non-blank, non-option line after at least one option was captured.
|
|
867
|
+
break;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (options.length === 0 && pendingKind) return noModal;
|
|
873
|
+
|
|
874
|
+
// ── Generic fallback: planning / clarifying question ───────────────────────
|
|
875
|
+
// No known heading matched. Check whether the capture contains a numbered
|
|
876
|
+
// picker (Codex uses › 1. / 2. lines) AND a confirm/cancel footer. If so,
|
|
877
|
+
// treat it as a free-form question with pendingKind='question'.
|
|
878
|
+
if (!pendingKind) {
|
|
879
|
+
// Scan for footer first — a footer is required.
|
|
880
|
+
const hasFooter = lines.some(isFooterLine);
|
|
881
|
+
if (hasFooter) {
|
|
882
|
+
// Collect options and find the index of the first option line.
|
|
883
|
+
let firstOptionIdx = -1;
|
|
884
|
+
const genericOptions = [];
|
|
885
|
+
let genericSeenOption = false;
|
|
886
|
+
for (let i = 0; i < lines.length; i++) {
|
|
887
|
+
const raw = lines[i];
|
|
888
|
+
if (isFooterLine(raw)) break;
|
|
889
|
+
const m = optionLineRegex.exec(raw);
|
|
890
|
+
if (m) {
|
|
891
|
+
if (firstOptionIdx === -1) firstOptionIdx = i;
|
|
892
|
+
genericSeenOption = true;
|
|
893
|
+
genericOptions.push({
|
|
894
|
+
n: Number(m[1]),
|
|
895
|
+
label: m[2].trim(),
|
|
896
|
+
shortcut: m[3] || null,
|
|
897
|
+
highlighted: raw.includes('›'),
|
|
898
|
+
});
|
|
899
|
+
} else if (genericSeenOption && raw.trim() && !m) {
|
|
900
|
+
break;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (genericOptions.length > 0) {
|
|
905
|
+
// Derive header from the question block immediately above the options.
|
|
906
|
+
// Codex separates the question from the picker with a blank line, so we
|
|
907
|
+
// first skip any blanks directly above the first option, then collect the
|
|
908
|
+
// contiguous block of non-empty lines — stopping at the next blank. This
|
|
909
|
+
// captures the actual question without sweeping in preceding scrollback.
|
|
910
|
+
let i = firstOptionIdx - 1;
|
|
911
|
+
while (i >= 0 && !lines[i].trim()) i--; // skip the separator blank(s)
|
|
912
|
+
const questionLines = [];
|
|
913
|
+
for (; i >= 0; i--) {
|
|
914
|
+
const t = lines[i].trim();
|
|
915
|
+
if (!t) break;
|
|
916
|
+
questionLines.unshift(t);
|
|
917
|
+
}
|
|
918
|
+
const derivedHeader = questionLines.join(' ').replace(/\s+/g, ' ').trim() || 'Question';
|
|
919
|
+
|
|
920
|
+
return {
|
|
921
|
+
transcriptPending: true,
|
|
922
|
+
pendingKind: 'question',
|
|
923
|
+
header: derivedHeader,
|
|
924
|
+
options: genericOptions,
|
|
925
|
+
};
|
|
926
|
+
}
|
|
397
927
|
}
|
|
928
|
+
return noModal;
|
|
398
929
|
}
|
|
399
930
|
|
|
400
931
|
if (options.length === 0) return noModal;
|
|
@@ -473,17 +1004,51 @@ export function buildSpawnCommand({ cwd, bin = 'codex' } = {}) {
|
|
|
473
1004
|
return { bin, args: ['-C', cwd] };
|
|
474
1005
|
}
|
|
475
1006
|
|
|
1007
|
+
/**
|
|
1008
|
+
* Build the command shape for Codex app-server mode. `bin` is the configured
|
|
1009
|
+
* operator command (for example "codex" or "yodex"); callers append and quote
|
|
1010
|
+
* runtime args before typing the command into the tmux shell.
|
|
1011
|
+
*/
|
|
1012
|
+
export function buildAppServerCommand({ endpoint, bin = 'codex' } = {}) {
|
|
1013
|
+
return { bin, args: ['app-server', '--listen', endpoint] };
|
|
1014
|
+
}
|
|
1015
|
+
|
|
476
1016
|
// ---------------------------------------------------------------------------
|
|
477
1017
|
// parseTuiStatus
|
|
478
1018
|
//
|
|
479
1019
|
// Parse model name from a Codex TUI header capture.
|
|
480
1020
|
// The header contains: │ model: gpt-5.5 xhigh fast /model to change │
|
|
481
|
-
//
|
|
1021
|
+
// Captures model + effort token (e.g. "gpt-5.5 xhigh") so the rail shows
|
|
1022
|
+
// both the model name and the reasoning effort setting.
|
|
482
1023
|
// ctx% is not shown in the Codex TUI.
|
|
483
1024
|
// ---------------------------------------------------------------------------
|
|
484
1025
|
export function parseTuiStatus(capture) {
|
|
485
|
-
const
|
|
486
|
-
|
|
1026
|
+
const text = capture || '';
|
|
1027
|
+
// Match model name + optional effort token (e.g. "gpt-5.5 xhigh").
|
|
1028
|
+
// The header line looks like: "model: gpt-5.5 xhigh fast /model to change"
|
|
1029
|
+
// We capture the first token (model) and an optional second token (effort),
|
|
1030
|
+
// stopping before known non-effort tokens: "fast", "slow", "/model".
|
|
1031
|
+
const EFFORT_TOKENS = new Set(['xhigh', 'high', 'medium', 'low']);
|
|
1032
|
+
let model = null;
|
|
1033
|
+
// (1) Top header box (visible at session start, before output scrolls it off):
|
|
1034
|
+
// "model: gpt-5.5 xhigh fast /model to change"
|
|
1035
|
+
const header = /model:\s+(\S+)(?:\s+(\S+))?/.exec(text);
|
|
1036
|
+
if (header) {
|
|
1037
|
+
model = EFFORT_TOKENS.has((header[2] || '').toLowerCase())
|
|
1038
|
+
? `${header[1]} ${header[2]}`
|
|
1039
|
+
: header[1];
|
|
1040
|
+
}
|
|
1041
|
+
// (2) Persistent footer status line (always at the bottom, which is what the
|
|
1042
|
+
// 8-line ctx-poll capture actually sees): "gpt-5.5 xhigh Fast · <cwd>".
|
|
1043
|
+
// Capture model + optional effort, then the speed word, then the " · " cwd
|
|
1044
|
+
// separator. Used only when the header isn't in view.
|
|
1045
|
+
if (!model) {
|
|
1046
|
+
const footer = /^\s*([\w.\-]+)(?:\s+(xhigh|high|medium|low))?\s+\S+\s+·\s/m.exec(text);
|
|
1047
|
+
if (footer) model = footer[2] ? `${footer[1]} ${footer[2]}` : footer[1];
|
|
1048
|
+
}
|
|
1049
|
+
// Codex prints "• Working (<N>s • esc to interrupt)" while generating.
|
|
1050
|
+
const working = /esc to interrupt/.test(text) || /Working \(/.test(text);
|
|
1051
|
+
return { ctxPct: null, model, working };
|
|
487
1052
|
}
|
|
488
1053
|
|
|
489
1054
|
// ---------------------------------------------------------------------------
|