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 +24 -2
- package/package.json +1 -1
- package/src/cli.js +183 -4
- package/src/community.js +127 -0
- package/src/daemon.js +31 -1
- package/src/default-config.js +41 -2
- package/src/format.js +102 -3
- package/src/gist.js +167 -0
- package/src/git.js +44 -0
- package/src/hook.js +64 -0
- package/src/install.js +19 -0
- package/src/state.js +17 -0
- package/src/version.js +1 -1
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
|
+

|
|
131
|
+

|
|
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 #
|
|
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
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());
|
package/src/community.js
ADDED
|
@@ -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);
|
package/src/default-config.js
CHANGED
|
@@ -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} · {
|
|
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} · {
|
|
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
|
-
//
|
|
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
|
-
|
|
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 `})`;
|
|
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,
|