@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,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(sum.requests.toLocaleString())}${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(all))
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(all))
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
+ }
@@ -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
 
@@ -0,0 +1,69 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ export interface Settings {
5
+ env?: Record<string, string>
6
+ autoCompactEnabled?: boolean
7
+ [key: string]: unknown
8
+ }
9
+
10
+ const CLAUDE_CLEANUP_DAYS = 30
11
+
12
+ export const BLOCKING_LIMIT_ENV = "CLAUDE_CODE_BLOCKING_LIMIT_OVERRIDE"
13
+ export const BLOCKING_LIMIT_OFF = "1000000000"
14
+
15
+ export const proxyUrl = (port: number) => `http://127.0.0.1:${port}`
16
+
17
+ export const pointsAtEunoe = (settings: Settings, port: number) => settings.env?.ANTHROPIC_BASE_URL === proxyUrl(port)
18
+
19
+ function withoutEnv(settings: Settings, name: string): Settings {
20
+ if (!settings.env || !(name in settings.env)) return settings
21
+ const { [name]: _, ...env } = settings.env
22
+ const next: Settings = { ...settings, env }
23
+ if (Object.keys(env).length === 0) delete next.env
24
+ return next
25
+ }
26
+
27
+ export function withCompactionOwner(settings: Settings, mode: string): Settings {
28
+ if (mode !== "off") return { ...settings, autoCompactEnabled: false, env: { ...settings.env, [BLOCKING_LIMIT_ENV]: BLOCKING_LIMIT_OFF } }
29
+ const { autoCompactEnabled: _, ...rest } = settings
30
+ return rest.env?.[BLOCKING_LIMIT_ENV] === BLOCKING_LIMIT_OFF ? withoutEnv(rest, BLOCKING_LIMIT_ENV) : rest
31
+ }
32
+
33
+ export function withEunoe(settings: Settings, port: number, mode: string): Settings {
34
+ return withCompactionOwner({ ...settings, env: { ...settings.env, ANTHROPIC_BASE_URL: proxyUrl(port) } }, mode)
35
+ }
36
+
37
+ export function withoutEunoe(settings: Settings, port: number): Settings {
38
+ return withCompactionOwner(pointsAtEunoe(settings, port) ? withoutEnv(settings, "ANTHROPIC_BASE_URL") : settings, "off")
39
+ }
40
+
41
+ export function readSettings(dir: string): Settings {
42
+ try {
43
+ return JSON.parse(readFileSync(path.join(dir, "settings.json"), "utf8"))
44
+ } catch {
45
+ return {}
46
+ }
47
+ }
48
+
49
+ export function cleanupDays(configDirs: string[]): number {
50
+ for (const dir of configDirs) {
51
+ const days = readSettings(dir).cleanupPeriodDays
52
+ if (typeof days === "number") return days
53
+ }
54
+ return CLAUDE_CLEANUP_DAYS
55
+ }
56
+
57
+ export function editSettings(dirs: string[], edit: (settings: Settings) => Settings): string[] {
58
+ const touched: string[] = []
59
+ for (const dir of dirs) {
60
+ if (!existsSync(dir)) continue
61
+ const file = path.join(dir, "settings.json")
62
+ const current: Settings = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {}
63
+ const next = edit(current)
64
+ if (JSON.stringify(next) === JSON.stringify(current)) continue
65
+ writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`)
66
+ touched.push(file)
67
+ }
68
+ return touched
69
+ }
@@ -0,0 +1,67 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
2
+ import path from "node:path"
3
+ import { type Config, claudeConfigDirs } from "../../config"
4
+ import { paths } from "../../config/paths"
5
+ import { HEALTH_PATH, type Health } from "../../proxy/server"
6
+ import { parseJsonl } from "../../transcript/parse"
7
+ import { pointsAtEunoe, proxyUrl, readSettings } from "./claude-settings"
8
+
9
+ export interface RequestRecord {
10
+ ts: string
11
+ action?: string
12
+ thread?: string | null
13
+ input?: number
14
+ charsIn?: number
15
+ charsOut?: number
16
+ charsFixed?: number
17
+ cacheRead?: number
18
+ cacheWrite?: number
19
+ uncached?: number
20
+ compacted?: boolean
21
+ turns?: number
22
+ reranked?: string
23
+ rerankError?: string
24
+ overflow?: number
25
+ fit?: string
26
+ [key: string]: unknown
27
+ }
28
+
29
+ export const RECENT_REQUESTS = 5_000
30
+
31
+ export async function probe(port: number): Promise<Health | null> {
32
+ try {
33
+ const response = await fetch(`${proxyUrl(port)}${HEALTH_PATH}`, { signal: AbortSignal.timeout(800) })
34
+ if (!response.ok) return null
35
+ return (await response.json()) as Health
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
41
+ function newestSource(dir: string): number {
42
+ let newest = 0
43
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
44
+ const file = path.join(dir, entry.name)
45
+ if (entry.isDirectory()) newest = Math.max(newest, newestSource(file))
46
+ else if (entry.name.endsWith(".ts")) newest = Math.max(newest, statSync(file).mtimeMs)
47
+ }
48
+ return newest
49
+ }
50
+
51
+ export function runsStaleCode(health: Health | null): boolean {
52
+ if (!health?.startedAt) return health !== null
53
+ try {
54
+ return newestSource(path.join(import.meta.dir, "..")) > health.startedAt
55
+ } catch {
56
+ return false
57
+ }
58
+ }
59
+
60
+ export function routedDirs(config: Config) {
61
+ return claudeConfigDirs(config).filter((dir) => pointsAtEunoe(readSettings(dir), config.port))
62
+ }
63
+
64
+ export function recentRequests(limit = RECENT_REQUESTS): RequestRecord[] {
65
+ if (!existsSync(paths.requests)) return []
66
+ return parseJsonl<RequestRecord>(readFileSync(paths.requests, "utf8")).slice(-limit)
67
+ }
@@ -0,0 +1,53 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ export interface Agent {
5
+ label: string
6
+ plist: string
7
+ interval?: number
8
+ }
9
+
10
+ export const supported = process.platform === "darwin"
11
+
12
+ export function isInstalled(agent: Agent) {
13
+ return existsSync(agent.plist)
14
+ }
15
+
16
+ function plistFor(agent: Agent, program: string[], logFile: string) {
17
+ const args = program.map((arg) => `<string>${arg}</string>`).join("")
18
+ const schedule = agent.interval ? `<key>StartInterval</key><integer>${agent.interval}</integer>` : `<key>KeepAlive</key><true/>\n <key>ThrottleInterval</key><integer>1</integer>`
19
+ return `<?xml version="1.0" encoding="UTF-8"?>
20
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
21
+ <plist version="1.0"><dict>
22
+ <key>Label</key><string>${agent.label}</string>
23
+ <key>ProgramArguments</key><array>${args}</array>
24
+ <key>RunAtLoad</key><true/>
25
+ ${schedule}
26
+ <key>StandardOutPath</key><string>${logFile}</string>
27
+ <key>StandardErrorPath</key><string>${logFile}</string>
28
+ </dict></plist>
29
+ `
30
+ }
31
+
32
+ export function installAgent(agent: Agent, program: string[], logFile: string) {
33
+ mkdirSync(path.dirname(agent.plist), { recursive: true })
34
+ const plist = plistFor(agent, program, logFile)
35
+ if (existsSync(agent.plist) && readFileSync(agent.plist, "utf8") === plist) {
36
+ Bun.spawnSync(["launchctl", "load", agent.plist])
37
+ return
38
+ }
39
+ writeFileSync(agent.plist, plist)
40
+ Bun.spawnSync(["launchctl", "unload", agent.plist])
41
+ Bun.spawnSync(["launchctl", "load", agent.plist])
42
+ }
43
+
44
+ export function restartAgent(agent: Agent) {
45
+ Bun.spawnSync(["launchctl", "kickstart", "-k", `gui/${process.getuid?.() ?? ""}/${agent.label}`])
46
+ }
47
+
48
+ export function removeAgent(agent: Agent) {
49
+ if (!existsSync(agent.plist)) return false
50
+ Bun.spawnSync(["launchctl", "unload", agent.plist])
51
+ unlinkSync(agent.plist)
52
+ return true
53
+ }