unsnooze 1.8.1 → 1.10.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/CHANGELOG.md +49 -0
- package/README.md +46 -0
- package/bin/unsnooze.js +12 -1
- package/package.json +1 -1
- package/src/agents/claude.js +8 -1
- package/src/cli.js +97 -5
- package/src/config.js +17 -2
- package/src/hook.js +33 -6
- package/src/install.js +11 -1
- package/src/launcher.js +24 -7
- package/src/monitor.js +123 -13
- package/src/multiplexers/session-name.js +28 -0
- package/src/multiplexers/tmux.js +63 -2
- package/src/multiplexers/zellij.js +111 -5
- package/src/reap.js +187 -0
- package/src/resumer.js +216 -25
- package/src/sessions.js +51 -2
- package/src/settings.js +27 -1
- package/src/spawn.js +23 -1
- package/src/state.js +91 -3
- package/src/time-parser.js +108 -43
- package/src/watcher.js +10 -3
- package/src/watchers/claude.js +44 -1
package/src/monitor.js
CHANGED
|
@@ -14,14 +14,15 @@ 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
|
-
|
|
17
|
+
PROBE_INTERVAL_MS, RESET_MARGIN_MS,
|
|
18
18
|
} from './config.js';
|
|
19
19
|
import { detectLimit, isBusy, overloadMatch } from './patterns.js';
|
|
20
20
|
import { getAgent } from './agents/index.js';
|
|
21
21
|
import { getConfig } from './settings.js';
|
|
22
22
|
import { notify } from './notify.js';
|
|
23
|
-
import { parseResetTime, resetAtMs } from './time-parser.js';
|
|
24
|
-
import { upsertSession, setStatus, readState } from './state.js';
|
|
23
|
+
import { parseResetTime, resetAtMs, sourceRank } from './time-parser.js';
|
|
24
|
+
import { upsertSession, setStatus, readState, updateState } from './state.js';
|
|
25
|
+
import { latestRateLimitFromTranscript } from './watchers/claude.js';
|
|
25
26
|
import { spawnResumerIfNeeded } from './spawn.js';
|
|
26
27
|
import { makeLogger } from './logger.js';
|
|
27
28
|
import { addressHash } from './lease.js';
|
|
@@ -40,6 +41,7 @@ export function createMonitor({
|
|
|
40
41
|
let overloadAttempt = 0;
|
|
41
42
|
let terminalNotified = false; // one notification per terminal-error appearance
|
|
42
43
|
let running = true;
|
|
44
|
+
let firstTick = true; // §6: don't inherit a previous session's banner
|
|
43
45
|
|
|
44
46
|
function markerPath() {
|
|
45
47
|
return join(EVENTS_DIR, `${addressHash({ mux: muxName, paneOwner, pane })}.json`);
|
|
@@ -56,27 +58,102 @@ export function createMonitor({
|
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
|
|
61
|
+
// Prefer a dated transcript entry when the agent keeps one (claude).
|
|
62
|
+
function resolveBanner(paneResetLine, paneLimitType) {
|
|
60
63
|
const detectedAt = Date.now();
|
|
61
|
-
const
|
|
62
|
-
|
|
64
|
+
const sessionId = agent.latestSessionId?.(cwd, detectedAt) || null;
|
|
65
|
+
const fromTx = latestRateLimitFromTranscript(cwd, sessionId, { now: detectedAt });
|
|
66
|
+
if (fromTx) {
|
|
67
|
+
return {
|
|
68
|
+
resetLine: fromTx.resetLine ?? paneResetLine,
|
|
69
|
+
limitType: fromTx.limitType && fromTx.limitType !== 'unknown' ? fromTx.limitType : paneLimitType,
|
|
70
|
+
bannerAt: fromTx.timestampMs,
|
|
71
|
+
sessionId: fromTx.sessionId || sessionId,
|
|
72
|
+
via: 'transcript',
|
|
73
|
+
detectedAt,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
resetLine: paneResetLine,
|
|
78
|
+
limitType: paneLimitType,
|
|
79
|
+
bannerAt: null,
|
|
80
|
+
sessionId,
|
|
81
|
+
via: null, // filled by caller (scrape/hook)
|
|
82
|
+
detectedAt,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function computeReset(resetLine, bannerAt, detectedAt) {
|
|
87
|
+
return resetAtMs(parseResetTime(resetLine), {
|
|
88
|
+
marginMs: RESET_MARGIN_MS,
|
|
89
|
+
fallbackMs: PROBE_INTERVAL_MS,
|
|
90
|
+
now: new Date(detectedAt),
|
|
91
|
+
bannerAt,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// §6 first-tick corroboration: only record if a fresh transcript backs it,
|
|
96
|
+
// or an absolute clock time resolves to a still-future instant (not the
|
|
97
|
+
// "already past → due now" path, which is margin above wall clock only).
|
|
98
|
+
function isCorroborated(resolved, at, source) {
|
|
99
|
+
if (resolved.via === 'transcript' && resolved.bannerAt != null) return true;
|
|
100
|
+
if (source === 'absolute' && at - RESET_MARGIN_MS > Date.now()) return true;
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// §7: upgrade a weak estimate (fallback/relative) when a better value
|
|
105
|
+
// becomes available. A strictly better source (e.g. fallback→absolute)
|
|
106
|
+
// always wins — absolute is truth even if later than a probe guess.
|
|
107
|
+
// Same-or-worse source may only pull the wake earlier, never push it later.
|
|
108
|
+
function maybeUpgrade(existing, at, source, bannerAt, limitType, via) {
|
|
109
|
+
if (!existing || existing.status !== 'stopped') return false;
|
|
110
|
+
const oldRank = sourceRank(existing.resetSource);
|
|
111
|
+
const newRank = sourceRank(source);
|
|
112
|
+
const betterSource = newRank > oldRank;
|
|
113
|
+
const earlier = at < existing.resetAt;
|
|
114
|
+
if (!betterSource && !earlier) return false;
|
|
115
|
+
if (!betterSource && at > existing.resetAt) return false;
|
|
116
|
+
updateState(state => {
|
|
117
|
+
const s = state.sessions[existing.key];
|
|
118
|
+
if (!s || s.status !== 'stopped') return;
|
|
119
|
+
s.resetAt = at;
|
|
120
|
+
s.resetSource = source;
|
|
121
|
+
if (bannerAt != null) s.bannerAt = bannerAt;
|
|
122
|
+
if (limitType && limitType !== 'unknown') s.limitType = limitType;
|
|
123
|
+
if (via) s.detectedVia = via;
|
|
124
|
+
if (source !== 'fallback') delete s.probeCount;
|
|
63
125
|
});
|
|
64
|
-
|
|
126
|
+
log(`pane ${pane}: upgraded reset ${existing.resetSource}→${source} at ${new Date(at).toISOString()}`);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function recordLimit(resetLine, limitType, via) {
|
|
131
|
+
const resolved = resolveBanner(resetLine, limitType);
|
|
132
|
+
const detectedVia = resolved.via || via;
|
|
133
|
+
const { at, source } = computeReset(resolved.resetLine, resolved.bannerAt, resolved.detectedAt);
|
|
134
|
+
let muxSession = null;
|
|
135
|
+
if (typeof mux.sessionForPane === 'function') {
|
|
136
|
+
try { muxSession = await mux.sessionForPane(pane); } catch { muxSession = null; }
|
|
137
|
+
}
|
|
65
138
|
const state = upsertSession({
|
|
66
|
-
sessionId, cwd, pane, mux: muxName, paneOwner, leaseId,
|
|
67
|
-
agent: agent.id, muxSession
|
|
68
|
-
status: 'stopped', limitType
|
|
139
|
+
sessionId: resolved.sessionId, cwd, pane, mux: muxName, paneOwner, leaseId,
|
|
140
|
+
agent: agent.id, muxSession,
|
|
141
|
+
status: 'stopped', limitType: resolved.limitType, detectedVia,
|
|
142
|
+
detectedAt: resolved.detectedAt,
|
|
143
|
+
bannerAt: resolved.bannerAt,
|
|
69
144
|
resetAt: at, resetSource: source,
|
|
70
145
|
attempts: 0, lastAttemptAt: null, lastError: null,
|
|
146
|
+
...(source === 'fallback' ? { probeCount: 0 } : {}),
|
|
71
147
|
});
|
|
72
|
-
trackedKey = sessionId
|
|
148
|
+
trackedKey = resolved.sessionId
|
|
73
149
|
|| Object.values(state.sessions).find(s => s.mux === muxName
|
|
74
150
|
&& s.paneOwner === paneOwner && s.pane === pane
|
|
75
151
|
&& ['stopped', 'resuming', 'resumed'].includes(s.status))?.key
|
|
76
152
|
|| null;
|
|
77
|
-
log(`pane ${pane}: limit recorded (${limitType}, via ${
|
|
153
|
+
log(`pane ${pane}: limit recorded (${resolved.limitType}, via ${detectedVia}), resets ${new Date(at).toISOString()} (${source})`);
|
|
78
154
|
notifier(`${agent.name} hit a usage limit`, `${cwd} — auto-resume at ${new Date(at).toLocaleTimeString()}`, { context: notifyCtx });
|
|
79
155
|
spawnResumerIfNeeded();
|
|
156
|
+
return { at, source, resolved };
|
|
80
157
|
}
|
|
81
158
|
|
|
82
159
|
async function driveMenu(text) {
|
|
@@ -150,9 +227,11 @@ export function createMonitor({
|
|
|
150
227
|
log(`pane ${pane}: limit menu detected but menuAutoAnswer is off — recording only`);
|
|
151
228
|
await recordLimit(d.hit ? d.resetLine : null, d.hit ? d.limitType : 'unknown', 'scrape');
|
|
152
229
|
}
|
|
230
|
+
firstTick = false;
|
|
153
231
|
return;
|
|
154
232
|
}
|
|
155
233
|
await driveMenu(visible);
|
|
234
|
+
firstTick = false;
|
|
156
235
|
return;
|
|
157
236
|
}
|
|
158
237
|
|
|
@@ -160,9 +239,37 @@ export function createMonitor({
|
|
|
160
239
|
if (d.hit) {
|
|
161
240
|
const state = readState();
|
|
162
241
|
const existing = trackedKey && state.sessions[trackedKey];
|
|
242
|
+
|
|
243
|
+
// §7: still-stopped records can upgrade a weak estimate.
|
|
244
|
+
if (existing && existing.status === 'stopped') {
|
|
245
|
+
const resolved = resolveBanner(d.resetLine, d.limitType);
|
|
246
|
+
const via = resolved.via || (marker ? 'hook' : 'scrape');
|
|
247
|
+
const { at, source } = computeReset(resolved.resetLine, resolved.bannerAt, resolved.detectedAt);
|
|
248
|
+
maybeUpgrade(existing, at, source, resolved.bannerAt, resolved.limitType, via);
|
|
249
|
+
firstTick = false;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
163
253
|
if (!existing || existing.status !== 'stopped') {
|
|
164
|
-
|
|
254
|
+
const via = marker ? 'hook' : 'scrape';
|
|
255
|
+
// §6: first tick needs corroboration so we don't inherit a leftover banner.
|
|
256
|
+
if (firstTick) {
|
|
257
|
+
const resolved = resolveBanner(d.resetLine, d.limitType);
|
|
258
|
+
const detectedVia = resolved.via || via;
|
|
259
|
+
const { at, source } = computeReset(resolved.resetLine, resolved.bannerAt, resolved.detectedAt);
|
|
260
|
+
if (!isCorroborated({ ...resolved, via: detectedVia }, at, source)) {
|
|
261
|
+
log(`pane ${pane}: first tick banner uncorroborated (${source}) — skipping`);
|
|
262
|
+
firstTick = false;
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
// Reuse recordLimit path; resolveBanner is idempotent enough.
|
|
266
|
+
await recordLimit(d.resetLine, d.limitType, via);
|
|
267
|
+
firstTick = false;
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
await recordLimit(d.resetLine, d.limitType, via);
|
|
165
271
|
}
|
|
272
|
+
firstTick = false;
|
|
166
273
|
return;
|
|
167
274
|
}
|
|
168
275
|
|
|
@@ -202,6 +309,8 @@ export function createMonitor({
|
|
|
202
309
|
} else if (!marker && overloadMatch(text, agent.patterns.overloadPatterns) && !isBusy(text, agent.patterns.busyPatterns)) {
|
|
203
310
|
await handleOverload();
|
|
204
311
|
}
|
|
312
|
+
|
|
313
|
+
firstTick = false;
|
|
205
314
|
}
|
|
206
315
|
|
|
207
316
|
return {
|
|
@@ -216,6 +325,7 @@ export function createMonitor({
|
|
|
216
325
|
stop() { running = false; },
|
|
217
326
|
_tick: tick, // exposed for tests
|
|
218
327
|
get trackedKey() { return trackedKey; },
|
|
328
|
+
get _firstTick() { return firstTick; },
|
|
219
329
|
};
|
|
220
330
|
}
|
|
221
331
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Wrapped sessions are created by name, and both tmux and zellij refuse a name
|
|
2
|
+
// that is already live (tmux: "duplicate session: unsnooze"). Concurrent
|
|
3
|
+
// `unsnooze <agent>` launches therefore need distinct names: the first holds
|
|
4
|
+
// the base, later ones take the first free `<base>-N`.
|
|
5
|
+
|
|
6
|
+
const MAX_PROBES = 64;
|
|
7
|
+
|
|
8
|
+
export function resolveSessionName(base, isTaken) {
|
|
9
|
+
if (!isTaken(base)) return base;
|
|
10
|
+
for (let n = 2; n <= MAX_PROBES; n += 1) {
|
|
11
|
+
const candidate = `${base}-${n}`;
|
|
12
|
+
if (!isTaken(candidate)) return candidate;
|
|
13
|
+
}
|
|
14
|
+
// Improbable, but a name must still be returned; the pid is unique among live
|
|
15
|
+
// sessions even if a stale `<base>-<pid>` somehow lingers.
|
|
16
|
+
return `${base}-${process.pid}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Raised by launchWrapped when the multiplexer binary could not be started
|
|
20
|
+
// (spawnSync result.error). Distinct from a normal agent exit status so the
|
|
21
|
+
// launcher can degrade to an unwatched CLI without double-running a healthy agent.
|
|
22
|
+
export class SessionCreateError extends Error {
|
|
23
|
+
constructor(message, cause) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'SessionCreateError';
|
|
26
|
+
if (cause !== undefined) this.cause = cause;
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/multiplexers/tmux.js
CHANGED
|
@@ -2,6 +2,8 @@ import { execFile as execFileCb, spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { constants as osConstants } from 'node:os';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
4
|
|
|
5
|
+
import { resolveSessionName, SessionCreateError } from './session-name.js';
|
|
6
|
+
|
|
5
7
|
const execFileAsync = promisify(execFileCb);
|
|
6
8
|
|
|
7
9
|
function defaultSpawner(file, args, { sync = false, ...options } = {}) {
|
|
@@ -28,6 +30,8 @@ function wrappedSessionName(env) {
|
|
|
28
30
|
// calls with a pause between them, or Ink treats Enter as bracketed paste.
|
|
29
31
|
export const SUBMIT_DELAY_MS = 150;
|
|
30
32
|
|
|
33
|
+
export { SessionCreateError };
|
|
34
|
+
|
|
31
35
|
export function createTmux({ spawner = defaultSpawner, env = process.env } = {}) {
|
|
32
36
|
const run = (...args) => spawner('tmux', args);
|
|
33
37
|
|
|
@@ -79,6 +83,17 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
|
|
|
79
83
|
}
|
|
80
84
|
},
|
|
81
85
|
|
|
86
|
+
// Live session the pane currently lives in. Trust stdout, not the exit
|
|
87
|
+
// code (same tmux 3.7b blank-success pitfall as paneAlive).
|
|
88
|
+
async sessionForPane(pane) {
|
|
89
|
+
try {
|
|
90
|
+
const out = (await run('display-message', '-t', pane, '-p', '#{session_name}')).trim();
|
|
91
|
+
return out || null;
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
|
|
82
97
|
// Clients attached to the session that owns `pane`. Detached / missing
|
|
83
98
|
// targets return [] so callers can feature-detect without try/catch.
|
|
84
99
|
async clientTtys(pane) {
|
|
@@ -147,6 +162,33 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
|
|
|
147
162
|
}
|
|
148
163
|
},
|
|
149
164
|
|
|
165
|
+
async listSessions() {
|
|
166
|
+
try {
|
|
167
|
+
const out = await run('list-sessions', '-F', '#{session_name}');
|
|
168
|
+
return out.split(/\r?\n/).map(line => line.trim()).filter(Boolean)
|
|
169
|
+
.map(name => ({ name, exited: false }));
|
|
170
|
+
} catch {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
async listSessionPanes(sessionName) {
|
|
176
|
+
try {
|
|
177
|
+
const out = await run('list-panes', '-t', sessionName, '-F', '#{pane_id}');
|
|
178
|
+
return out.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
|
179
|
+
} catch {
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
async closePane(pane) {
|
|
185
|
+
await run('kill-pane', '-t', pane);
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
async deleteSession(name) {
|
|
189
|
+
await run('kill-session', '-t', name);
|
|
190
|
+
},
|
|
191
|
+
|
|
150
192
|
async newWindow(sessionName, cwd, launchSpec) {
|
|
151
193
|
// Environment flags require tmux >= 3.0 for new-window and >= 3.2 for
|
|
152
194
|
// new-session. Older tmux fails revival with an "unknown flag -e" error.
|
|
@@ -159,7 +201,9 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
|
|
|
159
201
|
pane = await run('new-window', '-t', `${sessionName}:`, '-c', cwd,
|
|
160
202
|
'-P', '-F', '#{pane_id}', ...launch);
|
|
161
203
|
}
|
|
162
|
-
|
|
204
|
+
// tmux pane ids are server-global; paneOwner is only meaningful for
|
|
205
|
+
// zellij. Tracking the session lives on muxSession, not paneOwner.
|
|
206
|
+
return { pane: pane.trim(), paneOwner: null };
|
|
163
207
|
},
|
|
164
208
|
|
|
165
209
|
launchWrapped(launchSpec) {
|
|
@@ -167,9 +211,26 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
|
|
|
167
211
|
// tmux in the foreground also gives Ctrl-C to the active pane directly.
|
|
168
212
|
// Its -e environment flags require tmux >= 3.2; older versions fail
|
|
169
213
|
// revival with an "unknown flag -e" error.
|
|
170
|
-
const
|
|
214
|
+
const name = resolveSessionName(wrappedSessionName(env), candidate => {
|
|
215
|
+
try {
|
|
216
|
+
return spawner('tmux', ['has-session', '-t', candidate],
|
|
217
|
+
{ sync: true, stdio: 'ignore', env }).status === 0;
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
// Session name is discovered live via sessionForPane at record-write
|
|
223
|
+
// time — do NOT inject UNSNOOZE_SESSION_NAME (would leak into daemons
|
|
224
|
+
// spawned from the agent via {...process.env}).
|
|
225
|
+
const args = ['new-session', '-s', name,
|
|
171
226
|
...envArgs(launchSpec.env), launchSpec.file, ...(launchSpec.args || [])];
|
|
172
227
|
const result = spawner('tmux', args, { sync: true, stdio: 'inherit', env });
|
|
228
|
+
if (result?.error) {
|
|
229
|
+
throw new SessionCreateError(
|
|
230
|
+
`failed to start tmux session "${name}": ${result.error.message}`,
|
|
231
|
+
result.error,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
173
234
|
return exitStatus(result);
|
|
174
235
|
},
|
|
175
236
|
|
|
@@ -3,6 +3,8 @@ import { constants as osConstants } from 'node:os';
|
|
|
3
3
|
import { basename } from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
|
|
6
|
+
import { resolveSessionName, SessionCreateError } from './session-name.js';
|
|
7
|
+
|
|
6
8
|
const execFileAsync = promisify(execFileCb);
|
|
7
9
|
|
|
8
10
|
function defaultSpawner(file, args, { sync = false, ...options } = {}) {
|
|
@@ -48,20 +50,54 @@ function wrappedLayout(launchSpec) {
|
|
|
48
50
|
return `layout { pane command=${kdlString(argv[0])} close_on_exit=true { args ${argv.slice(1).map(kdlString).join(' ')} } }`;
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
// Parse `zellij list-sessions -s` rows: strip ANSI, keep leading name token,
|
|
54
|
+
// detect an "(EXITED …)" suffix. Rows that fail to parse are dropped.
|
|
55
|
+
function parseSessionRows(stdout) {
|
|
56
|
+
return String(stdout)
|
|
57
|
+
// eslint-disable-next-line no-control-regex
|
|
58
|
+
.replace(/\u001b\[[0-9;]*m/g, '')
|
|
59
|
+
.split(/\r?\n/)
|
|
60
|
+
.map(line => line.trim())
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.map(line => {
|
|
63
|
+
const name = line.split(/\s+/)[0];
|
|
64
|
+
if (!name) return null;
|
|
65
|
+
return { name, exited: /\(EXITED/i.test(line) };
|
|
66
|
+
})
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
export const SUBMIT_DELAY_MS = 150;
|
|
52
71
|
|
|
72
|
+
export { SessionCreateError };
|
|
73
|
+
|
|
53
74
|
export function createZellij({ spawner = defaultSpawner, env = process.env } = {}) {
|
|
54
75
|
const childEnv = () => scrubZellijEnv(env);
|
|
55
76
|
const run = (args, options = {}) => spawner('zellij', args, { env: childEnv(), ...options });
|
|
56
77
|
|
|
78
|
+
// Sync counterpart of sessionExists, for the sync launchWrapped path. Rows can
|
|
79
|
+
// carry ANSI colour and an "(EXITED …)" suffix, so only the leading name token
|
|
80
|
+
// is compared. Probe failure → empty set: assume free and let zellij arbitrate.
|
|
81
|
+
const liveSessionNames = () => {
|
|
82
|
+
try {
|
|
83
|
+
const result = spawner('zellij', ['list-sessions', '-s'],
|
|
84
|
+
{ sync: true, encoding: 'utf8', env: childEnv() });
|
|
85
|
+
const stdout = typeof result === 'string' ? result : (result?.stdout ?? '');
|
|
86
|
+
return new Set(parseSessionRows(stdout).map(row => row.name));
|
|
87
|
+
} catch {
|
|
88
|
+
return new Set();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
57
92
|
const build = owner => {
|
|
58
93
|
const owned = (...args) => {
|
|
59
94
|
if (!owner) throw new Error('unsnooze: zellij pane operation requires a session owner');
|
|
60
95
|
return run(['-s', owner, ...args]);
|
|
61
96
|
};
|
|
62
97
|
|
|
63
|
-
const paneEntries = async () => {
|
|
64
|
-
|
|
98
|
+
const paneEntries = async (session = owner) => {
|
|
99
|
+
if (!session) throw new Error('unsnooze: zellij pane operation requires a session owner');
|
|
100
|
+
const stdout = await run(['-s', session, 'action', 'list-panes', '-a', '-j']);
|
|
65
101
|
const parsed = JSON.parse(stdout);
|
|
66
102
|
return Array.isArray(parsed) ? parsed : [];
|
|
67
103
|
};
|
|
@@ -127,6 +163,12 @@ export function createZellij({ spawner = defaultSpawner, env = process.env } = {
|
|
|
127
163
|
}
|
|
128
164
|
},
|
|
129
165
|
|
|
166
|
+
// Pane ids are per-session; the bound owner is the session name. Fall
|
|
167
|
+
// back to the ambient ZELLIJ_SESSION_NAME when unbound.
|
|
168
|
+
async sessionForPane(_pane) {
|
|
169
|
+
return owner || env.ZELLIJ_SESSION_NAME || null;
|
|
170
|
+
},
|
|
171
|
+
|
|
130
172
|
async paneCurrentCommand(pane) {
|
|
131
173
|
try {
|
|
132
174
|
const id = Number(pane);
|
|
@@ -144,21 +186,75 @@ export function createZellij({ spawner = defaultSpawner, env = process.env } = {
|
|
|
144
186
|
async sessionExists(name) {
|
|
145
187
|
try {
|
|
146
188
|
const stdout = await run(['list-sessions', '-s']);
|
|
147
|
-
return stdout
|
|
189
|
+
return parseSessionRows(stdout).some(row => row.name === name);
|
|
148
190
|
} catch {
|
|
149
191
|
return false;
|
|
150
192
|
}
|
|
151
193
|
},
|
|
152
194
|
|
|
195
|
+
async listSessions() {
|
|
196
|
+
try {
|
|
197
|
+
const stdout = await run(['list-sessions', '-s']);
|
|
198
|
+
return parseSessionRows(stdout);
|
|
199
|
+
} catch {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
async listSessionPanes(sessionName) {
|
|
205
|
+
try {
|
|
206
|
+
const entries = await paneEntries(sessionName);
|
|
207
|
+
return entries
|
|
208
|
+
.filter(entry => entry.is_plugin === false && entry.exited === false)
|
|
209
|
+
.map(entry => String(entry.id));
|
|
210
|
+
} catch {
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
async closePane(pane) {
|
|
216
|
+
if (!owner) throw new Error('unsnooze: zellij pane operation requires a session owner');
|
|
217
|
+
await owned('action', 'close-pane', '-p', `terminal_${pane}`);
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
async deleteSession(name) {
|
|
221
|
+
await run(['delete-session', name]);
|
|
222
|
+
},
|
|
223
|
+
|
|
153
224
|
async newWindow(sessionName, cwd, launchSpec) {
|
|
225
|
+
let created = false;
|
|
226
|
+
let preexisting = [];
|
|
154
227
|
if (!(await backend.sessionExists(sessionName))) {
|
|
228
|
+
// attach -b -c uses the default layout, which leaves a shell pane
|
|
229
|
+
// that never exits. Snapshot its ids so we can close them after the
|
|
230
|
+
// agent pane is added (closing the last pane would kill the session).
|
|
155
231
|
await run(['attach', '-b', '-c', sessionName]);
|
|
232
|
+
created = true;
|
|
233
|
+
try {
|
|
234
|
+
preexisting = (await paneEntries(sessionName))
|
|
235
|
+
.filter(entry => entry.is_plugin === false)
|
|
236
|
+
.map(entry => entry.id);
|
|
237
|
+
} catch {
|
|
238
|
+
preexisting = [];
|
|
239
|
+
}
|
|
156
240
|
}
|
|
241
|
+
// --close-on-exit matches launchWrapped's close_on_exit=true and tmux
|
|
242
|
+
// (pane dies with its command) so exited revivals don't keep the session.
|
|
157
243
|
const stdout = await run([
|
|
158
|
-
'-s', sessionName, 'run', '--cwd', cwd, '--',
|
|
244
|
+
'-s', sessionName, 'run', '--close-on-exit', '--cwd', cwd, '--',
|
|
245
|
+
...launchArgv(launchSpec),
|
|
159
246
|
]);
|
|
160
247
|
const match = stdout.trim().match(/^terminal_(\d+)$/);
|
|
161
248
|
if (!match) throw new Error(`unsnooze: unexpected zellij pane id: ${stdout.trim()}`);
|
|
249
|
+
if (created && preexisting.length) {
|
|
250
|
+
for (const id of preexisting) {
|
|
251
|
+
try {
|
|
252
|
+
await run(['-s', sessionName, 'action', 'close-pane', '-p', `terminal_${id}`]);
|
|
253
|
+
} catch {
|
|
254
|
+
// Best-effort: a missing default pane is fine.
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
162
258
|
return { pane: match[1], paneOwner: sessionName };
|
|
163
259
|
},
|
|
164
260
|
|
|
@@ -166,11 +262,21 @@ export function createZellij({ spawner = defaultSpawner, env = process.env } = {
|
|
|
166
262
|
// The layout contains exactly one auto-closing pane, avoiding the
|
|
167
263
|
// lingering default shell created by attach -b. The foreground client
|
|
168
264
|
// owns the terminal, so Ctrl-C reaches zellij and its active pane.
|
|
265
|
+
const live = liveSessionNames();
|
|
266
|
+
const name = resolveSessionName(wrappedSessionName(env), c => live.has(c));
|
|
267
|
+
// Session name is discovered live via sessionForPane at record-write
|
|
268
|
+
// time — do NOT inject UNSNOOZE_SESSION_NAME into the layout env.
|
|
169
269
|
const result = spawner('zellij', [
|
|
170
|
-
'--session',
|
|
270
|
+
'--session', name, '--layout-string', wrappedLayout(launchSpec),
|
|
171
271
|
], {
|
|
172
272
|
sync: true, stdio: 'inherit', env: childEnv(),
|
|
173
273
|
});
|
|
274
|
+
if (result?.error) {
|
|
275
|
+
throw new SessionCreateError(
|
|
276
|
+
`failed to start zellij session "${name}": ${result.error.message}`,
|
|
277
|
+
result.error,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
174
280
|
return exitStatus(result);
|
|
175
281
|
},
|
|
176
282
|
|