@idl3/claude-control 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/lib/optimize.js +18 -3
- package/lib/prompt.js +66 -32
- package/lib/resources.js +75 -0
- package/lib/sessions.js +28 -5
- package/lib/skills.js +244 -33
- package/lib/subagents.js +6 -2
- package/lib/terminal.js +31 -3
- package/lib/tmux.js +22 -2
- package/lib/transcribe.js +20 -5
- package/lib/tui.js +23 -4
- package/package.json +1 -1
- package/server.js +107 -20
- package/web/dist/assets/{core-DjwRjAVq.js → core-CEmDs9PV.js} +1 -1
- package/web/dist/assets/index-CWs7fxHN.css +1 -0
- package/web/dist/assets/index-Dku_hPFx.js +103 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DJcMTsha.css +0 -1
- package/web/dist/assets/index-Dar5Ut3m.js +0 -89
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
12
|
-
* the
|
|
13
|
-
*
|
|
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
|
-
/**
|
|
29
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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' | '
|
|
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:
|
|
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
|
-
*
|
|
116
|
-
*
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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
|
|
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
|
|
30
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
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',
|
|
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
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
|
|
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.
|
|
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": [
|