teamshare-bridge 0.26.20 → 0.26.21

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 (46) hide show
  1. package/dist/bridge/daemon.js +60 -0
  2. package/dist/bridge/daemon.js.map +1 -1
  3. package/dist/bridge/local-server.js +29 -11
  4. package/dist/bridge/local-server.js.map +1 -1
  5. package/dist/bridge/session-relay.d.ts +14 -0
  6. package/dist/bridge/session-relay.js +53 -2
  7. package/dist/bridge/session-relay.js.map +1 -1
  8. package/dist/cli/commands/research.d.ts +7 -3
  9. package/dist/cli/commands/research.js +67 -15
  10. package/dist/cli/commands/research.js.map +1 -1
  11. package/dist/cli/commands/usage.js +85 -32
  12. package/dist/cli/commands/usage.js.map +1 -1
  13. package/dist/cli/index.js +1 -1
  14. package/dist/cli/index.js.map +1 -1
  15. package/dist/lib/brief.js +3 -2
  16. package/dist/lib/brief.js.map +1 -1
  17. package/dist/lib/browser/batch.d.ts +23 -0
  18. package/dist/lib/browser/batch.js +33 -0
  19. package/dist/lib/browser/batch.js.map +1 -1
  20. package/dist/lib/browser/manager.d.ts +1 -0
  21. package/dist/lib/browser/manager.js +1 -0
  22. package/dist/lib/browser/manager.js.map +1 -1
  23. package/dist/lib/browser/network-log.d.ts +28 -3
  24. package/dist/lib/browser/network-log.js +74 -2
  25. package/dist/lib/browser/network-log.js.map +1 -1
  26. package/dist/lib/browser/profile-sweep.d.ts +15 -0
  27. package/dist/lib/browser/profile-sweep.js +69 -0
  28. package/dist/lib/browser/profile-sweep.js.map +1 -0
  29. package/dist/lib/browser/screen-change.d.ts +5 -0
  30. package/dist/lib/browser/screen-change.js +22 -1
  31. package/dist/lib/browser/screen-change.js.map +1 -1
  32. package/dist/lib/browser/session-pool.d.ts +40 -0
  33. package/dist/lib/browser/session-pool.js +62 -1
  34. package/dist/lib/browser/session-pool.js.map +1 -1
  35. package/dist/lib/browser/session.d.ts +51 -8
  36. package/dist/lib/browser/session.js +144 -15
  37. package/dist/lib/browser/session.js.map +1 -1
  38. package/dist/lib/llm/usage-reader.d.ts +91 -4
  39. package/dist/lib/llm/usage-reader.js +750 -111
  40. package/dist/lib/llm/usage-reader.js.map +1 -1
  41. package/dist/lib/llm/usage.d.ts +9 -9
  42. package/dist/lib/llm/usage.js +9 -8
  43. package/dist/lib/llm/usage.js.map +1 -1
  44. package/dist/lib/orchestrator/handlers/browser.js +342 -56
  45. package/dist/lib/orchestrator/handlers/browser.js.map +1 -1
  46. package/package.json +1 -1
@@ -1,40 +1,141 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SUPPORTED_USAGE_HARNESSES = exports.USAGE_HARNESSES = void 0;
4
+ exports.todayLocalDay = todayLocalDay;
5
+ exports.dayBounds = dayBounds;
6
+ exports.estimateModelCost = estimateModelCost;
7
+ exports.readOpencodeSessions = readOpencodeSessions;
8
+ exports.readClaudeSessions = readClaudeSessions;
9
+ exports.readCodexSessions = readCodexSessions;
3
10
  exports.readUsageReport = readUsageReport;
4
11
  /**
5
- * Usage report reader — reads cost data directly from harness databases.
12
+ * Usage report reader — reads cost data directly from harness-local stores.
6
13
  *
7
- * Replaces the Python tools/usage_report.py with in-process Node reads via
8
- * better-sqlite3. Supports multiple providers:
9
- * - opencode: ~/.local/share/opencode/opencode.db (session table)
10
- * - codex: ~/.codex/logs_2.sqlite (logs table, no cost data yet)
11
- * - claude/gemini: no local cost database (returns empty)
14
+ * Each supported harness keeps its own ledger on this machine:
15
+ * - opencode: ~/.local/share/opencode/opencode.db (`session_v2` table;
16
+ * the legacy `session` table stopped at V2 and is only a fallback).
17
+ * Cost + all token counts are recorded per session.
18
+ * - claude: ~/.claude/projects (jsonl transcripts holding `message.usage`
19
+ * per assistant message). Tokens are real; cost is ESTIMATED from
20
+ * catalogue (`~/.cache/opencode/models.json`).
21
+ * - codex: ~/.codex/sessions (rollout-*.jsonl files holding `token_count`
22
+ * events with `last_token_usage`). Tokens are real; cost is ESTIMATED the
23
+ * same way. Only `last_token_usage` deltas are summed — `total_token_usage`
24
+ * is cumulative per session and summing it would massively overcount.
25
+ *
26
+ * Every other harness the bridge knows (gemini, antigravity, copilot, cursor,
27
+ * goose, openhands, aider, cline, openrouter) keeps no local token/cost data,
28
+ * so it is listed as unavailable with the reason instead of a silent zero.
12
29
  *
13
30
  * The report is fully offline, needs neither the backend nor the daemon.
14
31
  */
15
32
  const node_fs_1 = require("node:fs");
16
33
  const node_os_1 = require("node:os");
17
34
  const node_path_1 = require("node:path");
18
- // ── Provider-specific database paths ──────────────────────────────────────
19
- function opencodeDbPath() {
20
- return (0, node_path_1.join)((0, node_os_1.homedir)(), '.local', 'share', 'opencode', 'opencode.db');
21
- }
22
- function codexDbPath() {
23
- return (0, node_path_1.join)((0, node_os_1.homedir)(), '.codex', 'logs_2.sqlite');
24
- }
25
- /** Returns the first available provider path, or null if none found. */
26
- function detectProvider() {
27
- const opencodePath = opencodeDbPath();
28
- if ((0, node_fs_1.existsSync)(opencodePath))
29
- return { name: 'opencode', dbPath: opencodePath };
30
- const codexPath = codexDbPath();
31
- if ((0, node_fs_1.existsSync)(codexPath))
32
- return { name: 'codex', dbPath: codexPath };
33
- return null;
35
+ exports.USAGE_HARNESSES = [
36
+ {
37
+ id: 'opencode',
38
+ label: 'Opencode',
39
+ supported: true,
40
+ costSource: 'recorded',
41
+ },
42
+ {
43
+ id: 'claude',
44
+ label: 'Claude Code',
45
+ supported: true,
46
+ costSource: 'estimated',
47
+ },
48
+ { id: 'codex', label: 'Codex', supported: true, costSource: 'estimated' },
49
+ {
50
+ id: 'gemini',
51
+ label: 'Gemini CLI',
52
+ supported: false,
53
+ costSource: 'unavailable',
54
+ unavailableReason: 'Gemini CLI keeps chat logs only — no token or cost data is stored locally',
55
+ },
56
+ {
57
+ id: 'antigravity',
58
+ label: 'Antigravity',
59
+ supported: false,
60
+ costSource: 'unavailable',
61
+ unavailableReason: 'Antigravity transcripts record steps only — no token or cost data',
62
+ },
63
+ {
64
+ id: 'copilot',
65
+ label: 'Copilot CLI',
66
+ supported: false,
67
+ costSource: 'unavailable',
68
+ unavailableReason: 'Copilot CLI has no local usage store',
69
+ },
70
+ {
71
+ id: 'cursor',
72
+ label: 'Cursor',
73
+ supported: false,
74
+ costSource: 'unavailable',
75
+ unavailableReason: 'Cursor usage lives in the Cursor dashboard — no local store',
76
+ },
77
+ {
78
+ id: 'goose',
79
+ label: 'Goose',
80
+ supported: false,
81
+ costSource: 'unavailable',
82
+ unavailableReason: 'Goose has no local usage store on this machine',
83
+ },
84
+ {
85
+ id: 'openhands',
86
+ label: 'OpenHands',
87
+ supported: false,
88
+ costSource: 'unavailable',
89
+ unavailableReason: 'OpenHands has no local usage store on this machine',
90
+ },
91
+ {
92
+ id: 'aider',
93
+ label: 'Aider',
94
+ supported: false,
95
+ costSource: 'unavailable',
96
+ unavailableReason: 'Aider has no local usage store',
97
+ },
98
+ {
99
+ id: 'cline',
100
+ label: 'Cline',
101
+ supported: false,
102
+ costSource: 'unavailable',
103
+ unavailableReason: 'Cline session files record provider and model but no token counts',
104
+ },
105
+ {
106
+ id: 'openrouter',
107
+ label: 'OpenRouter',
108
+ supported: false,
109
+ costSource: 'unavailable',
110
+ unavailableReason: 'OpenRouter is a hosted gateway — usage lives on its dashboard',
111
+ },
112
+ ];
113
+ exports.SUPPORTED_USAGE_HARNESSES = exports.USAGE_HARNESSES.filter((h) => h.supported).map((h) => h.id);
114
+ function harnessDef(id) {
115
+ return exports.USAGE_HARNESSES.find((h) => h.id === id);
116
+ }
117
+ // ── Paths ───────────────────────────────────────────────────────────────────
118
+ function opencodeDbPath(home) {
119
+ return (0, node_path_1.join)(home, '.local', 'share', 'opencode', 'opencode.db');
120
+ }
121
+ function claudeProjectsPath(home) {
122
+ return (0, node_path_1.join)(home, '.claude', 'projects');
34
123
  }
35
- // ── Day bounds (local midnight ms-epoch) ─────────────────────────────────
36
- function today() {
37
- return new Date().toISOString().slice(0, 10);
124
+ function codexSessionsPath(home) {
125
+ return (0, node_path_1.join)(home, '.codex', 'sessions');
126
+ }
127
+ function codexIndexPath(home) {
128
+ return (0, node_path_1.join)(home, '.codex', 'session_index.jsonl');
129
+ }
130
+ function modelsCatalogPath(home) {
131
+ return (0, node_path_1.join)(home, '.cache', 'opencode', 'models.json');
132
+ }
133
+ // ── Day bounds (local midnight ms-epoch) ────────────────────────────────────
134
+ function todayLocalDay() {
135
+ const now = new Date();
136
+ const month = String(now.getMonth() + 1).padStart(2, '0');
137
+ const date = String(now.getDate()).padStart(2, '0');
138
+ return `${now.getFullYear()}-${month}-${date}`;
38
139
  }
39
140
  function dayBounds(day) {
40
141
  const d = new Date(`${day}T00:00:00`);
@@ -43,6 +144,113 @@ function dayBounds(day) {
43
144
  const end = new Date(start.getTime() + 86_400_000);
44
145
  return { startMs: start.getTime(), endMs: end.getTime() };
45
146
  }
147
+ let priceCache = null;
148
+ function stripDateSuffix(id) {
149
+ return id.replace(/-20\d{6}(-\d+)?$/, '').replace(/-\d{4}-\d{2}-\d{2}$/, '');
150
+ }
151
+ function toPrice(raw, fallbackOutput) {
152
+ if (!raw || typeof raw !== 'object')
153
+ return null;
154
+ const c = raw;
155
+ const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : 0;
156
+ // Catalogue rates are per MTok USD.
157
+ return {
158
+ input: num(c.input),
159
+ output: num(c.output) || fallbackOutput,
160
+ cacheRead: num(c.cache_read ?? c.cacheRead),
161
+ cacheWrite: num(c.cache_write ?? c.cacheWrite),
162
+ };
163
+ }
164
+ function loadPricing(home) {
165
+ const path = modelsCatalogPath(home);
166
+ let mtimeMs = 0;
167
+ try {
168
+ mtimeMs = (0, node_fs_1.statSync)(path).mtimeMs;
169
+ }
170
+ catch {
171
+ priceCache = { home, mtimeMs: -1, map: new Map() };
172
+ return priceCache.map;
173
+ }
174
+ if (priceCache &&
175
+ priceCache.home === home &&
176
+ priceCache.mtimeMs === mtimeMs) {
177
+ return priceCache.map;
178
+ }
179
+ const map = new Map();
180
+ try {
181
+ const raw = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'));
182
+ for (const provider of Object.values(raw)) {
183
+ if (!provider || typeof provider !== 'object')
184
+ continue;
185
+ const models = provider.models;
186
+ if (!models || typeof models !== 'object')
187
+ continue;
188
+ for (const [id, entry] of Object.entries(models)) {
189
+ if (map.has(id))
190
+ continue;
191
+ const price = toPrice(entry?.cost ?? entry, 0);
192
+ if (price)
193
+ map.set(id, price);
194
+ }
195
+ }
196
+ }
197
+ catch {
198
+ /* corrupt catalogue reads as empty — tokens still reported */
199
+ }
200
+ priceCache = { home, mtimeMs, map };
201
+ return map;
202
+ }
203
+ /**
204
+ * Estimate a session/message cost from the catalogue. Returns cost 0 with
205
+ * known=false when the model has no price — the caller still reports tokens.
206
+ * `style` decides how cached input is charged: claude reports cache tokens
207
+ * separately from input; codex `input_tokens` already include the cached
208
+ * ones, so only the fresh remainder is charged at the input rate.
209
+ */
210
+ function estimateModelCost(modelId, tokens, style, home = (0, node_os_1.homedir)()) {
211
+ if (!modelId || modelId === '<synthetic>')
212
+ return { cost: 0, known: false };
213
+ const map = loadPricing(home);
214
+ const price = map.get(modelId) ??
215
+ map.get(stripDateSuffix(modelId)) ??
216
+ map.get(modelId.toLowerCase());
217
+ if (!price)
218
+ return { cost: 0, known: false };
219
+ const freshInput = style === 'codex'
220
+ ? Math.max(0, tokens.input - tokens.cacheRead)
221
+ : tokens.input;
222
+ const cost = (freshInput * price.input +
223
+ tokens.cacheRead * (price.cacheRead || 0) +
224
+ tokens.cacheWrite * (price.cacheWrite || price.output) +
225
+ tokens.output * price.output) /
226
+ 1_000_000;
227
+ return { cost: round6(cost), known: true };
228
+ }
229
+ // ── Attribution ─────────────────────────────────────────────────────────────
230
+ /**
231
+ * The exact title shapes the bridge passes as `--title` (case-sensitive):
232
+ * `TeamShare task <taskId> (agent <agentId>)` (run.ts), `TeamShare chat/doc …`,
233
+ * `Tool run <key> (<runId>)` (research.ts), `Plan task <taskId>` (plan.ts).
234
+ * A loose prefix would mislabel a human's own window that merely mentions
235
+ * TeamShare (e.g. "Teamshare quota display …"), so the shape is matched.
236
+ */
237
+ const TEAMSHARE_TITLE_RE = /^(TeamShare (task|chat|doc) |Tool run [\w-]+ \(|Plan task )/;
238
+ function isTeamshareWork(title, directory) {
239
+ if (directory.includes('.teamshare-runs') ||
240
+ directory.includes('.teamshare-worktrees')) {
241
+ return true;
242
+ }
243
+ return TEAMSHARE_TITLE_RE.test(title);
244
+ }
245
+ function attributeSession(title, directory) {
246
+ // NOTE: opencode's `agent` column is the subagent lane (build/explore/plan)
247
+ // and is set on interactive windows too, so it can never discriminate.
248
+ // The only reliable join key is where the session ran / what it is titled.
249
+ if (isTeamshareWork(title, directory))
250
+ return { attribution: 'agent', teamshare: true };
251
+ return { attribution: 'user', teamshare: false };
252
+ }
253
+ // ── Model parsing ───────────────────────────────────────────────────────────
46
254
  function parseModel(modelJson) {
47
255
  if (!modelJson)
48
256
  return {};
@@ -54,31 +262,54 @@ function parseModel(modelJson) {
54
262
  return {};
55
263
  }
56
264
  }
57
- // ── Attribution ───────────────────────────────────────────────────────────
58
- function attributeSession(title, providerId) {
59
- if (title?.startsWith('TeamShare '))
60
- return { attribution: 'agent', teamshare: true };
61
- // opencode-go / opencode: providerID == "opencode" or "opencode-go" = user sessions
62
- if (providerId === 'opencode' || providerId === 'opencode-go')
63
- return { attribution: 'user', teamshare: false };
64
- // claude, codex, gemini, etc. — treat as agent sessions
65
- return { attribution: 'agent', teamshare: false };
66
- }
67
- // ── opencode reader ──────────────────────────────────────────────────────
68
- function readOpencode(dbPath, day, agentFilter) {
69
- // Lazy-import better-sqlite3 so non-opencode providers don't need it loaded.
265
+ function pickOpencodeTable(db) {
266
+ try {
267
+ const tables = new Set(db
268
+ .prepare("SELECT name FROM sqlite_master WHERE type='table'")
269
+ .all().map((t) => t.name));
270
+ // V2 is the live table; legacy `session` stopped in 2026-10. Never read
271
+ // both — every legacy id also exists in session_v2, so a union would
272
+ // double-count.
273
+ if (tables.has('session_v2'))
274
+ return 'session_v2';
275
+ if (tables.has('session'))
276
+ return 'session';
277
+ }
278
+ catch {
279
+ /* fall through */
280
+ }
281
+ throw new Error('opencode.db has neither session_v2 nor session table');
282
+ }
283
+ function hasColumn(db, table, col) {
284
+ try {
285
+ const cols = db.prepare(`PRAGMA table_info(${table})`).all();
286
+ return cols.some((c) => c.name === col);
287
+ }
288
+ catch {
289
+ return false;
290
+ }
291
+ }
292
+ function readOpencodeSessions(day, agentFilter, home = (0, node_os_1.homedir)()) {
293
+ const dbPath = opencodeDbPath(home);
294
+ if (!(0, node_fs_1.existsSync)(dbPath)) {
295
+ throw new Error(`opencode db not found at ${dbPath} — has opencode ever run?`);
296
+ }
297
+ // Lazy-import better-sqlite3 so jsonl-only harnesses never load it.
70
298
  const Database = require('better-sqlite3');
71
299
  const db = new Database(dbPath, {
72
300
  readonly: true,
73
301
  fileMustExist: true,
74
302
  });
75
303
  try {
304
+ const table = pickOpencodeTable(db);
305
+ const wantDir = hasColumn(db, table, 'directory');
306
+ const wantAgent = hasColumn(db, table, 'agent');
76
307
  const { startMs, endMs } = dayBounds(day);
77
308
  const rows = db
78
309
  .prepare(`SELECT title, model, cost, tokens_input, tokens_output,
79
310
  tokens_reasoning, tokens_cache_read, tokens_cache_write,
80
- time_created, time_updated
81
- FROM session WHERE time_created >= ? AND time_created < ?
311
+ time_created, time_updated${wantDir ? ', directory' : ''}${wantAgent ? ', agent' : ''}
312
+ FROM ${table} WHERE time_created >= ? AND time_created < ?
82
313
  ORDER BY time_created`)
83
314
  .all(startMs, endMs);
84
315
  const sessions = [];
@@ -97,7 +328,8 @@ function readOpencode(dbPath, day, agentFilter) {
97
328
  continue;
98
329
  const model = parseModel(row.model);
99
330
  const providerId = model.providerID ?? '';
100
- const { attribution, teamshare } = attributeSession(title, providerId);
331
+ const directory = row.directory ?? '';
332
+ const { attribution, teamshare } = attributeSession(title, directory);
101
333
  // Agent filter
102
334
  if (agentFilter &&
103
335
  !title.toLowerCase().includes(`agent ${agentFilter}`.toLowerCase()))
@@ -106,7 +338,10 @@ function readOpencode(dbPath, day, agentFilter) {
106
338
  title,
107
339
  model: model.id ?? '',
108
340
  providerID: providerId,
341
+ harness: 'opencode',
109
342
  cost: round6(cost),
343
+ costEstimated: false,
344
+ costKnown: true,
110
345
  tokensInput: tIn,
111
346
  tokensOutput: tOut,
112
347
  tokensReasoning: tReason,
@@ -119,108 +354,512 @@ function readOpencode(dbPath, day, agentFilter) {
119
354
  teamshare,
120
355
  });
121
356
  }
122
- return buildReport(day, dbPath, 'opencode', sessions);
357
+ return { sessions, source: dbPath };
123
358
  }
124
359
  finally {
125
360
  db.close();
126
361
  }
127
362
  }
128
- // ── Codex reader (placeholder — no cost data yet) ────────────────────────
129
- function readCodex(dbPath, day) {
130
- const Database = require('better-sqlite3');
131
- const db = new Database(dbPath, {
132
- readonly: true,
133
- fileMustExist: true,
134
- });
363
+ // ── jsonl helpers ───────────────────────────────────────────────────────────
364
+ function walkJsonl(root, cap, newerThanMs, out = []) {
365
+ if (out.length >= cap)
366
+ return out;
367
+ let entries;
135
368
  try {
136
- // Codex logs_2.sqlite has a logs table but no cost/token columns yet.
137
- // Return empty report — ready for when codex adds cost data.
138
- return buildReport(day, dbPath, 'codex', []);
369
+ entries = (0, node_fs_1.readdirSync)(root, { withFileTypes: true });
139
370
  }
140
- finally {
141
- db.close();
371
+ catch {
372
+ return out;
373
+ }
374
+ for (const entry of entries) {
375
+ if (out.length >= cap)
376
+ break;
377
+ const full = (0, node_path_1.join)(root, entry.name);
378
+ try {
379
+ if (entry.isDirectory()) {
380
+ walkJsonl(full, cap, newerThanMs, out);
381
+ }
382
+ else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
383
+ try {
384
+ if ((0, node_fs_1.statSync)(full).mtimeMs >= newerThanMs)
385
+ out.push(full);
386
+ }
387
+ catch {
388
+ /* unreadable — skip */
389
+ }
390
+ }
391
+ }
392
+ catch {
393
+ /* best-effort walk */
394
+ }
395
+ }
396
+ return out;
397
+ }
398
+ function parseTimeMs(value) {
399
+ if (typeof value === 'number' && Number.isFinite(value)) {
400
+ return value > 1e12 ? value : value * 1000;
401
+ }
402
+ if (typeof value === 'string' && value) {
403
+ const ms = new Date(value).getTime();
404
+ return Number.isFinite(ms) ? ms : null;
405
+ }
406
+ return null;
407
+ }
408
+ function readClaudeSessions(day, agentFilter, home = (0, node_os_1.homedir)()) {
409
+ const root = claudeProjectsPath(home);
410
+ if (!(0, node_fs_1.existsSync)(root)) {
411
+ throw new Error(`no Claude projects at ${root} — has Claude Code ever run here?`);
412
+ }
413
+ const { startMs, endMs } = dayBounds(day);
414
+ const files = walkJsonl(root, 2000, startMs);
415
+ const buckets = new Map();
416
+ for (const file of files) {
417
+ let text;
418
+ try {
419
+ text = (0, node_fs_1.readFileSync)(file, 'utf8');
420
+ }
421
+ catch {
422
+ continue;
423
+ }
424
+ for (const line of text.split('\n')) {
425
+ const trimmed = line.trim();
426
+ if (!trimmed)
427
+ continue;
428
+ let obj;
429
+ try {
430
+ obj = JSON.parse(trimmed);
431
+ }
432
+ catch {
433
+ continue;
434
+ }
435
+ if (obj.type !== 'assistant')
436
+ continue;
437
+ const message = obj.message;
438
+ const usage = message?.usage;
439
+ if (!usage)
440
+ continue;
441
+ const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : 0;
442
+ const tIn = num(usage.input_tokens);
443
+ const tOut = num(usage.output_tokens);
444
+ const tCr = num(usage.cache_read_input_tokens);
445
+ const tCw = num(usage.cache_creation_input_tokens ?? usage.cache_creation_tokens);
446
+ if (!tIn && !tOut && !tCr && !tCw)
447
+ continue;
448
+ const at = parseTimeMs(obj.timestamp);
449
+ if (at === null || at < startMs || at >= endMs)
450
+ continue;
451
+ const sessionId = typeof obj.sessionId === 'string' && obj.sessionId
452
+ ? obj.sessionId
453
+ : file;
454
+ const cwd = typeof obj.cwd === 'string' ? obj.cwd : '';
455
+ const model = typeof message?.model === 'string' && message.model
456
+ ? message.model
457
+ : '';
458
+ let bucket = buckets.get(sessionId);
459
+ if (!bucket) {
460
+ bucket = {
461
+ sessionId,
462
+ cwd,
463
+ model,
464
+ firstMs: at,
465
+ lastMs: at,
466
+ input: 0,
467
+ output: 0,
468
+ cacheRead: 0,
469
+ cacheWrite: 0,
470
+ cost: 0,
471
+ known: false,
472
+ };
473
+ buckets.set(sessionId, bucket);
474
+ }
475
+ if (model && model !== '<synthetic>')
476
+ bucket.model = model;
477
+ if (cwd && !bucket.cwd)
478
+ bucket.cwd = cwd;
479
+ bucket.firstMs = Math.min(bucket.firstMs, at);
480
+ bucket.lastMs = Math.max(bucket.lastMs, at);
481
+ bucket.input += tIn;
482
+ bucket.output += tOut;
483
+ bucket.cacheRead += tCr;
484
+ bucket.cacheWrite += tCw;
485
+ if (model) {
486
+ const est = estimateModelCost(model, { input: tIn, output: tOut, cacheRead: tCr, cacheWrite: tCw }, 'claude', home);
487
+ bucket.cost = round6(bucket.cost + est.cost);
488
+ if (est.known)
489
+ bucket.known = true;
490
+ }
491
+ }
492
+ }
493
+ const sessions = [];
494
+ for (const bucket of buckets.values()) {
495
+ const shortCwd = bucket.cwd ? (0, node_path_1.basename)(bucket.cwd) : '';
496
+ const title = shortCwd
497
+ ? `Claude · ${shortCwd}`
498
+ : `Claude ${bucket.sessionId.slice(0, 8)}`;
499
+ if (agentFilter &&
500
+ !title.toLowerCase().includes(`agent ${agentFilter}`.toLowerCase()) &&
501
+ !bucket.cwd.toLowerCase().includes(agentFilter.toLowerCase())) {
502
+ continue;
503
+ }
504
+ const { attribution, teamshare } = attributeSession(title, bucket.cwd);
505
+ sessions.push({
506
+ title,
507
+ model: bucket.model,
508
+ providerID: 'anthropic',
509
+ harness: 'claude',
510
+ cost: bucket.cost,
511
+ costEstimated: true,
512
+ costKnown: bucket.known,
513
+ tokensInput: bucket.input,
514
+ tokensOutput: bucket.output,
515
+ tokensReasoning: 0,
516
+ tokensCacheRead: bucket.cacheRead,
517
+ tokensCacheWrite: bucket.cacheWrite,
518
+ timeCreated: bucket.firstMs,
519
+ timeUpdated: bucket.lastMs,
520
+ durationMin: round1(Math.max(0, bucket.lastMs - bucket.firstMs) / 60_000),
521
+ attribution,
522
+ teamshare,
523
+ });
524
+ }
525
+ sessions.sort((a, b) => a.timeCreated - b.timeCreated);
526
+ return { sessions, source: root };
527
+ }
528
+ // ── codex reader ────────────────────────────────────────────────────────────
529
+ function readCodexSessions(day, agentFilter, home = (0, node_os_1.homedir)()) {
530
+ const root = codexSessionsPath(home);
531
+ if (!(0, node_fs_1.existsSync)(root)) {
532
+ throw new Error(`no Codex sessions at ${root} — has Codex ever run here?`);
533
+ }
534
+ const { startMs, endMs } = dayBounds(day);
535
+ // Cross-midnight sessions live in the previous day's directory, so the
536
+ // mtime gate opens a day early and each event is filtered by its own time.
537
+ const files = walkJsonl(root, 500, startMs - 86_400_000);
538
+ // Thread names live beside the sessions, not in them.
539
+ const threadNames = new Map();
540
+ try {
541
+ const indexText = (0, node_fs_1.readFileSync)(codexIndexPath(home), 'utf8');
542
+ for (const line of indexText.split('\n')) {
543
+ const trimmed = line.trim();
544
+ if (!trimmed)
545
+ continue;
546
+ try {
547
+ const row = JSON.parse(trimmed);
548
+ if (typeof row.id === 'string' && typeof row.thread_name === 'string') {
549
+ threadNames.set(row.id, row.thread_name);
550
+ }
551
+ }
552
+ catch {
553
+ /* malformed index line — skip */
554
+ }
555
+ }
556
+ }
557
+ catch {
558
+ /* no index — titles fall back to the session id */
559
+ }
560
+ const buckets = new Map();
561
+ for (const file of files) {
562
+ let text;
563
+ try {
564
+ text = (0, node_fs_1.readFileSync)(file, 'utf8');
565
+ }
566
+ catch {
567
+ continue;
568
+ }
569
+ const base = (0, node_path_1.basename)(file, '.jsonl');
570
+ const fileSessionId = base.startsWith('rollout-')
571
+ ? base.slice('rollout-'.length)
572
+ : base;
573
+ const ensure = () => {
574
+ let b = buckets.get(file);
575
+ if (!b) {
576
+ b = {
577
+ key: file,
578
+ sessionId: fileSessionId,
579
+ cwd: '',
580
+ model: '',
581
+ firstMs: Number.MAX_SAFE_INTEGER,
582
+ lastMs: 0,
583
+ input: 0,
584
+ cached: 0,
585
+ output: 0,
586
+ reasoning: 0,
587
+ };
588
+ buckets.set(file, b);
589
+ }
590
+ return b;
591
+ };
592
+ for (const line of text.split('\n')) {
593
+ const trimmed = line.trim();
594
+ if (!trimmed)
595
+ continue;
596
+ let obj;
597
+ try {
598
+ obj = JSON.parse(trimmed);
599
+ }
600
+ catch {
601
+ continue;
602
+ }
603
+ const payload = obj.payload;
604
+ if (obj.type === 'session_meta' && payload) {
605
+ const b = ensure();
606
+ if (typeof payload.id === 'string' && payload.id) {
607
+ b.sessionId = payload.id;
608
+ }
609
+ if (typeof payload.cwd === 'string')
610
+ b.cwd = payload.cwd;
611
+ if (typeof payload.model === 'string' && payload.model) {
612
+ b.model = payload.model;
613
+ }
614
+ continue;
615
+ }
616
+ if (obj.type === 'turn_context' && payload) {
617
+ const model = payload.model;
618
+ if (typeof model === 'string' && model)
619
+ ensure().model = model;
620
+ continue;
621
+ }
622
+ if (obj.type !== 'event_msg' || !payload)
623
+ continue;
624
+ if (payload.type !== 'token_count')
625
+ continue;
626
+ const info = payload.info;
627
+ const last = info?.last_token_usage;
628
+ if (!last)
629
+ continue;
630
+ const at = parseTimeMs(obj.timestamp);
631
+ if (at === null || at < startMs || at >= endMs)
632
+ continue;
633
+ const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : 0;
634
+ const b = ensure();
635
+ b.firstMs = Math.min(b.firstMs, at);
636
+ b.lastMs = Math.max(b.lastMs, at);
637
+ b.input += num(last.input_tokens);
638
+ b.cached += num(last.cached_input_tokens);
639
+ b.output += num(last.output_tokens);
640
+ b.reasoning += num(last.reasoning_output_tokens);
641
+ }
642
+ }
643
+ const sessions = [];
644
+ for (const bucket of buckets.values()) {
645
+ if (bucket.lastMs === 0)
646
+ continue;
647
+ const title = threadNames.get(bucket.sessionId) ??
648
+ `Codex ${bucket.sessionId.slice(0, 8)}`;
649
+ if (agentFilter &&
650
+ !title.toLowerCase().includes(`agent ${agentFilter}`.toLowerCase()) &&
651
+ !bucket.cwd.toLowerCase().includes(agentFilter.toLowerCase())) {
652
+ continue;
653
+ }
654
+ const est = estimateModelCost(bucket.model, {
655
+ input: bucket.input,
656
+ output: bucket.output,
657
+ cacheRead: bucket.cached,
658
+ cacheWrite: 0,
659
+ }, 'codex', home);
660
+ const { attribution, teamshare } = attributeSession(title, bucket.cwd);
661
+ sessions.push({
662
+ title,
663
+ model: bucket.model,
664
+ providerID: 'openai',
665
+ harness: 'codex',
666
+ cost: est.cost,
667
+ costEstimated: true,
668
+ costKnown: est.known,
669
+ tokensInput: bucket.input,
670
+ tokensOutput: bucket.output,
671
+ // Reasoning tokens ride inside output_tokens on the Responses API —
672
+ // recorded for display, never charged twice.
673
+ tokensReasoning: bucket.reasoning,
674
+ tokensCacheRead: bucket.cached,
675
+ tokensCacheWrite: 0,
676
+ timeCreated: bucket.firstMs,
677
+ timeUpdated: bucket.lastMs,
678
+ durationMin: round1(Math.max(0, bucket.lastMs - bucket.firstMs) / 60_000),
679
+ attribution,
680
+ teamshare,
681
+ });
682
+ }
683
+ sessions.sort((a, b) => a.timeCreated - b.timeCreated);
684
+ return { sessions, source: root };
685
+ }
686
+ // ── Availability probe ──────────────────────────────────────────────────────
687
+ function probeHarness(def, home) {
688
+ if (!def.supported) {
689
+ return { available: false, reason: def.unavailableReason };
690
+ }
691
+ if (def.id === 'opencode') {
692
+ const p = opencodeDbPath(home);
693
+ return (0, node_fs_1.existsSync)(p)
694
+ ? { available: true, source: p }
695
+ : {
696
+ available: false,
697
+ reason: 'opencode has not run on this machine yet (no opencode.db)',
698
+ };
142
699
  }
700
+ if (def.id === 'claude') {
701
+ const p = claudeProjectsPath(home);
702
+ return (0, node_fs_1.existsSync)(p)
703
+ ? { available: true, source: p }
704
+ : {
705
+ available: false,
706
+ reason: 'no Claude Code history on this machine yet',
707
+ };
708
+ }
709
+ const p = codexSessionsPath(home);
710
+ return (0, node_fs_1.existsSync)(p)
711
+ ? { available: true, source: p }
712
+ : { available: false, reason: 'no Codex history on this machine yet' };
713
+ }
714
+ // ── Report builder ──────────────────────────────────────────────────────────
715
+ function emptyTotals() {
716
+ return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 };
143
717
  }
144
- // ── Report builder ────────────────────────────────────────────────────────
145
- function buildReport(day, dbPath, provider, sessions) {
718
+ function buildReport(day, harness, sessions, harnesses, totalsByHarness, sources, dbPath, notice) {
146
719
  const totalCost = round6(sessions.reduce((s, x) => s + x.cost, 0));
147
720
  const userCost = round6(sessions
148
721
  .filter((s) => s.attribution === 'user')
149
722
  .reduce((s, x) => s + x.cost, 0));
150
723
  const agentCost = round6(totalCost - userCost);
151
724
  const teamshareCost = round6(sessions.filter((s) => s.teamshare).reduce((s, x) => s + x.cost, 0));
725
+ const tokens = emptyTotals();
726
+ for (const s of sessions) {
727
+ tokens.input += s.tokensInput;
728
+ tokens.output += s.tokensOutput;
729
+ tokens.reasoning += s.tokensReasoning;
730
+ tokens.cacheRead += s.tokensCacheRead;
731
+ tokens.cacheWrite += s.tokensCacheWrite;
732
+ }
152
733
  return {
153
734
  day,
735
+ harness,
154
736
  dbPath,
155
- provider,
737
+ sources,
738
+ provider: harness,
156
739
  totalCost,
157
740
  sessionCount: sessions.length,
158
741
  userCost,
159
742
  agentCost,
160
743
  teamshareCost,
161
- tokens: {
162
- input: sessions.reduce((s, x) => s + x.tokensInput, 0),
163
- output: sessions.reduce((s, x) => s + x.tokensOutput, 0),
164
- reasoning: sessions.reduce((s, x) => s + x.tokensReasoning, 0),
165
- cacheRead: sessions.reduce((s, x) => s + x.tokensCacheRead, 0),
166
- cacheWrite: sessions.reduce((s, x) => s + x.tokensCacheWrite, 0),
167
- },
744
+ unpricedSessions: sessions.filter((s) => !s.costKnown).length,
745
+ ...(notice ? { notice } : {}),
746
+ harnesses,
747
+ totalsByHarness,
748
+ tokens,
168
749
  sessions,
169
750
  };
170
751
  }
171
- // ── Helpers ───────────────────────────────────────────────────────────────
172
- function round6(n) {
173
- return Math.round(n * 1_000_000) / 1_000_000;
174
- }
175
- function round1(n) {
176
- return Math.round(n * 10) / 10;
177
- }
178
- // ── Public API ────────────────────────────────────────────────────────────
752
+ // ── Public API ──────────────────────────────────────────────────────────────
179
753
  /**
180
- * Read the usage report directly from the harness database.
181
- * Auto-detects the provider (opencode > codex) or uses the explicit option.
182
- * Throws if no provider database is found.
754
+ * Read one harness (or 'all' supported harnesses) for a local day.
755
+ * Each harness is its own report slice: sessions carry `harness`,
756
+ * `costEstimated` and `costKnown`, and the UI switches between them.
757
+ * Throws when no local store exists for an explicitly requested harness.
183
758
  */
184
759
  function readUsageReport(opts = {}) {
185
- const day = opts.day ?? today();
186
- if (opts.provider === 'opencode') {
187
- const dbPath = opencodeDbPath();
188
- if (!(0, node_fs_1.existsSync)(dbPath))
189
- throw new Error(`opencode db not found at ${dbPath} — has opencode ever run?`);
190
- return readOpencode(dbPath, day, opts.agent ?? null);
191
- }
192
- if (opts.provider === 'codex') {
193
- const dbPath = codexDbPath();
194
- if (!(0, node_fs_1.existsSync)(dbPath))
195
- throw new Error(`codex db not found at ${dbPath} — has codex ever run?`);
196
- return readCodex(dbPath, day);
197
- }
198
- // Auto-detect
199
- const detected = detectProvider();
200
- if (!detected) {
201
- return {
202
- day,
203
- dbPath: '(no provider database found)',
204
- provider: 'none',
205
- totalCost: 0,
206
- sessionCount: 0,
207
- userCost: 0,
208
- agentCost: 0,
209
- teamshareCost: 0,
760
+ const home = opts.homeDir ?? (0, node_os_1.homedir)();
761
+ const day = opts.day ?? todayLocalDay();
762
+ const requested = opts.harness ?? (opts.provider === 'codex' ? 'codex' : 'opencode');
763
+ const agentFilter = opts.agent ?? null;
764
+ const infos = new Map();
765
+ const totals = {};
766
+ const sources = {};
767
+ const allSessions = [];
768
+ const readOne = (id) => {
769
+ const def = harnessDef(id);
770
+ if (!def) {
771
+ throw new Error(`unknown harness "${id}" — supported: ${exports.SUPPORTED_USAGE_HARNESSES.join(', ')}`);
772
+ }
773
+ if (!def.supported) {
774
+ // The honest answer carries the reason, not a silent zero.
775
+ throw new Error(def.unavailableReason ?? `usage is not supported for harness "${id}"`);
776
+ }
777
+ const probe = probeHarness(def, home);
778
+ if (!probe.available) {
779
+ throw new Error(probe.reason ?? `no local data for ${id}`);
780
+ }
781
+ const { sessions, source } = id === 'opencode'
782
+ ? readOpencodeSessions(day, agentFilter, home)
783
+ : id === 'claude'
784
+ ? readClaudeSessions(day, agentFilter, home)
785
+ : readCodexSessions(day, agentFilter, home);
786
+ sources[id] = source;
787
+ const cost = round6(sessions.reduce((s, x) => s + x.cost, 0));
788
+ const info = {
789
+ id,
790
+ label: def.label,
791
+ available: true,
792
+ costSource: def.costSource,
793
+ sessions: sessions.length,
794
+ cost,
210
795
  tokens: {
211
- input: 0,
212
- output: 0,
213
- reasoning: 0,
214
- cacheRead: 0,
215
- cacheWrite: 0,
796
+ input: sessions.reduce((s, x) => s + x.tokensInput, 0),
797
+ output: sessions.reduce((s, x) => s + x.tokensOutput, 0),
216
798
  },
217
- sessions: [],
218
799
  };
800
+ infos.set(id, info);
801
+ totals[id] = {
802
+ sessions: sessions.length,
803
+ cost,
804
+ costEstimated: def.costSource === 'estimated',
805
+ unpricedSessions: sessions.filter((s) => !s.costKnown).length,
806
+ };
807
+ allSessions.push(...sessions);
808
+ };
809
+ if (requested === 'all') {
810
+ for (const id of exports.SUPPORTED_USAGE_HARNESSES) {
811
+ try {
812
+ readOne(id);
813
+ }
814
+ catch {
815
+ // A harness with no local store contributes nothing — its
816
+ // unavailability is still listed below.
817
+ }
818
+ }
819
+ }
820
+ else {
821
+ readOne(requested);
822
+ }
823
+ // Every known harness is listed so the UI can offer exactly the
824
+ // supported ones and explain the rest.
825
+ const harnesses = exports.USAGE_HARNESSES.map((def) => {
826
+ const read = infos.get(def.id);
827
+ if (read)
828
+ return read;
829
+ const probe = probeHarness(def, home);
830
+ return {
831
+ id: def.id,
832
+ label: def.label,
833
+ available: false,
834
+ ...(probe.reason ? { reason: probe.reason } : {}),
835
+ costSource: def.costSource,
836
+ sessions: 0,
837
+ cost: 0,
838
+ tokens: { input: 0, output: 0 },
839
+ };
840
+ });
841
+ for (const def of exports.USAGE_HARNESSES) {
842
+ if (!totals[def.id]) {
843
+ totals[def.id] = {
844
+ sessions: 0,
845
+ cost: 0,
846
+ costEstimated: def.costSource === 'estimated',
847
+ unpricedSessions: 0,
848
+ };
849
+ }
219
850
  }
220
- if (detected.name === 'opencode')
221
- return readOpencode(detected.dbPath, day, opts.agent ?? null);
222
- if (detected.name === 'codex')
223
- return readCodex(detected.dbPath, day);
224
- return buildReport(day, '(unknown)', 'none', []);
851
+ allSessions.sort((a, b) => a.timeCreated - b.timeCreated);
852
+ const dbPath = sources[requested] ??
853
+ sources.opencode ??
854
+ sources[Object.keys(sources)[0] ?? ''] ??
855
+ '(no provider database found)';
856
+ return buildReport(day, requested, allSessions, harnesses, totals, sources, dbPath);
857
+ }
858
+ // ── Helpers ─────────────────────────────────────────────────────────────────
859
+ function round6(n) {
860
+ return Math.round(n * 1_000_000) / 1_000_000;
861
+ }
862
+ function round1(n) {
863
+ return Math.round(n * 10) / 10;
225
864
  }
226
865
  //# sourceMappingURL=usage-reader.js.map