claude-usage-limits 1.23.0 → 1.24.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/commands/defer.md +47 -0
- package/package.json +1 -1
- package/skills/usage-limits/SKILL.md +30 -1
- package/skills/usage-limits/scripts/defer.js +318 -0
- package/skills/usage-limits/scripts/net.js +179 -0
- package/skills/usage-limits/scripts/relay.js +266 -2
- package/skills/usage-limits/scripts/stop.js +102 -1
- package/skills/usage-limits/scripts/wake.js +210 -30
|
@@ -68,6 +68,43 @@ const DEFAULTS = {
|
|
|
68
68
|
// session is the one that matters; a second agent in the same directory is
|
|
69
69
|
// a way to lose work.
|
|
70
70
|
whenBusy: 'notify',
|
|
71
|
+
// WHERE IN THE TURN the arming happens.
|
|
72
|
+
//
|
|
73
|
+
// threshold the moment the window crosses `at`, which is mid-reply. What
|
|
74
|
+
// gets carried is whatever state the reply happened to be in.
|
|
75
|
+
// completion the end of the reply. The work has reached a boundary, the
|
|
76
|
+
// todo list is current, and the continuation describes
|
|
77
|
+
// something finished rather than something interrupted.
|
|
78
|
+
//
|
|
79
|
+
// 'completion' is the default because the difference is not cosmetic: a
|
|
80
|
+
// continuation captured halfway through a tool call describes a state the
|
|
81
|
+
// next session cannot resume from cleanly.
|
|
82
|
+
armOn: 'completion',
|
|
83
|
+
// The exception to waiting for a boundary. Past this, arm immediately
|
|
84
|
+
// whatever the turn is doing - a relay that politely waits for a completion
|
|
85
|
+
// that never comes, because the limit cut the reply off mid-sentence, is a
|
|
86
|
+
// relay that was never armed at all.
|
|
87
|
+
backstopAt: 95,
|
|
88
|
+
// Being offline is not failing. These are the retries reserved for a machine
|
|
89
|
+
// that cannot reach the API, and the minutes between them. Twelve at ten
|
|
90
|
+
// minutes covers two hours of a router being off, which is the common case.
|
|
91
|
+
offlineAttempts: 12,
|
|
92
|
+
offlineRetryMinutes: 10,
|
|
93
|
+
// Run the resumed session in a window you can see. Invisible is tidier and
|
|
94
|
+
// it is also how you find out in the morning that nothing happened and have
|
|
95
|
+
// no idea why.
|
|
96
|
+
show: true,
|
|
97
|
+
// What happens after a wake fails for a reason that will not fix itself:
|
|
98
|
+
// arm again for the next window, or stop and leave it to a person.
|
|
99
|
+
onFailure: 'rearm',
|
|
100
|
+
// And how many times it may do that. Without a cap, "arm again for the next
|
|
101
|
+
// window" is a scheduled task that reschedules itself forever, which is a
|
|
102
|
+
// worse failure than giving up: it is invisible, it never ends, and nobody
|
|
103
|
+
// asked for it. Two rearms means the work gets three windows to happen in.
|
|
104
|
+
maxRearms: 2,
|
|
105
|
+
// Keep the full stdout and stderr of every resumed run on disk. It is the
|
|
106
|
+
// only record of what happened while nobody was watching.
|
|
107
|
+
runLog: true,
|
|
71
108
|
};
|
|
72
109
|
|
|
73
110
|
function configDir() {
|
|
@@ -82,6 +119,49 @@ function logFile() {
|
|
|
82
119
|
return path.join(configDir(), 'usage-limits-relay.log');
|
|
83
120
|
}
|
|
84
121
|
|
|
122
|
+
// Every resumed run's full output, kept per run. `relay log --run` reads the
|
|
123
|
+
// newest one back. Without this a failed overnight wake leaves one line in the
|
|
124
|
+
// note log and nothing to diagnose it with.
|
|
125
|
+
function runLogDir() {
|
|
126
|
+
return path.join(configDir(), 'relay-runs');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function runLogFile(id, now) {
|
|
130
|
+
const stamp = new Date(Number.isFinite(now) ? now : Date.now()).toISOString().replace(/[:.]/g, '-');
|
|
131
|
+
return path.join(runLogDir(), String(id).slice(0, 8) + '-' + stamp + '.log');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function writeRunLog(file, text) {
|
|
135
|
+
try {
|
|
136
|
+
fs.mkdirSync(runLogDir(), { recursive: true });
|
|
137
|
+
fs.writeFileSync(file, String(text == null ? '' : text), 'utf8');
|
|
138
|
+
// Ten runs is more history than anyone reads and less than a directory
|
|
139
|
+
// nobody ever cleans.
|
|
140
|
+
const kept = fs.readdirSync(runLogDir()).filter((name) => name.endsWith('.log')).sort();
|
|
141
|
+
for (const stale of kept.slice(0, Math.max(0, kept.length - 10))) {
|
|
142
|
+
try {
|
|
143
|
+
fs.unlinkSync(path.join(runLogDir(), stale));
|
|
144
|
+
} catch (err) {
|
|
145
|
+
// A log that will not delete is not worth a failed wake.
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return file;
|
|
149
|
+
} catch (err) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function latestRunLog() {
|
|
155
|
+
try {
|
|
156
|
+
const names = fs.readdirSync(runLogDir()).filter((name) => name.endsWith('.log')).sort();
|
|
157
|
+
if (!names.length) return null;
|
|
158
|
+
const file = path.join(runLogDir(), names[names.length - 1]);
|
|
159
|
+
return { file, text: fs.readFileSync(file, 'utf8') };
|
|
160
|
+
} catch (err) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
85
165
|
function planFile(id) {
|
|
86
166
|
return path.join(configDir(), 'usage-limits-relay-' + String(id).replace(/[^A-Za-z0-9_-]/g, '') + '.md');
|
|
87
167
|
}
|
|
@@ -165,6 +245,14 @@ function settings(state) {
|
|
|
165
245
|
model: typeof stored.model === 'string' ? stored.model : DEFAULTS.model,
|
|
166
246
|
attempts: Math.min(10, Math.max(1, number(stored.attempts, DEFAULTS.attempts))),
|
|
167
247
|
whenBusy: pick(stored.whenBusy, ['notify', 'resume'], DEFAULTS.whenBusy),
|
|
248
|
+
armOn: pick(env.USAGE_LIMITS_RELAY_ARM_ON, ['threshold', 'completion'], pick(stored.armOn, ['threshold', 'completion'], DEFAULTS.armOn)),
|
|
249
|
+
backstopAt: Math.min(100, Math.max(10, number(stored.backstopAt, DEFAULTS.backstopAt))),
|
|
250
|
+
offlineAttempts: Math.min(48, Math.max(1, number(stored.offlineAttempts, DEFAULTS.offlineAttempts))),
|
|
251
|
+
offlineRetryMinutes: Math.min(120, Math.max(2, number(stored.offlineRetryMinutes, DEFAULTS.offlineRetryMinutes))),
|
|
252
|
+
show: bool(stored.show, DEFAULTS.show),
|
|
253
|
+
onFailure: pick(stored.onFailure, ['rearm', 'stop'], DEFAULTS.onFailure),
|
|
254
|
+
maxRearms: Math.min(10, Math.max(0, number(stored.maxRearms, DEFAULTS.maxRearms))),
|
|
255
|
+
runLog: bool(stored.runLog, DEFAULTS.runLog),
|
|
168
256
|
};
|
|
169
257
|
}
|
|
170
258
|
|
|
@@ -603,6 +691,17 @@ function armable(input) {
|
|
|
603
691
|
if (!Number.isFinite(binding.resetsAt)) return { ok: false, why: 'the window has no known reset time' };
|
|
604
692
|
if (!options.sessionId) return { ok: false, why: 'no session id' };
|
|
605
693
|
if (!options.work || !options.work.hasWork) return { ok: false, why: 'no plan or unfinished todo list to carry' };
|
|
694
|
+
// The boundary rule. Past the threshold but not yet at the backstop, arming
|
|
695
|
+
// waits for the reply to finish - the Stop hook passes atCompletion and this
|
|
696
|
+
// is the only caller that does. Mid-reply callers get told to wait, and the
|
|
697
|
+
// brief says so rather than reporting a silent nothing.
|
|
698
|
+
if (config.armOn === 'completion' && !options.atCompletion && binding.percentUsed < config.backstopAt) {
|
|
699
|
+
return {
|
|
700
|
+
ok: false,
|
|
701
|
+
pending: true,
|
|
702
|
+
why: 'waiting for this reply to finish before arming (past ' + config.backstopAt + ' per cent it stops waiting)',
|
|
703
|
+
};
|
|
704
|
+
}
|
|
606
705
|
return { ok: true };
|
|
607
706
|
}
|
|
608
707
|
|
|
@@ -610,7 +709,14 @@ function arm(input) {
|
|
|
610
709
|
const options = input || {};
|
|
611
710
|
const now = Number.isFinite(options.now) ? options.now : Date.now();
|
|
612
711
|
const config = options.config || settings();
|
|
613
|
-
|
|
712
|
+
// `at` is an exact wake time, for work deferred to a moment somebody named
|
|
713
|
+
// rather than to a window reset. The grace minutes exist so the meter has
|
|
714
|
+
// really turned over before a resume fires; a time a person typed does not
|
|
715
|
+
// want fifteen minutes added to it. Still floored a minute out, because a
|
|
716
|
+
// task registered for a moment already past fires immediately.
|
|
717
|
+
const when = Number.isFinite(options.at)
|
|
718
|
+
? Math.max(options.at, now + MINUTE)
|
|
719
|
+
: wakeAt(options.resetsAt, config.graceMinutes, now);
|
|
614
720
|
if (!when) return { ok: false, error: 'no reset time to wake after' };
|
|
615
721
|
|
|
616
722
|
const state = read();
|
|
@@ -800,6 +906,110 @@ async function armByHand(rest) {
|
|
|
800
906
|
);
|
|
801
907
|
}
|
|
802
908
|
|
|
909
|
+
/* ------------------------------------------------------------- doctor ----- */
|
|
910
|
+
|
|
911
|
+
// Everything that has to be true hours from now, checked while somebody is
|
|
912
|
+
// still here to fix it.
|
|
913
|
+
//
|
|
914
|
+
// The relay's whole promise is that it works unattended, and every part of it
|
|
915
|
+
// fails quietly: a CLI that moved, a permission mode nobody set so the run sits
|
|
916
|
+
// waiting for an approval, a machine that sleeps through its own wake, a
|
|
917
|
+
// network that is not there. Each of those has cost a whole window at least
|
|
918
|
+
// once. This asks all of them at once and says which would bite.
|
|
919
|
+
async function doctor(now) {
|
|
920
|
+
const at = Number.isFinite(now) ? now : Date.now();
|
|
921
|
+
const state = read();
|
|
922
|
+
const config = settings(state);
|
|
923
|
+
const caps = capabilities();
|
|
924
|
+
const checks = [];
|
|
925
|
+
const add = (name, ok, detail, severity) => checks.push({ name, ok, detail, severity: severity || (ok ? 'ok' : 'error') });
|
|
926
|
+
|
|
927
|
+
add('relay enabled', config.enabled, config.enabled ? 'on, arming at ' + config.at + ' per cent' : 'off - nothing will ever be scheduled');
|
|
928
|
+
add('delivery mode', true, config.mode === 'resume' ? 'resume: it starts the CLI itself' : 'notify: it raises a toast and leaves the plan on disk', 'ok');
|
|
929
|
+
add('arming point', true, config.armOn === 'completion'
|
|
930
|
+
? 'completion - it waits for the reply to finish, and stops waiting past ' + config.backstopAt + ' per cent'
|
|
931
|
+
: 'threshold - it arms the moment the window crosses ' + config.at + ' per cent, even mid-reply', 'ok');
|
|
932
|
+
|
|
933
|
+
// The one that has cost the most windows: a headless resume starts in the
|
|
934
|
+
// default permission mode, so a run that needs to edit a file stops and asks
|
|
935
|
+
// a person who is asleep.
|
|
936
|
+
if (config.mode === 'resume') {
|
|
937
|
+
add('permission mode', Boolean(config.permissionMode),
|
|
938
|
+
config.permissionMode
|
|
939
|
+
? '--permission-mode ' + config.permissionMode
|
|
940
|
+
: 'not set - the resumed run will stop at the first approval and wait for nobody. Set: relay permission acceptEdits');
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const cli = caps.claude || caps.codex;
|
|
944
|
+
add('a CLI to resume with', Boolean(cli), cli || 'neither the claude nor the codex CLI could be found on PATH');
|
|
945
|
+
|
|
946
|
+
const net = await require('./net.js').reachable({ timeoutMs: 8000 });
|
|
947
|
+
add('network to the API', net.online, net.detail,
|
|
948
|
+
net.online ? 'ok' : net.reason === 'intercepted' ? 'error' : 'warning');
|
|
949
|
+
|
|
950
|
+
// Can a task actually be registered by this account? Registering and removing
|
|
951
|
+
// a throwaway is the only honest answer; asking the policy is not.
|
|
952
|
+
if (process.platform === 'win32') {
|
|
953
|
+
const probeName = 'usage-limits-doctor-probe';
|
|
954
|
+
const registered = scheduleWindows(at + 6 * 60 * MINUTE, [path.join(__dirname, 'wake.js'), '--id', 'doctor-probe'], probeName, os.homedir(), at + 20000);
|
|
955
|
+
add('scheduled tasks', registered.ok, registered.ok
|
|
956
|
+
? 'registered a test task via ' + registered.how + (registered.how === 'schtasks'
|
|
957
|
+
? ' - the PowerShell path failed, so a sleeping machine will miss its wake'
|
|
958
|
+
: ' - StartWhenAvailable and WakeToRun are set, so a machine that was off or asleep still runs it')
|
|
959
|
+
: registered.error, registered.ok && registered.how === 'schtasks' ? 'warning' : undefined);
|
|
960
|
+
if (registered.ok) cancelSchedule(probeName);
|
|
961
|
+
|
|
962
|
+
// A wake at 3am is no use if the machine hibernates at midnight and the
|
|
963
|
+
// task is not allowed to wake it. This reads the actual power policy.
|
|
964
|
+
const power = spawnSync('powercfg.exe', ['/query', 'SCHEME_CURRENT', 'SUB_SLEEP'], { encoding: 'utf8', windowsHide: true, timeout: 8000 });
|
|
965
|
+
const denied = /Allow wake timers[\s\S]{0,600}?Current AC Power Setting Index: 0x00000000/i.test(power.stdout || '');
|
|
966
|
+
add('wake timers', !denied,
|
|
967
|
+
denied
|
|
968
|
+
? 'wake timers are disabled on AC power, so a sleeping machine will not wake for the relay. Fix in Windows power settings, or: powercfg /setacvalueindex SCHEME_CURRENT SUB_SLEEP RTCWAKE 1'
|
|
969
|
+
: 'the power plan allows a scheduled task to wake this machine',
|
|
970
|
+
denied ? 'warning' : 'ok');
|
|
971
|
+
} else {
|
|
972
|
+
add('scheduled wake', true, 'posix: at(1) or launchd, checked at arming time', 'ok');
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
add('config directory writable', (() => {
|
|
976
|
+
try {
|
|
977
|
+
fs.mkdirSync(configDir(), { recursive: true });
|
|
978
|
+
const probe = path.join(configDir(), '.doctor');
|
|
979
|
+
fs.writeFileSync(probe, 'x');
|
|
980
|
+
fs.unlinkSync(probe);
|
|
981
|
+
return true;
|
|
982
|
+
} catch (err) {
|
|
983
|
+
return false;
|
|
984
|
+
}
|
|
985
|
+
})(), configDir());
|
|
986
|
+
|
|
987
|
+
if (state.armed) {
|
|
988
|
+
const continuation = readContinuation(state.armed.id);
|
|
989
|
+
add('continuation saved', Boolean(continuation && continuation.trim()),
|
|
990
|
+
continuation && continuation.trim()
|
|
991
|
+
? continuation.trim().length + ' characters - the resumed run knows what it is picking up'
|
|
992
|
+
: 'nothing written. The wake would hand back only the todo list, which is thinner than a paragraph the session wrote itself. Use: relay note "<what to do next>"',
|
|
993
|
+
'warning');
|
|
994
|
+
add('wake time', Number.isFinite(state.armed.wakeAt),
|
|
995
|
+
new Date(state.armed.wakeAt).toLocaleString() + ' (' + formatWait(state.armed.wakeAt - at) + ' from now) via ' + state.armed.how);
|
|
996
|
+
} else {
|
|
997
|
+
add('armed', false, 'nothing is armed yet', 'warning');
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const errors = checks.filter((c) => c.severity === 'error');
|
|
1001
|
+
const warnings = checks.filter((c) => c.severity === 'warning');
|
|
1002
|
+
const mark = (c) => (c.severity === 'ok' ? ' ok ' : c.severity === 'warning' ? ' warn ' : ' FAIL ');
|
|
1003
|
+
const lines = checks.map((c) => mark(c) + c.name.padEnd(26) + ' ' + c.detail);
|
|
1004
|
+
lines.unshift(errors.length
|
|
1005
|
+
? errors.length + ' thing' + (errors.length === 1 ? '' : 's') + ' would stop the relay working.'
|
|
1006
|
+
: warnings.length
|
|
1007
|
+
? 'Nothing would stop it, but ' + warnings.length + ' thing' + (warnings.length === 1 ? ' is' : 's are') + ' worth fixing.'
|
|
1008
|
+
: 'Everything the relay needs is in place.');
|
|
1009
|
+
lines.unshift('');
|
|
1010
|
+
return { text: lines.join('\n'), checks, errors: errors.length, warnings: warnings.length };
|
|
1011
|
+
}
|
|
1012
|
+
|
|
803
1013
|
/* ----------------------------------------------------------------- cli ---- */
|
|
804
1014
|
|
|
805
1015
|
function main(argv) {
|
|
@@ -885,7 +1095,53 @@ function main(argv) {
|
|
|
885
1095
|
// tool into its rollouts, so nothing there ever reads as work to carry; and
|
|
886
1096
|
// a person can have a project in their head that is in no todo list.
|
|
887
1097
|
if (command === 'arm') return armByHand(rest);
|
|
1098
|
+
if (command === 'armon' || command === 'arm-on') {
|
|
1099
|
+
if (!['threshold', 'completion'].includes(String(value))) {
|
|
1100
|
+
return 'Arming is "threshold" (the moment the window crosses the mark, mid-reply) or "completion" (the end of the reply).';
|
|
1101
|
+
}
|
|
1102
|
+
const config = configure({ armOn: value });
|
|
1103
|
+
return config.armOn === 'completion'
|
|
1104
|
+
? 'Arming at completion. Crossing ' + config.at + ' per cent no longer arms anything by itself - the relay arms when the reply it is watching finishes, so what it carries is work that reached a boundary rather than a state it was halfway through. Past ' + config.backstopAt + ' per cent it stops waiting and arms anyway, because a completion that never comes is a relay that was never armed.'
|
|
1105
|
+
: 'Arming at the threshold. It arms the moment the window crosses ' + config.at + ' per cent, even mid-reply.';
|
|
1106
|
+
}
|
|
1107
|
+
if (command === 'backstop') {
|
|
1108
|
+
if (!value) return 'Give a percentage, for example: relay backstop 95';
|
|
1109
|
+
const config = configure({ backstopAt: Number(value) });
|
|
1110
|
+
return 'Past ' + config.backstopAt + ' per cent it stops waiting for a completion and arms immediately.';
|
|
1111
|
+
}
|
|
1112
|
+
if (command === 'show') {
|
|
1113
|
+
const on = !['off', 'false', 'no', '0'].includes(String(value || 'on').toLowerCase());
|
|
1114
|
+
configure({ show: on });
|
|
1115
|
+
return on
|
|
1116
|
+
? 'The resumed run will open in a window you can see, and its full output is kept either way (relay log --run).'
|
|
1117
|
+
: 'The resumed run will be invisible. Its output is still kept: relay log --run.';
|
|
1118
|
+
}
|
|
1119
|
+
if (command === 'onfailure' || command === 'on-failure') {
|
|
1120
|
+
if (!['rearm', 'stop'].includes(String(value))) return 'On failure: "rearm" (try again next window) or "stop" (leave it to a person).';
|
|
1121
|
+
const config = configure({ onFailure: value });
|
|
1122
|
+
return config.onFailure === 'rearm'
|
|
1123
|
+
? 'A wake that fails will arm again for the next window rather than being lost, up to ' + config.maxRearms +
|
|
1124
|
+
' time' + (config.maxRearms === 1 ? '' : 's') + ' - so the work gets ' + (config.maxRearms + 1) + ' windows to happen in, and then it stops.'
|
|
1125
|
+
: 'A wake that fails will stop and leave a note.';
|
|
1126
|
+
}
|
|
1127
|
+
if (command === 'rearms') {
|
|
1128
|
+
if (!value) return 'Give a count, for example: relay rearms 2';
|
|
1129
|
+
const config = configure({ maxRearms: Number(value) });
|
|
1130
|
+
return 'A failing wake will arm itself again at most ' + config.maxRearms + ' time' + (config.maxRearms === 1 ? '' : 's') + '.';
|
|
1131
|
+
}
|
|
1132
|
+
if (command === 'offline') {
|
|
1133
|
+
if (!value) return 'Give a number of retries, for example: relay offline 12';
|
|
1134
|
+
const config = configure({ offlineAttempts: Number(value) });
|
|
1135
|
+
return 'A machine that cannot reach the API will retry ' + config.offlineAttempts + ' times, ' +
|
|
1136
|
+
config.offlineRetryMinutes + ' minutes apart at first and backing off from there. Being offline never counts as a failed run.';
|
|
1137
|
+
}
|
|
1138
|
+
if (command === 'doctor' || command === 'check') return doctor(Date.now()).then((r) => r.text);
|
|
888
1139
|
if (command === 'log') {
|
|
1140
|
+
if (rest.includes('--run')) {
|
|
1141
|
+
const latest = latestRunLog();
|
|
1142
|
+
if (!latest) return 'No resumed run has been recorded yet.';
|
|
1143
|
+
return latest.file + '\n\n' + latest.text.split('\n').slice(-120).join('\n');
|
|
1144
|
+
}
|
|
889
1145
|
try {
|
|
890
1146
|
return fs.readFileSync(logFile(), 'utf8').split('\n').slice(-20).join('\n');
|
|
891
1147
|
} catch (err) {
|
|
@@ -893,7 +1149,10 @@ function main(argv) {
|
|
|
893
1149
|
}
|
|
894
1150
|
}
|
|
895
1151
|
return [
|
|
896
|
-
'usage: relay.js [status|on|off|at N|grace N|mode notify|resume|permission MODE|model NAME|
|
|
1152
|
+
'usage: relay.js [status|on|off|at N|grace N|mode notify|resume|permission MODE|model NAME|',
|
|
1153
|
+
' thinking off|resume|always|armon threshold|completion|backstop N|',
|
|
1154
|
+
' show on|off|onfailure rearm|stop|offline N|doctor|',
|
|
1155
|
+
' arm [--session ID] [TEXT]|note TEXT|cancel|log [--run]]',
|
|
897
1156
|
'',
|
|
898
1157
|
status(Date.now()),
|
|
899
1158
|
].join('\n');
|
|
@@ -954,4 +1213,9 @@ module.exports = {
|
|
|
954
1213
|
disarm,
|
|
955
1214
|
status,
|
|
956
1215
|
formatWait,
|
|
1216
|
+
doctor,
|
|
1217
|
+
runLogDir,
|
|
1218
|
+
runLogFile,
|
|
1219
|
+
writeRunLog,
|
|
1220
|
+
latestRunLog,
|
|
957
1221
|
};
|
|
@@ -18,6 +18,94 @@ const tally = require('./tally.js');
|
|
|
18
18
|
const activity = require('./activity.js');
|
|
19
19
|
const mode = require('./mode.js');
|
|
20
20
|
const drift = require('./drift.js');
|
|
21
|
+
const relay = require('./relay.js');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
/* ---------------------------------------------- arming at the boundary ---- */
|
|
26
|
+
|
|
27
|
+
// The relay used to arm the instant the window crossed its mark, which is
|
|
28
|
+
// somewhere in the middle of a reply: halfway through a tool call, with a todo
|
|
29
|
+
// list that has not been updated since three steps ago. What it carried was a
|
|
30
|
+
// snapshot of an interruption.
|
|
31
|
+
//
|
|
32
|
+
// This is the other end of that. The Stop hook is the one moment in a session
|
|
33
|
+
// that is definitionally a boundary - the reply is finished, the todo list is
|
|
34
|
+
// current, and whatever the session wrote as a continuation is written. Arming
|
|
35
|
+
// here means the thing handed to the next session describes work that reached
|
|
36
|
+
// a stopping point.
|
|
37
|
+
//
|
|
38
|
+
// It is deliberately cheap in the case that matters, which is the relay being
|
|
39
|
+
// off: three file reads and a return. Nothing here may throw, and nothing here
|
|
40
|
+
// may delay the hook - a tally line the user is waiting for must not wait on a
|
|
41
|
+
// scheduled task.
|
|
42
|
+
const WINDOW_LABELS = { five_hour: '5-hour', seven_day: 'weekly', seven_day_opus: 'weekly (Opus)', seven_day_sonnet: 'weekly (Sonnet)' };
|
|
43
|
+
|
|
44
|
+
function bindingFromCollect(collected, now) {
|
|
45
|
+
const utilization = collected && collected.utilization;
|
|
46
|
+
if (!utilization) return null;
|
|
47
|
+
const windows = [];
|
|
48
|
+
for (const key of Object.keys(utilization)) {
|
|
49
|
+
const bucket = utilization[key];
|
|
50
|
+
if (!bucket || typeof bucket.utilization !== 'number') continue;
|
|
51
|
+
const resetsAt = bucket.resets_at ? Date.parse(bucket.resets_at) : NaN;
|
|
52
|
+
windows.push({
|
|
53
|
+
key,
|
|
54
|
+
label: WINDOW_LABELS[key] || key.replace(/_/g, ' '),
|
|
55
|
+
percentUsed: bucket.utilization,
|
|
56
|
+
resetsAt: Number.isFinite(resetsAt) ? resetsAt : null,
|
|
57
|
+
// A per-model weekly for a model this session is not running cannot be
|
|
58
|
+
// the thing that stops the work; bindingWindow already knows that, and
|
|
59
|
+
// this only has to hand it the flag.
|
|
60
|
+
applies: key.indexOf('seven_day_') !== 0 || key === 'seven_day',
|
|
61
|
+
stale: Number.isFinite(collected.snapshotAgeMs) && collected.snapshotAgeMs > 30 * 60 * 1000,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (!windows.length) return null;
|
|
65
|
+
const bound = usage.bindingWindow(windows);
|
|
66
|
+
// A window with no known reset time cannot be woken after, so it is no use
|
|
67
|
+
// to the relay even when it is the one that binds.
|
|
68
|
+
return bound && Number.isFinite(bound.resetsAt) ? bound : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function armAtCompletion(now, hookInput, sessionId) {
|
|
72
|
+
let state;
|
|
73
|
+
try {
|
|
74
|
+
state = relay.read();
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const config = relay.settings(state);
|
|
79
|
+
if (!config.enabled || config.armOn !== 'completion') return null;
|
|
80
|
+
// Already carrying this session forward: nothing to decide.
|
|
81
|
+
if (state.armed && state.armed.id === sessionId) return null;
|
|
82
|
+
|
|
83
|
+
const transcript = hookInput && hookInput.transcript_path ? hookInput.transcript_path : null;
|
|
84
|
+
const work = relay.detectWork(transcript, {});
|
|
85
|
+
if (!work.hasWork) return null;
|
|
86
|
+
|
|
87
|
+
const binding = bindingFromCollect(usage.collect(now), now);
|
|
88
|
+
if (!binding) return null;
|
|
89
|
+
|
|
90
|
+
const able = relay.armable({ config, binding, sessionId, work, atCompletion: true });
|
|
91
|
+
if (!able.ok) return null;
|
|
92
|
+
|
|
93
|
+
const armed = relay.arm({
|
|
94
|
+
now,
|
|
95
|
+
config,
|
|
96
|
+
sessionId,
|
|
97
|
+
binding,
|
|
98
|
+
work,
|
|
99
|
+
resetsAt: binding.resetsAt,
|
|
100
|
+
cwd: (hookInput && hookInput.cwd) || process.cwd(),
|
|
101
|
+
project: path.basename((hookInput && hookInput.cwd) || process.cwd()),
|
|
102
|
+
hostName: usage.currentHost(),
|
|
103
|
+
// The Stop hook is not on the prompt's clock, so registration can be
|
|
104
|
+
// allowed to finish rather than being abandoned half-done.
|
|
105
|
+
deadline: now + 20000,
|
|
106
|
+
});
|
|
107
|
+
return armed.ok ? armed.record : null;
|
|
108
|
+
}
|
|
21
109
|
|
|
22
110
|
async function run(now, hookInput) {
|
|
23
111
|
const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
|
|
@@ -40,6 +128,19 @@ async function run(now, hookInput) {
|
|
|
40
128
|
//
|
|
41
129
|
// USAGE_LIMITS_TALLY stays the independent control, for anyone who wants the
|
|
42
130
|
// briefing and not the cost line.
|
|
131
|
+
// Before the mode gate, deliberately.
|
|
132
|
+
//
|
|
133
|
+
// `off` means the plugin injects nothing and prints nothing. It does not mean
|
|
134
|
+
// a relay the user turned on themselves stops working - that is a promise
|
|
135
|
+
// made in a different place, by a different command, and silencing the brief
|
|
136
|
+
// must not quietly revoke it. The cost is one JSON read that returns
|
|
137
|
+
// immediately when the relay is off, which it is by default.
|
|
138
|
+
try {
|
|
139
|
+
armAtCompletion(now, hookInput, sessionId);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
// A relay that failed to arm is a relay that arms on the next reply.
|
|
142
|
+
}
|
|
143
|
+
|
|
43
144
|
const budget = mode.forSession({ sessionId });
|
|
44
145
|
if (budget.policy.briefStyle === 'none') return '';
|
|
45
146
|
|
|
@@ -99,4 +200,4 @@ if (require.main === module) {
|
|
|
99
200
|
);
|
|
100
201
|
}
|
|
101
202
|
|
|
102
|
-
module.exports = { run };
|
|
203
|
+
module.exports = { run, armAtCompletion, bindingFromCollect };
|