@idl3/claude-control 0.1.22 → 0.2.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/bin/cli.js +5 -0
- package/bin/setup.sh +60 -0
- package/hooks/record-pane.mjs +72 -0
- package/lib/match.js +39 -26
- package/lib/optimize.js +126 -2
- package/lib/pane-registry.js +86 -0
- package/lib/sessions.js +75 -35
- package/lib/shell.js +101 -0
- package/lib/tmux.js +77 -11
- package/package.json +5 -1
- package/scripts/eval-optimize.mjs +46 -0
- package/scripts/install-pane-hook.mjs +72 -0
- package/server.js +48 -1
- package/web/dist/assets/{core-CZTz1vMx.js → core-DM2iK52g.js} +1 -1
- package/web/dist/assets/index-DwNp83VT.css +1 -0
- package/web/dist/assets/index-DwmU8Yna.js +89 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-Bup-kzmD.js +0 -85
- package/web/dist/assets/index-D21GSqEK.css +0 -1
package/lib/sessions.js
CHANGED
|
@@ -16,6 +16,7 @@ import { promisify } from 'node:util';
|
|
|
16
16
|
import { parseTuiStatus, prettyModel } from './tui.js';
|
|
17
17
|
import { assignTranscripts, parseEtime } from './match.js';
|
|
18
18
|
import { pinKey } from './pins.js';
|
|
19
|
+
import { readPaneRegistry, gcPaneRegistry } from './pane-registry.js';
|
|
19
20
|
|
|
20
21
|
const execFile = promisify(_execFile);
|
|
21
22
|
|
|
@@ -397,8 +398,21 @@ export class SessionRegistry extends EventEmitter {
|
|
|
397
398
|
return true;
|
|
398
399
|
});
|
|
399
400
|
|
|
400
|
-
//
|
|
401
|
-
|
|
401
|
+
// Classify every pane by its process subtree (a `claude` descendant) and get
|
|
402
|
+
// its claude start time in one ps snapshot. Falls back to the cmd heuristic
|
|
403
|
+
// only when ps is unavailable.
|
|
404
|
+
const paneProc = await this._buildPaneProc(panes);
|
|
405
|
+
const isClaudePane = (p) => {
|
|
406
|
+
const info = paneProc.get(p.target);
|
|
407
|
+
return info ? info.isClaude : isClaudeCmd(p.cmd);
|
|
408
|
+
};
|
|
409
|
+
const claudePanes = panes.filter(isClaudePane);
|
|
410
|
+
|
|
411
|
+
// The exact pane→transcript map authored by the SessionStart hook. This is
|
|
412
|
+
// the deterministic binding; everything below is fallback for panes with no
|
|
413
|
+
// hook record (sessions started before the hook was installed).
|
|
414
|
+
const paneReg = await readPaneRegistry();
|
|
415
|
+
gcPaneRegistry(new Set(panes.map((p) => p.paneId).filter(Boolean))).catch(() => {});
|
|
402
416
|
|
|
403
417
|
// Manual pins win first: a pinned pane is force-bound to its transcript and
|
|
404
418
|
// that transcript is removed from the auto-matcher pool. Pins are keyed by
|
|
@@ -415,29 +429,43 @@ export class SessionRegistry extends EventEmitter {
|
|
|
415
429
|
}
|
|
416
430
|
}
|
|
417
431
|
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
const
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
432
|
+
// Hook-bound: a pane whose %N is in the registry binds to that EXACT
|
|
433
|
+
// transcript — no guessing. Pinned panes keep their pin.
|
|
434
|
+
const hookByTarget = new Map();
|
|
435
|
+
for (const p of claudePanes) {
|
|
436
|
+
if (pinnedByTarget.has(p.target)) continue;
|
|
437
|
+
const reg = p.paneId ? paneReg.get(p.paneId) : null;
|
|
438
|
+
if (!reg) continue;
|
|
439
|
+
const rec = await this._recordForPath(reg.transcriptPath);
|
|
440
|
+
if (rec) {
|
|
441
|
+
hookByTarget.set(p.target, rec);
|
|
442
|
+
pinnedPaths.add(rec.transcriptPath); // exclude from the auto-matcher pool
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Auto-match the rest with the deterministic timing matcher (pinned + hook
|
|
447
|
+
// panes and their transcripts excluded so nothing double-binds).
|
|
448
|
+
const autoPanes = claudePanes.filter(
|
|
449
|
+
(p) => !pinnedByTarget.has(p.target) && !hookByTarget.has(p.target),
|
|
450
|
+
);
|
|
451
|
+
const candidatesRaw = await this._buildCandidates(autoPanes);
|
|
425
452
|
const candidates = candidatesRaw.filter((c) => !pinnedPaths.has(c.transcriptPath));
|
|
426
453
|
const assignment = assignTranscripts(
|
|
427
454
|
autoPanes.map((p) => ({
|
|
428
455
|
target: p.target,
|
|
429
456
|
windowName: p.windowName,
|
|
430
457
|
cwd: p.cwd,
|
|
431
|
-
|
|
458
|
+
projectDir: encodeCwd(p.cwd), // scope candidates to this pane's own slug dir
|
|
459
|
+
procStartMs: paneProc.get(p.target)?.startMs ?? null,
|
|
432
460
|
})),
|
|
433
461
|
candidates,
|
|
434
462
|
);
|
|
435
463
|
for (const [target, rec] of pinnedByTarget) assignment.set(target, rec);
|
|
464
|
+
for (const [target, rec] of hookByTarget) assignment.set(target, rec);
|
|
436
465
|
|
|
437
466
|
const sessions = panes.map((win) => {
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
: null;
|
|
467
|
+
const isClaude = isClaudePane(win);
|
|
468
|
+
const transcript = isClaude ? assignment.get(win.target) ?? null : null;
|
|
441
469
|
const isPinned = pinnedByTarget.has(win.target);
|
|
442
470
|
const id = win.target;
|
|
443
471
|
// Pending = subscribed-tailer pending (live modal) OR transcript-derived
|
|
@@ -445,7 +473,7 @@ export class SessionRegistry extends EventEmitter {
|
|
|
445
473
|
const pending =
|
|
446
474
|
(this._pendingMap.get(id) ?? false) || !!transcript?.transcriptPending;
|
|
447
475
|
const title = transcript?.customTitle || transcript?.aiTitle || null;
|
|
448
|
-
const ctx = this._ctxMap.get(win.target) || {};
|
|
476
|
+
const ctx = isClaude ? this._ctxMap.get(win.target) || {} : {};
|
|
449
477
|
|
|
450
478
|
return {
|
|
451
479
|
id,
|
|
@@ -455,6 +483,7 @@ export class SessionRegistry extends EventEmitter {
|
|
|
455
483
|
title,
|
|
456
484
|
tmuxName: win.windowName,
|
|
457
485
|
target: win.target,
|
|
486
|
+
paneId: win.paneId, // stable tmux %N (survives renumber / grouped mirrors)
|
|
458
487
|
sessionName: win.sessionName,
|
|
459
488
|
windowIndex: win.windowIndex,
|
|
460
489
|
paneIndex: win.paneIndex,
|
|
@@ -467,16 +496,19 @@ export class SessionRegistry extends EventEmitter {
|
|
|
467
496
|
pending,
|
|
468
497
|
pendingQuestion: transcript?.pendingQuestion ?? null,
|
|
469
498
|
cmd: win.cmd,
|
|
470
|
-
isClaude
|
|
499
|
+
isClaude,
|
|
500
|
+
kind: isClaude ? 'claude' : 'terminal',
|
|
501
|
+
ccShell: !!win.ccShell, // a composer >_ sister shell pane
|
|
502
|
+
|
|
471
503
|
model: ctx.model || prettyModel(transcript?.model) || null,
|
|
472
504
|
ctxPct: ctx.ctxPct ?? null,
|
|
473
|
-
thinking: this._thinkingMap.get(win.target) ?? false,
|
|
505
|
+
thinking: isClaude ? this._thinkingMap.get(win.target) ?? false : false,
|
|
474
506
|
};
|
|
475
507
|
});
|
|
476
508
|
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
this._sessions = sessions
|
|
509
|
+
// Surface EVERY pane: Claude sessions AND plain terminals (each pane is a row;
|
|
510
|
+
// terminals render a live interactive terminal instead of a transcript).
|
|
511
|
+
this._sessions = sessions;
|
|
480
512
|
this._maybeEmit();
|
|
481
513
|
return this._sessions;
|
|
482
514
|
}
|
|
@@ -630,7 +662,10 @@ export class SessionRegistry extends EventEmitter {
|
|
|
630
662
|
extractTailRecord(r.filePath, r.mtime, r.birthtimeMs),
|
|
631
663
|
),
|
|
632
664
|
);
|
|
633
|
-
|
|
665
|
+
// Tag each candidate with the project-dir slug it was found in, so the
|
|
666
|
+
// matcher scopes it to panes whose cwd produces the SAME slug (prevents a
|
|
667
|
+
// parent-dir pane stealing a child worktree's transcript).
|
|
668
|
+
for (const rec of recs) if (rec) candidates.push({ ...rec, projectDir: name });
|
|
634
669
|
}),
|
|
635
670
|
);
|
|
636
671
|
|
|
@@ -638,17 +673,22 @@ export class SessionRegistry extends EventEmitter {
|
|
|
638
673
|
}
|
|
639
674
|
|
|
640
675
|
/**
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
676
|
+
* Classify each pane and resolve its claude-process start time in ONE `ps`
|
|
677
|
+
* snapshot. A pane is a Claude session iff its process subtree (from the pane
|
|
678
|
+
* shell pid) contains a `claude` descendant — far more reliable than the
|
|
679
|
+
* `pane_current_command` version-regex, which flips to `node`/`git` while
|
|
680
|
+
* Claude runs a tool. The same walk yields the claude start time (ms epoch)
|
|
681
|
+
* for the start-time matching fallback.
|
|
645
682
|
*
|
|
646
|
-
*
|
|
647
|
-
*
|
|
683
|
+
* Best-effort: if `ps` is unavailable every pane maps to {isClaude:false,
|
|
684
|
+
* startMs:null} and callers fall back to the cmd heuristic / other passes.
|
|
685
|
+
*
|
|
686
|
+
* @param {import('./tmux.js').Window[]} allPanes
|
|
687
|
+
* @returns {Promise<Map<string, {isClaude: boolean, startMs: number|null}>>} target -> info
|
|
648
688
|
*/
|
|
649
|
-
async
|
|
689
|
+
async _buildPaneProc(allPanes) {
|
|
650
690
|
const out = new Map();
|
|
651
|
-
if (
|
|
691
|
+
if (allPanes.length === 0) return out;
|
|
652
692
|
|
|
653
693
|
let rows;
|
|
654
694
|
try {
|
|
@@ -659,7 +699,7 @@ export class SessionRegistry extends EventEmitter {
|
|
|
659
699
|
);
|
|
660
700
|
rows = stdout.split('\n');
|
|
661
701
|
} catch {
|
|
662
|
-
return out; // ps unavailable —
|
|
702
|
+
return out; // ps unavailable — callers fall back
|
|
663
703
|
}
|
|
664
704
|
|
|
665
705
|
/** @type {Map<number, number[]>} ppid -> child pids */
|
|
@@ -677,8 +717,8 @@ export class SessionRegistry extends EventEmitter {
|
|
|
677
717
|
}
|
|
678
718
|
|
|
679
719
|
const now = Date.now();
|
|
680
|
-
|
|
681
|
-
|
|
720
|
+
// BFS from the pane shell pid for a `claude` descendant; return its start.
|
|
721
|
+
const findClaude = (rootPid) => {
|
|
682
722
|
const queue = [rootPid];
|
|
683
723
|
const seen = new Set();
|
|
684
724
|
while (queue.length) {
|
|
@@ -688,15 +728,15 @@ export class SessionRegistry extends EventEmitter {
|
|
|
688
728
|
const meta = info.get(pid);
|
|
689
729
|
if (meta && CLAUDE_COMM_RE.test(meta.comm)) {
|
|
690
730
|
const sec = parseEtime(meta.etime);
|
|
691
|
-
return sec == null ? null : now - sec * 1000;
|
|
731
|
+
return { isClaude: true, startMs: sec == null ? null : now - sec * 1000 };
|
|
692
732
|
}
|
|
693
733
|
for (const c of children.get(pid) ?? []) queue.push(c);
|
|
694
734
|
}
|
|
695
|
-
return null;
|
|
735
|
+
return { isClaude: false, startMs: null };
|
|
696
736
|
};
|
|
697
737
|
|
|
698
|
-
for (const p of
|
|
699
|
-
out.set(p.target, p.panePid ?
|
|
738
|
+
for (const p of allPanes) {
|
|
739
|
+
out.set(p.target, p.panePid ? findClaude(p.panePid) : { isClaude: false, startMs: null });
|
|
700
740
|
}
|
|
701
741
|
return out;
|
|
702
742
|
}
|
package/lib/shell.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/shell.js — per-session "sister" shell panes for the composer's terminal
|
|
3
|
+
* mode (>_). Each Claude session gets its OWN scratch shell, created on demand
|
|
4
|
+
* as a pane in that session's window (so it shares the window and inherits the
|
|
5
|
+
* cwd), and reused thereafter. Marked with the pane option `@cc_shell` so it can
|
|
6
|
+
* be found again. It's a real PTY (tmux), so interactive flows (npm login,
|
|
7
|
+
* prompts, OTP) work.
|
|
8
|
+
*
|
|
9
|
+
* Security: same posture as the rest of the app — WS traffic is token-gated and
|
|
10
|
+
* bound to 127.0.0.1 / the tailnet; this is no broader than the existing ttyd
|
|
11
|
+
* escape hatch. Commands run as the server user.
|
|
12
|
+
*/
|
|
13
|
+
import * as tmux from './tmux.js';
|
|
14
|
+
import { readConfig } from './config.js';
|
|
15
|
+
|
|
16
|
+
/** "0:1.2" → "0:1" (drop the pane index to address the window). */
|
|
17
|
+
function windowOf(target) {
|
|
18
|
+
return String(target || '').replace(/\.\d+$/, '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Control keys the UI may send (mirrors the `promptkey` allow-list philosophy —
|
|
22
|
+
// the command body goes through send-keys -l as literal text; only these named
|
|
23
|
+
// keys are interpreted). The set is generated but still a closed allow-list:
|
|
24
|
+
// every value is a known tmux send-keys token, so no arbitrary key-name injection.
|
|
25
|
+
// Covers the on-screen key bar (arrows / Tab / Esc / Ctrl-* / Home / End / paging)
|
|
26
|
+
// so a phone keyboard can reach keys it can't physically produce.
|
|
27
|
+
const ALPHA = 'abcdefghijklmnopqrstuvwxyz'.split('');
|
|
28
|
+
const NAMED_KEYS = [
|
|
29
|
+
'Enter', 'Tab', 'BTab', 'Escape', 'BSpace', 'DC', 'IC', 'Space',
|
|
30
|
+
'Up', 'Down', 'Left', 'Right', 'Home', 'End', 'PPage', 'NPage',
|
|
31
|
+
'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12',
|
|
32
|
+
];
|
|
33
|
+
export const SHELL_KEYS = new Set([
|
|
34
|
+
...NAMED_KEYS,
|
|
35
|
+
...ALPHA.map((c) => `C-${c}`), // C-a .. C-z
|
|
36
|
+
...ALPHA.map((c) => `M-${c}`), // M-a .. M-z (Option/Meta)
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Ensure the sister shell pane for a session's WINDOW exists; return its target.
|
|
41
|
+
* Reuses the `@cc_shell`-marked pane in that window, or splits the window to make
|
|
42
|
+
* one (rooted at the session's cwd, `-d` so the Claude pane keeps focus). Falls
|
|
43
|
+
* back to creating a standalone window only if there's no window to split.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} sessionTarget e.g. "0:1.1" (the Claude pane)
|
|
46
|
+
* @param {string} [cwd]
|
|
47
|
+
* @returns {Promise<string>} sister shell pane target
|
|
48
|
+
*/
|
|
49
|
+
export async function ensureSessionShell(sessionTarget, cwd) {
|
|
50
|
+
const win = windowOf(sessionTarget);
|
|
51
|
+
const dir = typeof cwd === 'string' && cwd ? cwd : readConfig().defaultCwd;
|
|
52
|
+
|
|
53
|
+
// Reuse an existing marked sister pane in this window.
|
|
54
|
+
try {
|
|
55
|
+
const panes = await tmux.listPanes();
|
|
56
|
+
const sister = panes.find(
|
|
57
|
+
(p) => p.ccShell && windowOf(p.target) === win && tmux.isValidTarget(p.target),
|
|
58
|
+
);
|
|
59
|
+
if (sister) return sister.target;
|
|
60
|
+
} catch {
|
|
61
|
+
// fall through to create
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Split the session's window to add the sister shell (no focus steal).
|
|
65
|
+
let target;
|
|
66
|
+
if (win && tmux.isValidTarget(`${win}.0`)) {
|
|
67
|
+
target = await tmux.splitWindow({ windowTarget: win, cwd: dir });
|
|
68
|
+
} else {
|
|
69
|
+
// No resolvable window (e.g. session vanished) — create a standalone one.
|
|
70
|
+
target = await tmux.createWindow({ cwd: dir, name: 'cc-shell' });
|
|
71
|
+
}
|
|
72
|
+
if (!tmux.isValidTarget(target)) throw new Error('shell: invalid pane target');
|
|
73
|
+
await tmux.setPaneOption(target, '@cc_shell', '1');
|
|
74
|
+
return target;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Run a command line (literal text + Enter) in the session's sister shell. */
|
|
78
|
+
export async function shellInput(sessionTarget, cwd, line) {
|
|
79
|
+
const target = await ensureSessionShell(sessionTarget, cwd);
|
|
80
|
+
await tmux.sendText(target, String(line ?? ''));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Forward literal keystroke text (NO Enter) for raw passthrough typing. */
|
|
84
|
+
export async function shellText(sessionTarget, cwd, text) {
|
|
85
|
+
const target = await ensureSessionShell(sessionTarget, cwd);
|
|
86
|
+
await tmux.sendLiteral(target, String(text ?? ''));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Send one allow-listed control key (e.g. C-c). Throws on anything else. */
|
|
90
|
+
export async function shellKey(sessionTarget, cwd, key) {
|
|
91
|
+
if (!SHELL_KEYS.has(key)) throw new Error('key not allowed');
|
|
92
|
+
const target = await ensureSessionShell(sessionTarget, cwd);
|
|
93
|
+
await tmux.sendRawKeys(target, [key]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Capture the sister shell pane WITH ANSI escapes for the colored live view. */
|
|
97
|
+
export async function shellCapture(sessionTarget, cwd, lines = 200) {
|
|
98
|
+
const target = await ensureSessionShell(sessionTarget, cwd);
|
|
99
|
+
const n = Math.max(1, Math.min(10000, Number(lines) || 200));
|
|
100
|
+
return tmux.capturePane(target, n, true);
|
|
101
|
+
}
|
package/lib/tmux.js
CHANGED
|
@@ -177,6 +177,8 @@ const FORMAT = [
|
|
|
177
177
|
'#{window_id}',
|
|
178
178
|
'#{pane_index}',
|
|
179
179
|
'#{pane_active}',
|
|
180
|
+
'#{pane_id}',
|
|
181
|
+
'#{@cc_shell}',
|
|
180
182
|
].join(SEP);
|
|
181
183
|
|
|
182
184
|
/**
|
|
@@ -222,7 +224,7 @@ export async function listPanes() {
|
|
|
222
224
|
const parts = trimmed.split(SEP);
|
|
223
225
|
if (parts.length < 9) continue;
|
|
224
226
|
|
|
225
|
-
const [sessionName, rawIndex, windowName, rawActive, rawPid, cwd, cmd, windowId, rawPane, rawPaneActive] = parts;
|
|
227
|
+
const [sessionName, rawIndex, windowName, rawActive, rawPid, cwd, cmd, windowId, rawPane, rawPaneActive, paneId, ccShell] = parts;
|
|
226
228
|
const windowIndex = Number(rawIndex);
|
|
227
229
|
const panePid = Number(rawPid);
|
|
228
230
|
const paneIndex = Number(rawPane) || 0;
|
|
@@ -239,6 +241,8 @@ export async function listPanes() {
|
|
|
239
241
|
cmd,
|
|
240
242
|
windowId: windowId ?? `${sessionName}:${windowIndex}`,
|
|
241
243
|
paneIndex,
|
|
244
|
+
paneId: paneId ?? null, // stable tmux %N — joins to $TMUX_PANE from the hook
|
|
245
|
+
ccShell: ccShell === '1', // sister shell pane created for the composer >_
|
|
242
246
|
});
|
|
243
247
|
}
|
|
244
248
|
|
|
@@ -377,6 +381,54 @@ export async function createWindow({ cwd, name } = {}) {
|
|
|
377
381
|
return target;
|
|
378
382
|
}
|
|
379
383
|
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
// Split window (sister pane)
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Split a window to add a sister pane running the default shell, WITHOUT
|
|
390
|
+
* stealing focus (`-d`), and return the new pane's target. Used to give each
|
|
391
|
+
* Claude session its own scratch shell next to it (composer >_).
|
|
392
|
+
*
|
|
393
|
+
* @param {{ windowTarget: string, cwd: string, size?: string }} opts
|
|
394
|
+
* windowTarget e.g. "0:1"; size e.g. "30%" (height of the new pane).
|
|
395
|
+
* @returns {Promise<string>} new pane target "session:window.pane"
|
|
396
|
+
*/
|
|
397
|
+
export async function splitWindow({ windowTarget, cwd, size = '30%' } = {}) {
|
|
398
|
+
if (!windowTarget) throw new Error('splitWindow: windowTarget required');
|
|
399
|
+
if (typeof cwd !== 'string' || !cwd) throw new Error('splitWindow: cwd required');
|
|
400
|
+
const args = [
|
|
401
|
+
'split-window',
|
|
402
|
+
'-d', // do not switch focus to the new pane
|
|
403
|
+
'-v', // stack below the source pane
|
|
404
|
+
'-l', size,
|
|
405
|
+
'-t', windowTarget,
|
|
406
|
+
'-c', cwd,
|
|
407
|
+
'-P',
|
|
408
|
+
'-F', '#{session_name}:#{window_index}.#{pane_index}',
|
|
409
|
+
];
|
|
410
|
+
const { stdout } = await runTmux(args);
|
|
411
|
+
const target = stdout.trim();
|
|
412
|
+
if (!isValidTarget(target)) {
|
|
413
|
+
throw new Error(`splitWindow: produced invalid target: ${JSON.stringify(target)}`);
|
|
414
|
+
}
|
|
415
|
+
return target;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Set a pane-scoped tmux option (e.g. a `@user` marker). Used to tag the sister
|
|
420
|
+
* shell pane so it can be found and reused later.
|
|
421
|
+
*
|
|
422
|
+
* @param {string} target pane target
|
|
423
|
+
* @param {string} name option name (e.g. "@cc_shell")
|
|
424
|
+
* @param {string} value
|
|
425
|
+
* @returns {Promise<void>}
|
|
426
|
+
*/
|
|
427
|
+
export async function setPaneOption(target, name, value) {
|
|
428
|
+
assertTarget(target);
|
|
429
|
+
await runTmux(['set-option', '-p', '-t', target, name, String(value)]);
|
|
430
|
+
}
|
|
431
|
+
|
|
380
432
|
// ---------------------------------------------------------------------------
|
|
381
433
|
// Rename window
|
|
382
434
|
// ---------------------------------------------------------------------------
|
|
@@ -416,6 +468,22 @@ export async function sendText(target, text) {
|
|
|
416
468
|
await runTmux(['send-keys', '-t', target, 'Enter']);
|
|
417
469
|
}
|
|
418
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Send literal text WITHOUT a trailing Enter — for raw keystroke passthrough,
|
|
473
|
+
* where each character (or a paste) is forwarded as the user types and the pane
|
|
474
|
+
* itself is the echo. `-l` means no key-name interpretation, so this can't inject
|
|
475
|
+
* control keys (those go through sendRawKeys with the SHELL_KEYS allow-list).
|
|
476
|
+
*
|
|
477
|
+
* @param {string} target
|
|
478
|
+
* @param {string} text
|
|
479
|
+
* @returns {Promise<void>}
|
|
480
|
+
*/
|
|
481
|
+
export async function sendLiteral(target, text) {
|
|
482
|
+
assertTarget(target);
|
|
483
|
+
if (!text) return;
|
|
484
|
+
await runTmux(['send-keys', '-t', target, '-l', text]);
|
|
485
|
+
}
|
|
486
|
+
|
|
419
487
|
// ---------------------------------------------------------------------------
|
|
420
488
|
// Send raw key names (no -l)
|
|
421
489
|
// ---------------------------------------------------------------------------
|
|
@@ -468,16 +536,14 @@ export async function sendRawKeysSequenced(target, keys, delayMs = 160) {
|
|
|
468
536
|
* @param {number} [lines=40] How many history lines above the visible area to include.
|
|
469
537
|
* @returns {Promise<string>}
|
|
470
538
|
*/
|
|
471
|
-
export async function capturePane(target, lines = 40) {
|
|
539
|
+
export async function capturePane(target, lines = 40, escapes = false) {
|
|
472
540
|
assertTarget(target);
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
'-S', `-${lines}`, // start N lines above visible area
|
|
481
|
-
]);
|
|
541
|
+
const args = ['capture-pane', '-t', target, '-p'];
|
|
542
|
+
// `-e` keeps ANSI/SGR sequences so the client can render terminal colors. Off
|
|
543
|
+
// by default: LivePane / AskModal render plain text (escapes would show as
|
|
544
|
+
// garbage). The composer terminal view opts in to get a themed, colored pane.
|
|
545
|
+
if (escapes) args.push('-e');
|
|
546
|
+
args.push('-S', `-${lines}`); // start N lines above the visible area
|
|
547
|
+
const { stdout } = await runTmux(args);
|
|
482
548
|
return stdout;
|
|
483
549
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idl3/claude-control",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local web UI to watch and drive your Claude Code sessions running in tmux — live transcripts, reply, answer AskUserQuestion, attach files, from a browser or phone.",
|
|
6
6
|
"keywords": [
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
"files": [
|
|
23
23
|
"server.js",
|
|
24
24
|
"lib/",
|
|
25
|
+
"hooks/",
|
|
26
|
+
"scripts/",
|
|
25
27
|
"public/",
|
|
26
28
|
"web/dist/",
|
|
27
29
|
"bin/",
|
|
@@ -32,6 +34,8 @@
|
|
|
32
34
|
"start": "node server.js",
|
|
33
35
|
"dev": "node --watch server.js",
|
|
34
36
|
"test": "node --test",
|
|
37
|
+
"eval:optimise": "node scripts/eval-optimize.mjs",
|
|
38
|
+
"install-hook": "node scripts/install-pane-hook.mjs",
|
|
35
39
|
"build:web": "cd web && npm install && npm run build",
|
|
36
40
|
"build": "npm run build:web",
|
|
37
41
|
"prepack": "npm run build"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* eval-optimize.mjs — offline scorecard for the prompt-optimiser acceptance gate.
|
|
4
|
+
* Runs lib/optimize.js → evaluateRewrite over the deterministic fixture set and
|
|
5
|
+
* prints a per-case pass/fail table plus an aggregate. Exits non-zero on any
|
|
6
|
+
* mismatch so it can gate CI / catch regressions in the guard logic.
|
|
7
|
+
*
|
|
8
|
+
* Run: npm run eval:optimise
|
|
9
|
+
*/
|
|
10
|
+
import { evaluateRewrite } from '../lib/optimize.js';
|
|
11
|
+
import { OPTIMIZE_CASES } from '../test/fixtures/optimize-cases.mjs';
|
|
12
|
+
|
|
13
|
+
let pass = 0;
|
|
14
|
+
const rows = [];
|
|
15
|
+
for (const c of OPTIMIZE_CASES) {
|
|
16
|
+
const ev = evaluateRewrite(c.draft, c.optimized);
|
|
17
|
+
const verdictOk = ev.ok === c.expectOk;
|
|
18
|
+
// If the case pins expected violations, require they all fired.
|
|
19
|
+
const violOk =
|
|
20
|
+
!c.expectViolations || c.expectViolations.every((v) => ev.violations.includes(v));
|
|
21
|
+
const ok = verdictOk && violOk;
|
|
22
|
+
if (ok) pass += 1;
|
|
23
|
+
rows.push({
|
|
24
|
+
name: c.name,
|
|
25
|
+
ok,
|
|
26
|
+
got: ev.ok ? 'accept' : `reject(${ev.violations.join(',')})`,
|
|
27
|
+
want: c.expectOk ? 'accept' : `reject(${(c.expectViolations || []).join(',') || 'any'})`,
|
|
28
|
+
ratio: ev.metrics.lengthRatio,
|
|
29
|
+
overlap: ev.metrics.contentOverlap,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const W = Math.max(...rows.map((r) => r.name.length));
|
|
34
|
+
console.log('Prompt-optimiser eval — acceptance gate\n');
|
|
35
|
+
for (const r of rows) {
|
|
36
|
+
console.log(
|
|
37
|
+
`${r.ok ? '✓' : '✗'} ${r.name.padEnd(W)} got=${r.got} want=${r.want} ` +
|
|
38
|
+
`ratio=${r.ratio} overlap=${r.overlap}`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const total = OPTIMIZE_CASES.length;
|
|
42
|
+
console.log(`\n${pass}/${total} cases passed`);
|
|
43
|
+
if (pass !== total) {
|
|
44
|
+
console.error('EVAL FAILED — guard logic regressed');
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* install-pane-hook.mjs — idempotently register the pane-recording hook
|
|
4
|
+
* (hooks/record-pane.mjs) as a Claude Code SessionStart + SessionEnd hook in
|
|
5
|
+
* ~/.claude/settings.json. Lets Claude Control bind each tmux pane to its EXACT
|
|
6
|
+
* transcript with zero guessing.
|
|
7
|
+
*
|
|
8
|
+
* Safe to re-run: detects an existing record-pane hook (by command substring)
|
|
9
|
+
* and leaves the file untouched if already installed. Preserves all other hooks.
|
|
10
|
+
*/
|
|
11
|
+
import { readFile, writeFile, mkdir, copyFile } from 'node:fs/promises';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const SETTINGS = path.join(homedir(), '.claude', 'settings.json');
|
|
17
|
+
const SRC_SCRIPT = path.resolve(fileURLToPath(import.meta.url), '..', '..', 'hooks', 'record-pane.mjs');
|
|
18
|
+
// Deploy to ~/.claude/scripts/ and reference it by $HOME — IDENTICAL to the
|
|
19
|
+
// atlas-toolbox olam-skills hook (members/idl3/hooks/record-pane.json), so the
|
|
20
|
+
// two install paths produce the same settings entry and never double-register.
|
|
21
|
+
const DEST_SCRIPT = path.join(homedir(), '.claude', 'scripts', 'record-pane.mjs');
|
|
22
|
+
const COMMAND = 'node "$HOME/.claude/scripts/record-pane.mjs"';
|
|
23
|
+
const EVENTS = ['SessionStart', 'SessionEnd'];
|
|
24
|
+
const MARKER = 'record-pane.mjs';
|
|
25
|
+
|
|
26
|
+
async function readSettings() {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(await readFile(SETTINGS, 'utf8'));
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err.code === 'ENOENT') return {};
|
|
31
|
+
throw new Error(`Could not parse ${SETTINGS}: ${err.message}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function alreadyInstalled(groups) {
|
|
36
|
+
return (groups || []).some((g) =>
|
|
37
|
+
(g.hooks || []).some((h) => typeof h.command === 'string' && h.command.includes(MARKER)),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function main() {
|
|
42
|
+
// Deploy the script to ~/.claude/scripts/ (idempotent — always refresh it).
|
|
43
|
+
await mkdir(path.dirname(DEST_SCRIPT), { recursive: true });
|
|
44
|
+
await copyFile(SRC_SCRIPT, DEST_SCRIPT);
|
|
45
|
+
|
|
46
|
+
const settings = await readSettings();
|
|
47
|
+
settings.hooks ??= {};
|
|
48
|
+
let changed = false;
|
|
49
|
+
|
|
50
|
+
for (const event of EVENTS) {
|
|
51
|
+
const groups = (settings.hooks[event] ??= []);
|
|
52
|
+
if (alreadyInstalled(groups)) continue;
|
|
53
|
+
groups.push({ hooks: [{ type: 'command', command: COMMAND }] });
|
|
54
|
+
changed = true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!changed) {
|
|
58
|
+
console.log(`✓ pane-recording hook already installed (${SETTINGS})`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await mkdir(path.dirname(SETTINGS), { recursive: true });
|
|
63
|
+
await writeFile(SETTINGS, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
|
|
64
|
+
console.log(`✓ installed pane-recording hook → ${SETTINGS}`);
|
|
65
|
+
console.log(` command: ${COMMAND}`);
|
|
66
|
+
console.log(' Applies to Claude sessions started from now on.');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
main().catch((err) => {
|
|
70
|
+
console.error(`✗ ${err.message}`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
});
|
package/server.js
CHANGED
|
@@ -14,6 +14,7 @@ import { WebSocketServer } from 'ws';
|
|
|
14
14
|
|
|
15
15
|
import * as tmux from './lib/tmux.js';
|
|
16
16
|
import * as terminal from './lib/terminal.js';
|
|
17
|
+
import * as shell from './lib/shell.js';
|
|
17
18
|
import { TranscriptTailer } from './lib/transcript.js';
|
|
18
19
|
import { SubAgentsWatcher } from './lib/subagents.js';
|
|
19
20
|
import { parsePanePrompt } from './lib/prompt.js';
|
|
@@ -1394,9 +1395,28 @@ async function handleClientMessage(ws, msg) {
|
|
|
1394
1395
|
if (!session) throw new Error('unknown session');
|
|
1395
1396
|
if (!tmux.isValidTarget(session.target)) throw new Error('invalid tmux target');
|
|
1396
1397
|
const lines = Math.max(1, Math.min(10000, Number(msg.lines) || 40));
|
|
1397
|
-
|
|
1398
|
+
// Terminal-pane rows opt into ANSI escapes so colours render; the plain
|
|
1399
|
+
// LivePane omits the flag (escapes would show as garbage there).
|
|
1400
|
+
const text = await tmux.capturePane(session.target, lines, !!msg.escapes);
|
|
1398
1401
|
return send(ws, { type: 'capture', id: msg.id, text });
|
|
1399
1402
|
}
|
|
1403
|
+
// Interactive terminal panes: forward keystrokes to ANY pane by id (the
|
|
1404
|
+
// selected one). Mirrors the cc-shell shell-* ops but target-addressed.
|
|
1405
|
+
case 'pane-text': {
|
|
1406
|
+
const session = sessionById(msg.id);
|
|
1407
|
+
if (!session) throw new Error('unknown session');
|
|
1408
|
+
if (!tmux.isValidTarget(session.target)) throw new Error('invalid tmux target');
|
|
1409
|
+
await tmux.sendLiteral(session.target, String(msg.text ?? ''));
|
|
1410
|
+
return send(ws, { type: 'ack', op: 'pane-text', ok: true });
|
|
1411
|
+
}
|
|
1412
|
+
case 'pane-key': {
|
|
1413
|
+
const session = sessionById(msg.id);
|
|
1414
|
+
if (!session) throw new Error('unknown session');
|
|
1415
|
+
if (!tmux.isValidTarget(session.target)) throw new Error('invalid tmux target');
|
|
1416
|
+
if (!shell.SHELL_KEYS.has(String(msg.key ?? ''))) throw new Error('key not allowed');
|
|
1417
|
+
await tmux.sendRawKeys(session.target, [String(msg.key)]);
|
|
1418
|
+
return send(ws, { type: 'ack', op: 'pane-key', ok: true });
|
|
1419
|
+
}
|
|
1400
1420
|
case 'promptkey': {
|
|
1401
1421
|
// Respond to a live TUI selection prompt (permission/menu). Whitelisted
|
|
1402
1422
|
// keys only — never arbitrary text — so this can't be used to inject input.
|
|
@@ -1411,6 +1431,33 @@ async function handleClientMessage(ws, msg) {
|
|
|
1411
1431
|
if (sub) sub._lastPrompt = '__force__';
|
|
1412
1432
|
return send(ws, { type: 'ack', op: 'promptkey', ok: true });
|
|
1413
1433
|
}
|
|
1434
|
+
// Composer terminal mode (>_): each Claude session has its OWN sister shell
|
|
1435
|
+
// pane in its window. Resolve the session by id → its target + cwd, then act
|
|
1436
|
+
// on (or lazily create) that window's sister shell.
|
|
1437
|
+
case 'shell-input': {
|
|
1438
|
+
const s = sessionById(msg.id);
|
|
1439
|
+
if (!s) throw new Error('unknown session');
|
|
1440
|
+
await shell.shellInput(s.target, s.cwd, String(msg.line ?? ''));
|
|
1441
|
+
return send(ws, { type: 'ack', op: 'shell-input', ok: true });
|
|
1442
|
+
}
|
|
1443
|
+
case 'shell-text': {
|
|
1444
|
+
const s = sessionById(msg.id);
|
|
1445
|
+
if (!s) throw new Error('unknown session');
|
|
1446
|
+
await shell.shellText(s.target, s.cwd, String(msg.text ?? ''));
|
|
1447
|
+
return send(ws, { type: 'ack', op: 'shell-text', ok: true });
|
|
1448
|
+
}
|
|
1449
|
+
case 'shell-key': {
|
|
1450
|
+
const s = sessionById(msg.id);
|
|
1451
|
+
if (!s) throw new Error('unknown session');
|
|
1452
|
+
await shell.shellKey(s.target, s.cwd, String(msg.key ?? ''));
|
|
1453
|
+
return send(ws, { type: 'ack', op: 'shell-key', ok: true });
|
|
1454
|
+
}
|
|
1455
|
+
case 'shell-capture': {
|
|
1456
|
+
const s = sessionById(msg.id);
|
|
1457
|
+
if (!s) throw new Error('unknown session');
|
|
1458
|
+
const text = await shell.shellCapture(s.target, s.cwd, msg.lines);
|
|
1459
|
+
return send(ws, { type: 'shell-output', id: msg.id, text });
|
|
1460
|
+
}
|
|
1414
1461
|
default:
|
|
1415
1462
|
return;
|
|
1416
1463
|
}
|