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/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
- // Best-effort native desktop notification. Never throws.
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 }).unref();
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 }).unref();
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 }).unref();
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
- // Fire-and-forget JSON POST. Never throws. Uses global fetch (Node 18+).
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
- // Should a status transition fire the webhook? Pure — exported for tests.
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 v${VERSION}`)}</text>
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.split('\n');
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 = r.timestamp ? new Date(r.timestamp).getTime() : null;
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
- const head = readFileSync(path, 'utf8').split('\n', 25);
381
- for (const line of head) {
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
- const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
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
- const raw = readFileSync(path, 'utf8');
415
- for (const line of raw.split('\n')) {
416
- if (!line) continue;
417
- const r = safeJson(line);
418
- if (!r || r.type !== 'assistant') continue;
419
- const u = r.message?.usage;
420
- if (!u) continue;
421
- tokens.input += u.input_tokens || 0;
422
- tokens.output += u.output_tokens || 0;
423
- tokens.cacheRead += u.cache_read_input_tokens || 0;
424
- tokens.cacheWrite += u.cache_creation_input_tokens || 0;
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 { return null; }
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 firstDay = new Date(days[0] + 'T00:00:00');
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
- agg.daysSinceFirst = Math.floor((today - firstDay) / 86_400_000) + 1;
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 d1 = new Date(prev + 'T00:00:00');
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', 'Vue': '#41b883',
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 · github.com/rar-file/claude-rpc</div>
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-copy">copy link</button>
139
+ <button class="btn" id="w-share">share ↗</button>
129
140
  </div>
130
141
  </div>
131
- <div class="hint">screenshot the card to share your wrapped</div>`, 9_000_000); // last slide: effectively no auto-advance
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-copy');
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
- navigator.clipboard?.writeText(location.href).then(() => { c.textContent = 'copied ✓'; setTimeout(() => c.textContent = 'copy link', 1500); }).catch(() => {});
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
 
@@ -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 { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
2
- import { dirname, basename } from 'node:path';
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
- const tmp = STATE_PATH + '.tmp';
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
- const current = readState();
69
- const next = mutator({ ...current }) ?? current;
70
- writeState(next);
71
- return next;
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
- const fresh = { ...DEFAULT_STATE, sessionStart: Date.now(), lastActivity: Date.now(), ...seed };
76
- writeState(fresh);
77
- return fresh;
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) {
package/src/version.js CHANGED
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { ROOT } from './paths.js';
13
13
 
14
- const BAKED = '0.12.1';
14
+ const BAKED = '0.13.2';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {