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/config.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
mkdirSync, chmodSync, writeFileSync, renameSync, lstatSync, statSync, readdirSync,
|
|
5
|
+
} from 'node:fs';
|
|
3
6
|
|
|
4
7
|
function envInt(name, def) {
|
|
5
8
|
const v = parseInt(process.env[name] ?? '', 10);
|
|
@@ -15,6 +18,172 @@ export const RESUMER_LOCK = join(STATE_DIR, 'resumer.lock');
|
|
|
15
18
|
// High-frequency burn accumulator + warn-dedup (daemon single-writer).
|
|
16
19
|
export const USAGE_FILE = join(STATE_DIR, 'usage.json');
|
|
17
20
|
|
|
21
|
+
// Everything under STATE_DIR is owner-only. The directory holds an ntfy
|
|
22
|
+
// bearer token (config.json), queued prompt text and cwd paths (state.json),
|
|
23
|
+
// ssh destinations and credential-source commands (hosts.json) — none of it
|
|
24
|
+
// another local user's business, and the dir mode is what protects the files
|
|
25
|
+
// that don't set their own. mkdir's `mode` only applies when it creates, so
|
|
26
|
+
// a 0755 dir left by a pre-1.17.0 install is repaired explicitly.
|
|
27
|
+
// No-op on Windows (node's chmod only carries the read-only bit there); the
|
|
28
|
+
// user profile ACL already restricts it.
|
|
29
|
+
export function ensureStateDir(dir = STATE_DIR) {
|
|
30
|
+
// Creating a directory owner-only is always right — we are about to put a
|
|
31
|
+
// 0600 file in it.
|
|
32
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
33
|
+
// Narrowing one that ALREADY exists is only ever done for our own state
|
|
34
|
+
// dir. ensureStateDir is also called with a caller-supplied path
|
|
35
|
+
// (writeUsageStore takes one), and re-permissioning someone else's
|
|
36
|
+
// directory — or the files inside it — is not this function's business.
|
|
37
|
+
if (isStateDir(dir)) {
|
|
38
|
+
try { chmodSync(dir, 0o700); } catch { /* shared/CI dir, not ours to fix */ }
|
|
39
|
+
repairModes(dir);
|
|
40
|
+
}
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Repairing an install that predates 1.17.0. Three things the writers alone
|
|
45
|
+
// cannot fix: mkdir's `mode` is ignored for a directory that already exists,
|
|
46
|
+
// an append/open reuses an existing file's inode, and config.json/hosts.json
|
|
47
|
+
// are written only by `config set` / `hosts add` — on an install that never
|
|
48
|
+
// touches them again they would keep their old 0644 (and, for config.json,
|
|
49
|
+
// the ntfy token in it) indefinitely. So the modes are repaired directly.
|
|
50
|
+
//
|
|
51
|
+
// Re-checked on an interval rather than once per process: daemon.log is
|
|
52
|
+
// created by launchd/systemd redirecting the daemon's stdout, so it can
|
|
53
|
+
// appear at 0644 AFTER a pass has run — in a process that then lives for
|
|
54
|
+
// days. A walk is a readdir and a handful of lstats, so this is free at
|
|
55
|
+
// daemon cadence.
|
|
56
|
+
const MODE_REPAIR_INTERVAL_MS = envInt('UNSNOOZE_MODE_REPAIR_MS', 60_000);
|
|
57
|
+
const repairedAt = new Map();
|
|
58
|
+
|
|
59
|
+
// Compare resolved paths, not raw strings: callers reach ensureStateDir
|
|
60
|
+
// through dirname(join(STATE_DIR, 'config.json')), which normalizes away a
|
|
61
|
+
// trailing slash that STATE_DIR itself may carry from UNSNOOZE_STATE_DIR. A
|
|
62
|
+
// raw comparison silently answers "not our directory" and skips the repair.
|
|
63
|
+
const isStateDir = (dir) => resolve(dir) === resolve(STATE_DIR);
|
|
64
|
+
|
|
65
|
+
// ONE walk, exported so `unsnooze doctor` reports from exactly what the
|
|
66
|
+
// automatic repair acts on. They were separate before, and drifted: the
|
|
67
|
+
// repair worked from a fixed list of filenames while doctor walked the whole
|
|
68
|
+
// directory, so a quarantined state.json or a crashed writer's tmp file — the
|
|
69
|
+
// two leftovers that hold verbatim copies of the sensitive files — were
|
|
70
|
+
// reported forever and never fixed.
|
|
71
|
+
//
|
|
72
|
+
// Top level plus one level down is the entire layout: a handful of files and
|
|
73
|
+
// four flat subdirectories.
|
|
74
|
+
export function scanStateDir(dir = STATE_DIR, { platform = process.platform } = {}) {
|
|
75
|
+
const out = [];
|
|
76
|
+
// Windows has no POSIX mode bits. libuv synthesises them by mirroring the
|
|
77
|
+
// owner bits into group and other (win/fs.c `fs__stat_impl`), so a file is
|
|
78
|
+
// always 0666 (0444 read-only) and a directory 0777 — `mode & 0o077` is
|
|
79
|
+
// never zero and EVERY entry would look permanently exposed. Meanwhile
|
|
80
|
+
// uv_fs_chmod only toggles FILE_ATTRIBUTE_READONLY, so no repair could ever
|
|
81
|
+
// clear it: doctor would never report healthy, `--fix` would claim success
|
|
82
|
+
// while changing nothing, and the daemon would re-chmod the whole directory
|
|
83
|
+
// every interval forever. Access there is governed by the profile ACL.
|
|
84
|
+
if (platform === 'win32') return out;
|
|
85
|
+
// The root is stat'd, not lstat'd: a state dir that is itself a symlink is
|
|
86
|
+
// a legitimate setup (dotfiles, another volume) and must still be checked
|
|
87
|
+
// and repaired. Entries INSIDE are lstat'd and a link is skipped — chmod
|
|
88
|
+
// follows links, and following one there would reach outside the dir.
|
|
89
|
+
const inspect = (path, rel, isRoot = false) => {
|
|
90
|
+
try {
|
|
91
|
+
const st = isRoot ? statSync(path) : lstatSync(path);
|
|
92
|
+
if (!isRoot && st.isSymbolicLink()) return null;
|
|
93
|
+
const linked = !isRoot && !st.isDirectory() && st.nlink > 1;
|
|
94
|
+
// Strip group/other, but never the owner's execute bit: the state dir
|
|
95
|
+
// holds executables (askpass.sh, and whatever a test or a user drops
|
|
96
|
+
// beside it), and forcing a flat 0600 turns those into EACCES on spawn.
|
|
97
|
+
// Directories need their own traverse bit, hence 0700 rather than 0600.
|
|
98
|
+
const want = st.isDirectory() ? 0o700 : (0o600 | (st.mode & 0o100));
|
|
99
|
+
if ((st.mode & 0o077) !== 0) {
|
|
100
|
+
// `skipRepair` for a hardlink: lstat cannot distinguish one, so chmod
|
|
101
|
+
// would change an inode that also lives outside this directory.
|
|
102
|
+
// Reported anyway — an exposed file the repair will not touch is
|
|
103
|
+
// exactly what the doctor finding exists to surface.
|
|
104
|
+
out.push({ path, rel, mode: st.mode & 0o777, want, skipRepair: linked });
|
|
105
|
+
}
|
|
106
|
+
return st;
|
|
107
|
+
} catch { return null; }
|
|
108
|
+
};
|
|
109
|
+
if (!inspect(dir, '.', true)) return out;
|
|
110
|
+
let names = [];
|
|
111
|
+
try { names = readdirSync(dir); } catch { return out; }
|
|
112
|
+
for (const name of names) {
|
|
113
|
+
const path = join(dir, name);
|
|
114
|
+
const st = inspect(path, name);
|
|
115
|
+
if (!st?.isDirectory()) continue;
|
|
116
|
+
try {
|
|
117
|
+
for (const child of readdirSync(path)) inspect(join(path, child), `${name}/${child}`);
|
|
118
|
+
} catch { /* unreadable */ }
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Applies what scanStateDir found. `want` carries 0700 for directories and
|
|
124
|
+
// 0600 for files — a directory chmod'd 0600 would lose its traverse bit and
|
|
125
|
+
// nothing inside it could be opened.
|
|
126
|
+
// Returns { fixed, refused, ineffective }. The three outcomes are genuinely
|
|
127
|
+
// different and the caller needs to tell them apart:
|
|
128
|
+
// fixed — the bits moved.
|
|
129
|
+
// refused — chmod threw (EPERM, EROFS, an immutable flag). A real
|
|
130
|
+
// exposure that cannot be repaired; keep reporting it.
|
|
131
|
+
// ineffective — chmod SUCCEEDED and nothing changed. The filesystem does
|
|
132
|
+
// not implement POSIX modes at all (a CIFS/vfat/NTFS mount
|
|
133
|
+
// with `noperm`, WSL's drvfs, a Docker bind mount from a
|
|
134
|
+
// Windows host). Retrying forever would never converge, and
|
|
135
|
+
// `process.platform` does not identify this case — only
|
|
136
|
+
// attempting it does.
|
|
137
|
+
export function narrowStateDir(entries) {
|
|
138
|
+
let fixed = 0, refused = 0, ineffective = 0;
|
|
139
|
+
for (const e of entries) {
|
|
140
|
+
if (e.skipRepair) continue;
|
|
141
|
+
try {
|
|
142
|
+
chmodSync(e.path, e.want);
|
|
143
|
+
if ((lstatSync(e.path).mode & 0o077) === 0) fixed += 1;
|
|
144
|
+
else ineffective += 1;
|
|
145
|
+
} catch { refused += 1; }
|
|
146
|
+
}
|
|
147
|
+
return { fixed, refused, ineffective };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Directories whose filesystem provably ignores chmod. Learned by attempting
|
|
151
|
+
// it, never guessed from the platform.
|
|
152
|
+
const modesUnenforced = new Set();
|
|
153
|
+
|
|
154
|
+
function repairModes(dir) {
|
|
155
|
+
// Key on the resolved path: callers reach this through
|
|
156
|
+
// dirname(join(STATE_DIR, 'x')), which normalizes a trailing slash away, so
|
|
157
|
+
// raw strings would give the same directory two throttle slots.
|
|
158
|
+
const key = resolve(dir);
|
|
159
|
+
if (modesUnenforced.has(key)) return;
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
if (now - (repairedAt.get(key) ?? -Infinity) < MODE_REPAIR_INTERVAL_MS) return;
|
|
162
|
+
repairedAt.set(key, now);
|
|
163
|
+
const entries = scanStateDir(dir);
|
|
164
|
+
if (entries.length === 0) return;
|
|
165
|
+
const { fixed, refused, ineffective } = narrowStateDir(entries);
|
|
166
|
+
// Every attempt succeeded and not one bit moved: this filesystem has no
|
|
167
|
+
// POSIX modes to set. Stop walking it — the alternative is re-chmodding
|
|
168
|
+
// the whole directory every interval for the life of the daemon.
|
|
169
|
+
if (fixed === 0 && refused === 0 && ineffective > 0) modesUnenforced.add(key);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// tmp + rename + chmod. writeFileSync's `mode` lands only when it CREATES the
|
|
173
|
+
// file, so a tmp left behind by a crashed predecessor that happened to share
|
|
174
|
+
// this pid would otherwise carry its own 0644 onto the target through the
|
|
175
|
+
// rename — and a target left 0644 by an older version would never be fixed.
|
|
176
|
+
// The chmod after the rename closes both. Same pattern, and the same reason,
|
|
177
|
+
// as ensureAskpassHelper in askpass.js.
|
|
178
|
+
export function writePrivateFile(target, tmp, data) {
|
|
179
|
+
writeFileSync(tmp, data, { mode: 0o600 });
|
|
180
|
+
renameSync(tmp, target);
|
|
181
|
+
// A filesystem with no usable chmod (FAT, some network mounts) must not
|
|
182
|
+
// turn every state write into a crash — the 0700 directory still covers it.
|
|
183
|
+
try { chmodSync(target, 0o600); } catch { /* best-effort */ }
|
|
184
|
+
return target;
|
|
185
|
+
}
|
|
186
|
+
|
|
18
187
|
export const CLAUDE_DIR = process.env.UNSNOOZE_CLAUDE_DIR
|
|
19
188
|
|| process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
|
20
189
|
export const CLAUDE_SETTINGS = join(CLAUDE_DIR, 'settings.json');
|
package/src/doctor.js
CHANGED
|
@@ -10,7 +10,7 @@ import { readFileSync, readdirSync, renameSync, existsSync, unlinkSync, lstatSyn
|
|
|
10
10
|
import { spawnSync } from 'node:child_process';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
12
|
import { join, dirname } from 'node:path';
|
|
13
|
-
import { CLAUDE_SETTINGS, RESUMER_LOCK } from './config.js';
|
|
13
|
+
import { CLAUDE_SETTINGS, RESUMER_LOCK, STATE_DIR, scanStateDir, narrowStateDir } from './config.js';
|
|
14
14
|
import { getMultiplexer } from './multiplexer.js';
|
|
15
15
|
import { makeLogger } from './logger.js';
|
|
16
16
|
import { shouldUseTui, formatDoctorTui } from './tui.js';
|
|
@@ -151,6 +151,26 @@ function daemonRunning() {
|
|
|
151
151
|
* detail, fix? } — `fix` is a machine-actionable descriptor consumed by
|
|
152
152
|
* applyFixes. healthy === no legacy and no health findings.
|
|
153
153
|
*/
|
|
154
|
+
// Both delegate to config.js's walk: the reporter and the automatic repair
|
|
155
|
+
// must never disagree about what counts as exposed, which is exactly what
|
|
156
|
+
// happened when each had its own idea of where to look.
|
|
157
|
+
export function findExposedStateFiles(dir = STATE_DIR, opts = {}) {
|
|
158
|
+
return scanStateDir(dir, opts).map(e => ({ ...e, mode: e.mode.toString(8) }));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Deliberately NOT given runDoctor's `platform`. Every other check uses that
|
|
162
|
+
// to simulate an install target — "what would doctor say on Windows?" — but
|
|
163
|
+
// whether mode bits mean anything is a fact about the filesystem actually
|
|
164
|
+
// under the process, not about the platform being simulated. Passing the
|
|
165
|
+
// simulated one made a `platform: 'darwin'` test raise this finding while
|
|
166
|
+
// running on a Windows runner, where the modes are synthetic and no repair
|
|
167
|
+
// could ever clear it. scanStateDir keeps its own injectable platform for
|
|
168
|
+
// direct unit tests.
|
|
169
|
+
|
|
170
|
+
export function narrowStateModes(entries) {
|
|
171
|
+
return narrowStateDir(entries);
|
|
172
|
+
}
|
|
173
|
+
|
|
154
174
|
export async function runDoctor({
|
|
155
175
|
runner = defaultRunner,
|
|
156
176
|
platform = process.platform,
|
|
@@ -164,6 +184,7 @@ export async function runDoctor({
|
|
|
164
184
|
rcContent = undefined,
|
|
165
185
|
profileContent = undefined,
|
|
166
186
|
autostartDir = null,
|
|
187
|
+
stateDir = STATE_DIR,
|
|
167
188
|
} = {}) {
|
|
168
189
|
const findings = [];
|
|
169
190
|
|
|
@@ -220,6 +241,22 @@ export async function runDoctor({
|
|
|
220
241
|
detail: ' run: unsnooze install --yes (then: exec $SHELL)',
|
|
221
242
|
});
|
|
222
243
|
}
|
|
244
|
+
// The state dir is repaired on every write, but chmod is swallowed there —
|
|
245
|
+
// a filesystem that cannot honour it (FAT, some network mounts) would
|
|
246
|
+
// otherwise leave everything world-readable with nothing ever saying so.
|
|
247
|
+
// This is the place that says so, and --fix retries the chmod.
|
|
248
|
+
const exposed = findExposedStateFiles(stateDir);
|
|
249
|
+
if (exposed.length) {
|
|
250
|
+
findings.push({
|
|
251
|
+
id: 'state-permissions', kind: 'health',
|
|
252
|
+
title: `${exposed.length} file(s) in ${stateDir} are readable by other users`,
|
|
253
|
+
detail: exposed.slice(0, 8).map(e => ` ${e.mode} ${e.rel}`).join('\n')
|
|
254
|
+
+ (exposed.length > 8 ? `\n …and ${exposed.length - 8} more` : '')
|
|
255
|
+
+ '\n run: unsnooze doctor --fix',
|
|
256
|
+
fix: { action: 'narrow-state-modes', entries: exposed },
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
223
260
|
let muxOk = false;
|
|
224
261
|
try { muxOk = !!mux.available(); } catch { muxOk = false; }
|
|
225
262
|
if (!muxOk) {
|
|
@@ -321,6 +358,11 @@ export async function applyFixes(report, {
|
|
|
321
358
|
} else {
|
|
322
359
|
actions.push({ action: 'error', detail: 'could not determine a PATH that finds the multiplexer — run `unsnooze install --daemon` from your shell' });
|
|
323
360
|
}
|
|
361
|
+
} else if (fix.action === 'narrow-state-modes') {
|
|
362
|
+
const r = narrowStateModes(fix.entries || []);
|
|
363
|
+
actions.push({ action: 'narrowed-modes', ...r });
|
|
364
|
+
log(`doctor: narrowed ${r.fixed} state-dir path(s) to owner-only `
|
|
365
|
+
+ `(${r.refused} refused, ${r.ineffective} ignored by the filesystem)`);
|
|
324
366
|
} else if (fix.action === 'archive-dir') {
|
|
325
367
|
let target = `${fix.dir}.bak`;
|
|
326
368
|
if (existsSync(target)) target = `${fix.dir}.bak.${now}`;
|
|
@@ -379,6 +421,16 @@ export async function cmdDoctor(rest = [], deps = {}) {
|
|
|
379
421
|
if (a.action === 'killed') print(` ✓ stopped csg process ${a.pid}`);
|
|
380
422
|
else if (a.action === 'removed-unit') print(` ✓ removed ${a.unit}`);
|
|
381
423
|
else if (a.action === 'archived') print(` ✓ archived ${a.from} → ${a.to}`);
|
|
424
|
+
else if (a.action === 'narrowed-modes') {
|
|
425
|
+
if (a.fixed) print(` ✓ made ${a.fixed} state-dir path(s) owner-only`);
|
|
426
|
+
// Distinguish "cannot" from "did not": a filesystem with no POSIX
|
|
427
|
+
// modes reports success and changes nothing, and telling the user to
|
|
428
|
+
// try again would be advice that can never work.
|
|
429
|
+
if (a.ineffective) {
|
|
430
|
+
print(` · ${a.ineffective} path(s) unchanged — this filesystem does not enforce POSIX permissions`);
|
|
431
|
+
}
|
|
432
|
+
if (a.refused) print(` ✗ ${a.refused} path(s) could not be changed (permission denied)`);
|
|
433
|
+
}
|
|
382
434
|
else if (a.action === 'error') print(` ! ${a.detail}`);
|
|
383
435
|
}
|
|
384
436
|
const pkg = legacy.find(f => f.id === 'csg-package');
|
|
@@ -386,8 +438,14 @@ export async function cmdDoctor(rest = [], deps = {}) {
|
|
|
386
438
|
return 0;
|
|
387
439
|
}
|
|
388
440
|
|
|
441
|
+
// Offer --fix whenever anything reported is actually machine-fixable, not
|
|
442
|
+
// only for csg leftovers: a health finding that carries a `fix` (state
|
|
443
|
+
// permissions) was otherwise a dead end for the reader.
|
|
444
|
+
const fixable = report.findings.some(f => f.fix);
|
|
389
445
|
print(legacy.length
|
|
390
446
|
? '\nRun `unsnooze doctor --fix` to stop csg processes, remove its autostart, and archive its state.'
|
|
391
|
-
:
|
|
447
|
+
: fixable
|
|
448
|
+
? '\nRun `unsnooze doctor --fix` to apply the repairs above.'
|
|
449
|
+
: '\nSee the hints above to finish the install.');
|
|
392
450
|
return 1;
|
|
393
451
|
}
|
package/src/fleet.js
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
// allowlisted host tokens, closed verb set, sentinel-framed JSON, and
|
|
6
6
|
// control-char-stripped ingest.
|
|
7
7
|
import { join } from 'node:path';
|
|
8
|
-
import { readFileSync,
|
|
8
|
+
import { readFileSync, existsSync as fsExistsSync } from 'node:fs';
|
|
9
9
|
import { spawn, execFileSync, spawnSync } from 'node:child_process';
|
|
10
|
-
import { STATE_DIR } from './config.js';
|
|
10
|
+
import { STATE_DIR, ensureStateDir, writePrivateFile } from './config.js';
|
|
11
11
|
import { colors, shouldUseTui, makeTable, logoBlock, badge } from './tui.js';
|
|
12
12
|
import { ensureAskpassHelper, resolveSecret, readSecret } from './askpass.js';
|
|
13
13
|
import { UNSNOOZE_BIN } from './spawn.js';
|
|
@@ -288,10 +288,12 @@ export function readHosts() {
|
|
|
288
288
|
}
|
|
289
289
|
|
|
290
290
|
export function writeHosts(hosts) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
291
|
+
ensureStateDir();
|
|
292
|
+
// Owner-only: a `command` source's --cmd string is whatever the user
|
|
293
|
+
// pointed at their secret store, and a keychain source names the
|
|
294
|
+
// service/account to look up.
|
|
295
|
+
writePrivateFile(HOSTS_FILE, HOSTS_FILE + `.tmp.${process.pid}`,
|
|
296
|
+
JSON.stringify(hosts, null, 2) + '\n');
|
|
295
297
|
}
|
|
296
298
|
|
|
297
299
|
function parseFlags(argv) {
|
|
@@ -740,10 +742,11 @@ export function readFleetCache() {
|
|
|
740
742
|
}
|
|
741
743
|
|
|
742
744
|
export function writeFleetCache(results) {
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
745
|
+
ensureStateDir();
|
|
746
|
+
// Owner-only: the cache mirrors every remote host's cwd paths and
|
|
747
|
+
// queued-prompt previews.
|
|
748
|
+
writePrivateFile(FLEET_CACHE_FILE, FLEET_CACHE_FILE + `.tmp.${process.pid}`,
|
|
749
|
+
JSON.stringify(results, null, 2) + '\n');
|
|
747
750
|
}
|
|
748
751
|
|
|
749
752
|
// I1: a `prompt`-source host at a real interactive TTY is the one case
|
package/src/hook.js
CHANGED
|
@@ -89,9 +89,10 @@ export async function runHook(rest = []) {
|
|
|
89
89
|
// Seconds-scale problem — leave a marker for the pane's monitor, do NOT
|
|
90
90
|
// record in state.json.
|
|
91
91
|
if (pane) {
|
|
92
|
-
mkdirSync(EVENTS_DIR, { recursive: true });
|
|
92
|
+
mkdirSync(EVENTS_DIR, { recursive: true, mode: 0o700 });
|
|
93
93
|
writeFileSync(join(EVENTS_DIR, `${addressHash({ mux: muxName, paneOwner, pane })}.json`),
|
|
94
|
-
JSON.stringify({ mux: muxName, paneOwner, pane, kind, at: Date.now(), payload: { error: payload.error ?? null } })
|
|
94
|
+
JSON.stringify({ mux: muxName, paneOwner, pane, kind, at: Date.now(), payload: { error: payload.error ?? null } }),
|
|
95
|
+
{ mode: 0o600 });
|
|
95
96
|
}
|
|
96
97
|
return 0;
|
|
97
98
|
}
|
package/src/install.js
CHANGED
|
@@ -16,6 +16,7 @@ import { CLAUDE_SETTINGS, STATE_DIR } from './config.js';
|
|
|
16
16
|
import { getConfig, configFileExists } from './settings.js';
|
|
17
17
|
import { xmlEscape } from './notify.js';
|
|
18
18
|
import { installGrokHooks, uninstallGrokHooks } from './agents/grok.js';
|
|
19
|
+
import { getAgent } from './agents/index.js';
|
|
19
20
|
import { findCsgProcesses, findCsgAutostarts } from './doctor.js';
|
|
20
21
|
import { UNSNOOZE_BIN, stopResumer } from './spawn.js';
|
|
21
22
|
import { uninstallStatuslineShim } from './usage.js';
|
|
@@ -115,18 +116,29 @@ export function removeHookFromSettings(settingsJson) {
|
|
|
115
116
|
|
|
116
117
|
// --- zshrc block management ---
|
|
117
118
|
|
|
119
|
+
// Which shell commands an agent's wrappers shadow. Normally exactly the id,
|
|
120
|
+
// because the id IS the command users type. Cursor breaks that: its id is
|
|
121
|
+
// `cursor` but its command is `cursor-agent`, and the bare `cursor` belongs to
|
|
122
|
+
// the IDE launcher (`cursor .`) which must never be shadowed. An adapter names
|
|
123
|
+
// the commands to wrap via `wrapperNames`; the `_run` argument stays the id.
|
|
124
|
+
export function wrapperNamesFor(id) {
|
|
125
|
+
const agent = getAgent(id);
|
|
126
|
+
// getAgent falls back to claude for unknown ids — never borrow its names.
|
|
127
|
+
return agent.id === id && agent.wrapperNames?.length ? agent.wrapperNames : [id];
|
|
128
|
+
}
|
|
129
|
+
|
|
118
130
|
export function wrapperBlock(agents = ['claude']) {
|
|
119
131
|
// The missing-file guard is load-bearing: if unsnooze is ever uninstalled,
|
|
120
132
|
// moved, or renamed without cleaning the rc file, the wrapper must degrade
|
|
121
133
|
// to the plain CLI — never brick the user's `claude`/`codex` command.
|
|
122
|
-
const fns = agents.
|
|
123
|
-
${
|
|
134
|
+
const fns = agents.flatMap(id => wrapperNamesFor(id).map(name => `unalias ${name} 2>/dev/null || true
|
|
135
|
+
${name}() {
|
|
124
136
|
if [ "\${UNSNOOZE_ACTIVE}" = "1" ] || [ ! -f "${UNSNOOZE_BIN}" ]; then
|
|
125
|
-
command ${
|
|
137
|
+
command ${name} "$@"
|
|
126
138
|
return $?
|
|
127
139
|
fi
|
|
128
140
|
node "${UNSNOOZE_BIN}" _run ${id} "$@"
|
|
129
|
-
}`).join('\n');
|
|
141
|
+
}`)).join('\n');
|
|
130
142
|
return `${FENCE_OPEN}
|
|
131
143
|
# unsnooze wrappers: route every interactive launch of the CLIs below through
|
|
132
144
|
# unsnooze so limit stops are recorded and auto-resumed.
|
|
@@ -142,16 +154,16 @@ ${FENCE_CLOSE}`;
|
|
|
142
154
|
// PowerShell comments are `#`, so the same fence markers work and
|
|
143
155
|
// stripFencedBlock() removes it unchanged.
|
|
144
156
|
export function powershellWrapperBlock(agents = ['claude'], bin = UNSNOOZE_BIN) {
|
|
145
|
-
const fns = agents.
|
|
146
|
-
function ${
|
|
157
|
+
const fns = agents.flatMap(id => wrapperNamesFor(id).map(name => `Remove-Item -Path Alias:${name} -Force -ErrorAction SilentlyContinue
|
|
158
|
+
function ${name} {
|
|
147
159
|
if ($env:UNSNOOZE_ACTIVE -eq '1' -or -not (Test-Path -LiteralPath '${bin}')) {
|
|
148
|
-
$real = Get-Command ${
|
|
160
|
+
$real = Get-Command ${name} -CommandType Application -ErrorAction SilentlyContinue |
|
|
149
161
|
Select-Object -First 1
|
|
150
|
-
if ($real) { & $real.Source @args } else { Write-Error '${
|
|
162
|
+
if ($real) { & $real.Source @args } else { Write-Error '${name}: not found' }
|
|
151
163
|
return
|
|
152
164
|
}
|
|
153
165
|
& node '${bin}' _run ${id} @args
|
|
154
|
-
}`).join('\n');
|
|
166
|
+
}`)).join('\n');
|
|
155
167
|
return `${FENCE_OPEN}
|
|
156
168
|
# unsnooze wrappers: route every interactive launch of the CLIs below through
|
|
157
169
|
# unsnooze so limit stops are recorded and auto-resumed.
|
|
@@ -502,7 +514,7 @@ export function uninstallDaemonAutostart({ platform = process.platform, dir = nu
|
|
|
502
514
|
// --- commands ---
|
|
503
515
|
|
|
504
516
|
export function enabledAgents() {
|
|
505
|
-
return ['claude', 'codex', 'grok', 'qwen', 'kimi', 'opencode', 'agy'].filter(id => getConfig(`agents.${id}`));
|
|
517
|
+
return ['claude', 'codex', 'grok', 'qwen', 'kimi', 'opencode', 'agy', 'cursor'].filter(id => getConfig(`agents.${id}`));
|
|
506
518
|
}
|
|
507
519
|
|
|
508
520
|
// Qwen keeps Claude-shaped hooks in its own settings.json — reuse the same
|
package/src/lease.js
CHANGED
|
@@ -43,10 +43,10 @@ function leasePath(address, leaseId) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export function writeLease(lease) {
|
|
46
|
-
mkdirSync(LEASES_DIR, { recursive: true });
|
|
46
|
+
mkdirSync(LEASES_DIR, { recursive: true, mode: 0o700 });
|
|
47
47
|
const path = leasePath(lease, lease.leaseId);
|
|
48
48
|
const tmp = `${path}.tmp.${process.pid}.${randomUUID()}`;
|
|
49
|
-
writeFileSync(tmp, JSON.stringify(lease));
|
|
49
|
+
writeFileSync(tmp, JSON.stringify(lease), { mode: 0o600 });
|
|
50
50
|
renameSync(tmp, path);
|
|
51
51
|
return lease;
|
|
52
52
|
}
|
package/src/logger.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { appendFileSync,
|
|
1
|
+
import { appendFileSync, statSync, renameSync, copyFileSync, truncateSync } from 'node:fs';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
|
-
import { LOG_FILE } from './config.js';
|
|
3
|
+
import { LOG_FILE, ensureStateDir } from './config.js';
|
|
4
4
|
|
|
5
5
|
// Logs must never grow unbounded (a single upgrade-window crash-loop once
|
|
6
6
|
// produced a 10.3 MB daemon.log). One rotated generation is kept: file → .1.
|
|
@@ -46,11 +46,13 @@ export function log(component, message) {
|
|
|
46
46
|
const line = `${new Date().toISOString()} [${component}] ${message}\n`;
|
|
47
47
|
try {
|
|
48
48
|
if (!dirReady) {
|
|
49
|
-
|
|
49
|
+
ensureStateDir(dirname(LOG_FILE));
|
|
50
50
|
dirReady = true;
|
|
51
51
|
}
|
|
52
52
|
rotateIfLarge(LOG_FILE);
|
|
53
|
-
|
|
53
|
+
// mode applies only when append CREATES the file — an existing log keeps
|
|
54
|
+
// whatever it has, and the 0700 dir is what covers it either way.
|
|
55
|
+
appendFileSync(LOG_FILE, line, { mode: 0o600 });
|
|
54
56
|
} catch {
|
|
55
57
|
// Logging must never crash the hook or monitor paths.
|
|
56
58
|
}
|
package/src/monitor.js
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
EVENT_MARKER_TTL_MS, OVERLOAD_BACKOFF_S, OVERLOAD_JITTER,
|
|
17
17
|
PROBE_INTERVAL_MS, RESET_MARGIN_MS, LEASE_GRACE_MS,
|
|
18
18
|
} from './config.js';
|
|
19
|
-
import { detectLimit, isBusy, overloadMatch } from './patterns.js';
|
|
19
|
+
import { detectLimit, isBusy, overloadMatch, modelRemedy } from './patterns.js';
|
|
20
20
|
import { getAgent } from './agents/index.js';
|
|
21
21
|
import { getConfig } from './settings.js';
|
|
22
22
|
import { notify } from './notify.js';
|
|
@@ -208,7 +208,7 @@ export function createMonitor({
|
|
|
208
208
|
log(`pane ${pane}: limit recorded (${resolved.limitType}, via ${detectedVia}), resets ${new Date(at).toISOString()} (${source})`);
|
|
209
209
|
if (resolved.limitType === 'model') {
|
|
210
210
|
notifier(`${agent.name} hit a model limit — needs you`,
|
|
211
|
-
`${cwd} —
|
|
211
|
+
`${cwd} — ${modelRemedy(agent)}; unsnooze wakes it once the banner clears`,
|
|
212
212
|
{ context: notifyCtx });
|
|
213
213
|
} else {
|
|
214
214
|
notifier(`${agent.name} hit a usage limit`, `${cwd} — auto-resume at ${new Date(at).toLocaleTimeString()}`, { context: notifyCtx });
|
|
@@ -96,9 +96,11 @@ export function createHeadless({
|
|
|
96
96
|
// A revive is just a detached child. Its output goes to a per-session log
|
|
97
97
|
// because there is no scrollback to read it out of later.
|
|
98
98
|
async newWindow(sessionName, cwd, launchSpec) {
|
|
99
|
-
|
|
99
|
+
// 0700/0600: this log is the entire stdout+stderr of an unattended
|
|
100
|
+
// agent run — the most revealing thing under the state dir.
|
|
101
|
+
mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
100
102
|
const logPath = join(logDir, `${sessionName}.log`);
|
|
101
|
-
const fd = openSync(logPath, 'a');
|
|
103
|
+
const fd = openSync(logPath, 'a', 0o600);
|
|
102
104
|
const child = spawner(launchSpec.file, launchSpec.args || [], {
|
|
103
105
|
cwd,
|
|
104
106
|
detached: true,
|
package/src/mux-sessions.js
CHANGED
|
@@ -29,10 +29,10 @@ export function recordOwnedSession({ mux, name }) {
|
|
|
29
29
|
if (!mux || !name) return null;
|
|
30
30
|
const record = { mux, name, createdAt: Date.now(), pid: process.pid };
|
|
31
31
|
try {
|
|
32
|
-
mkdirSync(SESSIONS_DIR(), { recursive: true });
|
|
32
|
+
mkdirSync(SESSIONS_DIR(), { recursive: true, mode: 0o700 });
|
|
33
33
|
const path = recordPath(mux, name);
|
|
34
34
|
const tmp = `${path}.tmp.${process.pid}.${randomUUID()}`;
|
|
35
|
-
writeFileSync(tmp, JSON.stringify(record));
|
|
35
|
+
writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 });
|
|
36
36
|
renameSync(tmp, path);
|
|
37
37
|
return record;
|
|
38
38
|
} catch {
|
package/src/patterns.js
CHANGED
|
@@ -148,6 +148,13 @@ export function detectLimit(text, tailLines = 12, sets = claudePatterns) {
|
|
|
148
148
|
return { hit: true, limitType, resetLine };
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
// What a user can actually do about a model limit. There is no reset time to
|
|
152
|
+
// wait for, so the notification must name a real remedy — and the remedy is
|
|
153
|
+
// per-CLI (claude has /usage-credits, cursor does not).
|
|
154
|
+
export function modelRemedy(agent) {
|
|
155
|
+
return agent?.modelRemedy || 'switch models or add credits';
|
|
156
|
+
}
|
|
157
|
+
|
|
151
158
|
function tail(text, n = 12) {
|
|
152
159
|
return contentLines(text, n);
|
|
153
160
|
}
|
package/src/resumer.js
CHANGED
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
// fires on the next tick), then re-opens/continues every due session.
|
|
5
5
|
// Exits when no non-terminal records remain; the next limit event respawns it.
|
|
6
6
|
|
|
7
|
-
import { writeFileSync, readFileSync, unlinkSync
|
|
7
|
+
import { writeFileSync, readFileSync, unlinkSync } from 'node:fs';
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
9
|
import { spawnSync } from 'node:child_process';
|
|
10
10
|
import { getMultiplexer, backendCanType } from './multiplexer.js';
|
|
11
11
|
import {
|
|
12
|
-
RESUMER_LOCK,
|
|
12
|
+
RESUMER_LOCK, POLL_INTERVAL_MS, STAGGER_MS, VERIFY_DELAY_MS, ensureStateDir,
|
|
13
13
|
BUSY_DEFER_MS, MAX_BUSY_DEFERS, MAX_RESUME_ATTEMPTS, READY_TIMEOUT_MS,
|
|
14
14
|
CAPTURE_LINES, PANE_SCAN_LINES, RESUME_SESSION_NAME,
|
|
15
15
|
RESET_MARGIN_MS, FALLBACK_RESET_MS, PROBE_INTERVAL_MS, PROBE_MAX_MS,
|
|
16
16
|
} from './config.js';
|
|
17
|
-
import { detectLimit, isBusy } from './patterns.js';
|
|
17
|
+
import { detectLimit, isBusy, modelRemedy } from './patterns.js';
|
|
18
18
|
import { getAgent } from './agents/index.js';
|
|
19
19
|
import { parseResetTime, resetAtMs, nextProbeDelayMs } from './time-parser.js';
|
|
20
20
|
import {
|
|
@@ -64,10 +64,10 @@ function defaultIsResumer(pid) {
|
|
|
64
64
|
// read-check-write window. A held lock is honored only when its pid is alive
|
|
65
65
|
// AND looks like a resumer; stale/garbage/recycled locks are replaced.
|
|
66
66
|
export function acquireSingleton({ isResumer = defaultIsResumer } = {}) {
|
|
67
|
-
|
|
67
|
+
ensureStateDir();
|
|
68
68
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
69
69
|
try {
|
|
70
|
-
writeFileSync(RESUMER_LOCK, String(process.pid), { flag: 'wx' });
|
|
70
|
+
writeFileSync(RESUMER_LOCK, String(process.pid), { flag: 'wx', mode: 0o600 });
|
|
71
71
|
return true;
|
|
72
72
|
} catch { /* lock exists — inspect the holder below */ }
|
|
73
73
|
let pid = NaN;
|
|
@@ -372,7 +372,7 @@ function rescheduleProbe(rec, now = Date.now()) {
|
|
|
372
372
|
}, { expect: ['stopped'] });
|
|
373
373
|
log(`${key}: model limit still active at probe ceiling — needs a human`);
|
|
374
374
|
notify('unsnooze: model limit needs you ⚠️',
|
|
375
|
-
`${rec.cwd}: still limited after probing —
|
|
375
|
+
`${rec.cwd}: still limited after probing — ${modelRemedy(getAgent(rec.agent))}`,
|
|
376
376
|
{ context: ctxOf(rec), priority: 4 });
|
|
377
377
|
return 'held';
|
|
378
378
|
}
|
package/src/sessions.js
CHANGED
|
@@ -40,8 +40,26 @@ export function latestSessionId(cwd, aroundTs = null) {
|
|
|
40
40
|
return best ? best.id : null;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// sessionId lands here straight off a hook payload / transcript record and is
|
|
44
|
+
// used as a path component — the same untrusted-value-as-filename shape the
|
|
45
|
+
// statusline shim had, on the channel everyone has enabled. dashCwd already
|
|
46
|
+
// sanitizes the cwd half; a '/' or a '..' in the id half would walk this read
|
|
47
|
+
// out of the projects tree.
|
|
48
|
+
//
|
|
49
|
+
// Deliberately a filename-shape check rather than UUID_RE: that regex is the
|
|
50
|
+
// right gate for *discovering* transcripts (it reads Claude's own directory
|
|
51
|
+
// listing) but the wrong one here, where the id arrives from an agent whose
|
|
52
|
+
// naming we do not own. Requiring a uuid would silently turn off the context
|
|
53
|
+
// guard for any session named otherwise; requiring a safe component closes
|
|
54
|
+
// the traversal either way. '.' and '..' are harmless: the '.jsonl' suffix
|
|
55
|
+
// makes them ordinary filenames. Returns null for anything else, which every
|
|
56
|
+
// caller already reports as "no transcript".
|
|
57
|
+
const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
58
|
+
|
|
43
59
|
export function transcriptPath(cwd, sessionId, { claudeDir = CLAUDE_DIR } = {}) {
|
|
44
|
-
|
|
60
|
+
const id = String(sessionId ?? '');
|
|
61
|
+
if (!SAFE_SESSION_ID_RE.test(id)) return null;
|
|
62
|
+
return join(claudeDir, 'projects', dashCwd(cwd), `${id}.jsonl`);
|
|
45
63
|
}
|
|
46
64
|
|
|
47
65
|
export function claudeRecordEnv(env = process.env) {
|