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/daemon.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { writeFileSync, existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
2
+ import { writeFileSync, readFileSync, existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
3
3
  import { basename, dirname } from 'node:path';
4
4
  import { Client } from './discord-ipc.js';
5
- import { readState } from './state.js';
5
+ import { readState, sweepStaleStateTmp } from './state.js';
6
+ import { makeRotationCursor, pickFrames, selectFrame, resolveLargeImageKey } from './presence.js';
6
7
  import { buildVars, fillTemplate, framePasses, applyIdle, applyShipped, applyTrigger } from './format.js';
7
8
  import { scan, readAggregate, findLiveSessions, readSessionTokens } from './scanner.js';
8
9
  import { detectGithubUrl } from './git.js';
@@ -10,7 +11,7 @@ import { applyPrivacy } from './privacy.js';
10
11
  import { pauseUntil } from './pause.js';
11
12
  import { loadConfig } from './config.js';
12
13
  import { migrateConfig } from './install.js';
13
- import { desktopNotify, postWebhook, shouldWebhook, shouldNotify } from './notify.js';
14
+ import { desktopNotify, postWebhook, shouldWebhook, shouldNotify, sanitizeLabel } from './notify.js';
14
15
  import { humanProject } from './format.js';
15
16
  import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH, PAUSE_PATH } from './paths.js';
16
17
  import { readUsageCache, pollUsage } from './usage.js';
@@ -90,54 +91,42 @@ let reconnectTimer = null;
90
91
  const RECONNECT_BASE_MS = 5_000;
91
92
  const RECONNECT_CAP_MS = 300_000;
92
93
  let reconnectDelayMs = RECONNECT_BASE_MS;
93
- let rotationIndex = 0;
94
- let lastRotationAt = 0;
94
+ // Rotation cursor (index + lastAt + status). selectFrame in presence.js resets
95
+ // it on a status transition — otherwise the cursor carries over from idle's
96
+ // 12-frame rotation into a single-frame working state and back, producing a
97
+ // jarring "blank tick" until modulo aligns.
98
+ const rotationCursor = makeRotationCursor();
95
99
  // Stabilizes Discord's elapsed timer: applyIdle can synthesize a sessionStart
96
100
  // from a moving transcript mtime, and missing-hook scenarios leave it null —
97
101
  // either case would make startTimestamp jump on every rotation.
98
102
  let effectiveSessionStart = null;
99
- // Track which status the rotation cursor belongs to so we can reset it cleanly
100
- // on a status transition — otherwise the cursor carries over from idle's
101
- // 7-frame rotation into a single-frame working state and back, producing a
102
- // jarring "blank tick" until modulo aligns.
103
- let rotationStatus = null;
104
103
 
104
+ // Single-instance guard. The CLI checks before spawning, but off-path launches
105
+ // (a login-startup entry racing a manual start; a packaged exe beside a dev
106
+ // run) could start a second daemon that fights over setActivity every ~4s and
107
+ // double-counts the additive community total. If a live daemon already owns the
108
+ // PID file, step aside; a stale PID (owner gone) means take over.
109
+ try {
110
+ if (existsSync(PID_PATH)) {
111
+ const existing = parseInt(readFileSync(PID_PATH, 'utf8'), 10);
112
+ if (existing && existing !== process.pid) {
113
+ let alive = false;
114
+ try { process.kill(existing, 0); alive = true; } catch { /* stale PID — take over */ }
115
+ if (alive) {
116
+ log(`Another daemon (pid ${existing}) is already running — exiting.`);
117
+ process.exit(0);
118
+ }
119
+ }
120
+ }
121
+ } catch { /* unreadable PID file — fall through and claim it */ }
105
122
  writeFileSync(PID_PATH, String(process.pid));
106
123
 
107
- function maybeAdvanceRotation(rotation, intervalMs) {
108
- if (!Array.isArray(rotation) || rotation.length === 0) return undefined;
109
- if (rotation.length === 1) return rotation[0];
110
- const now = Date.now();
111
- if (now - lastRotationAt >= intervalMs) {
112
- rotationIndex = (rotationIndex + 1) % rotation.length;
113
- lastRotationAt = now;
114
- }
115
- return rotation[rotationIndex % rotation.length];
116
- }
124
+ // Reclaim any per-pid state tmp files orphaned by a hard-killed writer.
125
+ sweepStaleStateTmp();
117
126
 
118
- // Choose frames + a status-level largeImageText override based on the new
119
- // `presence.byStatus` block when present. Falls back to legacy `p.rotation`
120
- // (which still works for any existing user config that doesn't use byStatus).
121
- //
122
- // Returns { frames, largeImageTextTpl }. A byStatus entry can be:
123
- // { details, state, largeImageText, rotation? }
124
- // If `rotation` is present, the base { details, state } is rendered first
125
- // and the rotation array cycles after it. Otherwise the entry is a single
126
- // fixed frame.
127
- function pickFrames(p, status) {
128
- const sb = p.byStatus?.[status];
129
- if (sb) {
130
- const base = { details: sb.details, state: sb.state, largeImageText: sb.largeImageText };
131
- const frames = Array.isArray(sb.rotation) && sb.rotation.length
132
- ? [base, ...sb.rotation]
133
- : [base];
134
- return { frames, largeImageTextTpl: sb.largeImageText || null };
135
- }
136
- if (Array.isArray(p.rotation) && p.rotation.length) {
137
- return { frames: p.rotation, largeImageTextTpl: null };
138
- }
139
- return { frames: [{ details: p.details, state: p.state }], largeImageTextTpl: null };
140
- }
127
+ // pickFrames / selectFrame / resolveLargeImageKey now live in presence.js (pure
128
+ // + unit-tested). The rotation cursor (rotationCursor) is owned here and passed
129
+ // into selectFrame.
141
130
 
142
131
  // Resolve the raw state file into the final presence state: idle/stale, shipped
143
132
  // and trigger overlays, live-session token enrichment, and the privacy verdict.
@@ -202,18 +191,10 @@ function buildActivity(opts = {}) {
202
191
  } else {
203
192
  ({ frames: rawFrames, largeImageTextTpl: statusLIT } = pickFrames(p, state.status));
204
193
  }
205
- if (state.status !== rotationStatus) {
206
- rotationIndex = 0;
207
- lastRotationAt = 0;
208
- rotationStatus = state.status;
209
- }
210
-
211
- // Drop frames whose `requires` vars are empty/zero. Keeps presence tight.
212
- const frames = rawFrames.filter((f) => framePasses(f, vars));
213
- const safeFrames = frames.length ? frames : rawFrames.slice(0, 1);
214
-
194
+ // Select the frame for this tick: filter by `requires`, reset the cursor on a
195
+ // status change (starting on the base frame), advance once per intervalMs.
215
196
  const intervalMs = Math.max(5000, config.rotationIntervalMs || 12000);
216
- const frame = maybeAdvanceRotation(safeFrames, intervalMs) || {};
197
+ const frame = selectFrame(rawFrames, vars, state.status, rotationCursor, intervalMs, framePasses, Date.now());
217
198
 
218
199
  const activity = {};
219
200
  // Forcing `name` overrides whatever Discord has cached for the app's
@@ -223,28 +204,10 @@ function buildActivity(opts = {}) {
223
204
  if (frame.details) activity.details = fillTemplate(frame.details, vars).slice(0, 128);
224
205
  if (frame.state) activity.state = fillTemplate(frame.state, vars).slice(0, 128);
225
206
 
226
- // ── Large-image precedence (single source of truth) ────────────────
227
- // 1. statusAssets[status] "working" gif when working, etc.
228
- // 2. modelAssets[opus|sonnet|haiku|default]
229
- // per-model art (Opus/Sonnet/Haiku),
230
- // only consulted when statusAssets
231
- // doesn't match AND state isn't stale.
232
- // 3. presence.largeImageKey global fallback.
233
- // smallImageKey separately resolves to the `{statusIcon}` template var
234
- // (set via config.statusIcons) and is dropped entirely when empty.
235
- let largeKeyTpl = p.largeImageKey;
236
- if (config.statusAssets && config.statusAssets[state.status]) {
237
- largeKeyTpl = config.statusAssets[state.status];
238
- } else if (config.modelAssets && state.model && state.status !== 'stale') {
239
- const m = String(state.model).toLowerCase();
240
- let pick = null;
241
- if (m.includes('fable')) pick = config.modelAssets.fable;
242
- else if (m.includes('opus')) pick = config.modelAssets.opus;
243
- else if (m.includes('sonnet')) pick = config.modelAssets.sonnet;
244
- else if (m.includes('haiku')) pick = config.modelAssets.haiku;
245
- if (!pick) pick = config.modelAssets.default;
246
- if (pick) largeKeyTpl = pick;
247
- }
207
+ // Large-image precedence (statusAssets[status] > modelAssets[tier] > global)
208
+ // lives in presence.js/resolveLargeImageKey. smallImageKey separately resolves
209
+ // to the `{statusIcon}` template var (config.statusIcons), dropped when empty.
210
+ const largeKeyTpl = resolveLargeImageKey(config, p, state.status, state.model);
248
211
  if (largeKeyTpl) activity.largeImageKey = fillTemplate(largeKeyTpl, vars);
249
212
  // largeImageText precedence: per-frame override > byStatus entry > global default.
250
213
  const largeTextTpl = frame.largeImageText || statusLIT || p.largeImageText;
@@ -304,13 +267,20 @@ function buildActivity(opts = {}) {
304
267
  }
305
268
 
306
269
  // Fire desktop-notification + webhook on a status transition (once per change).
307
- function fireStatusSideEffects(resolved) {
270
+ // When `suppressed` (paused / privacy=hidden), we still advance the transition
271
+ // cursor — so resume doesn't replay a stale notification — but stay silent, or
272
+ // the webhook/toast would leak the project name and defeat the snooze.
273
+ function fireStatusSideEffects(resolved, suppressed = false) {
308
274
  const status = resolved.status;
309
275
  if (status === lastNotifiedStatus) return;
310
276
  const prev = lastNotifiedStatus;
311
277
  lastNotifiedStatus = status;
278
+ if (suppressed) return;
312
279
  try {
313
- const project = humanProject(resolved.cwd) || 'Claude Code';
280
+ // Sanitize the cwd-derived project name before it reaches a notifier or
281
+ // webhook — see sanitizeLabel (closes a win32 PowerShell injection via a
282
+ // maliciously-named directory).
283
+ const project = sanitizeLabel(humanProject(resolved.cwd)) || 'Claude Code';
314
284
  if (shouldNotify(config.notify, prev, status)) {
315
285
  desktopNotify('Claude Code needs you', `Waiting on you in ${project}`);
316
286
  log(`desktop notification raised (status=${status})`);
@@ -338,17 +308,21 @@ async function pushPresence() {
338
308
  // for the decision and the frame to disagree.
339
309
  const resolved = resolvePresence();
340
310
 
341
- // Outbound side-effects on a status TRANSITION (fire once per change):
342
- // a desktop notification when Claude needs you, and an opt-in webhook POST.
343
- fireStatusSideEffects(resolved);
344
-
345
311
  const hideWhenStale = config.hideWhenStale !== false;
346
312
  const privacyHidden = resolved._privacy?.visibility === 'hidden';
347
313
  // Global snooze (`claude-rpc pause`) — clears the card while the deadline
348
314
  // is in the future. Re-checked every tick, so expiry resumes presence
349
315
  // automatically (the 'cleared' stamp differs from the next frame's hash).
350
316
  const pausedUntil = pauseUntil();
351
- if ((resolved.status === 'stale' && hideWhenStale) || privacyHidden || pausedUntil) {
317
+ const suppressed = privacyHidden || !!pausedUntil || (resolved.status === 'stale' && hideWhenStale);
318
+
319
+ // Outbound side-effects on a status TRANSITION (fire once per change): a
320
+ // desktop notification when Claude needs you, and an opt-in webhook POST.
321
+ // Suppressed while paused / privacy=hidden — those snooze the card, and a
322
+ // toast or webhook leaking the project name would defeat that.
323
+ fireStatusSideEffects(resolved, suppressed);
324
+
325
+ if (suppressed) {
352
326
  const stamp = 'cleared';
353
327
  if (lastPayloadHash === stamp) return;
354
328
  lastPayloadHash = stamp;
package/src/doctor.js CHANGED
@@ -366,8 +366,13 @@ function checkUsage(cfg) {
366
366
  }
367
367
  const u = readUsageCache();
368
368
  if (u) {
369
- check('usage polling', 'pass',
370
- `week ${u.weeklyPct}% · session ${u.sessionPct}% · fetched ${Math.max(0, Math.round((Date.now() - u.fetchedAt) / 60_000))} min ago`);
369
+ // Either bucket can be null (they come and go between Claude Code
370
+ // releases); interpolating both unconditionally printed a literal `null%`.
371
+ const parts = [];
372
+ if (u.weeklyPct != null) parts.push(`week ${u.weeklyPct}%`);
373
+ if (u.sessionPct != null) parts.push(`session ${u.sessionPct}%`);
374
+ parts.push(`fetched ${Math.max(0, Math.round((Date.now() - u.fetchedAt) / 60_000))} min ago`);
375
+ check('usage polling', 'pass', parts.join(' · '));
371
376
  } else {
372
377
  check('usage polling', 'warn', 'no fresh usage data yet',
373
378
  'the daemon polls every 10 min while a session is live — or run `claude-rpc usage` for a live fetch');
package/src/format.js CHANGED
@@ -450,6 +450,7 @@ export function buildVars(state, config, aggregate) {
450
450
  if (usage) {
451
451
  const bits = [];
452
452
  if (usage.sessionPct != null) bits.push(`session ${usage.sessionPct}%`);
453
+ if (usage.weeklyPct != null) bits.push(`weekly ${usage.weeklyPct}%`);
453
454
  const day = fmtResetDay(usage.weeklyResetsAt);
454
455
  if (day) bits.push(`resets ${day}`);
455
456
  usageStateLabel = bits.join(' · ');
@@ -803,6 +804,29 @@ function staleWipe(state) {
803
804
  };
804
805
  }
805
806
 
807
+ // A live transcript is being written by a session whose hooks don't feed THIS
808
+ // daemon (a sibling, or a session that out-lived a SessionEnd). The user IS
809
+ // working — adopt the most-recent live session as our 'working' context, zeroing
810
+ // the old session's hook-derived counters since they belong to the quiet one.
811
+ function borrowLiveSession(state, liveSessions, now) {
812
+ const recent = liveSessions[0] || {};
813
+ return {
814
+ ...state,
815
+ status: 'working',
816
+ cwd: recent.cwd || state.cwd || '',
817
+ sessionStart: recent.mtime || now,
818
+ lastActivity: recent.mtime || now,
819
+ currentTool: null,
820
+ currentFile: null,
821
+ messages: 0,
822
+ tools: 0,
823
+ filesOpened: [],
824
+ filesEdited: [],
825
+ filesRead: [],
826
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
827
+ };
828
+ }
829
+
806
830
  // Apply idle/stale transitions based on lastActivity age. Used by both daemon
807
831
  // and the `preview` CLI command so they agree.
808
832
  //
@@ -826,10 +850,28 @@ export function applyIdle(state, cfg = {}) {
826
850
  // backstop (keeps the card up through short pauses with the terminal open).
827
851
  const idleWhenOpen = cfg.idleWhenOpen === true;
828
852
 
829
- // Authoritative close signal from the SessionEnd hook trust it instead
830
- // of waiting on staleSessionMin. Any other hook clears the flag, so a
831
- // sibling session staying alive will reset us out of this branch.
832
- if (state.claudeClosed) return staleWipe(state);
853
+ // Most-recent disk-level activity across ALL transcripts we can see. Computed
854
+ // before the close check so it can defer to a live sibling.
855
+ const mostRecentLiveMs = liveSessions.length
856
+ ? Math.max(...liveSessions.map((s) => s.mtime || 0))
857
+ : 0;
858
+ const liveAgeMs = mostRecentLiveMs ? now - mostRecentLiveMs : Infinity;
859
+
860
+ // Authoritative close signal from the SessionEnd hook — trust it instead of
861
+ // waiting on staleSessionMin. BUT state.json is global: a SessionEnd from ONE
862
+ // session must not blank the card while a SIBLING is mid-work. A sibling is
863
+ // alive iff a live transcript in a DIFFERENT cwd than the one that just closed
864
+ // (still in state.cwd) is fresh — adopt it. The just-closed session's own
865
+ // transcript is briefly still fresh, so it must NOT keep the card up; anything
866
+ // but a distinct live sibling wipes. (A's next hook also clears the flag.)
867
+ //
868
+ // Known limitation: the inverse — B's SessionStart resetState briefly showing
869
+ // idle over A's work — isn't fully fixed here (the hook can't see live
870
+ // transcripts); it self-heals on A's next hook. See MEMORY/SECURITY notes.
871
+ if (state.claudeClosed) {
872
+ const sibling = liveSessions.find((s) => s.cwd && s.cwd !== state.cwd && now - (s.mtime || 0) <= staleMs);
873
+ return sibling ? borrowLiveSession(state, [sibling], now) : staleWipe(state);
874
+ }
833
875
 
834
876
  // Notification is a brief status — hold it for ~8s after the hook fires,
835
877
  // then fall through to normal idle/stale processing.
@@ -839,12 +881,6 @@ export function applyIdle(state, cfg = {}) {
839
881
  state = { ...state, status: 'idle' };
840
882
  }
841
883
 
842
- // Most-recent disk-level activity across ALL transcripts we can see.
843
- const mostRecentLiveMs = liveSessions.length
844
- ? Math.max(...liveSessions.map((s) => s.mtime || 0))
845
- : 0;
846
- const liveAgeMs = mostRecentLiveMs ? now - mostRecentLiveMs : Infinity;
847
-
848
884
  // Truly dormant: no live transcripts AND local state is old → stale.
849
885
  if (ageMs > staleMs && liveAgeMs > staleMs) return staleWipe(state);
850
886
 
@@ -852,23 +888,7 @@ export function applyIdle(state, cfg = {}) {
852
888
  // Borrow the most-recent live session as our "active" context, since the
853
889
  // user clearly IS working — just not in a session whose hooks feed us.
854
890
  if (ageMs > staleMs && liveAgeMs <= staleMs) {
855
- const recent = liveSessions[0] || {};
856
- return {
857
- ...state,
858
- status: 'working',
859
- cwd: recent.cwd || state.cwd || '',
860
- sessionStart: recent.mtime || now,
861
- lastActivity: recent.mtime || now,
862
- // Hook-derived per-session counters belong to the OLD session — zero them.
863
- currentTool: null,
864
- currentFile: null,
865
- messages: 0,
866
- tools: 0,
867
- filesOpened: [],
868
- filesEdited: [],
869
- filesRead: [],
870
- tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
871
- };
891
+ return borrowLiveSession(state, liveSessions, now);
872
892
  }
873
893
 
874
894
  // Local state is fresh.
package/src/gist.js CHANGED
@@ -17,6 +17,25 @@ import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
17
17
  import { tmpdir } from 'node:os';
18
18
  import { join } from 'node:path';
19
19
 
20
+ // `gh` on Windows is gh.cmd/gh.exe — a bare 'gh' spawn (no shell) won't resolve
21
+ // the extension, so detection and publish silently fell through to the REST
22
+ // path (which then needs GH_TOKEN). Run via the shell on win32 so PATHEXT
23
+ // resolves it. shell:true does NOT auto-quote, and our args include a
24
+ // space-bearing --desc, so quote anything with whitespace/quotes ourselves.
25
+ const WIN = process.platform === 'win32';
26
+ function ghQuote(a) {
27
+ return /[\s"]/.test(a) ? `"${String(a).replace(/"/g, '""')}"` : a;
28
+ }
29
+ function gh(args, opts = {}) {
30
+ return WIN
31
+ ? spawnSync('gh', args.map(ghQuote), { ...opts, shell: true })
32
+ : spawnSync('gh', args, opts);
33
+ }
34
+
35
+ // Bare fetch has no total timeout; a stalled GitHub endpoint would hang the
36
+ // publish forever. 10s is plenty for a gist round-trip.
37
+ const FETCH_TIMEOUT_MS = 10_000;
38
+
20
39
  // Extract { owner, id } from a "https://gist.github.com/<user>/<hash>"
21
40
  // URL. Returns null on no match — callers throw with the raw output so
22
41
  // debugging an unparseable gh response is straightforward.
@@ -43,7 +62,7 @@ export function gistMarkdown({ owner, id, filename, label = 'Claude' }) {
43
62
 
44
63
  export function hasGh() {
45
64
  try {
46
- const r = spawnSync('gh', ['--version'], { stdio: 'ignore' });
65
+ const r = gh(['--version'], { stdio: 'ignore' });
47
66
  return r.status === 0;
48
67
  } catch {
49
68
  // gh missing entirely → spawn throws on some platforms instead of
@@ -55,7 +74,7 @@ export function hasGh() {
55
74
  function ghCreate(filePath, description, isPublic) {
56
75
  const args = ['gist', 'create', filePath, '--desc', description];
57
76
  if (isPublic) args.push('--public');
58
- const r = spawnSync('gh', args, { encoding: 'utf8' });
77
+ const r = gh(args, { encoding: 'utf8' });
59
78
  if (r.status !== 0) {
60
79
  throw new Error(`gh gist create failed: ${(r.stderr || r.stdout || '').trim()}`);
61
80
  }
@@ -69,7 +88,7 @@ function ghCreate(filePath, description, isPublic) {
69
88
  }
70
89
 
71
90
  function ghEdit(gistId, filePath) {
72
- const r = spawnSync('gh', ['gist', 'edit', gistId, filePath], { encoding: 'utf8' });
91
+ const r = gh(['gist', 'edit', gistId, filePath], { encoding: 'utf8' });
73
92
  if (r.status !== 0) {
74
93
  throw new Error(`gh gist edit failed: ${(r.stderr || r.stdout || '').trim()}`);
75
94
  }
@@ -89,6 +108,7 @@ async function restCreate({ svg, filename, description, isPublic, token }) {
89
108
  public: !!isPublic,
90
109
  files: { [filename]: { content: svg } },
91
110
  }),
111
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
92
112
  });
93
113
  if (!res.ok) {
94
114
  const body = await res.text().catch(() => '');
@@ -108,6 +128,7 @@ async function restEdit({ svg, filename, gistId, token }) {
108
128
  'Content-Type': 'application/json',
109
129
  },
110
130
  body: JSON.stringify({ files: { [filename]: { content: svg } } }),
131
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
111
132
  });
112
133
  if (!res.ok) {
113
134
  const body = await res.text().catch(() => '');
package/src/git.js CHANGED
@@ -60,7 +60,16 @@ function readGitInfo(cwd) {
60
60
  // origin URL → github URL + repo name.
61
61
  try {
62
62
  const cfg = readFileSync(join(commonGitDir(gitDir), 'config'), 'utf8');
63
- const m = cfg.match(/\[remote\s+"origin"\][^[]*?url\s*=\s*([^\r\n]+)/i);
63
+ // Isolate the [remote "origin"] section (everything up to the next section
64
+ // header) and read its url= line. The old single regex used `[^[]*?`
65
+ // between the header and url=, so any stray `[` in between — e.g. a
66
+ // bracketed comment above the url — aborted the match and silently dropped
67
+ // GitHub detection.
68
+ const oi = cfg.search(/\[remote\s+"origin"\]/i);
69
+ const rest = oi === -1 ? '' : cfg.slice(oi);
70
+ const nextSec = rest ? rest.slice(1).search(/\n[ \t]*\[/) : -1;
71
+ const section = nextSec === -1 ? rest : rest.slice(0, nextSec + 1);
72
+ const m = section.match(/^[ \t]*url\s*=\s*(.+)$/mi);
64
73
  if (m) {
65
74
  const raw = m[1].trim();
66
75
  const ssh = raw.match(/^git@github\.com:([^\s]+?)(?:\.git)?$/i);
package/src/hook.js CHANGED
@@ -36,6 +36,21 @@ function gitSubcommand(args) {
36
36
  return null;
37
37
  }
38
38
 
39
+ // First two gh positionals (noun, verb), skipping global flags so the canonical
40
+ // targeted form `gh -R owner/repo pr create` still classifies. -R/--repo take a
41
+ // value; other globals here don't precede a create, so skipping the rest is safe.
42
+ function ghSubcommand(args) {
43
+ const pos = [];
44
+ for (let i = 0; i < args.length; i++) {
45
+ const a = args[i];
46
+ if (a === '-R' || a === '--repo') { i++; continue; } // flag that takes a value
47
+ if (a.startsWith('-')) continue;
48
+ pos.push(a.toLowerCase());
49
+ if (pos.length === 2) break;
50
+ }
51
+ return { noun: pos[0], verb: pos[1] };
52
+ }
53
+
39
54
  function shipKindForSegment(seg) {
40
55
  const toks = tokenizeSegment(seg);
41
56
  if (!toks.length) return null;
@@ -44,9 +59,10 @@ function shipKindForSegment(seg) {
44
59
  if (sub === 'push') return 'push';
45
60
  if (sub === 'commit') return 'commit';
46
61
  } else if (toks[0] === 'gh') {
47
- if (toks[1] === 'pr' && toks[2] === 'create') return 'pr';
48
- if (toks[1] === 'issue' && toks[2] === 'create') return 'issue';
49
- if (toks[1] === 'release' && toks[2] === 'create') return 'tag';
62
+ const { noun, verb } = ghSubcommand(toks.slice(1));
63
+ if (noun === 'pr' && verb === 'create') return 'pr';
64
+ if (noun === 'issue' && verb === 'create') return 'issue';
65
+ if (noun === 'release' && verb === 'create') return 'tag';
50
66
  }
51
67
  return null;
52
68
  }
@@ -211,13 +227,10 @@ export function processHookEvent(event, input = {}) {
211
227
  s.justShippedSubject = shipSubject;
212
228
  s.justShippedBranch = shipBranch;
213
229
  }
214
- const usage = input.tool_response?.usage || input.usage;
215
- if (usage) {
216
- s.tokens.input += usage.input_tokens || 0;
217
- s.tokens.output += usage.output_tokens || 0;
218
- s.tokens.cacheRead += usage.cache_read_input_tokens || 0;
219
- s.tokens.cacheWrite += usage.cache_creation_input_tokens || 0;
220
- }
230
+ // NOTE: PostToolUse tool_response carries no model usage, so this
231
+ // never fired (the daemon overrides state.tokens from the transcript —
232
+ // the documented single source of truth). Removed to kill a latent
233
+ // double-count if a future Claude Code version did attach usage here.
221
234
  return s;
222
235
  });
223
236
  break;
@@ -255,20 +268,9 @@ export function processHookEvent(event, input = {}) {
255
268
  appendEvent({ type: 'precompact', ts: now, trigger: input.trigger || input.matcher || null, cwd: input.cwd || null });
256
269
  break;
257
270
  }
258
- case 'PostCompact': {
259
- // Compaction finished clear the marker and drop to idle. The next
260
- // hook (UserPromptSubmit / PreToolUse) will set the real next state.
261
- updateState((s) => {
262
- s.status = 'idle';
263
- s.compactStartedAt = null;
264
- s.compactTrigger = null;
265
- s.lastActivity = now;
266
- s.claudeClosed = false;
267
- return s;
268
- });
269
- appendEvent({ type: 'postcompact', ts: now, cwd: input.cwd || null });
270
- break;
271
- }
271
+ // No PostCompact case: Claude Code has no such event. Post-compaction
272
+ // arrives as SessionStart (source:'compact'), whose resetState clears the
273
+ // compacting marker so the `compacting` state ends without a handler.
272
274
  case 'SessionEnd': {
273
275
  // Authoritative "Claude Code is gone" signal — don't wait on the
274
276
  // staleSessionMin timeout. applyIdle short-circuits to stale when it
@@ -293,9 +295,17 @@ export function processHookEvent(event, input = {}) {
293
295
  }
294
296
 
295
297
  // Stdin-driven CLI form: read JSON event payload from stdin, dispatch, ack.
298
+ // The `{continue:true}` ack is a documented contract (SECURITY.md) — Claude
299
+ // Code reads it on every hook — so a state-write failure (full/unwritable
300
+ // tmpdir) must never stop us from emitting it. Dispatch is best-effort; the
301
+ // ack always goes out.
296
302
  export function runHookCli(event) {
297
- processHookEvent(event, parseInput());
298
- process.stdout.write(JSON.stringify({ continue: true }));
303
+ try {
304
+ processHookEvent(event, parseInput());
305
+ } catch { /* presence is best-effort; never break the user's turn */ }
306
+ finally {
307
+ try { process.stdout.write(JSON.stringify({ continue: true })); } catch { /* stdout closed */ }
308
+ }
299
309
  }
300
310
 
301
311
  // Run directly when invoked as `node src/hook.js <event>`. Detection is based
package/src/install.js CHANGED
@@ -54,7 +54,7 @@ function noop(fact) { noopFacts.push(fact); }
54
54
 
55
55
  const EVENTS = [
56
56
  'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse',
57
- 'Stop', 'SubagentStop', 'Notification', 'SessionEnd',
57
+ 'Stop', 'SubagentStop', 'Notification', 'SessionEnd', 'PreCompact',
58
58
  ];
59
59
 
60
60
  function readJson(p, fb) {
@@ -135,15 +135,29 @@ function regCommand(args) {
135
135
  });
136
136
  }
137
137
 
138
+ const STARTUP_VBS = join(CANONICAL_INSTALL_DIR, 'claude-rpc-daemon.vbs');
139
+
138
140
  export async function addStartupEntry(exePath) {
141
+ // The packaged exe is a console-subsystem node.exe, so a bare Run-key entry
142
+ // (`"<exe>" daemon`) makes Explorer pop a console window at every login that
143
+ // persists for the daemon's whole (weeks-long) life — closing it kills the
144
+ // daemon. Launch through a tiny .vbs shim via wscript (window style 0) so the
145
+ // unattended startup path is windowless, like every other launch path. We
146
+ // avoid schtasks deliberately — SECURITY.md advertises "no scheduled task".
147
+ let runCmd = `"${exePath}" daemon`;
148
+ try {
149
+ mkdirSync(CANONICAL_INSTALL_DIR, { recursive: true });
150
+ writeFileSync(STARTUP_VBS, `CreateObject("WScript.Shell").Run """${exePath}"" daemon", 0, False\r\n`);
151
+ runCmd = `wscript.exe "${STARTUP_VBS}"`;
152
+ } catch { /* couldn't write the shim — fall back to the direct (windowed) entry */ }
139
153
  await regCommand([
140
154
  'add', STARTUP_KEY,
141
155
  '/v', STARTUP_VALUE,
142
156
  '/t', 'REG_SZ',
143
- '/d', `"${exePath}" daemon`,
157
+ '/d', runCmd,
144
158
  '/f',
145
159
  ]);
146
- if (runDirty) step(SYM_OK, 'startup entry', `HKCU\\…\\Run\\${STARTUP_VALUE} — daemon starts at login`);
160
+ if (runDirty) step(SYM_OK, 'startup entry', `HKCU\\…\\Run\\${STARTUP_VALUE} — daemon starts at login (windowless)`);
147
161
  else noop('startup entry present');
148
162
  }
149
163
 
@@ -154,6 +168,7 @@ export async function removeStartupEntry() {
154
168
  } catch {
155
169
  // Already absent — fine.
156
170
  }
171
+ try { unlinkSync(STARTUP_VBS); } catch { /* shim absent — fine */ }
157
172
  }
158
173
 
159
174
  function samePath(a, b) {
@@ -395,6 +410,37 @@ export function migrateConfig({ silent = false } = {}) {
395
410
  if (changed) added.push('presence.buttons[] → CTA');
396
411
  }
397
412
 
413
+ // Frame reconciliation. byStatus.<status>.rotation arrays are seeded once and
414
+ // never reconciled (arrays REPLACE on merge), so default frames shipped in
415
+ // later versions — the v0.16 usage / cost / churn / goal / budget frames —
416
+ // never reach an existing user just by bumping the package. For each status
417
+ // whose rotation is still default-derived (every frame the user has is a
418
+ // current default frame — they haven't added their own), append the default
419
+ // frames they're missing, in default order. A frame's `requires` signature is
420
+ // its stable identity, so a text tweak to an existing frame doesn't block it;
421
+ // anyone who added a custom frame is left entirely alone.
422
+ const frameId = (f) => (Array.isArray(f?.requires) && f.requires.length)
423
+ ? 'r:' + [...f.requires].map(String).sort().join('|')
424
+ : 't:' + (f?.details ?? '') + '' + (f?.state ?? '');
425
+ const dflBy = DEFAULT_CONFIG.presence?.byStatus || {};
426
+ const usrBy = cfg.presence.byStatus || {};
427
+ let framesAdded = 0;
428
+ for (const status of Object.keys(dflBy)) {
429
+ const dRot = dflBy[status]?.rotation;
430
+ const uEntry = usrBy[status];
431
+ const uRot = uEntry?.rotation;
432
+ if (!Array.isArray(dRot) || !dRot.length || !Array.isArray(uRot) || !uRot.length) continue;
433
+ const dIds = new Set(dRot.map(frameId));
434
+ if (!uRot.every((f) => dIds.has(frameId(f)))) continue; // user customized — hands off
435
+ const uIds = new Set(uRot.map(frameId));
436
+ const missing = dRot.filter((f) => !uIds.has(frameId(f)));
437
+ if (missing.length) {
438
+ uEntry.rotation = [...uRot, ...missing.map((f) => JSON.parse(JSON.stringify(f)))];
439
+ framesAdded += missing.length;
440
+ }
441
+ }
442
+ if (framesAdded) added.push(`+${framesAdded} default rotation frame${framesAdded === 1 ? '' : 's'}`);
443
+
398
444
  if (added.length === 0) return false;
399
445
  writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
400
446
  if (!silent) dirtyStep(SYM_OK, 'config migrated', `added: ${added.join(', ')}`);
package/src/mcp.js CHANGED
@@ -8,9 +8,6 @@
8
8
  // without standing up the transport.
9
9
 
10
10
  import { readAggregate, dayKey } from './scanner.js';
11
- import { buildVars } from './format.js';
12
- import { readState } from './state.js';
13
- import { loadConfig } from './config.js';
14
11
  import { VERSION } from './version.js';
15
12
 
16
13
  function fmtH(ms) { const h = (ms || 0) / 3_600_000; return h < 1 ? `${Math.round(h * 60)}m` : `${h.toFixed(1)}h`; }
@@ -99,7 +96,11 @@ export function toolList() {
99
96
  export function callTool(name, getAgg = readAggregate) {
100
97
  const t = TOOLS[name];
101
98
  if (!t) throw new Error(`unknown tool: ${name}`);
102
- const agg = getAgg() || {};
99
+ const agg = getAgg();
100
+ // No aggregate at all (fresh install / standalone MCP / scanner never ran):
101
+ // a `|| {}` here would render all-zeros, indistinguishable from a genuinely
102
+ // idle day. Surface the same hint every other surface uses instead.
103
+ if (agg == null) return 'No stats yet — run `claude-rpc scan` to build your history.';
103
104
  return t.handler(agg);
104
105
  }
105
106
 
@@ -130,6 +131,11 @@ export function runMcpServer({ input = process.stdin, output = process.stdout }
130
131
  if (method === 'tools/list') return reply(id, { tools: toolList() });
131
132
  if (method === 'tools/call') {
132
133
  const name = params?.name;
134
+ // "No such tool" is a protocol error, not a tool that ran and failed —
135
+ // validate before dispatch so we don't conflate the two (or leak the
136
+ // literal "undefined" when params.name is omitted).
137
+ if (!name) return replyErr(id, -32602, 'missing tool name');
138
+ if (!TOOLS[name]) return replyErr(id, -32602, `unknown tool: ${name}`);
133
139
  try {
134
140
  const text = callTool(name);
135
141
  return reply(id, { content: [{ type: 'text', text }], isError: false });