claude-rpc 0.9.1 → 0.10.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 +4 -0
- package/package.json +1 -1
- package/src/calendar.js +95 -0
- package/src/cli.js +87 -3
- package/src/daemon.js +46 -2
- package/src/default-config.js +36 -0
- package/src/format.js +75 -0
- package/src/hook.js +6 -0
- package/src/mcp.js +143 -0
- package/src/notify.js +58 -0
- package/src/session-card.js +69 -0
- package/src/version.js +1 -1
package/README.md
CHANGED
|
@@ -255,6 +255,10 @@ 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 |
|
|
258
262
|
| `private` / `public` / `privacy` | Per-cwd visibility toggles + status |
|
|
259
263
|
| `community` | Opt-in community totals — `on` \| `off` \| `status` \| `report` |
|
|
260
264
|
| `doctor` | Diagnostic checklist with one-line fix hints |
|
package/package.json
CHANGED
package/src/calendar.js
ADDED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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,15 @@ 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'],
|
|
1078
1144
|
['private', 'Mark the current directory as private (hide from Discord)'],
|
|
1079
1145
|
['public', 'Un-mark the current directory'],
|
|
1080
1146
|
['privacy', 'Show resolved visibility for the current directory'],
|
|
1081
1147
|
['community', 'Opt in/out of anonymous community totals (on|off|status|report)'],
|
|
1082
|
-
['doctor', 'Run a diagnostic checklist — common-failure triage'],
|
|
1148
|
+
['doctor', 'Run a diagnostic checklist — common-failure triage (--fix to auto-repair)'],
|
|
1083
1149
|
['tail', 'Tail the daemon log file'],
|
|
1084
1150
|
['daemon', 'Run daemon in foreground (debug)'],
|
|
1085
1151
|
];
|
|
@@ -1148,14 +1214,32 @@ const packagedDefault = IS_PACKAGED && !cmd;
|
|
|
1148
1214
|
case 'badge': await doBadge(process.argv.slice(3)); break;
|
|
1149
1215
|
case 'card': await doCard(process.argv.slice(3)); break;
|
|
1150
1216
|
case 'github-stat': await doGithubStat(process.argv.slice(3)); break;
|
|
1217
|
+
case 'statusline': doStatusline(process.argv.slice(3)); break;
|
|
1218
|
+
case 'calendar': await doCalendar(process.argv.slice(3)); break;
|
|
1219
|
+
case 'session-card': await doSessionCard(process.argv.slice(3)); break;
|
|
1220
|
+
case 'mcp': await doMcp(); break;
|
|
1151
1221
|
case 'private': doPrivate(); break;
|
|
1152
1222
|
case 'public': doPublic(); break;
|
|
1153
1223
|
case 'privacy': doPrivacy(); break;
|
|
1154
1224
|
case 'community': await doCommunity(process.argv.slice(3)); break;
|
|
1155
1225
|
case 'doctor': {
|
|
1156
1226
|
const { runDoctor } = await import('./doctor.js');
|
|
1157
|
-
process.
|
|
1158
|
-
|
|
1227
|
+
const fix = process.argv.includes('--fix');
|
|
1228
|
+
const code = runDoctor();
|
|
1229
|
+
if (!fix) process.exit(code);
|
|
1230
|
+
// --fix: re-run setup (re-seeds/migrates config, re-wires hooks — all
|
|
1231
|
+
// idempotent) and restart the daemon to pick it up. Covers the common
|
|
1232
|
+
// breakages doctor flags: missing/stale hooks, bricked config, dead daemon.
|
|
1233
|
+
console.log(`\n ${c.cyan}◆ --fix${c.reset} ${c.dim}— re-running setup and restarting the daemon${c.reset}`);
|
|
1234
|
+
try {
|
|
1235
|
+
await runInstall({ exePath: EXE_PATH || process.execPath });
|
|
1236
|
+
console.log(` ${c.green}✓${c.reset} config + hooks repaired`);
|
|
1237
|
+
} catch (e) {
|
|
1238
|
+
console.log(` ${c.red}✗${c.reset} setup step failed: ${e.message}`);
|
|
1239
|
+
}
|
|
1240
|
+
restartDaemon();
|
|
1241
|
+
console.log(` ${c.green}✓${c.reset} daemon restarting — run ${c.cyan}claude-rpc doctor${c.reset} again in a few seconds to confirm.`);
|
|
1242
|
+
break; // let the restart timer fire before the process drains
|
|
1159
1243
|
}
|
|
1160
1244
|
case 'tail':
|
|
1161
1245
|
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
|
-
|
|
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) {
|
package/src/default-config.js
CHANGED
|
@@ -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
|
+
}
|
package/src/notify.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Outbound side-effects on status transitions: a native desktop notification
|
|
2
|
+
// (so a permission prompt isn't missed) and a fire-and-forget status webhook
|
|
3
|
+
// (Slack / Discord channel / custom). Both best-effort — they must never throw
|
|
4
|
+
// into the daemon's render loop. The decision helpers are pure + tested.
|
|
5
|
+
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { platform } from 'node:os';
|
|
8
|
+
|
|
9
|
+
// Best-effort native desktop notification. Never throws.
|
|
10
|
+
export function desktopNotify(title, body = '') {
|
|
11
|
+
try {
|
|
12
|
+
const p = platform();
|
|
13
|
+
if (p === 'darwin') {
|
|
14
|
+
const script = `display notification ${JSON.stringify(body)} with title ${JSON.stringify(title)}`;
|
|
15
|
+
spawn('osascript', ['-e', script], { stdio: 'ignore', detached: true }).unref();
|
|
16
|
+
} else if (p === 'win32') {
|
|
17
|
+
const script = `Add-Type -AssemblyName System.Windows.Forms;`
|
|
18
|
+
+ `$n=New-Object System.Windows.Forms.NotifyIcon;`
|
|
19
|
+
+ `$n.Icon=[System.Drawing.SystemIcons]::Information;$n.Visible=$true;`
|
|
20
|
+
+ `$n.ShowBalloonTip(5000, ${JSON.stringify(title)}, ${JSON.stringify(body)}, 'Info')`;
|
|
21
|
+
spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { stdio: 'ignore', detached: true, windowsHide: true }).unref();
|
|
22
|
+
} else {
|
|
23
|
+
spawn('notify-send', ['-a', 'Claude Code', title, body], { stdio: 'ignore', detached: true }).unref();
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Fire-and-forget JSON POST. Never throws. Uses global fetch (Node 18+).
|
|
32
|
+
export function postWebhook(url, payload) {
|
|
33
|
+
try {
|
|
34
|
+
if (!url || typeof fetch !== 'function') return;
|
|
35
|
+
fetch(url, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'content-type': 'application/json' },
|
|
38
|
+
body: JSON.stringify(payload),
|
|
39
|
+
}).catch(() => {});
|
|
40
|
+
} catch {
|
|
41
|
+
/* best-effort */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Should a status transition fire the webhook? Pure — exported for tests.
|
|
46
|
+
export function shouldWebhook(webhookCfg, prevStatus, newStatus) {
|
|
47
|
+
if (!webhookCfg || !webhookCfg.url) return false;
|
|
48
|
+
if (prevStatus === newStatus) return false;
|
|
49
|
+
const on = Array.isArray(webhookCfg.on) ? webhookCfg.on : [];
|
|
50
|
+
return on.includes(newStatus);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Should a status transition raise a desktop notification? Pure — tested.
|
|
54
|
+
export function shouldNotify(notifyCfg, prevStatus, newStatus) {
|
|
55
|
+
if (!notifyCfg || !notifyCfg.enabled) return false;
|
|
56
|
+
if (prevStatus === newStatus) return false;
|
|
57
|
+
return notifyCfg.onNotification !== false && newStatus === 'notification';
|
|
58
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Per-session recap card — "here's what I built this session" as a shareable
|
|
2
|
+
// SVG. Renders from the live buildVars() table (current/most-recent session),
|
|
3
|
+
// so it covers project, model, duration, prompts, tools, files, tokens, cost.
|
|
4
|
+
// `claude-rpc session-card --out s.svg`.
|
|
5
|
+
|
|
6
|
+
import { VERSION } from './version.js';
|
|
7
|
+
|
|
8
|
+
const W = 520;
|
|
9
|
+
const H = 230;
|
|
10
|
+
|
|
11
|
+
const PALETTE = {
|
|
12
|
+
paper: '#f4ede0',
|
|
13
|
+
ink: '#1a1611',
|
|
14
|
+
inkMute:'#5c5147',
|
|
15
|
+
inkFaint:'#8a7c6d',
|
|
16
|
+
rust: '#c2491e',
|
|
17
|
+
tape: '#f2d76e',
|
|
18
|
+
grass: '#4a9462',
|
|
19
|
+
blurple:'#5865f2',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function escapeXml(s) {
|
|
23
|
+
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function statCell(x, y, label, value, accent = PALETTE.ink) {
|
|
27
|
+
return `
|
|
28
|
+
<text x="${x}" y="${y}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="11" font-weight="700" letter-spacing="2" fill="${PALETTE.inkMute}">${escapeXml(label.toUpperCase())}</text>
|
|
29
|
+
<text x="${x}" y="${y + 27}" font-family="Space Grotesk, Inter, system-ui, sans-serif" font-size="25" font-weight="800" letter-spacing="-0.5" fill="${accent}">${escapeXml(value)}</text>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function tape(x, y, text) {
|
|
33
|
+
const w = text.length * 7.4 + 22, h = 23;
|
|
34
|
+
return `<g transform="translate(${x} ${y}) rotate(3)"><rect width="${w}" height="${h}" fill="${PALETTE.tape}" stroke="${PALETTE.ink}" stroke-width="1.5"/><text x="${w / 2}" y="15.5" font-family="JetBrains Mono, ui-monospace, monospace" font-size="12" font-weight="700" letter-spacing="1.1" text-anchor="middle" fill="${PALETTE.ink}">${escapeXml(text.toUpperCase())}</text></g>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Render from a buildVars() table. `vars.project`, `.modelPretty`, `.duration`,
|
|
38
|
+
// `.messages`, `.tools`, `.filesEdited`, `.tokensFmt`, `.currentFilePretty`, etc.
|
|
39
|
+
export function renderSessionCard(vars = {}, { generatedAt = new Date() } = {}) {
|
|
40
|
+
const v = vars || {};
|
|
41
|
+
const C = [40, 210, 380];
|
|
42
|
+
const cost = v.todayCostFmt || '$0';
|
|
43
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="Claude Code session recap">
|
|
44
|
+
<defs><pattern id="dg" width="22" height="22" patternUnits="userSpaceOnUse"><circle cx="1" cy="1" r="1" fill="${PALETTE.ink}" opacity="0.06"/></pattern></defs>
|
|
45
|
+
<rect x="3" y="4" width="${W - 6}" height="${H - 7}" fill="${PALETTE.ink}"/>
|
|
46
|
+
<rect x="0.75" y="0.75" width="${W - 7}" height="${H - 9}" fill="${PALETTE.paper}" stroke="${PALETTE.ink}" stroke-width="1.5"/>
|
|
47
|
+
<rect x="0.75" y="0.75" width="${W - 7}" height="${H - 9}" fill="url(#dg)"/>
|
|
48
|
+
|
|
49
|
+
<text x="40" y="50" font-family="Space Grotesk, Inter, system-ui, sans-serif" font-size="28" font-weight="800" letter-spacing="-1" fill="${PALETTE.ink}">${escapeXml(v.project || 'this session')}</text>
|
|
50
|
+
<text x="40" y="72" font-family="JetBrains Mono, ui-monospace, monospace" font-size="12" fill="${PALETTE.inkMute}">${escapeXml(`${v.modelPretty || 'Claude'} · ${v.duration || '0s'} · ${generatedAt.toISOString().slice(0, 10)}`)}</text>
|
|
51
|
+
${v.modelPretty ? tape(W - 150, 28, String(v.modelPretty)) : ''}
|
|
52
|
+
|
|
53
|
+
<line x1="40" y1="92" x2="${W - 40}" y2="92" stroke="${PALETTE.ink}" stroke-width="1" opacity="0.18"/>
|
|
54
|
+
|
|
55
|
+
${statCell(C[0], 120, 'Prompts', String(v.messages ?? 0))}
|
|
56
|
+
${statCell(C[1], 120, 'Tool calls', String(v.tools ?? 0))}
|
|
57
|
+
${statCell(C[2], 120, 'Tokens', String(v.tokensFmt || '0'), PALETTE.rust)}
|
|
58
|
+
|
|
59
|
+
${statCell(C[0], 184, 'Files edited', String(v.filesEdited ?? 0), PALETTE.grass)}
|
|
60
|
+
${statCell(C[1], 184, 'Est. cost', String(cost), PALETTE.blurple)}
|
|
61
|
+
${statCell(C[2], 184, 'Reads', String(v.filesRead ?? 0))}
|
|
62
|
+
|
|
63
|
+
<text x="40" y="${H - 16}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="10" fill="${PALETTE.inkFaint}">${escapeXml(`${v.currentFilePretty ? 'last: ' + v.currentFilePretty + ' · ' : ''}claude-rpc v${VERSION}`)}</text>
|
|
64
|
+
</svg>`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function sessionCardSvg({ vars } = {}) {
|
|
68
|
+
return renderSessionCard(vars || {}, {});
|
|
69
|
+
}
|