unsnooze 1.6.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.
@@ -0,0 +1,105 @@
1
+ // OpenCode adapter (sst/opencode, command `opencode`) — EXPERIMENTAL.
2
+ //
3
+ // OpenCode is unusual: it self-retries rate limits FOREVER, honoring
4
+ // retry-after headers (it will happily sleep 3 hours until a reset), showing a
5
+ // status line like "Rate Limited [retrying in 2h5m attempt #4]". The session
6
+ // only stops on non-retryable errors (402 credits, auth, context overflow) or
7
+ // user abort. So the retry banner is BOTH a limit anchor and a busy pattern:
8
+ // - a LIVE self-retrying pane records a stop but is busy → the resumer
9
+ // defers, and the monitor flips the record to 'resumed' when the banner
10
+ // clears (OpenCode recovered on its own)
11
+ // - a DEAD pane mid-wait (laptop slept, tmux killed) is revived at the reset
12
+ // via `opencode -s <ses_id>`
13
+ // Banner strings are verbatim from packages/opencode/src/session/retry.ts and
14
+ // the console zen i18n; the bracketed countdown parses via time-parser's
15
+ // Go-duration support.
16
+
17
+ import { execFileSync } from 'node:child_process';
18
+
19
+ const LIMIT_ANCHORS = [
20
+ /(?:5.hour|weekly|monthly) usage limit reached/i, // Zen Go plans
21
+ /usage limit reached/i,
22
+ /Free usage exceeded/i, // Zen free tier
23
+ /Subscription quota exceeded/i, // Zen Black
24
+ /Rate Limited \[retrying/i, // status-line retry banner
25
+ /Too Many Requests \[retrying/i,
26
+ /Rate limit exceeded: limit_/i, // OpenRouter limit_rpd/limit_rpm
27
+ /free models per day/i, // OpenRouter free-tier daily
28
+ ];
29
+
30
+ const RETRY_BANNER = /\[retrying(?: in [^\]]+)? attempt #\d+\]/i;
31
+
32
+ export const patterns = {
33
+ limitPatterns: LIMIT_ANCHORS,
34
+ resetPatterns: [
35
+ /It will reset in/i,
36
+ /Resets? in/i,
37
+ /Retry in/i,
38
+ /\[retrying in/i,
39
+ ...LIMIT_ANCHORS,
40
+ ],
41
+ weeklyPatterns: [/weekly usage limit/i],
42
+ fiveHourPatterns: [/5.hour usage limit/i],
43
+ busyPatterns: [
44
+ RETRY_BANNER, // self-retrying — OpenCode is handling it
45
+ /esc (?:again to )?interrupt/i,
46
+ ],
47
+ idleRegex: /[›❯>]/,
48
+ overloadPatterns: [/Provider is overloaded/i],
49
+ transientPatterns: [/Provider is overloaded \[retrying/i],
50
+ // Non-retryable, non-resetting stops: notify-only.
51
+ terminalPatterns: [/insufficient credits/i, /out of credits/i],
52
+ };
53
+
54
+ function runSessionList() {
55
+ return execFileSync(process.env.UNSNOOZE_OPENCODE_BIN || 'opencode',
56
+ ['session', 'list', '--format', 'json'],
57
+ { timeout: 5000, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
58
+ }
59
+
60
+ // `opencode session list --format json` — sessions live in a SQLite db, so ask
61
+ // the CLI rather than parsing it. Output tolerated as a JSON array or JSONL.
62
+ export function latestSessionId(cwd, aroundTs = null, runner = runSessionList) {
63
+ let raw;
64
+ try { raw = runner(); } catch { return null; }
65
+ let sessions = [];
66
+ try {
67
+ const parsed = JSON.parse(raw);
68
+ sessions = Array.isArray(parsed) ? parsed : [parsed];
69
+ } catch {
70
+ for (const line of String(raw).split('\n')) {
71
+ if (!line.trim()) continue;
72
+ try { sessions.push(JSON.parse(line)); } catch { /* skip noise lines */ }
73
+ }
74
+ }
75
+ let best = null;
76
+ for (const s of sessions) {
77
+ if (!s || typeof s !== 'object') continue;
78
+ const dir = s.directory ?? s.dir ?? s.cwd;
79
+ const id = s.id ?? s.sessionID ?? s.session_id;
80
+ if (dir !== cwd || !id) continue;
81
+ const updated = s.time_updated ?? s.time?.updated ?? 0;
82
+ if (!best || updated > best.updated) best = { id, updated };
83
+ }
84
+ return best ? best.id : null;
85
+ }
86
+
87
+ export default {
88
+ id: 'opencode',
89
+ name: 'OpenCode',
90
+ bin: process.env.UNSNOOZE_OPENCODE_BIN || 'opencode',
91
+ experimental: true,
92
+ patterns,
93
+ menu: null,
94
+ // `opencode -s <id>` reopens the TUI on that session; the resume message is
95
+ // typed once idle. (`opencode run -s <id> "msg"` exists but is headless-only.)
96
+ resumeArgs(sessionId) {
97
+ return { args: sessionId ? ['-s', sessionId] : ['--continue'], messageViaPane: true };
98
+ },
99
+ latestSessionId,
100
+ isForegroundCommand(cmd) {
101
+ // The npm-shipped bun binary reports pane_current_command "opencode.exe"
102
+ // even on macOS (verified live).
103
+ return /^opencode(\.exe)?$/.test(cmd) || cmd === 'node' || cmd === 'unsnooze';
104
+ },
105
+ };
@@ -0,0 +1,108 @@
1
+ // Qwen Code CLI adapter (QwenLM/qwen-code, command `qwen`) — EXPERIMENTAL.
2
+ //
3
+ // Limits landscape (researched 2026-07): the Qwen OAuth free tier was
4
+ // discontinued 2026-04-15 (that message is terminal, not a limit). The paid
5
+ // path is the Alibaba Coding Plan — a rolling 5-hour window + weekly quota
6
+ // whose exhaustion 429 (`Throttling.AllocationQuota`) is fail-fast: the CLI
7
+ // stops. Error strings below are verbatim from the qwen-code source
8
+ // (packages/core/src/utils/{retry,errorParsing}.ts). No reset time is ever
9
+ // shown or written to disk → detection leans on the 5h fallback + the
10
+ // verify/self-correct loop.
11
+ //
12
+ // Qwen also supports Claude-shaped JSON hooks in ~/.qwen/settings.json with a
13
+ // StopFailure event (matcher matches the error class: rate_limit | ... );
14
+ // install.js merges ours in when the agent is enabled.
15
+
16
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { join } from 'node:path';
19
+
20
+ const QWEN_DIR = () => process.env.UNSNOOZE_QWEN_DIR || join(homedir(), '.qwen');
21
+
22
+ const LIMIT_ANCHORS = [
23
+ /Qwen (?:OAuth|API) quota exceeded/i, // legacy free-tier / API-quota renders
24
+ /Allocated quota exceeded/i, // Coding Plan + "Free allocated quota exceeded"
25
+ /\[API Error:.*(?:429|rate limit)/i, // OpenAI-compat providers incl. OpenRouter
26
+ /Possible quota limitations in place/i, // qwen's own 429 suffix line
27
+ /Rate limit exceeded: limit_/i, // OpenRouter limit_rpd/limit_rpm bodies
28
+ ];
29
+
30
+ export const patterns = {
31
+ limitPatterns: LIMIT_ANCHORS,
32
+ // No reset text exists in any qwen limit render — anchors double as reset
33
+ // lines (codex/grok trick) and time-parser falls back to the 5h default.
34
+ resetPatterns: [...LIMIT_ANCHORS],
35
+ weeklyPatterns: [],
36
+ fiveHourPatterns: [/Allocated quota exceeded/i], // Coding Plan window is rolling 5h
37
+ // Transient throttles self-retry with a countdown — never inject keys then.
38
+ busyPatterns: [
39
+ /Retrying in \d+s/i, // "↻ Retrying in 5s… (attempt 2/5)"
40
+ /attempt \d+\/\d+/i,
41
+ /esc to cancel/i,
42
+ ],
43
+ idleRegex: /[›❯>]/,
44
+ overloadPatterns: [/\[API Error:.*5\d\d/i, /Retrying in \d+s/i],
45
+ transientPatterns: [/Retrying in \d+s/i],
46
+ // Non-resetting stops: notify-only, never the ledger.
47
+ terminalPatterns: [/free tier has been discontinued/i, /insufficient credits/i],
48
+ };
49
+
50
+ // qwen-code maps a cwd to a project dir by replacing every non-alphanumeric
51
+ // char with '-' (packages/core/src/utils/paths.ts sanitizeCwd) — close to but
52
+ // not identical with claude's scheme (which keeps '_').
53
+ export function sanitizeCwd(cwd) {
54
+ return cwd.replace(/[^a-zA-Z0-9]/g, '-');
55
+ }
56
+
57
+ const RUNTIME_RE = /\.runtime\.json$/;
58
+ const CHAT_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/i;
59
+
60
+ // v0.16+ writes a <sessionId>.runtime.json sidecar ({pid, session_id,
61
+ // work_dir, started_at}) next to each chat, purpose-built for observability
62
+ // daemons. Prefer it; fall back to the newest chat transcript by mtime.
63
+ export function latestSessionId(cwd, aroundTs = null, qwenDir = QWEN_DIR()) {
64
+ const chatsDir = join(qwenDir, 'projects', sanitizeCwd(cwd), 'chats');
65
+ let entries;
66
+ try { entries = readdirSync(chatsDir); } catch { return null; }
67
+
68
+ let best = null;
69
+ for (const name of entries) {
70
+ if (!RUNTIME_RE.test(name)) continue;
71
+ try {
72
+ const meta = JSON.parse(readFileSync(join(chatsDir, name), 'utf-8'));
73
+ if (meta.work_dir !== cwd || !meta.session_id) continue;
74
+ if (!best || (meta.started_at || 0) > best.startedAt) {
75
+ best = { id: meta.session_id, startedAt: meta.started_at || 0 };
76
+ }
77
+ } catch { /* unreadable sidecar — skip */ }
78
+ }
79
+ if (best) return best.id;
80
+
81
+ let newest = null;
82
+ for (const name of entries) {
83
+ if (!CHAT_RE.test(name)) continue;
84
+ let mtime;
85
+ try { mtime = statSync(join(chatsDir, name)).mtimeMs; } catch { continue; }
86
+ if (aroundTs != null && Math.abs(mtime - aroundTs) > 30 * 60_000) continue;
87
+ if (!newest || mtime > newest.mtime) newest = { id: name.slice(0, -6), mtime };
88
+ }
89
+ return newest ? newest.id : null;
90
+ }
91
+
92
+ export default {
93
+ id: 'qwen',
94
+ name: 'Qwen Code',
95
+ bin: process.env.UNSNOOZE_QWEN_BIN || 'qwen',
96
+ experimental: true,
97
+ patterns,
98
+ menu: null,
99
+ // `qwen --resume <id>` reopens the TUI; there is no verified
100
+ // resume-with-prompt argv form, so the message is typed once idle.
101
+ resumeArgs(sessionId) {
102
+ return { args: sessionId ? ['--resume', sessionId] : ['--continue'], messageViaPane: true };
103
+ },
104
+ latestSessionId,
105
+ isForegroundCommand(cmd) {
106
+ return cmd === 'qwen' || cmd === 'node' || cmd === 'unsnooze';
107
+ },
108
+ };
package/src/cli.js CHANGED
@@ -30,6 +30,7 @@ export function cmdStatus() {
30
30
  const id = s.sessionId ? s.sessionId.slice(0, 8) : '(no id)';
31
31
  const reset = s.resetAt ? `${new Date(s.resetAt).toLocaleString()} (${fmtCountdown(s.resetAt - now)})` : '?';
32
32
  const origin = s.origin ?? (s.pane ? 'cli' : '?');
33
+ const pane = s.paneOwner ? `${s.paneOwner}:${s.pane ?? '-'}` : (s.pane ?? '-');
33
34
  const msg = s.resumeMessage
34
35
  ? ` · msg: "${s.resumeMessage.length > 44 ? s.resumeMessage.slice(0, 44) + '…' : s.resumeMessage}"`
35
36
  : '';
@@ -37,7 +38,7 @@ export function cmdStatus() {
37
38
  ? ` · workspace changed (${s.holdReason ?? '?'}) — resume-now to wake`
38
39
  : '';
39
40
  console.log(` [${s.status.toUpperCase().padEnd(9)}] ${id} ${(s.agent || 'claude').padEnd(6)} ${s.limitType?.padEnd(7) ?? 'unknown'} ${s.cwd}`);
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}`);
41
+ console.log(` mux ${s.mux ?? '-'} · pane ${pane} · session ${s.muxSession ?? '-'} · via ${origin} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}${msg}${hold}`);
41
42
  }
42
43
  return 0;
43
44
  }
package/src/config.js CHANGED
@@ -20,7 +20,9 @@ export const CODEX_DIR = process.env.UNSNOOZE_CODEX_DIR || join(homedir(), '.cod
20
20
  // Transcript/rollout watcher (GUI detection channel)
21
21
  export const WATCH_OFFSETS_FILE = join(STATE_DIR, 'watch-offsets.json');
22
22
 
23
- export const TMUX_SESSION_NAME = process.env.UNSNOOZE_TMUX_SESSION || 'unsnooze';
23
+ export const MUX_SESSION_NAME = process.env.UNSNOOZE_SESSION_NAME
24
+ || process.env.UNSNOOZE_TMUX_SESSION || 'unsnooze';
25
+ export const TMUX_SESSION_NAME = MUX_SESSION_NAME; // read compatibility
24
26
 
25
27
  // Timing (ms unless noted)
26
28
  export const RESET_MARGIN_MS = envInt('UNSNOOZE_RESET_MARGIN_MS', 60_000);
@@ -48,4 +50,3 @@ export const DEDUPE_WINDOW_MS = envInt('UNSNOOZE_DEDUPE_WINDOW_MS', 120_000);
48
50
  export const PRUNE_AFTER_MS = envInt('UNSNOOZE_PRUNE_AFTER_MS', 7 * 86_400_000);
49
51
  export const STALE_LOCK_MS = envInt('UNSNOOZE_STALE_LOCK_MS', 10_000);
50
52
 
51
-
package/src/hook.js CHANGED
@@ -6,15 +6,16 @@
6
6
 
7
7
  import { mkdirSync, writeFileSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
- import { EVENTS_DIR, FALLBACK_RESET_MS, RESET_MARGIN_MS, CAPTURE_LINES, PANE_SCAN_LINES, TMUX_SESSION_NAME } from './config.js';
9
+ import { EVENTS_DIR, FALLBACK_RESET_MS, RESET_MARGIN_MS, CAPTURE_LINES, PANE_SCAN_LINES, MUX_SESSION_NAME } from './config.js';
10
10
  import { detectLimit } from './patterns.js';
11
11
  import { getAgent } from './agents/index.js';
12
12
  import { getConfig } from './settings.js';
13
13
  import { parseResetTime, resetAtMs } from './time-parser.js';
14
14
  import { upsertSession } from './state.js';
15
- import { capturePane } from './tmux.js';
15
+ import { getMultiplexer } from './multiplexer.js';
16
16
  import { spawnResumerIfNeeded } from './spawn.js';
17
17
  import { makeLogger } from './logger.js';
18
+ import { addressHash } from './lease.js';
18
19
 
19
20
  const log = makeLogger('hook');
20
21
 
@@ -44,7 +45,16 @@ export async function runHook(rest = []) {
44
45
  let payload = {};
45
46
  try { payload = JSON.parse(raw); } catch { /* tolerate non-JSON */ }
46
47
 
47
- const pane = process.env.TMUX_PANE || payload.tmux_pane || null;
48
+ const managedMux = process.env.UNSNOOZE_MUX;
49
+ const muxName = managedMux || (process.env.ZELLIJ_PANE_ID ? 'zellij' : 'tmux');
50
+ const pane = managedMux
51
+ ? (process.env.UNSNOOZE_PANE || null)
52
+ : (muxName === 'zellij' ? process.env.ZELLIJ_PANE_ID : process.env.TMUX_PANE || payload.tmux_pane) || null;
53
+ const paneOwner = muxName === 'zellij'
54
+ ? (managedMux ? process.env.UNSNOOZE_PANE_OWNER : process.env.ZELLIJ_SESSION_NAME) || null
55
+ : null;
56
+ const leaseId = process.env.UNSNOOZE_LEASE_ID || null;
57
+ const mux = getMultiplexer(muxName, { owner: paneOwner });
48
58
  const kind = classify(payload, raw);
49
59
  log(`StopFailure: kind=${kind} pane=${pane} session=${payload.session_id || '?'}`);
50
60
 
@@ -53,8 +63,8 @@ export async function runHook(rest = []) {
53
63
  // record in state.json.
54
64
  if (pane) {
55
65
  mkdirSync(EVENTS_DIR, { recursive: true });
56
- writeFileSync(join(EVENTS_DIR, `${pane.replace(/[^%\w]/g, '_')}.json`),
57
- JSON.stringify({ pane, kind, at: Date.now(), payload: { error: payload.error ?? null } }));
66
+ writeFileSync(join(EVENTS_DIR, `${addressHash({ mux: muxName, paneOwner, pane })}.json`),
67
+ JSON.stringify({ mux: muxName, paneOwner, pane, kind, at: Date.now(), payload: { error: payload.error ?? null } }));
58
68
  }
59
69
  return 0;
60
70
  }
@@ -66,7 +76,7 @@ export async function runHook(rest = []) {
66
76
  let limitType = 'unknown';
67
77
  if (pane) {
68
78
  try {
69
- const text = await capturePane(pane, CAPTURE_LINES);
79
+ const text = await mux.capturePane(pane, CAPTURE_LINES);
70
80
  const d = detectLimit(text, PANE_SCAN_LINES, agent.patterns);
71
81
  if (d.hit) { resetLine = d.resetLine; limitType = d.limitType; }
72
82
  } catch { /* pane not capturable (no tmux) — fall back below */ }
@@ -83,9 +93,12 @@ export async function runHook(rest = []) {
83
93
  sessionId: sessionId || null,
84
94
  cwd,
85
95
  pane,
96
+ mux: muxName,
97
+ paneOwner,
98
+ leaseId,
86
99
  agent: agent.id,
87
100
  origin: 'cli', // the hook only fires for CLI launches we can see
88
- tmuxSession: TMUX_SESSION_NAME,
101
+ muxSession: MUX_SESSION_NAME,
89
102
  status: 'stopped',
90
103
  limitType,
91
104
  detectedVia: 'hook',
package/src/install.js CHANGED
@@ -56,15 +56,18 @@ function isLegacy(entry) {
56
56
  return (entry.hooks || []).some(h => /claude-auto-retry|csg\.js _hook-stopfailure/.test(h.command || ''));
57
57
  }
58
58
 
59
- export function mergeHookIntoSettings(settingsJson) {
59
+ // agent/matcher options cover CLIs with Claude-shaped hook config in their own
60
+ // settings.json (qwen); the default stays byte-identical for claude.
61
+ export function mergeHookIntoSettings(settingsJson, { agent = null, matcher = 'overloaded|server_error|rate_limit' } = {}) {
60
62
  const settings = JSON.parse(settingsJson);
61
63
  settings.hooks = settings.hooks || {};
62
64
  const list = (settings.hooks.StopFailure || []).filter(e => !isLegacy(e) && !isOurs(e));
65
+ const agentFlag = agent ? ` --agent ${agent}` : '';
63
66
  list.push({
64
- matcher: 'overloaded|server_error|rate_limit',
67
+ matcher,
65
68
  // Guarded like the shell wrapper: a vanished entry point must exit 0, not
66
69
  // spray MODULE_NOT_FOUND errors into every Claude Code turn.
67
- hooks: [{ type: 'command', command: `test -f "${UNSNOOZE_BIN}" && node "${UNSNOOZE_BIN}" _hook-stopfailure || exit 0`, timeout: 5 }],
70
+ hooks: [{ type: 'command', command: `test -f "${UNSNOOZE_BIN}" && node "${UNSNOOZE_BIN}" _hook-stopfailure${agentFlag} || exit 0`, timeout: 5 }],
68
71
  });
69
72
  settings.hooks.StopFailure = list;
70
73
  return JSON.stringify(settings, null, 2) + '\n';
@@ -205,7 +208,7 @@ export function installDaemonAutostart({ platform = process.platform, dir = null
205
208
  activate('systemctl', ['--user', 'enable', '--now', 'unsnooze.service']);
206
209
  return target;
207
210
  }
208
- return null; // native Windows: no tmux to revive into — daemon unsupported
211
+ return null; // native Windows: no supported multiplexer to revive into
209
212
  }
210
213
 
211
214
  export function uninstallDaemonAutostart({ platform = process.platform, dir = null, activate = defaultActivate } = {}) {
@@ -229,7 +232,36 @@ export function uninstallDaemonAutostart({ platform = process.platform, dir = nu
229
232
  // --- commands ---
230
233
 
231
234
  export function enabledAgents() {
232
- return ['claude', 'codex', 'grok'].filter(id => getConfig(`agents.${id}`));
235
+ return ['claude', 'codex', 'grok', 'qwen', 'kimi', 'opencode', 'agy'].filter(id => getConfig(`agents.${id}`));
236
+ }
237
+
238
+ // Qwen keeps Claude-shaped hooks in its own settings.json — reuse the same
239
+ // merge/remove machinery against ~/.qwen/settings.json. Matcher matches qwen's
240
+ // StopFailure `error` class; `unknown` is included because the discontinued
241
+ // free-tier message classifies as unknown, and hook.js's banner gate keeps
242
+ // unknowns without visible limit banners out of the ledger anyway.
243
+ const QWEN_HOOK_OPTS = { agent: 'qwen', matcher: 'rate_limit|unknown' };
244
+
245
+ function qwenSettingsPath() {
246
+ return join(process.env.UNSNOOZE_QWEN_DIR || join(homedir(), '.qwen'), 'settings.json');
247
+ }
248
+
249
+ export function installQwenHooks() {
250
+ const path = qwenSettingsPath();
251
+ if (existsSync(path)) {
252
+ copyFileSync(path, `${path}.unsnooze-bak`);
253
+ atomicWrite(path, mergeHookIntoSettings(readFileSync(path, 'utf-8'), QWEN_HOOK_OPTS));
254
+ } else {
255
+ atomicWrite(path, mergeHookIntoSettings('{}', QWEN_HOOK_OPTS));
256
+ }
257
+ return path;
258
+ }
259
+
260
+ export function uninstallQwenHooks() {
261
+ const path = qwenSettingsPath();
262
+ if (!existsSync(path)) return null;
263
+ atomicWrite(path, removeHookFromSettings(readFileSync(path, 'utf-8')));
264
+ return path;
233
265
  }
234
266
 
235
267
  // rc files to touch: the explicit --zshrc target, or every rc file that exists
@@ -271,6 +303,12 @@ export function cmdInstall(rest, { agents = enabledAgents() } = {}) {
271
303
  console.log(`unsnooze: Grok StopFailure hook installed at ${file}`);
272
304
  }
273
305
 
306
+ // 2b. Qwen Code hook (Claude-shaped hooks in ~/.qwen/settings.json).
307
+ if (agents.includes('qwen')) {
308
+ const file = installQwenHooks();
309
+ console.log(`unsnooze: Qwen StopFailure hook installed in ${file}`);
310
+ }
311
+
274
312
  // 3. Shell wrappers (zsh + bash).
275
313
  for (const rc of rcTargets(opts, explicitRc)) {
276
314
  const rcContent = existsSync(rc) ? readFileSync(rc, 'utf-8') : '';
@@ -310,6 +348,8 @@ export function cmdUninstall(rest) {
310
348
  }
311
349
 
312
350
  uninstallGrokHooks();
351
+ const qwenFile = uninstallQwenHooks();
352
+ if (qwenFile) console.log(`unsnooze: StopFailure hook removed from ${qwenFile}`);
313
353
 
314
354
  for (const rc of rcTargets(opts, explicitRc)) {
315
355
  if (!existsSync(rc)) continue;
package/src/launcher.js CHANGED
@@ -1,16 +1,16 @@
1
1
  // Default `unsnooze _run <agent> [args...]` path: run the agent CLI under watch.
2
- // - outside tmux: re-exec inside a new tmux session (the monitor needs a pane
3
- // to scrape) guarded by UNSNOOZE_ACTIVE to prevent recursion
4
- // - inside tmux: spawn a detached per-pane monitor, then run the CLI,
2
+ // - outside a multiplexer: re-exec through its launchWrapped operation
3
+ // - inside one: spawn a detached per-pane monitor, then run the CLI,
5
4
  // propagating its exit code
6
5
  // - -p/--print: pure pass-through, no monitor (nothing interactive to scrape)
7
6
 
8
7
  import { spawn, spawnSync } from 'node:child_process';
9
- import { insideTmux, currentPaneId, tmuxAvailable } from './tmux.js';
8
+ import { getMultiplexer } from './multiplexer.js';
10
9
  import { getAgent } from './agents/index.js';
11
10
  import { getConfig } from './settings.js';
12
11
  import { spawnDetached } from './spawn.js';
13
12
  import { makeLogger } from './logger.js';
13
+ import { createLeaseId, processBirth, writeLease, removeLease } from './lease.js';
14
14
 
15
15
  const log = makeLogger('launcher');
16
16
 
@@ -29,41 +29,60 @@ export function runLauncher(args, agentId = 'claude') {
29
29
  return r.status ?? 1;
30
30
  }
31
31
 
32
- if (!insideTmux()) {
33
- if (!tmuxAvailable()) {
32
+ const mux = getMultiplexer();
33
+ if (!mux.inside()) {
34
+ if (!mux.available()) {
34
35
  // Degrade gracefully: run the CLI unwatched rather than dying.
35
- process.stderr.write('unsnooze: tmux not found — running without limit-watch.\n');
36
+ process.stderr.write(`unsnooze: ${mux.name} not found — running without limit-watch.\n`);
36
37
  if (process.platform === 'win32') {
37
- process.stderr.write('unsnooze: native Windows is not supported; run inside WSL (https://learn.microsoft.com/windows/wsl/install) where tmux works.\n');
38
+ process.stderr.write('unsnooze: native Windows is not supported; run inside WSL.\n');
38
39
  } else {
39
- process.stderr.write('unsnooze: install tmux to enable auto-resume (brew install tmux / apt install tmux).\n');
40
+ process.stderr.write(`unsnooze: install ${mux.name} to enable auto-resume.\n`);
40
41
  }
41
42
  const r = spawnSync(agent.bin, args, { stdio: 'inherit', env: { ...process.env, UNSNOOZE_ACTIVE: '1' } });
42
43
  return r.status ?? 1;
43
44
  }
44
- // Re-enter under tmux: `tmux new-session unsnooze _run <agent> <args...>` —
45
- // the inner unsnooze lands in the insideTmux() branch below.
46
- log(`not in tmux — wrapping into a tmux session`);
47
- const inner = ['new-session', process.execPath, process.argv[1], '_run', agent.id, ...args];
48
- const r = spawnSync('tmux', inner, { stdio: 'inherit' });
49
- return r.status ?? 1;
45
+ log(`not in ${mux.name} wrapping into a managed session`);
46
+ return mux.launchWrapped({
47
+ file: process.execPath,
48
+ args: [process.argv[1], '_run', agent.id, ...args],
49
+ env: process.env,
50
+ });
50
51
  }
51
52
 
52
- const pane = currentPaneId();
53
+ const pane = mux.currentPaneId();
54
+ const paneOwner = mux.name === 'zellij'
55
+ ? (process.env.UNSNOOZE_MUX === 'zellij'
56
+ ? process.env.UNSNOOZE_PANE_OWNER : process.env.ZELLIJ_SESSION_NAME) || null
57
+ : null;
58
+ const leaseId = process.env.UNSNOOZE_LEASE_ID || createLeaseId();
53
59
  if (pane) {
54
- spawnDetached(['_monitor', pane, agent.id], { UNSNOOZE_CWD: process.cwd() });
55
- log(`launching ${agent.id} in pane ${pane}, monitor spawned`);
60
+ spawnDetached(['_monitor', mux.name, paneOwner || '', pane, agent.id, leaseId],
61
+ { UNSNOOZE_CWD: process.cwd() });
62
+ log(`launching ${agent.id} in ${mux.name} ${paneOwner ?? '-'}:${pane}, monitor spawned`);
56
63
  } else {
57
- log(`inside tmux but TMUX_PANE unset — launching ${agent.id} without monitor`);
64
+ log(`inside ${mux.name} but pane id unset — launching ${agent.id} without monitor`);
58
65
  }
59
66
 
67
+ const childEnv = {
68
+ ...process.env, UNSNOOZE_ACTIVE: '1', UNSNOOZE_MUX: mux.name,
69
+ UNSNOOZE_PANE: pane || '', UNSNOOZE_PANE_OWNER: paneOwner || '',
70
+ UNSNOOZE_LEASE_ID: leaseId,
71
+ };
60
72
  const child = spawn(agent.bin, args, {
61
73
  stdio: 'inherit',
62
- env: { ...process.env, UNSNOOZE_ACTIVE: '1', UNSNOOZE_PANE: pane || '' },
74
+ env: childEnv,
63
75
  });
76
+ const lease = pane && child.pid ? {
77
+ leaseId, mux: mux.name, paneOwner, pane, agent: agent.id,
78
+ pid: child.pid, pidBirth: processBirth(child.pid),
79
+ } : null;
80
+ if (lease?.pidBirth) writeLease(lease);
81
+ const cleanup = () => { if (lease) removeLease(lease, leaseId); };
64
82
  return new Promise(resolve => {
65
- child.on('exit', code => resolve(code ?? 1));
83
+ child.on('exit', code => { cleanup(); resolve(code ?? 1); });
66
84
  child.on('error', err => {
85
+ cleanup();
67
86
  process.stderr.write(`unsnooze: failed to launch ${agent.bin}: ${err.message}\n`);
68
87
  resolve(127);
69
88
  });
package/src/lease.js ADDED
@@ -0,0 +1,87 @@
1
+ import {
2
+ mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync,
3
+ } from 'node:fs';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { randomUUID, createHash } from 'node:crypto';
6
+ import { join } from 'node:path';
7
+ import { STATE_DIR } from './config.js';
8
+
9
+ const LEASES_DIR = join(STATE_DIR, 'leases');
10
+
11
+ export function addressHash({ mux, paneOwner, pane }) {
12
+ return createHash('sha256')
13
+ .update(`${mux ?? ''}\0${paneOwner ?? ''}\0${pane ?? ''}`)
14
+ .digest('hex');
15
+ }
16
+
17
+ export function createLeaseId() {
18
+ return randomUUID();
19
+ }
20
+
21
+ export function processBirth(pid, {
22
+ platform = process.platform,
23
+ readFile = path => readFileSync(path, 'utf-8'),
24
+ execFile = (file, args) => execFileSync(file, args, { encoding: 'utf-8' }),
25
+ } = {}) {
26
+ try {
27
+ if (platform === 'linux') {
28
+ const stat = readFile(`/proc/${pid}/stat`);
29
+ const close = stat.lastIndexOf(')');
30
+ if (close === -1) return null;
31
+ // The tail begins at field 3 (state); starttime is field 22.
32
+ return stat.slice(close + 1).trim().split(/\s+/)[19] || null;
33
+ }
34
+ if (platform === 'darwin') {
35
+ return execFile('ps', ['-o', 'lstart=', '-p', String(pid)]).trim() || null;
36
+ }
37
+ } catch { /* fail closed */ }
38
+ return null;
39
+ }
40
+
41
+ function leasePath(address, leaseId) {
42
+ return join(LEASES_DIR, `${addressHash(address)}.${leaseId}.json`);
43
+ }
44
+
45
+ export function writeLease(lease) {
46
+ mkdirSync(LEASES_DIR, { recursive: true });
47
+ const path = leasePath(lease, lease.leaseId);
48
+ const tmp = `${path}.tmp.${process.pid}.${randomUUID()}`;
49
+ writeFileSync(tmp, JSON.stringify(lease));
50
+ renameSync(tmp, path);
51
+ return lease;
52
+ }
53
+
54
+ export function readLease(address, leaseId) {
55
+ try {
56
+ const lease = JSON.parse(readFileSync(leasePath(address, leaseId), 'utf-8'));
57
+ return lease.leaseId === leaseId ? lease : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ export function removeLease(address, leaseId) {
64
+ const path = leasePath(address, leaseId);
65
+ const lease = readLease(address, leaseId);
66
+ if (!lease || lease.leaseId !== leaseId) return false;
67
+ try { unlinkSync(path); return true; } catch { return false; }
68
+ }
69
+
70
+ function defaultPidAlive(pid) {
71
+ try { process.kill(pid, 0); return true; } catch { return false; }
72
+ }
73
+
74
+ export async function leaseMatches(rec, {
75
+ mux,
76
+ pidAlive = defaultPidAlive,
77
+ processBirthFn = processBirth,
78
+ } = {}) {
79
+ if (!rec?.leaseId || !rec?.pane || !mux) return false;
80
+ const stored = readLease(rec, rec.leaseId);
81
+ if (!stored || stored.leaseId !== rec.leaseId || stored.agent !== rec.agent) return false;
82
+ if (stored.mux !== rec.mux || stored.paneOwner !== rec.paneOwner || stored.pane !== rec.pane) return false;
83
+ if (!pidAlive(stored.pid)) return false;
84
+ const birth = processBirthFn(stored.pid);
85
+ if (!birth || birth !== stored.pidBirth) return false;
86
+ try { return await mux.paneAlive(rec.pane); } catch { return false; }
87
+ }