claude-rpc 0.6.3 → 0.7.1

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 CHANGED
@@ -10,6 +10,10 @@
10
10
  **Discord Rich Presence for [Claude Code](https://claude.com/claude-code).**
11
11
  Your live model, project, current tool, tokens, and lifetime stats — in your Discord profile. Driven by the hooks Claude Code already fires. Zero polling between sessions.
12
12
 
13
+ [![community · sessions](https://claude-rpc-totals.claude-rpc.workers.dev/sessions.svg)](#community-totals)   [![community · tokens](https://claude-rpc-totals.claude-rpc.workers.dev/tokens.svg)](#community-totals)
14
+
15
+ <sub>live — on by default for fresh installs, opt out any time. see [community totals](#community-totals)</sub>
16
+
13
17
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
14
18
  [![Node 18+](https://img.shields.io/badge/node-%3E%3D18-43853d.svg?logo=node.js&logoColor=white)](https://nodejs.org)
15
19
  [![Claude Code](https://img.shields.io/badge/Claude%20Code-hooks-d97757.svg)](https://claude.com/claude-code)
@@ -106,6 +110,7 @@ Shields-style badges and a poster-style summary card you can paste into a README
106
110
  ```sh
107
111
  claude-rpc badge --metric hours --range 7d --out claude-hours.svg
108
112
  claude-rpc badge --metric streak --out claude-streak.svg
113
+ claude-rpc badge --metric hours --gist # publish to a gist (live README badge)
109
114
  claude-rpc card --range year --out year-on-claude.svg
110
115
  ```
111
116
 
@@ -113,6 +118,8 @@ claude-rpc card --range year --out year-on-claude.svg
113
118
  <img src="site/examples/year-on-claude.svg" width="560" alt="Year-on-claude card — hours, prompts, tokens, lines, cost, daily activity strip" />
114
119
  </div>
115
120
 
121
+ `badge --gist` writes the SVG to your own GitHub gist (creates one on first run, updates it after — id remembered in `config.json`). The URL printed back is README-ready and updates every time you re-run the command. Uses `gh` if available, else `GH_TOKEN` with `gist` scope.
122
+
116
123
  Live equivalents when the daemon is up:
117
124
 
118
125
  - `http://127.0.0.1:47474/api/badge.svg?metric=hours&range=7d`
@@ -120,6 +127,19 @@ Live equivalents when the daemon is up:
120
127
 
121
128
  Cost numbers come from `src/pricing.js`, seeded with **approximate** public list prices. Your actual Claude Code subscription bill is unrelated.
122
129
 
130
+ ### community totals
131
+
132
+ The badges at the top of this README are live, served by a small Cloudflare Worker ([`worker/`](worker/)) that holds running totals of sessions and tokens across every install that's reporting. As of v0.7 **fresh installs are on by default** — `setup` mints an anonymous UUID v4 and the daemon starts flushing deltas every 30 minutes. Existing users upgrading from a pre-v0.7 config stay off until they explicitly run `community on` (the consent flow prints the exact payload first).
133
+
134
+ ```sh
135
+ claude-rpc community # show state + instanceId (last 8 chars)
136
+ claude-rpc community off # opt out; instanceId retained for re-enable continuity
137
+ claude-rpc community on # explicit consent flow (upgraders / re-enable)
138
+ claude-rpc community report # one-shot manual flush (testing)
139
+ ```
140
+
141
+ Each report sends only: a `sessionsDelta`, a `tokensDelta`, the claude-rpc version, OS family (`linux`/`darwin`/`win32`), and the anonymous UUID v4. No prompts, paths, models, repos, costs, usernames, or hostnames — the Worker's [`validateReport`](worker/src/index.js) is the schema of record. The full Worker source is in this repo so the privacy claim is auditable.
142
+
123
143
  ## three pieces, glued by json files
124
144
 
125
145
  ```
@@ -225,9 +245,10 @@ The full default config is in [`src/default-config.js`](src/default-config.js)
225
245
  | `scan` / `rescan`| Incremental / forced re-parse of `~/.claude/projects` |
226
246
  | `backfill <dir>` | Import transcripts from any folder (backup, other machine) |
227
247
  | `insights` | Print 3–5 auto-generated lines about your week |
228
- | `badge` | Shields-style SVG (`--metric` `--range` `--out`) |
248
+ | `badge` | Shields-style SVG (`--metric` `--range` `--out` `--gist`) |
229
249
  | `card` | Poster-style SVG (`--range year\|month\|week\|all`) |
230
250
  | `private` / `public` / `privacy` | Per-cwd visibility toggles + status |
251
+ | `community` | Opt-in community totals — `on` \| `off` \| `status` \| `report` |
231
252
  | `doctor` | Diagnostic checklist with one-line fix hints |
232
253
  | `tail` / `logs` | Tail the daemon log |
233
254
  | `daemon` | Run the daemon in the foreground (debugging) |
@@ -247,7 +268,7 @@ Exit codes: `0` ok · `1` user error · `2` system error · `3` wrong state. `--
247
268
  ## development
248
269
 
249
270
  ```sh
250
- npm test # 134 tests, ~1.7s
271
+ npm test # 200+ tests, ~1.7s
251
272
  npm run start # run daemon in foreground
252
273
  npm run serve # web dashboard against your real data
253
274
  npm run dashboard # Electron settings GUI (dev mode)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.6.3",
3
+ "version": "0.7.1",
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
@@ -24,6 +24,8 @@ import { addPrivateCwd, removePrivateCwd, listPrivateCwds, resolveVisibility } f
24
24
  import { loadConfig, hasUserConfig } from './config.js';
25
25
  import { VERSION } from './version.js';
26
26
  import { fail, EX_USER_ERROR, EX_BAD_STATE } from './ui.js';
27
+ import { randomUUID } from 'node:crypto';
28
+ import { createInterface } from 'node:readline';
27
29
  import { basename } from 'node:path';
28
30
 
29
31
  const cmd = process.argv[2];
@@ -641,24 +643,29 @@ function showInsights() {
641
643
  }
642
644
 
643
645
  function parseBadgeArgs(argv) {
644
- const out = { metric: 'hours', range: '7d', out: '' };
646
+ const out = { metric: 'hours', range: '7d', out: '', gist: false };
645
647
  for (let i = 0; i < argv.length; i++) {
646
648
  const a = argv[i];
647
649
  if (a === '--metric' || a === '-m') out.metric = argv[++i];
648
650
  else if (a === '--range' || a === '-r') out.range = argv[++i];
649
651
  else if (a === '--out' || a === '-o') out.out = argv[++i];
650
652
  else if (a === '--label' || a === '-l') out.label = argv[++i];
653
+ else if (a === '--gist') out.gist = true;
651
654
  }
652
655
  return out;
653
656
  }
654
657
 
655
- function doBadge(argv) {
658
+ async function doBadge(argv) {
656
659
  const opts = parseBadgeArgs(argv);
657
660
  const aggregate = readAggregate();
658
661
  if (!aggregate) {
659
662
  fail('no aggregate yet — nothing to render', { hint: 'run `claude-rpc scan` first', code: EX_BAD_STATE });
660
663
  }
661
664
  const svg = badgeSvg({ aggregate, metric: opts.metric, range: opts.range, label: opts.label });
665
+ if (opts.gist) {
666
+ await publishBadgeToGist(svg, opts);
667
+ return;
668
+ }
662
669
  if (opts.out) {
663
670
  writeFileSync(opts.out, svg);
664
671
  console.log(`${c.green}✓${c.reset} Wrote ${c.cyan}${opts.out}${c.reset} (${svg.length} bytes)`);
@@ -667,6 +674,52 @@ function doBadge(argv) {
667
674
  }
668
675
  }
669
676
 
677
+ // Publish the rendered badge to a GitHub gist and emit the README-ready
678
+ // markdown snippet. First successful publish records id+owner in
679
+ // config.json so subsequent runs UPDATE that gist (raw URL stays stable).
680
+ async function publishBadgeToGist(svg, opts) {
681
+ const { publishGistFile, gistMarkdown } = await import('./gist.js');
682
+ const cfg = loadConfig();
683
+ const stored = cfg.gist || {};
684
+ const filename = stored.filename || 'claude.svg';
685
+ try {
686
+ const result = await publishGistFile({
687
+ svg,
688
+ filename,
689
+ description: `claude-rpc — ${opts.metric || 'hours'} (${opts.range || '7d'})`,
690
+ gistId: stored.id || undefined,
691
+ owner: stored.owner || undefined,
692
+ isPublic: stored.public !== false,
693
+ });
694
+ // Persist the resolved id+owner so the next `--gist` run does an EDIT.
695
+ // We merge into the user's config.json directly (not the merged-defaults)
696
+ // so the file stays minimal.
697
+ const userCfg = readJson(CONFIG_PATH, {});
698
+ userCfg.gist = {
699
+ ...(userCfg.gist || {}),
700
+ id: result.id,
701
+ owner: result.owner,
702
+ filename,
703
+ };
704
+ if (stored.public !== undefined) userCfg.gist.public = stored.public;
705
+ writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
706
+ const wasUpdate = !!stored.id;
707
+ console.log('');
708
+ console.log(` ${c.green}✓${c.reset} ${wasUpdate ? 'Updated' : 'Created'} gist ${c.cyan}${result.id}${c.reset}`);
709
+ console.log(` ${c.dim}raw:${c.reset} ${c.cyan}${result.rawUrl}${c.reset}`);
710
+ if (result.htmlUrl) console.log(` ${c.dim}gist:${c.reset} ${c.dim}${result.htmlUrl}${c.reset}`);
711
+ console.log('');
712
+ console.log(` ${c.dim}Paste into your README:${c.reset}`);
713
+ console.log(` ${gistMarkdown({ owner: result.owner, id: result.id, filename, label: 'Claude' })}`);
714
+ console.log('');
715
+ } catch (e) {
716
+ fail(`gist publish failed: ${e.message}`, {
717
+ hint: 'install gh (`gh auth login`) or set GH_TOKEN with `gist` scope',
718
+ code: EX_USER_ERROR,
719
+ });
720
+ }
721
+ }
722
+
670
723
  // Poster-style SVG card. Bigger sibling of `badge` — shareable summary
671
724
  // for a range (year / month / week / all-time). Output is SVG only;
672
725
  // screenshot or convert to PNG offline if needed.
@@ -745,6 +798,133 @@ function doPrivacy() {
745
798
  console.log('');
746
799
  }
747
800
 
801
+ // ── Community totals ─────────────────────────────────────────────────────
802
+ //
803
+ // `claude-rpc community` → show current state + endpoint
804
+ // `claude-rpc community on` → interactive consent flow, mint instanceId
805
+ // (used by pre-v0.7 upgraders; fresh installs
806
+ // already had setup mint the id)
807
+ // `claude-rpc community off` → flip the flag off; instanceId retained
808
+ // `claude-rpc community report` → one-shot manual flush (useful for testing)
809
+ //
810
+ // See src/community.js for the payload schema and worker/src/index.js
811
+ // for the receiving end. As of v0.7 this is on by default for fresh
812
+ // installs and preserved-off for pre-v0.7 upgraders (see migrateConfig).
813
+
814
+ function prompt(question) {
815
+ return new Promise((resolve) => {
816
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
817
+ rl.question(question, (answer) => { rl.close(); resolve(answer); });
818
+ });
819
+ }
820
+
821
+ function communityStatus() {
822
+ const cfg = loadConfig();
823
+ const community = cfg.community || {};
824
+ const on = !!community.enabled;
825
+ console.log('');
826
+ console.log(` ${c.bold}community totals${c.reset}`);
827
+ console.log(` ${c.dim}state: ${c.reset} ${on ? c.green + 'on' + c.reset : c.yellow + 'off' + c.reset}`);
828
+ console.log(` ${c.dim}endpoint: ${c.reset} ${community.endpoint || '(unset)'}`);
829
+ if (community.instanceId) {
830
+ console.log(` ${c.dim}id: ${c.reset} ${c.dim}…${community.instanceId.slice(-8)}${c.reset}`);
831
+ }
832
+ console.log('');
833
+ if (!on) {
834
+ console.log(` ${c.dim}enable with ${c.reset}${c.cyan}claude-rpc community on${c.reset}`);
835
+ } else {
836
+ console.log(` ${c.dim}disable with ${c.reset}${c.cyan}claude-rpc community off${c.reset}`);
837
+ }
838
+ console.log('');
839
+ }
840
+
841
+ async function communityOn() {
842
+ const cfg = loadConfig();
843
+ const community = cfg.community || {};
844
+ if (community.enabled) {
845
+ console.log(`${c.green}✓${c.reset} community totals are already enabled`);
846
+ return;
847
+ }
848
+ console.log('');
849
+ console.log(` ${c.bold}claude-rpc community totals${c.reset}`);
850
+ console.log('');
851
+ console.log(` ${c.dim}What gets sent (and only this):${c.reset}`);
852
+ console.log(` ${c.green}·${c.reset} sessions delta since the last report`);
853
+ console.log(` ${c.green}·${c.reset} tokens delta since the last report`);
854
+ console.log(` ${c.green}·${c.reset} claude-rpc version (${c.cyan}${VERSION}${c.reset})`);
855
+ console.log(` ${c.green}·${c.reset} OS family (${c.cyan}${process.platform}${c.reset})`);
856
+ console.log(` ${c.green}·${c.reset} anonymous instanceId (a fresh UUID v4)`);
857
+ console.log('');
858
+ console.log(` ${c.dim}What never leaves your machine:${c.reset}`);
859
+ console.log(` ${c.red}·${c.reset} prompts, file paths, models, repos, costs`);
860
+ console.log(` ${c.red}·${c.reset} usernames, hostnames, IPs (the worker stores none)`);
861
+ console.log('');
862
+ console.log(` ${c.dim}Endpoint:${c.reset} ${community.endpoint}`);
863
+ console.log(` ${c.dim}Source: ${c.reset} ${c.cyan}worker/src/index.js${c.reset} in the claude-rpc repo`);
864
+ console.log('');
865
+ const answer = (await prompt(` Enable? ${c.dim}[y/N]${c.reset} `)).trim();
866
+ if (!/^y(es)?$/i.test(answer)) {
867
+ console.log('');
868
+ console.log(` ${c.dim}cancelled.${c.reset}`);
869
+ console.log('');
870
+ return;
871
+ }
872
+ const userCfg = readJson(CONFIG_PATH, {});
873
+ const next = {
874
+ ...(userCfg.community || {}),
875
+ enabled: true,
876
+ instanceId: userCfg.community?.instanceId || community.instanceId || randomUUID(),
877
+ };
878
+ userCfg.community = next;
879
+ writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
880
+ console.log('');
881
+ console.log(` ${c.green}✓${c.reset} community totals enabled`);
882
+ console.log(` ${c.dim}id: …${next.instanceId.slice(-8)}${c.reset}`);
883
+ console.log(` ${c.dim}the daemon flushes every ${community.flushIntervalMin || 30} min${c.reset}`);
884
+ console.log('');
885
+ }
886
+
887
+ function communityOff() {
888
+ const userCfg = readJson(CONFIG_PATH, {});
889
+ if (!userCfg.community?.enabled) {
890
+ console.log(`${c.dim}community totals are already off.${c.reset}`);
891
+ return;
892
+ }
893
+ userCfg.community = { ...userCfg.community, enabled: false };
894
+ writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
895
+ console.log(`${c.green}✓${c.reset} community totals disabled. instanceId retained for re-enable continuity.`);
896
+ }
897
+
898
+ async function communityReport() {
899
+ const cfg = loadConfig();
900
+ if (!cfg.community?.enabled) {
901
+ fail('community totals are off', { hint: 'run `claude-rpc community on` first', code: EX_BAD_STATE });
902
+ }
903
+ const { flushCommunity } = await import('./community.js');
904
+ const result = await flushCommunity(cfg);
905
+ console.log('');
906
+ if (result.ok && result.delta) {
907
+ console.log(` ${c.green}✓${c.reset} reported ${c.cyan}+${result.delta.sessions} sessions${c.reset} ${c.cyan}+${result.delta.tokens} tokens${c.reset}`);
908
+ } else if (result.ok) {
909
+ console.log(` ${c.dim}${result.reason}${c.reset}`);
910
+ } else {
911
+ console.log(` ${c.yellow}↳${c.reset} flush did not complete ${c.dim}(${result.reason}${result.error ? ': ' + result.error : ''})${c.reset}`);
912
+ }
913
+ console.log('');
914
+ }
915
+
916
+ async function doCommunity(argv) {
917
+ const sub = (argv[0] || 'status').toLowerCase();
918
+ if (sub === 'on') return communityOn();
919
+ if (sub === 'off') return communityOff();
920
+ if (sub === 'status' || sub === '') return communityStatus();
921
+ if (sub === 'report' || sub === 'flush') return communityReport();
922
+ fail(`unknown community subcommand: ${sub}`, {
923
+ hint: 'try: community [status|on|off|report]',
924
+ code: EX_USER_ERROR,
925
+ });
926
+ }
927
+
748
928
  function tailLog() {
749
929
  if (!existsSync(LOG_PATH)) {
750
930
  console.log(`${c.yellow}No log yet at ${LOG_PATH}${c.reset}`);
@@ -842,8 +1022,8 @@ function overview() {
842
1022
 
843
1023
  function help() {
844
1024
  const cmds = [
845
- ['setup', 'Install Claude Code hooks (~/.claude/settings.json)'],
846
- ['uninstall', 'Remove Claude Code hooks'],
1025
+ ['setup', 'Install Claude Code hooks + Windows startup entry (~/.claude/settings.json)'],
1026
+ ['uninstall', 'Remove Claude Code hooks + Windows startup entry'],
847
1027
  ['upgrade-config', 'Re-run idempotent migrations on an existing config.json'],
848
1028
  ['start', 'Start the Discord RPC daemon (detached)'],
849
1029
  ['stop', 'Stop the daemon'],
@@ -857,11 +1037,12 @@ function help() {
857
1037
  ['rescan', 'Force re-parse every transcript (ignores cache)'],
858
1038
  ['backfill', 'Import transcripts from any folder (e.g. a backup)'],
859
1039
  ['insights', 'Auto-generated insights from your history'],
860
- ['badge', 'Render a Shields-style SVG (--metric --range --out)'],
1040
+ ['badge', 'Render a Shields-style SVG (--metric --range --out --gist)'],
861
1041
  ['card', 'Render a poster-style SVG summary (--range year|month|week|all)'],
862
1042
  ['private', 'Mark the current directory as private (hide from Discord)'],
863
1043
  ['public', 'Un-mark the current directory'],
864
1044
  ['privacy', 'Show resolved visibility for the current directory'],
1045
+ ['community', 'Opt in/out of anonymous community totals (on|off|status|report)'],
865
1046
  ['doctor', 'Run a diagnostic checklist — common-failure triage'],
866
1047
  ['tail', 'Tail the daemon log file'],
867
1048
  ['daemon', 'Run daemon in foreground (debug)'],
@@ -901,7 +1082,11 @@ const packagedDefault = IS_PACKAGED && !cmd;
901
1082
  case '--help':
902
1083
  case '-h':
903
1084
  case 'help': help(); break;
904
- case 'setup': await runInstall({ exePath: EXE_PATH || process.execPath, withStartup: false }); break;
1085
+ // `setup` and `install` are aliases as of v0.7: both register hooks AND
1086
+ // the Windows startup entry. Older behavior split them (setup = no
1087
+ // startup, install = with) but in practice users expect one command
1088
+ // to do everything. Non-Windows: addStartupEntry is a no-op + warning.
1089
+ case 'setup':
905
1090
  case 'install': await runInstall({ exePath: EXE_PATH || process.execPath }); break;
906
1091
  case 'uninstall': await runUninstall(); break;
907
1092
  case 'upgrade-config': migrateConfig(); break;
@@ -924,11 +1109,12 @@ const packagedDefault = IS_PACKAGED && !cmd;
924
1109
  case 'rescan': doScan(true); break;
925
1110
  case 'backfill': doBackfill(process.argv.slice(3)); break;
926
1111
  case 'insights': showInsights(); break;
927
- case 'badge': doBadge(process.argv.slice(3)); break;
1112
+ case 'badge': await doBadge(process.argv.slice(3)); break;
928
1113
  case 'card': await doCard(process.argv.slice(3)); break;
929
1114
  case 'private': doPrivate(); break;
930
1115
  case 'public': doPublic(); break;
931
1116
  case 'privacy': doPrivacy(); break;
1117
+ case 'community': await doCommunity(process.argv.slice(3)); break;
932
1118
  case 'doctor': {
933
1119
  const { runDoctor } = await import('./doctor.js');
934
1120
  process.exit(runDoctor());
@@ -0,0 +1,130 @@
1
+ // Community-totals client. On by default for fresh installs (setup mints
2
+ // the instanceId into the seeded config); existing users upgrading from a
3
+ // pre-v0.7 config keep the explicit-opt-in flow via `claude-rpc community
4
+ // on`. Reads aggregate.json + a small cursor file to compute counter
5
+ // DELTAs (not absolute values — the cursor moves forward as we report),
6
+ // then POSTs to the configured worker endpoint.
7
+ //
8
+ // Three guarantees this module owes the rest of the codebase:
9
+ //
10
+ // 1. Never throws. The daemon calls this from a setInterval and must
11
+ // not crash on a network burp or a malformed response. All failure
12
+ // modes resolve to `{ ok: false, reason }` and move on.
13
+ // 2. Never sends anything beyond the documented payload. No file paths,
14
+ // no prompts, no models, no cwd — the buildPayload function is the
15
+ // complete schema, and it's audited by the worker's validateReport.
16
+ // 3. Never advances the cursor on a failed flush. A 5xx today + a
17
+ // successful flush tomorrow still reports today's deltas.
18
+ //
19
+ // See worker/src/index.js for the receiving end.
20
+
21
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
22
+ import { join, dirname } from 'node:path';
23
+ import { platform } from 'node:os';
24
+ import { AGGREGATE_PATH, STATE_DIR } from './paths.js';
25
+ import { VERSION } from './version.js';
26
+
27
+ const CURSOR_PATH = join(STATE_DIR, 'community-cursor.json');
28
+
29
+ export function readCursor(path = CURSOR_PATH) {
30
+ if (!existsSync(path)) return { sessions: 0, tokens: 0, ts: 0 };
31
+ try { return { sessions: 0, tokens: 0, ts: 0, ...JSON.parse(readFileSync(path, 'utf8')) }; }
32
+ catch { return { sessions: 0, tokens: 0, ts: 0 }; }
33
+ }
34
+
35
+ export function writeCursor(c, path = CURSOR_PATH) {
36
+ try {
37
+ mkdirSync(dirname(path), { recursive: true });
38
+ writeFileSync(path, JSON.stringify(c, null, 2));
39
+ } catch {
40
+ // Cursor write failure is recoverable — the next flush will resend
41
+ // the same delta, which the worker accepts (we accumulate at the
42
+ // server, not de-dup on payload content).
43
+ }
44
+ }
45
+
46
+ export function osFamily() {
47
+ const p = platform();
48
+ if (p === 'win32') return 'win32';
49
+ if (p === 'darwin') return 'darwin';
50
+ // freebsd / openbsd / aix all collapse to 'linux' for telemetry
51
+ // — the worker only accepts the three canonical values.
52
+ return 'linux';
53
+ }
54
+
55
+ // Pure: given an aggregate and a cursor, produce the next payload. The
56
+ // worker's validateReport must accept this shape; if you add a field
57
+ // here, add it there too.
58
+ export function buildPayload(aggregate, cursor, { instanceId, now = Date.now() }) {
59
+ const sessions = aggregate?.sessions || 0;
60
+ const tokens = (aggregate?.inputTokens || 0)
61
+ + (aggregate?.outputTokens || 0)
62
+ + (aggregate?.cacheReadTokens || 0)
63
+ + (aggregate?.cacheWriteTokens || 0);
64
+ return {
65
+ instanceId,
66
+ sessionsDelta: Math.max(0, sessions - (cursor.sessions || 0)),
67
+ tokensDelta: Math.max(0, tokens - (cursor.tokens || 0)),
68
+ version: VERSION,
69
+ osFamily: osFamily(),
70
+ ts: now,
71
+ };
72
+ }
73
+
74
+ // Single best-effort flush. Returns { ok, reason, delta? } — never throws.
75
+ // Caller passes in the merged config so we can be tested without touching
76
+ // disk for config too.
77
+ export async function flushCommunity(cfg, {
78
+ aggregatePath = AGGREGATE_PATH,
79
+ cursorPath = CURSOR_PATH,
80
+ fetchImpl = globalThis.fetch,
81
+ } = {}) {
82
+ const community = cfg?.community || {};
83
+ if (!community.enabled) return { ok: false, reason: 'disabled' };
84
+ if (!community.instanceId) return { ok: false, reason: 'no-instance-id' };
85
+ if (!community.endpoint) return { ok: false, reason: 'no-endpoint' };
86
+ if (!existsSync(aggregatePath)) return { ok: false, reason: 'no-aggregate' };
87
+
88
+ let aggregate;
89
+ try { aggregate = JSON.parse(readFileSync(aggregatePath, 'utf8')); }
90
+ catch { return { ok: false, reason: 'unreadable-aggregate' }; }
91
+
92
+ const cursor = readCursor(cursorPath);
93
+ const payload = buildPayload(aggregate, cursor, { instanceId: community.instanceId });
94
+ if (payload.sessionsDelta === 0 && payload.tokensDelta === 0) {
95
+ return { ok: true, reason: 'no-delta' };
96
+ }
97
+
98
+ const url = community.endpoint.replace(/\/+$/, '') + '/report';
99
+ let res;
100
+ try {
101
+ res = await fetchImpl(url, {
102
+ method: 'POST',
103
+ headers: { 'Content-Type': 'application/json' },
104
+ body: JSON.stringify(payload),
105
+ });
106
+ } catch (e) {
107
+ return { ok: false, reason: 'network', error: e.message };
108
+ }
109
+ if (!res.ok) {
110
+ // 429 is rate-limit — not actually a failure, just "come back later".
111
+ if (res.status === 429) return { ok: false, reason: 'rate-limited' };
112
+ return { ok: false, reason: `http-${res.status}` };
113
+ }
114
+
115
+ // Only move the cursor on confirmed acceptance. If we crash between
116
+ // the response and the cursor write, the next flush resends — the
117
+ // worker accumulates blindly, so a duplicate would double-count.
118
+ // Rate-limiting on the worker side bounds the damage to one
119
+ // duplicate per minute per instance.
120
+ writeCursor({
121
+ sessions: (cursor.sessions || 0) + payload.sessionsDelta,
122
+ tokens: (cursor.tokens || 0) + payload.tokensDelta,
123
+ ts: payload.ts,
124
+ }, cursorPath);
125
+
126
+ return {
127
+ ok: true,
128
+ delta: { sessions: payload.sessionsDelta, tokens: payload.tokensDelta },
129
+ };
130
+ }
package/src/daemon.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { writeFileSync, existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
3
3
  import { Client } from '@xhayper/discord-rpc';
4
4
  import { readState } from './state.js';
5
- import { buildVars, fillTemplate, framePasses, applyIdle } from './format.js';
5
+ import { buildVars, fillTemplate, framePasses, applyIdle, applyShipped } from './format.js';
6
6
  import { scan, readAggregate, findLiveSessions, readSessionTokens } from './scanner.js';
7
7
  import { detectGithubUrl } from './git.js';
8
8
  import { applyPrivacy } from './privacy.js';
@@ -121,6 +121,9 @@ function buildActivity(opts = {}) {
121
121
  // see ongoing transcript activity, not just this daemon's hook state.
122
122
  state.liveSessions = opts.liveSessions || liveSessions;
123
123
  state = applyIdle(state, config);
124
+ // Shipped overlay sits on top of idle/working/thinking — but never over
125
+ // stale (we don't celebrate when Claude isn't running).
126
+ state = applyShipped(state, config);
124
127
 
125
128
  // Pull live session tokens from the transcript file. Claude Code's hook
126
129
  // payloads don't include usage data, so state.tokens from PostToolUse
@@ -250,6 +253,7 @@ async function pushPresence() {
250
253
  let resolved = readState();
251
254
  resolved.liveSessions = liveSessions;
252
255
  resolved = applyIdle(resolved, config);
256
+ resolved = applyShipped(resolved, config);
253
257
  // Privacy can convert any state into a "hidden" verdict — give it the
254
258
  // same treatment as hideWhenStale: a single clearActivity, deduped via
255
259
  // lastPayloadHash so we don't spam the IPC.
@@ -449,3 +453,29 @@ function refreshLiveSessions() {
449
453
  }
450
454
  refreshLiveSessions();
451
455
  setInterval(refreshLiveSessions, 30_000);
456
+
457
+ // Community-totals flush. Disabled by default; turns on via
458
+ // `claude-rpc community on`. Best-effort — flushCommunity swallows every
459
+ // failure mode, so a flaky endpoint or no network just means the deltas
460
+ // pile up locally until the next successful flush. Cadence is config-
461
+ // driven (`community.flushIntervalMin`, default 30 min).
462
+ async function runCommunityFlush() {
463
+ if (!config.community?.enabled) return;
464
+ try {
465
+ const { flushCommunity } = await import('./community.js');
466
+ const result = await flushCommunity(config);
467
+ if (result.ok && result.delta) {
468
+ log(`community: flushed +${result.delta.sessions} sessions, +${result.delta.tokens} tokens`);
469
+ } else if (!result.ok && result.reason !== 'rate-limited' && result.reason !== 'no-delta') {
470
+ // Don't spam the log for routine "nothing to send" cases.
471
+ log(`community: ${result.reason}${result.error ? ' (' + result.error + ')' : ''}`);
472
+ }
473
+ } catch (e) {
474
+ log('community flush threw:', e.message);
475
+ }
476
+ }
477
+ const communityFlushMs = Math.max(60_000, (config.community?.flushIntervalMin || 30) * 60 * 1000);
478
+ setInterval(runCommunityFlush, communityFlushMs);
479
+ // Initial flush after a short delay — gives the scan above a chance to
480
+ // build aggregate.json before we ask community.js to read it.
481
+ setTimeout(runCommunityFlush, 60_000);
@@ -25,11 +25,42 @@ export const DEFAULT_CONFIG = {
25
25
  // goes stale — your profile shows nothing instead of an "Away" frame.
26
26
  hideWhenStale: true,
27
27
  notificationWindowSec: 8,
28
+ // How long after a `git push` / `git commit` the card stays on the
29
+ // celebratory "Just shipped" frame before falling back to the
30
+ // underlying status. Set 0 to disable the overlay entirely.
31
+ shippedFrameSec: 60,
32
+ // `claude-rpc badge --gist` records id+owner here after a successful
33
+ // first publish so subsequent publishes UPDATE the same gist (the raw
34
+ // URL in your README stays stable). filename is the file inside the
35
+ // gist — change it only if you publish multiple badges to one gist.
36
+ gist: {
37
+ id: null,
38
+ owner: null,
39
+ filename: "claude.svg",
40
+ public: true,
41
+ },
42
+ // Community totals. On by default for fresh installs — `setup` mints an
43
+ // anonymous instanceId (UUID v4) into the freshly-seeded config so the
44
+ // daemon starts batching deltas immediately. Existing users upgrading
45
+ // from a version without this block keep their old behavior: migrateConfig
46
+ // writes `community.enabled: false` into their file, and the consent flow
47
+ // at `claude-rpc community on` is the only path to enable. Opt out at any
48
+ // time with `claude-rpc community off`. See worker/src/index.js for the
49
+ // receiving end and exactly what payload is accepted (the validator there
50
+ // is the schema of record).
51
+ community: {
52
+ enabled: true,
53
+ instanceId: null,
54
+ endpoint: "https://claude-rpc-totals.claude-rpc.workers.dev",
55
+ flushIntervalMin: 30,
56
+ },
28
57
  showElapsed: true,
29
58
  activityType: 0,
30
59
  statusAssets: {
31
60
  working: "https://cdn.qualit.ly/clawd-working-building.gif",
32
61
  thinking: "https://cdn.qualit.ly/clawd-working-typing.gif",
62
+ compacting: "https://cdn.qualit.ly/clawd-working-typing.gif",
63
+ shipped: "https://cdn.qualit.ly/clawd-working-building.gif",
33
64
  idle: "https://cdn.qualit.ly/clawd-sleeping.gif",
34
65
  stale: "https://cdn.qualit.ly/clawd-sleeping.gif",
35
66
  notification: "https://cdn.qualit.ly/clawd-notification.gif",
@@ -48,7 +79,7 @@ export const DEFAULT_CONFIG = {
48
79
  byStatus: {
49
80
  working: {
50
81
  details: "Working in {project}",
51
- state: "{currentToolPretty} · {currentFilePretty} · {tokensLabel}",
82
+ state: "{currentToolPretty} · {currentFilePretty} · {toolElapsed} · {tokensLabel}",
52
83
  largeImageText: "Working on a {fileLang} file",
53
84
  },
54
85
  thinking: {
@@ -56,6 +87,16 @@ export const DEFAULT_CONFIG = {
56
87
  state: "{modelPretty} · {messagesLabel} · {tokensLabel}",
57
88
  largeImageText: "Reasoning with {modelPretty}",
58
89
  },
90
+ compacting: {
91
+ details: "Compacting context in {project}",
92
+ state: "{modelPretty} · {messagesLabel}",
93
+ largeImageText: "Compacting · {compactTriggerLabel}",
94
+ },
95
+ shipped: {
96
+ details: "Just shipped in {project}",
97
+ state: "{lastCommit}",
98
+ largeImageText: "{justShippedLabel}",
99
+ },
59
100
  notification: {
60
101
  details: "Waiting on you · {project}",
61
102
  state: "{modelPretty} · {messagesLabel}",
@@ -84,6 +125,8 @@ export const DEFAULT_CONFIG = {
84
125
  statusIcons: {
85
126
  working: "working",
86
127
  thinking: "thinking",
128
+ compacting: "thinking",
129
+ shipped: "working",
87
130
  idle: "idle",
88
131
  notification: "",
89
132
  stale: "",
package/src/format.js CHANGED
@@ -51,6 +51,23 @@ function fmtDuration(ms) {
51
51
  return `${sec}s`;
52
52
  }
53
53
 
54
+ // Tighter formatter for the working-frame "tool has been running for X" var.
55
+ // Sub-minute: bare seconds. Sub-hour: decimal minutes (1.5min) up to 10,
56
+ // integer minutes thereafter. Hour+: "1h 5m" to match fmtDuration. The
57
+ // short forms keep the working frame readable on Discord's narrow rows
58
+ // — fmtDuration's "120m 0s" wraps awkwardly there.
59
+ function fmtToolElapsed(ms) {
60
+ if (!ms || ms < 0) return '';
61
+ const sec = Math.floor(ms / 1000);
62
+ if (sec < 60) return `${sec}s`;
63
+ const min = sec / 60;
64
+ if (min < 10) return `${min.toFixed(1)}min`;
65
+ if (sec < 3600) return `${Math.round(min)}min`;
66
+ const h = Math.floor(sec / 3600);
67
+ const m = Math.floor((sec % 3600) / 60);
68
+ return `${h}h ${m}m`;
69
+ }
70
+
54
71
  function fmtHours(ms) {
55
72
  if (!ms || ms < 0) return '0h';
56
73
  const hours = ms / 3_600_000;
@@ -118,6 +135,7 @@ function statusVerbose(status, currentToolPretty, idleMs) {
118
135
  switch (status) {
119
136
  case 'working': return currentToolPretty ? `Using ${currentToolPretty}` : 'Working';
120
137
  case 'thinking': return 'Thinking';
138
+ case 'compacting': return 'Compacting context';
121
139
  case 'notification': return 'Waiting on you';
122
140
  case 'idle': {
123
141
  if (idleMs && idleMs > 60_000) {
@@ -304,6 +322,29 @@ export function buildVars(state, config, aggregate) {
304
322
  ? Math.max(0, Date.now() - state.lastActivity)
305
323
  : 0;
306
324
 
325
+ // Tool-duration spotlight. Empty until the running tool has burned past
326
+ // a short threshold — quick Reads/Edits don't need a timer on the card,
327
+ // and showing it flickers as fast tools complete. Once it does exceed
328
+ // the threshold, "Bash · running tests · 2.5min" reads naturally.
329
+ const toolMs = (state.status === 'working' && state.toolStartedAt)
330
+ ? Math.max(0, Date.now() - state.toolStartedAt)
331
+ : 0;
332
+ const TOOL_ELAPSED_THRESHOLD_MS = 5_000;
333
+ const toolElapsed = toolMs >= TOOL_ELAPSED_THRESHOLD_MS ? fmtToolElapsed(toolMs) : '';
334
+
335
+ // Compaction vars — populated only while a compaction is in flight so
336
+ // the {compactDuration} suffix in the compacting template collapses
337
+ // away naturally otherwise (via fillTemplate's `·` collapse).
338
+ const compactMs = state.compactStartedAt
339
+ ? Math.max(0, Date.now() - state.compactStartedAt)
340
+ : 0;
341
+ const compactTrigger = state.compactTrigger || '';
342
+ const compactTriggerLabel = compactTrigger === 'manual'
343
+ ? 'manual compaction'
344
+ : compactTrigger === 'auto'
345
+ ? 'auto-compaction'
346
+ : 'context squeeze';
347
+
307
348
  const currentFilePretty = prettyFilePath(state.currentFile);
308
349
 
309
350
  // ── File / directory / language vars ──────────────────────────────────────
@@ -392,6 +433,31 @@ export function buildVars(state, config, aggregate) {
392
433
  // session lifecycle flag (for `requires` gating)
393
434
  sessionActive,
394
435
 
436
+ // ── Tool-duration spotlight (v0.7) ──────────────────────────
437
+ toolMs,
438
+ toolElapsed,
439
+
440
+ // ── Just-shipped (v0.7) ─────────────────────────────────────
441
+ justShippedKind: state.justShippedKind || '',
442
+ justShippedSubject: state.justShippedSubject || '',
443
+ justShippedBranch: state.justShippedBranch || '',
444
+ // Friendly headline for the largeImageText — "Pushed to main" or
445
+ // "Committed to feat/x". Falls back to a verb-only label when no
446
+ // branch is available (detached HEAD, sparse `.git`, etc.).
447
+ justShippedLabel: state.justShippedKind === 'push'
448
+ ? (state.justShippedBranch ? `Pushed to ${state.justShippedBranch}` : 'Pushed')
449
+ : state.justShippedKind === 'commit'
450
+ ? (state.justShippedBranch ? `Committed on ${state.justShippedBranch}` : 'Committed')
451
+ : '',
452
+ // {lastCommit} reads more naturally than {justShippedSubject} in user templates.
453
+ lastCommit: state.justShippedSubject || '',
454
+
455
+ // ── Compaction (v0.7) ───────────────────────────────────────
456
+ compactMs,
457
+ compactDuration: compactMs ? fmtDuration(compactMs) : '',
458
+ compactTrigger,
459
+ compactTriggerLabel,
460
+
395
461
  // concurrent / live
396
462
  concurrent,
397
463
  concurrentOther,
@@ -700,6 +766,24 @@ export function applyIdle(state, cfg = {}) {
700
766
  return state;
701
767
  }
702
768
 
769
+ // Promote status to 'shipped' for a brief celebratory window after a
770
+ // `git push` / `git commit` is observed. Called by the daemon AFTER
771
+ // applyIdle — stale/idle decisions still take precedence over the shipped
772
+ // overlay (we don't celebrate when Claude isn't running). The window is
773
+ // configurable via `shippedFrameSec` (default 60).
774
+ //
775
+ // Pure: returns a new state object when promoting, the input otherwise.
776
+ // The underlying `state.status` is untouched so the daemon falls back
777
+ // cleanly once the window expires.
778
+ export function applyShipped(state, cfg = {}) {
779
+ if (!state.justShipped) return state;
780
+ if (state.status === 'stale') return state;
781
+ const windowMs = Math.max(5_000, (cfg.shippedFrameSec ?? 60) * 1000);
782
+ const age = Date.now() - state.justShipped;
783
+ if (age < 0 || age > windowMs) return state;
784
+ return { ...state, status: 'shipped' };
785
+ }
786
+
703
787
  // True when `requires` (string or array of strings) all resolve to non-zero / non-empty.
704
788
  export function framePasses(frame, vars) {
705
789
  const req = frame.requires;
@@ -712,4 +796,4 @@ export function framePasses(frame, vars) {
712
796
  return true;
713
797
  }
714
798
 
715
- export { fmtNum, fmtDuration, fmtHours, humanModel, humanTool, humanProject, plural };
799
+ export { fmtNum, fmtDuration, fmtHours, fmtToolElapsed, humanModel, humanTool, humanProject, plural };
package/src/gist.js ADDED
@@ -0,0 +1,167 @@
1
+ // Publishes a single SVG file to a GitHub gist so a README can render it
2
+ // via raw.githubusercontent.com. Two paths:
3
+ //
4
+ // 1. The `gh` CLI (if installed + authed). This is the common terminal
5
+ // flow — no token plumbing required. We shell out to `gh gist create`
6
+ // / `gh gist edit` against a temp file.
7
+ // 2. The GitHub REST API directly, using GH_TOKEN / GITHUB_TOKEN. Falls
8
+ // through here when `gh` is missing — useful for CI cron jobs that
9
+ // can mint a fine-grained token but can't install gh.
10
+ //
11
+ // On create we capture { id, owner } and return them so cli.js can persist
12
+ // the linkage in config.json. Subsequent runs hit the EDIT path against
13
+ // the same gist, so the README markdown URL stays stable across updates.
14
+
15
+ import { spawnSync } from 'node:child_process';
16
+ import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+
20
+ // Extract { owner, id } from a "https://gist.github.com/<user>/<hash>"
21
+ // URL. Returns null on no match — callers throw with the raw output so
22
+ // debugging an unparseable gh response is straightforward.
23
+ export function parseGistUrl(url) {
24
+ if (!url || typeof url !== 'string') return null;
25
+ const m = url.match(/gist\.github\.com\/([^/\s]+)\/([0-9a-fA-F]+)/);
26
+ if (!m) return null;
27
+ return { owner: m[1], id: m[2] };
28
+ }
29
+
30
+ // Stable raw URL for a gist file — GitHub camo will cache + serve through
31
+ // this in a README image tag. The /raw/ path with no SHA resolves to the
32
+ // latest revision, so `claude-rpc badge --gist` runs always end up rendered
33
+ // without README edits.
34
+ export function rawGistUrl({ owner, id, filename }) {
35
+ return `https://gist.githubusercontent.com/${owner}/${id}/raw/${filename}`;
36
+ }
37
+
38
+ // Markdown snippet a user can paste into a README. The filename trailing
39
+ // the URL doubles as the alt-text/title hint.
40
+ export function gistMarkdown({ owner, id, filename, label = 'Claude' }) {
41
+ return `![${label}](${rawGistUrl({ owner, id, filename })})`;
42
+ }
43
+
44
+ export function hasGh() {
45
+ try {
46
+ const r = spawnSync('gh', ['--version'], { stdio: 'ignore' });
47
+ return r.status === 0;
48
+ } catch {
49
+ // gh missing entirely → spawn throws on some platforms instead of
50
+ // returning a non-zero status. Either signal means "not available".
51
+ return false;
52
+ }
53
+ }
54
+
55
+ function ghCreate(filePath, description, isPublic) {
56
+ const args = ['gist', 'create', filePath, '--desc', description];
57
+ if (isPublic) args.push('--public');
58
+ const r = spawnSync('gh', args, { encoding: 'utf8' });
59
+ if (r.status !== 0) {
60
+ throw new Error(`gh gist create failed: ${(r.stderr || r.stdout || '').trim()}`);
61
+ }
62
+ const out = (r.stdout || '').trim();
63
+ // gh prints assorted progress lines; the actual URL is the only token
64
+ // beginning with http(s)://.
65
+ const url = out.split(/\s+/).filter((s) => /^https?:\/\//.test(s)).pop() || out;
66
+ const parsed = parseGistUrl(url);
67
+ if (!parsed) throw new Error(`could not parse gist URL from gh output: ${out}`);
68
+ return { ...parsed, htmlUrl: url };
69
+ }
70
+
71
+ function ghEdit(gistId, filePath) {
72
+ const r = spawnSync('gh', ['gist', 'edit', gistId, filePath], { encoding: 'utf8' });
73
+ if (r.status !== 0) {
74
+ throw new Error(`gh gist edit failed: ${(r.stderr || r.stdout || '').trim()}`);
75
+ }
76
+ }
77
+
78
+ async function restCreate({ svg, filename, description, isPublic, token }) {
79
+ const res = await fetch('https://api.github.com/gists', {
80
+ method: 'POST',
81
+ headers: {
82
+ 'Authorization': `Bearer ${token}`,
83
+ 'Accept': 'application/vnd.github+json',
84
+ 'X-GitHub-Api-Version': '2022-11-28',
85
+ 'Content-Type': 'application/json',
86
+ },
87
+ body: JSON.stringify({
88
+ description,
89
+ public: !!isPublic,
90
+ files: { [filename]: { content: svg } },
91
+ }),
92
+ });
93
+ if (!res.ok) {
94
+ const body = await res.text().catch(() => '');
95
+ throw new Error(`POST /gists ${res.status}: ${body.slice(0, 200)}`);
96
+ }
97
+ const j = await res.json();
98
+ return { id: j.id, owner: j.owner?.login || '', htmlUrl: j.html_url };
99
+ }
100
+
101
+ async function restEdit({ svg, filename, gistId, token }) {
102
+ const res = await fetch(`https://api.github.com/gists/${gistId}`, {
103
+ method: 'PATCH',
104
+ headers: {
105
+ 'Authorization': `Bearer ${token}`,
106
+ 'Accept': 'application/vnd.github+json',
107
+ 'X-GitHub-Api-Version': '2022-11-28',
108
+ 'Content-Type': 'application/json',
109
+ },
110
+ body: JSON.stringify({ files: { [filename]: { content: svg } } }),
111
+ });
112
+ if (!res.ok) {
113
+ const body = await res.text().catch(() => '');
114
+ throw new Error(`PATCH /gists/${gistId} ${res.status}: ${body.slice(0, 200)}`);
115
+ }
116
+ }
117
+
118
+ // Publish (create or update) a single file in a gist. `gistId` + `owner`
119
+ // passed in => EDIT path; absent => CREATE path. Returns the resolved
120
+ // gist identity + raw URL the caller can put in a README.
121
+ export async function publishGistFile({
122
+ svg,
123
+ filename = 'claude.svg',
124
+ description = 'claude-rpc badge — autogenerated',
125
+ gistId,
126
+ owner,
127
+ isPublic = true,
128
+ token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN,
129
+ }) {
130
+ if (!svg || typeof svg !== 'string') throw new Error('publishGistFile: svg must be a non-empty string');
131
+ if (hasGh()) {
132
+ const dir = mkdtempSync(join(tmpdir(), 'claude-rpc-gist-'));
133
+ const path = join(dir, filename);
134
+ writeFileSync(path, svg);
135
+ try {
136
+ if (gistId) {
137
+ ghEdit(gistId, path);
138
+ return { id: gistId, owner: owner || '', filename, rawUrl: rawGistUrl({ owner: owner || '', id: gistId, filename }) };
139
+ }
140
+ const created = ghCreate(path, description, isPublic);
141
+ return {
142
+ id: created.id,
143
+ owner: created.owner,
144
+ filename,
145
+ htmlUrl: created.htmlUrl,
146
+ rawUrl: rawGistUrl({ owner: created.owner, id: created.id, filename }),
147
+ };
148
+ } finally {
149
+ rmSync(dir, { recursive: true, force: true });
150
+ }
151
+ }
152
+ if (!token) {
153
+ throw new Error('neither `gh` CLI nor GH_TOKEN/GITHUB_TOKEN available — run `gh auth login` or set GH_TOKEN');
154
+ }
155
+ if (gistId) {
156
+ await restEdit({ svg, filename, gistId, token });
157
+ return { id: gistId, owner: owner || '', filename, rawUrl: rawGistUrl({ owner: owner || '', id: gistId, filename }) };
158
+ }
159
+ const created = await restCreate({ svg, filename, description, isPublic, token });
160
+ return {
161
+ id: created.id,
162
+ owner: created.owner,
163
+ filename,
164
+ htmlUrl: created.htmlUrl,
165
+ rawUrl: rawGistUrl({ owner: created.owner, id: created.id, filename }),
166
+ };
167
+ }
package/src/git.js CHANGED
@@ -72,3 +72,47 @@ function lookup(cwd) {
72
72
  export function detectGithubUrl(cwd) { return lookup(cwd).github; }
73
73
  export function detectGitBranch(cwd) { return lookup(cwd).branch; }
74
74
  export function detectGitRepo(cwd) { return lookup(cwd).repo; }
75
+
76
+ // Last commit subject for the "just shipped" frame. Read on demand from
77
+ // `.git/COMMIT_EDITMSG` (written by `git commit` and left in place after).
78
+ // Falls back to the last line of `.git/logs/HEAD` when COMMIT_EDITMSG is
79
+ // missing — that file always reflects the most recent ref movement.
80
+ //
81
+ // Not cached on purpose: this is only called the moment a `git
82
+ // push`/`git commit` is detected, so we want the freshest possible value,
83
+ // and a cache hit might return the *previous* commit's subject if the user
84
+ // just made a new one.
85
+ export function detectLastCommitSubject(cwd, max = 80) {
86
+ if (!cwd) return '';
87
+ const gitDir = join(cwd, '.git');
88
+ if (!existsSync(gitDir)) return '';
89
+
90
+ // COMMIT_EDITMSG: the editor buffer from the most recent `git commit`.
91
+ // First non-blank, non-comment line is the subject.
92
+ try {
93
+ const raw = readFileSync(join(gitDir, 'COMMIT_EDITMSG'), 'utf8');
94
+ for (const line of raw.split(/\r?\n/)) {
95
+ const trimmed = line.trim();
96
+ if (!trimmed || trimmed.startsWith('#')) continue;
97
+ return trimmed.slice(0, max);
98
+ }
99
+ } catch { /* file may not exist on a freshly cloned repo */ }
100
+
101
+ // logs/HEAD line shape:
102
+ // <old> <new> Name <email> <ts> <tz>\t<action>: <subject>
103
+ // We split on the tab, take the action message, strip a leading
104
+ // "commit: " / "commit (initial): " / "merge ..." prefix.
105
+ try {
106
+ const raw = readFileSync(join(gitDir, 'logs', 'HEAD'), 'utf8');
107
+ const lines = raw.split(/\r?\n/).filter(Boolean);
108
+ if (!lines.length) return '';
109
+ const last = lines[lines.length - 1];
110
+ const tab = last.indexOf('\t');
111
+ if (tab < 0) return '';
112
+ let msg = last.slice(tab + 1).trim();
113
+ msg = msg.replace(/^(commit(?:\s+\([^)]+\))?:\s*)/, '');
114
+ return msg.slice(0, max);
115
+ } catch { /* no logs/HEAD — repo too young or .git/logs disabled */ }
116
+
117
+ return '';
118
+ }
package/src/hook.js CHANGED
@@ -2,8 +2,14 @@
2
2
  import { readFileSync, appendFileSync, existsSync, mkdirSync, statSync, renameSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
4
  import { updateState, resetState, pushUnique, shortFile } from './state.js';
5
+ import { detectLastCommitSubject, detectGitBranch } from './git.js';
5
6
  import { EVENTS_LOG_PATH } from './paths.js';
6
7
 
8
+ // Match the bash invocations we treat as "just shipped" — captures the
9
+ // verb (push / commit) for state.justShippedKind. Tolerates extra args,
10
+ // leading env vars, and chained commands ("git add . && git commit -m ...").
11
+ const GIT_SHIP_RE = /(?:^|[;&|]|\s)git\s+(push|commit)(?:\s|$)/;
12
+
7
13
  const EVENTS_LOG_ROTATE_BYTES = 5 * 1024 * 1024;
8
14
 
9
15
  function appendEvent(entry) {
@@ -81,6 +87,7 @@ export function processHookEvent(event, input = {}) {
81
87
  s.toolBreakdown[toolName] = (s.toolBreakdown[toolName] || 0) + 1;
82
88
  s.currentTool = toolName;
83
89
  s.currentFile = shortFile(file);
90
+ s.toolStartedAt = now;
84
91
  s.status = 'working';
85
92
  s.lastActivity = now;
86
93
  s.claudeClosed = false;
@@ -97,8 +104,26 @@ export function processHookEvent(event, input = {}) {
97
104
  const toolName = input.tool_name || input.toolName || '';
98
105
  const toolInput = input.tool_input || input.toolInput || {};
99
106
  const file = toolInput.file_path || toolInput.path || null;
107
+ // Just-shipped detection: any Bash command that contains `git push`
108
+ // or `git commit`. Capture cwd + branch + last commit subject NOW
109
+ // — by the time the daemon renders the next frame this info may be
110
+ // gone (Claude often `cd`s after a commit).
111
+ let shipKind = null;
112
+ let shipSubject = null;
113
+ let shipBranch = null;
114
+ if (toolName === 'Bash') {
115
+ const cmd = String(toolInput.command || '');
116
+ const m = cmd.match(GIT_SHIP_RE);
117
+ if (m) {
118
+ shipKind = m[1];
119
+ const shipCwd = input.cwd || process.cwd();
120
+ shipSubject = detectLastCommitSubject(shipCwd) || null;
121
+ shipBranch = detectGitBranch(shipCwd) || null;
122
+ }
123
+ }
100
124
  updateState((s) => {
101
125
  s.currentTool = null;
126
+ s.toolStartedAt = null;
102
127
  s.lastActivity = now;
103
128
  s.claudeClosed = false;
104
129
  if (!s.sessionStart) s.sessionStart = now;
@@ -106,6 +131,12 @@ export function processHookEvent(event, input = {}) {
106
131
  s.filesEdited = pushUnique(s.filesEdited, file);
107
132
  s.filesOpened = pushUnique(s.filesOpened, file);
108
133
  }
134
+ if (shipKind) {
135
+ s.justShipped = now;
136
+ s.justShippedKind = shipKind;
137
+ s.justShippedSubject = shipSubject;
138
+ s.justShippedBranch = shipBranch;
139
+ }
109
140
  const usage = input.tool_response?.usage || input.usage;
110
141
  if (usage) {
111
142
  s.tokens.input += usage.input_tokens || 0;
@@ -131,6 +162,39 @@ export function processHookEvent(event, input = {}) {
131
162
  appendEvent({ type: 'notification', ts: now, cwd: input.cwd || null });
132
163
  break;
133
164
  }
165
+ case 'PreCompact': {
166
+ // Compaction is mechanically distinct from "thinking" — the model is
167
+ // rewriting earlier context, not advancing a turn. Surface it as its
168
+ // own state so the card stops reading "Thinking…" for the 10-60s
169
+ // compactions can take on big sessions.
170
+ updateState((s) => {
171
+ s.status = 'compacting';
172
+ s.compactStartedAt = now;
173
+ s.compactTrigger = input.trigger || input.matcher || null;
174
+ s.currentTool = null;
175
+ s.currentFile = null;
176
+ s.lastActivity = now;
177
+ s.claudeClosed = false;
178
+ if (!s.sessionStart) s.sessionStart = now;
179
+ return s;
180
+ });
181
+ appendEvent({ type: 'precompact', ts: now, trigger: input.trigger || input.matcher || null, cwd: input.cwd || null });
182
+ break;
183
+ }
184
+ case 'PostCompact': {
185
+ // Compaction finished — clear the marker and drop to idle. The next
186
+ // hook (UserPromptSubmit / PreToolUse) will set the real next state.
187
+ updateState((s) => {
188
+ s.status = 'idle';
189
+ s.compactStartedAt = null;
190
+ s.compactTrigger = null;
191
+ s.lastActivity = now;
192
+ s.claudeClosed = false;
193
+ return s;
194
+ });
195
+ appendEvent({ type: 'postcompact', ts: now, cwd: input.cwd || null });
196
+ break;
197
+ }
134
198
  case 'SessionEnd': {
135
199
  // Authoritative "Claude Code is gone" signal — don't wait on the
136
200
  // staleSessionMin timeout. applyIdle short-circuits to stale when it
package/src/install.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  } from 'node:fs';
10
10
  import { dirname, join, resolve } from 'node:path';
11
11
  import { spawn, spawnSync } from 'node:child_process';
12
+ import { randomUUID } from 'node:crypto';
12
13
  import {
13
14
  CLAUDE_SETTINGS, CONFIG_PATH, USER_CONFIG_DIR, ROOT,
14
15
  HOOK_SCRIPT, IS_PACKAGED, IS_NPM_INSTALL,
@@ -211,8 +212,18 @@ export function seedConfig() {
211
212
  return false;
212
213
  }
213
214
  mkdirSync(USER_CONFIG_DIR, { recursive: true });
214
- writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2));
215
+ // Fresh install: mint an anonymous instanceId so community.enabled:true
216
+ // (the new default in v0.7) is immediately actionable — the daemon needs
217
+ // an id to actually flush. Users who want out: `claude-rpc community off`.
218
+ const seeded = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
219
+ if (seeded.community?.enabled && !seeded.community.instanceId) {
220
+ seeded.community.instanceId = randomUUID();
221
+ }
222
+ writeFileSync(CONFIG_PATH, JSON.stringify(seeded, null, 2));
215
223
  console.log(` config seeded → ${CONFIG_PATH}`);
224
+ if (seeded.community?.enabled && seeded.community.instanceId) {
225
+ console.log(` community totals on by default → opt out with \`claude-rpc community off\``);
226
+ }
216
227
  return true;
217
228
  }
218
229
 
@@ -279,6 +290,16 @@ export function migrateConfig() {
279
290
  added.push('presence.byStatus.thinking.state');
280
291
  }
281
292
 
293
+ // v0.7: community.enabled flipped to true in DEFAULT_CONFIG. For users
294
+ // upgrading from a version without a community block, we must NOT
295
+ // silently turn telemetry on — write an explicit `enabled: false` so
296
+ // the deep-merge in loadConfig sees their opt-out. They can run
297
+ // `claude-rpc community on` to consent.
298
+ if (!cfg.community) {
299
+ cfg.community = { enabled: false };
300
+ added.push('community (preserved-off)');
301
+ }
302
+
282
303
  if (added.length === 0) {
283
304
  console.log(` config up to date → ${CONFIG_PATH}`);
284
305
  return false;
package/src/state.js CHANGED
@@ -10,6 +10,23 @@ const DEFAULT_STATE = {
10
10
  status: 'idle',
11
11
  currentTool: null,
12
12
  currentFile: null,
13
+ // Set by PreCompact, cleared by PostCompact. While non-null the daemon
14
+ // renders the `compacting` frame so the card never reads "thinking"
15
+ // during a context squeeze (which is mechanically distinct from reasoning).
16
+ compactStartedAt: null,
17
+ compactTrigger: null,
18
+ // Set by PreToolUse, cleared by PostToolUse. format.js derives {toolElapsed}
19
+ // from this when the working tool has been running long enough to be
20
+ // worth surfacing (>5s by default — quick reads don't flicker on the card).
21
+ toolStartedAt: null,
22
+ // Set by PostToolUse when a `git push` or `git commit` is observed.
23
+ // format.applyShipped promotes status to 'shipped' for shippedFrameSec
24
+ // (default 60s) after this timestamp, so the card briefly celebrates
25
+ // a ship instead of immediately returning to "Working in <project>".
26
+ justShipped: null,
27
+ justShippedKind: null, // 'push' | 'commit'
28
+ justShippedSubject: null,
29
+ justShippedBranch: null,
13
30
  model: 'claude',
14
31
  cwd: process.cwd(),
15
32
  messages: 0,
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.6.3';
14
+ const BAKED = '0.7.1';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {