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