@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,7 +1,9 @@
1
+ import { readPluginSettings } from './plugin-settings.ts';
1
2
  import type { ComboLevel, ComboState, ExtensionCtx, SessionEntry, UiApi } from './types.ts';
2
3
 
3
4
  const BRIDGE_KEY = Symbol.for('tersio/combo-session-state');
4
5
 
6
+ // A verbatim sentence from OMP's subagent prompt, so it can drift; settings.json overrides it.
5
7
  export const OMP_SUBAGENT_MARKER = 'You are operating on a piece of work assigned to you by the main agent.';
6
8
 
7
9
  export const COMBO_LEVELS: Record<string, Readonly<ComboState>> = Object.freeze({
@@ -28,7 +30,7 @@ const MODE_ENTRY_TYPES: Record<string, ModeName> = {
28
30
 
29
31
  interface Bridge {
30
32
  state: Readonly<ComboState>;
31
- listeners: Set<(state: Readonly<ComboState>) => void>;
33
+ listeners: Map<string, (state: Readonly<ComboState>) => void>;
32
34
  }
33
35
 
34
36
  export function normalizeMode(name: ModeName, value: unknown): string | null {
@@ -67,13 +69,13 @@ function bridge(): Bridge {
67
69
  const existing = (globalThis as Record<symbol, unknown>)[BRIDGE_KEY] as Bridge | undefined;
68
70
  if (existing?.state) return existing;
69
71
  const initial = normalizedState((existing || COMBO_LEVELS.off) as Partial<Modes>);
70
- return ((globalThis as Record<symbol, unknown>)[BRIDGE_KEY] = { state: initial, listeners: new Set<(state: Readonly<ComboState>) => void>() });
72
+ return ((globalThis as Record<symbol, unknown>)[BRIDGE_KEY] = { state: initial, listeners: new Map() });
71
73
  }
72
74
 
73
75
  function publish(state: Readonly<ComboState>): Readonly<ComboState> {
74
76
  const shared = bridge();
75
77
  shared.state = state;
76
- for (const listener of shared.listeners) listener(state);
78
+ for (const listener of shared.listeners.values()) listener(state);
77
79
  return state;
78
80
  }
79
81
 
@@ -90,34 +92,50 @@ export function systemPromptIncludes(systemPrompt: string | string[], marker: st
90
92
  }
91
93
 
92
94
  export function isOmpSubagentPrompt(systemPrompt: string | string[]): boolean {
93
- return systemPromptIncludes(systemPrompt, OMP_SUBAGENT_MARKER);
95
+ const raw = readPluginSettings().subagentMarkers;
96
+ const configured = Array.isArray(raw) ? raw.filter((m): m is string => typeof m === 'string' && m.trim() !== '') : [];
97
+ // A malformed setting falls back rather than matching every turn.
98
+ const markers = configured.length ? configured : [OMP_SUBAGENT_MARKER];
99
+ return markers.some((marker) => systemPromptIncludes(systemPrompt, marker));
94
100
  }
95
101
 
102
+ // ultra maps to max, lite/full to nearest, rather than erroring.
103
+ const COMBO_ALIASES: Record<string, ComboLevel> = { ultra: 'max', lite: 'medium', full: 'balanced' };
104
+
96
105
  export function normalizeComboLevel(value: unknown): ComboLevel | null {
97
106
  const level = String(value || '').trim().toLowerCase();
98
- return isPresetLevel(level) ? level : null;
107
+ if (isPresetLevel(level)) return level;
108
+ return COMBO_ALIASES[level] ?? null;
99
109
  }
100
110
 
101
- export function paintStatusBar(ui: UiApi | undefined, key: string, emoji: string, label: string, isActive: boolean): void {
111
+ // OMP themes status text through ctx.ui.theme.fg; pi has no ctx.ui.theme, so
112
+ // the same string renders plain there.
113
+ export function themeStatus(ui: UiApi | undefined, indicator: string, label: string, highlight = false): string {
102
114
  const theme = ui?.theme;
103
- const indicator = isActive && theme?.fg ? theme.fg('accent', emoji) : emoji;
104
- ui?.setStatus?.(key, theme?.fg ? `${indicator} ${theme.fg('muted', label)}` : `${indicator} ${label}`);
105
- }
106
-
107
- // Which context a status bar should remember to paint through.
108
- //
109
- // A UI-less event — a headless child session_start, or a top-level
110
- // session_start that fires before the TUI attaches — must never become the
111
- // remembered context. Two things went wrong when it did: the old
112
- // `ctx || lastCtx` fallback only triggered for a *falsy* ctx, so a truthy
113
- // ctx without `ui` returned early anyway, and remembering it destroyed the one
114
- // paintable context we had. Every later bare syncStatus() then went mute —
115
- // and the bare call is the bridge-listener path, which is how a mode change
116
- // made by a sibling extension reaches this bar. The symptom is a bar frozen on
117
- // a preset the session no longer has: it shows "combo BALANCED" while caveman
118
- // and rtk are already off.
115
+ if (!theme?.fg) return `${indicator} ${label}`;
116
+ return `${highlight ? theme.fg('accent', indicator) : indicator} ${theme.fg('muted', label)}`;
117
+ }
118
+
119
+ export function paintStatusBar(ui: UiApi | undefined, key: string, emoji: string, label: string, isActive: boolean): void {
120
+ ui?.setStatus?.(key, themeStatus(ui, emoji, label, isActive));
121
+ }
122
+
123
+ // Stale ctx access throws, so never let it abort a mode restore.
124
+ export type StatusUi = UiApi & { setStatus: NonNullable<UiApi['setStatus']> };
125
+
126
+ export function statusUi(ctx: ExtensionCtx | undefined): StatusUi | undefined {
127
+ try {
128
+ const ui = ctx?.ui;
129
+ return ui?.setStatus ? (ui as StatusUi) : undefined;
130
+ } catch {
131
+ return undefined;
132
+ }
133
+ }
134
+
135
+ // Which context a status bar paints through. Never remember a UI-less event:
136
+ // that mutes every later bare syncStatus(), freezing the bar on a stale preset.
119
137
  export function paintableCtx(remembered: ExtensionCtx | undefined, next: ExtensionCtx | undefined): ExtensionCtx | undefined {
120
- return next?.ui?.setStatus ? next : remembered;
138
+ return statusUi(next) ? next : remembered;
121
139
  }
122
140
 
123
141
  // Last-wins scan for a custom session entry; skips entries whose value fails
@@ -156,8 +174,7 @@ export function setSharedComboMode(name: ModeName, value: unknown): Readonly<Com
156
174
  if (!mode) return getSharedComboState();
157
175
  const modes = { ...getSharedComboState(), [name]: mode } as Modes;
158
176
  // Derive the level from the final triplet so a redundant same-value write
159
- // (entry replay on resume, upstream ponytail re-affirming its mode) cannot
160
- // strand a matching preset at 'custom' and drop the combo bar.
177
+ // cannot strand a matching preset at 'custom' and drop the combo bar.
161
178
  return publish(normalizedState(modes));
162
179
  }
163
180
 
@@ -178,23 +195,19 @@ export function reconcileSharedComboEntries(entries: SessionEntry[] | null | und
178
195
  if (mode) modes[name] = mode;
179
196
  }
180
197
  }
181
- // The level always reflects the final mode triplet, never the write order:
182
- // a preset entry followed by the same values reconciles back to the preset.
198
+ // The level reflects the final triplet, never the write order, so a preset
199
+ // entry followed by the same values reconciles back to the preset.
183
200
  return publish(normalizedState(modes));
184
201
  }
185
202
 
186
- // One-line session-wide mode summary for change confirmations, e.g.
187
- // `caveman=ULTRA, rtk=ON, ponytail=ULTRA`.
203
+ // One-line session-wide mode summary for change confirmations.
188
204
  export function activeModesSummary(state: { caveman: string; rtk: string; ponytail: string }): string {
189
205
  return `caveman=${state.caveman.toUpperCase()}, rtk=${state.rtk.toUpperCase()}, ponytail=${state.ponytail.toUpperCase()}`;
190
206
  }
191
207
 
192
- export function setSharedComboListener(listener: ((state: Readonly<ComboState>) => void) | null): void {
193
- // Additive: every sibling extension syncs its local mirror on publish, so
194
- // a /tersio or /combo switch takes effect next turn with no session reload.
195
- // A null listener clears all (no current callers; reserved for teardown).
196
- if (typeof listener === 'function') bridge().listeners.add(listener);
197
- else bridge().listeners.clear();
208
+ // Keyed registration drops stale closures; hosts offer no teardown hook.
209
+ export function setSharedComboListener(key: string, listener: (state: Readonly<ComboState>) => void): void {
210
+ bridge().listeners.set(key, listener);
198
211
  }
199
212
 
200
213
  export function resetSharedComboState(): Readonly<ComboState> {
@@ -47,8 +47,16 @@ export interface ExtensionCtx {
47
47
 
48
48
  export interface SystemPromptEvent {
49
49
  systemPrompt: string | string[];
50
+ // pi only: the mutable prompt sections. OMP does not provide it.
51
+ systemPromptOptions?: { appendSystemPrompt?: string };
50
52
  }
51
53
 
54
+ /** What a `before_agent_start` handler hands back. OMP takes an array, pi a string. */
55
+ export type PromptInjection = { systemPrompt: string[] } | { systemPrompt: string };
56
+
57
+ /** A tool `parameters` object. OMP wants zod, pi wants JSON Schema. */
58
+ export type ToolParams = Record<string, unknown>;
59
+
52
60
  export interface InputEvent {
53
61
  text?: string;
54
62
  source?: string;
@@ -68,7 +76,7 @@ export interface ExtensionApi {
68
76
  execute: (
69
77
  toolCallId: string,
70
78
  params: { args: string[] },
71
- signal: AbortSignal,
79
+ signal: AbortSignal | undefined,
72
80
  onUpdate: ((data: unknown) => void) | undefined,
73
81
  ctx: ExtensionCtx | undefined,
74
82
  ) => Promise<{
@@ -86,5 +94,4 @@ export interface ExtensionApi {
86
94
  }>;
87
95
  cwd?: string;
88
96
  zod?: { z: unknown };
89
- on<E>(event: string, handler: (event: E, ctx: ExtensionCtx) => unknown): void;
90
- }
97
+ on<E>(event: string, handler: (event: E, ctx: ExtensionCtx) => unknown): void;}
@@ -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.js';
6
+ import { isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from '../lib/utils.js';
9
7
  import { execFileSync } from 'node:child_process';
10
8
  export function ledgerPath() {
11
9
  const override = process.env.TERSIO_USAGE_FILE;
@@ -13,11 +11,7 @@ export function ledgerPath() {
13
11
  return override;
14
12
  return tersioDataPath('usage.jsonl', 'tersio-usage.jsonl');
15
13
  }
16
- // Reset watermark: tersio-owned timestamp marking the last statistics reset.
17
- // Session transcripts and the RTK database are host/tool-owned and never
18
- // touched — instead, every derived view (token stats, command tools) filters
19
- // rows from before the watermark, so "reset" empties what tersio shows
20
- // without deleting anything it does not own.
14
+ // Reset watermark: views filter rows older than it, so nothing else is deleted.
21
15
  export function resetMarkerPath() {
22
16
  const override = process.env.TERSIO_RESET_FILE;
23
17
  if (override)
@@ -52,13 +46,14 @@ export function appendUsage(kind, detail) {
52
46
  function zeroBreakdown() {
53
47
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
54
48
  }
49
+ function stampMs(ts) {
50
+ return ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
51
+ }
55
52
  export function durOf(v) {
56
53
  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
57
54
  }
58
- // Measured cost for one message. OMP writes usage.cost as
59
- // { input, output, cacheRead, cacheWrite, total }; a bare number shows up in
60
- // older fixtures. Anything else means "not recorded" — deliberately not 0, so
61
- // the dashboard can tell a genuinely free run from an unrecorded one.
55
+ // Measured cost: usage.cost.total, or a bare number in old fixtures. Anything
56
+ // else means unrecorded, which is deliberately not 0.
62
57
  export function costOf(usage) {
63
58
  const c = usage.cost;
64
59
  if (typeof c === 'number')
@@ -92,11 +87,20 @@ function addInto(into, u) {
92
87
  into[k] += Math.floor(v);
93
88
  }
94
89
  }
95
- export function sessionsDir() {
90
+ export function sessionsDirs() {
96
91
  const override = process.env.TERSIO_SESSIONS_DIR;
97
92
  if (override)
98
- return override;
99
- return path.join(os.homedir(), '.omp', 'agent', 'sessions');
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.
96
+ const dirs = isPiProcess()
97
+ ? [path.join(piAgentDir(), 'sessions'), path.join(os.homedir(), '.omp', 'agent', 'sessions')]
98
+ : [path.join(os.homedir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
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];
100
104
  }
101
105
  export function codexSessionsDir() {
102
106
  const override = process.env.TERSIO_CODEX_DIR;
@@ -107,6 +111,22 @@ export function codexSessionsDir() {
107
111
  return path.join(codexHome, 'sessions');
108
112
  return path.join(os.homedir(), '.codex', 'sessions');
109
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
+ }
110
130
  export function dayKey(ts) {
111
131
  const ms = typeof ts === 'number' ? ts : Date.parse(ts);
112
132
  if (!Number.isFinite(ms))
@@ -115,7 +135,7 @@ export function dayKey(ts) {
115
135
  const p = (n) => String(n).padStart(2, '0');
116
136
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
117
137
  }
118
- export function walkJsonl(dir, out, cap) {
138
+ export function walkJsonl(dir, out, cap, ext = '.jsonl') {
119
139
  let entries;
120
140
  try {
121
141
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -128,14 +148,13 @@ export function walkJsonl(dir, out, cap) {
128
148
  return;
129
149
  const full = path.join(dir, e.name);
130
150
  if (e.isDirectory())
131
- walkJsonl(full, out, cap);
132
- else if (e.isFile() && e.name.endsWith('.jsonl'))
151
+ walkJsonl(full, out, cap, ext);
152
+ else if (e.isFile() && e.name.endsWith(ext))
133
153
  out.push(full);
134
154
  }
135
155
  }
136
- // Recent requests are their own full-width table in the dashboard, so this is
137
- // a payload bound rather than a "few highlights" bound. Rows are small
138
- // (model, tokens, timing, status), so a few hundred cost little to ship.
156
+ // Recent requests get their own full-width table, so this bounds payload
157
+ // rather than highlights; rows are small, so a few hundred cost little.
139
158
  export const RECENT_LIMIT = 200;
140
159
  const FREE_SUFFIX = /(?::free|-free)$/i;
141
160
  const RTK_ELIGIBLE_HEADS = new Set([
@@ -146,7 +165,7 @@ const RTK_ELIGIBLE_HEADS = new Set([
146
165
  export function newSessionAccum() {
147
166
  return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
148
167
  }
149
- export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames) {
168
+ export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames, host) {
150
169
  const watermark = readResetWatermark();
151
170
  const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
152
171
  if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark))
@@ -159,7 +178,7 @@ export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames)
159
178
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
160
179
  if (Number.isFinite(ms)) {
161
180
  const outcome = run ?? { st: 'completed' };
162
- 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 });
163
182
  }
164
183
  const day = ts !== undefined ? dayKey(ts) : null;
165
184
  if (day) {
@@ -184,6 +203,10 @@ export function classifySessionLine(row) {
184
203
  if (payload && typeof payload === 'object') {
185
204
  if (row.type === 'session_meta' && typeof payload.model_provider === 'string')
186
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
+ }
187
210
  if (payload.type === 'token_count') {
188
211
  const last = payload.info?.last_token_usage;
189
212
  if (last) {
@@ -197,7 +220,10 @@ export function classifySessionLine(row) {
197
220
  if (!msg || msg.role !== 'assistant' || !msg.usage)
198
221
  return { kind: 'skip' };
199
222
  const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
200
- 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;
201
227
  const tools = [];
202
228
  for (const part of msg.content ?? []) {
203
229
  if (part?.type !== 'toolCall' || typeof part.name !== 'string' || !part.name)
@@ -211,13 +237,63 @@ export function classifySessionLine(row) {
211
237
  }
212
238
  return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
213
239
  }
214
- // Fold `:free`/`-free` suffixes and case variants into one chart key, so the
215
- // same model from two providers stops splitting into separate rows.
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
+ }
291
+ // Fold `:free`/`-free` suffixes and case variants into one chart key.
216
292
  export function canonicalModelId(model) {
217
- return model.replace(FREE_SUFFIX, '').toLowerCase();
293
+ return canonicalPriceId(model.replace(FREE_SUFFIX, ''));
218
294
  }
219
- // LiteLLM-style display: namespace stays lowercase, model segments title-case
220
- // with version dots kept (`deepseek-v4.1-flash` → `Deepseek-V4.1-Flash`).
295
+ // LiteLLM-style display: lowercase namespace, title-cased model segments with
296
+ // version dots kept.
221
297
  export function displayModelId(model) {
222
298
  const bare = model.replace(FREE_SUFFIX, '');
223
299
  const cap = (s) => {
@@ -242,13 +318,30 @@ export function displayModelId(model) {
242
318
  export function importSessionTokens() {
243
319
  const accum = newSessionAccum();
244
320
  const files = [];
245
- walkJsonl(sessionsDir(), files, 2000);
246
- // A sessions override signals an isolated environment (tests, fixtures):
247
- // only walk the real codex dir when it is explicitly set.
321
+ for (const dir of sessionsDirs())
322
+ walkJsonl(dir, files, 2000);
323
+ // An override means an isolated environment (tests, fixtures): only walk the
324
+ // real codex dir when one is explicitly set.
248
325
  if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
249
326
  walkJsonl(codexSessionsDir(), files, 2000);
250
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
+ }
251
343
  let codexProvider = null;
344
+ let codexModel = null;
252
345
  for (const file of files) {
253
346
  let text;
254
347
  try {
@@ -257,11 +350,25 @@ export function importSessionTokens() {
257
350
  catch {
258
351
  continue;
259
352
  }
260
- 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));
261
359
  }
262
360
  accum.recent.sort((a, b) => b.t - a.t);
263
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) };
264
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
+ }
265
372
  const adoptionFileCache = new Map();
266
373
  function parseAdoptionFile(file) {
267
374
  const counts = { bashCalls: 0, eligibleCalls: 0, rtkCalls: 0 };
@@ -300,7 +407,8 @@ export function clearRtkAdoptionCache() {
300
407
  }
301
408
  export function readRtkAdoption() {
302
409
  const files = [];
303
- walkJsonl(sessionsDir(), files, 2000);
410
+ for (const dir of sessionsDirs())
411
+ walkJsonl(dir, files, 2000);
304
412
  if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
305
413
  walkJsonl(codexSessionsDir(), files, 2000);
306
414
  }
@@ -338,7 +446,7 @@ export function readRtkAdoption() {
338
446
  const missedCalls = Math.max(0, eligibleCalls - rtkCalls);
339
447
  return { sessions, bashCalls, eligibleCalls, rtkCalls, missedCalls, adoptionPct: eligibleCalls ? (rtkCalls / eligibleCalls) * 100 : 0 };
340
448
  }
341
- export function processSessionText(accum, state, text) {
449
+ export function processSessionText(accum, state, text, host) {
342
450
  for (const line of text.split('\n')) {
343
451
  if (!line.trim())
344
452
  continue;
@@ -350,14 +458,21 @@ export function processSessionText(accum, state, text) {
350
458
  state.codexProvider = parsed.provider;
351
459
  continue;
352
460
  }
461
+ if (parsed.kind === 'codex_model') {
462
+ if (parsed.model)
463
+ state.codexModel = parsed.model;
464
+ continue;
465
+ }
353
466
  if (parsed.kind === 'token_row') {
354
- const model = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
355
- 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');
356
471
  continue;
357
472
  }
358
473
  if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage)
359
474
  continue;
360
- 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);
361
476
  }
362
477
  catch { /* skip corrupt lines */ }
363
478
  }
@@ -385,8 +500,8 @@ export function readRtkRecallDiagnostics(binary = resolveRtkBinary()) {
385
500
  return { mode: 'unknown', entries: 0, available: false };
386
501
  }
387
502
  }
388
- // Lead binary of a shell string: first segment head past `cd` chains and
389
- // VAR=x assignments (`cd /x && git status` → `git`). Falls back to `bash`.
503
+ // Lead binary of a shell string: first segment past `cd` chains and VAR=x
504
+ // assignments. Falls back to `bash`.
390
505
  const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
391
506
  export function leadBinary(command) {
392
507
  if (typeof command !== 'string' || !command.trim())
@@ -403,7 +518,7 @@ export function leadBinary(command) {
403
518
  return 'bash';
404
519
  }
405
520
  // --- Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table) ---
406
- import { DEFAULT_PRICE, priceFor, refreshPricesIfStale } from './pricing.js';
521
+ import { DEFAULT_PRICE, canonicalPriceId, priceFor, refreshPricesIfStale } from './pricing.js';
407
522
  import { co2GramsFor, energyWhFor } from './carbon.js';
408
523
  export { DEFAULT_PRICE, priceFor, refreshPricesIfStale };
409
524
  export { co2GramsFor, energyWhFor };
@@ -422,11 +537,6 @@ export function usdCost(t, model) {
422
537
  buckets,
423
538
  };
424
539
  }
425
- // CO2 now model-differentiated via ./carbon.ts (EcoLogits 0.8.2 port).
426
- // Always render with ~est. and never merge with measured figures.
427
- // The constant below is the served gCO2eq per 1K output tokens for the
428
- // default (gpt-4o-class) model, kept so single-figure callers stay honest.
429
- export const CO2_G_PER_1K_OUTPUT = 0.2;
430
540
  export function co2Grams(outputTokens, model) {
431
541
  return co2GramsFor(model ?? 'gpt-4o', outputTokens);
432
542
  }
@@ -452,9 +562,8 @@ export function readUsage() {
452
562
  }
453
563
  return rows;
454
564
  }
455
- // Delete the ledger file. Returns the rows cleared (0 when already absent).
456
- // Only tersio-owned data lives here; session transcripts and the RTK
457
- // database are never touched.
565
+ // Delete the ledger file and return the rows cleared. Tersio-owned data only:
566
+ // transcripts and the RTK database are never touched.
458
567
  export function clearUsageLedger() {
459
568
  const rows = readUsage().length;
460
569
  try {