Fixed — release packaging
- Windows Engine companion wheels now read
.exepayloads in binary mode,
avoiding false truncation errors when binaries contain the DOS EOF byte
(0x1A). - Installed Windows launchers now stay attached until the bundled Engine exits,
preserving SDK process monitoring and deterministic temporary-file cleanup. - Linux musl release builds now use the platform system allocator instead of
compiling jemalloc, avoiding its C11-atomics incompatibility on supported
musl runners. glibc Linux and macOS builds continue to use jemalloc. - Release notes now compare against the latest actually published GitHub
release, so an aborted release run cannot omit still-unpublished changes.
Added — Python linting
- CI now runs Ruff's Pyflakes rules across all shipped Python scripts, catching
undefined names and unused imports without introducing unrelated style churn
(#1676).
Fixed — bounded session startup and retention
- Normal project-session lookup now uses a bounded per-project index instead of
scanning the entire global session store; missing, corrupt, empty, or stale
indexes are repaired safely on the exceptional path (#1713). - Explicit session cleanup has a configurable retention window and always keeps
the newest session for every project, independently of archive retention.
Fixed — Windows paths, sandbox environment, and daemon fallbacks
- Path expansion now falls back to the platform home directory when Windows
does not defineHOME(#1691). - Context Kernel feedback honors the configured cache directory instead of
constructing a HOME-relative path that could escape into the working tree
on Windows (#1693). - Sandboxed Windows processes preserve
SystemDrive, preventing literal
%SystemDrive%directories from appearing in the project (#1696). - One-shot reads and
lean-ctx lsrecover safely when a shared daemon is
rooted in another project; genuine standalone directory errors now use
stderr and a nonzero exit status (#1695). - Cross-platform process and command-wrapping regressions no longer depend on
Unix utilities or POSIX-only rendering assumptions (#1698, #1700). - Git test fixtures ignore global commit-signing policy, keeping non-interactive
contributor test runs deterministic (#1703). lean-ctx wrap claudenow reuses the Claude authentication guard: Pro/Max
OAuth gets no new proxy redirect, stale lean-ctx-owned redirects are repaired,
custom endpoints are preserved, and API-key users retain wire-level proxy
compression (#1705).- Shell exports for Windsurf, Cline, and Aider apply the same guard, because an
exportedANTHROPIC_BASE_URLalso affects Claude launched from that shell. - Fatal
wrapandunwrapsetup failures now return a nonzero process status;
invalid CLI arguments return status 2, and proxy failure occurs before agent
configuration is changed (#1707). - The README quick start now distinguishes the MCP/shell path available to
Claude Pro/Max users from API-key-only wire-level compression (#1706). - Public prose now links to the generated MCP registry instead of duplicating a
mutable tool count; unscoped savings claims were replaced with local
measurement guidance, and SDK links identifythinkery-leanctx-sdkas the
supported Agent SDK.
Fixed — Codex browser login was sent to the API-key endpoint (#1685)
lean-ctx wrap codexnow selects the/v1proxy rail only when Codex has
positive API-key evidence. Browser-login credentials in an olderauth.json,
the OS credential store, or another unreadable/unknown auth state stay on
Codex's native ChatGPT rail.- This prevents the misleading
401 Unauthorized: Missing scopes: api.responses.writefailure seen after a successful browser login. Explicit
API-key users continue to use the compressed/v1route unchanged.
Fixed — phone DLP confused repository numbers with phone numbers (#1682)
- The phone detector no longer treats ordinary bare numbers such as issue and
CI identifiers, ports, years, timestamps, counts, or regex bounds as phone
numbers. A candidate now needs an explicit international prefix, familiar
phone formatting, or nearby phone-specific context. - Common formatted numbers such as
612-338-6000,+1 612-338-6000, and
(612) 338-6000are detected, while invalid checksum-protected identifiers
remain untouched instead of being reclassified as phones. - Egress remains fail-closed when
egress.block_secretsis active. Its refusal
now points to the effective local or organization policy instead of implying
that a project-local file can always override an organization floor.
Fixed — lines vanished mid-result behind a content-shaped marker (#1679)
gh issue view … --commentscame back with 34 lines removed from the middle,
marked only by... (34 more lines)— a string shaped like output, which the
caller could not tell from a line the command printed.- It was the only trace. The
[lean-ctx: …→… tok]footer that announces a
compression is hidden on the MCP path by default (SavingsFooter::Autois
invisible in an MCP context), and that is the primary path. So a lossy result
was indistinguishable from a complete one, with nothing to recover from. - Elisions now announce themselves:
[lean-ctx: 34 lines elided — re-run with raw=true for the full output]. It
is unmistakably ours and names a route that always works — unlike an archive
handle,raw=truedoes not depend on the response having been archived or on
a hint tier being enabled. - Applied at all 41 elision sites across 34 compressors through one shared
helper, and a test walks the source tree so a new compressor cannot hand-roll
the old marker back in. - The reporter suspected a dedup/repeat-collapse path because the elided block
was a near-duplicate of the preceding comment. It was not:gh issue view
runscompact_head_tail(output, 40, 40), so with 114 non-blank lines exactly
34 fall in the middle. The duplicate comment was a coincidence of where the
cut landed.
Fixed — the walk hint blamed directories that were never walked (#1680)
grep -rn PATTERN /absolute/path/outside/the/root/file.go, run from a project
root, produced a hint naming.claude/worktrees/,node_modules/and
.venv/"walked by grep/find". grep read exactly one file, and none of those
directories are under it — the list tracked the shell's cwd, not the path the
command was given.- Already fixed on main by the #1662 follow-up, which scans the command's own
path operands instead of its starting directory; this adds the reporter's
exact scenario as a regression test, including that a single file operand
never produces a hint at all.
Added — the dashboard asks how you use lean-ctx
- A form in the dashboard's Settings view asks four questions: what you use
lean-ctx for, what you like most about it, what is missing, and how often you
use it. Three are free text on purpose — a fixed list of features can only
ever return the answers it already contains, and the point is to learn which
use cases exist. Frequency is the one multiple choice, and it is what makes
the free text groupable: "what's missing" from a daily user and from someone
who installed it yesterday are usually different wishes. - Nothing is sent unless the button is pressed.
POST /api/feedbackhas no
background caller, the form starts empty on every load, and the notice sits
above the button rather than in a changelog: pressing send transmits the
answers, the lean-ctx version and the anonymous installation id to
leanctx.com — nothing else, and nothing at all until then. - Answers are validated before they leave the machine: a submission that answers
nothing is refused, and an over-long answer is refused with the field named
rather than travelling to be silently truncated at the other end. A failed
send keeps what you typed. - Routed through the dashboard's own backend, like the leaderboard board (#466):
the CSP pinsconnect-srcto'self', so the browser never reaches
api.leanctx.com— and the one place that decides what leaves the machine is
a single file. - An optional contact field exists for people who want an answer back. Empty
means anonymous, and empty is the default.
Fixed — the token-budget gate measured one platform and enforced all (#1651)
minimal_arm_per_turn_prefix_stays_within_budgetcaps the per-turn prefix at
1965 tokens, but the prefix is not the same size everywhere: the Windows shell
hint is empty on POSIX. A developer measured 1941 on macOS, read 24 tokens of
headroom, and CI enforced a number ~15 higher. #1646 spent three CI
round-trips on it; the CHANGELOG records the same trap in #1625.- The guard now prices the Windows-only hint on every platform and asserts the
worst case, so a local run reports the number CI will enforce — 1958 instead
of 1941, and 7 tokens of real headroom instead of an imagined 24. - The hint's formatting moved into one function the runtime and the guard share,
so the two cannot drift apart. - The failure message now also names the second invisible consequence of editing
atool_def:docs/reference/generated/mcp-tools.mdembeds the description
verbatim, sogen_docs --checkfails too. Neither is visible from the diff.
Fixed — a relocation guard failed on writers it was not about (#1658)
descriptor_bound_root_relocation_never_retargets_a_replacement_rootfailed
intermittently on Linux withleft: 1, right: 0, which named nothing — a real
boundary breach and an unrelated writer were indistinguishable.- The assertion was stricter than the invariant its own message stated: it
required the replacement root to be entirely empty while claiming to check
"no artifact or temporary leaf". - Directories are bound before the root is renamed away, so anything reaching
the replacement can only have got there by re-resolving the path — which
recreates the artifact tree, the published name, or its temporary. Those three
still fail, and the message now lists what was found. Anything else came from
a different writer and is not this guard's subject. - A sibling test pins both directions, so the relaxation cannot quietly mute the
invariant. Whether an unrelated writer touches the data directory without
test_env_lock()remains open and is unaffected by this.
Fixed — the tee refusal contradicted itself (#1671)
echo hi | tee <project>/dist/index.htmlwas refused with "tee without pipe",
in a message whose next sentence stated thatcmd | tee fileis allowed. The
command is piped, so the stated reason was wrong and the message described
the very form it had just rejected as permitted.- The rule was always the destination: a
teetarget outside the permitted
write paths is refused whether it is piped, mid-pipeline, or bare. Naming the
pipe also sent callers off to restructure their pipeline, which cannot help —
… | tee FILE | wc -lis judged identically. - The refusal now names the destination and the rule, and says plainly that
piping makes no difference. The verdict itself is unchanged. - Reported by a reviewer who read the message, concluded the block was correct
because an alternative was offered, and closed a session review with no
findings. The self-contradiction is what made the verdict look settled.
Fixed — a cancelled background job still reported "running" (#1674)
background_action: "cancel"killed the job but repliedstate: "running"—
byte-indistinguishable from a status poll, while the very nextstatuscall
on the same job returnedcancelledwith exit 130.- The header already said "cancel requested"; the structured
statefield,
which is what the caller actually reads, did not.cancelreturns the job's
state as it was before the worker noticed the flag. - A cancel accepted on a running job now reports
cancelled, and its summary
acknowledges the cancel instead of sayingno output— previously the cancel
reply was strictly less informative than the poll it was mistaken for. - Cancelling a job that had already finished still reports what actually
happened;cancelonly sets its flag on a running job. - This is problem 1 of #1246, which was closed with only problem 2 fixed.
Fixed — the redirect verdict depended on where the redirect sat (#1659)
echo hi 1>/dev/nullwas allowed;echo hi 1>/dev/null; echo okwas blocked
as a file write. Same redirect, same shell semantics, opposite verdicts — the
only difference was a following;.- The scanner read the redirect target up to the next whitespace, so a
trailing;became part of it. The target was/dev/null;, which is not
/dev/null, so the exemption never applied.&&happened to work only
because a space precedes it. - The target now ends at a shell metacharacter as well. The same bug silently
hit file-descriptor duplication:cmd >&2; echo okwas blocked too. >|(noclobber override) and>&(fd duplication) are parsed as part of the
operator rather than as the first character of the target, and(is
deliberately not a terminator — it would reduce the process-substitution
target>(tee out.txt)to an empty word, which falls through unblocked. Both
are pinned by tests.
Fixed — a truncated response cut off the line naming the way back (#1660)
- When a
ctx_shellresult exceeded the turn budget, it reported
[… truncated at ~4062 of 4432 tokens — use ctx_read with lines= …]. There
is no file: the bytes came from a subprocess.ctx_readneeds apath, so
the suggested recovery could not be carried out at all. - The cause is mechanical. The archive reference — the one line that names the
stored copy and how to read it — is appended to the end of the response,
and truncation keeps a prefix. The recovery line was therefore always the
first thing discarded, and the response advertised a route it had just
removed. - The budget now reserves room for that line and re-attaches it after the cut,
and the notice points at it instead of naming a file tool that cannot accept
stream output. If the budget is too small for both, the recovery line wins:
it is what makes everything else retrievable.
Fixed — no in-tool way to download a binary (#1661)
cd <scratchpad> && curl -sL -o shot.png <url>was blocked, and both
suggested fallbacks were text-only: piping a PNG to stdout floods the context
with bytes, andWritetakes a string. Fetching an attachment for triage had
no sanctioned route, which trained agents to fall back to native shell.- Downloads into a scratch directory were already permitted (#1021) — but only
when written as an absolute path. Judged as the bare stringshot.png, a
target that lands squarely inside the sanctioned directory read as a project
write. Command segments are now paired with the directory they actually run
in, so a relative target is judged where it lands. - The #391 boundary is unchanged: a relative target outside a scratch directory,
or one behind acd "$VAR"that cannot be resolved, is still a file write.
wgetandddkeep no scratch carve-out at all — a separate, separately
tested decision this fix leaves alone. - The refusal now names a route that works for the payload at hand.
Fixed — a heredoc body mentioning a download was refused (#1672)
git commit -F - <<'MSG' … curl -sL -o shot.png … MSGwas blocked as a file
download. Nothing runs inside a heredoc body — the text was a commit message.- The redirect guard (#931) and the
teeguard (#989) already run on
heredoc-stripped text; the download guard was simply never switched over and
still read the raw command. It now uses the same stripped form. - Found while writing the commit message for #1661, which quoted the guard's own
advice back at it. Any heredoc payload namingcurl -o,wgetordd of=
was affected: commit messages, PR bodies, docs, fixtures.
Fixed — a piped recursive grep ran for two minutes with no warning (#1662)
grep -rn … --include=*.go . | headproduced its ten lines almost
immediately, then kept walkingnode_modulesand.gitfor another two
minutes and auto-detached past the foreground cap. No redirect, no hint.- The hint added in #1655 was only ever emitted on timeout — the one thing a
command that already looked finished never reaches. It now runs for any
recursive walk, which is where it was needed:headcloses early, the output
arrives at once, and nothing signals the walk is still going. - It also follows a leading
cd:cd repo && grep -r … .walks a tree the
call's owncwdnever named, and scanning the wrong directory found nothing,
which is indistinguishable from "this walk is fine". Acdthat cannot be
resolved statically stays silent rather than naming directories from
somewhere else. - Because it now runs on the fast path, it scans what the command was actually
pointed at:grep -r pattern src/is no longer blamed on anode_modules/
it never enters.
Fixed — grep output was sampled head+tail, dropping the line that mattered (#1663)
- A recursive grep returned 236 matches; the compressor showed 14 and omitted
222. The single line proving a struct field was read sat at position 205,
inside the dropped middle. The reporter concluded the field was dead code and
only found out otherwise when the build failed. - Head+tail sampling assumes the interesting content is at the edges. For a
search result every line is a discrete answer and the decisive one is at an
arbitrary position — usually the unusual one. Sampling is biased against
exactly the line that matters, and[222 lines omitted]reads as "more of the
same" rather than "the answer may be in here". - Output with the
path:line:shape is now never sampled. It is delivered as a
contiguous, ordered prefix with an unmissable notice naming the true match
count, so absence in the output can never be mistaken for absence in the
results. A12:34:56log timestamp is not a path and keeps the old sampler,
pinned by a test.
Fixed — a rerouted heredoc ignored the call's cwd (#1666)
ctx_shell(cwd=<worktree>, command="python3 - <<EOF … EOF\necho $(pwd)")ran
its two halves in two different directories: the shell remainder honoured
cwd, the interpreter rerouted toctx_execute(#1403) inherited the server
process's directory. Both reported success.- A script doing
open("api/api.go")then resolved against the project root
instead of the worktree, found no match, rewrote that file byte-identically,
and returned no error. With a replacement that does match, this is a silent
write to the wrong file. - The working directory is now resolved once, before the interpreter runs, and
both halves use it. A directory that cannot be entered is reported as itself:
falling back to the inherited one is the bug this exists to fix. Under
sandbox_level >= 1the directory is also granted read access in the seatbelt
and Landlock profiles, which otherwise deny by default; write permission is
deliberately not granted, since that boundary belongs to the sandbox level.
Fixed — a single-line payload over budget delivered nothing (#1665)
ctx_readon a one-line file above the turn budget returned
[… truncated at ~0 of 6800 tokens]and no content at all. The budget
truncator keeps whole lines, which is the right shape for source and logs; a
single-line payload — minified JSON, a--jqresult, a one-line CSV — made
the loop discard the only line it had.- A character-bounded prefix now takes over when not even the first line fits.
A cut line is worse than a clean line boundary and far better than silently
delivering zero. Multi-line text still breaks on line boundaries, pinned by
its own test. - The recovery hint named
lines=, which cannot narrow a payload that has one
line. For that case it now namesmode="raw", which returns the full content. - Reported as a regression of the #1453 class. Note the reproduction is through
the MCP tool:apply_turn_budgetis reached only from the tool pipeline,
so probing it through thelean-ctx readCLI shows a similar-looking notice
from a different code path and will mislead.
Fixed — VAR=$((arith)) was read as a command (#1664)
x=$((1+2))was blocked with'1+2' is not in the shell allowlist, and the
suggested fix,lean-ctx allow 1+2, could never work.$(( … ))is
arithmetic expansion over the shell's own variables — it executes nothing —
but the(expr)inside looked like a subshell to the assignment scanner
(#855), which yielded1+2as a leaf command.- The scanner now skips the whole expansion. Nothing is given up: there is no
command in there to gate. A real substitution in an assignment is still
checked, and arithmetic inside one does not shield it — both pinned by
tests. - What this unblocks is counters, and with them the ordinary way to write a
bounded wait or poll loop:
i=0; while [ $i -lt 40 ]; do sleep 15; i=$((i+1)); done. - The tokenizer was never at fault — it resolves
x=$((1+2))to an empty base
correctly. Measuring that first is what located the real path.
Fixed — the timeout notice invented a pipeline stage (#1654)
- Descendants are labelled, not presented as stages.
[still running at timeout: …]collects every live process in the timed-out child's process
group, then joined them with" | ". A segment's own children join that
group —go run ./xexecs the binary it just compiled under$TMPDIR— so
the notice rendered… | head -30 | /var/folders/…/exe/evcc …, a line that is
not runnable and names a stage the caller never wrote. The whole point of the
notice (#1086) is to say which part to fix. psnow also readsppid, so a process whose parent is in the same set is
shown asparent (spawned: child)rather than as a peer. Genuine pipeline
siblings share a parent that is not in the set — the shell wrapper is
dropped for carrying the whole command — so they stay top-level.- The separator is
;, which reads as a list. The set was never a pipeline;
the pipe character asserted a relationship the process table had not
established.
Added — a timed-out recursive walk says what probably ate it (#1655)
-
The
.gitignoreasymmetry is now visible.ctx_globandctx_tree
honour.gitignore;ctx_shellruns the user's owngrep/find, which does
not. A repo with nested checkouts under.claude/worktrees/therefore
searches a multiple of the intended tree, and nothing said so — the reporter
hit this twice and diagnosed it both times from memory of his own earlier
issue (#1089) rather than from the tool. -
On a timed-out recursive walk, the notice now names the bulk directories that
actually exist under the search root, with real file counts, and points at
ctx_search:[hint: rust/target/ (5000+ files), cookbook/node_modules/ (5000+ files) under this path — walked by grep/find, but ignored by ctx_search and ctx_glob, which honour .gitignore. Scope the path, add --exclude-dir, or use ctx_search.] -
Nothing about the command's traversal changes. Silently rewriting a user's
grepwould be worse than the timeout; the report says so and is right. This
is an explanation after the fact. -
Three honesty constraints, because a hint that guesses is worse than none:
only directories that exist are named; counts come from a bounded walk and a
capped count renders asN+rather than being passed off as a total; and no
percentage of the walk is claimed, because we know what is on disk, not what
the command traversed. -
rgis deliberately excluded — it already honours.gitignore, so an ignored
directory does not explain a slowrg, and saying otherwise would send the
reader after the wrong cause. A command that already excludes the directory
gets no hint either.
Fixed — ctx_grep turned context lines into files and inflated the count (#1648)
- A context line is no longer parsed as a file header. GNU grep separates a
match withpath:line:contentand context withpath-line-content; the
compressor split at the first colon anywhere on the line. So
…/mod_1.py-2- try:split at the colon inside the Python source, becoming
a file named after the whole fragment with empty content — counted as both a
file and a match. The reporter's 40 matches in 40 files rendered as
160 matches in 160F, with every context line gone. - The delimiter is now the first colon followed by a line number and another
colon, and context is resolved against paths a match line already proved
exist. Anchoring on known paths is what makes it unambiguous: in
src/v-1-x/mod.py-10-ctxthere are three candidate-delimiters and one
correct answer, and grep only emits context for a file it also matched in. - Context now renders under its real file, in grep's own convention (
3:for a
match,2-for context), and the per-file cap counts matches — capping
raw lines would have silently dropped matches as soon as context was asked
for. Line content is trimmed, as it already was for matches. - Unparseable lines, including grep's
--group separators, are dropped instead
of becoming synthetic files. - The failure was silent and read as absence: a caller could not tell the
context had been dropped rather than being absent from the file. It only
triggered when a context line contained a colon — common in Python, YAML, Go
struct tags and prose — which is why the single-file, uncompressed path looked
correct.
Fixed — ctx_shell split inline scripts at a quoted ; (#1646)
- Quoting now restarts inside
$( … ), as POSIX specifies. The command
scanner tracked quotes as two flat booleans and never noticed$(while
inside double quotes, so in
echo "$(python3 -c "import sys;print(1)")"the inner opening quote closed
the outer one.import sys;print(1)then looked unquoted, the;split a
second "command" out of a line of Python, and the allowlist rejected
print(1)). Any inlinepython3 -c/perl -e/node -ewith more than one
statement was unusable, andshell_allow_inline_scriptscould not help
because the damage happened during splitting, before that policy is consulted.
The scanner now keeps a stack of lexical contexts;$( … )and backticks push
a fresh one. - No enforcement was traded away for it. Commands a substitution genuinely
runs are found by the substitution scanner, which re-splits the inner text —
that path is unchanged, and a test now pins it in both its modes (warn by
default, block undershell_strict_mode) so a future change to the splitter
cannot quietly move it. - An implausible base is reported as a mis-split, not an allowlist gap. The
reporter was told to run
lean-ctx allow print(urllib.parse.quote(sys.argv[1],safe=)) $RU)&code_challenge=…
— a suggestion that cannot work and hides the real fault. A token that could
not be an executable name now says so and asks for a bug report instead. ctx_shell's own description no longer contradicts its block message. It
said a[BLOCKED]command should be escalated toctx_execute(language="shell");
the block message said not to, because both enforce the same allowlist. The
block message was right. The description now points at what actually resolves
a block, and marksctx_executeas subject to the active tool profile — the
reporter's session did not advertise it at all.
This release includes the defect fixes merged after the v3.10.0 tag
(5b69202), the addon channel, and the additive SDK Agent Tools interface
below. None of it is present in the 3.10.0 artifacts.
Added — Addons: WASM extensions you can publish yourself
lean-ctx addon—list,info,add,remove,release. An addon is
a sandboxed WebAssembly module that runs inside the context pipeline and
registers as a compressor. There is deliberately nosearch: that would be a
marketplace, and lean-ctx does not curate, rank or host one.- The module travels inside the signed package.
lean-ctx addon release ./my-addonreads the.wasmfiles next tolean-ctx-addon.toml, hashes and
embeds them, and signs the result. No artifact host, no checksum files, no CI
— the reported friction of the previous addon channel was that authors had to
run their own pipeline just to produce SHA files for externally hosted
binaries. It also removes a download from install, and with it the window
between "verified the manifest" and "fetched the binary". - Install asks, and verifies first.
addon addre-verifies the signature
locally (registry compromise ≠ client compromise), checks each module against
its pinned SHA-256 and its WebAssembly magic bytes, shows the publisher key
and module digests, then asks. It refuses to proceed non-interactively unless
given--yes.pack importstill declines executable content and points at
the right door. - Sandbox, stated honestly. Enforced: no ambient environment, a fresh WASM
store per call, the output budget applied by the host after decoding, and
modules stored read-only and never marked executable. Not claimed: a module
can compute whatever it likes within those bounds — the sandbox limits reach,
not intent. - The
wasmfeature is on by default (measured cost: 83_065_536 →
83_815_584 bytes, +750 KB, +0.90%). Without it the shipped binary could not
load an extension at all, which would leave authors with a channel nobody
could install from. - Today the ABI reaches compressors and context providers. Chunkers, read modes
and render transforms exist as Rust traits and are not exposed over it —
documented as such rather than implied. LEAN_CTX_WASM_DIRremains an unsigned developer override for authoring a
module before packaging it, with none of the verification above.- An addon may instead declare an MCP server under
[mcp], whichaddon addtranslates into a[[gateway.servers]]entry. A compressor has to run
inside the pipeline, so it is WASM; a tool that already speaks MCP has a
process model of its own, so it is declared rather than embedded. What does
not come back from the pre-3.9.20 channel is[install]: lean-ctx never
runsuv tool installornpxfor you. Fetching the server stays your step,
where your own package manager's trust model applies. - That server runs as a normal process with your privileges — it is not
sandboxed, and the WASM guarantees do not extend to it. Soaddon addprints
the exact argv and says so before asking. Anhttpendpoint gets the
disclosure that is true for it instead: nothing runs locally, but lean-ctx
sends it requests and treats its replies as untrusted input — and the pin line
is omitted, since a SHA-256 of a local binary means nothing for a URL. Adding
a server does not enable the gateway:[gateway]stays global-only and
opt-in, andaddon listreports an addon that is wired while the gateway is
off rather than letting you assume it is running.addon removeunwires as
well as uninstalls. lean-ctx doctornow reports what actually loaded.addon listalready
claimed modules were "visible inlean-ctx doctor" — they were not; no such
check existed. It does now, and it answers a question the store cannot:
addon addverifies a module's four magic bytes, which is a prefix and not a
parse, so a truncated or corrupt module installs cleanly, matches its pinned
digest, and is then refused by the loader.addon listwould still show it as
a compressor. Doctor compares the store against the extension registry and
names the modules that did not make it, alongside how many MCP servers are
wired and whether the gateway is on.addon listwas making that claim before it was true; the fix was to build
the check rather than delete the sentence.- Only one version of an addon loads. The store keeps versions side by side
like every other pack kind, and module discovery walked the whole tree — so
after an upgrade the registry received two modules with the same file stem and
load order decided the winner. Load order was sorted paths, where
"10.0.0" < "9.0.0", so upgrading 9 to 10 would have quietly kept running
version 9. Discovery now picks one version per addon by install time (the
manifest contract callsversionauthor-declared and free-form, so it is not
reliably orderable; the install that wrote the directory is a fact).
addon listmarks the rest(superseded)and reports their modules as on
disk but not loaded, instead of listing code that never runs. - An upgrade keeps what you configured. Re-installing replaces the fields
the author owns (transport, command, args, url, pin, integration) and
preserves the ones you do:secret_env/secret_headers, which a manifest
cannot carry by design — so a wholesale replace would have silently dropped
your token and left the server failing to authenticate with nothing saying
why — and the per-serverenabledswitch, so an addon you deliberately turned
off is not turned back on behind an upgrade. integrationreaches the L4 typed adapters from a manifest. Setting it
routes the server's output into the matching lean-ctx surface —
codebase-pack→ctx_expand,code-graph/code-symbols→ctx_callgraph,
memory→ctx_knowledge,compression→ the compressor pipeline — instead
of arriving as opaque text. It is read from[mcp]first, then[addon]
(its documented home, and where every manifest in the wild puts it — see
#1391), then derived from a recognised[addon] categoriesentry. An
unrecognised slug is refused at parse:IntegrationKind::parsemaps
anything it does not know toNone, so a typo would otherwise have installed
cleanly and quietly done less, which is indistinguishable from working
software. Category derivation stays lenient, because categories are free-form
browsing labels rather than a vocabulary.addon addaccepts a registry reference, not only a local file.pack installrefuses executable content and points ataddon add, which until now
took a path only — so a published addon could be resolved, downloaded, and
then installed by no command at all. The remote artifact is staged to a temp
file and goes through the same preview, prompt and verification as a local
one: one consent path, not a shorter one for downloads. A path that exists on
disk always wins over a registry lookup, soacme/widgetcannot quietly reach
the network when a file by that name is present.
Fixed — binary_sha256 was a pin that never fired
[[gateway.servers]] binary_sha256 (and the sha256 field of an addon's
[mcp] table, which becomes it) was parsed, stored, shown, included in the
connection-pool identity — and then discarded at the spawn point with
let _ = binary_sha256. addon-manifest-v1 promised the gateway "hashes the
resolved binary before spawn and refuses a mismatch (fail-closed)". It did not.
A pin that is displayed but never checked is worse than no pin, because it reads
as a guarantee.
It is now enforced. Two details matter as much as the hashing: the binary is
resolved against the PATH the child will see, since a server's own env
may override it — otherwise we would hash one file and spawn another; and once a
pin is set, the resolved path is what gets spawned, so name resolution cannot
land elsewhere between check and spawn. An empty pin stays a documented no-op; a
pin that cannot be checked (missing or unreadable binary) is an error, not a
skip.
addon-manifest-v1 also gained a "What 3.10.1 implements" section drawing the
line between what the current parser reads and what the document records from
the removed system ([install], [capabilities], [[dependencies]],
{pack_dir:} expansion, min_lean_ctx enforcement, the verified tier).
See the addon guide, moved from Research to Preview for
this release. wasm-abi-v1 is a frozen
artifact and is deliberately not edited: its status now lives in the
CONTRACTS.md stability matrix, per that document's own contract file rule. The
ABI is unchanged — a breaking change would ship as wasm-abi-v2 with an
overlap window.
Added — SDK Agent Tools Interface
- Publication is deliberately sequenced: Engine 3.10.1 ships and is verified
first; theAgentContextinterface becomes available with the separate SDK
1.1.0 release. The currently published SDK 1.0.0 does not include these tools. lean-ctx engine tool-sessionprovides a persistent, version-negotiated
NDJSON session for the Python SDK'sAgentContext.- Read, search, tree, compose, symbol, glob, patch, and shell capabilities use
the existing Engine registry, cache, path jail, and compression pipeline. - Each result includes deterministic original, output, and saved-token counts.
- Non-text MCP content blocks, including image reads, survive the SDK transport.
- Release builds produce deterministic, integrity-checking Python companion
wheels for macOS, Linux (glibc and musl), Windows, CUDA, and Windows GNU. - Every native release target installs its wheel, passes strict package-metadata
validation, and runs the SDK's real AgentContext lifecycle before publication.
Added — Dashboard
- The local dashboard now presents a one-time, dismissible invitation for
companies interested in a LeanCTX design partnership or organization-wide
rollout, plus a direct path to the public Python SDK. It stays out of the way
of onboarding, authentication, and product tours; supports keyboard dismissal
and focus trapping; and remembers the user's choice locally.
Changed — One Supported SDK
thinkery-leanctx-sdk, developed in the public
Thinkery-AG/leanctx-sdk
repository, is now the single supported Agent SDK. The duplicate in-repo
Python and Node SDK sources and their publication workflow were retired.- The unpublished Rust
lean-ctx-sdkcrate was renamedlean-ctx-embedto
describe its actual role as an in-process embedding facade. It is not a
second Agent SDK. - Previously published
lean-ctx-pythonand npmlean-ctx-sdkartifacts may
remain installable for compatibility, but new integrations should use
thinkery-leanctx-sdk. The release gate now rejects future package-name or
documentation drift back to multiple SDK surfaces.
Security
- Sessions are project-rooted and read-only by default. Write and execution
require an owner-only immutable policy file and are enforced by the Engine. - Execution accepts structured argv only. The Engine revalidates policy, env,
timeout, and workdir jail; resolves a bare allowlisted name to an absolute
executable outside the project; and launches argv without shell interpolation. - Shell output is bounded, timeouts terminate Unix process groups and Windows
process trees, and repository-local executable shadowing is rejected. - The protocol rejects oversized frames, malformed requests, unknown tools,
incompatible versions, unsafe roots, and permission escalation attempts. - SDK shell responses always include an explicit foreground exit code, so
custom agents never need to infer success from terminal text.
Compatibility
- Agent Tools Interface
1.0.0, schema1, transport1is additive. The
existing Engine Interface v1 context-view and recovery commands are
unchanged.
Fixed
- One-shot CLI tool calls could abort before dispatch —
lean-ctx call
created an incomplete tool context, so read, shell, session, knowledge,
handoff, ledger, fill, metrics, workflow, and multi-read calls could fail
with a missing-handle error even though the same tools worked over MCP. The
CLI now loads the bounded project session and persisted context ledger and
supplies empty per-process call history, enabling custom agents to use the
registered tools safely without running a long-lived MCP connection. - Claude Code lost its plan-usage windows and near-limit warnings behind the
proxy (#1638, thanks @online) — Claude Code derives both from the
anthropic-ratelimit-unified-*response headers, and the proxy's response
allowlist enumerated only the four legacyrequests-/tokens-names. When
theunified-*family arrived upstream the allowlist stayed correct about
what it listed and silently dropped the rest, sorate_limitsvanished from
the statusline payload and — the costlier half — the warning that a usage
limit is approaching could never fire. The wholeanthropic-ratelimit-*
family is now relayed by prefix rather than by enumeration, which is what
aged badly in the first place;request-idandx-should-retryare relayed
too. The allowlist stays an allowlist: the proxy rewrites the body
(decompression, SSE re-framing), so upstream framing headers are still not
passed through. ctx_searchwas unusable in read-only client modes (#1624, thanks @jh061084) —
MCP annotates tools, butctx_searchmultiplexes five actions and only four
of them read. The fifth,reindex, wrote persistent BM25 indexes, and that
single action withheldreadOnlyHintfrom every regex, semantic and symbol
lookup — so Devin Plan mode and Cursor's restricted contexts refused the tool
before dispatch. Search was collateral damage for a rebuild thatctx_index
already owns and correctly advertises as mutating.reindexis gone from
ctx_search; a call that still asks for it is answered with the exact
replacement (ctx_index(action="build-full")) rather than a bare rejection.ctx_searchreported partial results as if they were complete (#1625,
thanks @jh061084) — the file walk stops oncemax_resultsis reached, and
the scanned-file counter stops with it, so an audit that quit after the first
of 32 files answered "10 matches in 1 files (scanned 1)". Nothing said the
scan had been cut short. Hitting the cap is now announced the way the
wall-clock deadline has been since #336, and the counts are labelled a floor
rather than a total. Separately, amax_resultswritten inside aqueries[]
entry was read by nothing: the top-level budget is shared and split across
the queries, and the nested value was silently dropped. A per-query value now
wins over its share, and an unrecognised key is rejected by name.- Replace mode rejected other MCP servers' tools (#1631) — the deny guard
was "allowctx_*, deny everything else", which is safe only on a host that
filters by matcher first. Devin routes every MCP call through the same
PreToolUse pipeline, so a user running lean-ctx alongside the Atlassian
server had every Jira call answered with "use the equivalent ctx_* tool" —
naming an equivalent that does not exist. Replace mode now decides by
provenance (tool_info.mcp_tool_name,mcp__<server>__<tool>) before
falling back to the native-name list, so a foreign server exposing a tool
calledsearchorreadis not caught either.is_lean_ctx_toolalso
learned themcp__lean-ctx__…spelling the rest of the codebase already used. - Agent-bus registration errors named no path (#1619, thanks @GrimmiMeloni) —
a sandbox or filesystem policy denies exactly one directory, and the failure
arrived asagent bus registration is required before tool execution: File exists (os error 17): no path, no operation, nothing to allow-list. The
reporter had to guess~/.local/share/lean-ctx/agents. Directory creation
for the agent registry and the agent diary now names both the operation and
the resolved path (create agent registry directory /…/agents: …), as the
neighbouring registry-read, lock and persist errors already did. - A bare
lean-ctxrefused to serve MCP under a pty (#1595) — the startup
path treated a terminal on stdin as proof that a human was there, printed the
quickstart and exited. MCP clients that spawn the server under a pty (Devin,
containerized agent runners, anything driving the child through
script/expect) therefore never got a connection, and the child exited0
after writing help text to stdout, so nothing in the client's log explained
it. A TTY means a human may be watching; it never means a client is absent.
The quickstart now goes to stderr — which clients log rather than parse — and
the server starts either way.args = ["mcp"]remains the explicit spelling
and is what the writers emit; it is no longer the only spelling that works. - Codex config wrote
args = [](#1597, thanks @reysilvaa) — new Codex
entries are written asargs = ["mcp"], matching the Copilot writer, and an
existing list carryingmcpis preserved verbatim.lean-ctx doctor
deliberately still accepts a legacyargs = []and an absentargs: both
start the server, so reporting them would turn working installations red and
have--fixrewrite a file into an equivalent one. - Verbatim reads were capped at 4096 tokens (#1582) — the #1306 turn budget
applied to explicitraw=true/mode="raw"requests too, so the documented
way back to the original bytes was unreachable for any file above ~16 KB.
Verbatim requests now useturn_fresh_limit_verbatim(default 32768, env
LEAN_CTX_TURN_FRESH_LIMIT_VERBATIM,0= unlimited); everyday modes keep
the 4096-token backstop and itslines=recovery banner. - An OpenCode config could be replaced by a lean-ctx-only scaffold (#1586) —
both writers started from an empty object wheneverparse_jsoncfailed,
discarding providers, models, plugins and foreign MCP entries while still
reporting success. They now fail closed and print the reason.parse_jsonc
also tolerates a leading UTF-8 BOM, one of the ways a healthy config was read
as corrupt. opencode.jsonvsopencode.jsonc(#1585) — each call site hardcoded one
name, so install, doctor, setup and uninstall could target different files.
core::opencode_configis now the single resolver; uninstall visits both
names. The resolver stays under the caller'shomeon every platform.- Deliberately absent rules files reported as drift (#1596) — a missing
global rules file counted as unhealthy even whenrules_injection = "off"or
rules_scope = "project"made its absence intentional. Cline, Cline CLI, Roo,
Windsurf and the rest now reason like Claude Code's dedicated check. - Windows
setup --fixloop (#1598) — generated bash wrappers hold an MSYS
path while the expected binary is native, so byte comparison called a correct
wrapper stale and rewrote it to a byte-identical result, forever. rules_injection = "off"was only half honoured (#1599) — the initialize
instructionsfield still shipped the same steering block over a different
channel. Off is now off on every channel; the #1447 Antigravity/Gemini CLI
carve-out is unchanged.config set llm.api_keyreported a save it discarded (#1605) — the
[llm]schema block was hand-written and had drifted: anapi_keykey no
struct field backs (silently dropped after the CLI printed "Updated"), a wrong
modeldefault, and a missingbase_url. Defaults now derive from
cfg.llm.*,api_keyis gone, and a test guards the class.- Flattened
ctx_calllost its arguments (#1604) — the envelope parser
accepted inner arguments only underarguments, so
ctx_call(name="ctx_edit", path=…)reached the inner tool with none at all
and got back "path is required" about a parameter the caller had supplied.
Everything exceptnameis now forwarded; the #658 typo hint is unchanged. lean-ctx watchwas still advertised after its removal (#1602) —--help,
the completion spec andwatch --helpall described a command that had been
removed in 3.9.20. All surfaces now name the replacements (lean-ctx dashboard,lean-ctx cep,lean-ctx index watch).- Claude Code stayed “not detected” after setup on Windows (#1621) —
where claudecan return several CRLF-separated executable paths; setup
previously treated the entire output as one nonexistent path and skipped the
compact~/.claude/CLAUDE.mdblock. Detection now selects the first usable
path.lean-ctx rules syncandrules sync claudealso repair this compact,
idempotent pointer block directly without restoring the retired full rules
file or duplicating its token cost. - Multi-query search could look complete after silently hitting its result
budget (#1625) —ctx_searchnow acceptsmax_resultsinside each query,
states the effective shared and per-query limits, and reports when results
were capped. Audits no longer have to infer completeness from a partial file
count or discover that a nested limit was ignored. - Read-only clients could not call
ctx_search(#1624) — Plan and other
restricted modes rejected the whole tool because its MCP declaration lacked
readOnlyHint. Reindexing now uses the existing mutatingctx_index
operation, whilectx_searchis explicitly read-only and available without
write access. - Dependency —
chacha200.10.1 → 0.10.2 (0.10.1 was yanked upstream).
Internal
- Deterministic
ctx_explorestability test (#1635) — the byte-stability
guard failed under parallel load becausegraph_provider::open_or_build
returnsNonewhen the build gate is held by another test in the same binary
or the build times out, so one run cited a symbol the next did not. The index
is now built synchronously before the comparison, and the test additionally
requires the symbol citation to be present — without that, a degraded run
would have agreed with itself and passed while proving nothing. - History-policy gate no longer fails on a tempdir race (#1634) — the
fixture's teardown raced git's own file handles and reported
Directory not empty: '.git'as a policy-gate failure.gc.auto=0removes
the usual cause and teardown tolerates the residue. Because the test file is
a pinned scanner source, the attestation chain (scanner digest → policy
fingerprint → evidencepolicy_sha256→ report digest) was re-signed
deliberately in the same change; the audited history, its 558 findings and
audited_commitare untouched. - CodeQL alert list made meaningful again (#1636) — the Rust analysis
carried 142 open alerts, every one traced to a false positive:session_id
is a timestamp-plus-PID filename that authenticates nothing, the flagged
paths compose a startup argument with constants, and the caller-controlled
surface is jailed bypathjail, which CodeQL cannot model. The risk was not
a vulnerability but a broken smoke detector — a real finding arriving in that
list would have been invisible. Tests and fixtures are excluded from
analysis, those three rules are filtered with the reasoning recorded beside
each exclusion, andsecurity-extendedstays on so new rules still report. - Deterministic auto-detach test (#1611) — the
auto_detached_result
harness helper raced its own premise (a 10 ms soft cap against asleep 0.1
child) and turnedmainred on macOS after a green PR run. The child now
blocks on a barrier file until the detach has been observed, so the path
under test is exercised by construction. Test-only; no shipped code involved. - Post-release records (#1612) — the
v3.10.0artifacts were verified
againstSHA256SUMSandrelease-manifest.json, and a clean-environment
install passedlean-ctx doctor; the changelog now states which fixes a
given tag does and does not carry.
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.0...v3.10.1