claude-rpc 0.16.2 → 0.17.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/SECURITY.md +86 -16
- package/package.json +1 -1
- package/src/cli.js +92 -35
- package/src/community.js +7 -5
- package/src/daemon.js +57 -83
- package/src/doctor.js +7 -2
- package/src/format.js +47 -27
- package/src/gist.js +24 -3
- package/src/git.js +10 -1
- package/src/hook.js +36 -26
- package/src/install.js +49 -3
- package/src/mcp.js +10 -4
- package/src/notify.js +17 -0
- package/src/paths.js +15 -4
- package/src/presence.js +75 -0
- package/src/scanner.js +20 -2
- package/src/server/api.js +15 -1
- package/src/server/assets/dashboard.client.js +8 -3
- package/src/server/index.js +19 -1
- package/src/state.js +22 -4
- package/src/tui.js +3 -8
- package/src/version.js +1 -1
package/src/notify.js
CHANGED
|
@@ -6,6 +6,22 @@
|
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
7
|
import { platform } from 'node:os';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Strip shell/PowerShell metacharacters from a label before it's interpolated
|
|
11
|
+
* into a notifier command or webhook body. The win32 path below puts text
|
|
12
|
+
* inside a double-quoted PowerShell string, where `$(...)` and backtick are
|
|
13
|
+
* evaluated and JSON.stringify escapes neither — so an unsanitized project
|
|
14
|
+
* name (a directory literally named `x$(calc.exe)`) could execute. Allow only
|
|
15
|
+
* unicode letters/numbers + space/._- : plenty for a readable label, and it
|
|
16
|
+
* neutralizes the PowerShell, osascript, and webhook sinks alike. Callers
|
|
17
|
+
* passing any cwd-derived text MUST run it through this first.
|
|
18
|
+
* @param {string} s
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function sanitizeLabel(s) {
|
|
22
|
+
return String(s || '').replace(/[^\p{L}\p{N} ._-]/gu, '');
|
|
23
|
+
}
|
|
24
|
+
|
|
9
25
|
/**
|
|
10
26
|
* Best-effort native desktop notification (osascript / PowerShell / notify-send).
|
|
11
27
|
* Never throws — a missing notifier binary is swallowed.
|
|
@@ -57,6 +73,7 @@ export function postWebhook(url, payload) {
|
|
|
57
73
|
method: 'POST',
|
|
58
74
|
headers: { 'content-type': 'application/json' },
|
|
59
75
|
body: JSON.stringify(payload),
|
|
76
|
+
signal: AbortSignal.timeout(4000), // fire-and-forget; don't let a hung webhook leak a socket
|
|
60
77
|
}).catch(() => {});
|
|
61
78
|
} catch {
|
|
62
79
|
/* best-effort */
|
package/src/paths.js
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
import { homedir, tmpdir } from 'node:os';
|
|
2
2
|
import { join, dirname, resolve } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
4
5
|
|
|
5
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
7
|
|
|
7
|
-
// Detect packaged mode. Covers both pkg (process.pkg) and Node SEA
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
// Detect packaged mode. Covers both pkg (process.pkg) and Node SEA. We ask
|
|
9
|
+
// node:sea directly (the same guarded require server/assets.js uses) rather
|
|
10
|
+
// than sniffing process.execPath: the old "execPath doesn't end in /node"
|
|
11
|
+
// heuristic wrongly flagged a renamed runtime — Debian's `nodejs`, a `node24`
|
|
12
|
+
// build, any non-canonical binary — as a packaged SEA, which cascaded into a
|
|
13
|
+
// wrong CONFIG_PATH, a dead HOOK_SCRIPT, and IS_NPM_INSTALL flipping false on
|
|
14
|
+
// the primary install path. isSea() is true ONLY inside a real SEA binary.
|
|
15
|
+
function detectSea() {
|
|
16
|
+
try {
|
|
17
|
+
const sea = createRequire(import.meta.url)('node:sea');
|
|
18
|
+
return typeof sea.isSea === 'function' && sea.isSea();
|
|
19
|
+
} catch { return false; } // node:sea absent (Node < 20.12) → not a SEA
|
|
20
|
+
}
|
|
21
|
+
export const IS_PACKAGED = typeof process.pkg !== 'undefined' || detectSea();
|
|
11
22
|
|
|
12
23
|
export const ROOT = resolve(__dirname, '..');
|
|
13
24
|
|
package/src/presence.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Pure presence-render helpers extracted from daemon.js so the most
|
|
2
|
+
// regression-prone bits of the Discord payload — frame selection, rotation
|
|
3
|
+
// cursoring, and large-image precedence — are unit-testable. daemon.js can't be
|
|
4
|
+
// imported under test (it connects IPC and writes a PID file at module load),
|
|
5
|
+
// so this is where that logic lives and gets covered.
|
|
6
|
+
|
|
7
|
+
// A rotation cursor. The daemon owns one instance; selectFrame reads + advances
|
|
8
|
+
// it across ticks. Kept as plain mutable state (not a closure) so it can be
|
|
9
|
+
// constructed fresh in a test.
|
|
10
|
+
export function makeRotationCursor() {
|
|
11
|
+
return { index: 0, lastAt: 0, status: null };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Choose the active frame set for a status from the new `presence.byStatus`
|
|
15
|
+
// block when present. A byStatus entry is { details, state, largeImageText,
|
|
16
|
+
// rotation? }: with a rotation the base { details, state } renders first and the
|
|
17
|
+
// rotation array cycles after it; otherwise it's a single fixed frame. Falls
|
|
18
|
+
// back to a legacy top-level p.rotation, then a single { details, state } frame.
|
|
19
|
+
// Returns { frames, largeImageTextTpl }.
|
|
20
|
+
export function pickFrames(p, status) {
|
|
21
|
+
const sb = p.byStatus?.[status];
|
|
22
|
+
if (sb) {
|
|
23
|
+
const base = { details: sb.details, state: sb.state, largeImageText: sb.largeImageText };
|
|
24
|
+
const frames = Array.isArray(sb.rotation) && sb.rotation.length ? [base, ...sb.rotation] : [base];
|
|
25
|
+
return { frames, largeImageTextTpl: sb.largeImageText || null };
|
|
26
|
+
}
|
|
27
|
+
if (Array.isArray(p.rotation) && p.rotation.length) return { frames: p.rotation, largeImageTextTpl: null };
|
|
28
|
+
return { frames: [{ details: p.details, state: p.state }], largeImageTextTpl: null };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Pick the frame to render this tick. Drops frames whose `requires` vars are
|
|
32
|
+
// empty/zero (keeping at least the base frame), resets the cursor on a status
|
|
33
|
+
// change — STARTING on the base frame — and advances at most once per
|
|
34
|
+
// intervalMs. `framePasses` and `now` are injected so this is testable without
|
|
35
|
+
// format.js or a real clock.
|
|
36
|
+
//
|
|
37
|
+
// #5 fix: the prior code reset lastAt to 0 on a status change, so on the very
|
|
38
|
+
// next tick `now - 0 >= intervalMs` was always true and the cursor advanced
|
|
39
|
+
// straight past the base frame — entering idle landed the user mid-rotation on a
|
|
40
|
+
// lifetime-stats frame instead of "Idle in <project>". Seeding lastAt to `now`
|
|
41
|
+
// keeps the first tick on index 0.
|
|
42
|
+
export function selectFrame(rawFrames, vars, status, cursor, intervalMs, framePasses, now) {
|
|
43
|
+
if (status !== cursor.status) {
|
|
44
|
+
cursor.index = 0;
|
|
45
|
+
cursor.lastAt = now;
|
|
46
|
+
cursor.status = status;
|
|
47
|
+
}
|
|
48
|
+
const passing = (rawFrames || []).filter((f) => framePasses(f, vars));
|
|
49
|
+
const frames = passing.length ? passing : (rawFrames || []).slice(0, 1);
|
|
50
|
+
if (!frames.length) return {};
|
|
51
|
+
if (frames.length > 1 && now - cursor.lastAt >= intervalMs) {
|
|
52
|
+
cursor.index = (cursor.index + 1) % frames.length;
|
|
53
|
+
cursor.lastAt = now;
|
|
54
|
+
}
|
|
55
|
+
return frames[cursor.index % frames.length] || {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Large-image key precedence (returns the TEMPLATE string; the caller fills it):
|
|
59
|
+
// 1. statusAssets[status] per-status art ("working" gif, etc.)
|
|
60
|
+
// 2. modelAssets[fable|opus|sonnet|haiku|default] per-model art, never stale
|
|
61
|
+
// 3. p.largeImageKey global fallback
|
|
62
|
+
export function resolveLargeImageKey(config, p, status, model) {
|
|
63
|
+
if (config.statusAssets && config.statusAssets[status]) return config.statusAssets[status];
|
|
64
|
+
if (config.modelAssets && model && status !== 'stale') {
|
|
65
|
+
const m = String(model).toLowerCase();
|
|
66
|
+
let pick = null;
|
|
67
|
+
if (m.includes('fable')) pick = config.modelAssets.fable;
|
|
68
|
+
else if (m.includes('opus')) pick = config.modelAssets.opus;
|
|
69
|
+
else if (m.includes('sonnet')) pick = config.modelAssets.sonnet;
|
|
70
|
+
else if (m.includes('haiku')) pick = config.modelAssets.haiku;
|
|
71
|
+
if (!pick) pick = config.modelAssets.default;
|
|
72
|
+
if (pick) return pick;
|
|
73
|
+
}
|
|
74
|
+
return p.largeImageKey || null;
|
|
75
|
+
}
|
package/src/scanner.js
CHANGED
|
@@ -62,7 +62,7 @@ function dayKeyNum(key) {
|
|
|
62
62
|
return dayNum(y, m - 1, d);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
const EDITING_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
|
|
65
|
+
const EDITING_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
|
|
66
66
|
|
|
67
67
|
// First non-env token of a shell command. `FOO=bar git status` → `git`.
|
|
68
68
|
// Strips `sudo`, `time`, and tee-style decorators that aren't the "real" command.
|
|
@@ -316,7 +316,11 @@ function parseChunkInto(text, summary, pstate) {
|
|
|
316
316
|
const turnCost = costFor({ model: turnModel, usage: u });
|
|
317
317
|
if (turnCost > 0) {
|
|
318
318
|
summary.cost += turnCost;
|
|
319
|
-
|
|
319
|
+
// When the turn has no model id, mkey is null but costFor charged it
|
|
320
|
+
// at sonnet rates (pricing's default) — bucket it under 'sonnet', not
|
|
321
|
+
// a literal "null" key that renders as a "null" bar on the dashboard.
|
|
322
|
+
const ck = mkey || 'sonnet';
|
|
323
|
+
summary.costByModel[ck] = (summary.costByModel[ck] || 0) + turnCost;
|
|
320
324
|
if (mb) mb.cost += turnCost;
|
|
321
325
|
for (const bucket of allBuckets) bucket.cost += turnCost;
|
|
322
326
|
}
|
|
@@ -356,6 +360,20 @@ function parseChunkInto(text, summary, pstate) {
|
|
|
356
360
|
bucket.linesAdded += adds;
|
|
357
361
|
bucket.linesRemoved += rems;
|
|
358
362
|
}
|
|
363
|
+
} else if (b.name === 'MultiEdit') {
|
|
364
|
+
// One MultiEdit carries N independent edits; sum their churn the
|
|
365
|
+
// same way Edit does. (File-edit count above already credited it
|
|
366
|
+
// once, the right granularity for hotspots.)
|
|
367
|
+
for (const e of (Array.isArray(input.edits) ? input.edits : [])) {
|
|
368
|
+
const adds = countLines(e.new_string);
|
|
369
|
+
const rems = countLines(e.old_string);
|
|
370
|
+
summary.linesAdded += adds;
|
|
371
|
+
summary.linesRemoved += rems;
|
|
372
|
+
for (const bucket of allBuckets) {
|
|
373
|
+
bucket.linesAdded += adds;
|
|
374
|
+
bucket.linesRemoved += rems;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
359
377
|
} else if (b.name === 'Write') {
|
|
360
378
|
const adds = countLines(input.content);
|
|
361
379
|
summary.linesAdded += adds;
|
package/src/server/api.js
CHANGED
|
@@ -20,7 +20,10 @@ export function rangeToDays(range) {
|
|
|
20
20
|
if (range === 'all') return Infinity;
|
|
21
21
|
if (range === '1y') return 365;
|
|
22
22
|
const n = parseInt(range, 10);
|
|
23
|
-
|
|
23
|
+
// Clamp to a year-and-change. windowedAggregate loops once per day, so an
|
|
24
|
+
// unclamped `?range=99999999` would spin ~100M iterations and wedge the
|
|
25
|
+
// single-threaded serve process — reachable from any localhost page.
|
|
26
|
+
return Number.isFinite(n) && n > 0 ? Math.min(n, 366) : 90;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
// Filter byDay to a windowed slice; also recompute roll-ups (top files etc.)
|
|
@@ -56,10 +59,21 @@ export function windowedAggregate(agg, range) {
|
|
|
56
59
|
cacheWriteTokens += day.cacheWriteTokens || 0;
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
// Prior identical window (the `days` days immediately before this one) so the
|
|
63
|
+
// range card can show a "vs prior" delta like the today card does. Finite
|
|
64
|
+
// windows only — the 'all' branch returned early above. Bounded (days<=366).
|
|
65
|
+
let priorActiveMs = 0;
|
|
66
|
+
for (let i = days; i < days * 2; i++) {
|
|
67
|
+
const d = new Date(today); d.setDate(d.getDate() - i);
|
|
68
|
+
const day = (agg.byDay || {})[dayKey(d.getTime())];
|
|
69
|
+
if (day) priorActiveMs += day.activeMs || 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
59
72
|
return {
|
|
60
73
|
range,
|
|
61
74
|
byDay,
|
|
62
75
|
activeMs,
|
|
76
|
+
priorActiveMs,
|
|
63
77
|
userMessages: prompts,
|
|
64
78
|
toolCalls,
|
|
65
79
|
linesAdded: lines,
|
|
@@ -466,9 +466,14 @@
|
|
|
466
466
|
$('range-unit').textContent = ru === 'h' ? 'hrs' : ru;
|
|
467
467
|
$('range-sub').textContent = fmtN(aggData.userMessages || 0) + ' prompts · ' + fmtN(aggData.grandTokens || 0) + ' tok';
|
|
468
468
|
|
|
469
|
-
// Range delta vs prior identical window
|
|
470
|
-
//
|
|
471
|
-
|
|
469
|
+
// Range delta vs the prior identical window (server-computed priorActiveMs).
|
|
470
|
+
// Neutral when there's no prior data (fresh install / the 'all' range), so
|
|
471
|
+
// we don't show a misleading full-height arrow against an empty baseline.
|
|
472
|
+
if (aggData.priorActiveMs > 0) {
|
|
473
|
+
setDelta($('range-delta'), (aggData.activeMs || 0) - aggData.priorActiveMs, 'vs prior');
|
|
474
|
+
} else {
|
|
475
|
+
setDelta($('range-delta'), 0, '');
|
|
476
|
+
}
|
|
472
477
|
|
|
473
478
|
// Cost card
|
|
474
479
|
$('cost-num').textContent = fmtCost(aggData.estimatedCost || 0);
|
package/src/server/index.js
CHANGED
|
@@ -43,7 +43,7 @@ function isLocalHost(host) {
|
|
|
43
43
|
return h === 'localhost' || h === '127.0.0.1' || h === '::1';
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
function dispatch(req, res) {
|
|
47
47
|
if (!isLocalHost(req.headers.host)) {
|
|
48
48
|
res.writeHead(403, JSON_HEADERS).end(JSON.stringify({ error: 'forbidden' }));
|
|
49
49
|
return;
|
|
@@ -105,6 +105,18 @@ const server = createServer((req, res) => {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
res.writeHead(404).end('not found');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const server = createServer((req, res) => {
|
|
111
|
+
try {
|
|
112
|
+
dispatch(req, res);
|
|
113
|
+
} catch {
|
|
114
|
+
// A malformed aggregate or any thrown handler must not take down the whole
|
|
115
|
+
// in-process dashboard (it runs inside `claude-rpc serve`). Respond 500 if
|
|
116
|
+
// the socket is still writable, else just close it.
|
|
117
|
+
if (!res.headersSent) res.writeHead(500, JSON_HEADERS).end(JSON.stringify({ error: 'internal error' }));
|
|
118
|
+
else try { res.end(); } catch { /* socket already torn down */ }
|
|
119
|
+
}
|
|
108
120
|
});
|
|
109
121
|
|
|
110
122
|
watchSources();
|
|
@@ -127,3 +139,9 @@ server.listen(PORT, '127.0.0.1', () => {
|
|
|
127
139
|
|
|
128
140
|
process.on('SIGINT', () => process.exit(0));
|
|
129
141
|
process.on('SIGTERM', () => process.exit(0));
|
|
142
|
+
|
|
143
|
+
// Last-resort floor for the async edges the per-request try/catch can't reach
|
|
144
|
+
// (SSE writes, timer callbacks). Keeping the localhost dashboard alive beats
|
|
145
|
+
// crashing it; the per-request guard above handles the common sync case.
|
|
146
|
+
process.on('uncaughtException', (e) => { try { console.error('dashboard error:', e?.message || e); } catch { /* ignore */ } });
|
|
147
|
+
process.on('unhandledRejection', (e) => { try { console.error('dashboard rejection:', e?.message || e); } catch { /* ignore */ } });
|
package/src/state.js
CHANGED
|
@@ -9,8 +9,9 @@ import {
|
|
|
9
9
|
unlinkSync,
|
|
10
10
|
statSync,
|
|
11
11
|
fstatSync,
|
|
12
|
+
readdirSync,
|
|
12
13
|
} from 'node:fs';
|
|
13
|
-
import { basename } from 'node:path';
|
|
14
|
+
import { basename, join } from 'node:path';
|
|
14
15
|
import { STATE_PATH, STATE_DIR } from './paths.js';
|
|
15
16
|
|
|
16
17
|
const DEFAULT_STATE = {
|
|
@@ -21,9 +22,10 @@ const DEFAULT_STATE = {
|
|
|
21
22
|
status: 'idle',
|
|
22
23
|
currentTool: null,
|
|
23
24
|
currentFile: null,
|
|
24
|
-
// Set by PreCompact, cleared by
|
|
25
|
-
//
|
|
26
|
-
//
|
|
25
|
+
// Set by PreCompact, cleared by the next SessionStart (post-compaction
|
|
26
|
+
// arrives as SessionStart with source:'compact', whose reset clears it).
|
|
27
|
+
// While non-null the daemon renders the `compacting` frame so the card never
|
|
28
|
+
// reads "thinking" during a context squeeze (distinct from reasoning).
|
|
27
29
|
compactStartedAt: null,
|
|
28
30
|
compactTrigger: null,
|
|
29
31
|
// Set by PreToolUse, cleared by PostToolUse. format.js derives {toolElapsed}
|
|
@@ -166,6 +168,22 @@ export function writeState(next) {
|
|
|
166
168
|
renameSync(tmp, STATE_PATH);
|
|
167
169
|
}
|
|
168
170
|
|
|
171
|
+
// Best-effort sweep of orphaned per-pid tmp files (`state.json.<pid>.tmp`) left
|
|
172
|
+
// behind when a writer was SIGKILLed between writeFileSync and renameSync —
|
|
173
|
+
// they never self-clean otherwise. Call once on daemon startup, NOT in
|
|
174
|
+
// ensureDir (the hot write path). 60s grace so we never race a live writer.
|
|
175
|
+
export function sweepStaleStateTmp(now = Date.now()) {
|
|
176
|
+
try {
|
|
177
|
+
const re = new RegExp(`^${basename(STATE_PATH).replace(/\./g, '\\.')}\\.\\d+\\.tmp$`);
|
|
178
|
+
for (const name of readdirSync(STATE_DIR)) {
|
|
179
|
+
if (!re.test(name)) continue;
|
|
180
|
+
const full = join(STATE_DIR, name);
|
|
181
|
+
try { if (now - statSync(full).mtimeMs > 60_000) unlinkSync(full); }
|
|
182
|
+
catch { /* vanished or locked — best-effort */ }
|
|
183
|
+
}
|
|
184
|
+
} catch { /* STATE_DIR missing / unreadable — nothing to sweep */ }
|
|
185
|
+
}
|
|
186
|
+
|
|
169
187
|
export function updateState(mutator) {
|
|
170
188
|
return withLock(() => {
|
|
171
189
|
const current = readState();
|
package/src/tui.js
CHANGED
|
@@ -12,7 +12,6 @@ import { buildVars, applyIdle, humanProject } from './format.js';
|
|
|
12
12
|
import { loadConfig } from './config.js';
|
|
13
13
|
import { PID_PATH } from './paths.js';
|
|
14
14
|
import { fmtCost } from './pricing.js';
|
|
15
|
-
import { generateInsights } from './insights.js';
|
|
16
15
|
import { heat } from './ui.js';
|
|
17
16
|
|
|
18
17
|
// ── ANSI ────────────────────────────────────────────────────────────────────
|
|
@@ -79,13 +78,9 @@ function rule(w) { return C.gray + '─'.repeat(w - 4) + C.reset; }
|
|
|
79
78
|
function bar(value, max, w = 16) {
|
|
80
79
|
if (!max || max <= 0) return ''.padEnd(w);
|
|
81
80
|
const filled = Math.max(0, Math.min(w, Math.round((value / max) * w)));
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
// Pad a (possibly ANSI-colored) line with spaces so its VISIBLE width hits n.
|
|
86
|
-
function padR(s, n) {
|
|
87
|
-
const len = visLen(s);
|
|
88
|
-
return len >= n ? s : s + ' '.repeat(n - len);
|
|
81
|
+
// heat() is NO_COLOR-aware (returns '' when color is off); the old
|
|
82
|
+
// `|| C.magenta` fallback wasn't, so it injected a raw escape under NO_COLOR.
|
|
83
|
+
return `${heat(value / max)}${'█'.repeat(filled)}${C.reset}` + ' '.repeat(w - filled);
|
|
89
84
|
}
|
|
90
85
|
|
|
91
86
|
// Wrap each content line with a 2-space left margin.
|