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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +107 -2
- package/commands/relay.md +10 -5
- package/commands/usage-mode.md +3 -0
- package/package.json +4 -3
- package/skills/usage-limits/SKILL.md +39 -2
- package/skills/usage-limits/references/how-it-works.md +3 -2
- package/skills/usage-limits/scripts/agy-hook.js +11 -14
- package/skills/usage-limits/scripts/brief.js +281 -36
- package/skills/usage-limits/scripts/ceiling.js +73 -13
- package/skills/usage-limits/scripts/defer.js +10 -8
- package/skills/usage-limits/scripts/feed.js +36 -8
- package/skills/usage-limits/scripts/install-antigravity.js +2 -1
- package/skills/usage-limits/scripts/lowpower.js +1 -1
- package/skills/usage-limits/scripts/mode.js +118 -8
- package/skills/usage-limits/scripts/pulse.js +82 -29
- package/skills/usage-limits/scripts/relay.js +693 -65
- package/skills/usage-limits/scripts/stop.js +2 -2
- package/skills/usage-limits/scripts/tally.js +10 -1
- package/skills/usage-limits/scripts/usage.js +113 -14
- package/skills/usage-limits/scripts/wake.js +356 -31
|
@@ -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
|
-
|
|
341
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
525
|
-
|
|
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
|
|
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(
|
|
586
|
-
' -Argument ' + psQuote(argument) + ' -WorkingDirectory ' + psQuote(cwd
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
992
|
+
timeout: primaryMs,
|
|
606
993
|
});
|
|
607
|
-
|
|
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,
|
|
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',
|
|
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:
|
|
1018
|
+
{ encoding: 'utf8', windowsHide: true, timeout: fallbackMs }
|
|
619
1019
|
);
|
|
620
|
-
if (fallback.status === 0)
|
|
621
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
778
|
-
|
|
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
|
-
|
|
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
|
-
|
|
805
|
-
if (
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
-
|
|
816
|
-
|
|
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
|
-
|
|
1369
|
+
const all = records(state);
|
|
1370
|
+
for (const armed of all) {
|
|
843
1371
|
lines.push(
|
|
844
|
-
'Armed: session ' +
|
|
845
|
-
formatWait(
|
|
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 (
|
|
848
|
-
lines.push('
|
|
849
|
-
|
|
850
|
-
lines.push('
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1121
|
-
|
|
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
|
|
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,
|