@idl3/claude-control 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/match.js ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * lib/match.js — deterministic pane↔transcript assignment.
3
+ *
4
+ * Why this exists: multiple Claude sessions can run in the SAME directory (e.g.
5
+ * two panes both in ~/Projects). Claude Code names a transcript's project
6
+ * directory after the cwd, so directory alone cannot tell two same-cwd sessions
7
+ * apart. The previous "newest transcript in the dir → the active window" rule
8
+ * therefore mis-routed: a reply typed into one pane surfaced under another.
9
+ *
10
+ * This module assigns at most ONE transcript to each pane, 1:1, using layered
11
+ * signals (strongest first). It is pure and deterministic so the cross-send case
12
+ * is unit-testable.
13
+ */
14
+
15
+ import { isCwdConsistent } from './sessions.js';
16
+
17
+ const DEFAULT_START_SLACK_MS = 5 * 60_000; // proc-start vs transcript-birth tolerance
18
+
19
+ /**
20
+ * @param {string|null} etime macOS `ps -o etime` value: "[[dd-]hh:]mm:ss"
21
+ * @returns {number|null} elapsed seconds, or null if unparseable
22
+ */
23
+ export function parseEtime(etime) {
24
+ const s = String(etime || '').trim();
25
+ if (!s) return null;
26
+ // optional "dd-" prefix, then hh:mm:ss or mm:ss
27
+ const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(s);
28
+ if (!m) return null;
29
+ const days = Number(m[1] || 0);
30
+ const hours = Number(m[2] || 0);
31
+ const mins = Number(m[3] || 0);
32
+ const secs = Number(m[4] || 0);
33
+ return ((days * 24 + hours) * 60 + mins) * 60 + secs;
34
+ }
35
+
36
+ /**
37
+ * @typedef {Object} MatchPane
38
+ * @property {string} target "session:window.pane"
39
+ * @property {string} windowName
40
+ * @property {string} cwd
41
+ * @property {number|null} procStartMs claude process start (ms epoch), or null
42
+ *
43
+ * @typedef {Object} MatchCandidate
44
+ * @property {string} transcriptPath
45
+ * @property {string|null} cwd cwd recorded inside the transcript
46
+ * @property {number|null} birthtimeMs
47
+ * @property {number|null} mtimeMs
48
+ * @property {number|null} lastActivityMs
49
+ * @property {string|null} customTitle
50
+ * @property {string|null} aiTitle
51
+ */
52
+
53
+ /**
54
+ * Assign transcripts to panes 1:1.
55
+ *
56
+ * Layered passes (each claims candidates so no transcript is used twice):
57
+ * 1. Title match — a pane's tmux window name uniquely equals a candidate's
58
+ * customTitle (set by /rename) or aiTitle, cwd-consistent. Strongest:
59
+ * survives restarts and is independent of timing.
60
+ * 2. Start-time match — candidate birthtime closest to the pane's claude
61
+ * process start (cwd-consistent). A claude proc creates its transcript at
62
+ * launch, so this binds same-cwd siblings that started at different times.
63
+ * 3. Recency — most-recently-active remaining cwd-consistent candidate.
64
+ *
65
+ * Panes are processed in a stable (target-sorted) order so results are
66
+ * deterministic regardless of tmux listing order.
67
+ *
68
+ * @param {MatchPane[]} panes
69
+ * @param {MatchCandidate[]} candidates
70
+ * @param {{ startSlackMs?: number }} [opts]
71
+ * @returns {Map<string, MatchCandidate>} target -> candidate
72
+ */
73
+ export function assignTranscripts(panes, candidates, opts = {}) {
74
+ const startSlackMs = opts.startSlackMs ?? DEFAULT_START_SLACK_MS;
75
+ const result = new Map();
76
+ const claimed = new Set();
77
+ const ordered = [...panes].sort((a, b) => a.target.localeCompare(b.target));
78
+
79
+ const available = (pane) =>
80
+ candidates.filter(
81
+ (c) =>
82
+ !claimed.has(c.transcriptPath) && isCwdConsistent(c.cwd, pane.cwd),
83
+ );
84
+
85
+ const claim = (pane, cand) => {
86
+ result.set(pane.target, cand);
87
+ claimed.add(cand.transcriptPath);
88
+ };
89
+
90
+ // Pass 1 — unique title match.
91
+ for (const pane of ordered) {
92
+ if (result.has(pane.target)) continue;
93
+ const name = String(pane.windowName || '').trim();
94
+ if (!name) continue;
95
+ const hits = available(pane).filter(
96
+ (c) => c.customTitle === name || c.aiTitle === name,
97
+ );
98
+ if (hits.length === 1) claim(pane, hits[0]);
99
+ }
100
+
101
+ // Pass 2 — nearest start-time ↔ birthtime.
102
+ for (const pane of ordered) {
103
+ if (result.has(pane.target)) continue;
104
+ if (pane.procStartMs == null) continue;
105
+ let best = null;
106
+ let bestDelta = Infinity;
107
+ for (const c of available(pane)) {
108
+ if (c.birthtimeMs == null) continue;
109
+ const delta = Math.abs(c.birthtimeMs - pane.procStartMs);
110
+ // Prefer transcripts born around/after the proc started; reject ones born
111
+ // long before the proc (those belong to an earlier session in this dir).
112
+ if (c.birthtimeMs < pane.procStartMs - startSlackMs) continue;
113
+ if (
114
+ delta < bestDelta ||
115
+ (delta === bestDelta &&
116
+ (c.lastActivityMs ?? 0) > (best?.lastActivityMs ?? 0))
117
+ ) {
118
+ best = c;
119
+ bestDelta = delta;
120
+ }
121
+ }
122
+ if (best) claim(pane, best);
123
+ }
124
+
125
+ // Pass 3 — most-recently-active remaining candidate.
126
+ for (const pane of ordered) {
127
+ if (result.has(pane.target)) continue;
128
+ let best = null;
129
+ for (const c of available(pane)) {
130
+ if (!best || (c.lastActivityMs ?? 0) > (best.lastActivityMs ?? 0)) best = c;
131
+ }
132
+ if (best) claim(pane, best);
133
+ }
134
+
135
+ return result;
136
+ }
package/lib/pins.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * lib/pins.js — manual transcript pins.
3
+ *
4
+ * Escape hatch for sessions whose transcript can't be auto-matched (path drift,
5
+ * window-name ≠ session-title, no live fd). A pin explicitly binds a pane to a
6
+ * transcript file and takes top priority over the heuristic matcher.
7
+ *
8
+ * Pins are keyed by `windowId.paneIndex` (e.g. "@5.1") — STABLE across tmux
9
+ * window renumbering, unlike the session:window.pane target which shifts.
10
+ * Persisted as JSON: { "@5.1": "/abs/path/to/transcript.jsonl", ... }.
11
+ */
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+
16
+ /** Stable pin key for a pane/session: windowId.paneIndex. */
17
+ export function pinKey(windowId, paneIndex) {
18
+ return `${windowId}.${paneIndex ?? 0}`;
19
+ }
20
+
21
+ /** Load the pins map from disk. Never throws — returns {} on any problem. */
22
+ export function loadPins(file) {
23
+ try {
24
+ const obj = JSON.parse(fs.readFileSync(file, 'utf8'));
25
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) return obj;
26
+ } catch {
27
+ /* missing / malformed → empty */
28
+ }
29
+ return {};
30
+ }
31
+
32
+ /** Persist the pins map atomically (best-effort). */
33
+ export function savePins(file, pins) {
34
+ const dir = path.dirname(file);
35
+ fs.mkdirSync(dir, { recursive: true });
36
+ const tmp = `${file}.tmp`;
37
+ fs.writeFileSync(tmp, JSON.stringify(pins, null, 2), { mode: 0o600 });
38
+ fs.renameSync(tmp, file);
39
+ }
40
+
41
+ /**
42
+ * Validate a candidate transcript path for pinning: must be a string ending in
43
+ * .jsonl, resolve to inside projectsRoot, and exist. Returns the resolved path
44
+ * or null. Guards the pin API against arbitrary filesystem reads.
45
+ */
46
+ export function validateTranscriptPath(raw, projectsRoot) {
47
+ if (typeof raw !== 'string' || !raw.endsWith('.jsonl')) return null;
48
+ let full;
49
+ try {
50
+ full = path.resolve(raw);
51
+ } catch {
52
+ return null;
53
+ }
54
+ if (!full.startsWith(projectsRoot + path.sep)) return null;
55
+ try {
56
+ if (!fs.statSync(full).isFile()) return null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ return full;
61
+ }
package/lib/prompt.js ADDED
@@ -0,0 +1,73 @@
1
+ // lib/prompt.js — detect a Claude Code TUI selection prompt from a pane capture.
2
+ //
3
+ // Permission prompts ("Do you want to proceed? 1. Yes / 2. Yes, don't ask /
4
+ // 3. No"), trust prompts, and similar numbered menus live ONLY in the live TUI —
5
+ // they are never written to the transcript JSONL. The cockpit is transcript-
6
+ // driven, so without this it shows a pending tool-call and looks stuck. We poll
7
+ // the pane, parse the prompt here, and surface it as an actionable modal.
8
+
9
+ // Strip ANSI/OSC escape sequences (capture-pane is taken with -e).
10
+ // eslint-disable-next-line no-control-regex
11
+ const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07|\x1b[()][AB0]/g;
12
+
13
+ function stripAnsi(s) {
14
+ return String(s).replace(ANSI_RE, '');
15
+ }
16
+
17
+ const OPTION_RE = /^\s*[❯›>*]?\s*(\d)[.)]\s+(.+?)\s*$/;
18
+ const PROMPT_HINT_RE = /(do you want|want to proceed|proceed\?|trust|allow this|continue\?)/i;
19
+ const MAX_LABEL = 80;
20
+
21
+ /**
22
+ * Parse a Claude Code numbered selection prompt out of a pane capture.
23
+ *
24
+ * @param {string} capture raw `capture-pane -p -e` text
25
+ * @returns {{ question: string, options: {key:string,label:string,selected:boolean}[] }|null}
26
+ */
27
+ export function parsePanePrompt(capture) {
28
+ const lines = stripAnsi(capture).split('\n').map((l) => l.replace(/\s+$/, ''));
29
+
30
+ // Find the LAST contiguous run of numbered options (the active prompt is at the
31
+ // bottom of the pane). Walk from the end.
32
+ let end = -1;
33
+ for (let i = lines.length - 1; i >= 0; i--) {
34
+ if (OPTION_RE.test(lines[i])) {
35
+ end = i;
36
+ break;
37
+ }
38
+ }
39
+ if (end < 0) return null;
40
+
41
+ // Expand upward over the contiguous numbered block.
42
+ let start = end;
43
+ while (start - 1 >= 0 && OPTION_RE.test(lines[start - 1])) start -= 1;
44
+
45
+ const options = [];
46
+ for (let i = start; i <= end; i++) {
47
+ const m = OPTION_RE.exec(lines[i]);
48
+ if (!m) continue;
49
+ let label = m[2].trim();
50
+ if (label.length > MAX_LABEL) label = label.slice(0, MAX_LABEL - 1) + '…';
51
+ const selected = /^[\s]*[❯›>*]/.test(lines[i]);
52
+ options.push({ key: m[1], label, selected });
53
+ }
54
+ // Need ≥2 options numbered consecutively from 1 to look like a real menu.
55
+ if (options.length < 2) return null;
56
+ if (options[0].key !== '1') return null;
57
+
58
+ // Question = nearest non-empty line above the options block (e.g. "Do you want
59
+ // to proceed?"). Require a prompt-like hint to avoid matching random lists.
60
+ let question = '';
61
+ for (let i = start - 1; i >= 0 && i >= start - 4; i--) {
62
+ const t = lines[i].trim();
63
+ if (t) {
64
+ question = t;
65
+ break;
66
+ }
67
+ }
68
+ if (!PROMPT_HINT_RE.test(question) && !PROMPT_HINT_RE.test(lines.join(' '))) {
69
+ return null;
70
+ }
71
+
72
+ return { question: question || 'Do you want to proceed?', options };
73
+ }