Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ Other install methods: [one-line install script](#alternative-one-line-install-s

## 🔥🔥🔥 News (Pacific Time)

- August 16, 2026 (**v3.5.87**): **Permission prompts are now reserved for what actually needs a decision.** `auto` mode asks only when an action can change your files, run arbitrary code, or reach outside the session. Auto-approved now: **every** registry-marked read-only tool (18 more than before — diagnostics, task/memory queries, document readers), read-only shell **pipelines** (`git log | head -20`, `ls -la | grep test` — the old check rejected every `|`), session-state tools (tasks/memories/skills), and creating a **new** file inside the workspace. Still asks: overwrites, writes outside the workspace, `.git/hooks` and `.github/workflows` paths, interpreters and test/build runners, anything that deletes or uploads, and sub-agent spawns. The prompt also gained **`s`** — approve and stop asking for *that one command or file* for the session, a scoped alternative to `accept-all` (`/permissions clear` drops grants). The shell check is now a real parser instead of a prefix match, which along the way closed a hole where anything starting with `python `/`node `/`find ` auto-ran. [Details](docs/news.md)
- August 16, 2026: **OpenRouter is now a first-class provider — one key, 400+ models, with the secondary provider pinnable per call (PR #179).** `/model openrouter/<vendor>/<model>` (e.g. `openrouter/deepseek/deepseek-v4-flash`) routes through [OpenRouter](https://openrouter.ai); the key comes from `OPENROUTER_API_KEY` or `/config openrouter_api_key=sk-or-...`, and the model shows up in the `/model` Tab picker and the Web UI picker automatically. Append `@<provider>[/<quantization>]` — `openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8` — to pin which upstream serves the request; it is sent as OpenRouter's `provider` request-body object, so the model field stays a real catalog ID. Shipped alongside four routing fixes that gateway model IDs exposed: the provider is no longer re-derived from an already-stripped model string (which read `openrouter/deepseek/…` as the *DeepSeek* API and leaked DeepSeek-only request fields), cost estimates and context windows now resolve per model instead of defaulting to $0.00 and a flat 128k, and the `@…` routing suffix no longer strips a model of its prompt-family overlay. [Details](docs/news.md)
- July 30, 2026 (**v3.5.86**): **Next-prompt ghost text — the REPL predicts the line you'd type next.** After each reply the **auxiliary** (cheap/fast) model drafts your most likely next message and shows it dim at the prompt; **Tab** (or **→**) accepts it in full, typing just types over it, and Enter alone never submits it. Drafting runs on a background thread so the prompt never waits, stays silent on any failure (no key / no model → simply no ghost), and is one-shot per prompt so a stale prediction is never shown. Off with `/config input_suggest=false` or `CHEETAH_SUGGEST=0`. Also in this release: the **terminal tab title now configures itself over Remote-SSH / WSL / devcontainers** — it used to write a settings file on the server that the editor never reads, and never retry; it now targets the remote Machine settings the window actually reads. First tagged release carrying the July 11 tab-title / prompt-cache and July 20 `tool_profile` / bounded-I/O changes. [Details](docs/news.md)
- July 20, 2026: **Bounded-I/O fixes and a configurable tool surface.** `tool_profile` selects how many tool schemas are sent each turn — `full` (default, nothing hidden) / `standard` (compact coding) / `research` / `orchestration` — to cut prompt tokens on small-context models, switchable with `/config tool_profile=standard`. Also fixes two bounded-I/O regressions: `SummarizeLargeFile` no longer "summarizes" its own chunk-failure markers (clean `Error` when map/reduce fails), and the DuckDuckGo parser no longer crashes on a valueless `class` attribute. [Details](docs/news.md)
- July 11, 2026: **Terminal tab title tracks the live task, plus a cross-turn fix for the Anthropic prompt cache.** [Details](docs/news.md)
Expand Down Expand Up @@ -165,7 +167,7 @@ Claude Code is a powerful, production-grade AI coding assistant — but its sour

| Feature | Details |
|---|---|
| Multi-provider | Anthropic · OpenAI · Gemini · Kimi · Qwen · Zhipu · DeepSeek · MiniMax · Ollama · LM Studio · Custom endpoint |
| Multi-provider | Anthropic · OpenAI · Gemini · Kimi · Qwen · Zhipu · DeepSeek · MiniMax · OpenRouter · Ollama · LM Studio · Custom endpoint |
| Agent loop | Streaming API + automatic tool-use loop; the whole loop is in `agent.py` |
| 28 built-in tools | Read · Write · Edit · Bash · Glob · Grep · WebFetch · WebSearch · NotebookEdit · GetDiagnostics · Memory* · Agent/SendMessage · Skill · AskUserQuestion · Task* · SleepTimer · EnterPlanMode/ExitPlanMode · *(MCP + plugin tools auto-added)* |
| Tool profiles | `tool_profile` trims the tool surface sent each turn to save prompt tokens: `full` (default, everything) · `standard` (compact coding) · `research` (web + documents) · `orchestration` (agents + tasks). Set with `/config tool_profile=standard`. [Guide](docs/guides/usage.md#tool-profiles-tool_profile) |
Expand All @@ -175,7 +177,7 @@ Claude Code is a powerful, production-grade AI coding assistant — but its sour
| Context compression | Four cooperating layers — dynamic `max_tokens` cap, per-model context-window registry, two-layer snip + AI summarize at 70%, and auto-fanout for oversized tool outputs. [Details](docs/guides/reference.md) |
| Persistent memory | Dual-scope (user + project), 4 types, confidence/source metadata, conflict detection, recency-weighted search, `/memory consolidate`. Verification-anchored staleness — freshness tracks a `last_verified` date (not file mtime), so reading a memory can't fake-refresh it; only `MemoryVerify` resets the clock. [Details](docs/guides/features.md) |
| Multi-agent | Spawn typed sub-agents (coder/reviewer/researcher/…), git-worktree isolation, background mode |
| Permission system | `auto` / `accept-edits` / `accept-all` / `manual` / `plan` modes (`accept-edits` = auto-run edits, still ask for other Bash; hard denylist blocks host-destroying commands in every mode) |
| Permission system | Prompts only for what can change your files, run arbitrary code, or reach outside the session — every read-only tool, read-only shell pipeline (`git log \| head`), and new-file creation in the workspace runs silently. `s` at a prompt grants one command/file for the session (scoped alternative to accept-all). Modes: `auto` / `accept-edits` / `accept-all` / `manual` / `plan`; a hard denylist blocks host-destroying commands in every mode |
| Checkpoints & plan mode | Auto-snapshot conversation + files each turn (`/checkpoint`, `/rewind`); `/plan` read-only analysis mode |
| Slash commands & themes | 50+ slash commands with Tab-complete; `/theme` offers 15 curated palettes |
| Next-prompt ghost text | After each turn the auxiliary (cheap) model drafts the line you'd most likely type next and shows it dim at the prompt — **Tab** (or **→**) accepts it in full, typing ignores it. Background-drafted, never blocks the REPL, silent on failure. Off via `/config input_suggest=false` or `CHEETAH_SUGGEST=0`. [Details](docs/guides/reference.md#next-prompt-ghost-text) |
Expand Down Expand Up @@ -208,8 +210,11 @@ Claude Code is a powerful, production-grade AI coding assistant — but its sour
| **Zhipu (GLM)** | `glm-4-plus` · `glm-4` · `glm-4-flash` (free tier) | 128k | `ZHIPU_API_KEY` |
| **DeepSeek** | `deepseek-chat` · `deepseek-reasoner` | 64k | `DEEPSEEK_API_KEY` |
| **MiniMax** | `MiniMax-Text-01` · `MiniMax-VL-01` · `abab6.5s-chat` | 256k–1M | `MINIMAX_API_KEY` |
| **OpenRouter** _(400+ models, one key)_ | `openrouter/deepseek/deepseek-v4-flash` · `openrouter/anthropic/claude-sonnet-4-6` · `openrouter/openai/gpt-5` | varies | `OPENROUTER_API_KEY` |
| **AWS Bedrock / Azure / Vertex** _(via litellm)_ | `litellm/<provider>/<model>` | varies | provider-specific |

> **`openrouter/` gateway:** one key for 400+ models across vendors. The model ID keeps OpenRouter's upstream `<vendor>/<model>` path, so the call is double-prefixed: `openrouter/deepseek/deepseek-v4-flash`. To pin which upstream provider (and quantization) serves the request, append `@<provider>[/<quantization>]` — `openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8` — which is sent as OpenRouter's `provider` request-body object rather than glued into the model ID. See [usage.md](docs/guides/usage.md#openrouter-400-models-one-key).

> **`litellm/` adapter:** routes to 100+ providers behind one SDK — mainly for upstreams with awkward auth (Bedrock SigV4, Azure deployment routing, Vertex service-account JWTs). For plain OpenAI-shaped endpoints, prefer the zero-dependency `custom/` adapter. Install with `pip install ".[litellm]"`. See [recipes.md](docs/guides/recipes.md#alternative-cloud-providers-with-non-trivial-auth-via-the-litellm-provider).

### Open-Source (Local via Ollama)
Expand Down Expand Up @@ -305,7 +310,16 @@ cheetahclaws --model gpt-4o # pick any model
cheetahclaws --model deepseek-chat --thinking --verbose
```

Provider get-key pages: [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com) · [Kimi](https://platform.moonshot.cn) · [Qwen](https://dashscope.aliyun.com) · [Zhipu](https://open.bigmodel.cn) · [DeepSeek](https://platform.deepseek.com) · [MiniMax](https://platform.minimaxi.chat).
Provider get-key pages: [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com) · [Kimi](https://platform.moonshot.cn) · [Qwen](https://dashscope.aliyun.com) · [Zhipu](https://open.bigmodel.cn) · [DeepSeek](https://platform.deepseek.com) · [MiniMax](https://platform.minimaxi.chat) · [OpenRouter](https://openrouter.ai/keys).

**One key for 400+ models** — [OpenRouter](https://openrouter.ai) fronts every major vendor behind one OpenAI-compatible endpoint, so a single key covers Claude, GPT, Gemini, DeepSeek, Llama, Qwen and the rest:

```bash
export OPENROUTER_API_KEY=sk-or-...
cheetahclaws --model openrouter/deepseek/deepseek-v4-flash
cheetahclaws --model openrouter/anthropic/claude-sonnet-4-6
cheetahclaws --model openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8 # pin the upstream provider
```

**AWS Bedrock / Azure / Vertex** use the `litellm/<provider>/<model>` form (`pip install ".[litellm]"`) — full env-var recipes in [recipes.md](docs/guides/recipes.md#alternative-cloud-providers-with-non-trivial-auth-via-the-litellm-provider).

Expand Down Expand Up @@ -372,6 +386,16 @@ cheetahclaws --model ollama/qwen2.5-coder # 2. provider/model
cheetahclaws --model kimi:moonshot-v1-32k # 3. provider:model
```

**Gateways keep the upstream path.** OpenRouter, NIM and LiteLLM address models by a `<vendor>/<model>` path of their own, so those calls are double-prefixed — only the **first** segment is the provider, everything after it is passed through verbatim:

```bash
cheetahclaws --model openrouter/deepseek/deepseek-v4-flash # → OpenRouter, model "deepseek/deepseek-v4-flash"
cheetahclaws --model nim/meta/llama-3.3-70b-instruct # → NVIDIA NIM
cheetahclaws --model openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8 # + pinned provider / quantization
```

OpenRouter additionally accepts an `@<provider>[/<quantization>]` suffix (quantizations: `fp4` · `fp8` · `int4` · `int8`). It never reaches the model field — it is split off and sent as OpenRouter's `provider` routing object (`order` + `allow_fallbacks: false`, plus `quantizations` when given), so pinning `@gmicloud` means the request fails rather than silently landing on a different upstream.

**Auto-detection by prefix:** `claude-`→anthropic · `gpt-`/`o1`/`o3`→openai · `gemini-`→gemini · `moonshot-`/`kimi-`→kimi · `qwen`/`qwq-`→qwen · `glm-`→zhipu · `deepseek-`→deepseek · `MiniMax-`/`abab`→minimax · `llama`/`mistral`/`phi`/`gemma`/`mixtral`/`codellama`→ollama.

**Tab-completion (PR #166):** inside the REPL, type `/model ` and press **Tab** for a `provider/model` picker — one default per provider, plus a two-level `litellm/<backend>/<model>` tree you can drill into. Completions appear as you type when `prompt_toolkit` is present (now a core dependency, so always); otherwise readline serves them on Tab.
Expand Down
136 changes: 130 additions & 6 deletions cheetahclaws/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ class TurnDone:
class PermissionRequest:
description: str
granted: bool = False
# Coarse key for a session-scoped "don't ask again" grant, e.g.
# "Bash:git push" or "Edit:/repo/src/app.py". Empty when the front end
# should offer approve/reject only. See agent._permission_signature.
signature: str = ""

@dataclass
class QuotaPause:
Expand Down Expand Up @@ -489,7 +493,10 @@ def run(
if config.get("permission_mode") == "plan":
permitted = False
else:
req = PermissionRequest(description=_permission_desc(tc))
req = PermissionRequest(
description=_permission_desc(tc),
signature=_permission_signature(tc),
)
yield req
permitted = req.granted
permissions[tc["id"]] = permitted
Expand Down Expand Up @@ -682,6 +689,115 @@ def _exec_one(tc):

# ── Helpers ───────────────────────────────────────────────────────────────

# Tools that mutate only CheetahClaws' own session state — task lists, saved
# memories, loaded skill text, a sleep timer. They touch nothing in the
# user's repo, reach no network, and are undone by editing the same store, so
# prompting for them is pure friction. Anything that can reach the user's
# files, the shell, or the outside world stays off this list — notably Agent
# (a sub-agent runs its own tool loop) and MemoryDelete (destroys user data).
_SELF_STATE_TOOLS = frozenset({
"TaskCreate", "TaskUpdate", "MemorySave", "Skill", "SleepTimer",
"EnterPlanMode", "ExitPlanMode", "AskUserQuestion",
})


def _tool_is_read_only(name: str) -> bool:
"""True when the registry marks this tool as never mutating state.

Read from the registry rather than a hardcoded name list so every
read-only tool — built-in, plugin, or module (Task/Memory/Skill queries,
document readers, browser reads) — is auto-approved on the same rule, and
a new one is covered the day it is registered. Unknown tools (MCP,
third-party) default to False and still prompt.
"""
try:
from cheetahclaws.tool_registry import get_tool
tdef = get_tool(name)
return bool(tdef and tdef.read_only)
except Exception:
return False


def _creates_new_workspace_file(tc: dict, config: dict) -> bool:
"""True for a Write that creates a *new* file inside the workspace.

Creating a file destroys nothing: there is no prior content to lose and
the file is inside the directory the session is already working in, so
prompting for it is friction without a decision behind it. Overwriting
an existing file, or writing anywhere outside the workspace, still asks.

Excluded regardless: any path with a dot-prefixed component
(``.git/hooks/pre-commit``, ``.github/workflows/*``, ``.env``) — those are
configuration and hook locations that get executed or trusted by other
tools, which makes creating one a decision the user should see.
Disable the whole rule with ``/config auto_create_files=false``.
"""
if not config.get("auto_create_files", True):
return False
path = (tc.get("input") or {}).get("file_path") or ""
if not path:
return False
try:
from pathlib import Path as _Path
target = _Path(path).expanduser()
if not target.is_absolute():
target = _Path.cwd() / target
target = target.resolve()
if target.exists():
return False # overwriting real content → ask
root = _Path(config.get("allowed_root")
or config.get("_worktree_cwd")
or _Path.cwd()).resolve()
rel = target.relative_to(root) # ValueError → outside the workspace
except Exception:
return False
return not any(part.startswith(".") for part in rel.parts)


def _permission_signature(tc: dict) -> str:
"""Stable key for a 'don't ask again this session' grant.

Deliberately coarser than the exact call so a grant is actually useful,
but never so coarse that it covers a different kind of action:

Bash → the program + its subcommand ("git push", "pytest"), so repeat
runs of the same command with different arguments are covered
Write/Edit/NotebookEdit → the specific file, so approving one file
never approves another
other → the tool name
"""
name = tc["name"]
inp = tc.get("input") or {}
if name == "Bash":
cmd = (inp.get("command", "") or "").strip()
try:
import shlex
parts = shlex.split(cmd)[:2]
except ValueError:
parts = cmd.split()[:2]
if not parts:
return "Bash"
prog = os.path.basename(parts[0])
_MULTI = {"git", "npm", "pnpm", "yarn", "pip", "pip3", "uv", "cargo",
"go", "docker", "kubectl", "make", "poetry", "conda",
"systemctl", "brew", "gh"}
if prog in _MULTI and len(parts) > 1 and not parts[1].startswith("-"):
return f"Bash:{prog} {parts[1]}"
return f"Bash:{prog}"
path = inp.get("file_path") or inp.get("notebook_path")
if path:
return f"{name}:{os.path.abspath(path)}"
return name


def _session_approved(tc: dict, config: dict) -> bool:
"""True if the user already granted this signature for the session."""
try:
return _permission_signature(tc) in runtime.get_ctx(config).approved_sigs
except Exception:
return False


def _check_permission(tc: dict, config: dict) -> bool:
"""Return True if operation is auto-approved (no need to ask user)."""
perm_mode = config.get("permission_mode", "auto")
Expand Down Expand Up @@ -709,23 +825,31 @@ def _check_permission(tc: dict, config: dict) -> bool:
return False
if name == "Bash":
from cheetahclaws.tools import _is_safe_bash
return _is_safe_bash(tc["input"].get("command", ""))
return _is_safe_bash(tc["input"].get("command", ""), config)
return True # reads are fine

# Already granted for this session by answering "s" at an earlier prompt.
if _session_approved(tc, config):
return True

# "accept-edits" mode: same as "auto", but file edits are pre-approved.
# Bash and everything else still follow the auto rules below, so a
# non-allow-listed shell command is still prompted (and the hard denylist
# still applies at execution time).
if perm_mode == "accept-edits" and name in ("Write", "Edit", "NotebookEdit"):
return True

# "auto" mode (and accept-edits fall-through): only ask for writes and non-safe bash
if name in ("Read", "Glob", "Grep", "WebFetch", "WebSearch"):
# "auto" mode (and accept-edits fall-through): prompt only for actions
# that can change the user's files, run arbitrary code, or reach outside
# the session — reads and self-state updates run straight through.
if _tool_is_read_only(name) or name in _SELF_STATE_TOOLS:
return True
if name == "Write" and _creates_new_workspace_file(tc, config):
return True
if name == "Bash":
from cheetahclaws.tools import _is_safe_bash
return _is_safe_bash(tc["input"].get("command", ""))
return False # Write, Edit → ask
return _is_safe_bash(tc["input"].get("command", ""), config)
return False # Write, Edit, and anything unclassified → ask


def _permission_desc(tc: dict) -> str:
Expand Down
Loading
Loading