@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/auth.js +23 -2
- package/lib/codex.js +496 -0
- package/lib/config.js +38 -4
- package/lib/json-file.js +40 -0
- package/lib/match.js +78 -2
- package/lib/mlx.js +13 -0
- package/lib/pins.js +2 -3
- package/lib/push.js +3 -2
- package/lib/resources.js +112 -52
- package/lib/sessions.js +186 -13
- package/lib/shell.js +3 -1
- package/lib/subagents.js +7 -6
- package/lib/tmux.js +26 -7
- package/lib/transcribe.js +55 -24
- package/lib/transcript.js +5 -4
- package/lib/ws-heartbeat.js +32 -0
- package/package.json +1 -1
- package/server.js +312 -90
- package/web/dist/assets/{core-BA72pMy-.js → core-CpT6tRRG.js} +1 -1
- package/web/dist/assets/index-CjOcrKRX.css +1 -0
- package/web/dist/assets/index-CxhR0MPg.js +103 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CgHrw_VR.js +0 -103
- package/web/dist/assets/index-Dv9NwX8Q.css +0 -1
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
|
-
|
|
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)
|
|
405
|
-
// its
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
139
|
-
const
|
|
140
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
417
|
+
const msg = this._parse(line);
|
|
417
418
|
if (msg) {
|
|
418
419
|
newMsgs.push(msg);
|
|
419
420
|
this._trackPending(msg);
|