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/CHANGELOG.md +183 -0
- package/README.md +47 -13
- package/bin/unsnooze.js +8 -2
- package/package.json +1 -1
- package/src/agents/codex.js +96 -12
- package/src/cli.js +4 -0
- package/src/config.js +6 -0
- package/src/doctor.js +81 -2
- package/src/fish.js +22 -0
- package/src/install.js +86 -4
- package/src/launcher.js +45 -12
- package/src/multiplexers/headless.js +159 -11
- package/src/resumer.js +65 -7
- package/src/time-parser.js +6 -1
- package/src/usage.js +50 -21
- package/src/watcher.js +56 -19
- package/src/watchers/codex.js +262 -23
- package/src/which.js +63 -0
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
|
|
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 = {
|
|
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
|
|
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
|
|
20
|
-
return args.
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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 (!
|
|
113
|
-
|
|
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
|
-
|
|
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:
|
|
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 — ${
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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;
|
package/src/time-parser.js
CHANGED
|
@@ -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
|
-
|
|
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
|