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/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
- FALLBACK_RESET_MS, RESET_MARGIN_MS, MUX_SESSION_NAME,
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
- async function recordLimit(resetLine, limitType, via) {
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 { at, source } = resetAtMs(parseResetTime(resetLine), {
62
- marginMs: RESET_MARGIN_MS, fallbackMs: FALLBACK_RESET_MS,
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
- const sessionId = agent.latestSessionId(cwd, detectedAt);
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: MUX_SESSION_NAME,
68
- status: 'stopped', limitType, detectedVia: via, detectedAt,
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 ${via}), resets ${new Date(at).toISOString()}`);
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
- await recordLimit(d.resetLine, d.limitType, marker ? 'hook' : 'scrape');
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
+ }
@@ -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