What Changed
- Apr 18, 2026 (v3.05.75): External plugin discovery via
CHEETAHCLAWS_PLUGIN_PATH+ safer dependency management; end-to-end prompt-cache token tracking across providers-
PluginScope.EXTERNAL— new scope for plugins discovered in-place (never copied to~/.cheetahclaws/plugins/). Complements existingUSERandPROJECTscopes. Use case: shared team/company plugin directories mounted at a common path. -
CHEETAHCLAWS_PLUGIN_PATHenv var — colon-separated (os.pathsep) list of directories scanned for plugin subdirs. Each immediate subdirectory that has aplugin.jsonorPLUGIN.mdis surfaced as an external plugin. No new manifest format — reuses the existingPluginManifest.from_plugin_dir()loader. Missing or empty path segments are ignored; hidden directories (.git,.DS_Store, etc.) are skipped. -
Default disabled — external plugins land in
/pluginlist as[external] disabled. User must run/plugin enable <name>once to activate. Enable state persists to~/.cheetahclaws/plugins.jsonunder a newexternal_enabled: {name: bool}map, so it survives restarts without the plugin being installed. -
No silent pip install — unlike the original proposal in #49, cheetahclaws never installs plugin dependencies from an import-failure fallback. Dependency installation happens only at explicit user-consent points:
/plugin install(existing flow), or the first/plugin enableof an external plugin that declaresdependencies. The model cannot trick the runtime into mutating the Python environment. -
Dependency check uses
importlib.metadata.distribution()— new_missing_dependencies(deps)helper keys off the PyPI distribution name, notfind_spec(name). This fixes the PyPI-vs-import-name trap that breaks common packages:Pillow(imports asPIL),PyYAML(imports asyaml),opencv-python(cv2),scikit-learn(sklearn),beautifulsoup4(bs4). The oldfind_spec("pillow")approach returnedNonefor installed Pillow and would loop-install forever. -
Safety guards —
uninstall_pluginon anEXTERNALentry only drops the enable-state record; it nevershutil.rmtrees the user's source directory.update_pluginrefuses external plugins with "update the source directory directly" instead of attemptinggit pull. Malformedplugin.jsonfiles are logged to stderr and skipped, so one bad manifest can't crash/pluginlist. -
Dedupe on name collision — if a plugin name exists in both installed (
USER/PROJECT) and external scopes, the installed entry wins. Within external scopes, the earliest directory inCHEETAHCLAWS_PLUGIN_PATHwins (consistent with$PATHsemantics). -
Tests (
tests/test_plugin_external.py) — 16 tests covering: env var parsing with empty/nonexistent segments,plugin.jsonandPLUGIN.mddiscovery, hidden-directory skip, malformed-JSON resilience, path-order priority, installed-shadows-external dedupe, enable/disable persistence round-trip, PEP 508 requirement parsing (package[extra]>=1.0→package), and a regression test for the PyPI-vs-import-name bug. -
New public export —
from plugin import PLUGIN_PATH_ENVgives the env var name for use in tooling/docs. -
Not changed: existing
USER/PROJECTinstall flow,plugin.json/PLUGIN.mdmanifest format,/plugincommand subcommands. Fully backward compatible — unsetCHEETAHCLAWS_PLUGIN_PATHand the system behaves exactly as before. -
Fix (tool-history integrity for OpenAI-compatible providers) — resolves #57: after long sessions, DeepSeek (and other OpenAI-compatible endpoints) started rejecting requests with
"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'"(HTTP 400), only recoverable by rebooting which lost all context. Root cause:compaction.find_split_point()chose a split index by token count alone, so a split could land between anassistant(tool_calls)message and itstoolresponse messages, leaving orphanedtoolentries in the kept half. Three-layer defense:compaction._respect_tool_pairs(messages, split)— post-processes the split index: if the last message in the old half is anassistantwithtool_calls, advances the split forward past all consecutivetoolresponses; also skips any standalonetoolmessage the split would land on. Falls back to returning0(skip compaction this turn) if no safe split exists — the threshold will re-trigger next turn.compaction.sanitize_history(messages)— single-pass O(n) invariant enforcer. Tracks pendingtool_call_ids from the most recentassistant(tool_calls)in a rolling set; drops anytoolmessage whosetool_call_idis not in the set (orphan), and strips unansweredtool_callsentries from assistant messages when a non-tool message intervenes. If alltool_callson an assistant are stripped, thetool_callskey is removed entirely andcontentis normalized to a non-null string (required by the OpenAI schema). Does not mutate input.agent.run()— callssanitize_historyafter everymaybe_compactand before eachstream()call. Any divergence (from compaction, crashed tool execution, checkpoint restore, or future code paths) is caught before it reaches the provider; emits ahistory_sanitizedwarn-log with the number of messages removed so regressions are visible.- Why three layers instead of one: the split-point fix prevents the primary source of orphans; the sanitizer is a defense-in-depth net that keeps the invariant regardless of where history corruption originates; the agent-loop wiring ensures the net is actually applied. No user-visible behavior change on well-formed histories —
test_well_formed_history_unchangedpins this. - Tests (
tests/test_compaction.py) — 15 new tests across three classes (TestFindSplitPoint.test_split_never_splits_tool_pair,TestRespectToolPairs× 4,TestSanitizeHistory× 7) covering split-boundary edge cases (split at every ratio from 0.2 to 0.5, multi-tool-call blocks, standalone orphan tool at split), sanitizer correctness (well-formed history unchanged, orphan drop, partial and full unanswered-tool_calls stripping, unanswered at end of list, wrongtool_call_iddrop), and an input-immutability guarantee.
-
End-to-end prompt-cache token tracking (closes #43) — cache hit/miss counters now flow from provider →
AgentState→ checkpoint snapshots across every supported provider family. Two new default-0 fieldscache_read_tokens/cache_write_tokensonAssistantTurn;AgentState.total_cache_read_tokens/total_cache_write_tokensaccumulate viagetattr(..., 0)so providers that never set the fields still work. Extraction centralized into two helpers inproviders.py:_anthropic_cache_tokens(usage)readscache_read_input_tokens+cache_creation_input_tokens;_openai_cached_read_tokens(usage)walksprompt_tokens_details.cached_tokens. Both coerce missing /Noneto0— older SDKs, non-cached calls, Bedrock-over-litellm wrappers all fall through instead of raisingAttributeError. Provider coverage:Family Cache read Cache write Mechanism Anthropic ( stream_anthropic)✓ ✓ Both fields on final.usagewhen prompt-caching beta is activeOpenAI-schema ( stream_openai_compat— OpenAI, Gemini, Kimi, Qwen, Zhipu, DeepSeek, MiniMax, Groq, xAI, any compatible endpoint)✓ 0 (by design) OpenAI's schema has no separate "cache creation" counter; caching is implicit on their side Ollama ( stream_ollama)0 0 No prompt-caching in Ollama today Any future / custom provider 0 (default) 0 (default) getattr(event, "cache_read_tokens", 0)no-op fallbackPersistence:
checkpoint/store.make_snapshotwritestoken_snapshot["cache_read"]/["cache_write"];/checkpoint <id>(and/rewind) restores them alongside input/output totals so counters stay in lock-step with whatever snapshot the user rewound to. Structured logging:api_call_donerecords now includecache_read_tokens/cache_write_tokensalongsidein_tokens/out_tokens. Note: not yet surfaced in/costor/statusoutput — the tracking layer landed first, a follow-up will expose it in the user-facing commands. -
Tests (
tests/test_cache_tokens.py) — 14 tests across 5 layers:AssistantTurnfield defaults + explicit values;AgentStateaccumulation across increments; realmake_snapshotontmp_pathwith all four token fields; Anthropic + OpenAI extraction helpers against synthetic usage objects (populated / missing / None); end-to-endagent.runwith a scripted stream — single-turn propagation and multi-turn accumulation; plus atest_rewind_restores_cache_tokens_from_snapshotregression test that asserts the round-trip.tests/e2e_checkpoint.pyupdated to keep the scripted rewind path in sync with production code. -
Version bumped to 3.05.75.
-