claude-rpc 0.16.2 → 0.17.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/SECURITY.md CHANGED
@@ -18,11 +18,12 @@ fetch-and-execute anywhere in `src/`.
18
18
  | --- | --- | --- | --- |
19
19
  | Startup persistence | `src/install.js` → `addStartupEntry` | `HKCU` Run key, current user, no admin | Yes — `claude-rpc uninstall` / `removeStartupEntry` |
20
20
  | Hook injection | `src/install.js` → `installHooks` | Only into Claude Code's own `settings.json`, only our own commands | Yes — `uninstallHooks` removes exactly what it added |
21
- | Outbound network | `src/community.js`, `src/gist.js`, `default-config.js` asset URLs | Anonymous counters + (opt-in) gist publish + GIF assets | Telemetry: `community off`. Gist: only on explicit `badge --gist`. |
22
- | Local subprocess | `reg.exe`, `git`, `gh` | Static args, no shell interpolation of untrusted input | n/a |
21
+ | Outbound network | `src/community.js`, `src/gist.js`, `src/usage.js`, `src/notify.js`, `default-config.js` | Anonymous counters + (opt-in) profile/gist/webhook + own read-only OAuth-usage poll + GIF assets | Telemetry: `community off`. Profile: `profile off`. Gist/webhook: opt-in only. Usage: `usage.enabled:false`. |
22
+ | Local subprocess | `reg.exe`, `wscript`, `git`, `gh`, `npm`, `claude`, `security`, notifiers | Static or escaped args, no shell interpolation of untrusted input | n/a |
23
23
 
24
- No credential access, no filesystem scanning outside `~/.claude-rpc` and Claude
25
- Code transcripts, no keylogging, no clipboard access, no AV/EDR evasion.
24
+ No credential access beyond the read-only Claude Code OAuth-token read for usage
25
+ polling (§3d), no filesystem scanning outside `~/.claude-rpc` and Claude Code
26
+ transcripts, no keylogging, no clipboard access, no AV/EDR evasion.
26
27
 
27
28
  ## 1. Startup persistence (Windows Run key)
28
29
 
@@ -53,9 +54,9 @@ and skips it).
53
54
 
54
55
  **Source:** `src/install.js`, `installHooks` / `uninstallHooks`.
55
56
 
56
- `setup` adds command hooks to Claude Code's `settings.json` for eight lifecycle
57
+ `setup` adds command hooks to Claude Code's `settings.json` for nine lifecycle
57
58
  events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`,
58
- `Stop`, `SubagentStop`, `Notification`, `SessionEnd`. Each entry looks like:
59
+ `Stop`, `SubagentStop`, `Notification`, `SessionEnd`, `PreCompact`. Each entry looks like:
59
60
 
60
61
  ```jsonc
61
62
  { "matcher": "", "hooks": [{ "type": "command", "command": "\"<exe>\" hook PostToolUse" }] }
@@ -78,9 +79,11 @@ events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`,
78
79
 
79
80
  ## 3. Outbound network
80
81
 
81
- There are five distinct network behaviors: community totals (3a), gist
82
- publishing (3b), squads/web login (3c), subscription-usage polling (3d), and
83
- the cosmetic GIF assets (3e). Each is independently optional.
82
+ There are six distinct network behaviors: community totals (3a), gist
83
+ publishing (3b), squads/web login (3c), subscription-usage polling (3d),
84
+ the cosmetic GIF assets (3e), and the opt-in status webhook (3f). Each is
85
+ independently optional. The separate desktop dashboard app, if installed,
86
+ additionally auto-updates itself (3g).
84
87
 
85
88
  ### 3a. Community totals (telemetry) — ON by default for fresh installs
86
89
 
@@ -144,6 +147,31 @@ Worker-side storage adds: `gh:<login>` → profile link, `squad:*` membership
144
147
  records, and weekly baseline snapshots (auto-expiring). Leaving your last
145
148
  squad deletes its record.
146
149
 
150
+ When the opt-in public profile is enabled (`profile on` + a handle), the daemon
151
+ also POSTs to `<endpoint>/profile` on the same 30-minute timer. Unlike the
152
+ anonymous 3a report, this one carries your chosen public identity. The
153
+ **complete** payload (`buildProfilePayload`, enforced by the worker's
154
+ `validateProfile`) is:
155
+
156
+ ```json
157
+ {
158
+ "instanceId": "<your local UUID>",
159
+ "handle": "ada",
160
+ "displayName": "Ada L.",
161
+ "githubUser": "ada",
162
+ "tokens": 142000000,
163
+ "sessions": 1200,
164
+ "activeMs": 360000000,
165
+ "streak": 23,
166
+ "version": "0.16.2",
167
+ "osFamily": "linux",
168
+ "ts": 1716500000000
169
+ }
170
+ ```
171
+
172
+ It sends absolute totals (not deltas) and is idempotent worker-side (a SET, not
173
+ an add). `profile off` stops it.
174
+
147
175
  ### 3d. Subscription usage — your own token, to its issuer, ON by default
148
176
 
149
177
  **Source:** `src/usage.js`; consumed by the daemon poll, `claude-rpc usage`,
@@ -180,20 +208,62 @@ are handed to Discord as image keys; **Discord's** client fetches them to render
180
208
  the card. The daemon itself doesn't download them. Swap them for your own URLs
181
209
  in `config.json` if you prefer.
182
210
 
211
+ ### 3f. Status webhook — opt-in, OFF by default
212
+
213
+ **Source:** `src/notify.js` (`postWebhook`), fired from the daemon's
214
+ `fireStatusSideEffects` (`src/daemon.js`). Dormant unless you set `webhook.url`
215
+ and list statuses in `webhook.on`. On a matching status transition the daemon
216
+ POSTs to your configured URL (a Slack/Discord channel or your own endpoint):
217
+
218
+ ```json
219
+ { "status": "notification", "project": "my-app", "model": "claude-opus-4-8", "justShipped": null, "ts": 1716500000000 }
220
+ ```
221
+
222
+ `project` is the cwd-derived name — redacted to `"Claude Code"` when the
223
+ directory is privacy=hidden, and run through `sanitizeLabel` (strips shell /
224
+ PowerShell metacharacters) first; `model` is always sent. The webhook is
225
+ suppressed entirely while the card is paused or privacy=hidden. Turn it off by
226
+ removing `webhook.url`.
227
+
228
+ ### 3g. Desktop dashboard auto-update — the optional Electron app only
229
+
230
+ **Source:** `dashboard/main.js` (`initAutoUpdater`, electron-updater). The npm
231
+ CLI package never auto-updates. The *separate* desktop dashboard app, if you
232
+ install it, polls GitHub Releases hourly and downloads + installs updates on
233
+ quit (`autoDownload` / `autoInstallOnAppQuit`) over HTTPS. The release binaries
234
+ are currently **unsigned**, so update integrity rests on GitHub Releases + TLS
235
+ rather than a code signature — a known gap tracked for provenance + published
236
+ checksums. Avoid it by not installing the dashboard.
237
+
183
238
  ## 4. Local subprocesses
184
239
 
185
- All `child_process` use is static-argument and visible:
240
+ Every binary the package can spawn, with its trigger and argument shape. All
241
+ arguments are static constants or values we control — none interpolate
242
+ untrusted or remote input into a shell:
186
243
 
187
- - `reg.exe add/delete` — the Run key above (`src/install.js`).
244
+ - `reg.exe add/delete` — the Windows Run key (`src/install.js`).
245
+ - `wscript.exe` — runs the generated windowless startup shim (`src/install.js`).
246
+ - `chcp.com 65001` — set the console to UTF-8 on Windows TTYs (`src/cli.js`).
188
247
  - `git` — read last commit subject / branch for the "just shipped" card
189
248
  (`src/git.js`).
190
249
  - `gh repo view --json isPrivate` — auto-hide GitHub-private repos from the card
191
250
  (`src/privacy.js`); 1.5s timeout, silent skip if `gh` is absent.
192
- - `gh gist` — only under 3b above.
193
-
194
- No subprocess interpolates untrusted/remote input into a shell. The one
195
- `shell: true` call (`verifyHookPipe`) uses only static, trusted args and is
196
- documented inline as such.
251
+ - `gh gist` / `gh --version` gist badge publishing, only under 3b
252
+ (`src/gist.js`).
253
+ - `npm root -g` / `npm install -g` resolve / promote the global install during
254
+ `setup` (`src/install.js`, `src/cli.js`).
255
+ - `claude mcp add/remove` — register / unregister the MCP server on
256
+ `mcp install` / `mcp uninstall` (`src/install.js`).
257
+ - `security find-generic-password` — read Claude Code's OAuth token from the
258
+ macOS login keychain for usage polling (`src/usage.js`, §3d). Read-only; may
259
+ prompt for keychain access.
260
+ - `osascript` / `powershell` / `notify-send` — the opt-in desktop notification
261
+ (`src/notify.js`); off unless `notify.enabled`. The project label is
262
+ interpolated but sanitized first (`sanitizeLabel`).
263
+
264
+ No subprocess passes untrusted or remote input to a shell — arguments are
265
+ static or escaped. The historical `shell: true` paths (`verifyHookPipe`, and the
266
+ gist `gh` wrapper on Windows) use only trusted args.
197
267
 
198
268
  ## 5. What it stores locally
199
269
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.16.2",
3
+ "version": "0.17.0",
4
4
  "description": "Discord Rich Presence for Claude Code — live model, project, tokens, and lifetime stats driven by Claude Code's hook system.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn, spawnSync } from 'node:child_process';
3
- import { readFileSync, writeFileSync, existsSync, watchFile, unlinkSync } from 'node:fs';
3
+ import { readFileSync, writeFileSync, existsSync, watchFile, unlinkSync, mkdirSync } from 'node:fs';
4
4
  import process from 'node:process';
5
5
 
6
6
  // Force the console code page to UTF-8 (65001) on Windows so Unicode box
@@ -15,7 +15,7 @@ import { readState } from './state.js';
15
15
  import { buildVars, fillTemplate, humanProject, humanTool, applyIdle, framePasses, fmtNum } from './format.js';
16
16
  import { scan, readAggregate, findLiveSessions, dayKey, weekKey } from './scanner.js';
17
17
  import { runHookCli } from './hook.js';
18
- import { install as runInstall, uninstall as runUninstall, isInstalled, migrateConfig, installHooks, ensureCanonicalExe, installMcp, uninstallMcp, mcpServerCommand, setupOutro } from './install.js';
18
+ import { install as runInstall, uninstall as runUninstall, isInstalled, migrateConfig, installHooks, ensureCanonicalExe, installMcp, uninstallMcp, setupOutro } from './install.js';
19
19
  import { startTui } from './tui.js';
20
20
  import { generateInsights } from './insights.js';
21
21
  import { maybeNudge, pickTodayMilestone } from './nudge.js';
@@ -30,7 +30,7 @@ import { VERSION } from './version.js';
30
30
  import { fail, tailLines, heat, sparkline, fmtDelta, topPercentile, EX_USER_ERROR, EX_BAD_STATE, EX_SYS_ERROR } from './ui.js';
31
31
  import { randomUUID } from 'node:crypto';
32
32
  import { createInterface } from 'node:readline';
33
- import { basename, join } from 'node:path';
33
+ import { basename, join, dirname } from 'node:path';
34
34
 
35
35
  const cmd = process.argv[2];
36
36
 
@@ -56,6 +56,29 @@ function readJson(path, fallback) {
56
56
  try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return fallback; }
57
57
  }
58
58
 
59
+ // Persist a mutated config.json. The config dir doesn't exist until `setup`
60
+ // runs (npm/npx install no install script), so a bare writeFileSync threw an
61
+ // uncaught ENOENT for any config-mutating command run before setup
62
+ // (`profile set`, `community on`, `link`, …). mkdirSync first.
63
+ function writeUserConfig(userCfg) {
64
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
65
+ writeUserConfig(userCfg);
66
+ }
67
+
68
+ // Validate a flag's value taken with `argv[++i]`. A value that's missing or
69
+ // itself looks like a flag (`--out --gist` → out='--gist') is almost always a
70
+ // forgotten argument, so fail loudly instead of silently doing the wrong thing.
71
+ // The `--flag=value` form stays the escape hatch for legit `-`-leading values.
72
+ function takeValue(v, flag) {
73
+ if (v === undefined || (typeof v === 'string' && v.startsWith('-'))) {
74
+ fail(`${flag} needs a value`, {
75
+ hint: v === undefined ? 'nothing followed it' : `got \`${v}\` — use ${flag}=<value> if the value really starts with "-"`,
76
+ code: EX_USER_ERROR,
77
+ });
78
+ }
79
+ return v;
80
+ }
81
+
59
82
  function isAlive(pid) {
60
83
  try { process.kill(pid, 0); return true; } catch { return false; }
61
84
  }
@@ -98,12 +121,23 @@ function stopDaemon({ quiet = false } = {}) {
98
121
  }
99
122
 
100
123
  function restartDaemon() {
101
- if (stopDaemon({ quiet: true })) {
102
- // wait briefly for the OS to release the pid file, then spawn fresh
103
- setTimeout(() => startDaemon(), 600);
104
- } else {
105
- startDaemon();
106
- }
124
+ if (!stopDaemon({ quiet: true })) { startDaemon(); return; }
125
+ // Poll for the old daemon to release the PID file rather than guessing with a
126
+ // fixed sleep — under load 600ms wasn't always enough, and startDaemon would
127
+ // then see it "still up" and no-op, leaving NO daemon running once the old
128
+ // one exited. Give up after ~3s, force-kill the wedged pid, and start anyway.
129
+ const deadline = Date.now() + 3000;
130
+ const tick = () => {
131
+ if (!daemonPid()) { startDaemon(); return; }
132
+ if (Date.now() >= deadline) {
133
+ const pid = daemonPid();
134
+ if (pid) { try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } }
135
+ startDaemon();
136
+ return;
137
+ }
138
+ setTimeout(tick, 50);
139
+ };
140
+ setTimeout(tick, 50);
107
141
  }
108
142
 
109
143
  // ── Box drawing — auto-widens to fit longest line ────────────────────────────
@@ -795,10 +829,10 @@ function parseBadgeArgs(argv) {
795
829
  const out = { metric: 'hours', range: '7d', out: '', gist: false };
796
830
  for (let i = 0; i < argv.length; i++) {
797
831
  const a = argv[i];
798
- if (a === '--metric' || a === '-m') out.metric = argv[++i];
799
- else if (a === '--range' || a === '-r') out.range = argv[++i];
800
- else if (a === '--out' || a === '-o') out.out = argv[++i];
801
- else if (a === '--label' || a === '-l') out.label = argv[++i];
832
+ if (a === '--metric' || a === '-m') out.metric = takeValue(argv[++i], '--metric');
833
+ else if (a === '--range' || a === '-r') out.range = takeValue(argv[++i], '--range');
834
+ else if (a === '--out' || a === '-o') out.out = takeValue(argv[++i], '--out');
835
+ else if (a === '--label' || a === '-l') out.label = takeValue(argv[++i], '--label');
802
836
  else if (a === '--gist') out.gist = true;
803
837
  }
804
838
  return out;
@@ -851,7 +885,7 @@ async function publishBadgeToGist(svg, opts) {
851
885
  filename,
852
886
  };
853
887
  if (stored.public !== undefined) userCfg.gist.public = stored.public;
854
- writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
888
+ writeUserConfig(userCfg);
855
889
  const wasUpdate = !!stored.id;
856
890
  console.log('');
857
891
  console.log(` ${c.green}✓${c.reset} ${wasUpdate ? 'updated' : 'created'} gist ${c.cyan}${result.id}${c.reset}`);
@@ -876,8 +910,8 @@ function parseCardArgs(argv) {
876
910
  const out = { range: 'year', out: '' };
877
911
  for (let i = 0; i < argv.length; i++) {
878
912
  const a = argv[i];
879
- if (a === '--range' || a === '-r') out.range = argv[++i];
880
- else if (a === '--out' || a === '-o') out.out = argv[++i];
913
+ if (a === '--range' || a === '-r') out.range = takeValue(argv[++i], '--range');
914
+ else if (a === '--out' || a === '-o') out.out = takeValue(argv[++i], '--out');
881
915
  }
882
916
  return out;
883
917
  }
@@ -906,8 +940,8 @@ function parseGithubStatArgs(argv) {
906
940
  const out = { out: '', gist: false, handle: '' };
907
941
  for (let i = 0; i < argv.length; i++) {
908
942
  const a = argv[i];
909
- if (a === '--out' || a === '-o') out.out = argv[++i];
910
- else if (a === '--handle' || a === '-u') out.handle = argv[++i];
943
+ if (a === '--out' || a === '-o') out.out = takeValue(argv[++i], '--out');
944
+ else if (a === '--handle' || a === '-u') out.handle = takeValue(argv[++i], '--handle');
911
945
  else if (a === '--gist') out.gist = true;
912
946
  }
913
947
  return out;
@@ -950,7 +984,7 @@ function liveVars() {
950
984
  function doStatusline(argv) {
951
985
  let tpl = '{statusVerbose} · {project} · {modelPretty}{tokensLabelPad}';
952
986
  for (let i = 0; i < argv.length; i++) {
953
- if (argv[i] === '--template' || argv[i] === '-t') tpl = argv[++i] || tpl;
987
+ if (argv[i] === '--template' || argv[i] === '-t') tpl = takeValue(argv[++i], '--template');
954
988
  }
955
989
  const { vars } = liveVars();
956
990
  vars.tokensLabelPad = vars.tokensLabel ? ` · ${vars.tokensLabel}` : '';
@@ -961,7 +995,7 @@ function doStatusline(argv) {
961
995
  async function doCalendar(argv) {
962
996
  const opts = { out: '', gist: false };
963
997
  for (let i = 0; i < argv.length; i++) {
964
- if (argv[i] === '--out' || argv[i] === '-o') opts.out = argv[++i];
998
+ if (argv[i] === '--out' || argv[i] === '-o') opts.out = takeValue(argv[++i], '--out');
965
999
  else if (argv[i] === '--gist') opts.gist = true;
966
1000
  }
967
1001
  const aggregate = readAggregate();
@@ -979,7 +1013,7 @@ async function doCalendar(argv) {
979
1013
  async function doSessionCard(argv) {
980
1014
  const opts = { out: '' };
981
1015
  for (let i = 0; i < argv.length; i++) {
982
- if (argv[i] === '--out' || argv[i] === '-o') opts.out = argv[++i];
1016
+ if (argv[i] === '--out' || argv[i] === '-o') opts.out = takeValue(argv[++i], '--out');
983
1017
  }
984
1018
  const { vars } = liveVars();
985
1019
  const { renderSessionCard } = await import('./session-card.js');
@@ -1170,16 +1204,27 @@ function squadAuth() {
1170
1204
  code: EX_BAD_STATE,
1171
1205
  });
1172
1206
  }
1207
+ const netFail = (e) => fail(`couldn't reach the squads service: ${e.message}`, {
1208
+ hint: 'check your connection and retry — this never blocks Claude Code',
1209
+ code: EX_SYS_ERROR,
1210
+ });
1173
1211
  const post = async (path, body) => {
1174
- const res = await fetch(endpoint + path, {
1175
- method: 'POST',
1176
- headers: { 'Content-Type': 'application/json' },
1177
- body: JSON.stringify({ instanceId, ...body }),
1178
- });
1212
+ let res;
1213
+ try {
1214
+ res = await fetch(endpoint + path, {
1215
+ method: 'POST',
1216
+ headers: { 'Content-Type': 'application/json' },
1217
+ body: JSON.stringify({ instanceId, ...body }),
1218
+ signal: AbortSignal.timeout(10_000),
1219
+ });
1220
+ } catch (e) { return netFail(e); }
1179
1221
  return { status: res.status, json: await res.json().catch(() => ({})) };
1180
1222
  };
1181
1223
  const get = async (path) => {
1182
- const res = await fetch(endpoint + path);
1224
+ let res;
1225
+ try {
1226
+ res = await fetch(endpoint + path, { signal: AbortSignal.timeout(10_000) });
1227
+ } catch (e) { return netFail(e); }
1183
1228
  return { status: res.status, json: await res.json().catch(() => ({})) };
1184
1229
  };
1185
1230
  return { cfg, endpoint, instanceId, post, get };
@@ -1334,7 +1379,7 @@ async function doLink(argv) {
1334
1379
  // Mirror the verified identity locally so `profile status` agrees.
1335
1380
  const userCfg = readJson(CONFIG_PATH, {});
1336
1381
  userCfg.profile = { ...(userCfg.profile || {}), githubUser: r.json.githubUser, verified: true };
1337
- writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1382
+ writeUserConfig(userCfg);
1338
1383
  console.log(` ${c.green}✓${c.reset} linked as ${c.cyan}@${r.json.githubUser}${c.reset} — profile verified, squads unlocked in the browser`);
1339
1384
  if (r.json.merged) {
1340
1385
  // This machine joined an existing identity: its stats now roll up under the
@@ -1422,7 +1467,7 @@ async function communityOn() {
1422
1467
  instanceId: userCfg.community?.instanceId || community.instanceId || randomUUID(),
1423
1468
  };
1424
1469
  userCfg.community = next;
1425
- writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1470
+ writeUserConfig(userCfg);
1426
1471
  console.log('');
1427
1472
  console.log(` ${c.green}✓${c.reset} community totals enabled`);
1428
1473
  console.log(` ${c.dim}id: …${next.instanceId.slice(-8)}${c.reset}`);
@@ -1437,7 +1482,7 @@ function communityOff() {
1437
1482
  return;
1438
1483
  }
1439
1484
  userCfg.community = { ...userCfg.community, enabled: false };
1440
- writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1485
+ writeUserConfig(userCfg);
1441
1486
  console.log(` ${c.green}✓${c.reset} community totals disabled ${c.dim}(instanceId retained for re-enable continuity)${c.reset}`);
1442
1487
  }
1443
1488
 
@@ -1465,7 +1510,10 @@ async function communityReport() {
1465
1510
  // daemon flush runs with profile.enabled + a valid handle (Phase 2).
1466
1511
  function readFlag(argv, name) {
1467
1512
  const i = argv.indexOf(`--${name}`);
1468
- if (i !== -1 && i + 1 < argv.length) return argv[i + 1];
1513
+ // Don't consume the next token as the value when it's itself a flag
1514
+ // (`profile set --handle --name x` must not save handle '--name'); fall
1515
+ // through to the --name=value form, which stays the escape hatch.
1516
+ if (i !== -1 && i + 1 < argv.length && !argv[i + 1].startsWith('-')) return argv[i + 1];
1469
1517
  const eq = argv.find((a) => a.startsWith(`--${name}=`));
1470
1518
  return eq ? eq.slice(name.length + 3) : undefined;
1471
1519
  }
@@ -1572,7 +1620,7 @@ function profileSet(argv) {
1572
1620
  }
1573
1621
 
1574
1622
  userCfg.profile = next;
1575
- writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1623
+ writeUserConfig(userCfg);
1576
1624
  // One-line confirmation + a pointer at the next step. The full dashboard
1577
1625
  // stays behind `claude-rpc profile` — mutations shouldn't re-render it.
1578
1626
  const saved = [];
@@ -1604,7 +1652,7 @@ function profileEnable(on) {
1604
1652
  }
1605
1653
  }
1606
1654
  userCfg.profile = next;
1607
- writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1655
+ writeUserConfig(userCfg);
1608
1656
  if (on) {
1609
1657
  console.log(` ${c.green}✓${c.reset} publishing enabled ${c.dim}— board syncs on the next flush, or now: ${c.reset}${c.cyan}claude-rpc profile publish${c.reset}`);
1610
1658
  profileNextStep();
@@ -1699,7 +1747,7 @@ async function profileVerify() {
1699
1747
  // profile checklist and future publishes match what got verified.
1700
1748
  const userCfg = readJson(CONFIG_PATH, {});
1701
1749
  userCfg.profile = { ...(userCfg.profile || {}), ...(who ? { githubUser: who } : {}), verified: true };
1702
- writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1750
+ writeUserConfig(userCfg);
1703
1751
  console.log(` ${c.green}✓${c.reset} verified as ${c.cyan}@${who}${c.reset} — you'll show the ✓ on the board`);
1704
1752
  if (who && profile.githubUser && who.toLowerCase() !== profile.githubUser.toLowerCase()) {
1705
1753
  console.log(` ${c.dim}(your gist is owned by @${who}, so the profile now uses that account)${c.reset}`);
@@ -1845,7 +1893,7 @@ function help() {
1845
1893
  ['start', 'Start the Discord RPC daemon (detached)'],
1846
1894
  ['stop', 'Stop the daemon'],
1847
1895
  ['restart', 'Stop then start the daemon'],
1848
- ['status', 'Print current session + all-time stats'],
1896
+ ['status', 'Interactive stats TUI; --dump (or piped) prints static text'],
1849
1897
  ['today', 'Focus view: today\'s stats + 24h activity histogram'],
1850
1898
  ['week', 'Focus view: this week, daily breakdown'],
1851
1899
  ['usage', 'Subscription limits — session + weekly % (what /usage shows)'],
@@ -1862,6 +1910,7 @@ function help() {
1862
1910
  ['calendar', 'Year activity heatmap SVG (--out --gist)'],
1863
1911
  ['session-card', 'Recap card for the current session (--out)'],
1864
1912
  ['mcp install', 'Wire the stats MCP server into Claude Code (one command)'],
1913
+ ['mcp uninstall', 'Remove the stats MCP server from Claude Code'],
1865
1914
  ['mcp', 'Run the MCP server (stdio) — exposes your stats to Claude'],
1866
1915
  ['wrapped', 'Open your animated year-in-review (Claude Wrapped)'],
1867
1916
  ['pause', 'Snooze the Discord card globally (pause [30m|2h], default 1h)'],
@@ -1903,6 +1952,14 @@ function help() {
1903
1952
  // Dev mode keeps the original `help` fallback so behavior is unchanged.
1904
1953
  const packagedDefault = IS_PACKAGED && !cmd;
1905
1954
 
1955
+ // Floor for any rejection that escapes a command handler (a bare `await fetch`
1956
+ // going offline, etc.): the rejected IIFE promise lands here instead of
1957
+ // printing a raw stack + an unhandled-rejection warning, keeping the documented
1958
+ // 0/1/2/3 exit-code contract intact.
1959
+ process.on('unhandledRejection', (e) => {
1960
+ fail(`unexpected error: ${e?.message || e}`, { code: EX_SYS_ERROR });
1961
+ });
1962
+
1906
1963
  // Wrapped in an async IIFE so the same source compiles cleanly under both
1907
1964
  // ESM (dev) and CommonJS (esbuild → SEA bundle) — CJS doesn't allow
1908
1965
  // top-level await.
package/src/community.js CHANGED
@@ -82,11 +82,11 @@ export function buildPayload(aggregate, cursor, { instanceId, now = Date.now() }
82
82
  }
83
83
 
84
84
  // ── leaderboard profile flush ──────────────────────────────────────────
85
- // Publishes the opt-in public profile (identity + server-validated usage
86
- // deltas) to the worker's /profile endpoint. Reuses the anonymous community
87
- // instanceId as the profile's row key, and its own cursor (which also tracks
88
- // activeMs). Same three guarantees as flushCommunity: never throws, sends only
89
- // the documented fields, never advances the cursor on a failed flush.
85
+ // Publishes the opt-in public profile (identity + lifetime totals) to the
86
+ // worker's /profile endpoint. Reuses the anonymous community instanceId as the
87
+ // profile's row key. Unlike flushCommunity this is cursor-free and idempotent
88
+ // it POSTs absolute totals, so the worker SETs (never accumulates) and a resend
89
+ // is a no-op. Same safety guarantees: never throws, sends only documented fields.
90
90
 
91
91
  function totalTokens(aggregate) {
92
92
  return (aggregate?.inputTokens || 0)
@@ -140,6 +140,7 @@ export async function flushProfile(cfg, {
140
140
  method: 'POST',
141
141
  headers: { 'Content-Type': 'application/json' },
142
142
  body: JSON.stringify(payload),
143
+ signal: AbortSignal.timeout(10_000), // bare fetch never times out; a hung peer would wedge the 30-min flush loop
143
144
  });
144
145
  } catch (e) {
145
146
  return { ok: false, reason: 'network', error: e.message };
@@ -182,6 +183,7 @@ export async function flushCommunity(cfg, {
182
183
  method: 'POST',
183
184
  headers: { 'Content-Type': 'application/json' },
184
185
  body: JSON.stringify(payload),
186
+ signal: AbortSignal.timeout(10_000), // bare fetch never times out; a hung peer would wedge the 30-min flush loop
185
187
  });
186
188
  } catch (e) {
187
189
  return { ok: false, reason: 'network', error: e.message };