@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,438 @@
1
+ /**
2
+ * Token totals from the session logs Claude Code and Codex already write.
3
+ *
4
+ * Aggregates only: tokens per (local day, account, runtime, model, project),
5
+ * where the project is the basename of the working directory the log names.
6
+ * No prompt, reply, tool call or file path ever leaves this module — lines are
7
+ * parsed for their usage counters and nothing else is kept.
8
+ *
9
+ * - **Claude Code** writes `<config dir>/projects/<project>/<session>.jsonl`.
10
+ * Each assistant line carries the API response's `message.usage`; one
11
+ * response is split over several lines (one per content block) that repeat
12
+ * the same usage, and a resumed session can copy earlier lines into a new
13
+ * file. So usage is keyed on `message.id` across every file: the first
14
+ * sighting counts, a later one only adds what grew.
15
+ * - **Codex** writes `$CODEX_HOME/sessions/YYYY/MM/DD/rollout-*.jsonl` (moved
16
+ * to `archived_sessions/` when archived). Its `token_count` events carry the
17
+ * session's running `total_token_usage`; usage is the growth of that total
18
+ * between events, credited to the model of the latest `turn_context`. Its
19
+ * `input_tokens` include the cached ones, so they are split apart here.
20
+ *
21
+ * Incremental: a cursor per file (byte offset, size, mtime) lives in the scan
22
+ * state, so each pass reads only what was appended. The first pass looks back
23
+ * {@link RETENTION_DAYS} days. Every (day, account) a pass changes is marked dirty and is
24
+ * re-sent whole — the server replaces a day's rows, so a resend is harmless.
25
+ */
26
+ import { closeSync, fstatSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
27
+ import path from 'node:path';
28
+ import { writePrivateFile } from './config.js';
29
+ /** How far back the first scan looks, and how long totals are kept locally. */
30
+ export const RETENTION_DAYS = 30;
31
+ /** Most rows in one `POST /v1/usage/token-totals`. */
32
+ export const MAX_ROWS_PER_BATCH = 2000;
33
+ const DAY_MS = 86_400_000;
34
+ const READ_CHUNK_BYTES = 4 * 1024 * 1024;
35
+ /** Deepest directory level walked under `projects/` or `sessions/`. */
36
+ const MAX_WALK_DEPTH = 6;
37
+ const MAX_PROJECT_CHARS = 120;
38
+ const MAX_MODEL_CHARS = 120;
39
+ /** A dirty entry: one account's rows for one day. */
40
+ export function pendingKey(day, accountKey) {
41
+ return `${day}|${accountKey}`;
42
+ }
43
+ /** The day of a dirty entry. */
44
+ export function pendingDay(entry) {
45
+ return entry.slice(0, 10);
46
+ }
47
+ /** Days with anything pending, sorted. */
48
+ export function pendingDays(state) {
49
+ return [...new Set(state.dirty.map(pendingDay))].sort();
50
+ }
51
+ export function emptyScanState() {
52
+ return { version: 1, files: {}, buckets: {}, seen: {}, dirty: [], strikes: {} };
53
+ }
54
+ export function loadScanState(file) {
55
+ try {
56
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
57
+ if (parsed?.version !== 1)
58
+ return emptyScanState();
59
+ return {
60
+ version: 1,
61
+ files: parsed.files && typeof parsed.files === 'object' ? parsed.files : {},
62
+ buckets: parsed.buckets && typeof parsed.buckets === 'object' ? parsed.buckets : {},
63
+ seen: parsed.seen && typeof parsed.seen === 'object' ? parsed.seen : {},
64
+ dirty: Array.isArray(parsed.dirty) ? parsed.dirty.filter((d) => typeof d === 'string' && d.includes('|')) : [],
65
+ strikes: parsed.strikes && typeof parsed.strikes === 'object' ? parsed.strikes : {},
66
+ };
67
+ }
68
+ catch {
69
+ return emptyScanState();
70
+ }
71
+ }
72
+ export function saveScanState(file, state) {
73
+ writePrivateFile(file, JSON.stringify(state));
74
+ }
75
+ const formatters = new Map();
76
+ /** `YYYY-MM-DD` of an instant in a time zone (the machine's by default). */
77
+ export function dayKey(ms, timeZone) {
78
+ const zone = timeZone ?? '';
79
+ let formatter = formatters.get(zone);
80
+ if (!formatter) {
81
+ formatter = new Intl.DateTimeFormat('en-CA', {
82
+ ...(timeZone ? { timeZone } : {}),
83
+ year: 'numeric',
84
+ month: '2-digit',
85
+ day: '2-digit',
86
+ });
87
+ formatters.set(zone, formatter);
88
+ }
89
+ const parts = formatter.formatToParts(new Date(ms));
90
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? '';
91
+ return `${get('year')}-${get('month')}-${get('day')}`;
92
+ }
93
+ /** Bucket identity. JSON so it round-trips; the day is first so it can be read cheaply. */
94
+ export function bucketKey(day, accountKey, runtime, model, project) {
95
+ return JSON.stringify([day, accountKey, runtime, model, project]);
96
+ }
97
+ function dayOfBucket(key) {
98
+ return key.slice(2, 12);
99
+ }
100
+ /** Basename of a working directory — the repo or folder name, never the path. */
101
+ export function projectName(cwd) {
102
+ if (typeof cwd !== 'string' || !cwd.trim())
103
+ return null;
104
+ const base = path.basename(cwd.trim().replace(/[\\/]+$/, ''));
105
+ return base ? base.slice(0, MAX_PROJECT_CHARS) : null;
106
+ }
107
+ function asObject(value) {
108
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
109
+ }
110
+ function count(value) {
111
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
112
+ }
113
+ function text(value) {
114
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
115
+ }
116
+ function parse(line) {
117
+ try {
118
+ return asObject(JSON.parse(line));
119
+ }
120
+ catch {
121
+ return undefined;
122
+ }
123
+ }
124
+ function isZero(t) {
125
+ return t[0] === 0 && t[1] === 0 && t[2] === 0 && t[3] === 0;
126
+ }
127
+ /** Files under ``root`` matching ``accept``, at most {@link MAX_WALK_DEPTH} levels down. */
128
+ function walk(root, accept) {
129
+ const out = [];
130
+ const visit = (dir, depth) => {
131
+ let entries;
132
+ try {
133
+ entries = readdirSync(dir, { withFileTypes: true });
134
+ }
135
+ catch {
136
+ return;
137
+ }
138
+ for (const entry of entries) {
139
+ const full = path.join(dir, entry.name);
140
+ if (entry.isDirectory()) {
141
+ if (depth < MAX_WALK_DEPTH)
142
+ visit(full, depth + 1);
143
+ }
144
+ else if (entry.isFile() && accept(entry.name)) {
145
+ out.push(full);
146
+ }
147
+ }
148
+ };
149
+ visit(root, 0);
150
+ return out.sort();
151
+ }
152
+ /**
153
+ * Feed every complete line appended since ``offset`` to ``onLine``. Returns the
154
+ * offset just past the last complete line, so a line still being written is
155
+ * read whole next time.
156
+ */
157
+ function readAppended(file, offset, onLine) {
158
+ const fd = openSync(file, 'r');
159
+ try {
160
+ const size = fstatSync(fd).size;
161
+ const buffer = Buffer.alloc(READ_CHUNK_BYTES);
162
+ let position = offset;
163
+ let carry = Buffer.alloc(0);
164
+ while (position < size) {
165
+ const read = readSync(fd, buffer, 0, Math.min(READ_CHUNK_BYTES, size - position), position);
166
+ if (read <= 0)
167
+ break;
168
+ position += read;
169
+ const data = carry.length > 0 ? Buffer.concat([carry, buffer.subarray(0, read)]) : buffer.subarray(0, read);
170
+ let start = 0;
171
+ for (let newline = data.indexOf(10, start); newline !== -1; newline = data.indexOf(10, start)) {
172
+ onLine(data.toString('utf8', start, newline));
173
+ start = newline + 1;
174
+ }
175
+ carry = Buffer.from(data.subarray(start));
176
+ }
177
+ return position - carry.length;
178
+ }
179
+ finally {
180
+ closeSync(fd);
181
+ }
182
+ }
183
+ class Pass {
184
+ state;
185
+ cutoffDay;
186
+ timeZone;
187
+ touched = new Set();
188
+ filesRead = 0;
189
+ constructor(state, cutoffDay, timeZone) {
190
+ this.state = state;
191
+ this.cutoffDay = cutoffDay;
192
+ this.timeZone = timeZone;
193
+ }
194
+ credit(key, t) {
195
+ const bucket = this.state.buckets[key] ?? [0, 0, 0, 0];
196
+ for (let i = 0; i < 4; i += 1)
197
+ bucket[i] = bucket[i] + t[i];
198
+ this.state.buckets[key] = bucket;
199
+ this.touched.add(key);
200
+ }
201
+ claudeLine(account, line) {
202
+ // Cheap pre-filter: most lines are prompts, tool results and file snapshots.
203
+ if (!line.includes('"usage"') || !line.includes('"assistant"'))
204
+ return;
205
+ const entry = parse(line);
206
+ if (entry?.type !== 'assistant')
207
+ return;
208
+ const message = asObject(entry.message);
209
+ const usage = asObject(message?.usage);
210
+ if (!message || !usage)
211
+ return;
212
+ const id = text(message.id) ?? text(entry.requestId);
213
+ const model = text(message.model)?.slice(0, MAX_MODEL_CHARS);
214
+ const at = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : Number.NaN;
215
+ // `<synthetic>` marks a locally made message (an interruption, an API
216
+ // error) that no model produced.
217
+ if (!id || !model || model === '<synthetic>' || Number.isNaN(at))
218
+ return;
219
+ const observed = [
220
+ count(usage.input_tokens),
221
+ count(usage.cache_read_input_tokens),
222
+ count(usage.cache_creation_input_tokens),
223
+ count(usage.output_tokens),
224
+ ];
225
+ if (isZero(observed))
226
+ return;
227
+ const previous = this.state.seen[id];
228
+ if (!previous) {
229
+ const day = dayKey(at, this.timeZone);
230
+ if (day < this.cutoffDay)
231
+ return;
232
+ const key = bucketKey(day, account.key, 'claude_code', model, projectName(entry.cwd));
233
+ this.credit(key, observed);
234
+ this.state.seen[id] = [key, ...observed];
235
+ return;
236
+ }
237
+ // Seen before: a later line of the same response. Credit only growth.
238
+ const [key, ...credited] = previous;
239
+ const grown = [0, 0, 0, 0];
240
+ for (let i = 0; i < 4; i += 1)
241
+ grown[i] = Math.max(0, observed[i] - credited[i]);
242
+ if (isZero(grown))
243
+ return;
244
+ this.credit(key, grown);
245
+ this.state.seen[id] = [key, ...credited.map((c, i) => c + grown[i])];
246
+ }
247
+ codexLine(account, cursor, line) {
248
+ if (line.includes('"session_meta"') || line.includes('"turn_context"')) {
249
+ const entry = parse(line);
250
+ const payload = asObject(entry?.payload);
251
+ if (!payload)
252
+ return;
253
+ if (entry?.type === 'turn_context') {
254
+ cursor.model = text(payload.model)?.slice(0, MAX_MODEL_CHARS) ?? cursor.model;
255
+ if (payload.cwd !== undefined)
256
+ cursor.project = projectName(payload.cwd);
257
+ }
258
+ else if (entry?.type === 'session_meta' && cursor.project === undefined) {
259
+ cursor.project = projectName(payload.cwd);
260
+ }
261
+ return;
262
+ }
263
+ if (!line.includes('"token_count"'))
264
+ return;
265
+ const entry = parse(line);
266
+ const payload = asObject(entry?.payload);
267
+ if (payload?.type !== 'token_count')
268
+ return;
269
+ const total = asObject(asObject(payload.info)?.total_token_usage);
270
+ if (!total)
271
+ return;
272
+ const now = [
273
+ count(total.input_tokens),
274
+ count(total.cached_input_tokens),
275
+ count(total.cache_write_input_tokens),
276
+ count(total.output_tokens),
277
+ ];
278
+ // Growth since the last event. Never negative, and the high-water mark
279
+ // only rises — so a rewritten or re-read file can't count anything twice.
280
+ const grown = [0, 0, 0, 0];
281
+ for (let i = 0; i < 4; i += 1) {
282
+ grown[i] = Math.max(0, now[i] - cursor.totals[i]);
283
+ cursor.totals[i] = Math.max(cursor.totals[i], now[i]);
284
+ }
285
+ if (isZero(grown))
286
+ return;
287
+ const at = typeof entry?.timestamp === 'string' ? Date.parse(entry.timestamp) : Number.NaN;
288
+ if (Number.isNaN(at))
289
+ return;
290
+ const day = dayKey(at, this.timeZone);
291
+ if (day < this.cutoffDay)
292
+ return;
293
+ const [input, cached, cacheWrite, output] = grown;
294
+ const key = bucketKey(day, account.key, 'codex', cursor.model ?? 'unknown', cursor.project ?? null);
295
+ this.credit(key, [Math.max(0, input - cached - cacheWrite), cached, cacheWrite, output]);
296
+ }
297
+ }
298
+ function claudeFiles(dir) {
299
+ return walk(path.join(dir, 'projects'), (name) => name.endsWith('.jsonl'));
300
+ }
301
+ function codexFiles(dir) {
302
+ const accept = (name) => name.startsWith('rollout-') && name.endsWith('.jsonl');
303
+ return [...walk(path.join(dir, 'sessions'), accept), ...walk(path.join(dir, 'archived_sessions'), accept)];
304
+ }
305
+ /**
306
+ * One incremental pass over every account's logs. Mutates and returns
307
+ * ``state``; the caller persists it.
308
+ */
309
+ export function scanUsage(accounts, state, options = {}) {
310
+ const nowMs = options.now ?? Date.now();
311
+ const cutoffMs = nowMs - RETENTION_DAYS * DAY_MS;
312
+ const pass = new Pass(state, dayKey(cutoffMs, options.timeZone), options.timeZone);
313
+ for (const account of accounts) {
314
+ const isClaude = account.runtime === 'claude_code';
315
+ for (const file of isClaude ? claudeFiles(account.dir) : codexFiles(account.dir)) {
316
+ // A Codex rollout keeps its name when it is archived, so it is keyed by
317
+ // name and follows the move; a Claude log is keyed by path.
318
+ const id = isClaude ? `claude:${account.key}:${file}` : `codex:${account.key}:${path.basename(file)}`;
319
+ let stat;
320
+ try {
321
+ stat = statSync(file);
322
+ }
323
+ catch {
324
+ continue;
325
+ }
326
+ const existing = state.files[id];
327
+ if (!existing && stat.mtimeMs < cutoffMs)
328
+ continue;
329
+ if (existing && existing.size === stat.size && existing.mtimeMs === stat.mtimeMs)
330
+ continue;
331
+ const cursor = existing ?? (isClaude
332
+ ? { kind: 'claude', offset: 0, size: 0, mtimeMs: 0 }
333
+ : { kind: 'codex', offset: 0, size: 0, mtimeMs: 0, totals: [0, 0, 0, 0] });
334
+ // Shrunk: rewritten from scratch. Read it again; dedupe keeps it honest.
335
+ if (stat.size < cursor.offset)
336
+ cursor.offset = 0;
337
+ try {
338
+ cursor.offset = readAppended(file, cursor.offset, (line) => {
339
+ if (cursor.kind === 'claude')
340
+ pass.claudeLine(account, line);
341
+ else
342
+ pass.codexLine(account, cursor, line);
343
+ });
344
+ }
345
+ catch {
346
+ continue; // unreadable now; the cursor is unchanged and it is retried next pass
347
+ }
348
+ cursor.size = stat.size;
349
+ cursor.mtimeMs = stat.mtimeMs;
350
+ state.files[id] = cursor;
351
+ pass.filesRead += 1;
352
+ }
353
+ }
354
+ prune(state, pass.cutoffDay, cutoffMs);
355
+ const touchedPairs = [...pass.touched]
356
+ .filter((key) => dayOfBucket(key) >= pass.cutoffDay)
357
+ .map((key) => {
358
+ const [day, accountKey] = JSON.parse(key);
359
+ return pendingKey(day, accountKey);
360
+ });
361
+ // New usage for a pair starts its strike count over.
362
+ for (const pair of touchedPairs)
363
+ delete state.strikes[pair];
364
+ state.dirty = [...new Set([...state.dirty, ...touchedPairs])].filter((d) => pendingDay(d) >= pass.cutoffDay).sort();
365
+ for (const pair of Object.keys(state.strikes))
366
+ if (!state.dirty.includes(pair))
367
+ delete state.strikes[pair];
368
+ return { filesRead: pass.filesRead, touchedDays: [...new Set(touchedPairs.map(pendingDay))].sort() };
369
+ }
370
+ /**
371
+ * Drop what has aged out of the retention window. A cursor goes only once its
372
+ * file has been untouched that long — never merely because a pass could not
373
+ * list it: a Codex cursor holds the session's running total, and losing it
374
+ * would count the whole session again.
375
+ */
376
+ function prune(state, cutoffDay, cutoffMs) {
377
+ for (const key of Object.keys(state.buckets)) {
378
+ if (dayOfBucket(key) < cutoffDay)
379
+ delete state.buckets[key];
380
+ }
381
+ for (const [id, entry] of Object.entries(state.seen)) {
382
+ if (dayOfBucket(entry[0]) < cutoffDay)
383
+ delete state.seen[id];
384
+ }
385
+ for (const [id, cursor] of Object.entries(state.files)) {
386
+ if (cursor.mtimeMs < cutoffMs)
387
+ delete state.files[id];
388
+ }
389
+ }
390
+ /** The rows for the given days, in upload shape. Zero-token buckets are left out. */
391
+ export function rowsForDays(state, days) {
392
+ const wanted = new Set(days);
393
+ const rows = [];
394
+ for (const [key, totals] of Object.entries(state.buckets)) {
395
+ if (!wanted.has(dayOfBucket(key)) || isZero(totals))
396
+ continue;
397
+ const [day, accountKey, runtime, model, project] = JSON.parse(key);
398
+ rows.push({
399
+ day,
400
+ accountKey,
401
+ runtime,
402
+ model,
403
+ project,
404
+ inputTokens: totals[0],
405
+ cachedInputTokens: totals[1],
406
+ cacheWriteTokens: totals[2],
407
+ outputTokens: totals[3],
408
+ });
409
+ }
410
+ return rows.sort((a, b) => (a.day === b.day ? a.accountKey.localeCompare(b.accountKey) : a.day.localeCompare(b.day)));
411
+ }
412
+ /** The rows for the given `day|accountKey` pairs, in upload shape. */
413
+ export function rowsForPending(state, pairs) {
414
+ const wanted = new Set(pairs);
415
+ const days = new Set([...wanted].map(pendingDay));
416
+ return rowsForDays(state, days).filter((r) => wanted.has(pendingKey(r.day, r.accountKey)));
417
+ }
418
+ /** Split rows into upload batches. */
419
+ export function batches(rows, size = MAX_ROWS_PER_BATCH) {
420
+ const out = [];
421
+ for (let i = 0; i < rows.length; i += size)
422
+ out.push(rows.slice(i, i + size));
423
+ return out;
424
+ }
425
+ /** Local totals per account since ``fromDay`` (inclusive), for `usage status`. */
426
+ export function localTotals(state, fromDay) {
427
+ const out = new Map();
428
+ for (const [key, totals] of Object.entries(state.buckets)) {
429
+ if (dayOfBucket(key) < fromDay)
430
+ continue;
431
+ const accountKey = JSON.parse(key)[1];
432
+ const sum = out.get(accountKey) ?? [0, 0, 0, 0];
433
+ for (let i = 0; i < 4; i += 1)
434
+ sum[i] = sum[i] + totals[i];
435
+ out.set(accountKey, sum);
436
+ }
437
+ return out;
438
+ }
@@ -0,0 +1,122 @@
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 { Backoff } from './backoff.js';
11
+ import type { ObservationCache, Prober } from './quota.js';
12
+ export declare const REPORTS_PATH = "/v1/usage/reports";
13
+ export declare const TOKEN_TOTALS_PATH = "/v1/usage/token-totals";
14
+ /** Tick cadence. */
15
+ export declare const USAGE_INTERVAL_MS: number;
16
+ /** Partial uploads of one day's rows for one account before they are given up on. */
17
+ export declare const MAX_PARTIAL_STRIKES = 3;
18
+ /**
19
+ * Why the server skipped an account (`skipped[].reason`):
20
+ * - `not_owned` / `other_source` — the account belongs to another user, or is
21
+ * reported by another kind of source. It will never be accepted from here.
22
+ * - `out_of_range` (totals) — the day is outside what the server keeps. Never accepted either.
23
+ * - `not_reported` (totals) — the server hasn't taken a report for it yet.
24
+ * - `throttled` — too soon after the last frame; nothing is wrong.
25
+ * Anything else is treated like an unexplained skip.
26
+ */
27
+ export type SkipReason = 'not_owned' | 'other_source' | 'out_of_range' | 'not_reported' | 'throttled' | string;
28
+ export interface Acceptance {
29
+ /** How many the server took. A 2xx without the field is read as all of them. */
30
+ accepted: number;
31
+ /** Which accounts it took, when the server says (newer servers). */
32
+ keys?: Set<string>;
33
+ /** Accounts it skipped, and why (newer servers). */
34
+ skipped?: Map<string, SkipReason>;
35
+ }
36
+ /** Read `{accepted, acceptedAccountKeys?, skipped?}` from a 2xx body. */
37
+ export declare function parseAcceptance(body: string | undefined, sent: number): Acceptance;
38
+ export interface CallRecord {
39
+ at: string;
40
+ ok: boolean;
41
+ /** HTTP status, 0 for no response. */
42
+ status: number;
43
+ /** Accounts in the report, or rows in the upload. */
44
+ count?: number;
45
+ /** What the server said it accepted of those. */
46
+ accepted?: number;
47
+ }
48
+ /** What `usage status` reads. Written after every tick. */
49
+ export interface UsageStatus {
50
+ version: 1;
51
+ pid?: number;
52
+ lastTickAt?: string;
53
+ lastReport?: CallRecord;
54
+ lastTotals?: CallRecord;
55
+ accounts: ObservationCache;
56
+ }
57
+ export declare function scanStatePath(env?: NodeJS.ProcessEnv): string;
58
+ export declare function statusPath(env?: NodeJS.ProcessEnv): string;
59
+ export declare function loadStatus(env?: NodeJS.ProcessEnv): UsageStatus;
60
+ export interface UsageServiceOptions {
61
+ env?: NodeJS.ProcessEnv;
62
+ log?: (line: string) => void;
63
+ fetchImpl?: typeof fetch;
64
+ prober?: Prober;
65
+ now?: () => number;
66
+ /** Machine name shown in ProhostAI. Defaults to the hostname. */
67
+ machine?: string;
68
+ /** IANA zone for day buckets. Defaults to this machine's. */
69
+ timeZone?: string;
70
+ }
71
+ export interface TickResult {
72
+ reported: boolean;
73
+ uploadedRows: number;
74
+ skipped?: 'no_key' | 'no_machine_id';
75
+ }
76
+ /** One process's reporter. Holds the backoff state and the scan cursor in memory. */
77
+ export declare class UsageService {
78
+ private readonly options;
79
+ private readonly env;
80
+ private readonly log;
81
+ private readonly now;
82
+ private readonly prober;
83
+ readonly reportBackoff: Backoff;
84
+ readonly totalsBackoff: Backoff;
85
+ private scan;
86
+ private status;
87
+ private warned;
88
+ /**
89
+ * Accounts whose report the server has accepted from this process. The
90
+ * server takes token totals only for accounts this machine has already
91
+ * reported (and skips the rest), so totals for any other account wait —
92
+ * which also makes the first tick, and the tick after a login is
93
+ * discovered, report before it uploads. Kept in memory only: a restart
94
+ * always reports first anyway.
95
+ */
96
+ private readonly reportedKeys;
97
+ constructor(options?: UsageServiceOptions);
98
+ private warnOnce;
99
+ /** One full pass. Never throws. */
100
+ tick(): Promise<TickResult>;
101
+ /**
102
+ * Upload pending (day, account) rows in batches, for reported accounts only.
103
+ *
104
+ * A pair is done once the server takes it (its account is in
105
+ * `acceptedAccountKeys`, or — from an older server that only counts — its
106
+ * whole batch was accepted). A skip with a reason is handled by reason:
107
+ * `not_owned` / `other_source` / `out_of_range` will never be accepted, so
108
+ * the pair is done (logged once); `throttled` is retried next tick as is;
109
+ * `not_reported` waits for the next accepted report. Any other skip, and
110
+ * `not_reported`, costs a strike; after {@link MAX_PARTIAL_STRIKES} the pair
111
+ * is given up on (logged once), so nothing is resent forever. Pairs whose
112
+ * account has never been reported wait without a strike, and are not sent.
113
+ */
114
+ private uploadDirty;
115
+ private note;
116
+ private callRecord;
117
+ }
118
+ /** Tick until aborted. */
119
+ export declare function runUsageLoop(service: UsageService, options?: {
120
+ signal?: AbortSignal;
121
+ intervalMs?: number;
122
+ }): Promise<void>;