@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,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,59 @@
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
+ export const BLOCKING_LIMIT_ENV = "CLAUDE_CODE_BLOCKING_LIMIT_OVERRIDE"
11
+ export const BLOCKING_LIMIT_OFF = "1000000000"
12
+
13
+ export const proxyUrl = (port: number) => `http://127.0.0.1:${port}`
14
+
15
+ export const pointsAtEunoe = (settings: Settings, port: number) => settings.env?.ANTHROPIC_BASE_URL === proxyUrl(port)
16
+
17
+ function withoutEnv(settings: Settings, name: string): Settings {
18
+ if (!settings.env || !(name in settings.env)) return settings
19
+ const { [name]: _, ...env } = settings.env
20
+ const next: Settings = { ...settings, env }
21
+ if (Object.keys(env).length === 0) delete next.env
22
+ return next
23
+ }
24
+
25
+ export function withCompactionOwner(settings: Settings, mode: string): Settings {
26
+ if (mode !== "off") return { ...settings, autoCompactEnabled: false, env: { ...settings.env, [BLOCKING_LIMIT_ENV]: BLOCKING_LIMIT_OFF } }
27
+ const { autoCompactEnabled: _, ...rest } = settings
28
+ return rest.env?.[BLOCKING_LIMIT_ENV] === BLOCKING_LIMIT_OFF ? withoutEnv(rest, BLOCKING_LIMIT_ENV) : rest
29
+ }
30
+
31
+ export function withEunoe(settings: Settings, port: number, mode: string): Settings {
32
+ return withCompactionOwner({ ...settings, env: { ...settings.env, ANTHROPIC_BASE_URL: proxyUrl(port) } }, mode)
33
+ }
34
+
35
+ export function withoutEunoe(settings: Settings, port: number): Settings {
36
+ return withCompactionOwner(pointsAtEunoe(settings, port) ? withoutEnv(settings, "ANTHROPIC_BASE_URL") : settings, "off")
37
+ }
38
+
39
+ export function readSettings(dir: string): Settings {
40
+ try {
41
+ return JSON.parse(readFileSync(path.join(dir, "settings.json"), "utf8"))
42
+ } catch {
43
+ return {}
44
+ }
45
+ }
46
+
47
+ export function editSettings(dirs: string[], edit: (settings: Settings) => Settings): string[] {
48
+ const touched: string[] = []
49
+ for (const dir of dirs) {
50
+ if (!existsSync(dir)) continue
51
+ const file = path.join(dir, "settings.json")
52
+ const current: Settings = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {}
53
+ const next = edit(current)
54
+ if (JSON.stringify(next) === JSON.stringify(current)) continue
55
+ writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`)
56
+ touched.push(file)
57
+ }
58
+ return touched
59
+ }
@@ -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
+ }
@@ -0,0 +1,53 @@
1
+ import { homedir } from "node:os"
2
+ import path from "node:path"
3
+ import type { Config } from "../../config"
4
+ import { paths } from "../../config/paths"
5
+ import type { Health } from "../../proxy/server"
6
+ import { c, line, ok, warn } from "../terminal/ui"
7
+ import { proxyUrl } from "./claude-settings"
8
+ import { probe, runsStaleCode } from "./inspect"
9
+ import * as launchd from "./launchd"
10
+
11
+ const agentFor = (label: string, interval?: number): launchd.Agent => ({ label, plist: path.join(homedir(), "Library", "LaunchAgents", `${label}.plist`), interval })
12
+
13
+ export const PROXY_AGENT = agentFor("com.chroxify.eunoe")
14
+ const WATCH_AGENT = agentFor("com.chroxify.eunoe.watch", 10)
15
+
16
+ const cli = () => path.resolve(process.argv[1] ?? "")
17
+
18
+ async function waitFor(port: number, ready: (health: Health | null) => boolean) {
19
+ const deadline = Date.now() + 3_000
20
+ while (Date.now() < deadline) {
21
+ if (ready(await probe(port))) return true
22
+ await Bun.sleep(150)
23
+ }
24
+ return false
25
+ }
26
+
27
+ export async function startService(config: Config) {
28
+ if (!launchd.supported) {
29
+ if (await probe(config.port)) return true
30
+ warn("No launchd here, so keep the proxy running yourself:")
31
+ line(` ${c.cyan("eunoe serve")}`)
32
+ return false
33
+ }
34
+ launchd.installAgent(PROXY_AGENT, [process.execPath, cli(), "serve"], paths.proxyLog)
35
+ launchd.installAgent(WATCH_AGENT, [process.execPath, cli(), "watch"], paths.proxyLog)
36
+ const running = await probe(config.port)
37
+ if (running && runsStaleCode(running)) {
38
+ launchd.restartAgent(PROXY_AGENT)
39
+ await waitFor(config.port, (health) => health !== null && health.pid !== running.pid)
40
+ }
41
+ if (await waitFor(config.port, (health) => health !== null)) {
42
+ ok(`Proxy running in the background on ${c.cyan(proxyUrl(config.port))}`)
43
+ return true
44
+ }
45
+ warn(`The proxy didn't answer on ${proxyUrl(config.port)}. Check ${paths.proxyLog}.`)
46
+ return false
47
+ }
48
+
49
+ export function removeService() {
50
+ const removed = launchd.removeAgent(PROXY_AGENT)
51
+ launchd.removeAgent(WATCH_AGENT)
52
+ return removed
53
+ }
@@ -1,8 +1,8 @@
1
- import { STATUS_WINDOW_MS } from "./constants"
2
- import type { RequestRecord } from "./types"
1
+ import type { RequestRecord } from "./inspect"
3
2
 
4
3
  export interface SessionStats {
5
4
  context: number
5
+ fixed: number | null
6
6
  plain: number | null
7
7
  turns: number | null
8
8
  requests: number
@@ -21,13 +21,17 @@ export interface Totals {
21
21
  saved: number
22
22
  }
23
23
 
24
- const THREAD_SUFFIX = /-[0-9a-f]{24}$/
24
+ const sessionOf = (record: RequestRecord): string | null => (typeof record.thread === "string" ? record.thread.replace(/-[0-9a-f]{24}$/, "") : null)
25
25
 
26
- export const sessionOf = (record: RequestRecord): string | null => (typeof record.thread === "string" ? record.thread.replace(THREAD_SUFFIX, "") : null)
26
+ const inputOf = (record: RequestRecord) => record.input ?? 0
27
27
 
28
- const plainTokens = (record: RequestRecord): number | null => (record.charsIn > 0 && record.charsOut > 0 && record.input > 0 ? Math.round(record.input * record.charsIn / record.charsOut) : null)
28
+ function scaled(record: RequestRecord, chars: number | undefined): number | null {
29
+ const input = inputOf(record)
30
+ const charsOut = record.charsOut ?? 0
31
+ return input > 0 && charsOut > 0 && chars && chars > 0 ? Math.round((input * chars) / charsOut) : null
32
+ }
29
33
 
30
- export function withinWindow(records: RequestRecord[], now = Date.now(), window = STATUS_WINDOW_MS): RequestRecord[] {
34
+ export function withinWindow(records: RequestRecord[], now = Date.now(), window = 24 * 3_600_000): RequestRecord[] {
31
35
  return records.filter((record) => now - Date.parse(record.ts) <= window)
32
36
  }
33
37
 
@@ -35,12 +39,13 @@ export function perSession(records: RequestRecord[]): Map<string, SessionStats>
35
39
  const stats = new Map<string, SessionStats>()
36
40
  for (const record of records) {
37
41
  const id = sessionOf(record)
38
- if (!id || !(record.input > 0)) continue
42
+ if (!id || inputOf(record) <= 0) continue
39
43
  const previous = stats.get(id)
40
44
  stats.set(id, {
41
- context: record.input,
42
- plain: plainTokens(record),
43
- turns: typeof record.turns === "number" ? record.turns : previous?.turns ?? null,
45
+ context: inputOf(record),
46
+ fixed: scaled(record, record.charsFixed),
47
+ plain: scaled(record, record.charsIn),
48
+ turns: record.turns ?? previous?.turns ?? null,
44
49
  requests: (previous?.requests ?? 0) + 1,
45
50
  folds: (previous?.folds ?? 0) + (record.compacted ? 1 : 0),
46
51
  lastSeen: record.ts,
@@ -52,7 +57,7 @@ export function perSession(records: RequestRecord[]): Map<string, SessionStats>
52
57
  export function totals(records: RequestRecord[]): Totals {
53
58
  const out: Totals = { requests: 0, cacheRead: 0, cacheWrite: 0, uncached: 0, folds: 0, lastFold: null, sent: 0, saved: 0 }
54
59
  for (const record of records) {
55
- if (!(record.input > 0)) continue
60
+ if (inputOf(record) <= 0) continue
56
61
  out.requests += 1
57
62
  out.cacheRead += record.cacheRead ?? 0
58
63
  out.cacheWrite += record.cacheWrite ?? 0
@@ -61,13 +66,16 @@ export function totals(records: RequestRecord[]): Totals {
61
66
  out.folds += 1
62
67
  out.lastFold = record.ts
63
68
  }
64
- const plain = plainTokens(record)
69
+ const plain = scaled(record, record.charsIn)
65
70
  if (plain !== null) {
66
- out.sent += record.input
67
- out.saved += plain - record.input
71
+ out.sent += inputOf(record)
72
+ out.saved += plain - inputOf(record)
68
73
  }
69
74
  }
70
75
  return out
71
76
  }
72
77
 
73
- export const savedShare = (context: number, plain: number | null): number | null => (plain && plain > context ? 1 - context / plain : plain ? 0 : null)
78
+ export function savedShare(context: number, plain: number | null): number | null {
79
+ if (!plain) return null
80
+ return plain > context ? 1 - context / plain : 0
81
+ }
@@ -0,0 +1,60 @@
1
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
2
+ import { type Config, claudeConfigDirs, compactionOwner } from "../../config"
3
+ import { paths } from "../../config/paths"
4
+ import { editSettings, withEunoe, withoutEunoe } from "./claude-settings"
5
+ import { probe, routedDirs } from "./inspect"
6
+
7
+ interface Watch {
8
+ misses: number
9
+ paused: string[]
10
+ pausedAt?: string
11
+ }
12
+
13
+ interface WatchStep {
14
+ watch: Watch
15
+ pause: string[]
16
+ resume: string[]
17
+ }
18
+
19
+ export const WATCH_MISSES = 2
20
+
21
+ const idle = (): Watch => ({ misses: 0, paused: [] })
22
+
23
+ export function readWatch(): Watch {
24
+ try {
25
+ return { ...idle(), ...JSON.parse(readFileSync(paths.watch, "utf8")) }
26
+ } catch {
27
+ return idle()
28
+ }
29
+ }
30
+
31
+ function saveWatch(watch: Watch) {
32
+ if (watch.misses === 0 && watch.paused.length === 0) {
33
+ if (existsSync(paths.watch)) unlinkSync(paths.watch)
34
+ return
35
+ }
36
+ writeFileSync(paths.watch, `${JSON.stringify(watch, null, 2)}\n`)
37
+ }
38
+
39
+ export function clearWatch() {
40
+ saveWatch(idle())
41
+ }
42
+
43
+ export function nextWatch(watch: Watch, alive: boolean, routed: string[], now: string): WatchStep {
44
+ if (alive) return { watch: idle(), pause: [], resume: watch.paused }
45
+ if (routed.length === 0) return { watch: { ...watch, misses: 0 }, pause: [], resume: [] }
46
+ const misses = watch.misses + 1
47
+ if (misses < WATCH_MISSES) return { watch: { ...watch, misses }, pause: [], resume: [] }
48
+ return { watch: { misses: 0, paused: [...new Set([...watch.paused, ...routed])], pausedAt: now }, pause: routed, resume: [] }
49
+ }
50
+
51
+ export async function runWatch(config: Config): Promise<WatchStep> {
52
+ const alive = (await probe(config.port)) !== null
53
+ const step = nextWatch(readWatch(), alive, routedDirs(config), new Date().toISOString())
54
+ if (step.pause.length > 0) editSettings(step.pause, (settings) => withoutEunoe(settings, config.port))
55
+ const managed = claudeConfigDirs(config)
56
+ const resumed = step.resume.filter((dir) => managed.includes(dir))
57
+ if (resumed.length > 0) editSettings(resumed, (settings) => withEunoe(settings, config.port, compactionOwner(config)))
58
+ saveWatch(step.watch)
59
+ return step
60
+ }