unsnooze 1.5.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,15 @@
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
+
3
13
  ## 1.5.0 — 2026-07-12
4
14
 
5
15
  - **`unsnooze update`**: one command to update unsnooze itself — runs
package/README.md CHANGED
@@ -155,6 +155,7 @@ unsnooze help # full command list (also -h / --help)
155
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`. |
156
156
  | `resumeMessages.claude` / `.codex` / `.grok` | `""` | Per-agent override of `resumeMessage`. Empty = use the global message; clear one with `unsnooze config set resumeMessages.claude ""`. |
157
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. |
158
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. |
159
160
 
160
161
  Every setting also has a `UNSNOOZE_*` env override (see `src/settings.js`), and
@@ -178,6 +179,11 @@ all timings/paths are tunable via `UNSNOOZE_*` env vars (see `src/config.js`).
178
179
  block the CLI).
179
180
  - **Overload ≠ limit**: 5xx/529/429 transient errors take a seconds-scale
180
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.
181
187
 
182
188
  ## Requirements
183
189
 
@@ -233,6 +239,15 @@ desktop notification per version); after updating, the next command shows a
233
239
  short "what's new" from the changelog. It's a plain registry GET with nothing
234
240
  identifying — turn it off with `unsnooze config set updateCheck off`.
235
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
+
236
251
  ### Does it work if my laptop was asleep or the terminal was closed?
237
252
 
238
253
  Yes — reset times are stored as absolute timestamps and checked every 30
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.5.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;
@@ -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
+ }