unsnooze 1.7.0 → 1.8.1

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,19 +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, notifier = notify }) {
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;
35
41
  let terminalNotified = false; // one notification per terminal-error appearance
36
42
  let running = true;
37
43
 
38
44
  function markerPath() {
39
- return join(EVENTS_DIR, `${pane.replace(/[^%\w]/g, '_')}.json`);
45
+ return join(EVENTS_DIR, `${addressHash({ mux: muxName, paneOwner, pane })}.json`);
40
46
  }
41
47
 
42
48
  function consumeMarker() {
@@ -57,16 +63,19 @@ export function createMonitor({ pane, cwd, agent = getAgent('claude'), tmux = re
57
63
  });
58
64
  const sessionId = agent.latestSessionId(cwd, detectedAt);
59
65
  const state = upsertSession({
60
- 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,
61
68
  status: 'stopped', limitType, detectedVia: via, detectedAt,
62
69
  resetAt: at, resetSource: source,
63
70
  attempts: 0, lastAttemptAt: null, lastError: null,
64
71
  });
65
72
  trackedKey = sessionId
66
- || 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
67
76
  || null;
68
77
  log(`pane ${pane}: limit recorded (${limitType}, via ${via}), resets ${new Date(at).toISOString()}`);
69
- 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 });
70
79
  spawnResumerIfNeeded();
71
80
  }
72
81
 
@@ -78,10 +87,10 @@ export function createMonitor({ pane, cwd, agent = getAgent('claude'), tmux = re
78
87
  }
79
88
  const key = steps > 0 ? 'Down' : 'Up';
80
89
  for (let i = 0; i < Math.abs(steps); i++) {
81
- await tmux.sendKey(pane, key);
90
+ await mux.sendKey(pane, key);
82
91
  await sleep(120);
83
92
  }
84
- await tmux.sendKey(pane, 'Enter');
93
+ await mux.sendKey(pane, 'Enter');
85
94
  log(`pane ${pane}: selected "Stop and wait for limit to reset" (${steps} steps)`);
86
95
  await sleep(1000);
87
96
  // After selection the TUI prints the limit banner — next scrape records it.
@@ -101,16 +110,16 @@ export function createMonitor({ pane, cwd, agent = getAgent('claude'), tmux = re
101
110
  log(`pane ${pane}: overload — retry ${overloadAttempt}/${OVERLOAD_BACKOFF_S.length} in ${Math.round(wait / 1000)}s`);
102
111
  await sleep(wait);
103
112
  if (!running) return;
104
- const text = await tmux.capturePane(pane, CAPTURE_LINES).catch(() => null);
113
+ const text = await mux.capturePane(pane, CAPTURE_LINES).catch(() => null);
105
114
  if (text === null) return;
106
115
  if (isBusy(text, agent.patterns.busyPatterns)) { log(`pane ${pane}: busy after overload wait — skip inject`); return; }
107
116
  if (!overloadMatch(text, agent.patterns.overloadPatterns)) { overloadAttempt = 0; return; } // recovered on its own
108
- 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.');
109
118
  log(`pane ${pane}: overload retry message sent`);
110
119
  }
111
120
 
112
121
  async function tick() {
113
- if (!(await tmux.paneAlive(pane))) {
122
+ if (!(await mux.paneAlive(pane))) {
114
123
  log(`pane ${pane}: gone — monitor exiting (record persists for resumer)`);
115
124
  running = false;
116
125
  return;
@@ -119,7 +128,7 @@ export function createMonitor({ pane, cwd, agent = getAgent('claude'), tmux = re
119
128
  const marker = consumeMarker();
120
129
  let text;
121
130
  try {
122
- text = await tmux.capturePane(pane, CAPTURE_LINES);
131
+ text = await mux.capturePane(pane, CAPTURE_LINES);
123
132
  } catch {
124
133
  return; // transient capture failure
125
134
  }
@@ -127,8 +136,8 @@ export function createMonitor({ pane, cwd, agent = getAgent('claude'), tmux = re
127
136
  // Interactive menu takes priority — it blocks the session until answered.
128
137
  // Checked against the VISIBLE screen only: a menu in scrollback history
129
138
  // was already answered, and re-driving it would inject stray keys.
130
- const visible = tmux.capturePaneVisible
131
- ? await tmux.capturePaneVisible(pane).catch(() => '')
139
+ const visible = mux.capturePaneVisible
140
+ ? await mux.capturePaneVisible(pane).catch(() => '')
132
141
  : text;
133
142
  if (agent.menu && agent.menu.isPrompt(visible, PANE_SCAN_LINES)) {
134
143
  if (!getConfig('menuAutoAnswer')) {
@@ -164,7 +173,7 @@ export function createMonitor({ pane, cwd, agent = getAgent('claude'), tmux = re
164
173
  if (term) {
165
174
  if (!terminalNotified) {
166
175
  terminalNotified = true;
167
- notifier(`${agent.name} needs attention ⚠️`, `${cwd} — ${term.line}`);
176
+ notifier(`${agent.name} needs attention ⚠️`, `${cwd} — ${term.line}`, { context: notifyCtx });
168
177
  log(`pane ${pane}: terminal error (no auto-resume): ${term.line}`);
169
178
  }
170
179
  } else {
@@ -177,10 +186,14 @@ export function createMonitor({ pane, cwd, agent = getAgent('claude'), tmux = re
177
186
  const state = readState();
178
187
  const rec = state.sessions[trackedKey];
179
188
  if (rec && rec.status === 'stopped') {
180
- setStatus(trackedKey, 'resumed', { lastAttemptAt: Date.now() });
189
+ setStatus(trackedKey, 'resumed', { lastAttemptAt: Date.now(), bannerCleared: true });
181
190
  log(`pane ${pane}: banner cleared, ${trackedKey} marked resumed`);
191
+ trackedKey = null;
182
192
  }
183
- 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;
184
197
  }
185
198
 
186
199
  // Overload marker (banner-less path).
@@ -206,9 +219,9 @@ export function createMonitor({ pane, cwd, agent = getAgent('claude'), tmux = re
206
219
  };
207
220
  }
208
221
 
209
- export async function runMonitor(pane, agentId) {
222
+ export async function runMonitor(muxName, paneOwner, pane, agentId, leaseId) {
210
223
  const cwd = process.env.UNSNOOZE_CWD || process.cwd();
211
- const monitor = createMonitor({ pane, cwd, agent: getAgent(agentId) });
224
+ const monitor = createMonitor({ muxName, paneOwner: paneOwner || null, pane, leaseId, cwd, agent: getAgent(agentId) });
212
225
  await monitor.run();
213
226
  return 0;
214
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;