claude-rpc 0.13.6 → 0.14.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/README.md +6 -2
- package/package.json +1 -1
- package/src/cli.js +140 -25
- package/src/daemon.js +60 -58
- package/src/discord-ipc.js +10 -0
- package/src/format.js +17 -13
- package/src/git.js +39 -7
- package/src/hook.js +10 -1
- package/src/install.js +24 -0
- package/src/paths.js +3 -0
- package/src/pause.js +56 -0
- package/src/pricing.js +14 -7
- package/src/scanner.js +142 -37
- package/src/server/index.js +8 -2
- package/src/server/sse.js +26 -12
- package/src/ui.js +10 -0
- package/src/version.js +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
# claude-rpc
|
|
9
9
|
|
|
10
|
-
**Discord Rich Presence for [Claude Code](https://claude.com/claude-code).**
|
|
10
|
+
**Discord Rich Presence (RPC) for [Claude Code](https://claude.com/claude-code).**
|
|
11
11
|
Your live model, project, current tool, tokens, and lifetime stats — in your Discord profile. Driven by the hooks Claude Code already fires. Zero polling between sessions.
|
|
12
12
|
|
|
13
13
|
**→ [claude-rpc.vercel.app](https://claude-rpc.vercel.app)** — what it looks like, in one page.
|
|
@@ -38,9 +38,11 @@ A small Node daemon that takes the lifecycle events Claude Code already fires an
|
|
|
38
38
|
**macOS / Linux / any Node 18+** — one command:
|
|
39
39
|
|
|
40
40
|
```sh
|
|
41
|
-
npx claude-rpc setup
|
|
41
|
+
npx claude-rpc@latest setup
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
(The `@latest` matters — bare `npx claude-rpc` will happily reuse a stale cached copy.)
|
|
45
|
+
|
|
44
46
|
That installs `claude-rpc` globally, wires the hooks into Claude Code, and starts the daemon — no separate `start` step. Open Claude Code in any project and the card appears within a second. Something looks wrong? `claude-rpc doctor` (or `claude-rpc doctor --fix` to auto-repair).
|
|
45
47
|
|
|
46
48
|
**Prefer a one-liner that figures it out for you?**
|
|
@@ -312,6 +314,8 @@ npm run build:exe # SEA single-file binary for the current OS
|
|
|
312
314
|
|
|
313
315
|
Tests are `node --test` with zero deps. The CI pipeline ([release.yml](.github/workflows/release.yml)) runs the suite (plus the Cloudflare Worker's own tests) across Node 18/20/22 and gates the matrix build and the npm publish behind it. Every pure/logic module in `src/*.js` is unit-tested, including the MCP transport and the SVG renderers; the long-running daemon, the TUI, and the CLI dispatcher are covered by integration and manual smoke testing rather than unit tests.
|
|
314
316
|
|
|
317
|
+
Where the project is headed (and what it will deliberately never do) lives in [`ROADMAP.md`](ROADMAP.md).
|
|
318
|
+
|
|
315
319
|
## license
|
|
316
320
|
|
|
317
321
|
[MIT](LICENSE) © Archer Simmons
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -22,10 +22,11 @@ import { maybeNudge } from './nudge.js';
|
|
|
22
22
|
import { badgeSvg } from './badge.js';
|
|
23
23
|
import { fmtCost } from './pricing.js';
|
|
24
24
|
import { addPrivateCwd, removePrivateCwd, listPrivateCwds, resolveVisibility } from './privacy.js';
|
|
25
|
+
import { parseDuration, setPause, clearPause, pauseUntil } from './pause.js';
|
|
25
26
|
import { loadConfig, hasUserConfig } from './config.js';
|
|
26
27
|
import * as lb from './leaderboard.js';
|
|
27
28
|
import { VERSION } from './version.js';
|
|
28
|
-
import { fail, EX_USER_ERROR, EX_BAD_STATE, EX_SYS_ERROR } from './ui.js';
|
|
29
|
+
import { fail, tailLines, EX_USER_ERROR, EX_BAD_STATE, EX_SYS_ERROR } from './ui.js';
|
|
29
30
|
import { randomUUID } from 'node:crypto';
|
|
30
31
|
import { createInterface } from 'node:readline';
|
|
31
32
|
import { basename } from 'node:path';
|
|
@@ -110,7 +111,7 @@ function box(title, lines, minWidth = 64) {
|
|
|
110
111
|
const termWidth = process.stdout.columns || 100;
|
|
111
112
|
const maxAllowed = Math.max(40, termWidth - 2);
|
|
112
113
|
const width = Math.min(maxAllowed, Math.max(minWidth, longest + 4, title.length + 8));
|
|
113
|
-
const top = `${c.gray}┌─ ${c.reset}${c.bold}${title}${c.reset} ${c.gray}${'─'.repeat(Math.max(0, width -
|
|
114
|
+
const top = `${c.gray}┌─ ${c.reset}${c.bold}${title}${c.reset} ${c.gray}${'─'.repeat(Math.max(0, width - 5 - title.length))}┐${c.reset}`;
|
|
114
115
|
const bottom = `${c.gray}└${'─'.repeat(width - 2)}┘${c.reset}`;
|
|
115
116
|
console.log(top);
|
|
116
117
|
for (const raw of lines) {
|
|
@@ -932,6 +933,78 @@ function doPrivacy() {
|
|
|
932
933
|
console.log('');
|
|
933
934
|
}
|
|
934
935
|
|
|
936
|
+
// ── Pause / resume ───────────────────────────────────────────────────────
|
|
937
|
+
//
|
|
938
|
+
// `claude-rpc pause [30m|2h|1h30m|90]` → snooze the Discord card globally
|
|
939
|
+
// (default 1h). `claude-rpc resume` (or `pause off`) lifts it early; expiry
|
|
940
|
+
// lifts it automatically. Privacy controls are per-cwd — this is the
|
|
941
|
+
// "I'm screen-sharing" switch that hides everything regardless of project.
|
|
942
|
+
|
|
943
|
+
function fmtClock(ts) {
|
|
944
|
+
const d = new Date(ts);
|
|
945
|
+
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function doPause(argv) {
|
|
949
|
+
const arg = (argv[0] || '').toLowerCase();
|
|
950
|
+
if (arg === 'off' || arg === 'resume') return doResume();
|
|
951
|
+
if (arg === 'status') {
|
|
952
|
+
const until = pauseUntil();
|
|
953
|
+
if (until) console.log(`${c.yellow}●${c.reset} paused until ${c.cyan}${fmtClock(until)}${c.reset}`);
|
|
954
|
+
else console.log(`${c.green}○${c.reset} not paused`);
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
const ms = parseDuration(argv[0]);
|
|
958
|
+
if (ms === null) {
|
|
959
|
+
fail(`could not parse duration: ${argv[0]}`,
|
|
960
|
+
{ hint: 'use 30m, 2h, 1h30m, or a bare number of minutes (default: 1h)', code: EX_USER_ERROR });
|
|
961
|
+
}
|
|
962
|
+
const until = setPause(ms);
|
|
963
|
+
console.log(`${c.green}✓${c.reset} presence paused until ${c.cyan}${fmtClock(until)}${c.reset}`);
|
|
964
|
+
console.log(`${c.dim} the daemon clears the card within a few seconds. Resume early with ${c.reset}${c.cyan}claude-rpc resume${c.reset}`);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function doResume() {
|
|
968
|
+
const was = pauseUntil();
|
|
969
|
+
clearPause();
|
|
970
|
+
if (was) {
|
|
971
|
+
console.log(`${c.green}✓${c.reset} presence resumed ${c.dim}(was paused until ${fmtClock(was)})${c.reset}`);
|
|
972
|
+
} else {
|
|
973
|
+
console.log(`${c.dim}presence wasn't paused.${c.reset}`);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// ── Export ───────────────────────────────────────────────────────────────
|
|
978
|
+
//
|
|
979
|
+
// `claude-rpc export [--csv] [--out <file>]` — the full aggregate as JSON, or
|
|
980
|
+
// the per-day breakdown as CSV (same shape the dashboard's /api/export routes
|
|
981
|
+
// serve, without needing the server up). Writes to stdout unless --out, so it
|
|
982
|
+
// pipes cleanly into jq / a spreadsheet import.
|
|
983
|
+
|
|
984
|
+
async function doExport(argv) {
|
|
985
|
+
const csv = argv.includes('--csv');
|
|
986
|
+
let out = '';
|
|
987
|
+
const i = argv.findIndex((a) => a === '--out' || a === '-o');
|
|
988
|
+
if (i !== -1) out = argv[i + 1] || '';
|
|
989
|
+
const aggregate = readAggregate();
|
|
990
|
+
if (!aggregate) {
|
|
991
|
+
fail('no aggregate yet — nothing to export', { hint: 'run `claude-rpc scan` first', code: EX_BAD_STATE });
|
|
992
|
+
}
|
|
993
|
+
let payload;
|
|
994
|
+
if (csv) {
|
|
995
|
+
const { aggregateToCsv } = await import('./server/api.js');
|
|
996
|
+
payload = aggregateToCsv(aggregate);
|
|
997
|
+
} else {
|
|
998
|
+
payload = JSON.stringify(aggregate, null, 2) + '\n';
|
|
999
|
+
}
|
|
1000
|
+
if (out) {
|
|
1001
|
+
writeFileSync(out, payload);
|
|
1002
|
+
console.log(`${c.green}✓${c.reset} Wrote ${c.cyan}${out}${c.reset} (${payload.length} bytes, ${csv ? 'CSV' : 'JSON'})`);
|
|
1003
|
+
} else {
|
|
1004
|
+
process.stdout.write(payload);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
935
1008
|
// ── Community totals ─────────────────────────────────────────────────────
|
|
936
1009
|
//
|
|
937
1010
|
// `claude-rpc community` → show current state + endpoint
|
|
@@ -1060,16 +1133,46 @@ function readFlag(argv, name) {
|
|
|
1060
1133
|
|
|
1061
1134
|
function profileStatus() {
|
|
1062
1135
|
const p = (loadConfig().profile) || {};
|
|
1136
|
+
const handleOk = lb.isValidHandle(p.handle);
|
|
1137
|
+
const boardUrl = handleOk ? `https://claude-rpc.vercel.app/u/${encodeURIComponent(p.handle)}` : '';
|
|
1138
|
+
|
|
1063
1139
|
console.log('');
|
|
1064
|
-
console.log(` ${c.bold}
|
|
1140
|
+
console.log(` ${c.bold}${c.magenta}◆ profile${c.reset} ${c.dim}— public leaderboard identity${c.reset}`);
|
|
1065
1141
|
console.log('');
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1142
|
+
|
|
1143
|
+
const githubLine = p.githubUser
|
|
1144
|
+
? `${p.githubUser}${p.verified ? ` ${c.green}✓ verified${c.reset}` : ` ${c.dim}(unverified)${c.reset}`}`
|
|
1145
|
+
: `${c.dim}—${c.reset}`;
|
|
1146
|
+
box('profile', [
|
|
1147
|
+
pair('state', p.enabled ? `${c.green}● publishing${c.reset}` : `${c.dim}○ off${c.reset}`, ''),
|
|
1148
|
+
pair('handle', handleOk ? `${c.cyan}${p.handle}${c.reset}` : `${c.dim}(unset)${c.reset}`, ''),
|
|
1149
|
+
pair('name', p.displayName || `${c.dim}—${c.reset}`, ''),
|
|
1150
|
+
pair('github', githubLine, ''),
|
|
1151
|
+
...(p.enabled && handleOk ? [pair('board', boardUrl, c.cyan)] : []),
|
|
1152
|
+
]);
|
|
1070
1153
|
console.log('');
|
|
1071
|
-
|
|
1072
|
-
|
|
1154
|
+
|
|
1155
|
+
// Setup checklist — same shape every time, so the user always sees where
|
|
1156
|
+
// they are and the exact next command. This is the screen the daemon's
|
|
1157
|
+
// breadcrumbs point back to.
|
|
1158
|
+
const steps = [
|
|
1159
|
+
{ done: handleOk, label: 'set a handle', cmd: 'claude-rpc profile set --handle <name>', note: p.handle },
|
|
1160
|
+
{ done: !!p.enabled, label: 'enable publishing', cmd: 'claude-rpc profile on', note: 'daemon republishes automatically' },
|
|
1161
|
+
{ done: !!p.verified, label: 'verify on GitHub', cmd: 'claude-rpc profile verify', note: p.githubUser ? `@${p.githubUser}` : '' },
|
|
1162
|
+
];
|
|
1163
|
+
if (steps.every((s) => s.done)) {
|
|
1164
|
+
console.log(` ${c.green}✓${c.reset} all set — you're live at ${c.cyan}${boardUrl}${c.reset}`);
|
|
1165
|
+
} else {
|
|
1166
|
+
const nextIdx = steps.findIndex((s) => !s.done);
|
|
1167
|
+
box('next steps', steps.map((s, i) => {
|
|
1168
|
+
const mark = s.done ? `${c.green}✓${c.reset}` : (i === nextIdx ? `${c.yellow}○${c.reset}` : `${c.dim}○${c.reset}`);
|
|
1169
|
+
const label = s.done ? `${c.dim}${s.label}${c.reset}` : `${c.bold}${s.label}${c.reset}`;
|
|
1170
|
+
const tail = s.done
|
|
1171
|
+
? `${c.dim}${s.note || 'done'}${c.reset}`
|
|
1172
|
+
: `${c.cyan}${s.cmd}${c.reset}${i === nextIdx ? ` ${c.dim}← next${c.reset}` : ''}`;
|
|
1173
|
+
return `${mark} ${i + 1}. ${label}${' '.repeat(Math.max(1, 20 - s.label.length))}${tail}`;
|
|
1174
|
+
}));
|
|
1175
|
+
}
|
|
1073
1176
|
console.log('');
|
|
1074
1177
|
}
|
|
1075
1178
|
|
|
@@ -1094,6 +1197,9 @@ function profileSet(argv) {
|
|
|
1094
1197
|
if (!u) return fail(`invalid GitHub username: ${rawGh}`, { code: EX_USER_ERROR });
|
|
1095
1198
|
next.githubUser = u;
|
|
1096
1199
|
}
|
|
1200
|
+
// The ✓ belongs to the account that was verified — switching accounts
|
|
1201
|
+
// means re-verifying.
|
|
1202
|
+
if (next.githubUser !== (userCfg.profile || {}).githubUser) delete next.verified;
|
|
1097
1203
|
}
|
|
1098
1204
|
|
|
1099
1205
|
userCfg.profile = next;
|
|
@@ -1127,7 +1233,7 @@ function profileEnable(on) {
|
|
|
1127
1233
|
console.log(`${c.green}✓${c.reset} leaderboard publishing ${on ? 'enabled' : 'disabled'}`);
|
|
1128
1234
|
if (on) {
|
|
1129
1235
|
console.log(` ${c.dim}publish now with ${c.reset}${c.cyan}claude-rpc profile publish${c.reset}${c.dim} (or wait for the next daemon flush).${c.reset}`);
|
|
1130
|
-
|
|
1236
|
+
profileStatus();
|
|
1131
1237
|
}
|
|
1132
1238
|
}
|
|
1133
1239
|
|
|
@@ -1158,10 +1264,12 @@ async function profileVerify() {
|
|
|
1158
1264
|
const cfg = loadConfig();
|
|
1159
1265
|
const profile = cfg.profile || {};
|
|
1160
1266
|
const community = cfg.community || {};
|
|
1267
|
+
// No --github required up front: the worker treats it as a hint only and
|
|
1268
|
+
// takes the authoritative identity from whoever owns the proof gist —
|
|
1269
|
+
// and publishing that gist already requires gh auth, so the account is
|
|
1270
|
+
// known by the time it matters.
|
|
1161
1271
|
if (!profile.githubUser) {
|
|
1162
|
-
|
|
1163
|
-
hint: 'claude-rpc profile set --github <user>', code: EX_BAD_STATE,
|
|
1164
|
-
});
|
|
1272
|
+
console.log(`${c.dim}no --github set — your verified identity will be the account that owns the proof gist.${c.reset}`);
|
|
1165
1273
|
}
|
|
1166
1274
|
if (!community.instanceId) {
|
|
1167
1275
|
return fail('enable the profile first', { hint: 'claude-rpc profile on', code: EX_BAD_STATE });
|
|
@@ -1186,7 +1294,7 @@ async function profileVerify() {
|
|
|
1186
1294
|
await flushProfile(cfg);
|
|
1187
1295
|
}
|
|
1188
1296
|
console.log(`${c.dim}requesting a verification token…${c.reset}`);
|
|
1189
|
-
const start = await post('/verify/start', { instanceId: community.instanceId, githubUser: profile.githubUser });
|
|
1297
|
+
const start = await post('/verify/start', { instanceId: community.instanceId, githubUser: profile.githubUser || null });
|
|
1190
1298
|
if (!start.json?.token) return fail(`verify/start failed: ${start.json?.error || start.status}`, { code: EX_SYS_ERROR });
|
|
1191
1299
|
const token = start.json.token;
|
|
1192
1300
|
|
|
@@ -1206,13 +1314,11 @@ async function profileVerify() {
|
|
|
1206
1314
|
const check = await post('/verify/check', { instanceId: community.instanceId, gistId: gist.id });
|
|
1207
1315
|
if (check.json?.verified) {
|
|
1208
1316
|
const who = check.json.githubUser || gist.owner || profile.githubUser;
|
|
1209
|
-
// Persist the authoritative owner
|
|
1210
|
-
// match what got verified.
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
|
|
1215
|
-
}
|
|
1317
|
+
// Persist the authoritative owner + a local verified marker so the
|
|
1318
|
+
// profile checklist and future publishes match what got verified.
|
|
1319
|
+
const userCfg = readJson(CONFIG_PATH, {});
|
|
1320
|
+
userCfg.profile = { ...(userCfg.profile || {}), ...(who ? { githubUser: who } : {}), verified: true };
|
|
1321
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
|
|
1216
1322
|
console.log(`${c.green}✓${c.reset} verified as @${who} — you'll show the ✓ on the board.`);
|
|
1217
1323
|
if (who && profile.githubUser && who.toLowerCase() !== profile.githubUser.toLowerCase()) {
|
|
1218
1324
|
console.log(` ${c.dim}(your gist is owned by @${who}, so the profile now uses that account.)${c.reset}`);
|
|
@@ -1260,8 +1366,7 @@ function tailLog() {
|
|
|
1260
1366
|
return;
|
|
1261
1367
|
}
|
|
1262
1368
|
// Print the last ~30 lines, then follow.
|
|
1263
|
-
const
|
|
1264
|
-
const tail = raw.slice(-31, -1);
|
|
1369
|
+
const tail = tailLines(readFileSync(LOG_PATH, 'utf8'));
|
|
1265
1370
|
for (const line of tail) process.stdout.write(line + '\n');
|
|
1266
1371
|
let lastSize = readFileSync(LOG_PATH).length;
|
|
1267
1372
|
console.log(`${c.dim}-- tailing ${LOG_PATH} (Ctrl-C to stop) --${c.reset}`);
|
|
@@ -1375,6 +1480,9 @@ function help() {
|
|
|
1375
1480
|
['mcp install', 'Wire the stats MCP server into Claude Code (one command)'],
|
|
1376
1481
|
['mcp', 'Run the MCP server (stdio) — exposes your stats to Claude'],
|
|
1377
1482
|
['wrapped', 'Open your animated year-in-review (Claude Wrapped)'],
|
|
1483
|
+
['pause', 'Snooze the Discord card globally (pause [30m|2h], default 1h)'],
|
|
1484
|
+
['resume', 'Lift a pause early'],
|
|
1485
|
+
['export', 'Dump the aggregate as JSON, or daily rows as CSV (--csv --out)'],
|
|
1378
1486
|
['private', 'Mark the current directory as private (hide from Discord)'],
|
|
1379
1487
|
['public', 'Un-mark the current directory'],
|
|
1380
1488
|
['privacy', 'Show resolved visibility for the current directory'],
|
|
@@ -1483,6 +1591,10 @@ const packagedDefault = IS_PACKAGED && !cmd;
|
|
|
1483
1591
|
break;
|
|
1484
1592
|
}
|
|
1485
1593
|
case 'wrapped': process.env.CLAUDE_RPC_OPEN_PATH = '/wrapped'; await import('./server/index.js'); break;
|
|
1594
|
+
case 'pause': doPause(process.argv.slice(3)); break;
|
|
1595
|
+
case 'resume':
|
|
1596
|
+
case 'unpause': doResume(); break;
|
|
1597
|
+
case 'export': await doExport(process.argv.slice(3)); break;
|
|
1486
1598
|
case 'private': doPrivate(); break;
|
|
1487
1599
|
case 'public': doPublic(); break;
|
|
1488
1600
|
case 'privacy': doPrivacy(); break;
|
|
@@ -1579,8 +1691,11 @@ const packagedDefault = IS_PACKAGED && !cmd;
|
|
|
1579
1691
|
// click with no args" install-and-start flow.
|
|
1580
1692
|
overview();
|
|
1581
1693
|
} else {
|
|
1582
|
-
|
|
1583
|
-
|
|
1694
|
+
// Version in the error line is deliberate: the #1 cause of "unknown
|
|
1695
|
+
// command" in the wild is a stale global install resolving instead of
|
|
1696
|
+
// the version the user read the docs for. Make the skew visible.
|
|
1697
|
+
fail(`unknown command: ${cmd} (claude-rpc v${VERSION})`,
|
|
1698
|
+
{ hint: 'run `claude-rpc --help` for the full list — if this command should exist, update first: npm install -g claude-rpc@latest', code: EX_USER_ERROR });
|
|
1584
1699
|
}
|
|
1585
1700
|
}
|
|
1586
1701
|
}
|
package/src/daemon.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { writeFileSync, existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
|
|
3
|
-
import { dirname } from 'node:path';
|
|
3
|
+
import { basename, dirname } from 'node:path';
|
|
4
4
|
import { Client } from './discord-ipc.js';
|
|
5
5
|
import { readState } from './state.js';
|
|
6
6
|
import { buildVars, fillTemplate, framePasses, applyIdle, applyShipped, applyTrigger } from './format.js';
|
|
7
7
|
import { scan, readAggregate, findLiveSessions, readSessionTokens } from './scanner.js';
|
|
8
8
|
import { detectGithubUrl } from './git.js';
|
|
9
9
|
import { applyPrivacy } from './privacy.js';
|
|
10
|
+
import { pauseUntil } from './pause.js';
|
|
10
11
|
import { loadConfig } from './config.js';
|
|
11
12
|
import { migrateConfig } from './install.js';
|
|
12
13
|
import { desktopNotify, postWebhook, shouldWebhook, shouldNotify } from './notify.js';
|
|
13
14
|
import { humanProject } from './format.js';
|
|
14
|
-
import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH } from './paths.js';
|
|
15
|
+
import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH, PAUSE_PATH } from './paths.js';
|
|
15
16
|
|
|
16
17
|
if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
|
|
17
18
|
|
|
@@ -232,7 +233,8 @@ function buildActivity(opts = {}) {
|
|
|
232
233
|
} else if (config.modelAssets && state.model && state.status !== 'stale') {
|
|
233
234
|
const m = String(state.model).toLowerCase();
|
|
234
235
|
let pick = null;
|
|
235
|
-
if (m.includes('
|
|
236
|
+
if (m.includes('fable')) pick = config.modelAssets.fable;
|
|
237
|
+
else if (m.includes('opus')) pick = config.modelAssets.opus;
|
|
236
238
|
else if (m.includes('sonnet')) pick = config.modelAssets.sonnet;
|
|
237
239
|
else if (m.includes('haiku')) pick = config.modelAssets.haiku;
|
|
238
240
|
if (!pick) pick = config.modelAssets.default;
|
|
@@ -283,6 +285,16 @@ function buildActivity(opts = {}) {
|
|
|
283
285
|
url: fillTemplate(b.url, vars),
|
|
284
286
|
}));
|
|
285
287
|
}
|
|
288
|
+
|
|
289
|
+
// Concurrent sessions render natively via Discord's party field — the card
|
|
290
|
+
// shows "(2 of 2)" with no template work. Only attached when more than one
|
|
291
|
+
// live session exists (a party of one is noise). Opt out: showPartySize:false.
|
|
292
|
+
const liveCount = (state.liveSessions || []).length;
|
|
293
|
+
if (config.showPartySize !== false && liveCount > 1) {
|
|
294
|
+
activity.partyId = 'claude-rpc';
|
|
295
|
+
activity.partySize = liveCount;
|
|
296
|
+
activity.partyMax = liveCount;
|
|
297
|
+
}
|
|
286
298
|
return activity;
|
|
287
299
|
}
|
|
288
300
|
|
|
@@ -327,7 +339,11 @@ async function pushPresence() {
|
|
|
327
339
|
|
|
328
340
|
const hideWhenStale = config.hideWhenStale !== false;
|
|
329
341
|
const privacyHidden = resolved._privacy?.visibility === 'hidden';
|
|
330
|
-
|
|
342
|
+
// Global snooze (`claude-rpc pause`) — clears the card while the deadline
|
|
343
|
+
// is in the future. Re-checked every tick, so expiry resumes presence
|
|
344
|
+
// automatically (the 'cleared' stamp differs from the next frame's hash).
|
|
345
|
+
const pausedUntil = pauseUntil();
|
|
346
|
+
if ((resolved.status === 'stale' && hideWhenStale) || privacyHidden || pausedUntil) {
|
|
331
347
|
const stamp = 'cleared';
|
|
332
348
|
if (lastPayloadHash === stamp) return;
|
|
333
349
|
lastPayloadHash = stamp;
|
|
@@ -335,7 +351,9 @@ async function pushPresence() {
|
|
|
335
351
|
// elapsed timer rather than counting from a previous session.
|
|
336
352
|
effectiveSessionStart = null;
|
|
337
353
|
await client.user.clearActivity();
|
|
338
|
-
const reason =
|
|
354
|
+
const reason = pausedUntil
|
|
355
|
+
? `paused until ${new Date(pausedUntil).toLocaleTimeString()}`
|
|
356
|
+
: privacyHidden ? 'privacy=hidden in this project' : 'stale — Claude Code not running';
|
|
339
357
|
log(`Presence cleared (${reason})`);
|
|
340
358
|
return;
|
|
341
359
|
}
|
|
@@ -414,61 +432,45 @@ function scheduleReconnect(reason = 'reconnect') {
|
|
|
414
432
|
}
|
|
415
433
|
|
|
416
434
|
function watchFiles() {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
//
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
});
|
|
436
|
-
};
|
|
437
|
-
if (existsSync(CONFIG_PATH)) {
|
|
438
|
-
watchConfig();
|
|
439
|
-
} else {
|
|
440
|
-
let configTimer = null;
|
|
441
|
-
const dir = dirname(CONFIG_PATH);
|
|
442
|
-
if (existsSync(dir)) {
|
|
443
|
-
const dirWatcher = watch(dir, () => {
|
|
444
|
-
if (!existsSync(CONFIG_PATH)) return;
|
|
445
|
-
clearTimeout(configTimer);
|
|
446
|
-
configTimer = setTimeout(() => {
|
|
447
|
-
try {
|
|
448
|
-
dirWatcher.close();
|
|
449
|
-
} catch {
|
|
450
|
-
/* already closed */
|
|
451
|
-
}
|
|
452
|
-
log('Config appeared — reloading');
|
|
453
|
-
config = loadConfigWithLog();
|
|
454
|
-
lastPayloadHash = '';
|
|
455
|
-
watchConfig();
|
|
456
|
-
pushPresence();
|
|
457
|
-
}, 250);
|
|
435
|
+
// Watch DIRECTORIES, not files. Every watched file here is written via
|
|
436
|
+
// tmp+rename (state.js, the scanner, the settings GUI), and inotify tracks
|
|
437
|
+
// the inode — a watcher attached to the file path goes silent after the
|
|
438
|
+
// first rename. A directory watcher survives renames AND works when the
|
|
439
|
+
// file doesn't exist yet (fresh install where the daemon starts before the
|
|
440
|
+
// first hook / before `setup` seeds config.json). Events are filtered by
|
|
441
|
+
// filename where the platform reports one; the rare null-filename event
|
|
442
|
+
// just costs one debounced no-op push (the payload hash dedupes it).
|
|
443
|
+
const watchDirFor = (targetPath, label, onChange, extraNames = []) => {
|
|
444
|
+
const dir = dirname(targetPath);
|
|
445
|
+
const names = new Set([basename(targetPath), ...extraNames]);
|
|
446
|
+
if (!existsSync(dir)) return;
|
|
447
|
+
let timer = null;
|
|
448
|
+
try {
|
|
449
|
+
watch(dir, (event, filename) => {
|
|
450
|
+
if (filename && !names.has(filename)) return;
|
|
451
|
+
clearTimeout(timer);
|
|
452
|
+
timer = setTimeout(onChange, 250);
|
|
458
453
|
});
|
|
454
|
+
} catch (e) {
|
|
455
|
+
log(`watch failed for ${label} (poll fallback still covers it):`, e.message);
|
|
459
456
|
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
}
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
// state.json and pause.json share STATE_DIR — one watcher serves both, so
|
|
460
|
+
// a `claude-rpc pause` clears the card on the next debounce, not the next
|
|
461
|
+
// 4s tick. The filename filter keeps daemon.log appends from triggering it.
|
|
462
|
+
watchDirFor(STATE_PATH, 'state', pushPresence, [basename(PAUSE_PATH)]);
|
|
463
|
+
watchDirFor(CONFIG_PATH, 'config', () => {
|
|
464
|
+
log('Config changed — reloading');
|
|
465
|
+
config = loadConfigWithLog();
|
|
466
|
+
lastPayloadHash = '';
|
|
467
|
+
pushPresence();
|
|
468
|
+
});
|
|
469
|
+
watchDirFor(AGGREGATE_PATH, 'aggregate', () => {
|
|
470
|
+
aggregate = readAggregate() || aggregate;
|
|
471
|
+
lastPayloadHash = '';
|
|
472
|
+
pushPresence();
|
|
473
|
+
});
|
|
472
474
|
|
|
473
475
|
// Mtime-poll fallback. fs.watch on Windows occasionally drops events
|
|
474
476
|
// when the writer uses an atomic-rename pattern (which `state.js` does
|
package/src/discord-ipc.js
CHANGED
|
@@ -133,6 +133,16 @@ export function formatActivity(activity = {}, pid) {
|
|
|
133
133
|
if (activity.smallImageText) a.assets.small_text = activity.smallImageText;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
// Party — renders natively as "(2 of 4)" on the card. Same field names
|
|
137
|
+
// @xhayper used (partyId / partySize / partyMax); size requires both ends.
|
|
138
|
+
if (activity.partyId || (activity.partySize != null && activity.partyMax != null)) {
|
|
139
|
+
a.party = {};
|
|
140
|
+
if (activity.partyId) a.party.id = activity.partyId;
|
|
141
|
+
if (activity.partySize != null && activity.partyMax != null) {
|
|
142
|
+
a.party.size = [activity.partySize, activity.partyMax];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
136
146
|
if (activity.buttons?.length) a.buttons = activity.buttons;
|
|
137
147
|
|
|
138
148
|
return { pid: pid ?? process?.pid ?? 0, activity: a };
|
package/src/format.js
CHANGED
|
@@ -81,11 +81,17 @@ function plural(n, sing, plur) {
|
|
|
81
81
|
return `${fmtNum(n)} ${word}`;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
// claude-opus-4-7 → Opus 4.7 · claude-sonnet-4-6-20250514 → Sonnet 4.6
|
|
84
|
+
// claude-opus-4-7 → Opus 4.7 · claude-sonnet-4-6-20250514 → Sonnet 4.6 ·
|
|
85
|
+
// claude-fable-5 → Fable 5 (no minor version). Version digits are capped at
|
|
86
|
+
// two so a trailing date stamp ("…-5-20260301") can't be read as a minor.
|
|
85
87
|
function humanModel(id) {
|
|
86
88
|
if (!id || typeof id !== 'string') return 'Claude';
|
|
87
|
-
const m = id.match(/(opus|sonnet|haiku)[^\d]*(\d
|
|
88
|
-
if (m)
|
|
89
|
+
const m = id.match(/(opus|sonnet|haiku|fable)[^\d]*(\d{1,2})(?!\d)(?:[-.](\d{1,2})(?!\d))?/i);
|
|
90
|
+
if (m) {
|
|
91
|
+
const name = `${m[1][0].toUpperCase()}${m[1].slice(1).toLowerCase()}`;
|
|
92
|
+
return m[3] ? `${name} ${m[2]}.${m[3]}` : `${name} ${m[2]}`;
|
|
93
|
+
}
|
|
94
|
+
if (/fable/i.test(id)) return 'Fable';
|
|
89
95
|
if (/opus/i.test(id)) return 'Opus';
|
|
90
96
|
if (/sonnet/i.test(id)) return 'Sonnet';
|
|
91
97
|
if (/haiku/i.test(id)) return 'Haiku';
|
|
@@ -841,13 +847,11 @@ export function applyIdle(state, cfg = {}) {
|
|
|
841
847
|
// Local state is fresh.
|
|
842
848
|
if (state.status === 'idle') {
|
|
843
849
|
// No transcripts being written anywhere on disk — Claude Code may have
|
|
844
|
-
// closed without a SessionEnd hook (force-quit, OS sleep, crash).
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
//
|
|
849
|
-
// at the cost of dropping the card whenever you pause for a couple
|
|
850
|
-
// minutes with the session still open.
|
|
850
|
+
// closed without a SessionEnd hook (force-quit, OS sleep, crash). By
|
|
851
|
+
// default (idleWhenOpen=false) go straight to stale so a closed terminal
|
|
852
|
+
// clears the card within ~90-120s of the last write. Opt in with
|
|
853
|
+
// idleWhenOpen:true to keep showing 'idle' through short pauses; the
|
|
854
|
+
// staleMs dormancy backstop above still clears it if Claude is truly gone.
|
|
851
855
|
if (liveSessions.length === 0 && !idleWhenOpen) return staleWipe(state);
|
|
852
856
|
return state;
|
|
853
857
|
}
|
|
@@ -855,10 +859,10 @@ export function applyIdle(state, cfg = {}) {
|
|
|
855
859
|
// Hook channel is quiet, but a live transcript was modified recently?
|
|
856
860
|
// Keep "working" instead of dropping to "idle".
|
|
857
861
|
if (liveAgeMs <= idleMs) return state;
|
|
858
|
-
// Hooks quiet AND no live transcripts.
|
|
862
|
+
// Hooks quiet AND no live transcripts. By default (idleWhenOpen=false)
|
|
863
|
+
// treat Claude as gone and go stale now. With idleWhenOpen:true the
|
|
859
864
|
// session is treated as open-but-paused and drops to idle; the staleMs
|
|
860
|
-
// backstop above clears it if Claude
|
|
861
|
-
// idleWhenOpen:false to go straight to stale here (old behavior).
|
|
865
|
+
// backstop above clears it later if Claude actually exited.
|
|
862
866
|
if (liveSessions.length === 0 && !idleWhenOpen) return staleWipe(state);
|
|
863
867
|
// Going idle — wipe "current activity" indicators so rotation frames
|
|
864
868
|
// gated on filesEdited / currentFile / currentTool stop showing stale
|
package/src/git.js
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
// The detached-HEAD case (HEAD contains a raw SHA, not `ref: refs/heads/...`)
|
|
8
8
|
// returns an empty branch — template `requires` will hide the branch frame.
|
|
9
9
|
|
|
10
|
-
import { readFileSync,
|
|
11
|
-
import { basename, join } from 'node:path';
|
|
10
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
11
|
+
import { basename, join, isAbsolute } from 'node:path';
|
|
12
12
|
|
|
13
13
|
const TTL_MS = 5 * 60 * 1000;
|
|
14
14
|
const cache = new Map(); // cwd → { ts, github, branch, repo }
|
|
@@ -17,6 +17,35 @@ function fresh(entry) {
|
|
|
17
17
|
return entry && (Date.now() - entry.ts) < TTL_MS;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
// Resolve a cwd's `.git` to the directory that actually holds HEAD etc.
|
|
21
|
+
// Usually `.git` IS that directory, but in a linked worktree or a submodule
|
|
22
|
+
// it's a one-line FILE — "gitdir: <path>" — pointing at the real location
|
|
23
|
+
// (e.g. <main>/.git/worktrees/<name>). Treating the file as a directory made
|
|
24
|
+
// every read below fail silently, so worktrees lost branch/repo/GitHub-button
|
|
25
|
+
// detection entirely.
|
|
26
|
+
function resolveGitDir(cwd) {
|
|
27
|
+
const dotGit = join(cwd, '.git');
|
|
28
|
+
let st;
|
|
29
|
+
try { st = statSync(dotGit); } catch { return null; }
|
|
30
|
+
if (st.isDirectory()) return dotGit;
|
|
31
|
+
try {
|
|
32
|
+
const m = readFileSync(dotGit, 'utf8').match(/^gitdir:\s*(.+?)\s*$/m);
|
|
33
|
+
if (!m) return null;
|
|
34
|
+
return isAbsolute(m[1]) ? m[1] : join(cwd, m[1]);
|
|
35
|
+
} catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The COMMON git dir — where `config` lives. For a linked worktree the
|
|
39
|
+
// per-worktree gitdir holds a `commondir` file with a (usually relative)
|
|
40
|
+
// path back to the main `.git`; everywhere else the gitdir is its own
|
|
41
|
+
// common dir (main repos, submodules).
|
|
42
|
+
function commonGitDir(gitDir) {
|
|
43
|
+
try {
|
|
44
|
+
const rel = readFileSync(join(gitDir, 'commondir'), 'utf8').trim();
|
|
45
|
+
return isAbsolute(rel) ? rel : join(gitDir, rel);
|
|
46
|
+
} catch { return gitDir; }
|
|
47
|
+
}
|
|
48
|
+
|
|
20
49
|
function readGitInfo(cwd) {
|
|
21
50
|
const out = { github: null, branch: '', repo: '' };
|
|
22
51
|
if (!cwd) return out;
|
|
@@ -25,12 +54,12 @@ function readGitInfo(cwd) {
|
|
|
25
54
|
// find a github origin.
|
|
26
55
|
out.repo = basename(cwd) || '';
|
|
27
56
|
|
|
28
|
-
const gitDir =
|
|
29
|
-
if (!
|
|
57
|
+
const gitDir = resolveGitDir(cwd);
|
|
58
|
+
if (!gitDir) return out;
|
|
30
59
|
|
|
31
60
|
// origin URL → github URL + repo name.
|
|
32
61
|
try {
|
|
33
|
-
const cfg = readFileSync(join(gitDir, 'config'), 'utf8');
|
|
62
|
+
const cfg = readFileSync(join(commonGitDir(gitDir), 'config'), 'utf8');
|
|
34
63
|
const m = cfg.match(/\[remote\s+"origin"\][^[]*?url\s*=\s*([^\r\n]+)/i);
|
|
35
64
|
if (m) {
|
|
36
65
|
const raw = m[1].trim();
|
|
@@ -84,8 +113,11 @@ export function detectGitRepo(cwd) { return lookup(cwd).repo; }
|
|
|
84
113
|
// just made a new one.
|
|
85
114
|
export function detectLastCommitSubject(cwd, max = 80) {
|
|
86
115
|
if (!cwd) return '';
|
|
87
|
-
|
|
88
|
-
|
|
116
|
+
// Follow a worktree/submodule `.git` file to the real gitdir —
|
|
117
|
+
// COMMIT_EDITMSG and logs/HEAD are per-worktree, so the resolved
|
|
118
|
+
// dir (not the common dir) is the right place for both.
|
|
119
|
+
const gitDir = resolveGitDir(cwd);
|
|
120
|
+
if (!gitDir) return '';
|
|
89
121
|
|
|
90
122
|
// COMMIT_EDITMSG: the editor buffer from the most recent `git commit`.
|
|
91
123
|
// First non-blank, non-comment line is the subject.
|
package/src/hook.js
CHANGED
|
@@ -56,8 +56,17 @@ function shipKindForSegment(seg) {
|
|
|
56
56
|
// leading command is git/gh — so a quoted mention ("git push later" inside an
|
|
57
57
|
// echo or a commit message) no longer false-fires. Tolerates env prefixes,
|
|
58
58
|
// sudo/time, chained commands, and git global flags.
|
|
59
|
+
//
|
|
60
|
+
// Quoted spans are blanked BEFORE splitting: separators inside quotes
|
|
61
|
+
// (`echo "run git push && rejoice"`) used to create a fake segment whose
|
|
62
|
+
// leading command was git. The real command's own quoted args (`git commit
|
|
63
|
+
// -m "msg"`) classify the same with or without the message text, so blanking
|
|
64
|
+
// is lossless for detection. An unbalanced quote leaves the string untouched.
|
|
59
65
|
export function classifyShip(cmd) {
|
|
60
|
-
const
|
|
66
|
+
const blanked = String(cmd || '')
|
|
67
|
+
.replace(/'[^']*'/g, ' ')
|
|
68
|
+
.replace(/"(?:\\.|[^"\\])*"/g, ' ');
|
|
69
|
+
const segments = blanked.split(/[;&|\n]+/);
|
|
61
70
|
const found = new Set();
|
|
62
71
|
for (const seg of segments) {
|
|
63
72
|
const k = shipKindForSegment(seg);
|
package/src/install.js
CHANGED
|
@@ -425,7 +425,31 @@ function promoteNpxToGlobal() {
|
|
|
425
425
|
return !r.error && r.status === 0;
|
|
426
426
|
}
|
|
427
427
|
|
|
428
|
+
// Best-effort registry check. npx serves stale cached copies without
|
|
429
|
+
// warning, and promoteNpxToGlobal pins @VERSION — so a stale npx cache
|
|
430
|
+
// would otherwise propagate itself into the global install silently, and
|
|
431
|
+
// the user's next `claude-rpc profile …` hits "unknown command" with no
|
|
432
|
+
// clue why. Warn loudly up front; never block setup on it (offline is fine).
|
|
433
|
+
function warnIfStale() {
|
|
434
|
+
try {
|
|
435
|
+
const r = spawnSync('npm', ['view', 'claude-rpc', 'version'], {
|
|
436
|
+
encoding: 'utf8', timeout: 4000,
|
|
437
|
+
shell: process.platform === 'win32', // npm is npm.cmd on Windows
|
|
438
|
+
});
|
|
439
|
+
const latest = (r.stdout || '').trim();
|
|
440
|
+
if (!latest || latest === VERSION) return;
|
|
441
|
+
const num = (v) => v.split('.').map((n) => parseInt(n, 10) || 0);
|
|
442
|
+
const [l, v] = [num(latest), num(VERSION)];
|
|
443
|
+
const newer = l[0] !== v[0] ? l[0] > v[0] : l[1] !== v[1] ? l[1] > v[1] : l[2] > v[2];
|
|
444
|
+
if (newer) {
|
|
445
|
+
console.warn(` ! you're running v${VERSION} but v${latest} is published — npx may have served a stale cache.`);
|
|
446
|
+
console.warn(` for the newest version, stop here and re-run: npx claude-rpc@latest setup`);
|
|
447
|
+
}
|
|
448
|
+
} catch { /* offline or npm missing — a version check must never block setup */ }
|
|
449
|
+
}
|
|
450
|
+
|
|
428
451
|
export async function install({ exePath, withStartup = true } = {}) {
|
|
452
|
+
warnIfStale();
|
|
429
453
|
if (IS_NPX) {
|
|
430
454
|
if (!promoteNpxToGlobal()) {
|
|
431
455
|
console.error('\n ✗ Global install failed. Run this once, then you\'re set:');
|
package/src/paths.js
CHANGED
|
@@ -70,6 +70,9 @@ export const STATE_DIR = join(tmpdir(), 'claude-rpc');
|
|
|
70
70
|
export const STATE_PATH = join(STATE_DIR, 'state.json');
|
|
71
71
|
export const PID_PATH = join(STATE_DIR, 'daemon.pid');
|
|
72
72
|
export const LOG_PATH = join(STATE_DIR, 'daemon.log');
|
|
73
|
+
// Presence snooze marker (`claude-rpc pause`). Lives in the tmp state dir on
|
|
74
|
+
// purpose — a reboot clearing a forgotten pause is the right failure mode.
|
|
75
|
+
export const PAUSE_PATH = join(STATE_DIR, 'pause.json');
|
|
73
76
|
export const DATA_DIR = join(homedir(), '.claude-rpc');
|
|
74
77
|
export const AGGREGATE_PATH = join(DATA_DIR, 'aggregate.json');
|
|
75
78
|
export const SCAN_CACHE_PATH = join(DATA_DIR, 'scan-cache.json');
|
package/src/pause.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Global presence snooze — `claude-rpc pause [30m|2h]` / `claude-rpc resume`.
|
|
2
|
+
//
|
|
3
|
+
// Privacy controls are per-cwd; this is the orthogonal "I'm screen-sharing
|
|
4
|
+
// for an hour" switch. The CLI writes a tiny { until } marker and the daemon
|
|
5
|
+
// checks it on every push tick, clearing the Discord card while the deadline
|
|
6
|
+
// is in the future. Expiry is passive: once `until` passes, the next tick
|
|
7
|
+
// resumes presence — no timer, no daemon restart, nothing to clean up.
|
|
8
|
+
|
|
9
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import { PAUSE_PATH, STATE_DIR } from './paths.js';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_PAUSE_MS = 60 * 60 * 1000; // bare `pause` = 1 hour
|
|
13
|
+
|
|
14
|
+
// Parse a human duration: "30m", "2h", "1h30m", or a bare number (minutes).
|
|
15
|
+
// Returns milliseconds, or null when the input doesn't parse / is <= 0.
|
|
16
|
+
export function parseDuration(raw) {
|
|
17
|
+
if (raw == null || raw === '') return DEFAULT_PAUSE_MS;
|
|
18
|
+
const s = String(raw).trim().toLowerCase();
|
|
19
|
+
if (/^\d+$/.test(s)) {
|
|
20
|
+
const min = Number(s);
|
|
21
|
+
return min > 0 ? min * 60_000 : null;
|
|
22
|
+
}
|
|
23
|
+
const m = s.match(/^(?:(\d+)h)?(?:(\d+)m)?$/);
|
|
24
|
+
if (!m || (!m[1] && !m[2])) return null;
|
|
25
|
+
const ms = (Number(m[1] || 0) * 60 + Number(m[2] || 0)) * 60_000;
|
|
26
|
+
return ms > 0 ? ms : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function setPause(ms, { path = PAUSE_PATH, now = Date.now() } = {}) {
|
|
30
|
+
const until = now + ms;
|
|
31
|
+
if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
|
|
32
|
+
writeFileSync(path, JSON.stringify({ until }));
|
|
33
|
+
return until;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function clearPause({ path = PAUSE_PATH } = {}) {
|
|
37
|
+
try {
|
|
38
|
+
if (existsSync(path)) {
|
|
39
|
+
unlinkSync(path);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
} catch { /* already gone, or unwritable tmp — either way not paused anymore */ }
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Epoch ms the pause runs until, or 0 when not paused (missing file,
|
|
47
|
+
// unreadable JSON, or a deadline already in the past).
|
|
48
|
+
export function pauseUntil({ path = PAUSE_PATH, now = Date.now() } = {}) {
|
|
49
|
+
try {
|
|
50
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
51
|
+
const until = Number(raw?.until);
|
|
52
|
+
return Number.isFinite(until) && until > now ? until : 0;
|
|
53
|
+
} catch {
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/pricing.js
CHANGED
|
@@ -6,11 +6,16 @@
|
|
|
6
6
|
// To customize: edit this file and run `claude-rpc rescan`.
|
|
7
7
|
|
|
8
8
|
const PRICING = {
|
|
9
|
-
// Opus
|
|
9
|
+
// Fable family — Anthropic's frontier tier above Opus.
|
|
10
|
+
'fable-5': { input: 10.00, output: 50.00, cacheRead: 1.00, cacheWrite: 12.50 },
|
|
11
|
+
|
|
12
|
+
// Opus 4.x family. 4.0/4.1 launched at $15/$75; the list price dropped to
|
|
13
|
+
// $5/$25 with Opus 4.5 and has held through 4.8.
|
|
10
14
|
'opus-4': { input: 15.00, output: 75.00, cacheRead: 1.50, cacheWrite: 18.75 },
|
|
11
|
-
'opus-4-5': { input:
|
|
12
|
-
'opus-4-6': { input:
|
|
13
|
-
'opus-4-7': { input:
|
|
15
|
+
'opus-4-5': { input: 5.00, output: 25.00, cacheRead: 0.50, cacheWrite: 6.25 },
|
|
16
|
+
'opus-4-6': { input: 5.00, output: 25.00, cacheRead: 0.50, cacheWrite: 6.25 },
|
|
17
|
+
'opus-4-7': { input: 5.00, output: 25.00, cacheRead: 0.50, cacheWrite: 6.25 },
|
|
18
|
+
'opus-4-8': { input: 5.00, output: 25.00, cacheRead: 0.50, cacheWrite: 6.25 },
|
|
14
19
|
|
|
15
20
|
// Sonnet 4.x family
|
|
16
21
|
'sonnet-4': { input: 3.00, output: 15.00, cacheRead: 0.30, cacheWrite: 3.75 },
|
|
@@ -21,15 +26,17 @@ const PRICING = {
|
|
|
21
26
|
'haiku-4': { input: 1.00, output: 5.00, cacheRead: 0.10, cacheWrite: 1.25 },
|
|
22
27
|
'haiku-4-5': { input: 1.00, output: 5.00, cacheRead: 0.10, cacheWrite: 1.25 },
|
|
23
28
|
|
|
24
|
-
// Generic fallbacks by tier.
|
|
25
|
-
|
|
29
|
+
// Generic fallbacks by tier. Opus uses the current-generation rates —
|
|
30
|
+
// unknown/future opus ids are far more likely to be 4.5+ than 4.1-era.
|
|
31
|
+
'fable': { input: 10.00, output: 50.00, cacheRead: 1.00, cacheWrite: 12.50 },
|
|
32
|
+
'opus': { input: 5.00, output: 25.00, cacheRead: 0.50, cacheWrite: 6.25 },
|
|
26
33
|
'sonnet': { input: 3.00, output: 15.00, cacheRead: 0.30, cacheWrite: 3.75 },
|
|
27
34
|
'haiku': { input: 1.00, output: 5.00, cacheRead: 0.10, cacheWrite: 1.25 },
|
|
28
35
|
};
|
|
29
36
|
|
|
30
37
|
const DEFAULT = PRICING.sonnet;
|
|
31
38
|
|
|
32
|
-
const TIERS = new Set(['opus', 'sonnet', 'haiku']);
|
|
39
|
+
const TIERS = new Set(['opus', 'sonnet', 'haiku', 'fable']);
|
|
33
40
|
|
|
34
41
|
// Map a model id like "claude-opus-4-7-20251101" to a pricing key.
|
|
35
42
|
//
|
package/src/scanner.js
CHANGED
|
@@ -208,11 +208,8 @@ function readHead(path, maxBytes = 65536) {
|
|
|
208
208
|
}
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const raw = readFileSync(filePath, 'utf8');
|
|
214
|
-
const lines = iterLines(raw);
|
|
215
|
-
const summary = {
|
|
211
|
+
function blankTranscriptSummary() {
|
|
212
|
+
return {
|
|
216
213
|
sessionId: null,
|
|
217
214
|
project: null,
|
|
218
215
|
cwd: null,
|
|
@@ -244,18 +241,31 @@ export function parseTranscript(filePath) {
|
|
|
244
241
|
modelsUsed: {}, // raw model id → assistant turns
|
|
245
242
|
byModel: {}, // pricing key → { turns, tokens, cost } (model split)
|
|
246
243
|
};
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Sliding window for assistant message-id dedup (see parseChunkInto). Blocks
|
|
247
|
+
// of one message land on ADJACENT lines, so a small window catches every real
|
|
248
|
+
// split while staying cheap to persist in the scan cache.
|
|
249
|
+
const RECENT_IDS_MAX = 200;
|
|
250
|
+
|
|
251
|
+
// Parse complete JSONL lines into `summary`, mutating it in place. `pstate`
|
|
252
|
+
// carries the cross-chunk bookkeeping that incremental (append-only) parsing
|
|
253
|
+
// needs to behave exactly like a full parse:
|
|
254
|
+
// recentIds — recently counted assistant message.ids. Claude Code splits
|
|
255
|
+
// one assistant message (a single message.id) across several
|
|
256
|
+
// JSONL lines — one per content block — repeating the SAME
|
|
257
|
+
// `usage` object on every line. Token/cost/turn counting must
|
|
258
|
+
// happen once per message.id, or a 3-block turn counts 3×.
|
|
259
|
+
// Content blocks themselves are distinct per line, so those
|
|
260
|
+
// stay counted per line.
|
|
261
|
+
// lastRec — the previous chunk's final timestamped record, so the
|
|
262
|
+
// active-time gap across a chunk boundary still accrues.
|
|
263
|
+
function parseChunkInto(text, summary, pstate) {
|
|
264
|
+
const fileSet = new Set(summary.files || []);
|
|
255
265
|
// Records in their original order, retaining timestamps for per-day bucketing.
|
|
256
266
|
const records = [];
|
|
257
267
|
|
|
258
|
-
for (const line of
|
|
268
|
+
for (const line of iterLines(text)) {
|
|
259
269
|
if (!line) continue;
|
|
260
270
|
const r = safeJson(line);
|
|
261
271
|
if (!r) continue;
|
|
@@ -277,10 +287,13 @@ export function parseTranscript(filePath) {
|
|
|
277
287
|
const turnModel = r.message?.model || summary.model;
|
|
278
288
|
const u = r.message?.usage;
|
|
279
289
|
// Count usage/cost/turn only the first time we see this message.id (see
|
|
280
|
-
//
|
|
290
|
+
// the pstate.recentIds note above). No id (rare/legacy) → count it.
|
|
281
291
|
const msgId = r.message?.id;
|
|
282
|
-
const firstSeen = !msgId || !
|
|
283
|
-
if (msgId)
|
|
292
|
+
const firstSeen = !msgId || !pstate.recentIds.includes(msgId);
|
|
293
|
+
if (msgId && firstSeen) {
|
|
294
|
+
pstate.recentIds.push(msgId);
|
|
295
|
+
if (pstate.recentIds.length > RECENT_IDS_MAX) pstate.recentIds.shift();
|
|
296
|
+
}
|
|
284
297
|
// Per-model split bucket, keyed by pricing key so cost/tokens/turns align.
|
|
285
298
|
const mkey = turnModel ? pricingKeyFor(turnModel) : null;
|
|
286
299
|
const mb = mkey ? (summary.byModel[mkey] ||= { turns: 0, tokens: 0, cost: 0 }) : null;
|
|
@@ -373,24 +386,84 @@ export function parseTranscript(filePath) {
|
|
|
373
386
|
summary.files = Array.from(fileSet);
|
|
374
387
|
if (records.length) {
|
|
375
388
|
records.sort((a, b) => a.ts - b.ts);
|
|
376
|
-
summary.firstTs = records[0].ts;
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
if (
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
(summary.
|
|
389
|
+
if (!summary.firstTs || records[0].ts < summary.firstTs) summary.firstTs = records[0].ts;
|
|
390
|
+
const chunkLast = records[records.length - 1].ts;
|
|
391
|
+
if (!summary.lastTs || chunkLast > summary.lastTs) summary.lastTs = chunkLast;
|
|
392
|
+
// Charge each gap's active time to the day/week/hour of the earlier
|
|
393
|
+
// record. The first record's "earlier" is the previous chunk's last.
|
|
394
|
+
let prev = pstate.lastRec;
|
|
395
|
+
for (const rec of records) {
|
|
396
|
+
if (prev) {
|
|
397
|
+
const gap = rec.ts - prev.ts;
|
|
398
|
+
if (gap > 0 && gap < ACTIVE_GAP_CAP_MS) {
|
|
399
|
+
summary.activeMs += gap;
|
|
400
|
+
if (prev.day) (summary.byDay[prev.day] ||= blankDay()).activeMs += gap;
|
|
401
|
+
if (prev.week) (summary.byWeek[prev.week] ||= blankDay()).activeMs += gap;
|
|
402
|
+
if (prev.hour !== null && prev.hour !== undefined) {
|
|
403
|
+
(summary.byHour[prev.hour] ||= blankDay()).activeMs += gap;
|
|
404
|
+
}
|
|
389
405
|
}
|
|
390
406
|
}
|
|
407
|
+
prev = rec;
|
|
408
|
+
}
|
|
409
|
+
pstate.lastRec = prev;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Parse a single transcript JSONL into a per-file summary.
|
|
414
|
+
//
|
|
415
|
+
// With a prior cache entry (`prev`), parses only the bytes appended since the
|
|
416
|
+
// last scan — transcripts are append-only, and an active session's multi-MB
|
|
417
|
+
// file otherwise gets fully re-read every rescan tick. The entry carries
|
|
418
|
+
// `_offset` (bytes consumed through the last complete line) and `_parse`
|
|
419
|
+
// (the cross-chunk bookkeeping for parseChunkInto); anything that breaks the
|
|
420
|
+
// append assumption (file shrank, entry predates these fields, `_offset`
|
|
421
|
+
// null) falls back to a from-scratch parse.
|
|
422
|
+
export function parseTranscript(filePath, prev = null) {
|
|
423
|
+
const st = statSync(filePath);
|
|
424
|
+
const canAppend = !!(prev && prev._parse && typeof prev._offset === 'number'
|
|
425
|
+
&& st.size >= prev._offset);
|
|
426
|
+
const summary = canAppend ? structuredClone(prev) : blankTranscriptSummary();
|
|
427
|
+
const pstate = canAppend
|
|
428
|
+
? { recentIds: (prev._parse.recentIds || []).slice(), lastRec: prev._parse.lastRec || null }
|
|
429
|
+
: { recentIds: [], lastRec: null };
|
|
430
|
+
const startOffset = canAppend ? prev._offset : 0;
|
|
431
|
+
|
|
432
|
+
let text = '';
|
|
433
|
+
const len = st.size - startOffset;
|
|
434
|
+
if (len > 0) {
|
|
435
|
+
let fd;
|
|
436
|
+
try {
|
|
437
|
+
fd = openSync(filePath, 'r');
|
|
438
|
+
const buf = Buffer.allocUnsafe(len);
|
|
439
|
+
const n = readSync(fd, buf, 0, len, startOffset);
|
|
440
|
+
text = buf.toString('utf8', 0, n);
|
|
441
|
+
} finally {
|
|
442
|
+
if (fd !== undefined) {
|
|
443
|
+
try { closeSync(fd); } catch { /* already closed */ }
|
|
444
|
+
}
|
|
391
445
|
}
|
|
392
|
-
summary.activeMs = active;
|
|
393
446
|
}
|
|
447
|
+
|
|
448
|
+
// Consume through the last newline; \n is single-byte ASCII so the boundary
|
|
449
|
+
// is exact even with multi-byte content in the lines.
|
|
450
|
+
const lastNl = text.lastIndexOf('\n');
|
|
451
|
+
const complete = lastNl === -1 ? '' : text.slice(0, lastNl + 1);
|
|
452
|
+
const remainder = lastNl === -1 ? text : text.slice(lastNl + 1);
|
|
453
|
+
parseChunkInto(complete, summary, pstate);
|
|
454
|
+
let offset = startOffset + Buffer.byteLength(complete, 'utf8');
|
|
455
|
+
if (remainder.trim()) {
|
|
456
|
+
if (safeJson(remainder) !== null) {
|
|
457
|
+
// A complete final line that just isn't newline-terminated (fully
|
|
458
|
+
// written file). Count it, but mark the entry non-appendable — if more
|
|
459
|
+
// bytes ever land we can't tell whether they extend this line.
|
|
460
|
+
parseChunkInto(remainder, summary, pstate);
|
|
461
|
+
offset = null;
|
|
462
|
+
}
|
|
463
|
+
// else: a partial line mid-write — leave it for the next (append) read.
|
|
464
|
+
}
|
|
465
|
+
summary._offset = offset;
|
|
466
|
+
summary._parse = { recentIds: pstate.recentIds, lastRec: pstate.lastRec };
|
|
394
467
|
return summary;
|
|
395
468
|
}
|
|
396
469
|
|
|
@@ -426,12 +499,25 @@ function isSubagentPath(p) {
|
|
|
426
499
|
return /[\\/]subagents[\\/]/.test(p);
|
|
427
500
|
}
|
|
428
501
|
|
|
502
|
+
// The daemon runs for weeks; these per-transcript caches would otherwise grow
|
|
503
|
+
// one entry per file ever observed. LRU with a generous cap — the hot set is
|
|
504
|
+
// the handful of live sessions, so an eviction just costs one re-read.
|
|
505
|
+
const CACHE_MAX_ENTRIES = 512;
|
|
506
|
+
function lruTouch(map, key, value) {
|
|
507
|
+
if (map.has(key)) map.delete(key);
|
|
508
|
+
map.set(key, value);
|
|
509
|
+
if (map.size > CACHE_MAX_ENTRIES) map.delete(map.keys().next().value);
|
|
510
|
+
}
|
|
511
|
+
|
|
429
512
|
// Pull the real cwd from the head of a transcript so live sessions can show
|
|
430
513
|
// "my-app" instead of the slugified directory name.
|
|
431
514
|
const cwdCache = new Map(); // path → { mtime, cwd }
|
|
432
515
|
function readTranscriptCwd(path, mtimeMs) {
|
|
433
516
|
const cached = cwdCache.get(path);
|
|
434
|
-
if (cached && cached.mtime === mtimeMs)
|
|
517
|
+
if (cached && cached.mtime === mtimeMs) {
|
|
518
|
+
lruTouch(cwdCache, path, cached);
|
|
519
|
+
return cached.cwd;
|
|
520
|
+
}
|
|
435
521
|
let cwd = null;
|
|
436
522
|
try {
|
|
437
523
|
let seen = 0;
|
|
@@ -442,7 +528,7 @@ function readTranscriptCwd(path, mtimeMs) {
|
|
|
442
528
|
if (r?.cwd) { cwd = r.cwd; break; }
|
|
443
529
|
}
|
|
444
530
|
} catch { /* transcript head unreadable — cwd stays null, project name falls back to slug */ }
|
|
445
|
-
cwdCache
|
|
531
|
+
lruTouch(cwdCache, path, { mtime: mtimeMs, cwd });
|
|
446
532
|
return cwd;
|
|
447
533
|
}
|
|
448
534
|
|
|
@@ -480,7 +566,10 @@ export function readSessionTokens(path) {
|
|
|
480
566
|
let st;
|
|
481
567
|
try { st = statSync(path); } catch { return null; }
|
|
482
568
|
const cached = sessionTokenCache.get(path);
|
|
483
|
-
if (cached && cached.mtime === st.mtimeMs)
|
|
569
|
+
if (cached && cached.mtime === st.mtimeMs) {
|
|
570
|
+
lruTouch(sessionTokenCache, path, cached);
|
|
571
|
+
return cached.tokens;
|
|
572
|
+
}
|
|
484
573
|
|
|
485
574
|
// Transcripts are append-only JSONL. If the file only grew since the last
|
|
486
575
|
// read, parse just the appended tail from the cached byte offset instead of
|
|
@@ -519,7 +608,7 @@ export function readSessionTokens(path) {
|
|
|
519
608
|
}
|
|
520
609
|
}
|
|
521
610
|
|
|
522
|
-
sessionTokenCache
|
|
611
|
+
lruTouch(sessionTokenCache, path, { mtime: st.mtimeMs, size: st.size, offset: newOffset, tokens });
|
|
523
612
|
return tokens;
|
|
524
613
|
}
|
|
525
614
|
|
|
@@ -941,7 +1030,9 @@ export function scan({ projectsDir, projectsDirs, onProgress, force = false, ext
|
|
|
941
1030
|
continue;
|
|
942
1031
|
}
|
|
943
1032
|
try {
|
|
944
|
-
|
|
1033
|
+
// Hand the prior entry to parseTranscript so an active session's
|
|
1034
|
+
// growing transcript parses only its appended tail (force = from scratch).
|
|
1035
|
+
const summary = parseTranscript(fp, force ? null : cache.files[fp]);
|
|
945
1036
|
summary._sig = sig;
|
|
946
1037
|
summary.isSubagent = isSubagentPath(fp);
|
|
947
1038
|
cache.files[fp] = summary;
|
|
@@ -965,7 +1056,21 @@ export function scan({ projectsDir, projectsDirs, onProgress, force = false, ext
|
|
|
965
1056
|
removed += 1;
|
|
966
1057
|
}
|
|
967
1058
|
}
|
|
968
|
-
|
|
1059
|
+
const changed = scanned > 0 || removed > 0;
|
|
1060
|
+
if (changed) {
|
|
1061
|
+
writeCache(cache);
|
|
1062
|
+
} else {
|
|
1063
|
+
// Nothing parsed, nothing removed — the cache on disk is byte-identical,
|
|
1064
|
+
// so skip rewriting it (it can be tens of MB) AND skip the aggregate
|
|
1065
|
+
// recompute, UNLESS the local day has rolled since the aggregate was
|
|
1066
|
+
// generated: streak / daysSinceFirst / hotspot-aging are derived from
|
|
1067
|
+
// "today" and go stale at midnight even with no new data.
|
|
1068
|
+
const existing = readAggregate();
|
|
1069
|
+
if (existing && existing._v === CACHE_VERSION
|
|
1070
|
+
&& dayKey(existing.generatedAt || 0) === dayKey(Date.now())) {
|
|
1071
|
+
return { aggregate: existing, scanned, skipped, removed, total: transcripts.length, dirs };
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
969
1074
|
const aggregate = aggregateFrom(cache);
|
|
970
1075
|
writeAggregate(aggregate);
|
|
971
1076
|
return { aggregate, scanned, skipped, removed, total: transcripts.length, dirs };
|
package/src/server/index.js
CHANGED
|
@@ -30,6 +30,10 @@ function parseUrl(rawUrl) {
|
|
|
30
30
|
return { path: url.pathname, query: Object.fromEntries(url.searchParams) };
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
function safeDecode(s) {
|
|
34
|
+
try { return decodeURIComponent(s); } catch { return null; }
|
|
35
|
+
}
|
|
36
|
+
|
|
33
37
|
// Loopback-only Host allowlist. Binding to 127.0.0.1 blocks the LAN, but not
|
|
34
38
|
// DNS rebinding: a malicious page can point its own hostname at 127.0.0.1 and
|
|
35
39
|
// become "same-origin" with this server, then read /api/export.json. Rejecting
|
|
@@ -64,7 +68,8 @@ const server = createServer((req, res) => {
|
|
|
64
68
|
// Project drilldown. Path-prefix dispatch (the project name is in the
|
|
65
69
|
// URL itself, not in a query string).
|
|
66
70
|
if (req.method === 'GET' && path.startsWith('/api/project/')) {
|
|
67
|
-
const name =
|
|
71
|
+
const name = safeDecode(path.slice('/api/project/'.length));
|
|
72
|
+
if (name === null) { res.writeHead(400, JSON_HEADERS).end(JSON.stringify({ error: 'bad request' })); return; }
|
|
68
73
|
const result = projectDrilldown(name);
|
|
69
74
|
res.writeHead(result ? 200 : 404, JSON_HEADERS);
|
|
70
75
|
res.end(JSON.stringify(result || { error: 'not found' }));
|
|
@@ -73,7 +78,8 @@ const server = createServer((req, res) => {
|
|
|
73
78
|
|
|
74
79
|
// Day detail. Same pattern — day key in the URL path.
|
|
75
80
|
if (req.method === 'GET' && path.startsWith('/api/day/')) {
|
|
76
|
-
const day =
|
|
81
|
+
const day = safeDecode(path.slice('/api/day/'.length));
|
|
82
|
+
if (day === null) { res.writeHead(400, JSON_HEADERS).end(JSON.stringify({ error: 'bad request' })); return; }
|
|
77
83
|
const result = dayDetail(day);
|
|
78
84
|
res.writeHead(result ? 200 : 404, JSON_HEADERS);
|
|
79
85
|
res.end(JSON.stringify(result || { error: 'not found' }));
|
package/src/server/sse.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// shared client set.
|
|
5
5
|
|
|
6
6
|
import { watch } from 'node:fs';
|
|
7
|
+
import { dirname, basename } from 'node:path';
|
|
7
8
|
import { STATE_PATH, AGGREGATE_PATH } from '../paths.js';
|
|
8
9
|
|
|
9
10
|
export const sseClients = new Set();
|
|
@@ -15,18 +16,31 @@ export function broadcast(payload) {
|
|
|
15
16
|
}
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
// Watch a file that is updated via atomic rename (write-tmp + renameSync).
|
|
20
|
+
// Watching the file path directly binds to the inode at watch-time — on
|
|
21
|
+
// Linux that inode is replaced by the first rename, so the watcher fires
|
|
22
|
+
// once then goes permanently silent. Watching the parent directory avoids
|
|
23
|
+
// this: directory inodes are stable across entry renames.
|
|
24
|
+
export function watchFile(filePath, callback) {
|
|
25
|
+
const dir = dirname(filePath);
|
|
26
|
+
const name = basename(filePath);
|
|
20
27
|
try {
|
|
21
|
-
watch(
|
|
22
|
-
|
|
23
|
-
stTimer = setTimeout(() => broadcast({ type: 'state' }), 200);
|
|
28
|
+
return watch(dir, (_, filename) => {
|
|
29
|
+
if (!filename || filename === name) callback();
|
|
24
30
|
});
|
|
25
|
-
} catch {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function watchSources() {
|
|
37
|
+
let stTimer = null, agTimer = null;
|
|
38
|
+
watchFile(STATE_PATH, () => {
|
|
39
|
+
clearTimeout(stTimer);
|
|
40
|
+
stTimer = setTimeout(() => broadcast({ type: 'state' }), 200);
|
|
41
|
+
});
|
|
42
|
+
watchFile(AGGREGATE_PATH, () => {
|
|
43
|
+
clearTimeout(agTimer);
|
|
44
|
+
agTimer = setTimeout(() => broadcast({ type: 'aggregate' }), 200);
|
|
45
|
+
});
|
|
32
46
|
}
|
package/src/ui.js
CHANGED
|
@@ -72,6 +72,16 @@ export function fail(label, { hint = 'run `claude-rpc doctor` for a full diagnos
|
|
|
72
72
|
process.exit(code);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// Return the last n lines of a log file's raw text, trimming the trailing
|
|
76
|
+
// empty element that split('\n') produces when the file ends with a newline.
|
|
77
|
+
// When the file lacks a trailing newline the last element is the last real
|
|
78
|
+
// line — the old raw.slice(-31,-1) pattern silently dropped it.
|
|
79
|
+
export function tailLines(raw, n = 30) {
|
|
80
|
+
const lines = raw.split('\n');
|
|
81
|
+
if (lines.at(-1) === '') lines.pop();
|
|
82
|
+
return lines.slice(-n);
|
|
83
|
+
}
|
|
84
|
+
|
|
75
85
|
// Compatibility with doctor.js's existing API. Same `check(label, status,
|
|
76
86
|
// detail, hint)` signature; doctor.js can switch its private copy out for
|
|
77
87
|
// this without behavior change.
|