unsnooze 1.18.0 → 1.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/fish.js ADDED
@@ -0,0 +1,22 @@
1
+ // Where the fish shell looks for its config.
2
+ //
3
+ // A leaf module (node builtins only) on purpose — the same reason
4
+ // powershell.js exists: install.js already imports doctor.js, and doctor.js
5
+ // needs this to answer "are the wrappers installed?" for fish users.
6
+ // Declaring it in install.js would close that loop (see
7
+ // test/module-graph.test.js for what that breaks).
8
+ //
9
+ // fish reads its config from <config-home>/fish/config.fish, where
10
+ // <config-home> honors XDG_CONFIG_HOME (absolute paths only — the XDG spec
11
+ // says a relative value must be ignored, and fish follows that).
12
+
13
+ import { homedir } from 'node:os';
14
+ import { join, isAbsolute } from 'node:path';
15
+
16
+ export function fishConfigPath({ env = process.env, home = homedir() } = {}) {
17
+ // Test/CI override, matching UNSNOOZE_QWEN_DIR and the autostart dir vars.
18
+ if (env.UNSNOOZE_FISH_CONFIG) return env.UNSNOOZE_FISH_CONFIG;
19
+ const xdg = env.XDG_CONFIG_HOME;
20
+ if (xdg && isAbsolute(xdg)) return join(xdg, 'fish', 'config.fish');
21
+ return join(home, '.config', 'fish', 'config.fish');
22
+ }
package/src/install.js CHANGED
@@ -5,14 +5,17 @@
5
5
  // atomic write)
6
6
  // - ~/.zshrc + ~/.bashrc: one fence-marked block with a wrapper function per
7
7
  // enabled agent (claude/codex/grok), routed through `unsnooze _run`
8
+ // - ~/.config/fish/config.fish: the same block in fish syntax (fish shares
9
+ // no syntax with POSIX rc files — a bash function there is a parse error)
8
10
  // - ~/.grok/hooks/unsnooze.json when the grok agent is enabled
9
- // --settings <path> / --zshrc <path> override targets (used by tests).
11
+ // --settings <path> / --zshrc <path> / --fishrc <path> override targets (used
12
+ // by tests).
10
13
 
11
14
  import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, rmSync, mkdirSync, unlinkSync } from 'node:fs';
12
15
  import { execFileSync } from 'node:child_process';
13
16
  import { homedir, userInfo } from 'node:os';
14
17
  import { join, dirname, delimiter } from 'node:path';
15
- import { CLAUDE_SETTINGS, STATE_DIR } from './config.js';
18
+ import { CLAUDE_SETTINGS, STATE_DIR, WINDOWS_TASK_NAME } from './config.js';
16
19
  import { getConfig, configFileExists } from './settings.js';
17
20
  import { xmlEscape } from './notify.js';
18
21
  import { installGrokHooks, uninstallGrokHooks } from './agents/grok.js';
@@ -21,6 +24,7 @@ import { findCsgProcesses, findCsgAutostarts } from './doctor.js';
21
24
  import { UNSNOOZE_BIN, stopResumer } from './spawn.js';
22
25
  import { uninstallStatuslineShim } from './usage.js';
23
26
  import { powershellProfilePath } from './powershell.js';
27
+ import { fishConfigPath } from './fish.js';
24
28
 
25
29
  const FENCE_OPEN = '# >>> unsnooze >>>';
26
30
  const FENCE_CLOSE = '# <<< unsnooze <<<';
@@ -33,13 +37,17 @@ const LEGACY_FENCES = [
33
37
  const OLD_FENCE_OPEN = LEGACY_FENCES[0].open;
34
38
 
35
39
  function parseArgs(rest) {
36
- const opts = { yes: false, settings: CLAUDE_SETTINGS, zshrc: join(homedir(), '.zshrc'), purge: false, daemon: false };
40
+ const opts = {
41
+ yes: false, settings: CLAUDE_SETTINGS, zshrc: join(homedir(), '.zshrc'),
42
+ fishrc: fishConfigPath(), purge: false, daemon: false,
43
+ };
37
44
  for (let i = 0; i < rest.length; i++) {
38
45
  if (rest[i] === '--yes' || rest[i] === '-y') opts.yes = true;
39
46
  else if (rest[i] === '--purge') opts.purge = true;
40
47
  else if (rest[i] === '--daemon') opts.daemon = true;
41
48
  else if (rest[i] === '--settings') opts.settings = rest[++i];
42
49
  else if (rest[i] === '--zshrc') opts.zshrc = rest[++i];
50
+ else if (rest[i] === '--fishrc') opts.fishrc = rest[++i];
43
51
  }
44
52
  return opts;
45
53
  }
@@ -204,6 +212,38 @@ export function installZshrcBlock(content, agents = ['claude']) {
204
212
  return { content: result, oldRemoved };
205
213
  }
206
214
 
215
+ // The fish twin of wrapperBlock(). Fish shares no syntax with the POSIX block:
216
+ // `name() { … }` is a parse error there, `$?` is `$status`, and `"${X}"` is
217
+ // `"${X}"` verbatim text — so the wrapper gets its own body. Same semantics as
218
+ // the zsh/bash wrapper: a recursion guard (UNSNOOZE_ACTIVE), the load-bearing
219
+ // missing-file fallback to the real CLI, and `_run <id>` routing.
220
+ //
221
+ // Redefining a function replaces any earlier one, which covers fish "aliases"
222
+ // (they are functions too) — no `unalias` analogue is needed. Fish comments
223
+ // are `#`, so the same fence markers work and stripFencedBlock() removes it
224
+ // unchanged.
225
+ export function fishWrapperBlock(agents = ['claude'], bin = UNSNOOZE_BIN) {
226
+ const fns = agents.flatMap(id => wrapperNamesFor(id).map(name => `function ${name} --description 'unsnooze-wrapped ${name}'
227
+ if test "$UNSNOOZE_ACTIVE" = "1"; or not test -f '${bin}'
228
+ command ${name} $argv
229
+ return $status
230
+ end
231
+ node '${bin}' _run ${id} $argv
232
+ end`)).join('\n');
233
+ return `${FENCE_OPEN}
234
+ # unsnooze wrappers: route every interactive launch of the CLIs below through
235
+ # unsnooze so limit stops are recorded and auto-resumed.
236
+ ${fns}
237
+ ${FENCE_CLOSE}`;
238
+ }
239
+
240
+ // installZshrcBlock's fish twin. Same fence, same replace-don't-append
241
+ // behaviour, so re-running setup never stacks a second copy of the wrappers.
242
+ export function installFishBlock(content, agents = ['claude'], bin = UNSNOOZE_BIN) {
243
+ const { content: cleaned } = stripFencedBlock(content, FENCE_OPEN, FENCE_CLOSE);
244
+ return cleaned.replace(/\n+$/, '\n') + '\n' + fishWrapperBlock(agents, bin) + '\n';
245
+ }
246
+
207
247
  // --- daemon autostart ---
208
248
  // GUI sessions (VS Code extension, desktop apps) never pass through the shell
209
249
  // wrappers, so their limit stops are only caught while `unsnooze daemon` is
@@ -213,7 +253,7 @@ export function installZshrcBlock(content, agents = ['claude']) {
213
253
  export const DAEMON_LABEL = 'com.unsnooze.daemon';
214
254
  // Task Scheduler has no reverse-DNS convention and shows this name to the user
215
255
  // in taskschd.msc, so it is a plain word rather than the launchd label.
216
- export const WINDOWS_TASK_NAME = 'unsnooze';
256
+ export { WINDOWS_TASK_NAME };
217
257
 
218
258
  // Is this process running under the supervisor we install, rather than from
219
259
  // somebody's shell? It decides whether exiting is safe: a supervised daemon
@@ -555,6 +595,22 @@ function rcTargets(opts, explicit) {
555
595
  return existing.length > 0 ? existing : [opts.zshrc];
556
596
  }
557
597
 
598
+ // The fish config is a separate target from the POSIX rc loop: fish syntax
599
+ // shares nothing with bash, so it gets its own block, and fish reads only
600
+ // <config-home>/fish/config.fish. Touch it when the file already exists (the
601
+ // same any-rc rule as zsh/bash), or when fish is the login shell — a fish
602
+ // user may not have created config.fish yet, and a wrapper that would never
603
+ // load is the same silent-miss as a profile written to a guessed path. Empty
604
+ // list = no evidence of fish on this machine; leave it alone.
605
+ function fishTargets(opts, explicit) {
606
+ if (explicit) return [opts.fishrc];
607
+ const cfg = fishConfigPath();
608
+ if (existsSync(cfg)) return [cfg];
609
+ const loginShell = process.env.SHELL || userInfo().shell;
610
+ if (typeof loginShell === 'string' && /(^|\/|\\)fish$/.test(loginShell)) return [cfg];
611
+ return [];
612
+ }
613
+
558
614
  export function cmdInstall(rest, { agents = enabledAgents() } = {}) {
559
615
  const opts = parseArgs(rest);
560
616
  const explicitRc = rest.includes('--zshrc');
@@ -623,6 +679,21 @@ export function cmdInstall(rest, { agents = enabledAgents() } = {}) {
623
679
  }
624
680
  }
625
681
 
682
+ // 3c. Fish config. The POSIX loop above is a parse error in fish, and the
683
+ // PowerShell profile never loads there — fish needs its own block or a
684
+ // fish user's `claude` never routes through unsnooze at all.
685
+ for (const cfg of fishTargets(opts, rest.includes('--fishrc'))) {
686
+ try {
687
+ mkdirSync(dirname(cfg), { recursive: true });
688
+ const before = existsSync(cfg) ? readFileSync(cfg, 'utf-8') : '';
689
+ if (existsSync(cfg)) backupOnce(cfg);
690
+ atomicWrite(cfg, installFishBlock(before, agents, UNSNOOZE_BIN));
691
+ console.log(`unsnooze: fish wrappers (${agents.join(', ')}) installed in ${cfg}`);
692
+ } catch (err) {
693
+ console.log(`unsnooze: could not write the fish config (${err.message})`);
694
+ }
695
+ }
696
+
626
697
  // 4. Daemon autostart (GUI-session watching), opt-in via --daemon / wizard.
627
698
  if (opts.daemon) {
628
699
  const target = installDaemonAutostart();
@@ -698,6 +769,17 @@ export function cmdUninstall(rest) {
698
769
  console.log(`unsnooze: could not clean the PowerShell profile (${err.message})`);
699
770
  }
700
771
 
772
+ // The fish config is a wrapper site too, same reason as the PowerShell
773
+ // profile above: a stale block would keep shadowing the real CLI.
774
+ for (const cfg of fishTargets(opts, rest.includes('--fishrc'))) {
775
+ if (!existsSync(cfg)) continue;
776
+ const { content, found } = stripFencedBlock(readFileSync(cfg, 'utf-8'), FENCE_OPEN, FENCE_CLOSE);
777
+ if (found) {
778
+ atomicWrite(cfg, content);
779
+ console.log(`unsnooze: fish wrappers removed from ${cfg}`);
780
+ }
781
+ }
782
+
701
783
  const autostart = uninstallDaemonAutostart();
702
784
  if (autostart) console.log(`unsnooze: daemon autostart removed (${autostart})`);
703
785
 
package/src/launcher.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // - outside a multiplexer: re-exec through its launchWrapped operation
3
3
  // - inside one: spawn a detached per-pane monitor, then run the CLI,
4
4
  // propagating its exit code
5
- // - -p/--print: pure pass-through, no monitor (nothing interactive to scrape)
5
+ // - -p/--print, -h/--help, -v/-V/--version: pure pass-through, no monitor (nothing interactive to scrape)
6
6
 
7
7
  import { spawn, spawnSync } from 'node:child_process';
8
8
  import { getMultiplexer } from './multiplexer.js';
@@ -16,8 +16,12 @@ import { AgentDispatchedError, SessionCreateError, attachHint } from './multiple
16
16
 
17
17
  const log = makeLogger('launcher');
18
18
 
19
- function isPrintMode(args) {
20
- return args.includes('-p') || args.includes('--print');
19
+ export function isPassthrough(args = []) {
20
+ return args.some(a =>
21
+ a === '-p' || a === '--print'
22
+ || a === '-h' || a === '--help'
23
+ || a === '-v' || a === '-V' || a === '--version'
24
+ );
21
25
  }
22
26
 
23
27
  export function resolvePaneOwner(muxName, env = process.env) {
@@ -34,7 +38,14 @@ export function resolvePaneOwner(muxName, env = process.env) {
34
38
 
35
39
  function runUnwatched(agent, args, reason) {
36
40
  if (reason) process.stderr.write(`unsnooze: ${reason}\n`);
41
+ return runPassthrough(agent, args);
42
+ }
43
+
44
+ // spawnSync reports a binary it could not start as r.error with no status;
45
+ // `r.status ?? 1` alone turned `spawn codex ENOENT` into a silent exit 1.
46
+ function runPassthrough(agent, args) {
37
47
  const r = spawnSync(agent.bin, args, { stdio: 'inherit', env: { ...process.env, UNSNOOZE_ACTIVE: '1' } });
48
+ if (r.error) return launchFailed(agent, r.error);
38
49
  return r.status ?? 1;
39
50
  }
40
51
 
@@ -44,9 +55,13 @@ export function runLauncher(args, agentId = 'claude', { processBirthFn = process
44
55
  // Recursion / nested-launch guard: inside an unsnooze-managed session, a
45
56
  // plain `claude`/`codex`/`unsnooze` call goes straight through. Same for an
46
57
  // agent the user disabled in settings — run it, don't watch it.
47
- if (process.env.UNSNOOZE_ACTIVE === '1' || isPrintMode(args) || !getConfig(`agents.${agent.id}`)) {
48
- const r = spawnSync(agent.bin, args, { stdio: 'inherit', env: { ...process.env, UNSNOOZE_ACTIVE: '1' } });
49
- return r.status ?? 1;
58
+ //
59
+ // Non-interactive / informational invocations (-p/--print, -h/--help, -v/--version)
60
+ // also bypass multiplexer wrapping and monitoring — running a new tmux session
61
+ // or spawning a watcher for `claude --help` flashes the screen and slows down
62
+ // simple queries.
63
+ if (process.env.UNSNOOZE_ACTIVE === '1' || isPassthrough(args) || !getConfig(`agents.${agent.id}`)) {
64
+ return runPassthrough(agent, args);
50
65
  }
51
66
 
52
67
  // Flags the user wants on every session they start (claude's --autocompact
@@ -157,10 +172,17 @@ export function runLauncher(args, agentId = 'claude', { processBirthFn = process
157
172
  UNSNOOZE_PANE: pane || '', UNSNOOZE_PANE_OWNER: paneOwner || '',
158
173
  UNSNOOZE_LEASE_ID: leaseId,
159
174
  };
160
- const child = spawn(agent.bin, args, {
161
- stdio: 'inherit',
162
- env: childEnv,
163
- });
175
+ // The bin can be a path we resolved ourselves (a Windows .cmd shim, say).
176
+ // Node ≥ 20.12 refuses to spawn .bat/.cmd without a shell, and depending on
177
+ // the version that is a synchronous throw rather than an 'error' event —
178
+ // either way it has to come out as the same "failed to launch" line, with
179
+ // the reason, never as a crash with no exit code for the resumer to read.
180
+ let child;
181
+ try {
182
+ child = spawn(agent.bin, args, { stdio: 'inherit', env: childEnv });
183
+ } catch (err) {
184
+ return launchFailed(agent, err);
185
+ }
164
186
  const lease = pane && child.pid ? {
165
187
  leaseId, mux: mux.name, paneOwner, pane, agent: agent.id,
166
188
  pid: child.pid, pidBirth: processBirthFn(child.pid),
@@ -177,8 +199,19 @@ export function runLauncher(args, agentId = 'claude', { processBirthFn = process
177
199
  child.on('exit', code => { cleanup(); resolve(code ?? 1); });
178
200
  child.on('error', err => {
179
201
  cleanup();
180
- process.stderr.write(`unsnooze: failed to launch ${agent.bin}: ${err.message}\n`);
181
- resolve(127);
202
+ resolve(launchFailed(agent, err));
182
203
  });
183
204
  });
184
205
  }
206
+
207
+ // One place for the message, because the resumer reads it back out of the
208
+ // headless log when a revival dies (#25). 127 like a shell's "command not
209
+ // found"; the hint names the one misconfiguration that produces EINVAL here.
210
+ function launchFailed(agent, err) {
211
+ process.stderr.write(`unsnooze: failed to launch ${agent.bin}: ${err.message}\n`);
212
+ if (/\.(cmd|bat)$/i.test(String(agent.bin))) {
213
+ process.stderr.write(`unsnooze: ${agent.bin} is a .cmd/.bat shim, which cannot be launched directly — `
214
+ + `point UNSNOOZE_${agent.id.toUpperCase()}_BIN at the .exe\n`);
215
+ }
216
+ return 127;
217
+ }
@@ -18,7 +18,10 @@
18
18
  // session sweep skips headless rather than claiming to own anything.
19
19
 
20
20
  import { spawn } from 'node:child_process';
21
- import { mkdirSync, openSync } from 'node:fs';
21
+ import {
22
+ mkdirSync, openSync, closeSync, readSync, statSync, readFileSync, writeFileSync,
23
+ renameSync, unlinkSync, readdirSync,
24
+ } from 'node:fs';
22
25
  import { join } from 'node:path';
23
26
 
24
27
  import { HEADLESS_LOG_DIR } from '../config.js';
@@ -27,6 +30,12 @@ export const SUBMIT_DELAY_MS = 0; // nothing is ever typed
27
30
 
28
31
  const PID_PREFIX = 'pid:';
29
32
 
33
+ // How long a recorded exit stays on disk. A revival is verified ~20s after
34
+ // launch and retried within the hour; a day covers a daemon that was asleep.
35
+ const EXIT_TTL_MS = 24 * 3_600_000;
36
+ // Enough of the child's own output to say why it died, never the whole log.
37
+ const EXIT_OUTPUT_BYTES = 4096;
38
+
30
39
  export function parsePidAddress(pane) {
31
40
  if (typeof pane !== 'string' || !pane.startsWith(PID_PREFIX)) return null;
32
41
  const pid = Number.parseInt(pane.slice(PID_PREFIX.length), 10);
@@ -60,6 +69,64 @@ export function createHeadless({
60
69
  env = process.env,
61
70
  platform = process.platform,
62
71
  } = {}) {
72
+ const exitsDir = join(logDir, 'exits');
73
+ const exitPath = pid => join(exitsDir, `${pid}.json`);
74
+
75
+ function writeExit(pid, record) {
76
+ try {
77
+ mkdirSync(exitsDir, { recursive: true, mode: 0o700 });
78
+ const tmp = join(exitsDir, `.${pid}.${process.pid}.tmp`);
79
+ writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 });
80
+ renameSync(tmp, exitPath(pid));
81
+ } catch { /* evidence only — a revival must never fail on bookkeeping */ }
82
+ }
83
+
84
+ function readExit(pid) {
85
+ try {
86
+ const parsed = JSON.parse(readFileSync(exitPath(pid), 'utf-8'));
87
+ return parsed && typeof parsed === 'object' && parsed.pid === pid ? parsed : null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ function pruneExits(now = Date.now()) {
94
+ let names;
95
+ try { names = readdirSync(exitsDir); } catch { return; }
96
+ for (const name of names) {
97
+ if (!name.endsWith('.json')) continue;
98
+ const path = join(exitsDir, name);
99
+ try {
100
+ if (now - statSync(path).mtimeMs > EXIT_TTL_MS) unlinkSync(path);
101
+ } catch { /* raced or gone */ }
102
+ }
103
+ }
104
+
105
+ // The last lines the child wrote to the shared log, read between the offsets
106
+ // recorded at its launch and at its exit. The upper bound matters: the
107
+ // resumer staggers revivals 8s apart and verifies 20s later, so a revival
108
+ // that died at once is read back after the next one has written its own
109
+ // lines into the same log. (One running alongside it can still interleave.)
110
+ function childOutput(exit) {
111
+ if (!exit.log || !Number.isFinite(exit.from)) return null;
112
+ let fd;
113
+ try {
114
+ const { size } = statSync(exit.log);
115
+ const end = Number.isFinite(exit.to) ? Math.min(exit.to, size) : size;
116
+ const start = Math.max(exit.from, end - EXIT_OUTPUT_BYTES);
117
+ if (end <= start) return null;
118
+ fd = openSync(exit.log, 'r');
119
+ const buf = Buffer.alloc(end - start);
120
+ const n = readSync(fd, buf, 0, buf.length, start);
121
+ const lines = buf.toString('utf-8', 0, n).split(/\r?\n/).map(l => l.trim()).filter(Boolean);
122
+ return lines.length ? lines.slice(-3).join(' | ') : null;
123
+ } catch {
124
+ return null;
125
+ } finally {
126
+ if (fd !== undefined) closeSync(fd);
127
+ }
128
+ }
129
+
63
130
  const backend = {
64
131
  name: 'headless',
65
132
  SUBMIT_DELAY_MS,
@@ -95,24 +162,105 @@ export function createHeadless({
95
162
 
96
163
  // A revive is just a detached child. Its output goes to a per-session log
97
164
  // because there is no scrollback to read it out of later.
165
+ //
166
+ // The child's exit is recorded next to that log (exits/<pid>.json). With no
167
+ // pane to capture, an empty capture proves nothing — verifyOne used to read
168
+ // it as a cleared banner and mark a launcher that had died with `spawn
169
+ // codex ENOENT` as resumed (#25). The exit record, plus the slice of the
170
+ // log the child wrote, is what paneOutcome() answers with instead.
98
171
  async newWindow(sessionName, cwd, launchSpec) {
99
172
  // 0700/0600: this log is the entire stdout+stderr of an unattended
100
173
  // agent run — the most revealing thing under the state dir.
101
174
  mkdirSync(logDir, { recursive: true, mode: 0o700 });
102
175
  const logPath = join(logDir, `${sessionName}.log`);
103
176
  const fd = openSync(logPath, 'a', 0o600);
104
- const child = spawner(launchSpec.file, launchSpec.args || [], {
105
- cwd,
106
- detached: true,
107
- stdio: ['ignore', fd, fd],
108
- env: { ...env, ...launchSpec.env },
109
- windowsHide: true,
110
- });
177
+ // The log is shared by every revival into this session name, so remember
178
+ // where this child's output starts.
179
+ let from = 0;
180
+ try { from = statSync(logPath).size; } catch { /* just created */ }
181
+ // The child gets this process's environment — the one backend that
182
+ // passes it through. A resumer spawned by the StopFailure hook runs in
183
+ // claude's, which carries UNSNOOZE_ACTIVE=1: the launcher's "nested
184
+ // call, pass straight through" marker. A revival is a fresh top-level
185
+ // launch; with the marker it dropped launchExtraArgs and, on a spawn
186
+ // failure, exited 1 without saying why.
187
+ const childEnv = { ...env, ...launchSpec.env };
188
+ delete childEnv.UNSNOOZE_ACTIVE;
189
+ let child;
190
+ try {
191
+ child = spawner(launchSpec.file, launchSpec.args || [], {
192
+ cwd,
193
+ detached: true,
194
+ stdio: ['ignore', fd, fd],
195
+ env: childEnv,
196
+ windowsHide: true,
197
+ });
198
+ } finally {
199
+ // The child has its own copy; the daemon's would otherwise stay open
200
+ // for the life of the process, one descriptor per revival.
201
+ try { closeSync(fd); } catch { /* already closed */ }
202
+ }
203
+ const pid = child?.pid;
204
+ const startedAt = Date.now();
205
+ let spawnError = null;
206
+ // Listen before anything below can throw. A spawn that fails — the
207
+ // session's cwd was a worktree that has since been deleted, say — says
208
+ // so with an 'error' event after spawn() has returned, and an 'error'
209
+ // nobody listens for is an uncaught exception: the daemon dies with it.
210
+ if (typeof child?.on === 'function') {
211
+ const record = (code, signal, error) => {
212
+ if (!pid) return;
213
+ // Where this child's output ends in the shared log (childOutput).
214
+ let to = null;
215
+ try { to = statSync(logPath).size; } catch { /* gone */ }
216
+ writeExit(pid, {
217
+ pid, code, signal: signal ?? null, error: error ?? null,
218
+ startedAt, at: Date.now(), log: logPath, from, to,
219
+ });
220
+ };
221
+ child.on('exit', (code, signal) => record(code, signal, null));
222
+ child.on('error', err => {
223
+ spawnError = err;
224
+ record(null, null, err?.message || String(err));
225
+ });
226
+ }
111
227
  if (typeof child?.unref === 'function') child.unref();
112
- if (!child?.pid) {
113
- throw new Error(`unsnooze: headless launch of ${launchSpec.file} produced no pid`);
228
+ if (!pid) {
229
+ // The reason arrives on the next tick; wait for it so the record's
230
+ // lastError says why, not just that there was no pid.
231
+ await new Promise(resolve => setImmediate(resolve));
232
+ throw new Error(`unsnooze: headless launch of ${launchSpec.file} produced no pid`
233
+ + (spawnError ? ` (${spawnError.message})` : ''));
114
234
  }
115
- return { pane: `${PID_PREFIX}${child.pid}`, paneOwner: null, session: sessionName };
235
+ // A recycled pid must not inherit an old exit. Best-effort, like the
236
+ // pruning: an exit record is evidence, never something a launch needs.
237
+ // (No event can have fired yet — nothing above has yielded since spawn.)
238
+ try { unlinkSync(exitPath(pid)); } catch { /* none */ }
239
+ pruneExits();
240
+ return { pane: `${PID_PREFIX}${pid}`, paneOwner: null, session: sessionName };
241
+ },
242
+
243
+ // What became of a revival: { exited: false } while it runs, and once it is
244
+ // gone { exited: true, code, signal, output } from the recorded exit, where
245
+ // output is the tail of what the child itself wrote. null when the process
246
+ // is gone and nothing was recorded (a resumer that was not the parent) —
247
+ // the caller then knows exactly as much as it did before.
248
+ async paneOutcome(pane) {
249
+ const pid = parsePidAddress(pane);
250
+ if (pid === null) return null;
251
+ // The record first: newWindow clears any old one for this pid at spawn,
252
+ // so a record here is our child's exit — even if the pid has since been
253
+ // handed to another process (Windows recycles them quickly), which a
254
+ // liveness check alone would read as our revival still running.
255
+ const exit = readExit(pid);
256
+ if (!exit) return alive(pid) ? { exited: false } : null;
257
+ return {
258
+ exited: true,
259
+ code: exit.code ?? null,
260
+ signal: exit.signal ?? null,
261
+ error: exit.error ?? null,
262
+ output: childOutput(exit),
263
+ };
116
264
  },
117
265
 
118
266
  // Nothing to wrap into: the user's own terminal is the session. Returning
package/src/resumer.js CHANGED
@@ -9,7 +9,7 @@ import { homedir } from 'node:os';
9
9
  import { spawnSync } from 'node:child_process';
10
10
  import { getMultiplexer, backendCanType } from './multiplexer.js';
11
11
  import {
12
- RESUMER_LOCK, POLL_INTERVAL_MS, STAGGER_MS, VERIFY_DELAY_MS, ensureStateDir,
12
+ RESUMER_LOCK, POLL_INTERVAL_MS, STAGGER_MS, VERIFY_DELAY_MS, HEADLESS_SETTLE_MS, ensureStateDir,
13
13
  BUSY_DEFER_MS, MAX_BUSY_DEFERS, MAX_RESUME_ATTEMPTS, READY_TIMEOUT_MS,
14
14
  CAPTURE_LINES, PANE_SCAN_LINES, RESUME_SESSION_NAME,
15
15
  RESET_MARGIN_MS, FALLBACK_RESET_MS, PROBE_INTERVAL_MS, PROBE_MAX_MS,
@@ -123,6 +123,15 @@ export function resolveRecordMux(rec) {
123
123
  return getMultiplexer(rec.mux, { owner: rec.paneOwner });
124
124
  }
125
125
 
126
+ // The user's resumeExtraArgs, placed where the adapter's command parses them:
127
+ // appended, unless the adapter names an index (codex's `exec resume` takes
128
+ // its options after `exec`, not after the prompt).
129
+ function withResumeExtraArgs(resume, agentId) {
130
+ const extra = resolveResumeExtraArgs(agentId);
131
+ const at = Number.isInteger(resume.extraArgsAt) ? resume.extraArgsAt : resume.args.length;
132
+ return [...resume.args.slice(0, at), ...extra, ...resume.args.slice(at)];
133
+ }
134
+
126
135
  const stopEpisodeAt = rec => rec?.bannerAt ?? rec?.detectedAt;
127
136
 
128
137
  // Newer non-error parent-context Claude usage proves that a user/provider retry
@@ -366,13 +375,16 @@ function rescheduleProbe(rec, now = Date.now()) {
366
375
  // never by waiting it out. Typing a wake into it would hit the same wall,
367
376
  // so make the stall a visible terminal state instead of a futile resume.
368
377
  if (rec.limitType === 'model') {
378
+ // The agent's own remedy: `unsnooze status` shows this line, and
379
+ // Claude's slash commands mean nothing to a Codex workspace wall.
380
+ const remedy = modelRemedy(getAgent(rec.agent));
369
381
  setStatus(key, 'failed', {
370
- lastError: 'model limit still active — switch models (/model) or add credits',
382
+ lastError: `model limit still active — ${remedy}`,
371
383
  probeCount: probeCount + 1,
372
384
  }, { expect: ['stopped'] });
373
385
  log(`${key}: model limit still active at probe ceiling — needs a human`);
374
386
  notify('unsnooze: model limit needs you ⚠️',
375
- `${rec.cwd}: still limited after probing — ${modelRemedy(getAgent(rec.agent))}`,
387
+ `${rec.cwd}: still limited after probing — ${remedy}`,
376
388
  { context: ctxOf(rec), priority: 4 });
377
389
  return 'held';
378
390
  }
@@ -585,10 +597,12 @@ export async function planFor(rec, {
585
597
  return { ...base, action: 'superseded', target: { key: superseded.key } };
586
598
  }
587
599
  const target = await reviveTarget(mux, rec);
588
- const resume = agent.resumeArgs(rec.sessionId, message);
600
+ // The same argv reopen() builds: on a backend with no pane the prompt rides
601
+ // in argv, and for codex the command itself changes (`exec resume`).
602
+ const resume = agent.resumeArgs(rec.sessionId, message, { canType: backendCanType(mux) });
589
603
  return {
590
604
  ...base, action: 'reopen', target: { session: target }, message,
591
- argv: [agent.id, ...resume.args, ...resolveResumeExtraArgs(agent.id)],
605
+ argv: [agent.id, ...withResumeExtraArgs(resume, agent.id)],
592
606
  messageViaPane: !!resume.messageViaPane,
593
607
  };
594
608
  }
@@ -758,7 +772,7 @@ async function reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd, onD
758
772
  const leaseId = createLeaseId();
759
773
  const target = await reviveTarget(mux, rec);
760
774
  const launchSpec = {
761
- file: selfCmd[0], args: [...selfCmd.slice(1), '_run', agent.id, ...resume.args, ...resolveResumeExtraArgs(agent.id)],
775
+ file: selfCmd[0], args: [...selfCmd.slice(1), '_run', agent.id, ...withResumeExtraArgs(resume, agent.id)],
762
776
  env: reopenEnv(rec, leaseId, target),
763
777
  };
764
778
  // reviveTarget can await a multiplexer query. Recheck after it and claim the
@@ -936,6 +950,44 @@ export async function verifyOne(key, { resolveMux = resolveRecordMux } = {}) {
936
950
  log(`${key}: limit still active, rescheduled to ${new Date(at).toISOString()} (${source})`);
937
951
  return;
938
952
  }
953
+ // A pane-less backend answers every capture with '', so "no banner" is not
954
+ // evidence there. Headless records what became of the launcher it spawned;
955
+ // a child that already died non-zero (`spawn codex ENOENT`, the Codex TUI's
956
+ // "stdin is not a terminal") resumed nothing, and marking it resumed would
957
+ // drop a retryable stop from the ledger and announce a wake that never
958
+ // happened (#25). Exit 0 is fine — `claude --resume … "prompt"` runs to
959
+ // completion and exits before the verify delay — and so is running on
960
+ // past HEADLESS_SETTLE_MS. A revival that is still young stays in flight:
961
+ // the loop verifies every resuming record each tick, and once it counts as
962
+ // resumed nothing reads its exit again (the sweep drops the record when
963
+ // the pid goes), so a death at 25s would have been announced as a wake.
964
+ if (typeof mux.paneOutcome === 'function') {
965
+ let outcome = null;
966
+ try { outcome = await mux.paneOutcome(rec.pane); } catch { outcome = null; }
967
+ if (outcome && outcome.exited === false
968
+ && Date.now() - (rec.lastAttemptAt || 0) < HEADLESS_SETTLE_MS) {
969
+ return 'pending';
970
+ }
971
+ if (outcome?.exited && outcome.code !== 0) {
972
+ const attempts = (rec.attempts || 0) + 1;
973
+ const how = outcome.code != null ? `exit ${outcome.code}`
974
+ : outcome.signal ? `killed by ${outcome.signal}` : 'failed to start';
975
+ const why = outcome.output || outcome.error || '';
976
+ const lastError = `revive died before it could resume (${how}${why ? `: ${why}` : ''})`;
977
+ const applied = transitionStopEpisode(rec, 'stopped', {
978
+ attempts,
979
+ // Same backoff (and same manual exemption) as every other retry.
980
+ resetAt: rec.manual ? Date.now() : Date.now() + retryBackoffMs(attempts),
981
+ lastError, verifyRetries: 0, resumeEpisodeAt: null,
982
+ }, { expect: ['resuming'] });
983
+ if (!applied) {
984
+ releaseSupersededResume(key);
985
+ return 'stale';
986
+ }
987
+ log(`${key}: ${lastError}`);
988
+ return 'retry';
989
+ }
990
+ }
939
991
  if (!transitionStopEpisode(rec, 'resumed', {
940
992
  lastError: null, verifyRetries: 0, resumeEpisodeAt: null,
941
993
  bannerCleared: true,
@@ -1108,8 +1160,14 @@ export async function runResumer({
1108
1160
  // Anything over the attempts cap is dead — mark failed so we can exit.
1109
1161
  for (const s of dueForDispatch()) {
1110
1162
  if ((s.attempts || 0) >= MAX_RESUME_ATTEMPTS) {
1163
+ // Keep the last attempt's reason: this is the state `unsnooze status`
1164
+ // shows after the "gave up" notification, and "exceeded" alone does
1165
+ // not say that every revival died with `spawn codex ENOENT` (#25).
1111
1166
  const applied = transitionStopEpisode(s, 'failed', {
1112
- lastError: 'max resume attempts exceeded', verifyRetries: 0,
1167
+ lastError: s.lastError
1168
+ ? `max resume attempts exceeded — last: ${s.lastError}`
1169
+ : 'max resume attempts exceeded',
1170
+ verifyRetries: 0,
1113
1171
  resumeEpisodeAt: null,
1114
1172
  }, { expect: ['stopped'] });
1115
1173
  if (!applied) continue;
@@ -278,7 +278,12 @@ export function resetAtMs(parsed, {
278
278
  }
279
279
  return t; // unreachable in practice — 9 steps cover a week + DST repeat
280
280
  }
281
- if (t <= anchor) {
281
+ // A clock time names a whole minute. Codex prints the reset as %-I:%M and
282
+ // drops the seconds, so a dated banner written at 7:36:15 that says "try
283
+ // again at 7:36 AM" means a reset later in that same minute — not 7:36
284
+ // tomorrow, a day late. Only a minute that had fully passed rolls.
285
+ const past = bannerAt != null ? t + 60_000 <= anchor : t <= anchor;
286
+ if (past) {
282
287
  // Dated banner, or ambiguous (need both am/pm candidates as real
283
288
  // next-occurrences): roll to the next day. Undated non-ambiguous
284
289
  // past clock times stay past so the outer guard returns due-now