claude-rpc 0.18.0 → 0.20.1
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/SECURITY.md +17 -4
- package/package.json +1 -1
- package/src/badge.js +1 -16
- package/src/card.js +5 -17
- package/src/cli.js +44 -35
- package/src/daemon.js +138 -48
- package/src/default-config.js +14 -0
- package/src/discord-ipc.js +12 -5
- package/src/doctor.js +29 -19
- package/src/ensure-daemon.js +127 -0
- package/src/fmt.js +33 -0
- package/src/format.js +8 -21
- package/src/hook.js +16 -0
- package/src/insights.js +1 -15
- package/src/install.js +40 -25
- package/src/leaderboard.js +5 -1
- package/src/mcp.js +15 -1
- package/src/notify.js +4 -4
- package/src/presence.js +24 -0
- package/src/privacy.js +29 -13
- package/src/profile.js +1 -16
- package/src/scanner.js +29 -17
- package/src/server/api.js +3 -3
- package/src/server/assets/dashboard.client.js +32 -9
- package/src/server/index.js +3 -3
- package/src/server/sse.js +27 -0
- package/src/state.js +12 -0
- package/src/tui.js +5 -19
- package/src/usage.js +6 -2
- package/src/version.js +1 -1
- package/src/week.js +33 -0
package/SECURITY.md
CHANGED
|
@@ -16,10 +16,10 @@ fetch-and-execute anywhere in `src/`.
|
|
|
16
16
|
|
|
17
17
|
| Behavior | Where | Scope | Reversible? |
|
|
18
18
|
| --- | --- | --- | --- |
|
|
19
|
-
| Startup persistence | `src/install.js` → `addStartupEntry` | `HKCU` Run key
|
|
19
|
+
| Startup persistence | `src/install.js` → `addStartupEntry` (Windows Run key); `src/hook.js` → `ensureDaemonRunning` (SessionStart self-heal, all platforms) | `HKCU` Run key (no admin) + a detached daemon (re)launched when you start a Claude Code session | Yes — `claude-rpc uninstall` / `removeStartupEntry`; disable self-heal with `autostart:false` |
|
|
20
20
|
| Hook injection | `src/install.js` → `installHooks` | Only into Claude Code's own `settings.json`, only our own commands | Yes — `uninstallHooks` removes exactly what it added |
|
|
21
21
|
| Outbound network | `src/community.js`, `src/gist.js`, `src/usage.js`, `src/notify.js`, `default-config.js` | Anonymous counters + (opt-in) profile/gist/webhook + own read-only OAuth-usage poll + GIF assets | Telemetry: `community off`. Profile: `profile off`. Gist/webhook: opt-in only. Usage: `usage.enabled:false`. |
|
|
22
|
-
| Local subprocess | `reg.exe`, `wscript`, `git`, `gh`, `npm`, `claude`, `security`, notifiers | Static or escaped args, no shell interpolation of untrusted input | n/a |
|
|
22
|
+
| Local subprocess | `reg.exe`, `wscript`, the daemon itself (`node`/packaged exe — via the Run key and the SessionStart self-heal), `git`, `gh`, `npm`, `claude`, `security`, notifiers | Static or escaped args, no shell interpolation of untrusted input | n/a |
|
|
23
23
|
|
|
24
24
|
No credential access beyond the read-only Claude Code OAuth-token read for usage
|
|
25
25
|
polling (§3d), no filesystem scanning outside `~/.claude-rpc` and Claude Code
|
|
@@ -47,8 +47,16 @@ HKCU\Software\Microsoft\Windows\CurrentVersion\Run
|
|
|
47
47
|
not at wherever you happened to run the installer from — see
|
|
48
48
|
`ensureCanonicalExe`.
|
|
49
49
|
|
|
50
|
-
Non-Windows platforms get **no**
|
|
51
|
-
and skips it).
|
|
50
|
+
Non-Windows platforms get **no** Run-key/login registration (`install()` warns
|
|
51
|
+
and skips it). Instead — on every platform, Windows included — the
|
|
52
|
+
`SessionStart` hook **self-heals** the daemon: if none is running when you start
|
|
53
|
+
a Claude Code session, the hook spawns one (detached, windowless), so presence
|
|
54
|
+
is restored after a reboot, crash, or OS sleep exactly when you next use Claude.
|
|
55
|
+
It is the same long-lived daemon launched the same way (`src/ensure-daemon.js` →
|
|
56
|
+
`spawnDaemonDetached`), a no-op when a daemon is already running, and
|
|
57
|
+
cooldown-guarded against spawn storms; the daemon's atomic single-instance claim
|
|
58
|
+
reaps any duplicate. Disable it with `autostart:false` in config (then manage
|
|
59
|
+
the daemon yourself via `claude-rpc start` / `stop`).
|
|
52
60
|
|
|
53
61
|
## 2. Hook injection into Claude Code
|
|
54
62
|
|
|
@@ -71,6 +79,11 @@ events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`,
|
|
|
71
79
|
counters, and `git push`/`git commit` detection (for the "just shipped"
|
|
72
80
|
card). It writes only to local state/log files. It does not read file
|
|
73
81
|
*contents*, prompts, or responses beyond the usage counters Claude provides.
|
|
82
|
+
- **`SessionStart` additionally self-heals the daemon:** if none is running it
|
|
83
|
+
spawns the (detached) daemon so presence is assured cross-platform — see §1
|
|
84
|
+
and `ensureDaemonRunning`. Best-effort, gated by `autostart` (default on). It
|
|
85
|
+
is the **only** hook event that launches a process; every other event just
|
|
86
|
+
writes state and acks.
|
|
74
87
|
- **Scope:** the installer only ever touches entries whose command matches
|
|
75
88
|
`isOurHookCommand` (contains `claude-rpc` or `hook.js`). It will not modify,
|
|
76
89
|
reorder, or delete anyone else's hooks.
|
package/package.json
CHANGED
package/src/badge.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { dayKey } from './scanner.js';
|
|
5
5
|
import { fmtCost } from './pricing.js';
|
|
6
|
+
import { fmtNum, fmtHours as fmtHoursLabel } from './fmt.js';
|
|
6
7
|
|
|
7
8
|
const COLORS = {
|
|
8
9
|
hours: { left: '#555', right: '#4c1' }, // green
|
|
@@ -58,22 +59,6 @@ function rangeLabel(range) {
|
|
|
58
59
|
|
|
59
60
|
export { rangeToDays, rangeLabel, pickWindow };
|
|
60
61
|
|
|
61
|
-
function fmtHoursLabel(ms) {
|
|
62
|
-
if (!ms) return '0h';
|
|
63
|
-
const h = ms / 3_600_000;
|
|
64
|
-
if (h < 1) return `${Math.round(h * 60)}m`;
|
|
65
|
-
if (h < 10) return `${h.toFixed(1)}h`;
|
|
66
|
-
return `${Math.round(h)}h`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function fmtNum(n) {
|
|
70
|
-
if (!n) return '0';
|
|
71
|
-
if (n < 1000) return String(n);
|
|
72
|
-
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
|
|
73
|
-
if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
|
74
|
-
return `${(n / 1_000_000_000).toFixed(2)}B`;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
62
|
// Compute label/value pair for the requested metric.
|
|
78
63
|
function valueFor(aggregate, metric, range) {
|
|
79
64
|
const a = aggregate || {};
|
package/src/card.js
CHANGED
|
@@ -15,6 +15,7 @@ import { dayKey } from './scanner.js';
|
|
|
15
15
|
import { fmtCost } from './pricing.js';
|
|
16
16
|
import { rangeToDays, rangeLabel, pickWindow } from './badge.js';
|
|
17
17
|
import { VERSION } from './version.js';
|
|
18
|
+
import { fmtNum, fmtHours } from './fmt.js';
|
|
18
19
|
|
|
19
20
|
const W = 880;
|
|
20
21
|
const H = 540;
|
|
@@ -45,22 +46,6 @@ function escapeXml(s) {
|
|
|
45
46
|
.replace(/"/g, '"');
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
function fmtNum(n) {
|
|
49
|
-
if (!n) return '0';
|
|
50
|
-
if (n < 1000) return String(Math.round(n));
|
|
51
|
-
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
|
|
52
|
-
if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
|
53
|
-
return `${(n / 1_000_000_000).toFixed(2)}B`;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function fmtHours(ms) {
|
|
57
|
-
if (!ms || ms < 0) return '0h';
|
|
58
|
-
const h = ms / 3_600_000;
|
|
59
|
-
if (h < 1) return `${Math.round(h * 60)}m`;
|
|
60
|
-
if (h < 10) return `${h.toFixed(1)}h`;
|
|
61
|
-
return `${Math.round(h)}h`;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
49
|
function rangeTitle(range) {
|
|
65
50
|
const rl = rangeLabel(range);
|
|
66
51
|
if (rl === 'all-time') return 'on claude';
|
|
@@ -103,7 +88,9 @@ function topWeekday(aggregate) {
|
|
|
103
88
|
const wd = aggregate?.byWeekday || {};
|
|
104
89
|
let best = null;
|
|
105
90
|
for (const [k, v] of Object.entries(wd)) {
|
|
106
|
-
|
|
91
|
+
const day = Number(k);
|
|
92
|
+
if (!Number.isInteger(day) || day < 0 || day > 6) continue; // out-of-range key → skip, no blank name + stray "(3h)"
|
|
93
|
+
if (!best || (v.activeMs || 0) > (best.ms || 0)) best = { day, ms: v.activeMs || 0 };
|
|
107
94
|
}
|
|
108
95
|
return best;
|
|
109
96
|
}
|
|
@@ -133,6 +120,7 @@ function peakHourLabel(aggregate) {
|
|
|
133
120
|
const ph = aggregate?.peakHour;
|
|
134
121
|
if (!ph || ph.hour == null) return null;
|
|
135
122
|
const h = Number(ph.hour);
|
|
123
|
+
if (!Number.isInteger(h) || h < 0 || h > 23) return null; // corrupt aggregate → no label, not "99:00"/"NaN:00"
|
|
136
124
|
return `${String(h).padStart(2, '0')}:00`;
|
|
137
125
|
}
|
|
138
126
|
|
package/src/cli.js
CHANGED
|
@@ -10,10 +10,11 @@ import process from 'node:process';
|
|
|
10
10
|
if (process.platform === 'win32' && process.stdout.isTTY) {
|
|
11
11
|
try { spawnSync('chcp.com', ['65001'], { stdio: 'ignore', windowsHide: true }); } catch { /* chcp absent (Wine, custom shell) — accept whatever code page is set */ }
|
|
12
12
|
}
|
|
13
|
-
import { DAEMON_SCRIPT, PID_PATH, STATE_PATH, LOG_PATH, AGGREGATE_PATH, CONFIG_PATH, IS_PACKAGED, IS_NPX, EXE_PATH
|
|
14
|
-
import {
|
|
13
|
+
import { DAEMON_SCRIPT, PID_PATH, STATE_PATH, LOG_PATH, AGGREGATE_PATH, CONFIG_PATH, IS_PACKAGED, IS_NPX, EXE_PATH } from './paths.js';
|
|
14
|
+
import { readActiveState } from './state.js';
|
|
15
15
|
import { buildVars, fillTemplate, humanProject, humanTool, applyIdle, framePasses, fmtNum } from './format.js';
|
|
16
16
|
import { scan, readAggregate, findLiveSessions, dayKey, weekKey } from './scanner.js';
|
|
17
|
+
import { weekGrid } from './week.js';
|
|
17
18
|
import { runHookCli } from './hook.js';
|
|
18
19
|
import { install as runInstall, uninstall as runUninstall, isInstalled, migrateConfig, installHooks, ensureCanonicalExe, installMcp, uninstallMcp, setupOutro } from './install.js';
|
|
19
20
|
import { startTui } from './tui.js';
|
|
@@ -25,6 +26,7 @@ import { addPrivateCwd, removePrivateCwd, listPrivateCwds, resolveVisibility } f
|
|
|
25
26
|
import { parseDuration, setPause, clearPause, pauseUntil } from './pause.js';
|
|
26
27
|
import { readUsageCache, fetchUsage, writeUsageCache, fmtResetTime, fmtResetDay } from './usage.js';
|
|
27
28
|
import { loadConfig, hasUserConfig } from './config.js';
|
|
29
|
+
import { spawnDaemonDetached } from './ensure-daemon.js';
|
|
28
30
|
import * as lb from './leaderboard.js';
|
|
29
31
|
import { VERSION } from './version.js';
|
|
30
32
|
import { fail, tailLines, heat, sparkline, fmtDelta, topPercentile, EX_USER_ERROR, EX_BAD_STATE, EX_SYS_ERROR } from './ui.js';
|
|
@@ -62,7 +64,7 @@ function readJson(path, fallback) {
|
|
|
62
64
|
// (`profile set`, `community on`, `link`, …). mkdirSync first.
|
|
63
65
|
function writeUserConfig(userCfg) {
|
|
64
66
|
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
65
|
-
|
|
67
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2) + '\n');
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
// Validate a flag's value taken with `argv[++i]`. A value that's missing or
|
|
@@ -95,18 +97,39 @@ function startDaemon({ quiet = false } = {}) {
|
|
|
95
97
|
if (!quiet) console.log(` ${c.yellow}!${c.reset} ${'daemon running'.padEnd(16)}${c.dim}already up (pid ${pid}) · bounce it with ${c.reset}${c.cyan}claude-rpc restart${c.reset}`);
|
|
96
98
|
return false;
|
|
97
99
|
}
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
100
|
+
// Shared spawn recipe (canonical-exe preference, detached, windowless, error
|
|
101
|
+
// listener) lives in ensure-daemon.spawnDaemonDetached so the CLI and the
|
|
102
|
+
// self-healing hook launch the daemon identically.
|
|
103
|
+
const child = spawnDaemonDetached();
|
|
104
|
+
if (!child || !child.pid) {
|
|
105
|
+
if (!quiet) console.log(` ${c.red}✗${c.reset} ${'daemon start'.padEnd(16)}${c.dim}spawn failed — check ${shortPath(LOG_PATH)} or run ${c.reset}${c.cyan}claude-rpc doctor${c.reset}`);
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
106
108
|
if (!quiet) console.log(` ${c.green}✓${c.reset} ${'daemon launched'.padEnd(16)}${c.dim}pid ${c.reset}${c.cyan}${child.pid}${c.reset}${c.dim} · log ${shortPath(LOG_PATH)}${c.reset}`);
|
|
107
109
|
return true;
|
|
108
110
|
}
|
|
109
111
|
|
|
112
|
+
// Confirm a freshly-spawned daemon actually came up, instead of trusting the
|
|
113
|
+
// detached spawn (it can die a millisecond later — single-instance loss, a bad
|
|
114
|
+
// path, a startup throw). Polls the pid file for liveness, since the daemon
|
|
115
|
+
// claims it early in startup. Interactive callers (`start`) await this so the
|
|
116
|
+
// user gets honest "it's up" / "it didn't come up" feedback rather than a
|
|
117
|
+
// success message for a daemon that's already gone.
|
|
118
|
+
async function confirmDaemonUp({ timeoutMs = 3000, intervalMs = 100 } = {}) {
|
|
119
|
+
const deadline = Date.now() + timeoutMs;
|
|
120
|
+
for (;;) {
|
|
121
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
122
|
+
if (daemonPid()) {
|
|
123
|
+
console.log(` ${c.green}✓${c.reset} ${'daemon confirmed'.padEnd(16)}${c.dim}up — connecting to Discord${c.reset}`);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if (Date.now() >= deadline) {
|
|
127
|
+
console.log(` ${c.yellow}!${c.reset} ${'daemon check'.padEnd(16)}${c.dim}not up yet — see ${shortPath(LOG_PATH)} or run ${c.reset}${c.cyan}claude-rpc doctor${c.reset}`);
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
110
133
|
function stopDaemon({ quiet = false } = {}) {
|
|
111
134
|
const pid = daemonPid();
|
|
112
135
|
if (!pid) { if (!quiet) console.log(` ${c.cyan}·${c.reset} daemon not running`); return false; }
|
|
@@ -346,7 +369,7 @@ function todayBoxLines(vars, aggregate) {
|
|
|
346
369
|
}
|
|
347
370
|
|
|
348
371
|
function showStatus() {
|
|
349
|
-
const state =
|
|
372
|
+
const state = readActiveState();
|
|
350
373
|
const aggregate = readAggregate();
|
|
351
374
|
const config = loadConfig();
|
|
352
375
|
const live = findLiveSessions({ thresholdMs: 90_000 });
|
|
@@ -528,7 +551,7 @@ function showStatus() {
|
|
|
528
551
|
}
|
|
529
552
|
|
|
530
553
|
function showToday() {
|
|
531
|
-
const state =
|
|
554
|
+
const state = readActiveState();
|
|
532
555
|
const aggregate = readAggregate();
|
|
533
556
|
const config = loadConfig();
|
|
534
557
|
state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
|
|
@@ -561,7 +584,7 @@ function showToday() {
|
|
|
561
584
|
}
|
|
562
585
|
|
|
563
586
|
function showWeek() {
|
|
564
|
-
const state =
|
|
587
|
+
const state = readActiveState();
|
|
565
588
|
const aggregate = readAggregate();
|
|
566
589
|
const config = loadConfig();
|
|
567
590
|
state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
|
|
@@ -593,22 +616,7 @@ function showWeek() {
|
|
|
593
616
|
|
|
594
617
|
// Current ISO week (Mon → Sun). Future days show "—".
|
|
595
618
|
if (aggregate?.byDay) {
|
|
596
|
-
const
|
|
597
|
-
now.setHours(0, 0, 0, 0);
|
|
598
|
-
const monday = new Date(now);
|
|
599
|
-
monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
|
|
600
|
-
const days = [];
|
|
601
|
-
for (let i = 0; i < 7; i++) {
|
|
602
|
-
const d = new Date(monday);
|
|
603
|
-
d.setDate(d.getDate() + i);
|
|
604
|
-
const k = dayKey(d.getTime());
|
|
605
|
-
const dayName = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()];
|
|
606
|
-
const ms = aggregate.byDay[k]?.activeMs || 0;
|
|
607
|
-
const isFuture = d > now;
|
|
608
|
-
const isToday = k === dayKey(now.getTime());
|
|
609
|
-
days.push({ label: `${dayName} ${k.slice(5)}`, ms, isFuture, isToday });
|
|
610
|
-
}
|
|
611
|
-
const maxMs = Math.max(...days.map((d) => d.ms)) || 1;
|
|
619
|
+
const { days, maxMs } = weekGrid(aggregate.byDay);
|
|
612
620
|
const lines = days.map(({ label, ms, isFuture, isToday }) => {
|
|
613
621
|
if (isFuture) return `${c.dim}${label.padEnd(12)} ${'·'.repeat(22)} —${c.reset}`;
|
|
614
622
|
const h = ms / 3_600_000;
|
|
@@ -681,7 +689,7 @@ function statusColor(status) {
|
|
|
681
689
|
}
|
|
682
690
|
|
|
683
691
|
function showPreview() {
|
|
684
|
-
let state =
|
|
692
|
+
let state = readActiveState();
|
|
685
693
|
const aggregate = readAggregate();
|
|
686
694
|
const config = loadConfig();
|
|
687
695
|
const live = findLiveSessions({ thresholdMs: 90_000 });
|
|
@@ -744,7 +752,7 @@ function showPreview() {
|
|
|
744
752
|
// dashboard having to inline-eval ESM source. Output shape matches the
|
|
745
753
|
// previous helper exactly: { vars: [sorted keys], live: <full vars object> }.
|
|
746
754
|
function dumpVars() {
|
|
747
|
-
let state =
|
|
755
|
+
let state = readActiveState();
|
|
748
756
|
const config = loadConfig();
|
|
749
757
|
state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
|
|
750
758
|
state = applyIdle(state, config);
|
|
@@ -971,7 +979,7 @@ async function doGithubStat(argv) {
|
|
|
971
979
|
// Build the live template-variable table the way the daemon does — current
|
|
972
980
|
// state + idle/stale resolution + aggregate. Shared by statusline/session-card.
|
|
973
981
|
function liveVars() {
|
|
974
|
-
const state =
|
|
982
|
+
const state = readActiveState();
|
|
975
983
|
state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
|
|
976
984
|
const config = loadConfig();
|
|
977
985
|
const resolved = applyIdle(state, config);
|
|
@@ -1837,7 +1845,7 @@ function overview() {
|
|
|
1837
1845
|
}
|
|
1838
1846
|
|
|
1839
1847
|
// Status line: daemon up/down + project + model + status verb.
|
|
1840
|
-
const state =
|
|
1848
|
+
const state = readActiveState();
|
|
1841
1849
|
const aggregate = readAggregate();
|
|
1842
1850
|
state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
|
|
1843
1851
|
state.usage = readUsageCache();
|
|
@@ -2012,6 +2020,7 @@ process.on('unhandledRejection', (e) => {
|
|
|
2012
2020
|
const child = spawn(process.execPath, [script || DAEMON_SCRIPT], {
|
|
2013
2021
|
detached: true, stdio: 'ignore', windowsHide: true,
|
|
2014
2022
|
});
|
|
2023
|
+
child.on('error', () => {}); // never let a spawn ENOENT bubble as an unhandled crash
|
|
2015
2024
|
child.unref();
|
|
2016
2025
|
console.log(` ${c.green}✓${c.reset} ${'daemon launched'.padEnd(16)}${c.dim}pid ${c.reset}${c.cyan}${child.pid}${c.reset}${c.dim} · log ${shortPath(LOG_PATH)}${c.reset}`);
|
|
2017
2026
|
} else {
|
|
@@ -2031,7 +2040,7 @@ process.on('unhandledRejection', (e) => {
|
|
|
2031
2040
|
case 'upgrade-config':
|
|
2032
2041
|
if (!migrateConfig()) console.log(` ${c.green}✓${c.reset} config already current — nothing to migrate`);
|
|
2033
2042
|
break;
|
|
2034
|
-
case 'start': startDaemon(); break;
|
|
2043
|
+
case 'start': if (startDaemon()) await confirmDaemonUp(); break;
|
|
2035
2044
|
case 'stop': stopDaemon(); break;
|
|
2036
2045
|
case 'restart': restartDaemon(); break;
|
|
2037
2046
|
case 'status': {
|
package/src/daemon.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
|
|
3
3
|
import { basename, dirname } from 'node:path';
|
|
4
4
|
import { Client } from './discord-ipc.js';
|
|
5
5
|
import { readState, sweepStaleStateTmp, listSessionStates, sweepStaleSessionStates } from './state.js';
|
|
6
|
-
import { makeRotationCursor, pickFrames, selectFrame, resolveLargeImageKey, shouldShowGithubButton, pickActiveSession } from './presence.js';
|
|
6
|
+
import { makeRotationCursor, pickFrames, selectFrame, resolveLargeImageKey, shouldShowGithubButton, pickActiveSession, throttleDecision } from './presence.js';
|
|
7
7
|
import { buildVars, fillTemplate, framePasses, applyIdle, applyShipped, applyTrigger } from './format.js';
|
|
8
8
|
import { scan, readAggregate, findLiveSessions, readSessionTokens } from './scanner.js';
|
|
9
9
|
import { detectGithubUrl } from './git.js';
|
|
@@ -16,6 +16,7 @@ import { humanProject } from './format.js';
|
|
|
16
16
|
import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH, PAUSE_PATH } from './paths.js';
|
|
17
17
|
import { readUsageCache, pollUsage } from './usage.js';
|
|
18
18
|
import { pollDecision, pollIntervalMs } from './watch-poll.js';
|
|
19
|
+
import { claimSingleInstance } from './ensure-daemon.js';
|
|
19
20
|
|
|
20
21
|
if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
|
|
21
22
|
|
|
@@ -51,6 +52,19 @@ function log(...args) {
|
|
|
51
52
|
process.stdout.write(line);
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
// Last-resort resilience. A presence daemon is best-effort and long-lived — it
|
|
56
|
+
// must never die from an unexpected error in a non-critical path (a scan edge
|
|
57
|
+
// case, a transient IPC oddity, a stray rejection). Log and keep running:
|
|
58
|
+
// staying up is the entire point, and the periodic ticks/watchdog re-converge
|
|
59
|
+
// from almost anything. Installed before any other startup work so even a boot
|
|
60
|
+
// error is captured rather than killing us silently.
|
|
61
|
+
process.on('uncaughtException', (e) => {
|
|
62
|
+
try { log('uncaughtException (continuing):', e?.stack || e?.message || String(e)); } catch { /* logging itself failed — nothing more we can do */ }
|
|
63
|
+
});
|
|
64
|
+
process.on('unhandledRejection', (e) => {
|
|
65
|
+
try { log('unhandledRejection (continuing):', e?.stack || e?.message || String(e)); } catch { /* see above */ }
|
|
66
|
+
});
|
|
67
|
+
|
|
54
68
|
// Wrap loadConfig so a parse/IO failure logs once and the daemon keeps
|
|
55
69
|
// running on baked-in defaults. The Electron settings GUI saves the file
|
|
56
70
|
// atomically but mid-edit hand-edits used to brick the daemon — this is
|
|
@@ -78,7 +92,22 @@ let aggregate = readAggregate() || null;
|
|
|
78
92
|
let liveSessions = [];
|
|
79
93
|
let client = null;
|
|
80
94
|
let connected = false;
|
|
81
|
-
let
|
|
95
|
+
let connecting = false; // login() in flight — see the watchdog note in connect()
|
|
96
|
+
// ── Outbound presence throttle ──────────────────────────────────────────────
|
|
97
|
+
// Discord rate-limits SET_ACTIVITY hard (~5 writes per 20s); a burst past it
|
|
98
|
+
// makes the client blank the presence until the writes stop — the actual
|
|
99
|
+
// mechanism behind "the card sometimes looks buggy", since Claude fires hooks
|
|
100
|
+
// in flurries. We never write faster than activityGapMs(): the first change
|
|
101
|
+
// after a quiet gap goes out at once, and changes inside the gap coalesce to
|
|
102
|
+
// the LATEST and flush once when it expires. See presence.throttleDecision.
|
|
103
|
+
// `lastSentHash` is what we believe is on the wire — advanced ONLY after a
|
|
104
|
+
// confirmed write, so a dropped/rate-limited write self-heals on the next push
|
|
105
|
+
// instead of being recorded as success and stranded (the old optimistic-hash
|
|
106
|
+
// bug, which stuck the card on a stale frame).
|
|
107
|
+
let lastSentHash = '';
|
|
108
|
+
let lastSentAt = 0; // ms of the last write ATTEMPT (success or fail → back off either way)
|
|
109
|
+
let pendingSend = null; // { kind, activity, hash, logMsg } coalesced during the gap; latest wins
|
|
110
|
+
let flushTimer = null;
|
|
82
111
|
// Last status we acted on for outbound side-effects (webhook / desktop notify).
|
|
83
112
|
// Tracked separately from the render hash so we fire once per transition.
|
|
84
113
|
let lastNotifiedStatus = null;
|
|
@@ -104,25 +133,19 @@ let displayedSessionId = null;
|
|
|
104
133
|
// either case would make startTimestamp jump on every rotation.
|
|
105
134
|
let effectiveSessionStart = null;
|
|
106
135
|
|
|
107
|
-
// Single-instance guard
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
process.exit(0);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
} catch { /* unreadable PID file — fall through and claim it */ }
|
|
125
|
-
writeFileSync(PID_PATH, String(process.pid));
|
|
136
|
+
// Single-instance guard — an ATOMIC claim (see ensure-daemon.claimSingleInstance).
|
|
137
|
+
// Many launchers can fire at once now that the SessionStart hook self-heals the
|
|
138
|
+
// daemon: a manual `start`, a Windows Run entry, and several concurrent sessions'
|
|
139
|
+
// hooks could all spawn a daemon in the same instant. A second daemon would
|
|
140
|
+
// fight over setActivity every ~4s and double-count the additive community
|
|
141
|
+
// total. The old read-then-write guard had a window where two starters both saw
|
|
142
|
+
// "no live owner" and both wrote their pid; the exclusive-create claim closes it
|
|
143
|
+
// so exactly one survives. A live owner → we exit; a stale pid → we reclaim it.
|
|
144
|
+
const ownerPid = claimSingleInstance();
|
|
145
|
+
if (ownerPid) {
|
|
146
|
+
log(`Another daemon (pid ${ownerPid}) is already running — exiting.`);
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
126
149
|
|
|
127
150
|
// Reclaim any per-pid state tmp files orphaned by a hard-killed writer, plus
|
|
128
151
|
// per-session state files from sessions that ended long ago.
|
|
@@ -329,7 +352,12 @@ function fireStatusSideEffects(resolved, suppressed = false) {
|
|
|
329
352
|
}
|
|
330
353
|
}
|
|
331
354
|
|
|
332
|
-
|
|
355
|
+
// Compute the desired presence for the current state and hand it to the
|
|
356
|
+
// throttle. Called from many triggers (hook-driven state changes, the periodic
|
|
357
|
+
// tick, config/scan/session refreshes); transmit() collapses that into at most
|
|
358
|
+
// one Discord write per activityGapMs(), so callers never have to think about
|
|
359
|
+
// the rate limit. Best-effort throughout — presence must never crash the daemon.
|
|
360
|
+
function pushPresence() {
|
|
333
361
|
if (!connected || !client?.user) return;
|
|
334
362
|
try {
|
|
335
363
|
// Resolve state ONCE — this same object decides clear-vs-push, drives the
|
|
@@ -341,7 +369,7 @@ async function pushPresence() {
|
|
|
341
369
|
const privacyHidden = resolved._privacy?.visibility === 'hidden';
|
|
342
370
|
// Global snooze (`claude-rpc pause`) — clears the card while the deadline
|
|
343
371
|
// is in the future. Re-checked every tick, so expiry resumes presence
|
|
344
|
-
// automatically (the '
|
|
372
|
+
// automatically (the next frame's hash differs from the cleared sentinel).
|
|
345
373
|
const pausedUntil = pauseUntil();
|
|
346
374
|
const suppressed = privacyHidden || !!pausedUntil || (resolved.status === 'stale' && hideWhenStale);
|
|
347
375
|
|
|
@@ -352,44 +380,100 @@ async function pushPresence() {
|
|
|
352
380
|
fireStatusSideEffects(resolved, suppressed);
|
|
353
381
|
|
|
354
382
|
if (suppressed) {
|
|
355
|
-
const stamp = 'cleared';
|
|
356
|
-
if (lastPayloadHash === stamp) return;
|
|
357
|
-
lastPayloadHash = stamp;
|
|
358
383
|
// Wipe effectiveSessionStart so the next active push gets a fresh
|
|
359
384
|
// elapsed timer rather than counting from a previous session.
|
|
360
385
|
effectiveSessionStart = null;
|
|
361
|
-
await client.user.clearActivity();
|
|
362
386
|
const reason = pausedUntil
|
|
363
387
|
? `paused until ${new Date(pausedUntil).toLocaleTimeString()}`
|
|
364
388
|
: privacyHidden ? 'privacy=hidden in this project' : 'stale — Claude Code not running';
|
|
365
|
-
|
|
389
|
+
transmit('clear', null, `Presence cleared (${reason})`);
|
|
366
390
|
return;
|
|
367
391
|
}
|
|
368
392
|
|
|
369
393
|
const activity = buildActivity({ resolved });
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
394
|
+
transmit('set', activity, `Presence updated: ${activity.details || '-'} | ${activity.state || '-'}`);
|
|
395
|
+
} catch (e) {
|
|
396
|
+
log('pushPresence failed:', e.message);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Minimum gap between Discord writes, read live so a config reload takes effect.
|
|
401
|
+
// Floored at 2s as a sanity minimum; the 4s default keeps us under Discord's
|
|
402
|
+
// ~5-per-20s SET_ACTIVITY limit (see default-config.minActivityGapMs).
|
|
403
|
+
function activityGapMs() {
|
|
404
|
+
return Math.max(2000, config.minActivityGapMs || 4000);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Sentinel for a clear — can never collide with a real activity hash, since
|
|
408
|
+
// JSON.stringify of an activity object always begins with '{'.
|
|
409
|
+
const CLEAR_HASH = 'cleared';
|
|
410
|
+
|
|
411
|
+
// Route a desired payload (kind:'set'|'clear') through the rate-limit throttle.
|
|
412
|
+
// 'send' writes now; 'defer' stashes the LATEST payload and arms a single flush
|
|
413
|
+
// timer; 'skip' means the wire already shows this exact frame.
|
|
414
|
+
function transmit(kind, activity, logMsg) {
|
|
415
|
+
const hash = kind === 'clear' ? CLEAR_HASH : JSON.stringify(activity);
|
|
416
|
+
const d = throttleDecision({
|
|
417
|
+
hash, lastSentHash, lastSentAt,
|
|
418
|
+
now: Date.now(), gapMs: activityGapMs(), flushPending: !!flushTimer,
|
|
419
|
+
});
|
|
420
|
+
if (d.action === 'skip') { pendingSend = null; return; }
|
|
421
|
+
if (d.action === 'send') { doSend({ kind, activity, hash, logMsg }); return; }
|
|
422
|
+
// defer — remember the LATEST payload and flush it once when the gap expires.
|
|
423
|
+
pendingSend = { kind, activity, hash, logMsg };
|
|
424
|
+
if (!flushTimer) {
|
|
425
|
+
flushTimer = setTimeout(flushPendingSend, d.waitMs);
|
|
426
|
+
if (flushTimer.unref) flushTimer.unref();
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function flushPendingSend() {
|
|
431
|
+
flushTimer = null;
|
|
432
|
+
const p = pendingSend;
|
|
433
|
+
pendingSend = null;
|
|
434
|
+
if (!p || !connected || !client?.user) return;
|
|
435
|
+
if (p.hash === lastSentHash) return; // state reverted to what's already shown
|
|
436
|
+
doSend(p);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// The single point that actually writes to Discord. lastSentAt advances on every
|
|
440
|
+
// ATTEMPT (so a failure backs off a full gap before retrying); lastSentHash
|
|
441
|
+
// advances ONLY on success — a dropped/rate-limited write is therefore retried
|
|
442
|
+
// by the next push rather than being recorded as success and stranded.
|
|
443
|
+
async function doSend({ kind, activity, hash, logMsg }) {
|
|
444
|
+
lastSentAt = Date.now();
|
|
445
|
+
try {
|
|
446
|
+
if (kind === 'clear') await client.user.clearActivity();
|
|
447
|
+
else await client.user.setActivity(activity);
|
|
448
|
+
lastSentHash = hash;
|
|
449
|
+
log(logMsg);
|
|
375
450
|
} catch (e) {
|
|
376
451
|
log('setActivity failed:', e.message, '|', e.stack?.split('\n').slice(0, 3).join(' | '));
|
|
377
|
-
// A failed
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
452
|
+
// A failed write usually means the IPC pipe died WITHOUT a 'disconnected'
|
|
453
|
+
// event (Discord restart, socket reset, OS sleep). Left alone, `connected`
|
|
454
|
+
// stays true and the daemon goes silently dark forever. Tear down and force
|
|
455
|
+
// a backoff reconnect so we self-heal — guarded to connection-shaped errors
|
|
456
|
+
// so a one-off app error (e.g. a rate-limit rejection) just waits for the
|
|
457
|
+
// next push's retry instead of needlessly bouncing a healthy socket.
|
|
383
458
|
if (isConnectionError(e)) {
|
|
384
459
|
log('setActivity error looks connection-level — forcing reconnect');
|
|
385
460
|
connected = false;
|
|
386
|
-
|
|
461
|
+
resetTransmit();
|
|
387
462
|
try { client?.destroy(); } catch { /* already gone */ }
|
|
388
463
|
scheduleReconnect('setActivity failed');
|
|
389
464
|
}
|
|
390
465
|
}
|
|
391
466
|
}
|
|
392
467
|
|
|
468
|
+
// Force the next pushPresence to write even if the activity is byte-identical,
|
|
469
|
+
// and drop any queued flush. Called when an input changed (config/aggregate/
|
|
470
|
+
// scan/sessions) or the connection bounced — the new frame must go out fresh.
|
|
471
|
+
function resetTransmit() {
|
|
472
|
+
lastSentHash = '';
|
|
473
|
+
pendingSend = null;
|
|
474
|
+
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
|
475
|
+
}
|
|
476
|
+
|
|
393
477
|
// Heuristic: does this error indicate the IPC transport itself is dead
|
|
394
478
|
// (vs. a transient/application-level failure)? Matches the common broken-pipe
|
|
395
479
|
// / closed-socket shapes from our IPC client (src/discord-ipc.js) and the net
|
|
@@ -405,6 +489,10 @@ function isConnectionError(e) {
|
|
|
405
489
|
|
|
406
490
|
async function connect() {
|
|
407
491
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
492
|
+
// While login() is in flight, connected===false and reconnectTimer===null
|
|
493
|
+
// both hold, so the watchdog's "down, nothing pending" branch would spawn a
|
|
494
|
+
// second connect() that overwrites `client` and orphans this socket. Block it.
|
|
495
|
+
connecting = true;
|
|
408
496
|
client = new Client({ clientId: config.clientId, transport: { type: 'ipc' } });
|
|
409
497
|
|
|
410
498
|
client.on('ready', () => {
|
|
@@ -412,7 +500,7 @@ async function connect() {
|
|
|
412
500
|
// Reset backoff so the next outage also starts at RECONNECT_BASE_MS.
|
|
413
501
|
reconnectDelayMs = RECONNECT_BASE_MS;
|
|
414
502
|
log('Discord RPC connected as', client.user?.username);
|
|
415
|
-
|
|
503
|
+
resetTransmit();
|
|
416
504
|
pushPresence();
|
|
417
505
|
});
|
|
418
506
|
client.on('disconnected', () => {
|
|
@@ -422,6 +510,8 @@ async function connect() {
|
|
|
422
510
|
try { await client.login(); }
|
|
423
511
|
catch (e) {
|
|
424
512
|
scheduleReconnect(`Discord login failed: ${e.message}`);
|
|
513
|
+
} finally {
|
|
514
|
+
connecting = false;
|
|
425
515
|
}
|
|
426
516
|
}
|
|
427
517
|
|
|
@@ -452,12 +542,12 @@ function watchFiles() {
|
|
|
452
542
|
{ path: CONFIG_PATH, label: 'config', onChange: () => {
|
|
453
543
|
log('Config changed — reloading');
|
|
454
544
|
config = loadConfigWithLog();
|
|
455
|
-
|
|
545
|
+
resetTransmit();
|
|
456
546
|
pushPresence();
|
|
457
547
|
} },
|
|
458
548
|
{ path: AGGREGATE_PATH, label: 'aggregate', onChange: () => {
|
|
459
549
|
aggregate = readAggregate() || aggregate;
|
|
460
|
-
|
|
550
|
+
resetTransmit();
|
|
461
551
|
pushPresence();
|
|
462
552
|
} },
|
|
463
553
|
];
|
|
@@ -535,7 +625,7 @@ async function runBackgroundScan({ force = false } = {}) {
|
|
|
535
625
|
const { aggregate: agg, scanned, skipped, removed, total } = scan({ force });
|
|
536
626
|
aggregate = agg;
|
|
537
627
|
log(`Scan complete: ${scanned} parsed / ${skipped} cached / ${removed} removed / ${total} total in ${Date.now() - t0}ms — allHours=${(agg.activeMs / 3_600_000).toFixed(1)}, sessions=${agg.sessions}, tokens=${(agg.inputTokens + agg.outputTokens)}`);
|
|
538
|
-
|
|
628
|
+
resetTransmit();
|
|
539
629
|
pushPresence();
|
|
540
630
|
} catch (e) {
|
|
541
631
|
log('Scan failed:', e.message);
|
|
@@ -587,7 +677,7 @@ function refreshLiveSessions() {
|
|
|
587
677
|
liveSessions = next;
|
|
588
678
|
if (next.length !== prevCount) {
|
|
589
679
|
log('Concurrent sessions:', next.length, next.map((s) => `${s.project}(${s.ageSec}s)`).join(', '));
|
|
590
|
-
|
|
680
|
+
resetTransmit();
|
|
591
681
|
pushPresence();
|
|
592
682
|
}
|
|
593
683
|
} catch (e) {
|
|
@@ -609,7 +699,7 @@ const HEALTH_CHECK_MS = 30_000;
|
|
|
609
699
|
setInterval(() => {
|
|
610
700
|
try {
|
|
611
701
|
// Half-open: flag says connected but there's no usable user handle.
|
|
612
|
-
if (connected && !client?.user) {
|
|
702
|
+
if (connected && !client?.user && !connecting) {
|
|
613
703
|
log('Watchdog: connected but no user handle — forcing reconnect');
|
|
614
704
|
connected = false;
|
|
615
705
|
try { client?.destroy(); } catch { /* already gone */ }
|
|
@@ -618,7 +708,7 @@ setInterval(() => {
|
|
|
618
708
|
}
|
|
619
709
|
// Down with nothing scheduled to bring us back. scheduleReconnect is a
|
|
620
710
|
// no-op when a timer is already pending, so this can't stack retries.
|
|
621
|
-
if (!connected && !reconnectTimer) {
|
|
711
|
+
if (!connected && !reconnectTimer && !connecting) {
|
|
622
712
|
log('Watchdog: disconnected with no reconnect pending — forcing reconnect');
|
|
623
713
|
scheduleReconnect('watchdog: no retry pending');
|
|
624
714
|
}
|