Test-first workflow for coding agents, without the spec ceremony. A PreToolUse hook keeps source edits behind a mirror test and protects existing assertions…

lexi reads the task, proposes unit tests, waits for your OK, then drives RED → GREEN.<br> No spec documents, nothing to clean up: the tests are the spec, and they stay in the repo.
Quick start · How it works · The guard · Commands · Examples
Agents say they do TDD. Then they write the code, write a test that matches it, and bend the assertion until it goes green. lexi makes that impossible, not just discouraged.
| 🧪 Test first, mechanically | A guard blocks any edit to a source file whose test does not exist yet. |
| 🔒 No bending tests to green | Rewriting a committed assertion is blocked. Appending new tests is always fine. |
| 🧭 Right flow for the task | Bug, clear feature or open feature: the router picks the path, you confirm the tests. |
| ✂️ Minimal GREEN | ponytail keeps every implementation step as small as it can be. |
| 🔁 Two runtimes, one flow | Same skills and same guard on Claude Code and Pi (@earendil-works/pi-coding-agent). |
| 🤖 Optional review with jev | Picks model and effort per session, compacts context verbatim, and reviews the diff after the last GREEN with a verdict computed in code. Off until init turns it on. |
/lexi:lexi in src/domain/discount.js add applyDiscount(total, pct) capped at 50%
Proposed tests:
1. applies a 10% discount to total
2. caps discount at 50% of total
3. rejects a negative percentage
Confirm or add? › ok
test 1 RED ✗ → GREEN ✓
test 2 RED ✗ → GREEN ✓
test 3 RED ✗ → GREEN ✓
Report: 3 tests, 1 file touched, no untested code.
And when the agent tries to skip ahead:
lexi — write to `src/domain/discount.js` BLOCKED.
`src/domain/discount.js` is under `testable` and mirror test does not exist:
tests/domain/discount.test.js
The fast way. Open claude in your project and paste this prompt. It installs the suite (lexi, ponytail, caveman) and prepares the Jev key:
Install the lexi suite for Claude Code. Run these shell commands, stopping on the first failure:
claude plugin marketplace add DietrichGebert/ponytail
claude plugin marketplace add JuliusBrussee/caveman
claude plugin marketplace add savinofiore/lexi
claude plugin install lexi@lexi
Then check .claude/settings.local.json in this project: make sure git ignores it (git check-ignore;
if it is tracked, untrack it and add it to .gitignore). If env.TYPESAFE_API_KEY is missing there, add it
with the value "PASTE_YOUR_KEY_HERE" and keep every other setting. Never write the key in a committed
file, and never print its value. Finish by listing what got installed, and remind me to put my key there, restart the session, and run
/lexi:init answering yes to Jev.
In auto mode Claude Code may refuse to edit its own settings files ([Self-Modification]): that is by design. Add the key by hand as in step 2 below.
Then restart claude in the same folder (new plugins load only in a new session), accept the trust prompt, and run /lexi:init. Answer yes to Jev.
1. Install. Add the dependency marketplaces, then lexi. It brings ponytail and caveman with it.
/plugin marketplace add DietrichGebert/ponytail
/plugin marketplace add JuliusBrussee/caveman
/plugin marketplace add savinofiore/lexi
/plugin install lexi@lexi
From a shell: claude plugin marketplace add savinofiore/lexi && claude plugin install lexi@lexi.
2. Jev key (only for Jev). Put it in the project's .claude/settings.local.json, one key per project. Claude Code merges its env into every hook and command. Check that git ignores the file (git check-ignore .claude/settings.local.json); a tracked local file leaks the key on the next push:
{ "env": { "TYPESAFE_API_KEY": "..." } }
3. Verify. Restart the session and run /plugin. You should see:
lexi: 7 skills (init, lexi, bug, feature, tdd, grill, code-review), 1 PreToolUse hook and 1 hooks module4. Opt the project in. Open claude in the project folder, accept the trust prompt (Claude Code reads project settings only in a trusted folder), then run /lexi:init.
5. Interface (optional). With CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 lexi also draws, above the prompt: the model and effort the Jev router chose, context and rate-limit bars with time to reset, tokens and cost (no statusLine command needed); lexi:GREEN/lexi:RED and the last gate run; a toast when the guard blocks an edit; and the running subagents, one color per type, with their step count and ✗ after a failed step. /subagents opens a pane that lists them (running first, with the model each runs on, ▶/✓/✗ and its steps) and opens each onto its tool calls, answer, duration and tokens.
.lexi.json with the gate, the paths and the testable list..claude/settings.json: the lexi, ponytail and caveman marketplaces under extraKnownMarketplaces with autoUpdate: true, plus enabledPlugins: { "lexi@lexi": true }. Commit this file. Teammates who trust the folder are offered lexi, and later versions reach them with any new dependencies (third-party marketplaces do not auto-update by default)."jev": {} in .lexi.json;CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 and CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=50 in .claude/settings.json. While Claude Code's plugin-hooks rollout is off server-side, the settings value is read too late: every teammate also needs export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in their shell profile (~/.zshrc), or the router stays off silently;.claude/skills/code-review/ and .claude/skills/review/. A project skill replaces the built-in one with the same name, so /code-review and /review run Jev's review in that project. Delete the two folders to get the built-in review back.The next session confirms the router on its first prompt: [jev-router] session: ….
1. Install. pi-subagents is optional: it runs the gate in an isolated subagent.
pi install npm:pi-subagents # optional
pi install git:github.com/JuliusBrussee/caveman
pi install git:github.com/savinofiore/lexi
2. Jev key (only for Jev). Same place as on Claude Code: env.TYPESAFE_API_KEY in the project's gitignored .claude/settings.local.json. A shell export (export TYPESAFE_API_KEY=...) also works and wins.
3. Opt the project in. Approve the folder (once, or pi -a for one run), then run:
/lexi-init
[!TIP] Use
/lexi-init, not/skill:lexi-init: the bare skill command sends no request, and the model only answers that it is waiting for a task.
Key lookup. Pi has no env block. The router, compaction and review read the shell first, then env.TYPESAFE_API_KEY in the project's .claude/settings.local.json. ~/.claude/settings.json is not read: the key is per project, so both runtimes share the one file.
Extra init questions. Besides the gate and the testable paths, init asks two questions on Pi:
.pi/agents/lexi-gate.md and "gate_agent": true. Answer "inline" to keep the gate in the driving model.pi --list-models). It writes "jev": { "tiers": … } in .lexi.json and compaction.modelOverrides in .pi/settings.json, so compaction runs at ~50%.Package contents, declared in package.json's pi field:
pi/extensions/lexi-guard.ts: the guard, wrapping the same hooks/tdd_guard.pypi/extensions/lexi-init.ts: the /lexi-init commandpi/extensions/caveman.ts: caveman's rules on every turn, off with "stop caveman" or "normal mode"pi/extensions/jev-router/, pi/extensions/jev-compact/: jev, inert without a jev key in .lexi.jsonpi/skills/: lexi-init, lexi-lexi, lexi-bug, lexi-feature, lexi-grill, lexi-tdd, jev-code-reviewLocal development: pi install /absolute/path/to/lexi (edits to pi/ and jev/ are live).
Describe the task with /lexi:lexi. The router picks one of three paths. You rarely have to type it: in a lexi project a one-line hint at session start points every code change at /lexi:lexi, and with Jev enabled each prompt is classified as bug, feature or open scope and sent straight to that skill.
flowchart LR
T([your task]) --> R{router}
R -->|bug| B[rewrite existing tests<br/>to RED: prove the bug]
R -->|clear feature| F[propose 3–5 unit tests]
R -->|open feature| G[grill: settle open<br/>decisions in rounds]
G --> F
F --> C{you confirm}
C --> L[per test:<br/>RED → GREEN]
B --> X[fix code to GREEN]
L --> J[Jev review<br/>if enabled]
X --> J
J --> P([report])
testable goes back through RED → GREEN.Bug fix
lexi router <bug description>
→ bug skill
→ analyze, find root cause
→ identify existing tests that should fail if the bug exists
→ propose rewrites (how to make them RED) → wait for confirmation + additions
→ rewrite tests listed in .lexi/allow
→ RED: run gate
✅ fails for the bug reason → proceed
❌ fails for a setup reason → fix test
❌ passes → test does not prove the bug → go back
→ fix production code (minimal, ponytail governs)
→ GREEN: run gate
✅ passes → next
❌ fails after 3 attempts → report, stop
❌ green without the fix → fix was unnecessary → go back
→ empty .lexi/allow
→ Jev review (if enabled)
→ report: files touched, root cause fixed, tests stay as regression
Feature with clear scope
lexi router <clear feature description>
→ feature skill
→ propose 3-5 unit tests (no widget tests)
"applies 10% discount", "caps at 50%", "rejects negative", …
→ wait for confirmation (approve, add any I missed)
→ confirm seams (public boundaries under test)
→ per test, in order:
1. RED: write ONE test, run gate
✅ fails for the test reason → proceed
❌ fails for a setup reason → fix test
❌ passes → test asserts nothing → verify
2. GREEN: minimal production code (ponytail governs)
3. run gate → passes → next test
→ Jev review (if enabled)
→ report: tests written, production files touched, untested code and why
Feature with open scope
lexi router <vague feature description>
→ scope is open (several defensible designs)
→ grill skill
→ Q1 with recommendation → your answer
→ Q2 with recommendation → your answer
→ … (blocked questions held for the next round)
→ frontier empty → done
→ back to router → feature skill → same as clear scope
Manual flow (legacy)
lexi router <task>
→ propose seams manually (skips feature)
seam: function → mirror test file
tests: list in order
breaking: which existing tests change
outside testable: what has no test
→ confirm seams → RED→GREEN cycle
A hook (Claude Code) or extension (Pi) checks every edit once .lexi.json exists. No command needed. Shell commands are checked too, for the files they write through >, >>, tee or sed -i (an append counts as adding a test, > and sed -i as rewriting one).
| Rule | Why |
|---|---|
A source file under testable cannot be written before its mirror test exists | Test first, mechanically |
| A committed test cannot have its assertions rewritten | No bending a test to reach green |
Everything outside testable (UI, design tokens, generated code, platform bindings) is free to edit.
❌ BLOCKED
lexi — edit to `tests/domain/total.test.js` BLOCKED.
Test file is tracked by git and edit replaces existing content.
Rewriting an assertion to reach green is what this guard stops.
✅ ALLOWED
src/components/card.js ← outside testable
tests/domain/new.test.js ← untracked, about to be written
tests/domain/total.test.js ← appending a new case, existing ones intact
Sometimes an existing test is really obsolete. Name it when the flow proposes tests (breaking: tests/domain/cart.test.js, 'total is the sum of line items'). Once you agree, the file is released:
echo tests/domain/cart.test.js >> .lexi/allow # release one test file
# rewrite the test, run the gate
: > .lexi/allow # cleared when the gate passes
One breaking test per slice, never in bulk. A test found "obsolete" mid-implementation is not pre-approved: the agent stops and asks. .lexi/allow is per-task scratch, gitignored by init.
.lexi/allow: one test path per line, releases rewrites on those files only.LEXI_OFF=1: disables the guard for the session. Set it outside the agent, for emergencies.| Claude Code | Pi | Use when |
|---|---|---|
/lexi:lexi <task> | /skill:lexi-lexi <task> | Start here. Any task, feature or bug: routes to the right flow |
/lexi:init | /lexi-init | Once per project, and again after an update |
/lexi:bug <task> | /skill:lexi-bug <task> | A bug report with broken behavior |
/lexi:feature <task> | /skill:lexi-feature <task> | A feature with clear scope |
/lexi:grill <task> | /skill:lexi-grill <task> | A feature with open scope (feature can call it) |
/lexi:tdd | /skill:lexi-tdd | Reference: seams, assertions, mocking, anti-patterns |
/lexi:code-review | /skill:jev-code-review | Jev review of the working tree, a ref, a PR or a diff |
With Jev on, Claude Code's /code-review and /review also route to Jev, and the flows call the review on their own after the last GREEN.
init writes .lexi.json. Same file and format on both runtimes:
{
"gate": "flutter test",
"source": "lib/",
"tests": "test/",
"test_suffix": "_test.dart",
"testable": ["lib/models/", "lib/repositories/", "lib/utils/"]
}
Mirror rule: <source><rel>.<ext> maps to <tests><rel><test_suffix>. For co-located tests, set tests equal to source.
| Optional key | Effect |
|---|---|
"jev": {} | Turns on the review after the last GREEN (both runtimes) and, on Pi, the router and compaction. See jev for tiers and the conventions file .lexi/review.json. "jev": false records a "no" so updates stop asking |
"gate_agent": true | Pi only: runs the gate through the lexi-gate subagent defined in .pi/agents/lexi-gate.md. Absent: the gate runs inline. Claude Code ignores it |
{ "gate": "flutter test", "source": "lib/", "tests": "test/",
"test_suffix": "_test.dart", "testable": ["lib/models/", "lib/repositories/"] }
{ "gate": "npx vitest run", "source": "src/", "tests": "src/",
"test_suffix": ".test.ts", "testable": ["src/domain/", "src/lib/"] }
{ "gate": "pytest -q tests/unit", "source": "app/", "tests": "tests/unit/",
"test_suffix": "_test.py", "testable": ["app/services/", "app/parsers/"] }
The Vitest line is co-located: src/cart/total.ts maps to src/cart/total.test.ts.
Examples use Claude Code syntax. On Pi, use /skill:lexi-*.
/lexi:lexi discount calculation is negative for high percentages
Root cause: the formula is (pct * total) - total instead of total - (pct * total).
The existing test applies 10% discount is rewritten to prove the bug:
expect(applyDiscount(100, 10)).toBe(90)expect(applyDiscount(100, 10)).toBe(-10)RED: gate fails, bug confirmed. Fix:
export const applyDiscount = (total, pct) => total - (pct / 100) * total;
GREEN: gate passes. .lexi/allow is emptied. The tests stay as regression.
/lexi:lexi in src/domain/discount.js add applyDiscount(total, pct) capped at 50%
Proposed tests:
You confirm. Then three loops: test 1 RED → minimal code GREEN, test 2 RED → cap logic GREEN, test 3 RED → validation GREEN. Report: three tests, one file touched, no untested code.
/lexi:lexi support promo codes at checkout
Scope is open, so the grill skill runs first:
Back to the router, then the feature skill proposes:
{ ok: false, reason: 'not_found' }{ ok: false, reason: 'expired' }Breaking: total is the sum of line items will change. You confirm. Five RED→GREEN loops. Report: five tests, three files touched.
An update never breaks a project that is already set up. The guard reads only its own keys in .lexi.json, and every new step stays off until the project opts in.
# Claude Code
/plugin marketplace update lexi
/plugin update lexi@lexi
# Pi
pi update git:github.com/savinofiore/lexi
Then rerun init in each project (/lexi:init or /lexi-init). On an existing .lexi.json it keeps gate, testable and the rest, and asks only what the new version added. On Pi it also installs ponytail or caveman if missing. Skipping this step is fine: the project keeps working as before, without the new features.
jev or lexi-mods (lexi < 0.12.0)Since 0.12.0 lexi ships the Jev layer and the interface itself. jev and lexi-mods are now empty stubs that only say so. Move over once:
/plugin uninstall jev@lexi
/plugin uninstall lexi-mods@lexi
/plugin marketplace update lexi
/plugin update lexi@lexi
Restart claude, then run /lexi:init in each project. On an existing .lexi.json it rewrites the review shims (.claude/skills/code-review and review) to call /lexi:code-review and drops jev@lexi and lexi-mods@lexi from .claude/settings.json. Commit the result.
What was renamed: /jev:code-review is /lexi:code-review, /lexi-mods:agents is /subagents (the engine owns /agents). Nothing else changes: same .lexi.json, same key, same flags.
Before 0.9.0 the Jev key lived globally (~/.claude/settings.json or a shell export). From 0.9.0 it lives per project in the gitignored .claude/settings.local.json.
env.TYPESAFE_API_KEY in ~/.claude/settings.json and any export TYPESAFE_API_KEY= line in ~/.zshrc, ~/.zprofile, ~/.bashrc, ~/.bash_profile, ~/.profile..claude/settings.local.json ({ "env": { "TYPESAFE_API_KEY": "..." } }) and check git check-ignore .claude/settings.local.json.export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 to your shell profile: the router stays off without it.claude. Editors built on VS Code read the shell environment once at launch, so an old exported key survives until the app restarts.Get the key over a separate channel (a password manager), never in chat. If an old global key was shared, revoke it.
| What | Needed for | Check |
|---|---|---|
Python 3 as python3 | The guard on both runtimes, and jev's review | python3 --version |
| ponytail | Claude Code: governs every GREEN step. Installed with lexi. Pi has no port: lexi's Pi skills carry the same ladder inline | /plugin |
| caveman | Terse output in every session. Claude Code: installed with lexi. Pi: /lexi-init installs it if missing, and lexi warns at session start when it is missing | /plugin / pi list |
| pi-subagents | Pi only, optional: the gate in an isolated subagent | pi list |
TYPESAFE_API_KEY | jev only, optional | jev requirements |
| Claude Code ≥ 2.1.276 | jev's router and compaction hooks | claude --version |
| Claude Code ≥ 2.1.296 | the interface, optional | claude --version |
[!WARNING] Without Python every guarded edit fails instead of being checked. macOS and most Linux distributions already have it. On Windows, install it and check that
python3 --versionanswers. Ifpython3opens the Microsoft Store, turn off thepython3.exealias (Settings > Apps > Advanced app settings > App execution aliases) and copypython.exetopython3.exenext to it.
Edit/Write/MultiEdit/NotebookEdit/Bash (Claude Code) and edit/write/bash (Pi). In a shell command it reads >, >>, tee and sed -i with a regex, not a shell parser: cp, mv, python -c and paths held in variables still walk past it..lexi/allow itself: the skill says to ask first, and the file records what was released.python3 hooks/tdd_guard_test.py
python3 skills_frontmatter_test.py # every SKILL.md: valid frontmatter, name = folder
python3 versions_test.py # every copy of a plugin version matches
python3 hooks/session_hint_test.py
python3 tests/pressure/judge_test.py
Pressure tests check the skill prose, not the code: each scenario in tests/pressure/scenarios/ pushes a headless agent to break a rule (skip the red test, rewrite an assertion, guess a cause, claim done without the gate, obey a wrong review), and Jev judges the transcript. They cost a real session each, so they are not in the gate: run `python3 tests/pressu
engine/index.ts 28 lines1import type { Register } from 'claude-code'
2import { compact } from './jev/compact.ts'
3import { onAgentSpawn as routeAgent, onPromptSubmit, onSessionEnd, onSessionStart as resetRouter, onTurnStep } from './jev/router.ts'
4import { PANE, onAgentToolCall, onAgentsCommand, onAgentSpawn as listAgent, onPane, onSessionStart as resetAgents, onTurnComplete } from './ui/agents.tsx'
5import { onAbovePrompt } from './ui/band.tsx'
6import { GUARDED, onToolCall } from './ui/gate.ts'
7import { onMeasure } from './ui/usage.tsx'
8
9// An event takes one hook without a matcher, so the router and the interface split session.start and agent.spawn by matcher.
10// A plugin gets exactly one hooks module: this one wires the Jev layer (router, compaction) and the interface (usage row, gate, subagents).
11export const register: Register = (on) => {
12 on('session.start', resetRouter)
13 on('session.start', { isInteractive: true }, resetAgents)
14 on('session.end', onSessionEnd)
15 on('prompt.submit', onPromptSubmit)
16 on('turn.step', onTurnStep)
17 on('agent.spawn', { fork: false }, routeAgent)
18 on('agent.spawn', listAgent)
19 on('session.compact', compact)
20 on('command.run', { command: 'subagents' }, onAgentsCommand)
21 on('tool.call', GUARDED, onToolCall)
22 on('tool.call', onAgentToolCall)
23 on('turn.complete', onTurnComplete)
24 on('session.measure', onMeasure)
25 on('ui.render', { component: 'AbovePrompt' }, onAbovePrompt)
26 on('ui.render', { component: 'Pane', requestId: PANE }, onPane)
27}
28engine/jev/compact.ts 190 lines1import type { EngineInterface, Hook, SessionCompactInput, SessionCompactResult, SessionMessage, ToolResultSummary, ToolUseSummary } from 'claude-code'
2import type { CallInfo, JevState as State, Question, StateCall as Call, StateEntry as Entry, Verdict } from '../../jev/shared/compact-policy.ts'
3import {
4 bar,
5 describeCall,
6 fitState,
7 isOpen,
8 JEV_MODEL,
9 JEV_TIMEOUT_MS,
10 JEV_URL,
11 kilo,
12 MAX_STATE_TOKENS,
13 MIN_REDUCTION,
14 parseAnswers,
15 percent,
16 toBatches,
17 truncated,
18 verdictOf,
19} from '../../jev/shared/compact-policy.ts'
20
21// Claude Code side only: thresholds and questions live in shared/compact-policy.ts, shared with Pi.
22const RESULT_VISIBLE_MS = 30_000 // how long the outcome stays in the status line
23
24type Next = Parameters<Hook<'session.compact'>>[2]
25type Candidate = { use: ToolUseSummary; result: ToolResultSummary }
26type Plan = { messages: SessionMessage[]; candidates: Candidate[]; verdicts: Map<string, Verdict>; before: number; after: number; count: number }
27
28export async function compact($: EngineInterface, e: SessionCompactInput, next: Next): Promise<SessionCompactResult> {
29 const startedAt = await $.clock.now()
30 const plan = await planCompaction($, e, next.signal).catch((error: unknown) => `internal error: ${String(error)}`)
31 if (typeof plan === 'string') return delegate($, e, next, plan)
32 report($, plan, (await $.clock.now()) - startedAt)
33 return { messages: plan.messages }
34}
35
36// Every reason not to compact becomes a string: the caller defers to the native summary.
37async function planCompaction($: EngineInterface, e: SessionCompactInput, signal: AbortSignal): Promise<Plan | string> {
38 const key = await $.env.get('TYPESAFE_API_KEY')
39 if (!key) return 'TYPESAFE_API_KEY missing'
40 progress($, 10, 'finding candidates')
41 const candidates = findCandidates(e.messages)
42 if (!candidates.length) return 'no candidate tool call'
43 const state = fitState(buildState(e), e.messages.length)
44 if (!state) return `state over ${MAX_STATE_TOKENS} tokens`
45 const answers = await askJev($, { key, state, calls: candidates.map(infoOf) }, signal)
46 if (typeof answers === 'string') return answers
47 progress($, 90, 'rebuilding')
48 const verdicts = new Map(candidates.map((candidate) => [candidate.use.tool_use_id, verdictOf(answers, infoOf(candidate))]))
49 const messages = rebuild(e.messages, verdicts)
50 const before = charsOf(e.messages)
51 const after = charsOf(messages)
52 if (1 - after / before < MIN_REDUCTION) return `reduction ${percent(1 - after / before)} below ${percent(MIN_REDUCTION)}`
53 return { messages, candidates, verdicts, before, after, count: e.messages.length }
54}
55
56const infoOf = ({ use, result }: Candidate): CallInfo => ({ id: use.tool_use_id, tool: use.tool, resultChars: result.text.length })
57
58// Candidate: a call with its result, both outside the immune messages.
59const findCandidates = (messages: readonly SessionMessage[]): Candidate[] => {
60 const open = messages.filter((_, i) => isOpen(i, messages.length))
61 const results = new Map(open.flatMap((m) => m.toolResults ?? []).map((r) => [r.tool_use_id, r]))
62 return open.flatMap((m) => (m.role === 'assistant' ? m.toolUses : [])).flatMap((use) => {
63 const result = results.get(use.tool_use_id)
64 return result ? [{ use, result }] : []
65 })
66}
67
68// Structure and history without result contents: only a note with outcome and length.
69const buildState = ({ messages, instructions }: SessionCompactInput): State => {
70 const results = new Map(messages.flatMap((m) => m.toolResults ?? []).map((r) => [r.tool_use_id, r]))
71 const note = (id: string) => {
72 const r = results.get(id)
73 return r ? `${r.isError ? 'error' : 'ok'}, ${r.text.length} chars` : 'no result'
74 }
75 const conversation = messages.flatMap((m, i): Entry[] => {
76 const calls = m.toolUses.map((u) => ({ id: u.tool_use_id, tool: u.tool, input: u.input, result: note(u.tool_use_id) }))
77 if (!m.text && !calls.length) return []
78 return [{ i, role: m.role, text: m.text, ...(calls.length ? { tool_calls: calls } : {}) }]
79 })
80 const goal = messages.filter((m) => m.role === 'user' && m.text).slice(-3).map((m) => m.text)
81 return { goal, ...(instructions ? { instructions } : {}), conversation }
82}
83
84// Batches run in parallel under one timeout: an in-flight fetch does not consume the hook budget,
85// waiting on the clock does, so the timeout stays under 10 s.
86async function askJev($: EngineInterface, ask: { key: string; state: State; calls: CallInfo[] }, signal: AbortSignal): Promise<Record<string, number> | string> {
87 const batches = toBatches(ask.state, ask.calls)
88 const timer = new AbortController()
89 signal.addEventListener('abort', () => timer.abort())
90 const timeout = $.clock.sleep(JEV_TIMEOUT_MS, { signal: timer.signal }).then(() => 'timeout', () => 'timeout')
91 const status = { done: 0, total: batches.length, calls: ask.calls.length }
92 showBatches($, status)
93 const requests = Promise.all(batches.map((questions) => askBatch($, { ...ask, questions }, status)))
94 const outcome = await Promise.race([requests, timeout])
95 timer.abort()
96 if (typeof outcome === 'string') return outcome
97 return outcome.find((answer): answer is string => typeof answer === 'string') ?? Object.assign({}, ...outcome)
98}
99
100async function askBatch($: EngineInterface, ask: { key: string; state: State; questions: Record<string, Question> }, status: { done: number; total: number; calls: number }): Promise<Record<string, number> | string> {
101 const response = await $.http
102 .fetch(JEV_URL, {
103 method: 'POST',
104 headers: { authorization: `Bearer ${ask.key}`, 'content-type': 'application/json' },
105 body: JSON.stringify({ model: JEV_MODEL, state: ask.state, questions: ask.questions }),
106 })
107 .catch((error: unknown) => `network error: ${String(error)}`)
108 status.done++
109 showBatches($, status)
110 if (typeof response === 'string') return response
111 return response.ok ? parseAnswers(response.text, Object.keys(ask.questions)) : `HTTP ${response.status}`
112}
113
114// Call and result share one verdict: never a tool_use without its tool_result.
115const reshape = <T extends { tool_use_id: string; text?: string; result?: unknown }>(blocks: readonly T[], verdicts: Map<string, Verdict>): T[] =>
116 blocks.flatMap((block) => {
117 const verdict = verdicts.get(block.tool_use_id)
118 if (verdict === 'drop') return []
119 if (verdict !== 'truncate' || block.text === undefined) return [block]
120 const { result: _stored, ...rest } = block
121 return [{ ...rest, text: truncated(block.text) } as T]
122 })
123
124// Without a handle the engine rebuilds each message from role, text and blocks; with the handle it
125// would ignore the edits and reload the old chain on --resume.
126const rebuild = (messages: readonly SessionMessage[], verdicts: Map<string, Verdict>): SessionMessage[] =>
127 joinParallelCalls(
128 messages.flatMap(({ handle: _handle, ...message }) => {
129 const toolUses = reshape(message.toolUses, verdicts)
130 const toolResults = message.toolResults && reshape(message.toolResults, verdicts)
131 const rebuilt: SessionMessage = { ...message, toolUses, ...(toolResults ? { toolResults } : {}) }
132 return rebuilt.text || toolUses.length || toolResults?.length ? [rebuilt] : []
133 }),
134 )
135
136// Parallel calls of one response arrive as consecutive assistant messages, one per block. Rebuilt,
137// they would get new ids and on --resume the loader would no longer rejoin them
138// (ensureToolResultPairing): they go back to one message, like the original response.
139const joinParallelCalls = (messages: SessionMessage[]): SessionMessage[] => {
140 const joined: SessionMessage[] = []
141 for (const message of messages) {
142 const previous = joined[joined.length - 1]
143 if (message.role !== 'assistant' || previous?.role !== 'assistant' || !previous.toolUses.length) joined.push(message)
144 else joined[joined.length - 1] = { ...previous, text: [previous.text, message.text].filter(Boolean).join('\n\n'), toolUses: [...previous.toolUses, ...message.toolUses] }
145 }
146 return joined
147}
148
149const charsOf = (messages: readonly SessionMessage[]) =>
150 messages.reduce((sum, m) => sum + m.text.length + m.toolUses.reduce((s, u) => s + JSON.stringify(u.input).length, 0) + (m.toolResults ?? []).reduce((s, r) => s + r.text.length, 0), 0)
151
152function progress($: EngineInterface, pct: number, phase: string): void {
153 $.ui.status(`jev-compact ${bar(pct)} ${pct}% · ${phase}`)
154}
155
156function showBatches($: EngineInterface, { done, total, calls }: { done: number; total: number; calls: number }): void {
157 progress($, 20 + Math.round((60 * done) / total), `asking Jev (${done}/${total} batches, ${calls} calls)`)
158}
159
160// The outcome stays readable for RESULT_VISIBLE_MS: Jev answers in a second, the bar alone would vanish.
161function settle($: EngineInterface, outcome: string): void {
162 $.ui.status(`jev-compact ${outcome}`)
163 $.ui.toast(`jev-compact ${outcome}`, { timeoutMs: 8_000 })
164 $.clock.after(RESULT_VISIBLE_MS, () => $.ui.status(undefined))
165}
166
167function report($: EngineInterface, plan: Plan, ms: number): void {
168 const counts = { keep: 0, truncate: 0, drop: 0 }
169 for (const verdict of plan.verdicts.values()) counts[verdict]++
170 const saved = plan.before - plan.after
171 const reduction = percent(saved / plan.before)
172 settle($, `${bar(100)} 100% · -${reduction} (${kilo(saved)} chars) · ${counts.truncate} truncated, ${counts.drop} dropped, ${counts.keep} kept`)
173 $.ui.log(`Jev compacted the context: -${reduction} (${kilo(saved)} of ${kilo(plan.before)} chars) in ${ms}ms`)
174 $.ui.log(`${plan.verdicts.size} tool calls judged → ${counts.keep} kept, ${counts.truncate} truncated, ${counts.drop} dropped · messages ${plan.count} → ${plan.messages.length}`)
175 for (const candidate of plan.candidates) {
176 const verdict = plan.verdicts.get(candidate.use.tool_use_id) ?? 'keep'
177 if (verdict !== 'keep') $.ui.log(describeCall(infoOf(candidate), verdict, candidate.use.input))
178 }
179}
180
181async function delegate($: EngineInterface, e: SessionCompactInput, next: Next, reason: string): Promise<SessionCompactResult> {
182 $.ui.log(`Jev did not compact (${reason}): native Claude Code summary`)
183 progress($, 90, 'native summary')
184 try {
185 return await next(e)
186 } finally {
187 settle($, `Jev not used (${reason}) → native summary`)
188 }
189}
190engine/jev/router.ts 175 lines1import { atom, update } from 'claude-code'
2import type { EngineInterface, EventName, Hook } from 'claude-code'
3import { flowContext, flowSkill, FLOW_QUESTIONS, isRoutable, parseFlow } from '../../jev/shared/flow-policy.ts'
4import type { Answer, Effort } from '../../jev/shared/router-policy.ts'
5import {
6 aliasOfRank,
7 causeOf,
8 EFFORTS,
9 effortRankOf,
10 isMoveAllowed,
11 isRisky,
12 JEV_MODEL,
13 JEV_URL,
14 parseAnswer,
15 QUESTIONS,
16 sessionTierRank,
17 shortModel,
18 targetEffortRank,
19 targetTierRank,
20 TIER_ALIAS,
21 tierRankOfModel,
22 TIMEOUT_MS,
23} from '../../jev/shared/router-policy.ts'
24
25// Claude Code side only: thresholds and questions live in shared/router-policy.ts, shared with Pi.
26const MODEL_ID: Record<string, string> = { sonnet: 'claude-sonnet-5-5', opus: 'claude-opus-5-5' }
27const DEBUG = { to: 'debug' } as const
28
29// The usage row reads this: the model and effort the router settled on, since the session's own are not the ones answering.
30const routing = atom({ plugin: 'lexi', key: 'routing' } as const, null)
31
32type HookArgs<N extends EventName> = Parameters<Hook<N>>
33type Plan = { fromModel: string; fromEffort: unknown; model?: string; effort?: Effort }
34
35let apiKey: string | undefined | null = null // null: not read yet
36let sessionAnswer: Answer | undefined
37let sessionPlan: Plan | undefined
38let hasWarnedUnavailable = false
39
40// session.start does not fire on /clear (session.end with reason 'clear' does): reset on both.
41export async function onSessionStart($: EngineInterface, e: HookArgs<'session.start'>[1], next: HookArgs<'session.start'>[2]) {
42 await resetSession($)
43 return next(e)
44}
45
46export async function onSessionEnd($: EngineInterface, e: HookArgs<'session.end'>[1], next: HookArgs<'session.end'>[2]) {
47 await resetSession($)
48 return next(e)
49}
50
51async function resetSession($: EngineInterface) {
52 sessionAnswer = undefined
53 sessionPlan = undefined
54 await update($, routing, () => null)
55}
56
57// One hook per event: the model is chosen once per session, the lexi flow on every prompt.
58export async function onPromptSubmit($: EngineInterface, e: HookArgs<'prompt.submit'>[1], next: HookArgs<'prompt.submit'>[2]) {
59 if (sessionAnswer) $.ui.log('[jev-router] reusing the first prompt\'s choice', DEBUG)
60 // ponytail: sequential, so the first prompt can wait up to two Jev timeouts; run both at once if it shows.
61 else sessionAnswer = await classify($, 'prompt', { prompt: e.text })
62 const context = await routeFlow($, e.text)
63 return next(context ? { ...e, context: [...(e.context ?? []), context] } : e)
64}
65
66// In a lexi project (`.lexi.json` is the opt-in), Jev names the flow and its order rides along as context.
67async function routeFlow($: EngineInterface, prompt: string): Promise<string | undefined> {
68 if (!isRoutable(prompt) || !(await $.fs.read('.lexi.json').then(() => true, () => false))) return undefined
69 const key = await readApiKey($)
70 if (!key) return undefined
71 const outcome = await postJev($, { model: JEV_MODEL, state: { prompt }, questions: FLOW_QUESTIONS }, key)
72 const answer = typeof outcome === 'string' ? outcome : parseFlow(outcome.text)
73 if (typeof answer === 'string') {
74 $.ui.log(`[jev-flow] jev unavailable: ${answer}, no routing for this prompt`, DEBUG)
75 return undefined
76 }
77 // Shown like the router's line, so the person sees which skill Jev sent the prompt to; `none` stays in debug.
78 const skill = flowSkill(answer.flow, answer.confidence)
79 $.ui.log(`[jev-flow] ${answer.flow} (${answer.confidence.toFixed(2)})${skill ? ` → ${skill}` : ''}`, skill ? {} : DEBUG)
80 return flowContext(answer.flow, answer.confidence)
81}
82
83export async function* onTurnStep($: EngineInterface, e: HookArgs<'turn.step'>[1], next: HookArgs<'turn.step'>[2]) {
84 if (e.agentId !== undefined || !sessionAnswer) return yield* next(e)
85 if (!sessionPlan) {
86 sessionPlan = startSessionPlan($, sessionAnswer, e.model, e.effort)
87 await update($, routing, () => ({ model: shortModel(sessionPlan?.model ?? e.model), effort: String(sessionPlan?.effort ?? e.effort ?? 'default') }))
88 }
89 const isModelOurs = sessionPlan.model && e.model === sessionPlan.fromModel
90 const isEffortOurs = sessionPlan.effort && e.effort === sessionPlan.fromEffort
91 return yield* next({
92 ...e,
93 ...(isModelOurs ? { model: sessionPlan.model } : {}),
94 ...(isEffortOurs ? { effort: sessionPlan.effort } : {}),
95 })
96}
97
98export async function onAgentSpawn($: EngineInterface, e: HookArgs<'agent.spawn'>[1], next: HookArgs<'agent.spawn'>[2]) {
99 if (e.fork) return next(e)
100 const label = `subagent ${e.subagentType}`
101 const answer = await classify($, label, { prompt: e.prompt, description: e.description, agentType: e.subagentType })
102 if (!answer) return next(e)
103 const current = e.model ?? e.parentModel
104 const rank = targetTierRank(answer)
105 const alias = isMoveAllowed(tierRankOfModel(current), rank, answer.tierConfidence, isRisky(answer)) ? aliasOfRank(rank) : undefined
106 $.ui.log(`[jev-router] ${label}: ${alias ? `model ${shortModel(current)} → ${alias}` : 'unchanged'} (${causeOf(answer)})`, DEBUG)
107 const started = await next(alias ? { ...e, model: alias } : e)
108 if (started.model) $.ui.log(`[jev-router] ${label}: ${shortModel(started.model)}`)
109 return started
110}
111
112function startSessionPlan($: EngineInterface, answer: Answer, model: string, effort: unknown): Plan {
113 const tierRank = sessionTierRank(answer)
114 const effortRank = targetEffortRank(answer)
115 const moveModel = isMoveAllowed(tierRankOfModel(model), tierRank, answer.tierConfidence, isRisky(answer))
116 const moveEffort = isMoveAllowed(effortRankOf(effort), effortRank, answer.effortConfidence, isRisky(answer))
117 const plan: Plan = { fromModel: model, fromEffort: effort }
118 if (moveModel) plan.model = MODEL_ID[aliasOfRank(tierRank)] ?? aliasOfRank(tierRank)
119 if (moveEffort) plan.effort = EFFORTS[effortRank]
120 const changes = [
121 plan.model && `model ${shortModel(model)} → ${shortModel(plan.model)}`,
122 plan.effort && `effort ${String(effort ?? 'default')} → ${plan.effort}`,
123 ].filter(Boolean)
124 const summary = `${shortModel(plan.model ?? model)} · effort ${String(plan.effort ?? effort ?? 'default')}`
125 $.ui.log(`[jev-router] ${changes.length ? changes.join(', ') : `unchanged — staying on ${summary}`} (${causeOf(answer)})`, DEBUG)
126 $.ui.log(`[jev-router] session: ${summary}`)
127 return plan
128}
129
130async function classify($: EngineInterface, label: string, state: Record<string, string>): Promise<Answer | undefined> {
131 const key = await readApiKey($)
132 if (!key) return undefined
133 const result = await askJev($, state, key)
134 if (typeof result === 'string') {
135 // First failure goes to the transcript (bad key, blocked network), later ones to debug only.
136 $.ui.log(`[jev-router] jev unavailable (${label}): ${result}, retrying next time`, hasWarnedUnavailable ? DEBUG : {})
137 hasWarnedUnavailable = true
138 return undefined
139 }
140 const effortName = EFFORTS[result.effort] ?? result.effort
141 $.ui.log(`[jev-router] classification (${label}): ${result.tier} → ${TIER_ALIAS[result.tier]} (${result.tierConfidence.toFixed(2)}) · effort ${effortName} (${result.effortConfidence.toFixed(2)}) · risk ${result.risk.toFixed(2)} · ${result.ms}ms`, DEBUG)
142 return result
143}
144
145async function readApiKey($: EngineInterface): Promise<string | undefined> {
146 if (apiKey !== null) return apiKey
147 apiKey = await $.env.get('TYPESAFE_API_KEY')
148 if (apiKey) $.ui.log(`[jev-router] ready — ${JEV_URL}, timeout ${TIMEOUT_MS}ms`, DEBUG)
149 else $.ui.log('[jev-router] TYPESAFE_API_KEY missing: add it under "env" in .claude/settings.local.json or export it in the shell. Router off.')
150 return apiKey
151}
152
153async function askJev($: EngineInterface, state: Record<string, string>, key: string): Promise<Answer | string> {
154 const outcome = await postJev($, { model: JEV_MODEL, state, questions: QUESTIONS }, key)
155 return typeof outcome === 'string' ? outcome : parseAnswer(outcome.text, outcome.ms)
156}
157
158// Any failure (timeout, non-2xx, exception) becomes a reason string, never a throw.
159async function postJev($: EngineInterface, body: object, key: string): Promise<{ text: string; ms: number } | string> {
160 const startedAt = await $.clock.now()
161 const timer = new AbortController()
162 const timeout = $.clock.sleep(TIMEOUT_MS, { signal: timer.signal }).then(() => 'timeout', () => 'timeout')
163 const request = $.http
164 .fetch(JEV_URL, {
165 method: 'POST',
166 headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
167 body: JSON.stringify(body),
168 })
169 .then((response) => (response.ok ? response : `HTTP ${response.status}`), (error: unknown) => `error ${String(error)}`)
170 const outcome = await Promise.race([request, timeout])
171 timer.abort()
172 if (typeof outcome === 'string') return outcome
173 return { text: outcome.text, ms: (await $.clock.now()) - startedAt }
174}
175engine/ui/agents.tsx 117 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Hook, MatchedHook } from 'claude-code'
3import type { AgentRun } from '../types'
4import { shortModel } from '../../jev/shared/router-policy.ts'
5import { colorOf } from './palette.ts'
6
7export const PANE = 'lexi-agents' as const
8const TITLE = 'Subagents'
9
10const agents = atom({ plugin: 'lexi', key: 'agents' } as const, [])
11const selected = atom({ plugin: 'lexi', key: 'selected' } as const, null)
12
13// shortcut: keeps the last 50 subagents of the session; raise it if a long session needs more.
14const KEEP = 50
15
16type Spawned = { id: string; type: string; description: string; model?: string }
17
18const startRun = (list: readonly AgentRun[], { id, type, description, model }: Spawned): AgentRun => ({
19 id, type, description,
20 ...(model === undefined ? {} : { model: shortModel(model) }),
21 label: `${type} #${list.filter((run) => run.type === type).length + 1}`,
22 color: colorOf(type),
23 isDone: false,
24 steps: [],
25})
26
27export const onAgentSpawn: Hook<'agent.spawn'> = async ($, e, next) => {
28 const started = await next(e)
29 const id = started.agentId
30 if (id === undefined) return started
31 const isFirst = (await read($, agents)).length === 0
32 // Not awaited: on a narrow terminal the engine holds an unasked pane until there is room.
33 if (isFirst) void $.ui.open({ id: PANE, title: TITLE })
34 await update($, agents, (list) => [...list, startRun(list, { id, type: e.subagentType, description: e.description, model: started.model })].slice(-KEEP))
35 return started
36}
37
38// The argument that says what a call did, cut to one short line.
39const INPUT_KEYS = ['command', 'file_path', 'pattern', 'path', 'url', 'query', 'description'] as const
40const inputOf = (e: Readonly<Record<string, unknown>>): string => {
41 const value = INPUT_KEYS.map((key) => e[key]).find((one) => typeof one === 'string')
42 return typeof value === 'string' ? value.split('\n')[0]!.slice(0, 60) : ''
43}
44
45// One step of a subagent's timeline; calls of the main loop carry no agentId and are skipped.
46export const onAgentToolCall: Hook<'tool.call'> = async ($, e, next) => {
47 const ran = await next(e)
48 if (e.agentId === undefined) return ran
49 const step = { tool: e.tool, input: inputOf(e), isError: ran.deny !== undefined || ran.isError === true }
50 await update($, agents, (list) => list.map((run) => (run.id === e.agentId ? { ...run, steps: [...run.steps, step] } : run)))
51 return ran
52}
53
54export const onSessionStart: Hook<'session.start'> = async ($, e, next) => {
55 await $.command.register({ name: 'subagents', description: 'Show the subagents of this session, colored by type' })
56 return next(e)
57}
58
59export const onAgentsCommand: MatchedHook<'command.run', { command: 'subagents' }> = async ($) => {
60 await $.ui.open({ id: PANE, title: TITLE })
61 return { text: 'Subagents pane opened.' }
62}
63
64export const onTurnComplete: Hook<'turn.complete'> = async ($, e, next) => {
65 const done = await next(e)
66 if (e.agentId === undefined) return done
67 const tokens = e.usage === undefined ? undefined : e.usage.input_tokens + e.usage.output_tokens
68 const finished = { isDone: true, durationMs: e.durationMs, tokens, answer: e.answer }
69 await update($, agents, (list) => list.map((run) => (run.id === e.agentId ? { ...run, ...finished } : run)))
70 return done
71}
72
73export const stepsOf = (run: AgentRun): string => `${run.steps.length} step${run.steps.length === 1 ? '' : 's'}`
74
75// A failed step outranks the run's own state: that is what a glance at the list should catch.
76export const hasFailedStep = (run: AgentRun): boolean => run.steps.some((step) => step.isError)
77const glyphOf = (run: AgentRun): string => (hasFailedStep(run) ? '✗' : run.isDone ? '✓' : '▶')
78
79const statusOf = (run: AgentRun): string =>
80 run.isDone ? `done · ${((run.durationMs ?? 0) / 1000).toFixed(1)}s · ${run.tokens ?? 0} tok` : 'running'
81
82type Elements = ReturnType<EngineInterface['ui']['resolve']>
83type Select = (id: string | null) => Promise<unknown>
84
85const listView = (ui: Elements, list: readonly AgentRun[], select: Select) => (
86 <ui.Box flexDirection="column">
87 {list.length === 0 && <ui.Text dimColor>No subagents yet.</ui.Text>}
88 {[...list.filter((run) => !run.isDone), ...list.filter((run) => run.isDone)].map((run) => (
89 <ui.Box>
90 <ui.Text color={run.color}>{glyphOf(run)} {run.label} · {run.model === undefined ? '' : `${run.model} · `}{statusOf(run)} · {stepsOf(run)} · {run.description} </ui.Text>
91 <ui.Button key={run.id} label="open" onPress={() => select(run.id)} />
92 </ui.Box>
93 ))}
94 </ui.Box>
95)
96
97const detailView = (ui: Elements, run: AgentRun, select: Select) => (
98 <ui.Box flexDirection="column">
99 <ui.Text color={run.color}>{run.label} · {statusOf(run)}</ui.Text>
100 <ui.Text dimColor>{run.description}</ui.Text>
101 {run.steps.map((step) => (
102 <ui.Text color={step.isError ? 'error' : undefined}>{step.isError ? '✗' : '✓'} {step.tool} {step.input}</ui.Text>
103 ))}
104 <ui.Text>{run.answer ?? '…'}</ui.Text>
105 <ui.Button key="back" label="back" onPress={() => select(null)} />
106 </ui.Box>
107)
108
109export const onPane: MatchedHook<'ui.render', { component: 'Pane'; requestId: typeof PANE }> = async ($, e) => {
110 const ui = $.ui.resolve(e)
111 const list = await read($, agents)
112 const id = await read($, selected)
113 const run = list.find((one) => one.id === id)
114 const select: Select = (next) => update($, selected, () => next)
115 return run === undefined ? listView(ui, list, select) : detailView(ui, run, select)
116}
117engine/ui/band.tsx 49 lines1import { atom, read } from 'claude-code'
2import type { MatchedHook } from 'claude-code'
3import type { AgentRun, GateRun } from '../types'
4import { hasFailedStep, stepsOf } from './agents.tsx'
5import { usageRow } from './usage.tsx'
6import type { Elements } from './usage.tsx'
7
8// The engine's state scan wants each atom spelled in the file that reads it: gate.ts, agents.tsx and usage.tsx write these.
9const lastGate = atom({ plugin: 'lexi', key: 'lastGate' } as const, null)
10const agents = atom({ plugin: 'lexi', key: 'agents' } as const, [])
11const usage = atom({ plugin: 'lexi', key: 'usage' } as const, null)
12
13// One short row: the rest are counted, the pane lists them all.
14const SHOWN = 4
15
16const secondsOf = (ms: number): string => `${(ms / 1000).toFixed(1)}s`
17
18const gateRow = (ui: Elements, run: GateRun, age: number) => (
19 <ui.Box>
20 <ui.Text color={run.isGreen ? 'success' : 'error'}>gate {run.isGreen ? 'GREEN' : 'RED'}</ui.Text>
21 <ui.Text dimColor> · {secondsOf(run.durationMs)} · {age}s ago</ui.Text>
22 </ui.Box>
23)
24
25const agentsRow = (ui: Elements, running: readonly AgentRun[]) => (
26 <ui.Box>
27 {running.slice(0, SHOWN).map((run) => <ui.Text color={run.color}>{hasFailedStep(run) ? '✗' : '●'} {run.label} · {stepsOf(run)} </ui.Text>)}
28 {running.length > SHOWN && <ui.Text dimColor>+{running.length - SHOWN} more</ui.Text>}
29 </ui.Box>
30)
31
32// Above the prompt, whatever the terminal's width: the session's usage, the last gate run and the subagents running now.
33export const onAbovePrompt: MatchedHook<'ui.render', { component: 'AbovePrompt' }> = async ($, e, next) => {
34 const run = await read($, lastGate)
35 const running = (await read($, agents)).filter((one) => !one.isDone)
36 const now = await read($, usage)
37 if ((now === null && run === null && running.length === 0) || e.props.hasSurvey) return next(e)
38 const ui = $.ui.resolve(e)
39 const at = await $.clock.now()
40 const age = run === null ? 0 : Math.round((at - run.at) / 1000)
41 return (
42 <ui.Box flexDirection="column">
43 {now !== null && usageRow(ui, now, at)}
44 {run !== null && gateRow(ui, run, age)}
45 {running.length > 0 && agentsRow(ui, running)}
46 </ui.Box>
47 )
48}
49engine/ui/gate.ts 37 lines1import { atom, update } from 'claude-code'
2import type { EngineInterface, MatchedHook } from 'claude-code'
3
4const lastGate = atom({ plugin: 'lexi', key: 'lastGate' } as const, null)
5
6// The first line of lexi's guard refusal (hooks/tdd_guard.py): `lexi — edit to `x` BLOCKED.`
7const GUARD_LINE = /^lexi — .* BLOCKED\.$/m
8
9// The project's gate from `.lexi.json`; undefined outside a lexi project, so the mod stays quiet.
10const gateOf = async ($: EngineInterface): Promise<string | undefined> => {
11 const text = await $.fs.read('.lexi.json').then(String, () => undefined)
12 try {
13 const gate: unknown = text === undefined ? undefined : JSON.parse(text).gate
14 return typeof gate === 'string' && gate !== '' ? gate : undefined
15 } catch {
16 return undefined
17 }
18}
19
20// The tools lexi's guard covers (hooks/hooks.json): the gate runs in Bash, a block lands on any of them.
21export const GUARDED = { tool: /^(Bash|Edit|Write|NotebookEdit)$/ }
22
23export const onToolCall: MatchedHook<'tool.call', typeof GUARDED> = async ($, e, next) => {
24 const gate = e.tool === 'Bash' ? await gateOf($) : undefined
25 const startedAt = await $.clock.now()
26 const ran = await next(e)
27 if (ran.deny !== undefined) return ran
28 const blocked = ran.isError ? ran.text?.match(GUARD_LINE)?.[0] : undefined
29 if (blocked) $.ui.toast(blocked)
30 if (e.tool !== 'Bash' || gate === undefined || !e.command.includes(gate)) return ran
31 const at = await $.clock.now()
32 const isGreen = !ran.isError
33 await update($, lastGate, () => ({ isGreen, durationMs: at - startedAt, at }))
34 $.ui.status(isGreen ? 'lexi:GREEN' : 'lexi:RED')
35 return ran
36}
37engine/ui/usage.tsx 79 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Hook } from 'claude-code'
3import type { Usage } from '../types'
4
5export type Elements = ReturnType<EngineInterface['ui']['resolve']>
6
7const usage = atom({ plugin: 'lexi', key: 'usage' } as const, null)
8const routing = atom({ plugin: 'lexi', key: 'routing' } as const, null)
9
10// The usage gauges warm as a window fills.
11export const GREEN = '#81c784'
12export const YELLOW = '#fff176'
13export const ORANGE = '#ffb74d'
14export const RED = '#e57373'
15const COST = '#4fc3f7'
16
17const BAR_WIDTH = 10
18const EIGHTHS = ' ▏▎▍▌▋▊▉'
19const WINDOWS: Readonly<Record<string, string>> = { five_hour: '5h', seven_day: 'wk', spend_limit: 'spend' }
20
21const warmthOf = (percent: number): string => (percent >= 90 ? RED : percent >= 75 ? ORANGE : percent >= 50 ? YELLOW : GREEN)
22
23// The filled cells, to an eighth of a cell, and the empty track after them.
24const barOf = (percent: number): [string, string] => {
25 const cells = (Math.max(0, Math.min(100, percent)) / 100) * BAR_WIDTH
26 const whole = Math.floor(cells)
27 const partial = whole < BAR_WIDTH ? EIGHTHS[Math.floor((cells - whole) * 8)]!.trim() : ''
28 return ['█'.repeat(whole) + partial, '░'.repeat(BAR_WIDTH - whole - partial.length)]
29}
30
31// Time left to a reset, as 1d12h, 3h29m or 7m; empty once it has passed.
32const countdownOf = (resetsAt: string | undefined, now: number): string => {
33 const left = resetsAt === undefined ? 0 : Math.floor((Date.parse(resetsAt) - now) / 60_000)
34 if (!(left > 0)) return ''
35 const days = Math.floor(left / 1440)
36 const hours = Math.floor((left % 1440) / 60)
37 if (days > 0) return `${days}d${hours}h`
38 return hours > 0 ? `${hours}h${String(left % 60).padStart(2, '0')}m` : `${left}m`
39}
40
41const tokensOf = (tokens: number): string =>
42 tokens >= 1e6 ? `${(tokens / 1e6).toFixed(1)}M tok` : tokens >= 1e3 ? `${(tokens / 1e3).toFixed(1)}k tok` : `${tokens} tok`
43
44const gauge = (ui: Elements, label: string, percent: number, countdown = '') => {
45 const [filled, track] = barOf(percent)
46 return (
47 <ui.Box>
48 <ui.Text dimColor>{label} </ui.Text>
49 <ui.Text color={warmthOf(percent)}>{filled}</ui.Text>
50 <ui.Text dimColor>{track}</ui.Text>
51 <ui.Text color={warmthOf(percent)}> {Math.round(percent)}%</ui.Text>
52 {countdown !== '' && <ui.Text dimColor> ⟳{countdown}</ui.Text>}
53 </ui.Box>
54 )
55}
56
57// shortcut: countdowns move only when the engine measures (each turn); add a $.clock tick if they must run live.
58export const usageRow = (ui: Elements, now: Usage, at: number) => {
59 const limits = now.rateLimits.filter((limit) => WINDOWS[limit.kind] !== undefined)
60 const segments = [
61 <ui.Text bold>{now.model}</ui.Text>,
62 gauge(ui, 'ctx', now.context.percent ?? 0),
63 ...limits.map((limit) => gauge(ui, WINDOWS[limit.kind]!, limit.percentUsed, countdownOf(limit.resetsAt, at))),
64 ...(now.context.tokens === undefined ? [] : [<ui.Text dimColor>{tokensOf(now.context.tokens)}</ui.Text>]),
65 ...(now.usd === undefined ? [] : [<ui.Text color={COST}>${now.usd.toFixed(2)}</ui.Text>]),
66 ]
67 return <ui.Box>{segments.flatMap((one, index) => (index === 0 ? [one] : [<ui.Text dimColor> │ </ui.Text>, one]))}</ui.Box>
68}
69
70// The engine's own status-line figures, pushed after each turn and on each rate-limit move.
71export const onMeasure: Hook<'session.measure'> = async ($, e, next) => {
72 const routed = await read($, routing)
73 // The router swaps the model per step, so the session's own model is not the one answering.
74 const model = routed === null ? await $.session.model() : `${routed.model} · ${routed.effort}`
75 const { tokens, percent } = e.context
76 await update($, usage, () => ({ model, context: { tokens, percent }, rateLimits: [...e.rateLimits], usd: e.cost?.usd }))
77 return next(e)
78}
79jev/shared/compact-policy.ts 110 lines1// Compaction policy: questions to Jev, thresholds, verdict and formatting. No runtime dependency, so
2// the Claude Code hook (`jev/hooks/compact.ts`) and the Pi extension (`pi/extensions/jev-compact/`)
3// share the same numbers.
4
5export const KEEP_THRESHOLD = 0.5 // minimum probability to keep
6export const PRESERVE_RECENT = 6 // last messages never touched (the first is immune too)
7export const TRUNCATE_HEAD = 300 // what is left of a truncated result
8export const MAX_STATE_TOKENS = 25_000 // cap on the state sent to Jev
9export const MAX_REQUEST_TOKENS = 30_000 // cap on one request (state + questions)
10export const MIN_REDUCTION = 0.3 // below this, defer to native (rewriting the cache does not pay off)
11export const JEV_TIMEOUT_MS = 8_000
12export const JEV_MODEL = 'jev-latest'
13export const JEV_URL = 'https://api.typesafe.ai/v1/systemone'
14export const ARG_KEYS = ['file_path', 'command', 'pattern', 'url', 'query', 'path']
15
16export type Verdict = 'keep' | 'truncate' | 'drop'
17export type Question = { type: 'noul'; instructions: string; criteria: { true: string; false: string } }
18// The minimum needed to ask the questions and read the verdict: each runtime maps its own tool
19// call representation onto it.
20export type CallInfo = { id: string; tool: string; resultChars: number }
21
22export const isOpen = (i: number, total: number) => i > 0 && i < total - PRESERVE_RECENT
23
24// The state Jev reads: structure and history without tool result contents. Each runtime builds it
25// from its own messages; the JSON shape is the same.
26export type StateCall = { id: string; tool: string; input?: unknown; result: string }
27export type StateEntry = { i: number; role: string; text: string; tool_calls?: StateCall[] }
28export type JevState = { goal: string[]; instructions?: string; conversation: StateEntry[] }
29
30// Shrinks in steps until it fits: call inputs, then long texts, then whole messages, always from
31// the oldest unprotected ones. undefined if that is not enough.
32export const fitState = (state: JevState, total: number): JevState | undefined => {
33 const open = state.conversation.filter((entry) => isOpen(entry.i, total))
34 const lengths = new Map(open.map((entry) => [entry, entry.text.length]))
35 const clipInputs = () => state.conversation.forEach((entry) => entry.tool_calls?.forEach((c) => (c.input = clip(JSON.stringify(c.input), 200))))
36 const shorten = (entry: StateEntry) => () => (entry.text = headTail(entry.text))
37 const collapse = (entry: StateEntry) => () => {
38 entry.text = `[… ${lengths.get(entry)} chars]`
39 entry.tool_calls?.forEach((c) => (c.input = undefined))
40 }
41 for (const step of [() => {}, clipInputs, ...open.map(shorten), ...open.map(collapse)]) {
42 step()
43 if (tokensOf(state) <= MAX_STATE_TOKENS) return state
44 }
45 return undefined
46}
47
48export const questionsFor = ({ id, tool, resultChars }: CallInfo): Record<string, Question> => ({
49 [`keep_call_${id}`]: {
50 type: 'noul',
51 instructions: `Tool call \`${id}\` (\`${tool}\`) must stay in the history: knowing it was made, with its input, still matters for what the assistant does next.`,
52 criteria: { true: 'The assistant will still refer to this call or its input.', false: 'Step superseded: exploration finished, empty search, file later rewritten, nothing that steers the work.' },
53 },
54 [`keep_result_${id}`]: {
55 type: 'noul',
56 instructions: `The full output of tool call \`${id}\` (\`${tool}\`, ${resultChars} characters) must stay verbatim: the assistant still needs its content and re-running the tool would not do.`,
57 criteria: { true: 'Needed word for word and not recoverable by re-running the tool (exact error, non-repeatable output, changed state).', false: 'Superseded or recoverable by re-running the tool: file later edited or re-readable, listing, search, output already summarised.' },
58 },
59})
60
61// Batches under MAX_REQUEST_TOKENS: each one resends the full state.
62export const toBatches = (state: unknown, calls: readonly CallInfo[]): Record<string, Question>[] => {
63 const room = MAX_REQUEST_TOKENS - tokensOf(state)
64 const batches: Record<string, Question>[] = [{}]
65 let used = 0
66 for (const call of calls) {
67 const questions = questionsFor(call)
68 if (used > 0 && used + tokensOf(questions) > room) (batches.push({}), (used = 0))
69 Object.assign(batches[batches.length - 1] ?? {}, questions)
70 used += tokensOf(questions)
71 }
72 return batches
73}
74
75export const parseAnswers = (text: string, ids: string[]): Record<string, number> | string => {
76 let answers: Record<string, { noul?: unknown } | undefined> | undefined
77 try {
78 answers = (JSON.parse(text) as { answers?: typeof answers } | null)?.answers
79 } catch {
80 return 'answer is not JSON'
81 }
82 const entries = ids.flatMap((id) => {
83 const noul = answers?.[id]?.noul
84 return typeof noul === 'number' && Number.isFinite(noul) ? [[id, noul] as const] : []
85 })
86 return entries.length === ids.length ? Object.fromEntries(entries) : 'answer without valid noul'
87}
88
89export const verdictOf = (answers: Record<string, number>, { id, resultChars }: CallInfo): Verdict => {
90 if ((answers[`keep_result_${id}`] ?? 1) >= KEEP_THRESHOLD) return 'keep'
91 if ((answers[`keep_call_${id}`] ?? 1) < KEEP_THRESHOLD) return 'drop'
92 return resultChars > TRUNCATE_HEAD ? 'truncate' : 'keep'
93}
94
95export const truncated = (text: string) => `${text.slice(0, TRUNCATE_HEAD)}\n[… ${text.length - TRUNCATE_HEAD} chars removed]`
96
97export const tokensOf = (value: unknown) => JSON.stringify(value).length / 4
98export const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
99export const headTail = (text: string) => (text.length > 800 ? `${text.slice(0, 400)} […] ${text.slice(-400)}` : text)
100export const percent = (ratio: number) => `${Math.round(ratio * 100)}%`
101export const kilo = (chars: number) => (chars >= 1000 ? `${(chars / 1000).toFixed(1)}k` : `${chars}`)
102export const bar = (pct: number) => '█'.repeat(Math.round(pct / 10)) + '░'.repeat(10 - Math.round(pct / 10))
103export const argumentOf = (input: Record<string, unknown>) =>
104 clip((ARG_KEYS.map((key) => input[key]).find((v): v is string => typeof v === 'string') ?? JSON.stringify(input)).replace(/\s+/g, ' ').trim(), 70)
105
106export const describeCall = ({ tool, resultChars }: CallInfo, verdict: Verdict, input: Record<string, unknown>) =>
107 verdict === 'drop'
108 ? `✗ dropped ${tool} · ${argumentOf(input)} (-${kilo(resultChars + JSON.stringify(input).length)} chars)`
109 : `✂ truncated ${tool} · ${argumentOf(input)} (${kilo(resultChars)} → ${TRUNCATE_HEAD} chars)`
110jev/shared/flow-policy.ts 57 lines1// Flow policy: which lexi skill a prompt goes to. No runtime dependency, so the Claude Code hook
2// (`jev/hooks/flow.ts`) and the Pi extension (`pi/extensions/jev-flow.ts`) share it.
3
4export const FLOWS = ['bug', 'feature', 'open', 'none'] as const
5export type Flow = (typeof FLOWS)[number]
6export type FlowAnswer = { flow: Flow; confidence: number }
7
8export const FLOW_QUESTIONS = {
9 flow: {
10 type: 'choice',
11 instructions: 'In a project that writes a failing test before any code change, which flow fits this prompt?',
12 criteria: {
13 bug: 'Existing behaviour is broken: an error, a crash, a wrong result, a failing test, a regression the user reports.',
14 feature: 'A new or changed behaviour whose design the prompt already settles: what to build is stated, only how remains.',
15 open: 'A new or changed behaviour with several defensible designs or unstated product intent: decisions come before tests can be named.',
16 none: 'No change to production behaviour: a question, an explanation, docs, config, a review, a pure refactor, or a reply that continues the current task ("ok", "go on").',
17 },
18 },
19}
20type JevResponse = { answers?: { flow?: { choice?: string; confidence?: number } } }
21
22export const parseFlow = (text: string): FlowAnswer | string => {
23 let body: JevResponse
24 try {
25 body = JSON.parse(text) as JevResponse
26 } catch {
27 return 'unreadable JSON'
28 }
29 const answer = body.answers?.flow
30 const flow = FLOWS.find((name) => name === answer?.choice)
31 if (!flow) return 'answer without a valid flow'
32 return { flow, confidence: answer?.confidence ?? 0 }
33}
34
35export const MIN_CONFIDENCE = 0.6
36const SKILL_OF: Record<Exclude<Flow, 'none'>, string> = { bug: 'bug', feature: 'feature', open: 'grill' }
37
38// `skillOf` names a skill for the runtime: `lexi:bug` on Claude Code, `lexi-bug` on Pi.
39type SkillOf = (name: string) => string
40const LEXI_SKILL: SkillOf = (name) => `lexi:${name}`
41
42// The skill the flow sends a prompt to: its own when confident, lexi's router when not, none for `none`.
43export const flowSkill = (flow: Flow, confidence: number, skillOf = LEXI_SKILL): string | undefined => {
44 if (flow === 'none') return undefined
45 return skillOf(confidence < MIN_CONFIDENCE ? 'lexi' : SKILL_OF[flow])
46}
47
48export const flowContext = (flow: Flow, confidence: number, skillOf = LEXI_SKILL): string | undefined => {
49 const skill = flowSkill(flow, confidence, skillOf)
50 if (!skill) return undefined
51 if (confidence < MIN_CONFIDENCE) return `lexi flow (Jev): unsure (${flow}, ${confidence.toFixed(2)}). If this prompt asks for a code change, load the skill "${skill}" first: it routes.`
52 return `lexi flow (Jev): ${flow}, confidence ${confidence.toFixed(2)}. Load the skill "${skill}" before anything else. If that is clearly wrong for this prompt, say why in one line and load "${skillOf('lexi')}" instead.`
53}
54
55// A slash command already names what runs: classifying it would only second-guess the user.
56export const isRoutable = (prompt: string): boolean => !prompt.trimStart().startsWith('/')
57jev/shared/router-policy.ts 84 lines1// Router policy: questions to Jev, thresholds and decision. No runtime dependency, so the Claude
2// Code hook (`jev/hooks/router.ts`) and the Pi extension (`pi/extensions/jev-router/`) share it.
3
4export const JEV_URL = 'https://api.typesafe.ai/v1/systemone'
5export const JEV_MODEL = 'jev-latest'
6export const TIMEOUT_MS = 1500
7export const UP_CONFIDENCE = 0.3
8export const DOWN_CONFIDENCE = 0.6
9export const RISK_THRESHOLD = 0.7
10export const TIERS = ['trivial', 'fast', 'balanced', 'deep'] as const
11// Opus 5.5 beats Fable 5.1 at every cost point, so `deep` is Opus at a higher effort.
12export const TIER_ALIAS: Record<Tier, string> = { trivial: 'haiku', fast: 'sonnet', balanced: 'opus', deep: 'opus' }
13export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
14export const MIN_EFFORT = 1 // medium: Sonnet 5.5's start for agentic coding; at low it can call a change done unchecked
15export const DEEP_EFFORT = 2 // high
16export const RISKY_EFFORT = 3 // xhigh: max scores lower and costs more on Opus 5.5
17
18export const QUESTIONS = {
19 tier: {
20 type: 'choice',
21 instructions: 'Which is the cheapest tier that can complete this coding task well?',
22 criteria: {
23 trivial: 'Read-only work, a single shell command, or a mechanical edit whose exact change is already described: read/search/summarise files, run one command and report its output, answer from context, fix a typo, rename a symbol, apply a described change.',
24 fast: 'Small edit that still needs judgement: fix a bug whose cause is already known, add tests for a change in one or two files, a short change in one or two files that is not spelled out.',
25 balanced: 'Ordinary engineering: implement a well-specified change across several files, fix a bug with clear symptoms that still has to be located in the code, review a diff.',
26 deep: 'Hard or high-stakes: architecture and design, debugging a failure whose cause is unknown, security, data migrations, concurrency, anything touching production or money.',
27 },
28 },
29 effort: {
30 type: 'score',
31 instructions: 'How much step-by-step reasoning does this task need?',
32 criteria: ['almost none', 'some', 'a lot', 'as much as possible'],
33 },
34 risky: { type: 'noul', instructions: 'Carrying out this task would itself change production, move real money, or alter data that cannot be restored. Writing or testing code that deals with such things, without running it against the real system, does not count.' },
35}
36
37export type Tier = (typeof TIERS)[number]
38export type SessionTier = Exclude<Tier, 'trivial'>
39export type Effort = (typeof EFFORTS)[number]
40export type Answer = { tier: Tier; tierConfidence: number; effort: number; effortConfidence: number; risk: number; ms: number }
41type JevAnswers = { tier?: { choice?: string; confidence?: number }; effort?: { score?: number; confidence?: number }; risky?: { noul?: number } }
42type JevResponse = { answers?: JevAnswers }
43
44export const parseAnswer = (text: string, ms: number): Answer | string => {
45 let body: JevResponse
46 try {
47 body = JSON.parse(text) as JevResponse
48 } catch {
49 return 'unreadable JSON'
50 }
51 const answers = body.answers
52 const tier = TIERS.find((name) => name === answers?.tier?.choice)
53 const score = answers?.effort?.score
54 // Jev's score is continuous (0.78 = between "almost none" and "some"): take the nearest level.
55 if (!tier || typeof score !== 'number' || !(score >= 0 && score <= 3)) return 'answer without a valid tier/effort'
56 const effort = Math.round(score)
57 const confidenceOf = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
58 return { tier, tierConfidence: confidenceOf(answers?.tier?.confidence), effort, effortConfidence: confidenceOf(answers?.effort?.confidence), risk: confidenceOf(answers?.risky?.noul), ms }
59}
60
61// An unknown current value (-1: haiku, a numeric effort, an unrecognised id) counts as below
62// every level, so any move from it is a raise: it can only go up.
63export const isMoveAllowed = (from: number, to: number, confidence: number, isForced: boolean): boolean => {
64 if (to === from) return false
65 if (isForced) return to > from
66 return confidence >= (to > from ? UP_CONFIDENCE : DOWN_CONFIDENCE)
67}
68
69export const isRisky = (answer: Answer) => answer.risk > RISK_THRESHOLD
70// An off-scale level ('off', 'minimal', a number) ranks -1: below all, so it can only go up.
71export const effortRankOf = (level: unknown) => (EFFORTS as readonly unknown[]).indexOf(level)
72export const targetTierRank = (answer: Answer) => (isRisky(answer) ? TIERS.length - 1 : TIERS.indexOf(answer.tier))
73export const targetEffortRank = (answer: Answer) => {
74 if (isRisky(answer)) return Math.max(answer.effort, RISKY_EFFORT)
75 return Math.max(answer.effort, answer.tier === 'deep' ? DEEP_EFFORT : MIN_EFFORT)
76}
77// The session holds its model to the end: haiku is for one-shot subagents only.
78export const sessionTierRank = (answer: Answer) => Math.max(targetTierRank(answer), TIERS.indexOf('fast'))
79export const aliasOfRank = (rank: number) => TIER_ALIAS[TIERS[rank] ?? 'deep']
80export const tierRankOfModel = (model: string) => TIERS.findIndex((tier) => model.includes(TIER_ALIAS[tier]))
81export const shortModel = (model: string) => Object.values(TIER_ALIAS).find((alias) => model.includes(alias)) ?? model
82export const causeOf = (answer: Answer) =>
83 isRisky(answer) ? `risk ${answer.risk.toFixed(2)}` : `${answer.tier}, confidence ${answer.tierConfidence.toFixed(2)}`
84engine/ui/palette.ts 8 lines1// Distinct on dark and light terminals; a type keeps its color across sessions.
2export const PALETTE: readonly string[] = ['#4fc3f7', '#ffb74d', '#81c784', '#ba68c8', '#e57373', '#fff176', '#4db6ac', '#f06292']
3
4// djb2: a stable string hash, so the same subagent type always lands on the same color.
5const hashOf = (text: string): number => [...text].reduce((hash, char) => ((hash * 33) ^ char.charCodeAt(0)) >>> 0, 5381)
6
7export const colorOf = (type: string): string => PALETTE[hashOf(type) % PALETTE.length] ?? PALETTE[0]!
8engine/types/index.d.ts 37 lines1// The last run of the project's gate, as the status line and the band show it.
2export type GateRun = { isGreen: boolean; durationMs: number; at: number }
3
4// One tool call a subagent made: the tool, a short form of its input, whether it errored.
5export type AgentStep = { tool: string; input: string; isError: boolean }
6
7export type AgentRun = {
8 id: string
9 type: string
10 label: string
11 color: string
12 description: string
13 model?: string
14 isDone: boolean
15 steps: AgentStep[]
16 durationMs?: number
17 tokens?: number
18 answer?: string
19}
20
21// The engine's status-line figures at its last measurement, as the band's usage row draws them.
22export type Usage = {
23 model: string
24 context: { tokens?: number; percent?: number }
25 rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
26 usd?: number
27}
28
29// The model and effort the Jev router settled on for the session, as the usage row shows them.
30export type Routing = { model: string; effort: string }
31
32declare module 'claude-code' {
33 interface PluginState {
34 'lexi': { lastGate: GateRun | null; agents: AgentRun[]; selected: string | null; usage: Usage | null; routing: Routing | null }
35 }
36}
37