@phnx-labs/agents-cli 1.20.16 → 1.20.18

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 (75) 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 +250 -4
  11. package/dist/commands/sessions.js +4 -0
  12. package/dist/commands/sync.d.ts +10 -3
  13. package/dist/commands/sync.js +72 -9
  14. package/dist/index.js +4 -0
  15. package/dist/lib/budget/config.d.ts +9 -0
  16. package/dist/lib/budget/config.js +115 -0
  17. package/dist/lib/budget/enforce.d.ts +94 -0
  18. package/dist/lib/budget/enforce.js +151 -0
  19. package/dist/lib/budget/ledger.d.ts +61 -0
  20. package/dist/lib/budget/ledger.js +107 -0
  21. package/dist/lib/budget/preflight.d.ts +110 -0
  22. package/dist/lib/budget/preflight.js +200 -0
  23. package/dist/lib/checkpoint.d.ts +54 -0
  24. package/dist/lib/checkpoint.js +56 -0
  25. package/dist/lib/cloud/rush.js +18 -0
  26. package/dist/lib/exec.d.ts +36 -0
  27. package/dist/lib/exec.js +192 -4
  28. package/dist/lib/git.d.ts +18 -0
  29. package/dist/lib/git.js +67 -4
  30. package/dist/lib/hooks.js +12 -0
  31. package/dist/lib/loop.d.ts +145 -0
  32. package/dist/lib/loop.js +330 -0
  33. package/dist/lib/mcp.d.ts +7 -0
  34. package/dist/lib/mcp.js +24 -0
  35. package/dist/lib/models.d.ts +11 -0
  36. package/dist/lib/models.js +21 -0
  37. package/dist/lib/plugin-marketplace.js +16 -6
  38. package/dist/lib/plugins.js +5 -2
  39. package/dist/lib/pricing/cost.d.ts +46 -0
  40. package/dist/lib/pricing/cost.js +71 -0
  41. package/dist/lib/pricing/index.d.ts +8 -0
  42. package/dist/lib/pricing/index.js +8 -0
  43. package/dist/lib/pricing/prices.json +138 -0
  44. package/dist/lib/pricing/table.d.ts +17 -0
  45. package/dist/lib/pricing/table.js +73 -0
  46. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  47. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  48. package/dist/lib/secrets/agent.d.ts +134 -0
  49. package/dist/lib/secrets/agent.js +501 -0
  50. package/dist/lib/secrets/bundles.d.ts +21 -0
  51. package/dist/lib/secrets/bundles.js +43 -0
  52. package/dist/lib/secrets/drivers/rush.d.ts +14 -0
  53. package/dist/lib/secrets/drivers/rush.js +84 -0
  54. package/dist/lib/secrets/linux.js +88 -10
  55. package/dist/lib/secrets/sync-backend.d.ts +48 -0
  56. package/dist/lib/secrets/sync-backend.js +13 -0
  57. package/dist/lib/secrets/sync.d.ts +15 -23
  58. package/dist/lib/secrets/sync.js +31 -66
  59. package/dist/lib/session/db.d.ts +40 -0
  60. package/dist/lib/session/db.js +84 -2
  61. package/dist/lib/session/discover.d.ts +2 -0
  62. package/dist/lib/session/discover.js +126 -2
  63. package/dist/lib/session/render.d.ts +2 -0
  64. package/dist/lib/session/render.js +1 -1
  65. package/dist/lib/session/types.d.ts +4 -0
  66. package/dist/lib/sync-umbrella.d.ts +76 -0
  67. package/dist/lib/sync-umbrella.js +125 -0
  68. package/dist/lib/teams/agents.d.ts +32 -0
  69. package/dist/lib/teams/agents.js +66 -3
  70. package/dist/lib/teams/api.js +20 -0
  71. package/dist/lib/teams/parsers.js +16 -4
  72. package/dist/lib/types.d.ts +48 -0
  73. package/dist/lib/workflows.d.ts +56 -0
  74. package/dist/lib/workflows.js +72 -5
  75. package/package.json +2 -1
@@ -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
  }
@@ -20,6 +20,7 @@ import { walkForFiles } from '../fs-walk.js';
20
20
  import { getConfigSymlinkVersion } from '../shims.js';
21
21
  import { SESSION_AGENTS } from './types.js';
22
22
  import { extractSessionTopic } from './prompt.js';
23
+ import { costOfUsage } from '../pricing/index.js';
23
24
  import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
24
25
  const HOME = os.homedir();
25
26
  // Versions can live under either repo: the user repo (current canonical
@@ -108,6 +109,7 @@ function buildQueryOptions(options, agents, opts) {
108
109
  limit: opts.includeLimit ? (options?.limit ?? 50) : undefined,
109
110
  excludeTeamOrigin: options?.excludeTeamOrigin,
110
111
  onlyTeamOrigin: options?.onlyTeamOrigin,
112
+ sortBy: options?.sortBy,
111
113
  };
112
114
  }
113
115
  /** Resolve and canonicalize a working directory path (follows symlinks). */
@@ -402,6 +404,8 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
402
404
  label,
403
405
  messageCount: scan.messageCount,
404
406
  tokenCount: scan.tokenCount,
407
+ costUsd: scan.costUsd,
408
+ durationMs: scan.durationMs,
405
409
  isTeamOrigin,
406
410
  };
407
411
  }
@@ -417,6 +421,8 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
417
421
  label,
418
422
  messageCount: scan.messageCount,
419
423
  tokenCount: scan.tokenCount,
424
+ costUsd: scan.costUsd,
425
+ durationMs: scan.durationMs,
420
426
  topic: scan.topic,
421
427
  isTeamOrigin,
422
428
  };
@@ -529,6 +535,8 @@ async function readCodexMeta(filePath, account, currentVersion) {
529
535
  topic: scan.topic,
530
536
  messageCount: scan.messageCount,
531
537
  tokenCount: scan.tokenCount,
538
+ costUsd: scan.costUsd,
539
+ durationMs: scan.durationMs,
532
540
  account,
533
541
  };
534
542
  return { meta, content: scan.contentText || '' };
@@ -642,10 +650,15 @@ function readGeminiMeta(filePath, hashDir, projectMap, currentVersion) {
642
650
  const cwd = projectInfo?.path ? normalizeCwd(projectInfo.path) : undefined;
643
651
  const stat = safeStatSync(filePath);
644
652
  const messages = Array.isArray(session.messages) ? session.messages : [];
653
+ const sessionModel = typeof session.model === 'string' ? session.model : undefined;
645
654
  let topic;
646
655
  let messageCount = 0;
647
656
  let tokenCount = 0;
648
657
  let sawTokenCount = false;
658
+ let costUsd = 0;
659
+ let sawCost = false;
660
+ let firstTsMs;
661
+ let lastTsMs;
649
662
  const userTexts = [];
650
663
  for (const message of messages) {
651
664
  if (message.type === 'user') {
@@ -662,12 +675,43 @@ function readGeminiMeta(filePath, hashDir, projectMap, currentVersion) {
662
675
  messageCount++;
663
676
  }
664
677
  }
678
+ // Duration: messages carry a `timestamp` on most Gemini CLI versions.
679
+ const tsRaw = message.timestamp ?? message.time;
680
+ if (typeof tsRaw === 'string' || typeof tsRaw === 'number') {
681
+ const ms = new Date(tsRaw).getTime();
682
+ if (!Number.isNaN(ms)) {
683
+ if (firstTsMs === undefined || ms < firstTsMs)
684
+ firstTsMs = ms;
685
+ if (lastTsMs === undefined || ms > lastTsMs)
686
+ lastTsMs = ms;
687
+ }
688
+ }
665
689
  const total = getGeminiTokenCount(message.tokens);
666
690
  if (total !== null) {
667
691
  tokenCount += total;
668
692
  sawTokenCount = true;
669
693
  }
694
+ // Per-message cost: directional tokens × this message's model price.
695
+ const msgModel = (typeof message.model === 'string' ? message.model : undefined) || sessionModel;
696
+ const tk = message.tokens;
697
+ if (msgModel && tk && typeof tk === 'object') {
698
+ const c = costOfUsage({
699
+ model: msgModel,
700
+ inputTokens: typeof tk.input === 'number' ? tk.input : undefined,
701
+ outputTokens: (typeof tk.output === 'number' ? tk.output : 0) +
702
+ (typeof tk.thoughts === 'number' ? tk.thoughts : 0) +
703
+ (typeof tk.tool === 'number' ? tk.tool : 0),
704
+ cacheReadTokens: typeof tk.cached === 'number' ? tk.cached : undefined,
705
+ });
706
+ if (c > 0) {
707
+ costUsd += c;
708
+ sawCost = true;
709
+ }
710
+ }
670
711
  }
712
+ const durationMs = firstTsMs !== undefined && lastTsMs !== undefined && lastTsMs > firstTsMs
713
+ ? lastTsMs - firstTsMs
714
+ : undefined;
671
715
  const meta = {
672
716
  id: sessionId,
673
717
  shortId: sessionId.slice(0, 8),
@@ -680,6 +724,8 @@ function readGeminiMeta(filePath, hashDir, projectMap, currentVersion) {
680
724
  topic,
681
725
  messageCount,
682
726
  tokenCount: sawTokenCount ? tokenCount : undefined,
727
+ costUsd: sawCost ? costUsd : undefined,
728
+ durationMs,
683
729
  };
684
730
  return { meta, content: userTexts.join('\n') };
685
731
  }
@@ -1206,6 +1252,11 @@ async function scanClaudeSession(filePath) {
1206
1252
  let messageCount = 0;
1207
1253
  let tokenCount = 0;
1208
1254
  let sawTokenCount = false;
1255
+ let costUsd = 0;
1256
+ let sawCost = false;
1257
+ // Track the first and last timestamped event to derive wall-clock duration.
1258
+ let firstTsMs;
1259
+ let lastTsMs;
1209
1260
  const seenAssistantIds = new Set();
1210
1261
  const userTexts = [];
1211
1262
  try {
@@ -1224,6 +1275,16 @@ async function scanClaudeSession(filePath) {
1224
1275
  if (!entrypoint && typeof parsed.entrypoint === 'string') {
1225
1276
  entrypoint = parsed.entrypoint;
1226
1277
  }
1278
+ // Track duration across every timestamped event, not just the first.
1279
+ if (typeof parsed.timestamp === 'string') {
1280
+ const ms = new Date(parsed.timestamp).getTime();
1281
+ if (!Number.isNaN(ms)) {
1282
+ if (firstTsMs === undefined || ms < firstTsMs)
1283
+ firstTsMs = ms;
1284
+ if (lastTsMs === undefined || ms > lastTsMs)
1285
+ lastTsMs = ms;
1286
+ }
1287
+ }
1227
1288
  if (!timestamp && (parsed.type === 'user' || parsed.type === 'assistant') && parsed.timestamp) {
1228
1289
  timestamp = parsed.timestamp;
1229
1290
  cwd = parsed.cwd || '';
@@ -1252,17 +1313,37 @@ async function scanClaudeSession(filePath) {
1252
1313
  continue;
1253
1314
  seenAssistantIds.add(logicalId);
1254
1315
  messageCount++;
1255
- const usage = getClaudeUsageTotal(parsed.message?.usage || parsed.usage);
1316
+ const usageObj = parsed.message?.usage || parsed.usage;
1317
+ const usage = getClaudeUsageTotal(usageObj);
1256
1318
  if (usage !== null) {
1257
1319
  tokenCount += usage;
1258
1320
  sawTokenCount = true;
1259
1321
  }
1322
+ // Per-assistant-message cost: each event carries its own model, so we
1323
+ // multiply that event's raw token directions by that model's price.
1324
+ const model = parsed.message?.model;
1325
+ if (model && usageObj && typeof usageObj === 'object') {
1326
+ const eventCost = costOfUsage({
1327
+ model,
1328
+ inputTokens: usageObj.input_tokens,
1329
+ outputTokens: usageObj.output_tokens,
1330
+ cacheReadTokens: usageObj.cache_read_input_tokens,
1331
+ cacheCreationTokens: usageObj.cache_creation_input_tokens,
1332
+ });
1333
+ if (eventCost > 0) {
1334
+ costUsd += eventCost;
1335
+ sawCost = true;
1336
+ }
1337
+ }
1260
1338
  }
1261
1339
  }
1262
1340
  finally {
1263
1341
  rl.close();
1264
1342
  stream.destroy();
1265
1343
  }
1344
+ const durationMs = firstTsMs !== undefined && lastTsMs !== undefined && lastTsMs > firstTsMs
1345
+ ? lastTsMs - firstTsMs
1346
+ : undefined;
1266
1347
  return {
1267
1348
  timestamp,
1268
1349
  cwd,
@@ -1272,6 +1353,8 @@ async function scanClaudeSession(filePath) {
1272
1353
  entrypoint,
1273
1354
  messageCount,
1274
1355
  tokenCount: sawTokenCount ? tokenCount : undefined,
1356
+ costUsd: sawCost ? costUsd : undefined,
1357
+ durationMs,
1275
1358
  contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
1276
1359
  };
1277
1360
  }
@@ -1287,6 +1370,10 @@ async function scanCodexSession(filePath) {
1287
1370
  let topic;
1288
1371
  let messageCount = 0;
1289
1372
  let tokenCount;
1373
+ let model;
1374
+ let lastTotalTokenUsage;
1375
+ let firstTsMs;
1376
+ let lastTsMs;
1290
1377
  const userTexts = [];
1291
1378
  try {
1292
1379
  for await (const line of rl) {
@@ -1299,6 +1386,16 @@ async function scanCodexSession(filePath) {
1299
1386
  catch {
1300
1387
  continue;
1301
1388
  }
1389
+ // Track duration across every timestamped event.
1390
+ if (typeof parsed.timestamp === 'string') {
1391
+ const ms = new Date(parsed.timestamp).getTime();
1392
+ if (!Number.isNaN(ms)) {
1393
+ if (firstTsMs === undefined || ms < firstTsMs)
1394
+ firstTsMs = ms;
1395
+ if (lastTsMs === undefined || ms > lastTsMs)
1396
+ lastTsMs = ms;
1397
+ }
1398
+ }
1302
1399
  if (parsed.type === 'session_meta') {
1303
1400
  const payload = parsed.payload || {};
1304
1401
  sessionId = payload.id || sessionId;
@@ -1306,6 +1403,7 @@ async function scanCodexSession(filePath) {
1306
1403
  cwd = payload.cwd || cwd;
1307
1404
  gitBranch = payload.git?.branch || gitBranch;
1308
1405
  version = payload.cli_version || payload.version || version;
1406
+ model = payload.model || model;
1309
1407
  continue;
1310
1408
  }
1311
1409
  if (parsed.type === 'response_item' && parsed.payload?.type === 'message') {
@@ -1324,9 +1422,18 @@ async function scanCodexSession(filePath) {
1324
1422
  continue;
1325
1423
  }
1326
1424
  if (parsed.type === 'event_msg' && parsed.payload?.type === 'token_count') {
1327
- const total = getCodexTokenCount(parsed.payload.info?.total_token_usage);
1425
+ const totalUsage = parsed.payload.info?.total_token_usage;
1426
+ const total = getCodexTokenCount(totalUsage);
1328
1427
  if (total !== null)
1329
1428
  tokenCount = total;
1429
+ // token_count is cumulative — keep the latest snapshot and price it once
1430
+ // after the stream, so we don't double-count across intermediate events.
1431
+ if (totalUsage && typeof totalUsage === 'object')
1432
+ lastTotalTokenUsage = totalUsage;
1433
+ // Codex also stamps the model on the rate_limits/token_count payload on
1434
+ // some versions; prefer session_meta but fall back to it.
1435
+ if (!model && typeof parsed.payload.info?.model === 'string')
1436
+ model = parsed.payload.info.model;
1330
1437
  }
1331
1438
  }
1332
1439
  }
@@ -1334,6 +1441,21 @@ async function scanCodexSession(filePath) {
1334
1441
  rl.close();
1335
1442
  stream.destroy();
1336
1443
  }
1444
+ // Price the final cumulative token snapshot once, against the session model.
1445
+ let costUsd;
1446
+ if (model && lastTotalTokenUsage) {
1447
+ const c = costOfUsage({
1448
+ model,
1449
+ inputTokens: lastTotalTokenUsage.input_tokens,
1450
+ outputTokens: (lastTotalTokenUsage.output_tokens ?? 0) + (lastTotalTokenUsage.reasoning_output_tokens ?? 0),
1451
+ cacheReadTokens: lastTotalTokenUsage.cached_input_tokens,
1452
+ });
1453
+ if (c > 0)
1454
+ costUsd = c;
1455
+ }
1456
+ const durationMs = firstTsMs !== undefined && lastTsMs !== undefined && lastTsMs > firstTsMs
1457
+ ? lastTsMs - firstTsMs
1458
+ : undefined;
1337
1459
  return {
1338
1460
  sessionId,
1339
1461
  timestamp,
@@ -1343,6 +1465,8 @@ async function scanCodexSession(filePath) {
1343
1465
  topic,
1344
1466
  messageCount,
1345
1467
  tokenCount,
1468
+ costUsd,
1469
+ durationMs,
1346
1470
  contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
1347
1471
  };
1348
1472
  }
@@ -57,6 +57,8 @@ export interface SessionStats {
57
57
  }
58
58
  /** Compute aggregate statistics (turns, tools, tokens, duration) from session events. */
59
59
  export declare function computeSummaryStats(events: SessionEvent[]): SessionStats;
60
+ /** Format a duration in milliseconds as a human-readable string (e.g. '12 min', '2h 30min'). */
61
+ export declare function formatDuration(ms: number): string;
60
62
  /**
61
63
  * Return the stats line for a session summary header.
62
64
  * e.g. "221 turns · 198 tools (10 errors) · 67.5M cached / 361K out · 12 min"
@@ -218,7 +218,7 @@ function formatTokenCount(n) {
218
218
  return (m >= 100 ? Math.round(m) : parseFloat(m.toFixed(1))) + 'M';
219
219
  }
220
220
  /** Format a duration in milliseconds as a human-readable string (e.g. '12 min', '2h 30min'). */
221
- function formatDuration(ms) {
221
+ export function formatDuration(ms) {
222
222
  const totalMin = Math.round(ms / 60_000);
223
223
  if (totalMin < 1)
224
224
  return 'under 1 min';
@@ -52,6 +52,10 @@ export interface SessionMeta {
52
52
  gitBranch?: string;
53
53
  messageCount?: number;
54
54
  tokenCount?: number;
55
+ /** Total USD cost, computed at scan time from per-model token usage (issue #323). */
56
+ costUsd?: number;
57
+ /** Wall-clock duration in ms (lastTs − firstTs), persisted at scan time. */
58
+ durationMs?: number;
55
59
  version?: string;
56
60
  account?: string;
57
61
  topic?: string;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Umbrella `agents sync` orchestration — "make this machine current".
3
+ *
4
+ * Bare `agents sync` fetches remote state (config repos + secrets + sessions)
5
+ * then reconciles it into every installed agent's version home. Each stage is
6
+ * an existing exported library function; this module only sequences them and
7
+ * decides — from the flags — which stages run (`planUmbrellaStages`). The
8
+ * planner is pure so the flag matrix is unit-tested without any I/O.
9
+ *
10
+ * Stage backends:
11
+ * repos -> git pull of ~/.agents + enabled ~/.agents-* extras (pullRepo)
12
+ * secrets -> listRemoteBundles + pullBundle (needs a passphrase; skipped
13
+ * cleanly when none is available — tokenized non-interactive auth
14
+ * arrives with `agents login`, #366/#367)
15
+ * sessions -> syncSessions(), gated by isSyncConfigured() exactly like the daemon
16
+ * reconcile-> refresh({ skipPrompts }) — re-materialize resources into homes
17
+ */
18
+ /** The five umbrella flags off `agents sync`. */
19
+ export interface UmbrellaFlags {
20
+ repos?: boolean;
21
+ secrets?: boolean;
22
+ sessions?: boolean;
23
+ cloud?: boolean;
24
+ local?: boolean;
25
+ }
26
+ /** Which stages a given flag combination runs. */
27
+ export interface UmbrellaPlan {
28
+ fetchRepos: boolean;
29
+ fetchSecrets: boolean;
30
+ fetchSessions: boolean;
31
+ reconcile: boolean;
32
+ }
33
+ /**
34
+ * Decide which stages run. Pure — no I/O. Semantics:
35
+ * bare (no flags) fetch all three, then reconcile
36
+ * --local reconcile only, no fetch
37
+ * --cloud fetch (all, or the selected subset), skip reconcile
38
+ * --repos/--secrets/... fetch only the selected types, then reconcile
39
+ * `--local` wins over everything; `--cloud` suppresses reconcile.
40
+ */
41
+ export declare function planUmbrellaStages(f: UmbrellaFlags): UmbrellaPlan;
42
+ export interface UmbrellaResult {
43
+ plan: UmbrellaPlan;
44
+ repos?: {
45
+ pulled: number;
46
+ errors: string[];
47
+ };
48
+ secrets?: {
49
+ pulled: number;
50
+ skipped: boolean;
51
+ reason?: string;
52
+ errors: string[];
53
+ };
54
+ sessions?: {
55
+ ran: boolean;
56
+ pushed: number;
57
+ pulled: number;
58
+ merged: number;
59
+ };
60
+ reconciled: boolean;
61
+ }
62
+ export interface RunUmbrellaArgs {
63
+ flags: UmbrellaFlags;
64
+ /** Progress sink (already quiet-aware in the caller). */
65
+ log: (msg: string) => void;
66
+ /** Pass `skipPrompts` through to reconcile / non-interactive behavior. */
67
+ yes: boolean;
68
+ /** Secrets passphrase, if available (env var or prompt). Undefined => skip secrets. */
69
+ passphrase?: string;
70
+ }
71
+ /**
72
+ * Execute the planned stages in order: repos -> secrets -> sessions -> reconcile.
73
+ * A failure in one fetch stage is recorded and does not abort the others or the
74
+ * reconcile — `agents sync` should make as much current as it can in one pass.
75
+ */
76
+ export declare function runUmbrellaSync(args: RunUmbrellaArgs): Promise<UmbrellaResult>;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Umbrella `agents sync` orchestration — "make this machine current".
3
+ *
4
+ * Bare `agents sync` fetches remote state (config repos + secrets + sessions)
5
+ * then reconciles it into every installed agent's version home. Each stage is
6
+ * an existing exported library function; this module only sequences them and
7
+ * decides — from the flags — which stages run (`planUmbrellaStages`). The
8
+ * planner is pure so the flag matrix is unit-tested without any I/O.
9
+ *
10
+ * Stage backends:
11
+ * repos -> git pull of ~/.agents + enabled ~/.agents-* extras (pullRepo)
12
+ * secrets -> listRemoteBundles + pullBundle (needs a passphrase; skipped
13
+ * cleanly when none is available — tokenized non-interactive auth
14
+ * arrives with `agents login`, #366/#367)
15
+ * sessions -> syncSessions(), gated by isSyncConfigured() exactly like the daemon
16
+ * reconcile-> refresh({ skipPrompts }) — re-materialize resources into homes
17
+ */
18
+ import { pullRepo } from './git.js';
19
+ import { getUserAgentsDir, getEnabledExtraRepos } from './state.js';
20
+ import { listRemoteBundles, pullBundle } from './secrets/sync.js';
21
+ /**
22
+ * Decide which stages run. Pure — no I/O. Semantics:
23
+ * bare (no flags) fetch all three, then reconcile
24
+ * --local reconcile only, no fetch
25
+ * --cloud fetch (all, or the selected subset), skip reconcile
26
+ * --repos/--secrets/... fetch only the selected types, then reconcile
27
+ * `--local` wins over everything; `--cloud` suppresses reconcile.
28
+ */
29
+ export function planUmbrellaStages(f) {
30
+ if (f.local) {
31
+ return { fetchRepos: false, fetchSecrets: false, fetchSessions: false, reconcile: true };
32
+ }
33
+ const anySelector = !!(f.repos || f.secrets || f.sessions);
34
+ if (anySelector) {
35
+ return {
36
+ fetchRepos: !!f.repos,
37
+ fetchSecrets: !!f.secrets,
38
+ fetchSessions: !!f.sessions,
39
+ reconcile: !f.cloud,
40
+ };
41
+ }
42
+ // No per-type selector: bare = all + reconcile; --cloud = all, no reconcile.
43
+ return { fetchRepos: true, fetchSecrets: true, fetchSessions: true, reconcile: !f.cloud };
44
+ }
45
+ /**
46
+ * Execute the planned stages in order: repos -> secrets -> sessions -> reconcile.
47
+ * A failure in one fetch stage is recorded and does not abort the others or the
48
+ * reconcile — `agents sync` should make as much current as it can in one pass.
49
+ */
50
+ export async function runUmbrellaSync(args) {
51
+ const { flags, log, yes, passphrase } = args;
52
+ const plan = planUmbrellaStages(flags);
53
+ const result = { plan, reconciled: false };
54
+ if (plan.fetchRepos) {
55
+ const dirs = [
56
+ { alias: 'user', dir: getUserAgentsDir() },
57
+ ...getEnabledExtraRepos().map((e) => ({ alias: e.alias, dir: e.dir })),
58
+ ];
59
+ let pulled = 0;
60
+ const errors = [];
61
+ for (const { alias, dir } of dirs) {
62
+ const r = await pullRepo(dir);
63
+ if (r.success) {
64
+ pulled++;
65
+ log(`repos: ${alias} → ${r.commit}`);
66
+ }
67
+ else {
68
+ errors.push(`${alias}: ${r.error ?? 'unknown error'}`);
69
+ }
70
+ }
71
+ result.repos = { pulled, errors };
72
+ }
73
+ if (plan.fetchSecrets) {
74
+ if (!passphrase) {
75
+ result.secrets = {
76
+ pulled: 0,
77
+ skipped: true,
78
+ reason: 'no passphrase — set AGENTS_SECRETS_PASSPHRASE or run `agents login` (#366)',
79
+ errors: [],
80
+ };
81
+ log('secrets: skipped (no passphrase available)');
82
+ }
83
+ else {
84
+ let pulled = 0;
85
+ const errors = [];
86
+ try {
87
+ const remote = await listRemoteBundles();
88
+ for (const b of remote) {
89
+ try {
90
+ await pullBundle(b.name, { passphrase, force: true });
91
+ pulled++;
92
+ log(`secrets: ${b.name}`);
93
+ }
94
+ catch (err) {
95
+ errors.push(`${b.name}: ${err.message}`);
96
+ }
97
+ }
98
+ }
99
+ catch (err) {
100
+ errors.push(err.message);
101
+ }
102
+ result.secrets = { pulled, skipped: false, errors };
103
+ }
104
+ }
105
+ if (plan.fetchSessions) {
106
+ // Gate exactly like the daemon: a missing r2.backups bundle is a clean no-op,
107
+ // not an error that fails the whole sync.
108
+ const { isSyncConfigured } = await import('./session/sync/config.js');
109
+ if (isSyncConfigured()) {
110
+ const { syncSessions } = await import('./session/sync/sync.js');
111
+ const r = await syncSessions();
112
+ result.sessions = { ran: true, pushed: r.pushed, pulled: r.pulled, merged: r.merged };
113
+ log(`sessions: pushed ${r.pushed}, pulled ${r.pulled}, merged ${r.merged}`);
114
+ }
115
+ else {
116
+ result.sessions = { ran: false, pushed: 0, pulled: 0, merged: 0 };
117
+ }
118
+ }
119
+ if (plan.reconcile) {
120
+ const { refresh } = await import('./refresh.js');
121
+ await refresh({ skipPrompts: yes });
122
+ result.reconciled = true;
123
+ }
124
+ return result;
125
+ }