claude-rpc 0.17.2 → 0.19.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/package.json +1 -1
- package/src/badge.js +1 -16
- package/src/card.js +5 -17
- package/src/cli.js +11 -25
- package/src/daemon.js +43 -7
- package/src/discord-ipc.js +12 -5
- package/src/doctor.js +29 -19
- package/src/fmt.js +33 -0
- package/src/format.js +8 -21
- package/src/hook.js +16 -8
- package/src/insights.js +1 -15
- package/src/install.js +40 -25
- package/src/leaderboard.js +5 -1
- package/src/mcp.js +15 -1
- package/src/notify.js +4 -4
- package/src/presence.js +28 -0
- package/src/privacy.js +29 -13
- package/src/profile.js +1 -16
- package/src/scanner.js +29 -17
- package/src/server/api.js +3 -3
- package/src/server/assets/dashboard.client.js +32 -9
- package/src/server/index.js +3 -3
- package/src/server/sse.js +27 -0
- package/src/state.js +95 -27
- package/src/tui.js +5 -19
- package/src/usage.js +6 -2
- package/src/version.js +1 -1
- package/src/week.js +33 -0
package/src/state.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from 'node:fs';
|
|
14
14
|
import { basename, join } from 'node:path';
|
|
15
15
|
import { STATE_PATH, STATE_DIR } from './paths.js';
|
|
16
|
+
import { pickActiveSession } from './presence.js';
|
|
16
17
|
|
|
17
18
|
const DEFAULT_STATE = {
|
|
18
19
|
sessionStart: null,
|
|
@@ -68,8 +69,8 @@ function ensureDir() {
|
|
|
68
69
|
// serialize through an exclusive lock file. The lock is strictly best-effort:
|
|
69
70
|
// if we can't get it within LOCK_MAX_WAIT_MS we proceed anyway (a slightly racy
|
|
70
71
|
// write is better than a dropped hook), and a stale lock from a crashed process
|
|
71
|
-
// is reclaimed after LOCK_STALE_MS.
|
|
72
|
-
|
|
72
|
+
// is reclaimed after LOCK_STALE_MS. The lock path is per state file (passed in)
|
|
73
|
+
// so per-session writers don't serialize against each other.
|
|
73
74
|
const LOCK_STALE_MS = 2000;
|
|
74
75
|
const LOCK_RETRY_MS = 4;
|
|
75
76
|
const LOCK_MAX_WAIT_MS = 1000;
|
|
@@ -85,18 +86,18 @@ function sleepSync(ms) {
|
|
|
85
86
|
}
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
function acquireLock() {
|
|
89
|
+
function acquireLock(lockPath) {
|
|
89
90
|
const deadline = Date.now() + LOCK_MAX_WAIT_MS;
|
|
90
91
|
for (;;) {
|
|
91
92
|
try {
|
|
92
93
|
// 'wx' fails if the file exists — that's our mutex.
|
|
93
|
-
return openSync(
|
|
94
|
+
return openSync(lockPath, 'wx');
|
|
94
95
|
} catch (err) {
|
|
95
96
|
if (err.code !== 'EEXIST') return null; // unexpected (perms, etc.) — go lockless
|
|
96
97
|
try {
|
|
97
|
-
if (Date.now() - statSync(
|
|
98
|
+
if (Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS) {
|
|
98
99
|
try {
|
|
99
|
-
unlinkSync(
|
|
100
|
+
unlinkSync(lockPath);
|
|
100
101
|
} catch {
|
|
101
102
|
/* someone else reclaimed it first */
|
|
102
103
|
}
|
|
@@ -111,7 +112,7 @@ function acquireLock() {
|
|
|
111
112
|
}
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
function releaseLock(fd) {
|
|
115
|
+
function releaseLock(fd, lockPath) {
|
|
115
116
|
if (fd === null) return;
|
|
116
117
|
// Only unlink the lock if the path still points at OUR lock file. If this
|
|
117
118
|
// process somehow held it past LOCK_STALE_MS, a sibling has reclaimed the
|
|
@@ -120,7 +121,7 @@ function releaseLock(fd) {
|
|
|
120
121
|
let ours;
|
|
121
122
|
try {
|
|
122
123
|
const a = fstatSync(fd);
|
|
123
|
-
const b = statSync(
|
|
124
|
+
const b = statSync(lockPath);
|
|
124
125
|
ours = a.ino === b.ino && a.dev === b.dev;
|
|
125
126
|
} catch {
|
|
126
127
|
ours = false; // lock already gone — nothing to unlink
|
|
@@ -132,40 +133,64 @@ function releaseLock(fd) {
|
|
|
132
133
|
}
|
|
133
134
|
if (!ours) return;
|
|
134
135
|
try {
|
|
135
|
-
unlinkSync(
|
|
136
|
+
unlinkSync(lockPath);
|
|
136
137
|
} catch {
|
|
137
138
|
/* already removed */
|
|
138
139
|
}
|
|
139
140
|
}
|
|
140
141
|
|
|
141
|
-
function withLock(fn) {
|
|
142
|
-
const fd = acquireLock();
|
|
142
|
+
function withLock(lockPath, fn) {
|
|
143
|
+
const fd = acquireLock(lockPath);
|
|
143
144
|
try {
|
|
144
145
|
return fn();
|
|
145
146
|
} finally {
|
|
146
|
-
releaseLock(fd);
|
|
147
|
+
releaseLock(fd, lockPath);
|
|
147
148
|
}
|
|
148
149
|
}
|
|
149
150
|
|
|
150
|
-
|
|
151
|
+
// Resolve the state file for a session. No id → the legacy global state.json
|
|
152
|
+
// (single-session and back-compat). With an id → a per-session file, so
|
|
153
|
+
// concurrent sessions stop clobbering each other's status/tools/tokens and the
|
|
154
|
+
// daemon can pick which one to show. The id is a Claude Code session UUID;
|
|
155
|
+
// sanitize defensively for the filename regardless.
|
|
156
|
+
export function statePathFor(sessionId) {
|
|
157
|
+
if (!sessionId) return STATE_PATH;
|
|
158
|
+
const safe = String(sessionId).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64) || 'unknown';
|
|
159
|
+
return join(STATE_DIR, `state-${safe}.json`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function readState(sessionId) {
|
|
151
163
|
ensureDir();
|
|
152
|
-
|
|
164
|
+
const path = statePathFor(sessionId);
|
|
165
|
+
if (!existsSync(path)) return { ...DEFAULT_STATE };
|
|
153
166
|
try {
|
|
154
|
-
const raw = readFileSync(
|
|
167
|
+
const raw = readFileSync(path, 'utf8');
|
|
155
168
|
return { ...DEFAULT_STATE, ...JSON.parse(raw) };
|
|
156
169
|
} catch {
|
|
157
170
|
return { ...DEFAULT_STATE };
|
|
158
171
|
}
|
|
159
172
|
}
|
|
160
173
|
|
|
161
|
-
|
|
174
|
+
// The session a one-shot reader (status / serve / tui) should display. The
|
|
175
|
+
// daemon resolves per-session state with cross-tick stickiness; one-shot readers
|
|
176
|
+
// have no cursor, so they pass displayedId=null and get the most-recently-active
|
|
177
|
+
// session — exactly what the daemon settles on. Falls back to the legacy global
|
|
178
|
+
// state.json when no per-session files exist (e.g. a hook payload without an id).
|
|
179
|
+
export function readActiveState({ now = Date.now(), idleMs = 60_000 } = {}) {
|
|
180
|
+
const sessions = listSessionStates();
|
|
181
|
+
if (!sessions.length) return readState();
|
|
182
|
+
return pickActiveSession(sessions, null, now, idleMs).state || readState();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function writeState(next, sessionId) {
|
|
162
186
|
ensureDir();
|
|
163
|
-
|
|
164
|
-
//
|
|
187
|
+
const path = statePathFor(sessionId);
|
|
188
|
+
// Per-process tmp name: two processes writing the same <path>.tmp would
|
|
189
|
+
// clobber each other's tmp before rename. The pid suffix keeps the
|
|
165
190
|
// atomic-rename guarantee intact even on the best-effort lockless path.
|
|
166
|
-
const tmp = `${
|
|
191
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
167
192
|
writeFileSync(tmp, JSON.stringify(next, null, 2));
|
|
168
|
-
renameSync(tmp,
|
|
193
|
+
renameSync(tmp, path);
|
|
169
194
|
}
|
|
170
195
|
|
|
171
196
|
// Best-effort sweep of orphaned per-pid tmp files (`state.json.<pid>.tmp`) left
|
|
@@ -184,19 +209,62 @@ export function sweepStaleStateTmp(now = Date.now()) {
|
|
|
184
209
|
} catch { /* STATE_DIR missing / unreadable — nothing to sweep */ }
|
|
185
210
|
}
|
|
186
211
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
212
|
+
// All per-session states currently on disk, each tagged with its sessionId
|
|
213
|
+
// (recovered from the `state-<id>.json` filename). The daemon uses this to pick
|
|
214
|
+
// which session to show. Excludes the legacy global state.json (no `-<id>`),
|
|
215
|
+
// and tmp/lock siblings (they don't end in `.json`).
|
|
216
|
+
export function listSessionStates() {
|
|
217
|
+
ensureDir();
|
|
218
|
+
const out = [];
|
|
219
|
+
let names;
|
|
220
|
+
try { names = readdirSync(STATE_DIR); } catch { return out; }
|
|
221
|
+
for (const name of names) {
|
|
222
|
+
const m = /^state-(.+)\.json$/.exec(name);
|
|
223
|
+
if (!m) continue;
|
|
224
|
+
try {
|
|
225
|
+
const parsed = JSON.parse(readFileSync(join(STATE_DIR, name), 'utf8'));
|
|
226
|
+
out.push({ ...DEFAULT_STATE, ...parsed, sessionId: m[1] });
|
|
227
|
+
} catch { /* torn/broken file mid-write — skip this tick */ }
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Remove per-session state files whose last activity is older than maxAgeMs — a
|
|
233
|
+
// session that ended without cleanup, or a crashed one. Called periodically by
|
|
234
|
+
// the daemon; never touches the global state.json. Returns the count removed.
|
|
235
|
+
export function sweepStaleSessionStates(maxAgeMs = 6 * 60 * 60 * 1000, now = Date.now()) {
|
|
236
|
+
let removed = 0, names;
|
|
237
|
+
try { names = readdirSync(STATE_DIR); } catch { return 0; }
|
|
238
|
+
for (const name of names) {
|
|
239
|
+
if (!/^state-.+\.json$/.test(name)) continue;
|
|
240
|
+
const full = join(STATE_DIR, name);
|
|
241
|
+
try {
|
|
242
|
+
const last = JSON.parse(readFileSync(full, 'utf8')).lastActivity || 0;
|
|
243
|
+
if (now - last > maxAgeMs) { unlinkSync(full); removed++; }
|
|
244
|
+
} catch {
|
|
245
|
+
// Unparseable — age out by file mtime so a corrupt file still clears.
|
|
246
|
+
try { if (now - statSync(full).mtimeMs > maxAgeMs) { unlinkSync(full); removed++; } }
|
|
247
|
+
catch { /* gone */ }
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return removed;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function updateState(mutator, sessionId) {
|
|
254
|
+
const lockPath = statePathFor(sessionId) + '.lock';
|
|
255
|
+
return withLock(lockPath, () => {
|
|
256
|
+
const current = readState(sessionId);
|
|
190
257
|
const next = mutator({ ...current }) ?? current;
|
|
191
|
-
writeState(next);
|
|
258
|
+
writeState(next, sessionId);
|
|
192
259
|
return next;
|
|
193
260
|
});
|
|
194
261
|
}
|
|
195
262
|
|
|
196
|
-
export function resetState(seed = {}) {
|
|
197
|
-
|
|
263
|
+
export function resetState(seed = {}, sessionId) {
|
|
264
|
+
const lockPath = statePathFor(sessionId) + '.lock';
|
|
265
|
+
return withLock(lockPath, () => {
|
|
198
266
|
const fresh = { ...DEFAULT_STATE, sessionStart: Date.now(), lastActivity: Date.now(), ...seed };
|
|
199
|
-
writeState(fresh);
|
|
267
|
+
writeState(fresh, sessionId);
|
|
200
268
|
return fresh;
|
|
201
269
|
});
|
|
202
270
|
}
|
package/src/tui.js
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
import process from 'node:process';
|
|
8
8
|
import { readFileSync, existsSync } from 'node:fs';
|
|
9
|
-
import {
|
|
10
|
-
import { readAggregate, findLiveSessions,
|
|
9
|
+
import { readActiveState } from './state.js';
|
|
10
|
+
import { readAggregate, findLiveSessions, weekKey } from './scanner.js';
|
|
11
|
+
import { weekGrid } from './week.js';
|
|
11
12
|
import { buildVars, applyIdle, humanProject } from './format.js';
|
|
12
13
|
import { loadConfig } from './config.js';
|
|
13
14
|
import { PID_PATH } from './paths.js';
|
|
@@ -51,7 +52,7 @@ let exiting = false;
|
|
|
51
52
|
|
|
52
53
|
// ── Data ────────────────────────────────────────────────────────────────────
|
|
53
54
|
function loadSnapshot() {
|
|
54
|
-
let state =
|
|
55
|
+
let state = readActiveState();
|
|
55
56
|
state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
|
|
56
57
|
const config = loadConfig();
|
|
57
58
|
state = applyIdle(state, config);
|
|
@@ -186,22 +187,7 @@ function tabWeek(_, data) {
|
|
|
186
187
|
if (agg.byDay) {
|
|
187
188
|
out.push('');
|
|
188
189
|
out.push(`${C.dim}daily breakdown${C.reset}`);
|
|
189
|
-
const
|
|
190
|
-
now.setHours(0, 0, 0, 0);
|
|
191
|
-
const monday = new Date(now);
|
|
192
|
-
monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
|
|
193
|
-
const days = [];
|
|
194
|
-
for (let i = 0; i < 7; i++) {
|
|
195
|
-
const d = new Date(monday);
|
|
196
|
-
d.setDate(d.getDate() + i);
|
|
197
|
-
const k = dayKey(d.getTime());
|
|
198
|
-
const dayName = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()];
|
|
199
|
-
const ms = agg.byDay[k]?.activeMs || 0;
|
|
200
|
-
const isFuture = d > now;
|
|
201
|
-
const isToday = k === dayKey(now.getTime());
|
|
202
|
-
days.push({ label: `${dayName} ${k.slice(5)}`, ms, isFuture, isToday });
|
|
203
|
-
}
|
|
204
|
-
const maxMs = Math.max(...days.map((d) => d.ms)) || 1;
|
|
190
|
+
const { days, maxMs } = weekGrid(agg.byDay);
|
|
205
191
|
for (const { label, ms, isFuture, isToday } of days) {
|
|
206
192
|
if (isFuture) {
|
|
207
193
|
out.push(`${C.dim}${label.padEnd(11)}${' '.repeat(18)} —${C.reset}`);
|
package/src/usage.js
CHANGED
|
@@ -75,7 +75,7 @@ export function normalizeUsage(json) {
|
|
|
75
75
|
|
|
76
76
|
export async function fetchUsage({ fetchImpl = globalThis.fetch, creds = readClaudeCredentials(), now = Date.now() } = {}) {
|
|
77
77
|
if (!creds?.accessToken) return { ok: false, reason: 'no-credentials' };
|
|
78
|
-
if (creds.expiresAt && now > creds.expiresAt) return { ok: false, reason: 'token-expired' };
|
|
78
|
+
if (creds.expiresAt && now > creds.expiresAt - 30_000) return { ok: false, reason: 'token-expired' }; // 30s skew margin — skip a request we know will 401
|
|
79
79
|
let res;
|
|
80
80
|
try {
|
|
81
81
|
res = await fetchImpl(USAGE_ENDPOINT, {
|
|
@@ -112,7 +112,11 @@ export function writeUsageCache(usage, path = USAGE_CACHE_PATH) {
|
|
|
112
112
|
export function readUsageCache({ path = USAGE_CACHE_PATH, maxAgeMs = USAGE_STALE_MS, now = Date.now() } = {}) {
|
|
113
113
|
try {
|
|
114
114
|
const u = JSON.parse(readFileSync(path, 'utf8'));
|
|
115
|
-
if (!u?.fetchedAt
|
|
115
|
+
if (!u?.fetchedAt) return null;
|
|
116
|
+
const age = now - u.fetchedAt;
|
|
117
|
+
// Stale, or future-dated (corrupt write / clock skew) — a future fetchedAt
|
|
118
|
+
// would otherwise read as fresh forever.
|
|
119
|
+
if (age > maxAgeMs || age < -60_000) return null;
|
|
116
120
|
return u;
|
|
117
121
|
} catch { return null; }
|
|
118
122
|
}
|
package/src/version.js
CHANGED
package/src/week.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Shared Monday-anchored current-week grid. cli.js (showWeek) and tui.js
|
|
2
|
+
// (tabWeek) render it with different ANSI widths but compute the same seven
|
|
3
|
+
// days, future/today flags, and peak — extracted here so the date math can't
|
|
4
|
+
// drift between the two and is unit-testable against a fixed clock.
|
|
5
|
+
|
|
6
|
+
import { dayKey } from './scanner.js';
|
|
7
|
+
|
|
8
|
+
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
9
|
+
|
|
10
|
+
// Returns { days: [{ label, ms, isFuture, isToday }], maxMs } for the ISO week
|
|
11
|
+
// (Mon -> Sun) containing `now`. `label` is e.g. "Wed 06-17"; future days have
|
|
12
|
+
// ms 0 and isFuture true. maxMs is floored at 1 so callers divide safely.
|
|
13
|
+
export function weekGrid(byDay = {}, now = new Date()) {
|
|
14
|
+
const today = new Date(now);
|
|
15
|
+
today.setHours(0, 0, 0, 0);
|
|
16
|
+
const monday = new Date(today);
|
|
17
|
+
monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
|
|
18
|
+
const todayKey = dayKey(today.getTime());
|
|
19
|
+
const days = [];
|
|
20
|
+
for (let i = 0; i < 7; i++) {
|
|
21
|
+
const d = new Date(monday);
|
|
22
|
+
d.setDate(d.getDate() + i);
|
|
23
|
+
const k = dayKey(d.getTime());
|
|
24
|
+
days.push({
|
|
25
|
+
label: `${DAY_NAMES[d.getDay()]} ${k.slice(5)}`,
|
|
26
|
+
ms: byDay?.[k]?.activeMs || 0,
|
|
27
|
+
isFuture: d > today,
|
|
28
|
+
isToday: k === todayKey,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const maxMs = Math.max(...days.map((x) => x.ms)) || 1;
|
|
32
|
+
return { days, maxMs };
|
|
33
|
+
}
|