vibecoder-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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Younesfdj
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,87 @@
1
+ <div align="center">
2
+ <img src="assets/logo.jpg" alt="" width="160" height="160" align="middle" />
3
+ <h1>
4
+ Vibecoder Discord Presence
5
+ </h1>
6
+ <p><strong>Your AI coding sessions, live on Discord.</strong></p>
7
+
8
+ <img src="assets/demo.gif" alt="demo" width="480" />
9
+
10
+ </div>
11
+
12
+ A live Discord status for your AI coding sessions — thinking, editing, running
13
+ tests, as it happens. Theme it however you like (or build your own from scratch),
14
+ and it stays private unless you choose to share more.
15
+
16
+ ## Supported tools
17
+
18
+ | Tool | Status |
19
+ | ----------- | ------------ |
20
+ | Claude Code | ✅ supported |
21
+ | Gemini CLI | 🔜 planned |
22
+ | Codex | 🔜 planned |
23
+ | OpenCode | 🔜 planned |
24
+
25
+ > Built on a provider model — adding a tool only changes how events are read, not
26
+ > the rest. PRs welcome.
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ npm i -g vibecoder-discord-presence
32
+ ```
33
+
34
+ ## Setup
35
+
36
+ ```sh
37
+ vdp install
38
+ ```
39
+
40
+ Open your AI coding tool with the Discord **desktop** app running — your status
41
+ shows up on its own. That's the whole setup.
42
+
43
+ > Needs Node 18+ and the Discord desktop app.
44
+
45
+ ## Themes
46
+
47
+ Five built-ins, from privacy-safe to maximum vibes.
48
+
49
+ <p align="center">
50
+ <img src="assets/profile.png" alt="Discord rich presence" width="320"/>
51
+ </p>
52
+
53
+ `minimal` · `developer` · `focus` · `playful` · `chaos`
54
+
55
+ ## Customize
56
+
57
+ ```sh
58
+ vdp config
59
+ ```
60
+
61
+ Pick a theme or build your own — every line, image, and button — with a live
62
+ preview as you go.
63
+
64
+ ## Commands
65
+
66
+ | Command | What it does |
67
+ | -------------------------- | ---------------------------------------------- |
68
+ | `vdp install` | Set it up |
69
+ | `vdp config` | Customize the card |
70
+ | `vdp status` | See what's running |
71
+ | `vdp stop` / `vdp restart` | Control the background process |
72
+ | `vdp uninstall [--purge]` | Remove it (`--purge` also deletes your config) |
73
+
74
+ ## Privacy
75
+
76
+ The default `minimal` theme shares nothing about your work — no project names,
77
+ paths, or filenames. Anything more is opt-in, and there's no telemetry.
78
+
79
+ ## How it works
80
+
81
+ A small hook fires on each event and writes a marker file. A lightweight
82
+ background process reads it, updates Discord, and exits once you're idle — no
83
+ always-on daemon, no manual start/stop.
84
+
85
+ ## License
86
+
87
+ [MIT](LICENSE) © [younesfdj](https://github.com/younesfdj)
File without changes
Binary file
Binary file
@@ -0,0 +1,108 @@
1
+ import {
2
+ configPath
3
+ } from "./chunk-FRGALQ5R.js";
4
+
5
+ // src/themes/index.ts
6
+ var REPO_URL = "https://github.com/younesfdj/vibecoder-discord-presence";
7
+ var THEMES = {
8
+ minimal: {
9
+ details: "Coding with Claude Code",
10
+ state: "",
11
+ largeImage: { key: "logo", text: "Claude Code" },
12
+ smallImage: { key: "status-{state}", text: "{activity}" },
13
+ timer: true,
14
+ buttons: [],
15
+ // No state line; let the compact status show the activity from details.
16
+ statusDisplay: "details"
17
+ },
18
+ developer: {
19
+ details: "Coding {project} ({branch})",
20
+ state: "{activity} \xB7 {model}",
21
+ largeImage: { key: "logo", text: "Claude Code \xB7 {model}" },
22
+ smallImage: { key: "status-{state}", text: "{activity}" },
23
+ timer: true,
24
+ buttons: [{ label: "\u2B50 Star on GitHub", url: REPO_URL }],
25
+ statusDisplay: "state"
26
+ },
27
+ focus: {
28
+ details: "In a deep work session \u{1F3AF}",
29
+ state: "Focused for {elapsed}",
30
+ largeImage: { key: "focus", text: "Deep work" },
31
+ smallImage: { key: "status-focus", text: "Focusing" },
32
+ timer: true,
33
+ buttons: [],
34
+ statusDisplay: "state"
35
+ },
36
+ playful: {
37
+ details: "\u{1F916} vibecoding with Claude",
38
+ state: "shipping {project} \xB7 {model}",
39
+ largeImage: { key: "logo", text: "vibecoder" },
40
+ smallImage: { key: "status-{state}", text: "{activity}" },
41
+ timer: true,
42
+ buttons: [{ label: "get vibecoder", url: REPO_URL }],
43
+ statusDisplay: "state"
44
+ },
45
+ chaos: {
46
+ details: "\u{1F680} {activity} \u2014 \u{1F4C2} {project} {branch} \u{1F4BB}\u{1F525}",
47
+ state: "cooking with {model} \xB7 {tokens} tokens burned\xB7 {cost} \xB7 \u{1F465} {sessionCount} \xB7 \u231B {elapsed}",
48
+ largeImage: { key: "logo", text: "\u2728 locked in \xB7 {model} \xB7 no thoughts only vibes \u{1F525}" },
49
+ smallImage: { key: "status-{state}", text: "{activity} fr fr \u{1F4AF}" },
50
+ timer: true,
51
+ buttons: [
52
+ { label: "\u{1F525} join the vibe", url: REPO_URL },
53
+ { label: "\u2728 star (real)", url: REPO_URL }
54
+ ],
55
+ statusDisplay: "details"
56
+ }
57
+ };
58
+ var DEFAULT_THEME = "minimal";
59
+
60
+ // src/core/config.ts
61
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
62
+ import { dirname } from "path";
63
+ var DEFAULT_CONFIG = {
64
+ theme: DEFAULT_THEME,
65
+ overrides: {}
66
+ };
67
+ var DEFAULT_CLIENT_ID = "1511730102499541123";
68
+ function stripBom(s) {
69
+ return s.charCodeAt(0) === 65279 ? s.slice(1) : s;
70
+ }
71
+ function readUserConfig(configPath2) {
72
+ try {
73
+ const raw = stripBom(readFileSync(configPath2, "utf8"));
74
+ const parsed = JSON.parse(raw);
75
+ if (typeof parsed !== "object" || parsed === null) return DEFAULT_CONFIG;
76
+ return {
77
+ theme: typeof parsed.theme === "string" ? parsed.theme : DEFAULT_CONFIG.theme,
78
+ overrides: typeof parsed.overrides === "object" && parsed.overrides !== null ? parsed.overrides : {},
79
+ clientId: typeof parsed.clientId === "string" ? parsed.clientId : void 0
80
+ };
81
+ } catch {
82
+ return DEFAULT_CONFIG;
83
+ }
84
+ }
85
+ function resolveTheme(config) {
86
+ const base = THEMES[config.theme] ?? THEMES[DEFAULT_THEME];
87
+ return { ...base, ...config.overrides ?? {} };
88
+ }
89
+ function resolveClientId(config) {
90
+ return process.env.VDP_DISCORD_CLIENT_ID || config.clientId || DEFAULT_CLIENT_ID;
91
+ }
92
+ function saveUserConfig(config) {
93
+ const dest = configPath();
94
+ mkdirSync(dirname(dest), { recursive: true });
95
+ const tmp = `${dest}.${process.pid}.tmp`;
96
+ writeFileSync(tmp, `${JSON.stringify(config, null, 2)}
97
+ `);
98
+ renameSync(tmp, dest);
99
+ }
100
+
101
+ export {
102
+ THEMES,
103
+ DEFAULT_CONFIG,
104
+ readUserConfig,
105
+ resolveTheme,
106
+ resolveClientId,
107
+ saveUserConfig
108
+ };
@@ -0,0 +1,90 @@
1
+ import {
2
+ sessionsDir
3
+ } from "./chunk-FRGALQ5R.js";
4
+
5
+ // src/core/state.ts
6
+ import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "fs";
7
+ import { join } from "path";
8
+ var STALE_AFTER_MS = 20 * 60 * 1e3;
9
+ var PRUNE_AFTER_MS = 60 * 60 * 1e3;
10
+ function markerPath(id) {
11
+ return join(sessionsDir(), `${id}.json`);
12
+ }
13
+ function stripBom(s) {
14
+ return s.charCodeAt(0) === 65279 ? s.slice(1) : s;
15
+ }
16
+ function writeSessionMarker(marker) {
17
+ const dir = sessionsDir();
18
+ mkdirSync(dir, { recursive: true });
19
+ const dest = markerPath(marker.id);
20
+ const tmp = `${dest}.${process.pid}.tmp`;
21
+ writeFileSync(tmp, JSON.stringify(marker));
22
+ renameSync(tmp, dest);
23
+ }
24
+ function readSessionMarker(id) {
25
+ try {
26
+ const raw = stripBom(readFileSync(markerPath(id), "utf8"));
27
+ return JSON.parse(raw);
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+ function updateSessionMarker(id, patch, now) {
33
+ const existing = readSessionMarker(id);
34
+ const merged = {
35
+ id,
36
+ startedAt: existing?.startedAt ?? now,
37
+ ...existing,
38
+ ...patch,
39
+ heartbeat: now
40
+ };
41
+ writeSessionMarker(merged);
42
+ }
43
+ function removeSessionMarker(id) {
44
+ try {
45
+ rmSync(markerPath(id));
46
+ } catch {
47
+ }
48
+ }
49
+ function readMarkers() {
50
+ let files;
51
+ try {
52
+ files = readdirSync(sessionsDir());
53
+ } catch {
54
+ return [];
55
+ }
56
+ const out = [];
57
+ for (const f of files) {
58
+ if (!f.endsWith(".json")) continue;
59
+ const m = readSessionMarker(f.slice(0, -5));
60
+ if (m) out.push(m);
61
+ }
62
+ return out;
63
+ }
64
+ function aggregate(markers, now, staleAfterMs = STALE_AFTER_MS) {
65
+ const live = markers.filter((m) => now - m.heartbeat <= staleAfterMs);
66
+ if (live.length === 0) return null;
67
+ const current = live.reduce((a, b) => b.heartbeat > a.heartbeat ? b : a);
68
+ const startedAt = live.reduce((min, m) => Math.min(min, m.startedAt), Infinity);
69
+ return {
70
+ sessionCount: live.length,
71
+ startedAt,
72
+ transcriptPath: current.transcriptPath,
73
+ project: current.project,
74
+ branch: current.branch,
75
+ model: current.model,
76
+ state: current.state,
77
+ activity: current.activity,
78
+ file: current.file,
79
+ tokens: current.tokens,
80
+ cost: current.cost
81
+ };
82
+ }
83
+
84
+ export {
85
+ PRUNE_AFTER_MS,
86
+ updateSessionMarker,
87
+ removeSessionMarker,
88
+ readMarkers,
89
+ aggregate
90
+ };
@@ -0,0 +1,97 @@
1
+ import {
2
+ claudeDir,
3
+ settingsPath
4
+ } from "./chunk-FRGALQ5R.js";
5
+
6
+ // src/core/settings.ts
7
+ import { mkdir, readFile, rename, writeFile } from "fs/promises";
8
+ import { dirname } from "path";
9
+ var HOOK_MARKER = "vdp.js";
10
+ var HOOK_EVENTS = [
11
+ { name: "SessionStart", arg: "session-start" },
12
+ { name: "UserPromptSubmit", arg: "user-prompt-submit" },
13
+ { name: "PreToolUse", arg: "pre-tool-use" },
14
+ { name: "Notification", arg: "notification" },
15
+ { name: "Stop", arg: "stop" },
16
+ { name: "SessionEnd", arg: "session-end" }
17
+ ];
18
+ function stripBom(s) {
19
+ return s.charCodeAt(0) === 65279 ? s.slice(1) : s;
20
+ }
21
+ async function readSettings() {
22
+ try {
23
+ const raw = stripBom(await readFile(settingsPath(), "utf8"));
24
+ const parsed = JSON.parse(raw);
25
+ if (typeof parsed !== "object" || parsed === null) return {};
26
+ return parsed;
27
+ } catch (err) {
28
+ if (err.code === "ENOENT") return {};
29
+ throw err;
30
+ }
31
+ }
32
+ async function writeSettings(settings) {
33
+ const dest = settingsPath();
34
+ await mkdir(dirname(dest), { recursive: true });
35
+ const tmp = `${dest}.tmp`;
36
+ await writeFile(tmp, `${JSON.stringify(settings, null, 2)}
37
+ `);
38
+ await rename(tmp, dest);
39
+ }
40
+ async function backupSettings(settings) {
41
+ if (Object.keys(settings).length === 0) return null;
42
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
43
+ const path = `${settingsPath()}.${ts}.bak`;
44
+ await mkdir(claudeDir(), { recursive: true });
45
+ await writeFile(path, `${JSON.stringify(settings, null, 2)}
46
+ `);
47
+ return path;
48
+ }
49
+ function isOurEntry(entry) {
50
+ if (!Array.isArray(entry.hooks)) return false;
51
+ return entry.hooks.some((h) => typeof h.command === "string" && h.command.includes(HOOK_MARKER));
52
+ }
53
+ function buildEntry(entryPath, arg) {
54
+ return {
55
+ matcher: "*",
56
+ hooks: [{ type: "command", command: `node "${entryPath}" hook ${arg}` }]
57
+ };
58
+ }
59
+ function mergeHooks(settings, entryPath) {
60
+ const hooks = { ...settings.hooks ?? {} };
61
+ for (const event of HOOK_EVENTS) {
62
+ const existing = (hooks[event.name] ?? []).filter((e) => !isOurEntry(e));
63
+ existing.push(buildEntry(entryPath, event.arg));
64
+ hooks[event.name] = existing;
65
+ }
66
+ return { ...settings, hooks };
67
+ }
68
+ function stripOurHooks(settings) {
69
+ const inHooks = settings.hooks;
70
+ if (inHooks === void 0) return { cleaned: settings, removed: 0 };
71
+ let removed = 0;
72
+ const outHooks = {};
73
+ for (const [event, entries] of Object.entries(inHooks)) {
74
+ const kept = entries.filter((e) => {
75
+ if (isOurEntry(e)) {
76
+ removed++;
77
+ return false;
78
+ }
79
+ return true;
80
+ });
81
+ if (kept.length > 0) outHooks[event] = kept;
82
+ }
83
+ const cleaned = { ...settings };
84
+ if (Object.keys(outHooks).length > 0) cleaned.hooks = outHooks;
85
+ else delete cleaned.hooks;
86
+ return { cleaned, removed };
87
+ }
88
+
89
+ export {
90
+ HOOK_EVENTS,
91
+ readSettings,
92
+ writeSettings,
93
+ backupSettings,
94
+ isOurEntry,
95
+ mergeHooks,
96
+ stripOurHooks
97
+ };
@@ -0,0 +1,39 @@
1
+ // src/core/paths.ts
2
+ import os from "os";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+ function entryPath() {
6
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "vdp.js");
7
+ }
8
+ function claudeDir() {
9
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
10
+ }
11
+ function settingsPath() {
12
+ return path.join(claudeDir(), "settings.json");
13
+ }
14
+ function presenceDir() {
15
+ return path.join(claudeDir(), "discord-presence");
16
+ }
17
+ function configPath() {
18
+ return path.join(presenceDir(), "config.json");
19
+ }
20
+ function statePath() {
21
+ return path.join(presenceDir(), "state.json");
22
+ }
23
+ function lockPath() {
24
+ return path.join(presenceDir(), "daemon.lock");
25
+ }
26
+ function sessionsDir() {
27
+ return path.join(presenceDir(), "sessions");
28
+ }
29
+
30
+ export {
31
+ entryPath,
32
+ claudeDir,
33
+ settingsPath,
34
+ presenceDir,
35
+ configPath,
36
+ statePath,
37
+ lockPath,
38
+ sessionsDir
39
+ };
@@ -0,0 +1,126 @@
1
+ import {
2
+ entryPath,
3
+ lockPath,
4
+ presenceDir,
5
+ statePath
6
+ } from "./chunk-FRGALQ5R.js";
7
+
8
+ // src/core/daemon-state.ts
9
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
10
+ import { spawn } from "child_process";
11
+ var DAEMON_STATUS_STALE_MS = 60 * 1e3;
12
+ function stripBom(s) {
13
+ return s.charCodeAt(0) === 65279 ? s.slice(1) : s;
14
+ }
15
+ function isProcessAlive(pid) {
16
+ try {
17
+ process.kill(pid, 0);
18
+ return true;
19
+ } catch (err) {
20
+ return err.code === "EPERM";
21
+ }
22
+ }
23
+ function readLock() {
24
+ try {
25
+ return JSON.parse(stripBom(readFileSync(lockPath(), "utf8")));
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+ function acquireLock(now) {
31
+ mkdirSync(presenceDir(), { recursive: true });
32
+ const payload = JSON.stringify({ pid: process.pid, startedAt: now });
33
+ for (let attempt = 0; attempt < 2; attempt++) {
34
+ try {
35
+ writeFileSync(lockPath(), payload, { flag: "wx" });
36
+ return true;
37
+ } catch (err) {
38
+ if (err.code !== "EEXIST") return false;
39
+ const existing = readLock();
40
+ if (existing && existing.pid !== process.pid && isProcessAlive(existing.pid)) {
41
+ return false;
42
+ }
43
+ try {
44
+ rmSync(lockPath());
45
+ } catch {
46
+ }
47
+ }
48
+ }
49
+ return false;
50
+ }
51
+ function releaseLock() {
52
+ const existing = readLock();
53
+ if (existing && existing.pid === process.pid) {
54
+ try {
55
+ rmSync(lockPath());
56
+ } catch {
57
+ }
58
+ }
59
+ }
60
+ function writeDaemonStatus(status) {
61
+ try {
62
+ mkdirSync(presenceDir(), { recursive: true });
63
+ const dest = statePath();
64
+ const tmp = `${dest}.${process.pid}.tmp`;
65
+ writeFileSync(tmp, JSON.stringify(status));
66
+ renameSync(tmp, dest);
67
+ } catch {
68
+ }
69
+ }
70
+ function readDaemonStatus() {
71
+ try {
72
+ return JSON.parse(stripBom(readFileSync(statePath(), "utf8")));
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+ function clearDaemonStatus() {
78
+ try {
79
+ rmSync(statePath());
80
+ } catch {
81
+ }
82
+ }
83
+ async function waitForExit(pid, timeoutMs) {
84
+ const step = 100;
85
+ for (let waited = 0; waited < timeoutMs; waited += step) {
86
+ if (!isProcessAlive(pid)) return true;
87
+ await new Promise((r) => setTimeout(r, step));
88
+ }
89
+ return !isProcessAlive(pid);
90
+ }
91
+ async function stopDaemon(timeoutMs = 2e3) {
92
+ const lock = readLock();
93
+ if (!lock || !isProcessAlive(lock.pid)) return null;
94
+ try {
95
+ process.kill(lock.pid, "SIGTERM");
96
+ } catch {
97
+ return null;
98
+ }
99
+ await waitForExit(lock.pid, timeoutMs);
100
+ return lock.pid;
101
+ }
102
+ function spawnDaemon() {
103
+ try {
104
+ const child = spawn(process.execPath, [entryPath(), "daemon"], {
105
+ detached: true,
106
+ stdio: "ignore",
107
+ windowsHide: true
108
+ // don't flash a console window on Windows
109
+ });
110
+ child.unref();
111
+ } catch {
112
+ }
113
+ }
114
+
115
+ export {
116
+ DAEMON_STATUS_STALE_MS,
117
+ isProcessAlive,
118
+ readLock,
119
+ acquireLock,
120
+ releaseLock,
121
+ writeDaemonStatus,
122
+ readDaemonStatus,
123
+ clearDaemonStatus,
124
+ stopDaemon,
125
+ spawnDaemon
126
+ };
@@ -0,0 +1,28 @@
1
+ // src/ui.ts
2
+ import pc from "picocolors";
3
+ var ui = {
4
+ /** Bold cyan heading. */
5
+ title: (s) => pc.bold(pc.cyan(s)),
6
+ /** Success text (green). */
7
+ ok: (s) => pc.green(s),
8
+ /** Error text (red). */
9
+ err: (s) => pc.red(s),
10
+ /** Warning / attention text (yellow). */
11
+ warn: (s) => pc.yellow(s),
12
+ /** Accent for values, commands, URLs (cyan). */
13
+ accent: (s) => pc.cyan(s),
14
+ /** Secondary / muted text (dim). */
15
+ dim: (s) => pc.dim(s),
16
+ /** Emphasis (bold). */
17
+ bold: (s) => pc.bold(s),
18
+ /** Green check glyph. */
19
+ check: pc.green("\u2713"),
20
+ /** Red cross glyph. */
21
+ cross: pc.red("\u2717"),
22
+ /** Muted bullet glyph. */
23
+ bullet: pc.dim("\u2022")
24
+ };
25
+
26
+ export {
27
+ ui
28
+ };
@@ -0,0 +1,65 @@
1
+ // src/core/presence.ts
2
+ function formatElapsed(ms) {
3
+ const total = Math.max(0, Math.floor(ms / 1e3));
4
+ const h = Math.floor(total / 3600);
5
+ const m = Math.floor(total % 3600 / 60);
6
+ if (h > 0) return `${h}h ${m}m`;
7
+ if (m > 0) return `${m}m`;
8
+ return `${total}s`;
9
+ }
10
+ function formatTokens(n) {
11
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
12
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
13
+ return String(n);
14
+ }
15
+ function buildValues(state, now) {
16
+ return {
17
+ project: state.project ?? "",
18
+ branch: state.branch ?? "",
19
+ model: state.model ?? "",
20
+ activity: state.activity ?? "",
21
+ file: state.file ?? "",
22
+ tokens: state.tokens != null ? formatTokens(state.tokens) : "",
23
+ cost: state.cost != null ? `$${state.cost.toFixed(2)}` : "",
24
+ elapsed: formatElapsed(now - state.startedAt),
25
+ sessionCount: String(state.sessionCount),
26
+ // Drives the `status-{state}` badge; also available as a text token.
27
+ state: state.state ?? "idle"
28
+ };
29
+ }
30
+ function tidy(s) {
31
+ return s.replace(/\(\s*\)/g, "").replace(/\[\s*\]/g, "").replace(/\s+/g, " ").replace(/·(\s*·)+/g, "\xB7").replace(/^\s*·\s*/, "").replace(/\s*·\s*$/, "").trim();
32
+ }
33
+ function interpolate(template, values) {
34
+ return tidy(template.replace(/\{(\w+)\}/g, (_, key) => values[key] ?? ""));
35
+ }
36
+ function renderPresence(theme, state, now) {
37
+ const values = buildValues(state, now);
38
+ const payload = {};
39
+ const details = interpolate(theme.details, values);
40
+ if (details) payload.details = details;
41
+ const stateLine = interpolate(theme.state, values);
42
+ if (stateLine) payload.state = stateLine;
43
+ const largeKey = interpolate(theme.largeImage.key, values);
44
+ if (largeKey) {
45
+ payload.largeImageKey = largeKey;
46
+ const largeText = interpolate(theme.largeImage.text, values);
47
+ if (largeText) payload.largeImageText = largeText;
48
+ }
49
+ const smallKey = interpolate(theme.smallImage.key, values);
50
+ if (smallKey) {
51
+ payload.smallImageKey = smallKey;
52
+ const smallText = interpolate(theme.smallImage.text, values);
53
+ if (smallText) payload.smallImageText = smallText;
54
+ }
55
+ if (theme.timer) payload.startTimestamp = state.startedAt;
56
+ if (theme.statusDisplay === "state" && payload.state) payload.statusDisplayType = 1;
57
+ else if (theme.statusDisplay === "details" && payload.details) payload.statusDisplayType = 2;
58
+ const buttons = theme.buttons.map((b) => ({ label: interpolate(b.label, values), url: b.url })).filter((b) => b.label && b.url).slice(0, 2);
59
+ if (buttons.length > 0) payload.buttons = buttons;
60
+ return payload;
61
+ }
62
+
63
+ export {
64
+ renderPresence
65
+ };