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.
package/src/monitor.js CHANGED
@@ -10,11 +10,11 @@
10
10
 
11
11
  import { readFileSync, unlinkSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
- import * as realTmux from './tmux.js';
13
+ import { getMultiplexer } from './multiplexer.js';
14
14
  import {
15
15
  SCRAPE_INTERVAL_MS, PANE_SCAN_LINES, CAPTURE_LINES, EVENTS_DIR,
16
16
  EVENT_MARKER_TTL_MS, OVERLOAD_BACKOFF_S, OVERLOAD_JITTER,
17
- FALLBACK_RESET_MS, RESET_MARGIN_MS, TMUX_SESSION_NAME,
17
+ FALLBACK_RESET_MS, RESET_MARGIN_MS, MUX_SESSION_NAME,
18
18
  } from './config.js';
19
19
  import { detectLimit, isBusy, overloadMatch } from './patterns.js';
20
20
  import { getAgent } from './agents/index.js';
@@ -24,18 +24,25 @@ import { parseResetTime, resetAtMs } from './time-parser.js';
24
24
  import { upsertSession, setStatus, readState } from './state.js';
25
25
  import { spawnResumerIfNeeded } from './spawn.js';
26
26
  import { makeLogger } from './logger.js';
27
+ import { addressHash } from './lease.js';
27
28
 
28
29
  const log = makeLogger('monitor');
29
30
 
30
31
  const sleep = ms => new Promise(r => setTimeout(r, ms));
31
32
 
32
- export function createMonitor({ pane, cwd, agent = getAgent('claude'), tmux = realTmux, scrapeInterval = SCRAPE_INTERVAL_MS }) {
33
+ export function createMonitor({
34
+ muxName = 'tmux', paneOwner = null, pane, leaseId = null, cwd,
35
+ agent = getAgent('claude'), mux = getMultiplexer(muxName, { owner: paneOwner }),
36
+ scrapeInterval = SCRAPE_INTERVAL_MS, notifier = notify,
37
+ }) {
38
+ const notifyCtx = { mux: muxName, pane, paneOwner };
33
39
  let trackedKey = null; // state key of the record we created
34
40
  let overloadAttempt = 0;
41
+ let terminalNotified = false; // one notification per terminal-error appearance
35
42
  let running = true;
36
43
 
37
44
  function markerPath() {
38
- return join(EVENTS_DIR, `${pane.replace(/[^%\w]/g, '_')}.json`);
45
+ return join(EVENTS_DIR, `${addressHash({ mux: muxName, paneOwner, pane })}.json`);
39
46
  }
40
47
 
41
48
  function consumeMarker() {
@@ -56,16 +63,19 @@ export function createMonitor({ pane, cwd, agent = getAgent('claude'), tmux = re
56
63
  });
57
64
  const sessionId = agent.latestSessionId(cwd, detectedAt);
58
65
  const state = upsertSession({
59
- sessionId, cwd, pane, agent: agent.id, tmuxSession: TMUX_SESSION_NAME,
66
+ sessionId, cwd, pane, mux: muxName, paneOwner, leaseId,
67
+ agent: agent.id, muxSession: MUX_SESSION_NAME,
60
68
  status: 'stopped', limitType, detectedVia: via, detectedAt,
61
69
  resetAt: at, resetSource: source,
62
70
  attempts: 0, lastAttemptAt: null, lastError: null,
63
71
  });
64
72
  trackedKey = sessionId
65
- || Object.values(state.sessions).find(s => s.pane === pane && s.status === 'stopped')?.key
73
+ || Object.values(state.sessions).find(s => s.mux === muxName
74
+ && s.paneOwner === paneOwner && s.pane === pane
75
+ && ['stopped', 'resuming', 'resumed'].includes(s.status))?.key
66
76
  || null;
67
77
  log(`pane ${pane}: limit recorded (${limitType}, via ${via}), resets ${new Date(at).toISOString()}`);
68
- notify(`${agent.name} hit a usage limit`, `${cwd} — auto-resume at ${new Date(at).toLocaleTimeString()}`);
78
+ notifier(`${agent.name} hit a usage limit`, `${cwd} — auto-resume at ${new Date(at).toLocaleTimeString()}`, { context: notifyCtx });
69
79
  spawnResumerIfNeeded();
70
80
  }
71
81
 
@@ -77,10 +87,10 @@ export function createMonitor({ pane, cwd, agent = getAgent('claude'), tmux = re
77
87
  }
78
88
  const key = steps > 0 ? 'Down' : 'Up';
79
89
  for (let i = 0; i < Math.abs(steps); i++) {
80
- await tmux.sendKey(pane, key);
90
+ await mux.sendKey(pane, key);
81
91
  await sleep(120);
82
92
  }
83
- await tmux.sendKey(pane, 'Enter');
93
+ await mux.sendKey(pane, 'Enter');
84
94
  log(`pane ${pane}: selected "Stop and wait for limit to reset" (${steps} steps)`);
85
95
  await sleep(1000);
86
96
  // After selection the TUI prints the limit banner — next scrape records it.
@@ -100,16 +110,16 @@ export function createMonitor({ pane, cwd, agent = getAgent('claude'), tmux = re
100
110
  log(`pane ${pane}: overload — retry ${overloadAttempt}/${OVERLOAD_BACKOFF_S.length} in ${Math.round(wait / 1000)}s`);
101
111
  await sleep(wait);
102
112
  if (!running) return;
103
- const text = await tmux.capturePane(pane, CAPTURE_LINES).catch(() => null);
113
+ const text = await mux.capturePane(pane, CAPTURE_LINES).catch(() => null);
104
114
  if (text === null) return;
105
115
  if (isBusy(text, agent.patterns.busyPatterns)) { log(`pane ${pane}: busy after overload wait — skip inject`); return; }
106
116
  if (!overloadMatch(text, agent.patterns.overloadPatterns)) { overloadAttempt = 0; return; } // recovered on its own
107
- await tmux.sendText(pane, 'Continue where you left off. The previous attempt failed with a transient API error.');
117
+ await mux.sendText(pane, 'Continue where you left off. The previous attempt failed with a transient API error.');
108
118
  log(`pane ${pane}: overload retry message sent`);
109
119
  }
110
120
 
111
121
  async function tick() {
112
- if (!(await tmux.paneAlive(pane))) {
122
+ if (!(await mux.paneAlive(pane))) {
113
123
  log(`pane ${pane}: gone — monitor exiting (record persists for resumer)`);
114
124
  running = false;
115
125
  return;
@@ -118,7 +128,7 @@ export function createMonitor({ pane, cwd, agent = getAgent('claude'), tmux = re
118
128
  const marker = consumeMarker();
119
129
  let text;
120
130
  try {
121
- text = await tmux.capturePane(pane, CAPTURE_LINES);
131
+ text = await mux.capturePane(pane, CAPTURE_LINES);
122
132
  } catch {
123
133
  return; // transient capture failure
124
134
  }
@@ -126,8 +136,8 @@ export function createMonitor({ pane, cwd, agent = getAgent('claude'), tmux = re
126
136
  // Interactive menu takes priority — it blocks the session until answered.
127
137
  // Checked against the VISIBLE screen only: a menu in scrollback history
128
138
  // was already answered, and re-driving it would inject stray keys.
129
- const visible = tmux.capturePaneVisible
130
- ? await tmux.capturePaneVisible(pane).catch(() => '')
139
+ const visible = mux.capturePaneVisible
140
+ ? await mux.capturePaneVisible(pane).catch(() => '')
131
141
  : text;
132
142
  if (agent.menu && agent.menu.isPrompt(visible, PANE_SCAN_LINES)) {
133
143
  if (!getConfig('menuAutoAnswer')) {
@@ -156,16 +166,34 @@ export function createMonitor({ pane, cwd, agent = getAgent('claude'), tmux = re
156
166
  return;
157
167
  }
158
168
 
169
+ // Non-resetting terminal errors (credits exhausted, membership expired,
170
+ // discontinued tiers): notify once per appearance, never touch the ledger —
171
+ // there is no reset to wait for. Re-arms when the error clears.
172
+ const term = overloadMatch(text, agent.patterns.terminalPatterns || []);
173
+ if (term) {
174
+ if (!terminalNotified) {
175
+ terminalNotified = true;
176
+ notifier(`${agent.name} needs attention ⚠️`, `${cwd} — ${term.line}`, { context: notifyCtx });
177
+ log(`pane ${pane}: terminal error (no auto-resume): ${term.line}`);
178
+ }
179
+ } else {
180
+ terminalNotified = false;
181
+ }
182
+
159
183
  // No banner. If we were tracking a stopped record and claude is active
160
184
  // again, someone resumed it (user or resumer) — mark it.
161
185
  if (trackedKey) {
162
186
  const state = readState();
163
187
  const rec = state.sessions[trackedKey];
164
188
  if (rec && rec.status === 'stopped') {
165
- setStatus(trackedKey, 'resumed', { lastAttemptAt: Date.now() });
189
+ setStatus(trackedKey, 'resumed', { lastAttemptAt: Date.now(), bannerCleared: true });
166
190
  log(`pane ${pane}: banner cleared, ${trackedKey} marked resumed`);
191
+ trackedKey = null;
167
192
  }
168
- if (rec && rec.status !== 'stopped') trackedKey = null;
193
+ if (rec && rec.status === 'resumed') {
194
+ setStatus(trackedKey, 'resumed', { bannerCleared: true });
195
+ trackedKey = null;
196
+ } else if (rec && rec.status !== 'stopped') trackedKey = null;
169
197
  }
170
198
 
171
199
  // Overload marker (banner-less path).
@@ -191,9 +219,9 @@ export function createMonitor({ pane, cwd, agent = getAgent('claude'), tmux = re
191
219
  };
192
220
  }
193
221
 
194
- export async function runMonitor(pane, agentId) {
222
+ export async function runMonitor(muxName, paneOwner, pane, agentId, leaseId) {
195
223
  const cwd = process.env.UNSNOOZE_CWD || process.cwd();
196
- const monitor = createMonitor({ pane, cwd, agent: getAgent(agentId) });
224
+ const monitor = createMonitor({ muxName, paneOwner: paneOwner || null, pane, leaseId, cwd, agent: getAgent(agentId) });
197
225
  await monitor.run();
198
226
  return 0;
199
227
  }
@@ -0,0 +1,58 @@
1
+ import tmux from './multiplexers/tmux.js';
2
+ import zellij from './multiplexers/zellij.js';
3
+ import { getConfig } from './settings.js';
4
+
5
+ const NAMES = ['tmux', 'zellij'];
6
+
7
+ export function createMultiplexerFactory({
8
+ backends = { tmux, zellij },
9
+ getSetting = () => getConfig('multiplexer'),
10
+ env = process.env,
11
+ } = {}) {
12
+ const cache = new Map();
13
+
14
+ const prototypeFor = name => {
15
+ if (!NAMES.includes(name) || !backends[name]) {
16
+ throw new Error(`unsnooze: unknown multiplexer "${name}"`);
17
+ }
18
+ if (!cache.has(name)) cache.set(name, backends[name]);
19
+ return cache.get(name);
20
+ };
21
+
22
+ const isAvailable = name => {
23
+ try { return prototypeFor(name).available(); } catch { return false; }
24
+ };
25
+
26
+ const resolveName = explicit => {
27
+ if (explicit && explicit !== 'auto') return explicit;
28
+
29
+ let configured = 'auto';
30
+ try { configured = getSetting() || 'auto'; } catch { /* pre-setting compatibility */ }
31
+ if (configured !== 'auto') return configured;
32
+
33
+ if (env.ZELLIJ) return 'zellij';
34
+ if (env.TMUX) return 'tmux';
35
+
36
+ const tmuxInstalled = isAvailable('tmux');
37
+ const zellijInstalled = isAvailable('zellij');
38
+ if (tmuxInstalled !== zellijInstalled) return tmuxInstalled ? 'tmux' : 'zellij';
39
+ return 'tmux';
40
+ };
41
+
42
+ const getMultiplexer = (name, { owner = null } = {}) => {
43
+ const prototype = prototypeFor(resolveName(name));
44
+ return prototype.bind ? prototype.bind(owner) : prototype;
45
+ };
46
+
47
+ return {
48
+ getMultiplexer,
49
+ available: name => prototypeFor(name).available(),
50
+ inside: name => prototypeFor(name).inside(),
51
+ };
52
+ }
53
+
54
+ const factory = createMultiplexerFactory();
55
+
56
+ export const getMultiplexer = (...args) => factory.getMultiplexer(...args);
57
+ export const available = (...args) => factory.available(...args);
58
+ export const inside = (...args) => factory.inside(...args);
@@ -0,0 +1,200 @@
1
+ import { execFile as execFileCb, spawnSync } from 'node:child_process';
2
+ import { constants as osConstants } from 'node:os';
3
+ import { promisify } from 'node:util';
4
+
5
+ const execFileAsync = promisify(execFileCb);
6
+
7
+ function defaultSpawner(file, args, { sync = false, ...options } = {}) {
8
+ if (sync) return spawnSync(file, args, options);
9
+ return execFileAsync(file, args, options).then(({ stdout }) => stdout);
10
+ }
11
+
12
+ function envArgs(env = {}) {
13
+ return Object.entries(env)
14
+ .filter(([, value]) => value !== undefined)
15
+ .flatMap(([key, value]) => ['-e', `${key}=${value}`]);
16
+ }
17
+
18
+ function exitStatus(result) {
19
+ if (result.status !== null && result.status !== undefined) return result.status;
20
+ return result.signal ? 128 + (osConstants.signals[result.signal] || 0) : 1;
21
+ }
22
+
23
+ function wrappedSessionName(env) {
24
+ return env.UNSNOOZE_SESSION_NAME || env.UNSNOOZE_TMUX_SESSION || 'unsnooze';
25
+ }
26
+
27
+ // Submit delay: text and the submitting Enter must be TWO separate send-keys
28
+ // calls with a pause between them, or Ink treats Enter as bracketed paste.
29
+ export const SUBMIT_DELAY_MS = 150;
30
+
31
+ export function createTmux({ spawner = defaultSpawner, env = process.env } = {}) {
32
+ const run = (...args) => spawner('tmux', args);
33
+
34
+ const backend = {
35
+ name: 'tmux',
36
+ SUBMIT_DELAY_MS,
37
+
38
+ available() {
39
+ try {
40
+ return spawner('tmux', ['-V'], { sync: true, stdio: 'ignore' }).status === 0;
41
+ } catch {
42
+ return false;
43
+ }
44
+ },
45
+
46
+ inside() { return !!env.TMUX; },
47
+ currentPaneId() {
48
+ if (env.UNSNOOZE_MUX === 'tmux' && env.UNSNOOZE_PANE) return env.UNSNOOZE_PANE;
49
+ return env.TMUX_PANE || null;
50
+ },
51
+
52
+ async capturePane(pane, lines = 200) {
53
+ return run('capture-pane', '-t', pane, '-p', '-S', `-${lines}`);
54
+ },
55
+
56
+ async capturePaneVisible(pane) {
57
+ return run('capture-pane', '-t', pane, '-p');
58
+ },
59
+
60
+ async sendText(pane, text) {
61
+ await run('send-keys', '-t', pane, '-l', text);
62
+ await new Promise(resolve => setTimeout(resolve, SUBMIT_DELAY_MS));
63
+ await run('send-keys', '-t', pane, 'Enter');
64
+ },
65
+
66
+ async sendKey(pane, key) {
67
+ await run('send-keys', '-t', pane, key);
68
+ },
69
+
70
+ async paneAlive(pane) {
71
+ try {
72
+ // tmux 3.7b prints a blank line and exits 0 for a nonexistent target,
73
+ // so the exit code alone is not evidence — the output must echo the
74
+ // pane id back.
75
+ const out = await run('display-message', '-t', pane, '-p', '#{pane_id}');
76
+ return out.trim() === pane;
77
+ } catch {
78
+ return false;
79
+ }
80
+ },
81
+
82
+ // Clients attached to the session that owns `pane`. Detached / missing
83
+ // targets return [] so callers can feature-detect without try/catch.
84
+ async clientTtys(pane) {
85
+ try {
86
+ const out = await run('list-clients', '-t', pane, '-F', '#{client_tty}\t#{client_termname}');
87
+ return out.split('\n')
88
+ .map(line => line.trimEnd())
89
+ .filter(Boolean)
90
+ .map(line => {
91
+ const [tty, termname = ''] = line.split('\t');
92
+ return { tty, termname };
93
+ })
94
+ .filter(entry => entry.tty);
95
+ } catch {
96
+ return [];
97
+ }
98
+ },
99
+
100
+ async paneTty(pane) {
101
+ try {
102
+ const out = (await run('display-message', '-t', pane, '-p', '#{pane_tty}')).trim();
103
+ return out || null;
104
+ } catch {
105
+ return null;
106
+ }
107
+ },
108
+
109
+ // Global session env from `show-environment -g`. `-REMOVED` markers are
110
+ // skipped; only keys listed in `names` are returned. Errors → {}.
111
+ async globalEnv(names = []) {
112
+ if (!names.length) return {};
113
+ try {
114
+ const out = await run('show-environment', '-g');
115
+ const wanted = new Set(names);
116
+ const result = {};
117
+ for (const line of out.split('\n')) {
118
+ const trimmed = line.trimEnd();
119
+ // tmux prints "NAME=value" or "NAME -REMOVED"
120
+ if (!trimmed || trimmed.endsWith(' -REMOVED')) continue;
121
+ const eq = trimmed.indexOf('=');
122
+ if (eq <= 0) continue;
123
+ const key = trimmed.slice(0, eq);
124
+ if (!wanted.has(key)) continue;
125
+ result[key] = trimmed.slice(eq + 1);
126
+ }
127
+ return result;
128
+ } catch {
129
+ return {};
130
+ }
131
+ },
132
+
133
+ async paneCurrentCommand(pane) {
134
+ try {
135
+ return (await run('display-message', '-t', pane, '-p', '#{pane_current_command}')).trim();
136
+ } catch {
137
+ return null;
138
+ }
139
+ },
140
+
141
+ async sessionExists(name) {
142
+ try {
143
+ await run('has-session', '-t', name);
144
+ return true;
145
+ } catch {
146
+ return false;
147
+ }
148
+ },
149
+
150
+ async newWindow(sessionName, cwd, launchSpec) {
151
+ // Environment flags require tmux >= 3.0 for new-window and >= 3.2 for
152
+ // new-session. Older tmux fails revival with an "unknown flag -e" error.
153
+ const launch = [...envArgs(launchSpec.env), launchSpec.file, ...(launchSpec.args || [])];
154
+ let pane;
155
+ if (!(await backend.sessionExists(sessionName))) {
156
+ pane = await run('new-session', '-d', '-s', sessionName, '-c', cwd,
157
+ '-P', '-F', '#{pane_id}', ...launch);
158
+ } else {
159
+ pane = await run('new-window', '-t', `${sessionName}:`, '-c', cwd,
160
+ '-P', '-F', '#{pane_id}', ...launch);
161
+ }
162
+ return { pane: pane.trim(), paneOwner: sessionName };
163
+ },
164
+
165
+ launchWrapped(launchSpec) {
166
+ // A foreground, single-pane session disappears with the agent. Keeping
167
+ // tmux in the foreground also gives Ctrl-C to the active pane directly.
168
+ // Its -e environment flags require tmux >= 3.2; older versions fail
169
+ // revival with an "unknown flag -e" error.
170
+ const args = ['new-session', '-s', wrappedSessionName(env),
171
+ ...envArgs(launchSpec.env), launchSpec.file, ...(launchSpec.args || [])];
172
+ const result = spawner('tmux', args, { sync: true, stdio: 'inherit', env });
173
+ return exitStatus(result);
174
+ },
175
+
176
+ // tmux pane ids are server-global, so owner binding is intentionally inert.
177
+ bind() { return backend; },
178
+ };
179
+
180
+ return backend;
181
+ }
182
+
183
+ const tmux = createTmux();
184
+
185
+ export const available = (...args) => tmux.available(...args);
186
+ export const tmuxAvailable = available;
187
+ export const inside = (...args) => tmux.inside(...args);
188
+ export const insideTmux = inside;
189
+ export const currentPaneId = (...args) => tmux.currentPaneId(...args);
190
+ export const capturePane = (...args) => tmux.capturePane(...args);
191
+ export const capturePaneVisible = (...args) => tmux.capturePaneVisible(...args);
192
+ export const sendText = (...args) => tmux.sendText(...args);
193
+ export const sendKey = (...args) => tmux.sendKey(...args);
194
+ export const paneAlive = (...args) => tmux.paneAlive(...args);
195
+ export const paneCurrentCommand = (...args) => tmux.paneCurrentCommand(...args);
196
+ export const sessionExists = (...args) => tmux.sessionExists(...args);
197
+ export const newWindow = (...args) => tmux.newWindow(...args);
198
+ export const launchWrapped = (...args) => tmux.launchWrapped(...args);
199
+
200
+ export default tmux;
@@ -0,0 +1,193 @@
1
+ import { execFile as execFileCb, spawnSync } from 'node:child_process';
2
+ import { constants as osConstants } from 'node:os';
3
+ import { basename } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+
6
+ const execFileAsync = promisify(execFileCb);
7
+
8
+ function defaultSpawner(file, args, { sync = false, ...options } = {}) {
9
+ if (sync) return spawnSync(file, args, options);
10
+ return execFileAsync(file, args, options).then(({ stdout }) => stdout);
11
+ }
12
+
13
+ function scrubZellijEnv(env) {
14
+ return Object.fromEntries(Object.entries(env).filter(([key]) => !key.startsWith('ZELLIJ')));
15
+ }
16
+
17
+ function launchArgv({ file, args = [], env = {} }) {
18
+ const assignments = Object.entries(env)
19
+ .filter(([, value]) => value !== undefined)
20
+ .map(([key, value]) => `${key}=${value}`);
21
+ return ['/usr/bin/env', ...assignments, file, ...args];
22
+ }
23
+
24
+ function exitStatus(result) {
25
+ if (result.status !== null && result.status !== undefined) return result.status;
26
+ return result.signal ? 128 + (osConstants.signals[result.signal] || 0) : 1;
27
+ }
28
+
29
+ function wrappedSessionName(env) {
30
+ return env.UNSNOOZE_SESSION_NAME || env.UNSNOOZE_TMUX_SESSION || 'unsnooze';
31
+ }
32
+
33
+ function rawKeyBytes(key) {
34
+ const known = {
35
+ Escape: [27], Enter: [13], Tab: [9], Backspace: [127],
36
+ Down: [27, 91, 66], Up: [27, 91, 65],
37
+ Right: [27, 91, 67], Left: [27, 91, 68],
38
+ };
39
+ return known[key] || [...Buffer.from(key)];
40
+ }
41
+
42
+ function kdlString(value) {
43
+ return JSON.stringify(String(value));
44
+ }
45
+
46
+ function wrappedLayout(launchSpec) {
47
+ const argv = launchArgv(launchSpec);
48
+ return `layout { pane command=${kdlString(argv[0])} close_on_exit=true { args ${argv.slice(1).map(kdlString).join(' ')} } }`;
49
+ }
50
+
51
+ export const SUBMIT_DELAY_MS = 150;
52
+
53
+ export function createZellij({ spawner = defaultSpawner, env = process.env } = {}) {
54
+ const childEnv = () => scrubZellijEnv(env);
55
+ const run = (args, options = {}) => spawner('zellij', args, { env: childEnv(), ...options });
56
+
57
+ const build = owner => {
58
+ const owned = (...args) => {
59
+ if (!owner) throw new Error('unsnooze: zellij pane operation requires a session owner');
60
+ return run(['-s', owner, ...args]);
61
+ };
62
+
63
+ const paneEntries = async () => {
64
+ const stdout = await owned('action', 'list-panes', '-a', '-j');
65
+ const parsed = JSON.parse(stdout);
66
+ return Array.isArray(parsed) ? parsed : [];
67
+ };
68
+
69
+ const backend = {
70
+ name: 'zellij',
71
+ owner,
72
+ SUBMIT_DELAY_MS,
73
+
74
+ available() {
75
+ try {
76
+ return spawner('zellij', ['--version'], { sync: true, stdio: 'ignore', env: childEnv() }).status === 0;
77
+ } catch {
78
+ return false;
79
+ }
80
+ },
81
+
82
+ inside() { return !!env.ZELLIJ; },
83
+ currentPaneId() {
84
+ if (env.UNSNOOZE_MUX === 'zellij' && env.UNSNOOZE_PANE) return env.UNSNOOZE_PANE;
85
+ return env.ZELLIJ_PANE_ID || null;
86
+ },
87
+
88
+ // v1 limitation: dump-screen is viewport-only, so `lines` is ignored;
89
+ // unlike tmux, a banner/menu that scrolls away between polls is missed.
90
+ // `dump-screen --full` may provide a future scrollback-capable option.
91
+ async capturePane(pane, _lines = 200) {
92
+ return owned('action', 'dump-screen', '--pane-id', String(pane));
93
+ },
94
+
95
+ async capturePaneVisible(pane) {
96
+ return owned('action', 'dump-screen', '--pane-id', String(pane));
97
+ },
98
+
99
+ async sendText(pane, text) {
100
+ await owned('action', 'write-chars', '--pane-id', String(pane), text);
101
+ await new Promise(resolve => setTimeout(resolve, SUBMIT_DELAY_MS));
102
+ await owned('action', 'write', '--pane-id', String(pane), '13');
103
+ },
104
+
105
+ async sendKey(pane, key) {
106
+ if (key === 'Down' || key === 'Up') {
107
+ try {
108
+ await owned('action', 'send-keys', '--pane-id', String(pane), key);
109
+ return;
110
+ } catch {
111
+ // Older/changed zellij key grammars still have a raw-byte path.
112
+ }
113
+ } else if (key === 'Enter') {
114
+ await owned('action', 'write', '--pane-id', String(pane), '13');
115
+ return;
116
+ }
117
+ await owned('action', 'write', '--pane-id', String(pane), ...rawKeyBytes(key).map(String));
118
+ },
119
+
120
+ async paneAlive(pane) {
121
+ try {
122
+ const id = Number(pane);
123
+ return (await paneEntries()).some(entry =>
124
+ entry.id === id && entry.is_plugin === false && entry.exited === false);
125
+ } catch {
126
+ return false;
127
+ }
128
+ },
129
+
130
+ async paneCurrentCommand(pane) {
131
+ try {
132
+ const id = Number(pane);
133
+ const entry = (await paneEntries()).find(candidate =>
134
+ candidate.id === id && candidate.is_plugin === false && candidate.exited === false);
135
+ // zellij's pane_command includes arguments (e.g. "node -e …",
136
+ // "claude --resume <id>"), so take the executable token before basename.
137
+ if (!entry?.pane_command) return null;
138
+ return basename(entry.pane_command.trim().split(/\s+/)[0]);
139
+ } catch {
140
+ return null;
141
+ }
142
+ },
143
+
144
+ async sessionExists(name) {
145
+ try {
146
+ const stdout = await run(['list-sessions', '-s']);
147
+ return stdout.split(/\r?\n/).some(line => line.trim() === name);
148
+ } catch {
149
+ return false;
150
+ }
151
+ },
152
+
153
+ async newWindow(sessionName, cwd, launchSpec) {
154
+ if (!(await backend.sessionExists(sessionName))) {
155
+ await run(['attach', '-b', '-c', sessionName]);
156
+ }
157
+ const stdout = await run([
158
+ '-s', sessionName, 'run', '--cwd', cwd, '--', ...launchArgv(launchSpec),
159
+ ]);
160
+ const match = stdout.trim().match(/^terminal_(\d+)$/);
161
+ if (!match) throw new Error(`unsnooze: unexpected zellij pane id: ${stdout.trim()}`);
162
+ return { pane: match[1], paneOwner: sessionName };
163
+ },
164
+
165
+ launchWrapped(launchSpec) {
166
+ // The layout contains exactly one auto-closing pane, avoiding the
167
+ // lingering default shell created by attach -b. The foreground client
168
+ // owns the terminal, so Ctrl-C reaches zellij and its active pane.
169
+ const result = spawner('zellij', [
170
+ '--session', wrappedSessionName(env), '--layout-string', wrappedLayout(launchSpec),
171
+ ], {
172
+ sync: true, stdio: 'inherit', env: childEnv(),
173
+ });
174
+ return exitStatus(result);
175
+ },
176
+
177
+ bind(nextOwner) { return build(nextOwner); },
178
+ };
179
+
180
+ return backend;
181
+ };
182
+
183
+ return build(null);
184
+ }
185
+
186
+ const zellij = createZellij();
187
+
188
+ export const available = (...args) => zellij.available(...args);
189
+ export const inside = (...args) => zellij.inside(...args);
190
+ export const currentPaneId = (...args) => zellij.currentPaneId(...args);
191
+ export const launchWrapped = (...args) => zellij.launchWrapped(...args);
192
+
193
+ export default zellij;