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/CHANGELOG.md +35 -0
- package/README.md +57 -14
- package/bin/unsnooze.js +7 -6
- package/package.json +11 -3
- package/src/agents/codex.js +22 -2
- package/src/cli.js +2 -1
- package/src/config.js +3 -2
- package/src/hook.js +20 -7
- package/src/install.js +1 -1
- package/src/launcher.js +40 -21
- package/src/lease.js +87 -0
- package/src/monitor.js +33 -20
- package/src/multiplexer.js +58 -0
- package/src/multiplexers/tmux.js +200 -0
- package/src/multiplexers/zellij.js +193 -0
- package/src/notify-tty.js +311 -0
- package/src/notify.js +131 -14
- package/src/report.js +11 -4
- package/src/resumer.js +172 -73
- package/src/settings.js +6 -0
- package/src/state.js +29 -6
- package/src/watcher.js +4 -2
- package/src/wizard.js +6 -5
- package/src/tmux.js +0 -95
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
|
|
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,
|
|
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({
|
|
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, `${
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
|
90
|
+
await mux.sendKey(pane, key);
|
|
82
91
|
await sleep(120);
|
|
83
92
|
}
|
|
84
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
131
|
-
? await
|
|
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
|
|
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;
|