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.
Files changed (49) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +286 -22
  3. package/bin/unsnooze.js +52 -3
  4. package/package.json +7 -4
  5. package/src/agents/agy.js +2 -0
  6. package/src/agents/claude.js +2 -0
  7. package/src/agents/codex.js +2 -0
  8. package/src/agents/grok.js +2 -0
  9. package/src/agents/index.js +7 -1
  10. package/src/agents/kimi.js +2 -0
  11. package/src/agents/opencode.js +2 -0
  12. package/src/agents/qwen.js +2 -0
  13. package/src/askpass.js +188 -0
  14. package/src/cli.js +149 -25
  15. package/src/config.js +15 -0
  16. package/src/dashboard/App.js +390 -0
  17. package/src/dashboard/Logo.js +74 -0
  18. package/src/dashboard/components/TextInput.js +63 -0
  19. package/src/dashboard/data.js +143 -0
  20. package/src/dashboard/mark.js +51 -0
  21. package/src/dashboard/mouse-protocol.js +75 -0
  22. package/src/dashboard/mouse.js +125 -0
  23. package/src/dashboard/run.js +108 -0
  24. package/src/dashboard/tabs/DoctorTab.js +35 -0
  25. package/src/dashboard/tabs/FleetTab.js +94 -0
  26. package/src/dashboard/tabs/LogsTab.js +28 -0
  27. package/src/dashboard/tabs/PromptsTab.js +406 -0
  28. package/src/dashboard/tabs/SessionsTab.js +33 -0
  29. package/src/dashboard/tabs/StatusTab.js +58 -0
  30. package/src/dashboard/tabs/UsageTab.js +68 -0
  31. package/src/dashboard/theme.js +32 -0
  32. package/src/doctor.js +22 -16
  33. package/src/fleet.js +927 -0
  34. package/src/hook.js +19 -0
  35. package/src/install.js +9 -0
  36. package/src/monitor.js +14 -0
  37. package/src/prompt-queue.js +561 -0
  38. package/src/prompt.js +411 -0
  39. package/src/remote.js +223 -0
  40. package/src/resumer.js +60 -16
  41. package/src/settings.js +13 -0
  42. package/src/state.js +33 -9
  43. package/src/time-parser.js +15 -1
  44. package/src/tui.js +395 -0
  45. package/src/usage.js +1537 -0
  46. package/src/watcher.js +79 -8
  47. package/src/watchers/claude.js +3 -0
  48. package/src/watchers/codex.js +8 -8
  49. 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
+ }