unsnooze 1.0.0 → 1.1.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,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0 — 2026-07-10
4
+
5
+ ### Per-agent resume messages
6
+
7
+ - **`resumeMessages.claude` / `.codex` / `.grok`**: optional per-agent override
8
+ of the global `resumeMessage` — `unsnooze config set resumeMessages.codex
9
+ "..."` or `UNSNOOZE_RESUME_MESSAGE_CODEX`. Empty means "use the global
10
+ message"; clear with `unsnooze config set resumeMessages.codex ""`.
11
+ Specificity beats source: a per-agent file value outranks a global env var.
12
+ - **Setup wizard** asks for per-agent messages, prefills every message prompt
13
+ from the existing config, and merges over the config file — re-runs no
14
+ longer clobber values set via `unsnooze config`.
15
+ - Blank or whitespace-only messages are never sent: resolution falls through
16
+ to the global message, then the built-in default.
17
+
18
+ ### CLI & fixes
19
+
20
+ - **`-h` / `--help`** now print the unsnooze help (previously only
21
+ `unsnooze help`), and the usage documents every command.
22
+ - **Install**: `unsnooze setup` / `install` no longer crashes with ENOENT on
23
+ machines without a `~/.claude/` directory.
24
+ - `unsnooze config set <key> ""` is a valid way to clear string overrides, and
25
+ a config file holding non-object JSON is treated as empty instead of
26
+ corrupting later writes.
27
+
3
28
  ## 1.0.0 — 2026-07-10
4
29
 
5
30
  First public release (previously the private `claude-session-guard`/`csg`).
package/README.md CHANGED
@@ -103,6 +103,7 @@ unsnooze config set <k> <v> # e.g. autoResume off
103
103
  unsnooze logs [-f] # what unsnooze has been doing
104
104
  unsnooze report [agent] # capture a pane to report an undetected banner
105
105
  unsnooze uninstall [--purge] # remove wrappers + hooks (+ state with --purge)
106
+ unsnooze help # full command list (also -h / --help)
106
107
  ```
107
108
 
108
109
  ## Settings
@@ -116,6 +117,7 @@ unsnooze uninstall [--purge] # remove wrappers + hooks (+ state with --purge)
116
117
  | `menuAutoAnswer` | `true` | May unsnooze answer Claude's limit menu (send keys in your pane)? Off = watch-only. |
117
118
  | `notifications` | `true` | Desktop notification on limit detected / session resumed / gave up. |
118
119
  | `resumeMessage` | *"Continue where you left off…"* | The message sent to wake a session. |
120
+ | `resumeMessages.claude` / `.codex` / `.grok` | `""` | Per-agent override of `resumeMessage`. Empty = use the global message; clear one with `unsnooze config set resumeMessages.claude ""`. |
119
121
  | `agents.claude` / `agents.codex` / `agents.grok` | `true` / `true` / `false` | Which CLIs are guarded. |
120
122
 
121
123
  Every setting also has a `UNSNOOZE_*` env override (see `src/settings.js`), and
package/bin/unsnooze.js CHANGED
@@ -61,6 +61,8 @@ async function main() {
61
61
  return runResumer();
62
62
  }
63
63
  case 'help':
64
+ case '-h':
65
+ case '--help':
64
66
  case '--help-unsnooze': {
65
67
  console.log(`unsnooze — wakes every limit-stopped AI coding session when the limit resets
66
68
 
@@ -71,11 +73,13 @@ Usage:
71
73
  unsnooze resume-now [id|--all] resume stopped session(s) immediately
72
74
  unsnooze cancel [id|--all] stop tracking session(s)
73
75
  unsnooze logs [-f] show (or follow) the unsnooze log
74
- unsnooze config [list|get|set] view or change settings (toggles, message)
76
+ unsnooze config [list|get|set] view or change settings (toggles, global +
77
+ per-agent resume messages)
75
78
  unsnooze setup interactive setup wizard (agents + toggles)
76
79
  unsnooze install [--yes] wire up shell wrappers + hooks (non-interactive)
77
80
  unsnooze uninstall [--purge] remove wrappers + hooks (and state with --purge)
78
- unsnooze report [agent] [pane] capture a pane to report an undetected banner`);
81
+ unsnooze report [agent] [pane] capture a pane to report an undetected banner
82
+ unsnooze help show this help (also -h / --help)`);
79
83
  return 0;
80
84
  }
81
85
  default: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Wakes every limit-stopped AI coding session (Claude Code, Codex CLI, Grok) in tmux the moment the usage limit resets.",
5
5
  "keywords": [
6
6
  "claude-code",
package/src/cli.js CHANGED
@@ -89,7 +89,7 @@ export function cmdConfig(rest) {
89
89
  const listed = listConfig();
90
90
  console.log(`unsnooze settings (${CONFIG_FILE()}):\n`);
91
91
  for (const [k, v] of Object.entries(listed)) {
92
- console.log(` ${k.padEnd(16)} ${JSON.stringify(v)}`);
92
+ console.log(` ${k.padEnd(24)} ${JSON.stringify(v)}`);
93
93
  }
94
94
  return 0;
95
95
  }
@@ -100,7 +100,9 @@ export function cmdConfig(rest) {
100
100
  }
101
101
  if (action === 'set') {
102
102
  const value = valueParts.join(' ');
103
- if (!key || value === '') { console.error('unsnooze config set <key> <value>'); return 2; }
103
+ // An explicit "" is a valid value (clears string overrides); only a
104
+ // genuinely missing value is a usage error.
105
+ if (!key || valueParts.length === 0) { console.error('unsnooze config set <key> <value>'); return 2; }
104
106
  const applied = setConfigValue(key, value);
105
107
  console.log(`unsnooze: ${key} = ${JSON.stringify(applied)}`);
106
108
  return 0;
package/src/install.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // - ~/.grok/hooks/unsnooze.json when the grok agent is enabled
9
9
  // --settings <path> / --zshrc <path> override targets (used by tests).
10
10
 
11
- import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, rmSync } from 'node:fs';
11
+ import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, rmSync, mkdirSync } from 'node:fs';
12
12
  import { homedir } from 'node:os';
13
13
  import { join, dirname } from 'node:path';
14
14
  import { CLAUDE_SETTINGS, STATE_DIR } from './config.js';
@@ -38,6 +38,7 @@ function parseArgs(rest) {
38
38
  }
39
39
 
40
40
  function atomicWrite(path, content) {
41
+ mkdirSync(dirname(path), { recursive: true }); // e.g. ~/.claude may not exist yet
41
42
  const tmp = join(dirname(path), `.${Date.now()}.tmp`);
42
43
  writeFileSync(tmp, content);
43
44
  renameSync(tmp, path);
package/src/resumer.js CHANGED
@@ -16,7 +16,7 @@ import { detectLimit, isBusy } from './patterns.js';
16
16
  import { getAgent } from './agents/index.js';
17
17
  import { parseResetTime, resetAtMs } from './time-parser.js';
18
18
  import { readState, updateState, setStatus, dueSessions, activeStopped } from './state.js';
19
- import { getConfig } from './settings.js';
19
+ import { getConfig, resolveResumeMessage } from './settings.js';
20
20
  import { notify } from './notify.js';
21
21
  import { UNSNOOZE_BIN } from './spawn.js';
22
22
  import { makeLogger } from './logger.js';
@@ -68,9 +68,11 @@ function selfCommand() {
68
68
 
69
69
  // Decide how to act on one due record. Pure-ish; tmux injectable.
70
70
  // Returns: 'sent' | 'reopened' | 'deferred' | 'skip' | 'failed'
71
- export async function dispatchOne(rec, { tmux = realTmux, resumeMessage = getConfig('resumeMessage'), selfCmd = selfCommand() } = {}) {
71
+ export async function dispatchOne(rec, { tmux = realTmux, resumeMessage, selfCmd = selfCommand() } = {}) {
72
72
  const key = rec.key;
73
73
  const agent = getAgent(rec.agent);
74
+ // Explicit option wins; otherwise the agent's own message (or the global).
75
+ resumeMessage = resumeMessage ?? resolveResumeMessage(agent.id);
74
76
 
75
77
  // Live-pane path: only if the pane still exists AND the agent CLI is its
76
78
  // foreground command (pane ids get recycled — never inject into a random
package/src/settings.js CHANGED
@@ -1,6 +1,10 @@
1
1
  // User-facing settings: ~/.unsnooze/config.json, managed by `unsnooze config`
2
- // and the setup wizard. Precedence: env var > config file > default — env
3
- // stays the power-user/test escape hatch, the file is what the wizard writes.
2
+ // and the setup wizard. Precedence per key: env var > config file > default —
3
+ // env stays the power-user/test escape hatch, the file is what the wizard
4
+ // writes. One cross-key exception: the resume message resolves by specificity
5
+ // first, so a per-agent resumeMessages.<id> value (from either source) beats
6
+ // the global resumeMessage even when the global came from an env var (see
7
+ // resolveResumeMessage).
4
8
 
5
9
  import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
6
10
  import { join, dirname } from 'node:path';
@@ -13,6 +17,7 @@ export const DEFAULTS = {
13
17
  menuAutoAnswer: true, // may unsnooze drive Claude's limit menu (send keys)?
14
18
  notifications: true, // desktop notifications on detect/resume
15
19
  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.',
20
+ resumeMessages: { claude: '', codex: '', grok: '' }, // per-agent override; '' = use resumeMessage
16
21
  agents: { claude: true, codex: true, grok: false }, // grok is experimental
17
22
  };
18
23
 
@@ -22,6 +27,9 @@ const ENV_NAMES = {
22
27
  menuAutoAnswer: 'UNSNOOZE_MENU_AUTO_ANSWER',
23
28
  notifications: 'UNSNOOZE_NOTIFICATIONS',
24
29
  resumeMessage: 'UNSNOOZE_RESUME_MESSAGE',
30
+ 'resumeMessages.claude': 'UNSNOOZE_RESUME_MESSAGE_CLAUDE',
31
+ 'resumeMessages.codex': 'UNSNOOZE_RESUME_MESSAGE_CODEX',
32
+ 'resumeMessages.grok': 'UNSNOOZE_RESUME_MESSAGE_GROK',
25
33
  'agents.claude': 'UNSNOOZE_AGENT_CLAUDE',
26
34
  'agents.codex': 'UNSNOOZE_AGENT_CODEX',
27
35
  'agents.grok': 'UNSNOOZE_AGENT_GROK',
@@ -35,9 +43,11 @@ function parseBool(raw) {
35
43
  return null;
36
44
  }
37
45
 
38
- function readFileConfig() {
46
+ export function readFileConfig() {
39
47
  try {
40
- return JSON.parse(readFileSync(CONFIG_FILE(), 'utf-8'));
48
+ const parsed = JSON.parse(readFileSync(CONFIG_FILE(), 'utf-8'));
49
+ // Wrong-typed but valid JSON (a bare string/array) counts as corrupt too.
50
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
41
51
  } catch {
42
52
  return {}; // missing or corrupt file → defaults (never crash the hook path)
43
53
  }
@@ -63,6 +73,17 @@ export function getConfig(key) {
63
73
  return fromFile !== undefined ? fromFile : def;
64
74
  }
65
75
 
76
+ // The message to send a given agent: its resumeMessages.<id> override when
77
+ // set, else the global resumeMessage. Unknown agent ids fall back to global;
78
+ // blank values (empty or whitespace-only) fall through to the next level so a
79
+ // blank message is never sent.
80
+ export function resolveResumeMessage(agentId) {
81
+ const key = `resumeMessages.${agentId}`;
82
+ const set = v => (typeof v === 'string' && v.trim() ? v : '');
83
+ const perAgent = agentId && KNOWN_KEYS.includes(key) ? getConfig(key) : '';
84
+ return set(perAgent) || set(getConfig('resumeMessage')) || DEFAULTS.resumeMessage;
85
+ }
86
+
66
87
  export function setConfigValue(key, rawValue) {
67
88
  if (!KNOWN_KEYS.includes(key)) throw new Error(`unsnooze: unknown setting "${key}" (known: ${KNOWN_KEYS.join(', ')})`);
68
89
  const def = dig(DEFAULTS, key);
package/src/wizard.js CHANGED
@@ -6,7 +6,7 @@ import { execFileSync } from 'node:child_process';
6
6
  import * as p from '@clack/prompts';
7
7
  import { listAgents } from './agents/index.js';
8
8
  import { isCommunityGrokCli } from './agents/grok.js';
9
- import { DEFAULTS, writeConfig } from './settings.js';
9
+ import { DEFAULTS, writeConfig, readFileConfig } from './settings.js';
10
10
 
11
11
  export function detectInstalledAgents({ which = defaultWhich } = {}) {
12
12
  return listAgents().map(agent => ({ agent, installed: which(agent.bin) }));
@@ -69,25 +69,55 @@ export async function runWizard() {
69
69
  });
70
70
  if (p.isCancel(notifications)) return cancelled();
71
71
 
72
+ // Seed message prompts from the existing config so a setup re-run shows —
73
+ // and keeps — values previously set here or via `unsnooze config`.
74
+ const existing = readFileConfig();
75
+ const existingMsgs = existing.resumeMessages
76
+ && typeof existing.resumeMessages === 'object' && !Array.isArray(existing.resumeMessages)
77
+ ? existing.resumeMessages : {};
78
+
72
79
  const customizeMsg = await p.confirm({
73
80
  message: 'Customize the message sent to resume a session?',
74
81
  initialValue: false,
75
82
  });
76
83
  if (p.isCancel(customizeMsg)) return cancelled();
77
84
 
78
- let resumeMessage = DEFAULTS.resumeMessage;
85
+ let resumeMessage = typeof existing.resumeMessage === 'string' && existing.resumeMessage.trim()
86
+ ? existing.resumeMessage : DEFAULTS.resumeMessage;
79
87
  if (customizeMsg) {
80
88
  const msg = await p.text({
81
89
  message: 'Resume message:',
82
- initialValue: DEFAULTS.resumeMessage,
90
+ initialValue: resumeMessage,
83
91
  validate: v => (v.trim() ? undefined : 'message cannot be empty'),
84
92
  });
85
93
  if (p.isCancel(msg)) return cancelled();
86
94
  resumeMessage = msg;
87
95
  }
88
96
 
97
+ const customizePerAgent = await p.confirm({
98
+ message: 'Set a different resume message for specific agents?',
99
+ initialValue: false,
100
+ });
101
+ if (p.isCancel(customizePerAgent)) return cancelled();
102
+
103
+ const resumeMessages = {};
104
+ if (customizePerAgent) {
105
+ for (const agent of listAgents().filter(a => agents.includes(a.id))) {
106
+ const msg = await p.text({
107
+ message: `Message for ${agent.name} (leave empty to use the global message):`,
108
+ initialValue: typeof existingMsgs[agent.id] === 'string' ? existingMsgs[agent.id] : '',
109
+ });
110
+ if (p.isCancel(msg)) return cancelled();
111
+ resumeMessages[agent.id] = typeof msg === 'string' && msg.trim() ? msg : '';
112
+ }
113
+ }
114
+
115
+ // Merge over the existing file so a setup re-run keeps settings the wizard
116
+ // doesn't ask about (e.g. per-agent messages set via `unsnooze config`).
89
117
  writeConfig({
118
+ ...existing,
90
119
  autoResume, menuAutoAnswer, notifications, resumeMessage,
120
+ resumeMessages: { ...existingMsgs, ...resumeMessages },
91
121
  agents: Object.fromEntries(listAgents().map(a => [a.id, agents.includes(a.id)])),
92
122
  });
93
123