claude-usage-limits 1.11.0 → 1.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/usage-limits/scripts/activity.js +13 -3
- package/skills/usage-limits/scripts/feed.js +45 -22
- package/skills/usage-limits/scripts/live.js +86 -19
- package/skills/usage-limits/scripts/panel.js +45 -20
- package/skills/usage-limits/scripts/view.js +6 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usage-limits",
|
|
3
3
|
"displayName": "Usage Limits",
|
|
4
|
-
"version": "1.11.
|
|
4
|
+
"version": "1.11.2",
|
|
5
5
|
"description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Ridelink",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usage-limits",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.2",
|
|
4
4
|
"description": "Reports how much of your Codex usage limit is left as turns of work rather than a percentage, prices a job before you start it, and counts the other agents sharing the same budget.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Ridelink",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-usage-limits",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.2",
|
|
4
4
|
"description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -68,10 +68,20 @@ function mark(state, sessionId, extra, now) {
|
|
|
68
68
|
else if (previous.model) entry.model = previous.model;
|
|
69
69
|
all[sessionId || '_'] = entry;
|
|
70
70
|
const file = activityFile();
|
|
71
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
72
71
|
const temp = file + '.' + process.pid + '.usage-limits-tmp';
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
try {
|
|
73
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
74
|
+
fs.writeFileSync(temp, JSON.stringify(trim(all)), 'utf8');
|
|
75
|
+
fs.renameSync(temp, file);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
// A rename Windows refused leaves nothing behind.
|
|
78
|
+
try {
|
|
79
|
+
fs.unlinkSync(temp);
|
|
80
|
+
} catch (gone) {
|
|
81
|
+
// Nothing to clean up.
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
75
85
|
return true;
|
|
76
86
|
} catch (err) {
|
|
77
87
|
return false;
|
|
@@ -26,6 +26,7 @@ const bars = require('./bars.js');
|
|
|
26
26
|
const view = require('./view.js');
|
|
27
27
|
const activity = require('./activity.js');
|
|
28
28
|
const statusline = require('./statusline.js');
|
|
29
|
+
const live = require('./live.js');
|
|
29
30
|
|
|
30
31
|
const KEEP_SESSIONS = 8;
|
|
31
32
|
// Two updates this close together mean Claude is mid-turn.
|
|
@@ -69,16 +70,24 @@ function readFeed() {
|
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
function writeFeed(all) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
73
|
+
return live.writeAtomic(feedFile(), JSON.stringify(all));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Two updates a few seconds apart mean a turn in progress, unless the status
|
|
77
|
+
// line is on a timer that fires that often anyway, in which case the gap says
|
|
78
|
+
// nothing and only the hooks' marks do.
|
|
79
|
+
function gapMeansWorking(settings) {
|
|
80
|
+
const line = settings && settings.statusLine;
|
|
81
|
+
const every = line && Number.isFinite(line.refreshInterval) ? line.refreshInterval * 1000 : null;
|
|
82
|
+
return !(every !== null && every <= WORKING_GAP_MS);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// This session's own mark, from the hooks. Another window working must not
|
|
86
|
+
// spin this one's line.
|
|
87
|
+
function ownState(marks, sessionId, now) {
|
|
88
|
+
const mine = sessionId && marks ? marks[sessionId] : null;
|
|
89
|
+
if (!mine || !Number.isFinite(mine.at) || now - mine.at > activity.STALE_MS) return { working: false, ultracode: false };
|
|
90
|
+
return { working: mine.state === 'working', ultracode: Boolean(mine.ultracode) };
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
function number(value) {
|
|
@@ -231,13 +240,13 @@ function readStdin() {
|
|
|
231
240
|
|
|
232
241
|
// The status line that was there before ours, run with the same stdin, so
|
|
233
242
|
// installing this one loses nothing.
|
|
234
|
-
function runPrevious(command, raw, env) {
|
|
243
|
+
function runPrevious(command, raw, env, budgetMs) {
|
|
235
244
|
try {
|
|
236
245
|
const result = spawnSync(command, {
|
|
237
246
|
shell: true,
|
|
238
247
|
input: raw,
|
|
239
248
|
encoding: 'utf8',
|
|
240
|
-
timeout: CHAIN_TIMEOUT_MS,
|
|
249
|
+
timeout: Math.max(300, Number.isFinite(budgetMs) ? budgetMs : CHAIN_TIMEOUT_MS),
|
|
241
250
|
env: env || process.env,
|
|
242
251
|
windowsHide: true,
|
|
243
252
|
stdio: ['pipe', 'pipe', 'ignore'],
|
|
@@ -268,9 +277,18 @@ function motionOff(settings, env) {
|
|
|
268
277
|
return Boolean(settings && settings.prefersReducedMotion === true);
|
|
269
278
|
}
|
|
270
279
|
|
|
280
|
+
// Written and flushed before the process is allowed to end: stdout is a pipe
|
|
281
|
+
// here, and a pipe write can still be in flight when process.exit runs.
|
|
282
|
+
function out(text) {
|
|
283
|
+
return new Promise((resolve) => {
|
|
284
|
+
process.stdout.write(text, () => resolve());
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
271
288
|
async function main(argv) {
|
|
272
289
|
const env = process.env;
|
|
273
|
-
const
|
|
290
|
+
const started = Date.now();
|
|
291
|
+
const now = started;
|
|
274
292
|
let chained = '';
|
|
275
293
|
try {
|
|
276
294
|
usage.setHost(host.detect(argv || [], env));
|
|
@@ -285,7 +303,8 @@ async function main(argv) {
|
|
|
285
303
|
|
|
286
304
|
const state = statusline.readState();
|
|
287
305
|
if (state && state.chain && state.previous && state.previous.type === 'command' && state.previous.command) {
|
|
288
|
-
|
|
306
|
+
// Whatever the stdin wait used comes out of the previous line's time.
|
|
307
|
+
chained = runPrevious(state.previous.command, raw, env, CHAIN_TIMEOUT_MS - (Date.now() - started));
|
|
289
308
|
}
|
|
290
309
|
|
|
291
310
|
let all = readFeed();
|
|
@@ -296,11 +315,11 @@ async function main(argv) {
|
|
|
296
315
|
|
|
297
316
|
const off = String(env.USAGE_LIMITS_STATUSLINE || '').toLowerCase();
|
|
298
317
|
if (off === 'off' || off === '0' || off === 'false') {
|
|
299
|
-
if (chained)
|
|
318
|
+
if (chained) await out(chained + '\n');
|
|
300
319
|
return 0;
|
|
301
320
|
}
|
|
302
321
|
if (usage.isCodex()) {
|
|
303
|
-
if (chained)
|
|
322
|
+
if (chained) await out(chained + '\n');
|
|
304
323
|
return 0;
|
|
305
324
|
}
|
|
306
325
|
|
|
@@ -308,9 +327,11 @@ async function main(argv) {
|
|
|
308
327
|
const collected = usage.collect(now);
|
|
309
328
|
const settings = settingsFor(configDir());
|
|
310
329
|
const marks = activity.read();
|
|
311
|
-
const seen = activity.summarise(marks, now);
|
|
312
|
-
// The other sessions working right now, so the line can say so.
|
|
313
330
|
const mine = input && input.session_id ? input.session_id : null;
|
|
331
|
+
// This session's own state; only with no session id at all does the
|
|
332
|
+
// machine-wide picture stand in for it.
|
|
333
|
+
const own = mine ? ownState(marks, mine, now) : activity.summarise(marks, now);
|
|
334
|
+
// The other sessions working right now, so the line can say so.
|
|
314
335
|
const othersWorking = activity
|
|
315
336
|
.combine({ marks, feed: all }, now, activity.STALE_MS)
|
|
316
337
|
.filter((row) => row.state === 'working' && row.sessionId !== mine).length;
|
|
@@ -324,8 +345,8 @@ async function main(argv) {
|
|
|
324
345
|
model: slot ? slot.model : null,
|
|
325
346
|
modelName: slot ? slot.modelName : null,
|
|
326
347
|
effort: slot ? slot.effort : null,
|
|
327
|
-
working: isWorking(slot, now)
|
|
328
|
-
ultracode:
|
|
348
|
+
working: own.working || (gapMeansWorking(settings) && isWorking(slot, now)),
|
|
349
|
+
ultracode: own.ultracode || settings.ultracode === true,
|
|
329
350
|
settingsModel: collected.settings ? collected.settings.model : null,
|
|
330
351
|
env,
|
|
331
352
|
});
|
|
@@ -339,10 +360,10 @@ async function main(argv) {
|
|
|
339
360
|
ascii: String(env.USAGE_LIMITS_ASCII || '') === '1',
|
|
340
361
|
clock: clockFor(settings, env),
|
|
341
362
|
});
|
|
342
|
-
|
|
363
|
+
await out((chained ? chained + '\n' : '') + text + '\n');
|
|
343
364
|
return 0;
|
|
344
365
|
} catch (err) {
|
|
345
|
-
if (chained)
|
|
366
|
+
if (chained) await out(chained + '\n');
|
|
346
367
|
return 0;
|
|
347
368
|
}
|
|
348
369
|
}
|
|
@@ -358,6 +379,8 @@ module.exports = {
|
|
|
358
379
|
record,
|
|
359
380
|
newest,
|
|
360
381
|
isWorking,
|
|
382
|
+
gapMeansWorking,
|
|
383
|
+
ownState,
|
|
361
384
|
line,
|
|
362
385
|
runPrevious,
|
|
363
386
|
clockFor,
|
|
@@ -105,12 +105,21 @@ function readToken(options) {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
if (platform === 'darwin') {
|
|
108
|
+
// The keychain is a child process each time, so a long-lived panel or the
|
|
109
|
+
// VS Code extension remembers the answer for a few minutes.
|
|
110
|
+
const nowMs = Date.now();
|
|
111
|
+
// Only the real keychain is remembered; an injected reader (the tests)
|
|
112
|
+
// is asked every time.
|
|
113
|
+
const remember = opts.cache !== false && !opts.exec;
|
|
114
|
+
if (remember && keychainMemo && nowMs - keychainMemo.at < KEYCHAIN_MEMO_MS) return keychainMemo.result;
|
|
108
115
|
const exec = opts.exec || defaultExec;
|
|
109
116
|
try {
|
|
110
117
|
const out = exec(['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w']);
|
|
111
118
|
const parsed = parseCredentials(String(out || '').trim());
|
|
112
119
|
if (parsed && parsed.token) {
|
|
113
|
-
|
|
120
|
+
const result = { token: parsed.token, expiresAt: parsed.expiresAt, source: 'keychain' };
|
|
121
|
+
if (remember) keychainMemo = { at: nowMs, result };
|
|
122
|
+
return result;
|
|
114
123
|
}
|
|
115
124
|
} catch (err) {
|
|
116
125
|
// No keychain entry, or no permission to read it: same answer as no file.
|
|
@@ -120,6 +129,34 @@ function readToken(options) {
|
|
|
120
129
|
return { token: null, reason: 'no_credentials' };
|
|
121
130
|
}
|
|
122
131
|
|
|
132
|
+
let keychainMemo = null;
|
|
133
|
+
const KEYCHAIN_MEMO_MS = 5 * MINUTE;
|
|
134
|
+
|
|
135
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
|
|
136
|
+
|
|
137
|
+
function isLoopback(hostname) {
|
|
138
|
+
return LOOPBACK.has(String(hostname || '').toLowerCase());
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Where the login may be sent: Anthropic over https, or this machine (the
|
|
142
|
+
// tests run a stub on a loopback port). A project's settings file can put
|
|
143
|
+
// anything into the environment of a hook, and USAGE_LIMITS_USAGE_URL must
|
|
144
|
+
// not be a way to walk off with the token.
|
|
145
|
+
function allowedUrl(raw) {
|
|
146
|
+
if (!raw) return null;
|
|
147
|
+
let url;
|
|
148
|
+
try {
|
|
149
|
+
url = new URL(String(raw));
|
|
150
|
+
} catch (err) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
if (isLoopback(url.hostname)) return url.toString();
|
|
154
|
+
if (url.protocol !== 'https:') return null;
|
|
155
|
+
const host = url.hostname.toLowerCase();
|
|
156
|
+
if (host === 'anthropic.com' || host.endsWith('.anthropic.com')) return url.toString();
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
123
160
|
function bad(kind, status, retryAfterMs, message) {
|
|
124
161
|
return {
|
|
125
162
|
ok: false,
|
|
@@ -150,12 +187,11 @@ function fetchUsage(options) {
|
|
|
150
187
|
return new Promise((resolve) => {
|
|
151
188
|
if (!opts.token) return resolve(bad('no_credentials', null, null, 'no Claude login found'));
|
|
152
189
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
} catch (err) {
|
|
157
|
-
return resolve(bad('bad_response', null, null, 'the usage url is not a url'));
|
|
190
|
+
const permitted = allowedUrl(opts.url || USAGE_URL);
|
|
191
|
+
if (!permitted) {
|
|
192
|
+
return resolve(bad('bad_response', null, null, 'refusing to send the login anywhere but Anthropic over https'));
|
|
158
193
|
}
|
|
194
|
+
const url = new URL(permitted);
|
|
159
195
|
const client = url.protocol === 'http:' ? http : https;
|
|
160
196
|
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
161
197
|
|
|
@@ -242,6 +278,7 @@ function nextDelayMs(outcome, previousMs, options) {
|
|
|
242
278
|
? Math.min(Math.max(outcome.retryAfterMs, 1000), 10 * MINUTE)
|
|
243
279
|
: 60 * 1000;
|
|
244
280
|
case 'unauthorized':
|
|
281
|
+
case 'expired':
|
|
245
282
|
case 'forbidden':
|
|
246
283
|
case 'no_credentials':
|
|
247
284
|
return 30 * 1000;
|
|
@@ -263,6 +300,8 @@ function describe(outcome) {
|
|
|
263
300
|
return 'offline';
|
|
264
301
|
case 'unauthorized':
|
|
265
302
|
return 'sign in to Claude Code again';
|
|
303
|
+
case 'expired':
|
|
304
|
+
return 'login expired, Claude Code renews it on its next call';
|
|
266
305
|
case 'forbidden':
|
|
267
306
|
return 'usage not available for this login';
|
|
268
307
|
case 'no_credentials':
|
|
@@ -287,20 +326,30 @@ function readLive() {
|
|
|
287
326
|
return parsed;
|
|
288
327
|
}
|
|
289
328
|
|
|
290
|
-
// Through a temporary file, so a reader never sees
|
|
291
|
-
|
|
329
|
+
// Through a temporary file named for this process, so a reader never sees
|
|
330
|
+
// half a reading and two writers never share a temp file. A rename that fails
|
|
331
|
+
// (Windows, with the target held open) leaves nothing behind.
|
|
332
|
+
function writeAtomic(file, text) {
|
|
333
|
+
const temp = file + '.' + process.pid + '.usage-limits-tmp';
|
|
292
334
|
try {
|
|
293
|
-
const file = liveFile();
|
|
294
335
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
295
|
-
|
|
296
|
-
fs.writeFileSync(temp, JSON.stringify(snapshot), 'utf8');
|
|
336
|
+
fs.writeFileSync(temp, text, 'utf8');
|
|
297
337
|
fs.renameSync(temp, file);
|
|
298
338
|
return true;
|
|
299
339
|
} catch (err) {
|
|
340
|
+
try {
|
|
341
|
+
fs.unlinkSync(temp);
|
|
342
|
+
} catch (gone) {
|
|
343
|
+
// Nothing to clean up.
|
|
344
|
+
}
|
|
300
345
|
return false;
|
|
301
346
|
}
|
|
302
347
|
}
|
|
303
348
|
|
|
349
|
+
function writeLive(snapshot) {
|
|
350
|
+
return writeAtomic(liveFile(), JSON.stringify(snapshot));
|
|
351
|
+
}
|
|
352
|
+
|
|
304
353
|
function fetchDisabled(env) {
|
|
305
354
|
return String((env || process.env).USAGE_LIMITS_FETCH || '').toLowerCase() === 'off';
|
|
306
355
|
}
|
|
@@ -320,9 +369,14 @@ async function refresh(options) {
|
|
|
320
369
|
snapshot: readLive(),
|
|
321
370
|
};
|
|
322
371
|
}
|
|
372
|
+
// A token past its expiry gets a 401 and nothing else. Claude Code renews it
|
|
373
|
+
// on its own next call, so do not send it, and say what is being waited for.
|
|
374
|
+
if (Number.isFinite(creds.expiresAt) && creds.expiresAt < (Number.isFinite(opts.now) ? opts.now : Date.now())) {
|
|
375
|
+
return { outcome: bad('expired', null, null, 'the login has expired; Claude Code renews it on its next call'), snapshot: readLive() };
|
|
376
|
+
}
|
|
323
377
|
const outcome = await fetchUsage({
|
|
324
378
|
token: creds.token,
|
|
325
|
-
url: opts.url || env.USAGE_LIMITS_USAGE_URL || USAGE_URL,
|
|
379
|
+
url: allowedUrl(opts.url || env.USAGE_LIMITS_USAGE_URL) || USAGE_URL,
|
|
326
380
|
timeoutMs: opts.timeoutMs,
|
|
327
381
|
});
|
|
328
382
|
if (!outcome.ok) return { outcome, snapshot: readLive() };
|
|
@@ -352,11 +406,9 @@ function readAttempt() {
|
|
|
352
406
|
}
|
|
353
407
|
|
|
354
408
|
function writeAttempt(record) {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
// Without it the next caller merely tries again a little sooner.
|
|
359
|
-
}
|
|
409
|
+
// Atomic, so a concurrent reader never parses half a record and skips the
|
|
410
|
+
// backoff by accident.
|
|
411
|
+
writeAtomic(attemptFile(), JSON.stringify(record));
|
|
360
412
|
}
|
|
361
413
|
|
|
362
414
|
// A reading only when the one on disk has aged. This is what the hooks call:
|
|
@@ -381,10 +433,19 @@ async function refreshIfStale(options) {
|
|
|
381
433
|
if (newestAt > 0 && now - newestAt < maxAgeMs) return { outcome: null, snapshot: current, skipped: 'fresh' };
|
|
382
434
|
|
|
383
435
|
const attempt = readAttempt();
|
|
384
|
-
|
|
436
|
+
// A record from a clock that was ahead would hold the backoff until the
|
|
437
|
+
// wall clock caught up with it; a minute of skew is all that is honoured.
|
|
438
|
+
const sinceAttempt = attempt ? now - attempt.attemptedAtMs : 0;
|
|
439
|
+
if (attempt && Number.isFinite(attempt.delayMs) && sinceAttempt >= -MINUTE && sinceAttempt < attempt.delayMs) {
|
|
385
440
|
return { outcome: null, snapshot: current, skipped: 'backoff' };
|
|
386
441
|
}
|
|
387
442
|
|
|
443
|
+
// Claim the attempt before making it, so the other hooks that fire in the
|
|
444
|
+
// same second (parallel agents, several windows) skip rather than each
|
|
445
|
+
// sending its own request and each waiting out its own timeout.
|
|
446
|
+
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
447
|
+
writeAttempt({ attemptedAtMs: now, delayMs: timeoutMs, kind: 'inflight' });
|
|
448
|
+
|
|
388
449
|
const result = await refresh({
|
|
389
450
|
now,
|
|
390
451
|
accountUuid: opts.accountUuid,
|
|
@@ -395,7 +456,10 @@ async function refreshIfStale(options) {
|
|
|
395
456
|
});
|
|
396
457
|
writeAttempt({
|
|
397
458
|
attemptedAtMs: now,
|
|
398
|
-
delayMs: nextDelayMs(result.outcome, attempt ? attempt.delayMs : 0, {
|
|
459
|
+
delayMs: nextDelayMs(result.outcome, attempt && attempt.kind !== 'inflight' ? attempt.delayMs : 0, {
|
|
460
|
+
baseMs: maxAgeMs,
|
|
461
|
+
maxMs: 10 * MINUTE,
|
|
462
|
+
}),
|
|
399
463
|
kind: result.outcome.ok ? 'ok' : result.outcome.kind,
|
|
400
464
|
});
|
|
401
465
|
return { outcome: result.outcome, snapshot: result.snapshot, skipped: null };
|
|
@@ -409,6 +473,9 @@ module.exports = {
|
|
|
409
473
|
credentialsFile,
|
|
410
474
|
liveFile,
|
|
411
475
|
attemptFile,
|
|
476
|
+
allowedUrl,
|
|
477
|
+
isLoopback,
|
|
478
|
+
writeAtomic,
|
|
412
479
|
userAgent,
|
|
413
480
|
readToken,
|
|
414
481
|
fetchUsage,
|
|
@@ -281,7 +281,7 @@ function stateLine(built, mode) {
|
|
|
281
281
|
function noteColour(built) {
|
|
282
282
|
const kind = built.outcome && !built.outcome.ok ? built.outcome.kind : null;
|
|
283
283
|
if (kind === 'unauthorized' || kind === 'forbidden' || kind === 'no_credentials') return bars.THEME.error;
|
|
284
|
-
if (kind === 'offline' || kind === 'rate_limited' || kind === 'server' || kind === 'http' || kind === 'bad_response') {
|
|
284
|
+
if (kind === 'offline' || kind === 'expired' || kind === 'rate_limited' || kind === 'server' || kind === 'http' || kind === 'bad_response') {
|
|
285
285
|
return bars.THEME.warning;
|
|
286
286
|
}
|
|
287
287
|
return null;
|
|
@@ -291,7 +291,10 @@ function noteColour(built) {
|
|
|
291
291
|
// dropping breathing room first and footers second.
|
|
292
292
|
function render(built, options) {
|
|
293
293
|
const opts = options || {};
|
|
294
|
-
|
|
294
|
+
// Laid out for at least MIN_COLUMNS, but cut to the width that really
|
|
295
|
+
// exists: a line wider than the pane wraps, and a wrapped frame scrolls.
|
|
296
|
+
const real = Number.isFinite(opts.columns) ? Math.max(1, Math.floor(opts.columns)) : 40;
|
|
297
|
+
const columns = Math.max(MIN_COLUMNS, real);
|
|
295
298
|
const height = Number.isFinite(opts.rows) ? Math.floor(opts.rows) : null;
|
|
296
299
|
const mode = opts.mode || 'none';
|
|
297
300
|
const tick = Number.isFinite(opts.tick) ? opts.tick : 0;
|
|
@@ -399,7 +402,7 @@ function render(built, options) {
|
|
|
399
402
|
if (height !== null && lines.length > height) lines = compose(false, true);
|
|
400
403
|
if (height !== null && lines.length > height) lines = compose(false, false);
|
|
401
404
|
if (height !== null && lines.length > height) lines = lines.slice(0, height);
|
|
402
|
-
return lines.map((line) => fit(line,
|
|
405
|
+
return lines.map((line) => fit(line, real));
|
|
403
406
|
}
|
|
404
407
|
|
|
405
408
|
function quote(value) {
|
|
@@ -408,7 +411,7 @@ function quote(value) {
|
|
|
408
411
|
|
|
409
412
|
// How to put the panel in a pane to the right of the current one, for the
|
|
410
413
|
// terminals that can be told to. Pure, so the table can be tested.
|
|
411
|
-
function openCommand(env, panelPath, nodePath, platform, extraArgs) {
|
|
414
|
+
function openCommand(env, panelPath, nodePath, platform, extraArgs, options) {
|
|
412
415
|
const e = env || process.env;
|
|
413
416
|
const os = platform || process.platform;
|
|
414
417
|
const node = nodePath || process.execPath;
|
|
@@ -417,9 +420,12 @@ function openCommand(env, panelPath, nodePath, platform, extraArgs) {
|
|
|
417
420
|
const cmd = [quote(node), quote(panel)].concat(extra.map(quote)).join(' ');
|
|
418
421
|
|
|
419
422
|
if (e.TMUX) {
|
|
423
|
+
// A percentage on -l arrived in tmux 3.1; older ones want the old -p.
|
|
424
|
+
const version = options && options.tmuxVersion ? String(options.tmuxVersion).match(/(\d+)\.(\d+)/) : null;
|
|
425
|
+
const old = version && (Number(version[1]) < 3 || (Number(version[1]) === 3 && Number(version[2]) < 1));
|
|
420
426
|
return {
|
|
421
427
|
program: 'tmux',
|
|
422
|
-
args: ['split-window', '-h', '-d', '-l', '32%', cmd],
|
|
428
|
+
args: ['split-window', '-h', '-d'].concat(old ? ['-p', '32'] : ['-l', '32%'], [cmd]),
|
|
423
429
|
note: 'opened a pane to the right in tmux',
|
|
424
430
|
};
|
|
425
431
|
}
|
|
@@ -468,8 +474,20 @@ function openCommand(env, panelPath, nodePath, platform, extraArgs) {
|
|
|
468
474
|
return null;
|
|
469
475
|
}
|
|
470
476
|
|
|
477
|
+
function tmuxVersion() {
|
|
478
|
+
try {
|
|
479
|
+
const result = require('child_process').spawnSync('tmux', ['-V'], { encoding: 'utf8', timeout: 2000, windowsHide: true });
|
|
480
|
+
return result && result.stdout ? String(result.stdout).trim() : null;
|
|
481
|
+
} catch (err) {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
471
486
|
function openPanel(env, extraArgs) {
|
|
472
|
-
const
|
|
487
|
+
const e = env || process.env;
|
|
488
|
+
const plan = openCommand(e, __filename, process.execPath, process.platform, extraArgs, {
|
|
489
|
+
tmuxVersion: e.TMUX ? tmuxVersion() : null,
|
|
490
|
+
});
|
|
473
491
|
if (!plan) {
|
|
474
492
|
process.stdout.write(
|
|
475
493
|
'This terminal cannot be told to split. Open a second pane to the right and run:\n ' +
|
|
@@ -601,21 +619,28 @@ async function interactive(args) {
|
|
|
601
619
|
const now = Date.now();
|
|
602
620
|
const due = fetch && !state.fetching && now - state.lastFetchAt >= state.delayMs;
|
|
603
621
|
if (due) {
|
|
622
|
+
// The reading runs beside the frames, never in front of them: a slow
|
|
623
|
+
// network must not freeze the spinner or the countdown.
|
|
604
624
|
state.fetching = true;
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
625
|
+
snapshot({ fetch: true, network: fetch, env, now })
|
|
626
|
+
.then((built) => {
|
|
627
|
+
state.built = built;
|
|
628
|
+
state.outcome = built.outcome;
|
|
629
|
+
})
|
|
630
|
+
.catch((err) => {
|
|
631
|
+
state.outcome = { ok: false, kind: 'bad_response', message: err && err.message ? err.message : String(err) };
|
|
632
|
+
})
|
|
633
|
+
.then(() => {
|
|
634
|
+
state.lastFetchAt = Date.now();
|
|
635
|
+
state.delayMs = live.nextDelayMs(state.outcome, state.delayMs, {
|
|
636
|
+
baseMs: pollBase(state.built, args, env),
|
|
637
|
+
maxMs: POLL_IDLE_MS,
|
|
638
|
+
});
|
|
639
|
+
state.fetching = false;
|
|
640
|
+
state.dirty = true;
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
if (!state.built || now - state.lastCheck >= FILE_CHECK_MS) {
|
|
619
644
|
state.lastCheck = now;
|
|
620
645
|
try {
|
|
621
646
|
state.built = await snapshot({ fetch: false, network: fetch, env, now, outcome: state.outcome });
|
|
@@ -116,6 +116,8 @@ function noteFor(outcome, ageMs) {
|
|
|
116
116
|
return 'network off' + suffix;
|
|
117
117
|
case 'unauthorized':
|
|
118
118
|
return 'sign in to Claude Code again' + suffix;
|
|
119
|
+
case 'expired':
|
|
120
|
+
return 'the login has expired, Claude Code renews it on its next call' + suffix;
|
|
119
121
|
case 'forbidden':
|
|
120
122
|
return 'usage is not available for this login' + suffix;
|
|
121
123
|
case 'no_credentials':
|
|
@@ -141,7 +143,10 @@ function build(input) {
|
|
|
141
143
|
// knows for certain; the setting is the fallback; nothing is hidden when
|
|
142
144
|
// neither says.
|
|
143
145
|
const model = opts.model || opts.settingsModel || env.ANTHROPIC_MODEL || null;
|
|
144
|
-
|
|
146
|
+
// When Claude Code has said which model is running, that is the whole
|
|
147
|
+
// answer: adding the setting on top would keep a Fable week on screen after
|
|
148
|
+
// /model moved the session to Opus. The setting is only the fallback.
|
|
149
|
+
const families = usage.familiesInUse(null, null, opts.model ? [opts.model] : [opts.settingsModel, env.ANTHROPIC_MODEL]);
|
|
145
150
|
|
|
146
151
|
const rows = [];
|
|
147
152
|
for (const key of ['five_hour', 'seven_day']) {
|