unsnooze 1.19.0 → 1.19.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +218 -0
- package/README.md +41 -12
- package/bin/unsnooze.js +90 -64
- package/package.json +1 -1
- package/src/agents/codex.js +117 -12
- package/src/agents/opencode.js +2 -1
- package/src/cli.js +4 -0
- package/src/config.js +21 -0
- package/src/doctor.js +76 -0
- package/src/hook.js +6 -2
- package/src/install.js +104 -35
- package/src/launcher.js +52 -10
- package/src/monitor.js +11 -0
- package/src/multiplexers/headless.js +159 -11
- package/src/resumer.js +94 -11
- package/src/spawn.js +262 -9
- package/src/time-parser.js +6 -1
- package/src/usage.js +50 -21
- package/src/watcher.js +45 -12
- package/src/watchers/codex.js +202 -28
- package/src/which.js +63 -0
- package/src/wizard.js +8 -4
- package/src/workspace.js +4 -1
package/src/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, resolveLaunchExtraArgs } from './settings.js';
|
|
11
|
-
import { spawnDetached, monitorSpawnArgs } from './spawn.js';
|
|
11
|
+
import { spawnDetached, monitorSpawnArgs, ensureDaemon } from './spawn.js';
|
|
12
12
|
import { makeLogger } from './logger.js';
|
|
13
13
|
import { createLeaseId, processBirth, writeLease, removeLease } from './lease.js';
|
|
14
14
|
import { recordOwnedSession } from './mux-sessions.js';
|
|
@@ -36,13 +36,32 @@ export function resolvePaneOwner(muxName, env = process.env) {
|
|
|
36
36
|
return null;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// A headless revival runs this launcher detached, with no console and no
|
|
40
|
+
// terminal on any stdio. On Windows the agent would then be given a console
|
|
41
|
+
// window of its own — an empty window popping up on the desktop at every
|
|
42
|
+
// reset, which closing kills the revival. Hide it there. Whenever a terminal
|
|
43
|
+
// is attached nothing changes: the agent, a console program, shares the
|
|
44
|
+
// launcher's console, and windowsHide only affects a console Windows creates.
|
|
45
|
+
export function hideAgentWindow(streams = process) {
|
|
46
|
+
return !(streams.stdin?.isTTY || streams.stdout?.isTTY || streams.stderr?.isTTY);
|
|
47
|
+
}
|
|
48
|
+
|
|
39
49
|
function runUnwatched(agent, args, reason) {
|
|
40
50
|
if (reason) process.stderr.write(`unsnooze: ${reason}\n`);
|
|
51
|
+
return runPassthrough(agent, args);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// spawnSync reports a binary it could not start as r.error with no status;
|
|
55
|
+
// `r.status ?? 1` alone turned `spawn codex ENOENT` into a silent exit 1.
|
|
56
|
+
function runPassthrough(agent, args) {
|
|
41
57
|
const r = spawnSync(agent.bin, args, { stdio: 'inherit', env: { ...process.env, UNSNOOZE_ACTIVE: '1' } });
|
|
58
|
+
if (r.error) return launchFailed(agent, r.error);
|
|
42
59
|
return r.status ?? 1;
|
|
43
60
|
}
|
|
44
61
|
|
|
45
|
-
export function runLauncher(args, agentId = 'claude', {
|
|
62
|
+
export function runLauncher(args, agentId = 'claude', {
|
|
63
|
+
processBirthFn = processBirth, ensureDaemonFn = ensureDaemon,
|
|
64
|
+
} = {}) {
|
|
46
65
|
const agent = getAgent(agentId);
|
|
47
66
|
|
|
48
67
|
// Recursion / nested-launch guard: inside an unsnooze-managed session, a
|
|
@@ -54,8 +73,7 @@ export function runLauncher(args, agentId = 'claude', { processBirthFn = process
|
|
|
54
73
|
// or spawning a watcher for `claude --help` flashes the screen and slows down
|
|
55
74
|
// simple queries.
|
|
56
75
|
if (process.env.UNSNOOZE_ACTIVE === '1' || isPassthrough(args) || !getConfig(`agents.${agent.id}`)) {
|
|
57
|
-
|
|
58
|
-
return r.status ?? 1;
|
|
76
|
+
return runPassthrough(agent, args);
|
|
59
77
|
}
|
|
60
78
|
|
|
61
79
|
// Flags the user wants on every session they start (claude's --autocompact
|
|
@@ -116,6 +134,12 @@ export function runLauncher(args, agentId = 'claude', { processBirthFn = process
|
|
|
116
134
|
}
|
|
117
135
|
}
|
|
118
136
|
|
|
137
|
+
// Nothing keeps the daemon running on native Windows (see ensureDaemon), so
|
|
138
|
+
// every watched launch makes sure it is. Here, past the re-exec above, so a
|
|
139
|
+
// launch that wraps itself asks once, not twice. A no-op everywhere else,
|
|
140
|
+
// and until `install --daemon` has asked for the daemon.
|
|
141
|
+
ensureDaemonFn();
|
|
142
|
+
|
|
119
143
|
const rawPane = mux.currentPaneId();
|
|
120
144
|
const paneOwner = resolvePaneOwner(mux.name, process.env);
|
|
121
145
|
// A backend may know it cannot safely address the pane it is sitting in —
|
|
@@ -166,10 +190,17 @@ export function runLauncher(args, agentId = 'claude', { processBirthFn = process
|
|
|
166
190
|
UNSNOOZE_PANE: pane || '', UNSNOOZE_PANE_OWNER: paneOwner || '',
|
|
167
191
|
UNSNOOZE_LEASE_ID: leaseId,
|
|
168
192
|
};
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
193
|
+
// The bin can be a path we resolved ourselves (a Windows .cmd shim, say).
|
|
194
|
+
// Node ≥ 20.12 refuses to spawn .bat/.cmd without a shell, and depending on
|
|
195
|
+
// the version that is a synchronous throw rather than an 'error' event —
|
|
196
|
+
// either way it has to come out as the same "failed to launch" line, with
|
|
197
|
+
// the reason, never as a crash with no exit code for the resumer to read.
|
|
198
|
+
let child;
|
|
199
|
+
try {
|
|
200
|
+
child = spawn(agent.bin, args, { stdio: 'inherit', env: childEnv, windowsHide: hideAgentWindow() });
|
|
201
|
+
} catch (err) {
|
|
202
|
+
return launchFailed(agent, err);
|
|
203
|
+
}
|
|
173
204
|
const lease = pane && child.pid ? {
|
|
174
205
|
leaseId, mux: mux.name, paneOwner, pane, agent: agent.id,
|
|
175
206
|
pid: child.pid, pidBirth: processBirthFn(child.pid),
|
|
@@ -186,8 +217,19 @@ export function runLauncher(args, agentId = 'claude', { processBirthFn = process
|
|
|
186
217
|
child.on('exit', code => { cleanup(); resolve(code ?? 1); });
|
|
187
218
|
child.on('error', err => {
|
|
188
219
|
cleanup();
|
|
189
|
-
|
|
190
|
-
resolve(127);
|
|
220
|
+
resolve(launchFailed(agent, err));
|
|
191
221
|
});
|
|
192
222
|
});
|
|
193
223
|
}
|
|
224
|
+
|
|
225
|
+
// One place for the message, because the resumer reads it back out of the
|
|
226
|
+
// headless log when a revival dies (#25). 127 like a shell's "command not
|
|
227
|
+
// found"; the hint names the one misconfiguration that produces EINVAL here.
|
|
228
|
+
function launchFailed(agent, err) {
|
|
229
|
+
process.stderr.write(`unsnooze: failed to launch ${agent.bin}: ${err.message}\n`);
|
|
230
|
+
if (/\.(cmd|bat)$/i.test(String(agent.bin))) {
|
|
231
|
+
process.stderr.write(`unsnooze: ${agent.bin} is a .cmd/.bat shim, which cannot be launched directly — `
|
|
232
|
+
+ `point UNSNOOZE_${agent.id.toUpperCase()}_BIN at the .exe\n`);
|
|
233
|
+
}
|
|
234
|
+
return 127;
|
|
235
|
+
}
|
package/src/monitor.js
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from './config.js';
|
|
19
19
|
import { detectLimit, isBusy, overloadMatch, modelRemedy } from './patterns.js';
|
|
20
20
|
import { getAgent } from './agents/index.js';
|
|
21
|
+
import { pendingCodexPrompt } from './agents/codex.js';
|
|
21
22
|
import { getConfig } from './settings.js';
|
|
22
23
|
import { notify } from './notify.js';
|
|
23
24
|
import { parseResetTime, resetAtMs, sourceRank } from './time-parser.js';
|
|
@@ -327,6 +328,16 @@ export function createMonitor({
|
|
|
327
328
|
const visible = mux.capturePaneVisible
|
|
328
329
|
? await mux.capturePaneVisible(pane).catch(() => '')
|
|
329
330
|
: text;
|
|
331
|
+
if (agent.id === 'codex' && Object.values(readState().sessions).some(rec =>
|
|
332
|
+
rec.agent === 'codex' && rec.mux === muxName && rec.pane === pane
|
|
333
|
+
&& rec.paneOwner === paneOwner && (rec.leaseId ?? null) === leaseId
|
|
334
|
+
&& ['resuming', 'failed'].includes(rec.status)
|
|
335
|
+
&& pendingCodexPrompt(visible, rec.pendingPrompt))) {
|
|
336
|
+
// The resumer owns this unsubmitted draft. Its old banner is not a new
|
|
337
|
+
// stop; refreshing detectedAt here would invalidate the submit retry.
|
|
338
|
+
firstTick = false;
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
330
341
|
if (agent.menu && agent.menu.isPrompt(visible, PANE_SCAN_LINES)) {
|
|
331
342
|
if (!getConfig('menuAutoAnswer')) {
|
|
332
343
|
// Watch-only mode: record the stop (reset time may not be visible
|
|
@@ -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,13 +9,14 @@ 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,
|
|
16
16
|
} from './config.js';
|
|
17
17
|
import { detectLimit, isBusy, modelRemedy } from './patterns.js';
|
|
18
18
|
import { getAgent } from './agents/index.js';
|
|
19
|
+
import { pendingCodexPrompt } from './agents/codex.js';
|
|
19
20
|
import { parseResetTime, resetAtMs, nextProbeDelayMs } from './time-parser.js';
|
|
20
21
|
import {
|
|
21
22
|
readState, updateState, setStatus, dueSessions, activeStopped,
|
|
@@ -123,6 +124,15 @@ export function resolveRecordMux(rec) {
|
|
|
123
124
|
return getMultiplexer(rec.mux, { owner: rec.paneOwner });
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
// The user's resumeExtraArgs, placed where the adapter's command parses them:
|
|
128
|
+
// appended, unless the adapter names an index (codex's `exec resume` takes
|
|
129
|
+
// its options after `exec`, not after the prompt).
|
|
130
|
+
function withResumeExtraArgs(resume, agentId) {
|
|
131
|
+
const extra = resolveResumeExtraArgs(agentId);
|
|
132
|
+
const at = Number.isInteger(resume.extraArgsAt) ? resume.extraArgsAt : resume.args.length;
|
|
133
|
+
return [...resume.args.slice(0, at), ...extra, ...resume.args.slice(at)];
|
|
134
|
+
}
|
|
135
|
+
|
|
126
136
|
const stopEpisodeAt = rec => rec?.bannerAt ?? rec?.detectedAt;
|
|
127
137
|
|
|
128
138
|
// Newer non-error parent-context Claude usage proves that a user/provider retry
|
|
@@ -215,7 +225,7 @@ function paneStopOwner(rec, state = readState()) {
|
|
|
215
225
|
// One live pane can accept one wake. Legacy/subagent detections outside the
|
|
216
226
|
// short ingest-dedupe window are therefore collapsed here: the newest active
|
|
217
227
|
// stop owns the target and older active records become explicit history.
|
|
218
|
-
function claimStopForResume(rec) {
|
|
228
|
+
function claimStopForResume(rec, pendingPrompt = null) {
|
|
219
229
|
const cutoff = stopEpisodeAt(rec);
|
|
220
230
|
let claimed = false;
|
|
221
231
|
updateState(state => {
|
|
@@ -245,6 +255,7 @@ function claimStopForResume(rec) {
|
|
|
245
255
|
// single point where unsnooze takes ownership of a stop, whether the
|
|
246
256
|
// wake is typed, driven through the menu, or reopened.
|
|
247
257
|
resumedBy: 'unsnooze',
|
|
258
|
+
pendingPrompt, submitRetries: 0,
|
|
248
259
|
});
|
|
249
260
|
claimed = true;
|
|
250
261
|
}
|
|
@@ -366,13 +377,16 @@ function rescheduleProbe(rec, now = Date.now()) {
|
|
|
366
377
|
// never by waiting it out. Typing a wake into it would hit the same wall,
|
|
367
378
|
// so make the stall a visible terminal state instead of a futile resume.
|
|
368
379
|
if (rec.limitType === 'model') {
|
|
380
|
+
// The agent's own remedy: `unsnooze status` shows this line, and
|
|
381
|
+
// Claude's slash commands mean nothing to a Codex workspace wall.
|
|
382
|
+
const remedy = modelRemedy(getAgent(rec.agent));
|
|
369
383
|
setStatus(key, 'failed', {
|
|
370
|
-
lastError:
|
|
384
|
+
lastError: `model limit still active — ${remedy}`,
|
|
371
385
|
probeCount: probeCount + 1,
|
|
372
386
|
}, { expect: ['stopped'] });
|
|
373
387
|
log(`${key}: model limit still active at probe ceiling — needs a human`);
|
|
374
388
|
notify('unsnooze: model limit needs you ⚠️',
|
|
375
|
-
`${rec.cwd}: still limited after probing — ${
|
|
389
|
+
`${rec.cwd}: still limited after probing — ${remedy}`,
|
|
376
390
|
{ context: ctxOf(rec), priority: 4 });
|
|
377
391
|
return 'held';
|
|
378
392
|
}
|
|
@@ -585,10 +599,12 @@ export async function planFor(rec, {
|
|
|
585
599
|
return { ...base, action: 'superseded', target: { key: superseded.key } };
|
|
586
600
|
}
|
|
587
601
|
const target = await reviveTarget(mux, rec);
|
|
588
|
-
|
|
602
|
+
// The same argv reopen() builds: on a backend with no pane the prompt rides
|
|
603
|
+
// in argv, and for codex the command itself changes (`exec resume`).
|
|
604
|
+
const resume = agent.resumeArgs(rec.sessionId, message, { canType: backendCanType(mux) });
|
|
589
605
|
return {
|
|
590
606
|
...base, action: 'reopen', target: { session: target }, message,
|
|
591
|
-
argv: [agent.id, ...resume
|
|
607
|
+
argv: [agent.id, ...withResumeExtraArgs(resume, agent.id)],
|
|
592
608
|
messageViaPane: !!resume.messageViaPane,
|
|
593
609
|
};
|
|
594
610
|
}
|
|
@@ -699,7 +715,7 @@ export async function dispatchOne(rec, {
|
|
|
699
715
|
// assessPane is asynchronous. Close the final human/provider-resume race
|
|
700
716
|
// after assessment and immediately before the keystroke.
|
|
701
717
|
if (finishIfClaudeProgressed(rec, agent)) return 'already-resumed';
|
|
702
|
-
if (!claimStopForResume(rec)) return 'stale';
|
|
718
|
+
if (!claimStopForResume(rec, agent.id === 'codex' ? resumeMessage : null)) return 'stale';
|
|
703
719
|
await mux.sendText(rec.pane, resumeMessage);
|
|
704
720
|
log(`${key}: sent continue via ${rec.mux} ${rec.paneOwner ?? '-'}:${rec.pane}`);
|
|
705
721
|
notifyContext();
|
|
@@ -758,7 +774,7 @@ async function reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd, onD
|
|
|
758
774
|
const leaseId = createLeaseId();
|
|
759
775
|
const target = await reviveTarget(mux, rec);
|
|
760
776
|
const launchSpec = {
|
|
761
|
-
file: selfCmd[0], args: [...selfCmd.slice(1), '_run', agent.id, ...resume
|
|
777
|
+
file: selfCmd[0], args: [...selfCmd.slice(1), '_run', agent.id, ...withResumeExtraArgs(resume, agent.id)],
|
|
762
778
|
env: reopenEnv(rec, leaseId, target),
|
|
763
779
|
};
|
|
764
780
|
// reviveTarget can await a multiplexer query. Recheck after it and claim the
|
|
@@ -860,11 +876,11 @@ function recordVerifyRetry(rec, lastError) {
|
|
|
860
876
|
let applied;
|
|
861
877
|
if (verifyRetries >= MAX_VERIFY_RETRIES) {
|
|
862
878
|
const attempts = (rec.attempts || 0) + 1;
|
|
863
|
-
applied = transitionStopEpisode(rec, 'stopped', {
|
|
879
|
+
applied = transitionStopEpisode(rec, rec.pendingPrompt ? 'failed' : 'stopped', {
|
|
864
880
|
attempts,
|
|
865
881
|
// Same backoff (and same manual exemption) as routed retries.
|
|
866
882
|
resetAt: rec.manual ? Date.now() : Date.now() + retryBackoffMs(attempts),
|
|
867
|
-
lastError,
|
|
883
|
+
lastError: rec.pendingPrompt ? `${lastError} — wake submission unconfirmed; inspect the Codex pane` : lastError,
|
|
868
884
|
verifyRetries: 0, resumeEpisodeAt: null,
|
|
869
885
|
}, { expect: ['resuming'] });
|
|
870
886
|
} else {
|
|
@@ -903,6 +919,29 @@ export async function verifyOne(key, { resolveMux = resolveRecordMux } = {}) {
|
|
|
903
919
|
catch (err) {
|
|
904
920
|
return recordVerifyRetry(rec, `verify capture: ${err.message}`);
|
|
905
921
|
}
|
|
922
|
+
// A delayed TUI can process text and Enter as a single paste burst (#30).
|
|
923
|
+
// Recover only our exact draft, never type it again or treat the banner
|
|
924
|
+
// above it as a fresh refusal. Re-check the normal ownership/busy gates
|
|
925
|
+
// and the stop episode immediately before every additional Enter.
|
|
926
|
+
if (agent.id === 'codex' && pendingCodexPrompt(text, rec.pendingPrompt)) {
|
|
927
|
+
const a = await assessPane(rec, agent, { mux });
|
|
928
|
+
const current = readState().sessions[key];
|
|
929
|
+
if (!current || current.status !== 'resuming' || !samePaneTarget(current, rec)
|
|
930
|
+
|| stopEpisodeAt(current) !== stopEpisodeAt(rec)) return 'stale';
|
|
931
|
+
if (a.busy) return 'pending';
|
|
932
|
+
if ((rec.submitRetries || 0) >= 3 || !a.authorized || typeof mux.sendKey !== 'function') {
|
|
933
|
+
transitionStopEpisode(rec, 'failed', {
|
|
934
|
+
lastError: 'wake prompt not submitted — inspect the Codex pane and press Enter to continue',
|
|
935
|
+
}, { expect: ['resuming'] });
|
|
936
|
+
return 'held';
|
|
937
|
+
}
|
|
938
|
+
if (!pendingCodexPrompt(a.text, rec.pendingPrompt)) return 'pending';
|
|
939
|
+
if (!transitionStopEpisode(rec, 'resuming', { submitRetries: (rec.submitRetries || 0) + 1 },
|
|
940
|
+
{ expect: ['resuming'] })) return 'stale';
|
|
941
|
+
try { await mux.sendKey(rec.pane, 'Enter'); }
|
|
942
|
+
catch (err) { log(`${key}: submit retry failed: ${err.message}`); }
|
|
943
|
+
return 'pending';
|
|
944
|
+
}
|
|
906
945
|
const d = detectLimit(text, PANE_SCAN_LINES, agent.patterns);
|
|
907
946
|
if (d.hit || (agent.menu && agent.menu.isPrompt(text, PANE_SCAN_LINES))) {
|
|
908
947
|
// Limit not actually reset — reschedule from the fresh banner.
|
|
@@ -936,6 +975,44 @@ export async function verifyOne(key, { resolveMux = resolveRecordMux } = {}) {
|
|
|
936
975
|
log(`${key}: limit still active, rescheduled to ${new Date(at).toISOString()} (${source})`);
|
|
937
976
|
return;
|
|
938
977
|
}
|
|
978
|
+
// A pane-less backend answers every capture with '', so "no banner" is not
|
|
979
|
+
// evidence there. Headless records what became of the launcher it spawned;
|
|
980
|
+
// a child that already died non-zero (`spawn codex ENOENT`, the Codex TUI's
|
|
981
|
+
// "stdin is not a terminal") resumed nothing, and marking it resumed would
|
|
982
|
+
// drop a retryable stop from the ledger and announce a wake that never
|
|
983
|
+
// happened (#25). Exit 0 is fine — `claude --resume … "prompt"` runs to
|
|
984
|
+
// completion and exits before the verify delay — and so is running on
|
|
985
|
+
// past HEADLESS_SETTLE_MS. A revival that is still young stays in flight:
|
|
986
|
+
// the loop verifies every resuming record each tick, and once it counts as
|
|
987
|
+
// resumed nothing reads its exit again (the sweep drops the record when
|
|
988
|
+
// the pid goes), so a death at 25s would have been announced as a wake.
|
|
989
|
+
if (typeof mux.paneOutcome === 'function') {
|
|
990
|
+
let outcome = null;
|
|
991
|
+
try { outcome = await mux.paneOutcome(rec.pane); } catch { outcome = null; }
|
|
992
|
+
if (outcome && outcome.exited === false
|
|
993
|
+
&& Date.now() - (rec.lastAttemptAt || 0) < HEADLESS_SETTLE_MS) {
|
|
994
|
+
return 'pending';
|
|
995
|
+
}
|
|
996
|
+
if (outcome?.exited && outcome.code !== 0) {
|
|
997
|
+
const attempts = (rec.attempts || 0) + 1;
|
|
998
|
+
const how = outcome.code != null ? `exit ${outcome.code}`
|
|
999
|
+
: outcome.signal ? `killed by ${outcome.signal}` : 'failed to start';
|
|
1000
|
+
const why = outcome.output || outcome.error || '';
|
|
1001
|
+
const lastError = `revive died before it could resume (${how}${why ? `: ${why}` : ''})`;
|
|
1002
|
+
const applied = transitionStopEpisode(rec, 'stopped', {
|
|
1003
|
+
attempts,
|
|
1004
|
+
// Same backoff (and same manual exemption) as every other retry.
|
|
1005
|
+
resetAt: rec.manual ? Date.now() : Date.now() + retryBackoffMs(attempts),
|
|
1006
|
+
lastError, verifyRetries: 0, resumeEpisodeAt: null,
|
|
1007
|
+
}, { expect: ['resuming'] });
|
|
1008
|
+
if (!applied) {
|
|
1009
|
+
releaseSupersededResume(key);
|
|
1010
|
+
return 'stale';
|
|
1011
|
+
}
|
|
1012
|
+
log(`${key}: ${lastError}`);
|
|
1013
|
+
return 'retry';
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
939
1016
|
if (!transitionStopEpisode(rec, 'resumed', {
|
|
940
1017
|
lastError: null, verifyRetries: 0, resumeEpisodeAt: null,
|
|
941
1018
|
bannerCleared: true,
|
|
@@ -1108,8 +1185,14 @@ export async function runResumer({
|
|
|
1108
1185
|
// Anything over the attempts cap is dead — mark failed so we can exit.
|
|
1109
1186
|
for (const s of dueForDispatch()) {
|
|
1110
1187
|
if ((s.attempts || 0) >= MAX_RESUME_ATTEMPTS) {
|
|
1188
|
+
// Keep the last attempt's reason: this is the state `unsnooze status`
|
|
1189
|
+
// shows after the "gave up" notification, and "exceeded" alone does
|
|
1190
|
+
// not say that every revival died with `spawn codex ENOENT` (#25).
|
|
1111
1191
|
const applied = transitionStopEpisode(s, 'failed', {
|
|
1112
|
-
lastError:
|
|
1192
|
+
lastError: s.lastError
|
|
1193
|
+
? `max resume attempts exceeded — last: ${s.lastError}`
|
|
1194
|
+
: 'max resume attempts exceeded',
|
|
1195
|
+
verifyRetries: 0,
|
|
1113
1196
|
resumeEpisodeAt: null,
|
|
1114
1197
|
}, { expect: ['stopped'] });
|
|
1115
1198
|
if (!applied) continue;
|