@idl3/claude-control 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -9
- package/lib/match.js +24 -32
- package/lib/optimize.js +23 -3
- package/lib/prompt.js +66 -32
- package/lib/resources.js +75 -0
- package/lib/sessions.js +28 -5
- package/lib/shell.js +8 -0
- 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-DM2iK52g.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-DwNp83VT.css +0 -1
- package/web/dist/assets/index-DwmU8Yna.js +0 -89
package/README.md
CHANGED
|
@@ -18,11 +18,16 @@ talks to tmux. Bind is localhost-only by default.
|
|
|
18
18
|
npm install -g @idl3/claude-control # or run once: npx @idl3/claude-control
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
> **`claude-control: command not found`?** Use `-g` — a plain/local `npm install`
|
|
22
|
+
> only drops the binary in `./node_modules/.bin/` (not on `PATH`). If it's still
|
|
23
|
+
> missing after `-g`, your npm global bin dir isn't on `PATH`: run `npm prefix -g`
|
|
24
|
+
> and add `<that>/bin` to your shell `PATH`, or just use `npx @idl3/claude-control`.
|
|
25
|
+
|
|
21
26
|
**Prerequisites:** Node ≥20 and **tmux** on your `PATH` (`brew install tmux` · `sudo apt install tmux`). Optional: **ttyd** for the in-browser raw terminal (`brew install ttyd` · `sudo apt install ttyd`) — set `CLAUDE_CONTROL_TTYD` to override its path. The web UI ships prebuilt — no build step on install.
|
|
22
27
|
|
|
23
28
|
**Optional local AI (no API key):**
|
|
24
29
|
|
|
25
|
-
- **Voice → text** — `
|
|
30
|
+
- **Voice → text** — run `claude-control setup` once: it installs `ffmpeg` + `whisper.cpp` (Homebrew) and downloads a ggml model to `~/.claude-control/models/`. The mic in the composer then records audio and transcribes it locally (no API key). *(Manual equivalent: `brew install ffmpeg whisper-cpp` and drop a model at `~/.claude-control/models/ggml-base.en.bin`.)*
|
|
26
31
|
- **Prompt enhancer (✨)** — defaults to a **local MLX model** on Apple Silicon. One-time setup:
|
|
27
32
|
```bash
|
|
28
33
|
python3 -m venv ~/.claude-control/mlx-venv
|
|
@@ -96,16 +101,59 @@ matching transcript under `~/.claude/projects/`.
|
|
|
96
101
|
|
|
97
102
|
---
|
|
98
103
|
|
|
99
|
-
## Updating
|
|
104
|
+
## Updating & restarting
|
|
105
|
+
|
|
106
|
+
How you update depends on **how you installed** — pick your row. Check your
|
|
107
|
+
current version any time with `claude-control --version`.
|
|
108
|
+
|
|
109
|
+
> **`npm install` does NOT pull the git repo.** The npm package ships the app
|
|
110
|
+
> *prebuilt* (the `web/dist` bundle is included), so there's no source tree to
|
|
111
|
+
> `git pull` and nothing to build. Update by reinstalling the package.
|
|
112
|
+
|
|
113
|
+
### Installed globally (`npm install -g`)
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
npm install -g @idl3/claude-control@latest # fetch the new version
|
|
117
|
+
# then restart the server (see "Restarting" below)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The in-app **update banner / “Update now”** button is for **source checkouts
|
|
121
|
+
only** (it runs `git pull`); on an npm install it has no repo to update, so use
|
|
122
|
+
the command above instead.
|
|
123
|
+
|
|
124
|
+
### Run via `npx` (no install)
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
npx @idl3/claude-control@latest # always fetches the latest
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`npx` re-resolves the package each run, so you're already on the newest version
|
|
131
|
+
every time you start it — just restart the process.
|
|
132
|
+
|
|
133
|
+
### From source (git checkout)
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
git pull && npm install && npm run build # then restart
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
…or click **Update now** in the app: the server pulls from `origin`, reinstalls,
|
|
140
|
+
rebuilds `web/dist`, and restarts itself in place; the page reconnects
|
|
141
|
+
automatically.
|
|
142
|
+
|
|
143
|
+
### Restarting the server
|
|
144
|
+
|
|
145
|
+
- **Foreground** (you ran `claude-control` / `npm start` in a terminal): press
|
|
146
|
+
`Ctrl-C`, then run it again. The web UI reconnects on its own.
|
|
147
|
+
- **launchd service** (you ran `claude-control install-service`):
|
|
148
|
+
```bash
|
|
149
|
+
launchctl kickstart -k gui/$(id -u)/com.ernest.claude-control
|
|
150
|
+
```
|
|
151
|
+
or `claude-control uninstall-service && claude-control install-service`.
|
|
100
152
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
— the server pulls, reinstalls, rebuilds the web bundle, and restarts itself in
|
|
104
|
-
place; the page reconnects automatically. (Equivalent manual update: `git pull
|
|
105
|
-
&& npm install && npm run build`, then restart.)
|
|
153
|
+
Restarting is safe — sessions live in tmux, so nothing is lost; each browser
|
|
154
|
+
re-prompts for the token once (if one is set).
|
|
106
155
|
|
|
107
|
-
Version numbers follow npm semver (
|
|
108
|
-
**v0.1.0**.
|
|
156
|
+
Version numbers follow npm semver (`claude-control --version`).
|
|
109
157
|
|
|
110
158
|
---
|
|
111
159
|
|
package/lib/match.js
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
import { isCwdConsistent } from './sessions.js';
|
|
16
16
|
|
|
17
17
|
const DEFAULT_START_SLACK_MS = 5 * 60_000; // proc-start vs transcript-birth tolerance
|
|
18
|
+
// Two candidates active within this window count as a recency "tie", so the
|
|
19
|
+
// start-time ↔ birthtime signal breaks it (concurrent sessions in one cwd).
|
|
20
|
+
// Beyond it, the more-recently-active transcript always wins (resume-safe).
|
|
21
|
+
const RECENCY_TIE_MS = 2 * 60_000;
|
|
18
22
|
|
|
19
23
|
/**
|
|
20
24
|
* @param {string|null} etime macOS `ps -o etime` value: "[[dd-]hh:]mm:ss"
|
|
@@ -115,45 +119,33 @@ export function assignTranscripts(panes, candidates, opts = {}) {
|
|
|
115
119
|
// sessions.js BEFORE this matcher runs; assignTranscripts is the fallback for
|
|
116
120
|
// panes with no hook record, using only deterministic timing signals below.
|
|
117
121
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
)
|
|
135
|
-
best = c;
|
|
136
|
-
bestDelta = delta;
|
|
137
|
-
}
|
|
122
|
+
const activity = (c) => c.lastActivityMs ?? c.mtime ?? c.birthtimeMs ?? 0;
|
|
123
|
+
|
|
124
|
+
// Choose between two candidates for a pane: RECENCY is primary (the actively-
|
|
125
|
+
// written transcript wins), and start-time ↔ birthtime only breaks ties when
|
|
126
|
+
// two candidates were active at nearly the same time (genuinely concurrent
|
|
127
|
+
// sessions in one cwd). This is what fixes resumed sessions: a resumed
|
|
128
|
+
// transcript is born long ago but is the most-recently-active, so it must beat
|
|
129
|
+
// a freshly-BORN but stale sibling whose birth merely coincides with the
|
|
130
|
+
// resume time (the old "start-time grabs the wrong transcript" bug).
|
|
131
|
+
const prefer = (pane, c, best) => {
|
|
132
|
+
const ca = activity(c);
|
|
133
|
+
const ba = activity(best);
|
|
134
|
+
if (Math.abs(ca - ba) > RECENCY_TIE_MS) return ca > ba;
|
|
135
|
+
if (pane.procStartMs != null && c.birthtimeMs != null && best.birthtimeMs != null) {
|
|
136
|
+
const cd = Math.abs(c.birthtimeMs - pane.procStartMs);
|
|
137
|
+
const bd = Math.abs(best.birthtimeMs - pane.procStartMs);
|
|
138
|
+
if (cd !== bd) return cd < bd;
|
|
138
139
|
}
|
|
139
|
-
|
|
140
|
-
}
|
|
140
|
+
return ca > ba;
|
|
141
|
+
};
|
|
141
142
|
|
|
142
|
-
// Pass 2 — most-recently-active remaining candidate.
|
|
143
|
-
// Gate: when the pane's process start time is known, only consider candidates
|
|
144
|
-
// whose last known activity (lastActivityMs, falling back to file mtime or
|
|
145
|
-
// birthtime) is at or after the pane started (minus startSlackMs). A transcript
|
|
146
|
-
// that was never touched after the pane launched cannot belong to it — that is
|
|
147
|
-
// the "fresh pane inherits old transcript" bug. When procStartMs is unknown,
|
|
148
|
-
// skip the gate so we don't regress panes with missing timing data.
|
|
149
|
-
// NOTE: --resume is safe: Claude appends a record to the old transcript on
|
|
150
|
-
// resume, bumping its mtime/lastActivityMs above the pane's start time.
|
|
151
143
|
for (const pane of ordered) {
|
|
152
144
|
if (result.has(pane.target)) continue;
|
|
153
145
|
let best = null;
|
|
154
146
|
for (const c of available(pane)) {
|
|
155
147
|
if (!temporallyPlausible(pane, c)) continue;
|
|
156
|
-
if (!best || (c
|
|
148
|
+
if (!best || prefer(pane, c, best)) best = c;
|
|
157
149
|
}
|
|
158
150
|
if (best) claim(pane, best);
|
|
159
151
|
}
|
package/lib/optimize.js
CHANGED
|
@@ -176,6 +176,9 @@ function wordCount(s) {
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
const QUESTION_BOILERPLATE = /\b(specify|please provide|could you clarify|clarif(y|ication)|let me know)\b/i;
|
|
179
|
+
// The model sometimes echoes/optimises buildLlmPrompt itself — these phrases are
|
|
180
|
+
// the optimiser's own meta-instructions and must never appear in a rewrite.
|
|
181
|
+
const PROMPT_LEAK = /(treat the draft|content to rewrite|not as instructions to follow|return strict json|rewritten prompt|prompt optimiser|examples of the bar|```draft|"optimized"|"rationale")/i;
|
|
179
182
|
const LIST_LINE = /^\s*(\d+[).]|[-*])\s+/gm;
|
|
180
183
|
const STOPWORDS = new Set([
|
|
181
184
|
'the', 'a', 'an', 'to', 'of', 'and', 'or', 'for', 'in', 'on', 'with', 'is',
|
|
@@ -221,6 +224,7 @@ function isInterrogative(s) {
|
|
|
221
224
|
* - instruction-to-question: an imperative draft turned interrogative
|
|
222
225
|
* - added-list: ≥2 list lines the draft didn't have
|
|
223
226
|
* - intent-drift: <50% of the draft's content tokens survive
|
|
227
|
+
* - prompt-leak: the model echoed buildLlmPrompt's own instructions
|
|
224
228
|
* - empty: blank result
|
|
225
229
|
*
|
|
226
230
|
* @param {string} draft
|
|
@@ -260,6 +264,7 @@ export function evaluateRewrite(draft, optimized) {
|
|
|
260
264
|
if (isImperative(draft) && isInterrogative(opt)) violations.push('instruction-to-question');
|
|
261
265
|
if (!draftHasList && optListLines >= 2) violations.push('added-list');
|
|
262
266
|
if (dTokens.length >= 4 && survived < 0.5) violations.push('intent-drift');
|
|
267
|
+
if (PROMPT_LEAK.test(opt) && !PROMPT_LEAK.test(draft)) violations.push('prompt-leak');
|
|
263
268
|
|
|
264
269
|
return { ok: violations.length === 0, violations, metrics };
|
|
265
270
|
}
|
|
@@ -283,17 +288,32 @@ export function isRunawayRewrite(draft, optimized) {
|
|
|
283
288
|
*/
|
|
284
289
|
function coerceLlmParsed(parsed) {
|
|
285
290
|
if (!parsed || typeof parsed !== 'object') return null;
|
|
286
|
-
const optimized = typeof parsed.optimized === 'string' ? parsed.optimized.trim() : '';
|
|
291
|
+
const optimized = typeof parsed.optimized === 'string' ? decodeLeakedEscapes(parsed.optimized).trim() : '';
|
|
287
292
|
if (!optimized) return null;
|
|
288
293
|
const rationale = Array.isArray(parsed.rationale)
|
|
289
|
-
? parsed.rationale.filter((x) => typeof x === 'string')
|
|
294
|
+
? parsed.rationale.filter((x) => typeof x === 'string').map(decodeLeakedEscapes)
|
|
290
295
|
: [];
|
|
291
296
|
const changes = Array.isArray(parsed.changes)
|
|
292
|
-
? parsed.changes.filter((x) => typeof x === 'string')
|
|
297
|
+
? parsed.changes.filter((x) => typeof x === 'string').map(decodeLeakedEscapes)
|
|
293
298
|
: [];
|
|
294
299
|
return { optimized, rationale, changes };
|
|
295
300
|
}
|
|
296
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Decode literal `\uXXXX` escapes the model sometimes double-escapes into the
|
|
304
|
+
* JSON string (e.g. an apostrophe becomes the LITERAL text "’" instead of
|
|
305
|
+
* ’). Without this, those backslash-u sequences get typed verbatim into the
|
|
306
|
+
* composer/tmux. Only touches `\uXXXX` (and `\\`) — leaves other text intact.
|
|
307
|
+
*
|
|
308
|
+
* @param {string} s
|
|
309
|
+
* @returns {string}
|
|
310
|
+
*/
|
|
311
|
+
function decodeLeakedEscapes(s) {
|
|
312
|
+
return String(s)
|
|
313
|
+
.replace(/\\\\/g, '\\') // collapse double-escaped backslashes first
|
|
314
|
+
.replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
|
315
|
+
}
|
|
316
|
+
|
|
297
317
|
/**
|
|
298
318
|
* Tolerant JSON parse: try direct parse; on failure extract first balanced
|
|
299
319
|
* `{...}` block and try again.
|
package/lib/prompt.js
CHANGED
|
@@ -16,12 +16,15 @@ function stripAnsi(s) {
|
|
|
16
16
|
|
|
17
17
|
// A numbered option line, optionally preceded by the TUI cursor (❯/›).
|
|
18
18
|
const OPTION_RE = /^\s*([❯›]?)\s*(\d)[.)]\s+(.+?)\s*$/;
|
|
19
|
+
// A checkbox marker at the START of an option label, e.g. "[ ] Label" or "[x] Label".
|
|
20
|
+
// Matches the bracket content: space = unchecked; x/✓/✗ = checked.
|
|
21
|
+
const CHECKBOX_RE = /^\[([✓x✗ ])\]\s*(.*)/;
|
|
19
22
|
// The footer real Claude Code prompts render under the options.
|
|
20
23
|
const ESC_HINT_RE = /\besc\b[^\n]*(cancel|interrupt|exit|reject|keep|quit)|ctrl\+[a-z]\b/i;
|
|
21
24
|
// How many lines from the bottom to consider — the active prompt always renders
|
|
22
25
|
// at the bottom of the pane, so a numbered list higher up (assistant prose) is
|
|
23
26
|
// out of scope.
|
|
24
|
-
const BOTTOM_REGION =
|
|
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
|
|
|
@@ -494,7 +501,7 @@ export class SessionRegistry extends EventEmitter {
|
|
|
494
501
|
pinned: isPinned,
|
|
495
502
|
lastActivity: transcript?.lastActivity ?? null,
|
|
496
503
|
pending,
|
|
497
|
-
pendingQuestion: transcript?.pendingQuestion ?? null,
|
|
504
|
+
pendingQuestion: transcript?.pendingQuestion ?? panePrompt?.question ?? null,
|
|
498
505
|
cmd: win.cmd,
|
|
499
506
|
isClaude,
|
|
500
507
|
kind: isClaude ? 'claude' : 'terminal',
|
|
@@ -549,11 +556,27 @@ export class SessionRegistry extends EventEmitter {
|
|
|
549
556
|
sessions.map(async (s) => {
|
|
550
557
|
if (!this._tmux.isValidTarget(s.target)) return;
|
|
551
558
|
try {
|
|
552
|
-
|
|
559
|
+
// Capture enough of the bottom to catch BOTH the working line ("esc to
|
|
560
|
+
// interrupt", which can sit several rows above the input box) AND a TUI
|
|
561
|
+
// question picker (parsePanePrompt scans ~18 bottom rows). One capture
|
|
562
|
+
// feeds both — keeping activity detection robust without extra execs.
|
|
563
|
+
const cap = await this._tmux.capturePane(s.target, 26);
|
|
553
564
|
const { thinking } = parseTuiStatus(cap);
|
|
554
565
|
this._thinkingMap.set(s.target, thinking);
|
|
555
|
-
// Merge into the live session object without a full rebuild.
|
|
556
566
|
s.thinking = thinking;
|
|
567
|
+
|
|
568
|
+
// Pane-derived question detection (Claude panes only): an on-screen
|
|
569
|
+
// numbered picker means a question is waiting — even if the transcript
|
|
570
|
+
// isn't matched. This is why some sessions wrongly read as "sleeping".
|
|
571
|
+
if (s.kind === 'claude') {
|
|
572
|
+
const prompt = parsePanePrompt(cap);
|
|
573
|
+
const rec = { pending: !!prompt, question: prompt?.question ?? null };
|
|
574
|
+
this._panePromptMap.set(s.target, rec);
|
|
575
|
+
const merged =
|
|
576
|
+
(this._pendingMap.get(s.target) ?? false) || rec.pending || s.pending;
|
|
577
|
+
s.pending = merged;
|
|
578
|
+
if (rec.pending && !s.pendingQuestion) s.pendingQuestion = rec.question;
|
|
579
|
+
}
|
|
557
580
|
} catch {
|
|
558
581
|
// pane gone / capture failed — leave previous value
|
|
559
582
|
}
|
package/lib/shell.js
CHANGED
|
@@ -30,8 +30,16 @@ const NAMED_KEYS = [
|
|
|
30
30
|
'Up', 'Down', 'Left', 'Right', 'Home', 'End', 'PPage', 'NPage',
|
|
31
31
|
'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12',
|
|
32
32
|
];
|
|
33
|
+
// Navigation keys with every Ctrl/Meta/Shift modifier combination, so a hardware
|
|
34
|
+
// keyboard (e.g. iPad Magic Keyboard) can send Opt+Arrow word-jumps, Shift+Arrow
|
|
35
|
+
// selection, etc. Prefix order is C-,M-,S- (matches the frontend navToken).
|
|
36
|
+
const NAV_KEYS = ['Up', 'Down', 'Left', 'Right', 'Home', 'End', 'PPage', 'NPage'];
|
|
37
|
+
const NAV_PREFIXES = ['', 'C-', 'M-', 'S-', 'C-M-', 'C-S-', 'M-S-', 'C-M-S-'];
|
|
38
|
+
const NAV_COMBOS = NAV_KEYS.flatMap((k) => NAV_PREFIXES.map((p) => p + k));
|
|
39
|
+
|
|
33
40
|
export const SHELL_KEYS = new Set([
|
|
34
41
|
...NAMED_KEYS,
|
|
42
|
+
...NAV_COMBOS, // Up/Down/.../NPage with C-/M-/S- combinations
|
|
35
43
|
...ALPHA.map((c) => `C-${c}`), // C-a .. C-z
|
|
36
44
|
...ALPHA.map((c) => `M-${c}`), // M-a .. M-z (Option/Meta)
|
|
37
45
|
]);
|