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
|
@@ -30,6 +30,7 @@ const path = require('path');
|
|
|
30
30
|
const { spawnSync } = require('child_process');
|
|
31
31
|
|
|
32
32
|
const relay = require('./relay.js');
|
|
33
|
+
const net = require('./net.js');
|
|
33
34
|
const voice = require('./voice.js');
|
|
34
35
|
const host = require('./host.js');
|
|
35
36
|
|
|
@@ -149,34 +150,53 @@ function claudeArgs(record, prompt, config, fallback) {
|
|
|
149
150
|
return args;
|
|
150
151
|
}
|
|
151
152
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
// The whole output of a resumed run, kept on disk.
|
|
154
|
+
//
|
|
155
|
+
// A wake that failed at four in the morning left one line in the note log, and
|
|
156
|
+
// the only honest answer to "what happened" was "something". The run log is the
|
|
157
|
+
// rest of the story: what the CLI printed, on both streams, for every launch
|
|
158
|
+
// this wake attempted. `relay log --run` reads the newest one back.
|
|
159
|
+
function appendRun(runs, label, result) {
|
|
160
|
+
if (!runs) return result;
|
|
161
|
+
const code = result && result.status !== null && result.status !== undefined ? result.status : 'null';
|
|
162
|
+
runs.push(
|
|
163
|
+
'--- ' + label + ' (exit ' + code + ') ---' + '\n' +
|
|
164
|
+
((result && result.stdout) || '') +
|
|
165
|
+
((result && result.stderr) ? '\n' + '--- stderr ---' + '\n' + result.stderr : '')
|
|
166
|
+
);
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Invisible is tidier, and it is also how somebody finds out in the morning
|
|
171
|
+
// that nothing happened and has no way to tell why. `relay show off` restores
|
|
172
|
+
// the old behaviour for anyone who wants their screen left alone.
|
|
173
|
+
function spawnOptionsFor(record, config, cli) {
|
|
174
|
+
return {
|
|
155
175
|
encoding: 'utf8',
|
|
156
176
|
cwd: record.cwd,
|
|
157
177
|
timeout: RESUME_TIMEOUT_MS,
|
|
158
|
-
windowsHide:
|
|
178
|
+
windowsHide: config && config.show === false,
|
|
159
179
|
shell: process.platform === 'win32' && /\.(cmd|bat)$/i.test(cli),
|
|
160
|
-
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function deliverClaude(record, prompt, config, cli, runs) {
|
|
184
|
+
const args = claudeArgs(record, prompt, config, false);
|
|
185
|
+
const options = spawnOptionsFor(record, config, cli);
|
|
186
|
+
const run = appendRun(runs, 'claude --resume', spawnSync(cli, args, options));
|
|
161
187
|
if (run.status === 0) return { ok: true, how: 'claude --resume' };
|
|
162
188
|
const detail = ((run.stderr || run.stdout || '') + '').trim().split('\n')[0];
|
|
163
189
|
// A session id that no longer resolves is the one failure worth a second
|
|
164
190
|
// attempt: the work still needs doing, only the thread is gone.
|
|
165
191
|
if (/No conversation found/i.test(detail)) {
|
|
166
|
-
const second = spawnSync(cli, claudeArgs(record, prompt, config, true),
|
|
167
|
-
encoding: 'utf8',
|
|
168
|
-
cwd: record.cwd,
|
|
169
|
-
timeout: RESUME_TIMEOUT_MS,
|
|
170
|
-
windowsHide: true,
|
|
171
|
-
shell: process.platform === 'win32' && /\.(cmd|bat)$/i.test(cli),
|
|
172
|
-
});
|
|
192
|
+
const second = appendRun(runs, 'claude --continue', spawnSync(cli, claudeArgs(record, prompt, config, true), options));
|
|
173
193
|
if (second.status === 0) return { ok: true, how: 'claude --continue' };
|
|
174
194
|
return { ok: false, error: ((second.stderr || second.stdout || '') + '').trim().split('\n')[0] || detail };
|
|
175
195
|
}
|
|
176
196
|
return { ok: false, error: detail || 'claude exited ' + run.status };
|
|
177
197
|
}
|
|
178
198
|
|
|
179
|
-
function deliverCodex(record, prompt, cli) {
|
|
199
|
+
function deliverCodex(record, prompt, cli, runs) {
|
|
180
200
|
// Codex keeps interactive sessions on a local app server, and queue is the
|
|
181
201
|
// supported way to put a message into one from outside. If the thread is
|
|
182
202
|
// gone, exec resume does the same work in a fresh process.
|
|
@@ -186,6 +206,7 @@ function deliverCodex(record, prompt, cli) {
|
|
|
186
206
|
timeout: 60000,
|
|
187
207
|
windowsHide: true,
|
|
188
208
|
});
|
|
209
|
+
appendRun(runs, 'codex queue', queued);
|
|
189
210
|
if (queued.status === 0) return { ok: true, how: 'codex queue' };
|
|
190
211
|
const run = spawnSync(cli, ['exec', 'resume', record.id, prompt, '--skip-git-repo-check'], {
|
|
191
212
|
encoding: 'utf8',
|
|
@@ -193,6 +214,7 @@ function deliverCodex(record, prompt, cli) {
|
|
|
193
214
|
timeout: RESUME_TIMEOUT_MS,
|
|
194
215
|
windowsHide: true,
|
|
195
216
|
});
|
|
217
|
+
appendRun(runs, 'codex exec resume', run);
|
|
196
218
|
if (run.status === 0) return { ok: true, how: 'codex exec resume' };
|
|
197
219
|
return { ok: false, error: ((run.stderr || run.stdout || '') + '').trim().split('\n')[0] || 'codex exited ' + run.status };
|
|
198
220
|
}
|
|
@@ -216,7 +238,14 @@ function finish(state, record, outcome, detail, now) {
|
|
|
216
238
|
}
|
|
217
239
|
}
|
|
218
240
|
|
|
219
|
-
|
|
241
|
+
// `overrides` exists so the retry path can be tested without spawning a CLI or
|
|
242
|
+
// registering a real scheduled task. Production passes nothing and gets the
|
|
243
|
+
// real functions; only the names listed here can be swapped.
|
|
244
|
+
async function run(now, argv, overrides) {
|
|
245
|
+
const deps = Object.assign(
|
|
246
|
+
{ windowReopened, deliverClaude, deliverCodex, toast, userIsPresent, arm: relay.arm, capabilities: relay.capabilities },
|
|
247
|
+
overrides || null
|
|
248
|
+
);
|
|
220
249
|
const id = argOf(argv, '--id');
|
|
221
250
|
const state = relay.read();
|
|
222
251
|
const record = state.armed;
|
|
@@ -224,20 +253,20 @@ async function run(now, argv) {
|
|
|
224
253
|
if (id && record.id !== id) return { outcome: 'superseded' };
|
|
225
254
|
|
|
226
255
|
const config = relay.settings(state);
|
|
227
|
-
const capabilities =
|
|
256
|
+
const capabilities = deps.capabilities();
|
|
228
257
|
|
|
229
|
-
const reopened = await windowReopened(record, now);
|
|
258
|
+
const reopened = await deps.windowReopened(record, now);
|
|
230
259
|
// Only reschedule on a reading that says the window is still full. An
|
|
231
260
|
// unreadable meter is not evidence of a limit, and refusing to act on it
|
|
232
261
|
// would turn every offline moment into a cancelled relay.
|
|
233
262
|
if (reopened.known && Number.isFinite(reopened.percent) && reopened.percent >= config.at) {
|
|
234
263
|
const attempt = (record.attempt || 0) + 1;
|
|
235
264
|
if (attempt >= config.attempts) {
|
|
236
|
-
toast('Usage limits', 'The window still reads ' + Math.round(reopened.percent) + ' per cent after ' + attempt + ' checks. The plan is saved; pick it up when you are ready.');
|
|
265
|
+
deps.toast('Usage limits', 'The window still reads ' + Math.round(reopened.percent) + ' per cent after ' + attempt + ' checks. The plan is saved; pick it up when you are ready.');
|
|
237
266
|
finish(state, record, 'gave-up', 'window still at ' + Math.round(reopened.percent) + '%', now);
|
|
238
267
|
return { outcome: 'gave-up' };
|
|
239
268
|
}
|
|
240
|
-
const again =
|
|
269
|
+
const again = deps.arm({
|
|
241
270
|
now,
|
|
242
271
|
sessionId: record.id,
|
|
243
272
|
cwd: record.cwd,
|
|
@@ -257,6 +286,65 @@ async function run(now, argv) {
|
|
|
257
286
|
return { outcome: 'rescheduled', attempt };
|
|
258
287
|
}
|
|
259
288
|
|
|
289
|
+
// THE PREFLIGHT.
|
|
290
|
+
//
|
|
291
|
+
// The retry above is the right shape but the wrong budget for the failure it
|
|
292
|
+
// was written for. A relay woke at 04:25:01 and said "SSL certificate
|
|
293
|
+
// hostname mismatch" 1.5 seconds later - a laptop whose wifi is off, with
|
|
294
|
+
// something answering the handshake in the endpoint's place. Three retries
|
|
295
|
+
// five minutes apart cover fifteen minutes. A router that is off overnight is
|
|
296
|
+
// off for hours, and at the end of those fifteen minutes the relay was spent.
|
|
297
|
+
//
|
|
298
|
+
// So: ask first, before spending a launch on it, and give being offline its
|
|
299
|
+
// own much longer budget. A machine that cannot reach the API has not failed.
|
|
300
|
+
// It is waiting, and waiting is free.
|
|
301
|
+
const link = await net.reachable({ timeoutMs: 8000 });
|
|
302
|
+
if (!link.online) {
|
|
303
|
+
const offlineAttempt = (record.offlineAttempt || 0) + 1;
|
|
304
|
+
if (offlineAttempt <= config.offlineAttempts) {
|
|
305
|
+
const wait = net.backoffMinutes(offlineAttempt - 1, config.offlineRetryMinutes);
|
|
306
|
+
const again = deps.arm({
|
|
307
|
+
now,
|
|
308
|
+
sessionId: record.id,
|
|
309
|
+
cwd: record.cwd,
|
|
310
|
+
hostName: record.host,
|
|
311
|
+
project: record.project,
|
|
312
|
+
resetsAt: now + wait * MINUTE,
|
|
313
|
+
binding: { percentUsed: reopened.known ? reopened.percent : record.percentAtArming, resetsAt: now + wait * MINUTE },
|
|
314
|
+
work: Object.assign({ hasWork: true, pending: 1, source: null, todos: [] }, record.work || {}),
|
|
315
|
+
// graceMinutes is already inside `wait`; adding it again would compound
|
|
316
|
+
// the backoff every round until the retries were hours apart.
|
|
317
|
+
config: Object.assign({}, config, { graceMinutes: 0, armOn: 'threshold' }),
|
|
318
|
+
});
|
|
319
|
+
if (again.ok) {
|
|
320
|
+
const held = relay.read();
|
|
321
|
+
if (held.armed) {
|
|
322
|
+
held.armed.offlineAttempt = offlineAttempt;
|
|
323
|
+
held.armed.attempt = record.attempt || 0;
|
|
324
|
+
held.armed.continuation = record.continuation;
|
|
325
|
+
relay.write(held);
|
|
326
|
+
}
|
|
327
|
+
// Said once, on the first miss, then quiet. Twelve toasts through the
|
|
328
|
+
// night is how a useful notification becomes something to turn off.
|
|
329
|
+
if (offlineAttempt === 1) {
|
|
330
|
+
deps.toast(
|
|
331
|
+
'Usage limits: waiting for the network',
|
|
332
|
+
link.reason === 'intercepted'
|
|
333
|
+
? 'The window reset, but something is answering in the API\'s place - a captive portal, a VPN or a proxy. Holding the plan and retrying.'
|
|
334
|
+
: 'The window reset, but this machine has no route to the API. Holding the plan and retrying.'
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
relay.note('wake ' + record.id + ': offline (' + link.reason + '), retry ' + offlineAttempt + ' in ' + wait + 'm', now);
|
|
338
|
+
return { outcome: 'offline', reason: link.reason, attempt: offlineAttempt, retryInMinutes: wait };
|
|
339
|
+
}
|
|
340
|
+
relay.note('wake ' + record.id + ': offline and could not book a retry: ' + again.error, now);
|
|
341
|
+
}
|
|
342
|
+
deps.toast('Usage limits: still offline',
|
|
343
|
+
'No route to the API after ' + (record.offlineAttempt || 0) + ' tries. The plan is saved: claude --resume ' + record.id.slice(0, 8));
|
|
344
|
+
finish(state, record, 'offline', link.reason + ' - ' + link.detail, now);
|
|
345
|
+
return { outcome: 'offline-gave-up', detail: link.detail };
|
|
346
|
+
}
|
|
347
|
+
|
|
260
348
|
const continuation = relay.readContinuation(record.id);
|
|
261
349
|
const prompt = relay.compose({
|
|
262
350
|
continuation,
|
|
@@ -266,14 +354,14 @@ async function run(now, argv) {
|
|
|
266
354
|
});
|
|
267
355
|
|
|
268
356
|
let mode = config.mode;
|
|
269
|
-
const presence = userIsPresent(config.mode === 'resume' ? capabilities.computerUse : null);
|
|
357
|
+
const presence = deps.userIsPresent(config.mode === 'resume' ? capabilities.computerUse : null);
|
|
270
358
|
if (mode === 'resume' && presence.known && presence.present && config.whenBusy === 'notify') {
|
|
271
359
|
mode = 'notify';
|
|
272
360
|
relay.note('wake ' + record.id + ': someone is at the machine, leaving a note instead', now);
|
|
273
361
|
}
|
|
274
362
|
|
|
275
363
|
if (mode === 'notify') {
|
|
276
|
-
toast(
|
|
364
|
+
deps.toast(
|
|
277
365
|
'Usage limits: the window has reset',
|
|
278
366
|
'The plan for ' + (record.project || path.basename(record.cwd)) + ' is ready to pick up. Run: claude --resume ' + record.id.slice(0, 8)
|
|
279
367
|
);
|
|
@@ -283,21 +371,113 @@ async function run(now, argv) {
|
|
|
283
371
|
|
|
284
372
|
const cli = record.host === host.CODEX ? capabilities.codex : capabilities.claude;
|
|
285
373
|
if (!cli) {
|
|
286
|
-
toast('Usage limits', 'The window has reset but the ' + record.host + ' CLI could not be found, so the plan was left on disk.');
|
|
374
|
+
deps.toast('Usage limits', 'The window has reset but the ' + record.host + ' CLI could not be found, so the plan was left on disk.');
|
|
287
375
|
finish(state, record, 'no-cli', null, now);
|
|
288
376
|
return { outcome: 'no-cli' };
|
|
289
377
|
}
|
|
290
378
|
|
|
291
|
-
toast('Usage limits: resuming', 'Carrying on with ' + (record.project || path.basename(record.cwd)) + ' where the limit stopped it.');
|
|
292
|
-
const
|
|
379
|
+
deps.toast('Usage limits: resuming', 'Carrying on with ' + (record.project || path.basename(record.cwd)) + ' where the limit stopped it.');
|
|
380
|
+
const runs = [];
|
|
381
|
+
const delivered = record.host === host.CODEX
|
|
382
|
+
? deps.deliverCodex(record, prompt, cli, runs)
|
|
383
|
+
: deps.deliverClaude(record, prompt, config, cli, runs);
|
|
384
|
+
const logPath = config.runLog === false ? null : relay.writeRunLog(relay.runLogFile(record.id, now), runs.join('\n' + '\n'));
|
|
293
385
|
if (delivered.ok) {
|
|
294
|
-
toast('Usage limits: done', 'The resumed run finished. Open the session to read it.');
|
|
295
|
-
finish(state, record, 'resumed', delivered.how, now);
|
|
296
|
-
return { outcome: 'resumed', how: delivered.how };
|
|
386
|
+
deps.toast('Usage limits: done', 'The resumed run finished. Open the session to read it.');
|
|
387
|
+
finish(state, record, 'resumed', delivered.how + (logPath ? ' - log at ' + logPath : ''), now);
|
|
388
|
+
return { outcome: 'resumed', how: delivered.how, log: logPath };
|
|
389
|
+
}
|
|
390
|
+
// A launch that failed is not the same as a job that cannot be done, and
|
|
391
|
+
// until now it was treated as one.
|
|
392
|
+
//
|
|
393
|
+
// `attempts` is 3 by default, but the only path that ever counted an attempt
|
|
394
|
+
// was the one above, for a window that had not really reset. A failure to
|
|
395
|
+
// LAUNCH went straight to finish() and the relay was over - permanently,
|
|
396
|
+
// after a single try, hours later, with nobody awake to see it.
|
|
397
|
+
//
|
|
398
|
+
// Measured on this machine on 2026-09-14: a relay armed at 94 per cent woke
|
|
399
|
+
// at 04:25:01 and reported "Unable to connect to API: SSL certificate
|
|
400
|
+
// hostname mismatch" 1.5 seconds later, with attempt still 0. That is what a
|
|
401
|
+
// machine that has just woken looks like before its network is up. The work
|
|
402
|
+
// was saved and never picked up, which is the one outcome this whole feature
|
|
403
|
+
// exists to prevent.
|
|
404
|
+
//
|
|
405
|
+
// So a failed delivery now re-arms, up to the same attempt budget. No attempt
|
|
406
|
+
// is made to sort transient errors from permanent ones: the cost of retrying
|
|
407
|
+
// a permanent failure is one more launch and a later toast, and the cost of
|
|
408
|
+
// not retrying a transient one is the entire night's work.
|
|
409
|
+
const attempt = (record.attempt || 0) + 1;
|
|
410
|
+
if (attempt < config.attempts) {
|
|
411
|
+
// `resetsAt: now` means "treat this moment as the reset", so wakeAt() adds
|
|
412
|
+
// the usual grace and books the next try that far out.
|
|
413
|
+
const again = deps.arm({
|
|
414
|
+
now,
|
|
415
|
+
sessionId: record.id,
|
|
416
|
+
cwd: record.cwd,
|
|
417
|
+
hostName: record.host,
|
|
418
|
+
resetsAt: now,
|
|
419
|
+
binding: { percentUsed: reopened.known ? reopened.percent : null, resetsAt: now },
|
|
420
|
+
work: Object.assign({ hasWork: true, pending: 1, source: null, todos: [] }, record.work || {}),
|
|
421
|
+
config,
|
|
422
|
+
});
|
|
423
|
+
if (again.ok) {
|
|
424
|
+
const held = relay.read();
|
|
425
|
+
held.armed.attempt = attempt;
|
|
426
|
+
held.armed.continuation = record.continuation;
|
|
427
|
+
relay.write(held);
|
|
428
|
+
deps.toast(
|
|
429
|
+
'Usage limits: retrying',
|
|
430
|
+
'The resume could not start (' + (delivered.error || 'unknown error') + '). Trying again in ' +
|
|
431
|
+
config.graceMinutes + ' minutes.'
|
|
432
|
+
);
|
|
433
|
+
relay.note('wake ' + record.id + ': ' + delivered.error + ', retry ' + attempt + ' in ' + config.graceMinutes + 'm', now);
|
|
434
|
+
return { outcome: 'retrying', attempt, error: delivered.error };
|
|
435
|
+
}
|
|
436
|
+
// Could not even book the retry; fall through and say so plainly.
|
|
437
|
+
relay.note('wake ' + record.id + ': retry could not be scheduled: ' + again.error, now);
|
|
438
|
+
}
|
|
439
|
+
// Out of the short retries. Classification earns its place here, where the
|
|
440
|
+
// question is no longer "retry or not" but "how long is it worth waiting",
|
|
441
|
+
// and what to tell somebody who is asleep.
|
|
442
|
+
//
|
|
443
|
+
// wait the network or the service. Another window is cheap.
|
|
444
|
+
// permanent a key, a login, a missing binary. Another window changes
|
|
445
|
+
// nothing, and saying "retrying" would be a lie.
|
|
446
|
+
const verdict = net.classify((delivered.error || '') + ' ' + runs.join(' '));
|
|
447
|
+
const rearms = record.rearms || 0;
|
|
448
|
+
if (config.onFailure === 'rearm' && verdict.kind !== 'permanent' && rearms < config.maxRearms) {
|
|
449
|
+
// A whole window later, not five more minutes: whatever this is, time is
|
|
450
|
+
// the only variable left worth changing.
|
|
451
|
+
const nextWindow = deps.arm({
|
|
452
|
+
now,
|
|
453
|
+
sessionId: record.id,
|
|
454
|
+
cwd: record.cwd,
|
|
455
|
+
hostName: record.host,
|
|
456
|
+
project: record.project,
|
|
457
|
+
resetsAt: now + 5 * 60 * MINUTE,
|
|
458
|
+
binding: { percentUsed: record.percentAtArming, resetsAt: now + 5 * 60 * MINUTE },
|
|
459
|
+
work: Object.assign({ hasWork: true, pending: 1, source: null, todos: [] }, record.work || {}),
|
|
460
|
+
config: Object.assign({}, config, { armOn: 'threshold' }),
|
|
461
|
+
});
|
|
462
|
+
if (nextWindow.ok) {
|
|
463
|
+
const held = relay.read();
|
|
464
|
+
if (held.armed) {
|
|
465
|
+
held.armed.attempt = 0;
|
|
466
|
+
held.armed.offlineAttempt = 0;
|
|
467
|
+
held.armed.rearms = rearms + 1;
|
|
468
|
+
held.armed.continuation = record.continuation;
|
|
469
|
+
relay.write(held);
|
|
470
|
+
}
|
|
471
|
+
deps.toast('Usage limits: could not resume',
|
|
472
|
+
(delivered.error || 'the run failed') + ' - ' + verdict.why + '. Armed again for the next window.' + (logPath ? ' See: relay log --run' : ''));
|
|
473
|
+
relay.note('wake ' + record.id + ': failed (' + verdict.kind + '), armed again for the next window (' + (rearms + 1) + ' of ' + config.maxRearms + ')', now);
|
|
474
|
+
return { outcome: 'rearmed', error: delivered.error, why: verdict.why, rearms: rearms + 1, log: logPath };
|
|
475
|
+
}
|
|
297
476
|
}
|
|
298
|
-
toast('Usage limits: could not resume',
|
|
299
|
-
|
|
300
|
-
|
|
477
|
+
deps.toast('Usage limits: could not resume',
|
|
478
|
+
(delivered.error || 'The plan is still on disk.') + (verdict.kind === 'permanent' ? ' This will not fix itself: ' + verdict.why + '.' : '') + (logPath ? ' See: relay log --run' : ''));
|
|
479
|
+
finish(state, record, 'failed', (delivered.error || '') + ' [' + verdict.kind + ']' + (logPath ? ' - log at ' + logPath : ''), now);
|
|
480
|
+
return { outcome: 'failed', error: delivered.error, attempts: attempt, kind: verdict.kind, log: logPath };
|
|
301
481
|
}
|
|
302
482
|
|
|
303
483
|
if (require.main === module) {
|
|
@@ -313,4 +493,4 @@ if (require.main === module) {
|
|
|
313
493
|
);
|
|
314
494
|
}
|
|
315
495
|
|
|
316
|
-
module.exports = { run, toast, userIsPresent, claudeArgs, deliverClaude, deliverCodex, windowReopened, argOf };
|
|
496
|
+
module.exports = { run, toast, userIsPresent, claudeArgs, deliverClaude, deliverCodex, windowReopened, argOf, appendRun, spawnOptionsFor };
|