unsnooze 1.19.4 → 1.19.5

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,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.19.5 — 2026-10-05
4
+
5
+ Sessions resume again under psmux, Antigravity no longer wakes early, and
6
+ `unsnooze update` updates the copy you are running.
7
+
8
+ ### psmux (tmux on native Windows)
9
+
10
+ - Every Claude revival in psmux failed five times with `ready timeout`. psmux
11
+ ran the launch command through a shell, so the space in
12
+ `C:\Program Files\nodejs\node.exe` closed the window before Claude started.
13
+ Each revival now opens its own psmux session with its arguments passed
14
+ through as-is, which also keeps a Codex resume message in one piece (#43).
15
+ - A resume message can no longer be typed into an unrelated window. psmux
16
+ sends keys for a pane that has closed to some other pane, and every psmux
17
+ session numbers its first pane `%1`. unsnooze now addresses panes as
18
+ `session:%N` and checks the pane still exists before reading or typing (#43).
19
+
20
+ ### Antigravity (`agy`)
21
+
22
+ - A session could be woken hours before its quota came back. The `Resets in
23
+ 3h` countdown was dated from the last prompt sent in that folder, which can
24
+ be much older than the banner. It is now dated from when unsnooze reads the
25
+ banner, so a wake can be slightly late but not early (#40).
26
+
27
+ ### `unsnooze update`
28
+
29
+ - With two npm installs on one machine (for example unsnooze under `~/.local`
30
+ and nvm's npm first on PATH), the update went into the other npm's folder
31
+ and the running copy never changed. The update now targets the folder the
32
+ running copy was installed into (#41).
33
+ - It no longer prints "already up to date" when a newer version is known and
34
+ this copy did not change. It says what happened and exits with an error (#41).
35
+
3
36
  ## 1.19.4 — 2026-09-30
4
37
 
5
38
  Antigravity's new quota banner, and a shorter README.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.19.4",
3
+ "version": "1.19.5",
4
4
  "description": "Automatically resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode, Antigravity, and Cursor CLI sessions when 5-hour or weekly usage limits reset—on macOS, Linux and Windows, with or without a terminal multiplexer.",
5
5
  "keywords": [
6
6
  "claude",
package/src/agents/agy.js CHANGED
@@ -12,6 +12,10 @@
12
12
  // Deferred by design: the hooks.json channel (schema drifted between builds)
13
13
  // and the loopback RetrieveUserQuotaSummary endpoint (authoritative per-meter
14
14
  // resetTime) — the latter is the natural future resetProbe seam.
15
+ //
16
+ // No latestBannerAt: history.jsonl only records prompts, never banners, so its
17
+ // newest timestamp can predate the banner by hours. Anchoring a relative
18
+ // "Resets in 3h" to it fires the wake early. Countdowns anchor to scrape time.
15
19
 
16
20
  import { openSync, readSync, closeSync, statSync } from 'node:fs';
17
21
  import { homedir } from 'node:os';
@@ -106,28 +110,6 @@ export function latestSessionId(cwd, aroundTs = null, agyDir = AGY_DIR()) {
106
110
  return recentIds.size === 1 ? recentIds.values().next().value : null;
107
111
  }
108
112
 
109
- export function latestBannerAt(cwd, aroundTs = null, agyDir = AGY_DIR()) {
110
- const text = tailBytes(join(agyDir, 'history.jsonl'));
111
- if (!text) return null;
112
- const lines = text.split('\n').filter(l => l.trim());
113
- for (let i = lines.length - 1; i >= 0; i--) {
114
- let entry;
115
- try { entry = JSON.parse(lines[i]); } catch { continue; }
116
- if (!entry || typeof entry !== 'object') continue;
117
- if (!Object.values(entry).some(v => v === cwd)) continue;
118
- const timestamp = typeof entry.timestamp === 'number'
119
- ? entry.timestamp
120
- : typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
121
- if (Number.isFinite(timestamp)) {
122
- if (!Number.isFinite(aroundTs)) return timestamp;
123
- if (timestamp <= (aroundTs + 5000) && (aroundTs - timestamp) <= SESSION_RECENCY_MS) {
124
- return timestamp;
125
- }
126
- }
127
- }
128
- return null;
129
- }
130
-
131
113
  export default {
132
114
  id: 'agy',
133
115
  name: 'Antigravity CLI (Google)',
@@ -143,7 +125,6 @@ export default {
143
125
  // v1: every agent launches the bare TUI and gets the prompt typed once idle.
144
126
  launchArgs(message) { return { args: [], messageViaPane: true }; },
145
127
  latestSessionId,
146
- latestBannerAt,
147
128
  isForegroundCommand(cmd) {
148
129
  return cmd === 'agy' || cmd === 'node' || cmd === 'unsnooze';
149
130
  },
package/src/hook.js CHANGED
@@ -16,6 +16,7 @@ import { parseResetTime, resetAtMs } from './time-parser.js';
16
16
  import { upsertSession } from './state.js';
17
17
  import { latestRateLimitFromTranscript } from './watchers/claude.js';
18
18
  import { getMultiplexer } from './multiplexer.js';
19
+ import { tmuxPaneFromEnv } from './multiplexers/tmux.js';
19
20
  import { spawnResumerIfNeeded, ensureDaemon } from './spawn.js';
20
21
  import { makeLogger } from './logger.js';
21
22
  import { addressHash } from './lease.js';
@@ -47,7 +48,7 @@ export function hookContext(env = process.env, payload = {}) {
47
48
  const pane = managedMux
48
49
  ? (env.UNSNOOZE_PANE || null)
49
50
  : (muxName === 'herdr' ? env.HERDR_PANE_ID
50
- : muxName === 'zellij' ? env.ZELLIJ_PANE_ID : env.TMUX_PANE || payload.tmux_pane) || null;
51
+ : muxName === 'zellij' ? env.ZELLIJ_PANE_ID : tmuxPaneFromEnv(env) || payload.tmux_pane) || null;
51
52
  const paneOwner = muxName === 'herdr'
52
53
  ? (managedMux ? env.UNSNOOZE_PANE_OWNER : (env.HERDR_SESSION || 'default'))
53
54
  : muxName === 'zellij'
package/src/monitor.js CHANGED
@@ -113,13 +113,10 @@ export function createMonitor({
113
113
  detectedAt,
114
114
  };
115
115
  }
116
- const bannerAt = typeof agent.latestBannerAt === 'function'
117
- ? agent.latestBannerAt(cwd, detectedAt)
118
- : null;
119
116
  return {
120
117
  resetLine: paneResetLine,
121
118
  limitType: paneLimitType,
122
- bannerAt,
119
+ bannerAt: null,
123
120
  sessionId,
124
121
  via: null, // filled by caller (scrape/hook)
125
122
  detectedAt,
@@ -6,9 +6,11 @@ import { resolveSessionName, SessionCreateError } from './session-name.js';
6
6
 
7
7
  const execFileAsync = promisify(execFileCb);
8
8
 
9
+ // windowsHide: the console-less monitor polls through the async path every few
10
+ // seconds (see workspace.js). The sync new-session needs the real console.
9
11
  function defaultSpawner(file, args, { sync = false, ...options } = {}) {
10
12
  if (sync) return spawnSync(file, args, options);
11
- return execFileAsync(file, args, options).then(({ stdout }) => stdout);
13
+ return execFileAsync(file, args, { windowsHide: true, ...options }).then(({ stdout }) => stdout);
12
14
  }
13
15
 
14
16
  function envArgs(env = {}) {
@@ -51,8 +53,43 @@ const SESSION_START_ERROR_RE = new RegExp([
51
53
 
52
54
  export { SessionCreateError };
53
55
 
54
- export function createTmux({ spawner = defaultSpawner, env = process.env } = {}) {
56
+ // tmux on native Windows is psmux, and three of its differences break revival
57
+ // (verified against psmux 3.3.6, which reports itself as "tmux 3.3.6"):
58
+ // - Command words are joined with spaces and run through pwsh/cmd unless `--`
59
+ // precedes them, and only new-session honours `--` — new-window always goes
60
+ // through the shell. `C:\Program Files\nodejs\node.exe` then dies at
61
+ // "C:\Program" and the window closes before the agent ever starts.
62
+ // - Pane ids are per session: every session's first pane is %1, and a bare
63
+ // `-t %1` means whichever session is newest. `session:%1` is exact.
64
+ // - A target pane that is gone resolves to some other pane: send-keys exits 0
65
+ // and types into it, capture-pane reads it.
66
+ // Real tmux accepts `--` and `session:%N` too, so an MSYS2 tmux is unharmed.
67
+
68
+ // This pane's id as unsnooze addresses it: `session:%N` inside psmux, which
69
+ // exports PSMUX_SESSION; the bare server-global %N under real tmux.
70
+ export function tmuxPaneFromEnv(env = process.env) {
71
+ if (!env.TMUX_PANE) return null;
72
+ return env.PSMUX_SESSION ? `${env.PSMUX_SESSION}:${env.TMUX_PANE}` : env.TMUX_PANE;
73
+ }
74
+
75
+ export function createTmux({ spawner = defaultSpawner, env = process.env, platform = process.platform } = {}) {
55
76
  const run = (...args) => spawner('tmux', args);
77
+ const psmux = platform === 'win32';
78
+ const PANE_FMT = psmux ? '#{session_name}:#{pane_id}' : '#{pane_id}';
79
+ const RAW = psmux ? ['--'] : [];
80
+ // A pane-targeted command, refused for a pane that is gone — real tmux does
81
+ // that itself; psmux would run it against another pane.
82
+ const onPane = async (pane, cmd, ...args) => {
83
+ if (psmux && !(await backend.paneAlive(pane))) throw new Error(`can't find pane: ${pane}`);
84
+ return run(cmd, '-t', pane, ...args);
85
+ };
86
+ const sessionTaken = name => {
87
+ try {
88
+ return spawner('tmux', ['has-session', '-t', name], { sync: true, stdio: 'ignore', env }).status === 0;
89
+ } catch {
90
+ return false;
91
+ }
92
+ };
56
93
 
57
94
  const backend = {
58
95
  name: 'tmux',
@@ -69,33 +106,33 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
69
106
  inside() { return !!env.TMUX; },
70
107
  currentPaneId() {
71
108
  if (env.UNSNOOZE_MUX === 'tmux' && env.UNSNOOZE_PANE) return env.UNSNOOZE_PANE;
72
- return env.TMUX_PANE || null;
109
+ return tmuxPaneFromEnv(env);
73
110
  },
74
111
 
75
112
  async capturePane(pane, lines = 200) {
76
- return run('capture-pane', '-t', pane, '-p', '-S', `-${lines}`);
113
+ return onPane(pane, 'capture-pane', '-p', '-S', `-${lines}`);
77
114
  },
78
115
 
79
116
  async capturePaneVisible(pane) {
80
- return run('capture-pane', '-t', pane, '-p');
117
+ return onPane(pane, 'capture-pane', '-p');
81
118
  },
82
119
 
83
120
  async sendText(pane, text) {
84
- await run('send-keys', '-t', pane, '-l', text);
121
+ await onPane(pane, 'send-keys', '-l', text);
85
122
  await new Promise(resolve => setTimeout(resolve, SUBMIT_DELAY_MS));
86
- await run('send-keys', '-t', pane, 'Enter');
123
+ await onPane(pane, 'send-keys', 'Enter');
87
124
  },
88
125
 
89
126
  async sendKey(pane, key) {
90
- await run('send-keys', '-t', pane, key);
127
+ await onPane(pane, 'send-keys', key);
91
128
  },
92
129
 
93
130
  async paneAlive(pane) {
94
131
  try {
95
132
  // tmux 3.7b prints a blank line and exits 0 for a nonexistent target,
96
- // so the exit code alone is not evidence — the output must echo the
97
- // pane id back.
98
- const out = await run('display-message', '-t', pane, '-p', '#{pane_id}');
133
+ // and psmux names a different pane, so the exit code alone is not
134
+ // evidence — the output must echo the pane id back.
135
+ const out = await run('display-message', '-t', pane, '-p', PANE_FMT);
99
136
  return out.trim() === pane;
100
137
  } catch {
101
138
  return false;
@@ -106,7 +143,7 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
106
143
  // code (same tmux 3.7b blank-success pitfall as paneAlive).
107
144
  async sessionForPane(pane) {
108
145
  try {
109
- const out = (await run('display-message', '-t', pane, '-p', '#{session_name}')).trim();
146
+ const out = (await onPane(pane, 'display-message', '-p', '#{session_name}')).trim();
110
147
  return out || null;
111
148
  } catch {
112
149
  return null;
@@ -133,7 +170,7 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
133
170
 
134
171
  async paneTty(pane) {
135
172
  try {
136
- const out = (await run('display-message', '-t', pane, '-p', '#{pane_tty}')).trim();
173
+ const out = (await onPane(pane, 'display-message', '-p', '#{pane_tty}')).trim();
137
174
  return out || null;
138
175
  } catch {
139
176
  return null;
@@ -166,7 +203,7 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
166
203
 
167
204
  async paneCurrentCommand(pane) {
168
205
  try {
169
- return (await run('display-message', '-t', pane, '-p', '#{pane_current_command}')).trim();
206
+ return (await onPane(pane, 'display-message', '-p', '#{pane_current_command}')).trim();
170
207
  } catch {
171
208
  return null;
172
209
  }
@@ -178,7 +215,7 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
178
215
  // older tmux fails quietly and callers fall back to lease checks.
179
216
  async stampPaneOwner(pane, leaseId) {
180
217
  try {
181
- await run('set-option', '-p', '-t', pane, '@unsnooze_owner', String(leaseId));
218
+ await onPane(pane, 'set-option', '-p', '@unsnooze_owner', String(leaseId));
182
219
  return true;
183
220
  } catch {
184
221
  return false;
@@ -189,7 +226,7 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
189
226
  // both null — trust stdout, not the exit code (tmux 3.7b blank-success).
190
227
  async paneOwnerStamp(pane) {
191
228
  try {
192
- const out = (await run('display-message', '-t', pane, '-p', '#{@unsnooze_owner}')).trim();
229
+ const out = (await onPane(pane, 'display-message', '-p', '#{@unsnooze_owner}')).trim();
193
230
  return out || null;
194
231
  } catch {
195
232
  return null;
@@ -217,7 +254,7 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
217
254
 
218
255
  async listSessionPanes(sessionName) {
219
256
  try {
220
- const out = await run('list-panes', '-t', sessionName, '-F', '#{pane_id}');
257
+ const out = await run('list-panes', '-t', sessionName, '-F', PANE_FMT);
221
258
  return out.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
222
259
  } catch {
223
260
  return [];
@@ -235,7 +272,15 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
235
272
  async newWindow(sessionName, cwd, launchSpec) {
236
273
  // Environment flags require tmux >= 3.0 for new-window and >= 3.2 for
237
274
  // new-session. Older tmux fails revival with an "unknown flag -e" error.
238
- const launch = [...envArgs(launchSpec.env), launchSpec.file, ...(launchSpec.args || [])];
275
+ const launch = [...envArgs(launchSpec.env), ...RAW, launchSpec.file, ...(launchSpec.args || [])];
276
+ // psmux: new-window would run the command through a shell, so each
277
+ // revival opens its own session — resumer and prompt-queue persist it.
278
+ if (psmux) {
279
+ const session = resolveSessionName(sessionName, sessionTaken);
280
+ const pane = await run('new-session', '-d', '-s', session, '-c', cwd,
281
+ '-P', '-F', PANE_FMT, ...launch);
282
+ return { pane: pane.trim(), paneOwner: null, session };
283
+ }
239
284
  let pane;
240
285
  if (!(await backend.sessionExists(sessionName))) {
241
286
  pane = await run('new-session', '-d', '-s', sessionName, '-c', cwd,
@@ -254,20 +299,13 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
254
299
  // tmux in the foreground also gives Ctrl-C to the active pane directly.
255
300
  // Its -e environment flags require tmux >= 3.2; older versions fail
256
301
  // revival with an "unknown flag -e" error.
257
- const name = resolveSessionName(wrappedSessionName(env), candidate => {
258
- try {
259
- return spawner('tmux', ['has-session', '-t', candidate],
260
- { sync: true, stdio: 'ignore', env }).status === 0;
261
- } catch {
262
- return false;
263
- }
264
- });
302
+ const name = resolveSessionName(wrappedSessionName(env), sessionTaken);
265
303
  launchSpec.onSessionCreated?.(name);
266
304
  // Session name is discovered live via sessionForPane at record-write
267
305
  // time — do NOT inject UNSNOOZE_SESSION_NAME (would leak into daemons
268
306
  // spawned from the agent via {...process.env}).
269
307
  const args = ['new-session', '-s', name,
270
- ...envArgs(launchSpec.env), launchSpec.file, ...(launchSpec.args || [])];
308
+ ...envArgs(launchSpec.env), ...RAW, launchSpec.file, ...(launchSpec.args || [])];
271
309
  // stderr is piped, not inherited: the tmux client draws its UI on the
272
310
  // stdin/stdout tty; stderr carries only tmux's own error messages, and
273
311
  // capturing them is how a session-start failure is told apart from the
package/src/resumer.js CHANGED
@@ -342,10 +342,6 @@ export async function probeFallback(rec, {
342
342
  return null;
343
343
  }
344
344
 
345
- if (!bannerAt && typeof agent.latestBannerAt === 'function') {
346
- bannerAt = agent.latestBannerAt(rec.cwd, now);
347
- }
348
-
349
345
  // Banner still present. If it now parses to a real time, upgrade off fallback.
350
346
  if (resetLine) {
351
347
  const { at, source } = resetAtMs(parseResetTime(resetLine), {
@@ -959,8 +955,6 @@ export async function verifyOne(key, { resolveMux = resolveRecordMux } = {}) {
959
955
  if (fromTx) {
960
956
  resetLine = fromTx.resetLine ?? resetLine;
961
957
  bannerAt = fromTx.timestampMs;
962
- } else if (typeof agent.latestBannerAt === 'function') {
963
- bannerAt = agent.latestBannerAt(rec.cwd, Date.now());
964
958
  }
965
959
  const { at, source } = resetAtMs(parseResetTime(resetLine), {
966
960
  marginMs: RESET_MARGIN_MS,
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { readFileSync, existsSync } from 'node:fs';
11
11
  import { spawnSync } from 'node:child_process';
12
- import { join, dirname } from 'node:path';
12
+ import { join, dirname, posix, win32 } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { getConfig } from './settings.js';
15
15
  import { notify } from './notify.js';
@@ -225,24 +225,59 @@ export async function runUpdateCheck({ fetcher, notifier = notify, now = Date.no
225
225
  return 0;
226
226
  }
227
227
 
228
- // `unsnooze update` — self-update via npm, then show what changed. npm -g
229
- // overwrites this install in place, so re-reading package.json/CHANGELOG.md
230
- // after a successful install yields the NEW version's info.
231
- export function runSelfUpdate({ runner = spawnSync, print = console.log } = {}) {
232
- print(`unsnooze ${PKG_VERSION} — updating via npm install -g unsnooze@latest …`);
233
- const r = runner('npm', ['install', '-g', 'unsnooze@latest'], { stdio: 'inherit' });
228
+ // The npm prefix this copy was installed into, so `unsnooze update` replaces
229
+ // the copy that is running. With two npm prefixes on one machine (nvm's npm
230
+ // first on PATH while unsnooze lives under ~/.local, or Homebrew's npm next
231
+ // to nvm), a bare `npm install -g` writes into npm's own prefix and leaves
232
+ // the copy the wrappers, the StopFailure hook and the daemon unit exec as it
233
+ // was. npm's global layout is <prefix>/lib/node_modules/unsnooze plus a
234
+ // <prefix>/bin/unsnooze link on POSIX, <prefix>\node_modules\unsnooze plus
235
+ // <prefix>\unsnooze.cmd on Windows. Anything else (a checkout or `npm link`,
236
+ // npx, a pnpm/yarn/bun global, a project dependency) is null: npm's default
237
+ // prefix, as before.
238
+ export function installPrefix({ root = ROOT, platform = process.platform, exists = existsSync } = {}) {
239
+ const win = platform === 'win32';
240
+ const path = win ? win32 : posix;
241
+ const modules = path.dirname(root);
242
+ if (path.basename(root) !== 'unsnooze' || path.basename(modules) !== 'node_modules') return null;
243
+ const parent = path.dirname(modules);
244
+ if (win) return exists(path.join(parent, 'unsnooze.cmd')) ? parent : null;
245
+ if (path.basename(parent) !== 'lib') return null;
246
+ const prefix = path.dirname(parent);
247
+ return exists(path.join(prefix, 'bin', 'unsnooze')) ? prefix : null;
248
+ }
249
+
250
+ // `unsnooze update` — self-update via npm, then show what changed. With
251
+ // --prefix pointed at this copy's own prefix, npm -g overwrites this install
252
+ // in place, so re-reading package.json/CHANGELOG.md after a successful
253
+ // install yields the NEW version's info.
254
+ export function runSelfUpdate({ runner = spawnSync, print = console.log, root = ROOT, current = PKG_VERSION } = {}) {
255
+ const prefix = installPrefix({ root });
256
+ const args = ['install', '-g', ...(prefix ? ['--prefix', prefix] : []), 'unsnooze@latest'];
257
+ const command = ['npm', ...args].map(a => (/\s/.test(a) ? `"${a}"` : a)).join(' ');
258
+ print(`unsnooze ${current} — updating via ${command} …`);
259
+ const r = runner('npm', args, { stdio: 'inherit' });
234
260
  if (r.error || r.status !== 0) {
235
261
  print(`unsnooze: update failed (${r.error ? r.error.message : `npm exited ${r.status}`}).`);
236
- print('unsnooze: try it manually: npm install -g unsnooze@latest');
262
+ print(`unsnooze: try it manually: ${command}`);
237
263
  print('unsnooze: (permission errors usually mean your npm prefix needs sudo or a user-writable prefix)');
238
264
  return r.status || 1;
239
265
  }
240
- let newVersion = PKG_VERSION;
266
+ let newVersion;
241
267
  try {
242
- newVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')).version;
268
+ newVersion = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).version;
243
269
  } catch { /* moved install dir — the generic message below still holds */ }
244
- if (newVersion === PKG_VERSION) {
245
- print(`unsnooze: already up to date (${PKG_VERSION}).`);
270
+ // npm exited 0 but this copy is unchanged, while the registry check already
271
+ // knows a newer release: most likely npm installed it into another prefix.
272
+ const { latest } = readCache();
273
+ if (newVersion === current && isNewer(latest, current)) {
274
+ print(`unsnooze: npm finished, but this copy is still ${current} and ${latest} is out.`);
275
+ print(`unsnooze: npm may have installed it into its own prefix (\`npm prefix -g\`) instead of ${root}.`);
276
+ return 1;
277
+ }
278
+ newVersion ??= current;
279
+ if (newVersion === current) {
280
+ print(`unsnooze: already up to date (${current}).`);
246
281
  } else {
247
282
  const section = changelogSection(newVersion);
248
283
  print(`unsnooze: updated to ${newVersion}.${section ? ` What's new:\n${section}` : ''}`);