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/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 (where
8
- // process.execPath is the renamed exe rather than node/node.exe).
9
- export const IS_PACKAGED = typeof process.pkg !== 'undefined'
10
- || !/[\\/]node(\.exe)?$/i.test(process.execPath || '');
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
 
@@ -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
- summary.costByModel[mkey] = (summary.costByModel[mkey] || 0) + turnCost;
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
- return Number.isFinite(n) && n > 0 ? n : 90;
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
- // (approximation: today's value minus same-day-of-week last range)
471
- setDelta($('range-delta'), 0, 'range');
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);
@@ -43,7 +43,7 @@ function isLocalHost(host) {
43
43
  return h === 'localhost' || h === '127.0.0.1' || h === '::1';
44
44
  }
45
45
 
46
- const server = createServer((req, res) => {
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 PostCompact. While non-null the daemon
25
- // renders the `compacting` frame so the card never reads "thinking"
26
- // during a context squeeze (which is mechanically distinct from reasoning).
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
- return `${heat(value / max) || C.magenta}${''.repeat(filled)}${C.reset}` + ' '.repeat(w - filled);
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.
package/src/version.js CHANGED
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { ROOT } from './paths.js';
13
13
 
14
- const BAKED = '0.16.2';
14
+ const BAKED = '0.17.0';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {