@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/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,15 @@ 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;
35
+ // A file written within this window is treated as actively-running, overriding a
36
+ // (possibly premature, e.g. background-launch-ack) doneByParent flag.
37
+ const ACTIVE_WINDOW_MS = 20_000;
31
38
 
32
39
  // ---------------------------------------------------------------------------
33
40
  // Agent definition front-matter cache + discovery
@@ -134,6 +141,109 @@ function _lookupAgentDef(agentType) {
134
141
  return null;
135
142
  }
136
143
 
144
+ // ---------------------------------------------------------------------------
145
+ // Agent listing — mirrors lib/skills.js `listSkills` pattern
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /** 30-second TTL for the agent list cache. */
149
+ const AGENTS_CACHE_TTL_MS = 30_000;
150
+
151
+ /**
152
+ * Per-cwd cache so different sessions each get their own merged agent list.
153
+ * Key: cwd string (or '' for the process-cwd-only list).
154
+ * @type {Map<string, { agents: AgentEntry[], ts: number }>}
155
+ */
156
+ const _agentsCache = new Map();
157
+
158
+ /**
159
+ * @typedef {{ name: string, description: string, source: 'user' | 'project' | 'plugin' }} AgentEntry
160
+ */
161
+
162
+ /**
163
+ * Discover all available agent definitions for a given session cwd.
164
+ * Merges roots from `_agentSearchRoots()` (user + plugin + process.cwd agents)
165
+ * plus the session-specific cwd agents dir when provided.
166
+ *
167
+ * Priority (highest → lowest): project > user > plugin.
168
+ * Implemented by processing roots in order plugin → user → project and
169
+ * overwriting on name clash (later entry wins), which yields project-last = wins.
170
+ *
171
+ * Results are cached per-cwd for AGENTS_CACHE_TTL_MS.
172
+ *
173
+ * @param {string|null} [cwd] the session's working directory; null = no project agents
174
+ * @returns {AgentEntry[]}
175
+ */
176
+ export function listAgents(cwd) {
177
+ const cacheKey = cwd ?? '';
178
+ const now = Date.now();
179
+ const hit = _agentsCache.get(cacheKey);
180
+ if (hit && now - hit.ts < AGENTS_CACHE_TTL_MS) {
181
+ return hit.agents;
182
+ }
183
+
184
+ const home = os.homedir();
185
+ const pluginCacheRoot = path.join(home, '.claude', 'plugins', 'cache');
186
+
187
+ // Collect roots from _agentSearchRoots() (includes user + plugin cache + process.cwd agents).
188
+ const baseRoots = _agentSearchRoots();
189
+
190
+ // Also include the session-specific cwd agents dir when it differs from process.cwd().
191
+ const cwdAgentsDir = cwd ? path.join(cwd, '.claude', 'agents') : null;
192
+ const allRoots = cwdAgentsDir && !baseRoots.includes(cwdAgentsDir)
193
+ ? [...baseRoots, cwdAgentsDir]
194
+ : baseRoots;
195
+
196
+ /**
197
+ * Classify a root by source priority.
198
+ * Plugin roots live under ~/.claude/plugins/cache.
199
+ * The user root is ~/.claude/agents.
200
+ * Everything else is treated as project (cwd-local).
201
+ *
202
+ * We process in order: plugin → user → project so that when names clash
203
+ * the last write wins, giving project > user > plugin precedence.
204
+ */
205
+ const classifyRoot = (/** @type {string} */ dir) => {
206
+ if (dir.startsWith(pluginCacheRoot + path.sep) || dir === pluginCacheRoot) return 'plugin';
207
+ if (dir === path.join(home, '.claude', 'agents')) return 'user';
208
+ return 'project';
209
+ };
210
+
211
+ // Sort roots: plugin first, then user, then project (so project overwrites on clash).
212
+ const priority = { plugin: 0, user: 1, project: 2 };
213
+ const sortedRoots = [...allRoots].sort((a, b) => priority[classifyRoot(a)] - priority[classifyRoot(b)]);
214
+
215
+ /** @type {Map<string, AgentEntry>} */
216
+ const byName = new Map();
217
+
218
+ for (const dir of sortedRoots) {
219
+ const source = classifyRoot(dir);
220
+ let files;
221
+ try { files = fs.readdirSync(dir); } catch { continue; }
222
+ for (const filename of files) {
223
+ if (!filename.endsWith('.md')) continue;
224
+ const filePath = path.join(dir, filename);
225
+ let content;
226
+ try { content = fs.readFileSync(filePath, 'utf8'); } catch { continue; }
227
+ const fm = _parseFrontMatter(content);
228
+ const name = fm?.name?.trim() || filename.slice(0, -3); // sans .md
229
+ const description = fm?.description ?? '';
230
+ // Overwrite unconditionally — sortedRoots ordering ensures project wins last.
231
+ byName.set(name, { name, description, source });
232
+ }
233
+ }
234
+
235
+ const agents = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
236
+ _agentsCache.set(cacheKey, { agents, ts: now });
237
+ return agents;
238
+ }
239
+
240
+ /**
241
+ * Bust the in-process agents cache. Used in tests.
242
+ */
243
+ export function _bustAgentsCache() {
244
+ _agentsCache.clear();
245
+ }
246
+
137
247
  export class SubAgentsWatcher extends EventEmitter {
138
248
  /**
139
249
  * @param {string} transcriptPath absolute path to the PARENT transcript
@@ -257,13 +367,23 @@ export class SubAgentsWatcher extends EventEmitter {
257
367
  * even when their parent tool_result predates the bounded message buffer.
258
368
  */
259
369
  _statusFor(a) {
260
- if (a.doneByParent) return 'done';
370
+ let mtimeMs = null;
261
371
  try {
262
- const mtimeMs = fs.statSync(a.jsonlPath).mtimeMs;
263
- return Date.now() - mtimeMs < RUNNING_WINDOW_MS ? 'running' : 'done';
372
+ mtimeMs = fs.statSync(a.jsonlPath).mtimeMs;
264
373
  } catch {
265
374
  return 'done';
266
375
  }
376
+ const age = Date.now() - mtimeMs;
377
+ // Actively being written → RUNNING, even if the parent already emitted a
378
+ // tool_result for this agent. A BACKGROUND agent's launch-ack tool_result
379
+ // lands IMMEDIATELY (setting doneByParent) while the agent keeps writing for
380
+ // minutes — without this override it would wrongly read as done the whole run.
381
+ if (age < ACTIVE_WINDOW_MS) return 'running';
382
+ // Quiet file: the parent's tool_result is now authoritative (foreground agents
383
+ // go quiet exactly at completion → done within ACTIVE_WINDOW). Otherwise fall
384
+ // back to the longer freshness window (covers long mid-inference pauses).
385
+ if (a.doneByParent) return 'done';
386
+ return age < RUNNING_WINDOW_MS ? 'running' : 'done';
267
387
  }
268
388
 
269
389
  _entry(a) {
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 {