SLOPSHOPPER

oxen-meter

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…

newpaneguardcommandtoasttimer
★ 2v1.1.1MITupdated 2026-10-06thanhnhoncntt/oxen-pet/plugins/oxen-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · oxen-meter
│ ┃ oxen-meter ✕ › fix the failing auth test and add an audit log call │ ┃ Cache no model step yet │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /meter │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · oxen-meter
Cache no model step yet
README

oxen-meter

The prompt cache, measured for a team on Claude Code, Codex CLI and Devin CLI

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.

Claude Code mod Version Codex CLI and Devin CLI No network License: MIT

<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


Why oxen-meter

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.

  • 🔥 See which cache is about to go cold. /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.
  • ✋ Stop a cold resume before it costs you. Before Claude resumes an agent whose cache likely expired, the meter warns you, or asks whether to spawn a fresh agent with a short handoff instead.
  • 🧮 Compare cost without a price list. The token equivalent weighs reads, writes and output in plain input tokens, so sessions, people and models compare directly.
  • ⏱️ Learn your real TTLs. The meter measures how long each cache lived, from what Claude Code reports and from the gaps around warm and cold requests, instead of trusting folklore.
  • 🤝 Codex CLI and Devin CLI too. A command-line companion reads their logs and runs as their hooks, so one report covers all three tools, with the quota each session used.
  • 📊 Pool a team's numbers. /meter export writes an anonymized file; one script turns everyone's files into a team report.
  • 🔒 Metadata only. Token counts, times and names. Never a prompt, an answer, a path or a command. No network, no processes, and nothing added to what Claude sends: the meter stays out of the cache's way.

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.

Install

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.

Quick start

You useDo this onceThen
Claude CodeInstall, above./meter opens the pane; /meter report adds up the last 7 days.
Codex CLInode $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 CLInode $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 teamEach 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.

The pane

/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                           │
╰─────────────────────────────────────────────────────────────────────────────────────╯
RowWhat it says
CacheCache read over the whole context the requests carried, then the four token counts.
StepsModel requests, by the main thread and by subagents.
Token eq.What the session cost in plain input tokens (below).
TTLEach role's cache time to live, and how the meter knows it.
ColdThe cold resumes so far, and what they cost beyond a read. Absent while there are none.
main, one row per live subagentIts 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.
FilesWhere the session is saved, or why it is not.
Handoffs, Outcomes, CompactionsWork given to subagents and Codex; commits and pull requests; compactions and their sizes.
turn.step, tool.callHow long the meter's own hooks take.

Where no pane shows, /meter prints the same rows as text.

The cold resume guard

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:

  • warn (default): a toast says what resuming costs; the message goes.
  • ask: Claude Code asks you. Captured live (Claude Code 2.1.291, Sonnet 5.5, 2026-10-06):
   ☐ 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.

  • off: it is only recorded.

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.

Codex CLI and Devin CLI

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 CodeCodex CLIDevin CLI
Where the numbers come fromevery request, liveevery request, from its rollout filesevery request, from its database
Cache writes5m or 1hnone: OpenAI does not charge for themClaude models only
How long the cache livesa TTL, 5m or 1h, measured or inferredno fixed TTL: the gap curveno fixed TTL: the gap curve; Devin pings its cache to keep it
Subagentseach a threadeach its own rollout, a thread by its roleeach chain a thread by its profile
Quotathe 5-hour and 7-day windowsthe weekly windownot kept locally
Before a cold resumetoast, or a questionhooks: a message, or the prompt held back oncehooks: the prompt held back once

The hooks

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.

  • Codex. A prompt into a thread that sat past Cold after (60 minutes by default) with at least Cold resume tokens of context shows a line that the model never sees. Captured live (Codex CLI 0.160.1, gpt-6.1-sol medium, 2026-10-06, with Cold after and Cold resume tokens set low for the test), a prompt and then a follow-up to a subagent:
  ↳ 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.

  • Devin. Devin shows no message from a hook, only a held-back prompt's reason, so 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.

The report

/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.

Reading the numbers

  • Hit rate: cache read over the whole context the requests carried. A low one means the same context is written again and again.
  • Token equivalent: the cost in plain input tokens: uncached + writes × 1.25 (5m) or × 2 (1h) + reads × 0.1 + output × the output weight. For a model with no cache writes (OpenAI, SWE), a cached token weighs the Cached input weight. It compares sessions, people and models without a price list.
  • TTL: how long each role's cache lives, and how the meter knows. 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.
  • Cold resume: a step whose gap passed its TTL and that wrote at least the cold resume tokens again, or a session resumed (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.
  • Tools: each tool's sessions and token equivalent, when the report holds Codex or Devin.
  • Gaps (the gap curve, Codex and Devin): of the steps that came after the thread sat 5–10 minutes, 10–30, 30–60, 1–2 hours, 2–6 and longer, how many read their context back from the cache (warm). It is how long their cache lived for you; set Cold after from it.
  • Keepalive: Devin's pings that keep a cache warm, and what they cost. Compaction: how many, and what the compactions' own requests cost (Codex and Devin report them).
  • Quota: the points of each rate-limit window that moved while your sessions ran (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.
  • Handoffs: work given to a subagent (Agent), to Codex, or back to an agent by a message (resume). "Back" is how long it took to return; "next handoff" is how long the thread took to hand off again, such as sending the fix.
  • Flags (anti-patterns):
  • context bloat: a thread at 150K context or more for 20 steps without a compaction. Compact or hand off sooner.
  • big first prefix: a thread's first request carried 40K or more: many MCP servers, tool schemas, a large prompt.
  • short task on an expensive model: a subagent on Opus for five steps or fewer. Give such agents a smaller model.

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.

The team report

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.

Settings

Run /plugin configure oxen-meter@oxen-pet. Every setting has a default, and the companion reads the same ones.

SettingDefaultWhat it does
Cold resume guardwarnoff, warn or ask, above.
Cold resume tokens50000How large a context must be before writing it again counts as a cold resume.
Main thread cache TTLautoauto uses the TTL the meter measured or inferred, 1h until it has samples; or 5m, 1h.
Subagent cache TTLautoThe same for subagents, 5m until it has samples.
Your labelemptyThe name your exports carry in the team report. Empty: anonymous.
Hash project namesonThe project is kept as a hash salted with a key of your own, never its name.
Output weight5What one output token weighs against one uncached input token in the token equivalent.
Keep sessions (days)30Session files older than this are emptied (below).
Data folderemptyWhere sessions and exports go. Empty: oxen-meter in your .claude folder.
Cached input weight (Codex, Devin)0.1What a cached input token of a model with no cache writes (OpenAI, SWE) weighs against an uncached one.
Cold after (Codex, Devin)60Minutes 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.

What it records, and what it does not

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

Source 13 files
hooks/register.tsx 664 lines
1import 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}
664
hooks/analyze.ts 468 lines
1import { 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}
468
hooks/codex.ts 46 lines
1/**
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}
46
hooks/dataPath.ts 127 lines
1import 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}
127
hooks/exportFile.ts 123 lines
1import { 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}
123
hooks/project.ts 25 lines
1/**
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}
25
hooks/record.ts 254 lines
1import 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}
254
hooks/report.ts 298 lines
1import { 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}
298
hooks/resume.ts 67 lines
1import { 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.`
67
hooks/sessionFile.ts 110 lines
1import { 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}
110
hooks/settings.ts 46 lines
1/** 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}
46
hooks/timing.ts 44 lines
1/**
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