SLOPSHOPPER

claude-jev

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…

newpanecommandtoastprocess
★ 21v0.29.2MITupdated 2026-10-050x7067/claude-jev
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claude-jev
│ ┃ claude-jev (saved for all sessions) ✕ › fix the failing auth test and add an audit log call │ ┃ claude-jev saved for all sessions │ ┃ ⏺ Read(src/auth.ts) │ ┃ API key unknown ⎿ Read 6 lines │ ┃ Provider Auto (from the key) ⏺ Update(src/auth.ts) │ ┃ Prompt routing hints On ⎿ Added 2 lines, removed 1 line │ ┃ Subagent model routing On ⏺ Bash(bun test) │ ┃ Rule checks On ⎿ 3 pass, 1 fail │ ┃ Compaction On │ ┃ Stats ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Status │ ┃ Close ✻ Worked for 42s · done 4:20 PM │ ┃ ↑↓ move · Enter select · Esc close │ › /claude-jev │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · claude-jev (saved for all sessions)
claude-jev saved for all sessions API key unknown Provider Auto (from the key) Prompt routing hints On Subagent model routing On Rule checks On Compaction On Stats Status Close ↑↓ move · Enter select · Esc close
README

claude-jev

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:

  • Rules that hold. Edits that break your instruction files get blocked with a file:line citation. Re-scoring the same 247 accepted edits at v0.23.0 blocks 23 of them (9.3%); the v0.21.0 run blocked 22 (8.9%), 17 of them under a single repo's own comment-ban rule.
  • Compaction in about a second instead of a minute or two. Jev keeps the exact rows that matter instead of writing a summary. Planted user constraints survived 100% of the time.
  • Right-sized subagents. Each spawn gets a model tier. A brief that changes files but leaves out paths, acceptance criteria, verification, or commit policy is sent back once.
  • Routing hints. Each prompt gets a one-line hint such as "one search" or "focused edit, narrow verification."

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.

Install

Claude Code (Python)

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

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.

Keys and providers

  • If both variables are set, the plugin reads TYPESAFE_API_KEY first.
  • A key that starts with sk-or-, in either variable, sends every call to OpenRouter's System One API instead of api.typesafe.ai.
  • To use OpenRouter while both are set, pick it in the Provider row of /claude-jev or /config. A pinned provider reads only its own variable.

The function-hooks flag

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.

The /claude-jev pane

With the flag set, /claude-jev opens a settings pane. Use it to:

  • save an API key for all sessions (a key in the launch environment wins over the saved one)
  • pick the provider
  • turn each hook on or off
  • see the last Jev call
  • read the Stats row: the scripts/stats.py report on router hints against what sessions did, rule calibration, and compaction

Without the flag, the four on/off rows are in /config.

ast-grep (optional)

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

The hooks

HookJob
UserPromptSubmitClassify 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
StopJudge 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

Rules come from the instruction files you already keep:

  • CLAUDE.md and ~/.claude/CLAUDE.md
  • nested AGENTS.md
  • .claude/rules/* and .cursor/rules/*

Nothing needs compiling, and nothing extra gets committed.

How a rule is read

The hook classifies each file once per hash and caches the result under ~/.claude. Each rule gets four fields:

  • Kind: an instruction about written code, or a fact, pointer, or process rule.
  • Scope: per edit, or the whole turn.
  • Polarity: forbid or require.
  • Subject: imports, comments, naming, types, tests, errors, literals, files, process, or other.

The hook splits long prose paragraphs into sentences first, so a rule at the end of a paragraph is judged on its own.

How an edit is judged

  1. Filter locally. A cheap test per subject drops the rules a hunk can't break. For example, an import rule is never asked about an edit that touches no import line. The v0.21.0 run had the filter remove 36% of in-scope checks; the median edit carried 14 rules in scope and was asked 8 questions.
  2. Look up context with ast-grep. Some violations can't be seen in a hunk. Three subjects get a deterministic search of the repository first. The search finds the constant that already holds a literal the edit inlines, an assertion whose two sides are identical, and the callers of a function whose error handling changed.
  3. Ask Jev once. One batched call asks a yes/no question per remaining rule. Polarity picks the question: does the new code do the forbidden thing, or does it add a case the rule clearly covers without the required element? Jev scores each one as the probability the rule is broken. It sees the old→new hunk, your last prompt, and the lines around the edit. For import rules it also sees the sibling modules in the file's directory. An edit gets at most 40 questions, with path-scoped rules first and files taking turns.
  4. Decide. At 0.80 the hook blocks the edit and cites file:line. Below 0.50 it says nothing.
  5. Escalate the unsure ones. A rule between 0.50 and 0.80 gets a second call, with all such rules in one request. That call adds the enclosing function, read from disk after the edit, and the sentences around the rule in its instruction file. The second answer decides. Anything still uncertain is flagged to you only. About 14% of edits pay for that second call.

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.

Limits and whole-turn rules

  • A rule blocks the same file at most twice per session, because an unlandable repair would loop. A later act-band match on that rule and file is a notice that it was already raised. The agent is not told again. A flag-band match stays an uncertainty notice. Calibration can move one rule's act bar off 0.80. Stop blocks at most twice per session, across every turn rule. A hit held back by that cap says the session already used its two turn blocks. It does not claim that rule was cited.
  • Vendored, generated, and out-of-project paths are never judged.
  • Whole-turn rules skip the per-edit check. Examples: minimal changes, no single-caller abstraction, no unrelated refactoring. The 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.
  • Bash commands count too. The hook snapshots the git working tree, untracked files included, before each command and diffs it after. When a snapshot fails, for example outside a git repository, Jev is told the diff is partial.

Compaction

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:

  • Harness rows (slash-command wrappers, caveats) and one-word acks are dropped locally.
  • Every other row gets five yes/no checks. Does it state a user constraint? Record a decision and its reason? Hold an exact error? Name open work? Would a rerun print the same output again?
  • Code turns the answers into a verdict. The keep score is the strongest of the four keep checks.
  • A row with a high constraint or error score stays verbatim — unless rerunnable answers yes, in which case the rerun would print it again and a head with the re-run pointer stands in for the whole dump. Any other kept row becomes a truncated head plus a pointer to re-read it.
  • Kept plain messages come back byte-identical. Kept tool calls and results come back as text. Unscored rows are kept.
  • A fixed one-line header opens the compacted context. Any text you type after /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.

Routing

Prompts

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.

  • A lookup gets the hint "one search".
  • A fix gets "focused edit, narrow verification".
  • feature and ops get no hint. Live and in replay, those hints were wrong more often than right.
  • Below 0.75 confidence, the router stays silent.
  • The no-tools hint fires only on a near-certain yes/no answer, because skipping needed work is the expensive mistake.

Subagents

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:

  • names the paths
  • states acceptance criteria
  • names a verification command
  • states a commit policy

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.

AFK (TypeScript)

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.

Does it work?

Rule hook

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.0v0.23.0
Real edits blocked22 (8.9%)23 (9.3%)
Real edits flagged only89
Hand-written violations blocked17/2917/29
Compliant near-misses blocked0/240/24
Rules asked per edit, median88
Latency, median0.80s0.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.

Router

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 v7CoverageAccuracyLift over always guessingHarmful hints
Every hint v7 predicts43.1%33.8%+8.38
Only hints the hook shows14.1%48.0%+5.28

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).

Compaction

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 selectionDefault summary
Context after compaction3.2–3.9k tok2.4–5.0k tok
Time to compact~0.9–1.1s~117s
Of 710 artifacts re-fetched afterward76–82% held verbatim73–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.

Source 1 files
hooks/register.ts 540 lines
1const 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