@prohost/cli 0.5.0 → 0.7.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/CHANGELOG.md +101 -0
- package/README.md +61 -0
- package/dist/agent/account_runtime.d.ts +75 -0
- package/dist/agent/account_runtime.js +130 -0
- package/dist/agent/accounts.d.ts +162 -0
- package/dist/agent/accounts.js +413 -0
- package/dist/agent/agent_commands.d.ts +22 -0
- package/dist/agent/agent_commands.js +170 -0
- package/dist/agent/api.d.ts +15 -1
- package/dist/agent/api.js +19 -2
- package/dist/agent/claude.d.ts +42 -0
- package/dist/agent/claude.js +218 -0
- package/dist/agent/command.d.ts +18 -1
- package/dist/agent/command.js +88 -4
- package/dist/agent/contract.d.ts +43 -0
- package/dist/agent/contract.js +13 -0
- package/dist/agent/credentials.d.ts +5 -0
- package/dist/agent/events.d.ts +104 -0
- package/dist/agent/events.js +175 -0
- package/dist/agent/list.d.ts +41 -0
- package/dist/agent/list.js +126 -0
- package/dist/agent/lock.d.ts +37 -0
- package/dist/agent/lock.js +91 -0
- package/dist/agent/run.d.ts +15 -2
- package/dist/agent/run.js +227 -19
- package/dist/agent/runtime_status.d.ts +257 -0
- package/dist/agent/runtime_status.js +681 -0
- package/dist/agent/workspace.d.ts +40 -0
- package/dist/agent/workspace.js +97 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +27 -5
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime, model and remaining capacity — what `agent run` tells ProhostAI about itself.
|
|
3
|
+
*
|
|
4
|
+
* ProhostAI shows every paired AI employee with its runtime ("Claude Code"),
|
|
5
|
+
* model ("Opus 5.5"), machine and how much of the operator's Claude or Codex
|
|
6
|
+
* subscription is left. Only this machine can see those things, so the harness
|
|
7
|
+
* reports them in a `runtime_status` frame on the socket it already holds.
|
|
8
|
+
*
|
|
9
|
+
* **Credentials never leave this machine, and this module never reads them.**
|
|
10
|
+
* Every number comes from the agent CLI answering with its own login:
|
|
11
|
+
*
|
|
12
|
+
* - Claude Code: the `rate_limit_event` and `system/init` lines of a run's own
|
|
13
|
+
* `stream-json` output, and — when idle — the `get_usage` control request to
|
|
14
|
+
* an unmodified `claude -p --input-format stream-json` (no model turn).
|
|
15
|
+
* - Codex: the rollout file Codex writes for each run under
|
|
16
|
+
* `$CODEX_HOME/sessions`, and — when idle — `account/rateLimits/read` on
|
|
17
|
+
* `codex app-server`.
|
|
18
|
+
*
|
|
19
|
+
* Nothing here opens the Claude Code keychain item or `~/.codex/auth.json`, and
|
|
20
|
+
* nothing forwards an account id, email or org. Only labels, percentages and
|
|
21
|
+
* reset times go on the wire. Unknown or missing data is omitted, never sent as
|
|
22
|
+
* zero.
|
|
23
|
+
*
|
|
24
|
+
* Every parser here fails soft, in the same spirit as `readClaudeStream`: the
|
|
25
|
+
* shapes belong to other tools and grow without asking us.
|
|
26
|
+
*/
|
|
27
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
28
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
29
|
+
import os from 'node:os';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
/** Feature name the server lists in `auth_ok.features` when it accepts the frame. */
|
|
32
|
+
export const RUNTIME_STATUS_FEATURE = 'runtime_status';
|
|
33
|
+
/** Routine report cadence while connected. */
|
|
34
|
+
export const STATUS_INTERVAL_MS = 5 * 60_000;
|
|
35
|
+
/**
|
|
36
|
+
* Minimum spacing between frames. The server accepts one per 30 s per socket
|
|
37
|
+
* and drops the rest; a second of margin keeps clock jitter from costing us
|
|
38
|
+
* the frame that carried a change.
|
|
39
|
+
*/
|
|
40
|
+
export const MIN_FRAME_SPACING_MS = 31_000;
|
|
41
|
+
/** Ask the agent CLI for fresh numbers when the last ones are this old. */
|
|
42
|
+
export const QUOTA_REFRESH_AFTER_MS = 15 * 60_000;
|
|
43
|
+
/** Ceiling on one idle quota probe. */
|
|
44
|
+
export const PROVIDER_TIMEOUT_MS = 10_000;
|
|
45
|
+
/** Percent at or above which a window counts as running low / used up. */
|
|
46
|
+
const LOW_PERCENT = 75;
|
|
47
|
+
const EXHAUSTED_PERCENT = 100;
|
|
48
|
+
// ── Small, defensive readers ───────────────────────────────────────────────
|
|
49
|
+
function asObject(value) {
|
|
50
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
51
|
+
? value
|
|
52
|
+
: undefined;
|
|
53
|
+
}
|
|
54
|
+
function text(value) {
|
|
55
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
56
|
+
}
|
|
57
|
+
function parseLine(line) {
|
|
58
|
+
const trimmed = line.trim();
|
|
59
|
+
if (!trimmed.startsWith('{'))
|
|
60
|
+
return undefined;
|
|
61
|
+
try {
|
|
62
|
+
return asObject(JSON.parse(trimmed));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** A percentage on a 0–``scale`` scale, as 0–100 rounded to one decimal. */
|
|
69
|
+
export function percent(value, scale) {
|
|
70
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
71
|
+
return undefined;
|
|
72
|
+
const pct = value * (100 / scale);
|
|
73
|
+
return Math.min(100, Math.max(0, Math.round(pct * 10) / 10));
|
|
74
|
+
}
|
|
75
|
+
/** Epoch seconds or an ISO string, as ISO-8601; anything else is `undefined`. */
|
|
76
|
+
export function isoTime(value) {
|
|
77
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 1e9) {
|
|
78
|
+
return new Date(value * 1000).toISOString();
|
|
79
|
+
}
|
|
80
|
+
if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) {
|
|
81
|
+
return new Date(value).toISOString();
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
/** First `X.Y.Z…` in a `--version` line, e.g. `2.1.282 (Claude Code)` or `codex-cli 0.146.0`. */
|
|
86
|
+
export function parseVersion(stdout) {
|
|
87
|
+
return /\b(\d+\.\d+\.\d+[0-9A-Za-z.+-]*)/.exec(stdout)?.[1];
|
|
88
|
+
}
|
|
89
|
+
// ── Claude Code ────────────────────────────────────────────────────────────
|
|
90
|
+
const CLAUDE_WINDOWS = [
|
|
91
|
+
['five_hour', 'session'],
|
|
92
|
+
['seven_day', 'weekly'],
|
|
93
|
+
];
|
|
94
|
+
/** One window, or nothing when the usage itself is unknown. */
|
|
95
|
+
function quotaWindow(kind, used, resets) {
|
|
96
|
+
return used === undefined ? [] : [{ kind, used_percent: used, resets_at: resets ?? null }];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Windows from a stream `rate_limit_event.rate_limit_info`.
|
|
100
|
+
*
|
|
101
|
+
* `unifiedWindows.*.utilization` is a FRACTION (0–1, and it can exceed 1) with
|
|
102
|
+
* epoch-second resets — unlike `get_usage`, which is 0–100 with ISO strings.
|
|
103
|
+
* The field is marked internal upstream, so an unexpected shape yields nothing.
|
|
104
|
+
*/
|
|
105
|
+
export function fromClaudeRateLimitInfo(info) {
|
|
106
|
+
const windows = asObject(asObject(info)?.unifiedWindows) ?? {};
|
|
107
|
+
return CLAUDE_WINDOWS.flatMap(([key, kind]) => {
|
|
108
|
+
const entry = asObject(windows[key]);
|
|
109
|
+
const used = percent(entry?.utilization, 1);
|
|
110
|
+
const at = isoTime(entry?.resetsAt);
|
|
111
|
+
// The event's schema requires `resetsAt` on every window it sends (it
|
|
112
|
+
// omits a rolled-over window instead), so one without it is malformed.
|
|
113
|
+
return at ? quotaWindow(kind, used, at) : [];
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Windows from a `get_usage` control response (0–100, ISO strings).
|
|
118
|
+
*
|
|
119
|
+
* A window with a usage but a `null` reset is kept, with `resets_at: null` —
|
|
120
|
+
* that is how an idle account's 5-hour window reads, and dropping it is what
|
|
121
|
+
* left paired agents showing a weekly window and nothing else (B3).
|
|
122
|
+
*
|
|
123
|
+
* Per-model weekly windows come from `rate_limits.limits[]` rows of kind
|
|
124
|
+
* `weekly_scoped` (classified on `kind`, as the schema asks, never on a
|
|
125
|
+
* label), falling back to `model_scoped[]`.
|
|
126
|
+
*/
|
|
127
|
+
export function fromClaudeGetUsage(usage) {
|
|
128
|
+
const record = asObject(usage);
|
|
129
|
+
if (record?.rate_limits_available !== true)
|
|
130
|
+
return [];
|
|
131
|
+
const limits = asObject(record.rate_limits) ?? {};
|
|
132
|
+
const windows = CLAUDE_WINDOWS.flatMap(([key, kind]) => {
|
|
133
|
+
const entry = asObject(limits[key]);
|
|
134
|
+
return quotaWindow(kind, percent(entry?.utilization, 100), isoTime(entry?.resets_at));
|
|
135
|
+
});
|
|
136
|
+
const perModel = new Map();
|
|
137
|
+
const rows = Array.isArray(limits.limits) ? limits.limits : [];
|
|
138
|
+
for (const raw of rows) {
|
|
139
|
+
const row = asObject(raw);
|
|
140
|
+
if (row?.kind !== 'weekly_scoped')
|
|
141
|
+
continue;
|
|
142
|
+
const model = text(asObject(asObject(row.scope)?.model)?.display_name);
|
|
143
|
+
const used = percent(row.percent, 100);
|
|
144
|
+
if (!model || used === undefined || perModel.has(model))
|
|
145
|
+
continue;
|
|
146
|
+
perModel.set(model, { kind: 'weekly_model', used_percent: used, resets_at: isoTime(row.resets_at) ?? null, model_label: model });
|
|
147
|
+
}
|
|
148
|
+
if (perModel.size === 0 && Array.isArray(limits.model_scoped)) {
|
|
149
|
+
for (const raw of limits.model_scoped) {
|
|
150
|
+
const row = asObject(raw);
|
|
151
|
+
const model = text(row?.display_name);
|
|
152
|
+
const used = percent(row?.utilization, 100);
|
|
153
|
+
if (!model || used === undefined || perModel.has(model))
|
|
154
|
+
continue;
|
|
155
|
+
perModel.set(model, { kind: 'weekly_model', used_percent: used, resets_at: isoTime(row?.resets_at) ?? null, model_label: model });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return [...windows, ...perModel.values()];
|
|
159
|
+
}
|
|
160
|
+
const CLAUDE_PLANS = {
|
|
161
|
+
pro: 'Claude Pro',
|
|
162
|
+
max: 'Claude Max',
|
|
163
|
+
team: 'Claude Team',
|
|
164
|
+
enterprise: 'Claude Enterprise',
|
|
165
|
+
};
|
|
166
|
+
/** `get_usage.subscription_type` → display label; `undefined` for anything else. */
|
|
167
|
+
export function claudePlanLabel(subscriptionType) {
|
|
168
|
+
const key = text(subscriptionType)?.toLowerCase();
|
|
169
|
+
return key ? CLAUDE_PLANS[key] : undefined;
|
|
170
|
+
}
|
|
171
|
+
// ── Codex ──────────────────────────────────────────────────────────────────
|
|
172
|
+
/**
|
|
173
|
+
* Windows from Codex `rate_limits` (rollout, snake_case) or `rateLimits`
|
|
174
|
+
* (app-server, camelCase).
|
|
175
|
+
*
|
|
176
|
+
* Classified by window LENGTH, never by slot: on some plans `primary` is the
|
|
177
|
+
* weekly window and there is no 5-hour one at all.
|
|
178
|
+
*/
|
|
179
|
+
export function fromCodexRateLimits(rateLimits, lineTimestamp) {
|
|
180
|
+
const record = asObject(rateLimits);
|
|
181
|
+
if (!record)
|
|
182
|
+
return [];
|
|
183
|
+
return [record.primary, record.secondary].flatMap((raw) => {
|
|
184
|
+
const window = asObject(raw);
|
|
185
|
+
if (!window)
|
|
186
|
+
return [];
|
|
187
|
+
const minutes = window.window_minutes ?? window.windowDurationMins;
|
|
188
|
+
const kind = typeof minutes !== 'number'
|
|
189
|
+
? undefined
|
|
190
|
+
: minutes > 0 && minutes <= 360
|
|
191
|
+
? 'session'
|
|
192
|
+
: minutes >= 8_640 && minutes <= 11_520
|
|
193
|
+
? 'weekly'
|
|
194
|
+
: undefined;
|
|
195
|
+
const used = percent(window.used_percent ?? window.usedPercent, 100);
|
|
196
|
+
let at = isoTime(window.resets_at ?? window.resetsAt);
|
|
197
|
+
// Older Codex builds said "in N seconds", relative to the line it was on.
|
|
198
|
+
if (!at && typeof window.resets_in_seconds === 'number' && lineTimestamp) {
|
|
199
|
+
const base = Date.parse(lineTimestamp);
|
|
200
|
+
if (!Number.isNaN(base))
|
|
201
|
+
at = new Date(base + window.resets_in_seconds * 1000).toISOString();
|
|
202
|
+
}
|
|
203
|
+
return kind ? quotaWindow(kind, used, at) : [];
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/** `plan_type` / `planType` → `ChatGPT Pro`; `undefined` when there is no plan. */
|
|
207
|
+
export function codexPlanLabel(planType) {
|
|
208
|
+
const plan = text(planType);
|
|
209
|
+
if (!plan)
|
|
210
|
+
return undefined;
|
|
211
|
+
return `ChatGPT ${plan.charAt(0).toUpperCase()}${plan.slice(1).toLowerCase()}`;
|
|
212
|
+
}
|
|
213
|
+
/** Model named in the exec string itself (`-m gpt-5` / `--model gpt-5` / `--model=gpt-5`). */
|
|
214
|
+
export function modelFromExec(exec) {
|
|
215
|
+
const words = exec.trim().split(/\s+/);
|
|
216
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
217
|
+
const word = words[i] ?? '';
|
|
218
|
+
if (word === '-m' || word === '--model')
|
|
219
|
+
return text(words[i + 1]?.replace(/^['"]|['"]$/g, ''));
|
|
220
|
+
if (word.startsWith('--model='))
|
|
221
|
+
return text(word.slice('--model='.length).replace(/^['"]|['"]$/g, ''));
|
|
222
|
+
}
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
/** `$CODEX_HOME`, defaulting to `~/.codex` exactly as Codex does. */
|
|
226
|
+
export function codexHome(env = process.env) {
|
|
227
|
+
return env.CODEX_HOME || path.join(env.HOME || os.homedir(), '.codex');
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The top-level `model = "…"` in `$CODEX_HOME/config.toml` — Codex's default.
|
|
231
|
+
*
|
|
232
|
+
* Only the top level, before the first `[table]`: a `model` inside a profile
|
|
233
|
+
* table is not the default. A first guess only; the rollout's `turn_context`
|
|
234
|
+
* replaces it after the first run.
|
|
235
|
+
*/
|
|
236
|
+
export function codexConfiguredModel(env = process.env) {
|
|
237
|
+
let content;
|
|
238
|
+
try {
|
|
239
|
+
content = readFileSync(path.join(codexHome(env), 'config.toml'), 'utf8');
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
for (const line of content.split('\n')) {
|
|
245
|
+
const trimmed = line.trim();
|
|
246
|
+
if (trimmed.startsWith('['))
|
|
247
|
+
return undefined;
|
|
248
|
+
const match = /^model\s*=\s*["']([^"']+)["']/.exec(trimmed);
|
|
249
|
+
if (match)
|
|
250
|
+
return text(match[1]);
|
|
251
|
+
}
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Locate the rollout for one thread: `sessions/YYYY/MM/DD/rollout-*-<thread_id>.jsonl`.
|
|
256
|
+
*
|
|
257
|
+
* The date directory is local time and a resumed thread lives under the day it
|
|
258
|
+
* started, so the date is never guessed — days are scanned newest first.
|
|
259
|
+
*/
|
|
260
|
+
export function findCodexRollout(threadId, env = process.env) {
|
|
261
|
+
if (!/^[0-9A-Za-z-]+$/.test(threadId))
|
|
262
|
+
return undefined;
|
|
263
|
+
const suffix = `-${threadId}.jsonl`;
|
|
264
|
+
const root = path.join(codexHome(env), 'sessions');
|
|
265
|
+
const children = (dir) => {
|
|
266
|
+
try {
|
|
267
|
+
return readdirSync(dir).sort().reverse();
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
for (const year of children(root)) {
|
|
274
|
+
for (const month of children(path.join(root, year))) {
|
|
275
|
+
for (const day of children(path.join(root, year, month))) {
|
|
276
|
+
const dir = path.join(root, year, month, day);
|
|
277
|
+
const hit = children(dir).find((name) => name.startsWith('rollout-') && name.endsWith(suffix));
|
|
278
|
+
if (hit)
|
|
279
|
+
return path.join(dir, hit);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* What a Codex rollout says: the model the turn really used (after `-m`,
|
|
287
|
+
* profiles and `-c`), the CLI version, and the last rate-limit snapshot.
|
|
288
|
+
*/
|
|
289
|
+
export function parseCodexRollout(content) {
|
|
290
|
+
const observed = {};
|
|
291
|
+
for (const line of content.split('\n')) {
|
|
292
|
+
const entry = parseLine(line);
|
|
293
|
+
if (!entry)
|
|
294
|
+
continue;
|
|
295
|
+
const payload = asObject(entry.payload);
|
|
296
|
+
if (!payload)
|
|
297
|
+
continue;
|
|
298
|
+
if (entry.type === 'session_meta') {
|
|
299
|
+
observed.runtime_version = text(payload.cli_version) ?? observed.runtime_version;
|
|
300
|
+
}
|
|
301
|
+
else if (entry.type === 'turn_context') {
|
|
302
|
+
observed.model = text(payload.model) ?? observed.model;
|
|
303
|
+
}
|
|
304
|
+
else if (entry.type === 'event_msg' && payload.type === 'token_count' && payload.rate_limits) {
|
|
305
|
+
// One per model response — the last one is the freshest.
|
|
306
|
+
observed.quota_windows = fromCodexRateLimits(payload.rate_limits, typeof entry.timestamp === 'string' ? entry.timestamp : undefined);
|
|
307
|
+
observed.plan_label = codexPlanLabel(asObject(payload.rate_limits)?.plan_type) ?? observed.plan_label;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return observed;
|
|
311
|
+
}
|
|
312
|
+
/** Read the rollout for a finished Codex thread, if it can be found. */
|
|
313
|
+
export function readCodexObservation(threadId, env = process.env) {
|
|
314
|
+
if (!threadId)
|
|
315
|
+
return undefined;
|
|
316
|
+
const file = findCodexRollout(threadId, env);
|
|
317
|
+
if (!file || !existsSync(file))
|
|
318
|
+
return undefined;
|
|
319
|
+
try {
|
|
320
|
+
return parseCodexRollout(readFileSync(file, 'utf8'));
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Spawn ``command args``, write ``lines`` as JSONL, and resolve with the first
|
|
328
|
+
* stdout line ``pick`` accepts. Kills the child on answer, error or timeout.
|
|
329
|
+
*/
|
|
330
|
+
function exchangeJsonLines(spawnImpl, command, args, lines, pick, timeoutMs, env) {
|
|
331
|
+
return new Promise((resolve) => {
|
|
332
|
+
let settled = false;
|
|
333
|
+
let buffer = '';
|
|
334
|
+
let child;
|
|
335
|
+
const finish = (value) => {
|
|
336
|
+
if (settled)
|
|
337
|
+
return;
|
|
338
|
+
settled = true;
|
|
339
|
+
clearTimeout(timer);
|
|
340
|
+
try {
|
|
341
|
+
child?.kill();
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
/* already gone */
|
|
345
|
+
}
|
|
346
|
+
resolve(value);
|
|
347
|
+
};
|
|
348
|
+
const timer = setTimeout(() => finish(undefined), timeoutMs);
|
|
349
|
+
timer.unref?.();
|
|
350
|
+
try {
|
|
351
|
+
// `env` selects the account (CLAUDE_CONFIG_DIR / CODEX_HOME) — the
|
|
352
|
+
// probe must ask the login the agent actually runs on.
|
|
353
|
+
child = spawnImpl(command, args, {
|
|
354
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
355
|
+
...(env ? { env: { ...process.env, ...env } } : {}),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
finish(undefined);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
child.on('error', () => finish(undefined));
|
|
363
|
+
child.on('close', () => finish(undefined));
|
|
364
|
+
child.stdout?.setEncoding('utf8');
|
|
365
|
+
child.stdout?.on('data', (chunk) => {
|
|
366
|
+
buffer += chunk;
|
|
367
|
+
if (buffer.length > 1024 * 1024)
|
|
368
|
+
buffer = buffer.slice(-64 * 1024);
|
|
369
|
+
const parts = buffer.split('\n');
|
|
370
|
+
buffer = parts.pop() ?? '';
|
|
371
|
+
for (const part of parts) {
|
|
372
|
+
const parsed = parseLine(part);
|
|
373
|
+
if (!parsed)
|
|
374
|
+
continue;
|
|
375
|
+
const value = pick(parsed);
|
|
376
|
+
if (value !== undefined) {
|
|
377
|
+
finish(value);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
child.stdin?.on('error', () => {
|
|
383
|
+
/* child exited before reading; `close` settles */
|
|
384
|
+
});
|
|
385
|
+
child.stdin?.write(lines.map((line) => JSON.stringify(line)).join('\n') + '\n');
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Claude Code's `get_usage` control request. No model turn is made, so no
|
|
390
|
+
* quota is spent. The `initialize` response carries account identity; it is
|
|
391
|
+
* never read.
|
|
392
|
+
*/
|
|
393
|
+
export function claudeQuotaProvider(options) {
|
|
394
|
+
return {
|
|
395
|
+
refresh: () => exchangeJsonLines(options.spawnImpl ?? nodeSpawn, options.binary, ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose'], [
|
|
396
|
+
{ type: 'control_request', request_id: 'prohost-init', request: { subtype: 'initialize' } },
|
|
397
|
+
{
|
|
398
|
+
type: 'control_request',
|
|
399
|
+
request_id: 'prohost-usage',
|
|
400
|
+
request: { subtype: 'get_usage', skip_behaviors: true },
|
|
401
|
+
},
|
|
402
|
+
], (line) => {
|
|
403
|
+
if (line.type !== 'control_response')
|
|
404
|
+
return undefined;
|
|
405
|
+
const response = asObject(line.response);
|
|
406
|
+
if (response?.request_id !== 'prohost-usage')
|
|
407
|
+
return undefined;
|
|
408
|
+
if (response.subtype !== 'success')
|
|
409
|
+
return {};
|
|
410
|
+
const usage = asObject(response.response) ?? response;
|
|
411
|
+
const windows = fromClaudeGetUsage(usage);
|
|
412
|
+
return {
|
|
413
|
+
plan_label: claudePlanLabel(usage.subscription_type),
|
|
414
|
+
...(windows.length > 0 ? { quota_windows: windows } : {}),
|
|
415
|
+
};
|
|
416
|
+
}, options.timeoutMs ?? PROVIDER_TIMEOUT_MS, options.env?.()),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
/** Codex's `account/rateLimits/read` over `codex app-server` stdio JSON-RPC. */
|
|
420
|
+
export function codexQuotaProvider(options) {
|
|
421
|
+
return {
|
|
422
|
+
refresh: () => exchangeJsonLines(options.spawnImpl ?? nodeSpawn, options.binary, ['app-server'], [
|
|
423
|
+
{
|
|
424
|
+
id: 1,
|
|
425
|
+
method: 'initialize',
|
|
426
|
+
params: { clientInfo: { name: 'prohost-cli', version: options.clientVersion } },
|
|
427
|
+
},
|
|
428
|
+
{ method: 'initialized' },
|
|
429
|
+
{ id: 2, method: 'account/rateLimits/read' },
|
|
430
|
+
], (line) => {
|
|
431
|
+
if (line.id !== 2)
|
|
432
|
+
return undefined;
|
|
433
|
+
const rateLimits = asObject(asObject(line.result)?.rateLimits);
|
|
434
|
+
if (!rateLimits)
|
|
435
|
+
return {};
|
|
436
|
+
const windows = fromCodexRateLimits(rateLimits);
|
|
437
|
+
return {
|
|
438
|
+
plan_label: codexPlanLabel(rateLimits.planType),
|
|
439
|
+
...(windows.length > 0 ? { quota_windows: windows } : {}),
|
|
440
|
+
};
|
|
441
|
+
}, options.timeoutMs ?? PROVIDER_TIMEOUT_MS, options.env?.()),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
// ── The reporter ───────────────────────────────────────────────────────────
|
|
445
|
+
/** Coarse capacity band: a change of band is worth a frame straight away. */
|
|
446
|
+
function band(windows) {
|
|
447
|
+
const top = Math.max(0, ...(windows ?? []).map((w) => w.used_percent));
|
|
448
|
+
return top >= EXHAUSTED_PERCENT ? 2 : top >= LOW_PERCENT ? 1 : 0;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Fold new windows into the ones already known, one KIND at a time.
|
|
452
|
+
*
|
|
453
|
+
* Sources see different slices: a run's `rate_limit_event` carries only the
|
|
454
|
+
* 5-hour and weekly windows (and omits one whose reset has passed), while the
|
|
455
|
+
* idle `get_usage` probe also carries the per-model weekly ones. Replacing the
|
|
456
|
+
* whole list with whatever arrived last is how a stream event used to erase
|
|
457
|
+
* the windows it simply didn't mention. A kind the observation names replaces
|
|
458
|
+
* every window of that kind; a kind it doesn't name is kept.
|
|
459
|
+
*/
|
|
460
|
+
export function mergeWindows(known, observed) {
|
|
461
|
+
const kinds = new Set(observed.map((w) => w.kind));
|
|
462
|
+
return [...(known ?? []).filter((w) => !kinds.has(w.kind)), ...observed];
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* A window as of ``nowMs``: one whose reset has passed has rolled over, so it
|
|
466
|
+
* is reported as a fresh window — 0%, not started — rather than dropped. The
|
|
467
|
+
* agent CLI omits a rolled-over window from its own events, so this is the
|
|
468
|
+
* only place that knows it exists.
|
|
469
|
+
*/
|
|
470
|
+
function asOf(window, nowMs) {
|
|
471
|
+
if (window.resets_at === null || Date.parse(window.resets_at) > nowMs)
|
|
472
|
+
return window;
|
|
473
|
+
return { ...window, used_percent: 0, resets_at: null };
|
|
474
|
+
}
|
|
475
|
+
function defaultSchedule(fn, ms) {
|
|
476
|
+
const timer = setTimeout(fn, ms);
|
|
477
|
+
timer.unref?.();
|
|
478
|
+
return () => clearTimeout(timer);
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Holds the latest runtime snapshot and decides when to send it.
|
|
482
|
+
*
|
|
483
|
+
* Sends only while attached — i.e. only on a socket whose server advertised
|
|
484
|
+
* {@link RUNTIME_STATUS_FEATURE}. Cadence: once on attach, every
|
|
485
|
+
* {@link STATUS_INTERVAL_MS}, and promptly when the model, plan or runtime
|
|
486
|
+
* version changes or capacity crosses a band — but never closer together than
|
|
487
|
+
* {@link MIN_FRAME_SPACING_MS}.
|
|
488
|
+
*/
|
|
489
|
+
export class RuntimeStatusReporter {
|
|
490
|
+
options;
|
|
491
|
+
now;
|
|
492
|
+
schedule;
|
|
493
|
+
state = {};
|
|
494
|
+
quotaObservedAt;
|
|
495
|
+
send;
|
|
496
|
+
lastSentAt;
|
|
497
|
+
cancelPending;
|
|
498
|
+
cancelTick;
|
|
499
|
+
refreshing;
|
|
500
|
+
accountGeneration = 0;
|
|
501
|
+
accounts;
|
|
502
|
+
accountsObservedAt;
|
|
503
|
+
constructor(options) {
|
|
504
|
+
this.options = options;
|
|
505
|
+
this.now = options.now ?? Date.now;
|
|
506
|
+
this.schedule = options.schedule ?? defaultSchedule;
|
|
507
|
+
}
|
|
508
|
+
/** Merge what a source learned. Absent fields keep their previous value. */
|
|
509
|
+
observe(observation) {
|
|
510
|
+
if (!observation)
|
|
511
|
+
return;
|
|
512
|
+
const before = this.state;
|
|
513
|
+
const next = { ...before };
|
|
514
|
+
if (observation.runtime_version)
|
|
515
|
+
next.runtime_version = observation.runtime_version;
|
|
516
|
+
if (observation.model)
|
|
517
|
+
next.model = observation.model;
|
|
518
|
+
if (observation.plan_label)
|
|
519
|
+
next.plan_label = observation.plan_label;
|
|
520
|
+
if (observation.quota_windows && observation.quota_windows.length > 0) {
|
|
521
|
+
next.quota_windows = mergeWindows(before.quota_windows, observation.quota_windows);
|
|
522
|
+
this.quotaObservedAt = this.now();
|
|
523
|
+
}
|
|
524
|
+
this.state = next;
|
|
525
|
+
const changed = next.runtime_version !== before.runtime_version ||
|
|
526
|
+
next.model !== before.model ||
|
|
527
|
+
next.plan_label !== before.plan_label ||
|
|
528
|
+
band(next.quota_windows) !== band(before.quota_windows);
|
|
529
|
+
if (changed)
|
|
530
|
+
this.sendSoon();
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Record the accounts snapshot. Moving to another account drops what was
|
|
534
|
+
* known about the previous one's capacity and plan — they describe a
|
|
535
|
+
* different subscription — and asks for fresh numbers straight away.
|
|
536
|
+
*/
|
|
537
|
+
setAccounts(snapshot, options = {}) {
|
|
538
|
+
const before = this.accounts;
|
|
539
|
+
this.accounts = snapshot;
|
|
540
|
+
this.accountsObservedAt = this.now();
|
|
541
|
+
const switched = before?.current?.key !== snapshot?.current?.key;
|
|
542
|
+
if (switched) {
|
|
543
|
+
this.accountGeneration += 1;
|
|
544
|
+
// A probe belongs to the login it started with. Let the new login
|
|
545
|
+
// refresh immediately even while an older probe is still finishing.
|
|
546
|
+
this.refreshing = undefined;
|
|
547
|
+
const { quota_windows: _windows, plan_label: _plan, ...rest } = this.state;
|
|
548
|
+
this.state = rest;
|
|
549
|
+
this.quotaObservedAt = undefined;
|
|
550
|
+
}
|
|
551
|
+
if (!options.quiet && JSON.stringify(before) !== JSON.stringify(snapshot))
|
|
552
|
+
this.sendSoon();
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* The account choice changed (an operator picked one in ProhostAI): re-read
|
|
556
|
+
* accounts and capacity on the next tick rather than in fifteen minutes.
|
|
557
|
+
*/
|
|
558
|
+
accountsChanged() {
|
|
559
|
+
this.accountsObservedAt = undefined;
|
|
560
|
+
this.quotaObservedAt = undefined;
|
|
561
|
+
if (!this.send)
|
|
562
|
+
return;
|
|
563
|
+
this.cancelTick?.();
|
|
564
|
+
this.cancelTick = undefined;
|
|
565
|
+
void this.tick();
|
|
566
|
+
}
|
|
567
|
+
/** The model to report on a run's completion, when nothing more specific is known. */
|
|
568
|
+
model() {
|
|
569
|
+
return this.state.model;
|
|
570
|
+
}
|
|
571
|
+
/** Start reporting on a socket that advertised the feature. */
|
|
572
|
+
attach(send) {
|
|
573
|
+
this.detach();
|
|
574
|
+
this.send = send;
|
|
575
|
+
this.lastSentAt = undefined;
|
|
576
|
+
void this.tick();
|
|
577
|
+
}
|
|
578
|
+
/** Stop reporting — the socket is gone. */
|
|
579
|
+
detach() {
|
|
580
|
+
this.send = undefined;
|
|
581
|
+
this.cancelPending?.();
|
|
582
|
+
this.cancelPending = undefined;
|
|
583
|
+
this.cancelTick?.();
|
|
584
|
+
this.cancelTick = undefined;
|
|
585
|
+
}
|
|
586
|
+
/** The frame as it would be sent now. */
|
|
587
|
+
frame() {
|
|
588
|
+
const nowMs = this.now();
|
|
589
|
+
const windows = (this.state.quota_windows ?? []).map((w) => asOf(w, nowMs));
|
|
590
|
+
return {
|
|
591
|
+
type: 'runtime_status',
|
|
592
|
+
runtime: this.options.runtime,
|
|
593
|
+
runtime_version: this.state.runtime_version ?? null,
|
|
594
|
+
model: this.state.model ?? null,
|
|
595
|
+
plan_label: this.state.plan_label ?? null,
|
|
596
|
+
quota_windows: windows,
|
|
597
|
+
observed_at: new Date(nowMs).toISOString(),
|
|
598
|
+
// Optional additions: an older server ignores keys it doesn't know.
|
|
599
|
+
...(this.accounts?.current ? { account: this.accounts.current } : {}),
|
|
600
|
+
...(this.accounts?.all ? { machine_accounts: this.accounts.all } : {}),
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
/** Routine tick: refresh idle quota if it is stale, send, and re-arm. */
|
|
604
|
+
async tick() {
|
|
605
|
+
if (!this.send)
|
|
606
|
+
return;
|
|
607
|
+
const stale = this.quotaObservedAt === undefined || this.now() - this.quotaObservedAt >= QUOTA_REFRESH_AFTER_MS;
|
|
608
|
+
const sentBefore = this.lastSentAt;
|
|
609
|
+
const accountsStale = this.accountsObservedAt === undefined || this.now() - this.accountsObservedAt >= QUOTA_REFRESH_AFTER_MS;
|
|
610
|
+
if (accountsStale && this.options.accounts) {
|
|
611
|
+
// Before the quota probe: after a switch it must ask the new login.
|
|
612
|
+
try {
|
|
613
|
+
// Quiet: the tick sends one frame once the quota probe below is in.
|
|
614
|
+
this.setAccounts(await this.options.accounts(), { quiet: true });
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
/* fail soft — keep what we had */
|
|
618
|
+
}
|
|
619
|
+
if (!this.send)
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (stale && this.options.provider && !this.options.isBusy?.()) {
|
|
623
|
+
await this.refresh();
|
|
624
|
+
}
|
|
625
|
+
if (!this.send)
|
|
626
|
+
return;
|
|
627
|
+
// A refresh that changed something has already sent (or queued) a frame.
|
|
628
|
+
if (this.lastSentAt === sentBefore)
|
|
629
|
+
this.sendSoon();
|
|
630
|
+
this.cancelTick?.();
|
|
631
|
+
this.cancelTick = this.schedule(() => void this.tick(), this.options.intervalMs ?? STATUS_INTERVAL_MS);
|
|
632
|
+
}
|
|
633
|
+
refresh() {
|
|
634
|
+
if (!this.refreshing) {
|
|
635
|
+
const provider = this.options.provider;
|
|
636
|
+
const generation = this.accountGeneration;
|
|
637
|
+
const refreshing = (provider ? provider.refresh() : Promise.resolve(undefined))
|
|
638
|
+
.then((observed) => {
|
|
639
|
+
if (generation !== this.accountGeneration)
|
|
640
|
+
return;
|
|
641
|
+
// `observe` would itself schedule a send on a change; the caller
|
|
642
|
+
// sends right after, which the spacing rule coalesces.
|
|
643
|
+
this.observe(observed);
|
|
644
|
+
})
|
|
645
|
+
.catch(() => {
|
|
646
|
+
/* fail soft — keep what we had */
|
|
647
|
+
})
|
|
648
|
+
.finally(() => {
|
|
649
|
+
if (this.refreshing === refreshing)
|
|
650
|
+
this.refreshing = undefined;
|
|
651
|
+
});
|
|
652
|
+
this.refreshing = refreshing;
|
|
653
|
+
}
|
|
654
|
+
return this.refreshing;
|
|
655
|
+
}
|
|
656
|
+
/** Send now if spacing allows, else once it does. Coalesces. */
|
|
657
|
+
sendSoon() {
|
|
658
|
+
if (!this.send || this.cancelPending)
|
|
659
|
+
return;
|
|
660
|
+
const wait = this.lastSentAt === undefined ? 0 : this.lastSentAt + MIN_FRAME_SPACING_MS - this.now();
|
|
661
|
+
if (wait <= 0) {
|
|
662
|
+
this.flush();
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
this.cancelPending = this.schedule(() => {
|
|
666
|
+
this.cancelPending = undefined;
|
|
667
|
+
this.flush();
|
|
668
|
+
}, wait);
|
|
669
|
+
}
|
|
670
|
+
flush() {
|
|
671
|
+
if (!this.send)
|
|
672
|
+
return;
|
|
673
|
+
try {
|
|
674
|
+
this.send(this.frame());
|
|
675
|
+
this.lastSentAt = this.now();
|
|
676
|
+
}
|
|
677
|
+
catch (err) {
|
|
678
|
+
this.options.log?.(`runtime status not sent: ${err instanceof Error ? err.message : String(err)}`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|