claude-usage-limits 1.23.0 → 1.25.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.
@@ -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
 
@@ -555,7 +643,11 @@ function schedulePosix(when, argv, name, cwd) {
555
643
  encoding: 'utf8',
556
644
  timeout: 20000,
557
645
  });
558
- if (run.status === 0) return { ok: true, how: 'at' };
646
+ // macOS ships `at` but launchd leaves atrun DISABLED by default, so the
647
+ // command succeeds, prints a job id, and the wake never fires. Reporting
648
+ // ok for that is worse than not having it: it is a silent no-op that looks
649
+ // like a scheduled wake. Fall through to the sleeper, which at least runs.
650
+ if (run.status === 0 && process.platform !== 'darwin') return { ok: true, how: 'at' };
559
651
  }
560
652
  try {
561
653
  const child = spawn('sh', ['-c', 'sleep ' + seconds + ' && ' + command], {
@@ -603,6 +695,17 @@ function armable(input) {
603
695
  if (!Number.isFinite(binding.resetsAt)) return { ok: false, why: 'the window has no known reset time' };
604
696
  if (!options.sessionId) return { ok: false, why: 'no session id' };
605
697
  if (!options.work || !options.work.hasWork) return { ok: false, why: 'no plan or unfinished todo list to carry' };
698
+ // The boundary rule. Past the threshold but not yet at the backstop, arming
699
+ // waits for the reply to finish - the Stop hook passes atCompletion and this
700
+ // is the only caller that does. Mid-reply callers get told to wait, and the
701
+ // brief says so rather than reporting a silent nothing.
702
+ if (config.armOn === 'completion' && !options.atCompletion && binding.percentUsed < config.backstopAt) {
703
+ return {
704
+ ok: false,
705
+ pending: true,
706
+ why: 'waiting for this reply to finish before arming (past ' + config.backstopAt + ' per cent it stops waiting)',
707
+ };
708
+ }
606
709
  return { ok: true };
607
710
  }
608
711
 
@@ -610,7 +713,14 @@ function arm(input) {
610
713
  const options = input || {};
611
714
  const now = Number.isFinite(options.now) ? options.now : Date.now();
612
715
  const config = options.config || settings();
613
- const when = wakeAt(options.resetsAt, config.graceMinutes, now);
716
+ // `at` is an exact wake time, for work deferred to a moment somebody named
717
+ // rather than to a window reset. The grace minutes exist so the meter has
718
+ // really turned over before a resume fires; a time a person typed does not
719
+ // want fifteen minutes added to it. Still floored a minute out, because a
720
+ // task registered for a moment already past fires immediately.
721
+ const when = Number.isFinite(options.at)
722
+ ? Math.max(options.at, now + MINUTE)
723
+ : wakeAt(options.resetsAt, config.graceMinutes, now);
614
724
  if (!when) return { ok: false, error: 'no reset time to wake after' };
615
725
 
616
726
  const state = read();
@@ -661,16 +771,41 @@ function arm(input) {
661
771
  }
662
772
  : null,
663
773
  };
664
- if (state.armed && state.armed.id !== id && state.armed.task) cancelSchedule(state.armed.task);
774
+ // Displacing somebody else's live relay is legitimate - one machine, one
775
+ // relay - but it must not be silent. A wake still in the future belonged to
776
+ // work that somebody expected to be picked up.
777
+ if (state.armed && state.armed.id !== id) {
778
+ if (Number.isFinite(state.armed.wakeAt) && state.armed.wakeAt > now) {
779
+ note('displacing the relay armed for ' + state.armed.id + ' (was due ' + new Date(state.armed.wakeAt).toISOString() + ')', now);
780
+ }
781
+ if (state.armed.task) cancelSchedule(state.armed.task);
782
+ }
665
783
  state.armed = record;
666
784
  write(state);
667
785
  note('armed ' + id + ' for ' + new Date(when).toISOString() + ' via ' + scheduled.how, now);
668
786
  return { ok: true, record };
669
787
  }
670
788
 
671
- function disarm(reason, now) {
789
+ // `id` is optional, and when it is given it is a guard rather than a lookup:
790
+ // clear the relay only if the thing armed is the thing the caller means.
791
+ //
792
+ // Without it, disarm clears whatever happens to be armed, and that is not
793
+ // hypothetical. Measured 2026-09-14: a second process armed a throwaway
794
+ // session at 04:57:04 and cleaned it up four seconds later, and the cleanup
795
+ // took a live relay for an unrelated session - armed seven seconds earlier,
796
+ // due to wake five hours later - with it. Nothing reported that, because from
797
+ // disarm's point of view it did exactly what it was asked.
798
+ //
799
+ // A person typing `relay cancel` means "whatever is armed", so the CLI passes
800
+ // no id and the old behaviour stands. Anything that knows which session it is
801
+ // tidying up should say so.
802
+ function disarm(reason, now, id) {
672
803
  const state = read();
673
804
  if (!state.armed) return { ok: true, changed: false };
805
+ if (id && state.armed.id !== id) {
806
+ note('refused to disarm ' + state.armed.id + ' on behalf of ' + id, Number.isFinite(now) ? now : Date.now());
807
+ return { ok: true, changed: false, refused: true, armed: state.armed.id };
808
+ }
674
809
  const record = state.armed;
675
810
  if (record.task) cancelSchedule(record.task);
676
811
  state.history.push(Object.assign({}, record, { endedAt: Number.isFinite(now) ? now : Date.now(), outcome: reason || 'cancelled' }));
@@ -800,6 +935,110 @@ async function armByHand(rest) {
800
935
  );
801
936
  }
802
937
 
938
+ /* ------------------------------------------------------------- doctor ----- */
939
+
940
+ // Everything that has to be true hours from now, checked while somebody is
941
+ // still here to fix it.
942
+ //
943
+ // The relay's whole promise is that it works unattended, and every part of it
944
+ // fails quietly: a CLI that moved, a permission mode nobody set so the run sits
945
+ // waiting for an approval, a machine that sleeps through its own wake, a
946
+ // network that is not there. Each of those has cost a whole window at least
947
+ // once. This asks all of them at once and says which would bite.
948
+ async function doctor(now) {
949
+ const at = Number.isFinite(now) ? now : Date.now();
950
+ const state = read();
951
+ const config = settings(state);
952
+ const caps = capabilities();
953
+ const checks = [];
954
+ const add = (name, ok, detail, severity) => checks.push({ name, ok, detail, severity: severity || (ok ? 'ok' : 'error') });
955
+
956
+ add('relay enabled', config.enabled, config.enabled ? 'on, arming at ' + config.at + ' per cent' : 'off - nothing will ever be scheduled');
957
+ 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');
958
+ add('arming point', true, config.armOn === 'completion'
959
+ ? 'completion - it waits for the reply to finish, and stops waiting past ' + config.backstopAt + ' per cent'
960
+ : 'threshold - it arms the moment the window crosses ' + config.at + ' per cent, even mid-reply', 'ok');
961
+
962
+ // The one that has cost the most windows: a headless resume starts in the
963
+ // default permission mode, so a run that needs to edit a file stops and asks
964
+ // a person who is asleep.
965
+ if (config.mode === 'resume') {
966
+ add('permission mode', Boolean(config.permissionMode),
967
+ config.permissionMode
968
+ ? '--permission-mode ' + config.permissionMode
969
+ : 'not set - the resumed run will stop at the first approval and wait for nobody. Set: relay permission acceptEdits');
970
+ }
971
+
972
+ const cli = caps.claude || caps.codex;
973
+ add('a CLI to resume with', Boolean(cli), cli || 'neither the claude nor the codex CLI could be found on PATH');
974
+
975
+ const net = await require('./net.js').reachable({ timeoutMs: 8000 });
976
+ add('network to the API', net.online, net.detail,
977
+ net.online ? 'ok' : net.reason === 'intercepted' ? 'error' : 'warning');
978
+
979
+ // Can a task actually be registered by this account? Registering and removing
980
+ // a throwaway is the only honest answer; asking the policy is not.
981
+ if (process.platform === 'win32') {
982
+ const probeName = 'usage-limits-doctor-probe';
983
+ const registered = scheduleWindows(at + 6 * 60 * MINUTE, [path.join(__dirname, 'wake.js'), '--id', 'doctor-probe'], probeName, os.homedir(), at + 20000);
984
+ add('scheduled tasks', registered.ok, registered.ok
985
+ ? 'registered a test task via ' + registered.how + (registered.how === 'schtasks'
986
+ ? ' - the PowerShell path failed, so a sleeping machine will miss its wake'
987
+ : ' - StartWhenAvailable and WakeToRun are set, so a machine that was off or asleep still runs it')
988
+ : registered.error, registered.ok && registered.how === 'schtasks' ? 'warning' : undefined);
989
+ if (registered.ok) cancelSchedule(probeName);
990
+
991
+ // A wake at 3am is no use if the machine hibernates at midnight and the
992
+ // task is not allowed to wake it. This reads the actual power policy.
993
+ const power = spawnSync('powercfg.exe', ['/query', 'SCHEME_CURRENT', 'SUB_SLEEP'], { encoding: 'utf8', windowsHide: true, timeout: 8000 });
994
+ const denied = /Allow wake timers[\s\S]{0,600}?Current AC Power Setting Index: 0x00000000/i.test(power.stdout || '');
995
+ add('wake timers', !denied,
996
+ denied
997
+ ? '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'
998
+ : 'the power plan allows a scheduled task to wake this machine',
999
+ denied ? 'warning' : 'ok');
1000
+ } else {
1001
+ add('scheduled wake', true, 'posix: at(1) or launchd, checked at arming time', 'ok');
1002
+ }
1003
+
1004
+ add('config directory writable', (() => {
1005
+ try {
1006
+ fs.mkdirSync(configDir(), { recursive: true });
1007
+ const probe = path.join(configDir(), '.doctor');
1008
+ fs.writeFileSync(probe, 'x');
1009
+ fs.unlinkSync(probe);
1010
+ return true;
1011
+ } catch (err) {
1012
+ return false;
1013
+ }
1014
+ })(), configDir());
1015
+
1016
+ if (state.armed) {
1017
+ const continuation = readContinuation(state.armed.id);
1018
+ add('continuation saved', Boolean(continuation && continuation.trim()),
1019
+ continuation && continuation.trim()
1020
+ ? continuation.trim().length + ' characters - the resumed run knows what it is picking up'
1021
+ : '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>"',
1022
+ 'warning');
1023
+ add('wake time', Number.isFinite(state.armed.wakeAt),
1024
+ new Date(state.armed.wakeAt).toLocaleString() + ' (' + formatWait(state.armed.wakeAt - at) + ' from now) via ' + state.armed.how);
1025
+ } else {
1026
+ add('armed', false, 'nothing is armed yet', 'warning');
1027
+ }
1028
+
1029
+ const errors = checks.filter((c) => c.severity === 'error');
1030
+ const warnings = checks.filter((c) => c.severity === 'warning');
1031
+ const mark = (c) => (c.severity === 'ok' ? ' ok ' : c.severity === 'warning' ? ' warn ' : ' FAIL ');
1032
+ const lines = checks.map((c) => mark(c) + c.name.padEnd(26) + ' ' + c.detail);
1033
+ lines.unshift(errors.length
1034
+ ? errors.length + ' thing' + (errors.length === 1 ? '' : 's') + ' would stop the relay working.'
1035
+ : warnings.length
1036
+ ? 'Nothing would stop it, but ' + warnings.length + ' thing' + (warnings.length === 1 ? ' is' : 's are') + ' worth fixing.'
1037
+ : 'Everything the relay needs is in place.');
1038
+ lines.unshift('');
1039
+ return { text: lines.join('\n'), checks, errors: errors.length, warnings: warnings.length };
1040
+ }
1041
+
803
1042
  /* ----------------------------------------------------------------- cli ---- */
804
1043
 
805
1044
  function main(argv) {
@@ -885,7 +1124,53 @@ function main(argv) {
885
1124
  // tool into its rollouts, so nothing there ever reads as work to carry; and
886
1125
  // a person can have a project in their head that is in no todo list.
887
1126
  if (command === 'arm') return armByHand(rest);
1127
+ if (command === 'armon' || command === 'arm-on') {
1128
+ if (!['threshold', 'completion'].includes(String(value))) {
1129
+ return 'Arming is "threshold" (the moment the window crosses the mark, mid-reply) or "completion" (the end of the reply).';
1130
+ }
1131
+ const config = configure({ armOn: value });
1132
+ return config.armOn === 'completion'
1133
+ ? '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.'
1134
+ : 'Arming at the threshold. It arms the moment the window crosses ' + config.at + ' per cent, even mid-reply.';
1135
+ }
1136
+ if (command === 'backstop') {
1137
+ if (!value) return 'Give a percentage, for example: relay backstop 95';
1138
+ const config = configure({ backstopAt: Number(value) });
1139
+ return 'Past ' + config.backstopAt + ' per cent it stops waiting for a completion and arms immediately.';
1140
+ }
1141
+ if (command === 'show') {
1142
+ const on = !['off', 'false', 'no', '0'].includes(String(value || 'on').toLowerCase());
1143
+ configure({ show: on });
1144
+ return on
1145
+ ? 'The resumed run will open in a window you can see, and its full output is kept either way (relay log --run).'
1146
+ : 'The resumed run will be invisible. Its output is still kept: relay log --run.';
1147
+ }
1148
+ if (command === 'onfailure' || command === 'on-failure') {
1149
+ if (!['rearm', 'stop'].includes(String(value))) return 'On failure: "rearm" (try again next window) or "stop" (leave it to a person).';
1150
+ const config = configure({ onFailure: value });
1151
+ return config.onFailure === 'rearm'
1152
+ ? 'A wake that fails will arm again for the next window rather than being lost, up to ' + config.maxRearms +
1153
+ ' time' + (config.maxRearms === 1 ? '' : 's') + ' - so the work gets ' + (config.maxRearms + 1) + ' windows to happen in, and then it stops.'
1154
+ : 'A wake that fails will stop and leave a note.';
1155
+ }
1156
+ if (command === 'rearms') {
1157
+ if (!value) return 'Give a count, for example: relay rearms 2';
1158
+ const config = configure({ maxRearms: Number(value) });
1159
+ return 'A failing wake will arm itself again at most ' + config.maxRearms + ' time' + (config.maxRearms === 1 ? '' : 's') + '.';
1160
+ }
1161
+ if (command === 'offline') {
1162
+ if (!value) return 'Give a number of retries, for example: relay offline 12';
1163
+ const config = configure({ offlineAttempts: Number(value) });
1164
+ return 'A machine that cannot reach the API will retry ' + config.offlineAttempts + ' times, ' +
1165
+ config.offlineRetryMinutes + ' minutes apart at first and backing off from there. Being offline never counts as a failed run.';
1166
+ }
1167
+ if (command === 'doctor' || command === 'check') return doctor(Date.now()).then((r) => r.text);
888
1168
  if (command === 'log') {
1169
+ if (rest.includes('--run')) {
1170
+ const latest = latestRunLog();
1171
+ if (!latest) return 'No resumed run has been recorded yet.';
1172
+ return latest.file + '\n\n' + latest.text.split('\n').slice(-120).join('\n');
1173
+ }
889
1174
  try {
890
1175
  return fs.readFileSync(logFile(), 'utf8').split('\n').slice(-20).join('\n');
891
1176
  } catch (err) {
@@ -893,7 +1178,10 @@ function main(argv) {
893
1178
  }
894
1179
  }
895
1180
  return [
896
- 'usage: relay.js [status|on|off|at N|grace N|mode notify|resume|permission MODE|model NAME|thinking off|resume|always|arm [--session ID] [TEXT]|note TEXT|cancel|log]',
1181
+ 'usage: relay.js [status|on|off|at N|grace N|mode notify|resume|permission MODE|model NAME|',
1182
+ ' thinking off|resume|always|armon threshold|completion|backstop N|',
1183
+ ' show on|off|onfailure rearm|stop|offline N|doctor|',
1184
+ ' arm [--session ID] [TEXT]|note TEXT|cancel|log [--run]]',
897
1185
  '',
898
1186
  status(Date.now()),
899
1187
  ].join('\n');
@@ -954,4 +1242,9 @@ module.exports = {
954
1242
  disarm,
955
1243
  status,
956
1244
  formatWait,
1245
+ doctor,
1246
+ runLogDir,
1247
+ runLogFile,
1248
+ writeRunLog,
1249
+ latestRunLog,
957
1250
  };
@@ -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 };