claude-usage-limits 1.40.4 → 1.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +49 -129
- package/commands/relay.md +3 -0
- package/package.json +1 -1
- package/skills/usage-limits/references/how-it-works.md +3 -2
- package/skills/usage-limits/scripts/brief.js +44 -10
- package/skills/usage-limits/scripts/mode.js +157 -8
- package/skills/usage-limits/scripts/relay.js +38 -7
- package/skills/usage-limits/scripts/usage.js +83 -0
- package/skills/usage-limits/scripts/wake.js +131 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usage-limits",
|
|
3
3
|
"displayName": "Usage Limits",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.41.0",
|
|
5
5
|
"description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Ridelink",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usage-limits",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.41.0",
|
|
4
4
|
"description": "Reports how much of your Codex usage limit is left as turns of work rather than a percentage, prices a job before you start it, and counts the other agents sharing the same budget.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Ridelink",
|
package/README.md
CHANGED
|
@@ -19,6 +19,21 @@ opens with the answer instead:
|
|
|
19
19
|
|
|
20
20
|
Nobody read a chart to get that. The numbers reached the model, not you.
|
|
21
21
|
|
|
22
|
+

|
|
23
|
+
|
|
24
|
+
<sub>Real output of `npx claude-usage-limits` on the author's machine, 2026-09-26: the Claude Code windows and the closing verdict, with the Codex and per-model sections left out. The plugin puts the same reading into Claude's context, as the budget line, before every prompt.</sub>
|
|
25
|
+
|
|
26
|
+
Install it as a Claude Code plugin:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
/plugin marketplace add ridelink0/claude-code-usage-limits
|
|
30
|
+
/plugin install usage-limits@usage-limits
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or see the report without installing anything: `npx claude-usage-limits`.
|
|
34
|
+
Codex, the plain-skill route and keeping it updated are under
|
|
35
|
+
[Install](#install).
|
|
36
|
+
|
|
22
37
|
## What it runs on your machine, and what it never does
|
|
23
38
|
|
|
24
39
|
Installing this plugin registers six Claude Code hooks, which means Node runs on
|
|
@@ -1120,7 +1135,23 @@ There are two separate things people mean by "change the model":
|
|
|
1120
1135
|
|
|
1121
1136
|
`claude-usage-limits mode --baseline` shows both side by side. The budget line
|
|
1122
1137
|
now says the tier as well, and where the reading came from, because the number
|
|
1123
|
-
that decides what a turn costs was the one number the line never printed.
|
|
1138
|
+
that decides what a turn costs was the one number the line never printed. It
|
|
1139
|
+
names the version, not only the family (`opus 5.5/xhigh`, not `opus/xhigh`),
|
|
1140
|
+
read from the newest assistant message in the session's transcript: a settings
|
|
1141
|
+
alias such as `opus` cannot say which Opus answered.
|
|
1142
|
+
|
|
1143
|
+
When the model running is an older release of a family that has a newer one at
|
|
1144
|
+
a lower price - Opus 5 or 4.8 against Opus 5.5 ($4/$20, cache reads $0.20
|
|
1145
|
+
against $0.50), Fable 5 against Fable 5.1 (reads $0.25 against $1) - the brief
|
|
1146
|
+
says so once per session, with the prices and how many turns the one-off cache
|
|
1147
|
+
rebuild takes to repay. In Claude Code it offers `/model <id>`, which switches
|
|
1148
|
+
the session and saves the model as the default for new sessions; elsewhere it
|
|
1149
|
+
offers the host's own model setting and names no slash command. A relay whose
|
|
1150
|
+
own `model` is pinned to the older release is named too, since a wake starts
|
|
1151
|
+
with that `--model` whatever the session switched to. Releases on
|
|
1152
|
+
either side of the 4.7 tokenizer change are not compared, because a price per
|
|
1153
|
+
token is not like for like across it. `mode --no-advice` mutes this with the
|
|
1154
|
+
rest of the advice.
|
|
1124
1155
|
|
|
1125
1156
|
What Claude can genuinely move, stated without embroidery: the model on an
|
|
1126
1157
|
`Agent` call, and the model and effort inside a `Workflow` script. Its own
|
|
@@ -1321,18 +1352,6 @@ percentage and any pressure, the `max` line must never be longer than the
|
|
|
1321
1352
|
worse, and every mode path, alias, bound and guard is exercised before
|
|
1322
1353
|
`settings.json` is compared byte for byte.
|
|
1323
1354
|
|
|
1324
|
-
## Status
|
|
1325
|
-
|
|
1326
|
-
It works and I use it daily.
|
|
1327
|
-
|
|
1328
|
-
What I am not doing is fielding feature requests or support questions. If you
|
|
1329
|
-
want it to behave differently, fork it and change it, which is what the MIT
|
|
1330
|
-
licence is there for. Do not wait on me to add something for you.
|
|
1331
|
-
|
|
1332
|
-
## License
|
|
1333
|
-
|
|
1334
|
-
MIT. See [LICENSE](LICENSE).
|
|
1335
|
-
|
|
1336
1355
|
## The wall is not the end of the budget
|
|
1337
1356
|
|
|
1338
1357
|
A full window is not always a reason to stop, and the plugin now says which.
|
|
@@ -1363,119 +1382,20 @@ line said the budget was nearly gone, and the work stopped - with the 5-hour
|
|
|
1363
1382
|
window at 46 and every other model untouched. One command would have carried it
|
|
1364
1383
|
on.
|
|
1365
1384
|
|
|
1366
|
-
##
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
That is the whole of "Codex does not slow down when the limit is close": nothing
|
|
1385
|
-
ever told it the limit was close. The reader now keeps the newest reading of each
|
|
1386
|
-
meter and prefers the one that actually describes a window. Nothing is merged or
|
|
1387
|
-
synthesised - the payload returned is one Codex really wrote.
|
|
1388
|
-
|
|
1389
|
-
### An old snapshot is an estimate, not a reading
|
|
1390
|
-
|
|
1391
|
-
There was already a warning for a reading spent past its own remainder. It could
|
|
1392
|
-
never fire for a snapshot taken at the start of a window, because everything
|
|
1393
|
-
spent since is still inside the remainder.
|
|
1394
|
-
|
|
1395
|
-
So a session ran for most of an hour being told **7 per cent** while the account
|
|
1396
|
-
was at **41**: Claude Code's own cache had not moved in fifty minutes, the
|
|
1397
|
-
plugin's live reading was rate-limited into backoff, and the correction was
|
|
1398
|
-
quietly carrying the entire difference on its own. A correction is a good
|
|
1399
|
-
adjustment to a recent snapshot and a bad substitute for an old one, because the
|
|
1400
|
-
pricing error compounds with every point it has to bridge. Past fifteen minutes
|
|
1401
|
-
the brief now says the figure is an estimate that can run high or low and points
|
|
1402
|
-
at `/usage`. It is only called a floor when the correction has been refused
|
|
1403
|
-
outright and the raw snapshot is all that is shown, since that really is a
|
|
1404
|
-
lower bound; a snapshot plus a correction is not, and on 2026-09-20 it ran 13
|
|
1405
|
-
to 17 points high.
|
|
1406
|
-
|
|
1407
|
-
### The ceiling
|
|
1408
|
-
|
|
1409
|
-
node bin/cli.js mode --cap 60
|
|
1410
|
-
node bin/cli.js mode --cap off
|
|
1411
|
-
|
|
1412
|
-
Past the ceiling, **fan-out calls are refused at the hook** - `Agent`, `Task`,
|
|
1413
|
-
`Workflow` and their equivalents on each host. Everything else keeps working at
|
|
1414
|
-
any percentage: reads, edits, tests, commands. The work still finishes, just
|
|
1415
|
-
sequentially, in one session, which is where the saving is. A measured fan-out
|
|
1416
|
-
costs between 2.6x and 5.9x the same work done in sequence, because every agent
|
|
1417
|
-
warms its own cache from cold and none can report back until they all stop.
|
|
1418
|
-
|
|
1419
|
-
It is off until you set a number, it never fires without a reading behind it, and
|
|
1420
|
-
the refusal says what to do instead - a denial that only says "over budget" gets
|
|
1421
|
-
retried.
|
|
1422
|
-
|
|
1423
|
-
It is judged on the fullest window this session can spend into, and the refusal
|
|
1424
|
-
names that window. A weekly scoped to one model counts only while that model
|
|
1425
|
-
runs - by the setting, or by the model the session's last reply came from, so a
|
|
1426
|
-
`/model` switch is seen - and a window whose reset has passed does not count.
|
|
1427
|
-
Until 1.39.0 it took the highest number on disk, and an Opus session was refused
|
|
1428
|
-
on the Fable weekly with a message calling it "the binding window".
|
|
1429
|
-
|
|
1430
|
-
**Why a refusal rather than a sentence.** On Codex the reported figure
|
|
1431
|
-
demonstrably does not change behaviour, and the reason is not stubbornness.
|
|
1432
|
-
`gpt-6-astra`'s own system prompt, shipped in `models_cache.json`, says: *"Do not
|
|
1433
|
-
settle for a partial or 'helpful enough' solution that does not fully satisfy the
|
|
1434
|
-
user's task to save time, effort or tokens"* - and ranks the live user
|
|
1435
|
-
instruction above anything an `AGENTS.md` or a skill says. A line asking it to
|
|
1436
|
-
economise is arguing with its own instructions, and losing.
|
|
1437
|
-
|
|
1438
|
-
### The Codex subagent clamp
|
|
1439
|
-
|
|
1440
|
-
`lowpower on --host codex` now also bounds `[agents]`. Two facts read out of the
|
|
1441
|
-
model catalog Codex itself caches, not out of documentation:
|
|
1442
|
-
|
|
1443
|
-
gpt-6-astra: default_reasoning_level = "low"
|
|
1444
|
-
multi_agent_reasoning_effort = "xhigh"
|
|
1445
|
-
|
|
1446
|
-
Astra's own default effort is the cheapest one, and its subagents run at the
|
|
1447
|
-
dearest one **no matter what the session is set to**. Lowering effort without
|
|
1448
|
-
bounding them leaves the most expensive path in the product untouched. The clamp
|
|
1449
|
-
is a marked block, removed exactly by `lowpower off`, and it refuses outright
|
|
1450
|
-
rather than writing a second `[agents]` table over one you wrote yourself.
|
|
1451
|
-
`--no-agents` declines just that half.
|
|
1452
|
-
|
|
1453
|
-
### Antigravity
|
|
1454
|
-
|
|
1455
|
-
node skills/usage-limits/scripts/install-antigravity.js on
|
|
1456
|
-
|
|
1457
|
-
Installs into `~/.gemini/config/plugins/usage-limits/`. `PreInvocation` carries
|
|
1458
|
-
the budget line as an injected ephemeral message; `PreToolUse` carries the
|
|
1459
|
-
ceiling, which Antigravity implements as a real `decision: "deny"`.
|
|
1460
|
-
|
|
1461
|
-
One thing is said plainly rather than papered over: **Antigravity publishes no
|
|
1462
|
-
remaining quota anywhere readable on disk.** It refreshes quota - its own log
|
|
1463
|
-
says so - and keeps it in memory. A previous version filled that gap by reporting
|
|
1464
|
-
*Claude's* meter under a `cross-agent-claude` source, next to a hardcoded plan
|
|
1465
|
-
and a model name invented for a settings file that has no model key in it. All of
|
|
1466
|
-
that is gone. Where there is nothing to read, the report says the quota is
|
|
1467
|
-
unreadable.
|
|
1468
|
-
|
|
1469
|
-
### Caveman, and why it is not here
|
|
1470
|
-
|
|
1471
|
-
It was on the list for this release and it is not in it. JetBrains ran a
|
|
1472
|
-
controlled A/B of the caveman skill - 86 tasks, paired, ~240 billed trials - and
|
|
1473
|
-
measured output tokens down **8.5 per cent** against an advertised 65, with
|
|
1474
|
-
quality differences indistinguishable from noise (p=0.82). An independent
|
|
1475
|
-
benchmark found the literal instruction `be brief.` matched or beat it, and
|
|
1476
|
-
because agentic cost is input-dominated, the skill's own rules riding along every
|
|
1477
|
-
turn can cost more than they save. Its engine is also BSL-licensed, not open.
|
|
1478
|
-
|
|
1479
|
-
Claude Code already ships a built-in **Concise** output style that does the same
|
|
1480
|
-
job in the cached system-prompt layer at no marginal cost. Use that. This plugin
|
|
1481
|
-
will not ship a measured 8 per cent as a 65 per cent saving.
|
|
1385
|
+
## Release notes
|
|
1386
|
+
|
|
1387
|
+
What changed in each version is in the [GitHub Releases](https://github.com/ridelink0/claude-code-usage-limits/releases).
|
|
1388
|
+
The long notes for 1.23.0, the release that started intervening rather than
|
|
1389
|
+
only reporting, are in [docs/1.23.0.md](docs/1.23.0.md).
|
|
1390
|
+
|
|
1391
|
+
## Status
|
|
1392
|
+
|
|
1393
|
+
It works and I use it daily.
|
|
1394
|
+
|
|
1395
|
+
What I am not doing is fielding feature requests or support questions. If you
|
|
1396
|
+
want it to behave differently, fork it and change it, which is what the MIT
|
|
1397
|
+
licence is there for. Do not wait on me to add something for you.
|
|
1398
|
+
|
|
1399
|
+
## License
|
|
1400
|
+
|
|
1401
|
+
MIT. See [LICENSE](LICENSE).
|
package/commands/relay.md
CHANGED
|
@@ -29,6 +29,9 @@ The rest:
|
|
|
29
29
|
(or whichever mode you want) as well: a resume does **not** inherit the
|
|
30
30
|
session's permission mode, so without one it will sit waiting for an
|
|
31
31
|
approval nobody is there to give. `show off` makes it a headless run instead.
|
|
32
|
+
- `model <id>` - the `--model` a resumed run starts with; `model` alone goes
|
|
33
|
+
back to the default. It is separate from `/model` in the session, so a wake
|
|
34
|
+
pinned to an older model stays on it until this is changed.
|
|
32
35
|
- `voice on|off` - carry how you write in the hand-off, so the resumed session
|
|
33
36
|
answers in your voice without being reminded. On by default.
|
|
34
37
|
- `bugcheck on|always|off` - the hand-off asks for two bug passes before anything
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-usage-limits",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.41.0",
|
|
4
4
|
"description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -256,8 +256,9 @@ A bracketed suffix on a model id (`claude-sonnet-5[1m]`) is stripped before
|
|
|
256
256
|
the lookup: it marks a context-window variant of the same model, not a new
|
|
257
257
|
one. Cache reads price at a tenth of the input rate unless a row carries a
|
|
258
258
|
`cacheRead` figure of its own - Fable and Mythos 5.1 price reads outright at
|
|
259
|
-
$0.25 per million
|
|
260
|
-
|
|
259
|
+
$0.25 per million (0.025x input) and Opus 5.5 at $0.20 (0.05x its $4 input),
|
|
260
|
+
all under the tenth rule, and reads are the dominant input in exactly the long
|
|
261
|
+
sessions where the difference matters.
|
|
261
262
|
|
|
262
263
|
Until someone does, a model this table has not seen is priced at the average of
|
|
263
264
|
the family its name contains: an unreleased `claude-opus-5-2` is charged at the
|
|
@@ -160,7 +160,7 @@ function readSaid() {
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
function keepSaidFor(key) {
|
|
163
|
-
return /#(standing|cachemiss|stale|relaylast)$/.test(key) ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000;
|
|
163
|
+
return /#(standing|cachemiss|stale|relaylast|newer)$/.test(key) ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
// A plugin update takes effect when Claude Code restarts, so a session that
|
|
@@ -202,6 +202,30 @@ function staleVersionFor(sessionId, now, dir) {
|
|
|
202
202
|
return 'usage-limits ' + installed + ' is installed but this session still runs ' + running +
|
|
203
203
|
', because a plugin update applies at the next start; a relay or cap set here follows the older rules until then.';
|
|
204
204
|
}
|
|
205
|
+
// A newer model of the same family at a lower price, said once a session.
|
|
206
|
+
// Once is the rule every recommendation here keeps: the fact does not change
|
|
207
|
+
// from prompt to prompt, and repeating it would be the plugin charging for its
|
|
208
|
+
// own presence. Keyed on the pair, so a session that moves to a different model
|
|
209
|
+
// with its own newer sibling hears about that one.
|
|
210
|
+
function newerModelFor(sessionId, advice, now) {
|
|
211
|
+
if (!advice || !advice.text) return null;
|
|
212
|
+
const at = Number.isFinite(now) ? now : Date.now();
|
|
213
|
+
const key = String(sessionId || '_') + '#newer';
|
|
214
|
+
const all = readSaid();
|
|
215
|
+
const entry = all[key];
|
|
216
|
+
if (entry && entry.seen === advice.id && Number.isFinite(entry.at) && at - entry.at < keepSaidFor(key)) return null;
|
|
217
|
+
all[key] = { at, seen: advice.id };
|
|
218
|
+
writeSaid(all, at);
|
|
219
|
+
return advice.text;
|
|
220
|
+
}
|
|
221
|
+
// The model the relay resumes with, when one is set: wake.js passes it as --model.
|
|
222
|
+
function relayModelNow() {
|
|
223
|
+
try {
|
|
224
|
+
return relay.settings(relay.read()).model || null;
|
|
225
|
+
} catch (err) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
205
229
|
// How the last relay ended is news once. It used to ride along for six hours
|
|
206
230
|
// after any relay ended, on every prompt of every session: on 2026-09-22 a wake
|
|
207
231
|
// lost at 5:53 AM was repeated in two sessions' briefs all afternoon, about
|
|
@@ -784,6 +808,7 @@ function briefText(input) {
|
|
|
784
808
|
if (parts.tier) sentences.push(parts.tier);
|
|
785
809
|
// Once per session, and only when the installed version is not this one.
|
|
786
810
|
if (parts.staleVersion) sentences.push(parts.staleVersion);
|
|
811
|
+
if (parts.newerModel) sentences.push(parts.newerModel);
|
|
787
812
|
const bounded = mode.boundsNote(bounds);
|
|
788
813
|
if (bounded) sentences.push(bounded);
|
|
789
814
|
if (parts.planChanged) {
|
|
@@ -1228,15 +1253,20 @@ function briefText(input) {
|
|
|
1228
1253
|
// (autoContinueAtUsageLimit, code.claude.com/docs/en/settings-reference). In
|
|
1229
1254
|
// place, with the context intact, that is strictly better than a wake: no
|
|
1230
1255
|
// hand-off file to re-read and nothing lost. So the wake is the route for a
|
|
1231
|
-
// session that will be CLOSED
|
|
1232
|
-
//
|
|
1256
|
+
// session that will be CLOSED. Both firing for the same reset would run the
|
|
1257
|
+
// work twice and spend the weekly twice, so since 1.41.0 the wake looks for
|
|
1258
|
+
// this session's process first and stands down when it is open and working
|
|
1259
|
+
// again (wake.js, liveSession). What the CLI does NOT do is continue a session
|
|
1260
|
+
// that stopped short of the wall, at a cap, so that case is said too.
|
|
1233
1261
|
if (carry && carry.armed && carry.armed.mode === 'resume' && parts.autoContinue && parts.autoContinue.value) {
|
|
1234
1262
|
relaySentences.push(
|
|
1235
1263
|
'Claude Code\'s own "Continue automatically at usage limit" is on (' + parts.autoContinue.source +
|
|
1236
|
-
'), so if this terminal is still open
|
|
1237
|
-
'itself, in place, with its context intact - better than any wake. The wake is the
|
|
1238
|
-
'session that is closed by then. Both firing for the same reset would start the work
|
|
1239
|
-
'spend the weekly twice, so
|
|
1264
|
+
'), so if this terminal is still open when the limit is hit, the CLI carries THIS session across ' +
|
|
1265
|
+
'the reset by itself, in place, with its context intact - better than any wake. The wake is the ' +
|
|
1266
|
+
'route for a session that is closed by then. Both firing for the same reset would start the work ' +
|
|
1267
|
+
'twice and spend the weekly twice, so the wake checks for this session first and opens no second ' +
|
|
1268
|
+
'window while it is open and working again. The CLI does not continue a session that stopped short ' +
|
|
1269
|
+
'of the limit (at a cap); the wake gives such a session a few minutes, then opens it in one window.'
|
|
1240
1270
|
);
|
|
1241
1271
|
}
|
|
1242
1272
|
if (carry && carry.last) {
|
|
@@ -1403,7 +1433,7 @@ function briefText(input) {
|
|
|
1403
1433
|
// The one recommendation this session is allowed, in its short form. It
|
|
1404
1434
|
// still cites the measurement, still names the command: terse is fewer
|
|
1405
1435
|
// words, not less evidence.
|
|
1406
|
-
const adviceText = parts.adviceText ? ' ' + parts.adviceText : '';
|
|
1436
|
+
const adviceText = (parts.adviceText ? ' ' + parts.adviceText : '') + (parts.newerModel ? ' ' + parts.newerModel : '');
|
|
1407
1437
|
return (
|
|
1408
1438
|
sentences[0] + caveat + (parts.tier ? ' ' + parts.tier : '') + (bounded ? ' ' + bounded : '') + adviceText +
|
|
1409
1439
|
(escapeSentence ? ' ' + escapeSentence : '') +
|
|
@@ -1904,7 +1934,10 @@ async function run(now, hookInput, opts) {
|
|
|
1904
1934
|
// What tier is producing this turn, and what the user's own baseline is.
|
|
1905
1935
|
// Read, displayed, never written.
|
|
1906
1936
|
const terse = budget.policy.briefStyle === 'terse';
|
|
1907
|
-
const
|
|
1937
|
+
const tierReading = mode.tierNow({ sessionId, now, usage, env: process.env, transcriptPath: hookInput && hookInput.transcript_path });
|
|
1938
|
+
const tier = mode.tierLine(tierReading, { terse });
|
|
1939
|
+
// Muted advice is muted for this too: it is a recommendation like the others.
|
|
1940
|
+
const newerAdvice = budget.advice && budget.advice.off ? null : mode.newerModelAdvice(tierReading, { usage, host: usage.currentHost(), bounds: budget.bounds, relayModel: relayModelNow() });
|
|
1908
1941
|
|
|
1909
1942
|
// The recommendation channel. The measured fit sentence IS the
|
|
1910
1943
|
// recommendation - it cites this account's own numbers and names the exact
|
|
@@ -1959,6 +1992,7 @@ async function run(now, hookInput, opts) {
|
|
|
1959
1992
|
standingShort,
|
|
1960
1993
|
cacheMissWhy: cacheMissWhyFor(sessionId, now),
|
|
1961
1994
|
staleVersion: staleVersionFor(sessionId, now),
|
|
1995
|
+
newerModel: newerModelFor(sessionId, newerAdvice, now),
|
|
1962
1996
|
adviceText: terse && offering ? advice.text : null,
|
|
1963
1997
|
relay: carry,
|
|
1964
1998
|
voiceNote,
|
|
@@ -2065,7 +2099,7 @@ function withBugcheck(text) {
|
|
|
2065
2099
|
return text;
|
|
2066
2100
|
}
|
|
2067
2101
|
|
|
2068
|
-
module.exports = { wallFeatures, sweepDebris, withBugcheck, sayOnce, shapeOf, saidFile, REPEAT_MS, staleVersionFor, relayNewsFor, installedVersion, runningVersion,
|
|
2102
|
+
module.exports = { wallFeatures, sweepDebris, withBugcheck, sayOnce, shapeOf, saidFile, REPEAT_MS, staleVersionFor, newerModelFor, relayNewsFor, installedVersion, runningVersion,
|
|
2069
2103
|
readSaid, standingSaid, markStanding, standingShortFor, STANDING_SHORT, cacheMissWhyFor, missReason, MISS_RECENT_MS,
|
|
2070
2104
|
DEFAULTS,
|
|
2071
2105
|
HOOK_BUDGET_MS,
|
|
@@ -1057,9 +1057,15 @@ function tierNow(options) {
|
|
|
1057
1057
|
settings = null;
|
|
1058
1058
|
}
|
|
1059
1059
|
|
|
1060
|
+
// The newest assistant message is the model that actually answered, which a
|
|
1061
|
+
// settings alias ('opus') cannot say. The hook's own transcript_path is read
|
|
1062
|
+
// first when the caller has it: it is the file this very turn is written to,
|
|
1063
|
+
// where the session-id lookup has to find it under the config directory.
|
|
1060
1064
|
let running = null;
|
|
1061
1065
|
try {
|
|
1062
|
-
|
|
1066
|
+
let seen = null;
|
|
1067
|
+
if (opts.transcriptPath && typeof usage.transcriptModel === 'function') seen = usage.transcriptModel(opts.transcriptPath);
|
|
1068
|
+
if (!(seen && seen.model) && sessionId) seen = usage.liveModel(sessionId);
|
|
1063
1069
|
running = seen && seen.model ? seen.model : null;
|
|
1064
1070
|
} catch (err) {
|
|
1065
1071
|
running = null;
|
|
@@ -1112,6 +1118,150 @@ function sameFamily(a, b) {
|
|
|
1112
1118
|
return left === right;
|
|
1113
1119
|
}
|
|
1114
1120
|
|
|
1121
|
+
// The model as the brief names it: the family and the version. It used to be
|
|
1122
|
+
// the family alone, so claude-opus-5 and claude-opus-5-5 both printed "opus"
|
|
1123
|
+
// and a switch between them - a 20% price change, 60% on cache reads - was
|
|
1124
|
+
// invisible in the one line that exists to say what is producing the turn. A
|
|
1125
|
+
// bare alias ('opus') has no version to show and is shown as it is.
|
|
1126
|
+
function modelLabel(name) {
|
|
1127
|
+
if (!name) return null;
|
|
1128
|
+
let parsed = null;
|
|
1129
|
+
try {
|
|
1130
|
+
parsed = require('./usage.js').parseModelId(name);
|
|
1131
|
+
} catch (err) {
|
|
1132
|
+
parsed = null;
|
|
1133
|
+
}
|
|
1134
|
+
if (parsed) return parsed.version.length ? parsed.family + ' ' + parsed.version.join('.') : parsed.family;
|
|
1135
|
+
const rank = modelRank(name);
|
|
1136
|
+
return rank === null ? name : MODEL_ORDER[rank];
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// Same model for the purpose of "is the running tier the baseline". Same
|
|
1140
|
+
// family, and where both sides carry a version, the same version: opus 5 and
|
|
1141
|
+
// opus 5.5 are different models at different prices, but a baseline of the
|
|
1142
|
+
// bare alias 'opus' says nothing about which Opus, so it disagrees with none.
|
|
1143
|
+
function sameModel(a, b) {
|
|
1144
|
+
if (!sameFamily(a, b)) return false;
|
|
1145
|
+
let left = null;
|
|
1146
|
+
let right = null;
|
|
1147
|
+
try {
|
|
1148
|
+
const usage = require('./usage.js');
|
|
1149
|
+
left = usage.parseModelId(a);
|
|
1150
|
+
right = usage.parseModelId(b);
|
|
1151
|
+
} catch (err) {
|
|
1152
|
+
return true;
|
|
1153
|
+
}
|
|
1154
|
+
if (!left || !right || !left.version.length || !right.version.length) return true;
|
|
1155
|
+
return left.version.join('.') === right.version.join('.');
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function money(value) {
|
|
1159
|
+
return Number.isInteger(value) ? '$' + value : '$' + value.toFixed(2);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// A newer model in the SAME family at a lower price is the cheapest saving
|
|
1163
|
+
// there is: no step down in tier, nothing given up, only the price. The advice
|
|
1164
|
+
// channel above only ever says "choose lower"; this says "choose newer", once
|
|
1165
|
+
// per session (brief.js keeps the count), and only from prices on record.
|
|
1166
|
+
//
|
|
1167
|
+
// What the user can do differs by host, and the sentence says only what is
|
|
1168
|
+
// real where it is read. In Claude Code, `/model <id>` switches the session and
|
|
1169
|
+
// saves the model as the default for new sessions (the picker's `s` key is the
|
|
1170
|
+
// this-session-only form), per code.claude.com/docs/en/model-config, read
|
|
1171
|
+
// 2026-09-25. A subagent with no model of its own falls through to the main
|
|
1172
|
+
// conversation's model (docs/en/sub-agents, same day), so a switch reaches
|
|
1173
|
+
// those from their next dispatch. Codex has no /model of that kind, so there
|
|
1174
|
+
// the only lever named is its config.
|
|
1175
|
+
//
|
|
1176
|
+
// The payback figure is exact arithmetic on the two rows, not an estimate of
|
|
1177
|
+
// the session: switching costs one write of the context at the new model's
|
|
1178
|
+
// write price instead of one read at the old read price, and every later turn
|
|
1179
|
+
// saves the difference in read price on that context. The context size cancels
|
|
1180
|
+
// out of the ratio, so the number of turns holds for any session. It counts the
|
|
1181
|
+
// reads alone; the cheaper input and output only shorten it.
|
|
1182
|
+
function newerModelAdvice(tier, options) {
|
|
1183
|
+
const opts = options || {};
|
|
1184
|
+
if (!tier) return null;
|
|
1185
|
+
const usage = opts.usage || require('./usage.js');
|
|
1186
|
+
if (typeof usage.newerSibling !== 'function') return null;
|
|
1187
|
+
const base = tier.baseline || {};
|
|
1188
|
+
const run = tier.running || {};
|
|
1189
|
+
const hostName = opts.host || host.CLAUDE;
|
|
1190
|
+
// A bound the user set outranks the price: nothing said points outside it.
|
|
1191
|
+
const sibling = (model) => {
|
|
1192
|
+
const found = model ? usage.newerSibling(model) : null;
|
|
1193
|
+
return found && allows(opts.bounds, { model: found.to }) ? found : null;
|
|
1194
|
+
};
|
|
1195
|
+
const current = run.model || base.model;
|
|
1196
|
+
const found = sibling(current);
|
|
1197
|
+
// The relay resumes with its own --model when one is set (wake.js), so a
|
|
1198
|
+
// session moved to the newer model still wakes on the old one. Claude Code
|
|
1199
|
+
// only: that is where `relay model` feeds a Claude CLI.
|
|
1200
|
+
const relayFound = hostName === host.CLAUDE && opts.relayModel ? sibling(opts.relayModel) : null;
|
|
1201
|
+
if (!found && !relayFound) return null;
|
|
1202
|
+
|
|
1203
|
+
const name = (family, version) => family.charAt(0).toUpperCase() + family.slice(1) + ' ' + version.join('.');
|
|
1204
|
+
const pct = (was, now) => Math.round((1 - now / was) * 100);
|
|
1205
|
+
const prices = (pair) => {
|
|
1206
|
+
const a = pair.fromRate;
|
|
1207
|
+
const b = pair.toRate;
|
|
1208
|
+
const bits = [];
|
|
1209
|
+
if (b.input < a.input || b.output < a.output) {
|
|
1210
|
+
bits.push(money(b.input) + '/' + money(b.output) + ' per million tokens in and out against ' + money(a.input) + '/' + money(a.output));
|
|
1211
|
+
}
|
|
1212
|
+
if (b.cacheRead < a.cacheRead) {
|
|
1213
|
+
bits.push('cache reads ' + money(b.cacheRead) + ' against ' + money(a.cacheRead) + ', ' + pct(a.cacheRead, b.cacheRead) +
|
|
1214
|
+
'% less on the reads that are most of what a long session spends');
|
|
1215
|
+
}
|
|
1216
|
+
return bits.join('; ');
|
|
1217
|
+
};
|
|
1218
|
+
const family = (pair) => pair.family.charAt(0).toUpperCase() + pair.family.slice(1);
|
|
1219
|
+
|
|
1220
|
+
const sentences = [];
|
|
1221
|
+
if (found) {
|
|
1222
|
+
const a = found.fromRate;
|
|
1223
|
+
const b = found.toRate;
|
|
1224
|
+
sentences.push(name(found.family, found.toVersion) + ' is a newer ' + family(found) + ' at a lower price than the ' +
|
|
1225
|
+
name(found.family, found.fromVersion) + ' running here. At first-party API prices: ' + prices(found) +
|
|
1226
|
+
'. Same tier and a newer release, so no step down.');
|
|
1227
|
+
const pinnedAlready = base.model && String(base.model).toLowerCase().replace(/\[[^\]]*\]\s*$/, '').trim() === found.to;
|
|
1228
|
+
if (hostName === host.CLAUDE) {
|
|
1229
|
+
sentences.push('It is the user\'s switch, not yours: offer `/model ' + found.to + '`, which moves this session and saves it as the ' +
|
|
1230
|
+
'default for new sessions' + (pinnedAlready ? ' (settings.json already names it, so new sessions start on it either way)' : '') +
|
|
1231
|
+
'. Subagents given no model of their own (and no CLAUDE_CODE_SUBAGENT_MODEL) run on the session\'s model, so they follow from their next dispatch.');
|
|
1232
|
+
// Only where the switch can be made mid-session is its one-off cost
|
|
1233
|
+
// worth a number; elsewhere the offer is a pin for new sessions, which
|
|
1234
|
+
// rebuild anyway.
|
|
1235
|
+
if (b.cacheRead < a.cacheRead) {
|
|
1236
|
+
const turns = (write) => Math.ceil(Math.round(((write * b.input - a.cacheRead) / (a.cacheRead - b.cacheRead)) * 100) / 100);
|
|
1237
|
+
sentences.push('Switching rebuilds the prompt cache once; the cheaper reads alone repay that in about ' + turns(1.25) +
|
|
1238
|
+
' turns (' + turns(2) + ' on the one-hour cache).');
|
|
1239
|
+
} else {
|
|
1240
|
+
sentences.push('Switching rebuilds the prompt cache once, so on a large context it is worth making at the next session start rather than now.');
|
|
1241
|
+
}
|
|
1242
|
+
} else if (hostName === host.CODEX) {
|
|
1243
|
+
sentences.push('It is the user\'s setting, not yours: offer pinning model = "' + found.to + '" in config.toml for new sessions.');
|
|
1244
|
+
} else {
|
|
1245
|
+
sentences.push('It is the user\'s setting, not yours: offer choosing ' + found.to + ' in this host\'s own model setting.');
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
if (relayFound) {
|
|
1249
|
+
const same = found && found.to === relayFound.to && found.from === relayFound.from;
|
|
1250
|
+
sentences.push('Relay wakes are pinned to ' + opts.relayModel + ' on their own' +
|
|
1251
|
+
(same ? '' : ', and ' + name(relayFound.family, relayFound.toVersion) + ' is a newer ' + family(relayFound) +
|
|
1252
|
+
' at a lower price (' + prices(relayFound) + ')') +
|
|
1253
|
+
', so a resumed run starts on the older model even after this session switches: offer `/usage-limits:relay model ' + relayFound.to +
|
|
1254
|
+
'`, which is the user\'s setting too.');
|
|
1255
|
+
}
|
|
1256
|
+
const id = (found ? found.from + '->' + found.to : '') + (relayFound ? '|relay:' + relayFound.from + '->' + relayFound.to : '');
|
|
1257
|
+
return {
|
|
1258
|
+
id,
|
|
1259
|
+
from: found ? found.from : null,
|
|
1260
|
+
to: found ? found.to : null,
|
|
1261
|
+
relay: relayFound ? { from: relayFound.from, to: relayFound.to } : null,
|
|
1262
|
+
text: sentences.join(' '),
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1115
1265
|
// One clause for the brief, or two lines for `--baseline`.
|
|
1116
1266
|
//
|
|
1117
1267
|
// Where the baseline and the running tier agree there is nothing interesting
|
|
@@ -1124,16 +1274,12 @@ function tierLine(tier, options) {
|
|
|
1124
1274
|
if (!tier) return null;
|
|
1125
1275
|
const base = tier.baseline || {};
|
|
1126
1276
|
const run = tier.running || {};
|
|
1127
|
-
const
|
|
1128
|
-
const rank = modelRank(name);
|
|
1129
|
-
return rank === null ? name : MODEL_ORDER[rank];
|
|
1130
|
-
};
|
|
1131
|
-
const runningText = [shortModel(run.model) || shortModel(base.model), run.effort || base.effort].filter(Boolean).join('/');
|
|
1277
|
+
const runningText = [modelLabel(run.model) || modelLabel(base.model), run.effort || base.effort].filter(Boolean).join('/');
|
|
1132
1278
|
if (!runningText) return null;
|
|
1133
|
-
const baseText = [
|
|
1279
|
+
const baseText = [modelLabel(base.model), base.effort].filter(Boolean).join('/');
|
|
1134
1280
|
const differs =
|
|
1135
1281
|
baseText && runningText !== baseText &&
|
|
1136
|
-
(!
|
|
1282
|
+
(!sameModel(run.model || base.model, base.model) || (run.effort || base.effort) !== base.effort);
|
|
1137
1283
|
const source = run.source ? ' (' + run.source + ')' : '';
|
|
1138
1284
|
if (opts.terse) {
|
|
1139
1285
|
return differs ? runningText + source + ', yours ' + baseText : runningText + source;
|
|
@@ -1876,6 +2022,9 @@ module.exports = {
|
|
|
1876
2022
|
ultracodeName,
|
|
1877
2023
|
topTier,
|
|
1878
2024
|
tierLine,
|
|
2025
|
+
modelLabel,
|
|
2026
|
+
sameModel,
|
|
2027
|
+
newerModelAdvice,
|
|
1879
2028
|
ledger,
|
|
1880
2029
|
explain,
|
|
1881
2030
|
list,
|
|
@@ -904,19 +904,50 @@ function verifyRegistration(state, next, wanted, now) {
|
|
|
904
904
|
return { ok: true, nextRun: at };
|
|
905
905
|
}
|
|
906
906
|
|
|
907
|
-
const
|
|
907
|
+
const SYSTEM32 = path.join(process.env.SystemRoot || 'C:' + path.sep + 'Windows', 'System32');
|
|
908
|
+
const HIDDEN_HOST = path.join(SYSTEM32, 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
909
|
+
const HEADLESS_HOST = path.join(SYSTEM32, 'conhost.exe');
|
|
910
|
+
|
|
911
|
+
// conhost --headless arrived with the pseudoconsole in Windows 10 1809 (build
|
|
912
|
+
// 17763). Older builds, and anything that is not Windows, keep the PowerShell
|
|
913
|
+
// route alone.
|
|
914
|
+
function headlessAvailable() {
|
|
915
|
+
if (process.platform !== 'win32') return false;
|
|
916
|
+
const build = Number(String(os.release()).split('.')[2]);
|
|
917
|
+
return Number.isFinite(build) && build >= 17763 && fs.existsSync(HEADLESS_HOST);
|
|
918
|
+
}
|
|
908
919
|
|
|
909
920
|
// The task used to run node.exe directly, and node.exe is a console program:
|
|
910
921
|
// under the scheduler, in an interactive session, it gets a console window
|
|
911
922
|
// of its own. That was the "blank terminal that says Claude" - the wake's own
|
|
912
923
|
// console, shared by its headless child - and closing it killed both (task
|
|
913
|
-
// result 0xC000013A), so the wake never wrote its outcome.
|
|
914
|
-
//
|
|
915
|
-
//
|
|
916
|
-
|
|
924
|
+
// result 0xC000013A), so the wake never wrote its outcome.
|
|
925
|
+
//
|
|
926
|
+
// PowerShell's -WindowStyle Hidden was the first fix, and it only works where
|
|
927
|
+
// the old console host draws the window. Windows 11 hands every new console to
|
|
928
|
+
// Windows Terminal by default, and Terminal ignores the flag: measured
|
|
929
|
+
// 2026-09-27, the task opened a "powershell.exe" Terminal window beside the
|
|
930
|
+
// Claude window the wake started, so the user saw two terminals for one relay
|
|
931
|
+
// and closing the wrong one killed the wake again (0xC000013A the same
|
|
932
|
+
// evening). conhost --headless gives the console no window at all, so there
|
|
933
|
+
// is nothing to hand to Terminal; measured the same evening, the same task
|
|
934
|
+
// under it opened no window, and the session window the wake opens (cmd /c
|
|
935
|
+
// start) still appeared, alone.
|
|
936
|
+
function hiddenAction(launcher, cwd, headless) {
|
|
937
|
+
const useHeadless = headless === undefined ? headlessAvailable() : headless;
|
|
938
|
+
const run = '-Command "& ' + psQuote(launcher) + '"';
|
|
939
|
+
if (useHeadless) {
|
|
940
|
+
// No -WindowStyle here: there is no window to style, and the 20 characters
|
|
941
|
+
// matter to the schtasks fallback, which refuses a /TR over 261.
|
|
942
|
+
return {
|
|
943
|
+
execute: HEADLESS_HOST,
|
|
944
|
+
argument: '--headless "' + HIDDEN_HOST + '" -NoProfile -NonInteractive ' + run,
|
|
945
|
+
cwd: cwd || os.homedir(),
|
|
946
|
+
};
|
|
947
|
+
}
|
|
917
948
|
return {
|
|
918
949
|
execute: HIDDEN_HOST,
|
|
919
|
-
argument: '-NoProfile -NonInteractive -WindowStyle Hidden
|
|
950
|
+
argument: '-NoProfile -NonInteractive -WindowStyle Hidden ' + run,
|
|
920
951
|
cwd: cwd || os.homedir(),
|
|
921
952
|
};
|
|
922
953
|
}
|
|
@@ -1884,7 +1915,7 @@ if (require.main === module) {
|
|
|
1884
1915
|
);
|
|
1885
1916
|
}
|
|
1886
1917
|
|
|
1887
|
-
module.exports = { workWithContinuation, reapLost, hiddenAction, BUGCHECK_LINE,
|
|
1918
|
+
module.exports = { workWithContinuation, reapLost, hiddenAction, headlessAvailable, HEADLESS_HOST, HIDDEN_HOST, BUGCHECK_LINE,
|
|
1888
1919
|
taskAction, wakeLauncherFile, wakeLauncherScript, writeWakeLauncher, sweepWakeLaunchers, batchArg, describeSpawn, PS_ROUTE_MS, SCHTASKS_MS,
|
|
1889
1920
|
records, armedFor, putRecord, dropRecord, preflightPrompts, claudeJsonFile, projectKeys, isHome, launchDirFor, applySetting, settingIs,
|
|
1890
1921
|
DEFAULTS,
|
|
@@ -62,6 +62,10 @@ const RATES = {
|
|
|
62
62
|
'claude-mythos-5-1': { input: 10, output: 50, cacheRead: 0.25 },
|
|
63
63
|
'claude-fable-5': { input: 10, output: 50 },
|
|
64
64
|
'claude-mythos-5': { input: 10, output: 50 },
|
|
65
|
+
// Opus 5.5 prices reads outright: $0.20 is 0.05x its input, half the tenth
|
|
66
|
+
// rule every other Opus follows (platform.claude.com/docs/en/about-claude/pricing,
|
|
67
|
+
// read 2026-09-25). Left to the multiplier it would be priced at $0.40.
|
|
68
|
+
'claude-opus-5-5': { input: 4, output: 20, cacheRead: 0.2 },
|
|
65
69
|
'claude-opus-5': { input: 5, output: 25 },
|
|
66
70
|
'claude-opus-4-8': { input: 5, output: 25 },
|
|
67
71
|
'claude-opus-4-7': { input: 5, output: 25 },
|
|
@@ -129,6 +133,83 @@ const CACHE_WRITE_5M = 1.25;
|
|
|
129
133
|
const CACHE_WRITE_1H = 2;
|
|
130
134
|
const CACHE_READ = 0.1;
|
|
131
135
|
|
|
136
|
+
// The effective cache-read price of a row, in $/MTok.
|
|
137
|
+
function readRateOf(rate) {
|
|
138
|
+
return Number.isFinite(rate.cacheRead) ? rate.cacheRead : rate.input * CACHE_READ;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// A model id taken apart into the family word and the version numbers:
|
|
142
|
+
// 'claude-opus-5-5' is opus [5, 5], 'us.anthropic.claude-opus-5-v1:0' is
|
|
143
|
+
// opus [5]. A dated snapshot suffix (20250929) is not part of the version, and a
|
|
144
|
+
// bare alias such as 'opus' has no version at all, so nothing is claimed about
|
|
145
|
+
// which release it resolved to. Mythos stays mythos here: it is priced with
|
|
146
|
+
// Fable, but it is not the same model line.
|
|
147
|
+
function parseModelId(model) {
|
|
148
|
+
const id = normalizeModel(model);
|
|
149
|
+
const found = id.match(/(haiku|sonnet|opus|mythos|fable)((?:-\d+)*)/);
|
|
150
|
+
if (!found) return null;
|
|
151
|
+
const version = [];
|
|
152
|
+
for (const part of found[2].split('-').filter(Boolean)) {
|
|
153
|
+
if (part.length > 2) break;
|
|
154
|
+
version.push(Number(part));
|
|
155
|
+
}
|
|
156
|
+
return { family: found[1], version };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function compareVersions(a, b) {
|
|
160
|
+
const length = Math.max(a.length, b.length);
|
|
161
|
+
for (let i = 0; i < length; i += 1) {
|
|
162
|
+
const diff = (a[i] || 0) - (b[i] || 0);
|
|
163
|
+
if (diff !== 0) return diff;
|
|
164
|
+
}
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Claude 4.7 and later count text with a newer tokenizer that produces about
|
|
169
|
+
// 30% more tokens for the same text (pricing page, read 2026-09-25). A price per
|
|
170
|
+
// token across that line is not a like-for-like price, so a sibling on the other
|
|
171
|
+
// side of it is never offered as the cheaper one.
|
|
172
|
+
function sameTokenizer(a, b) {
|
|
173
|
+
return (compareVersions(a, [4, 7]) >= 0) === (compareVersions(b, [4, 7]) >= 0);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The newest release of the SAME family that is newer than this one and no
|
|
177
|
+
// dearer on any rate - input, output or cache reads - and cheaper on at least
|
|
178
|
+
// one. That is a saving with no step down in tier. Only a model whose own price
|
|
179
|
+
// is on record qualifies: comparing against a family average would be
|
|
180
|
+
// comparing against a guess.
|
|
181
|
+
function newerSibling(model, table) {
|
|
182
|
+
const rates = table || RATES;
|
|
183
|
+
const me = parseModelId(model);
|
|
184
|
+
if (!me || !me.version.length) return null;
|
|
185
|
+
const from = 'claude-' + me.family + '-' + me.version.join('-');
|
|
186
|
+
const mine = rates[from];
|
|
187
|
+
if (!mine) return null;
|
|
188
|
+
let best = null;
|
|
189
|
+
for (const id of Object.keys(rates)) {
|
|
190
|
+
const other = parseModelId(id);
|
|
191
|
+
if (!other || other.family !== me.family || !other.version.length) continue;
|
|
192
|
+
if (compareVersions(other.version, me.version) <= 0) continue;
|
|
193
|
+
if (!sameTokenizer(other.version, me.version)) continue;
|
|
194
|
+
const theirs = rates[id];
|
|
195
|
+
const noDearer = theirs.input <= mine.input && theirs.output <= mine.output && readRateOf(theirs) <= readRateOf(mine);
|
|
196
|
+
const cheaper = theirs.input < mine.input || theirs.output < mine.output || readRateOf(theirs) < readRateOf(mine);
|
|
197
|
+
if (!noDearer || !cheaper) continue;
|
|
198
|
+
if (!best || compareVersions(other.version, best.version) > 0) best = { id, version: other.version, rate: theirs };
|
|
199
|
+
}
|
|
200
|
+
if (!best) return null;
|
|
201
|
+
const shape = (rate) => ({ input: rate.input, output: rate.output, cacheRead: readRateOf(rate) });
|
|
202
|
+
return {
|
|
203
|
+
family: me.family,
|
|
204
|
+
from,
|
|
205
|
+
fromVersion: me.version,
|
|
206
|
+
fromRate: shape(mine),
|
|
207
|
+
to: best.id,
|
|
208
|
+
toVersion: best.version,
|
|
209
|
+
toRate: shape(best.rate),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
132
213
|
// `family` marks a window that caps one model family rather than the account
|
|
133
214
|
// as a whole. It is what tells the rest of the file that a window cannot stop
|
|
134
215
|
// work which does not use that family.
|
|
@@ -4282,6 +4363,8 @@ module.exports = {
|
|
|
4282
4363
|
WINDOWS,
|
|
4283
4364
|
rateFor,
|
|
4284
4365
|
familyOf,
|
|
4366
|
+
parseModelId,
|
|
4367
|
+
newerSibling,
|
|
4285
4368
|
familyAverage,
|
|
4286
4369
|
familiesInUse,
|
|
4287
4370
|
appliesTo,
|
|
@@ -308,6 +308,106 @@ function transcriptExists(id) {
|
|
|
308
308
|
return false;
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
+
function transcriptFile(id) {
|
|
312
|
+
const root = path.join(relay.configDir(), 'projects');
|
|
313
|
+
try {
|
|
314
|
+
for (const dir of fs.readdirSync(root)) {
|
|
315
|
+
const file = path.join(root, dir, id + '.jsonl');
|
|
316
|
+
if (fs.existsSync(file)) return file;
|
|
317
|
+
}
|
|
318
|
+
} catch (err) {
|
|
319
|
+
// No projects folder at all.
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ONE TERMINAL.
|
|
325
|
+
//
|
|
326
|
+
// The session a relay was armed for is often still open. Claude Code's own
|
|
327
|
+
// autoContinueAtUsageLimit (on by default) carries an open session across the
|
|
328
|
+
// reset in place, and a wake that opened `claude --resume` beside it put two
|
|
329
|
+
// terminals on the screen running the same conversation - the same work
|
|
330
|
+
// started twice and the weekly spent twice. Gev, 2026-09-27: "fix it to where
|
|
331
|
+
// it makes 1 terminal and not 2".
|
|
332
|
+
//
|
|
333
|
+
// Claude Code keeps one file per running process in <config>/sessions,
|
|
334
|
+
// named for the pid: { pid, sessionId, procStart, kind, cwd, ... }. procStart
|
|
335
|
+
// is the process creation time (a Windows FILETIME, read against
|
|
336
|
+
// Get-Process on 2026-09-27: 134350239768728478 on both sides), which is what
|
|
337
|
+
// tells a live session from a dead one whose pid Windows has handed to
|
|
338
|
+
// something else. A file whose process is gone is left where it is: it is
|
|
339
|
+
// Claude Code's, not the relay's.
|
|
340
|
+
function pidAlive(pid) {
|
|
341
|
+
try {
|
|
342
|
+
process.kill(pid, 0);
|
|
343
|
+
return true;
|
|
344
|
+
} catch (err) {
|
|
345
|
+
return err.code === 'EPERM';
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function processStart(pid) {
|
|
350
|
+
if (process.platform !== 'win32') return null;
|
|
351
|
+
const shell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
352
|
+
const run = spawnSync(shell, ['-NoProfile', '-NonInteractive', '-Command', '(Get-Process -Id ' + Number(pid) + ' -ErrorAction Stop).StartTime.ToFileTimeUtc()'], {
|
|
353
|
+
encoding: 'utf8',
|
|
354
|
+
timeout: 30000,
|
|
355
|
+
windowsHide: true,
|
|
356
|
+
});
|
|
357
|
+
const said = String(run.stdout || '').trim();
|
|
358
|
+
return run.status === 0 && /^\d+$/.test(said) ? said : null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function liveSession(id, io) {
|
|
362
|
+
const ops = Object.assign({ alive: pidAlive, started: processStart }, io || null);
|
|
363
|
+
const dir = path.join(relay.configDir(), 'sessions');
|
|
364
|
+
let names;
|
|
365
|
+
try {
|
|
366
|
+
names = fs.readdirSync(dir);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
for (const name of names) {
|
|
371
|
+
if (!/^\d+\.json$/.test(name)) continue;
|
|
372
|
+
let held;
|
|
373
|
+
try {
|
|
374
|
+
held = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
|
|
375
|
+
} catch (err) {
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (!held || held.sessionId !== id) continue;
|
|
379
|
+
const pid = Number(held.pid);
|
|
380
|
+
if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) continue;
|
|
381
|
+
if (!ops.alive(pid)) continue;
|
|
382
|
+
// A start time that can be read and does not match is a recycled pid.
|
|
383
|
+
// One that cannot be read (not Windows, or the query failed) leaves the
|
|
384
|
+
// pid's word as the answer.
|
|
385
|
+
if (held.procStart) {
|
|
386
|
+
const started = ops.started(pid);
|
|
387
|
+
if (started !== null && String(started) !== String(held.procStart)) continue;
|
|
388
|
+
}
|
|
389
|
+
return { pid, kind: held.kind || null, cwd: held.cwd || null };
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Written to since `since`: the open session is working again, which is what
|
|
395
|
+
// the CLI's own continue looks like from outside.
|
|
396
|
+
function transcriptActiveSince(id, since) {
|
|
397
|
+
const file = transcriptFile(id);
|
|
398
|
+
if (!file) return false;
|
|
399
|
+
try {
|
|
400
|
+
return fs.statSync(file).mtimeMs >= since;
|
|
401
|
+
} catch (err) {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// How long a wake that finds the session open but quiet waits for it to start
|
|
407
|
+
// working by itself before opening the one window it would have opened anyway.
|
|
408
|
+
const LIVE_WAIT_MS = 3 * MINUTE;
|
|
409
|
+
const LIVE_POLL_MS = 15 * 1000;
|
|
410
|
+
|
|
311
411
|
function readExit(file) {
|
|
312
412
|
try {
|
|
313
413
|
const code = parseInt(fs.readFileSync(file, 'utf8').trim(), 10);
|
|
@@ -569,6 +669,7 @@ async function run(now, argv, overrides) {
|
|
|
569
669
|
{
|
|
570
670
|
windowReopened, deliverClaude, deliverCodex, toast, userIsPresent,
|
|
571
671
|
arm: relay.arm, capabilities: relay.capabilities, reachable: net.reachable,
|
|
672
|
+
liveSession, transcriptActiveSince, sleep: sleepMs, now: Date.now,
|
|
572
673
|
},
|
|
573
674
|
overrides || null
|
|
574
675
|
);
|
|
@@ -615,6 +716,35 @@ async function run(now, argv, overrides) {
|
|
|
615
716
|
return { outcome: 'rescheduled', attempt };
|
|
616
717
|
}
|
|
617
718
|
|
|
719
|
+
// ONE TERMINAL (see liveSession). An open session that is working again -
|
|
720
|
+
// written to since the window reset - has been carried across by the CLI,
|
|
721
|
+
// and a second window would run the same work twice, so the wake stands
|
|
722
|
+
// down. One that is open but quiet gets LIVE_WAIT_MS to start by itself
|
|
723
|
+
// (the CLI's continue, or the session's own timer); if it is closed in that
|
|
724
|
+
// time, or stays quiet, the wake opens its one window, because nothing
|
|
725
|
+
// outside a session can type into it and the plan still has to run.
|
|
726
|
+
if (record.host !== host.CODEX) {
|
|
727
|
+
const since = Number.isFinite(record.wakeAt) ? record.wakeAt - (config.graceMinutes || 0) * MINUTE : now - 5 * MINUTE;
|
|
728
|
+
const started = deps.now();
|
|
729
|
+
let open = deps.liveSession(record.id);
|
|
730
|
+
while (open) {
|
|
731
|
+
if (deps.transcriptActiveSince(record.id, since)) {
|
|
732
|
+
deps.toast(
|
|
733
|
+
'Usage limits: carried on in place',
|
|
734
|
+
(record.project || path.basename(record.cwd)) + ' is still open and working again in its own terminal, so no second window was opened.'
|
|
735
|
+
);
|
|
736
|
+
finish(state, record, 'live', 'the session is open in process ' + open.pid + ' and working again; no second window', now);
|
|
737
|
+
return { outcome: 'live', pid: open.pid };
|
|
738
|
+
}
|
|
739
|
+
if (deps.now() - started >= LIVE_WAIT_MS) break;
|
|
740
|
+
deps.sleep(LIVE_POLL_MS);
|
|
741
|
+
open = deps.liveSession(record.id);
|
|
742
|
+
}
|
|
743
|
+
if (open) {
|
|
744
|
+
relay.note('wake ' + record.id + ': the session is open in process ' + open.pid + ' but idle since the reset; opening it in a window', now);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
618
748
|
// THE PREFLIGHT.
|
|
619
749
|
//
|
|
620
750
|
// The retry above is the right shape but the wrong budget for the failure it
|
|
@@ -848,4 +978,4 @@ if (require.main === module) {
|
|
|
848
978
|
);
|
|
849
979
|
}
|
|
850
980
|
|
|
851
|
-
module.exports = { launcherScriptPosix, shQuote, visibleArgs, launcherScript, pointerPrompt, transcriptExists, wakePromptFile, LAUNCH_GRACE_MS, run, toast, userIsPresent, claudeArgs, deliverClaude, deliverCodex, windowReopened, argOf, appendRun, spawnOptionsFor };
|
|
981
|
+
module.exports = { liveSession, transcriptActiveSince, pidAlive, LIVE_WAIT_MS, LIVE_POLL_MS, launcherScriptPosix, shQuote, visibleArgs, launcherScript, pointerPrompt, transcriptExists, wakePromptFile, LAUNCH_GRACE_MS, run, toast, userIsPresent, claudeArgs, deliverClaude, deliverCodex, windowReopened, argOf, appendRun, spawnOptionsFor };
|