@chroxify/eunoe 1.0.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 (85) hide show
  1. package/README.md +55 -9
  2. package/package.json +18 -2
  3. package/src/cli/{types.ts → command.ts} +0 -4
  4. package/src/cli/commands/index.ts +11 -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 +194 -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 +27 -18
  13. package/src/cli/commands/status.ts +98 -55
  14. package/src/cli/commands/stop.ts +8 -6
  15. package/src/cli/commands/uninstall.ts +10 -12
  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/{stats.ts → proxy/stats.ts} +23 -15
  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/{help.ts → terminal/help.ts} +24 -8
  32. package/src/cli/terminal/prompt.ts +114 -0
  33. package/src/cli/{ui.ts → terminal/ui.ts} +55 -24
  34. package/src/config/index.ts +99 -34
  35. package/src/config/paths.ts +17 -0
  36. package/src/context/content.ts +40 -14
  37. package/src/context/fold/evidence.ts +145 -0
  38. package/src/context/fold/index.ts +65 -0
  39. package/src/context/fold/strategies.ts +94 -0
  40. package/src/context/fold/turns.ts +76 -0
  41. package/src/context/prompts.ts +141 -22
  42. package/src/context/recall/index.ts +214 -0
  43. package/src/context/recall/rerank.ts +71 -0
  44. package/src/proxy/canonical.ts +17 -9
  45. package/src/proxy/compaction.ts +106 -53
  46. package/src/proxy/estimate.ts +14 -9
  47. package/src/proxy/fingerprint.ts +23 -9
  48. package/src/proxy/handoff.ts +71 -0
  49. package/src/proxy/log.ts +2 -2
  50. package/src/proxy/recall.ts +58 -0
  51. package/src/proxy/rewrite.ts +141 -119
  52. package/src/proxy/server.ts +108 -51
  53. package/src/proxy/sessions.ts +32 -14
  54. package/src/proxy/state.ts +17 -3
  55. package/src/proxy/usage.ts +50 -34
  56. package/src/proxy/window.ts +32 -34
  57. package/src/transcript/format.ts +88 -25
  58. package/src/transcript/locate.ts +38 -17
  59. package/src/transcript/messages.ts +3 -14
  60. package/src/transcript/parse.ts +137 -56
  61. package/src/transcript/render.ts +44 -17
  62. package/src/transcript/search.ts +20 -56
  63. package/src/transcript/subagents.ts +150 -0
  64. package/src/cli/claude-settings.ts +0 -42
  65. package/src/cli/constants.ts +0 -52
  66. package/src/cli/inspect.ts +0 -41
  67. package/src/cli/launchd.ts +0 -39
  68. package/src/cli/logo/pixels.ts +0 -34
  69. package/src/cli/prompt.ts +0 -91
  70. package/src/cli/service.ts +0 -33
  71. package/src/cli/sessions.ts +0 -25
  72. package/src/config/constants.ts +0 -39
  73. package/src/config/types.ts +0 -50
  74. package/src/context/constants.ts +0 -42
  75. package/src/context/evidence.ts +0 -92
  76. package/src/context/recall.ts +0 -155
  77. package/src/context/rerank.ts +0 -44
  78. package/src/context/strategies.ts +0 -89
  79. package/src/context/turns.ts +0 -87
  80. package/src/context/types.ts +0 -66
  81. package/src/proxy/constants.ts +0 -29
  82. package/src/proxy/types.ts +0 -82
  83. package/src/transcript/constants.ts +0 -15
  84. package/src/transcript/types.ts +0 -43
  85. /package/src/cli/{args.ts → terminal/args.ts} +0 -0
@@ -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,18 @@
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, 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"
10
9
  import { isKnown, sessionArgs, shortId } from "../sessions"
11
- import type { Command } from "../types"
12
- import { c, fail, line, listFiles, note, ok, rows, tilde } from "../ui"
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
13
 
14
14
  function scoped(config: Config, ids: string[]): Partial<Config> {
15
- if (!ids.length) return { scope: "all" }
15
+ if (ids.length === 0) return { scope: "all" }
16
16
  const managingAll = config.scope === "all" && routedDirs(config).length > 0
17
17
  const sessions = { ...config.sessions }
18
18
  for (const id of ids) {
@@ -22,6 +22,14 @@ function scoped(config: Config, ids: string[]): Partial<Config> {
22
22
  return { scope: managingAll ? "all" : "sessions", sessions }
23
23
  }
24
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
+
25
33
  export const start: Command = {
26
34
  summary: "Route Claude Code through eunoe",
27
35
  usage: "eunoe start [--session <id>] [--config-dir <dir>]",
@@ -40,25 +48,26 @@ export const start: Command = {
40
48
  async run(args) {
41
49
  const added = flagValues(args, "--config-dir").map((dir) => path.resolve(dir))
42
50
  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])] })
51
+ if (added.length > 0) saveConfig({ claudeConfigDirs: [...new Set([...readConfig().claudeConfigDirs, ...added])] })
44
52
  const ids = sessionArgs(args)
45
- saveConfig(scoped(loadConfig(), ids))
46
- const config = loadConfig()
53
+ saveConfig(scoped(readConfig(), ids))
54
+ const config = readConfig()
47
55
 
48
56
  line()
49
- await startService(config)
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()
50
59
  const touched = editSettings(claudeConfigDirs(config), (settings) => withEunoe(settings, config.port, compactionOwner(config)))
51
60
  ok("Claude Code routed through eunoe")
52
- listFiles(touched.length ? touched : routedDirs(config).map((dir) => path.join(dir, "settings.json")))
61
+ listFiles(touched.length > 0 ? touched : routedDirs(config).map((dir) => path.join(dir, "settings.json")))
53
62
  line()
54
63
  rows([
55
64
  ["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(", ")}`],
65
+ ["Sessions", managedSessions(config)],
57
66
  ])
58
67
  line()
59
68
  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.")
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.")
62
71
  line()
63
72
  },
64
73
  }
@@ -1,82 +1,125 @@
1
1
  import path from "node:path"
2
- import { loadConfig, modeFor, overrides } from "../../config"
3
- import { paths, RERANK_OFF } from "../../config/constants"
4
- import { rerankKey } from "../../context/rerank"
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"
5
6
  import { seenSessions } from "../../proxy/sessions"
6
7
  import { VERSION } from "../../version"
7
- import { proxyUrl } from "../claude-settings"
8
- import { MODE_DESCRIPTIONS, SESSIONS_SHOWN } from "../constants"
9
- import { probe, recentRequests, routedDirs } from "../inspect"
10
- import * as launchd from "../launchd"
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"
11
15
  import { shortId } from "../sessions"
12
- import { perSession, savedShare, totals, withinWindow } from "../stats"
13
- import type { Command } from "../types"
14
- import { ago, c, line, percent, rows, table, tilde, tokens, warn } from "../ui"
16
+ import { ago, c, line, percent, plural, rows, table, tilde, tokens, warn } from "../terminal/ui"
17
+ import { MODE_DESCRIPTIONS } from "./mode"
15
18
 
16
19
  const dot = (on: boolean) => (on ? c.green("●") : c.red("○"))
17
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
+ }
93
+
18
94
  export const status: Command = {
19
95
  summary: "Show what eunoe is doing and saving",
20
96
  usage: "eunoe status",
21
97
  async run() {
22
- const config = loadConfig()
98
+ const config = readConfig()
23
99
  const health = await probe(config.port)
24
100
  const routed = routedDirs(config)
25
- const recent = recentRequests()
26
- const today = withinWindow(recent)
27
- const stats = perSession(recent)
28
- const sum = totals(today)
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)
29
105
 
30
106
  line()
31
107
  line(` ${c.bold("eunoe")} ${c.dim(VERSION)}`)
32
108
  line()
33
-
34
- const recall = config.rerank === RERANK_OFF ? "bm25" : rerankKey() ? `bm25 ${c.dim("→")} ${config.rerank}` : `bm25 ${c.dim(`(${config.rerank} needs a key in ${tilde(paths.typesafeKey)} or TYPESAFE_API_KEY)`)}`
35
109
  rows([
36
- ["Proxy", health ? `${dot(true)} running on ${c.cyan(proxyUrl(config.port))}${launchd.isInstalled() ? c.dim(" launchd") : ""}` : `${dot(false)} not running ${c.dim("(eunoe start)")}`],
37
- ["Claude Code", routed.length ? `${dot(true)} routed ${c.dim(routed.map((dir) => tilde(path.join(dir, "settings.json"))).join(", "))}` : `${c.dim("○")} not routed ${c.dim("(eunoe start)")}`],
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)")}`],
38
112
  ["Mode", `${c.bold(config.mode)} ${c.dim(MODE_DESCRIPTIONS[config.mode])}`],
39
113
  ["Sessions", config.scope === "all" ? "all" : `only the ones you started it for ${c.dim("(eunoe start --session <id>)")}`],
40
- ["Recall", recall],
114
+ ["Recall", recallLine(config, recent)],
41
115
  ])
42
116
 
43
- const sessions = seenSessions().slice(0, SESSIONS_SHOWN)
44
- if (sessions.length) {
45
- line()
46
- line(` ${c.dim("Sessions")}`)
47
- table(sessions.map(([id, session]) => {
48
- const active = modeFor(config, id)
49
- const own = Object.entries(overrides(config.sessions[id])).map(([name, value]) => `${name} ${value}`).join(" · ")
50
- const stat = stats.get(id)
51
- const saved = stat ? savedShare(stat.context, stat.plain) : null
52
- return [
53
- c.cyan(shortId(id)),
54
- active === "off" ? c.dim("off") : c.green(active),
55
- session.cwd ? tilde(session.cwd) : c.dim("–"),
56
- stat?.turns ? c.dim(`${stat.turns} turns`) : "",
57
- stat && active !== "off" ? `${c.bold(tokens(stat.context))} ctx` : "",
58
- saved !== null && active !== "off" ? (saved > 0 ? c.green(`−${percent(saved)}`) : c.dim("±0")) : "",
59
- c.dim(ago(session.lastSeen)),
60
- own ? c.dim(own) : "",
61
- ]
62
- }))
63
- }
117
+ sessionsTable(config, perSession(recent))
64
118
 
65
- if (sum.requests) {
66
- const hit = sum.cacheRead + sum.cacheWrite + sum.uncached
67
- line()
68
- line(` ${c.dim("Last 24h")}`)
69
- rows([
70
- ["Requests", `${c.bold(String(sum.requests))}${sum.folds ? ` ${c.dim(`${sum.folds} fold${sum.folds === 1 ? "" : "s"}, last ${ago(sum.lastFold!)}`)}` : ""}`],
71
- ["Cache", `${c.bold(tokens(sum.cacheRead))} read ${c.dim("·")} ${tokens(sum.cacheWrite)} written ${c.dim("·")} ${tokens(sum.uncached)} uncached${hit ? c.dim(` (${percent(sum.cacheRead / hit)} hit)`) : ""}`],
72
- ["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")],
73
- ], 4)
74
- }
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)
75
121
 
76
- if (health && health.mode !== config.mode) {
77
- line()
78
- warn(`The running proxy reports mode ${health.mode}; it switches on its next request.`)
79
- }
122
+ warnings(config, health, routed, recent)
80
123
  line()
81
124
  },
82
125
  }
@@ -1,8 +1,9 @@
1
- import { claudeConfigDirs, loadConfig, saveConfig } from "../../config"
2
- import { editSettings, withoutEunoe } from "../claude-settings"
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"
3
5
  import { sessionArgs, shortId } from "../sessions"
4
- import type { Command } from "../types"
5
- import { line, listFiles, note, ok } from "../ui"
6
+ import { line, listFiles, note, ok } from "../terminal/ui"
6
7
 
7
8
  export const stop: Command = {
8
9
  summary: "Send new sessions straight to the API again",
@@ -16,9 +17,9 @@ export const stop: Command = {
16
17
  ],
17
18
  options: [["--session <id>", "Stop managing just this session (repeatable; a unique prefix is enough)"]],
18
19
  run(args) {
19
- const config = loadConfig()
20
+ const config = readConfig()
20
21
  const ids = sessionArgs(args)
21
- if (ids.length) {
22
+ if (ids.length > 0) {
22
23
  const sessions = { ...config.sessions }
23
24
  for (const id of ids) {
24
25
  if (config.scope === "all") sessions[id] = { ...sessions[id], mode: "off" }
@@ -29,6 +30,7 @@ export const stop: Command = {
29
30
  return
30
31
  }
31
32
  const touched = editSettings(claudeConfigDirs(config), (settings) => withoutEunoe(settings, config.port))
33
+ clearWatch()
32
34
  line()
33
35
  ok("Claude Code no longer routed through eunoe")
34
36
  listFiles(touched)
@@ -1,24 +1,22 @@
1
- import { claudeConfigDirs, loadConfig } from "../../config"
2
- import { editSettings, withoutEunoe } from "../claude-settings"
3
- import * as launchd from "../launchd"
4
- import type { Command } from "../types"
5
- import { c, line, listFiles, note, ok, warn } 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"
6
7
 
7
8
  export const uninstall: Command = {
8
9
  summary: "Stop eunoe and remove the background proxy",
9
10
  usage: "eunoe uninstall",
10
- details: [
11
- "Everything `stop` does, then shuts the proxy down and removes its background service.",
12
- "Sessions still running through eunoe lose their connection, so restart them first.",
13
- "Your config and transcripts in ~/.eunoe are kept.",
14
- ],
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."],
15
12
  run() {
16
- const config = loadConfig()
13
+ const config = readConfig()
17
14
  const touched = editSettings(claudeConfigDirs(config), (settings) => withoutEunoe(settings, config.port))
15
+ clearWatch()
18
16
  line()
19
17
  ok("Claude Code no longer routed through eunoe")
20
18
  listFiles(touched)
21
- if (launchd.removeAgent()) ok("Background proxy stopped and removed")
19
+ if (removeService()) ok("Background proxy stopped and removed")
22
20
  else note("No background proxy was installed.")
23
21
  warn(`Restart any Claude Code session that was still using eunoe.`)
24
22
  line(` ${c.dim("Data kept in")} ~/.eunoe`)
@@ -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
+ }