@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 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
@@ -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 };
@@ -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,
@@ -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)
@@ -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
  *
@@ -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
  *
@@ -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 { workdir, sessions, brain, skipPermissions, prepareMcp: () => ({ mcpWired: false }) };
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
- return execAgent({
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.5.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.5.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.5.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}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prohost/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Run your own AI agent as a ProhostAI teammate, and stream your account's webhooks to your laptop.",
5
5
  "type": "module",
6
6
  "license": "MIT",