@idl3/claude-control 0.2.1 → 0.4.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,11 +18,18 @@ 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):**
24
29
 
25
30
  - **Voice → text** — run `claude-control setup` once: it installs `ffmpeg` + `whisper.cpp` (Homebrew) and downloads a ggml model to `~/.claude-control/models/`. The mic in the composer then records audio and transcribes it locally (no API key). *(Manual equivalent: `brew install ffmpeg whisper-cpp` and drop a model at `~/.claude-control/models/ggml-base.en.bin`.)*
31
+
32
+ > **Microphone on a phone or tablet**: browsers only allow mic access on a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts) (HTTPS or `localhost`). A plain `http://192.168.x.x:4317` URL leaves `navigator.mediaDevices` undefined on iOS/Android — the permission won't stick and re-prompts every reload. `localhost` on the Mac itself is exempt. Easiest fix: `tailscale serve --bg 4317` then open the `https://<host>.ts.net` URL. Or run with your own cert: `TLS_CERT=cert.pem TLS_KEY=key.pem claude-control`.
26
33
  - **Prompt enhancer (✨)** — defaults to a **local MLX model** on Apple Silicon. One-time setup:
27
34
  ```bash
28
35
  python3 -m venv ~/.claude-control/mlx-venv
package/bin/setup.sh CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/bin/bash
2
- # claude-control setup — install local dependencies for voice transcription.
2
+ # claude-control setup — install local dependencies (voice + raw terminal).
3
3
  #
4
- # Whisper.cpp is NOT bundled. The 🎤 voice input needs three things, all local
5
- # (no API key, no cloud): ffmpeg, the whisper-cli binary (Homebrew `whisper-cpp`),
6
- # and a ggml model under ~/.claude-control/models. This installs/downloads them
4
+ # None of these are bundled. The 🎤 voice input needs ffmpeg, the whisper-cli
5
+ # binary (Homebrew `whisper-cpp`), and a ggml model under ~/.claude-control/models.
6
+ # The in-browser raw terminal needs ttyd. This installs/downloads them all
7
7
  # idempotently. tmux (required to run the app at all) is checked too.
8
8
  set -uo pipefail
9
9
 
@@ -28,8 +28,10 @@ if ! command -v brew >/dev/null 2>&1; then
28
28
  exit 1
29
29
  fi
30
30
 
31
- say "Installing ffmpeg + whisper-cpp (Homebrew, skips if already present)…"
32
- brew install ffmpeg whisper-cpp || {
31
+ say "Installing ffmpeg + whisper-cpp + ttyd (Homebrew, skips if already present)…"
32
+ # ttyd powers the in-browser raw tmux terminal; the server spawns/manages it on
33
+ # demand (no daemon to start), but the binary must exist first.
34
+ brew install ffmpeg whisper-cpp ttyd || {
33
35
  bad "brew install failed — see output above"
34
36
  exit 1
35
37
  }
@@ -50,10 +52,11 @@ else
50
52
  fi
51
53
  fi
52
54
 
53
- say "Verifying voice-transcription chain…"
55
+ say "Verifying local dependencies…"
54
56
  command -v ffmpeg >/dev/null 2>&1 && ok "ffmpeg: $(command -v ffmpeg)" || bad "ffmpeg missing"
55
57
  command -v whisper-cli >/dev/null 2>&1 && ok "whisper-cli: $(command -v whisper-cli)" || bad "whisper-cli missing (brew install whisper-cpp)"
56
58
  ls "$MODELS_DIR"/ggml-*.bin >/dev/null 2>&1 && ok "model: $(ls "$MODELS_DIR"/ggml-*.bin | head -1)" || bad "no ggml model in $MODELS_DIR"
59
+ command -v ttyd >/dev/null 2>&1 && ok "ttyd: $(command -v ttyd)" || bad "ttyd missing (brew install ttyd) — raw in-browser terminal won't work without it"
57
60
 
58
61
  say "Done. The 🎤 mic (voice → text) is ready."
59
62
  echo " Note: the MLX prompt-enhancer (optional) is separate; the optimiser falls"
package/lib/config.js CHANGED
@@ -45,6 +45,12 @@ const CLAUDE_BIN_MAX = 500;
45
45
  const MLX_MODEL_MAX = 200;
46
46
  const OPTIMIZE_BACKENDS = ['mlx', 'claude', 'rules'];
47
47
 
48
+ // Transcript font-size: integer px values the user can choose.
49
+ // Base range: 12-18px. External-display range: 12-22px (larger monitor benefit).
50
+ const FONT_SIZE_MIN = 12;
51
+ const FONT_SIZE_MAX = 18;
52
+ const EXT_FONT_SIZE_MAX = 22;
53
+
48
54
  /** Defaults, recomputed each call so a changed HOME/env is honoured. */
49
55
  function defaults() {
50
56
  return {
@@ -57,6 +63,11 @@ function defaults() {
57
63
  optimizeBackend: 'mlx',
58
64
  // Default MLX model auto-picked for this machine's unified memory.
59
65
  mlxModel: recommendMlxModel(detectMachine().ramGB),
66
+ // Transcript font-size (px). 0 = use the CSS default (--txt-transcript).
67
+ // transcriptFontSize applies on non-external-display (base / iPad).
68
+ // externalFontSize applies ONLY when body.is-external-display is set.
69
+ transcriptFontSize: 0,
70
+ externalFontSize: 0,
60
71
  };
61
72
  }
62
73
 
@@ -64,7 +75,7 @@ function defaults() {
64
75
  * Read the persisted config, merged over defaults. Never throws — a missing,
65
76
  * empty, or corrupt file falls back to defaults. Only known keys are surfaced.
66
77
  *
67
- * @returns {{ launchCommand: string, defaultCwd: string, optimizeModel: string, claudeBin: string, optimizeBackend: string, mlxModel: string }}
78
+ * @returns {{ launchCommand: string, defaultCwd: string, optimizeModel: string, claudeBin: string, optimizeBackend: string, mlxModel: string, transcriptFontSize: number, externalFontSize: number }}
68
79
  */
69
80
  export function readConfig() {
70
81
  const base = defaults();
@@ -75,6 +86,13 @@ export function readConfig() {
75
86
  return base;
76
87
  }
77
88
  if (!parsed || typeof parsed !== 'object') return base;
89
+
90
+ const clampFontSize = (v, max) => {
91
+ const n = Number(v);
92
+ if (!Number.isInteger(n) || n === 0) return 0; // 0 = use CSS default
93
+ return Math.min(max, Math.max(FONT_SIZE_MIN, n));
94
+ };
95
+
78
96
  return {
79
97
  launchCommand:
80
98
  typeof parsed.launchCommand === 'string' && parsed.launchCommand.trim()
@@ -101,6 +119,14 @@ export function readConfig() {
101
119
  typeof parsed.mlxModel === 'string' && parsed.mlxModel.trim()
102
120
  ? parsed.mlxModel
103
121
  : base.mlxModel,
122
+ transcriptFontSize:
123
+ parsed.transcriptFontSize !== undefined
124
+ ? clampFontSize(parsed.transcriptFontSize, FONT_SIZE_MAX)
125
+ : base.transcriptFontSize,
126
+ externalFontSize:
127
+ parsed.externalFontSize !== undefined
128
+ ? clampFontSize(parsed.externalFontSize, EXT_FONT_SIZE_MAX)
129
+ : base.externalFontSize,
104
130
  };
105
131
  }
106
132
 
@@ -191,6 +217,28 @@ export function writeConfig(partial = {}) {
191
217
  next.mlxModel = m;
192
218
  }
193
219
 
220
+ if (partial.transcriptFontSize !== undefined) {
221
+ const n = Number(partial.transcriptFontSize);
222
+ if (!Number.isFinite(n) || !Number.isInteger(n)) {
223
+ throw new Error('transcriptFontSize must be an integer');
224
+ }
225
+ if (n !== 0 && (n < FONT_SIZE_MIN || n > FONT_SIZE_MAX)) {
226
+ throw new Error(`transcriptFontSize must be 0 or ${FONT_SIZE_MIN}–${FONT_SIZE_MAX}`);
227
+ }
228
+ next.transcriptFontSize = n;
229
+ }
230
+
231
+ if (partial.externalFontSize !== undefined) {
232
+ const n = Number(partial.externalFontSize);
233
+ if (!Number.isFinite(n) || !Number.isInteger(n)) {
234
+ throw new Error('externalFontSize must be an integer');
235
+ }
236
+ if (n !== 0 && (n < FONT_SIZE_MIN || n > EXT_FONT_SIZE_MAX)) {
237
+ throw new Error(`externalFontSize must be 0 or ${FONT_SIZE_MIN}–${EXT_FONT_SIZE_MAX}`);
238
+ }
239
+ next.externalFontSize = n;
240
+ }
241
+
194
242
  const dir = dataDir();
195
243
  fs.mkdirSync(dir, { recursive: true });
196
244
  fs.writeFileSync(configPath(), JSON.stringify(next, null, 2), { mode: 0o600 });
package/lib/optimize.js CHANGED
@@ -107,16 +107,11 @@ export function rulesOptimize(input) {
107
107
  optimized = stripped;
108
108
  }
109
109
 
110
- // Step 3: detect missing structure
110
+ // Step 3: note (don't inject) a weak imperative. We deliberately do NOT prepend
111
+ // a "Goal:" line — "Goal:" reads as the /goal full-autonomy directive and must
112
+ // never be added by accident (the user explicitly opted out of this rewrite).
111
113
  if (!hasImperativeGoal(optimized)) {
112
- changes.push('Goal not stated as a clear imperative up front.');
113
- // Restructure: extract first sentence/clause as Goal line
114
- const firstSentenceEnd = optimized.search(/[.!?\n]/);
115
- const firstClause =
116
- firstSentenceEnd > 0 ? optimized.slice(0, firstSentenceEnd) : optimized;
117
- const rest = firstSentenceEnd > 0 ? optimized.slice(firstSentenceEnd + 1).trimStart() : '';
118
- optimized = `Goal: ${firstClause}${rest ? '\n\n' + rest : ''}`;
119
- rationale.push('Prepended "Goal:" line so the imperative comes first.');
114
+ changes.push('Consider leading with a clear imperative (verb-first).');
120
115
  }
121
116
 
122
117
  if (!mentionsOutputFormat(optimized)) {
@@ -288,17 +283,32 @@ export function isRunawayRewrite(draft, optimized) {
288
283
  */
289
284
  function coerceLlmParsed(parsed) {
290
285
  if (!parsed || typeof parsed !== 'object') return null;
291
- const optimized = typeof parsed.optimized === 'string' ? parsed.optimized.trim() : '';
286
+ const optimized = typeof parsed.optimized === 'string' ? decodeLeakedEscapes(parsed.optimized).trim() : '';
292
287
  if (!optimized) return null;
293
288
  const rationale = Array.isArray(parsed.rationale)
294
- ? parsed.rationale.filter((x) => typeof x === 'string')
289
+ ? parsed.rationale.filter((x) => typeof x === 'string').map(decodeLeakedEscapes)
295
290
  : [];
296
291
  const changes = Array.isArray(parsed.changes)
297
- ? parsed.changes.filter((x) => typeof x === 'string')
292
+ ? parsed.changes.filter((x) => typeof x === 'string').map(decodeLeakedEscapes)
298
293
  : [];
299
294
  return { optimized, rationale, changes };
300
295
  }
301
296
 
297
+ /**
298
+ * Decode literal `\uXXXX` escapes the model sometimes double-escapes into the
299
+ * JSON string (e.g. an apostrophe becomes the LITERAL text "’" instead of
300
+ * ’). Without this, those backslash-u sequences get typed verbatim into the
301
+ * composer/tmux. Only touches `\uXXXX` (and `\\`) — leaves other text intact.
302
+ *
303
+ * @param {string} s
304
+ * @returns {string}
305
+ */
306
+ function decodeLeakedEscapes(s) {
307
+ return String(s)
308
+ .replace(/\\\\/g, '\\') // collapse double-escaped backslashes first
309
+ .replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
310
+ }
311
+
302
312
  /**
303
313
  * Tolerant JSON parse: try direct parse; on failure extract first balanced
304
314
  * `{...}` 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
 
@@ -493,8 +500,9 @@ export class SessionRegistry extends EventEmitter {
493
500
  transcriptPath: transcript?.transcriptPath ?? null,
494
501
  pinned: isPinned,
495
502
  lastActivity: transcript?.lastActivity ?? null,
503
+ lastActivityMs: transcript?.lastActivityMs ?? null,
496
504
  pending,
497
- pendingQuestion: transcript?.pendingQuestion ?? null,
505
+ pendingQuestion: transcript?.pendingQuestion ?? panePrompt?.question ?? null,
498
506
  cmd: win.cmd,
499
507
  isClaude,
500
508
  kind: isClaude ? 'claude' : 'terminal',
@@ -549,11 +557,27 @@ export class SessionRegistry extends EventEmitter {
549
557
  sessions.map(async (s) => {
550
558
  if (!this._tmux.isValidTarget(s.target)) return;
551
559
  try {
552
- const cap = await this._tmux.capturePane(s.target, 5);
560
+ // Capture enough of the bottom to catch BOTH the working line ("esc to
561
+ // interrupt", which can sit several rows above the input box) AND a TUI
562
+ // question picker (parsePanePrompt scans ~18 bottom rows). One capture
563
+ // feeds both — keeping activity detection robust without extra execs.
564
+ const cap = await this._tmux.capturePane(s.target, 26);
553
565
  const { thinking } = parseTuiStatus(cap);
554
566
  this._thinkingMap.set(s.target, thinking);
555
- // Merge into the live session object without a full rebuild.
556
567
  s.thinking = thinking;
568
+
569
+ // Pane-derived question detection (Claude panes only): an on-screen
570
+ // numbered picker means a question is waiting — even if the transcript
571
+ // isn't matched. This is why some sessions wrongly read as "sleeping".
572
+ if (s.kind === 'claude') {
573
+ const prompt = parsePanePrompt(cap);
574
+ const rec = { pending: !!prompt, question: prompt?.question ?? null };
575
+ this._panePromptMap.set(s.target, rec);
576
+ const merged =
577
+ (this._pendingMap.get(s.target) ?? false) || rec.pending || s.pending;
578
+ s.pending = merged;
579
+ if (rec.pending && !s.pendingQuestion) s.pendingQuestion = rec.question;
580
+ }
557
581
  } catch {
558
582
  // pane gone / capture failed — leave previous value
559
583
  }