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 CHANGED
@@ -1,6 +1,66 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
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 — calibrating…)`. Never a bare %. |
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 always (local `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)).
263
- 2. **`(calibrated from N stops)`** — Claude token burn vs a ceiling learned from **your** limit-stop ledger (the same stops unsnooze already records for resume). Not plan presets, not circular P90-of-history.
264
- 3. **`(estimated)`** — used tokens + burn shown; ceiling unknown until the first observed stop.
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
- if (updMod.hasVersionSkew()) controller.abort();
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.1",
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",
@@ -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 || join(homedir(), '.claude');
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.
@@ -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(/&lt;/g, '<').replace(/&gt;/g, '>')
197
+ .replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/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({ platform = process.platform, dir = null, activate = defaultActivate } = {}) {
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
- const target = join(dir || autostartDir(platform), `${DAEMON_LABEL}.plist`);
233
- atomicWrite(target, launchdPlist());
234
- activate('launchctl', ['unload', target]); // reload cleanly if already loaded
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
- if (platform === 'linux') {
239
- const target = join(dir || autostartDir(platform), 'unsnooze.service');
240
- atomicWrite(target, systemdUnit());
241
- activate('systemctl', ['--user', 'daemon-reload']);
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
- export function healDaemonAutostart({ platform = process.platform, dir = null, activate = defaultActivate } = {}) {
255
- const marker = platform === 'darwin' ? 'EnvironmentVariables' : 'Environment="PATH=';
256
- const target = platform === 'darwin'
257
- ? join(dir || autostartDir(platform), `${DAEMON_LABEL}.plist`)
258
- : platform === 'linux'
259
- ? join(dir || autostartDir(platform), 'unsnooze.service')
260
- : null;
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
- try {
263
- if (readFileSync(target, 'utf-8').includes(marker)) return null; // already current
264
- } catch {
265
- return null; // unreadable — leave it alone
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
- return installDaemonAutostart({ platform, dir, activate });
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(['_monitor', mux.name, paneOwner || '', pane, agent.id, leaseId],
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 {