unsnooze 1.10.0 → 1.12.2

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.
@@ -30,6 +30,25 @@ function wrappedSessionName(env) {
30
30
  // calls with a pause between them, or Ink treats Enter as bracketed paste.
31
31
  export const SUBMIT_DELAY_MS = 150;
32
32
 
33
+ // tmux stderr signatures that mean the wrapped session never started (vs the
34
+ // session running and ending). Only these may trigger the unwatched fallback —
35
+ // anything broader risks double-running an agent that already ran. All of
36
+ // these are printed by the tmux client BEFORE the inner command executes
37
+ // (name collision, tty/socket problems, nesting refusal, fatal startup).
38
+ const SESSION_START_ERROR_RE = new RegExp([
39
+ 'duplicate session',
40
+ 'open terminal failed',
41
+ 'error connecting',
42
+ 'server exited unexpectedly',
43
+ 'no server running',
44
+ 'create session failed',
45
+ 'sessions should be nested',
46
+ 'not a terminal',
47
+ 'permission denied',
48
+ "couldn'?t create",
49
+ '^fatal:',
50
+ ].join('|'), 'im');
51
+
33
52
  export { SessionCreateError };
34
53
 
35
54
  export function createTmux({ spawner = defaultSpawner, env = process.env } = {}) {
@@ -153,6 +172,30 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
153
172
  }
154
173
  },
155
174
 
175
+ // Ownership stamp: a pane user option written at creation. It survives
176
+ // the agent process exiting but dies with the pane, so a recycled pane id
177
+ // never carries a stale stamp. Requires tmux >= 3.0 (-p pane options) —
178
+ // older tmux fails quietly and callers fall back to lease checks.
179
+ async stampPaneOwner(pane, leaseId) {
180
+ try {
181
+ await run('set-option', '-p', '-t', pane, '@unsnooze_owner', String(leaseId));
182
+ return true;
183
+ } catch {
184
+ return false;
185
+ }
186
+ },
187
+
188
+ // Read the stamp back. Blank (unset option / missing pane) and errors are
189
+ // both null — trust stdout, not the exit code (tmux 3.7b blank-success).
190
+ async paneOwnerStamp(pane) {
191
+ try {
192
+ const out = (await run('display-message', '-t', pane, '-p', '#{@unsnooze_owner}')).trim();
193
+ return out || null;
194
+ } catch {
195
+ return null;
196
+ }
197
+ },
198
+
156
199
  async sessionExists(name) {
157
200
  try {
158
201
  await run('has-session', '-t', name);
@@ -224,14 +267,30 @@ export function createTmux({ spawner = defaultSpawner, env = process.env } = {})
224
267
  // spawned from the agent via {...process.env}).
225
268
  const args = ['new-session', '-s', name,
226
269
  ...envArgs(launchSpec.env), launchSpec.file, ...(launchSpec.args || [])];
227
- const result = spawner('tmux', args, { sync: true, stdio: 'inherit', env });
270
+ // stderr is piped, not inherited: the tmux client draws its UI on the
271
+ // stdin/stdout tty; stderr carries only tmux's own error messages, and
272
+ // capturing them is how a session-start failure is told apart from the
273
+ // session simply ending (tmux exits non-zero for both us and the shell).
274
+ const result = spawner('tmux', args,
275
+ { sync: true, stdio: ['inherit', 'inherit', 'pipe'], env });
228
276
  if (result?.error) {
229
277
  throw new SessionCreateError(
230
278
  `failed to start tmux session "${name}": ${result.error.message}`,
231
279
  result.error,
232
280
  );
233
281
  }
234
- return exitStatus(result);
282
+ const status = exitStatus(result);
283
+ const stderr = result?.stderr == null ? '' : String(result.stderr);
284
+ if (status !== 0 && SESSION_START_ERROR_RE.test(stderr)) {
285
+ // The session never ran the agent — safe (and required) to fall back
286
+ // to an unwatched launch without any double-run risk.
287
+ throw new SessionCreateError(
288
+ `tmux could not start session "${name}": ${stderr.trim()}`);
289
+ }
290
+ // Any other tmux chatter stays visible — it was headed for the user's
291
+ // terminal before we piped it.
292
+ if (stderr.trim()) process.stderr.write(stderr);
293
+ return status;
235
294
  },
236
295
 
237
296
  // tmux pane ids are server-global, so owner binding is intentionally inert.
@@ -75,6 +75,11 @@ export function createZellij({ spawner = defaultSpawner, env = process.env } = {
75
75
  const childEnv = () => scrubZellijEnv(env);
76
76
  const run = (args, options = {}) => spawner('zellij', args, { env: childEnv(), ...options });
77
77
 
78
+ // dump-screen --full (scrollback) exists on zellij >= 0.35; older builds
79
+ // reject the flag. Shared across binds: learn once, never fail every tick.
80
+ // null = untested, true = supported, false = degrade to viewport-only.
81
+ let fullDumpSupported = null;
82
+
78
83
  // Sync counterpart of sessionExists, for the sync launchWrapped path. Rows can
79
84
  // carry ANSI colour and an "(EXITED …)" suffix, so only the leading name token
80
85
  // is compared. Probe failure → empty set: assume free and let zellij arbitrate.
@@ -121,10 +126,22 @@ export function createZellij({ spawner = defaultSpawner, env = process.env } = {
121
126
  return env.ZELLIJ_PANE_ID || null;
122
127
  },
123
128
 
124
- // v1 limitation: dump-screen is viewport-only, so `lines` is ignored;
125
- // unlike tmux, a banner/menu that scrolls away between polls is missed.
126
- // `dump-screen --full` may provide a future scrollback-capable option.
129
+ // --full includes scrollback, so a banner/menu that scrolled away
130
+ // between polls is still seen; `lines` is still ignored — zellij offers
131
+ // no line-count bound on the dump. Pre-0.35 zellij rejects the flag:
132
+ // fall back to the viewport-only form (1.10.x behavior) rather than
133
+ // failing every capture, and remember the answer.
127
134
  async capturePane(pane, _lines = 200) {
135
+ if (fullDumpSupported !== false) {
136
+ try {
137
+ const out = await owned('action', 'dump-screen', '--full', '--pane-id', String(pane));
138
+ fullDumpSupported = true;
139
+ return out;
140
+ } catch (err) {
141
+ if (fullDumpSupported === true) throw err; // flag is fine — real capture error
142
+ fullDumpSupported = false;
143
+ }
144
+ }
128
145
  return owned('action', 'dump-screen', '--pane-id', String(pane));
129
146
  },
130
147
 
@@ -0,0 +1,76 @@
1
+ // ntfy push channel (https://docs.ntfy.sh) — fire-and-forget phone/desktop
2
+ // push that rides ALONGSIDE the local notifyChannel; the whole point is
3
+ // reaching a user who is away from the machine.
4
+ //
5
+ // Dispatch is the JSON-to-root form (POST {server}/ with a JSON body) rather
6
+ // than POST-to-/topic with header metadata: every unsnooze title carries an
7
+ // emoji, and HTTP headers mangle non-ASCII unless RFC 2047-encoded — JSON
8
+ // string values are natively UTF-8. One code path, no escaping.
9
+ //
10
+ // SECURITY: topics on ntfy.sh are a public namespace — the topic name is
11
+ // effectively the password (read AND write for anyone who knows it). Use
12
+ // generateNtfyTopic() for an unguessable name, or a self-hosted/authed
13
+ // server; ntfyPrivacy=terse keeps cwd paths out of the pushed body entirely.
14
+ //
15
+ // Same contract as every other notifier: never throws, never blocks the
16
+ // detection/resume paths, all deps injectable, no network in tests.
17
+
18
+ import { randomBytes } from 'node:crypto';
19
+ import { getConfig } from './settings.js';
20
+
21
+ const BODY_MAX = 1024; // far under ntfy's ~4K push ceiling
22
+ const TIMEOUT_MS = 5000;
23
+
24
+ const TOPIC_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
25
+
26
+ /** Unguessable, ntfy-legal ([-_A-Za-z0-9], <=64 chars) topic name. */
27
+ export function generateNtfyTopic() {
28
+ const bytes = randomBytes(16);
29
+ let suffix = '';
30
+ for (const b of bytes) suffix += TOPIC_ALPHABET[b % TOPIC_ALPHABET.length];
31
+ return `unsnooze-${suffix}`;
32
+ }
33
+
34
+ /**
35
+ * Push one notification. Resolution: config/env (ntfyTopic/ntfyServer/
36
+ * ntfyToken/ntfyPrivacy). No topic → quiet no-op. Returns true only on an
37
+ * accepted (2xx) publish; every failure path returns false without throwing.
38
+ */
39
+ export async function sendNtfy(title, message, {
40
+ fetcher = globalThis.fetch,
41
+ priority = 3,
42
+ timeoutMs = TIMEOUT_MS,
43
+ } = {}) {
44
+ try {
45
+ const topic = getConfig('ntfyTopic');
46
+ if (!topic) return false;
47
+ const server = String(getConfig('ntfyServer') || 'https://ntfy.sh').replace(/\/+$/, '');
48
+ const token = getConfig('ntfyToken');
49
+ const terse = getConfig('ntfyPrivacy') === 'terse';
50
+
51
+ // terse: the body may embed cwd paths — never send those to a topic that
52
+ // is only as private as its name. The title alone still tells the story.
53
+ const body = terse
54
+ ? 'details withheld (ntfyPrivacy=terse) — run: unsnooze status'
55
+ : String(message ?? '').slice(0, BODY_MAX);
56
+
57
+ const headers = { 'Content-Type': 'application/json' };
58
+ if (token) headers.Authorization = `Bearer ${token}`;
59
+
60
+ const ctrl = new AbortController();
61
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
62
+ try {
63
+ const res = await fetcher(`${server}/`, {
64
+ method: 'POST',
65
+ headers,
66
+ body: JSON.stringify({ topic, title: String(title ?? ''), message: body, priority }),
67
+ signal: ctrl.signal,
68
+ });
69
+ return !!res?.ok;
70
+ } finally {
71
+ clearTimeout(timer);
72
+ }
73
+ } catch {
74
+ return false; // offline, DNS, 4xx/5xx, abort — never the caller's problem
75
+ }
76
+ }
package/src/notify.js CHANGED
@@ -15,6 +15,7 @@ import { release as osRelease } from 'node:os';
15
15
  import { getConfig } from './settings.js';
16
16
  import { getMultiplexer } from './multiplexer.js';
17
17
  import { sendOsc as defaultSendOsc, sendBell as defaultSendBell } from './notify-tty.js';
18
+ import { sendNtfy } from './notify-ntfy.js';
18
19
 
19
20
  function defaultSpawner(cmd, args) {
20
21
  const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
@@ -113,10 +114,18 @@ export function notify(title, message, {
113
114
  channel = null,
114
115
  tty = { sendOsc: defaultSendOsc, sendBell: defaultSendBell },
115
116
  getMux = getMultiplexer,
117
+ ntfyFetcher = undefined,
118
+ priority = 3,
116
119
  } = {}) {
117
120
  try {
118
121
  if (!getConfig('notifications')) return;
119
122
 
123
+ // ntfy push is ADDITIVE: a phone buzz complements the local toast — the
124
+ // scenarios unsnooze exists for are exactly the ones where the user is
125
+ // not looking at this machine. No-op until ntfyTopic is configured;
126
+ // fire-and-forget so an offline push can never slow a resume.
127
+ sendNtfy(title, message, { fetcher: ntfyFetcher, priority }).catch(() => {});
128
+
120
129
  const nativeOpts = { platform, wsl, spawner };
121
130
  const fireNative = () => nativeNotify(title, message, nativeOpts);
122
131
 
package/src/reap.js CHANGED
@@ -5,6 +5,7 @@ import { MUX_SESSION_NAME, RESUME_SESSION_NAME } from './config.js';
5
5
  import { getMultiplexer } from './multiplexer.js';
6
6
  import { readState, setStatus, updateState } from './state.js';
7
7
  import { getConfig } from './settings.js';
8
+ import { paneOwnedByRecord } from './lease.js';
8
9
  import { makeLogger } from './logger.js';
9
10
 
10
11
  const log = makeLogger('reap');
@@ -84,10 +85,24 @@ export async function reap({
84
85
  const terminal = Object.values(state.sessions).filter(s =>
85
86
  ['resumed', 'failed', 'cancelled'].includes(s.status) && s.pane);
86
87
 
88
+ const reapIdleAfter = getConfig('reapIdleAfter');
89
+ const now = Date.now();
87
90
  for (const rec of terminal) {
91
+ // 'resumed' is not really terminal — the agent may be mid-task in that
92
+ // pane right now. Honor the module contract ("never kill a live agent")
93
+ // by requiring the same idle threshold as auto-reap before closing.
94
+ if (rec.status === 'resumed') {
95
+ const ts = rec.lastAttemptAt || rec.detectedAt || 0;
96
+ if (ts > now - reapIdleAfter) {
97
+ actions.push({ kind: 'skip-active', key: rec.key, pane: rec.pane,
98
+ reason: `resumed and active within reapIdleAfter — not closing` });
99
+ continue;
100
+ }
101
+ }
88
102
  let alive = false;
103
+ let mux = null;
89
104
  try {
90
- const mux = resolveMux(rec);
105
+ mux = resolveMux(rec);
91
106
  alive = await mux.paneAlive(rec.pane);
92
107
  } catch { alive = false; }
93
108
  if (!alive) {
@@ -97,6 +112,24 @@ export async function reap({
97
112
  }
98
113
  continue;
99
114
  }
115
+ // Pane ids get recycled — closing by bare id could kill someone else's
116
+ // pane. Positive ownership (stamp or lease) is required to close; a
117
+ // demonstrably foreign pane means the record is stale, so drop it.
118
+ let owned = null;
119
+ try { owned = await paneOwnedByRecord(rec, { mux }); } catch { owned = null; }
120
+ if (owned === false) {
121
+ actions.push({ kind: 'drop-record', key: rec.key, reason: 'pane recycled — not ours' });
122
+ if (!dryRun) {
123
+ updateState(s => { delete s.sessions[rec.key]; return s; });
124
+ }
125
+ continue;
126
+ }
127
+ if (owned === null) {
128
+ // Legacy record with no lease: no proof either way — never close.
129
+ actions.push({ kind: 'skip-unowned', key: rec.key, pane: rec.pane,
130
+ reason: 'ownership unprovable (pre-lease record) — close it manually' });
131
+ continue;
132
+ }
100
133
  actions.push({
101
134
  kind: 'close-pane',
102
135
  key: rec.key,
@@ -107,7 +140,6 @@ export async function reap({
107
140
  });
108
141
  if (!dryRun) {
109
142
  try {
110
- const mux = resolveMux(rec);
111
143
  if (typeof mux.closePane === 'function') await mux.closePane(rec.pane);
112
144
  setStatus(rec.key, rec.status, { pane: null });
113
145
  updateState(s => { delete s.sessions[rec.key]; return s; });
@@ -173,6 +205,15 @@ export async function autoReapIfEnabled({
173
205
  updateState(s => { delete s.sessions[rec.key]; return s; });
174
206
  continue;
175
207
  }
208
+ // Same recycled-pane rule as reap(): positive ownership or no close.
209
+ const owned = await paneOwnedByRecord(rec, { mux });
210
+ if (owned !== true) {
211
+ if (owned === false) {
212
+ updateState(s => { delete s.sessions[rec.key]; return s; });
213
+ log(`${rec.key}: auto-reap skipped — pane ${rec.pane} recycled, record dropped`);
214
+ }
215
+ continue;
216
+ }
176
217
  if (typeof mux.closePane === 'function') {
177
218
  await mux.closePane(rec.pane);
178
219
  closed += 1;