claude-rpc 0.5.0 → 0.6.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/README.md +156 -195
- package/config.example.json +2 -65
- package/package.json +1 -1
- package/src/card.js +2 -1
- package/src/cli.js +105 -24
- package/src/config.js +89 -0
- package/src/daemon.js +113 -19
- package/src/doctor.js +10 -30
- package/src/git.js +2 -2
- package/src/hook.js +1 -1
- package/src/install.js +58 -5
- package/src/pricing.js +29 -6
- package/src/privacy.js +1 -1
- package/src/scanner.js +2 -2
- package/src/server/api.js +6 -3
- package/src/server/page.js +52 -14
- package/src/server/sse.js +2 -2
- package/src/tui.js +6 -7
- package/src/ui.js +89 -0
- package/src/version.js +26 -0
package/src/cli.js
CHANGED
|
@@ -8,7 +8,7 @@ import process from 'node:process';
|
|
|
8
8
|
// code page on Win10 is 437/850, which displays many of our chars as `?`.
|
|
9
9
|
// Hook events (no TTY) skip this — they don't print anything user-visible.
|
|
10
10
|
if (process.platform === 'win32' && process.stdout.isTTY) {
|
|
11
|
-
try { spawnSync('chcp.com', ['65001'], { stdio: 'ignore', windowsHide: true }); } catch {}
|
|
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
13
|
import { DAEMON_SCRIPT, PID_PATH, STATE_PATH, LOG_PATH, AGGREGATE_PATH, CONFIG_PATH, IS_PACKAGED, EXE_PATH, CANONICAL_EXE } from './paths.js';
|
|
14
14
|
import { readState } from './state.js';
|
|
@@ -21,6 +21,9 @@ import { generateInsights } from './insights.js';
|
|
|
21
21
|
import { badgeSvg } from './badge.js';
|
|
22
22
|
import { fmtCost } from './pricing.js';
|
|
23
23
|
import { addPrivateCwd, removePrivateCwd, listPrivateCwds, resolveVisibility } from './privacy.js';
|
|
24
|
+
import { loadConfig, hasUserConfig } from './config.js';
|
|
25
|
+
import { VERSION } from './version.js';
|
|
26
|
+
import { fail, EX_USER_ERROR, EX_BAD_STATE } from './ui.js';
|
|
24
27
|
import { basename } from 'node:path';
|
|
25
28
|
|
|
26
29
|
const cmd = process.argv[2];
|
|
@@ -246,7 +249,7 @@ function bar(val, max, width = 22) {
|
|
|
246
249
|
function showStatus() {
|
|
247
250
|
const state = readState();
|
|
248
251
|
const aggregate = readAggregate();
|
|
249
|
-
const config =
|
|
252
|
+
const config = loadConfig();
|
|
250
253
|
const live = findLiveSessions({ thresholdMs: 90_000 });
|
|
251
254
|
state.liveSessions = live;
|
|
252
255
|
const vars = buildVars(state, config, aggregate);
|
|
@@ -430,7 +433,7 @@ function showStatus() {
|
|
|
430
433
|
function showToday() {
|
|
431
434
|
const state = readState();
|
|
432
435
|
const aggregate = readAggregate();
|
|
433
|
-
const config =
|
|
436
|
+
const config = loadConfig();
|
|
434
437
|
state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
|
|
435
438
|
const vars = buildVars(state, config, aggregate);
|
|
436
439
|
|
|
@@ -458,7 +461,7 @@ function showToday() {
|
|
|
458
461
|
function showWeek() {
|
|
459
462
|
const state = readState();
|
|
460
463
|
const aggregate = readAggregate();
|
|
461
|
-
const config =
|
|
464
|
+
const config = loadConfig();
|
|
462
465
|
state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
|
|
463
466
|
const vars = buildVars(state, config, aggregate);
|
|
464
467
|
|
|
@@ -518,7 +521,7 @@ function statusColor(status) {
|
|
|
518
521
|
function showPreview() {
|
|
519
522
|
let state = readState();
|
|
520
523
|
const aggregate = readAggregate();
|
|
521
|
-
const config =
|
|
524
|
+
const config = loadConfig();
|
|
522
525
|
const live = findLiveSessions({ thresholdMs: 90_000 });
|
|
523
526
|
state.liveSessions = live;
|
|
524
527
|
state = applyIdle(state, config);
|
|
@@ -558,7 +561,7 @@ function showPreview() {
|
|
|
558
561
|
// previous helper exactly: { vars: [sorted keys], live: <full vars object> }.
|
|
559
562
|
function dumpVars() {
|
|
560
563
|
let state = readState();
|
|
561
|
-
const config =
|
|
564
|
+
const config = loadConfig();
|
|
562
565
|
state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
|
|
563
566
|
state = applyIdle(state, config);
|
|
564
567
|
const live = buildVars(state, config, readAggregate() || {});
|
|
@@ -597,14 +600,12 @@ function doScan(force = false) {
|
|
|
597
600
|
function doBackfill(argv) {
|
|
598
601
|
const path = argv[0];
|
|
599
602
|
if (!path) {
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
console.log(`${c.dim} merges them into your aggregate.${c.reset}`);
|
|
603
|
-
process.exit(1);
|
|
603
|
+
fail('usage: claude-rpc backfill <path>',
|
|
604
|
+
{ hint: 'point at any folder containing .jsonl transcripts (e.g. a backup of ~/.claude/projects)' });
|
|
604
605
|
}
|
|
605
606
|
if (!existsSync(path)) {
|
|
606
|
-
|
|
607
|
-
|
|
607
|
+
fail(`path doesn't exist: ${path}`,
|
|
608
|
+
{ hint: 'check the spelling, or run `claude-rpc doctor` to see where transcripts live' });
|
|
608
609
|
}
|
|
609
610
|
console.log(`${c.dim}Backfilling from${c.reset} ${c.cyan}${path}${c.reset}…`);
|
|
610
611
|
const t0 = Date.now();
|
|
@@ -655,8 +656,7 @@ function doBadge(argv) {
|
|
|
655
656
|
const opts = parseBadgeArgs(argv);
|
|
656
657
|
const aggregate = readAggregate();
|
|
657
658
|
if (!aggregate) {
|
|
658
|
-
|
|
659
|
-
process.exit(1);
|
|
659
|
+
fail('no aggregate yet — nothing to render', { hint: 'run `claude-rpc scan` first', code: EX_BAD_STATE });
|
|
660
660
|
}
|
|
661
661
|
const svg = badgeSvg({ aggregate, metric: opts.metric, range: opts.range, label: opts.label });
|
|
662
662
|
if (opts.out) {
|
|
@@ -684,8 +684,7 @@ async function doCard(argv) {
|
|
|
684
684
|
const opts = parseCardArgs(argv);
|
|
685
685
|
const aggregate = readAggregate();
|
|
686
686
|
if (!aggregate) {
|
|
687
|
-
|
|
688
|
-
process.exit(1);
|
|
687
|
+
fail('no aggregate yet — nothing to render', { hint: 'run `claude-rpc scan` first', code: EX_BAD_STATE });
|
|
689
688
|
}
|
|
690
689
|
const { renderCard } = await import('./card.js');
|
|
691
690
|
const svg = renderCard(aggregate, { range: opts.range });
|
|
@@ -707,10 +706,6 @@ async function doCard(argv) {
|
|
|
707
706
|
// Per-project overrides live in <project>/.claude-rpc.json and take priority
|
|
708
707
|
// over the runtime list. See src/privacy.js for the full resolution chain.
|
|
709
708
|
|
|
710
|
-
function loadConfigSafe() {
|
|
711
|
-
try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); } catch { return {}; }
|
|
712
|
-
}
|
|
713
|
-
|
|
714
709
|
function doPrivate() {
|
|
715
710
|
const cwd = process.cwd();
|
|
716
711
|
const list = addPrivateCwd(cwd);
|
|
@@ -731,7 +726,7 @@ function doPublic() {
|
|
|
731
726
|
|
|
732
727
|
function doPrivacy() {
|
|
733
728
|
const cwd = process.cwd();
|
|
734
|
-
const cfg =
|
|
729
|
+
const cfg = loadConfig();
|
|
735
730
|
const { visibility, projectName, reason } = resolveVisibility(cwd, cfg);
|
|
736
731
|
const color = visibility === 'hidden' ? c.red : visibility === 'name-only' ? c.yellow : c.green;
|
|
737
732
|
console.log('');
|
|
@@ -771,14 +766,85 @@ function tailLog() {
|
|
|
771
766
|
// file rotated
|
|
772
767
|
lastSize = buf.length;
|
|
773
768
|
}
|
|
774
|
-
} catch {}
|
|
769
|
+
} catch { /* read race vs rotation — next watchFile tick recovers */ }
|
|
775
770
|
});
|
|
776
771
|
}
|
|
777
772
|
|
|
773
|
+
// One-screen "where am I?" view. Goal: a user typing `claude-rpc` with no
|
|
774
|
+
// args sees in <24 lines what's happening and the four most useful next
|
|
775
|
+
// commands. Full command list lives behind --help.
|
|
776
|
+
function overview() {
|
|
777
|
+
const setUp = hasUserConfig();
|
|
778
|
+
const cfg = loadConfig();
|
|
779
|
+
const pid = daemonPid();
|
|
780
|
+
|
|
781
|
+
console.log('');
|
|
782
|
+
console.log(` ${c.bold}${c.magenta}◆ claude-rpc${c.reset} ${c.dim}v${VERSION} — Discord Rich Presence for Claude Code${c.reset}`);
|
|
783
|
+
console.log('');
|
|
784
|
+
|
|
785
|
+
if (!setUp) {
|
|
786
|
+
console.log(` ${c.yellow}○${c.reset} not configured yet`);
|
|
787
|
+
console.log('');
|
|
788
|
+
console.log(` Run ${c.cyan}claude-rpc setup${c.reset} to get started.`);
|
|
789
|
+
console.log('');
|
|
790
|
+
console.log(` ${c.dim}--help for the full command list${c.reset}`);
|
|
791
|
+
console.log('');
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Status line: daemon up/down + project + model + status verb.
|
|
796
|
+
const state = readState();
|
|
797
|
+
const aggregate = readAggregate();
|
|
798
|
+
state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
|
|
799
|
+
const vars = buildVars(state, cfg, aggregate);
|
|
800
|
+
const dot = pid
|
|
801
|
+
? `${c.green}●${c.reset} running ${c.dim}pid ${pid}${c.reset}`
|
|
802
|
+
: `${c.yellow}○${c.reset} ${c.dim}daemon not running${c.reset}`;
|
|
803
|
+
if (pid) {
|
|
804
|
+
console.log(` ${dot} ${c.gray}·${c.reset} ${c.bold}${vars.modelPretty}${c.reset} in ${c.bold}${vars.project}${c.reset} ${c.gray}·${c.reset} ${statusColor(vars.status)}${vars.statusVerbose}${c.reset}`);
|
|
805
|
+
} else {
|
|
806
|
+
console.log(` ${dot}`);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (aggregate) {
|
|
810
|
+
console.log('');
|
|
811
|
+
console.log(` ${c.dim}today ${c.reset}${c.green}${vars.todayHours.padEnd(6)}${c.reset}${c.dim}active · ${vars.todayPromptsLabel} · ${vars.todayCostFmt}${c.reset}`);
|
|
812
|
+
console.log(` ${c.dim}streak ${c.reset}${c.magenta}${String(vars.streak).padEnd(6)}${c.reset}${c.dim}${vars.streak === 1 ? 'day' : 'days'} · longest ${vars.longestStreak} · ${vars.allHours} all-time${c.reset}`);
|
|
813
|
+
} else {
|
|
814
|
+
console.log('');
|
|
815
|
+
console.log(` ${c.dim}no stats yet — run ${c.reset}${c.cyan}claude-rpc scan${c.reset}${c.dim} to build aggregates${c.reset}`);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Four most useful next commands. Doctor leads — it's the answer to
|
|
819
|
+
// "something looks wrong" without the user having to read anything.
|
|
820
|
+
console.log('');
|
|
821
|
+
const next = pid
|
|
822
|
+
? [['status', 'full dashboard with heatmap'],
|
|
823
|
+
['doctor', 'diagnose problems'],
|
|
824
|
+
['serve', 'open the web dashboard'],
|
|
825
|
+
['stop', 'stop the daemon']]
|
|
826
|
+
: (cfg.clientId && cfg.clientId !== '1234567890123456789')
|
|
827
|
+
? [['start', 'launch the daemon'],
|
|
828
|
+
['doctor', 'diagnose problems'],
|
|
829
|
+
['status', 'show current stats'],
|
|
830
|
+
['setup', 'register Claude Code hooks']]
|
|
831
|
+
: [['setup', 'install hooks and seed config'],
|
|
832
|
+
['doctor', 'diagnose problems'],
|
|
833
|
+
['start', 'launch the daemon'],
|
|
834
|
+
['status', 'show current stats']];
|
|
835
|
+
for (const [name, desc] of next) {
|
|
836
|
+
console.log(` ${c.cyan}${name.padEnd(8)}${c.reset} ${c.dim}→${c.reset} ${desc}`);
|
|
837
|
+
}
|
|
838
|
+
console.log('');
|
|
839
|
+
console.log(` ${c.dim}--help for the full command list · --version${c.reset}`);
|
|
840
|
+
console.log('');
|
|
841
|
+
}
|
|
842
|
+
|
|
778
843
|
function help() {
|
|
779
844
|
const cmds = [
|
|
780
845
|
['setup', 'Install Claude Code hooks (~/.claude/settings.json)'],
|
|
781
846
|
['uninstall', 'Remove Claude Code hooks'],
|
|
847
|
+
['upgrade-config', 'Re-run idempotent migrations on an existing config.json'],
|
|
782
848
|
['start', 'Start the Discord RPC daemon (detached)'],
|
|
783
849
|
['stop', 'Stop the daemon'],
|
|
784
850
|
['restart', 'Stop then start the daemon'],
|
|
@@ -815,6 +881,8 @@ function help() {
|
|
|
815
881
|
console.log('');
|
|
816
882
|
console.log(` ${c.dim}Tip: ${c.reset}edit ${c.cyan}config.json${c.reset} to customize rotation frames. Run ${c.cyan}claude-rpc preview${c.reset} to see the result without Discord.`);
|
|
817
883
|
console.log('');
|
|
884
|
+
console.log(` ${c.dim}Exit codes:${c.reset} ${c.dim}0 ok · 1 user error · 2 system error · 3 wrong state${c.reset}`);
|
|
885
|
+
console.log('');
|
|
818
886
|
}
|
|
819
887
|
|
|
820
888
|
// Packaged exe: `claude-rpc.exe` with no args → first-run install + start.
|
|
@@ -827,9 +895,16 @@ const packagedDefault = IS_PACKAGED && !cmd;
|
|
|
827
895
|
// top-level await.
|
|
828
896
|
(async () => {
|
|
829
897
|
switch (cmd) {
|
|
898
|
+
case '--version':
|
|
899
|
+
case '-V':
|
|
900
|
+
case '-v': console.log(`claude-rpc ${VERSION}`); break;
|
|
901
|
+
case '--help':
|
|
902
|
+
case '-h':
|
|
903
|
+
case 'help': help(); break;
|
|
830
904
|
case 'setup': await runInstall({ exePath: EXE_PATH || process.execPath, withStartup: false }); break;
|
|
831
905
|
case 'install': await runInstall({ exePath: EXE_PATH || process.execPath }); break;
|
|
832
906
|
case 'uninstall': await runUninstall(); break;
|
|
907
|
+
case 'upgrade-config': migrateConfig(); break;
|
|
833
908
|
case 'start': startDaemon(); break;
|
|
834
909
|
case 'stop': stopDaemon(); break;
|
|
835
910
|
case 'restart': restartDaemon(); break;
|
|
@@ -895,7 +970,7 @@ const packagedDefault = IS_PACKAGED && !cmd;
|
|
|
895
970
|
console.warn(`refresh skipped: ${e.message}`);
|
|
896
971
|
}
|
|
897
972
|
const wasRunning = stopDaemon({ quiet: true });
|
|
898
|
-
try { if (existsSync(STATE_PATH)) unlinkSync(STATE_PATH); } catch {}
|
|
973
|
+
try { if (existsSync(STATE_PATH)) unlinkSync(STATE_PATH); } catch { /* state.json locked or already gone — next hook will recreate it */ }
|
|
899
974
|
if (wasRunning) {
|
|
900
975
|
// Brief wait for the OS to release the pid file before we spawn.
|
|
901
976
|
setTimeout(() => startDaemon(), 700);
|
|
@@ -903,8 +978,14 @@ const packagedDefault = IS_PACKAGED && !cmd;
|
|
|
903
978
|
startDaemon();
|
|
904
979
|
}
|
|
905
980
|
}
|
|
981
|
+
} else if (!cmd) {
|
|
982
|
+
// True no-args invocation in dev/npm mode → one-screen overview.
|
|
983
|
+
// The packagedDefault branch above handles the SEA exe's "double-
|
|
984
|
+
// click with no args" install-and-start flow.
|
|
985
|
+
overview();
|
|
906
986
|
} else {
|
|
907
|
-
|
|
987
|
+
fail(`unknown command: ${cmd}`,
|
|
988
|
+
{ hint: 'run `claude-rpc --help` for the full list', code: EX_USER_ERROR });
|
|
908
989
|
}
|
|
909
990
|
}
|
|
910
991
|
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Config loader. One place that knows how to:
|
|
2
|
+
//
|
|
3
|
+
// 1. Read the user's config.json from disk.
|
|
4
|
+
// 2. Deep-merge it over DEFAULT_CONFIG so the user file only needs to
|
|
5
|
+
// hold *overrides* (a fresh user file is two lines: clientId and
|
|
6
|
+
// maybe appName).
|
|
7
|
+
// 3. Survive bad JSON / missing files / wrong types without crashing.
|
|
8
|
+
// Bad input logs a one-line warning and falls back to defaults.
|
|
9
|
+
// Critical for the daemon — an Electron-GUI mid-edit save used to
|
|
10
|
+
// hard-exit it via daemon.js's `process.exit(1)`. No more.
|
|
11
|
+
//
|
|
12
|
+
// Merge rule: plain objects deep-merge, arrays REPLACE. Arrays-as-deep-
|
|
13
|
+
// merge is rarely what anyone wants (a user rotation array becomes a
|
|
14
|
+
// spliced franken-array of theirs + defaults). Replacing matches what
|
|
15
|
+
// you'd expect from "I set this, it's mine."
|
|
16
|
+
//
|
|
17
|
+
// All callers (daemon, server/api, tui, cli) should go through
|
|
18
|
+
// `loadConfig()` rather than reading CONFIG_PATH directly.
|
|
19
|
+
|
|
20
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
21
|
+
import { CONFIG_PATH } from './paths.js';
|
|
22
|
+
import { DEFAULT_CONFIG } from './default-config.js';
|
|
23
|
+
|
|
24
|
+
// "Has the user run setup?" — a separate signal from `loadConfig` because
|
|
25
|
+
// loadConfig now always returns merged defaults (the daemon needs them
|
|
26
|
+
// even if the file isn't there yet). Callers that want to distinguish
|
|
27
|
+
// "never been set up" from "everything default" check this instead.
|
|
28
|
+
export function hasUserConfig(path = CONFIG_PATH) {
|
|
29
|
+
return existsSync(path);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isPlainObject(v) {
|
|
33
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Deep-merge `over` onto `base`. Plain objects merge recursively; arrays
|
|
37
|
+
// and primitives from `over` replace whatever was in `base`. Returns a
|
|
38
|
+
// fresh object — neither input is mutated. Used for layering user config
|
|
39
|
+
// over DEFAULT_CONFIG and (later) per-project overrides over user config.
|
|
40
|
+
export function mergeConfig(base, over) {
|
|
41
|
+
if (over === undefined || over === null) return structuredClone(base);
|
|
42
|
+
if (!isPlainObject(base) || !isPlainObject(over)) return structuredClone(over);
|
|
43
|
+
const out = {};
|
|
44
|
+
const keys = new Set([...Object.keys(base), ...Object.keys(over)]);
|
|
45
|
+
for (const k of keys) {
|
|
46
|
+
const a = base[k];
|
|
47
|
+
const b = over[k];
|
|
48
|
+
if (b === undefined) {
|
|
49
|
+
out[k] = structuredClone(a);
|
|
50
|
+
} else if (isPlainObject(a) && isPlainObject(b)) {
|
|
51
|
+
out[k] = mergeConfig(a, b);
|
|
52
|
+
} else {
|
|
53
|
+
out[k] = structuredClone(b);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Read + merge. `onError(message)` is called when the user config can't
|
|
60
|
+
// be parsed — caller decides whether to log to stdout, daemon.log, or
|
|
61
|
+
// nothing. Returning the merged-defaults object is the contract: never
|
|
62
|
+
// throw, never exit. Worst case we render with shipped defaults.
|
|
63
|
+
//
|
|
64
|
+
// path defaults to CONFIG_PATH so all daemon-like callers can drop the
|
|
65
|
+
// `readFileSync(CONFIG_PATH, ...)` boilerplate.
|
|
66
|
+
export function loadConfig({ path = CONFIG_PATH, onError } = {}) {
|
|
67
|
+
if (!existsSync(path)) {
|
|
68
|
+
return mergeConfig(DEFAULT_CONFIG, {});
|
|
69
|
+
}
|
|
70
|
+
let raw;
|
|
71
|
+
try {
|
|
72
|
+
raw = readFileSync(path, 'utf8');
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if (onError) onError(`config read failed at ${path}: ${e.message} — falling back to defaults`);
|
|
75
|
+
return mergeConfig(DEFAULT_CONFIG, {});
|
|
76
|
+
}
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(raw);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
if (onError) onError(`config parse failed at ${path}: ${e.message} — falling back to defaults`);
|
|
82
|
+
return mergeConfig(DEFAULT_CONFIG, {});
|
|
83
|
+
}
|
|
84
|
+
if (!isPlainObject(parsed)) {
|
|
85
|
+
if (onError) onError(`config at ${path} is not an object — falling back to defaults`);
|
|
86
|
+
return mergeConfig(DEFAULT_CONFIG, {});
|
|
87
|
+
}
|
|
88
|
+
return mergeConfig(DEFAULT_CONFIG, parsed);
|
|
89
|
+
}
|
package/src/daemon.js
CHANGED
|
@@ -1,33 +1,71 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { writeFileSync, existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
|
|
3
3
|
import { Client } from '@xhayper/discord-rpc';
|
|
4
4
|
import { readState } from './state.js';
|
|
5
5
|
import { buildVars, fillTemplate, framePasses, applyIdle } from './format.js';
|
|
6
6
|
import { scan, readAggregate, findLiveSessions, readSessionTokens } from './scanner.js';
|
|
7
7
|
import { detectGithubUrl } from './git.js';
|
|
8
8
|
import { applyPrivacy } from './privacy.js';
|
|
9
|
+
import { loadConfig } from './config.js';
|
|
9
10
|
import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH } from './paths.js';
|
|
10
11
|
|
|
11
12
|
if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
|
|
12
13
|
|
|
14
|
+
// Daemon log capped at 5MB. Same policy events.jsonl uses (see hook.js).
|
|
15
|
+
// On rotation we move the existing log aside as `daemon.log.1` so the
|
|
16
|
+
// last rotation's content is still available for `claude-rpc tail`.
|
|
17
|
+
// One file's worth of history is enough — older logs have never been
|
|
18
|
+
// useful in practice, and the daemon runs for weeks.
|
|
19
|
+
const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
function maybeRotateLog() {
|
|
22
|
+
try {
|
|
23
|
+
const st = statSync(LOG_PATH);
|
|
24
|
+
if (st.size <= LOG_ROTATE_BYTES) return;
|
|
25
|
+
renameSync(LOG_PATH, LOG_PATH + '.1');
|
|
26
|
+
} catch {
|
|
27
|
+
// No log file yet, or rename failed (another daemon is rotating
|
|
28
|
+
// simultaneously). Either case is safe to ignore — we'll just keep
|
|
29
|
+
// appending and try rotation again on the next write.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
13
33
|
function log(...args) {
|
|
14
34
|
const line = `[${new Date().toISOString()}] ${args.map((a) => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')}\n`;
|
|
15
|
-
|
|
35
|
+
maybeRotateLog();
|
|
36
|
+
try {
|
|
37
|
+
appendFileSync(LOG_PATH, line);
|
|
38
|
+
} catch {
|
|
39
|
+
// Disk full, permission denied, or LOG_PATH became invalid mid-run.
|
|
40
|
+
// The daemon must keep running regardless — Discord presence is more
|
|
41
|
+
// important than file logging.
|
|
42
|
+
}
|
|
16
43
|
process.stdout.write(line);
|
|
17
44
|
}
|
|
18
45
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
46
|
+
// Wrap loadConfig so a parse/IO failure logs once and the daemon keeps
|
|
47
|
+
// running on baked-in defaults. The Electron settings GUI saves the file
|
|
48
|
+
// atomically but mid-edit hand-edits used to brick the daemon — this is
|
|
49
|
+
// the "no, just keep going with what we shipped" failsafe.
|
|
50
|
+
function loadConfigWithLog() {
|
|
51
|
+
return loadConfig({ onError: (msg) => log(msg) });
|
|
22
52
|
}
|
|
23
53
|
|
|
24
|
-
let config =
|
|
54
|
+
let config = loadConfigWithLog();
|
|
25
55
|
let aggregate = readAggregate() || null;
|
|
26
56
|
let liveSessions = [];
|
|
27
57
|
let client = null;
|
|
28
58
|
let connected = false;
|
|
29
59
|
let lastPayloadHash = '';
|
|
30
60
|
let reconnectTimer = null;
|
|
61
|
+
// Exponential backoff for Discord reconnect: 5s → 10s → 20s → … → 300s cap.
|
|
62
|
+
// Reset to RECONNECT_BASE_MS on a successful connect so the next outage
|
|
63
|
+
// also starts gentle. Jitter (±30%) keeps multiple daemons (e.g. a user
|
|
64
|
+
// running both packaged and dev simultaneously) from synchronizing
|
|
65
|
+
// reconnect storms against Discord's IPC socket.
|
|
66
|
+
const RECONNECT_BASE_MS = 5_000;
|
|
67
|
+
const RECONNECT_CAP_MS = 300_000;
|
|
68
|
+
let reconnectDelayMs = RECONNECT_BASE_MS;
|
|
31
69
|
let rotationIndex = 0;
|
|
32
70
|
let lastRotationAt = 0;
|
|
33
71
|
// Stabilizes Discord's elapsed timer: applyIdle can synthesize a sessionStart
|
|
@@ -134,9 +172,15 @@ function buildActivity(opts = {}) {
|
|
|
134
172
|
if (frame.details) activity.details = fillTemplate(frame.details, vars).slice(0, 128);
|
|
135
173
|
if (frame.state) activity.state = fillTemplate(frame.state, vars).slice(0, 128);
|
|
136
174
|
|
|
137
|
-
//
|
|
138
|
-
// statusAssets
|
|
139
|
-
//
|
|
175
|
+
// ── Large-image precedence (single source of truth) ────────────────
|
|
176
|
+
// 1. statusAssets[status] "working" gif when working, etc.
|
|
177
|
+
// 2. modelAssets[opus|sonnet|haiku|default]
|
|
178
|
+
// per-model art (Opus/Sonnet/Haiku),
|
|
179
|
+
// only consulted when statusAssets
|
|
180
|
+
// doesn't match AND state isn't stale.
|
|
181
|
+
// 3. presence.largeImageKey global fallback.
|
|
182
|
+
// smallImageKey separately resolves to the `{statusIcon}` template var
|
|
183
|
+
// (set via config.statusIcons) and is dropped entirely when empty.
|
|
140
184
|
let largeKeyTpl = p.largeImageKey;
|
|
141
185
|
if (config.statusAssets && config.statusAssets[state.status]) {
|
|
142
186
|
largeKeyTpl = config.statusAssets[state.status];
|
|
@@ -243,26 +287,34 @@ async function connect() {
|
|
|
243
287
|
|
|
244
288
|
client.on('ready', () => {
|
|
245
289
|
connected = true;
|
|
290
|
+
// Reset backoff so the next outage also starts at RECONNECT_BASE_MS.
|
|
291
|
+
reconnectDelayMs = RECONNECT_BASE_MS;
|
|
246
292
|
log('Discord RPC connected as', client.user?.username);
|
|
247
293
|
lastPayloadHash = '';
|
|
248
294
|
pushPresence();
|
|
249
295
|
});
|
|
250
296
|
client.on('disconnected', () => {
|
|
251
297
|
connected = false;
|
|
252
|
-
|
|
253
|
-
scheduleReconnect();
|
|
298
|
+
scheduleReconnect('Discord disconnected');
|
|
254
299
|
});
|
|
255
300
|
try { await client.login(); }
|
|
256
301
|
catch (e) {
|
|
257
|
-
|
|
258
|
-
scheduleReconnect();
|
|
302
|
+
scheduleReconnect(`Discord login failed: ${e.message}`);
|
|
259
303
|
}
|
|
260
304
|
}
|
|
261
305
|
|
|
262
|
-
function scheduleReconnect() {
|
|
306
|
+
function scheduleReconnect(reason = 'reconnect') {
|
|
263
307
|
connected = false;
|
|
264
308
|
if (reconnectTimer) return;
|
|
265
|
-
|
|
309
|
+
// ±30% jitter on the current step. Cheap protection against
|
|
310
|
+
// synchronized reconnect storms from sibling daemons.
|
|
311
|
+
const jitter = 0.7 + Math.random() * 0.6;
|
|
312
|
+
const wait = Math.round(reconnectDelayMs * jitter);
|
|
313
|
+
log(`${reason} — retry in ${Math.round(wait / 1000)}s. Is Discord desktop running?`);
|
|
314
|
+
reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, wait);
|
|
315
|
+
// Step the base for the *next* failure. Cap at 5min so a long Discord
|
|
316
|
+
// outage doesn't push us into multi-hour silences.
|
|
317
|
+
reconnectDelayMs = Math.min(RECONNECT_CAP_MS, reconnectDelayMs * 2);
|
|
266
318
|
}
|
|
267
319
|
|
|
268
320
|
function watchFiles() {
|
|
@@ -275,8 +327,9 @@ function watchFiles() {
|
|
|
275
327
|
}
|
|
276
328
|
watch(CONFIG_PATH, () => {
|
|
277
329
|
log('Config changed — reloading');
|
|
278
|
-
|
|
279
|
-
|
|
330
|
+
config = loadConfigWithLog();
|
|
331
|
+
lastPayloadHash = '';
|
|
332
|
+
pushPresence();
|
|
280
333
|
});
|
|
281
334
|
if (existsSync(AGGREGATE_PATH)) {
|
|
282
335
|
let aggTimer = null;
|
|
@@ -289,6 +342,44 @@ function watchFiles() {
|
|
|
289
342
|
}, 250);
|
|
290
343
|
});
|
|
291
344
|
}
|
|
345
|
+
|
|
346
|
+
// Mtime-poll fallback. fs.watch on Windows occasionally drops events
|
|
347
|
+
// when the writer uses an atomic-rename pattern (which `state.js` does
|
|
348
|
+
// and the scanner does for aggregate.json). A 30s poll comparing
|
|
349
|
+
// last-seen mtime catches anything the watcher missed without making
|
|
350
|
+
// the watcher itself the bottleneck. No-op on Linux/macOS most of the
|
|
351
|
+
// time, but cheap enough to leave on everywhere.
|
|
352
|
+
let lastStateMtime = 0, lastAggMtime = 0;
|
|
353
|
+
setInterval(() => {
|
|
354
|
+
try {
|
|
355
|
+
if (existsSync(STATE_PATH)) {
|
|
356
|
+
const m = statSync(STATE_PATH).mtimeMs;
|
|
357
|
+
if (m > lastStateMtime) {
|
|
358
|
+
if (lastStateMtime !== 0) {
|
|
359
|
+
// The first observation is just the starting value; only
|
|
360
|
+
// log + push when we actually missed a watcher event.
|
|
361
|
+
log('state.json mtime advanced without a watcher event (poll fallback)');
|
|
362
|
+
pushPresence();
|
|
363
|
+
}
|
|
364
|
+
lastStateMtime = m;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (existsSync(AGGREGATE_PATH)) {
|
|
368
|
+
const m = statSync(AGGREGATE_PATH).mtimeMs;
|
|
369
|
+
if (m > lastAggMtime) {
|
|
370
|
+
if (lastAggMtime !== 0) {
|
|
371
|
+
aggregate = readAggregate() || aggregate;
|
|
372
|
+
lastPayloadHash = '';
|
|
373
|
+
pushPresence();
|
|
374
|
+
}
|
|
375
|
+
lastAggMtime = m;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
} catch {
|
|
379
|
+
// Stat fail mid-rotate of the watched file. The next tick will
|
|
380
|
+
// pick up the new mtime. Silent on purpose.
|
|
381
|
+
}
|
|
382
|
+
}, 30_000);
|
|
292
383
|
}
|
|
293
384
|
|
|
294
385
|
async function runBackgroundScan({ force = false } = {}) {
|
|
@@ -306,8 +397,11 @@ async function runBackgroundScan({ force = false } = {}) {
|
|
|
306
397
|
|
|
307
398
|
function shutdown() {
|
|
308
399
|
log('Shutting down…');
|
|
309
|
-
|
|
310
|
-
|
|
400
|
+
// Both calls below are best-effort cleanup on the way out the door.
|
|
401
|
+
// If the IPC client is already half-dead or the PID file was removed
|
|
402
|
+
// by something else, we don't care — we're exiting anyway.
|
|
403
|
+
try { client?.destroy(); } catch { /* IPC already gone */ }
|
|
404
|
+
try { if (existsSync(PID_PATH)) unlinkSync(PID_PATH); } catch { /* race vs another shutdown */ }
|
|
311
405
|
process.exit(0);
|
|
312
406
|
}
|
|
313
407
|
|
package/src/doctor.js
CHANGED
|
@@ -16,37 +16,17 @@ import {
|
|
|
16
16
|
} from './paths.js';
|
|
17
17
|
import { findLiveSessions } from './scanner.js';
|
|
18
18
|
import { resolveVisibility, listPrivateCwds } from './privacy.js';
|
|
19
|
-
|
|
20
|
-
const TTY = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
21
|
-
const c = {
|
|
22
|
-
reset: TTY ? '\x1b[0m' : '',
|
|
23
|
-
dim: TTY ? '\x1b[2m' : '',
|
|
24
|
-
bold: TTY ? '\x1b[1m' : '',
|
|
25
|
-
red: TTY ? '\x1b[31m' : '',
|
|
26
|
-
green: TTY ? '\x1b[32m' : '',
|
|
27
|
-
yellow: TTY ? '\x1b[33m' : '',
|
|
28
|
-
cyan: TTY ? '\x1b[36m' : '',
|
|
29
|
-
gray: TTY ? '\x1b[90m' : '',
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const SYM_OK = TTY ? `${c.green}✓${c.reset}` : '[ok] ';
|
|
33
|
-
const SYM_FAIL = TTY ? `${c.red}✗${c.reset}` : '[fail]';
|
|
34
|
-
const SYM_WARN = TTY ? `${c.yellow}!${c.reset}` : '[warn]';
|
|
35
|
-
const SYM_INFO = TTY ? `${c.cyan}·${c.reset}` : '[info]';
|
|
19
|
+
import { c, check as uiCheck } from './ui.js';
|
|
36
20
|
|
|
37
21
|
const counters = { pass: 0, fail: 0, warn: 0 };
|
|
38
22
|
|
|
23
|
+
// Thin wrapper around the shared ui.check so we can keep counters local
|
|
24
|
+
// to this module without exporting a stateful version from ui.js.
|
|
39
25
|
function check(label, status, detail = '', hint = '') {
|
|
40
|
-
|
|
41
|
-
if (status === '
|
|
42
|
-
else if (status === '
|
|
43
|
-
|
|
44
|
-
else { sym = SYM_INFO; }
|
|
45
|
-
const tail = detail ? ` ${c.dim}${detail}${c.reset}` : '';
|
|
46
|
-
console.log(` ${sym} ${label}${tail}`);
|
|
47
|
-
if (hint && status !== 'pass') {
|
|
48
|
-
console.log(` ${c.gray}↳ ${hint}${c.reset}`);
|
|
49
|
-
}
|
|
26
|
+
if (status === 'pass') counters.pass++;
|
|
27
|
+
else if (status === 'fail') counters.fail++;
|
|
28
|
+
else if (status === 'warn') counters.warn++;
|
|
29
|
+
uiCheck(label, status, detail, hint);
|
|
50
30
|
}
|
|
51
31
|
|
|
52
32
|
function section(title) {
|
|
@@ -143,7 +123,7 @@ function checkClaudeProjects() {
|
|
|
143
123
|
if (f.endsWith('.jsonl')) count++;
|
|
144
124
|
}
|
|
145
125
|
}
|
|
146
|
-
} catch {}
|
|
126
|
+
} catch { /* unreadable subdir — just report whatever we counted so far */ }
|
|
147
127
|
check('claude transcripts visible', count > 0 ? 'pass' : 'warn',
|
|
148
128
|
`${count} .jsonl ${count === 1 ? 'file' : 'files'}`,
|
|
149
129
|
count === 0 ? 'open claude code and send a prompt — transcripts appear immediately' : '');
|
|
@@ -207,7 +187,7 @@ function checkCanonicalExe() {
|
|
|
207
187
|
}
|
|
208
188
|
if (existsSync(CANONICAL_EXE)) {
|
|
209
189
|
let size = '';
|
|
210
|
-
try { size = `${(statSync(CANONICAL_EXE).size / 1024 / 1024).toFixed(1)} MB`; } catch {}
|
|
190
|
+
try { size = `${(statSync(CANONICAL_EXE).size / 1024 / 1024).toFixed(1)} MB`; } catch { /* stat failed, size stays blank */ }
|
|
211
191
|
check('canonical exe installed', 'pass', `${CANONICAL_EXE} (${size})`);
|
|
212
192
|
} else {
|
|
213
193
|
check('canonical exe installed', 'fail', `missing: ${CANONICAL_EXE}`,
|
|
@@ -253,7 +233,7 @@ function checkDaemonLog() {
|
|
|
253
233
|
try {
|
|
254
234
|
const tail = readFileSync(LOG_PATH, 'utf8').split('\n').slice(-50).join('\n');
|
|
255
235
|
connected = /Discord RPC connected/i.test(tail);
|
|
256
|
-
} catch {}
|
|
236
|
+
} catch { /* log unreadable — connected stays false, warn check renders */ }
|
|
257
237
|
if (connected) {
|
|
258
238
|
check('discord IPC connection', 'pass',
|
|
259
239
|
`${sizeKB} KB log · last write ${ageMin.toFixed(1)} min ago`);
|
package/src/git.js
CHANGED
|
@@ -48,14 +48,14 @@ function readGitInfo(cwd) {
|
|
|
48
48
|
if (leaf) out.repo = leaf;
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
-
} catch {}
|
|
51
|
+
} catch { /* missing/unreadable .git/config — out.repo stays at cwd basename */ }
|
|
52
52
|
|
|
53
53
|
// HEAD → branch (or empty when detached).
|
|
54
54
|
try {
|
|
55
55
|
const head = readFileSync(join(gitDir, 'HEAD'), 'utf8').trim();
|
|
56
56
|
const ref = head.match(/^ref:\s+refs\/heads\/(.+)$/);
|
|
57
57
|
if (ref) out.branch = ref[1].trim();
|
|
58
|
-
} catch {}
|
|
58
|
+
} catch { /* missing/unreadable HEAD — leave branch blank, template will hide */ }
|
|
59
59
|
|
|
60
60
|
return out;
|
|
61
61
|
}
|
package/src/hook.js
CHANGED