@prohost/cli 0.4.2 → 0.6.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 +99 -0
- package/README.md +24 -0
- package/dist/agent/api.d.ts +45 -2
- package/dist/agent/api.js +67 -8
- package/dist/agent/claude.d.ts +7 -0
- package/dist/agent/claude.js +13 -0
- package/dist/agent/command.js +4 -0
- package/dist/agent/contract.d.ts +2 -0
- package/dist/agent/contract.js +2 -0
- package/dist/agent/heartbeat.js +4 -3
- package/dist/agent/run.d.ts +19 -0
- package/dist/agent/run.js +214 -21
- package/dist/agent/runtime_status.d.ts +191 -0
- package/dist/agent/runtime_status.js +556 -0
- package/dist/listen.d.ts +7 -0
- package/dist/listen.js +25 -2
- package/dist/liveness.d.ts +107 -0
- package/dist/liveness.js +156 -0
- package/dist/upgrade.d.ts +49 -0
- package/dist/upgrade.js +99 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,556 @@
|
|
|
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
|
+
/**
|
|
95
|
+
* Windows from a stream `rate_limit_event.rate_limit_info`.
|
|
96
|
+
*
|
|
97
|
+
* `unifiedWindows.*.utilization` is a FRACTION (0–1, and it can exceed 1) with
|
|
98
|
+
* epoch-second resets — unlike `get_usage`, which is 0–100 with ISO strings.
|
|
99
|
+
* The field is marked internal upstream, so an unexpected shape yields nothing.
|
|
100
|
+
*/
|
|
101
|
+
export function fromClaudeRateLimitInfo(info) {
|
|
102
|
+
const windows = asObject(asObject(info)?.unifiedWindows) ?? {};
|
|
103
|
+
return CLAUDE_WINDOWS.flatMap(([key, kind]) => {
|
|
104
|
+
const window = asObject(windows[key]);
|
|
105
|
+
const used = percent(window?.utilization, 1);
|
|
106
|
+
const at = isoTime(window?.resetsAt);
|
|
107
|
+
return used !== undefined && at ? [{ kind, used_percent: used, resets_at: at }] : [];
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/** Windows from a `get_usage` control response (0–100, ISO strings). */
|
|
111
|
+
export function fromClaudeGetUsage(usage) {
|
|
112
|
+
const record = asObject(usage);
|
|
113
|
+
if (record?.rate_limits_available !== true)
|
|
114
|
+
return [];
|
|
115
|
+
const limits = asObject(record.rate_limits) ?? {};
|
|
116
|
+
return CLAUDE_WINDOWS.flatMap(([key, kind]) => {
|
|
117
|
+
const window = asObject(limits[key]);
|
|
118
|
+
const used = percent(window?.utilization, 100);
|
|
119
|
+
const at = isoTime(window?.resets_at);
|
|
120
|
+
return used !== undefined && at ? [{ kind, used_percent: used, resets_at: at }] : [];
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const CLAUDE_PLANS = {
|
|
124
|
+
pro: 'Claude Pro',
|
|
125
|
+
max: 'Claude Max',
|
|
126
|
+
team: 'Claude Team',
|
|
127
|
+
enterprise: 'Claude Enterprise',
|
|
128
|
+
};
|
|
129
|
+
/** `get_usage.subscription_type` → display label; `undefined` for anything else. */
|
|
130
|
+
export function claudePlanLabel(subscriptionType) {
|
|
131
|
+
const key = text(subscriptionType)?.toLowerCase();
|
|
132
|
+
return key ? CLAUDE_PLANS[key] : undefined;
|
|
133
|
+
}
|
|
134
|
+
// ── Codex ──────────────────────────────────────────────────────────────────
|
|
135
|
+
/**
|
|
136
|
+
* Windows from Codex `rate_limits` (rollout, snake_case) or `rateLimits`
|
|
137
|
+
* (app-server, camelCase).
|
|
138
|
+
*
|
|
139
|
+
* Classified by window LENGTH, never by slot: on some plans `primary` is the
|
|
140
|
+
* weekly window and there is no 5-hour one at all.
|
|
141
|
+
*/
|
|
142
|
+
export function fromCodexRateLimits(rateLimits, lineTimestamp) {
|
|
143
|
+
const record = asObject(rateLimits);
|
|
144
|
+
if (!record)
|
|
145
|
+
return [];
|
|
146
|
+
return [record.primary, record.secondary].flatMap((raw) => {
|
|
147
|
+
const window = asObject(raw);
|
|
148
|
+
if (!window)
|
|
149
|
+
return [];
|
|
150
|
+
const minutes = window.window_minutes ?? window.windowDurationMins;
|
|
151
|
+
const kind = typeof minutes !== 'number'
|
|
152
|
+
? undefined
|
|
153
|
+
: minutes > 0 && minutes <= 360
|
|
154
|
+
? 'session'
|
|
155
|
+
: minutes >= 8_640 && minutes <= 11_520
|
|
156
|
+
? 'weekly'
|
|
157
|
+
: undefined;
|
|
158
|
+
const used = percent(window.used_percent ?? window.usedPercent, 100);
|
|
159
|
+
let at = isoTime(window.resets_at ?? window.resetsAt);
|
|
160
|
+
// Older Codex builds said "in N seconds", relative to the line it was on.
|
|
161
|
+
if (!at && typeof window.resets_in_seconds === 'number' && lineTimestamp) {
|
|
162
|
+
const base = Date.parse(lineTimestamp);
|
|
163
|
+
if (!Number.isNaN(base))
|
|
164
|
+
at = new Date(base + window.resets_in_seconds * 1000).toISOString();
|
|
165
|
+
}
|
|
166
|
+
return kind && used !== undefined && at ? [{ kind, used_percent: used, resets_at: at }] : [];
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/** `plan_type` / `planType` → `ChatGPT Pro`; `undefined` when there is no plan. */
|
|
170
|
+
export function codexPlanLabel(planType) {
|
|
171
|
+
const plan = text(planType);
|
|
172
|
+
if (!plan)
|
|
173
|
+
return undefined;
|
|
174
|
+
return `ChatGPT ${plan.charAt(0).toUpperCase()}${plan.slice(1).toLowerCase()}`;
|
|
175
|
+
}
|
|
176
|
+
/** Model named in the exec string itself (`-m gpt-5` / `--model gpt-5` / `--model=gpt-5`). */
|
|
177
|
+
export function modelFromExec(exec) {
|
|
178
|
+
const words = exec.trim().split(/\s+/);
|
|
179
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
180
|
+
const word = words[i] ?? '';
|
|
181
|
+
if (word === '-m' || word === '--model')
|
|
182
|
+
return text(words[i + 1]?.replace(/^['"]|['"]$/g, ''));
|
|
183
|
+
if (word.startsWith('--model='))
|
|
184
|
+
return text(word.slice('--model='.length).replace(/^['"]|['"]$/g, ''));
|
|
185
|
+
}
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
/** `$CODEX_HOME`, defaulting to `~/.codex` exactly as Codex does. */
|
|
189
|
+
export function codexHome(env = process.env) {
|
|
190
|
+
return env.CODEX_HOME || path.join(env.HOME || os.homedir(), '.codex');
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The top-level `model = "…"` in `$CODEX_HOME/config.toml` — Codex's default.
|
|
194
|
+
*
|
|
195
|
+
* Only the top level, before the first `[table]`: a `model` inside a profile
|
|
196
|
+
* table is not the default. A first guess only; the rollout's `turn_context`
|
|
197
|
+
* replaces it after the first run.
|
|
198
|
+
*/
|
|
199
|
+
export function codexConfiguredModel(env = process.env) {
|
|
200
|
+
let content;
|
|
201
|
+
try {
|
|
202
|
+
content = readFileSync(path.join(codexHome(env), 'config.toml'), 'utf8');
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
for (const line of content.split('\n')) {
|
|
208
|
+
const trimmed = line.trim();
|
|
209
|
+
if (trimmed.startsWith('['))
|
|
210
|
+
return undefined;
|
|
211
|
+
const match = /^model\s*=\s*["']([^"']+)["']/.exec(trimmed);
|
|
212
|
+
if (match)
|
|
213
|
+
return text(match[1]);
|
|
214
|
+
}
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Locate the rollout for one thread: `sessions/YYYY/MM/DD/rollout-*-<thread_id>.jsonl`.
|
|
219
|
+
*
|
|
220
|
+
* The date directory is local time and a resumed thread lives under the day it
|
|
221
|
+
* started, so the date is never guessed — days are scanned newest first.
|
|
222
|
+
*/
|
|
223
|
+
export function findCodexRollout(threadId, env = process.env) {
|
|
224
|
+
if (!/^[0-9A-Za-z-]+$/.test(threadId))
|
|
225
|
+
return undefined;
|
|
226
|
+
const suffix = `-${threadId}.jsonl`;
|
|
227
|
+
const root = path.join(codexHome(env), 'sessions');
|
|
228
|
+
const children = (dir) => {
|
|
229
|
+
try {
|
|
230
|
+
return readdirSync(dir).sort().reverse();
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
for (const year of children(root)) {
|
|
237
|
+
for (const month of children(path.join(root, year))) {
|
|
238
|
+
for (const day of children(path.join(root, year, month))) {
|
|
239
|
+
const dir = path.join(root, year, month, day);
|
|
240
|
+
const hit = children(dir).find((name) => name.startsWith('rollout-') && name.endsWith(suffix));
|
|
241
|
+
if (hit)
|
|
242
|
+
return path.join(dir, hit);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* What a Codex rollout says: the model the turn really used (after `-m`,
|
|
250
|
+
* profiles and `-c`), the CLI version, and the last rate-limit snapshot.
|
|
251
|
+
*/
|
|
252
|
+
export function parseCodexRollout(content) {
|
|
253
|
+
const observed = {};
|
|
254
|
+
for (const line of content.split('\n')) {
|
|
255
|
+
const entry = parseLine(line);
|
|
256
|
+
if (!entry)
|
|
257
|
+
continue;
|
|
258
|
+
const payload = asObject(entry.payload);
|
|
259
|
+
if (!payload)
|
|
260
|
+
continue;
|
|
261
|
+
if (entry.type === 'session_meta') {
|
|
262
|
+
observed.runtime_version = text(payload.cli_version) ?? observed.runtime_version;
|
|
263
|
+
}
|
|
264
|
+
else if (entry.type === 'turn_context') {
|
|
265
|
+
observed.model = text(payload.model) ?? observed.model;
|
|
266
|
+
}
|
|
267
|
+
else if (entry.type === 'event_msg' && payload.type === 'token_count' && payload.rate_limits) {
|
|
268
|
+
// One per model response — the last one is the freshest.
|
|
269
|
+
observed.quota_windows = fromCodexRateLimits(payload.rate_limits, typeof entry.timestamp === 'string' ? entry.timestamp : undefined);
|
|
270
|
+
observed.plan_label = codexPlanLabel(asObject(payload.rate_limits)?.plan_type) ?? observed.plan_label;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return observed;
|
|
274
|
+
}
|
|
275
|
+
/** Read the rollout for a finished Codex thread, if it can be found. */
|
|
276
|
+
export function readCodexObservation(threadId, env = process.env) {
|
|
277
|
+
if (!threadId)
|
|
278
|
+
return undefined;
|
|
279
|
+
const file = findCodexRollout(threadId, env);
|
|
280
|
+
if (!file || !existsSync(file))
|
|
281
|
+
return undefined;
|
|
282
|
+
try {
|
|
283
|
+
return parseCodexRollout(readFileSync(file, 'utf8'));
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Spawn ``command args``, write ``lines`` as JSONL, and resolve with the first
|
|
291
|
+
* stdout line ``pick`` accepts. Kills the child on answer, error or timeout.
|
|
292
|
+
*/
|
|
293
|
+
function exchangeJsonLines(spawnImpl, command, args, lines, pick, timeoutMs) {
|
|
294
|
+
return new Promise((resolve) => {
|
|
295
|
+
let settled = false;
|
|
296
|
+
let buffer = '';
|
|
297
|
+
let child;
|
|
298
|
+
const finish = (value) => {
|
|
299
|
+
if (settled)
|
|
300
|
+
return;
|
|
301
|
+
settled = true;
|
|
302
|
+
clearTimeout(timer);
|
|
303
|
+
try {
|
|
304
|
+
child?.kill();
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
/* already gone */
|
|
308
|
+
}
|
|
309
|
+
resolve(value);
|
|
310
|
+
};
|
|
311
|
+
const timer = setTimeout(() => finish(undefined), timeoutMs);
|
|
312
|
+
timer.unref?.();
|
|
313
|
+
try {
|
|
314
|
+
child = spawnImpl(command, args, { stdio: ['pipe', 'pipe', 'ignore'] });
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
finish(undefined);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
child.on('error', () => finish(undefined));
|
|
321
|
+
child.on('close', () => finish(undefined));
|
|
322
|
+
child.stdout?.setEncoding('utf8');
|
|
323
|
+
child.stdout?.on('data', (chunk) => {
|
|
324
|
+
buffer += chunk;
|
|
325
|
+
if (buffer.length > 1024 * 1024)
|
|
326
|
+
buffer = buffer.slice(-64 * 1024);
|
|
327
|
+
const parts = buffer.split('\n');
|
|
328
|
+
buffer = parts.pop() ?? '';
|
|
329
|
+
for (const part of parts) {
|
|
330
|
+
const parsed = parseLine(part);
|
|
331
|
+
if (!parsed)
|
|
332
|
+
continue;
|
|
333
|
+
const value = pick(parsed);
|
|
334
|
+
if (value !== undefined) {
|
|
335
|
+
finish(value);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
child.stdin?.on('error', () => {
|
|
341
|
+
/* child exited before reading; `close` settles */
|
|
342
|
+
});
|
|
343
|
+
child.stdin?.write(lines.map((line) => JSON.stringify(line)).join('\n') + '\n');
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Claude Code's `get_usage` control request. No model turn is made, so no
|
|
348
|
+
* quota is spent. The `initialize` response carries account identity; it is
|
|
349
|
+
* never read.
|
|
350
|
+
*/
|
|
351
|
+
export function claudeQuotaProvider(options) {
|
|
352
|
+
return {
|
|
353
|
+
refresh: () => exchangeJsonLines(options.spawnImpl ?? nodeSpawn, options.binary, ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose'], [
|
|
354
|
+
{ type: 'control_request', request_id: 'prohost-init', request: { subtype: 'initialize' } },
|
|
355
|
+
{
|
|
356
|
+
type: 'control_request',
|
|
357
|
+
request_id: 'prohost-usage',
|
|
358
|
+
request: { subtype: 'get_usage', skip_behaviors: true },
|
|
359
|
+
},
|
|
360
|
+
], (line) => {
|
|
361
|
+
if (line.type !== 'control_response')
|
|
362
|
+
return undefined;
|
|
363
|
+
const response = asObject(line.response);
|
|
364
|
+
if (response?.request_id !== 'prohost-usage')
|
|
365
|
+
return undefined;
|
|
366
|
+
if (response.subtype !== 'success')
|
|
367
|
+
return {};
|
|
368
|
+
const usage = asObject(response.response) ?? response;
|
|
369
|
+
const windows = fromClaudeGetUsage(usage);
|
|
370
|
+
return {
|
|
371
|
+
plan_label: claudePlanLabel(usage.subscription_type),
|
|
372
|
+
...(windows.length > 0 ? { quota_windows: windows } : {}),
|
|
373
|
+
};
|
|
374
|
+
}, options.timeoutMs ?? PROVIDER_TIMEOUT_MS),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
/** Codex's `account/rateLimits/read` over `codex app-server` stdio JSON-RPC. */
|
|
378
|
+
export function codexQuotaProvider(options) {
|
|
379
|
+
return {
|
|
380
|
+
refresh: () => exchangeJsonLines(options.spawnImpl ?? nodeSpawn, options.binary, ['app-server'], [
|
|
381
|
+
{
|
|
382
|
+
id: 1,
|
|
383
|
+
method: 'initialize',
|
|
384
|
+
params: { clientInfo: { name: 'prohost-cli', version: options.clientVersion } },
|
|
385
|
+
},
|
|
386
|
+
{ method: 'initialized' },
|
|
387
|
+
{ id: 2, method: 'account/rateLimits/read' },
|
|
388
|
+
], (line) => {
|
|
389
|
+
if (line.id !== 2)
|
|
390
|
+
return undefined;
|
|
391
|
+
const rateLimits = asObject(asObject(line.result)?.rateLimits);
|
|
392
|
+
if (!rateLimits)
|
|
393
|
+
return {};
|
|
394
|
+
const windows = fromCodexRateLimits(rateLimits);
|
|
395
|
+
return {
|
|
396
|
+
plan_label: codexPlanLabel(rateLimits.planType),
|
|
397
|
+
...(windows.length > 0 ? { quota_windows: windows } : {}),
|
|
398
|
+
};
|
|
399
|
+
}, options.timeoutMs ?? PROVIDER_TIMEOUT_MS),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
// ── The reporter ───────────────────────────────────────────────────────────
|
|
403
|
+
/** Coarse capacity band: a change of band is worth a frame straight away. */
|
|
404
|
+
function band(windows) {
|
|
405
|
+
const top = Math.max(0, ...(windows ?? []).map((w) => w.used_percent));
|
|
406
|
+
return top >= EXHAUSTED_PERCENT ? 2 : top >= LOW_PERCENT ? 1 : 0;
|
|
407
|
+
}
|
|
408
|
+
function defaultSchedule(fn, ms) {
|
|
409
|
+
const timer = setTimeout(fn, ms);
|
|
410
|
+
timer.unref?.();
|
|
411
|
+
return () => clearTimeout(timer);
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Holds the latest runtime snapshot and decides when to send it.
|
|
415
|
+
*
|
|
416
|
+
* Sends only while attached — i.e. only on a socket whose server advertised
|
|
417
|
+
* {@link RUNTIME_STATUS_FEATURE}. Cadence: once on attach, every
|
|
418
|
+
* {@link STATUS_INTERVAL_MS}, and promptly when the model, plan or runtime
|
|
419
|
+
* version changes or capacity crosses a band — but never closer together than
|
|
420
|
+
* {@link MIN_FRAME_SPACING_MS}.
|
|
421
|
+
*/
|
|
422
|
+
export class RuntimeStatusReporter {
|
|
423
|
+
options;
|
|
424
|
+
now;
|
|
425
|
+
schedule;
|
|
426
|
+
state = {};
|
|
427
|
+
quotaObservedAt;
|
|
428
|
+
send;
|
|
429
|
+
lastSentAt;
|
|
430
|
+
cancelPending;
|
|
431
|
+
cancelTick;
|
|
432
|
+
refreshing;
|
|
433
|
+
constructor(options) {
|
|
434
|
+
this.options = options;
|
|
435
|
+
this.now = options.now ?? Date.now;
|
|
436
|
+
this.schedule = options.schedule ?? defaultSchedule;
|
|
437
|
+
}
|
|
438
|
+
/** Merge what a source learned. Absent fields keep their previous value. */
|
|
439
|
+
observe(observation) {
|
|
440
|
+
if (!observation)
|
|
441
|
+
return;
|
|
442
|
+
const before = this.state;
|
|
443
|
+
const next = { ...before };
|
|
444
|
+
if (observation.runtime_version)
|
|
445
|
+
next.runtime_version = observation.runtime_version;
|
|
446
|
+
if (observation.model)
|
|
447
|
+
next.model = observation.model;
|
|
448
|
+
if (observation.plan_label)
|
|
449
|
+
next.plan_label = observation.plan_label;
|
|
450
|
+
if (observation.quota_windows && observation.quota_windows.length > 0) {
|
|
451
|
+
next.quota_windows = observation.quota_windows;
|
|
452
|
+
this.quotaObservedAt = this.now();
|
|
453
|
+
}
|
|
454
|
+
this.state = next;
|
|
455
|
+
const changed = next.runtime_version !== before.runtime_version ||
|
|
456
|
+
next.model !== before.model ||
|
|
457
|
+
next.plan_label !== before.plan_label ||
|
|
458
|
+
band(next.quota_windows) !== band(before.quota_windows);
|
|
459
|
+
if (changed)
|
|
460
|
+
this.sendSoon();
|
|
461
|
+
}
|
|
462
|
+
/** The model to report on a run's completion, when nothing more specific is known. */
|
|
463
|
+
model() {
|
|
464
|
+
return this.state.model;
|
|
465
|
+
}
|
|
466
|
+
/** Start reporting on a socket that advertised the feature. */
|
|
467
|
+
attach(send) {
|
|
468
|
+
this.detach();
|
|
469
|
+
this.send = send;
|
|
470
|
+
this.lastSentAt = undefined;
|
|
471
|
+
void this.tick();
|
|
472
|
+
}
|
|
473
|
+
/** Stop reporting — the socket is gone. */
|
|
474
|
+
detach() {
|
|
475
|
+
this.send = undefined;
|
|
476
|
+
this.cancelPending?.();
|
|
477
|
+
this.cancelPending = undefined;
|
|
478
|
+
this.cancelTick?.();
|
|
479
|
+
this.cancelTick = undefined;
|
|
480
|
+
}
|
|
481
|
+
/** The frame as it would be sent now. */
|
|
482
|
+
frame() {
|
|
483
|
+
const nowMs = this.now();
|
|
484
|
+
// A window whose reset has passed describes a period that is over.
|
|
485
|
+
const windows = (this.state.quota_windows ?? []).filter((w) => Date.parse(w.resets_at) > nowMs);
|
|
486
|
+
return {
|
|
487
|
+
type: 'runtime_status',
|
|
488
|
+
runtime: this.options.runtime,
|
|
489
|
+
runtime_version: this.state.runtime_version ?? null,
|
|
490
|
+
model: this.state.model ?? null,
|
|
491
|
+
plan_label: this.state.plan_label ?? null,
|
|
492
|
+
quota_windows: windows,
|
|
493
|
+
observed_at: new Date(nowMs).toISOString(),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
/** Routine tick: refresh idle quota if it is stale, send, and re-arm. */
|
|
497
|
+
async tick() {
|
|
498
|
+
if (!this.send)
|
|
499
|
+
return;
|
|
500
|
+
const stale = this.quotaObservedAt === undefined || this.now() - this.quotaObservedAt >= QUOTA_REFRESH_AFTER_MS;
|
|
501
|
+
const sentBefore = this.lastSentAt;
|
|
502
|
+
if (stale && this.options.provider && !this.options.isBusy?.()) {
|
|
503
|
+
await this.refresh();
|
|
504
|
+
}
|
|
505
|
+
if (!this.send)
|
|
506
|
+
return;
|
|
507
|
+
// A refresh that changed something has already sent (or queued) a frame.
|
|
508
|
+
if (this.lastSentAt === sentBefore)
|
|
509
|
+
this.sendSoon();
|
|
510
|
+
this.cancelTick?.();
|
|
511
|
+
this.cancelTick = this.schedule(() => void this.tick(), this.options.intervalMs ?? STATUS_INTERVAL_MS);
|
|
512
|
+
}
|
|
513
|
+
refresh() {
|
|
514
|
+
if (!this.refreshing) {
|
|
515
|
+
const provider = this.options.provider;
|
|
516
|
+
this.refreshing = (provider ? provider.refresh() : Promise.resolve(undefined))
|
|
517
|
+
.then((observed) => {
|
|
518
|
+
// `observe` would itself schedule a send on a change; the caller
|
|
519
|
+
// sends right after, which the spacing rule coalesces.
|
|
520
|
+
this.observe(observed);
|
|
521
|
+
})
|
|
522
|
+
.catch(() => {
|
|
523
|
+
/* fail soft — keep what we had */
|
|
524
|
+
})
|
|
525
|
+
.finally(() => {
|
|
526
|
+
this.refreshing = undefined;
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
return this.refreshing;
|
|
530
|
+
}
|
|
531
|
+
/** Send now if spacing allows, else once it does. Coalesces. */
|
|
532
|
+
sendSoon() {
|
|
533
|
+
if (!this.send || this.cancelPending)
|
|
534
|
+
return;
|
|
535
|
+
const wait = this.lastSentAt === undefined ? 0 : this.lastSentAt + MIN_FRAME_SPACING_MS - this.now();
|
|
536
|
+
if (wait <= 0) {
|
|
537
|
+
this.flush();
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
this.cancelPending = this.schedule(() => {
|
|
541
|
+
this.cancelPending = undefined;
|
|
542
|
+
this.flush();
|
|
543
|
+
}, wait);
|
|
544
|
+
}
|
|
545
|
+
flush() {
|
|
546
|
+
if (!this.send)
|
|
547
|
+
return;
|
|
548
|
+
try {
|
|
549
|
+
this.send(this.frame());
|
|
550
|
+
this.lastSentAt = this.now();
|
|
551
|
+
}
|
|
552
|
+
catch (err) {
|
|
553
|
+
this.options.log?.(`runtime status not sent: ${err instanceof Error ? err.message : String(err)}`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
package/dist/listen.d.ts
CHANGED
|
@@ -31,12 +31,19 @@ export interface ListenOptions {
|
|
|
31
31
|
* and the final status has been emitted.
|
|
32
32
|
*/
|
|
33
33
|
signal?: AbortSignal;
|
|
34
|
+
/**
|
|
35
|
+
* How long the server may go silent before the socket is torn down.
|
|
36
|
+
* Defaults to {@link PING_LIVENESS_MS}; tests shorten it.
|
|
37
|
+
*/
|
|
38
|
+
livenessTimeoutMs?: number;
|
|
34
39
|
}
|
|
35
40
|
export interface ListenMetrics {
|
|
36
41
|
connected: boolean;
|
|
37
42
|
eventsReceived: number;
|
|
38
43
|
eventsForwarded: number;
|
|
39
44
|
forwardErrors: number;
|
|
45
|
+
/** Set when the listener ended because the server went silent, not because it closed. */
|
|
46
|
+
livenessTimeout?: boolean;
|
|
40
47
|
}
|
|
41
48
|
/**
|
|
42
49
|
* `/ws/webhooks` is served by **api_public**, which is `connect.prohost.ai` —
|
package/dist/listen.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import WebSocket from 'ws';
|
|
7
7
|
import { forwardEvent, isWebhookEvent } from './forward.js';
|
|
8
|
+
import { ServerLiveness, enableTcpKeepAlive, seconds, terminateSocket } from './liveness.js';
|
|
9
|
+
import { CLI_VERSION, USER_AGENT } from './version.js';
|
|
8
10
|
/**
|
|
9
11
|
* `/ws/webhooks` is served by **api_public**, which is `connect.prohost.ai` —
|
|
10
12
|
* the same host as {@link DEFAULT_API_BASE_URL}. It is NOT on `api.prohost.ai`
|
|
@@ -35,7 +37,7 @@ export async function runListen(options) {
|
|
|
35
37
|
eventsForwarded: 0,
|
|
36
38
|
forwardErrors: 0,
|
|
37
39
|
};
|
|
38
|
-
const ws = new Ctor(url);
|
|
40
|
+
const ws = new Ctor(url, { headers: { 'User-Agent': USER_AGENT } });
|
|
39
41
|
return new Promise((resolve) => {
|
|
40
42
|
const status = () => {
|
|
41
43
|
const dot = metrics.connected ? '\u25CF' : '\u25CB';
|
|
@@ -55,11 +57,27 @@ export async function runListen(options) {
|
|
|
55
57
|
else
|
|
56
58
|
options.signal.addEventListener('abort', closeWith, { once: true });
|
|
57
59
|
}
|
|
60
|
+
// Same contract as the agent harness (see `liveness.ts`): the server's
|
|
61
|
+
// 60 s reaper cannot close a socket whose FIN never reached us, so keep a
|
|
62
|
+
// client-side deadline. `listen` has no reconnect loop \u2014 it resolves on
|
|
63
|
+
// close and the operator restarts it \u2014 so this ends the session with a
|
|
64
|
+
// reason on screen instead of a counter that silently stopped moving.
|
|
65
|
+
const liveness = new ServerLiveness({
|
|
66
|
+
timeoutMs: options.livenessTimeoutMs,
|
|
67
|
+
onSilence: (silentMs) => {
|
|
68
|
+
metrics.livenessTimeout = true;
|
|
69
|
+
onStatus(`\n\u2026 no server ping for ${seconds(silentMs)}s \u2014 connection lost\n`);
|
|
70
|
+
terminateSocket(ws);
|
|
71
|
+
},
|
|
72
|
+
});
|
|
58
73
|
ws.on('open', () => {
|
|
74
|
+
enableTcpKeepAlive(ws);
|
|
75
|
+
liveness.start();
|
|
59
76
|
const authFrame = {
|
|
60
77
|
type: 'auth',
|
|
61
78
|
api_key: options.apiKey,
|
|
62
79
|
machine,
|
|
80
|
+
client_version: CLI_VERSION,
|
|
63
81
|
...(options.subscriptionId ? { subscription_id: options.subscriptionId } : {}),
|
|
64
82
|
};
|
|
65
83
|
ws.send(JSON.stringify(authFrame));
|
|
@@ -70,11 +88,15 @@ export async function runListen(options) {
|
|
|
70
88
|
frame = JSON.parse(data.toString());
|
|
71
89
|
}
|
|
72
90
|
catch {
|
|
91
|
+
liveness.observe(undefined);
|
|
73
92
|
return;
|
|
74
93
|
}
|
|
75
|
-
if (!frame || typeof frame !== 'object')
|
|
94
|
+
if (!frame || typeof frame !== 'object') {
|
|
95
|
+
liveness.observe(undefined);
|
|
76
96
|
return;
|
|
97
|
+
}
|
|
77
98
|
const f = frame;
|
|
99
|
+
liveness.observe(f.type);
|
|
78
100
|
if (f.type === 'auth_ok') {
|
|
79
101
|
metrics.connected = true;
|
|
80
102
|
status();
|
|
@@ -114,6 +136,7 @@ export async function runListen(options) {
|
|
|
114
136
|
}
|
|
115
137
|
});
|
|
116
138
|
ws.on('close', () => {
|
|
139
|
+
liveness.stop();
|
|
117
140
|
metrics.connected = false;
|
|
118
141
|
status();
|
|
119
142
|
resolve(metrics);
|