unsnooze 1.7.0 → 1.8.1
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 +35 -0
- package/README.md +57 -14
- package/bin/unsnooze.js +7 -6
- package/package.json +11 -3
- package/src/agents/codex.js +22 -2
- package/src/cli.js +2 -1
- package/src/config.js +3 -2
- package/src/hook.js +20 -7
- package/src/install.js +1 -1
- package/src/launcher.js +40 -21
- package/src/lease.js +87 -0
- package/src/monitor.js +33 -20
- package/src/multiplexer.js +58 -0
- package/src/multiplexers/tmux.js +200 -0
- package/src/multiplexers/zellij.js +193 -0
- package/src/notify-tty.js +311 -0
- package/src/notify.js +131 -14
- package/src/report.js +11 -4
- package/src/resumer.js +172 -73
- package/src/settings.js +6 -0
- package/src/state.js +29 -6
- package/src/watcher.js +4 -2
- package/src/wizard.js +6 -5
- package/src/tmux.js +0 -95
package/src/resumer.js
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
import { writeFileSync, readFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
|
-
import
|
|
9
|
+
import { getMultiplexer } from './multiplexer.js';
|
|
10
10
|
import {
|
|
11
11
|
RESUMER_LOCK, STATE_DIR, POLL_INTERVAL_MS, STAGGER_MS, VERIFY_DELAY_MS,
|
|
12
12
|
BUSY_DEFER_MS, MAX_BUSY_DEFERS, MAX_RESUME_ATTEMPTS, READY_TIMEOUT_MS,
|
|
13
|
-
CAPTURE_LINES, PANE_SCAN_LINES,
|
|
13
|
+
CAPTURE_LINES, PANE_SCAN_LINES, MUX_SESSION_NAME,
|
|
14
14
|
RESET_MARGIN_MS, FALLBACK_RESET_MS,
|
|
15
15
|
} from './config.js';
|
|
16
16
|
import { detectLimit, isBusy } from './patterns.js';
|
|
@@ -22,9 +22,12 @@ import { workspaceFingerprint, workspaceChanged, describeChange } from './worksp
|
|
|
22
22
|
import { notify } from './notify.js';
|
|
23
23
|
import { UNSNOOZE_BIN } from './spawn.js';
|
|
24
24
|
import { makeLogger } from './logger.js';
|
|
25
|
+
import { createLeaseId, leaseMatches } from './lease.js';
|
|
25
26
|
|
|
26
27
|
const log = makeLogger('resumer');
|
|
27
28
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
29
|
+
const MAX_VERIFY_RETRIES = 3;
|
|
30
|
+
const ctxOf = rec => ({ mux: rec.mux, pane: rec.pane, paneOwner: rec.paneOwner });
|
|
28
31
|
|
|
29
32
|
function pidAlive(pid) {
|
|
30
33
|
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
@@ -56,22 +59,40 @@ export function dueForDispatch(now = Date.now()) {
|
|
|
56
59
|
return dueSessions(now).filter(s => (auto || s.manual) && (!s.workspaceHold || s.manual));
|
|
57
60
|
}
|
|
58
61
|
|
|
59
|
-
function shellQuote(arg) {
|
|
60
|
-
return /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// The reopen command runs inside a fresh tmux window, i.e. with the tmux
|
|
64
|
-
// SERVER's environment — which may lack npm globals or nvm's node on PATH.
|
|
65
|
-
// Always embed absolute paths. UNSNOOZE_SELF is the test-harness override.
|
|
66
62
|
function selfCommand() {
|
|
67
63
|
return process.env.UNSNOOZE_SELF
|
|
68
64
|
? [process.env.UNSNOOZE_SELF]
|
|
69
65
|
: [process.execPath, UNSNOOZE_BIN];
|
|
70
66
|
}
|
|
71
67
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
68
|
+
export function resolveRecordMux(rec) {
|
|
69
|
+
return getMultiplexer(rec.mux, { owner: rec.paneOwner });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function reopenEnv(rec, leaseId) {
|
|
73
|
+
const env = { ...(rec.env || {}) };
|
|
74
|
+
env.UNSNOOZE_MUX = rec.mux;
|
|
75
|
+
if (rec.muxSession) env.UNSNOOZE_PANE_OWNER = rec.muxSession;
|
|
76
|
+
env.UNSNOOZE_LEASE_ID = leaseId;
|
|
77
|
+
return env;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function driveMenu(mux, pane, agent, text) {
|
|
81
|
+
const steps = agent.menu.stepsToWait(text, PANE_SCAN_LINES);
|
|
82
|
+
if (steps === null) return false;
|
|
83
|
+
const key = steps > 0 ? 'Down' : 'Up';
|
|
84
|
+
for (let i = 0; i < Math.abs(steps); i++) await mux.sendKey(pane, key);
|
|
85
|
+
await mux.sendKey(pane, 'Enter');
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Ordered safety decision. Returns: busy | retry | progress | held | injected | reopen.
|
|
90
|
+
export async function dispatchOne(rec, {
|
|
91
|
+
mux = resolveRecordMux(rec), resolveMux = null,
|
|
92
|
+
resumeMessage, selfCmd = selfCommand(), fingerprint = workspaceFingerprint,
|
|
93
|
+
notifier = notify, matchesLease = leaseMatches,
|
|
94
|
+
} = {}) {
|
|
95
|
+
resolveMux ||= () => mux;
|
|
75
96
|
const key = rec.key;
|
|
76
97
|
const agent = getAgent(rec.agent);
|
|
77
98
|
// Wake-message precedence: per-session (`unsnooze message <id> "..."`) →
|
|
@@ -88,7 +109,7 @@ export async function dispatchOne(rec, { tmux = realTmux, resumeMessage, selfCmd
|
|
|
88
109
|
const desc = describeChange(change);
|
|
89
110
|
if (guardMode === 'pause') {
|
|
90
111
|
setStatus(key, 'stopped', { workspaceHold: true, holdReason: desc });
|
|
91
|
-
notifier('unsnooze: session held', `${rec.cwd}: workspace changed while stopped (${desc}) — run: unsnooze resume-now
|
|
112
|
+
notifier('unsnooze: session held', `${rec.cwd}: workspace changed while stopped (${desc}) — run: unsnooze resume-now`, { context: ctxOf(rec) });
|
|
92
113
|
log(`${key}: workspace changed (${desc}) — held (workspaceGuard=pause)`);
|
|
93
114
|
return 'held';
|
|
94
115
|
}
|
|
@@ -100,76 +121,134 @@ export async function dispatchOne(rec, { tmux = realTmux, resumeMessage, selfCmd
|
|
|
100
121
|
// Live-pane path: only if the pane still exists AND the agent CLI is its
|
|
101
122
|
// foreground command (pane ids get recycled — never inject into a random
|
|
102
123
|
// program).
|
|
103
|
-
if (rec.pane && await
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
124
|
+
if (rec.pane && await mux.paneAlive(rec.pane)) {
|
|
125
|
+
let text;
|
|
126
|
+
try { text = await mux.capturePane(rec.pane, CAPTURE_LINES); }
|
|
127
|
+
catch (err) {
|
|
128
|
+
setStatus(key, 'stopped', { lastError: `capture: ${err.message}` });
|
|
129
|
+
return 'retry';
|
|
130
|
+
}
|
|
131
|
+
if (isBusy(text, agent.patterns.busyPatterns)) return 'busy';
|
|
132
|
+
const d = detectLimit(text, PANE_SCAN_LINES, agent.patterns);
|
|
133
|
+
const menu = !!(agent.menu && agent.menu.isPrompt(text, PANE_SCAN_LINES));
|
|
134
|
+
let cmd = null;
|
|
135
|
+
try { cmd = await mux.paneCurrentCommand(rec.pane); }
|
|
136
|
+
catch (err) { log(`${key}: pane command lookup failed: ${err.message}`); }
|
|
137
|
+
const contentOwned = menu || d.hit || agent.patterns.idleRegex.test(text);
|
|
138
|
+
const leased = await matchesLease(rec, { mux });
|
|
139
|
+
const owned = leased || agent.isForegroundCommand(cmd);
|
|
140
|
+
const authorized = owned && contentOwned;
|
|
141
|
+
|
|
142
|
+
if (menu) {
|
|
143
|
+
if (!authorized) return reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd });
|
|
144
|
+
if (!getConfig('menuAutoAnswer')) return 'held';
|
|
145
|
+
try {
|
|
146
|
+
if (await driveMenu(mux, rec.pane, agent, text)) return 'progress';
|
|
147
|
+
setStatus(key, 'stopped', { lastError: 'menu drive: wait option unavailable' });
|
|
148
|
+
} catch (err) {
|
|
149
|
+
setStatus(key, 'stopped', { lastError: `menu drive: ${err.message}` });
|
|
150
|
+
}
|
|
151
|
+
return 'retry';
|
|
152
|
+
}
|
|
153
|
+
if (authorized) {
|
|
154
|
+
setStatus(key, 'resuming', { lastAttemptAt: Date.now(), lastError: null, verifyRetries: 0 });
|
|
155
|
+
await mux.sendText(rec.pane, resumeMessage);
|
|
156
|
+
log(`${key}: sent continue via ${rec.mux} ${rec.paneOwner ?? '-'}:${rec.pane}`);
|
|
157
|
+
return 'injected';
|
|
112
158
|
}
|
|
113
|
-
|
|
114
|
-
// re-open; never hijack a shell prompt with a chat message.
|
|
159
|
+
if (owned) return 'busy';
|
|
115
160
|
}
|
|
116
161
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
162
|
+
return reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd }) {
|
|
166
|
+
const key = rec.key;
|
|
120
167
|
const resume = agent.resumeArgs(rec.sessionId, resumeMessage);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
let newPane;
|
|
168
|
+
const leaseId = createLeaseId();
|
|
169
|
+
const launchSpec = {
|
|
170
|
+
file: selfCmd[0], args: [...selfCmd.slice(1), '_run', agent.id, ...resume.args],
|
|
171
|
+
env: reopenEnv(rec, leaseId),
|
|
172
|
+
};
|
|
173
|
+
setStatus(key, 'resuming', { lastAttemptAt: Date.now(), verifyRetries: 0 });
|
|
174
|
+
let address;
|
|
129
175
|
try {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
newPane = await tmux.newWindow(rec.tmuxSession || TMUX_SESSION_NAME, rec.cwd || homedir(), command);
|
|
176
|
+
address = await mux.newWindow(rec.muxSession ?? rec.tmuxSession ?? MUX_SESSION_NAME,
|
|
177
|
+
rec.cwd || homedir(), launchSpec);
|
|
133
178
|
} catch (err) {
|
|
134
|
-
setStatus(key, 'stopped', {
|
|
135
|
-
|
|
179
|
+
setStatus(key, 'stopped', {
|
|
180
|
+
attempts: (rec.attempts || 0) + 1,
|
|
181
|
+
lastError: `new-window: ${err.message}`,
|
|
182
|
+
verifyRetries: 0,
|
|
183
|
+
});
|
|
184
|
+
return 'retry';
|
|
136
185
|
}
|
|
137
186
|
updateState(state => {
|
|
138
187
|
const s = state.sessions[key];
|
|
139
|
-
if (s) s
|
|
188
|
+
if (s) Object.assign(s, address, { leaseId });
|
|
140
189
|
});
|
|
141
|
-
|
|
190
|
+
const rebound = { ...rec, ...address, leaseId };
|
|
191
|
+
mux = resolveMux(rebound);
|
|
192
|
+
log(`${key}: re-opened via ${rec.mux} ${address.paneOwner ?? '-'}:${address.pane}`);
|
|
142
193
|
|
|
143
194
|
// The resume prompt traveled in argv (e.g. `codex resume <id> "msg"`) —
|
|
144
195
|
// nothing to type into the TUI; verifyOne checks the outcome later.
|
|
145
|
-
if (!resume.messageViaPane) return '
|
|
196
|
+
if (!resume.messageViaPane) return 'reopen';
|
|
146
197
|
|
|
147
198
|
// Wait for the TUI to be ready (input prompt visible), then send the message.
|
|
148
199
|
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
149
200
|
while (Date.now() < deadline) {
|
|
150
201
|
await sleep(2000);
|
|
151
|
-
|
|
202
|
+
let text;
|
|
203
|
+
try { text = await mux.capturePane(address.pane, CAPTURE_LINES); }
|
|
204
|
+
catch { continue; }
|
|
152
205
|
if ((agent.menu && agent.menu.isPrompt(text, PANE_SCAN_LINES)) || detectLimit(text, PANE_SCAN_LINES, agent.patterns).hit) {
|
|
153
206
|
// Limit hadn't actually reset — the fresh session hit it immediately.
|
|
154
|
-
return '
|
|
207
|
+
return 'reopen';
|
|
155
208
|
}
|
|
156
209
|
// The idle input box: a prompt glyph with no busy footer.
|
|
157
210
|
if (!isBusy(text, agent.patterns.busyPatterns) && agent.patterns.idleRegex.test(text)) {
|
|
158
|
-
await
|
|
159
|
-
log(`${key}: resume message sent to new pane ${
|
|
160
|
-
return '
|
|
211
|
+
await mux.sendText(address.pane, resumeMessage);
|
|
212
|
+
log(`${key}: resume message sent to new pane ${address.pane}`);
|
|
213
|
+
return 'reopen';
|
|
161
214
|
}
|
|
162
215
|
}
|
|
163
|
-
setStatus(key, 'stopped', {
|
|
164
|
-
|
|
216
|
+
setStatus(key, 'stopped', {
|
|
217
|
+
attempts: (rec.attempts || 0) + 1,
|
|
218
|
+
lastError: 'ready timeout',
|
|
219
|
+
verifyRetries: 0,
|
|
220
|
+
});
|
|
221
|
+
return 'retry';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function recordVerifyRetry(rec, lastError) {
|
|
225
|
+
const verifyRetries = (rec.verifyRetries || 0) + 1;
|
|
226
|
+
if (verifyRetries >= MAX_VERIFY_RETRIES) {
|
|
227
|
+
setStatus(rec.key, 'stopped', {
|
|
228
|
+
attempts: (rec.attempts || 0) + 1,
|
|
229
|
+
lastError,
|
|
230
|
+
verifyRetries: 0,
|
|
231
|
+
});
|
|
232
|
+
} else {
|
|
233
|
+
setStatus(rec.key, 'resuming', { lastError, verifyRetries });
|
|
234
|
+
}
|
|
235
|
+
return 'retry';
|
|
165
236
|
}
|
|
166
237
|
|
|
167
238
|
// Post-dispatch verification: did the limit banner come back?
|
|
168
|
-
export async function verifyOne(key, {
|
|
239
|
+
export async function verifyOne(key, { resolveMux = resolveRecordMux } = {}) {
|
|
169
240
|
const rec = readState().sessions[key];
|
|
170
241
|
if (!rec || rec.status !== 'resuming') return;
|
|
171
242
|
const agent = getAgent(rec.agent);
|
|
172
|
-
|
|
243
|
+
if (!rec.pane) {
|
|
244
|
+
return recordVerifyRetry(rec, 'verify: pane unavailable');
|
|
245
|
+
}
|
|
246
|
+
const mux = resolveMux(rec);
|
|
247
|
+
let text;
|
|
248
|
+
try { text = await mux.capturePane(rec.pane, CAPTURE_LINES); }
|
|
249
|
+
catch (err) {
|
|
250
|
+
return recordVerifyRetry(rec, `verify capture: ${err.message}`);
|
|
251
|
+
}
|
|
173
252
|
const d = detectLimit(text, PANE_SCAN_LINES, agent.patterns);
|
|
174
253
|
if (d.hit || (agent.menu && agent.menu.isPrompt(text, PANE_SCAN_LINES))) {
|
|
175
254
|
// Limit not actually reset — reschedule from the fresh banner.
|
|
@@ -178,22 +257,49 @@ export async function verifyOne(key, { tmux = realTmux } = {}) {
|
|
|
178
257
|
});
|
|
179
258
|
setStatus(key, 'stopped', {
|
|
180
259
|
attempts: (rec.attempts || 0) + 1, resetAt: at, resetSource: source,
|
|
181
|
-
lastError: 'limit still active at resume time',
|
|
260
|
+
lastError: 'limit still active at resume time', verifyRetries: 0,
|
|
182
261
|
});
|
|
183
262
|
log(`${key}: limit still active, rescheduled to ${new Date(at).toISOString()}`);
|
|
184
263
|
return;
|
|
185
264
|
}
|
|
186
|
-
setStatus(key, 'resumed');
|
|
265
|
+
setStatus(key, 'resumed', { lastError: null, verifyRetries: 0 });
|
|
187
266
|
log(`${key}: verified resumed`);
|
|
188
267
|
const fromGui = rec.origin && rec.origin !== 'cli';
|
|
189
|
-
notify('unsnoozed ✅', `${rec.cwd} is running again${fromGui ? ` (was in ${rec.origin} — revived in
|
|
268
|
+
notify('unsnoozed ✅', `${rec.cwd} is running again${fromGui ? ` (was in ${rec.origin} — revived in ${rec.mux})` : ''}`, { context: ctxOf(rec) });
|
|
269
|
+
return 'resumed';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function routeDispatchOutcome(result, rec, deferCounts, { maxBusyDefers = MAX_BUSY_DEFERS } = {}) {
|
|
273
|
+
if (result === 'busy') {
|
|
274
|
+
const n = (deferCounts.get(rec.key) || 0) + 1;
|
|
275
|
+
deferCounts.set(rec.key, n);
|
|
276
|
+
if (n > maxBusyDefers) {
|
|
277
|
+
setStatus(rec.key, 'resumed', { lastError: null, verifyRetries: 0 });
|
|
278
|
+
return { verify: false, waitBusy: false };
|
|
279
|
+
}
|
|
280
|
+
return { verify: false, waitBusy: true };
|
|
281
|
+
}
|
|
282
|
+
if (result === 'retry') {
|
|
283
|
+
setStatus(rec.key, 'stopped', {
|
|
284
|
+
attempts: (rec.attempts || 0) + 1,
|
|
285
|
+
lastError: readState().sessions[rec.key]?.lastError,
|
|
286
|
+
verifyRetries: 0,
|
|
287
|
+
});
|
|
288
|
+
return { verify: false, waitBusy: false };
|
|
289
|
+
}
|
|
290
|
+
if (result === 'progress') {
|
|
291
|
+
setStatus(rec.key, 'stopped', { lastError: null, verifyRetries: 0 });
|
|
292
|
+
return { verify: false, waitBusy: false };
|
|
293
|
+
}
|
|
294
|
+
if (result === 'held') return { verify: false, waitBusy: false };
|
|
295
|
+
return { verify: result === 'injected' || result === 'reopen', waitBusy: false };
|
|
190
296
|
}
|
|
191
297
|
|
|
192
298
|
// persistent: never exit on an empty ledger (daemon mode — `unsnooze daemon`,
|
|
193
299
|
// launchd/systemd). watcher: transcript watcher ticked every loop, so GUI
|
|
194
300
|
// sessions are detected without a hook or pane. signal: clean shutdown.
|
|
195
301
|
export async function runResumer({
|
|
196
|
-
|
|
302
|
+
resolveMux = resolveRecordMux, pollInterval = POLL_INTERVAL_MS,
|
|
197
303
|
persistent = false, watcher = null, signal = null,
|
|
198
304
|
} = {}) {
|
|
199
305
|
// A transient hook-spawned resumer may hold the lock right now; a daemon
|
|
@@ -230,33 +336,26 @@ export async function runResumer({
|
|
|
230
336
|
// Anything over the attempts cap is dead — mark failed so we can exit.
|
|
231
337
|
for (const s of dueForDispatch()) {
|
|
232
338
|
if ((s.attempts || 0) >= MAX_RESUME_ATTEMPTS) {
|
|
233
|
-
setStatus(s.key, 'failed', { lastError: 'max resume attempts exceeded' });
|
|
339
|
+
setStatus(s.key, 'failed', { lastError: 'max resume attempts exceeded', verifyRetries: 0 });
|
|
234
340
|
log(`${s.key}: giving up after ${s.attempts} attempts`);
|
|
235
|
-
notify('unsnooze gave up ⚠️', `${s.cwd}: ${s.attempts} resume attempts failed — check \`unsnooze status
|
|
341
|
+
notify('unsnooze gave up ⚠️', `${s.cwd}: ${s.attempts} resume attempts failed — check \`unsnooze status\``, { context: ctxOf(s) });
|
|
236
342
|
}
|
|
237
343
|
}
|
|
238
344
|
|
|
239
345
|
const dispatched = [];
|
|
240
346
|
for (const rec of due) {
|
|
241
|
-
const result = await dispatchOne(rec, {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
setStatus(rec.key, 'resumed', { lastError: null });
|
|
247
|
-
log(`${rec.key}: busy through ${n} defers — it is clearly working, marking resumed`);
|
|
248
|
-
} else {
|
|
249
|
-
await sleep(BUSY_DEFER_MS);
|
|
250
|
-
}
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
if (result === 'sent' || result === 'reopened') dispatched.push(rec.key);
|
|
347
|
+
const result = await dispatchOne(rec, { mux: resolveMux(rec), resolveMux });
|
|
348
|
+
const routed = routeDispatchOutcome(result, rec, deferCounts);
|
|
349
|
+
if (routed.waitBusy) await sleep(BUSY_DEFER_MS);
|
|
350
|
+
if (routed.verify) dispatched.push(rec.key);
|
|
351
|
+
if (!routed.verify) continue;
|
|
254
352
|
if (due.indexOf(rec) < due.length - 1) await sleep(STAGGER_MS);
|
|
255
353
|
}
|
|
256
354
|
|
|
257
|
-
|
|
355
|
+
const verifying = [...new Set([...resuming.map(rec => rec.key), ...dispatched])];
|
|
356
|
+
if (verifying.length > 0) {
|
|
258
357
|
await sleep(VERIFY_DELAY_MS);
|
|
259
|
-
for (const key of
|
|
358
|
+
for (const key of verifying) await verifyOne(key, { resolveMux });
|
|
260
359
|
}
|
|
261
360
|
|
|
262
361
|
await sleep(pollInterval);
|
package/src/settings.js
CHANGED
|
@@ -13,9 +13,11 @@ import { STATE_DIR } from './config.js';
|
|
|
13
13
|
export const CONFIG_FILE = () => join(process.env.UNSNOOZE_STATE_DIR || STATE_DIR, 'config.json');
|
|
14
14
|
|
|
15
15
|
export const DEFAULTS = {
|
|
16
|
+
multiplexer: 'auto', // auto | tmux | zellij
|
|
16
17
|
autoResume: true, // master switch: dispatch resumes when limits reset
|
|
17
18
|
menuAutoAnswer: true, // may unsnooze drive Claude's limit menu (send keys)?
|
|
18
19
|
notifications: true, // desktop notifications on detect/resume
|
|
20
|
+
notifyChannel: 'auto', // auto | native | osc | bell
|
|
19
21
|
guiWatch: true, // daemon watches transcripts/rollouts for GUI-session stops
|
|
20
22
|
updateCheck: true, // daily registry version check + update notices/toast
|
|
21
23
|
workspaceGuard: 'inform', // repo changed while stopped: off | inform | pause
|
|
@@ -26,9 +28,11 @@ export const DEFAULTS = {
|
|
|
26
28
|
|
|
27
29
|
// Env override per key. Booleans accept 1/0, true/false, on/off, yes/no.
|
|
28
30
|
const ENV_NAMES = {
|
|
31
|
+
multiplexer: 'UNSNOOZE_MULTIPLEXER',
|
|
29
32
|
autoResume: 'UNSNOOZE_AUTO_RESUME',
|
|
30
33
|
menuAutoAnswer: 'UNSNOOZE_MENU_AUTO_ANSWER',
|
|
31
34
|
notifications: 'UNSNOOZE_NOTIFICATIONS',
|
|
35
|
+
notifyChannel: 'UNSNOOZE_NOTIFY_CHANNEL',
|
|
32
36
|
guiWatch: 'UNSNOOZE_GUI_WATCH',
|
|
33
37
|
updateCheck: 'UNSNOOZE_UPDATE_CHECK',
|
|
34
38
|
workspaceGuard: 'UNSNOOZE_WORKSPACE_GUARD',
|
|
@@ -53,7 +57,9 @@ const KNOWN_KEYS = Object.keys(ENV_NAMES);
|
|
|
53
57
|
|
|
54
58
|
// String settings restricted to a fixed set of values.
|
|
55
59
|
const ENUMS = {
|
|
60
|
+
multiplexer: ['auto', 'tmux', 'zellij'],
|
|
56
61
|
workspaceGuard: ['off', 'inform', 'pause'],
|
|
62
|
+
notifyChannel: ['auto', 'native', 'osc', 'bell'],
|
|
57
63
|
};
|
|
58
64
|
|
|
59
65
|
function parseBool(raw) {
|
package/src/state.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from './config.js';
|
|
16
16
|
import { workspaceFingerprint } from './workspace.js';
|
|
17
17
|
import { makeLogger } from './logger.js';
|
|
18
|
+
import { addressHash } from './lease.js';
|
|
18
19
|
|
|
19
20
|
const log = makeLogger('state');
|
|
20
21
|
|
|
@@ -53,7 +54,7 @@ function releaseLock() {
|
|
|
53
54
|
|
|
54
55
|
export function readState() {
|
|
55
56
|
try {
|
|
56
|
-
return JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
|
|
57
|
+
return normalizeState(JSON.parse(readFileSync(STATE_FILE, 'utf-8')));
|
|
57
58
|
} catch (err) {
|
|
58
59
|
if (err.code === 'ENOENT') return EMPTY();
|
|
59
60
|
// Corrupt file: quarantine loudly, start fresh — never crash the hook path.
|
|
@@ -66,6 +67,20 @@ export function readState() {
|
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
function normalizeState(state) {
|
|
71
|
+
if (!state || typeof state !== 'object') return EMPTY();
|
|
72
|
+
state.sessions ||= {};
|
|
73
|
+
for (const rec of Object.values(state.sessions)) normalizeRecord(rec);
|
|
74
|
+
return state;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeRecord(rec) {
|
|
78
|
+
if (!rec.mux) rec.mux = 'tmux';
|
|
79
|
+
if (!rec.muxSession && rec.tmuxSession) rec.muxSession = rec.tmuxSession;
|
|
80
|
+
if (rec.mux === 'tmux' && rec.paneOwner === undefined) rec.paneOwner = null;
|
|
81
|
+
return rec;
|
|
82
|
+
}
|
|
83
|
+
|
|
69
84
|
// Locked read-modify-write. mutator receives the state object and mutates it
|
|
70
85
|
// (or returns a replacement). Returns the final state.
|
|
71
86
|
export function updateState(mutator) {
|
|
@@ -87,11 +102,14 @@ export function updateState(mutator) {
|
|
|
87
102
|
// if a record for the same pane was created within DEDUPE_WINDOW_MS, merge into
|
|
88
103
|
// it (a record WITH a sessionId wins over one without).
|
|
89
104
|
export function upsertSession(record) {
|
|
105
|
+
record = normalizeRecord({ ...record });
|
|
90
106
|
return updateState(state => {
|
|
91
107
|
prune(state);
|
|
92
108
|
const existingKey = findDuplicate(state, record);
|
|
93
109
|
if (existingKey) {
|
|
94
110
|
const existing = state.sessions[existingKey];
|
|
111
|
+
const staleBanner = existing.status === 'resumed' && !existing.bannerCleared
|
|
112
|
+
&& record.status === 'stopped';
|
|
95
113
|
const merged = {
|
|
96
114
|
...existing,
|
|
97
115
|
...record,
|
|
@@ -99,7 +117,11 @@ export function upsertSession(record) {
|
|
|
99
117
|
sessionId: record.sessionId || existing.sessionId,
|
|
100
118
|
// A detection that races an in-flight resume must not flip the record
|
|
101
119
|
// back to 'stopped' — the post-resume verify pass owns that outcome.
|
|
102
|
-
status: existing.status === 'resuming'
|
|
120
|
+
status: existing.status === 'resuming'
|
|
121
|
+
|| staleBanner
|
|
122
|
+
? existing.status : record.status,
|
|
123
|
+
attempts: staleBanner ? existing.attempts : record.attempts,
|
|
124
|
+
lastAttemptAt: staleBanner ? existing.lastAttemptAt : record.lastAttemptAt,
|
|
103
125
|
key: existingKey,
|
|
104
126
|
};
|
|
105
127
|
state.sessions[existingKey] = merged;
|
|
@@ -111,7 +133,7 @@ export function upsertSession(record) {
|
|
|
111
133
|
if (record.status === 'stopped' && record.workspace === undefined) {
|
|
112
134
|
record.workspace = workspaceFingerprint(record.cwd);
|
|
113
135
|
}
|
|
114
|
-
const key = record.sessionId || `pane:${record
|
|
136
|
+
const key = record.sessionId || `pane:${addressHash(record)}:${record.detectedAt}`;
|
|
115
137
|
state.sessions[key] = { ...record, key };
|
|
116
138
|
return state;
|
|
117
139
|
});
|
|
@@ -126,8 +148,9 @@ function findDuplicate(state, record) {
|
|
|
126
148
|
// 'resuming' counts too: while the resumer types into a pane, a scrape can
|
|
127
149
|
// still see the banner for a few hundred ms — that must not fork a second
|
|
128
150
|
// record (it would double-resume the session).
|
|
129
|
-
if (s.pane &&
|
|
130
|
-
&& (s.status === 'stopped' || s.status === 'resuming'
|
|
151
|
+
if (s.pane && record.pane && addressHash(s) === addressHash(record)
|
|
152
|
+
&& (s.status === 'stopped' || s.status === 'resuming'
|
|
153
|
+
|| (s.status === 'resumed' && !s.bannerCleared))
|
|
131
154
|
&& Math.abs((s.detectedAt || 0) - record.detectedAt) < DEDUPE_WINDOW_MS) {
|
|
132
155
|
return key;
|
|
133
156
|
}
|
|
@@ -138,7 +161,7 @@ function findDuplicate(state, record) {
|
|
|
138
161
|
// writes the very transcript the watcher reads, so same-cwd evidence is
|
|
139
162
|
// almost always the same session, and the alternative (two records) would
|
|
140
163
|
// double-resume it.
|
|
141
|
-
if (record.sessionId && !s.sessionId
|
|
164
|
+
if (record.sessionId && !s.sessionId && (!record.pane || !s.pane)
|
|
142
165
|
&& s.agent === record.agent && s.cwd && s.cwd === record.cwd
|
|
143
166
|
&& s.status === 'stopped'
|
|
144
167
|
&& Math.abs((s.detectedAt || 0) - record.detectedAt) < DEDUPE_WINDOW_MS) {
|
package/src/watcher.js
CHANGED
|
@@ -18,8 +18,9 @@ import { join, sep, basename, dirname } from 'node:path';
|
|
|
18
18
|
import { homedir } from 'node:os';
|
|
19
19
|
import {
|
|
20
20
|
CLAUDE_DIR, CODEX_DIR, WATCH_OFFSETS_FILE, WATCH_FRESHNESS_MS,
|
|
21
|
-
RESET_MARGIN_MS, FALLBACK_RESET_MS,
|
|
21
|
+
RESET_MARGIN_MS, FALLBACK_RESET_MS, MUX_SESSION_NAME,
|
|
22
22
|
} from './config.js';
|
|
23
|
+
import { getMultiplexer } from './multiplexer.js';
|
|
23
24
|
import { parseTranscriptLine } from './watchers/claude.js';
|
|
24
25
|
import { parseRolloutLine, rolloutMeta } from './watchers/codex.js';
|
|
25
26
|
import { ROLLOUT_RE } from './agents/codex.js';
|
|
@@ -177,7 +178,8 @@ export function dispatchCandidate(c) {
|
|
|
177
178
|
cwd: c.cwd || null,
|
|
178
179
|
agent: c.agent,
|
|
179
180
|
origin: c.origin || null,
|
|
180
|
-
|
|
181
|
+
mux: getMultiplexer().name, paneOwner: null, leaseId: null,
|
|
182
|
+
muxSession: MUX_SESSION_NAME,
|
|
181
183
|
status: 'stopped',
|
|
182
184
|
limitType: c.limitType || 'unknown',
|
|
183
185
|
detectedVia: 'transcript',
|
package/src/wizard.js
CHANGED
|
@@ -24,11 +24,12 @@ function defaultWhich(bin) {
|
|
|
24
24
|
export async function runWizard() {
|
|
25
25
|
p.intro('unsnooze — wake every limit-stopped AI coding session automatically');
|
|
26
26
|
|
|
27
|
-
const {
|
|
28
|
-
|
|
27
|
+
const { getMultiplexer } = await import('./multiplexer.js');
|
|
28
|
+
const mux = getMultiplexer();
|
|
29
|
+
if (!mux.available()) {
|
|
29
30
|
p.log.warn(process.platform === 'win32'
|
|
30
|
-
? '
|
|
31
|
-
:
|
|
31
|
+
? 'No supported multiplexer found — unsnooze does not support native Windows.\nRun it inside WSL.'
|
|
32
|
+
: `${mux.name} not found — install it, then re-run \`unsnooze setup\`.`);
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
const detected = detectInstalledAgents();
|
|
@@ -75,7 +76,7 @@ export async function runWizard() {
|
|
|
75
76
|
const answer = await p.confirm({
|
|
76
77
|
message: 'Also guard GUI sessions (Claude Code in VS Code/desktop, Codex app/IDE)?\n'
|
|
77
78
|
+ ' Installs a small background daemon (launchd/systemd) that watches session\n'
|
|
78
|
-
+ ' files for limit stops; revived sessions open in
|
|
79
|
+
+ ' files for limit stops; revived sessions open in the configured multiplexer and stay visible in\n'
|
|
79
80
|
+ ' the GUI\'s own history.',
|
|
80
81
|
initialValue: DEFAULTS.guiWatch,
|
|
81
82
|
});
|
package/src/tmux.js
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { execFile as execFileCb, spawnSync } from 'node:child_process';
|
|
2
|
-
import { promisify } from 'node:util';
|
|
3
|
-
|
|
4
|
-
const execFileAsync = promisify(execFileCb);
|
|
5
|
-
|
|
6
|
-
// Is tmux runnable at all? Used for a friendly error instead of a cryptic
|
|
7
|
-
// spawn failure (native Windows users get pointed at WSL).
|
|
8
|
-
export function tmuxAvailable() {
|
|
9
|
-
try {
|
|
10
|
-
return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
|
|
11
|
-
} catch {
|
|
12
|
-
return false;
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
// Submit delay: text and the submitting Enter must be TWO separate send-keys
|
|
17
|
-
// calls with a pause between them, or Ink (Claude Code's TUI) treats the Enter
|
|
18
|
-
// as a newline inside a bracketed-paste burst instead of a submit.
|
|
19
|
-
export const SUBMIT_DELAY_MS = 150;
|
|
20
|
-
|
|
21
|
-
async function tmux(...args) {
|
|
22
|
-
const { stdout } = await execFileAsync('tmux', args);
|
|
23
|
-
return stdout;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function insideTmux() { return !!process.env.TMUX; }
|
|
27
|
-
export function currentPaneId() { return process.env.TMUX_PANE || null; }
|
|
28
|
-
|
|
29
|
-
export async function capturePane(pane, lines = 200) {
|
|
30
|
-
return tmux('capture-pane', '-t', pane, '-p', '-S', `-${lines}`);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Visible screen ONLY (no scrollback). Interactive menus must be checked
|
|
34
|
-
// against this: a menu that scrolled into history was already answered —
|
|
35
|
-
// re-driving it sends stray keystrokes into whatever is foreground now.
|
|
36
|
-
export async function capturePaneVisible(pane) {
|
|
37
|
-
return tmux('capture-pane', '-t', pane, '-p');
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Type a message into a TUI and submit it (split form; -l sends literally so
|
|
41
|
-
// tmux key names inside the message are typed, not interpreted).
|
|
42
|
-
export async function sendText(pane, text) {
|
|
43
|
-
await tmux('send-keys', '-t', pane, '-l', text);
|
|
44
|
-
await new Promise(r => setTimeout(r, SUBMIT_DELAY_MS));
|
|
45
|
-
await tmux('send-keys', '-t', pane, 'Enter');
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Single named key ('Down', 'Up', 'Enter', 'Escape') — drives menus.
|
|
49
|
-
export async function sendKey(pane, key) {
|
|
50
|
-
await tmux('send-keys', '-t', pane, key);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export async function paneAlive(pane) {
|
|
54
|
-
try {
|
|
55
|
-
await tmux('display-message', '-t', pane, '-p', '#{pane_id}');
|
|
56
|
-
return true;
|
|
57
|
-
} catch {
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export async function paneCurrentCommand(pane) {
|
|
63
|
-
try {
|
|
64
|
-
return (await tmux('display-message', '-t', pane, '-p', '#{pane_current_command}')).trim();
|
|
65
|
-
} catch {
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// NOTE: plain session names, not the '=name' exact-match prefix — tmux 3.7b
|
|
71
|
-
// rejects '=name' as a target here ("name not found" despite the session
|
|
72
|
-
// existing). Exact matches take priority over prefix matches anyway.
|
|
73
|
-
export async function sessionExists(name) {
|
|
74
|
-
try {
|
|
75
|
-
await tmux('has-session', '-t', name);
|
|
76
|
-
return true;
|
|
77
|
-
} catch {
|
|
78
|
-
return false;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Open a new window in the named session (creating the session if needed),
|
|
83
|
-
// cd'd to cwd, running command. Returns the new pane id.
|
|
84
|
-
export async function newWindow(sessionName, cwd, command) {
|
|
85
|
-
if (!(await sessionExists(sessionName))) {
|
|
86
|
-
await tmux('new-session', '-d', '-s', sessionName, '-c', cwd);
|
|
87
|
-
// The fresh session's first window IS our window — run the command there.
|
|
88
|
-
const pane = (await tmux('display-message', '-t', sessionName, '-p', '#{pane_id}')).trim();
|
|
89
|
-
await tmux('send-keys', '-t', pane, '-l', command);
|
|
90
|
-
await tmux('send-keys', '-t', pane, 'Enter');
|
|
91
|
-
return pane;
|
|
92
|
-
}
|
|
93
|
-
const out = await tmux('new-window', '-t', `${sessionName}:`, '-c', cwd, '-P', '-F', '#{pane_id}', command);
|
|
94
|
-
return out.trim();
|
|
95
|
-
}
|