@prohost/cli 0.10.2 → 0.10.4

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,29 @@ 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.4
8
+
9
+ - **A day's rebuilt totals wait for its 0.10.1 rows to land.** If the server
10
+ takes the rows 0.10.1 never uploaded only in part (a throttle, or a partial
11
+ acceptance), the rebuilt rows for that (day, login) are held back until those
12
+ rows are accepted or permanently skipped. Sending the rebuild first could let
13
+ the stale rows, retried on a later tick, overwrite the right total.
14
+
15
+ ## 0.10.3
16
+
17
+ Two fixes to the 0.10.2 repository attribution.
18
+
19
+ - **A bare repository is named without `.git`.** A session whose working
20
+ directory is a bare repository (`tools.git`, with `HEAD`, `objects/` and
21
+ `refs/` and no `.git` inside), or a directory within it, counts toward
22
+ `tools`, the same name its linked worktrees get. A plain folder that only
23
+ ends in `.git` keeps its name.
24
+ - **Days 0.10.1 had not uploaded yet are not lost in the upgrade.** The rows
25
+ of any (day, login) still waiting to upload when the scan state was rebuilt
26
+ are sent as 0.10.1 had them, before any rebuilt row. A day whose log has
27
+ since been removed, or reads back smaller, still reaches the server with
28
+ the total 0.10.1 counted.
29
+
7
30
  ## 0.10.2
8
31
 
9
32
  Token totals are attributed to the repository, not the directory a session ran in.
@@ -12,6 +12,8 @@
12
12
  * 3. A `.git` file is a linked worktree (or a submodule): its `gitdir:` names
13
13
  * `<repo>/.git/worktrees/<name>`, whose `commondir` leads back to
14
14
  * `<repo>/.git`. A submodule's gitdir has neither, so it is its own project.
15
+ * A bare repository (`HEAD`, `objects/`, `refs/` and no `.git`) is its own
16
+ * root, named without a `.git` suffix: `tools.git` is `tools`.
15
17
  * 4. A directory that no longer exists (a removed worktree) is recognised by
16
18
  * its layout — `…/.worktrees/<repo>/<name>` or `…/<repo>/.claude/worktrees/<name>`
17
19
  * — and is otherwise named by its basename.
@@ -12,6 +12,8 @@
12
12
  * 3. A `.git` file is a linked worktree (or a submodule): its `gitdir:` names
13
13
  * `<repo>/.git/worktrees/<name>`, whose `commondir` leads back to
14
14
  * `<repo>/.git`. A submodule's gitdir has neither, so it is its own project.
15
+ * A bare repository (`HEAD`, `objects/`, `refs/` and no `.git`) is its own
16
+ * root, named without a `.git` suffix: `tools.git` is `tools`.
15
17
  * 4. A directory that no longer exists (a removed worktree) is recognised by
16
18
  * its layout — `…/.worktrees/<repo>/<name>` or `…/<repo>/.claude/worktrees/<name>`
17
19
  * — and is otherwise named by its basename.
@@ -69,7 +71,7 @@ function resolveProjectRoot(dir) {
69
71
  }
70
72
  return dir;
71
73
  }
72
- /** The main repository root when `dir` holds a `.git`; `undefined` when it holds none. */
74
+ /** The main repository root when `dir` holds a `.git` or is a bare repository; `undefined` otherwise. */
73
75
  function repoRootAt(dir) {
74
76
  const dotGit = path.join(dir, '.git');
75
77
  let stat;
@@ -77,7 +79,7 @@ function repoRootAt(dir) {
77
79
  stat = statSync(dotGit);
78
80
  }
79
81
  catch {
80
- return undefined;
82
+ return isBareRepo(dir) ? withoutGitSuffix(dir) : undefined;
81
83
  }
82
84
  if (stat.isDirectory())
83
85
  return dir;
@@ -115,7 +117,22 @@ function mainRepoOf(gitDir) {
115
117
  // `<repo>/.git` → `<repo>`; a bare `<repo>.git` common dir names the repo itself.
116
118
  if (path.basename(common) === '.git')
117
119
  return path.dirname(common);
118
- return common.replace(/\.git$/, '') || common;
120
+ return withoutGitSuffix(common);
121
+ }
122
+ /** `<repo>.git` → `<repo>`; anything else as it is. */
123
+ function withoutGitSuffix(dir) {
124
+ return dir.replace(/\.git$/, '') || dir;
125
+ }
126
+ /** A bare repository's own directory: `HEAD`, `objects/` and `refs/` with no worktree. */
127
+ function isBareRepo(dir) {
128
+ try {
129
+ return (statSync(path.join(dir, 'HEAD')).isFile() &&
130
+ statSync(path.join(dir, 'objects')).isDirectory() &&
131
+ statSync(path.join(dir, 'refs')).isDirectory());
132
+ }
133
+ catch {
134
+ return false;
135
+ }
119
136
  }
120
137
  /** The repository a removed worktree path belonged to, from the two layouts agents use. */
121
138
  function removedWorktreeRepo(dir) {
@@ -82,6 +82,13 @@ export interface ScanState {
82
82
  * the pair is replaced whole on the server, or is given up on.
83
83
  */
84
84
  legacy: Record<string, Totals>;
85
+ /**
86
+ * Bucket key → totals: the version-1 rows of pairs version 1 had not
87
+ * uploaded yet (still dirty). The server never took them, so its copy is
88
+ * not the right total; they go out as they were, before any rebuilt row,
89
+ * and an entry goes once the server takes its pair or never will.
90
+ */
91
+ legacyUnsent: Record<string, Totals>;
85
92
  }
86
93
  /** A dirty entry: one account's rows for one day. */
87
94
  export declare function pendingKey(day: string, accountKey: string): string;
@@ -116,7 +123,8 @@ export declare function emptyScanState(): ScanState;
116
123
  /**
117
124
  * Load the scan state. A version-1 state (per-directory projects) is replaced
118
125
  * by an empty one that re-reads every log, keeping its per-pair totals as
119
- * {@link ScanState.legacy}.
126
+ * {@link ScanState.legacy} and the rows it had not uploaded as
127
+ * {@link ScanState.legacyUnsent}.
120
128
  */
121
129
  export declare function loadScanState(file: string): ScanState;
122
130
  /**
@@ -137,6 +145,10 @@ export declare function bucketKey(day: string, accountKey: string, runtime: Acco
137
145
  export declare function scanUsage(accounts: ScanAccount[], state: ScanState, options?: ScanOptions): ScanSummary;
138
146
  /** The rows for the given days, in upload shape. Zero-token buckets are left out. */
139
147
  export declare function rowsForDays(state: ScanState, days: Iterable<string>): TokenRow[];
148
+ /** The version-1 rows not yet uploaded ({@link ScanState.legacyUnsent}), in upload shape. */
149
+ export declare function legacyUnsentRows(state: ScanState): TokenRow[];
150
+ /** The day/account pairs ({@link pendingKey}) that still have version-1 rows to upload, zero-token ones aside. */
151
+ export declare function legacyUnsentPairs(state: ScanState): Set<string>;
140
152
  /** The rows for the given `day|accountKey` pairs, in upload shape. */
141
153
  export declare function rowsForPending(state: ScanState, pairs: Iterable<string>): TokenRow[];
142
154
  /** One `POST /v1/usage/token-totals` body's rows, and what it says about each (day, account) pair. */
@@ -65,7 +65,7 @@ export function pendingDays(state) {
65
65
  return [...new Set(state.dirty.map(pendingDay))].sort();
66
66
  }
67
67
  export function emptyScanState() {
68
- return { version: 2, files: {}, buckets: {}, seen: {}, dirty: [], strikes: {}, legacy: {} };
68
+ return { version: 2, files: {}, buckets: {}, seen: {}, dirty: [], strikes: {}, legacy: {}, legacyUnsent: {} };
69
69
  }
70
70
  const pairList = (value) => Array.isArray(value) ? value.filter((d) => typeof d === 'string' && d.includes('|')) : [];
71
71
  const isTotals = (value) => Array.isArray(value) && value.length === 4 && value.every((n) => typeof n === 'number' && Number.isFinite(n));
@@ -95,10 +95,42 @@ function legacyTotals(buckets) {
95
95
  }
96
96
  return out;
97
97
  }
98
+ /** The version-1 buckets of the pairs it still had dirty. */
99
+ function legacyUnsentBuckets(buckets, dirty) {
100
+ const out = {};
101
+ if (!buckets || typeof buckets !== 'object' || dirty.length === 0)
102
+ return out;
103
+ const wanted = new Set(dirty);
104
+ for (const [key, totals] of Object.entries(buckets)) {
105
+ if (!isTotals(totals) || !isBucketKey(key))
106
+ continue;
107
+ const [day, accountKey] = JSON.parse(key);
108
+ if (wanted.has(pendingKey(day, accountKey)))
109
+ out[key] = totals;
110
+ }
111
+ return out;
112
+ }
113
+ /** A {@link bucketKey}: `[day, accountKey, runtime, model, project]`. */
114
+ function isBucketKey(key) {
115
+ try {
116
+ const parts = JSON.parse(key);
117
+ return (Array.isArray(parts) &&
118
+ parts.length === 5 &&
119
+ parts.slice(0, 4).every((part) => typeof part === 'string') &&
120
+ (parts[4] === null || typeof parts[4] === 'string'));
121
+ }
122
+ catch {
123
+ return false;
124
+ }
125
+ }
126
+ const totalsRecord = (value, validKey) => value && typeof value === 'object'
127
+ ? Object.fromEntries(Object.entries(value).filter(([key, t]) => validKey(key) && isTotals(t)))
128
+ : {};
98
129
  /**
99
130
  * Load the scan state. A version-1 state (per-directory projects) is replaced
100
131
  * by an empty one that re-reads every log, keeping its per-pair totals as
101
- * {@link ScanState.legacy}.
132
+ * {@link ScanState.legacy} and the rows it had not uploaded as
133
+ * {@link ScanState.legacyUnsent}.
102
134
  */
103
135
  export function loadScanState(file) {
104
136
  let parsed;
@@ -108,8 +140,13 @@ export function loadScanState(file) {
108
140
  catch {
109
141
  return emptyScanState();
110
142
  }
111
- if (parsed?.version === 1)
112
- return { ...emptyScanState(), legacy: legacyTotals(parsed.buckets) };
143
+ if (parsed?.version === 1) {
144
+ return {
145
+ ...emptyScanState(),
146
+ legacy: legacyTotals(parsed.buckets),
147
+ legacyUnsent: legacyUnsentBuckets(parsed.buckets, pairList(parsed.dirty)),
148
+ };
149
+ }
113
150
  if (parsed?.version !== 2)
114
151
  return emptyScanState();
115
152
  return {
@@ -119,9 +156,8 @@ export function loadScanState(file) {
119
156
  seen: parsed.seen && typeof parsed.seen === 'object' ? parsed.seen : {},
120
157
  dirty: pairList(parsed.dirty),
121
158
  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
- : {},
159
+ legacy: totalsRecord(parsed.legacy, (pair) => pair.includes('|')),
160
+ legacyUnsent: totalsRecord(parsed.legacyUnsent, isBucketKey),
125
161
  };
126
162
  }
127
163
  /**
@@ -421,6 +457,10 @@ export function scanUsage(accounts, state, options = {}) {
421
457
  for (const pair of Object.keys(state.legacy))
422
458
  if (pendingDay(pair) < pass.cutoffDay)
423
459
  delete state.legacy[pair];
460
+ for (const key of Object.keys(state.legacyUnsent)) {
461
+ if (dayOfBucket(key) < pass.cutoffDay)
462
+ delete state.legacyUnsent[key];
463
+ }
424
464
  for (const pair of Object.keys(state.strikes))
425
465
  if (!state.dirty.includes(pair))
426
466
  delete state.strikes[pair];
@@ -449,9 +489,20 @@ function prune(state, cutoffDay, cutoffMs) {
449
489
  /** The rows for the given days, in upload shape. Zero-token buckets are left out. */
450
490
  export function rowsForDays(state, days) {
451
491
  const wanted = new Set(days);
492
+ return bucketRows(state.buckets, (key) => wanted.has(dayOfBucket(key)));
493
+ }
494
+ /** The version-1 rows not yet uploaded ({@link ScanState.legacyUnsent}), in upload shape. */
495
+ export function legacyUnsentRows(state) {
496
+ return bucketRows(state.legacyUnsent, () => true);
497
+ }
498
+ /** The day/account pairs ({@link pendingKey}) that still have version-1 rows to upload, zero-token ones aside. */
499
+ export function legacyUnsentPairs(state) {
500
+ return new Set(legacyUnsentRows(state).map((r) => pendingKey(r.day, r.accountKey)));
501
+ }
502
+ function bucketRows(buckets, include) {
452
503
  const rows = [];
453
- for (const [key, totals] of Object.entries(state.buckets)) {
454
- if (!wanted.has(dayOfBucket(key)) || isZero(totals))
504
+ for (const [key, totals] of Object.entries(buckets)) {
505
+ if (!include(key) || isZero(totals))
455
506
  continue;
456
507
  const [day, accountKey, runtime, model, project] = JSON.parse(key);
457
508
  rows.push({
@@ -132,8 +132,20 @@ export declare class UsageService {
132
132
  * {@link REPLACE_PROBE_INTERVAL_MS}; other pairs go out meanwhile. A pair
133
133
  * whose rebuild counted less than version 1 had (a log removed or rewritten
134
134
  * since) is not sent at all: its old rows are mis-attributed but total right.
135
+ *
136
+ * First, though, the version-1 rows version 1 never uploaded
137
+ * (`scan.legacyUnsent`) go out as they were, without `completeDays` — the
138
+ * server's copy of those pairs is behind, so it is not the right total
139
+ * until they land. A rebuilt complete pair sent after them replaces them.
135
140
  */
136
141
  private uploadDirty;
142
+ /**
143
+ * Upload the version-1 rows version 1 had not uploaded, as plain rows (the
144
+ * upsert version 1 would have sent). A pair's rows go once the server takes
145
+ * them or never will (a permanent skip); `not_reported` waits for a report.
146
+ * Returns `false` when a call failed, so nothing else is sent this tick.
147
+ */
148
+ private uploadLegacyUnsent;
137
149
  /**
138
150
  * Whether the server deletes the rows a complete day no longer carries: an
139
151
  * empty body is answered with `replacedDays` only by one that does. A yes
@@ -17,7 +17,7 @@ import { Backoff } from './backoff.js';
17
17
  import { resolveUsageKey, usageStateDir, writePrivateFile } from './config.js';
18
18
  import { accountConfigDir } from './discovery.js';
19
19
  import { buildReportFrame, defaultProber, probeAccounts } from './quota.js';
20
- import { loadScanState, pendingAccount, pendingDay, pendingKey, rebuildFellShort, rowsForPending, saveScanState, scanUsage, uploadBatches, } from './scanner.js';
20
+ import { loadScanState, pendingAccount, pendingDay, legacyUnsentPairs, legacyUnsentRows, pendingKey, rebuildFellShort, rowsForPending, saveScanState, scanUsage, uploadBatches, } from './scanner.js';
21
21
  export const REPORTS_PATH = '/v1/usage/reports';
22
22
  export const TOKEN_TOTALS_PATH = '/v1/usage/token-totals';
23
23
  /** Tick cadence. */
@@ -193,7 +193,8 @@ export class UsageService {
193
193
  // The state can run to megabytes; rewrite it only when a file was read.
194
194
  if (scanned.filesRead > 0)
195
195
  saveScanState(scanStatePath(this.env), this.scan);
196
- if (this.totalsBackoff.ready() && this.scan.dirty.length > 0) {
196
+ const pending = this.scan.dirty.length > 0 || Object.keys(this.scan.legacyUnsent).length > 0;
197
+ if (this.totalsBackoff.ready() && pending) {
197
198
  result.uploadedRows = await this.uploadDirty(api, machineId, machine);
198
199
  }
199
200
  }
@@ -236,6 +237,11 @@ export class UsageService {
236
237
  * {@link REPLACE_PROBE_INTERVAL_MS}; other pairs go out meanwhile. A pair
237
238
  * whose rebuild counted less than version 1 had (a log removed or rewritten
238
239
  * since) is not sent at all: its old rows are mis-attributed but total right.
240
+ *
241
+ * First, though, the version-1 rows version 1 never uploaded
242
+ * (`scan.legacyUnsent`) go out as they were, without `completeDays` — the
243
+ * server's copy of those pairs is behind, so it is not the right total
244
+ * until they land. A rebuilt complete pair sent after them replaces them.
239
245
  */
240
246
  async uploadDirty(api, machineId, machine) {
241
247
  const scan = this.scan;
@@ -247,6 +253,16 @@ export class UsageService {
247
253
  for (const pair of scan.dirty)
248
254
  if (!pendingWithRows.has(pair))
249
255
  done.add(pair);
256
+ if (!(await this.uploadLegacyUnsent(api, machineId, machine))) {
257
+ saveScanState(scanStatePath(this.env), scan);
258
+ return 0;
259
+ }
260
+ // A pair whose version-1 rows are still pending (throttled, partly accepted, or waiting for a
261
+ // report) holds its rebuilt rows back: sent now, they could be overwritten by those stale rows
262
+ // landing on a later tick. They stay dirty and go once the legacy rows settle.
263
+ const heldByLegacy = legacyUnsentPairs(scan);
264
+ if (heldByLegacy.size > 0)
265
+ ready = ready.filter((r) => !heldByLegacy.has(pendingKey(r.day, r.accountKey)));
250
266
  const legacyReady = new Set(ready.map((r) => pendingKey(r.day, r.accountKey)).filter((pair) => scan.legacy[pair] !== undefined));
251
267
  if (legacyReady.size > 0) {
252
268
  if (!(await this.serverReplacesDays(api, machineId, machine))) {
@@ -341,6 +357,41 @@ export class UsageService {
341
357
  saveScanState(scanStatePath(this.env), scan);
342
358
  return sent;
343
359
  }
360
+ /**
361
+ * Upload the version-1 rows version 1 had not uploaded, as plain rows (the
362
+ * upsert version 1 would have sent). A pair's rows go once the server takes
363
+ * them or never will (a permanent skip); `not_reported` waits for a report.
364
+ * Returns `false` when a call failed, so nothing else is sent this tick.
365
+ */
366
+ async uploadLegacyUnsent(api, machineId, machine) {
367
+ const scan = this.scan;
368
+ const rows = legacyUnsentRows(scan).filter((r) => this.reportedKeys.has(r.accountKey));
369
+ for (const batch of uploadBatches(rows)) {
370
+ const body = { machineId, machine, days: batch.rows };
371
+ const response = await postJson(api, TOKEN_TOTALS_PATH, body, undefined, true);
372
+ this.note(this.totalsBackoff, response);
373
+ const acceptance = response.ok ? parseAcceptance(response.body, batch.rows.length) : { accepted: 0 };
374
+ this.status.lastTotals = { ...this.callRecord(response, batch.rows.length), accepted: acceptance.accepted };
375
+ if (!response.ok)
376
+ return false;
377
+ const settled = new Set();
378
+ for (const row of batch.rows) {
379
+ const pair = pendingKey(row.day, row.accountKey);
380
+ const taken = acceptance.keys ? acceptance.keys.has(row.accountKey) : acceptance.accepted >= batch.rows.length;
381
+ const reason = acceptance.skipped?.get(row.accountKey);
382
+ if ((taken && batch.last.includes(pair)) || (reason && PERMANENT_SKIPS.has(reason)))
383
+ settled.add(pair);
384
+ else if (!taken && reason !== 'throttled')
385
+ this.reportedKeys.delete(row.accountKey);
386
+ }
387
+ for (const key of Object.keys(scan.legacyUnsent)) {
388
+ const [day, accountKey] = JSON.parse(key);
389
+ if (settled.has(pendingKey(day, accountKey)))
390
+ delete scan.legacyUnsent[key];
391
+ }
392
+ }
393
+ return true;
394
+ }
344
395
  /**
345
396
  * Whether the server deletes the rows a complete day no longer carries: an
346
397
  * empty body is answered with `replacedDays` only by one that does. A yes
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.2";
10
+ export declare const CLI_VERSION = "0.10.4";
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
- export declare const USER_AGENT = "prohost-cli/0.10.2";
12
+ export declare const USER_AGENT = "prohost-cli/0.10.4";
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.2';
10
+ export const CLI_VERSION = '0.10.4';
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.2",
3
+ "version": "0.10.4",
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",