@prohost/cli 0.9.0 → 0.10.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,45 @@ 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.10.0
8
+
9
+ Every Claude Code and Codex login on a machine can report its remaining
10
+ capacity and token use to ProhostAI, without pairing an agent.
11
+
12
+ - **`prohost usage connect [--key <key>]`** stores an API key with the
13
+ `usage:write` scope in `$PROHOST_HOME/usage.json` (`0600`, never printed). A
14
+ machine that already paired an agent can skip `--key`; its credential is
15
+ accepted.
16
+ - **Discovery.** `connect`, `usage discover [--dry-run]` and every `usage start`
17
+ register the config directories already on the machine — `~/.claude*`,
18
+ `~/.codex*`, `$CLAUDE_CONFIG_DIR`, `$CODEX_HOME` — in the machine-wide account
19
+ registry, labelled from the directory name (`~/.claude-work` → `work`). Only
20
+ directory names and non-secret markers are looked at.
21
+ - **`prohost usage start`** reports every 5 minutes (`POST /v1/usage/reports`,
22
+ source `cli`): each login's windows from the same idle probes `agent run`
23
+ uses, asked at most every 15 minutes per login, three at a time.
24
+ - **Token totals** from the CLIs' own session logs, summed per local day,
25
+ login, model and project folder name (`POST /v1/usage/token-totals`). Claude
26
+ Code responses are counted once however often their lines repeat; Codex usage
27
+ is the growth of each session's running total, with cached input split out.
28
+ The first run looks back 30 days, then reads only appended lines. No prompt
29
+ text is read into the totals or sent.
30
+ Totals for a login are sent only once a capacity report for it has been
31
+ accepted (`acceptedAccountKeys` when the server names them; otherwise any
32
+ non-zero `accepted`), so a first run or a newly found login reports first.
33
+ Skipped rows follow the server's `skipped[].reason`: `not_owned`,
34
+ `other_source` and `out_of_range` are dropped (logged once), `throttled` is
35
+ retried next pass, `not_reported` waits for the next accepted report. Rows
36
+ skipped without a reason are retried at most three times.
37
+ - **Fails soft.** A 401/403/404 (routes not deployed, feature off, key without
38
+ the scope) is logged once and retried every 30 minutes; network errors back
39
+ off exponentially. A tick never throws.
40
+ - **`prohost usage install-daemon` / `uninstall-daemon`** keep it running under
41
+ launchd with its own label (`ai.prohost.usage.*`), next to any agent daemon.
42
+ **`prohost usage status`** shows what was last sent and local totals.
43
+ - `prohost agent account remove` on a login registered from an existing
44
+ directory forgets it and leaves the directory in place.
45
+
7
46
  ## 0.9.0
8
47
 
9
48
  A quick question no longer waits behind a long job in another thread.
package/README.md CHANGED
@@ -1,12 +1,15 @@
1
1
  # `@prohost/cli`
2
2
 
3
- The [ProhostAI](https://www.prohost.ai) command-line tool. Two things it does:
3
+ The [ProhostAI](https://www.prohost.ai) command-line tool. Three things it does:
4
4
 
5
5
  - **`prohost agent`** — run your own local agent as a ProhostAI teammate. Someone
6
6
  @-mentions it in the app, it runs on your machine, and its answer is posted
7
7
  back into the conversation.
8
8
  - **`prohost listen`** — stream your account's webhooks to a local HTTP endpoint
9
9
  while you develop against them (like `stripe listen`).
10
+ - **`prohost usage`** — show your workspace how much Claude Code and Codex
11
+ capacity every login on this machine has left, and how many tokens each one
12
+ used, without pairing an agent.
10
13
 
11
14
  You need a ProhostAI account and **Node.js 18 or newer**. Nothing to install —
12
15
  `npx` fetches it on demand:
@@ -728,6 +731,75 @@ through ProhostAI's own approval path, not around it via an API key.
728
731
 
729
732
  ---
730
733
 
734
+ ## AI usage reporting
735
+
736
+ `prohost usage` reports, for every Claude Code and Codex login on this machine,
737
+ how much of its subscription is left (the 5-hour and weekly windows, with their
738
+ reset times) and how many tokens it used per day, model and project folder.
739
+ ProhostAI shows it in the workspace's AI Usage view. No agent pairing needed.
740
+
741
+ ```bash
742
+ # 1. In ProhostAI: Settings → API keys → create a key with the usage:write scope.
743
+ # (A machine with a paired agent can skip --key: its credential is accepted.)
744
+ npx @prohost/cli usage connect --key <api-key>
745
+
746
+ # 2. Report now, then every 5 minutes, in the foreground…
747
+ npx @prohost/cli usage start
748
+
749
+ # …or keep it running across reboots (macOS launchd; prints a systemd unit on Linux).
750
+ npx @prohost/cli usage install-daemon
751
+
752
+ npx @prohost/cli usage status # logins, last report, local token totals
753
+ ```
754
+
755
+ **Which logins.** `connect`, `discover` and every `start` register the config
756
+ directories already on this machine: `~/.claude`, `~/.codex` (as `default`) and
757
+ any `~/.claude-<name>` / `~/.codex-<name>` (as `<name>` — `~/.claude-work` is
758
+ `work`), plus `$CLAUDE_CONFIG_DIR` and `$CODEX_HOME`. They join the same
759
+ machine-wide registry `prohost agent account` uses (`~/.prohost-accounts`), so a
760
+ login added there is reported too. `prohost usage discover --dry-run` shows what
761
+ would be registered without writing anything. Removing a discovered login with
762
+ `prohost agent account remove` leaves its directory alone.
763
+
764
+ **Capacity.** Each login is asked at most every 15 minutes, three at a time,
765
+ through the unmodified CLI with that login's directory — `claude`'s `get_usage`
766
+ control request and `codex app-server`'s `account/rateLimits/read`. Neither
767
+ spends a model turn. Sign-in state comes from `claude auth status` /
768
+ `codex login status`. `prohost` never opens a credential file or keychain item.
769
+
770
+ **Tokens.** Read from the session logs the CLIs already write —
771
+ `<config dir>/projects/**/*.jsonl` for Claude Code, `sessions/**/rollout-*.jsonl`
772
+ for Codex — and summed per local day, login, model and project, where the
773
+ project is the **name** of the working directory (`backend-service`, never its
774
+ path). Only usage counters are read: no prompt, reply, tool call or file content
775
+ is kept or sent. The first run looks back 30 days; after that only what was
776
+ appended is read, using a cursor in `$PROHOST_HOME/usage/`. A day that changes
777
+ is re-sent whole, and the server replaces it.
778
+ Totals for a login go only after the server has accepted a capacity report
779
+ for it (the server ignores totals for logins it hasn't seen), so each pass
780
+ reports first. When the server says why it skipped a login's rows, the reason
781
+ decides: another user's or another source's login, or a day out of range, is
782
+ dropped (logged once); `throttled` is retried next pass; `not_reported` waits
783
+ for the next accepted report. Unexplained skips are retried at most three
784
+ times, so a login the server never takes is never resent forever and never
785
+ holds up the others.
786
+
787
+ **If the server says no.** Before the feature is enabled for your workspace
788
+ (or with a key that lacks `usage:write`) the server answers 403/404. The reporter
789
+ logs that once, waits 30 minutes, and tries again — it never exits over it.
790
+ Network errors are retried with backoff. Logs go to `$PROHOST_HOME/usage.log`
791
+ under the daemon.
792
+
793
+ | Command | What it does |
794
+ | --- | --- |
795
+ | `usage connect [--key <key>] [--base-url <url>]` | Store the key (`$PROHOST_HOME/usage.json`, `0600`; `$PROHOST_API_KEY` works too), then discover logins. |
796
+ | `usage discover [--dry-run]` | Register logins found on this machine. |
797
+ | `usage start [--once] [--machine <name>]` | Report every 5 minutes; `--once` runs one pass and exits. |
798
+ | `usage status` | Connection, daemon, last report and upload, and each login's local totals. |
799
+ | `usage install-daemon [--machine <name>]` / `uninstall-daemon` | Keep `usage start` running (its own launchd job, separate from the agent's). |
800
+
801
+ ---
802
+
731
803
  ## Local webhook forwarding
732
804
 
733
805
  ```bash
@@ -81,9 +81,25 @@ export declare function addAccount(options: {
81
81
  runtime: AccountRuntime;
82
82
  billing?: AccountBilling;
83
83
  }, env?: NodeJS.ProcessEnv): AccountRecord;
84
+ /**
85
+ * Register a config directory that already exists — a login the operator made
86
+ * outside this CLI (`~/.claude-work`, a `$CODEX_HOME`). Nothing in it is read
87
+ * or written; signing in stays the agent CLI's own business. A directory that
88
+ * is already registered (under any label) is not registered twice.
89
+ */
90
+ export declare function registerAccountDir(options: {
91
+ label: string;
92
+ runtime: AccountRuntime;
93
+ dir: string;
94
+ billing?: AccountBilling;
95
+ }, env?: NodeJS.ProcessEnv): AccountRecord;
96
+ /** Whether removing this account deletes its directory (only ones this CLI created). */
97
+ export declare function ownsAccountDir(record: AccountRecord, env?: NodeJS.ProcessEnv): boolean;
84
98
  /**
85
99
  * Forget an account and delete its config directory (the login inside it goes
86
- * with it). The implicit default can't be removed — it is the CLI's own login.
100
+ * with it) — but only a directory this CLI created; a registered existing one
101
+ * is left where it is. The implicit default can't be removed — it is the CLI's
102
+ * own login.
87
103
  */
88
104
  export declare function removeAccount(label: string, runtime: AccountRuntime, env?: NodeJS.ProcessEnv): AccountRecord;
89
105
  export declare function runtimeName(runtime: AccountRuntime): string;
@@ -236,9 +236,50 @@ function addAccountLocked(label, runtime, billing, env) {
236
236
  saveAccounts([...loadAccounts(env), record], env);
237
237
  return record;
238
238
  }
239
+ /**
240
+ * Register a config directory that already exists — a login the operator made
241
+ * outside this CLI (`~/.claude-work`, a `$CODEX_HOME`). Nothing in it is read
242
+ * or written; signing in stays the agent CLI's own business. A directory that
243
+ * is already registered (under any label) is not registered twice.
244
+ */
245
+ export function registerAccountDir(options, env = process.env) {
246
+ const { label, runtime } = options;
247
+ if (!isValidLabel(label) || label === DEFAULT_ACCOUNT_LABEL) {
248
+ throw new AccountError(`"${label}" is not a usable label for an existing login directory.`);
249
+ }
250
+ const dir = path.resolve(options.dir);
251
+ return withRegistryLock(env, () => {
252
+ const accounts = loadAccounts(env);
253
+ const same = accounts.find((a) => a.runtime === runtime && a.dir !== null && path.resolve(a.dir) === dir);
254
+ if (same)
255
+ return same;
256
+ if (findAccount(label, runtime, env)) {
257
+ throw new AccountError(`A ${runtimeName(runtime)} account named "${label}" already exists on this machine.`);
258
+ }
259
+ if (accounts.filter((a) => a.label !== DEFAULT_ACCOUNT_LABEL).length >= MAX_REPORTED_ACCOUNTS - 2) {
260
+ throw new AccountError(`This machine already has ${MAX_REPORTED_ACCOUNTS - 2} accounts, the most ProhostAI can show. Remove one first.`);
261
+ }
262
+ const record = {
263
+ key: randomUUID(),
264
+ label,
265
+ runtime,
266
+ billing: options.billing ?? 'subscription',
267
+ dir,
268
+ created_at: new Date().toISOString(),
269
+ };
270
+ saveAccounts([...accounts, record], env);
271
+ return record;
272
+ });
273
+ }
274
+ /** Whether removing this account deletes its directory (only ones this CLI created). */
275
+ export function ownsAccountDir(record, env = process.env) {
276
+ return !!record.dir && record.dir.startsWith(path.join(accountsRoot(env), 'accounts') + path.sep);
277
+ }
239
278
  /**
240
279
  * Forget an account and delete its config directory (the login inside it goes
241
- * with it). The implicit default can't be removed — it is the CLI's own login.
280
+ * with it) — but only a directory this CLI created; a registered existing one
281
+ * is left where it is. The implicit default can't be removed — it is the CLI's
282
+ * own login.
242
283
  */
243
284
  export function removeAccount(label, runtime, env = process.env) {
244
285
  if (label === DEFAULT_ACCOUNT_LABEL) {
@@ -253,7 +294,7 @@ export function removeAccount(label, runtime, env = process.env) {
253
294
  return found;
254
295
  });
255
296
  // Only ever a directory this module created under the root.
256
- if (record.dir && record.dir.startsWith(path.join(accountsRoot(env), 'accounts') + path.sep)) {
297
+ if (ownsAccountDir(record, env) && record.dir) {
257
298
  rmSync(record.dir, { recursive: true, force: true });
258
299
  }
259
300
  return record;
@@ -4,7 +4,7 @@
4
4
  * `accounts.ts`, `list.ts` and `workspace.ts`.
5
5
  */
6
6
  import process from 'node:process';
7
- import { AccountError, addAccount, checkAuth, loadAccounts, parseAccountRuntime, removeAccount, runtimeName, signInCommand, waitForSignIn, } from './accounts.js';
7
+ import { AccountError, addAccount, checkAuth, loadAccounts, ownsAccountDir, parseAccountRuntime, removeAccount, runtimeName, signInCommand, waitForSignIn, } from './accounts.js';
8
8
  import { ensureWorkspace } from './credentials.js';
9
9
  import { findAgentHomes, renderAgentList } from './list.js';
10
10
  import { WorkspaceInitError, initDeveloperWorkspace } from './workspace.js';
@@ -118,8 +118,9 @@ export async function accountCommand(args, flags, io = defaultIO) {
118
118
  'Move them to another account first, or pass --force.');
119
119
  return 1;
120
120
  }
121
+ let removed;
121
122
  try {
122
- removeAccount(label, target?.runtime ?? runtime ?? 'claude_code', env);
123
+ removed = removeAccount(label, target?.runtime ?? runtime ?? 'claude_code', env);
123
124
  }
124
125
  catch (err) {
125
126
  if (err instanceof AccountError) {
@@ -128,7 +129,9 @@ export async function accountCommand(args, flags, io = defaultIO) {
128
129
  }
129
130
  throw err;
130
131
  }
131
- io.out(`✓ Removed "${label}" and its login directory.`);
132
+ io.out(ownsAccountDir(removed, env)
133
+ ? `✓ Removed "${label}" and its login directory.`
134
+ : `✓ Removed "${label}". Its login directory${removed.dir ? ` (${removed.dir})` : ''} was left in place.`);
132
135
  return 0;
133
136
  }
134
137
  io.err('Expected: prohost agent account add|list|remove');
@@ -68,6 +68,12 @@ export declare function describeFailure(result: ApiResult): string;
68
68
  * cached, and the next replay receives its response.
69
69
  */
70
70
  export declare function isIdempotencyInFlight(result: ApiResult): boolean;
71
+ /**
72
+ * POST a JSON body with the API key. Never throws: every failure comes back as
73
+ * an {@link ApiResult}. Exported for the `prohost usage` reporter, which owns
74
+ * its own paths.
75
+ */
76
+ export declare function postJson(options: ApiOptions, path: string, body: unknown, idempotencyKey?: string, captureBody?: boolean, timeoutMs?: number): Promise<ApiResult>;
71
77
  /**
72
78
  * Post the agent's reply into the conversation that triggered the run.
73
79
  *
package/dist/agent/api.js CHANGED
@@ -52,7 +52,12 @@ export function describeFailure(result) {
52
52
  export function isIdempotencyInFlight(result) {
53
53
  return result.status === 409 && (result.error ?? '').includes('idempotency_key_in_flight');
54
54
  }
55
- async function post(options, path, body, idempotencyKey, captureBody = false, timeoutMs = DEFAULT_TIMEOUT_MS) {
55
+ /**
56
+ * POST a JSON body with the API key. Never throws: every failure comes back as
57
+ * an {@link ApiResult}. Exported for the `prohost usage` reporter, which owns
58
+ * its own paths.
59
+ */
60
+ export async function postJson(options, path, body, idempotencyKey, captureBody = false, timeoutMs = DEFAULT_TIMEOUT_MS) {
56
61
  const fetchImpl = options.fetchImpl ?? fetch;
57
62
  const controller = new AbortController();
58
63
  const deadlineMs = options.timeoutMs ?? timeoutMs;
@@ -126,7 +131,7 @@ export async function postReply(options, path, bodyKey, message, runId, extra) {
126
131
  // thing in this request that must survive.
127
132
  // Byte-identical on every attempt: the server hashes the body against the
128
133
  // key, so a replay must serialise exactly as the original did.
129
- return post(options, path, { ...extra, [bodyKey]: message }, `prohost-cli-run-${runId}`, false, SEND_TIMEOUT_MS);
134
+ return postJson(options, path, { ...extra, [bodyKey]: message }, `prohost-cli-run-${runId}`, false, SEND_TIMEOUT_MS);
130
135
  }
131
136
  function booleanField(body, key) {
132
137
  if (!body)
@@ -154,7 +159,7 @@ function booleanField(body, key) {
154
159
  */
155
160
  export async function heartbeatRun(options, path, progress) {
156
161
  const trimmed = progress?.trim();
157
- const result = await post(options, path, trimmed ? { progress: trimmed.slice(0, MAX_PROGRESS_CHARS) } : {}, undefined, true);
162
+ const result = await postJson(options, path, trimmed ? { progress: trimmed.slice(0, MAX_PROGRESS_CHARS) } : {}, undefined, true);
158
163
  if (result.ok)
159
164
  return { kind: 'alive', cancelRequested: booleanField(result.body, 'cancel_requested') };
160
165
  if (result.status === 404)
@@ -180,7 +185,7 @@ export async function completeRun(options, path, outcome, model) {
180
185
  : { status: RUN_STATUS_FAILED, error: outcome.error.slice(0, MAX_ERROR_CHARS) };
181
186
  if (model)
182
187
  body.model = model.slice(0, MAX_MODEL_CHARS);
183
- const result = await post(options, path, body, undefined, false, SEND_TIMEOUT_MS);
188
+ const result = await postJson(options, path, body, undefined, false, SEND_TIMEOUT_MS);
184
189
  if (result.status === 409)
185
190
  return { ...result, ok: true };
186
191
  return result;
@@ -195,5 +200,5 @@ export async function completeRun(options, path, outcome, model) {
195
200
  * over-fills one gets it clipped rather than rejected whole.
196
201
  */
197
202
  export async function postToolEvents(options, path, events) {
198
- return post(options, path, { events: events.slice(0, MAX_TOOL_EVENTS_PER_BATCH) });
203
+ return postJson(options, path, { events: events.slice(0, MAX_TOOL_EVENTS_PER_BATCH) });
199
204
  }
package/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * npx @prohost/cli agent pair --code <pairing-code>
9
9
  * npx @prohost/cli agent run --exec 'claude -p'
10
10
  * npx @prohost/cli agent install-daemon --exec 'claude -p'
11
+ * npx @prohost/cli usage connect --key <api-key>
11
12
  *
12
13
  * Parses arguments with a zero-dep, zero-magic flag parser so the published
13
14
  * binary stays small and starts fast.
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * npx @prohost/cli agent pair --code <pairing-code>
9
9
  * npx @prohost/cli agent run --exec 'claude -p'
10
10
  * npx @prohost/cli agent install-daemon --exec 'claude -p'
11
+ * npx @prohost/cli usage connect --key <api-key>
11
12
  *
12
13
  * Parses arguments with a zero-dep, zero-magic flag parser so the published
13
14
  * binary stays small and starts fast.
@@ -17,6 +18,7 @@ import process from 'node:process';
17
18
  import { fileURLToPath } from 'node:url';
18
19
  import { runAgent } from './agent/command.js';
19
20
  import { DEFAULT_WS_URL, runListen } from './listen.js';
21
+ import { runUsage } from './usage/command.js';
20
22
  import { CLI_VERSION } from './version.js';
21
23
  function printHelp() {
22
24
  const lines = [
@@ -33,6 +35,9 @@ function printHelp() {
33
35
  ' prohost agent account list | remove <label>',
34
36
  ' prohost agent list Every paired agent on this machine, and its accounts',
35
37
  ' prohost agent workspace init --developer --repo <checkout> [--workdir <dir>]',
38
+ ' prohost usage connect [--key <api-key>] [--base-url <url>] Report AI usage without pairing an agent',
39
+ ' prohost usage discover [--dry-run] | start [--once] [--machine <name>] | status',
40
+ ' prohost usage install-daemon [--machine <name>] | uninstall-daemon',
36
41
  '',
37
42
  'listen flags:',
38
43
  ' --forward-to Local URL to POST each webhook payload to (required)',
@@ -99,6 +104,15 @@ function printHelp() {
99
104
  ' Adds the ProhostAI PR-protocol skill and the `gh pr create` gate hook from --repo (a',
100
105
  ' backend-service checkout) to the agent workspace\'s .claude/. Opt-in; nothing else changes.',
101
106
  '',
107
+ 'Usage reporting (observer mode):',
108
+ ' `usage connect` stores an API key with the usage:write scope ($PROHOST_HOME/usage.json, owner-only;',
109
+ ' a machine with a paired agent can reuse its credential) and registers every Claude Code and Codex',
110
+ ' login it finds: ~/.claude*, ~/.codex*, $CLAUDE_CONFIG_DIR, $CODEX_HOME (`~/.claude-work` → "work").',
111
+ ' `usage start` then reports, every 5 minutes, each login\'s remaining capacity — asked of the',
112
+ ' unmodified CLI, at most every 15 minutes per login — and daily token totals per model and project',
113
+ ' folder, read from the local session logs (last 30 days, then incrementally). Never prompt text.',
114
+ ' `--once` runs one pass and exits. `usage status` shows what was last sent.',
115
+ '',
102
116
  'agent install-daemon flags: (macOS: launchd; Linux: prints a systemd-user unit)',
103
117
  ' --exec The same command you would pass to `agent run` (required).',
104
118
  ' --workdir, --concurrency, --repos, --idle-timeout, --timeout, --machine, --url,',
@@ -182,6 +196,9 @@ async function main(argv) {
182
196
  if (command === 'agent') {
183
197
  return runAgent(subcommand, flags, printHelp, args);
184
198
  }
199
+ if (command === 'usage') {
200
+ return runUsage(subcommand, flags, printHelp);
201
+ }
185
202
  if (command !== 'listen') {
186
203
  process.stderr.write(`Unknown command: ${command}\n\n`);
187
204
  printHelp();
@@ -0,0 +1,42 @@
1
+ /**
2
+ * When the usage reporter may try an endpoint again, and what it says about it.
3
+ *
4
+ * The reporter runs for weeks under launchd, so it must never crash and never
5
+ * fill a log. Three kinds of failure, treated differently:
6
+ *
7
+ * - **Gated** — 401, 403, 404. The routes are not deployed yet, the workspace
8
+ * doesn't have the feature, or the key lacks `usage:write`. None of that
9
+ * changes in minutes, so wait {@link GATED_BACKOFF_MS} and say so once —
10
+ * not on every retry.
11
+ * - **Transient** — no response, 408, 429, 5xx. Retry with exponential backoff
12
+ * from {@link BASE_BACKOFF_MS} up to {@link MAX_BACKOFF_MS}; logged once
13
+ * per streak.
14
+ * - **Rejected** — any other 4xx. A body this build got wrong; retrying soon
15
+ * won't fix it, so it waits like a gated one and logs the server's reason.
16
+ *
17
+ * A success after any of these says "recovered" once and resets everything.
18
+ */
19
+ import type { ApiResult } from '../agent/api.js';
20
+ export declare const BASE_BACKOFF_MS = 60000;
21
+ export declare const MAX_BACKOFF_MS: number;
22
+ export declare const GATED_BACKOFF_MS: number;
23
+ export type FailureKind = 'gated' | 'transient' | 'rejected';
24
+ export declare function classifyFailure(result: ApiResult): FailureKind;
25
+ export declare class Backoff {
26
+ private readonly name;
27
+ private readonly now;
28
+ private nextAt;
29
+ private failures;
30
+ private lastKind;
31
+ private lastStatus;
32
+ constructor(name: string, now?: () => number);
33
+ /** Whether a request may be made now. */
34
+ ready(): boolean;
35
+ /** When the next attempt is allowed (epoch ms; 0 = now). */
36
+ retryAt(): number;
37
+ /**
38
+ * Record an attempt's outcome. Returns a line to log, or `undefined` when
39
+ * this outcome was already reported.
40
+ */
41
+ record(result: ApiResult): string | undefined;
42
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * When the usage reporter may try an endpoint again, and what it says about it.
3
+ *
4
+ * The reporter runs for weeks under launchd, so it must never crash and never
5
+ * fill a log. Three kinds of failure, treated differently:
6
+ *
7
+ * - **Gated** — 401, 403, 404. The routes are not deployed yet, the workspace
8
+ * doesn't have the feature, or the key lacks `usage:write`. None of that
9
+ * changes in minutes, so wait {@link GATED_BACKOFF_MS} and say so once —
10
+ * not on every retry.
11
+ * - **Transient** — no response, 408, 429, 5xx. Retry with exponential backoff
12
+ * from {@link BASE_BACKOFF_MS} up to {@link MAX_BACKOFF_MS}; logged once
13
+ * per streak.
14
+ * - **Rejected** — any other 4xx. A body this build got wrong; retrying soon
15
+ * won't fix it, so it waits like a gated one and logs the server's reason.
16
+ *
17
+ * A success after any of these says "recovered" once and resets everything.
18
+ */
19
+ import { describeFailure } from '../agent/api.js';
20
+ export const BASE_BACKOFF_MS = 60_000;
21
+ export const MAX_BACKOFF_MS = 30 * 60_000;
22
+ export const GATED_BACKOFF_MS = 30 * 60_000;
23
+ export function classifyFailure(result) {
24
+ const { status } = result;
25
+ if (status === 401 || status === 403 || status === 404)
26
+ return 'gated';
27
+ if (status === 0 || status === 408 || status === 429 || status >= 500)
28
+ return 'transient';
29
+ return 'rejected';
30
+ }
31
+ export class Backoff {
32
+ name;
33
+ now;
34
+ nextAt = 0;
35
+ failures = 0;
36
+ lastKind;
37
+ lastStatus;
38
+ constructor(name, now = Date.now) {
39
+ this.name = name;
40
+ this.now = now;
41
+ }
42
+ /** Whether a request may be made now. */
43
+ ready() {
44
+ return this.now() >= this.nextAt;
45
+ }
46
+ /** When the next attempt is allowed (epoch ms; 0 = now). */
47
+ retryAt() {
48
+ return this.nextAt;
49
+ }
50
+ /**
51
+ * Record an attempt's outcome. Returns a line to log, or `undefined` when
52
+ * this outcome was already reported.
53
+ */
54
+ record(result) {
55
+ if (result.ok) {
56
+ const wasFailing = this.failures > 0;
57
+ this.failures = 0;
58
+ this.nextAt = 0;
59
+ this.lastKind = undefined;
60
+ this.lastStatus = undefined;
61
+ return wasFailing ? `${this.name}: recovered` : undefined;
62
+ }
63
+ const kind = classifyFailure(result);
64
+ this.failures += 1;
65
+ const delay = kind === 'transient'
66
+ ? Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** Math.min(this.failures - 1, 16))
67
+ : GATED_BACKOFF_MS;
68
+ this.nextAt = this.now() + delay;
69
+ const repeat = this.lastKind === kind && (kind === 'transient' || this.lastStatus === result.status);
70
+ this.lastKind = kind;
71
+ this.lastStatus = result.status;
72
+ if (repeat)
73
+ return undefined;
74
+ const minutes = Math.round(delay / 60_000);
75
+ if (kind === 'gated') {
76
+ return (`${this.name}: ${describeFailure(result)} — usage reporting is not available for this key yet ` +
77
+ `(not deployed, not enabled for the workspace, or the key lacks usage:write). ` +
78
+ `Retrying every ${minutes} min.`);
79
+ }
80
+ if (kind === 'rejected') {
81
+ return `${this.name}: ${describeFailure(result)} rejected${result.error ? ` (${result.error.slice(0, 200)})` : ''}. Retrying in ${minutes} min.`;
82
+ }
83
+ return `${this.name}: ${describeFailure(result)}. Retrying with backoff (next in ${minutes} min).`;
84
+ }
85
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `prohost usage …` — observer mode: report this machine's Claude Code and
3
+ * Codex capacity and token totals to ProhostAI without pairing an agent.
4
+ *
5
+ * Flag plumbing only; the work lives in `discovery.ts`, `quota.ts`,
6
+ * `scanner.ts` and `service.ts`.
7
+ */
8
+ import type { DiscoveryResult } from './discovery.js';
9
+ type Flags = Record<string, string | boolean>;
10
+ export interface UsageIO {
11
+ out: (line: string) => void;
12
+ err: (line: string) => void;
13
+ env?: NodeJS.ProcessEnv;
14
+ }
15
+ /** launchd label prefix for the usage reporter — distinct from the agent daemon's. */
16
+ export declare const USAGE_LAUNCHD_PREFIX = "ai.prohost.usage";
17
+ /** Label for the usage daemon serving one `$PROHOST_HOME`. Same digest as the agent's, other prefix. */
18
+ export declare function usageLaunchdLabel(home: string): string;
19
+ export declare function usageLogPath(env?: NodeJS.ProcessEnv): string;
20
+ /** `prohost usage start` argv for a supervisor. */
21
+ export declare function usageDaemonArguments(options: {
22
+ execPath: string;
23
+ scriptPath: string;
24
+ machine?: string;
25
+ }): string[];
26
+ /** Dispatch a `usage` subcommand. Returns the exit code. */
27
+ export declare function runUsage(subcommand: string | undefined, flags: Flags, printHelp: () => void, io?: UsageIO): Promise<number>;
28
+ export declare function renderDiscovery(result: DiscoveryResult, env: NodeJS.ProcessEnv, dryRun: boolean): string[];
29
+ /** `1234567` → `1.2M`. */
30
+ export declare function compactTokens(n: number): string;
31
+ export {};