@idl3/claude-control 0.1.22 → 0.2.1

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
@@ -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
- // Only Claude panes have transcripts to match (shells don't).
401
- const claudePanes = panes.filter((p) => isClaudeCmd(p.cmd));
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
- // Auto-match the rest with the deterministic 1:1 matcher (pinned panes and
419
- // pinned transcripts excluded so nothing double-binds or gets stolen).
420
- const autoPanes = claudePanes.filter((p) => !pinnedByTarget.has(p.target));
421
- const [candidatesRaw, procStart] = await Promise.all([
422
- this._buildCandidates(autoPanes),
423
- this._buildProcStart(autoPanes),
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
- procStartMs: procStart.get(p.target) ?? null,
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 transcript = isClaudeCmd(win.cmd)
439
- ? assignment.get(win.target) ?? null
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: true,
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
- // Only surface Claude sessions; skip plain shell panes. (assignTranscripts
478
- // already guarantees 1:1, so no post-hoc collision dedup is needed.)
479
- this._sessions = sessions.filter((s) => isClaudeCmd(s.cmd) || s.transcriptPath);
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
- for (const rec of recs) if (rec) candidates.push(rec);
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
- * Resolve each Claude pane's claude-process start time (ms epoch) for the
642
- * start-time matching pass. One `ps` snapshot, then walk the process tree from
643
- * each pane's shell pid to its `claude` descendant. Best-effort: panes whose
644
- * proc can't be found map to null and fall through to other match passes.
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
- * @param {import('./tmux.js').Window[]} claudePanes
647
- * @returns {Promise<Map<string, number|null>>} target -> startMs
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 _buildProcStart(claudePanes) {
689
+ async _buildPaneProc(allPanes) {
650
690
  const out = new Map();
651
- if (claudePanes.length === 0) return out;
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 — every pane falls back to null
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
- const findClaudeStart = (rootPid) => {
681
- // BFS for a descendant whose command basename is `claude`.
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 claudePanes) {
699
- out.set(p.target, p.panePid ? findClaudeStart(p.panePid) : null);
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,109 @@
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
+ // Navigation keys with every Ctrl/Meta/Shift modifier combination, so a hardware
34
+ // keyboard (e.g. iPad Magic Keyboard) can send Opt+Arrow word-jumps, Shift+Arrow
35
+ // selection, etc. Prefix order is C-,M-,S- (matches the frontend navToken).
36
+ const NAV_KEYS = ['Up', 'Down', 'Left', 'Right', 'Home', 'End', 'PPage', 'NPage'];
37
+ const NAV_PREFIXES = ['', 'C-', 'M-', 'S-', 'C-M-', 'C-S-', 'M-S-', 'C-M-S-'];
38
+ const NAV_COMBOS = NAV_KEYS.flatMap((k) => NAV_PREFIXES.map((p) => p + k));
39
+
40
+ export const SHELL_KEYS = new Set([
41
+ ...NAMED_KEYS,
42
+ ...NAV_COMBOS, // Up/Down/.../NPage with C-/M-/S- combinations
43
+ ...ALPHA.map((c) => `C-${c}`), // C-a .. C-z
44
+ ...ALPHA.map((c) => `M-${c}`), // M-a .. M-z (Option/Meta)
45
+ ]);
46
+
47
+ /**
48
+ * Ensure the sister shell pane for a session's WINDOW exists; return its target.
49
+ * Reuses the `@cc_shell`-marked pane in that window, or splits the window to make
50
+ * one (rooted at the session's cwd, `-d` so the Claude pane keeps focus). Falls
51
+ * back to creating a standalone window only if there's no window to split.
52
+ *
53
+ * @param {string} sessionTarget e.g. "0:1.1" (the Claude pane)
54
+ * @param {string} [cwd]
55
+ * @returns {Promise<string>} sister shell pane target
56
+ */
57
+ export async function ensureSessionShell(sessionTarget, cwd) {
58
+ const win = windowOf(sessionTarget);
59
+ const dir = typeof cwd === 'string' && cwd ? cwd : readConfig().defaultCwd;
60
+
61
+ // Reuse an existing marked sister pane in this window.
62
+ try {
63
+ const panes = await tmux.listPanes();
64
+ const sister = panes.find(
65
+ (p) => p.ccShell && windowOf(p.target) === win && tmux.isValidTarget(p.target),
66
+ );
67
+ if (sister) return sister.target;
68
+ } catch {
69
+ // fall through to create
70
+ }
71
+
72
+ // Split the session's window to add the sister shell (no focus steal).
73
+ let target;
74
+ if (win && tmux.isValidTarget(`${win}.0`)) {
75
+ target = await tmux.splitWindow({ windowTarget: win, cwd: dir });
76
+ } else {
77
+ // No resolvable window (e.g. session vanished) — create a standalone one.
78
+ target = await tmux.createWindow({ cwd: dir, name: 'cc-shell' });
79
+ }
80
+ if (!tmux.isValidTarget(target)) throw new Error('shell: invalid pane target');
81
+ await tmux.setPaneOption(target, '@cc_shell', '1');
82
+ return target;
83
+ }
84
+
85
+ /** Run a command line (literal text + Enter) in the session's sister shell. */
86
+ export async function shellInput(sessionTarget, cwd, line) {
87
+ const target = await ensureSessionShell(sessionTarget, cwd);
88
+ await tmux.sendText(target, String(line ?? ''));
89
+ }
90
+
91
+ /** Forward literal keystroke text (NO Enter) for raw passthrough typing. */
92
+ export async function shellText(sessionTarget, cwd, text) {
93
+ const target = await ensureSessionShell(sessionTarget, cwd);
94
+ await tmux.sendLiteral(target, String(text ?? ''));
95
+ }
96
+
97
+ /** Send one allow-listed control key (e.g. C-c). Throws on anything else. */
98
+ export async function shellKey(sessionTarget, cwd, key) {
99
+ if (!SHELL_KEYS.has(key)) throw new Error('key not allowed');
100
+ const target = await ensureSessionShell(sessionTarget, cwd);
101
+ await tmux.sendRawKeys(target, [key]);
102
+ }
103
+
104
+ /** Capture the sister shell pane WITH ANSI escapes for the colored live view. */
105
+ export async function shellCapture(sessionTarget, cwd, lines = 200) {
106
+ const target = await ensureSessionShell(sessionTarget, cwd);
107
+ const n = Math.max(1, Math.min(10000, Number(lines) || 200));
108
+ return tmux.capturePane(target, n, true);
109
+ }
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 { stdout } = await runTmux([
474
- 'capture-pane',
475
- '-t', target,
476
- '-p', // print to stdout
477
- // NOTE: no '-e' — the UI renders the capture as plain text (LivePane <pre>,
478
- // AskModal peek), so ANSI escapes would show as literal garbage. Strip them
479
- // at the source by capturing without escape sequences.
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.1.22",
3
+ "version": "0.2.1",
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
- const text = await tmux.capturePane(session.target, lines);
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
  }