claude-usage-limits 1.25.0 → 1.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -94,6 +94,11 @@ const DEFAULTS = {
94
94
  // it is also how you find out in the morning that nothing happened and have
95
95
  // no idea why.
96
96
  show: true,
97
+ // The hand-off carries how the user writes, so the resumed session answers
98
+ // in their voice without being reminded; and it asks for the two bug passes
99
+ // the user asks for on nearly every request. Both can be turned off.
100
+ voice: true,
101
+ bugcheck: 'on',
97
102
  // What happens after a wake fails for a reason that will not fix itself:
98
103
  // arm again for the next window, or stop and leave it to a person.
99
104
  onFailure: 'rearm',
@@ -191,9 +196,159 @@ function read() {
191
196
  config: parsed.config && typeof parsed.config === 'object' && !Array.isArray(parsed.config) ? parsed.config : {},
192
197
  armed: parsed.armed && typeof parsed.armed === 'object' && !Array.isArray(parsed.armed) ? parsed.armed : null,
193
198
  history: Array.isArray(parsed.history) ? parsed.history.slice(-10) : [],
199
+ ...(Array.isArray(parsed.others) && parsed.others.length
200
+ ? { others: parsed.others.filter((r) => r && typeof r === 'object' && !Array.isArray(r) && r.id) }
201
+ : {}),
194
202
  };
195
203
  }
196
204
 
205
+ /* ------------------------------------------------- more than one session -- */
206
+
207
+ // state.armed is the record armed first; every further session's record waits
208
+ // in state.others, each with its own scheduled task. Two sessions arming on
209
+ // the same night is the normal case (the plugin was tried ten times before
210
+ // this and one of the failures was the second session being refused), so
211
+ // nothing displaces anything: a session can only replace or cancel its own.
212
+ function records(state) {
213
+ return [state && state.armed, ...(state && Array.isArray(state.others) ? state.others : [])].filter(Boolean);
214
+ }
215
+ function armedFor(state, id) {
216
+ if (!id) return null;
217
+ return records(state).find((r) => r && r.id === id) || null;
218
+ }
219
+ function putRecord(state, record) {
220
+ if (!state.armed || state.armed.id === record.id) { state.armed = record; return; }
221
+ const rest = (Array.isArray(state.others) ? state.others : []).filter((r) => r.id !== record.id);
222
+ rest.push(record);
223
+ state.others = rest;
224
+ }
225
+ function dropRecord(state, id) {
226
+ const rest = Array.isArray(state.others) ? state.others.filter((r) => r.id !== id) : [];
227
+ if (state.armed && state.armed.id === id) state.armed = rest.shift() || null;
228
+ if (rest.length) state.others = rest;
229
+ else delete state.others;
230
+ }
231
+
232
+ /* ------------------------------------------------- what stops a resume ---- */
233
+
234
+ // Claude Code asks two questions on a start nobody is there to answer at four
235
+ // in the morning: whether to trust the folder, and whether bypass permissions
236
+ // is really meant. Both answers live in .claude.json. The folder key is spelled
237
+ // the way the CLI that wrote it spelled it - on the machine this was written
238
+ // on, both C:/x and C:\x keys exist for the same folder - so every spelling
239
+ // gets the answer. This is what stopped the relay on 2026-09-20: the window
240
+ // opened, the trust question appeared, and the wake waited forever.
241
+ function claudeJsonFile() {
242
+ if (process.env.CLAUDE_CONFIG_DIR) return path.join(process.env.CLAUDE_CONFIG_DIR, '.claude.json');
243
+ return path.join(os.homedir(), '.claude.json');
244
+ }
245
+ function projectKeys(cwd) {
246
+ const raw = String(cwd || '').replace(/[\\/]+$/, '');
247
+ if (!raw) return [];
248
+ const fwd = raw.replace(/\\/g, '/');
249
+ const back = raw.replace(/\//g, '\\');
250
+ return [...new Set(process.platform === 'win32' ? [raw, fwd, back] : [raw])];
251
+ }
252
+ // Claude Code never writes trust for the home folder itself (its security
253
+ // page says so, and 2026-09-20 proved it), so a session that lives in $HOME
254
+ // is resumed from a small folder under the config dir that can be trusted,
255
+ // with --add-dir pointing back at the real one. --resume <id> finds the
256
+ // transcript from anywhere on 2.1.223 and later.
257
+ function isHome(dir) {
258
+ const norm = (p) => String(p || '').replace(/[\\/]+$/, '').replace(/\\/g, '/').toLowerCase();
259
+ return Boolean(dir) && norm(dir) === norm(os.homedir());
260
+ }
261
+ function launchDirFor(cwd) {
262
+ if (!isHome(cwd)) return cwd;
263
+ const dir = path.join(configDir(), 'relay-cwd');
264
+ try {
265
+ fs.mkdirSync(dir, { recursive: true });
266
+ } catch (err) {
267
+ }
268
+ return dir;
269
+ }
270
+ function readSettings() {
271
+ try {
272
+ return JSON.parse(fs.readFileSync(settingsFile(), 'utf8').replace(/^\uFEFF/, ''));
273
+ } catch (err) {
274
+ return null;
275
+ }
276
+ }
277
+ function settingIs(key, value) {
278
+ const parsed = readSettings();
279
+ return Boolean(parsed) && parsed[key] === value;
280
+ }
281
+ function applySetting(key, value) {
282
+ const file = settingsFile();
283
+ let raw = null;
284
+ let parsed = {};
285
+ try {
286
+ raw = fs.readFileSync(file, 'utf8');
287
+ parsed = JSON.parse(raw.replace(/^\uFEFF/, ''));
288
+ } catch (err) {
289
+ if (raw !== null) return { changed: false, error: 'settings.json is not readable JSON; left untouched' };
290
+ }
291
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { changed: false, error: 'settings.json is not an object; left untouched' };
292
+ if (parsed[key] === value) return { changed: false };
293
+ parsed[key] = value;
294
+ try {
295
+ fs.mkdirSync(configDir(), { recursive: true });
296
+ if (raw !== null) fs.writeFileSync(file + '.bak-usage-limits', raw);
297
+ fs.writeFileSync(file, JSON.stringify(parsed, null, 2) + '\n');
298
+ } catch (err) {
299
+ return { changed: false, error: err.message };
300
+ }
301
+ return { changed: true, file };
302
+ }
303
+
304
+ function preflightPrompts(cwd, config, options) {
305
+ const file = claudeJsonFile();
306
+ let raw = null;
307
+ let parsed = {};
308
+ try {
309
+ raw = fs.readFileSync(file, 'utf8');
310
+ parsed = JSON.parse(raw.replace(/^\uFEFF/, ''));
311
+ } catch (err) {
312
+ if (raw !== null) return { ok: false, file, error: file + ' is not readable JSON; left untouched', changes: [] };
313
+ }
314
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { ok: false, file, error: file + ' is not an object; left untouched', changes: [] };
315
+ const changes = [];
316
+ if (!parsed.projects || typeof parsed.projects !== 'object' || Array.isArray(parsed.projects)) parsed.projects = {};
317
+ for (const key of projectKeys(cwd)) {
318
+ const entry = parsed.projects[key];
319
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
320
+ parsed.projects[key] = { allowedTools: [], hasTrustDialogAccepted: true };
321
+ changes.push('folder trust for ' + key);
322
+ } else if (entry.hasTrustDialogAccepted !== true) {
323
+ entry.hasTrustDialogAccepted = true;
324
+ changes.push('folder trust for ' + key);
325
+ }
326
+ }
327
+ if (config && config.permissionMode === 'bypassPermissions' && parsed.bypassPermissionsModeAccepted !== true) {
328
+ parsed.bypassPermissionsModeAccepted = true;
329
+ changes.push('bypass permissions accepted');
330
+ }
331
+ // The current build gates that dialog on settings.json instead.
332
+ if (config && config.permissionMode === 'bypassPermissions') {
333
+ if (options && options.dry) {
334
+ if (!settingIs('skipDangerousModePermissionPrompt', true)) changes.push('skipDangerousModePermissionPrompt in settings.json');
335
+ } else {
336
+ const skipped = applySetting('skipDangerousModePermissionPrompt', true);
337
+ if (skipped.changed) changes.push('skipDangerousModePermissionPrompt in settings.json');
338
+ }
339
+ }
340
+ if (raw === null) changes.push('created ' + file);
341
+ if (!changes.length || (options && options.dry)) return { ok: true, file, changes, dry: Boolean(options && options.dry) };
342
+ try {
343
+ fs.mkdirSync(path.dirname(file), { recursive: true });
344
+ if (raw !== null) fs.writeFileSync(file + '.bak-usage-limits', raw);
345
+ writeAtomic(file, JSON.stringify(parsed, null, 2) + '\n');
346
+ } catch (err) {
347
+ return { ok: false, file, error: err.message, changes: [] };
348
+ }
349
+ return { ok: true, file, changes, backup: raw !== null ? file + '.bak-usage-limits' : null };
350
+ }
351
+
197
352
  // Written to a sibling and renamed into place. Two hooks can run at once, and a
198
353
  // reader that lands between the truncate and the write of a plain writeFileSync
199
354
  // sees half a file; the rename is the only step another process can observe.
@@ -250,6 +405,8 @@ function settings(state) {
250
405
  offlineAttempts: Math.min(48, Math.max(1, number(stored.offlineAttempts, DEFAULTS.offlineAttempts))),
251
406
  offlineRetryMinutes: Math.min(120, Math.max(2, number(stored.offlineRetryMinutes, DEFAULTS.offlineRetryMinutes))),
252
407
  show: bool(stored.show, DEFAULTS.show),
408
+ voice: bool(stored.voice, DEFAULTS.voice),
409
+ bugcheck: pick(stored.bugcheck, ['on', 'always', 'off'], DEFAULTS.bugcheck),
253
410
  onFailure: pick(stored.onFailure, ['rearm', 'stop'], DEFAULTS.onFailure),
254
411
  maxRearms: Math.min(10, Math.max(0, number(stored.maxRearms, DEFAULTS.maxRearms))),
255
412
  runLog: bool(stored.runLog, DEFAULTS.runLog),
@@ -337,9 +494,29 @@ function detectWork(transcriptPath, options) {
337
494
  // The continuation the session wrote itself, stored by the /usage-limits:relay
338
495
  // note command. This is the good case: a paragraph from the session that knows
339
496
  // what it was doing beats anything reconstructed from a todo list.
340
- function saveContinuation(id, text) {
341
- const body = String(text || '').slice(0, 8000).trim();
497
+ // The continuation is delivered on stdin now (wake.js), where the cap is
498
+ // 10 MB, so the old 8,000-character slice was a leftover from the argv era -
499
+ // and a silent one: a plan that ran long simply lost its tail. 64 KB is more
500
+ // than any note needs and small enough that a runaway write cannot fill a
501
+ // disk.
502
+ const CONTINUATION_MAX = 65536;
503
+
504
+ // `options.force` is the only way to write ANOTHER session's armed
505
+ // continuation. On 2026-09-14 a session wrote its note to whatever record was
506
+ // armed without checking whose it was; the armed record belonged to a peer,
507
+ // and the peer's 343-byte handoff was replaced with 3 KB of somebody else's
508
+ // plan minutes before it was due to fire. One machine has one relay, so
509
+ // displacement is normal - but a session may only overwrite a different
510
+ // session's plan on purpose, never by default.
511
+ function saveContinuation(id, text, options) {
512
+ const body = String(text || '').slice(0, CONTINUATION_MAX).trim();
342
513
  if (!body) return null;
514
+ const me = process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_SESSION_ID || null;
515
+ const armedNow = armedFor(read(), id);
516
+ if (armedNow && me && me !== id && !(options && options.force)) {
517
+ note('refused to overwrite the armed continuation of ' + id + ' from session ' + me + ' (pass force to do it on purpose)');
518
+ return null;
519
+ }
343
520
  try {
344
521
  fs.mkdirSync(configDir(), { recursive: true });
345
522
  fs.writeFileSync(planFile(id), body + '\n');
@@ -347,9 +524,10 @@ function saveContinuation(id, text) {
347
524
  return null;
348
525
  }
349
526
  const state = read();
350
- if (state.armed && state.armed.id === id) {
351
- state.armed.continuation = true;
352
- state.armed.continuationAt = Date.now();
527
+ const mine = armedFor(state, id);
528
+ if (mine) {
529
+ mine.continuation = true;
530
+ mine.continuationAt = Date.now();
353
531
  write(state);
354
532
  }
355
533
  return planFile(id);
@@ -369,6 +547,12 @@ function readContinuation(id) {
369
547
  // them and a form letter gets a form letter back. Written in the imperative,
370
548
  // because a resumed session that opens by asking what to do has wasted the
371
549
  // wake it was given.
550
+ // Asked for on nearly every request, so the hand-off asks for it unprompted.
551
+ const BUGCHECK_LINE =
552
+ 'Before you call any of this done, check it for bugs twice: one full pass, then a second pass that assumes ' +
553
+ 'the first missed something. The user asks for this on nearly every request, so do it without being asked, ' +
554
+ 'unless the last turn already did both.';
555
+
372
556
  function compose(input) {
373
557
  const parts = [];
374
558
  const options = input || {};
@@ -395,6 +579,7 @@ function compose(input) {
395
579
  parts.push('The plan that was approved:\n\n' + work.plan);
396
580
  }
397
581
  parts.push('Verify anything that was mid-change before building on it: the last turn may have been cut off part-way through an edit.');
582
+ if (options.bugcheck !== false && options.bugcheck !== 'off') parts.push(BUGCHECK_LINE);
398
583
  const card = options.voice;
399
584
  if (card) parts.push('When you write back to the user, this is how they write:\n' + card);
400
585
  return parts.join('\n\n');
@@ -521,8 +706,27 @@ function wakeAt(resetsAt, graceMinutes, now) {
521
706
  return Math.max(at, floor);
522
707
  }
523
708
 
524
- function taskName(id) {
525
- return 'UsageLimitsRelay-' + String(id).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 40);
709
+ // One name per WAKE, not one per session.
710
+ //
711
+ // Reusing a name entangles every registration with the previous one's fate,
712
+ // and all of it is undocumented: whether -Force truly replaces a stale
713
+ // definition, what happens when a name is re-registered while its predecessor
714
+ // is pending expiry-deletion, and whether an old EndBoundary survives the
715
+ // update. On top of that MultipleInstances defaults to IgnoreNew, so a
716
+ // previous run still held open by the four-hour execution limit silently
717
+ // swallows the next launch.
718
+ //
719
+ // A unique suffix removes all of it at once, and lets DeleteExpiredTaskAfter
720
+ // do what it should: each fired wake garbage-collects itself. cancelSchedule
721
+ // works off the stored name, so nothing downstream changes.
722
+ function taskName(id, at) {
723
+ const stamp = Number.isFinite(at) ? at : Date.now();
724
+ return (
725
+ 'UsageLimitsRelay-' +
726
+ String(id).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 40) +
727
+ '-' +
728
+ stamp.toString(36)
729
+ );
526
730
  }
527
731
 
528
732
  function psQuote(value) {
@@ -574,51 +778,259 @@ function remainingMs(deadline, ceiling) {
574
778
  return Math.max(0, Math.min(ceiling, left));
575
779
  }
576
780
 
781
+ // What the PowerShell route may take when nothing bounds it. It used to be a
782
+ // fixed eight seconds whether or not a hook was waiting, and on this machine
783
+ // the ScheduledTasks module alone takes longer than that: measured
784
+ // 2026-09-21, 32 seconds to load and register, 16 to unregister. So every arm
785
+ // by hand timed out here (ETIMEDOUT, which has no stderr), fell through to
786
+ // schtasks, and reported only the fallback's refusal - twice on the night of
787
+ // 2026-09-20, with the primary route's failure never seen at all.
788
+ const PS_ROUTE_MS = 60000;
789
+ const SCHTASKS_MS = 8000;
790
+
791
+ // One spawn's failure in a phrase that fits beside another's. A timeout has
792
+ // no stderr, so reading only stderr is how the primary route's failure was
793
+ // lost.
794
+ function describeSpawn(label, run, timeoutMs) {
795
+ if (!run) return label + ': not attempted';
796
+ if (run.error) {
797
+ return label + ': ' + (run.error.code === 'ETIMEDOUT'
798
+ ? 'timed out after ' + Math.round(timeoutMs / 1000) + ' s'
799
+ : run.error.message);
800
+ }
801
+ const said = String(run.stderr || run.stdout || '').trim().split('\n')[0];
802
+ if (said) return label + ': ' + said;
803
+ return label + ': exited ' + run.status + ' with no output';
804
+ }
805
+
806
+ /* ------------------------------------------------------ the task launcher -- */
807
+
808
+ // The task's own launcher: one small batch file per record, under the config
809
+ // directory, holding the whole node command. Both registration routes point
810
+ // at it, so the task action has one fixed shape whose length no longer
811
+ // depends on where the plugin is installed. It has to be: schtasks caps /TR
812
+ // at 261 characters, and the action used to carry node's path, the plugin
813
+ // cache path to wake.js, the session id and the config directory - 326
814
+ // characters on the 1.36.0 install - so the fallback was refused every time
815
+ // it was tried. cmd's quoting rules apply to text this code wrote: a doubled
816
+ // percent sign is the one escape needed inside double quotes, and a double
817
+ // quote has no escape there, so it is dropped (no path can hold one).
818
+ function wakeLauncherFile(id) {
819
+ return path.join(configDir(), 'relay-task-' + String(id).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 8) + '.cmd');
820
+ }
821
+
822
+ function batchArg(text) {
823
+ return '"' + String(text).replace(/%/g, '%%').replace(/"/g, '') + '"';
824
+ }
825
+
826
+ function wakeLauncherScript(argv) {
827
+ return '@echo off\r\n' + [process.execPath].concat(argv).map(batchArg).join(' ') + '\r\n';
828
+ }
829
+
830
+ function writeWakeLauncher(id, argv) {
831
+ const file = wakeLauncherFile(id);
832
+ fs.mkdirSync(path.dirname(file), { recursive: true });
833
+ fs.writeFileSync(file, wakeLauncherScript(argv), 'utf8');
834
+ return file;
835
+ }
836
+
837
+ // Deleting a batch file while cmd is still on its last line makes cmd end
838
+ // with "The batch file cannot be found" (tested 2026-09-21), so a wake never
839
+ // deletes its own launcher. They are swept here instead, from a process that
840
+ // is not running under one: every relay-task-*.cmd whose record is gone.
841
+ function sweepWakeLaunchers(state) {
842
+ const keep = new Set(records(state).map((r) => path.basename(wakeLauncherFile(r.id))));
843
+ let names;
844
+ try {
845
+ names = fs.readdirSync(configDir());
846
+ } catch (err) {
847
+ return;
848
+ }
849
+ for (const name of names) {
850
+ if (!/^relay-task-[A-Za-z0-9_-]{0,8}\.cmd$/.test(name) || keep.has(name)) continue;
851
+ try {
852
+ fs.unlinkSync(path.join(configDir(), name));
853
+ } catch (err) {
854
+ // A launcher that will not delete is a stray file, not a failed relay.
855
+ }
856
+ }
857
+ }
858
+
859
+ // Does Windows agree that this task will run?
860
+ //
861
+ // `state` and `next` come straight from Get-ScheduledTask and
862
+ // Get-ScheduledTaskInfo. A task can register cleanly and still never fire,
863
+ // and each of these has been seen: Disabled by policy or by an earlier
864
+ // failure; NextRunTime empty because the trigger time had already passed by
865
+ // the time it was written; NextRunTime set to something other than what was
866
+ // asked for, meaning the trigger did not take. None of them raised an error
867
+ // before, so the relay reported an armed wake and nothing happened.
868
+ function verifyRegistration(state, next, wanted, now) {
869
+ const said = String(state || '').trim();
870
+ if (/disabled/i.test(said)) {
871
+ return { ok: false, error: 'the scheduled task registered but is Disabled, so it will not run' };
872
+ }
873
+ const text = String(next || '').trim();
874
+ if (!text) {
875
+ return { ok: false, error: 'the scheduled task registered but Windows reports no next run time, so it will not fire' };
876
+ }
877
+ // It has to LOOK like a timestamp before it is read as one.
878
+ //
879
+ // Date.parse is far too willing: Date.parse("12345") is the year 12345, which
880
+ // is finite, comfortably in the future, and would sail through every check
881
+ // below. This function exists to refuse a task that will not fire, so
882
+ // accepting garbage as proof that one will is the worst failure it has.
883
+ // PowerShell's .ToString("s") is always yyyy-MM-ddTHH:mm:ss; anything else is
884
+ // not an answer to the question that was asked.
885
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?/.test(text)) {
886
+ return { ok: false, error: 'could not read the next run time Windows reported: ' + text };
887
+ }
888
+ const at = Date.parse(text);
889
+ if (!Number.isFinite(at)) {
890
+ return { ok: false, error: 'could not read the next run time Windows reported: ' + text };
891
+ }
892
+ if (at <= now) {
893
+ return { ok: false, error: 'the scheduled task next run time is already in the past' };
894
+ }
895
+ // Five minutes of slack: Task Scheduler rounds to the minute and a trigger
896
+ // can be nudged. Anything further out is a different time from the one asked
897
+ // for, which means the trigger did not take.
898
+ if (Number.isFinite(wanted) && Math.abs(at - wanted) > 5 * 60 * 1000) {
899
+ return {
900
+ ok: false,
901
+ error: 'the scheduled task is set for ' + new Date(at).toISOString() + ', not the requested ' + new Date(wanted).toISOString(),
902
+ };
903
+ }
904
+ return { ok: true, nextRun: at };
905
+ }
906
+
907
+ const HIDDEN_HOST = path.join(process.env.SystemRoot || 'C:' + path.sep + 'Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
908
+
909
+ // The task used to run node.exe directly, and node.exe is a console program:
910
+ // under the scheduler, in an interactive session, it gets a console window
911
+ // of its own. That was the "blank terminal that says Claude" - the wake's own
912
+ // console, shared by its headless child - and closing it killed both (task
913
+ // result 0xC000013A), so the wake never wrote its outcome. PowerShell can
914
+ // start hidden and node inherits that; the session the wake opens for the
915
+ // user is a new window (cmd /c start) and stays visible.
916
+ function hiddenAction(launcher, cwd) {
917
+ return {
918
+ execute: HIDDEN_HOST,
919
+ argument: '-NoProfile -NonInteractive -WindowStyle Hidden -Command "& ' + psQuote(launcher) + '"',
920
+ cwd: cwd || os.homedir(),
921
+ };
922
+ }
923
+
924
+ // The one string schtasks measures: the /TR value.
925
+ function taskAction(action) {
926
+ return '"' + action.execute + '" ' + action.argument;
927
+ }
928
+
577
929
  function scheduleWindows(when, argv, name, cwd, deadline) {
930
+ // No time left is a plain refusal, not a hung hook - and not a launcher
931
+ // written for a task that is never registered.
932
+ if (Number.isFinite(deadline) && remainingMs(deadline, PS_ROUTE_MS) < 1500) {
933
+ return { ok: false, error: 'no time left in this hook to register the wake; it will arm on the next prompt' };
934
+ }
935
+ let lastVerifyError = null;
578
936
  const date = new Date(when);
579
937
  const stamp =
580
938
  date.getFullYear() + '-' + two(date.getMonth() + 1) + '-' + two(date.getDate()) + ' ' +
581
939
  two(date.getHours()) + ':' + two(date.getMinutes()) + ':' + two(date.getSeconds());
582
- const argument = argv.map(winArg).join(' ');
940
+ const idAt = argv.indexOf('--id');
941
+ let launcher;
942
+ try {
943
+ launcher = writeWakeLauncher(idAt !== -1 && argv[idAt + 1] ? argv[idAt + 1] : name, argv);
944
+ } catch (err) {
945
+ return { ok: false, error: 'could not write the wake launcher: ' + err.message };
946
+ }
947
+ const action = hiddenAction(launcher, cwd);
583
948
  const script = [
584
949
  '$ErrorActionPreference = "Stop"',
585
- '$action = New-ScheduledTaskAction -Execute ' + psQuote(process.execPath) +
586
- ' -Argument ' + psQuote(argument) + ' -WorkingDirectory ' + psQuote(cwd || os.homedir()),
950
+ '$action = New-ScheduledTaskAction -Execute ' + psQuote(action.execute) +
951
+ ' -Argument ' + psQuote(action.argument) + ' -WorkingDirectory ' + psQuote(action.cwd),
587
952
  '$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date ' + psQuote(stamp) + ')',
588
953
  // A one-shot task does not remove itself. Without an expiry, every relay
589
954
  // leaves a dead entry in Task Scheduler forever; the wake also unregisters
590
955
  // itself when it finishes, and this is what catches the wakes that never
591
956
  // get to run at all.
592
- '$trigger.EndBoundary = (Get-Date ' + psQuote(stamp) + ').AddHours(12).ToString("s")',
957
+ // "zzz" is load-bearing. EndBoundary is documented as
958
+ // YYYY-MM-DDTHH:MM:SS(+-)HH:MM, and .ToString("s") emits no offset at
959
+ // all. A boundary written without one can be read as UTC, which west of
960
+ // Greenwich puts it HOURS IN THE PAST - so the task is born expired,
961
+ // never fires, and DeleteExpiredTaskAfter quietly reaps it. None of that
962
+ // raises an error: registration returns SCHED_S_SOME_TRIGGERS_FAILED
963
+ // (0x0004131B), which is a SUCCESS code, so PowerShell never throws and
964
+ // the relay reported a wake that could not happen.
965
+ '$trigger.EndBoundary = (Get-Date ' + psQuote(stamp) + ').AddHours(12).ToString("yyyy-MM-ddTHH:mm:sszzz")',
593
966
  '$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -WakeToRun -AllowStartIfOnBatteries ' +
594
967
  '-DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Hours 4) ' +
595
968
  '-DeleteExpiredTaskAfter (New-TimeSpan -Minutes 10)',
596
969
  'Register-ScheduledTask -TaskName ' + psQuote(name) + ' -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null',
597
- 'Write-Output "registered"',
970
+ // Ask Windows whether it will actually fire, rather than trusting that a
971
+ // registration which did not throw is a wake that will happen.
972
+ //
973
+ // This is the difference between "it said it would restart at 4:30 and
974
+ // nothing happened" and an error at arming time. Register-ScheduledTask
975
+ // reports success for a task that will never run: a trigger already in the
976
+ // past, a task left Disabled, a name whose previous registration is still
977
+ // being torn down. NextRunTime is the only field that answers the question
978
+ // actually being asked.
979
+ '$info = Get-ScheduledTaskInfo -TaskName ' + psQuote(name) + ' -ErrorAction SilentlyContinue',
980
+ '$state = (Get-ScheduledTask -TaskName ' + psQuote(name) + ' -ErrorAction SilentlyContinue).State',
981
+ '$next = ""',
982
+ 'if ($info -and $info.NextRunTime) { $next = $info.NextRunTime.ToString("s") }',
983
+ 'Write-Output ("registered|" + $state + "|" + $next)',
598
984
  ].join('\n');
599
985
  const file = path.join(os.tmpdir(), name + '.ps1');
600
986
  try {
601
987
  fs.writeFileSync(file, script, 'utf8');
988
+ const primaryMs = Math.max(500, remainingMs(deadline, PS_ROUTE_MS));
602
989
  const run = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', file], {
603
990
  encoding: 'utf8',
604
991
  windowsHide: true,
605
- timeout: Math.max(500, remainingMs(deadline, 8000)),
992
+ timeout: primaryMs,
606
993
  });
607
- if (run.status === 0 && /registered/.test(run.stdout || '')) return { ok: true, how: 'ScheduledTasks' };
994
+ const registered = (run.stdout || '').match(/registered\|([^|\r\n]*)\|([^\r\n]*)/);
995
+ if (run.status === 0 && registered) {
996
+ const verdict = verifyRegistration(registered[1], registered[2], when, Date.now());
997
+ if (verdict.ok) return { ok: true, how: 'ScheduledTasks', nextRun: verdict.nextRun };
998
+ // Registered but it will not fire. Fall through to schtasks rather than
999
+ // reporting a wake that is not going to happen.
1000
+ lastVerifyError = verdict.error;
1001
+ }
1002
+ // Kept from here on, whatever the fallback does: the fallback's outcome
1003
+ // is the second half of the answer, never the whole of it.
1004
+ const primaryError = lastVerifyError ? 'ScheduledTasks: ' + lastVerifyError : describeSpawn('ScheduledTasks', run, primaryMs);
608
1005
  // No time left for a second attempt is a plain refusal, not a hung hook.
609
- if (remainingMs(deadline, 8000) < 1500) return { ok: false, error: 'no time left in this hook to register the wake; it will arm on the next prompt' };
1006
+ if (remainingMs(deadline, SCHTASKS_MS) < 1500) {
1007
+ return { ok: false, error: 'no time left in this hook to register the wake (' + primaryError + '); it will arm on the next prompt' };
1008
+ }
610
1009
  // schtasks cannot express StartWhenAvailable, so this path is a worse
611
1010
  // guarantee and says so rather than pretending the two are the same.
1011
+ const fallbackMs = Math.max(500, remainingMs(deadline, SCHTASKS_MS));
612
1012
  const fallback = spawnSync(
613
1013
  'schtasks.exe',
614
- ['/Create', '/TN', name, '/TR', '"' + process.execPath + '" ' + argument, '/SC', 'ONCE',
1014
+ ['/Create', '/TN', name, '/TR', taskAction(action), '/SC', 'ONCE',
615
1015
  '/ST', two(date.getHours()) + ':' + two(date.getMinutes()),
616
1016
  '/SD', two(date.getMonth() + 1) + '/' + two(date.getDate()) + '/' + date.getFullYear(),
617
1017
  '/IT', '/Z', '/F'],
618
- { encoding: 'utf8', windowsHide: true, timeout: Math.max(500, remainingMs(deadline, 8000)) }
1018
+ { encoding: 'utf8', windowsHide: true, timeout: fallbackMs }
619
1019
  );
620
- if (fallback.status === 0) return { ok: true, how: 'schtasks', warning: 'a sleeping machine will miss this wake' };
621
- return { ok: false, error: (run.stderr || fallback.stderr || 'could not register a scheduled task').trim().split('\n')[0] };
1020
+ if (fallback.status === 0) {
1021
+ return { ok: true, how: 'schtasks', warning: 'a sleeping machine will miss this wake (' + primaryError + ')', primaryError };
1022
+ }
1023
+ // Both paths failed. If the PowerShell one registered something that
1024
+ // will not fire - or will fire at the wrong time - leaving it behind is
1025
+ // an orphan that nothing tracks and nothing cancels.
1026
+ if (lastVerifyError) {
1027
+ try {
1028
+ cancelSchedule(name);
1029
+ } catch (err) {
1030
+ // Nothing further to do; the expiry is the backstop.
1031
+ }
1032
+ }
1033
+ return { ok: false, error: primaryError + '; ' + describeSpawn('schtasks', fallback, fallbackMs), primaryError };
622
1034
  } catch (err) {
623
1035
  return { ok: false, error: err.message };
624
1036
  } finally {
@@ -664,6 +1076,13 @@ function schedulePosix(when, argv, name, cwd) {
664
1076
 
665
1077
  function schedule(when, argv, name, cwd, deadline) {
666
1078
  if (process.platform === 'win32') return scheduleWindows(when, argv, name, cwd, deadline);
1079
+ // The same refusal the Windows route makes. The `at` spawn below may wait
1080
+ // twenty seconds, inside a hook that is killed at ten, and a hook with no
1081
+ // time left must say so rather than start a registration it cannot finish -
1082
+ // the deadline was passed here and ignored on every platform but one.
1083
+ if (Number.isFinite(deadline) && deadline - Date.now() < 1500) {
1084
+ return { ok: false, error: 'no time left in this hook to register the wake; it will arm on the next prompt' };
1085
+ }
667
1086
  return schedulePosix(when, argv, name, cwd);
668
1087
  }
669
1088
 
@@ -684,6 +1103,59 @@ function cancelSchedule(name) {
684
1103
 
685
1104
  // Everything that has to be true before a wake is scheduled, in the order that
686
1105
  // makes the answer most useful to read.
1106
+ // A saved continuation is work by definition: the session wrote down what
1107
+ // comes next. On 2026-09-14 a session with an 8 KB note and no todo list hit
1108
+ // 100 per cent and was refused for having "no plan or unfinished todo list",
1109
+ // so nothing picked it up after the reset.
1110
+ function workWithContinuation(work, id) {
1111
+ const base = work || { hasWork: false, pending: 0, source: null, todos: [], plan: null };
1112
+ if (base.hasWork) return base;
1113
+ const saved = id ? readContinuation(id) : '';
1114
+ if (!saved) return base;
1115
+ return Object.assign({}, base, { hasWork: true, pending: Math.max(1, base.pending || 0), source: 'continuation' });
1116
+ }
1117
+
1118
+ // A wake that fired and never reported back. The 8:05 PM wake on 2026-09-14
1119
+ // was killed with its console (task result 0xC000013A), so finish() never
1120
+ // ran: the record stayed armed for a wake that was already over, its task
1121
+ // stayed registered, and status said "wake in now" for hours. A wake marks
1122
+ // wokeAt as its first act; a record past its wake with no mark for half an
1123
+ // hour never started, and one marked but silent for longer than a resume
1124
+ // can run has died. Either way it is history, and the slot is free.
1125
+ // Not 30 minutes: a task registered StartWhenAvailable runs when a sleeping
1126
+ // machine comes back, any time inside its 12-hour expiry, and reaping it
1127
+ // sooner would cancel the very catch-up run the registration promises.
1128
+ const LOST_UNSTARTED_MS = 12 * 60 * MINUTE + 30 * MINUTE;
1129
+ const LOST_RUNNING_MS = 3 * 60 * MINUTE + 10 * MINUTE;
1130
+ function reapLost(now) {
1131
+ const state = read();
1132
+ let reaped = false;
1133
+ for (const armed of records(state)) {
1134
+ if (!Number.isFinite(armed.wakeAt)) continue;
1135
+ const limit = Number.isFinite(armed.wokeAt) ? armed.wokeAt + LOST_RUNNING_MS : armed.wakeAt + LOST_UNSTARTED_MS;
1136
+ if (now < limit) continue;
1137
+ const detail = Number.isFinite(armed.wokeAt)
1138
+ ? 'the wake started ' + new Date(armed.wokeAt).toISOString() + ' and never reported back'
1139
+ : 'the wake was due ' + new Date(armed.wakeAt).toISOString() + ' and never started, or died before it could say so';
1140
+ state.history.push(Object.assign({}, armed, { endedAt: now, outcome: 'lost', detail }));
1141
+ state.history = state.history.slice(-10);
1142
+ dropRecord(state, armed.id);
1143
+ note('lost ' + armed.id + ': ' + detail, now);
1144
+ if (armed.task) {
1145
+ try {
1146
+ cancelSchedule(armed.task);
1147
+ } catch (err) {
1148
+ }
1149
+ }
1150
+ reaped = true;
1151
+ }
1152
+ if (reaped) {
1153
+ write(state);
1154
+ sweepWakeLaunchers(state);
1155
+ }
1156
+ return reaped;
1157
+ }
1158
+
687
1159
  function armable(input) {
688
1160
  const options = input || {};
689
1161
  const config = options.config || settings();
@@ -692,9 +1164,16 @@ function armable(input) {
692
1164
  if (!binding || binding.percentUsed === null || binding.percentUsed === undefined) return { ok: false, why: 'no usable window reading' };
693
1165
  if (binding.stale) return { ok: false, why: 'the reading is stale' };
694
1166
  if (binding.percentUsed < config.at) return { ok: false, why: 'below ' + config.at + ' per cent' };
1167
+ // The account's own reading has to be past the mark, not the local
1168
+ // extrapolation on top of it: on 2026-09-20 the estimate ran 17 points hot
1169
+ // and a relay armed on it would have armed a whole window early.
1170
+ const beyond = Number.isFinite(binding.pointsBeyondSnapshot) ? binding.pointsBeyondSnapshot : 0;
1171
+ const raw = binding.percentUsed - Math.max(0, beyond);
1172
+ if (raw < config.at) return { ok: false, why: 'the account reads ' + Math.round(raw) + ' per cent; only the local estimate (' + Math.round(binding.percentUsed) + ') is past ' + config.at };
695
1173
  if (!Number.isFinite(binding.resetsAt)) return { ok: false, why: 'the window has no known reset time' };
696
1174
  if (!options.sessionId) return { ok: false, why: 'no session id' };
697
- if (!options.work || !options.work.hasWork) return { ok: false, why: 'no plan or unfinished todo list to carry' };
1175
+ const work = workWithContinuation(options.work, options.sessionId);
1176
+ if (!work.hasWork) return { ok: false, why: 'no plan or unfinished todo list to carry, and no saved continuation' };
698
1177
  // The boundary rule. Past the threshold but not yet at the backstop, arming
699
1178
  // waits for the reply to finish - the Stop hook passes atCompletion and this
700
1179
  // is the only caller that does. Mid-reply callers get told to wait, and the
@@ -713,19 +1192,47 @@ function arm(input) {
713
1192
  const options = input || {};
714
1193
  const now = Number.isFinite(options.now) ? options.now : Date.now();
715
1194
  const config = options.config || settings();
1195
+ reapLost(now);
1196
+ options.work = workWithContinuation(options.work, options.sessionId);
716
1197
  // `at` is an exact wake time, for work deferred to a moment somebody named
717
1198
  // rather than to a window reset. The grace minutes exist so the meter has
718
1199
  // really turned over before a resume fires; a time a person typed does not
719
1200
  // want fifteen minutes added to it. Still floored a minute out, because a
720
1201
  // task registered for a moment already past fires immediately.
721
- const when = Number.isFinite(options.at)
1202
+ let when = Number.isFinite(options.at)
722
1203
  ? Math.max(options.at, now + MINUTE)
723
1204
  : wakeAt(options.resetsAt, config.graceMinutes, now);
724
1205
  if (!when) return { ok: false, error: 'no reset time to wake after' };
725
1206
 
726
1207
  const state = read();
727
1208
  const id = options.sessionId;
728
- const name = taskName(id);
1209
+ // Two windows starting in the same second race each other for .claude.json
1210
+ // (verified: it corrupts, and onboarding comes back). A minute apart is safe.
1211
+ for (let guard = 0; guard < 20; guard++) {
1212
+ const clash = records(state).find((r) => r.id !== id && Number.isFinite(r.wakeAt) && Math.abs(r.wakeAt - when) < MINUTE);
1213
+ if (!clash) break;
1214
+ when = clash.wakeAt + MINUTE;
1215
+ }
1216
+ const launchCwd = launchDirFor(options.cwd || process.cwd());
1217
+ const name = taskName(id, when);
1218
+ // Names are unique per wake now, so re-arming no longer replaces the old
1219
+ // registration by name; the previous one has to be retired by hand or it
1220
+ // stays live and fires on its own.
1221
+ //
1222
+ // It is retired AFTER the replacement is registered, never before. While
1223
+ // the names were identical, -Force made this one atomic operation. With
1224
+ // unique names, cancelling first opens a window in which the old wake is
1225
+ // gone and the new one does not exist yet - and if registration then
1226
+ // fails, that window never closes: the user is left with no relay at all,
1227
+ // unattended, having had a working one a moment earlier.
1228
+ const existing = armedFor(state, id);
1229
+ const previousTask = existing && existing.task && existing.task !== name ? existing.task : null;
1230
+ let preflight = null;
1231
+ if (config.mode === 'resume' && (!options.hostName || options.hostName === host.CLAUDE) && options.preflight !== false) {
1232
+ preflight = preflightPrompts(launchCwd, config);
1233
+ if (preflight.ok && preflight.changes.length) note('pre-answered for ' + id + ': ' + preflight.changes.join(', '), now);
1234
+ else if (!preflight.ok) note('could not pre-answer the start-up questions for ' + id + ': ' + preflight.error, now);
1235
+ }
729
1236
  // Re-arming the same session for the same reset would register the task
730
1237
  // twice; -Force replaces it, and the record is rewritten either way.
731
1238
  const argv = [path.join(__dirname, 'wake.js'), '--id', id];
@@ -736,8 +1243,21 @@ function arm(input) {
736
1243
  // testing meant it read the real one and found nothing armed.
737
1244
  if (process.env.CLAUDE_CONFIG_DIR) argv.push('--config-dir', process.env.CLAUDE_CONFIG_DIR);
738
1245
  const scheduled = options.schedule === false ? { ok: true, how: 'none' } : schedule(when, argv, name, options.cwd, options.deadline);
1246
+ if (scheduled.ok && previousTask) {
1247
+ // Safe now: the replacement exists.
1248
+ try {
1249
+ cancelSchedule(previousTask);
1250
+ } catch (err) {
1251
+ // The expiry removes it eventually either way.
1252
+ }
1253
+ }
739
1254
  if (!scheduled.ok) {
740
- note('arm failed for ' + id + ': ' + scheduled.error, now);
1255
+ // The old wake is deliberately left alone here. A stale wake that still
1256
+ // fires is worth more than none, and it carries the same continuation.
1257
+ note('arm failed for ' + id + ': ' + scheduled.error + (previousTask ? '; the previous wake was left in place' : ''), now);
1258
+ // A launcher written for a session that has no record is a stray; one
1259
+ // that an earlier wake of this session still points at is kept with it.
1260
+ sweepWakeLaunchers(state);
741
1261
  return { ok: false, error: scheduled.error };
742
1262
  }
743
1263
 
@@ -746,6 +1266,7 @@ function arm(input) {
746
1266
  task: scheduled.how === 'none' ? null : name,
747
1267
  host: options.hostName || host.CLAUDE,
748
1268
  cwd: options.cwd || process.cwd(),
1269
+ launchCwd: launchCwd !== (options.cwd || process.cwd()) ? launchCwd : null,
749
1270
  project: options.project || null,
750
1271
  armedAt: now,
751
1272
  wakeAt: when,
@@ -757,6 +1278,7 @@ function arm(input) {
757
1278
  mode: config.mode,
758
1279
  how: scheduled.how,
759
1280
  warning: scheduled.warning || null,
1281
+ preflight: preflight ? preflight.changes : null,
760
1282
  attempt: 0,
761
1283
  continuation: false,
762
1284
  // The outstanding items travel with the record, not just their count. They
@@ -774,15 +1296,16 @@ function arm(input) {
774
1296
  // Displacing somebody else's live relay is legitimate - one machine, one
775
1297
  // relay - but it must not be silent. A wake still in the future belonged to
776
1298
  // work that somebody expected to be picked up.
777
- if (state.armed && state.armed.id !== id) {
778
- if (Number.isFinite(state.armed.wakeAt) && state.armed.wakeAt > now) {
779
- note('displacing the relay armed for ' + state.armed.id + ' (was due ' + new Date(state.armed.wakeAt).toISOString() + ')', now);
780
- }
781
- if (state.armed.task) cancelSchedule(state.armed.task);
782
- }
783
- state.armed = record;
1299
+ const beside = records(state).filter((r) => r.id !== id).map((r) => r.id.slice(0, 8));
1300
+ putRecord(state, record);
784
1301
  write(state);
785
- note('armed ' + id + ' for ' + new Date(when).toISOString() + ' via ' + scheduled.how, now);
1302
+ sweepWakeLaunchers(state);
1303
+ note(
1304
+ 'armed ' + id + ' for ' + new Date(when).toISOString() + ' via ' + scheduled.how +
1305
+ (scheduled.primaryError ? ' (' + scheduled.primaryError + ')' : '') +
1306
+ (beside.length ? ' beside ' + beside.join(', ') : ''),
1307
+ now
1308
+ );
786
1309
  return { ok: true, record };
787
1310
  }
788
1311
 
@@ -801,24 +1324,27 @@ function arm(input) {
801
1324
  // tidying up should say so.
802
1325
  function disarm(reason, now, id) {
803
1326
  const state = read();
804
- if (!state.armed) return { ok: true, changed: false };
805
- if (id && state.armed.id !== id) {
806
- note('refused to disarm ' + state.armed.id + ' on behalf of ' + id, Number.isFinite(now) ? now : Date.now());
807
- return { ok: true, changed: false, refused: true, armed: state.armed.id };
808
- }
809
- const record = state.armed;
810
- if (record.task) cancelSchedule(record.task);
811
- state.history.push(Object.assign({}, record, { endedAt: Number.isFinite(now) ? now : Date.now(), outcome: reason || 'cancelled' }));
1327
+ const all = records(state);
1328
+ if (!all.length) return { ok: true, changed: false };
1329
+ const targets = id ? all.filter((r) => r.id === id) : all;
1330
+ if (!targets.length) {
1331
+ note('nothing armed for ' + id + '; ' + all.map((r) => r.id.slice(0, 8)).join(', ') + ' left as they were', Number.isFinite(now) ? now : Date.now());
1332
+ return { ok: true, changed: false, refused: true, armed: all[0].id };
1333
+ }
1334
+ for (const record of targets) {
1335
+ if (record.task) cancelSchedule(record.task);
1336
+ state.history.push(Object.assign({}, record, { endedAt: Number.isFinite(now) ? now : Date.now(), outcome: reason || 'cancelled' }));
1337
+ dropRecord(state, record.id);
1338
+ try {
1339
+ fs.unlinkSync(planFile(record.id));
1340
+ } catch (err) {
1341
+ }
1342
+ note('disarmed ' + record.id + ': ' + (reason || 'cancelled'), now);
1343
+ }
812
1344
  state.history = state.history.slice(-10);
813
- state.armed = null;
814
1345
  write(state);
815
- try {
816
- fs.unlinkSync(planFile(record.id));
817
- } catch (err) {
818
- // The continuation file may never have been written.
819
- }
820
- note('disarmed ' + record.id + ': ' + (reason || 'cancelled'), now);
821
- return { ok: true, changed: true, record };
1346
+ sweepWakeLaunchers(state);
1347
+ return { ok: true, changed: true, record: targets[0], records: targets };
822
1348
  }
823
1349
 
824
1350
  function formatWait(ms) {
@@ -831,6 +1357,7 @@ function formatWait(ms) {
831
1357
 
832
1358
  function status(now) {
833
1359
  const at = Number.isFinite(now) ? now : Date.now();
1360
+ reapLost(at);
834
1361
  const state = read();
835
1362
  const config = settings(state);
836
1363
  const able = capabilities();
@@ -839,16 +1366,19 @@ function status(now) {
839
1366
  'Relay is ' + (config.enabled ? 'ON' : 'OFF') + ', arming at ' + config.at + ' per cent, waking ' +
840
1367
  config.graceMinutes + ' min after the reset, delivery ' + config.mode + '.'
841
1368
  );
842
- if (state.armed) {
1369
+ const all = records(state);
1370
+ for (const armed of all) {
843
1371
  lines.push(
844
- 'Armed: session ' + state.armed.id.slice(0, 8) + ' in ' + state.armed.cwd + ', wake in ' +
845
- formatWait(state.armed.wakeAt - at) + ' (' + new Date(state.armed.wakeAt).toLocaleString() + '), via ' + state.armed.how + '.'
1372
+ 'Armed: session ' + armed.id.slice(0, 8) + ' in ' + armed.cwd + ', wake in ' +
1373
+ formatWait(armed.wakeAt - at) + ' (' + new Date(armed.wakeAt).toLocaleString() + '), via ' + armed.how + '.'
846
1374
  );
847
- if (state.armed.warning) lines.push(' Caveat: ' + state.armed.warning + '.');
848
- lines.push(' Continuation written: ' + (state.armed.continuation ? 'yes' : 'not yet'));
849
- } else {
850
- lines.push('Nothing armed.');
1375
+ if (armed.warning) lines.push(' Caveat: ' + armed.warning + '.');
1376
+ if (armed.preflight && armed.preflight.length) lines.push(' Pre-answered: ' + armed.preflight.join(', '));
1377
+ if (armed.launchCwd) lines.push(' Resumes from ' + armed.launchCwd + ' (the home folder can never be trusted) with --add-dir back to ' + armed.cwd);
1378
+ lines.push(' Continuation written: ' + (armed.continuation ? 'yes' : 'not yet'));
851
1379
  }
1380
+ if (!all.length) lines.push('Nothing armed.');
1381
+ else if (all.length > 1) lines.push(all.length + ' sessions armed; each wakes on its own task.');
852
1382
  lines.push(
853
1383
  'Available here: ' + [
854
1384
  able.claude ? 'claude CLI' : null,
@@ -931,6 +1461,7 @@ async function armByHand(rest) {
931
1461
  return (
932
1462
  'Armed by hand for session ' + sessionId.slice(0, 8) + ': wake at ' +
933
1463
  new Date(result.record.wakeAt).toLocaleString() + ' via ' + result.record.how + '.' +
1464
+ (result.record.preflight && result.record.preflight.length ? ' Pre-answered: ' + result.record.preflight.join(', ') + '.' : '') +
934
1465
  (text ? ' Continuation saved.' : ' No continuation yet - add one with: relay note "<text>"')
935
1466
  );
936
1467
  }
@@ -972,6 +1503,36 @@ async function doctor(now) {
972
1503
  const cli = caps.claude || caps.codex;
973
1504
  add('a CLI to resume with', Boolean(cli), cli || 'neither the claude nor the codex CLI could be found on PATH');
974
1505
 
1506
+ // Whether there is a login for the wake to resume under, asked of the CLI
1507
+ // itself: `claude auth status --json` reports loggedIn and nothing secret.
1508
+ // WHEN it expires is the one thing this cannot check. That command reports
1509
+ // no expiry (2.1.263, read from its output), and the only expiry on disk
1510
+ // is inside .credentials.json, which the relay does not read for credential
1511
+ // values. So it is stated as a limit rather than guessed at: a login that
1512
+ // lapses before the wake stops the resumed run at a prompt nobody answers.
1513
+ if (caps.claude) {
1514
+ const auth = spawnSync(caps.claude, ['auth', 'status', '--json'], {
1515
+ encoding: 'utf8',
1516
+ windowsHide: true,
1517
+ timeout: 20000,
1518
+ shell: process.platform === 'win32' && /\.(cmd|bat)$/i.test(caps.claude),
1519
+ });
1520
+ let parsed = null;
1521
+ try {
1522
+ parsed = JSON.parse(String(auth.stdout || '').trim());
1523
+ } catch (err) {
1524
+ parsed = null;
1525
+ }
1526
+ if (parsed && typeof parsed.loggedIn === 'boolean') {
1527
+ add('login', parsed.loggedIn, parsed.loggedIn
1528
+ ? 'logged in' + (parsed.subscriptionType ? ' (' + parsed.subscriptionType + ')' : '') +
1529
+ '. Its expiry is not checked: claude auth status does not report one and the relay does not read .credentials.json, so run /login before a relay that fires hours from now'
1530
+ : 'not logged in - the resumed run would stop at the login prompt. Run: claude auth login');
1531
+ } else {
1532
+ add('login', true, 'claude auth status gave no answer; expiry is not checked either way, so run /login before a long relay', 'warning');
1533
+ }
1534
+ }
1535
+
975
1536
  const net = await require('./net.js').reachable({ timeoutMs: 8000 });
976
1537
  add('network to the API', net.online, net.detail,
977
1538
  net.online ? 'ok' : net.reason === 'intercepted' ? 'error' : 'warning');
@@ -979,14 +1540,37 @@ async function doctor(now) {
979
1540
  // Can a task actually be registered by this account? Registering and removing
980
1541
  // a throwaway is the only honest answer; asking the policy is not.
981
1542
  if (process.platform === 'win32') {
1543
+ for (const rec of records(state)) {
1544
+ const pre = preflightPrompts(rec.launchCwd || rec.cwd, config, { dry: true });
1545
+ const quiet = pre.ok && !pre.changes.length;
1546
+ add('start-up questions for ' + String(rec.id).slice(0, 8), quiet,
1547
+ pre.ok
1548
+ ? (quiet ? 'folder trust and bypass permissions already answered in ' + pre.file : 'the resumed window would stop at: ' + pre.changes.join(', ') + ' - run relay preflight, or re-arm')
1549
+ : pre.error,
1550
+ quiet ? 'ok' : 'warn');
1551
+ }
1552
+ if (!records(state).length && config.mode === 'resume') {
1553
+ const pre = preflightPrompts(launchDirFor(process.cwd()), config, { dry: true });
1554
+ const quiet = pre.ok && !pre.changes.length;
1555
+ add('start-up questions here', quiet,
1556
+ pre.ok
1557
+ ? (quiet ? 'folder trust and bypass permissions already answered for ' + process.cwd() : 'a resume in ' + process.cwd() + ' would stop at: ' + pre.changes.join(', ') + ' - arming writes the answers')
1558
+ : pre.error,
1559
+ quiet ? 'ok' : 'warn');
1560
+ }
982
1561
  const probeName = 'usage-limits-doctor-probe';
983
- const registered = scheduleWindows(at + 6 * 60 * MINUTE, [path.join(__dirname, 'wake.js'), '--id', 'doctor-probe'], probeName, os.homedir(), at + 20000);
1562
+ // Ninety seconds, not twenty: the PowerShell route takes over thirty on
1563
+ // this machine, and a probe cut off before it can finish reports the
1564
+ // fallback, which is a different answer to the question being asked.
1565
+ const registered = scheduleWindows(at + 6 * 60 * MINUTE, [path.join(__dirname, 'wake.js'), '--id', 'doctor-probe'], probeName, os.homedir(), at + 90000);
984
1566
  add('scheduled tasks', registered.ok, registered.ok
985
1567
  ? 'registered a test task via ' + registered.how + (registered.how === 'schtasks'
986
- ? ' - the PowerShell path failed, so a sleeping machine will miss its wake'
1568
+ ? ' - the PowerShell path failed (' + registered.primaryError + '), so a sleeping machine will miss its wake'
987
1569
  : ' - StartWhenAvailable and WakeToRun are set, so a machine that was off or asleep still runs it')
988
1570
  : registered.error, registered.ok && registered.how === 'schtasks' ? 'warning' : undefined);
989
1571
  if (registered.ok) cancelSchedule(probeName);
1572
+ // The probe's launcher has no record, so the sweep takes it.
1573
+ sweepWakeLaunchers(state);
990
1574
 
991
1575
  // A wake at 3am is no use if the machine hibernates at midnight and the
992
1576
  // task is not allowed to wake it. This reads the actual power policy.
@@ -1103,9 +1687,16 @@ function main(argv) {
1103
1687
  }
1104
1688
  if (command === 'note') {
1105
1689
  const state = read();
1106
- if (!state.armed) return 'Nothing is armed, so there is nowhere to put a continuation yet.';
1690
+ const all = records(state);
1691
+ if (!all.length) return 'Nothing is armed, so there is nowhere to put a continuation yet.';
1692
+ // This session's own relay; with several armed and none this session's, the id has to be named.
1693
+ const sessionAt = rest.indexOf('--session');
1694
+ const named = sessionAt !== -1 ? rest[sessionAt + 1] : null;
1695
+ const me = named || process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_SESSION_ID || null;
1696
+ const target = armedFor(state, me) || (all.length === 1 ? all[0] : null);
1697
+ if (!target) return all.length + ' relays are armed (' + all.map((r) => r.id.slice(0, 8)).join(', ') + ') and none is this session\'s; pass --session <id>.';
1107
1698
  const fromFile = rest.indexOf('--file') !== -1 ? rest[rest.indexOf('--file') + 1] : null;
1108
- let text = fromFile ? '' : rest.filter((item) => item !== '--file').join(' ');
1699
+ let text = fromFile ? '' : rest.filter((item, i) => item !== '--file' && item !== '--session' && !(sessionAt !== -1 && i === sessionAt + 1)).join(' ');
1109
1700
  if (fromFile) {
1110
1701
  try {
1111
1702
  text = fs.readFileSync(fromFile, 'utf8');
@@ -1113,12 +1704,17 @@ function main(argv) {
1113
1704
  return 'Could not read ' + fromFile;
1114
1705
  }
1115
1706
  }
1116
- const written = saveContinuation(state.armed.id, text);
1117
- return written ? 'Continuation saved for the relay (' + written + ').' : 'Nothing to save.';
1707
+ const written = saveContinuation(target.id, text, { force: Boolean(named) });
1708
+ return written ? 'Continuation saved for the relay of ' + target.id.slice(0, 8) + ' (' + written + ').' : 'Nothing to save.';
1118
1709
  }
1119
1710
  if (command === 'cancel') {
1120
- const result = disarm('cancelled by hand', Date.now());
1121
- return result.changed ? 'Relay cancelled and the scheduled wake removed.' : 'Nothing was armed.';
1711
+ // This session's own relay by default; --all takes every session's down.
1712
+ const me = process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_SESSION_ID || null;
1713
+ const every = rest.includes('--all') || !me || !armedFor(read(), me);
1714
+ const result = disarm('cancelled by hand', Date.now(), every ? undefined : me);
1715
+ if (!result.changed) return 'Nothing was armed.';
1716
+ const n = result.records ? result.records.length : 1;
1717
+ return n > 1 ? n + ' relays cancelled and their scheduled wakes removed.' : 'Relay cancelled and the scheduled wake removed.';
1122
1718
  }
1123
1719
  // Arming by hand, for the cases the hook cannot see: Codex writes no plan
1124
1720
  // tool into its rollouts, so nothing there ever reads as work to carry; and
@@ -1138,11 +1734,28 @@ function main(argv) {
1138
1734
  const config = configure({ backstopAt: Number(value) });
1139
1735
  return 'Past ' + config.backstopAt + ' per cent it stops waiting for a completion and arms immediately.';
1140
1736
  }
1737
+ if (command === 'voice') {
1738
+ const on = !['off', 'false', 'no', '0'].includes(String(value || 'on').toLowerCase());
1739
+ configure({ voice: on });
1740
+ return on
1741
+ ? 'The hand-off will carry how you write, so the resumed session answers in your voice without being reminded.'
1742
+ : 'The hand-off will not carry how you write.';
1743
+ }
1744
+ if (command === 'bugcheck') {
1745
+ // pick() is local to settings(); the command checks the value itself.
1746
+ const wanted = String(value || 'on').toLowerCase();
1747
+ const choice = ['on', 'always', 'off'].includes(wanted) ? wanted : null;
1748
+ if (!choice) return 'Bug check: on (the hand-off asks for two passes), always (every prompt does), or off.';
1749
+ configure({ bugcheck: choice });
1750
+ if (choice === 'off') return 'Neither the hand-off nor the prompt hook will ask for the two bug passes.';
1751
+ if (choice === 'always') return 'Every prompt, and the hand-off, will ask for two bug passes before anything is called done.';
1752
+ return 'The hand-off will ask for two bug passes before anything is called done.';
1753
+ }
1141
1754
  if (command === 'show') {
1142
1755
  const on = !['off', 'false', 'no', '0'].includes(String(value || 'on').toLowerCase());
1143
1756
  configure({ show: on });
1144
1757
  return on
1145
- ? 'The resumed run will open in a window you can see, and its full output is kept either way (relay log --run).'
1758
+ ? 'The resumed run will be a session in a window you can see, with Remote Control on; a headless run keeps its output in relay log --run.'
1146
1759
  : 'The resumed run will be invisible. Its output is still kept: relay log --run.';
1147
1760
  }
1148
1761
  if (command === 'onfailure' || command === 'on-failure') {
@@ -1164,6 +1777,18 @@ function main(argv) {
1164
1777
  return 'A machine that cannot reach the API will retry ' + config.offlineAttempts + ' times, ' +
1165
1778
  config.offlineRetryMinutes + ' minutes apart at first and backing off from there. Being offline never counts as a failed run.';
1166
1779
  }
1780
+ if (command === 'preflight') {
1781
+
1782
+ const target = process.argv.slice(3).find((a) => !String(a).startsWith('--')) || process.cwd();
1783
+
1784
+ const r = preflightPrompts(target, settings(read()));
1785
+
1786
+ if (!r.ok) return 'Could not pre-answer: ' + r.error;
1787
+
1788
+ return r.changes.length ? 'Pre-answered: ' + r.changes.join('; ') + '.' + (r.backup ? ' Backup: ' + r.backup : '') : 'Nothing to pre-answer for ' + target + ': folder trust and bypass permissions are already in ' + r.file + '.';
1789
+
1790
+ }
1791
+
1167
1792
  if (command === 'doctor' || command === 'check') return doctor(Date.now()).then((r) => r.text);
1168
1793
  if (command === 'log') {
1169
1794
  if (rest.includes('--run')) {
@@ -1180,7 +1805,7 @@ function main(argv) {
1180
1805
  return [
1181
1806
  'usage: relay.js [status|on|off|at N|grace N|mode notify|resume|permission MODE|model NAME|',
1182
1807
  ' thinking off|resume|always|armon threshold|completion|backstop N|',
1183
- ' show on|off|onfailure rearm|stop|offline N|doctor|',
1808
+ ' show on|off|onfailure rearm|stop|offline N|doctor|preflight [cwd]|',
1184
1809
  ' arm [--session ID] [TEXT]|note TEXT|cancel|log [--run]]',
1185
1810
  '',
1186
1811
  status(Date.now()),
@@ -1202,7 +1827,9 @@ if (require.main === module) {
1202
1827
  );
1203
1828
  }
1204
1829
 
1205
- module.exports = {
1830
+ module.exports = { workWithContinuation, reapLost, hiddenAction, BUGCHECK_LINE,
1831
+ taskAction, wakeLauncherFile, wakeLauncherScript, writeWakeLauncher, sweepWakeLaunchers, batchArg, describeSpawn, PS_ROUTE_MS, SCHTASKS_MS,
1832
+ records, armedFor, putRecord, dropRecord, preflightPrompts, claudeJsonFile, projectKeys, isHome, launchDirFor, applySetting, settingIs,
1206
1833
  DEFAULTS,
1207
1834
  MINUTE,
1208
1835
  main,
@@ -1234,6 +1861,7 @@ module.exports = {
1234
1861
  remainingMs,
1235
1862
  armByHand,
1236
1863
  schedule,
1864
+ verifyRegistration,
1237
1865
  scheduleWindows,
1238
1866
  schedulePosix,
1239
1867
  cancelSchedule,