@prohost/cli 0.10.1 → 0.10.3

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,45 @@ 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.3
8
+
9
+ Two fixes to the 0.10.2 repository attribution.
10
+
11
+ - **A bare repository is named without `.git`.** A session whose working
12
+ directory is a bare repository (`tools.git`, with `HEAD`, `objects/` and
13
+ `refs/` and no `.git` inside), or a directory within it, counts toward
14
+ `tools`, the same name its linked worktrees get. A plain folder that only
15
+ ends in `.git` keeps its name.
16
+ - **Days 0.10.1 had not uploaded yet are not lost in the upgrade.** The rows
17
+ of any (day, login) still waiting to upload when the scan state was rebuilt
18
+ are sent as 0.10.1 had them, before any rebuilt row. A day whose log has
19
+ since been removed, or reads back smaller, still reaches the server with
20
+ the total 0.10.1 counted.
21
+
22
+ ## 0.10.2
23
+
24
+ Token totals are attributed to the repository, not the directory a session ran in.
25
+
26
+ - **Projects are repositories.** A session in a git worktree
27
+ (`.worktrees/backend-service/fix-something`, `.claude/worktrees/agent-…`) or
28
+ a subdirectory (`iosApp`, `Sources`) now counts toward its repository
29
+ (`backend-service`, `mobile`) in "By project". The repository is found from
30
+ the `.git` the directory sits under — a worktree's `gitdir` / `commondir`
31
+ lead back to the main repository — with no `git` process per line, and
32
+ cached per directory. A worktree that has since been removed is recognised by
33
+ its `…/.worktrees/<repo>/<name>` or `…/<repo>/.claude/worktrees/<name>`
34
+ path; anything else keeps its basename.
35
+ - **Days are replaced whole.** Each upload now names the (day, account) pairs
36
+ it carries in full (`completeDays`), and the server deletes that day's other
37
+ rows for the account, so a renamed project never counts twice.
38
+ - **The upgrade re-reads the last 30 days.** The local scan state is rebuilt
39
+ once with the new attribution and every day re-sent whole, which replaces
40
+ the old per-worktree rows. Those days wait until the server confirms it
41
+ replaces a day whole (an empty upload answered with `replacedDays`, asked at
42
+ most hourly), so an older server never counts them twice. A day that reads
43
+ back smaller than first counted (a log removed or rewritten since) is left
44
+ as it is on the server: per-folder, but with the right total.
45
+
7
46
  ## 0.10.1
8
47
 
9
48
  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,32 @@
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
+ * A bare repository (`HEAD`, `objects/`, `refs/` and no `.git`) is its own
16
+ * root, named without a `.git` suffix: `tools.git` is `tools`.
17
+ * 4. A directory that no longer exists (a removed worktree) is recognised by
18
+ * its layout — `…/.worktrees/<repo>/<name>` or `…/<repo>/.claude/worktrees/<name>`
19
+ * — and is otherwise named by its basename.
20
+ *
21
+ * Only the basename ever leaves this module, never the path. Results are
22
+ * cached per directory: the scanner asks once per log line, and the answer is
23
+ * a few `stat`s the first time and a map lookup after — never a `git` child
24
+ * process.
25
+ *
26
+ * The walk does what `git` itself does, so a directory under a dotfiles
27
+ * repository at `~` is attributed to it.
28
+ */
29
+ /** Forget every cached answer (tests; a long-running process never needs it). */
30
+ export declare function clearProjectCache(): void;
31
+ /** The repository name of a session's working directory, or `null` when it names none. */
32
+ export declare function projectName(cwd: unknown): string | null;
@@ -0,0 +1,155 @@
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
+ * A bare repository (`HEAD`, `objects/`, `refs/` and no `.git`) is its own
16
+ * root, named without a `.git` suffix: `tools.git` is `tools`.
17
+ * 4. A directory that no longer exists (a removed worktree) is recognised by
18
+ * its layout — `…/.worktrees/<repo>/<name>` or `…/<repo>/.claude/worktrees/<name>`
19
+ * — and is otherwise named by its basename.
20
+ *
21
+ * Only the basename ever leaves this module, never the path. Results are
22
+ * cached per directory: the scanner asks once per log line, and the answer is
23
+ * a few `stat`s the first time and a map lookup after — never a `git` child
24
+ * process.
25
+ *
26
+ * The walk does what `git` itself does, so a directory under a dotfiles
27
+ * repository at `~` is attributed to it.
28
+ */
29
+ import { readFileSync, statSync } from 'node:fs';
30
+ import path from 'node:path';
31
+ const MAX_PROJECT_CHARS = 120;
32
+ /** Directories remembered; far more than one machine's sessions ever name. */
33
+ const MAX_CACHED = 5000;
34
+ /** Deepest walk up from a directory looking for `.git`. */
35
+ const MAX_ANCESTORS = 64;
36
+ const cache = new Map();
37
+ /** Forget every cached answer (tests; a long-running process never needs it). */
38
+ export function clearProjectCache() {
39
+ cache.clear();
40
+ }
41
+ /** The repository name of a session's working directory, or `null` when it names none. */
42
+ export function projectName(cwd) {
43
+ if (typeof cwd !== 'string' || !cwd.trim())
44
+ return null;
45
+ const dir = cwd.trim().replace(/[\\/]+$/, '');
46
+ if (!dir)
47
+ return null;
48
+ const cached = cache.get(dir);
49
+ if (cached !== undefined)
50
+ return cached;
51
+ const base = path.basename(resolveProjectRoot(dir));
52
+ const name = base ? base.slice(0, MAX_PROJECT_CHARS) : null;
53
+ if (cache.size >= MAX_CACHED)
54
+ cache.clear();
55
+ cache.set(dir, name);
56
+ return name;
57
+ }
58
+ /** The directory whose basename names the project. */
59
+ function resolveProjectRoot(dir) {
60
+ if (!isDirectory(dir))
61
+ return removedWorktreeRepo(dir) ?? dir;
62
+ let current = dir;
63
+ for (let depth = 0; depth < MAX_ANCESTORS; depth += 1) {
64
+ const root = repoRootAt(current);
65
+ if (root)
66
+ return root;
67
+ const parent = path.dirname(current);
68
+ if (parent === current)
69
+ break;
70
+ current = parent;
71
+ }
72
+ return dir;
73
+ }
74
+ /** The main repository root when `dir` holds a `.git` or is a bare repository; `undefined` otherwise. */
75
+ function repoRootAt(dir) {
76
+ const dotGit = path.join(dir, '.git');
77
+ let stat;
78
+ try {
79
+ stat = statSync(dotGit);
80
+ }
81
+ catch {
82
+ return isBareRepo(dir) ? withoutGitSuffix(dir) : undefined;
83
+ }
84
+ if (stat.isDirectory())
85
+ return dir;
86
+ if (!stat.isFile())
87
+ return undefined;
88
+ const gitDir = readGitDir(dotGit);
89
+ return (gitDir && mainRepoOf(gitDir)) ?? dir;
90
+ }
91
+ /** The `gitdir:` a `.git` file points at, resolved against the file's directory. */
92
+ function readGitDir(dotGitFile) {
93
+ try {
94
+ const match = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(dotGitFile, 'utf8'));
95
+ return match?.[1] ? path.resolve(path.dirname(dotGitFile), match[1]) : undefined;
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ }
101
+ /** A linked worktree's main repository root, from its gitdir; `undefined` for a submodule. */
102
+ function mainRepoOf(gitDir) {
103
+ let common;
104
+ try {
105
+ const relative = readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim();
106
+ if (relative)
107
+ common = path.resolve(gitDir, relative);
108
+ }
109
+ catch {
110
+ // No commondir: read the conventional `<repo>/.git/worktrees/<name>` layout.
111
+ const match = /^(.*)[\\/]\.git[\\/]worktrees[\\/][^\\/]+$/.exec(gitDir);
112
+ if (match?.[1])
113
+ return match[1];
114
+ }
115
+ if (!common)
116
+ return undefined;
117
+ // `<repo>/.git` → `<repo>`; a bare `<repo>.git` common dir names the repo itself.
118
+ if (path.basename(common) === '.git')
119
+ return path.dirname(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
+ }
136
+ }
137
+ /** The repository a removed worktree path belonged to, from the two layouts agents use. */
138
+ function removedWorktreeRepo(dir) {
139
+ const sep = '[\\\\/]';
140
+ const claude = new RegExp(`^(.*?)${sep}\\.claude${sep}worktrees${sep}[^\\\\/]+(?:${sep}.*)?$`).exec(dir);
141
+ if (claude?.[1])
142
+ return claude[1];
143
+ const sibling = new RegExp(`${sep}\\.worktrees${sep}([^\\\\/]+)${sep}[^\\\\/]+(?:${sep}.*)?$`).exec(dir);
144
+ if (sibling?.[1])
145
+ return sibling[1];
146
+ return undefined;
147
+ }
148
+ function isDirectory(dir) {
149
+ try {
150
+ return statSync(dir).isDirectory();
151
+ }
152
+ catch {
153
+ return false;
154
+ }
155
+ }
@@ -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,26 @@ 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>;
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>;
67
92
  }
68
93
  /** A dirty entry: one account's rows for one day. */
69
94
  export declare function pendingKey(day: string, accountKey: string): string;
70
95
  /** The day of a dirty entry. */
71
96
  export declare function pendingDay(entry: string): string;
97
+ /** The account key of a dirty entry. */
98
+ export declare function pendingAccount(entry: string): string;
72
99
  /** Days with anything pending, sorted. */
73
100
  export declare function pendingDays(state: ScanState): string[];
74
101
  export interface TokenRow {
@@ -93,14 +120,24 @@ export interface ScanSummary {
93
120
  touchedDays: string[];
94
121
  }
95
122
  export declare function emptyScanState(): ScanState;
123
+ /**
124
+ * Load the scan state. A version-1 state (per-directory projects) is replaced
125
+ * by an empty one that re-reads every log, keeping its per-pair totals as
126
+ * {@link ScanState.legacy} and the rows it had not uploaded as
127
+ * {@link ScanState.legacyUnsent}.
128
+ */
96
129
  export declare function loadScanState(file: string): ScanState;
130
+ /**
131
+ * Whether a re-attributed pair's rebuild counted less than version 1 had — a
132
+ * log rewritten or removed since, or a file that fell out of the window. Its
133
+ * server rows are then left as they are: mis-attributed, but the right total.
134
+ */
135
+ export declare function rebuildFellShort(rebuilt: Totals, legacy: Totals): boolean;
97
136
  export declare function saveScanState(file: string, state: ScanState): void;
98
137
  /** `YYYY-MM-DD` of an instant in a time zone (the machine's by default). */
99
138
  export declare function dayKey(ms: number, timeZone?: string): string;
100
139
  /** Bucket identity. JSON so it round-trips; the day is first so it can be read cheaply. */
101
140
  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
141
  /**
105
142
  * One incremental pass over every account's logs. Mutates and returns
106
143
  * ``state``; the caller persists it.
@@ -108,10 +145,24 @@ export declare function projectName(cwd: unknown): string | null;
108
145
  export declare function scanUsage(accounts: ScanAccount[], state: ScanState, options?: ScanOptions): ScanSummary;
109
146
  /** The rows for the given days, in upload shape. Zero-token buckets are left out. */
110
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[];
111
150
  /** The rows for the given `day|accountKey` pairs, in upload shape. */
112
151
  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[][];
152
+ /** One `POST /v1/usage/token-totals` body's rows, and what it says about each (day, account) pair. */
153
+ export interface UploadBatch {
154
+ rows: TokenRow[];
155
+ /** Pairs whose first row is here: sent as `completeDays`, so the server replaces each one whole. */
156
+ complete: string[];
157
+ /** Pairs whose last row is here: done once this batch is taken. */
158
+ last: string[];
159
+ }
160
+ /**
161
+ * Split rows into upload batches without splitting a (day, account) pair,
162
+ * so each body carries every row of the pairs it names complete. A pair
163
+ * larger than a batch is sent over consecutive batches: complete in the
164
+ * first (the server clears its rows, then stores these), added to by the rest.
165
+ */
166
+ export declare function uploadBatches(rows: TokenRow[], size?: number): UploadBatch[];
115
167
  /** Local totals per account since ``fromDay`` (inclusive), for `usage status`. */
116
168
  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,117 @@ 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: {}, legacyUnsent: {} };
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
+ /** 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
+ }
53
125
  }
126
+ const totalsRecord = (value, validKey) => value && typeof value === 'object'
127
+ ? Object.fromEntries(Object.entries(value).filter(([key, t]) => validKey(key) && isTotals(t)))
128
+ : {};
129
+ /**
130
+ * Load the scan state. A version-1 state (per-directory projects) is replaced
131
+ * by an empty one that re-reads every log, keeping its per-pair totals as
132
+ * {@link ScanState.legacy} and the rows it had not uploaded as
133
+ * {@link ScanState.legacyUnsent}.
134
+ */
54
135
  export function loadScanState(file) {
136
+ let parsed;
55
137
  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
- };
138
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
67
139
  }
68
140
  catch {
69
141
  return emptyScanState();
70
142
  }
143
+ if (parsed?.version === 1) {
144
+ return {
145
+ ...emptyScanState(),
146
+ legacy: legacyTotals(parsed.buckets),
147
+ legacyUnsent: legacyUnsentBuckets(parsed.buckets, pairList(parsed.dirty)),
148
+ };
149
+ }
150
+ if (parsed?.version !== 2)
151
+ return emptyScanState();
152
+ return {
153
+ version: 2,
154
+ files: parsed.files && typeof parsed.files === 'object' ? parsed.files : {},
155
+ buckets: parsed.buckets && typeof parsed.buckets === 'object' ? parsed.buckets : {},
156
+ seen: parsed.seen && typeof parsed.seen === 'object' ? parsed.seen : {},
157
+ dirty: pairList(parsed.dirty),
158
+ strikes: parsed.strikes && typeof parsed.strikes === 'object' ? parsed.strikes : {},
159
+ legacy: totalsRecord(parsed.legacy, (pair) => pair.includes('|')),
160
+ legacyUnsent: totalsRecord(parsed.legacyUnsent, isBucketKey),
161
+ };
162
+ }
163
+ /**
164
+ * Whether a re-attributed pair's rebuild counted less than version 1 had — a
165
+ * log rewritten or removed since, or a file that fell out of the window. Its
166
+ * server rows are then left as they are: mis-attributed, but the right total.
167
+ */
168
+ export function rebuildFellShort(rebuilt, legacy) {
169
+ return rebuilt.some((n, i) => n < legacy[i]);
71
170
  }
72
171
  export function saveScanState(file, state) {
73
172
  writePrivateFile(file, JSON.stringify(state));
@@ -97,13 +196,6 @@ export function bucketKey(day, accountKey, runtime, model, project) {
97
196
  function dayOfBucket(key) {
98
197
  return key.slice(2, 12);
99
198
  }
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
199
  function asObject(value) {
108
200
  return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
109
201
  }
@@ -362,6 +454,13 @@ export function scanUsage(accounts, state, options = {}) {
362
454
  for (const pair of touchedPairs)
363
455
  delete state.strikes[pair];
364
456
  state.dirty = [...new Set([...state.dirty, ...touchedPairs])].filter((d) => pendingDay(d) >= pass.cutoffDay).sort();
457
+ for (const pair of Object.keys(state.legacy))
458
+ if (pendingDay(pair) < pass.cutoffDay)
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
+ }
365
464
  for (const pair of Object.keys(state.strikes))
366
465
  if (!state.dirty.includes(pair))
367
466
  delete state.strikes[pair];
@@ -390,9 +489,16 @@ function prune(state, cutoffDay, cutoffMs) {
390
489
  /** The rows for the given days, in upload shape. Zero-token buckets are left out. */
391
490
  export function rowsForDays(state, days) {
392
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
+ function bucketRows(buckets, include) {
393
499
  const rows = [];
394
- for (const [key, totals] of Object.entries(state.buckets)) {
395
- if (!wanted.has(dayOfBucket(key)) || isZero(totals))
500
+ for (const [key, totals] of Object.entries(buckets)) {
501
+ if (!include(key) || isZero(totals))
396
502
  continue;
397
503
  const [day, accountKey, runtime, model, project] = JSON.parse(key);
398
504
  rows.push({
@@ -415,11 +521,41 @@ export function rowsForPending(state, pairs) {
415
521
  const days = new Set([...wanted].map(pendingDay));
416
522
  return rowsForDays(state, days).filter((r) => wanted.has(pendingKey(r.day, r.accountKey)));
417
523
  }
418
- /** Split rows into upload batches. */
419
- export function batches(rows, size = MAX_ROWS_PER_BATCH) {
524
+ /**
525
+ * Split rows into upload batches without splitting a (day, account) pair,
526
+ * so each body carries every row of the pairs it names complete. A pair
527
+ * larger than a batch is sent over consecutive batches: complete in the
528
+ * first (the server clears its rows, then stores these), added to by the rest.
529
+ */
530
+ export function uploadBatches(rows, size = MAX_ROWS_PER_BATCH) {
531
+ const groups = new Map();
532
+ for (const row of rows) {
533
+ const pair = pendingKey(row.day, row.accountKey);
534
+ const group = groups.get(pair);
535
+ if (group)
536
+ group.push(row);
537
+ else
538
+ groups.set(pair, [row]);
539
+ }
420
540
  const out = [];
421
- for (let i = 0; i < rows.length; i += size)
422
- out.push(rows.slice(i, i + size));
541
+ let current;
542
+ for (const [pair, group] of groups) {
543
+ if (current && current.rows.length + group.length <= size) {
544
+ current.rows.push(...group);
545
+ current.complete.push(pair);
546
+ current.last.push(pair);
547
+ continue;
548
+ }
549
+ for (let start = 0; start < group.length; start += size) {
550
+ const chunk = group.slice(start, start + size);
551
+ current = {
552
+ rows: chunk,
553
+ complete: start === 0 ? [pair] : [],
554
+ last: start + size >= group.length ? [pair] : [],
555
+ };
556
+ out.push(current);
557
+ }
558
+ }
423
559
  return out;
424
560
  }
425
561
  /** 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,39 @@ 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.
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.
113
140
  */
114
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;
149
+ /**
150
+ * Whether the server deletes the rows a complete day no longer carries: an
151
+ * empty body is answered with `replacedDays` only by one that does. A yes
152
+ * is kept for the process; a no (or a failed call) is asked again after
153
+ * {@link REPLACE_PROBE_INTERVAL_MS}.
154
+ */
155
+ private serverReplacesDays;
115
156
  private note;
116
157
  private callRecord;
117
158
  }
@@ -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, 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. */
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();
@@ -183,7 +193,8 @@ export class UsageService {
183
193
  // The state can run to megabytes; rewrite it only when a file was read.
184
194
  if (scanned.filesRead > 0)
185
195
  saveScanState(scanStatePath(this.env), this.scan);
186
- 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) {
187
198
  result.uploadedRows = await this.uploadDirty(api, machineId, machine);
188
199
  }
189
200
  }
@@ -214,35 +225,98 @@ export class UsageService {
214
225
  * `not_reported`, costs a strike; after {@link MAX_PARTIAL_STRIKES} the pair
215
226
  * is given up on (logged once), so nothing is resent forever. Pairs whose
216
227
  * account has never been reported wait without a strike, and are not sent.
228
+ *
229
+ * Every pair goes out as `completeDays`, never split across bodies unless
230
+ * it alone outgrows one: a server that understands it deletes the pair's
231
+ * rows the body no longer carries and says so with `replacedDays`.
232
+ *
233
+ * A pair re-attributed by the version-2 upgrade (`scan.legacy`) still has
234
+ * per-directory rows on the server, so sending it to a server that only
235
+ * upserts would count it twice. It is held until a probe (an empty body)
236
+ * comes back with `replacedDays`, asked again at most every
237
+ * {@link REPLACE_PROBE_INTERVAL_MS}; other pairs go out meanwhile. A pair
238
+ * whose rebuild counted less than version 1 had (a log removed or rewritten
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.
217
245
  */
218
246
  async uploadDirty(api, machineId, machine) {
219
247
  const scan = this.scan;
220
248
  const rows = rowsForPending(scan, scan.dirty);
221
- const ready = rows.filter((r) => this.reportedKeys.has(r.accountKey));
249
+ let ready = rows.filter((r) => this.reportedKeys.has(r.accountKey));
222
250
  const done = new Set();
223
251
  const pendingWithRows = new Set(rows.map((r) => pendingKey(r.day, r.accountKey)));
224
252
  // Pending pairs with nothing to send (all-zero) are simply done.
225
253
  for (const pair of scan.dirty)
226
254
  if (!pendingWithRows.has(pair))
227
255
  done.add(pair);
256
+ if (!(await this.uploadLegacyUnsent(api, machineId, machine))) {
257
+ saveScanState(scanStatePath(this.env), scan);
258
+ return 0;
259
+ }
260
+ const legacyReady = new Set(ready.map((r) => pendingKey(r.day, r.accountKey)).filter((pair) => scan.legacy[pair] !== undefined));
261
+ if (legacyReady.size > 0) {
262
+ if (!(await this.serverReplacesDays(api, machineId, machine))) {
263
+ ready = ready.filter((r) => !legacyReady.has(pendingKey(r.day, r.accountKey)));
264
+ }
265
+ else {
266
+ const rebuilt = new Map();
267
+ for (const r of ready) {
268
+ const pair = pendingKey(r.day, r.accountKey);
269
+ if (!legacyReady.has(pair))
270
+ continue;
271
+ const sum = rebuilt.get(pair) ?? [0, 0, 0, 0];
272
+ rebuilt.set(pair, [
273
+ sum[0] + r.inputTokens,
274
+ sum[1] + r.cachedInputTokens,
275
+ sum[2] + r.cacheWriteTokens,
276
+ sum[3] + r.outputTokens,
277
+ ]);
278
+ }
279
+ const short = new Set([...rebuilt].filter(([pair, t]) => rebuildFellShort(t, scan.legacy[pair])).map(([p]) => p));
280
+ for (const pair of short) {
281
+ done.add(pair);
282
+ delete scan.legacy[pair];
283
+ }
284
+ if (short.size > 0) {
285
+ ready = ready.filter((r) => !short.has(pendingKey(r.day, r.accountKey)));
286
+ this.warnOnce('legacy_short', `token totals: ${short.size} earlier day(s) read back smaller than first counted (logs removed or ` +
287
+ 'rewritten since); they keep their per-folder projects on the server so their totals stay right.');
288
+ }
289
+ }
290
+ }
228
291
  let sent = 0;
229
292
  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);
293
+ for (const batch of uploadBatches(ready)) {
294
+ const body = {
295
+ machineId,
296
+ machine,
297
+ days: batch.rows,
298
+ completeDays: batch.complete.map((pair) => ({ day: pendingDay(pair), accountKey: pendingAccount(pair) })),
299
+ };
300
+ const response = await postJson(api, TOKEN_TOTALS_PATH, body, undefined, true);
232
301
  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 };
302
+ const acceptance = response.ok ? parseAcceptance(response.body, batch.rows.length) : { accepted: 0 };
303
+ this.status.lastTotals = { ...this.callRecord(response, batch.rows.length), accepted: acceptance.accepted };
235
304
  if (!response.ok)
236
305
  break;
237
306
  sent += acceptance.accepted;
307
+ const lastHere = new Set(batch.last);
238
308
  const pairs = new Map(); // pair → accountKey
239
- for (const row of batch)
309
+ for (const row of batch.rows)
240
310
  pairs.set(pendingKey(row.day, row.accountKey), row.accountKey);
241
311
  for (const [pair, accountKey] of pairs) {
242
- const taken = acceptance.keys ? acceptance.keys.has(accountKey) : acceptance.accepted >= batch.length;
312
+ const taken = acceptance.keys ? acceptance.keys.has(accountKey) : acceptance.accepted >= batch.rows.length;
243
313
  if (taken) {
314
+ // A pair spread over several bodies is done with its last one.
315
+ if (!lastHere.has(pair))
316
+ continue;
244
317
  done.add(pair);
245
318
  delete scan.strikes[pair];
319
+ delete scan.legacy[pair];
246
320
  continue;
247
321
  }
248
322
  const reason = acceptance.skipped?.get(accountKey);
@@ -251,6 +325,7 @@ export class UsageService {
251
325
  if (reason && PERMANENT_SKIPS.has(reason)) {
252
326
  done.add(pair);
253
327
  delete scan.strikes[pair];
328
+ delete scan.legacy[pair];
254
329
  this.warnOnce(`totals_skip:${accountKey}:${reason}`, `token totals: account ${accountKey.slice(0, 8)}… skipped (${reason}); those rows will not be resent.`);
255
330
  continue;
256
331
  }
@@ -260,6 +335,7 @@ export class UsageService {
260
335
  if (strikes >= MAX_PARTIAL_STRIKES) {
261
336
  done.add(pair);
262
337
  delete scan.strikes[pair];
338
+ delete scan.legacy[pair];
263
339
  gaveUp += 1;
264
340
  }
265
341
  else {
@@ -275,6 +351,62 @@ export class UsageService {
275
351
  saveScanState(scanStatePath(this.env), scan);
276
352
  return sent;
277
353
  }
354
+ /**
355
+ * Upload the version-1 rows version 1 had not uploaded, as plain rows (the
356
+ * upsert version 1 would have sent). A pair's rows go once the server takes
357
+ * them or never will (a permanent skip); `not_reported` waits for a report.
358
+ * Returns `false` when a call failed, so nothing else is sent this tick.
359
+ */
360
+ async uploadLegacyUnsent(api, machineId, machine) {
361
+ const scan = this.scan;
362
+ const rows = legacyUnsentRows(scan).filter((r) => this.reportedKeys.has(r.accountKey));
363
+ for (const batch of uploadBatches(rows)) {
364
+ const body = { machineId, machine, days: batch.rows };
365
+ const response = await postJson(api, TOKEN_TOTALS_PATH, body, undefined, true);
366
+ this.note(this.totalsBackoff, response);
367
+ const acceptance = response.ok ? parseAcceptance(response.body, batch.rows.length) : { accepted: 0 };
368
+ this.status.lastTotals = { ...this.callRecord(response, batch.rows.length), accepted: acceptance.accepted };
369
+ if (!response.ok)
370
+ return false;
371
+ const settled = new Set();
372
+ for (const row of batch.rows) {
373
+ const pair = pendingKey(row.day, row.accountKey);
374
+ const taken = acceptance.keys ? acceptance.keys.has(row.accountKey) : acceptance.accepted >= batch.rows.length;
375
+ const reason = acceptance.skipped?.get(row.accountKey);
376
+ if ((taken && batch.last.includes(pair)) || (reason && PERMANENT_SKIPS.has(reason)))
377
+ settled.add(pair);
378
+ else if (!taken && reason !== 'throttled')
379
+ this.reportedKeys.delete(row.accountKey);
380
+ }
381
+ for (const key of Object.keys(scan.legacyUnsent)) {
382
+ const [day, accountKey] = JSON.parse(key);
383
+ if (settled.has(pendingKey(day, accountKey)))
384
+ delete scan.legacyUnsent[key];
385
+ }
386
+ }
387
+ return true;
388
+ }
389
+ /**
390
+ * Whether the server deletes the rows a complete day no longer carries: an
391
+ * empty body is answered with `replacedDays` only by one that does. A yes
392
+ * is kept for the process; a no (or a failed call) is asked again after
393
+ * {@link REPLACE_PROBE_INTERVAL_MS}.
394
+ */
395
+ async serverReplacesDays(api, machineId, machine) {
396
+ if (this.replaceProbe?.supported)
397
+ return true;
398
+ if (this.replaceProbe && this.now() - this.replaceProbe.at < REPLACE_PROBE_INTERVAL_MS)
399
+ return false;
400
+ const response = await postJson(api, TOKEN_TOTALS_PATH, { machineId, machine, days: [], completeDays: [] }, undefined, true);
401
+ this.note(this.totalsBackoff, response);
402
+ const supported = response.ok && parseAcceptance(response.body, 0).replacedDays !== undefined;
403
+ this.replaceProbe = { supported, at: this.now() };
404
+ if (!supported && response.ok) {
405
+ this.warnOnce('replace_unsupported', 'token totals: the server does not replace a day whole yet; days counted under per-folder projects ' +
406
+ 'wait until it does, so nothing is counted twice.');
407
+ }
408
+ return supported;
409
+ }
278
410
  note(backoff, response) {
279
411
  const line = backoff.record(response);
280
412
  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.3";
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.3";
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.3';
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.3",
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",