claude-usage-limits 1.24.0 → 1.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/usage-limits/scripts/agy-hook.js +2 -2
- package/skills/usage-limits/scripts/ceiling.js +29 -7
- package/skills/usage-limits/scripts/defer.js +1 -1
- package/skills/usage-limits/scripts/lowpower.js +1 -1
- package/skills/usage-limits/scripts/mode.js +20 -5
- package/skills/usage-limits/scripts/pulse.js +2 -0
- package/skills/usage-limits/scripts/relay.js +135 -10
- package/skills/usage-limits/scripts/wake.js +26 -5
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usage-limits",
|
|
3
3
|
"displayName": "Usage Limits",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.26.0",
|
|
5
5
|
"description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Ridelink",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usage-limits",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.26.0",
|
|
4
4
|
"description": "Reports how much of your Codex usage limit is left as turns of work rather than a percentage, prices a job before you start it, and counts the other agents sharing the same budget.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Ridelink",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-usage-limits",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.26.0",
|
|
4
4
|
"description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -124,7 +124,7 @@ async function run(now, input, argv) {
|
|
|
124
124
|
if (event === 'PreToolUse') {
|
|
125
125
|
const tool = toolNameOf(input);
|
|
126
126
|
if (!ceiling.isMultiplier(tool)) return {};
|
|
127
|
-
const at = ceiling.assess({ percent: percentNow(now), state: budget.state, env: process.env });
|
|
127
|
+
const at = ceiling.assess({ percent: percentNow(now), state: budget.state, env: process.env, sessionId });
|
|
128
128
|
const call = ceiling.verdict(at, tool);
|
|
129
129
|
if (call.decision !== 'deny') return {};
|
|
130
130
|
return { decision: 'deny', reason: call.reason };
|
|
@@ -141,7 +141,7 @@ async function run(now, input, argv) {
|
|
|
141
141
|
text = '';
|
|
142
142
|
}
|
|
143
143
|
const warning = ceiling.warning(
|
|
144
|
-
ceiling.assess({ percent: percentNow(now), state: budget.state, env: process.env })
|
|
144
|
+
ceiling.assess({ percent: percentNow(now), state: budget.state, env: process.env, sessionId })
|
|
145
145
|
);
|
|
146
146
|
const message = [text, warning].filter(Boolean).join(' ');
|
|
147
147
|
return message ? { injectSteps: [{ ephemeralMessage: message }] } : { injectSteps: [] };
|
|
@@ -64,10 +64,22 @@ function isMultiplier(tool) {
|
|
|
64
64
|
return MULTIPLIER_PATTERN.test(name);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
// The number, and who set it.
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
67
|
+
// The number, and who set it.
|
|
68
|
+
//
|
|
69
|
+
// A cap belongs to THE SESSION THAT SET IT, and this is the whole of the fix
|
|
70
|
+
// for it outliving one. "Do not spend past 65 per cent" is a thing somebody
|
|
71
|
+
// says about the work in front of them; carrying it into tomorrow's session
|
|
72
|
+
// means refusing fan-outs on a fresh window for a reason nobody remembers
|
|
73
|
+
// giving. It was reported exactly that way: the session restarted and the cap
|
|
74
|
+
// was still being enforced.
|
|
75
|
+
//
|
|
76
|
+
// So the stored cap carries the session id it was set in, and applies only
|
|
77
|
+
// there. A cap with no session recorded is a cap from before this rule existed
|
|
78
|
+
// - it is ignored rather than honoured, because honouring it is the bug.
|
|
79
|
+
//
|
|
80
|
+
// An explicit environment value is different: it is the user saying it outright
|
|
81
|
+
// for this one process, so it beats the file and needs no session.
|
|
82
|
+
function ceilingFrom(state, env, sessionId) {
|
|
71
83
|
const environment = env || process.env;
|
|
72
84
|
const raw = environment.USAGE_LIMITS_CEILING;
|
|
73
85
|
if (raw !== undefined && raw !== null && String(raw).trim() !== '') {
|
|
@@ -81,8 +93,16 @@ function ceilingFrom(state, env) {
|
|
|
81
93
|
// the file rather than enforcing a number nobody typed.
|
|
82
94
|
}
|
|
83
95
|
const stored = state && Number.isFinite(state.ceilingPercent) ? state.ceilingPercent : null;
|
|
84
|
-
if (stored
|
|
85
|
-
|
|
96
|
+
if (stored === null || stored <= 0 || stored > 100) return { percent: null, source: null };
|
|
97
|
+
const owner = state && state.ceilingSession ? String(state.ceilingSession) : null;
|
|
98
|
+
if (!owner) {
|
|
99
|
+
// Set before caps were session-scoped. Not this session's instruction.
|
|
100
|
+
return { percent: null, source: null, staleCap: stored };
|
|
101
|
+
}
|
|
102
|
+
if (!sessionId || String(sessionId) !== owner) {
|
|
103
|
+
return { percent: null, source: null, otherSessionCap: stored };
|
|
104
|
+
}
|
|
105
|
+
return { percent: stored, source: 'this session' };
|
|
86
106
|
}
|
|
87
107
|
|
|
88
108
|
// Where the binding window stands against the ceiling.
|
|
@@ -92,7 +112,7 @@ function ceilingFrom(state, env) {
|
|
|
92
112
|
// the same mistake the brief was corrected for.
|
|
93
113
|
function assess(options) {
|
|
94
114
|
const opts = options || {};
|
|
95
|
-
const ceiling = ceilingFrom(opts.state, opts.env);
|
|
115
|
+
const ceiling = ceilingFrom(opts.state, opts.env, opts.sessionId);
|
|
96
116
|
const percent = Number.isFinite(opts.percent) ? opts.percent : null;
|
|
97
117
|
if (ceiling.percent === null || percent === null) {
|
|
98
118
|
return {
|
|
@@ -103,6 +123,8 @@ function assess(options) {
|
|
|
103
123
|
over: false,
|
|
104
124
|
near: false,
|
|
105
125
|
headroomPoints: null,
|
|
126
|
+
staleCap: ceiling.staleCap || null,
|
|
127
|
+
otherSessionCap: ceiling.otherSessionCap || null,
|
|
106
128
|
};
|
|
107
129
|
}
|
|
108
130
|
const headroomPoints = ceiling.percent - percent;
|
|
@@ -175,7 +175,7 @@ function bindingReset(now) {
|
|
|
175
175
|
function sessionId(argv, env) {
|
|
176
176
|
const at = argv.indexOf('--session-id');
|
|
177
177
|
if (at !== -1 && argv[at + 1]) return argv[at + 1];
|
|
178
|
-
return env.CLAUDE_SESSION_ID || env.CODEX_SESSION_ID || 'defer-' + Date.now().toString(36);
|
|
178
|
+
return env.CLAUDE_CODE_SESSION_ID || env.CLAUDE_SESSION_ID || env.CODEX_SESSION_ID || 'defer-' + Date.now().toString(36);
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
function argOf(argv, name) {
|
|
@@ -192,7 +192,7 @@ function describe(settings, state) {
|
|
|
192
192
|
function logSettingsChange(changes, direction, env) {
|
|
193
193
|
if (!changes || !changes.length) return;
|
|
194
194
|
const e = env || process.env;
|
|
195
|
-
const by = e.CLAUDE_SESSION_ID || e.CODEX_SESSION_ID ? 'claude' : 'user';
|
|
195
|
+
const by = e.CLAUDE_CODE_SESSION_ID || e.CLAUDE_SESSION_ID || e.CODEX_SESSION_ID ? 'claude' : 'user';
|
|
196
196
|
try {
|
|
197
197
|
const mode = require('./mode.js');
|
|
198
198
|
for (const change of changes) {
|
|
@@ -388,6 +388,11 @@ function empty() {
|
|
|
388
388
|
// reported: past it, fan-out calls are refused at the hook. Null means no
|
|
389
389
|
// ceiling, and a ceiling nobody set never refuses anything. See ceiling.js.
|
|
390
390
|
ceilingPercent: null,
|
|
391
|
+
// WHOSE cap it is. A cap is said about the work in front of someone, so
|
|
392
|
+
// it binds only in the session that set it; carrying it into the next one
|
|
393
|
+
// means refusing fan-outs on a fresh window for a reason nobody gave. A
|
|
394
|
+
// stored cap with no session here predates this rule and is ignored.
|
|
395
|
+
ceilingSession: null,
|
|
391
396
|
setAt: null,
|
|
392
397
|
setBy: null,
|
|
393
398
|
session: null,
|
|
@@ -431,6 +436,7 @@ function read() {
|
|
|
431
436
|
base.guardPercent = Number.isFinite(parsed.guardPercent) ? parsed.guardPercent : null;
|
|
432
437
|
// Anything outside 1-100 is not a ceiling, and enforcing a number that was
|
|
433
438
|
// never a percentage would refuse work over a typo.
|
|
439
|
+
base.ceilingSession = typeof parsed.ceilingSession === 'string' ? parsed.ceilingSession : null;
|
|
434
440
|
base.ceilingPercent =
|
|
435
441
|
Number.isFinite(parsed.ceilingPercent) && parsed.ceilingPercent > 0 && parsed.ceilingPercent <= 100
|
|
436
442
|
? parsed.ceilingPercent
|
|
@@ -1240,7 +1246,10 @@ function setMode(name, opts, now) {
|
|
|
1240
1246
|
state.setAt = at;
|
|
1241
1247
|
state.setBy = 'user';
|
|
1242
1248
|
if (opts && Number.isFinite(opts.guard)) state.guardPercent = opts.guard;
|
|
1243
|
-
if (opts && Number.isFinite(opts.ceiling))
|
|
1249
|
+
if (opts && Number.isFinite(opts.ceiling)) {
|
|
1250
|
+
state.ceilingPercent = opts.ceiling;
|
|
1251
|
+
state.ceilingSession = opts.sessionId || null;
|
|
1252
|
+
}
|
|
1244
1253
|
write(state);
|
|
1245
1254
|
logChange({ plane: 'mode', key: 'mode', from: before, to: name, by: 'user', reason: null }, at);
|
|
1246
1255
|
|
|
@@ -1288,7 +1297,10 @@ function main(argv) {
|
|
|
1288
1297
|
const next = args[at + 1];
|
|
1289
1298
|
return next && next.indexOf('--') !== 0 ? next : null;
|
|
1290
1299
|
};
|
|
1291
|
-
|
|
1300
|
+
// CLAUDE_CODE_SESSION_ID is the one Claude Code actually exports;
|
|
1301
|
+
// CLAUDE_SESSION_ID never existed, so this read null and a cap set from the
|
|
1302
|
+
// command line could record no owner and therefore never bind.
|
|
1303
|
+
const sessionId = value('--session-id') || process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_SESSION_ID || null;
|
|
1292
1304
|
const decided = resolve({ sessionId });
|
|
1293
1305
|
|
|
1294
1306
|
if (flag('--list')) return list(decided);
|
|
@@ -1465,6 +1477,7 @@ function main(argv) {
|
|
|
1465
1477
|
const state = read();
|
|
1466
1478
|
const previous = state.ceilingPercent;
|
|
1467
1479
|
state.ceilingPercent = ceilingArg.clear ? null : ceilingArg.percent;
|
|
1480
|
+
state.ceilingSession = ceilingArg.clear ? null : sessionId;
|
|
1468
1481
|
write(state);
|
|
1469
1482
|
logChange(
|
|
1470
1483
|
{ plane: 'mode', key: 'ceiling', from: previous, to: state.ceilingPercent, by: 'user', reason: null },
|
|
@@ -1481,6 +1494,7 @@ function clearCeiling(now) {
|
|
|
1481
1494
|
const state = read();
|
|
1482
1495
|
const previous = state.ceilingPercent;
|
|
1483
1496
|
state.ceilingPercent = null;
|
|
1497
|
+
state.ceilingSession = null;
|
|
1484
1498
|
write(state);
|
|
1485
1499
|
logChange({ plane: 'mode', key: 'ceiling', from: previous, to: null, by: 'user', reason: null }, now);
|
|
1486
1500
|
}
|
|
@@ -1493,10 +1507,11 @@ function ceilingLine() {
|
|
|
1493
1507
|
return 'Ceiling off. Nothing is refused; the plugin reports and does not intervene.';
|
|
1494
1508
|
}
|
|
1495
1509
|
return (
|
|
1496
|
-
'
|
|
1497
|
-
'
|
|
1510
|
+
'Cap ' + state.ceilingPercent + '%' + (state.ceilingSession ? ', for THIS session only' : ' (stored against no session, so it is ignored - set it again to apply it here)') +
|
|
1511
|
+
'. Past that, fan-out calls (Agent, Task, Workflow and ' +
|
|
1512
|
+
'their equivalents) are refused at the hook. Nothing else ' +
|
|
1498
1513
|
'is blocked, so the work still finishes - sequentially, in one session, which is where the ' +
|
|
1499
|
-
'saving comes from. "mode --cap off" removes it.'
|
|
1514
|
+
'saving comes from. It lapses when this session ends; "mode --cap off" removes it now.'
|
|
1500
1515
|
);
|
|
1501
1516
|
}
|
|
1502
1517
|
|
|
@@ -253,6 +253,8 @@ async function run(now, hookInput) {
|
|
|
253
253
|
percent: ceilingPercent(now),
|
|
254
254
|
state: budget.state,
|
|
255
255
|
env: process.env,
|
|
256
|
+
// The cap only binds in the session that set it.
|
|
257
|
+
sessionId,
|
|
256
258
|
});
|
|
257
259
|
const call = ceiling.verdict(at, tool);
|
|
258
260
|
if (call.decision === 'deny') return { deny: true, reason: call.reason };
|
|
@@ -521,8 +521,27 @@ function wakeAt(resetsAt, graceMinutes, now) {
|
|
|
521
521
|
return Math.max(at, floor);
|
|
522
522
|
}
|
|
523
523
|
|
|
524
|
-
|
|
525
|
-
|
|
524
|
+
// One name per WAKE, not one per session.
|
|
525
|
+
//
|
|
526
|
+
// Reusing a name entangles every registration with the previous one's fate,
|
|
527
|
+
// and all of it is undocumented: whether -Force truly replaces a stale
|
|
528
|
+
// definition, what happens when a name is re-registered while its predecessor
|
|
529
|
+
// is pending expiry-deletion, and whether an old EndBoundary survives the
|
|
530
|
+
// update. On top of that MultipleInstances defaults to IgnoreNew, so a
|
|
531
|
+
// previous run still held open by the four-hour execution limit silently
|
|
532
|
+
// swallows the next launch.
|
|
533
|
+
//
|
|
534
|
+
// A unique suffix removes all of it at once, and lets DeleteExpiredTaskAfter
|
|
535
|
+
// do what it should: each fired wake garbage-collects itself. cancelSchedule
|
|
536
|
+
// works off the stored name, so nothing downstream changes.
|
|
537
|
+
function taskName(id, at) {
|
|
538
|
+
const stamp = Number.isFinite(at) ? at : Date.now();
|
|
539
|
+
return (
|
|
540
|
+
'UsageLimitsRelay-' +
|
|
541
|
+
String(id).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 40) +
|
|
542
|
+
'-' +
|
|
543
|
+
stamp.toString(36)
|
|
544
|
+
);
|
|
526
545
|
}
|
|
527
546
|
|
|
528
547
|
function psQuote(value) {
|
|
@@ -574,7 +593,45 @@ function remainingMs(deadline, ceiling) {
|
|
|
574
593
|
return Math.max(0, Math.min(ceiling, left));
|
|
575
594
|
}
|
|
576
595
|
|
|
596
|
+
// Does Windows agree that this task will run?
|
|
597
|
+
//
|
|
598
|
+
// `state` and `next` come straight from Get-ScheduledTask and
|
|
599
|
+
// Get-ScheduledTaskInfo. A task can register cleanly and still never fire,
|
|
600
|
+
// and each of these has been seen: Disabled by policy or by an earlier
|
|
601
|
+
// failure; NextRunTime empty because the trigger time had already passed by
|
|
602
|
+
// the time it was written; NextRunTime set to something other than what was
|
|
603
|
+
// asked for, meaning the trigger did not take. None of them raised an error
|
|
604
|
+
// before, so the relay reported an armed wake and nothing happened.
|
|
605
|
+
function verifyRegistration(state, next, wanted, now) {
|
|
606
|
+
const said = String(state || '').trim();
|
|
607
|
+
if (/disabled/i.test(said)) {
|
|
608
|
+
return { ok: false, error: 'the scheduled task registered but is Disabled, so it will not run' };
|
|
609
|
+
}
|
|
610
|
+
const text = String(next || '').trim();
|
|
611
|
+
if (!text) {
|
|
612
|
+
return { ok: false, error: 'the scheduled task registered but Windows reports no next run time, so it will not fire' };
|
|
613
|
+
}
|
|
614
|
+
const at = Date.parse(text);
|
|
615
|
+
if (!Number.isFinite(at)) {
|
|
616
|
+
return { ok: false, error: 'could not read the next run time Windows reported: ' + text };
|
|
617
|
+
}
|
|
618
|
+
if (at <= now) {
|
|
619
|
+
return { ok: false, error: 'the scheduled task next run time is already in the past' };
|
|
620
|
+
}
|
|
621
|
+
// Five minutes of slack: Task Scheduler rounds to the minute and a trigger
|
|
622
|
+
// can be nudged. Anything further out is a different time from the one asked
|
|
623
|
+
// for, which means the trigger did not take.
|
|
624
|
+
if (Number.isFinite(wanted) && Math.abs(at - wanted) > 5 * 60 * 1000) {
|
|
625
|
+
return {
|
|
626
|
+
ok: false,
|
|
627
|
+
error: 'the scheduled task is set for ' + new Date(at).toISOString() + ', not the requested ' + new Date(wanted).toISOString(),
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
return { ok: true, nextRun: at };
|
|
631
|
+
}
|
|
632
|
+
|
|
577
633
|
function scheduleWindows(when, argv, name, cwd, deadline) {
|
|
634
|
+
let lastVerifyError = null;
|
|
578
635
|
const date = new Date(when);
|
|
579
636
|
const stamp =
|
|
580
637
|
date.getFullYear() + '-' + two(date.getMonth() + 1) + '-' + two(date.getDate()) + ' ' +
|
|
@@ -589,12 +646,33 @@ function scheduleWindows(when, argv, name, cwd, deadline) {
|
|
|
589
646
|
// leaves a dead entry in Task Scheduler forever; the wake also unregisters
|
|
590
647
|
// itself when it finishes, and this is what catches the wakes that never
|
|
591
648
|
// get to run at all.
|
|
592
|
-
|
|
649
|
+
// "zzz" is load-bearing. EndBoundary is documented as
|
|
650
|
+
// YYYY-MM-DDTHH:MM:SS(+-)HH:MM, and .ToString("s") emits no offset at
|
|
651
|
+
// all. A boundary written without one can be read as UTC, which west of
|
|
652
|
+
// Greenwich puts it HOURS IN THE PAST - so the task is born expired,
|
|
653
|
+
// never fires, and DeleteExpiredTaskAfter quietly reaps it. None of that
|
|
654
|
+
// raises an error: registration returns SCHED_S_SOME_TRIGGERS_FAILED
|
|
655
|
+
// (0x0004131B), which is a SUCCESS code, so PowerShell never throws and
|
|
656
|
+
// the relay reported a wake that could not happen.
|
|
657
|
+
'$trigger.EndBoundary = (Get-Date ' + psQuote(stamp) + ').AddHours(12).ToString("yyyy-MM-ddTHH:mm:sszzz")',
|
|
593
658
|
'$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -WakeToRun -AllowStartIfOnBatteries ' +
|
|
594
659
|
'-DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Hours 4) ' +
|
|
595
660
|
'-DeleteExpiredTaskAfter (New-TimeSpan -Minutes 10)',
|
|
596
661
|
'Register-ScheduledTask -TaskName ' + psQuote(name) + ' -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null',
|
|
597
|
-
|
|
662
|
+
// Ask Windows whether it will actually fire, rather than trusting that a
|
|
663
|
+
// registration which did not throw is a wake that will happen.
|
|
664
|
+
//
|
|
665
|
+
// This is the difference between "it said it would restart at 4:30 and
|
|
666
|
+
// nothing happened" and an error at arming time. Register-ScheduledTask
|
|
667
|
+
// reports success for a task that will never run: a trigger already in the
|
|
668
|
+
// past, a task left Disabled, a name whose previous registration is still
|
|
669
|
+
// being torn down. NextRunTime is the only field that answers the question
|
|
670
|
+
// actually being asked.
|
|
671
|
+
'$info = Get-ScheduledTaskInfo -TaskName ' + psQuote(name) + ' -ErrorAction SilentlyContinue',
|
|
672
|
+
'$state = (Get-ScheduledTask -TaskName ' + psQuote(name) + ' -ErrorAction SilentlyContinue).State',
|
|
673
|
+
'$next = ""',
|
|
674
|
+
'if ($info -and $info.NextRunTime) { $next = $info.NextRunTime.ToString("s") }',
|
|
675
|
+
'Write-Output ("registered|" + $state + "|" + $next)',
|
|
598
676
|
].join('\n');
|
|
599
677
|
const file = path.join(os.tmpdir(), name + '.ps1');
|
|
600
678
|
try {
|
|
@@ -604,7 +682,14 @@ function scheduleWindows(when, argv, name, cwd, deadline) {
|
|
|
604
682
|
windowsHide: true,
|
|
605
683
|
timeout: Math.max(500, remainingMs(deadline, 8000)),
|
|
606
684
|
});
|
|
607
|
-
|
|
685
|
+
const registered = (run.stdout || '').match(/registered\|([^|\r\n]*)\|([^\r\n]*)/);
|
|
686
|
+
if (run.status === 0 && registered) {
|
|
687
|
+
const verdict = verifyRegistration(registered[1], registered[2], when, Date.now());
|
|
688
|
+
if (verdict.ok) return { ok: true, how: 'ScheduledTasks', nextRun: verdict.nextRun };
|
|
689
|
+
// Registered but it will not fire. Fall through to schtasks rather than
|
|
690
|
+
// reporting a wake that is not going to happen.
|
|
691
|
+
lastVerifyError = verdict.error;
|
|
692
|
+
}
|
|
608
693
|
// No time left for a second attempt is a plain refusal, not a hung hook.
|
|
609
694
|
if (remainingMs(deadline, 8000) < 1500) return { ok: false, error: 'no time left in this hook to register the wake; it will arm on the next prompt' };
|
|
610
695
|
// schtasks cannot express StartWhenAvailable, so this path is a worse
|
|
@@ -618,7 +703,7 @@ function scheduleWindows(when, argv, name, cwd, deadline) {
|
|
|
618
703
|
{ encoding: 'utf8', windowsHide: true, timeout: Math.max(500, remainingMs(deadline, 8000)) }
|
|
619
704
|
);
|
|
620
705
|
if (fallback.status === 0) return { ok: true, how: 'schtasks', warning: 'a sleeping machine will miss this wake' };
|
|
621
|
-
return { ok: false, error: (run.stderr || fallback.stderr || 'could not register a scheduled task').trim().split('\n')[0] };
|
|
706
|
+
return { ok: false, error: (lastVerifyError || run.stderr || fallback.stderr || 'could not register a scheduled task').trim().split('\n')[0] };
|
|
622
707
|
} catch (err) {
|
|
623
708
|
return { ok: false, error: err.message };
|
|
624
709
|
} finally {
|
|
@@ -643,7 +728,11 @@ function schedulePosix(when, argv, name, cwd) {
|
|
|
643
728
|
encoding: 'utf8',
|
|
644
729
|
timeout: 20000,
|
|
645
730
|
});
|
|
646
|
-
|
|
731
|
+
// macOS ships `at` but launchd leaves atrun DISABLED by default, so the
|
|
732
|
+
// command succeeds, prints a job id, and the wake never fires. Reporting
|
|
733
|
+
// ok for that is worse than not having it: it is a silent no-op that looks
|
|
734
|
+
// like a scheduled wake. Fall through to the sleeper, which at least runs.
|
|
735
|
+
if (run.status === 0 && process.platform !== 'darwin') return { ok: true, how: 'at' };
|
|
647
736
|
}
|
|
648
737
|
try {
|
|
649
738
|
const child = spawn('sh', ['-c', 'sleep ' + seconds + ' && ' + command], {
|
|
@@ -721,7 +810,17 @@ function arm(input) {
|
|
|
721
810
|
|
|
722
811
|
const state = read();
|
|
723
812
|
const id = options.sessionId;
|
|
724
|
-
const name = taskName(id);
|
|
813
|
+
const name = taskName(id, when);
|
|
814
|
+
// Names are unique per wake now, so re-arming no longer replaces the old
|
|
815
|
+
// registration by name. Retire it explicitly, or every re-arm leaves a live
|
|
816
|
+
// task behind that would fire on its own.
|
|
817
|
+
if (state.armed && state.armed.task && state.armed.task !== name) {
|
|
818
|
+
try {
|
|
819
|
+
cancelSchedule(state.armed.task);
|
|
820
|
+
} catch (err) {
|
|
821
|
+
// The expiry removes it eventually either way.
|
|
822
|
+
}
|
|
823
|
+
}
|
|
725
824
|
// Re-arming the same session for the same reset would register the task
|
|
726
825
|
// twice; -Force replaces it, and the record is rewritten either way.
|
|
727
826
|
const argv = [path.join(__dirname, 'wake.js'), '--id', id];
|
|
@@ -767,16 +866,41 @@ function arm(input) {
|
|
|
767
866
|
}
|
|
768
867
|
: null,
|
|
769
868
|
};
|
|
770
|
-
|
|
869
|
+
// Displacing somebody else's live relay is legitimate - one machine, one
|
|
870
|
+
// relay - but it must not be silent. A wake still in the future belonged to
|
|
871
|
+
// work that somebody expected to be picked up.
|
|
872
|
+
if (state.armed && state.armed.id !== id) {
|
|
873
|
+
if (Number.isFinite(state.armed.wakeAt) && state.armed.wakeAt > now) {
|
|
874
|
+
note('displacing the relay armed for ' + state.armed.id + ' (was due ' + new Date(state.armed.wakeAt).toISOString() + ')', now);
|
|
875
|
+
}
|
|
876
|
+
if (state.armed.task) cancelSchedule(state.armed.task);
|
|
877
|
+
}
|
|
771
878
|
state.armed = record;
|
|
772
879
|
write(state);
|
|
773
880
|
note('armed ' + id + ' for ' + new Date(when).toISOString() + ' via ' + scheduled.how, now);
|
|
774
881
|
return { ok: true, record };
|
|
775
882
|
}
|
|
776
883
|
|
|
777
|
-
|
|
884
|
+
// `id` is optional, and when it is given it is a guard rather than a lookup:
|
|
885
|
+
// clear the relay only if the thing armed is the thing the caller means.
|
|
886
|
+
//
|
|
887
|
+
// Without it, disarm clears whatever happens to be armed, and that is not
|
|
888
|
+
// hypothetical. Measured 2026-09-14: a second process armed a throwaway
|
|
889
|
+
// session at 04:57:04 and cleaned it up four seconds later, and the cleanup
|
|
890
|
+
// took a live relay for an unrelated session - armed seven seconds earlier,
|
|
891
|
+
// due to wake five hours later - with it. Nothing reported that, because from
|
|
892
|
+
// disarm's point of view it did exactly what it was asked.
|
|
893
|
+
//
|
|
894
|
+
// A person typing `relay cancel` means "whatever is armed", so the CLI passes
|
|
895
|
+
// no id and the old behaviour stands. Anything that knows which session it is
|
|
896
|
+
// tidying up should say so.
|
|
897
|
+
function disarm(reason, now, id) {
|
|
778
898
|
const state = read();
|
|
779
899
|
if (!state.armed) return { ok: true, changed: false };
|
|
900
|
+
if (id && state.armed.id !== id) {
|
|
901
|
+
note('refused to disarm ' + state.armed.id + ' on behalf of ' + id, Number.isFinite(now) ? now : Date.now());
|
|
902
|
+
return { ok: true, changed: false, refused: true, armed: state.armed.id };
|
|
903
|
+
}
|
|
780
904
|
const record = state.armed;
|
|
781
905
|
if (record.task) cancelSchedule(record.task);
|
|
782
906
|
state.history.push(Object.assign({}, record, { endedAt: Number.isFinite(now) ? now : Date.now(), outcome: reason || 'cancelled' }));
|
|
@@ -1205,6 +1329,7 @@ module.exports = {
|
|
|
1205
1329
|
remainingMs,
|
|
1206
1330
|
armByHand,
|
|
1207
1331
|
schedule,
|
|
1332
|
+
verifyRegistration,
|
|
1208
1333
|
scheduleWindows,
|
|
1209
1334
|
schedulePosix,
|
|
1210
1335
|
cancelSchedule,
|
|
@@ -146,6 +146,10 @@ function userIsPresent(cli) {
|
|
|
146
146
|
function claudeArgs(record, prompt, config, fallback) {
|
|
147
147
|
const args = fallback ? ['--continue', '-p', prompt] : ['--resume', record.id, '-p', prompt];
|
|
148
148
|
if (config.permissionMode) args.push('--permission-mode', config.permissionMode);
|
|
149
|
+
// Nobody is awake to answer a prompt. Deny it rather than stall on it: a
|
|
150
|
+
// resumed run that sits waiting for a keystroke burns its whole timeout and
|
|
151
|
+
// reports nothing.
|
|
152
|
+
args.push('--permission-prompts', 'none');
|
|
149
153
|
if (!fallback && config.model) args.push('--model', config.model);
|
|
150
154
|
return args;
|
|
151
155
|
}
|
|
@@ -186,12 +190,29 @@ function deliverClaude(record, prompt, config, cli, runs) {
|
|
|
186
190
|
const run = appendRun(runs, 'claude --resume', spawnSync(cli, args, options));
|
|
187
191
|
if (run.status === 0) return { ok: true, how: 'claude --resume' };
|
|
188
192
|
const detail = ((run.stderr || run.stdout || '') + '').trim().split('\n')[0];
|
|
189
|
-
// A session id that no longer resolves
|
|
190
|
-
//
|
|
193
|
+
// A session id that no longer resolves used to fall back to `--continue`,
|
|
194
|
+
// on the reasoning that the work still needs doing and only the thread is
|
|
195
|
+
// gone. That fallback is removed, because of what `--continue` actually
|
|
196
|
+
// selects.
|
|
197
|
+
//
|
|
198
|
+
// `--continue` normally SKIPS sessions created by `claude -p`, the SDK and
|
|
199
|
+
// /loop - but `claude -p --continue`, which is exactly what this ran,
|
|
200
|
+
// INCLUDES them. So the most recent session it could land on is a previous
|
|
201
|
+
// relay's own headless run, not the user's work. Resuming that, unattended,
|
|
202
|
+
// at four in the morning, with permissionMode bypassPermissions, means an
|
|
203
|
+
// agent continuing a conversation nobody chose, in a directory it was not
|
|
204
|
+
// asked about.
|
|
205
|
+
//
|
|
206
|
+
// The plan is on disk either way. A wake that stops and says the thread is
|
|
207
|
+
// gone loses nothing; a wake that resumes the wrong conversation can.
|
|
191
208
|
if (/No conversation found/i.test(detail)) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
error:
|
|
212
|
+
'the session ' + String(record.id).slice(0, 8) + ' no longer exists, so there was nothing to resume. ' +
|
|
213
|
+
'The plan is still on disk: run "claude" in ' + record.cwd + ' and paste it in.',
|
|
214
|
+
permanent: true,
|
|
215
|
+
};
|
|
195
216
|
}
|
|
196
217
|
return { ok: false, error: detail || 'claude exited ' + run.status };
|
|
197
218
|
}
|