@prohost/cli 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,12 +11,14 @@ import process from 'node:process';
11
11
  import { existsSync } from 'node:fs';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { DEFAULT_ACCOUNT_LABEL, loadAccounts, runtimeName } from '../agent/accounts.js';
14
+ import { baseUrlProblem } from '../agent/base-url.js';
14
15
  import { daemonEnvironment } from '../agent/command.js';
15
16
  import { maskSecret, prohostHome } from '../agent/credentials.js';
16
17
  import { buildSystemdUnit, installLaunchdDaemon, launchdLabel, launchdPlistPath, uninstallLaunchdDaemon, } from '../agent/daemon.js';
17
18
  import { acquireRunLock } from '../agent/lock.js';
18
19
  import { ensurePrivateDir, resolveUsageKey, saveUsageCredentials, usageStateDir } from './config.js';
19
20
  import { accountConfigDir, discoverAccounts } from './discovery.js';
21
+ import { PipedKeyTooLongError, promptHiddenKey, readPipedKey } from './key-input.js';
20
22
  import { RETENTION_DAYS, dayKey, loadScanState, localTotals, pendingDays } from './scanner.js';
21
23
  import { UsageService, loadStatus, runUsageLoop, scanStatePath } from './service.js';
22
24
  const defaultIO = {
@@ -83,21 +85,77 @@ export function renderDiscovery(result, env, dryRun) {
83
85
  lines.push(row('!', s.label, s.runtime, s.dir, `skipped: ${s.reason}`));
84
86
  return lines;
85
87
  }
88
+ /**
89
+ * The key `connect` should store, in order: `--key -` (stdin, or a hidden
90
+ * prompt at a terminal), a literal `--key <value>` (warned about), then
91
+ * `$PROHOST_API_KEY`, then piped stdin, then — at a terminal, with no
92
+ * credential to fall back on — a hidden prompt. `undefined` means "none given";
93
+ * `null` means a usage error that has already been printed.
94
+ */
95
+ async function connectKey(flags, io, env) {
96
+ const stdin = io.stdin ?? process.stdin;
97
+ const prompt = () => promptHiddenKey(stdin, (text) => process.stderr.write(text));
98
+ const literal = flagString(flags, 'key');
99
+ if (literal === '-') {
100
+ const key = stdin.isTTY ? await prompt() : await readPipedKey(stdin);
101
+ if (!key)
102
+ io.err('✗ --key - got no key. Pipe one in (pbpaste | prohost usage connect --key -) or type it at the prompt.');
103
+ return key ?? null;
104
+ }
105
+ if (literal) {
106
+ io.err('! --key <value> leaves the key in your shell history and the process list. ' +
107
+ 'Next time pipe it instead: pbpaste | prohost usage connect --key -');
108
+ return literal.trim();
109
+ }
110
+ if (env.PROHOST_API_KEY)
111
+ return env.PROHOST_API_KEY.trim();
112
+ if (!stdin.isTTY)
113
+ return readPipedKey(stdin);
114
+ // At a terminal: prompt only when there is nothing to fall back on, so a
115
+ // connected machine (or one with a paired agent) is not asked again.
116
+ if (resolveUsageKey(env))
117
+ return undefined;
118
+ return prompt();
119
+ }
86
120
  async function connectCommand(flags, io) {
87
121
  const env = io.env ?? process.env;
88
122
  if (flags.key === true) {
89
- io.err('--key needs a value. Create a key with the usage:write scope in ProhostAI → Settings → API keys.');
123
+ io.err('--key needs a value. Use --key - to read it from stdin (pbpaste | prohost usage connect --key -), ' +
124
+ 'or leave --key off to be prompted. Create a key with the usage:write scope in ProhostAI → Settings → API keys.');
125
+ return 1;
126
+ }
127
+ if (flags['base-url'] === true) {
128
+ io.err('--base-url needs a value, e.g. --base-url https://connect.prohost.ai');
129
+ return 1;
130
+ }
131
+ // Checked before any key is read or stored: the key goes to this URL.
132
+ const baseUrl = flagString(flags, 'base-url');
133
+ const problem = baseUrl ? baseUrlProblem(baseUrl) : undefined;
134
+ if (problem) {
135
+ io.err(`✗ --base-url rejected: ${problem}`);
136
+ return 1;
137
+ }
138
+ let key;
139
+ try {
140
+ key = await connectKey(flags, io, env);
141
+ }
142
+ catch (err) {
143
+ if (!(err instanceof PipedKeyTooLongError))
144
+ throw err;
145
+ io.err('✗ The piped input is not an API key (no line ended within 4096 characters). Nothing was saved.');
90
146
  return 1;
91
147
  }
92
- const key = flagString(flags, 'key') ?? env.PROHOST_API_KEY;
148
+ if (key === null)
149
+ return 1;
93
150
  if (key) {
94
- const file = saveUsageCredentials({ apiKey: key.trim(), baseUrl: flagString(flags, 'base-url') }, env);
95
- io.out(`✓ Saved key ${maskSecret(key.trim())} to ${file} (owner-only).`);
151
+ const file = saveUsageCredentials({ apiKey: key, baseUrl }, env);
152
+ io.out(`✓ Saved key ${maskSecret(key)} to ${file} (owner-only).`);
96
153
  }
97
154
  else {
98
155
  const existing = resolveUsageKey(env);
99
156
  if (!existing) {
100
- io.err('✗ No API key. Pass --key <key> (scope usage:write) or set $PROHOST_API_KEY. ' +
157
+ io.err('✗ No API key. Run prohost usage connect at a terminal to be prompted, pipe one in with --key -, ' +
158
+ 'or set $PROHOST_API_KEY (scope usage:write). ' +
101
159
  'A machine with a paired agent can skip this — its credential is used.');
102
160
  return 1;
103
161
  }
@@ -112,6 +170,26 @@ async function connectCommand(flags, io) {
112
170
  io.out('Report now and every 5 minutes: prohost usage start (or keep it running: prohost usage install-daemon)');
113
171
  return 0;
114
172
  }
173
+ /**
174
+ * The stored key, when it can be sent: present, and its base URL is https://
175
+ * (or loopback http://). Prints why not and returns `null` otherwise.
176
+ */
177
+ function usableKey(env, io) {
178
+ const key = resolveUsageKey(env);
179
+ if (!key) {
180
+ io.err('✗ Not connected. Run: prohost usage connect');
181
+ return null;
182
+ }
183
+ const problem = baseUrlProblem(key.baseUrl);
184
+ if (problem) {
185
+ io.err(`✗ Refusing to send the API key: the stored API base URL is unusable. ${problem} ` +
186
+ (key.source === 'agent'
187
+ ? 'Re-pair the agent with an https:// --base-url.'
188
+ : 'Reconnect: prohost usage connect --key - --base-url https://…'));
189
+ return null;
190
+ }
191
+ return key;
192
+ }
115
193
  function discoverCommand(flags, io) {
116
194
  const env = io.env ?? process.env;
117
195
  const dryRun = flags['dry-run'] === true;
@@ -121,10 +199,8 @@ function discoverCommand(flags, io) {
121
199
  }
122
200
  async function startCommand(flags, io) {
123
201
  const env = io.env ?? process.env;
124
- if (!resolveUsageKey(env)) {
125
- io.err('✗ Not connected. Run: prohost usage connect --key <api key>');
202
+ if (!usableKey(env, io))
126
203
  return 1;
127
- }
128
204
  // Its own lock, under $PROHOST_HOME/usage — `agent run` holds the home's.
129
205
  const stateDir = ensurePrivateDir(usageStateDir(env));
130
206
  const lock = acquireRunLock({ ...env, PROHOST_HOME: stateDir });
@@ -163,10 +239,8 @@ function cliEntrypoint() {
163
239
  }
164
240
  function installCommand(flags, io) {
165
241
  const env = io.env ?? process.env;
166
- if (!resolveUsageKey(env)) {
167
- io.err('✗ Not connected. Run: prohost usage connect --key <api key>');
242
+ if (!usableKey(env, io))
168
243
  return 1;
169
- }
170
244
  const workdir = ensurePrivateDir(usageStateDir(env));
171
245
  const spec = {
172
246
  label: usageLaunchdLabel(prohostHome(env)),
@@ -233,8 +307,9 @@ function statusCommand(io) {
233
307
  const env = io.env ?? process.env;
234
308
  const key = resolveUsageKey(env);
235
309
  io.out(key
236
- ? `Connected ${maskSecret(key.apiKey)} → ${key.baseUrl}${key.source === 'agent' ? ' (paired agent credential)' : ''}`
237
- : 'Connected no — run: prohost usage connect --key <api key>');
310
+ ? `Connected ${maskSecret(key.apiKey)} → ${key.baseUrl}${key.source === 'agent' ? ' (paired agent credential)' : ''}` +
311
+ `${baseUrlProblem(key.baseUrl) ? ' (not https:// — nothing is sent)' : ''}`
312
+ : 'Connected no — run: prohost usage connect');
238
313
  const plist = launchdPlistPath(usageLaunchdLabel(prohostHome(env)));
239
314
  io.out(`Daemon ${os.platform() === 'darwin' ? (existsSync(plist) ? `installed (${tildify(plist, env)})` : 'not installed') : 'see systemd'}`);
240
315
  const status = loadStatus(env);
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Reading an API key for `prohost usage connect` without putting it on the
3
+ * command line, where it would land in shell history and the process list.
4
+ *
5
+ * Two safe paths: piped stdin (`pbpaste | prohost usage connect --key -`) and,
6
+ * at a terminal, a prompt that does not echo what is typed or pasted.
7
+ */
8
+ /** The slice of `process.stdin` this module uses, so tests can pass a stream. */
9
+ export interface KeyStdin extends AsyncIterable<string | Buffer> {
10
+ isTTY?: boolean;
11
+ setRawMode?(mode: boolean): unknown;
12
+ setEncoding(encoding: BufferEncoding): unknown;
13
+ on(event: 'data', listener: (chunk: string | Buffer) => void): unknown;
14
+ removeListener(event: 'data', listener: (chunk: string | Buffer) => void): unknown;
15
+ resume(): unknown;
16
+ pause(): unknown;
17
+ }
18
+ /** Longer than any ProhostAI key; a pipe that runs past it is not a key. */
19
+ export declare const MAX_PIPED_KEY_CHARS = 4096;
20
+ export declare class PipedKeyTooLongError extends Error {
21
+ }
22
+ /**
23
+ * The first non-empty line of piped stdin, trimmed; `undefined` when there is
24
+ * none. Returns as soon as that line ends, so a producer that keeps the pipe
25
+ * open does not hang `connect`, and stops reading past
26
+ * {@link MAX_PIPED_KEY_CHARS}.
27
+ *
28
+ * @throws PipedKeyTooLongError when no line ends within the limit.
29
+ */
30
+ export declare function readPipedKey(stdin: KeyStdin): Promise<string | undefined>;
31
+ /**
32
+ * Prompt for a key at a terminal without echoing it. Resolves with the
33
+ * trimmed key, or `undefined` when nothing was entered or the user pressed
34
+ * Ctrl-C. The prompt goes to `write` (stderr), keeping stdout clean.
35
+ */
36
+ export declare function promptHiddenKey(stdin: KeyStdin, write: (text: string) => void, prompt?: string): Promise<string | undefined>;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Reading an API key for `prohost usage connect` without putting it on the
3
+ * command line, where it would land in shell history and the process list.
4
+ *
5
+ * Two safe paths: piped stdin (`pbpaste | prohost usage connect --key -`) and,
6
+ * at a terminal, a prompt that does not echo what is typed or pasted.
7
+ */
8
+ /** Longer than any ProhostAI key; a pipe that runs past it is not a key. */
9
+ export const MAX_PIPED_KEY_CHARS = 4096;
10
+ export class PipedKeyTooLongError extends Error {
11
+ }
12
+ /**
13
+ * The first non-empty line of piped stdin, trimmed; `undefined` when there is
14
+ * none. Returns as soon as that line ends, so a producer that keeps the pipe
15
+ * open does not hang `connect`, and stops reading past
16
+ * {@link MAX_PIPED_KEY_CHARS}.
17
+ *
18
+ * @throws PipedKeyTooLongError when no line ends within the limit.
19
+ */
20
+ export async function readPipedKey(stdin) {
21
+ let pending = '';
22
+ for await (const chunk of stdin) {
23
+ pending += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
24
+ let newline = pending.search(/\r?\n/);
25
+ while (newline !== -1) {
26
+ // Measured before trimming: padding counts toward the bound too.
27
+ const raw = pending.slice(0, newline);
28
+ if (raw.length > MAX_PIPED_KEY_CHARS)
29
+ throw new PipedKeyTooLongError('piped input is not a key');
30
+ const line = raw.trim();
31
+ if (line)
32
+ return line;
33
+ pending = pending.slice(newline).replace(/^\r?\n/, '');
34
+ newline = pending.search(/\r?\n/);
35
+ }
36
+ if (pending.length > MAX_PIPED_KEY_CHARS)
37
+ throw new PipedKeyTooLongError('piped input is not a key');
38
+ }
39
+ if (pending.length > MAX_PIPED_KEY_CHARS)
40
+ throw new PipedKeyTooLongError('piped input is not a key');
41
+ return pending.trim() || undefined;
42
+ }
43
+ const ENTER = new Set(['\r', '\n', '\u0004']); // Return, or Ctrl-D
44
+ const CTRL_C = '\u0003';
45
+ const BACKSPACE = new Set(['\u007f', '\b']);
46
+ /**
47
+ * Prompt for a key at a terminal without echoing it. Resolves with the
48
+ * trimmed key, or `undefined` when nothing was entered or the user pressed
49
+ * Ctrl-C. The prompt goes to `write` (stderr), keeping stdout clean.
50
+ */
51
+ export function promptHiddenKey(stdin, write, prompt = 'API key (usage:write scope; input hidden): ') {
52
+ return new Promise((resolve) => {
53
+ let value = '';
54
+ const finish = (result) => {
55
+ stdin.removeListener('data', onData);
56
+ stdin.setRawMode?.(false);
57
+ stdin.pause();
58
+ write('\n');
59
+ resolve(result);
60
+ };
61
+ const onData = (chunk) => {
62
+ for (const ch of typeof chunk === 'string' ? chunk : chunk.toString('utf8')) {
63
+ if (ENTER.has(ch))
64
+ return finish(value.trim() || undefined);
65
+ if (ch === CTRL_C)
66
+ return finish(undefined);
67
+ if (BACKSPACE.has(ch))
68
+ value = value.slice(0, -1);
69
+ else if (ch >= ' ')
70
+ value += ch;
71
+ }
72
+ };
73
+ write(prompt);
74
+ stdin.setRawMode?.(true);
75
+ stdin.setEncoding('utf8');
76
+ stdin.on('data', onData);
77
+ stdin.resume();
78
+ });
79
+ }
@@ -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 {};