unsnooze 1.12.2 → 1.14.0
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 +84 -0
- package/README.md +286 -22
- package/bin/unsnooze.js +52 -3
- package/package.json +7 -4
- package/src/agents/agy.js +2 -0
- package/src/agents/claude.js +2 -0
- package/src/agents/codex.js +2 -0
- package/src/agents/grok.js +2 -0
- package/src/agents/index.js +7 -1
- package/src/agents/kimi.js +2 -0
- package/src/agents/opencode.js +2 -0
- package/src/agents/qwen.js +2 -0
- package/src/askpass.js +188 -0
- package/src/cli.js +149 -25
- package/src/config.js +15 -0
- package/src/dashboard/App.js +390 -0
- package/src/dashboard/Logo.js +74 -0
- package/src/dashboard/components/TextInput.js +63 -0
- package/src/dashboard/data.js +143 -0
- package/src/dashboard/mark.js +51 -0
- package/src/dashboard/mouse-protocol.js +75 -0
- package/src/dashboard/mouse.js +125 -0
- package/src/dashboard/run.js +108 -0
- package/src/dashboard/tabs/DoctorTab.js +35 -0
- package/src/dashboard/tabs/FleetTab.js +94 -0
- package/src/dashboard/tabs/LogsTab.js +28 -0
- package/src/dashboard/tabs/PromptsTab.js +406 -0
- package/src/dashboard/tabs/SessionsTab.js +33 -0
- package/src/dashboard/tabs/StatusTab.js +58 -0
- package/src/dashboard/tabs/UsageTab.js +68 -0
- package/src/dashboard/theme.js +32 -0
- package/src/doctor.js +22 -16
- package/src/fleet.js +927 -0
- package/src/hook.js +19 -0
- package/src/install.js +9 -0
- package/src/monitor.js +14 -0
- package/src/prompt-queue.js +561 -0
- package/src/prompt.js +411 -0
- package/src/remote.js +223 -0
- package/src/resumer.js +60 -16
- package/src/settings.js +13 -0
- package/src/state.js +33 -9
- package/src/time-parser.js +15 -1
- package/src/tui.js +395 -0
- package/src/usage.js +1537 -0
- package/src/watcher.js +79 -8
- package/src/watchers/claude.js +3 -0
- package/src/watchers/codex.js +8 -8
- package/src/wizard.js +2 -1
package/src/hook.js
CHANGED
|
@@ -19,6 +19,7 @@ import { getMultiplexer } from './multiplexer.js';
|
|
|
19
19
|
import { spawnResumerIfNeeded } from './spawn.js';
|
|
20
20
|
import { makeLogger } from './logger.js';
|
|
21
21
|
import { addressHash } from './lease.js';
|
|
22
|
+
import { prepareCalibrationSample, applyCalibrationToState } from './usage.js';
|
|
22
23
|
|
|
23
24
|
const log = makeLogger('hook');
|
|
24
25
|
|
|
@@ -114,6 +115,20 @@ export async function runHook(rest = []) {
|
|
|
114
115
|
try { muxSession = await mux.sessionForPane(pane); } catch { muxSession = null; }
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
// Raw reset (no margin) for calibration window math; `at` keeps the resume margin.
|
|
119
|
+
const rawResetMs = Number.isFinite(at) ? at - RESET_MARGIN_MS : null;
|
|
120
|
+
let calSample = null;
|
|
121
|
+
try {
|
|
122
|
+
calSample = prepareCalibrationSample({
|
|
123
|
+
agent: agent.id,
|
|
124
|
+
limitType,
|
|
125
|
+
resetAtMs: rawResetMs,
|
|
126
|
+
now: detectedAt,
|
|
127
|
+
});
|
|
128
|
+
} catch (err) {
|
|
129
|
+
log(`calibration prepare failed: ${err.message}`);
|
|
130
|
+
}
|
|
131
|
+
// Stop record + calibration sample in ONE locked update (plan §6).
|
|
117
132
|
upsertSession({
|
|
118
133
|
sessionId: sessionId || null,
|
|
119
134
|
cwd,
|
|
@@ -135,6 +150,10 @@ export async function runHook(rest = []) {
|
|
|
135
150
|
lastAttemptAt: null,
|
|
136
151
|
lastError: null,
|
|
137
152
|
...(source === 'fallback' ? { probeCount: 0 } : {}),
|
|
153
|
+
}, {
|
|
154
|
+
after: calSample
|
|
155
|
+
? (state) => applyCalibrationToState(state, calSample)
|
|
156
|
+
: null,
|
|
138
157
|
});
|
|
139
158
|
log(`recorded limit stop: session=${sessionId} resetAt=${new Date(at).toISOString()} (${source})`);
|
|
140
159
|
spawnResumerIfNeeded();
|
package/src/install.js
CHANGED
|
@@ -18,6 +18,7 @@ import { xmlEscape } from './notify.js';
|
|
|
18
18
|
import { installGrokHooks, uninstallGrokHooks } from './agents/grok.js';
|
|
19
19
|
import { findCsgProcesses, findCsgAutostarts } from './doctor.js';
|
|
20
20
|
import { UNSNOOZE_BIN, stopResumer } from './spawn.js';
|
|
21
|
+
import { uninstallStatuslineShim } from './usage.js';
|
|
21
22
|
|
|
22
23
|
const FENCE_OPEN = '# >>> unsnooze >>>';
|
|
23
24
|
const FENCE_CLOSE = '# <<< unsnooze <<<';
|
|
@@ -442,6 +443,14 @@ export function cmdUninstall(rest) {
|
|
|
442
443
|
const autostart = uninstallDaemonAutostart();
|
|
443
444
|
if (autostart) console.log(`unsnooze: daemon autostart removed (${autostart})`);
|
|
444
445
|
|
|
446
|
+
// Restore Claude statusLine if our usage shim was chained in.
|
|
447
|
+
try {
|
|
448
|
+
const r = uninstallStatuslineShim({ settingsPath: opts.settings });
|
|
449
|
+
if (r.removed) console.log('unsnooze: statusline usage shim removed');
|
|
450
|
+
} catch (err) {
|
|
451
|
+
console.log(`unsnooze: could not remove statusline shim (${err.message})`);
|
|
452
|
+
}
|
|
453
|
+
|
|
445
454
|
if (opts.purge) {
|
|
446
455
|
rmSync(STATE_DIR, { recursive: true, force: true });
|
|
447
456
|
console.log(`unsnooze: state dir ${STATE_DIR} removed`);
|
package/src/monitor.js
CHANGED
|
@@ -22,6 +22,7 @@ import { getConfig } from './settings.js';
|
|
|
22
22
|
import { notify } from './notify.js';
|
|
23
23
|
import { parseResetTime, resetAtMs, sourceRank } from './time-parser.js';
|
|
24
24
|
import { upsertSession, setStatus, readState, updateState } from './state.js';
|
|
25
|
+
import { prepareCalibrationSample, applyCalibrationToState } from './usage.js';
|
|
25
26
|
import { latestRateLimitFromTranscript } from './watchers/claude.js';
|
|
26
27
|
import { spawnResumerIfNeeded } from './spawn.js';
|
|
27
28
|
import { makeLogger } from './logger.js';
|
|
@@ -136,6 +137,17 @@ export function createMonitor({
|
|
|
136
137
|
if (typeof mux.sessionForPane === 'function') {
|
|
137
138
|
try { muxSession = await mux.sessionForPane(pane); } catch { muxSession = null; }
|
|
138
139
|
}
|
|
140
|
+
// Calibration sample prepared outside the lock; applied in the same
|
|
141
|
+
// updateState as the stop upsert (usage ladder ground-truth, 1.13).
|
|
142
|
+
let calSample = null;
|
|
143
|
+
try {
|
|
144
|
+
calSample = prepareCalibrationSample({
|
|
145
|
+
agent: agent.id,
|
|
146
|
+
limitType: resolved.limitType,
|
|
147
|
+
resetAtMs: Number.isFinite(at) ? at - RESET_MARGIN_MS : null,
|
|
148
|
+
now: resolved.detectedAt,
|
|
149
|
+
});
|
|
150
|
+
} catch { /* best-effort */ }
|
|
139
151
|
const state = upsertSession({
|
|
140
152
|
sessionId: resolved.sessionId, cwd, pane, mux: muxName, paneOwner, leaseId,
|
|
141
153
|
agent: agent.id, muxSession,
|
|
@@ -145,6 +157,8 @@ export function createMonitor({
|
|
|
145
157
|
resetAt: at, resetSource: source,
|
|
146
158
|
attempts: 0, lastAttemptAt: null, lastError: null,
|
|
147
159
|
...(source === 'fallback' ? { probeCount: 0 } : {}),
|
|
160
|
+
}, {
|
|
161
|
+
after: calSample ? (s) => applyCalibrationToState(s, calSample) : null,
|
|
148
162
|
});
|
|
149
163
|
trackedKey = resolved.sessionId
|
|
150
164
|
|| Object.values(state.sessions).find(s => s.mux === muxName
|
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
// One-shot prompt queue: user-predefined prompts that, once a usage limit
|
|
2
|
+
// clears, spawn a NEW agent session in a project cwd and type the prompt in.
|
|
3
|
+
// This module owns state plumbing (CRUD, sanitization, due-ness) AND
|
|
4
|
+
// dispatch — spawning the fresh window, typing the prompt, verifying it
|
|
5
|
+
// landed. tickPromptQueue is called from the resumer's own loop (additive
|
|
6
|
+
// only — see resumer.js) but this module never imports the resumer
|
|
7
|
+
// statically, to avoid a load cycle: resumer.js dynamic-imports us instead.
|
|
8
|
+
|
|
9
|
+
import { randomBytes } from 'node:crypto';
|
|
10
|
+
import { isAbsolute } from 'node:path';
|
|
11
|
+
import { updateState, readState, prune } from './state.js';
|
|
12
|
+
import { listAgents, getAgent } from './agents/index.js';
|
|
13
|
+
import { resolveClaudeResetAnchor, readUsageStore, readExactClaudeFromStatusline } from './usage.js';
|
|
14
|
+
import { spawnResumerIfNeeded, UNSNOOZE_BIN } from './spawn.js';
|
|
15
|
+
import { getMultiplexer } from './multiplexer.js';
|
|
16
|
+
import { createLeaseId } from './lease.js';
|
|
17
|
+
import { parseResetTime, resetAtMs } from './time-parser.js';
|
|
18
|
+
import { detectLimit } from './patterns.js';
|
|
19
|
+
import { notify } from './notify.js';
|
|
20
|
+
import { attachHint } from './reap.js';
|
|
21
|
+
import { makeLogger } from './logger.js';
|
|
22
|
+
import { awaitReadyAndSend, retryBackoffMs } from './resumer.js';
|
|
23
|
+
import {
|
|
24
|
+
RESUME_SESSION_NAME, VERIFY_DELAY_MS, STAGGER_MS, CAPTURE_LINES,
|
|
25
|
+
PANE_SCAN_LINES, RESET_MARGIN_MS, PROBE_INTERVAL_MS, MAX_RESUME_ATTEMPTS,
|
|
26
|
+
READY_TIMEOUT_MS,
|
|
27
|
+
} from './config.js';
|
|
28
|
+
|
|
29
|
+
const log = makeLogger('prompt-queue');
|
|
30
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
31
|
+
|
|
32
|
+
const CREATED_BY = ['local', 'remote'];
|
|
33
|
+
const MODES = ['next-reset', 'at', 'now'];
|
|
34
|
+
const QUEUE_CAP = 50;
|
|
35
|
+
const MAX_CWD_LEN = 1024;
|
|
36
|
+
const MAX_PROMPT_LEN = 4000;
|
|
37
|
+
const TERMINAL_STATUSES = ['delivered', 'failed', 'cancelled'];
|
|
38
|
+
const AGENT_IDS = new Set(listAgents().map(a => a.id));
|
|
39
|
+
|
|
40
|
+
// Strip full ANSI/VT escape sequences (CSI, OSC, DCS/other string types, bare
|
|
41
|
+
// two-char escapes) so a queued prompt can never inject cursor moves, title
|
|
42
|
+
// changes, or hyperlinks into whatever pane later types it out.
|
|
43
|
+
/* eslint-disable no-control-regex */
|
|
44
|
+
const ANSI_SEQ_RE =
|
|
45
|
+
/\x1b(?:\][^\x07\x1b]*(?:\x07|\x1b\\)?|[PX^_][^\x1b]*(?:\x1b\\)?|\[[0-9:;<=>?]*[ -/]*[@-~]|[ -~])/g;
|
|
46
|
+
// Remaining C0/C1 control bytes and DEL, except \n (0x0a) and \t (0x09) which
|
|
47
|
+
// a multi-line prompt legitimately needs.
|
|
48
|
+
const CONTROL_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g;
|
|
49
|
+
/* eslint-enable no-control-regex */
|
|
50
|
+
|
|
51
|
+
export function sanitizePrompt(text) {
|
|
52
|
+
if (text == null) return '';
|
|
53
|
+
return String(text)
|
|
54
|
+
.replace(/\r\n/g, '\n')
|
|
55
|
+
.replace(/\r/g, '\n')
|
|
56
|
+
.replace(ANSI_SEQ_RE, '')
|
|
57
|
+
.replace(CONTROL_RE, '')
|
|
58
|
+
.trim()
|
|
59
|
+
.slice(0, MAX_PROMPT_LEN);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// `queueAdd` never throws on validation — callers (CLI, remote ingest) render
|
|
63
|
+
// `error` directly to the user.
|
|
64
|
+
// `spawn`/`spawnFn` let callers (and tests) suppress or observe the
|
|
65
|
+
// wake-a-transient-resumer side effect without forking a real process —
|
|
66
|
+
// production callers (CLI, remote ingest) get it for free via the defaults.
|
|
67
|
+
export function queueAdd({
|
|
68
|
+
cwd, agent, prompt, mode = 'next-reset', atMs = null, createdBy = 'local',
|
|
69
|
+
spawn = true, spawnFn = spawnResumerIfNeeded,
|
|
70
|
+
} = {}) {
|
|
71
|
+
if (typeof cwd !== 'string' || !isAbsolute(cwd) || cwd.length > MAX_CWD_LEN) {
|
|
72
|
+
return { ok: false, error: `cwd must be an absolute path of at most ${MAX_CWD_LEN} characters` };
|
|
73
|
+
}
|
|
74
|
+
if (!AGENT_IDS.has(agent)) {
|
|
75
|
+
return { ok: false, error: `unknown agent: ${agent}` };
|
|
76
|
+
}
|
|
77
|
+
const sanitized = sanitizePrompt(prompt);
|
|
78
|
+
if (!sanitized) {
|
|
79
|
+
return { ok: false, error: 'prompt is empty after sanitization' };
|
|
80
|
+
}
|
|
81
|
+
if (!MODES.includes(mode)) {
|
|
82
|
+
return { ok: false, error: `mode must be one of: ${MODES.join(', ')}` };
|
|
83
|
+
}
|
|
84
|
+
if (mode === 'at' && !Number.isFinite(atMs)) {
|
|
85
|
+
return { ok: false, error: 'atMs must be a finite number when mode is "at"' };
|
|
86
|
+
}
|
|
87
|
+
if (!CREATED_BY.includes(createdBy)) {
|
|
88
|
+
return { ok: false, error: `createdBy must be one of: ${CREATED_BY.join(', ')}` };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let result;
|
|
92
|
+
updateState(state => {
|
|
93
|
+
prune(state);
|
|
94
|
+
const dup = state.promptQueue.find(e => e.status === 'pending'
|
|
95
|
+
&& e.cwd === cwd && e.agent === agent && e.prompt === sanitized);
|
|
96
|
+
if (dup) {
|
|
97
|
+
result = { ok: false, error: 'duplicate', existing: { ...dup } };
|
|
98
|
+
return state;
|
|
99
|
+
}
|
|
100
|
+
const nonTerminal = state.promptQueue.filter(e => !TERMINAL_STATUSES.includes(e.status));
|
|
101
|
+
if (nonTerminal.length >= QUEUE_CAP) {
|
|
102
|
+
result = { ok: false, error: 'queue full' };
|
|
103
|
+
return state;
|
|
104
|
+
}
|
|
105
|
+
const entry = {
|
|
106
|
+
id: `p-${randomBytes(4).toString('hex')}`,
|
|
107
|
+
cwd,
|
|
108
|
+
agent,
|
|
109
|
+
prompt: sanitized,
|
|
110
|
+
mode,
|
|
111
|
+
atMs: mode === 'at' ? atMs : null,
|
|
112
|
+
notBefore: 0,
|
|
113
|
+
createdAt: Date.now(),
|
|
114
|
+
createdBy,
|
|
115
|
+
status: 'pending',
|
|
116
|
+
attempts: 0,
|
|
117
|
+
lastError: null,
|
|
118
|
+
deliveredAt: null,
|
|
119
|
+
sentAt: null,
|
|
120
|
+
launchedAt: null,
|
|
121
|
+
pane: null,
|
|
122
|
+
muxSession: null,
|
|
123
|
+
leaseId: null,
|
|
124
|
+
};
|
|
125
|
+
state.promptQueue.push(entry);
|
|
126
|
+
result = { ok: true, entry: { ...entry } };
|
|
127
|
+
return state;
|
|
128
|
+
});
|
|
129
|
+
// Wake a transient resumer so a due entry (mode 'now'/'at') doesn't sit
|
|
130
|
+
// until the next unrelated tick — mirrors markResumeNow's spawn-on-mutate.
|
|
131
|
+
if (result.ok && spawn) spawnFn();
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function queueList() {
|
|
136
|
+
return [...readState().promptQueue]
|
|
137
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
138
|
+
.map(e => ({ ...e }));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// pending/launching → cancelled (terminal, later pruned). Already-terminal or
|
|
142
|
+
// unknown ids are a no-op — the caller should treat `false` as "nothing to do".
|
|
143
|
+
export function queueRemove(id) {
|
|
144
|
+
let removed = false;
|
|
145
|
+
updateState(state => {
|
|
146
|
+
const entry = state.promptQueue.find(e => e.id === id);
|
|
147
|
+
if (entry && ['pending', 'launching'].includes(entry.status)) {
|
|
148
|
+
entry.status = 'cancelled';
|
|
149
|
+
removed = true;
|
|
150
|
+
}
|
|
151
|
+
return state;
|
|
152
|
+
});
|
|
153
|
+
return removed;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function queueClear() {
|
|
157
|
+
let count = 0;
|
|
158
|
+
updateState(state => {
|
|
159
|
+
for (const entry of state.promptQueue) {
|
|
160
|
+
if (['pending', 'launching'].includes(entry.status)) {
|
|
161
|
+
entry.status = 'cancelled';
|
|
162
|
+
count += 1;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return state;
|
|
166
|
+
});
|
|
167
|
+
return count;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Min future resetAt among an agent's stopped/resuming session records, with
|
|
171
|
+
// the same 60s margin peel resolveClaudeResetAnchor uses (usage.js:649) —
|
|
172
|
+
// `resetAt` in the ledger already includes RESET_MARGIN_MS.
|
|
173
|
+
function bestRecordAnchor(agentId, sessions, now) {
|
|
174
|
+
const list = sessions ? Object.values(sessions) : Object.values(readState().sessions || {});
|
|
175
|
+
let best = null;
|
|
176
|
+
for (const s of list) {
|
|
177
|
+
if (s.agent !== agentId) continue;
|
|
178
|
+
if (!['stopped', 'resuming'].includes(s.status)) continue;
|
|
179
|
+
if (!Number.isFinite(s.resetAt)) continue;
|
|
180
|
+
const raw = s.resetAt - 60_000;
|
|
181
|
+
if (raw <= now - 60_000) continue;
|
|
182
|
+
if (best == null || raw < best) best = raw;
|
|
183
|
+
}
|
|
184
|
+
return best;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Best-known reset-window anchor for an agent. Never fabricates a time: no
|
|
188
|
+
// signal → { resetAtMs: null, source: null }.
|
|
189
|
+
export function resolveAgentResetAnchor(agentId, { sessions = null, now = Date.now() } = {}) {
|
|
190
|
+
if (agentId === 'claude') {
|
|
191
|
+
const exactClaude = readExactClaudeFromStatusline({ now });
|
|
192
|
+
const { resetAtMs, source } = resolveClaudeResetAnchor({ exactClaude, sessions, now });
|
|
193
|
+
return { resetAtMs, source };
|
|
194
|
+
}
|
|
195
|
+
if (agentId === 'codex') {
|
|
196
|
+
const samples = (readUsageStore().samples || [])
|
|
197
|
+
.filter(s => s.agent === 'codex' && Number.isFinite(s.at));
|
|
198
|
+
if (samples.length) {
|
|
199
|
+
const latest = samples.reduce((a, b) => (b.at > a.at ? b : a));
|
|
200
|
+
const resetsAtMs = latest.primary?.resetsAtMs;
|
|
201
|
+
if (Number.isFinite(resetsAtMs) && resetsAtMs > now) {
|
|
202
|
+
return { resetAtMs: resetsAtMs, source: 'usage-store' };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const recordBest = bestRecordAnchor('codex', sessions, now);
|
|
206
|
+
return recordBest == null ? { resetAtMs: null, source: null } : { resetAtMs: recordBest, source: 'record' };
|
|
207
|
+
}
|
|
208
|
+
const recordBest = bestRecordAnchor(agentId, sessions, now);
|
|
209
|
+
return recordBest == null ? { resetAtMs: null, source: null } : { resetAtMs: recordBest, source: 'record' };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Pending entries due now, FIFO. `anchors` (agentId -> {resetAtMs}) lets
|
|
213
|
+
// tests/callers inject reset knowledge; when omitted, resolved once per
|
|
214
|
+
// distinct agent among the pending 'next-reset' entries.
|
|
215
|
+
export function duePromptEntries(now = Date.now(), { anchors = null } = {}) {
|
|
216
|
+
const pending = queueList().filter(e => e.status === 'pending');
|
|
217
|
+
const resolved = anchors ? anchors : {};
|
|
218
|
+
if (!anchors) {
|
|
219
|
+
const agentIds = new Set(pending.filter(e => e.mode === 'next-reset').map(e => e.agent));
|
|
220
|
+
for (const id of agentIds) resolved[id] = resolveAgentResetAnchor(id, { now });
|
|
221
|
+
}
|
|
222
|
+
return pending.filter(e => {
|
|
223
|
+
// notBefore is a universal backoff floor — set by a failed dispatch
|
|
224
|
+
// attempt (dispatchPromptEntry/verifyPromptEntry) regardless of mode, so
|
|
225
|
+
// it must gate 'now'/'at' entries too, not just 'next-reset'. Without
|
|
226
|
+
// this, a failing 'now' entry gets redispatched on literally the very
|
|
227
|
+
// next tick and burns MAX_RESUME_ATTEMPTS in milliseconds.
|
|
228
|
+
if (now < e.notBefore) return false;
|
|
229
|
+
if (e.mode === 'now') return true;
|
|
230
|
+
if (e.mode === 'at') return now >= e.atMs;
|
|
231
|
+
// next-reset
|
|
232
|
+
const resetAtMs = resolved[e.agent]?.resetAtMs ?? null;
|
|
233
|
+
return resetAtMs === null || resetAtMs <= now;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- delivery -----------------------------------------------------------
|
|
238
|
+
// Turns a due entry into a fresh agent session: open a window in the
|
|
239
|
+
// daemon's resume session, wait for the TUI to go idle, type the prompt
|
|
240
|
+
// once. Structurally mirrors resumer.js reopen()/verifyOne(), but there is
|
|
241
|
+
// no prior pane/session to revive — every dispatch is a brand-new window.
|
|
242
|
+
|
|
243
|
+
function selfCommand() {
|
|
244
|
+
return process.env.UNSNOOZE_SELF
|
|
245
|
+
? [process.env.UNSNOOZE_SELF]
|
|
246
|
+
: [process.execPath, UNSNOOZE_BIN];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function dispatchEnv(entry, leaseId, mux, target) {
|
|
250
|
+
const env = { UNSNOOZE_MUX: mux.name, UNSNOOZE_LEASE_ID: leaseId, UNSNOOZE_CWD: entry.cwd };
|
|
251
|
+
// tmux pane ids are server-global — paneOwner is always null there. Only
|
|
252
|
+
// zellij needs UNSNOOZE_PANE_OWNER so bind() can address the right session
|
|
253
|
+
// (same reasoning as resumer.js's reopenEnv).
|
|
254
|
+
if (mux.name === 'zellij') env.UNSNOOZE_PANE_OWNER = target;
|
|
255
|
+
return env;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Notify context shape mirrors resumer.js's ctxOf(rec), but prompt-queue
|
|
259
|
+
// entries have no persisted mux-backend/paneOwner fields (every dispatch
|
|
260
|
+
// always targets RESUME_SESSION_NAME on whichever mux the caller resolved),
|
|
261
|
+
// so the live mux object supplies mux.name/mux.owner instead of a record.
|
|
262
|
+
function queueNotifyContext(mux, pane) {
|
|
263
|
+
return { mux: mux?.name ?? null, pane, paneOwner: mux?.owner ?? null };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function updateEntry(id, patch) {
|
|
267
|
+
let updated = null;
|
|
268
|
+
updateState(state => {
|
|
269
|
+
const e = state.promptQueue.find(x => x.id === id);
|
|
270
|
+
if (e) {
|
|
271
|
+
Object.assign(e, typeof patch === 'function' ? patch(e) : patch);
|
|
272
|
+
updated = { ...e };
|
|
273
|
+
}
|
|
274
|
+
return state;
|
|
275
|
+
});
|
|
276
|
+
return updated;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Shared "give up on this attempt, back off" landing spot for timeout /
|
|
280
|
+
// unparseable-limit / new-window-threw outcomes. CAS-guarded on 'launching':
|
|
281
|
+
// a concurrent queueRemove (dashboard 'x'/'d', or another process) may have
|
|
282
|
+
// already moved the entry to 'cancelled' between the caller's read and this
|
|
283
|
+
// write — a cancelled entry must never be resurrected back to 'pending'.
|
|
284
|
+
// updateEntry always returns the current post-CAS row (winner's write, or
|
|
285
|
+
// the loser's unchanged snapshot), so returning it either way is correct.
|
|
286
|
+
function retryPromptEntry(entry, { notBefore, lastError }) {
|
|
287
|
+
return updateEntry(entry.id, e => {
|
|
288
|
+
if (e.status !== 'launching') return {};
|
|
289
|
+
return {
|
|
290
|
+
status: 'pending',
|
|
291
|
+
attempts: (e.attempts || 0) + 1,
|
|
292
|
+
notBefore,
|
|
293
|
+
lastError,
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Same CAS concern as retryPromptEntry, but this one fires before the
|
|
299
|
+
// pending→launching transition (attempts-cap check at the top of
|
|
300
|
+
// dispatchPromptEntry), so the guard is on 'pending' instead — and only a
|
|
301
|
+
// winning transition may fire the "failed" notification (a CAS loss must
|
|
302
|
+
// stay silent: no bogus notify for an entry someone else already moved).
|
|
303
|
+
function failPromptEntry(entry, lastError, { mux = null, notifier = notify } = {}) {
|
|
304
|
+
let transitioned = false;
|
|
305
|
+
const updated = updateEntry(entry.id, e => {
|
|
306
|
+
if (e.status !== 'pending') return {};
|
|
307
|
+
transitioned = true;
|
|
308
|
+
return { status: 'failed', lastError };
|
|
309
|
+
});
|
|
310
|
+
if (transitioned && updated) {
|
|
311
|
+
notifier(
|
|
312
|
+
'queued prompt failed ⚠️',
|
|
313
|
+
`${updated.cwd}: ${updated.attempts} attempts failed — check \`unsnooze prompt list\``,
|
|
314
|
+
{ context: queueNotifyContext(mux, updated.pane), priority: 4 },
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
return updated;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Parsed reset banner → notBefore = the parsed epoch; unparseable/no banner
|
|
321
|
+
// → notBefore = now + retryBackoffMs(attempts-after-this-one). Shared by the
|
|
322
|
+
// dispatch-time immediate-limit path and verifyPromptEntry's post-hoc check.
|
|
323
|
+
function limitNotBefore(resetLine, attemptsAfter, now) {
|
|
324
|
+
if (resetLine) {
|
|
325
|
+
const { at } = resetAtMs(parseResetTime(resetLine), {
|
|
326
|
+
marginMs: RESET_MARGIN_MS, fallbackMs: PROBE_INTERVAL_MS, now: new Date(now),
|
|
327
|
+
});
|
|
328
|
+
return at;
|
|
329
|
+
}
|
|
330
|
+
return now + retryBackoffMs(attemptsAfter);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// dispatchPromptEntry: pending → launching (CAS) → open a fresh window →
|
|
334
|
+
// type the prompt once idle. Never throws for the outcomes it knows about
|
|
335
|
+
// (limit / timeout / new-window failure) — those all land back on `pending`
|
|
336
|
+
// with backoff. Only truly unexpected errors (bugs) propagate; tickPromptQueue
|
|
337
|
+
// wraps every call anyway so a queue bug can never escape into the resumer's
|
|
338
|
+
// own session-dispatch tick.
|
|
339
|
+
export async function dispatchPromptEntry(entry, {
|
|
340
|
+
mux, now = Date.now(), notifier = notify, readyTimeoutMs = undefined,
|
|
341
|
+
} = {}) {
|
|
342
|
+
if ((entry.attempts || 0) >= MAX_RESUME_ATTEMPTS) {
|
|
343
|
+
return failPromptEntry(entry, `max resume attempts (${entry.attempts}) exceeded`, { mux, notifier });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// updateEntry returns the post-state regardless of who performed the
|
|
347
|
+
// transition, so `cased.status === 'launching'` alone can't tell a winner
|
|
348
|
+
// from a loser (a loser reading 'pending' as false applies {} and still
|
|
349
|
+
// sees 'launching' from whoever did win). Capture the CAS outcome directly
|
|
350
|
+
// in the mutator closure instead.
|
|
351
|
+
let transitioned = false;
|
|
352
|
+
const cased = updateEntry(entry.id, e => {
|
|
353
|
+
if (e.status !== 'pending') return {};
|
|
354
|
+
transitioned = true;
|
|
355
|
+
return { status: 'launching', launchedAt: now };
|
|
356
|
+
});
|
|
357
|
+
if (!cased || !transitioned) {
|
|
358
|
+
// Someone else already moved it (or it's gone) — nothing to do.
|
|
359
|
+
return readState().promptQueue.find(e => e.id === entry.id) || entry;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const agent = getAgent(cased.agent);
|
|
363
|
+
const launch = agent.launchArgs(cased.prompt);
|
|
364
|
+
const leaseId = createLeaseId();
|
|
365
|
+
const target = RESUME_SESSION_NAME;
|
|
366
|
+
const selfCmd = selfCommand();
|
|
367
|
+
const launchSpec = {
|
|
368
|
+
file: selfCmd[0],
|
|
369
|
+
args: [...selfCmd.slice(1), '_run', agent.id, ...launch.args],
|
|
370
|
+
env: dispatchEnv(cased, leaseId, mux, target),
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
let address;
|
|
374
|
+
try {
|
|
375
|
+
address = await mux.newWindow(target, cased.cwd, launchSpec);
|
|
376
|
+
} catch (err) {
|
|
377
|
+
log(`${cased.id}: new-window failed in session "${target}": ${err.message}`);
|
|
378
|
+
return retryPromptEntry(cased, {
|
|
379
|
+
notBefore: now + retryBackoffMs((cased.attempts || 0) + 1),
|
|
380
|
+
lastError: `new-window: ${err.message}`,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Stamp the fresh pane as ours (best-effort; tmux only) — same as reopen().
|
|
385
|
+
if (address?.pane && typeof mux.stampPaneOwner === 'function') {
|
|
386
|
+
try { await mux.stampPaneOwner(address.pane, leaseId); } catch { /* legacy tmux */ }
|
|
387
|
+
}
|
|
388
|
+
updateEntry(cased.id, { pane: address?.pane ?? null, muxSession: target, leaseId });
|
|
389
|
+
log(`${cased.id}: opened ${agent.id} in ${mux.name} ${address?.paneOwner ?? '-'}:${address?.pane} (session ${target})`);
|
|
390
|
+
|
|
391
|
+
if (!launch.messageViaPane) {
|
|
392
|
+
// Defensive only — every v1 adapter's launchArgs returns messageViaPane:
|
|
393
|
+
// true, so this path is currently unreachable. If a future adapter sends
|
|
394
|
+
// the prompt via argv instead, there's nothing left to type; treat it as
|
|
395
|
+
// delivered-pending-verification, same as reopen()'s argv branch.
|
|
396
|
+
return updateEntry(cased.id, { sentAt: now });
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const outcome = await awaitReadyAndSend(mux, address.pane, agent, cased.prompt,
|
|
400
|
+
readyTimeoutMs != null ? { timeoutMs: readyTimeoutMs } : undefined);
|
|
401
|
+
if (outcome === 'sent') {
|
|
402
|
+
log(`${cased.id}: prompt sent to new pane ${address.pane}`);
|
|
403
|
+
// Real send time, not the tick-start `now` — verifyPromptEntry measures
|
|
404
|
+
// its VERIFY_DELAY_MS wait from this timestamp, and awaitReadyAndSend can
|
|
405
|
+
// have spent real time polling for the pane to go idle first.
|
|
406
|
+
return updateEntry(cased.id, { sentAt: Date.now() });
|
|
407
|
+
}
|
|
408
|
+
// 'limit' or 'timeout': the entry is going straight back to pending, so the
|
|
409
|
+
// just-opened window is abandoned — unlike resumer's own reopen(), which
|
|
410
|
+
// leaves a still-limited fresh pane open because verifyOne reschedules from
|
|
411
|
+
// it later. Here there's no verify step to reschedule from, so an
|
|
412
|
+
// abandoned pane is pure liability: an unwatched monitor could later
|
|
413
|
+
// discover it and register a phantom session that auto-resume revives. Read
|
|
414
|
+
// the pane BEFORE closing it (the 'limit' branch still needs its text to
|
|
415
|
+
// parse a reset banner) and close best-effort after — the window may
|
|
416
|
+
// already be gone, or the mux backend may not support closePane at all.
|
|
417
|
+
let text = null;
|
|
418
|
+
if (outcome === 'limit') {
|
|
419
|
+
try { text = await mux.capturePane(address.pane, CAPTURE_LINES); } catch { /* window may already be gone */ }
|
|
420
|
+
}
|
|
421
|
+
if (typeof mux.closePane === 'function') {
|
|
422
|
+
try { await mux.closePane(address.pane); } catch { /* best-effort */ }
|
|
423
|
+
}
|
|
424
|
+
if (outcome === 'limit') {
|
|
425
|
+
// The limit hadn't actually reset — the fresh session hit it immediately.
|
|
426
|
+
const d = text ? detectLimit(text, PANE_SCAN_LINES, agent.patterns) : { hit: false, resetLine: null };
|
|
427
|
+
const attemptsAfter = (cased.attempts || 0) + 1;
|
|
428
|
+
return retryPromptEntry(cased, {
|
|
429
|
+
notBefore: limitNotBefore(d.hit ? d.resetLine : null, attemptsAfter, now),
|
|
430
|
+
lastError: 'limit still active',
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
// outcome === 'timeout'
|
|
434
|
+
return retryPromptEntry(cased, {
|
|
435
|
+
notBefore: now + retryBackoffMs((cased.attempts || 0) + 1),
|
|
436
|
+
lastError: 'ready timeout',
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// verifyPromptEntry: for a `launching` entry whose prompt was sent
|
|
441
|
+
// VERIFY_DELAY_MS ago, confirm delivery. A still-active limit banner sends
|
|
442
|
+
// it back to pending (rescheduled from the fresh banner, like verifyOne). A
|
|
443
|
+
// clean pane — or a pane we can no longer read because the window already
|
|
444
|
+
// closed — counts as delivered: we cannot re-verify a closed window, and a
|
|
445
|
+
// one-shot prompt queue entry has no live pane to keep polling, so treat
|
|
446
|
+
// "can't tell anymore" as success rather than retrying forever.
|
|
447
|
+
export async function verifyPromptEntry(entry, { mux, now = Date.now(), notifier = notify } = {}) {
|
|
448
|
+
if (entry.status !== 'launching' || !entry.sentAt) return entry;
|
|
449
|
+
if (now - entry.sentAt < VERIFY_DELAY_MS) return entry;
|
|
450
|
+
|
|
451
|
+
const agent = getAgent(entry.agent);
|
|
452
|
+
let text = null;
|
|
453
|
+
let readable = false;
|
|
454
|
+
if (entry.pane) {
|
|
455
|
+
try { text = await mux.capturePane(entry.pane, CAPTURE_LINES); readable = true; }
|
|
456
|
+
catch { readable = false; }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (readable) {
|
|
460
|
+
const d = detectLimit(text, PANE_SCAN_LINES, agent.patterns);
|
|
461
|
+
const menu = !!(agent.menu && agent.menu.isPrompt(text, PANE_SCAN_LINES));
|
|
462
|
+
if (d.hit || menu) {
|
|
463
|
+
const attemptsAfter = (entry.attempts || 0) + 1;
|
|
464
|
+
return retryPromptEntry(entry, {
|
|
465
|
+
notBefore: limitNotBefore(d.hit ? d.resetLine : null, attemptsAfter, now),
|
|
466
|
+
lastError: 'limit still active at delivery time',
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// CAS on 'launching' — a concurrent queueRemove may have cancelled this
|
|
472
|
+
// entry while we were off capturing/parsing the pane above; a cancelled
|
|
473
|
+
// entry must never flip to 'delivered', and must never fire the "delivered"
|
|
474
|
+
// notification for a session the user just cancelled.
|
|
475
|
+
let transitioned = false;
|
|
476
|
+
const updated = updateEntry(entry.id, e => {
|
|
477
|
+
if (e.status !== 'launching') return {};
|
|
478
|
+
transitioned = true;
|
|
479
|
+
return { status: 'delivered', deliveredAt: now, lastError: null };
|
|
480
|
+
});
|
|
481
|
+
if (transitioned && updated) {
|
|
482
|
+
const session = updated.muxSession || RESUME_SESSION_NAME;
|
|
483
|
+
const hint = attachHint(mux?.name, session);
|
|
484
|
+
const where = hint ? ` in ${session} — attach: ${hint}` : '';
|
|
485
|
+
notifier(
|
|
486
|
+
'queued prompt delivered ▶',
|
|
487
|
+
`${updated.cwd}: new ${updated.agent} session started${where}`,
|
|
488
|
+
{ context: queueNotifyContext(mux, updated.pane) },
|
|
489
|
+
);
|
|
490
|
+
log(`${updated.id}: delivered${readable ? '' : ' (pane unreadable — unverified)'}`);
|
|
491
|
+
}
|
|
492
|
+
return updated;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// A 'launching' entry with no sentAt whose launchedAt is far enough in the
|
|
496
|
+
// past never got as far as awaitReadyAndSend's own timeout — the process
|
|
497
|
+
// that opened its window (daemon or a transient resumer) died mid-dispatch,
|
|
498
|
+
// stranding it forever between 'pending' and 'launching'. CAS-guarded same
|
|
499
|
+
// as retryPromptEntry: a concurrent cancel must win over recovery too.
|
|
500
|
+
function reclaimStrandedEntry(entry) {
|
|
501
|
+
return updateEntry(entry.id, e => {
|
|
502
|
+
if (e.status !== 'launching') return {};
|
|
503
|
+
return {
|
|
504
|
+
status: 'pending',
|
|
505
|
+
attempts: (e.attempts || 0) + 1,
|
|
506
|
+
lastError: 'stranded launch',
|
|
507
|
+
};
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const STRANDED_LAUNCH_MS = READY_TIMEOUT_MS + 30_000;
|
|
512
|
+
|
|
513
|
+
// tickPromptQueue: called from the resumer's own loop each tick, after
|
|
514
|
+
// session dispatch (see resumer.js runResumer). Must never throw — every
|
|
515
|
+
// internal step is wrapped so a queue bug can never skip or delay a session
|
|
516
|
+
// resume, which is the one thing this feature must never touch.
|
|
517
|
+
export async function tickPromptQueue({ mux = null, now = Date.now(), notifier = notify } = {}) {
|
|
518
|
+
try {
|
|
519
|
+
// Crash recovery first — pure state, no mux involved, so it must run
|
|
520
|
+
// even on an otherwise-idle tick (a stranded entry alone should not need
|
|
521
|
+
// a live mux to get itself unstuck, and it re-enters `due` below once
|
|
522
|
+
// reclaimed).
|
|
523
|
+
for (const entry of queueList().filter(e => e.status === 'launching' && e.sentAt == null)) {
|
|
524
|
+
if (!Number.isFinite(entry.launchedAt) || now - entry.launchedAt <= STRANDED_LAUNCH_MS) continue;
|
|
525
|
+
reclaimStrandedEntry(entry);
|
|
526
|
+
log(`${entry.id}: reclaimed stranded launch (no send after ${Math.round((now - entry.launchedAt) / 1000)}s)`);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const due = duePromptEntries(now);
|
|
530
|
+
const launching = queueList().filter(e => e.status === 'launching');
|
|
531
|
+
if (due.length === 0 && launching.length === 0) return; // nothing to do — never touch a mux
|
|
532
|
+
|
|
533
|
+
// Every dispatch targets the single RESUME_SESSION_NAME session, so one
|
|
534
|
+
// mux resolution (bound to that session for zellij) covers dispatch AND
|
|
535
|
+
// verify for the whole tick. Only resolved here (not eagerly) so an
|
|
536
|
+
// empty/idle queue — the overwhelming common case — never probes for a
|
|
537
|
+
// real multiplexer backend.
|
|
538
|
+
const resolved = mux || getMultiplexer(undefined, { owner: RESUME_SESSION_NAME });
|
|
539
|
+
|
|
540
|
+
for (let i = 0; i < due.length; i++) {
|
|
541
|
+
try {
|
|
542
|
+
await dispatchPromptEntry(due[i], { mux: resolved, now, notifier });
|
|
543
|
+
} catch (err) {
|
|
544
|
+
log(`dispatch ${due[i].id} failed: ${err.message}`);
|
|
545
|
+
}
|
|
546
|
+
if (i < due.length - 1) await sleep(STAGGER_MS);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Re-read: dispatch above may have moved entries into 'launching' (freshly
|
|
550
|
+
// sent) that are also eligible once VERIFY_DELAY_MS elapses on a later tick.
|
|
551
|
+
for (const entry of queueList().filter(e => e.status === 'launching')) {
|
|
552
|
+
try {
|
|
553
|
+
await verifyPromptEntry(entry, { mux: resolved, now, notifier });
|
|
554
|
+
} catch (err) {
|
|
555
|
+
log(`verify ${entry.id} failed: ${err.message}`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
} catch (err) {
|
|
559
|
+
log(`tickPromptQueue failed: ${err.message}`);
|
|
560
|
+
}
|
|
561
|
+
}
|