Lean
June 25, 2026 · View on GitHub
Agency gives you 40 MCP servers. You use 3. Lean prunes the rest — live.
Lean is a real-time, cinematic web cockpit that watches a GitHub Copilot CLI / Agency
session and prunes unused MCP tools, skills, and agents while you watch. Hundreds of
loaded-but-unused tool definitions burn context tokens and degrade tool-selection accuracy.
Lean visualizes the bloat and prunes it — and the pruning is genuinely enforced, not faked:
a pruned tool's preToolUse hook returns a block decision the CLI obeys.
Quick start (simulator — no CLI needed)
npm install
npm run dev
- Backend → http://localhost:8787 (Express + socket.io)
- Frontend → http://localhost:5173 (Vite) — open this in your browser
The simulator runs by default, so the cockpit demos instantly with no Copilot session attached. A realistic Agency session loads 40 MCP servers → 214 tools + 61 skills + 15 agents (290 callable items), fires ~12 of them over ~60s, then desaturates the cold ones, tags them REAP, and prunes them live. Watch the context-budget gauge fall from ~72% → ~17%, the tokens-saved / $-saved counters climb, and the agent-sharpness meter rise. An end card shows the Before → After.
Controls (bottom bar): Play / Pause / Restart, Prune all cold (batch animation), Restore all.
Configure via .env
All settings are env-driven. Copy the template and edit — the backend loads it automatically
(real environment variables and node --env-file always win):
cp .env.example .env # then set LEAN_SOURCE, LEAN_PORT, LEAN_LIVE_PRUNE…
How it works
work2/
package.json # npm workspaces; `npm run dev` boots both via concurrently
server/ # Express + socket.io on :8787 (TypeScript, run with tsx)
src/registry.ts # in-memory registry + prune/restore/keep + metrics
src/sources/ # EventSource interface: simulator | live-hooks | history-replay
src/integration/ # real Copilot layout: settings.json, session-store.db, prune engine
src/routes/ # /event (hook receiver), /action, /inventory, /control, /mode
src/data/ # simulatorScript.ts — the rich demo dataset
client/ # React + Vite + Tailwind + framer-motion cockpit
.env.example # all config knobs (copy to .env)
scripts/setup-live.mjs # one-command live-telemetry installer
.github/hooks/hooks.json # HTTP-hook wiring the CLI actually loads (event map under "hooks")
hooks/hooks.http.json # portable HTTP-hook template (no scripts — the proper path)
hooks/hooks.command.json # command-hook template (uses the scripts below)
hooks/ # lean-hook.sh + lean-hook.ps1 (command-hook scripts)
A single EventSource interface drives the registry, swappable behind one env var:
| Mode | LEAN_SOURCE | What it does |
|---|---|---|
| Simulator | simulator (default) | Replays the scripted Agency session. Non-destructive. Demo-ready. |
| Live hooks | live-hooks | Reacts to real Copilot CLI hook events (see below). |
| History replay | history-replay | Reads ~/.copilot/session-store.db to compute real past usage. |
You can also switch modes at runtime from the control bar (POST /mode).
Attach a real Copilot CLI session (live hooks)
This wires your actual Copilot CLI session into the cockpit using genuine hook telemetry — not mock data. Tools light up as they really fire, with real MCP-server attribution, and any tool you prune in Lean is blocked the next time the agent tries to call it.
One command
npm run setup:live # installs hooks into the current repo + sets LEAN_SOURCE=live-hooks
npm run dev # start the cockpit
# …then, in the shell that runs copilot:
# PowerShell: $env:COPILOT_HOOK_ALLOW_LOCALHOST = '1'
# bash: export COPILOT_HOOK_ALLOW_LOCALHOST=1
setup:live writes .github/hooks/hooks.json (portable HTTP hooks → localhost:8787) into
the target repo and flips .env to LEAN_SOURCE=live-hooks. Options:
node scripts/setup-live.mjs --user # install user-level (~/.copilot/hooks/hooks.json) — no trust prompt
node scripts/setup-live.mjs --repo <path> # install into another repo
node scripts/setup-live.mjs --scripts # use command-hook scripts instead of HTTP
node scripts/setup-live.mjs --prune # also enable on-disk enforcement (LEAN_LIVE_PRUNE=1)
node scripts/setup-live.mjs --port 9000 # custom backend port for the HTTP URLs
Repo vs user hooks. Repo hooks (
.github/hooks/hooks.json) require a one-time trust confirmation the first time you open the repo interactively — headless / automation (copilot -p …) can't answer that prompt, so the hooks stay deferred. For a session that fires immediately (and for any non-interactive run), install user-level hooks with--user(~/.copilot/hooks/hooks.json); they apply to every session with no per-repo trust step.
Then run Copilot in that repo and give it a prompt that calls MCP tools — watch the cockpit react live at http://localhost:5173.
What telemetry is captured (real data)
The backend's /event handler parses the real Copilot CLI hook payloads (camelCase by
default; snake_case under VS Code compat — both accepted):
| Event | Used for |
|---|---|
sessionStart | session banner + the initial prompt |
preToolUse | fires the tool live; returns allow/block (enforces prunes) |
preMcpToolCall | explicit serverName + toolName → precise MCP server attribution |
postToolUse / …Failure | records success/error (does not double-count) |
errorOccurred | surfaces errors in the activity feed |
sessionEnd | renders the Before → After end card |
A single MCP call emits both preToolUse and preMcpToolCall; Lean normalizes them to the
same tool and counts the fire once (the payloads carry no stable call id). Tool names are
parsed across every shape the CLI emits — mcp__server__tool, server/tool, server.tool —
and unseen tools/servers are discovered on first fire, so real servers appear as their tools
light up.
Manual hook wiring
The CLI loads hook configs from a file named exactly hooks.json — at
<gitRoot>/.github/hooks/hooks.json (repo) or ~/.copilot/hooks/hooks.json (user) —
with the event map nested under a top-level "hooks" object. A differently-named
file (or a bare event map) is ignored silently. Two flavors ship:
- HTTP hooks (
hooks/hooks.http.json) — the CLI POSTs each event straight tohttp://localhost:8787/event?event=<name>. No scripts, lowest latency. NeedsCOPILOT_HOOK_ALLOW_LOCALHOST=1in the shell that runscopilot. This is the pathsetup:liveinstalls. - Command hooks (
hooks/hooks.command.json+hooks/lean-hook.{sh,ps1}) — the CLI passes each event's JSON on stdin; the script POSTs it (forwarding?event=<name>) and echoes the backend's decision on stdout. If Lean is unreachable the scripts fail open (allow), so they never wedge your session.
Copy whichever template to <repo>/.github/hooks/hooks.json (or ~/.copilot/hooks/hooks.json).
preToolUse/preMcpToolCallare authorization-affecting. For a non-localhost HTTP target you'd also needCOPILOT_HOOK_ALLOW_HTTP_AUTH_HOOKS=1; localhost only needsCOPILOT_HOOK_ALLOW_LOCALHOST=1.
How enforcement works
When the agent tries to call a tool you've pruned, Lean's /event handler returns a body the
CLI recognizes as a block:
{ "decision": "block", "permissionDecision": "deny", "behavior": "deny", "reason": "pruned by Lean" }
All three signals are included because the CLI treats a result as a block on any of
decision==="block", permissionDecision==="deny", or behavior==="deny". If Lean is
unreachable the scripts fail open (allow), so they never wedge your session.
History replay (your real past usage)
LEAN_SOURCE=history-replay npm run dev
Reads ~/.copilot/session-store.db with Node 24's built-in node:sqlite (no native
dependency) — specifically forge_trajectory_events (which tools actually fired) and
dynamic_context_items (context-item read counts) — to compute usage-based prune
recommendations from your own history.
Real, opt-in pruning (mutates files)
By default Lean never touches your real config — pruning is visual only. To make pruning genuinely rewrite disk state on this machine's layout, opt in:
LEAN_LIVE_PRUNE=1 LEAN_SOURCE=live-hooks npm run dev
When enabled, pruning:
- MCP servers → adds the server to
disabledMcpServersin~/.copilot/settings.json(equivalent to/mcp disable <server>). - Skills → moves the skill's
SKILL.mdinto a local.lean-quarantine/folder.
Nothing is ever deleted — Restore reverses both. This machine has no mcp-config.json;
MCP enable/disable state lives in settings.json, which is what Lean edits.
Configuration
| Env var | Default | Purpose |
|---|---|---|
LEAN_SOURCE | simulator | simulator | live-hooks | history-replay |
PORT / LEAN_PORT | 8787 | Backend port |
LEAN_LIVE_PRUNE | (off) | 1 enables real on-disk prune/restore |
VITE_LEAN_API | http://localhost:8787 | Frontend → backend base URL |
LEAN_URL | http://localhost:8787/event | Hook scripts' POST target |
COPILOT_HOOK_ALLOW_LOCALHOST | (off) | 1 lets the CLI call HTTP hooks on localhost |
All knobs can be set in a repo-root .env (see .env.example) instead of exporting them.
Boolean flags accept 1 / true / yes / on. npm run setup:live writes the live-mode
values for you.
API reference
| Method | Route | Purpose |
|---|---|---|
| GET | /health | Liveness probe. |
| GET | /inventory | Current registry snapshot (items, metrics, session, activity). |
| POST | /event | Copilot CLI hook receiver. preToolUse returns allow/block. |
| POST | /action | { action: 'prune' | 'restore' | 'keep', id }. Reversible. |
| POST | /control | { action: 'play' | 'pause' | 'restart' | 'pruneAllCold' | 'restoreAll' }. |
| POST | /mode | { mode: 'simulator' | 'live-hooks' | 'history-replay' }. |
Live updates stream over socket.io: snapshot, item:update, items:update, metrics,
activity, session, session:end.
Build & verify
npm run build # type-checks + builds server (tsc) and client (vite)
npm run start # serve the built backend
The simulator auto-runs end-to-end on boot: 290 → ~40 active, context 72% → ~17%, ~2M tokens / ~$6 saved per session, sharpness ~90, with fired tools staying active and pruned tools genuinely blocked.
Notes & honesty
- Live mode is genuine telemetry. In
live-hooksmode every fire, server attribution, and block reflects a real Copilot CLI hook event — nothing is synthesized. The simulator (the default) is clearly scripted demo data and never pretends to be live. - The full 40-server inventory isn't enumerable from any local file on this machine, so the
rich loaded inventory lives in the simulator dataset. Live mode shows what's actually
discoverable from
settings.jsonplus whatever fires through hooks (servers/tools are discovered the moment they fire) — documented here rather than faked. node:sqliteis built into Node 24 (used here at v24.17); it prints a one-line experimental stderr notice but needs no flag and no native module.- Requires Node ≥ 24.