@prohost/cli 0.10.0 → 0.10.2

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.
@@ -2,7 +2,9 @@
2
2
  * Token totals from the session logs Claude Code and Codex already write.
3
3
  *
4
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.
5
+ * where the project is the name of the repository the log's working directory
6
+ * belongs to — a worktree or a subdirectory counts as its repository (see
7
+ * `project.ts`).
6
8
  * No prompt, reply, tool call or file path ever leaves this module — lines are
7
9
  * parsed for their usage counters and nothing else is kept.
8
10
  *
@@ -21,11 +23,22 @@
21
23
  * Incremental: a cursor per file (byte offset, size, mtime) lives in the scan
22
24
  * state, so each pass reads only what was appended. The first pass looks back
23
25
  * {@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.
26
+ * re-sent whole, and the upload names it as complete: the server replaces that
27
+ * day's rows for the account and deletes any it no longer carries, so a resend
28
+ * is harmless and a renamed project leaves no stale row behind.
29
+ *
30
+ * State version 2 changed project attribution (repository, not directory). A
31
+ * version-1 state is discarded and every log in the window read again; its
32
+ * per-(day, account) totals are kept as {@link ScanState.legacy}. Those pairs'
33
+ * server rows still carry per-directory projects, so the uploader sends them
34
+ * only to a server that replaces a day whole, and only when the rebuild
35
+ * found at least what version 1 had counted (see `service.ts`).
25
36
  */
26
37
  import { closeSync, fstatSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
27
38
  import path from 'node:path';
28
39
  import { writePrivateFile } from './config.js';
40
+ import { projectName } from './project.js';
41
+ export { projectName } from './project.js';
29
42
  /** How far back the first scan looks, and how long totals are kept locally. */
30
43
  export const RETENTION_DAYS = 30;
31
44
  /** Most rows in one `POST /v1/usage/token-totals`. */
@@ -34,7 +47,6 @@ const DAY_MS = 86_400_000;
34
47
  const READ_CHUNK_BYTES = 4 * 1024 * 1024;
35
48
  /** Deepest directory level walked under `projects/` or `sessions/`. */
36
49
  const MAX_WALK_DEPTH = 6;
37
- const MAX_PROJECT_CHARS = 120;
38
50
  const MAX_MODEL_CHARS = 120;
39
51
  /** A dirty entry: one account's rows for one day. */
40
52
  export function pendingKey(day, accountKey) {
@@ -44,30 +56,81 @@ export function pendingKey(day, accountKey) {
44
56
  export function pendingDay(entry) {
45
57
  return entry.slice(0, 10);
46
58
  }
59
+ /** The account key of a dirty entry. */
60
+ export function pendingAccount(entry) {
61
+ return entry.slice(11);
62
+ }
47
63
  /** Days with anything pending, sorted. */
48
64
  export function pendingDays(state) {
49
65
  return [...new Set(state.dirty.map(pendingDay))].sort();
50
66
  }
51
67
  export function emptyScanState() {
52
- return { version: 1, files: {}, buckets: {}, seen: {}, dirty: [], strikes: {} };
68
+ return { version: 2, files: {}, buckets: {}, seen: {}, dirty: [], strikes: {}, legacy: {} };
53
69
  }
70
+ const pairList = (value) => Array.isArray(value) ? value.filter((d) => typeof d === 'string' && d.includes('|')) : [];
71
+ const isTotals = (value) => Array.isArray(value) && value.length === 4 && value.every((n) => typeof n === 'number' && Number.isFinite(n));
72
+ /** Version-1 buckets summed per (day, account) pair. */
73
+ function legacyTotals(buckets) {
74
+ const out = {};
75
+ if (!buckets || typeof buckets !== 'object')
76
+ return out;
77
+ for (const [key, totals] of Object.entries(buckets)) {
78
+ if (!isTotals(totals))
79
+ continue;
80
+ let day;
81
+ let accountKey;
82
+ try {
83
+ [day, accountKey] = JSON.parse(key);
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ if (typeof day !== 'string' || typeof accountKey !== 'string')
89
+ continue;
90
+ const pair = pendingKey(day, accountKey);
91
+ const sum = out[pair] ?? [0, 0, 0, 0];
92
+ for (let i = 0; i < 4; i += 1)
93
+ sum[i] = sum[i] + totals[i];
94
+ out[pair] = sum;
95
+ }
96
+ return out;
97
+ }
98
+ /**
99
+ * Load the scan state. A version-1 state (per-directory projects) is replaced
100
+ * by an empty one that re-reads every log, keeping its per-pair totals as
101
+ * {@link ScanState.legacy}.
102
+ */
54
103
  export function loadScanState(file) {
104
+ let parsed;
55
105
  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
- };
106
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
67
107
  }
68
108
  catch {
69
109
  return emptyScanState();
70
110
  }
111
+ if (parsed?.version === 1)
112
+ return { ...emptyScanState(), legacy: legacyTotals(parsed.buckets) };
113
+ if (parsed?.version !== 2)
114
+ return emptyScanState();
115
+ return {
116
+ version: 2,
117
+ files: parsed.files && typeof parsed.files === 'object' ? parsed.files : {},
118
+ buckets: parsed.buckets && typeof parsed.buckets === 'object' ? parsed.buckets : {},
119
+ seen: parsed.seen && typeof parsed.seen === 'object' ? parsed.seen : {},
120
+ dirty: pairList(parsed.dirty),
121
+ strikes: parsed.strikes && typeof parsed.strikes === 'object' ? parsed.strikes : {},
122
+ legacy: parsed.legacy && typeof parsed.legacy === 'object'
123
+ ? Object.fromEntries(Object.entries(parsed.legacy).filter(([pair, t]) => pair.includes('|') && isTotals(t)))
124
+ : {},
125
+ };
126
+ }
127
+ /**
128
+ * Whether a re-attributed pair's rebuild counted less than version 1 had — a
129
+ * log rewritten or removed since, or a file that fell out of the window. Its
130
+ * server rows are then left as they are: mis-attributed, but the right total.
131
+ */
132
+ export function rebuildFellShort(rebuilt, legacy) {
133
+ return rebuilt.some((n, i) => n < legacy[i]);
71
134
  }
72
135
  export function saveScanState(file, state) {
73
136
  writePrivateFile(file, JSON.stringify(state));
@@ -97,13 +160,6 @@ export function bucketKey(day, accountKey, runtime, model, project) {
97
160
  function dayOfBucket(key) {
98
161
  return key.slice(2, 12);
99
162
  }
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
163
  function asObject(value) {
108
164
  return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
109
165
  }
@@ -362,6 +418,9 @@ export function scanUsage(accounts, state, options = {}) {
362
418
  for (const pair of touchedPairs)
363
419
  delete state.strikes[pair];
364
420
  state.dirty = [...new Set([...state.dirty, ...touchedPairs])].filter((d) => pendingDay(d) >= pass.cutoffDay).sort();
421
+ for (const pair of Object.keys(state.legacy))
422
+ if (pendingDay(pair) < pass.cutoffDay)
423
+ delete state.legacy[pair];
365
424
  for (const pair of Object.keys(state.strikes))
366
425
  if (!state.dirty.includes(pair))
367
426
  delete state.strikes[pair];
@@ -415,11 +474,41 @@ export function rowsForPending(state, pairs) {
415
474
  const days = new Set([...wanted].map(pendingDay));
416
475
  return rowsForDays(state, days).filter((r) => wanted.has(pendingKey(r.day, r.accountKey)));
417
476
  }
418
- /** Split rows into upload batches. */
419
- export function batches(rows, size = MAX_ROWS_PER_BATCH) {
477
+ /**
478
+ * Split rows into upload batches without splitting a (day, account) pair,
479
+ * so each body carries every row of the pairs it names complete. A pair
480
+ * larger than a batch is sent over consecutive batches: complete in the
481
+ * first (the server clears its rows, then stores these), added to by the rest.
482
+ */
483
+ export function uploadBatches(rows, size = MAX_ROWS_PER_BATCH) {
484
+ const groups = new Map();
485
+ for (const row of rows) {
486
+ const pair = pendingKey(row.day, row.accountKey);
487
+ const group = groups.get(pair);
488
+ if (group)
489
+ group.push(row);
490
+ else
491
+ groups.set(pair, [row]);
492
+ }
420
493
  const out = [];
421
- for (let i = 0; i < rows.length; i += size)
422
- out.push(rows.slice(i, i + size));
494
+ let current;
495
+ for (const [pair, group] of groups) {
496
+ if (current && current.rows.length + group.length <= size) {
497
+ current.rows.push(...group);
498
+ current.complete.push(pair);
499
+ current.last.push(pair);
500
+ continue;
501
+ }
502
+ for (let start = 0; start < group.length; start += size) {
503
+ const chunk = group.slice(start, start + size);
504
+ current = {
505
+ rows: chunk,
506
+ complete: start === 0 ? [pair] : [],
507
+ last: start + size >= group.length ? [pair] : [],
508
+ };
509
+ out.push(current);
510
+ }
511
+ }
423
512
  return out;
424
513
  }
425
514
  /** Local totals per account since ``fromDay`` (inclusive), for `usage status`. */
@@ -13,6 +13,8 @@ export declare const REPORTS_PATH = "/v1/usage/reports";
13
13
  export declare const TOKEN_TOTALS_PATH = "/v1/usage/token-totals";
14
14
  /** Tick cadence. */
15
15
  export declare const USAGE_INTERVAL_MS: number;
16
+ /** How long a server that does not replace a day whole is trusted to stay that way before it is asked again. */
17
+ export declare const REPLACE_PROBE_INTERVAL_MS: number;
16
18
  /** Partial uploads of one day's rows for one account before they are given up on. */
17
19
  export declare const MAX_PARTIAL_STRIKES = 3;
18
20
  /**
@@ -32,6 +34,12 @@ export interface Acceptance {
32
34
  keys?: Set<string>;
33
35
  /** Accounts it skipped, and why (newer servers). */
34
36
  skipped?: Map<string, SkipReason>;
37
+ /**
38
+ * Token totals: how many `completeDays` pairs the server replaced whole.
39
+ * Absent from a server that ignores `completeDays` — it only upserts, and
40
+ * leaves rows the body no longer carries.
41
+ */
42
+ replacedDays?: number;
35
43
  }
36
44
  /** Read `{accepted, acceptedAccountKeys?, skipped?}` from a 2xx body. */
37
45
  export declare function parseAcceptance(body: string | undefined, sent: number): Acceptance;
@@ -71,7 +79,7 @@ export interface UsageServiceOptions {
71
79
  export interface TickResult {
72
80
  reported: boolean;
73
81
  uploadedRows: number;
74
- skipped?: 'no_key' | 'no_machine_id';
82
+ skipped?: 'no_key' | 'insecure_base_url' | 'no_machine_id';
75
83
  }
76
84
  /** One process's reporter. Holds the backoff state and the scan cursor in memory. */
77
85
  export declare class UsageService {
@@ -82,6 +90,8 @@ export declare class UsageService {
82
90
  private readonly prober;
83
91
  readonly reportBackoff: Backoff;
84
92
  readonly totalsBackoff: Backoff;
93
+ /** The last {@link serverReplacesDays} answer, for this process only. */
94
+ private replaceProbe?;
85
95
  private scan;
86
96
  private status;
87
97
  private warned;
@@ -110,8 +120,27 @@ export declare class UsageService {
110
120
  * `not_reported`, costs a strike; after {@link MAX_PARTIAL_STRIKES} the pair
111
121
  * is given up on (logged once), so nothing is resent forever. Pairs whose
112
122
  * account has never been reported wait without a strike, and are not sent.
123
+ *
124
+ * Every pair goes out as `completeDays`, never split across bodies unless
125
+ * it alone outgrows one: a server that understands it deletes the pair's
126
+ * rows the body no longer carries and says so with `replacedDays`.
127
+ *
128
+ * A pair re-attributed by the version-2 upgrade (`scan.legacy`) still has
129
+ * per-directory rows on the server, so sending it to a server that only
130
+ * upserts would count it twice. It is held until a probe (an empty body)
131
+ * comes back with `replacedDays`, asked again at most every
132
+ * {@link REPLACE_PROBE_INTERVAL_MS}; other pairs go out meanwhile. A pair
133
+ * whose rebuild counted less than version 1 had (a log removed or rewritten
134
+ * since) is not sent at all: its old rows are mis-attributed but total right.
113
135
  */
114
136
  private uploadDirty;
137
+ /**
138
+ * Whether the server deletes the rows a complete day no longer carries: an
139
+ * empty body is answered with `replacedDays` only by one that does. A yes
140
+ * is kept for the process; a no (or a failed call) is asked again after
141
+ * {@link REPLACE_PROBE_INTERVAL_MS}.
142
+ */
143
+ private serverReplacesDays;
115
144
  private note;
116
145
  private callRecord;
117
146
  }
@@ -12,15 +12,18 @@ import os from 'node:os';
12
12
  import path from 'node:path';
13
13
  import { loadAccounts, machineId as machineIdFor } from '../agent/accounts.js';
14
14
  import { postJson } from '../agent/api.js';
15
+ import { baseUrlProblem } from '../agent/base-url.js';
15
16
  import { Backoff } from './backoff.js';
16
17
  import { resolveUsageKey, usageStateDir, writePrivateFile } from './config.js';
17
18
  import { accountConfigDir } from './discovery.js';
18
19
  import { buildReportFrame, defaultProber, probeAccounts } from './quota.js';
19
- import { batches, loadScanState, pendingKey, rowsForPending, saveScanState, scanUsage } from './scanner.js';
20
+ import { loadScanState, pendingAccount, pendingDay, pendingKey, rebuildFellShort, rowsForPending, saveScanState, scanUsage, uploadBatches, } from './scanner.js';
20
21
  export const REPORTS_PATH = '/v1/usage/reports';
21
22
  export const TOKEN_TOTALS_PATH = '/v1/usage/token-totals';
22
23
  /** Tick cadence. */
23
24
  export const USAGE_INTERVAL_MS = 5 * 60_000;
25
+ /** How long a server that does not replace a day whole is trusted to stay that way before it is asked again. */
26
+ export const REPLACE_PROBE_INTERVAL_MS = 60 * 60_000;
24
27
  /** Partial uploads of one day's rows for one account before they are given up on. */
25
28
  export const MAX_PARTIAL_STRIKES = 3;
26
29
  /** Skips that will never turn into an acceptance from this machine. */
@@ -48,7 +51,13 @@ export function parseAcceptance(body, sent) {
48
51
  }
49
52
  }
50
53
  }
51
- return { accepted, ...(keys ? { keys } : {}), ...(skipped ? { skipped } : {}) };
54
+ const replacedDays = typeof parsed?.replacedDays === 'number' && Number.isFinite(parsed.replacedDays) ? parsed.replacedDays : undefined;
55
+ return {
56
+ accepted,
57
+ ...(keys ? { keys } : {}),
58
+ ...(skipped ? { skipped } : {}),
59
+ ...(replacedDays !== undefined ? { replacedDays } : {}),
60
+ };
52
61
  }
53
62
  export function scanStatePath(env = process.env) {
54
63
  return path.join(usageStateDir(env), 'scan.json');
@@ -81,6 +90,8 @@ export class UsageService {
81
90
  prober;
82
91
  reportBackoff;
83
92
  totalsBackoff;
93
+ /** The last {@link serverReplacesDays} answer, for this process only. */
94
+ replaceProbe;
84
95
  scan;
85
96
  status;
86
97
  warned = new Set();
@@ -115,10 +126,17 @@ export class UsageService {
115
126
  try {
116
127
  const key = resolveUsageKey(this.env);
117
128
  if (!key) {
118
- this.warnOnce('no_key', 'Not connected — run `prohost usage connect --key <api key>`. Checking again each tick.');
129
+ this.warnOnce('no_key', 'Not connected — run `prohost usage connect`. Checking again each tick.');
119
130
  return { ...result, skipped: 'no_key' };
120
131
  }
121
132
  this.warned.delete('no_key');
133
+ // The key travels to this URL; a stored http:// one would send it in plaintext.
134
+ const problem = baseUrlProblem(key.baseUrl);
135
+ if (problem) {
136
+ this.warnOnce('insecure_base_url', `Not sending: the stored API base URL is unusable. ${problem}`);
137
+ return { ...result, skipped: 'insecure_base_url' };
138
+ }
139
+ this.warned.delete('insecure_base_url');
122
140
  const machineId = machineIdFor(this.env);
123
141
  if (!machineId) {
124
142
  this.warnOnce('no_machine_id', 'Could not create this machine\'s id in the accounts root; nothing can be reported.');
@@ -206,35 +224,89 @@ export class UsageService {
206
224
  * `not_reported`, costs a strike; after {@link MAX_PARTIAL_STRIKES} the pair
207
225
  * is given up on (logged once), so nothing is resent forever. Pairs whose
208
226
  * account has never been reported wait without a strike, and are not sent.
227
+ *
228
+ * Every pair goes out as `completeDays`, never split across bodies unless
229
+ * it alone outgrows one: a server that understands it deletes the pair's
230
+ * rows the body no longer carries and says so with `replacedDays`.
231
+ *
232
+ * A pair re-attributed by the version-2 upgrade (`scan.legacy`) still has
233
+ * per-directory rows on the server, so sending it to a server that only
234
+ * upserts would count it twice. It is held until a probe (an empty body)
235
+ * comes back with `replacedDays`, asked again at most every
236
+ * {@link REPLACE_PROBE_INTERVAL_MS}; other pairs go out meanwhile. A pair
237
+ * whose rebuild counted less than version 1 had (a log removed or rewritten
238
+ * since) is not sent at all: its old rows are mis-attributed but total right.
209
239
  */
210
240
  async uploadDirty(api, machineId, machine) {
211
241
  const scan = this.scan;
212
242
  const rows = rowsForPending(scan, scan.dirty);
213
- const ready = rows.filter((r) => this.reportedKeys.has(r.accountKey));
243
+ let ready = rows.filter((r) => this.reportedKeys.has(r.accountKey));
214
244
  const done = new Set();
215
245
  const pendingWithRows = new Set(rows.map((r) => pendingKey(r.day, r.accountKey)));
216
246
  // Pending pairs with nothing to send (all-zero) are simply done.
217
247
  for (const pair of scan.dirty)
218
248
  if (!pendingWithRows.has(pair))
219
249
  done.add(pair);
250
+ const legacyReady = new Set(ready.map((r) => pendingKey(r.day, r.accountKey)).filter((pair) => scan.legacy[pair] !== undefined));
251
+ if (legacyReady.size > 0) {
252
+ if (!(await this.serverReplacesDays(api, machineId, machine))) {
253
+ ready = ready.filter((r) => !legacyReady.has(pendingKey(r.day, r.accountKey)));
254
+ }
255
+ else {
256
+ const rebuilt = new Map();
257
+ for (const r of ready) {
258
+ const pair = pendingKey(r.day, r.accountKey);
259
+ if (!legacyReady.has(pair))
260
+ continue;
261
+ const sum = rebuilt.get(pair) ?? [0, 0, 0, 0];
262
+ rebuilt.set(pair, [
263
+ sum[0] + r.inputTokens,
264
+ sum[1] + r.cachedInputTokens,
265
+ sum[2] + r.cacheWriteTokens,
266
+ sum[3] + r.outputTokens,
267
+ ]);
268
+ }
269
+ const short = new Set([...rebuilt].filter(([pair, t]) => rebuildFellShort(t, scan.legacy[pair])).map(([p]) => p));
270
+ for (const pair of short) {
271
+ done.add(pair);
272
+ delete scan.legacy[pair];
273
+ }
274
+ if (short.size > 0) {
275
+ ready = ready.filter((r) => !short.has(pendingKey(r.day, r.accountKey)));
276
+ this.warnOnce('legacy_short', `token totals: ${short.size} earlier day(s) read back smaller than first counted (logs removed or ` +
277
+ 'rewritten since); they keep their per-folder projects on the server so their totals stay right.');
278
+ }
279
+ }
280
+ }
220
281
  let sent = 0;
221
282
  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);
283
+ for (const batch of uploadBatches(ready)) {
284
+ const body = {
285
+ machineId,
286
+ machine,
287
+ days: batch.rows,
288
+ completeDays: batch.complete.map((pair) => ({ day: pendingDay(pair), accountKey: pendingAccount(pair) })),
289
+ };
290
+ const response = await postJson(api, TOKEN_TOTALS_PATH, body, undefined, true);
224
291
  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 };
292
+ const acceptance = response.ok ? parseAcceptance(response.body, batch.rows.length) : { accepted: 0 };
293
+ this.status.lastTotals = { ...this.callRecord(response, batch.rows.length), accepted: acceptance.accepted };
227
294
  if (!response.ok)
228
295
  break;
229
296
  sent += acceptance.accepted;
297
+ const lastHere = new Set(batch.last);
230
298
  const pairs = new Map(); // pair → accountKey
231
- for (const row of batch)
299
+ for (const row of batch.rows)
232
300
  pairs.set(pendingKey(row.day, row.accountKey), row.accountKey);
233
301
  for (const [pair, accountKey] of pairs) {
234
- const taken = acceptance.keys ? acceptance.keys.has(accountKey) : acceptance.accepted >= batch.length;
302
+ const taken = acceptance.keys ? acceptance.keys.has(accountKey) : acceptance.accepted >= batch.rows.length;
235
303
  if (taken) {
304
+ // A pair spread over several bodies is done with its last one.
305
+ if (!lastHere.has(pair))
306
+ continue;
236
307
  done.add(pair);
237
308
  delete scan.strikes[pair];
309
+ delete scan.legacy[pair];
238
310
  continue;
239
311
  }
240
312
  const reason = acceptance.skipped?.get(accountKey);
@@ -243,6 +315,7 @@ export class UsageService {
243
315
  if (reason && PERMANENT_SKIPS.has(reason)) {
244
316
  done.add(pair);
245
317
  delete scan.strikes[pair];
318
+ delete scan.legacy[pair];
246
319
  this.warnOnce(`totals_skip:${accountKey}:${reason}`, `token totals: account ${accountKey.slice(0, 8)}… skipped (${reason}); those rows will not be resent.`);
247
320
  continue;
248
321
  }
@@ -252,6 +325,7 @@ export class UsageService {
252
325
  if (strikes >= MAX_PARTIAL_STRIKES) {
253
326
  done.add(pair);
254
327
  delete scan.strikes[pair];
328
+ delete scan.legacy[pair];
255
329
  gaveUp += 1;
256
330
  }
257
331
  else {
@@ -267,6 +341,27 @@ export class UsageService {
267
341
  saveScanState(scanStatePath(this.env), scan);
268
342
  return sent;
269
343
  }
344
+ /**
345
+ * Whether the server deletes the rows a complete day no longer carries: an
346
+ * empty body is answered with `replacedDays` only by one that does. A yes
347
+ * is kept for the process; a no (or a failed call) is asked again after
348
+ * {@link REPLACE_PROBE_INTERVAL_MS}.
349
+ */
350
+ async serverReplacesDays(api, machineId, machine) {
351
+ if (this.replaceProbe?.supported)
352
+ return true;
353
+ if (this.replaceProbe && this.now() - this.replaceProbe.at < REPLACE_PROBE_INTERVAL_MS)
354
+ return false;
355
+ const response = await postJson(api, TOKEN_TOTALS_PATH, { machineId, machine, days: [], completeDays: [] }, undefined, true);
356
+ this.note(this.totalsBackoff, response);
357
+ const supported = response.ok && parseAcceptance(response.body, 0).replacedDays !== undefined;
358
+ this.replaceProbe = { supported, at: this.now() };
359
+ if (!supported && response.ok) {
360
+ this.warnOnce('replace_unsupported', 'token totals: the server does not replace a day whole yet; days counted under per-folder projects ' +
361
+ 'wait until it does, so nothing is counted twice.');
362
+ }
363
+ return supported;
364
+ }
270
365
  note(backoff, response) {
271
366
  const line = backoff.record(response);
272
367
  if (line)
package/dist/version.d.ts CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export declare const CLI_VERSION = "0.10.0";
10
+ export declare const CLI_VERSION = "0.10.2";
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
- export declare const USER_AGENT = "prohost-cli/0.10.0";
12
+ export declare const USER_AGENT = "prohost-cli/0.10.2";
package/dist/version.js CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export const CLI_VERSION = '0.10.0';
10
+ export const CLI_VERSION = '0.10.2';
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.10.0",
3
+ "version": "0.10.2",
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",