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.
@@ -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
- function deliverClaude(record, prompt, config, cli) {
153
- const args = claudeArgs(record, prompt, config, false);
154
- const run = spawnSync(cli, args, {
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: true,
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
- async function run(now, argv) {
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 = relay.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 = relay.arm({
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 delivered = record.host === host.CODEX ? deliverCodex(record, prompt, cli) : deliverClaude(record, prompt, config, cli);
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', delivered.error || 'The plan is still on disk.');
299
- finish(state, record, 'failed', delivered.error, now);
300
- return { outcome: 'failed', error: delivered.error };
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 };