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
@@ -0,0 +1,196 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createLogger } from "./logger.js";
3
+ import { matchesWindowsProcess, } from "./process-liveness.js";
4
+ const log = createLogger("proc-scan");
5
+ const SCAN_INTERVAL_S = 5;
6
+ const RESTART_DELAY_MS = 1_000;
7
+ const STALL_TIMEOUT_MS = 30_000;
8
+ const DEAD_CONFIRMATIONS = 2;
9
+ function buildScript(nameFilter) {
10
+ return String.raw `
11
+ $namePattern = '${nameFilter.replace(/'/g, "''")}'
12
+ while ($true) {
13
+ $items = @(Get-CimInstance Win32_Process | ForEach-Object {
14
+ $processName = [System.IO.Path]::GetFileNameWithoutExtension([string]$_.Name)
15
+ if ($processName -match $namePattern) {
16
+ $hasMainWindow = $false
17
+ try { $hasMainWindow = (Get-Process -Id $_.ProcessId -ErrorAction Stop).MainWindowHandle -ne 0 } catch {}
18
+ $startedAt = 0
19
+ try { $startedAt = ([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds() } catch {}
20
+ [PSCustomObject]@{
21
+ pid = [int]$_.ProcessId
22
+ name = $processName
23
+ path = [string]$_.ExecutablePath
24
+ commandLine = [string]$_.CommandLine
25
+ hasMainWindow = $hasMainWindow
26
+ startedAt = $startedAt
27
+ }
28
+ }
29
+ })
30
+ [Console]::Out.WriteLine((ConvertTo-Json -InputObject @($items) -Compress))
31
+ [Console]::Out.Flush()
32
+ Start-Sleep -Seconds ${SCAN_INTERVAL_S}
33
+ }
34
+ `;
35
+ }
36
+ function parseProcesses(raw) {
37
+ try {
38
+ const parsed = JSON.parse(raw);
39
+ if (!Array.isArray(parsed))
40
+ return undefined;
41
+ const result = [];
42
+ for (const value of parsed) {
43
+ if (!value || typeof value !== "object")
44
+ return undefined;
45
+ const item = value;
46
+ if (typeof item.name !== "string" ||
47
+ typeof item.pid !== "number" ||
48
+ !Number.isInteger(item.pid) ||
49
+ item.pid <= 0 ||
50
+ typeof item.path !== "string" ||
51
+ typeof item.commandLine !== "string" ||
52
+ typeof item.hasMainWindow !== "boolean" ||
53
+ typeof item.startedAt !== "number" ||
54
+ !Number.isFinite(item.startedAt)) {
55
+ return undefined;
56
+ }
57
+ result.push({
58
+ pid: item.pid,
59
+ name: item.name,
60
+ path: item.path,
61
+ commandLine: item.commandLine,
62
+ hasMainWindow: item.hasMainWindow,
63
+ startedAt: item.startedAt,
64
+ });
65
+ }
66
+ return result;
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ }
72
+ export class ProcessRuleTracker {
73
+ label;
74
+ rules;
75
+ onUpdate;
76
+ lastAlive;
77
+ deadStreak = 0;
78
+ constructor(label, rules, onUpdate) {
79
+ this.label = label;
80
+ this.rules = rules;
81
+ this.onUpdate = onUpdate;
82
+ }
83
+ update(processes) {
84
+ let alive = false;
85
+ let earliest;
86
+ let pid;
87
+ for (const info of processes) {
88
+ if (!this.rules.some((rule) => matchesWindowsProcess(info, rule)))
89
+ continue;
90
+ alive = true;
91
+ const ms = info.startedAt;
92
+ if (Number.isFinite(ms) && ms > 0 && (earliest === undefined || ms < earliest)) {
93
+ earliest = ms;
94
+ pid = info.pid;
95
+ }
96
+ else if (pid === undefined) {
97
+ pid = info.pid;
98
+ }
99
+ }
100
+ if (alive) {
101
+ this.deadStreak = 0;
102
+ }
103
+ else {
104
+ this.deadStreak++;
105
+ if (this.lastAlive === true && this.deadStreak < DEAD_CONFIRMATIONS)
106
+ return;
107
+ }
108
+ if (alive !== this.lastAlive) {
109
+ this.lastAlive = alive;
110
+ log.debug(`${this.label} alive=${alive}` +
111
+ (earliest !== undefined ? ` earliestStartedAt=${earliest}` : "") +
112
+ (pid !== undefined ? ` pid=${pid}` : ""));
113
+ }
114
+ this.onUpdate(alive, earliest, pid);
115
+ }
116
+ }
117
+ export class WindowsProcessScanWatcher {
118
+ nameFilter;
119
+ onProcesses;
120
+ child;
121
+ stopped = false;
122
+ lastLineAt = 0;
123
+ stallTimer;
124
+ restartTimer;
125
+ constructor(nameFilter, onProcesses) {
126
+ this.nameFilter = nameFilter;
127
+ this.onProcesses = onProcesses;
128
+ }
129
+ start() {
130
+ if (process.platform !== "win32" || this.child || this.stallTimer)
131
+ return;
132
+ this.stopped = false;
133
+ this.lastLineAt = Date.now();
134
+ this.stallTimer = setInterval(() => {
135
+ if (this.child && Date.now() - this.lastLineAt > STALL_TIMEOUT_MS) {
136
+ log.warn("process scan stalled; restarting the scanner");
137
+ this.lastLineAt = Date.now();
138
+ try {
139
+ this.child.kill();
140
+ }
141
+ catch { }
142
+ }
143
+ }, STALL_TIMEOUT_MS);
144
+ this.spawnChild();
145
+ }
146
+ stop() {
147
+ this.stopped = true;
148
+ if (this.stallTimer)
149
+ clearInterval(this.stallTimer);
150
+ this.stallTimer = undefined;
151
+ if (this.restartTimer)
152
+ clearTimeout(this.restartTimer);
153
+ this.restartTimer = undefined;
154
+ this.child?.kill();
155
+ this.child = undefined;
156
+ }
157
+ spawnChild() {
158
+ if (this.stopped)
159
+ return;
160
+ const child = spawn("powershell", ["-NoProfile", "-NonInteractive", "-Command", buildScript(this.nameFilter)], { windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
161
+ this.child = child;
162
+ this.lastLineAt = Date.now();
163
+ let pending = "";
164
+ child.stdout?.setEncoding("utf8");
165
+ child.stdout?.on("data", (chunk) => {
166
+ pending += chunk;
167
+ const lines = pending.split(/\r?\n/);
168
+ pending = lines.pop() ?? "";
169
+ for (const line of lines)
170
+ this.report(line);
171
+ });
172
+ child.on("error", (err) => log.debug(`scan spawn failed: ${err.message}`));
173
+ child.on("exit", () => {
174
+ if (this.child === child)
175
+ this.child = undefined;
176
+ if (this.stopped)
177
+ return;
178
+ this.restartTimer = setTimeout(() => {
179
+ this.restartTimer = undefined;
180
+ this.spawnChild();
181
+ }, RESTART_DELAY_MS);
182
+ });
183
+ }
184
+ report(line) {
185
+ const trimmed = line.trim();
186
+ if (trimmed === "")
187
+ return;
188
+ this.lastLineAt = Date.now();
189
+ const processes = parseProcesses(trimmed);
190
+ if (!processes) {
191
+ log.debug("process scan returned invalid JSON");
192
+ return;
193
+ }
194
+ this.onProcesses(processes);
195
+ }
196
+ }
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "claude-code-discord-presence",
3
+ "version": "1.0.0",
4
+ "description": "Cross-platform Discord Rich Presence for Claude Code Desktop and CLI with live models, limits, costs, tools, agents, statusline, and secure SSH remotes.",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "claude-code-discord-presence": "dist/cli.js",
9
+ "claude-code-presence": "dist/cli.js",
10
+ "claude-discord-presence": "dist/cli.js"
11
+ },
12
+ "files": [
13
+ "dist/",
14
+ "scripts/",
15
+ "assets/",
16
+ "pricing.json",
17
+ ".env.example",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "keywords": [
22
+ "claude-code",
23
+ "anthropic",
24
+ "claude-code-cli",
25
+ "claude-desktop",
26
+ "discord-rich-presence",
27
+ "discord-rpc",
28
+ "statusline",
29
+ "hooks",
30
+ "developer-tools",
31
+ "cross-platform"
32
+ ],
33
+ "author": "dayfinggg",
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/dayfinggg/claude-code-discord-presence.git"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/dayfinggg/claude-code-discord-presence/issues"
41
+ },
42
+ "homepage": "https://github.com/dayfinggg/claude-code-discord-presence#readme",
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "engines": {
47
+ "node": ">=22.18.0",
48
+ "npm": ">=10"
49
+ },
50
+ "scripts": {
51
+ "prestart": "npm run build",
52
+ "start": "node --disable-warning=ExperimentalWarning --env-file-if-exists=.env --enable-source-maps dist/index.js",
53
+ "dev": "node --disable-warning=ExperimentalWarning --env-file-if-exists=.env --import=tsx --watch src/index.ts",
54
+ "build": "tsc --project tsconfig.build.json",
55
+ "typecheck": "tsc --noEmit",
56
+ "test": "node --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs run",
57
+ "setup": "node scripts/setup-claude.mjs",
58
+ "remote:setup": "node --env-file-if-exists=.env scripts/install-remote.mjs",
59
+ "autostart": "npm run build && node scripts/install-autostart.mjs",
60
+ "autostart:remove": "node scripts/remove-autostart.mjs",
61
+ "prepack": "npm run build",
62
+ "prepublishOnly": "npm test && npm run typecheck"
63
+ },
64
+ "devDependencies": {
65
+ "@types/node": "^26.1.1",
66
+ "tsx": "^4.23.1",
67
+ "typescript": "^7.0.2",
68
+ "vitest": "^4.1.10"
69
+ },
70
+ "dependencies": {
71
+ "@xhayper/discord-rpc": "^1.3.4"
72
+ }
73
+ }
package/pricing.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "codex": {
3
+ "fallback": { "input": 1.25, "cached": 0.125, "output": 10 },
4
+ "models": {
5
+ "gpt-5.6-sol": { "input": 5, "cached": 0.5, "output": 30 },
6
+ "gpt-5.6": { "input": 5, "cached": 0.5, "output": 30 },
7
+ "gpt-5.6-terra": { "input": 2.5, "cached": 0.25, "output": 15 },
8
+ "gpt-5.6-luna": { "input": 1, "cached": 0.1, "output": 6 },
9
+ "gpt-5.5": { "input": 5, "cached": 0.5, "output": 30 },
10
+ "gpt-5.5-pro": { "input": 30, "cached": 3, "output": 180 },
11
+ "gpt-5.4": { "input": 2.5, "cached": 0.25, "output": 15 },
12
+ "gpt-5.4-2026-03-05": { "input": 2.5, "cached": 0.25, "output": 15 },
13
+ "gpt-5.4-mini": { "input": 0.75, "cached": 0.075, "output": 4.5 },
14
+ "gpt-5.3-codex": { "input": 1.75, "cached": 0.175, "output": 14 },
15
+ "gpt-5.3-codex-latest": { "input": 1.75, "cached": 0.175, "output": 14 },
16
+ "gpt-5.3-codex-spark": { "input": 1.75, "cached": 0.175, "output": 14 },
17
+ "gpt-5.2": { "input": 1.75, "cached": 0.175, "output": 14 },
18
+ "gpt-5.2-chat-latest": { "input": 1.75, "cached": 0.175, "output": 14 },
19
+ "gpt-5.2-codex": { "input": 1.75, "cached": 0.175, "output": 14 },
20
+ "gpt-5.1": { "input": 1.25, "cached": 0.125, "output": 10 },
21
+ "gpt-5.1-chat-latest": { "input": 1.25, "cached": 0.125, "output": 10 },
22
+ "gpt-5.1-codex": { "input": 1.25, "cached": 0.125, "output": 10 },
23
+ "gpt-5.1-codex-max": { "input": 1.25, "cached": 0.125, "output": 10 },
24
+ "gpt-5": { "input": 1.25, "cached": 0.125, "output": 10 },
25
+ "gpt-5-chat-latest": { "input": 1.25, "cached": 0.125, "output": 10 },
26
+ "gpt-5-codex": { "input": 1.25, "cached": 0.125, "output": 10 },
27
+ "gpt-5-mini": { "input": 0.25, "cached": 0.025, "output": 2 },
28
+ "gpt-5.1-codex-mini": { "input": 0.25, "cached": 0.025, "output": 2 },
29
+ "gpt-5-nano": { "input": 0.05, "cached": 0.005, "output": 0.4 },
30
+ "codex-mini-latest": { "input": 1.5, "cached": 0.375, "output": 6 }
31
+ }
32
+ },
33
+ "claude": {
34
+ "models": {
35
+ "claude-opus-4-8": { "input": 5, "output": 25, "cacheWrite": 6.25, "cacheWriteOneHour": 10, "cacheRead": 0.5, "fastMultiplier": 2 },
36
+ "claude-opus-4-7": { "input": 5, "output": 25, "cacheWrite": 6.25, "cacheWriteOneHour": 10, "cacheRead": 0.5, "fastMultiplier": 6 },
37
+ "claude-opus-4-6": { "input": 5, "output": 25, "cacheWrite": 6.25, "cacheWriteOneHour": 10, "cacheRead": 0.5, "fastMultiplier": 6 },
38
+ "claude-opus-4-5": { "input": 5, "output": 25, "cacheWrite": 6.25, "cacheWriteOneHour": 10, "cacheRead": 0.5 },
39
+ "claude-opus-4-1": { "input": 15, "output": 75, "cacheWrite": 18.75, "cacheWriteOneHour": 30, "cacheRead": 1.5 },
40
+ "claude-sonnet-5": { "input": 2, "output": 10, "cacheWrite": 2.5, "cacheWriteOneHour": 4, "cacheRead": 0.2 },
41
+ "claude-sonnet-4-5": { "input": 3, "output": 15, "cacheWrite": 3.75, "cacheWriteOneHour": 6, "cacheRead": 0.3 },
42
+ "claude-sonnet-4": { "input": 3, "output": 15, "cacheWrite": 3.75, "cacheWriteOneHour": 6, "cacheRead": 0.3 },
43
+ "claude-fable-5": { "input": 10, "output": 50, "cacheWrite": 12.5, "cacheWriteOneHour": 20, "cacheRead": 1 },
44
+ "claude-haiku-4-5": { "input": 1, "output": 5, "cacheWrite": 1.25, "cacheWriteOneHour": 2, "cacheRead": 0.1 }
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,32 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const MAX_BYTES = 512 * 1024;
6
+ let raw = "";
7
+ for await (const chunk of process.stdin) {
8
+ raw += chunk;
9
+ if (Buffer.byteLength(raw) > MAX_BYTES) process.exit(0);
10
+ }
11
+
12
+ try {
13
+ const configPath = join(dirname(fileURLToPath(import.meta.url)), "config.json");
14
+ const config = JSON.parse(await readFile(configPath, "utf8"));
15
+ const port = Number(config.port);
16
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) process.exit(0);
17
+ let body = raw.trim() || "{}";
18
+ if (config.remote === true) {
19
+ try {
20
+ const payload = JSON.parse(body);
21
+ if (payload && typeof payload === "object" && !Array.isArray(payload)) {
22
+ body = JSON.stringify({ ...payload, remote: true });
23
+ }
24
+ } catch {}
25
+ }
26
+ await fetch(`http://127.0.0.1:${port}/hook`, {
27
+ method: "POST",
28
+ headers: { "content-type": "application/json" },
29
+ body,
30
+ signal: AbortSignal.timeout(1_000),
31
+ }).catch(() => undefined);
32
+ } catch {}
@@ -0,0 +1,50 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { createLaunchAgentPlist, createSystemdUserUnit } from "../dist/util/autostart.js";
7
+
8
+ const projectDir = dirname(dirname(fileURLToPath(import.meta.url)));
9
+ const entryPoint = join(projectDir, "dist", "index.js");
10
+ const envFile = join(projectDir, ".env");
11
+ const label = "com.claude-code.discord-presence";
12
+ const definition = {
13
+ label,
14
+ description: "Claude Code Discord Presence",
15
+ nodePath: process.execPath,
16
+ projectDir,
17
+ envFile: existsSync(envFile) ? envFile : undefined,
18
+ entryPoint,
19
+ };
20
+
21
+ if (!existsSync(entryPoint)) throw new Error("Built service not found. Run npm run build first.");
22
+
23
+ if (process.platform === "win32") {
24
+ execFileSync("powershell", [
25
+ "-NoProfile",
26
+ "-ExecutionPolicy",
27
+ "Bypass",
28
+ "-File",
29
+ join(projectDir, "scripts", "setup-autostart.ps1"),
30
+ ], { stdio: "inherit", windowsHide: true });
31
+ } else if (process.platform === "darwin") {
32
+ const path = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
33
+ mkdirSync(dirname(path), { recursive: true });
34
+ writeFileSync(path, createLaunchAgentPlist(definition), { mode: 0o600 });
35
+ const domain = `gui/${process.getuid()}`;
36
+ try { execFileSync("launchctl", ["bootout", domain, path], { stdio: "ignore" }); } catch {}
37
+ execFileSync("launchctl", ["bootstrap", domain, path], { stdio: "inherit" });
38
+ execFileSync("launchctl", ["kickstart", "-k", `${domain}/${label}`], { stdio: "inherit" });
39
+ console.log(`Installed ${path}`);
40
+ } else if (process.platform === "linux") {
41
+ const unitName = "claude-code-discord-presence.service";
42
+ const path = join(homedir(), ".config", "systemd", "user", unitName);
43
+ mkdirSync(dirname(path), { recursive: true });
44
+ writeFileSync(path, createSystemdUserUnit(definition), { mode: 0o600 });
45
+ execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" });
46
+ execFileSync("systemctl", ["--user", "enable", "--now", unitName], { stdio: "inherit" });
47
+ console.log(`Installed ${path}`);
48
+ } else {
49
+ throw new Error(`Autostart is not supported on ${process.platform}.`);
50
+ }
@@ -0,0 +1,53 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const SSH_ALIAS = /^[a-z0-9](?:[a-z0-9._-]{0,252}[a-z0-9])?$/i;
7
+ const hosts = [...new Set((process.env.CLAUDE_REMOTE_HOSTS || "")
8
+ .split(",").map((host) => host.trim()).filter(Boolean))];
9
+ if (hosts.length === 0) throw new Error("Set CLAUDE_REMOTE_HOSTS to one or more SSH config aliases.");
10
+ const invalid = hosts.find((host) => !SSH_ALIAS.test(host));
11
+ if (invalid) throw new Error(`Unsafe SSH alias: ${JSON.stringify(invalid)}`);
12
+ const port = Number(process.env.CLAUDE_REMOTE_PORT || process.env.PORT || 41724);
13
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error("Invalid CLAUDE_REMOTE_PORT");
14
+
15
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
16
+ const [setup, hook, statusline] = await Promise.all([
17
+ readFile(join(scriptsDir, "setup-remote.mjs"), "utf8"),
18
+ readFile(join(scriptsDir, "hook.mjs")),
19
+ readFile(join(scriptsDir, "statusline.mjs")),
20
+ ]);
21
+ const hookBase64 = hook.toString("base64");
22
+ const statuslineBase64 = statusline.toString("base64");
23
+
24
+ async function install(host) {
25
+ const args = [
26
+ "-o", "BatchMode=yes",
27
+ "-o", "ConnectTimeout=10",
28
+ host,
29
+ "node", "-", String(port), hookBase64, statuslineBase64,
30
+ ];
31
+ await new Promise((resolve, reject) => {
32
+ const child = spawn("ssh", args, { shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
33
+ let stdout = "";
34
+ let stderr = "";
35
+ child.stdout.setEncoding("utf8");
36
+ child.stderr.setEncoding("utf8");
37
+ child.stdout.on("data", (chunk) => { stdout = (stdout + chunk).slice(-8_192); });
38
+ child.stderr.on("data", (chunk) => { stderr = (stderr + chunk).slice(-8_192); });
39
+ child.once("error", reject);
40
+ child.once("exit", (code) => {
41
+ if (code === 0) {
42
+ if (stdout.trim()) console.log(`[${host}] ${stdout.trim()}`);
43
+ resolve();
44
+ } else {
45
+ reject(new Error(`Remote setup failed for ${host}: ${stderr.trim() || `exit ${code}`}`));
46
+ }
47
+ });
48
+ child.stdin.end(setup);
49
+ });
50
+ }
51
+
52
+ await Promise.all(hosts.map(install));
53
+ console.log("Remote hooks are installed. The presence service maintains loopback-only reverse SSH tunnels automatically.");
@@ -0,0 +1,35 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, rmSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const projectDir = dirname(dirname(fileURLToPath(import.meta.url)));
8
+ const label = "com.claude-code.discord-presence";
9
+
10
+ if (process.platform === "win32") {
11
+ execFileSync("powershell", [
12
+ "-NoProfile",
13
+ "-ExecutionPolicy",
14
+ "Bypass",
15
+ "-File",
16
+ join(projectDir, "scripts", "remove-autostart.ps1"),
17
+ ], { stdio: "inherit", windowsHide: true });
18
+ } else if (process.platform === "darwin") {
19
+ const path = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
20
+ const domain = `gui/${process.getuid()}`;
21
+ if (existsSync(path)) {
22
+ try { execFileSync("launchctl", ["bootout", domain, path], { stdio: "ignore" }); } catch {}
23
+ rmSync(path, { force: true });
24
+ }
25
+ console.log(`Removed ${path}`);
26
+ } else if (process.platform === "linux") {
27
+ const unitName = "claude-code-discord-presence.service";
28
+ const path = join(homedir(), ".config", "systemd", "user", unitName);
29
+ try { execFileSync("systemctl", ["--user", "disable", "--now", unitName], { stdio: "inherit" }); } catch {}
30
+ rmSync(path, { force: true });
31
+ execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" });
32
+ console.log(`Removed ${path}`);
33
+ } else {
34
+ throw new Error(`Autostart is not supported on ${process.platform}.`);
35
+ }
@@ -0,0 +1,7 @@
1
+ #Requires -Version 5.1
2
+ $ErrorActionPreference = 'Stop'
3
+
4
+ $name = 'ClaudeCodeDiscordPresence'
5
+ $runKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
6
+ Remove-ItemProperty -Path $runKey -Name $name -ErrorAction SilentlyContinue
7
+ Write-Host "Removed '$name' from $runKey."
@@ -0,0 +1,7 @@
1
+ Dim fso, sh, scriptDir, projectDir
2
+ Set fso = CreateObject("Scripting.FileSystemObject")
3
+ scriptDir = fso.GetParentFolderName(WScript.ScriptFullName)
4
+ projectDir = fso.GetParentFolderName(scriptDir)
5
+ Set sh = CreateObject("WScript.Shell")
6
+ sh.CurrentDirectory = projectDir
7
+ sh.Run "powershell -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File """ & fso.BuildPath(scriptDir, "run-service.ps1") & """", 0, False
@@ -0,0 +1,44 @@
1
+ $ErrorActionPreference = 'Continue'
2
+ $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
3
+ $projectDir = Split-Path -Parent $scriptDir
4
+ $entryPoint = Join-Path $projectDir 'dist\index.js'
5
+ $logFile = Join-Path $env:LOCALAPPDATA 'Claude Code Discord Presence\supervisor.log'
6
+ $maxLogBytes = 1MB
7
+
8
+ $created = $false
9
+ $mutex = New-Object System.Threading.Mutex($true, 'Local\ClaudeCodeDiscordPresenceSupervisor', [ref]$created)
10
+ if (-not $created) { exit 0 }
11
+
12
+ $logDir = Split-Path -Parent $logFile
13
+ if (-not (Test-Path -LiteralPath $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
14
+
15
+ Set-Location $projectDir
16
+
17
+ function Rotate-Log {
18
+ try {
19
+ if (-not (Test-Path -LiteralPath $logFile)) { return }
20
+ if ((Get-Item -LiteralPath $logFile).Length -lt $maxLogBytes) { return }
21
+ $archive = "$logFile.1"
22
+ Remove-Item -LiteralPath $archive -Force -ErrorAction SilentlyContinue
23
+ Move-Item -LiteralPath $logFile -Destination $archive -Force
24
+ } catch {}
25
+ }
26
+
27
+ function Write-BoundedLog([string]$message) {
28
+ Rotate-Log
29
+ try { Add-Content -LiteralPath $logFile -Value $message -Encoding UTF8 } catch {}
30
+ }
31
+
32
+ Rotate-Log
33
+ $fastExits = 0
34
+ while ($true) {
35
+ $startedAt = Get-Date
36
+ & node --disable-warning=ExperimentalWarning --env-file-if-exists=.env --enable-source-maps $entryPoint 2>&1 |
37
+ ForEach-Object { Write-BoundedLog ([string]$_) }
38
+ $exitCode = $LASTEXITCODE
39
+ $uptimeSeconds = [int]((Get-Date) - $startedAt).TotalSeconds
40
+ Write-BoundedLog "[$(Get-Date -Format o)] supervisor: service exited code=$exitCode uptime=${uptimeSeconds}s"
41
+ if ($uptimeSeconds -lt 60) { $fastExits++ } else { $fastExits = 0 }
42
+ $delay = [int][Math]::Min(60, 5 * [Math]::Pow(2, [Math]::Min($fastExits, 4)))
43
+ Start-Sleep -Seconds $delay
44
+ }
@@ -0,0 +1,32 @@
1
+ #Requires -Version 5.1
2
+ $ErrorActionPreference = 'Stop'
3
+
4
+ $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
5
+ $projectDir = Split-Path -Parent $scriptDir
6
+ $launcher = Join-Path $scriptDir 'run-hidden.vbs'
7
+ $entryPoint = Join-Path $projectDir 'dist\index.js'
8
+ $name = 'ClaudeCodeDiscordPresence'
9
+ $runKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
10
+
11
+ if (-not (Test-Path -LiteralPath $launcher)) {
12
+ throw "Launcher not found: $launcher"
13
+ }
14
+ if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
15
+ throw 'Node.js is not available on PATH.'
16
+ }
17
+ if (-not (Test-Path -LiteralPath $entryPoint)) {
18
+ throw 'Built service not found. Run npm install and npm run build first.'
19
+ }
20
+
21
+ $command = "wscript.exe `"$launcher`""
22
+ Set-ItemProperty -Path $runKey -Name $name -Value $command
23
+
24
+ $escapedEntryPoint = [WildcardPattern]::Escape($entryPoint)
25
+ $existing = Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" -ErrorAction SilentlyContinue |
26
+ Where-Object { $_.CommandLine -like "*$escapedEntryPoint*" }
27
+ if (-not $existing) {
28
+ Start-Process -FilePath 'wscript.exe' -ArgumentList "`"$launcher`"" -WindowStyle Hidden
29
+ }
30
+
31
+ Write-Host "Registered '$name' in $runKey."
32
+ Write-Host "Remove it with: Remove-ItemProperty -Path '$runKey' -Name '$name'"