unsnooze 1.14.1 → 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 +14 -0
- package/README.md +19 -4
- package/package.json +1 -1
- package/src/agents/claude.js +3 -1
- package/src/config.js +2 -1
- package/src/hook.js +10 -0
- package/src/monitor.js +33 -13
- package/src/reap.js +115 -17
- package/src/resumer.js +274 -46
- package/src/sessions.js +42 -4
- package/src/state.js +57 -13
- package/src/tui.js +1 -1
- package/src/usage.js +2 -2
- package/src/watchers/claude.js +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
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
|
+
|
|
5
19
|
## 1.14.1 — 2026-08-04
|
|
6
20
|
|
|
7
21
|
- **Package discovery**: refreshed the npm description and README links, and
|
package/README.md
CHANGED
|
@@ -147,6 +147,21 @@ risks, and vulnerability reporting: **[SECURITY.md](SECURITY.md)**.
|
|
|
147
147
|
the CLIs that use it (OpenCode, Qwen Code), and credit exhaustion (402) is
|
|
148
148
|
surfaced as a notification — there's no reset to wait for, only a top-up.
|
|
149
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
|
+
|
|
150
165
|
## GUI surfaces (VS Code extension, desktop apps)
|
|
151
166
|
|
|
152
167
|
Terminal sessions are watched through the shell wrapper + tmux or Zellij. Sessions in
|
|
@@ -251,7 +266,7 @@ unsnooze usage — account burn & time-to-limit (daemon: running · warnings at
|
|
|
251
266
|
|
|
252
267
|
| What you get | How |
|
|
253
268
|
|---|---|
|
|
254
|
-
| **Honest labels** | Every figure is tagged `(exact)`, `(calibrated from N stops)`, or `(estimated —
|
|
269
|
+
| **Honest labels** | Every figure is tagged `(exact)`, `(calibrated from N stops)`, or `(estimated — percentage unavailable)`. Never a bare %. |
|
|
255
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. |
|
|
256
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. |
|
|
257
272
|
| **Pre-wall warnings** | Daemon notifies at `%` bands (`usageWarnAt`, default `80,95`) **and** time tiers (30 / 10 min). Deduped once per window. |
|
|
@@ -259,9 +274,9 @@ unsnooze usage — account burn & time-to-limit (daemon: running · warnings at
|
|
|
259
274
|
|
|
260
275
|
**Provenance ladder** (why this isn't another guessed-quota dashboard):
|
|
261
276
|
|
|
262
|
-
1. **`(exact)`** — Codex
|
|
263
|
-
2. **`(calibrated from N stops)`** — Claude token burn vs a ceiling learned from **your
|
|
264
|
-
3. **`(estimated)`** — used tokens + burn shown
|
|
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).
|
|
265
280
|
|
|
266
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).
|
|
267
282
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unsnooze",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.2",
|
|
4
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",
|
package/src/agents/claude.js
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
165
|
+
after: (s, applied) => {
|
|
166
|
+
appliedKey = applied.key;
|
|
167
|
+
if (calSample) applyCalibrationToState(s, calSample);
|
|
168
|
+
},
|
|
162
169
|
});
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
//
|
|
322
|
-
//
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 |
|
|
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
|
|
587
|
+
if (probed != null) return probed;
|
|
450
588
|
// null → banner gone or ceiling hit — fall through to normal dispatch.
|
|
451
|
-
// Re-read
|
|
452
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
657
|
+
transitionStopEpisode(rec, 'stopped', {
|
|
658
|
+
lastError: 'menu drive: wait option unavailable',
|
|
659
|
+
}, { expect: ['resuming'] });
|
|
504
660
|
} catch (err) {
|
|
505
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
793
|
+
applied = transitionStopEpisode(rec, 'resuming', { lastError, verifyRetries },
|
|
794
|
+
{ expect: ['resuming'] });
|
|
625
795
|
}
|
|
626
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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', {
|
|
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
|
-
|
|
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(
|
|
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, {
|
|
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
|
|
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 = () => ({
|
|
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
|
|
233
|
-
// the
|
|
234
|
-
//
|
|
235
|
-
export function setStatus(key, status, extra = {}, {
|
|
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)))
|
|
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
|
|
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
|
|
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
|
-
|
|
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 —
|
|
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 —
|
|
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 —
|
|
209
|
+
return '(estimated — percentage unavailable)';
|
|
210
210
|
}
|
|
211
211
|
|
|
212
212
|
export function parseUsageWarnAt(raw) {
|
package/src/watchers/claude.js
CHANGED
|
@@ -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);
|