@prohost/cli 0.8.3 → 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.
Files changed (41) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +119 -2
  3. package/dist/agent/account_runtime.d.ts +19 -3
  4. package/dist/agent/account_runtime.js +28 -6
  5. package/dist/agent/accounts.d.ts +17 -1
  6. package/dist/agent/accounts.js +43 -2
  7. package/dist/agent/agent_commands.js +6 -3
  8. package/dist/agent/api.d.ts +6 -0
  9. package/dist/agent/api.js +10 -5
  10. package/dist/agent/claude.js +9 -3
  11. package/dist/agent/command.d.ts +9 -0
  12. package/dist/agent/command.js +64 -2
  13. package/dist/agent/daemon.d.ts +4 -0
  14. package/dist/agent/daemon.js +4 -0
  15. package/dist/agent/prompt.d.ts +27 -0
  16. package/dist/agent/prompt.js +21 -1
  17. package/dist/agent/run.d.ts +28 -3
  18. package/dist/agent/run.js +90 -18
  19. package/dist/agent/scheduler.d.ts +44 -0
  20. package/dist/agent/scheduler.js +93 -0
  21. package/dist/agent/worktrees.d.ts +136 -0
  22. package/dist/agent/worktrees.js +317 -0
  23. package/dist/index.d.ts +1 -0
  24. package/dist/index.js +29 -2
  25. package/dist/usage/backoff.d.ts +42 -0
  26. package/dist/usage/backoff.js +85 -0
  27. package/dist/usage/command.d.ts +31 -0
  28. package/dist/usage/command.js +269 -0
  29. package/dist/usage/config.d.ts +38 -0
  30. package/dist/usage/config.js +75 -0
  31. package/dist/usage/discovery.d.ts +63 -0
  32. package/dist/usage/discovery.js +187 -0
  33. package/dist/usage/quota.d.ts +96 -0
  34. package/dist/usage/quota.js +145 -0
  35. package/dist/usage/scanner.d.ts +117 -0
  36. package/dist/usage/scanner.js +438 -0
  37. package/dist/usage/service.d.ts +122 -0
  38. package/dist/usage/service.js +299 -0
  39. package/dist/version.d.ts +2 -2
  40. package/dist/version.js +1 -1
  41. package/package.json +1 -1
@@ -0,0 +1,299 @@
1
+ /**
2
+ * The `prohost usage start` loop: every five minutes, report each login's
3
+ * remaining capacity and upload the token totals that changed.
4
+ *
5
+ * Built to run unattended for weeks. Nothing in a tick can throw out of it:
6
+ * a missing key, an unreachable server, a 404 from a server that doesn't have
7
+ * the routes yet, a probe that hangs — each is logged once, backed off
8
+ * (see `backoff.ts`), and the loop carries on.
9
+ */
10
+ import { readFileSync } from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { loadAccounts, machineId as machineIdFor } from '../agent/accounts.js';
14
+ import { postJson } from '../agent/api.js';
15
+ import { Backoff } from './backoff.js';
16
+ import { resolveUsageKey, usageStateDir, writePrivateFile } from './config.js';
17
+ import { accountConfigDir } from './discovery.js';
18
+ import { buildReportFrame, defaultProber, probeAccounts } from './quota.js';
19
+ import { batches, loadScanState, pendingKey, rowsForPending, saveScanState, scanUsage } from './scanner.js';
20
+ export const REPORTS_PATH = '/v1/usage/reports';
21
+ export const TOKEN_TOTALS_PATH = '/v1/usage/token-totals';
22
+ /** Tick cadence. */
23
+ export const USAGE_INTERVAL_MS = 5 * 60_000;
24
+ /** Partial uploads of one day's rows for one account before they are given up on. */
25
+ export const MAX_PARTIAL_STRIKES = 3;
26
+ /** Skips that will never turn into an acceptance from this machine. */
27
+ const PERMANENT_SKIPS = new Set(['not_owned', 'other_source', 'out_of_range']);
28
+ /** Read `{accepted, acceptedAccountKeys?, skipped?}` from a 2xx body. */
29
+ export function parseAcceptance(body, sent) {
30
+ let parsed = {};
31
+ try {
32
+ parsed = JSON.parse(body ?? '');
33
+ }
34
+ catch {
35
+ /* fall through */
36
+ }
37
+ const accepted = typeof parsed?.accepted === 'number' && Number.isFinite(parsed.accepted) ? parsed.accepted : sent;
38
+ const keys = Array.isArray(parsed?.acceptedAccountKeys)
39
+ ? new Set(parsed.acceptedAccountKeys.filter((k) => typeof k === 'string'))
40
+ : undefined;
41
+ let skipped;
42
+ if (Array.isArray(parsed?.skipped)) {
43
+ skipped = new Map();
44
+ for (const entry of parsed.skipped) {
45
+ const item = entry;
46
+ if (typeof item?.accountKey === 'string') {
47
+ skipped.set(item.accountKey, typeof item.reason === 'string' ? item.reason : 'unknown');
48
+ }
49
+ }
50
+ }
51
+ return { accepted, ...(keys ? { keys } : {}), ...(skipped ? { skipped } : {}) };
52
+ }
53
+ export function scanStatePath(env = process.env) {
54
+ return path.join(usageStateDir(env), 'scan.json');
55
+ }
56
+ export function statusPath(env = process.env) {
57
+ return path.join(usageStateDir(env), 'status.json');
58
+ }
59
+ export function loadStatus(env = process.env) {
60
+ try {
61
+ const parsed = JSON.parse(readFileSync(statusPath(env), 'utf8'));
62
+ return {
63
+ version: 1,
64
+ pid: parsed.pid,
65
+ lastTickAt: parsed.lastTickAt,
66
+ lastReport: parsed.lastReport,
67
+ lastTotals: parsed.lastTotals,
68
+ accounts: parsed.accounts && typeof parsed.accounts === 'object' ? parsed.accounts : {},
69
+ };
70
+ }
71
+ catch {
72
+ return { version: 1, accounts: {} };
73
+ }
74
+ }
75
+ /** One process's reporter. Holds the backoff state and the scan cursor in memory. */
76
+ export class UsageService {
77
+ options;
78
+ env;
79
+ log;
80
+ now;
81
+ prober;
82
+ reportBackoff;
83
+ totalsBackoff;
84
+ scan;
85
+ status;
86
+ warned = new Set();
87
+ /**
88
+ * Accounts whose report the server has accepted from this process. The
89
+ * server takes token totals only for accounts this machine has already
90
+ * reported (and skips the rest), so totals for any other account wait —
91
+ * which also makes the first tick, and the tick after a login is
92
+ * discovered, report before it uploads. Kept in memory only: a restart
93
+ * always reports first anyway.
94
+ */
95
+ reportedKeys = new Set();
96
+ constructor(options = {}) {
97
+ this.options = options;
98
+ this.env = options.env ?? process.env;
99
+ this.log = options.log ?? ((line) => process.stdout.write(`${new Date().toISOString()} ${line}\n`));
100
+ this.now = options.now ?? Date.now;
101
+ this.prober = options.prober ?? defaultProber();
102
+ this.reportBackoff = new Backoff('usage report', this.now);
103
+ this.totalsBackoff = new Backoff('token totals', this.now);
104
+ this.status = loadStatus(this.env);
105
+ }
106
+ warnOnce(key, line) {
107
+ if (this.warned.has(key))
108
+ return;
109
+ this.warned.add(key);
110
+ this.log(line);
111
+ }
112
+ /** One full pass. Never throws. */
113
+ async tick() {
114
+ const result = { reported: false, uploadedRows: 0 };
115
+ try {
116
+ const key = resolveUsageKey(this.env);
117
+ if (!key) {
118
+ this.warnOnce('no_key', 'Not connected — run `prohost usage connect --key <api key>`. Checking again each tick.');
119
+ return { ...result, skipped: 'no_key' };
120
+ }
121
+ this.warned.delete('no_key');
122
+ const machineId = machineIdFor(this.env);
123
+ if (!machineId) {
124
+ this.warnOnce('no_machine_id', 'Could not create this machine\'s id in the accounts root; nothing can be reported.');
125
+ return { ...result, skipped: 'no_machine_id' };
126
+ }
127
+ const api = { baseUrl: key.baseUrl, apiKey: key.apiKey, fetchImpl: this.options.fetchImpl };
128
+ const machine = this.options.machine ?? os.hostname();
129
+ const records = loadAccounts(this.env);
130
+ // Forget what was learned about logins that have since been removed.
131
+ const live = new Set(records.map((r) => r.key));
132
+ for (const key of Object.keys(this.status.accounts))
133
+ if (!live.has(key))
134
+ delete this.status.accounts[key];
135
+ // ── Capacity ──────────────────────────────────────────────────────
136
+ await probeAccounts(records, this.status.accounts, { prober: this.prober, now: this.now });
137
+ if (this.reportBackoff.ready() && records.length > 0) {
138
+ const { frame, omitted } = buildReportFrame({ machineId, machine, records, cache: this.status.accounts });
139
+ if (omitted > 0)
140
+ this.warnOnce('omitted', `${omitted} account(s) beyond the ${frame.accounts.length} a report carries were left out.`);
141
+ const response = await postJson(api, REPORTS_PATH, frame, undefined, true);
142
+ this.note(this.reportBackoff, response);
143
+ const sent = frame.accounts.map((a) => a.accountKey);
144
+ const acceptance = response.ok ? parseAcceptance(response.body, sent.length) : { accepted: 0 };
145
+ this.status.lastReport = { ...this.callRecord(response, sent.length), accepted: acceptance.accepted };
146
+ result.reported = response.ok && acceptance.accepted > 0;
147
+ if (acceptance.keys) {
148
+ // The server named the accounts it took (another teammate's login
149
+ // on this machine, say, is not among them): exactly those. A
150
+ // throttled one was taken before and still counts as it did.
151
+ for (const key of sent) {
152
+ const reason = acceptance.skipped?.get(key);
153
+ if (acceptance.keys.has(key))
154
+ this.reportedKeys.add(key);
155
+ else if (reason !== 'throttled')
156
+ this.reportedKeys.delete(key);
157
+ if (reason && PERMANENT_SKIPS.has(reason)) {
158
+ this.warnOnce(`report_skip:${key}:${reason}`, `usage report: account ${key.slice(0, 8)}… skipped (${reason}); its usage belongs to another user or source.`);
159
+ }
160
+ }
161
+ }
162
+ else if (acceptance.accepted > 0) {
163
+ // A count alone can't say which. Treat every account as reported
164
+ // and let the token-totals endpoint skip the ones it doesn't know —
165
+ // requiring all of them would starve every login on a machine that
166
+ // holds one the server will never take. 0 is the server's "too soon
167
+ // after the last frame" answer and reports nothing.
168
+ for (const key of sent)
169
+ this.reportedKeys.add(key);
170
+ }
171
+ }
172
+ // ── Token totals ─────────────────────────────────────────────────
173
+ this.scan ??= loadScanState(scanStatePath(this.env));
174
+ const scanned = scanUsage(records.map((r) => ({ key: r.key, runtime: r.runtime, dir: accountConfigDir(r, this.env) })), this.scan, { now: this.now(), timeZone: this.options.timeZone });
175
+ // The state can run to megabytes; rewrite it only when a file was read.
176
+ if (scanned.filesRead > 0)
177
+ saveScanState(scanStatePath(this.env), this.scan);
178
+ if (this.totalsBackoff.ready() && this.scan.dirty.length > 0) {
179
+ result.uploadedRows = await this.uploadDirty(api, machineId, machine);
180
+ }
181
+ }
182
+ catch (err) {
183
+ this.log(`usage tick failed: ${err instanceof Error ? err.message : String(err)}`);
184
+ }
185
+ finally {
186
+ this.status.lastTickAt = new Date(this.now()).toISOString();
187
+ this.status.pid = process.pid;
188
+ try {
189
+ writePrivateFile(statusPath(this.env), `${JSON.stringify(this.status, null, 2)}\n`);
190
+ }
191
+ catch {
192
+ /* status is informational */
193
+ }
194
+ }
195
+ return result;
196
+ }
197
+ /**
198
+ * Upload pending (day, account) rows in batches, for reported accounts only.
199
+ *
200
+ * A pair is done once the server takes it (its account is in
201
+ * `acceptedAccountKeys`, or — from an older server that only counts — its
202
+ * whole batch was accepted). A skip with a reason is handled by reason:
203
+ * `not_owned` / `other_source` / `out_of_range` will never be accepted, so
204
+ * the pair is done (logged once); `throttled` is retried next tick as is;
205
+ * `not_reported` waits for the next accepted report. Any other skip, and
206
+ * `not_reported`, costs a strike; after {@link MAX_PARTIAL_STRIKES} the pair
207
+ * is given up on (logged once), so nothing is resent forever. Pairs whose
208
+ * account has never been reported wait without a strike, and are not sent.
209
+ */
210
+ async uploadDirty(api, machineId, machine) {
211
+ const scan = this.scan;
212
+ const rows = rowsForPending(scan, scan.dirty);
213
+ const ready = rows.filter((r) => this.reportedKeys.has(r.accountKey));
214
+ const done = new Set();
215
+ const pendingWithRows = new Set(rows.map((r) => pendingKey(r.day, r.accountKey)));
216
+ // Pending pairs with nothing to send (all-zero) are simply done.
217
+ for (const pair of scan.dirty)
218
+ if (!pendingWithRows.has(pair))
219
+ done.add(pair);
220
+ let sent = 0;
221
+ let gaveUp = 0;
222
+ for (const batch of batches(ready)) {
223
+ const response = await postJson(api, TOKEN_TOTALS_PATH, { machineId, machine, days: batch }, undefined, true);
224
+ this.note(this.totalsBackoff, response);
225
+ const acceptance = response.ok ? parseAcceptance(response.body, batch.length) : { accepted: 0 };
226
+ this.status.lastTotals = { ...this.callRecord(response, batch.length), accepted: acceptance.accepted };
227
+ if (!response.ok)
228
+ break;
229
+ sent += acceptance.accepted;
230
+ const pairs = new Map(); // pair → accountKey
231
+ for (const row of batch)
232
+ pairs.set(pendingKey(row.day, row.accountKey), row.accountKey);
233
+ for (const [pair, accountKey] of pairs) {
234
+ const taken = acceptance.keys ? acceptance.keys.has(accountKey) : acceptance.accepted >= batch.length;
235
+ if (taken) {
236
+ done.add(pair);
237
+ delete scan.strikes[pair];
238
+ continue;
239
+ }
240
+ const reason = acceptance.skipped?.get(accountKey);
241
+ if (reason === 'throttled')
242
+ continue; // pending as is; next tick
243
+ if (reason && PERMANENT_SKIPS.has(reason)) {
244
+ done.add(pair);
245
+ delete scan.strikes[pair];
246
+ this.warnOnce(`totals_skip:${accountKey}:${reason}`, `token totals: account ${accountKey.slice(0, 8)}… skipped (${reason}); those rows will not be resent.`);
247
+ continue;
248
+ }
249
+ // `not_reported`, or skipped without a reason: report it again first.
250
+ this.reportedKeys.delete(accountKey);
251
+ const strikes = (scan.strikes[pair] ?? 0) + 1;
252
+ if (strikes >= MAX_PARTIAL_STRIKES) {
253
+ done.add(pair);
254
+ delete scan.strikes[pair];
255
+ gaveUp += 1;
256
+ }
257
+ else {
258
+ scan.strikes[pair] = strikes;
259
+ }
260
+ }
261
+ }
262
+ if (gaveUp > 0) {
263
+ this.warnOnce('gave_up', `token totals: the server skipped some rows ${MAX_PARTIAL_STRIKES} times in a row (a login it doesn't ` +
264
+ 'attribute to this key, most likely); they are no longer resent.');
265
+ }
266
+ scan.dirty = scan.dirty.filter((pair) => !done.has(pair));
267
+ saveScanState(scanStatePath(this.env), scan);
268
+ return sent;
269
+ }
270
+ note(backoff, response) {
271
+ const line = backoff.record(response);
272
+ if (line)
273
+ this.log(line);
274
+ }
275
+ callRecord(response, count) {
276
+ return { at: new Date(this.now()).toISOString(), ok: response.ok, status: response.status, count };
277
+ }
278
+ }
279
+ /** Sleep that ends early on abort. */
280
+ function sleep(ms, signal) {
281
+ return new Promise((resolve) => {
282
+ if (signal?.aborted)
283
+ return resolve();
284
+ const timer = setTimeout(done, ms);
285
+ function done() {
286
+ clearTimeout(timer);
287
+ signal?.removeEventListener('abort', done);
288
+ resolve();
289
+ }
290
+ signal?.addEventListener('abort', done, { once: true });
291
+ });
292
+ }
293
+ /** Tick until aborted. */
294
+ export async function runUsageLoop(service, options = {}) {
295
+ while (!options.signal?.aborted) {
296
+ await service.tick();
297
+ await sleep(options.intervalMs ?? USAGE_INTERVAL_MS, options.signal);
298
+ }
299
+ }
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.8.3";
10
+ export declare const CLI_VERSION = "0.10.0";
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
- export declare const USER_AGENT = "prohost-cli/0.8.3";
12
+ export declare const USER_AGENT = "prohost-cli/0.10.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.8.3';
10
+ export const CLI_VERSION = '0.10.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.8.3",
3
+ "version": "0.10.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",