unsnooze 1.14.1 → 1.14.3
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 +61 -1
- package/README.md +19 -4
- package/bin/unsnooze.js +24 -1
- package/package.json +1 -1
- package/src/agents/claude.js +3 -1
- package/src/config.js +2 -1
- package/src/dashboard/data.js +1 -5
- package/src/doctor.js +40 -0
- package/src/hook.js +10 -0
- package/src/install.js +157 -26
- package/src/launcher.js +3 -2
- package/src/monitor.js +62 -15
- package/src/reap.js +115 -17
- package/src/resumer.js +309 -52
- package/src/sessions.js +42 -4
- package/src/spawn.js +17 -1
- package/src/state.js +57 -13
- package/src/tui.js +1 -1
- package/src/update-check.js +70 -1
- package/src/usage.js +2 -2
- package/src/watchers/claude.js +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 1.14.3 — 2026-08-08
|
|
4
|
+
|
|
5
|
+
- **Upgrades now reach running watchers.** A per-pane monitor lives as long as
|
|
6
|
+
the agent and a transient resumer lives until the reset; neither has a
|
|
7
|
+
supervisor, and `npm i -g unsnooze` cannot change code a running process has
|
|
8
|
+
already loaded. A session started before an upgrade therefore kept applying
|
|
9
|
+
the old rules indefinitely — which is why 1.14.2's resume-lifecycle fix could
|
|
10
|
+
be installed and still change nothing on a long-lived pane. Both processes now
|
|
11
|
+
notice that the package changed underneath them, hand off to a replacement on
|
|
12
|
+
the fresh code, and stand down. Sessions launched under 1.14.2 or earlier need
|
|
13
|
+
one restart to pick this up; after that, upgrades propagate on their own.
|
|
14
|
+
- **Hand-offs are verified, never assumed.** `spawn` resolves the node binary,
|
|
15
|
+
which exists regardless of the package, so a tree caught mid-swap returns a
|
|
16
|
+
healthy-looking pid for a child that is already dead. The hand-off now checks
|
|
17
|
+
that the entry point exists before starting anything, and that the replacement
|
|
18
|
+
survived startup before standing down. Either check failing keeps the old
|
|
19
|
+
process watching — a stale watcher beats no watcher.
|
|
20
|
+
- **A manually run daemon no longer dies on upgrade.** `unsnooze daemon` started
|
|
21
|
+
from a shell has nothing to restart it, so exiting on version skew ended GUI
|
|
22
|
+
watching silently. It now detects whether launchd/systemd is actually
|
|
23
|
+
supervising it: supervised, it exits and returns on fresh code; unsupervised,
|
|
24
|
+
it warns once and keeps watching. The 15-minute detection interval is
|
|
25
|
+
unchanged — a tighter loop raises the odds of restarting into a half-installed
|
|
26
|
+
package.
|
|
27
|
+
- **The daemon PATH self-heal actually heals.** The 1.12 repair regenerated the
|
|
28
|
+
autostart unit from the PATH of whichever process called it, and the only
|
|
29
|
+
caller is the daemon itself — whose PATH is the minimal one launchd and
|
|
30
|
+
systemd provide. It wrote the broken PATH straight back, then, because the
|
|
31
|
+
check only asked whether a PATH block was *present*, treated the job as done
|
|
32
|
+
forever. Observed live: a daemon running with `PATH=/usr/bin:/bin:/usr/sbin:/sbin`
|
|
33
|
+
while tmux sat in `/opt/homebrew/bin`, so every GUI revival failed with ENOENT.
|
|
34
|
+
The heal now asks the login shell for a usable PATH and rewrites the unit only
|
|
35
|
+
when the result can actually find the multiplexer *and* differs from what is
|
|
36
|
+
already there — rewriting reloads the unit, which kills the calling daemon, so
|
|
37
|
+
a heal that cannot improve anything must do nothing.
|
|
38
|
+
- **`unsnooze doctor` reports a daemon that cannot reach its multiplexer**, and
|
|
39
|
+
`--fix` repairs it. This was previously invisible, because doctor probes tmux
|
|
40
|
+
from your shell's PATH rather than the daemon's.
|
|
41
|
+
- **Version stamps in the log.** The monitor and resumer record which build they
|
|
42
|
+
are running as they start, so a pasted log answers "is this process even on
|
|
43
|
+
the new code" without further diagnosis.
|
|
44
|
+
- Internal: one shared `pidAlive` now backs the resumer's lock hygiene, the
|
|
45
|
+
dashboard, and the hand-off. It rejects pid 0, which `kill(0, 0)` reports as
|
|
46
|
+
alive because that signals the caller's own process group — a lock file
|
|
47
|
+
holding `0` could otherwise be honored forever. Writing an autostart unit
|
|
48
|
+
outside the live location no longer activates it: every unit carries the same
|
|
49
|
+
label, so loading a copy hijacks the real job instead of adding a second one.
|
|
50
|
+
|
|
51
|
+
## 1.14.2 — 2026-08-06
|
|
52
|
+
|
|
53
|
+
- **Resume lifecycle:** a limit banner scrolling out of the pane no longer
|
|
54
|
+
marks a Claude session resumed without a wake. Unsnooze now requires a
|
|
55
|
+
newer non-error parent-assistant usage record, rechecks that evidence
|
|
56
|
+
immediately before dispatch, ignores subagent `StopFailure` records, follows
|
|
57
|
+
the canonical key retained by state dedupe, and never treats a persistently
|
|
58
|
+
busy pane as proof of completion.
|
|
59
|
+
- **Usage diagnostics:** estimated usage now says that its percentage is
|
|
60
|
+
unavailable, rather than claiming no stop was observed. Proxy
|
|
61
|
+
launcher documentation explains that `headroom wrap` bypasses Unsnooze's
|
|
62
|
+
shell launcher and same-pane monitor while hook detection and enabled daemon
|
|
63
|
+
file watching remain available.
|
|
4
64
|
|
|
5
65
|
## 1.14.1 — 2026-08-04
|
|
6
66
|
|
package/README.md
CHANGED
|
@@ -147,6 +147,21 @@ risks, and vulnerability reporting: **[SECURITY.md](SECURITY.md)**.
|
|
|
147
147
|
the CLIs that use it (OpenCode, Qwen Code), and credit exhaustion (402) is
|
|
148
148
|
surfaced as a notification — there's no reset to wait for, only a top-up.
|
|
149
149
|
|
|
150
|
+
### Proxy launchers such as Headroom
|
|
151
|
+
|
|
152
|
+
`headroom wrap claude` and `headroom wrap codex` resolve and launch the real
|
|
153
|
+
agent executable directly. That bypasses Unsnooze's shell-function launcher,
|
|
154
|
+
so no same-pane monitor is attached. Claude's installed `StopFailure` hook can
|
|
155
|
+
still record stops, as can the transcript/rollout watcher while the daemon,
|
|
156
|
+
`guiWatch`, and that agent are enabled. For full pane monitoring too, configure
|
|
157
|
+
Headroom's provider-scope persistent routing, then start `claude` / `codex`
|
|
158
|
+
normally so Unsnooze remains the outer launcher. With Headroom v0.34, one
|
|
159
|
+
explicit setup is:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
headroom install apply --scope provider --providers manual --target claude --target codex
|
|
163
|
+
```
|
|
164
|
+
|
|
150
165
|
## GUI surfaces (VS Code extension, desktop apps)
|
|
151
166
|
|
|
152
167
|
Terminal sessions are watched through the shell wrapper + tmux or Zellij. Sessions in
|
|
@@ -251,7 +266,7 @@ unsnooze usage — account burn & time-to-limit (daemon: running · warnings at
|
|
|
251
266
|
|
|
252
267
|
| What you get | How |
|
|
253
268
|
|---|---|
|
|
254
|
-
| **Honest labels** | Every figure is tagged `(exact)`, `(calibrated from N stops)`, or `(estimated —
|
|
269
|
+
| **Honest labels** | Every figure is tagged `(exact)`, `(calibrated from N stops)`, or `(estimated — percentage unavailable)`. Never a bare %. |
|
|
255
270
|
| **Account-wide burn** | Sums all active sessions **and** subagents (the limit is per-account, not per-pane). Idle gaps >5 min are excluded so a quiet hour doesn't hide a fast burn. |
|
|
256
271
|
| **Time-to-wall** | ETA as a band (not a false-precision minute), cross-checked against known reset times. Weekly shown info-only — no unstable weekly ETA. |
|
|
257
272
|
| **Pre-wall warnings** | Daemon notifies at `%` bands (`usageWarnAt`, default `80,95`) **and** time tiers (30 / 10 min). Deduped once per window. |
|
|
@@ -259,9 +274,9 @@ unsnooze usage — account burn & time-to-limit (daemon: running · warnings at
|
|
|
259
274
|
|
|
260
275
|
**Provenance ladder** (why this isn't another guessed-quota dashboard):
|
|
261
276
|
|
|
262
|
-
1. **`(exact)`** — Codex
|
|
263
|
-
2. **`(calibrated from N stops)`** — Claude token burn vs a ceiling learned from **your
|
|
264
|
-
3. **`(estimated)`** — used tokens + burn shown
|
|
277
|
+
1. **`(exact)`** — Codex when a recent local rollout contains `token_count.rate_limits` (`used_percent` + epoch reset). Claude only with the opt-in statusline shim (server-authoritative `rate_limits` from [Claude Code's statusline JSON](https://code.claude.com/docs/en/statusline)).
|
|
278
|
+
2. **`(calibrated from N stops)`** — Claude 5-hour token burn vs a ceiling learned from **your matching usable calibration samples**: a stop classified as `5h`, with a nonzero local token sample from the same model pool when known, otherwise an unpooled fallback sample. A stop can still be valid for revival without meeting those calibration requirements. Not plan presets, not circular P90-of-history.
|
|
279
|
+
3. **`(estimated — percentage unavailable)`** — used tokens + burn are shown when available, but there is no exact percentage (and a Claude 5-hour row has no matching usable calibration ceiling yet).
|
|
265
280
|
|
|
266
281
|
**Honest limits:** Claude transcript sums are a **lower bound** — subscription quotas are account-pooled with claude.ai and the Desktop app. Without the statusline shim, Claude tops out at calibrated/estimated. Exact Claude % needs Pro/Max after the first API response of a session (absent after `/clear` and on API auth).
|
|
267
282
|
|
package/bin/unsnooze.js
CHANGED
|
@@ -256,10 +256,33 @@ async function main() {
|
|
|
256
256
|
}
|
|
257
257
|
// Version-skew guard: when npm swaps the package underneath us, exit
|
|
258
258
|
// cleanly so launchd/systemd restart the daemon on the fresh code.
|
|
259
|
+
// Interval stays at 15 minutes on purpose — a tighter loop raises the
|
|
260
|
+
// odds of restarting INTO a half-installed package, which is the
|
|
261
|
+
// crash-loop the unit throttles exist to survive.
|
|
262
|
+
// Exiting is only safe under a supervisor. `unsnooze daemon` run from a
|
|
263
|
+
// shell has nothing to restart it, so there it warns and keeps watching
|
|
264
|
+
// rather than ending GUI watching silently.
|
|
259
265
|
const updMod = await safeImport('../src/update-check.js');
|
|
266
|
+
const notifyMod = await safeImport('../src/notify.js');
|
|
260
267
|
if (updMod?.hasVersionSkew) {
|
|
268
|
+
let warnedSkew = false;
|
|
261
269
|
setInterval(() => {
|
|
262
|
-
|
|
270
|
+
const action = updMod.daemonSkewAction({
|
|
271
|
+
skewed: updMod.hasVersionSkew(),
|
|
272
|
+
// Unknown means assume supervised: that is how the daemon is
|
|
273
|
+
// installed for all but a handful of users, and it preserves the
|
|
274
|
+
// long-standing behavior when install.js could not be loaded.
|
|
275
|
+
supervised: installMod?.isSupervised ? installMod.isSupervised() : true,
|
|
276
|
+
alreadyWarned: warnedSkew,
|
|
277
|
+
});
|
|
278
|
+
if (action === 'restart') controller.abort();
|
|
279
|
+
else if (action === 'warn') {
|
|
280
|
+
warnedSkew = true;
|
|
281
|
+
const msg = 'package was upgraded on disk but this daemon is not supervised — '
|
|
282
|
+
+ 'still running the old code; restart it to pick up the new version';
|
|
283
|
+
loggerMod?.log('daemon', msg);
|
|
284
|
+
notifyMod?.notify('unsnooze needs a restart', msg, { priority: 4 });
|
|
285
|
+
}
|
|
263
286
|
}, 15 * 60_000).unref();
|
|
264
287
|
}
|
|
265
288
|
// Daily update check from the daemon: GUI-only users never run CLI
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unsnooze",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.3",
|
|
4
4
|
"description": "Automatically resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode, and Antigravity sessions when 5-hour or weekly usage limits reset—across tmux, Zellij, VS Code, and desktop apps.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/src/agents/claude.js
CHANGED
|
@@ -100,7 +100,9 @@ export default {
|
|
|
100
100
|
// method are unguarded.
|
|
101
101
|
contextTokens(rec) {
|
|
102
102
|
if (!rec?.cwd || !rec?.sessionId) return null; // watcher/scrape records may lack either
|
|
103
|
-
return lastUsageTokens(transcriptPath(rec.cwd, rec.sessionId
|
|
103
|
+
return lastUsageTokens(transcriptPath(rec.cwd, rec.sessionId, {
|
|
104
|
+
claudeDir: rec.env?.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CONFIG_DIR,
|
|
105
|
+
}));
|
|
104
106
|
},
|
|
105
107
|
// The foreground process for a claude session is `node` (nvm shim) or `claude`.
|
|
106
108
|
isForegroundCommand(cmd) {
|
package/src/config.js
CHANGED
|
@@ -15,7 +15,8 @@ export const RESUMER_LOCK = join(STATE_DIR, 'resumer.lock');
|
|
|
15
15
|
// High-frequency burn accumulator + warn-dedup (daemon single-writer).
|
|
16
16
|
export const USAGE_FILE = join(STATE_DIR, 'usage.json');
|
|
17
17
|
|
|
18
|
-
export const CLAUDE_DIR = process.env.UNSNOOZE_CLAUDE_DIR
|
|
18
|
+
export const CLAUDE_DIR = process.env.UNSNOOZE_CLAUDE_DIR
|
|
19
|
+
|| process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
|
19
20
|
export const CLAUDE_SETTINGS = join(CLAUDE_DIR, 'settings.json');
|
|
20
21
|
export const CODEX_DIR = process.env.UNSNOOZE_CODEX_DIR || join(homedir(), '.codex');
|
|
21
22
|
// Opt-in statusline shim drop dir for exact Claude rate_limits.
|
package/src/dashboard/data.js
CHANGED
|
@@ -14,11 +14,7 @@ import {
|
|
|
14
14
|
import { listOwnedSessions } from '../reap.js';
|
|
15
15
|
import { runDoctor } from '../doctor.js';
|
|
16
16
|
import { fetchFleet, readHosts } from '../fleet.js';
|
|
17
|
-
|
|
18
|
-
function pidAlive(pid) {
|
|
19
|
-
if (!pid) return false;
|
|
20
|
-
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
21
|
-
}
|
|
17
|
+
import { pidAlive } from '../spawn.js';
|
|
22
18
|
|
|
23
19
|
export function loadStatusSnapshot() {
|
|
24
20
|
const state = readState();
|
package/src/doctor.js
CHANGED
|
@@ -138,6 +138,7 @@ export async function runDoctor({
|
|
|
138
138
|
mux = getMultiplexer(),
|
|
139
139
|
hookInstalled = defaultHookInstalled,
|
|
140
140
|
wrappersInstalled = defaultWrappersInstalled,
|
|
141
|
+
autostartDir = null,
|
|
141
142
|
} = {}) {
|
|
142
143
|
const findings = [];
|
|
143
144
|
|
|
@@ -204,6 +205,25 @@ export async function runDoctor({
|
|
|
204
205
|
});
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
// The daemon reaches tmux through PATH, and launchd/systemd hand it a
|
|
209
|
+
// minimal one — so a unit that bakes the wrong PATH revives nothing while
|
|
210
|
+
// looking perfectly healthy from here. Lazy import: install.js imports this
|
|
211
|
+
// module, so a static import would cycle (same reason resumer.js defers
|
|
212
|
+
// prompt-queue.js).
|
|
213
|
+
try {
|
|
214
|
+
const { daemonPathBroken } = await import('./install.js');
|
|
215
|
+
const broken = daemonPathBroken({ platform, dir: autostartDir, muxBin: mux.name || 'tmux' });
|
|
216
|
+
if (broken) {
|
|
217
|
+
findings.push({
|
|
218
|
+
id: 'daemon-path', kind: 'health',
|
|
219
|
+
title: `daemon autostart cannot find ${broken.muxBin} — every revival will fail`,
|
|
220
|
+
detail: ` ${broken.unit}\n baked PATH: ${broken.path}\n`
|
|
221
|
+
+ ` run: unsnooze doctor --fix (or: unsnooze install --daemon from your shell)`,
|
|
222
|
+
fix: { action: 'heal-daemon-path', platform, dir: autostartDir },
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
} catch { /* best-effort: a PATH check must never break doctor */ }
|
|
226
|
+
|
|
207
227
|
findings.push({
|
|
208
228
|
id: 'daemon', kind: 'info',
|
|
209
229
|
title: daemonRunning() ? 'resumer/daemon: running' : 'resumer/daemon: not running (starts on the next limit stop)',
|
|
@@ -225,6 +245,9 @@ export async function applyFixes(report, {
|
|
|
225
245
|
kill = defaultKill,
|
|
226
246
|
runner = defaultRunner,
|
|
227
247
|
now = Date.now(),
|
|
248
|
+
resolvePath = undefined,
|
|
249
|
+
currentPath = undefined,
|
|
250
|
+
activate = undefined,
|
|
228
251
|
} = {}) {
|
|
229
252
|
const actions = [];
|
|
230
253
|
for (const finding of report.findings) {
|
|
@@ -244,6 +267,23 @@ export async function applyFixes(report, {
|
|
|
244
267
|
actions.push({ action: 'removed-unit', unit });
|
|
245
268
|
log(`doctor: removed csg autostart ${unit}`);
|
|
246
269
|
}
|
|
270
|
+
} else if (fix.action === 'heal-daemon-path') {
|
|
271
|
+
const { healDaemonAutostart } = await import('./install.js');
|
|
272
|
+
const healed = healDaemonAutostart({
|
|
273
|
+
platform: fix.platform, dir: fix.dir,
|
|
274
|
+
...(resolvePath ? { resolvePath } : {}),
|
|
275
|
+
...(currentPath !== undefined ? { currentPath } : {}),
|
|
276
|
+
...(activate ? { activate } : {}),
|
|
277
|
+
});
|
|
278
|
+
// healDaemonAutostart declines when it has no better PATH to offer —
|
|
279
|
+
// that refusal is the crash-loop guard, so surface it rather than
|
|
280
|
+
// claiming a repair that did not happen.
|
|
281
|
+
if (healed) {
|
|
282
|
+
actions.push({ action: 'healed-daemon-path', unit: healed });
|
|
283
|
+
log(`doctor: rewrote ${healed} with a working PATH`);
|
|
284
|
+
} else {
|
|
285
|
+
actions.push({ action: 'error', detail: 'could not determine a PATH that finds the multiplexer — run `unsnooze install --daemon` from your shell' });
|
|
286
|
+
}
|
|
247
287
|
} else if (fix.action === 'archive-dir') {
|
|
248
288
|
let target = `${fix.dir}.bak`;
|
|
249
289
|
if (existsSync(target)) target = `${fix.dir}.bak.${now}`;
|
package/src/hook.js
CHANGED
|
@@ -20,6 +20,7 @@ import { spawnResumerIfNeeded } 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';
|
|
23
|
+
import { claudeRecordEnv } from './sessions.js';
|
|
23
24
|
|
|
24
25
|
const log = makeLogger('hook');
|
|
25
26
|
|
|
@@ -48,6 +49,13 @@ export async function runHook(rest = []) {
|
|
|
48
49
|
const raw = await readStdin();
|
|
49
50
|
let payload = {};
|
|
50
51
|
try { payload = JSON.parse(raw); } catch { /* tolerate non-JSON */ }
|
|
52
|
+
// Claude includes agent_id when a hook fires inside a subagent. The parent
|
|
53
|
+
// transcript owns revival; recording the child would create a second stop
|
|
54
|
+
// with no independently resumable pane.
|
|
55
|
+
if (agent.id === 'claude' && payload.agent_id) {
|
|
56
|
+
log(`StopFailure: ignoring subagent ${payload.agent_id}; parent session owns resume`);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
51
59
|
|
|
52
60
|
const managedMux = process.env.UNSNOOZE_MUX;
|
|
53
61
|
const muxName = managedMux || (process.env.ZELLIJ_PANE_ID ? 'zellij' : 'tmux');
|
|
@@ -129,6 +137,7 @@ export async function runHook(rest = []) {
|
|
|
129
137
|
log(`calibration prepare failed: ${err.message}`);
|
|
130
138
|
}
|
|
131
139
|
// Stop record + calibration sample in ONE locked update (plan §6).
|
|
140
|
+
const recordEnv = agent.id === 'claude' ? claudeRecordEnv() : null;
|
|
132
141
|
upsertSession({
|
|
133
142
|
sessionId: sessionId || null,
|
|
134
143
|
cwd,
|
|
@@ -149,6 +158,7 @@ export async function runHook(rest = []) {
|
|
|
149
158
|
attempts: 0,
|
|
150
159
|
lastAttemptAt: null,
|
|
151
160
|
lastError: null,
|
|
161
|
+
...(recordEnv ? { env: recordEnv } : {}),
|
|
152
162
|
...(source === 'fallback' ? { probeCount: 0 } : {}),
|
|
153
163
|
}, {
|
|
154
164
|
after: calSample
|
package/src/install.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, rmSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
12
12
|
import { execFileSync } from 'node:child_process';
|
|
13
|
-
import { homedir } from 'node:os';
|
|
13
|
+
import { homedir, userInfo } from 'node:os';
|
|
14
14
|
import { join, dirname } from 'node:path';
|
|
15
15
|
import { CLAUDE_SETTINGS, STATE_DIR } from './config.js';
|
|
16
16
|
import { getConfig, configFileExists } from './settings.js';
|
|
@@ -149,6 +149,23 @@ export function installZshrcBlock(content, agents = ['claude']) {
|
|
|
149
149
|
|
|
150
150
|
export const DAEMON_LABEL = 'com.unsnooze.daemon';
|
|
151
151
|
|
|
152
|
+
// Is this process running under the supervisor we install, rather than from
|
|
153
|
+
// somebody's shell? It decides whether exiting is safe: a supervised daemon
|
|
154
|
+
// that exits on version skew comes straight back on fresh code, while an
|
|
155
|
+
// unsupervised one exits into nothing and GUI watching stops silently.
|
|
156
|
+
//
|
|
157
|
+
// Both markers are injected by the supervisor itself and were read off real
|
|
158
|
+
// processes rather than inferred: launchd sets XPC_SERVICE_NAME to the job
|
|
159
|
+
// label for its jobs (an interactive shell reports the literal '0'), and
|
|
160
|
+
// systemd sets a per-invocation INVOCATION_ID for every unit it starts.
|
|
161
|
+
// Matching the exact label, not merely "some launchd job", keeps an unrelated
|
|
162
|
+
// XPC service from being mistaken for our supervisor.
|
|
163
|
+
export function isSupervised({ platform = process.platform, env = process.env } = {}) {
|
|
164
|
+
if (platform === 'darwin') return env.XPC_SERVICE_NAME === DAEMON_LABEL;
|
|
165
|
+
if (platform === 'linux') return typeof env.INVOCATION_ID === 'string' && env.INVOCATION_ID !== '';
|
|
166
|
+
return false; // nowhere else do we install a supervisor to be run by
|
|
167
|
+
}
|
|
168
|
+
|
|
152
169
|
// Env overrides keep tests/e2e away from the real LaunchAgents / systemd dirs.
|
|
153
170
|
function autostartDir(platform) {
|
|
154
171
|
if (platform === 'darwin') {
|
|
@@ -157,6 +174,78 @@ function autostartDir(platform) {
|
|
|
157
174
|
return process.env.UNSNOOZE_SYSTEMD_USER_DIR || join(homedir(), '.config', 'systemd', 'user');
|
|
158
175
|
}
|
|
159
176
|
|
|
177
|
+
// The one place that knows what our unit file is called on each platform.
|
|
178
|
+
export function autostartUnitPath({ platform = process.platform, dir = null } = {}) {
|
|
179
|
+
const base = dir || autostartDir(platform);
|
|
180
|
+
if (platform === 'darwin') return join(base, `${DAEMON_LABEL}.plist`);
|
|
181
|
+
if (platform === 'linux') return join(base, 'unsnooze.service');
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Can this PATH string actually find `bin`? The daemon reaches tmux through
|
|
186
|
+
// PATH (`execFile('tmux', …)`), so a unit whose baked PATH fails this test
|
|
187
|
+
// cannot revive anything — every call dies with ENOENT. Pure and synchronous
|
|
188
|
+
// on purpose: this decides whether to rewrite-and-reload a unit, and that
|
|
189
|
+
// decision must be deterministic.
|
|
190
|
+
export function pathResolves(pathStr, bin) {
|
|
191
|
+
if (typeof pathStr !== 'string' || pathStr === '') return false;
|
|
192
|
+
return pathStr.split(':').some(d => d && existsSync(join(d, bin)));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function xmlUnescape(s) {
|
|
196
|
+
return s.replace(/</g, '<').replace(/>/g, '>')
|
|
197
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The PATH a unit currently bakes in, or null when it bakes none.
|
|
201
|
+
function bakedUnitPath(content, platform) {
|
|
202
|
+
if (platform === 'darwin') {
|
|
203
|
+
const m = content.match(/<key>PATH<\/key>\s*<string>([^<]*)<\/string>/);
|
|
204
|
+
return m ? xmlUnescape(m[1]) : null;
|
|
205
|
+
}
|
|
206
|
+
const m = content.match(/^Environment="PATH=(.*)"$/m);
|
|
207
|
+
return m ? m[1].replace(/%%/g, '%') : null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function defaultLoginPathRunner(cmd, args, opts) {
|
|
211
|
+
return execFileSync(cmd, args, opts);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Ask the user's login shell what PATH it builds. This exists because the only
|
|
215
|
+
// caller of the self-heal is the daemon itself, and a daemon's PATH is the
|
|
216
|
+
// launchd/systemd minimal one — the very thing being repaired. Reading
|
|
217
|
+
// process.env.PATH there just writes the breakage back.
|
|
218
|
+
//
|
|
219
|
+
// PATH is stripped from the child env so the login files construct it fresh
|
|
220
|
+
// rather than appending to the broken value we inherited. Failure returns null:
|
|
221
|
+
// a bad guess is worse than leaving the unit alone.
|
|
222
|
+
export function resolveLoginPath({
|
|
223
|
+
shell = process.env.SHELL || userInfo().shell,
|
|
224
|
+
runner = defaultLoginPathRunner,
|
|
225
|
+
timeoutMs = 3000,
|
|
226
|
+
} = {}) {
|
|
227
|
+
if (!shell) return null;
|
|
228
|
+
try {
|
|
229
|
+
const { PATH, ...env } = process.env; // eslint-disable-line no-unused-vars
|
|
230
|
+
const out = runner(shell, ['-lc', 'echo $PATH'], {
|
|
231
|
+
encoding: 'utf-8', timeout: timeoutMs, env, stdio: ['ignore', 'pipe', 'ignore'],
|
|
232
|
+
});
|
|
233
|
+
const path = String(out || '').trim();
|
|
234
|
+
return path || null;
|
|
235
|
+
} catch {
|
|
236
|
+
return null; // no shell, slow rc files, non-zero exit — all "no answer"
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The best PATH we can offer a unit: the caller's own if it already finds the
|
|
241
|
+
// binary (the `unsnooze setup`-from-a-shell case), otherwise whatever the login
|
|
242
|
+
// shell reports. null means we have nothing better to write.
|
|
243
|
+
function bestUnitPath({ currentPath, resolvePath, muxBin }) {
|
|
244
|
+
if (pathResolves(currentPath, muxBin)) return currentPath;
|
|
245
|
+
const fresh = resolvePath();
|
|
246
|
+
return fresh && pathResolves(fresh, muxBin) ? fresh : null;
|
|
247
|
+
}
|
|
248
|
+
|
|
160
249
|
export function launchdPlist({
|
|
161
250
|
nodeBin = process.execPath, unsnoozeBin = UNSNOOZE_BIN,
|
|
162
251
|
logFile = join(STATE_DIR, 'daemon.log'),
|
|
@@ -227,22 +316,31 @@ function defaultActivate(cmd, args) {
|
|
|
227
316
|
}
|
|
228
317
|
}
|
|
229
318
|
|
|
230
|
-
export function installDaemonAutostart({
|
|
319
|
+
export function installDaemonAutostart({
|
|
320
|
+
platform = process.platform, dir = null, activate = defaultActivate, path = undefined,
|
|
321
|
+
} = {}) {
|
|
322
|
+
const target = autostartUnitPath({ platform, dir });
|
|
323
|
+
if (!target) return null; // native Windows: no supported multiplexer to revive into
|
|
324
|
+
// Safety interlock. Every unit we generate carries the SAME label, so running
|
|
325
|
+
// the real launchctl/systemctl against a unit written somewhere else (a test
|
|
326
|
+
// fixture, a staging copy) does not create a second job — it HIJACKS the
|
|
327
|
+
// user's live one, leaving it pointed at a directory that may not outlive the
|
|
328
|
+
// caller. So the real activator only ever runs for the unit path this
|
|
329
|
+
// platform actually uses; an injected activator is always honored, since a
|
|
330
|
+
// caller that supplied one has said what it wants to happen.
|
|
331
|
+
const act = (activate === defaultActivate && target !== autostartUnitPath({ platform }))
|
|
332
|
+
? () => true
|
|
333
|
+
: activate;
|
|
231
334
|
if (platform === 'darwin') {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
activate('launchctl', ['load', '-w', target]);
|
|
335
|
+
atomicWrite(target, launchdPlist(path ? { path } : {}));
|
|
336
|
+
act('launchctl', ['unload', target]); // reload cleanly if already loaded
|
|
337
|
+
act('launchctl', ['load', '-w', target]);
|
|
236
338
|
return target;
|
|
237
339
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
activate('systemctl', ['--user', 'enable', '--now', 'unsnooze.service']);
|
|
243
|
-
return target;
|
|
244
|
-
}
|
|
245
|
-
return null; // native Windows: no supported multiplexer to revive into
|
|
340
|
+
atomicWrite(target, systemdUnit(path ? { path } : {}));
|
|
341
|
+
act('systemctl', ['--user', 'daemon-reload']);
|
|
342
|
+
act('systemctl', ['--user', 'enable', '--now', 'unsnooze.service']);
|
|
343
|
+
return target;
|
|
246
344
|
}
|
|
247
345
|
|
|
248
346
|
// One-time self-heal for units written before 1.12.0: they carry no PATH, so
|
|
@@ -251,20 +349,53 @@ export function installDaemonAutostart({ platform = process.platform, dir = null
|
|
|
251
349
|
// so the daemon repairs it on startup: regenerate + reload. Reloading kills
|
|
252
350
|
// the calling daemon — by design; the supervisor restarts it under the fixed
|
|
253
351
|
// unit. Returns the healed target, or null when nothing needed healing.
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
352
|
+
// Does an installed unit bake a PATH that cannot reach the multiplexer? This
|
|
353
|
+
// is invisible from a user's shell — `doctor` finds tmux on the rich PATH it
|
|
354
|
+
// inherited and calls the install healthy — so the daemon's OWN baked PATH has
|
|
355
|
+
// to be inspected directly. Returns null when there is nothing to report
|
|
356
|
+
// (no unit, no baked PATH, or a PATH that works).
|
|
357
|
+
export function daemonPathBroken({ platform = process.platform, dir = null, muxBin = 'tmux' } = {}) {
|
|
358
|
+
const unit = autostartUnitPath({ platform, dir });
|
|
359
|
+
if (!unit || !existsSync(unit)) return null; // not a daemon-autostart user
|
|
360
|
+
let content;
|
|
361
|
+
try { content = readFileSync(unit, 'utf-8'); } catch { return null; }
|
|
362
|
+
const baked = bakedUnitPath(content, platform);
|
|
363
|
+
if (baked == null || pathResolves(baked, muxBin)) return null;
|
|
364
|
+
return { unit, path: baked, muxBin };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function healDaemonAutostart({
|
|
368
|
+
platform = process.platform, dir = null, activate = defaultActivate,
|
|
369
|
+
resolvePath = resolveLoginPath, muxBin = 'tmux',
|
|
370
|
+
currentPath = process.env.PATH || '',
|
|
371
|
+
} = {}) {
|
|
372
|
+
const target = autostartUnitPath({ platform, dir });
|
|
261
373
|
if (!target || !existsSync(target)) return null; // not a daemon-autostart user
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
374
|
+
let content;
|
|
375
|
+
try { content = readFileSync(target, 'utf-8'); } catch { return null; }
|
|
376
|
+
|
|
377
|
+
const marker = platform === 'darwin' ? 'EnvironmentVariables' : 'Environment="PATH=';
|
|
378
|
+
const better = bestUnitPath({ currentPath, resolvePath, muxBin });
|
|
379
|
+
|
|
380
|
+
// Pre-1.12 unit: no PATH at all. Always worth healing — but prefer a PATH
|
|
381
|
+
// that actually works over the caller's, which is minimal when (as always)
|
|
382
|
+
// the caller is the daemon.
|
|
383
|
+
if (!content.includes(marker)) {
|
|
384
|
+
return installDaemonAutostart({ platform, dir, activate, path: better || currentPath });
|
|
266
385
|
}
|
|
267
|
-
|
|
386
|
+
|
|
387
|
+
// Present but useless. This is the trap the original heal fell into: run
|
|
388
|
+
// from the daemon, it baked the daemon's own tmux-less PATH back in, and the
|
|
389
|
+
// marker being present then made it "already current" forever.
|
|
390
|
+
const baked = bakedUnitPath(content, platform);
|
|
391
|
+
if (baked == null || pathResolves(baked, muxBin)) return null; // genuinely fine
|
|
392
|
+
|
|
393
|
+
// CRASH-LOOP GUARD. Healing reloads the unit, which kills this daemon; the
|
|
394
|
+
// supervisor restarts it and we arrive here again. Rewriting without a
|
|
395
|
+
// strictly better PATH would repeat every 30s forever, which is worse than
|
|
396
|
+
// the bug. No improvement available → leave it exactly as it is.
|
|
397
|
+
if (!better || better === baked) return null;
|
|
398
|
+
return installDaemonAutostart({ platform, dir, activate, path: better });
|
|
268
399
|
}
|
|
269
400
|
|
|
270
401
|
export function uninstallDaemonAutostart({ platform = process.platform, dir = null, activate = defaultActivate } = {}) {
|
package/src/launcher.js
CHANGED
|
@@ -8,7 +8,7 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
8
8
|
import { getMultiplexer } from './multiplexer.js';
|
|
9
9
|
import { getAgent } from './agents/index.js';
|
|
10
10
|
import { getConfig } from './settings.js';
|
|
11
|
-
import { spawnDetached } from './spawn.js';
|
|
11
|
+
import { spawnDetached, monitorSpawnArgs } from './spawn.js';
|
|
12
12
|
import { makeLogger } from './logger.js';
|
|
13
13
|
import { createLeaseId, processBirth, writeLease, removeLease } from './lease.js';
|
|
14
14
|
import { SessionCreateError } from './multiplexers/session-name.js';
|
|
@@ -79,7 +79,8 @@ export function runLauncher(args, agentId = 'claude') {
|
|
|
79
79
|
if (typeof mux.stampPaneOwner === 'function') {
|
|
80
80
|
mux.stampPaneOwner(pane, leaseId).catch(() => { /* legacy tmux */ });
|
|
81
81
|
}
|
|
82
|
-
spawnDetached(
|
|
82
|
+
spawnDetached(
|
|
83
|
+
monitorSpawnArgs({ muxName: mux.name, paneOwner, pane, agentId: agent.id, leaseId }),
|
|
83
84
|
{ UNSNOOZE_CWD: process.cwd() });
|
|
84
85
|
log(`launching ${agent.id} in ${mux.name} ${paneOwner ?? '-'}:${pane}, monitor spawned`);
|
|
85
86
|
} else {
|