unsnooze 1.19.0 → 1.19.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 +218 -0
- package/README.md +41 -12
- package/bin/unsnooze.js +90 -64
- package/package.json +1 -1
- package/src/agents/codex.js +117 -12
- package/src/agents/opencode.js +2 -1
- package/src/cli.js +4 -0
- package/src/config.js +21 -0
- package/src/doctor.js +76 -0
- package/src/hook.js +6 -2
- package/src/install.js +104 -35
- package/src/launcher.js +52 -10
- package/src/monitor.js +11 -0
- package/src/multiplexers/headless.js +159 -11
- package/src/resumer.js +94 -11
- package/src/spawn.js +262 -9
- package/src/time-parser.js +6 -1
- package/src/usage.js +50 -21
- package/src/watcher.js +45 -12
- package/src/watchers/codex.js +202 -28
- package/src/which.js +63 -0
- package/src/wizard.js +8 -4
- package/src/workspace.js +4 -1
package/src/agents/codex.js
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
// the overload path, never the ledger.
|
|
12
12
|
|
|
13
13
|
import { openSync, readSync, closeSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
14
|
-
import { join } from 'node:path';
|
|
14
|
+
import { join, basename } from 'node:path';
|
|
15
15
|
import { CODEX_DIR } from '../config.js';
|
|
16
|
+
import { findOnPath } from '../which.js';
|
|
17
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
16
18
|
|
|
17
19
|
// Since the 2026 unified ChatGPT desktop app absorbed the Codex app, the codex
|
|
18
20
|
// binary ships INSIDE the app bundle and many machines have no standalone
|
|
@@ -21,21 +23,59 @@ import { CODEX_DIR } from '../config.js';
|
|
|
21
23
|
// codex-cli 0.144 from ChatGPT.app).
|
|
22
24
|
export const CHATGPT_CODEX_BIN = '/Applications/ChatGPT.app/Contents/Resources/codex';
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
// Windows keeps the Desktop/Store install's CLI under a versioned runtime
|
|
27
|
+
// directory — %LOCALAPPDATA%\OpenAI\Codex\bin\<hash>\codex.exe — whose name
|
|
28
|
+
// changes on every update. The interactive shell finds it through a PATH the
|
|
29
|
+
// app rewrote; a daemon started at logon keeps the PATH it was born with,
|
|
30
|
+
// which after an update names a directory that no longer exists (#25). So
|
|
31
|
+
// look the directory up at launch time and take the newest runtime, the way
|
|
32
|
+
// CHATGPT_CODEX_BIN covers the macOS bundle. Layout as reported by the
|
|
33
|
+
// issue's Microsoft Store install; a machine without it just gets null.
|
|
34
|
+
export function windowsBundledCodex({ env = process.env, readdir = readdirSync, stat = statSync } = {}) {
|
|
35
|
+
if (!env.LOCALAPPDATA) return null;
|
|
36
|
+
const base = join(env.LOCALAPPDATA, 'OpenAI', 'Codex', 'bin');
|
|
37
|
+
let entries;
|
|
38
|
+
try { entries = readdir(base, { withFileTypes: true }); } catch { return null; }
|
|
39
|
+
let best = null;
|
|
40
|
+
for (const e of entries) {
|
|
41
|
+
if (!e.isDirectory()) continue;
|
|
42
|
+
const exe = join(base, e.name, 'codex.exe');
|
|
43
|
+
let mtime;
|
|
44
|
+
try { mtime = stat(exe).mtimeMs; } catch { continue; }
|
|
45
|
+
if (!best || mtime > best.mtime) best = { exe, mtime };
|
|
46
|
+
}
|
|
47
|
+
return best ? best.exe : null;
|
|
28
48
|
}
|
|
29
49
|
|
|
30
|
-
|
|
50
|
+
// What to spawn for codex. Order, on Windows: an explicit UNSNOOZE_CODEX_BIN,
|
|
51
|
+
// a codex.exe on PATH (spawn resolves the bare name itself), the newest
|
|
52
|
+
// Desktop/Store runtime, then a .cmd/.bat shim on PATH by full path — Node
|
|
53
|
+
// refuses to spawn those without a shell, and a full path makes the launcher's
|
|
54
|
+
// error say which file it was. Elsewhere: PATH, then the macOS app bundle.
|
|
55
|
+
export function resolveCodexBin({
|
|
56
|
+
env = process.env,
|
|
57
|
+
platform = process.platform,
|
|
58
|
+
exists = existsSync,
|
|
59
|
+
onPath = null,
|
|
60
|
+
bundled = () => windowsBundledCodex({ env }),
|
|
61
|
+
} = {}) {
|
|
31
62
|
if (env.UNSNOOZE_CODEX_BIN) return env.UNSNOOZE_CODEX_BIN;
|
|
32
|
-
if (
|
|
63
|
+
if (platform === 'win32') {
|
|
64
|
+
if (findOnPath(['codex.exe'], { env, exists, platform })) return 'codex';
|
|
65
|
+
const runtime = bundled();
|
|
66
|
+
if (runtime) return runtime;
|
|
67
|
+
const shim = findOnPath(['codex.cmd', 'codex.bat'], { env, exists, platform });
|
|
68
|
+
if (shim) return shim.path;
|
|
69
|
+
return 'codex';
|
|
70
|
+
}
|
|
71
|
+
const found = onPath ? onPath() : !!findOnPath(['codex'], { env, exists, platform });
|
|
72
|
+
if (found) return 'codex';
|
|
33
73
|
if (exists(CHATGPT_CODEX_BIN)) return CHATGPT_CODEX_BIN;
|
|
34
74
|
return 'codex'; // neither — the launcher degrades gracefully on spawn error
|
|
35
75
|
}
|
|
36
76
|
|
|
37
77
|
const LIMIT_ANCHORS = [
|
|
38
|
-
/You've hit your usage limit/i,
|
|
78
|
+
/You['’]ve hit your usage limit/i, // the desktop app also emits a curly apostrophe
|
|
39
79
|
/Your workspace is out of credits/i,
|
|
40
80
|
/hit your spend cap/i,
|
|
41
81
|
];
|
|
@@ -62,11 +102,48 @@ export const patterns = {
|
|
|
62
102
|
transientPatterns: [/stream error/i, /exceeded retry limit/i],
|
|
63
103
|
};
|
|
64
104
|
|
|
65
|
-
//
|
|
105
|
+
// A submitted user turn uses the same › glyph as the composer. Only accept
|
|
106
|
+
// the LAST prompt, followed directly by a blank line and Codex's status footer.
|
|
107
|
+
// ponytail: truncated/collapsed pastes cannot be proved; leave those for the user.
|
|
108
|
+
export function pendingCodexPrompt(text, message) {
|
|
109
|
+
if (!message) return false;
|
|
110
|
+
const lines = stripVTControlCharacters(text).split('\n');
|
|
111
|
+
const start = lines.findLastIndex(line => /^\s*›/.test(line));
|
|
112
|
+
if (start < 0) return false;
|
|
113
|
+
const tail = lines.slice(start + 1);
|
|
114
|
+
const footer = tail.findIndex((line, i) => i > 0 && !tail[i - 1].trim()
|
|
115
|
+
&& (/\s·\s/.test(line) || /(?:\d+% context left|\? for shortcuts)/.test(line)));
|
|
116
|
+
if (footer < 0) return false;
|
|
117
|
+
if (tail.slice(footer + 1).some(line => line.trim()
|
|
118
|
+
&& !/(?:\d+% context left|\? for shortcuts)/.test(line))) return false;
|
|
119
|
+
// Ignore wrapping and the extra newline swallowed by paste-burst handling.
|
|
120
|
+
const compact = value => value.replace(/\s/g, '');
|
|
121
|
+
const draft = [lines[start].replace(/^\s*›\s?/, ''), ...tail.slice(0, footer)].join('\n');
|
|
122
|
+
return compact(draft) === compact(message);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Sessions live in ~/.codex/sessions/YYYY/MM/DD/rollout-{ts}-{THREAD}.jsonl;
|
|
66
126
|
// the first JSONL line carries the session cwd. Conservative: no cwd match →
|
|
67
127
|
// null (the resumer then uses `codex resume --last`, which codex itself scopes
|
|
68
128
|
// to the launch cwd).
|
|
69
|
-
|
|
129
|
+
//
|
|
130
|
+
// A reverted thread (the desktop app's edit/regenerate, `/undo`) continues in
|
|
131
|
+
// rollout-{ts}-{THREAD}_{ROLLOUT}.jsonl — codex-rs/rollout/src/rollout_file_name.rs:
|
|
132
|
+
// "filenames for reverted threads append an underscore and a distinct rollout
|
|
133
|
+
// ID after the stable thread ID". Group 1 is always the thread id, which is
|
|
134
|
+
// the id `codex resume` takes; the rollout id after the underscore is not.
|
|
135
|
+
const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
|
|
136
|
+
export const ROLLOUT_RE = new RegExp(`^rollout-.*-(${UUID})(?:_${UUID})?\\.jsonl$`, 'i');
|
|
137
|
+
|
|
138
|
+
// The thread uuid a rollout path names, or null for anything else. Cheaper than
|
|
139
|
+
// rolloutMeta() (no read) and the same id in every rollout seen so far. A
|
|
140
|
+
// reverted thread's `…_<rollout id>` file names the same thread, so usage
|
|
141
|
+
// readings compare across the two files of one conversation.
|
|
142
|
+
export function rolloutId(path) {
|
|
143
|
+
if (typeof path !== 'string') return null;
|
|
144
|
+
const m = basename(path).match(ROLLOUT_RE);
|
|
145
|
+
return m ? m[1].toLowerCase() : null;
|
|
146
|
+
}
|
|
70
147
|
|
|
71
148
|
function fileHead(path, bytes = 4096) {
|
|
72
149
|
let fd;
|
|
@@ -115,12 +192,40 @@ export default {
|
|
|
115
192
|
experimental: false,
|
|
116
193
|
patterns,
|
|
117
194
|
menu: null, // no interactive limit menu
|
|
195
|
+
// What to do about a workspace wall (credits depleted, a spend cap): no
|
|
196
|
+
// window reset clears it, so the resumer's ceiling notification names this.
|
|
197
|
+
// Not `unsnooze resume-now`: the hold leaves the record failed, and
|
|
198
|
+
// resume-now only wakes stopped ones — it would answer "no matching stopped
|
|
199
|
+
// sessions" to the one command the notification told the user to run.
|
|
200
|
+
modelRemedy: 'add credits or raise the spend cap in the ChatGPT workspace (or ask its owner to), then continue the session in Codex',
|
|
118
201
|
// Resume takes the prompt in argv — `codex resume <id> "msg"` starts the turn
|
|
119
202
|
// immediately, nothing to type into the TUI.
|
|
120
|
-
|
|
203
|
+
//
|
|
204
|
+
// canType: false (headless — no pane): the TUI cannot run there at all. It
|
|
205
|
+
// refuses a non-TTY stdin before it looks at the session ("Error: stdin is
|
|
206
|
+
// not a terminal", exit 1 — reproduced against codex-cli 0.150 with the
|
|
207
|
+
// headless backend's exact stdio), so `codex exec resume <id> "msg"` carries
|
|
208
|
+
// the same conversation forward non-interactively instead (#25). With
|
|
209
|
+
// --last, codex reads a lone positional as the prompt, not a session id.
|
|
210
|
+
//
|
|
211
|
+
// `exec` also refuses any directory that is not a git repository unless
|
|
212
|
+
// told --skip-git-repo-check ("Not inside a trusted directory…", exit 1),
|
|
213
|
+
// a guard the TUI never had: without the flag, a session that ran in a
|
|
214
|
+
// plain folder could never be revived headless. It is the user's own
|
|
215
|
+
// session, resumed where it already ran, so the flag goes on.
|
|
216
|
+
//
|
|
217
|
+
// extraArgsAt: resumeExtraArgs go right after `exec`, not at the end. The
|
|
218
|
+
// `resume` subcommand rejects -s/--sandbox, -p/--profile and --add-dir
|
|
219
|
+
// ("unexpected argument", exit 2) — exactly what a user sets to match
|
|
220
|
+
// their normal launch — while `exec` takes them. (TUI-only flags such as
|
|
221
|
+
// -a or --search have no exec equivalent and still fail, visibly.)
|
|
222
|
+
resumeArgs(sessionId, message, { canType = true } = {}) {
|
|
223
|
+
const tail = sessionId ? [sessionId, message] : ['--last', message];
|
|
224
|
+
if (canType) return { args: ['resume', ...tail], messageViaPane: false };
|
|
121
225
|
return {
|
|
122
|
-
args:
|
|
226
|
+
args: ['exec', '--skip-git-repo-check', 'resume', ...tail],
|
|
123
227
|
messageViaPane: false,
|
|
228
|
+
extraArgsAt: 1,
|
|
124
229
|
};
|
|
125
230
|
},
|
|
126
231
|
// v1: every agent launches the bare TUI and gets the prompt typed once idle.
|
package/src/agents/opencode.js
CHANGED
|
@@ -51,10 +51,11 @@ export const patterns = {
|
|
|
51
51
|
terminalPatterns: [/insufficient credits/i, /out of credits/i],
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
+
// windowsHide: the console-less daemon calls this too (see workspace.js).
|
|
54
55
|
function runSessionList() {
|
|
55
56
|
return execFileSync(process.env.UNSNOOZE_OPENCODE_BIN || 'opencode',
|
|
56
57
|
['session', 'list', '--format', 'json'],
|
|
57
|
-
{ timeout: 5000, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
58
|
+
{ timeout: 5000, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
// `opencode session list --format json` — sessions live in a SQLite db, so ask
|
package/src/cli.js
CHANGED
|
@@ -90,6 +90,10 @@ export async function cmdStatus(args = []) {
|
|
|
90
90
|
sessions: Object.values(state.sessions).map(s => ({
|
|
91
91
|
key: s.key, sessionId: s.sessionId ?? null, agent: s.agent ?? 'claude',
|
|
92
92
|
cwd: s.cwd ?? null, status: s.status, limitType: s.limitType ?? null,
|
|
93
|
+
// Why the provider refused, when it said (Codex's rate_limit_reached_type,
|
|
94
|
+
// e.g. workspace_member_credits_depleted) — the reason a stop is probed
|
|
95
|
+
// and held rather than scheduled.
|
|
96
|
+
limitReason: s.limitReason ?? null,
|
|
93
97
|
resetAt: s.resetAt ?? null, resetSource: s.resetSource ?? null,
|
|
94
98
|
mux: s.mux ?? null, pane: s.pane ?? null, muxSession: s.muxSession ?? null,
|
|
95
99
|
attempts: s.attempts ?? 0, lastError: s.lastError ?? null,
|
package/src/config.js
CHANGED
|
@@ -229,6 +229,10 @@ export const PROBE_INTERVAL_MS = envInt('UNSNOOZE_PROBE_INTERVAL_MS', 15 * 60_00
|
|
|
229
229
|
export const PROBE_MAX_MS = envInt('UNSNOOZE_PROBE_MAX_MS', 60 * 60_000);
|
|
230
230
|
export const STAGGER_MS = envInt('UNSNOOZE_STAGGER_MS', 8_000);
|
|
231
231
|
export const VERIFY_DELAY_MS = envInt('UNSNOOZE_VERIFY_DELAY_MS', 20_000);
|
|
232
|
+
// A headless revival still running at verify time is watched this long before
|
|
233
|
+
// it counts as resumed: the failures worth catching (an auth refresh that gives
|
|
234
|
+
// up, a retry loop on a bad network) take longer than VERIFY_DELAY_MS to exit.
|
|
235
|
+
export const HEADLESS_SETTLE_MS = envInt('UNSNOOZE_HEADLESS_SETTLE_MS', 2 * 60_000);
|
|
232
236
|
export const BUSY_DEFER_MS = envInt('UNSNOOZE_BUSY_DEFER_MS', 60_000);
|
|
233
237
|
export const READY_TIMEOUT_MS = envInt('UNSNOOZE_READY_TIMEOUT_MS', 60_000);
|
|
234
238
|
export const EVENT_MARKER_TTL_MS = envInt('UNSNOOZE_EVENT_MARKER_TTL_MS', 120_000);
|
|
@@ -254,6 +258,23 @@ export const MUX_NAMES = ['tmux', 'zellij', 'herdr', 'cmux', 'headless'];
|
|
|
254
258
|
// Where a headless revive tees the agent's output. There is no pane to scroll
|
|
255
259
|
// back through, so the log is the only record of what an unattended run did.
|
|
256
260
|
export const HEADLESS_LOG_DIR = join(STATE_DIR, 'headless');
|
|
261
|
+
// The logon Scheduled Task that unsnooze <= 1.19.1 registered for the daemon.
|
|
262
|
+
// Nothing creates it any more (see DAEMON_ON_DEMAND); uninstall still removes
|
|
263
|
+
// one an older install left behind.
|
|
264
|
+
export const WINDOWS_TASK_NAME = 'unsnooze';
|
|
265
|
+
|
|
266
|
+
// The daemon on native Windows. There is no launchd/systemd to hand it to, and
|
|
267
|
+
// every way of starting a program at sign-in — a Scheduled Task, a Run key, the
|
|
268
|
+
// Startup folder — is what antivirus heuristics are trained to call malware
|
|
269
|
+
// persistence: Microsoft Defender flagged `schtasks /create /sc onlogon` as
|
|
270
|
+
// Trojan:Win32/Commando.A!ml. So nothing is registered with Windows. This
|
|
271
|
+
// marker is the whole record (install --daemon writes it), and while it exists
|
|
272
|
+
// the wrappers and the StopFailure hook start the daemon whenever it is not
|
|
273
|
+
// running.
|
|
274
|
+
export const DAEMON_ON_DEMAND = join(STATE_DIR, 'daemon-on-demand');
|
|
275
|
+
// Which daemon is running (Windows only). Its mtime is a heartbeat, so a pid
|
|
276
|
+
// that Windows recycled after a crash or a sign-out is not mistaken for it.
|
|
277
|
+
export const DAEMON_PIDFILE = join(STATE_DIR, 'daemon.pid');
|
|
257
278
|
|
|
258
279
|
// Pane scanning
|
|
259
280
|
export const PANE_SCAN_LINES = envInt('UNSNOOZE_PANE_SCAN_LINES', 12);
|
package/src/doctor.js
CHANGED
|
@@ -17,6 +17,10 @@ import { shouldUseTui, formatDoctorTui } from './tui.js';
|
|
|
17
17
|
import { designRegisteredOffline } from './design.js';
|
|
18
18
|
import { powershellProfilePath } from './powershell.js';
|
|
19
19
|
import { fishConfigPath } from './fish.js';
|
|
20
|
+
import { listAgents } from './agents/index.js';
|
|
21
|
+
import { getConfig } from './settings.js';
|
|
22
|
+
import { resolveBin } from './which.js';
|
|
23
|
+
import { CLAUDE_DIR, CODEX_DIR } from './config.js';
|
|
20
24
|
|
|
21
25
|
const log = makeLogger('doctor');
|
|
22
26
|
|
|
@@ -187,6 +191,11 @@ export async function runDoctor({
|
|
|
187
191
|
profileContent = undefined,
|
|
188
192
|
autostartDir = null,
|
|
189
193
|
stateDir = STATE_DIR,
|
|
194
|
+
env = process.env,
|
|
195
|
+
exists = existsSync,
|
|
196
|
+
agents = listAgents(),
|
|
197
|
+
enabled = id => !!getConfig(`agents.${id}`),
|
|
198
|
+
inUse = agentInUse,
|
|
190
199
|
} = {}) {
|
|
191
200
|
const findings = [];
|
|
192
201
|
|
|
@@ -288,6 +297,14 @@ export async function runDoctor({
|
|
|
288
297
|
}
|
|
289
298
|
} catch { /* best-effort: a PATH check must never break doctor */ }
|
|
290
299
|
|
|
300
|
+
// What each enabled agent would actually be launched as — resolved the way
|
|
301
|
+
// the launcher resolves it, from this environment. #25 was a daemon whose
|
|
302
|
+
// logon-time PATH no longer named the Codex runtime directory; every revival
|
|
303
|
+
// died with ENOENT and nothing said so until the headless log was read.
|
|
304
|
+
// Doctor cannot see the daemon's environment, only its own, so on Windows
|
|
305
|
+
// it also says how the two can differ.
|
|
306
|
+
for (const f of agentBinFindings({ platform, env, exists, agents, enabled, inUse })) findings.push(f);
|
|
307
|
+
|
|
291
308
|
findings.push({
|
|
292
309
|
id: 'daemon', kind: 'info',
|
|
293
310
|
title: daemonRunning() ? 'resumer/daemon: running' : 'resumer/daemon: not running (starts on the next limit stop)',
|
|
@@ -310,6 +327,65 @@ export async function runDoctor({
|
|
|
310
327
|
return { findings, healthy };
|
|
311
328
|
}
|
|
312
329
|
|
|
330
|
+
// claude and codex are enabled by default, so "enabled" does not mean "used
|
|
331
|
+
// here": a machine that has only ever run one of them has nothing of the
|
|
332
|
+
// other's to revive, and must not be reported unhealthy for lacking its
|
|
333
|
+
// binary. Their own data directories say whether they have run here. The
|
|
334
|
+
// other agents are opt-in; the user switching one on is evidence enough.
|
|
335
|
+
const AGENT_HOMES = { claude: CLAUDE_DIR, codex: CODEX_DIR };
|
|
336
|
+
export function agentInUse(id, { exists = existsSync } = {}) {
|
|
337
|
+
const home = AGENT_HOMES[id];
|
|
338
|
+
return home ? exists(home) : true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function agentBinFindings({
|
|
342
|
+
platform = process.platform, env = process.env, exists = existsSync,
|
|
343
|
+
agents = listAgents(), enabled = id => !!getConfig(`agents.${id}`),
|
|
344
|
+
inUse = agentInUse,
|
|
345
|
+
} = {}) {
|
|
346
|
+
const findings = [];
|
|
347
|
+
const envVar = id => `UNSNOOZE_${String(id).toUpperCase()}_BIN`;
|
|
348
|
+
// One command, never `a && b`: && only exists from PowerShell 7, and the
|
|
349
|
+
// Windows PowerShell 5.1 that ships with the OS is a supported shell.
|
|
350
|
+
const windowsNote = platform === 'win32'
|
|
351
|
+
? `\n the daemon keeps the PATH of whatever started it; once this shell finds the agent,\n`
|
|
352
|
+
+ ` restart it from here with: unsnooze install --daemon (or sign out and back in)`
|
|
353
|
+
: '';
|
|
354
|
+
for (const agent of agents) {
|
|
355
|
+
if (!agent?.id || !enabled(agent.id)) continue;
|
|
356
|
+
const bin = agent.bin;
|
|
357
|
+
let resolved = null;
|
|
358
|
+
try { resolved = resolveBin(bin, { env, exists, platform }); } catch { resolved = null; }
|
|
359
|
+
if (resolved?.launchable) {
|
|
360
|
+
findings.push({
|
|
361
|
+
id: `agent-bin-${agent.id}`, kind: 'info',
|
|
362
|
+
title: `${agent.id}: ${resolved.path}`,
|
|
363
|
+
detail: '',
|
|
364
|
+
});
|
|
365
|
+
} else if (resolved) {
|
|
366
|
+
findings.push({
|
|
367
|
+
id: `agent-bin-${agent.id}`, kind: 'health',
|
|
368
|
+
title: `${agent.id} resolves to ${resolved.path}, a .cmd/.bat shim that cannot be launched directly`,
|
|
369
|
+
detail: ` set ${envVar(agent.id)} to the agent's .exe`,
|
|
370
|
+
});
|
|
371
|
+
} else if (!inUse(agent.id)) {
|
|
372
|
+
findings.push({
|
|
373
|
+
id: `agent-bin-${agent.id}`, kind: 'info',
|
|
374
|
+
title: `${agent.id}: not installed (no ${agent.id} sessions here to revive)`,
|
|
375
|
+
detail: '',
|
|
376
|
+
});
|
|
377
|
+
} else {
|
|
378
|
+
findings.push({
|
|
379
|
+
id: `agent-bin-${agent.id}`, kind: 'health',
|
|
380
|
+
title: `${agent.id} is not launchable from this environment (${bin} not found)`,
|
|
381
|
+
detail: ` every revival of a ${agent.id} session will fail with ENOENT\n`
|
|
382
|
+
+ ` install it, put it on PATH, or set ${envVar(agent.id)}${windowsNote}`,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return findings;
|
|
387
|
+
}
|
|
388
|
+
|
|
313
389
|
// --- fixes ------------------------------------------------------------------------
|
|
314
390
|
|
|
315
391
|
function defaultKill(pid) {
|
package/src/hook.js
CHANGED
|
@@ -16,7 +16,7 @@ import { parseResetTime, resetAtMs } from './time-parser.js';
|
|
|
16
16
|
import { upsertSession } from './state.js';
|
|
17
17
|
import { latestRateLimitFromTranscript } from './watchers/claude.js';
|
|
18
18
|
import { getMultiplexer } from './multiplexer.js';
|
|
19
|
-
import { spawnResumerIfNeeded } from './spawn.js';
|
|
19
|
+
import { spawnResumerIfNeeded, ensureDaemon } from './spawn.js';
|
|
20
20
|
import { makeLogger } from './logger.js';
|
|
21
21
|
import { addressHash } from './lease.js';
|
|
22
22
|
import { prepareCalibrationSample, applyCalibrationToState } from './usage.js';
|
|
@@ -56,11 +56,15 @@ export function hookContext(env = process.env, payload = {}) {
|
|
|
56
56
|
return { muxName, pane, paneOwner };
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
export async function runHook(rest = []) {
|
|
59
|
+
export async function runHook(rest = [], { ensureDaemonFn = ensureDaemon } = {}) {
|
|
60
60
|
try {
|
|
61
61
|
const agentIdx = rest.indexOf('--agent');
|
|
62
62
|
const agent = getAgent(agentIdx !== -1 ? rest[agentIdx + 1] : 'claude');
|
|
63
63
|
if (!getConfig(`agents.${agent.id}`)) return 0; // agent disabled in settings
|
|
64
|
+
// Nothing keeps the daemon running on native Windows (see ensureDaemon),
|
|
65
|
+
// and the hook is the one entry point GUI Claude sessions pass through —
|
|
66
|
+
// so it makes sure the daemon is up. A no-op everywhere else.
|
|
67
|
+
ensureDaemonFn();
|
|
64
68
|
const raw = await readStdin();
|
|
65
69
|
let payload = {};
|
|
66
70
|
try { payload = JSON.parse(raw); } catch { /* tolerate non-JSON */ }
|
package/src/install.js
CHANGED
|
@@ -14,14 +14,14 @@
|
|
|
14
14
|
import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, rmSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
15
15
|
import { execFileSync } from 'node:child_process';
|
|
16
16
|
import { homedir, userInfo } from 'node:os';
|
|
17
|
-
import { join, dirname, delimiter } from 'node:path';
|
|
18
|
-
import { CLAUDE_SETTINGS, STATE_DIR } from './config.js';
|
|
17
|
+
import { join, dirname, basename, delimiter } from 'node:path';
|
|
18
|
+
import { CLAUDE_SETTINGS, STATE_DIR, WINDOWS_TASK_NAME, DAEMON_ON_DEMAND } from './config.js';
|
|
19
19
|
import { getConfig, configFileExists } from './settings.js';
|
|
20
20
|
import { xmlEscape } from './notify.js';
|
|
21
21
|
import { installGrokHooks, uninstallGrokHooks } from './agents/grok.js';
|
|
22
22
|
import { getAgent } from './agents/index.js';
|
|
23
23
|
import { findCsgProcesses, findCsgAutostarts } from './doctor.js';
|
|
24
|
-
import { UNSNOOZE_BIN, stopResumer } from './spawn.js';
|
|
24
|
+
import { UNSNOOZE_BIN, stopResumer, restartDaemon, stopDaemon } from './spawn.js';
|
|
25
25
|
import { uninstallStatuslineShim } from './usage.js';
|
|
26
26
|
import { powershellProfilePath } from './powershell.js';
|
|
27
27
|
import { fishConfigPath } from './fish.js';
|
|
@@ -248,12 +248,13 @@ export function installFishBlock(content, agents = ['claude'], bin = UNSNOOZE_BI
|
|
|
248
248
|
// GUI sessions (VS Code extension, desktop apps) never pass through the shell
|
|
249
249
|
// wrappers, so their limit stops are only caught while `unsnooze daemon` is
|
|
250
250
|
// alive. Autostart keeps it alive: a launchd user agent on macOS, a systemd
|
|
251
|
-
// user unit on Linux/WSL.
|
|
251
|
+
// user unit on Linux/WSL. Native Windows deliberately gets neither a unit nor
|
|
252
|
+
// a Scheduled Task — the daemon is started on demand there (DAEMON_ON_DEMAND).
|
|
252
253
|
|
|
253
254
|
export const DAEMON_LABEL = 'com.unsnooze.daemon';
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
export
|
|
255
|
+
// The Scheduled Task name unsnooze <= 1.19.1 used; kept for the uninstall that
|
|
256
|
+
// removes one left behind.
|
|
257
|
+
export { WINDOWS_TASK_NAME };
|
|
257
258
|
|
|
258
259
|
// Is this process running under the supervisor we install, rather than from
|
|
259
260
|
// somebody's shell? It decides whether exiting is safe: a supervised daemon
|
|
@@ -269,14 +270,15 @@ export const WINDOWS_TASK_NAME = 'unsnooze';
|
|
|
269
270
|
export function isSupervised({ platform = process.platform, env = process.env } = {}) {
|
|
270
271
|
if (platform === 'darwin') return env.XPC_SERVICE_NAME === DAEMON_LABEL;
|
|
271
272
|
if (platform === 'linux') return typeof env.INVOCATION_ID === 'string' && env.INVOCATION_ID !== '';
|
|
272
|
-
// win32 stays false on purpose
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
// lesser of the two, and
|
|
273
|
+
// win32 stays false on purpose. launchd KeepAlive and systemd Restart=always
|
|
274
|
+
// bring the daemon straight back; on Windows it is only started again by the
|
|
275
|
+
// next wrapped launch or StopFailure hook, which may be hours away. Answering
|
|
276
|
+
// true here would let the version-skew guard exit 0 into nothing and stop
|
|
277
|
+
// Windows watching silently — the exact failure mode of issue #8, just with a
|
|
278
|
+
// different trigger. The cost is that a Windows daemon keeps running
|
|
279
|
+
// pre-upgrade code until it is restarted (a sign-out, or re-running
|
|
280
|
+
// `unsnooze install --daemon`); that is the lesser of the two, and
|
|
281
|
+
// `unsnooze doctor` reports the skew.
|
|
280
282
|
return false;
|
|
281
283
|
}
|
|
282
284
|
|
|
@@ -434,20 +436,49 @@ function defaultActivate(cmd, args) {
|
|
|
434
436
|
}
|
|
435
437
|
}
|
|
436
438
|
|
|
439
|
+
// Native Windows keeps its "unit" in the state dir: a marker that asks for the
|
|
440
|
+
// daemon to be started on demand. `dir` relocates it, as it does the unit dir
|
|
441
|
+
// elsewhere.
|
|
442
|
+
function onDemandMarker(dir) {
|
|
443
|
+
return dir ? join(dir, basename(DAEMON_ON_DEMAND)) : DAEMON_ON_DEMAND;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const ON_DEMAND_NOTE = 'While this file exists, the unsnooze daemon is started on demand — by the\n'
|
|
447
|
+
+ 'agent wrappers and the StopFailure hook — whenever it is not running. Nothing\n'
|
|
448
|
+
+ 'is registered to run at sign-in. `unsnooze uninstall` removes this file.\n';
|
|
449
|
+
|
|
450
|
+
// unsnooze <= 1.19.1 registered the daemon as a logon Scheduled Task. Nothing
|
|
451
|
+
// creates one now, but an install from then may still have it; uninstall
|
|
452
|
+
// removes it. Ask before deleting, so a machine that never had one — most of
|
|
453
|
+
// them: creating it needed an elevated shell — only ever sees a read-only
|
|
454
|
+
// query. /f so the delete is not an interactive prompt. 'removed', 'stuck'
|
|
455
|
+
// (it exists but the delete failed — a task created from an elevated shell
|
|
456
|
+
// can need one to delete), or null when there was none.
|
|
457
|
+
export function removeLegacyScheduledTask({ activate = defaultActivate } = {}) {
|
|
458
|
+
if (!activate('schtasks', ['/query', '/tn', WINDOWS_TASK_NAME])) return null;
|
|
459
|
+
return activate('schtasks', ['/delete', '/f', '/tn', WINDOWS_TASK_NAME]) ? 'removed' : 'stuck';
|
|
460
|
+
}
|
|
461
|
+
|
|
437
462
|
export function installDaemonAutostart({
|
|
438
463
|
platform = process.platform, dir = null, activate = defaultActivate, path = undefined,
|
|
464
|
+
start = restartDaemon,
|
|
439
465
|
} = {}) {
|
|
440
|
-
// Native Windows
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
466
|
+
// Native Windows matters more than anywhere: the transcript watcher lives in
|
|
467
|
+
// the daemon, and headless has no pane monitor to fall back on. But nothing
|
|
468
|
+
// is registered with Windows to start it. Up to 1.19.1 this created a logon
|
|
469
|
+
// Scheduled Task (`schtasks /create /sc onlogon /tr "node … daemon"`), and
|
|
470
|
+
// Microsoft Defender flagged setup as Trojan:Win32/Commando.A!ml for it —
|
|
471
|
+
// an interpreter running a script from %APPDATA% at every logon is exactly
|
|
472
|
+
// how malware persists, and any logon entry (Run key, Startup folder) reads
|
|
473
|
+
// the same way. Without elevation schtasks refused the task anyway. So the
|
|
474
|
+
// daemon is started now, and started again on demand (spawn.js ensureDaemon).
|
|
445
475
|
if (platform === 'win32') {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
476
|
+
const marker = onDemandMarker(dir);
|
|
477
|
+
atomicWrite(marker, ON_DEMAND_NOTE);
|
|
478
|
+
// Same interlock as the unit platforms below: the real starter only runs
|
|
479
|
+
// for the marker it would actually read.
|
|
480
|
+
if (start !== restartDaemon || marker === DAEMON_ON_DEMAND) start();
|
|
481
|
+
return marker;
|
|
451
482
|
}
|
|
452
483
|
const target = autostartUnitPath({ platform, dir });
|
|
453
484
|
if (!target) return null;
|
|
@@ -528,11 +559,30 @@ export function healDaemonAutostart({
|
|
|
528
559
|
return installDaemonAutostart({ platform, dir, activate, path: better });
|
|
529
560
|
}
|
|
530
561
|
|
|
531
|
-
export function uninstallDaemonAutostart({
|
|
562
|
+
export function uninstallDaemonAutostart({
|
|
563
|
+
platform = process.platform, dir = null, activate = defaultActivate, stop = stopDaemon,
|
|
564
|
+
say = () => {},
|
|
565
|
+
} = {}) {
|
|
532
566
|
if (platform === 'win32') {
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
567
|
+
const removed = [];
|
|
568
|
+
// The marker first, so nothing starts the daemon again once it is stopped
|
|
569
|
+
// (and an on-demand daemon that is only now starting sees it gone).
|
|
570
|
+
const marker = onDemandMarker(dir);
|
|
571
|
+
if (existsSync(marker)) {
|
|
572
|
+
try { unlinkSync(marker); removed.push(marker); } catch { /* still there — not reported as removed */ }
|
|
573
|
+
}
|
|
574
|
+
const live = marker === DAEMON_ON_DEMAND;
|
|
575
|
+
if (stop !== stopDaemon || live) stop();
|
|
576
|
+
// The interlock again: a relocated marker is a fixture, and the machine's
|
|
577
|
+
// real Task Scheduler is none of its business.
|
|
578
|
+
const act = (activate === defaultActivate && !live) ? () => false : activate;
|
|
579
|
+
const legacy = removeLegacyScheduledTask({ activate: act });
|
|
580
|
+
if (legacy === 'removed') removed.push(`Scheduled Task \\${WINDOWS_TASK_NAME}`);
|
|
581
|
+
else if (legacy === 'stuck') {
|
|
582
|
+
say(`could not delete the Scheduled Task \\${WINDOWS_TASK_NAME} an earlier version created; `
|
|
583
|
+
+ `from an administrator shell run: schtasks /delete /tn ${WINDOWS_TASK_NAME} /f`);
|
|
584
|
+
}
|
|
585
|
+
return removed.length ? removed.join(' and ') : null;
|
|
536
586
|
}
|
|
537
587
|
if (platform === 'darwin') {
|
|
538
588
|
const target = join(dir || autostartDir(platform), `${DAEMON_LABEL}.plist`);
|
|
@@ -696,8 +746,21 @@ export function cmdInstall(rest, { agents = enabledAgents() } = {}) {
|
|
|
696
746
|
|
|
697
747
|
// 4. Daemon autostart (GUI-session watching), opt-in via --daemon / wizard.
|
|
698
748
|
if (opts.daemon) {
|
|
699
|
-
|
|
700
|
-
|
|
749
|
+
// On Windows the start's outcome is kept, so the message can be honest.
|
|
750
|
+
let started = null;
|
|
751
|
+
const target = installDaemonAutostart(process.platform === 'win32'
|
|
752
|
+
? { start: () => { started = restartDaemon(); } } : {});
|
|
753
|
+
if (target && process.platform === 'win32') {
|
|
754
|
+
if (started) console.log(`unsnooze: daemon started in the background (pid ${started}) — GUI sessions are watched`);
|
|
755
|
+
else console.log('unsnooze: the daemon did not start — run `unsnooze daemon` to see why');
|
|
756
|
+
console.log('unsnooze: nothing is registered to run at sign-in (no Scheduled Task); your wrapped');
|
|
757
|
+
console.log('unsnooze: agents and the Claude hook start the daemon again whenever it is not running');
|
|
758
|
+
// A task left by 1.19.1 or earlier is deliberately not looked for here:
|
|
759
|
+
// node.exe spawning schtasks.exe is the pairing Defender reacted to, and
|
|
760
|
+
// setup should spawn nothing of the kind. Such a task only starts this
|
|
761
|
+
// same daemon at sign-in, which the pidfile tolerates; uninstall removes
|
|
762
|
+
// it.
|
|
763
|
+
} else if (target) console.log(`unsnooze: daemon autostart installed (${target}) — GUI sessions are watched`);
|
|
701
764
|
else console.log('unsnooze: daemon autostart is not supported on this platform');
|
|
702
765
|
}
|
|
703
766
|
|
|
@@ -724,7 +787,16 @@ export function cmdUninstall(rest) {
|
|
|
724
787
|
const opts = parseArgs(rest);
|
|
725
788
|
const explicitRc = rest.includes('--zshrc');
|
|
726
789
|
|
|
727
|
-
//
|
|
790
|
+
// Whatever starts the daemon goes first — the launchd/systemd unit, or on
|
|
791
|
+
// Windows the marker the wrappers and the hook consult — so nothing can
|
|
792
|
+
// start it again mid-uninstall. On Windows this order also stops the daemon
|
|
793
|
+
// while its pidfile still proves which process it is, not after
|
|
794
|
+
// stopResumer has killed it and left a pid that may already be someone
|
|
795
|
+
// else's.
|
|
796
|
+
const autostart = uninstallDaemonAutostart({ say: m => console.log(`unsnooze: ${m}`) });
|
|
797
|
+
if (autostart) console.log(`unsnooze: daemon autostart removed (${autostart})`);
|
|
798
|
+
|
|
799
|
+
// Stop the resumer/daemon so it cannot keep writing state after hooks
|
|
728
800
|
// are gone (zombie-daemon-running-deleted-code failure mode).
|
|
729
801
|
try {
|
|
730
802
|
const result = stopResumer();
|
|
@@ -780,9 +852,6 @@ export function cmdUninstall(rest) {
|
|
|
780
852
|
}
|
|
781
853
|
}
|
|
782
854
|
|
|
783
|
-
const autostart = uninstallDaemonAutostart();
|
|
784
|
-
if (autostart) console.log(`unsnooze: daemon autostart removed (${autostart})`);
|
|
785
|
-
|
|
786
855
|
// Restore Claude statusLine if our usage shim was chained in.
|
|
787
856
|
try {
|
|
788
857
|
const r = uninstallStatuslineShim({ settingsPath: opts.settings });
|