@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/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 matchesProcess(cmd) {
122
+ export function processMatchKind(cmd) {
62
123
  const c = String(cmd || '').trim();
63
- return c === 'codex' || /(^|\/)codex$/.test(c) || /^codex\s/.test(c);
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 (encrypted) ---
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: '[reasoning encrypted]' }],
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
- 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];
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
- // 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;
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
- 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
- };
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(payload.cwd);
308
- if (!existing || mtime > existing.mtime) {
309
- index.byCwd.set(payload.cwd, discovered);
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 = trimmed;
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
- if (!pendingKind) return noModal;
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
- const footerHints = ['Press enter to confirm or esc to cancel', 'Press enter to continue'];
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
- 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;
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
- // Extracts the model identifier immediately after "model:" with optional whitespace.
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 m = /model:\s+(\S+)/.exec(capture || '');
486
- return { ctxPct: null, model: m ? m[1] : null };
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
  // ---------------------------------------------------------------------------