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
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
const MIN = 2;
|
|
2
|
+
const MAX = 128;
|
|
3
|
+
const SEP = " • ";
|
|
4
|
+
const MODEL_NAMES = {
|
|
5
|
+
"claude-fable-5": "Fable 5",
|
|
6
|
+
"claude-opus-4-8": "Opus 4.8",
|
|
7
|
+
"claude-opus-4-7": "Opus 4.7",
|
|
8
|
+
"claude-sonnet-5": "Sonnet 5",
|
|
9
|
+
"claude-haiku-4-5": "Haiku 4.5",
|
|
10
|
+
};
|
|
11
|
+
const EFFORT_LABELS = {
|
|
12
|
+
minimal: "Minimal",
|
|
13
|
+
low: "Light",
|
|
14
|
+
medium: "Medium",
|
|
15
|
+
high: "High",
|
|
16
|
+
xhigh: "Extra High",
|
|
17
|
+
max: "Max",
|
|
18
|
+
ultra: "Ultra",
|
|
19
|
+
};
|
|
20
|
+
export function effortLabel(level) {
|
|
21
|
+
return EFFORT_LABELS[level];
|
|
22
|
+
}
|
|
23
|
+
export function modelDisplayName(id, fallback) {
|
|
24
|
+
if (id && MODEL_NAMES[id])
|
|
25
|
+
return MODEL_NAMES[id];
|
|
26
|
+
if (fallback && fallback.trim() !== "")
|
|
27
|
+
return fallback;
|
|
28
|
+
if (id && id.trim() !== "")
|
|
29
|
+
return id;
|
|
30
|
+
return "Claude";
|
|
31
|
+
}
|
|
32
|
+
function pctLeft(usedPercentage) {
|
|
33
|
+
const left = Math.round(100 - usedPercentage);
|
|
34
|
+
return Math.max(0, Math.min(100, left));
|
|
35
|
+
}
|
|
36
|
+
export function formatGoalDuration(elapsedSeconds) {
|
|
37
|
+
const totalMinutes = Math.max(0, Math.floor((elapsedSeconds ?? 0) / 60));
|
|
38
|
+
const days = Math.floor(totalMinutes / (24 * 60));
|
|
39
|
+
const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
|
|
40
|
+
const minutes = totalMinutes % 60;
|
|
41
|
+
if (days > 0)
|
|
42
|
+
return `${days}d ${hours}h ${minutes}m`;
|
|
43
|
+
if (hours > 0)
|
|
44
|
+
return `${hours}h ${minutes}m`;
|
|
45
|
+
return `${minutes}m`;
|
|
46
|
+
}
|
|
47
|
+
function goalChip(state) {
|
|
48
|
+
return state.goalActive ? `Goal active (${formatGoalDuration(state.goalElapsedSeconds)})` : undefined;
|
|
49
|
+
}
|
|
50
|
+
export function clamp(text) {
|
|
51
|
+
let out = text;
|
|
52
|
+
if (byteLength(out) > MAX)
|
|
53
|
+
out = fitBytes(out, MAX, true);
|
|
54
|
+
if (out.length < MIN)
|
|
55
|
+
out = (out + " ").slice(0, MIN);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
function byteLength(text) {
|
|
59
|
+
return new TextEncoder().encode(text).length;
|
|
60
|
+
}
|
|
61
|
+
function fitBytes(text, maxBytes, ellipsis) {
|
|
62
|
+
const suffix = ellipsis ? "…" : "";
|
|
63
|
+
const suffixBytes = byteLength(suffix);
|
|
64
|
+
if (maxBytes <= suffixBytes)
|
|
65
|
+
return suffix.slice(0, maxBytes);
|
|
66
|
+
let fitted = text;
|
|
67
|
+
while (fitted !== "" && byteLength(fitted) + suffixBytes > maxBytes)
|
|
68
|
+
fitted = fitted.slice(0, -1);
|
|
69
|
+
return fitted.trimEnd() + suffix;
|
|
70
|
+
}
|
|
71
|
+
export function formatResetCountdown(resetsAt, now = Date.now()) {
|
|
72
|
+
if (resetsAt === undefined || !Number.isFinite(resetsAt) || resetsAt <= now)
|
|
73
|
+
return undefined;
|
|
74
|
+
const totalMinutes = Math.max(1, Math.ceil((resetsAt - now) / 60_000));
|
|
75
|
+
const days = Math.floor(totalMinutes / (24 * 60));
|
|
76
|
+
const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
|
|
77
|
+
const minutes = totalMinutes % 60;
|
|
78
|
+
if (days > 0)
|
|
79
|
+
return `${days}d${hours > 0 ? ` ${hours}h` : ""}`;
|
|
80
|
+
if (hours > 0)
|
|
81
|
+
return `${hours}h${minutes > 0 ? ` ${minutes}m` : ""}`;
|
|
82
|
+
return `${minutes}m`;
|
|
83
|
+
}
|
|
84
|
+
function limitText(label, usedPercentage, resetsAt, options) {
|
|
85
|
+
let text = `${label} ${pctLeft(usedPercentage)}% left`;
|
|
86
|
+
if (!options.showResetCountdowns)
|
|
87
|
+
return text;
|
|
88
|
+
const countdown = formatResetCountdown(resetsAt, options.now);
|
|
89
|
+
if (countdown)
|
|
90
|
+
text += ` (resets in ${countdown})`;
|
|
91
|
+
return text;
|
|
92
|
+
}
|
|
93
|
+
export function buildDetails(state, options = {}) {
|
|
94
|
+
const segments = [];
|
|
95
|
+
if (state.planName.trim() !== "")
|
|
96
|
+
segments.push(state.planName);
|
|
97
|
+
if (state.resetCreditsAvailable !== undefined && state.resetCreditsAvailable > 0) {
|
|
98
|
+
const noun = state.resetCreditsAvailable === 1 ? "reset" : "resets";
|
|
99
|
+
segments.push(`${state.resetCreditsAvailable} ${noun} left`);
|
|
100
|
+
}
|
|
101
|
+
const five = state.limits?.fiveHour;
|
|
102
|
+
const seven = state.limits?.sevenDay;
|
|
103
|
+
const scoped = state.limits?.sevenDayScoped;
|
|
104
|
+
if (five) {
|
|
105
|
+
segments.push(limitText("5h", five.usedPercentage, five.resetsAt, options));
|
|
106
|
+
}
|
|
107
|
+
const scopedText = scoped
|
|
108
|
+
?.map((s) => `${s.label} ${pctLeft(s.usedPercentage)}% left`)
|
|
109
|
+
.join(", ");
|
|
110
|
+
if (seven) {
|
|
111
|
+
let sevenText = limitText("7d", seven.usedPercentage, seven.resetsAt, options);
|
|
112
|
+
if (scopedText)
|
|
113
|
+
sevenText += ` (${scopedText})`;
|
|
114
|
+
segments.push(sevenText);
|
|
115
|
+
}
|
|
116
|
+
else if (scopedText) {
|
|
117
|
+
segments.push(`7d ${scopedText}`);
|
|
118
|
+
}
|
|
119
|
+
return clamp(segments.join(SEP));
|
|
120
|
+
}
|
|
121
|
+
function tailChips(state) {
|
|
122
|
+
const chips = [];
|
|
123
|
+
const goal = goalChip(state);
|
|
124
|
+
if (goal)
|
|
125
|
+
chips.push(goal);
|
|
126
|
+
if (state.planMode)
|
|
127
|
+
chips.push("Plan mode");
|
|
128
|
+
if (state.agentsRunning > 0) {
|
|
129
|
+
const noun = state.agentsRunning === 1 ? "agent" : "agents";
|
|
130
|
+
let chip = `${state.agentsRunning} ${noun} running`;
|
|
131
|
+
if (state.agentsIdle > 0)
|
|
132
|
+
chip += ` (${state.agentsIdle} idle)`;
|
|
133
|
+
chips.push(chip);
|
|
134
|
+
}
|
|
135
|
+
if (state.realtime)
|
|
136
|
+
chips.push("Realtime");
|
|
137
|
+
if (state.remote)
|
|
138
|
+
chips.push("Remote");
|
|
139
|
+
return chips.join(SEP);
|
|
140
|
+
}
|
|
141
|
+
function requiredTailChips(state) {
|
|
142
|
+
const chips = [];
|
|
143
|
+
const goal = goalChip(state);
|
|
144
|
+
if (goal)
|
|
145
|
+
chips.push(goal);
|
|
146
|
+
return chips.join(SEP);
|
|
147
|
+
}
|
|
148
|
+
export function buildStateLine(state) {
|
|
149
|
+
const effort = state.effort ? ` (${effortLabel(state.effort)})` : "";
|
|
150
|
+
const fast = state.fastMode ? " Fast" : "";
|
|
151
|
+
const head = state.model ? `${state.model.displayName}${effort}${fast}` : state.fastMode ? "Fast" : "";
|
|
152
|
+
const action = state.status === "thinking" && state.thinkingSeconds !== undefined
|
|
153
|
+
? `Thinking (${Math.max(0, Math.floor(state.thinkingSeconds))}s)`
|
|
154
|
+
: state.action.trim();
|
|
155
|
+
const tail = tailChips(state);
|
|
156
|
+
const priority = [head, action, tail].filter((s) => s !== "");
|
|
157
|
+
if (priority.length === 0)
|
|
158
|
+
return clamp("Claude Code");
|
|
159
|
+
let line = priority.join(SEP);
|
|
160
|
+
if (byteLength(line) <= MAX)
|
|
161
|
+
return clamp(line);
|
|
162
|
+
const requiredTail = requiredTailChips(state);
|
|
163
|
+
if (requiredTail !== "") {
|
|
164
|
+
const fixedLength = byteLength([head, requiredTail].filter((s) => s !== "").join(SEP));
|
|
165
|
+
const actionBudget = MAX - fixedLength - (action !== "" ? byteLength(SEP) : 0);
|
|
166
|
+
const fittedAction = actionBudget <= 0
|
|
167
|
+
? ""
|
|
168
|
+
: byteLength(action) > actionBudget
|
|
169
|
+
? fitBytes(action, actionBudget, true)
|
|
170
|
+
: action;
|
|
171
|
+
return clamp([head, fittedAction, requiredTail].filter((s) => s !== "").join(SEP));
|
|
172
|
+
}
|
|
173
|
+
const headOnly = head !== "" ? head : action;
|
|
174
|
+
const withAction = [head, action].filter((s) => s !== "").join(SEP);
|
|
175
|
+
if (byteLength(withAction) <= MAX)
|
|
176
|
+
return clamp(withAction);
|
|
177
|
+
return clamp(headOnly);
|
|
178
|
+
}
|
|
179
|
+
export function formatTokens(n) {
|
|
180
|
+
if (n >= 1_000_000_000_000)
|
|
181
|
+
return `${(n / 1_000_000_000_000).toFixed(1).replace(/\.0$/, "")}T`;
|
|
182
|
+
if (n >= 1_000_000_000)
|
|
183
|
+
return `${(n / 1_000_000_000).toFixed(1).replace(/\.0$/, "")}B`;
|
|
184
|
+
if (n >= 1_000_000)
|
|
185
|
+
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
|
186
|
+
if (n >= 1_000)
|
|
187
|
+
return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}K`;
|
|
188
|
+
return String(Math.round(n));
|
|
189
|
+
}
|
|
190
|
+
export function formatCost(usd) {
|
|
191
|
+
if (usd >= 0.1)
|
|
192
|
+
return `$${usd.toFixed(2)}`;
|
|
193
|
+
if (usd >= 0.01)
|
|
194
|
+
return `$${usd.toFixed(3)}`;
|
|
195
|
+
return `$${usd.toFixed(4)}`;
|
|
196
|
+
}
|
|
197
|
+
export function formatMonthlyUsage(state) {
|
|
198
|
+
const monthly = state.monthlyUsage;
|
|
199
|
+
if (!monthly || (monthly.totalTokens <= 0 && monthly.costUsd <= 0))
|
|
200
|
+
return undefined;
|
|
201
|
+
if (!monthly.day || !monthly.week || !monthly.allTime) {
|
|
202
|
+
return `Month\u00a0$${formatMonthlyCost(monthly.costUsd)}·${formatTokens(monthly.totalTokens)}\u00a0tok`;
|
|
203
|
+
}
|
|
204
|
+
const period = (label, usage) => `${label}\u00a0$${formatMonthlyCost(usage.costUsd)}·${formatTokens(usage.totalTokens)}\u00a0tok`;
|
|
205
|
+
return clampBytes([
|
|
206
|
+
period("Day", monthly.day),
|
|
207
|
+
period("Week", monthly.week),
|
|
208
|
+
period("Month", monthly),
|
|
209
|
+
period("Total", monthly.allTime),
|
|
210
|
+
].join("\n"), MAX);
|
|
211
|
+
}
|
|
212
|
+
function formatMonthlyCost(costUsd) {
|
|
213
|
+
if (costUsd < 10)
|
|
214
|
+
return costUsd.toFixed(2);
|
|
215
|
+
if (costUsd < 100)
|
|
216
|
+
return costUsd.toFixed(1).replace(/\.0$/, "");
|
|
217
|
+
if (Math.round(costUsd) < 1_000)
|
|
218
|
+
return String(Math.round(costUsd));
|
|
219
|
+
return `${(costUsd / 1_000).toFixed(2).replace(/\.00$/, "").replace(/(\.\d)0$/, "$1")}K`;
|
|
220
|
+
}
|
|
221
|
+
function clampBytes(text, maxBytes) {
|
|
222
|
+
const encoder = new TextEncoder();
|
|
223
|
+
if (encoder.encode(text).length <= maxBytes)
|
|
224
|
+
return text;
|
|
225
|
+
let out = text;
|
|
226
|
+
while (out.length > 0 && encoder.encode(out).length > maxBytes)
|
|
227
|
+
out = out.slice(0, -1);
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
export function buildHoverText(state, appName) {
|
|
231
|
+
const usage = state.usage;
|
|
232
|
+
if (!usage)
|
|
233
|
+
return appName;
|
|
234
|
+
const cost = state.costBreakdown;
|
|
235
|
+
const seg = (label, tokens, price) => price !== undefined ? `${label} ${formatTokens(tokens)} ${formatCost(price)}` : `${label} ${formatTokens(tokens)}`;
|
|
236
|
+
const parts = [seg("In", usage.input, cost?.input), seg("Out", usage.output, cost?.output)];
|
|
237
|
+
if (usage.cacheRead > 0)
|
|
238
|
+
parts.push(seg("Cache R", usage.cacheRead, cost?.cacheRead));
|
|
239
|
+
if (usage.cacheWrite > 0)
|
|
240
|
+
parts.push(seg("Cache W", usage.cacheWrite, cost?.cacheWrite));
|
|
241
|
+
if (cost)
|
|
242
|
+
parts.push(`Total ${formatCost(cost.total)}`);
|
|
243
|
+
const encoder = new TextEncoder();
|
|
244
|
+
while (parts.length > 1 && encoder.encode(parts.join(SEP)).length > 128)
|
|
245
|
+
parts.pop();
|
|
246
|
+
return clampBytes(parts.join(SEP), 128);
|
|
247
|
+
}
|
|
248
|
+
export function buildActivity(state, assets) {
|
|
249
|
+
const activity = {
|
|
250
|
+
type: 0,
|
|
251
|
+
name: assets.appName,
|
|
252
|
+
details: buildDetails(state),
|
|
253
|
+
state: buildStateLine(state),
|
|
254
|
+
};
|
|
255
|
+
const hover = buildHoverText(state, assets.appName);
|
|
256
|
+
if (assets.largeImageKey) {
|
|
257
|
+
activity.largeImageKey = assets.largeImageKey;
|
|
258
|
+
activity.largeImageText = hover;
|
|
259
|
+
}
|
|
260
|
+
else if (assets.largeImageUrl) {
|
|
261
|
+
activity.largeImageUrl = assets.largeImageUrl;
|
|
262
|
+
activity.largeImageText = hover;
|
|
263
|
+
}
|
|
264
|
+
const monthlyHover = formatMonthlyUsage(state);
|
|
265
|
+
if (monthlyHover && assets.smallImageKey) {
|
|
266
|
+
activity.smallImageKey = assets.smallImageKey;
|
|
267
|
+
activity.smallImageText = monthlyHover;
|
|
268
|
+
}
|
|
269
|
+
else if (monthlyHover && assets.smallImageUrl) {
|
|
270
|
+
activity.smallImageUrl = assets.smallImageUrl;
|
|
271
|
+
activity.smallImageText = monthlyHover;
|
|
272
|
+
}
|
|
273
|
+
if (state.startTimestamp)
|
|
274
|
+
activity.startTimestamp = state.startTimestamp;
|
|
275
|
+
return activity;
|
|
276
|
+
}
|
|
277
|
+
export function activityEquals(a, b) {
|
|
278
|
+
if (a === b)
|
|
279
|
+
return true;
|
|
280
|
+
if (!a || !b)
|
|
281
|
+
return false;
|
|
282
|
+
return (a.type === b.type &&
|
|
283
|
+
a.name === b.name &&
|
|
284
|
+
a.statusDisplayType === b.statusDisplayType &&
|
|
285
|
+
a.details === b.details &&
|
|
286
|
+
a.state === b.state &&
|
|
287
|
+
a.largeImageKey === b.largeImageKey &&
|
|
288
|
+
a.largeImageUrl === b.largeImageUrl &&
|
|
289
|
+
a.largeImageText === b.largeImageText &&
|
|
290
|
+
a.smallImageKey === b.smallImageKey &&
|
|
291
|
+
a.smallImageUrl === b.smallImageUrl &&
|
|
292
|
+
a.smallImageText === b.smallImageText &&
|
|
293
|
+
a.startTimestamp === b.startTimestamp &&
|
|
294
|
+
a.endTimestamp === b.endTimestamp);
|
|
295
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { Client } from "@xhayper/discord-rpc";
|
|
2
|
+
import { activityEquals } from "./presence-builder.js";
|
|
3
|
+
import { createLogger } from "../util/logger.js";
|
|
4
|
+
const log = createLogger("rpc");
|
|
5
|
+
const MIN_INTERVAL_MS = 2_000;
|
|
6
|
+
const REFRESH_INTERVAL_MS = 60_000;
|
|
7
|
+
const LOGIN_TIMEOUT_MS = 15_000;
|
|
8
|
+
const BACKOFFS_MS = [10_000, 30_000, 60_000];
|
|
9
|
+
function withTimeout(promise, ms) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const timer = setTimeout(() => reject(new Error(`login timed out after ${ms}ms`)), ms);
|
|
12
|
+
promise.then((value) => {
|
|
13
|
+
clearTimeout(timer);
|
|
14
|
+
resolve(value);
|
|
15
|
+
}, (err) => {
|
|
16
|
+
clearTimeout(timer);
|
|
17
|
+
reject(err);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
export class RpcClient {
|
|
22
|
+
factory;
|
|
23
|
+
injected;
|
|
24
|
+
client;
|
|
25
|
+
ready = false;
|
|
26
|
+
stopped = false;
|
|
27
|
+
connecting = false;
|
|
28
|
+
desired = null;
|
|
29
|
+
desiredPid = process.pid;
|
|
30
|
+
lastSent;
|
|
31
|
+
lastSentPid;
|
|
32
|
+
lastSendAt = 0;
|
|
33
|
+
sending = false;
|
|
34
|
+
flushTimer;
|
|
35
|
+
refreshTimer;
|
|
36
|
+
reconnectTimer;
|
|
37
|
+
reconnectAttempt = 0;
|
|
38
|
+
connectionGeneration = 0;
|
|
39
|
+
constructor(applicationId, transport) {
|
|
40
|
+
if (typeof transport === "function") {
|
|
41
|
+
this.factory = transport;
|
|
42
|
+
}
|
|
43
|
+
else if (transport) {
|
|
44
|
+
this.injected = transport;
|
|
45
|
+
this.factory = () => transport;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
this.factory = () => new Client({ clientId: applicationId });
|
|
49
|
+
}
|
|
50
|
+
if (this.injected)
|
|
51
|
+
this.acquireClient();
|
|
52
|
+
}
|
|
53
|
+
start() {
|
|
54
|
+
this.stopped = false;
|
|
55
|
+
if (!this.refreshTimer) {
|
|
56
|
+
this.refreshTimer = setInterval(() => this.refresh(), REFRESH_INTERVAL_MS);
|
|
57
|
+
}
|
|
58
|
+
void this.connect();
|
|
59
|
+
}
|
|
60
|
+
async stop() {
|
|
61
|
+
this.stopped = true;
|
|
62
|
+
if (this.flushTimer)
|
|
63
|
+
clearTimeout(this.flushTimer);
|
|
64
|
+
if (this.reconnectTimer)
|
|
65
|
+
clearTimeout(this.reconnectTimer);
|
|
66
|
+
if (this.refreshTimer)
|
|
67
|
+
clearInterval(this.refreshTimer);
|
|
68
|
+
this.flushTimer = undefined;
|
|
69
|
+
this.reconnectTimer = undefined;
|
|
70
|
+
this.refreshTimer = undefined;
|
|
71
|
+
const client = this.client;
|
|
72
|
+
if (!client)
|
|
73
|
+
return;
|
|
74
|
+
if (this.ready) {
|
|
75
|
+
await client.request("SET_ACTIVITY", { pid: this.desiredPid }).catch(() => undefined);
|
|
76
|
+
}
|
|
77
|
+
this.ready = false;
|
|
78
|
+
await client.destroy().catch(() => undefined);
|
|
79
|
+
}
|
|
80
|
+
acquireClient() {
|
|
81
|
+
const previous = this.client;
|
|
82
|
+
const next = this.factory();
|
|
83
|
+
if (previous === next)
|
|
84
|
+
return next;
|
|
85
|
+
if (previous && !this.injected)
|
|
86
|
+
void previous.destroy().catch(() => undefined);
|
|
87
|
+
this.client = next;
|
|
88
|
+
next.on("ready", () => {
|
|
89
|
+
if (this.client !== next)
|
|
90
|
+
return;
|
|
91
|
+
this.connectionGeneration++;
|
|
92
|
+
this.ready = true;
|
|
93
|
+
this.lastSent = undefined;
|
|
94
|
+
this.lastSentPid = undefined;
|
|
95
|
+
this.lastSendAt = 0;
|
|
96
|
+
this.reconnectAttempt = 0;
|
|
97
|
+
log.info("connected to Discord");
|
|
98
|
+
this.flush();
|
|
99
|
+
});
|
|
100
|
+
next.on("disconnected", () => {
|
|
101
|
+
if (this.client !== next)
|
|
102
|
+
return;
|
|
103
|
+
this.connectionGeneration++;
|
|
104
|
+
this.ready = false;
|
|
105
|
+
log.warn("disconnected from Discord");
|
|
106
|
+
this.scheduleReconnect();
|
|
107
|
+
});
|
|
108
|
+
return next;
|
|
109
|
+
}
|
|
110
|
+
async connect() {
|
|
111
|
+
if (this.stopped || this.connecting || this.ready)
|
|
112
|
+
return;
|
|
113
|
+
this.connecting = true;
|
|
114
|
+
try {
|
|
115
|
+
const client = this.acquireClient();
|
|
116
|
+
await withTimeout(Promise.resolve(client.login()), LOGIN_TIMEOUT_MS);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
this.ready = false;
|
|
120
|
+
const message = err.message;
|
|
121
|
+
log.warn(`connect failed (${message}); Discord may be closed`);
|
|
122
|
+
if (!this.injected && this.client) {
|
|
123
|
+
const stale = this.client;
|
|
124
|
+
this.client = undefined;
|
|
125
|
+
void stale.destroy().catch(() => undefined);
|
|
126
|
+
}
|
|
127
|
+
this.scheduleReconnect();
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
this.connecting = false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
scheduleReconnect() {
|
|
134
|
+
if (this.stopped || this.reconnectTimer)
|
|
135
|
+
return;
|
|
136
|
+
const delay = BACKOFFS_MS[Math.min(this.reconnectAttempt, BACKOFFS_MS.length - 1)];
|
|
137
|
+
this.reconnectAttempt++;
|
|
138
|
+
this.reconnectTimer = setTimeout(() => {
|
|
139
|
+
this.reconnectTimer = undefined;
|
|
140
|
+
void this.connect();
|
|
141
|
+
}, delay);
|
|
142
|
+
}
|
|
143
|
+
setActivity(activity, pid) {
|
|
144
|
+
this.desired = activity;
|
|
145
|
+
if (pid !== undefined && Number.isInteger(pid) && pid > 0) {
|
|
146
|
+
this.desiredPid = pid;
|
|
147
|
+
}
|
|
148
|
+
else if (activity !== null) {
|
|
149
|
+
this.desiredPid = process.pid;
|
|
150
|
+
}
|
|
151
|
+
this.scheduleFlush();
|
|
152
|
+
}
|
|
153
|
+
refresh() {
|
|
154
|
+
if (this.stopped || !this.ready || this.desired === null)
|
|
155
|
+
return;
|
|
156
|
+
if (Date.now() - this.lastSendAt < REFRESH_INTERVAL_MS)
|
|
157
|
+
return;
|
|
158
|
+
this.lastSent = undefined;
|
|
159
|
+
this.flush();
|
|
160
|
+
}
|
|
161
|
+
scheduleFlush() {
|
|
162
|
+
const wait = Math.max(0, MIN_INTERVAL_MS - (Date.now() - this.lastSendAt));
|
|
163
|
+
if (wait === 0) {
|
|
164
|
+
this.flush();
|
|
165
|
+
}
|
|
166
|
+
else if (!this.flushTimer) {
|
|
167
|
+
this.flushTimer = setTimeout(() => {
|
|
168
|
+
this.flushTimer = undefined;
|
|
169
|
+
this.flush();
|
|
170
|
+
}, wait);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
flush() {
|
|
174
|
+
if (this.flushTimer) {
|
|
175
|
+
clearTimeout(this.flushTimer);
|
|
176
|
+
this.flushTimer = undefined;
|
|
177
|
+
}
|
|
178
|
+
if (!this.ready || this.sending)
|
|
179
|
+
return;
|
|
180
|
+
const user = this.client?.user;
|
|
181
|
+
if (!user)
|
|
182
|
+
return;
|
|
183
|
+
const desired = this.desired;
|
|
184
|
+
const pid = this.desiredPid;
|
|
185
|
+
if (desired === null) {
|
|
186
|
+
if (this.lastSent === null && this.lastSentPid === pid)
|
|
187
|
+
return;
|
|
188
|
+
void this.send(desired, pid);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (this.lastSentPid === pid && this.lastSent !== null && activityEquals(desired, this.lastSent))
|
|
192
|
+
return;
|
|
193
|
+
void this.send(desired, pid);
|
|
194
|
+
}
|
|
195
|
+
async send(activity, pid) {
|
|
196
|
+
const client = this.client;
|
|
197
|
+
const user = client?.user;
|
|
198
|
+
if (!client || !user || !this.ready || this.stopped)
|
|
199
|
+
return;
|
|
200
|
+
this.sending = true;
|
|
201
|
+
const generation = this.connectionGeneration;
|
|
202
|
+
this.lastSendAt = Date.now();
|
|
203
|
+
try {
|
|
204
|
+
if (activity === null) {
|
|
205
|
+
await client.request("SET_ACTIVITY", { pid });
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
await user.setActivity(activity, pid);
|
|
209
|
+
}
|
|
210
|
+
if (generation === this.connectionGeneration) {
|
|
211
|
+
this.lastSent = activity;
|
|
212
|
+
this.lastSentPid = pid;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
log.warn(`${activity === null ? "clearActivity" : "setActivity"} failed: ${err.message}`);
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
this.sending = false;
|
|
220
|
+
if (!this.stopped)
|
|
221
|
+
this.scheduleFlush();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { loadConfig } from "./config.js";
|
|
3
|
+
import { startServer } from "./server/http-server.js";
|
|
4
|
+
import { SessionStore } from "./claude/session-store.js";
|
|
5
|
+
import { DesktopFocusWatcher } from "./claude/desktop-focus.js";
|
|
6
|
+
import { ClaudeDefaultSelectionWatcher } from "./claude/default-selection.js";
|
|
7
|
+
import { ClaudeAppLiveness } from "./claude/app-liveness.js";
|
|
8
|
+
import { UsagePoller } from "./claude/usage-poller.js";
|
|
9
|
+
import { ClaudeMonthlyUsageWatcher } from "./claude/monthly-usage.js";
|
|
10
|
+
import { getDefaultEffort, getPlanName } from "./claude/plan-info.js";
|
|
11
|
+
import { loadPricingOverrides } from "./claude/cost.js";
|
|
12
|
+
import { buildActivity } from "./discord/presence-builder.js";
|
|
13
|
+
import { RpcClient } from "./discord/rpc-client.js";
|
|
14
|
+
import { CLAUDE_WINDOWS_PROCESS_RULES, ProcessLiveness, } from "./util/process-liveness.js";
|
|
15
|
+
import { ProcessRuleTracker, WindowsProcessScanWatcher } from "./util/process-scan-watcher.js";
|
|
16
|
+
import { configureLogger, createLogger } from "./util/logger.js";
|
|
17
|
+
import { activityAssetsForTheme } from "./appearance/theme-assets.js";
|
|
18
|
+
import { AppearanceThemeWatcher } from "./appearance/theme-watcher.js";
|
|
19
|
+
import { RemoteTunnelManager } from "./remote/tunnel-manager.js";
|
|
20
|
+
const config = loadConfig();
|
|
21
|
+
mkdirSync(config.dataDir, { recursive: true });
|
|
22
|
+
configureLogger(config.logFile);
|
|
23
|
+
const log = createLogger("main");
|
|
24
|
+
process.on("uncaughtException", (err) => {
|
|
25
|
+
log.error(`uncaught exception: ${err.stack ?? err.message}`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
});
|
|
28
|
+
process.on("unhandledRejection", (reason) => {
|
|
29
|
+
log.error(`unhandled rejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}`);
|
|
30
|
+
});
|
|
31
|
+
if (loadPricingOverrides())
|
|
32
|
+
log.info("loaded pricing overrides");
|
|
33
|
+
const assets = {
|
|
34
|
+
appName: config.appName,
|
|
35
|
+
largeImageKey: config.largeImageKey,
|
|
36
|
+
largeImageKeyLight: config.largeImageKeyLight,
|
|
37
|
+
largeImageKeyDark: config.largeImageKeyDark,
|
|
38
|
+
largeImageUrl: config.largeImageUrl,
|
|
39
|
+
smallImageKey: config.smallImageKey,
|
|
40
|
+
smallImageKeyLight: config.smallImageKeyLight,
|
|
41
|
+
smallImageKeyDark: config.smallImageKeyDark,
|
|
42
|
+
smallImageUrl: config.smallImageUrl,
|
|
43
|
+
};
|
|
44
|
+
const rpc = new RpcClient(config.applicationId);
|
|
45
|
+
let theme = "light";
|
|
46
|
+
let trackedPid;
|
|
47
|
+
let desired = null;
|
|
48
|
+
function coalesce(fn) {
|
|
49
|
+
let queued = false;
|
|
50
|
+
return () => {
|
|
51
|
+
if (queued)
|
|
52
|
+
return;
|
|
53
|
+
queued = true;
|
|
54
|
+
queueMicrotask(() => {
|
|
55
|
+
queued = false;
|
|
56
|
+
fn();
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function updateActivity() {
|
|
61
|
+
const snapshot = store.snapshot();
|
|
62
|
+
desired = snapshot ? buildActivity(snapshot, activityAssetsForTheme(assets, theme)) : null;
|
|
63
|
+
rpc.setActivity(desired, trackedPid);
|
|
64
|
+
}
|
|
65
|
+
const store = new SessionStore(coalesce(updateActivity));
|
|
66
|
+
const livenessSources = new Map();
|
|
67
|
+
function updateLivenessSource(source, alive, startedAt, pid) {
|
|
68
|
+
livenessSources.set(source, { alive, startedAt, pid });
|
|
69
|
+
const live = [...livenessSources.values()].filter((state) => state.alive);
|
|
70
|
+
const earliest = live.reduce((current, state) => {
|
|
71
|
+
if (!current)
|
|
72
|
+
return state;
|
|
73
|
+
if (state.startedAt === undefined)
|
|
74
|
+
return current;
|
|
75
|
+
if (current.startedAt === undefined || state.startedAt < current.startedAt)
|
|
76
|
+
return state;
|
|
77
|
+
return current;
|
|
78
|
+
}, undefined);
|
|
79
|
+
const aliveNow = live.length > 0;
|
|
80
|
+
trackedPid = aliveNow ? earliest?.pid ?? live.find((state) => state.pid)?.pid : undefined;
|
|
81
|
+
store.setAppLiveness(aliveNow, earliest?.startedAt);
|
|
82
|
+
if (desired)
|
|
83
|
+
rpc.setActivity(desired, trackedPid);
|
|
84
|
+
}
|
|
85
|
+
const processScan = process.platform === "win32"
|
|
86
|
+
? (() => {
|
|
87
|
+
const tracker = new ProcessRuleTracker("claude", CLAUDE_WINDOWS_PROCESS_RULES, (alive, startedAt, pid) => updateLivenessSource("process", alive, startedAt, pid));
|
|
88
|
+
return new WindowsProcessScanWatcher("^claude$", (processes) => tracker.update(processes));
|
|
89
|
+
})()
|
|
90
|
+
: undefined;
|
|
91
|
+
const sessionLiveness = process.platform === "win32"
|
|
92
|
+
? undefined
|
|
93
|
+
: new ClaudeAppLiveness((alive, startedAt, pid) => updateLivenessSource("session", alive, startedAt, pid));
|
|
94
|
+
const processLiveness = process.platform === "win32"
|
|
95
|
+
? undefined
|
|
96
|
+
: new ProcessLiveness(/^claude$/i, (alive, startedAt, pid) => updateLivenessSource("process", alive, startedAt, pid));
|
|
97
|
+
const desktopFocus = config.desktopSessionsDir
|
|
98
|
+
? new DesktopFocusWatcher(config.desktopSessionsDir, (focus) => store.setDesktopFocus(focus.cliSessionId, focus))
|
|
99
|
+
: undefined;
|
|
100
|
+
const defaultSelection = new ClaudeDefaultSelectionWatcher({}, (selection) => {
|
|
101
|
+
store.setDefaultModel(selection.model);
|
|
102
|
+
store.setDefaultEffort(selection.effort);
|
|
103
|
+
});
|
|
104
|
+
const poller = new UsagePoller(config.usagePollIntervalMs, (limits) => store.setUsageLimits(limits));
|
|
105
|
+
const monthlyUsage = new ClaudeMonthlyUsageWatcher((usage) => store.setMonthlyUsage(false, usage));
|
|
106
|
+
const themeWatcher = new AppearanceThemeWatcher({}, (themes) => {
|
|
107
|
+
theme = themes.claude;
|
|
108
|
+
updateActivity();
|
|
109
|
+
});
|
|
110
|
+
const server = startServer(config.port, {
|
|
111
|
+
onHook: (payload) => store.handleHook(payload),
|
|
112
|
+
onStatusline: (payload) => store.handleStatusline(payload),
|
|
113
|
+
});
|
|
114
|
+
const remoteTunnels = new RemoteTunnelManager(config.remoteHosts, config.port, config.remotePort);
|
|
115
|
+
async function refreshPlan() {
|
|
116
|
+
try {
|
|
117
|
+
store.setPlanName(await getPlanName());
|
|
118
|
+
store.setDefaultEffort(await getDefaultEffort());
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
log.warn(`plan refresh failed: ${err.message}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
rpc.start();
|
|
125
|
+
themeWatcher.start();
|
|
126
|
+
poller.start();
|
|
127
|
+
if (desktopFocus)
|
|
128
|
+
void desktopFocus.start();
|
|
129
|
+
defaultSelection.start();
|
|
130
|
+
processScan?.start();
|
|
131
|
+
sessionLiveness?.start();
|
|
132
|
+
processLiveness?.start();
|
|
133
|
+
monthlyUsage.start();
|
|
134
|
+
remoteTunnels.start();
|
|
135
|
+
void refreshPlan();
|
|
136
|
+
const planTimer = setInterval(() => void refreshPlan(), 30 * 60 * 1000);
|
|
137
|
+
let shuttingDown = false;
|
|
138
|
+
async function shutdown(signal) {
|
|
139
|
+
if (shuttingDown)
|
|
140
|
+
return;
|
|
141
|
+
shuttingDown = true;
|
|
142
|
+
log.info(`shutting down (${signal})`);
|
|
143
|
+
clearInterval(planTimer);
|
|
144
|
+
themeWatcher.stop();
|
|
145
|
+
poller.stop();
|
|
146
|
+
desktopFocus?.stop();
|
|
147
|
+
defaultSelection.stop();
|
|
148
|
+
processScan?.stop();
|
|
149
|
+
sessionLiveness?.stop();
|
|
150
|
+
processLiveness?.stop();
|
|
151
|
+
monthlyUsage.stop();
|
|
152
|
+
remoteTunnels.stop();
|
|
153
|
+
store.dispose();
|
|
154
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
155
|
+
await rpc.stop();
|
|
156
|
+
process.exit(0);
|
|
157
|
+
}
|
|
158
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
159
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
160
|
+
log.info("Claude Code Discord Presence started");
|