ai-battery 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +283 -0
- package/bin/ai-battery-hud +89 -0
- package/bin/ai-battery-hud.js +332 -0
- package/bin/ai-battery-hud.ps1 +1835 -0
- package/bin/ai-battery-run +344 -0
- package/bin/ai-battery.js +2082 -0
- package/docs/claude-statusline-preview.svg +37 -0
- package/docs/terminal-preview.svg +44 -0
- package/package.json +48 -0
|
@@ -0,0 +1,2082 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_TAIL_BYTES = 4 * 1024 * 1024;
|
|
10
|
+
const DEFAULT_MAX_FILES = 40;
|
|
11
|
+
const CLAUDE_LIMIT_HIT_TAIL_BYTES = 256 * 1024;
|
|
12
|
+
const CLAUDE_LIMIT_HIT_MAX_FILES = 80;
|
|
13
|
+
const DIVIDER = "│";
|
|
14
|
+
const PROVIDER_DIVIDER = "┃";
|
|
15
|
+
const ANSI_RE = /\u001b\[[0-?]*[ -/]*[@-~]/g;
|
|
16
|
+
const DEFAULT_STATUSLINE_COLUMN_GUARD = 4;
|
|
17
|
+
const COMMANDS = new Set([
|
|
18
|
+
"show",
|
|
19
|
+
"setup",
|
|
20
|
+
"doctor",
|
|
21
|
+
"hud",
|
|
22
|
+
"on",
|
|
23
|
+
"off",
|
|
24
|
+
"capture-claude",
|
|
25
|
+
"install-claude-statusline",
|
|
26
|
+
"uninstall-claude-statusline"
|
|
27
|
+
]);
|
|
28
|
+
const PROVIDERS = ["codex", "claude"];
|
|
29
|
+
const CODEX_WRAPPER_MARKER = "AI_BATTERY_MANAGED_CODEX_WRAPPER";
|
|
30
|
+
|
|
31
|
+
function parseArgs(argv) {
|
|
32
|
+
const args = {
|
|
33
|
+
command: "show",
|
|
34
|
+
provider: "all",
|
|
35
|
+
json: false,
|
|
36
|
+
watch: false,
|
|
37
|
+
interval: 10,
|
|
38
|
+
style: process.stdout.isTTY ? "ansi" : "plain",
|
|
39
|
+
barWidth: 10,
|
|
40
|
+
maxWidth: null,
|
|
41
|
+
leftPadding: 0,
|
|
42
|
+
activeProvider: null,
|
|
43
|
+
showPaths: false,
|
|
44
|
+
silent: false,
|
|
45
|
+
force: false,
|
|
46
|
+
header: true,
|
|
47
|
+
help: false,
|
|
48
|
+
targets: [],
|
|
49
|
+
rest: []
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
53
|
+
const arg = argv[i];
|
|
54
|
+
if (!arg.startsWith("-") && COMMANDS.has(arg)) {
|
|
55
|
+
args.command = arg;
|
|
56
|
+
if (arg === "hud") {
|
|
57
|
+
args.rest = argv.slice(i + 1);
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
} else if (!arg.startsWith("-") && ["setup", "on", "off"].includes(args.command)) {
|
|
61
|
+
args.targets.push(arg);
|
|
62
|
+
} else if (arg === "--provider" || arg === "-p") {
|
|
63
|
+
args.provider = argv[++i] || "all";
|
|
64
|
+
} else if (arg === "--json") {
|
|
65
|
+
args.json = true;
|
|
66
|
+
args.style = "plain";
|
|
67
|
+
} else if (arg === "--ansi") {
|
|
68
|
+
args.style = "ansi";
|
|
69
|
+
} else if (arg === "--muted") {
|
|
70
|
+
args.style = "muted";
|
|
71
|
+
} else if (arg === "--tmux") {
|
|
72
|
+
args.style = "tmux";
|
|
73
|
+
} else if (arg === "--silent") {
|
|
74
|
+
args.silent = true;
|
|
75
|
+
} else if (arg === "--no-header") {
|
|
76
|
+
args.header = false;
|
|
77
|
+
} else if (arg === "--force") {
|
|
78
|
+
args.force = true;
|
|
79
|
+
} else if (arg === "--watch" || arg === "-w") {
|
|
80
|
+
args.watch = true;
|
|
81
|
+
const maybeInterval = argv[i + 1];
|
|
82
|
+
if (maybeInterval && !maybeInterval.startsWith("-")) {
|
|
83
|
+
args.interval = Math.max(1, Number(maybeInterval) || args.interval);
|
|
84
|
+
i += 1;
|
|
85
|
+
}
|
|
86
|
+
} else if (arg === "--no-color") {
|
|
87
|
+
args.style = "plain";
|
|
88
|
+
} else if (arg === "--bar-width") {
|
|
89
|
+
args.barWidth = Math.max(4, Math.min(30, Number(argv[++i]) || args.barWidth));
|
|
90
|
+
} else if (arg === "--max-width") {
|
|
91
|
+
args.maxWidth = Math.max(20, Number(argv[++i]) || 0) || null;
|
|
92
|
+
} else if (arg === "--left-padding") {
|
|
93
|
+
args.leftPadding = Math.max(0, Math.min(20, Number(argv[++i]) || 0));
|
|
94
|
+
} else if (arg === "--active-provider") {
|
|
95
|
+
args.activeProvider = argv[++i] || null;
|
|
96
|
+
} else if (arg === "--show-paths") {
|
|
97
|
+
args.showPaths = true;
|
|
98
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
99
|
+
args.help = true;
|
|
100
|
+
} else {
|
|
101
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!["all", "codex", "claude"].includes(args.provider)) {
|
|
106
|
+
throw new Error("--provider must be one of: all, codex, claude");
|
|
107
|
+
}
|
|
108
|
+
if (args.activeProvider && !["codex", "claude"].includes(args.activeProvider)) {
|
|
109
|
+
throw new Error("--active-provider must be one of: codex, claude");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return args;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function printHelp() {
|
|
116
|
+
console.log(`ai-battery
|
|
117
|
+
|
|
118
|
+
Usage:
|
|
119
|
+
ai-battery [options]
|
|
120
|
+
ai-battery setup [codex|claude] [--force]
|
|
121
|
+
ai-battery doctor
|
|
122
|
+
ai-battery hud [start|stop|status] [hud options]
|
|
123
|
+
ai-battery hud autostart on|off|status
|
|
124
|
+
ai-battery off codex|claude|all
|
|
125
|
+
ai-battery on codex|claude|all
|
|
126
|
+
|
|
127
|
+
Aliases:
|
|
128
|
+
claudex-battery
|
|
129
|
+
|
|
130
|
+
Options:
|
|
131
|
+
-p, --provider all|codex|claude Provider to show (default: all)
|
|
132
|
+
--json Print machine-readable JSON
|
|
133
|
+
--ansi Force ANSI color output
|
|
134
|
+
--muted Use Codex-style muted status-line colors
|
|
135
|
+
--no-header Hide Claude's extra statusLine header
|
|
136
|
+
-w, --watch [seconds] Refresh in place (default: 10 seconds)
|
|
137
|
+
--bar-width N Battery bar width (default: 10)
|
|
138
|
+
--max-width N Fit text output within N terminal columns
|
|
139
|
+
--left-padding N Prefix status output with N spaces
|
|
140
|
+
--active-provider codex|claude
|
|
141
|
+
--show-paths Include source log paths in text output
|
|
142
|
+
--tmux Emit tmux status-line color markup
|
|
143
|
+
--force Replace an existing Claude statusLine
|
|
144
|
+
--no-color Disable ANSI colors
|
|
145
|
+
-h, --help Show this help
|
|
146
|
+
|
|
147
|
+
Compatibility:
|
|
148
|
+
ai-battery install-claude-statusline [--force]
|
|
149
|
+
ai-battery uninstall-claude-statusline
|
|
150
|
+
`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function userHome() {
|
|
154
|
+
const home = os.homedir();
|
|
155
|
+
if (process.platform !== "win32" && (/^[A-Za-z]:[\\/]/.test(home) || home.includes("\\"))) {
|
|
156
|
+
try {
|
|
157
|
+
const info = os.userInfo();
|
|
158
|
+
if (info?.homedir?.startsWith("/")) return info.homedir;
|
|
159
|
+
} catch {
|
|
160
|
+
// Fall through to a conventional Linux home path.
|
|
161
|
+
}
|
|
162
|
+
if (process.env.USER) return `/home/${process.env.USER}`;
|
|
163
|
+
}
|
|
164
|
+
return home;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function homePath(...parts) {
|
|
168
|
+
return path.join(userHome(), ...parts);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function stateDir() {
|
|
172
|
+
if (process.env.AI_BATTERY_STATE_DIR) return process.env.AI_BATTERY_STATE_DIR;
|
|
173
|
+
if (process.env.CLAUDEX_BATTERY_STATE_DIR) return process.env.CLAUDEX_BATTERY_STATE_DIR;
|
|
174
|
+
if (process.env.XDG_STATE_HOME) return path.join(process.env.XDG_STATE_HOME, "ai-battery");
|
|
175
|
+
return homePath(".local", "state", "ai-battery");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function legacyStateDir() {
|
|
179
|
+
if (process.env.XDG_STATE_HOME) return path.join(process.env.XDG_STATE_HOME, "claudex-battery");
|
|
180
|
+
return homePath(".local", "state", "claudex-battery");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function claudeCachePath() {
|
|
184
|
+
return path.join(stateDir(), "claude-statusline.json");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function claudeSessionCacheDir(root = stateDir()) {
|
|
188
|
+
return path.join(root, "claude-statusline-sessions");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function safeCacheName(value) {
|
|
192
|
+
return String(value || "unknown").replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function claudeSessionCachePath(sessionId) {
|
|
196
|
+
return path.join(claudeSessionCacheDir(), `${safeCacheName(sessionId)}.json`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function configPath() {
|
|
200
|
+
return path.join(stateDir(), "config.json");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function defaultConfig() {
|
|
204
|
+
return {
|
|
205
|
+
version: 1,
|
|
206
|
+
providers: {
|
|
207
|
+
codex: true,
|
|
208
|
+
claude: true
|
|
209
|
+
},
|
|
210
|
+
codexWrapper: null
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function readConfig() {
|
|
215
|
+
const stored = readJson(configPath()) ?? {};
|
|
216
|
+
const defaults = defaultConfig();
|
|
217
|
+
return {
|
|
218
|
+
...defaults,
|
|
219
|
+
...stored,
|
|
220
|
+
providers: {
|
|
221
|
+
...defaults.providers,
|
|
222
|
+
...(stored.providers ?? {})
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function writeConfig(config) {
|
|
228
|
+
writeJsonAtomic(configPath(), config);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function providerVisible(provider) {
|
|
232
|
+
const config = readConfig();
|
|
233
|
+
return config.providers?.[provider] !== false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function providerTargets(targets) {
|
|
237
|
+
const requested = targets.length ? targets : ["all"];
|
|
238
|
+
const providers = new Set();
|
|
239
|
+
for (const target of requested) {
|
|
240
|
+
if (target === "all") {
|
|
241
|
+
PROVIDERS.forEach((provider) => providers.add(provider));
|
|
242
|
+
} else if (PROVIDERS.includes(target)) {
|
|
243
|
+
providers.add(target);
|
|
244
|
+
} else {
|
|
245
|
+
throw new Error("target must be one of: codex, claude, all");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return [...providers];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function setProviderVisibility(targets, visible) {
|
|
252
|
+
const providers = providerTargets(targets);
|
|
253
|
+
const config = readConfig();
|
|
254
|
+
for (const provider of providers) {
|
|
255
|
+
config.providers[provider] = visible;
|
|
256
|
+
}
|
|
257
|
+
writeConfig(config);
|
|
258
|
+
return {
|
|
259
|
+
configPath: configPath(),
|
|
260
|
+
providers,
|
|
261
|
+
visible
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function runningInsideCodex() {
|
|
266
|
+
return Boolean(process.env.CODEX_THREAD_ID || process.env.CODEX_MANAGED_BY_NPM || process.env.CODEX_MANAGED_PACKAGE_ROOT);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function runningInsideAiBatteryCodexWrapper() {
|
|
270
|
+
return Boolean(process.env.AI_BATTERY_WRAPPED_CODEX || process.env.AI_BATTERY_ORIGINAL_CODEX);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function scriptDir() {
|
|
274
|
+
return path.dirname(fileURLToPath(import.meta.url));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function shQuote(value) {
|
|
278
|
+
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function pathEntries() {
|
|
282
|
+
return (process.env.PATH || "")
|
|
283
|
+
.split(path.delimiter)
|
|
284
|
+
.filter(Boolean);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function isExecutable(filePath) {
|
|
288
|
+
try {
|
|
289
|
+
fs.accessSync(filePath, fs.constants.X_OK);
|
|
290
|
+
return true;
|
|
291
|
+
} catch {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function samePath(a, b) {
|
|
297
|
+
try {
|
|
298
|
+
return fs.realpathSync(a) === fs.realpathSync(b);
|
|
299
|
+
} catch {
|
|
300
|
+
return path.resolve(a) === path.resolve(b);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function executableTarget(commandPath) {
|
|
305
|
+
try {
|
|
306
|
+
return fs.realpathSync(commandPath);
|
|
307
|
+
} catch {
|
|
308
|
+
return commandPath;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function findCommand(command, skipPaths = []) {
|
|
313
|
+
const names = process.platform === "win32"
|
|
314
|
+
? [command, `${command}.cmd`, `${command}.exe`, `${command}.bat`]
|
|
315
|
+
: [command];
|
|
316
|
+
const skips = skipPaths.filter(Boolean);
|
|
317
|
+
|
|
318
|
+
for (const dir of pathEntries()) {
|
|
319
|
+
for (const name of names) {
|
|
320
|
+
const candidate = path.join(dir, name);
|
|
321
|
+
if (skips.some((skip) => samePath(candidate, skip))) continue;
|
|
322
|
+
if (safeStat(candidate)?.isFile() && isExecutable(candidate)) return candidate;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function codexWrapperScript(originalCommand) {
|
|
329
|
+
const runner = path.join(scriptDir(), "ai-battery-run");
|
|
330
|
+
return `#!/bin/sh
|
|
331
|
+
# ${CODEX_WRAPPER_MARKER}=1
|
|
332
|
+
export AI_BATTERY_ORIGINAL_CODEX=${shQuote(originalCommand)}
|
|
333
|
+
export AI_BATTERY_WRAPPED_CODEX=1
|
|
334
|
+
if [ -t 0 ] && [ -t 1 ]; then
|
|
335
|
+
exec ${shQuote(runner)} --provider all -- ${shQuote(originalCommand)} "$@"
|
|
336
|
+
fi
|
|
337
|
+
exec ${shQuote(originalCommand)} "$@"
|
|
338
|
+
`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function managedCodexWrapper(filePath) {
|
|
342
|
+
try {
|
|
343
|
+
return fs.readFileSync(filePath, "utf8").includes(CODEX_WRAPPER_MARKER);
|
|
344
|
+
} catch {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function shellRcPath() {
|
|
350
|
+
if (process.env.AI_BATTERY_RC) return process.env.AI_BATTERY_RC;
|
|
351
|
+
const shell = path.basename(process.env.SHELL || "");
|
|
352
|
+
if (shell === "zsh") return homePath(".zshrc");
|
|
353
|
+
if (shell === "bash") return homePath(".bashrc");
|
|
354
|
+
if (shell === "fish") return homePath(".config", "fish", "config.fish");
|
|
355
|
+
return homePath(".profile");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function shellPathBlock(shimDir) {
|
|
359
|
+
const shell = path.basename(process.env.SHELL || "");
|
|
360
|
+
if (shell === "fish") {
|
|
361
|
+
return `\n# >>> ai-battery setup >>>\nfish_add_path -p ${shQuote(shimDir)}\n# <<< ai-battery setup <<<\n`;
|
|
362
|
+
}
|
|
363
|
+
return `\n# >>> ai-battery setup >>>\nexport PATH=${shQuote(shimDir)}:"$PATH"\n# <<< ai-battery setup <<<\n`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function ensureShimPath(shimDir, originalCommand) {
|
|
367
|
+
const entries = pathEntries();
|
|
368
|
+
const shimIndex = entries.findIndex((entry) => path.resolve(entry) === path.resolve(shimDir));
|
|
369
|
+
const originalDir = path.dirname(originalCommand);
|
|
370
|
+
const originalIndex = entries.findIndex((entry) => path.resolve(entry) === path.resolve(originalDir));
|
|
371
|
+
const activeNow = shimIndex >= 0 && (originalIndex < 0 || shimIndex < originalIndex);
|
|
372
|
+
if (activeNow) {
|
|
373
|
+
return {
|
|
374
|
+
changed: false,
|
|
375
|
+
rcPath: null,
|
|
376
|
+
note: `${shimDir} is already before the original codex on PATH`
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const rcPath = shellRcPath();
|
|
381
|
+
fs.mkdirSync(path.dirname(rcPath), { recursive: true });
|
|
382
|
+
const existing = fs.existsSync(rcPath) ? fs.readFileSync(rcPath, "utf8") : "";
|
|
383
|
+
if (!existing.includes(">>> ai-battery setup >>>")) {
|
|
384
|
+
fs.appendFileSync(rcPath, shellPathBlock(shimDir));
|
|
385
|
+
return {
|
|
386
|
+
changed: true,
|
|
387
|
+
rcPath,
|
|
388
|
+
note: `Added ${shimDir} before PATH in ${rcPath}. Open a new terminal for plain "codex" to use AI Battery.`
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
changed: false,
|
|
394
|
+
rcPath,
|
|
395
|
+
note: `${rcPath} already has an AI Battery PATH block. Open a new terminal if plain "codex" does not use AI Battery yet.`
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function installCodexWrapper(args) {
|
|
400
|
+
if (process.platform === "win32") {
|
|
401
|
+
return {
|
|
402
|
+
ok: false,
|
|
403
|
+
skipped: true,
|
|
404
|
+
reason: "Codex wrapper uses a POSIX PTY and is not supported on native Windows"
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const shimDir = process.env.AI_BATTERY_SHIM_DIR || homePath(".local", "bin");
|
|
409
|
+
const wrapperPath = path.join(shimDir, "codex");
|
|
410
|
+
const config = readConfig();
|
|
411
|
+
const configuredOriginal = config.codexWrapper?.originalCommand;
|
|
412
|
+
const originalCandidate = configuredOriginal && fs.existsSync(configuredOriginal)
|
|
413
|
+
? configuredOriginal
|
|
414
|
+
: findCommand("codex", [wrapperPath]);
|
|
415
|
+
const originalCommand = originalCandidate ? executableTarget(originalCandidate) : null;
|
|
416
|
+
|
|
417
|
+
if (!originalCommand) {
|
|
418
|
+
return {
|
|
419
|
+
ok: false,
|
|
420
|
+
skipped: true,
|
|
421
|
+
reason: "codex command was not found on PATH"
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
fs.mkdirSync(shimDir, { recursive: true, mode: 0o755 });
|
|
426
|
+
if (fs.existsSync(wrapperPath) && !managedCodexWrapper(wrapperPath)) {
|
|
427
|
+
if (!args.force) {
|
|
428
|
+
throw new Error(`${wrapperPath} already exists. Re-run setup with --force to replace it.`);
|
|
429
|
+
}
|
|
430
|
+
fs.renameSync(wrapperPath, `${wrapperPath}.ai-battery-backup-${Date.now()}`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
fs.writeFileSync(wrapperPath, codexWrapperScript(originalCommand), { mode: 0o755 });
|
|
434
|
+
|
|
435
|
+
const pathResult = ensureShimPath(shimDir, originalCommand);
|
|
436
|
+
config.codexWrapper = {
|
|
437
|
+
wrapperPath,
|
|
438
|
+
originalCommand,
|
|
439
|
+
installedAt: new Date().toISOString()
|
|
440
|
+
};
|
|
441
|
+
writeConfig(config);
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
ok: true,
|
|
445
|
+
wrapperPath,
|
|
446
|
+
originalCommand,
|
|
447
|
+
path: pathResult
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function codexRestartNote() {
|
|
452
|
+
if (!runningInsideCodex() || runningInsideAiBatteryCodexWrapper()) return null;
|
|
453
|
+
return "Current Codex was not started through AI Battery. Exit this Codex session and run plain \"codex\" again from a normal terminal.";
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function diagnoseCodex() {
|
|
457
|
+
const config = readConfig();
|
|
458
|
+
const configuredWrapper = config.codexWrapper?.wrapperPath || homePath(".local", "bin", "codex");
|
|
459
|
+
const configuredOriginal = config.codexWrapper?.originalCommand || null;
|
|
460
|
+
const activeCodex = findCommand("codex");
|
|
461
|
+
const wrapperInstalled = configuredWrapper ? managedCodexWrapper(configuredWrapper) : false;
|
|
462
|
+
const activeIsWrapper = activeCodex ? managedCodexWrapper(activeCodex) : false;
|
|
463
|
+
const originalExists = configuredOriginal ? fs.existsSync(configuredOriginal) : false;
|
|
464
|
+
const providerEnabled = providerVisible("codex");
|
|
465
|
+
const notes = [];
|
|
466
|
+
|
|
467
|
+
if (!providerEnabled) {
|
|
468
|
+
notes.push("Codex provider is hidden. Run: ai-battery on codex");
|
|
469
|
+
}
|
|
470
|
+
if (!wrapperInstalled) {
|
|
471
|
+
notes.push("Codex wrapper is not installed. Run: ai-battery setup codex");
|
|
472
|
+
}
|
|
473
|
+
if (wrapperInstalled && !activeIsWrapper) {
|
|
474
|
+
notes.push("Plain \"codex\" does not resolve to the AI Battery wrapper in this shell. Open a new terminal or put ~/.local/bin before the original codex on PATH.");
|
|
475
|
+
}
|
|
476
|
+
if (configuredOriginal && !originalExists) {
|
|
477
|
+
notes.push(`Original codex path saved by setup no longer exists: ${configuredOriginal}`);
|
|
478
|
+
}
|
|
479
|
+
const restartNote = codexRestartNote();
|
|
480
|
+
if (restartNote) notes.push(restartNote);
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
providerEnabled,
|
|
484
|
+
activeCodex,
|
|
485
|
+
activeIsWrapper,
|
|
486
|
+
wrapperPath: configuredWrapper,
|
|
487
|
+
wrapperInstalled,
|
|
488
|
+
originalCommand: configuredOriginal,
|
|
489
|
+
originalExists: configuredOriginal ? originalExists : null,
|
|
490
|
+
insideCodex: runningInsideCodex(),
|
|
491
|
+
currentCodexWrapped: runningInsideAiBatteryCodexWrapper(),
|
|
492
|
+
notes
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function runDoctor() {
|
|
497
|
+
return {
|
|
498
|
+
generatedAt: new Date().toISOString(),
|
|
499
|
+
aiBattery: {
|
|
500
|
+
script: fileURLToPath(import.meta.url),
|
|
501
|
+
stateDir: stateDir(),
|
|
502
|
+
configPath: configPath()
|
|
503
|
+
},
|
|
504
|
+
codex: diagnoseCodex(),
|
|
505
|
+
claude: {
|
|
506
|
+
providerEnabled: providerVisible("claude"),
|
|
507
|
+
cachePath: claudeCachePath(),
|
|
508
|
+
statuslineCache: Boolean(readClaudeStatuslineCache())
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function runSetup(args) {
|
|
514
|
+
const targets = providerTargets(args.targets);
|
|
515
|
+
const results = {};
|
|
516
|
+
if (targets.includes("claude")) {
|
|
517
|
+
results.claude = installClaudeStatusline({ ...args, force: true });
|
|
518
|
+
}
|
|
519
|
+
if (targets.includes("codex")) {
|
|
520
|
+
results.codex = installCodexWrapper(args);
|
|
521
|
+
}
|
|
522
|
+
return results;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function runHud(args) {
|
|
526
|
+
const hudPath = path.join(scriptDir(), "ai-battery-hud.js");
|
|
527
|
+
return spawnSync(process.execPath, [hudPath, ...args.rest], {
|
|
528
|
+
stdio: "inherit",
|
|
529
|
+
windowsHide: true
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function safeStat(filePath) {
|
|
534
|
+
try {
|
|
535
|
+
return fs.statSync(filePath);
|
|
536
|
+
} catch {
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function listJsonlFiles(root, maxFiles = DEFAULT_MAX_FILES) {
|
|
542
|
+
const files = [];
|
|
543
|
+
|
|
544
|
+
function walk(dir) {
|
|
545
|
+
let entries;
|
|
546
|
+
try {
|
|
547
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
548
|
+
} catch {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
for (const entry of entries) {
|
|
553
|
+
const fullPath = path.join(dir, entry.name);
|
|
554
|
+
if (entry.isDirectory()) {
|
|
555
|
+
walk(fullPath);
|
|
556
|
+
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
557
|
+
const stat = safeStat(fullPath);
|
|
558
|
+
if (stat) {
|
|
559
|
+
files.push({ path: fullPath, mtimeMs: stat.mtimeMs, size: stat.size });
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
walk(root);
|
|
566
|
+
return files.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, maxFiles);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function readTail(filePath, bytes = DEFAULT_TAIL_BYTES) {
|
|
570
|
+
const stat = safeStat(filePath);
|
|
571
|
+
if (!stat) return "";
|
|
572
|
+
|
|
573
|
+
const length = Math.min(bytes, stat.size);
|
|
574
|
+
const start = Math.max(0, stat.size - length);
|
|
575
|
+
const fd = fs.openSync(filePath, "r");
|
|
576
|
+
try {
|
|
577
|
+
const buffer = Buffer.alloc(length);
|
|
578
|
+
fs.readSync(fd, buffer, 0, length, start);
|
|
579
|
+
return buffer.toString("utf8");
|
|
580
|
+
} finally {
|
|
581
|
+
fs.closeSync(fd);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function readHead(filePath, bytes = 32 * 1024) {
|
|
586
|
+
const stat = safeStat(filePath);
|
|
587
|
+
if (!stat) return "";
|
|
588
|
+
|
|
589
|
+
const length = Math.min(bytes, stat.size);
|
|
590
|
+
const fd = fs.openSync(filePath, "r");
|
|
591
|
+
try {
|
|
592
|
+
const buffer = Buffer.alloc(length);
|
|
593
|
+
fs.readSync(fd, buffer, 0, length, 0);
|
|
594
|
+
return buffer.toString("utf8");
|
|
595
|
+
} finally {
|
|
596
|
+
fs.closeSync(fd);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function latestMatchingJsonLine(files, predicate) {
|
|
601
|
+
let fallback = null;
|
|
602
|
+
|
|
603
|
+
for (const file of files) {
|
|
604
|
+
const text = readTail(file.path);
|
|
605
|
+
const lines = text.split("\n");
|
|
606
|
+
|
|
607
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
608
|
+
const line = lines[i].trim();
|
|
609
|
+
if (!line || !predicate(line)) continue;
|
|
610
|
+
|
|
611
|
+
try {
|
|
612
|
+
const json = JSON.parse(line);
|
|
613
|
+
return { json, file: file.path };
|
|
614
|
+
} catch {
|
|
615
|
+
fallback = fallback || { error: "Found a matching line but could not parse it", file: file.path };
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return fallback;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function codexSessionMeta(filePath) {
|
|
624
|
+
const text = readHead(filePath);
|
|
625
|
+
const firstLine = text.split("\n").find((line) => line.includes("\"session_meta\""));
|
|
626
|
+
if (!firstLine) return null;
|
|
627
|
+
try {
|
|
628
|
+
const json = JSON.parse(firstLine);
|
|
629
|
+
return json.type === "session_meta" ? json.payload ?? null : null;
|
|
630
|
+
} catch {
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function sameOrNestedPath(a, b) {
|
|
636
|
+
if (!a || !b) return false;
|
|
637
|
+
const left = path.resolve(a);
|
|
638
|
+
const right = path.resolve(b);
|
|
639
|
+
return left === right || left.startsWith(`${right}${path.sep}`) || right.startsWith(`${left}${path.sep}`);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function prioritizeCodexSessionFiles(files) {
|
|
643
|
+
const threadId = process.env.CODEX_THREAD_ID;
|
|
644
|
+
const cwd = (runningInsideAiBatteryCodexWrapper() || runningInsideCodex()) ? process.cwd() : null;
|
|
645
|
+
if (!threadId && !cwd) return files;
|
|
646
|
+
const metaCache = new Map();
|
|
647
|
+
const metaFor = (file) => {
|
|
648
|
+
if (!metaCache.has(file.path)) metaCache.set(file.path, codexSessionMeta(file.path));
|
|
649
|
+
return metaCache.get(file.path);
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
return [...files].sort((a, b) => {
|
|
653
|
+
const aThread = threadId && a.path.includes(threadId) ? 1 : 0;
|
|
654
|
+
const bThread = threadId && b.path.includes(threadId) ? 1 : 0;
|
|
655
|
+
if (aThread !== bThread) return bThread - aThread;
|
|
656
|
+
|
|
657
|
+
const aCwd = cwd && sameOrNestedPath(metaFor(a)?.cwd, cwd) ? 1 : 0;
|
|
658
|
+
const bCwd = cwd && sameOrNestedPath(metaFor(b)?.cwd, cwd) ? 1 : 0;
|
|
659
|
+
if (aCwd !== bCwd) return bCwd - aCwd;
|
|
660
|
+
|
|
661
|
+
return b.mtimeMs - a.mtimeMs;
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function normalizeLimit(limit, options = {}) {
|
|
666
|
+
if (!limit) return null;
|
|
667
|
+
|
|
668
|
+
const usedKey = options.usedKey || "used_percent";
|
|
669
|
+
const remainingKeys = [options.remainingKey].flat().filter(Boolean);
|
|
670
|
+
const windowMinutes = options.windowMinutes ?? limit?.window_minutes ?? null;
|
|
671
|
+
const usedValue = limit?.[usedKey];
|
|
672
|
+
const remainingValue = remainingKeys
|
|
673
|
+
.map((key) => limit?.[key])
|
|
674
|
+
.find((value) => Number.isFinite(value));
|
|
675
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
676
|
+
const resetPassed = Number.isFinite(limit.resets_at) && limit.resets_at <= nowSeconds;
|
|
677
|
+
const inferResetPassed = options.inferResetPassed !== false;
|
|
678
|
+
|
|
679
|
+
if (!Number.isFinite(usedValue) && !Number.isFinite(remainingValue) && !(resetPassed && inferResetPassed)) return null;
|
|
680
|
+
|
|
681
|
+
let usedPercent = resetPassed && inferResetPassed
|
|
682
|
+
? 0
|
|
683
|
+
: Number.isFinite(usedValue)
|
|
684
|
+
? clamp(Math.round(usedValue), 0, 100)
|
|
685
|
+
: clamp(100 - Math.round(remainingValue), 0, 100);
|
|
686
|
+
let remainingPercent = resetPassed && inferResetPassed
|
|
687
|
+
? 100
|
|
688
|
+
: Number.isFinite(remainingValue)
|
|
689
|
+
? clamp(Math.round(remainingValue), 0, 100)
|
|
690
|
+
: clamp(100 - usedPercent, 0, 100);
|
|
691
|
+
|
|
692
|
+
return {
|
|
693
|
+
usedPercent,
|
|
694
|
+
remainingPercent,
|
|
695
|
+
windowMinutes,
|
|
696
|
+
resetsAt: limit.resets_at ? new Date(limit.resets_at * 1000).toISOString() : null,
|
|
697
|
+
resetsInSeconds: limit.resets_at ? Math.max(0, limit.resets_at - nowSeconds) : null,
|
|
698
|
+
resetPassed
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function cacheAgeSeconds(timestamp) {
|
|
703
|
+
if (!timestamp) return null;
|
|
704
|
+
const millis = Date.parse(timestamp);
|
|
705
|
+
if (Number.isNaN(millis)) return null;
|
|
706
|
+
return Math.max(0, Math.floor((Date.now() - millis) / 1000));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function writeJsonAtomic(filePath, data) {
|
|
710
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
711
|
+
const tempPath = `${filePath}.${process.pid}.tmp`;
|
|
712
|
+
fs.writeFileSync(tempPath, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
713
|
+
fs.renameSync(tempPath, filePath);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function readJson(filePath) {
|
|
717
|
+
try {
|
|
718
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
719
|
+
} catch {
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const SCAN_CACHE_VERSION = 1;
|
|
725
|
+
|
|
726
|
+
function scanCacheSeconds(defaultSeconds) {
|
|
727
|
+
const raw = Number(process.env.AI_BATTERY_SCAN_CACHE_SECONDS);
|
|
728
|
+
if (Number.isFinite(raw)) return clamp(raw, 0, 60);
|
|
729
|
+
return defaultSeconds;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function scanCachePath(name) {
|
|
733
|
+
return path.join(stateDir(), `${name}-scan-cache.json`);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function readScanCache(name, maxAgeSeconds) {
|
|
737
|
+
if (maxAgeSeconds <= 0) return null;
|
|
738
|
+
const cached = readJson(scanCachePath(name));
|
|
739
|
+
if (!cached || cached.version !== SCAN_CACHE_VERSION) return null;
|
|
740
|
+
const age = cacheAgeSeconds(cached.capturedAt);
|
|
741
|
+
if (age === null || age > maxAgeSeconds) return null;
|
|
742
|
+
return cached;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function writeScanCache(name, value) {
|
|
746
|
+
try {
|
|
747
|
+
writeJsonAtomic(scanCachePath(name), {
|
|
748
|
+
version: SCAN_CACHE_VERSION,
|
|
749
|
+
capturedAt: new Date().toISOString(),
|
|
750
|
+
value: value ?? null
|
|
751
|
+
});
|
|
752
|
+
} catch {
|
|
753
|
+
// Scan caching is best-effort; every reader can rebuild from source logs.
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function shellArg(value) {
|
|
758
|
+
const text = String(value);
|
|
759
|
+
if (process.platform === "win32") return `"${text.replace(/"/g, '\\"')}"`;
|
|
760
|
+
return `'${text.replace(/'/g, "'\\''")}'`;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function claudeCommandIsBackground(cmdline) {
|
|
764
|
+
return /(^|\s)daemon\s+run(\s|$)/.test(cmdline)
|
|
765
|
+
|| cmdline.includes("--bg-pty-host")
|
|
766
|
+
|| cmdline.includes("--bg-spare");
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function commandMatchesProvider(cmdline, provider) {
|
|
770
|
+
if (
|
|
771
|
+
cmdline.includes("ai-battery.js")
|
|
772
|
+
&& !cmdline.includes("@openai/codex")
|
|
773
|
+
&& !cmdline.includes("@anthropic-ai/claude-code")
|
|
774
|
+
&& !cmdline.includes("claude-code")
|
|
775
|
+
) {
|
|
776
|
+
return false;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (provider === "codex") {
|
|
780
|
+
return /(^|\s|[\\/])codex(\.cmd|\.exe)?(\s|$)/i.test(cmdline) || cmdline.includes("@openai/codex");
|
|
781
|
+
}
|
|
782
|
+
if (provider === "claude") {
|
|
783
|
+
if (claudeCommandIsBackground(cmdline)) return false;
|
|
784
|
+
return /(^|\s|[\\/])claude(\.cmd|\.exe)?(\s|$)/i.test(cmdline)
|
|
785
|
+
|| cmdline.includes("@anthropic-ai/claude-code")
|
|
786
|
+
|| cmdline.includes("claude-code");
|
|
787
|
+
}
|
|
788
|
+
return false;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function processHasControllingTty(pid) {
|
|
792
|
+
try {
|
|
793
|
+
const stat = fs.readFileSync(path.join("/proc", String(pid), "stat"), "utf8");
|
|
794
|
+
const afterComm = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/);
|
|
795
|
+
const ttyNr = Number(afterComm[4]);
|
|
796
|
+
return Number.isFinite(ttyNr) && ttyNr !== 0;
|
|
797
|
+
} catch {
|
|
798
|
+
return false;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function listWindowsProcessCommands() {
|
|
803
|
+
try {
|
|
804
|
+
const output = execFileSync("powershell.exe", [
|
|
805
|
+
"-NoProfile",
|
|
806
|
+
"-Command",
|
|
807
|
+
"Get-CimInstance Win32_Process | ForEach-Object { $_.CommandLine }"
|
|
808
|
+
], {
|
|
809
|
+
encoding: "utf8",
|
|
810
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
811
|
+
timeout: 2000,
|
|
812
|
+
windowsHide: true,
|
|
813
|
+
maxBuffer: 2 * 1024 * 1024
|
|
814
|
+
});
|
|
815
|
+
return output
|
|
816
|
+
.split(/\r?\n/)
|
|
817
|
+
.map((line) => line.trim())
|
|
818
|
+
.filter(Boolean)
|
|
819
|
+
.map((cmdline) => ({ cmdline, hasTty: true }));
|
|
820
|
+
} catch {
|
|
821
|
+
return null;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function listDarwinProcessCommands() {
|
|
826
|
+
try {
|
|
827
|
+
const output = execFileSync("ps", ["-axo", "tty=,args="], {
|
|
828
|
+
encoding: "utf8",
|
|
829
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
830
|
+
timeout: 1500,
|
|
831
|
+
maxBuffer: 4 * 1024 * 1024
|
|
832
|
+
});
|
|
833
|
+
return output
|
|
834
|
+
.split("\n")
|
|
835
|
+
.map((line) => {
|
|
836
|
+
const trimmed = line.trim();
|
|
837
|
+
const space = trimmed.indexOf(" ");
|
|
838
|
+
if (space < 0) return null;
|
|
839
|
+
const tty = trimmed.slice(0, space);
|
|
840
|
+
const cmdline = trimmed.slice(space + 1).trim();
|
|
841
|
+
if (!cmdline) return null;
|
|
842
|
+
return { cmdline, hasTty: tty !== "??" && tty !== "-" };
|
|
843
|
+
})
|
|
844
|
+
.filter(Boolean);
|
|
845
|
+
} catch {
|
|
846
|
+
return [];
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function listProcProcessCommands() {
|
|
851
|
+
const procRoot = "/proc";
|
|
852
|
+
let entries;
|
|
853
|
+
try {
|
|
854
|
+
entries = fs.readdirSync(procRoot, { withFileTypes: true });
|
|
855
|
+
} catch {
|
|
856
|
+
return [];
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const ownPid = process.pid;
|
|
860
|
+
const commands = [];
|
|
861
|
+
for (const entry of entries) {
|
|
862
|
+
if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
|
|
863
|
+
const pid = Number(entry.name);
|
|
864
|
+
if (pid === ownPid) continue;
|
|
865
|
+
|
|
866
|
+
let cmdline = "";
|
|
867
|
+
try {
|
|
868
|
+
cmdline = fs.readFileSync(path.join(procRoot, entry.name, "cmdline"), "utf8").replace(/\0/g, " ").trim();
|
|
869
|
+
} catch {
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
if (!cmdline) continue;
|
|
873
|
+
commands.push({ pid, cmdline });
|
|
874
|
+
}
|
|
875
|
+
return commands;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const PROCESS_SCAN_TTL_MS = 2000;
|
|
879
|
+
let processScanMemo = null;
|
|
880
|
+
|
|
881
|
+
function scanProcessCommands() {
|
|
882
|
+
if (processScanMemo && Date.now() - processScanMemo.at < PROCESS_SCAN_TTL_MS) {
|
|
883
|
+
return processScanMemo.commands;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
let commands;
|
|
887
|
+
if (process.platform === "win32") {
|
|
888
|
+
// Spawning PowerShell for the process list is the slow part on Windows, so
|
|
889
|
+
// share one recent scan between the HUD, statusline, and watch invocations.
|
|
890
|
+
const cached = readScanCache("windows-processes", scanCacheSeconds(3));
|
|
891
|
+
if (cached) {
|
|
892
|
+
commands = Array.isArray(cached.value) ? cached.value : [];
|
|
893
|
+
} else {
|
|
894
|
+
commands = listWindowsProcessCommands();
|
|
895
|
+
if (commands === null) {
|
|
896
|
+
commands = readJson(scanCachePath("windows-processes"))?.value ?? [];
|
|
897
|
+
} else {
|
|
898
|
+
writeScanCache("windows-processes", commands);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
} else if (process.platform === "darwin") {
|
|
902
|
+
commands = listDarwinProcessCommands();
|
|
903
|
+
} else {
|
|
904
|
+
commands = listProcProcessCommands();
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
processScanMemo = { at: Date.now(), commands };
|
|
908
|
+
return commands;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function isProviderRunning(provider) {
|
|
912
|
+
const needsTty = provider === "claude" && process.platform !== "win32";
|
|
913
|
+
return scanProcessCommands().some((proc) => {
|
|
914
|
+
if (!commandMatchesProvider(proc.cmdline, provider)) return false;
|
|
915
|
+
if (!needsTty) return true;
|
|
916
|
+
if (proc.hasTty === undefined) proc.hasTty = processHasControllingTty(proc.pid);
|
|
917
|
+
return proc.hasTty;
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function readStdin() {
|
|
922
|
+
return new Promise((resolve, reject) => {
|
|
923
|
+
let input = "";
|
|
924
|
+
process.stdin.setEncoding("utf8");
|
|
925
|
+
process.stdin.on("data", (chunk) => {
|
|
926
|
+
input += chunk;
|
|
927
|
+
});
|
|
928
|
+
process.stdin.on("end", () => resolve(input));
|
|
929
|
+
process.stdin.on("error", reject);
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function claudeLimitFromStatusline(limit, windowMinutes) {
|
|
934
|
+
if (!limit) return null;
|
|
935
|
+
const hasUsedPercentage = Number.isFinite(limit.used_percentage);
|
|
936
|
+
const remainingPercentage = [
|
|
937
|
+
limit.remaining_percentage,
|
|
938
|
+
limit.remaining_percent,
|
|
939
|
+
limit.percent_remaining
|
|
940
|
+
].find((value) => Number.isFinite(value));
|
|
941
|
+
const hasRemainingPercentage = Number.isFinite(remainingPercentage);
|
|
942
|
+
const hasReset = Number.isFinite(limit.resets_at);
|
|
943
|
+
if (!hasUsedPercentage && !hasRemainingPercentage && !hasReset) return null;
|
|
944
|
+
return {
|
|
945
|
+
...limit,
|
|
946
|
+
used_percentage: hasUsedPercentage ? limit.used_percentage : null,
|
|
947
|
+
remaining_percentage: hasRemainingPercentage ? remainingPercentage : null,
|
|
948
|
+
resets_at: hasReset ? limit.resets_at : null,
|
|
949
|
+
window_minutes: limit.window_minutes ?? windowMinutes
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function normalizeClaudeCachedLimit(limit, options = {}) {
|
|
954
|
+
if (!limit) return null;
|
|
955
|
+
return normalizeLimit(limit, {
|
|
956
|
+
usedKey: "used_percentage",
|
|
957
|
+
remainingKey: ["remaining_percentage", "remaining_percent", "percent_remaining"],
|
|
958
|
+
windowMinutes: limit.window_minutes ?? null,
|
|
959
|
+
...options
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function claudeTranscriptSessionKind(transcriptPath) {
|
|
964
|
+
if (!transcriptPath) return null;
|
|
965
|
+
const text = readTail(transcriptPath, 256 * 1024);
|
|
966
|
+
if (!text) return null;
|
|
967
|
+
|
|
968
|
+
const lines = text.split("\n");
|
|
969
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
970
|
+
const line = lines[i].trim();
|
|
971
|
+
if (!line || !line.includes("\"sessionKind\"")) continue;
|
|
972
|
+
try {
|
|
973
|
+
const json = JSON.parse(line);
|
|
974
|
+
if (typeof json.sessionKind === "string") return json.sessionKind;
|
|
975
|
+
} catch {
|
|
976
|
+
// Keep scanning older lines.
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
return null;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function claudeStatuslineSessionKind(inputOrCache) {
|
|
983
|
+
return inputOrCache?.sessionKind
|
|
984
|
+
?? inputOrCache?.session_kind
|
|
985
|
+
?? claudeTranscriptSessionKind(inputOrCache?.transcriptPath ?? inputOrCache?.transcript_path);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function messageText(message) {
|
|
989
|
+
const content = message?.content;
|
|
990
|
+
if (typeof content === "string") return content;
|
|
991
|
+
if (!Array.isArray(content)) return "";
|
|
992
|
+
return content
|
|
993
|
+
.map((part) => (typeof part?.text === "string" ? part.text : ""))
|
|
994
|
+
.filter(Boolean)
|
|
995
|
+
.join(" ");
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function claudeLimitWindowFromText(text) {
|
|
999
|
+
const lower = text.toLowerCase();
|
|
1000
|
+
if (lower.includes("session limit")) return "fiveHour";
|
|
1001
|
+
if (
|
|
1002
|
+
lower.includes("weekly limit")
|
|
1003
|
+
|| lower.includes("fable 5 limit")
|
|
1004
|
+
|| lower.includes("opus limit")
|
|
1005
|
+
|| lower.includes("sonnet limit")
|
|
1006
|
+
) {
|
|
1007
|
+
return "sevenDay";
|
|
1008
|
+
}
|
|
1009
|
+
return null;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function claudeRateLimitHitFromJson(json, filePath) {
|
|
1013
|
+
const text = messageText(json.message);
|
|
1014
|
+
const isRateLimit = json?.apiErrorStatus === 429
|
|
1015
|
+
|| json?.error === "rate_limit"
|
|
1016
|
+
|| text.includes("You've hit your")
|
|
1017
|
+
|| text.includes("You've reached your");
|
|
1018
|
+
if (!isRateLimit) return null;
|
|
1019
|
+
|
|
1020
|
+
const window = claudeLimitWindowFromText(text);
|
|
1021
|
+
if (!window) return null;
|
|
1022
|
+
|
|
1023
|
+
return {
|
|
1024
|
+
window,
|
|
1025
|
+
timestamp: json.timestamp ?? null,
|
|
1026
|
+
source: filePath,
|
|
1027
|
+
message: text
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function scanClaudeRateLimitHit() {
|
|
1032
|
+
const files = listJsonlFiles(homePath(".claude", "projects"), CLAUDE_LIMIT_HIT_MAX_FILES);
|
|
1033
|
+
// A hit only matters while its 5h/7d window can still be active, so files
|
|
1034
|
+
// untouched for longer than the longest window cannot change the result.
|
|
1035
|
+
const oldestUsefulMtimeMs = Date.now() - (8 * 24 * 60 * 60 * 1000);
|
|
1036
|
+
let fallback = null;
|
|
1037
|
+
|
|
1038
|
+
for (const file of files) {
|
|
1039
|
+
if (file.mtimeMs < oldestUsefulMtimeMs) break;
|
|
1040
|
+
const text = readTail(file.path, CLAUDE_LIMIT_HIT_TAIL_BYTES);
|
|
1041
|
+
const lines = text.split("\n");
|
|
1042
|
+
|
|
1043
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
1044
|
+
const line = lines[i].trim();
|
|
1045
|
+
if (
|
|
1046
|
+
!line
|
|
1047
|
+
|| (
|
|
1048
|
+
!line.includes("\"rate_limit\"")
|
|
1049
|
+
&& !(line.includes("\"apiErrorStatus\"") && line.includes("429"))
|
|
1050
|
+
&& !line.includes("You've hit your")
|
|
1051
|
+
&& !line.includes("You've reached your")
|
|
1052
|
+
)
|
|
1053
|
+
) {
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
try {
|
|
1058
|
+
const hit = claudeRateLimitHitFromJson(JSON.parse(line), file.path);
|
|
1059
|
+
if (hit) return hit;
|
|
1060
|
+
} catch {
|
|
1061
|
+
fallback = fallback || {
|
|
1062
|
+
error: "Found a Claude rate-limit line but could not parse it",
|
|
1063
|
+
source: file.path
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
return fallback;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
let claudeRateLimitHitMemo = null;
|
|
1073
|
+
|
|
1074
|
+
function latestClaudeRateLimitHit() {
|
|
1075
|
+
if (claudeRateLimitHitMemo && Date.now() - claudeRateLimitHitMemo.at < 5000) {
|
|
1076
|
+
return claudeRateLimitHitMemo.value;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const cached = readScanCache("claude-rate-limit-hit", scanCacheSeconds(10));
|
|
1080
|
+
const value = cached ? cached.value ?? null : scanClaudeRateLimitHit();
|
|
1081
|
+
if (!cached) writeScanCache("claude-rate-limit-hit", value);
|
|
1082
|
+
claudeRateLimitHitMemo = { at: Date.now(), value };
|
|
1083
|
+
return value;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function applyClaudeRateLimitHit(limit, hit, window) {
|
|
1087
|
+
if (!limit || !hit || hit.window !== window || !hit.timestamp) return limit;
|
|
1088
|
+
|
|
1089
|
+
const hitMillis = Date.parse(hit.timestamp);
|
|
1090
|
+
const resetMillis = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN;
|
|
1091
|
+
if (Number.isNaN(hitMillis) || Number.isNaN(resetMillis)) return limit;
|
|
1092
|
+
if (resetMillis <= Date.now()) return limit;
|
|
1093
|
+
|
|
1094
|
+
const windowMinutes = limit.windowMinutes ?? (window === "fiveHour" ? 300 : 10080);
|
|
1095
|
+
const windowStartMillis = resetMillis - (windowMinutes * 60 * 1000);
|
|
1096
|
+
if (hitMillis < windowStartMillis || hitMillis > resetMillis) return limit;
|
|
1097
|
+
|
|
1098
|
+
return {
|
|
1099
|
+
...limit,
|
|
1100
|
+
usedPercent: 100,
|
|
1101
|
+
remainingPercent: 0,
|
|
1102
|
+
limitReached: true,
|
|
1103
|
+
reachedAt: hit.timestamp,
|
|
1104
|
+
reachedSource: hit.source,
|
|
1105
|
+
reachedMessage: hit.message
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function captureClaudeStatusline(input) {
|
|
1110
|
+
const rateLimits = input.rate_limits ?? {};
|
|
1111
|
+
const sessionId = input.session_id ?? null;
|
|
1112
|
+
const previous = (sessionId ? readJson(claudeSessionCachePath(sessionId)) : null) ?? readJson(claudeCachePath());
|
|
1113
|
+
const sessionKind = claudeStatuslineSessionKind(input);
|
|
1114
|
+
const snapshot = {
|
|
1115
|
+
version: 1,
|
|
1116
|
+
provider: "claude",
|
|
1117
|
+
sourceType: "statusline",
|
|
1118
|
+
capturedAt: new Date().toISOString(),
|
|
1119
|
+
sessionId,
|
|
1120
|
+
promptId: input.prompt_id ?? null,
|
|
1121
|
+
transcriptPath: input.transcript_path ?? null,
|
|
1122
|
+
sessionKind,
|
|
1123
|
+
claudeVersion: input.version ?? null,
|
|
1124
|
+
model: {
|
|
1125
|
+
id: input.model?.id ?? null,
|
|
1126
|
+
displayName: input.model?.display_name ?? null
|
|
1127
|
+
},
|
|
1128
|
+
rateLimits: {
|
|
1129
|
+
fiveHour: claudeLimitFromStatusline(rateLimits.five_hour, 300),
|
|
1130
|
+
sevenDay: claudeLimitFromStatusline(rateLimits.seven_day, 10080)
|
|
1131
|
+
},
|
|
1132
|
+
rawRateLimits: rateLimits,
|
|
1133
|
+
contextWindow: input.context_window
|
|
1134
|
+
? {
|
|
1135
|
+
usedPercentage: input.context_window.used_percentage ?? null,
|
|
1136
|
+
remainingPercentage: input.context_window.remaining_percentage ?? null,
|
|
1137
|
+
contextWindowSize: input.context_window.context_window_size ?? null,
|
|
1138
|
+
totalInputTokens: input.context_window.total_input_tokens ?? null,
|
|
1139
|
+
totalOutputTokens: input.context_window.total_output_tokens ?? null
|
|
1140
|
+
}
|
|
1141
|
+
: null
|
|
1142
|
+
};
|
|
1143
|
+
|
|
1144
|
+
if (previous?.provider === "claude" && previous?.sourceType === "statusline") {
|
|
1145
|
+
const sameSession = previous.sessionId && previous.sessionId === snapshot.sessionId;
|
|
1146
|
+
if (sameSession && !snapshot.rateLimits.fiveHour) {
|
|
1147
|
+
snapshot.rateLimits.fiveHour = previous.rateLimits?.fiveHour ?? null;
|
|
1148
|
+
}
|
|
1149
|
+
if (sameSession && !snapshot.rateLimits.sevenDay) {
|
|
1150
|
+
snapshot.rateLimits.sevenDay = previous.rateLimits?.sevenDay ?? null;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
if (snapshot.sessionId) {
|
|
1155
|
+
writeJsonAtomic(claudeSessionCachePath(snapshot.sessionId), snapshot);
|
|
1156
|
+
}
|
|
1157
|
+
if (sessionKind !== "bg") {
|
|
1158
|
+
writeJsonAtomic(claudeCachePath(), snapshot);
|
|
1159
|
+
}
|
|
1160
|
+
return snapshot;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function claudeStatuslineResultFromCache(cache, cachePath, options = {}) {
|
|
1164
|
+
if (!cache || cache.provider !== "claude" || cache.sourceType !== "statusline") return null;
|
|
1165
|
+
const sessionKind = claudeStatuslineSessionKind(cache);
|
|
1166
|
+
if (!options.includeBackground && sessionKind === "bg") return null;
|
|
1167
|
+
|
|
1168
|
+
const primaryBase = normalizeClaudeCachedLimit(cache.rateLimits?.fiveHour);
|
|
1169
|
+
const secondaryBase = normalizeClaudeCachedLimit(cache.rateLimits?.sevenDay);
|
|
1170
|
+
if (!primaryBase && !secondaryBase) return null;
|
|
1171
|
+
|
|
1172
|
+
const rateLimitHit = latestClaudeRateLimitHit();
|
|
1173
|
+
const primary = applyClaudeRateLimitHit(primaryBase, rateLimitHit, "fiveHour");
|
|
1174
|
+
const secondary = applyClaudeRateLimitHit(secondaryBase, rateLimitHit, "sevenDay");
|
|
1175
|
+
const appliedRateLimitHit = primary?.limitReached || secondary?.limitReached ? rateLimitHit : null;
|
|
1176
|
+
|
|
1177
|
+
return {
|
|
1178
|
+
provider: "claude",
|
|
1179
|
+
ok: true,
|
|
1180
|
+
sourceType: "statusline",
|
|
1181
|
+
timestamp: cache.capturedAt ?? null,
|
|
1182
|
+
ageSeconds: cacheAgeSeconds(cache.capturedAt ?? null),
|
|
1183
|
+
source: cachePath,
|
|
1184
|
+
sessionId: cache.sessionId ?? null,
|
|
1185
|
+
sessionKind,
|
|
1186
|
+
model: cache.model?.displayName || cache.model?.id || null,
|
|
1187
|
+
percentRemaining: primary?.remainingPercent ?? secondary?.remainingPercent ?? null,
|
|
1188
|
+
percentUsed: primary?.usedPercent ?? secondary?.usedPercent ?? null,
|
|
1189
|
+
primary,
|
|
1190
|
+
secondary,
|
|
1191
|
+
rateLimitHit: appliedRateLimitHit,
|
|
1192
|
+
contextWindow: cache.contextWindow ?? null
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function listClaudeSessionCacheFiles(root = stateDir()) {
|
|
1197
|
+
let entries;
|
|
1198
|
+
try {
|
|
1199
|
+
entries = fs.readdirSync(claudeSessionCacheDir(root), { withFileTypes: true });
|
|
1200
|
+
} catch {
|
|
1201
|
+
return [];
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
return entries
|
|
1205
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
1206
|
+
.map((entry) => {
|
|
1207
|
+
const filePath = path.join(claudeSessionCacheDir(root), entry.name);
|
|
1208
|
+
const stat = safeStat(filePath);
|
|
1209
|
+
return stat ? { path: filePath, mtimeMs: stat.mtimeMs } : null;
|
|
1210
|
+
})
|
|
1211
|
+
.filter(Boolean)
|
|
1212
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
1213
|
+
.slice(0, 20)
|
|
1214
|
+
.map((entry) => entry.path);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function readClaudeStatuslineCacheFrom(root) {
|
|
1218
|
+
const cachePaths = [
|
|
1219
|
+
path.join(root, "claude-statusline.json"),
|
|
1220
|
+
...listClaudeSessionCacheFiles(root)
|
|
1221
|
+
];
|
|
1222
|
+
const entries = cachePaths
|
|
1223
|
+
.map((cachePath) => ({ cachePath, cache: readJson(cachePath) }))
|
|
1224
|
+
.filter((entry) => entry.cache)
|
|
1225
|
+
.sort((a, b) => (Date.parse(b.cache.capturedAt ?? "") || 0) - (Date.parse(a.cache.capturedAt ?? "") || 0));
|
|
1226
|
+
|
|
1227
|
+
// Evaluate newest-first and stop at the first usable snapshot so stale or
|
|
1228
|
+
// background-session caches do not cost extra transcript reads.
|
|
1229
|
+
for (const entry of entries) {
|
|
1230
|
+
const result = claudeStatuslineResultFromCache(entry.cache, entry.cachePath);
|
|
1231
|
+
if (result) return result;
|
|
1232
|
+
}
|
|
1233
|
+
return cachePaths.some((cachePath) => fs.existsSync(cachePath)) ? null : undefined;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function readClaudeStatuslineCache() {
|
|
1237
|
+
const primary = readClaudeStatuslineCacheFrom(stateDir());
|
|
1238
|
+
if (primary !== undefined) return primary;
|
|
1239
|
+
|
|
1240
|
+
if (path.resolve(legacyStateDir()) === path.resolve(stateDir())) return null;
|
|
1241
|
+
return readClaudeStatuslineCacheFrom(legacyStateDir()) ?? null;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function installClaudeStatusline(args) {
|
|
1245
|
+
const settingsPath = homePath(".claude", "settings.json");
|
|
1246
|
+
const existing = readJson(settingsPath) ?? {};
|
|
1247
|
+
const command = `${shellArg(process.execPath)} ${shellArg(fileURLToPath(import.meta.url))} capture-claude --muted --left-padding 1`;
|
|
1248
|
+
|
|
1249
|
+
if (existing.statusLine && !args.force) {
|
|
1250
|
+
throw new Error(`Claude statusLine already exists in ${settingsPath}. Re-run with --force to replace it.`);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
const next = {
|
|
1254
|
+
...existing,
|
|
1255
|
+
statusLine: {
|
|
1256
|
+
type: "command",
|
|
1257
|
+
command,
|
|
1258
|
+
padding: 0,
|
|
1259
|
+
refreshInterval: 5
|
|
1260
|
+
}
|
|
1261
|
+
};
|
|
1262
|
+
|
|
1263
|
+
writeJsonAtomic(settingsPath, next);
|
|
1264
|
+
return {
|
|
1265
|
+
settingsPath,
|
|
1266
|
+
command
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function uninstallClaudeStatusline() {
|
|
1271
|
+
const settingsPath = homePath(".claude", "settings.json");
|
|
1272
|
+
const existing = readJson(settingsPath) ?? {};
|
|
1273
|
+
const command = existing.statusLine?.command ?? "";
|
|
1274
|
+
const installedByAiBattery = command.includes("ai-battery.js") && command.includes("capture-claude");
|
|
1275
|
+
|
|
1276
|
+
if (!existing.statusLine) {
|
|
1277
|
+
return {
|
|
1278
|
+
settingsPath,
|
|
1279
|
+
changed: false,
|
|
1280
|
+
reason: "No Claude statusLine is configured"
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
if (!installedByAiBattery) {
|
|
1285
|
+
throw new Error(`Claude statusLine exists but does not look like AI Battery's command: ${command}`);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
const next = { ...existing };
|
|
1289
|
+
delete next.statusLine;
|
|
1290
|
+
writeJsonAtomic(settingsPath, next);
|
|
1291
|
+
return {
|
|
1292
|
+
settingsPath,
|
|
1293
|
+
changed: true
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function readCodex() {
|
|
1298
|
+
const running = isProviderRunning("codex");
|
|
1299
|
+
let scan = readScanCache("codex-status", scanCacheSeconds(4))?.value;
|
|
1300
|
+
if (!scan || typeof scan !== "object") {
|
|
1301
|
+
scan = scanCodexStatus();
|
|
1302
|
+
writeScanCache("codex-status", scan);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
const result = { ...scan, running };
|
|
1306
|
+
if (result.ok) result.ageSeconds = cacheAgeSeconds(result.timestamp ?? null);
|
|
1307
|
+
return result;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function scanCodexStatus() {
|
|
1311
|
+
const roots = (process.env.CODEX_HOME || homePath(".codex"))
|
|
1312
|
+
.split(",")
|
|
1313
|
+
.map((entry) => entry.trim())
|
|
1314
|
+
.filter(Boolean)
|
|
1315
|
+
.map((entry) => entry.replace(/^~(?=$|\/)/, userHome()));
|
|
1316
|
+
const files = prioritizeCodexSessionFiles(roots
|
|
1317
|
+
.flatMap((root) => listJsonlFiles(path.join(root, "sessions")))
|
|
1318
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs));
|
|
1319
|
+
|
|
1320
|
+
if (!files.length) {
|
|
1321
|
+
return {
|
|
1322
|
+
provider: "codex",
|
|
1323
|
+
ok: false,
|
|
1324
|
+
error: "No Codex session logs found",
|
|
1325
|
+
roots
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
const match = latestMatchingJsonLine(files, (line) => line.includes("\"rate_limits\""));
|
|
1330
|
+
const rateLimits = match?.json?.payload?.rate_limits;
|
|
1331
|
+
|
|
1332
|
+
if (!rateLimits) {
|
|
1333
|
+
return {
|
|
1334
|
+
provider: "codex",
|
|
1335
|
+
ok: false,
|
|
1336
|
+
error: "No Codex rate-limit event found in recent logs",
|
|
1337
|
+
roots
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// The approval mode lives in turn_context events of the same session, so
|
|
1342
|
+
// only that file needs a second look.
|
|
1343
|
+
const sessionFiles = files.filter((file) => file.path === match.file);
|
|
1344
|
+
const contextMatch = latestMatchingJsonLine(sessionFiles, (line) => line.includes("\"turn_context\""));
|
|
1345
|
+
const turnContext = contextMatch?.json?.payload ?? null;
|
|
1346
|
+
const approvalPolicy = turnContext?.approval_policy ?? null;
|
|
1347
|
+
const sandboxMode = turnContext?.sandbox_policy?.type ?? null;
|
|
1348
|
+
const collaborationMode = turnContext?.collaboration_mode?.mode ?? null;
|
|
1349
|
+
|
|
1350
|
+
const primary = normalizeLimit(rateLimits.primary);
|
|
1351
|
+
const secondary = normalizeLimit(rateLimits.secondary);
|
|
1352
|
+
|
|
1353
|
+
return {
|
|
1354
|
+
provider: "codex",
|
|
1355
|
+
ok: true,
|
|
1356
|
+
planType: rateLimits.plan_type ?? null,
|
|
1357
|
+
limitId: rateLimits.limit_id ?? null,
|
|
1358
|
+
timestamp: match.json.timestamp ?? null,
|
|
1359
|
+
source: match.file,
|
|
1360
|
+
percentRemaining: primary?.remainingPercent ?? null,
|
|
1361
|
+
percentUsed: primary?.usedPercent ?? null,
|
|
1362
|
+
primary,
|
|
1363
|
+
secondary,
|
|
1364
|
+
approvalPolicy,
|
|
1365
|
+
sandboxMode,
|
|
1366
|
+
collaborationMode,
|
|
1367
|
+
mode: codexModeLabel(approvalPolicy, sandboxMode, collaborationMode),
|
|
1368
|
+
reachedType: rateLimits.rate_limit_reached_type ?? null
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function codexModeLabel(approvalPolicy, sandboxMode, collaborationMode) {
|
|
1373
|
+
// Mirror Claude's own indicator: label only noteworthy states and stay
|
|
1374
|
+
// quiet in the default ask-before-running mode. Approvals (whether Codex
|
|
1375
|
+
// asks) and sandbox (what it may touch) are separate axes; "auto" strictly
|
|
1376
|
+
// means approvals are off. Values come from the last turn_context, so a
|
|
1377
|
+
// mid-session switch shows up after the next message.
|
|
1378
|
+
if (collaborationMode === "plan") return "plan";
|
|
1379
|
+
if (sandboxMode === "danger-full-access") return "full access";
|
|
1380
|
+
if (sandboxMode === "read-only") return "read only";
|
|
1381
|
+
if (approvalPolicy === "never" || approvalPolicy === "on-failure") return "auto";
|
|
1382
|
+
return null;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function usageTotal(usage) {
|
|
1386
|
+
if (!usage) return 0;
|
|
1387
|
+
return [
|
|
1388
|
+
usage.input_tokens,
|
|
1389
|
+
usage.output_tokens,
|
|
1390
|
+
usage.cache_creation_input_tokens,
|
|
1391
|
+
usage.cache_read_input_tokens
|
|
1392
|
+
].reduce((sum, value) => sum + (Number(value) || 0), 0);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
function readClaude() {
|
|
1396
|
+
const running = isProviderRunning("claude");
|
|
1397
|
+
const statuslineCache = readClaudeStatuslineCache();
|
|
1398
|
+
if (statuslineCache) return { ...statuslineCache, running };
|
|
1399
|
+
|
|
1400
|
+
const root = homePath(".claude", "projects");
|
|
1401
|
+
const files = listJsonlFiles(root);
|
|
1402
|
+
const statsPath = homePath(".claude", "stats-cache.json");
|
|
1403
|
+
|
|
1404
|
+
if (!files.length) {
|
|
1405
|
+
return {
|
|
1406
|
+
provider: "claude",
|
|
1407
|
+
ok: false,
|
|
1408
|
+
running,
|
|
1409
|
+
error: "No Claude Code project logs found",
|
|
1410
|
+
root
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
const match = latestMatchingJsonLine(files, (line) => line.includes("\"usage\""));
|
|
1415
|
+
const message = match?.json?.message;
|
|
1416
|
+
const usage = message?.usage;
|
|
1417
|
+
|
|
1418
|
+
if (!usage) {
|
|
1419
|
+
return {
|
|
1420
|
+
provider: "claude",
|
|
1421
|
+
ok: false,
|
|
1422
|
+
running,
|
|
1423
|
+
error: "No Claude usage event found in recent logs",
|
|
1424
|
+
root
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
let stats = null;
|
|
1429
|
+
try {
|
|
1430
|
+
stats = JSON.parse(fs.readFileSync(statsPath, "utf8"));
|
|
1431
|
+
} catch {
|
|
1432
|
+
stats = null;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
return {
|
|
1436
|
+
provider: "claude",
|
|
1437
|
+
ok: true,
|
|
1438
|
+
running,
|
|
1439
|
+
timestamp: match.json.timestamp ?? null,
|
|
1440
|
+
source: match.file,
|
|
1441
|
+
model: message.model ?? null,
|
|
1442
|
+
percentRemaining: null,
|
|
1443
|
+
percentUsed: null,
|
|
1444
|
+
note: "Claude Code local logs do not expose a remaining subscription percentage",
|
|
1445
|
+
lastTurnTokens: usageTotal(usage),
|
|
1446
|
+
lastTurn: {
|
|
1447
|
+
inputTokens: usage.input_tokens ?? 0,
|
|
1448
|
+
outputTokens: usage.output_tokens ?? 0,
|
|
1449
|
+
cacheCreationInputTokens: usage.cache_creation_input_tokens ?? 0,
|
|
1450
|
+
cacheReadInputTokens: usage.cache_read_input_tokens ?? 0,
|
|
1451
|
+
serviceTier: usage.service_tier ?? null
|
|
1452
|
+
},
|
|
1453
|
+
stats: stats
|
|
1454
|
+
? {
|
|
1455
|
+
lastComputedDate: stats.lastComputedDate ?? null,
|
|
1456
|
+
totalSessions: stats.totalSessions ?? null,
|
|
1457
|
+
totalMessages: stats.totalMessages ?? null
|
|
1458
|
+
}
|
|
1459
|
+
: null
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
function clamp(value, min, max) {
|
|
1464
|
+
return Math.max(min, Math.min(max, value));
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function colorize(text, color, style) {
|
|
1468
|
+
if (style === "plain") return text;
|
|
1469
|
+
if (style === "muted") {
|
|
1470
|
+
// Muted style dims the status text, but the battery bar keeps its
|
|
1471
|
+
// green/orange/red charge color so the level reads at a glance.
|
|
1472
|
+
const mutedColorCodes = {
|
|
1473
|
+
white: 97,
|
|
1474
|
+
green: 32,
|
|
1475
|
+
orange: "38;5;208",
|
|
1476
|
+
red: 31
|
|
1477
|
+
};
|
|
1478
|
+
const code = mutedColorCodes[color] ?? 90;
|
|
1479
|
+
return `\u001b[${code}m${text}\u001b[0m`;
|
|
1480
|
+
}
|
|
1481
|
+
const codes = {
|
|
1482
|
+
white: 37,
|
|
1483
|
+
green: 32,
|
|
1484
|
+
orange: "38;5;208",
|
|
1485
|
+
red: 31,
|
|
1486
|
+
gray: 90,
|
|
1487
|
+
cyan: 36
|
|
1488
|
+
};
|
|
1489
|
+
const tmuxColors = {
|
|
1490
|
+
white: "white",
|
|
1491
|
+
green: "green",
|
|
1492
|
+
orange: "colour208",
|
|
1493
|
+
red: "red",
|
|
1494
|
+
gray: "colour244",
|
|
1495
|
+
cyan: "cyan"
|
|
1496
|
+
};
|
|
1497
|
+
if (style === "tmux") return `#[fg=${tmuxColors[color] || "default"}]${text}#[default]`;
|
|
1498
|
+
return `\u001b[${codes[color] || 0}m${text}\u001b[0m`;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
function remainingColor(percent) {
|
|
1502
|
+
if (percent <= 20) return "red";
|
|
1503
|
+
if (percent <= 40) return "orange";
|
|
1504
|
+
return "green";
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
function activityColor(data) {
|
|
1508
|
+
return data?.running ? "white" : "gray";
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
function statusColorize(data, text, args) {
|
|
1512
|
+
return colorize(text, activityColor(data), args.style);
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
function bar(percent, width) {
|
|
1516
|
+
if (typeof percent !== "number") return "─".repeat(width);
|
|
1517
|
+
|
|
1518
|
+
// Whole cells only: eighth-width partial blocks (▏▎▍…) leave the rest of
|
|
1519
|
+
// their cell as bare background, which reads as a hole between the solid
|
|
1520
|
+
// fill and the ░ shade, and they render inconsistently across terminal
|
|
1521
|
+
// fonts. Rounding keeps both provider bars visually identical in style.
|
|
1522
|
+
const exact = (clamp(percent, 0, 100) / 100) * width;
|
|
1523
|
+
let full = Math.round(exact);
|
|
1524
|
+
if (percent > 0 && full === 0) full = 1;
|
|
1525
|
+
return `${"█".repeat(full)}${"░".repeat(width - full)}`;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
function duration(seconds) {
|
|
1529
|
+
if (typeof seconds !== "number") return "?";
|
|
1530
|
+
if (seconds <= 0) return "now";
|
|
1531
|
+
|
|
1532
|
+
const minutes = Math.floor(seconds / 60);
|
|
1533
|
+
if (minutes < 60) return `${minutes}m`;
|
|
1534
|
+
|
|
1535
|
+
const hours = Math.floor(minutes / 60);
|
|
1536
|
+
const restMinutes = minutes % 60;
|
|
1537
|
+
if (hours < 48) return restMinutes ? `${hours}h${restMinutes}m` : `${hours}h`;
|
|
1538
|
+
|
|
1539
|
+
const days = Math.floor(hours / 24);
|
|
1540
|
+
const restHours = hours % 24;
|
|
1541
|
+
return restHours ? `${days}d${restHours}h` : `${days}d`;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
function shortWindow(minutes) {
|
|
1545
|
+
if (minutes === 300) return "5h";
|
|
1546
|
+
if (minutes === 10080) return "7d";
|
|
1547
|
+
if (!minutes) return "?";
|
|
1548
|
+
if (minutes % 1440 === 0) return `${minutes / 1440}d`;
|
|
1549
|
+
if (minutes % 60 === 0) return `${minutes / 60}h`;
|
|
1550
|
+
return `${minutes}m`;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
function resetClock(limit) {
|
|
1554
|
+
if (!limit?.resetsAt || limit.resetPassed) return "--:--";
|
|
1555
|
+
const date = new Date(limit.resetsAt);
|
|
1556
|
+
if (Number.isNaN(date.getTime())) return "--:--";
|
|
1557
|
+
const hours = String(date.getHours()).padStart(2, "0");
|
|
1558
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
1559
|
+
return `${hours}:${minutes}`;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
function stripAnsi(text) {
|
|
1563
|
+
return String(text).replace(ANSI_RE, "");
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
function charWidth(char) {
|
|
1567
|
+
const code = char.codePointAt(0);
|
|
1568
|
+
if (
|
|
1569
|
+
(code >= 0x0300 && code <= 0x036f)
|
|
1570
|
+
|| (code >= 0x1ab0 && code <= 0x1aff)
|
|
1571
|
+
|| (code >= 0x1dc0 && code <= 0x1dff)
|
|
1572
|
+
|| (code >= 0x20d0 && code <= 0x20ff)
|
|
1573
|
+
|| (code >= 0xfe20 && code <= 0xfe2f)
|
|
1574
|
+
) {
|
|
1575
|
+
return 0;
|
|
1576
|
+
}
|
|
1577
|
+
if (
|
|
1578
|
+
(code >= 0x1100 && code <= 0x115f)
|
|
1579
|
+
|| code === 0x2329
|
|
1580
|
+
|| code === 0x232a
|
|
1581
|
+
|| (code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f)
|
|
1582
|
+
|| (code >= 0xac00 && code <= 0xd7a3)
|
|
1583
|
+
|| (code >= 0xf900 && code <= 0xfaff)
|
|
1584
|
+
|| (code >= 0xfe10 && code <= 0xfe19)
|
|
1585
|
+
|| (code >= 0xfe30 && code <= 0xfe6f)
|
|
1586
|
+
|| (code >= 0xff00 && code <= 0xff60)
|
|
1587
|
+
|| (code >= 0xffe0 && code <= 0xffe6)
|
|
1588
|
+
) {
|
|
1589
|
+
return 2;
|
|
1590
|
+
}
|
|
1591
|
+
return 1;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
function visibleWidth(text) {
|
|
1595
|
+
return Array.from(stripAnsi(text)).reduce((width, char) => width + charWidth(char), 0);
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function takeVisibleStart(text, maxWidth) {
|
|
1599
|
+
let width = 0;
|
|
1600
|
+
let output = "";
|
|
1601
|
+
for (const char of Array.from(text)) {
|
|
1602
|
+
const nextWidth = width + charWidth(char);
|
|
1603
|
+
if (nextWidth > maxWidth) break;
|
|
1604
|
+
output += char;
|
|
1605
|
+
width = nextWidth;
|
|
1606
|
+
}
|
|
1607
|
+
return output;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
function takeVisibleEnd(text, maxWidth) {
|
|
1611
|
+
let width = 0;
|
|
1612
|
+
let output = "";
|
|
1613
|
+
for (const char of Array.from(text).reverse()) {
|
|
1614
|
+
const nextWidth = width + charWidth(char);
|
|
1615
|
+
if (nextWidth > maxWidth) break;
|
|
1616
|
+
output = char + output;
|
|
1617
|
+
width = nextWidth;
|
|
1618
|
+
}
|
|
1619
|
+
return output;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
function truncateMiddleVisible(text, maxWidth) {
|
|
1623
|
+
if (visibleWidth(text) <= maxWidth) return text;
|
|
1624
|
+
if (maxWidth <= 0) return "";
|
|
1625
|
+
if (maxWidth === 1) return "…";
|
|
1626
|
+
|
|
1627
|
+
const marker = "…";
|
|
1628
|
+
const budget = maxWidth - visibleWidth(marker);
|
|
1629
|
+
const startWidth = Math.ceil(budget * 0.55);
|
|
1630
|
+
const endWidth = Math.max(0, budget - startWidth);
|
|
1631
|
+
return `${takeVisibleStart(text, startWidth)}${marker}${takeVisibleEnd(text, endWidth)}`;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
function numericColumn(value) {
|
|
1635
|
+
const number = Number(value);
|
|
1636
|
+
if (!Number.isFinite(number)) return null;
|
|
1637
|
+
const columns = Math.floor(number);
|
|
1638
|
+
if (columns < 20) return null;
|
|
1639
|
+
return clamp(columns, 20, 500);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
function numericGuard(value) {
|
|
1643
|
+
const number = Number(value);
|
|
1644
|
+
if (!Number.isFinite(number)) return null;
|
|
1645
|
+
return clamp(Math.floor(number), 0, 20);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
function statusLineColumns(input) {
|
|
1649
|
+
const candidates = [
|
|
1650
|
+
process.env.AI_BATTERY_COLUMNS,
|
|
1651
|
+
process.env.CLAUDEX_BATTERY_COLUMNS,
|
|
1652
|
+
input.terminal?.columns,
|
|
1653
|
+
input.terminal?.cols,
|
|
1654
|
+
input.terminal?.width,
|
|
1655
|
+
input.terminal_columns,
|
|
1656
|
+
input.terminal_width,
|
|
1657
|
+
input.columns,
|
|
1658
|
+
input.width,
|
|
1659
|
+
process.env.COLUMNS,
|
|
1660
|
+
process.stdout.columns
|
|
1661
|
+
];
|
|
1662
|
+
|
|
1663
|
+
for (const candidate of candidates) {
|
|
1664
|
+
const columns = numericColumn(candidate);
|
|
1665
|
+
if (columns) return columns;
|
|
1666
|
+
}
|
|
1667
|
+
return 80;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
function statusLineUsableColumns(input) {
|
|
1671
|
+
const guard = numericGuard(process.env.AI_BATTERY_COLUMN_GUARD)
|
|
1672
|
+
?? numericGuard(process.env.CLAUDEX_BATTERY_COLUMN_GUARD)
|
|
1673
|
+
?? DEFAULT_STATUSLINE_COLUMN_GUARD;
|
|
1674
|
+
return Math.max(20, statusLineColumns(input) - guard);
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
function contextRemainingPercent(input) {
|
|
1678
|
+
const context = input.context_window ?? input.contextWindow ?? null;
|
|
1679
|
+
const remaining = context?.remaining_percentage ?? context?.remainingPercentage;
|
|
1680
|
+
if (typeof remaining === "number") return clamp(Math.round(remaining), 0, 100);
|
|
1681
|
+
|
|
1682
|
+
const used = context?.used_percentage ?? context?.usedPercentage;
|
|
1683
|
+
if (typeof used === "number") return clamp(100 - Math.round(used), 0, 100);
|
|
1684
|
+
return null;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
function contextLeftText(input) {
|
|
1688
|
+
const remaining = contextRemainingPercent(input);
|
|
1689
|
+
if (remaining === null) return null;
|
|
1690
|
+
return `${remaining}% context left`;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
function alignHeader(left, right, columns) {
|
|
1694
|
+
if (!right) return left;
|
|
1695
|
+
|
|
1696
|
+
const gap = 2;
|
|
1697
|
+
const rightWidth = visibleWidth(right);
|
|
1698
|
+
if (!columns || columns <= rightWidth + gap) return `${left} ${right}`;
|
|
1699
|
+
|
|
1700
|
+
const leftBudget = columns - rightWidth - gap;
|
|
1701
|
+
const fittedLeft = truncateMiddleVisible(left, leftBudget);
|
|
1702
|
+
const spaces = Math.max(gap, columns - visibleWidth(fittedLeft) - rightWidth);
|
|
1703
|
+
return `${fittedLeft}${" ".repeat(spaces)}${right}`;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
function displayPath(dir) {
|
|
1707
|
+
if (!dir) return "~";
|
|
1708
|
+
const home = userHome();
|
|
1709
|
+
if (dir === home) return "~";
|
|
1710
|
+
if (dir.startsWith(`${home}/`)) return `~/${path.relative(home, dir)}`;
|
|
1711
|
+
return dir;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
function gitBranchFromDir(dir) {
|
|
1715
|
+
if (!dir) return null;
|
|
1716
|
+
try {
|
|
1717
|
+
const branch = execFileSync("git", ["-C", dir, "branch", "--show-current"], {
|
|
1718
|
+
encoding: "utf8",
|
|
1719
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1720
|
+
timeout: 500
|
|
1721
|
+
}).trim();
|
|
1722
|
+
return branch || null;
|
|
1723
|
+
} catch {
|
|
1724
|
+
return null;
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
function statuslineGitBranch(input) {
|
|
1729
|
+
const explicit = input.git?.branch
|
|
1730
|
+
|| input.git_branch
|
|
1731
|
+
|| input.workspace?.git_branch
|
|
1732
|
+
|| input.workspace?.git?.branch
|
|
1733
|
+
|| null;
|
|
1734
|
+
if (explicit) return explicit;
|
|
1735
|
+
|
|
1736
|
+
const dirs = [
|
|
1737
|
+
input.workspace?.current_dir,
|
|
1738
|
+
input.cwd,
|
|
1739
|
+
input.workspace?.project_dir
|
|
1740
|
+
].filter(Boolean);
|
|
1741
|
+
|
|
1742
|
+
for (const dir of dirs) {
|
|
1743
|
+
const branch = gitBranchFromDir(dir);
|
|
1744
|
+
if (branch) return branch;
|
|
1745
|
+
}
|
|
1746
|
+
return null;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
function claudeHeader(input, args) {
|
|
1750
|
+
const model = input.model?.display_name || input.model?.id || "Claude";
|
|
1751
|
+
const effort = input.effort?.level || null;
|
|
1752
|
+
const workspaceRoot = input.workspace?.project_dir || input.cwd || input.workspace?.current_dir || "";
|
|
1753
|
+
const gitBranch = statuslineGitBranch(input);
|
|
1754
|
+
const parts = [model];
|
|
1755
|
+
if (effort) parts.push(effort);
|
|
1756
|
+
parts.push("·");
|
|
1757
|
+
parts.push(displayPath(workspaceRoot));
|
|
1758
|
+
if (gitBranch) {
|
|
1759
|
+
parts.push("·");
|
|
1760
|
+
parts.push(gitBranch);
|
|
1761
|
+
}
|
|
1762
|
+
const left = parts.join(" ");
|
|
1763
|
+
const right = contextLeftText(input);
|
|
1764
|
+
const columns = Math.max(20, statusLineUsableColumns(input) - (args.leftPadding || 0));
|
|
1765
|
+
const header = alignHeader(left, right, columns);
|
|
1766
|
+
return statusColorize({ running: false }, header, args);
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
function limitResetText(limit) {
|
|
1770
|
+
if (!limit) return null;
|
|
1771
|
+
const label = shortWindow(limit.windowMinutes);
|
|
1772
|
+
return `${label} ${resetClock(limit)}`.padEnd(8, " ");
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
function ageText(seconds) {
|
|
1776
|
+
if (typeof seconds !== "number" || seconds < 60) return null;
|
|
1777
|
+
return `seen ${duration(seconds)} ago`;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
function divider(args) {
|
|
1781
|
+
return colorize(DIVIDER, "gray", args.style);
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
function weekText(secondary) {
|
|
1785
|
+
const label = shortWindow(secondary.windowMinutes);
|
|
1786
|
+
let text = `${label} ${secondary.remainingPercent}%`;
|
|
1787
|
+
if (secondary.remainingPercent <= 10 && !secondary.resetPassed) {
|
|
1788
|
+
text += ` ${resetClock(secondary)}`;
|
|
1789
|
+
}
|
|
1790
|
+
return text;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
function formatCodex(data, args) {
|
|
1794
|
+
if (!data.ok) return statusColorize(data, `Codex ? (${data.error})`, args);
|
|
1795
|
+
|
|
1796
|
+
const batteryColor = remainingColor(data.percentRemaining);
|
|
1797
|
+
const primary = data.primary;
|
|
1798
|
+
const secondary = data.secondary;
|
|
1799
|
+
const bits = [
|
|
1800
|
+
`${statusColorize(data, "Codex ", args)}${colorize(bar(data.percentRemaining, args.barWidth), batteryColor, args.style)}${statusColorize(data, ` ${data.percentRemaining}%`, args)}`
|
|
1801
|
+
];
|
|
1802
|
+
|
|
1803
|
+
const primaryReset = limitResetText(primary);
|
|
1804
|
+
if (primaryReset) {
|
|
1805
|
+
bits.push(divider(args));
|
|
1806
|
+
bits.push(statusColorize(data, primaryReset, args));
|
|
1807
|
+
}
|
|
1808
|
+
if (secondary) {
|
|
1809
|
+
bits.push(divider(args));
|
|
1810
|
+
bits.push(statusColorize(data, weekText(secondary), args));
|
|
1811
|
+
}
|
|
1812
|
+
if (data.reachedType) {
|
|
1813
|
+
bits.push(statusColorize(data, `limit ${data.reachedType}`, args));
|
|
1814
|
+
}
|
|
1815
|
+
if (args.showPaths) {
|
|
1816
|
+
const seen = ageText(data.ageSeconds);
|
|
1817
|
+
if (seen) bits.push(statusColorize(data, seen, args));
|
|
1818
|
+
bits.push(statusColorize(data, data.source, args));
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
return bits.join(" ");
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
function formatClaude(data, args) {
|
|
1825
|
+
if (!data.ok) return statusColorize(data, `Claude ? (${data.error})`, args);
|
|
1826
|
+
|
|
1827
|
+
if (data.sourceType === "statusline") {
|
|
1828
|
+
const batteryColor = remainingColor(data.percentRemaining);
|
|
1829
|
+
const bits = [
|
|
1830
|
+
`${statusColorize(data, "Claude ", args)}${colorize(bar(data.percentRemaining, args.barWidth), batteryColor, args.style)}${statusColorize(data, ` ${data.percentRemaining}%`, args)}`
|
|
1831
|
+
];
|
|
1832
|
+
const primaryReset = limitResetText(data.primary);
|
|
1833
|
+
if (primaryReset) {
|
|
1834
|
+
bits.push(divider(args));
|
|
1835
|
+
bits.push(statusColorize(data, primaryReset, args));
|
|
1836
|
+
}
|
|
1837
|
+
if (data.secondary) {
|
|
1838
|
+
bits.push(divider(args));
|
|
1839
|
+
bits.push(statusColorize(data, weekText(data.secondary), args));
|
|
1840
|
+
}
|
|
1841
|
+
if (args.showPaths) {
|
|
1842
|
+
const seen = ageText(data.ageSeconds);
|
|
1843
|
+
if (seen) bits.push(statusColorize(data, seen, args));
|
|
1844
|
+
bits.push(statusColorize(data, data.source, args));
|
|
1845
|
+
}
|
|
1846
|
+
return bits.join(" ");
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
const bits = [
|
|
1850
|
+
`${statusColorize(data, "Claude ", args)}${colorize(bar(null, args.barWidth), "gray", args.style)}${statusColorize(data, " --%", args)}`,
|
|
1851
|
+
divider(args),
|
|
1852
|
+
statusColorize(data, "5h --:--", args),
|
|
1853
|
+
divider(args),
|
|
1854
|
+
statusColorize(data, "7d ---%", args)
|
|
1855
|
+
];
|
|
1856
|
+
|
|
1857
|
+
if (args.showPaths) bits.push(statusColorize(data, data.source, args));
|
|
1858
|
+
return bits.join(" ");
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
function compactNumber(value) {
|
|
1862
|
+
const number = Number(value) || 0;
|
|
1863
|
+
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}M`;
|
|
1864
|
+
if (number >= 1_000) return `${(number / 1_000).toFixed(1)}k`;
|
|
1865
|
+
return String(number);
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
function collect(args) {
|
|
1869
|
+
const results = [];
|
|
1870
|
+
const includeHidden = args.provider !== "all";
|
|
1871
|
+
if ((args.provider === "all" || args.provider === "codex") && (includeHidden || providerVisible("codex"))) {
|
|
1872
|
+
results.push(readCodex());
|
|
1873
|
+
}
|
|
1874
|
+
if ((args.provider === "all" || args.provider === "claude") && (includeHidden || providerVisible("claude"))) {
|
|
1875
|
+
results.push(readClaude());
|
|
1876
|
+
}
|
|
1877
|
+
return {
|
|
1878
|
+
generatedAt: new Date().toISOString(),
|
|
1879
|
+
results
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
function renderLine(snapshot, args) {
|
|
1884
|
+
const providerDivider = ` ${colorize(PROVIDER_DIVIDER, "gray", args.style)} `;
|
|
1885
|
+
return snapshot.results
|
|
1886
|
+
.map((result) => {
|
|
1887
|
+
if (result.provider === "codex") return formatCodex(result, args);
|
|
1888
|
+
if (result.provider === "claude") return formatClaude(result, args);
|
|
1889
|
+
return `${result.provider} ?`;
|
|
1890
|
+
})
|
|
1891
|
+
.join(providerDivider);
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
function applyLeftPadding(output, args) {
|
|
1895
|
+
const padding = Math.max(0, Number(args.leftPadding) || 0);
|
|
1896
|
+
if (!padding) return output;
|
|
1897
|
+
const prefix = " ".repeat(padding);
|
|
1898
|
+
return String(output)
|
|
1899
|
+
.split("\n")
|
|
1900
|
+
.map((line) => `${prefix}${line}`)
|
|
1901
|
+
.join("\n");
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
function render(snapshot, args) {
|
|
1905
|
+
if (args.json) return JSON.stringify(snapshot, null, 2);
|
|
1906
|
+
|
|
1907
|
+
const maxWidth = args.maxWidth
|
|
1908
|
+
? Math.max(20, args.maxWidth - (Number(args.leftPadding) || 0))
|
|
1909
|
+
: null;
|
|
1910
|
+
|
|
1911
|
+
if (args.maxWidth) {
|
|
1912
|
+
for (let width = args.barWidth; width >= 4; width -= 1) {
|
|
1913
|
+
const trialArgs = { ...args, barWidth: width };
|
|
1914
|
+
const line = renderLine(snapshot, trialArgs);
|
|
1915
|
+
if (visibleWidth(line) <= maxWidth || width === 4) return applyLeftPadding(line, args);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
return applyLeftPadding(renderLine(snapshot, args), args);
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
async function main() {
|
|
1923
|
+
const args = parseArgs(process.argv.slice(2));
|
|
1924
|
+
if (args.help) {
|
|
1925
|
+
printHelp();
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
if (args.command === "setup") {
|
|
1930
|
+
const result = runSetup(args);
|
|
1931
|
+
if (args.json) {
|
|
1932
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1933
|
+
} else {
|
|
1934
|
+
if (result.claude) {
|
|
1935
|
+
console.log(`Claude statusLine installed: ${result.claude.settingsPath}`);
|
|
1936
|
+
}
|
|
1937
|
+
if (result.codex?.ok) {
|
|
1938
|
+
console.log(`Codex wrapper installed: ${result.codex.wrapperPath}`);
|
|
1939
|
+
console.log(`Original codex: ${result.codex.originalCommand}`);
|
|
1940
|
+
if (result.codex.path?.note) console.log(result.codex.path.note);
|
|
1941
|
+
} else if (result.codex?.skipped) {
|
|
1942
|
+
console.log(`Codex wrapper skipped: ${result.codex.reason}`);
|
|
1943
|
+
}
|
|
1944
|
+
const note = codexRestartNote();
|
|
1945
|
+
if (result.codex && note) console.log(note);
|
|
1946
|
+
}
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
if (args.command === "doctor") {
|
|
1951
|
+
const result = runDoctor();
|
|
1952
|
+
if (args.json) {
|
|
1953
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1954
|
+
} else {
|
|
1955
|
+
console.log(`AI Battery: ${result.aiBattery.script}`);
|
|
1956
|
+
console.log(`State: ${result.aiBattery.stateDir}`);
|
|
1957
|
+
console.log("");
|
|
1958
|
+
console.log(`Codex provider: ${result.codex.providerEnabled ? "on" : "off"}`);
|
|
1959
|
+
console.log(`Codex on PATH: ${result.codex.activeCodex || "not found"}`);
|
|
1960
|
+
console.log(`Codex wrapper: ${result.codex.wrapperInstalled ? "installed" : "missing"} (${result.codex.wrapperPath})`);
|
|
1961
|
+
console.log(`PATH uses wrapper: ${result.codex.activeIsWrapper ? "yes" : "no"}`);
|
|
1962
|
+
console.log(`Inside Codex: ${result.codex.insideCodex ? "yes" : "no"}`);
|
|
1963
|
+
console.log(`Current Codex wrapped: ${result.codex.currentCodexWrapped ? "yes" : "no"}`);
|
|
1964
|
+
if (result.codex.notes.length) {
|
|
1965
|
+
console.log("");
|
|
1966
|
+
for (const note of result.codex.notes) console.log(`- ${note}`);
|
|
1967
|
+
}
|
|
1968
|
+
console.log("");
|
|
1969
|
+
console.log(`Claude provider: ${result.claude.providerEnabled ? "on" : "off"}`);
|
|
1970
|
+
console.log(`Claude statusLine cache: ${result.claude.statuslineCache ? "found" : "missing"}`);
|
|
1971
|
+
}
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
if (args.command === "hud") {
|
|
1976
|
+
const result = runHud(args);
|
|
1977
|
+
process.exit(result.status ?? 0);
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
if (args.command === "on" || args.command === "off") {
|
|
1981
|
+
const visible = args.command === "on";
|
|
1982
|
+
const result = setProviderVisibility(args.targets, visible);
|
|
1983
|
+
if (args.json) {
|
|
1984
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1985
|
+
} else {
|
|
1986
|
+
const state = visible ? "on" : "off";
|
|
1987
|
+
console.log(`${result.providers.join(", ")} ${state}`);
|
|
1988
|
+
console.log(`Updated ${result.configPath}`);
|
|
1989
|
+
const note = visible && result.providers.includes("codex") ? codexRestartNote() : null;
|
|
1990
|
+
if (note) console.log(note);
|
|
1991
|
+
}
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
if (args.command === "install-claude-statusline") {
|
|
1996
|
+
const result = installClaudeStatusline(args);
|
|
1997
|
+
if (!args.json) {
|
|
1998
|
+
console.log(`Installed Claude statusLine: ${result.command}`);
|
|
1999
|
+
console.log(`Updated ${result.settingsPath}`);
|
|
2000
|
+
} else {
|
|
2001
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2002
|
+
}
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
if (args.command === "uninstall-claude-statusline") {
|
|
2007
|
+
const result = uninstallClaudeStatusline();
|
|
2008
|
+
if (!args.json) {
|
|
2009
|
+
if (result.changed) {
|
|
2010
|
+
console.log(`Removed Claude statusLine from ${result.settingsPath}`);
|
|
2011
|
+
} else {
|
|
2012
|
+
console.log(`${result.reason}: ${result.settingsPath}`);
|
|
2013
|
+
}
|
|
2014
|
+
} else {
|
|
2015
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2016
|
+
}
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
if (args.command === "capture-claude") {
|
|
2021
|
+
const inputText = await readStdin();
|
|
2022
|
+
if (!inputText.trim()) throw new Error("capture-claude expected Claude statusline JSON on stdin");
|
|
2023
|
+
const input = JSON.parse(inputText);
|
|
2024
|
+
const capturedClaude = captureClaudeStatusline(input);
|
|
2025
|
+
if (!args.silent) {
|
|
2026
|
+
const results = [];
|
|
2027
|
+
if (providerVisible("codex")) results.push(readCodex());
|
|
2028
|
+
if (providerVisible("claude")) {
|
|
2029
|
+
const capturedSource = capturedClaude.sessionKind === "bg" && capturedClaude.sessionId
|
|
2030
|
+
? claudeSessionCachePath(capturedClaude.sessionId)
|
|
2031
|
+
: claudeCachePath();
|
|
2032
|
+
const claudeData = claudeStatuslineResultFromCache(capturedClaude, capturedSource, { includeBackground: true }) ?? readClaude();
|
|
2033
|
+
results.push({ ...claudeData, running: true });
|
|
2034
|
+
}
|
|
2035
|
+
const header = args.header ? applyLeftPadding(claudeHeader(input, args), args) : "";
|
|
2036
|
+
const usage = render({
|
|
2037
|
+
generatedAt: new Date().toISOString(),
|
|
2038
|
+
results
|
|
2039
|
+
}, { ...args, activeProvider: "claude", maxWidth: statusLineUsableColumns(input) });
|
|
2040
|
+
console.log(header && usage ? `${header}\n${usage}` : (usage || header));
|
|
2041
|
+
}
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
const watchTty = args.watch && process.stdout.isTTY;
|
|
2046
|
+
let lastFrame = null;
|
|
2047
|
+
|
|
2048
|
+
const draw = () => {
|
|
2049
|
+
let renderArgs = args;
|
|
2050
|
+
if (watchTty && !args.maxWidth && Number.isFinite(process.stdout.columns)) {
|
|
2051
|
+
// Keep the line inside the terminal so the single-line repaint never
|
|
2052
|
+
// wraps and leaves residue rows behind.
|
|
2053
|
+
renderArgs = { ...args, maxWidth: Math.max(20, process.stdout.columns - 1) };
|
|
2054
|
+
}
|
|
2055
|
+
const output = render(collect(args), renderArgs);
|
|
2056
|
+
if (watchTty) {
|
|
2057
|
+
if (output === lastFrame) return;
|
|
2058
|
+
lastFrame = output;
|
|
2059
|
+
process.stdout.write("\u001b[2K\r");
|
|
2060
|
+
process.stdout.write(output);
|
|
2061
|
+
} else {
|
|
2062
|
+
console.log(output);
|
|
2063
|
+
}
|
|
2064
|
+
};
|
|
2065
|
+
|
|
2066
|
+
draw();
|
|
2067
|
+
|
|
2068
|
+
if (args.watch) {
|
|
2069
|
+
setInterval(draw, args.interval * 1000);
|
|
2070
|
+
if (watchTty) {
|
|
2071
|
+
process.stdout.on("resize", () => {
|
|
2072
|
+
lastFrame = null;
|
|
2073
|
+
draw();
|
|
2074
|
+
});
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
main().catch((error) => {
|
|
2080
|
+
console.error(`ai-battery: ${error.message}`);
|
|
2081
|
+
process.exit(1);
|
|
2082
|
+
});
|