@chroxify/eunoe 1.0.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.
Files changed (88) hide show
  1. package/README.md +68 -9
  2. package/package.json +18 -2
  3. package/src/cli/{types.ts → command.ts} +0 -4
  4. package/src/cli/commands/index.ts +13 -1
  5. package/src/cli/commands/load.ts +44 -0
  6. package/src/cli/commands/mode.ts +41 -33
  7. package/src/cli/commands/peek.ts +22 -0
  8. package/src/cli/commands/replay.ts +245 -0
  9. package/src/cli/commands/serve.ts +22 -7
  10. package/src/cli/commands/sessions.ts +70 -0
  11. package/src/cli/commands/settings.ts +79 -60
  12. package/src/cli/commands/start.ts +56 -21
  13. package/src/cli/commands/stats.ts +233 -0
  14. package/src/cli/commands/status.ts +98 -55
  15. package/src/cli/commands/stop.ts +8 -6
  16. package/src/cli/commands/uninstall.ts +10 -12
  17. package/src/cli/commands/watch.ts +16 -0
  18. package/src/cli/logo/art.ts +99 -0
  19. package/src/cli/logo/index.ts +12 -18
  20. package/src/cli/main.ts +3 -3
  21. package/src/cli/proxy/claude-settings.ts +69 -0
  22. package/src/cli/proxy/inspect.ts +67 -0
  23. package/src/cli/proxy/launchd.ts +53 -0
  24. package/src/cli/proxy/live.ts +57 -0
  25. package/src/cli/proxy/service.ts +53 -0
  26. package/src/cli/{stats.ts → proxy/stats.ts} +29 -16
  27. package/src/cli/proxy/watch.ts +60 -0
  28. package/src/cli/replay/index.ts +146 -0
  29. package/src/cli/replay/worker.ts +20 -0
  30. package/src/cli/sessions/browse.ts +88 -0
  31. package/src/cli/sessions/index.ts +76 -0
  32. package/src/cli/sessions/source.ts +74 -0
  33. package/src/cli/{help.ts → terminal/help.ts} +24 -8
  34. package/src/cli/terminal/prompt.ts +114 -0
  35. package/src/cli/{ui.ts → terminal/ui.ts} +55 -24
  36. package/src/cli/usage/index.ts +151 -0
  37. package/src/config/index.ts +99 -34
  38. package/src/config/paths.ts +17 -0
  39. package/src/context/content.ts +40 -14
  40. package/src/context/fold/evidence.ts +145 -0
  41. package/src/context/fold/index.ts +65 -0
  42. package/src/context/fold/strategies.ts +94 -0
  43. package/src/context/fold/turns.ts +76 -0
  44. package/src/context/prompts.ts +141 -22
  45. package/src/context/recall/index.ts +214 -0
  46. package/src/context/recall/rerank.ts +71 -0
  47. package/src/proxy/canonical.ts +17 -9
  48. package/src/proxy/compaction.ts +106 -53
  49. package/src/proxy/estimate.ts +14 -9
  50. package/src/proxy/fingerprint.ts +23 -9
  51. package/src/proxy/handoff.ts +71 -0
  52. package/src/proxy/log.ts +2 -2
  53. package/src/proxy/recall.ts +58 -0
  54. package/src/proxy/rewrite.ts +141 -119
  55. package/src/proxy/server.ts +108 -51
  56. package/src/proxy/sessions.ts +32 -14
  57. package/src/proxy/state.ts +17 -3
  58. package/src/proxy/usage.ts +50 -34
  59. package/src/proxy/window.ts +32 -34
  60. package/src/transcript/format.ts +88 -25
  61. package/src/transcript/locate.ts +38 -17
  62. package/src/transcript/messages.ts +3 -14
  63. package/src/transcript/parse.ts +137 -56
  64. package/src/transcript/render.ts +44 -17
  65. package/src/transcript/search.ts +20 -56
  66. package/src/transcript/subagents.ts +150 -0
  67. package/src/cli/claude-settings.ts +0 -42
  68. package/src/cli/constants.ts +0 -52
  69. package/src/cli/inspect.ts +0 -41
  70. package/src/cli/launchd.ts +0 -39
  71. package/src/cli/logo/pixels.ts +0 -34
  72. package/src/cli/prompt.ts +0 -91
  73. package/src/cli/service.ts +0 -33
  74. package/src/cli/sessions.ts +0 -25
  75. package/src/config/constants.ts +0 -39
  76. package/src/config/types.ts +0 -50
  77. package/src/context/constants.ts +0 -42
  78. package/src/context/evidence.ts +0 -92
  79. package/src/context/recall.ts +0 -155
  80. package/src/context/rerank.ts +0 -44
  81. package/src/context/strategies.ts +0 -89
  82. package/src/context/turns.ts +0 -87
  83. package/src/context/types.ts +0 -66
  84. package/src/proxy/constants.ts +0 -29
  85. package/src/proxy/types.ts +0 -82
  86. package/src/transcript/constants.ts +0 -15
  87. package/src/transcript/types.ts +0 -43
  88. /package/src/cli/{args.ts → terminal/args.ts} +0 -0
@@ -1,10 +1,22 @@
1
- import { loadConfig } from "../../config"
1
+ import { readConfig } from "../../config"
2
2
  import { serve as startProxy } from "../../proxy/server"
3
3
  import { VERSION } from "../../version"
4
- import { proxyUrl } from "../claude-settings"
5
- import { probe } from "../inspect"
6
- import type { Command } from "../types"
7
- import { c, fail, line } from "../ui"
4
+ import type { Command } from "../command"
5
+ import { proxyUrl } from "../proxy/claude-settings"
6
+ import { probe } from "../proxy/inspect"
7
+ import { c, fail, line, warn } from "../terminal/ui"
8
+
9
+ async function bind(port: number) {
10
+ for (let attempt = 1; ; attempt++) {
11
+ try {
12
+ return startProxy(port)
13
+ } catch (error) {
14
+ if (attempt >= 30) throw error
15
+ warn(`Port ${port} is busy (${String(error)}), trying again in 1000ms`)
16
+ await Bun.sleep(1_000)
17
+ }
18
+ }
19
+ }
8
20
 
9
21
  export const serve: Command = {
10
22
  summary: "Run the proxy in the foreground",
@@ -12,9 +24,12 @@ export const serve: Command = {
12
24
  details: ["`eunoe start` runs this for you in the background. Use it directly where there is no launchd."],
13
25
  hidden: true,
14
26
  async run() {
15
- const config = loadConfig()
27
+ const config = readConfig()
16
28
  if (await probe(config.port)) fail(`Something is already serving eunoe on port ${config.port}`, "Stop it first, or set another port in ~/.eunoe/config.json.")
17
- startProxy(config.port)
29
+ const keepGoing = (error: unknown) => warn(`eunoe kept going after an error: ${String(error)}`)
30
+ process.on("uncaughtException", keepGoing)
31
+ process.on("unhandledRejection", keepGoing)
32
+ await bind(config.port)
18
33
  line(`${c.bold("eunoe")} ${c.dim(VERSION)} listening on ${c.cyan(proxyUrl(config.port))} ${c.dim(`mode ${config.mode} · Ctrl-C to stop`)}`)
19
34
  },
20
35
  }
@@ -0,0 +1,70 @@
1
+ import { readConfig } from "../../config"
2
+ import type { Command } from "../command"
3
+ import { routedDirs } from "../proxy/inspect"
4
+ import { askOf, candidatesHere, continuePrompt, copy, metaOf, shortId } from "../sessions"
5
+ import { browse, filterCandidates, UNTITLED } from "../sessions/browse"
6
+ import type { SessionCandidate } from "../sessions/source"
7
+ import { interactive } from "../terminal/prompt"
8
+ import { c, fail, line, ok, warn, wrap } from "../terminal/ui"
9
+
10
+ const INDENT = 6
11
+
12
+ const askWidth = () => Math.max(40, Math.min(100, (process.stdout.columns ?? 100 + INDENT) - INDENT - 12))
13
+
14
+ function ask(label: string, text: string | undefined) {
15
+ const [first = "", ...rest] = wrap(askOf(text), askWidth())
16
+ line(`${" ".repeat(INDENT)}${c.dim(label)} ${first}`)
17
+ for (const more of rest) line(`${" ".repeat(INDENT + label.length + 2)}${more}`)
18
+ }
19
+
20
+ function show(cand: SessionCandidate) {
21
+ const title = cand.label ? c.bold(cand.label) : c.dim(UNTITLED)
22
+ line(` ${c.cyan(shortId(cand.id))} ${title} ${c.dim(metaOf(cand))}`)
23
+ ask("first", cand.asks[0])
24
+ ask("last ", cand.asks.at(-1))
25
+ line()
26
+ }
27
+
28
+ function listSessions(words: string, candidates: SessionCandidate[]) {
29
+ const order = filterCandidates(words, candidates)
30
+ line()
31
+ line(` ${c.dim(words ? `Earlier sessions here, closest to "${words}" first` : "Earlier sessions here, newest first")}`)
32
+ line()
33
+ for (const cand of order) show(cand)
34
+ if (order.length === 0) line(` ${c.dim(`Nothing matches "${words}".`)}`)
35
+ }
36
+
37
+ function handOff(id: string) {
38
+ const prompt = continuePrompt(id)
39
+ line()
40
+ ok(`${c.bold(prompt)} ${copy(prompt) ? c.dim("copied") : ""}`)
41
+ if (routedDirs(readConfig()).length === 0) warn("Claude Code isn't routed through eunoe, so the agent won't know to load it (eunoe start).")
42
+ line()
43
+ }
44
+
45
+ export const sessions: Command = {
46
+ summary: "Pick an earlier session for the agent to continue from",
47
+ usage: "eunoe sessions [words] [--list]",
48
+ details: [
49
+ "Browse this directory's earlier Claude Code sessions: type to filter, pick",
50
+ "one, and a {ee:continue:<id>} marker is copied. Paste it into any Claude Code",
51
+ "chat along with what you want done: eunoe attaches that whole session to the",
52
+ "message, and its dropped output stays searchable from then on. Words start",
53
+ "the filter.",
54
+ "",
55
+ "--list, or a pipe, prints the sessions newest first with each one's first",
56
+ "and last request. That is what the agent reads when it runs this itself.",
57
+ ],
58
+ options: [["--list", "Print the sessions instead of browsing them"]],
59
+ async run(args) {
60
+ const words = args
61
+ .filter((a) => !a.startsWith("-"))
62
+ .join(" ")
63
+ .trim()
64
+ const candidates = candidatesHere()
65
+ if (candidates.length === 0) fail("No earlier sessions in this directory.")
66
+ if (args.includes("--list") || !interactive) return listSessions(words, candidates)
67
+ const chosen = await browse("Earlier sessions here", candidates, words, "↑↓ move · type to filter · enter copy the marker · esc quit")
68
+ if (chosen) handOff(chosen.id)
69
+ },
70
+ }
@@ -1,66 +1,95 @@
1
- import { loadConfig, overrides, parseTunable, saveConfig } from "../../config"
2
- import { DEFAULTS, TUNABLES } from "../../config/constants"
3
- import type { Tunable } from "../../config/types"
4
- import { TUNABLE_DESCRIPTIONS, TUNABLE_HINTS } from "../constants"
5
- import { input, interactive, select } from "../prompt"
1
+ import { DEFAULTS, overrides, parseTunable, readConfig, saveConfig, TUNABLES, type Tunable } from "../../config"
2
+ import type { Command } from "../command"
6
3
  import { sessionArgs, shortId } from "../sessions"
7
- import type { Command } from "../types"
8
- import { c, fail, line, ok, rows } from "../ui"
4
+ import { input, interactive, select } from "../terminal/prompt"
5
+ import { c, fail, line, ok, rows } from "../terminal/ui"
6
+
7
+ const DESCRIPTIONS: Record<Tunable, string> = {
8
+ keep: "Turns kept whole at the end, in default mode",
9
+ budget: "Tokens of older detail that trigger a fold, in default mode",
10
+ turnCap: "A kept turn larger than this many tokens is folded at once, live turn excepted; or off",
11
+ compactAt: "Share of the context window that triggers the cut",
12
+ keepTurns: "Earlier turns kept after a cut",
13
+ search: "How transcripts are written for the agent to search",
14
+ rerank: "Typesafe model that reorders recall, or off",
15
+ }
16
+
17
+ const HINTS: Record<Tunable, string> = {
18
+ keep: "a whole number, 1 or more",
19
+ budget: "a token count, 1000 or more",
20
+ turnCap: "a token count, 1000 or more, or off",
21
+ compactAt: "a number above 0, up to 1",
22
+ keepTurns: "a whole number or all",
23
+ search: "markdown, xml, jsonl or qmd",
24
+ rerank: "a model name such as jev-preview, or off",
25
+ }
9
26
 
10
27
  const RESET = "default"
11
28
 
29
+ const forSessions = (ids: string[]) => ids.map(shortId).join(", ")
30
+
12
31
  function listSettings() {
13
- const config = loadConfig()
32
+ const config = readConfig()
14
33
  line()
15
34
  const width = Math.max(...TUNABLES.map((name) => String(config[name]).length))
16
- rows(TUNABLES.map((name) => {
17
- const value = String(config[name])
18
- const changed = config[name] !== DEFAULTS[name]
19
- return [name, `${c.bold(value.padEnd(width))} ${c.dim(TUNABLE_DESCRIPTIONS[name])}${changed ? c.dim(` (default ${DEFAULTS[name]})`) : ""}`]
20
- }))
21
- const own = Object.entries(config.sessions).map(([id, settings]) => [id, overrides(settings)] as const).filter(([, values]) => Object.keys(values).length)
22
- if (own.length) {
35
+ rows(
36
+ TUNABLES.map((name) => {
37
+ const changed = config[name] !== DEFAULTS[name]
38
+ return [name, `${c.bold(String(config[name]).padEnd(width))} ${c.dim(DESCRIPTIONS[name])}${changed ? c.dim(` (default ${DEFAULTS[name]})`) : ""}`]
39
+ }),
40
+ )
41
+ const own = Object.entries(config.sessions)
42
+ .map(([id, session]) => [id, overrides(session)] as const)
43
+ .filter(([, values]) => Object.keys(values).length > 0)
44
+ if (own.length > 0) {
23
45
  line()
24
- for (const [id, values] of own) line(` ${c.cyan(shortId(id))} ${Object.entries(values).map(([name, value]) => `${name} ${c.bold(String(value))}`).join(c.dim(" · "))}`)
46
+ for (const [id, values] of own) {
47
+ const shown = Object.entries(values).map(([name, value]) => `${name} ${c.bold(String(value))}`)
48
+ line(` ${c.cyan(shortId(id))} ${shown.join(c.dim(" · "))}`)
49
+ }
25
50
  }
26
51
  line()
27
52
  }
28
53
 
29
- function applySetting(name: Tunable, raw: string, ids: string[]) {
30
- const config = loadConfig()
31
- if (raw === RESET) {
32
- if (!ids.length) fail(`\`${RESET}\` only applies with --session`, `eunoe settings set ${name} ${RESET} --session <id>`)
33
- const sessions = { ...config.sessions }
34
- for (const id of ids) {
35
- if (!sessions[id]) continue
36
- const { [name]: _, ...rest } = sessions[id]
37
- sessions[id] = rest
38
- }
39
- saveConfig({ sessions })
40
- ok(`${name} back to the default (${c.bold(String(config[name]))}) for ${ids.map(shortId).join(", ")}`)
41
- return
54
+ function resetSetting(name: Tunable, ids: string[]) {
55
+ const config = readConfig()
56
+ if (ids.length === 0) fail(`\`${RESET}\` only applies with --session`, `eunoe settings set ${name} ${RESET} --session <id>`)
57
+ const sessions = { ...config.sessions }
58
+ for (const id of ids) {
59
+ if (!sessions[id]) continue
60
+ const { [name]: _, ...rest } = sessions[id]
61
+ sessions[id] = rest
42
62
  }
63
+ saveConfig({ sessions })
64
+ ok(`${name} back to the default (${c.bold(String(config[name]))}) for ${forSessions(ids)}`)
65
+ }
66
+
67
+ function applySetting(name: Tunable, raw: string, ids: string[]) {
68
+ if (raw === RESET) return resetSetting(name, ids)
69
+ const config = readConfig()
43
70
  const value = parseTunable(name, raw)
44
- if (value === null) fail(`Not a valid ${name}: ${raw}`, `Expected ${TUNABLE_HINTS[name]}.`)
45
- if (ids.length) {
46
- saveConfig({ sessions: { ...config.sessions, ...Object.fromEntries(ids.map((id) => [id, { ...config.sessions[id], [name]: value }])) } })
47
- ok(`${name} ${c.bold(String(value))} for ${ids.map(shortId).join(", ")}`)
48
- if (config.scope === "sessions" && ids.some((id) => !config.sessions[id])) line(` ${c.dim("eunoe only manages sessions you started it for: eunoe start --session <id>")}`)
71
+ if (value === null) fail(`Not a valid ${name}: ${raw}`, `Expected ${HINTS[name]}.`)
72
+ if (ids.length === 0) {
73
+ saveConfig({ [name]: value })
74
+ ok(`${name} ${c.bold(String(value))}`)
49
75
  return
50
76
  }
51
- saveConfig({ [name]: value })
52
- ok(`${name} ${c.bold(String(value))}`)
77
+ saveConfig({ sessions: { ...config.sessions, ...Object.fromEntries(ids.map((id) => [id, { ...config.sessions[id], [name]: value }])) } })
78
+ ok(`${name} ${c.bold(String(value))} for ${forSessions(ids)}`)
79
+ if (config.scope === "sessions" && ids.some((id) => !config.sessions[id])) line(` ${c.dim("eunoe only manages sessions you started it for: eunoe start --session <id>")}`)
53
80
  }
54
81
 
55
82
  async function pickSetting(ids: string[]) {
56
- const config = loadConfig()
83
+ const config = readConfig()
57
84
  line()
58
- const name = await select<Tunable>(ids.length ? `Setting for ${ids.map(shortId).join(", ")}` : "Setting", TUNABLES.map((value) => ({ value, hint: `${String(config[value]).padEnd(12)} ${TUNABLE_DESCRIPTIONS[value]}` })))
85
+ const name = await select<Tunable>(
86
+ ids.length > 0 ? `Setting for ${forSessions(ids)}` : "Setting",
87
+ TUNABLES.map((value) => ({ value, hint: `${String(config[value]).padEnd(12)} ${DESCRIPTIONS[value]}` })),
88
+ )
59
89
  if (name === null) return
60
- const current = ids.length === 1 ? config.sessions[ids[0]]?.[name] ?? config[name] : config[name]
61
- const raw = await input(`${name} ${c.dim(TUNABLE_HINTS[name])}`, `${current} (current)`)
62
- if (raw === null || raw === "") return
63
- applySetting(name, raw, ids)
90
+ const current = ids.length === 1 ? (config.sessions[ids[0]]?.[name] ?? config[name]) : config[name]
91
+ const raw = await input(`${name} ${c.dim(HINTS[name])}`, `${current} (current)`)
92
+ if (raw) applySetting(name, raw, ids)
64
93
  }
65
94
 
66
95
  export const settings: Command = {
@@ -68,33 +97,23 @@ export const settings: Command = {
68
97
  usage: "eunoe settings [list | set <key> <value>] [--session <id>]",
69
98
  details: [
70
99
  "Without arguments it opens a picker. Keys:",
71
- ...TUNABLES.map((key) => ` ${key.padEnd(10)} ${TUNABLE_DESCRIPTIONS[key]} ${c.dim(`(${TUNABLE_HINTS[key]})`)}`),
100
+ ...TUNABLES.map((key) => ` ${key.padEnd(10)} ${DESCRIPTIONS[key]} ${c.dim(`(${HINTS[key]})`)}`),
72
101
  "",
73
102
  `With --session, the value applies to that session only; \`${RESET}\` removes its own value.`,
74
103
  "Takes effect on the next request; no restart needed.",
75
104
  ],
76
105
  options: [["--session <id>", "Change it for just this session (repeatable; a unique prefix is enough)"]],
77
- async run(args) {
78
- const words = args.filter((arg, i) => !arg.startsWith("-") && args[i - 1] !== "--session")
106
+ run(args) {
107
+ const [verb, key, raw] = args.filter((arg, i) => !arg.startsWith("-") && args[i - 1] !== "--session")
79
108
  const ids = sessionArgs(args)
80
- const [verb, key, raw] = words
81
- if (!verb) {
82
- if (interactive) await pickSetting(ids)
83
- else listSettings()
84
- return
85
- }
86
- if (verb === "list") {
87
- listSettings()
88
- return
89
- }
90
- if (verb !== "set") fail(`Unknown subcommand: ${verb}`, "Use `eunoe settings list` or `eunoe settings set <key> <value>`.")
109
+ if (verb === "list" || (!verb && !interactive)) return listSettings()
110
+ if (verb && verb !== "set") fail(`Unknown subcommand: ${verb}`, "Use `eunoe settings list` or `eunoe settings set <key> <value>`.")
91
111
  if (!key) {
92
- if (interactive) await pickSetting(ids)
93
- else fail("set needs a key and a value", "eunoe settings set <key> <value>")
94
- return
112
+ if (!interactive) fail("set needs a key and a value", "eunoe settings set <key> <value>")
113
+ return pickSetting(ids)
95
114
  }
96
115
  if (!TUNABLES.includes(key as Tunable)) fail(`Unknown setting: ${key}`, `Choose ${TUNABLES.join(", ")}.`)
97
- if (raw === undefined) fail(`${key} needs a value`, `eunoe settings set ${key} <${TUNABLE_HINTS[key as Tunable]}>`)
116
+ if (raw === undefined) fail(`${key} needs a value`, `eunoe settings set ${key} <${HINTS[key as Tunable]}>`)
98
117
  applySetting(key as Tunable, raw, ids)
99
118
  },
100
119
  }
@@ -1,18 +1,20 @@
1
1
  import { existsSync } from "node:fs"
2
2
  import path from "node:path"
3
- import { claudeConfigDirs, compactionOwner, loadConfig, saveConfig } from "../../config"
4
- import type { Config } from "../../config/types"
5
- import { flagValues } from "../args"
6
- import { editSettings, withEunoe } from "../claude-settings"
7
- import { MODE_DESCRIPTIONS } from "../constants"
8
- import { routedDirs } from "../inspect"
9
- import { startService } from "../service"
3
+ import { type Config, claudeConfigDirs, compactionOwner, modeFor, readConfig, saveConfig } from "../../config"
4
+ import { seenSessions } from "../../proxy/sessions"
5
+ import type { Command } from "../command"
6
+ import { editSettings, withEunoe } from "../proxy/claude-settings"
7
+ import { routedDirs } from "../proxy/inspect"
8
+ import { isIdle, isTerminal, type LiveSession, listLiveSessions, restartSessions, unroutedSessions } from "../proxy/live"
9
+ import { startService } from "../proxy/service"
10
+ import { clearWatch } from "../proxy/watch"
10
11
  import { isKnown, sessionArgs, shortId } from "../sessions"
11
- import type { Command } from "../types"
12
- import { c, fail, line, listFiles, note, ok, rows, tilde } from "../ui"
12
+ import { flagValues } from "../terminal/args"
13
+ import { c, fail, line, listFiles, note, ok, plural, rows, tilde } from "../terminal/ui"
14
+ import { MODE_DESCRIPTIONS } from "./mode"
13
15
 
14
16
  function scoped(config: Config, ids: string[]): Partial<Config> {
15
- if (!ids.length) return { scope: "all" }
17
+ if (ids.length === 0) return { scope: "all" }
16
18
  const managingAll = config.scope === "all" && routedDirs(config).length > 0
17
19
  const sessions = { ...config.sessions }
18
20
  for (const id of ids) {
@@ -22,13 +24,42 @@ function scoped(config: Config, ids: string[]): Partial<Config> {
22
24
  return { scope: managingAll ? "all" : "sessions", sessions }
23
25
  }
24
26
 
27
+ function managedSessions(config: Config): string {
28
+ if (config.scope === "all") return "all"
29
+ const ids = Object.entries(config.sessions)
30
+ .filter(([, own]) => own.mode !== "off")
31
+ .map(([id]) => shortId(id))
32
+ return `only ${ids.join(", ")}`
33
+ }
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
+
25
54
  export const start: Command = {
26
55
  summary: "Route Claude Code through eunoe",
27
- usage: "eunoe start [--session <id>] [--config-dir <dir>]",
56
+ usage: "eunoe start [--session <id>] [--config-dir <dir>] [--no-restart]",
28
57
  details: [
29
58
  "Starts the background proxy if it isn't running (setting it up the first time)",
30
59
  "and points Claude Code at it through ANTHROPIC_BASE_URL in settings.json.",
31
- "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.",
32
63
  "",
33
64
  "With --session, eunoe manages only the sessions you name and passes the rest",
34
65
  "through untouched. Session ids are in Claude Code's /status, or `eunoe status`.",
@@ -36,29 +67,33 @@ export const start: Command = {
36
67
  options: [
37
68
  ["--session <id>", "Only manage this session (repeatable; a unique prefix is enough)"],
38
69
  ["--config-dir <dir>", "Also manage this Claude config directory (repeatable, remembered)"],
70
+ ["--no-restart", "Leave running sessions alone"],
39
71
  ],
40
72
  async run(args) {
41
73
  const added = flagValues(args, "--config-dir").map((dir) => path.resolve(dir))
42
74
  for (const dir of added) if (!existsSync(dir)) fail(`No such directory: ${tilde(dir)}`)
43
- if (added.length) saveConfig({ claudeConfigDirs: [...new Set([...loadConfig().claudeConfigDirs, ...added])] })
75
+ if (added.length > 0) saveConfig({ claudeConfigDirs: [...new Set([...readConfig().claudeConfigDirs, ...added])] })
44
76
  const ids = sessionArgs(args)
45
- saveConfig(scoped(loadConfig(), ids))
46
- const config = loadConfig()
77
+ saveConfig(scoped(readConfig(), ids))
78
+ const config = readConfig()
47
79
 
48
80
  line()
49
- await startService(config)
81
+ if (!(await startService(config))) fail("Claude Code was left alone because the proxy isn't up.", "Nothing is routed through a proxy that can't answer. Fix the proxy and run `eunoe start` again.")
82
+ clearWatch()
50
83
  const touched = editSettings(claudeConfigDirs(config), (settings) => withEunoe(settings, config.port, compactionOwner(config)))
51
84
  ok("Claude Code routed through eunoe")
52
- listFiles(touched.length ? touched : routedDirs(config).map((dir) => path.join(dir, "settings.json")))
85
+ listFiles(touched.length > 0 ? touched : routedDirs(config).map((dir) => path.join(dir, "settings.json")))
53
86
  line()
54
87
  rows([
55
88
  ["Mode", `${c.bold(config.mode)} ${c.dim(MODE_DESCRIPTIONS[config.mode])}`],
56
- ["Sessions", config.scope === "all" ? "all" : `only ${Object.entries(config.sessions).filter(([, own]) => own.mode !== "off").map(([id]) => shortId(id)).join(", ")}`],
89
+ ["Sessions", managedSessions(config)],
57
90
  ])
58
91
  line()
59
- const unseen = ids.filter((id) => !isKnown(id))
60
- if (unseen.length) 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.`)
61
- else note(ids.length ? "Takes effect on the session's next request." : "New sessions go through eunoe. Restart any that are already running.")
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))
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.`)
96
+ else note(ids.length > 0 ? "Takes effect on the session's next request." : "New sessions go through eunoe.")
62
97
  line()
63
98
  },
64
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
+ }