claude-rpc 0.13.2 → 0.13.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.13.2",
3
+ "version": "0.13.4",
4
4
  "description": "Discord Rich Presence for Claude Code — live model, project, tokens, and lifetime stats driven by Claude Code's hook system.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.js CHANGED
@@ -1179,6 +1179,12 @@ async function profileVerify() {
1179
1179
  };
1180
1180
 
1181
1181
  try {
1182
+ // Make sure the profile row exists server-side before we verify it, so
1183
+ // verification works regardless of whether `profile publish` was run first.
1184
+ if (lb.profileIsPublishable(profile)) {
1185
+ const { flushProfile } = await import('./community.js');
1186
+ await flushProfile(cfg);
1187
+ }
1182
1188
  console.log(`${c.dim}requesting a verification token…${c.reset}`);
1183
1189
  const start = await post('/verify/start', { instanceId: community.instanceId, githubUser: profile.githubUser });
1184
1190
  if (!start.json?.token) return fail(`verify/start failed: ${start.json?.error || start.status}`, { code: EX_SYS_ERROR });
package/src/community.js CHANGED
@@ -26,7 +26,6 @@ import { VERSION } from './version.js';
26
26
  import { profileIsPublishable } from './leaderboard.js';
27
27
 
28
28
  const CURSOR_PATH = join(STATE_DIR, 'community-cursor.json');
29
- const PROFILE_CURSOR_PATH = join(STATE_DIR, 'profile-cursor.json');
30
29
 
31
30
  export function readCursor(path = CURSOR_PATH) {
32
31
  if (!existsSync(path)) return { sessions: 0, tokens: 0, ts: 0 };
@@ -54,6 +53,15 @@ export function osFamily() {
54
53
  return 'linux';
55
54
  }
56
55
 
56
+ // Per-report caps. These mirror the worker's validateReport limits — the
57
+ // client CLAMPS each delta to them so a large first-time backfill (a heavy
58
+ // user's whole lifetime total on the very first report) STREAMS over multiple
59
+ // flushes instead of being rejected. Without this, anyone with >5B lifetime
60
+ // tokens would 400 forever (the cursor never advances on a rejected report) and
61
+ // be silently dropped from the community totals.
62
+ const MAX_REPORT_SESSIONS = 100_000;
63
+ const MAX_REPORT_TOKENS = 5_000_000_000;
64
+
57
65
  // Pure: given an aggregate and a cursor, produce the next payload. The
58
66
  // worker's validateReport must accept this shape; if you add a field
59
67
  // here, add it there too.
@@ -65,8 +73,8 @@ export function buildPayload(aggregate, cursor, { instanceId, now = Date.now() }
65
73
  + (aggregate?.cacheWriteTokens || 0);
66
74
  return {
67
75
  instanceId,
68
- sessionsDelta: Math.max(0, sessions - (cursor.sessions || 0)),
69
- tokensDelta: Math.max(0, tokens - (cursor.tokens || 0)),
76
+ sessionsDelta: Math.min(MAX_REPORT_SESSIONS, Math.max(0, sessions - (cursor.sessions || 0))),
77
+ tokensDelta: Math.min(MAX_REPORT_TOKENS, Math.max(0, tokens - (cursor.tokens || 0))),
70
78
  version: VERSION,
71
79
  osFamily: osFamily(),
72
80
  ts: now,
@@ -87,25 +95,20 @@ function totalTokens(aggregate) {
87
95
  + (aggregate?.cacheWriteTokens || 0);
88
96
  }
89
97
 
90
- export function readProfileCursor(path = PROFILE_CURSOR_PATH) {
91
- const base = { sessions: 0, tokens: 0, activeMs: 0, ts: 0 };
92
- if (!existsSync(path)) return base;
93
- try { return { ...base, ...JSON.parse(readFileSync(path, 'utf8')) }; }
94
- catch { return base; }
95
- }
96
-
97
- export function buildProfilePayload(aggregate, profileCfg, cursor, { instanceId, now = Date.now() }) {
98
- const sessions = aggregate?.sessions || 0;
99
- const tokens = totalTokens(aggregate);
100
- const activeMs = aggregate?.activeMs || 0;
98
+ // A profile reports ABSOLUTE lifetime totals (not deltas). It's per-user and
99
+ // keyed by the instanceId, so the server just stores the latest value — no
100
+ // cursor, no double-count risk, and the board matches your real aggregate
101
+ // exactly. (Deltas were wrong here: the first publish carried the entire
102
+ // lifetime total, which blew past the per-report caps for any established user.)
103
+ export function buildProfilePayload(aggregate, profileCfg, { instanceId, now = Date.now() }) {
101
104
  return {
102
105
  instanceId,
103
106
  handle: profileCfg.handle,
104
107
  displayName: profileCfg.displayName || null,
105
108
  githubUser: profileCfg.githubUser || null,
106
- sessionsDelta: Math.max(0, sessions - (cursor.sessions || 0)),
107
- tokensDelta: Math.max(0, tokens - (cursor.tokens || 0)),
108
- activeMsDelta: Math.max(0, activeMs - (cursor.activeMs || 0)),
109
+ tokens: totalTokens(aggregate),
110
+ sessions: aggregate?.sessions || 0,
111
+ activeMs: aggregate?.activeMs || 0,
109
112
  streak: aggregate?.streak || 0,
110
113
  version: VERSION,
111
114
  osFamily: osFamily(),
@@ -115,7 +118,6 @@ export function buildProfilePayload(aggregate, profileCfg, cursor, { instanceId,
115
118
 
116
119
  export async function flushProfile(cfg, {
117
120
  aggregatePath = AGGREGATE_PATH,
118
- cursorPath = PROFILE_CURSOR_PATH,
119
121
  fetchImpl = globalThis.fetch,
120
122
  } = {}) {
121
123
  const profile = cfg?.profile || {};
@@ -130,9 +132,7 @@ export async function flushProfile(cfg, {
130
132
  try { aggregate = JSON.parse(readFileSync(aggregatePath, 'utf8')); }
131
133
  catch { return { ok: false, reason: 'unreadable-aggregate' }; }
132
134
 
133
- const cursor = readProfileCursor(cursorPath);
134
- const payload = buildProfilePayload(aggregate, profile, cursor, { instanceId });
135
-
135
+ const payload = buildProfilePayload(aggregate, profile, { instanceId });
136
136
  const url = community.endpoint.replace(/\/+$/, '') + '/profile';
137
137
  let res;
138
138
  try {
@@ -148,16 +148,7 @@ export async function flushProfile(cfg, {
148
148
  if (res.status === 429) return { ok: false, reason: 'rate-limited' };
149
149
  return { ok: false, reason: `http-${res.status}` };
150
150
  }
151
-
152
- // Advance the cursor only on acceptance (same reasoning as flushCommunity).
153
- writeCursor({
154
- sessions: (cursor.sessions || 0) + payload.sessionsDelta,
155
- tokens: (cursor.tokens || 0) + payload.tokensDelta,
156
- activeMs: (cursor.activeMs || 0) + payload.activeMsDelta,
157
- ts: payload.ts,
158
- }, cursorPath);
159
-
160
- return { ok: true, delta: { sessions: payload.sessionsDelta, tokens: payload.tokensDelta, activeMs: payload.activeMsDelta } };
151
+ return { ok: true, totals: { tokens: payload.tokens, sessions: payload.sessions, activeMs: payload.activeMs } };
161
152
  }
162
153
 
163
154
  // Single best-effort flush. Returns { ok, reason, delta? } — never throws.
package/src/daemon.js CHANGED
@@ -627,8 +627,8 @@ async function runCommunityFlush() {
627
627
  }
628
628
  if (config.profile?.enabled) {
629
629
  const pr = await flushProfile(config);
630
- if (pr.ok && pr.delta) {
631
- log(`profile: published @${config.profile.handle} (+${pr.delta.tokens} tokens)`);
630
+ if (pr.ok && pr.totals) {
631
+ log(`profile: published @${config.profile.handle} (${pr.totals.tokens} tokens)`);
632
632
  } else if (!pr.ok && pr.reason !== 'rate-limited' && pr.reason !== 'disabled') {
633
633
  log(`profile: ${pr.reason}${pr.error ? ' (' + pr.error + ')' : ''}`);
634
634
  }
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.13.2';
14
+ const BAKED = '0.13.4';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {