claude-rpc 0.6.2 → 0.7.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/README.md CHANGED
@@ -106,6 +106,7 @@ Shields-style badges and a poster-style summary card you can paste into a README
106
106
  ```sh
107
107
  claude-rpc badge --metric hours --range 7d --out claude-hours.svg
108
108
  claude-rpc badge --metric streak --out claude-streak.svg
109
+ claude-rpc badge --metric hours --gist # publish to a gist (live README badge)
109
110
  claude-rpc card --range year --out year-on-claude.svg
110
111
  ```
111
112
 
@@ -113,6 +114,8 @@ claude-rpc card --range year --out year-on-claude.svg
113
114
  <img src="site/examples/year-on-claude.svg" width="560" alt="Year-on-claude card — hours, prompts, tokens, lines, cost, daily activity strip" />
114
115
  </div>
115
116
 
117
+ `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.
118
+
116
119
  Live equivalents when the daemon is up:
117
120
 
118
121
  - `http://127.0.0.1:47474/api/badge.svg?metric=hours&range=7d`
@@ -120,6 +123,24 @@ Live equivalents when the daemon is up:
120
123
 
121
124
  Cost numbers come from `src/pricing.js`, seeded with **approximate** public list prices. Your actual Claude Code subscription bill is unrelated.
122
125
 
126
+ ### community totals (opt-in)
127
+
128
+ A small Cloudflare Worker ([`worker/`](worker/)) hosts running totals of sessions and tokens across every install that has opted in:
129
+
130
+ ![sessions](https://claude-rpc-totals.claude-rpc.workers.dev/sessions.svg)
131
+ ![tokens](https://claude-rpc-totals.claude-rpc.workers.dev/tokens.svg)
132
+
133
+ The opt-in is per-install and **off by default**:
134
+
135
+ ```sh
136
+ claude-rpc community # show state
137
+ claude-rpc community on # opt in (consent flow + prints exact payload)
138
+ claude-rpc community off # opt out
139
+ claude-rpc community report # one-shot manual flush (testing)
140
+ ```
141
+
142
+ Each report sends only: a `sessionsDelta`, a `tokensDelta`, the claude-rpc version, OS family (`linux`/`darwin`/`win32`), and an 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.
143
+
123
144
  ## three pieces, glued by json files
124
145
 
125
146
  ```
@@ -225,9 +246,10 @@ The full default config is in [`src/default-config.js`](src/default-config.js)
225
246
  | `scan` / `rescan`| Incremental / forced re-parse of `~/.claude/projects` |
226
247
  | `backfill <dir>` | Import transcripts from any folder (backup, other machine) |
227
248
  | `insights` | Print 3–5 auto-generated lines about your week |
228
- | `badge` | Shields-style SVG (`--metric` `--range` `--out`) |
249
+ | `badge` | Shields-style SVG (`--metric` `--range` `--out` `--gist`) |
229
250
  | `card` | Poster-style SVG (`--range year\|month\|week\|all`) |
230
251
  | `private` / `public` / `privacy` | Per-cwd visibility toggles + status |
252
+ | `community` | Opt-in community totals — `on` \| `off` \| `status` \| `report` |
231
253
  | `doctor` | Diagnostic checklist with one-line fix hints |
232
254
  | `tail` / `logs` | Tail the daemon log |
233
255
  | `daemon` | Run the daemon in the foreground (debugging) |
@@ -247,7 +269,7 @@ Exit codes: `0` ok · `1` user error · `2` system error · `3` wrong state. `--
247
269
  ## development
248
270
 
249
271
  ```sh
250
- npm test # 134 tests, ~1.7s
272
+ npm test # 200+ tests, ~1.7s
251
273
  npm run start # run daemon in foreground
252
274
  npm run serve # web dashboard against your real data
253
275
  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.2",
3
+ "version": "0.7.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
@@ -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,130 @@ function doPrivacy() {
745
798
  console.log('');
746
799
  }
747
800
 
801
+ // ── Community totals ─────────────────────────────────────────────────────
802
+ //
803
+ // `claude-rpc community` → show current opt-in state + endpoint
804
+ // `claude-rpc community on` → interactive consent flow, mint instanceId
805
+ // `claude-rpc community off` → flip the flag off; instanceId retained
806
+ // `claude-rpc community report` → one-shot manual flush (useful for testing)
807
+ //
808
+ // See src/community.js for the payload schema and worker/src/index.js
809
+ // for the receiving end. The opt-in is per-install — disabled by default.
810
+
811
+ function prompt(question) {
812
+ return new Promise((resolve) => {
813
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
814
+ rl.question(question, (answer) => { rl.close(); resolve(answer); });
815
+ });
816
+ }
817
+
818
+ function communityStatus() {
819
+ const cfg = loadConfig();
820
+ const community = cfg.community || {};
821
+ const on = !!community.enabled;
822
+ console.log('');
823
+ console.log(` ${c.bold}community totals${c.reset}`);
824
+ console.log(` ${c.dim}state: ${c.reset} ${on ? c.green + 'on' + c.reset : c.yellow + 'off' + c.reset}`);
825
+ console.log(` ${c.dim}endpoint: ${c.reset} ${community.endpoint || '(unset)'}`);
826
+ if (community.instanceId) {
827
+ console.log(` ${c.dim}id: ${c.reset} ${c.dim}…${community.instanceId.slice(-8)}${c.reset}`);
828
+ }
829
+ console.log('');
830
+ if (!on) {
831
+ console.log(` ${c.dim}enable with ${c.reset}${c.cyan}claude-rpc community on${c.reset}`);
832
+ } else {
833
+ console.log(` ${c.dim}disable with ${c.reset}${c.cyan}claude-rpc community off${c.reset}`);
834
+ }
835
+ console.log('');
836
+ }
837
+
838
+ async function communityOn() {
839
+ const cfg = loadConfig();
840
+ const community = cfg.community || {};
841
+ if (community.enabled) {
842
+ console.log(`${c.green}✓${c.reset} community totals are already enabled`);
843
+ return;
844
+ }
845
+ console.log('');
846
+ console.log(` ${c.bold}claude-rpc community totals${c.reset}`);
847
+ console.log('');
848
+ console.log(` ${c.dim}What gets sent (and only this):${c.reset}`);
849
+ console.log(` ${c.green}·${c.reset} sessions delta since the last report`);
850
+ console.log(` ${c.green}·${c.reset} tokens delta since the last report`);
851
+ console.log(` ${c.green}·${c.reset} claude-rpc version (${c.cyan}${VERSION}${c.reset})`);
852
+ console.log(` ${c.green}·${c.reset} OS family (${c.cyan}${process.platform}${c.reset})`);
853
+ console.log(` ${c.green}·${c.reset} anonymous instanceId (a fresh UUID v4)`);
854
+ console.log('');
855
+ console.log(` ${c.dim}What never leaves your machine:${c.reset}`);
856
+ console.log(` ${c.red}·${c.reset} prompts, file paths, models, repos, costs`);
857
+ console.log(` ${c.red}·${c.reset} usernames, hostnames, IPs (the worker stores none)`);
858
+ console.log('');
859
+ console.log(` ${c.dim}Endpoint:${c.reset} ${community.endpoint}`);
860
+ console.log(` ${c.dim}Source: ${c.reset} ${c.cyan}worker/src/index.js${c.reset} in the claude-rpc repo`);
861
+ console.log('');
862
+ const answer = (await prompt(` Enable? ${c.dim}[y/N]${c.reset} `)).trim();
863
+ if (!/^y(es)?$/i.test(answer)) {
864
+ console.log('');
865
+ console.log(` ${c.dim}cancelled.${c.reset}`);
866
+ console.log('');
867
+ return;
868
+ }
869
+ const userCfg = readJson(CONFIG_PATH, {});
870
+ const next = {
871
+ ...(userCfg.community || {}),
872
+ enabled: true,
873
+ instanceId: userCfg.community?.instanceId || community.instanceId || randomUUID(),
874
+ };
875
+ userCfg.community = next;
876
+ writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
877
+ console.log('');
878
+ console.log(` ${c.green}✓${c.reset} community totals enabled`);
879
+ console.log(` ${c.dim}id: …${next.instanceId.slice(-8)}${c.reset}`);
880
+ console.log(` ${c.dim}the daemon flushes every ${community.flushIntervalMin || 30} min${c.reset}`);
881
+ console.log('');
882
+ }
883
+
884
+ function communityOff() {
885
+ const userCfg = readJson(CONFIG_PATH, {});
886
+ if (!userCfg.community?.enabled) {
887
+ console.log(`${c.dim}community totals are already off.${c.reset}`);
888
+ return;
889
+ }
890
+ userCfg.community = { ...userCfg.community, enabled: false };
891
+ writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
892
+ console.log(`${c.green}✓${c.reset} community totals disabled. instanceId retained for re-enable continuity.`);
893
+ }
894
+
895
+ async function communityReport() {
896
+ const cfg = loadConfig();
897
+ if (!cfg.community?.enabled) {
898
+ fail('community totals are off', { hint: 'run `claude-rpc community on` first', code: EX_BAD_STATE });
899
+ }
900
+ const { flushCommunity } = await import('./community.js');
901
+ const result = await flushCommunity(cfg);
902
+ console.log('');
903
+ if (result.ok && result.delta) {
904
+ console.log(` ${c.green}✓${c.reset} reported ${c.cyan}+${result.delta.sessions} sessions${c.reset} ${c.cyan}+${result.delta.tokens} tokens${c.reset}`);
905
+ } else if (result.ok) {
906
+ console.log(` ${c.dim}${result.reason}${c.reset}`);
907
+ } else {
908
+ console.log(` ${c.yellow}↳${c.reset} flush did not complete ${c.dim}(${result.reason}${result.error ? ': ' + result.error : ''})${c.reset}`);
909
+ }
910
+ console.log('');
911
+ }
912
+
913
+ async function doCommunity(argv) {
914
+ const sub = (argv[0] || 'status').toLowerCase();
915
+ if (sub === 'on') return communityOn();
916
+ if (sub === 'off') return communityOff();
917
+ if (sub === 'status' || sub === '') return communityStatus();
918
+ if (sub === 'report' || sub === 'flush') return communityReport();
919
+ fail(`unknown community subcommand: ${sub}`, {
920
+ hint: 'try: community [status|on|off|report]',
921
+ code: EX_USER_ERROR,
922
+ });
923
+ }
924
+
748
925
  function tailLog() {
749
926
  if (!existsSync(LOG_PATH)) {
750
927
  console.log(`${c.yellow}No log yet at ${LOG_PATH}${c.reset}`);
@@ -857,11 +1034,12 @@ function help() {
857
1034
  ['rescan', 'Force re-parse every transcript (ignores cache)'],
858
1035
  ['backfill', 'Import transcripts from any folder (e.g. a backup)'],
859
1036
  ['insights', 'Auto-generated insights from your history'],
860
- ['badge', 'Render a Shields-style SVG (--metric --range --out)'],
1037
+ ['badge', 'Render a Shields-style SVG (--metric --range --out --gist)'],
861
1038
  ['card', 'Render a poster-style SVG summary (--range year|month|week|all)'],
862
1039
  ['private', 'Mark the current directory as private (hide from Discord)'],
863
1040
  ['public', 'Un-mark the current directory'],
864
1041
  ['privacy', 'Show resolved visibility for the current directory'],
1042
+ ['community', 'Opt in/out of anonymous community totals (on|off|status|report)'],
865
1043
  ['doctor', 'Run a diagnostic checklist — common-failure triage'],
866
1044
  ['tail', 'Tail the daemon log file'],
867
1045
  ['daemon', 'Run daemon in foreground (debug)'],
@@ -924,11 +1102,12 @@ const packagedDefault = IS_PACKAGED && !cmd;
924
1102
  case 'rescan': doScan(true); break;
925
1103
  case 'backfill': doBackfill(process.argv.slice(3)); break;
926
1104
  case 'insights': showInsights(); break;
927
- case 'badge': doBadge(process.argv.slice(3)); break;
1105
+ case 'badge': await doBadge(process.argv.slice(3)); break;
928
1106
  case 'card': await doCard(process.argv.slice(3)); break;
929
1107
  case 'private': doPrivate(); break;
930
1108
  case 'public': doPublic(); break;
931
1109
  case 'privacy': doPrivacy(); break;
1110
+ case 'community': await doCommunity(process.argv.slice(3)); break;
932
1111
  case 'doctor': {
933
1112
  const { runDoctor } = await import('./doctor.js');
934
1113
  process.exit(runDoctor());
@@ -0,0 +1,127 @@
1
+ // Opt-in community-totals client. Reads aggregate.json + a small cursor
2
+ // file to compute counter DELTAs (not absolute values — the cursor moves
3
+ // forward as we report), then POSTs to the configured worker endpoint.
4
+ //
5
+ // Three guarantees this module owes the rest of the codebase:
6
+ //
7
+ // 1. Never throws. The daemon calls this from a setInterval and must
8
+ // not crash on a network burp or a malformed response. All failure
9
+ // modes resolve to `{ ok: false, reason }` and move on.
10
+ // 2. Never sends anything beyond the documented payload. No file paths,
11
+ // no prompts, no models, no cwd — the buildPayload function is the
12
+ // complete schema, and it's audited by the worker's validateReport.
13
+ // 3. Never advances the cursor on a failed flush. A 5xx today + a
14
+ // successful flush tomorrow still reports today's deltas.
15
+ //
16
+ // See worker/src/index.js for the receiving end.
17
+
18
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
19
+ import { join, dirname } from 'node:path';
20
+ import { platform } from 'node:os';
21
+ import { AGGREGATE_PATH, STATE_DIR } from './paths.js';
22
+ import { VERSION } from './version.js';
23
+
24
+ const CURSOR_PATH = join(STATE_DIR, 'community-cursor.json');
25
+
26
+ export function readCursor(path = CURSOR_PATH) {
27
+ if (!existsSync(path)) return { sessions: 0, tokens: 0, ts: 0 };
28
+ try { return { sessions: 0, tokens: 0, ts: 0, ...JSON.parse(readFileSync(path, 'utf8')) }; }
29
+ catch { return { sessions: 0, tokens: 0, ts: 0 }; }
30
+ }
31
+
32
+ export function writeCursor(c, path = CURSOR_PATH) {
33
+ try {
34
+ mkdirSync(dirname(path), { recursive: true });
35
+ writeFileSync(path, JSON.stringify(c, null, 2));
36
+ } catch {
37
+ // Cursor write failure is recoverable — the next flush will resend
38
+ // the same delta, which the worker accepts (we accumulate at the
39
+ // server, not de-dup on payload content).
40
+ }
41
+ }
42
+
43
+ export function osFamily() {
44
+ const p = platform();
45
+ if (p === 'win32') return 'win32';
46
+ if (p === 'darwin') return 'darwin';
47
+ // freebsd / openbsd / aix all collapse to 'linux' for telemetry
48
+ // — the worker only accepts the three canonical values.
49
+ return 'linux';
50
+ }
51
+
52
+ // Pure: given an aggregate and a cursor, produce the next payload. The
53
+ // worker's validateReport must accept this shape; if you add a field
54
+ // here, add it there too.
55
+ export function buildPayload(aggregate, cursor, { instanceId, now = Date.now() }) {
56
+ const sessions = aggregate?.sessions || 0;
57
+ const tokens = (aggregate?.inputTokens || 0)
58
+ + (aggregate?.outputTokens || 0)
59
+ + (aggregate?.cacheReadTokens || 0)
60
+ + (aggregate?.cacheWriteTokens || 0);
61
+ return {
62
+ instanceId,
63
+ sessionsDelta: Math.max(0, sessions - (cursor.sessions || 0)),
64
+ tokensDelta: Math.max(0, tokens - (cursor.tokens || 0)),
65
+ version: VERSION,
66
+ osFamily: osFamily(),
67
+ ts: now,
68
+ };
69
+ }
70
+
71
+ // Single best-effort flush. Returns { ok, reason, delta? } — never throws.
72
+ // Caller passes in the merged config so we can be tested without touching
73
+ // disk for config too.
74
+ export async function flushCommunity(cfg, {
75
+ aggregatePath = AGGREGATE_PATH,
76
+ cursorPath = CURSOR_PATH,
77
+ fetchImpl = globalThis.fetch,
78
+ } = {}) {
79
+ const community = cfg?.community || {};
80
+ if (!community.enabled) return { ok: false, reason: 'disabled' };
81
+ if (!community.instanceId) return { ok: false, reason: 'no-instance-id' };
82
+ if (!community.endpoint) return { ok: false, reason: 'no-endpoint' };
83
+ if (!existsSync(aggregatePath)) return { ok: false, reason: 'no-aggregate' };
84
+
85
+ let aggregate;
86
+ try { aggregate = JSON.parse(readFileSync(aggregatePath, 'utf8')); }
87
+ catch { return { ok: false, reason: 'unreadable-aggregate' }; }
88
+
89
+ const cursor = readCursor(cursorPath);
90
+ const payload = buildPayload(aggregate, cursor, { instanceId: community.instanceId });
91
+ if (payload.sessionsDelta === 0 && payload.tokensDelta === 0) {
92
+ return { ok: true, reason: 'no-delta' };
93
+ }
94
+
95
+ const url = community.endpoint.replace(/\/+$/, '') + '/report';
96
+ let res;
97
+ try {
98
+ res = await fetchImpl(url, {
99
+ method: 'POST',
100
+ headers: { 'Content-Type': 'application/json' },
101
+ body: JSON.stringify(payload),
102
+ });
103
+ } catch (e) {
104
+ return { ok: false, reason: 'network', error: e.message };
105
+ }
106
+ if (!res.ok) {
107
+ // 429 is rate-limit — not actually a failure, just "come back later".
108
+ if (res.status === 429) return { ok: false, reason: 'rate-limited' };
109
+ return { ok: false, reason: `http-${res.status}` };
110
+ }
111
+
112
+ // Only move the cursor on confirmed acceptance. If we crash between
113
+ // the response and the cursor write, the next flush resends — the
114
+ // worker accumulates blindly, so a duplicate would double-count.
115
+ // Rate-limiting on the worker side bounds the damage to one
116
+ // duplicate per minute per instance.
117
+ writeCursor({
118
+ sessions: (cursor.sessions || 0) + payload.sessionsDelta,
119
+ tokens: (cursor.tokens || 0) + payload.tokensDelta,
120
+ ts: payload.ts,
121
+ }, cursorPath);
122
+
123
+ return {
124
+ ok: true,
125
+ delta: { sessions: payload.sessionsDelta, tokens: payload.tokensDelta },
126
+ };
127
+ }
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,38 @@ 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
+ // Opt-in community totals. Disabled by default. `claude-rpc community on`
43
+ // walks through the consent flow and flips `enabled`, mints an anonymous
44
+ // instanceId (UUID v4), and the daemon starts batching deltas to the
45
+ // endpoint. See worker/src/index.js for the receiving end and exactly
46
+ // what payload is accepted (the validator there is the schema of record).
47
+ community: {
48
+ enabled: false,
49
+ instanceId: null,
50
+ endpoint: "https://claude-rpc-totals.claude-rpc.workers.dev",
51
+ flushIntervalMin: 30,
52
+ },
28
53
  showElapsed: true,
29
54
  activityType: 0,
30
55
  statusAssets: {
31
56
  working: "https://cdn.qualit.ly/clawd-working-building.gif",
32
57
  thinking: "https://cdn.qualit.ly/clawd-working-typing.gif",
58
+ compacting: "https://cdn.qualit.ly/clawd-working-typing.gif",
59
+ shipped: "https://cdn.qualit.ly/clawd-working-building.gif",
33
60
  idle: "https://cdn.qualit.ly/clawd-sleeping.gif",
34
61
  stale: "https://cdn.qualit.ly/clawd-sleeping.gif",
35
62
  notification: "https://cdn.qualit.ly/clawd-notification.gif",
@@ -48,14 +75,24 @@ export const DEFAULT_CONFIG = {
48
75
  byStatus: {
49
76
  working: {
50
77
  details: "Working in {project}",
51
- state: "{currentToolPretty} · {currentFilePretty} · {tokensFmt} tokens",
78
+ state: "{currentToolPretty} · {currentFilePretty} · {toolElapsed} · {tokensLabel}",
52
79
  largeImageText: "Working on a {fileLang} file",
53
80
  },
54
81
  thinking: {
55
82
  details: "Thinking in {project}",
56
- state: "{modelPretty} · {messagesLabel} · {tokensFmt} tokens",
83
+ state: "{modelPretty} · {messagesLabel} · {tokensLabel}",
57
84
  largeImageText: "Reasoning with {modelPretty}",
58
85
  },
86
+ compacting: {
87
+ details: "Compacting context in {project}",
88
+ state: "{modelPretty} · {messagesLabel}",
89
+ largeImageText: "Compacting · {compactTriggerLabel}",
90
+ },
91
+ shipped: {
92
+ details: "Just shipped in {project}",
93
+ state: "{lastCommit}",
94
+ largeImageText: "{justShippedLabel}",
95
+ },
59
96
  notification: {
60
97
  details: "Waiting on you · {project}",
61
98
  state: "{modelPretty} · {messagesLabel}",
@@ -84,6 +121,8 @@ export const DEFAULT_CONFIG = {
84
121
  statusIcons: {
85
122
  working: "working",
86
123
  thinking: "thinking",
124
+ compacting: "thinking",
125
+ shipped: "working",
87
126
  idle: "idle",
88
127
  notification: "",
89
128
  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 ──────────────────────────────────────
@@ -379,7 +420,10 @@ export function buildVars(state, config, aggregate) {
379
420
  // Literal single space — handy for blanking a line without `requires`.
380
421
  empty: ' ',
381
422
 
382
- // pluralized session labels
423
+ // Pluralized session labels. tokensLabel is empty when zero so the
424
+ // `· {tokensLabel}` suffix in default templates collapses away —
425
+ // "Bash · · 0 tokens" is not a useful frame.
426
+ tokensLabel: sessionTokens > 0 ? `${fmtNum(sessionTokens)} tokens` : '',
383
427
  messagesLabel: plural(messages, 'prompt'),
384
428
  toolsLabel: plural(tools, 'tool call'),
385
429
  filesEditedLabel: plural(filesEdited, 'edit'),
@@ -389,6 +433,31 @@ export function buildVars(state, config, aggregate) {
389
433
  // session lifecycle flag (for `requires` gating)
390
434
  sessionActive,
391
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
+
392
461
  // concurrent / live
393
462
  concurrent,
394
463
  concurrentOther,
@@ -562,7 +631,19 @@ export function buildVars(state, config, aggregate) {
562
631
 
563
632
  export function fillTemplate(tpl, vars) {
564
633
  if (typeof tpl !== 'string') return tpl;
565
- return tpl.replace(/\{(\w+)\}/g, (_, key) => (key in vars ? String(vars[key]) : `{${key}}`));
634
+ const filled = tpl.replace(/\{(\w+)\}/g, (_, key) => (key in vars ? String(vars[key]) : `{${key}}`));
635
+ return collapseSeparators(filled);
636
+ }
637
+
638
+ // After substitution, a template like "{currentToolPretty} · {currentFilePretty} · {tokensFmt} tokens"
639
+ // can resolve to "Bash · · 0 tokens" when the tool doesn't have a file path
640
+ // and no tokens have accumulated yet. Split on `·`, trim each segment, drop
641
+ // empty segments, rejoin — so empty middle vars don't leave orphan separators
642
+ // and trailing/leading separators disappear entirely. Templates without `·`
643
+ // pass through untouched.
644
+ function collapseSeparators(s) {
645
+ if (!s.includes('·')) return s;
646
+ return s.split('·').map((p) => p.trim()).filter(Boolean).join(' · ');
566
647
  }
567
648
 
568
649
  // Helper used by every "go stale" branch in applyIdle. Wipes the current-
@@ -685,6 +766,24 @@ export function applyIdle(state, cfg = {}) {
685
766
  return state;
686
767
  }
687
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
+
688
787
  // True when `requires` (string or array of strings) all resolve to non-zero / non-empty.
689
788
  export function framePasses(frame, vars) {
690
789
  const req = frame.requires;
@@ -697,4 +796,4 @@ export function framePasses(frame, vars) {
697
796
  return true;
698
797
  }
699
798
 
700
- 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
@@ -260,6 +260,25 @@ export function migrateConfig() {
260
260
  added.push('presence.largeImageText');
261
261
  }
262
262
 
263
+ // v0.6.3: byStatus.working.state and .thinking.state used `{tokensFmt} tokens`
264
+ // which renders "0 tokens" before any session activity has accrued — combined
265
+ // with empty `{currentFilePretty}` for tools like Bash, that surfaced as
266
+ // "Bash · · 0 tokens" on the card. New default uses `{tokensLabel}` which is
267
+ // empty until tokens > 0, and fillTemplate now collapses adjacent separators.
268
+ // Migrate only the verbatim old template — leave anything the user customized.
269
+ const OLD_WORKING = '{currentToolPretty} · {currentFilePretty} · {tokensFmt} tokens';
270
+ const OLD_THINKING = '{modelPretty} · {messagesLabel} · {tokensFmt} tokens';
271
+ if (cfg.presence.byStatus?.working?.state === OLD_WORKING &&
272
+ DEFAULT_CONFIG.presence?.byStatus?.working?.state) {
273
+ cfg.presence.byStatus.working.state = DEFAULT_CONFIG.presence.byStatus.working.state;
274
+ added.push('presence.byStatus.working.state');
275
+ }
276
+ if (cfg.presence.byStatus?.thinking?.state === OLD_THINKING &&
277
+ DEFAULT_CONFIG.presence?.byStatus?.thinking?.state) {
278
+ cfg.presence.byStatus.thinking.state = DEFAULT_CONFIG.presence.byStatus.thinking.state;
279
+ added.push('presence.byStatus.thinking.state');
280
+ }
281
+
263
282
  if (added.length === 0) {
264
283
  console.log(` config up to date → ${CONFIG_PATH}`);
265
284
  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.2';
14
+ const BAKED = '0.7.0';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {