@idl3/claude-control 0.2.0 → 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,11 +18,16 @@ 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
- - **Voice → text** — `brew install ffmpeg whisper-cpp` and drop a model at `~/.claude-control/models/ggml-base.en.bin`. The mic in the composer records audio and transcribes it locally.
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`.)*
26
31
  - **Prompt enhancer (✨)** — defaults to a **local MLX model** on Apple Silicon. One-time setup:
27
32
  ```bash
28
33
  python3 -m venv ~/.claude-control/mlx-venv
@@ -96,16 +101,59 @@ matching transcript under `~/.claude/projects/`.
96
101
 
97
102
  ---
98
103
 
99
- ## Updating
104
+ ## Updating & restarting
105
+
106
+ How you update depends on **how you installed** — pick your row. Check your
107
+ current version any time with `claude-control --version`.
108
+
109
+ > **`npm install` does NOT pull the git repo.** The npm package ships the app
110
+ > *prebuilt* (the `web/dist` bundle is included), so there's no source tree to
111
+ > `git pull` and nothing to build. Update by reinstalling the package.
112
+
113
+ ### Installed globally (`npm install -g`)
114
+
115
+ ```bash
116
+ npm install -g @idl3/claude-control@latest # fetch the new version
117
+ # then restart the server (see "Restarting" below)
118
+ ```
119
+
120
+ The in-app **update banner / “Update now”** button is for **source checkouts
121
+ only** (it runs `git pull`); on an npm install it has no repo to update, so use
122
+ the command above instead.
123
+
124
+ ### Run via `npx` (no install)
125
+
126
+ ```bash
127
+ npx @idl3/claude-control@latest # always fetches the latest
128
+ ```
129
+
130
+ `npx` re-resolves the package each run, so you're already on the newest version
131
+ every time you start it — just restart the process.
132
+
133
+ ### From source (git checkout)
134
+
135
+ ```bash
136
+ git pull && npm install && npm run build # then restart
137
+ ```
138
+
139
+ …or click **Update now** in the app: the server pulls from `origin`, reinstalls,
140
+ rebuilds `web/dist`, and restarts itself in place; the page reconnects
141
+ automatically.
142
+
143
+ ### Restarting the server
144
+
145
+ - **Foreground** (you ran `claude-control` / `npm start` in a terminal): press
146
+ `Ctrl-C`, then run it again. The web UI reconnects on its own.
147
+ - **launchd service** (you ran `claude-control install-service`):
148
+ ```bash
149
+ launchctl kickstart -k gui/$(id -u)/com.ernest.claude-control
150
+ ```
151
+ or `claude-control uninstall-service && claude-control install-service`.
100
152
 
101
- claude-control compares your checkout against its git upstream (`origin`) and
102
- shows an **update banner** when new commits are available. Click **Update now**
103
- — the server pulls, reinstalls, rebuilds the web bundle, and restarts itself in
104
- place; the page reconnects automatically. (Equivalent manual update: `git pull
105
- && npm install && npm run build`, then restart.)
153
+ Restarting is safe sessions live in tmux, so nothing is lost; each browser
154
+ re-prompts for the token once (if one is set).
106
155
 
107
- Version numbers follow npm semver (bump `package.json` per release); this is
108
- **v0.1.0**.
156
+ Version numbers follow npm semver (`claude-control --version`).
109
157
 
110
158
  ---
111
159
 
package/lib/match.js CHANGED
@@ -15,6 +15,10 @@
15
15
  import { isCwdConsistent } from './sessions.js';
16
16
 
17
17
  const DEFAULT_START_SLACK_MS = 5 * 60_000; // proc-start vs transcript-birth tolerance
18
+ // Two candidates active within this window count as a recency "tie", so the
19
+ // start-time ↔ birthtime signal breaks it (concurrent sessions in one cwd).
20
+ // Beyond it, the more-recently-active transcript always wins (resume-safe).
21
+ const RECENCY_TIE_MS = 2 * 60_000;
18
22
 
19
23
  /**
20
24
  * @param {string|null} etime macOS `ps -o etime` value: "[[dd-]hh:]mm:ss"
@@ -115,45 +119,33 @@ export function assignTranscripts(panes, candidates, opts = {}) {
115
119
  // sessions.js BEFORE this matcher runs; assignTranscripts is the fallback for
116
120
  // panes with no hook record, using only deterministic timing signals below.
117
121
 
118
- // Pass 1 nearest start-time birthtime.
119
- for (const pane of ordered) {
120
- if (result.has(pane.target)) continue;
121
- if (pane.procStartMs == null) continue;
122
- let best = null;
123
- let bestDelta = Infinity;
124
- for (const c of available(pane)) {
125
- if (c.birthtimeMs == null) continue;
126
- const delta = Math.abs(c.birthtimeMs - pane.procStartMs);
127
- // Prefer transcripts born around/after the proc started; reject ones born
128
- // long before the proc (those belong to an earlier session in this dir).
129
- if (c.birthtimeMs < pane.procStartMs - startSlackMs) continue;
130
- if (
131
- delta < bestDelta ||
132
- (delta === bestDelta &&
133
- (c.lastActivityMs ?? 0) > (best?.lastActivityMs ?? 0))
134
- ) {
135
- best = c;
136
- bestDelta = delta;
137
- }
122
+ const activity = (c) => c.lastActivityMs ?? c.mtime ?? c.birthtimeMs ?? 0;
123
+
124
+ // Choose between two candidates for a pane: RECENCY is primary (the actively-
125
+ // written transcript wins), and start-time ↔ birthtime only breaks ties when
126
+ // two candidates were active at nearly the same time (genuinely concurrent
127
+ // sessions in one cwd). This is what fixes resumed sessions: a resumed
128
+ // transcript is born long ago but is the most-recently-active, so it must beat
129
+ // a freshly-BORN but stale sibling whose birth merely coincides with the
130
+ // resume time (the old "start-time grabs the wrong transcript" bug).
131
+ const prefer = (pane, c, best) => {
132
+ const ca = activity(c);
133
+ const ba = activity(best);
134
+ if (Math.abs(ca - ba) > RECENCY_TIE_MS) return ca > ba;
135
+ if (pane.procStartMs != null && c.birthtimeMs != null && best.birthtimeMs != null) {
136
+ const cd = Math.abs(c.birthtimeMs - pane.procStartMs);
137
+ const bd = Math.abs(best.birthtimeMs - pane.procStartMs);
138
+ if (cd !== bd) return cd < bd;
138
139
  }
139
- if (best) claim(pane, best);
140
- }
140
+ return ca > ba;
141
+ };
141
142
 
142
- // Pass 2 — most-recently-active remaining candidate.
143
- // Gate: when the pane's process start time is known, only consider candidates
144
- // whose last known activity (lastActivityMs, falling back to file mtime or
145
- // birthtime) is at or after the pane started (minus startSlackMs). A transcript
146
- // that was never touched after the pane launched cannot belong to it — that is
147
- // the "fresh pane inherits old transcript" bug. When procStartMs is unknown,
148
- // skip the gate so we don't regress panes with missing timing data.
149
- // NOTE: --resume is safe: Claude appends a record to the old transcript on
150
- // resume, bumping its mtime/lastActivityMs above the pane's start time.
151
143
  for (const pane of ordered) {
152
144
  if (result.has(pane.target)) continue;
153
145
  let best = null;
154
146
  for (const c of available(pane)) {
155
147
  if (!temporallyPlausible(pane, c)) continue;
156
- if (!best || (c.lastActivityMs ?? 0) > (best.lastActivityMs ?? 0)) best = c;
148
+ if (!best || prefer(pane, c, best)) best = c;
157
149
  }
158
150
  if (best) claim(pane, best);
159
151
  }
package/lib/optimize.js CHANGED
@@ -176,6 +176,9 @@ function wordCount(s) {
176
176
  }
177
177
 
178
178
  const QUESTION_BOILERPLATE = /\b(specify|please provide|could you clarify|clarif(y|ication)|let me know)\b/i;
179
+ // The model sometimes echoes/optimises buildLlmPrompt itself — these phrases are
180
+ // the optimiser's own meta-instructions and must never appear in a rewrite.
181
+ const PROMPT_LEAK = /(treat the draft|content to rewrite|not as instructions to follow|return strict json|rewritten prompt|prompt optimiser|examples of the bar|```draft|"optimized"|"rationale")/i;
179
182
  const LIST_LINE = /^\s*(\d+[).]|[-*])\s+/gm;
180
183
  const STOPWORDS = new Set([
181
184
  'the', 'a', 'an', 'to', 'of', 'and', 'or', 'for', 'in', 'on', 'with', 'is',
@@ -221,6 +224,7 @@ function isInterrogative(s) {
221
224
  * - instruction-to-question: an imperative draft turned interrogative
222
225
  * - added-list: ≥2 list lines the draft didn't have
223
226
  * - intent-drift: <50% of the draft's content tokens survive
227
+ * - prompt-leak: the model echoed buildLlmPrompt's own instructions
224
228
  * - empty: blank result
225
229
  *
226
230
  * @param {string} draft
@@ -260,6 +264,7 @@ export function evaluateRewrite(draft, optimized) {
260
264
  if (isImperative(draft) && isInterrogative(opt)) violations.push('instruction-to-question');
261
265
  if (!draftHasList && optListLines >= 2) violations.push('added-list');
262
266
  if (dTokens.length >= 4 && survived < 0.5) violations.push('intent-drift');
267
+ if (PROMPT_LEAK.test(opt) && !PROMPT_LEAK.test(draft)) violations.push('prompt-leak');
263
268
 
264
269
  return { ok: violations.length === 0, violations, metrics };
265
270
  }
@@ -283,17 +288,32 @@ export function isRunawayRewrite(draft, optimized) {
283
288
  */
284
289
  function coerceLlmParsed(parsed) {
285
290
  if (!parsed || typeof parsed !== 'object') return null;
286
- const optimized = typeof parsed.optimized === 'string' ? parsed.optimized.trim() : '';
291
+ const optimized = typeof parsed.optimized === 'string' ? decodeLeakedEscapes(parsed.optimized).trim() : '';
287
292
  if (!optimized) return null;
288
293
  const rationale = Array.isArray(parsed.rationale)
289
- ? parsed.rationale.filter((x) => typeof x === 'string')
294
+ ? parsed.rationale.filter((x) => typeof x === 'string').map(decodeLeakedEscapes)
290
295
  : [];
291
296
  const changes = Array.isArray(parsed.changes)
292
- ? parsed.changes.filter((x) => typeof x === 'string')
297
+ ? parsed.changes.filter((x) => typeof x === 'string').map(decodeLeakedEscapes)
293
298
  : [];
294
299
  return { optimized, rationale, changes };
295
300
  }
296
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
+
297
317
  /**
298
318
  * Tolerant JSON parse: try direct parse; on failure extract first balanced
299
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
  }
package/lib/shell.js CHANGED
@@ -30,8 +30,16 @@ const NAMED_KEYS = [
30
30
  'Up', 'Down', 'Left', 'Right', 'Home', 'End', 'PPage', 'NPage',
31
31
  'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12',
32
32
  ];
33
+ // Navigation keys with every Ctrl/Meta/Shift modifier combination, so a hardware
34
+ // keyboard (e.g. iPad Magic Keyboard) can send Opt+Arrow word-jumps, Shift+Arrow
35
+ // selection, etc. Prefix order is C-,M-,S- (matches the frontend navToken).
36
+ const NAV_KEYS = ['Up', 'Down', 'Left', 'Right', 'Home', 'End', 'PPage', 'NPage'];
37
+ const NAV_PREFIXES = ['', 'C-', 'M-', 'S-', 'C-M-', 'C-S-', 'M-S-', 'C-M-S-'];
38
+ const NAV_COMBOS = NAV_KEYS.flatMap((k) => NAV_PREFIXES.map((p) => p + k));
39
+
33
40
  export const SHELL_KEYS = new Set([
34
41
  ...NAMED_KEYS,
42
+ ...NAV_COMBOS, // Up/Down/.../NPage with C-/M-/S- combinations
35
43
  ...ALPHA.map((c) => `C-${c}`), // C-a .. C-z
36
44
  ...ALPHA.map((c) => `M-${c}`), // M-a .. M-z (Option/Meta)
37
45
  ]);