Skip to content

v3.10.1

Latest

Choose a tag to compare

@github-actions github-actions released this 06 Sep 02:25
· 3 commits to main since this release
4a76710

Fixed — release packaging

  • Windows Engine companion wheels now read .exe payloads 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 define HOME (#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 ls recover 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 claude now 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
    exported ANTHROPIC_BASE_URL also affects Claude launched from that shell.
  • Fatal wrap and unwrap setup 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 identify thinkery-leanctx-sdk as the
    supported Agent SDK.

Fixed — Codex browser login was sent to the API-key endpoint (#1685)

  • lean-ctx wrap codex now selects the /v1 proxy rail only when Codex has
    positive API-key evidence. Browser-login credentials in an older auth.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.write failure seen after a successful browser login. Explicit
    API-key users continue to use the compressed /v1 route 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-6000 are detected, while invalid checksum-protected identifiers
    remain untouched instead of being reclassified as phones.
  • Egress remains fail-closed when egress.block_secrets is 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 … --comments came 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::Auto is
    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=true does 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
    runs compact_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/feedback has 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 pins connect-src to '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_budget caps 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
    a tool_def: docs/reference/generated/mcp-tools.md embeds the description
    verbatim, so gen_docs --check fails 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_root failed
    intermittently on Linux with left: 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.html was refused with "tee without pipe",
    in a message whose next sentence stated that cmd | tee file is 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 tee target 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 -l is 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 replied state: "running"
    byte-indistinguishable from a status poll, while the very next status call
    on the same job returned cancelled with exit 130.
  • The header already said "cancel requested"; the structured state field,
    which is what the caller actually reads, did not. cancel returns 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 saying no 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; cancel only 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/null was allowed; echo hi 1>/dev/null; echo ok was 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 ok was 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_shell result 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_read needs a path, 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, and Write takes 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 string shot.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 a cd "$VAR" that cannot be resolved, is still a file write.
    wget and dd keep 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 … MSG was blocked as a file
    download. Nothing runs inside a heredoc body — the text was a commit message.
  • The redirect guard (#931) and the tee guard (#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 naming curl -o, wget or dd 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 . | head produced its ten lines almost
    immediately, then kept walking node_modules and .git for 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: head closes 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 own cwd never named, and scanning the wrong directory found nothing,
    which is indistinguishable from "this walk is fine". A cd that 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 a node_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. A 12:34:56 log 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 to ctx_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 >= 1 the 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_read on 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 --jq result, 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 names mode="raw", which returns the full content.
  • Reported as a regression of the #1453 class. Note the reproduction is through
    the MCP tool: apply_turn_budget is reached only from the tool pipeline,
    so probing it through the lean-ctx read CLI 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 yielded 1+2 as 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 ./x execs 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.
  • ps now also reads ppid, so a process whose parent is in the same set is
    shown as parent (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 .gitignore asymmetry is now visible. ctx_glob and ctx_tree
    honour .gitignore; ctx_shell runs the user's own grep/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
    grep would 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 as N+ 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.

  • rg is deliberately excluded — it already honours .gitignore, so an ignored
    directory does not explain a slow rg, 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 with path:line:content and context with path-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-ctx there 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 inline python3 -c / perl -e / node -e with more than one
    statement was unusable, and shell_allow_inline_scripts could 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 under shell_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 to ctx_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 marks ctx_execute as 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 addonlist, 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 no search: 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-addon reads the .wasm files next to lean-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 add re-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 import still 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 wasm feature 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_DIR remains 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], which addon add translates 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
    runs uv tool install or npx for 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. So addon add prints
    the exact argv and says so before asking. An http endpoint 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, and addon list reports an addon that is wired while the gateway is
    off rather than letting you assume it is running. addon remove unwires as
    well as uninstalls.
  • lean-ctx doctor now reports what actually loaded. addon list already
    claimed modules were "visible in lean-ctx doctor" — they were not; no such
    check existed. It does now, and it answers a question the store cannot:
    addon add verifies 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 list would 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 list was 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 calls version author-declared and free-form, so it is not
    reliably orderable; the install that wrote the directory is a fact).
    addon list marks 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-server enabled switch, so an addon you deliberately turned
    off is not turned back on behind an upgrade.
  • integration reaches the L4 typed adapters from a manifest. Setting it
    routes the server's output into the matching lean-ctx surface —
    codebase-packctx_expand, code-graph/code-symbolsctx_callgraph,
    memoryctx_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] categories entry. An
    unrecognised slug is refused at parse: IntegrationKind::parse maps
    anything it does not know to None, 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 add accepts a registry reference, not only a local file. pack install refuses executable content and points at addon 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, so acme/widget cannot 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; the AgentContext interface 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-session provides a persistent, version-negotiated
    NDJSON session for the Python SDK's AgentContext.
  • 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-sdk crate was renamed lean-ctx-embed to
    describe its actual role as an in-process embedding facade. It is not a
    second Agent SDK.
  • Previously published lean-ctx-python and npm lean-ctx-sdk artifacts 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, schema 1, transport 1 is additive. The
    existing Engine Interface v1 context-view and recovery commands are
    unchanged.

Fixed

  • One-shot CLI tool calls could abort before dispatchlean-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 legacy requests-/tokens- names. When
    the unified-* family arrived upstream the allowlist stayed correct about
    what it listed and silently dropped the rest, so rate_limits vanished from
    the statusline payload and — the costlier half — the warning that a usage
    limit is approaching could never fire. The whole anthropic-ratelimit-*
    family is now relayed by prefix rather than by enumeration, which is what
    aged badly in the first place; request-id and x-should-retry are 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_search was unusable in read-only client modes (#1624, thanks @jh061084)
    MCP annotates tools, but ctx_search multiplexes five actions and only four
    of them read. The fifth, reindex, wrote persistent BM25 indexes, and that
    single action withheld readOnlyHint from 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 that ctx_index
    already owns and correctly advertises as mutating. reindex is 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_search reported partial results as if they were complete (#1625,
    thanks @jh061084)
    — the file walk stops once max_results is 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, a max_results written inside a queries[]
    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 "allow ctx_*, 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
    called search or read is not caught either. is_lean_ctx_tool also
    learned the mcp__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 as agent 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-ctx refused 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 exited 0
    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 as args = ["mcp"], matching the Copilot writer, and an
    existing list carrying mcp is preserved verbatim. lean-ctx doctor
    deliberately still accepts a legacy args = [] and an absent args: both
    start the server, so reporting them would turn working installations red and
    have --fix rewrite a file into an equivalent one.
  • Verbatim reads were capped at 4096 tokens (#1582) — the #1306 turn budget
    applied to explicit raw=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 use turn_fresh_limit_verbatim (default 32768, env
    LEAN_CTX_TURN_FRESH_LIMIT_VERBATIM, 0 = unlimited); everyday modes keep
    the 4096-token backstop and its lines= recovery banner.
  • An OpenCode config could be replaced by a lean-ctx-only scaffold (#1586)
    both writers started from an empty object whenever parse_jsonc failed,
    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.json vs opencode.jsonc (#1585) — each call site hardcoded one
    name, so install, doctor, setup and uninstall could target different files.
    core::opencode_config is now the single resolver; uninstall visits both
    names. The resolver stays under the caller's home on every platform.
  • Deliberately absent rules files reported as drift (#1596) — a missing
    global rules file counted as unhealthy even when rules_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 --fix loop (#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
    instructions field 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_key reported a save it discarded (#1605) — the
    [llm] schema block was hand-written and had drifted: an api_key key no
    struct field backs (silently dropped after the CLI printed "Updated"), a wrong
    model default, and a missing base_url. Defaults now derive from
    cfg.llm.*, api_key is gone, and a test guards the class.
  • Flattened ctx_call lost its arguments (#1604) — the envelope parser
    accepted inner arguments only under arguments, 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 except name is now forwarded; the #658 typo hint is unchanged.
  • lean-ctx watch was still advertised after its removal (#1602)--help,
    the completion spec and watch --help all 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 claude can return several CRLF-separated executable paths; setup
    previously treated the entire output as one nonexistent path and skipped the
    compact ~/.claude/CLAUDE.md block. Detection now selects the first usable
    path. lean-ctx rules sync and rules sync claude also 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_search now accepts max_results inside 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 mutating ctx_index
    operation, while ctx_search is explicitly read-only and available without
    write access.
  • Dependencychacha20 0.10.1 → 0.10.2 (0.10.1 was yanked upstream).

Internal

  • Deterministic ctx_explore stability test (#1635) — the byte-stability
    guard failed under parallel load because graph_provider::open_or_build
    returns None when 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=0 removes
    the usual cause and teardown tolerates the residue. Because the test file is
    a pinned scanner source, the attestation chain (scanner digest → policy
    fingerprint → evidence policy_sha256 → report digest) was re-signed
    deliberately in the same change; the audited history, its 558 findings and
    audited_commit are 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 by pathjail, 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, and security-extended stays 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 a sleep 0.1
    child) and turned main red 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.0 artifacts were verified
    against SHA256SUMS and release-manifest.json, and a clean-environment
    install passed lean-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-ctx

Note: After upgrading via cargo/npm/brew, run lean-ctx setup to refresh shell aliases. lean-ctx update does this automatically.

Full Changelog: v3.10.0...v3.10.1