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.
@@ -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
+ }
@@ -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
- return { pane: pane.trim(), paneOwner: sessionName };
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 args = ['new-session', '-s', wrappedSessionName(env),
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
- const stdout = await owned('action', 'list-panes', '-a', '-j');
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.split(/\r?\n/).some(line => line.trim() === name);
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, '--', ...launchArgv(launchSpec),
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', wrappedSessionName(env), '--layout-string', wrappedLayout(launchSpec),
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
+ }