Files
VolcanicAgenticCore/docs/claude-code-provider.md
EugeneTes 094d518827 fix(claude-code): disable built-in plugins and learn new ones from init
Claude Code 2.1.287 loads cc-plugin-* built-ins regardless of --setting-sources,
which tripped the fail-closed init check. Disable every known built-in in
--settings, and when init lists only built-ins that slipped through, disable them
process-wide and replay the request once. Other plugins still fail closed.
2026-10-02 10:08:23 +00:00

127 lines
8.6 KiB
Markdown

# Claude Code provider
`Volcanic.AgenticCore.Extensions.ClaudeCode` lets the core use a **Claude subscription** through the locally
installed `claude` CLI (Claude Code) instead of an Anthropic API key. It is a C# port of
[chem/pi-claude-code-provider](https://github.com/chem/pi-claude-code-provider); read that project's
`DESIGN.md` for the full reasoning behind each decision.
## Requirements
- `claude` (Claude Code) 2.1.x on `PATH` (or `VOLCANIC_CLAUDE_CODE_PATH`), logged in with a claude.ai
subscription (`claude auth status` must report `loggedIn: true`, `authMethod: claude.ai`,
`apiProvider: firstParty`).
- The .NET runtime (`dotnet`) on `PATH` or `Environment.ProcessPath`: `claude` spawns the bridge as
`dotnet Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll`.
- None of `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, `CLAUDE_CODE_OAUTH_TOKEN`,
`CLAUDE_CODE_USE_BEDROCK`, `CLAUDE_CODE_USE_VERTEX` in the host environment: the extension refuses to run
with them, because it is designed for subscription auth only.
Register it and pick a model:
```csharp
o.AddExtension<ClaudeCodeProviderExtension>();
o.DefaultModel = "claude-code/sonnet"; // or opus, haiku, fable, or the pinned claude-opus-5-5
```
Run `/claude-code-doctor` in a session to check the installation, auth, required CLI flags and the bridge
handshake. It costs no quota. If it reports "not logged in", run `/claude-code-login` (see below) or `claude
auth login` on the host.
## How it works
Every model call spawns a fresh `claude -p` process. Nothing is resumed on Claude's side: the whole
conversation is replayed each time.
1. The active tools are written to a JSON catalogue; each tool becomes an MCP tool `mcp__volcanic__<alias>`.
2. `claude` is started with `--tools ""` (all built-in tools off), `--permission-mode dontAsk`,
`--strict-mcp-config --mcp-config <bridge>`, `--input-format stream-json --output-format stream-json
--include-partial-messages --verbose`, `--system-prompt-file <tmp>`, `--model <id>`, `--effort <level>`,
plus settings that disable hooks, auto-memory, plugins, slash commands and the token reminder (which would
defeat prompt caching). Only an allow-list of environment variables is passed through, plus `TZ` set to
the session's time zone (the chat's zone on Telegram, read per call so a `/timezone` change applies from
the next model call; never the host's own `TZ`, and not on Windows, which ignores it), and compaction
inside Claude is disabled: the core owns compaction. `claude` and its login/doctor processes don't go
through the host's process spawn policy (`AgenticCoreOptions.ProcessEnvironment`): they already get a
replaced, allow-listed environment. The Bridge inherits `claude`'s environment, and the core tools it
advertises (such as `bash`) run in the core, so they go through the policy like any other tool call.
3. The bridge (a tiny console app) is the MCP server. It answers `initialize` and `tools/list` with the
catalogue and touches a "ready" marker file. It **never executes** anything: `tools/call` returns an error
and touches a "violation" marker that fails the request.
4. The transcript is sent as one stream-json user message: a header record, then one JSON record per
message (user, assistant with text/thinking/toolCall, toolResult). `@` characters are escaped because
Claude expands `@path` references. A cache breakpoint is placed on the last record so Claude's prompt
cache keeps hitting as the conversation grows. Images are written to a per-session content-addressed store
and referenced as `@"path"` attachments.
5. Claude's stream-json output is mapped to the core's streaming events. When the model asks for a tool
(`message_delta.stop_reason == "tool_use"`), Claude would deny it itself (dontAsk mode), so the provider
terminates the process immediately and publishes `Done(ToolUse)`. The core executes the tool and the next
call replays the transcript including the result.
6. The `system/init` record is validated strictly: the exposed tools, `permissionMode`, empty slash commands,
`apiKeySource == "none"` (proof of subscription auth) and a connected `volcanic` MCP server. Anything else
fails closed, with one exception: built-in plugins (`<name>@builtin`, such as `cc-plugin-*`) load whatever the
setting sources say, and which ones load depends on the CLI version and the account's feature flags. The
settings disable every known built-in (`BuiltinPlugins.Known`); when the init lists only built-ins that weren't
disabled, the provider adds them to the process-wide `BuiltinPlugins.Shared` set and replays the request once.
Nothing is published before the replay. A built-in that still loads, or any other plugin, fails the request.
## Session expiry and re-login
A `claude` subscription session can expire mid-use (a 401, or the CLI's own "Please run /login" / "Invalid API
key" wording). The provider detects this from the stream and, given somewhere to send the owner a link, logs
back in without failing the request outright:
1. The failing request pauses instead of erroring, as long as nothing has reached the caller yet (no partial
reply, no tool call).
2. If a `ClaudeCodeProviderOptions.LoginUi` is configured, its `IUserInterface` gets one input prompt whose
title carries the steps and the `claude auth login --claudeai` link and asks for the code shown on the page
("Paste the Claude login code"). Link and prompt are one message, so they can't arrive out of order.
3. `ClaudeLoginCoordinator` keeps this to one flow at a time: concurrent expired requests all wait on the same
login instead of racing the CLI. A request whose `claude` run started before a login completed, but whose
expiry is only reported afterwards, replays straight away instead of starting a second login (the coordinator
counts successful logins in `Generation`).
4. A wrong code gets a fresh link, up to 3 attempts; the owner has 10 minutes to answer each prompt
(`ClaudeLoginOptions.CodeTimeout`), and resolving where the owner is has 30 seconds
(`ClaudeLoginOptions.OwnerTimeout`) before they count as unreachable.
5. On success the paused request replays exactly once; a failure there (including expiring again immediately)
is published as-is, so there is no retry loop.
6. **`LoginUi` is null** (the default, e.g. headless hosts): no login flow runs. The request fails with
`[auth] Claude Code session expired; run "claude auth login" on the host (or /claude-code-login from a chat
UI)`.
Run `/claude-code-login` in any session with a UI to sign in (or switch accounts) through that session's own
dialog, with no owner redirect — useful even before the session has expired. If a login is already running
elsewhere (another chat's expired request), the command waits for it and reports its outcome. Without a UI it
explains that it needs one instead of failing silently.
`ClaudeCodeProviderOptions` carries the pieces a host wires up: `LoginUi` (where the link goes),
`Coordinator` (defaults to the process-wide `ClaudeLoginCoordinator.Shared`), and `LoginOptions` (the timeouts
and attempt budget above). The Telegram host points `LoginUi` at the first `AllowedUserIds` entry's private chat.
## Limitations (inherited from the design)
- Full-history replay costs more context than the Messages API and cannot replay thinking signatures.
- Claude adds its own identity line and an environment block to every request; a Claude Code upgrade
invalidates the prompt cache.
- Claude still runs `git ls-files` / `rg --files` in the working directory at startup (slow in huge repos) and
reads `.claude/settings*.json` there.
- Mid-stream API recovery (`api_retry`, message id changes) fails the request as `stream_interrupted`; the
core's retry policy retries it.
- Cost is always reported as 0. Cancelling may still consume quota.
- Windows: process-tree termination is best effort.
## Configuration
| Setting | Where | Default |
|---|---|---|
| CLI path | `VOLCANIC_CLAUDE_CODE_PATH` or `ClaudeCodeProviderOptions.ClaudePath` | `claude` on PATH |
| Idle timeout | `VOLCANIC_CLAUDE_CODE_IDLE_TIMEOUT_MS` | 5 min |
| Total timeout | `VOLCANIC_CLAUDE_CODE_TOTAL_TIMEOUT_MS` | 30 min |
| Thinking | session thinking level → `--effort` (`low`…`max`), not sent for `haiku` | model default |
## Errors
Provider failures surface as assistant error messages with a code prefix, for example
`[executable_missing] ...`, `[auth] ...`, `[mcp_startup] ...`, `[protocol_init] ...`. Two are special:
`stream_interrupted` messages contain `stream ended before message_stop` (retried automatically), and context
overflow messages start with `context_length_exceeded:` (the core compacts and retries once).