@prohost/cli 0.9.0 → 0.10.1
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/CHANGELOG.md +63 -0
- package/README.md +77 -2
- package/dist/agent/accounts.d.ts +17 -1
- package/dist/agent/accounts.js +43 -2
- package/dist/agent/agent_commands.js +6 -3
- package/dist/agent/api.d.ts +6 -0
- package/dist/agent/api.js +10 -5
- package/dist/agent/base-url.d.ts +43 -0
- package/dist/agent/base-url.js +72 -0
- package/dist/agent/command.js +21 -6
- package/dist/agent/credentials.d.ts +9 -0
- package/dist/agent/credentials.js +1 -1
- package/dist/agent/pair.js +41 -20
- package/dist/index.d.ts +1 -0
- package/dist/index.js +27 -2
- package/dist/usage/backoff.d.ts +42 -0
- package/dist/usage/backoff.js +85 -0
- package/dist/usage/command.d.ts +34 -0
- package/dist/usage/command.js +344 -0
- package/dist/usage/config.d.ts +38 -0
- package/dist/usage/config.js +75 -0
- package/dist/usage/discovery.d.ts +63 -0
- package/dist/usage/discovery.js +187 -0
- package/dist/usage/key-input.d.ts +36 -0
- package/dist/usage/key-input.js +79 -0
- package/dist/usage/quota.d.ts +96 -0
- package/dist/usage/quota.js +145 -0
- package/dist/usage/scanner.d.ts +117 -0
- package/dist/usage/scanner.js +438 -0
- package/dist/usage/service.d.ts +122 -0
- package/dist/usage/service.js +307 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find the Claude Code and Codex logins already on this machine.
|
|
3
|
+
*
|
|
4
|
+
* People who juggle several subscriptions keep each one in its own config
|
|
5
|
+
* directory — `~/.claude-work`, `~/.codex-personal`, or whatever
|
|
6
|
+
* `$CLAUDE_CONFIG_DIR` / `$CODEX_HOME` points at. Observer mode reports all of
|
|
7
|
+
* them, so instead of asking for each one to be re-created under
|
|
8
|
+
* `~/.prohost-accounts`, it registers the directories that are already there.
|
|
9
|
+
*
|
|
10
|
+
* Only directory names and the presence of a few non-secret markers
|
|
11
|
+
* (`projects/`, `settings.json`, `sessions/`, `config.toml`…) are looked at —
|
|
12
|
+
* never a file's contents, and never anything that holds a credential.
|
|
13
|
+
* Whether a login is signed in is asked of the unmodified agent CLI later,
|
|
14
|
+
* exactly as `accounts.ts` already does.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { AccountError, DEFAULT_ACCOUNT_LABEL, MAX_REPORTED_ACCOUNTS, ensureDefaultAccount, isValidLabel, loadAccounts, registerAccountDir, } from '../agent/accounts.js';
|
|
20
|
+
/** Entries whose presence says "this is a Claude Code config directory". None of them hold a login. */
|
|
21
|
+
const CLAUDE_MARKERS = ['projects', 'settings.json', '.claude.json', 'history.jsonl', 'todos', 'statsig', 'shell-snapshots'];
|
|
22
|
+
/** Same for Codex. `auth.json` is deliberately not one of them. */
|
|
23
|
+
const CODEX_MARKERS = ['sessions', 'archived_sessions', 'config.toml', 'history.jsonl', 'log', 'version.json'];
|
|
24
|
+
const CLAUDE_NAME = /^\.claude(?:[-_.](.+))?$/;
|
|
25
|
+
const CODEX_NAME = /^\.codex(?:[-_.](.+))?$/;
|
|
26
|
+
function homeDir(env) {
|
|
27
|
+
return env.HOME || os.homedir();
|
|
28
|
+
}
|
|
29
|
+
function resolveReal(dir) {
|
|
30
|
+
try {
|
|
31
|
+
return realpathSync(dir);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return path.resolve(dir);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function isDirectory(p) {
|
|
38
|
+
try {
|
|
39
|
+
return statSync(p).isDirectory();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function looksLikeConfigDir(dir, runtime) {
|
|
46
|
+
const markers = runtime === 'claude_code' ? CLAUDE_MARKERS : CODEX_MARKERS;
|
|
47
|
+
return isDirectory(dir) && markers.some((m) => existsSync(path.join(dir, m)));
|
|
48
|
+
}
|
|
49
|
+
/** The directory an account's logs live in: its own, or the CLI's default under `$HOME`. */
|
|
50
|
+
export function accountConfigDir(record, env = process.env) {
|
|
51
|
+
if (record.dir)
|
|
52
|
+
return record.dir;
|
|
53
|
+
return path.join(homeDir(env), record.runtime === 'claude_code' ? '.claude' : '.codex');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A registry label from a directory name: `~/.claude-baxy` → `baxy`,
|
|
57
|
+
* `~/.codex_work` → `work`, `~/.claude` → `default`. Anything a label can't
|
|
58
|
+
* hold becomes `-`; an empty result falls back to the runtime's name.
|
|
59
|
+
*/
|
|
60
|
+
export function deriveLabel(dirName, runtime) {
|
|
61
|
+
const base = path.basename(dirName);
|
|
62
|
+
const match = (runtime === 'claude_code' ? CLAUDE_NAME : CODEX_NAME).exec(base);
|
|
63
|
+
if (match && match[1] === undefined)
|
|
64
|
+
return DEFAULT_ACCOUNT_LABEL;
|
|
65
|
+
const raw = match ? match[1] : base.replace(/^\.+/, '');
|
|
66
|
+
const cleaned = raw
|
|
67
|
+
.replace(/[^A-Za-z0-9_-]+/g, '-')
|
|
68
|
+
.replace(/^[^A-Za-z0-9]+/, '')
|
|
69
|
+
.replace(/-+$/, '')
|
|
70
|
+
.slice(0, 32);
|
|
71
|
+
if (cleaned && isValidLabel(cleaned))
|
|
72
|
+
return cleaned;
|
|
73
|
+
return runtime === 'claude_code' ? 'claude' : 'codex';
|
|
74
|
+
}
|
|
75
|
+
/** Make `label` unique among `taken` by appending `-2`, `-3`… (and never `default`). */
|
|
76
|
+
function uniqueLabel(label, taken) {
|
|
77
|
+
if (!taken.has(label) && label !== DEFAULT_ACCOUNT_LABEL)
|
|
78
|
+
return label;
|
|
79
|
+
for (let n = 2;; n += 1) {
|
|
80
|
+
const suffix = `-${n}`;
|
|
81
|
+
const candidate = `${label.slice(0, 32 - suffix.length)}${suffix}`;
|
|
82
|
+
if (!taken.has(candidate))
|
|
83
|
+
return candidate;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Every config directory on this machine, before any registry comparison:
|
|
88
|
+
* `~/.claude*` / `~/.codex*` that look like config directories, plus
|
|
89
|
+
* `$CLAUDE_CONFIG_DIR` / `$CODEX_HOME`. Deduplicated by real path.
|
|
90
|
+
*/
|
|
91
|
+
export function findConfigDirs(env = process.env) {
|
|
92
|
+
const home = homeDir(env);
|
|
93
|
+
const found = new Map();
|
|
94
|
+
const defaults = {
|
|
95
|
+
claude_code: resolveReal(path.join(home, '.claude')),
|
|
96
|
+
codex: resolveReal(path.join(home, '.codex')),
|
|
97
|
+
};
|
|
98
|
+
const consider = (dir, runtime, via) => {
|
|
99
|
+
if (!looksLikeConfigDir(dir, runtime))
|
|
100
|
+
return;
|
|
101
|
+
const real = resolveReal(dir);
|
|
102
|
+
const key = `${runtime}:${real}`;
|
|
103
|
+
if (found.has(key))
|
|
104
|
+
return;
|
|
105
|
+
const isDefault = real === defaults[runtime];
|
|
106
|
+
found.set(key, {
|
|
107
|
+
runtime,
|
|
108
|
+
dir: real,
|
|
109
|
+
label: isDefault ? DEFAULT_ACCOUNT_LABEL : deriveLabel(dir, runtime),
|
|
110
|
+
isDefault,
|
|
111
|
+
via,
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
let entries = [];
|
|
115
|
+
try {
|
|
116
|
+
entries = readdirSync(home).sort();
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
/* no readable home: only the env vars below */
|
|
120
|
+
}
|
|
121
|
+
for (const name of entries) {
|
|
122
|
+
if (CLAUDE_NAME.test(name))
|
|
123
|
+
consider(path.join(home, name), 'claude_code', 'home');
|
|
124
|
+
else if (CODEX_NAME.test(name))
|
|
125
|
+
consider(path.join(home, name), 'codex', 'home');
|
|
126
|
+
}
|
|
127
|
+
if (env.CLAUDE_CONFIG_DIR)
|
|
128
|
+
consider(path.resolve(env.CLAUDE_CONFIG_DIR), 'claude_code', 'CLAUDE_CONFIG_DIR');
|
|
129
|
+
if (env.CODEX_HOME)
|
|
130
|
+
consider(path.resolve(env.CODEX_HOME), 'codex', 'CODEX_HOME');
|
|
131
|
+
return [...found.values()];
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Register every login found by {@link findConfigDirs} that the machine-wide
|
|
135
|
+
* registry doesn't already know. `dryRun` reports the same answer and writes
|
|
136
|
+
* nothing.
|
|
137
|
+
*/
|
|
138
|
+
export function discoverAccounts(options = {}, env = process.env) {
|
|
139
|
+
const result = { added: [], known: [], skipped: [] };
|
|
140
|
+
const registry = loadAccounts(env);
|
|
141
|
+
const byDir = new Map();
|
|
142
|
+
for (const record of registry) {
|
|
143
|
+
// The implicit default (dir null) compares on the CLI's own directory.
|
|
144
|
+
byDir.set(`${record.runtime}:${resolveReal(accountConfigDir(record, env))}`, record);
|
|
145
|
+
}
|
|
146
|
+
const taken = { claude_code: new Set(), codex: new Set() };
|
|
147
|
+
for (const record of registry)
|
|
148
|
+
taken[record.runtime].add(record.label);
|
|
149
|
+
let room = MAX_REPORTED_ACCOUNTS - 2 - registry.filter((a) => a.label !== DEFAULT_ACCOUNT_LABEL).length;
|
|
150
|
+
for (const found of findConfigDirs(env)) {
|
|
151
|
+
const existing = byDir.get(`${found.runtime}:${found.dir}`);
|
|
152
|
+
if (existing) {
|
|
153
|
+
result.known.push({ ...found, label: existing.label, record: existing });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (found.isDefault) {
|
|
157
|
+
if (options.dryRun)
|
|
158
|
+
result.added.push(found);
|
|
159
|
+
else
|
|
160
|
+
result.added.push({ ...found, record: ensureDefaultAccount(found.runtime, env) });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (room <= 0) {
|
|
164
|
+
result.skipped.push({ ...found, reason: `this machine already has ${MAX_REPORTED_ACCOUNTS - 2} accounts` });
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const label = uniqueLabel(found.label, taken[found.runtime]);
|
|
168
|
+
taken[found.runtime].add(label);
|
|
169
|
+
const candidate = { ...found, label };
|
|
170
|
+
if (options.dryRun) {
|
|
171
|
+
result.added.push(candidate);
|
|
172
|
+
room -= 1;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const record = registerAccountDir({ label, runtime: found.runtime, dir: found.dir }, env);
|
|
177
|
+
result.added.push({ ...candidate, label: record.label, record });
|
|
178
|
+
room -= 1;
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
if (!(err instanceof AccountError))
|
|
182
|
+
throw err;
|
|
183
|
+
result.skipped.push({ ...candidate, reason: err.message });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading an API key for `prohost usage connect` without putting it on the
|
|
3
|
+
* command line, where it would land in shell history and the process list.
|
|
4
|
+
*
|
|
5
|
+
* Two safe paths: piped stdin (`pbpaste | prohost usage connect --key -`) and,
|
|
6
|
+
* at a terminal, a prompt that does not echo what is typed or pasted.
|
|
7
|
+
*/
|
|
8
|
+
/** The slice of `process.stdin` this module uses, so tests can pass a stream. */
|
|
9
|
+
export interface KeyStdin extends AsyncIterable<string | Buffer> {
|
|
10
|
+
isTTY?: boolean;
|
|
11
|
+
setRawMode?(mode: boolean): unknown;
|
|
12
|
+
setEncoding(encoding: BufferEncoding): unknown;
|
|
13
|
+
on(event: 'data', listener: (chunk: string | Buffer) => void): unknown;
|
|
14
|
+
removeListener(event: 'data', listener: (chunk: string | Buffer) => void): unknown;
|
|
15
|
+
resume(): unknown;
|
|
16
|
+
pause(): unknown;
|
|
17
|
+
}
|
|
18
|
+
/** Longer than any ProhostAI key; a pipe that runs past it is not a key. */
|
|
19
|
+
export declare const MAX_PIPED_KEY_CHARS = 4096;
|
|
20
|
+
export declare class PipedKeyTooLongError extends Error {
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The first non-empty line of piped stdin, trimmed; `undefined` when there is
|
|
24
|
+
* none. Returns as soon as that line ends, so a producer that keeps the pipe
|
|
25
|
+
* open does not hang `connect`, and stops reading past
|
|
26
|
+
* {@link MAX_PIPED_KEY_CHARS}.
|
|
27
|
+
*
|
|
28
|
+
* @throws PipedKeyTooLongError when no line ends within the limit.
|
|
29
|
+
*/
|
|
30
|
+
export declare function readPipedKey(stdin: KeyStdin): Promise<string | undefined>;
|
|
31
|
+
/**
|
|
32
|
+
* Prompt for a key at a terminal without echoing it. Resolves with the
|
|
33
|
+
* trimmed key, or `undefined` when nothing was entered or the user pressed
|
|
34
|
+
* Ctrl-C. The prompt goes to `write` (stderr), keeping stdout clean.
|
|
35
|
+
*/
|
|
36
|
+
export declare function promptHiddenKey(stdin: KeyStdin, write: (text: string) => void, prompt?: string): Promise<string | undefined>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading an API key for `prohost usage connect` without putting it on the
|
|
3
|
+
* command line, where it would land in shell history and the process list.
|
|
4
|
+
*
|
|
5
|
+
* Two safe paths: piped stdin (`pbpaste | prohost usage connect --key -`) and,
|
|
6
|
+
* at a terminal, a prompt that does not echo what is typed or pasted.
|
|
7
|
+
*/
|
|
8
|
+
/** Longer than any ProhostAI key; a pipe that runs past it is not a key. */
|
|
9
|
+
export const MAX_PIPED_KEY_CHARS = 4096;
|
|
10
|
+
export class PipedKeyTooLongError extends Error {
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The first non-empty line of piped stdin, trimmed; `undefined` when there is
|
|
14
|
+
* none. Returns as soon as that line ends, so a producer that keeps the pipe
|
|
15
|
+
* open does not hang `connect`, and stops reading past
|
|
16
|
+
* {@link MAX_PIPED_KEY_CHARS}.
|
|
17
|
+
*
|
|
18
|
+
* @throws PipedKeyTooLongError when no line ends within the limit.
|
|
19
|
+
*/
|
|
20
|
+
export async function readPipedKey(stdin) {
|
|
21
|
+
let pending = '';
|
|
22
|
+
for await (const chunk of stdin) {
|
|
23
|
+
pending += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
24
|
+
let newline = pending.search(/\r?\n/);
|
|
25
|
+
while (newline !== -1) {
|
|
26
|
+
// Measured before trimming: padding counts toward the bound too.
|
|
27
|
+
const raw = pending.slice(0, newline);
|
|
28
|
+
if (raw.length > MAX_PIPED_KEY_CHARS)
|
|
29
|
+
throw new PipedKeyTooLongError('piped input is not a key');
|
|
30
|
+
const line = raw.trim();
|
|
31
|
+
if (line)
|
|
32
|
+
return line;
|
|
33
|
+
pending = pending.slice(newline).replace(/^\r?\n/, '');
|
|
34
|
+
newline = pending.search(/\r?\n/);
|
|
35
|
+
}
|
|
36
|
+
if (pending.length > MAX_PIPED_KEY_CHARS)
|
|
37
|
+
throw new PipedKeyTooLongError('piped input is not a key');
|
|
38
|
+
}
|
|
39
|
+
if (pending.length > MAX_PIPED_KEY_CHARS)
|
|
40
|
+
throw new PipedKeyTooLongError('piped input is not a key');
|
|
41
|
+
return pending.trim() || undefined;
|
|
42
|
+
}
|
|
43
|
+
const ENTER = new Set(['\r', '\n', '\u0004']); // Return, or Ctrl-D
|
|
44
|
+
const CTRL_C = '\u0003';
|
|
45
|
+
const BACKSPACE = new Set(['\u007f', '\b']);
|
|
46
|
+
/**
|
|
47
|
+
* Prompt for a key at a terminal without echoing it. Resolves with the
|
|
48
|
+
* trimmed key, or `undefined` when nothing was entered or the user pressed
|
|
49
|
+
* Ctrl-C. The prompt goes to `write` (stderr), keeping stdout clean.
|
|
50
|
+
*/
|
|
51
|
+
export function promptHiddenKey(stdin, write, prompt = 'API key (usage:write scope; input hidden): ') {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
let value = '';
|
|
54
|
+
const finish = (result) => {
|
|
55
|
+
stdin.removeListener('data', onData);
|
|
56
|
+
stdin.setRawMode?.(false);
|
|
57
|
+
stdin.pause();
|
|
58
|
+
write('\n');
|
|
59
|
+
resolve(result);
|
|
60
|
+
};
|
|
61
|
+
const onData = (chunk) => {
|
|
62
|
+
for (const ch of typeof chunk === 'string' ? chunk : chunk.toString('utf8')) {
|
|
63
|
+
if (ENTER.has(ch))
|
|
64
|
+
return finish(value.trim() || undefined);
|
|
65
|
+
if (ch === CTRL_C)
|
|
66
|
+
return finish(undefined);
|
|
67
|
+
if (BACKSPACE.has(ch))
|
|
68
|
+
value = value.slice(0, -1);
|
|
69
|
+
else if (ch >= ' ')
|
|
70
|
+
value += ch;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
write(prompt);
|
|
74
|
+
stdin.setRawMode?.(true);
|
|
75
|
+
stdin.setEncoding('utf8');
|
|
76
|
+
stdin.on('data', onData);
|
|
77
|
+
stdin.resume();
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remaining capacity for every login on this machine, in `POST /v1/usage/reports` shape.
|
|
3
|
+
*
|
|
4
|
+
* The probes are the ones `agent run` already uses when idle (see
|
|
5
|
+
* `runtime_status.ts`): Claude Code's `get_usage` control request and Codex's
|
|
6
|
+
* `account/rateLimits/read`, each asked of the unmodified CLI with the
|
|
7
|
+
* account's own config directory, so no model turn is spent and no credential
|
|
8
|
+
* is read here. Sign-in state comes from `checkAuth` in `accounts.ts`.
|
|
9
|
+
*
|
|
10
|
+
* Each account is probed at most once per {@link QUOTA_REFRESH_AFTER_MS}, a
|
|
11
|
+
* few at a time, and what was learned is kept between probes (and restarts)
|
|
12
|
+
* so every report carries the latest known numbers with the time they were
|
|
13
|
+
* observed.
|
|
14
|
+
*/
|
|
15
|
+
import type { AccountBilling, AccountRecord, AccountRuntime, AuthCheck, AuthState, CommandRunner } from '../agent/accounts.js';
|
|
16
|
+
import type { QuotaWindow, RuntimeObservation } from '../agent/runtime_status.js';
|
|
17
|
+
/** Accounts probed at once. Each probe is a short-lived agent CLI process. */
|
|
18
|
+
export declare const PROBE_CONCURRENCY = 3;
|
|
19
|
+
/** What is known about one account between probes. Persisted in the status file. */
|
|
20
|
+
export interface AccountObservation {
|
|
21
|
+
/** Epoch ms of the last probe attempt, successful or not. */
|
|
22
|
+
probedAt?: number;
|
|
23
|
+
/** ISO time the windows were last learned. */
|
|
24
|
+
observedAt?: string;
|
|
25
|
+
authState: AuthState;
|
|
26
|
+
billing?: AccountBilling;
|
|
27
|
+
planLabel?: string;
|
|
28
|
+
windows: QuotaWindow[];
|
|
29
|
+
}
|
|
30
|
+
export type ObservationCache = Record<string, AccountObservation>;
|
|
31
|
+
export interface ProbeResult {
|
|
32
|
+
auth: AuthCheck;
|
|
33
|
+
observation?: RuntimeObservation;
|
|
34
|
+
}
|
|
35
|
+
export type Prober = (record: AccountRecord) => Promise<ProbeResult>;
|
|
36
|
+
/** Wire window (`RawWindow` in the server contract). */
|
|
37
|
+
export interface RawWindow {
|
|
38
|
+
kind: string;
|
|
39
|
+
usedPercent: number;
|
|
40
|
+
resetsAt: string | null;
|
|
41
|
+
modelLabel?: string;
|
|
42
|
+
}
|
|
43
|
+
export interface ReportedAccount {
|
|
44
|
+
accountKey: string;
|
|
45
|
+
label: string;
|
|
46
|
+
runtime: AccountRuntime;
|
|
47
|
+
billing: AccountBilling;
|
|
48
|
+
planLabel?: string;
|
|
49
|
+
authState: AuthState;
|
|
50
|
+
quotaWindows: RawWindow[];
|
|
51
|
+
observedAt?: string;
|
|
52
|
+
}
|
|
53
|
+
export interface UsageReportFrame {
|
|
54
|
+
source: 'cli';
|
|
55
|
+
machineId: string;
|
|
56
|
+
machine: string;
|
|
57
|
+
accounts: ReportedAccount[];
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Environment that points the agent CLI at this account. The default login is
|
|
61
|
+
* the CLI's own directory, so an inherited `CLAUDE_CONFIG_DIR` / `CODEX_HOME`
|
|
62
|
+
* (a foreground shell that exports one) is cleared rather than followed —
|
|
63
|
+
* otherwise "default" would probe some other login. Node drops `undefined`
|
|
64
|
+
* entries from a child's environment.
|
|
65
|
+
*/
|
|
66
|
+
export declare function probeEnv(record: AccountRecord): NodeJS.ProcessEnv;
|
|
67
|
+
/** The probes `agent run` uses, pointed at one account at a time. */
|
|
68
|
+
export declare function defaultProber(options?: {
|
|
69
|
+
run?: CommandRunner;
|
|
70
|
+
timeoutMs?: number;
|
|
71
|
+
}): Prober;
|
|
72
|
+
/** Run ``fn`` over ``items`` with at most ``limit`` in flight. Never rejects for one item. */
|
|
73
|
+
export declare function mapLimit<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void>;
|
|
74
|
+
/** Accounts due a probe: never probed, or probed {@link QUOTA_REFRESH_AFTER_MS} ago or more. */
|
|
75
|
+
export declare function dueForProbe(records: AccountRecord[], cache: ObservationCache, nowMs: number, refreshAfterMs?: number): AccountRecord[];
|
|
76
|
+
/** Probe every account that is due, folding the answers into ``cache``. */
|
|
77
|
+
export declare function probeAccounts(records: AccountRecord[], cache: ObservationCache, options: {
|
|
78
|
+
prober: Prober;
|
|
79
|
+
now?: () => number;
|
|
80
|
+
concurrency?: number;
|
|
81
|
+
refreshAfterMs?: number;
|
|
82
|
+
}): Promise<AccountRecord[]>;
|
|
83
|
+
export declare function toRawWindow(window: QuotaWindow): RawWindow;
|
|
84
|
+
/**
|
|
85
|
+
* The report body. At most {@link MAX_REPORTED_ACCOUNTS} accounts — the
|
|
86
|
+
* defaults first, then in registry order; `omitted` says how many did not fit.
|
|
87
|
+
*/
|
|
88
|
+
export declare function buildReportFrame(options: {
|
|
89
|
+
machineId: string;
|
|
90
|
+
machine: string;
|
|
91
|
+
records: AccountRecord[];
|
|
92
|
+
cache: ObservationCache;
|
|
93
|
+
}): {
|
|
94
|
+
frame: UsageReportFrame;
|
|
95
|
+
omitted: number;
|
|
96
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remaining capacity for every login on this machine, in `POST /v1/usage/reports` shape.
|
|
3
|
+
*
|
|
4
|
+
* The probes are the ones `agent run` already uses when idle (see
|
|
5
|
+
* `runtime_status.ts`): Claude Code's `get_usage` control request and Codex's
|
|
6
|
+
* `account/rateLimits/read`, each asked of the unmodified CLI with the
|
|
7
|
+
* account's own config directory, so no model turn is spent and no credential
|
|
8
|
+
* is read here. Sign-in state comes from `checkAuth` in `accounts.ts`.
|
|
9
|
+
*
|
|
10
|
+
* Each account is probed at most once per {@link QUOTA_REFRESH_AFTER_MS}, a
|
|
11
|
+
* few at a time, and what was learned is kept between probes (and restarts)
|
|
12
|
+
* so every report carries the latest known numbers with the time they were
|
|
13
|
+
* observed.
|
|
14
|
+
*/
|
|
15
|
+
import { DEFAULT_ACCOUNT_LABEL, MAX_REPORTED_ACCOUNTS, checkAuth, defaultCommandRunner } from '../agent/accounts.js';
|
|
16
|
+
import { PROVIDER_TIMEOUT_MS, QUOTA_REFRESH_AFTER_MS, claudeQuotaProvider, codexQuotaProvider, mergeWindows, } from '../agent/runtime_status.js';
|
|
17
|
+
import { CLI_VERSION } from '../version.js';
|
|
18
|
+
/** Accounts probed at once. Each probe is a short-lived agent CLI process. */
|
|
19
|
+
export const PROBE_CONCURRENCY = 3;
|
|
20
|
+
/** Most windows per account the server accepts. */
|
|
21
|
+
const MAX_WINDOWS = 8;
|
|
22
|
+
const MAX_LABEL_CHARS = 60;
|
|
23
|
+
const MAX_MACHINE_CHARS = 120;
|
|
24
|
+
/**
|
|
25
|
+
* Environment that points the agent CLI at this account. The default login is
|
|
26
|
+
* the CLI's own directory, so an inherited `CLAUDE_CONFIG_DIR` / `CODEX_HOME`
|
|
27
|
+
* (a foreground shell that exports one) is cleared rather than followed —
|
|
28
|
+
* otherwise "default" would probe some other login. Node drops `undefined`
|
|
29
|
+
* entries from a child's environment.
|
|
30
|
+
*/
|
|
31
|
+
export function probeEnv(record) {
|
|
32
|
+
const variable = record.runtime === 'claude_code' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME';
|
|
33
|
+
return { [variable]: record.dir ?? undefined };
|
|
34
|
+
}
|
|
35
|
+
/** The probes `agent run` uses, pointed at one account at a time. */
|
|
36
|
+
export function defaultProber(options = {}) {
|
|
37
|
+
const baseRun = options.run ?? defaultCommandRunner;
|
|
38
|
+
return async (record) => {
|
|
39
|
+
const env = probeEnv(record);
|
|
40
|
+
// `checkAuth` passes only the account's own variable; the override clears
|
|
41
|
+
// an inherited one for the default login.
|
|
42
|
+
const auth = await checkAuth(record, { run: (command, args, extra) => baseRun(command, args, { ...extra, ...env }) });
|
|
43
|
+
// A signed-out login has nothing to say about capacity.
|
|
44
|
+
if (auth.state === 'expired')
|
|
45
|
+
return { auth };
|
|
46
|
+
const provider = record.runtime === 'claude_code'
|
|
47
|
+
? claudeQuotaProvider({ binary: 'claude', timeoutMs: options.timeoutMs ?? PROVIDER_TIMEOUT_MS, env: () => env })
|
|
48
|
+
: codexQuotaProvider({
|
|
49
|
+
binary: 'codex',
|
|
50
|
+
clientVersion: CLI_VERSION,
|
|
51
|
+
timeoutMs: options.timeoutMs ?? PROVIDER_TIMEOUT_MS,
|
|
52
|
+
env: () => env,
|
|
53
|
+
});
|
|
54
|
+
let observation;
|
|
55
|
+
try {
|
|
56
|
+
observation = await provider.refresh();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
observation = undefined;
|
|
60
|
+
}
|
|
61
|
+
return { auth, observation };
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** Run ``fn`` over ``items`` with at most ``limit`` in flight. Never rejects for one item. */
|
|
65
|
+
export async function mapLimit(items, limit, fn) {
|
|
66
|
+
let next = 0;
|
|
67
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
68
|
+
while (next < items.length) {
|
|
69
|
+
const item = items[next];
|
|
70
|
+
next += 1;
|
|
71
|
+
try {
|
|
72
|
+
await fn(item);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
/* one account's failure never stops the others */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
await Promise.all(workers);
|
|
80
|
+
}
|
|
81
|
+
/** Accounts due a probe: never probed, or probed {@link QUOTA_REFRESH_AFTER_MS} ago or more. */
|
|
82
|
+
export function dueForProbe(records, cache, nowMs, refreshAfterMs = QUOTA_REFRESH_AFTER_MS) {
|
|
83
|
+
return records.filter((r) => {
|
|
84
|
+
const probedAt = cache[r.key]?.probedAt;
|
|
85
|
+
return probedAt === undefined || nowMs - probedAt >= refreshAfterMs;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/** Probe every account that is due, folding the answers into ``cache``. */
|
|
89
|
+
export async function probeAccounts(records, cache, options) {
|
|
90
|
+
const now = options.now ?? Date.now;
|
|
91
|
+
const due = dueForProbe(records, cache, now(), options.refreshAfterMs);
|
|
92
|
+
await mapLimit(due, options.concurrency ?? PROBE_CONCURRENCY, async (record) => {
|
|
93
|
+
const startedAt = now();
|
|
94
|
+
const previous = cache[record.key] ?? { authState: 'unknown', windows: [] };
|
|
95
|
+
// Stamped before the probe so a probe that throws still waits its turn.
|
|
96
|
+
cache[record.key] = { ...previous, probedAt: startedAt };
|
|
97
|
+
const { auth, observation } = await options.prober(record);
|
|
98
|
+
const next = { ...previous, probedAt: startedAt, authState: auth.state };
|
|
99
|
+
if (auth.billing)
|
|
100
|
+
next.billing = auth.billing;
|
|
101
|
+
if (observation?.plan_label)
|
|
102
|
+
next.planLabel = observation.plan_label;
|
|
103
|
+
if (observation?.quota_windows && observation.quota_windows.length > 0) {
|
|
104
|
+
next.windows = mergeWindows(previous.windows, observation.quota_windows);
|
|
105
|
+
next.observedAt = new Date(now()).toISOString();
|
|
106
|
+
}
|
|
107
|
+
cache[record.key] = next;
|
|
108
|
+
});
|
|
109
|
+
return due;
|
|
110
|
+
}
|
|
111
|
+
export function toRawWindow(window) {
|
|
112
|
+
return {
|
|
113
|
+
kind: window.kind,
|
|
114
|
+
usedPercent: window.used_percent,
|
|
115
|
+
resetsAt: window.resets_at,
|
|
116
|
+
...(window.model_label ? { modelLabel: window.model_label } : {}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The report body. At most {@link MAX_REPORTED_ACCOUNTS} accounts — the
|
|
121
|
+
* defaults first, then in registry order; `omitted` says how many did not fit.
|
|
122
|
+
*/
|
|
123
|
+
export function buildReportFrame(options) {
|
|
124
|
+
const ordered = [
|
|
125
|
+
...options.records.filter((r) => r.label === DEFAULT_ACCOUNT_LABEL),
|
|
126
|
+
...options.records.filter((r) => r.label !== DEFAULT_ACCOUNT_LABEL),
|
|
127
|
+
];
|
|
128
|
+
const accounts = ordered.slice(0, MAX_REPORTED_ACCOUNTS).map((record) => {
|
|
129
|
+
const seen = options.cache[record.key];
|
|
130
|
+
return {
|
|
131
|
+
accountKey: record.key,
|
|
132
|
+
label: record.label.slice(0, MAX_LABEL_CHARS),
|
|
133
|
+
runtime: record.runtime,
|
|
134
|
+
billing: seen?.billing ?? record.billing,
|
|
135
|
+
...(seen?.planLabel ? { planLabel: seen.planLabel } : {}),
|
|
136
|
+
authState: seen?.authState ?? 'unknown',
|
|
137
|
+
quotaWindows: (seen?.windows ?? []).slice(0, MAX_WINDOWS).map(toRawWindow),
|
|
138
|
+
...(seen?.observedAt ? { observedAt: seen.observedAt } : {}),
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
return {
|
|
142
|
+
frame: { source: 'cli', machineId: options.machineId, machine: options.machine.slice(0, MAX_MACHINE_CHARS), accounts },
|
|
143
|
+
omitted: Math.max(0, ordered.length - MAX_REPORTED_ACCOUNTS),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token totals from the session logs Claude Code and Codex already write.
|
|
3
|
+
*
|
|
4
|
+
* Aggregates only: tokens per (local day, account, runtime, model, project),
|
|
5
|
+
* where the project is the basename of the working directory the log names.
|
|
6
|
+
* No prompt, reply, tool call or file path ever leaves this module — lines are
|
|
7
|
+
* parsed for their usage counters and nothing else is kept.
|
|
8
|
+
*
|
|
9
|
+
* - **Claude Code** writes `<config dir>/projects/<project>/<session>.jsonl`.
|
|
10
|
+
* Each assistant line carries the API response's `message.usage`; one
|
|
11
|
+
* response is split over several lines (one per content block) that repeat
|
|
12
|
+
* the same usage, and a resumed session can copy earlier lines into a new
|
|
13
|
+
* file. So usage is keyed on `message.id` across every file: the first
|
|
14
|
+
* sighting counts, a later one only adds what grew.
|
|
15
|
+
* - **Codex** writes `$CODEX_HOME/sessions/YYYY/MM/DD/rollout-*.jsonl` (moved
|
|
16
|
+
* to `archived_sessions/` when archived). Its `token_count` events carry the
|
|
17
|
+
* session's running `total_token_usage`; usage is the growth of that total
|
|
18
|
+
* between events, credited to the model of the latest `turn_context`. Its
|
|
19
|
+
* `input_tokens` include the cached ones, so they are split apart here.
|
|
20
|
+
*
|
|
21
|
+
* Incremental: a cursor per file (byte offset, size, mtime) lives in the scan
|
|
22
|
+
* state, so each pass reads only what was appended. The first pass looks back
|
|
23
|
+
* {@link RETENTION_DAYS} days. Every (day, account) a pass changes is marked dirty and is
|
|
24
|
+
* re-sent whole — the server replaces a day's rows, so a resend is harmless.
|
|
25
|
+
*/
|
|
26
|
+
import type { AccountRuntime } from '../agent/accounts.js';
|
|
27
|
+
/** How far back the first scan looks, and how long totals are kept locally. */
|
|
28
|
+
export declare const RETENTION_DAYS = 30;
|
|
29
|
+
/** Most rows in one `POST /v1/usage/token-totals`. */
|
|
30
|
+
export declare const MAX_ROWS_PER_BATCH = 2000;
|
|
31
|
+
/** `[input, cachedInput, cacheWrite, output]`. Claude's input excludes cache; Codex's is split to match. */
|
|
32
|
+
export type Totals = [number, number, number, number];
|
|
33
|
+
export interface ScanAccount {
|
|
34
|
+
key: string;
|
|
35
|
+
runtime: AccountRuntime;
|
|
36
|
+
/** The login's config directory. */
|
|
37
|
+
dir: string;
|
|
38
|
+
}
|
|
39
|
+
interface ClaudeCursor {
|
|
40
|
+
kind: 'claude';
|
|
41
|
+
offset: number;
|
|
42
|
+
size: number;
|
|
43
|
+
mtimeMs: number;
|
|
44
|
+
}
|
|
45
|
+
interface CodexCursor {
|
|
46
|
+
kind: 'codex';
|
|
47
|
+
offset: number;
|
|
48
|
+
size: number;
|
|
49
|
+
mtimeMs: number;
|
|
50
|
+
/** The session's running `total_token_usage` as last seen, raw (input includes cached). */
|
|
51
|
+
totals: Totals;
|
|
52
|
+
model?: string;
|
|
53
|
+
project?: string | null;
|
|
54
|
+
}
|
|
55
|
+
type Cursor = ClaudeCursor | CodexCursor;
|
|
56
|
+
export interface ScanState {
|
|
57
|
+
version: 1;
|
|
58
|
+
files: Record<string, Cursor>;
|
|
59
|
+
/** Bucket key ({@link bucketKey}) → totals. */
|
|
60
|
+
buckets: Record<string, Totals>;
|
|
61
|
+
/** Claude response id → the bucket it was credited to and what was credited. */
|
|
62
|
+
seen: Record<string, [string, number, number, number, number]>;
|
|
63
|
+
/** `day|accountKey` pairs ({@link pendingKey}) changed since they were last uploaded. */
|
|
64
|
+
dirty: string[];
|
|
65
|
+
/** Pending pair → consecutive uploads the server only partly accepted. */
|
|
66
|
+
strikes: Record<string, number>;
|
|
67
|
+
}
|
|
68
|
+
/** A dirty entry: one account's rows for one day. */
|
|
69
|
+
export declare function pendingKey(day: string, accountKey: string): string;
|
|
70
|
+
/** The day of a dirty entry. */
|
|
71
|
+
export declare function pendingDay(entry: string): string;
|
|
72
|
+
/** Days with anything pending, sorted. */
|
|
73
|
+
export declare function pendingDays(state: ScanState): string[];
|
|
74
|
+
export interface TokenRow {
|
|
75
|
+
day: string;
|
|
76
|
+
accountKey: string;
|
|
77
|
+
runtime: AccountRuntime;
|
|
78
|
+
model: string;
|
|
79
|
+
project: string | null;
|
|
80
|
+
inputTokens: number;
|
|
81
|
+
cachedInputTokens: number;
|
|
82
|
+
cacheWriteTokens: number;
|
|
83
|
+
outputTokens: number;
|
|
84
|
+
}
|
|
85
|
+
export interface ScanOptions {
|
|
86
|
+
now?: number;
|
|
87
|
+
/** IANA zone the days are bucketed in. Defaults to this machine's. */
|
|
88
|
+
timeZone?: string;
|
|
89
|
+
}
|
|
90
|
+
export interface ScanSummary {
|
|
91
|
+
filesRead: number;
|
|
92
|
+
/** Days whose totals changed in this pass. */
|
|
93
|
+
touchedDays: string[];
|
|
94
|
+
}
|
|
95
|
+
export declare function emptyScanState(): ScanState;
|
|
96
|
+
export declare function loadScanState(file: string): ScanState;
|
|
97
|
+
export declare function saveScanState(file: string, state: ScanState): void;
|
|
98
|
+
/** `YYYY-MM-DD` of an instant in a time zone (the machine's by default). */
|
|
99
|
+
export declare function dayKey(ms: number, timeZone?: string): string;
|
|
100
|
+
/** Bucket identity. JSON so it round-trips; the day is first so it can be read cheaply. */
|
|
101
|
+
export declare function bucketKey(day: string, accountKey: string, runtime: AccountRuntime, model: string, project: string | null): string;
|
|
102
|
+
/** Basename of a working directory — the repo or folder name, never the path. */
|
|
103
|
+
export declare function projectName(cwd: unknown): string | null;
|
|
104
|
+
/**
|
|
105
|
+
* One incremental pass over every account's logs. Mutates and returns
|
|
106
|
+
* ``state``; the caller persists it.
|
|
107
|
+
*/
|
|
108
|
+
export declare function scanUsage(accounts: ScanAccount[], state: ScanState, options?: ScanOptions): ScanSummary;
|
|
109
|
+
/** The rows for the given days, in upload shape. Zero-token buckets are left out. */
|
|
110
|
+
export declare function rowsForDays(state: ScanState, days: Iterable<string>): TokenRow[];
|
|
111
|
+
/** The rows for the given `day|accountKey` pairs, in upload shape. */
|
|
112
|
+
export declare function rowsForPending(state: ScanState, pairs: Iterable<string>): TokenRow[];
|
|
113
|
+
/** Split rows into upload batches. */
|
|
114
|
+
export declare function batches<T>(rows: T[], size?: number): T[][];
|
|
115
|
+
/** Local totals per account since ``fromDay`` (inclusive), for `usage status`. */
|
|
116
|
+
export declare function localTotals(state: ScanState, fromDay: string): Map<string, Totals>;
|
|
117
|
+
export {};
|