@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 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 = 18;
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
- // Last contiguous run of numbered options within the bottom region.
45
- let end = -1;
46
- for (let i = lines.length - 1; i >= 0; i--) {
47
- if (OPTION_RE.test(lines[i])) {
48
- end = i;
49
- break;
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
- options.push({ key: m[2], label, selected: cursor });
64
+ matches.push({ line: i, key: m[2], label, cursor: m[1] === '❯' || m[1] === '›', checked });
67
65
  }
68
- // Need 2 options numbered consecutively from 1 to look like a menu.
69
- if (options.length < 2 || options[0].key !== '1') return null;
66
+ if (matches.length < 2) return null;
70
67
 
71
- // "Esc to cancel / ctrl+e" footer within a few lines below the block.
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 = end + 1; i <= Math.min(lines.length - 1, end + 3); 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 = nearest non-empty line above the options block.
84
- let question = '';
85
- for (let i = start - 1; i >= 0 && i >= start - 4; i--) {
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
- question = t;
89
- break;
90
- }
112
+ if (!t) break;
113
+ qLines.unshift(t);
91
114
  }
115
+ const question = qLines.join(' ').slice(0, 240);
92
116
 
93
- return { question: question || 'Make a selection', options };
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 (works for ANY session, even unsubscribed ones, for push).
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) || !!transcript?.transcriptPending;
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
- const cap = await this._tmux.capturePane(s.target, 5);
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
  }