@idl3/claude-control 1.3.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 +553 -89
- package/lib/pane-registry.js +38 -3
- package/lib/prompt.js +60 -21
- package/lib/sessions.js +281 -60
- 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 -39
- package/web/dist/assets/{core-C29-1O9j.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-CT-y6LU4.css +0 -1
- package/web/dist/assets/index-DzIDTXLS.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
|
|
@@ -115,12 +119,194 @@ export function extractUsageFromTail(text) {
|
|
|
115
119
|
// a path ending in "/codex", or "codex" followed by a space (with flags).
|
|
116
120
|
// Does NOT match "codex-control" or version strings like "2.1.162".
|
|
117
121
|
// ---------------------------------------------------------------------------
|
|
118
|
-
export function
|
|
122
|
+
export function processMatchKind(cmd) {
|
|
119
123
|
const c = String(cmd || '').trim();
|
|
120
|
-
|
|
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;
|
|
121
134
|
}
|
|
122
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;
|
|
166
|
+
}
|
|
167
|
+
|
|
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.
|
|
123
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
|
+
|
|
124
310
|
// parseCodexRecord
|
|
125
311
|
//
|
|
126
312
|
// Parse one JSONL line from a Codex rollout file into a NormalizedMessage,
|
|
@@ -164,6 +350,7 @@ export function parseCodexRecord(line) {
|
|
|
164
350
|
if (Array.isArray(p.content)) {
|
|
165
351
|
for (const item of p.content) {
|
|
166
352
|
const text = item?.text;
|
|
353
|
+
if (isCodexSubagentNotificationText(text)) continue;
|
|
167
354
|
if (typeof text === 'string' && text) {
|
|
168
355
|
blocks.push({ kind: 'text', text });
|
|
169
356
|
}
|
|
@@ -180,13 +367,19 @@ export function parseCodexRecord(line) {
|
|
|
180
367
|
};
|
|
181
368
|
}
|
|
182
369
|
|
|
183
|
-
// --- 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]".
|
|
184
375
|
if (subType === 'reasoning') {
|
|
376
|
+
const summary = decodeReasoningSummary(p.summary);
|
|
377
|
+
if (!summary) return null;
|
|
185
378
|
return {
|
|
186
379
|
uuid: record.id ?? null,
|
|
187
380
|
role: 'assistant',
|
|
188
381
|
ts,
|
|
189
|
-
blocks: [{ kind: 'thinking', text:
|
|
382
|
+
blocks: [{ kind: 'thinking', text: summary }],
|
|
190
383
|
rawType: 'reasoning',
|
|
191
384
|
};
|
|
192
385
|
}
|
|
@@ -277,6 +470,193 @@ export function parseCodexRecord(line) {
|
|
|
277
470
|
return null;
|
|
278
471
|
}
|
|
279
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
|
+
|
|
280
660
|
// ---------------------------------------------------------------------------
|
|
281
661
|
// buildTranscriptIndex
|
|
282
662
|
//
|
|
@@ -288,7 +668,7 @@ export function parseCodexRecord(line) {
|
|
|
288
668
|
// care about the clock may omit it and get `new Date()`.
|
|
289
669
|
// ---------------------------------------------------------------------------
|
|
290
670
|
export async function buildTranscriptIndex({ codexSessionsRoot }, now = new Date()) {
|
|
291
|
-
const index = { byCwd: new Map() };
|
|
671
|
+
const index = { byCwd: new Map(), byPath: new Map(), bySessionId: new Map() };
|
|
292
672
|
|
|
293
673
|
if (!codexSessionsRoot) return index;
|
|
294
674
|
|
|
@@ -301,10 +681,26 @@ export async function buildTranscriptIndex({ codexSessionsRoot }, now = new Date
|
|
|
301
681
|
return path.join(codexSessionsRoot, yyyy, mm, dd);
|
|
302
682
|
}
|
|
303
683
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
//
|
|
307
|
-
|
|
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
|
+
}
|
|
308
704
|
|
|
309
705
|
for (const dateDir of dateDirs) {
|
|
310
706
|
let files;
|
|
@@ -321,69 +717,31 @@ export async function buildTranscriptIndex({ codexSessionsRoot }, now = new Date
|
|
|
321
717
|
rollouts.map(async (filename) => {
|
|
322
718
|
const filePath = path.join(dateDir, filename);
|
|
323
719
|
try {
|
|
324
|
-
// Stat for mtime.
|
|
720
|
+
// Stat for mtime gate (active window check).
|
|
325
721
|
const stat = await fs.stat(filePath);
|
|
326
722
|
const mtime = stat.mtimeMs;
|
|
327
723
|
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
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;
|
|
331
729
|
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
if (!firstLine || !firstLine.trim()) return;
|
|
730
|
+
const discovered = await readCodexTranscriptRecord(filePath);
|
|
731
|
+
if (!discovered) return;
|
|
335
732
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
return;
|
|
733
|
+
// Newest mtime wins per cwd.
|
|
734
|
+
const existing = index.byCwd.get(discovered.cwd);
|
|
735
|
+
if (!existing || discovered.mtime > existing.mtime) {
|
|
736
|
+
index.byCwd.set(discovered.cwd, discovered);
|
|
341
737
|
}
|
|
342
|
-
|
|
343
|
-
if (
|
|
344
|
-
|
|
345
|
-
|
|
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;
|
|
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);
|
|
360
743
|
}
|
|
361
744
|
}
|
|
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
745
|
} catch {
|
|
388
746
|
// Per-file resilience: skip malformed or unreadable files.
|
|
389
747
|
}
|
|
@@ -391,7 +749,6 @@ export async function buildTranscriptIndex({ codexSessionsRoot }, now = new Date
|
|
|
391
749
|
);
|
|
392
750
|
}
|
|
393
751
|
|
|
394
|
-
// Return byCwd only — no byDir key. sessions.js merge loop guards `if (byDir)`.
|
|
395
752
|
return index;
|
|
396
753
|
}
|
|
397
754
|
|
|
@@ -416,6 +773,12 @@ export function detectPendingFromCapture(capture) {
|
|
|
416
773
|
|
|
417
774
|
const lines = capture.split('\n');
|
|
418
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
|
+
|
|
419
782
|
const headings = [
|
|
420
783
|
{ text: 'Would you like to run the following command?', kind: 'exec_command' },
|
|
421
784
|
{ text: 'Would you like to make the following edits?', kind: 'apply_patch' },
|
|
@@ -426,12 +789,13 @@ export function detectPendingFromCapture(capture) {
|
|
|
426
789
|
let header = null;
|
|
427
790
|
let headingIdx = -1;
|
|
428
791
|
|
|
792
|
+
// First try per-line exact match (fast path, no allocation).
|
|
429
793
|
for (let i = 0; i < lines.length; i++) {
|
|
430
794
|
const trimmed = lines[i].trim();
|
|
431
795
|
for (const h of headings) {
|
|
432
796
|
if (trimmed === h.text) {
|
|
433
797
|
pendingKind = h.kind;
|
|
434
|
-
header =
|
|
798
|
+
header = h.text;
|
|
435
799
|
headingIdx = i;
|
|
436
800
|
break;
|
|
437
801
|
}
|
|
@@ -439,38 +803,129 @@ export function detectPendingFromCapture(capture) {
|
|
|
439
803
|
if (headingIdx !== -1) break;
|
|
440
804
|
}
|
|
441
805
|
|
|
442
|
-
|
|
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
|
+
}
|
|
443
830
|
|
|
444
|
-
// Scan lines after the heading for option lines.
|
|
445
831
|
// Option line regex: /^\s*[›\s]\s*(\d+)\.\s+(.+?)(?:\s+\(([^)]+)\))?\s*$/
|
|
446
832
|
// U+203A = ›
|
|
447
833
|
const optionLineRegex = /^\s*[›\s]\s*(\d+)\.\s+(.+?)(?:\s+\(([^)]+)\))?\s*$/;
|
|
448
|
-
|
|
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
|
+
};
|
|
449
843
|
|
|
450
844
|
const options = [];
|
|
451
845
|
let seenOption = false;
|
|
452
846
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
+
}
|
|
473
927
|
}
|
|
928
|
+
return noModal;
|
|
474
929
|
}
|
|
475
930
|
|
|
476
931
|
if (options.length === 0) return noModal;
|
|
@@ -549,6 +1004,15 @@ export function buildSpawnCommand({ cwd, bin = 'codex' } = {}) {
|
|
|
549
1004
|
return { bin, args: ['-C', cwd] };
|
|
550
1005
|
}
|
|
551
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
|
+
|
|
552
1016
|
// ---------------------------------------------------------------------------
|
|
553
1017
|
// parseTuiStatus
|
|
554
1018
|
//
|