claude-rpc 0.17.1 → 0.18.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/package.json +1 -1
- package/src/daemon.js +34 -5
- package/src/format.js +6 -1
- package/src/hook.js +16 -8
- package/src/presence.js +28 -0
- package/src/state.js +83 -27
- package/src/version.js +1 -1
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import { writeFileSync, readFileSync, existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
|
|
3
3
|
import { basename, dirname } from 'node:path';
|
|
4
4
|
import { Client } from './discord-ipc.js';
|
|
5
|
-
import { readState, sweepStaleStateTmp } from './state.js';
|
|
6
|
-
import { makeRotationCursor, pickFrames, selectFrame, resolveLargeImageKey, shouldShowGithubButton } from './presence.js';
|
|
5
|
+
import { readState, sweepStaleStateTmp, listSessionStates, sweepStaleSessionStates } from './state.js';
|
|
6
|
+
import { makeRotationCursor, pickFrames, selectFrame, resolveLargeImageKey, shouldShowGithubButton, pickActiveSession } from './presence.js';
|
|
7
7
|
import { buildVars, fillTemplate, framePasses, applyIdle, applyShipped, applyTrigger } from './format.js';
|
|
8
8
|
import { scan, readAggregate, findLiveSessions, readSessionTokens } from './scanner.js';
|
|
9
9
|
import { detectGithubUrl } from './git.js';
|
|
@@ -96,6 +96,9 @@ let reconnectDelayMs = RECONNECT_BASE_MS;
|
|
|
96
96
|
// 12-frame rotation into a single-frame working state and back, producing a
|
|
97
97
|
// jarring "blank tick" until modulo aligns.
|
|
98
98
|
const rotationCursor = makeRotationCursor();
|
|
99
|
+
// Which concurrent session's card we're currently showing. pickActiveSession
|
|
100
|
+
// keeps it sticky — see resolvePresence.
|
|
101
|
+
let displayedSessionId = null;
|
|
99
102
|
// Stabilizes Discord's elapsed timer: applyIdle can synthesize a sessionStart
|
|
100
103
|
// from a moving transcript mtime, and missing-hook scenarios leave it null —
|
|
101
104
|
// either case would make startTimestamp jump on every rotation.
|
|
@@ -121,8 +124,10 @@ try {
|
|
|
121
124
|
} catch { /* unreadable PID file — fall through and claim it */ }
|
|
122
125
|
writeFileSync(PID_PATH, String(process.pid));
|
|
123
126
|
|
|
124
|
-
// Reclaim any per-pid state tmp files orphaned by a hard-killed writer
|
|
127
|
+
// Reclaim any per-pid state tmp files orphaned by a hard-killed writer, plus
|
|
128
|
+
// per-session state files from sessions that ended long ago.
|
|
125
129
|
sweepStaleStateTmp();
|
|
130
|
+
sweepStaleSessionStates();
|
|
126
131
|
|
|
127
132
|
// pickFrames / selectFrame / resolveLargeImageKey now live in presence.js (pure
|
|
128
133
|
// + unit-tested). The rotation cursor (rotationCursor) is owned here and passed
|
|
@@ -135,7 +140,29 @@ sweepStaleStateTmp();
|
|
|
135
140
|
// previously this chain ran twice and only buildActivity applied the trigger
|
|
136
141
|
// overlay, so the two could diverge.
|
|
137
142
|
function resolvePresence(opts = {}) {
|
|
138
|
-
let state
|
|
143
|
+
let state;
|
|
144
|
+
if (opts.state) {
|
|
145
|
+
state = opts.state; // standalone caller (preview/api) passes an explicit state
|
|
146
|
+
} else {
|
|
147
|
+
// Multi-session: each session writes its own state-<id>.json. Pick which one
|
|
148
|
+
// to show, sticking with the current session while it's active so the card
|
|
149
|
+
// doesn't thrash between projects (which also stabilizes the elapsed timer
|
|
150
|
+
// and the GitHub button — both follow the displayed session). Fall back to
|
|
151
|
+
// the legacy global state.json when there are no per-session files.
|
|
152
|
+
const sessions = listSessionStates();
|
|
153
|
+
if (sessions.length) {
|
|
154
|
+
const idleMs = Math.max(30_000, (config.idleThresholdSec || 60) * 1000);
|
|
155
|
+
const picked = pickActiveSession(sessions, displayedSessionId, Date.now(), idleMs);
|
|
156
|
+
displayedSessionId = picked.sessionId;
|
|
157
|
+
state = picked.state || readState();
|
|
158
|
+
// Party count from the SAME per-session list we selected from, so the
|
|
159
|
+
// "N sessions" field stays consistent with the card instead of wobbling
|
|
160
|
+
// on transcript-mtime timing.
|
|
161
|
+
state._liveCount = picked.liveCount;
|
|
162
|
+
} else {
|
|
163
|
+
state = readState();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
139
166
|
// Attach live sessions BEFORE applyIdle so the stale/idle decision can
|
|
140
167
|
// see ongoing transcript activity, not just this daemon's hook state.
|
|
141
168
|
state.liveSessions = opts.liveSessions || liveSessions;
|
|
@@ -257,7 +284,9 @@ function buildActivity(opts = {}) {
|
|
|
257
284
|
// Concurrent sessions render natively via Discord's party field — the card
|
|
258
285
|
// shows "(2 of 2)" with no template work. Only attached when more than one
|
|
259
286
|
// live session exists (a party of one is noise). Opt out: showPartySize:false.
|
|
260
|
-
|
|
287
|
+
// Prefer the per-session count (consistent with the displayed session); fall
|
|
288
|
+
// back to the transcript-derived count for the legacy single-state path.
|
|
289
|
+
const liveCount = state._liveCount != null ? state._liveCount : (state.liveSessions || []).length;
|
|
261
290
|
if (config.showPartySize !== false && liveCount > 1) {
|
|
262
291
|
activity.partyId = 'claude-rpc';
|
|
263
292
|
activity.partySize = liveCount;
|
package/src/format.js
CHANGED
|
@@ -448,11 +448,16 @@ export function buildVars(state, config, aggregate) {
|
|
|
448
448
|
const usageWeeklyPct = usage?.weeklyPct ?? '';
|
|
449
449
|
let usageStateLabel = '';
|
|
450
450
|
if (usage) {
|
|
451
|
+
// The usage rotation frame's DETAILS line already shows "{usageWeeklyPct}%
|
|
452
|
+
// weekly", so this state line must NOT repeat weekly% — it complements with
|
|
453
|
+
// session% + reset day. (A weekly-only fallback lives below so the line
|
|
454
|
+
// isn't empty when session% is absent and we'd otherwise show nothing.)
|
|
451
455
|
const bits = [];
|
|
452
456
|
if (usage.sessionPct != null) bits.push(`session ${usage.sessionPct}%`);
|
|
453
|
-
if (usage.weeklyPct != null) bits.push(`weekly ${usage.weeklyPct}%`);
|
|
454
457
|
const day = fmtResetDay(usage.weeklyResetsAt);
|
|
455
458
|
if (day) bits.push(`resets ${day}`);
|
|
459
|
+
// Only fall back to weekly% when the line would otherwise be empty.
|
|
460
|
+
if (!bits.length && usage.weeklyPct != null) bits.push(`weekly ${usage.weeklyPct}%`);
|
|
456
461
|
usageStateLabel = bits.join(' · ');
|
|
457
462
|
}
|
|
458
463
|
|
package/src/hook.js
CHANGED
|
@@ -125,9 +125,17 @@ function parseInput() {
|
|
|
125
125
|
// directly (avoids spawning a child process per hook).
|
|
126
126
|
export function processHookEvent(event, input = {}) {
|
|
127
127
|
const now = Date.now();
|
|
128
|
+
// Each session writes its OWN state file (state-<sessionId>.json) so concurrent
|
|
129
|
+
// sessions stop clobbering one shared state.json — that thrash made the card
|
|
130
|
+
// jump between projects, the timer reset, and counters over-count. Subagent
|
|
131
|
+
// hooks carry the parent's session_id, so their activity rolls up correctly.
|
|
132
|
+
// No id (legacy payload) → the global state.json, preserving old behavior.
|
|
133
|
+
const sid = input.session_id || input.sessionId || null;
|
|
134
|
+
const update = (fn) => updateState(fn, sid);
|
|
135
|
+
const reset = (seed) => resetState(seed, sid);
|
|
128
136
|
|
|
129
137
|
function setActivity(patch) {
|
|
130
|
-
|
|
138
|
+
update((s) => {
|
|
131
139
|
Object.assign(s, patch);
|
|
132
140
|
s.lastActivity = now;
|
|
133
141
|
// Any hook firing means Claude Code is alive — clear the closed flag
|
|
@@ -140,7 +148,7 @@ export function processHookEvent(event, input = {}) {
|
|
|
140
148
|
|
|
141
149
|
switch (event) {
|
|
142
150
|
case 'SessionStart': {
|
|
143
|
-
|
|
151
|
+
reset({
|
|
144
152
|
cwd: input.cwd || process.cwd(),
|
|
145
153
|
model: input.model?.id || input.model || 'claude',
|
|
146
154
|
status: 'idle',
|
|
@@ -148,7 +156,7 @@ export function processHookEvent(event, input = {}) {
|
|
|
148
156
|
break;
|
|
149
157
|
}
|
|
150
158
|
case 'UserPromptSubmit': {
|
|
151
|
-
|
|
159
|
+
update((s) => {
|
|
152
160
|
s.messages += 1;
|
|
153
161
|
s.lastUserPrompt = now;
|
|
154
162
|
s.lastActivity = now;
|
|
@@ -164,7 +172,7 @@ export function processHookEvent(event, input = {}) {
|
|
|
164
172
|
const toolName = input.tool_name || input.toolName || 'tool';
|
|
165
173
|
const toolInput = input.tool_input || input.toolInput || {};
|
|
166
174
|
const file = toolInput.file_path || toolInput.path || toolInput.notebook_path || null;
|
|
167
|
-
|
|
175
|
+
update((s) => {
|
|
168
176
|
s.tools += 1;
|
|
169
177
|
s.toolBreakdown[toolName] = (s.toolBreakdown[toolName] || 0) + 1;
|
|
170
178
|
s.currentTool = toolName;
|
|
@@ -211,7 +219,7 @@ export function processHookEvent(event, input = {}) {
|
|
|
211
219
|
}
|
|
212
220
|
}
|
|
213
221
|
}
|
|
214
|
-
|
|
222
|
+
update((s) => {
|
|
215
223
|
s.currentTool = null;
|
|
216
224
|
s.toolStartedAt = null;
|
|
217
225
|
s.lastActivity = now;
|
|
@@ -236,7 +244,7 @@ export function processHookEvent(event, input = {}) {
|
|
|
236
244
|
break;
|
|
237
245
|
}
|
|
238
246
|
case 'Notification': {
|
|
239
|
-
|
|
247
|
+
update((s) => {
|
|
240
248
|
s.status = 'notification';
|
|
241
249
|
s.lastNotification = now;
|
|
242
250
|
s.lastActivity = now;
|
|
@@ -254,7 +262,7 @@ export function processHookEvent(event, input = {}) {
|
|
|
254
262
|
// rewriting earlier context, not advancing a turn. Surface it as its
|
|
255
263
|
// own state so the card stops reading "Thinking…" for the 10-60s
|
|
256
264
|
// compactions can take on big sessions.
|
|
257
|
-
|
|
265
|
+
update((s) => {
|
|
258
266
|
s.status = 'compacting';
|
|
259
267
|
s.compactStartedAt = now;
|
|
260
268
|
s.compactTrigger = input.trigger || input.matcher || null;
|
|
@@ -276,7 +284,7 @@ export function processHookEvent(event, input = {}) {
|
|
|
276
284
|
// staleSessionMin timeout. applyIdle short-circuits to stale when it
|
|
277
285
|
// sees claudeClosed=true. Any subsequent hook from another live
|
|
278
286
|
// session will flip the flag back to false.
|
|
279
|
-
|
|
287
|
+
update((s) => {
|
|
280
288
|
s.status = 'stale';
|
|
281
289
|
s.claudeClosed = true;
|
|
282
290
|
s.currentTool = null;
|
package/src/presence.js
CHANGED
|
@@ -55,6 +55,34 @@ export function selectFrame(rawFrames, vars, status, cursor, intervalMs, framePa
|
|
|
55
55
|
return frames[cursor.index % frames.length] || {};
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// Pick which session's card to show when several Claude sessions run at once.
|
|
59
|
+
// Each session writes its own state-<id>.json (lastActivity stamped on every
|
|
60
|
+
// hook), so `states` is one entry per session. Behavior: STICK to the currently
|
|
61
|
+
// shown session while it's still active (lastActivity within idleMs), so the
|
|
62
|
+
// card doesn't thrash between sessions while you're working in one; only once
|
|
63
|
+
// the shown session goes idle do we switch to the most-recently-active session.
|
|
64
|
+
// Returns { state, sessionId, liveCount } — liveCount feeds the "N sessions"
|
|
65
|
+
// party field so it stays consistent with what's displayed.
|
|
66
|
+
export function pickActiveSession(states, displayedId, now, idleMs) {
|
|
67
|
+
const list = (states || []).filter((s) => s && s.sessionId);
|
|
68
|
+
if (!list.length) return { state: null, sessionId: null, liveCount: 0 };
|
|
69
|
+
// A just-ended session (SessionEnd → claudeClosed) stamps a recent
|
|
70
|
+
// lastActivity but is gone — never count it live or stick to it.
|
|
71
|
+
const isLive = (s) => !s.claudeClosed && now - (s.lastActivity || 0) <= idleMs;
|
|
72
|
+
const liveCount = list.filter(isLive).length;
|
|
73
|
+
const byRecent = [...list].sort((a, b) => (b.lastActivity || 0) - (a.lastActivity || 0));
|
|
74
|
+
// Stickiness: keep the shown session while it's still active.
|
|
75
|
+
const current = list.find((s) => s.sessionId === displayedId);
|
|
76
|
+
if (current && isLive(current)) {
|
|
77
|
+
return { state: current, sessionId: current.sessionId, liveCount };
|
|
78
|
+
}
|
|
79
|
+
// Otherwise show the most-recently-active live session — or, if none are live,
|
|
80
|
+
// the most-recent overall so the card follows the last session into idle/stale
|
|
81
|
+
// rather than blanking.
|
|
82
|
+
const chosen = byRecent.find(isLive) || byRecent[0];
|
|
83
|
+
return { state: chosen, sessionId: chosen.sessionId, liveCount };
|
|
84
|
+
}
|
|
85
|
+
|
|
58
86
|
// Should the daemon auto-add a "View on GitHub →" button for this cwd? The
|
|
59
87
|
// button URL is read from .git/config (no `gh` needed), but private-repo
|
|
60
88
|
// detection DOES need the gh CLI — so on a machine without gh a private repo
|
package/src/state.js
CHANGED
|
@@ -68,8 +68,8 @@ function ensureDir() {
|
|
|
68
68
|
// serialize through an exclusive lock file. The lock is strictly best-effort:
|
|
69
69
|
// if we can't get it within LOCK_MAX_WAIT_MS we proceed anyway (a slightly racy
|
|
70
70
|
// write is better than a dropped hook), and a stale lock from a crashed process
|
|
71
|
-
// is reclaimed after LOCK_STALE_MS.
|
|
72
|
-
|
|
71
|
+
// is reclaimed after LOCK_STALE_MS. The lock path is per state file (passed in)
|
|
72
|
+
// so per-session writers don't serialize against each other.
|
|
73
73
|
const LOCK_STALE_MS = 2000;
|
|
74
74
|
const LOCK_RETRY_MS = 4;
|
|
75
75
|
const LOCK_MAX_WAIT_MS = 1000;
|
|
@@ -85,18 +85,18 @@ function sleepSync(ms) {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
function acquireLock() {
|
|
88
|
+
function acquireLock(lockPath) {
|
|
89
89
|
const deadline = Date.now() + LOCK_MAX_WAIT_MS;
|
|
90
90
|
for (;;) {
|
|
91
91
|
try {
|
|
92
92
|
// 'wx' fails if the file exists — that's our mutex.
|
|
93
|
-
return openSync(
|
|
93
|
+
return openSync(lockPath, 'wx');
|
|
94
94
|
} catch (err) {
|
|
95
95
|
if (err.code !== 'EEXIST') return null; // unexpected (perms, etc.) — go lockless
|
|
96
96
|
try {
|
|
97
|
-
if (Date.now() - statSync(
|
|
97
|
+
if (Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS) {
|
|
98
98
|
try {
|
|
99
|
-
unlinkSync(
|
|
99
|
+
unlinkSync(lockPath);
|
|
100
100
|
} catch {
|
|
101
101
|
/* someone else reclaimed it first */
|
|
102
102
|
}
|
|
@@ -111,7 +111,7 @@ function acquireLock() {
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
function releaseLock(fd) {
|
|
114
|
+
function releaseLock(fd, lockPath) {
|
|
115
115
|
if (fd === null) return;
|
|
116
116
|
// Only unlink the lock if the path still points at OUR lock file. If this
|
|
117
117
|
// process somehow held it past LOCK_STALE_MS, a sibling has reclaimed the
|
|
@@ -120,7 +120,7 @@ function releaseLock(fd) {
|
|
|
120
120
|
let ours;
|
|
121
121
|
try {
|
|
122
122
|
const a = fstatSync(fd);
|
|
123
|
-
const b = statSync(
|
|
123
|
+
const b = statSync(lockPath);
|
|
124
124
|
ours = a.ino === b.ino && a.dev === b.dev;
|
|
125
125
|
} catch {
|
|
126
126
|
ours = false; // lock already gone — nothing to unlink
|
|
@@ -132,40 +132,53 @@ function releaseLock(fd) {
|
|
|
132
132
|
}
|
|
133
133
|
if (!ours) return;
|
|
134
134
|
try {
|
|
135
|
-
unlinkSync(
|
|
135
|
+
unlinkSync(lockPath);
|
|
136
136
|
} catch {
|
|
137
137
|
/* already removed */
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
function withLock(fn) {
|
|
142
|
-
const fd = acquireLock();
|
|
141
|
+
function withLock(lockPath, fn) {
|
|
142
|
+
const fd = acquireLock(lockPath);
|
|
143
143
|
try {
|
|
144
144
|
return fn();
|
|
145
145
|
} finally {
|
|
146
|
-
releaseLock(fd);
|
|
146
|
+
releaseLock(fd, lockPath);
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
|
|
150
|
+
// Resolve the state file for a session. No id → the legacy global state.json
|
|
151
|
+
// (single-session and back-compat). With an id → a per-session file, so
|
|
152
|
+
// concurrent sessions stop clobbering each other's status/tools/tokens and the
|
|
153
|
+
// daemon can pick which one to show. The id is a Claude Code session UUID;
|
|
154
|
+
// sanitize defensively for the filename regardless.
|
|
155
|
+
export function statePathFor(sessionId) {
|
|
156
|
+
if (!sessionId) return STATE_PATH;
|
|
157
|
+
const safe = String(sessionId).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64) || 'unknown';
|
|
158
|
+
return join(STATE_DIR, `state-${safe}.json`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function readState(sessionId) {
|
|
151
162
|
ensureDir();
|
|
152
|
-
|
|
163
|
+
const path = statePathFor(sessionId);
|
|
164
|
+
if (!existsSync(path)) return { ...DEFAULT_STATE };
|
|
153
165
|
try {
|
|
154
|
-
const raw = readFileSync(
|
|
166
|
+
const raw = readFileSync(path, 'utf8');
|
|
155
167
|
return { ...DEFAULT_STATE, ...JSON.parse(raw) };
|
|
156
168
|
} catch {
|
|
157
169
|
return { ...DEFAULT_STATE };
|
|
158
170
|
}
|
|
159
171
|
}
|
|
160
172
|
|
|
161
|
-
export function writeState(next) {
|
|
173
|
+
export function writeState(next, sessionId) {
|
|
162
174
|
ensureDir();
|
|
163
|
-
|
|
164
|
-
//
|
|
175
|
+
const path = statePathFor(sessionId);
|
|
176
|
+
// Per-process tmp name: two processes writing the same <path>.tmp would
|
|
177
|
+
// clobber each other's tmp before rename. The pid suffix keeps the
|
|
165
178
|
// atomic-rename guarantee intact even on the best-effort lockless path.
|
|
166
|
-
const tmp = `${
|
|
179
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
167
180
|
writeFileSync(tmp, JSON.stringify(next, null, 2));
|
|
168
|
-
renameSync(tmp,
|
|
181
|
+
renameSync(tmp, path);
|
|
169
182
|
}
|
|
170
183
|
|
|
171
184
|
// Best-effort sweep of orphaned per-pid tmp files (`state.json.<pid>.tmp`) left
|
|
@@ -184,19 +197,62 @@ export function sweepStaleStateTmp(now = Date.now()) {
|
|
|
184
197
|
} catch { /* STATE_DIR missing / unreadable — nothing to sweep */ }
|
|
185
198
|
}
|
|
186
199
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
200
|
+
// All per-session states currently on disk, each tagged with its sessionId
|
|
201
|
+
// (recovered from the `state-<id>.json` filename). The daemon uses this to pick
|
|
202
|
+
// which session to show. Excludes the legacy global state.json (no `-<id>`),
|
|
203
|
+
// and tmp/lock siblings (they don't end in `.json`).
|
|
204
|
+
export function listSessionStates() {
|
|
205
|
+
ensureDir();
|
|
206
|
+
const out = [];
|
|
207
|
+
let names;
|
|
208
|
+
try { names = readdirSync(STATE_DIR); } catch { return out; }
|
|
209
|
+
for (const name of names) {
|
|
210
|
+
const m = /^state-(.+)\.json$/.exec(name);
|
|
211
|
+
if (!m) continue;
|
|
212
|
+
try {
|
|
213
|
+
const parsed = JSON.parse(readFileSync(join(STATE_DIR, name), 'utf8'));
|
|
214
|
+
out.push({ ...DEFAULT_STATE, ...parsed, sessionId: m[1] });
|
|
215
|
+
} catch { /* torn/broken file mid-write — skip this tick */ }
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Remove per-session state files whose last activity is older than maxAgeMs — a
|
|
221
|
+
// session that ended without cleanup, or a crashed one. Called periodically by
|
|
222
|
+
// the daemon; never touches the global state.json. Returns the count removed.
|
|
223
|
+
export function sweepStaleSessionStates(maxAgeMs = 6 * 60 * 60 * 1000, now = Date.now()) {
|
|
224
|
+
let removed = 0, names;
|
|
225
|
+
try { names = readdirSync(STATE_DIR); } catch { return 0; }
|
|
226
|
+
for (const name of names) {
|
|
227
|
+
if (!/^state-.+\.json$/.test(name)) continue;
|
|
228
|
+
const full = join(STATE_DIR, name);
|
|
229
|
+
try {
|
|
230
|
+
const last = JSON.parse(readFileSync(full, 'utf8')).lastActivity || 0;
|
|
231
|
+
if (now - last > maxAgeMs) { unlinkSync(full); removed++; }
|
|
232
|
+
} catch {
|
|
233
|
+
// Unparseable — age out by file mtime so a corrupt file still clears.
|
|
234
|
+
try { if (now - statSync(full).mtimeMs > maxAgeMs) { unlinkSync(full); removed++; } }
|
|
235
|
+
catch { /* gone */ }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return removed;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function updateState(mutator, sessionId) {
|
|
242
|
+
const lockPath = statePathFor(sessionId) + '.lock';
|
|
243
|
+
return withLock(lockPath, () => {
|
|
244
|
+
const current = readState(sessionId);
|
|
190
245
|
const next = mutator({ ...current }) ?? current;
|
|
191
|
-
writeState(next);
|
|
246
|
+
writeState(next, sessionId);
|
|
192
247
|
return next;
|
|
193
248
|
});
|
|
194
249
|
}
|
|
195
250
|
|
|
196
|
-
export function resetState(seed = {}) {
|
|
197
|
-
|
|
251
|
+
export function resetState(seed = {}, sessionId) {
|
|
252
|
+
const lockPath = statePathFor(sessionId) + '.lock';
|
|
253
|
+
return withLock(lockPath, () => {
|
|
198
254
|
const fresh = { ...DEFAULT_STATE, sessionStart: Date.now(), lastActivity: Date.now(), ...seed };
|
|
199
|
-
writeState(fresh);
|
|
255
|
+
writeState(fresh, sessionId);
|
|
200
256
|
return fresh;
|
|
201
257
|
});
|
|
202
258
|
}
|