unsnooze 1.16.3 → 1.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/CHANGELOG.md +193 -0
- package/README.md +24 -6
- package/bin/unsnooze.js +1 -0
- package/package.json +5 -2
- package/src/agents/claude.js +3 -0
- package/src/agents/cursor.js +225 -0
- package/src/agents/index.js +2 -1
- package/src/agents/kimi.js +8 -0
- package/src/askpass.js +3 -2
- package/src/config.js +170 -1
- package/src/doctor.js +60 -2
- package/src/fleet.js +13 -10
- package/src/hook.js +3 -2
- package/src/install.js +22 -10
- package/src/lease.js +2 -2
- package/src/logger.js +6 -4
- package/src/monitor.js +2 -2
- package/src/multiplexers/headless.js +4 -2
- package/src/mux-sessions.js +2 -2
- package/src/patterns.js +7 -0
- package/src/resumer.js +6 -6
- package/src/sessions.js +19 -1
- package/src/settings.js +15 -10
- package/src/state.js +106 -15
- package/src/update-check.js +7 -5
- package/src/usage.js +22 -8
- package/src/watcher.js +2 -2
package/src/settings.js
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
// the global resumeMessage even when the global came from an env var (see
|
|
7
7
|
// resolveResumeMessage).
|
|
8
8
|
|
|
9
|
-
import { readFileSync
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
10
|
import { join, dirname } from 'node:path';
|
|
11
|
-
import { MUX_NAMES, STATE_DIR } from './config.js';
|
|
11
|
+
import { MUX_NAMES, STATE_DIR, ensureStateDir, writePrivateFile } from './config.js';
|
|
12
12
|
|
|
13
13
|
export const CONFIG_FILE = () => join(process.env.UNSNOOZE_STATE_DIR || STATE_DIR, 'config.json');
|
|
14
14
|
|
|
@@ -45,19 +45,19 @@ export const DEFAULTS = {
|
|
|
45
45
|
// command that answers 'disabled' still returns a valid framed envelope.
|
|
46
46
|
remoteQueue: true,
|
|
47
47
|
resumeMessage: 'Continue where you left off. The session was interrupted by a usage limit which has now reset — pick up the task you were working on and finish it.',
|
|
48
|
-
resumeMessages: { claude: '', codex: '', grok: '', qwen: '', kimi: '', opencode: '', agy: '' }, // per-agent override; '' = use resumeMessage
|
|
48
|
+
resumeMessages: { claude: '', codex: '', grok: '', qwen: '', kimi: '', opencode: '', agy: '', cursor: '' }, // per-agent override; '' = use resumeMessage
|
|
49
49
|
// Extra argv appended when unsnooze itself launches the agent binary (dead-pane
|
|
50
50
|
// reopen, queued prompts). Space-separated. Lets a revived session match the
|
|
51
51
|
// user's normal launch mode — shell aliases don't apply to direct spawns
|
|
52
52
|
// (e.g. claude users who always launch with --dangerously-skip-permissions).
|
|
53
|
-
resumeExtraArgs: { claude: '', codex: '', grok: '', qwen: '', kimi: '', opencode: '', agy: '' },
|
|
53
|
+
resumeExtraArgs: { claude: '', codex: '', grok: '', qwen: '', kimi: '', opencode: '', agy: '', cursor: '' },
|
|
54
54
|
// Extra argv for launches the USER performs through the shell wrapper — the
|
|
55
55
|
// launch-side twin of resumeExtraArgs. A flag that has to hold for the whole
|
|
56
56
|
// session (claude's --autocompact, which decides when the context window is
|
|
57
57
|
// compacted) is useless if it only applies to revives, because the session
|
|
58
58
|
// that runs out of context is the one the user started.
|
|
59
|
-
launchExtraArgs: { claude: '', codex: '', grok: '', qwen: '', kimi: '', opencode: '', agy: '' },
|
|
60
|
-
agents: { claude: true, codex: true, grok: false, qwen: false, kimi: false, opencode: false, agy: false }, // experimental agents default off
|
|
59
|
+
launchExtraArgs: { claude: '', codex: '', grok: '', qwen: '', kimi: '', opencode: '', agy: '', cursor: '' },
|
|
60
|
+
agents: { claude: true, codex: true, grok: false, qwen: false, kimi: false, opencode: false, agy: false, cursor: false }, // experimental agents default off
|
|
61
61
|
};
|
|
62
62
|
|
|
63
63
|
// Env override per key. Booleans accept 1/0, true/false, on/off, yes/no.
|
|
@@ -90,6 +90,7 @@ const ENV_NAMES = {
|
|
|
90
90
|
'resumeMessages.kimi': 'UNSNOOZE_RESUME_MESSAGE_KIMI',
|
|
91
91
|
'resumeMessages.opencode': 'UNSNOOZE_RESUME_MESSAGE_OPENCODE',
|
|
92
92
|
'resumeMessages.agy': 'UNSNOOZE_RESUME_MESSAGE_AGY',
|
|
93
|
+
'resumeMessages.cursor': 'UNSNOOZE_RESUME_MESSAGE_CURSOR',
|
|
93
94
|
'resumeExtraArgs.claude': 'UNSNOOZE_RESUME_EXTRA_ARGS_CLAUDE',
|
|
94
95
|
'resumeExtraArgs.codex': 'UNSNOOZE_RESUME_EXTRA_ARGS_CODEX',
|
|
95
96
|
'resumeExtraArgs.grok': 'UNSNOOZE_RESUME_EXTRA_ARGS_GROK',
|
|
@@ -97,6 +98,7 @@ const ENV_NAMES = {
|
|
|
97
98
|
'resumeExtraArgs.kimi': 'UNSNOOZE_RESUME_EXTRA_ARGS_KIMI',
|
|
98
99
|
'resumeExtraArgs.opencode': 'UNSNOOZE_RESUME_EXTRA_ARGS_OPENCODE',
|
|
99
100
|
'resumeExtraArgs.agy': 'UNSNOOZE_RESUME_EXTRA_ARGS_AGY',
|
|
101
|
+
'resumeExtraArgs.cursor': 'UNSNOOZE_RESUME_EXTRA_ARGS_CURSOR',
|
|
100
102
|
'launchExtraArgs.claude': 'UNSNOOZE_LAUNCH_EXTRA_ARGS_CLAUDE',
|
|
101
103
|
'launchExtraArgs.codex': 'UNSNOOZE_LAUNCH_EXTRA_ARGS_CODEX',
|
|
102
104
|
'launchExtraArgs.grok': 'UNSNOOZE_LAUNCH_EXTRA_ARGS_GROK',
|
|
@@ -104,6 +106,7 @@ const ENV_NAMES = {
|
|
|
104
106
|
'launchExtraArgs.kimi': 'UNSNOOZE_LAUNCH_EXTRA_ARGS_KIMI',
|
|
105
107
|
'launchExtraArgs.opencode': 'UNSNOOZE_LAUNCH_EXTRA_ARGS_OPENCODE',
|
|
106
108
|
'launchExtraArgs.agy': 'UNSNOOZE_LAUNCH_EXTRA_ARGS_AGY',
|
|
109
|
+
'launchExtraArgs.cursor': 'UNSNOOZE_LAUNCH_EXTRA_ARGS_CURSOR',
|
|
107
110
|
'agents.claude': 'UNSNOOZE_AGENT_CLAUDE',
|
|
108
111
|
'agents.codex': 'UNSNOOZE_AGENT_CODEX',
|
|
109
112
|
'agents.grok': 'UNSNOOZE_AGENT_GROK',
|
|
@@ -111,6 +114,7 @@ const ENV_NAMES = {
|
|
|
111
114
|
'agents.kimi': 'UNSNOOZE_AGENT_KIMI',
|
|
112
115
|
'agents.opencode': 'UNSNOOZE_AGENT_OPENCODE',
|
|
113
116
|
'agents.agy': 'UNSNOOZE_AGENT_AGY',
|
|
117
|
+
'agents.cursor': 'UNSNOOZE_AGENT_CURSOR',
|
|
114
118
|
};
|
|
115
119
|
|
|
116
120
|
const KNOWN_KEYS = Object.keys(ENV_NAMES);
|
|
@@ -267,10 +271,11 @@ export function setConfigValue(key, rawValue) {
|
|
|
267
271
|
|
|
268
272
|
export function writeConfig(config) {
|
|
269
273
|
const path = CONFIG_FILE();
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
+
ensureStateDir(dirname(path));
|
|
275
|
+
// Owner-only: ntfyToken is a Bearer credential, and config.json was
|
|
276
|
+
// world-readable before 1.17.0.
|
|
277
|
+
writePrivateFile(path, join(dirname(path), `.config.tmp.${process.pid}`),
|
|
278
|
+
JSON.stringify(config, null, 2) + '\n');
|
|
274
279
|
}
|
|
275
280
|
|
|
276
281
|
export function configFileExists() {
|
package/src/state.js
CHANGED
|
@@ -10,7 +10,8 @@ import {
|
|
|
10
10
|
} from 'node:fs';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import {
|
|
13
|
-
STATE_DIR, STATE_FILE, LOCK_DIR, STALE_LOCK_MS, PRUNE_AFTER_MS,
|
|
13
|
+
STATE_DIR, STATE_FILE, LOCK_DIR, STALE_LOCK_MS, PRUNE_AFTER_MS, ensureStateDir,
|
|
14
|
+
writePrivateFile,
|
|
14
15
|
DEDUPE_WINDOW_MS, STALE_AFTER_MS, PROBE_INTERVAL_MS, PROBE_MAX_MS,
|
|
15
16
|
RESET_MARGIN_MS,
|
|
16
17
|
} from './config.js';
|
|
@@ -48,22 +49,78 @@ function lockHolderAlive() {
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
// lockHolderAlive() can only ask "is this pid alive", never "is it still
|
|
53
|
+
// unsnooze". A leaked lock whose pid is later recycled onto some unrelated
|
|
54
|
+
// long-lived process therefore looks live forever, and every writer — daemon,
|
|
55
|
+
// CLI, in-agent hook — wedges permanently with no way out but deleting the
|
|
56
|
+
// directory by hand. Past this ceiling, age wins regardless.
|
|
57
|
+
//
|
|
58
|
+
// This is a deliberate trade, not a free win: stealing from a holder that IS
|
|
59
|
+
// still working puts two writers in the critical section, and the robbed one's
|
|
60
|
+
// rename then clobbers the thief's write — a lost update. It is chosen because
|
|
61
|
+
// a permanent unrecoverable wedge is the worse failure. The exposure is kept
|
|
62
|
+
// small by holding this lock only for in-memory work plus one write:
|
|
63
|
+
// upsertSession's git call is hoisted out for exactly this reason. Five
|
|
64
|
+
// minutes is ~200x the worst measured critical section.
|
|
65
|
+
//
|
|
66
|
+
// Known limit: both sides of the age are wall-clock, so a forward clock step
|
|
67
|
+
// (VM resume, laptop wake, a large NTP correction) can age a fresh lock past
|
|
68
|
+
// the ceiling at once. There is no cross-process monotonic clock to use
|
|
69
|
+
// instead, which is part of why the ceiling is generous rather than tight.
|
|
70
|
+
const HARD_STALE_LOCK_MS = Math.max(STALE_LOCK_MS * 30, 300_000);
|
|
71
|
+
|
|
51
72
|
function acquireLock() {
|
|
52
73
|
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
74
|
+
let parentRepaired = false;
|
|
53
75
|
for (;;) {
|
|
54
76
|
try {
|
|
55
|
-
mkdirSync(LOCK_DIR);
|
|
77
|
+
mkdirSync(LOCK_DIR, { mode: 0o700 });
|
|
56
78
|
// Record the holder so a slow-but-alive writer is never robbed — age
|
|
57
79
|
// alone can't tell a hung process from a busy one.
|
|
58
|
-
try { writeFileSync(join(LOCK_DIR, 'pid'), String(process.pid)); } catch { /* best-effort */ }
|
|
80
|
+
try { writeFileSync(join(LOCK_DIR, 'pid'), String(process.pid), { mode: 0o600 }); } catch { /* best-effort */ }
|
|
59
81
|
return;
|
|
60
82
|
} catch (err) {
|
|
61
|
-
if (err.code !== 'EEXIST') {
|
|
83
|
+
if (err.code !== 'EEXIST') {
|
|
84
|
+
// NOT contention. A missing STATE_DIR is worth exactly one immediate
|
|
85
|
+
// repair-and-retry; every other errno (EACCES on an unwritable
|
|
86
|
+
// ~/.unsnooze, EROFS, ENOSPC) must fall through to the same backoff
|
|
87
|
+
// and deadline as any other failure. This branch used to `continue`
|
|
88
|
+
// unconditionally, skipping both — and since mkdir -p on an existing
|
|
89
|
+
// STATE_DIR succeeds as a no-op, an unwritable state dir spun here
|
|
90
|
+
// forever at ~70% of a core, ignoring LOCK_TIMEOUT_MS, in every writer:
|
|
91
|
+
// the daemon, the CLI, and the StopFailure hook inside the agent.
|
|
92
|
+
if (!parentRepaired) {
|
|
93
|
+
parentRepaired = true;
|
|
94
|
+
try { ensureStateDir(); } catch { /* fall through to the backoff */ }
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (Date.now() > deadline) {
|
|
98
|
+
throw new Error(`unsnooze: cannot create state lock at ${LOCK_DIR}: ${err.code || err.message}`);
|
|
99
|
+
}
|
|
100
|
+
sleepSync(50);
|
|
101
|
+
// A *recurring* ENOENT means the directory keeps going away under us
|
|
102
|
+
// (an uninstall racing a write), not that the one-shot repair failed.
|
|
103
|
+
// Keep repairing it, but from the backoff — never in a tight loop.
|
|
104
|
+
if (err.code === 'ENOENT') { try { ensureStateDir(); } catch { /* next pass */ } }
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
62
107
|
try {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
108
|
+
const before = statSync(LOCK_DIR);
|
|
109
|
+
const age = Date.now() - before.mtimeMs;
|
|
110
|
+
if (age > STALE_LOCK_MS && (age > HARD_STALE_LOCK_MS || lockHolderAlive() !== true)) {
|
|
111
|
+
// Re-stat immediately before removing. The staleness verdict above
|
|
112
|
+
// costs a readFileSync + kill(), and in that gap another contender
|
|
113
|
+
// can have stolen this same lock and created a fresh one — removing
|
|
114
|
+
// that would drop a live holder's lock and break mutual exclusion.
|
|
115
|
+
// A matching inode means it is still the dir we judged stale. This
|
|
116
|
+
// narrows the window rather than closing it: a filesystem that
|
|
117
|
+
// recycles a just-freed directory inode can hand the same number
|
|
118
|
+
// back, and Windows may report 0 for both — in either case this
|
|
119
|
+
// degrades to the old unconditional behaviour, never to a worse one.
|
|
120
|
+
if (statSync(LOCK_DIR).ino === before.ino) {
|
|
121
|
+
rmSync(LOCK_DIR, { recursive: true, force: true }); // steal from a dead/unknown holder
|
|
122
|
+
log(`stole stale lock (age ${Math.round(age)}ms)`);
|
|
123
|
+
}
|
|
67
124
|
continue;
|
|
68
125
|
}
|
|
69
126
|
} catch { /* lock vanished between check and stat — retry */ }
|
|
@@ -74,6 +131,14 @@ function acquireLock() {
|
|
|
74
131
|
}
|
|
75
132
|
|
|
76
133
|
function releaseLock() {
|
|
134
|
+
try {
|
|
135
|
+
// Only drop a lock we still hold. A stale-lock steal can rob a live
|
|
136
|
+
// holder (see acquireLock); if that happened, the dir now belongs to the
|
|
137
|
+
// thief, and removing it would hand a third writer a lock the thief still
|
|
138
|
+
// believes is theirs. An unreadable/absent pid file means the lock is
|
|
139
|
+
// ours-but-unstamped or already gone — both fall through to the remove.
|
|
140
|
+
if (readFileSync(join(LOCK_DIR, 'pid'), 'utf-8') !== String(process.pid)) return;
|
|
141
|
+
} catch { /* no pid file: our own best-effort stamp failed, or it's gone */ }
|
|
77
142
|
try { rmSync(LOCK_DIR, { recursive: true, force: true }); } catch { /* already gone */ }
|
|
78
143
|
}
|
|
79
144
|
|
|
@@ -86,7 +151,12 @@ export function readState() {
|
|
|
86
151
|
try {
|
|
87
152
|
const quarantine = `${STATE_FILE}.corrupt.${Date.now()}`;
|
|
88
153
|
renameSync(STATE_FILE, quarantine);
|
|
89
|
-
|
|
154
|
+
// err.message, NOT logged: V8 embeds the first bytes of the input in
|
|
155
|
+
// its JSON.parse message ("Unexpected token 'S', \"SUPER SECR\"..."),
|
|
156
|
+
// so a state.json that is really a symlink to something else would
|
|
157
|
+
// print that file's opening bytes into unsnooze.log. The quarantined
|
|
158
|
+
// file is right there on disk for anyone who needs to see the content.
|
|
159
|
+
log(`CORRUPT state.json quarantined to ${quarantine} (${err.name || 'parse error'})`);
|
|
90
160
|
} catch { /* someone else quarantined it first */ }
|
|
91
161
|
return EMPTY();
|
|
92
162
|
}
|
|
@@ -137,14 +207,14 @@ function normalizeRecord(rec) {
|
|
|
137
207
|
// Locked read-modify-write. mutator receives the state object and mutates it
|
|
138
208
|
// (or returns a replacement). Returns the final state.
|
|
139
209
|
export function updateState(mutator) {
|
|
140
|
-
|
|
210
|
+
ensureStateDir();
|
|
141
211
|
acquireLock();
|
|
142
212
|
try {
|
|
143
213
|
const state = readState();
|
|
144
214
|
const result = mutator(state) ?? state;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
215
|
+
// Owner-only: session records embed cwd paths and queued prompt text.
|
|
216
|
+
writePrivateFile(STATE_FILE, join(STATE_DIR, `.state.tmp.${process.pid}`),
|
|
217
|
+
JSON.stringify(result, null, 2));
|
|
148
218
|
return result;
|
|
149
219
|
} finally {
|
|
150
220
|
releaseLock();
|
|
@@ -159,6 +229,24 @@ export function updateState(mutator) {
|
|
|
159
229
|
// calibration snapshots so stop + ceiling sample never race (1.13).
|
|
160
230
|
export function upsertSession(record, { after = null } = {}) {
|
|
161
231
|
record = normalizeRecord({ ...record });
|
|
232
|
+
// Computed BEFORE the lock, never inside the mutator. workspaceFingerprint
|
|
233
|
+
// shells out to git, and execFileSync's `timeout` is a soft bound — it
|
|
234
|
+
// sends SIGTERM at the deadline and then waits for the child to actually
|
|
235
|
+
// exit, which a git wedged in uninterruptible I/O on a hung network mount
|
|
236
|
+
// never does. That made it the one unbounded operation under the state
|
|
237
|
+
// lock, and the only way a critical section could outlive
|
|
238
|
+
// HARD_STALE_LOCK_MS and be stolen from a writer that is still working.
|
|
239
|
+
// The cost of hoisting is one extra git call when the record turns out to
|
|
240
|
+
// be a duplicate; the benefit is that the lock is only ever held for
|
|
241
|
+
// in-memory work plus one write.
|
|
242
|
+
// 'resuming' is included deliberately: the `closing` branch inside the
|
|
243
|
+
// mutator can flip such a record to 'stopped', and the apply site below
|
|
244
|
+
// would then want a baseline this precompute never made. No caller passes
|
|
245
|
+
// 'resuming' today — this keeps the hoist from becoming a trap for one that
|
|
246
|
+
// later does.
|
|
247
|
+
const needsFingerprint = ['stopped', 'resuming'].includes(record.status)
|
|
248
|
+
&& record.workspace === undefined;
|
|
249
|
+
const fingerprint = needsFingerprint ? workspaceFingerprint(record.cwd) : undefined;
|
|
162
250
|
return updateState(state => {
|
|
163
251
|
prune(state);
|
|
164
252
|
const closing = state.paneClosures.find(c => c.pane && c.leaseId
|
|
@@ -228,9 +316,12 @@ export function upsertSession(record, { after = null } = {}) {
|
|
|
228
316
|
log(`merged duplicate detection for pane ${record.pane} into ${existingKey}`);
|
|
229
317
|
} else {
|
|
230
318
|
// Baseline for the stale-workspace guard, captured once at stop time.
|
|
231
|
-
// (Merged duplicates above keep the ORIGINAL baseline — spread
|
|
319
|
+
// (Merged duplicates above keep the ORIGINAL baseline — spread
|
|
320
|
+
// semantics — so this is applied on the non-duplicate branch ONLY.
|
|
321
|
+
// Assigning it before the branch would let the merge spread it over the
|
|
322
|
+
// existing record's baseline and silently reset it.)
|
|
232
323
|
if (record.status === 'stopped' && record.workspace === undefined) {
|
|
233
|
-
record.workspace =
|
|
324
|
+
record.workspace = fingerprint;
|
|
234
325
|
}
|
|
235
326
|
const key = record.sessionId || `pane:${addressHash(record)}:${record.detectedAt}`;
|
|
236
327
|
applied = { ...record, key };
|
package/src/update-check.js
CHANGED
|
@@ -7,13 +7,14 @@
|
|
|
7
7
|
// All of it is gated on the `updateCheck` setting / UNSNOOZE_UPDATE_CHECK.
|
|
8
8
|
// The check is a plain GET to registry.npmjs.org — nothing identifying.
|
|
9
9
|
|
|
10
|
-
import { readFileSync,
|
|
10
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
11
11
|
import { spawnSync } from 'node:child_process';
|
|
12
12
|
import { join, dirname } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { getConfig } from './settings.js';
|
|
15
15
|
import { notify } from './notify.js';
|
|
16
16
|
import { UNSNOOZE_BIN, pidAlive } from './spawn.js';
|
|
17
|
+
import { ensureStateDir, writePrivateFile } from './config.js';
|
|
17
18
|
|
|
18
19
|
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
19
20
|
export const PKG_VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')).version;
|
|
@@ -31,10 +32,11 @@ export function readCache() {
|
|
|
31
32
|
export function writeCache(patch) {
|
|
32
33
|
const merged = { ...readCache(), ...patch };
|
|
33
34
|
const path = CACHE_FILE();
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
// _update-check runs as a detached child, so on a fresh install this can be
|
|
36
|
+
// the process that creates ~/.unsnooze — it must create it owner-only.
|
|
37
|
+
ensureStateDir(dirname(path));
|
|
38
|
+
writePrivateFile(path, join(dirname(path), `.update-check.tmp.${process.pid}`),
|
|
39
|
+
JSON.stringify(merged, null, 2) + '\n');
|
|
38
40
|
return merged;
|
|
39
41
|
}
|
|
40
42
|
|
package/src/usage.js
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
CLAUDE_DIR, CODEX_DIR, USAGE_FILE, USAGE_ETA_WARN_MIN,
|
|
12
12
|
USAGE_BURN_LOOKBACK_MS, USAGE_BURN_MIN_COVERAGE_MS, USAGE_IDLE_GAP_MS,
|
|
13
13
|
USAGE_WINDOW_IDLE_MS, USAGE_CALIBRATION_RING, USAGE_CALIBRATION_MEDIAN_N,
|
|
14
|
-
USAGE_STATUSLINE_DIR,
|
|
14
|
+
USAGE_STATUSLINE_DIR, ensureStateDir, writePrivateFile,
|
|
15
15
|
} from './config.js';
|
|
16
16
|
import { readState, updateState } from './state.js';
|
|
17
17
|
import { getConfig } from './settings.js';
|
|
@@ -535,10 +535,9 @@ export function readUsageStore(path = USAGE_FILE) {
|
|
|
535
535
|
}
|
|
536
536
|
|
|
537
537
|
export function writeUsageStore(store, path = USAGE_FILE) {
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
renameSync(tmp, path);
|
|
538
|
+
ensureStateDir(dirname(path));
|
|
539
|
+
writePrivateFile(path, join(dirname(path), `.usage.tmp.${process.pid}`),
|
|
540
|
+
JSON.stringify(store));
|
|
542
541
|
return store;
|
|
543
542
|
}
|
|
544
543
|
|
|
@@ -1239,15 +1238,30 @@ let data = {};
|
|
|
1239
1238
|
try { data = JSON.parse(raw || '{}'); } catch { /* ignore */ }
|
|
1240
1239
|
|
|
1241
1240
|
const dir = path.join(os.homedir(), '.claude', 'unsnooze');
|
|
1242
|
-
|
|
1243
|
-
|
|
1241
|
+
// 0700/0600 like the state dir: these drops carry your live rate-limit
|
|
1242
|
+
// numbers, and the shim is the only thing that creates them. mkdir's mode is
|
|
1243
|
+
// ignored for a directory that already exists, so an install that predates
|
|
1244
|
+
// this is repaired explicitly — the same reasoning ensureStateDir applies one
|
|
1245
|
+
// directory over. Skipped on Windows, where the POSIX bits are synthetic.
|
|
1246
|
+
try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* ignore */ }
|
|
1247
|
+
if (process.platform !== 'win32') {
|
|
1248
|
+
try { fs.chmodSync(dir, 0o700); } catch { /* not ours */ }
|
|
1249
|
+
}
|
|
1250
|
+
// The id becomes a filename. Claude Code sends a uuid, but nothing here is
|
|
1251
|
+
// in a position to trust that: a '/' or a '..' would walk the drop file out
|
|
1252
|
+
// of ~/.claude/unsnooze and overwrite any .json the user can write.
|
|
1253
|
+
const rawSid = String(data.session_id || data.sessionId || 'unknown');
|
|
1254
|
+
const sid = /^[A-Za-z0-9._-]{1,128}$/.test(rawSid) ? rawSid : 'unknown';
|
|
1244
1255
|
const drop = path.join(dir, 'usage-' + sid + '.json');
|
|
1245
1256
|
try {
|
|
1246
1257
|
fs.writeFileSync(drop, JSON.stringify({
|
|
1247
1258
|
rate_limits: data.rate_limits || null,
|
|
1248
1259
|
at: Date.now(),
|
|
1249
1260
|
sessionId: sid,
|
|
1250
|
-
}));
|
|
1261
|
+
}), { mode: 0o600 });
|
|
1262
|
+
// mode only lands when writeFileSync CREATES the file; an existing drop
|
|
1263
|
+
// from before this change keeps its own until chmod'd.
|
|
1264
|
+
if (process.platform !== 'win32') fs.chmodSync(drop, 0o600);
|
|
1251
1265
|
} catch { /* ignore */ }
|
|
1252
1266
|
|
|
1253
1267
|
const orig = process.env.UNSNOOZE_STATUSLINE_ORIG || '';
|
package/src/watcher.js
CHANGED
|
@@ -293,9 +293,9 @@ export function createWatcher({
|
|
|
293
293
|
}
|
|
294
294
|
if (!dirty) return;
|
|
295
295
|
try {
|
|
296
|
-
mkdirSync(dirname(offsetsPath), { recursive: true });
|
|
296
|
+
mkdirSync(dirname(offsetsPath), { recursive: true, mode: 0o700 });
|
|
297
297
|
const tmp = join(dirname(offsetsPath), `.offsets.tmp.${process.pid}`);
|
|
298
|
-
writeFileSync(tmp, JSON.stringify(offsets));
|
|
298
|
+
writeFileSync(tmp, JSON.stringify(offsets), { mode: 0o600 });
|
|
299
299
|
renameSync(tmp, offsetsPath);
|
|
300
300
|
dirty = false;
|
|
301
301
|
} catch (err) {
|