@phnx-labs/agents-cli 1.20.17 → 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 +15 -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/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/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/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/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/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.d.ts
CHANGED
|
@@ -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. */
|
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;
|
|
@@ -17,6 +17,14 @@ export declare enum AgentStatus {
|
|
|
17
17
|
export type TaskType = 'plan' | 'implement' | 'test' | 'review' | 'bugfix' | 'docs';
|
|
18
18
|
export declare const VALID_TASK_TYPES: readonly TaskType[];
|
|
19
19
|
export type { AgentType } from './parsers.js';
|
|
20
|
+
/**
|
|
21
|
+
* Wrap a teammate argv in a POSIX shell command that runs it and then records
|
|
22
|
+
* the real exit code to `exitCodePath`. `echo $?` captures the status of the
|
|
23
|
+
* preceding command, so the sentinel reflects the underlying CLI's exit code,
|
|
24
|
+
* not the shell's. Single source of truth shared by launchProcess() and its
|
|
25
|
+
* test. See reapProcess() for how the sentinel is consumed.
|
|
26
|
+
*/
|
|
27
|
+
export declare function buildSentinelCommand(cmd: string[], exitCodePath: string): string;
|
|
20
28
|
/**
|
|
21
29
|
* Capture a stable identifier for a process at the moment it was started.
|
|
22
30
|
* Used to defeat PID reuse: a kill(pid, ...) is only safe when the process
|
|
@@ -118,6 +126,13 @@ export declare class AgentProcess {
|
|
|
118
126
|
}>;
|
|
119
127
|
getStdoutPath(): Promise<string>;
|
|
120
128
|
getMetaPath(): Promise<string>;
|
|
129
|
+
/**
|
|
130
|
+
* Path to the exit-code sentinel. The launcher wraps the teammate command in
|
|
131
|
+
* a shell that writes the underlying CLI's `$?` here once it exits. Detached
|
|
132
|
+
* teammates can't be wait()ed on by the parent, so this file is the only
|
|
133
|
+
* durable record of the real exit status — see reapProcess().
|
|
134
|
+
*/
|
|
135
|
+
getExitCodePath(): Promise<string>;
|
|
121
136
|
toDict(): any;
|
|
122
137
|
duration(): string | null;
|
|
123
138
|
get events(): any[];
|
|
@@ -131,6 +146,23 @@ export declare class AgentProcess {
|
|
|
131
146
|
static loadFromDisk(agentId: string, baseDir?: string | null): Promise<AgentProcess | null>;
|
|
132
147
|
isProcessAlive(): boolean;
|
|
133
148
|
updateStatusFromProcess(): Promise<void>;
|
|
149
|
+
/**
|
|
150
|
+
* Recover the teammate's exit status after its process is gone.
|
|
151
|
+
*
|
|
152
|
+
* The teammate is spawned detached + unref()'d (see launchProcess), so the
|
|
153
|
+
* parent never gets the child's exit code from the OS. Instead the launcher
|
|
154
|
+
* wraps the command in a shell that records `$?` to the exit-code sentinel.
|
|
155
|
+
* This reads that file:
|
|
156
|
+
* - still alive -> null (no verdict yet)
|
|
157
|
+
* - sentinel present -> the real exit code (0 = success)
|
|
158
|
+
* - sentinel absent -> 1 (the shell was killed before it could write
|
|
159
|
+
* it, e.g. SIGKILL on timeout/stop — a real
|
|
160
|
+
* failure)
|
|
161
|
+
*
|
|
162
|
+
* Returning a real code (not a hardcoded 1) is what lets agents whose stream
|
|
163
|
+
* never emits a parsed terminal event — kimi, antigravity, droid — be marked
|
|
164
|
+
* completed on success instead of falsely failed.
|
|
165
|
+
*/
|
|
134
166
|
private reapProcess;
|
|
135
167
|
}
|
|
136
168
|
/**
|