unsnooze 1.14.0 → 1.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.14.2 — 2026-08-06
6
+
7
+ - **Resume lifecycle:** a limit banner scrolling out of the pane no longer
8
+ marks a Claude session resumed without a wake. Unsnooze now requires a
9
+ newer non-error parent-assistant usage record, rechecks that evidence
10
+ immediately before dispatch, ignores subagent `StopFailure` records, follows
11
+ the canonical key retained by state dedupe, and never treats a persistently
12
+ busy pane as proof of completion.
13
+ - **Usage diagnostics:** estimated usage now says that its percentage is
14
+ unavailable, rather than claiming no stop was observed. Proxy
15
+ launcher documentation explains that `headroom wrap` bypasses Unsnooze's
16
+ shell launcher and same-pane monitor while hook detection and enabled daemon
17
+ file watching remain available.
18
+
19
+ ## 1.14.1 — 2026-08-04
20
+
21
+ - **Package discovery**: refreshed the npm description and README links, and
22
+ moved the published package homepage from the legacy Combustor Tech hostname
23
+ to [unsnooze.dev](https://unsnooze.dev). No runtime behavior changed.
24
+
5
25
  ## 1.14.0 — 2026-07-19
6
26
 
7
27
  - **Queued prompts**: `unsnooze prompt add [--agent id] [--project path]
package/README.md CHANGED
@@ -9,7 +9,9 @@
9
9
  [![node](https://img.shields.io/badge/node-%E2%89%A5%2020-3fb950)](package.json)
10
10
  [![license](https://img.shields.io/badge/license-MIT-8b949e)](LICENSE)
11
11
 
12
- **[unsnooze.combustortech.in](https://unsnooze.combustortech.in)** — docs · changelog · feedback
12
+ **Automatically resume every limit-stopped AI coding session when its usage limit resets.**
13
+
14
+ [Website](https://unsnooze.dev) · [Documentation](https://unsnooze.dev/docs/) · [Changelog](https://unsnooze.dev/changelog/) · [Feedback](https://unsnooze.dev/feedback/)
13
15
 
14
16
  **Claude Code · Codex CLI · Grok · Qwen · Kimi · OpenCode · Antigravity** — when they hit the 5-hour or weekly usage limit
15
17
  ("You've hit your usage limit"), your session just… stops.<br/>
@@ -145,6 +147,21 @@ risks, and vulnerability reporting: **[SECURITY.md](SECURITY.md)**.
145
147
  the CLIs that use it (OpenCode, Qwen Code), and credit exhaustion (402) is
146
148
  surfaced as a notification — there's no reset to wait for, only a top-up.
147
149
 
150
+ ### Proxy launchers such as Headroom
151
+
152
+ `headroom wrap claude` and `headroom wrap codex` resolve and launch the real
153
+ agent executable directly. That bypasses Unsnooze's shell-function launcher,
154
+ so no same-pane monitor is attached. Claude's installed `StopFailure` hook can
155
+ still record stops, as can the transcript/rollout watcher while the daemon,
156
+ `guiWatch`, and that agent are enabled. For full pane monitoring too, configure
157
+ Headroom's provider-scope persistent routing, then start `claude` / `codex`
158
+ normally so Unsnooze remains the outer launcher. With Headroom v0.34, one
159
+ explicit setup is:
160
+
161
+ ```bash
162
+ headroom install apply --scope provider --providers manual --target claude --target codex
163
+ ```
164
+
148
165
  ## GUI surfaces (VS Code extension, desktop apps)
149
166
 
150
167
  Terminal sessions are watched through the shell wrapper + tmux or Zellij. Sessions in
@@ -249,7 +266,7 @@ unsnooze usage — account burn & time-to-limit (daemon: running · warnings at
249
266
 
250
267
  | What you get | How |
251
268
  |---|---|
252
- | **Honest labels** | Every figure is tagged `(exact)`, `(calibrated from N stops)`, or `(estimated — calibrating…)`. Never a bare %. |
269
+ | **Honest labels** | Every figure is tagged `(exact)`, `(calibrated from N stops)`, or `(estimated — percentage unavailable)`. Never a bare %. |
253
270
  | **Account-wide burn** | Sums all active sessions **and** subagents (the limit is per-account, not per-pane). Idle gaps >5 min are excluded so a quiet hour doesn't hide a fast burn. |
254
271
  | **Time-to-wall** | ETA as a band (not a false-precision minute), cross-checked against known reset times. Weekly shown info-only — no unstable weekly ETA. |
255
272
  | **Pre-wall warnings** | Daemon notifies at `%` bands (`usageWarnAt`, default `80,95`) **and** time tiers (30 / 10 min). Deduped once per window. |
@@ -257,9 +274,9 @@ unsnooze usage — account burn & time-to-limit (daemon: running · warnings at
257
274
 
258
275
  **Provenance ladder** (why this isn't another guessed-quota dashboard):
259
276
 
260
- 1. **`(exact)`** — Codex always (local `used_percent` + epoch reset). Claude only with the opt-in statusline shim (server-authoritative `rate_limits` from [Claude Code's statusline JSON](https://code.claude.com/docs/en/statusline)).
261
- 2. **`(calibrated from N stops)`** — Claude token burn vs a ceiling learned from **your** limit-stop ledger (the same stops unsnooze already records for resume). Not plan presets, not circular P90-of-history.
262
- 3. **`(estimated)`** — used tokens + burn shown; ceiling unknown until the first observed stop.
277
+ 1. **`(exact)`** — Codex when a recent local rollout contains `token_count.rate_limits` (`used_percent` + epoch reset). Claude only with the opt-in statusline shim (server-authoritative `rate_limits` from [Claude Code's statusline JSON](https://code.claude.com/docs/en/statusline)).
278
+ 2. **`(calibrated from N stops)`** — Claude 5-hour token burn vs a ceiling learned from **your matching usable calibration samples**: a stop classified as `5h`, with a nonzero local token sample from the same model pool when known, otherwise an unpooled fallback sample. A stop can still be valid for revival without meeting those calibration requirements. Not plan presets, not circular P90-of-history.
279
+ 3. **`(estimated — percentage unavailable)`** — used tokens + burn are shown when available, but there is no exact percentage (and a Claude 5-hour row has no matching usable calibration ceiling yet).
263
280
 
264
281
  **Honest limits:** Claude transcript sums are a **lower bound** — subscription quotas are account-pooled with claude.ai and the Desktop app. Without the statusline shim, Claude tops out at calibrated/estimated. Exact Claude % needs Pro/Max after the first API response of a session (absent after `/clear` and on API auth).
265
282
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.14.0",
4
- "description": "Auto-resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode & Antigravity when the 5-hour/weekly usage limit resets \u2014 tmux, Zellij, VS Code & desktop apps. Wakes every limit-stopped AI coding session automatically.",
3
+ "version": "1.14.2",
4
+ "description": "Automatically resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode, and Antigravity sessions when 5-hour or weekly usage limits reset—across tmux, Zellij, VS Code, and desktop apps.",
5
5
  "keywords": [
6
6
  "claude",
7
7
  "claude-code",
@@ -49,7 +49,7 @@
49
49
  "bugs": {
50
50
  "url": "https://github.com/saaranshM/unsnooze/issues"
51
51
  },
52
- "homepage": "https://unsnooze.combustortech.in",
52
+ "homepage": "https://unsnooze.dev",
53
53
  "type": "module",
54
54
  "bin": {
55
55
  "unsnooze": "bin/unsnooze.js"
@@ -100,7 +100,9 @@ export default {
100
100
  // method are unguarded.
101
101
  contextTokens(rec) {
102
102
  if (!rec?.cwd || !rec?.sessionId) return null; // watcher/scrape records may lack either
103
- return lastUsageTokens(transcriptPath(rec.cwd, rec.sessionId));
103
+ return lastUsageTokens(transcriptPath(rec.cwd, rec.sessionId, {
104
+ claudeDir: rec.env?.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CONFIG_DIR,
105
+ }));
104
106
  },
105
107
  // The foreground process for a claude session is `node` (nvm shim) or `claude`.
106
108
  isForegroundCommand(cmd) {
package/src/config.js CHANGED
@@ -15,7 +15,8 @@ export const RESUMER_LOCK = join(STATE_DIR, 'resumer.lock');
15
15
  // High-frequency burn accumulator + warn-dedup (daemon single-writer).
16
16
  export const USAGE_FILE = join(STATE_DIR, 'usage.json');
17
17
 
18
- export const CLAUDE_DIR = process.env.UNSNOOZE_CLAUDE_DIR || join(homedir(), '.claude');
18
+ export const CLAUDE_DIR = process.env.UNSNOOZE_CLAUDE_DIR
19
+ || process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
19
20
  export const CLAUDE_SETTINGS = join(CLAUDE_DIR, 'settings.json');
20
21
  export const CODEX_DIR = process.env.UNSNOOZE_CODEX_DIR || join(homedir(), '.codex');
21
22
  // Opt-in statusline shim drop dir for exact Claude rate_limits.
package/src/hook.js CHANGED
@@ -20,6 +20,7 @@ import { spawnResumerIfNeeded } from './spawn.js';
20
20
  import { makeLogger } from './logger.js';
21
21
  import { addressHash } from './lease.js';
22
22
  import { prepareCalibrationSample, applyCalibrationToState } from './usage.js';
23
+ import { claudeRecordEnv } from './sessions.js';
23
24
 
24
25
  const log = makeLogger('hook');
25
26
 
@@ -48,6 +49,13 @@ export async function runHook(rest = []) {
48
49
  const raw = await readStdin();
49
50
  let payload = {};
50
51
  try { payload = JSON.parse(raw); } catch { /* tolerate non-JSON */ }
52
+ // Claude includes agent_id when a hook fires inside a subagent. The parent
53
+ // transcript owns revival; recording the child would create a second stop
54
+ // with no independently resumable pane.
55
+ if (agent.id === 'claude' && payload.agent_id) {
56
+ log(`StopFailure: ignoring subagent ${payload.agent_id}; parent session owns resume`);
57
+ return 0;
58
+ }
51
59
 
52
60
  const managedMux = process.env.UNSNOOZE_MUX;
53
61
  const muxName = managedMux || (process.env.ZELLIJ_PANE_ID ? 'zellij' : 'tmux');
@@ -129,6 +137,7 @@ export async function runHook(rest = []) {
129
137
  log(`calibration prepare failed: ${err.message}`);
130
138
  }
131
139
  // Stop record + calibration sample in ONE locked update (plan §6).
140
+ const recordEnv = agent.id === 'claude' ? claudeRecordEnv() : null;
132
141
  upsertSession({
133
142
  sessionId: sessionId || null,
134
143
  cwd,
@@ -149,6 +158,7 @@ export async function runHook(rest = []) {
149
158
  attempts: 0,
150
159
  lastAttemptAt: null,
151
160
  lastError: null,
161
+ ...(recordEnv ? { env: recordEnv } : {}),
152
162
  ...(source === 'fallback' ? { probeCount: 0 } : {}),
153
163
  }, {
154
164
  after: calSample
package/src/monitor.js CHANGED
@@ -27,6 +27,7 @@ import { latestRateLimitFromTranscript } from './watchers/claude.js';
27
27
  import { spawnResumerIfNeeded } from './spawn.js';
28
28
  import { makeLogger } from './logger.js';
29
29
  import { addressHash, readLease } from './lease.js';
30
+ import { claudeRecordEnv, hasClaudeParentUsageAfter } from './sessions.js';
30
31
 
31
32
  const log = makeLogger('monitor');
32
33
 
@@ -148,7 +149,9 @@ export function createMonitor({
148
149
  now: resolved.detectedAt,
149
150
  });
150
151
  } catch { /* best-effort */ }
151
- const state = upsertSession({
152
+ const recordEnv = agent.id === 'claude' ? claudeRecordEnv() : null;
153
+ let appliedKey = null;
154
+ upsertSession({
152
155
  sessionId: resolved.sessionId, cwd, pane, mux: muxName, paneOwner, leaseId,
153
156
  agent: agent.id, muxSession,
154
157
  status: 'stopped', limitType: resolved.limitType, detectedVia,
@@ -156,15 +159,18 @@ export function createMonitor({
156
159
  bannerAt: resolved.bannerAt,
157
160
  resetAt: at, resetSource: source,
158
161
  attempts: 0, lastAttemptAt: null, lastError: null,
162
+ ...(recordEnv ? { env: recordEnv } : {}),
159
163
  ...(source === 'fallback' ? { probeCount: 0 } : {}),
160
164
  }, {
161
- after: calSample ? (s) => applyCalibrationToState(s, calSample) : null,
165
+ after: (s, applied) => {
166
+ appliedKey = applied.key;
167
+ if (calSample) applyCalibrationToState(s, calSample);
168
+ },
162
169
  });
163
- trackedKey = resolved.sessionId
164
- || Object.values(state.sessions).find(s => s.mux === muxName
165
- && s.paneOwner === paneOwner && s.pane === pane
166
- && ['stopped', 'resuming', 'resumed'].includes(s.status))?.key
167
- || null;
170
+ // upsertSession may retain a pane-based key while learning a sessionId.
171
+ // Capture the exact record applied under the state lock; a lookup by pane
172
+ // is ambiguous when historical records exist for the same address.
173
+ trackedKey = appliedKey;
168
174
  log(`pane ${pane}: limit recorded (${resolved.limitType}, via ${detectedVia}), resets ${new Date(at).toISOString()} (${source})`);
169
175
  notifier(`${agent.name} hit a usage limit`, `${cwd} — auto-resume at ${new Date(at).toLocaleTimeString()}`, { context: notifyCtx });
170
176
  spawnResumerIfNeeded();
@@ -318,18 +324,32 @@ export function createMonitor({
318
324
  terminalNotified = false;
319
325
  }
320
326
 
321
- // No banner. If we were tracking a stopped record and claude is active
322
- // again, someone resumed it (user or resumer) — mark it.
327
+ // A banner leaving the 12-line scan is not evidence of a resume: ordinary
328
+ // output (including background-agent notices) can scroll it away. Claude
329
+ // is terminal only after its parent transcript records newer non-error
330
+ // assistant usage. Other adapters retain their legacy behavior until they
331
+ // expose an equally authoritative progress signal.
323
332
  if (trackedKey) {
324
333
  const state = readState();
325
334
  const rec = state.sessions[trackedKey];
326
335
  if (rec && rec.status === 'stopped') {
327
- setStatus(trackedKey, 'resumed', { lastAttemptAt: Date.now(), bannerCleared: true });
328
- log(`pane ${pane}: banner cleared, ${trackedKey} marked resumed`);
329
- trackedKey = null;
336
+ const cutoff = rec.bannerAt ?? rec.detectedAt;
337
+ const progressed = agent.id !== 'claude'
338
+ || hasClaudeParentUsageAfter(rec, cutoff);
339
+ if (progressed) {
340
+ const next = setStatus(trackedKey, 'resumed', {
341
+ lastAttemptAt: Date.now(), bannerCleared: true, lastError: null,
342
+ }, { expect: ['stopped'], expectCutoff: cutoff });
343
+ const applied = next.sessions[trackedKey];
344
+ if (applied?.status === 'resumed'
345
+ && (applied.bannerAt ?? applied.detectedAt) === cutoff) {
346
+ log(`pane ${pane}: post-limit progress observed, ${trackedKey} marked resumed`);
347
+ trackedKey = null;
348
+ }
349
+ }
330
350
  }
331
351
  if (rec && rec.status === 'resumed') {
332
- setStatus(trackedKey, 'resumed', { bannerCleared: true });
352
+ setStatus(trackedKey, 'resumed', { bannerCleared: true }, { expect: ['resumed'] });
333
353
  trackedKey = null;
334
354
  } else if (rec && rec.status !== 'stopped') trackedKey = null;
335
355
  }
package/src/reap.js CHANGED
@@ -3,13 +3,84 @@
3
3
 
4
4
  import { MUX_SESSION_NAME, RESUME_SESSION_NAME } from './config.js';
5
5
  import { getMultiplexer } from './multiplexer.js';
6
- import { readState, setStatus, updateState } from './state.js';
6
+ import { readState, updateState } from './state.js';
7
7
  import { getConfig } from './settings.js';
8
8
  import { paneOwnedByRecord } from './lease.js';
9
9
  import { makeLogger } from './logger.js';
10
10
 
11
11
  const log = makeLogger('reap');
12
12
 
13
+ function samePaneGeneration(a, b) {
14
+ return !!(a?.pane && b?.pane && a.leaseId && b.leaseId
15
+ && a.mux === b.mux && a.paneOwner === b.paneOwner && a.pane === b.pane
16
+ && a.leaseId === b.leaseId);
17
+ }
18
+
19
+ function sameRecordEpisode(a, b) {
20
+ return !!(a && b && a.status === b.status && a.pane === b.pane
21
+ && a.mux === b.mux && a.paneOwner === b.paneOwner && a.leaseId === b.leaseId
22
+ && (a.bannerAt ?? a.detectedAt) === (b.bannerAt ?? b.detectedAt)
23
+ && (a.resumeEpisodeAt ?? null) === (b.resumeEpisodeAt ?? null));
24
+ }
25
+
26
+ function activePaneAlias(state, rec) {
27
+ return Object.values(state.sessions).find(other => other.key !== rec.key
28
+ && ['stopped', 'resuming', 'resumed'].includes(other.status)
29
+ && samePaneGeneration(rec, other));
30
+ }
31
+
32
+ // Reserve a close under the state lock immediately before touching the mux.
33
+ // Removing the terminal record is the claim: another reap cannot close it,
34
+ // and a live alias that appeared during an awaited ownership probe wins.
35
+ function claimPaneClose(rec) {
36
+ let result = { kind: 'stale' };
37
+ updateState(state => {
38
+ const current = state.sessions[rec.key];
39
+ if (!sameRecordEpisode(current, rec)) return state;
40
+ const alias = activePaneAlias(state, current);
41
+ delete state.sessions[rec.key];
42
+ if (alias) {
43
+ result = { kind: 'alias', alias };
44
+ } else if ((state.paneClosures || []).some(c => samePaneGeneration(c, current))) {
45
+ result = { kind: 'closing' };
46
+ } else {
47
+ state.paneClosures ||= [];
48
+ state.paneClosures.push({
49
+ mux: current.mux,
50
+ paneOwner: current.paneOwner,
51
+ pane: current.pane,
52
+ leaseId: current.leaseId,
53
+ claimedAt: Date.now(),
54
+ });
55
+ result = { kind: 'claimed' };
56
+ }
57
+ return state;
58
+ });
59
+ return result;
60
+ }
61
+
62
+ function dropIfUnchanged(rec) {
63
+ let dropped = false;
64
+ updateState(state => {
65
+ if (sameRecordEpisode(state.sessions[rec.key], rec)) {
66
+ delete state.sessions[rec.key];
67
+ dropped = true;
68
+ }
69
+ return state;
70
+ });
71
+ return dropped;
72
+ }
73
+
74
+ function restoreFailedClaim(rec) {
75
+ updateState(state => {
76
+ state.paneClosures = (state.paneClosures || []).filter(c => !samePaneGeneration(c, rec));
77
+ if (!state.sessions[rec.key] && !activePaneAlias(state, rec)) {
78
+ state.sessions[rec.key] = rec;
79
+ }
80
+ return state;
81
+ });
82
+ }
83
+
13
84
  // How a user reaches a revived session. Shared by status, toast, and `sessions`.
14
85
  export function attachHint(muxName, sessionName) {
15
86
  if (!sessionName) return null;
@@ -81,8 +152,7 @@ export async function reap({
81
152
  // --yes flips dry-run off; plain default stays dry-run.
82
153
  if (yes) dryRun = false;
83
154
  const actions = [];
84
- const state = readState();
85
- const terminal = Object.values(state.sessions).filter(s =>
155
+ const terminal = Object.values(readState().sessions).filter(s =>
86
156
  ['resumed', 'failed', 'cancelled'].includes(s.status) && s.pane);
87
157
 
88
158
  const reapIdleAfter = getConfig('reapIdleAfter');
@@ -107,9 +177,7 @@ export async function reap({
107
177
  } catch { alive = false; }
108
178
  if (!alive) {
109
179
  actions.push({ kind: 'drop-record', key: rec.key, reason: 'pane already dead' });
110
- if (!dryRun) {
111
- updateState(s => { delete s.sessions[rec.key]; return s; });
112
- }
180
+ if (!dryRun) dropIfUnchanged(rec);
113
181
  continue;
114
182
  }
115
183
  // Pane ids get recycled — closing by bare id could kill someone else's
@@ -119,9 +187,7 @@ export async function reap({
119
187
  try { owned = await paneOwnedByRecord(rec, { mux }); } catch { owned = null; }
120
188
  if (owned === false) {
121
189
  actions.push({ kind: 'drop-record', key: rec.key, reason: 'pane recycled — not ours' });
122
- if (!dryRun) {
123
- updateState(s => { delete s.sessions[rec.key]; return s; });
124
- }
190
+ if (!dryRun) dropIfUnchanged(rec);
125
191
  continue;
126
192
  }
127
193
  if (owned === null) {
@@ -130,6 +196,33 @@ export async function reap({
130
196
  reason: 'ownership unprovable (pre-lease record) — close it manually' });
131
197
  continue;
132
198
  }
199
+ if (dryRun) {
200
+ const alias = activePaneAlias(readState(), rec);
201
+ if (alias) {
202
+ actions.push({ kind: 'drop-record', key: rec.key,
203
+ reason: `pane is shared with active record ${alias.key} — not closing` });
204
+ continue;
205
+ }
206
+ } else {
207
+ // paneAlive() and ownership checks await external commands. Re-check and
208
+ // claim only now so an owner created while they ran cannot be killed.
209
+ const claim = claimPaneClose(rec);
210
+ if (claim.kind === 'alias') {
211
+ actions.push({ kind: 'drop-record', key: rec.key,
212
+ reason: `pane is shared with active record ${claim.alias.key} — not closing` });
213
+ continue;
214
+ }
215
+ if (claim.kind === 'closing') {
216
+ actions.push({ kind: 'drop-record', key: rec.key,
217
+ reason: 'pane generation is already being closed by another reap' });
218
+ continue;
219
+ }
220
+ if (claim.kind !== 'claimed') {
221
+ actions.push({ kind: 'skip-stale', key: rec.key, pane: rec.pane,
222
+ reason: 'record changed while checking pane — not closing' });
223
+ continue;
224
+ }
225
+ }
133
226
  actions.push({
134
227
  kind: 'close-pane',
135
228
  key: rec.key,
@@ -141,9 +234,8 @@ export async function reap({
141
234
  if (!dryRun) {
142
235
  try {
143
236
  if (typeof mux.closePane === 'function') await mux.closePane(rec.pane);
144
- setStatus(rec.key, rec.status, { pane: null });
145
- updateState(s => { delete s.sessions[rec.key]; return s; });
146
237
  } catch (err) {
238
+ restoreFailedClaim(rec);
147
239
  actions.push({ kind: 'error', key: rec.key, message: err.message });
148
240
  }
149
241
  }
@@ -202,24 +294,30 @@ export async function autoReapIfEnabled({
202
294
  try {
203
295
  const mux = resolveMux(rec);
204
296
  if (!(await mux.paneAlive(rec.pane))) {
205
- updateState(s => { delete s.sessions[rec.key]; return s; });
297
+ dropIfUnchanged(rec);
206
298
  continue;
207
299
  }
208
300
  // Same recycled-pane rule as reap(): positive ownership or no close.
209
301
  const owned = await paneOwnedByRecord(rec, { mux });
210
302
  if (owned !== true) {
211
303
  if (owned === false) {
212
- updateState(s => { delete s.sessions[rec.key]; return s; });
304
+ dropIfUnchanged(rec);
213
305
  log(`${rec.key}: auto-reap skipped — pane ${rec.pane} recycled, record dropped`);
214
306
  }
215
307
  continue;
216
308
  }
309
+ const claim = claimPaneClose(rec);
310
+ if (claim.kind !== 'claimed') continue;
217
311
  if (typeof mux.closePane === 'function') {
218
- await mux.closePane(rec.pane);
219
- closed += 1;
220
- log(`${rec.key}: auto-reaped idle resumed pane ${rec.pane}`);
312
+ try {
313
+ await mux.closePane(rec.pane);
314
+ closed += 1;
315
+ log(`${rec.key}: auto-reaped idle resumed pane ${rec.pane}`);
316
+ } catch (err) {
317
+ restoreFailedClaim(rec);
318
+ throw err;
319
+ }
221
320
  }
222
- updateState(s => { delete s.sessions[rec.key]; return s; });
223
321
  } catch (err) {
224
322
  log(`${rec.key}: auto-reap failed: ${err.message}`);
225
323
  }
package/src/resumer.js CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  readState, updateState, setStatus, dueSessions, activeStopped,
22
22
  prune, sweepRecords, markStaleAbandoned,
23
23
  } from './state.js';
24
- import { approxTokens } from './sessions.js';
24
+ import { approxTokens, hasClaudeParentUsageAfter } from './sessions.js';
25
25
  import { latestRateLimitFromTranscript } from './watchers/claude.js';
26
26
  import { getConfig, resolveResumeMessage } from './settings.js';
27
27
  import { workspaceFingerprint, workspaceChanged, describeChange } from './workspace.js';
@@ -126,6 +126,125 @@ export function resolveRecordMux(rec) {
126
126
  return getMultiplexer(rec.mux, { owner: rec.paneOwner });
127
127
  }
128
128
 
129
+ const stopEpisodeAt = rec => rec?.bannerAt ?? rec?.detectedAt;
130
+
131
+ // Newer non-error parent-context Claude usage proves that a user/provider retry
132
+ // already made progress after this stop. Banner absence alone does not.
133
+ function claudeProgressedAfterStop(rec, agent) {
134
+ const cutoff = stopEpisodeAt(rec);
135
+ return agent.id === 'claude' && hasClaudeParentUsageAfter(rec, cutoff);
136
+ }
137
+
138
+ // Compare-and-set for one stop episode, with an applied result for callers
139
+ // that must suppress stale logs, notifications, or follow-up actions.
140
+ function transitionStopEpisode(rec, status, extra = {}, {
141
+ expect = ['stopped', 'resuming'],
142
+ } = {}) {
143
+ const cutoff = stopEpisodeAt(rec);
144
+ let applied = false;
145
+ updateState(state => {
146
+ const current = state.sessions[rec.key];
147
+ if (!current || !expect.includes(current.status)
148
+ || stopEpisodeAt(current) !== cutoff) return state;
149
+ Object.assign(current, extra, { status });
150
+ if (status !== 'resuming') current.resumeEpisodeAt = null;
151
+ applied = true;
152
+ return state;
153
+ });
154
+ return applied;
155
+ }
156
+
157
+ // Reconcile durable progress without letting a stale snapshot clear a newer
158
+ // stop episode. A false return means no progress was observed; true means this
159
+ // dispatch must stop, whether the transition applied or the snapshot went stale.
160
+ function finishIfClaudeProgressed(rec, agent) {
161
+ if (!claudeProgressedAfterStop(rec, agent)) return false;
162
+ const cutoff = stopEpisodeAt(rec);
163
+ let applied = false;
164
+ updateState(state => {
165
+ const current = state.sessions[rec.key];
166
+ if (current?.status === 'stopped'
167
+ && stopEpisodeAt(current) === cutoff) {
168
+ Object.assign(current, {
169
+ status: 'resumed', lastAttemptAt: Date.now(), bannerCleared: true,
170
+ lastError: null, verifyRetries: 0, resumeEpisodeAt: null,
171
+ });
172
+ applied = true;
173
+ }
174
+ return state;
175
+ });
176
+ log(applied
177
+ ? `${rec.key}: newer non-error parent-context usage recorded — no wake sent`
178
+ : `${rec.key}: stop episode changed during progress check — stale dispatch skipped`);
179
+ return true;
180
+ }
181
+
182
+ function samePaneTarget(a, b) {
183
+ return !!(a?.pane && b?.pane
184
+ && a.mux === b.mux && a.paneOwner === b.paneOwner && a.pane === b.pane
185
+ && a.agent === b.agent && a.cwd === b.cwd
186
+ // A different non-null lease is a different pane generation. Missing
187
+ // legacy leases only coalesce with each other; they cannot overrule a
188
+ // record whose current generation is demonstrable.
189
+ && (a.leaseId ?? null) === (b.leaseId ?? null));
190
+ }
191
+
192
+ function newerStop(a, b) {
193
+ const aCutoff = stopEpisodeAt(a) ?? 0;
194
+ const bCutoff = stopEpisodeAt(b) ?? 0;
195
+ if (aCutoff !== bCutoff) return aCutoff > bCutoff ? a : b;
196
+ if ((a.detectedAt ?? 0) !== (b.detectedAt ?? 0)) {
197
+ return (a.detectedAt ?? 0) > (b.detectedAt ?? 0) ? a : b;
198
+ }
199
+ if (!!a.sessionId !== !!b.sessionId) return a.sessionId ? a : b;
200
+ return String(a.key).localeCompare(String(b.key)) >= 0 ? a : b;
201
+ }
202
+
203
+ function paneStopOwner(rec, state = readState()) {
204
+ if (!rec?.pane) return rec;
205
+ const contenders = Object.values(state.sessions).filter(s =>
206
+ ['stopped', 'resuming'].includes(s.status) && samePaneTarget(s, rec));
207
+ return contenders.reduce((latest, candidate) => newerStop(latest, candidate), rec);
208
+ }
209
+
210
+ // Atomically own this exact stop before the irreversible pane/window action.
211
+ // One live pane can accept one wake. Legacy/subagent detections outside the
212
+ // short ingest-dedupe window are therefore collapsed here: the newest active
213
+ // stop owns the target and older active records become explicit history.
214
+ function claimStopForResume(rec) {
215
+ const cutoff = stopEpisodeAt(rec);
216
+ let claimed = false;
217
+ updateState(state => {
218
+ const current = state.sessions[rec.key];
219
+ if (current?.status !== 'stopped'
220
+ || stopEpisodeAt(current) !== cutoff) return state;
221
+
222
+ const contenders = current.pane
223
+ ? Object.values(state.sessions).filter(s => ['stopped', 'resuming'].includes(s.status)
224
+ && samePaneTarget(s, current))
225
+ : [current];
226
+ const owner = paneStopOwner(current, state);
227
+ const now = Date.now();
228
+ for (const candidate of contenders) {
229
+ if (candidate.key === owner.key) continue;
230
+ Object.assign(candidate, {
231
+ status: 'cancelled', lastAttemptAt: now, verifyRetries: 0,
232
+ resumeEpisodeAt: null,
233
+ lastError: `superseded by newer stop ${owner.key} on the same pane`,
234
+ });
235
+ }
236
+ if (owner.key === current.key && current.status === 'stopped') {
237
+ Object.assign(current, {
238
+ status: 'resuming', lastAttemptAt: now, lastError: null, verifyRetries: 0,
239
+ resumeEpisodeAt: cutoff,
240
+ });
241
+ claimed = true;
242
+ }
243
+ return state;
244
+ });
245
+ return claimed;
246
+ }
247
+
129
248
  // Join the session the pane lived in only if it is still alive; otherwise the
130
249
  // daemon gets its own session. It must never CREATE the launcher's base name.
131
250
  export async function reviveTarget(mux, rec) {
@@ -174,7 +293,9 @@ export async function probeFallback(rec, {
174
293
  let bannerAt = rec.bannerAt ?? null;
175
294
 
176
295
  // Prefer a fresh transcript entry when available (claude).
177
- const fromTx = latestRateLimitFromTranscript(rec.cwd, rec.sessionId, { now });
296
+ const fromTx = latestRateLimitFromTranscript(rec.cwd, rec.sessionId, {
297
+ now, claudeDir: rec.env?.CLAUDE_CONFIG_DIR,
298
+ });
178
299
  if (fromTx) {
179
300
  stillLimited = true;
180
301
  resetLine = fromTx.resetLine;
@@ -211,11 +332,12 @@ export async function probeFallback(rec, {
211
332
  bannerAt,
212
333
  });
213
334
  if (source !== 'fallback') {
214
- setStatus(key, 'stopped', {
335
+ const applied = transitionStopEpisode(rec, 'stopped', {
215
336
  resetAt: at, resetSource: source, bannerAt,
216
337
  lastError: 'limit still active (probe upgraded estimate)',
217
338
  probeCount: 0,
218
- });
339
+ }, { expect: ['stopped'] });
340
+ if (!applied) return 'stale';
219
341
  log(`${key}: probe upgraded fallback→${source}, rescheduled to ${new Date(at).toISOString()}`);
220
342
  return 'probe';
221
343
  }
@@ -232,12 +354,13 @@ function rescheduleProbe(rec, now = Date.now()) {
232
354
  // Past the hard ceiling: schedule a final attempt at the ceiling (or now
233
355
  // if already past) and stop tagging as endless probes — reopen path runs.
234
356
  if (now >= ceiling) {
235
- setStatus(key, 'stopped', {
357
+ const applied = transitionStopEpisode(rec, 'stopped', {
236
358
  resetAt: now,
237
359
  resetSource: 'fallback',
238
360
  lastError: 'probe ceiling reached — attempting resume',
239
361
  probeCount: probeCount + 1,
240
- });
362
+ }, { expect: ['stopped'] });
363
+ if (!applied) return 'stale';
241
364
  log(`${key}: probe ceiling reached — attempting resume`);
242
365
  return null; // proceed with dispatch
243
366
  }
@@ -246,12 +369,13 @@ function rescheduleProbe(rec, now = Date.now()) {
246
369
  });
247
370
  let nextAt = now + delay + RESET_MARGIN_MS;
248
371
  if (nextAt > ceiling) nextAt = ceiling;
249
- setStatus(key, 'stopped', {
372
+ const applied = transitionStopEpisode(rec, 'stopped', {
250
373
  resetAt: nextAt,
251
374
  resetSource: 'fallback',
252
375
  lastError: 'limit still active — probing',
253
376
  probeCount: probeCount + 1,
254
- });
377
+ }, { expect: ['stopped'] });
378
+ if (!applied) return 'stale';
255
379
  log(`${key}: probe #${probeCount + 1} rescheduled to ${new Date(nextAt).toISOString()}`);
256
380
  return 'probe';
257
381
  }
@@ -341,6 +465,15 @@ export async function planFor(rec, {
341
465
  gates.push(`status ${rec.status} — nothing to dispatch`);
342
466
  return { ...base, action: 'none' };
343
467
  }
468
+ if (claudeProgressedAfterStop(rec, agent)) {
469
+ gates.push('newer non-error parent-context usage shows that the session already resumed');
470
+ return { ...base, action: 'none' };
471
+ }
472
+ const paneOwner = paneStopOwner(rec);
473
+ if (paneOwner?.key !== rec.key) {
474
+ gates.push(`newer active stop ${paneOwner.key} owns this live pane target`);
475
+ return { ...base, action: 'none' };
476
+ }
344
477
  // Gate order mirrors runResumer exactly: give-up only ever happens to
345
478
  // records that pass dueForDispatch (due AND auto-or-manual AND not held),
346
479
  // so paused/held/backing-off records are narrated as such even when their
@@ -429,7 +562,8 @@ export async function planFor(rec, {
429
562
  };
430
563
  }
431
564
 
432
- // Ordered safety decision. Returns: busy | retry | progress | held | injected | reopen | probe.
565
+ // Ordered safety decision. Returns: already-resumed | busy | retry | progress |
566
+ // held | injected | reopen | probe.
433
567
  export async function dispatchOne(rec, {
434
568
  mux = resolveRecordMux(rec), resolveMux = null,
435
569
  resumeMessage, selfCmd = selfCommand(), fingerprint = workspaceFingerprint,
@@ -438,6 +572,9 @@ export async function dispatchOne(rec, {
438
572
  resolveMux ||= () => mux;
439
573
  const key = rec.key;
440
574
  const agent = getAgent(rec.agent);
575
+ // Cheap early reconciliation; this is repeated after asynchronous pane or
576
+ // multiplexer inspection, immediately before any keystroke/window launch.
577
+ if (finishIfClaudeProgressed(rec, agent)) return 'already-resumed';
441
578
  // Wake-message precedence: per-session (`unsnooze message <id> "..."`) →
442
579
  // explicit option → per-agent (`resumeMessages.<id>`) → global. Applies to
443
580
  // both the live-pane sendText and the argv reopen path.
@@ -445,11 +582,17 @@ export async function dispatchOne(rec, {
445
582
 
446
583
  // §4: fallback records probe cheaply before a real resume attempt.
447
584
  if (rec.resetSource === 'fallback' && !rec.manual) {
585
+ const episode = stopEpisodeAt(rec);
448
586
  const probed = await probeFallback(rec, { mux });
449
- if (probed === 'probe') return 'probe';
587
+ if (probed != null) return probed;
450
588
  // null → banner gone or ceiling hit — fall through to normal dispatch.
451
- // Re-read record in case probeFallback mutated it.
452
- rec = readState().sessions[key] || rec;
589
+ // Re-read only this episode in case the ceiling probe updated its fields.
590
+ // A newer stop may not inherit the old episode's due decision.
591
+ const current = readState().sessions[key];
592
+ if (!current || current.status !== 'stopped' || stopEpisodeAt(current) !== episode) {
593
+ return 'stale';
594
+ }
595
+ rec = current;
453
596
  }
454
597
 
455
598
  // Stale-workspace guard: another session (or a human) may have moved the
@@ -458,7 +601,9 @@ export async function dispatchOne(rec, {
458
601
  // dispatch owns the side effects.
459
602
  const ws = evaluateWorkspaceGuard(rec, resumeMessage, { fingerprint });
460
603
  if (ws.hold) {
461
- setStatus(key, 'stopped', { workspaceHold: true, holdReason: ws.hold.reason });
604
+ if (!transitionStopEpisode(rec, 'stopped', {
605
+ workspaceHold: true, holdReason: ws.hold.reason,
606
+ }, { expect: ['stopped'] })) return 'stale';
462
607
  notifier('unsnooze: session held', `${rec.cwd}: workspace changed while stopped (${ws.hold.desc}) — run: unsnooze resume-now`, { context: ctxOf(rec) });
463
608
  log(`${key}: workspace changed (${ws.hold.desc}) — held (workspaceGuard=pause)`);
464
609
  return 'held';
@@ -473,7 +618,9 @@ export async function dispatchOne(rec, {
473
618
  // tick. Manual resumes proceed.
474
619
  const ctx = evaluateContextGuard(rec, agent, { contextTokens });
475
620
  if (ctx.hold) {
476
- setStatus(key, 'stopped', { workspaceHold: true, holdReason: ctx.hold.reason });
621
+ if (!transitionStopEpisode(rec, 'stopped', {
622
+ workspaceHold: true, holdReason: ctx.hold.reason,
623
+ }, { expect: ['stopped'] })) return 'stale';
477
624
  notifier('unsnooze: session held', `${rec.cwd}: waking would re-read ${ctx.hold.size} tokens of context at full (uncached) price — run: unsnooze resume-now`, { context: ctxOf(rec) });
478
625
  log(`${key}: context ${ctx.hold.size} tokens ≥ threshold — held (contextGuard=pause)`);
479
626
  return 'held';
@@ -491,23 +638,37 @@ export async function dispatchOne(rec, {
491
638
  const a = await assessPane(rec, agent, { mux, matchesLease });
492
639
  if (a.alive) {
493
640
  if (a.captureError) {
494
- setStatus(key, 'stopped', { lastError: `capture: ${a.captureError}` });
641
+ if (!transitionStopEpisode(rec, 'stopped', {
642
+ lastError: `capture: ${a.captureError}`,
643
+ }, { expect: ['stopped'] })) return 'stale';
495
644
  return 'retry';
496
645
  }
497
646
  if (a.busy) return 'busy';
498
647
  if (a.menu) {
648
+ if (finishIfClaudeProgressed(rec, agent)) return 'already-resumed';
499
649
  if (!a.authorized) return reopenGuarded();
500
650
  if (!getConfig('menuAutoAnswer')) return 'held';
651
+ // Menu navigation is an irreversible pane action too. Claim the exact
652
+ // stop after assessment so a refreshed episode cannot receive stale
653
+ // Down/Enter keystrokes.
654
+ if (!claimStopForResume(rec)) return 'stale';
501
655
  try {
502
656
  if (await driveMenu(mux, rec.pane, agent, a.text)) return 'progress';
503
- setStatus(key, 'stopped', { lastError: 'menu drive: wait option unavailable' });
657
+ transitionStopEpisode(rec, 'stopped', {
658
+ lastError: 'menu drive: wait option unavailable',
659
+ }, { expect: ['resuming'] });
504
660
  } catch (err) {
505
- setStatus(key, 'stopped', { lastError: `menu drive: ${err.message}` });
661
+ transitionStopEpisode(rec, 'stopped', {
662
+ lastError: `menu drive: ${err.message}`,
663
+ }, { expect: ['resuming'] });
506
664
  }
507
665
  return 'retry';
508
666
  }
509
667
  if (a.authorized) {
510
- setStatus(key, 'resuming', { lastAttemptAt: Date.now(), lastError: null, verifyRetries: 0 });
668
+ // assessPane is asynchronous. Close the final human/provider-resume race
669
+ // after assessment and immediately before the keystroke.
670
+ if (finishIfClaudeProgressed(rec, agent)) return 'already-resumed';
671
+ if (!claimStopForResume(rec)) return 'stale';
511
672
  await mux.sendText(rec.pane, resumeMessage);
512
673
  log(`${key}: sent continue via ${rec.mux} ${rec.paneOwner ?? '-'}:${rec.pane}`);
513
674
  notifyContext();
@@ -523,6 +684,7 @@ export async function dispatchOne(rec, {
523
684
  // (argv or typed) — not on ready-timeouts or a still-active limit banner.
524
685
  async function reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd, onDelivered = () => {} }) {
525
686
  const key = rec.key;
687
+ const cutoff = rec.bannerAt ?? rec.detectedAt;
526
688
  const resume = agent.resumeArgs(rec.sessionId, resumeMessage);
527
689
  const leaseId = createLeaseId();
528
690
  const target = await reviveTarget(mux, rec);
@@ -530,7 +692,10 @@ async function reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd, onD
530
692
  file: selfCmd[0], args: [...selfCmd.slice(1), '_run', agent.id, ...resume.args],
531
693
  env: reopenEnv(rec, leaseId, target),
532
694
  };
533
- setStatus(key, 'resuming', { lastAttemptAt: Date.now(), verifyRetries: 0 });
695
+ // reviveTarget can await a multiplexer query. Recheck after it and claim the
696
+ // exact episode immediately before opening a new pane.
697
+ if (finishIfClaudeProgressed(rec, agent)) return 'already-resumed';
698
+ if (!claimStopForResume(rec)) return 'stale';
534
699
  let address;
535
700
  try {
536
701
  address = await mux.newWindow(target, rec.cwd || homedir(), launchSpec);
@@ -542,8 +707,8 @@ async function reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd, onD
542
707
  setStatus(key, 'stopped', {
543
708
  attempts: (rec.attempts || 0) + 1,
544
709
  lastError: `new-window: ${err.message}`,
545
- verifyRetries: 0,
546
- });
710
+ verifyRetries: 0, resumeEpisodeAt: null,
711
+ }, { expect: ['resuming'], expectCutoff: cutoff });
547
712
  return 'retry';
548
713
  }
549
714
  // Stamp the fresh pane as ours (best-effort; tmux only) so later close /
@@ -555,7 +720,10 @@ async function reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd, onD
555
720
  // name the session the pane actually lives in.
556
721
  updateState(state => {
557
722
  const s = state.sessions[key];
558
- if (s) Object.assign(s, address, { leaseId, muxSession: target });
723
+ if (s?.status === 'resuming' && (s.bannerAt ?? s.detectedAt) === cutoff) {
724
+ Object.assign(s, address, { leaseId, muxSession: target });
725
+ }
726
+ return state;
559
727
  });
560
728
  const rebound = { ...rec, ...address, leaseId, muxSession: target };
561
729
  mux = resolveMux(rebound);
@@ -579,8 +747,8 @@ async function reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd, onD
579
747
  setStatus(key, 'stopped', {
580
748
  attempts: (rec.attempts || 0) + 1,
581
749
  lastError: 'ready timeout',
582
- verifyRetries: 0,
583
- });
750
+ verifyRetries: 0, resumeEpisodeAt: null,
751
+ }, { expect: ['resuming'], expectCutoff: cutoff });
584
752
  return 'retry';
585
753
  }
586
754
 
@@ -611,25 +779,42 @@ export async function awaitReadyAndSend(mux, pane, agent, message, {
611
779
 
612
780
  function recordVerifyRetry(rec, lastError) {
613
781
  const verifyRetries = (rec.verifyRetries || 0) + 1;
782
+ let applied;
614
783
  if (verifyRetries >= MAX_VERIFY_RETRIES) {
615
784
  const attempts = (rec.attempts || 0) + 1;
616
- setStatus(rec.key, 'stopped', {
785
+ applied = transitionStopEpisode(rec, 'stopped', {
617
786
  attempts,
618
787
  // Same backoff (and same manual exemption) as routed retries.
619
788
  resetAt: rec.manual ? Date.now() : Date.now() + retryBackoffMs(attempts),
620
789
  lastError,
621
- verifyRetries: 0,
622
- });
790
+ verifyRetries: 0, resumeEpisodeAt: null,
791
+ }, { expect: ['resuming'] });
623
792
  } else {
624
- setStatus(rec.key, 'resuming', { lastError, verifyRetries });
793
+ applied = transitionStopEpisode(rec, 'resuming', { lastError, verifyRetries },
794
+ { expect: ['resuming'] });
625
795
  }
626
- return 'retry';
796
+ if (!applied) releaseSupersededResume(rec.key);
797
+ return applied ? 'retry' : 'stale';
798
+ }
799
+
800
+ function releaseSupersededResume(key) {
801
+ const current = readState().sessions[key];
802
+ if (!current || current.status !== 'resuming' || current.resumeEpisodeAt == null
803
+ || stopEpisodeAt(current) === current.resumeEpisodeAt) return false;
804
+ return transitionStopEpisode(current, 'stopped', {
805
+ lastError: 'newer limit detected during resume', verifyRetries: 0,
806
+ resumeEpisodeAt: null,
807
+ }, { expect: ['resuming'] });
627
808
  }
628
809
 
629
810
  // Post-dispatch verification: did the limit banner come back?
630
811
  export async function verifyOne(key, { resolveMux = resolveRecordMux } = {}) {
631
812
  const rec = readState().sessions[key];
632
813
  if (!rec || rec.status !== 'resuming') return;
814
+ // A detection that lands after the action claim deliberately preserves the
815
+ // in-flight status, but it advances bannerAt/detectedAt. Do not verify that
816
+ // newer episode using an older pane action.
817
+ if (releaseSupersededResume(key)) return 'stale';
633
818
  const agent = getAgent(rec.agent);
634
819
  if (!rec.pane) {
635
820
  return recordVerifyRetry(rec, 'verify: pane unavailable');
@@ -647,7 +832,9 @@ export async function verifyOne(key, { resolveMux = resolveRecordMux } = {}) {
647
832
  // offsets anchor to the banner's own time.
648
833
  let resetLine = d.hit ? d.resetLine : null;
649
834
  let bannerAt = null;
650
- const fromTx = latestRateLimitFromTranscript(rec.cwd, rec.sessionId);
835
+ const fromTx = latestRateLimitFromTranscript(rec.cwd, rec.sessionId, {
836
+ claudeDir: rec.env?.CLAUDE_CONFIG_DIR,
837
+ });
651
838
  if (fromTx) {
652
839
  resetLine = fromTx.resetLine ?? resetLine;
653
840
  bannerAt = fromTx.timestampMs;
@@ -657,16 +844,27 @@ export async function verifyOne(key, { resolveMux = resolveRecordMux } = {}) {
657
844
  fallbackMs: PROBE_INTERVAL_MS,
658
845
  bannerAt,
659
846
  });
660
- setStatus(key, 'stopped', {
847
+ const applied = transitionStopEpisode(rec, 'stopped', {
661
848
  attempts: (rec.attempts || 0) + 1, resetAt: at, resetSource: source,
662
849
  bannerAt: bannerAt ?? rec.bannerAt ?? null,
663
850
  lastError: 'limit still active at resume time', verifyRetries: 0,
851
+ resumeEpisodeAt: null,
664
852
  ...(source === 'fallback' ? { probeCount: (rec.probeCount || 0) + 1 } : { probeCount: 0 }),
665
- });
853
+ }, { expect: ['resuming'] });
854
+ if (!applied) {
855
+ releaseSupersededResume(key);
856
+ return 'stale';
857
+ }
666
858
  log(`${key}: limit still active, rescheduled to ${new Date(at).toISOString()} (${source})`);
667
859
  return;
668
860
  }
669
- setStatus(key, 'resumed', { lastError: null, verifyRetries: 0 });
861
+ if (!transitionStopEpisode(rec, 'resumed', {
862
+ lastError: null, verifyRetries: 0, resumeEpisodeAt: null,
863
+ bannerCleared: true,
864
+ }, { expect: ['resuming'] })) {
865
+ releaseSupersededResume(key);
866
+ return 'stale';
867
+ }
670
868
  const session = rec.muxSession || RESUME_SESSION_NAME;
671
869
  const hint = attachHint(rec.mux, session);
672
870
  log(`${key}: verified resumed in ${session}${hint ? ` — ${hint}` : ''}`);
@@ -681,30 +879,50 @@ export function routeDispatchOutcome(result, rec, deferCounts, { maxBusyDefers =
681
879
  const n = (deferCounts.get(rec.key) || 0) + 1;
682
880
  deferCounts.set(rec.key, n);
683
881
  if (n > maxBusyDefers) {
684
- setStatus(rec.key, 'resumed', { lastError: null, verifyRetries: 0 });
882
+ // Busy is evidence that typing would be unsafe, not evidence that the
883
+ // stopped episode completed. Drop the hot-loop delay after the cap but
884
+ // leave the record stopped for the next normal poll.
685
885
  return { verify: false, waitBusy: false };
686
886
  }
687
887
  return { verify: false, waitBusy: true };
688
888
  }
889
+ deferCounts.delete(rec.key);
689
890
  if (result === 'retry') {
690
891
  const attempts = (rec.attempts || 0) + 1;
691
- setStatus(rec.key, 'stopped', {
692
- attempts,
693
- // Back off before the next attempt — a due record retried on every poll
694
- // tick exhausts MAX_RESUME_ATTEMPTS in minutes. Manual records are
695
- // exempt: `resume-now` promised an immediate wake, so a transient error
696
- // must not silently defer it.
697
- resetAt: rec.manual ? now : now + retryBackoffMs(attempts),
698
- lastError: readState().sessions[rec.key]?.lastError,
699
- verifyRetries: 0,
892
+ const episode = stopEpisodeAt(rec);
893
+ updateState(state => {
894
+ const current = state.sessions[rec.key];
895
+ if (!current || !['stopped', 'resuming'].includes(current.status)) return state;
896
+ if (stopEpisodeAt(current) !== episode) {
897
+ // A fresh detection superseded this failed attempt. If ingest kept the
898
+ // action marked in-flight, release it without charging the new stop.
899
+ if (current.status === 'resuming') {
900
+ Object.assign(current, { status: 'stopped', resumeEpisodeAt: null, verifyRetries: 0 });
901
+ }
902
+ return state;
903
+ }
904
+ Object.assign(current, {
905
+ status: 'stopped', attempts,
906
+ // Back off before the next attempt — a due record retried on every poll
907
+ // tick exhausts MAX_RESUME_ATTEMPTS in minutes. Manual records are
908
+ // exempt: `resume-now` promised an immediate wake, so a transient error
909
+ // must not silently defer it.
910
+ resetAt: rec.manual ? now : now + retryBackoffMs(attempts),
911
+ lastError: current.lastError, verifyRetries: 0, resumeEpisodeAt: null,
912
+ });
913
+ return state;
700
914
  });
701
915
  return { verify: false, waitBusy: false };
702
916
  }
703
917
  if (result === 'progress') {
704
- setStatus(rec.key, 'stopped', { lastError: null, verifyRetries: 0 });
918
+ setStatus(rec.key, 'stopped', {
919
+ lastError: null, verifyRetries: 0, resumeEpisodeAt: null,
920
+ }, { expect: ['resuming'] });
921
+ return { verify: false, waitBusy: false };
922
+ }
923
+ if (result === 'already-resumed' || result === 'stale' || result === 'held' || result === 'probe') {
705
924
  return { verify: false, waitBusy: false };
706
925
  }
707
- if (result === 'held' || result === 'probe') return { verify: false, waitBusy: false };
708
926
  return { verify: result === 'injected' || result === 'reopen', waitBusy: false };
709
927
  }
710
928
 
@@ -758,6 +976,12 @@ export async function runResumer({
758
976
  log(`cleanup tick failed: ${err.message}`);
759
977
  }
760
978
 
979
+ // Reconcile manual/provider retries before waiting/attempt-cap decisions.
980
+ // dispatchOne repeats this immediately before acting to close the race.
981
+ for (const rec of activeStopped()) {
982
+ finishIfClaudeProgressed(rec, getAgent(rec.agent));
983
+ }
984
+
761
985
  const stopped = activeStopped();
762
986
  const resuming = Object.values(readState().sessions).filter(s => s.status === 'resuming');
763
987
  // Additive: a pending/launching prompt-queue entry keeps a transient
@@ -774,7 +998,11 @@ export async function runResumer({
774
998
  // Anything over the attempts cap is dead — mark failed so we can exit.
775
999
  for (const s of dueForDispatch()) {
776
1000
  if ((s.attempts || 0) >= MAX_RESUME_ATTEMPTS) {
777
- setStatus(s.key, 'failed', { lastError: 'max resume attempts exceeded', verifyRetries: 0 });
1001
+ const applied = transitionStopEpisode(s, 'failed', {
1002
+ lastError: 'max resume attempts exceeded', verifyRetries: 0,
1003
+ resumeEpisodeAt: null,
1004
+ }, { expect: ['stopped'] });
1005
+ if (!applied) continue;
778
1006
  log(`${s.key}: giving up after ${s.attempts} attempts`);
779
1007
  notify('unsnooze gave up ⚠️', `${s.cwd}: ${s.attempts} resume attempts failed — check \`unsnooze status\``, { context: ctxOf(s), priority: 4 });
780
1008
  }
package/src/sessions.js CHANGED
@@ -40,8 +40,18 @@ export function latestSessionId(cwd, aroundTs = null) {
40
40
  return best ? best.id : null;
41
41
  }
42
42
 
43
- export function transcriptPath(cwd, sessionId) {
44
- return join(projectDir(cwd), `${sessionId}.jsonl`);
43
+ export function transcriptPath(cwd, sessionId, { claudeDir = CLAUDE_DIR } = {}) {
44
+ return join(claudeDir, 'projects', dashCwd(cwd), `${sessionId}.jsonl`);
45
+ }
46
+
47
+ export function claudeRecordEnv(env = process.env) {
48
+ if (!env.CLAUDE_CONFIG_DIR) return null;
49
+ return {
50
+ CLAUDE_CONFIG_DIR: env.CLAUDE_CONFIG_DIR,
51
+ ...(env.CLAUDE_SECURESTORAGE_CONFIG_DIR != null
52
+ ? { CLAUDE_SECURESTORAGE_CONFIG_DIR: env.CLAUDE_SECURESTORAGE_CONFIG_DIR }
53
+ : {}),
54
+ };
45
55
  }
46
56
 
47
57
  export function approxTokens(n) {
@@ -53,7 +63,11 @@ export function approxTokens(n) {
53
63
  // Tail-read only — transcripts reach tens of MB — doubling the window until a
54
64
  // usage entry appears or maxWindow is hit. null = unknown (missing file, no
55
65
  // usage found); callers skip the guard, like workspaceFingerprint's null.
56
- export function lastUsageTokens(path, { window = 256 * 1024, maxWindow = 4 * 1024 * 1024 } = {}) {
66
+ export function lastUsageTokens(path, {
67
+ window = 256 * 1024,
68
+ maxWindow = 4 * 1024 * 1024,
69
+ afterMs = null,
70
+ } = {}) {
57
71
  let fd;
58
72
  try {
59
73
  const { size } = statSync(path);
@@ -71,11 +85,24 @@ export function lastUsageTokens(path, { window = 256 * 1024, maxWindow = 4 * 102
71
85
  let entry;
72
86
  try { entry = JSON.parse(line); } catch { continue; }
73
87
  if (entry.isSidechain === true) continue; // subagent traffic doesn't ride the main context
88
+ if (afterMs != null && entry.isApiErrorMessage === true && entry.error === 'rate_limit') {
89
+ // The newest main-context outcome is another stop, so an older
90
+ // successful turn cannot prove that the current episode resumed.
91
+ return null;
92
+ }
93
+ if (afterMs != null && (entry.isApiErrorMessage === true
94
+ || entry.type !== 'assistant' || entry.message?.role !== 'assistant')) continue;
74
95
  const u = entry.message?.usage;
75
96
  if (!u || typeof u !== 'object') continue;
76
97
  const sum = (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0)
77
98
  + (u.cache_read_input_tokens || 0) + (u.output_tokens || 0);
78
- if (sum > 0) return sum; // zero-sum = synthetic/error entry, keep scanning
99
+ if (sum <= 0) continue; // zero-sum = synthetic/error entry, keep scanning
100
+ if (afterMs == null) return sum;
101
+ const at = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
102
+ if (Number.isFinite(at) && at > afterMs) return sum;
103
+ // Lines are chronological. Once the newest timestamped main-context
104
+ // usage is at/before the cutoff, no older entry can prove progress.
105
+ if (Number.isFinite(at)) return null;
79
106
  }
80
107
  if (len >= size || window >= maxWindow) return null;
81
108
  window = Math.min(window * 2, maxWindow);
@@ -86,3 +113,14 @@ export function lastUsageTokens(path, { window = 256 * 1024, maxWindow = 4 * 102
86
113
  if (fd !== undefined) closeSync(fd);
87
114
  }
88
115
  }
116
+
117
+ // Durable evidence that Claude's parent context received a non-error response
118
+ // after a recorded stop. Desktop records may live under an isolated
119
+ // CLAUDE_CONFIG_DIR rather than the global ~/.claude tree.
120
+ export function hasClaudeParentUsageAfter(rec, afterMs) {
121
+ if (!rec?.cwd || !rec?.sessionId || !Number.isFinite(afterMs)) return false;
122
+ const path = transcriptPath(rec.cwd, rec.sessionId, {
123
+ claudeDir: rec.env?.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CONFIG_DIR || CLAUDE_DIR,
124
+ });
125
+ return lastUsageTokens(path, { afterMs }) != null;
126
+ }
package/src/state.js CHANGED
@@ -20,7 +20,9 @@ import { addressHash } from './lease.js';
20
20
 
21
21
  const log = makeLogger('state');
22
22
 
23
- const EMPTY = () => ({ version: 1, resumerPid: null, sessions: {}, calibration: {}, promptQueue: [] });
23
+ const EMPTY = () => ({
24
+ version: 1, resumerPid: null, sessions: {}, calibration: {}, promptQueue: [], paneClosures: [],
25
+ });
24
26
 
25
27
  function sleepSync(ms) {
26
28
  const buf = new SharedArrayBuffer(4);
@@ -98,6 +100,9 @@ function normalizeState(state) {
98
100
  }
99
101
  // One-shot prompt queue — always an array; corrupt/missing values reset empty.
100
102
  if (!Array.isArray(state.promptQueue)) state.promptQueue = [];
103
+ // Short-lived pane-generation tombstones prevent a detection racing a
104
+ // user-requested reap from publishing a just-closed pane as live.
105
+ if (!Array.isArray(state.paneClosures)) state.paneClosures = [];
101
106
  for (const rec of Object.values(state.sessions)) normalizeRecord(rec);
102
107
  return state;
103
108
  }
@@ -155,6 +160,24 @@ export function upsertSession(record, { after = null } = {}) {
155
160
  record = normalizeRecord({ ...record });
156
161
  return updateState(state => {
157
162
  prune(state);
163
+ const closing = state.paneClosures.find(c => c.pane && c.leaseId
164
+ && c.mux === record.mux && c.paneOwner === record.paneOwner
165
+ && c.pane === record.pane && c.leaseId === record.leaseId);
166
+ if (closing) {
167
+ // The close command has already been submitted. Keep the stop, but make
168
+ // it reopenable instead of attaching it to a pane that is going away.
169
+ record = {
170
+ ...record,
171
+ pane: null,
172
+ paneOwner: null,
173
+ muxSession: null,
174
+ leaseId: null,
175
+ pid: null,
176
+ pidBirth: null,
177
+ status: record.status === 'resuming' ? 'stopped' : record.status,
178
+ resumeEpisodeAt: null,
179
+ };
180
+ }
158
181
  let applied = null;
159
182
  const existingKey = findDuplicate(state, record);
160
183
  if (existingKey) {
@@ -175,6 +198,7 @@ export function upsertSession(record, { after = null } = {}) {
175
198
  lastAttemptAt: staleBanner ? existing.lastAttemptAt : record.lastAttemptAt,
176
199
  key: existingKey,
177
200
  };
201
+ if (merged.status !== 'resuming') merged.resumeEpisodeAt = null;
178
202
  state.sessions[existingKey] = merged;
179
203
  applied = merged;
180
204
  log(`merged duplicate detection for pane ${record.pane} into ${existingKey}`);
@@ -229,15 +253,19 @@ function findDuplicate(state, record) {
229
253
  return null;
230
254
  }
231
255
 
232
- // `expect`: compare-and-set — apply only while the record is still in one of
233
- // the listed statuses. Sweepers that decide from a snapshot (markStaleAbandoned)
234
- // must not clobber a record that moved on (e.g. to 'resumed') mid-decision.
235
- export function setStatus(key, status, extra = {}, { expect = null } = {}) {
256
+ // `expect` / `expectCutoff`: compare-and-set — apply only while the record is
257
+ // still in the expected state and stop episode. Snapshot-based decisions must
258
+ // not clobber a record that moved on or was refreshed by a newer limit.
259
+ export function setStatus(key, status, extra = {}, {
260
+ expect = null, expectCutoff = null,
261
+ } = {}) {
236
262
  return updateState(state => {
237
263
  const s = state.sessions[key];
238
264
  if (!s) return state;
239
265
  if (expect && !expect.includes(s.status)) return state;
266
+ if (expectCutoff != null && (s.bannerAt ?? s.detectedAt) !== expectCutoff) return state;
240
267
  Object.assign(s, extra, { status });
268
+ if (status !== 'resuming') s.resumeEpisodeAt = null;
241
269
  return state;
242
270
  });
243
271
  }
@@ -258,6 +286,9 @@ export function prune(state) {
258
286
  return !(terminal && ts < cutoff);
259
287
  });
260
288
  }
289
+ const closureCutoff = Date.now() - DEDUPE_WINDOW_MS;
290
+ state.paneClosures = (Array.isArray(state.paneClosures) ? state.paneClosures : [])
291
+ .filter(c => Number.isFinite(c?.claimedAt) && c.claimedAt >= closureCutoff);
261
292
  }
262
293
 
263
294
  export function pruneNow() {
@@ -279,22 +310,31 @@ export async function sweepRecords({ resolveMux } = {}) {
279
310
  // 30 seconds of happening. Age-based prune() owns failed-record expiry.
280
311
  if (!['resumed', 'cancelled'].includes(rec.status)) continue;
281
312
  if (!rec.pane) {
282
- drop.push(rec.key);
313
+ drop.push({ key: rec.key, status: rec.status, cutoff: rec.bannerAt ?? rec.detectedAt });
283
314
  continue;
284
315
  }
285
316
  try {
286
317
  const mux = resolveMux(rec);
287
- if (!(await mux.paneAlive(rec.pane))) drop.push(rec.key);
318
+ if (!(await mux.paneAlive(rec.pane))) {
319
+ drop.push({ key: rec.key, status: rec.status, cutoff: rec.bannerAt ?? rec.detectedAt });
320
+ }
288
321
  } catch {
289
- drop.push(rec.key);
322
+ drop.push({ key: rec.key, status: rec.status, cutoff: rec.bannerAt ?? rec.detectedAt });
290
323
  }
291
324
  }
292
325
  if (drop.length === 0) return 0;
326
+ let removed = 0;
293
327
  updateState(s => {
294
- for (const key of drop) delete s.sessions[key];
328
+ for (const snapshot of drop) {
329
+ const current = s.sessions[snapshot.key];
330
+ if (current?.status !== snapshot.status
331
+ || (current.bannerAt ?? current.detectedAt) !== snapshot.cutoff) continue;
332
+ delete s.sessions[snapshot.key];
333
+ removed += 1;
334
+ }
295
335
  return s;
296
336
  });
297
- return drop.length;
337
+ return removed;
298
338
  }
299
339
 
300
340
  // Non-terminal records with a dead/absent pane and old detectedAt are marked
@@ -320,11 +360,15 @@ export async function markStaleAbandoned({
320
360
  }
321
361
  if (!dead) continue;
322
362
  // CAS: the async liveness probe races real resumes — only mark failed if
323
- // the record is still where the snapshot saw it.
324
- setStatus(rec.key, 'failed', {
363
+ // the record is still where the snapshot saw it, including its episode.
364
+ const episode = rec.bannerAt ?? rec.detectedAt;
365
+ const next = setStatus(rec.key, 'failed', {
325
366
  lastError: rec.pane ? 'stale: pane dead' : 'stale: pane absent',
326
367
  verifyRetries: 0,
327
- }, { expect: ['stopped', 'resuming'] });
368
+ }, { expect: ['stopped', 'resuming'], expectCutoff: episode });
369
+ const applied = next.sessions[rec.key];
370
+ if (applied?.status !== 'failed'
371
+ || (applied.bannerAt ?? applied.detectedAt) !== episode) continue;
328
372
  marked += 1;
329
373
  log(`${rec.key}: marked failed (stale abandoned, detectedAt ${new Date(rec.detectedAt || 0).toISOString()})`);
330
374
  }
package/src/tui.js CHANGED
@@ -256,7 +256,7 @@ export function formatUsageTui(report, {
256
256
  const n = ladder.stopCount || 0;
257
257
  return `(calibrated from ${n} stop${n === 1 ? '' : 's'})`;
258
258
  }
259
- return '(estimated — calibrating, needs one observed limit stop)';
259
+ return '(estimated — percentage unavailable)';
260
260
  });
261
261
 
262
262
  for (const a of report.agents || []) {
package/src/usage.js CHANGED
@@ -200,13 +200,13 @@ export function ladderUsage({ exactPct = null, used = null, ceiling = null, stop
200
200
  }
201
201
 
202
202
  export function fmtUsageProvenance(ladder) {
203
- if (!ladder) return '(estimated — calibrating, needs one observed limit stop)';
203
+ if (!ladder) return '(estimated — percentage unavailable)';
204
204
  if (ladder.tier === 'exact') return '(exact)';
205
205
  if (ladder.tier === 'calibrated') {
206
206
  const n = ladder.stopCount || 0;
207
207
  return `(calibrated from ${n} stop${n === 1 ? '' : 's'})`;
208
208
  }
209
- return '(estimated — calibrating, needs one observed limit stop)';
209
+ return '(estimated — percentage unavailable)';
210
210
  }
211
211
 
212
212
  export function parseUsageWarnAt(raw) {
@@ -52,9 +52,10 @@ export function latestRateLimitFromTranscript(cwd, sessionId, {
52
52
  now = Date.now(),
53
53
  window = 256 * 1024,
54
54
  maxWindow = 4 * 1024 * 1024,
55
+ claudeDir,
55
56
  } = {}) {
56
57
  if (!cwd || !sessionId) return null;
57
- const path = transcriptPath(cwd, sessionId);
58
+ const path = transcriptPath(cwd, sessionId, { claudeDir });
58
59
  let fd;
59
60
  try {
60
61
  const { size } = statSync(path);