@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/lib/skills.js CHANGED
@@ -1,17 +1,19 @@
1
1
  /**
2
2
  * lib/skills.js — discover and list available Claude slash-command skills.
3
3
  *
4
- * Skills are discovered from ~/.claude/skills/<name>/SKILL.md the synced,
5
- * authoritative set of invocable slash commands. (Plugin skills are materialised
6
- * INTO this directory by `olam skills sync` with their correct prefixed names,
7
- * e.g. `100x:brainstorm`; we deliberately do NOT walk ~/.claude/plugins/cache,
8
- * whose raw dir names use dashes `100x-debug` — and are NOT valid slash
9
- * commands, so prefilling them would not resolve.)
4
+ * Skills come from three places, lowest → highest priority:
5
+ * 1. PLUGIN skills installed Claude Code plugins listed in
6
+ * ~/.claude/plugins/installed_plugins.json. Each plugin `<name>@<market>`
7
+ * has an installPath under which nested `skills/<skill>/SKILL.md` files
8
+ * live; the slash command is `<name>:<skill>` (e.g. `100x` `100x:plan-hard`).
9
+ * 2. USER skills ~/.claude/skills/<name>/SKILL.md (synced/authored). The
10
+ * directory name IS the slash slug (e.g. `olam:create`, `api-design`).
11
+ * 3. PROJECT skills — <cwd>/.claude/skills/<name>/SKILL.md for the session's
12
+ * working directory; these override same-named user skills.
10
13
  *
11
- * For each directory that contains a SKILL.md, the skill's invocation name is
12
- * the directory name (e.g. `100x:brainstorm`, `api-design`). We parse the
13
- * SKILL.md YAML front-matter for `description` (and optionally `name`, but the
14
- * dir name is always the invocation slug).
14
+ * We parse each SKILL.md's YAML front-matter for `description`. Plugin SKILL.md
15
+ * paths aren't derivable from the slug, so we remember them in `_pluginPaths`
16
+ * for readSkill().
15
17
  *
16
18
  * Results are de-duped by name, sorted alphabetically, and cached for 30 s so
17
19
  * repeated opens don't cause repeated disk scans.
@@ -25,11 +27,39 @@ import path from 'node:path';
25
27
 
26
28
  const CACHE_TTL_MS = 30_000;
27
29
 
28
- /** @type {{ skills: SkillEntry[], ts: number } | null} */
29
- let _cache = null;
30
+ /**
31
+ * The Claude config home. Honors CLAUDE_HOME_DIR (Claude Code relocates ~/.claude
32
+ * via this env var — the user sets it per project, e.g. atlas / grain / pleri-org
33
+ * / personal), falling back to ~/.claude. Skills + plugins are read from here.
34
+ *
35
+ * @returns {string}
36
+ */
37
+ function claudeHome() {
38
+ const override = process.env.CLAUDE_HOME_DIR;
39
+ return override && override.trim() ? override : path.join(os.homedir(), '.claude');
40
+ }
30
41
 
31
42
  /**
32
- * @typedef {{ name: string, description: string, source: 'user' | 'plugin' }} SkillEntry
43
+ * Per-cwd cache so different sessions (different cwds) each get their own
44
+ * merged skill list. Key: cwd string (or '' for the global user-only list).
45
+ * @type {Map<string, { skills: SkillEntry[], ts: number }>}
46
+ */
47
+ const _cache = new Map();
48
+
49
+ /**
50
+ * Plugin skill command-name → absolute SKILL.md path. Plugin paths can't be
51
+ * derived from the slug (they live deep under installPath), so we remember them
52
+ * here during discovery for readSkill(). Rebuilt on each plugin scan.
53
+ * @type {Map<string, string>}
54
+ */
55
+ const _pluginPaths = new Map();
56
+
57
+ // Directories never worth descending into when scanning a plugin tree.
58
+ const _PLUGIN_SKIP = new Set(['node_modules', '.git', '.github', 'dist', 'build', 'coverage']);
59
+ const _PLUGIN_MAX_DEPTH = 6;
60
+
61
+ /**
62
+ * @typedef {{ name: string, description: string, source: 'user' | 'project' | 'plugin' }} SkillEntry
33
63
  */
34
64
 
35
65
  /**
@@ -37,14 +67,16 @@ let _cache = null;
37
67
  * subagents.js — scalar values only, no yaml dep. Returns null when no valid
38
68
  * front-matter block is found.
39
69
  *
70
+ * Returns both the key/value map AND the body text after the closing `---`.
71
+ *
40
72
  * @param {string} content
41
- * @returns {Record<string,string>|null}
73
+ * @returns {{ fm: Record<string,string>|null, body: string }}
42
74
  */
43
75
  function _parseFrontMatter(content) {
44
76
  const lines = content.split('\n');
45
- if (lines[0].trim() !== '---') return null;
77
+ if (lines[0].trim() !== '---') return { fm: null, body: content };
46
78
  const end = lines.indexOf('---', 1);
47
- if (end === -1) return null;
79
+ if (end === -1) return { fm: null, body: content };
48
80
  /** @type {Record<string,string>} */
49
81
  const result = {};
50
82
  for (let i = 1; i < end; i++) {
@@ -55,7 +87,11 @@ function _parseFrontMatter(content) {
55
87
  const val = line.slice(colon + 1).trim();
56
88
  if (key) result[key] = val;
57
89
  }
58
- return Object.keys(result).length > 0 ? result : null;
90
+ const body = lines.slice(end + 1).join('\n');
91
+ return {
92
+ fm: Object.keys(result).length > 0 ? result : null,
93
+ body,
94
+ };
59
95
  }
60
96
 
61
97
  /**
@@ -73,7 +109,7 @@ function _readDescription(skillDir) {
73
109
  } catch {
74
110
  return '';
75
111
  }
76
- const fm = _parseFrontMatter(content);
112
+ const { fm } = _parseFrontMatter(content);
77
113
  return fm?.description ?? '';
78
114
  }
79
115
 
@@ -83,10 +119,11 @@ function _readDescription(skillDir) {
83
119
  * files) are skipped.
84
120
  *
85
121
  * @param {string} dir
86
- * @param {'user' | 'plugin'} source
122
+ * @param {'user' | 'project'} source
87
123
  * @param {Map<string, SkillEntry>} into de-dup map keyed by skill name
124
+ * @param {boolean} overwrite when true, existing entries are replaced (project > user)
88
125
  */
89
- function _collectFrom(dir, source, into) {
126
+ function _collectFrom(dir, source, into, overwrite = false) {
90
127
  let entries;
91
128
  try {
92
129
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -104,44 +141,218 @@ function _collectFrom(dir, source, into) {
104
141
  } catch {
105
142
  continue;
106
143
  }
107
- // De-dup: first discovery wins (user skills before plugin).
108
- if (into.has(skillName)) continue;
144
+ // De-dup: project skills override same-named user skills when overwrite=true.
145
+ if (!overwrite && into.has(skillName)) continue;
109
146
  const description = _readDescription(skillDir);
110
147
  into.set(skillName, { name: skillName, description, source });
111
148
  }
112
149
  }
113
150
 
114
151
  /**
115
- * Discover all available skills. Returns a de-duped, name-sorted array.
116
- * Result is cached for 30 s.
152
+ * Register every `<skillsDir>/<skill>/SKILL.md` as `<pluginSlug>:<skill>`.
153
+ * Lowest priority never overwrites a user/project (or earlier-plugin) entry.
154
+ *
155
+ * @param {string} skillsDir
156
+ * @param {string} pluginSlug
157
+ * @param {Map<string, SkillEntry>} into
158
+ */
159
+ function _registerPluginSkillsIn(skillsDir, pluginSlug, into) {
160
+ let entries;
161
+ try {
162
+ entries = fs.readdirSync(skillsDir, { withFileTypes: true });
163
+ } catch {
164
+ return;
165
+ }
166
+ for (const ent of entries) {
167
+ if (!ent.isDirectory()) continue;
168
+ const mdPath = path.join(skillsDir, ent.name, 'SKILL.md');
169
+ let content;
170
+ try {
171
+ content = fs.readFileSync(mdPath, 'utf8');
172
+ } catch {
173
+ continue;
174
+ }
175
+ const cmd = `${pluginSlug}:${ent.name}`;
176
+ if (into.has(cmd)) continue; // user/project or an earlier plugin path wins
177
+ const { fm } = _parseFrontMatter(content);
178
+ into.set(cmd, { name: cmd, description: fm?.description ?? '', source: 'plugin' });
179
+ _pluginPaths.set(cmd, mdPath);
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Recursively walk a plugin install dir looking for `skills/` directories (the
185
+ * tree nests them under members/<x>/skills, shared/<x>/skills, etc.). Bounded
186
+ * depth + skip-list keep it cheap.
187
+ *
188
+ * @param {string} dir
189
+ * @param {string} pluginSlug
190
+ * @param {Map<string, SkillEntry>} into
191
+ * @param {number} depth
192
+ */
193
+ function _scanPluginDir(dir, pluginSlug, into, depth) {
194
+ if (depth > _PLUGIN_MAX_DEPTH) return;
195
+ let entries;
196
+ try {
197
+ entries = fs.readdirSync(dir, { withFileTypes: true });
198
+ } catch {
199
+ return;
200
+ }
201
+ for (const ent of entries) {
202
+ if (!ent.isDirectory() || _PLUGIN_SKIP.has(ent.name)) continue;
203
+ const sub = path.join(dir, ent.name);
204
+ if (ent.name === 'skills') _registerPluginSkillsIn(sub, pluginSlug, into);
205
+ else _scanPluginDir(sub, pluginSlug, into, depth + 1);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Discover skills from every installed Claude Code plugin. The slash command is
211
+ * `<pluginName>:<skillDir>` where pluginName is the part before `@` in the
212
+ * installed_plugins.json key (e.g. `100x@atlas-one` → `100x:plan-hard`).
117
213
  *
214
+ * @param {Map<string, SkillEntry>} into
215
+ */
216
+ function _collectPluginSkills(into) {
217
+ _pluginPaths.clear();
218
+ const jsonPath = path.join(claudeHome(), 'plugins', 'installed_plugins.json');
219
+ let manifest;
220
+ try {
221
+ manifest = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
222
+ } catch {
223
+ return;
224
+ }
225
+ const plugins = manifest?.plugins;
226
+ if (!plugins || typeof plugins !== 'object') return;
227
+ for (const [key, arr] of Object.entries(plugins)) {
228
+ const pluginSlug = String(key).split('@')[0];
229
+ if (!pluginSlug) continue;
230
+ for (const e of Array.isArray(arr) ? arr : []) {
231
+ const installPath = e?.installPath;
232
+ if (typeof installPath === 'string' && installPath) {
233
+ _scanPluginDir(installPath, pluginSlug, into, 0);
234
+ }
235
+ }
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Discover all available skills for a given session cwd. Merges project skills
241
+ * (from <cwd>/.claude/skills/) on top of user skills (from ~/.claude/skills/),
242
+ * with plugin skills as the base layer. Project > user > plugin on name clash.
243
+ * Results are cached per-cwd for 30 s.
244
+ *
245
+ * @param {string|null} [cwd] the session's working directory; null/undefined = user skills only
118
246
  * @returns {SkillEntry[]}
119
247
  */
120
- export function listSkills() {
248
+ export function listSkills(cwd) {
249
+ const cacheKey = cwd ?? '';
121
250
  const now = Date.now();
122
- if (_cache && now - _cache.ts < CACHE_TTL_MS) {
123
- return _cache.skills;
251
+ const hit = _cache.get(cacheKey);
252
+ if (hit && now - hit.ts < CACHE_TTL_MS) {
253
+ return hit.skills;
124
254
  }
125
255
 
126
- const home = os.homedir();
127
256
  /** @type {Map<string, SkillEntry>} */
128
257
  const byName = new Map();
129
258
 
130
- // ~/.claude/skills/<name>/SKILL.md — the synced, correctly-named invocable set.
131
- const userSkillsDir = path.join(home, '.claude', 'skills');
132
- _collectFrom(userSkillsDir, 'user', byName);
259
+ // 1. Plugin skills — the base layer (e.g. 100x:plan-hard from installed plugins).
260
+ _collectPluginSkills(byName);
261
+
262
+ // 2. User skills override plugins on name clash.
263
+ const userSkillsDir = path.join(claudeHome(), 'skills');
264
+ _collectFrom(userSkillsDir, 'user', byName, true);
265
+
266
+ // 3. Project skills override user + plugin.
267
+ if (cwd) {
268
+ const projectSkillsDir = path.join(cwd, '.claude', 'skills');
269
+ _collectFrom(projectSkillsDir, 'project', byName, true);
270
+ }
133
271
 
134
272
  const skills = [...byName.values()].sort((a, b) =>
135
273
  a.name.localeCompare(b.name),
136
274
  );
137
275
 
138
- _cache = { skills, ts: now };
276
+ _cache.set(cacheKey, { skills, ts: now });
139
277
  return skills;
140
278
  }
141
279
 
280
+ /**
281
+ * Read a single skill's SKILL.md content, validated against the discovered set
282
+ * for the given session cwd (traversal guard: only reads files in known dirs).
283
+ *
284
+ * @param {string} name skill name (directory name, e.g. '100x:brainstorm')
285
+ * @param {string|null} [cwd] session working directory
286
+ * @returns {{ name: string, source: 'user'|'project', frontMatter: Record<string,string>, body: string }|null}
287
+ */
288
+ export function readSkill(name, cwd) {
289
+ // Validate name: must appear in the discovered set for this cwd.
290
+ const discovered = listSkills(cwd);
291
+ const entry = discovered.find((s) => s.name === name);
292
+ if (!entry) return null;
293
+
294
+ // Plugin skills: resolve from the remembered SKILL.md path, guarded to stay
295
+ // under the claude home plugins dir (defence in depth — path came from our scan).
296
+ if (entry.source === 'plugin') {
297
+ const md = _pluginPaths.get(name);
298
+ if (!md) return null;
299
+ const pluginsRoot = path.resolve(path.join(claudeHome(), 'plugins'));
300
+ const resolved = path.resolve(md);
301
+ if (!resolved.startsWith(pluginsRoot + path.sep)) return null;
302
+ let content;
303
+ try {
304
+ content = fs.readFileSync(resolved, 'utf8');
305
+ } catch {
306
+ return null;
307
+ }
308
+ const { fm, body } = _parseFrontMatter(content);
309
+ return { name, source: 'plugin', frontMatter: fm ?? {}, body: body.trimStart() };
310
+ }
311
+
312
+ // User/project skills: resolve the correct directory WITHOUT interpolating
313
+ // `name` or `cwd` into an arbitrary path. We only look in the two known roots.
314
+ const userSkillsDir = path.join(claudeHome(), 'skills');
315
+ const projectSkillsDir = cwd ? path.join(cwd, '.claude', 'skills') : null;
316
+
317
+ // Security: resolve the canonical path of the SKILL.md and ensure it stays
318
+ // inside the expected root (prevents any path-traversal via name).
319
+ function safeRead(root) {
320
+ if (!root) return null;
321
+ // path.join normalises '..' segments; then we check the result is still
322
+ // inside root.
323
+ const candidate = path.join(root, name, 'SKILL.md');
324
+ const resolvedRoot = path.resolve(root);
325
+ const resolvedCandidate = path.resolve(candidate);
326
+ if (!resolvedCandidate.startsWith(resolvedRoot + path.sep)) return null;
327
+ let content;
328
+ try {
329
+ content = fs.readFileSync(resolvedCandidate, 'utf8');
330
+ } catch {
331
+ return null;
332
+ }
333
+ return content;
334
+ }
335
+
336
+ // Prefer project skill over user skill (matches listing precedence).
337
+ const content =
338
+ (projectSkillsDir ? safeRead(projectSkillsDir) : null) ??
339
+ safeRead(userSkillsDir);
340
+ if (content === null) return null;
341
+
342
+ const { fm, body } = _parseFrontMatter(content);
343
+
344
+ return {
345
+ name,
346
+ source: entry.source,
347
+ frontMatter: fm ?? {},
348
+ body: body.trimStart(),
349
+ };
350
+ }
351
+
142
352
  /**
143
353
  * Bust the in-process cache. Used in tests.
144
354
  */
145
355
  export function _bustCache() {
146
- _cache = null;
356
+ _cache.clear();
357
+ _pluginPaths.clear();
147
358
  }
package/lib/subagents.js CHANGED
@@ -26,8 +26,12 @@ import { TranscriptTailer } from './transcript.js';
26
26
  const META_RE = /^agent-(.+)\.meta\.json$/;
27
27
  // A sub-agent whose transcript hasn't grown in this long is treated as finished,
28
28
  // even if we never saw the parent's tool_result (e.g. it predates the parent's
29
- // bounded message buffer). Live sub-agents append continuously.
30
- const RUNNING_WINDOW_MS = 45_000;
29
+ // bounded message buffer). Live sub-agents append whenever the LLM produces a
30
+ // token or tool result, but long inference calls (extended thinking, slow tools)
31
+ // can pause writes for several minutes. 600 s (10 min) covers realistic worst-case
32
+ // LLM pauses while still expiring stale-but-finished agents whose tool_result
33
+ // predates the bounded parent buffer. doneByParent always wins when available.
34
+ const RUNNING_WINDOW_MS = 600_000;
31
35
 
32
36
  // ---------------------------------------------------------------------------
33
37
  // Agent definition front-matter cache + discovery
package/lib/terminal.js CHANGED
@@ -151,12 +151,36 @@ export async function ensureTerminal(id, target) {
151
151
  clearTimeout(existing.idleTimer);
152
152
  existing.idleTimer = null;
153
153
  }
154
+ existing.lastUsed = Date.now();
154
155
  const port = await existing.ready;
155
156
  return { port };
156
157
  }
157
158
 
158
- if (terminals.size >= MAX_TERMINALS) {
159
- throw new Error(`terminal cap reached (${MAX_TERMINALS} live); close one and retry`);
159
+ // LRU bound: keep at most MAX_TERMINALS live. When full, evict the
160
+ // least-recently-used terminal (preferring ones with no connected clients)
161
+ // instead of erroring — the UI keeps the latest few warm and expects older
162
+ // ones to be offloaded silently.
163
+ while (terminals.size >= MAX_TERMINALS) {
164
+ let victimId = null;
165
+ let victim = null;
166
+ for (const [vid, t] of terminals) {
167
+ if (!victim) {
168
+ victimId = vid;
169
+ victim = t;
170
+ continue;
171
+ }
172
+ const vIdle = victim.clients.size === 0;
173
+ const tIdle = t.clients.size === 0;
174
+ if (tIdle && !vIdle) {
175
+ victimId = vid;
176
+ victim = t;
177
+ } else if (tIdle === vIdle && (t.lastUsed || 0) < (victim.lastUsed || 0)) {
178
+ victimId = vid;
179
+ victim = t;
180
+ }
181
+ }
182
+ if (victimId == null) break;
183
+ reap(victimId);
160
184
  }
161
185
 
162
186
  const tmuxBin = await tmux.resolveTmuxBin();
@@ -229,10 +253,13 @@ export async function ensureTerminal(id, target) {
229
253
  });
230
254
 
231
255
  /** @type {Terminal} */
232
- const term = { proc, port, target, clients: new Set(), idleTimer: null, ready };
256
+ const term = { proc, port, target, clients: new Set(), idleTimer: null, ready, lastUsed: Date.now() };
233
257
  terminals.set(id, term);
234
258
 
235
259
  const readyPort = await ready;
260
+ // Pin the window to its largest client so this extra (cockpit) attach never
261
+ // shrinks the user's real terminal. Best-effort — don't fail the open on it.
262
+ tmux.setWindowSizeLargest(target).catch(() => {});
236
263
  return { port: readyPort };
237
264
  }
238
265
 
@@ -248,6 +275,7 @@ export function addClient(id, client) {
248
275
  const term = terminals.get(id);
249
276
  if (!term) return;
250
277
  term.clients.add(client);
278
+ term.lastUsed = Date.now();
251
279
  if (term.idleTimer) {
252
280
  clearTimeout(term.idleTimer);
253
281
  term.idleTimer = null;
package/lib/tmux.js CHANGED
@@ -92,8 +92,13 @@ export async function getSocketPath() {
92
92
  // Target validation
93
93
  // ---------------------------------------------------------------------------
94
94
 
95
- /** Pattern from CONTRACT: ^[A-Za-z0-9_.-]+:\d+(\.\d+)?$ */
96
- const TARGET_RE = /^[A-Za-z0-9_.-]+:\d+(\.\d+)?$/;
95
+ // tmux session names allow spaces and punctuation (e.g. a grouped session named
96
+ // "claude-control & olam"). The session-name part is therefore any run of
97
+ // printable chars EXCEPT the `:` target delimiter and control chars; then
98
+ // `:window(.pane)`. Targets only ever reach tmux as an execFile/spawn argv (never
99
+ // a shell), so spaces/`&` can't inject — and ids from clients must additionally
100
+ // resolve via sessionById. Window/pane segments stay strictly numeric.
101
+ const TARGET_RE = /^[^\x00-\x1f:]+:\d+(\.\d+)?$/;
97
102
 
98
103
  /**
99
104
  * Returns true when `target` is a syntactically valid tmux target string.
@@ -429,6 +434,21 @@ export async function setPaneOption(target, name, value) {
429
434
  await runTmux(['set-option', '-p', '-t', target, name, String(value)]);
430
435
  }
431
436
 
437
+ /**
438
+ * Size the window to its LARGEST attached client, not the latest/smallest. The
439
+ * cockpit's ttyd attaches as an extra client; with tmux's default `window-size
440
+ * latest` that extra client shrinks the user's real terminal ("cramped"). With
441
+ * `largest`, the user's full-size terminal wins and the cockpit view just
442
+ * letterboxes. Best-effort; window option set via the pane's target.
443
+ *
444
+ * @param {string} target pane/window target
445
+ * @returns {Promise<void>}
446
+ */
447
+ export async function setWindowSizeLargest(target) {
448
+ assertTarget(target);
449
+ await runTmux(['set-option', '-w', '-t', target, 'window-size', 'largest']);
450
+ }
451
+
432
452
  // ---------------------------------------------------------------------------
433
453
  // Rename window
434
454
  // ---------------------------------------------------------------------------
package/lib/transcribe.js CHANGED
@@ -61,11 +61,15 @@ export function resolveWhisperBin() {
61
61
  export function resolveWhisperModel() {
62
62
  const e = process.env.WHISPER_MODEL;
63
63
  if (e && e.trim() && fs.existsSync(e.trim())) return e.trim();
64
+ // Prefer multilingual models (no `.en`) when present: a `.en` model can ONLY
65
+ // do English, so if the user dropped in a multilingual ggml they want the mix
66
+ // (English + Chinese + Singlish/…). English-only models are the fallback.
64
67
  const prefs = [
68
+ 'ggml-medium.bin',
69
+ 'ggml-small.bin',
70
+ 'ggml-base.bin',
65
71
  'ggml-base.en.bin',
66
72
  'ggml-small.en.bin',
67
- 'ggml-base.bin',
68
- 'ggml-small.bin',
69
73
  'ggml-tiny.en.bin',
70
74
  ];
71
75
  for (const m of prefs) {
@@ -121,13 +125,16 @@ function run(bin, args) {
121
125
  }
122
126
 
123
127
  /**
124
- * Transcribe an audio file (any ffmpeg-readable format) to text.
128
+ * Transcribe an audio file (any ffmpeg-readable format) to text — always in
129
+ * English. A multilingual model uses Whisper's TRANSLATE task, so Chinese,
130
+ * Singlish, and mixed speech all come back as English. English-only (`.en`)
131
+ * models are already English; nothing to translate.
125
132
  *
126
133
  * @param {string} inputPath - path to the recorded audio file.
127
134
  * @param {{ lang?: string }} [opts]
128
135
  * @returns {Promise<string>}
129
136
  */
130
- export async function transcribe(inputPath, { lang = 'en' } = {}) {
137
+ export async function transcribe(inputPath, { lang } = {}) {
131
138
  const ffmpeg = resolveFfmpeg();
132
139
  const whisper = resolveWhisperBin();
133
140
  const model = resolveWhisperModel();
@@ -135,6 +142,13 @@ export async function transcribe(inputPath, { lang = 'en' } = {}) {
135
142
  if (!whisper) throw new Error('whisper-cli not found (brew install whisper-cpp)');
136
143
  if (!model) throw new Error(`no whisper model found in ${MODELS_DIR}`);
137
144
 
145
+ // `.en` models do English only; multilingual models auto-detect the source then
146
+ // translate it to English. Source language is overridable (lang / WHISPER_LANG)
147
+ // for the rare case you want to pin detection; output stays English.
148
+ const englishOnly = /\.en\.bin$/i.test(path.basename(model));
149
+ const effLang = lang || process.env.WHISPER_LANG || (englishOnly ? 'en' : 'auto');
150
+ const translate = !englishOnly; // → always-English output
151
+
138
152
  const wav = path.join(
139
153
  os.tmpdir(),
140
154
  `cc-stt-${Date.now()}-${process.pid}.wav`,
@@ -147,7 +161,8 @@ export async function transcribe(inputPath, { lang = 'en' } = {}) {
147
161
  '-f', 'wav', wav,
148
162
  ]);
149
163
  const { stdout } = await run(whisper, [
150
- '-m', model, '-f', wav, '-np', '-nt', '-l', lang,
164
+ '-m', model, '-f', wav, '-np', '-nt', '-l', effLang,
165
+ ...(translate ? ['--translate'] : []),
151
166
  ]);
152
167
  return cleanTranscript(stdout);
153
168
  } finally {
package/lib/tui.js CHANGED
@@ -11,10 +11,24 @@
11
11
  const ANSI_RE = /\x1b\[[0-9;]*m/g;
12
12
  const CTX_RE = /ctx:\s*(\d+)\s*%/i;
13
13
  const MODEL_RE = /\b(Opus|Sonnet|Haiku)\s+[\d.]+(?:\s*\([^)]*\))?/i;
14
- // Claude is actively generating when the TUI shows the working line, which
15
- // always ends with "esc to interrupt" (the verb/glyph vary). Crucially we must
16
- // NOT trip on the AskUserQuestion picker, which shows "esc to cancel".
14
+ // Claude is actively generating when the TUI shows the working line. Two signals:
15
+ // 1) "esc to interrupt" (the classic interruptible working line), OR
16
+ // 2) the loader+timer: a verb ending in "…" followed by a "(Ns" elapsed counter,
17
+ // e.g. "✛ Hyperspacing… (20s · still thinking…)". This catches sub-agent /
18
+ // high-effort states that omit "esc to interrupt".
19
+ // Neither matches the AskUserQuestion picker ("esc to cancel") nor the idle
20
+ // "Brewed for 8h" summary (no "…(Ns").
21
+ //
22
+ // IMPORTANT: these are only tested against the LAST THINKING_SCAN_LINES lines of
23
+ // the capture. _pollThinking captures 26 lines (visible + scrollback history) so
24
+ // that parsePanePrompt can find question pickers. Scanning the full 26 lines for
25
+ // thinking signals causes false positives: a completed-turn working line that has
26
+ // scrolled into history (but is still within the 26-line window) keeps matching
27
+ // after generation ends. Limiting to the bottom 8 lines covers the entire visible
28
+ // Claude TUI status area while excluding stale scrollback content.
17
29
  const THINKING_RE = /esc to interrupt/i;
30
+ const WORKING_TIMER_RE = /…\s*\(\s*\d+\s*[smh]\b/;
31
+ const THINKING_SCAN_LINES = 8;
18
32
 
19
33
  /**
20
34
  * @param {string} capture raw `tmux capture-pane -p` output (ANSI ok)
@@ -34,7 +48,12 @@ export function parseTuiStatus(capture) {
34
48
  const modelMatch = text.match(MODEL_RE);
35
49
  if (modelMatch) model = modelMatch[0].replace(/\s+/g, ' ').trim();
36
50
 
37
- const thinking = THINKING_RE.test(text);
51
+ // Restrict the thinking-signal scan to the bottom THINKING_SCAN_LINES lines so
52
+ // that stale working/timer lines in the scrollback history (above the visible
53
+ // area) do not produce a false positive after generation ends.
54
+ const lines = text.split('\n');
55
+ const tail = lines.slice(-THINKING_SCAN_LINES).join('\n');
56
+ const thinking = THINKING_RE.test(tail) || WORKING_TIMER_RE.test(tail);
38
57
 
39
58
  return { ctxPct, model, thinking };
40
59
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idl3/claude-control",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Local web UI to watch and drive your Claude Code sessions running in tmux — live transcripts, reply, answer AskUserQuestion, attach files, from a browser or phone.",
6
6
  "keywords": [