unsnooze 1.9.0 → 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 +32 -0
- package/README.md +19 -0
- package/bin/unsnooze.js +12 -1
- package/package.json +1 -1
- package/src/cli.js +86 -4
- 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 +175 -19
- package/src/sessions.js +2 -1
- package/src/settings.js +7 -0
- 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
|
@@ -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
|
|
package/src/reap.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Explicit process/session cleanup (`unsnooze sessions` / `unsnooze reap`).
|
|
2
|
+
// Never auto-kills a live agent pane unless the user opts in via reapResumed.
|
|
3
|
+
|
|
4
|
+
import { MUX_SESSION_NAME, RESUME_SESSION_NAME } from './config.js';
|
|
5
|
+
import { getMultiplexer } from './multiplexer.js';
|
|
6
|
+
import { readState, setStatus, updateState } from './state.js';
|
|
7
|
+
import { getConfig } from './settings.js';
|
|
8
|
+
import { makeLogger } from './logger.js';
|
|
9
|
+
|
|
10
|
+
const log = makeLogger('reap');
|
|
11
|
+
|
|
12
|
+
// How a user reaches a revived session. Shared by status, toast, and `sessions`.
|
|
13
|
+
export function attachHint(muxName, sessionName) {
|
|
14
|
+
if (!sessionName) return null;
|
|
15
|
+
if (muxName === 'zellij') return `zellij attach ${sessionName}`;
|
|
16
|
+
return `tmux attach -t ${sessionName}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// A session name is unsnooze-owned if it is the interactive base, a collision
|
|
20
|
+
// suffix (`unsnooze-2`…), the dedicated resume session, or a pid fallback.
|
|
21
|
+
export function isUnsnoozeSessionName(name, base = MUX_SESSION_NAME) {
|
|
22
|
+
if (!name || typeof name !== 'string') return false;
|
|
23
|
+
if (name === base) return true;
|
|
24
|
+
if (name === RESUME_SESSION_NAME) return true;
|
|
25
|
+
if (name === `${base}-resumed`) return true;
|
|
26
|
+
// base-N / base-<pid>
|
|
27
|
+
if (name.startsWith(`${base}-`) && /^[0-9]+$/.test(name.slice(base.length + 1))) return true;
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function listOwnedSessions({ muxName = null } = {}) {
|
|
32
|
+
const names = muxName ? [muxName] : ['tmux', 'zellij'];
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const name of names) {
|
|
35
|
+
let mux;
|
|
36
|
+
try { mux = getMultiplexer(name); } catch { continue; }
|
|
37
|
+
if (!mux.available?.()) continue;
|
|
38
|
+
if (typeof mux.listSessions !== 'function') continue;
|
|
39
|
+
let sessions = [];
|
|
40
|
+
try { sessions = await mux.listSessions(); } catch { continue; }
|
|
41
|
+
for (const row of sessions) {
|
|
42
|
+
if (!isUnsnoozeSessionName(row.name)) continue;
|
|
43
|
+
let panes = [];
|
|
44
|
+
try {
|
|
45
|
+
const bound = mux.bind ? mux.bind(row.name) : mux;
|
|
46
|
+
panes = typeof bound.listSessionPanes === 'function'
|
|
47
|
+
? await bound.listSessionPanes(row.name)
|
|
48
|
+
: [];
|
|
49
|
+
} catch { panes = []; }
|
|
50
|
+
// Match records that live in this session.
|
|
51
|
+
const records = Object.values(readState().sessions).filter(s =>
|
|
52
|
+
(s.muxSession === row.name || s.paneOwner === row.name)
|
|
53
|
+
&& (!s.mux || s.mux === name));
|
|
54
|
+
out.push({
|
|
55
|
+
mux: name,
|
|
56
|
+
name: row.name,
|
|
57
|
+
exited: !!row.exited,
|
|
58
|
+
panes,
|
|
59
|
+
records: records.map(r => ({
|
|
60
|
+
key: r.key,
|
|
61
|
+
status: r.status,
|
|
62
|
+
agent: r.agent,
|
|
63
|
+
cwd: r.cwd,
|
|
64
|
+
pane: r.pane,
|
|
65
|
+
})),
|
|
66
|
+
attach: attachHint(name, row.name),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Close panes for terminal records and remove empty/exited unsnooze sessions.
|
|
74
|
+
// dryRun (default true) only reports what would happen.
|
|
75
|
+
export async function reap({
|
|
76
|
+
dryRun = true,
|
|
77
|
+
yes = false,
|
|
78
|
+
resolveMux = rec => getMultiplexer(rec.mux, { owner: rec.paneOwner }),
|
|
79
|
+
} = {}) {
|
|
80
|
+
// --yes flips dry-run off; plain default stays dry-run.
|
|
81
|
+
if (yes) dryRun = false;
|
|
82
|
+
const actions = [];
|
|
83
|
+
const state = readState();
|
|
84
|
+
const terminal = Object.values(state.sessions).filter(s =>
|
|
85
|
+
['resumed', 'failed', 'cancelled'].includes(s.status) && s.pane);
|
|
86
|
+
|
|
87
|
+
for (const rec of terminal) {
|
|
88
|
+
let alive = false;
|
|
89
|
+
try {
|
|
90
|
+
const mux = resolveMux(rec);
|
|
91
|
+
alive = await mux.paneAlive(rec.pane);
|
|
92
|
+
} catch { alive = false; }
|
|
93
|
+
if (!alive) {
|
|
94
|
+
actions.push({ kind: 'drop-record', key: rec.key, reason: 'pane already dead' });
|
|
95
|
+
if (!dryRun) {
|
|
96
|
+
updateState(s => { delete s.sessions[rec.key]; return s; });
|
|
97
|
+
}
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
actions.push({
|
|
101
|
+
kind: 'close-pane',
|
|
102
|
+
key: rec.key,
|
|
103
|
+
mux: rec.mux,
|
|
104
|
+
pane: rec.pane,
|
|
105
|
+
paneOwner: rec.paneOwner,
|
|
106
|
+
session: rec.muxSession,
|
|
107
|
+
});
|
|
108
|
+
if (!dryRun) {
|
|
109
|
+
try {
|
|
110
|
+
const mux = resolveMux(rec);
|
|
111
|
+
if (typeof mux.closePane === 'function') await mux.closePane(rec.pane);
|
|
112
|
+
setStatus(rec.key, rec.status, { pane: null });
|
|
113
|
+
updateState(s => { delete s.sessions[rec.key]; return s; });
|
|
114
|
+
} catch (err) {
|
|
115
|
+
actions.push({ kind: 'error', key: rec.key, message: err.message });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Empty / EXITED unsnooze-owned sessions.
|
|
121
|
+
for (const name of ['tmux', 'zellij']) {
|
|
122
|
+
let mux;
|
|
123
|
+
try { mux = getMultiplexer(name); } catch { continue; }
|
|
124
|
+
if (!mux.available?.() || typeof mux.listSessions !== 'function') continue;
|
|
125
|
+
let sessions = [];
|
|
126
|
+
try { sessions = await mux.listSessions(); } catch { continue; }
|
|
127
|
+
for (const row of sessions) {
|
|
128
|
+
if (!isUnsnoozeSessionName(row.name)) continue;
|
|
129
|
+
const bound = mux.bind ? mux.bind(row.name) : mux;
|
|
130
|
+
let panes = [];
|
|
131
|
+
try {
|
|
132
|
+
panes = typeof bound.listSessionPanes === 'function'
|
|
133
|
+
? await bound.listSessionPanes(row.name)
|
|
134
|
+
: [];
|
|
135
|
+
} catch { panes = []; }
|
|
136
|
+
const empty = panes.length === 0;
|
|
137
|
+
const exited = !!row.exited;
|
|
138
|
+
// tmux auto-destroys empty sessions; only act when empty (or EXITED for zellij).
|
|
139
|
+
if (!empty && !exited) continue;
|
|
140
|
+
actions.push({
|
|
141
|
+
kind: 'delete-session',
|
|
142
|
+
mux: name,
|
|
143
|
+
name: row.name,
|
|
144
|
+
reason: exited ? 'exited' : 'empty',
|
|
145
|
+
});
|
|
146
|
+
if (!dryRun && typeof bound.deleteSession === 'function') {
|
|
147
|
+
try { await bound.deleteSession(row.name); }
|
|
148
|
+
catch (err) {
|
|
149
|
+
actions.push({ kind: 'error', name: row.name, message: err.message });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { dryRun, actions };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Optional auto-reap of long-idle `resumed` panes when reapResumed is on.
|
|
159
|
+
export async function autoReapIfEnabled({
|
|
160
|
+
resolveMux = rec => getMultiplexer(rec.mux, { owner: rec.paneOwner }),
|
|
161
|
+
now = Date.now(),
|
|
162
|
+
} = {}) {
|
|
163
|
+
if (!getConfig('reapResumed')) return 0;
|
|
164
|
+
const idleAfter = getConfig('reapIdleAfter');
|
|
165
|
+
let closed = 0;
|
|
166
|
+
for (const rec of Object.values(readState().sessions)) {
|
|
167
|
+
if (rec.status !== 'resumed' || !rec.pane) continue;
|
|
168
|
+
const ts = rec.lastAttemptAt || rec.detectedAt || 0;
|
|
169
|
+
if (ts > now - idleAfter) continue;
|
|
170
|
+
try {
|
|
171
|
+
const mux = resolveMux(rec);
|
|
172
|
+
if (!(await mux.paneAlive(rec.pane))) {
|
|
173
|
+
updateState(s => { delete s.sessions[rec.key]; return s; });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (typeof mux.closePane === 'function') {
|
|
177
|
+
await mux.closePane(rec.pane);
|
|
178
|
+
closed += 1;
|
|
179
|
+
log(`${rec.key}: auto-reaped idle resumed pane ${rec.pane}`);
|
|
180
|
+
}
|
|
181
|
+
updateState(s => { delete s.sessions[rec.key]; return s; });
|
|
182
|
+
} catch (err) {
|
|
183
|
+
log(`${rec.key}: auto-reap failed: ${err.message}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return closed;
|
|
187
|
+
}
|