claude-rpc 0.7.2 → 0.7.4
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 +4 -0
- package/SECURITY.md +169 -0
- package/package.json +3 -2
- package/src/doctor.js +21 -10
- package/src/privacy.js +62 -2
- package/src/server/api.js +35 -0
- package/src/server/assets/dashboard.client.js +647 -0
- package/src/server/assets/dashboard.css +326 -0
- package/src/server/assets/dashboard.html +276 -0
- package/src/server/assets.js +36 -0
- package/src/server/page.js +24 -1248
- package/src/server/routes.js +24 -1
- package/src/version.js +1 -1
package/README.md
CHANGED
|
@@ -43,6 +43,8 @@ claude-rpc start
|
|
|
43
43
|
|
|
44
44
|
That's the whole pitch. Open Claude Code in any project — the daemon picks it up within a second. Something looks wrong? `claude-rpc doctor`.
|
|
45
45
|
|
|
46
|
+
> `setup` registers a Windows startup entry and wires hooks into Claude Code's `settings.json`, and the daemon reports anonymous totals by default. All of it is reversible (`claude-rpc uninstall`, `community off`) and fully documented in [`SECURITY.md`](SECURITY.md) — read it first if you want to know exactly what runs.
|
|
47
|
+
|
|
46
48
|
The Discord *desktop* app must be running. The browser client doesn't expose the local IPC bridge that Rich Presence uses.
|
|
47
49
|
|
|
48
50
|
<details>
|
|
@@ -142,6 +144,8 @@ claude-rpc community report # one-shot manual flush (testing)
|
|
|
142
144
|
|
|
143
145
|
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.
|
|
144
146
|
|
|
147
|
+
For a complete account of the sensitive things claude-rpc does — startup persistence, hook injection, every outbound request, and the exact telemetry payload — see [`SECURITY.md`](SECURITY.md). It's also the reference for supply-chain scanner findings (Socket.dev et al.): the flagged persistence and hook-injection behaviors are inherent to the tool and documented there.
|
|
148
|
+
|
|
145
149
|
## three pieces, glued by json files
|
|
146
150
|
|
|
147
151
|
```
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Security & behavior disclosure
|
|
2
|
+
|
|
3
|
+
claude-rpc is a Discord Rich Presence daemon for Claude Code. To do its job it
|
|
4
|
+
has to (a) register itself to start on login and (b) wire commands into Claude
|
|
5
|
+
Code's hook system. Both are legitimate, documented behaviors — but they are
|
|
6
|
+
also patterns that automated supply-chain scanners (e.g. Socket.dev) flag,
|
|
7
|
+
because malware persists and injects the same way. This document is the
|
|
8
|
+
audit trail: every sensitive thing the package does, where in the source it
|
|
9
|
+
lives, why it's there, its blast radius, and how to reverse or disable it.
|
|
10
|
+
|
|
11
|
+
Everything below is verifiable against the published source — there is no
|
|
12
|
+
minified bundle, no obfuscation, no `eval`/`new Function`, and no remote code
|
|
13
|
+
fetch-and-execute anywhere in `src/`.
|
|
14
|
+
|
|
15
|
+
## TL;DR for reviewers
|
|
16
|
+
|
|
17
|
+
| Behavior | Where | Scope | Reversible? |
|
|
18
|
+
| --- | --- | --- | --- |
|
|
19
|
+
| Startup persistence | `src/install.js` → `addStartupEntry` | `HKCU` Run key, current user, no admin | Yes — `claude-rpc uninstall` / `removeStartupEntry` |
|
|
20
|
+
| Hook injection | `src/install.js` → `installHooks` | Only into Claude Code's own `settings.json`, only our own commands | Yes — `uninstallHooks` removes exactly what it added |
|
|
21
|
+
| Outbound network | `src/community.js`, `src/gist.js`, `default-config.js` asset URLs | Anonymous counters + (opt-in) gist publish + GIF assets | Telemetry: `community off`. Gist: only on explicit `badge --gist`. |
|
|
22
|
+
| Local subprocess | `reg.exe`, `git`, `gh` | Static args, no shell interpolation of untrusted input | n/a |
|
|
23
|
+
|
|
24
|
+
No credential access, no filesystem scanning outside `~/.claude-rpc` and Claude
|
|
25
|
+
Code transcripts, no keylogging, no clipboard access, no AV/EDR evasion.
|
|
26
|
+
|
|
27
|
+
## 1. Startup persistence (Windows Run key)
|
|
28
|
+
|
|
29
|
+
**Source:** `src/install.js`, `addStartupEntry` / `removeStartupEntry`.
|
|
30
|
+
|
|
31
|
+
On Windows, `setup` writes one value:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
HKCU\Software\Microsoft\Windows\CurrentVersion\Run
|
|
35
|
+
ClaudeRPC = "<path-to-exe>" daemon
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- **Why:** the Discord presence is driven by a long-lived daemon. If it doesn't
|
|
39
|
+
restart on login, your presence silently stops working after every reboot.
|
|
40
|
+
- **Scope:** `HKCU` (current user) only. No admin elevation, no `HKLM`, no
|
|
41
|
+
service install, no scheduled task.
|
|
42
|
+
- **Reverse it:** `claude-rpc uninstall` deletes the value. You can also delete
|
|
43
|
+
it by hand in `regedit` or with
|
|
44
|
+
`reg delete "HKCU\...\Run" /v ClaudeRPC /f`.
|
|
45
|
+
- The value points at the canonical install path (`%LOCALAPPDATA%`-class dir),
|
|
46
|
+
not at wherever you happened to run the installer from — see
|
|
47
|
+
`ensureCanonicalExe`.
|
|
48
|
+
|
|
49
|
+
Non-Windows platforms get **no** persistence registration (`install()` warns
|
|
50
|
+
and skips it).
|
|
51
|
+
|
|
52
|
+
## 2. Hook injection into Claude Code
|
|
53
|
+
|
|
54
|
+
**Source:** `src/install.js`, `installHooks` / `uninstallHooks`.
|
|
55
|
+
|
|
56
|
+
`setup` adds command hooks to Claude Code's `settings.json` for eight lifecycle
|
|
57
|
+
events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`,
|
|
58
|
+
`Stop`, `SubagentStop`, `Notification`, `SessionEnd`. Each entry looks like:
|
|
59
|
+
|
|
60
|
+
```jsonc
|
|
61
|
+
{ "matcher": "", "hooks": [{ "type": "command", "command": "\"<exe>\" hook PostToolUse" }] }
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- **Why:** this *is* the integration. Claude Code's hook system is the
|
|
65
|
+
supported, documented way for a tool to observe session lifecycle. The hook
|
|
66
|
+
reads the event JSON on stdin, updates `~/.claude-rpc/state.json`, and prints
|
|
67
|
+
`{"continue":true}`. It never blocks, rewrites, or vetoes a tool call — see
|
|
68
|
+
`src/hook.js`, `processHookEvent`.
|
|
69
|
+
- **What the hook reads:** tool name, file path of the active tool, token usage
|
|
70
|
+
counters, and `git push`/`git commit` detection (for the "just shipped"
|
|
71
|
+
card). It writes only to local state/log files. It does not read file
|
|
72
|
+
*contents*, prompts, or responses beyond the usage counters Claude provides.
|
|
73
|
+
- **Scope:** the installer only ever touches entries whose command matches
|
|
74
|
+
`isOurHookCommand` (contains `claude-rpc` or `hook.js`). It will not modify,
|
|
75
|
+
reorder, or delete anyone else's hooks.
|
|
76
|
+
- **Reverse it:** `claude-rpc uninstall` (or `uninstallHooks`) strips exactly
|
|
77
|
+
the entries it added and leaves the rest of `settings.json` intact.
|
|
78
|
+
|
|
79
|
+
## 3. Outbound network
|
|
80
|
+
|
|
81
|
+
There are three distinct network behaviors. Two are optional; one is cosmetic.
|
|
82
|
+
|
|
83
|
+
### 3a. Community totals (telemetry) — ON by default for fresh installs
|
|
84
|
+
|
|
85
|
+
**Source:** `src/community.js`; endpoint in `src/default-config.js`
|
|
86
|
+
(`community.endpoint`); receiving end is the full Worker source in
|
|
87
|
+
[`worker/src/index.js`](worker/src/index.js).
|
|
88
|
+
|
|
89
|
+
A fresh install mints an anonymous UUID v4 and the daemon POSTs to
|
|
90
|
+
`https://claude-rpc-totals.claude-rpc.workers.dev/report` every 30 minutes.
|
|
91
|
+
The **complete** payload (see `buildPayload`, enforced by the Worker's
|
|
92
|
+
`validateReport`) is:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"instanceId": "<random UUID v4>",
|
|
97
|
+
"sessionsDelta": 3,
|
|
98
|
+
"tokensDelta": 142000,
|
|
99
|
+
"version": "0.7.3",
|
|
100
|
+
"osFamily": "win32",
|
|
101
|
+
"ts": 1716500000000
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
What is **not** sent, ever: prompts, responses, file paths, file contents,
|
|
106
|
+
project/repo names, models, cost figures, usernames, hostnames, IP (beyond what
|
|
107
|
+
any HTTP request inherently exposes to Cloudflare's edge), or absolute counter
|
|
108
|
+
values — only forward deltas since the last accepted report.
|
|
109
|
+
|
|
110
|
+
- **Opt out any time:** `claude-rpc community off`.
|
|
111
|
+
- **Upgraders are protected:** anyone upgrading from a pre-v0.7 config is
|
|
112
|
+
written `community.enabled: false`; re-enabling requires the explicit consent
|
|
113
|
+
flow `claude-rpc community on`, which prints the payload schema first.
|
|
114
|
+
- **Why on by default:** the live badges in the README aggregate these counters.
|
|
115
|
+
The trade-off is disclosed here, in the README, and at install time.
|
|
116
|
+
- **Auditable:** the Worker persists only two running integers and a 30-day
|
|
117
|
+
`seen:<instanceId>` dedup marker. The source is in this repo.
|
|
118
|
+
|
|
119
|
+
### 3b. Gist badge publishing — only on explicit command
|
|
120
|
+
|
|
121
|
+
**Source:** `src/gist.js`. Runs **only** when you run `claude-rpc badge --gist`.
|
|
122
|
+
Publishes a badge SVG to *your own* GitHub gist via the `gh` CLI or a
|
|
123
|
+
`GH_TOKEN` you supply with `gist` scope. Hits `api.github.com` /
|
|
124
|
+
`gist.github.com`. Never runs unattended, never on install, never from the
|
|
125
|
+
daemon.
|
|
126
|
+
|
|
127
|
+
### 3c. Presence GIF assets — Discord-side only
|
|
128
|
+
|
|
129
|
+
`default-config.js` references `https://cdn.qualit.ly/clawd-*.gif`. These URLs
|
|
130
|
+
are handed to Discord as image keys; **Discord's** client fetches them to render
|
|
131
|
+
the card. The daemon itself doesn't download them. Swap them for your own URLs
|
|
132
|
+
in `config.json` if you prefer.
|
|
133
|
+
|
|
134
|
+
## 4. Local subprocesses
|
|
135
|
+
|
|
136
|
+
All `child_process` use is static-argument and visible:
|
|
137
|
+
|
|
138
|
+
- `reg.exe add/delete` — the Run key above (`src/install.js`).
|
|
139
|
+
- `git` — read last commit subject / branch for the "just shipped" card
|
|
140
|
+
(`src/git.js`).
|
|
141
|
+
- `gh repo view --json isPrivate` — auto-hide GitHub-private repos from the card
|
|
142
|
+
(`src/privacy.js`); 1.5s timeout, silent skip if `gh` is absent.
|
|
143
|
+
- `gh gist` — only under 3b above.
|
|
144
|
+
|
|
145
|
+
No subprocess interpolates untrusted/remote input into a shell. The one
|
|
146
|
+
`shell: true` call (`verifyHookPipe`) uses only static, trusted args and is
|
|
147
|
+
documented inline as such.
|
|
148
|
+
|
|
149
|
+
## 5. What it stores locally
|
|
150
|
+
|
|
151
|
+
Under `~/.claude-rpc/` (a.k.a. `%APPDATA%\claude-rpc\` on Windows):
|
|
152
|
+
`config.json`, `state.json`, `aggregate.json`, `events.jsonl` (rotated at 5 MB),
|
|
153
|
+
`private-list.json`, `community-cursor.json`. The scanner also reads Claude Code
|
|
154
|
+
transcript files to build aggregates. None of this leaves your machine except
|
|
155
|
+
the minimal telemetry in 3a.
|
|
156
|
+
|
|
157
|
+
## 6. Privacy controls
|
|
158
|
+
|
|
159
|
+
Independent of telemetry, the Discord card has a privacy valve (`src/privacy.js`):
|
|
160
|
+
per-project `.claude-rpc.json`, a runtime private-list, config glob patterns, and
|
|
161
|
+
`gh`-based auto-hide of private repos. Levels: `public` / `name-only` / `hidden`.
|
|
162
|
+
Local dashboards and aggregates are never redacted — privacy is a one-way valve
|
|
163
|
+
from local state to Discord only.
|
|
164
|
+
|
|
165
|
+
## Reporting a vulnerability
|
|
166
|
+
|
|
167
|
+
Open an issue at https://github.com/rar-file/claude-rpc/issues, or for anything
|
|
168
|
+
sensitive email c.archer.simmons@gmail.com. Please include version
|
|
169
|
+
(`claude-rpc --version`), OS, and repro steps.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-rpc",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"description": "Discord Rich Presence for Claude Code — live model, project, tokens, and lifetime stats driven by Claude Code's hook system.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"src",
|
|
58
58
|
"config.example.json",
|
|
59
59
|
"LICENSE",
|
|
60
|
-
"README.md"
|
|
60
|
+
"README.md",
|
|
61
|
+
"SECURITY.md"
|
|
61
62
|
]
|
|
62
63
|
}
|
package/src/doctor.js
CHANGED
|
@@ -228,19 +228,30 @@ function checkDaemonLog() {
|
|
|
228
228
|
}
|
|
229
229
|
const ageMin = (Date.now() - st.mtimeMs) / 60_000;
|
|
230
230
|
const sizeKB = (st.size / 1024).toFixed(1);
|
|
231
|
-
//
|
|
232
|
-
|
|
231
|
+
// Infer live IPC state from the log. The daemon connects once and stays
|
|
232
|
+
// connected without re-logging, so grepping only for "connected" produces
|
|
233
|
+
// a false warning on a long-lived daemon. Instead: "Discord RPC connected",
|
|
234
|
+
// "Presence updated", and "Presence cleared" all imply a live connection —
|
|
235
|
+
// the latter two only log *after* the daemon's `connected` guard passes and
|
|
236
|
+
// a setActivity/clearActivity succeeds (see daemon.js). A later "retry in
|
|
237
|
+
// Ns" / "login failed" / "disconnected" line means it dropped. Whichever
|
|
238
|
+
// happened most recently wins.
|
|
239
|
+
let ipc = 'unknown';
|
|
233
240
|
try {
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
241
|
+
for (const line of readFileSync(LOG_PATH, 'utf8').split('\n').slice(-80)) {
|
|
242
|
+
if (/Discord RPC connected|Presence updated|Presence cleared/i.test(line)) ipc = 'up';
|
|
243
|
+
else if (/retry in \d+s|login failed|Discord disconnected/i.test(line)) ipc = 'down';
|
|
244
|
+
}
|
|
245
|
+
} catch { /* log unreadable — ipc stays 'unknown', warn renders */ }
|
|
246
|
+
if (ipc === 'up') {
|
|
238
247
|
check('discord IPC connection', 'pass',
|
|
239
|
-
|
|
240
|
-
} else {
|
|
241
|
-
check('discord IPC connection', 'warn',
|
|
242
|
-
`log shows no recent "connected" line`,
|
|
248
|
+
`connected · ${sizeKB} KB log · last write ${ageMin.toFixed(1)} min ago`);
|
|
249
|
+
} else if (ipc === 'down') {
|
|
250
|
+
check('discord IPC connection', 'warn', 'daemon is reconnecting to Discord',
|
|
243
251
|
'is the discord desktop client running? rpc only works via desktop, not browser');
|
|
252
|
+
} else {
|
|
253
|
+
check('discord IPC connection', 'warn', 'no connection activity in the log yet',
|
|
254
|
+
'start the daemon with discord desktop running');
|
|
244
255
|
}
|
|
245
256
|
}
|
|
246
257
|
|
package/src/privacy.js
CHANGED
|
@@ -25,7 +25,10 @@ import { execFileSync } from 'node:child_process';
|
|
|
25
25
|
import { join, basename, dirname, resolve as resolvePath } from 'node:path';
|
|
26
26
|
import { DATA_DIR } from './paths.js';
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// CLAUDE_RPC_PRIVATE_LIST lets tests point the runtime store at a temp file
|
|
29
|
+
// instead of the user's real ~/.claude-rpc/private-list.json. Unset in normal
|
|
30
|
+
// operation.
|
|
31
|
+
const PRIVATE_LIST_PATH = process.env.CLAUDE_RPC_PRIVATE_LIST || join(DATA_DIR, 'private-list.json');
|
|
29
32
|
const TTL_MS = 5 * 60 * 1000;
|
|
30
33
|
|
|
31
34
|
const projectFileCache = new Map(); // cwd → { ts, value | null }
|
|
@@ -82,7 +85,14 @@ function readPrivateList() {
|
|
|
82
85
|
if (!existsSync(PRIVATE_LIST_PATH)) return { paths: [] };
|
|
83
86
|
try {
|
|
84
87
|
const v = JSON.parse(readFileSync(PRIVATE_LIST_PATH, 'utf8'));
|
|
85
|
-
if (
|
|
88
|
+
if (v && typeof v === 'object') {
|
|
89
|
+
// `paths` is the legacy binary list (presence ≡ hidden). `visibility`
|
|
90
|
+
// is the richer GUI-managed map: { "<abs cwd>": "hidden|name-only|public" }.
|
|
91
|
+
return {
|
|
92
|
+
paths: Array.isArray(v.paths) ? v.paths : [],
|
|
93
|
+
visibility: (v.visibility && typeof v.visibility === 'object') ? v.visibility : undefined,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
86
96
|
} catch { /* broken JSON ≡ no list (treat as empty rather than crash) */ }
|
|
87
97
|
return { paths: [] };
|
|
88
98
|
}
|
|
@@ -122,6 +132,50 @@ function isInPrivateList(cwd) {
|
|
|
122
132
|
);
|
|
123
133
|
}
|
|
124
134
|
|
|
135
|
+
// ── Central visibility map (GUI-managed) ────────────────────────────────
|
|
136
|
+
// A richer alternative to the binary `paths` list: maps an absolute cwd to
|
|
137
|
+
// an explicit visibility level. Highest-priority runtime layer — an explicit
|
|
138
|
+
// 'public' here even overrides config-glob / gh auto-detect, because the user
|
|
139
|
+
// deliberately marked it. Subdirectories inherit a marked parent.
|
|
140
|
+
|
|
141
|
+
const VIS_LEVELS = ['public', 'name-only', 'hidden'];
|
|
142
|
+
function normLevel(v) { return VIS_LEVELS.includes(v) ? v : null; }
|
|
143
|
+
|
|
144
|
+
function visibilityForCwd(cwd) {
|
|
145
|
+
if (!cwd) return null;
|
|
146
|
+
const map = readPrivateList().visibility;
|
|
147
|
+
if (!map) return null;
|
|
148
|
+
const abs = resolvePath(cwd);
|
|
149
|
+
if (map[abs]) return normLevel(map[abs]);
|
|
150
|
+
for (const [p, v] of Object.entries(map)) {
|
|
151
|
+
if (abs === p || abs.startsWith(p + '/') || abs.startsWith(p + '\\')) return normLevel(v);
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Set (or clear) the explicit visibility for a cwd. `level` of null/'default'
|
|
157
|
+
// removes the override so resolution falls back through the lower layers.
|
|
158
|
+
// Returns the updated visibility map.
|
|
159
|
+
export function setCwdVisibility(cwd, level) {
|
|
160
|
+
const abs = resolvePath(cwd);
|
|
161
|
+
const list = readPrivateList();
|
|
162
|
+
const map = (list.visibility && typeof list.visibility === 'object') ? list.visibility : {};
|
|
163
|
+
const norm = normLevel(level);
|
|
164
|
+
if (norm) map[abs] = norm;
|
|
165
|
+
else delete map[abs];
|
|
166
|
+
// A path managed by the map shouldn't also linger in the legacy binary
|
|
167
|
+
// list, where it would always read as hidden regardless of the map value.
|
|
168
|
+
list.paths = (list.paths || []).filter((p) => p !== abs);
|
|
169
|
+
list.visibility = map;
|
|
170
|
+
writePrivateList(list);
|
|
171
|
+
return map;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Read the current explicit-visibility map (abs cwd → level).
|
|
175
|
+
export function listVisibility() {
|
|
176
|
+
return readPrivateList().visibility || {};
|
|
177
|
+
}
|
|
178
|
+
|
|
125
179
|
// ── GitHub-private detection (best-effort, gh CLI) ──────────────────────
|
|
126
180
|
|
|
127
181
|
function detectGithubPrivate(cwd) {
|
|
@@ -151,6 +205,12 @@ export function resolveVisibility(cwd, config = {}) {
|
|
|
151
205
|
if (proj?.visibility) {
|
|
152
206
|
return { visibility: proj.visibility, projectName: proj.projectName, reason: '.claude-rpc.json' };
|
|
153
207
|
}
|
|
208
|
+
// Central GUI-managed map (incl. explicit 'public' as an opt-out of
|
|
209
|
+
// auto-hide). Ranks above the legacy binary list and config/gh layers.
|
|
210
|
+
const mapped = visibilityForCwd(cwd);
|
|
211
|
+
if (mapped) {
|
|
212
|
+
return { visibility: mapped, projectName: proj?.projectName ?? null, reason: 'private-list (visibility)' };
|
|
213
|
+
}
|
|
154
214
|
if (isInPrivateList(cwd)) {
|
|
155
215
|
return { visibility: 'hidden', projectName: proj?.projectName ?? null, reason: 'private-list' };
|
|
156
216
|
}
|
package/src/server/api.js
CHANGED
|
@@ -173,3 +173,38 @@ export function dayDetail(dayKeyStr) {
|
|
|
173
173
|
if (!day) return null;
|
|
174
174
|
return { day: dayKeyStr, ...day };
|
|
175
175
|
}
|
|
176
|
+
|
|
177
|
+
// Flatten the aggregate's byDay map into a daily-rows CSV for spreadsheet /
|
|
178
|
+
// pandas analysis. One row per day, sorted ascending. Date keys are
|
|
179
|
+
// YYYY-MM-DD and all other columns are numeric, so nothing needs quoting.
|
|
180
|
+
export const CSV_COLUMNS = [
|
|
181
|
+
'date', 'activeMs', 'activeHours', 'sessions', 'userMessages', 'toolCalls',
|
|
182
|
+
'linesAdded', 'linesRemoved', 'cost', 'inputTokens', 'outputTokens',
|
|
183
|
+
'cacheReadTokens', 'cacheWriteTokens', 'notifications',
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
export function aggregateToCsv(agg) {
|
|
187
|
+
const byDay = (agg && agg.byDay) || {};
|
|
188
|
+
const rows = [CSV_COLUMNS.join(',')];
|
|
189
|
+
for (const date of Object.keys(byDay).sort()) {
|
|
190
|
+
const d = byDay[date] || {};
|
|
191
|
+
const activeMs = d.activeMs || 0;
|
|
192
|
+
rows.push([
|
|
193
|
+
date,
|
|
194
|
+
activeMs,
|
|
195
|
+
(activeMs / 3_600_000).toFixed(3),
|
|
196
|
+
d.sessions || 0,
|
|
197
|
+
d.userMessages || 0,
|
|
198
|
+
d.toolCalls || 0,
|
|
199
|
+
d.linesAdded || 0,
|
|
200
|
+
d.linesRemoved || 0,
|
|
201
|
+
(d.cost || 0).toFixed(4),
|
|
202
|
+
d.inputTokens || 0,
|
|
203
|
+
d.outputTokens || 0,
|
|
204
|
+
d.cacheReadTokens || 0,
|
|
205
|
+
d.cacheWriteTokens || 0,
|
|
206
|
+
d.notifications || 0,
|
|
207
|
+
].join(','));
|
|
208
|
+
}
|
|
209
|
+
return rows.join('\n') + '\n';
|
|
210
|
+
}
|