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.
127 lines
8.6 KiB
Markdown
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).
|