@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.
- package/README.md +55 -9
- package/package.json +18 -2
- package/src/cli/{types.ts → command.ts} +0 -4
- package/src/cli/commands/index.ts +11 -1
- package/src/cli/commands/load.ts +44 -0
- package/src/cli/commands/mode.ts +41 -33
- package/src/cli/commands/peek.ts +22 -0
- package/src/cli/commands/replay.ts +194 -0
- package/src/cli/commands/serve.ts +22 -7
- package/src/cli/commands/sessions.ts +70 -0
- package/src/cli/commands/settings.ts +79 -60
- package/src/cli/commands/start.ts +27 -18
- package/src/cli/commands/status.ts +98 -55
- package/src/cli/commands/stop.ts +8 -6
- package/src/cli/commands/uninstall.ts +10 -12
- package/src/cli/commands/watch.ts +16 -0
- package/src/cli/logo/art.ts +99 -0
- package/src/cli/logo/index.ts +12 -18
- package/src/cli/main.ts +3 -3
- package/src/cli/proxy/claude-settings.ts +59 -0
- package/src/cli/proxy/inspect.ts +67 -0
- package/src/cli/proxy/launchd.ts +53 -0
- package/src/cli/proxy/service.ts +53 -0
- package/src/cli/{stats.ts → proxy/stats.ts} +23 -15
- package/src/cli/proxy/watch.ts +60 -0
- package/src/cli/replay/index.ts +141 -0
- package/src/cli/replay/worker.ts +15 -0
- package/src/cli/sessions/browse.ts +88 -0
- package/src/cli/sessions/index.ts +76 -0
- package/src/cli/sessions/source.ts +74 -0
- package/src/cli/{help.ts → terminal/help.ts} +24 -8
- package/src/cli/terminal/prompt.ts +114 -0
- package/src/cli/{ui.ts → terminal/ui.ts} +55 -24
- package/src/config/index.ts +99 -34
- package/src/config/paths.ts +17 -0
- package/src/context/content.ts +40 -14
- package/src/context/fold/evidence.ts +145 -0
- package/src/context/fold/index.ts +65 -0
- package/src/context/fold/strategies.ts +94 -0
- package/src/context/fold/turns.ts +76 -0
- package/src/context/prompts.ts +141 -22
- package/src/context/recall/index.ts +214 -0
- package/src/context/recall/rerank.ts +71 -0
- package/src/proxy/canonical.ts +17 -9
- package/src/proxy/compaction.ts +106 -53
- package/src/proxy/estimate.ts +14 -9
- package/src/proxy/fingerprint.ts +23 -9
- package/src/proxy/handoff.ts +71 -0
- package/src/proxy/log.ts +2 -2
- package/src/proxy/recall.ts +58 -0
- package/src/proxy/rewrite.ts +141 -119
- package/src/proxy/server.ts +108 -51
- package/src/proxy/sessions.ts +32 -14
- package/src/proxy/state.ts +17 -3
- package/src/proxy/usage.ts +50 -34
- package/src/proxy/window.ts +32 -34
- package/src/transcript/format.ts +88 -25
- package/src/transcript/locate.ts +38 -17
- package/src/transcript/messages.ts +3 -14
- package/src/transcript/parse.ts +137 -56
- package/src/transcript/render.ts +44 -17
- package/src/transcript/search.ts +20 -56
- package/src/transcript/subagents.ts +150 -0
- package/src/cli/claude-settings.ts +0 -42
- package/src/cli/constants.ts +0 -52
- package/src/cli/inspect.ts +0 -41
- package/src/cli/launchd.ts +0 -39
- package/src/cli/logo/pixels.ts +0 -34
- package/src/cli/prompt.ts +0 -91
- package/src/cli/service.ts +0 -33
- package/src/cli/sessions.ts +0 -25
- package/src/config/constants.ts +0 -39
- package/src/config/types.ts +0 -50
- package/src/context/constants.ts +0 -42
- package/src/context/evidence.ts +0 -92
- package/src/context/recall.ts +0 -155
- package/src/context/rerank.ts +0 -44
- package/src/context/strategies.ts +0 -89
- package/src/context/turns.ts +0 -87
- package/src/context/types.ts +0 -66
- package/src/proxy/constants.ts +0 -29
- package/src/proxy/types.ts +0 -82
- package/src/transcript/constants.ts +0 -15
- package/src/transcript/types.ts +0 -43
- /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 {
|
|
2
|
-
import {
|
|
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
|
|
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 =
|
|
32
|
+
const config = readConfig()
|
|
14
33
|
line()
|
|
15
34
|
const width = Math.max(...TUNABLES.map((name) => String(config[name]).length))
|
|
16
|
-
rows(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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)
|
|
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
|
|
30
|
-
const config =
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 ${
|
|
45
|
-
if (ids.length) {
|
|
46
|
-
saveConfig({
|
|
47
|
-
ok(`${name} ${c.bold(String(value))}
|
|
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 =
|
|
83
|
+
const config = readConfig()
|
|
57
84
|
line()
|
|
58
|
-
const name = await select<Tunable>(
|
|
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(
|
|
62
|
-
if (raw
|
|
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)} ${
|
|
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
|
-
|
|
78
|
-
const
|
|
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
|
-
|
|
81
|
-
if (
|
|
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)
|
|
93
|
-
|
|
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} <${
|
|
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,
|
|
4
|
-
import type {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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
|
|
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 (
|
|
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([...
|
|
51
|
+
if (added.length > 0) saveConfig({ claudeConfigDirs: [...new Set([...readConfig().claudeConfigDirs, ...added])] })
|
|
44
52
|
const ids = sessionArgs(args)
|
|
45
|
-
saveConfig(scoped(
|
|
46
|
-
const config =
|
|
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",
|
|
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 {
|
|
3
|
-
import { paths
|
|
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 {
|
|
8
|
-
import {
|
|
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 {
|
|
13
|
-
import
|
|
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 =
|
|
98
|
+
const config = readConfig()
|
|
23
99
|
const health = await probe(config.port)
|
|
24
100
|
const routed = routedDirs(config)
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
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
|
|
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",
|
|
114
|
+
["Recall", recallLine(config, recent)],
|
|
41
115
|
])
|
|
42
116
|
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/cli/commands/stop.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { claudeConfigDirs,
|
|
2
|
-
import {
|
|
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
|
|
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 =
|
|
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,
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
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 =
|
|
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 (
|
|
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
|
+
}
|