@chroxify/eunoe 0.1.0 → 1.1.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 (80) hide show
  1. package/README.md +364 -239
  2. package/package.json +20 -3
  3. package/src/cli/{types.ts → command.ts} +1 -4
  4. package/src/cli/commands/index.ts +27 -3
  5. package/src/cli/commands/load.ts +44 -0
  6. package/src/cli/commands/mode.ts +68 -24
  7. package/src/cli/commands/peek.ts +22 -0
  8. package/src/cli/commands/replay.ts +194 -0
  9. package/src/cli/commands/serve.ts +24 -8
  10. package/src/cli/commands/sessions.ts +70 -0
  11. package/src/cli/commands/settings.ts +119 -0
  12. package/src/cli/commands/start.ts +73 -0
  13. package/src/cli/commands/status.ts +107 -30
  14. package/src/cli/commands/stop.ts +36 -12
  15. package/src/cli/commands/uninstall.ts +14 -14
  16. package/src/cli/commands/watch.ts +16 -0
  17. package/src/cli/logo/art.ts +99 -0
  18. package/src/cli/logo/index.ts +12 -18
  19. package/src/cli/main.ts +3 -3
  20. package/src/cli/proxy/claude-settings.ts +59 -0
  21. package/src/cli/proxy/inspect.ts +67 -0
  22. package/src/cli/proxy/launchd.ts +53 -0
  23. package/src/cli/proxy/service.ts +53 -0
  24. package/src/cli/proxy/stats.ts +81 -0
  25. package/src/cli/proxy/watch.ts +60 -0
  26. package/src/cli/replay/index.ts +141 -0
  27. package/src/cli/replay/worker.ts +15 -0
  28. package/src/cli/sessions/browse.ts +88 -0
  29. package/src/cli/sessions/index.ts +76 -0
  30. package/src/cli/sessions/source.ts +74 -0
  31. package/src/cli/{args.ts → terminal/args.ts} +1 -1
  32. package/src/cli/{help.ts → terminal/help.ts} +25 -9
  33. package/src/cli/terminal/prompt.ts +114 -0
  34. package/src/cli/terminal/ui.ts +137 -0
  35. package/src/config/index.ts +128 -14
  36. package/src/config/{constants.ts → paths.ts} +4 -14
  37. package/src/context/content.ts +51 -3
  38. package/src/context/fold/evidence.ts +145 -0
  39. package/src/context/fold/index.ts +65 -0
  40. package/src/context/fold/strategies.ts +94 -0
  41. package/src/context/fold/turns.ts +76 -0
  42. package/src/context/prompts.ts +141 -22
  43. package/src/context/recall/index.ts +214 -0
  44. package/src/context/recall/rerank.ts +71 -0
  45. package/src/proxy/canonical.ts +40 -0
  46. package/src/proxy/compaction.ts +122 -52
  47. package/src/proxy/estimate.ts +14 -9
  48. package/src/proxy/fingerprint.ts +29 -0
  49. package/src/proxy/handoff.ts +71 -0
  50. package/src/proxy/log.ts +2 -2
  51. package/src/proxy/recall.ts +58 -0
  52. package/src/proxy/rewrite.ts +152 -56
  53. package/src/proxy/server.ts +122 -47
  54. package/src/proxy/sessions.ts +64 -0
  55. package/src/proxy/state.ts +17 -3
  56. package/src/proxy/usage.ts +50 -34
  57. package/src/proxy/window.ts +32 -34
  58. package/src/transcript/format.ts +88 -25
  59. package/src/transcript/locate.ts +38 -17
  60. package/src/transcript/messages.ts +18 -0
  61. package/src/transcript/parse.ts +137 -56
  62. package/src/transcript/render.ts +44 -17
  63. package/src/transcript/search.ts +20 -56
  64. package/src/transcript/subagents.ts +150 -0
  65. package/src/cli/claude-settings.ts +0 -42
  66. package/src/cli/commands/install.ts +0 -44
  67. package/src/cli/constants.ts +0 -29
  68. package/src/cli/inspect.ts +0 -41
  69. package/src/cli/launchd.ts +0 -34
  70. package/src/cli/logo/pixels.ts +0 -34
  71. package/src/cli/ui.ts +0 -91
  72. package/src/config/types.ts +0 -21
  73. package/src/context/constants.ts +0 -7
  74. package/src/context/strategies.ts +0 -89
  75. package/src/context/turns.ts +0 -82
  76. package/src/context/types.ts +0 -24
  77. package/src/proxy/constants.ts +0 -19
  78. package/src/proxy/types.ts +0 -58
  79. package/src/transcript/constants.ts +0 -16
  80. package/src/transcript/types.ts +0 -43
@@ -0,0 +1,119 @@
1
+ import { DEFAULTS, overrides, parseTunable, readConfig, saveConfig, TUNABLES, type Tunable } from "../../config"
2
+ import type { Command } from "../command"
3
+ import { sessionArgs, shortId } from "../sessions"
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
+ }
26
+
27
+ const RESET = "default"
28
+
29
+ const forSessions = (ids: string[]) => ids.map(shortId).join(", ")
30
+
31
+ function listSettings() {
32
+ const config = readConfig()
33
+ line()
34
+ const width = Math.max(...TUNABLES.map((name) => String(config[name]).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) {
45
+ line()
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
+ }
50
+ }
51
+ line()
52
+ }
53
+
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
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()
70
+ const value = parseTunable(name, raw)
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))}`)
75
+ return
76
+ }
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>")}`)
80
+ }
81
+
82
+ async function pickSetting(ids: string[]) {
83
+ const config = readConfig()
84
+ line()
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
+ )
89
+ if (name === null) return
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)
93
+ }
94
+
95
+ export const settings: Command = {
96
+ summary: "List or change settings, for every session or just one",
97
+ usage: "eunoe settings [list | set <key> <value>] [--session <id>]",
98
+ details: [
99
+ "Without arguments it opens a picker. Keys:",
100
+ ...TUNABLES.map((key) => ` ${key.padEnd(10)} ${DESCRIPTIONS[key]} ${c.dim(`(${HINTS[key]})`)}`),
101
+ "",
102
+ `With --session, the value applies to that session only; \`${RESET}\` removes its own value.`,
103
+ "Takes effect on the next request; no restart needed.",
104
+ ],
105
+ options: [["--session <id>", "Change it for just this session (repeatable; a unique prefix is enough)"]],
106
+ run(args) {
107
+ const [verb, key, raw] = args.filter((arg, i) => !arg.startsWith("-") && args[i - 1] !== "--session")
108
+ const ids = sessionArgs(args)
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>`.")
111
+ if (!key) {
112
+ if (!interactive) fail("set needs a key and a value", "eunoe settings set <key> <value>")
113
+ return pickSetting(ids)
114
+ }
115
+ if (!TUNABLES.includes(key as Tunable)) fail(`Unknown setting: ${key}`, `Choose ${TUNABLES.join(", ")}.`)
116
+ if (raw === undefined) fail(`${key} needs a value`, `eunoe settings set ${key} <${HINTS[key as Tunable]}>`)
117
+ applySetting(key as Tunable, raw, ids)
118
+ },
119
+ }
@@ -0,0 +1,73 @@
1
+ import { existsSync } from "node:fs"
2
+ import path from "node:path"
3
+ import { type Config, claudeConfigDirs, compactionOwner, readConfig, saveConfig } from "../../config"
4
+ import type { Command } from "../command"
5
+ import { editSettings, withEunoe } from "../proxy/claude-settings"
6
+ import { routedDirs } from "../proxy/inspect"
7
+ import { startService } from "../proxy/service"
8
+ import { clearWatch } from "../proxy/watch"
9
+ import { isKnown, sessionArgs, shortId } from "../sessions"
10
+ import { flagValues } from "../terminal/args"
11
+ import { c, fail, line, listFiles, note, ok, rows, tilde } from "../terminal/ui"
12
+ import { MODE_DESCRIPTIONS } from "./mode"
13
+
14
+ function scoped(config: Config, ids: string[]): Partial<Config> {
15
+ if (ids.length === 0) return { scope: "all" }
16
+ const managingAll = config.scope === "all" && routedDirs(config).length > 0
17
+ const sessions = { ...config.sessions }
18
+ for (const id of ids) {
19
+ const { mode, ...rest } = sessions[id] ?? {}
20
+ sessions[id] = mode && mode !== "off" ? { mode, ...rest } : rest
21
+ }
22
+ return { scope: managingAll ? "all" : "sessions", sessions }
23
+ }
24
+
25
+ function managedSessions(config: Config): string {
26
+ if (config.scope === "all") return "all"
27
+ const ids = Object.entries(config.sessions)
28
+ .filter(([, own]) => own.mode !== "off")
29
+ .map(([id]) => shortId(id))
30
+ return `only ${ids.join(", ")}`
31
+ }
32
+
33
+ export const start: Command = {
34
+ summary: "Route Claude Code through eunoe",
35
+ usage: "eunoe start [--session <id>] [--config-dir <dir>]",
36
+ details: [
37
+ "Starts the background proxy if it isn't running (setting it up the first time)",
38
+ "and points Claude Code at it through ANTHROPIC_BASE_URL in settings.json.",
39
+ "New sessions pick it up; restart the ones already running.",
40
+ "",
41
+ "With --session, eunoe manages only the sessions you name and passes the rest",
42
+ "through untouched. Session ids are in Claude Code's /status, or `eunoe status`.",
43
+ ],
44
+ options: [
45
+ ["--session <id>", "Only manage this session (repeatable; a unique prefix is enough)"],
46
+ ["--config-dir <dir>", "Also manage this Claude config directory (repeatable, remembered)"],
47
+ ],
48
+ async run(args) {
49
+ const added = flagValues(args, "--config-dir").map((dir) => path.resolve(dir))
50
+ for (const dir of added) if (!existsSync(dir)) fail(`No such directory: ${tilde(dir)}`)
51
+ if (added.length > 0) saveConfig({ claudeConfigDirs: [...new Set([...readConfig().claudeConfigDirs, ...added])] })
52
+ const ids = sessionArgs(args)
53
+ saveConfig(scoped(readConfig(), ids))
54
+ const config = readConfig()
55
+
56
+ line()
57
+ 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.")
58
+ clearWatch()
59
+ const touched = editSettings(claudeConfigDirs(config), (settings) => withEunoe(settings, config.port, compactionOwner(config)))
60
+ ok("Claude Code routed through eunoe")
61
+ listFiles(touched.length > 0 ? touched : routedDirs(config).map((dir) => path.join(dir, "settings.json")))
62
+ line()
63
+ rows([
64
+ ["Mode", `${c.bold(config.mode)} ${c.dim(MODE_DESCRIPTIONS[config.mode])}`],
65
+ ["Sessions", managedSessions(config)],
66
+ ])
67
+ line()
68
+ const unseen = ids.filter((id) => !isKnown(id))
69
+ 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.")
71
+ line()
72
+ },
73
+ }
@@ -1,48 +1,125 @@
1
1
  import path from "node:path"
2
- import { loadConfig } from "../../config"
2
+ import { type Config, modeFor, overrides, readConfig } from "../../config"
3
+ import { paths } from "../../config/paths"
4
+ import { rerankKey } from "../../context/recall/rerank"
5
+ import type { Health } from "../../proxy/server"
6
+ import { seenSessions } from "../../proxy/sessions"
3
7
  import { VERSION } from "../../version"
4
- import { proxyUrl } from "../claude-settings"
5
- import { MODE_DESCRIPTIONS } from "../constants"
6
- import { probe, recentRequests, routedDirs } from "../inspect"
7
- import * as launchd from "../launchd"
8
- import type { Command } from "../types"
9
- import { ago, c, line, rows, tilde, tokens, warn } from "../ui"
8
+ import type { Command } from "../command"
9
+ import { BLOCKING_LIMIT_ENV, proxyUrl, readSettings } from "../proxy/claude-settings"
10
+ import { probe, RECENT_REQUESTS, type RequestRecord, recentRequests, routedDirs, runsStaleCode } from "../proxy/inspect"
11
+ import * as launchd from "../proxy/launchd"
12
+ import { PROXY_AGENT } from "../proxy/service"
13
+ import { perSession, type SessionStats, savedShare, type Totals, totals, withinWindow } from "../proxy/stats"
14
+ import { readWatch } from "../proxy/watch"
15
+ import { shortId } from "../sessions"
16
+ import { ago, c, line, percent, plural, rows, table, tilde, tokens, warn } from "../terminal/ui"
17
+ import { MODE_DESCRIPTIONS } from "./mode"
18
+
19
+ const dot = (on: boolean) => (on ? c.green("●") : c.red("○"))
20
+
21
+ const settingsFiles = (dirs: string[]) => dirs.map((dir) => tilde(path.join(dir, "settings.json"))).join(", ")
22
+
23
+ function summary(title: string, sum: Totals) {
24
+ const hit = sum.cacheRead + sum.cacheWrite + sum.uncached
25
+ const folds = sum.folds && sum.lastFold ? ` ${c.dim(`${plural(sum.folds, "fold")}, last ${ago(sum.lastFold)}`)}` : ""
26
+ const hitRate = hit ? c.dim(` (${percent(sum.cacheRead / hit)} hit)`) : ""
27
+ const saved = sum.saved > 0 ? `${c.bold(c.green(tokens(sum.saved)))} input tokens not sent ${c.dim(`(${percent(sum.saved / (sum.sent + sum.saved))} less than plain Claude Code)`)}` : c.dim("no data yet")
28
+ line()
29
+ line(` ${title}`)
30
+ rows(
31
+ [
32
+ ["Requests", `${c.bold(String(sum.requests))}${folds}`],
33
+ ["Cache", `${c.bold(tokens(sum.cacheRead))} read ${c.dim("·")} ${tokens(sum.cacheWrite)} written ${c.dim("·")} ${tokens(sum.uncached)} uncached${hitRate}`],
34
+ ["Saved", saved],
35
+ ],
36
+ 4,
37
+ )
38
+ }
39
+
40
+ function recallLine(config: Config, recent: RequestRecord[]) {
41
+ if (config.rerank === "off") return "bm25"
42
+ if (!rerankKey()) return `bm25 ${c.dim(`(${config.rerank} needs a key in ${tilde(paths.typesafeKey)} or TYPESAFE_API_KEY)`)}`
43
+ const last = recent.findLast((r) => r.reranked || r.rerankError)
44
+ if (last?.rerankError) return `bm25 ${c.yellow(`(${config.rerank} failing: ${last.rerankError.replace(/^[^:]+: /, "")}, last ${ago(last.ts)})`)}`
45
+ return `bm25 ${c.dim("→")} ${config.rerank}`
46
+ }
47
+
48
+ function sessionRow(config: Config, stats: Map<string, SessionStats>, [id, session]: ReturnType<typeof seenSessions>[number]) {
49
+ const active = modeFor(config, id)
50
+ const own = Object.entries(overrides(config.sessions[id]))
51
+ .map(([name, value]) => `${name} ${value}`)
52
+ .join(" · ")
53
+ const stat = stats.get(id)
54
+ const managed = active !== "off"
55
+ const saved = stat ? savedShare(stat.context, stat.plain) : null
56
+ return [
57
+ c.cyan(shortId(id)),
58
+ managed ? c.green(active) : c.dim("off"),
59
+ session.cwd ? tilde(session.cwd) : c.dim("–"),
60
+ stat?.turns ? c.dim(`${stat.turns} turns`) : "",
61
+ stat && managed ? `${c.bold(tokens(stat.context))} ctx${stat.fixed ? c.dim(` (${tokens(stat.fixed)} fixed)`) : ""}` : "",
62
+ saved !== null && managed ? (saved > 0 ? c.green(`−${percent(saved)}`) : c.dim("±0")) : "",
63
+ c.dim(ago(session.lastSeen)),
64
+ own ? c.dim(own) : "",
65
+ ]
66
+ }
67
+
68
+ function sessionsTable(config: Config, stats: Map<string, SessionStats>) {
69
+ const sessions = seenSessions().slice(0, 5)
70
+ if (sessions.length === 0) return
71
+ line()
72
+ line(` ${c.dim("Sessions")}`)
73
+ table(sessions.map((entry) => sessionRow(config, stats, entry)))
74
+ }
75
+
76
+ function warnings(config: Config, health: Health | null, routed: string[], recent: RequestRecord[]) {
77
+ const notes: string[] = []
78
+ const watch = readWatch()
79
+ if (watch.paused.length > 0)
80
+ notes.push(`The proxy stopped answering ${watch.pausedAt ? ago(watch.pausedAt) : ""}, so Claude Code was sent straight to the API. Routing comes back by itself once the proxy does; ${c.bold("eunoe start")} forces it.`)
81
+ if (health && health.mode !== config.mode) notes.push(`The running proxy reports mode ${health.mode}; it switches on its next request.`)
82
+ if (health && runsStaleCode(health)) notes.push(`The proxy is running older code than what's installed. ${c.bold("eunoe start")} restarts it.`)
83
+ const unguarded = config.mode !== "off" ? routed.filter((dir) => readSettings(dir).env?.[BLOCKING_LIMIT_ENV] === undefined) : []
84
+ if (unguarded.length > 0) notes.push(`Claude Code can still refuse to send near a full window in ${settingsFiles(unguarded)}. ${c.bold("eunoe start")} fixes it.`)
85
+ const missed = recent.filter((r) => r.overflow || r.fit === "over")
86
+ const lastMissed = missed.at(-1)
87
+ if (lastMissed) notes.push(`${missed.length} request${missed.length === 1 ? "" : "s"} didn't fit the window on the first try, last ${ago(lastMissed.ts)}. Details in ${tilde(paths.requests)}.`)
88
+ for (const text of notes) {
89
+ line()
90
+ warn(text)
91
+ }
92
+ }
10
93
 
11
94
  export const status: Command = {
12
- summary: "Show what eunoe is doing",
95
+ summary: "Show what eunoe is doing and saving",
13
96
  usage: "eunoe status",
14
97
  async run() {
15
- const config = loadConfig()
98
+ const config = readConfig()
16
99
  const health = await probe(config.port)
17
100
  const routed = routedDirs(config)
18
- const recent = recentRequests()
101
+ const all = recentRequests(Number.POSITIVE_INFINITY)
102
+ const recent = all.slice(-RECENT_REQUESTS)
103
+ const sum = totals(withinWindow(recent))
104
+ const allTime = totals(all)
19
105
 
20
106
  line()
21
107
  line(` ${c.bold("eunoe")} ${c.dim(VERSION)}`)
22
108
  line()
23
109
  rows([
24
- ["Proxy", health ? `${c.green("●")} running on ${c.cyan(proxyUrl(config.port))}` : `${c.red("○")} not running`],
25
- ["Claude Code", routed.length ? `${c.green("●")} routed through eunoe` : `${c.dim("○")} not routed ${c.dim("(eunoe install)")}`],
110
+ ["Proxy", health ? `${dot(true)} running on ${c.cyan(proxyUrl(config.port))}${launchd.isInstalled(PROXY_AGENT) ? c.dim(" launchd") : ""}` : `${dot(false)} not running ${c.dim("(eunoe start)")}`],
111
+ ["Claude Code", routed.length > 0 ? `${dot(true)} routed ${c.dim(settingsFiles(routed))}` : `${c.dim("○")} not routed ${c.dim("(eunoe start)")}`],
26
112
  ["Mode", `${c.bold(config.mode)} ${c.dim(MODE_DESCRIPTIONS[config.mode])}`],
27
- ["Search", `${config.search} transcripts`],
28
- ["Background", launchd.isInstalled() ? "launchd agent" : c.dim("none")],
113
+ ["Sessions", config.scope === "all" ? "all" : `only the ones you started it for ${c.dim("(eunoe start --session <id>)")}`],
114
+ ["Recall", recallLine(config, recent)],
29
115
  ])
30
- for (const dir of routed) line(` ${" ".repeat(13)}${c.dim(tilde(path.join(dir, "settings.json")))}`)
31
-
32
- if (recent.length) {
33
- const sum = (key: string) => recent.reduce((total, entry) => total + (entry[key] ?? 0), 0)
34
- const compactions = recent.filter((entry) => entry.compacted)
35
- line()
36
- line(` ${c.dim(`Last ${recent.length} requests`)}`)
37
- rows([
38
- ["Cache", `${c.bold(tokens(sum("cacheRead")))} read · ${tokens(sum("cacheWrite"))} written · ${tokens(sum("uncached"))} uncached`],
39
- ["Cuts", compactions.length ? `${compactions.length}, last ${ago(compactions.at(-1)!.ts)}` : c.dim("none yet")],
40
- ])
41
- }
42
- if (health && health.mode !== config.mode) {
43
- line()
44
- warn(`The running proxy reports mode ${health.mode}; it switches on its next request.`)
45
- }
116
+
117
+ sessionsTable(config, perSession(recent))
118
+
119
+ if (sum.requests) summary(c.dim("Last 24h"), sum)
120
+ if (allTime.requests > sum.requests) summary(`${c.dim("All time")} ${c.dim(`since ${new Date(all[0].ts).toLocaleDateString()}`)}`, allTime)
121
+
122
+ warnings(config, health, routed, recent)
46
123
  line()
47
124
  },
48
125
  }
@@ -1,16 +1,40 @@
1
- import { loadConfig } from "../../config"
2
- import { routedDirs } from "../inspect"
3
- import * as launchd from "../launchd"
4
- import type { Command } from "../types"
5
- import { fail, note, ok } from "../ui"
1
+ import { claudeConfigDirs, readConfig, saveConfig } from "../../config"
2
+ import type { Command } from "../command"
3
+ import { editSettings, withoutEunoe } from "../proxy/claude-settings"
4
+ import { clearWatch } from "../proxy/watch"
5
+ import { sessionArgs, shortId } from "../sessions"
6
+ import { line, listFiles, note, ok } from "../terminal/ui"
6
7
 
7
8
  export const stop: Command = {
8
- summary: "Stop the background proxy",
9
- usage: "eunoe stop",
10
- details: ["Sessions still pointed at the proxy will fail until restarted, so run `eunoe uninstall` first."],
11
- run() {
12
- if (routedDirs(loadConfig()).length) fail("Claude Code is still routed through eunoe", "Run `eunoe uninstall` first, then restart your sessions.")
13
- if (launchd.removeAgent()) ok("Background proxy stopped")
14
- else note("The proxy isn't running as a background service.")
9
+ summary: "Send new sessions straight to the API again",
10
+ usage: "eunoe stop [--session <id>]",
11
+ details: [
12
+ "Removes eunoe from Claude Code's settings and hands compaction back to it.",
13
+ "The proxy stays up for sessions that are already running through it, so they",
14
+ "keep working until you restart them. `eunoe start` turns it back on.",
15
+ "",
16
+ "With --session, only that session is passed through untouched from its next request.",
17
+ ],
18
+ options: [["--session <id>", "Stop managing just this session (repeatable; a unique prefix is enough)"]],
19
+ run(args) {
20
+ const config = readConfig()
21
+ const ids = sessionArgs(args)
22
+ if (ids.length > 0) {
23
+ const sessions = { ...config.sessions }
24
+ for (const id of ids) {
25
+ if (config.scope === "all") sessions[id] = { ...sessions[id], mode: "off" }
26
+ else delete sessions[id]
27
+ }
28
+ saveConfig({ sessions })
29
+ ok(`eunoe off for ${ids.map(shortId).join(", ")} from the next request`)
30
+ return
31
+ }
32
+ const touched = editSettings(claudeConfigDirs(config), (settings) => withoutEunoe(settings, config.port))
33
+ clearWatch()
34
+ line()
35
+ ok("Claude Code no longer routed through eunoe")
36
+ listFiles(touched)
37
+ note("Sessions already running through eunoe keep using it until restarted.")
38
+ line()
15
39
  },
16
40
  }
@@ -1,25 +1,25 @@
1
- import { claudeConfigDirs, loadConfig, saveConfig } from "../../config"
2
- import { editSettings, withoutEunoe } from "../claude-settings"
3
- import type { Command } from "../types"
4
- import { c, line, listFiles, note, ok } from "../ui"
1
+ import { claudeConfigDirs, readConfig } from "../../config"
2
+ import type { Command } from "../command"
3
+ import { editSettings, withoutEunoe } from "../proxy/claude-settings"
4
+ import { removeService } from "../proxy/service"
5
+ import { clearWatch } from "../proxy/watch"
6
+ import { c, line, listFiles, note, ok, warn } from "../terminal/ui"
5
7
 
6
8
  export const uninstall: Command = {
7
- summary: "Send new sessions straight to the API again",
9
+ summary: "Stop eunoe and remove the background proxy",
8
10
  usage: "eunoe uninstall",
9
- details: [
10
- "Removes eunoe from Claude Code's settings and hands compaction back to it.",
11
- "The proxy keeps running as a passthrough, so sessions already pointed at it",
12
- "keep working. Run `eunoe stop` once they have been restarted.",
13
- ],
11
+ details: ["Everything `stop` does, then shuts the proxy down and removes its background service.", "Sessions still running through eunoe lose their connection, so restart them first.", "Your config and transcripts in ~/.eunoe are kept."],
14
12
  run() {
15
- const config = loadConfig()
13
+ const config = readConfig()
16
14
  const touched = editSettings(claudeConfigDirs(config), (settings) => withoutEunoe(settings, config.port))
17
- saveConfig({ mode: "off" })
15
+ clearWatch()
18
16
  line()
19
17
  ok("Claude Code no longer routed through eunoe")
20
18
  listFiles(touched)
21
- note("The proxy stays up as a passthrough for sessions still using it.")
22
- line(` ${c.dim("Once they're restarted:")} ${c.cyan("eunoe stop")}`)
19
+ if (removeService()) ok("Background proxy stopped and removed")
20
+ else note("No background proxy was installed.")
21
+ warn(`Restart any Claude Code session that was still using eunoe.`)
22
+ line(` ${c.dim("Data kept in")} ~/.eunoe`)
23
23
  line()
24
24
  },
25
25
  }
@@ -0,0 +1,16 @@
1
+ import { readConfig } from "../../config"
2
+ import type { Command } from "../command"
3
+ import { runWatch } from "../proxy/watch"
4
+ import { line } from "../terminal/ui"
5
+
6
+ export const watch: Command = {
7
+ summary: "Check the proxy once and unroute Claude Code while it is down",
8
+ usage: "eunoe watch",
9
+ details: ["`eunoe start` schedules this in the background. When the proxy stops answering, Claude Code goes straight to the API; when it is back, routing returns."],
10
+ hidden: true,
11
+ async run() {
12
+ const step = await runWatch(readConfig())
13
+ for (const dir of step.pause) line(`eunoe watch proxy down, routing removed from ${dir}`)
14
+ for (const dir of step.resume) line(`eunoe watch proxy back, routing restored in ${dir}`)
15
+ },
16
+ }
@@ -0,0 +1,99 @@
1
+ export const HALF_BLOCKS = " ▀▄█"
2
+ export const OCTANTS = [
3
+ ..." 𜺨𜺫🮂𜴀▘𜴁𜴂𜴃𜴄▝𜴅𜴆𜴇𜴈▀𜴉𜴊𜴋𜴌🯦𜴍𜴎𜴏𜴐𜴑𜴒𜴓𜴔𜴕𜴖𜴗𜴘𜴙𜴚𜴛𜴜𜴝𜴞𜴟🯧𜴠𜴡𜴢𜴣𜴤𜴥𜴦𜴧𜴨𜴩𜴪𜴫𜴬𜴭𜴮𜴯𜴰𜴱𜴲𜴳𜴴𜴵🮅𜺣𜴶𜴷𜴸𜴹𜴺𜴻𜴼𜴽𜴾𜴿𜵀𜵁𜵂𜵃𜵄▖𜵅𜵆𜵇𜵈▌𜵉𜵊𜵋𜵌▞𜵍𜵎𜵏𜵐▛𜵑𜵒𜵓𜵔𜵕𜵖𜵗𜵘𜵙𜵚𜵛𜵜𜵝𜵞𜵟𜵠𜵡𜵢𜵣𜵤𜵥𜵦𜵧𜵨𜵩𜵪𜵫𜵬𜵭𜵮𜵯𜵰𜺠𜵱𜵲𜵳𜵴𜵵𜵶𜵷𜵸𜵹𜵺𜵻𜵼𜵽𜵾𜵿𜶀𜶁𜶂𜶃𜶄𜶅𜶆𜶇𜶈𜶉𜶊𜶋𜶌𜶍𜶎𜶏▗𜶐𜶑𜶒𜶓▚𜶔𜶕𜶖𜶗▐𜶘𜶙𜶚𜶛▜𜶜𜶝𜶞𜶟𜶠𜶡𜶢𜶣𜶤𜶥𜶦𜶧𜶨𜶩𜶪𜶫▂𜶬𜶭𜶮𜶯𜶰𜶱𜶲𜶳𜶴𜶵𜶶𜶷𜶸𜶹𜶺𜶻𜶼𜶽𜶾𜶿𜷀𜷁𜷂𜷃𜷄𜷅𜷆𜷇𜷈𜷉𜷊𜷋𜷌𜷍𜷎𜷏𜷐𜷑𜷒𜷓𜷔𜷕𜷖𜷗𜷘𜷙𜷚▄𜷛𜷜𜷝𜷞▙𜷟𜷠𜷡𜷢▟𜷣▆𜷤𜷥█",
4
+ ]
5
+
6
+ export const KEYS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456"
7
+
8
+ export const PALETTE = [
9
+ "#fefefd",
10
+ "#fdfcf1",
11
+ "#fef7db",
12
+ "#fef7da",
13
+ "#fdf7dc",
14
+ "#fef7d9",
15
+ "#fdf6d9",
16
+ "#fef6d6",
17
+ "#fdf6d7",
18
+ "#fdf4d5",
19
+ "#fdf6cf",
20
+ "#fef4ae",
21
+ "#fde9a5",
22
+ "#feec97",
23
+ "#fde186",
24
+ "#fddb6b",
25
+ "#fdd763",
26
+ "#fdd661",
27
+ "#fdd55d",
28
+ "#fcd259",
29
+ "#fdcd49",
30
+ "#fdc524",
31
+ "#f6f5de",
32
+ "#f5e6b1",
33
+ "#d7ecc9",
34
+ "#e2d596",
35
+ "#e5b93b",
36
+ "#7ce5d0",
37
+ "#68dbd4",
38
+ "#74cdd0",
39
+ "#52d1cc",
40
+ "#33c4c9",
41
+ "#2bc0c7",
42
+ "#2abcc5",
43
+ "#29bbc4",
44
+ "#27bcc5",
45
+ "#20bccc",
46
+ "#27bac3",
47
+ "#24b7bc",
48
+ "#12b3d0",
49
+ "#0baccd",
50
+ "#0fa7b4",
51
+ "#7f7870",
52
+ "#0678a5",
53
+ "#038abb",
54
+ "#04527e",
55
+ "#018cd0",
56
+ "#016b95",
57
+ "#014b85",
58
+ "#01417b",
59
+ "#023d73",
60
+ "#013a71",
61
+ "#033b6a",
62
+ "#023768",
63
+ "#013367",
64
+ "#092945",
65
+ "#002d56",
66
+ "#071a2c",
67
+ "#041223",
68
+ ]
69
+
70
+ export const PIXELS = [
71
+ "..............W4...........a",
72
+ ".............Xutt..........U",
73
+ ".........tt...kuut.......uu.",
74
+ "........tkr...1Yxt4tT....u..",
75
+ "...XXW..soq...X2Xtst0.......",
76
+ "..TqssW.xr6...0CJRXs4.......",
77
+ "..oCCCsW.srrr012S4R1s.1.....",
78
+ "..kCCPrsV.ttslRSRJCFEB0m....",
79
+ "...pCPPrqW4W43nJJJJJHCG0....",
80
+ "...koCPR0sppHEtEILIHHHGhTrq.",
81
+ "....oCOSSZSFFEFFIIIHHHhhkEp.",
82
+ "XXX..nyEVPByEEEGGIIIHHLbwLo.",
83
+ "YrrYYY04WMGFEEggdhIIHHH63Vx.",
84
+ ".rqnnnxKKLwFifg66hhHHGJ6ujTe",
85
+ ".ZrpoCNSLKkGfh3355fffB4ejmj.",
86
+ "..1qpCY1PMLFfg3553hhhh.ihgRw",
87
+ ".....ZZ2POJGdd653AdcgQQhfe..",
88
+ ".....ZZPZPJdxjj6vcddcAAffT..",
89
+ ".CC.YpCE2VhGkjmmfdcddghdT..A",
90
+ "....Xpp1ZVRVxhhfgddfffTT..Ab",
91
+ ".....1Z2pC02VxxkxxgnmzA....6",
92
+ ".......qrp2ewVPMxxmmavvv6..A",
93
+ "..UU...YXY..PPFkEkkvvaaavA..",
94
+ ".UUCU......XXPMCkkvalanxavvN",
95
+ ".UCyUNN..1Yu4PMP055nlalxBavN",
96
+ "..UU.NN.1uD2PwJ0MP33nlmyBuav",
97
+ "QyDy3y..uo2RRwFPPPP6nnBBavaa",
98
+ "DQQQDyy.tB2RVPPPSR4poBBaaB6N",
99
+ ]
@@ -1,6 +1,5 @@
1
- import { HALF_BLOCKS, OCTANT_TERMINALS, OCTANTS, TRANSPARENT_PIXEL } from "../constants"
2
- import { trueColor, useColor } from "../ui"
3
- import { LOGO_KEYS, LOGO_PALETTE, LOGO_PIXELS } from "./pixels"
1
+ import { trueColor, useColor } from "../terminal/ui"
2
+ import { HALF_BLOCKS, KEYS, OCTANTS, PALETTE, PIXELS } from "./art"
4
3
 
5
4
  type Rgb = [number, number, number]
6
5
  type Pixel = Rgb | null
@@ -9,8 +8,6 @@ const rgb = (hex: string): Rgb => [1, 3, 5].map((i) => Number.parseInt(hex.slice
9
8
 
10
9
  const toCube = (value: number) => (value < 48 ? 0 : value < 115 ? 1 : Math.min(5, Math.round((value - 35) / 40)))
11
10
 
12
- const MISMATCH = 3 * 255 ** 2
13
-
14
11
  function sgr(color: Rgb, layer: "fg" | "bg") {
15
12
  const base = layer === "fg" ? 38 : 48
16
13
  if (trueColor) return `${base};2;${color.join(";")}`
@@ -19,12 +16,12 @@ function sgr(color: Rgb, layer: "fg" | "bg") {
19
16
  }
20
17
 
21
18
  function pixel(key: string | undefined): Pixel {
22
- if (!key || key === TRANSPARENT_PIXEL) return null
23
- return rgb(LOGO_PALETTE[LOGO_KEYS.indexOf(key)])
19
+ if (!key || key === ".") return null
20
+ return rgb(PALETTE[KEYS.indexOf(key)])
24
21
  }
25
22
 
26
23
  function distance(a: Pixel, b: Pixel) {
27
- if (!a || !b) return a === b ? 0 : MISMATCH
24
+ if (!a || !b) return a === b ? 0 : 3 * 255 ** 2
28
25
  return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2
29
26
  }
30
27
 
@@ -57,26 +54,23 @@ function cell(block: Pixel[], glyphs: string[]) {
57
54
  }
58
55
 
59
56
  function supportsOctants() {
60
- const terminal = `${process.env.TERM_PROGRAM ?? ""} ${process.env.TERM ?? ""}`.toLowerCase()
61
- return OCTANT_TERMINALS.some((name) => terminal.includes(name))
57
+ return `${process.env.TERM_PROGRAM ?? ""} ${process.env.TERM ?? ""}`.toLowerCase().includes("ghostty")
62
58
  }
63
59
 
64
60
  function region(x: number, y: number, width: number, height: number): Pixel[] {
65
61
  const block: Pixel[] = []
66
- for (let dy = 0; dy < height; dy += 1) for (let dx = 0; dx < width; dx += 1) block.push(pixel(LOGO_PIXELS[y + dy]?.[x + dx]))
62
+ for (let dy = 0; dy < height; dy += 1) for (let dx = 0; dx < width; dx += 1) block.push(pixel(PIXELS[y + dy]?.[x + dx]))
67
63
  return block
68
64
  }
69
65
 
70
- export function logoLines(): string[] {
66
+ function logoLines(): string[] {
71
67
  if (!useColor) return []
72
68
  const octants = supportsOctants()
73
69
  const lines: string[] = []
74
- for (let y = 0; y < LOGO_PIXELS.length; y += 4) {
70
+ for (let y = 0; y < PIXELS.length; y += 4) {
75
71
  let row = ""
76
- for (let x = 0; x < LOGO_PIXELS[0].length; x += 2) {
77
- row += octants
78
- ? cell(region(x, y, 2, 4), OCTANTS)
79
- : cell([dominant(region(x, y, 2, 2)), dominant(region(x, y + 2, 2, 2))], [...HALF_BLOCKS])
72
+ for (let x = 0; x < PIXELS[0].length; x += 2) {
73
+ row += octants ? cell(region(x, y, 2, 4), OCTANTS) : cell([dominant(region(x, y, 2, 2)), dominant(region(x, y + 2, 2, 2))], [...HALF_BLOCKS])
80
74
  }
81
75
  lines.push(row)
82
76
  }
@@ -85,5 +79,5 @@ export function logoLines(): string[] {
85
79
 
86
80
  export function withLogo(text: string[]): string[] {
87
81
  const logo = logoLines()
88
- return [...logo.map((row) => ` ${row}`), ...(logo.length ? [""] : []), ...text.map((entry) => ` ${entry}`)]
82
+ return [...logo.map((row) => ` ${row}`), ...(logo.length > 0 ? [""] : []), ...text.map((entry) => ` ${entry}`)]
89
83
  }
package/src/cli/main.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env bun
2
2
  import { VERSION } from "../version"
3
- import { wantsHelp } from "./args"
4
3
  import { commands } from "./commands"
5
- import { printCommandHelp, printHelp } from "./help"
6
- import { closest, fail, line } from "./ui"
4
+ import { wantsHelp } from "./terminal/args"
5
+ import { printCommandHelp, printHelp } from "./terminal/help"
6
+ import { closest, fail, line } from "./terminal/ui"
7
7
 
8
8
  const [name, ...args] = process.argv.slice(2)
9
9