claude-usage-limits 1.40.5 → 1.41.1
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.
|
|
4
|
+
"version": "1.41.1",
|
|
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.1",
|
|
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
|
|
@@ -1337,18 +1352,6 @@ percentage and any pressure, the `max` line must never be longer than the
|
|
|
1337
1352
|
worse, and every mode path, alias, bound and guard is exercised before
|
|
1338
1353
|
`settings.json` is compared byte for byte.
|
|
1339
1354
|
|
|
1340
|
-
## Status
|
|
1341
|
-
|
|
1342
|
-
It works and I use it daily.
|
|
1343
|
-
|
|
1344
|
-
What I am not doing is fielding feature requests or support questions. If you
|
|
1345
|
-
want it to behave differently, fork it and change it, which is what the MIT
|
|
1346
|
-
licence is there for. Do not wait on me to add something for you.
|
|
1347
|
-
|
|
1348
|
-
## License
|
|
1349
|
-
|
|
1350
|
-
MIT. See [LICENSE](LICENSE).
|
|
1351
|
-
|
|
1352
1355
|
## The wall is not the end of the budget
|
|
1353
1356
|
|
|
1354
1357
|
A full window is not always a reason to stop, and the plugin now says which.
|
|
@@ -1379,119 +1382,20 @@ line said the budget was nearly gone, and the work stopped - with the 5-hour
|
|
|
1379
1382
|
window at 46 and every other model untouched. One command would have carried it
|
|
1380
1383
|
on.
|
|
1381
1384
|
|
|
1382
|
-
##
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
That is the whole of "Codex does not slow down when the limit is close": nothing
|
|
1401
|
-
ever told it the limit was close. The reader now keeps the newest reading of each
|
|
1402
|
-
meter and prefers the one that actually describes a window. Nothing is merged or
|
|
1403
|
-
synthesised - the payload returned is one Codex really wrote.
|
|
1404
|
-
|
|
1405
|
-
### An old snapshot is an estimate, not a reading
|
|
1406
|
-
|
|
1407
|
-
There was already a warning for a reading spent past its own remainder. It could
|
|
1408
|
-
never fire for a snapshot taken at the start of a window, because everything
|
|
1409
|
-
spent since is still inside the remainder.
|
|
1410
|
-
|
|
1411
|
-
So a session ran for most of an hour being told **7 per cent** while the account
|
|
1412
|
-
was at **41**: Claude Code's own cache had not moved in fifty minutes, the
|
|
1413
|
-
plugin's live reading was rate-limited into backoff, and the correction was
|
|
1414
|
-
quietly carrying the entire difference on its own. A correction is a good
|
|
1415
|
-
adjustment to a recent snapshot and a bad substitute for an old one, because the
|
|
1416
|
-
pricing error compounds with every point it has to bridge. Past fifteen minutes
|
|
1417
|
-
the brief now says the figure is an estimate that can run high or low and points
|
|
1418
|
-
at `/usage`. It is only called a floor when the correction has been refused
|
|
1419
|
-
outright and the raw snapshot is all that is shown, since that really is a
|
|
1420
|
-
lower bound; a snapshot plus a correction is not, and on 2026-09-20 it ran 13
|
|
1421
|
-
to 17 points high.
|
|
1422
|
-
|
|
1423
|
-
### The ceiling
|
|
1424
|
-
|
|
1425
|
-
node bin/cli.js mode --cap 60
|
|
1426
|
-
node bin/cli.js mode --cap off
|
|
1427
|
-
|
|
1428
|
-
Past the ceiling, **fan-out calls are refused at the hook** - `Agent`, `Task`,
|
|
1429
|
-
`Workflow` and their equivalents on each host. Everything else keeps working at
|
|
1430
|
-
any percentage: reads, edits, tests, commands. The work still finishes, just
|
|
1431
|
-
sequentially, in one session, which is where the saving is. A measured fan-out
|
|
1432
|
-
costs between 2.6x and 5.9x the same work done in sequence, because every agent
|
|
1433
|
-
warms its own cache from cold and none can report back until they all stop.
|
|
1434
|
-
|
|
1435
|
-
It is off until you set a number, it never fires without a reading behind it, and
|
|
1436
|
-
the refusal says what to do instead - a denial that only says "over budget" gets
|
|
1437
|
-
retried.
|
|
1438
|
-
|
|
1439
|
-
It is judged on the fullest window this session can spend into, and the refusal
|
|
1440
|
-
names that window. A weekly scoped to one model counts only while that model
|
|
1441
|
-
runs - by the setting, or by the model the session's last reply came from, so a
|
|
1442
|
-
`/model` switch is seen - and a window whose reset has passed does not count.
|
|
1443
|
-
Until 1.39.0 it took the highest number on disk, and an Opus session was refused
|
|
1444
|
-
on the Fable weekly with a message calling it "the binding window".
|
|
1445
|
-
|
|
1446
|
-
**Why a refusal rather than a sentence.** On Codex the reported figure
|
|
1447
|
-
demonstrably does not change behaviour, and the reason is not stubbornness.
|
|
1448
|
-
`gpt-6-astra`'s own system prompt, shipped in `models_cache.json`, says: *"Do not
|
|
1449
|
-
settle for a partial or 'helpful enough' solution that does not fully satisfy the
|
|
1450
|
-
user's task to save time, effort or tokens"* - and ranks the live user
|
|
1451
|
-
instruction above anything an `AGENTS.md` or a skill says. A line asking it to
|
|
1452
|
-
economise is arguing with its own instructions, and losing.
|
|
1453
|
-
|
|
1454
|
-
### The Codex subagent clamp
|
|
1455
|
-
|
|
1456
|
-
`lowpower on --host codex` now also bounds `[agents]`. Two facts read out of the
|
|
1457
|
-
model catalog Codex itself caches, not out of documentation:
|
|
1458
|
-
|
|
1459
|
-
gpt-6-astra: default_reasoning_level = "low"
|
|
1460
|
-
multi_agent_reasoning_effort = "xhigh"
|
|
1461
|
-
|
|
1462
|
-
Astra's own default effort is the cheapest one, and its subagents run at the
|
|
1463
|
-
dearest one **no matter what the session is set to**. Lowering effort without
|
|
1464
|
-
bounding them leaves the most expensive path in the product untouched. The clamp
|
|
1465
|
-
is a marked block, removed exactly by `lowpower off`, and it refuses outright
|
|
1466
|
-
rather than writing a second `[agents]` table over one you wrote yourself.
|
|
1467
|
-
`--no-agents` declines just that half.
|
|
1468
|
-
|
|
1469
|
-
### Antigravity
|
|
1470
|
-
|
|
1471
|
-
node skills/usage-limits/scripts/install-antigravity.js on
|
|
1472
|
-
|
|
1473
|
-
Installs into `~/.gemini/config/plugins/usage-limits/`. `PreInvocation` carries
|
|
1474
|
-
the budget line as an injected ephemeral message; `PreToolUse` carries the
|
|
1475
|
-
ceiling, which Antigravity implements as a real `decision: "deny"`.
|
|
1476
|
-
|
|
1477
|
-
One thing is said plainly rather than papered over: **Antigravity publishes no
|
|
1478
|
-
remaining quota anywhere readable on disk.** It refreshes quota - its own log
|
|
1479
|
-
says so - and keeps it in memory. A previous version filled that gap by reporting
|
|
1480
|
-
*Claude's* meter under a `cross-agent-claude` source, next to a hardcoded plan
|
|
1481
|
-
and a model name invented for a settings file that has no model key in it. All of
|
|
1482
|
-
that is gone. Where there is nothing to read, the report says the quota is
|
|
1483
|
-
unreadable.
|
|
1484
|
-
|
|
1485
|
-
### Caveman, and why it is not here
|
|
1486
|
-
|
|
1487
|
-
It was on the list for this release and it is not in it. JetBrains ran a
|
|
1488
|
-
controlled A/B of the caveman skill - 86 tasks, paired, ~240 billed trials - and
|
|
1489
|
-
measured output tokens down **8.5 per cent** against an advertised 65, with
|
|
1490
|
-
quality differences indistinguishable from noise (p=0.82). An independent
|
|
1491
|
-
benchmark found the literal instruction `be brief.` matched or beat it, and
|
|
1492
|
-
because agentic cost is input-dominated, the skill's own rules riding along every
|
|
1493
|
-
turn can cost more than they save. Its engine is also BSL-licensed, not open.
|
|
1494
|
-
|
|
1495
|
-
Claude Code already ships a built-in **Concise** output style that does the same
|
|
1496
|
-
job in the cached system-prompt layer at no marginal cost. Use that. This plugin
|
|
1497
|
-
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-usage-limits",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.41.1",
|
|
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",
|
|
@@ -1253,15 +1253,20 @@ function briefText(input) {
|
|
|
1253
1253
|
// (autoContinueAtUsageLimit, code.claude.com/docs/en/settings-reference). In
|
|
1254
1254
|
// place, with the context intact, that is strictly better than a wake: no
|
|
1255
1255
|
// hand-off file to re-read and nothing lost. So the wake is the route for a
|
|
1256
|
-
// session that will be CLOSED
|
|
1257
|
-
//
|
|
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.
|
|
1258
1261
|
if (carry && carry.armed && carry.armed.mode === 'resume' && parts.autoContinue && parts.autoContinue.value) {
|
|
1259
1262
|
relaySentences.push(
|
|
1260
1263
|
'Claude Code\'s own "Continue automatically at usage limit" is on (' + parts.autoContinue.source +
|
|
1261
|
-
'), so if this terminal is still open
|
|
1262
|
-
'itself, in place, with its context intact - better than any wake. The wake is the
|
|
1263
|
-
'session that is closed by then. Both firing for the same reset would start the work
|
|
1264
|
-
'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.'
|
|
1265
1270
|
);
|
|
1266
1271
|
}
|
|
1267
1272
|
if (carry && carry.last) {
|
|
@@ -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
|
}
|
|
@@ -926,12 +957,57 @@ function taskAction(action) {
|
|
|
926
957
|
return '"' + action.execute + '" ' + action.argument;
|
|
927
958
|
}
|
|
928
959
|
|
|
960
|
+
// THE SCHEDULER SWITCH.
|
|
961
|
+
//
|
|
962
|
+
// Task Scheduler is machine-wide. A fake HOME, a fake CLAUDE_CONFIG_DIR or a
|
|
963
|
+
// test's temp folder changes where the relay keeps its records, and nothing at
|
|
964
|
+
// all about where its wakes go. Found 2026-09-25 23:55: an adversarial run of
|
|
965
|
+
// the relay hook with HOME and USERPROFILE pointed at sandbox folders
|
|
966
|
+
// registered 8 REAL tasks, two of them for the real session id, which would
|
|
967
|
+
// have resumed a second copy of the live conversation. So the real scheduler
|
|
968
|
+
// is only touched when nothing says this is a test or a sandbox:
|
|
969
|
+
// USAGE_LIMITS_SCHEDULER=real always real (the escape hatch)
|
|
970
|
+
// USAGE_LIMITS_SCHEDULER=dry-run never real
|
|
971
|
+
// NODE_TEST_CONTEXT set by `node --test` for every test file,
|
|
972
|
+
// and inherited by what a test spawns
|
|
973
|
+
// a redirected home os.homedir() is not the account's own
|
|
974
|
+
// A dry run registers nothing, cancels nothing, and says so in its answer.
|
|
975
|
+
function sandboxedHome() {
|
|
976
|
+
let real;
|
|
977
|
+
try {
|
|
978
|
+
real = os.userInfo().homedir;
|
|
979
|
+
} catch (err) {
|
|
980
|
+
return false;
|
|
981
|
+
}
|
|
982
|
+
if (!real) return false;
|
|
983
|
+
const norm = (p) => {
|
|
984
|
+
const resolved = path.resolve(p).replace(/[\\/]+$/, '');
|
|
985
|
+
return process.platform === 'linux' ? resolved : resolved.toLowerCase();
|
|
986
|
+
};
|
|
987
|
+
return norm(real) !== norm(os.homedir());
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function schedulerMode() {
|
|
991
|
+
const said = String(process.env.USAGE_LIMITS_SCHEDULER || '').trim().toLowerCase();
|
|
992
|
+
if (said === 'real') return { dry: false, why: null };
|
|
993
|
+
if (said === 'dry-run' || said === 'dry') return { dry: true, why: 'USAGE_LIMITS_SCHEDULER=' + said };
|
|
994
|
+
if (process.env.NODE_TEST_CONTEXT) return { dry: true, why: 'running under node --test' };
|
|
995
|
+
if (sandboxedHome()) return { dry: true, why: 'the home folder is redirected to ' + os.homedir() };
|
|
996
|
+
return { dry: false, why: null };
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function dryRun(mode) {
|
|
1000
|
+
return { ok: true, how: 'dry-run', dry: true, warning: 'no wake was registered (' + mode.why + ')' };
|
|
1001
|
+
}
|
|
1002
|
+
|
|
929
1003
|
function scheduleWindows(when, argv, name, cwd, deadline) {
|
|
930
1004
|
// No time left is a plain refusal, not a hung hook - and not a launcher
|
|
931
1005
|
// written for a task that is never registered.
|
|
932
1006
|
if (Number.isFinite(deadline) && remainingMs(deadline, PS_ROUTE_MS) < 1500) {
|
|
933
1007
|
return { ok: false, error: 'no time left in this hook to register the wake; it will arm on the next prompt' };
|
|
934
1008
|
}
|
|
1009
|
+
const mode = schedulerMode();
|
|
1010
|
+
if (mode.dry) return dryRun(mode);
|
|
935
1011
|
let lastVerifyError = null;
|
|
936
1012
|
const date = new Date(when);
|
|
937
1013
|
const stamp =
|
|
@@ -1043,6 +1119,8 @@ function scheduleWindows(when, argv, name, cwd, deadline) {
|
|
|
1043
1119
|
}
|
|
1044
1120
|
|
|
1045
1121
|
function schedulePosix(when, argv, name, cwd, deadline) {
|
|
1122
|
+
const mode = schedulerMode();
|
|
1123
|
+
if (mode.dry) return dryRun(mode);
|
|
1046
1124
|
const seconds = Math.max(60, Math.round((when - Date.now()) / 1000));
|
|
1047
1125
|
// `at` is the right tool and is absent on most desktops now. A detached
|
|
1048
1126
|
// sleeper is second best: it survives the terminal closing, but not a
|
|
@@ -1090,6 +1168,8 @@ function schedule(when, argv, name, cwd, deadline) {
|
|
|
1090
1168
|
}
|
|
1091
1169
|
|
|
1092
1170
|
function cancelSchedule(name) {
|
|
1171
|
+
// A dry run never touches a task, including one a real run registered.
|
|
1172
|
+
if (schedulerMode().dry) return true;
|
|
1093
1173
|
if (process.platform === 'win32') {
|
|
1094
1174
|
const run = spawnSync(
|
|
1095
1175
|
'powershell.exe',
|
|
@@ -1884,7 +1964,7 @@ if (require.main === module) {
|
|
|
1884
1964
|
);
|
|
1885
1965
|
}
|
|
1886
1966
|
|
|
1887
|
-
module.exports = { workWithContinuation, reapLost, hiddenAction, BUGCHECK_LINE,
|
|
1967
|
+
module.exports = { workWithContinuation, reapLost, schedulerMode, sandboxedHome, hiddenAction, headlessAvailable, HEADLESS_HOST, HIDDEN_HOST, BUGCHECK_LINE,
|
|
1888
1968
|
taskAction, wakeLauncherFile, wakeLauncherScript, writeWakeLauncher, sweepWakeLaunchers, batchArg, describeSpawn, PS_ROUTE_MS, SCHTASKS_MS,
|
|
1889
1969
|
records, armedFor, putRecord, dropRecord, preflightPrompts, claudeJsonFile, projectKeys, isHome, launchDirFor, applySetting, settingIs,
|
|
1890
1970
|
DEFAULTS,
|
|
@@ -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 };
|