claude-usage-limits 1.26.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');
@@ -593,6 +778,84 @@ function remainingMs(deadline, ceiling) {
593
778
  return Math.max(0, Math.min(ceiling, left));
594
779
  }
595
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
+
596
859
  // Does Windows agree that this task will run?
597
860
  //
598
861
  // `state` and `next` come straight from Get-ScheduledTask and
@@ -611,6 +874,17 @@ function verifyRegistration(state, next, wanted, now) {
611
874
  if (!text) {
612
875
  return { ok: false, error: 'the scheduled task registered but Windows reports no next run time, so it will not fire' };
613
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
+ }
614
888
  const at = Date.parse(text);
615
889
  if (!Number.isFinite(at)) {
616
890
  return { ok: false, error: 'could not read the next run time Windows reported: ' + text };
@@ -630,17 +904,51 @@ function verifyRegistration(state, next, wanted, now) {
630
904
  return { ok: true, nextRun: at };
631
905
  }
632
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
+
633
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
+ }
634
935
  let lastVerifyError = null;
635
936
  const date = new Date(when);
636
937
  const stamp =
637
938
  date.getFullYear() + '-' + two(date.getMonth() + 1) + '-' + two(date.getDate()) + ' ' +
638
939
  two(date.getHours()) + ':' + two(date.getMinutes()) + ':' + two(date.getSeconds());
639
- 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);
640
948
  const script = [
641
949
  '$ErrorActionPreference = "Stop"',
642
- '$action = New-ScheduledTaskAction -Execute ' + psQuote(process.execPath) +
643
- ' -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),
644
952
  '$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date ' + psQuote(stamp) + ')',
645
953
  // A one-shot task does not remove itself. Without an expiry, every relay
646
954
  // leaves a dead entry in Task Scheduler forever; the wake also unregisters
@@ -677,10 +985,11 @@ function scheduleWindows(when, argv, name, cwd, deadline) {
677
985
  const file = path.join(os.tmpdir(), name + '.ps1');
678
986
  try {
679
987
  fs.writeFileSync(file, script, 'utf8');
988
+ const primaryMs = Math.max(500, remainingMs(deadline, PS_ROUTE_MS));
680
989
  const run = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', file], {
681
990
  encoding: 'utf8',
682
991
  windowsHide: true,
683
- timeout: Math.max(500, remainingMs(deadline, 8000)),
992
+ timeout: primaryMs,
684
993
  });
685
994
  const registered = (run.stdout || '').match(/registered\|([^|\r\n]*)\|([^\r\n]*)/);
686
995
  if (run.status === 0 && registered) {
@@ -690,20 +999,38 @@ function scheduleWindows(when, argv, name, cwd, deadline) {
690
999
  // reporting a wake that is not going to happen.
691
1000
  lastVerifyError = verdict.error;
692
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);
693
1005
  // No time left for a second attempt is a plain refusal, not a hung hook.
694
- 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
+ }
695
1009
  // schtasks cannot express StartWhenAvailable, so this path is a worse
696
1010
  // guarantee and says so rather than pretending the two are the same.
1011
+ const fallbackMs = Math.max(500, remainingMs(deadline, SCHTASKS_MS));
697
1012
  const fallback = spawnSync(
698
1013
  'schtasks.exe',
699
- ['/Create', '/TN', name, '/TR', '"' + process.execPath + '" ' + argument, '/SC', 'ONCE',
1014
+ ['/Create', '/TN', name, '/TR', taskAction(action), '/SC', 'ONCE',
700
1015
  '/ST', two(date.getHours()) + ':' + two(date.getMinutes()),
701
1016
  '/SD', two(date.getMonth() + 1) + '/' + two(date.getDate()) + '/' + date.getFullYear(),
702
1017
  '/IT', '/Z', '/F'],
703
- { encoding: 'utf8', windowsHide: true, timeout: Math.max(500, remainingMs(deadline, 8000)) }
1018
+ { encoding: 'utf8', windowsHide: true, timeout: fallbackMs }
704
1019
  );
705
- if (fallback.status === 0) return { ok: true, how: 'schtasks', warning: 'a sleeping machine will miss this wake' };
706
- return { ok: false, error: (lastVerifyError || 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 };
707
1034
  } catch (err) {
708
1035
  return { ok: false, error: err.message };
709
1036
  } finally {
@@ -749,6 +1076,13 @@ function schedulePosix(when, argv, name, cwd) {
749
1076
 
750
1077
  function schedule(when, argv, name, cwd, deadline) {
751
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
+ }
752
1086
  return schedulePosix(when, argv, name, cwd);
753
1087
  }
754
1088
 
@@ -769,6 +1103,59 @@ function cancelSchedule(name) {
769
1103
 
770
1104
  // Everything that has to be true before a wake is scheduled, in the order that
771
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
+
772
1159
  function armable(input) {
773
1160
  const options = input || {};
774
1161
  const config = options.config || settings();
@@ -777,9 +1164,16 @@ function armable(input) {
777
1164
  if (!binding || binding.percentUsed === null || binding.percentUsed === undefined) return { ok: false, why: 'no usable window reading' };
778
1165
  if (binding.stale) return { ok: false, why: 'the reading is stale' };
779
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 };
780
1173
  if (!Number.isFinite(binding.resetsAt)) return { ok: false, why: 'the window has no known reset time' };
781
1174
  if (!options.sessionId) return { ok: false, why: 'no session id' };
782
- 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' };
783
1177
  // The boundary rule. Past the threshold but not yet at the backstop, arming
784
1178
  // waits for the reply to finish - the Stop hook passes atCompletion and this
785
1179
  // is the only caller that does. Mid-reply callers get told to wait, and the
@@ -798,28 +1192,46 @@ function arm(input) {
798
1192
  const options = input || {};
799
1193
  const now = Number.isFinite(options.now) ? options.now : Date.now();
800
1194
  const config = options.config || settings();
1195
+ reapLost(now);
1196
+ options.work = workWithContinuation(options.work, options.sessionId);
801
1197
  // `at` is an exact wake time, for work deferred to a moment somebody named
802
1198
  // rather than to a window reset. The grace minutes exist so the meter has
803
1199
  // really turned over before a resume fires; a time a person typed does not
804
1200
  // want fifteen minutes added to it. Still floored a minute out, because a
805
1201
  // task registered for a moment already past fires immediately.
806
- const when = Number.isFinite(options.at)
1202
+ let when = Number.isFinite(options.at)
807
1203
  ? Math.max(options.at, now + MINUTE)
808
1204
  : wakeAt(options.resetsAt, config.graceMinutes, now);
809
1205
  if (!when) return { ok: false, error: 'no reset time to wake after' };
810
1206
 
811
1207
  const state = read();
812
1208
  const id = options.sessionId;
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());
813
1217
  const name = taskName(id, when);
814
1218
  // Names are unique per wake now, so re-arming no longer replaces the old
815
- // registration by name. Retire it explicitly, or every re-arm leaves a live
816
- // task behind that would fire on its own.
817
- if (state.armed && state.armed.task && state.armed.task !== name) {
818
- try {
819
- cancelSchedule(state.armed.task);
820
- } catch (err) {
821
- // The expiry removes it eventually either way.
822
- }
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);
823
1235
  }
824
1236
  // Re-arming the same session for the same reset would register the task
825
1237
  // twice; -Force replaces it, and the record is rewritten either way.
@@ -831,8 +1243,21 @@ function arm(input) {
831
1243
  // testing meant it read the real one and found nothing armed.
832
1244
  if (process.env.CLAUDE_CONFIG_DIR) argv.push('--config-dir', process.env.CLAUDE_CONFIG_DIR);
833
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
+ }
834
1254
  if (!scheduled.ok) {
835
- 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);
836
1261
  return { ok: false, error: scheduled.error };
837
1262
  }
838
1263
 
@@ -841,6 +1266,7 @@ function arm(input) {
841
1266
  task: scheduled.how === 'none' ? null : name,
842
1267
  host: options.hostName || host.CLAUDE,
843
1268
  cwd: options.cwd || process.cwd(),
1269
+ launchCwd: launchCwd !== (options.cwd || process.cwd()) ? launchCwd : null,
844
1270
  project: options.project || null,
845
1271
  armedAt: now,
846
1272
  wakeAt: when,
@@ -852,6 +1278,7 @@ function arm(input) {
852
1278
  mode: config.mode,
853
1279
  how: scheduled.how,
854
1280
  warning: scheduled.warning || null,
1281
+ preflight: preflight ? preflight.changes : null,
855
1282
  attempt: 0,
856
1283
  continuation: false,
857
1284
  // The outstanding items travel with the record, not just their count. They
@@ -869,15 +1296,16 @@ function arm(input) {
869
1296
  // Displacing somebody else's live relay is legitimate - one machine, one
870
1297
  // relay - but it must not be silent. A wake still in the future belonged to
871
1298
  // work that somebody expected to be picked up.
872
- if (state.armed && state.armed.id !== id) {
873
- if (Number.isFinite(state.armed.wakeAt) && state.armed.wakeAt > now) {
874
- note('displacing the relay armed for ' + state.armed.id + ' (was due ' + new Date(state.armed.wakeAt).toISOString() + ')', now);
875
- }
876
- if (state.armed.task) cancelSchedule(state.armed.task);
877
- }
878
- state.armed = record;
1299
+ const beside = records(state).filter((r) => r.id !== id).map((r) => r.id.slice(0, 8));
1300
+ putRecord(state, record);
879
1301
  write(state);
880
- 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
+ );
881
1309
  return { ok: true, record };
882
1310
  }
883
1311
 
@@ -896,24 +1324,27 @@ function arm(input) {
896
1324
  // tidying up should say so.
897
1325
  function disarm(reason, now, id) {
898
1326
  const state = read();
899
- if (!state.armed) return { ok: true, changed: false };
900
- if (id && state.armed.id !== id) {
901
- note('refused to disarm ' + state.armed.id + ' on behalf of ' + id, Number.isFinite(now) ? now : Date.now());
902
- return { ok: true, changed: false, refused: true, armed: state.armed.id };
903
- }
904
- const record = state.armed;
905
- if (record.task) cancelSchedule(record.task);
906
- 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
+ }
907
1344
  state.history = state.history.slice(-10);
908
- state.armed = null;
909
1345
  write(state);
910
- try {
911
- fs.unlinkSync(planFile(record.id));
912
- } catch (err) {
913
- // The continuation file may never have been written.
914
- }
915
- note('disarmed ' + record.id + ': ' + (reason || 'cancelled'), now);
916
- return { ok: true, changed: true, record };
1346
+ sweepWakeLaunchers(state);
1347
+ return { ok: true, changed: true, record: targets[0], records: targets };
917
1348
  }
918
1349
 
919
1350
  function formatWait(ms) {
@@ -926,6 +1357,7 @@ function formatWait(ms) {
926
1357
 
927
1358
  function status(now) {
928
1359
  const at = Number.isFinite(now) ? now : Date.now();
1360
+ reapLost(at);
929
1361
  const state = read();
930
1362
  const config = settings(state);
931
1363
  const able = capabilities();
@@ -934,16 +1366,19 @@ function status(now) {
934
1366
  'Relay is ' + (config.enabled ? 'ON' : 'OFF') + ', arming at ' + config.at + ' per cent, waking ' +
935
1367
  config.graceMinutes + ' min after the reset, delivery ' + config.mode + '.'
936
1368
  );
937
- if (state.armed) {
1369
+ const all = records(state);
1370
+ for (const armed of all) {
938
1371
  lines.push(
939
- 'Armed: session ' + state.armed.id.slice(0, 8) + ' in ' + state.armed.cwd + ', wake in ' +
940
- 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 + '.'
941
1374
  );
942
- if (state.armed.warning) lines.push(' Caveat: ' + state.armed.warning + '.');
943
- lines.push(' Continuation written: ' + (state.armed.continuation ? 'yes' : 'not yet'));
944
- } else {
945
- 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'));
946
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.');
947
1382
  lines.push(
948
1383
  'Available here: ' + [
949
1384
  able.claude ? 'claude CLI' : null,
@@ -1026,6 +1461,7 @@ async function armByHand(rest) {
1026
1461
  return (
1027
1462
  'Armed by hand for session ' + sessionId.slice(0, 8) + ': wake at ' +
1028
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(', ') + '.' : '') +
1029
1465
  (text ? ' Continuation saved.' : ' No continuation yet - add one with: relay note "<text>"')
1030
1466
  );
1031
1467
  }
@@ -1067,6 +1503,36 @@ async function doctor(now) {
1067
1503
  const cli = caps.claude || caps.codex;
1068
1504
  add('a CLI to resume with', Boolean(cli), cli || 'neither the claude nor the codex CLI could be found on PATH');
1069
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
+
1070
1536
  const net = await require('./net.js').reachable({ timeoutMs: 8000 });
1071
1537
  add('network to the API', net.online, net.detail,
1072
1538
  net.online ? 'ok' : net.reason === 'intercepted' ? 'error' : 'warning');
@@ -1074,14 +1540,37 @@ async function doctor(now) {
1074
1540
  // Can a task actually be registered by this account? Registering and removing
1075
1541
  // a throwaway is the only honest answer; asking the policy is not.
1076
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
+ }
1077
1561
  const probeName = 'usage-limits-doctor-probe';
1078
- 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);
1079
1566
  add('scheduled tasks', registered.ok, registered.ok
1080
1567
  ? 'registered a test task via ' + registered.how + (registered.how === 'schtasks'
1081
- ? ' - 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'
1082
1569
  : ' - StartWhenAvailable and WakeToRun are set, so a machine that was off or asleep still runs it')
1083
1570
  : registered.error, registered.ok && registered.how === 'schtasks' ? 'warning' : undefined);
1084
1571
  if (registered.ok) cancelSchedule(probeName);
1572
+ // The probe's launcher has no record, so the sweep takes it.
1573
+ sweepWakeLaunchers(state);
1085
1574
 
1086
1575
  // A wake at 3am is no use if the machine hibernates at midnight and the
1087
1576
  // task is not allowed to wake it. This reads the actual power policy.
@@ -1198,9 +1687,16 @@ function main(argv) {
1198
1687
  }
1199
1688
  if (command === 'note') {
1200
1689
  const state = read();
1201
- 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>.';
1202
1698
  const fromFile = rest.indexOf('--file') !== -1 ? rest[rest.indexOf('--file') + 1] : null;
1203
- 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(' ');
1204
1700
  if (fromFile) {
1205
1701
  try {
1206
1702
  text = fs.readFileSync(fromFile, 'utf8');
@@ -1208,12 +1704,17 @@ function main(argv) {
1208
1704
  return 'Could not read ' + fromFile;
1209
1705
  }
1210
1706
  }
1211
- const written = saveContinuation(state.armed.id, text);
1212
- 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.';
1213
1709
  }
1214
1710
  if (command === 'cancel') {
1215
- const result = disarm('cancelled by hand', Date.now());
1216
- 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.';
1217
1718
  }
1218
1719
  // Arming by hand, for the cases the hook cannot see: Codex writes no plan
1219
1720
  // tool into its rollouts, so nothing there ever reads as work to carry; and
@@ -1233,11 +1734,28 @@ function main(argv) {
1233
1734
  const config = configure({ backstopAt: Number(value) });
1234
1735
  return 'Past ' + config.backstopAt + ' per cent it stops waiting for a completion and arms immediately.';
1235
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
+ }
1236
1754
  if (command === 'show') {
1237
1755
  const on = !['off', 'false', 'no', '0'].includes(String(value || 'on').toLowerCase());
1238
1756
  configure({ show: on });
1239
1757
  return on
1240
- ? '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.'
1241
1759
  : 'The resumed run will be invisible. Its output is still kept: relay log --run.';
1242
1760
  }
1243
1761
  if (command === 'onfailure' || command === 'on-failure') {
@@ -1259,6 +1777,18 @@ function main(argv) {
1259
1777
  return 'A machine that cannot reach the API will retry ' + config.offlineAttempts + ' times, ' +
1260
1778
  config.offlineRetryMinutes + ' minutes apart at first and backing off from there. Being offline never counts as a failed run.';
1261
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
+
1262
1792
  if (command === 'doctor' || command === 'check') return doctor(Date.now()).then((r) => r.text);
1263
1793
  if (command === 'log') {
1264
1794
  if (rest.includes('--run')) {
@@ -1275,7 +1805,7 @@ function main(argv) {
1275
1805
  return [
1276
1806
  'usage: relay.js [status|on|off|at N|grace N|mode notify|resume|permission MODE|model NAME|',
1277
1807
  ' thinking off|resume|always|armon threshold|completion|backstop N|',
1278
- ' show on|off|onfailure rearm|stop|offline N|doctor|',
1808
+ ' show on|off|onfailure rearm|stop|offline N|doctor|preflight [cwd]|',
1279
1809
  ' arm [--session ID] [TEXT]|note TEXT|cancel|log [--run]]',
1280
1810
  '',
1281
1811
  status(Date.now()),
@@ -1297,7 +1827,9 @@ if (require.main === module) {
1297
1827
  );
1298
1828
  }
1299
1829
 
1300
- 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,
1301
1833
  DEFAULTS,
1302
1834
  MINUTE,
1303
1835
  main,