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.
- package/CHANGELOG.md +143 -0
- package/README.md +49 -0
- package/bin/unsnooze.js +151 -16
- package/package.json +1 -1
- package/src/cli.js +71 -0
- package/src/doctor.js +310 -0
- package/src/install.js +74 -5
- package/src/launcher.js +5 -0
- package/src/lease.js +22 -0
- package/src/logger.js +40 -1
- package/src/monitor.js +19 -1
- package/src/multiplexers/tmux.js +61 -2
- package/src/multiplexers/zellij.js +20 -3
- package/src/notify-ntfy.js +76 -0
- package/src/notify.js +9 -0
- package/src/reap.js +43 -2
- package/src/resumer.js +289 -72
- package/src/settings.js +13 -0
- package/src/state.js +39 -9
- package/src/time-parser.js +31 -15
- package/src/update-check.js +34 -0
package/src/multiplexers/tmux.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
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
|
-
|
|
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;
|