unsnooze 1.4.0 → 1.6.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 CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.0 — 2026-07-12
4
+
5
+ - **Stale-workspace guard** (`workspaceGuard`: `off` | `inform` | `pause`,
6
+ default `inform`): the repo's HEAD + dirty state are fingerprinted when a
7
+ session stops and re-checked at wake. `inform` resumes with a "workspace
8
+ changed while you slept — re-read before acting" note in the wake message;
9
+ `pause` holds the session (desktop notification, `workspace changed` marker
10
+ in status) until `unsnooze resume-now`, which prints the diff stat first.
11
+ Non-git directories are unaffected. Suggested by r/codex feedback.
12
+
13
+ ## 1.5.0 — 2026-07-12
14
+
15
+ - **`unsnooze update`**: one command to update unsnooze itself — runs
16
+ `npm install -g unsnooze@latest` and immediately prints the new version's
17
+ changelog. Update notices and the daemon toast now say `run: unsnooze
18
+ update` instead of the raw npm command.
19
+
3
20
  ## 1.4.0 — 2026-07-12
4
21
 
5
22
  - **Update notices**: unsnooze now checks the npm registry (at most once a
package/README.md CHANGED
@@ -133,6 +133,7 @@ unsnooze message <id> "text" # per-session wake message (--clear to reset)
133
133
  unsnooze config list # settings (see below)
134
134
  unsnooze config set <k> <v> # e.g. autoResume off
135
135
  unsnooze logs [-f] # what unsnooze has been doing
136
+ unsnooze update # update unsnooze itself
136
137
  unsnooze daemon # persistent GUI-session watcher (usually run
137
138
  # by launchd/systemd via `install --daemon`)
138
139
  unsnooze report [agent] # capture a pane to report an undetected banner
@@ -154,6 +155,7 @@ unsnooze help # full command list (also -h / --help)
154
155
  | `resumeMessage` | *"Continue where you left off…"* | The message sent to wake a session. Override it for a single session with `unsnooze message <id> "…"` — visible in `unsnooze status`. |
155
156
  | `resumeMessages.claude` / `.codex` / `.grok` | `""` | Per-agent override of `resumeMessage`. Empty = use the global message; clear one with `unsnooze config set resumeMessages.claude ""`. |
156
157
  | `agents.claude` / `agents.codex` / `agents.grok` | `true` / `true` / `false` | Which CLIs are guarded. |
158
+ | `workspaceGuard` | `inform` | Repo changed while a session slept? `inform` wakes it with a heads-up in the message; `pause` holds it (desktop notification, diff shown on `resume-now`); `off` disables. |
157
159
  | `updateCheck` | `true` | Daily new-version check (a plain GET to the npm registry, nothing identifying is sent). Notices after commands + one desktop toast per version. |
158
160
 
159
161
  Every setting also has a `UNSNOOZE_*` env override (see `src/settings.js`), and
@@ -177,6 +179,11 @@ all timings/paths are tunable via `UNSNOOZE_*` env vars (see `src/config.js`).
177
179
  block the CLI).
178
180
  - **Overload ≠ limit**: 5xx/529/429 transient errors take a seconds-scale
179
181
  backoff path ([30,60,120,240,300]s ± jitter) and never enter the ledger.
182
+ - **Stale-workspace guard**: the repo's HEAD + dirty state are fingerprinted
183
+ when a session stops. If another session (or you) changed the repo before
184
+ the wake, the resumed agent is told to re-read before acting — or, with
185
+ `workspaceGuard=pause`, the session is held and `unsnooze resume-now`
186
+ shows the diff first.
180
187
 
181
188
  ## Requirements
182
189
 
@@ -226,12 +233,21 @@ verifies the limit actually lifted. It replaces the 4am alarm, not the limit.
226
233
 
227
234
  ### How do I update, and how do I know when to?
228
235
 
229
- `npm i -g unsnooze`. unsnooze checks the npm registry at most once a day and
236
+ `unsnooze update` (or `npm i -g unsnooze`). unsnooze checks the npm registry at most once a day and
230
237
  tells you when a newer version exists (a line after CLI commands, plus one
231
238
  desktop notification per version); after updating, the next command shows a
232
239
  short "what's new" from the changelog. It's a plain registry GET with nothing
233
240
  identifying — turn it off with `unsnooze config set updateCheck off`.
234
241
 
242
+ ### What if another session changed the repo while one was stopped?
243
+
244
+ unsnooze fingerprints the workspace (HEAD + uncommitted state) at stop time
245
+ and re-checks at wake. By default the session still resumes, but the wake
246
+ message includes what changed ("HEAD abc1234 → def5678 — re-read before
247
+ continuing"). Set `workspaceGuard` to `pause` to hold such sessions for a
248
+ manual `unsnooze resume-now` (which prints the diff stat), or `off` to
249
+ disable the check.
250
+
235
251
  ### Does it work if my laptop was asleep or the terminal was closed?
236
252
 
237
253
  Yes — reset times are stored as absolute timestamps and checked every 30
package/bin/unsnooze.js CHANGED
@@ -82,6 +82,10 @@ async function main() {
82
82
  const { runResumer } = await import('../src/resumer.js');
83
83
  return runResumer();
84
84
  }
85
+ case 'update': {
86
+ const { runSelfUpdate } = await import('../src/update-check.js');
87
+ return runSelfUpdate();
88
+ }
85
89
  case '_update-check': {
86
90
  const { runUpdateCheck } = await import('../src/update-check.js');
87
91
  return runUpdateCheck();
@@ -116,6 +120,7 @@ Usage:
116
120
  unsnooze cancel [id|--all] stop tracking session(s)
117
121
  unsnooze message <id|--all> <t> set a per-session wake message (--clear to reset)
118
122
  unsnooze logs [-f] show (or follow) the unsnooze log
123
+ unsnooze update update unsnooze itself to the latest version
119
124
  unsnooze daemon persistent watcher for GUI sessions (VS Code
120
125
  extension, desktop apps) — no tmux needed to
121
126
  detect; revival still opens in tmux
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Auto-resume Claude Code, Codex CLI & Grok sessions in tmux when the usage limit (5-hour or weekly) resets. Wakes every limit-stopped AI coding session automatically.",
5
5
  "keywords": [
6
6
  "claude",
package/src/cli.js CHANGED
@@ -33,8 +33,11 @@ export function cmdStatus() {
33
33
  const msg = s.resumeMessage
34
34
  ? ` · msg: "${s.resumeMessage.length > 44 ? s.resumeMessage.slice(0, 44) + '…' : s.resumeMessage}"`
35
35
  : '';
36
+ const hold = s.workspaceHold
37
+ ? ` · workspace changed (${s.holdReason ?? '?'}) — resume-now to wake`
38
+ : '';
36
39
  console.log(` [${s.status.toUpperCase().padEnd(9)}] ${id} ${(s.agent || 'claude').padEnd(6)} ${s.limitType?.padEnd(7) ?? 'unknown'} ${s.cwd}`);
37
- console.log(` pane ${s.pane ?? '-'} · via ${origin} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}${msg}`);
40
+ console.log(` pane ${s.pane ?? '-'} · via ${origin} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}${msg}${hold}`);
38
41
  }
39
42
  return 0;
40
43
  }
@@ -46,15 +49,30 @@ function selectKeys(state, idOrAll, statuses = ['stopped']) {
46
49
  return match.map(s => s.key);
47
50
  }
48
51
 
49
- export function cmdResumeNow(idOrAll) {
52
+ export async function cmdResumeNow(idOrAll) {
50
53
  const state = readState();
51
54
  const keys = selectKeys(state, idOrAll);
52
55
  if (keys.length === 0) { console.log('unsnooze: no matching stopped sessions.'); return 1; }
56
+ // The commenter's "show me the diff": held records print what moved since
57
+ // the stop-time baseline before we wake them. Best-effort, never blocking.
58
+ for (const key of keys) {
59
+ const rec = state.sessions[key];
60
+ if (rec?.workspaceHold && rec.workspace?.head && rec.cwd) {
61
+ try {
62
+ const { execFileSync } = await import('node:child_process');
63
+ const stat = execFileSync('git', ['-C', rec.cwd, 'diff', '--stat', `${rec.workspace.head}..HEAD`],
64
+ { stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).toString().trim();
65
+ if (stat) console.log(`unsnooze: workspace changes since ${key.slice(0, 12)} stopped:\n${stat}`);
66
+ } catch { /* repo gone or git unhappy — proceed anyway */ }
67
+ }
68
+ }
53
69
  updateState(s => {
54
70
  for (const key of keys) {
55
71
  if (s.sessions[key]) {
56
72
  s.sessions[key].resetAt = Date.now();
57
- s.sessions[key].manual = true; // explicit user action beats autoResume=off
73
+ s.sessions[key].manual = true; // explicit user action beats autoResume=off + workspaceGuard
74
+ delete s.sessions[key].workspaceHold;
75
+ delete s.sessions[key].holdReason;
58
76
  }
59
77
  }
60
78
  });
package/src/resumer.js CHANGED
@@ -18,6 +18,7 @@ import { getAgent } from './agents/index.js';
18
18
  import { parseResetTime, resetAtMs } from './time-parser.js';
19
19
  import { readState, updateState, setStatus, dueSessions, activeStopped } from './state.js';
20
20
  import { getConfig, resolveResumeMessage } from './settings.js';
21
+ import { workspaceFingerprint, workspaceChanged, describeChange } from './workspace.js';
21
22
  import { notify } from './notify.js';
22
23
  import { UNSNOOZE_BIN } from './spawn.js';
23
24
  import { makeLogger } from './logger.js';
@@ -51,7 +52,8 @@ export function releaseSingleton() {
51
52
  // it's off. Stops stay tracked either way.
52
53
  export function dueForDispatch(now = Date.now()) {
53
54
  const auto = getConfig('autoResume');
54
- return dueSessions(now).filter(s => auto || s.manual);
55
+ // workspaceHold: guarded sessions wait for an explicit resume-now (manual).
56
+ return dueSessions(now).filter(s => (auto || s.manual) && (!s.workspaceHold || s.manual));
55
57
  }
56
58
 
57
59
  function shellQuote(arg) {
@@ -69,7 +71,7 @@ function selfCommand() {
69
71
 
70
72
  // Decide how to act on one due record. Pure-ish; tmux injectable.
71
73
  // Returns: 'sent' | 'reopened' | 'deferred' | 'skip' | 'failed'
72
- export async function dispatchOne(rec, { tmux = realTmux, resumeMessage, selfCmd = selfCommand() } = {}) {
74
+ export async function dispatchOne(rec, { tmux = realTmux, resumeMessage, selfCmd = selfCommand(), fingerprint = workspaceFingerprint, notifier = notify } = {}) {
73
75
  const key = rec.key;
74
76
  const agent = getAgent(rec.agent);
75
77
  // Wake-message precedence: per-session (`unsnooze message <id> "..."`) →
@@ -77,6 +79,24 @@ export async function dispatchOne(rec, { tmux = realTmux, resumeMessage, selfCmd
77
79
  // both the live-pane sendText and the argv reopen path.
78
80
  resumeMessage = rec.resumeMessage ?? resumeMessage ?? resolveResumeMessage(agent.id);
79
81
 
82
+ // Stale-workspace guard: another session (or a human) may have moved the
83
+ // repo while this one slept. Manual resumes (resume-now) always proceed.
84
+ const guardMode = getConfig('workspaceGuard');
85
+ if (rec.workspace && guardMode !== 'off' && !rec.manual) {
86
+ const change = workspaceChanged(rec, fingerprint(rec.cwd));
87
+ if (change) {
88
+ const desc = describeChange(change);
89
+ if (guardMode === 'pause') {
90
+ setStatus(key, 'stopped', { workspaceHold: true, holdReason: desc });
91
+ notifier('unsnooze: session held', `${rec.cwd}: workspace changed while stopped (${desc}) — run: unsnooze resume-now`);
92
+ log(`${key}: workspace changed (${desc}) — held (workspaceGuard=pause)`);
93
+ return 'held';
94
+ }
95
+ resumeMessage += `\n\nHeads up: this workspace changed while the session was stopped (${desc}). Re-read the current state of the repo before continuing.`;
96
+ log(`${key}: workspace changed (${desc}) — informing agent in the wake message`);
97
+ }
98
+ }
99
+
80
100
  // Live-pane path: only if the pane still exists AND the agent CLI is its
81
101
  // foreground command (pane ids get recycled — never inject into a random
82
102
  // program).
package/src/settings.js CHANGED
@@ -18,6 +18,7 @@ export const DEFAULTS = {
18
18
  notifications: true, // desktop notifications on detect/resume
19
19
  guiWatch: true, // daemon watches transcripts/rollouts for GUI-session stops
20
20
  updateCheck: true, // daily registry version check + update notices/toast
21
+ workspaceGuard: 'inform', // repo changed while stopped: off | inform | pause
21
22
  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.',
22
23
  resumeMessages: { claude: '', codex: '', grok: '' }, // per-agent override; '' = use resumeMessage
23
24
  agents: { claude: true, codex: true, grok: false }, // grok is experimental
@@ -30,6 +31,7 @@ const ENV_NAMES = {
30
31
  notifications: 'UNSNOOZE_NOTIFICATIONS',
31
32
  guiWatch: 'UNSNOOZE_GUI_WATCH',
32
33
  updateCheck: 'UNSNOOZE_UPDATE_CHECK',
34
+ workspaceGuard: 'UNSNOOZE_WORKSPACE_GUARD',
33
35
  resumeMessage: 'UNSNOOZE_RESUME_MESSAGE',
34
36
  'resumeMessages.claude': 'UNSNOOZE_RESUME_MESSAGE_CLAUDE',
35
37
  'resumeMessages.codex': 'UNSNOOZE_RESUME_MESSAGE_CODEX',
@@ -41,6 +43,11 @@ const ENV_NAMES = {
41
43
 
42
44
  const KNOWN_KEYS = Object.keys(ENV_NAMES);
43
45
 
46
+ // String settings restricted to a fixed set of values.
47
+ const ENUMS = {
48
+ workspaceGuard: ['off', 'inform', 'pause'],
49
+ };
50
+
44
51
  function parseBool(raw) {
45
52
  if (/^(1|true|on|yes)$/i.test(raw)) return true;
46
53
  if (/^(0|false|off|no)$/i.test(raw)) return false;
@@ -98,6 +105,9 @@ export function setConfigValue(key, rawValue) {
98
105
  value = b;
99
106
  } else {
100
107
  value = String(rawValue);
108
+ if (ENUMS[key] && !ENUMS[key].includes(value)) {
109
+ throw new Error(`unsnooze: "${key}" must be one of: ${ENUMS[key].join(', ')}`);
110
+ }
101
111
  }
102
112
  const config = readFileConfig();
103
113
  const parts = key.split('.');
package/src/state.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  STATE_DIR, STATE_FILE, LOCK_DIR, STALE_LOCK_MS, PRUNE_AFTER_MS,
14
14
  DEDUPE_WINDOW_MS,
15
15
  } from './config.js';
16
+ import { workspaceFingerprint } from './workspace.js';
16
17
  import { makeLogger } from './logger.js';
17
18
 
18
19
  const log = makeLogger('state');
@@ -105,6 +106,11 @@ export function upsertSession(record) {
105
106
  log(`merged duplicate detection for pane ${record.pane} into ${existingKey}`);
106
107
  return state;
107
108
  }
109
+ // Baseline for the stale-workspace guard, captured once at stop time.
110
+ // (Merged duplicates above keep the ORIGINAL baseline — spread semantics.)
111
+ if (record.status === 'stopped' && record.workspace === undefined) {
112
+ record.workspace = workspaceFingerprint(record.cwd);
113
+ }
108
114
  const key = record.sessionId || `pane:${record.pane}:${record.detectedAt}`;
109
115
  state.sessions[key] = { ...record, key };
110
116
  return state;
@@ -8,6 +8,7 @@
8
8
  // The check is a plain GET to registry.npmjs.org — nothing identifying.
9
9
 
10
10
  import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
11
+ import { spawnSync } from 'node:child_process';
11
12
  import { join, dirname } from 'node:path';
12
13
  import { fileURLToPath } from 'node:url';
13
14
  import { getConfig } from './settings.js';
@@ -75,7 +76,7 @@ export function updateNotice(pkgVersion = PKG_VERSION) {
75
76
  if (!getConfig('updateCheck')) return null;
76
77
  const { latest } = readCache();
77
78
  if (!latest || !isNewer(latest, pkgVersion)) return null;
78
- return `unsnooze ${latest} is available (you have ${pkgVersion}) — npm i -g unsnooze · ${RELEASES_URL}`;
79
+ return `unsnooze ${latest} is available (you have ${pkgVersion}) — run: unsnooze update · ${RELEASES_URL}`;
79
80
  }
80
81
 
81
82
  // The "## <version>" block from the CHANGELOG.md that ships in the tarball.
@@ -113,8 +114,35 @@ export async function runUpdateCheck({ fetcher, notifier = notify, now = Date.no
113
114
  if (!latest) return 0;
114
115
  const cache = writeCache({ lastCheckedAt: now, latest });
115
116
  if (isNewer(latest, PKG_VERSION) && cache.notifiedVersion !== latest) {
116
- notifier('unsnooze update available', `${latest} is out (you have ${PKG_VERSION}) — npm i -g unsnooze`);
117
+ notifier('unsnooze update available', `${latest} is out (you have ${PKG_VERSION}) — run: unsnooze update`);
117
118
  writeCache({ notifiedVersion: latest });
118
119
  }
119
120
  return 0;
120
121
  }
122
+
123
+ // `unsnooze update` — self-update via npm, then show what changed. npm -g
124
+ // overwrites this install in place, so re-reading package.json/CHANGELOG.md
125
+ // after a successful install yields the NEW version's info.
126
+ export function runSelfUpdate({ runner = spawnSync, print = console.log } = {}) {
127
+ print(`unsnooze ${PKG_VERSION} — updating via npm install -g unsnooze@latest …`);
128
+ const r = runner('npm', ['install', '-g', 'unsnooze@latest'], { stdio: 'inherit' });
129
+ if (r.error || r.status !== 0) {
130
+ print(`unsnooze: update failed (${r.error ? r.error.message : `npm exited ${r.status}`}).`);
131
+ print('unsnooze: try it manually: npm install -g unsnooze@latest');
132
+ print('unsnooze: (permission errors usually mean your npm prefix needs sudo or a user-writable prefix)');
133
+ return r.status || 1;
134
+ }
135
+ let newVersion = PKG_VERSION;
136
+ try {
137
+ newVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')).version;
138
+ } catch { /* moved install dir — the generic message below still holds */ }
139
+ if (newVersion === PKG_VERSION) {
140
+ print(`unsnooze: already up to date (${PKG_VERSION}).`);
141
+ } else {
142
+ const section = changelogSection(newVersion);
143
+ print(`unsnooze: updated to ${newVersion}.${section ? ` What's new:\n${section}` : ''}`);
144
+ }
145
+ // Don't repeat "what's new" on the next command.
146
+ writeCache({ lastRunVersion: newVersion });
147
+ return 0;
148
+ }
@@ -0,0 +1,44 @@
1
+ // Stale-workspace guard: fingerprint a repo when a session stops, compare at
2
+ // wake time. If another session (or a human) moved the repo meanwhile, the
3
+ // resumer either warns the agent in the wake message or holds the session,
4
+ // per the `workspaceGuard` setting. Non-git directories fingerprint to null
5
+ // and are never guarded.
6
+
7
+ import { execFileSync } from 'node:child_process';
8
+ import { createHash } from 'node:crypto';
9
+
10
+ function git(cwd, args) {
11
+ return execFileSync('git', ['-C', cwd, ...args], {
12
+ stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500,
13
+ }).toString().trim();
14
+ }
15
+
16
+ export function workspaceFingerprint(cwd) {
17
+ if (!cwd) return null;
18
+ try {
19
+ const head = git(cwd, ['rev-parse', 'HEAD']);
20
+ const dirty = git(cwd, ['status', '--porcelain']);
21
+ return { head, dirtyHash: createHash('sha1').update(dirty).digest('hex') };
22
+ } catch {
23
+ return null; // not a git repo / git missing / unreadable — skip the guard
24
+ }
25
+ }
26
+
27
+ // null → nothing to report (no baseline, no current, or identical).
28
+ export function workspaceChanged(rec, current) {
29
+ const before = rec?.workspace;
30
+ if (!before || !current) return null;
31
+ if (before.head === current.head && before.dirtyHash === current.dirtyHash) return null;
32
+ return {
33
+ oldHead: before.head,
34
+ newHead: current.head,
35
+ dirtyChanged: before.dirtyHash !== current.dirtyHash,
36
+ };
37
+ }
38
+
39
+ export function describeChange(d) {
40
+ const parts = [];
41
+ if (d.oldHead !== d.newHead) parts.push(`HEAD ${d.oldHead.slice(0, 7)} → ${d.newHead.slice(0, 7)}`);
42
+ if (d.dirtyChanged) parts.push('uncommitted changes differ');
43
+ return parts.join('; ');
44
+ }