Releases: SAIL-Research-Lab/cheetahclaws
Release list
v3.5.87
- August 16, 2026 (v3.5.87): Permission prompts are now reserved for what actually needs a decision. (This release also carries the OpenRouter provider entry + gateway-routing fixes in the entry below.) A prompt every user answers "yes" to is worse than no prompt — it trains people to stop reading, and pushes them to
accept-all, which removes every gate. Theautomode gate was asking for far more than it protected, so it was re-derived from one rule: ask only when an action can change your files, run arbitrary code, or reach outside the session. (1) Read-only tools now come from the registry._check_permissionmatched a hardcoded five-name list (Read/Glob/Grep/WebFetch/WebSearch), so the other 18 tools already markedread_only=True—GetDiagnostics,TaskList/TaskGet,MemoryList/MemorySearch,SkillList,ListAgentTypes,ReadPDF/ReadImage/ReadSpreadsheet,SummarizeLargeFile,WebBrowse, … — prompted on every call. It now readsToolDef.read_only, so every read-only tool is covered by the same rule and a new one is covered the day it is registered. A small curated set of session-state tools (TaskCreate,TaskUpdate,MemorySave,Skill,SleepTimer) joins them: they touch no repo file, no shell, and no network. Unclassified tools (MCP, third-party plugins) still prompt. (2) The Bash check is a parser, not a prefix match. The old_is_safe_bashtestedcmd.startswith(...)against a 30-entry prefix list and rejected any command containing|, so the single most common inspection idiom in a terminal —git log | head -20,ls -la | grep test,ps aux | grep python— needed approval. The command is now parsed withshlex, split on|/&&/||/;, and auto-approved only when every segment is a known read-only invocation, classified by program name and guarded per command. Redirection (>), backgrounding (&), subshells and command substitution are refused outright. The vocabulary grew from ~30 prefixes to a proper set —stat,file,tree,realpath,jq,diff,sha256sum,sort,cut,sed -n,git blame/rev-parse/ls-files/config --get,docker ps,kubectl get,systemctl status,tar -t,unzip -l, plus any--version/--helpinvocation of any program. This also closed a hole: the old list auto-approved anything starting withpython,node,ruby,perlorfind, i.e. arbitrary code execution andfind … -delete. Interpreters and program-runners (xargs,env VAR=…,make,pytest,npm run) now prompt — their--versionforms still don't. (3) Creating a new file no longer prompts. AWriteto a path that does not exist yet, inside the working directory (orallowed_root), destroys nothing and runs. Overwriting existing content, writing outside the workspace, and any dot-prefixed path (.git/hooks/*,.github/workflows/*,.env— locations other tools execute or trust) still ask. Turn the rule off with/config auto_create_files=false. (4) A scoped alternative to accept-all. The prompt gaineds: approve and stop asking for this one thing for the session. The grant is a signature —Bash:git commitcovers anygit commit …but no other subcommand,Edit:/repo/app.pycovers repeat edits to that one file but no other file — held in memory on theRuntimeContext, never persisted, ignored inmanualmode, listed by/permissionsand dropped by/permissions clear. A task that edits one file forty times now asks once instead of forty times, without handing over the whole tool surface. (5) Tuning./config bash_safe_extra=["your-query-tool"]adds project-specific read-only programs to the shell vocabulary. Tests:tests/test_permission_auto_approve.py, 110 cases — the read-only vocabulary and pipelines, ~35 mutating commands that must still prompt (chained deletes, redirection, substitution, interpreters,find -delete,sed -i,git push,npm install), registry-driven tool classification, all five mode semantics unchanged, new-file rules, and session-grant scoping/isolation. Docs: security.md (the policy and its boundaries), reference.md (what runs silently, what prompts, how to tune). - August 16, 2026: OpenRouter is a first-class provider — one key for 400+ models, with the upstream provider pinnable per call (PR #179), plus four gateway-routing fixes it surfaced. (1) The provider (PR #179, @albertcheng). A new
openrouterentry inproviders.pypoints athttps://openrouter.ai/api/v1, readsOPENROUTER_API_KEY(or/config openrouter_api_key=sk-or-...), and ships a curated model list so OpenRouter shows up in the/modelTab picker and the Web UI picker with no extra wiring — both enumeratePROVIDERS. Model IDs keep OpenRouter's upstream<vendor>/<model>path, so calls are double-prefixed —/model openrouter/deepseek/deepseek-v4-flash— exactly like the existingnim/<vendor>/<model>form:detect_provider()takes the first segment,bare_model()strips only that one, and the rest is passed through verbatim. (2) Pinning the secondary provider. OpenRouter serves each model from a rotating pool of upstreams;parse_openrouter_routing()lets you pin one by appending@<provider-slug>[/<quantization>]—openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8, or@fp8for quantization alone (fp4·fp8·int4·int8). The suffix never reaches the model field: it is split off and sent as OpenRouter'sproviderrequest-body object (order+allow_fallbacks: false, plusquantizations), because provider selection glued into a model ID makes OpenRouter reject the call as an unknown model. Note the trade-off — a pinned provider means the request fails rather than rerouting around an outage; drop the suffix if you want fallbacks. (3) Four routing fixes. Multi-level model IDs broke four things that only bit gateway routes, all now fixed and regression-tested. (a) Provider identity survived the prefix strip.stream_openai_compat()re-derived the provider from a model string that had already been stripped, so the OpenRouter routedeepseek/deepseek-v4-flashread as the DeepSeek API: it injected DeepSeek-only request fields (extra_body.thinkingwhen/thinkingis off,reasoning_effort) into OpenRouter calls and applied DeepSeek's output cap instead of OpenRouter's, whileopenrouter/openai/gpt-5sent OpenAI'smax_completion_tokensin place of themax_tokensOpenRouter documents.stream()now passes the resolved provider inconfig["_provider_name"];detect_provider()remains the fallback for direct callers. Side effect worth knowing:PROVIDERS["nim"]["max_completion_tokens"]and the live/v1/modelslookup for slashedcustom/<vendor>/<model>IDs were dead code for the same reason and now take effect. (b) Cost estimates were $0.00.COSTSis keyed by plain model name, so everyopenrouter/*route missed and priced at zero — meaning the dollar budget inquota.record_usagenever fired for a gateway that bills real money (token budgets were unaffected). A newlookup_model_key()drops both the vendor path and the@…suffix, soopenrouter/deepseek/deepseek-v4-flashis now priced identically to the direct route. Models with no price entry at all still record $0 — set a token budget if you need a hard cap on those. (c) Context windows were a flat 128 K. Same key mismatch: every OpenRouter model inherited the provider-level default regardless of its real window — compaction fired far too late on a 32 K model (upstream then rejects the prompt) and needlessly early on a 1 M one.get_model_context_window()now retries the per-model registry with the bare name and, failing that, falls back to the vendor's own window when the vendor names a provider we know natively.openrouter/qwen/qwen2.5-coder-32b-instruct→ 32 K,openrouter/meta-llama/llama-3.3-70b-instruct→ 131 K,openrouter/anthropic/claude-sonnet-4-6→ 200 K, matching each model's direct route. (d) The routing suffix ate the prompt overlay.prompts/select.pyroutes model-family overlays on the last path segment, so…/claude-sonnet-4-6@gmicloud/fp8tailed to"fp8"and silently lostclaude.md; the suffix is now stripped before matching. Tests:tests/test_openrouter_provider.pygrew from 11 to 23 cases — provider registration, multi-level routing,@suffixparsing, request-body forwarding, and one case per fix above including a reverse guard that the real DeepSeek provider still gets itsthinkingtoggle. 2652 pass, 5 skipped, zero regressions. Docs: usage.md (setup + pinning), reference.md (env var //config//model), recipes.md (preferopenrouter/overcustom/+litellm/openrouter/…), architecture.md (why gateway IDs need_provider_nameandlookup_model_key).
v3.5.86
-
July 30, 2026 (v3.5.86): Next-prompt ghost text — the REPL predicts the line you'd type next, Tab accepts it. Claude Code leaves a dim suggestion sitting in the empty input box after each reply; CheetahClaws now does the same. (1) Where the text comes from. At the end of every foreground turn,
run_querycalls the newui/suggest.py::schedule(), which flattens the last ~4 user/assistant messages (tool-use blocks dropped, each turn truncated to 1500 chars) and asks the auxiliary cheap/fast model — the same router compaction uses,auxiliary.py— for the single most probable next user message: one line, under 12 words, imperative and first-person, in whichever language the user has been writing, or the literalNONEwhen nothing is plausibly next. It runs on a background daemon thread, so the prompt is never delayed and the draft lands whenever it lands; every failure path is silent (no auxiliary model, no API key, provider down → simply no ghost). A generation counter drops a slow draft from turn N once turn N+1 has started, and each new turn clears the previous ghost immediately, so a stale prediction is never left on screen. The reply is cleaned before it can be displayed: first line only, surrounding quotes/backticks/bullets stripped, rejected outright if it exceeds 90 chars or if the model starts explaining itself (Sure, …,The user …) instead of impersonating the user. Background turns (Telegram/WeChat/Slack/QQ, proactive events) never draft one — they don't own the prompt. (2) How it renders, and how you accept it.ui/input.pygained a thread-safe pending-suggestion store (written by the drafting thread, read by the prompt_toolkit event loop) andPredictiveAutoSuggest: the whole prediction is offered on an empty buffer, the remainder keeps being offered while what you typed still prefixes it, and anything else falls back to the existing shell-history suggestion. It reuses the dim-italicauto-suggestionstyle already in the session, and the Tab binding that already accepted history ghosts now accepts these too (→ works natively). Two prompt_toolkit details drove the design: the auto-suggester is consulted only on text insert, so an empty prompt is never asked at all — the prediction is therefore applied synchronously atpre_runand on everyon_text_changed, which additionally makes the ghost exact when a fast Tab would otherwise beat the async pass, and brings it back when you erase your line to empty. The prediction is one-shot:read_line()consumes it on return, so it can never leak into a later prompt. Slash completion is untouched — an active completion menu still suppresses ghost acceptance, so/cmd+ Tab behaves exactly as before. (3) Control and cost. Newinput_suggestconfig key (defaulttrue):/config input_suggest=falsedisables it persistently,CHEETAH_SUGGEST=0for a single run. It costs one small auxiliary call per turn — pointauxiliary_modelat a cheap model (or disable the feature) if that matters on your setup. (4) Tests. Newtests/test_input_suggest.py— 29 cases covering the pending store, prediction-vs-history precedence, cursor/multi-line suppression, draft cleaning (including CJK), transcript flattening, disable switches, auxiliary failure, and superseded-draft staleness, plus end-to-end tests that drive a realprompt_toolkitsession over a pipe and assert the ghost actually renders, Enter alone never submits it, Tab accepts it whole, a typed prefix still completes, erasing brings it back, and/cmdTab is not hijacked. Full suite: 2610 passed, 5 skipped. Version bumped3.5.85→3.5.86inpyproject.toml; this is also the first tagged release to carry the July 11 (terminal tab title + Anthropic prompt-cache) and July 20 (tool_profile+ bounded-I/O) changes, which landed untagged after v3.5.85. Not a breaking change — with no auxiliary model reachable the REPL behaves exactly as it did before.Also in v3.5.86 — the terminal tab title now configures itself over Remote-SSH. The July 11 tab title works out of the box in iTerm2 / Terminal.app / GNOME Terminal / Windows Terminal, and VS Code-family terminals get a one-time auto-setup of
terminal.integrated.tabs.title. But that setup only ever considered a local editor install: in a Remote-SSH / WSL / devcontainer / Codespaces session the window — and the User settings it reads — live on the user's own machine, while CheetahClaws runs on the server, so it wrote~/.config/Code/User/settings.jsonon the server, fabricating a file the editor never reads (creating~/.config/Code/from scratch when no editor was installed there at all), printed a success message, and wrote a one-shot marker that stopped it from ever retrying. Result: a silently broken tab title with no way back short of finding the marker.ui/vscode_setup.pynow resolves the target properly. A new_remote_server_root()locates the editor server install — walking the absolute paths VS Code exports into it (VSCODE_AGENT_FOLDER,VSCODE_GIT_ASKPASS_NODE/_MAIN) for a-serverdirectory that actually containsdata/cli/bin/extensions, falling back to~/.vscode-server,~/.vscode-server-insiders,~/.cursor-server,~/.windsurf-server— and when one is found the key goes into the remote Machine settings (<server-root>/data/Machine/settings.json, the "Remote [SSH: host]" scope), which the window does read; verified end-to-end on a live Remote-SSH session. The local path is kept for genuine local installs but now requires the editor's own User directory to already exist, so a settings file nothing reads is never fabricated again; when neither target is reachable, CheetahClaws prints the single line to paste into the UI machine's settings instead. The marker became target-scoped ({"ts": …, "target": …}), so a machine whose target changes retries — and the old bare-timestamp marker triggers exactly one retry, which self-heals every session stuck by the original bug./terminal-setupfollows the same resolution and now reports which scope it wrote. Newtests/test_vscode_setup.py(19 cases; the module had none) covers server detection incl. forks and lookalike-serverdirectories, target resolution and precedence, the never-fabricate regression, JSONC comment preservation, marker scoping and legacy-marker retry, and the auto path end-to-end. -
July 20, 2026: Bounded-I/O fixes and a configurable tool surface. Two things. (1)
tool_profileconfig. Every model request ships the JSON schemas of the tools the agent may call; the newtool_profileselects how much of that surface is advertised each turn — a smaller surface means fewer prompt tokens and less for a weak or small-context model to choose between. Four values:full(default — everything registered: coding, web/documents, multi-agent + tasks, plan mode, email, MCP, and plugins),standard(compact coding set only —Read/Write/Edit/Bash/Glob/Grep/GetDiagnostics/NotebookEdit/AskUserQuestionand theMemory*tools),research(standard+WebFetch/WebSearch/WebBrowse/Research/ReadPDF/ReadImage/ReadSpreadsheet/ReadEmail/SummarizeLargeFile), andorchestration(standard+Agent/SendMessage/CheckAgentResult/ListAgentTasks/ListAgentTypes/Skill/SkillList/Task*/EnterPlanMode/ExitPlanMode/SleepTimer). Every non-fullprofile still includes thestandardcoding tools, so narrowing the surface never costs you Read/Write/Edit/Bash. Switch with/config tool_profile=standard, the Web UI Tool Surface dropdown, orPATCH /api/config(an unknown value is rejected —400on the API, an error on the CLI). The default isfulland a config that omits the key inheritsfull, so upgrading never silently drops a capability a user relied on; sub-agents inherit the parent session's profile. The selected profile is applied consistently across the provider tool schemas, execution dispatch, the system prompt's Active Tool Surface block,/configvalidation, the Web API, and the read-only tool-result cache key. (2) Two bounded-I/O regression fixes.SummarizeLargeFilerecorded a failed map chunk as an error-marker string ([chunk-summarize error: …]), notNone, so the reduce stage neither skipped it nor warned — a file whose chunks all failed came back as a confident "summary" of the error text. It now detects those markers, keeps them out of the reduce prompt, warns on incomplete coverage (distinguishing failed chunks from reduce-cap drops), and returns a cleanErrorwhen every chunk fails, when the reduce call fails, or when a single-shot summary fails. Separately, the DuckDuckGo result parser calleddict(attrs).get("class", "").split(), which returnsNone(crashing the entire search) on a valuelessclassattribute such as<div class>; it is now guarded withor "". Adds regression tests totests/test_summarize_large_file.pyandtests/test_bounded_tool_io.pyand regenerates the golden prompt fixture (made order-independent under thefulldefault); full suite green (2570 passed, 5 skipped). New docs: a Tool Profiles section in docs/guides/usage.md, and the/api/configwritable-keys list in docs/guides/web-ui.md now includestool_profile. Not a breaking change — the default tool surface is unchanged (full), and the summarize/parser fixes only affect failure paths. -
July 11, 2026: Claude-Code-style terminal tab title, and a cross-turn fix for the Anthropic prompt cache. Two changes. (1) Animated terminal tab title. The terminal window/tab title now tracks the live task instead of showing the shell default: a pulsing glyph + the user's current prompt while the model works (`✶ ✳ ✻ Cheetah...
v3.5.85
- July 10, 2026 (v3.5.85): REPL quality-of-life — completion works on every install,
/modelgets a Tab picker, and sessions autosave every turn. Three related changes. (1)prompt_toolkitis now a core dependency. The typing-time completion menu (slash commands, subcommands, the new/modelpicker) is driven byprompt_toolkit, but it was an optional extra ([autosuggest]), so only environments that happened to already have it — e.g. a fat Anaconda base — got the rich experience; a cleanpip install cheetahclawsor an isolateduv tool install cheetahclawsfell back to bare readline (Tab-only, no live dropdown). Since the interactive REPL is the product,prompt_toolkit>=3.0.43moved from[project.optional-dependencies].autosuggestinto[project].dependencies(and into the core block ofrequirements.txt), so every install method now gets live completion out of the box. The readline fallback path inui/input.pyis untouched — it still covers any environment whereprompt_toolkitgenuinely can't be installed. The[autosuggest]extra is kept as a harmless no-op alias for backward compatibility. (2)/modeldynamic completion (PR #166). Typing/modeland pressing Tab now offers aprovider/modelpicker — one default model per configured provider plus a two-levellitellm/<backend>/<model>tree you can drill into — instead of forcing you to remember and hand-type long model strings. Completions are context-aware (/model openai/gnarrows to OpenAI models;litellm/openrouter/expands that backend). Wired into both theprompt_toolkitand readline completers via a new dynamic-completions registry; thelitellmprovider also gained a small curated starting model list to seed the picker (any valid LiteLLM string still works regardless of the list). (3) Per-turn crash-safe session autosave. Previously the live transcript was written tosession_latest.jsononly on a clean exit /Ctrl+C/ budget-pause, so a power-loss or hard kill mid-conversation lost everything since the session started (file edits and explicit/rememberwrites were already immediate, so only the transcript was at risk). A newautosave_session()(incommands/session.py) is now called at the end of every turn inrun_query: it rewrites onlysession_latest.jsonvia a temp file +flush()+os.fsync()+ atomicos.replace()(durable against a power cut, and a crash can never leave a half-written file), stays silent (no console spam), reuses one stablesession_idso each turn overwrites the same file, and is best-effort (never raises into the REPL). It deliberately does not write adaily/copy, append tohistory.json, or touch SQLite — those remain exit-time finalization steps insave_latest(), which still prints the loudSession saved → …paths on quit. Net effect:/resumenow recovers a conversation after a crash, not just after a clean exit. See docs/PR/resume_Feature.md · docs/guides/reference.md (/model,/resume) · docs/guides/features.md (Session persistence). Version bumped3.5.84→3.5.85inpyproject.toml. Not a breaking change — no runtime behavior changes for existing installs beyond the always-on completion and autosave; publishing the new release (git tag + PyPI) is what lets users pull theprompt_toolkitcore-dependency change viapip install -U/uv tool upgrade. - July 9, 2026: Official Docker image on Docker Hub + a one-command publish script, plus a first-run permission fix. You can now run CheetahClaws without cloning the source:
docker pull chauncygu/cheetahclawsanddocker run --rm -p 8080:8080 chauncygu/cheetahclawsbrings up the Web UI. Three parts shipped. (1) First-runPermissionErrorfixed. The image runs as a non-rootcheetahuser (uid 1000), but theDockerfiledeclaredVOLUME ["/home/cheetah/.cheetahclaws"]and setWORKDIR /workspacewithout pre-creating those directories, so Docker created them root-owned — and the very first launch died withPermissionError: [Errno 13] ... '/home/cheetah/.cheetahclaws/sessions'whenconfig.load_configtried tomkdirthe sessions dir. TheDockerfilenowmkdir -ps both.cheetahclawsand/workspaceandchowns them tocheetahbeforeUSER cheetah, so the anonymous volume and workspace inherit correct ownership and startup succeeds with no host mount required. (2) Compose image is overridable.docker-compose.ymlstill builds and tagscheetahclaws:latestlocally by default, but theimage:key is now${CHEETAH_IMAGE:-cheetahclaws:latest}— setCHEETAH_IMAGE=chauncygu/cheetahclaws:latest docker compose up -dto run the published image and skip the build. (3)scripts/docker-publish.shreads the version frompyproject.toml, tags both:<version>and:latest, and pushes; multi-arch (linux/amd64,linux/arm64) via buildx by default, orSINGLE_ARCH=1for a host-arch-onlydocker build+push, withDRY_RUN=1to preview andPUSH_LATEST=0to skip the floating tag. Docs: docs/guides/docker.md gains a Pull from Docker Hub section (pull/run, compose override, maintainer publish) and an Interactive setup / CLI mode section explaining that the default--webimage configures the model in the Web UI Settings panel, and how to get thepip-style first-run wizard instead (docker run -it … --setup, with the required-v …:/home/cheetah/.cheetahclawsconfig-persistence mount). Current published tags:chauncygu/cheetahclaws:latestand:3.5.84(amd64). Not a breaking change — no source behavior changes; nativepip install cheetahclawsis unaffected. - July 8, 2026:
/workspace— isolated working directories under~/.cheetahclaws/workspaces, now strictly opt-in (PR #162 + follow-up hardening). Adds a/workspaceslash command mirroring the Dulus workflow:list,switch <name>(creates on demand, records last-used),default [name](show/set the startup workspace),create <name>, anddelete <name>(empty dirs only; refuses to delete the workspace you're currently in), plus a bare/workspacethat prints the current workspace + cwd. The follow-up fixes two problems in the original PR before they reach users. (1) No more silentchdiron startup. The merged version unconditionallyos.chdir-ed into~/.cheetahclaws/workspaces/workspace1at every launch — so a user startingcheetahclawsinside their project would have the agent's file tools silently operating on an empty hidden directory instead of their repo. Boot-time switching is now gated behind a newworkspace_autoconfig flag (defaultFalse): out of the box the CLI stays in whatever directory you launched from (unchanged behavior); isolation is enabled explicitly with/config workspace_auto=true. (2)defaultis now sticky.defaultandswitchpreviously shared oneworkspace_lastkey, so setting a startup default was clobbered by the nextswitch. They're now split intoworkspace_default(the sticky startup target, set only by/workspace default) andworkspace_last(most-recent switch); startup precedence isworkspace_default→workspace_last→workspace1. All three keys are registered inconfig.DEFAULTS. Covered bytests/test_workspace_cmd.py(12 tests: CRUD round-trips, current-workspace detection, the distinct-keys regression, startup precedence, and the boot helper). Not a breaking change — withworkspace_autooff (the default), no existing behavior changes. See docs/guides/reference.md (/workspace,workspace_auto).
v3.5.84
- July 6, 2026 (latest, v3.5.84):
/imagegains local-OCR prompt enrichment — clipboard screenshots become actionable text even on non-vision models. The/imagecommand captured a clipboard screenshot and attached it as base64 for a vision model to interpret, which is useless on the dominant local-Ollama setup where the model has no vision at all — the screenshot was silently ignored. Now, after capturing the PNG,/imageruns best-effort local OCR (ocr_image_bytesintools/files.py, wrappingpytesseract) and appends the extracted text to the prompt, clearly labeled as verbatim OCR that may contain recognition errors, so a screenshot of an error dump, a code snippet, a receipt, or a table becomes text the model can act on. Vision models get both signals — pixels and the exact transcribed text (OCR tends to beat vision models at dense text). The helper never raises: missingpytesseract/Pillow/tesseractbinary, corrupt bytes, and empty results all collapse to'', so the feature is a zero-cost no-op when OCR isn't available and never breaks the existing vision path (pending_imagebase64 is still set). Two safety bounds: the appended OCR block is capped at 8000 chars to protect small local context windows, and — because OCR is synchronous (noticeable latency on large images) and injects imperfect text a vision model may not want — the whole enrichment is opt-out viaCHEETAHCLAWS_IMAGE_OCR=0(default on). Covered bytests/test_image_ocr.py(never-raises paths, prompt append, truncation, the still-sets-pending_imageregression, and the env opt-out / default-on toggle). Version bumped3.5.83→3.5.84inpyproject.toml. Not a breaking change — no existing behavior changes; the OCR text is purely additive and disabled with one env var. See docs/guides/features.md (Vision input) · docs/guides/reference.md (/image, env vars). - June 28, 2026: New
accept-editspermission mode — the middle ground betweenautoandaccept-all— plus an exposedplanmode and corrected permission docs. Previouslyautoasked before every file edit whileaccept-allran everything (destructive shell commands included), with no setting in between — awkward for a coding session where you trust the model to edit files but want a prompt before it runs commands.accept-editsfills that gap: it auto-runsWrite/Edit/NotebookEditbut still prompts for any non-allow-listed Bash, so agit push --forceorrmis never run silently (and the host-destroying hard denylist —rm -rf /,mkfs,ddto a raw disk, fork bombs — still applies at execution time, in every mode). Implemented as one branch in_check_permission; reads and Bash keep theautorules. Two cleanups shipped alongside: the already-implementedplanmode (read-only analysis, writes refused except to the plan file) is now listed in the/permissionsmenu and tab-completion instead of being reachable only via/plan; and the system prompt's "Safe vs Unsafe" section — which wrongly told the model thatautoauto-approves checkpoint-protected edits, and that unsafe ops are asked "even underaccept-all" — was rewritten to match the real_check_permissionbehavior (autoprompts for all edits;accept-alldoes not prompt; only the hard denylist blocks unconditionally). README, the 7 i18n READMEs,docs/guides/features.mdanddocs/guides/reference.mdall list the five modes. See docs/guides/reference.md. - June 28, 2026: Memory staleness is now anchored to verification, not file mtime — and the agent is told how to keep memories fresh. Two parts. (1) The bug (PR #150).
MemorySearchrewrites a memory file to bumplast_used_at, which advanced its mtime; both the retrieval recency score and the⚠ stalewarning were derived from that mtime, so a single read of a stale, never-re-verified memory reset its recency to ~1.0 and suppressed its "verify against current code" warning — the "stale-but-confident" failure the design warns against, and worst for the most-retrieved (most-likely-acted-on) memories. The fix adds alast_verifiedfrontmatter field (defaults tocreated); staleness/recency now come fromverified_epoch(last_verified→created→ mtime fallback for legacy files), never raw mtime.touch_last_usedpreserves mtime and never writeslast_verified, so a read can't look like a write. A newMemoryVerifytool /mark_verified()is the only thing that refreshes the clock, called after the agent re-checks the claim against the environment — integral to the fix, since removing the broken implicit refresh (read = fresh) requires providing the correct explicit one. 7 regression tests encode the bug (tests/test_memory_staleness.py); existing memory tests stay green. (2) Follow-up — activating the dormant half. Merging #150 alone left the explicit-refresh path unused: the memory system prompt told the model to verify a memory before acting on it but never mentioned theMemoryVerifytool, so no memory ever got re-verified and still-correct old memories would keep the stale flag and decay in ranking forever. The prompt now explicitly instructs: after confirming a claim still holds, callMemoryVerify(the only thing that clears the flag / restores ranking); if it no longer holds,MemorySave(overwrite) orMemoryDeleteinstead. And the always-injected memory manifest — which still sorted and aged by mtime — is now anchored toverified_epochtoo, matching howMemorySearchranks (legacy files without date fields fall back to mtime, so they're unchanged). See docs/guides/features.md · docs/guides/reference.md.
v3.5.83
- June 23, 2026 (latest) (v3.5.83): Documentation slimmed to its essentials, a native desktop app brought into the repo, and the version-string format unified. Three threads of housekeeping. (1) README / docs trim. The top-level README had grown a verbose multi-paragraph News block and several reference dumps that duplicated the guides. Each News item is now one sentence + a
[Details](docs/news.md)link (the full write-ups stay here); the 59-model Atlas Cloud list moved todocs/guides/usage.md(Option D) leaving only the 3-line example in the README; and the FAQ dropped to its three highest-value entries (MCP, Ollama tool calls, macOS PATH) with the rest pointed atdocs/guides/faq.md. Net README ~551 → ~516 lines with no content lost — everything trimmed already lived indocs/. (2) Native desktop app (desktop/). A thin Electron shell that launchescheetahclaws --web --no-authas a localhost sidecar, parses itsChat UI: http://…/chatready line, and points aBrowserWindowat it — so the production web UI becomes a native window with nothing reimplemented. It couples to the rest only through that CLI contract (verified:npm run smokelaunches the real server against this repo and confirms/chat//healthall serve), andscripts/build-app.shcan freeze the server with PyInstaller into a self-contained.dmg/.exe/.AppImageneeding neither Node nor Python on the user's machine. Surfaced from three entry points (top-of-README callout, Web UI section, Documentation table). Remaining for a shippable installer: code signing / notarization. (3) Version-string format unified. Historical release notes mixedv3.05.xandv3.5.x; all ~166 occurrences across the README, the 7 i18n translations,docs/news.md, the guides, the demo/cast generators, and the recorded.cast/.svgbanners are now the canonicalv3.5.x. Version bumped3.5.82→3.5.83inpyproject.toml. Full suite green (2449 passed, 3 skipped); the desktop sidecar smoke test passes against this repo's web server. Not a breaking change — no runtime behavior changed. - June 16, 2026: All internal modules move into a single
cheetahclawspackage. Previously the importable modules lived flat at the top level (config.py,daemon/,kernel/,mcp_client/,providers.py, …). That works when you run from the repo dir but breaks once CheetahClaws is installed and launched from its entry point: a generic top-level name likeconfigordaemongets shadowed by whatever else is onsys.path— another project'sconfig/directory, the PyPIpython-daemonpackage — andcheetahclawsdies at startup withImportError: cannot import name … from 'config' (unknown location). (An earlier pass that merely dropped acc_prefix from four of these modules re-introduced exactly this collision, which the prefix had originally been added to prevent — so this change supersedes it.) The fix is the standard one: own a single namespace. All 21 single-file modules and 20 sub-packages now live undercheetahclaws/and are imported ascheetahclaws.<name>; the entry scriptcheetahclaws.pybecamecheetahclaws/cli.py, with a deliberately lightcheetahclaws/__init__.py(definesVERSION, lazily proxies CLI entry symbols via PEP 562__getattr__so importing a submodule never drags in the heavy CLI) and acheetahclaws/__main__.pyforpython -m cheetahclaws. Imports were rewritten across all 448.pyfiles — 1269from NAME+ 126import NAME+ 41 dottedimport NAME.substatements, 118 stringpatch/mock/import_moduletargets, subprocess-margv paths, the modular plugin f-string loaders, the voice/video back-compat shims, and embedded driver-script strings — all prefixed withcheetahclaws., using whole-word matching so RPC method names, filenames, and unrelated tokens were left alone.pyproject.tomlnow ships a singlecheetahclaws*package (nopy-modules) with entry pointcheetahclaws.cli:main;agent_templates/moved into the package so it ships as data. Triage of the move surfaced and fixed seven regression classes — kernel/daemon subprocess-margv paths, thetest_packagingimport contract, the voice shim's submodule registration, the daemon e2e launcher, tests that patched the package object instead of theclimodule, tests with hardcoded repo-root data paths, and asys.modulesstub-restore leak betweentest_researchandtest_setup_wizard. Breaking only for code that imports CheetahClaws internals directly —import kernel→from cheetahclaws import kernel,from mcp_client.client import get_mcp_manager→from cheetahclaws.mcp_client.client import get_mcp_manager; thecheetahclawsCLI,python -m cheetahclaws, the Web UI, and all bridges are unaffected. Verified end-to-end:python -m cheetahclaws --versionandfrom cheetahclaws import configboth work from outside the repo (the original crash), a built wheel containscheetahclaws/*with all data files (web, prompts, agent_templates) and zero bare top-level modules, and the full suite is 2449 passed, 3 skipped, 0 failed.
v3.5.82.5
- June 6, 2026 (v3.5.82.5) (latest): macOS install reliably puts
cheetahclawson PATH, and local Ollama models that emit tool calls as text now actually execute them. Two fixes reported in issue #131. (1) Install / PATH on macOS. On macOS the installer creates a dedicated venv (~/.cheetahclaws-venv) andsources it, so the post-install verificationif command -v cheetahclawssucceeded inside the script's own activated shell — it printed "cheetahclaws is on PATH" and short-circuited past the entire rc-file block, including thetouch ~/.zshrcthat was supposed to create the file. Result:~/.zshrcwas never created/updated, and in a fresh terminal (no venv active) the binary was unreachable, so users had to hunt for the install location by hand. The verification step no longer trusts the venv-pollutedcommand -v: it confirms the binary at the expectedBIN_DIR, then (for venv installs) symlinks only thecheetahclawsentry point into~/.local/bin— pipx-style, so the venv'spython/pipnever get prepended to PATH and can't shadow the user's own — creates the right rc file if missing (~/.zshrcfor zsh,~/.bash_profilefor bash on macOS,config.fishfor fish), and appends the exposure dir to PATH there. The fish branch now also writes fish (set -gx PATH …) syntax instead ofexport, and the reload hint points bash-on-macOS at.bash_profile(scripts/install.sh). (2) Ollama tool calls (the "model just keeps talking" bug). The Ollama streaming path (stream_ollama) only read tool calls from Ollama's structuredmessage.tool_callsfield, whereas the OpenAI-compatible cloud path (stream_openai_compat) also recovers tool calls a model emits as text via_find_native_tool_marker+_extract_native_tool_calls. Many local models — Qwen-coder, Gemma, Mistral — emit calls as<tool_call>{…}</tool_call>/<|tool_call|>…/[TOOL_CALLS][…]insidecontent; on the Ollama path that markup was streamed straight to the screen as chat and never executed, so the agent loop saw no tool calls and ended the turn — exactly the reported "tool-calling-style chat that never runs."stream_ollamanow mirrors the cloud path: when a native marker appears in the streamed content it buffers from that point (so the user never sees raw markup), and at end-of-stream parses the buffer into real tool calls (falling back to surfacing the buffered text if parsing fails, so nothing is silently swallowed). Note: Ollama's native/api/chatdoes not accept atool_choiceparameter, so the fix is the text-format recovery, not a request-param change. Existing provider + cache-token suites stay green. See docs/guides/usage.md · docs/guides/faq.md. - June 5, 2026 (v3.5.82): User-controllable token / cost budgets — set a spend cap; on hit the session auto-saves and you can resume or raise it. The quota engine (
quota.py: per-session + per-day token/cost counters, enforced before each model call) already existed but had no friendly surface — you had to know four config keys (session_token_budget/session_cost_budget/daily_token_budget/daily_cost_budget) and there was no way to see how close you were, no warning before the wall, and the hard stop printed a bare[Quota exceeded]. This adds the UX layer on top of the unchanged engine: a/budgetcommand — no args shows usage vs every budget as colored bars + percentages;/budget $5sets a session cost cap (the$means USD),/budget 200ka session token cap (parses200k/1.5m/200000),/budget daily $20//budget daily 2mthe daily caps, and/budget clearremoves all. A--budget $5/--budget 200kstartup flag sets the session cap at launch. Proximity warnings fire at the end of any turn that crosses ≥80% (yellow) / ≥95% (red) of a cap, so the wall never arrives by surprise. On hit the agent now yields aQuotaPauseevent (instead of a plain text line): the REPL auto-saves the session (session_latest.json+ daily backup, the same path/resumereads) and prints a friendly next-steps block — raise the same cap or remove it (/budget clear) then resend, or restart later and/resume. So a long task that runs out of budget is never lost: you analyze, adjust, and continue. Tight enforcement (no surprise overshoot): the check projects the next request's input (compaction.estimate_tokens) and stops before the call if it would cross the cap, and clamps that call'smax_tokensto the remaining headroom (quota.output_room) — so a single tool-heavy turn can't blow 40k→49k past the budget the way a pure "already-spent ≥ limit" check let it. One budget per scope: setting a cap replaces the other unit for that scope (/budget $5after/budget 200kswitches the session cap to cost rather than stacking), so a leftover token cap can't silently keep blocking after you switch to a$cap. Unit-matched hint:QuotaExceeded/QuotaPausecarry which cap broke (key/scope/unit/limit), so the "raise it" suggestion is in the right unit — a token cap shows/budget 40k, a daily cost cap shows/budget daily $40— instead of a generic$amount that wouldn't lift a token cap. New helpersquota.parse_budget/fmt_amount/usage_vs_limits/warnings/output_room; command incommands/core.py:cmd_budget;QuotaPauseinagent.py; REPL handling +--budgetincheetahclaws.py; 42-casetests/test_budget.py(isolated quota dir, incl. a regression that the hint matches the breached unit and that switching units clears the stale cap). The daemon's conservativeserve-mode defaults (200k tok / $2 per session, 2M / $20 per day) are unchanged — interactive stays unlimited by default, the server stays guard-railed. See docs/guides/features.md · docs/guides/reference.md. - June 5, 2026 (v3.5.82): Adaptive Markdown streaming — live output that stays correct on every device. In-place Rich Live redraw is great on capable terminals but breaks elsewhere: it was disabled wholesale over SSH (so SSH users got raw tokens with no formatting), and where it did run it could leave duplicate or stale frames — on macOS Terminal (which can't erase above the scroll boundary), over laggy network PTYs, or with wide CJK / emoji text whose display width a naive line-count gets wrong. The renderer now selects a streaming tier per device in
ui.render.auto_stream_mode(config):live— full in-place redraw, only on terminals known to handle cursor-up (local TTYs, and modern emulators even over SSH: iTerm2, WezTerm, Windows Terminal, VSCode, kitty, Alacritty, Ghostty, detected viaTERM_PROGRAM/TERM/WT_SESSION/KITTY_WINDOW_ID/ALACRITTY_WINDOW_ID/WEZTERM_PANE);commit— append-only progressive Markdown, the safe default for unknown-SSH / Apple Terminal / pipes / non-TTY, where each completed block (split on blank lines, respecting open code fences so a fenced block renders atomically) is rendered and printed permanently and the cursor is never moved, making a duplicate frame structurally impossible regardless of terminal, latency, or character width;plain— raw tokens, only whenrichis unavailable. The append-only floor is provably duplication-free;liveis progressive enhancement on top. Override with/config stream_mode=live|commit|plain(legacy boolean/config rich_live=true|falsestill works →live/commit). Implemented inui/render.py(set_stream_mode/auto_stream_mode/_safe_commit_point/_commit_stream/_commit_flush), wired in at REPL start incheetahclaws.py, with a 26-case test suite intests/test_stream_modes.py(device routing, code-fence-aware block boundaries, append-only commit, and a regression asserting commit mode emits zero cursor sequences even on a TTY with CJK text). Two related UX items shipped alongside:/contextis now a visual grid — a Claude-Code-style 20×10 cell grid of context-window usage, colored and broken down by category (system prompt / system tools / memory files / skills / messages / free space) with per-category token counts and percentages, adapting to the model's real context window and falling back to#/.on non-UTF-8 terminals (commands/core.py:cmd_context); anddeepseek-v4-flashis registered at its 1M context window inproviders._MODEL_CONTEXT_LIMITS(overriding the 128K deepseek provider default, which still applies todeepseek-chat/deepseek-v4-pro), so the prompt%,/context, and the compaction trigger all reflect the true 1M window. See docs/guides/features.md · docs/guides/reference.md.
v3.05.82
- June 5, 2026 (v3.05.82) (latest): User-controllable token / cost budgets — set a spend cap; on hit the session auto-saves and you can resume or raise it. The quota engine (
quota.py: per-session + per-day token/cost counters, enforced before each model call) already existed but had no friendly surface — you had to know four config keys (session_token_budget/session_cost_budget/daily_token_budget/daily_cost_budget) and there was no way to see how close you were, no warning before the wall, and the hard stop printed a bare[Quota exceeded]. This adds the UX layer on top of the unchanged engine: a/budgetcommand — no args shows usage vs every budget as colored bars + percentages;/budget $5sets a session cost cap (the$means USD),/budget 200ka session token cap (parses200k/1.5m/200000),/budget daily $20//budget daily 2mthe daily caps, and/budget clearremoves all. A--budget $5/--budget 200kstartup flag sets the session cap at launch. Proximity warnings fire at the end of any turn that crosses ≥80% (yellow) / ≥95% (red) of a cap, so the wall never arrives by surprise. On hit the agent now yields aQuotaPauseevent (instead of a plain text line): the REPL auto-saves the session (session_latest.json+ daily backup, the same path/resumereads) and prints a friendly next-steps block — raise the same cap or remove it (/budget clear) then resend, or restart later and/resume. So a long task that runs out of budget is never lost: you analyze, adjust, and continue. Tight enforcement (no surprise overshoot): the check projects the next request's input (compaction.estimate_tokens) and stops before the call if it would cross the cap, and clamps that call'smax_tokensto the remaining headroom (quota.output_room) — so a single tool-heavy turn can't blow 40k→49k past the budget the way a pure "already-spent ≥ limit" check let it. One budget per scope: setting a cap replaces the other unit for that scope (/budget $5after/budget 200kswitches the session cap to cost rather than stacking), so a leftover token cap can't silently keep blocking after you switch to a$cap. Unit-matched hint:QuotaExceeded/QuotaPausecarry which cap broke (key/scope/unit/limit), so the "raise it" suggestion is in the right unit — a token cap shows/budget 40k, a daily cost cap shows/budget daily $40— instead of a generic$amount that wouldn't lift a token cap. New helpersquota.parse_budget/fmt_amount/usage_vs_limits/warnings/output_room; command incommands/core.py:cmd_budget;QuotaPauseinagent.py; REPL handling +--budgetincheetahclaws.py; 42-casetests/test_budget.py(isolated quota dir, incl. a regression that the hint matches the breached unit and that switching units clears the stale cap). The daemon's conservativeserve-mode defaults (200k tok / $2 per session, 2M / $20 per day) are unchanged — interactive stays unlimited by default, the server stays guard-railed. See docs/guides/features.md · docs/guides/reference.md. - June 5, 2026 (v3.05.82): Adaptive Markdown streaming — live output that stays correct on every device. In-place Rich Live redraw is great on capable terminals but breaks elsewhere: it was disabled wholesale over SSH (so SSH users got raw tokens with no formatting), and where it did run it could leave duplicate or stale frames — on macOS Terminal (which can't erase above the scroll boundary), over laggy network PTYs, or with wide CJK / emoji text whose display width a naive line-count gets wrong. The renderer now selects a streaming tier per device in
ui.render.auto_stream_mode(config):live— full in-place redraw, only on terminals known to handle cursor-up (local TTYs, and modern emulators even over SSH: iTerm2, WezTerm, Windows Terminal, VSCode, kitty, Alacritty, Ghostty, detected viaTERM_PROGRAM/TERM/WT_SESSION/KITTY_WINDOW_ID/ALACRITTY_WINDOW_ID/WEZTERM_PANE);commit— append-only progressive Markdown, the safe default for unknown-SSH / Apple Terminal / pipes / non-TTY, where each completed block (split on blank lines, respecting open code fences so a fenced block renders atomically) is rendered and printed permanently and the cursor is never moved, making a duplicate frame structurally impossible regardless of terminal, latency, or character width;plain— raw tokens, only whenrichis unavailable. The append-only floor is provably duplication-free;liveis progressive enhancement on top. Override with/config stream_mode=live|commit|plain(legacy boolean/config rich_live=true|falsestill works →live/commit). Implemented inui/render.py(set_stream_mode/auto_stream_mode/_safe_commit_point/_commit_stream/_commit_flush), wired in at REPL start incheetahclaws.py, with a 26-case test suite intests/test_stream_modes.py(device routing, code-fence-aware block boundaries, append-only commit, and a regression asserting commit mode emits zero cursor sequences even on a TTY with CJK text). Two related UX items shipped alongside:/contextis now a visual grid — a Claude-Code-style 20×10 cell grid of context-window usage, colored and broken down by category (system prompt / system tools / memory files / skills / messages / free space) with per-category token counts and percentages, adapting to the model's real context window and falling back to#/.on non-UTF-8 terminals (commands/core.py:cmd_context); anddeepseek-v4-flashis registered at its 1M context window inproviders._MODEL_CONTEXT_LIMITS(overriding the 128K deepseek provider default, which still applies todeepseek-chat/deepseek-v4-pro), so the prompt%,/context, and the compaction trigger all reflect the true 1M window. See docs/guides/features.md · docs/guides/reference.md.
v3.05.81
-
June 4, 2026 (v3.05.81) (latest): Claude-Code-style quiet output — hide tool execution, show one summary line per turn. Long analysis turns used to scroll the terminal with a
⚙ Bash(...)line and a✓ → N lines (… chars)line for every tool call, and the permission prompt dumped the entire inline script (e.g. a 60-linepython3 << 'PYEOF'heredoc). A new quiet mode (on by default) suppresses the per-tool lines — the spinner conveys live activity and a single summary line is emitted at the tool→text boundary, sitting just above the reply (Read 2 files, ran 3 shell commands), the way Claude Code does. Errors and denials still surface so a mid-turn failure is never silent. In quiet mode the permission prompt also collapses a multi-line command to one line (Run: python3 << 'PYEOF' … (+59 行)) instead of printing the whole script./verboseoverrides quiet (full per-tool lines + inputs + token counts); toggle with/quiet, or launch with--show-tools(alias--no-quiet). The startup banner gains anOutput: quiet/Output: fullline so the active mode is visible at a glance. Live status line: the spinner now shows elapsed time plus a running output-token estimate (Thinking… (7s · ↓ 435 tokens)) — char-based, since providers only report real usage at the end — and each quiet turn closes with a real-usage footer✻ Worked for 7.2s · ↑ 1.2k · ↓ 435built from the trueTurnDonecounts. Implemented inui/render.py(turn-level tool accumulator +turn_summary_line(), spinner token meter,print_turn_stats()), wired through the REPL event loop incheetahclaws.py, with the/quiettoggle incommands/config_cmd.py. See docs/guides/features.md. -
June 4, 2026: Context-window override — the prompt % and compaction now follow a settable context length. The prompt's context-usage
%(and the compaction trigger) derive from the model's context window, which previously could only be a hardcoded provider default — andmax_tokens(the OUTPUT cap) doesn't change it, so/config max_tokens=…left the%unchanged (a common point of confusion). New per-session keycontext_window(/config context_window=<N>,0= model default) overrides it, kept deliberately distinct frommax_tokens. A single parser (providers.context_window_override) feeds the prompt%,/context, the compaction trigger, and the per-call output-token cap, so all four stay consistent; it is bidirectional — a smaller value forces earlier compaction, a larger value corrects a stale default. The value is read live each prompt, so switching model orcontext_windowupdates the%with no restart./configwarns when the value exceeds the model's real window (which would disable compaction and let the API reject oversized prompts). No-op when unset, so existing behavior is unchanged. See docs/guides/reference.md. -
June 4, 2026: Rich Live streaming — long responses stay live via a bounded tail window. Large streamed responses that would overflow the terminal's redraw area could leave duplicate or stale frames behind on some emulators (macOS Terminal, etc.), because Rich Live redraws the whole accumulated output in place and the cursor can't reach content that has scrolled into the scrollback. Building on the per-response fallback from PR #133, Rich Live now keeps the live region bounded to the viewport: a short response is shown in full, but once it would overflow, only the last screenful of rendered lines (a tail window) is redrawn — so the Live region can never exceed the terminal and cannot leave stale frames. The complete output is committed once when the response finishes (including on Ctrl-C, since the REPL flushes on interrupt), so the head that scrolled out of the window is never lost. Plain streaming is kept only as a safety net (precise render failed, or the terminal is too small to bound a window). A cheap per-line wrap estimate short-circuits the expensive full
render_lines()measurement while a response stays well under the limit, so normal responses pay no extra Markdown re-render per chunk. Adds focused tests covering full-frame streaming, the full→tail transition, tail-window commit-on-flush, realSegmentsrendering, and both safety-net fallbacks. See docs/guides/features.md. -
May 31, 2026: QQ bot bridge —
/qqconnects cheetahclaws to QQ groups + C2C private chats (PR #121). Uses the officialqq-botpyWebSocket + HTTP SDK (pip install "cheetahclaws[qq]"). botpy's async client runs on a dedicated asyncio event loop inside a daemon thread, bridged to the synchronous main thread via thread-safe queues. Handleson_group_at_message_create(group @-mentions, prefix stripped) andon_c2c_message_create(private). Since QQ has no message-edit API, replies stream as new messages every ~2 s (2000-char chunking) instead of updating a placeholder; passive replies reference the originalmsg_id/event_idwithin QQ's 5-minute window, then fall back to active pushes. Per-target FIFO job queues, slash-command passthrough,!jobs/!retry/!cancelremote control, image input, and permission prompts scoped to the originating chat (no cross-chat approvals). A supervisor reconnects with exponential backoff (2 s → 120 s). Secret handling matches the hardening standard below:$QQ_SECRET(recommended) > REPL arg (deprecated, warns + scrubs history) > config; env-supplied secrets never touch~/.cheetahclaws/config.json./qq <appid>,/qq,/qq stop|status|logout. Two follow-up fixes over the original PR: image downloads moved off the event loop intoloop.run_in_executor(a blockingurlopenwould freeze the WebSocket heartbeat for up to 30 s), and the secret no longer gets written to disk unconditionally. See docs/guides/bridges.md.
v3.05.80
-
May 12, 2026 (v3.05.80): (latest, security-hardening branch): Two-round security hardening sweep — CRITICAL + HIGH findings from the in-repo code review. Lands a cluster of fixes that close real attack surfaces opened by the recent rapid feature growth. Zero regressions across the full 2347-test suite.
Bot tokens off
argv/ readline history.cmd_telegramandcmd_slacknow accept a single-arg form (/telegram <chat_id>//slack <channel_id>) and read the bot token from$TELEGRAM_BOT_TOKEN/$SLACK_BOT_TOKEN. Env-supplied tokens never get persisted to~/.cheetahclaws/config.json; only tokens that actually came in via the deprecated REPL-arg path are saved on disk. Newbridges.scrub_token_from_history(token)walksreadline.get_history_itembackwards and removes any in-memory entry that embeds the token the moment we know its value. Bridge supervisors get atoken=/channel=kwarg so the env-sourced token can flow to the worker thread without ever sitting on the config dict —_slack_start_bridge(config, *, token, channel). Telegram already passed the token explicitly to_tg_supervisor. WeChat is unaffected (QR-scan token, never in argv).Web UI CSRF — double-submit cookie. Server mints
ccsrf=<24B>; Path=/; SameSite=Strict; Max-Age=86400(non-HttpOnly) on every connection that arrives without one._handle_connectiongates POST/PUT/PATCH/DELETE on a matchingX-CSRF-Tokenrequest header (rejection:403 csrf token mismatch). Exempt:/api/auth/{bootstrap,register,login,logout,api/auth}— they establish the session that later carries the cookie. Newweb/static/js/csrf.jsmonkey-patcheswindow.fetchso every state-changing request automatically echoes the cookie value; loaded as the first script inchat.html, the inline terminal script in_build_html, andlab.html. Test harness (tests/test_web_api.py:_client) gains anhttpxevent hook that mirrors the browser behaviour. SameSite=Strict on the JWT cookie remains the first-line defence; CSRF is the second line.Web terminal session ownership.
_PtySession(owner_uid=...)records the creator's JWTsubat/api/sessiontime._check_pty_owner(session, cookie)is consulted at/api/stream//api/input//api/resize— any other authenticated user trying to reach a knownsidgets403 not session owner. Password-only mode (no JWT) keepsowner_uid=Noneand skips the check, preserving the shared-secret model. Closes the trivial-sid-hijack hole in multi-user web deployments.Bash hard-denylist. Eight regexes in
tools/shell.py:_BASH_HARD_DENYrefuse host-destroying patterns regardless ofpermission_mode—rm -rf /and its--recursive/--forcevariants,rm -rf /*,mkfs.*,dd of=/dev/{sd,hd,nvme,vd,mmcblk,xvd},> /dev/{sd,hd,...},chmod -R 777 /,chown -R <user> /, and the classic:(){ :|:& };:fork bomb. Hits the Bash tool, the REPL!cmdescape, and all three bridges'!cmdpaths. Plus NUL-byte + control-char + 64 KB length rejection on every Bash invocation.Filesystem credential denylist.
tools/security.py:_check_path_allowednow refuses access to a small denylist by default — SSH private keys (~/.ssh/id_*),~/.aws,~/.gnupg,~/.kube,~/.docker,~/.netrc,~/.pgpass,/etc/shadow,/etc/gshadow,/etc/sudoers*,/root. Public-by-convention SSH files (config,known_hosts,authorized_keys) remain readable. SetCHEETAHCLAWS_FS_NO_SANDBOX=1to bypass when intentionally auditing your own secrets. Independent ofallowed_root, which still works as the strict-mode toggle for multi-user daemon deployments.Plugin loader hardening. Two new env switches in
plugin/loader.py:CHEETAHCLAWS_DISABLE_PLUGINS=1(kill switch) andCHEETAHCLAWS_PLUGIN_ALLOWLIST=a,b,c(whitelist). EXTERNAL-scope plugins (loaded via$CHEETAHCLAWS_PLUGIN_PATH) print a one-time stderr warning on first load so a stolen env-var-set doesn't silently execute. Module path resolution now usesPath.resolve()+relative_to(install_dir)to confine a malicious manifest's"tools": ["../../etc/passwd_loader"]style entry.MCP env sanitisation.
cc_mcp/client.py:_sanitized_mcp_envstrips a fixed set of process-hijack keys (LD_PRELOAD,LD_LIBRARY_PATH,LD_AUDIT,DYLD_INSERT_LIBRARIES,DYLD_LIBRARY_PATH,PYTHONPATH,PYTHONSTARTUP,PYTHONHOME,PYTHONEXECUTABLE,NODE_OPTIONS,NODE_PATH,BASH_ENV,ENV) from anyenvmap an.mcp.jsonconfig supplies. Dropped keys print a one-line stderr notice. Bypass:CHEETAHCLAWS_MCP_TRUST_ENV=1. Closes a real local-priv-esc path on a host with multiple MCP server configs of varying trust.macOS daemon peer-cred.
cc_daemon/auth.py:get_peer_uidnow branches onsys.platform: Linux keepsSO_PEERCRED, macOS / *BSD goes through ctypes-loadedgetpeereid(2). Closes a long-standing TODO that effectively reduced macOS Unix-socket auth to token-only (a stolen daemon-token implied full RCE without peer-uid validation).Smaller fixes folded in. Web JWT secret loader rewritten with
O_CREAT \| O_EXCL+ 0o600 + post-write mode verification (refuses to read a world-readable secret file; auto-falls-back to in-memory secret if chmod can't be enforced; override withCHEETAHCLAWS_WEB_SECRET). Terminal one-time password fromsecrets.token_urlsafe(6)[:6](~30 bits, online-bruteable) tosecrets.token_urlsafe(32)(~190 bits).cc_config.save_configstripspermission_mode=accept-allbefore persisting — once-confirmed escape hatches no longer outlive the session that set them.session_store.save_sessionwrapped in a module-levelLock+ explicitBEGIN IMMEDIATE/ROLLBACKso two threads writing the samesession_idno longer silently drop one set of changes.agent_runner.pyerr_msginitialised before the try block (defends against aNameErroron first iteration if_handle_permission_requestreturns"error");quota.QuotaExceededmatched byisinstanceinstead of class-name string.compaction.compact_messageswrapsstream_auxiliaryin try/except + falls back to the original messages instead of crashing the agent loop.providers._recover_args_from_textcaps the regex scan window to the last 32 KB of accumulated text (was scanning ~100 KB+ on every tool call).context.get_git_info+get_claude_mdget TTL caches (30 s / 10 s, keyed by cwd) so the per-turngit rev-parse / status / logand CLAUDE.md re-read stop showing up in profiles.cc_mcp/client.pyreader loops usedict.pop()instead ofin+index so a late response after a timeout doesn't race the request side.tool_registry._cache_keyaddssession_iddimension so aRead(/etc/...)cached for one session never leaks to another.session_store.search_sessionsLIKE-fallback path escapes%/_/\before interpolation.Frontend XSS audit. Existing
_esc(textContent-→-innerHTML) and_renderMd(HTML-tag-strip → marked) cover all user/model content paths. One deep-trust hole closed:web/static/js/settings.js:_renderModelspreviously injected server-supplied model names directly into anonclick="app.selectModel('${full}')"attribute — now usesdata-model+ a delegated click handler, so a malicious model registry entry cannot break out of the JS string literal.Defaults you can flip.
CHEETAHCLAWS_BRIDGE_TERMINAL=0hard-disables the bridge!cmdshell entirely (default1, owner-bound bychat_idwhitelist anyway).CHEETAHCLAWS_FS_NO_SANDBOX=1lifts the credential denylist.CHEETAHCLAWS_DISABLE_PLUGINS=1/CHEETAHCLAWS_PLUGIN_ALLOWLIST=…/CHEETAHCLAWS_MCP_TRUST_ENV=1control plugin + MCP behaviour. Full reference in docs/guides/security.md. All 12 CRITICAL + 10 HIGH items from the review now closed (4 of those 22 turned out to be review misjudgements —_all_errorsinit, permission double-answer race,_broadcastiter race, and theQuotaExceededclassname check was a real fix but the surrounding "shell injection in REPL!command" was reclassified as user-typed-input not RCE). Architecture refactor items (cheetahclaws.py/providers.pyGod-object split, sentinel state machine) deliberately left for a separate decision — they're shape changes, not bug fixes. -
May 12, 2026 (
daemon/f-4-followups-f-6-9branch): Daemon foundation roadmap finished — all nine F-1…F-9 items in RFC 0002 now LANDED. Closes the remaining four scope items end-to-end (≈1500 LoC of code + ≈900 LoC of tests + docs). Drilldown:F-4 #2 — Bridge
notifyforwarding. The subprocess-runner reader loop'snotifyIPC branch used to drop the payload on the floor (F-6/7/8 didn't exist yet). Now it routes throughcc_daemon.bridge_supervisor.notify(kind, text). The runner can target a specific bridge viamsg["bridge"](e.g."telegram") or omit it for a"*"broadcast.agent_runner_notifyevents on the bus carry{name, run_id, bridge, delivered, text[:500]}so observers can audit deliveries. Empty-text frames are silently dropped (common during agent shutdown).F-4 #3 — Restart policy. New
RestartPolicydataclass:mode(none|on-crash),max_restarts,backoff_base_s,backoff_cap_s,backoff_jitter_s. Frozen + a purenext_delay(restart_count)so the decision matrix is unit-testable.agent.startaccepts the five fields flat (validation rejectscap < basewhich would clamp every attempt down to a useless ceiling). On a crash the reader'sfinallyarms athreading.Timer(delay, _do_restart, ...); the Timer respawns via a swappable spawner hook (_RESTART_SPAWNERfor tests) and carriesrestart_countforward.stop()cancels the Timer before the kill ladder, and the same_unregister(name, expected=handle)identity check protects against a Timer-fired respawn racing past a deliberate stop. Bus events:agent_runner_restart_scheduled,agent_runner_restart, `agent_runner_restart_faile...
v3.05.79
-
May 10, 2026 (latest, v3.05.79): Web Chat UI session organization + headless-bridges slash handler + stale-session reaper crash fix. Three threads of work merged into a single release. Bridges / headless deploys (#84 follow-up): Telegram / Slack / WeChat
/help,/monitor,/model,/statusproduced zero response in Docker /--webdeploys because_start_headless_bridges()only wiredrun_queryandagent_stateon the sharedsession_ctx— neverhandle_slash. The bridge poll loops gate onif slash_cb:and fell through tocontinuebefore the📩 Telegram:log line, so the failure was invisible indocker compose logs -f. Fix: extracted the slash handler (originally inlined inrepl()) into a module-level factory_make_bridge_slash_handler(state, config, run_query); both REPL and headless paths now use it (single source of truth, no future drift between modes). Stale-session reaper crash:web/api.py:reap_stale_chat_sessions()calledremove_chat_session(sid)without theuser_idthe function now requires for ownership-check parity — every reaper tick raisedTypeError, killing the daemon thread, so staleChatSessionobjects accumulated forever in the in-memory cache. Fix: capture(sid, user_id)pairs from the cachedChatSessionobjects under_chat_lock, then apply outside the lock. Web UI session organization: five-feature bundle layered on top — folders + drag-drop + Move-to context menu, ChatGPT-style active-folder context (click a folder name →+ Newand direct-typing both drop new sessions into that folder, with aChat · in <Folder>topbar breadcrumb), batch select with Select-all-respecting-search-filter, batch delete + combined-Markdown export (chats-N-sessions.md), and a 4-px draggable sidebar divider with localStorage persistence. Backend adds afolderstable,chat_sessions.folder_idnullable FK, in-placePRAGMA table_info+ALTER TABLEmigration ininit_db(), and 5 new HTTP endpoints (GET/POST /api/folders,PATCH/DELETE /api/folders/{id},PATCH /api/sessions/{id}/folder). Also rolled in: issue #111 (handle_slash_sync/handle_slash_streamno longer double-broadcast to WS) and--web --model Xpersistence. Tests: +16 new acrosstest_web_api.py(folder CRUD, batch ops, reaper regression) and the newtest_bridge_slash_handler.py(5 cases pinning the headless handler contract). Full suite: 2154 / 2154 passing, zero regressions. User-side guide:docs/guides/web-ui.md. -
May 10, 2026: Web Chat UI fixes — slash commands no longer reply twice;
--web --model Xactually applies the model. Two related issues that surfaced when wiring a self-hosted vLLM endpoint into the Chat UI. (1) Issue #111 — slash commands duplicated in Chat UI but not in terminal.web/api.py:handle_slash_syncwas both returning events inline in the HTTP response and broadcasting the same events to the WS subscribers of the same client;chat.jsthen iterateddata.eventsAND fired_handleEventfromws.onmessage, rendering every reply twice. Same bug inhandle_slash_streamfor SSE-streamed long commands (/brainstorm,/worker,/agent,/plan). Both helpers now deliver events through a single channel — HTTP/SSE only — so_handleEventruns exactly once per event. Background-thread events (sentinel flows, agent runs) are unaffected: by the time the worker thread emits,_broadcastis already restored to the live WS broadcaster infinally. (2)--web --model Xwas silently ignored. The CLI override branch only ran in the interactive-REPL path; theif args.web:branch loaded config straight from disk and started the server, sopython cheetahclaws.py --web --model custom/qwen2.5-72bwould happily boot but every request handler reloaded~/.cheetahclaws/config.jsonwith the previous model name (e.g.gemma-4-31B-it), producing a confusing404: model does not existagainst the new endpoint. Fix:cheetahclaws.pynow persistsargs.modelto config before callingstart_web_server, matching the documented behavior;provider:model→provider/modelnormalization is identical to the REPL path. User-side guide:docs/guides/web-ui.md(Troubleshooting + Architecture notes updated). -
May 10, 2026: Small-context local models survive large workloads — 4-part fix: ctx cap, auto-fanout, stagnation-stop, output paths under
~/.cheetahclaws/. Repro that motivated the work: running/agent → 1 (Research Assistant)on a 6.6 MB PDF (AutoRedTeamer.pdf— ~70k tokens of extracted text) withcustom/qwen2.5-72b(32k ctx). Old behavior: 400 BadRequest "context length 32768"; the agent_runner kept polling the template every 2 s; the model produced 1500+ identical "task complete" summaries before anything stopped it. New behavior, four cooperating layers: (1) Per-model context-window registry + dynamic max_tokens cap (providers._MODEL_CONTEXT_LIMITS+get_model_context_window+dynamic_cap_max_tokens) — covers Qwen 2.5/3, Llama 3.x, Mistral/Mixtral, Phi, Gemma, DeepSeek local variants;_fetch_custom_model_limitnow backfillsPROVIDERS["custom"]["context_limit"]so compaction sees the live/v1/modelsvalue; per-call shrink based on actual prompt size keepsinput + output + 1024 safety ≤ ctx.compaction.get_context_limitgains an optionalconfigarg so custom-endpoint detection works on the very first turn. (2) Auto-fanout for oversize tool outputs (multi_agent/fanout.py) — when a single tool result (Read on a huge PDF, Grep over a giant tree, WebFetch of a long article) exceeds 0.4 × ctx_window, split into chunks at paragraph boundaries with token-overlap, dispatch parallel sub-LLM map calls (one per chunk, default cap 5 subagents), merge with a single reduce call; substitutes the merged summary in conversation history instead of letting the next API call overflow. Hooked at the tool-result append site inagent.py; transparent UX prints[Auto-fanout: <Tool> returned ~N chars (>threshold) → dispatching K parallel sub-summaries]. Configurable:auto_fanout_enabled/_threshold/_max_subagents/_chunk_overlap_tokens. (3) Stagnation-stop inagent_runner.py— when the model emits the same summary N iterations in a row (default 3, whitespace/case-normalized), stop the loop with a clear notification instead of burning thousands of API calls; configurable viaauto_agent_dup_summary_limit(0 disables). (4) Agent output paths under~/.cheetahclaws/—/agentwizard now resolves relative output filenames (e.g.research_notes.md) to absolute paths under~/.cheetahclaws/agents/<name>/output/instead of CWD;AgentRunnerexposesrunner.output_dir, eagerly mkdir'd; Summary block + post-start info show the resolved path in green; absolute paths pass through unchanged. Tests: +47 new (fanout 23, ctx cap 18, dup-stop 13, output paths 8). Full suite: 2139 passing, zero regressions. User-side guide:docs/guides/extensions.md. -
May 9, 2026: Read tool auto-redirects on overflow — defense-in-depth for the case where model ignores the template instruction. Re-running the same
/agent + autodan.pdffailure showed two real-world problems with the prior fix: (1) The user was running the pip-installed binary (/home/shangdinggu/anaconda3/bin/cheetahclaws), not the source tree. New tools / templates added to source had no effect. (2) Even if the user reinstalled, qwen2.5-72b would likely still callReadinstead ofSummarizeLargeFile— models default to familiar tools no matter what the template says. The fix moves the routing decision into the Read tool itself. (a) New_maybe_redirect_to_summarizehelper (tools/files.py). WhenReadorReadPDFwould return content too large to safely fit in the next API call, it instead returns a short redirect message like[ReadTooLarge: file is too large — call SummarizeLargeFile with file_path='X' instead] PREVIEW: …. The model sees the redirect, callsSummarizeLargeFile, gets a chunked-and-merged summary back. The raw content never enters the API call. (b) CJK-aware token estimation. CJK content tokenizes at ~1 token per character (vs ~2.8 chars/token for English). New_is_cjk_heavy()heuristic: ≥20% CJK characters → use 1:1 char-to-token estimate. A 24K-char Chinese file is 24K tokens, not 8.6K, and now triggers redirect on a 32K-context model. (c) Conservative ceiling for unreliable provider declarations.custom/<model>provider declares 128K context by default but the underlying model is often 32K (qwen2.5-72b, llama 3 8B, etc.). Newsafe_ctx = min(declared_ctx, 30000)caps the threshold at 30K tokens regardless of provider claims — the redirect now fires on the user's exact ~25K-token PDF case (would NOT have fired with the unconditional 128K ceiling, which is exactly the bug). (d) Wrapped Read registration (tools/__init__.py). New_read_with_overflow_checklambda calls_maybe_redirect_to_summarizeafter_readreturns; for results <8KB it skips (not worth the check). ReadPDF gets the same treatment inline in_read_pdf. Why this works even on the old install: as soon as the user updatestools/files.pyandtools/__init__.py, the redirect fires regardless of whether SummarizeLargeFile / template changes are present. The redirect's prose tells the model exactly which tool to call and with what args. Tests: 14 new pytest cases (tests/test_read_overflow_redirect.py) — CJK detection (English / Chinese / Japanese / mixed-minority / empty), threshold logic (small file → no redirect; user's exact failure case → redirect with right pointer; CJK at lower char count triggers vs same chars in English; conservative ceiling protects against overconfident provider; preview included for context). Plus 2 integration tests viaexecute_tool("Read", ...)confirming the wrapper applies the redirect ...