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.
- package/.env.example +21 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/assets/claude-code.png +0 -0
- package/assets/claude-liquid-dark.png +0 -0
- package/assets/claude-liquid-light.png +0 -0
- package/assets/claude-stats-dark.png +0 -0
- package/assets/claude-stats-light.png +0 -0
- package/assets/claude-usage-stats.png +0 -0
- package/assets/social-preview.jpg +0 -0
- package/assets/usage-stats.png +0 -0
- package/dist/appearance/theme-assets.js +13 -0
- package/dist/appearance/theme-watcher.js +188 -0
- package/dist/claude/app-liveness.js +82 -0
- package/dist/claude/cost.js +54 -0
- package/dist/claude/default-selection.js +268 -0
- package/dist/claude/desktop-focus.js +191 -0
- package/dist/claude/limits.js +90 -0
- package/dist/claude/monthly-usage.js +279 -0
- package/dist/claude/oauth-discovery.js +82 -0
- package/dist/claude/paths.js +13 -0
- package/dist/claude/plan-info.js +102 -0
- package/dist/claude/session-store.js +617 -0
- package/dist/claude/tool-labels.js +55 -0
- package/dist/claude/transcript.js +150 -0
- package/dist/claude/usage-poller.js +243 -0
- package/dist/cli.js +137 -0
- package/dist/config.js +96 -0
- package/dist/discord/presence-builder.js +295 -0
- package/dist/discord/rpc-client.js +224 -0
- package/dist/index.js +160 -0
- package/dist/remote/tunnel-manager.js +83 -0
- package/dist/server/http-server.js +89 -0
- package/dist/types.js +1 -0
- package/dist/util/autostart.js +73 -0
- package/dist/util/logger.js +74 -0
- package/dist/util/process-liveness.js +231 -0
- package/dist/util/process-scan-watcher.js +196 -0
- package/package.json +73 -0
- package/pricing.json +47 -0
- package/scripts/hook.mjs +32 -0
- package/scripts/install-autostart.mjs +50 -0
- package/scripts/install-remote.mjs +53 -0
- package/scripts/remove-autostart.mjs +35 -0
- package/scripts/remove-autostart.ps1 +7 -0
- package/scripts/run-hidden.vbs +7 -0
- package/scripts/run-service.ps1 +44 -0
- package/scripts/setup-autostart.ps1 +32 -0
- package/scripts/setup-claude.mjs +132 -0
- package/scripts/setup-remote.mjs +99 -0
- 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
|
+
[](https://www.npmjs.com/package/claude-code-discord-presence)
|
|
4
|
+
[](https://github.com/dayfinggg/claude-code-discord-presence/actions/workflows/ci.yml)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](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
|
+
}
|