@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 +136 -0
- package/lib/pins.js +61 -0
- package/lib/prompt.js +73 -0
- package/lib/sessions.js +248 -105
- package/lib/subagents.js +154 -0
- package/lib/tmux.js +39 -18
- package/lib/uploads.js +20 -0
- package/package.json +1 -1
- package/server.js +193 -7
- package/web/dist/assets/{core-BYJcZW10.js → core-BYoRNKN7.js} +1 -1
- package/web/dist/assets/index-BxcH-YdA.css +1 -0
- package/web/dist/assets/index-CmkTUTz_.js +77 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-Bb7gXgl-.css +0 -1
- package/web/dist/assets/index-wrjqfzbL.js +0 -77
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
|
+
}
|