@krtclcdy/tersio 2.24.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 (49) hide show
  1. package/README.md +1 -3
  2. package/cli/banner.js +2 -3
  3. package/cli/currency.js +3 -7
  4. package/cli/dashboard.js +167 -3
  5. package/cli/doctor-fix.js +15 -12
  6. package/cli/doctor.js +1 -12
  7. package/cli/hosts.js +7 -11
  8. package/cli/install.js +113 -71
  9. package/cli/interactive.js +36 -2
  10. package/cli/layer.js +1 -3
  11. package/cli/manifest.js +1 -4
  12. package/cli/profile.js +9 -7
  13. package/cli/reset.js +3 -7
  14. package/cli/rtk-wiring.js +1 -6
  15. package/cli/settings.js +3 -8
  16. package/cli/uninstall.js +43 -24
  17. package/cli/update.js +7 -8
  18. package/cli/usage.js +15 -5
  19. package/dashboard/dist/index.html +18 -18
  20. package/extensions/ai-addons-updater/index.js +6 -5
  21. package/extensions/ai-addons-updater/index.ts +7 -6
  22. package/extensions/caveman-session/index.js +14 -29
  23. package/extensions/caveman-session/index.ts +15 -30
  24. package/extensions/combo-toggle/index.js +10 -14
  25. package/extensions/combo-toggle/index.ts +10 -15
  26. package/extensions/lib/utils.js +4 -0
  27. package/extensions/lib/utils.ts +3 -0
  28. package/extensions/rtk-session/index.js +27 -31
  29. package/extensions/rtk-session/index.ts +25 -29
  30. package/extensions/shared/carbon.js +3 -8
  31. package/extensions/shared/carbon.ts +4 -8
  32. package/extensions/shared/host.js +1 -11
  33. package/extensions/shared/host.ts +1 -11
  34. package/extensions/shared/plugin-settings.js +4 -14
  35. package/extensions/shared/plugin-settings.ts +4 -14
  36. package/extensions/shared/pricing.js +4 -6
  37. package/extensions/shared/pricing.ts +4 -6
  38. package/extensions/shared/rtk-gain.js +2 -7
  39. package/extensions/shared/rtk-gain.ts +2 -7
  40. package/extensions/shared/session-state.js +30 -18
  41. package/extensions/shared/session-state.ts +33 -16
  42. package/extensions/shared/usage-ledger.js +138 -17
  43. package/extensions/shared/usage-ledger.ts +143 -19
  44. package/extensions/shared/usage-store.js +175 -23
  45. package/extensions/shared/usage-store.ts +171 -22
  46. package/extensions/tersio-commands/index.js +1 -5
  47. package/extensions/tersio-commands/index.ts +1 -5
  48. package/package.json +6 -7
  49. package/tersio.js +1 -3
@@ -46,6 +46,9 @@ export function appendUsage(kind, detail) {
46
46
  function zeroBreakdown() {
47
47
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
48
48
  }
49
+ function stampMs(ts) {
50
+ return ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
51
+ }
49
52
  export function durOf(v) {
50
53
  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
51
54
  }
@@ -84,15 +87,20 @@ function addInto(into, u) {
84
87
  into[k] += Math.floor(v);
85
88
  }
86
89
  }
87
- export function sessionsDir() {
90
+ export function sessionsDirs() {
88
91
  const override = process.env.TERSIO_SESSIONS_DIR;
89
92
  if (override)
90
- return override;
91
- // The running host wins; the other host's directory is the fallback.
93
+ return [override];
94
+ // Both hosts, not just the running one: a session on pi is missing from the
95
+ // dashboard entirely if we stop at the first directory that exists.
92
96
  const dirs = isPiProcess()
93
97
  ? [path.join(piAgentDir(), 'sessions'), path.join(os.homedir(), '.omp', 'agent', 'sessions')]
94
98
  : [path.join(os.homedir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
95
- return dirs.find((dir) => fs.existsSync(dir)) ?? dirs[0];
99
+ const found = dirs.filter((dir) => fs.existsSync(dir));
100
+ return found.length > 0 ? found : [dirs[0]];
101
+ }
102
+ export function sessionsDir() {
103
+ return sessionsDirs()[0];
96
104
  }
97
105
  export function codexSessionsDir() {
98
106
  const override = process.env.TERSIO_CODEX_DIR;
@@ -103,6 +111,22 @@ export function codexSessionsDir() {
103
111
  return path.join(codexHome, 'sessions');
104
112
  return path.join(os.homedir(), '.codex', 'sessions');
105
113
  }
114
+ // One JSON file per message; probe XDG, then local, then macOS default.
115
+ export function opencodeSessionsDir() {
116
+ const override = process.env.TERSIO_OPENCODE_DIR;
117
+ if (override)
118
+ return override;
119
+ const xdg = process.env.XDG_DATA_HOME;
120
+ if (xdg && xdg.trim() !== '')
121
+ return path.join(xdg, 'opencode', 'storage', 'message');
122
+ const local = path.join(os.homedir(), '.local', 'share', 'opencode', 'storage', 'message');
123
+ try {
124
+ if (fs.existsSync(local))
125
+ return local;
126
+ }
127
+ catch { /* fall through to the platform default */ }
128
+ return path.join(os.homedir(), 'Library', 'Application Support', 'opencode', 'storage', 'message');
129
+ }
106
130
  export function dayKey(ts) {
107
131
  const ms = typeof ts === 'number' ? ts : Date.parse(ts);
108
132
  if (!Number.isFinite(ms))
@@ -111,7 +135,7 @@ export function dayKey(ts) {
111
135
  const p = (n) => String(n).padStart(2, '0');
112
136
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
113
137
  }
114
- export function walkJsonl(dir, out, cap) {
138
+ export function walkJsonl(dir, out, cap, ext = '.jsonl') {
115
139
  let entries;
116
140
  try {
117
141
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -124,8 +148,8 @@ export function walkJsonl(dir, out, cap) {
124
148
  return;
125
149
  const full = path.join(dir, e.name);
126
150
  if (e.isDirectory())
127
- walkJsonl(full, out, cap);
128
- else if (e.isFile() && e.name.endsWith('.jsonl'))
151
+ walkJsonl(full, out, cap, ext);
152
+ else if (e.isFile() && e.name.endsWith(ext))
129
153
  out.push(full);
130
154
  }
131
155
  }
@@ -141,7 +165,7 @@ const RTK_ELIGIBLE_HEADS = new Set([
141
165
  export function newSessionAccum() {
142
166
  return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
143
167
  }
144
- export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames) {
168
+ export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames, host) {
145
169
  const watermark = readResetWatermark();
146
170
  const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
147
171
  if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark))
@@ -154,7 +178,7 @@ export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames)
154
178
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
155
179
  if (Number.isFinite(ms)) {
156
180
  const outcome = run ?? { st: 'completed' };
157
- 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 });
181
+ 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 });
158
182
  }
159
183
  const day = ts !== undefined ? dayKey(ts) : null;
160
184
  if (day) {
@@ -179,6 +203,10 @@ export function classifySessionLine(row) {
179
203
  if (payload && typeof payload === 'object') {
180
204
  if (row.type === 'session_meta' && typeof payload.model_provider === 'string')
181
205
  return { kind: 'codex_provider', provider: payload.model_provider };
206
+ // A Codex transcript states the model once per turn, in turn_context.
207
+ if (row.type === 'turn_context' && typeof payload.model === 'string' && payload.model) {
208
+ return { kind: 'codex_model', model: payload.model };
209
+ }
182
210
  if (payload.type === 'token_count') {
183
211
  const last = payload.info?.last_token_usage;
184
212
  if (last) {
@@ -192,7 +220,10 @@ export function classifySessionLine(row) {
192
220
  if (!msg || msg.role !== 'assistant' || !msg.usage)
193
221
  return { kind: 'skip' };
194
222
  const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
195
- const computedDur = (typeof msg.completedAt === 'number' && typeof msg.timestamp === 'number') ? msg.completedAt - msg.timestamp : undefined;
223
+ // pi omits duration, so derive Elapsed/Speed from the timestamp pair.
224
+ const startMs = typeof msg.timestamp === 'number' ? msg.timestamp : undefined;
225
+ const endMs = typeof msg.completedAt === 'number' ? msg.completedAt : stampMs(row.timestamp);
226
+ const computedDur = startMs !== undefined && Number.isFinite(endMs) && endMs > startMs ? endMs - startMs : undefined;
196
227
  const tools = [];
197
228
  for (const part of msg.content ?? []) {
198
229
  if (part?.type !== 'toolCall' || typeof part.name !== 'string' || !part.name)
@@ -206,6 +237,57 @@ export function classifySessionLine(row) {
206
237
  }
207
238
  return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
208
239
  }
240
+ // True when at least one token bucket holds a positive finite count.
241
+ export function hasPositiveUsage(usage) {
242
+ for (const k of ['input', 'output', 'cacheRead', 'cacheWrite']) {
243
+ const v = usage[k];
244
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0)
245
+ return true;
246
+ }
247
+ return false;
248
+ }
249
+ // One OpenCode message file; assistant rows carry the token buckets.
250
+ export function classifyOpencodeMessage(obj) {
251
+ if (!obj || typeof obj !== 'object' || obj.role !== 'assistant')
252
+ return null;
253
+ const t = obj.tokens;
254
+ if (!t || typeof t !== 'object')
255
+ return null;
256
+ const usage = {
257
+ input: t.input,
258
+ output: t.output,
259
+ cacheRead: t.cache?.read,
260
+ cacheWrite: t.cache?.write,
261
+ cost: obj.cost,
262
+ };
263
+ if (!hasPositiveUsage(usage))
264
+ return null;
265
+ const provider = typeof obj.providerID === 'string' && obj.providerID ? obj.providerID : 'unknown';
266
+ const model = typeof obj.modelID === 'string' && obj.modelID ? obj.modelID : 'unknown';
267
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined);
268
+ const created = num(obj.time?.created);
269
+ const completed = num(obj.time?.completed);
270
+ return {
271
+ model: `opencode/${provider}/${model}`,
272
+ usage,
273
+ ms: created,
274
+ durMs: created !== undefined && completed !== undefined && completed > created ? completed - created : undefined,
275
+ };
276
+ }
277
+ // One OpenCode message file into the shared accum.
278
+ export function processOpencodeFile(accum, text) {
279
+ let obj;
280
+ try {
281
+ obj = JSON.parse(text);
282
+ }
283
+ catch {
284
+ return;
285
+ }
286
+ const parsed = classifyOpencodeMessage(obj);
287
+ if (!parsed)
288
+ return;
289
+ ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, undefined, 'opencode');
290
+ }
209
291
  // Fold `:free`/`-free` suffixes and case variants into one chart key.
210
292
  export function canonicalModelId(model) {
211
293
  return canonicalPriceId(model.replace(FREE_SUFFIX, ''));
@@ -236,13 +318,30 @@ export function displayModelId(model) {
236
318
  export function importSessionTokens() {
237
319
  const accum = newSessionAccum();
238
320
  const files = [];
239
- walkJsonl(sessionsDir(), files, 2000);
321
+ for (const dir of sessionsDirs())
322
+ walkJsonl(dir, files, 2000);
240
323
  // An override means an isolated environment (tests, fixtures): only walk the
241
324
  // real codex dir when one is explicitly set.
242
325
  if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
243
326
  walkJsonl(codexSessionsDir(), files, 2000);
244
327
  }
328
+ // OpenCode message bodies are single JSON documents, not JSONL.
329
+ const ocFiles = [];
330
+ if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DIR !== undefined) {
331
+ walkJsonl(opencodeSessionsDir(), ocFiles, 5000, '.json');
332
+ }
333
+ for (const file of ocFiles) {
334
+ let text;
335
+ try {
336
+ text = fs.readFileSync(file, 'utf8');
337
+ }
338
+ catch {
339
+ continue;
340
+ }
341
+ processOpencodeFile(accum, text);
342
+ }
245
343
  let codexProvider = null;
344
+ let codexModel = null;
246
345
  for (const file of files) {
247
346
  let text;
248
347
  try {
@@ -251,11 +350,25 @@ export function importSessionTokens() {
251
350
  catch {
252
351
  continue;
253
352
  }
254
- processSessionText(accum, { get codexProvider() { return codexProvider; }, set codexProvider(v) { codexProvider = v; } }, text);
353
+ processSessionText(accum, {
354
+ get codexProvider() { return codexProvider; },
355
+ set codexProvider(v) { codexProvider = v; },
356
+ get codexModel() { return codexModel; },
357
+ set codexModel(v) { codexModel = v; },
358
+ }, text, hostOfSessionFile(file));
255
359
  }
256
360
  accum.recent.sort((a, b) => b.t - a.t);
257
361
  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) };
258
362
  }
363
+ // Which agent wrote a session file, from the directory it sits in.
364
+ export function hostOfSessionFile(file) {
365
+ const norm = file.replace(/\\/g, '/');
366
+ if (norm.includes('/.omp/'))
367
+ return 'omp';
368
+ if (norm.includes('/.codex/'))
369
+ return 'codex';
370
+ return 'pi';
371
+ }
259
372
  const adoptionFileCache = new Map();
260
373
  function parseAdoptionFile(file) {
261
374
  const counts = { bashCalls: 0, eligibleCalls: 0, rtkCalls: 0 };
@@ -294,7 +407,8 @@ export function clearRtkAdoptionCache() {
294
407
  }
295
408
  export function readRtkAdoption() {
296
409
  const files = [];
297
- walkJsonl(sessionsDir(), files, 2000);
410
+ for (const dir of sessionsDirs())
411
+ walkJsonl(dir, files, 2000);
298
412
  if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
299
413
  walkJsonl(codexSessionsDir(), files, 2000);
300
414
  }
@@ -332,7 +446,7 @@ export function readRtkAdoption() {
332
446
  const missedCalls = Math.max(0, eligibleCalls - rtkCalls);
333
447
  return { sessions, bashCalls, eligibleCalls, rtkCalls, missedCalls, adoptionPct: eligibleCalls ? (rtkCalls / eligibleCalls) * 100 : 0 };
334
448
  }
335
- export function processSessionText(accum, state, text) {
449
+ export function processSessionText(accum, state, text, host) {
336
450
  for (const line of text.split('\n')) {
337
451
  if (!line.trim())
338
452
  continue;
@@ -344,14 +458,21 @@ export function processSessionText(accum, state, text) {
344
458
  state.codexProvider = parsed.provider;
345
459
  continue;
346
460
  }
461
+ if (parsed.kind === 'codex_model') {
462
+ if (parsed.model)
463
+ state.codexModel = parsed.model;
464
+ continue;
465
+ }
347
466
  if (parsed.kind === 'token_row') {
348
- const model = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
349
- ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, undefined, { st: 'completed' });
467
+ const base = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
468
+ // The provider alone told you nothing about which model ran.
469
+ const model = state.codexModel ? `${base}/${state.codexModel}` : base;
470
+ ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' }, undefined, host ?? 'codex');
350
471
  continue;
351
472
  }
352
473
  if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage)
353
474
  continue;
354
- ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools);
475
+ ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host);
355
476
  }
356
477
  catch { /* skip corrupt lines */ }
357
478
  }
@@ -70,6 +70,8 @@ export interface RecentRequest {
70
70
  o: number;
71
71
  t: number;
72
72
  d?: number;
73
+ /** Which agent wrote the session: pi, omp, or codex. */
74
+ h?: string;
73
75
  cr?: number;
74
76
  cw?: number;
75
77
  // Undefined when the host recorded no cost; never backfilled.
@@ -109,6 +111,10 @@ export interface RtkRecallDiagnostics {
109
111
  function zeroBreakdown(): TokenBreakdown {
110
112
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
111
113
  }
114
+ function stampMs(ts: string | number | undefined): number {
115
+ return ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
116
+ }
117
+
112
118
  export function durOf(v: unknown): number | undefined {
113
119
  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
114
120
  }
@@ -147,14 +153,20 @@ function addInto(into: TokenBreakdown, u: { input?: unknown; output?: unknown; c
147
153
  }
148
154
  }
149
155
 
150
- export function sessionsDir(): string {
156
+ export function sessionsDirs(): string[] {
151
157
  const override = process.env.TERSIO_SESSIONS_DIR;
152
- if (override) return override;
153
- // The running host wins; the other host's directory is the fallback.
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.
154
161
  const dirs = isPiProcess()
155
162
  ? [path.join(piAgentDir(), 'sessions'), path.join(os.homedir(), '.omp', 'agent', 'sessions')]
156
163
  : [path.join(os.homedir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
157
- return dirs.find((dir) => fs.existsSync(dir)) ?? dirs[0];
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];
158
170
  }
159
171
 
160
172
  export function codexSessionsDir(): string {
@@ -165,6 +177,19 @@ export function codexSessionsDir(): string {
165
177
  return path.join(os.homedir(), '.codex', 'sessions');
166
178
  }
167
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
+
168
193
  export function dayKey(ts: string | number): string | null {
169
194
  const ms = typeof ts === 'number' ? ts : Date.parse(ts);
170
195
  if (!Number.isFinite(ms)) return null;
@@ -172,7 +197,7 @@ export function dayKey(ts: string | number): string | null {
172
197
  const p = (n: number): string => String(n).padStart(2, '0');
173
198
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
174
199
  }
175
- export function walkJsonl(dir: string, out: string[], cap: number): void {
200
+ export function walkJsonl(dir: string, out: string[], cap: number, ext = '.jsonl'): void {
176
201
  let entries: fs.Dirent[];
177
202
  try {
178
203
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -182,8 +207,8 @@ export function walkJsonl(dir: string, out: string[], cap: number): void {
182
207
  for (const e of entries) {
183
208
  if (out.length >= cap) return;
184
209
  const full = path.join(dir, e.name);
185
- if (e.isDirectory()) walkJsonl(full, out, cap);
186
- 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);
187
212
  }
188
213
  }
189
214
  // Recent requests get their own full-width table, so this bounds payload
@@ -211,7 +236,7 @@ export interface SessionAccum {
211
236
  export function newSessionAccum(): SessionAccum {
212
237
  return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
213
238
  }
214
- 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 {
215
240
  const watermark = readResetWatermark();
216
241
  const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
217
242
  if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark)) return false;
@@ -223,7 +248,7 @@ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Reco
223
248
  const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
224
249
  if (Number.isFinite(ms)) {
225
250
  const outcome = run ?? { st: 'completed' as RunStatus };
226
- 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 });
227
252
  }
228
253
  const day = ts !== undefined ? dayKey(ts) : null;
229
254
  if (day) {
@@ -241,11 +266,15 @@ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Reco
241
266
  for (const name of toolNames ?? []) accum.byTool[name] = (accum.byTool[name] ?? 0) + 1;
242
267
  return true;
243
268
  }
244
- export type SessionLineKind = 'codex_provider' | 'token_row' | 'assistant_row' | 'skip';
245
- 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 } {
246
271
  const payload = row.payload;
247
272
  if (payload && typeof payload === 'object') {
248
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
+ }
249
278
  if (payload.type === 'token_count') {
250
279
  const last = payload.info?.last_token_usage;
251
280
  if (last) {
@@ -258,7 +287,10 @@ export function classifySessionLine(row: { timestamp?: string | number; type?: u
258
287
  const msg = row.message;
259
288
  if (!msg || msg.role !== 'assistant' || !msg.usage) return { kind: 'skip' };
260
289
  const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
261
- 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;
262
294
  const tools: string[] = [];
263
295
  for (const part of msg.content ?? []) {
264
296
  if (part?.type !== 'toolCall' || typeof part.name !== 'string' || !part.name) continue;
@@ -271,6 +303,64 @@ export function classifySessionLine(row: { timestamp?: string | number; type?: u
271
303
  }
272
304
  return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
273
305
  }
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
+
274
364
  // Fold `:free`/`-free` suffixes and case variants into one chart key.
275
365
  export function canonicalModelId(model: string): string {
276
366
  return canonicalPriceId(model.replace(FREE_SUFFIX, ''));
@@ -297,13 +387,28 @@ export function displayModelId(model: string): string {
297
387
  export function importSessionTokens(): SessionTokens {
298
388
  const accum = newSessionAccum();
299
389
  const files: string[] = [];
300
- walkJsonl(sessionsDir(), files, 2000);
390
+ for (const dir of sessionsDirs()) walkJsonl(dir, files, 2000);
301
391
  // An override means an isolated environment (tests, fixtures): only walk the
302
392
  // real codex dir when one is explicitly set.
303
393
  if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
304
394
  walkJsonl(codexSessionsDir(), files, 2000);
305
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
+ }
306
410
  let codexProvider: string | null = null;
411
+ let codexModel: string | null = null;
307
412
  for (const file of files) {
308
413
  let text: string;
309
414
  try {
@@ -311,12 +416,25 @@ export function importSessionTokens(): SessionTokens {
311
416
  } catch {
312
417
  continue;
313
418
  }
314
- 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));
315
425
  }
316
426
  accum.recent.sort((a, b) => b.t - a.t);
317
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) };
318
428
  }
319
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
+
320
438
  const adoptionFileCache = new Map<string, { size: number; mtimeMs: number; counts: { bashCalls: number; eligibleCalls: number; rtkCalls: number } }>();
321
439
 
322
440
  function parseAdoptionFile(file: string): { counts: { bashCalls: number; eligibleCalls: number; rtkCalls: number }; sessions: number } {
@@ -355,7 +473,7 @@ export function clearRtkAdoptionCache(): void {
355
473
 
356
474
  export function readRtkAdoption(): RtkAdoption {
357
475
  const files: string[] = [];
358
- walkJsonl(sessionsDir(), files, 2000);
476
+ for (const dir of sessionsDirs()) walkJsonl(dir, files, 2000);
359
477
  if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
360
478
  walkJsonl(codexSessionsDir(), files, 2000);
361
479
  }
@@ -390,7 +508,7 @@ export function readRtkAdoption(): RtkAdoption {
390
508
  const missedCalls = Math.max(0, eligibleCalls - rtkCalls);
391
509
  return { sessions, bashCalls, eligibleCalls, rtkCalls, missedCalls, adoptionPct: eligibleCalls ? (rtkCalls / eligibleCalls) * 100 : 0 };
392
510
  }
393
- 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 {
394
512
  for (const line of text.split('\n')) {
395
513
  if (!line.trim()) continue;
396
514
  try {
@@ -400,13 +518,19 @@ export function processSessionText(accum: SessionAccum, state: { codexProvider:
400
518
  if (parsed.provider) state.codexProvider = parsed.provider;
401
519
  continue;
402
520
  }
521
+ if (parsed.kind === 'codex_model') {
522
+ if (parsed.model) state.codexModel = parsed.model;
523
+ continue;
524
+ }
403
525
  if (parsed.kind === 'token_row') {
404
- const model = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
405
- 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');
406
530
  continue;
407
531
  }
408
532
  if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage) continue;
409
- 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);
410
534
  } catch { /* skip corrupt lines */ }
411
535
  }
412
536
  }