@krtclcdy/tersio 2.23.0 → 2.24.1

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 (58) hide show
  1. package/README.md +56 -45
  2. package/cli/agent-hosts.js +42 -0
  3. package/cli/agents.js +266 -0
  4. package/cli/banner.js +2 -3
  5. package/cli/common.js +16 -20
  6. package/cli/currency.js +11 -19
  7. package/cli/dashboard.js +216 -40
  8. package/cli/doctor-fix.js +40 -26
  9. package/cli/doctor.js +41 -41
  10. package/cli/hosts.js +114 -0
  11. package/cli/install.js +284 -179
  12. package/cli/interactive.js +62 -2
  13. package/cli/layer.js +22 -0
  14. package/cli/manifest.js +33 -0
  15. package/cli/omp-layer.js +51 -0
  16. package/cli/pi-layer.js +37 -0
  17. package/cli/pi-wiring.js +92 -0
  18. package/cli/profile.js +48 -48
  19. package/cli/reset.js +5 -32
  20. package/cli/rtk-wiring.js +7 -17
  21. package/cli/settings.js +3 -8
  22. package/cli/uninstall.js +162 -52
  23. package/cli/update.js +19 -27
  24. package/cli/usage.js +15 -5
  25. package/dashboard/dist/index.html +18 -18
  26. package/extensions/ai-addons-updater/index.js +37 -29
  27. package/extensions/ai-addons-updater/index.ts +39 -38
  28. package/extensions/caveman-session/index.js +19 -34
  29. package/extensions/caveman-session/index.ts +20 -35
  30. package/extensions/combo-toggle/index.js +47 -49
  31. package/extensions/combo-toggle/index.ts +42 -58
  32. package/extensions/lib/utils.js +40 -9
  33. package/extensions/lib/utils.ts +40 -9
  34. package/extensions/rtk-session/index.js +34 -38
  35. package/extensions/rtk-session/index.ts +32 -41
  36. package/extensions/shared/carbon.js +3 -11
  37. package/extensions/shared/carbon.ts +4 -11
  38. package/extensions/shared/host.js +58 -0
  39. package/extensions/shared/host.ts +94 -0
  40. package/extensions/shared/omp-prompt.js +12 -0
  41. package/extensions/shared/plugin-settings.js +33 -30
  42. package/extensions/shared/plugin-settings.ts +35 -26
  43. package/extensions/shared/pricing.js +25 -16
  44. package/extensions/shared/pricing.ts +27 -17
  45. package/extensions/shared/rtk-gain.js +6 -22
  46. package/extensions/shared/rtk-gain.ts +6 -22
  47. package/extensions/shared/session-state.js +44 -36
  48. package/extensions/shared/session-state.ts +48 -35
  49. package/extensions/shared/types.ts +10 -3
  50. package/extensions/shared/usage-ledger.js +160 -51
  51. package/extensions/shared/usage-ledger.ts +170 -67
  52. package/extensions/shared/usage-store.js +178 -24
  53. package/extensions/shared/usage-store.ts +176 -25
  54. package/extensions/tersio-commands/index.js +3 -6
  55. package/extensions/tersio-commands/index.ts +3 -6
  56. package/package.json +6 -5
  57. package/tersio.js +4 -6
  58. package/extensions/shared/mode-reinforcement.js +0 -26
@@ -1,11 +1,9 @@
1
- // Shared usage ledger: append-only JSON-lines file. One source of truth read
2
- // by `tersio usage`, `/tersio usage`, and the Dashboard.
3
- // Best-effort by design: a ledger failure never breaks the caller, and
4
- // corrupt lines are skipped on read (same pattern as the config normalizer).
1
+ // Shared usage ledger: append-only JSON lines, read by the CLI, `/tersio`, and
2
+ // the Dashboard. Best-effort; corrupt lines are skipped on read.
5
3
  import fs from 'node:fs';
6
4
  import os from 'node:os';
7
5
  import path from 'node:path';
8
- import { resolveRtkBinary, tersioDataPath } from '../lib/utils.ts';
6
+ import { isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from '../lib/utils.ts';
9
7
  import { execFileSync } from 'node:child_process';
10
8
 
11
9
  export type UsageKind = 'command' | 'toggle' | 'install' | 'update' | 'rtk-audit';
@@ -22,11 +20,7 @@ export function ledgerPath(): string {
22
20
  return tersioDataPath('usage.jsonl', 'tersio-usage.jsonl');
23
21
  }
24
22
 
25
- // Reset watermark: tersio-owned timestamp marking the last statistics reset.
26
- // Session transcripts and the RTK database are host/tool-owned and never
27
- // touched — instead, every derived view (token stats, command tools) filters
28
- // rows from before the watermark, so "reset" empties what tersio shows
29
- // without deleting anything it does not own.
23
+ // Reset watermark: views filter rows older than it, so nothing else is deleted.
30
24
  export function resetMarkerPath(): string {
31
25
  const override = process.env.TERSIO_RESET_FILE;
32
26
  if (override) return override;
@@ -59,10 +53,7 @@ export function appendUsage(kind: UsageKind, detail: string): void {
59
53
  }
60
54
 
61
55
  // --- Session tokens, tokscale-style ------------------------------------------
62
- // OMP writes per-assistant-message usage into ~/.omp/agent/sessions/**/*.jsonl:
63
- // message.role === 'assistant', message.usage = { input, output, cacheRead,
64
- // cacheWrite }, message.model, plus a per-line timestamp. Same source tokscale
65
- // parses for its oh-my-pi client. Best-effort: unreadable files are skipped.
56
+ // Assistant messages in the host transcripts carry usage, model, and a timestamp.
66
57
  export interface TokenBreakdown {
67
58
  input: number;
68
59
  output: number;
@@ -70,10 +61,7 @@ export interface TokenBreakdown {
70
61
  cacheWrite: number;
71
62
  }
72
63
 
73
- // Run outcome for one assistant message. OMP writes stopReason on every turn
74
- // (toolUse | stop | aborted | error) and, when the request failed, an HTTP
75
- // errorStatus plus errorMessage. `toolUse` is an ordinary turn that handed off
76
- // to tools, so it reads as completed rather than as a distinct state.
64
+ // `toolUse` is an ordinary turn that handed off to tools, so it reads completed.
77
65
  export type RunStatus = 'completed' | 'aborted' | 'error';
78
66
 
79
67
  export interface RecentRequest {
@@ -82,11 +70,11 @@ export interface RecentRequest {
82
70
  o: number;
83
71
  t: number;
84
72
  d?: number;
73
+ /** Which agent wrote the session: pi, omp, or codex. */
74
+ h?: string;
85
75
  cr?: number;
86
76
  cw?: number;
87
- // Measured spend for this message, from the transcript's usage.cost.total.
88
- // Left undefined when the host recorded no cost — never backfilled with an
89
- // estimate, so a measured figure is never mistaken for a modeled one.
77
+ // Undefined when the host recorded no cost; never backfilled.
90
78
  usd?: number;
91
79
  st: RunStatus;
92
80
  code?: number;
@@ -123,14 +111,16 @@ export interface RtkRecallDiagnostics {
123
111
  function zeroBreakdown(): TokenBreakdown {
124
112
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
125
113
  }
114
+ function stampMs(ts: string | number | undefined): number {
115
+ return ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
116
+ }
117
+
126
118
  export function durOf(v: unknown): number | undefined {
127
119
  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
128
120
  }
129
121
 
130
- // Measured cost for one message. OMP writes usage.cost as
131
- // { input, output, cacheRead, cacheWrite, total }; a bare number shows up in
132
- // older fixtures. Anything else means "not recorded" — deliberately not 0, so
133
- // the dashboard can tell a genuinely free run from an unrecorded one.
122
+ // Measured cost: usage.cost.total, or a bare number in old fixtures. Anything
123
+ // else means unrecorded, which is deliberately not 0.
134
124
  export function costOf(usage: Record<string, unknown>): number | undefined {
135
125
  const c = usage.cost;
136
126
  if (typeof c === 'number') return Number.isFinite(c) ? c : undefined;
@@ -163,10 +153,20 @@ function addInto(into: TokenBreakdown, u: { input?: unknown; output?: unknown; c
163
153
  }
164
154
  }
165
155
 
166
- export function sessionsDir(): string {
156
+ export function sessionsDirs(): string[] {
167
157
  const override = process.env.TERSIO_SESSIONS_DIR;
168
- if (override) return override;
169
- return path.join(os.homedir(), '.omp', 'agent', 'sessions');
158
+ if (override) return [override];
159
+ // Both hosts, not just the running one: a session on pi is missing from the
160
+ // dashboard entirely if we stop at the first directory that exists.
161
+ const dirs = isPiProcess()
162
+ ? [path.join(piAgentDir(), 'sessions'), path.join(os.homedir(), '.omp', 'agent', 'sessions')]
163
+ : [path.join(os.homedir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
164
+ const found = dirs.filter((dir) => fs.existsSync(dir));
165
+ return found.length > 0 ? found : [dirs[0]];
166
+ }
167
+
168
+ export function sessionsDir(): string {
169
+ return sessionsDirs()[0];
170
170
  }
171
171
 
172
172
  export function codexSessionsDir(): string {
@@ -177,6 +177,19 @@ export function codexSessionsDir(): string {
177
177
  return path.join(os.homedir(), '.codex', 'sessions');
178
178
  }
179
179
 
180
+ // One JSON file per message; probe XDG, then local, then macOS default.
181
+ export function opencodeSessionsDir(): string {
182
+ const override = process.env.TERSIO_OPENCODE_DIR;
183
+ if (override) return override;
184
+ const xdg = process.env.XDG_DATA_HOME;
185
+ if (xdg && xdg.trim() !== '') return path.join(xdg, 'opencode', 'storage', 'message');
186
+ const local = path.join(os.homedir(), '.local', 'share', 'opencode', 'storage', 'message');
187
+ try {
188
+ if (fs.existsSync(local)) return local;
189
+ } catch { /* fall through to the platform default */ }
190
+ return path.join(os.homedir(), 'Library', 'Application Support', 'opencode', 'storage', 'message');
191
+ }
192
+
180
193
  export function dayKey(ts: string | number): string | null {
181
194
  const ms = typeof ts === 'number' ? ts : Date.parse(ts);
182
195
  if (!Number.isFinite(ms)) return null;
@@ -184,7 +197,7 @@ export function dayKey(ts: string | number): string | null {
184
197
  const p = (n: number): string => String(n).padStart(2, '0');
185
198
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
186
199
  }
187
- export function walkJsonl(dir: string, out: string[], cap: number): void {
200
+ export function walkJsonl(dir: string, out: string[], cap: number, ext = '.jsonl'): void {
188
201
  let entries: fs.Dirent[];
189
202
  try {
190
203
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -194,13 +207,12 @@ export function walkJsonl(dir: string, out: string[], cap: number): void {
194
207
  for (const e of entries) {
195
208
  if (out.length >= cap) return;
196
209
  const full = path.join(dir, e.name);
197
- if (e.isDirectory()) walkJsonl(full, out, cap);
198
- else if (e.isFile() && e.name.endsWith('.jsonl')) out.push(full);
210
+ if (e.isDirectory()) walkJsonl(full, out, cap, ext);
211
+ else if (e.isFile() && e.name.endsWith(ext)) out.push(full);
199
212
  }
200
213
  }
201
- // Recent requests are their own full-width table in the dashboard, so this is
202
- // a payload bound rather than a "few highlights" bound. Rows are small
203
- // (model, tokens, timing, status), so a few hundred cost little to ship.
214
+ // Recent requests get their own full-width table, so this bounds payload
215
+ // rather than highlights; rows are small, so a few hundred cost little.
204
216
  export const RECENT_LIMIT = 200;
205
217
  const FREE_SUFFIX = /(?::free|-free)$/i;
206
218
  const RTK_ELIGIBLE_HEADS = new Set([
@@ -208,8 +220,8 @@ const RTK_ELIGIBLE_HEADS = new Set([
208
220
  'npm', 'npx', 'pnpm', 'bun', 'bunx', 'cargo', 'go', 'python', 'pytest',
209
221
  'ruff', 'mypy', 'docker', 'kubectl', 'psql', 'aws', 'gh', 'glab', 'wc',
210
222
  ]);
211
- // Session-parse buffer shared by live reads and usage.db syncs: identical
212
- // inputs produce identical aggregates, so the store is a cache, never a fork.
223
+ // Parse buffer shared by live reads and usage.db syncs, so the store is a
224
+ // cache and never a fork.
213
225
  export interface SessionAccum {
214
226
  byModel: Record<string, TokenBreakdown>;
215
227
  byDay: Record<string, TokenBreakdown>;
@@ -224,7 +236,7 @@ export interface SessionAccum {
224
236
  export function newSessionAccum(): SessionAccum {
225
237
  return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
226
238
  }
227
- export function ingestSessionRow(accum: SessionAccum, model: string, usage: Record<string, unknown>, ts: string | number | undefined, durMs?: unknown, run?: { st: RunStatus; code?: number; note?: string }, toolNames?: string[]): boolean {
239
+ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Record<string, unknown>, ts: string | number | undefined, durMs?: unknown, run?: { st: RunStatus; code?: number; note?: string }, toolNames?: string[], host?: string): boolean {
228
240
  const watermark = readResetWatermark();
229
241
  const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
230
242
  if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark)) return false;
@@ -236,7 +248,7 @@ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Reco
236
248
  const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
237
249
  if (Number.isFinite(ms)) {
238
250
  const outcome = run ?? { st: 'completed' as RunStatus };
239
- accum.recent.push({ m: key, i: num(usage.input), o: num(usage.output), t: ms, d: durOf(durMs), cr: num(usage.cacheRead), cw: num(usage.cacheWrite), usd: costOf(usage), st: outcome.st, code: outcome.code, note: outcome.note });
251
+ accum.recent.push({ m: key, i: num(usage.input), o: num(usage.output), t: ms, d: durOf(durMs), h: host, cr: num(usage.cacheRead), cw: num(usage.cacheWrite), usd: costOf(usage), st: outcome.st, code: outcome.code, note: outcome.note });
240
252
  }
241
253
  const day = ts !== undefined ? dayKey(ts) : null;
242
254
  if (day) {
@@ -254,11 +266,15 @@ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Reco
254
266
  for (const name of toolNames ?? []) accum.byTool[name] = (accum.byTool[name] ?? 0) + 1;
255
267
  return true;
256
268
  }
257
- export type SessionLineKind = 'codex_provider' | 'token_row' | 'assistant_row' | 'skip';
258
- export function classifySessionLine(row: { timestamp?: string | number; type?: unknown; message?: { role?: unknown; model?: unknown; usage?: Record<string, unknown>; duration?: unknown; completedAt?: unknown; timestamp?: unknown; stopReason?: unknown; errorStatus?: unknown; errorMessage?: unknown; isError?: unknown; content?: Array<{ type?: unknown; name?: unknown; arguments?: unknown }> }; payload?: { type?: unknown; model_provider?: unknown; info?: { last_token_usage?: Record<string, unknown> } } }): { kind: SessionLineKind; provider?: string; model?: string; usage?: Record<string, unknown>; durMs?: number; run?: { st: RunStatus; code?: number; note?: string }; tools?: string[]; ms?: number } {
269
+ export type SessionLineKind = 'codex_provider' | 'codex_model' | 'token_row' | 'assistant_row' | 'skip';
270
+ export function classifySessionLine(row: { timestamp?: string | number; type?: unknown; message?: { role?: unknown; model?: unknown; usage?: Record<string, unknown>; duration?: unknown; completedAt?: unknown; timestamp?: unknown; stopReason?: unknown; errorStatus?: unknown; errorMessage?: unknown; isError?: unknown; content?: Array<{ type?: unknown; name?: unknown; arguments?: unknown }> }; payload?: { type?: unknown; model?: unknown; model_provider?: unknown; info?: { last_token_usage?: Record<string, unknown> } } }): { kind: SessionLineKind; provider?: string; model?: string; usage?: Record<string, unknown>; durMs?: number; run?: { st: RunStatus; code?: number; note?: string }; tools?: string[]; ms?: number } {
259
271
  const payload = row.payload;
260
272
  if (payload && typeof payload === 'object') {
261
273
  if (row.type === 'session_meta' && typeof payload.model_provider === 'string') return { kind: 'codex_provider', provider: payload.model_provider };
274
+ // A Codex transcript states the model once per turn, in turn_context.
275
+ if (row.type === 'turn_context' && typeof payload.model === 'string' && payload.model) {
276
+ return { kind: 'codex_model', model: payload.model };
277
+ }
262
278
  if (payload.type === 'token_count') {
263
279
  const last = payload.info?.last_token_usage;
264
280
  if (last) {
@@ -271,7 +287,10 @@ export function classifySessionLine(row: { timestamp?: string | number; type?: u
271
287
  const msg = row.message;
272
288
  if (!msg || msg.role !== 'assistant' || !msg.usage) return { kind: 'skip' };
273
289
  const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
274
- const computedDur = (typeof msg.completedAt === 'number' && typeof msg.timestamp === 'number') ? msg.completedAt - msg.timestamp : undefined;
290
+ // pi omits duration, so derive Elapsed/Speed from the timestamp pair.
291
+ const startMs = typeof msg.timestamp === 'number' ? msg.timestamp : undefined;
292
+ const endMs = typeof msg.completedAt === 'number' ? msg.completedAt : stampMs(row.timestamp);
293
+ const computedDur = startMs !== undefined && Number.isFinite(endMs) && endMs > startMs ? endMs - startMs : undefined;
275
294
  const tools: string[] = [];
276
295
  for (const part of msg.content ?? []) {
277
296
  if (part?.type !== 'toolCall' || typeof part.name !== 'string' || !part.name) continue;
@@ -284,13 +303,70 @@ export function classifySessionLine(row: { timestamp?: string | number; type?: u
284
303
  }
285
304
  return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
286
305
  }
287
- // Fold `:free`/`-free` suffixes and case variants into one chart key, so the
288
- // same model from two providers stops splitting into separate rows.
306
+ // True when at least one token bucket holds a positive finite count.
307
+ export function hasPositiveUsage(usage: Record<string, unknown>): boolean {
308
+ for (const k of ['input', 'output', 'cacheRead', 'cacheWrite']) {
309
+ const v = usage[k];
310
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0) return true;
311
+ }
312
+ return false;
313
+ }
314
+
315
+ export interface OpencodeMessage {
316
+ role?: unknown;
317
+ tokens?: { input?: unknown; output?: unknown; reasoning?: unknown; cache?: { read?: unknown; write?: unknown } } | null;
318
+ modelID?: unknown;
319
+ providerID?: unknown;
320
+ time?: { created?: unknown; completed?: unknown } | null;
321
+ cost?: unknown;
322
+ finish?: unknown;
323
+ }
324
+
325
+ // One OpenCode message file; assistant rows carry the token buckets.
326
+ export function classifyOpencodeMessage(obj: OpencodeMessage | null | undefined): { model: string; usage: Record<string, unknown>; ms: number | undefined; durMs: number | undefined } | null {
327
+ if (!obj || typeof obj !== 'object' || obj.role !== 'assistant') return null;
328
+ const t = obj.tokens;
329
+ if (!t || typeof t !== 'object') return null;
330
+ const usage: Record<string, unknown> = {
331
+ input: t.input,
332
+ output: t.output,
333
+ cacheRead: t.cache?.read,
334
+ cacheWrite: t.cache?.write,
335
+ cost: (obj as Record<string, unknown>).cost,
336
+ };
337
+ if (!hasPositiveUsage(usage)) return null;
338
+ const provider = typeof obj.providerID === 'string' && obj.providerID ? obj.providerID : 'unknown';
339
+ const model = typeof obj.modelID === 'string' && obj.modelID ? obj.modelID : 'unknown';
340
+ const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined);
341
+ const created = num(obj.time?.created);
342
+ const completed = num(obj.time?.completed);
343
+ return {
344
+ model: `opencode/${provider}/${model}`,
345
+ usage,
346
+ ms: created,
347
+ durMs: created !== undefined && completed !== undefined && completed > created ? completed - created : undefined,
348
+ };
349
+ }
350
+
351
+ // One OpenCode message file into the shared accum.
352
+ export function processOpencodeFile(accum: SessionAccum, text: string): void {
353
+ let obj: unknown;
354
+ try {
355
+ obj = JSON.parse(text);
356
+ } catch {
357
+ return;
358
+ }
359
+ const parsed = classifyOpencodeMessage(obj as OpencodeMessage);
360
+ if (!parsed) return;
361
+ ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' as RunStatus }, undefined, 'opencode');
362
+ }
363
+
364
+ // Fold `:free`/`-free` suffixes and case variants into one chart key.
289
365
  export function canonicalModelId(model: string): string {
290
- return model.replace(FREE_SUFFIX, '').toLowerCase();
366
+ return canonicalPriceId(model.replace(FREE_SUFFIX, ''));
291
367
  }
292
- // LiteLLM-style display: namespace stays lowercase, model segments title-case
293
- // with version dots kept (`deepseek-v4.1-flash` → `Deepseek-V4.1-Flash`).
368
+ // LiteLLM-style display: lowercase namespace, title-cased model segments with
369
+ // version dots kept.
294
370
  export function displayModelId(model: string): string {
295
371
  const bare = model.replace(FREE_SUFFIX, '');
296
372
  const cap = (s: string): string => {
@@ -311,13 +387,28 @@ export function displayModelId(model: string): string {
311
387
  export function importSessionTokens(): SessionTokens {
312
388
  const accum = newSessionAccum();
313
389
  const files: string[] = [];
314
- walkJsonl(sessionsDir(), files, 2000);
315
- // A sessions override signals an isolated environment (tests, fixtures):
316
- // only walk the real codex dir when it is explicitly set.
390
+ for (const dir of sessionsDirs()) walkJsonl(dir, files, 2000);
391
+ // An override means an isolated environment (tests, fixtures): only walk the
392
+ // real codex dir when one is explicitly set.
317
393
  if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
318
394
  walkJsonl(codexSessionsDir(), files, 2000);
319
395
  }
396
+ // OpenCode message bodies are single JSON documents, not JSONL.
397
+ const ocFiles: string[] = [];
398
+ if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DIR !== undefined) {
399
+ walkJsonl(opencodeSessionsDir(), ocFiles, 5000, '.json');
400
+ }
401
+ for (const file of ocFiles) {
402
+ let text: string;
403
+ try {
404
+ text = fs.readFileSync(file, 'utf8');
405
+ } catch {
406
+ continue;
407
+ }
408
+ processOpencodeFile(accum, text);
409
+ }
320
410
  let codexProvider: string | null = null;
411
+ let codexModel: string | null = null;
321
412
  for (const file of files) {
322
413
  let text: string;
323
414
  try {
@@ -325,12 +416,25 @@ export function importSessionTokens(): SessionTokens {
325
416
  } catch {
326
417
  continue;
327
418
  }
328
- processSessionText(accum, { get codexProvider() { return codexProvider; }, set codexProvider(v: string | null) { codexProvider = v; } }, text);
419
+ processSessionText(accum, {
420
+ get codexProvider() { return codexProvider; },
421
+ set codexProvider(v: string | null) { codexProvider = v; },
422
+ get codexModel() { return codexModel; },
423
+ set codexModel(v: string | null) { codexModel = v; },
424
+ }, text, hostOfSessionFile(file));
329
425
  }
330
426
  accum.recent.sort((a, b) => b.t - a.t);
331
427
  return { messages: accum.messages, totals: accum.totals, byModel: accum.byModel, byDay: accum.byDay, byDayModel: accum.byDayModel, byTool: accum.byTool, byModelMessages: accum.byModelMessages, costMeasured: accum.costMeasured, recent: accum.recent.slice(0, RECENT_LIMIT) };
332
428
  }
333
429
 
430
+ // Which agent wrote a session file, from the directory it sits in.
431
+ export function hostOfSessionFile(file: string): string {
432
+ const norm = file.replace(/\\/g, '/');
433
+ if (norm.includes('/.omp/')) return 'omp';
434
+ if (norm.includes('/.codex/')) return 'codex';
435
+ return 'pi';
436
+ }
437
+
334
438
  const adoptionFileCache = new Map<string, { size: number; mtimeMs: number; counts: { bashCalls: number; eligibleCalls: number; rtkCalls: number } }>();
335
439
 
336
440
  function parseAdoptionFile(file: string): { counts: { bashCalls: number; eligibleCalls: number; rtkCalls: number }; sessions: number } {
@@ -369,7 +473,7 @@ export function clearRtkAdoptionCache(): void {
369
473
 
370
474
  export function readRtkAdoption(): RtkAdoption {
371
475
  const files: string[] = [];
372
- walkJsonl(sessionsDir(), files, 2000);
476
+ for (const dir of sessionsDirs()) walkJsonl(dir, files, 2000);
373
477
  if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
374
478
  walkJsonl(codexSessionsDir(), files, 2000);
375
479
  }
@@ -404,7 +508,7 @@ export function readRtkAdoption(): RtkAdoption {
404
508
  const missedCalls = Math.max(0, eligibleCalls - rtkCalls);
405
509
  return { sessions, bashCalls, eligibleCalls, rtkCalls, missedCalls, adoptionPct: eligibleCalls ? (rtkCalls / eligibleCalls) * 100 : 0 };
406
510
  }
407
- export function processSessionText(accum: SessionAccum, state: { codexProvider: string | null }, text: string): void {
511
+ export function processSessionText(accum: SessionAccum, state: { codexProvider: string | null; codexModel?: string | null }, text: string, host?: string): void {
408
512
  for (const line of text.split('\n')) {
409
513
  if (!line.trim()) continue;
410
514
  try {
@@ -414,13 +518,19 @@ export function processSessionText(accum: SessionAccum, state: { codexProvider:
414
518
  if (parsed.provider) state.codexProvider = parsed.provider;
415
519
  continue;
416
520
  }
521
+ if (parsed.kind === 'codex_model') {
522
+ if (parsed.model) state.codexModel = parsed.model;
523
+ continue;
524
+ }
417
525
  if (parsed.kind === 'token_row') {
418
- const model = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
419
- ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, undefined, { st: 'completed' as RunStatus });
526
+ const base = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
527
+ // The provider alone told you nothing about which model ran.
528
+ const model = state.codexModel ? `${base}/${state.codexModel}` : base;
529
+ ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' as RunStatus }, undefined, host ?? 'codex');
420
530
  continue;
421
531
  }
422
532
  if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage) continue;
423
- ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools);
533
+ ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host);
424
534
  } catch { /* skip corrupt lines */ }
425
535
  }
426
536
  }
@@ -443,8 +553,8 @@ export function readRtkRecallDiagnostics(binary: string | null = resolveRtkBinar
443
553
  return { mode: 'unknown', entries: 0, available: false };
444
554
  }
445
555
  }
446
- // Lead binary of a shell string: first segment head past `cd` chains and
447
- // VAR=x assignments (`cd /x && git status` → `git`). Falls back to `bash`.
556
+ // Lead binary of a shell string: first segment past `cd` chains and VAR=x
557
+ // assignments. Falls back to `bash`.
448
558
  const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
449
559
  export function leadBinary(command: unknown): string {
450
560
  if (typeof command !== 'string' || !command.trim()) return 'bash';
@@ -459,7 +569,7 @@ export function leadBinary(command: unknown): string {
459
569
  }
460
570
 
461
571
  // --- Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table) ---
462
- import { DEFAULT_PRICE, priceFor, refreshPricesIfStale } from './pricing.ts';
572
+ import { DEFAULT_PRICE, canonicalPriceId, priceFor, refreshPricesIfStale } from './pricing.ts';
463
573
  import { co2GramsFor, energyWhFor } from './carbon.ts';
464
574
  import type { ModelPrice } from './pricing.ts';
465
575
 
@@ -483,12 +593,6 @@ export function usdCost(t: TokenBreakdown, model?: string): { usd: number; price
483
593
  };
484
594
  }
485
595
 
486
- // CO2 now model-differentiated via ./carbon.ts (EcoLogits 0.8.2 port).
487
- // Always render with ~est. and never merge with measured figures.
488
- // The constant below is the served gCO2eq per 1K output tokens for the
489
- // default (gpt-4o-class) model, kept so single-figure callers stay honest.
490
- export const CO2_G_PER_1K_OUTPUT = 0.2;
491
-
492
596
  export function co2Grams(outputTokens: number, model?: string): number {
493
597
  return co2GramsFor(model ?? 'gpt-4o', outputTokens);
494
598
  }
@@ -513,9 +617,8 @@ export function readUsage(): UsageRow[] {
513
617
  return rows;
514
618
  }
515
619
 
516
- // Delete the ledger file. Returns the rows cleared (0 when already absent).
517
- // Only tersio-owned data lives here; session transcripts and the RTK
518
- // database are never touched.
620
+ // Delete the ledger file and return the rows cleared. Tersio-owned data only:
621
+ // transcripts and the RTK database are never touched.
519
622
  export function clearUsageLedger(): number {
520
623
  const rows = readUsage().length;
521
624
  try {