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.

cockpit


Quick start (simulator — no CLI needed)

npm install
npm run dev

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:

ModeLEAN_SOURCEWhat it does
Simulatorsimulator (default)Replays the scripted Agency session. Non-destructive. Demo-ready.
Live hookslive-hooksReacts to real Copilot CLI hook events (see below).
History replayhistory-replayReads ~/.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 hookslocalhost: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):

EventUsed for
sessionStartsession banner + the initial prompt
preToolUsefires the tool live; returns allow/block (enforces prunes)
preMcpToolCallexplicit serverName + toolName → precise MCP server attribution
postToolUse / …Failurerecords success/error (does not double-count)
errorOccurredsurfaces errors in the activity feed
sessionEndrenders 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 to http://localhost:8787/event?event=<name>. No scripts, lowest latency. Needs COPILOT_HOOK_ALLOW_LOCALHOST=1 in the shell that runs copilot. This is the path setup:live installs.
  • 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 / preMcpToolCall are authorization-affecting. For a non-localhost HTTP target you'd also need COPILOT_HOOK_ALLOW_HTTP_AUTH_HOOKS=1; localhost only needs COPILOT_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 disabledMcpServers in ~/.copilot/settings.json (equivalent to /mcp disable <server>).
  • Skills → moves the skill's SKILL.md into 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 varDefaultPurpose
LEAN_SOURCEsimulatorsimulator | live-hooks | history-replay
PORT / LEAN_PORT8787Backend port
LEAN_LIVE_PRUNE(off)1 enables real on-disk prune/restore
VITE_LEAN_APIhttp://localhost:8787Frontend → backend base URL
LEAN_URLhttp://localhost:8787/eventHook 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

MethodRoutePurpose
GET/healthLiveness probe.
GET/inventoryCurrent registry snapshot (items, metrics, session, activity).
POST/eventCopilot 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-hooks mode 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.json plus whatever fires through hooks (servers/tools are discovered the moment they fire) — documented here rather than faked.
  • node:sqlite is 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.