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/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, writeFileSync, renameSync, mkdirSync } from 'node:fs';
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
- mkdirSync(dirname(path), { recursive: true });
271
- const tmp = join(dirname(path), `.config.tmp.${process.pid}`);
272
- writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n');
273
- renameSync(tmp, path);
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') { mkdirSync(STATE_DIR, { recursive: true }); continue; }
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 age = Date.now() - statSync(LOCK_DIR).mtimeMs;
64
- if (age > STALE_LOCK_MS && lockHolderAlive() !== true) {
65
- rmSync(LOCK_DIR, { recursive: true, force: true }); // steal from a dead/unknown holder
66
- log(`stole stale lock (age ${Math.round(age)}ms)`);
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
- log(`CORRUPT state.json quarantined to ${quarantine}: ${err.message}`);
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
- mkdirSync(STATE_DIR, { recursive: true });
210
+ ensureStateDir();
141
211
  acquireLock();
142
212
  try {
143
213
  const state = readState();
144
214
  const result = mutator(state) ?? state;
145
- const tmp = join(STATE_DIR, `.state.tmp.${process.pid}`);
146
- writeFileSync(tmp, JSON.stringify(result, null, 2));
147
- renameSync(tmp, STATE_FILE);
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 semantics.)
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 = workspaceFingerprint(record.cwd);
324
+ record.workspace = fingerprint;
234
325
  }
235
326
  const key = record.sessionId || `pane:${addressHash(record)}:${record.detectedAt}`;
236
327
  applied = { ...record, key };
@@ -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, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs';
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
- mkdirSync(dirname(path), { recursive: true });
35
- const tmp = join(dirname(path), `.update-check.tmp.${process.pid}`);
36
- writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n');
37
- renameSync(tmp, path);
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
- mkdirSync(dirname(path), { recursive: true });
539
- const tmp = join(dirname(path), `.usage.tmp.${process.pid}`);
540
- writeFileSync(tmp, JSON.stringify(store));
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
- try { fs.mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
1243
- const sid = data.session_id || data.sessionId || 'unknown';
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) {