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 +23 -2
- package/package.json +1 -1
- package/src/cli.js +193 -7
- package/src/community.js +130 -0
- package/src/daemon.js +31 -1
- package/src/default-config.js +44 -1
- package/src/format.js +85 -1
- package/src/gist.js +167 -0
- package/src/git.js +44 -0
- package/src/hook.js +64 -0
- package/src/install.js +22 -1
- package/src/state.js +17 -0
- package/src/version.js +1 -1
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-totals) [](#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)
|
|
14
18
|
[](https://nodejs.org)
|
|
15
19
|
[](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 #
|
|
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
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
|
-
|
|
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());
|
package/src/community.js
ADDED
|
@@ -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);
|
package/src/default-config.js
CHANGED
|
@@ -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 `})`;
|
|
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
|
-
|
|
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,
|