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/CHANGELOG.md +108 -0
- package/README.md +24 -13
- package/bin/unsnooze.js +90 -64
- package/package.json +1 -1
- package/src/agents/codex.js +52 -1
- package/src/agents/opencode.js +2 -1
- package/src/config.js +16 -1
- package/src/doctor.js +4 -5
- package/src/hook.js +6 -2
- package/src/install.js +103 -34
- package/src/launcher.js +21 -3
- package/src/monitor.js +22 -6
- package/src/patterns.js +23 -1
- package/src/resumer.js +29 -4
- package/src/spawn.js +262 -9
- package/src/state.js +15 -0
- package/src/watchers/codex.js +88 -7
- package/src/wizard.js +8 -4
- package/src/workspace.js +4 -1
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
|
-
//
|
|
255
|
-
//
|
|
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
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
// lesser of the two, and
|
|
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
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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({
|
|
562
|
+
export function uninstallDaemonAutostart({
|
|
563
|
+
platform = process.platform, dir = null, activate = defaultActivate, stop = stopDaemon,
|
|
564
|
+
say = () => {},
|
|
565
|
+
} = {}) {
|
|
532
566
|
if (platform === 'win32') {
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
700
|
-
|
|
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
|
-
//
|
|
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', {
|
|
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
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
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
|
|
412
|
-
|
|
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]))) {
|
|
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.
|