claude-code-discord-presence 1.0.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.
Files changed (51) hide show
  1. package/.env.example +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +164 -0
  4. package/assets/claude-code.png +0 -0
  5. package/assets/claude-liquid-dark.png +0 -0
  6. package/assets/claude-liquid-light.png +0 -0
  7. package/assets/claude-stats-dark.png +0 -0
  8. package/assets/claude-stats-light.png +0 -0
  9. package/assets/claude-usage-stats.png +0 -0
  10. package/assets/social-preview.jpg +0 -0
  11. package/assets/usage-stats.png +0 -0
  12. package/dist/appearance/theme-assets.js +13 -0
  13. package/dist/appearance/theme-watcher.js +188 -0
  14. package/dist/claude/app-liveness.js +82 -0
  15. package/dist/claude/cost.js +54 -0
  16. package/dist/claude/default-selection.js +268 -0
  17. package/dist/claude/desktop-focus.js +191 -0
  18. package/dist/claude/limits.js +90 -0
  19. package/dist/claude/monthly-usage.js +279 -0
  20. package/dist/claude/oauth-discovery.js +82 -0
  21. package/dist/claude/paths.js +13 -0
  22. package/dist/claude/plan-info.js +102 -0
  23. package/dist/claude/session-store.js +617 -0
  24. package/dist/claude/tool-labels.js +55 -0
  25. package/dist/claude/transcript.js +150 -0
  26. package/dist/claude/usage-poller.js +243 -0
  27. package/dist/cli.js +137 -0
  28. package/dist/config.js +96 -0
  29. package/dist/discord/presence-builder.js +295 -0
  30. package/dist/discord/rpc-client.js +224 -0
  31. package/dist/index.js +160 -0
  32. package/dist/remote/tunnel-manager.js +83 -0
  33. package/dist/server/http-server.js +89 -0
  34. package/dist/types.js +1 -0
  35. package/dist/util/autostart.js +73 -0
  36. package/dist/util/logger.js +74 -0
  37. package/dist/util/process-liveness.js +231 -0
  38. package/dist/util/process-scan-watcher.js +196 -0
  39. package/package.json +73 -0
  40. package/pricing.json +47 -0
  41. package/scripts/hook.mjs +32 -0
  42. package/scripts/install-autostart.mjs +50 -0
  43. package/scripts/install-remote.mjs +53 -0
  44. package/scripts/remove-autostart.mjs +35 -0
  45. package/scripts/remove-autostart.ps1 +7 -0
  46. package/scripts/run-hidden.vbs +7 -0
  47. package/scripts/run-service.ps1 +44 -0
  48. package/scripts/setup-autostart.ps1 +32 -0
  49. package/scripts/setup-claude.mjs +132 -0
  50. package/scripts/setup-remote.mjs +99 -0
  51. package/scripts/statusline.mjs +59 -0
package/.env.example ADDED
@@ -0,0 +1,21 @@
1
+ CLAUDE_DISCORD_APPLICATION_ID=
2
+ CLAUDE_APP_NAME=Claude Code
3
+ PORT=41724
4
+ USAGE_POLL_INTERVAL_S=300
5
+ CLAUDE_LARGE_IMAGE_KEY=claude_code
6
+ CLAUDE_LARGE_IMAGE_KEY_LIGHT=claude-liquid-light
7
+ CLAUDE_LARGE_IMAGE_KEY_DARK=claude-liquid-dark
8
+ CLAUDE_LARGE_IMAGE_URL=
9
+ CLAUDE_SMALL_IMAGE_KEY=claude-usage-stats
10
+ CLAUDE_SMALL_IMAGE_KEY_LIGHT=claude-stats-light
11
+ CLAUDE_SMALL_IMAGE_KEY_DARK=claude-stats-dark
12
+ CLAUDE_SMALL_IMAGE_URL=
13
+ CLAUDE_CONFIG_DIR=
14
+ CLAUDE_DESKTOP_SESSIONS_DIR=
15
+ CLAUDE_REMOTE_HOSTS=
16
+ CLAUDE_REMOTE_PORT=
17
+ CLAUDE_PRESENCE_DATA_DIR=
18
+ RPC_LOG_FILE=
19
+ RPC_LOG_LEVEL=warn
20
+ RPC_LOG_MAX_BYTES=1048576
21
+ USAGE_TOKEN_REFRESH=off
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dayfinggg
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,164 @@
1
+ # Claude Code Discord Presence
2
+
3
+ [![npm version](https://img.shields.io/npm/v/claude-code-discord-presence?logo=npm)](https://www.npmjs.com/package/claude-code-discord-presence)
4
+ [![CI](https://github.com/dayfinggg/claude-code-discord-presence/actions/workflows/ci.yml/badge.svg)](https://github.com/dayfinggg/claude-code-discord-presence/actions/workflows/ci.yml)
5
+ [![Node.js 22+](https://img.shields.io/badge/Node.js-22.18%2B-339933?logo=node.js&logoColor=white)](https://nodejs.org/)
6
+ [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+
8
+ Cross-platform Discord Rich Presence for Claude Code Desktop and Claude Code CLI. Show the active
9
+ model, effort, usage limits, reset countdowns, tools, agents, token usage, cost, context, plan mode,
10
+ Fast mode, and secure remote SSH sessions on Windows, macOS, and Linux.
11
+
12
+ <p align="center">
13
+ <img src="assets/claude-code.png" width="180" alt="Claude Code Discord Presence icon">
14
+ </p>
15
+
16
+ ## Quick start — no manual hook setup
17
+
18
+ You only need Node.js 22.18 or newer, Claude Code, and Discord Desktop. The first command safely
19
+ installs the required Claude Code hooks and statusline, then starts Rich Presence:
20
+
21
+ ```bash
22
+ npx claude-code-discord-presence
23
+ ```
24
+
25
+ For a persistent command:
26
+
27
+ ```bash
28
+ npm install --global claude-code-discord-presence
29
+ claude-code-presence
30
+ ```
31
+
32
+ No Discord application, `.env` file, Bun, `jq`, `curl`, or platform-specific shell script is
33
+ required. Existing unrelated Claude settings and hooks are preserved.
34
+
35
+ ## What it shows
36
+
37
+ | Discord field | Live data |
38
+ | --- | --- |
39
+ | Details | Plan and authoritative 5-hour/7-day allowance left with reset countdowns |
40
+ | State | Model, effort, Fast mode, current tool/action, Plan mode, agents, notifications |
41
+ | Timer | Current Claude Code session duration |
42
+ | Large-image tooltip | Input, cache read/write, output, cost, and context usage |
43
+ | Small-image tooltip | Day, week, month, and all-time token and cost totals |
44
+
45
+ The account usage endpoint is preferred over placeholder statusline zeroes. This prevents the false
46
+ `100% left` display that some Claude clients emit while keeping statusline data as an offline fallback.
47
+
48
+ ## Hooks and statusline
49
+
50
+ The installer copies self-contained Node.js scripts to `~/.claude/discord-presence/` and updates the
51
+ user-level `settings.json` used by Claude Code CLI and Desktop.
52
+
53
+ - Command hooks cover `SessionStart`, prompts, tool use and failures, agents, notifications, stop,
54
+ and session end.
55
+ - `SessionStart` uses an official command hook rather than an unsupported HTTP hook.
56
+ - The statusline displays the model, effort, Fast mode, and available usage while forwarding the
57
+ same structured payload to Rich Presence.
58
+ - Hook and statusline scripts never create their own logs and silently tolerate a stopped service.
59
+ - Existing settings are backed up under `~/.claude/backups/claude-code-presence/`; only five backups
60
+ are retained.
61
+
62
+ Run the installer explicitly at any time with `claude-code-presence setup`.
63
+
64
+ ## Desktop, CLI, and platforms
65
+
66
+ | Platform | Claude Code Desktop | Claude Code CLI | Per-user autostart |
67
+ | --- | ---: | ---: | ---: |
68
+ | Windows | Yes | Yes | Registry launcher with a bounded supervisor log |
69
+ | macOS | Yes | Yes | LaunchAgent |
70
+ | Linux | Where available | Yes | systemd user service |
71
+
72
+ Windows detection supports Microsoft Store, native Desktop, and native CLI processes. macOS and
73
+ Linux combine Claude's session PID files with native `ps` scanning, so either Desktop or CLI keeps
74
+ the correct activity alive. Desktop focus files select ordinary chats as well as project sessions.
75
+
76
+ ## Autostart
77
+
78
+ ```bash
79
+ claude-code-presence autostart
80
+ ```
81
+
82
+ The command ensures hooks are installed before registering startup. Remove it with:
83
+
84
+ ```bash
85
+ claude-code-presence autostart:remove
86
+ ```
87
+
88
+ ## Optional configuration
89
+
90
+ Defaults work without configuration. Pass options directly:
91
+
92
+ ```bash
93
+ claude-code-presence --log-level debug --usage-poll-interval 120
94
+ claude-code-presence --claude-config-dir ~/.custom-claude
95
+ ```
96
+
97
+ Or copy `.env.example`, edit it, and run `claude-code-presence --env /path/to/.env`.
98
+
99
+ | CLI option | Environment variable | Purpose |
100
+ | --- | --- | --- |
101
+ | `--application-id` | `CLAUDE_DISCORD_APPLICATION_ID` | Use a different Discord application |
102
+ | `--port` | `PORT` | Local loopback hook port; default `41724` |
103
+ | `--claude-config-dir` | `CLAUDE_CONFIG_DIR` | Override Claude's user config directory |
104
+ | `--desktop-sessions-dir` | `CLAUDE_DESKTOP_SESSIONS_DIR` | Override Desktop focus files, or `off` |
105
+ | `--remote-hosts` | `CLAUDE_REMOTE_HOSTS` | Comma-separated SSH config aliases |
106
+ | `--remote-port` | `CLAUDE_REMOTE_PORT` | Remote loopback forwarding port |
107
+ | `--usage-poll-interval` | `USAGE_POLL_INTERVAL_S` | Account limit refresh interval |
108
+ | `--log-level` | `RPC_LOG_LEVEL` | `debug`, `info`, `warn`, `error`, or `silent` |
109
+ | `--log-max-bytes` | `RPC_LOG_MAX_BYTES` | Maximum size of the active log; default 1 MiB |
110
+
111
+ Set `USAGE_TOKEN_REFRESH=on` only if you explicitly want the service to refresh and save Claude's
112
+ OAuth token. The safe default is read-only: Claude Code remains responsible for its credentials.
113
+ The repository `.env` is ignored and never published to GitHub or npm.
114
+
115
+ ## Secure remote Claude Code sessions
116
+
117
+ Use SSH config aliases; the project never contains a hostname, address, username, port, or key.
118
+ First install the same Node hooks and statusline on explicitly selected hosts:
119
+
120
+ ```bash
121
+ claude-code-presence remote:setup --remote-hosts work-box,dev-box
122
+ ```
123
+
124
+ Then start with the same allowlist:
125
+
126
+ ```bash
127
+ claude-code-presence --remote-hosts work-box,dev-box
128
+ ```
129
+
130
+ The service maintains reverse tunnels from remote `127.0.0.1` to local `127.0.0.1`. Aliases that
131
+ look like SSH options, user/address targets, whitespace, or shell syntax are rejected. SSH runs with
132
+ `shell: false`, batch mode, connection timeouts, bounded reconnect backoff, and loopback-only binds.
133
+
134
+ ## Logging, privacy, and safety
135
+
136
+ The receiver binds only to `127.0.0.1`, accepts only health, hook, and statusline routes, caps JSON
137
+ bodies at 512 KiB, and has short request timeouts. Hooks do not control Claude or execute received
138
+ commands; they provide telemetry to the in-memory presence store.
139
+
140
+ The default log level is `warn`. Normal statusline invocations, hooks, polling, and Discord updates
141
+ are not logged. The active log and one archive are each limited to 1 MiB by default. Access tokens,
142
+ hook bodies, transcripts, and tool inputs are never written to logs.
143
+
144
+ ## Build from source
145
+
146
+ ```bash
147
+ git clone https://github.com/dayfinggg/claude-code-discord-presence.git
148
+ cd claude-code-discord-presence
149
+ npm ci
150
+ npm test
151
+ npm run typecheck
152
+ npm run build
153
+ npm start
154
+ ```
155
+
156
+ ## Discord assets
157
+
158
+ The exact light, dark, fallback, and usage images used by the default Discord application are in
159
+ [`assets/`](assets). Use the matching keys from `.env.example` when configuring another application.
160
+
161
+ ## License and attribution
162
+
163
+ [MIT](LICENSE). This community project is not affiliated with or endorsed by Anthropic or Discord.
164
+ Anthropic, Claude, Claude Code, and Discord are trademarks of their respective owners.
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,13 @@
1
+ export function activityAssetsForTheme(assets, theme) {
2
+ return {
3
+ appName: assets.appName,
4
+ largeImageKey: theme === "dark"
5
+ ? assets.largeImageKeyDark ?? assets.largeImageKey
6
+ : assets.largeImageKeyLight ?? assets.largeImageKey,
7
+ largeImageUrl: assets.largeImageUrl,
8
+ smallImageKey: theme === "dark"
9
+ ? assets.smallImageKeyDark ?? assets.smallImageKey
10
+ : assets.smallImageKeyLight ?? assets.smallImageKey,
11
+ smallImageUrl: assets.smallImageUrl,
12
+ };
13
+ }
@@ -0,0 +1,188 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ const execFileAsync = promisify(execFile);
7
+ function defaultClaudeDataDir() {
8
+ if (process.platform === "win32") {
9
+ return join(process.env.APPDATA?.trim() || join(homedir(), "AppData", "Roaming"), "Claude");
10
+ }
11
+ if (process.platform === "darwin") {
12
+ return join(homedir(), "Library", "Application Support", "Claude");
13
+ }
14
+ return join(process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config"), "Claude");
15
+ }
16
+ function normalizeThemePreference(value) {
17
+ if (typeof value !== "string")
18
+ return undefined;
19
+ const raw = value.toLowerCase();
20
+ if (raw === "auto" || raw === "system")
21
+ return "system";
22
+ return raw === "light" || raw === "dark" ? raw : undefined;
23
+ }
24
+ export function parseCodexThemePreference(text) {
25
+ const match = /^\s*appearanceTheme\s*=\s*["']?(light|dark|system)["']?\s*(?:#.*)?$/im.exec(text);
26
+ return match?.[1]?.toLowerCase();
27
+ }
28
+ export function parseClaudeThemePreference(data) {
29
+ const text = typeof data === "string" ? data : Buffer.from(data).toString("latin1");
30
+ const matches = [...text.matchAll(/userTheme[\s\S]{0,96}?(auto|system|light|dark)/gi)];
31
+ return normalizeThemePreference(matches.at(-1)?.[1]);
32
+ }
33
+ export function parseClaudeConfigTheme(text) {
34
+ try {
35
+ const config = JSON.parse(text);
36
+ return normalizeThemePreference(config.userThemeMode ?? config.userTheme);
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ export function readClaudeConfigTheme(path) {
43
+ try {
44
+ return parseClaudeConfigTheme(readFileSync(path, "utf8"));
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ }
50
+ export function resolveTheme(preference, systemTheme) {
51
+ return preference === "light" || preference === "dark" ? preference : systemTheme;
52
+ }
53
+ function readCodexPreference(codexHome) {
54
+ if (!codexHome)
55
+ return undefined;
56
+ try {
57
+ return parseCodexThemePreference(readFileSync(join(codexHome, "config.toml"), "utf8"));
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ }
63
+ async function readSystemTheme() {
64
+ try {
65
+ if (process.platform === "win32") {
66
+ const { stdout } = await execFileAsync("reg.exe", [
67
+ "query",
68
+ "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
69
+ "/v",
70
+ "AppsUseLightTheme",
71
+ ], { windowsHide: true });
72
+ const match = /AppsUseLightTheme\s+REG_DWORD\s+0x([0-9a-f]+)/i.exec(stdout);
73
+ if (match?.[1])
74
+ return Number.parseInt(match[1], 16) === 0 ? "dark" : "light";
75
+ }
76
+ else if (process.platform === "darwin") {
77
+ const { stdout } = await execFileAsync("defaults", ["read", "-g", "AppleInterfaceStyle"]);
78
+ return /dark/i.test(stdout) ? "dark" : "light";
79
+ }
80
+ else {
81
+ const { stdout } = await execFileAsync("gsettings", [
82
+ "get",
83
+ "org.gnome.desktop.interface",
84
+ "color-scheme",
85
+ ]);
86
+ return /dark/i.test(stdout) ? "dark" : "light";
87
+ }
88
+ }
89
+ catch {
90
+ return "light";
91
+ }
92
+ return "light";
93
+ }
94
+ function themesEqual(a, b) {
95
+ return a?.claude === b.claude && a.codex === b.codex;
96
+ }
97
+ export class AppearanceThemeWatcher {
98
+ codexHome;
99
+ claudeConfigPath;
100
+ claudeLevelDbDir;
101
+ pollIntervalMs;
102
+ onChange;
103
+ timer;
104
+ polling = false;
105
+ current;
106
+ claudePreference;
107
+ claudeFileSignatures = new Map();
108
+ constructor(options, onChange) {
109
+ const claudeDataDir = defaultClaudeDataDir();
110
+ this.codexHome = options.codexHome;
111
+ this.claudeConfigPath = options.claudeConfigPath || join(claudeDataDir, "config.json");
112
+ this.claudeLevelDbDir =
113
+ options.claudeLevelDbDir || join(claudeDataDir, "Local Storage", "leveldb");
114
+ this.pollIntervalMs = options.pollIntervalMs ?? 2_000;
115
+ this.onChange = onChange;
116
+ }
117
+ start() {
118
+ if (this.timer)
119
+ return;
120
+ void this.poll();
121
+ this.timer = setInterval(() => void this.poll(), this.pollIntervalMs);
122
+ }
123
+ stop() {
124
+ if (!this.timer)
125
+ return;
126
+ clearInterval(this.timer);
127
+ this.timer = undefined;
128
+ }
129
+ async poll() {
130
+ if (this.polling)
131
+ return;
132
+ this.polling = true;
133
+ try {
134
+ const systemTheme = await readSystemTheme();
135
+ const themes = {
136
+ claude: resolveTheme(this.readClaudePreference(), systemTheme),
137
+ codex: resolveTheme(readCodexPreference(this.codexHome), systemTheme),
138
+ };
139
+ if (!themesEqual(this.current, themes)) {
140
+ this.current = themes;
141
+ this.onChange(themes);
142
+ }
143
+ }
144
+ finally {
145
+ this.polling = false;
146
+ }
147
+ }
148
+ readClaudePreference() {
149
+ const configPreference = readClaudeConfigTheme(this.claudeConfigPath);
150
+ if (configPreference) {
151
+ this.claudePreference = configPreference;
152
+ return configPreference;
153
+ }
154
+ if (!existsSync(this.claudeLevelDbDir))
155
+ return this.claudePreference;
156
+ try {
157
+ const candidates = readdirSync(this.claudeLevelDbDir)
158
+ .filter((name) => /\.(?:ldb|log)$/i.test(name))
159
+ .map((name) => {
160
+ const path = join(this.claudeLevelDbDir, name);
161
+ const stats = statSync(path);
162
+ return {
163
+ path,
164
+ modifiedAt: stats.mtimeMs,
165
+ signature: `${stats.mtimeMs}:${stats.size}`,
166
+ };
167
+ })
168
+ .sort((a, b) => b.modifiedAt - a.modifiedAt);
169
+ let latest;
170
+ const signatures = new Map();
171
+ for (const candidate of candidates) {
172
+ signatures.set(candidate.path, candidate.signature);
173
+ if (this.claudeFileSignatures.get(candidate.path) === candidate.signature)
174
+ continue;
175
+ const preference = parseClaudeThemePreference(readFileSync(candidate.path));
176
+ if (!latest && preference)
177
+ latest = preference;
178
+ }
179
+ this.claudeFileSignatures = signatures;
180
+ if (latest)
181
+ this.claudePreference = latest;
182
+ }
183
+ catch {
184
+ return this.claudePreference;
185
+ }
186
+ return this.claudePreference;
187
+ }
188
+ }
@@ -0,0 +1,82 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { createLogger } from "../util/logger.js";
4
+ import { claudeConfigDir } from "./paths.js";
5
+ const log = createLogger("app-liveness");
6
+ const POLL_MS = 15_000;
7
+ function processAlive(pid) {
8
+ try {
9
+ process.kill(pid, 0);
10
+ return true;
11
+ }
12
+ catch (err) {
13
+ return err.code === "EPERM";
14
+ }
15
+ }
16
+ export class ClaudeAppLiveness {
17
+ onUpdate;
18
+ dir;
19
+ timer;
20
+ scanning = false;
21
+ stopped = false;
22
+ lastAlive;
23
+ constructor(onUpdate, dir = join(claudeConfigDir(), "sessions")) {
24
+ this.onUpdate = onUpdate;
25
+ this.dir = dir;
26
+ }
27
+ start() {
28
+ void this.scan();
29
+ this.timer = setInterval(() => void this.scan(), POLL_MS);
30
+ }
31
+ stop() {
32
+ this.stopped = true;
33
+ if (this.timer)
34
+ clearInterval(this.timer);
35
+ }
36
+ async scan() {
37
+ if (this.stopped || this.scanning)
38
+ return;
39
+ this.scanning = true;
40
+ try {
41
+ let alive = false;
42
+ let earliest;
43
+ let pid;
44
+ let entries = [];
45
+ try {
46
+ entries = await readdir(this.dir);
47
+ }
48
+ catch {
49
+ entries = [];
50
+ }
51
+ for (const name of entries) {
52
+ if (!name.endsWith(".json"))
53
+ continue;
54
+ let obj;
55
+ try {
56
+ obj = JSON.parse(await readFile(join(this.dir, name), "utf8"));
57
+ }
58
+ catch {
59
+ continue;
60
+ }
61
+ if (typeof obj.pid !== "number" || !processAlive(obj.pid))
62
+ continue;
63
+ alive = true;
64
+ if (typeof obj.startedAt === "number" && (earliest === undefined || obj.startedAt < earliest)) {
65
+ earliest = obj.startedAt;
66
+ pid = obj.pid;
67
+ }
68
+ else if (pid === undefined) {
69
+ pid = obj.pid;
70
+ }
71
+ }
72
+ if (alive !== this.lastAlive) {
73
+ this.lastAlive = alive;
74
+ log.debug(`claude processes alive=${alive}${earliest !== undefined ? ` earliestStartedAt=${earliest}` : ""}`);
75
+ }
76
+ this.onUpdate(alive, earliest, pid);
77
+ }
78
+ finally {
79
+ this.scanning = false;
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,54 @@
1
+ import { join } from "node:path";
2
+ import { readFileSync, existsSync } from "node:fs";
3
+ import pricingConfig from "../../pricing.json" with { type: "json" };
4
+ import { claudeConfigDir } from "./paths.js";
5
+ const DEFAULT_PRICING = pricingConfig.claude.models;
6
+ const PRICING_PATH = join(claudeConfigDir(), "discord-rpc-pricing.json");
7
+ let pricing = { ...DEFAULT_PRICING };
8
+ export function loadPricingOverrides() {
9
+ if (!existsSync(PRICING_PATH))
10
+ return false;
11
+ try {
12
+ const overrides = JSON.parse(readFileSync(PRICING_PATH, "utf8"));
13
+ pricing = { ...DEFAULT_PRICING };
14
+ for (const [id, p] of Object.entries(overrides)) {
15
+ const base = pricing[normalizeModelId(id)] ?? { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
16
+ pricing[normalizeModelId(id)] = { ...base, ...p };
17
+ }
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ export function normalizeModelId(id) {
25
+ return id.replace(/-fast$/, "").replace(/-\d{8}$/, "");
26
+ }
27
+ export function costBreakdown(model, usage) {
28
+ const price = pricing[normalizeModelId(model)];
29
+ if (!price)
30
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
31
+ const multiplier = model.endsWith("-fast") ? (price.fastMultiplier ?? 1) : 1;
32
+ const oneHour = Math.min(usage.cacheWrite, usage.cacheWriteOneHour ?? 0);
33
+ const input = (usage.input * price.input * multiplier) / 1_000_000;
34
+ const output = (usage.output * price.output * multiplier) / 1_000_000;
35
+ const cacheRead = (usage.cacheRead * price.cacheRead * multiplier) / 1_000_000;
36
+ const cacheWrite = ((usage.cacheWrite - oneHour) * price.cacheWrite +
37
+ oneHour * (price.cacheWriteOneHour ?? price.cacheWrite * 2)) * multiplier / 1_000_000;
38
+ return { input, output, cacheRead, cacheWrite, total: input + output + cacheRead + cacheWrite };
39
+ }
40
+ export function costUsd(model, usage) {
41
+ return costBreakdown(model, usage).total;
42
+ }
43
+ export function costForUsageByModel(usageByModel) {
44
+ const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
45
+ for (const [model, usage] of Object.entries(usageByModel)) {
46
+ const breakdown = costBreakdown(model, usage);
47
+ total.input += breakdown.input;
48
+ total.output += breakdown.output;
49
+ total.cacheRead += breakdown.cacheRead;
50
+ total.cacheWrite += breakdown.cacheWrite;
51
+ total.total += breakdown.total;
52
+ }
53
+ return total;
54
+ }