@chroxify/eunoe 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -336,6 +336,7 @@ eunoe settings # pick a setting and change it
336
336
  eunoe settings list # show them all
337
337
  eunoe settings set keep 5 # change one directly
338
338
  eunoe status # what's running, every session, context and tokens saved
339
+ eunoe stats # tokens and API cost by day (--days 14, --weekly), and what eunoe saved
339
340
  eunoe replay # replay your whole Claude Code history through eunoe, offline, and see what it saves
340
341
  eunoe uninstall # stop and remove the background proxy
341
342
  ```
@@ -416,6 +417,18 @@ Every request is written to `~/.eunoe/requests.jsonl` with what eunoe did
416
417
  to it and how much of it the API served from cache, which is also where
417
418
  `eunoe status` gets its numbers.
418
419
 
420
+ `eunoe stats` reads the usage the API returned, from Claude Code's own session
421
+ files, subagents included, so it covers every session, routed through eunoe or
422
+ not. Claude Code writes one response as several lines, so each response is
423
+ counted once, with its final output count. Cost is at API list prices.
424
+ Its eunoe columns are only what eunoe actually did, from its request log: the
425
+ input tokens it kept out, and what that saved. Those tokens are mostly history
426
+ plain Claude Code would have read from cache, so they are priced as cache
427
+ reads, and every token a fold rewrote is charged back at the cache-write price.
428
+ `eunoe replay` and `eunoe stats` can only see what is still on disk: Claude Code
429
+ deletes sessions that have been idle for 30 days unless `cleanupPeriodDays` in
430
+ its `settings.json` says otherwise.
431
+
419
432
  ## What it doesn't do
420
433
 
421
434
  - **It is a proxy, which makes it a single point of failure.** If it isn't
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chroxify/eunoe",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Context management for Claude Code that never forgets what matters.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -7,6 +7,7 @@ import { serve } from "./serve"
7
7
  import { sessions } from "./sessions"
8
8
  import { settings } from "./settings"
9
9
  import { start } from "./start"
10
+ import { stats } from "./stats"
10
11
  import { status } from "./status"
11
12
  import { stop } from "./stop"
12
13
  import { uninstall } from "./uninstall"
@@ -20,6 +21,7 @@ export const commands: Record<string, Command> = {
20
21
  mode,
21
22
  settings,
22
23
  status,
24
+ stats,
23
25
  replay,
24
26
  sessions,
25
27
  peek,
@@ -1,20 +1,23 @@
1
- import { statSync } from "node:fs"
1
+ import { closeSync, openSync, readSync, statSync } from "node:fs"
2
2
  import { cpus } from "node:os"
3
3
  import path from "node:path"
4
4
  import { claudeConfigDirs, DEFAULTS, parseTunable, readConfig } from "../../config"
5
5
  import { listEntries, projectFolderName, projectFolders } from "../../transcript/locate"
6
6
  import type { Command } from "../command"
7
+ import { cleanupDays } from "../proxy/claude-settings"
7
8
  import { recentRequests } from "../proxy/inspect"
8
- import type { ReplayedSession, ReplayOptions } from "../replay"
9
+ import type { ReplayedSession, ReplayFailure, ReplayOptions } from "../replay"
9
10
  import { shortId } from "../sessions"
10
- import { c, fail, line, percent, plural, rows, table, tilde, tokens, useColor } from "../terminal/ui"
11
+ import { c, fail, line, percent, plural, rows, table, tilde, tokens, useColor, warn } from "../terminal/ui"
11
12
 
12
13
  interface Target {
13
14
  file: string
14
15
  bytes: number
16
+ lastActive: number
15
17
  }
16
18
 
17
19
  const WINDOW = 1_000_000
20
+ const HEAD_BYTES = 64 * 1024
18
21
  const TOP_SESSIONS = 10
19
22
 
20
23
  function targets(configDirs: string[], ids: string[], here: string | null): Target[] {
@@ -28,7 +31,8 @@ function targets(configDirs: string[], ids: string[], here: string | null): Targ
28
31
  const id = name.slice(0, -".jsonl".length)
29
32
  if (!wanted(id) || seen.has(id)) continue
30
33
  const file = path.join(folder, name)
31
- seen.set(id, { file, bytes: statSync(file).size })
34
+ const stat = statSync(file)
35
+ seen.set(id, { file, bytes: stat.size, lastActive: stat.mtimeMs })
32
36
  }
33
37
  }
34
38
  return [...seen.values()].sort((a, b) => b.bytes - a.bytes)
@@ -42,12 +46,35 @@ function fixedTokens(): { value: number; measured: boolean } {
42
46
  return { value: sizes[Math.floor(sizes.length / 2)], measured: true }
43
47
  }
44
48
 
49
+ const day = (ms: number) => new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })
50
+
45
51
  const duration = (ms: number) => (ms < 60_000 ? `${Math.max(1, Math.round(ms / 1000))}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`)
46
52
  const size = (bytes: number) => (bytes >= 1e9 ? `${(bytes / 1e9).toFixed(1)} GB` : `${Math.round(bytes / 1e6)} MB`)
47
53
  const fewer = (plain: number, eunoe: number) => (plain > 0 ? percent(1 - eunoe / plain) : "–")
48
54
 
49
- async function replayAll(list: Target[], options: ReplayOptions, onDone: (done: number, bytes: number) => void): Promise<ReplayedSession[]> {
55
+ function startedAt(target: Target): number {
56
+ const buffer = Buffer.alloc(HEAD_BYTES)
57
+ let read = 0
58
+ try {
59
+ const fd = openSync(target.file, "r")
60
+ read = readSync(fd, buffer, 0, HEAD_BYTES, 0)
61
+ closeSync(fd)
62
+ } catch {
63
+ return target.lastActive
64
+ }
65
+ const found = /"timestamp":"([^"]+)"/.exec(buffer.toString("utf8", 0, read))
66
+ const at = found ? Date.parse(found[1]) : Number.NaN
67
+ return Number.isNaN(at) ? target.lastActive : Math.min(at, target.lastActive)
68
+ }
69
+
70
+ function historyLine(list: Target[], keepDays: number): string {
71
+ const span = `${day(Math.min(...list.map(startedAt)))} – ${day(Math.max(...list.map((t) => t.lastActive)))}`
72
+ return `${span} ${c.dim(`· the sessions still on disk; Claude Code deletes sessions idle for ${keepDays} days (cleanupPeriodDays), so totals cover a moving window`)}`
73
+ }
74
+
75
+ async function replayAll(list: Target[], options: ReplayOptions, onDone: (done: number, bytes: number) => void): Promise<{ results: ReplayedSession[]; failures: ReplayFailure[] }> {
50
76
  const results: ReplayedSession[] = []
77
+ const failures: ReplayFailure[] = []
51
78
  const queue = [...list]
52
79
  let bytesDone = 0
53
80
  const workers = Math.max(1, Math.min(8, cpus().length - 1, list.length))
@@ -56,28 +83,46 @@ async function replayAll(list: Target[], options: ReplayOptions, onDone: (done:
56
83
  { length: workers },
57
84
  () =>
58
85
  new Promise<void>((resolve) => {
59
- const worker = new Worker(new URL("../replay/worker.ts", import.meta.url).href)
86
+ let worker = new Worker(new URL("../replay/worker.ts", import.meta.url).href)
87
+ let current: Target | undefined
88
+ const finish = (target: Target) => {
89
+ bytesDone += target.bytes
90
+ onDone(results.length + failures.length, bytesDone)
91
+ }
60
92
  const next = () => {
61
- const target = queue.shift()
62
- if (!target) {
93
+ current = queue.shift()
94
+ if (!current) {
63
95
  worker.terminate()
64
96
  resolve()
65
97
  return
66
98
  }
67
- worker.postMessage({ file: target.file, options })
99
+ worker.postMessage({ file: current.file, options })
68
100
  }
69
- worker.onmessage = (event: MessageEvent<ReplayedSession>) => {
70
- results.push(event.data)
71
- bytesDone += event.data.bytes
72
- onDone(results.length, bytesDone)
73
- next()
101
+ const listen = () => {
102
+ worker.onmessage = (event: MessageEvent<ReplayedSession | ReplayFailure>) => {
103
+ if ("error" in event.data) failures.push(event.data)
104
+ else results.push(event.data)
105
+ if (current) finish(current)
106
+ next()
107
+ }
108
+ worker.onerror = (event) => {
109
+ event.preventDefault()
110
+ if (current) {
111
+ failures.push({ file: current.file, error: event.message || "worker crashed" })
112
+ finish(current)
113
+ }
114
+ worker.terminate()
115
+ worker = new Worker(new URL("../replay/worker.ts", import.meta.url).href)
116
+ listen()
117
+ next()
118
+ }
74
119
  }
75
- worker.onerror = () => next()
120
+ listen()
76
121
  next()
77
122
  }),
78
123
  ),
79
124
  )
80
- return results
125
+ return { results, failures }
81
126
  }
82
127
 
83
128
  export const replay: Command = {
@@ -105,7 +150,8 @@ export const replay: Command = {
105
150
  const config = readConfig()
106
151
  const turnCap = capAt === -1 ? config.turnCap : parseTunable("turnCap", args[capAt + 1] ?? "")
107
152
  if (turnCap === null) fail("--turn-cap needs a token count or off", "eunoe replay --turn-cap off")
108
- const list = targets(claudeConfigDirs(config), ids, args.includes("--here") ? process.cwd() : null)
153
+ const configDirs = claudeConfigDirs(config)
154
+ const list = targets(configDirs, ids, args.includes("--here") ? process.cwd() : null)
109
155
  if (list.length === 0) fail("No Claude Code sessions found.")
110
156
  const totalBytes = list.reduce((total, entry) => total + entry.bytes, 0)
111
157
  const fixed = fixedTokens()
@@ -113,7 +159,7 @@ export const replay: Command = {
113
159
  const options: ReplayOptions = { config: { ...config, turnCap, mode: config.mode === "off" ? DEFAULTS.mode : config.mode }, window, fixedTokens: fixed.value }
114
160
  const started = Date.now()
115
161
  const progress = useColor && process.stdout.isTTY
116
- const results = await replayAll(list, options, (done, doneBytes) => {
162
+ const { results, failures } = await replayAll(list, options, (done, doneBytes) => {
117
163
  if (!progress) return
118
164
  const left = doneBytes > 0 ? ` · ~${duration(((Date.now() - started) / doneBytes) * (totalBytes - doneBytes))} left` : ""
119
165
  process.stdout.write(`\r\x1b[K ${c.dim(`Replaying ${done} / ${list.length} sessions · ${size(doneBytes)} of ${size(totalBytes)}${left}`)}`)
@@ -133,7 +179,8 @@ export const replay: Command = {
133
179
  line()
134
180
  rows(
135
181
  [
136
- ["Sessions", `${c.bold(String(compared.length))} replayed ${c.dim("·")} ${size(totalBytes)} ${c.dim("·")} ${duration(Date.now() - started)}`],
182
+ ["Sessions", `${c.bold(compared.length.toLocaleString())} replayed ${c.dim("·")} ${size(totalBytes)} ${c.dim("·")} ${duration(Date.now() - started)}`],
183
+ ["History", historyLine(list, cleanupDays(configDirs))],
137
184
  ["Requests", c.bold(sum.requests.toLocaleString())],
138
185
  ["Window", `${tokens(window)} ${c.dim(`· ${tokens(fixed.value)} of it system prompt and tools, ${fixed.measured ? "from your logged requests" : "assumed"}`)}`],
139
186
  ],
@@ -144,15 +191,15 @@ export const replay: Command = {
144
191
  [
145
192
  [
146
193
  "History kept",
147
- `eunoe ${c.bold(c.green("every turn"))}, folded or whole ${c.dim("·")} Claude Code compacted ${c.bold(String(sum.nativeCompactions))} time${sum.nativeCompactions === 1 ? "" : "s"}, ${c.bold(tokens(sum.discarded))} tokens of conversation replaced by summaries`,
194
+ `eunoe ${c.bold(c.green("every turn"))}, folded or whole ${c.dim("·")} Claude Code compacted ${c.bold(sum.nativeCompactions.toLocaleString())} time${sum.nativeCompactions === 1 ? "" : "s"}, ${c.bold(tokens(sum.discarded))} tokens of conversation replaced by summaries`,
148
195
  ],
149
196
  [
150
197
  "Input tokens",
151
198
  `plain ${tokens(sum.plainTokens)} ${c.dim("→")} eunoe ${c.bold(tokens(sum.eunoeTokens))} tokens ${c.green(c.bold(`${fewer(sum.plainTokens, sum.eunoeTokens)} fewer`))} ${c.dim("the conversation; system prompt and tools are the same on both sides")}`,
152
199
  ],
153
200
  ["Cost", `${c.green(c.bold(`${fewer(sum.plainCost, sum.eunoeCost)} cheaper`))} ${c.dim("cache reads at 0.1×, writes at 1.25×")}`],
154
- ["Folds", c.bold(String(sum.folds))],
155
- ["Folds dropped", `${c.bold(String(sum.dropped))} ${c.dim("folded turns removed entirely to fit the window")}`],
201
+ ["Folds", c.bold(sum.folds.toLocaleString())],
202
+ ["Folds dropped", `${c.bold(sum.dropped.toLocaleString())} ${c.dim("folded turns removed entirely to fit the window")}`],
156
203
  ["Peak context", `plain ${tokens(sum.peakPlain)} ${c.dim("→")} eunoe ${c.bold(tokens(sum.peakEunoe))}`],
157
204
  ],
158
205
  4,
@@ -189,6 +236,10 @@ export const replay: Command = {
189
236
  ]),
190
237
  )
191
238
  }
239
+ if (failures.length > 0) {
240
+ line()
241
+ warn(`${plural(failures.length, "session")} couldn't be replayed and ${failures.length === 1 ? "is" : "are"} not counted: ${failures.map((f) => `${shortId(path.basename(f.file, ".jsonl"))} (${f.error})`).join(", ")}`)
242
+ }
192
243
  line()
193
244
  },
194
245
  }
@@ -1,14 +1,16 @@
1
1
  import { existsSync } from "node:fs"
2
2
  import path from "node:path"
3
- import { type Config, claudeConfigDirs, compactionOwner, readConfig, saveConfig } from "../../config"
3
+ import { type Config, claudeConfigDirs, compactionOwner, modeFor, readConfig, saveConfig } from "../../config"
4
+ import { seenSessions } from "../../proxy/sessions"
4
5
  import type { Command } from "../command"
5
6
  import { editSettings, withEunoe } from "../proxy/claude-settings"
6
7
  import { routedDirs } from "../proxy/inspect"
8
+ import { isIdle, isTerminal, type LiveSession, listLiveSessions, restartSessions, unroutedSessions } from "../proxy/live"
7
9
  import { startService } from "../proxy/service"
8
10
  import { clearWatch } from "../proxy/watch"
9
11
  import { isKnown, sessionArgs, shortId } from "../sessions"
10
12
  import { flagValues } from "../terminal/args"
11
- import { c, fail, line, listFiles, note, ok, rows, tilde } from "../terminal/ui"
13
+ import { c, fail, line, listFiles, note, ok, plural, rows, tilde } from "../terminal/ui"
12
14
  import { MODE_DESCRIPTIONS } from "./mode"
13
15
 
14
16
  function scoped(config: Config, ids: string[]): Partial<Config> {
@@ -30,13 +32,34 @@ function managedSessions(config: Config): string {
30
32
  return `only ${ids.join(", ")}`
31
33
  }
32
34
 
35
+ function restartUnrouted(config: Config, skip: boolean): { restarted: LiveSession[]; busy: LiveSession[] } {
36
+ const lastSeen = new Map(seenSessions().map(([id, seen]) => [id, seen.lastSeen]))
37
+ const managed = (id: string) => modeFor(config, id) !== "off"
38
+ const unrouted = unroutedSessions(listLiveSessions(claudeConfigDirs(config)), lastSeen, managed, process.env.CLAUDE_CODE_SESSION_ID)
39
+ if (skip) return { restarted: [], busy: unrouted }
40
+ return { restarted: restartSessions(unrouted.filter(isIdle)), busy: unrouted.filter((session) => !isIdle(session)) }
41
+ }
42
+
43
+ function reportRestarts({ restarted, busy }: { restarted: LiveSession[]; busy: LiveSession[] }) {
44
+ if (restarted.length > 0) {
45
+ ok(`Restarted ${plural(restarted.length, "idle session")} so they go through eunoe`)
46
+ if (restarted.some((session) => !isTerminal(session))) note("App sessions come back on their next message.")
47
+ if (restarted.some(isTerminal)) note("Resume the terminal ones with:")
48
+ for (const session of restarted.filter(isTerminal)) line(` ${c.dim(`cd ${tilde(session.cwd)} && claude --resume ${session.sessionId}`)}`)
49
+ line()
50
+ }
51
+ if (busy.length > 0) note(`Still on the direct route until restarted: ${busy.map((session) => shortId(session.sessionId)).join(", ")}.`)
52
+ }
53
+
33
54
  export const start: Command = {
34
55
  summary: "Route Claude Code through eunoe",
35
- usage: "eunoe start [--session <id>] [--config-dir <dir>]",
56
+ usage: "eunoe start [--session <id>] [--config-dir <dir>] [--no-restart]",
36
57
  details: [
37
58
  "Starts the background proxy if it isn't running (setting it up the first time)",
38
59
  "and points Claude Code at it through ANTHROPIC_BASE_URL in settings.json.",
39
- "New sessions pick it up; restart the ones already running.",
60
+ "New sessions pick it up. Idle sessions already running are restarted so they",
61
+ "pick it up too: app-hosted ones resume on their next message, terminal ones",
62
+ "print the command that resumes them. Busy sessions are left alone.",
40
63
  "",
41
64
  "With --session, eunoe manages only the sessions you name and passes the rest",
42
65
  "through untouched. Session ids are in Claude Code's /status, or `eunoe status`.",
@@ -44,6 +67,7 @@ export const start: Command = {
44
67
  options: [
45
68
  ["--session <id>", "Only manage this session (repeatable; a unique prefix is enough)"],
46
69
  ["--config-dir <dir>", "Also manage this Claude config directory (repeatable, remembered)"],
70
+ ["--no-restart", "Leave running sessions alone"],
47
71
  ],
48
72
  async run(args) {
49
73
  const added = flagValues(args, "--config-dir").map((dir) => path.resolve(dir))
@@ -65,9 +89,11 @@ export const start: Command = {
65
89
  ["Sessions", managedSessions(config)],
66
90
  ])
67
91
  line()
68
- const unseen = ids.filter((id) => !isKnown(id))
92
+ const restarts = restartUnrouted(config, args.includes("--no-restart"))
93
+ reportRestarts(restarts)
94
+ const unseen = ids.filter((id) => !isKnown(id) && !restarts.restarted.some((session) => session.sessionId === id))
69
95
  if (unseen.length > 0) note(`Not seen through eunoe yet: ${unseen.map(shortId).join(", ")}. It applies once that session's requests come through; one started before routing needs a restart.`)
70
- else note(ids.length > 0 ? "Takes effect on the session's next request." : "New sessions go through eunoe. Restart any that are already running.")
96
+ else note(ids.length > 0 ? "Takes effect on the session's next request." : "New sessions go through eunoe.")
71
97
  line()
72
98
  },
73
99
  }
@@ -0,0 +1,233 @@
1
+ import { claudeConfigDirs, readConfig } from "../../config"
2
+ import type { Command } from "../command"
3
+ import { cleanupDays } from "../proxy/claude-settings"
4
+ import { type RequestRecord, recentRequests } from "../proxy/inspect"
5
+ import { savedTokens, totals } from "../proxy/stats"
6
+ import { c, fail, line, percent, plural, table, tokens, wrap } from "../terminal/ui"
7
+ import { costOf, ratesOf, readUsage, type UsageEntry, usageFiles } from "../usage"
8
+
9
+ interface Bucket {
10
+ start: number
11
+ label: string
12
+ usage: UsageEntry[]
13
+ logged: RequestRecord[]
14
+ }
15
+
16
+ type Matched = Map<RequestRecord, UsageEntry>
17
+
18
+ const DAY = 86_400_000
19
+ const MATCH_WITHIN = 10 * 60_000
20
+ const NOTE_WIDTH = 110
21
+ const DEFAULT_DAYS = 7
22
+ const DEFAULT_WEEKS = 8
23
+
24
+ const midnight = (ms: number) => new Date(new Date(ms).toDateString()).getTime()
25
+ const addDays = (ms: number, days: number) => {
26
+ const date = new Date(ms)
27
+ date.setDate(date.getDate() + days)
28
+ return date.getTime()
29
+ }
30
+ const monday = (ms: number) => addDays(midnight(ms), -((new Date(ms).getDay() + 6) % 7))
31
+ const short = (ms: number) => new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric" })
32
+ const dayLabel = (ms: number) => new Date(ms).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
33
+ const dollars = (amount: number) => `$${amount >= 100 ? Math.round(amount).toLocaleString() : amount.toFixed(2)}`
34
+
35
+ export function buckets(now: number, weekly: boolean, count: number): Bucket[] {
36
+ const step = weekly ? 7 : 1
37
+ const last = weekly ? monday(now) : midnight(now)
38
+ return Array.from({ length: count }, (_, i) => {
39
+ const start = addDays(last, -step * (count - 1 - i))
40
+ const label = weekly ? `${short(start)} – ${short(addDays(start, 6))}` : dayLabel(start)
41
+ return { start, label, usage: [], logged: [] }
42
+ })
43
+ }
44
+
45
+ function place(list: Bucket[], at: number, add: (bucket: Bucket) => void) {
46
+ for (let i = list.length - 1; i >= 0; i -= 1) {
47
+ if (at < list[i].start) continue
48
+ add(list[i])
49
+ return
50
+ }
51
+ }
52
+
53
+ export function summarize(usage: UsageEntry[]) {
54
+ const sum = { requests: usage.length, input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0, unpriced: 0 }
55
+ for (const entry of usage) {
56
+ sum.input += entry.input
57
+ sum.output += entry.output
58
+ sum.cacheWrite += entry.cacheWrite5m + entry.cacheWrite1h
59
+ sum.cacheRead += entry.cacheRead
60
+ const cost = costOf(entry)
61
+ if (cost === null) sum.unpriced += 1
62
+ else sum.cost += cost
63
+ }
64
+ return sum
65
+ }
66
+
67
+ const usageKey = (cacheRead: number, cacheWrite: number, uncached: number) => `${cacheRead}:${cacheWrite}:${uncached}`
68
+
69
+ export function matchLogged(usage: UsageEntry[], logged: RequestRecord[]): Matched {
70
+ const index = new Map<string, UsageEntry[]>()
71
+ for (const entry of usage) {
72
+ const key = usageKey(entry.cacheRead, entry.cacheWrite5m + entry.cacheWrite1h, entry.input)
73
+ index.set(key, [...(index.get(key) ?? []), entry])
74
+ }
75
+ const matched: Matched = new Map()
76
+ for (const record of logged) {
77
+ if (!record.input) continue
78
+ const at = Date.parse(record.ts)
79
+ const distance = (entry: UsageEntry) => Math.abs(entry.at - at)
80
+ const nearest = (index.get(usageKey(record.cacheRead ?? 0, record.cacheWrite ?? 0, record.uncached ?? 0)) ?? []).reduce<UsageEntry | null>((best, entry) => (!best || distance(entry) < distance(best) ? entry : best), null)
81
+ if (nearest && distance(nearest) <= MATCH_WITHIN) matched.set(record, nearest)
82
+ }
83
+ return matched
84
+ }
85
+
86
+ export function costSaved(record: RequestRecord, entry: UsageEntry): number | null {
87
+ const saved = savedTokens(record)
88
+ const rates = ratesOf(entry)
89
+ if (saved === null || !rates) return null
90
+ const foldRewrite = record.compacted ? (record.cacheWrite ?? 0) * (rates.write - rates.read) : 0
91
+ return saved * rates.read - foldRewrite
92
+ }
93
+
94
+ export function savings(logged: RequestRecord[], matched: Matched) {
95
+ const sum = { handled: 0, priced: 0, unsized: 0, unmatched: 0, actual: 0, saved: 0 }
96
+ for (const record of logged) {
97
+ if (!record.input) continue
98
+ sum.handled += 1
99
+ const entry = matched.get(record)
100
+ if (savedTokens(record) === null) sum.unsized += 1
101
+ else if (!entry) sum.unmatched += 1
102
+ const saved = entry ? costSaved(record, entry) : null
103
+ const actual = entry ? costOf(entry) : null
104
+ if (saved === null || actual === null) continue
105
+ sum.priced += 1
106
+ sum.actual += actual
107
+ sum.saved += saved
108
+ }
109
+ return sum
110
+ }
111
+
112
+ function leftOut(sum: ReturnType<typeof savings>) {
113
+ const reasons = [
114
+ sum.unsized ? `${sum.unsized.toLocaleString()} predate size logging` : "",
115
+ sum.unmatched ? `${sum.unmatched.toLocaleString()} aren't in session files` : "",
116
+ sum.handled - sum.priced - sum.unsized - sum.unmatched > 0 ? `${sum.handled - sum.priced - sum.unsized - sum.unmatched} to models without a price` : "",
117
+ ]
118
+ const listed = reasons.filter(Boolean)
119
+ return listed.length > 1 ? `${listed.slice(0, -1).join(", ")} and ${listed.at(-1)}` : listed.join("")
120
+ }
121
+
122
+ function notSentCell(logged: RequestRecord[]) {
123
+ const sum = totals(logged)
124
+ if (sum.requests === 0) return c.dim("–")
125
+ if (sum.saved <= 0) return c.dim("±0")
126
+ return `${tokens(sum.saved)} ${c.dim(plural(sum.folds, "fold"))}`
127
+ }
128
+
129
+ function costSavedCell(logged: RequestRecord[], matched: Matched) {
130
+ const sum = savings(logged, matched)
131
+ if (sum.priced === 0) return c.dim("–")
132
+ if (Math.abs(sum.saved) < 0.005) return c.dim("±0")
133
+ const share = c.dim(percent(sum.saved / (sum.actual + sum.saved)))
134
+ return sum.saved > 0 ? `${c.green(c.bold(dollars(sum.saved)))} ${share}` : `${c.yellow(`−${dollars(-sum.saved)}`)} ${share}`
135
+ }
136
+
137
+ function row(label: string, usage: UsageEntry[], logged: RequestRecord[], matched: Matched, bold = false) {
138
+ const sum = summarize(usage)
139
+ const strong = (text: string) => (bold ? c.bold(text) : text)
140
+ const eunoe = [notSentCell(logged), costSavedCell(logged, matched)]
141
+ if (sum.requests === 0) return [strong(label), c.dim("–"), "", "", "", "", "", ...eunoe]
142
+ return [strong(label), strong(sum.requests.toLocaleString()), tokens(sum.input), tokens(sum.output), tokens(sum.cacheWrite), tokens(sum.cacheRead), strong(`${dollars(sum.cost)}${sum.unpriced ? c.dim("*") : ""}`), ...eunoe]
143
+ }
144
+
145
+ function byModel(usage: UsageEntry[]) {
146
+ const models = new Map<string, UsageEntry[]>()
147
+ for (const entry of usage) models.set(entry.model, [...(models.get(entry.model) ?? []), entry])
148
+ const total = summarize(usage).cost
149
+ return [...models]
150
+ .map(([model, entries]) => ({ model, sum: summarize(entries) }))
151
+ .sort((a, b) => b.sum.cost - a.sum.cost || b.sum.requests - a.sum.requests)
152
+ .map(({ model, sum }) => [
153
+ model,
154
+ sum.requests.toLocaleString(),
155
+ tokens(sum.input),
156
+ tokens(sum.output),
157
+ tokens(sum.cacheWrite),
158
+ tokens(sum.cacheRead),
159
+ sum.unpriced ? c.dim("no price") : c.bold(dollars(sum.cost)),
160
+ c.dim(total > 0 && !sum.unpriced ? percent(sum.cost / total) : ""),
161
+ ])
162
+ }
163
+
164
+ const countArg = (args: string[], flag: string, fallback: number) => {
165
+ const at = args.indexOf(flag)
166
+ if (at === -1) return fallback
167
+ const value = Number(args[at + 1])
168
+ if (!Number.isInteger(value) || value < 1) fail(`${flag} needs a whole number`, `eunoe stats ${flag} 14`)
169
+ return value
170
+ }
171
+
172
+ export const stats: Command = {
173
+ summary: "Token usage and cost by day or week, and what eunoe saved",
174
+ usage: "eunoe stats [--days <n>] [--weekly] [--weeks <n>]",
175
+ details: [
176
+ "Reads the usage the API returned for every request in your Claude Code",
177
+ "sessions, subagents included, counting each response once. Cost is at",
178
+ "API list prices. The eunoe columns come from its request log: the input",
179
+ "tokens it kept out of the requests it handled, and what that saved at",
180
+ "cache-read prices once the cache writes its folds caused are paid for.",
181
+ ],
182
+ options: [
183
+ ["--days <n>", `Days to show (default ${DEFAULT_DAYS})`],
184
+ ["--weekly", `Group by week, Monday first (default ${DEFAULT_WEEKS} weeks)`],
185
+ ["--weeks <n>", "Weeks to show; implies --weekly"],
186
+ ],
187
+ run(args) {
188
+ const weekly = args.includes("--weekly") || args.includes("--weeks")
189
+ const count = weekly ? countArg(args, "--weeks", DEFAULT_WEEKS) : countArg(args, "--days", DEFAULT_DAYS)
190
+ const now = Date.now()
191
+ const list = buckets(now, weekly, count)
192
+ const since = list[0].start
193
+ const configDirs = claudeConfigDirs(readConfig())
194
+ const usage = readUsage(usageFiles(configDirs, since), since)
195
+ const logged = recentRequests(Number.POSITIVE_INFINITY).filter((record) => Date.parse(record.ts) >= since)
196
+ for (const entry of usage) place(list, entry.at, (bucket) => bucket.usage.push(entry))
197
+ const matched = matchLogged(usage, logged)
198
+ for (const record of logged) place(list, Date.parse(record.ts), (bucket) => bucket.logged.push(record))
199
+
200
+ line()
201
+ line(` ${c.bold("eunoe stats")} ${c.dim(`the last ${count} ${weekly ? "weeks" : "days"} of Claude Code, from your session files`)}`)
202
+ line()
203
+ table([
204
+ [weekly ? "Week" : "Day", "Requests", "Input", "Output", "Cache write", "Cache read", "API cost", "Not sent by eunoe", "Cost saved"].map((title) => c.dim(title)),
205
+ ...list.map((bucket) => row(bucket.label, bucket.usage, bucket.logged, matched)),
206
+ row("Total", usage, logged, matched, true),
207
+ ])
208
+
209
+ const models = byModel(usage)
210
+ if (models.length > 0) {
211
+ line()
212
+ table([["Model", "Requests", "Input", "Output", "Cache write", "Cache read", "API cost", "Share"].map((title) => c.dim(title)), ...models])
213
+ }
214
+
215
+ const keepDays = cleanupDays(configDirs)
216
+ const handled = savings(logged, matched)
217
+ const notes: Array<[label: string, text: string]> = [
218
+ ["API cost", "At list prices, including cache writes and fast mode."],
219
+ ["Not sent", "Input eunoe kept out, mostly history Claude Code would have read from cache."],
220
+ ["Cost saved", "Those tokens at cache-read prices, minus what each fold cost to rewrite."],
221
+ ...(handled.priced < handled.handled ? [["Not priced", `${(handled.handled - handled.priced).toLocaleString()} of ${handled.handled.toLocaleString()} eunoe requests: ${leftOut(handled)}.`] as [string, string]] : []),
222
+ ...(summarize(usage).unpriced ? [["* No price", "Some models have no known price, so their tokens count but their cost doesn't."] as [string, string]] : []),
223
+ ...(since < now - keepDays * DAY ? [["History", `Claude Code deletes sessions after ${keepDays} idle days, so older rows may be incomplete.`] as [string, string]] : []),
224
+ ]
225
+ const labelWidth = Math.max(...notes.map(([label]) => label.length)) + 3
226
+ const width = Math.min(process.stdout.columns || NOTE_WIDTH, NOTE_WIDTH) - 2 - labelWidth
227
+ for (const [label, text] of notes) {
228
+ line()
229
+ for (const [index, part] of wrap(text, width).entries()) line(` ${c.dim((index === 0 ? label : "").padEnd(labelWidth))}${part}`)
230
+ }
231
+ line()
232
+ },
233
+ }
@@ -29,7 +29,7 @@ function summary(title: string, sum: Totals) {
29
29
  line(` ${title}`)
30
30
  rows(
31
31
  [
32
- ["Requests", `${c.bold(String(sum.requests))}${folds}`],
32
+ ["Requests", `${c.bold(sum.requests.toLocaleString())}${folds}`],
33
33
  ["Cache", `${c.bold(tokens(sum.cacheRead))} read ${c.dim("·")} ${tokens(sum.cacheWrite)} written ${c.dim("·")} ${tokens(sum.uncached)} uncached${hitRate}`],
34
34
  ["Saved", saved],
35
35
  ],
@@ -100,7 +100,7 @@ export const status: Command = {
100
100
  const routed = routedDirs(config)
101
101
  const all = recentRequests(Number.POSITIVE_INFINITY)
102
102
  const recent = all.slice(-RECENT_REQUESTS)
103
- const sum = totals(withinWindow(recent))
103
+ const sum = totals(withinWindow(all))
104
104
  const allTime = totals(all)
105
105
 
106
106
  line()
@@ -114,7 +114,7 @@ export const status: Command = {
114
114
  ["Recall", recallLine(config, recent)],
115
115
  ])
116
116
 
117
- sessionsTable(config, perSession(recent))
117
+ sessionsTable(config, perSession(all))
118
118
 
119
119
  if (sum.requests) summary(c.dim("Last 24h"), sum)
120
120
  if (allTime.requests > sum.requests) summary(`${c.dim("All time")} ${c.dim(`since ${new Date(all[0].ts).toLocaleDateString()}`)}`, allTime)
@@ -7,6 +7,8 @@ export interface Settings {
7
7
  [key: string]: unknown
8
8
  }
9
9
 
10
+ const CLAUDE_CLEANUP_DAYS = 30
11
+
10
12
  export const BLOCKING_LIMIT_ENV = "CLAUDE_CODE_BLOCKING_LIMIT_OVERRIDE"
11
13
  export const BLOCKING_LIMIT_OFF = "1000000000"
12
14
 
@@ -44,6 +46,14 @@ export function readSettings(dir: string): Settings {
44
46
  }
45
47
  }
46
48
 
49
+ export function cleanupDays(configDirs: string[]): number {
50
+ for (const dir of configDirs) {
51
+ const days = readSettings(dir).cleanupPeriodDays
52
+ if (typeof days === "number") return days
53
+ }
54
+ return CLAUDE_CLEANUP_DAYS
55
+ }
56
+
47
57
  export function editSettings(dirs: string[], edit: (settings: Settings) => Settings): string[] {
48
58
  const touched: string[] = []
49
59
  for (const dir of dirs) {
@@ -0,0 +1,57 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ export interface LiveSession {
6
+ pid: number
7
+ sessionId: string
8
+ cwd: string
9
+ startedAt: number
10
+ status: string
11
+ entrypoint: string
12
+ }
13
+
14
+ const isLiveSession = (value: Partial<LiveSession>): value is LiveSession => typeof value.pid === "number" && typeof value.sessionId === "string" && typeof value.startedAt === "number" && typeof value.status === "string"
15
+
16
+ function runsClaude(pid: number) {
17
+ const result = spawnSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8" })
18
+ return result.status === 0 && /claude/i.test(result.stdout)
19
+ }
20
+
21
+ export function listLiveSessions(configDirs: string[]): LiveSession[] {
22
+ const live: LiveSession[] = []
23
+ for (const dir of configDirs) {
24
+ const sessionsDir = path.join(dir, "sessions")
25
+ if (!existsSync(sessionsDir)) continue
26
+ for (const file of readdirSync(sessionsDir).filter((name) => /^\d+\.json$/.test(name))) {
27
+ try {
28
+ const session = JSON.parse(readFileSync(path.join(sessionsDir, file), "utf8"))
29
+ if (isLiveSession(session) && runsClaude(session.pid)) live.push(session)
30
+ } catch {}
31
+ }
32
+ }
33
+ return live
34
+ }
35
+
36
+ export function unroutedSessions(live: LiveSession[], lastSeen: Map<string, string>, managed: (sessionId: string) => boolean, self: string | undefined): LiveSession[] {
37
+ return live.filter((session) => {
38
+ if (session.sessionId === self || !managed(session.sessionId)) return false
39
+ const seen = lastSeen.get(session.sessionId)
40
+ return !seen || Date.parse(seen) < session.startedAt
41
+ })
42
+ }
43
+
44
+ export const isIdle = (session: LiveSession) => session.status === "idle"
45
+
46
+ export const isTerminal = (session: LiveSession) => session.entrypoint === "cli"
47
+
48
+ export function restartSessions(sessions: LiveSession[]): LiveSession[] {
49
+ return sessions.filter((session) => {
50
+ try {
51
+ process.kill(session.pid, "SIGTERM")
52
+ return true
53
+ } catch {
54
+ return false
55
+ }
56
+ })
57
+ }
@@ -31,6 +31,11 @@ function scaled(record: RequestRecord, chars: number | undefined): number | null
31
31
  return input > 0 && charsOut > 0 && chars && chars > 0 ? Math.round((input * chars) / charsOut) : null
32
32
  }
33
33
 
34
+ export function savedTokens(record: RequestRecord): number | null {
35
+ const plain = scaled(record, record.charsIn)
36
+ return plain === null ? null : plain - inputOf(record)
37
+ }
38
+
34
39
  export function withinWindow(records: RequestRecord[], now = Date.now(), window = 24 * 3_600_000): RequestRecord[] {
35
40
  return records.filter((record) => now - Date.parse(record.ts) <= window)
36
41
  }
@@ -66,10 +71,10 @@ export function totals(records: RequestRecord[]): Totals {
66
71
  out.folds += 1
67
72
  out.lastFold = record.ts
68
73
  }
69
- const plain = scaled(record, record.charsIn)
70
- if (plain !== null) {
74
+ const saved = savedTokens(record)
75
+ if (saved !== null) {
71
76
  out.sent += inputOf(record)
72
- out.saved += plain - inputOf(record)
77
+ out.saved += saved
73
78
  }
74
79
  }
75
80
  return out
@@ -36,6 +36,11 @@ export interface ReplayedSession extends Replay {
36
36
  ms: number
37
37
  }
38
38
 
39
+ export interface ReplayFailure {
40
+ file: string
41
+ error: string
42
+ }
43
+
39
44
  export const REPLAY_NATIVE_COMPACT_AT = 0.92
40
45
 
41
46
  const NATIVE_SUMMARY_CHARS = 30_000
@@ -2,14 +2,19 @@ import { statSync } from "node:fs"
2
2
  import { basename } from "node:path"
3
3
  import { cwdOf, titleOf } from "../../proxy/sessions"
4
4
  import { sessionMessages } from "../../transcript/messages"
5
- import { type ReplayedSession, type ReplayOptions, replaySession } from "."
5
+ import { type ReplayedSession, type ReplayFailure, type ReplayOptions, replaySession } from "."
6
6
 
7
7
  declare var self: Worker
8
8
 
9
9
  self.onmessage = (event: MessageEvent<{ file: string; options: ReplayOptions }>) => {
10
10
  const { file, options } = event.data
11
11
  const started = performance.now()
12
- const replay = replaySession(sessionMessages(file), options)
13
- const done: ReplayedSession = { ...replay, id: basename(file, ".jsonl"), file, bytes: statSync(file).size, cwd: cwdOf(file) ?? null, title: titleOf(file) ?? null, ms: Math.round(performance.now() - started) }
14
- postMessage(done)
12
+ try {
13
+ const replay = replaySession(sessionMessages(file), options)
14
+ const done: ReplayedSession = { ...replay, id: basename(file, ".jsonl"), file, bytes: statSync(file).size, cwd: cwdOf(file) ?? null, title: titleOf(file) ?? null, ms: Math.round(performance.now() - started) }
15
+ postMessage(done)
16
+ } catch (error) {
17
+ const failed: ReplayFailure = { file, error: String(error) }
18
+ postMessage(failed)
19
+ }
15
20
  }
@@ -0,0 +1,151 @@
1
+ import { readFileSync, statSync } from "node:fs"
2
+ import path from "node:path"
3
+ import { listEntries, projectFolders } from "../../transcript/locate"
4
+ import { subagentsDir } from "../../transcript/subagents"
5
+
6
+ export interface UsageEntry {
7
+ at: number
8
+ model: string
9
+ input: number
10
+ output: number
11
+ cacheWrite5m: number
12
+ cacheWrite1h: number
13
+ cacheRead: number
14
+ fast: boolean
15
+ }
16
+
17
+ interface Price {
18
+ input: number
19
+ output: number
20
+ cacheRead: number
21
+ }
22
+
23
+ interface RawUsage {
24
+ input_tokens?: number
25
+ output_tokens?: number
26
+ cache_creation_input_tokens?: number
27
+ cache_read_input_tokens?: number
28
+ cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number }
29
+ service_tier?: string
30
+ speed?: string
31
+ }
32
+
33
+ interface RawLine {
34
+ timestamp?: string
35
+ requestId?: string
36
+ message?: { id?: string; model?: string; usage?: RawUsage }
37
+ }
38
+
39
+ const PRICES: Array<[prefix: string, price: Price]> = [
40
+ ["claude-fable-5-1", { input: 10, output: 50, cacheRead: 0.25 }],
41
+ ["claude-mythos-5-1", { input: 10, output: 50, cacheRead: 0.25 }],
42
+ ["claude-fable-5", { input: 10, output: 50, cacheRead: 1 }],
43
+ ["claude-mythos-5", { input: 10, output: 50, cacheRead: 1 }],
44
+ ["claude-opus-5-5", { input: 4, output: 20, cacheRead: 0.2 }],
45
+ ["claude-opus-5", { input: 5, output: 25, cacheRead: 0.5 }],
46
+ ["claude-opus-4-8", { input: 5, output: 25, cacheRead: 0.5 }],
47
+ ["claude-opus-4-7", { input: 5, output: 25, cacheRead: 0.5 }],
48
+ ["claude-opus-4-6", { input: 5, output: 25, cacheRead: 0.5 }],
49
+ ["claude-opus-4-5", { input: 5, output: 25, cacheRead: 0.5 }],
50
+ ["claude-sonnet-5-5", { input: 2, output: 10, cacheRead: 0.2 }],
51
+ ["claude-sonnet-5", { input: 2, output: 10, cacheRead: 0.2 }],
52
+ ["claude-sonnet-4", { input: 3, output: 15, cacheRead: 0.3 }],
53
+ ["claude-haiku-4-5", { input: 1, output: 5, cacheRead: 0.1 }],
54
+ ]
55
+
56
+ const CACHE_WRITE_5M = 1.25
57
+ const CACHE_WRITE_1H = 2
58
+ const FAST_MULTIPLIER = 2
59
+ const PLACEHOLDER_INPUT = 1000
60
+
61
+ const priceFor = (model: string) => PRICES.find(([prefix]) => model === prefix || model.startsWith(`${prefix}-`))?.[1] ?? null
62
+
63
+ export function ratesOf(entry: UsageEntry): { read: number; write: number } | null {
64
+ const price = priceFor(entry.model)
65
+ if (!price) return null
66
+ const writes = entry.cacheWrite5m + entry.cacheWrite1h
67
+ const writeShare = writes > 0 ? (entry.cacheWrite5m * CACHE_WRITE_5M + entry.cacheWrite1h * CACHE_WRITE_1H) / writes : CACHE_WRITE_1H
68
+ const scale = (entry.fast ? FAST_MULTIPLIER : 1) / 1e6
69
+ return { read: price.cacheRead * scale, write: price.input * writeShare * scale }
70
+ }
71
+
72
+ export function costOf(entry: UsageEntry): number | null {
73
+ const price = priceFor(entry.model)
74
+ if (!price) return null
75
+ const dollars = entry.input * price.input + entry.output * price.output + entry.cacheRead * price.cacheRead + entry.cacheWrite5m * price.input * CACHE_WRITE_5M + entry.cacheWrite1h * price.input * CACHE_WRITE_1H
76
+ return (dollars / 1e6) * (entry.fast ? FAST_MULTIPLIER : 1)
77
+ }
78
+
79
+ const fromApi = (usage: RawUsage) => usage.service_tier !== undefined
80
+
81
+ const isPlaceholder = (usage: RawUsage) => !fromApi(usage) && usage.input_tokens === PLACEHOLDER_INPUT && !usage.cache_read_input_tokens && !usage.cache_creation_input_tokens
82
+
83
+ const supersedes = (next: RawUsage, known: RawUsage) =>
84
+ fromApi(next) !== fromApi(known)
85
+ ? fromApi(next)
86
+ : (next.output_tokens ?? 0) > (known.output_tokens ?? 0) || ((next.output_tokens ?? 0) === (known.output_tokens ?? 0) && (next.cache_creation_input_tokens ?? 0) > (known.cache_creation_input_tokens ?? 0))
87
+
88
+ function entryOf(line: RawLine, usage: RawUsage, at: number): UsageEntry {
89
+ const written = usage.cache_creation_input_tokens ?? 0
90
+ const hour = Math.min(written, usage.cache_creation?.ephemeral_1h_input_tokens ?? 0)
91
+ return {
92
+ at,
93
+ model: line.message?.model ?? "unknown",
94
+ input: usage.input_tokens ?? 0,
95
+ output: usage.output_tokens ?? 0,
96
+ cacheWrite5m: written - hour,
97
+ cacheWrite1h: hour,
98
+ cacheRead: usage.cache_read_input_tokens ?? 0,
99
+ fast: usage.speed === "fast",
100
+ }
101
+ }
102
+
103
+ export function usageFiles(configDirs: string[], since: number): string[] {
104
+ const files: string[] = []
105
+ const recent = (file: string) => {
106
+ try {
107
+ return statSync(file).mtimeMs >= since
108
+ } catch {
109
+ return false
110
+ }
111
+ }
112
+ for (const folder of projectFolders(configDirs)) {
113
+ for (const name of listEntries(folder)) {
114
+ if (!name.endsWith(".jsonl")) continue
115
+ const file = path.join(folder, name)
116
+ if (recent(file)) files.push(file)
117
+ const subagents = subagentsDir(file)
118
+ for (const agent of listEntries(subagents)) if (agent.endsWith(".jsonl") && recent(path.join(subagents, agent))) files.push(path.join(subagents, agent))
119
+ }
120
+ }
121
+ return files.sort()
122
+ }
123
+
124
+ export function readUsage(files: string[], since: number): UsageEntry[] {
125
+ const seen = new Map<string, { usage: RawUsage; entry: UsageEntry }>()
126
+ for (const file of files) {
127
+ let text: string
128
+ try {
129
+ text = readFileSync(file, "utf8")
130
+ } catch {
131
+ continue
132
+ }
133
+ for (const [index, raw] of text.split("\n").entries()) {
134
+ if (!raw.includes('"usage"')) continue
135
+ let line: RawLine
136
+ try {
137
+ line = JSON.parse(raw)
138
+ } catch {
139
+ continue
140
+ }
141
+ const usage = line.message?.usage
142
+ const at = Date.parse(line.timestamp ?? "")
143
+ if (!usage || !(at >= since) || line.message?.model === "<synthetic>") continue
144
+ const key = line.message?.id ? `${line.message.id}:${line.requestId ?? ""}` : `${file}:${index}`
145
+ const known = seen.get(key)
146
+ if (known && !supersedes(usage, known.usage)) continue
147
+ seen.set(key, { usage, entry: entryOf(line, usage, at) })
148
+ }
149
+ }
150
+ return [...seen.values()].filter(({ usage }) => !isPlaceholder(usage)).map(({ entry }) => entry)
151
+ }