claude-usage-limits 1.11.7 → 1.13.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.
@@ -0,0 +1,312 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // The other end of the relay: what the operating system runs a few minutes
5
+ // after the window resets.
6
+ //
7
+ // Nothing here is on a hook's ten-second clock. It runs on its own, in its own
8
+ // process, long after the session that armed it has gone, so it can afford to
9
+ // check its assumptions before acting - and it has to, because the one thing
10
+ // worse than not resuming is resuming into a limit that has not lifted and
11
+ // burning the first minutes of a fresh window on a refusal.
12
+ //
13
+ // The order is deliberate:
14
+ //
15
+ // 1. Is there still something armed, and is it this one.
16
+ // 2. Has the window actually turned over. The reset time is a prediction;
17
+ // the meter is the fact. If it has not, book another wake and stop.
18
+ // 3. Is a person sitting at this machine right now. If Computer Use is
19
+ // installed it can answer that, and if the answer is yes the default is
20
+ // to leave a notification rather than start a second agent in the same
21
+ // directory as the one they are typing into.
22
+ // 4. Deliver.
23
+ //
24
+ // Every branch ends with a record on disk, because the next session's first
25
+ // job is to say what happened while nobody was watching.
26
+
27
+ const fs = require('fs');
28
+ const os = require('os');
29
+ const path = require('path');
30
+ const { spawnSync } = require('child_process');
31
+
32
+ const relay = require('./relay.js');
33
+ const voice = require('./voice.js');
34
+ const host = require('./host.js');
35
+
36
+ const MINUTE = 60 * 1000;
37
+ const RESUME_TIMEOUT_MS = 3 * 60 * 60 * 1000;
38
+
39
+ function argOf(argv, name) {
40
+ const at = argv.indexOf(name);
41
+ return at === -1 ? null : argv[at + 1] || null;
42
+ }
43
+
44
+ // Settled before anything reads a file. A scheduled task inherits none of the
45
+ // session's environment, so the config directory has to be carried in the
46
+ // command line or every path below points at the wrong account.
47
+ const configDirArg = argOf(process.argv.slice(2), '--config-dir');
48
+ if (configDirArg) process.env.CLAUDE_CONFIG_DIR = configDirArg;
49
+
50
+ // A notification that needs no module installed and no identity registered.
51
+ // The WinRT toast looks better and fails silently when the calling process has
52
+ // no app identity, which a scheduled task frequently does not; a balloon from
53
+ // the in-box Forms assembly has shown up every time.
54
+ function toast(title, body) {
55
+ if (process.platform === 'win32') {
56
+ const script = [
57
+ 'Add-Type -AssemblyName System.Windows.Forms',
58
+ 'Add-Type -AssemblyName System.Drawing',
59
+ '$icon = New-Object System.Windows.Forms.NotifyIcon',
60
+ '$icon.Icon = [System.Drawing.SystemIcons]::Information',
61
+ '$icon.Visible = $true',
62
+ '$icon.ShowBalloonTip(15000, ' + relay.psQuote(title) + ', ' + relay.psQuote(body) + ', [System.Windows.Forms.ToolTipIcon]::Info)',
63
+ 'Start-Sleep -Seconds 12',
64
+ '$icon.Dispose()',
65
+ ].join('\n');
66
+ spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
67
+ encoding: 'utf8',
68
+ windowsHide: true,
69
+ timeout: 30000,
70
+ });
71
+ return;
72
+ }
73
+ if (process.platform === 'darwin') {
74
+ spawnSync('osascript', ['-e', 'display notification ' + JSON.stringify(body) + ' with title ' + JSON.stringify(title)], {
75
+ encoding: 'utf8',
76
+ timeout: 15000,
77
+ });
78
+ return;
79
+ }
80
+ spawnSync('notify-send', [title, body], { encoding: 'utf8', timeout: 15000 });
81
+ }
82
+
83
+ // Has the window really opened? The endpoint is the only thing that knows, and
84
+ // it is worth four seconds to ask rather than trusting arithmetic done an hour
85
+ // ago on a reset time that can move.
86
+ async function windowReopened(record, now) {
87
+ try {
88
+ if (record.host === host.CODEX) {
89
+ const codex = require('./codex.js');
90
+ await codex.refresh({ now, timeoutMs: 8000 }).catch(() => null);
91
+ const collected = codex.collect(now);
92
+ const percent = collected && collected.utilization ? Number(collected.utilization.primary) : null;
93
+ return { known: Number.isFinite(percent), percent };
94
+ }
95
+ const live = require('./live.js');
96
+ const usage = require('./usage.js');
97
+ await live.refresh({ now, accountUuid: usage.accountUuid(), timeoutMs: 8000 }).catch(() => null);
98
+ const collected = usage.collect(now);
99
+ const utilization = collected && collected.utilization;
100
+ if (!utilization) return { known: false, percent: null };
101
+ // The window the relay was armed against, by the key it was armed with.
102
+ // Each entry is { utilization, resets_at, ... }; reading a flattened
103
+ // "five_hour_utilization" off the top gave undefined every time, which read
104
+ // as "cannot tell" and quietly disabled this whole check.
105
+ const bucket = utilization[record.windowKey || 'five_hour'];
106
+ const value = bucket && typeof bucket.utilization === 'number' ? bucket.utilization : null;
107
+ return { known: value !== null, percent: value };
108
+ } catch (err) {
109
+ return { known: false, percent: null, error: err.message };
110
+ }
111
+ }
112
+
113
+ // Is somebody at the keyboard? Computer Use knows, because it tracks physical
114
+ // input to stay out of the user's way. Without it, assume nobody is: the relay
115
+ // only ever fires after a window that was spent to exhaustion, which is not
116
+ // usually a moment somebody is still sitting there.
117
+ function userIsPresent(cli) {
118
+ if (!cli) return { known: false, present: false };
119
+ const run = spawnSync(process.execPath, [cli, '--json', 'status'], {
120
+ encoding: 'utf8',
121
+ timeout: 45000,
122
+ windowsHide: true,
123
+ env: Object.assign({}, process.env, { CLI_QUIET: '1', CU_OVERLAY: 'off' }),
124
+ });
125
+ if (run.status !== 0 || !run.stdout) return { known: false, present: false };
126
+ try {
127
+ const parsed = JSON.parse(run.stdout);
128
+ const text = JSON.stringify(parsed);
129
+ // The status report names the user when it has seen them recently. This
130
+ // is a coarse read on purpose: a false "present" costs a notification
131
+ // instead of a resume, which is the safe way round.
132
+ return { known: true, present: /user (?:is )?active|user_active|physical input/i.test(text) };
133
+ } catch (err) {
134
+ return { known: false, present: false };
135
+ }
136
+ }
137
+
138
+ // None of this is restored by --resume: a headless resume starts in the
139
+ // permission mode a fresh -p run would, so a session that was running with
140
+ // edits accepted comes back asking a person who is not there.
141
+ function claudeArgs(record, prompt, config, fallback) {
142
+ const args = fallback ? ['--continue', '-p', prompt] : ['--resume', record.id, '-p', prompt];
143
+ if (config.permissionMode) args.push('--permission-mode', config.permissionMode);
144
+ if (!fallback && config.model) args.push('--model', config.model);
145
+ return args;
146
+ }
147
+
148
+ function deliverClaude(record, prompt, config, cli) {
149
+ const args = claudeArgs(record, prompt, config, false);
150
+ const run = spawnSync(cli, args, {
151
+ encoding: 'utf8',
152
+ cwd: record.cwd,
153
+ timeout: RESUME_TIMEOUT_MS,
154
+ windowsHide: true,
155
+ shell: process.platform === 'win32' && /\.(cmd|bat)$/i.test(cli),
156
+ });
157
+ if (run.status === 0) return { ok: true, how: 'claude --resume' };
158
+ const detail = ((run.stderr || run.stdout || '') + '').trim().split('\n')[0];
159
+ // A session id that no longer resolves is the one failure worth a second
160
+ // attempt: the work still needs doing, only the thread is gone.
161
+ if (/No conversation found/i.test(detail)) {
162
+ const second = spawnSync(cli, claudeArgs(record, prompt, config, true), {
163
+ encoding: 'utf8',
164
+ cwd: record.cwd,
165
+ timeout: RESUME_TIMEOUT_MS,
166
+ windowsHide: true,
167
+ shell: process.platform === 'win32' && /\.(cmd|bat)$/i.test(cli),
168
+ });
169
+ if (second.status === 0) return { ok: true, how: 'claude --continue' };
170
+ return { ok: false, error: ((second.stderr || second.stdout || '') + '').trim().split('\n')[0] || detail };
171
+ }
172
+ return { ok: false, error: detail || 'claude exited ' + run.status };
173
+ }
174
+
175
+ function deliverCodex(record, prompt, cli) {
176
+ // Codex keeps interactive sessions on a local app server, and queue is the
177
+ // supported way to put a message into one from outside. If the thread is
178
+ // gone, exec resume does the same work in a fresh process.
179
+ const queued = spawnSync(cli, ['queue', '--thread', record.id, '--message', prompt], {
180
+ encoding: 'utf8',
181
+ cwd: record.cwd,
182
+ timeout: 60000,
183
+ windowsHide: true,
184
+ });
185
+ if (queued.status === 0) return { ok: true, how: 'codex queue' };
186
+ const run = spawnSync(cli, ['exec', 'resume', record.id, prompt, '--skip-git-repo-check'], {
187
+ encoding: 'utf8',
188
+ cwd: record.cwd,
189
+ timeout: RESUME_TIMEOUT_MS,
190
+ windowsHide: true,
191
+ });
192
+ if (run.status === 0) return { ok: true, how: 'codex exec resume' };
193
+ return { ok: false, error: ((run.stderr || run.stdout || '') + '').trim().split('\n')[0] || 'codex exited ' + run.status };
194
+ }
195
+
196
+ function finish(state, record, outcome, detail, now) {
197
+ state.history.push(
198
+ Object.assign({}, record, { endedAt: now, outcome, detail: detail || null })
199
+ );
200
+ state.history = state.history.slice(-10);
201
+ state.armed = null;
202
+ relay.write(state);
203
+ relay.note('wake ' + record.id + ': ' + outcome + (detail ? ' - ' + detail : ''), now);
204
+ // Last, deliberately: this deletes the task this process is running under,
205
+ // so everything that had to be recorded is already on disk before it runs.
206
+ if (record.task) {
207
+ try {
208
+ relay.cancelSchedule(record.task);
209
+ } catch (err) {
210
+ // The expiry set at registration removes it either way.
211
+ }
212
+ }
213
+ }
214
+
215
+ async function run(now, argv) {
216
+ const id = argOf(argv, '--id');
217
+ const state = relay.read();
218
+ const record = state.armed;
219
+ if (!record) return { outcome: 'nothing-armed' };
220
+ if (id && record.id !== id) return { outcome: 'superseded' };
221
+
222
+ const config = relay.settings(state);
223
+ const capabilities = relay.capabilities();
224
+
225
+ const reopened = await windowReopened(record, now);
226
+ // Only reschedule on a reading that says the window is still full. An
227
+ // unreadable meter is not evidence of a limit, and refusing to act on it
228
+ // would turn every offline moment into a cancelled relay.
229
+ if (reopened.known && Number.isFinite(reopened.percent) && reopened.percent >= config.at) {
230
+ const attempt = (record.attempt || 0) + 1;
231
+ if (attempt >= config.attempts) {
232
+ toast('Usage limits', 'The window still reads ' + Math.round(reopened.percent) + ' per cent after ' + attempt + ' checks. The plan is saved; pick it up when you are ready.');
233
+ finish(state, record, 'gave-up', 'window still at ' + Math.round(reopened.percent) + '%', now);
234
+ return { outcome: 'gave-up' };
235
+ }
236
+ const again = relay.arm({
237
+ now,
238
+ sessionId: record.id,
239
+ cwd: record.cwd,
240
+ hostName: record.host,
241
+ resetsAt: now + config.graceMinutes * MINUTE,
242
+ binding: { percentUsed: reopened.percent, resetsAt: now + config.graceMinutes * MINUTE },
243
+ work: Object.assign({ hasWork: true, pending: 1, source: null, todos: [] }, record.work || {}),
244
+ config,
245
+ });
246
+ if (again.ok) {
247
+ const held = relay.read();
248
+ held.armed.attempt = attempt;
249
+ held.armed.continuation = record.continuation;
250
+ relay.write(held);
251
+ }
252
+ relay.note('wake ' + record.id + ': window still at ' + Math.round(reopened.percent) + '%, retry ' + attempt, now);
253
+ return { outcome: 'rescheduled', attempt };
254
+ }
255
+
256
+ const continuation = relay.readContinuation(record.id);
257
+ const prompt = relay.compose({
258
+ continuation,
259
+ work: record.work && record.work.todos ? record.work : null,
260
+ thinking: config.thinking !== 'off',
261
+ voice: voice.card(),
262
+ });
263
+
264
+ let mode = config.mode;
265
+ const presence = userIsPresent(config.mode === 'resume' ? capabilities.computerUse : null);
266
+ if (mode === 'resume' && presence.known && presence.present && config.whenBusy === 'notify') {
267
+ mode = 'notify';
268
+ relay.note('wake ' + record.id + ': someone is at the machine, leaving a note instead', now);
269
+ }
270
+
271
+ if (mode === 'notify') {
272
+ toast(
273
+ 'Usage limits: the window has reset',
274
+ 'The plan for ' + (record.project || path.basename(record.cwd)) + ' is ready to pick up. Run: claude --resume ' + record.id.slice(0, 8)
275
+ );
276
+ finish(state, record, 'notified', presence.present ? 'user present' : null, now);
277
+ return { outcome: 'notified' };
278
+ }
279
+
280
+ const cli = record.host === host.CODEX ? capabilities.codex : capabilities.claude;
281
+ if (!cli) {
282
+ toast('Usage limits', 'The window has reset but the ' + record.host + ' CLI could not be found, so the plan was left on disk.');
283
+ finish(state, record, 'no-cli', null, now);
284
+ return { outcome: 'no-cli' };
285
+ }
286
+
287
+ toast('Usage limits: resuming', 'Carrying on with ' + (record.project || path.basename(record.cwd)) + ' where the limit stopped it.');
288
+ const delivered = record.host === host.CODEX ? deliverCodex(record, prompt, cli) : deliverClaude(record, prompt, config, cli);
289
+ if (delivered.ok) {
290
+ toast('Usage limits: done', 'The resumed run finished. Open the session to read it.');
291
+ finish(state, record, 'resumed', delivered.how, now);
292
+ return { outcome: 'resumed', how: delivered.how };
293
+ }
294
+ toast('Usage limits: could not resume', delivered.error || 'The plan is still on disk.');
295
+ finish(state, record, 'failed', delivered.error, now);
296
+ return { outcome: 'failed', error: delivered.error };
297
+ }
298
+
299
+ if (require.main === module) {
300
+ run(Date.now(), process.argv.slice(2)).then(
301
+ (result) => {
302
+ process.stdout.write(JSON.stringify(result) + '\n');
303
+ process.exit(0);
304
+ },
305
+ (err) => {
306
+ relay.note('wake crashed: ' + err.message, Date.now());
307
+ process.exit(0);
308
+ }
309
+ );
310
+ }
311
+
312
+ module.exports = { run, toast, userIsPresent, claudeArgs, deliverClaude, deliverCodex, windowReopened, argOf };