@prohost/cli 0.10.1 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,30 @@ 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.2
8
+
9
+ Token totals are attributed to the repository, not the directory a session ran in.
10
+
11
+ - **Projects are repositories.** A session in a git worktree
12
+ (`.worktrees/backend-service/fix-something`, `.claude/worktrees/agent-…`) or
13
+ a subdirectory (`iosApp`, `Sources`) now counts toward its repository
14
+ (`backend-service`, `mobile`) in "By project". The repository is found from
15
+ the `.git` the directory sits under — a worktree's `gitdir` / `commondir`
16
+ lead back to the main repository — with no `git` process per line, and
17
+ cached per directory. A worktree that has since been removed is recognised by
18
+ its `…/.worktrees/<repo>/<name>` or `…/<repo>/.claude/worktrees/<name>`
19
+ path; anything else keeps its basename.
20
+ - **Days are replaced whole.** Each upload now names the (day, account) pairs
21
+ it carries in full (`completeDays`), and the server deletes that day's other
22
+ rows for the account, so a renamed project never counts twice.
23
+ - **The upgrade re-reads the last 30 days.** The local scan state is rebuilt
24
+ once with the new attribution and every day re-sent whole, which replaces
25
+ the old per-worktree rows. Those days wait until the server confirms it
26
+ replaces a day whole (an empty upload answered with `replacedDays`, asked at
27
+ most hourly), so an older server never counts them twice. A day that reads
28
+ back smaller than first counted (a log removed or rewritten since) is left
29
+ as it is on the server: per-folder, but with the right total.
30
+
7
31
  ## 0.10.1
8
32
 
9
33
  Safer key handling for `prohost usage connect`, and no credential ever goes to
package/README.md CHANGED
@@ -24,8 +24,9 @@ npx @prohost/cli --version # the version you're running
24
24
  Pair once, then leave the agent running:
25
25
 
26
26
  ```bash
27
- # 1. In ProhostAI: Hire an AI employee → Bring your own agent → copy the code.
28
- # Codes are single-use and expire 15 minutes after they're generated.
27
+ # 1. In ProhostAI: AI Employees → Computers → Pair a computer. It shows this
28
+ # command with your code filled in. Codes are single-use and expire 15
29
+ # minutes after they're generated.
29
30
  npx @prohost/cli agent pair --code <pairing-code>
30
31
 
31
32
  # 2. Point it at whatever agent you already use.
@@ -48,8 +49,9 @@ doesn't know or care which one it is.
48
49
 
49
50
  ### 1. Get a pairing code
50
51
 
51
- In ProhostAI: **Hire an AI employee → Bring your own agent**. You'll get a
52
- one-time code. It is single-use and expires 15 minutes after it's generated.
52
+ In ProhostAI: **AI Employees → Computers → Pair a computer**. You'll get a
53
+ one-time code, already filled into the command it shows you. It is single-use
54
+ and expires 15 minutes after it's generated.
53
55
 
54
56
  ### 2. Pair
55
57
 
@@ -735,7 +737,7 @@ through ProhostAI's own approval path, not around it via an API key.
735
737
 
736
738
  `prohost usage` reports, for every Claude Code and Codex login on this machine,
737
739
  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.
740
+ reset times) and how many tokens it used per day, model and project (repository).
739
741
  ProhostAI shows it in the workspace's Usage Monitor app. No agent pairing needed.
740
742
 
741
743
  ```bash
@@ -773,11 +775,17 @@ spends a model turn. Sign-in state comes from `claude auth status` /
773
775
  **Tokens.** Read from the session logs the CLIs already write —
774
776
  `<config dir>/projects/**/*.jsonl` for Claude Code, `sessions/**/rollout-*.jsonl`
775
777
  for Codex — and summed per local day, login, model and project, where the
776
- project is the **name** of the working directory (`backend-service`, never its
777
- path). Only usage counters are read: no prompt, reply, tool call or file content
778
- is kept or sent. The first run looks back 30 days; after that only what was
779
- appended is read, using a cursor in `$PROHOST_HOME/usage/`. A day that changes
780
- is re-sent whole, and the server replaces it.
778
+ project is the **name** of the repository the working directory belongs to
779
+ (`backend-service`, never its path). A git worktree or a subdirectory counts as
780
+ its main repository: `.worktrees/backend-service/fix-x` and
781
+ `backend-service/web/src` are both `backend-service`. A directory outside any
782
+ repository is its own name; a removed worktree is recognised by its
783
+ `…/.worktrees/<repo>/<name>` or `…/<repo>/.claude/worktrees/<name>` path. Only
784
+ usage counters are read: no prompt, reply, tool call or file content is kept or
785
+ sent. The first run looks back 30 days; after that only what was appended is
786
+ read, using a cursor in `$PROHOST_HOME/usage/`. A day that changes is re-sent
787
+ whole, marked complete, and the server replaces that day's rows for the login —
788
+ including deleting any project the day no longer has.
781
789
  Totals for a login go only after the server has accepted a capacity report
782
790
  for it (the server ignores totals for logins it hasn't seen), so each pass
783
791
  reports first. When the server says why it skipped a login's rows, the reason
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The project a session's working directory belongs to: its repository's name.
3
+ *
4
+ * A session can run in the repository root, a subdirectory (`iosApp`,
5
+ * `Sources`) or a git worktree with a branch-like name
6
+ * (`.worktrees/backend-service/fix-agent-delete-memory-aware`). All of them
7
+ * are the one repository, so the project is the basename of the MAIN
8
+ * repository's root:
9
+ *
10
+ * 1. Walk up from the directory to the nearest ancestor holding `.git`.
11
+ * 2. A `.git` directory: that ancestor is the root.
12
+ * 3. A `.git` file is a linked worktree (or a submodule): its `gitdir:` names
13
+ * `<repo>/.git/worktrees/<name>`, whose `commondir` leads back to
14
+ * `<repo>/.git`. A submodule's gitdir has neither, so it is its own project.
15
+ * 4. A directory that no longer exists (a removed worktree) is recognised by
16
+ * its layout — `…/.worktrees/<repo>/<name>` or `…/<repo>/.claude/worktrees/<name>`
17
+ * — and is otherwise named by its basename.
18
+ *
19
+ * Only the basename ever leaves this module, never the path. Results are
20
+ * cached per directory: the scanner asks once per log line, and the answer is
21
+ * a few `stat`s the first time and a map lookup after — never a `git` child
22
+ * process.
23
+ *
24
+ * The walk does what `git` itself does, so a directory under a dotfiles
25
+ * repository at `~` is attributed to it.
26
+ */
27
+ /** Forget every cached answer (tests; a long-running process never needs it). */
28
+ export declare function clearProjectCache(): void;
29
+ /** The repository name of a session's working directory, or `null` when it names none. */
30
+ export declare function projectName(cwd: unknown): string | null;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The project a session's working directory belongs to: its repository's name.
3
+ *
4
+ * A session can run in the repository root, a subdirectory (`iosApp`,
5
+ * `Sources`) or a git worktree with a branch-like name
6
+ * (`.worktrees/backend-service/fix-agent-delete-memory-aware`). All of them
7
+ * are the one repository, so the project is the basename of the MAIN
8
+ * repository's root:
9
+ *
10
+ * 1. Walk up from the directory to the nearest ancestor holding `.git`.
11
+ * 2. A `.git` directory: that ancestor is the root.
12
+ * 3. A `.git` file is a linked worktree (or a submodule): its `gitdir:` names
13
+ * `<repo>/.git/worktrees/<name>`, whose `commondir` leads back to
14
+ * `<repo>/.git`. A submodule's gitdir has neither, so it is its own project.
15
+ * 4. A directory that no longer exists (a removed worktree) is recognised by
16
+ * its layout — `…/.worktrees/<repo>/<name>` or `…/<repo>/.claude/worktrees/<name>`
17
+ * — and is otherwise named by its basename.
18
+ *
19
+ * Only the basename ever leaves this module, never the path. Results are
20
+ * cached per directory: the scanner asks once per log line, and the answer is
21
+ * a few `stat`s the first time and a map lookup after — never a `git` child
22
+ * process.
23
+ *
24
+ * The walk does what `git` itself does, so a directory under a dotfiles
25
+ * repository at `~` is attributed to it.
26
+ */
27
+ import { readFileSync, statSync } from 'node:fs';
28
+ import path from 'node:path';
29
+ const MAX_PROJECT_CHARS = 120;
30
+ /** Directories remembered; far more than one machine's sessions ever name. */
31
+ const MAX_CACHED = 5000;
32
+ /** Deepest walk up from a directory looking for `.git`. */
33
+ const MAX_ANCESTORS = 64;
34
+ const cache = new Map();
35
+ /** Forget every cached answer (tests; a long-running process never needs it). */
36
+ export function clearProjectCache() {
37
+ cache.clear();
38
+ }
39
+ /** The repository name of a session's working directory, or `null` when it names none. */
40
+ export function projectName(cwd) {
41
+ if (typeof cwd !== 'string' || !cwd.trim())
42
+ return null;
43
+ const dir = cwd.trim().replace(/[\\/]+$/, '');
44
+ if (!dir)
45
+ return null;
46
+ const cached = cache.get(dir);
47
+ if (cached !== undefined)
48
+ return cached;
49
+ const base = path.basename(resolveProjectRoot(dir));
50
+ const name = base ? base.slice(0, MAX_PROJECT_CHARS) : null;
51
+ if (cache.size >= MAX_CACHED)
52
+ cache.clear();
53
+ cache.set(dir, name);
54
+ return name;
55
+ }
56
+ /** The directory whose basename names the project. */
57
+ function resolveProjectRoot(dir) {
58
+ if (!isDirectory(dir))
59
+ return removedWorktreeRepo(dir) ?? dir;
60
+ let current = dir;
61
+ for (let depth = 0; depth < MAX_ANCESTORS; depth += 1) {
62
+ const root = repoRootAt(current);
63
+ if (root)
64
+ return root;
65
+ const parent = path.dirname(current);
66
+ if (parent === current)
67
+ break;
68
+ current = parent;
69
+ }
70
+ return dir;
71
+ }
72
+ /** The main repository root when `dir` holds a `.git`; `undefined` when it holds none. */
73
+ function repoRootAt(dir) {
74
+ const dotGit = path.join(dir, '.git');
75
+ let stat;
76
+ try {
77
+ stat = statSync(dotGit);
78
+ }
79
+ catch {
80
+ return undefined;
81
+ }
82
+ if (stat.isDirectory())
83
+ return dir;
84
+ if (!stat.isFile())
85
+ return undefined;
86
+ const gitDir = readGitDir(dotGit);
87
+ return (gitDir && mainRepoOf(gitDir)) ?? dir;
88
+ }
89
+ /** The `gitdir:` a `.git` file points at, resolved against the file's directory. */
90
+ function readGitDir(dotGitFile) {
91
+ try {
92
+ const match = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(dotGitFile, 'utf8'));
93
+ return match?.[1] ? path.resolve(path.dirname(dotGitFile), match[1]) : undefined;
94
+ }
95
+ catch {
96
+ return undefined;
97
+ }
98
+ }
99
+ /** A linked worktree's main repository root, from its gitdir; `undefined` for a submodule. */
100
+ function mainRepoOf(gitDir) {
101
+ let common;
102
+ try {
103
+ const relative = readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim();
104
+ if (relative)
105
+ common = path.resolve(gitDir, relative);
106
+ }
107
+ catch {
108
+ // No commondir: read the conventional `<repo>/.git/worktrees/<name>` layout.
109
+ const match = /^(.*)[\\/]\.git[\\/]worktrees[\\/][^\\/]+$/.exec(gitDir);
110
+ if (match?.[1])
111
+ return match[1];
112
+ }
113
+ if (!common)
114
+ return undefined;
115
+ // `<repo>/.git` → `<repo>`; a bare `<repo>.git` common dir names the repo itself.
116
+ if (path.basename(common) === '.git')
117
+ return path.dirname(common);
118
+ return common.replace(/\.git$/, '') || common;
119
+ }
120
+ /** The repository a removed worktree path belonged to, from the two layouts agents use. */
121
+ function removedWorktreeRepo(dir) {
122
+ const sep = '[\\\\/]';
123
+ const claude = new RegExp(`^(.*?)${sep}\\.claude${sep}worktrees${sep}[^\\\\/]+(?:${sep}.*)?$`).exec(dir);
124
+ if (claude?.[1])
125
+ return claude[1];
126
+ const sibling = new RegExp(`${sep}\\.worktrees${sep}([^\\\\/]+)${sep}[^\\\\/]+(?:${sep}.*)?$`).exec(dir);
127
+ if (sibling?.[1])
128
+ return sibling[1];
129
+ return undefined;
130
+ }
131
+ function isDirectory(dir) {
132
+ try {
133
+ return statSync(dir).isDirectory();
134
+ }
135
+ catch {
136
+ return false;
137
+ }
138
+ }
@@ -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,9 +23,19 @@
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 type { AccountRuntime } from '../agent/accounts.js';
38
+ export { projectName } from './project.js';
27
39
  /** How far back the first scan looks, and how long totals are kept locally. */
28
40
  export declare const RETENTION_DAYS = 30;
29
41
  /** Most rows in one `POST /v1/usage/token-totals`. */
@@ -54,7 +66,7 @@ interface CodexCursor {
54
66
  }
55
67
  type Cursor = ClaudeCursor | CodexCursor;
56
68
  export interface ScanState {
57
- version: 1;
69
+ version: 2;
58
70
  files: Record<string, Cursor>;
59
71
  /** Bucket key ({@link bucketKey}) → totals. */
60
72
  buckets: Record<string, Totals>;
@@ -64,11 +76,19 @@ export interface ScanState {
64
76
  dirty: string[];
65
77
  /** Pending pair → consecutive uploads the server only partly accepted. */
66
78
  strikes: Record<string, number>;
79
+ /**
80
+ * Pair → what the version-1 state had counted for it, for pairs whose
81
+ * server rows may still carry per-directory projects. An entry goes once
82
+ * the pair is replaced whole on the server, or is given up on.
83
+ */
84
+ legacy: Record<string, Totals>;
67
85
  }
68
86
  /** A dirty entry: one account's rows for one day. */
69
87
  export declare function pendingKey(day: string, accountKey: string): string;
70
88
  /** The day of a dirty entry. */
71
89
  export declare function pendingDay(entry: string): string;
90
+ /** The account key of a dirty entry. */
91
+ export declare function pendingAccount(entry: string): string;
72
92
  /** Days with anything pending, sorted. */
73
93
  export declare function pendingDays(state: ScanState): string[];
74
94
  export interface TokenRow {
@@ -93,14 +113,23 @@ export interface ScanSummary {
93
113
  touchedDays: string[];
94
114
  }
95
115
  export declare function emptyScanState(): ScanState;
116
+ /**
117
+ * Load the scan state. A version-1 state (per-directory projects) is replaced
118
+ * by an empty one that re-reads every log, keeping its per-pair totals as
119
+ * {@link ScanState.legacy}.
120
+ */
96
121
  export declare function loadScanState(file: string): ScanState;
122
+ /**
123
+ * Whether a re-attributed pair's rebuild counted less than version 1 had — a
124
+ * log rewritten or removed since, or a file that fell out of the window. Its
125
+ * server rows are then left as they are: mis-attributed, but the right total.
126
+ */
127
+ export declare function rebuildFellShort(rebuilt: Totals, legacy: Totals): boolean;
97
128
  export declare function saveScanState(file: string, state: ScanState): void;
98
129
  /** `YYYY-MM-DD` of an instant in a time zone (the machine's by default). */
99
130
  export declare function dayKey(ms: number, timeZone?: string): string;
100
131
  /** Bucket identity. JSON so it round-trips; the day is first so it can be read cheaply. */
101
132
  export declare function bucketKey(day: string, accountKey: string, runtime: AccountRuntime, model: string, project: string | null): string;
102
- /** Basename of a working directory — the repo or folder name, never the path. */
103
- export declare function projectName(cwd: unknown): string | null;
104
133
  /**
105
134
  * One incremental pass over every account's logs. Mutates and returns
106
135
  * ``state``; the caller persists it.
@@ -110,8 +139,20 @@ export declare function scanUsage(accounts: ScanAccount[], state: ScanState, opt
110
139
  export declare function rowsForDays(state: ScanState, days: Iterable<string>): TokenRow[];
111
140
  /** The rows for the given `day|accountKey` pairs, in upload shape. */
112
141
  export declare function rowsForPending(state: ScanState, pairs: Iterable<string>): TokenRow[];
113
- /** Split rows into upload batches. */
114
- export declare function batches<T>(rows: T[], size?: number): T[][];
142
+ /** One `POST /v1/usage/token-totals` body's rows, and what it says about each (day, account) pair. */
143
+ export interface UploadBatch {
144
+ rows: TokenRow[];
145
+ /** Pairs whose first row is here: sent as `completeDays`, so the server replaces each one whole. */
146
+ complete: string[];
147
+ /** Pairs whose last row is here: done once this batch is taken. */
148
+ last: string[];
149
+ }
150
+ /**
151
+ * Split rows into upload batches without splitting a (day, account) pair,
152
+ * so each body carries every row of the pairs it names complete. A pair
153
+ * larger than a batch is sent over consecutive batches: complete in the
154
+ * first (the server clears its rows, then stores these), added to by the rest.
155
+ */
156
+ export declare function uploadBatches(rows: TokenRow[], size?: number): UploadBatch[];
115
157
  /** Local totals per account since ``fromDay`` (inclusive), for `usage status`. */
116
158
  export declare function localTotals(state: ScanState, fromDay: string): Map<string, Totals>;
117
- export {};
@@ -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;
@@ -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
  }
@@ -17,11 +17,13 @@ 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 { batches, loadScanState, pendingKey, rowsForPending, saveScanState, scanUsage } from './scanner.js';
20
+ import { loadScanState, pendingAccount, pendingDay, 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. */
24
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;
25
27
  /** Partial uploads of one day's rows for one account before they are given up on. */
26
28
  export const MAX_PARTIAL_STRIKES = 3;
27
29
  /** Skips that will never turn into an acceptance from this machine. */
@@ -49,7 +51,13 @@ export function parseAcceptance(body, sent) {
49
51
  }
50
52
  }
51
53
  }
52
- 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
+ };
53
61
  }
54
62
  export function scanStatePath(env = process.env) {
55
63
  return path.join(usageStateDir(env), 'scan.json');
@@ -82,6 +90,8 @@ export class UsageService {
82
90
  prober;
83
91
  reportBackoff;
84
92
  totalsBackoff;
93
+ /** The last {@link serverReplacesDays} answer, for this process only. */
94
+ replaceProbe;
85
95
  scan;
86
96
  status;
87
97
  warned = new Set();
@@ -214,35 +224,89 @@ export class UsageService {
214
224
  * `not_reported`, costs a strike; after {@link MAX_PARTIAL_STRIKES} the pair
215
225
  * is given up on (logged once), so nothing is resent forever. Pairs whose
216
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.
217
239
  */
218
240
  async uploadDirty(api, machineId, machine) {
219
241
  const scan = this.scan;
220
242
  const rows = rowsForPending(scan, scan.dirty);
221
- const ready = rows.filter((r) => this.reportedKeys.has(r.accountKey));
243
+ let ready = rows.filter((r) => this.reportedKeys.has(r.accountKey));
222
244
  const done = new Set();
223
245
  const pendingWithRows = new Set(rows.map((r) => pendingKey(r.day, r.accountKey)));
224
246
  // Pending pairs with nothing to send (all-zero) are simply done.
225
247
  for (const pair of scan.dirty)
226
248
  if (!pendingWithRows.has(pair))
227
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
+ }
228
281
  let sent = 0;
229
282
  let gaveUp = 0;
230
- for (const batch of batches(ready)) {
231
- 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);
232
291
  this.note(this.totalsBackoff, response);
233
- const acceptance = response.ok ? parseAcceptance(response.body, batch.length) : { accepted: 0 };
234
- 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 };
235
294
  if (!response.ok)
236
295
  break;
237
296
  sent += acceptance.accepted;
297
+ const lastHere = new Set(batch.last);
238
298
  const pairs = new Map(); // pair → accountKey
239
- for (const row of batch)
299
+ for (const row of batch.rows)
240
300
  pairs.set(pendingKey(row.day, row.accountKey), row.accountKey);
241
301
  for (const [pair, accountKey] of pairs) {
242
- 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;
243
303
  if (taken) {
304
+ // A pair spread over several bodies is done with its last one.
305
+ if (!lastHere.has(pair))
306
+ continue;
244
307
  done.add(pair);
245
308
  delete scan.strikes[pair];
309
+ delete scan.legacy[pair];
246
310
  continue;
247
311
  }
248
312
  const reason = acceptance.skipped?.get(accountKey);
@@ -251,6 +315,7 @@ export class UsageService {
251
315
  if (reason && PERMANENT_SKIPS.has(reason)) {
252
316
  done.add(pair);
253
317
  delete scan.strikes[pair];
318
+ delete scan.legacy[pair];
254
319
  this.warnOnce(`totals_skip:${accountKey}:${reason}`, `token totals: account ${accountKey.slice(0, 8)}… skipped (${reason}); those rows will not be resent.`);
255
320
  continue;
256
321
  }
@@ -260,6 +325,7 @@ export class UsageService {
260
325
  if (strikes >= MAX_PARTIAL_STRIKES) {
261
326
  done.add(pair);
262
327
  delete scan.strikes[pair];
328
+ delete scan.legacy[pair];
263
329
  gaveUp += 1;
264
330
  }
265
331
  else {
@@ -275,6 +341,27 @@ export class UsageService {
275
341
  saveScanState(scanStatePath(this.env), scan);
276
342
  return sent;
277
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
+ }
278
365
  note(backoff, response) {
279
366
  const line = backoff.record(response);
280
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.1";
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.1";
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.1';
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.1",
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",