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/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
- const LOCK_PATH = STATE_PATH + '.lock';
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(LOCK_PATH, 'wx');
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(LOCK_PATH).mtimeMs > LOCK_STALE_MS) {
98
+ if (Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS) {
98
99
  try {
99
- unlinkSync(LOCK_PATH);
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(LOCK_PATH);
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(LOCK_PATH);
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
- export function readState() {
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
- if (!existsSync(STATE_PATH)) return { ...DEFAULT_STATE };
164
+ const path = statePathFor(sessionId);
165
+ if (!existsSync(path)) return { ...DEFAULT_STATE };
153
166
  try {
154
- const raw = readFileSync(STATE_PATH, 'utf8');
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
- export function writeState(next) {
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
- // Per-process tmp name: two processes writing the shared STATE_PATH + '.tmp'
164
- // would clobber each other's tmp before rename. The pid suffix keeps the
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 = `${STATE_PATH}.${process.pid}.tmp`;
191
+ const tmp = `${path}.${process.pid}.tmp`;
167
192
  writeFileSync(tmp, JSON.stringify(next, null, 2));
168
- renameSync(tmp, STATE_PATH);
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
- export function updateState(mutator) {
188
- return withLock(() => {
189
- const current = readState();
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
- return withLock(() => {
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 { readState } from './state.js';
10
- import { readAggregate, findLiveSessions, dayKey, weekKey } from './scanner.js';
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 = readState();
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 now = new Date();
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 || now - u.fetchedAt > maxAgeMs) return null;
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
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { ROOT } from './paths.js';
13
13
 
14
- const BAKED = '0.17.2';
14
+ const BAKED = '0.19.1';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {
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
+ }