@phnx-labs/agents-cli 1.20.17 → 1.20.19

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +1 -1
  3. package/dist/commands/budget.d.ts +14 -0
  4. package/dist/commands/budget.js +137 -0
  5. package/dist/commands/cost.d.ts +12 -0
  6. package/dist/commands/cost.js +139 -0
  7. package/dist/commands/exec.d.ts +20 -0
  8. package/dist/commands/exec.js +382 -5
  9. package/dist/commands/secrets.d.ts +15 -0
  10. package/dist/commands/secrets.js +343 -16
  11. package/dist/commands/sessions.js +4 -0
  12. package/dist/index.js +4 -0
  13. package/dist/lib/budget/config.d.ts +9 -0
  14. package/dist/lib/budget/config.js +115 -0
  15. package/dist/lib/budget/enforce.d.ts +94 -0
  16. package/dist/lib/budget/enforce.js +151 -0
  17. package/dist/lib/budget/ledger.d.ts +61 -0
  18. package/dist/lib/budget/ledger.js +107 -0
  19. package/dist/lib/budget/preflight.d.ts +110 -0
  20. package/dist/lib/budget/preflight.js +200 -0
  21. package/dist/lib/checkpoint.d.ts +54 -0
  22. package/dist/lib/checkpoint.js +56 -0
  23. package/dist/lib/cloud/rush.js +18 -0
  24. package/dist/lib/exec.d.ts +36 -0
  25. package/dist/lib/exec.js +192 -4
  26. package/dist/lib/git.d.ts +18 -0
  27. package/dist/lib/git.js +67 -4
  28. package/dist/lib/loop.d.ts +145 -0
  29. package/dist/lib/loop.js +330 -0
  30. package/dist/lib/mcp.d.ts +7 -0
  31. package/dist/lib/mcp.js +24 -0
  32. package/dist/lib/models.d.ts +11 -0
  33. package/dist/lib/models.js +21 -0
  34. package/dist/lib/plugins.js +5 -2
  35. package/dist/lib/pricing/cost.d.ts +46 -0
  36. package/dist/lib/pricing/cost.js +71 -0
  37. package/dist/lib/pricing/index.d.ts +8 -0
  38. package/dist/lib/pricing/index.js +8 -0
  39. package/dist/lib/pricing/prices.json +138 -0
  40. package/dist/lib/pricing/table.d.ts +17 -0
  41. package/dist/lib/pricing/table.js +73 -0
  42. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  43. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  44. package/dist/lib/secrets/agent.d.ts +147 -0
  45. package/dist/lib/secrets/agent.js +500 -0
  46. package/dist/lib/secrets/bundles.d.ts +58 -7
  47. package/dist/lib/secrets/bundles.js +264 -75
  48. package/dist/lib/secrets/filestore.d.ts +82 -0
  49. package/dist/lib/secrets/filestore.js +295 -0
  50. package/dist/lib/secrets/linux.d.ts +6 -24
  51. package/dist/lib/secrets/linux.js +22 -265
  52. package/dist/lib/session/db.d.ts +40 -0
  53. package/dist/lib/session/db.js +84 -2
  54. package/dist/lib/session/discover.d.ts +2 -0
  55. package/dist/lib/session/discover.js +126 -2
  56. package/dist/lib/session/render.d.ts +2 -0
  57. package/dist/lib/session/render.js +1 -1
  58. package/dist/lib/session/types.d.ts +4 -0
  59. package/dist/lib/teams/agents.d.ts +32 -0
  60. package/dist/lib/teams/agents.js +66 -3
  61. package/dist/lib/teams/api.js +20 -0
  62. package/dist/lib/teams/parsers.js +16 -4
  63. package/dist/lib/types.d.ts +48 -0
  64. package/dist/lib/workflows.d.ts +56 -0
  65. package/dist/lib/workflows.js +72 -5
  66. package/package.json +2 -1
@@ -7,24 +7,21 @@
7
7
  * (common on server-class Linux — no graphical login means the keyring
8
8
  * passphrase never enters the daemon, so `secret-tool store` fails with
9
9
  * "Cannot create an item in a locked collection"), we transparently switch
10
- * to a file-based AES-256-GCM encrypted store under
11
- * `~/.agents/.cache/secrets/`. The encryption key is scrypt-derived from a
12
- * passphrase read from `AGENTS_SECRETS_PASSPHRASE` (preferred) or a TTY
13
- * prompt. The decision is cached per process; one stderr line is emitted
14
- * the first time the fallback activates.
10
+ * to the AES-256-GCM encrypted-file store in ./filestore.ts. The decision is
11
+ * cached per process; one stderr line is emitted the first time the fallback
12
+ * activates.
15
13
  *
16
14
  * Secrets stored via secret-tool use:
17
15
  * service = "agents-cli"
18
16
  * account = username
19
17
  * item = the secret identifier
20
- *
21
- * File-fallback layout: one `<item>.enc` JSON file per item, mode 0600.
22
18
  */
23
- import { spawnSync, execSync } from 'child_process';
24
- import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
25
- import * as fs from 'fs';
19
+ import { spawnSync } from 'child_process';
26
20
  import * as os from 'os';
27
- import * as path from 'path';
21
+ import { fileStore, fileDir, fileStoreHasItems, machinePassphraseExists, _resetFileStoreForTest, } from './filestore.js';
22
+ // Re-exported so existing importers (and tests) can keep reaching these via
23
+ // './linux.js'. The implementations live in ./filestore.ts.
24
+ export { encryptForFallback, decryptForFallback, fileBackend, } from './filestore.js';
28
25
  const SERVICE = 'agents-cli';
29
26
  // ---------- secret-tool availability ----------
30
27
  function secretToolAvailable() {
@@ -38,12 +35,6 @@ let isAvailable = false;
38
35
  // ---------- file fallback state ----------
39
36
  let useFileFallback = false;
40
37
  let warnedFallback = false;
41
- let warnedAutoPassphrase = false;
42
- let fileDirOverride = null;
43
- let cachedPassphrase = null;
44
- function fileDir() {
45
- return fileDirOverride ?? path.join(os.homedir(), '.agents', '.cache', 'secrets');
46
- }
47
38
  function activateFileFallback() {
48
39
  if (useFileFallback)
49
40
  return;
@@ -57,18 +48,6 @@ function isLockedCollectionError(stderr) {
57
48
  return /locked collection/i.test(stderr) ||
58
49
  /Prompt was dismissed/i.test(stderr);
59
50
  }
60
- /** True if the fallback dir has any committed encrypted items. Means an
61
- * earlier process (this one or another) already routed writes to the file
62
- * store, so this process must keep reading/writing from the same store —
63
- * otherwise `list` / `get` / `has` would silently miss them. */
64
- function fileFallbackPreviouslyActivated() {
65
- try {
66
- return fs.readdirSync(fileDir()).some((e) => e.endsWith('.enc'));
67
- }
68
- catch {
69
- return false;
70
- }
71
- }
72
51
  /**
73
52
  * Decide which backend a given op should use. Activates file fallback if
74
53
  * `secret-tool` is missing and `AGENTS_SECRETS_PASSPHRASE` is set, OR if a
@@ -79,7 +58,7 @@ function fileFallbackPreviouslyActivated() {
79
58
  function preflight() {
80
59
  if (useFileFallback)
81
60
  return 'file';
82
- if (fileFallbackPreviouslyActivated()) {
61
+ if (fileStoreHasItems()) {
83
62
  activateFileFallback();
84
63
  return 'file';
85
64
  }
@@ -107,233 +86,12 @@ function preflight() {
107
86
  }
108
87
  return 'secret-tool';
109
88
  }
110
- // ---------- passphrase ----------
111
- function readPassphraseFromTty() {
112
- const fd = fs.openSync('/dev/tty', 'r+');
113
- let echoDisabled = false;
114
- try {
115
- fs.writeSync(fd, 'Enter AGENTS_SECRETS_PASSPHRASE: ');
116
- try {
117
- execSync('stty -echo < /dev/tty', { stdio: 'ignore' });
118
- echoDisabled = true;
119
- }
120
- catch {
121
- // stty not available — fall through; passphrase will echo. Better
122
- // than refusing to function.
123
- }
124
- let pass = '';
125
- const buf = Buffer.alloc(1);
126
- while (true) {
127
- const n = fs.readSync(fd, buf, 0, 1, null);
128
- if (n === 0)
129
- break;
130
- const ch = buf.toString('utf8', 0, n);
131
- if (ch === '\n' || ch === '\r')
132
- break;
133
- pass += ch;
134
- }
135
- return pass;
136
- }
137
- finally {
138
- if (echoDisabled) {
139
- try {
140
- execSync('stty echo < /dev/tty', { stdio: 'ignore' });
141
- }
142
- catch { /* best effort */ }
143
- }
144
- try {
145
- fs.writeSync(fd, '\n');
146
- }
147
- catch { /* best effort */ }
148
- fs.closeSync(fd);
149
- }
150
- }
151
- /** Path of the auto-provisioned machine-local passphrase. Lives alongside the
152
- * encrypted items but is never itself an item (no `.enc` suffix, so it's
153
- * excluded from list/has/get and from fileFallbackPreviouslyActivated). */
154
- function passphraseFilePath() {
155
- return path.join(fileDir(), '.passphrase');
156
- }
157
- /** True if a machine-local passphrase has already been provisioned. */
158
- function machinePassphraseExists() {
159
- try {
160
- return fs.readFileSync(passphraseFilePath(), 'utf8').trim().length > 0;
161
- }
162
- catch {
163
- return false;
164
- }
165
- }
166
- function readMachinePassphrase() {
167
- try {
168
- const p = fs.readFileSync(passphraseFilePath(), 'utf8').trim();
169
- return p.length > 0 ? p : null;
170
- }
171
- catch {
172
- return null;
173
- }
174
- }
175
- /**
176
- * Provision (or read back) a stable machine-local passphrase for the encrypted
177
- * file store, so `agents secrets` works out of the box on a headless box where
178
- * the keyring is locked and no AGENTS_SECRETS_PASSPHRASE is set.
179
- *
180
- * Security model: this is encryption-at-rest with the key held in a 0600 file —
181
- * the same posture as an SSH private key, and identical to the common
182
- * "export AGENTS_SECRETS_PASSPHRASE=… in ~/.zshenv (chmod 600)" workaround. The
183
- * keyring (key in a daemon's locked memory) is stronger but is unavailable
184
- * without a graphical/unlocked session. For an off-disk key, set
185
- * AGENTS_SECRETS_PASSPHRASE (it always takes precedence) or unlock the keyring.
186
- */
187
- function provisionMachinePassphrase() {
188
- const existing = readMachinePassphrase();
189
- if (existing)
190
- return existing;
191
- ensureFileDir();
192
- const generated = randomBytes(32).toString('base64');
193
- const fp = passphraseFilePath();
194
- try {
195
- // wx: fail if a concurrent process created it first (then we read theirs).
196
- fs.writeFileSync(fp, generated, { mode: 0o600, flag: 'wx' });
197
- }
198
- catch {
199
- const raced = readMachinePassphrase();
200
- if (raced)
201
- return raced;
202
- throw new Error(`Failed to provision machine-local passphrase at ${fp}.`);
203
- }
204
- if (!warnedAutoPassphrase) {
205
- warnedAutoPassphrase = true;
206
- process.stderr.write(`[agents] keyring locked and no AGENTS_SECRETS_PASSPHRASE set; provisioned a ` +
207
- `machine-local passphrase at ${fp} (mode 0600). Set AGENTS_SECRETS_PASSPHRASE ` +
208
- `for a key held off disk.\n`);
209
- }
210
- return generated;
211
- }
212
- function getPassphrase() {
213
- if (cachedPassphrase !== null)
214
- return cachedPassphrase;
215
- const env = process.env.AGENTS_SECRETS_PASSPHRASE;
216
- if (env && env.length > 0) {
217
- cachedPassphrase = env;
218
- return env;
219
- }
220
- // A previously-provisioned machine-local passphrase is this machine's stable
221
- // file-store key — prefer it for both interactive and headless runs so they
222
- // always agree (a TTY run won't re-prompt once the file exists).
223
- const onDisk = readMachinePassphrase();
224
- if (onDisk) {
225
- cachedPassphrase = onDisk;
226
- return onDisk;
227
- }
228
- // First run, no env, no provisioned key: prompt when interactive, otherwise
229
- // (headless — the reported bug) auto-provision instead of hard-failing.
230
- if (process.stdin.isTTY) {
231
- const p = readPassphraseFromTty();
232
- if (!p)
233
- throw new Error('No passphrase entered.');
234
- cachedPassphrase = p;
235
- return p;
236
- }
237
- cachedPassphrase = provisionMachinePassphrase();
238
- return cachedPassphrase;
239
- }
240
- function deriveKey(passphrase, salt) {
241
- return scryptSync(passphrase, salt, 32);
242
- }
243
- /** Encrypt plaintext under a passphrase using AES-256-GCM with a random
244
- * scrypt salt and a random 96-bit IV. Exported for tests. */
245
- export function encryptForFallback(plaintext, passphrase) {
246
- const salt = randomBytes(16);
247
- const iv = randomBytes(12);
248
- const key = deriveKey(passphrase, salt);
249
- const cipher = createCipheriv('aes-256-gcm', key, iv);
250
- const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
251
- return {
252
- salt: salt.toString('hex'),
253
- iv: iv.toString('hex'),
254
- authTag: cipher.getAuthTag().toString('hex'),
255
- ciphertext: ciphertext.toString('hex'),
256
- };
257
- }
258
- /** Decrypt an EncFile under a passphrase. Throws on wrong key or tampered
259
- * ciphertext (auth-tag mismatch). Exported for tests. */
260
- export function decryptForFallback(enc, passphrase) {
261
- const salt = Buffer.from(enc.salt, 'hex');
262
- const iv = Buffer.from(enc.iv, 'hex');
263
- const authTag = Buffer.from(enc.authTag, 'hex');
264
- const ciphertext = Buffer.from(enc.ciphertext, 'hex');
265
- const key = deriveKey(passphrase, salt);
266
- const decipher = createDecipheriv('aes-256-gcm', key, iv);
267
- decipher.setAuthTag(authTag);
268
- const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
269
- return plaintext.toString('utf8');
270
- }
271
- // ---------- file backend ----------
272
- function fileFor(item) {
273
- return path.join(fileDir(), `${item}.enc`);
274
- }
275
- function ensureFileDir() {
276
- fs.mkdirSync(fileDir(), { recursive: true, mode: 0o700 });
277
- }
278
- function fileHas(item) {
279
- return fs.existsSync(fileFor(item));
280
- }
281
- function fileGet(item) {
282
- const fp = fileFor(item);
283
- if (!fs.existsSync(fp)) {
284
- throw new Error(`Secret '${item}' not found in encrypted store.`);
285
- }
286
- const raw = fs.readFileSync(fp, 'utf8');
287
- let parsed;
288
- try {
289
- parsed = JSON.parse(raw);
290
- }
291
- catch {
292
- throw new Error(`Encrypted secret file ${fp} is corrupt (not valid JSON).`);
293
- }
294
- try {
295
- return decryptForFallback(parsed, getPassphrase());
296
- }
297
- catch {
298
- throw new Error(`Failed to decrypt '${item}'. Wrong AGENTS_SECRETS_PASSPHRASE or tampered file.`);
299
- }
300
- }
301
- function fileSet(item, value) {
302
- ensureFileDir();
303
- const enc = encryptForFallback(value, getPassphrase());
304
- fs.writeFileSync(fileFor(item), JSON.stringify(enc), { mode: 0o600 });
305
- }
306
- function fileDelete(item) {
307
- const fp = fileFor(item);
308
- if (!fs.existsSync(fp))
309
- return true; // idempotent, matches secret-tool clear
310
- fs.unlinkSync(fp);
311
- return true;
312
- }
313
- function fileList(prefix) {
314
- const dir = fileDir();
315
- if (!fs.existsSync(dir))
316
- return [];
317
- return fs.readdirSync(dir)
318
- .filter((f) => f.endsWith('.enc'))
319
- .map((f) => f.slice(0, -'.enc'.length))
320
- .filter((name) => name.startsWith(prefix));
321
- }
322
- /** File-only KeychainBackend (exported for tests; the public surface uses
323
- * the secret-tool-with-fallback `linuxBackend` below). */
324
- export const fileBackend = {
325
- has: fileHas,
326
- get: fileGet,
327
- set: fileSet,
328
- delete: fileDelete,
329
- list: fileList,
330
- };
331
89
  // ---------- secret-tool ops with fallback ----------
332
90
  /** secret-tool lookup attributes:
333
91
  * service=agents-cli account=<user> item=<itemName> */
334
92
  export function hasSecretToolToken(item) {
335
93
  if (preflight() === 'file')
336
- return fileHas(item);
94
+ return fileStore.has(item);
337
95
  const user = os.userInfo().username;
338
96
  const result = spawnSync('secret-tool', [
339
97
  'lookup',
@@ -347,13 +105,13 @@ export function hasSecretToolToken(item) {
347
105
  const stderr = result.stderr?.toString() ?? '';
348
106
  if (isLockedCollectionError(stderr)) {
349
107
  activateFileFallback();
350
- return fileHas(item);
108
+ return fileStore.has(item);
351
109
  }
352
110
  return false;
353
111
  }
354
112
  export function getSecretToolToken(item) {
355
113
  if (preflight() === 'file')
356
- return fileGet(item);
114
+ return fileStore.get(item);
357
115
  const user = os.userInfo().username;
358
116
  const result = spawnSync('secret-tool', [
359
117
  'lookup',
@@ -370,7 +128,7 @@ export function getSecretToolToken(item) {
370
128
  const stderr = result.stderr?.toString() ?? '';
371
129
  if (isLockedCollectionError(stderr)) {
372
130
  activateFileFallback();
373
- return fileGet(item);
131
+ return fileStore.get(item);
374
132
  }
375
133
  throw new Error(`Secret '${item}' not found in keyring.`);
376
134
  }
@@ -378,7 +136,7 @@ export function setSecretToolToken(item, value) {
378
136
  if (!value || !value.trim())
379
137
  throw new Error('Secret value is empty.');
380
138
  if (preflight() === 'file')
381
- return fileSet(item, value);
139
+ return fileStore.set(item, value);
382
140
  const user = os.userInfo().username;
383
141
  const label = `agents-cli: ${item}`;
384
142
  const result = spawnSync('secret-tool', [
@@ -393,7 +151,7 @@ export function setSecretToolToken(item, value) {
393
151
  const stderr = result.stderr?.toString().trim() ?? '';
394
152
  if (isLockedCollectionError(stderr)) {
395
153
  activateFileFallback();
396
- fileSet(item, value);
154
+ fileStore.set(item, value);
397
155
  return;
398
156
  }
399
157
  throw new Error(`Failed to store secret '${item}': ${stderr || 'unknown error'}\n` +
@@ -402,7 +160,7 @@ export function setSecretToolToken(item, value) {
402
160
  }
403
161
  export function deleteSecretToolToken(item) {
404
162
  if (preflight() === 'file')
405
- return fileDelete(item);
163
+ return fileStore.delete(item);
406
164
  const user = os.userInfo().username;
407
165
  const result = spawnSync('secret-tool', [
408
166
  'clear',
@@ -415,7 +173,7 @@ export function deleteSecretToolToken(item) {
415
173
  const stderr = result.stderr?.toString() ?? '';
416
174
  if (isLockedCollectionError(stderr)) {
417
175
  activateFileFallback();
418
- return fileDelete(item);
176
+ return fileStore.delete(item);
419
177
  }
420
178
  // secret-tool clear returns 0 whether the item existed or not.
421
179
  // A non-zero exit that isn't a locked-collection error is a real failure;
@@ -456,7 +214,7 @@ export function parseSecretToolItems(output, prefix) {
456
214
  */
457
215
  export function listSecretToolItems(prefix) {
458
216
  if (preflight() === 'file')
459
- return fileList(prefix);
217
+ return fileStore.list(prefix);
460
218
  const result = spawnSync('secret-tool', [
461
219
  'search',
462
220
  '--all',
@@ -466,7 +224,7 @@ export function listSecretToolItems(prefix) {
466
224
  const stderr = result.stderr?.toString() ?? '';
467
225
  if (isLockedCollectionError(stderr)) {
468
226
  activateFileFallback();
469
- return fileList(prefix);
227
+ return fileStore.list(prefix);
470
228
  }
471
229
  return [];
472
230
  }
@@ -495,13 +253,12 @@ export const linuxBackend = {
495
253
  },
496
254
  };
497
255
  /** Test-only: reset module state so independent test cases don't bleed
498
- * passphrase / fallback decisions across each other. */
256
+ * passphrase / fallback decisions across each other. File-store state (file
257
+ * dir + cached passphrase) lives in ./filestore.ts and is reset there. */
499
258
  export function _resetForTest(opts = {}) {
500
- fileDirOverride = opts.fileDir ?? null;
259
+ _resetFileStoreForTest({ fileDir: opts.fileDir ?? null, passphrase: opts.passphrase ?? null });
501
260
  useFileFallback = opts.forceFileFallback ?? false;
502
261
  warnedFallback = false;
503
- warnedAutoPassphrase = false;
504
- cachedPassphrase = opts.passphrase ?? null;
505
262
  checkedAvailability = false;
506
263
  isAvailable = false;
507
264
  }
@@ -23,6 +23,8 @@ export interface SessionRow {
23
23
  label: string | null;
24
24
  message_count: number | null;
25
25
  token_count: number | null;
26
+ cost_usd: number | null;
27
+ duration_ms: number | null;
26
28
  file_path: string;
27
29
  file_mtime_ms: number | null;
28
30
  file_size: number | null;
@@ -50,6 +52,12 @@ export interface QueryOptions {
50
52
  excludeTeamOrigin?: boolean;
51
53
  /** Keep only team-origin rows (for hidden-count queries). */
52
54
  onlyTeamOrigin?: boolean;
55
+ /**
56
+ * Column to order by, all descending. 'timestamp' (default) sorts newest
57
+ * first; 'cost' and 'duration' put the priciest / longest sessions on top,
58
+ * with NULLs sorted last so unpriced rows never crowd out real data.
59
+ */
60
+ sortBy?: 'timestamp' | 'cost' | 'duration';
53
61
  }
54
62
  /** Open (or return the cached) sessions database, applying migrations as needed. */
55
63
  export declare function getDB(): Database.Database;
@@ -114,6 +122,38 @@ export declare function syncLabels(labelMap: Map<string, string | null>): number
114
122
  export declare function querySessions(options?: QueryOptions): SessionMeta[];
115
123
  /** Count sessions matching the given filter options. */
116
124
  export declare function countSessions(options?: QueryOptions): number;
125
+ /** One grouped row in a cost/duration rollup. */
126
+ export interface UsageRollupRow {
127
+ /** Grouping key value: the agent id, project name, or ISO date (YYYY-MM-DD). */
128
+ key: string;
129
+ costUsd: number;
130
+ durationMs: number;
131
+ sessionCount: number;
132
+ tokenCount: number;
133
+ }
134
+ /** What to group a usage rollup by. */
135
+ export type UsageRollupGroup = 'agent' | 'project' | 'day';
136
+ /**
137
+ * Aggregate cost / duration / tokens across sessions, grouped by agent,
138
+ * project, or calendar day. Honors the same filter shape as querySessions
139
+ * (agent, since/until, team-origin) so `agents cost --since 7d --by day`
140
+ * lines up with what `agents sessions` would list. Ordered by cost desc.
141
+ */
142
+ export declare function queryUsageRollup(options: QueryOptions & {
143
+ groupBy: UsageRollupGroup;
144
+ }): UsageRollupRow[];
145
+ /** A session with its cost, for the top-N-by-cost listing. */
146
+ export interface TopCostSession {
147
+ meta: SessionMeta;
148
+ costUsd: number;
149
+ durationMs: number;
150
+ }
151
+ /**
152
+ * Return the N most expensive sessions (cost_usd DESC, NULLs excluded),
153
+ * honoring the same filter shape as querySessions. Drops rows whose JSONL
154
+ * vanished, mirroring querySessions' liveness filter.
155
+ */
156
+ export declare function topSessionsByCost(n: number, options?: QueryOptions): TopCostSession[];
117
157
  /** Return the set of all file paths currently tracked in the sessions table. */
118
158
  export declare function getAllFilePaths(): Set<string>;
119
159
  /** Look up sessions by their source file paths. */
@@ -13,7 +13,7 @@ import { getSessionsDir, getSessionsDbPath } from '../state.js';
13
13
  const SESSIONS_DIR = getSessionsDir();
14
14
  const DB_PATH = getSessionsDbPath();
15
15
  /** Current schema version; bumped when migrations are added. */
16
- const SCHEMA_VERSION = 5;
16
+ const SCHEMA_VERSION = 6;
17
17
  /**
18
18
  * Canonicalize a file path for use as a scan_ledger key. The same physical
19
19
  * session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
@@ -53,6 +53,8 @@ CREATE TABLE IF NOT EXISTS sessions (
53
53
  label TEXT,
54
54
  message_count INTEGER,
55
55
  token_count INTEGER,
56
+ cost_usd REAL,
57
+ duration_ms INTEGER,
56
58
  file_path TEXT NOT NULL,
57
59
  file_mtime_ms INTEGER,
58
60
  file_size INTEGER,
@@ -141,6 +143,19 @@ function migrateSchema(db, fromVersion) {
141
143
  // repopulate under canonical keys.
142
144
  db.exec(`DELETE FROM scan_ledger;`);
143
145
  }
146
+ if (fromVersion < 6) {
147
+ // v5 → v6: cost ($) and wall-clock duration are now computed at scan time
148
+ // from raw per-model token usage. Add the columns and force a full rescan
149
+ // so every existing session gets its cost_usd / duration_ms populated.
150
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
151
+ if (!cols.some(c => c.name === 'cost_usd')) {
152
+ db.exec(`ALTER TABLE sessions ADD COLUMN cost_usd REAL`);
153
+ }
154
+ if (!cols.some(c => c.name === 'duration_ms')) {
155
+ db.exec(`ALTER TABLE sessions ADD COLUMN duration_ms INTEGER`);
156
+ }
157
+ db.exec(`DELETE FROM scan_ledger;`);
158
+ }
144
159
  }
145
160
  /** Open (or return the cached) sessions database, applying migrations as needed. */
146
161
  export function getDB() {
@@ -350,10 +365,12 @@ const upsertSessionStmt = (db) => db.prepare(`
350
365
  INSERT INTO sessions (
351
366
  id, short_id, agent, version, account, timestamp,
352
367
  project, cwd, git_branch, topic, label, message_count, token_count,
368
+ cost_usd, duration_ms,
353
369
  file_path, file_mtime_ms, file_size, scanned_at, is_team_origin
354
370
  ) VALUES (
355
371
  @id, @short_id, @agent, @version, @account, @timestamp,
356
372
  @project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
373
+ @cost_usd, @duration_ms,
357
374
  @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin
358
375
  )
359
376
  ON CONFLICT(id) DO UPDATE SET
@@ -369,6 +386,8 @@ const upsertSessionStmt = (db) => db.prepare(`
369
386
  label = excluded.label,
370
387
  message_count = excluded.message_count,
371
388
  token_count = excluded.token_count,
389
+ cost_usd = excluded.cost_usd,
390
+ duration_ms = excluded.duration_ms,
372
391
  file_path = excluded.file_path,
373
392
  file_mtime_ms = excluded.file_mtime_ms,
374
393
  file_size = excluded.file_size,
@@ -409,6 +428,8 @@ export function upsertSession(meta, content, scan) {
409
428
  label: meta.label ?? null,
410
429
  message_count: meta.messageCount ?? null,
411
430
  token_count: meta.tokenCount ?? null,
431
+ cost_usd: meta.costUsd ?? null,
432
+ duration_ms: meta.durationMs ?? null,
412
433
  file_path: meta.filePath,
413
434
  file_mtime_ms: scan?.fileMtimeMs ?? null,
414
435
  file_size: scan?.fileSize ?? null,
@@ -482,6 +503,8 @@ export function upsertSessionsBatch(entries) {
482
503
  label: meta.label ?? null,
483
504
  message_count: meta.messageCount ?? null,
484
505
  token_count: meta.tokenCount ?? null,
506
+ cost_usd: meta.costUsd ?? null,
507
+ duration_ms: meta.durationMs ?? null,
485
508
  file_path: meta.filePath,
486
509
  file_mtime_ms: scan?.fileMtimeMs ?? null,
487
510
  file_size: scan?.fileSize ?? null,
@@ -548,6 +571,8 @@ function rowToMeta(row) {
548
571
  gitBranch: row.git_branch ?? undefined,
549
572
  messageCount: row.message_count ?? undefined,
550
573
  tokenCount: row.token_count ?? undefined,
574
+ costUsd: row.cost_usd ?? undefined,
575
+ durationMs: row.duration_ms ?? undefined,
551
576
  version: row.version ?? undefined,
552
577
  account: row.account ?? undefined,
553
578
  topic: row.topic ?? undefined,
@@ -611,7 +636,14 @@ export function querySessions(options = {}) {
611
636
  const limitClause = options.limit
612
637
  ? `LIMIT ${Math.max(1, Math.floor(options.limit)) + 16}`
613
638
  : '';
614
- const sql = `SELECT * FROM sessions ${clause} ORDER BY timestamp DESC ${limitClause}`;
639
+ // NULLs last so unpriced / duration-less rows never crowd out real data when
640
+ // sorting by cost or duration. timestamp is never null (NOT NULL column).
641
+ const orderClause = options.sortBy === 'cost'
642
+ ? 'ORDER BY cost_usd IS NULL, cost_usd DESC, timestamp DESC'
643
+ : options.sortBy === 'duration'
644
+ ? 'ORDER BY duration_ms IS NULL, duration_ms DESC, timestamp DESC'
645
+ : 'ORDER BY timestamp DESC';
646
+ const sql = `SELECT * FROM sessions ${clause} ${orderClause} ${limitClause}`;
615
647
  const rows = db.prepare(sql).all(...params);
616
648
  // Belt-and-suspenders: drop rows whose JSONL no longer exists on disk. The
617
649
  // authoritative fix is to keep file_path in sync (see updateSessionFilePaths
@@ -631,6 +663,56 @@ export function countSessions(options = {}) {
631
663
  const row = db.prepare(sql).get(...params);
632
664
  return row ? row.n : 0;
633
665
  }
666
+ /**
667
+ * Aggregate cost / duration / tokens across sessions, grouped by agent,
668
+ * project, or calendar day. Honors the same filter shape as querySessions
669
+ * (agent, since/until, team-origin) so `agents cost --since 7d --by day`
670
+ * lines up with what `agents sessions` would list. Ordered by cost desc.
671
+ */
672
+ export function queryUsageRollup(options) {
673
+ const db = getDB();
674
+ const { clause, params } = buildSessionWhere(options);
675
+ const keyExpr = options.groupBy === 'agent'
676
+ ? 'agent'
677
+ : options.groupBy === 'project'
678
+ ? `IFNULL(NULLIF(project, ''), '(no project)')`
679
+ // ISO timestamps are lexicographically date-sortable; the date is the
680
+ // first 10 chars (YYYY-MM-DD).
681
+ : `substr(timestamp, 1, 10)`;
682
+ const sql = `
683
+ SELECT
684
+ ${keyExpr} AS key,
685
+ IFNULL(SUM(cost_usd), 0) AS costUsd,
686
+ IFNULL(SUM(duration_ms), 0) AS durationMs,
687
+ COUNT(*) AS sessionCount,
688
+ IFNULL(SUM(token_count), 0) AS tokenCount
689
+ FROM sessions
690
+ ${clause}
691
+ GROUP BY key
692
+ ORDER BY costUsd DESC, key ASC
693
+ `;
694
+ return db.prepare(sql).all(...params);
695
+ }
696
+ /**
697
+ * Return the N most expensive sessions (cost_usd DESC, NULLs excluded),
698
+ * honoring the same filter shape as querySessions. Drops rows whose JSONL
699
+ * vanished, mirroring querySessions' liveness filter.
700
+ */
701
+ export function topSessionsByCost(n, options = {}) {
702
+ const db = getDB();
703
+ const { clause, params } = buildSessionWhere(options);
704
+ const whereCost = clause ? `${clause} AND cost_usd IS NOT NULL` : 'WHERE cost_usd IS NOT NULL';
705
+ const limit = Math.max(1, Math.floor(n));
706
+ // Over-fetch a small buffer to survive the on-disk liveness filter below.
707
+ const sql = `SELECT * FROM sessions ${whereCost} ORDER BY cost_usd DESC, timestamp DESC LIMIT ${limit + 16}`;
708
+ const rows = db.prepare(sql).all(...params);
709
+ const live = rows.filter(r => !r.file_path || fs.existsSync(r.file_path));
710
+ return live.slice(0, limit).map(r => ({
711
+ meta: rowToMeta(r),
712
+ costUsd: r.cost_usd ?? 0,
713
+ durationMs: r.duration_ms ?? 0,
714
+ }));
715
+ }
634
716
  /** Return the set of all file paths currently tracked in the sessions table. */
635
717
  export function getAllFilePaths() {
636
718
  const db = getDB();
@@ -25,6 +25,8 @@ export interface DiscoverOptions {
25
25
  excludeTeamOrigin?: boolean;
26
26
  /** Keep only team-spawned sessions (used for hidden-count queries). */
27
27
  onlyTeamOrigin?: boolean;
28
+ /** Column to order results by (all descending): 'timestamp' (default), 'cost', or 'duration'. */
29
+ sortBy?: 'timestamp' | 'cost' | 'duration';
28
30
  /** Called as each agent makes parsing progress. Totals count only files that need re-parsing (cache misses). */
29
31
  onProgress?: (progress: ScanProgress) => void;
30
32
  }