claude-usage-limits 1.23.0 → 1.24.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/commands/defer.md +47 -0
- package/package.json +1 -1
- package/skills/usage-limits/SKILL.md +30 -1
- package/skills/usage-limits/scripts/defer.js +318 -0
- package/skills/usage-limits/scripts/net.js +179 -0
- package/skills/usage-limits/scripts/relay.js +266 -2
- package/skills/usage-limits/scripts/stop.js +102 -1
- package/skills/usage-limits/scripts/wake.js +210 -30
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usage-limits",
|
|
3
3
|
"displayName": "Usage Limits",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.24.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.24.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",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Put this work off until a time you name, and start nothing now
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Do not start the work in this message. Schedule it, confirm in one line, and stop.
|
|
6
|
+
|
|
7
|
+
Run, from the plugin's skill directory:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
node skills/usage-limits/scripts/defer.js "$ARGUMENTS" --work "<the work, one item per line>"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Where `$ARGUMENTS` is the time the user gave. Accepted forms:
|
|
14
|
+
|
|
15
|
+
- `9:50pm`, `21:50`, `9pm`, `09:05` - a clock time. A time already past today
|
|
16
|
+
means tomorrow.
|
|
17
|
+
- `in 90m`, `in 2h`, `in 45 minutes`
|
|
18
|
+
- `reset` - when the binding usage window resets, plus a few minutes for the
|
|
19
|
+
meter to turn over
|
|
20
|
+
|
|
21
|
+
Pass the work itself in `--work`: the list of things the user asked for, one per
|
|
22
|
+
line, written so a session with none of this conversation's context can act on
|
|
23
|
+
it. That text is saved to disk and handed to the run when it fires. If the user
|
|
24
|
+
gave no list, summarise the pending work from this session instead.
|
|
25
|
+
|
|
26
|
+
Then print the single line the script returns and **write nothing else**. Do not
|
|
27
|
+
start any of the work, do not read files "to prepare", do not draft a plan in
|
|
28
|
+
the reply. The whole point of the command is that this turn is cheap and
|
|
29
|
+
nothing happens yet.
|
|
30
|
+
|
|
31
|
+
Other forms:
|
|
32
|
+
|
|
33
|
+
- `node skills/usage-limits/scripts/defer.js status` - what is deferred and when
|
|
34
|
+
it fires
|
|
35
|
+
- `node skills/usage-limits/scripts/defer.js cancel` - call it off
|
|
36
|
+
|
|
37
|
+
## What actually happens
|
|
38
|
+
|
|
39
|
+
The work is saved as a continuation and a real scheduled task is registered
|
|
40
|
+
(Windows Task Scheduler, or `at`/launchd elsewhere). At the named time the same
|
|
41
|
+
wake script the usage relay uses starts a fresh session in the original
|
|
42
|
+
directory and hands it the saved plan. If the launch fails - a machine whose
|
|
43
|
+
network is not up yet is the common one - it retries rather than giving up.
|
|
44
|
+
|
|
45
|
+
If the time is unreadable or ambiguous, the script refuses and says so. It never
|
|
46
|
+
picks a reading: a deferral that fires at the wrong hour while nobody is awake
|
|
47
|
+
is worse than one that was never set.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-usage-limits",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.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",
|
|
@@ -620,6 +620,32 @@ which files are mid-change, what must be verified before anything is built on
|
|
|
620
620
|
it. It is delivered as a prompt, so an instruction beats a summary. If nothing
|
|
621
621
|
is written, the relay falls back to the outstanding todo list, which is worse.
|
|
622
622
|
|
|
623
|
+
**It arms at the end of a reply, not in the middle of one.** Crossing the
|
|
624
|
+
threshold no longer schedules anything by itself: the relay waits for the reply
|
|
625
|
+
to finish, so what it carries is work that reached a boundary rather than a
|
|
626
|
+
snapshot of an interruption. Past the backstop (95 per cent by default) it stops
|
|
627
|
+
waiting and arms anyway, because a completion that never comes - the limit cut
|
|
628
|
+
the reply off mid-sentence - is a relay that was never armed at all. That is
|
|
629
|
+
what "waiting for this reply to finish before arming" means when the budget line
|
|
630
|
+
says it, and it is not an error. `relay armon threshold` restores the old
|
|
631
|
+
behaviour.
|
|
632
|
+
|
|
633
|
+
**Being offline is not failing.** Before it launches anything the wake checks it
|
|
634
|
+
can actually reach the API, and a machine that cannot is held, not spent: it
|
|
635
|
+
retries on a backoff for hours rather than burning the relay on a launch that
|
|
636
|
+
could not work. This exists because it happened - a relay woke on time, said
|
|
637
|
+
`SSL certificate hostname mismatch` 1.5 seconds later, recorded a failure and
|
|
638
|
+
deleted itself, and a night's work was lost to a router being off. A failed
|
|
639
|
+
launch is also classified now: the network and a busy service buy another
|
|
640
|
+
window, an invalid key or a missing CLI does not, because retrying those would
|
|
641
|
+
waste the window and saying "retrying" would be a lie.
|
|
642
|
+
|
|
643
|
+
Run `relay doctor` before trusting it with anything. It checks the things that
|
|
644
|
+
fail silently hours later - a CLI that moved, a permission mode nobody set, a
|
|
645
|
+
machine whose power plan forbids wake timers, a scheduled task this account
|
|
646
|
+
cannot register, no continuation written, no network - and says which would
|
|
647
|
+
bite. Every resumed run's full output is kept: `relay log --run`.
|
|
648
|
+
|
|
623
649
|
The relay is off unless the user turned it on, and it only arms while there is
|
|
624
650
|
an unfinished todo list or an approved plan to carry. Do not turn it on for
|
|
625
651
|
them, and do not promise behaviour it does not have:
|
|
@@ -628,6 +654,8 @@ them, and do not promise behaviour it does not have:
|
|
|
628
654
|
input to a shell on purpose; the relay uses it only to tell whether somebody
|
|
629
655
|
is at the keyboard, and to show a banner.
|
|
630
656
|
- In `notify` mode — the default — it raises a notification and starts nothing.
|
|
657
|
+
- The resumed run opens in a window you can see. `relay show off` hides it; the
|
|
658
|
+
output is kept either way.
|
|
631
659
|
- In `resume` mode it runs the CLI itself. A headless resume does **not**
|
|
632
660
|
inherit the session's permission mode, so unless one was set the resumed run
|
|
633
661
|
will sit waiting for an approval nobody is there to give.
|
|
@@ -810,7 +838,8 @@ stop.
|
|
|
810
838
|
| `scripts/live.js` | The usage reading itself, taken the way Claude Code takes it for `/usage`, kept in `usage-limits-live.json` where `collect()` prefers it when newer than the cache. |
|
|
811
839
|
| `scripts/drift.js` | How wrong the reading was: each correction written down against the one it replaced. Run it with no arguments for the median and worst gap measured so far, `--json` for the fields. Answer "how far behind does this plugin actually run" from here rather than from memory. |
|
|
812
840
|
| `scripts/view.js`, `scripts/bars.js`, `scripts/activity.js` | The display model, the drawing in Claude's colours, and the working/idle marks the hooks leave for the panel. Not meant to be called by hand. |
|
|
813
|
-
| `scripts/relay.js` | The relay: `status`, `on`/`off`, `at N`, `grace N`, `mode notify\|resume`, `permission MODE`, `thinking off\|resume\|always`, `note "<text>"`, `cancel`, `log`. |
|
|
841
|
+
| `scripts/relay.js` | The relay: `status`, `on`/`off`, `at N`, `grace N`, `mode notify\|resume`, `permission MODE`, `thinking off\|resume\|always`, `armon threshold\|completion`, `backstop N`, `show on\|off`, `onfailure rearm\|stop`, `rearms N`, `offline N`, `doctor`, `note "<text>"`, `cancel`, `log [--run]`. |
|
|
842
|
+
| `scripts/net.js` | Can this machine reach the API, and was a failed run the network's fault. Three probes, TLS-interception detection, and the backoff the offline retries use. |
|
|
814
843
|
| `scripts/wake.js` | What the scheduler runs after the reset: re-checks the meter, then notifies or resumes. Never called by hand. |
|
|
815
844
|
| `scripts/voice.js` | The local writing profile: `show`, `card`, `set "<instruction>"`, `clear`, `off`/`on`, `forget`. |
|
|
816
845
|
| `references/how-it-works.md` | Where the numbers come from and where they are soft. |
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// "Not now. Do it at ten."
|
|
5
|
+
//
|
|
6
|
+
// The relay answers a question the plugin asks itself: the window ran out, so
|
|
7
|
+
// when can this be picked up? This answers a question the USER asks: I do not
|
|
8
|
+
// want this started now, start it then.
|
|
9
|
+
//
|
|
10
|
+
// They share all the machinery below the surface - the same saved plan, the
|
|
11
|
+
// same scheduled task, the same wake script - and differ in one way that
|
|
12
|
+
// matters. The relay fires when a window RESETS, which is a time nobody chose
|
|
13
|
+
// and which moves. A deferral fires at a time a person named, so the grace
|
|
14
|
+
// minutes that let a meter settle are not added to it: 9:50 means 9:50.
|
|
15
|
+
//
|
|
16
|
+
// node defer.js 9:50pm tonight at 21:50
|
|
17
|
+
// node defer.js 21:50 --work "..." with the work spelled out
|
|
18
|
+
// node defer.js "in 90m" ninety minutes from now
|
|
19
|
+
// node defer.js reset when the binding window resets
|
|
20
|
+
// node defer.js status what is deferred, and when it fires
|
|
21
|
+
// node defer.js cancel call it off
|
|
22
|
+
//
|
|
23
|
+
// The reply is deliberately one line. The whole point of the command is that
|
|
24
|
+
// nothing happens now, and a paragraph explaining that would itself be the
|
|
25
|
+
// thing the user was trying to avoid.
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const os = require('os');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
|
|
31
|
+
const relay = require('./relay.js');
|
|
32
|
+
const host = require('./host.js');
|
|
33
|
+
|
|
34
|
+
const MINUTE = 60 * 1000;
|
|
35
|
+
const HOUR = 60 * MINUTE;
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// When
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
// Turn what somebody typed into a moment.
|
|
42
|
+
//
|
|
43
|
+
// Deliberately narrow. Every format here is one a person actually types at a
|
|
44
|
+
// terminal, and anything else is refused with the list rather than guessed at:
|
|
45
|
+
// a deferral that silently fires at the wrong hour is worse than one that
|
|
46
|
+
// refuses to be set.
|
|
47
|
+
function parseWhen(text, now, resetsAt) {
|
|
48
|
+
const raw = String(text == null ? '' : text).trim().toLowerCase();
|
|
49
|
+
if (!raw) return { error: 'No time given.' };
|
|
50
|
+
|
|
51
|
+
// The window reset, which is the common case and the sensible default.
|
|
52
|
+
if (/^(reset|next reset|the reset|window|when it resets)$/.test(raw)) {
|
|
53
|
+
if (!Number.isFinite(resetsAt)) {
|
|
54
|
+
return { error: 'The reset time is not known right now, so name a clock time instead.' };
|
|
55
|
+
}
|
|
56
|
+
// The one case where the relay's grace IS wanted: a meter needs a moment
|
|
57
|
+
// to turn over, and this is a window reset rather than a chosen time.
|
|
58
|
+
return { at: resetsAt + 5 * MINUTE, label: 'when the window resets' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// "in 90m", "in 2h", "in 45 minutes", "in 1.5 hours"
|
|
62
|
+
const relative = raw.match(/^in\s+([0-9]+(?:\.[0-9]+)?)\s*(m|min|mins|minute|minutes|h|hr|hrs|hour|hours)$/);
|
|
63
|
+
if (relative) {
|
|
64
|
+
const amount = Number(relative[1]);
|
|
65
|
+
if (!Number.isFinite(amount) || amount <= 0) return { error: 'That is not a length of time.' };
|
|
66
|
+
const unit = /^m/.test(relative[2]) ? MINUTE : HOUR;
|
|
67
|
+
const span = amount * unit;
|
|
68
|
+
// A year out is not a deferral, it is a mistake.
|
|
69
|
+
if (span > 14 * 24 * HOUR) return { error: 'That is more than a fortnight away.' };
|
|
70
|
+
return { at: now + span, label: 'in ' + relative[1] + (unit === MINUTE ? ' minutes' : ' hours') };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// "9:50pm", "9pm", "21:50", "09:50"
|
|
74
|
+
const clock = raw.match(/^([0-9]{1,2})(?::([0-9]{2}))?\s*(am|pm)?$/);
|
|
75
|
+
if (clock) {
|
|
76
|
+
let hour = Number(clock[1]);
|
|
77
|
+
const minute = clock[2] === undefined ? 0 : Number(clock[2]);
|
|
78
|
+
const meridiem = clock[3];
|
|
79
|
+
if (minute > 59) return { error: 'There is no such minute as ' + minute + '.' };
|
|
80
|
+
if (meridiem) {
|
|
81
|
+
if (hour < 1 || hour > 12) return { error: 'With am or pm the hour has to be 1 to 12.' };
|
|
82
|
+
if (meridiem === 'pm' && hour !== 12) hour += 12;
|
|
83
|
+
if (meridiem === 'am' && hour === 12) hour = 0;
|
|
84
|
+
} else if (hour > 23) {
|
|
85
|
+
return { error: 'There is no such hour as ' + hour + '.' };
|
|
86
|
+
} else if (clock[2] === undefined) {
|
|
87
|
+
// A bare number with no minutes and no am/pm is ambiguous - "9" could be
|
|
88
|
+
// either nine. Refuse rather than pick one.
|
|
89
|
+
return { error: 'Ambiguous: say 9am, 9pm or 09:00.' };
|
|
90
|
+
}
|
|
91
|
+
const at = new Date(now);
|
|
92
|
+
at.setHours(hour, minute, 0, 0);
|
|
93
|
+
let stamp = at.getTime();
|
|
94
|
+
// A time already past today means tomorrow. That is what a person means by
|
|
95
|
+
// "do it at nine" when they say it at eleven at night.
|
|
96
|
+
if (stamp <= now) stamp += 24 * HOUR;
|
|
97
|
+
return { at: stamp, label: formatClock(stamp) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
error:
|
|
102
|
+
'Could not read "' + text + '" as a time. Try 9:50pm, 21:50, "in 90m", or "reset".',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function formatClock(stamp) {
|
|
107
|
+
const d = new Date(stamp);
|
|
108
|
+
let hour = d.getHours();
|
|
109
|
+
const meridiem = hour >= 12 ? 'PM' : 'AM';
|
|
110
|
+
hour = hour % 12 === 0 ? 12 : hour % 12;
|
|
111
|
+
const minute = String(d.getMinutes()).padStart(2, '0');
|
|
112
|
+
return hour + ':' + minute + ' ' + meridiem;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// "3h 12m", the same shape the rest of the plugin prints.
|
|
116
|
+
function formatSpan(ms) {
|
|
117
|
+
if (!Number.isFinite(ms) || ms <= 0) return 'now';
|
|
118
|
+
const minutes = Math.round(ms / MINUTE);
|
|
119
|
+
if (minutes < 60) return minutes + 'm';
|
|
120
|
+
const hours = Math.floor(minutes / 60);
|
|
121
|
+
const rest = minutes % 60;
|
|
122
|
+
if (hours < 24) return rest ? hours + 'h ' + rest + 'm' : hours + 'h';
|
|
123
|
+
const days = Math.floor(hours / 24);
|
|
124
|
+
return days + 'd ' + (hours % 24) + 'h';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// The line
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
// One sentence, and it has to carry four things: that nothing was started,
|
|
132
|
+
// when it will start, how far away that is, and how to call it off. Everything
|
|
133
|
+
// else is noise in a command whose whole purpose is to not do things.
|
|
134
|
+
function confirmation(parts) {
|
|
135
|
+
const bits = [];
|
|
136
|
+
const descriptive = parts.label && parts.label !== parts.clock && !/^in /.test(parts.label);
|
|
137
|
+
bits.push('Doing this at ' + parts.clock + (descriptive ? ' (' + parts.label + ')' : ''));
|
|
138
|
+
bits.push('in ' + formatSpan(parts.in));
|
|
139
|
+
const line = bits.join(', ') + '. ';
|
|
140
|
+
const tail = [];
|
|
141
|
+
tail.push('Nothing has been started');
|
|
142
|
+
if (parts.items) tail.push(parts.items + ' saved');
|
|
143
|
+
if (parts.resetNote) tail.push(parts.resetNote);
|
|
144
|
+
return line + tail.join('; ') + '. Run "defer cancel" to call it off.';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Doing it
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
function bindingReset(now) {
|
|
152
|
+
try {
|
|
153
|
+
const usage = require('./usage.js');
|
|
154
|
+
usage.setHost(host.detect(process.argv.slice(2), process.env));
|
|
155
|
+
const collected = usage.collect(now);
|
|
156
|
+
const utilization = collected && collected.utilization;
|
|
157
|
+
if (!utilization) return { resetsAt: null, percent: null };
|
|
158
|
+
let worst = null;
|
|
159
|
+
for (const key of Object.keys(utilization)) {
|
|
160
|
+
const window = utilization[key];
|
|
161
|
+
if (!window || typeof window !== 'object') continue;
|
|
162
|
+
const percent = Number(window.utilization);
|
|
163
|
+
if (!Number.isFinite(percent)) continue;
|
|
164
|
+
const resets = Date.parse(window.resets_at);
|
|
165
|
+
if (!worst || percent > worst.percent) {
|
|
166
|
+
worst = { percent, resetsAt: Number.isFinite(resets) ? resets : null };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return worst || { resetsAt: null, percent: null };
|
|
170
|
+
} catch (err) {
|
|
171
|
+
return { resetsAt: null, percent: null };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function sessionId(argv, env) {
|
|
176
|
+
const at = argv.indexOf('--session-id');
|
|
177
|
+
if (at !== -1 && argv[at + 1]) return argv[at + 1];
|
|
178
|
+
return env.CLAUDE_SESSION_ID || env.CODEX_SESSION_ID || 'defer-' + Date.now().toString(36);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function argOf(argv, name) {
|
|
182
|
+
const at = argv.indexOf(name);
|
|
183
|
+
return at === -1 ? null : argv[at + 1] || null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function plan(options) {
|
|
187
|
+
const now = options.now;
|
|
188
|
+
const binding = options.binding || { resetsAt: null, percent: null };
|
|
189
|
+
const when = parseWhen(options.when, now, binding.resetsAt);
|
|
190
|
+
if (when.error) return { ok: false, error: when.error };
|
|
191
|
+
|
|
192
|
+
const notes = [];
|
|
193
|
+
// Worth saying, because it is the difference between the deferred run having
|
|
194
|
+
// a budget and hitting the same wall again.
|
|
195
|
+
if (Number.isFinite(binding.resetsAt)) {
|
|
196
|
+
if (when.at >= binding.resetsAt) notes.push('the window will have reset by then');
|
|
197
|
+
else if (Number.isFinite(binding.percent) && binding.percent >= 80) {
|
|
198
|
+
notes.push('note: that is before the window resets at ' + formatClock(binding.resetsAt));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
ok: true,
|
|
203
|
+
at: when.at,
|
|
204
|
+
label: when.label,
|
|
205
|
+
clock: formatClock(when.at),
|
|
206
|
+
in: when.at - now,
|
|
207
|
+
resetNote: notes[0] || null,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function status(now) {
|
|
212
|
+
const state = relay.read();
|
|
213
|
+
const armed = state.armed;
|
|
214
|
+
if (!armed) return 'Nothing is deferred.';
|
|
215
|
+
const when = Number(armed.wakeAt);
|
|
216
|
+
const deferred = armed.deferred === true;
|
|
217
|
+
return (
|
|
218
|
+
(deferred ? 'Deferred' : 'Relay armed') +
|
|
219
|
+
': ' +
|
|
220
|
+
(armed.project || path.basename(armed.cwd || '')) +
|
|
221
|
+
' at ' +
|
|
222
|
+
formatClock(when) +
|
|
223
|
+
' (in ' +
|
|
224
|
+
formatSpan(when - now) +
|
|
225
|
+
')' +
|
|
226
|
+
(armed.how ? ', via ' + armed.how : '') +
|
|
227
|
+
'.'
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function cancel() {
|
|
232
|
+
const state = relay.read();
|
|
233
|
+
if (!state.armed) return 'Nothing was deferred.';
|
|
234
|
+
const label = formatClock(Number(state.armed.wakeAt));
|
|
235
|
+
const result = relay.disarm('cancelled by hand', Date.now());
|
|
236
|
+
return result && result.ok === false
|
|
237
|
+
? 'Could not cancel: ' + result.error
|
|
238
|
+
: 'Cancelled the run booked for ' + label + '.';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function main(argv, now) {
|
|
242
|
+
const args = (argv || []).filter((a) => a !== undefined);
|
|
243
|
+
const first = args.find((a) => !a.startsWith('--')) || '';
|
|
244
|
+
const at = Number.isFinite(now) ? now : Date.now();
|
|
245
|
+
|
|
246
|
+
if (first === 'status') return status(at);
|
|
247
|
+
if (first === 'cancel' || first === 'off') return cancel();
|
|
248
|
+
if (!first || first === 'help') {
|
|
249
|
+
return [
|
|
250
|
+
'defer <time> [--work "..."] put the work off until then and start nothing now',
|
|
251
|
+
' 9:50pm | 21:50 | "in 90m" | reset',
|
|
252
|
+
'defer status what is deferred and when it fires',
|
|
253
|
+
'defer cancel call it off',
|
|
254
|
+
].join('\n');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const binding = bindingReset(at);
|
|
258
|
+
const decided = plan({ now: at, when: first, binding });
|
|
259
|
+
if (!decided.ok) return decided.error;
|
|
260
|
+
|
|
261
|
+
const id = sessionId(args, process.env);
|
|
262
|
+
const work = argOf(args, '--work');
|
|
263
|
+
const cwd = argOf(args, '--cwd') || process.cwd();
|
|
264
|
+
|
|
265
|
+
// The plan is saved before the task is registered. A task that fires with
|
|
266
|
+
// nothing to read is worse than a plan nobody scheduled.
|
|
267
|
+
let items = null;
|
|
268
|
+
if (work) {
|
|
269
|
+
relay.saveContinuation(id, work);
|
|
270
|
+
items = work.split('\n').filter((l) => l.trim()).length + ' line' +
|
|
271
|
+
(work.split('\n').filter((l) => l.trim()).length === 1 ? '' : 's');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const armed = relay.arm({
|
|
275
|
+
now: at,
|
|
276
|
+
sessionId: id,
|
|
277
|
+
cwd,
|
|
278
|
+
hostName: host.detect(args, process.env),
|
|
279
|
+
at: decided.at,
|
|
280
|
+
binding: { percentUsed: binding.percent, resetsAt: binding.resetsAt },
|
|
281
|
+
work: { hasWork: true, pending: 1, source: 'defer', todos: [] },
|
|
282
|
+
});
|
|
283
|
+
if (!armed.ok) return 'Could not schedule it: ' + armed.error;
|
|
284
|
+
|
|
285
|
+
// Mark it as a deferral rather than a limit relay, so `status` and the next
|
|
286
|
+
// session can tell the two apart - they read the same record.
|
|
287
|
+
try {
|
|
288
|
+
const held = relay.read();
|
|
289
|
+
if (held.armed) {
|
|
290
|
+
held.armed.deferred = true;
|
|
291
|
+
held.armed.continuation = Boolean(work);
|
|
292
|
+
relay.write(held);
|
|
293
|
+
}
|
|
294
|
+
} catch (err) {
|
|
295
|
+
// The schedule is the part that matters; the label is not worth failing for.
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
relay.note('deferred ' + id + ' until ' + new Date(decided.at).toISOString(), at);
|
|
299
|
+
return confirmation({
|
|
300
|
+
label: decided.label,
|
|
301
|
+
clock: decided.clock,
|
|
302
|
+
in: decided.in,
|
|
303
|
+
items,
|
|
304
|
+
resetNote: decided.resetNote,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (require.main === module) {
|
|
309
|
+
try {
|
|
310
|
+
process.stdout.write(main(process.argv.slice(2), Date.now()) + '\n');
|
|
311
|
+
process.exitCode = 0;
|
|
312
|
+
} catch (err) {
|
|
313
|
+
process.stderr.write('defer: ' + (err && err.message ? err.message : String(err)) + '\n');
|
|
314
|
+
process.exitCode = 1;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
module.exports = { parseWhen, formatClock, formatSpan, confirmation, plan, status, cancel, main, MINUTE, HOUR };
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Is the network actually usable, and if a run just failed, was the network
|
|
5
|
+
// the reason?
|
|
6
|
+
//
|
|
7
|
+
// This exists because of a real failure. A relay armed overnight woke on time,
|
|
8
|
+
// found nothing wrong with itself, started the CLI, and got back
|
|
9
|
+
//
|
|
10
|
+
// API Error: Unable to connect to API: SSL certificate hostname mismatch
|
|
11
|
+
//
|
|
12
|
+
// which is what a laptop says when the wifi is off and something - a captive
|
|
13
|
+
// portal, a VPN, a corporate proxy - is answering the TLS handshake in the
|
|
14
|
+
// endpoint's place. The relay treated that as a failed run, recorded "failed",
|
|
15
|
+
// cleared itself and deleted its own scheduled task. The work was not done, the
|
|
16
|
+
// window it had waited for was wide open, and nothing was left to try again.
|
|
17
|
+
//
|
|
18
|
+
// The rule that follows from it: **a machine that cannot reach the API has not
|
|
19
|
+
// failed, it is waiting.** Being offline is a normal state for a laptop at
|
|
20
|
+
// three in the morning and it must cost a retry, never the relay.
|
|
21
|
+
//
|
|
22
|
+
// Nothing here uses a package. `fetch` is in Node 18+, and the only thing that
|
|
23
|
+
// matters is telling three cases apart:
|
|
24
|
+
//
|
|
25
|
+
// online the endpoint answered, with anything at all including a 401
|
|
26
|
+
// offline nothing answered - no DNS, no route, no socket
|
|
27
|
+
// intercepted something answered but it was not the endpoint. This is the
|
|
28
|
+
// nasty one, because a captive portal returns a valid HTTP
|
|
29
|
+
// response and a working TLS session for the wrong certificate,
|
|
30
|
+
// so a naive "did I get bytes back" check says yes.
|
|
31
|
+
|
|
32
|
+
const MINUTE = 60 * 1000;
|
|
33
|
+
|
|
34
|
+
// 401 is the correct, healthy answer from an authenticated endpoint hit without
|
|
35
|
+
// a key: it proves DNS, routing, TLS and the service. Anything in the 2xx-5xx
|
|
36
|
+
// range proves the same thing more loosely. Only a thrown error is offline.
|
|
37
|
+
const PROBES = [
|
|
38
|
+
{ name: 'api.anthropic.com', url: 'https://api.anthropic.com/v1/models' },
|
|
39
|
+
{ name: 'claude.ai', url: 'https://claude.ai/robots.txt' },
|
|
40
|
+
{ name: 'github.com', url: 'https://github.com/robots.txt' },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// The strings a TLS interception actually produces, across Node, curl and the
|
|
44
|
+
// CLIs. Matched case-insensitively against the whole error chain because Node
|
|
45
|
+
// buries the real reason in err.cause.
|
|
46
|
+
const TLS_INTERCEPTION = /(certificate|self.signed|self_signed|hostname\/ip does not match|hostname mismatch|altname|unable to verify|cert_authority|ERR_TLS|DEPTH_ZERO|CERT_HAS_EXPIRED|UNABLE_TO_GET_ISSUER)/i;
|
|
47
|
+
|
|
48
|
+
const OFFLINE = /(ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ENETUNREACH|ENETDOWN|EPIPE|ETIMEDOUT|ECONNABORTED|socket hang up|network is unreachable|getaddrinfo|fetch failed|Unable to connect|Connection (?:error|closed|reset)|dns)/i;
|
|
49
|
+
|
|
50
|
+
// Failures that are the service's, not ours, and are worth waiting out rather
|
|
51
|
+
// than burning the relay on. A 529 is Anthropic's own overload code.
|
|
52
|
+
const TRANSIENT_SERVICE = /(\b429\b|\b500\b|\b502\b|\b503\b|\b504\b|\b529\b|overloaded|rate.?limit|too many requests|temporarily unavailable|service unavailable|internal server error|upstream|gateway|try again|timed? ?out|timeout)/i;
|
|
53
|
+
|
|
54
|
+
// Failures that will still be failures in five hours. Retrying these is how a
|
|
55
|
+
// relay spends a whole window re-running the same refusal.
|
|
56
|
+
const PERMANENT = /(\b401\b|\b403\b|invalid.?api.?key|authentication|unauthorized|forbidden|no conversation found|not logged in|please run .?claude .?login|credit balance|billing|quota exceeded|permission denied|ENOENT|command not found|is not recognized)/i;
|
|
57
|
+
|
|
58
|
+
function chain(err) {
|
|
59
|
+
const seen = [];
|
|
60
|
+
let node = err;
|
|
61
|
+
for (let depth = 0; node && depth < 6; depth++) {
|
|
62
|
+
if (node.message) seen.push(String(node.message));
|
|
63
|
+
if (node.code) seen.push(String(node.code));
|
|
64
|
+
if (node.errno) seen.push(String(node.errno));
|
|
65
|
+
node = node.cause;
|
|
66
|
+
}
|
|
67
|
+
return seen.join(' | ');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// One probe. Resolves rather than rejects: a probe that throws is a result.
|
|
71
|
+
async function probe(target, timeoutMs) {
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
const timer = setTimeout(() => controller.abort(), Math.max(1000, timeoutMs || 8000));
|
|
74
|
+
const started = Date.now();
|
|
75
|
+
try {
|
|
76
|
+
const response = await fetch(target.url, {
|
|
77
|
+
method: 'GET',
|
|
78
|
+
signal: controller.signal,
|
|
79
|
+
redirect: 'manual',
|
|
80
|
+
headers: { 'user-agent': 'usage-limits-relay/1 (connectivity probe)' },
|
|
81
|
+
});
|
|
82
|
+
return { name: target.name, ok: true, status: response.status, ms: Date.now() - started };
|
|
83
|
+
} catch (err) {
|
|
84
|
+
const text = chain(err);
|
|
85
|
+
return {
|
|
86
|
+
name: target.name,
|
|
87
|
+
ok: false,
|
|
88
|
+
ms: Date.now() - started,
|
|
89
|
+
intercepted: TLS_INTERCEPTION.test(text),
|
|
90
|
+
aborted: /abort/i.test(text),
|
|
91
|
+
detail: text.split(' | ')[0] || 'unknown',
|
|
92
|
+
};
|
|
93
|
+
} finally {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The question the wake asks: can this machine reach the service right now.
|
|
99
|
+
//
|
|
100
|
+
// Probes run together and the first success wins, because one endpoint being
|
|
101
|
+
// down is not the same as having no network, and waiting for three timeouts in
|
|
102
|
+
// series burns half a minute for an answer the first probe already had.
|
|
103
|
+
async function reachable(options) {
|
|
104
|
+
const opts = options || {};
|
|
105
|
+
const timeoutMs = opts.timeoutMs || 8000;
|
|
106
|
+
const targets = opts.targets || PROBES;
|
|
107
|
+
let results = [];
|
|
108
|
+
try {
|
|
109
|
+
results = await Promise.all(targets.map((target) => probe(target, timeoutMs)));
|
|
110
|
+
} catch (err) {
|
|
111
|
+
return { online: false, reason: 'offline', detail: err.message, results: [] };
|
|
112
|
+
}
|
|
113
|
+
const good = results.filter((r) => r.ok);
|
|
114
|
+
if (good.length) {
|
|
115
|
+
return {
|
|
116
|
+
online: true,
|
|
117
|
+
reason: 'ok',
|
|
118
|
+
detail: good.map((r) => r.name + ' ' + r.status + ' in ' + r.ms + 'ms').join(', '),
|
|
119
|
+
results,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
// Nothing answered. Interception is worth naming separately: the fix is not
|
|
123
|
+
// "wait for the network", it is "sign in to the wifi" or "turn the VPN off",
|
|
124
|
+
// and a message that says so saves somebody a morning.
|
|
125
|
+
if (results.some((r) => r.intercepted)) {
|
|
126
|
+
return {
|
|
127
|
+
online: false,
|
|
128
|
+
reason: 'intercepted',
|
|
129
|
+
detail: 'something answered in the endpoint\'s place - a captive portal, a VPN or a TLS-inspecting proxy. ' +
|
|
130
|
+
(results.find((r) => r.intercepted) || {}).detail,
|
|
131
|
+
results,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
online: false,
|
|
136
|
+
reason: 'offline',
|
|
137
|
+
detail: results.map((r) => r.name + ': ' + (r.aborted ? 'timed out' : r.detail)).join('; '),
|
|
138
|
+
results,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Given the text of a failed run, decide whether trying again later is sensible.
|
|
143
|
+
//
|
|
144
|
+
// wait the network or the service. Retry; do not consume the relay.
|
|
145
|
+
// permanent a key, a login, a missing binary. Retrying wastes the window.
|
|
146
|
+
// unknown treated as wait once and permanent after that, because an
|
|
147
|
+
// unrecognised error that repeats is not going to fix itself.
|
|
148
|
+
function classify(text) {
|
|
149
|
+
const message = String(text || '');
|
|
150
|
+
if (!message.trim()) return { kind: 'unknown', why: 'the run failed without saying why' };
|
|
151
|
+
if (PERMANENT.test(message)) return { kind: 'permanent', why: 'this will still be true after the next reset' };
|
|
152
|
+
if (TLS_INTERCEPTION.test(message)) {
|
|
153
|
+
return {
|
|
154
|
+
kind: 'wait',
|
|
155
|
+
why: 'the TLS handshake did not reach the endpoint - offline, a captive portal, or a VPN in the way',
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (OFFLINE.test(message)) return { kind: 'wait', why: 'the machine could not reach the network' };
|
|
159
|
+
if (TRANSIENT_SERVICE.test(message)) return { kind: 'wait', why: 'the service was busy or unavailable' };
|
|
160
|
+
return { kind: 'unknown', why: 'an error this has not seen before' };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// How long to wait before looking again, given how many times it has already
|
|
164
|
+
// looked. Gentle at first because most outages are a router restarting, then
|
|
165
|
+
// backing off so an overnight outage is not a thousand wake-ups.
|
|
166
|
+
function backoffMinutes(attempt, base) {
|
|
167
|
+
const start = Number.isFinite(base) ? base : 10;
|
|
168
|
+
const steps = [start, start, start * 2, start * 3, start * 6, start * 6];
|
|
169
|
+
return steps[Math.min(Math.max(0, attempt), steps.length - 1)];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = { reachable, probe, classify, backoffMinutes, PROBES, MINUTE };
|
|
173
|
+
|
|
174
|
+
if (require.main === module) {
|
|
175
|
+
reachable({ timeoutMs: 8000 }).then((result) => {
|
|
176
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
177
|
+
process.exit(result.online ? 0 : 1);
|
|
178
|
+
});
|
|
179
|
+
}
|