@idl3/claude-control 0.4.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/sessions.js CHANGED
@@ -15,14 +15,17 @@ import { promisify } from 'node:util';
15
15
 
16
16
  import { parseTuiStatus, prettyModel } from './tui.js';
17
17
  import { parsePanePrompt } from './prompt.js';
18
- import { assignTranscripts, parseEtime } from './match.js';
18
+ import { assignTranscripts, parseEtime, fingerprintScore, shouldRebind } from './match.js';
19
19
  import { pinKey } from './pins.js';
20
20
  import { readPaneRegistry, gcPaneRegistry } from './pane-registry.js';
21
+ import { matchesProcess as codexMatchesProcess, buildTranscriptIndex as buildCodexIndex } from './codex.js';
21
22
 
22
23
  const execFile = promisify(_execFile);
23
24
 
24
25
  // Matches Claude Code's executable basename (e.g. /Users/x/.local/bin/claude).
25
26
  const CLAUDE_COMM_RE = /(^|\/)claude$/;
27
+ // Matches Codex CLI executable basename.
28
+ const CODEX_COMM_RE = /(^|\/)codex$/;
26
29
 
27
30
  // A pane is a Claude Code session when its process title is the Claude version
28
31
  // (e.g. "2.1.162") — shells report zsh/bash/etc. A linked transcript also counts.
@@ -35,6 +38,10 @@ const REFRESH_INTERVAL_MS = 4000;
35
38
  const CTX_POLL_INTERVAL_MS = 12000; // TUI ctx%/model capture — slower than refresh
36
39
  const THINKING_POLL_INTERVAL_MS = 2000; // bottom-5-line capture for the live "thinking" flag
37
40
 
41
+ // Self-heal: minimum number of refresh() cycles between consecutive rebinds for
42
+ // the same pane. Prevents rapid-fire flapping when borderline scores oscillate.
43
+ const SELFHEAL_DEBOUNCE_CYCLES = 5;
44
+
38
45
  /**
39
46
  * Encode an absolute cwd the way Claude Code names its transcript project
40
47
  * directories: every '/' and '.' becomes '-'. This is derived from the cwd the
@@ -192,6 +199,7 @@ async function extractTailRecord(filePath, mtime, birthtime = null) {
192
199
  transcriptPending: false,
193
200
  pendingToolUseId: null,
194
201
  pendingQuestion: null,
202
+ recentText: null,
195
203
  };
196
204
 
197
205
  // Transcript-derived pending: detect an AskUserQuestion that is open in the
@@ -202,9 +210,12 @@ async function extractTailRecord(filePath, mtime, birthtime = null) {
202
210
  base.pendingToolUseId = pending.pendingToolUseId;
203
211
  base.pendingQuestion = pending.pendingQuestion;
204
212
 
205
- // Walk from end collecting the newest cwd/sessionId/timestamp/model/title.
213
+ // Walk from end collecting the newest cwd/sessionId/timestamp/model/title,
214
+ // and the most recent assistant message texts for the content-fingerprint tiebreak.
206
215
  // ai-title is re-emitted throughout the file so the tail usually carries it;
207
216
  // custom-title (a user /rename) is written when renamed, so it appears late.
217
+ const recentSnippets = [];
218
+ const MAX_RECENT_SNIPPETS = 3;
208
219
  for (let i = lines.length - 1; i >= 0; i--) {
209
220
  const line = lines[i].trim();
210
221
  if (!line) continue;
@@ -221,10 +232,25 @@ async function extractTailRecord(filePath, mtime, birthtime = null) {
221
232
  if (base.aiTitle === null && rec.type === 'ai-title' && rec.aiTitle) base.aiTitle = rec.aiTitle;
222
233
  if (base.model === null && rec.type === 'assistant' && typeof rec.message?.model === 'string') base.model = rec.message.model;
223
234
  if (base.cwd === null && typeof rec.cwd === 'string' && rec.cwd) base.cwd = rec.cwd;
224
- if (base.cwd && base.sessionId && base.model && (base.customTitle || base.aiTitle)) {
235
+ // Collect recent assistant text for content-fingerprint tiebreak. Walk
236
+ // text content blocks from the most recent assistant messages backwards.
237
+ if (recentSnippets.length < MAX_RECENT_SNIPPETS && rec.type === 'assistant') {
238
+ const content = rec.message?.content;
239
+ if (Array.isArray(content)) {
240
+ for (const block of content) {
241
+ if (block?.type === 'text' && typeof block.text === 'string' && block.text.length > 0) {
242
+ recentSnippets.push(block.text.slice(0, 500));
243
+ break; // one text block per message is enough
244
+ }
245
+ }
246
+ }
247
+ }
248
+ if (base.cwd && base.sessionId && base.model && (base.customTitle || base.aiTitle) &&
249
+ recentSnippets.length >= MAX_RECENT_SNIPPETS) {
225
250
  break; // everything found
226
251
  }
227
252
  }
253
+ if (recentSnippets.length > 0) base.recentText = recentSnippets.join(' ');
228
254
  return base;
229
255
  }
230
256
 
@@ -314,11 +340,12 @@ export async function listRecentTranscripts({ projectsRoot, limit = 60 }) {
314
340
 
315
341
  export class SessionRegistry extends EventEmitter {
316
342
  /**
317
- * @param {{ projectsRoot: string, tmux: object, debounceMs?: number }} opts
343
+ * @param {{ projectsRoot: string, codexSessionsRoot?: string, tmux: object, debounceMs?: number }} opts
318
344
  */
319
- constructor({ projectsRoot, tmux, debounceMs = 1000 } = {}) {
345
+ constructor({ projectsRoot, codexSessionsRoot, tmux, debounceMs = 1000 } = {}) {
320
346
  super();
321
347
  this._projectsRoot = projectsRoot;
348
+ this._codexSessionsRoot = codexSessionsRoot;
322
349
  this._tmux = tmux;
323
350
  this._debounceMs = debounceMs;
324
351
 
@@ -336,12 +363,28 @@ export class SessionRegistry extends EventEmitter {
336
363
  this._thinkingMap = new Map();
337
364
  /** @type {Map<string, {pending:boolean, question:string|null}>} target -> pane-derived prompt */
338
365
  this._panePromptMap = new Map();
366
+ /** @type {Map<string, string>} target -> most-recent captured pane text (for fingerprint tiebreak) */
367
+ this._paneTextCache = new Map();
368
+ /** @type {number} monotonically-incrementing refresh() cycle counter */
369
+ this._refreshCycle = 0;
370
+ /** @type {Map<string, number>} target -> refresh cycle on which it was last self-healed */
371
+ this._healLastCycle = new Map();
339
372
  /** @type {ReturnType<setInterval>|null} */
340
373
  this._interval = null;
341
374
  /** @type {ReturnType<setInterval>|null} */
342
375
  this._ctxInterval = null;
343
376
  /** @type {ReturnType<setInterval>|null} */
344
377
  this._thinkingInterval = null;
378
+
379
+ // Re-entrancy guards: skip a tick if the previous one is still in flight.
380
+ // Each flag is owned exclusively by its worker; reset in finally() so a
381
+ // rejected shellout cannot wedge the flag permanently.
382
+ /** @type {boolean} */
383
+ this._refreshing = false;
384
+ /** @type {boolean} */
385
+ this._pollingCtx = false;
386
+ /** @type {boolean} */
387
+ this._pollingThinking = false;
345
388
  }
346
389
 
347
390
  // -------------------------------------------------------------------------
@@ -387,6 +430,17 @@ export class SessionRegistry extends EventEmitter {
387
430
  * @returns {Promise<Session[]>}
388
431
  */
389
432
  async refresh() {
433
+ if (this._refreshing) return;
434
+ this._refreshing = true;
435
+ try {
436
+ return await this._doRefresh();
437
+ } finally {
438
+ this._refreshing = false;
439
+ }
440
+ }
441
+
442
+ /** @private — the actual refresh body; called only when not already in flight. */
443
+ async _doRefresh() {
390
444
  const allPanes = await this._listWindows();
391
445
 
392
446
  // Grouped tmux sessions (e.g. a `_mobile` mirror of session `0`) expose the
@@ -401,14 +455,21 @@ export class SessionRegistry extends EventEmitter {
401
455
  return true;
402
456
  });
403
457
 
404
- // Classify every pane by its process subtree (a `claude` descendant) and get
405
- // its claude start time in one ps snapshot. Falls back to the cmd heuristic
458
+ // Classify every pane by its process subtree (a `claude` or `codex` descendant)
459
+ // and get its start time in one ps snapshot. Falls back to the cmd heuristic
406
460
  // only when ps is unavailable.
407
461
  const paneProc = await this._buildPaneProc(panes);
408
462
  const isClaudePane = (p) => {
409
463
  const info = paneProc.get(p.target);
410
464
  return info ? info.isClaude : isClaudeCmd(p.cmd);
411
465
  };
466
+ const paneKind = (p) => {
467
+ const info = paneProc.get(p.target);
468
+ if (info?.kind) return info.kind;
469
+ if (isClaudeCmd(p.cmd)) return 'claude';
470
+ if (codexMatchesProcess(p.cmd)) return 'codex';
471
+ return 'terminal';
472
+ };
412
473
  const claudePanes = panes.filter(isClaudePane);
413
474
 
414
475
  // The exact pane→transcript map authored by the SessionStart hook. This is
@@ -460,15 +521,108 @@ export class SessionRegistry extends EventEmitter {
460
521
  cwd: p.cwd,
461
522
  projectDir: encodeCwd(p.cwd), // scope candidates to this pane's own slug dir
462
523
  procStartMs: paneProc.get(p.target)?.startMs ?? null,
524
+ // Cached from the last _pollThinking() run — used by the content-fingerprint
525
+ // tiebreak when timing signals cannot distinguish same-cwd candidates.
526
+ capturedText: this._paneTextCache.get(p.target) ?? null,
463
527
  })),
464
528
  candidates,
465
529
  );
466
530
  for (const [target, rec] of pinnedByTarget) assignment.set(target, rec);
467
531
  for (const [target, rec] of hookByTarget) assignment.set(target, rec);
468
532
 
533
+ // ── Self-heal pass (PLE-44) ───────────────────────────────────────────────
534
+ // Re-verify each MATCHER-bound pane (not pinned, not registry-hooked) against
535
+ // all candidates to catch drift that wasn't caught at initial binding time.
536
+ // Registry-pinned panes are authoritative and are NEVER re-evaluated here.
537
+ this._refreshCycle++;
538
+ for (const p of autoPanes) {
539
+ const target = p.target;
540
+ const currentRec = assignment.get(target);
541
+ if (!currentRec) continue; // unmatched — nothing to heal
542
+
543
+ // Debounce: skip panes re-bound too recently to avoid flapping.
544
+ const lastHeal = this._healLastCycle.get(target) ?? -Infinity;
545
+ if (this._refreshCycle - lastHeal < SELFHEAL_DEBOUNCE_CYCLES) continue;
546
+
547
+ const paneText = this._paneTextCache.get(target) ?? null;
548
+ if (!paneText) continue; // no captured text yet — cannot score
549
+
550
+ const currentScore = fingerprintScore(paneText, currentRec.recentText ?? null);
551
+
552
+ // Find the best OTHER candidate in the same pool.
553
+ let bestOtherRec = null;
554
+ let bestOtherScore = 0;
555
+ for (const c of candidates) {
556
+ if (c.transcriptPath === currentRec.transcriptPath) continue;
557
+ const s = fingerprintScore(paneText, c.recentText ?? null);
558
+ if (s > bestOtherScore) {
559
+ bestOtherScore = s;
560
+ bestOtherRec = c;
561
+ }
562
+ }
563
+
564
+ if (!bestOtherRec) continue; // no alternative — nothing to heal to
565
+ if (!shouldRebind(currentScore, bestOtherScore)) continue;
566
+
567
+ // Re-bind.
568
+ const oldPath = currentRec.transcriptPath;
569
+ assignment.set(target, bestOtherRec);
570
+ this._healLastCycle.set(target, this._refreshCycle);
571
+ console.log(
572
+ `[pane-selfheal] re-bound ${target}: ${oldPath} (score ${currentScore}) → ` +
573
+ `${bestOtherRec.transcriptPath} (score ${bestOtherScore})`,
574
+ );
575
+ }
576
+ // ── End self-heal ─────────────────────────────────────────────────────────
577
+
578
+ // ── Codex pane → transcript matching ────────────────────────────────────
579
+ // Discover Codex session transcripts and match them to Codex panes.
580
+ // The Claude assignment above is computed first and left untouched;
581
+ // codex results are merged in after.
582
+ const codexPanes = panes.filter((p) => paneKind(p) === 'codex');
583
+ if (codexPanes.length > 0) {
584
+ const codexIndex = await buildCodexIndex({ codexSessionsRoot: this._codexSessionsRoot });
585
+ const codexCandidates = [];
586
+ for (const rec of codexIndex.byCwd.values()) {
587
+ codexCandidates.push({
588
+ transcriptPath: rec.transcriptPath,
589
+ cwd: rec.cwd,
590
+ projectDir: null, // triggers isCwdConsistent scope fallback in match.js
591
+ birthtimeMs: rec.mtime,
592
+ mtimeMs: rec.mtime,
593
+ lastActivityMs: rec.mtime,
594
+ customTitle: rec.customTitle,
595
+ aiTitle: rec.aiTitle,
596
+ recentText: null,
597
+ // Pass through for later session assembly
598
+ sessionId: rec.sessionId,
599
+ lastActivity: rec.lastActivity,
600
+ model: rec.model,
601
+ transcriptPending: rec.transcriptPending,
602
+ pendingToolUseId: rec.pendingToolUseId,
603
+ pendingQuestion: rec.pendingQuestion,
604
+ agentType: rec.agentType,
605
+ mtime: rec.mtime,
606
+ });
607
+ }
608
+ const codexPaneInputs = codexPanes.map((p) => ({
609
+ target: p.target,
610
+ windowName: p.windowName,
611
+ cwd: p.cwd,
612
+ projectDir: null,
613
+ procStartMs: paneProc.get(p.target)?.startMs ?? null,
614
+ capturedText: this._paneTextCache.get(p.target) ?? null,
615
+ }));
616
+ const codexAssignment = assignTranscripts(codexPaneInputs, codexCandidates);
617
+ for (const [t, rec] of codexAssignment) assignment.set(t, rec);
618
+ }
619
+ // ── End Codex matching ───────────────────────────────────────────────────
620
+
469
621
  const sessions = panes.map((win) => {
470
622
  const isClaude = isClaudePane(win);
471
- const transcript = isClaude ? assignment.get(win.target) ?? null : null;
623
+ const kind = paneKind(win);
624
+ const hasTranscript = kind === 'claude' || kind === 'codex';
625
+ const transcript = hasTranscript ? assignment.get(win.target) ?? null : null;
472
626
  const isPinned = pinnedByTarget.has(win.target);
473
627
  const id = win.target;
474
628
  // Pending = subscribed-tailer pending (live modal) OR transcript-derived
@@ -505,7 +659,7 @@ export class SessionRegistry extends EventEmitter {
505
659
  pendingQuestion: transcript?.pendingQuestion ?? panePrompt?.question ?? null,
506
660
  cmd: win.cmd,
507
661
  isClaude,
508
- kind: isClaude ? 'claude' : 'terminal',
662
+ kind,
509
663
  ccShell: !!win.ccShell, // a composer >_ sister shell pane
510
664
 
511
665
  model: ctx.model || prettyModel(transcript?.model) || null,
@@ -527,6 +681,9 @@ export class SessionRegistry extends EventEmitter {
527
681
  * cheap but we keep it off the hot path per the resource doctrine.
528
682
  */
529
683
  async _pollCtx() {
684
+ if (this._pollingCtx) return;
685
+ this._pollingCtx = true;
686
+ try {
530
687
  const sessions = this._sessions;
531
688
  await Promise.all(
532
689
  sessions.map(async (s) => {
@@ -544,6 +701,9 @@ export class SessionRegistry extends EventEmitter {
544
701
  }),
545
702
  );
546
703
  this._maybeEmit();
704
+ } finally {
705
+ this._pollingCtx = false;
706
+ }
547
707
  }
548
708
 
549
709
  /**
@@ -552,6 +712,9 @@ export class SessionRegistry extends EventEmitter {
552
712
  * model/ctx values are left to the slower _pollCtx(). Best-effort.
553
713
  */
554
714
  async _pollThinking() {
715
+ if (this._pollingThinking) return;
716
+ this._pollingThinking = true;
717
+ try {
555
718
  const sessions = this._sessions;
556
719
  await Promise.all(
557
720
  sessions.map(async (s) => {
@@ -564,6 +727,9 @@ export class SessionRegistry extends EventEmitter {
564
727
  const cap = await this._tmux.capturePane(s.target, 26);
565
728
  const { thinking } = parseTuiStatus(cap);
566
729
  this._thinkingMap.set(s.target, thinking);
730
+ // Cache raw capture text for the content-fingerprint tiebreak in
731
+ // the next refresh() — cheap: already captured here.
732
+ this._paneTextCache.set(s.target, cap);
567
733
  s.thinking = thinking;
568
734
 
569
735
  // Pane-derived question detection (Claude panes only): an on-screen
@@ -584,6 +750,9 @@ export class SessionRegistry extends EventEmitter {
584
750
  }),
585
751
  );
586
752
  this._maybeEmit();
753
+ } finally {
754
+ this._pollingThinking = false;
755
+ }
587
756
  }
588
757
 
589
758
  /**
@@ -741,7 +910,7 @@ export class SessionRegistry extends EventEmitter {
741
910
  }
742
911
 
743
912
  const now = Date.now();
744
- // BFS from the pane shell pid for a `claude` descendant; return its start.
913
+ // BFS from the pane shell pid for a `claude` or `codex` descendant; return its start.
745
914
  const findClaude = (rootPid) => {
746
915
  const queue = [rootPid];
747
916
  const seen = new Set();
@@ -752,15 +921,19 @@ export class SessionRegistry extends EventEmitter {
752
921
  const meta = info.get(pid);
753
922
  if (meta && CLAUDE_COMM_RE.test(meta.comm)) {
754
923
  const sec = parseEtime(meta.etime);
755
- return { isClaude: true, startMs: sec == null ? null : now - sec * 1000 };
924
+ return { isClaude: true, isCodex: false, kind: 'claude', startMs: sec == null ? null : now - sec * 1000 };
925
+ }
926
+ if (meta && CODEX_COMM_RE.test(meta.comm)) {
927
+ const sec = parseEtime(meta.etime);
928
+ return { isClaude: false, isCodex: true, kind: 'codex', startMs: sec == null ? null : now - sec * 1000 };
756
929
  }
757
930
  for (const c of children.get(pid) ?? []) queue.push(c);
758
931
  }
759
- return { isClaude: false, startMs: null };
932
+ return { isClaude: false, isCodex: false, kind: null, startMs: null };
760
933
  };
761
934
 
762
935
  for (const p of allPanes) {
763
- out.set(p.target, p.panePid ? findClaude(p.panePid) : { isClaude: false, startMs: null });
936
+ out.set(p.target, p.panePid ? findClaude(p.panePid) : { isClaude: false, isCodex: false, kind: null, startMs: null });
764
937
  }
765
938
  return out;
766
939
  }
package/lib/shell.js CHANGED
@@ -105,5 +105,7 @@ export async function shellKey(sessionTarget, cwd, key) {
105
105
  export async function shellCapture(sessionTarget, cwd, lines = 200) {
106
106
  const target = await ensureSessionShell(sessionTarget, cwd);
107
107
  const n = Math.max(1, Math.min(10000, Number(lines) || 200));
108
- return tmux.capturePane(target, n, true);
108
+ // escapes=true (keep ANSI colors), join=true (rejoin soft-wrapped lines so
109
+ // URLs split across narrow pane columns are reconstructed as single <a> tags).
110
+ return tmux.capturePane(target, n, true, true);
109
111
  }
package/lib/subagents.js CHANGED
@@ -26,12 +26,13 @@ import { TranscriptTailer } from './transcript.js';
26
26
  const META_RE = /^agent-(.+)\.meta\.json$/;
27
27
  // A sub-agent whose transcript hasn't grown in this long is treated as finished,
28
28
  // even if we never saw the parent's tool_result (e.g. it predates the parent's
29
- // bounded message buffer). Live sub-agents append whenever the LLM produces a
30
- // token or tool result, but long inference calls (extended thinking, slow tools)
31
- // can pause writes for several minutes. 600 s (10 min) covers realistic worst-case
32
- // LLM pauses while still expiring stale-but-finished agents whose tool_result
33
- // predates the bounded parent buffer. doneByParent always wins when available.
34
- const RUNNING_WINDOW_MS = 600_000;
29
+ // bounded message buffer). Live sub-agents append every few seconds (each token
30
+ // or tool result updates the file), so a quiet file past ACTIVE_WINDOW_MS (20 s)
31
+ // is almost certainly done. 45 s is generous enough to absorb a brief inference
32
+ // pause without mis-classifying a still-running agent, while clearing finished
33
+ // agents ~13× faster than the previous 600 s fallback.
34
+ // doneByParent always wins when available (authoritative, instant).
35
+ const RUNNING_WINDOW_MS = 45_000;
35
36
  // A file written within this window is treated as actively-running, overriding a
36
37
  // (possibly premature, e.g. background-launch-ack) doneByParent flag.
37
38
  const ACTIVE_WINDOW_MS = 20_000;
package/lib/tmux.js CHANGED
@@ -325,9 +325,19 @@ export function shellQuoteName(name) {
325
325
  * session is created first and used.
326
326
  *
327
327
  * @param {{ cwd: string, name?: string }} opts
328
+ * @param {{ _run?: Function, _listPanes?: Function }} [_injected]
329
+ * Test-only injection seam. Production callers omit this argument entirely.
330
+ * - `_run(args)` replaces the internal `runTmux` call (records argv, returns
331
+ * canned `{ stdout, stderr }` without shelling out).
332
+ * - `_listPanes()` replaces the `listWindows` call used to detect an existing
333
+ * server session (returns a canned pane array).
328
334
  * @returns {Promise<string>} target "session:windowIndex"
329
335
  */
330
- export async function createWindow({ cwd, name } = {}) {
336
+ export async function createWindow({ cwd, name } = {}, { _run, _listPanes } = {}) {
337
+ // Allow tests to inject a stub runner; production path uses the real runTmux.
338
+ const runner = _run ?? runTmux;
339
+ const lister = _listPanes ?? listWindows;
340
+
331
341
  if (typeof cwd !== 'string' || !cwd) {
332
342
  throw new Error('createWindow: cwd is required');
333
343
  }
@@ -347,7 +357,7 @@ export async function createWindow({ cwd, name } = {}) {
347
357
  // callers may pass raw user text. An empty result means "let tmux auto-name".
348
358
  const safeName = sanitizeName(name);
349
359
 
350
- const windows = await listWindows();
360
+ const windows = await lister();
351
361
 
352
362
  // No tmux server/session yet — bootstrap a detached session in the cwd. The
353
363
  // session's first window IS our target window, so no extra new-window needed.
@@ -355,10 +365,10 @@ export async function createWindow({ cwd, name } = {}) {
355
365
  const sessionName = 'claude-control';
356
366
  const args = ['new-session', '-d', '-s', sessionName, '-c', cwd];
357
367
  if (safeName) args.push('-n', safeName);
358
- await runTmux(args);
368
+ await runner(args);
359
369
  // The fresh session opens at window index 0 (tmux's base-index may differ,
360
370
  // but the first list entry is authoritative).
361
- const after = await listWindows();
371
+ const after = await lister();
362
372
  const win = after.find((w) => w.sessionName === sessionName);
363
373
  const target = win ? win.target : `${sessionName}:0`;
364
374
  if (!isValidTarget(target)) {
@@ -378,7 +388,7 @@ export async function createWindow({ cwd, name } = {}) {
378
388
  '-c', cwd,
379
389
  ];
380
390
  if (safeName) args.push('-n', safeName);
381
- const { stdout } = await runTmux(args);
391
+ const { stdout } = await runner(args);
382
392
  const target = stdout.trim();
383
393
  if (!isValidTarget(target)) {
384
394
  throw new Error(`createWindow: produced invalid target: ${JSON.stringify(target)}`);
@@ -551,19 +561,28 @@ export async function sendRawKeysSequenced(target, keys, delayMs = 160) {
551
561
  /**
552
562
  * Capture the visible content of a tmux pane.
553
563
  * `-e` preserves ANSI escape sequences (server may strip before forwarding).
564
+ * `-J` joins soft-wrapped lines so a URL split across pane columns is
565
+ * reconstructed into a single logical line.
554
566
  *
555
567
  * @param {string} target
556
568
  * @param {number} [lines=40] How many history lines above the visible area to include.
569
+ * @param {boolean} [escapes=false] Pass `-e` to keep ANSI/SGR sequences.
570
+ * @param {boolean} [join=false] Pass `-J` to rejoin soft-wrapped lines.
571
+ * @param {{ _run?: Function }} [_injected] Test-only seam; omit in production.
557
572
  * @returns {Promise<string>}
558
573
  */
559
- export async function capturePane(target, lines = 40, escapes = false) {
574
+ export async function capturePane(target, lines = 40, escapes = false, join = false, { _run } = {}) {
560
575
  assertTarget(target);
576
+ const runner = _run ?? runTmux;
561
577
  const args = ['capture-pane', '-t', target, '-p'];
562
578
  // `-e` keeps ANSI/SGR sequences so the client can render terminal colors. Off
563
579
  // by default: LivePane / AskModal render plain text (escapes would show as
564
580
  // garbage). The composer terminal view opts in to get a themed, colored pane.
565
581
  if (escapes) args.push('-e');
582
+ // `-J` rejoins soft-wrapped lines into logical lines so that a URL split
583
+ // across narrow pane columns is reconstructed before the client linkifies it.
584
+ if (join) args.push('-J');
566
585
  args.push('-S', `-${lines}`); // start N lines above the visible area
567
- const { stdout } = await runTmux(args);
586
+ const { stdout } = await runner(args);
568
587
  return stdout;
569
588
  }
package/lib/transcribe.js CHANGED
@@ -54,16 +54,13 @@ export function resolveWhisperBin() {
54
54
  }
55
55
 
56
56
  /**
57
- * Resolve the ggml model: WHISPER_MODEL env preferred names in the models
58
- * dir any `ggml-*.bin` there.
59
- * @returns {string | null}
57
+ * Pure model-preference resolver: given a list of filenames present on disk,
58
+ * return the preferred one (multilingual before .en). Exposed for testing.
59
+ *
60
+ * @param {string[]} files - basenames available (e.g. from fs.readdirSync)
61
+ * @returns {string | null} preferred basename, or null
60
62
  */
61
- export function resolveWhisperModel() {
62
- const e = process.env.WHISPER_MODEL;
63
- if (e && e.trim() && fs.existsSync(e.trim())) return e.trim();
64
- // Prefer multilingual models (no `.en`) when present: a `.en` model can ONLY
65
- // do English, so if the user dropped in a multilingual ggml they want the mix
66
- // (English + Chinese + Singlish/…). English-only models are the fallback.
63
+ export function resolveModelFromFiles(files) {
67
64
  const prefs = [
68
65
  'ggml-medium.bin',
69
66
  'ggml-small.bin',
@@ -73,16 +70,30 @@ export function resolveWhisperModel() {
73
70
  'ggml-tiny.en.bin',
74
71
  ];
75
72
  for (const m of prefs) {
76
- const p = path.join(MODELS_DIR, m);
77
- if (fs.existsSync(p)) return p;
73
+ if (files.includes(m)) return m;
78
74
  }
75
+ return files.find((n) => /^ggml-.*\.bin$/.test(n)) ?? null;
76
+ }
77
+
78
+ /**
79
+ * Resolve the ggml model: WHISPER_MODEL env → preferred names in the models
80
+ * dir → any `ggml-*.bin` there.
81
+ * @returns {string | null}
82
+ */
83
+ export function resolveWhisperModel() {
84
+ const e = process.env.WHISPER_MODEL;
85
+ if (e && e.trim() && fs.existsSync(e.trim())) return e.trim();
86
+ // Prefer multilingual models (no `.en`) when present: a `.en` model can ONLY
87
+ // do English, so if the user dropped in a multilingual ggml they want the mix
88
+ // (English + Chinese + Singlish/…). English-only models are the fallback.
89
+ let files = [];
79
90
  try {
80
- const found = fs.readdirSync(MODELS_DIR).find((n) => /^ggml-.*\.bin$/.test(n));
81
- if (found) return path.join(MODELS_DIR, found);
91
+ files = fs.readdirSync(MODELS_DIR);
82
92
  } catch {
83
93
  /* dir missing */
84
94
  }
85
- return null;
95
+ const found = resolveModelFromFiles(files);
96
+ return found ? path.join(MODELS_DIR, found) : null;
86
97
  }
87
98
 
88
99
  /**
@@ -124,6 +135,22 @@ function run(bin, args) {
124
135
  });
125
136
  }
126
137
 
138
+ /**
139
+ * Derive the whisper-cli language flags from the resolved model path and call
140
+ * options. Pure function — no I/O. Exposed for testing.
141
+ *
142
+ * @param {string} modelPath - resolved model file path (used for its basename)
143
+ * @param {{ lang?: string }} [opts]
144
+ * @param {NodeJS.ProcessEnv} [env] - defaults to process.env
145
+ * @returns {{ effLang: string, translate: boolean }}
146
+ */
147
+ export function buildWhisperFlags(modelPath, { lang } = {}, env = process.env) {
148
+ const englishOnly = /\.en\.bin$/i.test(path.basename(modelPath));
149
+ const effLang = lang || env.WHISPER_LANG || (englishOnly ? 'en' : 'auto');
150
+ const translate = !englishOnly; // → always-English output
151
+ return { effLang, translate };
152
+ }
153
+
127
154
  /**
128
155
  * Transcribe an audio file (any ffmpeg-readable format) to text — always in
129
156
  * English. A multilingual model uses Whisper's TRANSLATE task, so Chinese,
@@ -131,13 +158,19 @@ function run(bin, args) {
131
158
  * models are already English; nothing to translate.
132
159
  *
133
160
  * @param {string} inputPath - path to the recorded audio file.
134
- * @param {{ lang?: string }} [opts]
161
+ * @param {{ lang?: string, _resolvers?: object, _run?: Function }} [opts]
135
162
  * @returns {Promise<string>}
136
163
  */
137
- export async function transcribe(inputPath, { lang } = {}) {
138
- const ffmpeg = resolveFfmpeg();
139
- const whisper = resolveWhisperBin();
140
- const model = resolveWhisperModel();
164
+ export async function transcribe(inputPath, { lang, _resolvers, _run } = {}) {
165
+ const resolvers = _resolvers ?? {};
166
+ const ffmpegFn = resolvers.resolveFfmpeg ?? resolveFfmpeg;
167
+ const whisperFn = resolvers.resolveWhisperBin ?? resolveWhisperBin;
168
+ const modelFn = resolvers.resolveWhisperModel ?? resolveWhisperModel;
169
+ const runFn = _run ?? run;
170
+
171
+ const ffmpeg = ffmpegFn();
172
+ const whisper = whisperFn();
173
+ const model = modelFn();
141
174
  if (!ffmpeg) throw new Error('ffmpeg not found (brew install ffmpeg)');
142
175
  if (!whisper) throw new Error('whisper-cli not found (brew install whisper-cpp)');
143
176
  if (!model) throw new Error(`no whisper model found in ${MODELS_DIR}`);
@@ -145,22 +178,20 @@ export async function transcribe(inputPath, { lang } = {}) {
145
178
  // `.en` models do English only; multilingual models auto-detect the source then
146
179
  // translate it to English. Source language is overridable (lang / WHISPER_LANG)
147
180
  // for the rare case you want to pin detection; output stays English.
148
- const englishOnly = /\.en\.bin$/i.test(path.basename(model));
149
- const effLang = lang || process.env.WHISPER_LANG || (englishOnly ? 'en' : 'auto');
150
- const translate = !englishOnly; // → always-English output
181
+ const { effLang, translate } = buildWhisperFlags(model, { lang });
151
182
 
152
183
  const wav = path.join(
153
184
  os.tmpdir(),
154
185
  `cc-stt-${Date.now()}-${process.pid}.wav`,
155
186
  );
156
187
  try {
157
- await run(ffmpeg, [
188
+ await runFn(ffmpeg, [
158
189
  '-nostdin', '-y',
159
190
  '-i', inputPath,
160
191
  '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le',
161
192
  '-f', 'wav', wav,
162
193
  ]);
163
- const { stdout } = await run(whisper, [
194
+ const { stdout } = await runFn(whisper, [
164
195
  '-m', model, '-f', wav, '-np', '-nt', '-l', effLang,
165
196
  ...(translate ? ['--translate'] : []),
166
197
  ]);
package/lib/transcript.js CHANGED
@@ -194,13 +194,14 @@ export function parseRecord(line) {
194
194
  export class TranscriptTailer extends EventEmitter {
195
195
  /**
196
196
  * @param {string} filePath
197
- * @param {{ maxBuffer?: number, debounceMs?: number }} options
197
+ * @param {{ maxBuffer?: number, debounceMs?: number, parser?: Function }} options
198
198
  */
199
- constructor(filePath, { maxBuffer = DEFAULT_MAX_BUFFER, debounceMs = 150 } = {}) {
199
+ constructor(filePath, { maxBuffer = DEFAULT_MAX_BUFFER, debounceMs = 150, parser = parseRecord } = {}) {
200
200
  super();
201
201
  this._filePath = filePath;
202
202
  this._maxBuffer = maxBuffer;
203
203
  this._debounceMs = debounceMs;
204
+ this._parse = parser;
204
205
 
205
206
  /** @type {import('./transcript.js').NormalizedMessage[]} */
206
207
  this._messages = [];
@@ -340,7 +341,7 @@ export class TranscriptTailer extends EventEmitter {
340
341
 
341
342
  const parsed = [];
342
343
  for (const line of lines) {
343
- const msg = parseRecord(line);
344
+ const msg = this._parse(line);
344
345
  if (msg) {
345
346
  parsed.push(msg);
346
347
  this._trackPending(msg);
@@ -413,7 +414,7 @@ export class TranscriptTailer extends EventEmitter {
413
414
 
414
415
  const newMsgs = [];
415
416
  for (const line of complete) {
416
- const msg = parseRecord(line);
417
+ const msg = this._parse(line);
417
418
  if (msg) {
418
419
  newMsgs.push(msg);
419
420
  this._trackPending(msg);