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/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', { processBirthFn = processBirth } = {}) {
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
- const r = spawnSync(agent.bin, args, { stdio: 'inherit', env: { ...process.env, UNSNOOZE_ACTIVE: '1' } });
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
- const child = spawn(agent.bin, args, {
170
- stdio: 'inherit',
171
- env: childEnv,
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
- process.stderr.write(`unsnooze: failed to launch ${agent.bin}: ${err.message}\n`);
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 { 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,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: 'model limit still active — switch models (/model) or add credits',
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 — ${modelRemedy(getAgent(rec.agent))}`,
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
- const resume = agent.resumeArgs(rec.sessionId, message);
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.args, ...resolveResumeExtraArgs(agent.id)],
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.args, ...resolveResumeExtraArgs(agent.id)],
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: 'max resume attempts exceeded', verifyRetries: 0,
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;