@prohost/cli 0.5.0 → 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 +26 -0
- package/README.md +14 -0
- package/dist/agent/api.d.ts +4 -1
- package/dist/agent/api.js +7 -2
- package/dist/agent/claude.d.ts +7 -0
- package/dist/agent/claude.js +13 -0
- package/dist/agent/contract.d.ts +2 -0
- package/dist/agent/contract.js +2 -0
- package/dist/agent/run.d.ts +7 -0
- package/dist/agent/run.js +100 -11
- package/dist/agent/runtime_status.d.ts +191 -0
- package/dist/agent/runtime_status.js +556 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,32 @@ Versions follow [semver](https://semver.org/). Publishing is automated: merging
|
|
|
4
4
|
a version bump to `main` triggers `.github/workflows/npm-publish-cli.yml`, which
|
|
5
5
|
builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
|
|
6
6
|
|
|
7
|
+
## 0.6.0
|
|
8
|
+
|
|
9
|
+
ProhostAI can now show which runtime, model and machine a paired AI employee
|
|
10
|
+
runs on, and how much of the operator's Claude or Codex subscription is left.
|
|
11
|
+
|
|
12
|
+
- **A `runtime_status` frame.** When — and only when — the server lists
|
|
13
|
+
`runtime_status` in `auth_ok.features`, `agent run` reports its runtime
|
|
14
|
+
(`claude_code`, `codex`, `goose` or `custom`), the agent CLI's version, the
|
|
15
|
+
model, the plan (`Claude Max`, `ChatGPT Pro`, …) and the 5-hour and weekly
|
|
16
|
+
capacity windows as percentages with reset times. It is sent on connect,
|
|
17
|
+
every five minutes, and promptly when the model or plan changes or capacity
|
|
18
|
+
crosses 75% / 100% — never more than once per 31 s. An older server gets
|
|
19
|
+
nothing new.
|
|
20
|
+
- **Your login never leaves this machine, and the CLI never reads it.** The
|
|
21
|
+
numbers come from the agent CLI answering with its own credentials: Claude
|
|
22
|
+
Code's own `stream-json` output (`system/init`, `rate_limit_event`) and, when
|
|
23
|
+
idle, its `get_usage` control request (no model turn, no quota spent); Codex's
|
|
24
|
+
per-thread rollout file under `$CODEX_HOME/sessions` and, when idle,
|
|
25
|
+
`codex app-server`'s `account/rateLimits/read`. The Claude Code keychain item
|
|
26
|
+
and `~/.codex/auth.json` are never opened, and account ids, emails and org
|
|
27
|
+
names are never forwarded. Anything unknown is omitted, never sent as zero.
|
|
28
|
+
- **The model on completion.** `POST /v1/agent-runs/{id}/complete` now carries
|
|
29
|
+
`model` when the harness knows it (Claude Code's `system/init.model`, Codex's
|
|
30
|
+
rollout `turn_context.model`), so a reply can say which model wrote it. A
|
|
31
|
+
plain `--exec` command's completion body is unchanged.
|
|
32
|
+
|
|
7
33
|
## 0.5.0
|
|
8
34
|
|
|
9
35
|
A paired agent can no longer sit on a dead socket believing it is connected.
|
package/README.md
CHANGED
|
@@ -428,6 +428,20 @@ your agent a second time. That matters because your agent holds a credential:
|
|
|
428
428
|
a second execution could file the same task or send the same email again, which
|
|
429
429
|
no amount of message deduplication would undo.
|
|
430
430
|
|
|
431
|
+
### What ProhostAI sees about your runtime
|
|
432
|
+
|
|
433
|
+
When the server supports it, `agent run` reports which runtime it drives
|
|
434
|
+
(Claude Code, Codex, goose or a custom command), that CLI's version, the model,
|
|
435
|
+
your plan's name, and how much of your 5-hour and weekly capacity is used, as
|
|
436
|
+
percentages with reset times. ProhostAI shows this next to the AI employee.
|
|
437
|
+
|
|
438
|
+
Your Claude or Codex login never leaves your machine, and `prohost` never reads
|
|
439
|
+
it: the numbers come from the agent CLI's own output (Claude Code's
|
|
440
|
+
`stream-json` events, Codex's session rollout files) or from asking the
|
|
441
|
+
unmodified CLI through its own protocol (`get_usage`,
|
|
442
|
+
`codex app-server`), which answers with its own login. Account ids, emails and
|
|
443
|
+
org names are never sent.
|
|
444
|
+
|
|
431
445
|
### The trust model — read this once
|
|
432
446
|
|
|
433
447
|
`agent run` is a standing instruction to execute a command on your machine when
|
package/dist/agent/api.d.ts
CHANGED
|
@@ -129,10 +129,13 @@ export declare function heartbeatRun(options: ApiOptions, path: string, progress
|
|
|
129
129
|
* A ``409`` is reported as success: it means the run already reached a terminal
|
|
130
130
|
* state (a redelivery we raced, or the 2h stale-run sweeper got there first).
|
|
131
131
|
* Treating that as a failure would only produce noise about work that is done.
|
|
132
|
+
*
|
|
133
|
+
* ``model`` is the model id the run used, when the harness knows it. Omitted
|
|
134
|
+
* otherwise, so a plain ``--exec`` command's completion body is unchanged.
|
|
132
135
|
*/
|
|
133
136
|
export declare function completeRun(options: ApiOptions, path: string, outcome: {
|
|
134
137
|
ok: true;
|
|
135
138
|
} | {
|
|
136
139
|
ok: false;
|
|
137
140
|
error: string;
|
|
138
|
-
}): Promise<ApiResult>;
|
|
141
|
+
}, model?: string): Promise<ApiResult>;
|
package/dist/agent/api.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* `completion.path`), which is why these take a path rather than building one.
|
|
11
11
|
*/
|
|
12
12
|
import { USER_AGENT } from '../version.js';
|
|
13
|
-
import { MAX_ERROR_CHARS, MAX_PROGRESS_CHARS, RUN_STATUS_FAILED, RUN_STATUS_SUCCEEDED, } from './contract.js';
|
|
13
|
+
import { MAX_ERROR_CHARS, MAX_MODEL_CHARS, MAX_PROGRESS_CHARS, RUN_STATUS_FAILED, RUN_STATUS_SUCCEEDED, } from './contract.js';
|
|
14
14
|
/** Deadline for the cheap calls — a heartbeat answers in milliseconds. */
|
|
15
15
|
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
16
16
|
/**
|
|
@@ -170,11 +170,16 @@ export async function heartbeatRun(options, path, progress) {
|
|
|
170
170
|
* A ``409`` is reported as success: it means the run already reached a terminal
|
|
171
171
|
* state (a redelivery we raced, or the 2h stale-run sweeper got there first).
|
|
172
172
|
* Treating that as a failure would only produce noise about work that is done.
|
|
173
|
+
*
|
|
174
|
+
* ``model`` is the model id the run used, when the harness knows it. Omitted
|
|
175
|
+
* otherwise, so a plain ``--exec`` command's completion body is unchanged.
|
|
173
176
|
*/
|
|
174
|
-
export async function completeRun(options, path, outcome) {
|
|
177
|
+
export async function completeRun(options, path, outcome, model) {
|
|
175
178
|
const body = outcome.ok
|
|
176
179
|
? { status: RUN_STATUS_SUCCEEDED }
|
|
177
180
|
: { status: RUN_STATUS_FAILED, error: outcome.error.slice(0, MAX_ERROR_CHARS) };
|
|
181
|
+
if (model)
|
|
182
|
+
body.model = model.slice(0, MAX_MODEL_CHARS);
|
|
178
183
|
const result = await post(options, path, body, undefined, false, SEND_TIMEOUT_MS);
|
|
179
184
|
if (result.status === 409)
|
|
180
185
|
return { ...result, ok: true };
|
package/dist/agent/claude.d.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* happens and still completes. These are enhancements that must fail soft.
|
|
16
16
|
*/
|
|
17
17
|
import { MCP_SERVER_NAME } from './mcp.js';
|
|
18
|
+
import type { RuntimeObservation } from './runtime_status.js';
|
|
18
19
|
export { MCP_SERVER_NAME };
|
|
19
20
|
/**
|
|
20
21
|
* What goes to `--allowedTools` so the ProhostAI tools are callable.
|
|
@@ -151,6 +152,12 @@ export interface ClaudeStream {
|
|
|
151
152
|
activity(): string | undefined;
|
|
152
153
|
/** The tail of the last thing the agent wrote, for a run that was killed. */
|
|
153
154
|
salvage(): string | undefined;
|
|
155
|
+
/**
|
|
156
|
+
* Model, Claude Code version and capacity windows the stream announced —
|
|
157
|
+
* `system/init.{model, claude_code_version}` and the last
|
|
158
|
+
* `rate_limit_event`. Fields the stream never mentioned are absent.
|
|
159
|
+
*/
|
|
160
|
+
observed(): RuntimeObservation;
|
|
154
161
|
/**
|
|
155
162
|
* The turn's outcome. ``stdout`` is the complete captured output, used only
|
|
156
163
|
* when the stream held no `result` event — a crashed binary, a login prompt,
|
package/dist/agent/claude.js
CHANGED
|
@@ -18,6 +18,7 @@ import { chmodSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import { prohostHome } from './credentials.js';
|
|
20
20
|
import { MCP_SERVER_NAME } from './mcp.js';
|
|
21
|
+
import { fromClaudeRateLimitInfo } from './runtime_status.js';
|
|
21
22
|
import { SHELL_CONTROL_OPERATORS, shellQuote } from './shell.js';
|
|
22
23
|
export { MCP_SERVER_NAME };
|
|
23
24
|
/**
|
|
@@ -315,10 +316,21 @@ export function readClaudeStream() {
|
|
|
315
316
|
let lastActivity;
|
|
316
317
|
let lastText;
|
|
317
318
|
let result;
|
|
319
|
+
const observed = {};
|
|
318
320
|
const handle = (line) => {
|
|
319
321
|
const event = asObject(line);
|
|
320
322
|
if (!event)
|
|
321
323
|
return;
|
|
324
|
+
if (event.type === 'system' && event.subtype === 'init') {
|
|
325
|
+
observed.model = text(event.model) ?? observed.model;
|
|
326
|
+
observed.runtime_version = text(event.claude_code_version) ?? observed.runtime_version;
|
|
327
|
+
}
|
|
328
|
+
else if (event.type === 'rate_limit_event') {
|
|
329
|
+
// Last one wins; absent for API-key / Bedrock / Vertex sessions.
|
|
330
|
+
const windows = fromClaudeRateLimitInfo(event.rate_limit_info);
|
|
331
|
+
if (windows.length > 0)
|
|
332
|
+
observed.quota_windows = windows;
|
|
333
|
+
}
|
|
322
334
|
// Present on the `system`/`init` event, and repeated on most others. Taken
|
|
323
335
|
// from whichever arrives first — they all name the same session.
|
|
324
336
|
if (!session)
|
|
@@ -359,6 +371,7 @@ export function readClaudeStream() {
|
|
|
359
371
|
},
|
|
360
372
|
sessionId: () => session,
|
|
361
373
|
activity: () => lastActivity,
|
|
374
|
+
observed: () => ({ ...observed }),
|
|
362
375
|
salvage: () => {
|
|
363
376
|
const trimmed = lastText?.trim();
|
|
364
377
|
if (!trimmed)
|
package/dist/agent/contract.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export declare const RUN_STATUS_SUCCEEDED = "succeeded";
|
|
|
29
29
|
export declare const RUN_STATUS_FAILED = "failed";
|
|
30
30
|
/** Server-side cap on the `error` field of a completion (`MAX_ERROR_CHARS`). */
|
|
31
31
|
export declare const MAX_ERROR_CHARS = 1000;
|
|
32
|
+
/** Server-side cap on the `model` field of a completion (`MAX_MODEL_CHARS`). */
|
|
33
|
+
export declare const MAX_MODEL_CHARS = 100;
|
|
32
34
|
/**
|
|
33
35
|
* The completion `error` that means "a person asked for this run to stop".
|
|
34
36
|
*
|
package/dist/agent/contract.js
CHANGED
|
@@ -29,6 +29,8 @@ export const RUN_STATUS_SUCCEEDED = 'succeeded';
|
|
|
29
29
|
export const RUN_STATUS_FAILED = 'failed';
|
|
30
30
|
/** Server-side cap on the `error` field of a completion (`MAX_ERROR_CHARS`). */
|
|
31
31
|
export const MAX_ERROR_CHARS = 1000;
|
|
32
|
+
/** Server-side cap on the `model` field of a completion (`MAX_MODEL_CHARS`). */
|
|
33
|
+
export const MAX_MODEL_CHARS = 100;
|
|
32
34
|
/**
|
|
33
35
|
* The completion `error` that means "a person asked for this run to stop".
|
|
34
36
|
*
|
package/dist/agent/run.d.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import type { spawn } from 'node:child_process';
|
|
26
26
|
import WebSocket from 'ws';
|
|
27
27
|
import type { AgentCredentials } from './credentials.js';
|
|
28
|
+
import type { QuotaProvider } from './runtime_status.js';
|
|
28
29
|
/**
|
|
29
30
|
* Default ceiling on one agent invocation: none.
|
|
30
31
|
*
|
|
@@ -131,6 +132,12 @@ export interface AgentRunOptions {
|
|
|
131
132
|
* to {@link HEARTBEAT_LOG_INTERVAL_MS}; tests shorten it.
|
|
132
133
|
*/
|
|
133
134
|
heartbeatLogIntervalMs?: number;
|
|
135
|
+
/**
|
|
136
|
+
* Where idle capacity numbers come from (see `runtime_status.ts`). Defaults
|
|
137
|
+
* to asking the detected agent CLI itself; `null` disables idle refresh.
|
|
138
|
+
* Tests inject a fake so no real `claude` / `codex` is spawned.
|
|
139
|
+
*/
|
|
140
|
+
quotaProvider?: QuotaProvider | null;
|
|
134
141
|
}
|
|
135
142
|
export interface AgentRunMetrics {
|
|
136
143
|
connections: number;
|
package/dist/agent/run.js
CHANGED
|
@@ -36,6 +36,7 @@ import { execAgent } from './exec.js';
|
|
|
36
36
|
import { HeartbeatLoop } from './heartbeat.js';
|
|
37
37
|
import { buildPrompt, replyFromStdout } from './prompt.js';
|
|
38
38
|
import { RunStore } from './runstore.js';
|
|
39
|
+
import { RUNTIME_STATUS_FEATURE, RuntimeStatusReporter, claudeQuotaProvider, codexConfiguredModel, codexQuotaProvider, modelFromExec, parseVersion, readCodexObservation, } from './runtime_status.js';
|
|
39
40
|
import { SessionStore, sessionKeyFor } from './sessionstore.js';
|
|
40
41
|
import { verifySignature } from './signature.js';
|
|
41
42
|
import { isWebhookEvent } from '../forward.js';
|
|
@@ -133,8 +134,8 @@ const COMPLETION_RETRY_DELAYS_MS = [1_000, 3_000, 9_000, 30_000, 60_000, 120_000
|
|
|
133
134
|
* sweeper reaps it. A 409 (`already_resolved`) counts as done. A 4xx other
|
|
134
135
|
* than 429 is a decision, not a blip, so it isn't retried.
|
|
135
136
|
*/
|
|
136
|
-
async function completeWithRetry(api, path, outcome, log, sleep) {
|
|
137
|
-
let result = await completeRun(api, path, outcome);
|
|
137
|
+
async function completeWithRetry(api, path, outcome, log, sleep, model) {
|
|
138
|
+
let result = await completeRun(api, path, outcome, model);
|
|
138
139
|
for (const delay of COMPLETION_RETRY_DELAYS_MS) {
|
|
139
140
|
if (result.ok)
|
|
140
141
|
return result;
|
|
@@ -143,7 +144,7 @@ async function completeWithRetry(api, path, outcome, log, sleep) {
|
|
|
143
144
|
return result;
|
|
144
145
|
log(` … completion failed (${describeFailure(result)}); retrying in ${delay / 1000}s`);
|
|
145
146
|
await sleep(delay);
|
|
146
|
-
result = await completeRun(api, path, outcome);
|
|
147
|
+
result = await completeRun(api, path, outcome, model);
|
|
147
148
|
}
|
|
148
149
|
return result;
|
|
149
150
|
}
|
|
@@ -240,8 +241,21 @@ function buildRuntime(options, log) {
|
|
|
240
241
|
? 'codex'
|
|
241
242
|
: 'plain';
|
|
242
243
|
const skipPermissions = options.safeTools !== true;
|
|
244
|
+
const runtimeName = runtimeNameFor(brain, options.exec);
|
|
245
|
+
// Replaced by the harness once its status reporter exists.
|
|
246
|
+
const observe = () => { };
|
|
247
|
+
const model = () => undefined;
|
|
243
248
|
if (brain === 'plain') {
|
|
244
|
-
return {
|
|
249
|
+
return {
|
|
250
|
+
workdir,
|
|
251
|
+
sessions,
|
|
252
|
+
brain,
|
|
253
|
+
skipPermissions,
|
|
254
|
+
prepareMcp: () => ({ mcpWired: false }),
|
|
255
|
+
runtimeName,
|
|
256
|
+
observe,
|
|
257
|
+
model,
|
|
258
|
+
};
|
|
245
259
|
}
|
|
246
260
|
const mcpUrl = options.credentials.mcp_url;
|
|
247
261
|
let announced = '';
|
|
@@ -296,7 +310,30 @@ function buildRuntime(options, log) {
|
|
|
296
310
|
'access, on instructions from your workspace. Use --safe-tools to gate them.'
|
|
297
311
|
: `${timestamp()} ⚙ --safe-tools: tools are permission-gated; anything needing ` +
|
|
298
312
|
'approval will not run (nobody is at a terminal to approve it).');
|
|
299
|
-
return { workdir, sessions, brain, prepareMcp, skipPermissions };
|
|
313
|
+
return { workdir, sessions, brain, prepareMcp, skipPermissions, runtimeName, observe, model };
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* The `runtime` a status frame names. A plain command is `custom` unless it is
|
|
317
|
+
* recognisably goose — the one other agent CLI people pair today.
|
|
318
|
+
*/
|
|
319
|
+
function runtimeNameFor(brain, exec) {
|
|
320
|
+
if (brain === 'claude')
|
|
321
|
+
return 'claude_code';
|
|
322
|
+
if (brain === 'codex')
|
|
323
|
+
return 'codex';
|
|
324
|
+
const base = (exec.trim().split(/\s+/)[0] ?? '').split('/').pop() ?? '';
|
|
325
|
+
return base === 'goose' ? 'goose' : 'custom';
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Idle capacity source for the detected brain: the agent CLI itself, asked
|
|
329
|
+
* through its own protocol so its own login answers. `plain` has none.
|
|
330
|
+
*/
|
|
331
|
+
function defaultQuotaProvider(brain, exec) {
|
|
332
|
+
if (brain === 'claude')
|
|
333
|
+
return claudeQuotaProvider({ binary: exec.trim().split(/\s+/)[0] ?? 'claude' });
|
|
334
|
+
if (brain === 'codex')
|
|
335
|
+
return codexQuotaProvider({ binary: codexBinary(exec), clientVersion: CLI_VERSION });
|
|
336
|
+
return undefined;
|
|
300
337
|
}
|
|
301
338
|
/**
|
|
302
339
|
* Prepare a fresh Codex MCP override immediately before each invocation.
|
|
@@ -574,7 +611,7 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
|
|
|
574
611
|
const reader = runtime.brain === 'claude' ? readClaudeStream() : undefined;
|
|
575
612
|
stream = reader;
|
|
576
613
|
tail = '';
|
|
577
|
-
|
|
614
|
+
const running = execAgent({
|
|
578
615
|
command,
|
|
579
616
|
prompt,
|
|
580
617
|
timeoutMs: capped ? Math.max(1, remainingMs()) : 0,
|
|
@@ -594,6 +631,9 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
|
|
|
594
631
|
}
|
|
595
632
|
: undefined,
|
|
596
633
|
});
|
|
634
|
+
// Whatever the stream announced — model, version, capacity — is true of
|
|
635
|
+
// this machine whether or not the turn succeeded.
|
|
636
|
+
return reader ? running.finally(() => runtime.observe(reader.observed())) : running;
|
|
597
637
|
};
|
|
598
638
|
// Wall clock for the whole turn, retry included — what the operator waited,
|
|
599
639
|
// rather than what the last attempt took.
|
|
@@ -704,6 +744,9 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
|
|
|
704
744
|
};
|
|
705
745
|
if (codex) {
|
|
706
746
|
const output = parseCodexOutput(result.stdout);
|
|
747
|
+
// Codex's `--json` stream carries neither model nor limits; its rollout
|
|
748
|
+
// file for this thread does.
|
|
749
|
+
runtime.observe(readCodexObservation(output.sessionId, { ...process.env, ...options.env }));
|
|
707
750
|
reportDenials(output.denials);
|
|
708
751
|
if (!output.ok) {
|
|
709
752
|
return {
|
|
@@ -787,7 +830,7 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
|
|
|
787
830
|
}
|
|
788
831
|
// The exact marker string, alone: the server words the thread notice from
|
|
789
832
|
// it, and anything wrapped around it reads as a crash instead of a stop.
|
|
790
|
-
const closed = await completeWithRetry(api, run.completion_path, { ok: false, error: RUN_ERROR_CANCELLED_BY_USER }, log, sleep);
|
|
833
|
+
const closed = await completeWithRetry(api, run.completion_path, { ok: false, error: RUN_ERROR_CANCELLED_BY_USER }, log, sleep, runtime.model());
|
|
791
834
|
if (!closed.ok) {
|
|
792
835
|
log(` ! could not report the cancellation (${describeFailure(closed)})` +
|
|
793
836
|
`${closed.status !== 0 && closed.error ? ` ${closed.error}` : ''}`);
|
|
@@ -805,7 +848,7 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
|
|
|
805
848
|
log(` [dry-run] would report the run as failed`);
|
|
806
849
|
return 'skipped';
|
|
807
850
|
}
|
|
808
|
-
const completed = await completeWithRetry(api, run.completion_path, { ok: false, error: detail ? `${reason}: ${detail}` : reason }, log, sleep);
|
|
851
|
+
const completed = await completeWithRetry(api, run.completion_path, { ok: false, error: detail ? `${reason}: ${detail}` : reason }, log, sleep, runtime.model());
|
|
809
852
|
if (!completed.ok) {
|
|
810
853
|
log(` ! could not report failure (${describeFailure(completed)})` +
|
|
811
854
|
`${completed.status !== 0 && completed.error ? ` ${completed.error}` : ''}`);
|
|
@@ -833,7 +876,7 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
|
|
|
833
876
|
log(`${timestamp()} ✓ ${label} finished in ${seconds}s (${why})`);
|
|
834
877
|
for (const line of reply.split('\n'))
|
|
835
878
|
log(` │ ${line}`);
|
|
836
|
-
const completed = await completeWithRetry(api, run.completion_path, { ok: true }, log, sleep);
|
|
879
|
+
const completed = await completeWithRetry(api, run.completion_path, { ok: true }, log, sleep, runtime.model());
|
|
837
880
|
if (!completed.ok) {
|
|
838
881
|
log(` ! could not mark the run complete (${describeFailure(completed)})`);
|
|
839
882
|
return 'unreported';
|
|
@@ -856,7 +899,7 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
|
|
|
856
899
|
// used to be expressed by simply not recording until the reply landed.
|
|
857
900
|
if (result.sessionRecorded)
|
|
858
901
|
runtime.sessions.forget(sessionKeyFor(run));
|
|
859
|
-
const reported = await completeWithRetry(api, run.completion_path, { ok: false, error: `posting the reply failed: ${failure}` }, log, sleep);
|
|
902
|
+
const reported = await completeWithRetry(api, run.completion_path, { ok: false, error: `posting the reply failed: ${failure}` }, log, sleep, runtime.model());
|
|
860
903
|
return reported.ok ? 'failed' : 'unreported';
|
|
861
904
|
}
|
|
862
905
|
// Advance continuity only after the answer is durable on its reply surface.
|
|
@@ -865,7 +908,7 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
|
|
|
865
908
|
if (result.sessionId) {
|
|
866
909
|
runtime.sessions.remember(sessionKeyFor(run), result.sessionId);
|
|
867
910
|
}
|
|
868
|
-
const completed = await completeWithRetry(api, run.completion_path, { ok: true }, log, sleep);
|
|
911
|
+
const completed = await completeWithRetry(api, run.completion_path, { ok: true }, log, sleep, runtime.model());
|
|
869
912
|
if (!completed.ok) {
|
|
870
913
|
// The reply is already in the conversation, so this is a bookkeeping
|
|
871
914
|
// failure, not a lost answer — say so precisely. Returning false lets the
|
|
@@ -914,6 +957,44 @@ export async function runAgentHarness(options) {
|
|
|
914
957
|
/** Accepted, not yet reported — the executing run and everything queued. */
|
|
915
958
|
const accepted = new Map();
|
|
916
959
|
const heartbeats = new HeartbeatLoop({ api, log, intervalMs: options.heartbeatIntervalMs });
|
|
960
|
+
const status = new RuntimeStatusReporter({
|
|
961
|
+
runtime: runtime.runtimeName,
|
|
962
|
+
provider: options.quotaProvider === undefined
|
|
963
|
+
? defaultQuotaProvider(runtime.brain, options.exec)
|
|
964
|
+
: (options.quotaProvider ?? undefined),
|
|
965
|
+
// A run in flight reports fresh numbers when it ends; don't probe beside it.
|
|
966
|
+
isBusy: () => [...accepted.values()].some((entry) => entry.executing),
|
|
967
|
+
log,
|
|
968
|
+
});
|
|
969
|
+
runtime.observe = (observation) => status.observe(observation);
|
|
970
|
+
runtime.model = () => (runtime.brain === 'plain' ? undefined : status.model());
|
|
971
|
+
let statusPrimed = false;
|
|
972
|
+
/**
|
|
973
|
+
* First-connect facts that need no run: the CLI's version and any model the
|
|
974
|
+
* exec string or Codex config pins. Done lazily — only once a server has
|
|
975
|
+
* asked for status — so nothing is probed for a server that can't use it.
|
|
976
|
+
*/
|
|
977
|
+
const primeStatus = () => {
|
|
978
|
+
if (statusPrimed || runtime.brain === 'plain')
|
|
979
|
+
return;
|
|
980
|
+
statusPrimed = true;
|
|
981
|
+
const binary = runtime.brain === 'codex'
|
|
982
|
+
? codexBinary(options.exec)
|
|
983
|
+
: (options.exec.trim().split(/\s+/)[0] ?? 'claude');
|
|
984
|
+
const probe = options.probeImpl ?? defaultProbe;
|
|
985
|
+
let version;
|
|
986
|
+
try {
|
|
987
|
+
version = parseVersion(probe(`${binary} --version`, runtime.workdir).stdout);
|
|
988
|
+
}
|
|
989
|
+
catch {
|
|
990
|
+
version = undefined;
|
|
991
|
+
}
|
|
992
|
+
status.observe({
|
|
993
|
+
runtime_version: version,
|
|
994
|
+
model: modelFromExec(options.exec) ??
|
|
995
|
+
(runtime.brain === 'codex' ? codexConfiguredModel({ ...process.env, ...options.env }) : undefined),
|
|
996
|
+
});
|
|
997
|
+
};
|
|
917
998
|
let queue = Promise.resolve();
|
|
918
999
|
let fatal;
|
|
919
1000
|
let attempt = 0;
|
|
@@ -1001,6 +1082,7 @@ export async function runAgentHarness(options) {
|
|
|
1001
1082
|
// any cancellation the heartbeat loop started and deliberately did not wait
|
|
1002
1083
|
// for, so the harness never exits with a run half-closed.
|
|
1003
1084
|
await queue;
|
|
1085
|
+
status.detach();
|
|
1004
1086
|
// Stopped first, so no further tick can start work after the wait below has
|
|
1005
1087
|
// taken its snapshot of `background`.
|
|
1006
1088
|
heartbeats.stop();
|
|
@@ -1102,6 +1184,12 @@ export async function runAgentHarness(options) {
|
|
|
1102
1184
|
// read mention code instead of the run dispatcher.
|
|
1103
1185
|
log(`${timestamp()} ● connected as "${name}" — waiting for ` +
|
|
1104
1186
|
`${EVENT_AGENT_RUN_REQUESTED} (exec: ${options.exec})`);
|
|
1187
|
+
// Only a server that says it understands the frame gets one; an
|
|
1188
|
+
// older server may treat an unknown frame type as a protocol error.
|
|
1189
|
+
if (Array.isArray(f.features) && f.features.includes(RUNTIME_STATUS_FEATURE)) {
|
|
1190
|
+
primeStatus();
|
|
1191
|
+
status.attach((frame) => ws.send(JSON.stringify(frame)));
|
|
1192
|
+
}
|
|
1105
1193
|
return;
|
|
1106
1194
|
}
|
|
1107
1195
|
if (f.type === 'auth_error') {
|
|
@@ -1265,6 +1353,7 @@ export async function runAgentHarness(options) {
|
|
|
1265
1353
|
});
|
|
1266
1354
|
ws.on('close', () => {
|
|
1267
1355
|
liveness.stop();
|
|
1356
|
+
status.detach();
|
|
1268
1357
|
if (options.signal)
|
|
1269
1358
|
options.signal.removeEventListener('abort', close);
|
|
1270
1359
|
resolve(authed);
|
|
@@ -0,0 +1,191 @@
|
|
|
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
|
+
/** Feature name the server lists in `auth_ok.features` when it accepts the frame. */
|
|
29
|
+
export declare const RUNTIME_STATUS_FEATURE = "runtime_status";
|
|
30
|
+
/** Routine report cadence while connected. */
|
|
31
|
+
export declare const STATUS_INTERVAL_MS: number;
|
|
32
|
+
/**
|
|
33
|
+
* Minimum spacing between frames. The server accepts one per 30 s per socket
|
|
34
|
+
* and drops the rest; a second of margin keeps clock jitter from costing us
|
|
35
|
+
* the frame that carried a change.
|
|
36
|
+
*/
|
|
37
|
+
export declare const MIN_FRAME_SPACING_MS = 31000;
|
|
38
|
+
/** Ask the agent CLI for fresh numbers when the last ones are this old. */
|
|
39
|
+
export declare const QUOTA_REFRESH_AFTER_MS: number;
|
|
40
|
+
/** Ceiling on one idle quota probe. */
|
|
41
|
+
export declare const PROVIDER_TIMEOUT_MS = 10000;
|
|
42
|
+
export type QuotaKind = 'session' | 'weekly';
|
|
43
|
+
export interface QuotaWindow {
|
|
44
|
+
kind: QuotaKind;
|
|
45
|
+
/** 0–100. */
|
|
46
|
+
used_percent: number;
|
|
47
|
+
/** ISO-8601. */
|
|
48
|
+
resets_at: string;
|
|
49
|
+
}
|
|
50
|
+
/** What one source managed to learn. Every field is optional; absent means "didn't learn it". */
|
|
51
|
+
export interface RuntimeObservation {
|
|
52
|
+
runtime_version?: string;
|
|
53
|
+
model?: string;
|
|
54
|
+
plan_label?: string;
|
|
55
|
+
quota_windows?: QuotaWindow[];
|
|
56
|
+
}
|
|
57
|
+
/** A percentage on a 0–``scale`` scale, as 0–100 rounded to one decimal. */
|
|
58
|
+
export declare function percent(value: unknown, scale: 1 | 100): number | undefined;
|
|
59
|
+
/** Epoch seconds or an ISO string, as ISO-8601; anything else is `undefined`. */
|
|
60
|
+
export declare function isoTime(value: unknown): string | undefined;
|
|
61
|
+
/** First `X.Y.Z…` in a `--version` line, e.g. `2.1.282 (Claude Code)` or `codex-cli 0.146.0`. */
|
|
62
|
+
export declare function parseVersion(stdout: string): string | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* Windows from a stream `rate_limit_event.rate_limit_info`.
|
|
65
|
+
*
|
|
66
|
+
* `unifiedWindows.*.utilization` is a FRACTION (0–1, and it can exceed 1) with
|
|
67
|
+
* epoch-second resets — unlike `get_usage`, which is 0–100 with ISO strings.
|
|
68
|
+
* The field is marked internal upstream, so an unexpected shape yields nothing.
|
|
69
|
+
*/
|
|
70
|
+
export declare function fromClaudeRateLimitInfo(info: unknown): QuotaWindow[];
|
|
71
|
+
/** Windows from a `get_usage` control response (0–100, ISO strings). */
|
|
72
|
+
export declare function fromClaudeGetUsage(usage: unknown): QuotaWindow[];
|
|
73
|
+
/** `get_usage.subscription_type` → display label; `undefined` for anything else. */
|
|
74
|
+
export declare function claudePlanLabel(subscriptionType: unknown): string | undefined;
|
|
75
|
+
/**
|
|
76
|
+
* Windows from Codex `rate_limits` (rollout, snake_case) or `rateLimits`
|
|
77
|
+
* (app-server, camelCase).
|
|
78
|
+
*
|
|
79
|
+
* Classified by window LENGTH, never by slot: on some plans `primary` is the
|
|
80
|
+
* weekly window and there is no 5-hour one at all.
|
|
81
|
+
*/
|
|
82
|
+
export declare function fromCodexRateLimits(rateLimits: unknown, lineTimestamp?: string): QuotaWindow[];
|
|
83
|
+
/** `plan_type` / `planType` → `ChatGPT Pro`; `undefined` when there is no plan. */
|
|
84
|
+
export declare function codexPlanLabel(planType: unknown): string | undefined;
|
|
85
|
+
/** Model named in the exec string itself (`-m gpt-5` / `--model gpt-5` / `--model=gpt-5`). */
|
|
86
|
+
export declare function modelFromExec(exec: string): string | undefined;
|
|
87
|
+
/** `$CODEX_HOME`, defaulting to `~/.codex` exactly as Codex does. */
|
|
88
|
+
export declare function codexHome(env?: NodeJS.ProcessEnv): string;
|
|
89
|
+
/**
|
|
90
|
+
* The top-level `model = "…"` in `$CODEX_HOME/config.toml` — Codex's default.
|
|
91
|
+
*
|
|
92
|
+
* Only the top level, before the first `[table]`: a `model` inside a profile
|
|
93
|
+
* table is not the default. A first guess only; the rollout's `turn_context`
|
|
94
|
+
* replaces it after the first run.
|
|
95
|
+
*/
|
|
96
|
+
export declare function codexConfiguredModel(env?: NodeJS.ProcessEnv): string | undefined;
|
|
97
|
+
/**
|
|
98
|
+
* Locate the rollout for one thread: `sessions/YYYY/MM/DD/rollout-*-<thread_id>.jsonl`.
|
|
99
|
+
*
|
|
100
|
+
* The date directory is local time and a resumed thread lives under the day it
|
|
101
|
+
* started, so the date is never guessed — days are scanned newest first.
|
|
102
|
+
*/
|
|
103
|
+
export declare function findCodexRollout(threadId: string, env?: NodeJS.ProcessEnv): string | undefined;
|
|
104
|
+
/**
|
|
105
|
+
* What a Codex rollout says: the model the turn really used (after `-m`,
|
|
106
|
+
* profiles and `-c`), the CLI version, and the last rate-limit snapshot.
|
|
107
|
+
*/
|
|
108
|
+
export declare function parseCodexRollout(content: string): RuntimeObservation;
|
|
109
|
+
/** Read the rollout for a finished Codex thread, if it can be found. */
|
|
110
|
+
export declare function readCodexObservation(threadId: string | undefined, env?: NodeJS.ProcessEnv): RuntimeObservation | undefined;
|
|
111
|
+
/**
|
|
112
|
+
* Where fresh numbers come from when no run has produced any lately.
|
|
113
|
+
*
|
|
114
|
+
* Implementations ask the unmodified agent CLI through its own protocol, so
|
|
115
|
+
* the CLI answers with its own login and this process never holds a
|
|
116
|
+
* credential. Resolve `undefined` on any failure — the reporter keeps what it
|
|
117
|
+
* had and omits what it doesn't know.
|
|
118
|
+
*/
|
|
119
|
+
export interface QuotaProvider {
|
|
120
|
+
refresh(): Promise<RuntimeObservation | undefined>;
|
|
121
|
+
}
|
|
122
|
+
type SpawnFn = typeof nodeSpawn;
|
|
123
|
+
/**
|
|
124
|
+
* Claude Code's `get_usage` control request. No model turn is made, so no
|
|
125
|
+
* quota is spent. The `initialize` response carries account identity; it is
|
|
126
|
+
* never read.
|
|
127
|
+
*/
|
|
128
|
+
export declare function claudeQuotaProvider(options: {
|
|
129
|
+
binary: string;
|
|
130
|
+
spawnImpl?: SpawnFn;
|
|
131
|
+
timeoutMs?: number;
|
|
132
|
+
}): QuotaProvider;
|
|
133
|
+
/** Codex's `account/rateLimits/read` over `codex app-server` stdio JSON-RPC. */
|
|
134
|
+
export declare function codexQuotaProvider(options: {
|
|
135
|
+
binary: string;
|
|
136
|
+
clientVersion: string;
|
|
137
|
+
spawnImpl?: SpawnFn;
|
|
138
|
+
timeoutMs?: number;
|
|
139
|
+
}): QuotaProvider;
|
|
140
|
+
export interface RuntimeStatusReporterOptions {
|
|
141
|
+
/** `claude_code` | `codex` | `goose` | `custom`. */
|
|
142
|
+
runtime: string;
|
|
143
|
+
/** Idle quota source; omit for a runtime that has none. */
|
|
144
|
+
provider?: QuotaProvider;
|
|
145
|
+
/** A run is executing — it will bring fresh numbers, so don't probe beside it. */
|
|
146
|
+
isBusy?: () => boolean;
|
|
147
|
+
now?: () => number;
|
|
148
|
+
/** Timer seam for tests. Returns a cancel function. */
|
|
149
|
+
schedule?: (fn: () => void, ms: number) => () => void;
|
|
150
|
+
intervalMs?: number;
|
|
151
|
+
log?: (line: string) => void;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Holds the latest runtime snapshot and decides when to send it.
|
|
155
|
+
*
|
|
156
|
+
* Sends only while attached — i.e. only on a socket whose server advertised
|
|
157
|
+
* {@link RUNTIME_STATUS_FEATURE}. Cadence: once on attach, every
|
|
158
|
+
* {@link STATUS_INTERVAL_MS}, and promptly when the model, plan or runtime
|
|
159
|
+
* version changes or capacity crosses a band — but never closer together than
|
|
160
|
+
* {@link MIN_FRAME_SPACING_MS}.
|
|
161
|
+
*/
|
|
162
|
+
export declare class RuntimeStatusReporter {
|
|
163
|
+
private readonly options;
|
|
164
|
+
private readonly now;
|
|
165
|
+
private readonly schedule;
|
|
166
|
+
private state;
|
|
167
|
+
private quotaObservedAt;
|
|
168
|
+
private send;
|
|
169
|
+
private lastSentAt;
|
|
170
|
+
private cancelPending;
|
|
171
|
+
private cancelTick;
|
|
172
|
+
private refreshing;
|
|
173
|
+
constructor(options: RuntimeStatusReporterOptions);
|
|
174
|
+
/** Merge what a source learned. Absent fields keep their previous value. */
|
|
175
|
+
observe(observation: RuntimeObservation | undefined): void;
|
|
176
|
+
/** The model to report on a run's completion, when nothing more specific is known. */
|
|
177
|
+
model(): string | undefined;
|
|
178
|
+
/** Start reporting on a socket that advertised the feature. */
|
|
179
|
+
attach(send: (frame: Record<string, unknown>) => void): void;
|
|
180
|
+
/** Stop reporting — the socket is gone. */
|
|
181
|
+
detach(): void;
|
|
182
|
+
/** The frame as it would be sent now. */
|
|
183
|
+
frame(): Record<string, unknown>;
|
|
184
|
+
/** Routine tick: refresh idle quota if it is stale, send, and re-arm. */
|
|
185
|
+
private tick;
|
|
186
|
+
private refresh;
|
|
187
|
+
/** Send now if spacing allows, else once it does. Coalesces. */
|
|
188
|
+
private sendSoon;
|
|
189
|
+
private flush;
|
|
190
|
+
}
|
|
191
|
+
export {};
|
|
@@ -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/version.d.ts
CHANGED
|
@@ -7,6 +7,6 @@
|
|
|
7
7
|
* `package.json`, so a release bump that forgets this file fails the suite
|
|
8
8
|
* instead of shipping a `User-Agent` that lies about which build is calling.
|
|
9
9
|
*/
|
|
10
|
-
export declare const CLI_VERSION = "0.
|
|
10
|
+
export declare const CLI_VERSION = "0.6.0";
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
|
-
export declare const USER_AGENT = "prohost-cli/0.
|
|
12
|
+
export declare const USER_AGENT = "prohost-cli/0.6.0";
|
package/dist/version.js
CHANGED
|
@@ -7,6 +7,6 @@
|
|
|
7
7
|
* `package.json`, so a release bump that forgets this file fails the suite
|
|
8
8
|
* instead of shipping a `User-Agent` that lies about which build is calling.
|
|
9
9
|
*/
|
|
10
|
-
export const CLI_VERSION = '0.
|
|
10
|
+
export const CLI_VERSION = '0.6.0';
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
12
|
export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
|