Hands the small judgments in a session to TypeSafe's Jev, a System One model that returns typed answers instead of generating text: what kind of request each…

Stop paying frontier-model prices for small judgments.
A coding agent makes dozens of quick calls every turn. What kind of prompt is this? Does it need tools? Does this edit break a rule in CLAUDE.md? Which turns still matter when the context fills up? This plugin sends those questions to TypeSafe's Jev. Jev is a System One model: it returns typed answers, not text, and it doesn't write code.
What you get:
Every hook fails open. An error, a missing key, or a timeout produces no output and never blocks a prompt. Compaction falls back to Claude Code's own summary.
claude plugin marketplace add 0x7067/claude-jev
claude plugin install claude-jev@claude-jev
Then set TYPESAFE_API_KEY or OPENROUTER_API_KEY. Without a key, the hooks switch off silently. The plugin needs only python3 and its standard library.
To turn on compaction and the /claude-jev settings pane, start Claude Code 2.1.278 or later with:
export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
The other hooks work without this flag.
pi install git:github.com/0x7067/claude-jev
Pi loads adapters/pi/jev.ts from the pi.extensions field in package.json. That file is the session_before_compact hook and the /jev command. Without TYPESAFE_API_KEY or OPENROUTER_API_KEY the hook returns nothing and Pi writes its own summary. The same two variables are read from the environment first, then from $PI_CODING_AGENT_DIR/.env (~/.pi/agent/.env). JEV_PROVIDER pins typesafe or openrouter. JEV_MODEL defaults to jev-latest.
The hook judges with selectBlocks in src/compact/strategy.ts. Pi-only pieces stay in the adapter: how a Pi message becomes a block, pairing a tool result to its call by toolCallId, relabeling bash output and earlier summaries, and the <read-files> and <modified-files> lists the summary shows the model. The kept blocks are fit to 14,000 characters. Paths from dropped or truncated calls may use 2,000 more. Pi's own read paths and <modified-files> sit outside that reservation. Claude's compaction budget is still one flat 16,000. 0x7067/pi-jev stays up; this repo does not replace that install yet.
TYPESAFE_API_KEY first.sk-or-, in either variable, sends every call to OpenRouter's System One API instead of api.typesafe.ai./claude-jev or /config. A pinned provider reads only its own variable.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is Claude Code's own switch for its experimental function hooks. It's off by default and undocumented. docs/claude-code-compaction-research.md records what was verified. Function-hook modules load only in a trusted workspace, and never for subagents.
Leave auto-compact on. It's the Auto-compact row in /config, stored as autoCompactEnabled in ~/.claude/settings.json. With the flag set, auto-compaction goes through Jev like /compact does. With auto-compact off, a session runs until it hits the context limit, and then you must run /compact yourself.
/claude-jev paneWith the flag set, /claude-jev opens a settings pane. Use it to:
scripts/stats.py report on router hints against what sessions did, rule calibration, and compactionWithout the flag, the four on/off rows are in /config.
The rule hook's comparators use ast-grep 0.45.3. If it isn't on your PATH, a detached process fetches the pinned release to ~/.claude/jev-bin the first time an edit needs it, and checks its sha256. Every hook works without it. To fetch it now, or to see which binary would run:
python3 scripts/comparators.py fetch
python3 scripts/comparators.py which
| Hook | Job | |
|---|---|---|
UserPromptSubmit | Classify the prompt and add a one-line routing hint | |
PreToolUse (`Agent\ | Task`) | Pick the model tier a subagent spawns on, and check its brief |
PostToolUse (edits) | Judge the edit against your instruction files | |
PreToolUse / PostToolUse (Bash) | Snapshot the git working tree around each command, so Stop sees what it changed | |
Stop | Judge the whole turn against the rules that need it | |
session.compact (experimental function hook) | Replace the compaction summary with the rows Jev keeps, so the summarizer never runs |
Slash commands, # lines, and prompts under 3 characters never reach Jev.
Rules come from the instruction files you already keep:
CLAUDE.md and ~/.claude/CLAUDE.mdAGENTS.md.claude/rules/* and .cursor/rules/*Nothing needs compiling, and nothing extra gets committed.
The hook classifies each file once per hash and caches the result under ~/.claude. Each rule gets four fields:
The hook splits long prose paragraphs into sentences first, so a rule at the end of a paragraph is judged on its own.
The ast-grep binary is 51 MB, pinned by sha256, and fetched once in a detached process. Until it lands, or if anything fails, every lookup returns nothing.
Stop hook judges them against all of the turn's hunks, where scope creep shows.Stop sees only hunks made since the latest user prompt, and skips a turn with none.hooks/register.ts hooks session.compact. On /compact, auto-compaction, and /rewind summaries, it hands the conversation to Jev as rows. The rows Jev keeps become the whole post-compaction context. No summary is written. Compaction takes under a second instead of 30–60 s. Without the flag, Claude Code compacts as it always has, and the plugin adds nothing to that path.
The hook keeps bytes, not prose:
/compact is named in the state, and every check defers to it.Jev judges only the newest 150 rows with all five checks, plus the 150 before them with the constraint check alone, so a requirement stated early in a long session still reaches Jev. Kept text is capped at 16k chars, and the lowest-confidence keeps are downgraded first. Jev's rows replace the summary however much they shrink it. Only a missing key, a Jev outage, or an error in the bridge falls through to the built-in summary.
To see which path ran, start Claude Code with -d and read ~/.claude/debug/<session-id>.txt after a compaction:
a hook's N messages stand (hooked by claude-jev); core never ran means Jev's rows replaced the summary.jev-compact: ... built-in summary runs means the bridge fell through, and the line says why.Each prompt costs one API call. It asks for intent, scope, and whether tools are needed. The call includes the previous turn, because most prompts are follow-ups.
lookup gets the hint "one search".fix gets "focused edit, narrow verification".feature and ops get no hint. Live and in replay, those hints were wrong more often than right.For subagent spawns, the hook sets model through updatedInput unless something already chose one: the call's model, CLAUDE_CODE_SUBAGENT_MODEL, a model: other than inherit in the agent's definition file, or a built-in with a fixed model (statusline-setup, claude-code-guide). general-purpose, Explore, Plan, and claude inherit the parent's model, so they are routed. An agent whose definition the hook cannot find is left alone. Every spawn still gets the brief check.
The hook picks the cheapest tier where Jev puts at most a 0.10 chance on the task needing a stronger one. On 300 past spawns where you named the model yourself, that rule matched your pick 160 times, went cheaper 51 times, and went dearer 89 times. The old 0.75 confidence gate routed only 110 of them, and the rest inherited an opus or fable parent: 92 matches, 50 cheaper, 158 dearer. See eval/README.md.
The tier question has four options. Its shipped text calls fable rare: meant for work where a cheaper tier would likely return a confident wrong answer, and never for implementation. To replace that text, add a ## Delegating to sub-agents section to ~/.claude/CLAUDE.md with - haiku: …, - sonnet: …, - opus: …, and - fable: … bullets.
The same call reviews the brief. When Jev is confident the task changes files, four yes/no checks ask whether the brief:
Any part scored at or below 0.25 counts as missing. The hook denies the spawn once and lists the missing parts, so the parent can rewrite the prompt. If the same brief comes back in that session, it goes through with a systemMessage. Read-only briefs skip the checks.
cd adapters/afk && npm install && npm run build
cp -r adapters/afk ~/.afk/plugins/claude-jev
Same enforcement, TypeScript, installable as an AFK plugin. The deltas run both ways: it adds a SessionStart rule digest the Claude Code plugin lacks, and it drops compaction and the subagent-spawn denial, which AFK's hooks cannot express. Known gaps: adapters/afk/README.md.
The rule eval judges edits inside real repos, against those repos' own rules, so its corpora aren't committed. eval/rules_eval.py extract pulls the reachable Edits and Writes from ~/.claude/projects. Those edits were accepted at the time, so any block counts as a measured false positive.
The v0.21.0 run judged a fresh 250-edit extract, each edit at its own commit, with the AskUserQuestion answers of the 22 edits that had them composed into the request the way the hook sends them. The v0.23.0 column is those same 247 judged records re-scored at the current source on cached answers (eval/rules_eval.py run --sample 250 --seed 0, 0 errors, 22/247 still carrying their answers), so the two columns are a same-corpus comparison rather than a new sample. Every v0.23.0 cell except latency comes from that pass:
| v0.21.0 | v0.23.0 | |
|---|---|---|
| Real edits blocked | 22 (8.9%) | 23 (9.3%) |
| Real edits flagged only | 8 | 9 |
| Hand-written violations blocked | 17/29 | 17/29 |
| Compliant near-misses blocked | 0/24 | 0/24 |
| Rules asked per edit, median | 8 | 8 |
| Latency, median | 0.80s | 0.40s |
Latency is the one row a cached re-score cannot produce. --out moves the answer file, not the cache, so a re-score times the cache and prints 0.01s. The 0.40s above comes from a live pass instead — JEV_RULES_CACHE=$(mktemp -d)/cache.jsonl python3 eval/rules_eval.py run --sample 250 --seed 0 --out /tmp/live.jsonl — which re-judged all 247 edits over 326 real calls with 0 errors and the pinned ast-grep present, and returned the same 23 blocks and 9 flags as the cached column. The drop from 0.80s is consistent with the per-chunk parallelism and the 9s deadline budget added since v0.21.0, but the two passes ran on different days against the same endpoint, so read it as an observation rather than a controlled before-and-after.
Most of the v0.21.0 blocks come from one repo's own code-comments-are-banned-in rule firing on 17 accepted edits (0.82–0.91): the current corpus reaches repos the old sample never did, so conflicts between a rule and the practice it governs are now visible in the number instead of hidden by the sample.
Both corpora are weak labels. A real edit counts as compliant because nobody objected at the time. The hand-written set is small enough that one case swings the score five points. The live decision log now records what happened after each block: repaired, retried identical, ignored, or abandoned. That's the signal for growing the case set. See the Stats row in /claude-jev, or run python3 scripts/stats.py.
python3 eval/rules_eval.py extract
python3 eval/rules_eval.py run --sample 250
python3 eval/rules_eval.py report
Stats also reports failures by HTTP status or timeout, recent call health, and last failure and success timestamps per caller. Its rule outcome labels are heuristics. "Repaired" means a later edit touched the flagged text. "Abandoned" means no later edit to that file. Neither verifies final compliance. Post-edit blocks do not undo writes. Calls alone cannot measure coverage, because disabled hooks and missing keys produce no API call.
eval/replay.py replays your past prompts, and scripts/observed.py scores them. Change the scorer and every number here moves.
Whether a shell command wrote anything is not guessed from its text. A Bash result in the transcript carries bashEditDiff, the file-state diff Claude Code took around the command, and it names the git-visible project files that changed — the same notion of a write the live hook uses, and the reason then tee out, { sed … ; } and perl -pi all count while a scratch file in /tmp does not. Claude Code 2.1.274 started recording it. On older transcripts the command-text patterns still decide, and against that diff they measure precision 0.25 over 6,143 real Bash calls.
| 2,464 prompts, taxonomy v7 | Coverage | Accuracy | Lift over always guessing | Harmful hints |
|---|---|---|---|---|
| Every hint v7 predicts | 43.1% | 33.8% | +8.3 | 8 |
| Only hints the hook shows | 14.1% | 48.0% | +5.2 | 8 |
A harmful hint is "no tools" followed by 5+ tool calls. The first row counts the feature and ops hints the hook never shows. The second scores the hook as it ships:
python3 eval/replay.py run --variant v7_no_unclear
python3 eval/replay.py report --variant v7_no_unclear
python3 eval/replay.py report --variant v9_hinted_only --preds eval/observed/pred_v7_no_unclear.jsonl
Humans labeled 16 of the hints the hook shows, and agreed with 11.
33.8% is a floor, because the labels come from transcripts. On 120 hand-labeled prompts (eval/audit_labels.json), humans agreed with the derived labels only 51.7% of the time. Coarser taxonomies score higher and help less: collapsing to talk/read/act reaches 65.0%, but always guessing act gets 63.6%, so the three-way router earns 1.4 points over a constant (python3 eval/replay.py report --variant v5_three_way, over the 1,627 prompts its predictions are cached for).
eval/compare.py takes the pre-compaction blocks at each compact_boundary in recorded transcripts (12 real, 60 synthetic). It runs them through the selection the hook uses.
| Jev selection | Default summary | |
|---|---|---|
| Context after compaction | 3.2–3.9k tok | 2.4–5.0k tok |
| Time to compact | ~0.9–1.1s | ~117s |
| Of 710 artifacts re-fetched afterward | 76–82% held verbatim | 73–91% mentioned |
A mention isn't the content. The artifacts the kept blocks lacked fell outside the judgment window or below the keep floor.
eval/planted.py tests whether what the user said survives. On 56 recorded sessions, it plants a constraint mid-transcript and buries a restatement at the end of a later reply. The planted prompt survived 100% of the time, up from 77% under the earlier two aggregate questions. The buried restatement survived 98%, up from 35%.
The live hook has run on Claude Code 2.1.278, one session each rather than a sweep. A 15-row session compacted in 0.7s with 7 rows kept. The live hook runs the same selection code the eval measures, so trust the eval numbers.
hooks/register.ts 540 lines1const HOOK_TIMEOUT_MS = 30000;
2
3const PLUGIN = "claude-jev";
4
5const PANE_ID = "claude-jev";
6
7const KEY_FIELD = "typesafeApiKey";
8
9const PENDING_KEY = "pendingSave";
10
11const MAX_KEY_LENGTH = 1024;
12
13const TOGGLES = [
14 ["promptRouter", "Prompt routing hints"],
15 ["subagentRouter", "Subagent model routing"],
16 ["rules", "Rule checks"],
17 ["compaction", "Compaction"],
18];
19
20const KEY_LABELS = { env: "from the environment", saved: "saved", missing: "missing" };
21
22const PROVIDER_LABELS = { typesafe: "TypeSafe", openrouter: "OpenRouter" };
23
24const PROVIDER_CHOICES = [
25 ["auto", "Auto (from the key)"],
26 ["typesafe", "TypeSafe"],
27 ["openrouter", "OpenRouter"],
28];
29
30const OFF_TERMINAL = "Open /claude-jev in the terminal, or change the claude-jev rows in /config.";
31
32let loaded = {};
33
34let view = "menu";
35
36let menuRow = "menu:key";
37
38let keyDraft;
39
40let info;
41
42let statusLine;
43
44let statsReport;
45
46const rowKey = (field) => `${PLUGIN}.${field}`;
47
48function isString(v: unknown): v is string {
49 return typeof v === "string";
50}
51
52function savedKey() {
53 const value = loaded[KEY_FIELD];
54
55 return isString(value) ? value.trim() : "";
56}
57
58function pinnedProvider(rows) {
59 const row = rows?.find((candidate) => candidate.key === rowKey("provider"));
60 const value = row ? row.value : loaded.provider;
61
62 return PROVIDER_CHOICES.some(([name]) => name === value) ? value : "auto";
63}
64
65function pluginEnv() {
66 const env: Record<string, string> = {};
67
68 env.CLAUDE_PLUGIN_OPTION_PROVIDER = pinnedProvider();
69
70 const key = savedKey();
71
72 if (key) env.CLAUDE_PLUGIN_OPTION_TYPESAFEAPIKEY = key;
73
74 return env;
75}
76
77function runNode($, args, stdin) {
78 const argv = ["node", "--experimental-strip-types", `${$.plugin.root}/${args[0]}`, ...args.slice(1)];
79 const base = { env: pluginEnv(), timeoutMs: HOOK_TIMEOUT_MS };
80
81 return stdin === undefined
82 ? $.process.run(argv, base)
83 : $.process.run(argv, { ...base, stdin });
84}
85
86function runPython($, args) {
87 return $.process.run(["python3", `${$.plugin.root}/scripts/${args[0]}`, ...args.slice(1)], {
88 env: pluginEnv(),
89 timeoutMs: HOOK_TIMEOUT_MS,
90 });
91}
92
93async function refreshInfo($) {
94 try {
95 const run = await runPython($, ["jev.py", "status"]);
96 info = run.exitCode === 0 ? JSON.parse(run.stdout) : { error: run.stderr.trim() };
97 } catch (err) {
98 info = { error: String(err) };
99 }
100}
101
102function keyLabel() {
103 if (!info) return "checking…";
104
105 if (info.error) return "unknown";
106 const provider = PROVIDER_LABELS[info.provider];
107
108 return provider ? `${KEY_LABELS[info.key]} · ${provider}` : KEY_LABELS[info.key];
109}
110
111async function loadStats($) {
112 try {
113 const run = await runPython($, ["stats.py"]);
114 statsReport = run.exitCode === 0 ? run.stdout.trimEnd() : `stats.py failed: ${run.stderr.trim().slice(0, 300)}`;
115 } catch (err) {
116 statsReport = `stats.py failed: ${String(err)}`;
117 }
118}
119
120function describeStatus() {
121 if (!info) return "Status unavailable.";
122
123 if (info.error) return `jev.py status failed: ${info.error.slice(0, 200)}`;
124 const call = info.last_call;
125
126 const last = !call
127 ? "no calls logged yet"
128 : `Last call ${call.ok ? "ok" : "failed"} in ${call.ms} ms at ${call.ts}${
129 call.ok ? "" : `: ${String(call.error ?? "").slice(0, 160)}`
130 }`;
131
132 return `claude-jev ${info.version}. Key ${keyLabel()}. ${last}.`;
133}
134
135function parseKey(text) {
136 const value = text.trim();
137
138 if (!value) throw new Error("Paste a TypeSafe or OpenRouter key, or press Esc to go back.");
139
140 if (value.length > MAX_KEY_LENGTH) throw new Error("That value is too long to be an API key.");
141
142 if ([...value].some((ch) => ch.charCodeAt(0) < 32 || ch.charCodeAt(0) === 127)) {
143 throw new Error("The key cannot contain control characters.");
144 }
145
146 return value;
147}
148
149function isOn(rows, field) {
150 const row = rows.find((candidate) => candidate.key === rowKey(field));
151
152 return (row ? row.value : loaded[field]) !== false;
153}
154
155async function save($, field, value, message) {
156 await $.store.set(PENDING_KEY, { row: menuRow, message });
157 const result = await $.config.set({ key: rowKey(field), value });
158
159 if (result.deny !== undefined) {
160 await $.store.delete(PENDING_KEY);
161 $.ui.toast(`Not saved: ${result.deny}`, { timeoutMs: 8000 });
162
163 return false;
164 }
165
166 loaded = { ...loaded, [field]: value };
167 info = undefined;
168
169 return true;
170}
171
172function openPane($) {
173 return $.ui.open({
174 id: PANE_ID,
175 title: "claude-jev (saved for all sessions)",
176 focus: true,
177 closeOnEscape: true,
178 rows: 12,
179 });
180}
181
182async function paneOpen($) {
183 try {
184 return (await $.ui.panes()).some((pane) => pane.id === PANE_ID);
185 } catch {
186 return false;
187 }
188}
189
190async function placeRing($, key, attempts = 20) {
191 for (let attempt = 0; attempt < attempts; attempt++) {
192 try {
193 if ((await $.ui.focus({ requestId: PANE_ID, key })).deny === undefined) return;
194 } catch {
195 return;
196 }
197
198 await $.clock.sleep(50);
199 }
200}
201
202function showMenu(row) {
203 view = "menu";
204
205 if (row !== undefined) menuRow = row;
206 keyDraft = undefined;
207}
208
209async function resumeAfterSave($) {
210 const pending = await $.store.get(PENDING_KEY);
211
212 if (pending === undefined) return;
213 await $.store.delete(PENDING_KEY);
214
215 if (isString(pending.message)) $.ui.toast(pending.message, { timeoutMs: 6000 });
216
217 if (isString(pending.row) && (await paneOpen($))) {
218 showMenu(pending.row);
219 await openPane($).catch(() => undefined);
220 await placeRing($, pending.row);
221 }
222}
223
224function drawPane($, e, rows) {
225 const { Box, Text, Input, Button } = $.ui.resolve(e);
226 const column = (children) => Box({ flexDirection: "column", children });
227
228 const heading = (crumb) =>
229 Box({
230 flexDirection: "row",
231 marginBottom: 1,
232 children: [
233 Text({ bold: true, children: "claude-jev" }),
234 Text({ dimColor: true, children: crumb ? ` › ${crumb}` : " saved for all sessions" }),
235 ],
236 });
237
238 const hint = (leave) => Text({ dimColor: true, children: `↑↓ move · Enter select · Esc ${leave}` });
239
240 const redraw = async (focus) => {
241 await $.ui.invalidate("ui.render");
242
243 if (focus) await placeRing($, focus);
244 };
245
246 const run = (action, focus) => {
247 void action()
248 .catch((err) => $.ui.toast(err instanceof Error ? err.message : String(err), { timeoutMs: 8000 }))
249 .finally(() => redraw(focus));
250 };
251
252 const list = (entries, focus) => {
253 const width = Math.max(...entries.map((entry) => entry.label.length));
254
255 return column(
256 entries.map((entry) => {
257 const props = {
258 key: entry.key,
259 label: entry.label.padEnd(width),
260 plain: true,
261 onPress: entry.onPress,
262 };
263
264 if (entry.dim) props.dimColor = true;
265
266 if (entry.key === focus) props.autoFocus = true;
267
268 return Button(props);
269 }),
270 );
271 };
272
273 const back = () => {
274 showMenu();
275 void redraw(menuRow);
276 };
277
278 if (view === "provider") {
279 const current = pinnedProvider(rows);
280
281 return column([
282 heading("Provider"),
283 Text({
284 dimColor: true,
285 wrap: "wrap",
286 children:
287 "Auto reads TYPESAFE_API_KEY, then OPENROUTER_API_KEY, and lets the key pick. A pinned provider reads only its own variable, or the saved key.",
288 }),
289 list(
290 [
291 ...PROVIDER_CHOICES.map(([name, label]) => ({
292 key: `provider:${name}`,
293 label: `${name === current ? "●" : " "} ${label}`,
294 onPress: () => {
295 showMenu();
296
297 if (name === current) void redraw(menuRow);
298 else run(() => save($, "provider", name, `Provider: ${label} (all sessions).`), menuRow);
299 },
300 })),
301 { key: "provider:back", label: " Back", dim: true, onPress: back },
302 ],
303 `provider:${current}`,
304 ),
305 hint("back"),
306 ]);
307 }
308
309 if (view === "stats") {
310 return column([
311 heading("Stats"),
312 Text({ dimColor: true, children: "PgUp/PgDn scroll · Esc back" }),
313 list([{ key: "stats:back", label: "Back", dim: true, onPress: back }], "stats:back"),
314 ...(statsReport ?? "Reading the logs…").split("\n").map((line) => Text({ wrap: "wrap", children: line || " " })),
315 ]);
316 }
317
318 if (view === "key") {
319 const saved = savedKey() !== "";
320
321 return column([
322 heading("API key"),
323 Text({
324 dimColor: true,
325 wrap: "wrap",
326 children:
327 "TypeSafe or OpenRouter key; an sk-or- key calls OpenRouter. TYPESAFE_API_KEY or OPENROUTER_API_KEY in the launch environment wins over a saved key.",
328 }),
329 Input({
330 key: "key:input",
331 label: "Key",
332 value: keyDraft?.text ?? "",
333 placeholder: saved ? "paste a key to replace the saved one" : "paste a key to save it",
334 submitLabel: "save",
335 autoFocus: true,
336 onSubmit: (text) =>
337 run(async () => {
338 try {
339 if (await save($, KEY_FIELD, parseKey(text), "API key saved (all sessions).")) showMenu();
340 else keyDraft = { text };
341 } catch (err) {
342 keyDraft = { text, error: err instanceof Error ? err.message : String(err) };
343 }
344 }),
345 }),
346 ...(keyDraft?.error ? [Text({ color: "error", children: keyDraft.error })] : []),
347 list(
348 [
349 ...(saved
350 ? [
351 {
352 key: "key:clear",
353 label: "Clear saved key",
354 onPress: () =>
355 run(async () => {
356 if (await save($, KEY_FIELD, "", "Saved API key cleared (all sessions).")) showMenu();
357 }),
358 },
359 ]
360 : []),
361 { key: "key:back", label: "Back", dim: true, onPress: back },
362 ],
363 "",
364 ),
365 hint("back"),
366 ]);
367 }
368
369 const setting = (label, value) => `${label.padEnd(24)}${value}`;
370
371 return column([
372 heading(),
373 list(
374 [
375 {
376 key: "menu:key",
377 label: setting("API key", keyLabel()),
378 onPress: () => {
379 view = "key";
380 menuRow = "menu:key";
381 void redraw("key:input");
382 },
383 },
384 {
385 key: "menu:provider",
386 label: setting("Provider", Object.fromEntries(PROVIDER_CHOICES)[pinnedProvider(rows)]),
387 onPress: () => {
388 view = "provider";
389 menuRow = "menu:provider";
390 void redraw(`provider:${pinnedProvider(rows)}`);
391 },
392 },
393 ...TOGGLES.map(([field, label]) => {
394 const on = isOn(rows, field);
395
396 return {
397 key: `menu:${field}`,
398 label: setting(label, on ? "On" : "Off"),
399 onPress: () => {
400 menuRow = `menu:${field}`;
401 run(() => save($, field, !on, `${label} ${on ? "off" : "on"} (all sessions).`), menuRow);
402 },
403 };
404 }),
405 {
406 key: "menu:stats",
407 label: "Stats",
408 onPress: () => {
409 view = "stats";
410 menuRow = "menu:stats";
411 statsReport = undefined;
412 run(() => loadStats($), "stats:back");
413 },
414 },
415 {
416 key: "menu:status",
417 label: "Status",
418 onPress: () => {
419 menuRow = "menu:status";
420
421 if (info) {
422 statusLine = describeStatus();
423 void redraw(menuRow);
424 }
425
426 run(async () => {
427 await refreshInfo($);
428 statusLine = describeStatus();
429 }, menuRow);
430 },
431 },
432 { key: "menu:close", label: "Close", onPress: () => void $.ui.close({ id: PANE_ID }) },
433 ],
434 menuRow,
435 ),
436 ...(statusLine ? [Text({ dimColor: true, wrap: "wrap", children: statusLine })] : []),
437 hint("close"),
438 ]);
439}
440
441export function register(on, options) {
442 loaded = options ?? {};
443
444 on("session.start", async ($, e, next) => {
445 if (e.isInteractive) {
446 await $.command.register({
447 name: PLUGIN,
448 description: "Manage claude-jev: API key, provider, and which hooks run",
449 });
450 await resumeAfterSave($).catch(() => undefined);
451 }
452
453 return next(e);
454 });
455
456 on("command.run", { command: PLUGIN }, async ($, _e, _next) => {
457 showMenu("menu:key");
458 statusLine = undefined;
459 await refreshInfo($);
460 await openPane($);
461 await placeRing($, menuRow);
462
463 return {};
464 });
465
466 on("config.describe", { key: "claude-jev.typesafeApiKey" }, async (_$, e, next) => ({
467 ...(await next(e)),
468 isHidden: true,
469 }));
470
471 on("ui.close", { id: PANE_ID }, async ($, e, next) => {
472 if (e.origin.kind !== "person" || view === "menu") return next(e);
473 showMenu();
474 await $.ui.invalidate("ui.render");
475 await openPane($).catch(() => undefined);
476 await placeRing($, menuRow);
477
478 return { value: undefined };
479 });
480
481 on("ui.render", { component: "Pane" }, async ($, e, next) => {
482 if (e.requestId !== PANE_ID) return next(e);
483
484 if (e.surface !== "terminal") return $.ui.resolve(e).Text({ children: OFF_TERMINAL });
485
486 if (info === undefined) void refreshInfo($).then(() => $.ui.invalidate("ui.render"));
487
488 return drawPane($, e, await $.config.list());
489 });
490
491 on("session.compact", async ($, e, next) => {
492 if (loaded.compaction === false) return next(e);
493
494 const fallThrough = async (why) => {
495 await $.ui.log(`jev-compact: ${why}; built-in summary runs`);
496
497 return next(e);
498 };
499
500 let run;
501
502 try {
503 const [cwd, sessionId] = await Promise.all([$.session.cwd(), $.session.id()]);
504 run = await runNode(
505 $,
506 ["src/compactor.ts", "rows"],
507 JSON.stringify({
508 trigger: e.trigger,
509 instructions: e.instructions ?? null,
510 cwd,
511 session_id: sessionId,
512 messages: e.messages,
513 }),
514 );
515 } catch (err) {
516 return fallThrough(`bridge failed: ${String(err)}`);
517 }
518
519 if (run.exitCode !== 0) {
520 return fallThrough(`compactor exit ${run.exitCode}: ${run.stderr.slice(0, 300)}`);
521 }
522
523 let out;
524
525 try {
526 out = JSON.parse(run.stdout);
527 } catch (err) {
528 return fallThrough(`unreadable compactor.ts output: ${String(err)}`);
529 }
530
531 if (!out || !Array.isArray(out.messages)) {
532 return fallThrough(out?.fallback ?? "no rows returned");
533 }
534
535 await $.ui.log(`jev-compact: ${out.summary ?? `${out.messages.length} rows returned`}`);
536
537 return { messages: out.messages };
538 });
539}
540