Measures the prompt cache for a team, in Claude Code and, with its command-line companion, Codex CLI and Devin CLI: hit rate per session, subagent and model, a…

See the cache hit rate of every session, subagent and model while it runs. Get a warning, or a question, before a cold resume sends a whole context to the model again. Know the quota each session used. Then pool anonymized numbers across a team to see where its cost and quota go.
<img src="../../docs/images/meter-demo.gif" alt="oxen-meter: the /meter pane counts down a subagent's cache from warm to cold while it waits for Codex, Claude Code asks before resuming it, Codex and Devin show the companion's hook before a prompt into a cold thread, and the report adds up a week of all three tools" width="860">
<sub>A scripted session played through the meter's own modules: every line the meter shows is its own output, but the numbers are made up. The screens quoted in this guide are real captures from a live machine.</sub>
Install · Quick start · The pane · The guard · Codex and Devin · The report · Team report · Settings · Privacy · FAQ
The prompt cache prices input three ways against a plain input token: a read costs 0.1, a write 1.25 when the cache lives 5 minutes, 2 when it lives an hour. Each read starts the cache's time to live over. A thread that sits past it pays for its whole context again on its next request.
That is how a long task can eat a weekly limit. A subagent holding 400K tokens of context waits more than an hour for a Codex audit, is resumed, and writes the 400K again. One such run took 30–35% of a weekly limit where 10–15% was expected. oxen-meter shows those moments while they happen, warns before the ones it can see coming, and keeps the numbers to compare across a team.
/meter lists every live thread with its context and how long its cache has left: warm, cooling (the last fifth of its time to live), or cold./meter export writes an anonymized file; one script turns everyone's files into a team report.It measures the right side of the usual cost model, request by request: cost = users × sessions × turns × requests per turn × tokens per request × price per token.
You need Claude Code v2.1.291 or later (claude --version).
claude plugin marketplace add thanhnhoncntt/oxen-pet
claude plugin install oxen-meter@oxen-pet
Start a new session, or run /reload-plugins in an open one. The meter records from the next model request on. It draws no pet and no band, so it runs beside oxen-pet or alone. Claude Code may say that 11 options are not yet set: each has a default, so there is nothing to do until you want another value.
| You use | Do this once | Then |
|---|---|---|
| Claude Code | Install, above. | /meter opens the pane; /meter report adds up the last 7 days. |
| Codex CLI | node $M setup codex --write, then open Codex and trust the hooks in /hooks. | A warning before a prompt into a thread that sat an hour; node $M report. |
| Devin CLI | node $M setup devin --write, and set Cold resume guard to ask (Settings): Claude Code's guard asks then too, unless the hooks get a settings file of their own. | The prompt held back once before it resumes a cold session; node $M report. |
| A team | Each person runs /meter export (or node $M export) and sends the file. | node tools/meter/aggregate.mjs --out report/ exports/ |
$M is the companion, ~/.claude/plugins/marketplaces/oxen-pet/tools/meter/oxen-meter.mjs: see Codex CLI and Devin CLI.
/meter opens a pane with this session's numbers; /meter again closes it. It redraws every 15 seconds.
<img src="../../docs/images/meter-pane.png" alt="The /meter pane from the scripted demo: the cache hit rate and token counts, steps, token equivalent and TTLs, the main thread warm for 53 more minutes, a subagent that sat 7 minutes shown cold in red, where the session is saved, the handoffs, and the meter's own hook timings" width="860">
The picture is from the scripted demo. Here is the top of a real pane, captured in Claude Code 2.1.291 with Opus 5.5 on 2026-10-06, after a subagent that sat 7 minutes was resumed anyway and wrote its context again:
╭───────────────────────────────────────────────────────────────────────────────────✕─╮
│ Cache hit 74% · read 530K · written 187K · uncached 24 · output 1.0K │
│ Steps 7 main · 2 subagent │
│ Token eq. 335K: main 173K · subagent 162K │
│ TTL main 1h (inferred: 6 warm up to 6m) · subagent 5m (inferred: 1 cold from │
│ 7m) │
│ Cold 1 cold resume: 74K eq beyond a read │
│ main opus-5-5 · ctx 86K · read 0m ago · warm ~1h00m │
╰─────────────────────────────────────────────────────────────────────────────────────╯
| Row | What it says |
|---|---|
| Cache | Cache read over the whole context the requests carried, then the four token counts. |
| Steps | Model requests, by the main thread and by subagents. |
| Token eq. | What the session cost in plain input tokens (below). |
| TTL | Each role's cache time to live, and how the meter knows it. |
| Cold | The cold resumes so far, and what they cost beyond a read. Absent while there are none. |
| main, one row per live subagent | Its model and context, when its cache was last read, and how long it has left: green warm, amber cooling, red cold. Resume a cooling agent soon, or let it go and spawn a fresh one later. |
| Files | Where the session is saved, or why it is not. |
| Handoffs, Outcomes, Compactions | Work given to subagents and Codex; commits and pull requests; compactions and their sizes. |
| turn.step, tool.call | How long the meter's own hooks take. |
Where no pane shows, /meter prints the same rows as text.
Before Claude sends a message (SendMessage) that resumes an agent idle past nine tenths of its TTL, with at least Cold resume tokens of context, the Cold resume guard setting decides:
☐ Cold resume
│ oxen-meter: general-purpose ea47 has sat 10m, past its 5m cache. Resuming it writes
│ its 64K context again: about 74K token equivalents. Spawn a fresh agent with a short
│ handoff instead?
❯ 1. Spawn a fresh agent
2. Resume anyway
3. Type something.
Spawn a fresh agent keeps the message back, and Claude reads that it should start a fresh agent with a short handoff instead. Resume anyway sends it. With no one to answer (claude -p, CI), the message goes.
A thread that wakes on its own past its TTL, such as a subagent whose background Codex run just finished, cannot be stopped by a plugin. It gets a toast, once per idle spell, and the report counts it.
Neither can load a Claude Code mod, so the meter has a command-line companion, tools/meter/oxen-meter.mjs (Node 22.18 or later). It reads their logs into the meter's data folder and runs as their hooks.
Claude Code keeps a clone of this marketplace in ~/.claude/plugins/marketplaces/oxen-pet, and claude plugin marketplace update oxen-pet brings it up to date (the companion is in it from oxen-meter 1.1.0 on). Run the companion from there, a path that stays put across updates, or from a clone of your own:
M=~/.claude/plugins/marketplaces/oxen-pet/tools/meter/oxen-meter.mjs
node $M import [days] # Codex's ~/.codex/sessions and Devin's sessions.db, into the data folder
node $M report [days] # the report over Claude Code, Codex and Devin, 7 days by default
node $M export [days] # the export, for whoever has no Claude Code, 30 days by default
node $M setup codex --write # adds the meter's hooks to ~/.codex/hooks.json (asks first, keeps a backup)
node $M setup devin --write # the same in the "hooks" of ~/.config/devin/config.json
Once imported, /meter report and /meter export in Claude Code count Codex and Devin too. The companion takes your oxen-meter settings from Claude Code (~/.claude/settings.json) and writes to the same data folder. An import reads each Codex file on from where the last one stopped, so it stays quick: on one laptop, 30 days of rollouts (229 files, 2.1 GB) and Devin's 560 MB database took about 5 seconds the first time, and nothing the next.
| Claude Code | Codex CLI | Devin CLI | |
|---|---|---|---|
| Where the numbers come from | every request, live | every request, from its rollout files | every request, from its database |
| Cache writes | 5m or 1h | none: OpenAI does not charge for them | Claude models only |
| How long the cache lives | a TTL, 5m or 1h, measured or inferred | no fixed TTL: the gap curve | no fixed TTL: the gap curve; Devin pings its cache to keep it |
| Subagents | each a thread | each its own rollout, a thread by its role | each chain a thread by its profile |
| Quota | the 5-hour and 7-day windows | the weekly window | not kept locally |
| Before a cold resume | toast, or a question | hooks: a message, or the prompt held back once | hooks: the prompt held back once |
setup prints what it would add; --write adds it after a yes, beside your own hooks, and keeps the old file as ….oxen-meter.bak, as private as the file it keeps. With no terminal to answer (a script, an agent), add --yes. It never touches ~/.claude/settings.json. Codex runs a new hook only once you trust it: open Codex, run /hooks, and trust the oxen-meter entries. The hooks run the Node that ran setup; after you upgrade Node, run setup again.
↳ Hook · oxen-meter: this thread sat 8m. Its 18K context is likely out of the cache, so
this prompt sends it all again (~16K eq). A fresh thread with a short summary costs
less.
• I’ll give helper the follow-up task and wait for its answer.
↳ Hook · oxen-meter: agent d1e3 sat 8m. Resuming it likely sends its 17K context again
(~15K eq); a fresh subagent with a short handoff costs less.
With the guard on ask, the prompt is held back once instead: press ↑ and Enter to send it anyway. A follow-up (followup_task, send_message) to a subagent that sat as long gets the same line; Codex cannot ask before a tool call, so it is never held back. Each turn's, subagent's and session's end imports that thread at once.
warn says nothing there. With the guard on ask, a prompt into a session that sat past Cold after is held back once, and ↑ and Enter send it. Captured live (Devin CLI 3000.11.3, SWE-2 Medium, 2026-10-06, the thresholds set low for the test): ❭ Reply with the first word of README.md.
✱ Prompt blocked: oxen-meter: this thread sat 7m. Its 14K context is likely out of
the cache, so this prompt sends it all again (~13K eq). Press ↑ and Enter to send
it anyway, or start a fresh thread with a short summary.
❭ Reply with the first word of README.md.
hello
Devin's keepalive pings read the cache every few minutes, so the guard counts from the last one. Each turn's and session's end imports that session.
A hook prints nothing and lets everything through when anything goes wrong. A Codex hook takes about 70 to 180 ms a run, a Devin one 150 to 460 ms (it opens Devin's database), Node's start included.
/meter report [days] in Claude Code, or node $M report [days], adds up your sessions of the last days, 7 by default, over every tool. A real one, captured in Claude Code on one developer's laptop on 2026-10-06, right after the install and node $M import 30: 30 days of their Codex and Devin logs, and one Claude Code session, the one that ran the command. Nothing is changed but one line left out, Quota, at the owner's request.
❯ /meter report 30
⎿ oxen-meter: 119 sessions in the last 30 days
Tools claude 1 session, 0 eq · codex 96 sessions, 505M eq · devin 22 sessions, 132M eq
Cache hit 97% · read 4280M · written 1.2M · uncached 124M · output 17M
Token eq. 637M: main 512M · subagent 125M
Models gpt-5.6-sol hit 97%, 361M eq · swe-2-max hit 97%, 90M eq · gpt-6-astra hit 97%, 55M eq · gpt-6-sol hit 98%, 47M eq · swe-2-high hit 97%, 28M eq · gpt-6.1-sol hit
97%, 23M eq · gpt-6-luna hit 98%, 9.2M eq · gpt-5.5 hit 92%, 6.4M eq · compactor hit 0%, 6.0M eq · fable-5-1-medium hit 96%, 4.5M eq · swe-2-medium hit 95%, 4.1M eq ·
gpt-5.6-terra hit 96%, 3.6M eq
Agents main 496M eq · worker 37M · code-reviewer 35M · compaction 18M · explorer 18M · fullstack-developer 6.5M · tester 5.8M · Sidekick 5.5M · ui-ux-designer 5.1M ·
debugger 4.2M · planner 1.3M · subagent 1.1M · default 1.0M · docs-manager 848K · General 795K · code-simplifier 509K · journal-writer 271K · project-manager 220K ·
git-manager 171K · advisor 49K
TTL main 1h (default: no samples) · subagent 5m (default: no samples)
Gaps codex gpt-5.6: 5–10m 30/33 warm · 10–30m 22/25 · 30–60m 9/14 · 1–2h 14/23 · 2–6h 0/11 · 6h+ 0/6
codex gpt-5.5: 5–10m 1/2 warm · 10–30m 1/1
codex gpt-6: 5–10m 15/15 warm · 10–30m 14/15 · 30–60m 6/6 · 1–2h 2/4 · 2–6h 2/3 · 6h+ 0/6
codex gpt-6.1: 5–10m 7/7 warm · 10–30m 2/3 · 30–60m 1/1 · 1–2h 0/1 · 2–6h 0/1 · 6h+ 0/1
devin swe-2: 5–10m 3/8 warm · 10–30m 0/6 · 30–60m 0/5 · 1–2h 0/2 · 2–6h 0/3 · 6h+ 0/1
devin fable: 5–10m 0/1 warm
Keepalive 64 pings, 2.0M eq
Compaction 268, 18M eq
Cold 68 cold resumes: 8.4M written again, 7.6M eq beyond a read
2026-09-15 10:20 UTC codex explorer f5ff gpt-5.6-sol idle 2h14m sent again 221K +199K eq
2026-09-14 03:17 UTC codex main gpt-5.6-sol idle 2h01m sent again 205K +184K eq
2026-09-17 06:56 UTC codex main gpt-5.6-sol idle 3h31m sent again 198K +178K eq
2026-09-14 11:35 UTC codex main gpt-5.6-sol idle 59m sent again 198K +178K eq
2026-09-15 13:43 UTC codex code-reviewer 7399 gpt-5.6-sol idle 19m sent again 195K +176K eq
Handoffs 156 Agent (156 background), median 6m back · 418 resumes (418 background), median 6m back · next handoff median 3m
Flags 236 context bloat · 14 big first prefix · 2 short task on an expensive model
What it says, read plainly. Codex's cache held through most short breaks (gpt-5.6: 30 of 33 steps after 5–10 minutes, 14 of 23 after 1–2 hours) and never past 2 hours (0 of 17). Devin's SWE-2 lost it after 5–10 minutes more often than not (3 of 8 held), and never held it past 10 (0 of 17). The 68 cold resumes cost 7.6M token equivalents beyond a read, about 1% of the month. Cache reads cost far more: 4280M read at 0.1 are 428M of the 637M, which is why the 236 context bloat flags (threads at 150K or more for 20 steps without a compaction) matter more here than cold resumes. The TTL row says no samples because Claude Code had no recorded session yet.
measured: Claude Code reported it (an Agent call's cache writes, split 5m/1h, or a model switch). inferred: from the gaps around warm and cold steps; a cold step after 5.5–60 minutes says 5m, a warm one after more than 5.5 minutes says 1h. default: no evidence yet. setting: you chose it.claude --resume) after its cache likely expired. For Codex and Devin, which have no TTL, a step that sent its context again after 5 minutes or more. "Beyond a read" is what it cost over reading the same context warm. Steps after a compaction, a model switch or a rewind are left out: those write the context anyway.warm). It is how long their cache lived for you; set Cold after from it.claude 7d +12 pts). Readings more than an hour apart are not joined, since someone else's use may sit between them; two accounts count apart.Limits. Claude Code reports a request's cache writes as one number, not split by TTL, so a role's TTL is measured only where an Agent call or a model switch reports it, and inferred elsewhere; the report shows the samples behind it. The token equivalent leaves out what the price list adds on top, such as long-context pricing.
Each person runs /meter export [days] (30 days by default), or node $M export without Claude Code. It writes one anonymized file to exports/ in the data folder and prints its path. Whoever collects the files clones this repo and runs, with Node 22.18 or later:
node tools/meter/aggregate.mjs --out report/ path/to/exports/
It writes report/team-report.md and team-report.json: the team's summary; one row per person (by their label), tool, agent type and model; the top 20 cold resumes; each role's TTL with its samples; the Codex and Devin caches by gap; the quota each person used; a row per week; the flags and handoffs per person; the meter's own hook timing. --cold-tokens, --output-weight and --cached-weight set the team's thresholds.
Run /plugin configure oxen-meter@oxen-pet. Every setting has a default, and the companion reads the same ones.
| Setting | Default | What it does |
|---|---|---|
| Cold resume guard | warn | off, warn or ask, above. |
| Cold resume tokens | 50000 | How large a context must be before writing it again counts as a cold resume. |
| Main thread cache TTL | auto | auto uses the TTL the meter measured or inferred, 1h until it has samples; or 5m, 1h. |
| Subagent cache TTL | auto | The same for subagents, 5m until it has samples. |
| Your label | empty | The name your exports carry in the team report. Empty: anonymous. |
| Hash project names | on | The project is kept as a hash salted with a key of your own, never its name. |
| Output weight | 5 | What one output token weighs against one uncached input token in the token equivalent. |
| Keep sessions (days) | 30 | Session files older than this are emptied (below). |
| Data folder | empty | Where sessions and exports go. Empty: oxen-meter in your .claude folder. |
| Cached input weight (Codex, Devin) | 0.1 | What a cached input token of a model with no cache writes (OpenAI, SWE) weighs against an uncached one. |
| Cold after (Codex, Devin) | 60 | Minutes a Codex or Devin thread may sit before the companion's hooks warn its cache is likely gone. |
Settings apply after Claude Code restarts. The companion reads them from Claude Code's settings file, ~/.claude/settings.json, under pluginConfigs → oxen-meter@oxen-pet → options, with the setting names of plugin.json (resumeGuard, coldAfterMin, …). Without Claude Code, keep a file of that shape, such as {"pluginConfigs": {"oxen-meter@oxen-pet": {"options": {"resumeGuard": "ask"}}}}, and pass it as --claude-settings <file>; setup puts the flag in the hooks it adds.
Recorded, for each model request: when it started and ended, the thread (main or the agent's id), the agent type, model and effort, the four token counts, the context size, the gap since the thread's last request, the message count, the names of the tools it asked for, and why it stopped. Around them: a subagent's start and stop; an Agent call's agent, type, model, total tokens and cache split; a Codex call's sub-command (task, review, exec), times, a
hooks/register.tsx 664 lines1import type { EngineInterface, Register, TurnStepResult } from 'claude-code'
2
3import { roleOf, summarize, ttlOf } from './analyze'
4import type { Role } from './analyze'
5import { codexCallOf, outcomeOf } from './codex'
6import { dataPathError, dataRootOf, dataTargetError, exportPath, saltPath, sessionPath } from './dataPath'
7import { exportOf, exportText } from './exportFile'
8import { projectLabel, shortHash } from './project'
9import { MAIN, addEvent, addStep, agentCallOf, claudeQuota, newCollector, quotaRecordsOf, stepRecordOf, threadOf } from './record'
10import type { Collector } from './record'
11import { COLORS, filesText, fmtDur, fmtTokens, paneRows, reportText, rowsText, threadLabel } from './report'
12import { RESUME_OPTIONS, coldStartText, freshReason, resolveRecipient, resumeQuestion, resumeRisk, resumeToast } from './resume'
13import type { LiveAgent } from './report'
14import { TOMBSTONE, activeAt, isExpired, readSessionText, restoreCollector, sessionText } from './sessionFile'
15import type { SessionFile } from './sessionFile'
16import type { SessionData } from './analyze'
17import { readSettings } from './settings'
18import type { Settings } from './settings'
19import { noteTiming } from './timing'
20
21const COMMAND = 'meter'
22const PANE_ID = 'meter'
23const TICK_MS = 15000 // how often an open pane redraws and a changed session is written
24const FLUSH_MS = 30000 // the longest a changed session waits for its file while no main turn ends
25const DAY_MS = 86400000
26const SALT_KEY = 'salt' // in $.store: the user's own salt for project hashes
27const SWEPT_KEY = 'sweptAt' // in $.store: when expired session files were last emptied
28const REPORT_DAYS = 7
29const EXPORT_DAYS = 30
30const MAX_REPORT_DAYS = 365
31
32/** The meter's state for the session: the collector, and where and when its file was written. */
33type Meter = {
34 c: Collector
35 root: string | undefined // the data folder; undefined writes nothing
36 sid?: string
37 startedAt: number
38 project: string
39 version: string
40 savedAt?: number
41 triedAt: number
42 error?: string
43 writing: boolean
44 turnEnded: boolean // a main turn ended since the last write
45 paneOpen: boolean
46 ttl: Record<Role, number> // each role's TTL in minutes, as the last tick worked it out, for the hot path
47 warned: Record<string, number> // the thread's last step the user was last warned about, so one idle spell warns once
48}
49
50/** Now, or undefined when the clock call fails: a hook that cannot read the time records nothing and goes on. */
51async function nowOr($: EngineInterface) {
52 try {
53 return await $.clock.now()
54 } catch {
55 return undefined
56 }
57}
58
59/** The session's agents as the pane needs them, or none when the list call fails. */
60async function liveAgents($: EngineInterface): Promise<LiveAgent[]> {
61 try {
62 return (await $.agent.list()).map(a => ({ id: a.id, status: a.status }))
63 } catch {
64 return []
65 }
66}
67
68/** The session's agents with the names SendMessage addresses them by, or none when the list call fails. */
69async function namedAgents($: EngineInterface): Promise<{ id: string; name?: string }[]> {
70 try {
71 return (await $.agent.list()).map(a => ({ id: a.id, ...(a.name !== undefined ? { name: a.name } : {}) }))
72 } catch {
73 return []
74 }
75}
76
77/** The stat of `path` with where it lands, or undefined when nothing is there. */
78async function statOr($: EngineInterface, path: string) {
79 try {
80 return await $.fs.stat(path, { resolve: true })
81 } catch {
82 return undefined
83 }
84}
85
86const parentOf = (path: string) => path.slice(0, Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')))
87
88/** Writes `text` to `path` in the data folder `root`, once the guard allows it; throws the guard's reason when it does not. */
89async function writeGuarded($: EngineInterface, root: string, path: string, text: string) {
90 const spelled = dataPathError(root, path)
91 if (spelled !== undefined) {
92 throw new Error(spelled)
93 }
94 const target = dataTargetError(path, { parent: await statOr($, parentOf(root)), root: await statOr($, root), dir: await statOr($, parentOf(path)), file: await statOr($, path) })
95 if (target !== undefined) {
96 throw new Error(target)
97 }
98 await $.fs.write(path, text)
99}
100
101/** The meter's version, from its own manifest. */
102async function versionOf($: EngineInterface) {
103 try {
104 const version = (JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: unknown }).version
105 return typeof version === 'string' ? version : 'unknown'
106 } catch {
107 return 'unknown'
108 }
109}
110
111/**
112 * The user's own salt for hashes: the one in the data folder, which the CLI reads too, so a session keeps one id in
113 * every export; else the mod's own, made the first time it is needed and written there.
114 */
115async function saltOf($: EngineInterface, root: string | undefined) {
116 if (root !== undefined) {
117 try {
118 const shared = (JSON.parse(await $.fs.read(saltPath(root))) as { salt?: unknown }).salt
119 if (typeof shared === 'string' && shared.length >= 8) {
120 return shared
121 }
122 } catch {
123 // No shared salt yet.
124 }
125 }
126 let salt = await $.store.get(SALT_KEY)
127 if (typeof salt !== 'string') {
128 salt = crypto.randomUUID()
129 await $.store.set(SALT_KEY, salt)
130 }
131 if (root !== undefined) {
132 await writeGuarded($, root, saltPath(root), JSON.stringify({ salt })).catch(() => undefined)
133 }
134
135 return salt as string
136}
137
138/** The session's project as the records name it, hashed with the user's own salt unless they turned hashing off. */
139async function projectOf($: EngineInterface, root: string | undefined, hash: boolean) {
140 try {
141 return await projectLabel(await $.session.root(), await saltOf($, root), hash)
142 } catch {
143 return ''
144 }
145}
146
147/** The session's id, or undefined when the call fails. */
148async function sessionIdOr($: EngineInterface) {
149 try {
150 return await $.session.id()
151 } catch {
152 return undefined
153 }
154}
155
156/** The settings a session file and an export keep, so a report of them weighs as this one did. */
157const summaryOptions = (s: Settings) => ({ mainTtl: s.mainTtl, subagentTtl: s.subagentTtl, coldTokens: s.coldTokens, outputWeight: s.outputWeight, cachedWeight: s.cachedWeight })
158
159/** Writes the session's file when the session changed, and notes when it did or why not. Never throws. */
160async function flush($: EngineInterface, m: Meter, s: Settings) {
161 if (m.root === undefined || m.writing || !m.c.dirty) {
162 return
163 }
164 m.writing = true
165 try {
166 m.sid ??= await sessionIdOr($)
167 const now = await $.clock.now()
168 m.triedAt = now
169 if (m.sid === undefined) {
170 throw new Error('the session has no id yet.')
171 }
172 let costUsd: number | undefined
173 try {
174 costUsd = (await $.session.usage()).cost?.usd
175 } catch {
176 // The file goes without the cost.
177 }
178 const text = sessionText(m.c, { sid: m.sid, tool: 'claude', project: m.project, startedAt: m.startedAt, savedAt: now, version: m.version, settings: summaryOptions(s), ...(costUsd !== undefined ? { costUsd } : {}) })
179 // Records added while the file is written mark it changed again.
180 m.c.dirty = false
181 await writeGuarded($, m.root, sessionPath(m.root, m.sid), text)
182 m.savedAt = now
183 m.error = undefined
184 m.turnEnded = false
185 } catch (err) {
186 m.c.dirty = true
187 m.error = err instanceof Error ? err.message : String(err)
188 } finally {
189 m.writing = false
190 }
191}
192
193/** Goes on from the session's file when it has one: after a reload, or in a resumed session. */
194async function restore($: EngineInterface, m: Meter) {
195 if (m.root === undefined || m.sid === undefined) {
196 return
197 }
198 try {
199 const file = readSessionText(await $.fs.read(sessionPath(m.root, m.sid)))
200 if (file) {
201 // What arrived before the file was read (a classic SessionStart) goes on after it.
202 const restored = restoreCollector(file)
203 for (const r of m.c.records) {
204 if (r.k === 'step') {
205 addStep(restored, r)
206 } else {
207 addEvent(restored, r)
208 }
209 }
210 restored.dirty = m.c.records.length > 0
211 m.c = restored
212 m.startedAt = file.startedAt
213 }
214 } catch {
215 // No file yet: a new session.
216 }
217}
218
219/** Empties the session files past the retention days, once a day: the meter cannot delete a file. */
220async function sweep($: EngineInterface, m: Meter, s: Settings) {
221 if (m.root === undefined) {
222 return
223 }
224 try {
225 const now = await $.clock.now()
226 const last = await $.store.get(SWEPT_KEY)
227 if (typeof last === 'number' && now - last < DAY_MS) {
228 return
229 }
230 await $.store.set(SWEPT_KEY, now)
231 const dir = `${m.root}/sessions`
232 for (const f of await $.fs.list(dir)) {
233 const path = `${dir}/${f.name}`
234 if (f.kind === 'file' && !f.isLink && f.size > TOMBSTONE.length && f.name !== `${m.sid}.json` && isExpired(f.mtimeMs, now, s.retentionDays) && dataPathError(m.root, path) === undefined) {
235 await writeGuarded($, m.root, path, TOMBSTONE)
236 }
237 }
238 } catch {
239 // Expired files wait for the next session.
240 }
241}
242
243/** The session as the summary reads it. */
244const sessionData = (m: Meter): SessionData => ({ sid: m.sid ?? '', startedAt: m.startedAt, records: m.c.records, groups: m.c.groups })
245
246/** The pane's rows now. */
247async function rowsNow($: EngineInterface, m: Meter, s: Settings) {
248 const now = (await nowOr($)) ?? 0
249 const summary = summarize([sessionData(m)], s)
250
251 return paneRows(m.c, await liveAgents($), now, { main: summary.ttl.main.min, subagent: summary.ttl.subagent.min }, { summary, files: filesText(m, now) })
252}
253
254/** The days a `/meter report` or `/meter export` argument asks for, else `fallback`. */
255function daysOf(arg: string | undefined, fallback: number) {
256 const asked = Number.parseInt(arg ?? '', 10)
257
258 return Number.isFinite(asked) && asked > 0 ? Math.min(asked, MAX_REPORT_DAYS) : fallback
259}
260
261/** The session files written in the last `days`, this session's first brought up to date, and how many were emptied. */
262async function readSessions($: EngineInterface, m: Meter, s: Settings, days: number) {
263 const files: SessionFile[] = []
264 let skipped = 0
265 if (m.root === undefined) {
266 return { files, skipped }
267 }
268 await flush($, m, s)
269 const now = await $.clock.now()
270 const dir = `${m.root}/sessions`
271 for (const f of await $.fs.list(dir).catch(() => [])) {
272 if (f.kind !== 'file' || f.isLink || !f.name.endsWith('.json') || now - f.mtimeMs > days * DAY_MS) {
273 continue
274 }
275 const text = await $.fs.read(`${dir}/${f.name}`).catch(() => '')
276 const file = readSessionText(text)
277 if (file && now - activeAt(file) <= days * DAY_MS) {
278 files.push(file)
279 } else if (text.trim() === TOMBSTONE) {
280 skipped += 1
281 }
282 }
283
284 return { files, skipped }
285}
286
287/** `/meter report [days]`: the session files of the last days, added up. */
288async function report($: EngineInterface, m: Meter, s: Settings, arg: string | undefined) {
289 const days = daysOf(arg, REPORT_DAYS)
290 if (m.root === undefined) {
291 return `No report: ${filesText(m, 0)}`
292 }
293 const { files, skipped } = await readSessions($, m, s, days)
294 const sessions: SessionData[] = files.map(f => ({ sid: f.sid, startedAt: f.startedAt, records: f.records, groups: f.groups, ...(f.tool !== undefined ? { tool: f.tool } : {}) }))
295
296 return reportText(summarize(sessions, s), { days, skipped })
297}
298
299/** `/meter export [days]`: the session files of the last days, anonymized, in one file for the team report. */
300async function exportTo($: EngineInterface, m: Meter, s: Settings, arg: string | undefined) {
301 const days = daysOf(arg, EXPORT_DAYS)
302 if (m.root === undefined) {
303 return `No export written: ${filesText(m, 0)}`
304 }
305 try {
306 const { files } = await readSessions($, m, s, days)
307 const salt = await saltOf($, m.root)
308 const sessions = await Promise.all(files.map(async file => ({ file, id: await shortHash(salt, file.sid) })))
309 const day = new Date(await $.clock.now()).toISOString().slice(0, 10)
310 const path = exportPath(m.root, day.replace(/-/g, ''), s.userLabel)
311 await writeGuarded($, m.root, path, exportText(exportOf(sessions, { label: s.userLabel, version: m.version, day, days, settings: summaryOptions(s) })))
312 const n = sessions.length
313
314 return `Wrote ${n} session${n === 1 ? '' : 's'} of the last ${days} days to ${path}. Send that file to whoever builds the team report.`
315 } catch (err) {
316 return `No export written: ${err instanceof Error ? err.message : String(err)}`
317 }
318}
319
320export const register: Register = (on, options) => {
321 const settings = readSettings(options)
322 const m: Meter = { c: newCollector(), root: undefined, startedAt: 0, project: '', version: 'unknown', triedAt: 0, writing: false, turnEnded: false, paneOpen: false, ttl: { main: 60, subagent: 5 }, warned: {} }
323 const refreshTtl = () => {
324 const ttl = ttlOf(m.c.records, settings)
325 m.ttl = { main: ttl.main.min, subagent: ttl.subagent.min }
326 }
327
328 on('session.start', async ($, e, next) => {
329 m.root = dataRootOf($.plugin.root, settings.dataDir)
330 m.version = await versionOf($)
331 m.project = await projectOf($, m.root, settings.hashProject)
332 m.sid = await sessionIdOr($)
333 try {
334 m.startedAt = (await $.session.usage()).startedAt
335 } catch {
336 m.startedAt = (await nowOr($)) ?? 0
337 }
338 await restore($, m)
339 refreshTtl()
340 await sweep($, m, settings)
341 try {
342 await $.command.register({ name: COMMAND, description: 'oxen-meter: open or close the prompt cache pane; /meter report [days] adds up past sessions; /meter export [days] writes them for the team', argumentHint: '[report [days] | export [days]]', immediate: true })
343 } catch {
344 // Without the command the meter still records; only the pane and the report are missing.
345 }
346 $.clock.every(TICK_MS, async () => {
347 try {
348 if (m.paneOpen) {
349 $.ui.invalidate('ui.render')
350 }
351 refreshTtl()
352 const now = (await nowOr($)) ?? 0
353 if (m.c.dirty && (m.turnEnded || now - m.triedAt >= FLUSH_MS)) {
354 await flush($, m, settings)
355 }
356 } catch {
357 // The next tick tries again; a failing tick never ends the timer.
358 }
359 })
360
361 return next(e)
362 })
363
364 // Every model request of every loop: passed on untouched, then recorded from its usage. Never rewritten or answered.
365 // A thread waking past its TTL with a large context gets one toast for that idle spell: it cannot be stopped here.
366 on('turn.step', async function* ($, e, next) {
367 let mark = performance.now()
368 const t0 = await nowOr($)
369 const thread = threadOf(e.agentId)
370 let coldStart = false
371 try {
372 const state = m.c.threads[thread]
373 const risk = t0 === undefined ? undefined : resumeRisk(state, t0, m.ttl[roleOf(thread)], settings.coldTokens, 1)
374 if (risk && state) {
375 coldStart = true
376 if (settings.resumeGuard !== 'off' && m.warned[thread] !== state.lastT0) {
377 m.warned[thread] = state.lastT0
378 $.ui.toast(coldStartText(risk, threadLabel(thread, m.c)))
379 }
380 }
381 } catch {
382 // The step goes on unwarned.
383 }
384 let own = performance.now() - mark
385 let result: TurnStepResult | undefined
386 try {
387 result = yield* next(e)
388 return result
389 } finally {
390 mark = performance.now()
391 const t1 = await nowOr($)
392 try {
393 if (t0 !== undefined && t1 !== undefined) {
394 const r = stepRecordOf(e, result, t0, t1, m.c)
395 addStep(m.c, coldStart ? { ...r, coldStart: true } : r)
396 }
397 } catch {
398 // A step the meter cannot record goes on as the engine sent it.
399 }
400 own += performance.now() - mark
401 noteTiming(m.c.timings, 'turn.step', own)
402 }
403 })
404
405 // A Bash call: a Codex handoff or an outcome, by the command's text, which is never kept.
406 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
407 let mark = performance.now()
408 let codex: ReturnType<typeof codexCallOf>
409 let outcome: ReturnType<typeof outcomeOf>
410 try {
411 codex = codexCallOf(e.command)
412 outcome = outcomeOf(e.command)
413 } catch {
414 return next(e)
415 }
416 if (!codex && !outcome) {
417 noteTiming(m.c.timings, 'tool.call', performance.now() - mark)
418 return next(e)
419 }
420 const t0 = await nowOr($)
421 // The meter's own time, before the command and after it, never the command's.
422 const own = performance.now() - mark
423 const result = await next(e)
424 mark = performance.now() - own
425 const t1 = await nowOr($)
426 try {
427 const thread = threadOf(e.agentId)
428 const failed = 'deny' in result || result.isError === true
429 const bg = e.run_in_background === true || (!failed && result.result?.backgroundTaskId !== undefined)
430 if (codex && t0 !== undefined && t1 !== undefined) {
431 addEvent(m.c, { k: 'codex', t0, t1, thread, sub: codex.sub, ...(bg ? { bg: true as const } : {}), ...(failed ? { failed: true as const } : {}) })
432 }
433 if (outcome && !failed && t1 !== undefined) {
434 addEvent(m.c, { k: 'outcome', t: t1, thread, outcome })
435 }
436 } catch {
437 // The call's result stands whatever the meter makes of it.
438 }
439 noteTiming(m.c.timings, 'tool.call', performance.now() - mark)
440
441 return result
442 })
443
444 // An Agent call: the agent it started, its type and model, and the TTL its cache writes used.
445 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
446 const t0 = await nowOr($)
447 const result = await next(e)
448 const mark = performance.now()
449 const t1 = await nowOr($)
450 try {
451 if (t0 !== undefined && t1 !== undefined) {
452 const records = agentCallOf('deny' in result ? undefined : result.result, t0, t1, threadOf(e.agentId))
453 for (const r of records) {
454 addEvent(m.c, r)
455 }
456 const started = records[0]?.k === 'agent-call' ? records[0].agent : undefined
457 if (typeof e.name === 'string' && e.name !== '' && started !== undefined) {
458 m.c.names[e.name] = started
459 }
460 }
461 } catch {
462 // The call's result stands whatever the meter makes of it.
463 }
464 noteTiming(m.c.timings, 'tool.call', performance.now() - mark)
465
466 return result
467 })
468
469 on('classic.SubagentStart', async ($, e, next) => {
470 const t = await nowOr($)
471 if (t !== undefined && typeof e.agent_id === 'string') {
472 addEvent(m.c, { k: 'agent-start', t, thread: e.agent_id, agentType: String(e.agent_type ?? '') })
473 }
474 return next(e)
475 })
476
477 on('classic.SubagentStop', async ($, e, next) => {
478 const t = await nowOr($)
479 if (t !== undefined && typeof e.agent_id === 'string') {
480 addEvent(m.c, { k: 'agent-stop', t, thread: e.agent_id, agentType: String(e.agent_type ?? '') })
481 }
482 return next(e)
483 })
484
485 // A message from Claude that resumes an agent whose cache likely went cold: a toast (warn), a question (ask), or a
486 // record alone (off). Only the user's "Spawn a fresh agent" keeps the message back; no answer sends it.
487 on('session.send', async ($, e, next) => {
488 if (e.origin.kind !== 'model') {
489 return next(e)
490 }
491 const t = await nowOr($)
492 const agent = t === undefined ? undefined : resolveRecipient(e.to, m.c, await namedAgents($))
493 if (t === undefined || agent === undefined) {
494 return next(e)
495 }
496 const state = m.c.threads[agent]
497 const risk = resumeRisk(state, t, m.ttl[roleOf(agent)], settings.coldTokens)
498 const sent = { k: 'send' as const, t, thread: threadOf(e.agentId), to: agent, risk: risk !== undefined, ...(state ? { gapMs: t - state.lastT0, ctx: state.lastCtx } : {}), mode: settings.resumeGuard }
499 if (!risk || !state || settings.resumeGuard === 'off') {
500 addEvent(m.c, sent)
501 return next(e)
502 }
503 const label = threadLabel(agent, m.c)
504 m.warned[agent] = state.lastT0
505 if (settings.resumeGuard === 'warn') {
506 $.ui.toast(resumeToast(risk, label))
507 addEvent(m.c, sent)
508 return next(e)
509 }
510 const answer = await $.ui.ask(resumeQuestion(risk, label), { header: 'Cold resume', options: [RESUME_OPTIONS.fresh, RESUME_OPTIONS.resume] }).catch(() => undefined)
511 const chose = answer === RESUME_OPTIONS.fresh ? 'fresh' : answer === RESUME_OPTIONS.resume ? 'resume' : 'unanswered'
512 addEvent(m.c, { ...sent, answer: chose })
513 if (chose === 'fresh') {
514 return { isDelivered: false as const, reason: freshReason(risk, label) }
515 }
516
517 return next(e)
518 })
519
520 // A resumed session: how long it sat and whether its cache likely expired, as Claude Code worked it out.
521 on('classic.SessionStart', async ($, e, next) => {
522 try {
523 const idleS = e.seconds_since_last_response
524 const t = await nowOr($)
525 if ((e.source === 'resume' || e.source === 'fork') && typeof idleS === 'number' && t !== undefined) {
526 const ctx = e.context_tokens ?? 0
527 const expired = e.prompt_cache_likely_expired === true
528 addEvent(m.c, { k: 'main-resume', t, thread: MAIN, idleS, ctx, expired })
529 if (expired && ctx >= settings.coldTokens && settings.resumeGuard !== 'off') {
530 $.ui.toast(`oxen-meter: this session sat ${fmtDur(idleS * 1000)} and its cache likely expired: the first request writes its ${fmtTokens(ctx)} context again.`)
531 }
532 }
533 } catch {
534 // The session starts as Claude Code starts it.
535 }
536 return next(e)
537 })
538
539 // A model switch reports the main thread's TTL: a measured one.
540 on('classic.PostModelSwitch', async ($, e, next) => {
541 const t = await nowOr($)
542 if (t !== undefined && (e.cache_ttl === '5m' || e.cache_ttl === '1h')) {
543 addEvent(m.c, { k: 'ttl', t, thread: MAIN, ttl: e.cache_ttl, source: 'model-switch' })
544 }
545 return next(e)
546 })
547
548 // A compaction: passed on untouched, then recorded with its sizes and the summarizer's tokens.
549 on('session.compact', async ($, e, next) => {
550 if (e.trigger === 'precompute') {
551 return next(e)
552 }
553 const t0 = await nowOr($)
554 const stepsBefore = m.c.stepsInCompaction
555 m.c.compacting += 1
556 let result: Awaited<ReturnType<typeof next>>
557 try {
558 result = await next(e)
559 } finally {
560 m.c.compacting -= 1
561 }
562 const t1 = await nowOr($)
563 if (result.skip === undefined && t0 !== undefined && t1 !== undefined) {
564 const u = result.usage
565 addEvent(m.c, {
566 k: 'compact',
567 t0,
568 t1,
569 thread: threadOf(e.agentId),
570 trigger: e.trigger,
571 ...(result.tokensBefore !== undefined ? { before: result.tokensBefore } : {}),
572 ...(result.tokensAfter !== undefined ? { after: result.tokensAfter } : {}),
573 ...(u ? { usage: { in: u.input_tokens, out: u.output_tokens, cr: u.cache_read_input_tokens, cw: u.cache_creation_input_tokens } } : {}),
574 stepsSeen: m.c.stepsInCompaction - stepsBefore,
575 })
576 }
577
578 return result
579 })
580
581 // A rate-limit window that moved a point: a quota reading, so a report can say how much of it the session used.
582 on('session.measure', async ($, e, next) => {
583 try {
584 const t = e.changed.includes('rateLimits') ? await nowOr($) : undefined
585 if (t !== undefined) {
586 for (const r of quotaRecordsOf(m.c.records, t, MAIN, claudeQuota(e.rateLimits))) {
587 addEvent(m.c, r)
588 }
589 }
590 } catch {
591 // The measurement goes on unrecorded.
592 }
593 return next(e)
594 })
595
596 // A main turn's end asks for the session's file at the next tick.
597 on('turn.complete', async ($, e, next) => {
598 if (e.agentId === undefined) {
599 m.turnEnded = true
600 }
601 return next(e)
602 })
603
604 // The session's file is written as it ends. A /clear goes on under a new session id with no session.start: the
605 // next records start a file of their own.
606 on('session.end', async ($, e, next) => {
607 await flush($, m, settings)
608 if (e.reason === 'clear') {
609 m.c = newCollector()
610 m.sid = undefined
611 m.savedAt = undefined
612 m.startedAt = (await nowOr($)) ?? 0
613 }
614 return next(e)
615 })
616
617 // /meter opens the pane, and closes it when it is open. Where no pane shows, the rows print as the command's output.
618 on('command.run', { command: COMMAND }, async ($, e) => {
619 const [sub, arg] = e.args.trim().split(/\s+/)
620 if (sub === 'report') {
621 return { text: await report($, m, settings, arg) }
622 }
623 if (sub === 'export') {
624 return { text: await exportTo($, m, settings, arg) }
625 }
626 if (sub !== undefined && sub !== '') {
627 return { text: 'Usage: /meter opens or closes the pane; /meter report [days] adds up the sessions of the last days (7); /meter export [days] writes them, anonymized, for the team report (30).' }
628 }
629 if ((await $.ui.panes()).some(p => p.id === PANE_ID)) {
630 await $.ui.close({ id: PANE_ID })
631 m.paneOpen = false
632 return {}
633 }
634 const opened = await $.ui.open({ id: PANE_ID, title: 'oxen-meter', closeOnEscape: true, rows: 16 })
635 m.paneOpen = opened.isPlaced
636 if (opened.isPlaced) {
637 return {}
638 }
639
640 return { text: rowsText(await rowsNow($, m, settings)) }
641 })
642
643 // Text alone, so the pane draws alike on a terminal and on the desktop.
644 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
645 if (e.requestId !== PANE_ID) {
646 return next(e)
647 }
648 const rows = await rowsNow($, m, settings)
649 const labelW = Math.max(...rows.map(r => r.label.length))
650 const { Box, Text } = $.ui.resolve(e)
651
652 return (
653 <Box flexDirection="column">
654 {rows.map(r => (
655 <Box key={r.label}>
656 <Text color={COLORS.label} bold>{`${r.label.padEnd(labelW)} `}</Text>
657 <Text color={r.color ?? COLORS.detail}>{r.value}</Text>
658 </Box>
659 ))}
660 </Box>
661 )
662 })
663}
664hooks/analyze.ts 468 lines1import { familyOf, weightsOf } from './provider'
2import { COMPACTION_TYPE as COMPACTION, MAIN } from './record'
3import type { Group, MeterRecord, StepRecord, Tool, Usage } from './record'
4import type { TtlSetting } from './settings'
5
6/**
7 * What the records say, as pure functions: the hit rate, the token equivalent, each step's cache judged warm or cold,
8 * the TTL of each role, the gap curve, the cold resumes, the handoffs, the anti-patterns and the quota used.
9 * `register.tsx`, `/meter report`, the CLI and `tools/meter/aggregate.mjs` all read the records through here.
10 *
11 * Sessions come from three tools. Claude Code's (the mod's own) have a TTL of 5m or 1h per role. Codex and Devin
12 * (imported by the CLI) have none the meter can rely on: Codex's cache fades over hours, Devin's server sets it and
13 * Devin pings to keep it. Their caches are read as a gap curve instead.
14 */
15
16export type Role = 'main' | 'subagent'
17export const roleOf = (thread: string): Role => (thread === MAIN ? 'main' : 'subagent')
18
19const MIN = 60000
20const READ_WEIGHT = 0.1
21/** Past the shorter TTL with some slack: a gap this long tells 5m from 1h. */
22const PAST_5M_MS = 5.5 * MIN
23const HOUR_MS = 60 * MIN
24const WARM_SHARE = 0.5 // a step read at least this share of the context before it: its cache was warm
25const MIN_CONTEXT = 2000 // a context smaller than this judges nothing
26const DEFAULT_CACHED_WEIGHT = 0.1
27/** Where the gap curve's buckets start, in minutes; the last has no end. */
28export const GAP_EDGES_MIN = [5, 10, 30, 60, 120, 360] as const
29/** Two quota readings further apart than this may hold someone else's use between them: their move is not counted. */
30const QUOTA_SPAN_MS = 60 * MIN
31/** A window whose reset moved later by more than this has started over. */
32const QUOTA_RESET_MS = 60 * MIN
33
34/** What one token written to the cache weighs against an uncached one, for a cache of `ttlMin`. */
35export const writeWeight = (ttlMin: number) => (ttlMin >= 60 ? 2 : 1.25)
36
37/** Cache read over the whole context the steps carried, from 0 to 1. */
38export function hitRate(u: Usage) {
39 const whole = u.in + u.cr + u.cw
40
41 return whole > 0 ? u.cr / whole : 0
42}
43
44/** The steps' cost in uncached input tokens: writes weigh `w`, reads `rw` (0.1 unless given), output `outputWeight`. */
45export const tokenEquivalent = (u: Usage, w: number, outputWeight: number, rw = READ_WEIGHT) => u.in + u.cw * w + u.cr * rw + u.out * outputWeight
46
47const isStep = (r: MeterRecord): r is StepRecord => r.k === 'step'
48
49/** A step judged against the step before it in its thread: whether it read the cache that step left. */
50export type Sample = { thread: string; role: Role; t0: number; gapMs: number; warm: boolean; cr: number; cw: number; prevCtx: number; model: string }
51
52/**
53 * Each step with the step before it in its thread, as the cache judges it: warm when it read at least half of that
54 * step's context, cold when it wrote or sent uncached at least half of it again (a provider that writes nothing to
55 * the cache sends it uncached). Left out, as no evidence of the TTL: a step after a compaction, a model change or a
56 * rewind (fewer messages), after a context too small, or with no usage; and a keepalive, which reads the cache by
57 * design, though the step after it is judged against it.
58 */
59export function cacheSamples(records: readonly MeterRecord[]): Sample[] {
60 const compactions = records.filter(r => r.k === 'compact')
61 const last: Record<string, StepRecord> = {}
62 const samples: Sample[] = []
63 for (const s of records.filter(isStep)) {
64 const prev = last[s.thread]
65 last[s.thread] = s
66 if (!prev || s.keepalive || s.noUsage || prev.noUsage || prev.ctx < MIN_CONTEXT || s.model !== prev.model || s.msgs < prev.msgs) {
67 continue
68 }
69 if (compactions.some(c => c.thread === s.thread && c.t1 >= prev.t0 && c.t0 <= s.t0)) {
70 continue
71 }
72 const warm = s.cr >= WARM_SHARE * prev.ctx
73 if (warm || s.in + s.cw >= WARM_SHARE * prev.ctx) {
74 samples.push({ thread: s.thread, role: roleOf(s.thread), t0: s.t0, gapMs: s.t0 - prev.t0, warm, cr: s.cr, cw: s.cw, prevCtx: prev.ctx, model: s.model })
75 }
76 }
77
78 return samples
79}
80
81export type TtlSource = 'setting' | 'measured' | 'inferred' | 'default'
82
83/** A role's TTL, where it came from, and the samples behind it. */
84export type TtlView = {
85 role: Role
86 min: number
87 source: TtlSource
88 warm: number
89 cold: number
90 longestWarmMs?: number
91 shortestColdMs?: number
92 measured: { '5m': number; '1h': number }
93}
94
95/**
96 * The TTL of `role`: the setting's when it names one; else the one Claude Code reported (an Agent call's cache writes,
97 * a model switch), by count; else the one the samples show (a cold step between 5.5 and 60 minutes says 5m, a warm one
98 * past 5.5 minutes says 1h, both or neither say nothing); else 1h for main and 5m for a subagent.
99 */
100export function inferTtl(samples: readonly Sample[], records: readonly MeterRecord[], role: Role, setting: TtlSetting): TtlView {
101 const own = samples.filter(s => s.role === role)
102 const warm = own.filter(s => s.warm)
103 const cold = own.filter(s => !s.warm)
104 const measured = { '5m': 0, '1h': 0 }
105 for (const r of records) {
106 if (r.k === 'ttl' && roleOf(r.thread) === role) {
107 measured[r.ttl] += 1
108 }
109 }
110 const longestWarmMs = warm.length > 0 ? Math.max(...warm.map(s => s.gapMs)) : undefined
111 const shortestColdMs = cold.length > 0 ? Math.min(...cold.map(s => s.gapMs)) : undefined
112 const view = { role, warm: warm.length, cold: cold.length, ...(longestWarmMs !== undefined ? { longestWarmMs } : {}), ...(shortestColdMs !== undefined ? { shortestColdMs } : {}), measured }
113 if (setting !== 'auto') {
114 return { ...view, min: setting === '5m' ? 5 : 60, source: 'setting' }
115 }
116 if (measured['5m'] + measured['1h'] > 0) {
117 return { ...view, min: measured['1h'] >= measured['5m'] ? 60 : 5, source: 'measured' }
118 }
119 const says5m = cold.some(s => s.gapMs > PAST_5M_MS && s.gapMs < HOUR_MS)
120 const says1h = warm.some(s => s.gapMs > PAST_5M_MS && s.gapMs <= HOUR_MS)
121 if (says5m !== says1h) {
122 return { ...view, min: says5m ? 5 : 60, source: 'inferred' }
123 }
124
125 return { ...view, min: role === 'main' ? 60 : 5, source: 'default' }
126}
127
128/**
129 * A cold resume: a step that wrote its whole context again after its cache's TTL, or a main session resumed so.
130 * `cw` is what went to the model again: written for Claude Code, written or sent uncached for another tool, which
131 * `tool` names (none for Claude Code).
132 */
133export type ColdResume = { t: number; thread: string; role: Role; tool?: Tool; agentType?: string; model: string; gapMs: number; cw: number; extra: number }
134
135/** How a session's steps are weighed and judged: its tool (Claude Code when none) and the Cached input weight. */
136export type ToolOptions = { tool?: Tool; cachedWeight?: number }
137
138/**
139 * The cold resumes: a step whose gap passed its role's TTL, that wrote at least `coldTokens` and did not read the
140 * context before it back; and a main session resumed after its cache likely expired, with that much context.
141 * Another tool has no TTL: its cold resume is a cold step five minutes or more after the one before, that sent at
142 * least `coldTokens` again. `extra` is what that cost beyond reading it, in token equivalents. The exclusions of
143 * `cacheSamples` apply.
144 */
145export function coldResumes(records: readonly MeterRecord[], ttlMin: Record<Role, number>, coldTokens: number, o: ToolOptions = {}): ColdResume[] {
146 const judged = new Map(cacheSamples(records).map(s => [`${s.thread}@${s.t0}`, s]))
147 const found: ColdResume[] = []
148 const tool = o.tool ?? 'claude'
149 for (const r of records) {
150 if (r.k === 'step' && tool !== 'claude') {
151 const s = judged.get(`${r.thread}@${r.t0}`)
152 const again = r.in + r.cw
153 if (s && !s.warm && s.gapMs >= GAP_EDGES_MIN[0] * MIN && again >= coldTokens) {
154 const { w, rw } = weightsOf(tool, r.model, 0, o.cachedWeight ?? DEFAULT_CACHED_WEIGHT)
155 found.push({ t: r.t0, thread: r.thread, role: s.role, tool, ...(r.agentType !== undefined ? { agentType: r.agentType } : {}), model: r.model, gapMs: s.gapMs, cw: again, extra: Math.round(r.in * (1 - rw) + r.cw * (w - rw)) })
156 }
157 } else if (r.k === 'step') {
158 const s = judged.get(`${r.thread}@${r.t0}`)
159 const ttl = ttlMin[roleOf(r.thread)]
160 if (s && !s.warm && s.gapMs > ttl * MIN && r.cw >= coldTokens) {
161 found.push({ t: r.t0, thread: r.thread, role: s.role, ...(r.agentType !== undefined ? { agentType: r.agentType } : {}), model: r.model, gapMs: s.gapMs, cw: r.cw, extra: Math.round(r.cw * (writeWeight(ttl) - READ_WEIGHT)) })
162 }
163 } else if (r.k === 'main-resume' && tool === 'claude' && r.expired && r.ctx >= coldTokens) {
164 found.push({ t: r.t, thread: MAIN, role: 'main', model: '', gapMs: r.idleS * 1000, cw: r.ctx, extra: Math.round(r.ctx * (writeWeight(ttlMin.main) - READ_WEIGHT)) })
165 }
166 }
167
168 return found
169}
170
171/**
172 * A handoff: work a thread gave to a subagent, to Codex, or back to an agent by a message that resumed it; how long it
173 * took to come back, and how long the thread took to hand off again.
174 */
175export type Handoff = { kind: 'agent' | 'codex' | 'resume'; thread: string; t0: number; bg: boolean; workMs?: number; reactMs?: number }
176
177/**
178 * The handoffs, in order. A call that waits returns when it ends; a background agent, or one a message resumed, when
179 * its next SubagentStop comes; a background Codex run has no return the meter sees. A message the user refused to
180 * send is none. The reaction runs from the return to the thread's next handoff.
181 */
182export function handoffsOf(records: readonly MeterRecord[]): Handoff[] {
183 const stops = new Map<string, number[]>()
184 for (const r of records) {
185 if (r.k === 'agent-stop') {
186 stops.set(r.thread, [...(stops.get(r.thread) ?? []), r.t])
187 }
188 }
189 const stopAfter = (agent: string | undefined, t: number) => (agent === undefined ? undefined : stops.get(agent)?.find(s => s >= t))
190 type Call = { kind: Handoff['kind']; thread: string; t0: number; bg: boolean; back: number | undefined }
191 const calls = records
192 .flatMap((r): Call[] => {
193 if (r.k === 'agent-call') {
194 return [{ kind: 'agent', thread: r.thread, t0: r.t0, bg: r.bg === true, back: r.bg ? stopAfter(r.agent, r.t0) : r.t1 }]
195 }
196 if (r.k === 'codex') {
197 return [{ kind: 'codex', thread: r.thread, t0: r.t0, bg: r.bg === true, back: r.bg ? undefined : r.t1 }]
198 }
199 if (r.k === 'send' && r.answer !== 'fresh') {
200 return [{ kind: 'resume', thread: r.thread, t0: r.t, bg: true, back: stopAfter(r.to, r.t) }]
201 }
202 return []
203 })
204 .sort((a, b) => a.t0 - b.t0)
205
206 return calls.map(c => {
207 const next = c.back === undefined ? undefined : calls.find(n => n.thread === c.thread && n.t0 >= c.back!)
208 return {
209 kind: c.kind,
210 thread: c.thread,
211 t0: c.t0,
212 bg: c.bg,
213 ...(c.back !== undefined ? { workMs: c.back - c.t0 } : {}),
214 ...(next !== undefined && c.back !== undefined ? { reactMs: next.t0 - c.back } : {}),
215 }
216 })
217}
218
219export type FlagKind = 'cold-resume' | 'expensive-short' | 'context-bloat' | 'big-first-prefix'
220export type Flag = { kind: FlagKind; thread: string; t: number; detail: string }
221
222const EXPENSIVE = /opus|fable/i
223const SHORT_STEPS = 5
224const SHORT_OUTPUT = 20000
225const BLOAT_CONTEXT = 150000
226const BLOAT_STEPS = 20
227const BIG_PREFIX = 40000
228
229/**
230 * The anti-patterns, by time then thread: each cold resume; a subagent on an expensive model for a short task (five
231 * steps or fewer, 20K output or less); a thread whose context stayed at 150K or more for 20 steps without a compaction;
232 * a thread whose first request carried 40K or more (tool schemas, MCP servers, a large prompt).
233 */
234export function flagsOf(records: readonly MeterRecord[], cold: readonly ColdResume[]): Flag[] {
235 const flags: Flag[] = cold.map(c => ({ kind: 'cold-resume', thread: c.thread, t: c.t, detail: `${Math.round(c.gapMs / MIN)}m idle, ${c.cw} written` }))
236 const byThread = new Map<string, StepRecord[]>()
237 for (const s of records.filter(isStep)) {
238 byThread.set(s.thread, [...(byThread.get(s.thread) ?? []), s])
239 }
240 const compactions = records.filter(r => r.k === 'compact')
241 for (const [thread, own] of byThread) {
242 const first = own[0]!
243 if (first.ctx >= BIG_PREFIX) {
244 flags.push({ kind: 'big-first-prefix', thread, t: first.t0, detail: `${first.ctx} in the first request` })
245 }
246 const output = own.reduce((n, s) => n + s.out, 0)
247 if (thread !== MAIN && own.length <= SHORT_STEPS && output <= SHORT_OUTPUT && EXPENSIVE.test(first.model)) {
248 flags.push({ kind: 'expensive-short', thread, t: first.t0, detail: `${own.length} steps, ${output} output on ${first.model}` })
249 }
250 let run = 0
251 for (let i = 0; i < own.length; i++) {
252 const s = own[i]!
253 const compacted = i > 0 && compactions.some(c => c.thread === thread && c.t1 >= own[i - 1]!.t0 && c.t0 <= s.t0)
254 run = s.ctx >= BLOAT_CONTEXT && !compacted ? run + 1 : 0
255 if (run === BLOAT_STEPS) {
256 flags.push({ kind: 'context-bloat', thread, t: s.t0, detail: `${BLOAT_STEPS} steps at ${BLOAT_CONTEXT}+ context` })
257 }
258 }
259 }
260
261 return flags.sort((a, b) => a.t - b.t || a.thread.localeCompare(b.thread) || a.kind.localeCompare(b.kind))
262}
263
264/** One bucket of the gap curve: the warm and cold samples whose gap was from `fromMin` to `toMin` minutes. */
265export type GapBucket = { fromMin: number; toMin?: number; warm: number; cold: number }
266
267/** How often the cache held by how long it sat: the samples of five minutes or more, by bucket. */
268export function gapCurve(samples: readonly Sample[]): GapBucket[] {
269 const buckets: GapBucket[] = GAP_EDGES_MIN.map((fromMin, i) => {
270 const toMin = GAP_EDGES_MIN[i + 1]
271 return { fromMin, ...(toMin !== undefined ? { toMin } : {}), warm: 0, cold: 0 }
272 })
273 for (const s of samples) {
274 const bucket = buckets.findLast(b => s.gapMs >= b.fromMin * MIN)
275 if (bucket) {
276 bucket[s.warm ? 'warm' : 'cold'] += 1
277 }
278 }
279
280 return buckets
281}
282
283/**
284 * How many points of each rate-limit window, by its length in minutes, moved while the records' sessions ran.
285 *
286 * Readings that name the same reset time (within an hour) are one window; two accounts, or two limits, reported side
287 * by side are two. A window's readings make spans, each broken by a gap of over an hour (someone else's use may sit in
288 * it); a span counts from its first reading to its highest, so a thread that reports a reading a little late never
289 * counts a point twice. A window that starts within an hour of the last one's reset time counts from zero. Readings
290 * with no reset time are one window, reset by a reading under half the last.
291 */
292export function quotaUsed(records: readonly MeterRecord[]): Record<number, number> {
293 type Span = { base: number; top: number; t: number; used: number }
294 const windows: { windowMin: number; resetsAt?: number; span: Span }[] = []
295 const used: Record<number, number> = {}
296 const add = (windowMin: number, s: Span) => {
297 used[windowMin] = (used[windowMin] ?? 0) + s.top - s.base
298 }
299 const near = (a?: number, b?: number) => (a === undefined || b === undefined ? a === b : Math.abs(a - b) <= QUOTA_RESET_MS)
300 for (const r of [...records].sort((a, b) => ('t' in a ? a.t : a.t0) - ('t' in b ? b.t : b.t0))) {
301 if (r.k !== 'quota') {
302 continue
303 }
304 used[r.windowMin] ??= 0
305 const open = (base: number): Span => ({ base, top: r.used, t: r.t, used: r.used })
306 const own = windows.find(w => w.windowMin === r.windowMin && near(w.resetsAt, r.resetsAt))
307 if (own === undefined) {
308 const ended = windows.some(w => w.windowMin === r.windowMin && w.resetsAt !== undefined && w.resetsAt <= r.t && r.t - w.span.t <= QUOTA_SPAN_MS)
309 windows.push({ windowMin: r.windowMin, ...(r.resetsAt !== undefined ? { resetsAt: r.resetsAt } : {}), span: open(ended ? 0 : r.used) })
310 continue
311 }
312 const s = own.span
313 const reset = r.resetsAt === undefined && r.used < s.used / 2
314 if (reset || r.t - s.t > QUOTA_SPAN_MS) {
315 add(r.windowMin, s)
316 own.span = open(reset && r.t - s.t <= QUOTA_SPAN_MS ? 0 : r.used)
317 continue
318 }
319 s.top = Math.max(s.top, r.used)
320 s.t = r.t
321 s.used = r.used
322 }
323 for (const w of windows) {
324 add(w.windowMin, w.span)
325 }
326
327 return used
328}
329
330/** One session as the summary reads it: its records, its exact totals by role, agent type and model, and its tool. */
331export type SessionData = { sid: string; startedAt: number; records: readonly MeterRecord[]; groups: Readonly<Record<string, Group>>; tool?: Tool }
332
333export type Totals = Usage & { steps: number }
334export type Weighed = Totals & { eq: number }
335export type SummaryOptions = { mainTtl: TtlSetting; subagentTtl: TtlSetting; coldTokens: number; outputWeight: number; cachedWeight?: number }
336
337
338export type Summary = {
339 sessions: number
340 totals: Totals
341 eq: number
342 byRole: Record<Role, Weighed>
343 byModel: Record<string, Weighed>
344 byAgentType: Record<string, Weighed>
345 byTool: Partial<Record<Tool, Weighed & { sessions: number }>>
346 ttl: Record<Role, TtlView>
347 gaps: Record<string, GapBucket[]> // by `tool|family`, for the tools with no TTL; only the curves with a sample
348 cold: ColdResume[]
349 handoffs: Handoff[]
350 flags: Flag[]
351 keepalive: { steps: number; eq: number }
352 compaction: { count: number; eq: number }
353 quota: Record<string, number> // points used, by `tool|window minutes`
354}
355
356const zero = (): Weighed => ({ steps: 0, in: 0, out: 0, cr: 0, cw: 0, eq: 0 })
357
358function addTo(into: Weighed, g: Group, eq: number) {
359 into.steps += g.steps
360 into.in += g.in
361 into.out += g.out
362 into.cr += g.cr
363 into.cw += g.cw
364 into.eq += eq
365}
366
367/** The TTL of each role over `records`, as `inferTtl` gives it. */
368export function ttlOf(records: readonly MeterRecord[], o: Pick<SummaryOptions, 'mainTtl' | 'subagentTtl'>): Record<Role, TtlView> {
369 const samples = cacheSamples(records)
370
371 return { main: inferTtl(samples, records, 'main', o.mainTtl), subagent: inferTtl(samples, records, 'subagent', o.subagentTtl) }
372}
373
374const toolOf = (s: SessionData): Tool => s.tool ?? 'claude'
375
376/**
377 * What a set of sessions adds up to: exact totals from their groups, the rest from their records. Each session's
378 * steps are judged on their own, so one session's last step never pairs with the next one's first. Claude Code's
379 * TTLs come from Claude Code's sessions alone; the other tools' samples make the gap curves.
380 */
381export function summarize(sessions: readonly SessionData[], o: SummaryOptions): Summary {
382 const cachedWeight = o.cachedWeight ?? DEFAULT_CACHED_WEIGHT
383 const claude = sessions.filter(s => toolOf(s) === 'claude')
384 const records = claude.flatMap(s => s.records)
385 const samples = claude.flatMap(s => cacheSamples(s.records))
386 const ttl = { main: inferTtl(samples, records, 'main', o.mainTtl), subagent: inferTtl(samples, records, 'subagent', o.subagentTtl) }
387 const ttlMin = { main: ttl.main.min, subagent: ttl.subagent.min }
388 const totals = { steps: 0, in: 0, out: 0, cr: 0, cw: 0 }
389 const byRole = { main: zero(), subagent: zero() }
390 const byModel: Record<string, Weighed> = {}
391 const byAgentType: Record<string, Weighed> = {}
392 const byTool: Summary['byTool'] = {}
393 const compaction = { count: 0, eq: 0 }
394 const keepalive = { steps: 0, eq: 0 }
395 for (const s of sessions) {
396 const tool = toolOf(s)
397 const own = (byTool[tool] ??= { ...zero(), sessions: 0 })
398 own.sessions += 1
399 for (const [key, g] of Object.entries(s.groups)) {
400 const [roleName, agentType = '', model = ''] = key.split('|')
401 const role: Role = roleName === 'main' ? 'main' : 'subagent'
402 const { w, rw } = weightsOf(tool, model, ttlMin[role], cachedWeight)
403 const eq = tokenEquivalent(g, w, o.outputWeight, rw)
404 totals.steps += g.steps
405 totals.in += g.in
406 totals.out += g.out
407 totals.cr += g.cr
408 totals.cw += g.cw
409 addTo(byRole[role], g, eq)
410 addTo(own, g, eq)
411 addTo((byModel[model] ??= zero()), g, eq)
412 addTo((byAgentType[agentType === COMPACTION ? COMPACTION : role === 'main' ? 'main' : agentType || 'subagent'] ??= zero()), g, eq)
413 compaction.eq += agentType === COMPACTION ? eq : 0
414 }
415 for (const r of s.records) {
416 if (r.k === 'compact') {
417 compaction.count += 1
418 } else if (r.k === 'step' && r.keepalive) {
419 const { w, rw } = weightsOf(tool, r.model, ttlMin[roleOf(r.thread)], cachedWeight)
420 keepalive.steps += 1
421 keepalive.eq += tokenEquivalent(r, w, o.outputWeight, rw)
422 }
423 }
424 }
425 const cold: ColdResume[] = []
426 const flags: Flag[] = []
427 const gapSamples: Record<string, Sample[]> = {}
428 for (const s of sessions) {
429 const own = coldResumes(s.records, ttlMin, o.coldTokens, { tool: toolOf(s), cachedWeight })
430 cold.push(...own)
431 flags.push(...flagsOf(s.records, own))
432 if (toolOf(s) !== 'claude') {
433 for (const sample of cacheSamples(s.records)) {
434 ;(gapSamples[`${toolOf(s)}|${familyOf(sample.model)}`] ??= []).push(sample)
435 }
436 }
437 }
438 const gaps = Object.fromEntries(
439 Object.entries(gapSamples)
440 .map(([key, own]) => [key, gapCurve(own)] as const)
441 .filter(([, curve]) => curve.some(b => b.warm + b.cold > 0)),
442 )
443 const quota: Record<string, number> = {}
444 for (const tool of Object.keys(byTool) as Tool[]) {
445 for (const [windowMin, points] of Object.entries(quotaUsed(sessions.filter(s => toolOf(s) === tool).flatMap(s => s.records)))) {
446 quota[`${tool}|${windowMin}`] = points
447 }
448 }
449
450 return {
451 sessions: sessions.length,
452 totals,
453 eq: byRole.main.eq + byRole.subagent.eq,
454 byRole,
455 byModel,
456 byAgentType,
457 byTool,
458 ttl,
459 gaps,
460 cold,
461 handoffs: sessions.flatMap(s => handoffsOf(s.records)),
462 flags,
463 keepalive,
464 compaction,
465 quota,
466 }
467}
468hooks/codex.ts 46 lines1/**
2 * What a Bash command is, by its text, for the records: a Codex call (a handoff to another model) or an outcome (a
3 * commit, a pull request). Only the label leaves this module; the command itself is never kept.
4 */
5
6/** A Codex call, named by its sub-command (`task`, `review`, `exec`, ...) or `run` for a bare prompt. */
7export type CodexCall = { sub: string }
8export type Outcome = 'commit' | 'pr'
9
10const ENV = /^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+)*/
11const COMPANION = /codex-companion\.mjs["']?\s+([a-z][a-z-]*)/
12const CLI = /^codex(?:\s+([a-z][a-z-]*))?(?:\s|$)/
13const COMMIT = /^git(?:\s+-[A-Za-z]\s+\S+|\s+--?[\w-]+(?:=\S+)?)*\s+commit(?:\s|$)/
14const PR = /^gh\s+pr\s+create(?:\s|$)/
15
16/** The command's parts, split where the shell starts another command, each without its leading env assignments. */
17const partsOf = (command: string) =>
18 command
19 .split(/&&|\|\||[;|\n]/)
20 .map(p => p.trim().replace(/^\(+\s*/, '').replace(ENV, ''))
21 .filter(p => p !== '')
22
23export function codexCallOf(command: string): CodexCall | undefined {
24 for (const part of partsOf(command)) {
25 const companion = part.match(COMPANION)
26 if (companion) {
27 return { sub: companion[1]! }
28 }
29 const cli = part.match(CLI)
30 if (cli) {
31 return { sub: cli[1] ?? 'run' }
32 }
33 }
34
35 return undefined
36}
37
38export function outcomeOf(command: string): Outcome | undefined {
39 const parts = partsOf(command)
40 if (parts.some(p => PR.test(p))) {
41 return 'pr'
42 }
43
44 return parts.some(p => COMMIT.test(p)) ? 'commit' : undefined
45}
46hooks/dataPath.ts 127 lines1import type { FsStat } from 'claude-code'
2
3/**
4 * Where the meter may write: its data folder's `sessions/<session id>.json`, `exports/oxen-meter-export-….json`, and
5 * `state/salt.json`, the salt the mod and the CLI share; the CLI (`tools/meter`) also its `state/codex|devin.json|lock`
6 * and its hooks' `state/codex|devin.guard.json`.
7 * Nothing else. Every write goes through `dataPathError` (the spelling) and `dataTargetError` (where the path leads on
8 * disk). The data folder's own ancestors may be links (a `.claude` folder kept in a dotfiles repo); from the data folder
9 * down, nothing may be.
10 */
11
12export type DataKind = 'sessions' | 'exports' | 'salt' | 'import'
13
14const SESSION_NAME = /^[A-Za-z0-9-]{8,64}\.json$/
15const EXPORT_NAME = /^oxen-meter-export-\d{8}(?:-[a-z0-9-]{1,32})?\.json$/
16const KINDS: Record<DataKind, { folder: string; name: RegExp }> = {
17 sessions: { folder: 'sessions', name: SESSION_NAME },
18 exports: { folder: 'exports', name: EXPORT_NAME },
19 salt: { folder: 'state', name: /^salt\.json$/ },
20 import: { folder: 'state', name: /^(codex|devin)\.(json|lock|guard\.json)$/ },
21}
22/** What the mod writes: its sessions, its exports, and the salt it shares with the CLI. */
23export const MOD_KINDS: readonly DataKind[] = ['sessions', 'exports', 'salt']
24/** What the CLI writes: the same, and the state of its imports with their locks. */
25export const CLI_KINDS: readonly DataKind[] = [...MOD_KINDS, 'import']
26
27const isAbsolute = (p: string) => p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p)
28const hasDotDot = (p: string) => p.split(/[\\/]/).includes('..')
29/** A path with `/` for every separator and no trailing one, so spellings from different calls compare. */
30const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
31
32/** The data folder: the setting's, when it is an absolute path with no `..`, `~` or `$`; else oxen-meter in the `.claude` folder the plugin is installed under. */
33export function dataRootOf(pluginRoot: string, setting: string): string | undefined {
34 const asked = setting.trim()
35 if (asked !== '' && isAbsolute(asked) && !hasDotDot(asked) && !/[$~]/.test(asked)) {
36 return asked.replace(/[\\/]+$/, '')
37 }
38 const at = pluginRoot.search(/[\\/]\.claude[\\/]/)
39
40 return at < 0 ? undefined : `${pluginRoot.slice(0, at + '/.claude'.length)}/oxen-meter`
41}
42
43export const sessionPath = (root: string, sid: string) => `${root}/sessions/${sid}.json`
44export const saltPath = (root: string) => `${root}/state/salt.json`
45export const statePath = (root: string, tool: 'codex' | 'devin', ext: 'json' | 'lock' | 'guard.json') => `${root}/state/${tool}.${ext}`
46
47/** The export file of `day` (YYYYMMDD), with the user's label made safe for a file name. */
48export function exportPath(root: string, day: string, label: string) {
49 const slug = label
50 .toLowerCase()
51 .replace(/[^a-z0-9]+/g, '-')
52 .replace(/^-+|-+$/g, '')
53 .slice(0, 32)
54 .replace(/-+$/, '')
55
56 return `${root}/exports/oxen-meter-export-${day}${slug ? `-${slug}` : ''}.json`
57}
58
59/** Why the meter must not write `path`, by its spelling alone, or undefined when it may: one of `kinds`, the mod's by default. */
60export function dataPathError(root: string, path: unknown, kinds: readonly DataKind[] = MOD_KINDS): string | undefined {
61 if (typeof path !== 'string' || path === '') {
62 return 'no path.'
63 }
64 if (!isAbsolute(root) || !isAbsolute(path) || hasDotDot(root) || hasDotDot(path)) {
65 return 'the path must be absolute, with no `..`.'
66 }
67 for (const kind of kinds) {
68 const folder = `${norm(root)}/${KINDS[kind].folder}/`
69 const p = norm(path)
70 if (p.startsWith(folder) && KINDS[kind].name.test(p.slice(folder.length))) {
71 return undefined
72 }
73 }
74
75 return `${path} is not a file the meter writes in ${root}.`
76}
77
78/** What is on disk along an allowed path: its data folder's parent, the data folder, the kind folder and the file, each undefined when missing. */
79export type TargetStats = { parent?: FsStat; root?: FsStat; dir?: FsStat; file?: FsStat }
80
81const landsOn = (s: FsStat) => `the path lands on ${s.realPath ?? 'a place that cannot be resolved'}.`
82
83/**
84 * Why the meter must not write `path` (already through `dataPathError`) where it leads, or undefined when it may.
85 * Missing folders may be created, the data folder only inside a folder that exists.
86 */
87export function dataTargetError(path: string, s: TargetStats): string | undefined {
88 const parts = norm(path).split('/')
89 const name = parts.at(-1) ?? ''
90 const kind = parts.at(-2) ?? ''
91 if (s.root === undefined) {
92 return s.parent?.kind === 'dir' ? undefined : "the data folder's parent must already be a folder."
93 }
94 if (s.root.isLink) {
95 return 'the data folder is a symbolic link.'
96 }
97 if (s.root.kind !== 'dir' || s.root.realPath === undefined) {
98 return s.root.kind !== 'dir' ? 'the data folder is not a folder.' : landsOn(s.root)
99 }
100 if (s.dir === undefined) {
101 return undefined
102 }
103 if (s.dir.isLink) {
104 return `the ${kind} folder is a symbolic link.`
105 }
106 if (s.dir.kind !== 'dir') {
107 return `the ${kind} folder is not a folder.`
108 }
109 if (s.dir.realPath === undefined || norm(s.dir.realPath) !== `${norm(s.root.realPath)}/${kind}`) {
110 return landsOn(s.dir)
111 }
112 if (s.file === undefined) {
113 return undefined
114 }
115 if (s.file.isLink) {
116 return 'the file is a symbolic link.'
117 }
118 if (s.file.kind !== 'file') {
119 return 'the path is not a plain file.'
120 }
121 if (s.file.realPath === undefined || norm(s.file.realPath) !== `${norm(s.dir.realPath)}/${name}`) {
122 return landsOn(s.file)
123 }
124
125 return undefined
126}
127hooks/exportFile.ts 123 lines1import { summarize } from './analyze'
2import type { Summary, SummaryOptions } from './analyze'
3import { MAIN } from './record'
4import type { Group, MeterRecord, Tool } from './record'
5import type { SessionFile } from './sessionFile'
6import type { TimingSummary } from './timing'
7
8/**
9 * `/meter export`: the sessions of the last days in one file a user sends to whoever builds the team report
10 * (`tools/meter/aggregate.mjs`). Each session goes by a hash of its id; agent and turn ids become `a1`, `t1` within it;
11 * MCP tools become `mcp` (a server's name can say what a team uses); times count from the session's start, which
12 * keeps only its day. The project is as the session file has it: a salted hash unless the user turned that off.
13 */
14
15export const EXPORT_KIND = 'oxen-meter-export'
16/** Under `$.fs.write`'s 4 MiB, with room. */
17export const MAX_EXPORT_BYTES = 3.5 * 1024 * 1024
18
19export type ExportSession = {
20 id: string
21 tool?: Tool // Codex or Devin; none for Claude Code
22 project: string
23 startedDay: string // YYYY-MM-DD, UTC
24 costUsd?: number
25 dropped: number
26 groups: Record<string, Group>
27 timings: Record<string, TimingSummary>
28 records?: MeterRecord[]
29 recordsLeftOut?: true // left out to keep the file under its size limit; the groups still count them
30}
31
32export type ExportFile = {
33 kind: typeof EXPORT_KIND
34 v: 1
35 label: string // the user's label, or anonymous
36 version: string
37 day: string // the day it was made, YYYY-MM-DD
38 days: number
39 settings: SummaryOptions
40 summary: Summary
41 sessions: ExportSession[]
42}
43
44const isMcp = (tool: string) => tool.startsWith('mcp__')
45
46/** `records` of one session with its agents as `a1`, `a2`, its turns as `t1`, `t2`, MCP tools as `mcp`, and times from `startedAt`. */
47export function anonymizeRecords(records: readonly MeterRecord[], startedAt: number): MeterRecord[] {
48 const agents = new Map<string, string>()
49 const turns = new Map<string, string>()
50 const agent = (id: string) => {
51 if (id === MAIN) {
52 return MAIN
53 }
54 if (!agents.has(id)) {
55 agents.set(id, `a${agents.size + 1}`)
56 }
57 return agents.get(id)!
58 }
59 const turn = (id: string) => {
60 if (!turns.has(id)) {
61 turns.set(id, `t${turns.size + 1}`)
62 }
63 return turns.get(id)!
64 }
65 const at = (t: number) => t - startedAt
66
67 return records.map((r): MeterRecord => {
68 switch (r.k) {
69 case 'step':
70 return { ...r, thread: agent(r.thread), turn: turn(r.turn), t0: at(r.t0), t1: at(r.t1), tools: r.tools.map(t => (isMcp(t) ? 'mcp' : t)) }
71 case 'agent-call':
72 return { ...r, thread: agent(r.thread), ...(r.agent !== undefined ? { agent: agent(r.agent) } : {}), t0: at(r.t0), t1: at(r.t1) }
73 case 'codex':
74 case 'compact':
75 return { ...r, thread: agent(r.thread), t0: at(r.t0), t1: at(r.t1) }
76 case 'send':
77 return { ...r, thread: agent(r.thread), to: agent(r.to), t: at(r.t) }
78 default:
79 return { ...r, thread: agent(r.thread), t: at(r.t) }
80 }
81 })
82}
83
84const dayOf = (t: number) => new Date(t).toISOString().slice(0, 10)
85
86/** The export of `sessions`, each with the id it goes by, oldest first, and their summary. */
87export function exportOf(sessions: readonly { file: SessionFile; id: string }[], o: { label: string; version: string; day: string; days: number; settings: SummaryOptions }): ExportFile {
88 const out = [...sessions]
89 .sort((a, b) => a.file.startedAt - b.file.startedAt)
90 .map(({ file, id }): ExportSession => ({
91 id,
92 ...(file.tool !== undefined && file.tool !== 'claude' ? { tool: file.tool } : {}),
93 project: file.project,
94 startedDay: dayOf(file.startedAt),
95 ...(file.costUsd !== undefined ? { costUsd: file.costUsd } : {}),
96 dropped: file.dropped,
97 groups: file.groups,
98 timings: file.timings,
99 records: anonymizeRecords(file.records, file.startedAt),
100 }))
101 const summary = summarize(
102 out.map(s => ({ sid: s.id, startedAt: 0, records: s.records ?? [], groups: s.groups, ...(s.tool !== undefined ? { tool: s.tool } : {}) })),
103 o.settings,
104 )
105
106 return { kind: EXPORT_KIND, v: 1, label: o.label || 'anonymous', version: o.version, day: o.day, days: o.days, settings: o.settings, summary, sessions: out }
107}
108
109const bytes = (text: string) => new TextEncoder().encode(text).length
110
111/** The export as text, at most `maxBytes`: the records of its oldest sessions are left out first. */
112export function exportText(e: ExportFile, maxBytes = MAX_EXPORT_BYTES): string {
113 const sessions = [...e.sessions]
114 for (let i = 0; ; i++) {
115 const text = JSON.stringify({ ...e, sessions })
116 if (bytes(text) <= maxBytes || i >= sessions.length) {
117 return text
118 }
119 const { records: _left, ...rest } = sessions[i]!
120 sessions[i] = { ...rest, recordsLeftOut: true }
121 }
122}
123hooks/project.ts 25 lines1/**
2 * Names the records keep short and unguessable: a session's id in an export, and the project a session ran in, by
3 * default a hash of its folder's name with a salt of the user's own; the name itself when the user turns hashing off.
4 */
5
6const HEX_LENGTH = 12
7
8const baseName = (path: string) => path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
9
10/** The first 12 hex digits of SHA-256 over `salt` and `text`. */
11export async function shortHash(salt: string, text: string): Promise<string> {
12 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`${salt}:${text}`))
13
14 return [...new Uint8Array(digest)]
15 .map(b => b.toString(16).padStart(2, '0'))
16 .join('')
17 .slice(0, HEX_LENGTH)
18}
19
20export async function projectLabel(root: string, salt: string, hash: boolean): Promise<string> {
21 const name = baseName(root)
22
23 return hash ? shortHash(salt, name) : name
24}
25hooks/record.ts 254 lines1import type { Outcome } from './codex'
2import { newTimings } from './timing'
3import type { Timings } from './timing'
4
5/**
6 * The meter's records: one per model step, and one per event around them (a subagent's start and stop, an Agent or
7 * Codex handoff, a compaction, an outcome). Metadata only: token counts, times, model and tool names, agent types.
8 * No prompt, output, code, path or command is ever kept. The collector holds them in memory; the hooks add to it in
9 * place and return, and a timer writes it out.
10 */
11
12/** The main conversation's thread; every other thread is a subagent's, by its agent id. */
13export const MAIN = 'main'
14/** The tools a session file can come from: Claude Code, where the mod records it, or Codex or Devin, imported by the CLI. */
15export const TOOLS = ['claude', 'codex', 'devin'] as const
16export type Tool = (typeof TOOLS)[number]
17export const MAX_RECORDS = 20000
18/** How many of the oldest steps go at once past MAX_RECORDS, so dropping stays rare. */
19export const DROP_CHUNK = 1000
20
21export type Usage = { in: number; out: number; cr: number; cw: number }
22
23export type StepRecord = Usage & {
24 k: 'step'
25 t0: number // when the request was about to be sent: when the thread's cache was read
26 t1: number // when the response was whole
27 turn: string
28 idx: number
29 thread: string
30 agentType?: string
31 model: string
32 effort?: string | number
33 ctx: number // in + cr + cw: the context the request carried
34 gapMs?: number // t0 minus the thread's previous step's t0
35 msgs: number
36 tools: string[]
37 stop?: string
38 noUsage?: true // no response, or one with no usage
39 coldStart?: true // the meter judged the thread's cache cold before the request went
40 keepalive?: true // a request the tool sent only to keep the cache warm (Devin)
41}
42
43export type EventRecord =
44 | { k: 'agent-start' | 'agent-stop'; t: number; thread: string; agentType: string }
45 | { k: 'agent-call'; t0: number; t1: number; thread: string; agent?: string; agentType?: string; model?: string; status: string; bg?: true; tokens?: number; cw5m?: number; cw1h?: number }
46 | { k: 'codex'; t0: number; t1: number; thread: string; sub: string; bg?: true; failed?: true }
47 | { k: 'outcome'; t: number; thread: string; outcome: Outcome }
48 | { k: 'compact'; t0: number; t1: number; thread: string; trigger: string; before?: number; after?: number; usage?: Usage; stepsSeen: number }
49 | { k: 'main-resume'; t: number; thread: string; idleS: number; ctx: number; expired: boolean }
50 | { k: 'ttl'; t: number; thread: string; ttl: '5m' | '1h'; source: 'model-switch' | 'agent-call' }
51 | { k: 'send'; t: number; thread: string; to: string; risk: boolean; gapMs?: number; ctx?: number; mode: 'off' | 'warn' | 'ask'; answer?: 'resume' | 'fresh' | 'unanswered' }
52 | ({ k: 'quota'; t: number; thread: string } & QuotaReading)
53
54export type MeterRecord = StepRecord | EventRecord
55
56/** Every kind of record this version writes; a file's record of another kind is left out when it is read. */
57export const RECORD_KINDS: ReadonlySet<string> = new Set(['step', 'agent-start', 'agent-stop', 'agent-call', 'codex', 'outcome', 'compact', 'main-resume', 'ttl', 'send', 'quota'])
58
59/** How much of a rate-limit window is used, 0 to 100, as the tool reported it; the window by its length in minutes. */
60export type QuotaReading = { windowMin: number; used: number; resetsAt?: number }
61
62/** Where a thread stands after its last step. */
63export type ThreadState = { lastT0: number; lastT1: number; lastCtx: number; lastModel: string; lastMsgs: number; steps: number; agentType?: string }
64
65/** Exact totals of one role, agent type and model, kept apart from the records so dropping records loses nothing. */
66export type Group = Usage & { steps: number }
67
68export type Collector = {
69 records: MeterRecord[]
70 dropped: number
71 threads: Record<string, ThreadState>
72 groups: Record<string, Group>
73 agentTypes: Record<string, string> // agent id to its type
74 names: Record<string, string> // the name SendMessage addresses an agent by, to its id
75 timings: Timings
76 dirty: boolean
77 compacting: number // compactions running now
78 stepsInCompaction: number // steps that started while one ran
79}
80
81export const newCollector = (): Collector => ({ records: [], dropped: 0, threads: {}, groups: {}, agentTypes: {}, names: {}, timings: newTimings(), dirty: false, compacting: 0, stepsInCompaction: 0 })
82
83export const threadOf = (agentId: string | undefined) => agentId ?? MAIN
84
85/** How long since `thread`'s last step started, at `t0`; undefined before its first step. */
86export function gapOf(c: Collector, thread: string, t0: number) {
87 const last = c.threads[thread]
88
89 return last === undefined ? undefined : t0 - last.lastT0
90}
91
92type StepInput = { turnId: string; index: number; model: string; effort?: string | number; messageCount: number; agentId?: string }
93type StepResult = { toolUses: readonly { name: string }[]; serverToolUses?: readonly { name: string }[]; stopReason: string | null; usage: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number; model: string } | null }
94
95/** The record of one model step: `input` as `turn.step` received it, `result` as it resolved (undefined if it never did). */
96export function stepRecordOf(input: StepInput, result: StepResult | undefined, t0: number, t1: number, c: Collector): StepRecord {
97 const thread = threadOf(input.agentId)
98 const u = result?.usage ?? undefined
99 const usage = { in: u?.input_tokens ?? 0, out: u?.output_tokens ?? 0, cr: u?.cache_read_input_tokens ?? 0, cw: u?.cache_creation_input_tokens ?? 0 }
100 const agentType = thread === MAIN ? undefined : c.agentTypes[thread]
101 const gapMs = gapOf(c, thread, t0)
102 const tools = [...(result?.toolUses ?? []), ...(result?.serverToolUses ?? [])].map(t => t.name)
103
104 return {
105 k: 'step',
106 t0,
107 t1,
108 turn: input.turnId,
109 idx: input.index,
110 thread,
111 ...(agentType !== undefined ? { agentType } : {}),
112 model: u?.model || input.model,
113 ...(input.effort !== undefined ? { effort: input.effort } : {}),
114 ...usage,
115 ctx: usage.in + usage.cr + usage.cw,
116 ...(gapMs !== undefined ? { gapMs } : {}),
117 msgs: input.messageCount,
118 tools,
119 ...(result?.stopReason ? { stop: result.stopReason } : {}),
120 ...(u ? {} : { noUsage: true as const }),
121 }
122}
123
124export const groupKey = (r: StepRecord) => `${r.thread === MAIN ? 'main' : 'subagent'}|${r.agentType ?? ''}|${r.model}`
125
126function cap(c: Collector) {
127 if (c.records.length <= MAX_RECORDS) {
128 return
129 }
130 let left = DROP_CHUNK
131 c.records = c.records.filter(r => !(r.k === 'step' && left-- > 0))
132 c.dropped += DROP_CHUNK - Math.max(0, left)
133}
134
135/** Moves `r`'s thread on to it, in place. */
136export function noteThread(c: Collector, r: StepRecord) {
137 const steps = (c.threads[r.thread]?.steps ?? 0) + 1
138 c.threads[r.thread] = { lastT0: r.t0, lastT1: r.t1, lastCtx: r.ctx, lastModel: r.model, lastMsgs: r.msgs, steps, ...(r.agentType !== undefined ? { agentType: r.agentType } : {}) }
139}
140
141/** Adds a step, in place: its record, its thread's state, and its group's totals. */
142export function addStep(c: Collector, r: StepRecord) {
143 c.records.push(r)
144 const g = (c.groups[groupKey(r)] ??= { steps: 0, in: 0, out: 0, cr: 0, cw: 0 })
145 g.steps += 1
146 g.in += r.in
147 g.out += r.out
148 g.cr += r.cr
149 g.cw += r.cw
150 noteThread(c, r)
151 if (c.compacting > 0) {
152 c.stepsInCompaction += 1
153 }
154 c.dirty = true
155 cap(c)
156}
157
158/** The agent type of a compaction's own request, which Codex and Devin report apart from the steps. */
159export const COMPACTION_TYPE = 'compaction'
160
161/** Adds a compaction's request to the session's totals, in place, under the agent type `compaction` of its thread's role. */
162export function addCompaction(c: Collector, thread: string, model: string, u: Usage) {
163 const g = (c.groups[`${thread === MAIN ? 'main' : 'subagent'}|${COMPACTION_TYPE}|${model}`] ??= { steps: 0, in: 0, out: 0, cr: 0, cw: 0 })
164 g.steps += 1
165 g.in += u.in
166 g.out += u.out
167 g.cr += u.cr
168 g.cw += u.cw
169 c.dirty = true
170}
171
172/** Notes the agent type an event names, in place: a subagent's start, or the Agent call that started it. */
173export function noteAgentType(c: Collector, r: EventRecord) {
174 if (r.k === 'agent-start') {
175 c.agentTypes[r.thread] = r.agentType
176 } else if (r.k === 'agent-call' && r.agent !== undefined && r.agentType !== undefined) {
177 c.agentTypes[r.agent] = r.agentType
178 }
179}
180
181/** Adds an event, in place. A subagent's start names its type for its later steps. */
182export function addEvent(c: Collector, r: EventRecord) {
183 c.records.push(r)
184 noteAgentType(c, r)
185 c.dirty = true
186 cap(c)
187}
188
189const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
190const str = (v: unknown) => (typeof v === 'string' && v !== '' ? v : undefined)
191
192/**
193 * The records of an Agent call that `thread` made, from its result: the agent it started, its type and model, and
194 * for a finished one its tokens and the TTL its cache writes used, which is the one place a TTL is measured.
195 * The prompt and the report are never read.
196 */
197export function agentCallOf(result: unknown, t0: number, t1: number, thread: string): EventRecord[] {
198 if (result === undefined || result === null) {
199 return [{ k: 'agent-call', t0, t1, thread, status: 'failed' }]
200 }
201 if (typeof result !== 'object') {
202 return [{ k: 'agent-call', t0, t1, thread, status: 'unknown' }]
203 }
204 const r = result as Record<string, unknown>
205 const status = str(r.status) ?? 'unknown'
206 const agent = str(r.agentId) ?? str(r.agent_id)
207 const agentType = str(r.agentType) ?? str(r.agent_type)
208 const model = str(r.resolvedModel)
209 const cache = ((r.usage as Record<string, unknown> | undefined)?.cache_creation ?? undefined) as Record<string, unknown> | undefined
210 const cw5m = num(cache?.ephemeral_5m_input_tokens)
211 const cw1h = num(cache?.ephemeral_1h_input_tokens)
212 const tokens = num(r.totalTokens)
213 const call: EventRecord = {
214 k: 'agent-call',
215 t0,
216 t1,
217 thread,
218 ...(agent !== undefined ? { agent } : {}),
219 ...(agentType !== undefined ? { agentType } : {}),
220 ...(model !== undefined ? { model } : {}),
221 status,
222 ...(status !== 'completed' && status !== 'unknown' ? { bg: true as const } : {}),
223 ...(tokens !== undefined ? { tokens } : {}),
224 ...(cw5m !== undefined ? { cw5m } : {}),
225 ...(cw1h !== undefined ? { cw1h } : {}),
226 }
227 const ttl = (cw1h ?? 0) > 0 ? '1h' : (cw5m ?? 0) > 0 ? '5m' : undefined
228 if (ttl === undefined || agent === undefined) {
229 return [call]
230 }
231
232 return [call, { k: 'ttl', t: t1, thread: agent, ttl, source: 'agent-call' }]
233}
234
235const WINDOWS: Record<string, number> = { five_hour: 300, seven_day: 10080 }
236
237/** Claude Code's rate limits as quota readings: its 5-hour and 7-day windows; any other limit is left out. */
238export function claudeQuota(limits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]): QuotaReading[] {
239 return limits.flatMap(l => {
240 const windowMin = WINDOWS[l.kind]
241 const resetsAt = l.resetsAt === undefined ? Number.NaN : Date.parse(l.resetsAt)
242 return windowMin === undefined || !Number.isFinite(l.percentUsed) ? [] : [{ windowMin, used: l.percentUsed, ...(Number.isFinite(resetsAt) ? { resetsAt } : {}) }]
243 })
244}
245
246/** The quota records for `readings` at `t`: one for each window whose reading moved since `records` last had it. */
247export function quotaRecordsOf(records: readonly MeterRecord[], t: number, thread: string, readings: readonly QuotaReading[]): EventRecord[] {
248 return readings.flatMap(q => {
249 const last = records.findLast(r => r.k === 'quota' && r.windowMin === q.windowMin)
250 const same = last?.k === 'quota' && last.used === q.used && last.resetsAt === q.resetsAt
251 return same ? [] : [{ k: 'quota' as const, t, thread, ...q }]
252 })
253}
254hooks/report.ts 298 lines1import { hitRate } from './analyze'
2import type { ColdResume, GapBucket, Handoff, Summary, TtlView } from './analyze'
3import { MAIN, TOOLS } from './record'
4import type { Collector } from './record'
5import { timingRows } from './timing'
6
7/** One row of the pane: a label, its value, and a color when the row has a state (warm, cooling, cold). */
8export type Row = { label: string; value: string; color?: string }
9
10/** An agent as `$.agent.list()` gives it, as much as the pane needs. */
11export type LiveAgent = { id: string; status: string }
12
13/** Mid tones, so each reads on a dark terminal and a light one. */
14export const COLORS = { warm: '#3fa66b', cooling: '#c98a12', cold: '#d1495b', label: '#5aa9ff', detail: '#8b93b8' }
15
16const ENDED = new Set(['completed', 'failed', 'killed'])
17const COOLING_SHARE = 0.2 // the cache counts as cooling in the last fifth of its TTL
18const MIN = 60000
19
20export function fmtTokens(n: number) {
21 if (n >= 1e7) {
22 return `${Math.round(n / 1e6)}M`
23 }
24 if (n >= 1e6) {
25 return `${(n / 1e6).toFixed(1)}M`
26 }
27 if (n >= 1e4) {
28 return `${Math.round(n / 1e3)}K`
29 }
30
31 return n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(Math.round(n))
32}
33
34export function fmtMin(min: number) {
35 return min < 60 ? `${min}m` : `${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}m`
36}
37
38/** A model id without its `claude-` prefix and its date. */
39export const shortModel = (model: string) => model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
40
41const pct = (part: number, whole: number) => (whole > 0 ? Math.round((part / whole) * 100) : 0)
42
43/** A subagent's label: its type, or `agent`, and the last four letters or digits of its id. */
44export const agentLabel = (id: string, type: string | undefined) => `${type ?? 'agent'} ${id.replace(/[^A-Za-z0-9]/g, '').slice(-4)}`
45
46/** A thread's label: `main`, or its agent's. */
47export const threadLabel = (thread: string, c: Collector) => (thread === MAIN ? 'main' : agentLabel(thread, c.agentTypes[thread]))
48
49/** A thread's row: its model and context, how long since its cache was read, and how long the cache has left. */
50function threadRow(label: string, c: Collector, id: string, now: number, ttlMin: number): Row {
51 const t = c.threads[id]
52 if (t === undefined) {
53 return { label, value: 'no step yet' }
54 }
55 const idle = now - t.lastT0
56 const left = ttlMin * MIN - idle
57 const state = left <= 0 ? 'cold' : left <= ttlMin * MIN * COOLING_SHARE ? 'cooling' : 'warm'
58 const cache = state === 'cold' ? 'cold' : `${state} ~${fmtMin(Math.ceil(left / MIN))}`
59
60 return { label, value: `${shortModel(t.lastModel)} · ctx ${fmtTokens(t.lastCtx)} · read ${fmtMin(Math.floor(idle / MIN))} ago · ${cache}`, color: COLORS[state] }
61}
62
63/** What the pane adds once the meter has it: the session's summary, and where its file goes. */
64export type PaneExtras = { summary?: Summary; files?: string }
65
66/**
67 * The pane's rows at `now`: the cache over every step, the steps by role, the token equivalent, TTLs and cold resumes,
68 * each live thread, where the file goes, the handoffs and outcomes, and the meter's own hook times.
69 */
70export function paneRows(c: Collector, live: readonly LiveAgent[], now: number, ttlMin: { main: number; subagent: number }, more: PaneExtras = {}): Row[] {
71 const groups = Object.entries(c.groups)
72 if (groups.length === 0) {
73 return [{ label: 'Cache', value: 'no model step yet' }]
74 }
75 const sum = { steps: 0, in: 0, out: 0, cr: 0, cw: 0, main: 0, subagent: 0 }
76 for (const [key, g] of groups) {
77 sum.steps += g.steps
78 sum.in += g.in
79 sum.out += g.out
80 sum.cr += g.cr
81 sum.cw += g.cw
82 sum[key.startsWith('main|') ? 'main' : 'subagent'] += g.steps
83 }
84 const rows: Row[] = [
85 { label: 'Cache', value: `hit ${pct(sum.cr, sum.cr + sum.cw + sum.in)}% · read ${fmtTokens(sum.cr)} · written ${fmtTokens(sum.cw)} · uncached ${fmtTokens(sum.in)} · output ${fmtTokens(sum.out)}` },
86 { label: 'Steps', value: [sum.main > 0 ? `${sum.main} main` : '', sum.subagent > 0 ? `${sum.subagent} subagent` : ''].filter(Boolean).join(' · ') },
87 ]
88 const summary = more.summary
89 if (summary) {
90 rows.push({ label: 'Token eq.', value: eqText(summary) }, { label: 'TTL', value: ttlText(summary.ttl) })
91 if (summary.cold.length > 0) {
92 rows.push({ label: 'Cold', value: `${plural(summary.cold.length, 'cold resume')}: ${fmtTokens(summary.cold.reduce((n, r) => n + r.extra, 0))} eq beyond a read`, color: COLORS.cold })
93 }
94 }
95 if (c.threads[MAIN]) {
96 rows.push(threadRow('main', c, MAIN, now, ttlMin.main))
97 }
98 for (const a of live) {
99 if (!ENDED.has(a.status)) {
100 rows.push(threadRow(agentLabel(a.id, c.agentTypes[a.id]), c, a.id, now, ttlMin.subagent))
101 }
102 }
103 if (more.files !== undefined) {
104 rows.push({ label: 'Files', value: more.files })
105 }
106
107 return [...rows, ...eventRows(c), ...timingRows(c.timings).map(t => ({ label: t.hook, value: t.text }))]
108}
109
110const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
111const notes = (parts: string[]) => (parts.length > 0 ? ` (${parts.join(', ')})` : '')
112
113/** The rows for what happened around the steps: handoffs to subagents and Codex, outcomes, compactions. */
114function eventRows(c: Collector): Row[] {
115 const n = { agent: 0, agentBg: 0, codex: 0, codexBg: 0, codexFailed: 0, commit: 0, pr: 0 }
116 const compactions: string[] = []
117 for (const r of c.records) {
118 if (r.k === 'agent-call') {
119 n.agent += 1
120 n.agentBg += r.bg ? 1 : 0
121 } else if (r.k === 'codex') {
122 n.codex += 1
123 n.codexBg += r.bg ? 1 : 0
124 n.codexFailed += r.failed ? 1 : 0
125 } else if (r.k === 'outcome') {
126 n[r.outcome] += 1
127 } else if (r.k === 'compact') {
128 compactions.push(r.before !== undefined && r.after !== undefined ? `${fmtTokens(r.before)} → ${fmtTokens(r.after)}` : r.trigger)
129 }
130 }
131 const rows: Row[] = []
132 const handoffs = [
133 n.agent > 0 ? `${n.agent} Agent${notes(n.agentBg > 0 ? [`${n.agentBg} background`] : [])}` : '',
134 n.codex > 0 ? `${n.codex} Codex${notes([...(n.codexBg > 0 ? [`${n.codexBg} background`] : []), ...(n.codexFailed > 0 ? [`${n.codexFailed} failed`] : [])])}` : '',
135 ].filter(Boolean)
136 if (handoffs.length > 0) {
137 rows.push({ label: 'Handoffs', value: handoffs.join(' · ') })
138 }
139 if (n.commit + n.pr > 0) {
140 rows.push({ label: 'Outcomes', value: [n.commit > 0 ? plural(n.commit, 'commit') : '', n.pr > 0 ? `${n.pr} PR${n.pr === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ') })
141 }
142 if (compactions.length > 0) {
143 rows.push({ label: 'Compactions', value: `${compactions.length}: ${compactions.slice(-3).join(', ')}` })
144 }
145
146 return rows
147}
148
149/** The rows as plain text, one `label: value` a line, for where no pane shows. */
150export const rowsText = (rows: readonly Row[]) => rows.map(r => `${r.label}: ${r.value}`).join('\n')
151
152const eqText = (s: Summary) => `${fmtTokens(s.eq)}: main ${fmtTokens(s.byRole.main.eq)} · subagent ${fmtTokens(s.byRole.subagent.eq)}`
153
154/** How long `ms` is, in seconds under a minute, else as fmtMin does. */
155export const fmtDur = (ms: number) => (ms < 60000 ? `${Math.round(ms / 1000)}s` : fmtMin(Math.round(ms / 60000)))
156
157function ttlOne(t: TtlView) {
158 const ttl = t.min >= 60 ? '1h' : '5m'
159 if (t.source === 'setting') {
160 return `${t.role} ${ttl} (setting)`
161 }
162 if (t.source === 'measured') {
163 return `${t.role} ${ttl} (measured: ${t.measured['5m'] + t.measured['1h']})`
164 }
165 const seen = [
166 t.warm > 0 ? `${t.warm} warm up to ${fmtDur(t.longestWarmMs ?? 0)}` : '',
167 t.cold > 0 ? `${t.cold} cold from ${fmtDur(t.shortestColdMs ?? 0)}` : '',
168 ].filter(Boolean)
169
170 return `${t.role} ${ttl} (${t.source}: ${seen.length > 0 ? seen.join(', ') : 'no samples'})`
171}
172
173const ttlText = (ttl: Summary['ttl']) => `${ttlOne(ttl.main)} · ${ttlOne(ttl.subagent)}`
174
175const median = (values: number[]) => {
176 const sorted = [...values].sort((a, b) => a - b)
177 const mid = Math.floor(sorted.length / 2)
178 return sorted.length === 0 ? undefined : sorted.length % 2 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2
179}
180
181const pad = (label: string) => label.padEnd(10)
182const INDENT = ' '.repeat(11)
183
184/** When `t` was, in UTC: the mod has no time zone to read. */
185const utc = (t: number) => `${new Date(t).toISOString().slice(0, 16).replace('T', ' ')} UTC`
186
187function coldLine(r: ColdResume) {
188 const who = r.role === 'main' ? (r.model === '' ? 'main (resumed)' : `main ${shortModel(r.model)}`) : `${agentLabel(r.thread, r.agentType)} ${shortModel(r.model)}`
189
190 return `${utc(r.t)} ${r.tool !== undefined ? `${r.tool} ` : ''}${who} idle ${fmtDur(r.gapMs)} ${r.tool !== undefined ? 'sent again' : 'wrote'} ${fmtTokens(r.cw)} +${fmtTokens(r.extra)} eq`
191}
192
193/** A gap bucket's span, in minutes up to an hour and in hours past it: `5–10m`, `30–60m`, `1–2h`, `6h+`. */
194function spanOf(b: GapBucket) {
195 if (b.toMin === undefined) {
196 return b.fromMin >= 60 ? `${b.fromMin / 60}h+` : `${b.fromMin}m+`
197 }
198
199 return b.toMin <= 60 ? `${b.fromMin}–${b.toMin}m` : `${b.fromMin / 60}–${b.toMin / 60}h`
200}
201
202/** One gap curve: each bucket with samples, as warm over all. */
203function gapLine(key: string, curve: readonly GapBucket[]) {
204 const [tool, family] = key.split('|')
205 const parts = curve.filter(b => b.warm + b.cold > 0).map((b, i) => `${spanOf(b)} ${b.warm}/${b.warm + b.cold}${i === 0 ? ' warm' : ''}`)
206
207 return `${tool} ${family}: ${parts.join(' · ')}`
208}
209
210/** A rate-limit window by its length: `5h`, `7d`, else minutes. */
211const windowName = (min: number) => (min % 1440 === 0 ? `${min / 1440}d` : min % 60 === 0 ? `${min / 60}h` : `${min}m`)
212
213function quotaText(quota: Summary['quota']) {
214 return Object.entries(quota)
215 .map(([key, points]) => {
216 const [tool, windowMin] = key.split('|')
217 const p = Math.round(points * 10) / 10
218 return `${tool} ${windowName(Number(windowMin))} +${p} pt${p === 1 ? '' : 's'}`
219 })
220 .join(' · ')
221}
222
223function handoffText(all: readonly Handoff[]) {
224 const part = (kind: Handoff['kind'], one: string, many: string) => {
225 const own = all.filter(h => h.kind === kind)
226 if (own.length === 0) {
227 return ''
228 }
229 const bg = own.filter(h => h.bg).length
230 const back = median(own.flatMap(h => (h.workMs !== undefined ? [h.workMs] : [])))
231 return `${own.length} ${own.length === 1 ? one : many}${bg > 0 ? ` (${bg} background)` : ''}${back !== undefined ? `, median ${fmtDur(back)} back` : ''}`
232 }
233 const react = median(all.flatMap(h => (h.reactMs !== undefined ? [h.reactMs] : [])))
234
235 return [part('agent', 'Agent', 'Agent'), part('codex', 'Codex', 'Codex'), part('resume', 'resume', 'resumes'), react !== undefined ? `next handoff median ${fmtDur(react)}` : ''].filter(Boolean).join(' · ')
236}
237
238const FLAG_NAMES = { 'context-bloat': 'context bloat', 'big-first-prefix': 'big first prefix', 'expensive-short': 'short task on an expensive model' } as const
239const TOP_COLD = 5
240
241/** `/meter report`: what `s` adds up to over the last `days`, one row a line, the cold resumes that cost most first. */
242export function reportText(s: Summary, o: { days: number; skipped: number }): string {
243 if (s.sessions === 0) {
244 return `No session in the last ${plural(o.days, 'day')}.`
245 }
246 const byEq = <T extends { eq: number }>(rec: Record<string, T>) => Object.entries(rec).sort((a, b) => b[1].eq - a[1].eq)
247 const tools = TOOLS.flatMap(tool => (s.byTool[tool] !== undefined ? [[tool, s.byTool[tool]] as const] : []))
248 const lines = [
249 `${plural(s.sessions, 'session')} in the last ${plural(o.days, 'day')}${o.skipped > 0 ? ` (${plural(o.skipped, 'emptied file')} skipped)` : ''}`,
250 ...(tools.length > 1 || (tools.length === 1 && tools[0]![0] !== 'claude') ? [`${pad('Tools')} ${tools.map(([tool, g]) => `${tool} ${plural(g.sessions, 'session')}, ${fmtTokens(g.eq)} eq`).join(' · ')}`] : []),
251 `${pad('Cache')} hit ${pct(s.totals.cr, s.totals.cr + s.totals.cw + s.totals.in)}% · read ${fmtTokens(s.totals.cr)} · written ${fmtTokens(s.totals.cw)} · uncached ${fmtTokens(s.totals.in)} · output ${fmtTokens(s.totals.out)}`,
252 `${pad('Token eq.')} ${eqText(s)}`,
253 `${pad('Models')} ${byEq(s.byModel).map(([m, g]) => `${shortModel(m)} hit ${Math.round(hitRate(g) * 100)}%, ${fmtTokens(g.eq)} eq`).join(' · ')}`,
254 `${pad('Agents')} ${byEq(s.byAgentType).map(([a, g], i) => `${a} ${fmtTokens(g.eq)}${i === 0 ? ' eq' : ''}`).join(' · ')}`,
255 ...(s.byTool.claude !== undefined ? [`${pad('TTL')} ${ttlText(s.ttl)}`] : []),
256 ...Object.entries(s.gaps).map(([key, curve], i) => `${i === 0 ? pad('Gaps') : INDENT.slice(1)} ${gapLine(key, curve)}`),
257 ...(s.keepalive.steps > 0 ? [`${pad('Keepalive')} ${plural(s.keepalive.steps, 'ping')}, ${fmtTokens(s.keepalive.eq)} eq`] : []),
258 ...(s.compaction.count > 0 ? [`${pad('Compaction')} ${s.compaction.count}${s.compaction.eq > 0 ? `, ${fmtTokens(s.compaction.eq)} eq` : ''}`] : []),
259 ...(Object.keys(s.quota).length > 0 ? [`${pad('Quota')} ${quotaText(s.quota)}`] : []),
260 ]
261 if (s.cold.length > 0) {
262 const written = s.cold.reduce((n, r) => n + r.cw, 0)
263 const extra = s.cold.reduce((n, r) => n + r.extra, 0)
264 lines.push(`${pad('Cold')} ${plural(s.cold.length, 'cold resume')}: ${fmtTokens(written)} written again, ${fmtTokens(extra)} eq beyond a read`)
265 for (const r of [...s.cold].sort((a, b) => b.extra - a.extra).slice(0, TOP_COLD)) {
266 lines.push(`${INDENT}${coldLine(r)}`)
267 }
268 }
269 const handoffs = handoffText(s.handoffs)
270 if (handoffs !== '') {
271 lines.push(`${pad('Handoffs')} ${handoffs}`)
272 }
273 const flags = (Object.keys(FLAG_NAMES) as (keyof typeof FLAG_NAMES)[]).flatMap(k => {
274 const n = s.flags.filter(f => f.kind === k).length
275 return n > 0 ? [`${n} ${FLAG_NAMES[k]}`] : []
276 })
277 if (flags.length > 0) {
278 lines.push(`${pad('Flags')} ${flags.join(' · ')}`)
279 }
280
281 return lines.join('\n')
282}
283
284/** Where the session's file goes, as the meter last wrote it. */
285export type FilesState = { root: string | undefined; savedAt?: number; error?: string }
286
287/** The pane's files row: where the session is saved, or why it is not. */
288export function filesText(f: FilesState, now: number) {
289 if (f.root === undefined) {
290 return 'not saved: set Data folder in /plugin configure oxen-meter@oxen-pet'
291 }
292 if (f.error !== undefined) {
293 return `not saved: ${f.error}`
294 }
295
296 return f.savedAt === undefined ? `not saved yet: ${f.root}/sessions` : `saved ${fmtMin(Math.floor((now - f.savedAt) / MIN))} ago to ${f.root}/sessions`
297}
298hooks/resume.ts 67 lines1import { writeWeight } from './analyze'
2import { MAIN } from './record'
3import type { Collector, ThreadState } from './record'
4import { fmtDur, fmtTokens } from './report'
5
6/**
7 * The cold resume guard's judgement: whether waking a thread now writes its whole context to the cache again, who a
8 * message goes to, and what the user and Claude are told. `register.tsx` asks before a SendMessage resumes an agent
9 * (`session.send`), and warns when a thread wakes on its own (`turn.step`).
10 */
11
12/** A resume that likely finds the cache cold: how long the thread sat, how much it holds, its TTL, and what writing it again costs beyond a read. */
13export type ResumeRisk = { gapMs: number; ctx: number; ttlMin: number; extra: number }
14
15/** From nine tenths of the TTL on: the cache may lapse before the request reaches it. */
16const RISK_SHARE = 0.9
17const READ_WEIGHT = 0.1
18
19export const RESUME_OPTIONS = { fresh: 'Spawn a fresh agent', resume: 'Resume anyway' } as const
20
21/**
22 * The risk of waking `thread` at `now`, or undefined when its cache is likely warm or its context small. `share` of
23 * the TTL is where the risk starts: nine tenths before a resume the user can still stop, the whole TTL for a thread
24 * already waking.
25 */
26export function resumeRisk(thread: ThreadState | undefined, now: number, ttlMin: number, coldTokens: number, share = RISK_SHARE): ResumeRisk | undefined {
27 if (thread === undefined) {
28 return undefined
29 }
30 const gapMs = now - thread.lastT0
31 if (gapMs < ttlMin * 60000 * share || thread.lastCtx < coldTokens) {
32 return undefined
33 }
34
35 return { gapMs, ctx: thread.lastCtx, ttlMin, extra: Math.round(thread.lastCtx * (writeWeight(ttlMin) - READ_WEIGHT)) }
36}
37
38/**
39 * The thread a message `to` goes to: an agent id the meter has seen or that is alive, the name an Agent call gave an
40 * agent, or a name exactly one live agent has. `main` is the main thread. Anything else (another session, a teammate
41 * the meter cannot place, a name two agents share) is undefined.
42 */
43export function resolveRecipient(to: string, c: Collector, live: readonly { id: string; name?: string }[]): string | undefined {
44 if (to === MAIN || c.threads[to] !== undefined || live.some(a => a.id === to)) {
45 return to
46 }
47 if (c.names[to] !== undefined) {
48 return c.names[to]
49 }
50 const named = live.filter(a => a.name === to)
51
52 return named.length === 1 ? named[0]!.id : undefined
53}
54
55const ttlName = (min: number) => (min >= 60 ? '1h' : '5m')
56
57export const resumeQuestion = (r: ResumeRisk, label: string) =>
58 `oxen-meter: ${label} has sat ${fmtDur(r.gapMs)}, past its ${ttlName(r.ttlMin)} cache. Resuming it writes its ${fmtTokens(r.ctx)} context again: about ${fmtTokens(r.extra)} token equivalents. Spawn a fresh agent with a short handoff instead?`
59
60export const resumeToast = (r: ResumeRisk, label: string) => `oxen-meter: resuming ${label} after ${fmtDur(r.gapMs)} writes its ${fmtTokens(r.ctx)} context again (~${fmtTokens(r.extra)} eq).`
61
62export const coldStartText = (r: ResumeRisk, label: string) => `oxen-meter: ${label} woke after ${fmtDur(r.gapMs)} with a cold cache: writing ${fmtTokens(r.ctx)} context again (~${fmtTokens(r.extra)} eq).`
63
64/** What Claude reads when the user refuses the resume: the message was not sent, and what to do instead. */
65export const freshReason = (r: ResumeRisk, label: string) =>
66 `The user chose not to resume ${label}: its prompt cache went cold ${fmtDur(r.gapMs)} ago, and resuming it would write its ${fmtTokens(r.ctx)} context again. The message was not sent. Spawn a fresh agent instead, with a short handoff: the task, what ${label} found or changed, and what is left to do.`
67hooks/sessionFile.ts 110 lines1import { RECORD_KINDS, TOOLS, newCollector, noteAgentType, noteThread } from './record'
2import type { Collector, Group, MeterRecord, Tool } from './record'
3import { timingSummary } from './timing'
4import type { TimingSummary } from './timing'
5
6/**
7 * A session's file in the data folder's `sessions/`: what the meter knew of the session when it last wrote, whole.
8 * `$.fs.write` writes a whole file and nothing deletes one, so the file is written over each time, kept under a size
9 * limit by dropping its oldest steps, and an expired one is written over with a tombstone.
10 */
11
12export const FILE_VERSION = 1
13/** Under `$.fs.write`'s 4 MiB, with room. */
14export const MAX_BYTES = 3 * 1024 * 1024
15/** What an expired session file is written over with. */
16export const TOMBSTONE = '{"v":1,"expired":true}'
17
18export type SessionMeta = {
19 sid: string
20 project: string // a hash of the project folder's name, or the name when the user turned hashing off
21 startedAt: number
22 savedAt: number
23 version: string // the meter's version that wrote the file
24 settings: { mainTtl: string; subagentTtl: string; coldTokens: number; outputWeight: number; cachedWeight?: number }
25 costUsd?: number // what the session had cost, as /cost totals it
26 tool?: Tool // the tool the session ran in; none in a file of 1.0.0, which only Claude Code wrote
27}
28
29export type SessionFile = SessionMeta & {
30 v: typeof FILE_VERSION
31 dropped: number
32 groups: Record<string, Group>
33 names: Record<string, string>
34 timings: Record<string, TimingSummary>
35 records: MeterRecord[]
36}
37
38const bytes = (text: string) => new TextEncoder().encode(text).length
39
40/** The session's file as text, at most `maxBytes`: its oldest steps go first when it would be larger. */
41export function sessionText(c: Collector, meta: SessionMeta, maxBytes = MAX_BYTES): string {
42 const timings = Object.fromEntries(Object.entries(c.timings).map(([hook, t]) => [hook, timingSummary(t)]))
43 let records = c.records
44 let dropped = c.dropped
45 for (;;) {
46 const file: SessionFile = { v: FILE_VERSION, ...meta, dropped, groups: c.groups, names: c.names, timings, records }
47 const text = JSON.stringify(file)
48 const size = bytes(text)
49 const steps = records.filter(r => r.k === 'step').length
50 if (size <= maxBytes || steps === 0) {
51 return text
52 }
53 // Drop the share of steps the file is over by, and a little more, so it fits in a few passes.
54 let drop = Math.max(1, Math.ceil(steps * ((size - maxBytes) / size) * 1.1))
55 dropped += drop
56 records = records.filter(r => !(r.k === 'step' && drop-- > 0))
57 }
58}
59
60const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
61
62/** A session file from its text, or undefined for a tombstone, a file that is not JSON, or one of another shape. */
63export function readSessionText(text: string): SessionFile | undefined {
64 let parsed: unknown
65 try {
66 parsed = JSON.parse(text)
67 } catch {
68 return undefined
69 }
70 if (!isObject(parsed) || parsed.v !== FILE_VERSION || typeof parsed.sid !== 'string' || !Array.isArray(parsed.records) || !isObject(parsed.groups)) {
71 return undefined
72 }
73 const records = parsed.records.filter((r): r is MeterRecord => isObject(r) && typeof r.k === 'string' && RECORD_KINDS.has(r.k))
74 const { tool, ...rest } = parsed as SessionFile
75
76 return {
77 ...rest,
78 ...((TOOLS as readonly unknown[]).includes(tool) ? { tool } : {}),
79 dropped: typeof parsed.dropped === 'number' ? parsed.dropped : 0,
80 names: isObject(parsed.names) ? (parsed.names as Record<string, string>) : {},
81 timings: isObject(parsed.timings) ? (parsed.timings as Record<string, TimingSummary>) : {},
82 records,
83 }
84}
85
86/** A collector that goes on from a file: its records and totals, its threads, agent types and names. */
87export function restoreCollector(file: SessionFile): Collector {
88 const c = newCollector()
89 c.records = [...file.records]
90 c.dropped = file.dropped
91 c.groups = structuredClone(file.groups)
92 c.names = { ...file.names }
93 for (const r of c.records) {
94 if (r.k === 'step') {
95 noteThread(c, r)
96 } else {
97 noteAgentType(c, r)
98 }
99 }
100
101 return c
102}
103
104export const isExpired = (mtimeMs: number, now: number, retentionDays: number) => now - mtimeMs > retentionDays * 86400000
105
106/** When the session last did something: its latest record's end, else its start. An imported file is written long after. */
107export function activeAt(file: SessionFile) {
108 return file.records.reduce((last, r) => Math.max(last, 't' in r ? r.t : r.t1), file.startedAt)
109}
110hooks/settings.ts 46 lines1/** The user's settings, from the plugin's `userConfig`, as the meter uses them. */
2export type Settings = {
3 resumeGuard: 'off' | 'warn' | 'ask' // what happens before a cold resume
4 coldTokens: number // the context a re-write must reach to count as a cold resume
5 mainTtl: TtlSetting // the main thread's cache TTL; auto takes the measured or inferred one
6 subagentTtl: TtlSetting
7 userLabel: string // the name exports carry; empty for anonymous
8 hashProject: boolean // records keep the project as a hash
9 outputWeight: number // one output token in uncached input tokens, for the token equivalent
10 retentionDays: number // session files older than this are emptied
11 dataDir: string // where sessions and exports go; empty for the .claude folder's oxen-meter
12 cachedWeight: number // one cached input token of Codex or Devin (no cache writes) in uncached input tokens
13 coldAfterMin: number // how long a Codex or Devin thread may sit before the CLI's hooks warn that its cache is likely gone
14}
15
16export type TtlSetting = 'auto' | '5m' | '1h'
17
18const GUARDS = ['off', 'warn', 'ask'] as const
19const TTLS = ['auto', '5m', '1h'] as const
20const MAX_LABEL = 40
21
22export const DEFAULTS: Settings = { resumeGuard: 'warn', coldTokens: 50000, mainTtl: 'auto', subagentTtl: 'auto', userLabel: '', hashProject: true, outputWeight: 5, retentionDays: 30, dataDir: '', cachedWeight: 0.1, coldAfterMin: 60 }
23
24const oneOf = <T extends string>(values: readonly T[], v: unknown, fallback: T): T => ((values as readonly unknown[]).includes(v) ? (v as T) : fallback)
25const number = (v: unknown, min: number, max: number, fallback: number) => (typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max ? v : fallback)
26
27/**
28 * Settings from the options Claude Code passes to `register`. A missing or malformed value takes its default, so
29 * settings saved by another version of the meter never stop it loading.
30 */
31export function readSettings(options: Readonly<Record<string, unknown>>): Settings {
32 return {
33 resumeGuard: oneOf(GUARDS, options.resumeGuard, DEFAULTS.resumeGuard),
34 coldTokens: number(options.coldTokens, 0, 2000000, DEFAULTS.coldTokens),
35 mainTtl: oneOf(TTLS, options.mainTtl, DEFAULTS.mainTtl),
36 subagentTtl: oneOf(TTLS, options.subagentTtl, DEFAULTS.subagentTtl),
37 userLabel: typeof options.userLabel === 'string' ? options.userLabel.trim().slice(0, MAX_LABEL) : DEFAULTS.userLabel,
38 hashProject: typeof options.hashProject === 'boolean' ? options.hashProject : DEFAULTS.hashProject,
39 outputWeight: number(options.outputWeight, 0, 100, DEFAULTS.outputWeight),
40 retentionDays: Math.round(number(options.retentionDays, 1, 3650, DEFAULTS.retentionDays)),
41 dataDir: typeof options.dataDir === 'string' ? options.dataDir.trim() : DEFAULTS.dataDir,
42 cachedWeight: number(options.cachedWeight, 0, 1, DEFAULTS.cachedWeight),
43 coldAfterMin: number(options.coldAfterMin, 5, 1440, DEFAULTS.coldAfterMin),
44 }
45}
46hooks/timing.ts 44 lines1/**
2 * How long the meter's own hooks take, by hook: every call counts toward the count, mean and max, and the last
3 * `RING` calls give the 95th percentile. The hooks note into it in place, so the hot path allocates nothing.
4 */
5export type Timing = { count: number; totalMs: number; maxMs: number; ring: number[]; next: number }
6export type Timings = Record<string, Timing>
7export type TimingSummary = { count: number; meanMs: number; p95Ms: number; maxMs: number }
8
9export const RING = 256
10
11export const newTimings = (): Timings => ({})
12
13const round3 = (n: number) => Math.round(n * 1000) / 1000
14
15/** Notes one call of `hook` that took `ms`, in place. */
16export function noteTiming(t: Timings, hook: string, ms: number) {
17 const took = Number.isFinite(ms) && ms > 0 ? ms : 0
18 const at = (t[hook] ??= { count: 0, totalMs: 0, maxMs: 0, ring: [], next: 0 })
19 at.count += 1
20 at.totalMs += took
21 at.maxMs = Math.max(at.maxMs, took)
22 if (at.ring.length < RING) {
23 at.ring.push(took)
24 } else {
25 at.ring[at.next] = took
26 }
27 at.next = (at.next + 1) % RING
28}
29
30export function timingSummary(t: Timing): TimingSummary {
31 const sorted = [...t.ring].sort((a, b) => a - b)
32 const p95 = sorted[Math.max(0, Math.ceil(sorted.length * 0.95) - 1)] ?? 0
33
34 return { count: t.count, meanMs: round3(t.count > 0 ? t.totalMs / t.count : 0), p95Ms: round3(p95), maxMs: round3(t.maxMs) }
35}
36
37/** One row per hook that ran, the slowest mean first. */
38export function timingRows(t: Timings): { hook: string; text: string }[] {
39 return Object.entries(t)
40 .map(([hook, at]) => ({ hook, s: timingSummary(at) }))
41 .sort((a, b) => b.s.meanMs - a.s.meanMs || a.hook.localeCompare(b.hook))
42 .map(({ hook, s }) => ({ hook, text: `${s.meanMs.toFixed(2)} ms mean · p95 ${s.p95Ms.toFixed(2)} · max ${s.maxMs.toFixed(2)} · ${s.count} call${s.count === 1 ? '' : 's'}` }))
43}
44