@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/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 matchesProcess(cmd) {
122
+ export function processMatchKind(cmd) {
119
123
  const c = String(cmd || '').trim();
120
- 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;
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 (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]".
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: '[reasoning encrypted]' }],
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
- const today = datePath(now);
305
- const yesterday = datePath(new Date(now.getTime() - 24 * 3600 * 1000));
306
- // Dedup if equal (e.g. right at midnight boundary)
307
- 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
+ }
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
- // Head-read only the first 65536 bytes to extract session_meta.
329
- const buf = await readHead(filePath, 65536);
330
- if (!buf || buf.length === 0) 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;
331
729
 
332
- const text = buf.toString('utf8');
333
- const firstLine = text.split('\n')[0];
334
- if (!firstLine || !firstLine.trim()) return;
730
+ const discovered = await readCodexTranscriptRecord(filePath);
731
+ if (!discovered) return;
335
732
 
336
- let record;
337
- try {
338
- record = JSON.parse(firstLine.trim());
339
- } catch {
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 (record.type !== 'session_meta') return;
344
- const payload = record.payload || {};
345
- if (typeof payload.cwd !== 'string' || !payload.cwd) return;
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 = trimmed;
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
- 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
+ }
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
- 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
+ };
449
843
 
450
844
  const options = [];
451
845
  let seenOption = false;
452
846
 
453
- for (let i = headingIdx + 1; i < lines.length; i++) {
454
- const raw = lines[i];
455
- const trimmed = raw.trim();
456
-
457
- // Check footer hint — stop collecting after it.
458
- if (footerHints.includes(trimmed)) break;
459
-
460
- const m = optionLineRegex.exec(raw);
461
- if (m) {
462
- seenOption = true;
463
- options.push({
464
- n: Number(m[1]),
465
- label: m[2].trim(),
466
- shortcut: m[3] || null,
467
- // Highlighted if the raw line contains the › character (U+203A).
468
- highlighted: raw.includes('›'),
469
- });
470
- } else if (seenOption && trimmed && !m) {
471
- // First non-blank, non-option line after at least one option was captured.
472
- 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
+ }
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
  //