unsnooze 1.19.1 → 1.19.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/install.js CHANGED
@@ -14,14 +14,14 @@
14
14
  import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, rmSync, mkdirSync, unlinkSync } from 'node:fs';
15
15
  import { execFileSync } from 'node:child_process';
16
16
  import { homedir, userInfo } from 'node:os';
17
- import { join, dirname, delimiter } from 'node:path';
18
- import { CLAUDE_SETTINGS, STATE_DIR, WINDOWS_TASK_NAME } from './config.js';
17
+ import { join, dirname, basename, delimiter } from 'node:path';
18
+ import { CLAUDE_SETTINGS, STATE_DIR, WINDOWS_TASK_NAME, DAEMON_ON_DEMAND } from './config.js';
19
19
  import { getConfig, configFileExists } from './settings.js';
20
20
  import { xmlEscape } from './notify.js';
21
21
  import { installGrokHooks, uninstallGrokHooks } from './agents/grok.js';
22
22
  import { getAgent } from './agents/index.js';
23
23
  import { findCsgProcesses, findCsgAutostarts } from './doctor.js';
24
- import { UNSNOOZE_BIN, stopResumer } from './spawn.js';
24
+ import { UNSNOOZE_BIN, stopResumer, restartDaemon, stopDaemon } from './spawn.js';
25
25
  import { uninstallStatuslineShim } from './usage.js';
26
26
  import { powershellProfilePath } from './powershell.js';
27
27
  import { fishConfigPath } from './fish.js';
@@ -248,11 +248,12 @@ export function installFishBlock(content, agents = ['claude'], bin = UNSNOOZE_BI
248
248
  // GUI sessions (VS Code extension, desktop apps) never pass through the shell
249
249
  // wrappers, so their limit stops are only caught while `unsnooze daemon` is
250
250
  // alive. Autostart keeps it alive: a launchd user agent on macOS, a systemd
251
- // user unit on Linux/WSL.
251
+ // user unit on Linux/WSL. Native Windows deliberately gets neither a unit nor
252
+ // a Scheduled Task — the daemon is started on demand there (DAEMON_ON_DEMAND).
252
253
 
253
254
  export const DAEMON_LABEL = 'com.unsnooze.daemon';
254
- // Task Scheduler has no reverse-DNS convention and shows this name to the user
255
- // in taskschd.msc, so it is a plain word rather than the launchd label.
255
+ // The Scheduled Task name unsnooze <= 1.19.1 used; kept for the uninstall that
256
+ // removes one left behind.
256
257
  export { WINDOWS_TASK_NAME };
257
258
 
258
259
  // Is this process running under the supervisor we install, rather than from
@@ -269,14 +270,15 @@ export { WINDOWS_TASK_NAME };
269
270
  export function isSupervised({ platform = process.platform, env = process.env } = {}) {
270
271
  if (platform === 'darwin') return env.XPC_SERVICE_NAME === DAEMON_LABEL;
271
272
  if (platform === 'linux') return typeof env.INVOCATION_ID === 'string' && env.INVOCATION_ID !== '';
272
- // win32 stays false on purpose, even though we now install a Scheduled Task.
273
- // launchd KeepAlive and systemd Restart=always bring the daemon straight
274
- // back; a logon-triggered task does not restart anything until the next
275
- // logon. Answering true here would let the version-skew guard exit 0 into
276
- // nothing and stop Windows watching silently — the exact failure mode of
277
- // issue #8, just with a different trigger. The cost is that a Windows daemon
278
- // keeps running pre-upgrade code until the user logs back in; that is the
279
- // lesser of the two, and `unsnooze doctor` reports the skew.
273
+ // win32 stays false on purpose. launchd KeepAlive and systemd Restart=always
274
+ // bring the daemon straight back; on Windows it is only started again by the
275
+ // next wrapped launch or StopFailure hook, which may be hours away. Answering
276
+ // true here would let the version-skew guard exit 0 into nothing and stop
277
+ // Windows watching silently — the exact failure mode of issue #8, just with a
278
+ // different trigger. The cost is that a Windows daemon keeps running
279
+ // pre-upgrade code until it is restarted (a sign-out, or re-running
280
+ // `unsnooze install --daemon`); that is the lesser of the two, and
281
+ // `unsnooze doctor` reports the skew.
280
282
  return false;
281
283
  }
282
284
 
@@ -434,20 +436,49 @@ function defaultActivate(cmd, args) {
434
436
  }
435
437
  }
436
438
 
439
+ // Native Windows keeps its "unit" in the state dir: a marker that asks for the
440
+ // daemon to be started on demand. `dir` relocates it, as it does the unit dir
441
+ // elsewhere.
442
+ function onDemandMarker(dir) {
443
+ return dir ? join(dir, basename(DAEMON_ON_DEMAND)) : DAEMON_ON_DEMAND;
444
+ }
445
+
446
+ const ON_DEMAND_NOTE = 'While this file exists, the unsnooze daemon is started on demand — by the\n'
447
+ + 'agent wrappers and the StopFailure hook — whenever it is not running. Nothing\n'
448
+ + 'is registered to run at sign-in. `unsnooze uninstall` removes this file.\n';
449
+
450
+ // unsnooze <= 1.19.1 registered the daemon as a logon Scheduled Task. Nothing
451
+ // creates one now, but an install from then may still have it; uninstall
452
+ // removes it. Ask before deleting, so a machine that never had one — most of
453
+ // them: creating it needed an elevated shell — only ever sees a read-only
454
+ // query. /f so the delete is not an interactive prompt. 'removed', 'stuck'
455
+ // (it exists but the delete failed — a task created from an elevated shell
456
+ // can need one to delete), or null when there was none.
457
+ export function removeLegacyScheduledTask({ activate = defaultActivate } = {}) {
458
+ if (!activate('schtasks', ['/query', '/tn', WINDOWS_TASK_NAME])) return null;
459
+ return activate('schtasks', ['/delete', '/f', '/tn', WINDOWS_TASK_NAME]) ? 'removed' : 'stuck';
460
+ }
461
+
437
462
  export function installDaemonAutostart({
438
463
  platform = process.platform, dir = null, activate = defaultActivate, path = undefined,
464
+ start = restartDaemon,
439
465
  } = {}) {
440
- // Native Windows has no unit *file* — the Task Scheduler holds the record —
441
- // so it is handled before the file-based platforms below. It matters more
442
- // here than anywhere else: the transcript watcher lives in the daemon, and
443
- // headless has no pane monitor to fall back on, so without this a Windows
444
- // machine only ever catches limit stops through the StopFailure hook.
466
+ // Native Windows matters more than anywhere: the transcript watcher lives in
467
+ // the daemon, and headless has no pane monitor to fall back on. But nothing
468
+ // is registered with Windows to start it. Up to 1.19.1 this created a logon
469
+ // Scheduled Task (`schtasks /create /sc onlogon /tr "node … daemon"`), and
470
+ // Microsoft Defender flagged setup as Trojan:Win32/Commando.A!ml for it —
471
+ // an interpreter running a script from %APPDATA% at every logon is exactly
472
+ // how malware persists, and any logon entry (Run key, Startup folder) reads
473
+ // the same way. Without elevation schtasks refused the task anyway. So the
474
+ // daemon is started now, and started again on demand (spawn.js ensureDaemon).
445
475
  if (platform === 'win32') {
446
- // /f overwrites an existing task rather than failing with "already exists",
447
- // which is what re-running setup does every time.
448
- activate('schtasks', ['/create', '/f', '/tn', WINDOWS_TASK_NAME, '/sc', 'onlogon',
449
- '/tr', `"${process.execPath}" "${UNSNOOZE_BIN}" daemon`]);
450
- return `Scheduled Task \\${WINDOWS_TASK_NAME}`;
476
+ const marker = onDemandMarker(dir);
477
+ atomicWrite(marker, ON_DEMAND_NOTE);
478
+ // Same interlock as the unit platforms below: the real starter only runs
479
+ // for the marker it would actually read.
480
+ if (start !== restartDaemon || marker === DAEMON_ON_DEMAND) start();
481
+ return marker;
451
482
  }
452
483
  const target = autostartUnitPath({ platform, dir });
453
484
  if (!target) return null;
@@ -528,11 +559,30 @@ export function healDaemonAutostart({
528
559
  return installDaemonAutostart({ platform, dir, activate, path: better });
529
560
  }
530
561
 
531
- export function uninstallDaemonAutostart({ platform = process.platform, dir = null, activate = defaultActivate } = {}) {
562
+ export function uninstallDaemonAutostart({
563
+ platform = process.platform, dir = null, activate = defaultActivate, stop = stopDaemon,
564
+ say = () => {},
565
+ } = {}) {
532
566
  if (platform === 'win32') {
533
- // /f so a missing task is not an interactive prompt on an uninstall path.
534
- activate('schtasks', ['/delete', '/f', '/tn', WINDOWS_TASK_NAME]);
535
- return `Scheduled Task \\${WINDOWS_TASK_NAME}`;
567
+ const removed = [];
568
+ // The marker first, so nothing starts the daemon again once it is stopped
569
+ // (and an on-demand daemon that is only now starting sees it gone).
570
+ const marker = onDemandMarker(dir);
571
+ if (existsSync(marker)) {
572
+ try { unlinkSync(marker); removed.push(marker); } catch { /* still there — not reported as removed */ }
573
+ }
574
+ const live = marker === DAEMON_ON_DEMAND;
575
+ if (stop !== stopDaemon || live) stop();
576
+ // The interlock again: a relocated marker is a fixture, and the machine's
577
+ // real Task Scheduler is none of its business.
578
+ const act = (activate === defaultActivate && !live) ? () => false : activate;
579
+ const legacy = removeLegacyScheduledTask({ activate: act });
580
+ if (legacy === 'removed') removed.push(`Scheduled Task \\${WINDOWS_TASK_NAME}`);
581
+ else if (legacy === 'stuck') {
582
+ say(`could not delete the Scheduled Task \\${WINDOWS_TASK_NAME} an earlier version created; `
583
+ + `from an administrator shell run: schtasks /delete /tn ${WINDOWS_TASK_NAME} /f`);
584
+ }
585
+ return removed.length ? removed.join(' and ') : null;
536
586
  }
537
587
  if (platform === 'darwin') {
538
588
  const target = join(dir || autostartDir(platform), `${DAEMON_LABEL}.plist`);
@@ -696,8 +746,21 @@ export function cmdInstall(rest, { agents = enabledAgents() } = {}) {
696
746
 
697
747
  // 4. Daemon autostart (GUI-session watching), opt-in via --daemon / wizard.
698
748
  if (opts.daemon) {
699
- const target = installDaemonAutostart();
700
- if (target) console.log(`unsnooze: daemon autostart installed (${target}) — GUI sessions are watched`);
749
+ // On Windows the start's outcome is kept, so the message can be honest.
750
+ let started = null;
751
+ const target = installDaemonAutostart(process.platform === 'win32'
752
+ ? { start: () => { started = restartDaemon(); } } : {});
753
+ if (target && process.platform === 'win32') {
754
+ if (started) console.log(`unsnooze: daemon started in the background (pid ${started}) — GUI sessions are watched`);
755
+ else console.log('unsnooze: the daemon did not start — run `unsnooze daemon` to see why');
756
+ console.log('unsnooze: nothing is registered to run at sign-in (no Scheduled Task); your wrapped');
757
+ console.log('unsnooze: agents and the Claude hook start the daemon again whenever it is not running');
758
+ // A task left by 1.19.1 or earlier is deliberately not looked for here:
759
+ // node.exe spawning schtasks.exe is the pairing Defender reacted to, and
760
+ // setup should spawn nothing of the kind. Such a task only starts this
761
+ // same daemon at sign-in, which the pidfile tolerates; uninstall removes
762
+ // it.
763
+ } else if (target) console.log(`unsnooze: daemon autostart installed (${target}) — GUI sessions are watched`);
701
764
  else console.log('unsnooze: daemon autostart is not supported on this platform');
702
765
  }
703
766
 
@@ -724,7 +787,16 @@ export function cmdUninstall(rest) {
724
787
  const opts = parseArgs(rest);
725
788
  const explicitRc = rest.includes('--zshrc');
726
789
 
727
- // Stop the resumer/daemon first so it cannot keep writing state after hooks
790
+ // Whatever starts the daemon goes first — the launchd/systemd unit, or on
791
+ // Windows the marker the wrappers and the hook consult — so nothing can
792
+ // start it again mid-uninstall. On Windows this order also stops the daemon
793
+ // while its pidfile still proves which process it is, not after
794
+ // stopResumer has killed it and left a pid that may already be someone
795
+ // else's.
796
+ const autostart = uninstallDaemonAutostart({ say: m => console.log(`unsnooze: ${m}`) });
797
+ if (autostart) console.log(`unsnooze: daemon autostart removed (${autostart})`);
798
+
799
+ // Stop the resumer/daemon so it cannot keep writing state after hooks
728
800
  // are gone (zombie-daemon-running-deleted-code failure mode).
729
801
  try {
730
802
  const result = stopResumer();
@@ -780,9 +852,6 @@ export function cmdUninstall(rest) {
780
852
  }
781
853
  }
782
854
 
783
- const autostart = uninstallDaemonAutostart();
784
- if (autostart) console.log(`unsnooze: daemon autostart removed (${autostart})`);
785
-
786
855
  // Restore Claude statusLine if our usage shim was chained in.
787
856
  try {
788
857
  const r = uninstallStatuslineShim({ settingsPath: opts.settings });
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,6 +36,16 @@ 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`);
41
51
  return runPassthrough(agent, args);
@@ -49,7 +59,9 @@ function runPassthrough(agent, args) {
49
59
  return r.status ?? 1;
50
60
  }
51
61
 
52
- export function runLauncher(args, agentId = 'claude', { processBirthFn = processBirth } = {}) {
62
+ export function runLauncher(args, agentId = 'claude', {
63
+ processBirthFn = processBirth, ensureDaemonFn = ensureDaemon,
64
+ } = {}) {
53
65
  const agent = getAgent(agentId);
54
66
 
55
67
  // Recursion / nested-launch guard: inside an unsnooze-managed session, a
@@ -122,6 +134,12 @@ export function runLauncher(args, agentId = 'claude', { processBirthFn = process
122
134
  }
123
135
  }
124
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
+
125
143
  const rawPane = mux.currentPaneId();
126
144
  const paneOwner = resolvePaneOwner(mux.name, process.env);
127
145
  // A backend may know it cannot safely address the pane it is sitting in —
@@ -179,7 +197,7 @@ export function runLauncher(args, agentId = 'claude', { processBirthFn = process
179
197
  // the reason, never as a crash with no exit code for the resumer to read.
180
198
  let child;
181
199
  try {
182
- child = spawn(agent.bin, args, { stdio: 'inherit', env: childEnv });
200
+ child = spawn(agent.bin, args, { stdio: 'inherit', env: childEnv, windowsHide: hideAgentWindow() });
183
201
  } catch (err) {
184
202
  return launchFailed(agent, err);
185
203
  }
package/src/monitor.js CHANGED
@@ -18,12 +18,14 @@ 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';
24
25
  import { upsertSession, setStatus, readState, updateState } from './state.js';
25
26
  import { prepareCalibrationSample, applyCalibrationToState } from './usage.js';
26
27
  import { latestRateLimitFromTranscript } from './watchers/claude.js';
28
+ import { hasCodexProgressAfter } from './watchers/codex.js';
27
29
  import { spawnResumerIfNeeded, spawnDetached, monitorSpawnArgs, UNSNOOZE_BIN } from './spawn.js';
28
30
  import { restartOnVersionSkew, hasVersionSkew, PKG_VERSION } from './update-check.js';
29
31
  import { makeLogger } from './logger.js';
@@ -327,6 +329,16 @@ export function createMonitor({
327
329
  const visible = mux.capturePaneVisible
328
330
  ? await mux.capturePaneVisible(pane).catch(() => '')
329
331
  : text;
332
+ if (agent.id === 'codex' && Object.values(readState().sessions).some(rec =>
333
+ rec.agent === 'codex' && rec.mux === muxName && rec.pane === pane
334
+ && rec.paneOwner === paneOwner && (rec.leaseId ?? null) === leaseId
335
+ && ['resuming', 'failed'].includes(rec.status)
336
+ && pendingCodexPrompt(visible, rec.pendingPrompt))) {
337
+ // The resumer owns this unsubmitted draft. Its old banner is not a new
338
+ // stop; refreshing detectedAt here would invalidate the submit retry.
339
+ firstTick = false;
340
+ return;
341
+ }
330
342
  if (agent.menu && agent.menu.isPrompt(visible, PANE_SCAN_LINES)) {
331
343
  if (!getConfig('menuAutoAnswer')) {
332
344
  // Watch-only mode: record the stop (reset time may not be visible
@@ -399,17 +411,21 @@ export function createMonitor({
399
411
  }
400
412
 
401
413
  // A banner leaving the 12-line scan is not evidence of a resume: ordinary
402
- // output (including background-agent notices) can scroll it away. Claude
403
- // is terminal only after its parent transcript records newer non-error
404
- // assistant usage. Other adapters retain their legacy behavior until they
405
- // expose an equally authoritative progress signal.
414
+ // output (including background-agent notices) can scroll it away, and in
415
+ // Codex a keypress or tab switch can hide it while the session is still
416
+ // stopped. Claude is terminal only after its parent transcript records
417
+ // newer non-error assistant usage; Codex only after its rollout records
418
+ // newer model output (or, with no rollout to read, the legacy rule).
419
+ // Other adapters retain their legacy behavior until they expose an
420
+ // equally authoritative progress signal.
406
421
  if (trackedKey) {
407
422
  const state = readState();
408
423
  const rec = state.sessions[trackedKey];
409
424
  if (rec && rec.status === 'stopped') {
410
425
  const cutoff = rec.bannerAt ?? rec.detectedAt;
411
- const progressed = agent.id !== 'claude'
412
- || hasClaudeParentUsageAfter(rec, cutoff);
426
+ const progressed = agent.id === 'claude' ? hasClaudeParentUsageAfter(rec, cutoff)
427
+ : agent.id === 'codex' ? hasCodexProgressAfter(rec, cutoff) !== false
428
+ : true;
413
429
  if (progressed) {
414
430
  const next = setStatus(trackedKey, 'resumed', {
415
431
  lastAttemptAt: Date.now(), bannerCleared: true, lastError: null,
package/src/patterns.js CHANGED
@@ -96,6 +96,25 @@ function quotedLineFlags(lines) {
96
96
  return flags;
97
97
  }
98
98
 
99
+ // A banner the terminal soft-wraps continues on the next rows until its
100
+ // closing punctuation. Codex writes one long line, so at some widths the
101
+ // reset clock lands on a later row than the phrase that introduces it:
102
+ // "... or try" / "again at 6:02 PM." "... try again at 6:02" / "PM."
103
+ // Rejoin those rows. Stop at a blank row, at the next TUI glyph, or once the
104
+ // sentence has ended, so a footer hint below the banner is never absorbed.
105
+ const WRAP_MAX_ROWS = 3;
106
+ const NEW_BLOCK = /^\s*[›•■⏺⚠>]/;
107
+ function joinWrapped(lines, idx) {
108
+ let joined = lines[idx].trim();
109
+ for (let j = idx + 1; j <= idx + WRAP_MAX_ROWS && j < lines.length; j++) {
110
+ if (/[.!?]$/.test(joined)) break;
111
+ const next = lines[j].trim();
112
+ if (!next || NEW_BLOCK.test(lines[j])) break;
113
+ joined += ` ${next}`;
114
+ }
115
+ return joined;
116
+ }
117
+
99
118
  export function detectLimit(text, tailLines = 12, sets = claudePatterns) {
100
119
  const lines = contentLines(text, tailLines);
101
120
 
@@ -138,7 +157,10 @@ export function detectLimit(text, tailLines = 12, sets = claudePatterns) {
138
157
  // Most recent reset line wins (the TUI never clears old banners from scrollback).
139
158
  let resetLine = null;
140
159
  for (let i = lines.length - 1; i >= 0; i--) {
141
- if (sets.resetPatterns.some(p => p.test(lines[i]))) { resetLine = lines[i].trim(); break; }
160
+ if (sets.resetPatterns.some(p => p.test(lines[i]))) {
161
+ resetLine = sets.bannerWraps ? joinWrapped(lines, i) : lines[i].trim();
162
+ break;
163
+ }
142
164
  }
143
165
  if (!resetLine) {
144
166
  for (let i = lines.length - 1; i >= 0; i--) {
package/src/resumer.js CHANGED
@@ -16,6 +16,7 @@ import {
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,
@@ -224,7 +225,7 @@ function paneStopOwner(rec, state = readState()) {
224
225
  // One live pane can accept one wake. Legacy/subagent detections outside the
225
226
  // short ingest-dedupe window are therefore collapsed here: the newest active
226
227
  // stop owns the target and older active records become explicit history.
227
- function claimStopForResume(rec) {
228
+ function claimStopForResume(rec, pendingPrompt = null) {
228
229
  const cutoff = stopEpisodeAt(rec);
229
230
  let claimed = false;
230
231
  updateState(state => {
@@ -254,6 +255,7 @@ function claimStopForResume(rec) {
254
255
  // single point where unsnooze takes ownership of a stop, whether the
255
256
  // wake is typed, driven through the menu, or reopened.
256
257
  resumedBy: 'unsnooze',
258
+ pendingPrompt, submitRetries: 0,
257
259
  });
258
260
  claimed = true;
259
261
  }
@@ -713,7 +715,7 @@ export async function dispatchOne(rec, {
713
715
  // assessPane is asynchronous. Close the final human/provider-resume race
714
716
  // after assessment and immediately before the keystroke.
715
717
  if (finishIfClaudeProgressed(rec, agent)) return 'already-resumed';
716
- if (!claimStopForResume(rec)) return 'stale';
718
+ if (!claimStopForResume(rec, agent.id === 'codex' ? resumeMessage : null)) return 'stale';
717
719
  await mux.sendText(rec.pane, resumeMessage);
718
720
  log(`${key}: sent continue via ${rec.mux} ${rec.paneOwner ?? '-'}:${rec.pane}`);
719
721
  notifyContext();
@@ -874,11 +876,11 @@ function recordVerifyRetry(rec, lastError) {
874
876
  let applied;
875
877
  if (verifyRetries >= MAX_VERIFY_RETRIES) {
876
878
  const attempts = (rec.attempts || 0) + 1;
877
- applied = transitionStopEpisode(rec, 'stopped', {
879
+ applied = transitionStopEpisode(rec, rec.pendingPrompt ? 'failed' : 'stopped', {
878
880
  attempts,
879
881
  // Same backoff (and same manual exemption) as routed retries.
880
882
  resetAt: rec.manual ? Date.now() : Date.now() + retryBackoffMs(attempts),
881
- lastError,
883
+ lastError: rec.pendingPrompt ? `${lastError} — wake submission unconfirmed; inspect the Codex pane` : lastError,
882
884
  verifyRetries: 0, resumeEpisodeAt: null,
883
885
  }, { expect: ['resuming'] });
884
886
  } else {
@@ -917,6 +919,29 @@ export async function verifyOne(key, { resolveMux = resolveRecordMux } = {}) {
917
919
  catch (err) {
918
920
  return recordVerifyRetry(rec, `verify capture: ${err.message}`);
919
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
+ }
920
945
  const d = detectLimit(text, PANE_SCAN_LINES, agent.patterns);
921
946
  if (d.hit || (agent.menu && agent.menu.isPrompt(text, PANE_SCAN_LINES))) {
922
947
  // Limit not actually reset — reschedule from the fresh banner.