claude-rpc 0.12.1 → 0.13.2
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 +29 -8
- package/package.json +12 -3
- package/src/calendar.js +1 -1
- package/src/card.js +1 -1
- package/src/cli.js +260 -15
- package/src/community.js +89 -0
- package/src/daemon.js +78 -28
- package/src/default-config.js +23 -1
- package/src/doctor.js +36 -14
- package/src/format.js +11 -5
- package/src/git.js +1 -1
- package/src/hook.js +56 -13
- package/src/install.js +58 -22
- package/src/leaderboard.js +0 -0
- package/src/mcp.js +20 -4
- package/src/notify.js +33 -6
- package/src/nudge.js +87 -0
- package/src/paths.js +7 -0
- package/src/profile.js +1 -1
- package/src/scanner.js +119 -26
- package/src/server/assets/dashboard.client.js +1 -1
- package/src/server/assets/wrapped.client.js +26 -5
- package/src/session-card.js +1 -1
- package/src/state.js +102 -10
- package/src/version.js +1 -1
package/src/notify.js
CHANGED
|
@@ -6,21 +6,36 @@
|
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
7
|
import { platform } from 'node:os';
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Best-effort native desktop notification (osascript / PowerShell / notify-send).
|
|
11
|
+
* Never throws — a missing notifier binary is swallowed.
|
|
12
|
+
* @param {string} title - Notification title.
|
|
13
|
+
* @param {string} [body] - Notification body text.
|
|
14
|
+
* @returns {boolean} true if a notifier was spawned, false on failure.
|
|
15
|
+
*/
|
|
10
16
|
export function desktopNotify(title, body = '') {
|
|
11
17
|
try {
|
|
12
18
|
const p = platform();
|
|
19
|
+
// A missing notifier binary (e.g. no `notify-send`) makes spawn emit an
|
|
20
|
+
// async 'error' event — with no listener that surfaces as an UNCAUGHT
|
|
21
|
+
// exception that the sync try/catch below can't stop, which would crash
|
|
22
|
+
// the daemon's render loop. The no-op 'error' listener keeps the promise
|
|
23
|
+
// in this function ("never throws") actually true.
|
|
24
|
+
const swallow = (child) => {
|
|
25
|
+
child.on('error', () => {});
|
|
26
|
+
child.unref();
|
|
27
|
+
};
|
|
13
28
|
if (p === 'darwin') {
|
|
14
29
|
const script = `display notification ${JSON.stringify(body)} with title ${JSON.stringify(title)}`;
|
|
15
|
-
spawn('osascript', ['-e', script], { stdio: 'ignore', detached: true })
|
|
30
|
+
swallow(spawn('osascript', ['-e', script], { stdio: 'ignore', detached: true }));
|
|
16
31
|
} else if (p === 'win32') {
|
|
17
32
|
const script = `Add-Type -AssemblyName System.Windows.Forms;`
|
|
18
33
|
+ `$n=New-Object System.Windows.Forms.NotifyIcon;`
|
|
19
34
|
+ `$n.Icon=[System.Drawing.SystemIcons]::Information;$n.Visible=$true;`
|
|
20
35
|
+ `$n.ShowBalloonTip(5000, ${JSON.stringify(title)}, ${JSON.stringify(body)}, 'Info')`;
|
|
21
|
-
spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { stdio: 'ignore', detached: true, windowsHide: true })
|
|
36
|
+
swallow(spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { stdio: 'ignore', detached: true, windowsHide: true }));
|
|
22
37
|
} else {
|
|
23
|
-
spawn('notify-send', ['-a', 'Claude Code', title, body], { stdio: 'ignore', detached: true })
|
|
38
|
+
swallow(spawn('notify-send', ['-a', 'Claude Code', title, body], { stdio: 'ignore', detached: true }));
|
|
24
39
|
}
|
|
25
40
|
return true;
|
|
26
41
|
} catch {
|
|
@@ -28,7 +43,13 @@ export function desktopNotify(title, body = '') {
|
|
|
28
43
|
}
|
|
29
44
|
}
|
|
30
45
|
|
|
31
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Fire-and-forget JSON POST. Never throws; rejections are swallowed.
|
|
48
|
+
* Uses global fetch (Node 18+).
|
|
49
|
+
* @param {string} url - Webhook endpoint. Falsy → no-op.
|
|
50
|
+
* @param {unknown} payload - JSON-serializable body.
|
|
51
|
+
* @returns {void}
|
|
52
|
+
*/
|
|
32
53
|
export function postWebhook(url, payload) {
|
|
33
54
|
try {
|
|
34
55
|
if (!url || typeof fetch !== 'function') return;
|
|
@@ -42,7 +63,13 @@ export function postWebhook(url, payload) {
|
|
|
42
63
|
}
|
|
43
64
|
}
|
|
44
65
|
|
|
45
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Should a status transition fire the webhook? Pure — exported for tests.
|
|
68
|
+
* @param {{url?: string, on?: string[]}} webhookCfg - Webhook config.
|
|
69
|
+
* @param {string} prevStatus - Previous presence status.
|
|
70
|
+
* @param {string} newStatus - Incoming presence status.
|
|
71
|
+
* @returns {boolean}
|
|
72
|
+
*/
|
|
46
73
|
export function shouldWebhook(webhookCfg, prevStatus, newStatus) {
|
|
47
74
|
if (!webhookCfg || !webhookCfg.url) return false;
|
|
48
75
|
if (prevStatus === newStatus) return false;
|
package/src/nudge.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Share nudges — the gentle half of the viral loop. When you cross a genuine
|
|
2
|
+
// milestone (a streak record, a round number of sessions or hours), the CLI
|
|
3
|
+
// offers a one-liner to share it. Deliberately conservative:
|
|
4
|
+
//
|
|
5
|
+
// - Only ever surfaces the single biggest *new* milestone, and only once
|
|
6
|
+
// (deduped by key in a tiny state file). Crossing nothing new → silence.
|
|
7
|
+
// - Off-switch: config.nudges.enabled === false.
|
|
8
|
+
// - Never throws and never blocks — it's the last thing printed, best-effort.
|
|
9
|
+
//
|
|
10
|
+
// pickShareNudge is pure (aggregate, lastKey) → nudge|null, so it's unit-tested
|
|
11
|
+
// without touching disk.
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
14
|
+
import { join, dirname } from 'node:path';
|
|
15
|
+
import { STATE_DIR } from './paths.js';
|
|
16
|
+
|
|
17
|
+
const NUDGE_STATE = join(STATE_DIR, 'nudge-state.json');
|
|
18
|
+
|
|
19
|
+
// Largest milestone in `list` that `value` has reached, or null.
|
|
20
|
+
function reached(value, list) {
|
|
21
|
+
let hit = null;
|
|
22
|
+
for (const m of list) if (value >= m) hit = m;
|
|
23
|
+
return hit;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const fmt = (n) => n >= 1000 ? n.toLocaleString('en-US') : String(n);
|
|
27
|
+
|
|
28
|
+
// Returns { key, weight, message } for the biggest milestone the aggregate has
|
|
29
|
+
// crossed, or null. `weight` ranks across milestone types so we only ever show
|
|
30
|
+
// the single most impressive one.
|
|
31
|
+
export function pickShareNudge(agg) {
|
|
32
|
+
if (!agg || typeof agg !== 'object') return null;
|
|
33
|
+
const out = [];
|
|
34
|
+
|
|
35
|
+
const streak = agg.streak || 0;
|
|
36
|
+
const longest = agg.longestStreak || 0;
|
|
37
|
+
// Only celebrate a streak when it's also a personal record — otherwise the
|
|
38
|
+
// "share your streak" prompt fires mid-decline, which feels off.
|
|
39
|
+
if (streak >= 3 && streak === longest) {
|
|
40
|
+
const m = reached(streak, [3, 7, 14, 30, 50, 100, 200, 365]);
|
|
41
|
+
if (m) out.push({
|
|
42
|
+
key: `streak:${m}`, weight: 1000 + m,
|
|
43
|
+
message: `${m}-day streak — a personal record. Drop a live badge in your README: \`claude-rpc badge --metric streak --gist\``,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sessions = agg.sessions || 0;
|
|
48
|
+
const s = reached(sessions, [50, 100, 250, 500, 1000, 2500, 5000, 10000]);
|
|
49
|
+
if (s) out.push({
|
|
50
|
+
key: `sessions:${s}`, weight: s / 50,
|
|
51
|
+
message: `${fmt(s)} Claude Code sessions logged. Show it off: \`claude-rpc card --range all --out claude.svg\` (or --gist for a live one).`,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const hours = Math.floor((agg.activeMs || 0) / 3_600_000);
|
|
55
|
+
const h = reached(hours, [50, 100, 250, 500, 1000, 2000, 5000]);
|
|
56
|
+
if (h) out.push({
|
|
57
|
+
key: `hours:${h}`, weight: h,
|
|
58
|
+
message: `${fmt(h)}+ hours on Claude Code. Your year-in-review is ready — \`claude-rpc serve\` then open /wrapped and hit Share.`,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (!out.length) return null;
|
|
62
|
+
out.sort((a, b) => b.weight - a.weight);
|
|
63
|
+
return out[0];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readLastKey(path = NUDGE_STATE) {
|
|
67
|
+
try { return JSON.parse(readFileSync(path, 'utf8')).key || null; }
|
|
68
|
+
catch { return null; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function writeLastKey(key, path = NUDGE_STATE) {
|
|
72
|
+
try {
|
|
73
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
74
|
+
writeFileSync(path, JSON.stringify({ key, ts: Date.now() }));
|
|
75
|
+
} catch { /* best-effort */ }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Resolve a nudge to print right now, honoring the config gate and once-only
|
|
79
|
+
// dedup. Returns a string to print, or null. Marks the nudge as shown.
|
|
80
|
+
export function maybeNudge(agg, config = {}, { path = NUDGE_STATE } = {}) {
|
|
81
|
+
if (config?.nudges?.enabled === false) return null;
|
|
82
|
+
const n = pickShareNudge(agg);
|
|
83
|
+
if (!n) return null;
|
|
84
|
+
if (n.key === readLastKey(path)) return null; // already shown this one
|
|
85
|
+
writeLastKey(n.key, path);
|
|
86
|
+
return n.message;
|
|
87
|
+
}
|
package/src/paths.js
CHANGED
|
@@ -16,6 +16,13 @@ export const ROOT = resolve(__dirname, '..');
|
|
|
16
16
|
// SEA exe) and dev mode (cloned repo, no node_modules wrapper).
|
|
17
17
|
export const IS_NPM_INSTALL = !IS_PACKAGED && /[\\/]node_modules[\\/]/i.test(ROOT);
|
|
18
18
|
|
|
19
|
+
// True when we were launched via `npx claude-rpc` — npm stages the package in
|
|
20
|
+
// its ephemeral `_npx/<hash>/node_modules/...` cache, which matches the
|
|
21
|
+
// node_modules test above but is DELETED when the process exits. A hook wired
|
|
22
|
+
// to the PATH-resolved `claude-rpc` bin would dangle, so setup must promote an
|
|
23
|
+
// npx run to a real global install before wiring anything. See install.js.
|
|
24
|
+
export const IS_NPX = IS_NPM_INSTALL && /[\\/]_npx[\\/]/i.test(ROOT);
|
|
25
|
+
|
|
19
26
|
// "Installed" covers both real distribution paths — config and runtime
|
|
20
27
|
// artifacts live outside the install tree so they survive package updates.
|
|
21
28
|
export const IS_INSTALLED = IS_PACKAGED || IS_NPM_INSTALL;
|
package/src/profile.js
CHANGED
|
@@ -150,7 +150,7 @@ export function renderProfileCard(aggregate, { handle = '', generatedAt = new Da
|
|
|
150
150
|
<!-- footer -->
|
|
151
151
|
<text x="40" y="${H - 18}"
|
|
152
152
|
font-family="JetBrains Mono, ui-monospace, monospace"
|
|
153
|
-
font-size="11" fill="${PALETTE.inkFaint}">${escapeXml(`best streak ${t.longestStreak}d · ≈${fmtCost(t.cost)} · claude-rpc
|
|
153
|
+
font-size="11" fill="${PALETTE.inkFaint}">${escapeXml(`best streak ${t.longestStreak}d · ≈${fmtCost(t.cost)} · claude-rpc.vercel.app`)}</text>
|
|
154
154
|
</svg>`;
|
|
155
155
|
}
|
|
156
156
|
|
package/src/scanner.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdirSync, readFileSync, statSync, existsSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
1
|
+
import { readdirSync, readFileSync, statSync, existsSync, writeFileSync, mkdirSync, renameSync, openSync, readSync, closeSync } from 'node:fs';
|
|
2
2
|
import { join, dirname, basename } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { CLAUDE_PROJECTS, SCAN_CACHE_PATH, AGGREGATE_PATH, DATA_DIR, EVENTS_LOG_PATH } from './paths.js';
|
|
@@ -35,6 +35,33 @@ function hourKey(ts) {
|
|
|
35
35
|
return new Date(ts).getHours();
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// Reject malformed or implausible transcript timestamps before they poison
|
|
39
|
+
// firstTs/lastTs (which drive wallMs) and the day/week/hour buckets. A NaN from
|
|
40
|
+
// a bad string, a year-0 epoch artifact, or a far-future entry from a skewed
|
|
41
|
+
// clock would otherwise inflate lifetime totals. Floor: before Claude Code
|
|
42
|
+
// could plausibly exist. Ceiling: now + a generous clock-skew margin.
|
|
43
|
+
const TS_FLOOR = Date.UTC(2020, 0, 1);
|
|
44
|
+
const TS_SKEW_MS = 48 * 60 * 60 * 1000;
|
|
45
|
+
function parseTs(raw) {
|
|
46
|
+
if (!raw) return null;
|
|
47
|
+
const t = Date.parse(raw);
|
|
48
|
+
if (!Number.isFinite(t)) return null;
|
|
49
|
+
if (t < TS_FLOOR || t > Date.now() + TS_SKEW_MS) return null;
|
|
50
|
+
return t;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Calendar day index (whole days since the Unix epoch) anchored at UTC noon.
|
|
54
|
+
// Subtracting two of these always yields an exact number of calendar days —
|
|
55
|
+
// immune to DST, where subtracting two local-midnight Dates gives a 23h or 25h
|
|
56
|
+
// span that Math.floor/Math.round can turn into an off-by-one day.
|
|
57
|
+
function dayNum(y, mZeroBased, d) {
|
|
58
|
+
return Math.floor(Date.UTC(y, mZeroBased, d, 12) / 86_400_000);
|
|
59
|
+
}
|
|
60
|
+
function dayKeyNum(key) {
|
|
61
|
+
const [y, m, d] = key.split('-').map(Number);
|
|
62
|
+
return dayNum(y, m - 1, d);
|
|
63
|
+
}
|
|
64
|
+
|
|
38
65
|
const EDITING_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
|
|
39
66
|
|
|
40
67
|
// First non-env token of a shell command. `FOO=bar git status` → `git`.
|
|
@@ -151,10 +178,40 @@ function collectFilePath(input = {}) {
|
|
|
151
178
|
return input.file_path || input.path || input.notebook_path || null;
|
|
152
179
|
}
|
|
153
180
|
|
|
181
|
+
// Iterate newline-delimited lines of a string without materializing the full
|
|
182
|
+
// `.split('\n')` array (a second full-size copy of the file). Peak overhead is
|
|
183
|
+
// one line slice instead of N strings — meaningful for multi-MB transcripts.
|
|
184
|
+
function* iterLines(raw) {
|
|
185
|
+
let start = 0;
|
|
186
|
+
let nl;
|
|
187
|
+
while ((nl = raw.indexOf('\n', start)) !== -1) {
|
|
188
|
+
yield raw.slice(start, nl);
|
|
189
|
+
start = nl + 1;
|
|
190
|
+
}
|
|
191
|
+
if (start < raw.length) yield raw.slice(start);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Read up to `maxBytes` from the head of a file without loading the whole
|
|
195
|
+
// thing. Used to pull the cwd from a transcript's first lines — reading a
|
|
196
|
+
// multi-MB transcript in full just to inspect its head was pure waste.
|
|
197
|
+
function readHead(path, maxBytes = 65536) {
|
|
198
|
+
let fd;
|
|
199
|
+
try {
|
|
200
|
+
fd = openSync(path, 'r');
|
|
201
|
+
const buf = Buffer.allocUnsafe(maxBytes);
|
|
202
|
+
const n = readSync(fd, buf, 0, maxBytes, 0);
|
|
203
|
+
return buf.toString('utf8', 0, n);
|
|
204
|
+
} finally {
|
|
205
|
+
if (fd !== undefined) {
|
|
206
|
+
try { closeSync(fd); } catch { /* already closed */ }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
154
211
|
// Parse a single transcript JSONL into a per-file summary.
|
|
155
212
|
export function parseTranscript(filePath) {
|
|
156
213
|
const raw = readFileSync(filePath, 'utf8');
|
|
157
|
-
const lines = raw
|
|
214
|
+
const lines = iterLines(raw);
|
|
158
215
|
const summary = {
|
|
159
216
|
sessionId: null,
|
|
160
217
|
project: null,
|
|
@@ -207,7 +264,7 @@ export function parseTranscript(filePath) {
|
|
|
207
264
|
summary.cwd = r.cwd;
|
|
208
265
|
summary.project = cleanProjectName(basename(r.cwd));
|
|
209
266
|
}
|
|
210
|
-
const ts =
|
|
267
|
+
const ts = parseTs(r.timestamp);
|
|
211
268
|
const day = ts ? dayKey(ts) : null;
|
|
212
269
|
const week = ts ? weekKey(ts) : null;
|
|
213
270
|
const hour = ts ? hourKey(ts) : null;
|
|
@@ -377,8 +434,9 @@ function readTranscriptCwd(path, mtimeMs) {
|
|
|
377
434
|
if (cached && cached.mtime === mtimeMs) return cached.cwd;
|
|
378
435
|
let cwd = null;
|
|
379
436
|
try {
|
|
380
|
-
|
|
381
|
-
for (const line of
|
|
437
|
+
let seen = 0;
|
|
438
|
+
for (const line of iterLines(readHead(path))) {
|
|
439
|
+
if (++seen > 25) break;
|
|
382
440
|
if (!line) continue;
|
|
383
441
|
const r = safeJson(line);
|
|
384
442
|
if (r?.cwd) { cwd = r.cwd; break; }
|
|
@@ -391,7 +449,22 @@ function readTranscriptCwd(path, mtimeMs) {
|
|
|
391
449
|
// Per-transcript token cache. Reading a multi-MB .jsonl on every push tick
|
|
392
450
|
// (4s) would be wasteful, so we only re-parse when the file's mtime has
|
|
393
451
|
// advanced since the last read.
|
|
394
|
-
const sessionTokenCache = new Map(); // path → { mtime, tokens }
|
|
452
|
+
const sessionTokenCache = new Map(); // path → { mtime, size, offset, tokens }
|
|
453
|
+
|
|
454
|
+
// Accumulate assistant-usage tokens from a chunk of complete JSONL lines.
|
|
455
|
+
function sumUsageLines(text, tokens) {
|
|
456
|
+
for (const line of iterLines(text)) {
|
|
457
|
+
if (!line) continue;
|
|
458
|
+
const r = safeJson(line);
|
|
459
|
+
if (!r || r.type !== 'assistant') continue;
|
|
460
|
+
const u = r.message?.usage;
|
|
461
|
+
if (!u) continue;
|
|
462
|
+
tokens.input += u.input_tokens || 0;
|
|
463
|
+
tokens.output += u.output_tokens || 0;
|
|
464
|
+
tokens.cacheRead += u.cache_read_input_tokens || 0;
|
|
465
|
+
tokens.cacheWrite += u.cache_creation_input_tokens || 0;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
395
468
|
|
|
396
469
|
// Sum input/output/cache tokens from a single transcript JSONL.
|
|
397
470
|
//
|
|
@@ -409,23 +482,44 @@ export function readSessionTokens(path) {
|
|
|
409
482
|
const cached = sessionTokenCache.get(path);
|
|
410
483
|
if (cached && cached.mtime === st.mtimeMs) return cached.tokens;
|
|
411
484
|
|
|
412
|
-
|
|
485
|
+
// Transcripts are append-only JSONL. If the file only grew since the last
|
|
486
|
+
// read, parse just the appended tail from the cached byte offset instead of
|
|
487
|
+
// re-reading the whole (growing) file on every 4s daemon tick. Anything else
|
|
488
|
+
// (shrunk/truncated/rewritten) falls back to a full re-read.
|
|
489
|
+
const canAppend = cached && st.size >= cached.size && cached.offset <= st.size;
|
|
490
|
+
const tokens = canAppend
|
|
491
|
+
? { ...cached.tokens }
|
|
492
|
+
: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
493
|
+
const startOffset = canAppend ? cached.offset : 0;
|
|
494
|
+
let newOffset = startOffset;
|
|
495
|
+
|
|
496
|
+
let fd;
|
|
413
497
|
try {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
498
|
+
fd = openSync(path, 'r');
|
|
499
|
+
const len = st.size - startOffset;
|
|
500
|
+
if (len > 0) {
|
|
501
|
+
const buf = Buffer.allocUnsafe(len);
|
|
502
|
+
const n = readSync(fd, buf, 0, len, startOffset);
|
|
503
|
+
const text = buf.toString('utf8', 0, n);
|
|
504
|
+
// Only consume through the last newline; a trailing partial line is left
|
|
505
|
+
// for a later read (offset stays before it). \n is single-byte ASCII so
|
|
506
|
+
// the boundary is exact even with multi-byte content in the lines.
|
|
507
|
+
const lastNl = text.lastIndexOf('\n');
|
|
508
|
+
if (lastNl !== -1) {
|
|
509
|
+
const complete = text.slice(0, lastNl + 1);
|
|
510
|
+
newOffset = startOffset + Buffer.byteLength(complete, 'utf8');
|
|
511
|
+
sumUsageLines(complete, tokens);
|
|
512
|
+
}
|
|
425
513
|
}
|
|
426
|
-
} catch {
|
|
514
|
+
} catch {
|
|
515
|
+
return null;
|
|
516
|
+
} finally {
|
|
517
|
+
if (fd !== undefined) {
|
|
518
|
+
try { closeSync(fd); } catch { /* already closed */ }
|
|
519
|
+
}
|
|
520
|
+
}
|
|
427
521
|
|
|
428
|
-
sessionTokenCache.set(path, { mtime: st.mtimeMs, tokens });
|
|
522
|
+
sessionTokenCache.set(path, { mtime: st.mtimeMs, size: st.size, offset: newOffset, tokens });
|
|
429
523
|
return tokens;
|
|
430
524
|
}
|
|
431
525
|
|
|
@@ -681,11 +775,12 @@ function aggregateFrom(cache) {
|
|
|
681
775
|
}
|
|
682
776
|
agg.bestDay = best;
|
|
683
777
|
|
|
684
|
-
// Days since first.
|
|
685
|
-
const
|
|
778
|
+
// Days since first — computed on DST-immune calendar day indices.
|
|
779
|
+
const now = new Date();
|
|
686
780
|
const today = new Date();
|
|
687
781
|
today.setHours(0, 0, 0, 0);
|
|
688
|
-
|
|
782
|
+
const todayNum = dayNum(now.getFullYear(), now.getMonth(), now.getDate());
|
|
783
|
+
agg.daysSinceFirst = todayNum - dayKeyNum(days[0]) + 1;
|
|
689
784
|
|
|
690
785
|
// Current streak: walk back from today.
|
|
691
786
|
const has = (offset) => {
|
|
@@ -704,9 +799,7 @@ function aggregateFrom(cache) {
|
|
|
704
799
|
let prev = null;
|
|
705
800
|
for (const k of days) {
|
|
706
801
|
if (prev) {
|
|
707
|
-
const
|
|
708
|
-
const d2 = new Date(k + 'T00:00:00');
|
|
709
|
-
const diff = Math.round((d2 - d1) / 86_400_000);
|
|
802
|
+
const diff = dayKeyNum(k) - dayKeyNum(prev);
|
|
710
803
|
run = diff === 1 ? run + 1 : 1;
|
|
711
804
|
} else {
|
|
712
805
|
run = 1;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
'XML': '#0060ac', 'Protobuf': '#888', 'LaTeX': '#3D6117', 'Text': '#888',
|
|
16
16
|
'reStructuredText': '#888', 'Lockfile': '#444', 'Gradle': '#02303a',
|
|
17
17
|
'Crystal': '#000100', 'Nim': '#ffc200', 'V': '#4f87c4', 'Objective-C': '#438eff',
|
|
18
|
-
'Objective-C++': '#6866fb', 'Sass': '#a53b70', 'Less': '#1d365d',
|
|
18
|
+
'Objective-C++': '#6866fb', 'Sass': '#a53b70', 'Less': '#1d365d',
|
|
19
19
|
'Scala': '#c22d40', 'Groovy': '#4298b8', 'Interface Builder': '#888', 'Env': '#888',
|
|
20
20
|
'Config': '#888', 'Git': '#f1502f',
|
|
21
21
|
};
|
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
// anim-stagger style helper
|
|
18
18
|
let _i = 0; const A = () => `style="--i:${_i++}"`;
|
|
19
19
|
|
|
20
|
+
// Landing-page CTA used by the finale share. The dashboard is served on
|
|
21
|
+
// localhost, so sharing `location.href` hands the recipient a dead link —
|
|
22
|
+
// the finale shares this public URL + a stats summary instead.
|
|
23
|
+
const LANDING = 'https://claude-rpc.vercel.app/?ref=wrapped';
|
|
24
|
+
let _shareText = ''; // built in the finale slide, consumed by wireFinale
|
|
25
|
+
|
|
20
26
|
// ── build the story slides from the wrapped payload ──────────
|
|
21
27
|
function buildSlides(d) {
|
|
22
28
|
const out = [];
|
|
@@ -105,6 +111,11 @@
|
|
|
105
111
|
|
|
106
112
|
// 11. finale summary
|
|
107
113
|
const cell = (k, v, cls) => `<div><div class="cellk">${k}</div><div class="cellv ${cls || ''}">${v}</div></div>`;
|
|
114
|
+
// Summary that travels with the share — stats + the public install link,
|
|
115
|
+
// so anyone who receives it knows what it is and where to get it.
|
|
116
|
+
_shareText = `My year on Claude Code: ${fmtHours(d.activeMs)} across ${fmtNum(d.sessions)} sessions · `
|
|
117
|
+
+ `${fmtNum(d.prompts)} prompts · ${fmtNum(d.tokens)} tokens · ${(d.longestStreak || 0)}d best streak.\n\n`
|
|
118
|
+
+ `Made with claude-rpc → ${LANDING}`;
|
|
108
119
|
S('ink', `
|
|
109
120
|
<div class="summary">
|
|
110
121
|
<div class="card pop" style="--i:0">
|
|
@@ -120,15 +131,15 @@
|
|
|
120
131
|
${cell('Lines', (d.linesNet >= 0 ? '+' : '−') + fmtNum(Math.abs(d.linesNet)), 'grass')}
|
|
121
132
|
${cell('Hotspot', d.hotspot ? esc(d.hotspot.name) : '—')}
|
|
122
133
|
</div>
|
|
123
|
-
<div class="foot">made with claude-rpc ·
|
|
134
|
+
<div class="foot">made with claude-rpc · claude-rpc.vercel.app</div>
|
|
124
135
|
</div>
|
|
125
136
|
<div class="actions">
|
|
126
137
|
<button class="btn primary" id="w-replay">↺ replay</button>
|
|
127
138
|
<a class="btn" id="w-poster" href="/api/card.svg?range=all" target="_blank">poster ↗</a>
|
|
128
|
-
<button class="btn" id="w-
|
|
139
|
+
<button class="btn" id="w-share">share ↗</button>
|
|
129
140
|
</div>
|
|
130
141
|
</div>
|
|
131
|
-
<div class="hint">screenshot the card
|
|
142
|
+
<div class="hint">screenshot the card, or tap share to spread your wrapped</div>`, 9_000_000); // last slide: effectively no auto-advance
|
|
132
143
|
|
|
133
144
|
return out;
|
|
134
145
|
}
|
|
@@ -220,11 +231,21 @@
|
|
|
220
231
|
function wireFinale() {
|
|
221
232
|
if (wired) return; wired = true;
|
|
222
233
|
const r = document.getElementById('w-replay');
|
|
223
|
-
const c = document.getElementById('w-
|
|
234
|
+
const c = document.getElementById('w-share');
|
|
224
235
|
if (r) r.onclick = (e) => { e.stopPropagation(); go(0); };
|
|
225
236
|
if (c) c.onclick = (e) => {
|
|
226
237
|
e.stopPropagation();
|
|
227
|
-
|
|
238
|
+
const flash = (msg) => { c.textContent = msg; setTimeout(() => c.textContent = 'share ↗', 1600); };
|
|
239
|
+
// Native share sheet where available (mobile / modern browsers);
|
|
240
|
+
// fall back to copying the summary + install link to the clipboard.
|
|
241
|
+
if (navigator.share) {
|
|
242
|
+
navigator.share({ title: 'My Year on Claude Code', text: _shareText, url: LANDING })
|
|
243
|
+
.catch(() => {});
|
|
244
|
+
} else {
|
|
245
|
+
navigator.clipboard?.writeText(_shareText)
|
|
246
|
+
.then(() => flash('copied ✓'))
|
|
247
|
+
.catch(() => flash('copy failed'));
|
|
248
|
+
}
|
|
228
249
|
};
|
|
229
250
|
}
|
|
230
251
|
|
package/src/session-card.js
CHANGED
|
@@ -60,7 +60,7 @@ export function renderSessionCard(vars = {}, { generatedAt = new Date() } = {})
|
|
|
60
60
|
${statCell(C[1], 184, 'Est. cost', String(cost), PALETTE.blurple)}
|
|
61
61
|
${statCell(C[2], 184, 'Reads', String(v.filesRead ?? 0))}
|
|
62
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>
|
|
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.vercel.app · v${VERSION}`)}</text>
|
|
64
64
|
</svg>`;
|
|
65
65
|
}
|
|
66
66
|
|
package/src/state.js
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
writeFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
openSync,
|
|
8
|
+
closeSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
statSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { basename } from 'node:path';
|
|
3
13
|
import { STATE_PATH, STATE_DIR } from './paths.js';
|
|
4
14
|
|
|
5
15
|
const DEFAULT_STATE = {
|
|
@@ -46,6 +56,81 @@ function ensureDir() {
|
|
|
46
56
|
if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
|
|
47
57
|
}
|
|
48
58
|
|
|
59
|
+
// Claude Code fires lifecycle hooks in rapid bursts, and concurrent sessions /
|
|
60
|
+
// subagents mean several `claude-rpc hook` processes can run at once. Each does
|
|
61
|
+
// a read-modify-write of state.json; without a cross-process lock the last
|
|
62
|
+
// writer wins and the others' increments (messages, tools, tokens, file lists)
|
|
63
|
+
// are silently lost. The atomic tmp+rename in writeState only protects readers
|
|
64
|
+
// from torn files — it does nothing for lost updates. So updateState/resetState
|
|
65
|
+
// serialize through an exclusive lock file. The lock is strictly best-effort:
|
|
66
|
+
// if we can't get it within LOCK_MAX_WAIT_MS we proceed anyway (a slightly racy
|
|
67
|
+
// write is better than a dropped hook), and a stale lock from a crashed process
|
|
68
|
+
// is reclaimed after LOCK_STALE_MS.
|
|
69
|
+
const LOCK_PATH = STATE_PATH + '.lock';
|
|
70
|
+
const LOCK_STALE_MS = 2000;
|
|
71
|
+
const LOCK_RETRY_MS = 4;
|
|
72
|
+
const LOCK_MAX_WAIT_MS = 1000;
|
|
73
|
+
|
|
74
|
+
// Synchronous sleep — hooks are short-lived sync processes, so a blocking spin
|
|
75
|
+
// with a real wait (no busy-loop) is the right tool. Atomics.wait on a throwaway
|
|
76
|
+
// SharedArrayBuffer parks the thread without burning CPU.
|
|
77
|
+
function sleepSync(ms) {
|
|
78
|
+
try {
|
|
79
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
80
|
+
} catch {
|
|
81
|
+
/* SharedArrayBuffer unavailable — fall through, caller just retries sooner */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function acquireLock() {
|
|
86
|
+
const deadline = Date.now() + LOCK_MAX_WAIT_MS;
|
|
87
|
+
for (;;) {
|
|
88
|
+
try {
|
|
89
|
+
// 'wx' fails if the file exists — that's our mutex.
|
|
90
|
+
return openSync(LOCK_PATH, 'wx');
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (err.code !== 'EEXIST') return null; // unexpected (perms, etc.) — go lockless
|
|
93
|
+
try {
|
|
94
|
+
if (Date.now() - statSync(LOCK_PATH).mtimeMs > LOCK_STALE_MS) {
|
|
95
|
+
try {
|
|
96
|
+
unlinkSync(LOCK_PATH);
|
|
97
|
+
} catch {
|
|
98
|
+
/* someone else reclaimed it first */
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
/* lock vanished between open and stat — retry immediately */
|
|
104
|
+
}
|
|
105
|
+
if (Date.now() >= deadline) return null; // give up, proceed best-effort
|
|
106
|
+
sleepSync(LOCK_RETRY_MS);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function releaseLock(fd) {
|
|
112
|
+
if (fd === null) return;
|
|
113
|
+
try {
|
|
114
|
+
closeSync(fd);
|
|
115
|
+
} catch {
|
|
116
|
+
/* already closed */
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
unlinkSync(LOCK_PATH);
|
|
120
|
+
} catch {
|
|
121
|
+
/* already removed */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function withLock(fn) {
|
|
126
|
+
const fd = acquireLock();
|
|
127
|
+
try {
|
|
128
|
+
return fn();
|
|
129
|
+
} finally {
|
|
130
|
+
releaseLock(fd);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
49
134
|
export function readState() {
|
|
50
135
|
ensureDir();
|
|
51
136
|
if (!existsSync(STATE_PATH)) return { ...DEFAULT_STATE };
|
|
@@ -59,22 +144,29 @@ export function readState() {
|
|
|
59
144
|
|
|
60
145
|
export function writeState(next) {
|
|
61
146
|
ensureDir();
|
|
62
|
-
|
|
147
|
+
// Per-process tmp name: two processes writing the shared STATE_PATH + '.tmp'
|
|
148
|
+
// would clobber each other's tmp before rename. The pid suffix keeps the
|
|
149
|
+
// atomic-rename guarantee intact even on the best-effort lockless path.
|
|
150
|
+
const tmp = `${STATE_PATH}.${process.pid}.tmp`;
|
|
63
151
|
writeFileSync(tmp, JSON.stringify(next, null, 2));
|
|
64
152
|
renameSync(tmp, STATE_PATH);
|
|
65
153
|
}
|
|
66
154
|
|
|
67
155
|
export function updateState(mutator) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
156
|
+
return withLock(() => {
|
|
157
|
+
const current = readState();
|
|
158
|
+
const next = mutator({ ...current }) ?? current;
|
|
159
|
+
writeState(next);
|
|
160
|
+
return next;
|
|
161
|
+
});
|
|
72
162
|
}
|
|
73
163
|
|
|
74
164
|
export function resetState(seed = {}) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
165
|
+
return withLock(() => {
|
|
166
|
+
const fresh = { ...DEFAULT_STATE, sessionStart: Date.now(), lastActivity: Date.now(), ...seed };
|
|
167
|
+
writeState(fresh);
|
|
168
|
+
return fresh;
|
|
169
|
+
});
|
|
78
170
|
}
|
|
79
171
|
|
|
80
172
|
export function pushUnique(arr, value, max = 50) {
|