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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "usage-limits",
3
3
  "displayName": "Usage Limits",
4
- "version": "1.40.4",
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.40.4",
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
+ ![The usage report: the 5-hour window is binding with about 44 turns left, and at the current pace it runs out 38 minutes before the reset.](docs/img/usage-report.png)
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
- ## 1.23.0 - the ceiling, and three hosts that can be told no
1367
-
1368
- Everything before this release reported. This one intervenes.
1369
-
1370
- ### The Codex meter was going blind about a third of the time
1371
-
1372
- Codex writes more than one meter into its rollouts, and they are not successive
1373
- readings of one thing. A ChatGPT Plus session carries `limit_id: "codex"`, which
1374
- holds the 5-hour and weekly windows, interleaved with `limit_id: "premium"`,
1375
- which holds the credit balance and has `primary` and `secondary` set to `null`.
1376
- The reader took whichever was written last.
1377
-
1378
- On the machine this was found on, two of every six rollouts ended on a `premium`
1379
- payload - and when one did, the plugin reported no windows at all. Measured on a
1380
- real session: the final line was a `premium` payload two lines after a `codex`
1381
- payload reading **99 per cent of the 5-hour window**, and what reached the agent
1382
- was a meter with nothing in it.
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.40.4",
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, far under the tenth rule, and reads are the dominant input
260
- in exactly the long sessions where the difference matters.
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, and saying so is what stops both firing for the
1232
- // same reset - which would run the work twice and spend the weekly twice.
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 at the reset the CLI carries THIS session across by ' +
1237
- 'itself, in place, with its context intact - better than any wake. The wake is the route for a ' +
1238
- 'session that is closed by then. Both firing for the same reset would start the work twice and ' +
1239
- 'spend the weekly twice, so if the terminal is staying open, one of the two is worth standing down.'
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 tier = mode.tierLine(mode.tierNow({ sessionId, now, usage, env: process.env }), { terse });
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
- const seen = sessionId ? usage.liveModel(sessionId) : null;
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 shortModel = (name) => {
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 = [shortModel(base.model), base.effort].filter(Boolean).join('/');
1279
+ const baseText = [modelLabel(base.model), base.effort].filter(Boolean).join('/');
1134
1280
  const differs =
1135
1281
  baseText && runningText !== baseText &&
1136
- (!sameFamily(run.model || base.model, base.model) || (run.effort || base.effort) !== base.effort);
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 HIDDEN_HOST = path.join(process.env.SystemRoot || 'C:' + path.sep + 'Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
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. PowerShell can
914
- // start hidden and node inherits that; the session the wake opens for the
915
- // user is a new window (cmd /c start) and stays visible.
916
- function hiddenAction(launcher, cwd) {
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 -Command "& ' + psQuote(launcher) + '"',
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 };