unsnooze 1.7.0 → 1.8.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/resumer.js CHANGED
@@ -6,11 +6,11 @@
6
6
 
7
7
  import { writeFileSync, readFileSync, unlinkSync, mkdirSync } from 'node:fs';
8
8
  import { homedir } from 'node:os';
9
- import * as realTmux from './tmux.js';
9
+ import { getMultiplexer } from './multiplexer.js';
10
10
  import {
11
11
  RESUMER_LOCK, STATE_DIR, POLL_INTERVAL_MS, STAGGER_MS, VERIFY_DELAY_MS,
12
12
  BUSY_DEFER_MS, MAX_BUSY_DEFERS, MAX_RESUME_ATTEMPTS, READY_TIMEOUT_MS,
13
- CAPTURE_LINES, PANE_SCAN_LINES, TMUX_SESSION_NAME,
13
+ CAPTURE_LINES, PANE_SCAN_LINES, MUX_SESSION_NAME,
14
14
  RESET_MARGIN_MS, FALLBACK_RESET_MS,
15
15
  } from './config.js';
16
16
  import { detectLimit, isBusy } from './patterns.js';
@@ -22,9 +22,12 @@ import { workspaceFingerprint, workspaceChanged, describeChange } from './worksp
22
22
  import { notify } from './notify.js';
23
23
  import { UNSNOOZE_BIN } from './spawn.js';
24
24
  import { makeLogger } from './logger.js';
25
+ import { createLeaseId, leaseMatches } from './lease.js';
25
26
 
26
27
  const log = makeLogger('resumer');
27
28
  const sleep = ms => new Promise(r => setTimeout(r, ms));
29
+ const MAX_VERIFY_RETRIES = 3;
30
+ const ctxOf = rec => ({ mux: rec.mux, pane: rec.pane, paneOwner: rec.paneOwner });
28
31
 
29
32
  function pidAlive(pid) {
30
33
  try { process.kill(pid, 0); return true; } catch { return false; }
@@ -56,22 +59,40 @@ export function dueForDispatch(now = Date.now()) {
56
59
  return dueSessions(now).filter(s => (auto || s.manual) && (!s.workspaceHold || s.manual));
57
60
  }
58
61
 
59
- function shellQuote(arg) {
60
- return /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`;
61
- }
62
-
63
- // The reopen command runs inside a fresh tmux window, i.e. with the tmux
64
- // SERVER's environment — which may lack npm globals or nvm's node on PATH.
65
- // Always embed absolute paths. UNSNOOZE_SELF is the test-harness override.
66
62
  function selfCommand() {
67
63
  return process.env.UNSNOOZE_SELF
68
64
  ? [process.env.UNSNOOZE_SELF]
69
65
  : [process.execPath, UNSNOOZE_BIN];
70
66
  }
71
67
 
72
- // Decide how to act on one due record. Pure-ish; tmux injectable.
73
- // Returns: 'sent' | 'reopened' | 'deferred' | 'skip' | 'failed'
74
- export async function dispatchOne(rec, { tmux = realTmux, resumeMessage, selfCmd = selfCommand(), fingerprint = workspaceFingerprint, notifier = notify } = {}) {
68
+ export function resolveRecordMux(rec) {
69
+ return getMultiplexer(rec.mux, { owner: rec.paneOwner });
70
+ }
71
+
72
+ function reopenEnv(rec, leaseId) {
73
+ const env = { ...(rec.env || {}) };
74
+ env.UNSNOOZE_MUX = rec.mux;
75
+ if (rec.muxSession) env.UNSNOOZE_PANE_OWNER = rec.muxSession;
76
+ env.UNSNOOZE_LEASE_ID = leaseId;
77
+ return env;
78
+ }
79
+
80
+ async function driveMenu(mux, pane, agent, text) {
81
+ const steps = agent.menu.stepsToWait(text, PANE_SCAN_LINES);
82
+ if (steps === null) return false;
83
+ const key = steps > 0 ? 'Down' : 'Up';
84
+ for (let i = 0; i < Math.abs(steps); i++) await mux.sendKey(pane, key);
85
+ await mux.sendKey(pane, 'Enter');
86
+ return true;
87
+ }
88
+
89
+ // Ordered safety decision. Returns: busy | retry | progress | held | injected | reopen.
90
+ export async function dispatchOne(rec, {
91
+ mux = resolveRecordMux(rec), resolveMux = null,
92
+ resumeMessage, selfCmd = selfCommand(), fingerprint = workspaceFingerprint,
93
+ notifier = notify, matchesLease = leaseMatches,
94
+ } = {}) {
95
+ resolveMux ||= () => mux;
75
96
  const key = rec.key;
76
97
  const agent = getAgent(rec.agent);
77
98
  // Wake-message precedence: per-session (`unsnooze message <id> "..."`) →
@@ -88,7 +109,7 @@ export async function dispatchOne(rec, { tmux = realTmux, resumeMessage, selfCmd
88
109
  const desc = describeChange(change);
89
110
  if (guardMode === 'pause') {
90
111
  setStatus(key, 'stopped', { workspaceHold: true, holdReason: desc });
91
- notifier('unsnooze: session held', `${rec.cwd}: workspace changed while stopped (${desc}) — run: unsnooze resume-now`);
112
+ notifier('unsnooze: session held', `${rec.cwd}: workspace changed while stopped (${desc}) — run: unsnooze resume-now`, { context: ctxOf(rec) });
92
113
  log(`${key}: workspace changed (${desc}) — held (workspaceGuard=pause)`);
93
114
  return 'held';
94
115
  }
@@ -100,76 +121,134 @@ export async function dispatchOne(rec, { tmux = realTmux, resumeMessage, selfCmd
100
121
  // Live-pane path: only if the pane still exists AND the agent CLI is its
101
122
  // foreground command (pane ids get recycled — never inject into a random
102
123
  // program).
103
- if (rec.pane && await tmux.paneAlive(rec.pane)) {
104
- const cmd = await tmux.paneCurrentCommand(rec.pane);
105
- if (agent.isForegroundCommand(cmd)) {
106
- const text = await tmux.capturePane(rec.pane, CAPTURE_LINES).catch(() => '');
107
- if (isBusy(text, agent.patterns.busyPatterns)) return 'deferred';
108
- setStatus(key, 'resuming', { lastAttemptAt: Date.now() });
109
- await tmux.sendText(rec.pane, resumeMessage);
110
- log(`${key}: sent continue to live pane ${rec.pane}`);
111
- return 'sent';
124
+ if (rec.pane && await mux.paneAlive(rec.pane)) {
125
+ let text;
126
+ try { text = await mux.capturePane(rec.pane, CAPTURE_LINES); }
127
+ catch (err) {
128
+ setStatus(key, 'stopped', { lastError: `capture: ${err.message}` });
129
+ return 'retry';
130
+ }
131
+ if (isBusy(text, agent.patterns.busyPatterns)) return 'busy';
132
+ const d = detectLimit(text, PANE_SCAN_LINES, agent.patterns);
133
+ const menu = !!(agent.menu && agent.menu.isPrompt(text, PANE_SCAN_LINES));
134
+ let cmd = null;
135
+ try { cmd = await mux.paneCurrentCommand(rec.pane); }
136
+ catch (err) { log(`${key}: pane command lookup failed: ${err.message}`); }
137
+ const contentOwned = menu || d.hit || agent.patterns.idleRegex.test(text);
138
+ const leased = await matchesLease(rec, { mux });
139
+ const owned = leased || agent.isForegroundCommand(cmd);
140
+ const authorized = owned && contentOwned;
141
+
142
+ if (menu) {
143
+ if (!authorized) return reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd });
144
+ if (!getConfig('menuAutoAnswer')) return 'held';
145
+ try {
146
+ if (await driveMenu(mux, rec.pane, agent, text)) return 'progress';
147
+ setStatus(key, 'stopped', { lastError: 'menu drive: wait option unavailable' });
148
+ } catch (err) {
149
+ setStatus(key, 'stopped', { lastError: `menu drive: ${err.message}` });
150
+ }
151
+ return 'retry';
152
+ }
153
+ if (authorized) {
154
+ setStatus(key, 'resuming', { lastAttemptAt: Date.now(), lastError: null, verifyRetries: 0 });
155
+ await mux.sendText(rec.pane, resumeMessage);
156
+ log(`${key}: sent continue via ${rec.mux} ${rec.paneOwner ?? '-'}:${rec.pane}`);
157
+ return 'injected';
112
158
  }
113
- // Pane alive but the agent gone (user's shell now) fall through to
114
- // re-open; never hijack a shell prompt with a chat message.
159
+ if (owned) return 'busy';
115
160
  }
116
161
 
117
- // Re-open path: new tmux window in the well-known session, resume by id.
118
- // Records from sandboxed GUI sessions carry env (e.g. CLAUDE_CONFIG_DIR) the
119
- // revived CLI needs to find its session store.
162
+ return reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd });
163
+ }
164
+
165
+ async function reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd }) {
166
+ const key = rec.key;
120
167
  const resume = agent.resumeArgs(rec.sessionId, resumeMessage);
121
- // `env K=V cmd`, not a bare `K=V cmd` prefix: the command runs in the
122
- // user's default shell, and fish/nushell reject POSIX prefix assignments.
123
- const envPrefix = rec.env
124
- ? 'env ' + Object.entries(rec.env).map(([k, v]) => `${k}=${shellQuote(String(v))}`).join(' ') + ' '
125
- : '';
126
- const command = envPrefix + [...selfCmd, '_run', agent.id, ...resume.args].map(shellQuote).join(' ');
127
- setStatus(key, 'resuming', { lastAttemptAt: Date.now() });
128
- let newPane;
168
+ const leaseId = createLeaseId();
169
+ const launchSpec = {
170
+ file: selfCmd[0], args: [...selfCmd.slice(1), '_run', agent.id, ...resume.args],
171
+ env: reopenEnv(rec, leaseId),
172
+ };
173
+ setStatus(key, 'resuming', { lastAttemptAt: Date.now(), verifyRetries: 0 });
174
+ let address;
129
175
  try {
130
- // cwd can be null on watcher records (unreadable rollout head) execFile
131
- // rejects null args, so fall back to the home dir rather than crash-loop.
132
- newPane = await tmux.newWindow(rec.tmuxSession || TMUX_SESSION_NAME, rec.cwd || homedir(), command);
176
+ address = await mux.newWindow(rec.muxSession ?? rec.tmuxSession ?? MUX_SESSION_NAME,
177
+ rec.cwd || homedir(), launchSpec);
133
178
  } catch (err) {
134
- setStatus(key, 'stopped', { attempts: (rec.attempts || 0) + 1, lastError: `new-window: ${err.message}` });
135
- return 'failed';
179
+ setStatus(key, 'stopped', {
180
+ attempts: (rec.attempts || 0) + 1,
181
+ lastError: `new-window: ${err.message}`,
182
+ verifyRetries: 0,
183
+ });
184
+ return 'retry';
136
185
  }
137
186
  updateState(state => {
138
187
  const s = state.sessions[key];
139
- if (s) s.pane = newPane;
188
+ if (s) Object.assign(s, address, { leaseId });
140
189
  });
141
- log(`${key}: re-opened in pane ${newPane} (${command})`);
190
+ const rebound = { ...rec, ...address, leaseId };
191
+ mux = resolveMux(rebound);
192
+ log(`${key}: re-opened via ${rec.mux} ${address.paneOwner ?? '-'}:${address.pane}`);
142
193
 
143
194
  // The resume prompt traveled in argv (e.g. `codex resume <id> "msg"`) —
144
195
  // nothing to type into the TUI; verifyOne checks the outcome later.
145
- if (!resume.messageViaPane) return 'reopened';
196
+ if (!resume.messageViaPane) return 'reopen';
146
197
 
147
198
  // Wait for the TUI to be ready (input prompt visible), then send the message.
148
199
  const deadline = Date.now() + READY_TIMEOUT_MS;
149
200
  while (Date.now() < deadline) {
150
201
  await sleep(2000);
151
- const text = await tmux.capturePane(newPane, CAPTURE_LINES).catch(() => '');
202
+ let text;
203
+ try { text = await mux.capturePane(address.pane, CAPTURE_LINES); }
204
+ catch { continue; }
152
205
  if ((agent.menu && agent.menu.isPrompt(text, PANE_SCAN_LINES)) || detectLimit(text, PANE_SCAN_LINES, agent.patterns).hit) {
153
206
  // Limit hadn't actually reset — the fresh session hit it immediately.
154
- return 'failed';
207
+ return 'reopen';
155
208
  }
156
209
  // The idle input box: a prompt glyph with no busy footer.
157
210
  if (!isBusy(text, agent.patterns.busyPatterns) && agent.patterns.idleRegex.test(text)) {
158
- await tmux.sendText(newPane, resumeMessage);
159
- log(`${key}: resume message sent to new pane ${newPane}`);
160
- return 'reopened';
211
+ await mux.sendText(address.pane, resumeMessage);
212
+ log(`${key}: resume message sent to new pane ${address.pane}`);
213
+ return 'reopen';
161
214
  }
162
215
  }
163
- setStatus(key, 'stopped', { attempts: (rec.attempts || 0) + 1, lastError: 'ready timeout' });
164
- return 'failed';
216
+ setStatus(key, 'stopped', {
217
+ attempts: (rec.attempts || 0) + 1,
218
+ lastError: 'ready timeout',
219
+ verifyRetries: 0,
220
+ });
221
+ return 'retry';
222
+ }
223
+
224
+ function recordVerifyRetry(rec, lastError) {
225
+ const verifyRetries = (rec.verifyRetries || 0) + 1;
226
+ if (verifyRetries >= MAX_VERIFY_RETRIES) {
227
+ setStatus(rec.key, 'stopped', {
228
+ attempts: (rec.attempts || 0) + 1,
229
+ lastError,
230
+ verifyRetries: 0,
231
+ });
232
+ } else {
233
+ setStatus(rec.key, 'resuming', { lastError, verifyRetries });
234
+ }
235
+ return 'retry';
165
236
  }
166
237
 
167
238
  // Post-dispatch verification: did the limit banner come back?
168
- export async function verifyOne(key, { tmux = realTmux } = {}) {
239
+ export async function verifyOne(key, { resolveMux = resolveRecordMux } = {}) {
169
240
  const rec = readState().sessions[key];
170
241
  if (!rec || rec.status !== 'resuming') return;
171
242
  const agent = getAgent(rec.agent);
172
- const text = rec.pane ? await tmux.capturePane(rec.pane, CAPTURE_LINES).catch(() => '') : '';
243
+ if (!rec.pane) {
244
+ return recordVerifyRetry(rec, 'verify: pane unavailable');
245
+ }
246
+ const mux = resolveMux(rec);
247
+ let text;
248
+ try { text = await mux.capturePane(rec.pane, CAPTURE_LINES); }
249
+ catch (err) {
250
+ return recordVerifyRetry(rec, `verify capture: ${err.message}`);
251
+ }
173
252
  const d = detectLimit(text, PANE_SCAN_LINES, agent.patterns);
174
253
  if (d.hit || (agent.menu && agent.menu.isPrompt(text, PANE_SCAN_LINES))) {
175
254
  // Limit not actually reset — reschedule from the fresh banner.
@@ -178,22 +257,49 @@ export async function verifyOne(key, { tmux = realTmux } = {}) {
178
257
  });
179
258
  setStatus(key, 'stopped', {
180
259
  attempts: (rec.attempts || 0) + 1, resetAt: at, resetSource: source,
181
- lastError: 'limit still active at resume time',
260
+ lastError: 'limit still active at resume time', verifyRetries: 0,
182
261
  });
183
262
  log(`${key}: limit still active, rescheduled to ${new Date(at).toISOString()}`);
184
263
  return;
185
264
  }
186
- setStatus(key, 'resumed');
265
+ setStatus(key, 'resumed', { lastError: null, verifyRetries: 0 });
187
266
  log(`${key}: verified resumed`);
188
267
  const fromGui = rec.origin && rec.origin !== 'cli';
189
- notify('unsnoozed ✅', `${rec.cwd} is running again${fromGui ? ` (was in ${rec.origin} — revived in tmux)` : ''}`);
268
+ notify('unsnoozed ✅', `${rec.cwd} is running again${fromGui ? ` (was in ${rec.origin} — revived in ${rec.mux})` : ''}`, { context: ctxOf(rec) });
269
+ return 'resumed';
270
+ }
271
+
272
+ export function routeDispatchOutcome(result, rec, deferCounts, { maxBusyDefers = MAX_BUSY_DEFERS } = {}) {
273
+ if (result === 'busy') {
274
+ const n = (deferCounts.get(rec.key) || 0) + 1;
275
+ deferCounts.set(rec.key, n);
276
+ if (n > maxBusyDefers) {
277
+ setStatus(rec.key, 'resumed', { lastError: null, verifyRetries: 0 });
278
+ return { verify: false, waitBusy: false };
279
+ }
280
+ return { verify: false, waitBusy: true };
281
+ }
282
+ if (result === 'retry') {
283
+ setStatus(rec.key, 'stopped', {
284
+ attempts: (rec.attempts || 0) + 1,
285
+ lastError: readState().sessions[rec.key]?.lastError,
286
+ verifyRetries: 0,
287
+ });
288
+ return { verify: false, waitBusy: false };
289
+ }
290
+ if (result === 'progress') {
291
+ setStatus(rec.key, 'stopped', { lastError: null, verifyRetries: 0 });
292
+ return { verify: false, waitBusy: false };
293
+ }
294
+ if (result === 'held') return { verify: false, waitBusy: false };
295
+ return { verify: result === 'injected' || result === 'reopen', waitBusy: false };
190
296
  }
191
297
 
192
298
  // persistent: never exit on an empty ledger (daemon mode — `unsnooze daemon`,
193
299
  // launchd/systemd). watcher: transcript watcher ticked every loop, so GUI
194
300
  // sessions are detected without a hook or pane. signal: clean shutdown.
195
301
  export async function runResumer({
196
- tmux = realTmux, pollInterval = POLL_INTERVAL_MS,
302
+ resolveMux = resolveRecordMux, pollInterval = POLL_INTERVAL_MS,
197
303
  persistent = false, watcher = null, signal = null,
198
304
  } = {}) {
199
305
  // A transient hook-spawned resumer may hold the lock right now; a daemon
@@ -230,33 +336,26 @@ export async function runResumer({
230
336
  // Anything over the attempts cap is dead — mark failed so we can exit.
231
337
  for (const s of dueForDispatch()) {
232
338
  if ((s.attempts || 0) >= MAX_RESUME_ATTEMPTS) {
233
- setStatus(s.key, 'failed', { lastError: 'max resume attempts exceeded' });
339
+ setStatus(s.key, 'failed', { lastError: 'max resume attempts exceeded', verifyRetries: 0 });
234
340
  log(`${s.key}: giving up after ${s.attempts} attempts`);
235
- notify('unsnooze gave up ⚠️', `${s.cwd}: ${s.attempts} resume attempts failed — check \`unsnooze status\``);
341
+ notify('unsnooze gave up ⚠️', `${s.cwd}: ${s.attempts} resume attempts failed — check \`unsnooze status\``, { context: ctxOf(s) });
236
342
  }
237
343
  }
238
344
 
239
345
  const dispatched = [];
240
346
  for (const rec of due) {
241
- const result = await dispatchOne(rec, { tmux });
242
- if (result === 'deferred') {
243
- const n = (deferCounts.get(rec.key) || 0) + 1;
244
- deferCounts.set(rec.key, n);
245
- if (n > MAX_BUSY_DEFERS) {
246
- setStatus(rec.key, 'resumed', { lastError: null });
247
- log(`${rec.key}: busy through ${n} defers — it is clearly working, marking resumed`);
248
- } else {
249
- await sleep(BUSY_DEFER_MS);
250
- }
251
- continue;
252
- }
253
- if (result === 'sent' || result === 'reopened') dispatched.push(rec.key);
347
+ const result = await dispatchOne(rec, { mux: resolveMux(rec), resolveMux });
348
+ const routed = routeDispatchOutcome(result, rec, deferCounts);
349
+ if (routed.waitBusy) await sleep(BUSY_DEFER_MS);
350
+ if (routed.verify) dispatched.push(rec.key);
351
+ if (!routed.verify) continue;
254
352
  if (due.indexOf(rec) < due.length - 1) await sleep(STAGGER_MS);
255
353
  }
256
354
 
257
- if (dispatched.length > 0) {
355
+ const verifying = [...new Set([...resuming.map(rec => rec.key), ...dispatched])];
356
+ if (verifying.length > 0) {
258
357
  await sleep(VERIFY_DELAY_MS);
259
- for (const key of dispatched) await verifyOne(key, { tmux });
358
+ for (const key of verifying) await verifyOne(key, { resolveMux });
260
359
  }
261
360
 
262
361
  await sleep(pollInterval);
package/src/settings.js CHANGED
@@ -13,9 +13,11 @@ import { STATE_DIR } from './config.js';
13
13
  export const CONFIG_FILE = () => join(process.env.UNSNOOZE_STATE_DIR || STATE_DIR, 'config.json');
14
14
 
15
15
  export const DEFAULTS = {
16
+ multiplexer: 'auto', // auto | tmux | zellij
16
17
  autoResume: true, // master switch: dispatch resumes when limits reset
17
18
  menuAutoAnswer: true, // may unsnooze drive Claude's limit menu (send keys)?
18
19
  notifications: true, // desktop notifications on detect/resume
20
+ notifyChannel: 'auto', // auto | native | osc | bell
19
21
  guiWatch: true, // daemon watches transcripts/rollouts for GUI-session stops
20
22
  updateCheck: true, // daily registry version check + update notices/toast
21
23
  workspaceGuard: 'inform', // repo changed while stopped: off | inform | pause
@@ -26,9 +28,11 @@ export const DEFAULTS = {
26
28
 
27
29
  // Env override per key. Booleans accept 1/0, true/false, on/off, yes/no.
28
30
  const ENV_NAMES = {
31
+ multiplexer: 'UNSNOOZE_MULTIPLEXER',
29
32
  autoResume: 'UNSNOOZE_AUTO_RESUME',
30
33
  menuAutoAnswer: 'UNSNOOZE_MENU_AUTO_ANSWER',
31
34
  notifications: 'UNSNOOZE_NOTIFICATIONS',
35
+ notifyChannel: 'UNSNOOZE_NOTIFY_CHANNEL',
32
36
  guiWatch: 'UNSNOOZE_GUI_WATCH',
33
37
  updateCheck: 'UNSNOOZE_UPDATE_CHECK',
34
38
  workspaceGuard: 'UNSNOOZE_WORKSPACE_GUARD',
@@ -53,7 +57,9 @@ const KNOWN_KEYS = Object.keys(ENV_NAMES);
53
57
 
54
58
  // String settings restricted to a fixed set of values.
55
59
  const ENUMS = {
60
+ multiplexer: ['auto', 'tmux', 'zellij'],
56
61
  workspaceGuard: ['off', 'inform', 'pause'],
62
+ notifyChannel: ['auto', 'native', 'osc', 'bell'],
57
63
  };
58
64
 
59
65
  function parseBool(raw) {
package/src/state.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  } from './config.js';
16
16
  import { workspaceFingerprint } from './workspace.js';
17
17
  import { makeLogger } from './logger.js';
18
+ import { addressHash } from './lease.js';
18
19
 
19
20
  const log = makeLogger('state');
20
21
 
@@ -53,7 +54,7 @@ function releaseLock() {
53
54
 
54
55
  export function readState() {
55
56
  try {
56
- return JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
57
+ return normalizeState(JSON.parse(readFileSync(STATE_FILE, 'utf-8')));
57
58
  } catch (err) {
58
59
  if (err.code === 'ENOENT') return EMPTY();
59
60
  // Corrupt file: quarantine loudly, start fresh — never crash the hook path.
@@ -66,6 +67,20 @@ export function readState() {
66
67
  }
67
68
  }
68
69
 
70
+ function normalizeState(state) {
71
+ if (!state || typeof state !== 'object') return EMPTY();
72
+ state.sessions ||= {};
73
+ for (const rec of Object.values(state.sessions)) normalizeRecord(rec);
74
+ return state;
75
+ }
76
+
77
+ function normalizeRecord(rec) {
78
+ if (!rec.mux) rec.mux = 'tmux';
79
+ if (!rec.muxSession && rec.tmuxSession) rec.muxSession = rec.tmuxSession;
80
+ if (rec.mux === 'tmux' && rec.paneOwner === undefined) rec.paneOwner = null;
81
+ return rec;
82
+ }
83
+
69
84
  // Locked read-modify-write. mutator receives the state object and mutates it
70
85
  // (or returns a replacement). Returns the final state.
71
86
  export function updateState(mutator) {
@@ -87,11 +102,14 @@ export function updateState(mutator) {
87
102
  // if a record for the same pane was created within DEDUPE_WINDOW_MS, merge into
88
103
  // it (a record WITH a sessionId wins over one without).
89
104
  export function upsertSession(record) {
105
+ record = normalizeRecord({ ...record });
90
106
  return updateState(state => {
91
107
  prune(state);
92
108
  const existingKey = findDuplicate(state, record);
93
109
  if (existingKey) {
94
110
  const existing = state.sessions[existingKey];
111
+ const staleBanner = existing.status === 'resumed' && !existing.bannerCleared
112
+ && record.status === 'stopped';
95
113
  const merged = {
96
114
  ...existing,
97
115
  ...record,
@@ -99,7 +117,11 @@ export function upsertSession(record) {
99
117
  sessionId: record.sessionId || existing.sessionId,
100
118
  // A detection that races an in-flight resume must not flip the record
101
119
  // back to 'stopped' — the post-resume verify pass owns that outcome.
102
- status: existing.status === 'resuming' ? existing.status : record.status,
120
+ status: existing.status === 'resuming'
121
+ || staleBanner
122
+ ? existing.status : record.status,
123
+ attempts: staleBanner ? existing.attempts : record.attempts,
124
+ lastAttemptAt: staleBanner ? existing.lastAttemptAt : record.lastAttemptAt,
103
125
  key: existingKey,
104
126
  };
105
127
  state.sessions[existingKey] = merged;
@@ -111,7 +133,7 @@ export function upsertSession(record) {
111
133
  if (record.status === 'stopped' && record.workspace === undefined) {
112
134
  record.workspace = workspaceFingerprint(record.cwd);
113
135
  }
114
- const key = record.sessionId || `pane:${record.pane}:${record.detectedAt}`;
136
+ const key = record.sessionId || `pane:${addressHash(record)}:${record.detectedAt}`;
115
137
  state.sessions[key] = { ...record, key };
116
138
  return state;
117
139
  });
@@ -126,8 +148,9 @@ function findDuplicate(state, record) {
126
148
  // 'resuming' counts too: while the resumer types into a pane, a scrape can
127
149
  // still see the banner for a few hundred ms — that must not fork a second
128
150
  // record (it would double-resume the session).
129
- if (s.pane && s.pane === record.pane
130
- && (s.status === 'stopped' || s.status === 'resuming')
151
+ if (s.pane && record.pane && addressHash(s) === addressHash(record)
152
+ && (s.status === 'stopped' || s.status === 'resuming'
153
+ || (s.status === 'resumed' && !s.bannerCleared))
131
154
  && Math.abs((s.detectedAt || 0) - record.detectedAt) < DEDUPE_WINDOW_MS) {
132
155
  return key;
133
156
  }
@@ -138,7 +161,7 @@ function findDuplicate(state, record) {
138
161
  // writes the very transcript the watcher reads, so same-cwd evidence is
139
162
  // almost always the same session, and the alternative (two records) would
140
163
  // double-resume it.
141
- if (record.sessionId && !s.sessionId
164
+ if (record.sessionId && !s.sessionId && (!record.pane || !s.pane)
142
165
  && s.agent === record.agent && s.cwd && s.cwd === record.cwd
143
166
  && s.status === 'stopped'
144
167
  && Math.abs((s.detectedAt || 0) - record.detectedAt) < DEDUPE_WINDOW_MS) {
package/src/watcher.js CHANGED
@@ -18,8 +18,9 @@ import { join, sep, basename, dirname } from 'node:path';
18
18
  import { homedir } from 'node:os';
19
19
  import {
20
20
  CLAUDE_DIR, CODEX_DIR, WATCH_OFFSETS_FILE, WATCH_FRESHNESS_MS,
21
- RESET_MARGIN_MS, FALLBACK_RESET_MS, TMUX_SESSION_NAME,
21
+ RESET_MARGIN_MS, FALLBACK_RESET_MS, MUX_SESSION_NAME,
22
22
  } from './config.js';
23
+ import { getMultiplexer } from './multiplexer.js';
23
24
  import { parseTranscriptLine } from './watchers/claude.js';
24
25
  import { parseRolloutLine, rolloutMeta } from './watchers/codex.js';
25
26
  import { ROLLOUT_RE } from './agents/codex.js';
@@ -177,7 +178,8 @@ export function dispatchCandidate(c) {
177
178
  cwd: c.cwd || null,
178
179
  agent: c.agent,
179
180
  origin: c.origin || null,
180
- tmuxSession: TMUX_SESSION_NAME,
181
+ mux: getMultiplexer().name, paneOwner: null, leaseId: null,
182
+ muxSession: MUX_SESSION_NAME,
181
183
  status: 'stopped',
182
184
  limitType: c.limitType || 'unknown',
183
185
  detectedVia: 'transcript',
package/src/wizard.js CHANGED
@@ -24,11 +24,12 @@ function defaultWhich(bin) {
24
24
  export async function runWizard() {
25
25
  p.intro('unsnooze — wake every limit-stopped AI coding session automatically');
26
26
 
27
- const { tmuxAvailable } = await import('./tmux.js');
28
- if (!tmuxAvailable()) {
27
+ const { getMultiplexer } = await import('./multiplexer.js');
28
+ const mux = getMultiplexer();
29
+ if (!mux.available()) {
29
30
  p.log.warn(process.platform === 'win32'
30
- ? 'tmux not found — unsnooze does not support native Windows.\nRun it inside WSL (https://learn.microsoft.com/windows/wsl/install), where tmux and the wrappers work normally.'
31
- : 'tmux not found — unsnooze needs tmux to watch and revive sessions.\nInstall it first (brew install tmux / apt install tmux), then re-run `unsnooze setup`.');
31
+ ? 'No supported multiplexer found — unsnooze does not support native Windows.\nRun it inside WSL.'
32
+ : `${mux.name} not found — install it, then re-run \`unsnooze setup\`.`);
32
33
  }
33
34
 
34
35
  const detected = detectInstalledAgents();
@@ -75,7 +76,7 @@ export async function runWizard() {
75
76
  const answer = await p.confirm({
76
77
  message: 'Also guard GUI sessions (Claude Code in VS Code/desktop, Codex app/IDE)?\n'
77
78
  + ' Installs a small background daemon (launchd/systemd) that watches session\n'
78
- + ' files for limit stops; revived sessions open in tmux and stay visible in\n'
79
+ + ' files for limit stops; revived sessions open in the configured multiplexer and stay visible in\n'
79
80
  + ' the GUI\'s own history.',
80
81
  initialValue: DEFAULTS.guiWatch,
81
82
  });
package/src/tmux.js DELETED
@@ -1,95 +0,0 @@
1
- import { execFile as execFileCb, spawnSync } from 'node:child_process';
2
- import { promisify } from 'node:util';
3
-
4
- const execFileAsync = promisify(execFileCb);
5
-
6
- // Is tmux runnable at all? Used for a friendly error instead of a cryptic
7
- // spawn failure (native Windows users get pointed at WSL).
8
- export function tmuxAvailable() {
9
- try {
10
- return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
11
- } catch {
12
- return false;
13
- }
14
- }
15
-
16
- // Submit delay: text and the submitting Enter must be TWO separate send-keys
17
- // calls with a pause between them, or Ink (Claude Code's TUI) treats the Enter
18
- // as a newline inside a bracketed-paste burst instead of a submit.
19
- export const SUBMIT_DELAY_MS = 150;
20
-
21
- async function tmux(...args) {
22
- const { stdout } = await execFileAsync('tmux', args);
23
- return stdout;
24
- }
25
-
26
- export function insideTmux() { return !!process.env.TMUX; }
27
- export function currentPaneId() { return process.env.TMUX_PANE || null; }
28
-
29
- export async function capturePane(pane, lines = 200) {
30
- return tmux('capture-pane', '-t', pane, '-p', '-S', `-${lines}`);
31
- }
32
-
33
- // Visible screen ONLY (no scrollback). Interactive menus must be checked
34
- // against this: a menu that scrolled into history was already answered —
35
- // re-driving it sends stray keystrokes into whatever is foreground now.
36
- export async function capturePaneVisible(pane) {
37
- return tmux('capture-pane', '-t', pane, '-p');
38
- }
39
-
40
- // Type a message into a TUI and submit it (split form; -l sends literally so
41
- // tmux key names inside the message are typed, not interpreted).
42
- export async function sendText(pane, text) {
43
- await tmux('send-keys', '-t', pane, '-l', text);
44
- await new Promise(r => setTimeout(r, SUBMIT_DELAY_MS));
45
- await tmux('send-keys', '-t', pane, 'Enter');
46
- }
47
-
48
- // Single named key ('Down', 'Up', 'Enter', 'Escape') — drives menus.
49
- export async function sendKey(pane, key) {
50
- await tmux('send-keys', '-t', pane, key);
51
- }
52
-
53
- export async function paneAlive(pane) {
54
- try {
55
- await tmux('display-message', '-t', pane, '-p', '#{pane_id}');
56
- return true;
57
- } catch {
58
- return false;
59
- }
60
- }
61
-
62
- export async function paneCurrentCommand(pane) {
63
- try {
64
- return (await tmux('display-message', '-t', pane, '-p', '#{pane_current_command}')).trim();
65
- } catch {
66
- return null;
67
- }
68
- }
69
-
70
- // NOTE: plain session names, not the '=name' exact-match prefix — tmux 3.7b
71
- // rejects '=name' as a target here ("name not found" despite the session
72
- // existing). Exact matches take priority over prefix matches anyway.
73
- export async function sessionExists(name) {
74
- try {
75
- await tmux('has-session', '-t', name);
76
- return true;
77
- } catch {
78
- return false;
79
- }
80
- }
81
-
82
- // Open a new window in the named session (creating the session if needed),
83
- // cd'd to cwd, running command. Returns the new pane id.
84
- export async function newWindow(sessionName, cwd, command) {
85
- if (!(await sessionExists(sessionName))) {
86
- await tmux('new-session', '-d', '-s', sessionName, '-c', cwd);
87
- // The fresh session's first window IS our window — run the command there.
88
- const pane = (await tmux('display-message', '-t', sessionName, '-p', '#{pane_id}')).trim();
89
- await tmux('send-keys', '-t', pane, '-l', command);
90
- await tmux('send-keys', '-t', pane, 'Enter');
91
- return pane;
92
- }
93
- const out = await tmux('new-window', '-t', `${sessionName}:`, '-c', cwd, '-P', '-F', '#{pane_id}', command);
94
- return out.trim();
95
- }