v3.10.3
Added — lean-ctx value: what lean-ctx did, with proof
-
lean-ctx valueshows the tokens kept out of the model's context and the
security events of the current session.--session <id>picks another
session,--allcovers every session, and--jsonprints the result as
JSON. The numbers do not come from the display counters. They are
recomputed from the hash-chained savings ledger and the signed audit trail.
Both chains are checked from their first entry, and the output names the
first and last entry hash each number rests on. When a chain is broken, the
numbers are marked as not proven and the command exits 1. -
Ledger events now include the session id in their hash (canonical v7), so
a session's savings can be proven on their own. Entries written as v1–v6
still verify. -
Protections that fire by default now leave a signed audit-trail entry:
- a secret redacted from tool output (counted once, on the output that
reaches the model) - a shell command blocked by the allowlist
- a path refused by the project jail
The entry records the kind, the count and the session.
- a secret redacted from tool output (counted once, on the output that
-
New
[value_display]config section (mode = off | minimal | milestones | verbose, defaultminimal, envLEAN_CTX_VALUE_DISPLAY). It controls the
value surfaces added in later changes. The proof chains are written
regardless of the mode.
Added — Claude Code shows what lean-ctx did, outside the model's context
- Status line.
lean-ctx statuslineprints one dim line for Claude Code's
statusLine, for example◆ lean-ctx −1.2M tok · 41 cached · ⛨ 3(⛨ counts
security events). It reads the snapshot of the project Claude Code is
working in. It prints nothing when nothing was measured yet, when the
numbers are older than 12 hours, or when they belong to an earlier
conversation.init --agent claudesets it only when there is no status
line yet, or when the existing one is already lean-ctx's. If you have your
own status line, it stays untouched andinitprints the command that
chains it:lean-ctx statusline --wrap '<your command>'. The wrapped
command gets the same input, and lean-ctx's segment is appended to its
first line. Uninstall gives your wrapped command back and removes only
lean-ctx's own entry. - Turn recap. Every 10 turns (
recap_every_turns), the Stop hook shows a
one-linesystemMessagesuch as◆ lean-ctx · last 10 turns: −312.0K tokens, but only when at least 50,000 tokens were saved
(recap_min_tokens) or a security event happened. A quiet window keeps
growing until it is worth a line. - Session recap and weekly digest. On a fresh start, SessionStart shows
the last session once, with its share of tool input
(◆ lean-ctx · last session (62% of tool input): −1.4M tokens). Once a
week it shows a digest of all sessions instead. Nothing is shown on
resume, compact or clear. - These lines are
systemMessageand status-line output. They never reach
the model's context. The SessionStart rules and the recap are written as a
single JSON object, because a host reads only one per hook. Hosts that do
not showsystemMessage(Codex, Cursor) get no recap.mode = offturns
all of it off, andlean-ctx valueproves every number.
Added — what lean-ctx did, in your prompt, your desktop and your commits
- Prompt segment.
lean-ctx init --promptadds a dim segment such as
◆ −1.2M tok ⛨ 3for the project you are in. It goes on the right in zsh
and fish, and in front ofPS1in bash. The segment has its own rc block,
your prompt stays as it is, andinit --prompt offorlean-ctx uninstall
removes it. Under Starship,initprints acustommodule instead of
editing rc files.lean-ctx prompt-segment --shell plainserves any other
prompt engine. It reads one small snapshot file and prints nothing when
there is nothing current to show. - Milestone notifications with
mode = milestones: a desktop notification
the first time you reach 1M/10M/100M/1B tokens kept out of context, the
first secret kept out of context, the first risky command blocked, or a
7/30/100-day streak.- At most one a day, and of a ladder only the highest new rung is shown.
- Milestones are computed from the verified chains. A chain that fails
verification triggers none. - Every notification ends with
Proof: lean-ctx value --all. - They use
osascript,notify-sendor a Windows toast, with no new
dependency. The text is passed as arguments, never as script.
- Commit trailer.
lean-ctx init --git-trailerinstalls a
prepare-commit-msghook that addslean-ctx: 840.0K tokens saved, 1 secret kept out of contextto a commit message.- It counts only what happened in this project since the last trailered
commit. - It skips merges, squashes and amends.
- It can never fail a commit, and it leaves an existing hook untouched
(printing the line to add instead).
- It counts only what happened in this project since the last trailered
- Wrapped security section.
gain --wrappedand its compact form list the
period's security events, re-counted from the audit trail. They appear only
when the trail verifies. - The guide Seeing what lean-ctx did covers
every surface, including Starship and Powerlevel10k.
Added — lean-ctx prove speed: a signed measurement instead of a speed claim
lean-ctx prove speed --suite <file>asks your model each task of an eval
suite twice per run: once with a raw context dump, once with lean-ctx's
context, both within the same token budget (--budget, default 4000). It
needs a live, OpenAI-compatible endpoint (LEAN_CTX_EVAL_MODEL_URL,
LEAN_CTX_EVAL_MODEL, optionalLEAN_CTX_EVAL_MODEL_KEY), local Ollama
included. Recorded responses are refused, since they have no latency.- The measurement is built to be fair: one warm-up request is not counted, the
arm that goes first alternates per task and round, and each arm's latency is
the median of--runsrounds (default 3). Every answer is scored, so a
faster but worse result is reported as worse. - The result is a signed proof (
<data_dir>/value/speed/,--outfor a
copy,--jsonto print it).prove speed --verify [FILE]re-checks the
signature and recomputes the summary from the raw timings. A modified proof
printsTAMPEREDand exits 1. gain --wrappedquotes your latest proof (<N>% faster model answers with lean-ctx, with date, tasks, runs and model) only when it verifies and
lean-ctx was faster without answering fewer tasks correctly. The figure
describes your suite, model and machine, not lean-ctx in general. No
surface estimates speed from live sessions.
Added — what lean-ctx did, in your editor and in signed savings batches
- VS Code, Cursor and Windsurf extension (
packages/vscode-lean-ctx,
source only in this release; it is not yet published to an extension
registry): one quiet status bar item for the open project, for example
◆ −1.2M tok ⛨ 3.- Hovering it shows the breakdown, each line labelled
✓(counted) or≈
(derived), plus the verified speed proof if you have one. - Clicking it runs
lean-ctx value. - It is hidden when nothing was measured, the numbers are stale, or
mode = offis set. - The extension computes nothing itself and never writes to the model's
context.
- Hovering it shows the breakdown, each line labelled
lean-ctx prompt-segment --json [--dir PATH]prints the segment, the
labelled breakdown, the speed proof, the directory to watch and the verify
command as a stable JSON contract (schema: 1) for editor status bars.- Local Protection view: the Protection page that
lean-ctx dashboard
serves on your machine opens with Guards that fired.- It shows the lifetime counts of secrets kept out of context, risky
commands blocked, paths outside the project blocked, and prompt-injection
patterns flagged. - Every count is re-derived from the local audit trail, the same as
lean-ctx value --all. - It shows whether the trail is intact, or where the chain breaks.
- It shows the lifetime counts of secrets kept out of context, risky
- Signed savings batches: a signed savings batch now carries a separately
signed security tally.- The tally holds the counts, the audit trail's entry count, and its first
and last hash. - It is bound to the batch's last entry hash, so a copied or edited tally
failslean-ctx savings verify-batch, which lists it. - The batch's own signature is unchanged, so a verifier that does not read
the tally keeps verifying batches as before.
- The tally holds the counts, the audit trail's entry count, and its first
Fixed — the savings_footer default is documented as never
- The config schema and reference said
savings_footerdefaults toalways.
The real default has always beennever, so no footer tokens are added to
tool output unless you turn it on.
Fixed — init --help prints help, and the rules name only tools the agent has (#1849)
lean-ctx init --help(and-h) ran a fullinitinstead of printing the
help text, so a user checking the options rewrote their shell and agent
configuration. Both flags now print the usage and change nothing.- The session instructions and the injected rules files listed tools that
tools/listhides. Withdisabled_tools = ["ctx_callgraph"]the agent was
still told to usectx_callgraph. Withprefer_native_editor, or on a
native-editor client, it was told "if denied, use ctx_patch". The guidance
is now built from the same profile,disabled_toolslist and client rules
that decidetools/list, one profile per rules target. The parallel-calls
and "ACTUALLY EMIT" lines mentionctx_composeonly when the profile has
it, and the graph anti-pattern names only the graph tools still enabled. - The shadow-mode edit line said "native Edit/StrReplace". Codex edits with
apply_patch, so the line now says "the host's native edit tool". Rules
version 11 rewrites the installed files on the next start. The Codex guide
no longer tells Codex to use a nativeGlobit does not have.
Thanks to @skonebrant for the detailed report.
Fixed — _lc shims and shell hooks survive package-manager installs and updates (#1851)
lean-ctx init --globalwrote the_lc/_lc_compressPATH shims next to
the running binary. With scoop, Homebrew, npm or mise that is a versioned
directory offPATH(scoop/apps/lean-ctx/3.10.2,Cellar/…,
node_modules/…,mise/installs/…). The shims were therefore never found.
Hosts that replay a shell snapshot without the_lcfunction, such as
Claude Code's Bash tool, kept thealias git='_lc git'aliases, and every
aliased command failed with_lc: command not found.- The shims now go to the running binary's directory only when it is on
PATH. Otherwise they go to thePATHdirectory holding thelean-ctx
launcher: the Homebrew symlink, the scoop shim, the npm or mise launcher.
~/.local/binis the last choice when it is onPATH, and the first
writable directory wins.lean-ctx uninstallalso removes shims from the
PATHdirectories, and still removes only files carrying the lean-ctx
marker. shell-hook.*andenv.shembedded the same versioned path, which stopped
existing afterscoop update+scoop cleanuporbrew upgrade. They now
embed the stablePATHlauncher when the binary is offPATH. Installs
whose binary sits onPATH(theinstall.shand Windows ZIP layouts) are
unchanged. Runlean-ctx init --globalonce after updating to rewrite the
hook. Thanks to @ZacKienzle2 for the detailed report.
Fixed — ctx_shell places a relative redirect after a cd where it lands (#1850)
- The write guard placed a relative redirect or
teetarget in the call's
cwd, even after acdearlier in the same command.cd /tmp/x && echo a > out.txtwas therefore refused, althoughout.txtlands in a scratch
directory. With a scratchcwd,cd <project> && echo a > fwas allowed,
althoughflands inside the project. Each command is now judged in the
directory it actually runs in: the call'scwd, moved by a plain
cd <dir>that is certain to have run before it. When that directory cannot
be known, a relative target is refused, and the message asks for an
absolute path. That covers acdthrough a variable,pushd, acdin a
subshell or a group, acdthat may fail or be skipped, and a backgrounded
list. Absolute targets are judged as before. Thanks to @andig for the report.
Fixed — the PowerShell tool no longer gets a Bash command back (#1848)
- On Windows, Claude Code and Copilot CLI send PowerShell tool calls through
lean-ctx hook rewrite. The hook answered them the way it answers Bash:
compounds and commands outside the shell allowlist came back wrapped as
'…\lean-ctx.exe' -c '…'. PowerShell cannot parse that
(Unexpected token '-c'), so every such call failed. Quoting alone would not
have fixed it:lean-ctx -cruns its command in the shell lean-ctx detects
for itself, which is Git Bash on most Windows machines, not PowerShell. - PowerShell calls now take their own path. A single
Get-Content,
Select-StringorGet-ChildItem(and the other read, search and list
commands) becomes& '…\lean-ctx.exe' read …, quoted for PowerShell.
Windows paths such assrc\main.rskeep their backslashes. Everything else
runs in PowerShell unchanged. A word that PowerShell would evaluate first,
such as$env:X,@args,a,b,*.mdor(…), is never rewritten. - The shell allowlist still applies. In
enforcemode a PowerShell command it
blocks (for exampleRemove-Item) is refused with the allowlist's reason,
not wrapped.warnandoffbehave as before. - PowerShell quoting elsewhere in lean-ctx now also quotes
@and,, which
PowerShell treats as operators. - Thanks to @ZacKienzle2 for the precise root-cause analysis.
Fixed — a task overview no longer lists facts that share only a generic verb (#1832)
lean-ctx overview '<task>'listed any fact that shared a single word with
the task as a "relevant fact". A task like "Inspect alpha parser header
validation." therefore showed an unrelated fact such as "Inspect and review
gamma certificate deployment sequencing." because both contain "inspect".
Two unrelated tasks got the same unrelated facts. Only the task's content
words are matched now: words like "the", "and", "inspect", "review", "check"
or "fix" are ignored, punctuation is trimmed ("validation." matches
"validation") and words are split the way facts are indexed. A task made
only of such words lists no facts. The sub-agent briefing pack uses the same
matching. An explicitctx_knowledgerecall still matches every word you
pass. Thanks to @rtbe for the isolated reproduction.
Fixed — Pi: ctx_shell's timeout now reaches lean-ctx -c (#1833)
- In
pi-lean-ctx,ctx_shell(command, timeout=<seconds>)passed the timeout
only to Pi's outer bash tool. Thelean-ctx -cwrapper inside it kept its
default of 120 s. A call withtimeout=200was therefore stopped after
about two minutes withoutput truncated at 8 MB / 120s limit. The
per-call timeout is now passed tolean-ctx -cas
LEAN_CTX_SHELL_TIMEOUT_MS, capped at the same one-hour ceiling the MCP
timeout_mshas. ALEAN_CTX_SHELL_TIMEOUT_MSyou set yourself still wins,
andraw=trueis unchanged because it does not go through lean-ctx. Thanks
to @rtbe for the precise report.
Fixed — a jq program in single quotes is no longer blocked as source (#1829)
- A quoted
jqfilter such as'… | . as $r | …'was blocked. The block said
the command runseval/exec/sourceor a substitution. The quick
pre-scan for| .and similar separators looked through quotes, so it read
jq's identity filter as the shell's.(source) builtin. That scan now
ignores text inside single quotes. The per-segment check still decides
every command, so a real.orsourceat command position is still
blocked, next to quotes too. The block message now namessourceand..
Thanks to @andig for the report and the reproductions.
Fixed — Claude Code's Bash sandbox no longer blocks every command (#1834)
- With
sandbox.enabled, Claude Code spawns each Bash call as
env SANDBOX_RUNTIME=1 … /usr/bin/sandbox-exec -p '<profile>' /bin/zsh -c '<cmd>'
throughzsh -c. The.zshenvredirect forwarded that launcher to
lean-ctx -c, nothing recognised it, and the allowlist hard-blocked on the
innereval— exit 126 for every command, evenecho.lean-ctx -cnow
recognises exactly the launcher Claude Code builds
(/usr/bin/sandbox-exec -p <profile>followed by/bin/zsh,/bin/bash,
/bin/shor the user's own$SHELL, then-c <script>). It runs the
allowlist gate on the unwrapped script before starting the sandbox, then
spawns the launcher as is, so the command still runs inside the user's
sandbox. The launcher is never unwrapped, because that would run the
command outside the sandbox. - Only the environment variables Claude Code itself sets are accepted in
front of the launcher: the proxy and CA variables,TMPDIR, and the exact
GIT_SSH_COMMAND/GIT_CONFIG_*values. Every other variable name falls
back to the normal gate, as doenvoptions,-uof a hook variable,
anything betweensandbox-execand the shell, relative paths, and Linux
bwrap. The normal gate still blocks these, as before. This includes
user-definedsandbox.setEnvVarsand an inheritedJAVA_TOOL_OPTIONSwith
a non-default value.
Fixed — npm install no longer breaks when the install path contains $
npm install lean-ctx-bininto a path such ascache_$HOME_binran the
wrong command (#1838).postinstall.jsbuilt shell strings like
"<binary>" onboard, and a double-quoted$…is still expanded by the shell,
so the binary path was rewritten before it ran.onboardand the pre-install
stopnow go throughexecFileSyncwith an argv array.- The same applies to the remaining commands that carried a path or URL: the
curl download, the GitHub API lookup, the Windowsstopand the Windowstar
extraction. None of them go through a shell anymore. postinstall.dollar.test.cjsnow runs in CI next to the stdio test (skipped
on Windows, where the shebang fixture cannot run).
Security — a rewritten Bash command no longer expands $vars or runs `…` early (#1862)
lean-ctx hook rewritebuilt the command it handed back to the host's Bash
tool with double quotes in two places. On Windows, thelean-ctx -c "…"wrap
used the quoting of the shell lean-ctx detects for itself, which can be
cmd.exe. Git Bash then expanded$HOMEand ran$(…)before lean-ctx saw
the command. On every platform, a word that the directread/grep/ls
rewrites re-quoted, such as a single-quoted'$HOME'pattern, came back as
"$HOME"and was expanded as well.- The Bash tool's shell is POSIX everywhere, so the wrap and every re-quoted
word now use single quotes.lean-ctx -c 'git log --format="$HOME"'reaches
lean-ctx unchanged. - A command containing
$or`is no longer rewritten word by word, since
the rewrite cannot tell'$HOME'(literal) from"$HOME"(expand). It keeps
the agent's own quoting inside the-cwrap, or runs unchanged where there
is no wrap (cat). - PowerShell tool calls are unaffected; they have their own path (#1848).
- Thanks to @rickgoud for the report.
Security — the shell allowlist checks the command a wrapper really runs
- An option value of a delegation wrapper was taken for the delegated
command. Forenv,sudo,doas,nice,timeoutandxargsthe
allowlist skipped each-xword on its own, so the value that followed it
(-u NAME,-s SIGNAL,-I REPLACE, …) was checked in place of the command
that actually runs, and that command never reached the allowlist or the
inline-code check. Each wrapper's options are now parsed with the argument
they take — attached or separate, short clusters, GNU long-option prefixes,
--,timeout's duration operand,env -Ssplit strings — and the check
applies to the real command. - The side effect ran the other way too:
env -u HOME git statuswas blocked
becauseHOMElooked like the command. It is allowed now. commandandbuiltinrun the word after them, but as shell builtins they
skipped every check. They are now walked like the other wrappers;
command -v/-Vstill only look a name up.- A wrapper can no longer reach
eval,execorsource, which stay
blocked regardless of the allowlist. - Wrappers nested more than three deep used to end the check silently; they
are now refused. - Commands inside a shell function body now get the inline-code and
dangerous-flag checks, not only the allowlist lookup.
Fixed — secret redaction stays on its line and leaves ***** masks alone (#1830, #1831)
- A keyword with no value redacted the next line. When a line ended in
token:orpassword =, the blank after the separator also matched the line
break, so the first word of the following line was treated as the value. In
a diff that was the+/-marker; in YAML the nested key was replaced
while its value stayed visible. All key/value rules, in bothctx_read
redaction and secret detection, now allow only spaces and tabs around the
separator;BearerandAuthorization:likewise stay on their line.
Reported by @andig (#1830). - Asterisk masks such as
password: *****were redacted. They are what a
redactor writes in place of a secret, not a secret. Replacing them broke
fullreads as an edit source:replace_uniquebuilt from the view did not
find the text on disk. An all-asterisk value now counts as a placeholder.
Reported by @andig (#1831).
Fixed — lean-ctx builds on FreeBSD again, without relying on renameat2
- The FreeBSD build stopped at
engine_artifact/unix.rswith
cannot find value result(#1828, reported with a patch by @yurivict).
Engine artifacts are published with a rename that must never replace an
existing file; only Linux (renameat2) and macOS (renameatx_np) had one,
and the fallback branch for every other Unix did not compile. - The fix does not call
renameat2by syscall number: FreeBSD only has it
since 16.0, and an unknown syscall on 14.x/15.x raises SIGSYS and kills the
process. Targets without a native no-replace rename now link the new name
(linkatfails withEEXISTif it exists, on every POSIX system) and then
remove the temporary name. A file system without hard links is reported as
unsupported by the existing capability probe instead of failing mid-publish.
Fixed — the account commands are no longer hidden behind a research flag
login,register,sync,cloudandcontributeanswered "unavailable"
unlessLEAN_CTX_EXPERIMENTAL_HOSTED=1was set. The flag was meant for
unreleased research, but it also hid the optional account sync of existing
accounts. The flag is gone, and these commands work without it again. They
need an account; without one, lean-ctx keeps working locally as before.cloud statusnames the signed-in account.- If your plan does not include synchronization, the message now says so
directly and confirms that local context is unchanged, instead of a generic
upgrade pitch. - The help section
HOSTED RESEARCHis nowACCOUNT SYNCand lists the
commands that actually work.
Fixed — a rejected credential no longer looks like being offline
- Background sync treated a rejected credential as a network problem.
classify_outcomesspecial-cased only HTTP 402; an HTTP 401 fell through to
NetworkFailure, which prints nothing and deliberately leaves the day's sync
slot open so the next cycle retries. A machine whose API key had been
revoked therefore retried forever, in silence. - Added
AutoSyncOutcome::Unauthenticated, ranked above the plan check: a
dead credential makes every other signal moot. It prints once per process,
says that local data is intact, and names the fix (lean-ctx login). - The slot rule is now the named predicate
consumes_daily_slot, so "only a
network failure leaves the slot open" is stated in one place and tested. loginandregisternow send a device label with the request.
Fixed — a grep pattern is no longer silently reinterpreted (#1827)
- The
PreToolUseshell hook rewrotegrepontolean-ctx grepwhile
passing the pattern through verbatim — but the two sides do not speak the
same regex dialect. Plaingrepapplies POSIX basic regular expressions,
where\|alternates and a bare|is a literal.lean-ctx grepcompiles
with the Rustregexcrate, which reads those exactly the other way round. - The report was a false negative:
grep -n "headroom\|HEADROOM" db.pyon a
file containing both answered0 matches for 'headroom\|HEADROOM' in 1 files
and exited 1. The same defect runs the other way too —grep -n "a|b"is a
literal search in BRE, but the rewrite reported every line containingaor
b. Seven metacharacters flip meaning this way:|+?(){}. - Both directions produced a wrong answer that looks like a right one, with
no error to notice, which is the shape that matters for anyone scripting
against the output. - A
grepinvocation whose pattern contains one of those seven now declines
the rewrite and falls through to thelean-ctx -cwrap, where the platform's
own grep resolves the pattern — the same escape valvefgrepand the
semantic flags (-i,-w,-F, …) already used. Output is still
compressed; only the matching is handed back. egrepandrgkeep the fast path: POSIX extended regular expressions and
the Rustregexcrate agree on all seven. So does a plaingreppattern that
contains none of them.- Not a Windows defect. The report came from Git Bash on Windows 11, but the
cause is platform-independent and reproduces identically on macOS and Linux.
Fixed — the proxy no longer forwards conversations it has emptied (#1789)
- On every forwarded route the proxy replaced each live-zone message with an
empty string. Upstream received only the system message, so the model
answered a conversation it could not see — HTTP 200 on both sides, no error
anywhere, which is what let it ship in two releases. - The cause is a category error, not a faulty compressor.
compress_live_prose
passes a task hint, which selectsCompressionStrategy::Aggressive, and that
strategy drops any paragraph carrying neither a task nor a technical
keyword. Sound for a document, where the surviving sections still carry the
meaning; a chat turn is a single paragraph, so dropping its only section
deletes the message.compress_textthen accepted the empty result because it
was shorter — exactly what a compressor is supposed to prefer. - Only the system turn survived, because
detect_live_zonepins the frozen
boundary tolast_system + 1. English technical conversations largely escaped
too, since they happen to hit the hardcoded keyword list, which is why a
French-language report is what finally surfaced it. - None of the three existing guards could observe this: the determinism guard
snapshots before the pipeline runs, the pipeline's guard proves the cache
prefix stable (untouched by emptying the live zone), and the savings floor
only reverts compressions that save too little — deleting all content sails
through as a perfect saving. - Fixed at three levels: conversation turns are pinned to
CompressionStrategy::Light;compress_with_strategyrefuses to turn
non-empty input into empty output; and a newdestroys_contentinvariant
reverts every stage when a message that arrived with text comes out empty,
covering both wire shapes (string content and block arrays). Tool output keeps
its section-dropping compressor, where whole-message deletion is not possible.
Fixed — a sub-agent is never served a cache stub it cannot resolve (#1801, #1804)
- Sub-agents silently received references to content they had never seen. On
their first read of anything the parent had touched they got a
[cross-agent cache · … tokens avoided]orunchanged, already in context
stub instead of the data. Nothing in the stub signalled the loss, so an agent
that did not notice proceeded as if the file or directory were empty. - Two layers rested on one premise that is false in Claude Code.
subagent_scope
resolvesproc:{pid}-{ts}once per process on the reasoning that "each MCP
connection is a separate stdio process" — but Claude Code sub-agents reuse the
parent's connection and spawn no lean-ctx process, so parent and sub-agent
resolve the identical scope. And the content-dedup ledger is process-global
and keyed on path alone;check_contenttakes no session, conversation or
agent argument at all. - Both now fail closed, reusing the rule
multiple_conversations_recentalready
established for concurrent chats: when a matching id cannot be trusted to name
this caller, nothing is provably in context, so no stub is served.
Re-delivering costs tokens; delivering a dangling reference costs the caller
its data. - Scopes that do name one agent — Cursor's
task:and an explicitcustom:
override — keep deduplicating, as does the legacy transcript path where one
daemon serves one conversation. - Withheld stubs are recorded as misses, not hits, so
tools healthcannot
report savings that were not made. - Tests also stop inheriting the developer's ambient agent environment: running
the suite inside Claude Code resolved aproc:scope for every test and 31 of
them failed, while the same tests passed in CI whereCLAUDECODEis unset.
Fixed — git decides corpus membership, not a leading dot (#1792)
lean-ctx findreturned nothing for a tracked dotfile or a tracked file
under a hidden directory, whilectx_globfound the very same paths. The BM25
and graph corpus builders excluded them too, soctx_composeand
ctx_overviewanswered "no match" for code that was present and tracked — an
incomplete index is indistinguishable from an empty result.- The walkers disagreed with no stated rule between them:
ctx_glob,
ctx_searchand one of the two walks insearch_index.rsincluded hidden
paths;find, the other walk in that same file, BM25 and both graph walks
excluded them.findhad no flag to change it, thoughlshas had--all
all along. - The rule now lives in
walk_filterasSKIP_HIDDEN_IN_CONTENT_WALKwith its
reasoning attached. A leading dot is a display convention, not a relevance
signal —.github/,.agents/and.config/routinely hold source-owned
automation a repository genuinely tracks. - Deliberately not a config key: a corpus that silently omits tracked files is a
correctness bug, not a preference. - Scope is content walks only.
ctx_treestill hides dotfiles behind--all,
because a listing rendered for a person is where that convention belongs. - Two boundaries verified rather than assumed, both tested:
.gitignorestill
decides, so an ignored dotfile stays excluded; andkeep_entrystill prunes
.claude/.cursorand the other agent-copy directories (#1480).
Fixed — auto_capture = false is enforced at the store, not at each producer (#1802)
- With
auto_capture = false, machine-derived facts kept appearing, and
deleting them fromknowledge.jsondid not help: one MCP call brought every
one back carrying its originalcreated_at. A curated store could not be
kept clean — 11 curated facts against 150+ machine entries. - There are two producers of automatic facts and the flag reached only one.
auto_capture::capture_findingchecksis_enabled();
session::state::extract_session_factsdoes not, and it is reached from
session::persistence::persist_session_facts— the session save path, which
is why a single tool call sufficed. The stale dates came from
auto_session_factcopying the finding's own timestamp: the facts were
re-materialised fromsessions/<id>.json, not re-derived. - The guard now sits at the store's ingestion points,
add_factandremember.
A check at a call site only covers the call sites that exist when it is
written — which is exactly how this defect arose. At the store, a producer
added later cannot bypass it. - The refusal sits before the coalescing branch, so a disabled run cannot even
refreshlast_confirmedon facts an earlier enabled run left behind; they
would otherwise look perpetually fresh and never age out. - Session state is deliberately untouched: it is ephemeral, capped at
MAX_FINDINGS, and backs handoff, recap and metrics — none of which this key
claims to disable. Existingauto:*facts are not deleted either; removing
someone's data on a config flag is not this change's call. They can now be
deleted by hand and stay deleted, which before they did not.
Fixed — ctx_shell no longer fails outright on Windows hosts that validate env names (#1799)
- Every
ctx_shellcall failed before the command ran, with
Invalid bash env name: "COMMONPROGRAMFILES(X86)". - The Pi extension builds its own bash tool instances and forwards the whole
inherited environment through their spawn hook. Windows has carried
ProgramFiles(x86)/CommonProgramFiles(x86)since forever, and hosts
commonly validate names against^[A-Za-z_][A-Za-z0-9_]*$— the parentheses
are rejected, so the spawn was refused for every command, whether or not it
touched those variables. - The engine's MCP
ctx_shellworked on the same machine because it runs
through lean-ctx's own executor and never hands the environment to a
validating host. That contrast is what located the defect in the wrapper
rather than in the engine. - Names outside the POSIX identifier shape are now dropped at that boundary, in
both spawn hooks, sincerawbypasses lean-ctx but still spawns through the
host. Filtering here rather than inleanCtxEnvkeeps the complete
environment for the MCP bridge and the engine's executor, which do not
validate. The filter runs last over the merged result, so it also covers
config-suppliedforwardedEnv. - Dropping these names costs nothing in practice: a POSIX shell cannot expand
$ProgramFiles(x86)by name anyway, so the value was only ever reachable
throughenv/printenv.
Fixed — instruction files are classified by file, not by parent directory (#1794)
ctx_read(mode="map")on ordinary TypeScript under a skill directory was
overridden tofulland answered with a large, truncated dump. The caller
lost both the structural map it asked for and the tail of the file, and the
workaround — guessing line windows — requires already knowing which sections
matter.is_instruction_filematched on path substrings: any path containing
/skills/,/.cursor/rules/or/.claude/rules/counted, whatever the file
was. A skill ships its instructions as documents and its implementation as
source, so.ts,.py,.rsand.shbeneath one are ordinary code.- Classification is now by file: instruction documents by name anywhere, and
inside an instruction directory only document extensions (md, mdc, markdown,
txt, rst, adoc) or no extension at all — rule files are routinely named
without one, and no language ships source that way. - Also drops the redundant
lower.contains("/agents.md"), which the filename
match already covers and which would additionally have matched a directory
namedagents.md. - #1584 fixed a bounded mode being widened to
fullfor instruction files; this
fixes the classification that decided what an instruction file is.
Fixed — ctx_grep bounds how wide a result line may be, not just how many (#1650)
limit: 1withcontext: 0returned an entire minified JSON line — a
~100 KB payload for one match, almost all of it unrelated content that
happened to share the line.limitcaps how many matches come back; nothing
capped how wide each one is, and the only size guard was a 512 KB cap on the
whole output, far above any single line.- ripgrep bounds this natively and, unlike a slice applied afterwards, knows
where the match sits, so it does the cutting:-M/--max-columnswith
--max-columns-preview. The truncation is therefore explicit in the output
rather than a silently short answer, which is what made the original behaviour
hard to notice. - Recovery needs no new machinery:
path:line:is still printed, so the full
line is onectx_read(path, mode="lines:N-N")away. The newmaxLineChars
parameter raises the budget and0removes it. 400 bytes comfortably fits a
real source line while keeping a minified blob out of the context window. - Measured against the reporter's fixture (a 100 KB single-line JSON): the same
search drops from 100042 to 434 bytes of output.
Fixed — the documented bare -N tail mode now works (#1813)
- The schema has advertised
-N=tailall along, but onlylines:-Nwas ever
implemented.mode="-3"failed to parse as a mode and was answered with the
head of the file — no header, no warning, no sign that the requested view was
not the one delivered. - That is the worst of the available answers. On a 165-line file the reporter got
lines 1..~140 and then the token cap, so the tail was unreachable through the
mode that exists to reach it, and the truncation notice made a wrong answer
look like a size problem. - The rule lives beside
ReadMode, which owns mode spelling, and canonicalizes
to the single internal form. Both entry points call it — the MCP handler and
lean-ctx read— because fixing only the MCP path would have left
lean-ctx read --mode -3still answering with the head, and two surfaces
disagreeing about a documented mode is how this class of defect starts. - Only a pure
-<digits>payload is rewritten.-x,-3-5,--3and a bare
-still reach the normal unknown-mode handling instead of being silently
reinterpreted — the very failure this removes.
Fixed — inline gets the verbatim turn budget it shares with raw (#1812)
ctx_shell(inline=true)truncated at ~4k tokens with no archive id, no path
and noctx_expandreference. The same command withraw=truereturned all
4251 tokens. The tail was simply gone, and the notice pointed at a
file-oriented tool that needs a path command output does not have.- #1582 gave verbatim requests the larger turn budget precisely because the
ordinary backstop made a documented recovery path unreachable above ~16 KB.
verbatim_requestedrecognisedraw = trueandmode = "raw"but not
inline = true, although the schema calls that one "return verbatim output
inline" — the same request in different words. - The omission also removed the recovery route rather than merely shortening the
answer: the archive line is produced on the compressed path, whichinline
skips by definition. Addinginlineto the verbatim set fixes both halves —
the output fits, so no cut happens and no recovery line is needed. - Recorded because it is still true:
archive.inline_max_bytesis referenced
only in the schema description and read nowhere in the code, so the "larger
output uses the archive/firewall" half of that sentence is unimplemented.
Fixed — the ctx_shell guard messages state the rule that actually fires (#1814, #1815)
- "never modifies project files" was false. The write-redirect and tee
guards claimedctx_shellis read-only for project files, whilecp,mkdir,
touch,rm -rf,git commitandgit worktree addall pass unblocked in
the same session. The rule that fires is narrower: no output capture (>,
>>,| tee, heredoc-write, download-to-file) into a project path, because
ctx_shellcompresses what it returns and the captured bytes may not be the
command's own (#1303). - The overclaim cost twice over: it sent callers to native Write for a plain
cp
thatctx_shellwould have run, and it offered a safety property that does not
hold. All four messages now name the capture rule and say explicitly that other
commands are not restricted. - A
$varcommand word is a correct split, not a mis-split. The diagnostic
blamed lean-ctx's parser, told the caller not to trust the split, suggested
re-quoting, and asked for a bug report — for behaviour working as designed;
$var-as-commandis listed under ANTIPATTERN in the tool description, and no
quoting makes a variable command gateable. A$-prefixed base now gets its own
message explaining that the name is only known at run time and naming the form
that works. The genuine mis-split guidance stays for tokens that really are not
command names (#1646), with a test pinning both halves.
Fixed — a relative redirect target is judged where the command actually runs (#1811)
- The write guard says "the destination decides", then refused every relative
target without resolving it.cwd=<scratch> … > probe.txtwas blocked while
the identical> <scratch>/probe.txtwas allowed — same destination, opposite
verdicts, under a message naming a rule it had not applied. - A relative target is now placed against the directory the command runs in. That
narrows as often as it widens: a relative target under a project cwd resolves
into the project and is refused on the same rule as an absolute one, instead
of by accident of its spelling. - The directory is the resolved one, not the
cwdargument. A jail-rejected
cwdis silently replaced with the project root, so judging the raw argument
would let a caller name an out-of-project scratch dir, have the guard approve
> probe.txtagainst it, and then have the command run in the project root and
write there.ctx_shellresolves the run directory once and feeds the same
value to the guard and to the run. Without a session the directory is unknown
and the guard keeps its stricter earlier refusal. - Only the caller-supplied
cwdis followed; an in-commandcdis not. Deciding
the effective directory from command text means gettingcd a && cd b,
conditional and quoted forms all right, and an error there would grant a write
the guard means to refuse.
Fixed — [[ … ]] is treated as a conditional construct, not a command (#1793)
ctx_shellrejected ordinary read-only verification commands that used a
bash conditional, e.g.git status --short; if [[ -n x ]]; then …→
'[[' is not in the shell allowlist.- Two independent defects, both needed for the reported shapes.
[[was missing
fromSHELL_BUILTINS, although its POSIX equivalentstestand[were
already members and it is bash conditional syntax evaluated by the shell
itself. Andsplit_on_operatorsshielded( … )and{ … }but had no notion
of[[ … ]], so the&&inside a condition split the conditional into
fragments that resolve to no real command. - Both delimiters must be standalone words to take effect, mirroring bash.
Without that, a glob character class (ls a[[:alpha:]]) or an array subscript
would open a depth that never closes and would shield the rest of the line from
splitting — an under-block, which this walker must never do. - The shield is scoped to operator splitting only. It does not widen what may
run: a command after the conditional is still its own validated leaf, and a
substitution at command position stays hard-blocked.
Fixed — Codex is wired onto the rail its credential authenticates on (#1685)
- The shell export contradicted the Codex config.
install_shell_exports
wroteOPENAI_BASE_URL=…/v1into every shell rc unconditionally, while
install_codex_envdeliberately writes nothing for a ChatGPT-subscription
login — that rail answers a subscription token with
401 … Missing scopes: api.responses.write, a message about organization roles
that names nothing real. The environment overrode the config decision, so the
careful answer never took effect and the 401 was what users saw. - Every other provider in that block was already gated on auth mode; OpenAI was
the only unconditional one. It is now gated the same way in all four shell
dialects, with an explanatory comment in place of the export so the omission is
visible rather than silent. The rule itself still lives in
codex_uses_chatgpt_login. - The strip deleted a setting lean-ctx never wrote.
is_codex_proxy_model_provider_entrycountedmodel_provider = "openai"as
one of ours. lean-ctx writesleanctx-chatgpt, neveropenai, so that branch
could only ever remove a pin the user had set themselves — silently, on every
setup pass, with no test covering it. The generated pin is still stripped, so
flipping the ChatGPT rail back off still restores native Codex history (#597). - #1774 had closed the same report by appending an explanation to the 401 body.
Routing the subscription token to the rail it actually authenticates on
replaces that explanation with a working path. - The new shell tests pin
CODEX_HOME, because the OpenAI line now depends on
Codex's auth state, whichresolve_codex_dirotherwise reads from the real
~/.codex— a test that tracked the developer's own login would assert
nothing.
Fixed — the steering profiles say which tools the ctx_* mapping governs (#1788)
- The profiles told the agent "NEVER use built-in Read/Grep/Shell/Glob" and,
one line earlier, that the mapping "is NOT optional" — a claim with no stated
boundary. Read literally it swallows every other MCP server's tools, so an
agent that takes it seriously stops using tools lean-ctx has no opinion about,
and an agent that notices the overclaim learns to discount the rule it was
supposed to follow. - The boundary is now stated where the rule is: the mapping governs the built-in
Read/Grep/Shell/Glob, and "Other MCP servers keep their own jobs." - It is paid for, not added. The dedicated rules profile has a hard 2400-char
budget (injected_profiles_stay_lean) because it costs tokens on every turn.
CRITICAL and NEVER stopped saying the same thing twice — and CRITICAL was
itself the unbounded claim — so one statement of the rule, with its scope, now
costs less than the two overlapping ones did. The profile lands at 2390/2400. RULES_VERSION9 → 10, with the regeneratedLEAN-CTX.mdand
rust/LEAN-CTX.md.- Two tests keep both halves honest:
every_steering_profile_states_what_it_governsrequires every profile to name
the built-ins and carry the boundary sentence, and
the_boundary_does_not_weaken_the_built_in_tool_rulepins the prohibition and
MANDATORY MAPPING so the fix cannot be "solved" by deleting the rule.
Fixed — a stuck session save no longer wedges every later tool call (#1783)
- #1783 reported every
ctx_*call hanging at once — fast tools included,
sub-agent calls included — with no self-recovery short of restarting the
server. The report could not be reproduced, so the search was for a mechanism
that produces exactly that shape: one stuck operation, all tools, permanent. PreparedSave::write_to_disktook the per-session file lock with
fs2::lock_exclusive(), which has no deadline.resolve_roots_oncecalls
session.save()— that same blocking write — while holding thetokiowrite
guard onself.session, and it runs pre-dispatch, before the handler watchdog,
which returnsNoneforctx_shell/ctx_executeanyway.call_tool_guarded
needssession.read()for every tool call, tokio'sRwLockis
write-preferring, and the roots probe re-arms itself
(roots_resolved.store(false, …)) so it is reachable on an ordinary call.
Nothing in the process can end that wait: no timeout, no cancellation, no
watchdog.- What this is not: proof of what happened on the reporter's machine. It is a
concrete path from "one save cannot get a file lock" to "the whole server is
wedged forever", now removed. - The fix is the house pattern this file already used twice elsewhere. New
file_lock::acquire_exclusive_timeoutbounds the acquire — the same
try_lock_exclusive+ deadline loopwith_project_index_lockran 130 lines
above the offending call, now shared rather than duplicated, leaving that call
site 14 lines shorter.write_to_diskuses it with a 5s deadline; a save that
gives up is recoverable, becausesave()restoresunsaved_changesonErr,
while a save that waits forever is not.resolve_roots_onceserializes under
the guard (prepare_save), drops it, then writes inspawn_blocking. initializekeeps its synchronous save on purpose — once per connection,
before any tool can run, and now bounded — with the reasoning in a comment.- Deliberately untouched:
check_idle_expiry's untimed guards. They are a
candidate, not evidence.
Added — bm25_max_files makes the BM25 corpus cap configurable (#1790)
- The BM25 corpus walk stopped at a hardcoded
MAX_BM25_FILES = 5000with no
config, env or CLI override, while the semantic index chunks the same corpus
(#737). On large monorepos — ~19k code files in the reported case —
bm25/dense/hybrid search was silently blind to everything past the first 5000
files in walk order, since the check fires beforefiles.sort(). - New
bm25_max_fileskey (u64, default 5000,0= unlimited), mirroring the
graph_index_max_filesprecedent (#206 → #790), with a schema entry and a
generatedconfig-keys.mdrow. The cap warning now names the key, so it is
discoverable from the output that mentions it.
Hardened — nested lean-ctx shell spawns are bounded (#1795)
- Nested spawns are capped at
MAX_EXEC_DEPTH = 8viaLEAN_CTX_EXEC_DEPTH, as
defence in depth against a shell hook re-firing inside a shell lean-ctx itself
spawned, which can otherwise fork-bomb the host (observed 2026-09-16).
Changed — Windows release engines are signed as Thinkery AG (#1820)
- Release builds for
x86_64-pc-windows-msvcandx86_64-pc-windows-gnuare
signed through Azure Trusted Signing before packaging. The workflow
authenticates without a stored secret, so no certificate or key material
enters the repository or the runner. - Signatures are RFC3161-timestamped (
timestamp.acs.microsoft.com, SHA256), so
they remain valid after the signing certificate expires. A verification step
runsscripts/verify-windows-signature.ps1on the signed binary, so an
unsigned or untimestamped artifact fails the job instead of shipping. - v3.10.3 is the first release to exercise this path. Unsigned binaries were
reported blocked by Smart App Control; whether a signed one passes on a clean
Windows 11 machine is tracked in #1825 and is not claimed here. Smart App
Control also weighs reputation that accrues with distribution, so a lag after
the first signed release would be expected rather than a defect.
Upgrade
lean-ctx update # recommended (auto-downloads + refreshes shell hooks)
cargo install lean-ctx # or
npm update -g lean-ctx-bin # or
brew upgrade lean-ctxNote: After upgrading via cargo/npm/brew, run
lean-ctx setupto refresh shell aliases.lean-ctx updatedoes this automatically.
Full Changelog: v3.10.2...v3.10.3