@idl3/claude-control 0.2.1 → 0.3.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/README.md +5 -0
- package/lib/optimize.js +18 -3
- package/lib/prompt.js +66 -32
- package/lib/resources.js +75 -0
- package/lib/sessions.js +28 -5
- package/lib/skills.js +244 -33
- package/lib/subagents.js +6 -2
- package/lib/terminal.js +31 -3
- package/lib/tmux.js +22 -2
- package/lib/transcribe.js +20 -5
- package/lib/tui.js +23 -4
- package/package.json +1 -1
- package/server.js +107 -20
- package/web/dist/assets/{core-DjwRjAVq.js → core-CEmDs9PV.js} +1 -1
- package/web/dist/assets/index-CWs7fxHN.css +1 -0
- package/web/dist/assets/index-Dku_hPFx.js +103 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DJcMTsha.css +0 -1
- package/web/dist/assets/index-Dar5Ut3m.js +0 -89
package/README.md
CHANGED
|
@@ -18,6 +18,11 @@ talks to tmux. Bind is localhost-only by default.
|
|
|
18
18
|
npm install -g @idl3/claude-control # or run once: npx @idl3/claude-control
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
> **`claude-control: command not found`?** Use `-g` — a plain/local `npm install`
|
|
22
|
+
> only drops the binary in `./node_modules/.bin/` (not on `PATH`). If it's still
|
|
23
|
+
> missing after `-g`, your npm global bin dir isn't on `PATH`: run `npm prefix -g`
|
|
24
|
+
> and add `<that>/bin` to your shell `PATH`, or just use `npx @idl3/claude-control`.
|
|
25
|
+
|
|
21
26
|
**Prerequisites:** Node ≥20 and **tmux** on your `PATH` (`brew install tmux` · `sudo apt install tmux`). Optional: **ttyd** for the in-browser raw terminal (`brew install ttyd` · `sudo apt install ttyd`) — set `CLAUDE_CONTROL_TTYD` to override its path. The web UI ships prebuilt — no build step on install.
|
|
22
27
|
|
|
23
28
|
**Optional local AI (no API key):**
|
package/lib/optimize.js
CHANGED
|
@@ -288,17 +288,32 @@ export function isRunawayRewrite(draft, optimized) {
|
|
|
288
288
|
*/
|
|
289
289
|
function coerceLlmParsed(parsed) {
|
|
290
290
|
if (!parsed || typeof parsed !== 'object') return null;
|
|
291
|
-
const optimized = typeof parsed.optimized === 'string' ? parsed.optimized.trim() : '';
|
|
291
|
+
const optimized = typeof parsed.optimized === 'string' ? decodeLeakedEscapes(parsed.optimized).trim() : '';
|
|
292
292
|
if (!optimized) return null;
|
|
293
293
|
const rationale = Array.isArray(parsed.rationale)
|
|
294
|
-
? parsed.rationale.filter((x) => typeof x === 'string')
|
|
294
|
+
? parsed.rationale.filter((x) => typeof x === 'string').map(decodeLeakedEscapes)
|
|
295
295
|
: [];
|
|
296
296
|
const changes = Array.isArray(parsed.changes)
|
|
297
|
-
? parsed.changes.filter((x) => typeof x === 'string')
|
|
297
|
+
? parsed.changes.filter((x) => typeof x === 'string').map(decodeLeakedEscapes)
|
|
298
298
|
: [];
|
|
299
299
|
return { optimized, rationale, changes };
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Decode literal `\uXXXX` escapes the model sometimes double-escapes into the
|
|
304
|
+
* JSON string (e.g. an apostrophe becomes the LITERAL text "’" instead of
|
|
305
|
+
* ’). Without this, those backslash-u sequences get typed verbatim into the
|
|
306
|
+
* composer/tmux. Only touches `\uXXXX` (and `\\`) — leaves other text intact.
|
|
307
|
+
*
|
|
308
|
+
* @param {string} s
|
|
309
|
+
* @returns {string}
|
|
310
|
+
*/
|
|
311
|
+
function decodeLeakedEscapes(s) {
|
|
312
|
+
return String(s)
|
|
313
|
+
.replace(/\\\\/g, '\\') // collapse double-escaped backslashes first
|
|
314
|
+
.replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
|
315
|
+
}
|
|
316
|
+
|
|
302
317
|
/**
|
|
303
318
|
* Tolerant JSON parse: try direct parse; on failure extract first balanced
|
|
304
319
|
* `{...}` block and try again.
|
package/lib/prompt.js
CHANGED
|
@@ -16,12 +16,15 @@ function stripAnsi(s) {
|
|
|
16
16
|
|
|
17
17
|
// A numbered option line, optionally preceded by the TUI cursor (❯/›).
|
|
18
18
|
const OPTION_RE = /^\s*([❯›]?)\s*(\d)[.)]\s+(.+?)\s*$/;
|
|
19
|
+
// A checkbox marker at the START of an option label, e.g. "[ ] Label" or "[x] Label".
|
|
20
|
+
// Matches the bracket content: space = unchecked; x/✓/✗ = checked.
|
|
21
|
+
const CHECKBOX_RE = /^\[([✓x✗ ])\]\s*(.*)/;
|
|
19
22
|
// The footer real Claude Code prompts render under the options.
|
|
20
23
|
const ESC_HINT_RE = /\besc\b[^\n]*(cancel|interrupt|exit|reject|keep|quit)|ctrl\+[a-z]\b/i;
|
|
21
24
|
// How many lines from the bottom to consider — the active prompt always renders
|
|
22
25
|
// at the bottom of the pane, so a numbered list higher up (assistant prose) is
|
|
23
26
|
// out of scope.
|
|
24
|
-
const BOTTOM_REGION =
|
|
27
|
+
const BOTTOM_REGION = 26;
|
|
25
28
|
const MAX_LABEL = 80;
|
|
26
29
|
|
|
27
30
|
/**
|
|
@@ -41,36 +44,57 @@ export function parsePanePrompt(capture) {
|
|
|
41
44
|
const offset = Math.max(0, all.length - BOTTOM_REGION);
|
|
42
45
|
const lines = all.slice(offset);
|
|
43
46
|
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
if (end < 0) return null;
|
|
53
|
-
|
|
54
|
-
let start = end;
|
|
55
|
-
while (start - 1 >= 0 && OPTION_RE.test(lines[start - 1])) start -= 1;
|
|
56
|
-
|
|
57
|
-
const options = [];
|
|
58
|
-
let hasCursor = false;
|
|
59
|
-
for (let i = start; i <= end; i++) {
|
|
47
|
+
// Collect every numbered-option line in the bottom region. The AskUserQuestion
|
|
48
|
+
// picker renders each option as a header line PLUS a wrapped description line,
|
|
49
|
+
// so options are NOT contiguous — we must look past the description lines and
|
|
50
|
+
// stitch together a 1,2,3… sequence by key, not by adjacency.
|
|
51
|
+
const matches = [];
|
|
52
|
+
for (let i = 0; i < lines.length; i++) {
|
|
60
53
|
const m = OPTION_RE.exec(lines[i]);
|
|
61
54
|
if (!m) continue;
|
|
62
|
-
const cursor = m[1] === '❯' || m[1] === '›';
|
|
63
|
-
if (cursor) hasCursor = true;
|
|
64
55
|
let label = m[3].trim();
|
|
56
|
+
// Detect and strip a checkbox marker from the label.
|
|
57
|
+
let checked = undefined;
|
|
58
|
+
const cbMatch = CHECKBOX_RE.exec(label);
|
|
59
|
+
if (cbMatch) {
|
|
60
|
+
checked = cbMatch[1] !== ' '; // space = unchecked; x/✓/✗ = checked
|
|
61
|
+
label = cbMatch[2].trim();
|
|
62
|
+
}
|
|
65
63
|
if (label.length > MAX_LABEL) label = label.slice(0, MAX_LABEL - 1) + '…';
|
|
66
|
-
|
|
64
|
+
matches.push({ line: i, key: m[2], label, cursor: m[1] === '❯' || m[1] === '›', checked });
|
|
67
65
|
}
|
|
68
|
-
|
|
69
|
-
if (options.length < 2 || options[0].key !== '1') return null;
|
|
66
|
+
if (matches.length < 2) return null;
|
|
70
67
|
|
|
71
|
-
//
|
|
68
|
+
// Group into runs of consecutive ascending keys (1,2,3… OR 3,4,5… — the menu's
|
|
69
|
+
// first options can scroll off the top of the capture, so we must NOT require
|
|
70
|
+
// it to start at 1). Description lines between options don't break a run since
|
|
71
|
+
// we key off the NUMBERS, not line adjacency. Pick the bottom-most run — the
|
|
72
|
+
// active picker always renders at the bottom of the pane.
|
|
73
|
+
const runs = [];
|
|
74
|
+
let cur = [];
|
|
75
|
+
for (const m of matches) {
|
|
76
|
+
const prevKey = cur.length ? Number(cur[cur.length - 1].key) : null;
|
|
77
|
+
if (prevKey !== null && Number(m.key) === prevKey + 1) {
|
|
78
|
+
cur.push(m);
|
|
79
|
+
} else if (prevKey !== null && Number(m.key) === prevKey) {
|
|
80
|
+
// duplicate key (re-render artifact) — ignore
|
|
81
|
+
} else {
|
|
82
|
+
if (cur.length) runs.push(cur);
|
|
83
|
+
cur = [m];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (cur.length) runs.push(cur);
|
|
87
|
+
const options = [...runs].reverse().find((r) => r.length >= 2);
|
|
88
|
+
// Need ≥2 consecutively-numbered options to look like a menu.
|
|
89
|
+
if (!options) return null;
|
|
90
|
+
|
|
91
|
+
const firstLine = options[0].line;
|
|
92
|
+
const lastLine = options[options.length - 1].line;
|
|
93
|
+
const hasCursor = options.some((o) => o.cursor);
|
|
94
|
+
|
|
95
|
+
// "Esc to cancel / ctrl+e" footer within a few lines below the last option.
|
|
72
96
|
let hasEsc = false;
|
|
73
|
-
for (let i =
|
|
97
|
+
for (let i = lastLine + 1; i <= Math.min(lines.length - 1, lastLine + 3); i++) {
|
|
74
98
|
if (ESC_HINT_RE.test(lines[i])) {
|
|
75
99
|
hasEsc = true;
|
|
76
100
|
break;
|
|
@@ -80,15 +104,25 @@ export function parsePanePrompt(capture) {
|
|
|
80
104
|
// Require a genuine interactive-prompt signal — not just numbered prose.
|
|
81
105
|
if (!hasCursor && !hasEsc) return null;
|
|
82
106
|
|
|
83
|
-
// Question =
|
|
84
|
-
|
|
85
|
-
|
|
107
|
+
// Question = the contiguous non-empty block immediately above the options (the
|
|
108
|
+
// prompt can wrap onto several lines), joined into one string.
|
|
109
|
+
const qLines = [];
|
|
110
|
+
for (let i = firstLine - 1; i >= 0 && i >= firstLine - 4; i--) {
|
|
86
111
|
const t = lines[i].trim();
|
|
87
|
-
if (t)
|
|
88
|
-
|
|
89
|
-
break;
|
|
90
|
-
}
|
|
112
|
+
if (!t) break;
|
|
113
|
+
qLines.unshift(t);
|
|
91
114
|
}
|
|
115
|
+
const question = qLines.join(' ').slice(0, 240);
|
|
92
116
|
|
|
93
|
-
|
|
117
|
+
const hasCheckboxes = options.some((o) => o.checked !== undefined);
|
|
118
|
+
return {
|
|
119
|
+
question: question || 'Make a selection',
|
|
120
|
+
...(hasCheckboxes ? { multiSelect: true } : {}),
|
|
121
|
+
options: options.map((o) => ({
|
|
122
|
+
key: o.key,
|
|
123
|
+
label: o.label,
|
|
124
|
+
selected: o.cursor,
|
|
125
|
+
...(o.checked !== undefined ? { checked: o.checked } : {}),
|
|
126
|
+
})),
|
|
127
|
+
};
|
|
94
128
|
}
|
package/lib/resources.js
CHANGED
|
@@ -45,6 +45,27 @@ function reclaimableFreeBytes() {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Battery / power status via `pmset -g batt` (darwin only). Returns null on
|
|
50
|
+
* other platforms or any failure (UI then hides the battery chip). `low` is a
|
|
51
|
+
* UI hint: ≤20% and not charging.
|
|
52
|
+
* ponytail: macOS-only via pmset; add upower/sysfs parsing if Linux is ever needed.
|
|
53
|
+
*/
|
|
54
|
+
function powerStatus() {
|
|
55
|
+
if (process.platform !== 'darwin') return null;
|
|
56
|
+
try {
|
|
57
|
+
const out = execSync('pmset -g batt', { encoding: 'utf8', timeout: 1000 });
|
|
58
|
+
const onAc = /AC Power/.test(out);
|
|
59
|
+
if (!/InternalBattery/.test(out)) return { hasBattery: false, charging: onAc };
|
|
60
|
+
const percent = Number((out.match(/(\d+)%/) || [])[1]);
|
|
61
|
+
const charging = onAc || /\bcharging\b/.test(out) || /\bcharged\b/.test(out);
|
|
62
|
+
const pct = Number.isFinite(percent) ? percent : null;
|
|
63
|
+
return { hasBattery: true, percent: pct, charging, low: pct != null && pct <= 20 && !charging };
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
48
69
|
export class ResourceMonitor extends EventEmitter {
|
|
49
70
|
/**
|
|
50
71
|
* @param {{ intervalMs?: number, rssLimitMB?: number }} opts
|
|
@@ -60,6 +81,11 @@ export class ResourceMonitor extends EventEmitter {
|
|
|
60
81
|
this._prevCpu = process.cpuUsage();
|
|
61
82
|
this._prevWall = Date.now();
|
|
62
83
|
|
|
84
|
+
// Power is sampled less often than cpu/mem (pmset is a subprocess): refresh
|
|
85
|
+
// every 5th tick (~15s). Cache between refreshes.
|
|
86
|
+
this._power = powerStatus();
|
|
87
|
+
this._powerTick = 0;
|
|
88
|
+
|
|
63
89
|
// Compute an initial snapshot so snapshot() works before start().
|
|
64
90
|
this._latest = this._compute();
|
|
65
91
|
}
|
|
@@ -87,6 +113,7 @@ export class ResourceMonitor extends EventEmitter {
|
|
|
87
113
|
// ---- internals -------------------------------------------------------------
|
|
88
114
|
|
|
89
115
|
_tick() {
|
|
116
|
+
if (++this._powerTick % 5 === 0) this._power = powerStatus();
|
|
90
117
|
const snap = this._compute();
|
|
91
118
|
this._latest = snap;
|
|
92
119
|
this.emit('sample', snap);
|
|
@@ -131,7 +158,55 @@ export class ResourceMonitor extends EventEmitter {
|
|
|
131
158
|
ts: Date.now(),
|
|
132
159
|
self: { cpuPct, rssMB, heapMB },
|
|
133
160
|
system: { loadavg, cpuCount, totalMB, freeMB, memUsedPct },
|
|
161
|
+
power: this._power,
|
|
134
162
|
overLimit: rssMB > this._rssLimitMB,
|
|
135
163
|
};
|
|
136
164
|
}
|
|
137
165
|
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Snapshot of the busiest processes via `ps`, newest BSD/macOS + Linux compatible
|
|
169
|
+
* flags. Returns up to `limit` rows sorted by CPU%. Best-effort: [] on failure.
|
|
170
|
+
*/
|
|
171
|
+
export function listProcesses(limit = 40) {
|
|
172
|
+
try {
|
|
173
|
+
// -A all procs; -o explicit columns; comm=command name, args truncated by us.
|
|
174
|
+
const out = execSync('ps -Ao pid,ppid,%cpu,%mem,rss,comm', {
|
|
175
|
+
encoding: 'utf8',
|
|
176
|
+
timeout: 2000,
|
|
177
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
178
|
+
});
|
|
179
|
+
const rows = out.trim().split('\n').slice(1).map((line) => {
|
|
180
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)\s+(.+)$/);
|
|
181
|
+
if (!m) return null;
|
|
182
|
+
return {
|
|
183
|
+
pid: Number(m[1]),
|
|
184
|
+
ppid: Number(m[2]),
|
|
185
|
+
cpu: Number(m[3]),
|
|
186
|
+
mem: Number(m[4]),
|
|
187
|
+
rssMB: Math.round(Number(m[5]) / 1024),
|
|
188
|
+
command: m[6],
|
|
189
|
+
};
|
|
190
|
+
}).filter(Boolean);
|
|
191
|
+
rows.sort((a, b) => b.cpu - a.cpu);
|
|
192
|
+
return rows.slice(0, limit);
|
|
193
|
+
} catch {
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Send a signal to a pid. Validates the pid is a positive integer and refuses
|
|
200
|
+
* pid 1 and the server's own pid. Returns {ok} / {ok:false,error}.
|
|
201
|
+
*/
|
|
202
|
+
export function killProcess(pid, signal = 'SIGTERM') {
|
|
203
|
+
const n = Number(pid);
|
|
204
|
+
if (!Number.isInteger(n) || n <= 1) return { ok: false, error: 'invalid pid' };
|
|
205
|
+
if (n === process.pid) return { ok: false, error: 'refusing to kill the control server' };
|
|
206
|
+
try {
|
|
207
|
+
process.kill(n, signal);
|
|
208
|
+
return { ok: true };
|
|
209
|
+
} catch (err) {
|
|
210
|
+
return { ok: false, error: String(err?.message || err) };
|
|
211
|
+
}
|
|
212
|
+
}
|
package/lib/sessions.js
CHANGED
|
@@ -14,6 +14,7 @@ import { execFile as _execFile } from 'node:child_process';
|
|
|
14
14
|
import { promisify } from 'node:util';
|
|
15
15
|
|
|
16
16
|
import { parseTuiStatus, prettyModel } from './tui.js';
|
|
17
|
+
import { parsePanePrompt } from './prompt.js';
|
|
17
18
|
import { assignTranscripts, parseEtime } from './match.js';
|
|
18
19
|
import { pinKey } from './pins.js';
|
|
19
20
|
import { readPaneRegistry, gcPaneRegistry } from './pane-registry.js';
|
|
@@ -333,6 +334,8 @@ export class SessionRegistry extends EventEmitter {
|
|
|
333
334
|
this._ctxMap = new Map();
|
|
334
335
|
/** @type {Map<string, boolean>} target -> actively-generating flag */
|
|
335
336
|
this._thinkingMap = new Map();
|
|
337
|
+
/** @type {Map<string, {pending:boolean, question:string|null}>} target -> pane-derived prompt */
|
|
338
|
+
this._panePromptMap = new Map();
|
|
336
339
|
/** @type {ReturnType<setInterval>|null} */
|
|
337
340
|
this._interval = null;
|
|
338
341
|
/** @type {ReturnType<setInterval>|null} */
|
|
@@ -469,9 +472,13 @@ export class SessionRegistry extends EventEmitter {
|
|
|
469
472
|
const isPinned = pinnedByTarget.has(win.target);
|
|
470
473
|
const id = win.target;
|
|
471
474
|
// Pending = subscribed-tailer pending (live modal) OR transcript-derived
|
|
472
|
-
// pending
|
|
475
|
+
// pending OR pane-derived prompt (a numbered picker on screen — catches
|
|
476
|
+
// questions even when the transcript isn't matched). Works for ANY session.
|
|
477
|
+
const panePrompt = isClaude ? this._panePromptMap.get(id) : null;
|
|
473
478
|
const pending =
|
|
474
|
-
(this._pendingMap.get(id) ?? false) ||
|
|
479
|
+
(this._pendingMap.get(id) ?? false) ||
|
|
480
|
+
!!transcript?.transcriptPending ||
|
|
481
|
+
!!panePrompt?.pending;
|
|
475
482
|
const title = transcript?.customTitle || transcript?.aiTitle || null;
|
|
476
483
|
const ctx = isClaude ? this._ctxMap.get(win.target) || {} : {};
|
|
477
484
|
|
|
@@ -494,7 +501,7 @@ export class SessionRegistry extends EventEmitter {
|
|
|
494
501
|
pinned: isPinned,
|
|
495
502
|
lastActivity: transcript?.lastActivity ?? null,
|
|
496
503
|
pending,
|
|
497
|
-
pendingQuestion: transcript?.pendingQuestion ?? null,
|
|
504
|
+
pendingQuestion: transcript?.pendingQuestion ?? panePrompt?.question ?? null,
|
|
498
505
|
cmd: win.cmd,
|
|
499
506
|
isClaude,
|
|
500
507
|
kind: isClaude ? 'claude' : 'terminal',
|
|
@@ -549,11 +556,27 @@ export class SessionRegistry extends EventEmitter {
|
|
|
549
556
|
sessions.map(async (s) => {
|
|
550
557
|
if (!this._tmux.isValidTarget(s.target)) return;
|
|
551
558
|
try {
|
|
552
|
-
|
|
559
|
+
// Capture enough of the bottom to catch BOTH the working line ("esc to
|
|
560
|
+
// interrupt", which can sit several rows above the input box) AND a TUI
|
|
561
|
+
// question picker (parsePanePrompt scans ~18 bottom rows). One capture
|
|
562
|
+
// feeds both — keeping activity detection robust without extra execs.
|
|
563
|
+
const cap = await this._tmux.capturePane(s.target, 26);
|
|
553
564
|
const { thinking } = parseTuiStatus(cap);
|
|
554
565
|
this._thinkingMap.set(s.target, thinking);
|
|
555
|
-
// Merge into the live session object without a full rebuild.
|
|
556
566
|
s.thinking = thinking;
|
|
567
|
+
|
|
568
|
+
// Pane-derived question detection (Claude panes only): an on-screen
|
|
569
|
+
// numbered picker means a question is waiting — even if the transcript
|
|
570
|
+
// isn't matched. This is why some sessions wrongly read as "sleeping".
|
|
571
|
+
if (s.kind === 'claude') {
|
|
572
|
+
const prompt = parsePanePrompt(cap);
|
|
573
|
+
const rec = { pending: !!prompt, question: prompt?.question ?? null };
|
|
574
|
+
this._panePromptMap.set(s.target, rec);
|
|
575
|
+
const merged =
|
|
576
|
+
(this._pendingMap.get(s.target) ?? false) || rec.pending || s.pending;
|
|
577
|
+
s.pending = merged;
|
|
578
|
+
if (rec.pending && !s.pendingQuestion) s.pendingQuestion = rec.question;
|
|
579
|
+
}
|
|
557
580
|
} catch {
|
|
558
581
|
// pane gone / capture failed — leave previous value
|
|
559
582
|
}
|