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.
@@ -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
- const when = wakeAt(options.resetsAt, config.graceMinutes, now);
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|thinking off|resume|always|arm [--session ID] [TEXT]|note TEXT|cancel|log]',
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 };