@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.
- package/CHANGELOG.md +19 -0
- package/README.md +1 -1
- package/dist/commands/budget.d.ts +14 -0
- package/dist/commands/budget.js +137 -0
- package/dist/commands/cost.d.ts +12 -0
- package/dist/commands/cost.js +139 -0
- package/dist/commands/exec.d.ts +20 -0
- package/dist/commands/exec.js +382 -5
- package/dist/commands/secrets.d.ts +15 -0
- package/dist/commands/secrets.js +250 -4
- package/dist/commands/sessions.js +4 -0
- package/dist/commands/sync.d.ts +10 -3
- package/dist/commands/sync.js +72 -9
- package/dist/index.js +4 -0
- package/dist/lib/budget/config.d.ts +9 -0
- package/dist/lib/budget/config.js +115 -0
- package/dist/lib/budget/enforce.d.ts +94 -0
- package/dist/lib/budget/enforce.js +151 -0
- package/dist/lib/budget/ledger.d.ts +61 -0
- package/dist/lib/budget/ledger.js +107 -0
- package/dist/lib/budget/preflight.d.ts +110 -0
- package/dist/lib/budget/preflight.js +200 -0
- package/dist/lib/checkpoint.d.ts +54 -0
- package/dist/lib/checkpoint.js +56 -0
- package/dist/lib/cloud/rush.js +18 -0
- package/dist/lib/exec.d.ts +36 -0
- package/dist/lib/exec.js +192 -4
- package/dist/lib/git.d.ts +18 -0
- package/dist/lib/git.js +67 -4
- package/dist/lib/hooks.js +12 -0
- package/dist/lib/loop.d.ts +145 -0
- package/dist/lib/loop.js +330 -0
- package/dist/lib/mcp.d.ts +7 -0
- package/dist/lib/mcp.js +24 -0
- package/dist/lib/models.d.ts +11 -0
- package/dist/lib/models.js +21 -0
- package/dist/lib/plugin-marketplace.js +16 -6
- package/dist/lib/plugins.js +5 -2
- package/dist/lib/pricing/cost.d.ts +46 -0
- package/dist/lib/pricing/cost.js +71 -0
- package/dist/lib/pricing/index.d.ts +8 -0
- package/dist/lib/pricing/index.js +8 -0
- package/dist/lib/pricing/prices.json +138 -0
- package/dist/lib/pricing/table.d.ts +17 -0
- package/dist/lib/pricing/table.js +73 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +134 -0
- package/dist/lib/secrets/agent.js +501 -0
- package/dist/lib/secrets/bundles.d.ts +21 -0
- package/dist/lib/secrets/bundles.js +43 -0
- package/dist/lib/secrets/drivers/rush.d.ts +14 -0
- package/dist/lib/secrets/drivers/rush.js +84 -0
- package/dist/lib/secrets/linux.js +88 -10
- package/dist/lib/secrets/sync-backend.d.ts +48 -0
- package/dist/lib/secrets/sync-backend.js +13 -0
- package/dist/lib/secrets/sync.d.ts +15 -23
- package/dist/lib/secrets/sync.js +31 -66
- package/dist/lib/session/db.d.ts +40 -0
- package/dist/lib/session/db.js +84 -2
- package/dist/lib/session/discover.d.ts +2 -0
- package/dist/lib/session/discover.js +126 -2
- package/dist/lib/session/render.d.ts +2 -0
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/session/types.d.ts +4 -0
- package/dist/lib/sync-umbrella.d.ts +76 -0
- package/dist/lib/sync-umbrella.js +125 -0
- package/dist/lib/teams/agents.d.ts +32 -0
- package/dist/lib/teams/agents.js +66 -3
- package/dist/lib/teams/api.js +20 -0
- package/dist/lib/teams/parsers.js +16 -4
- package/dist/lib/types.d.ts +48 -0
- package/dist/lib/workflows.d.ts +56 -0
- package/dist/lib/workflows.js +72 -5
- package/package.json +2 -1
package/dist/lib/session/db.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|