claude-rpc 0.9.1 → 0.11.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 CHANGED
@@ -255,6 +255,11 @@ The full default config is in [`src/default-config.js`](src/default-config.js)
255
255
  | `badge` | Shields-style SVG (`--metric` `--range` `--out` `--gist`) |
256
256
  | `card` | Poster-style SVG (`--range year\|month\|week\|all`) |
257
257
  | `github-stat` | Embeddable profile stat card (`--handle` `--out` `--gist`) |
258
+ | `calendar` | Year activity heatmap SVG (`--out` `--gist`) |
259
+ | `session-card` | Recap card for the current session (`--out`) |
260
+ | `statusline` | One-line status for tmux/shell prompts (`--template`) |
261
+ | `mcp` | Run as an MCP server — expose your stats to Claude Code |
262
+ | `wrapped` | Open your animated year-in-review (Claude Wrapped) |
258
263
  | `private` / `public` / `privacy` | Per-cwd visibility toggles + status |
259
264
  | `community` | Opt-in community totals — `on` \| `off` \| `status` \| `report` |
260
265
  | `doctor` | Diagnostic checklist with one-line fix hints |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "Discord Rich Presence for Claude Code — live model, project, tokens, and lifetime stats driven by Claude Code's hook system.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,95 @@
1
+ // Activity calendar — a GitHub-contributions-style year heatmap of Claude
2
+ // Code activity, rendered as an embeddable SVG (paper/terracotta brand).
3
+ // `claude-rpc calendar --out cal.svg [--gist]`.
4
+
5
+ import { dayKey } from './scanner.js';
6
+ import { VERSION } from './version.js';
7
+
8
+ const PALETTE = {
9
+ paper: '#f4ede0',
10
+ ink: '#1a1611',
11
+ inkMute:'#5c5147',
12
+ inkFaint:'#8a7c6d',
13
+ empty: '#e1d6c0',
14
+ l1: '#f6dccb',
15
+ l2: '#f08a4a',
16
+ l3: '#c2491e',
17
+ l4: '#8f3415',
18
+ };
19
+
20
+ const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
21
+
22
+ function escapeXml(s) {
23
+ return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
24
+ }
25
+
26
+ // activeMs → one of 5 intensity buckets (0..4). 4h saturates.
27
+ function level(ms) {
28
+ if (!ms) return 0;
29
+ const h = ms / 3_600_000;
30
+ if (h < 0.5) return 1;
31
+ if (h < 1.5) return 2;
32
+ if (h < 3) return 3;
33
+ return 4;
34
+ }
35
+ const FILL = [PALETTE.empty, PALETTE.l1, PALETTE.l2, PALETTE.l3, PALETTE.l4];
36
+
37
+ export function renderCalendar(aggregate, { weeks = 53, generatedAt = new Date() } = {}) {
38
+ const byDay = aggregate?.byDay || {};
39
+ const cell = 12, gap = 3, step = cell + gap;
40
+ const padX = 36, padTop = 68, padBottom = 30;
41
+
42
+ // Build the grid ending today. Align the last column to today's weekday so
43
+ // rows read Sun..Sat like GitHub. We render `weeks` columns back from today.
44
+ const today = new Date(generatedAt);
45
+ today.setHours(0, 0, 0, 0);
46
+ const todayDow = today.getDay(); // 0=Sun
47
+ const totalDays = (weeks - 1) * 7 + todayDow + 1;
48
+
49
+ let cells = '';
50
+ let monthLabels = '';
51
+ let lastMonth = -1;
52
+ let totalActiveMs = 0, activeDays = 0;
53
+
54
+ for (let i = 0; i < totalDays; i++) {
55
+ const d = new Date(today);
56
+ d.setDate(d.getDate() - (totalDays - 1 - i));
57
+ const col = Math.floor(i / 7);
58
+ const row = d.getDay();
59
+ const key = dayKey(d);
60
+ const ms = byDay[key]?.activeMs || 0;
61
+ if (ms > 0) { totalActiveMs += ms; activeDays += 1; }
62
+ const x = padX + col * step;
63
+ const y = padTop + row * step;
64
+ cells += `<rect x="${x}" y="${y}" width="${cell}" height="${cell}" rx="2" fill="${FILL[level(ms)]}" stroke="${PALETTE.ink}" stroke-width="0.4"/>`;
65
+ // Month label when a new month starts in the top row region.
66
+ if (d.getMonth() !== lastMonth && d.getDate() <= 7) {
67
+ lastMonth = d.getMonth();
68
+ monthLabels += `<text x="${x}" y="${padTop - 9}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="10" fill="${PALETTE.inkMute}">${MONTHS[d.getMonth()]}</text>`;
69
+ }
70
+ }
71
+
72
+ const W = padX + weeks * step + 14;
73
+ const H = padTop + 7 * step + padBottom;
74
+ const totalHours = (totalActiveMs / 3_600_000).toFixed(0);
75
+ let legend = '';
76
+ for (let l = 0; l < 5; l++) {
77
+ legend += `<rect x="${W - 150 + l * 16}" y="${H - 20}" width="${cell}" height="${cell}" rx="2" fill="${FILL[l]}" stroke="${PALETTE.ink}" stroke-width="0.4"/>`;
78
+ }
79
+
80
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="Claude Code activity calendar">
81
+ <rect width="${W}" height="${H}" fill="${PALETTE.paper}"/>
82
+ <text x="${padX}" y="28" font-family="Space Grotesk, Inter, system-ui, sans-serif" font-size="20" font-weight="800" fill="${PALETTE.ink}">a year on Claude Code</text>
83
+ <text x="${padX}" y="44" font-family="JetBrains Mono, ui-monospace, monospace" font-size="11" fill="${PALETTE.inkMute}">${escapeXml(activeDays)} active days · ${escapeXml(totalHours)}h · as of ${generatedAt.toISOString().slice(0, 10)}</text>
84
+ ${monthLabels}
85
+ ${cells}
86
+ <text x="${W - 170}" y="${H - 10}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="9" fill="${PALETTE.inkFaint}">less</text>
87
+ ${legend}
88
+ <text x="${W - 28}" y="${H - 10}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="9" fill="${PALETTE.inkFaint}">more</text>
89
+ <text x="${padX}" y="${H - 8}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="9" fill="${PALETTE.inkFaint}">claude-rpc v${VERSION}</text>
90
+ </svg>`;
91
+ }
92
+
93
+ export function calendarSvg({ aggregate } = {}) {
94
+ return renderCalendar(aggregate, {});
95
+ }
package/src/cli.js CHANGED
@@ -785,6 +785,68 @@ async function doGithubStat(argv) {
785
785
  }
786
786
  }
787
787
 
788
+ // Build the live template-variable table the way the daemon does — current
789
+ // state + idle/stale resolution + aggregate. Shared by statusline/session-card.
790
+ function liveVars() {
791
+ const state = readState();
792
+ state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
793
+ const config = loadConfig();
794
+ const resolved = applyIdle(state, config);
795
+ return { vars: buildVars(resolved, config, readAggregate()), config };
796
+ }
797
+
798
+ // statusline — a compact one-line status for tmux / starship / shell prompts
799
+ // and Claude Code's own statusline. `--template "..."` overrides the format.
800
+ function doStatusline(argv) {
801
+ let tpl = '{statusVerbose} · {project} · {modelPretty}{tokensLabelPad}';
802
+ for (let i = 0; i < argv.length; i++) {
803
+ if (argv[i] === '--template' || argv[i] === '-t') tpl = argv[++i] || tpl;
804
+ }
805
+ const { vars } = liveVars();
806
+ vars.tokensLabelPad = vars.tokensLabel ? ` · ${vars.tokensLabel}` : '';
807
+ process.stdout.write(fillTemplate(tpl, vars));
808
+ }
809
+
810
+ // Activity calendar — GitHub-contributions-style year heatmap SVG.
811
+ async function doCalendar(argv) {
812
+ const opts = { out: '', gist: false };
813
+ for (let i = 0; i < argv.length; i++) {
814
+ if (argv[i] === '--out' || argv[i] === '-o') opts.out = argv[++i];
815
+ else if (argv[i] === '--gist') opts.gist = true;
816
+ }
817
+ const aggregate = readAggregate();
818
+ if (!aggregate) fail('no aggregate yet — run `claude-rpc scan` first', { code: EX_BAD_STATE });
819
+ const { renderCalendar } = await import('./calendar.js');
820
+ const svg = renderCalendar(aggregate, {});
821
+ if (opts.gist) return publishBadgeToGist(svg, { metric: 'calendar', range: 'year' });
822
+ if (opts.out) {
823
+ writeFileSync(opts.out, svg);
824
+ console.log(`${c.green}✓${c.reset} Wrote ${c.cyan}${opts.out}${c.reset} (${svg.length} bytes)`);
825
+ } else process.stdout.write(svg);
826
+ }
827
+
828
+ // Per-session recap card — current/most-recent session as a shareable SVG.
829
+ async function doSessionCard(argv) {
830
+ const opts = { out: '' };
831
+ for (let i = 0; i < argv.length; i++) {
832
+ if (argv[i] === '--out' || argv[i] === '-o') opts.out = argv[++i];
833
+ }
834
+ const { vars } = liveVars();
835
+ const { renderSessionCard } = await import('./session-card.js');
836
+ const svg = renderSessionCard(vars, {});
837
+ if (opts.out) {
838
+ writeFileSync(opts.out, svg);
839
+ console.log(`${c.green}✓${c.reset} Wrote ${c.cyan}${opts.out}${c.reset} (${svg.length} bytes)`);
840
+ } else process.stdout.write(svg);
841
+ }
842
+
843
+ // MCP server — expose stats to Claude Code over stdio. Long-running; never
844
+ // writes to stdout except JSON-RPC frames.
845
+ async function doMcp() {
846
+ const { runMcpServer } = await import('./mcp.js');
847
+ runMcpServer();
848
+ }
849
+
788
850
  // ── Privacy commands ─────────────────────────────────────────────────────
789
851
  //
790
852
  // `claude-rpc private` → add current cwd to ~/.claude-rpc/private-list.json
@@ -1075,11 +1137,16 @@ function help() {
1075
1137
  ['badge', 'Render a Shields-style SVG (--metric --range --out --gist)'],
1076
1138
  ['card', 'Render a poster-style SVG summary (--range year|month|week|all)'],
1077
1139
  ['github-stat', 'Render an embeddable profile stat card (--handle --out --gist)'],
1140
+ ['statusline', 'One-line status for tmux/shell prompts (--template)'],
1141
+ ['calendar', 'Year activity heatmap SVG (--out --gist)'],
1142
+ ['session-card', 'Recap card for the current session (--out)'],
1143
+ ['mcp', 'Run as an MCP server — expose your stats to Claude Code'],
1144
+ ['wrapped', 'Open your animated year-in-review (Claude Wrapped)'],
1078
1145
  ['private', 'Mark the current directory as private (hide from Discord)'],
1079
1146
  ['public', 'Un-mark the current directory'],
1080
1147
  ['privacy', 'Show resolved visibility for the current directory'],
1081
1148
  ['community', 'Opt in/out of anonymous community totals (on|off|status|report)'],
1082
- ['doctor', 'Run a diagnostic checklist — common-failure triage'],
1149
+ ['doctor', 'Run a diagnostic checklist — common-failure triage (--fix to auto-repair)'],
1083
1150
  ['tail', 'Tail the daemon log file'],
1084
1151
  ['daemon', 'Run daemon in foreground (debug)'],
1085
1152
  ];
@@ -1148,14 +1215,33 @@ const packagedDefault = IS_PACKAGED && !cmd;
1148
1215
  case 'badge': await doBadge(process.argv.slice(3)); break;
1149
1216
  case 'card': await doCard(process.argv.slice(3)); break;
1150
1217
  case 'github-stat': await doGithubStat(process.argv.slice(3)); break;
1218
+ case 'statusline': doStatusline(process.argv.slice(3)); break;
1219
+ case 'calendar': await doCalendar(process.argv.slice(3)); break;
1220
+ case 'session-card': await doSessionCard(process.argv.slice(3)); break;
1221
+ case 'mcp': await doMcp(); break;
1222
+ case 'wrapped': process.env.CLAUDE_RPC_OPEN_PATH = '/wrapped'; await import('./server/index.js'); break;
1151
1223
  case 'private': doPrivate(); break;
1152
1224
  case 'public': doPublic(); break;
1153
1225
  case 'privacy': doPrivacy(); break;
1154
1226
  case 'community': await doCommunity(process.argv.slice(3)); break;
1155
1227
  case 'doctor': {
1156
1228
  const { runDoctor } = await import('./doctor.js');
1157
- process.exit(runDoctor());
1158
- break;
1229
+ const fix = process.argv.includes('--fix');
1230
+ const code = runDoctor();
1231
+ if (!fix) process.exit(code);
1232
+ // --fix: re-run setup (re-seeds/migrates config, re-wires hooks — all
1233
+ // idempotent) and restart the daemon to pick it up. Covers the common
1234
+ // breakages doctor flags: missing/stale hooks, bricked config, dead daemon.
1235
+ console.log(`\n ${c.cyan}◆ --fix${c.reset} ${c.dim}— re-running setup and restarting the daemon${c.reset}`);
1236
+ try {
1237
+ await runInstall({ exePath: EXE_PATH || process.execPath });
1238
+ console.log(` ${c.green}✓${c.reset} config + hooks repaired`);
1239
+ } catch (e) {
1240
+ console.log(` ${c.red}✗${c.reset} setup step failed: ${e.message}`);
1241
+ }
1242
+ restartDaemon();
1243
+ console.log(` ${c.green}✓${c.reset} daemon restarting — run ${c.cyan}claude-rpc doctor${c.reset} again in a few seconds to confirm.`);
1244
+ break; // let the restart timer fire before the process drains
1159
1245
  }
1160
1246
  case 'tail':
1161
1247
  case 'logs':
package/src/daemon.js CHANGED
@@ -2,12 +2,14 @@
2
2
  import { writeFileSync, existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
3
3
  import { Client } from '@xhayper/discord-rpc';
4
4
  import { readState } from './state.js';
5
- import { buildVars, fillTemplate, framePasses, applyIdle, applyShipped } from './format.js';
5
+ import { buildVars, fillTemplate, framePasses, applyIdle, applyShipped, applyTrigger } from './format.js';
6
6
  import { scan, readAggregate, findLiveSessions, readSessionTokens } from './scanner.js';
7
7
  import { detectGithubUrl } from './git.js';
8
8
  import { applyPrivacy } from './privacy.js';
9
9
  import { loadConfig } from './config.js';
10
10
  import { migrateConfig } from './install.js';
11
+ import { desktopNotify, postWebhook, shouldWebhook, shouldNotify } from './notify.js';
12
+ import { humanProject } from './format.js';
11
13
  import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH } from './paths.js';
12
14
 
13
15
  if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
@@ -72,6 +74,9 @@ let liveSessions = [];
72
74
  let client = null;
73
75
  let connected = false;
74
76
  let lastPayloadHash = '';
77
+ // Last status we acted on for outbound side-effects (webhook / desktop notify).
78
+ // Tracked separately from the render hash so we fire once per transition.
79
+ let lastNotifiedStatus = null;
75
80
  let reconnectTimer = null;
76
81
  // Exponential backoff for Discord reconnect: 5s → 10s → 20s → … → 300s cap.
77
82
  // Reset to RECONNECT_BASE_MS on a successful connect so the next outage
@@ -139,6 +144,8 @@ function buildActivity(opts = {}) {
139
144
  // Shipped overlay sits on top of idle/working/thinking — but never over
140
145
  // stale (we don't celebrate when Claude isn't running).
141
146
  state = applyShipped(state, config);
147
+ // Custom-command trigger overlay (config.triggers) — never over stale/shipped.
148
+ state = applyTrigger(state, config);
142
149
 
143
150
  // Pull live session tokens from the transcript file. Claude Code's hook
144
151
  // payloads don't include usage data, so state.tokens from PostToolUse
@@ -168,7 +175,13 @@ function buildActivity(opts = {}) {
168
175
  // Pick the active set of frames + any status-level largeImageText override.
169
176
  // Reset the rotation cursor when status changes so a 7-frame idle rotation
170
177
  // doesn't bleed its index into a 1-frame working state.
171
- const { frames: rawFrames, largeImageTextTpl: statusLIT } = pickFrames(p, state.status);
178
+ let rawFrames, statusLIT;
179
+ if (state.status === 'trigger' && state._triggerFrame) {
180
+ rawFrames = [state._triggerFrame];
181
+ statusLIT = state._triggerFrame.largeImageText || null;
182
+ } else {
183
+ ({ frames: rawFrames, largeImageTextTpl: statusLIT } = pickFrames(p, state.status));
184
+ }
172
185
  if (state.status !== rotationStatus) {
173
186
  rotationIndex = 0;
174
187
  lastRotationAt = 0;
@@ -259,6 +272,33 @@ function buildActivity(opts = {}) {
259
272
  return activity;
260
273
  }
261
274
 
275
+ // Fire desktop-notification + webhook on a status transition (once per change).
276
+ function fireStatusSideEffects(resolved) {
277
+ const status = resolved.status;
278
+ if (status === lastNotifiedStatus) return;
279
+ const prev = lastNotifiedStatus;
280
+ lastNotifiedStatus = status;
281
+ try {
282
+ const project = humanProject(resolved.cwd) || 'Claude Code';
283
+ if (shouldNotify(config.notify, prev, status)) {
284
+ desktopNotify('Claude Code needs you', `Waiting on you in ${project}`);
285
+ log(`desktop notification raised (status=${status})`);
286
+ }
287
+ if (shouldWebhook(config.webhook, prev, status)) {
288
+ postWebhook(config.webhook.url, {
289
+ status,
290
+ project,
291
+ model: resolved.model || null,
292
+ justShipped: resolved.justShippedKind || null,
293
+ ts: Date.now(),
294
+ });
295
+ log(`webhook: POSTed status=${status} (${project})`);
296
+ }
297
+ } catch (e) {
298
+ log('status side-effect failed:', e.message);
299
+ }
300
+ }
301
+
262
302
  async function pushPresence() {
263
303
  if (!connected || !client?.user) return;
264
304
  try {
@@ -274,6 +314,10 @@ async function pushPresence() {
274
314
  // lastPayloadHash so we don't spam the IPC.
275
315
  resolved = applyPrivacy(resolved, config);
276
316
 
317
+ // Outbound side-effects on a status TRANSITION (fire once per change):
318
+ // a desktop notification when Claude needs you, and an opt-in webhook POST.
319
+ fireStatusSideEffects(resolved);
320
+
277
321
  const hideWhenStale = config.hideWhenStale !== false;
278
322
  const privacyHidden = resolved._privacy?.visibility === 'hidden';
279
323
  if ((resolved.status === 'stale' && hideWhenStale) || privacyHidden) {
@@ -37,6 +37,40 @@ export const DEFAULT_CONFIG = {
37
37
  // celebratory "Just shipped" frame before falling back to the
38
38
  // underlying status. Set 0 to disable the overlay entirely.
39
39
  shippedFrameSec: 60,
40
+ // Daily / weekly goals (v0.10). When set (> 0), the daemon surfaces a
41
+ // progress frame ("2.1h / 4h · 52%") and the dashboard shows a ring.
42
+ // Set any field to 0/null to disable that goal.
43
+ goals: {
44
+ dailyHours: 0, // target active hours per day
45
+ dailyPrompts: 0, // target prompts per day
46
+ weeklyHours: 0, // target active hours per week
47
+ },
48
+ // Monthly cost budget (v0.10). When budget.monthly > 0, the dashboard and
49
+ // a presence frame warn as month-to-date spend approaches it.
50
+ budget: {
51
+ monthly: 0, // USD; 0 disables
52
+ warnAtPct: 80, // surface a warning once MTD spend hits this %
53
+ },
54
+ // Outbound status webhook (v0.10). When url is set, the daemon POSTs a small
55
+ // JSON body on status transitions you opt into (best-effort, fire-and-forget).
56
+ // Pair with a Slack/Discord incoming-webhook or your own endpoint.
57
+ webhook: {
58
+ url: "", // "" disables
59
+ on: ["shipped", "notification"], // statuses that fire a POST
60
+ },
61
+ // Desktop notifications (v0.10). When enabled, the daemon raises a native OS
62
+ // notification (notify-send / osascript / PowerShell toast) when Claude needs
63
+ // you — so a permission prompt isn't missed while you're tabbed away.
64
+ notify: {
65
+ enabled: false,
66
+ onNotification: true, // raise on the Notification hook
67
+ },
68
+ // Custom command triggers (v0.10). Each entry maps a regex against the Bash
69
+ // command Claude runs to a brief presence frame, generalizing ship-detection.
70
+ // e.g. { "match": "npm (run )?test", "details": "Running tests in {project}" }
71
+ triggers: [],
72
+ // How long (seconds) a matched trigger frame stays up after the command ran.
73
+ triggerFrameSec: 20,
40
74
  // `claude-rpc badge --gist` records id+owner here after a successful
41
75
  // first publish so subsequent publishes UPDATE the same gist (the raw
42
76
  // URL in your README stays stable). filename is the file inside the
@@ -131,6 +165,8 @@ export const DEFAULT_CONFIG = {
131
165
  { details: "{allFreshTokensFmt} fresh tokens", state: "{allCachePctLabel}", requires: ["allCachePctLabel"] },
132
166
  { details: "Code churn · {linesAddedFmt} added", state: "{linesNetFmt} net · {topLanguage}", requires: ["topLanguage"] },
133
167
  { details: "Cost · {todayCostFmt} today", state: "{allCostFmt} all-time", requires: ["allCost"] },
168
+ { details: "Daily goal", state: "{goalLabel}", requires: ["goalLabel"] },
169
+ { details: "Monthly budget", state: "{budgetLabel}", requires: ["budgetLabel"] },
134
170
  ],
135
171
  },
136
172
  },
package/src/format.js CHANGED
@@ -303,6 +303,38 @@ export function buildVars(state, config, aggregate) {
303
303
  // Per-project cost for the current cwd's project.
304
304
  const projectCost = projectStats?.cost || 0;
305
305
 
306
+ // Goals (v0.10) — daily/weekly targets → progress label + hit flag.
307
+ const goalsCfg = config?.goals || {};
308
+ const todayHoursNum = (today.activeMs || 0) / 3_600_000;
309
+ const weekHoursNum = (thisWeek.activeMs || 0) / 3_600_000;
310
+ const pctOf = (cur, target) => (target > 0 ? Math.min(999, Math.round((cur / target) * 100)) : 0);
311
+ const goalDailyHoursPct = pctOf(todayHoursNum, goalsCfg.dailyHours || 0);
312
+ const goalDailyPromptsPct = pctOf(today.userMessages || 0, goalsCfg.dailyPrompts || 0);
313
+ const goalWeeklyHoursPct = pctOf(weekHoursNum, goalsCfg.weeklyHours || 0);
314
+ let goalLabel = '', goalHit = 0;
315
+ if ((goalsCfg.dailyHours || 0) > 0) {
316
+ goalLabel = `${fmtHours(today.activeMs || 0)} / ${goalsCfg.dailyHours}h · ${goalDailyHoursPct}%`;
317
+ goalHit = goalDailyHoursPct >= 100 ? 1 : 0;
318
+ } else if ((goalsCfg.dailyPrompts || 0) > 0) {
319
+ goalLabel = `${today.userMessages || 0} / ${goalsCfg.dailyPrompts} prompts · ${goalDailyPromptsPct}%`;
320
+ goalHit = goalDailyPromptsPct >= 100 ? 1 : 0;
321
+ } else if ((goalsCfg.weeklyHours || 0) > 0) {
322
+ goalLabel = `${fmtHours(thisWeek.activeMs || 0)} / ${goalsCfg.weeklyHours}h this week · ${goalWeeklyHoursPct}%`;
323
+ goalHit = goalWeeklyHoursPct >= 100 ? 1 : 0;
324
+ }
325
+
326
+ // Monthly cost budget (v0.10) — month-to-date spend vs budget.
327
+ const budgetCfg = config?.budget || {};
328
+ const monthlyBudget = budgetCfg.monthly || 0;
329
+ const monthPrefix = dayKey(Date.now()).slice(0, 7); // YYYY-MM
330
+ let mtdCost = 0;
331
+ for (const [k, d] of Object.entries(agg.byDay || {})) {
332
+ if (k.startsWith(monthPrefix)) mtdCost += d.cost || 0;
333
+ }
334
+ const budgetPct = monthlyBudget > 0 ? Math.round((mtdCost / monthlyBudget) * 100) : 0;
335
+ const budgetWarn = monthlyBudget > 0 && budgetPct >= (budgetCfg.warnAtPct || 80) ? 1 : 0;
336
+ const budgetLabel = monthlyBudget > 0 ? `${fmtCost(mtdCost)} / ${fmtCost(monthlyBudget)} · ${budgetPct}%` : '';
337
+
306
338
  // Weekday name from today's date.
307
339
  const weekdayLabel = WEEKDAY_NAMES[new Date().getDay()];
308
340
 
@@ -673,6 +705,17 @@ export function buildVars(state, config, aggregate) {
673
705
  projectCost,
674
706
  projectCostFmt: fmtCost(projectCost),
675
707
 
708
+ // ── Goals & budget (v0.10) ──────────────────────────────────
709
+ goalLabel,
710
+ goalHit,
711
+ goalDailyHoursPct,
712
+ goalDailyPromptsPct,
713
+ goalWeeklyHoursPct,
714
+ budgetLabel,
715
+ budgetPct,
716
+ budgetWarn,
717
+ budgetMtdFmt: fmtCost(mtdCost),
718
+
676
719
  // ── Time-of-day / weekday ───────────────────────────────────
677
720
  weekdayLabel,
678
721
  startTimeLabel: todayStartLabel,
@@ -847,6 +890,38 @@ export function applyShipped(state, cfg = {}) {
847
890
  return { ...state, status: 'shipped' };
848
891
  }
849
892
 
893
+ // Overlay a custom-trigger frame when a recent Bash command matches a
894
+ // user-defined pattern (config.triggers: [{ match, details, state }]). Called
895
+ // by the daemon AFTER applyIdle/applyShipped — never overrides stale or the
896
+ // shipped celebration. Window = triggerFrameSec (default 20s) from the command.
897
+ // Returns a new state with status:'trigger' + _triggerFrame, or the input.
898
+ export function applyTrigger(state, cfg = {}) {
899
+ const triggers = Array.isArray(cfg.triggers) ? cfg.triggers : [];
900
+ if (!triggers.length) return state;
901
+ if (state.status === 'stale' || state.status === 'shipped') return state;
902
+ const cmd = state.lastBashCommand || '';
903
+ if (!cmd) return state;
904
+ const windowMs = Math.max(3_000, (cfg.triggerFrameSec ?? 20) * 1000);
905
+ if (Date.now() - (state.lastBashAt || 0) > windowMs) return state;
906
+ for (const t of triggers) {
907
+ if (!t || !t.match) continue;
908
+ let re;
909
+ try { re = new RegExp(t.match, 'i'); } catch { continue; }
910
+ if (re.test(cmd)) {
911
+ return {
912
+ ...state,
913
+ status: 'trigger',
914
+ _triggerFrame: {
915
+ details: t.details || '{statusVerbose} in {project}',
916
+ state: t.state || '',
917
+ largeImageText: t.largeImageText || null,
918
+ },
919
+ };
920
+ }
921
+ }
922
+ return state;
923
+ }
924
+
850
925
  // True when `requires` (string or array of strings) all resolve to non-zero / non-empty.
851
926
  export function framePasses(frame, vars) {
852
927
  const req = frame.requires;
package/src/hook.js CHANGED
@@ -106,6 +106,12 @@ export function processHookEvent(event, input = {}) {
106
106
  s.lastActivity = now;
107
107
  s.claudeClosed = false;
108
108
  if (!s.sessionStart) s.sessionStart = now;
109
+ // Remember the running Bash command so the daemon can match custom
110
+ // triggers (config.triggers) against it for a brief overlay frame.
111
+ if (toolName === 'Bash' && toolInput.command) {
112
+ s.lastBashCommand = String(toolInput.command).slice(0, 500);
113
+ s.lastBashAt = now;
114
+ }
109
115
  if (file && (toolName === 'Read' || toolName === 'NotebookEdit')) {
110
116
  s.filesOpened = pushUnique(s.filesOpened, file);
111
117
  if (toolName === 'Read') s.filesRead = pushUnique(s.filesRead, file);
package/src/mcp.js ADDED
@@ -0,0 +1,143 @@
1
+ // MCP server mode — exposes your own Claude Code stats as MCP tools so you can
2
+ // ask Claude, mid-session, "how long have I worked today?" / "what's my top
3
+ // file this week?" / "am I over budget?". Minimal newline-delimited JSON-RPC
4
+ // over stdio (no SDK dep). Wire it into Claude Code as an MCP server:
5
+ // claude mcp add claude-rpc -- claude-rpc mcp
6
+ //
7
+ // The tool HANDLERS are pure functions of an aggregate, so they're unit-tested
8
+ // without standing up the transport.
9
+
10
+ import { readAggregate } from './scanner.js';
11
+ import { buildVars } from './format.js';
12
+ import { readState } from './state.js';
13
+ import { loadConfig } from './config.js';
14
+ import { VERSION } from './version.js';
15
+
16
+ function fmtH(ms) { const h = (ms || 0) / 3_600_000; return h < 1 ? `${Math.round(h * 60)}m` : `${h.toFixed(1)}h`; }
17
+ function fmtN(n) {
18
+ if (!n) return '0';
19
+ if (n < 1000) return String(Math.round(n));
20
+ if (n < 1e6) return `${(n / 1e3).toFixed(1)}k`;
21
+ if (n < 1e9) return `${(n / 1e6).toFixed(2)}M`;
22
+ return `${(n / 1e9).toFixed(2)}B`;
23
+ }
24
+
25
+ // ── Tool handlers — pure(aggregate) → string. Exported for tests. ──────────
26
+ export const TOOLS = {
27
+ get_lifetime_stats: {
28
+ description: 'Overall all-time Claude Code stats: active hours, sessions, current/longest streak, total tokens, estimated cost, top language.',
29
+ handler(agg) {
30
+ const tokens = (agg.inputTokens || 0) + (agg.outputTokens || 0) + (agg.cacheReadTokens || 0) + (agg.cacheWriteTokens || 0);
31
+ const lang = Object.entries(agg.languages || {}).sort((a, b) => (b[1].edits || 0) - (a[1].edits || 0))[0];
32
+ return [
33
+ `Active time: ${fmtH(agg.activeMs)}`,
34
+ `Sessions: ${fmtN(agg.sessions || 0)}`,
35
+ `Streak: ${agg.streak || 0} days (best ${agg.longestStreak || 0})`,
36
+ `Prompts: ${fmtN(agg.userMessages || 0)}`,
37
+ `Tokens: ${fmtN(tokens)}`,
38
+ `Est. cost: $${(agg.estimatedCost || 0).toFixed(2)}`,
39
+ `Top language: ${lang ? `${lang[0]} (${fmtN(lang[1].edits)} edits)` : '—'}`,
40
+ `Since: day ${agg.daysSinceFirst || 0}`,
41
+ ].join('\n');
42
+ },
43
+ },
44
+ get_today: {
45
+ description: "Today's Claude Code activity: active hours, prompts, tool calls, tokens, estimated cost.",
46
+ handler(agg) {
47
+ const today = (agg.byDay || {})[new Date().toISOString().slice(0, 10)] || {};
48
+ const tokens = (today.inputTokens || 0) + (today.outputTokens || 0) + (today.cacheReadTokens || 0) + (today.cacheWriteTokens || 0);
49
+ return [
50
+ `Active time: ${fmtH(today.activeMs)}`,
51
+ `Prompts: ${today.userMessages || 0}`,
52
+ `Tool calls: ${today.toolCalls || 0}`,
53
+ `Tokens: ${fmtN(tokens)}`,
54
+ `Est. cost: $${(today.cost || 0).toFixed(2)}`,
55
+ ].join('\n');
56
+ },
57
+ },
58
+ get_top_files: {
59
+ description: 'Most-edited files all-time, with edit counts and how long since each was last touched.',
60
+ handler(agg) {
61
+ const list = (agg.topEditedFiles || []).slice(0, 10);
62
+ if (!list.length) return 'No edited files recorded yet.';
63
+ return list.map((f, i) => {
64
+ const name = String(f.path || '').replace(/\\/g, '/').split('/').pop();
65
+ const age = f.daysSinceLastEdit == null ? '' : f.daysSinceLastEdit === 0 ? ' · today' : ` · ${f.daysSinceLastEdit}d ago`;
66
+ return `${i + 1}. ${name} — ${f.count} edits${age}`;
67
+ }).join('\n');
68
+ },
69
+ },
70
+ get_model_split: {
71
+ description: 'Per-model breakdown of spend, tokens, and turns across all of Claude Code history.',
72
+ handler(agg) {
73
+ const split = agg.modelSplit || [];
74
+ if (!split.length) return 'No model usage recorded yet.';
75
+ return split.map((m) => `${m.model}: $${(m.cost || 0).toFixed(2)} (${Math.round((m.costPct || 0) * 100)}%) · ${fmtN(m.tokens)} tokens · ${m.turns} turns`).join('\n');
76
+ },
77
+ },
78
+ };
79
+
80
+ // Build a tools/list payload from the registry.
81
+ export function toolList() {
82
+ return Object.entries(TOOLS).map(([name, t]) => ({
83
+ name,
84
+ description: t.description,
85
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
86
+ }));
87
+ }
88
+
89
+ // Dispatch a tools/call by name. Reads a fresh aggregate per call (cheap).
90
+ export function callTool(name, getAgg = readAggregate) {
91
+ const t = TOOLS[name];
92
+ if (!t) throw new Error(`unknown tool: ${name}`);
93
+ const agg = getAgg() || {};
94
+ return t.handler(agg);
95
+ }
96
+
97
+ // ── stdio JSON-RPC transport (newline-delimited) ──────────────────────────
98
+ export function runMcpServer({ input = process.stdin, output = process.stdout } = {}) {
99
+ let buf = '';
100
+ const send = (msg) => output.write(JSON.stringify(msg) + '\n');
101
+ const reply = (id, result) => send({ jsonrpc: '2.0', id, result });
102
+ const replyErr = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
103
+
104
+ function handle(msg) {
105
+ const { id, method, params } = msg;
106
+ if (method === 'initialize') {
107
+ return reply(id, {
108
+ protocolVersion: '2024-11-05',
109
+ capabilities: { tools: {} },
110
+ serverInfo: { name: 'claude-rpc', version: VERSION },
111
+ });
112
+ }
113
+ if (method === 'notifications/initialized' || method === 'notifications/cancelled') return; // notifications: no reply
114
+ if (method === 'tools/list') return reply(id, { tools: toolList() });
115
+ if (method === 'tools/call') {
116
+ const name = params?.name;
117
+ try {
118
+ const text = callTool(name);
119
+ return reply(id, { content: [{ type: 'text', text }], isError: false });
120
+ } catch (e) {
121
+ return reply(id, { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true });
122
+ }
123
+ }
124
+ if (method === 'ping') return reply(id, {});
125
+ if (id !== undefined) replyErr(id, -32601, `method not found: ${method}`);
126
+ }
127
+
128
+ input.setEncoding('utf8');
129
+ input.on('data', (chunk) => {
130
+ buf += chunk;
131
+ let nl;
132
+ while ((nl = buf.indexOf('\n')) >= 0) {
133
+ const line = buf.slice(0, nl).trim();
134
+ buf = buf.slice(nl + 1);
135
+ if (!line) continue;
136
+ let msg;
137
+ try { msg = JSON.parse(line); } catch { continue; }
138
+ try { handle(msg); } catch (e) { process.stderr.write(`mcp handler error: ${e.message}\n`); }
139
+ }
140
+ });
141
+ input.on('end', () => process.exit(0));
142
+ process.stderr.write(`claude-rpc MCP server v${VERSION} ready (stdio)\n`);
143
+ }