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