claude-usage-limits 1.26.0 → 1.39.2
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 +47 -9
- package/skills/usage-limits/scripts/defer.js +9 -7
- package/skills/usage-limits/scripts/feed.js +36 -8
- package/skills/usage-limits/scripts/install-antigravity.js +2 -1
- package/skills/usage-limits/scripts/mode.js +98 -3
- package/skills/usage-limits/scripts/pulse.js +80 -29
- package/skills/usage-limits/scripts/relay.js +605 -70
- 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');
|
|
@@ -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
|
|
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(
|
|
643
|
-
' -Argument ' + psQuote(argument) + ' -WorkingDirectory ' + psQuote(cwd
|
|
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:
|
|
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,
|
|
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',
|
|
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:
|
|
1018
|
+
{ encoding: 'utf8', windowsHide: true, timeout: fallbackMs }
|
|
704
1019
|
);
|
|
705
|
-
if (fallback.status === 0)
|
|
706
|
-
|
|
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 {
|
|
@@ -715,7 +1042,7 @@ function scheduleWindows(when, argv, name, cwd, deadline) {
|
|
|
715
1042
|
}
|
|
716
1043
|
}
|
|
717
1044
|
|
|
718
|
-
function schedulePosix(when, argv, name, cwd) {
|
|
1045
|
+
function schedulePosix(when, argv, name, cwd, deadline) {
|
|
719
1046
|
const seconds = Math.max(60, Math.round((when - Date.now()) / 1000));
|
|
720
1047
|
// `at` is the right tool and is absent on most desktops now. A detached
|
|
721
1048
|
// sleeper is second best: it survives the terminal closing, but not a
|
|
@@ -724,9 +1051,12 @@ function schedulePosix(when, argv, name, cwd) {
|
|
|
724
1051
|
const at = spawnSync('sh', ['-c', 'command -v at >/dev/null 2>&1 && echo yes || echo no'], { encoding: 'utf8' });
|
|
725
1052
|
if ((at.stdout || '').trim() === 'yes') {
|
|
726
1053
|
const minutes = Math.max(1, Math.round(seconds / 60));
|
|
1054
|
+
// Bounded by what the caller has left, like the Windows route: a hook is
|
|
1055
|
+
// killed at ten seconds and this used to wait twenty. An `at` that runs
|
|
1056
|
+
// out of time falls through to the sleeper below, which returns at once.
|
|
727
1057
|
const run = spawnSync('sh', ['-c', 'echo ' + JSON.stringify(command) + ' | at now + ' + minutes + ' minutes'], {
|
|
728
1058
|
encoding: 'utf8',
|
|
729
|
-
timeout: 20000,
|
|
1059
|
+
timeout: Math.max(1000, remainingMs(deadline, 20000)),
|
|
730
1060
|
});
|
|
731
1061
|
// macOS ships `at` but launchd leaves atrun DISABLED by default, so the
|
|
732
1062
|
// command succeeds, prints a job id, and the wake never fires. Reporting
|
|
@@ -749,7 +1079,14 @@ function schedulePosix(when, argv, name, cwd) {
|
|
|
749
1079
|
|
|
750
1080
|
function schedule(when, argv, name, cwd, deadline) {
|
|
751
1081
|
if (process.platform === 'win32') return scheduleWindows(when, argv, name, cwd, deadline);
|
|
752
|
-
|
|
1082
|
+
// The same refusal the Windows route makes. The `at` spawn below may wait
|
|
1083
|
+
// twenty seconds, inside a hook that is killed at ten, and a hook with no
|
|
1084
|
+
// time left must say so rather than start a registration it cannot finish -
|
|
1085
|
+
// the deadline was passed here and ignored on every platform but one.
|
|
1086
|
+
if (Number.isFinite(deadline) && deadline - Date.now() < 1500) {
|
|
1087
|
+
return { ok: false, error: 'no time left in this hook to register the wake; it will arm on the next prompt' };
|
|
1088
|
+
}
|
|
1089
|
+
return schedulePosix(when, argv, name, cwd, deadline);
|
|
753
1090
|
}
|
|
754
1091
|
|
|
755
1092
|
function cancelSchedule(name) {
|
|
@@ -769,6 +1106,59 @@ function cancelSchedule(name) {
|
|
|
769
1106
|
|
|
770
1107
|
// Everything that has to be true before a wake is scheduled, in the order that
|
|
771
1108
|
// makes the answer most useful to read.
|
|
1109
|
+
// A saved continuation is work by definition: the session wrote down what
|
|
1110
|
+
// comes next. On 2026-09-14 a session with an 8 KB note and no todo list hit
|
|
1111
|
+
// 100 per cent and was refused for having "no plan or unfinished todo list",
|
|
1112
|
+
// so nothing picked it up after the reset.
|
|
1113
|
+
function workWithContinuation(work, id) {
|
|
1114
|
+
const base = work || { hasWork: false, pending: 0, source: null, todos: [], plan: null };
|
|
1115
|
+
if (base.hasWork) return base;
|
|
1116
|
+
const saved = id ? readContinuation(id) : '';
|
|
1117
|
+
if (!saved) return base;
|
|
1118
|
+
return Object.assign({}, base, { hasWork: true, pending: Math.max(1, base.pending || 0), source: 'continuation' });
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// A wake that fired and never reported back. The 8:05 PM wake on 2026-09-14
|
|
1122
|
+
// was killed with its console (task result 0xC000013A), so finish() never
|
|
1123
|
+
// ran: the record stayed armed for a wake that was already over, its task
|
|
1124
|
+
// stayed registered, and status said "wake in now" for hours. A wake marks
|
|
1125
|
+
// wokeAt as its first act; a record past its wake with no mark for half an
|
|
1126
|
+
// hour never started, and one marked but silent for longer than a resume
|
|
1127
|
+
// can run has died. Either way it is history, and the slot is free.
|
|
1128
|
+
// Not 30 minutes: a task registered StartWhenAvailable runs when a sleeping
|
|
1129
|
+
// machine comes back, any time inside its 12-hour expiry, and reaping it
|
|
1130
|
+
// sooner would cancel the very catch-up run the registration promises.
|
|
1131
|
+
const LOST_UNSTARTED_MS = 12 * 60 * MINUTE + 30 * MINUTE;
|
|
1132
|
+
const LOST_RUNNING_MS = 3 * 60 * MINUTE + 10 * MINUTE;
|
|
1133
|
+
function reapLost(now) {
|
|
1134
|
+
const state = read();
|
|
1135
|
+
let reaped = false;
|
|
1136
|
+
for (const armed of records(state)) {
|
|
1137
|
+
if (!Number.isFinite(armed.wakeAt)) continue;
|
|
1138
|
+
const limit = Number.isFinite(armed.wokeAt) ? armed.wokeAt + LOST_RUNNING_MS : armed.wakeAt + LOST_UNSTARTED_MS;
|
|
1139
|
+
if (now < limit) continue;
|
|
1140
|
+
const detail = Number.isFinite(armed.wokeAt)
|
|
1141
|
+
? 'the wake started ' + new Date(armed.wokeAt).toISOString() + ' and never reported back'
|
|
1142
|
+
: 'the wake was due ' + new Date(armed.wakeAt).toISOString() + ' and never started, or died before it could say so';
|
|
1143
|
+
state.history.push(Object.assign({}, armed, { endedAt: now, outcome: 'lost', detail }));
|
|
1144
|
+
state.history = state.history.slice(-10);
|
|
1145
|
+
dropRecord(state, armed.id);
|
|
1146
|
+
note('lost ' + armed.id + ': ' + detail, now);
|
|
1147
|
+
if (armed.task) {
|
|
1148
|
+
try {
|
|
1149
|
+
cancelSchedule(armed.task);
|
|
1150
|
+
} catch (err) {
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
reaped = true;
|
|
1154
|
+
}
|
|
1155
|
+
if (reaped) {
|
|
1156
|
+
write(state);
|
|
1157
|
+
sweepWakeLaunchers(state);
|
|
1158
|
+
}
|
|
1159
|
+
return reaped;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
772
1162
|
function armable(input) {
|
|
773
1163
|
const options = input || {};
|
|
774
1164
|
const config = options.config || settings();
|
|
@@ -777,9 +1167,16 @@ function armable(input) {
|
|
|
777
1167
|
if (!binding || binding.percentUsed === null || binding.percentUsed === undefined) return { ok: false, why: 'no usable window reading' };
|
|
778
1168
|
if (binding.stale) return { ok: false, why: 'the reading is stale' };
|
|
779
1169
|
if (binding.percentUsed < config.at) return { ok: false, why: 'below ' + config.at + ' per cent' };
|
|
1170
|
+
// The account's own reading has to be past the mark, not the local
|
|
1171
|
+
// extrapolation on top of it: on 2026-09-20 the estimate ran 17 points hot
|
|
1172
|
+
// and a relay armed on it would have armed a whole window early.
|
|
1173
|
+
const beyond = Number.isFinite(binding.pointsBeyondSnapshot) ? binding.pointsBeyondSnapshot : 0;
|
|
1174
|
+
const raw = binding.percentUsed - Math.max(0, beyond);
|
|
1175
|
+
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
1176
|
if (!Number.isFinite(binding.resetsAt)) return { ok: false, why: 'the window has no known reset time' };
|
|
781
1177
|
if (!options.sessionId) return { ok: false, why: 'no session id' };
|
|
782
|
-
|
|
1178
|
+
const work = workWithContinuation(options.work, options.sessionId);
|
|
1179
|
+
if (!work.hasWork) return { ok: false, why: 'no plan or unfinished todo list to carry, and no saved continuation' };
|
|
783
1180
|
// The boundary rule. Past the threshold but not yet at the backstop, arming
|
|
784
1181
|
// waits for the reply to finish - the Stop hook passes atCompletion and this
|
|
785
1182
|
// is the only caller that does. Mid-reply callers get told to wait, and the
|
|
@@ -798,28 +1195,46 @@ function arm(input) {
|
|
|
798
1195
|
const options = input || {};
|
|
799
1196
|
const now = Number.isFinite(options.now) ? options.now : Date.now();
|
|
800
1197
|
const config = options.config || settings();
|
|
1198
|
+
reapLost(now);
|
|
1199
|
+
options.work = workWithContinuation(options.work, options.sessionId);
|
|
801
1200
|
// `at` is an exact wake time, for work deferred to a moment somebody named
|
|
802
1201
|
// rather than to a window reset. The grace minutes exist so the meter has
|
|
803
1202
|
// really turned over before a resume fires; a time a person typed does not
|
|
804
1203
|
// want fifteen minutes added to it. Still floored a minute out, because a
|
|
805
1204
|
// task registered for a moment already past fires immediately.
|
|
806
|
-
|
|
1205
|
+
let when = Number.isFinite(options.at)
|
|
807
1206
|
? Math.max(options.at, now + MINUTE)
|
|
808
1207
|
: wakeAt(options.resetsAt, config.graceMinutes, now);
|
|
809
1208
|
if (!when) return { ok: false, error: 'no reset time to wake after' };
|
|
810
1209
|
|
|
811
1210
|
const state = read();
|
|
812
1211
|
const id = options.sessionId;
|
|
1212
|
+
// Two windows starting in the same second race each other for .claude.json
|
|
1213
|
+
// (verified: it corrupts, and onboarding comes back). A minute apart is safe.
|
|
1214
|
+
for (let guard = 0; guard < 20; guard++) {
|
|
1215
|
+
const clash = records(state).find((r) => r.id !== id && Number.isFinite(r.wakeAt) && Math.abs(r.wakeAt - when) < MINUTE);
|
|
1216
|
+
if (!clash) break;
|
|
1217
|
+
when = clash.wakeAt + MINUTE;
|
|
1218
|
+
}
|
|
1219
|
+
const launchCwd = launchDirFor(options.cwd || process.cwd());
|
|
813
1220
|
const name = taskName(id, when);
|
|
814
1221
|
// Names are unique per wake now, so re-arming no longer replaces the old
|
|
815
|
-
// registration by name
|
|
816
|
-
//
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
1222
|
+
// registration by name; the previous one has to be retired by hand or it
|
|
1223
|
+
// stays live and fires on its own.
|
|
1224
|
+
//
|
|
1225
|
+
// It is retired AFTER the replacement is registered, never before. While
|
|
1226
|
+
// the names were identical, -Force made this one atomic operation. With
|
|
1227
|
+
// unique names, cancelling first opens a window in which the old wake is
|
|
1228
|
+
// gone and the new one does not exist yet - and if registration then
|
|
1229
|
+
// fails, that window never closes: the user is left with no relay at all,
|
|
1230
|
+
// unattended, having had a working one a moment earlier.
|
|
1231
|
+
const existing = armedFor(state, id);
|
|
1232
|
+
const previousTask = existing && existing.task && existing.task !== name ? existing.task : null;
|
|
1233
|
+
let preflight = null;
|
|
1234
|
+
if (config.mode === 'resume' && (!options.hostName || options.hostName === host.CLAUDE) && options.preflight !== false) {
|
|
1235
|
+
preflight = preflightPrompts(launchCwd, config);
|
|
1236
|
+
if (preflight.ok && preflight.changes.length) note('pre-answered for ' + id + ': ' + preflight.changes.join(', '), now);
|
|
1237
|
+
else if (!preflight.ok) note('could not pre-answer the start-up questions for ' + id + ': ' + preflight.error, now);
|
|
823
1238
|
}
|
|
824
1239
|
// Re-arming the same session for the same reset would register the task
|
|
825
1240
|
// twice; -Force replaces it, and the record is rewritten either way.
|
|
@@ -831,8 +1246,21 @@ function arm(input) {
|
|
|
831
1246
|
// testing meant it read the real one and found nothing armed.
|
|
832
1247
|
if (process.env.CLAUDE_CONFIG_DIR) argv.push('--config-dir', process.env.CLAUDE_CONFIG_DIR);
|
|
833
1248
|
const scheduled = options.schedule === false ? { ok: true, how: 'none' } : schedule(when, argv, name, options.cwd, options.deadline);
|
|
1249
|
+
if (scheduled.ok && previousTask) {
|
|
1250
|
+
// Safe now: the replacement exists.
|
|
1251
|
+
try {
|
|
1252
|
+
cancelSchedule(previousTask);
|
|
1253
|
+
} catch (err) {
|
|
1254
|
+
// The expiry removes it eventually either way.
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
834
1257
|
if (!scheduled.ok) {
|
|
835
|
-
|
|
1258
|
+
// The old wake is deliberately left alone here. A stale wake that still
|
|
1259
|
+
// fires is worth more than none, and it carries the same continuation.
|
|
1260
|
+
note('arm failed for ' + id + ': ' + scheduled.error + (previousTask ? '; the previous wake was left in place' : ''), now);
|
|
1261
|
+
// A launcher written for a session that has no record is a stray; one
|
|
1262
|
+
// that an earlier wake of this session still points at is kept with it.
|
|
1263
|
+
sweepWakeLaunchers(state);
|
|
836
1264
|
return { ok: false, error: scheduled.error };
|
|
837
1265
|
}
|
|
838
1266
|
|
|
@@ -841,6 +1269,7 @@ function arm(input) {
|
|
|
841
1269
|
task: scheduled.how === 'none' ? null : name,
|
|
842
1270
|
host: options.hostName || host.CLAUDE,
|
|
843
1271
|
cwd: options.cwd || process.cwd(),
|
|
1272
|
+
launchCwd: launchCwd !== (options.cwd || process.cwd()) ? launchCwd : null,
|
|
844
1273
|
project: options.project || null,
|
|
845
1274
|
armedAt: now,
|
|
846
1275
|
wakeAt: when,
|
|
@@ -852,6 +1281,7 @@ function arm(input) {
|
|
|
852
1281
|
mode: config.mode,
|
|
853
1282
|
how: scheduled.how,
|
|
854
1283
|
warning: scheduled.warning || null,
|
|
1284
|
+
preflight: preflight ? preflight.changes : null,
|
|
855
1285
|
attempt: 0,
|
|
856
1286
|
continuation: false,
|
|
857
1287
|
// The outstanding items travel with the record, not just their count. They
|
|
@@ -869,15 +1299,16 @@ function arm(input) {
|
|
|
869
1299
|
// Displacing somebody else's live relay is legitimate - one machine, one
|
|
870
1300
|
// relay - but it must not be silent. A wake still in the future belonged to
|
|
871
1301
|
// work that somebody expected to be picked up.
|
|
872
|
-
|
|
873
|
-
|
|
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;
|
|
1302
|
+
const beside = records(state).filter((r) => r.id !== id).map((r) => r.id.slice(0, 8));
|
|
1303
|
+
putRecord(state, record);
|
|
879
1304
|
write(state);
|
|
880
|
-
|
|
1305
|
+
sweepWakeLaunchers(state);
|
|
1306
|
+
note(
|
|
1307
|
+
'armed ' + id + ' for ' + new Date(when).toISOString() + ' via ' + scheduled.how +
|
|
1308
|
+
(scheduled.primaryError ? ' (' + scheduled.primaryError + ')' : '') +
|
|
1309
|
+
(beside.length ? ' beside ' + beside.join(', ') : ''),
|
|
1310
|
+
now
|
|
1311
|
+
);
|
|
881
1312
|
return { ok: true, record };
|
|
882
1313
|
}
|
|
883
1314
|
|
|
@@ -896,24 +1327,27 @@ function arm(input) {
|
|
|
896
1327
|
// tidying up should say so.
|
|
897
1328
|
function disarm(reason, now, id) {
|
|
898
1329
|
const state = read();
|
|
899
|
-
|
|
900
|
-
if (
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1330
|
+
const all = records(state);
|
|
1331
|
+
if (!all.length) return { ok: true, changed: false };
|
|
1332
|
+
const targets = id ? all.filter((r) => r.id === id) : all;
|
|
1333
|
+
if (!targets.length) {
|
|
1334
|
+
note('nothing armed for ' + id + '; ' + all.map((r) => r.id.slice(0, 8)).join(', ') + ' left as they were', Number.isFinite(now) ? now : Date.now());
|
|
1335
|
+
return { ok: true, changed: false, refused: true, armed: all[0].id };
|
|
1336
|
+
}
|
|
1337
|
+
for (const record of targets) {
|
|
1338
|
+
if (record.task) cancelSchedule(record.task);
|
|
1339
|
+
state.history.push(Object.assign({}, record, { endedAt: Number.isFinite(now) ? now : Date.now(), outcome: reason || 'cancelled' }));
|
|
1340
|
+
dropRecord(state, record.id);
|
|
1341
|
+
try {
|
|
1342
|
+
fs.unlinkSync(planFile(record.id));
|
|
1343
|
+
} catch (err) {
|
|
1344
|
+
}
|
|
1345
|
+
note('disarmed ' + record.id + ': ' + (reason || 'cancelled'), now);
|
|
1346
|
+
}
|
|
907
1347
|
state.history = state.history.slice(-10);
|
|
908
|
-
state.armed = null;
|
|
909
1348
|
write(state);
|
|
910
|
-
|
|
911
|
-
|
|
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 };
|
|
1349
|
+
sweepWakeLaunchers(state);
|
|
1350
|
+
return { ok: true, changed: true, record: targets[0], records: targets };
|
|
917
1351
|
}
|
|
918
1352
|
|
|
919
1353
|
function formatWait(ms) {
|
|
@@ -926,6 +1360,7 @@ function formatWait(ms) {
|
|
|
926
1360
|
|
|
927
1361
|
function status(now) {
|
|
928
1362
|
const at = Number.isFinite(now) ? now : Date.now();
|
|
1363
|
+
reapLost(at);
|
|
929
1364
|
const state = read();
|
|
930
1365
|
const config = settings(state);
|
|
931
1366
|
const able = capabilities();
|
|
@@ -934,16 +1369,19 @@ function status(now) {
|
|
|
934
1369
|
'Relay is ' + (config.enabled ? 'ON' : 'OFF') + ', arming at ' + config.at + ' per cent, waking ' +
|
|
935
1370
|
config.graceMinutes + ' min after the reset, delivery ' + config.mode + '.'
|
|
936
1371
|
);
|
|
937
|
-
|
|
1372
|
+
const all = records(state);
|
|
1373
|
+
for (const armed of all) {
|
|
938
1374
|
lines.push(
|
|
939
|
-
'Armed: session ' +
|
|
940
|
-
formatWait(
|
|
1375
|
+
'Armed: session ' + armed.id.slice(0, 8) + ' in ' + armed.cwd + ', wake in ' +
|
|
1376
|
+
formatWait(armed.wakeAt - at) + ' (' + new Date(armed.wakeAt).toLocaleString() + '), via ' + armed.how + '.'
|
|
941
1377
|
);
|
|
942
|
-
if (
|
|
943
|
-
lines.push('
|
|
944
|
-
|
|
945
|
-
lines.push('
|
|
1378
|
+
if (armed.warning) lines.push(' Caveat: ' + armed.warning + '.');
|
|
1379
|
+
if (armed.preflight && armed.preflight.length) lines.push(' Pre-answered: ' + armed.preflight.join(', '));
|
|
1380
|
+
if (armed.launchCwd) lines.push(' Resumes from ' + armed.launchCwd + ' (the home folder can never be trusted) with --add-dir back to ' + armed.cwd);
|
|
1381
|
+
lines.push(' Continuation written: ' + (armed.continuation ? 'yes' : 'not yet'));
|
|
946
1382
|
}
|
|
1383
|
+
if (!all.length) lines.push('Nothing armed.');
|
|
1384
|
+
else if (all.length > 1) lines.push(all.length + ' sessions armed; each wakes on its own task.');
|
|
947
1385
|
lines.push(
|
|
948
1386
|
'Available here: ' + [
|
|
949
1387
|
able.claude ? 'claude CLI' : null,
|
|
@@ -1026,6 +1464,7 @@ async function armByHand(rest) {
|
|
|
1026
1464
|
return (
|
|
1027
1465
|
'Armed by hand for session ' + sessionId.slice(0, 8) + ': wake at ' +
|
|
1028
1466
|
new Date(result.record.wakeAt).toLocaleString() + ' via ' + result.record.how + '.' +
|
|
1467
|
+
(result.record.preflight && result.record.preflight.length ? ' Pre-answered: ' + result.record.preflight.join(', ') + '.' : '') +
|
|
1029
1468
|
(text ? ' Continuation saved.' : ' No continuation yet - add one with: relay note "<text>"')
|
|
1030
1469
|
);
|
|
1031
1470
|
}
|
|
@@ -1067,6 +1506,36 @@ async function doctor(now) {
|
|
|
1067
1506
|
const cli = caps.claude || caps.codex;
|
|
1068
1507
|
add('a CLI to resume with', Boolean(cli), cli || 'neither the claude nor the codex CLI could be found on PATH');
|
|
1069
1508
|
|
|
1509
|
+
// Whether there is a login for the wake to resume under, asked of the CLI
|
|
1510
|
+
// itself: `claude auth status --json` reports loggedIn and nothing secret.
|
|
1511
|
+
// WHEN it expires is the one thing this cannot check. That command reports
|
|
1512
|
+
// no expiry (2.1.263, read from its output), and the only expiry on disk
|
|
1513
|
+
// is inside .credentials.json, which the relay does not read for credential
|
|
1514
|
+
// values. So it is stated as a limit rather than guessed at: a login that
|
|
1515
|
+
// lapses before the wake stops the resumed run at a prompt nobody answers.
|
|
1516
|
+
if (caps.claude) {
|
|
1517
|
+
const auth = spawnSync(caps.claude, ['auth', 'status', '--json'], {
|
|
1518
|
+
encoding: 'utf8',
|
|
1519
|
+
windowsHide: true,
|
|
1520
|
+
timeout: 20000,
|
|
1521
|
+
shell: process.platform === 'win32' && /\.(cmd|bat)$/i.test(caps.claude),
|
|
1522
|
+
});
|
|
1523
|
+
let parsed = null;
|
|
1524
|
+
try {
|
|
1525
|
+
parsed = JSON.parse(String(auth.stdout || '').trim());
|
|
1526
|
+
} catch (err) {
|
|
1527
|
+
parsed = null;
|
|
1528
|
+
}
|
|
1529
|
+
if (parsed && typeof parsed.loggedIn === 'boolean') {
|
|
1530
|
+
add('login', parsed.loggedIn, parsed.loggedIn
|
|
1531
|
+
? 'logged in' + (parsed.subscriptionType ? ' (' + parsed.subscriptionType + ')' : '') +
|
|
1532
|
+
'. 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'
|
|
1533
|
+
: 'not logged in - the resumed run would stop at the login prompt. Run: claude auth login');
|
|
1534
|
+
} else {
|
|
1535
|
+
add('login', true, 'claude auth status gave no answer; expiry is not checked either way, so run /login before a long relay', 'warning');
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1070
1539
|
const net = await require('./net.js').reachable({ timeoutMs: 8000 });
|
|
1071
1540
|
add('network to the API', net.online, net.detail,
|
|
1072
1541
|
net.online ? 'ok' : net.reason === 'intercepted' ? 'error' : 'warning');
|
|
@@ -1074,14 +1543,37 @@ async function doctor(now) {
|
|
|
1074
1543
|
// Can a task actually be registered by this account? Registering and removing
|
|
1075
1544
|
// a throwaway is the only honest answer; asking the policy is not.
|
|
1076
1545
|
if (process.platform === 'win32') {
|
|
1546
|
+
for (const rec of records(state)) {
|
|
1547
|
+
const pre = preflightPrompts(rec.launchCwd || rec.cwd, config, { dry: true });
|
|
1548
|
+
const quiet = pre.ok && !pre.changes.length;
|
|
1549
|
+
add('start-up questions for ' + String(rec.id).slice(0, 8), quiet,
|
|
1550
|
+
pre.ok
|
|
1551
|
+
? (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')
|
|
1552
|
+
: pre.error,
|
|
1553
|
+
quiet ? 'ok' : 'warn');
|
|
1554
|
+
}
|
|
1555
|
+
if (!records(state).length && config.mode === 'resume') {
|
|
1556
|
+
const pre = preflightPrompts(launchDirFor(process.cwd()), config, { dry: true });
|
|
1557
|
+
const quiet = pre.ok && !pre.changes.length;
|
|
1558
|
+
add('start-up questions here', quiet,
|
|
1559
|
+
pre.ok
|
|
1560
|
+
? (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')
|
|
1561
|
+
: pre.error,
|
|
1562
|
+
quiet ? 'ok' : 'warn');
|
|
1563
|
+
}
|
|
1077
1564
|
const probeName = 'usage-limits-doctor-probe';
|
|
1078
|
-
|
|
1565
|
+
// Ninety seconds, not twenty: the PowerShell route takes over thirty on
|
|
1566
|
+
// this machine, and a probe cut off before it can finish reports the
|
|
1567
|
+
// fallback, which is a different answer to the question being asked.
|
|
1568
|
+
const registered = scheduleWindows(at + 6 * 60 * MINUTE, [path.join(__dirname, 'wake.js'), '--id', 'doctor-probe'], probeName, os.homedir(), at + 90000);
|
|
1079
1569
|
add('scheduled tasks', registered.ok, registered.ok
|
|
1080
1570
|
? 'registered a test task via ' + registered.how + (registered.how === 'schtasks'
|
|
1081
|
-
? ' - the PowerShell path failed, so a sleeping machine will miss its wake'
|
|
1571
|
+
? ' - the PowerShell path failed (' + registered.primaryError + '), so a sleeping machine will miss its wake'
|
|
1082
1572
|
: ' - StartWhenAvailable and WakeToRun are set, so a machine that was off or asleep still runs it')
|
|
1083
1573
|
: registered.error, registered.ok && registered.how === 'schtasks' ? 'warning' : undefined);
|
|
1084
1574
|
if (registered.ok) cancelSchedule(probeName);
|
|
1575
|
+
// The probe's launcher has no record, so the sweep takes it.
|
|
1576
|
+
sweepWakeLaunchers(state);
|
|
1085
1577
|
|
|
1086
1578
|
// A wake at 3am is no use if the machine hibernates at midnight and the
|
|
1087
1579
|
// task is not allowed to wake it. This reads the actual power policy.
|
|
@@ -1198,9 +1690,16 @@ function main(argv) {
|
|
|
1198
1690
|
}
|
|
1199
1691
|
if (command === 'note') {
|
|
1200
1692
|
const state = read();
|
|
1201
|
-
|
|
1693
|
+
const all = records(state);
|
|
1694
|
+
if (!all.length) return 'Nothing is armed, so there is nowhere to put a continuation yet.';
|
|
1695
|
+
// This session's own relay; with several armed and none this session's, the id has to be named.
|
|
1696
|
+
const sessionAt = rest.indexOf('--session');
|
|
1697
|
+
const named = sessionAt !== -1 ? rest[sessionAt + 1] : null;
|
|
1698
|
+
const me = named || process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_SESSION_ID || null;
|
|
1699
|
+
const target = armedFor(state, me) || (all.length === 1 ? all[0] : null);
|
|
1700
|
+
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
1701
|
const fromFile = rest.indexOf('--file') !== -1 ? rest[rest.indexOf('--file') + 1] : null;
|
|
1203
|
-
let text = fromFile ? '' : rest.filter((item) => item !== '--file').join(' ');
|
|
1702
|
+
let text = fromFile ? '' : rest.filter((item, i) => item !== '--file' && item !== '--session' && !(sessionAt !== -1 && i === sessionAt + 1)).join(' ');
|
|
1204
1703
|
if (fromFile) {
|
|
1205
1704
|
try {
|
|
1206
1705
|
text = fs.readFileSync(fromFile, 'utf8');
|
|
@@ -1208,12 +1707,17 @@ function main(argv) {
|
|
|
1208
1707
|
return 'Could not read ' + fromFile;
|
|
1209
1708
|
}
|
|
1210
1709
|
}
|
|
1211
|
-
const written = saveContinuation(
|
|
1212
|
-
return written ? 'Continuation saved for the relay (' + written + ').' : 'Nothing to save.';
|
|
1710
|
+
const written = saveContinuation(target.id, text, { force: Boolean(named) });
|
|
1711
|
+
return written ? 'Continuation saved for the relay of ' + target.id.slice(0, 8) + ' (' + written + ').' : 'Nothing to save.';
|
|
1213
1712
|
}
|
|
1214
1713
|
if (command === 'cancel') {
|
|
1215
|
-
|
|
1216
|
-
|
|
1714
|
+
// This session's own relay by default; --all takes every session's down.
|
|
1715
|
+
const me = process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_SESSION_ID || null;
|
|
1716
|
+
const every = rest.includes('--all') || !me || !armedFor(read(), me);
|
|
1717
|
+
const result = disarm('cancelled by hand', Date.now(), every ? undefined : me);
|
|
1718
|
+
if (!result.changed) return 'Nothing was armed.';
|
|
1719
|
+
const n = result.records ? result.records.length : 1;
|
|
1720
|
+
return n > 1 ? n + ' relays cancelled and their scheduled wakes removed.' : 'Relay cancelled and the scheduled wake removed.';
|
|
1217
1721
|
}
|
|
1218
1722
|
// Arming by hand, for the cases the hook cannot see: Codex writes no plan
|
|
1219
1723
|
// tool into its rollouts, so nothing there ever reads as work to carry; and
|
|
@@ -1233,11 +1737,28 @@ function main(argv) {
|
|
|
1233
1737
|
const config = configure({ backstopAt: Number(value) });
|
|
1234
1738
|
return 'Past ' + config.backstopAt + ' per cent it stops waiting for a completion and arms immediately.';
|
|
1235
1739
|
}
|
|
1740
|
+
if (command === 'voice') {
|
|
1741
|
+
const on = !['off', 'false', 'no', '0'].includes(String(value || 'on').toLowerCase());
|
|
1742
|
+
configure({ voice: on });
|
|
1743
|
+
return on
|
|
1744
|
+
? 'The hand-off will carry how you write, so the resumed session answers in your voice without being reminded.'
|
|
1745
|
+
: 'The hand-off will not carry how you write.';
|
|
1746
|
+
}
|
|
1747
|
+
if (command === 'bugcheck') {
|
|
1748
|
+
// pick() is local to settings(); the command checks the value itself.
|
|
1749
|
+
const wanted = String(value || 'on').toLowerCase();
|
|
1750
|
+
const choice = ['on', 'always', 'off'].includes(wanted) ? wanted : null;
|
|
1751
|
+
if (!choice) return 'Bug check: on (the hand-off asks for two passes), always (every prompt does), or off.';
|
|
1752
|
+
configure({ bugcheck: choice });
|
|
1753
|
+
if (choice === 'off') return 'Neither the hand-off nor the prompt hook will ask for the two bug passes.';
|
|
1754
|
+
if (choice === 'always') return 'Every prompt, and the hand-off, will ask for two bug passes before anything is called done.';
|
|
1755
|
+
return 'The hand-off will ask for two bug passes before anything is called done.';
|
|
1756
|
+
}
|
|
1236
1757
|
if (command === 'show') {
|
|
1237
1758
|
const on = !['off', 'false', 'no', '0'].includes(String(value || 'on').toLowerCase());
|
|
1238
1759
|
configure({ show: on });
|
|
1239
1760
|
return on
|
|
1240
|
-
? 'The resumed run will
|
|
1761
|
+
? '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
1762
|
: 'The resumed run will be invisible. Its output is still kept: relay log --run.';
|
|
1242
1763
|
}
|
|
1243
1764
|
if (command === 'onfailure' || command === 'on-failure') {
|
|
@@ -1259,6 +1780,18 @@ function main(argv) {
|
|
|
1259
1780
|
return 'A machine that cannot reach the API will retry ' + config.offlineAttempts + ' times, ' +
|
|
1260
1781
|
config.offlineRetryMinutes + ' minutes apart at first and backing off from there. Being offline never counts as a failed run.';
|
|
1261
1782
|
}
|
|
1783
|
+
if (command === 'preflight') {
|
|
1784
|
+
|
|
1785
|
+
const target = process.argv.slice(3).find((a) => !String(a).startsWith('--')) || process.cwd();
|
|
1786
|
+
|
|
1787
|
+
const r = preflightPrompts(target, settings(read()));
|
|
1788
|
+
|
|
1789
|
+
if (!r.ok) return 'Could not pre-answer: ' + r.error;
|
|
1790
|
+
|
|
1791
|
+
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 + '.';
|
|
1792
|
+
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1262
1795
|
if (command === 'doctor' || command === 'check') return doctor(Date.now()).then((r) => r.text);
|
|
1263
1796
|
if (command === 'log') {
|
|
1264
1797
|
if (rest.includes('--run')) {
|
|
@@ -1275,7 +1808,7 @@ function main(argv) {
|
|
|
1275
1808
|
return [
|
|
1276
1809
|
'usage: relay.js [status|on|off|at N|grace N|mode notify|resume|permission MODE|model NAME|',
|
|
1277
1810
|
' thinking off|resume|always|armon threshold|completion|backstop N|',
|
|
1278
|
-
' show on|off|onfailure rearm|stop|offline N|doctor|',
|
|
1811
|
+
' show on|off|onfailure rearm|stop|offline N|doctor|preflight [cwd]|',
|
|
1279
1812
|
' arm [--session ID] [TEXT]|note TEXT|cancel|log [--run]]',
|
|
1280
1813
|
'',
|
|
1281
1814
|
status(Date.now()),
|
|
@@ -1297,7 +1830,9 @@ if (require.main === module) {
|
|
|
1297
1830
|
);
|
|
1298
1831
|
}
|
|
1299
1832
|
|
|
1300
|
-
module.exports = {
|
|
1833
|
+
module.exports = { workWithContinuation, reapLost, hiddenAction, BUGCHECK_LINE,
|
|
1834
|
+
taskAction, wakeLauncherFile, wakeLauncherScript, writeWakeLauncher, sweepWakeLaunchers, batchArg, describeSpawn, PS_ROUTE_MS, SCHTASKS_MS,
|
|
1835
|
+
records, armedFor, putRecord, dropRecord, preflightPrompts, claudeJsonFile, projectKeys, isHome, launchDirFor, applySetting, settingIs,
|
|
1301
1836
|
DEFAULTS,
|
|
1302
1837
|
MINUTE,
|
|
1303
1838
|
main,
|