diff --git a/README.md b/README.md
index c30a4823..0dd09053 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@ See the official [plugin documentation](https://www.appdevforall.org/codeonthego
| Plugin | Purpose |
| -------------------------------------------------- | ----------------------------------------------------------------- |
+| [`plugins/AI-Agent-Claude/`](plugins/AI-Agent-Claude/) | Adds Anthropic's Claude as an inference backend for AI Core. |
| [`plugins/AI-Agent-Gemini/`](plugins/AI-Agent-Gemini/) | Adds Google Gemini as an inference backend for AI Core. |
| [`plugins/AI-Agent-Local/`](plugins/AI-Agent-Local/) | Runs GGUF models on the device itself, with no network access. |
| [`plugins/AI-Agent-MCP/`](plugins/AI-Agent-MCP/) | Gives the AI Core agent the tools of Model Context Protocol servers. |
diff --git a/plugins/AI-Agent-Claude/.gitignore b/plugins/AI-Agent-Claude/.gitignore
new file mode 100644
index 00000000..5380c6d5
--- /dev/null
+++ b/plugins/AI-Agent-Claude/.gitignore
@@ -0,0 +1,3 @@
+**/.cxx/
+build-output.log
+**/.kotlin/
diff --git a/plugins/AI-Agent-Claude/README.md b/plugins/AI-Agent-Claude/README.md
new file mode 100644
index 00000000..22657b28
--- /dev/null
+++ b/plugins/AI-Agent-Claude/README.md
@@ -0,0 +1,143 @@
+# AI Agent Claude plugin for CodeOnTheGo
+
+Claude inference for CodeOnTheGo's AI plugins. Registers itself as the `claude`
+backend with [`ai-core`](../AI-Core/)'s `LlmInferenceService`, which is what
+`ai-core`'s Agent chat, `Code-Suggestions` and `Speech-to-Text` talk to.
+
+It speaks Anthropic's Messages API, `POST https://api.anthropic.com/v1/messages`,
+with the agent's tools declared natively (`tools[]` out, `tool_use` blocks in).
+The endpoint is fixed: unlike the OpenAI backend there is no family of
+compatible servers to point it at.
+
+Calls the API directly over `HttpURLConnection` rather than Anthropic's Java
+SDK: plugins run in the host IDE's classloader, which resolves `okhttp3` to the
+host's older OkHttp first, and an SDK bundling its own copy crashes generation
+with a `NoSuchMethodError`.
+
+**No embeddings.** Anthropic has no embeddings endpoint, so this backend does not
+implement `EmbeddingBackend` and `Vector-Search` needs `ai-agent-openai` or
+`ai-agent-gemini` for indexing.
+
+## Building
+
+Prerequisites: Android SDK (API 33+), JDK 17. Create `local.properties` with
+`sdk.dir=...`. This plugin uses the shared wrapper at the repo root:
+
+```bash
+cd plugins/AI-Agent-Claude
+../../gradlew assemblePlugin # release -> build/plugin/ai-agent-claude.cgp
+../../gradlew assemblePluginDebug # debug variant
+../../gradlew testDebugUnitTest # the JVM unit tests
+```
+
+## Configuration
+
+Everything is configured in **AI Core → Agent settings**, on the pane this plugin
+contributes: API key, model, and one **Test Connection & List Models** button.
+Nothing outside this plugin handles the key. Listing models and testing the key
+are the same `GET /v1/models`, so they are one control, and the model is a
+single editable dropdown: type any id, or pick one the key can use.
+
+## Request shape
+
+`ClaudeRequestBuilder` owns it. Most rules below exist because the alternative is
+a 400:
+
+- **History is text.** AI Core hands tool results back as user text and never the
+ assistant `tool_use` block they answer, so they are sent as user text rather
+ than `tool_result` blocks, which the API rejects without a matching call.
+ Consecutive same-role turns are merged and blank turns dropped; `SYSTEM` turns
+ join the top-level `system`. Because no thinking block is ever replayed, the
+ preserved-thinking history check never applies.
+- **Two kinds of turn.** A streamed turn is the agent's: it asks for
+ `thinking: {type: "adaptive"}` where the model takes it (on Opus 4.x and
+ Sonnet 4.6 an omitted `thinking` means none), effort `high`, and a 64K
+ budget, since thinking counts against `max_tokens`. A turn that is not
+ streamed is a small job (a chat title, an inline suggestion): no `thinking`,
+ effort `low`, and the caller's own budget, raised to 4K only on the 5.x models,
+ which think whether asked to or not.
+- **Never `temperature`:** it is a 400 on current Opus and Sonnet models.
+- **Per-model fields** come from `ClaudeModelTraits`, which reads what the live
+ catalog said each model accepts: output cap, adaptive thinking, effort. The
+ settings pane stores that with the model list. For a model the catalog has not
+ described, allow-lists decide, so an unknown model gets a bare request rather
+ than a field it may reject. `fallbacks: "default"` with the
+ `server-side-fallback-2026-07-01` beta goes only to the models whose safety
+ classifiers can decline a request (Fable 5.1, Opus 5.5, Opus 5, Sonnet 5.5).
+- **Aliases and dated ids are the same model.** The catalog lists some models
+ only by dated id (`claude-haiku-4-5-20251001`), so a saved alias is matched to
+ it rather than retired.
+- **`required_tool` is ignored.** Forced `tool_choice` is a 400 on current
+ models, and the contract lets a backend that cannot force a call ignore it.
+- **Tools are not `strict`.** Strict mode needs closed schemas, which contributed
+ MCP schemas rarely are.
+- **Top-level `cache_control`** caches the system prompt and tool list an agent
+ run re-sends every turn.
+
+## Failures
+
+- **`stop_reason: "refusal"`** is checked before anything else: the turn is
+ reported as declined, and a tool call inside it is not run.
+- **A server-side fallback** drops any tool call the declined model began, since
+ the model that takes over never saw it. A tool call cut off by `max_tokens`
+ is dropped too, even when its input happens to parse.
+- **Stop** cancels the turn and closes its socket at once, so the API stops
+ generating rather than running on until its next line.
+- **A stream that goes silent** past the 120 s read timeout is reported as
+ Claude stopping mid-reply, not as a lost connection.
+- **Overload and rate limits** (408, 409, 429, 500, 502, 503, 504, 529) are
+ retried twice with backoff,
+ honouring `retry-after` up to 10 s, but only before a stream has delivered
+ anything, so a retry never repeats text on screen (`TransientRetry`).
+- An `error` event inside a 200 stream is raised as the HTTP status its type
+ stands for, so it classifies and retries like the same failure on the status
+ line.
+- `ClaudeErrorFormatter` turns every failure into one translated sentence; a raw
+ JSON body never reaches the transcript. An empty credit balance arrives as a
+ 400 whose message names it, and is reported as billing, not as a bad request.
+
+## API key handling
+
+Stored encrypted with the IDE's `KeystoreSecretStore` and sent as an `x-api-key`
+**header**, never in a URL. `security/SecureApiKeyStore.kt` holds only this
+plugin's Keystore alias (`cotg_ai_claude_key_v1`), unique so that another plugin's
+invalidated-key recovery cannot delete this one's key. A key is checked against
+`/v1/models` before it is saved; one that cannot be checked because the device is
+offline can be saved anyway and is marked unverified.
+
+**Keys outside a workspace.** Such a key is refused with a 400 on every request
+until it carries an `anthropic-workspace-id` header. The plugin cannot look the
+id up (listing workspaces needs an admin key), so when the key check reports
+this, the pane shows a **Workspace ID** field and says where to find the id
+(the Claude Console's **Workspaces** page; ids start with `wrkspc_`); the id is saved with the key,
+sent on every request, and removed with the key. A key in a workspace never sees
+the field. Typed ids are checked by `WorkspaceIds` before they become a header:
+one printable token, no whitespace or line breaks.
+
+## Installation
+
+Install **`ai-core` as well**; without the router this plugin has nothing to
+register with. Order does not matter: this plugin re-registers when it sees
+ai-core activate. Copy `build/plugin/ai-agent-claude.cgp` to the device, install
+via CodeOnTheGo's Plugin Manager, then restart the IDE.
+
+## Key classes
+
+- `plugin/ClaudePlugin.kt` — entry point; registers the backend with ai-core
+- `backend/ClaudeBackend.kt` — the conversation: streaming, retries, empty-reply diagnosis
+- `backend/ClaudeRequestBuilder.kt` — `messages[]` mapping and request JSON (pure)
+- `backend/ClaudeModelTraits.kt` — which optional fields each model accepts (pure)
+- `backend/ClaudeStreamEvent.kt` — one line of the event stream (pure)
+- `backend/TurnAssembler.kt` — folds a stream into a turn and decides how it ends (pure)
+- `backend/ClaudeToolProtocol.kt` — `tools[]` declaration and `tool_use` accumulation (pure)
+- `backend/TransientRetry.kt` — the retry schedule (pure)
+- `backend/ClaudeModelCatalog.kt` — reads `GET /v1/models` (pure)
+- `backend/ClaudeHttpClient.kt` — sockets, headers and timeouts
+- `errors/ClaudeErrorFormatter.kt` — turns a failure into one translated sentence
+- `prompt/ClaudeSystemPrompt.kt` — the system prompt this cloud model is given
+- `settings/` — the pane this backend contributes to the selector
+- `logging/` — `LOG_PREFIX` (`AiAgentClaude`), prefixing every logcat tag
+
+## License
+
+GPL-3.0 — same as AndroidIDE / CodeOnTheGo.
diff --git a/plugins/AI-Agent-Claude/addon.json b/plugins/AI-Agent-Claude/addon.json
new file mode 100644
index 00000000..8b4c3813
--- /dev/null
+++ b/plugins/AI-Agent-Claude/addon.json
@@ -0,0 +1,15 @@
+{
+ "summary": "Use Anthropic's Claude as the AI backend for the Agent. Requires the AI Core addon and a Claude API key.",
+ "description": "Connects the AI Core Agent to Anthropic's Claude API with your own API key, billed to your Claude Console account. The Agent's tools are declared to Claude directly, so it reads and edits your project through structured calls. Prompts and any file contents the Agent sends go to Anthropic.",
+ "tags": [
+ "ai",
+ "agent",
+ "claude"
+ ],
+ "origin": "appdevforall",
+ "license": "AGPL-3.0-or-later",
+ "author": {
+ "name": "App Dev For All",
+ "url": "https://www.appdevforall.org"
+ }
+}
diff --git a/plugins/AI-Agent-Claude/ai-agent-claude.html b/plugins/AI-Agent-Claude/ai-agent-claude.html
new file mode 100644
index 00000000..ecdedf69
--- /dev/null
+++ b/plugins/AI-Agent-Claude/ai-agent-claude.html
@@ -0,0 +1,91 @@
+
+
+
+
+
+AI Agent Claude Plugin
+
+
+
+
AI Agent Claude Plugin
+
+
Executive overview
+
AI Agent Claude adds Anthropic's Claude as an inference backend for
+ AI Core. With a Claude API key, the Agent chat, Code Suggestions and
+ Speech to Text can run on Claude Opus, Sonnet or Haiku, with the Agent's tools
+ declared natively so it edits projects through structured calls.
+
+
Core functionality
+
+
Streaming replies over Anthropic's Messages API.
+
Multi-turn conversations and native tool calling for the Agent.
+
Stop: an in-flight reply is cancelled and its connection closed.
+
A settings pane with key entry, Keystore-encrypted storage, a model
+ picker filled from the API, and a connection test.
+
Automatic retry when Claude is briefly overloaded or rate-limited.
+
Server-side fallback when a safety check declines a request on the
+ models that support it.
+
+
+
Technical architecture
+
+
Raw HttpURLConnection and org.json, no SDK: the
+ host loads plugin classes parent-first, and a bundled OkHttp would collide
+ with the host's.
+
Pure, unit-tested units for the request shape, the event stream, tool
+ calls, per-model parameters, retries and error classification.
+
The API key is encrypted with the IDE's KeystoreSecretStore
+ under this plugin's own alias.
+
+
+
Usage
+
+
Install AI Core and this plugin, then restart the IDE.
+
Open AI Core → Agent settings and select Claude.
+
Tap Get API Key, create a key in the Claude Console, paste it and
+ tap Save Key.
+
Optionally pick a model, then start a chat.
+
+
+
Cost
+
The Claude API is billed to prepaid credit, separate from a Claude.ai
+ subscription. The free alternatives are AI Agent Local and AI Agent
+ Gemini.
+
+
Limitations
+
+
No embeddings, so it cannot power Vector Search.
+
No web search yet.
+
+
+
diff --git a/plugins/AI-Agent-Claude/build.gradle.kts b/plugins/AI-Agent-Claude/build.gradle.kts
new file mode 100644
index 00000000..5dc9c0ea
--- /dev/null
+++ b/plugins/AI-Agent-Claude/build.gradle.kts
@@ -0,0 +1,80 @@
+plugins {
+ id("com.android.application")
+ id("com.itsaky.androidide.plugins.build")
+}
+
+pluginBuilder {
+ pluginName = "ai-agent-claude"
+}
+
+android {
+ namespace = "com.itsaky.androidide.plugins.aiagentclaude"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "com.itsaky.androidide.plugins.aiagentclaude"
+ minSdk = 33
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ isShrinkResources = false
+ signingConfig = signingConfigs.getByName("debug")
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ }
+ }
+
+ packaging {
+ resources {
+ excludes += setOf(
+ "META-INF/DEPENDENCIES",
+ "META-INF/LICENSE",
+ "META-INF/LICENSE.txt",
+ "META-INF/NOTICE",
+ "META-INF/NOTICE.txt",
+ "META-INF/INDEX.LIST"
+ )
+ }
+ }
+}
+
+dependencies {
+ compileOnly(files("../../libs/plugin-api.jar"))
+
+ // 'implementation' (not 'compileOnly') for the androidx/Material libraries: AAPT2 needs them
+ // at compile time to process the settings pane's layout, as in every CoGo plugin with XML.
+ implementation("androidx.appcompat:appcompat:1.6.1")
+ implementation("androidx.fragment:fragment-ktx:1.8.8")
+ implementation("com.google.android.material:material:1.10.0")
+ implementation("org.jetbrains.kotlin:kotlin-stdlib:2.3.21")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
+
+ testImplementation(files("../../libs/plugin-api.jar"))
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("io.mockk:mockk:1.13.8")
+ testImplementation("org.json:json:20231013")
+}
+
+// No SecureApiKeyStore parity check any more: the AES/GCM core is the host's KeystoreSecretStore
+// (plugin-api), so there is one implementation in the process rather than copies to keep in step.
+
+// AAR metadata checks are disabled by convention for these application-as-library plugins.
+tasks.matching {
+ it.name.contains("checkDebugAarMetadata") ||
+ it.name.contains("checkReleaseAarMetadata")
+}.configureEach { enabled = false }
diff --git a/plugins/AI-Agent-Claude/gradle.properties b/plugins/AI-Agent-Claude/gradle.properties
new file mode 100644
index 00000000..fcd58cda
--- /dev/null
+++ b/plugins/AI-Agent-Claude/gradle.properties
@@ -0,0 +1,10 @@
+android.enableJetifier=false
+android.jetifier.ignorelist=common-30.2.2.jar
+android.nonTransitiveRClass=false
+android.useAndroidX=true
+org.gradle.caching=true
+org.gradle.configureondemand=true
+org.gradle.jvmargs=-Xmx4096M -Dkotlin.daemon.jvm.options\="-Xmx4096M"
+org.gradle.parallel=true
+
+kotlin.code.style=official
diff --git a/plugins/AI-Agent-Claude/proguard-rules.pro b/plugins/AI-Agent-Claude/proguard-rules.pro
new file mode 100644
index 00000000..846005e9
--- /dev/null
+++ b/plugins/AI-Agent-Claude/proguard-rules.pro
@@ -0,0 +1,21 @@
+# AI Agent Claude Plugin ProGuard Rules
+
+# Keep plugin entry point
+-keep public class com.itsaky.androidide.plugins.aiagentclaude.plugin.ClaudePlugin {
+ public ;
+}
+
+# Keep the backend: its settings pane resolves it through ClaudePlugin.getBackend() to list
+# models and test a connection, and AI Core reaches it across the plugin classloader boundary.
+-keep public class com.itsaky.androidide.plugins.aiagentclaude.backend.ClaudeBackend {
+ public ;
+}
+
+# Keep the settings pane: it is named to the host as a string by
+# ClaudeBackend.getSettingsFragmentClassName() and instantiated reflectively.
+-keep public class com.itsaky.androidide.plugins.aiagentclaude.settings.ClaudeSettingsFragment {
+ public ;
+}
+
+# Keep plugin-api interfaces
+-keep interface com.itsaky.androidide.plugins.** { *; }
diff --git a/plugins/AI-Agent-Claude/settings.gradle.kts b/plugins/AI-Agent-Claude/settings.gradle.kts
new file mode 100644
index 00000000..43f3d863
--- /dev/null
+++ b/plugins/AI-Agent-Claude/settings.gradle.kts
@@ -0,0 +1,35 @@
+@file:Suppress("UnstableApiUsage")
+
+enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
+
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ google()
+ mavenCentral()
+ }
+}
+
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath(files("../../libs/plugin-api.jar"))
+ classpath(files("../../libs/gradle-plugin.jar"))
+ classpath("com.android.tools.build:gradle:9.3.1")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.21")
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ maven { url = uri("https://jitpack.io") }
+ }
+}
+
+rootProject.name = "ai-agent-claude"
diff --git a/plugins/AI-Agent-Claude/src/main/AndroidManifest.xml b/plugins/AI-Agent-Claude/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..2c1f2e6d
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/AndroidManifest.xml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/AI-Agent-Claude/src/main/assets/docs/index.html b/plugins/AI-Agent-Claude/src/main/assets/docs/index.html
new file mode 100644
index 00000000..3b2e31a4
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/assets/docs/index.html
@@ -0,0 +1,125 @@
+
+
+
+
+
+AI Agent Claude — Guide
+
+
+
+
AI Agent Claude — Anthropic's Claude for the Agent
+
+
AI Agent Claude is a headless plugin: it has no screens of its own,
+ only a settings pane inside AI Core. It adds the claude backend to
+ AI Core, which routes requests from the Agent chat, Code
+ Suggestions and Speech to Text. Install AI Core as well —
+ without it this plugin has nothing to register with.
+
+
Setup
+
+
API key — required. Tap Get API Key to open the Claude
+ Console in your browser, create a key (it starts with
+ sk-ant-), copy it, paste it into the field and tap
+ Save Key. The key is checked with Claude before it is saved.
+
Model — one field that is also a list: type any model id, or tap
+ it to pick one your key can use. Typing is saved when you leave the field.
+ The default is claude-opus-5-5, which suits building apps;
+ claude-fable-5-1 is more capable and costs more,
+ claude-sonnet-5-5 is faster and cheaper, and
+ claude-haiku-4-5 is fastest and cheapest.
+
Test Connection & List Models — checks the key without saving
+ it, and fills the model list from the same reply.
+
+
+
+ The key is stored encrypted with the Android Keystore, and is sent as an
+ x-api-key header — never in a URL, where it would leak into logs
+ and proxies.
+
+
+
How the Agent uses Claude
+
The Agent's tools — reading, searching and editing files, building and
+ running the app — are declared to Claude directly, so its calls arrive as
+ structured data rather than text that has to be written exactly right. A file
+ whose contents contain quotes or line breaks cannot break a call.
+
Most Claude models think before they answer (Haiku 4.5 does not). That
+ thinking is never shown, but it takes time, so a pause before the first word of
+ a reply is normal.
+
+
Cost
+
The Claude API is not free. It draws on prepaid credit in the Claude Console
+ and is separate from a Claude.ai subscription — paying for Claude.ai does
+ not give you API access. If you want to use the Agent without paying, use
+ AI Agent Local to run a small model on the device, or AI Agent
+ Gemini, which has a free tier.
+
+
Privacy
+
Prompts, and any file contents a plugin includes in them, are sent to
+ Anthropic over HTTPS. Your code leaves the device.
+
+
What it does not do
+
+
Semantic search. Anthropic has no embeddings service, so
+ Vector Search needs the OpenAI or Gemini backend to index a
+ project.
+
+
+
Troubleshooting
+
+
"Claude refused your API key" — the key is wrong or revoked;
+ re-enter it.
+
"Your Claude API credit balance is too low" — add credit in the
+ Claude Console.
+
"Claude is overloaded or unavailable" — Claude is busy. If no
+ reply had appeared yet, the request was already retried twice; try again in
+ a moment.
+
"Claude stopped sending its reply partway through" — Claude went
+ silent for two minutes. Try again, or ask for a smaller change.
+
"This key is not in a workspace" — Claude needs a workspace ID
+ with this key. A Workspace ID field appears under the key: workspace
+ IDs start with wrkspc_ and are listed in the Claude Console
+ under Workspaces. Paste the ID and tap Save Key again.
+
"Claude declined to answer this request" — a safety check stopped
+ the reply. Rephrase the request, or pick a different model.
+
"Claude has no model called …" — tap Test Connection & List
+ Models, then pick from the Model field.
+
The Claude backend never appears — AI Core isn't
+ installed or activated; install it and restart the IDE.
+
+
+
diff --git a/plugins/AI-Agent-Claude/src/main/assets/icon_day.png b/plugins/AI-Agent-Claude/src/main/assets/icon_day.png
new file mode 100644
index 00000000..bc79cfc5
Binary files /dev/null and b/plugins/AI-Agent-Claude/src/main/assets/icon_day.png differ
diff --git a/plugins/AI-Agent-Claude/src/main/assets/icon_night.png b/plugins/AI-Agent-Claude/src/main/assets/icon_night.png
new file mode 100644
index 00000000..b7bc692e
Binary files /dev/null and b/plugins/AI-Agent-Claude/src/main/assets/icon_night.png differ
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeBackend.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeBackend.kt
new file mode 100644
index 00000000..a760a9fa
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeBackend.kt
@@ -0,0 +1,674 @@
+package com.itsaky.androidide.plugins.aiagentclaude.backend
+
+import android.content.SharedPreferences
+import android.util.Log
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aiagentclaude.R
+import com.itsaky.androidide.plugins.aiagentclaude.errors.ClaudeErrorFormatter
+import com.itsaky.androidide.plugins.aiagentclaude.errors.ClaudeFailure
+import com.itsaky.androidide.plugins.aiagentclaude.errors.ClaudeFailureMessages
+import com.itsaky.androidide.plugins.aiagentclaude.errors.ClaudeHttpException
+import com.itsaky.androidide.plugins.aiagentclaude.errors.ClaudeStreamStalledException
+import com.itsaky.androidide.plugins.aiagentclaude.errors.CredentialFailureLog
+import com.itsaky.androidide.plugins.aiagentclaude.errors.isCredentialProblem
+import com.itsaky.androidide.plugins.aiagentclaude.logging.LOG_PREFIX
+import com.itsaky.androidide.plugins.aiagentclaude.preferences.ClaudePreferences
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.ClaudeSystemPrompt
+import com.itsaky.androidide.plugins.aiagentclaude.security.ApiKeyCache
+import com.itsaky.androidide.plugins.services.LlmInferenceService.*
+import java.io.BufferedReader
+import java.net.HttpURLConnection
+import java.net.SocketTimeoutException
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.coroutines.coroutineContext
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import org.json.JSONObject
+
+/**
+ * Tool-protocol tracing, under the tag suffix `ai-core` uses for the other half of the same run:
+ * `adb logcat -s AiCore.AgentTrace:V AiAgentClaude.AgentTrace:V` reads a run end to end.
+ *
+ * Through [Log] rather than `context.logger`, which the host funnels into its own class's tag with
+ * only a `[pluginId]` prefix — unfilterable, and so absent from a captured log of an agent run.
+ */
+private const val TAG = "$LOG_PREFIX.AgentTrace"
+
+/**
+ * Claude backend: Anthropic's Messages API, `POST /v1/messages`.
+ *
+ * What this class owns is the *conversation*: which model, which turns, what to do when the API
+ * is overloaded or a turn ends with nothing to show. Sockets are [ClaudeHttpClient]'s, the request
+ * shape is [ClaudeRequestBuilder]'s, how a stream becomes a turn is [TurnAssembler]'s, the
+ * decrypted key is [ApiKeyCache]'s, and the wording of a failure is [ClaudeFailureMessages]'.
+ *
+ * Not an [EmbeddingBackend]: Anthropic offers no embeddings endpoint, and a backend that claimed
+ * one would leave semantic search failing on every index build.
+ */
+class ClaudeBackend(
+ private val context: PluginContext
+) : HistoryCapableBackend, CancellableBackend, ConfigurableBackend, ToolCallingBackend {
+
+ private val scope = CoroutineScope(Dispatchers.IO)
+
+ private val http = ClaudeHttpClient()
+
+ private val keyCache =
+ ApiKeyCache(::claudePrefs, ClaudePreferences.KEY_API_KEY, context.logger, scope)
+
+ private val failureMessages = ClaudeFailureMessages(context)
+
+ /**
+ * Where a refused credential is left for the settings pane to report, so a key problem is not
+ * only readable in a transcript the user has already navigated away from.
+ */
+ private val credentialFailures = CredentialFailureLog(::claudePrefs)
+
+ /**
+ * One streamed turn in flight: its coroutine, and its socket once connected.
+ *
+ * The socket is held so Stop can close it at once. Cancelling the coroutine alone does not: a
+ * reader blocked on the stream only sees the cancellation when the next line arrives, and
+ * until then the model keeps generating, and billing, for a turn nobody will read.
+ */
+ private class ActiveStream {
+ @Volatile var job: Job? = null
+ @Volatile var connection: HttpURLConnection? = null
+ }
+
+ /**
+ * Every streamed turn in flight. A set rather than one field, so a request from another
+ * plugin cannot overwrite the agent's turn, which Stop would then fail to cancel. Requests
+ * that are not streamed are not here: Stop is for the stream, and those are cancelled through
+ * their own futures.
+ */
+ private val activeStreams = ConcurrentHashMap.newKeySet()
+
+ companion object {
+ /** Backend id, as persisted by AI Core when the user selects this backend. */
+ const val BACKEND_ID = "claude"
+
+ /** Default model. Editable on this backend's settings pane. */
+ const val DEFAULT_MODEL = "claude-opus-5-5"
+
+ /** The Claude API. Fixed: there is no compatible third-party server to point this at. */
+ const val BASE_URL = "https://api.anthropic.com/v1"
+
+ /** Messages endpoint, appended to [BASE_URL]. */
+ private const val MESSAGES_PATH = "/messages"
+ }
+
+ /** This plugin's own settings, written by its settings pane and read here at request time. */
+ private fun claudePrefs(): SharedPreferences? = try {
+ ClaudePreferences.of(context)
+ } catch (e: Exception) {
+ context.logger.error("ClaudeBackend: Error getting preferences", e)
+ null
+ }
+
+ /** The model to request, or the default when nothing is stored. */
+ private fun getModelName(): String =
+ claudePrefs()?.getString(ClaudePreferences.KEY_MODEL, DEFAULT_MODEL)
+ ?.trim()?.takeIf { it.isNotEmpty() }
+ ?: DEFAULT_MODEL
+
+ /**
+ * What the last live catalog said [model] accepts, or null when it has not described it.
+ * Written by the settings pane, which is the only place the catalog is fetched.
+ */
+ private fun knownCapabilities(model: String): ModelCapabilities? {
+ val stored = claudePrefs()?.getString(ClaudePreferences.KEY_MODEL_CAPABILITIES, null)
+ return ClaudeModelTraits.lookup(model, ClaudeModelCatalog.decodeCapabilities(stored))
+ }
+
+ /**
+ * Decrypt the stored key off-thread now, so a main-thread [isAvailable] can't report "no key"
+ * for a key that is there. Called once, on activation.
+ */
+ fun warmKeyCache() = keyCache.warm()
+
+ override fun getId(): String = BACKEND_ID
+
+ /** Falls back to a literal: an empty name would be an unlabelled row in the selector. */
+ override fun getName(): String = configLabel(R.string.claude_backend_name, fallback = "Claude")
+
+ /**
+ * Resolves a label against this plugin's own resources, degrading rather than throwing —
+ * [getName] is called across the plugin boundary.
+ *
+ * @param fallback returned when the lookup fails
+ */
+ private fun configLabel(resId: Int, fallback: String = ""): String = try {
+ context.androidContext.getString(resId)
+ } catch (e: Exception) {
+ context.logger.error("ClaudeBackend: could not resolve label $resId", e)
+ fallback
+ }
+
+ /**
+ * Written for a large cloud model; see [ClaudeSystemPrompt] for why the wording belongs here
+ * rather than with the caller.
+ */
+ override fun getSystemPrompt(request: SystemPromptRequest): String =
+ ClaudeSystemPrompt.build(request)
+
+ /** Null: this backend sends no `temperature`, which current Claude models reject outright. */
+ override fun getDefaultTemperature(): Float? = null
+
+ /**
+ * This backend draws its own settings, so the consumer needs no knowledge of API keys or
+ * model catalogs.
+ */
+ override fun getSettingsFragmentClassName(): String =
+ "com.itsaky.androidide.plugins.aiagentclaude.settings.ClaudeSettingsFragment"
+
+ /** Available once a key is stored: the Claude API has no anonymous access. */
+ override fun isAvailable(): Boolean {
+ val hasKey = readApiKeyOrBlank().isNotBlank()
+ context.logger.debug("ClaudeBackend.isAvailable() - API key configured: $hasKey")
+ return hasKey
+ }
+
+ override fun generate(prompt: String, config: LlmConfig): CompletableFuture =
+ generateBlocking(emptyList(), prompt, config)
+
+ override fun generateWithHistory(
+ history: List,
+ prompt: String,
+ config: LlmConfig
+ ): CompletableFuture {
+ context.logger.info("ClaudeBackend.generateWithHistory() called with ${history.size} messages")
+ return generateBlocking(history, prompt, config)
+ }
+
+ /**
+ * One request that is not streamed, completing with the whole reply.
+ *
+ * These are the small jobs — chat titles, inline suggestions — so [ClaudeRequestBuilder] keeps
+ * the caller's budget and asks for low effort, rather than an agent turn's.
+ *
+ * @param history the conversation so far, oldest first
+ * @param prompt the current user turn
+ * @param config its system prompt becomes the top-level `system`
+ */
+ private fun generateBlocking(
+ history: List,
+ prompt: String,
+ config: LlmConfig,
+ ): CompletableFuture {
+ val future = CompletableFuture()
+
+ val job = scope.launch {
+ val keyStamp = storedKeyStamp()
+ try {
+ val startTime = System.currentTimeMillis()
+ val conversation =
+ ClaudeRequestBuilder.conversation(history, prompt, config.systemPrompt)
+ val reply = requestReply(conversation, config)
+
+ when {
+ reply.stopReason == "refusal" ->
+ future.complete(LlmResponse.failure(failureMessages.of(ClaudeFailure.Refused)))
+
+ reply.text.isBlank() ->
+ future.complete(LlmResponse.failure(failureMessages.of(reply.emptyFailure())))
+
+ else -> {
+ val tokenCount = reply.text.split("\\s+".toRegex()).size // Approximate
+ context.logger.info("ClaudeBackend: Generated ${reply.text.length} chars, ~$tokenCount tokens")
+ future.complete(
+ LlmResponse.success(reply.text, tokenCount, System.currentTimeMillis() - startTime)
+ )
+ }
+ }
+ } catch (e: CancellationException) {
+ future.cancel(true)
+ throw e
+ } catch (e: Exception) {
+ context.logger.error("ClaudeBackend: Error generating response", e)
+ future.complete(LlmResponse.failure(formatErrorMessage(e, keyStamp)))
+ }
+ }
+ future.cancelJobOnCancel(job)
+
+ return future
+ }
+
+ override fun generateStreaming(
+ prompt: String,
+ config: LlmConfig,
+ callback: StreamCallback
+ ) {
+ streamTurn(emptyList(), prompt, config, emptyList(), callback.asToolCallback())
+ }
+
+ /**
+ * Streams a reply for a multi-turn conversation, sending [history] as real `messages[]` turns.
+ *
+ * @param history the conversation so far, oldest first
+ * @param prompt the current user turn
+ * @param config its system prompt becomes the top-level `system`
+ * @param callback receives tokens, completion, and errors
+ */
+ override fun generateStreamingWithHistory(
+ history: List,
+ prompt: String,
+ config: LlmConfig,
+ callback: StreamCallback
+ ) {
+ streamTurn(history, prompt, config, emptyList(), callback.asToolCallback())
+ }
+
+ /**
+ * Streams a turn with [tools] declared to the API, reporting each `tool_use` block through
+ * [ToolStreamCallback.onToolCall].
+ *
+ * This is the path the agent takes. Declaring the tools is what stops the model writing a call
+ * as prose the caller has to parse back: the input arrives already structured, so a file whose
+ * contents contain quotes or newlines can no longer break the call carrying it (ADFA-5410).
+ *
+ * @param prompt the current user turn
+ * @param history the conversation so far, oldest first
+ * @param config its system prompt becomes the top-level `system`
+ * @param tools the tools to declare; an empty list streams plain text
+ * @param callback receives tokens, tool calls, completion, and errors
+ */
+ override fun generateStreamingWithTools(
+ prompt: String,
+ history: List,
+ config: LlmConfig,
+ tools: List,
+ callback: ToolStreamCallback
+ ) {
+ streamTurn(history, prompt, config, tools, callback)
+ }
+
+ /**
+ * Adapts a plain stream callback to the tool-aware one [streamTurn] takes.
+ *
+ * @return a [ToolStreamCallback] that forwards every event and reports no tool calls
+ */
+ private fun StreamCallback.asToolCallback(): ToolStreamCallback = object : ToolStreamCallback {
+ override fun onToken(token: String) = this@asToolCallback.onToken(token)
+ override fun onToolCall(request: ToolCallRequest) = Unit
+ override fun onComplete(response: LlmResponse) = this@asToolCallback.onComplete(response)
+ override fun onError(error: String) = this@asToolCallback.onError(error)
+ }
+
+ /**
+ * Streams one Messages request.
+ *
+ * @param history the conversation so far, oldest first
+ * @param prompt the current user turn
+ * @param config supplies the system prompt and stop sequences
+ * @param tools the tools to declare, or empty to stream plain text
+ * @param callback receives tokens, tool calls, completion, and errors
+ */
+ private fun streamTurn(
+ history: List,
+ prompt: String,
+ config: LlmConfig,
+ tools: List,
+ callback: ToolStreamCallback
+ ) {
+ val stream = ActiveStream()
+ // Lazy, so the job is on record before it can run: a Stop that lands in between would
+ // otherwise find a stream with nothing to cancel.
+ val job = scope.launch(start = CoroutineStart.LAZY) {
+ val keyStamp = storedKeyStamp()
+ try {
+ val startTime = System.currentTimeMillis()
+ val model = getModelName()
+ val conversation =
+ ClaudeRequestBuilder.conversation(history, prompt, config.systemPrompt)
+ val body = ClaudeRequestBuilder.body(
+ conversation, model, stream = true, config, tools, knownCapabilities(model)
+ )
+ Log.i(
+ TAG,
+ "REQUEST | model=$model turns=${conversation.messages.length()} " +
+ "tools=${tools.size} " + tools.joinToString(",") { it.name }
+ )
+
+ var attempt: TurnAssembler? = null
+ val turn = TransientRetry.run(
+ delivered = { (attempt?.chunks ?: 0) > 0 },
+ onRetry = { e, retry, wait ->
+ context.logger.warn(
+ "ClaudeBackend: ${(e as? ClaudeHttpException)?.statusCode} from the API; " +
+ "retry $retry of ${TransientRetry.MAX_RETRIES} in ${wait}ms"
+ )
+ },
+ ) {
+ // A fresh assembler per attempt, so a retried turn cannot report a call twice.
+ TurnAssembler { chunk -> callback.onToken(chunk) }.also { assembler ->
+ attempt = assembler
+ streamOnce(body, ClaudeRequestBuilder.betas(model), stream, assembler)
+ }
+ }
+ val result = turn.finish()
+ Log.i(
+ TAG,
+ "STREAM | model=${turn.servedBy ?: model} chunks=${turn.chunks} " +
+ "dropped=${turn.droppedCalls} discardedAtFallback=${turn.discardedAtFallback} " +
+ "stop=${turn.stopReason}"
+ )
+
+ when (result) {
+ TurnAssembler.Result.Refused -> {
+ context.logger.warn("ClaudeBackend: the model declined this request")
+ callback.onError(failureMessages.of(ClaudeFailure.Refused))
+ }
+
+ is TurnAssembler.Result.Empty -> {
+ context.logger.warn(
+ "ClaudeBackend: stream produced no reply text " +
+ "(skipped=${turn.skippedLines}, thinkingBlocks=${turn.thinkingBlocks}, " +
+ "droppedCalls=${turn.droppedCalls}, stopReason=${turn.stopReason})"
+ )
+ callback.onError(failureMessages.of(result.failure))
+ }
+
+ is TurnAssembler.Result.Reply -> {
+ // Reported after the stream, so a call is never acted on before it is complete.
+ for (call in result.calls) {
+ Log.i(
+ TAG,
+ "FUNCTION_CALL | tool=${call.name} " +
+ "args=${call.args.orEmpty().keys.joinToString(",")}"
+ )
+ callback.onToolCall(call)
+ }
+ val tokenCount = result.text.split("\\s+".toRegex()).size
+ context.logger.info(
+ "ClaudeBackend: Streamed ${result.text.length} chars in ${turn.chunks} chunks, ~$tokenCount tokens"
+ )
+ callback.onComplete(
+ LlmResponse.success(result.text, tokenCount, System.currentTimeMillis() - startTime)
+ )
+ }
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ ensureActive()
+ Log.e(TAG, "STREAM | failed: ${e.message}", e)
+ context.logger.error("ClaudeBackend: Error in streaming", e)
+ callback.onError(formatErrorMessage(e, keyStamp))
+ }
+ }
+ stream.job = job
+ activeStreams += stream
+ // On completion rather than in a finally: a job in an already-closed scope never runs.
+ job.invokeOnCompletion { activeStreams -= stream }
+ job.start()
+ }
+
+ /**
+ * POST [body] and feed each line of the event stream to [turn].
+ *
+ * Tokens already delivered before a mid-stream failure stay delivered.
+ *
+ * @param betas the `anthropic-beta` values [body] needs
+ * @param stream where the live socket is published, so Stop can close it
+ * @throws ClaudeStreamStalledException when the stream goes silent past the read timeout
+ */
+ private suspend fun streamOnce(
+ body: JSONObject,
+ betas: List,
+ stream: ActiveStream,
+ turn: TurnAssembler,
+ ) {
+ // Hoisted: the reader below is an ordinary lambda, with no suspend context of its own.
+ val requestContext = coroutineContext
+ try {
+ postMessages(
+ body = body,
+ betas = betas,
+ sse = true,
+ onConnected = { conn -> stream.connection = conn },
+ ) { reader ->
+ try {
+ for (line in reader.lineSequence()) {
+ requestContext.ensureActive()
+ val event = ClaudeStreamEvent.parse(line)
+ if (event is ClaudeStreamEvent.Malformed) {
+ context.logger.warn("ClaudeBackend: skipping stream line: ${event.detail}")
+ }
+ if (event is ClaudeStreamEvent.FallbackSwitch) {
+ Log.i(TAG, "FALLBACK | continued on ${event.toModel}")
+ }
+ if (!turn.accept(event)) break
+ }
+ } catch (e: SocketTimeoutException) {
+ // The status line said 2xx, so this is the API going quiet, not the network.
+ throw ClaudeStreamStalledException(e)
+ }
+ }
+ } finally {
+ stream.connection = null
+ }
+ }
+
+ /** A whole reply read from a request that was not streamed. */
+ private class BlockingReply(val text: String, val stopReason: String?, val thinkingBlocks: Int) {
+ /** Which empty-reply case this is; the stream's rules, applied to a whole response. */
+ fun emptyFailure(): ClaudeFailure = when {
+ stopReason == "max_tokens" -> ClaudeFailure.TruncatedBeforeReply
+ thinkingBlocks > 0 -> ClaudeFailure.ReasoningOnly
+ else -> ClaudeFailure.EmptyReply(0)
+ }
+ }
+
+ /**
+ * POST [conversation] without streaming and read back the reply.
+ *
+ * @param config supplies the token cap and stop sequences
+ */
+ private suspend fun requestReply(
+ conversation: ClaudeRequestBuilder.Conversation,
+ config: LlmConfig,
+ ): BlockingReply {
+ val model = getModelName()
+ val body = ClaudeRequestBuilder.body(
+ conversation, model, stream = false, config, known = knownCapabilities(model)
+ )
+ return TransientRetry.run {
+ postMessages(body, ClaudeRequestBuilder.betas(model)) { reader ->
+ val text = try {
+ reader.readText()
+ } catch (e: SocketTimeoutException) {
+ throw ClaudeStreamStalledException(e)
+ }
+ parseBlockingReply(JSONObject(text))
+ }
+ }
+ }
+
+ /**
+ * Reads a whole Messages response.
+ *
+ * Content is read by block `type`, never by position: a response can open with thinking or
+ * fallback blocks before its text.
+ */
+ private fun parseBlockingReply(response: JSONObject): BlockingReply {
+ val content = response.optJSONArray("content")
+ var thinkingBlocks = 0
+ val text = buildString {
+ for (i in 0 until (content?.length() ?: 0)) {
+ val block = content?.optJSONObject(i) ?: continue
+ when (block.optString("type")) {
+ "text" -> append(block.optString("text"))
+ "thinking", "redacted_thinking" -> thinkingBlocks++
+ }
+ }
+ }
+ return BlockingReply(
+ text = text,
+ stopReason = response.optString("stop_reason").takeIf { it.isNotBlank() && it != "null" },
+ thinkingBlocks = thinkingBlocks,
+ )
+ }
+
+ /**
+ * POST [body] to the Messages endpoint with the stored credential.
+ *
+ * Every generation goes through here, streaming or not, which is why the recorded refusal is
+ * cleared here — on the status line, since a 2xx is the API accepting the key whether or not
+ * the body that follows is read to the end, or cancelled, or dropped mid-stream.
+ *
+ * @param betas the `anthropic-beta` values [body] needs
+ * @param sse true to ask for the server-sent-events stream
+ * @param onConnected receives the live connection, so the caller can disconnect it on cancel
+ * @return whatever [readResponse] produced
+ */
+ private fun postMessages(
+ body: JSONObject,
+ betas: List,
+ sse: Boolean = false,
+ onConnected: (HttpURLConnection) -> Unit = {},
+ readResponse: (BufferedReader) -> T,
+ ): T = http.post(
+ url = BASE_URL + MESSAGES_PATH,
+ apiKey = readApiKeyOrBlank(),
+ workspaceId = storedWorkspaceId(),
+ body = body,
+ betas = betas,
+ sse = sse,
+ readTimeoutMs = if (sse) ClaudeHttpClient.STREAM_READ_TIMEOUT_MS else ClaudeHttpClient.BLOCKING_READ_TIMEOUT_MS,
+ onConnected = onConnected,
+ onAccepted = { credentialFailures.clear() },
+ readResponse = readResponse,
+ )
+
+ /**
+ * List the models available with the stored key.
+ *
+ * Completes exceptionally on a network/API failure — an HTTP one as a [ClaudeHttpException], so
+ * the caller can tell a refused key from an unreachable API.
+ */
+ internal fun listModels(): CompletableFuture> =
+ listModels(readApiKeyOrBlank(), storedWorkspaceId())
+
+ /**
+ * List the models a caller-supplied key can use, with what each accepts.
+ *
+ * Lets the settings pane check a just-typed key *before* it is persisted; the no-arg
+ * [listModels] reads what is on disk. Nothing here touches the stored key or its cache.
+ *
+ * @param apiKey the candidate key; never logged
+ * @param workspaceId the candidate workspace for a key that belongs to none, or null
+ */
+ internal fun listModels(
+ apiKey: String,
+ workspaceId: String?,
+ ): CompletableFuture> {
+ val future = CompletableFuture>()
+ // close() cancels the scope, making launch a silent no-op; fail loudly instead.
+ if (!scope.isActive) {
+ future.completeExceptionally(IllegalStateException("Claude backend is closed"))
+ return future
+ }
+
+ val job = scope.launch {
+ try {
+ val body = http.get(BASE_URL + ClaudeModelCatalog.PATH, apiKey.trim(), workspaceId)
+ val models = ClaudeModelCatalog.entries(body)
+ context.logger.info("ClaudeBackend: the API offers ${models.size} models")
+ future.complete(models)
+ } catch (e: CancellationException) {
+ future.cancel(true)
+ throw e
+ } catch (e: Exception) {
+ context.logger.warn("ClaudeBackend: model listing failed: ${e.message}")
+ future.completeExceptionally(e)
+ }
+ }
+ future.cancelJobOnCancel(job)
+
+ return future
+ }
+
+ /**
+ * When the stored key was saved, or 0 when none is stored.
+ *
+ * Read into a local at the top of each request and carried to that request's error handler: a
+ * field on the backend is overwritten by any other key read before the refusal lands.
+ */
+ private fun storedKeyStamp(): Long =
+ claudePrefs()?.getLong(ClaudePreferences.KEY_API_KEY_TIMESTAMP, 0L) ?: 0L
+
+ /** The saved workspace id, or null when the key needs none. Checked again where it is sent. */
+ private fun storedWorkspaceId(): String? =
+ claudePrefs()?.getString(ClaudePreferences.KEY_WORKSPACE_ID, null)
+
+ /** The saved key, or blank when none is stored or it cannot be decrypted. */
+ private fun readApiKeyOrBlank(): String = keyCache.read().orEmpty()
+
+ /**
+ * Stop every streamed turn in flight (user pressed Stop): cancel its coroutine and close its
+ * socket, so the API stops generating now rather than at the next line it sends.
+ *
+ * Safe on the main thread, which is where AI Core calls it: the socket is closed on a thread
+ * of its own, since closing a TLS connection writes to the network.
+ */
+ override fun cancelStreaming() {
+ for (stream in activeStreams) {
+ stream.job?.cancel()
+ stream.connection?.let(::disconnectInBackground)
+ }
+ }
+
+ private fun disconnectInBackground(connection: HttpURLConnection) {
+ Thread({ runCatching { connection.disconnect() } }, "$LOG_PREFIX.disconnect").start()
+ }
+
+ /** Release all resources: stop every stream, cancel the backend scope, drop the key cache. */
+ fun close() {
+ cancelStreaming()
+ scope.cancel()
+ keyCache.clear()
+ }
+
+ /**
+ * Turn a failure into one user-facing sentence.
+ *
+ * [ClaudeErrorFormatter] decides *what* went wrong; the wording comes from `strings.xml`. The
+ * raw HTTP error body stays on the logged exception and must never reach the transcript.
+ *
+ * @param keyStamp when the key this request read was saved, so a refusal landing after a later
+ * save or clear is not reported against a credential that was never tried
+ */
+ private fun formatErrorMessage(e: Exception, keyStamp: Long): String {
+ val failure = ClaudeErrorFormatter.classify(
+ error = e,
+ modelName = getModelName(),
+ hasApiKey = readApiKeyOrBlank().isNotBlank(),
+ )
+ val message = failureMessages.of(failure)
+ // Only a credential failure is recorded: any other reason says nothing about the key, and
+ // filing it as one would send the user off to replace a key that works.
+ if (failure.isCredentialProblem) credentialFailures.record(failure, keyStamp)
+ return message
+ }
+}
+
+/**
+ * Cancel [job] when this future is cancelled by its caller.
+ *
+ * [CompletableFuture.cancel] only flips the future's own state, so without this a caller that gives
+ * up leaves the HTTP fetch running to completion for a result nobody will read.
+ *
+ * @param job the coroutine producing this future's value
+ */
+private fun CompletableFuture.cancelJobOnCancel(job: Job) {
+ whenComplete { _, _ -> if (isCancelled) job.cancel() }
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeHttpClient.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeHttpClient.kt
new file mode 100644
index 00000000..b5c3481f
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeHttpClient.kt
@@ -0,0 +1,147 @@
+package com.itsaky.androidide.plugins.aiagentclaude.backend
+
+import com.itsaky.androidide.plugins.aiagentclaude.errors.ClaudeHttpException
+import java.io.BufferedReader
+import java.net.HttpURLConnection
+import java.net.URL
+import org.json.JSONObject
+
+/**
+ * The HTTP transport this backend speaks: one POST that streams or does not, and one GET.
+ *
+ * [HttpURLConnection] rather than Anthropic's Java SDK: plugins run in the host IDE's classloader,
+ * which resolves `okhttp3` to the host's older OkHttp first, and an SDK bundling its own copy
+ * crashes with a NoSuchMethodError. Kept apart from the backend so the backend is about
+ * generating, not sockets.
+ *
+ * Both entry points tag their sockets ([NetworkTags]): the host's debug builds install
+ * `StrictMode.VmPolicy.detectAll()`, and an untagged socket trips `detectUntaggedSockets()` from
+ * inside plugin code.
+ *
+ * @param connectTimeoutMs how long to wait for the connection itself
+ */
+internal class ClaudeHttpClient(
+ private val connectTimeoutMs: Int = CONNECT_TIMEOUT_MS,
+) {
+
+ companion object {
+ private const val CONNECT_TIMEOUT_MS = 15_000
+
+ /**
+ * Longest silence a stream may hold. The stream carries `ping` events, but how often they
+ * arrive during long thinking is not documented, so this is a guess sized well above a
+ * normal gap. A turn that does go quiet this long is reported as stalled, not as a lost
+ * connection (`ClaudeStreamStalledException`).
+ */
+ const val STREAM_READ_TIMEOUT_MS = 120_000
+
+ /** A turn that is not streamed sends nothing until it is done, thinking included. */
+ const val BLOCKING_READ_TIMEOUT_MS = 600_000
+
+ /** The Messages API version every request names. */
+ private const val API_VERSION = "2023-06-01"
+
+ /**
+ * The headers that say who is asking. A blank key sends none, so the API answers 401 and
+ * the caller reports a missing key rather than a refused one; the workspace header goes
+ * only with an id [WorkspaceIds] accepts, so no typed value reaches the wire unchecked.
+ *
+ * @param apiKey the Claude API key
+ * @param workspaceId the workspace a key that belongs to none must name, or null
+ */
+ fun authHeaders(apiKey: String, workspaceId: String?): Map = buildMap {
+ put("anthropic-version", API_VERSION)
+ if (apiKey.isNotBlank()) put("x-api-key", apiKey)
+ WorkspaceIds.headerValue(workspaceId)?.let { put(WorkspaceIds.HEADER, it) }
+ }
+ }
+
+ /**
+ * POST [body] to [url] and hand the response's reader to [readResponse].
+ *
+ * The connection is closed before this returns, whatever [readResponse] did with it.
+ *
+ * @param apiKey the Claude API key; sent as `x-api-key`, never in the URL
+ * @param workspaceId the workspace a key that belongs to none must name, or null
+ * @param betas `anthropic-beta` values the body's parameters need
+ * @param sse true to ask for the server-sent-events stream
+ * @param readTimeoutMs how long the response may stay silent
+ * @param tag the [NetworkTags] value to tag this request's socket with
+ * @param onConnected receives the live connection, so a caller can disconnect it on cancellation
+ * @param onAccepted called once the status line says 2xx, before a byte of the body is read
+ * @return whatever [readResponse] produced
+ * @throws ClaudeHttpException on a non-2xx answer, carrying the API's error body
+ */
+ fun post(
+ url: String,
+ apiKey: String,
+ workspaceId: String?,
+ body: JSONObject,
+ betas: List = emptyList(),
+ sse: Boolean = false,
+ readTimeoutMs: Int = BLOCKING_READ_TIMEOUT_MS,
+ tag: Int = NetworkTags.INFERENCE,
+ onConnected: (HttpURLConnection) -> Unit = {},
+ onAccepted: () -> Unit = {},
+ readResponse: (BufferedReader) -> T,
+ ): T = withTrafficTag(tag) {
+ val conn = open(url, "POST", apiKey, workspaceId).apply {
+ readTimeout = readTimeoutMs
+ doOutput = true
+ setRequestProperty("Content-Type", "application/json")
+ if (betas.isNotEmpty()) setRequestProperty("anthropic-beta", betas.joinToString(","))
+ if (sse) setRequestProperty("Accept", "text/event-stream")
+ }
+ onConnected(conn)
+ try {
+ conn.outputStream.use { it.write(body.toString().toByteArray(Charsets.UTF_8)) }
+ conn.failIfNotOk()
+ onAccepted()
+ conn.inputStream.bufferedReader().use(readResponse)
+ } finally {
+ conn.disconnect()
+ }
+ }
+
+ /**
+ * GET [url] and return its response body, over a socket tagged [NetworkTags.CATALOG].
+ *
+ * @param apiKey the Claude API key
+ * @param workspaceId the workspace a key that belongs to none must name, or null
+ * @throws ClaudeHttpException on a non-2xx answer, carrying the API's error body
+ */
+ fun get(url: String, apiKey: String, workspaceId: String?): String = withTrafficTag(NetworkTags.CATALOG) {
+ val conn = open(url, "GET", apiKey, workspaceId)
+ try {
+ conn.failIfNotOk()
+ conn.inputStream.bufferedReader().use { it.readText() }
+ } finally {
+ conn.disconnect()
+ }
+ }
+
+ /**
+ * Open a connection carrying [authHeaders]. The read timeout starts at the connect budget;
+ * only a generation raises it.
+ */
+ private fun open(url: String, method: String, apiKey: String, workspaceId: String?): HttpURLConnection =
+ (URL(url).openConnection() as HttpURLConnection).apply {
+ requestMethod = method
+ connectTimeout = connectTimeoutMs
+ readTimeout = connectTimeoutMs
+ authHeaders(apiKey, workspaceId).forEach { (name, value) -> setRequestProperty(name, value) }
+ }
+
+ /**
+ * Fail with the API's error body attached, so the status reaches its readers as a number
+ * rather than as text they have to match.
+ */
+ private fun HttpURLConnection.failIfNotOk() {
+ val code = responseCode
+ if (code !in 200..299) {
+ val body = errorStream?.bufferedReader()?.use { it.readText() }.orEmpty()
+ val retryAfter = getHeaderField("retry-after")?.trim()?.toLongOrNull()
+ throw ClaudeHttpException(code, body, retryAfter)
+ }
+ }
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeModelCatalog.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeModelCatalog.kt
new file mode 100644
index 00000000..e71e9e97
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeModelCatalog.kt
@@ -0,0 +1,106 @@
+package com.itsaky.androidide.plugins.aiagentclaude.backend
+
+import org.json.JSONObject
+
+/**
+ * Reads `GET /v1/models`, and stores what it says about each model.
+ *
+ * Every model the endpoint lists can chat, so unlike an OpenAI-compatible catalog there is nothing
+ * to filter out. Each entry also carries the model's output cap and a capability tree, which is
+ * what decides the optional request fields ([ClaudeModelTraits]). Pure, so the parse is
+ * unit-testable from a captured body.
+ */
+object ClaudeModelCatalog {
+
+ /** One page large enough for the whole catalog, so a listing is one request. */
+ const val PATH = "/models?limit=1000"
+
+ /** One listed model. */
+ data class Entry(val id: String, val capabilities: ModelCapabilities)
+
+ /**
+ * The models in [body], newest first as the API orders them.
+ *
+ * @param body the response body
+ * @return the entries; empty when the body lists none or is not the expected shape
+ */
+ fun entries(body: String): List {
+ val data = runCatching { JSONObject(body).optJSONArray("data") }.getOrNull()
+ ?: return emptyList()
+ val seen = HashSet()
+ return (0 until data.length()).mapNotNull { index ->
+ val model = data.optJSONObject(index) ?: return@mapNotNull null
+ val id = model.optString("id").trim().takeIf { it.isNotEmpty() && seen.add(it) }
+ ?: return@mapNotNull null
+ Entry(id, capabilitiesOf(model))
+ }
+ }
+
+ /** The model ids in [body]; see [entries]. */
+ fun ids(body: String): List = entries(body).map { it.id }
+
+ /**
+ * What one model object says it accepts. A field the listing leaves out stays null, so the
+ * static rules decide it rather than a guess made here.
+ */
+ private fun capabilitiesOf(model: JSONObject): ModelCapabilities {
+ val caps = model.optJSONObject("capabilities")
+ return ModelCapabilities(
+ maxTokens = model.optInt("max_tokens", 0).takeIf { it > 0 },
+ adaptiveThinking = caps?.optJSONObject("thinking")
+ ?.optJSONObject("types")
+ ?.optJSONObject("adaptive")
+ ?.supported(),
+ effort = caps?.optJSONObject("effort")?.supported(),
+ )
+ }
+
+ /** A capability leaf's `supported` flag, or null when the leaf carries none. */
+ private fun JSONObject.supported(): Boolean? =
+ if (has("supported")) optBoolean("supported") else null
+
+ /** Separates fields within one stored line; no model id contains a tab. */
+ private const val FIELD = "\t"
+
+ /**
+ * Flattens [catalog] for storage, one `idmaxTokensadaptiveeffort` line per
+ * model, with an empty field for an unknown value. Newline-delimited rather than JSON, like
+ * the remembered model list, so a read can never throw.
+ *
+ * @return the encoded form, or null when there is nothing to remember
+ */
+ fun encodeCapabilities(catalog: List): String? = catalog
+ .takeIf { it.isNotEmpty() }
+ ?.joinToString("\n") { (id, c) ->
+ listOf(id, c.maxTokens?.toString().orEmpty(), c.adaptiveThinking.flag(), c.effort.flag())
+ .joinToString(FIELD)
+ }
+
+ /** Restores what [encodeCapabilities] wrote; a malformed line is skipped. */
+ fun decodeCapabilities(stored: String?): Map {
+ if (stored.isNullOrBlank()) return emptyMap()
+ val result = LinkedHashMap()
+ for (line in stored.split("\n")) {
+ val fields = line.split(FIELD)
+ val id = fields.getOrNull(0)?.trim()?.takeIf { it.isNotEmpty() } ?: continue
+ result[id] = ModelCapabilities(
+ maxTokens = fields.getOrNull(1)?.toIntOrNull()?.takeIf { it > 0 },
+ adaptiveThinking = fields.getOrNull(2).parseFlag(),
+ effort = fields.getOrNull(3).parseFlag(),
+ )
+ }
+ return result
+ }
+
+ private fun Boolean?.flag(): String = when (this) {
+ true -> "1"
+ false -> "0"
+ null -> ""
+ }
+
+ private fun String?.parseFlag(): Boolean? = when (this?.trim()) {
+ "1" -> true
+ "0" -> false
+ else -> null
+ }
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeModelTraits.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeModelTraits.kt
new file mode 100644
index 00000000..adfcd44e
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeModelTraits.kt
@@ -0,0 +1,135 @@
+package com.itsaky.androidide.plugins.aiagentclaude.backend
+
+/**
+ * What one model accepts, as `GET /v1/models` reports it. A null field is one the listing did not
+ * carry, which sends the decision back to the static rules in [ClaudeModelTraits].
+ *
+ * @property maxTokens the model's output cap
+ * @property adaptiveThinking whether `thinking: {type: "adaptive"}` is accepted
+ * @property effort whether `output_config.effort` is accepted
+ */
+data class ModelCapabilities(
+ val maxTokens: Int? = null,
+ val adaptiveThinking: Boolean? = null,
+ val effort: Boolean? = null,
+)
+
+/**
+ * Which optional request fields a Claude model accepts.
+ *
+ * Each rule is a 400 on the models it is wrong for, so the request builder asks here rather than
+ * sending a field and hoping. The live catalog decides when it has an answer ([ModelCapabilities]);
+ * the static tables below only cover a model the catalog has not described, such as one typed
+ * before the key was ever tested. They are allow-lists, so an unknown model gets the bare request
+ * every model accepts rather than a field it may reject. Pure, so every row is unit-testable.
+ */
+internal object ClaudeModelTraits {
+
+ /** Ceiling on any request's output budget: the largest every 64K-or-larger model accepts. */
+ const val MAX_OUTPUT_CEILING = 64_000
+
+ /**
+ * Models that take `thinking: {type: "adaptive"}`. Opus 4.5, Sonnet 4.5 and Haiku 4.5 think
+ * only with a token budget, which this backend does not send, so they run without thinking.
+ */
+ private val ADAPTIVE_THINKING_PREFIXES = listOf(
+ "claude-fable-5",
+ "claude-mythos-5",
+ "claude-opus-5",
+ "claude-sonnet-5",
+ "claude-opus-4-8",
+ "claude-opus-4-7",
+ "claude-opus-4-6",
+ "claude-sonnet-4-6",
+ )
+
+ /**
+ * Models that think when `thinking` is omitted. On these a request has to leave room for
+ * thinking whatever the caller asked for, because it cannot be turned off.
+ */
+ private val THINKS_BY_DEFAULT_PREFIXES = listOf(
+ "claude-fable-5",
+ "claude-mythos-5",
+ "claude-opus-5",
+ "claude-sonnet-5",
+ )
+
+ /** Models that take `output_config.effort`; it arrived with Opus 4.5. */
+ private val EFFORT_PREFIXES = ADAPTIVE_THINKING_PREFIXES + "claude-opus-4-5"
+
+ /**
+ * Models whose output cap is below [MAX_OUTPUT_CEILING]: Opus 4 and 4.1, by alias and by dated
+ * id. Every other current model takes 64K or more.
+ */
+ private val SMALL_OUTPUT_PREFIXES = mapOf(
+ "claude-opus-4-0" to 32_000,
+ "claude-opus-4-1" to 32_000,
+ "claude-opus-4-2025" to 32_000,
+ )
+
+ /**
+ * Models whose safety classifiers can decline a request with `stop_reason: "refusal"`, and
+ * which accept the server-side `fallbacks: "default"` that re-runs a declined request on a
+ * model chosen by the refusal category.
+ */
+ private val SERVER_FALLBACK_MODELS = setOf(
+ "claude-fable-5-1",
+ "claude-opus-5-5",
+ "claude-opus-5",
+ "claude-sonnet-5-5",
+ )
+
+ /** Beta header that enables `fallbacks: "default"`; the array form uses a different one. */
+ const val SERVER_FALLBACK_BETA = "server-side-fallback-2026-07-01"
+
+ /** A dated id is the alias plus `-YYYYMMDD`, e.g. `claude-haiku-4-5-20251001`. */
+ private val DATE_SUFFIX = Regex("""-\d{8}""")
+
+ /**
+ * Whether [catalogId] names the model [saved] means: the same id, or [saved] as an alias of a
+ * dated id. The catalog lists some models only by dated id, so an exact match alone retires a
+ * perfectly good alias the user picked.
+ */
+ fun sameModel(saved: String, catalogId: String): Boolean {
+ val a = saved.trim().lowercase()
+ val b = catalogId.trim().lowercase()
+ if (a == b) return true
+ return b.startsWith(a) && DATE_SUFFIX.matches(b.substring(a.length))
+ }
+
+ /** The catalog's entry for [model], matching an alias to its dated id; null when not listed. */
+ fun lookup(model: String, catalog: Map): ModelCapabilities? =
+ catalog[model] ?: catalog.entries.firstOrNull { sameModel(model, it.key) }?.value
+
+ /** Whether [model] accepts `thinking: {type: "adaptive"}`. */
+ fun supportsAdaptiveThinking(model: String, known: ModelCapabilities? = null): Boolean =
+ known?.adaptiveThinking ?: matchesAny(model, ADAPTIVE_THINKING_PREFIXES)
+
+ /** Whether [model] thinks even when `thinking` is omitted. Static: the catalog does not say. */
+ fun thinksByDefault(model: String): Boolean = matchesAny(model, THINKS_BY_DEFAULT_PREFIXES)
+
+ /** Whether [model] accepts `output_config.effort`. */
+ fun supportsEffort(model: String, known: ModelCapabilities? = null): Boolean =
+ known?.effort ?: matchesAny(model, EFFORT_PREFIXES)
+
+ /** The largest `max_tokens` [model] accepts, held at [MAX_OUTPUT_CEILING]. */
+ fun outputCap(model: String, known: ModelCapabilities? = null): Int {
+ val id = model.trim().lowercase()
+ val cap = known?.maxTokens
+ ?: SMALL_OUTPUT_PREFIXES.entries.firstOrNull { id.startsWith(it.key) }?.value
+ ?: MAX_OUTPUT_CEILING
+ return cap.coerceIn(1, MAX_OUTPUT_CEILING)
+ }
+
+ /**
+ * Whether [model] takes the server-side `fallbacks` parameter. Exact ids only: the parameter is
+ * a 400 on a model that does not list it, so a guess costs every request.
+ */
+ fun supportsServerFallback(model: String): Boolean =
+ model.trim().lowercase() in SERVER_FALLBACK_MODELS
+
+ private fun matchesAny(model: String, prefixes: List): Boolean {
+ val id = model.trim().lowercase()
+ return prefixes.any { id.startsWith(it) }
+ }
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeRequestBuilder.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeRequestBuilder.kt
new file mode 100644
index 00000000..27ad8b86
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeRequestBuilder.kt
@@ -0,0 +1,209 @@
+package com.itsaky.androidide.plugins.aiagentclaude.backend
+
+import com.itsaky.androidide.plugins.services.LlmInferenceService.ChatMessage
+import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig
+import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition
+import org.json.JSONArray
+import org.json.JSONObject
+
+/**
+ * Builds Messages API request bodies.
+ *
+ * Pure — no Android types and no network — so the request shape, which is the thing the API 400s
+ * over, is unit-testable.
+ */
+internal object ClaudeRequestBuilder {
+
+ private const val ROLE_USER = "user"
+ private const val ROLE_ASSISTANT = "assistant"
+
+ /**
+ * Stands in for a conversation that would otherwise open on an assistant turn, which the API
+ * refuses. Only reachable from a history the caller trimmed mid-exchange.
+ */
+ private const val LEADING_USER_TURN = "(Earlier conversation omitted.)"
+
+ /**
+ * Output budget for a streamed turn, which is the agent's: thinking counts against
+ * `max_tokens`, so the 4096 AI Core asks for would cut a turn off mid-thought. Held at each
+ * model's own cap.
+ */
+ const val STREAMING_MAX_TOKENS = ClaudeModelTraits.MAX_OUTPUT_CEILING
+
+ /**
+ * Least budget for a turn that is not streamed, on a model that thinks whether asked to or
+ * not: a 512-token chat title would otherwise be spent before the reply began. Other models
+ * get exactly the caller's budget, since these are small jobs that asked for a small answer.
+ */
+ const val BLOCKING_THINKING_FLOOR = 4_096
+
+ /**
+ * Effort for a streamed turn. Claude Opus 5.5 defaults to `medium`; an agent that edits and
+ * builds a project is the workload `high` exists for.
+ */
+ const val STREAMING_EFFORT = "high"
+
+ /**
+ * Effort for a turn that is not streamed: titles and inline suggestions, which want a quick
+ * short answer, and on Opus 5.5 effort is the only way to keep its thinking short.
+ */
+ const val BLOCKING_EFFORT = "low"
+
+ /**
+ * The conversation split into what the Messages API takes: a top-level `system` string and a
+ * `messages[]` array of alternating turns.
+ *
+ * @property system the system prompt plus any SYSTEM turns from the history, or null
+ * @property messages the turns, opening on `user` and alternating
+ */
+ data class Conversation(val system: String?, val messages: JSONArray)
+
+ /**
+ * Maps the conversation onto the Messages API's shape.
+ *
+ * The caller hands tool results back as ordinary text — a USER turn wrapping each result, or
+ * a TOOL turn — and never the assistant `tool_use` block they answer, so they are sent as user
+ * text rather than as `tool_result` blocks, which the API rejects without a matching call.
+ * That leaves runs of same-role turns, which are merged into one; blank turns are dropped,
+ * since the API refuses an empty text block. SYSTEM turns join the top-level system prompt:
+ * the API has no system role inside `messages[]` that every model accepts.
+ *
+ * @param history the conversation so far, oldest first
+ * @param prompt the current user turn, appended last
+ * @param systemPrompt the system prompt, or null to send none
+ */
+ fun conversation(
+ history: List,
+ prompt: String,
+ systemPrompt: String?,
+ ): Conversation {
+ val system = StringBuilder(systemPrompt?.trim().orEmpty())
+ val turns = mutableListOf>()
+
+ fun append(role: String, text: String) {
+ if (text.isBlank()) return
+ val last = turns.lastOrNull()
+ if (last != null && last.first == role) {
+ last.second.append("\n\n").append(text)
+ } else {
+ turns += role to StringBuilder(text)
+ }
+ }
+
+ for (entry in history) {
+ when (entry.role) {
+ ChatMessage.Role.USER, ChatMessage.Role.TOOL -> append(ROLE_USER, entry.content)
+ ChatMessage.Role.ASSISTANT -> append(ROLE_ASSISTANT, entry.content)
+ ChatMessage.Role.SYSTEM -> if (entry.content.isNotBlank()) {
+ if (system.isNotEmpty()) system.append("\n\n")
+ system.append(entry.content.trim())
+ }
+ }
+ }
+ append(ROLE_USER, prompt)
+
+ if (turns.firstOrNull()?.first == ROLE_ASSISTANT) {
+ turns.add(0, ROLE_USER to StringBuilder(LEADING_USER_TURN))
+ }
+ // A conversation must end on the user: current models reject an assistant prefill. Only
+ // a blank prompt after an assistant turn reaches this, and that is a caller asking for
+ // nothing, so the request fails here with the reason rather than as an opaque 400.
+ require(turns.lastOrNull()?.first == ROLE_USER) { "nothing to send: the last turn is not the user's" }
+
+ val messages = JSONArray()
+ for ((role, text) in turns) {
+ messages.put(JSONObject().put("role", role).put("content", text.toString()))
+ }
+ return Conversation(system.toString().takeIf { it.isNotBlank() }, messages)
+ }
+
+ /**
+ * Builds the request body for [conversation].
+ *
+ * A streamed turn is the agent's, so it asks for adaptive thinking where the model takes it:
+ * on Opus 4.x and Sonnet 4.6 an omitted `thinking` means none at all, and the agent would run
+ * without reasoning. A turn that is not streamed sends no `thinking`, so those models answer
+ * a small job directly; the 5.x models think regardless, which [maxTokens] makes room for.
+ * No `temperature` is ever sent: it is a 400 on current Opus and Sonnet models. The caller's
+ * `required_tool` is not honoured either: forcing a call with `tool_choice` is a 400 on
+ * current models, and the contract lets a backend that cannot force one ignore it.
+ *
+ * @param model the model id to request
+ * @param stream true to ask for the event stream, i.e. an agent turn
+ * @param config supplies the token cap and stop sequences
+ * @param tools the tools to declare; omitted from the body when empty
+ * @param known what the live catalog says [model] accepts, or null when it says nothing
+ */
+ fun body(
+ conversation: Conversation,
+ model: String,
+ stream: Boolean,
+ config: LlmConfig,
+ tools: List = emptyList(),
+ known: ModelCapabilities? = null,
+ ): JSONObject {
+ val body = JSONObject()
+ .put("model", model)
+ .put("max_tokens", maxTokens(config, model, stream, known))
+ .put("messages", conversation.messages)
+ if (stream) body.put("stream", true)
+ conversation.system?.let { body.put("system", it) }
+
+ // Declared, not described in the prompt: the input then arrives already structured, so a
+ // file whose contents carry quotes or newlines can no longer break the call (ADFA-5410).
+ if (tools.isNotEmpty()) body.put("tools", ClaudeToolProtocol.toolsArray(tools))
+
+ // Caches everything up to the last turn, so an agent run re-sends its system prompt and
+ // tool list at cache-read price. A prefix too short to cache is simply not cached.
+ body.put("cache_control", JSONObject().put("type", "ephemeral"))
+
+ if (stream && ClaudeModelTraits.supportsAdaptiveThinking(model, known)) {
+ body.put("thinking", JSONObject().put("type", "adaptive"))
+ }
+ if (ClaudeModelTraits.supportsEffort(model, known)) {
+ body.put(
+ "output_config",
+ JSONObject().put("effort", if (stream) STREAMING_EFFORT else BLOCKING_EFFORT),
+ )
+ }
+ if (ClaudeModelTraits.supportsServerFallback(model)) {
+ // A safety classifier can decline a benign coding request; this re-runs it on the
+ // model the API picks for that category instead of ending the turn.
+ body.put("fallbacks", "default")
+ }
+
+ config.stopSequences
+ ?.filter { it.isNotEmpty() }
+ ?.takeIf { it.isNotEmpty() }
+ ?.let { body.put("stop_sequences", JSONArray(it)) }
+
+ return body
+ }
+
+ /**
+ * The `anthropic-beta` header values [model]'s requests need, empty when none.
+ *
+ * Paired with [body] on purpose: a parameter sent without its beta is a 400, and so is a beta
+ * named for a model that does not take it.
+ */
+ fun betas(model: String): List =
+ if (ClaudeModelTraits.supportsServerFallback(model)) {
+ listOf(ClaudeModelTraits.SERVER_FALLBACK_BETA)
+ } else {
+ emptyList()
+ }
+
+ /**
+ * The output budget: the agent's full budget for a stream, the caller's own otherwise, raised
+ * only where thinking cannot be turned off, and never above what [model] accepts.
+ */
+ private fun maxTokens(config: LlmConfig, model: String, stream: Boolean, known: ModelCapabilities?): Int {
+ val cap = ClaudeModelTraits.outputCap(model, known)
+ val wanted = when {
+ stream -> STREAMING_MAX_TOKENS
+ ClaudeModelTraits.thinksByDefault(model) -> maxOf(config.maxTokens, BLOCKING_THINKING_FLOOR)
+ else -> config.maxTokens
+ }
+ return wanted.coerceIn(1, cap)
+ }
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeStreamEvent.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeStreamEvent.kt
new file mode 100644
index 00000000..85494d58
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeStreamEvent.kt
@@ -0,0 +1,144 @@
+package com.itsaky.androidide.plugins.aiagentclaude.backend
+
+import org.json.JSONException
+import org.json.JSONObject
+
+/**
+ * One line of a Messages API event stream, reduced to what the backend acts on.
+ *
+ * The stream frames each event as an `event:` line and a `data:` line; the `data` JSON repeats the
+ * event name as its `type`, so only `data:` lines are read and the rest are [Ignored]. Pure, so the
+ * shapes that decide what reaches the transcript are unit-testable from captured lines.
+ */
+internal sealed interface ClaudeStreamEvent {
+
+ /** `message_start`: names the model actually serving the turn, which a fallback can change. */
+ data class Started(val model: String?) : ClaudeStreamEvent
+
+ /** A `text_delta`: reply text, shown as it arrives. */
+ data class Text(val text: String) : ClaudeStreamEvent
+
+ /** A `tool_use` block opened at [index]; its input follows as [ToolInput] fragments. */
+ data class ToolStart(val index: Int, val id: String, val name: String) : ClaudeStreamEvent
+
+ /** One `input_json_delta` slice of the `tool_use` block at [index]. */
+ data class ToolInput(val index: Int, val partialJson: String) : ClaudeStreamEvent
+
+ /**
+ * Any block other than `tool_use` or `fallback` opened at [index]: `text`, `thinking`, or a type
+ * added later. Tracked so the turn knows which block a `max_tokens` stop cut off; a `thinking`
+ * block is never shown, but is proof the model was working when a turn ends with no text.
+ *
+ * @property type the block's `type`
+ * @property text text the block opened with, which for a `text` block is reply text
+ */
+ data class BlockOpened(val index: Int, val type: String, val text: String = "") : ClaudeStreamEvent
+
+ /**
+ * A `fallback` block at [index]: a safety classifier declined the requested model and the
+ * server moved the turn to [toModel]. Text already streamed stays valid; a tool call the
+ * declined model began does not.
+ */
+ data class FallbackSwitch(val index: Int, val toModel: String?) : ClaudeStreamEvent
+
+ /** `message_delta` carrying why generation stopped (`end_turn`, `tool_use`, `max_tokens`, ...). */
+ data class Stop(val reason: String) : ClaudeStreamEvent
+
+ /** `message_stop`: the stream is over. */
+ data object Done : ClaudeStreamEvent
+
+ /**
+ * An `error` event inside a 200: the stream started, then failed (`overloaded_error` is the
+ * common one). Carries the API's own error type so it classifies like an HTTP failure.
+ */
+ data class Failure(val errorType: String?, val message: String?) : ClaudeStreamEvent
+
+ /** A line with nothing to act on: framing, `ping`, block stops, signatures, usage. */
+ data object Ignored : ClaudeStreamEvent
+
+ /** A `data:` payload that is not the JSON it should be. */
+ data class Malformed(val detail: String) : ClaudeStreamEvent
+
+ companion object {
+
+ /** Longest slice of a bad payload carried into a log line. */
+ private const val MAX_DETAIL = 120
+
+ /**
+ * Reads one line of the stream.
+ *
+ * @param line a raw line, `data:` prefix included
+ * @return what the line means for the turn
+ */
+ fun parse(line: String): ClaudeStreamEvent {
+ if (!line.startsWith("data:")) return Ignored
+ val payload = line.removePrefix("data:").trim()
+ if (payload.isEmpty()) return Ignored
+ val json = try {
+ JSONObject(payload)
+ } catch (e: JSONException) {
+ return Malformed(payload.take(MAX_DETAIL))
+ }
+ return when (json.optString("type")) {
+ "message_start" ->
+ Started(json.optJSONObject("message")?.optString("model")?.takeIf { it.isNotBlank() })
+
+ "content_block_start" -> blockStart(json)
+ "content_block_delta" -> blockDelta(json)
+
+ "message_delta" ->
+ json.optJSONObject("delta")?.optString("stop_reason")
+ ?.takeIf { it.isNotBlank() && it != "null" }
+ ?.let(::Stop)
+ ?: Ignored
+
+ "message_stop" -> Done
+
+ "error" -> json.optJSONObject("error").let { error ->
+ Failure(
+ errorType = error?.optString("type")?.takeIf { it.isNotBlank() },
+ message = error?.optString("message")?.takeIf { it.isNotBlank() },
+ )
+ }
+
+ // ping, content_block_stop, and event types added after this was written.
+ else -> Ignored
+ }
+ }
+
+ /** A `content_block_start`, which only matters for the block types the backend tracks. */
+ private fun blockStart(json: JSONObject): ClaudeStreamEvent {
+ val index = json.optInt("index", -1)
+ val block = json.optJSONObject("content_block") ?: return Malformed("block start without content_block")
+ return when (block.optString("type")) {
+ "tool_use" -> {
+ val name = block.optString("name")
+ if (index < 0 || name.isBlank()) {
+ Malformed("tool_use block without index or name")
+ } else {
+ ToolStart(index, block.optString("id"), name)
+ }
+ }
+
+ "fallback" -> FallbackSwitch(
+ index,
+ block.optJSONObject("to")?.optString("model")?.takeIf { it.isNotBlank() },
+ )
+
+ // A text block can open with text already in it; every one seen so far is empty.
+ else -> BlockOpened(index, block.optString("type"), block.optString("text"))
+ }
+ }
+
+ /** A `content_block_delta`, by the kind of delta it carries. */
+ private fun blockDelta(json: JSONObject): ClaudeStreamEvent {
+ val delta = json.optJSONObject("delta") ?: return Malformed("block delta without delta")
+ return when (delta.optString("type")) {
+ "text_delta" -> delta.optString("text").takeIf { it.isNotEmpty() }?.let(::Text) ?: Ignored
+ "input_json_delta" -> ToolInput(json.optInt("index", -1), delta.optString("partial_json"))
+ // thinking_delta, signature_delta, citations_delta: nothing the transcript shows.
+ else -> Ignored
+ }
+ }
+ }
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeToolProtocol.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeToolProtocol.kt
new file mode 100644
index 00000000..7baa5ead
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeToolProtocol.kt
@@ -0,0 +1,210 @@
+package com.itsaky.androidide.plugins.aiagentclaude.backend
+
+import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolCallRequest
+import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition
+import org.json.JSONArray
+import org.json.JSONObject
+
+/**
+ * This backend's half of the Messages API tool protocol: `tools[]` out, `tool_use` blocks in.
+ *
+ * Pure and free of Android types, so the shapes that decide whether a tool call runs at all are
+ * unit-testable without a device or a network.
+ */
+internal object ClaudeToolProtocol {
+
+ /**
+ * Nesting a declared schema may carry. A contributed (MCP) schema is provider-supplied text,
+ * and a pathologically deep one would otherwise recurse until the host process dies.
+ */
+ private const val MAX_SCHEMA_DEPTH = 12
+
+ /**
+ * The `tools[]` array declaring [tools] to the API.
+ *
+ * Not `strict`: strict mode needs every schema closed with `additionalProperties: false`, which
+ * a contributed MCP schema rarely is, and the API answers an unconforming one with a 400.
+ *
+ * @param tools the tools to declare.
+ * @return one `{"name", "description", "input_schema"}` entry per tool.
+ */
+ fun toolsArray(tools: List): JSONArray {
+ val declarations = JSONArray()
+ for (tool in tools) {
+ declarations.put(
+ JSONObject()
+ .put("name", tool.name)
+ .put("description", tool.description.orEmpty())
+ .put("input_schema", inputSchemaJson(tool.parametersSchema))
+ )
+ }
+ return declarations
+ }
+
+ /**
+ * The `input_schema` value for a tool.
+ *
+ * The API requires an object schema, so an empty one becomes a bare `{"type":"object"}` and a
+ * schema that names no type gets one; any other declared type is passed through for the API to
+ * judge, rather than silently rewritten into a contract the tool did not offer.
+ *
+ * @param schema the tool's JSON Schema, empty when it publishes none.
+ * @return the schema to declare.
+ */
+ fun inputSchemaJson(schema: Map?): JSONObject {
+ if (schema.isNullOrEmpty()) return JSONObject().put("type", "object")
+ val json = schemaJson(schema, MAX_SCHEMA_DEPTH)
+ if (!json.has("type")) json.put("type", "object")
+ return json
+ }
+
+ /**
+ * Converts a JSON Schema to JSON, keyword for keyword: the Messages API takes plain JSON
+ * Schema, which is the dialect a contributed tool already arrives in.
+ *
+ * @param schema the tool's JSON Schema.
+ * @param depth how much further nesting to render; a deeper subtree is dropped.
+ */
+ private fun schemaJson(schema: Map<*, *>, depth: Int): JSONObject {
+ val json = JSONObject()
+ if (depth <= 0) return json
+ for ((key, value) in schema) {
+ val name = key as? String ?: continue
+ json.put(name, jsonValue(value, depth))
+ }
+ return json
+ }
+
+ /** One schema value: a nested schema, a list of them, or a scalar as it stands. */
+ private fun jsonValue(value: Any?, depth: Int): Any = when (value) {
+ null -> JSONObject.NULL
+ is Map<*, *> -> schemaJson(value, depth - 1)
+ is Collection<*> -> JSONArray().apply { value.forEach { put(jsonValue(it, depth)) } }
+ else -> value
+ }
+
+ /**
+ * Joins a stream's `tool_use` blocks back into whole calls.
+ *
+ * A block opens with its id and name (`content_block_start`) and its input follows as
+ * `input_json_delta` fragments, each a slice of one JSON object that only parses once the block
+ * is complete. Fragments are joined on the block's index, which is unique within one message.
+ *
+ * Not thread-safe: it belongs to the one reader loop consuming a single response body.
+ */
+ class CallAccumulator {
+
+ /** One call under construction, at content-block [index]. */
+ private class Entry(val index: Int, val id: String, val name: String) {
+ val input = StringBuilder()
+ }
+
+ /** Every call this message has begun, in arrival order. */
+ private val entries = mutableListOf()
+
+ /** The call each content-block index is receiving fragments for. */
+ private val open = HashMap()
+
+ /**
+ * Calls whose input never parsed, as of the last [requests] call.
+ *
+ * The diagnostic for a turn that asked for a tool and ran none: a stream cut off by
+ * `max_tokens` leaves the input half-written, which is a truncated reply, not an empty one.
+ */
+ var droppedCalls: Int = 0
+ private set
+
+ /**
+ * Opens the `tool_use` block at [index].
+ *
+ * @param id the API's `toolu_...` id, carried back to correlate the result.
+ * @param name the tool the model chose.
+ */
+ fun start(index: Int, id: String, name: String) {
+ val entry = Entry(index, id, name)
+ entries += entry
+ open[index] = entry
+ }
+
+ /**
+ * Forgets every call begun so far. For a server-side fallback: the declined model's calls
+ * are not passed to the model that takes over, so running one would run a call nobody
+ * stands behind, and the new model may well make the same call again.
+ *
+ * @return how many calls were forgotten
+ */
+ fun discardAll(): Int {
+ val count = entries.size
+ entries.clear()
+ open.clear()
+ return count
+ }
+
+ /**
+ * Drops the call at content-block [index], if there is one: the block a `max_tokens` stop
+ * cut off. Counted in [droppedCalls], since it is a truncated call; its input may even
+ * look complete, as an empty one does.
+ *
+ * @return true when a call was dropped
+ */
+ fun dropAt(index: Int): Boolean {
+ val entry = open.remove(index) ?: return false
+ entries.remove(entry)
+ truncated++
+ return true
+ }
+
+ /** Calls dropped by [dropAt]; folded into [droppedCalls]. */
+ private var truncated = 0
+
+ /**
+ * Appends one `input_json_delta` fragment to the block at [index]. A fragment for an index
+ * no `tool_use` block opened belongs to some other block type and is ignored.
+ */
+ fun appendInput(index: Int, partialJson: String) {
+ open[index]?.input?.append(partialJson)
+ }
+
+ /**
+ * The calls accumulated so far, in the order the stream began them.
+ *
+ * A call whose input will not parse is left out and counted in [droppedCalls] rather than
+ * reported with empty arguments, which would run the tool on nothing.
+ *
+ * @return the whole calls; empty when the message carried none.
+ */
+ fun requests(): List {
+ val requests = mutableListOf()
+ var dropped = 0
+ for (entry in entries) {
+ val args = argsOf(entry.input.toString())
+ if (entry.name.isBlank() || args == null) {
+ dropped++
+ continue
+ }
+ requests += ToolCallRequest(entry.id.ifBlank { entry.name }, entry.name, args)
+ }
+ droppedCalls = dropped + truncated
+ return requests
+ }
+ }
+
+ /**
+ * Reads one call's accumulated input.
+ *
+ * Parsed with org.json rather than matched as text: the API may escape the same string
+ * differently from one model to the next (unicode, forward slashes).
+ *
+ * @param input the accumulated JSON; blank for a tool called with none.
+ * @return the arguments, or null when the JSON is incomplete, malformed, or not an object.
+ */
+ fun argsOf(input: String): Map? {
+ val text = input.trim()
+ if (text.isEmpty()) return emptyMap()
+ val json = runCatching { JSONObject(text) }.getOrNull() ?: return null
+ val args = mutableMapOf()
+ // Values stay as org.json types, as the other transports also hand them over.
+ for (key in json.keys()) args[key] = json.get(key)
+ return args
+ }
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/NetworkTags.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/NetworkTags.kt
new file mode 100644
index 00000000..afae0088
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/NetworkTags.kt
@@ -0,0 +1,35 @@
+package com.itsaky.androidide.plugins.aiagentclaude.backend
+
+import android.net.TrafficStats
+
+/**
+ * Thread stats tags for this plugin's sockets: ASCII bytes, so a raw `dumpsys netstats` dump stays
+ * readable. Keep-alive pooling makes the split approximate — a pooled socket keeps the tag it was
+ * born with, and inference and the catalog share a host.
+ */
+internal object NetworkTags {
+
+ /** Messages, streaming and not — `"CLIN"`. */
+ const val INFERENCE = 0x434C494E
+
+ /** The model catalog — `"CLCT"`. */
+ const val CATALOG = 0x434C4354
+}
+
+/**
+ * Run [block] with [tag] on this thread's sockets, restoring the previous tag in a `finally` so the
+ * tag never leaks onto these shared `Dispatchers.IO` threads and an enclosing tag survives. Not
+ * `inline` on purpose: the tag is thread-local, so [block] must not suspend.
+ *
+ * @param tag one of [NetworkTags]
+ * @return whatever [block] returned
+ */
+internal fun withTrafficTag(tag: Int, block: () -> T): T {
+ val previous = TrafficStats.getThreadStatsTag()
+ TrafficStats.setThreadStatsTag(tag)
+ return try {
+ block()
+ } finally {
+ TrafficStats.setThreadStatsTag(previous)
+ }
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/TransientRetry.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/TransientRetry.kt
new file mode 100644
index 00000000..8c0db822
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/TransientRetry.kt
@@ -0,0 +1,77 @@
+package com.itsaky.androidide.plugins.aiagentclaude.backend
+
+import com.itsaky.androidide.plugins.aiagentclaude.errors.ClaudeHttpException
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.delay
+
+/**
+ * When a failed request is worth sending again, and after how long.
+ *
+ * The Claude API answers 529 when it is overloaded and 429 when the account's rate limit is hit;
+ * both usually clear within seconds, and failing the turn on the first one makes a busy minute
+ * look like a broken plugin. Retried only before a stream has delivered anything, so a retry can
+ * never show the user the same tokens twice. Pure, so the schedule is unit-testable.
+ */
+internal object TransientRetry {
+
+ /** Attempts after the first. Two matches what the official SDKs do by default. */
+ const val MAX_RETRIES = 2
+
+ /** Statuses that say "try again" rather than "this request is wrong". */
+ private val RETRYABLE_STATUSES = setOf(408, 409, 429, 500, 502, 503, 504, 529)
+
+ /** First backoff; doubled per attempt when the API names no delay of its own. */
+ private const val BASE_DELAY_MS = 1_000L
+
+ /**
+ * Longest wait honoured, even when `retry-after` asks for more: past this the user is better
+ * served by an error they can act on than by a turn that sits silent.
+ */
+ private const val MAX_DELAY_MS = 10_000L
+
+ /**
+ * Runs [attempt], retrying it per [delayMs] for as long as [delivered] says nothing has
+ * reached the user. From the first delivered token a failure is final, since a retry would
+ * repeat what is already on screen.
+ *
+ * @param onRetry told about each retry before its wait, for the log
+ * @param sleep the wait; injectable so the schedule is testable without real time
+ * @return what the successful attempt returned
+ */
+ suspend fun run(
+ delivered: () -> Boolean = { false },
+ onRetry: (error: Exception, retry: Int, waitMs: Long) -> Unit = { _, _, _ -> },
+ sleep: suspend (Long) -> Unit = { delay(it) },
+ attempt: suspend () -> T,
+ ): T {
+ var retries = 0
+ while (true) {
+ try {
+ return attempt()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ val wait = if (delivered()) null else delayMs(e, retries)
+ if (wait == null) throw e
+ retries++
+ onRetry(e, retries, wait)
+ sleep(wait)
+ }
+ }
+ }
+
+ /**
+ * How long to wait before retrying after [error], or null to give up.
+ *
+ * @param error the failure the last attempt ended with
+ * @param retriesSoFar retries already made for this request
+ */
+ fun delayMs(error: Throwable, retriesSoFar: Int): Long? {
+ if (retriesSoFar >= MAX_RETRIES) return null
+ val http = error as? ClaudeHttpException ?: return null
+ if (http.statusCode !in RETRYABLE_STATUSES) return null
+ val asked = http.retryAfterSeconds?.takeIf { it >= 0 }?.times(1_000L)
+ val backoff = BASE_DELAY_MS shl retriesSoFar
+ return (asked ?: backoff).coerceAtMost(MAX_DELAY_MS)
+ }
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/TurnAssembler.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/TurnAssembler.kt
new file mode 100644
index 00000000..b43a9634
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/TurnAssembler.kt
@@ -0,0 +1,148 @@
+package com.itsaky.androidide.plugins.aiagentclaude.backend
+
+import com.itsaky.androidide.plugins.aiagentclaude.errors.ClaudeFailure
+import com.itsaky.androidide.plugins.aiagentclaude.errors.ClaudeHttpException
+import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolCallRequest
+import org.json.JSONObject
+
+/**
+ * Folds one stream's events into a turn, and decides how the turn ends.
+ *
+ * Kept apart from the socket so every rule that decides what reaches the caller is unit-testable
+ * from captured event lines: a refusal is reported before any call runs, a declined model's calls
+ * are dropped at a fallback, a call cut off by `max_tokens` is not run, and an empty turn says why.
+ *
+ * Not thread-safe: it belongs to the one reader loop consuming a single response body. Use a new
+ * one per attempt, so a retried turn cannot report a call twice.
+ *
+ * @param onText receives each piece of reply text as it arrives, for the live transcript
+ */
+internal class TurnAssembler(private val onText: (String) -> Unit) {
+
+ /** What ended the turn, or why it produced nothing to show. */
+ sealed interface Result {
+
+ /**
+ * The model declined. Any partial output, and any tool call inside it, is not an answer
+ * the model stands behind.
+ */
+ data object Refused : Result
+
+ /** A turn to report: [text], [calls], or both. */
+ data class Reply(val text: String, val calls: List) : Result
+
+ /** The stream ended cleanly with neither text nor a call; [failure] says which case. */
+ data class Empty(val failure: ClaudeFailure) : Result
+ }
+
+ private val text = StringBuilder()
+ private val calls = ClaudeToolProtocol.CallAccumulator()
+
+ /** The content block opened last, which is the one a `max_tokens` stop cut off. */
+ private var lastBlockIndex = -1
+
+ /** Reply text delivered so far, in pieces; a retry is only safe while this is zero. */
+ var chunks = 0
+ private set
+
+ /** Payloads the parser could not use. */
+ var skippedLines = 0
+ private set
+
+ /** Thinking blocks seen, which are never part of the reply. */
+ var thinkingBlocks = 0
+ private set
+
+ /** The `stop_reason` the API reported, if any. */
+ var stopReason: String? = null
+ private set
+
+ /** The model `message_start` named, which a fallback can make another one. */
+ var servedBy: String? = null
+ private set
+
+ /** Calls dropped because the model that began them was declined mid-turn. */
+ var discardedAtFallback = 0
+ private set
+
+ /** Calls dropped in [finish] as incomplete or cut off; set once [finish] runs. */
+ var droppedCalls = 0
+ private set
+
+ /**
+ * Applies one event.
+ *
+ * @return false once the stream is over and the reader should stop
+ * @throws ClaudeHttpException for an `error` event, as the HTTP failure it stands for, so it
+ * classifies and retries like the same failure on the status line
+ */
+ fun accept(event: ClaudeStreamEvent): Boolean {
+ when (event) {
+ is ClaudeStreamEvent.Text -> deliver(event.text)
+ is ClaudeStreamEvent.ToolStart -> {
+ lastBlockIndex = event.index
+ calls.start(event.index, event.id, event.name)
+ }
+ is ClaudeStreamEvent.ToolInput -> calls.appendInput(event.index, event.partialJson)
+ is ClaudeStreamEvent.BlockOpened -> {
+ lastBlockIndex = event.index
+ if (event.type == "thinking" || event.type == "redacted_thinking") thinkingBlocks++
+ if (event.type == "text" && event.text.isNotEmpty()) deliver(event.text)
+ }
+ is ClaudeStreamEvent.FallbackSwitch -> {
+ lastBlockIndex = event.index
+ servedBy = event.toModel ?: servedBy
+ discardedAtFallback += calls.discardAll()
+ }
+ is ClaudeStreamEvent.Started -> servedBy = event.model
+ is ClaudeStreamEvent.Stop -> stopReason = event.reason
+ is ClaudeStreamEvent.Failure -> throw ClaudeHttpException(
+ ClaudeHttpException.statusForStreamError(event.errorType),
+ JSONObject().put(
+ "error",
+ JSONObject().put("type", event.errorType).put("message", event.message)
+ ).toString(),
+ )
+ ClaudeStreamEvent.Done -> return false
+ ClaudeStreamEvent.Ignored -> Unit
+ // One bad line must not abort a stream that is otherwise producing text.
+ is ClaudeStreamEvent.Malformed -> skippedLines++
+ }
+ return true
+ }
+
+ private fun deliver(piece: String) {
+ chunks++
+ text.append(piece)
+ onText(piece)
+ }
+
+ /** Ends the turn: what the caller should be told, once the stream is over. */
+ fun finish(): Result {
+ // Checked before the calls: a declined turn's partial output is not an answer, and a tool
+ // call inside it is not one the model stands behind.
+ if (stopReason == "refusal") return Result.Refused
+ // The block open at a max_tokens stop was cut off; a tool call there may parse, as an
+ // empty input does, yet is not the call the model meant.
+ if (stopReason == "max_tokens") calls.dropAt(lastBlockIndex)
+ val requests = calls.requests()
+ droppedCalls = calls.droppedCalls
+ val reply = text.toString()
+ // A turn that called a tool and said nothing is the normal agent turn, so only a reply
+ // with neither text nor a call is empty.
+ if (requests.isEmpty() && reply.isBlank()) return Result.Empty(emptyReplyFailure())
+ return Result.Reply(reply, requests)
+ }
+
+ /**
+ * Which empty-reply case this turn is. Ordered by how actionable the advice is: a cap that
+ * cut the turn off has a cause the user can see, while an unrecognised shape only has a log.
+ */
+ private fun emptyReplyFailure(): ClaudeFailure = when {
+ stopReason == "max_tokens" -> ClaudeFailure.TruncatedBeforeReply
+ // Input that stops mid-JSON is a cut-off reply, whatever the API said stopped it.
+ droppedCalls > 0 -> ClaudeFailure.TruncatedBeforeReply
+ thinkingBlocks > 0 -> ClaudeFailure.ReasoningOnly
+ else -> ClaudeFailure.EmptyReply(skippedLines)
+ }
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/WorkspaceIds.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/WorkspaceIds.kt
new file mode 100644
index 00000000..41fcc849
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/WorkspaceIds.kt
@@ -0,0 +1,55 @@
+package com.itsaky.androidide.plugins.aiagentclaude.backend
+
+/**
+ * The workspace a key that belongs to no workspace must name on every request.
+ *
+ * Such a key is refused with a 400 until each request carries an `anthropic-workspace-id` header,
+ * and the plugin cannot look the id up itself: listing workspaces takes an admin key, not the
+ * user's API key. So the user types it, and what they type becomes a header value. Pure, so the
+ * rule that keeps a pasted value from forging a second header is unit-testable.
+ */
+internal object WorkspaceIds {
+
+ /** The header the API reads the id from. */
+ const val HEADER = "anthropic-workspace-id"
+
+ /**
+ * Longest id accepted. Real ids are far shorter; the cap only bounds what a paste can put in
+ * a header.
+ */
+ private const val MAX_LENGTH = 128
+
+ /** What [normalize] concluded about a typed id. */
+ sealed interface Result {
+ /** Nothing was typed: requests carry no workspace header. */
+ data object None : Result
+
+ /** A usable id, trimmed. */
+ data class Valid(val id: String) : Result
+
+ /**
+ * Not usable as a header value: it has whitespace or a control character inside it, or is
+ * too long. A line break in particular would let a pasted value add a header of its own.
+ */
+ data object Invalid : Result
+ }
+
+ /**
+ * Checks a typed workspace id.
+ *
+ * The `wrkspc_` prefix real ids carry is not required: the API is the judge of what a
+ * workspace id looks like, and an id it rejects is reported like any other refusal.
+ *
+ * @param input the id as typed or stored, possibly null
+ */
+ fun normalize(input: String?): Result {
+ val id = input?.trim().orEmpty()
+ if (id.isEmpty()) return Result.None
+ if (id.length > MAX_LENGTH) return Result.Invalid
+ if (id.any { it.isWhitespace() || it.isISOControl() || it.code > 0x7E }) return Result.Invalid
+ return Result.Valid(id)
+ }
+
+ /** The id to send for [input], or null when none should be: blank or unusable. */
+ fun headerValue(input: String?): String? = (normalize(input) as? Result.Valid)?.id
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeErrorFormatter.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeErrorFormatter.kt
new file mode 100644
index 00000000..bb65a0de
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeErrorFormatter.kt
@@ -0,0 +1,289 @@
+package com.itsaky.androidide.plugins.aiagentclaude.errors
+
+import androidx.annotation.StringRes
+import com.itsaky.androidide.plugins.aiagentclaude.R
+import org.json.JSONObject
+import java.io.IOException
+
+/**
+ * What the Claude API said went wrong, as far as it could be determined.
+ *
+ * Every field is nullable because the failure may not be an API response at all — a DNS failure,
+ * a dropped connection, or a captive portal's HTML page reaches the same code path.
+ */
+data class ClaudeApiError(
+ /** HTTP status lifted from the `... HTTP : ` message, or null if there wasn't one. */
+ val httpStatus: Int?,
+ /** The API's `error.type`, e.g. `authentication_error`, or null. */
+ val apiType: String?,
+ /** The API's human-readable `error.message`, collapsed to one line, or null. */
+ val apiMessage: String?,
+)
+
+/**
+ * A Claude failure reduced to the thing the user needs to be told.
+ *
+ * Carries no text: the wording lives in `strings.xml`, which also lets every branch be unit-tested
+ * without a Context. Any reason is already single-lined, length-capped, and never a JSON body.
+ */
+sealed interface ClaudeFailure {
+
+ /** The model is unknown, or not available to this account (HTTP 404). */
+ data class ModelUnavailable(val modelName: String) : ClaudeFailure
+
+ /** Rate limit (HTTP 429). The key itself is fine. */
+ data object QuotaExceeded : ClaudeFailure
+
+ /** The account has no credit left (HTTP 402, or a 400 that says the balance is too low). */
+ data object BillingRequired : ClaudeFailure
+
+ /** The credential was refused (HTTP 401). */
+ data object KeyRefused : ClaudeFailure
+
+ /** No key was configured, so none was sent (HTTP 401 with nothing sent). */
+ data object KeyMissing : ClaudeFailure
+
+ /** The key is valid but not allowed to do this (HTTP 403). */
+ data object KeyForbidden : ClaudeFailure
+
+ /**
+ * The key belongs to no workspace, so the API wants an `anthropic-workspace-id` header with
+ * every request (HTTP 400). Its own state because the cure is entering a workspace ID in the
+ * settings pane, not resending, and the API's explanation is longer than a reason this class
+ * echoes.
+ */
+ data object KeyNeedsWorkspace : ClaudeFailure
+
+ /** The conversation is larger than the API accepts (HTTP 413). */
+ data object RequestTooLarge : ClaudeFailure
+
+ /** HTTP 400 about the request rather than the credential. */
+ data class RequestRejected(val reason: String?) : ClaudeFailure
+
+ /** API overloaded (529) or failing (5xx). Says nothing about the key or the model. */
+ data class ServiceUnavailable(val httpStatus: Int) : ClaudeFailure
+
+ /** An HTTP status with no specific handling. */
+ data class Unexpected(val httpStatus: Int, val reason: String?) : ClaudeFailure
+
+ /** No response at all — no network, DNS failure, or a connection that timed out. */
+ data object Unreachable : ClaudeFailure
+
+ /** The API accepted the request and then sent nothing for longer than the read timeout. */
+ data object Stalled : ClaudeFailure
+
+ /**
+ * The model declined the request (`stop_reason: "refusal"`), and any fallback the API tried
+ * declined it too. The request did not fail, so this is not reported as an error with it.
+ */
+ data object Refused : ClaudeFailure
+
+ /**
+ * The stream ended successfully but carried no reply text.
+ *
+ * Its own state because the request did **not** fail: reporting a network-shaped error here
+ * sends the user hunting for a connection problem that does not exist.
+ *
+ * @param skippedChunks payloads the parser could not use, which is the diagnostic
+ */
+ data class EmptyReply(val skippedChunks: Int) : ClaudeFailure
+
+ /** The model thought and never got to an answer before the turn ended. */
+ data object ReasoningOnly : ClaudeFailure
+
+ /** The token cap cut the turn off before any reply text arrived (`stop_reason: max_tokens`). */
+ data object TruncatedBeforeReply : ClaudeFailure
+
+ /** Everything else, including failures that never reached the network. */
+ data class Failed(val reason: String?) : ClaudeFailure
+}
+
+/**
+ * A credential failure in the form the settings pane can be handed: a tag stable enough to persist,
+ * and the wording resolved wherever it is shown.
+ *
+ * The pane is handed one of these rather than a rendered sentence. A sentence recorded at the
+ * moment of failure is frozen in the locale it was produced in — change the device language and
+ * the banner reads half in each — and it outlives a later correction to the wording.
+ */
+internal enum class CredentialFailure(val tag: String, @get:StringRes val messageRes: Int) {
+ KeyRefused("key_refused", R.string.claude_error_key_refused),
+ KeyMissing("key_missing", R.string.claude_error_key_missing),
+ KeyForbidden("key_forbidden", R.string.claude_error_key_forbidden),
+ KeyNeedsWorkspace("key_needs_workspace", R.string.claude_error_key_needs_workspace);
+
+ companion object {
+ /**
+ * The credential failure [failure] is, or null when it is about something else.
+ *
+ * The settings pane reports only these: a 500, a rate limit or an unreachable API says
+ * nothing about the key, and reporting one as a credential problem would send the user off
+ * to replace a key that works. [ClaudeFailure.QuotaExceeded] and
+ * [ClaudeFailure.BillingRequired] are deliberately outside it — the key was accepted, the
+ * account simply has nothing left to spend. Listed exhaustively so a failure added later
+ * has to be classified here.
+ */
+ fun of(failure: ClaudeFailure): CredentialFailure? = when (failure) {
+ ClaudeFailure.KeyRefused -> KeyRefused
+ ClaudeFailure.KeyMissing -> KeyMissing
+ ClaudeFailure.KeyForbidden -> KeyForbidden
+ ClaudeFailure.KeyNeedsWorkspace -> KeyNeedsWorkspace
+ is ClaudeFailure.ModelUnavailable,
+ ClaudeFailure.QuotaExceeded,
+ ClaudeFailure.BillingRequired,
+ ClaudeFailure.RequestTooLarge,
+ is ClaudeFailure.RequestRejected,
+ is ClaudeFailure.ServiceUnavailable,
+ is ClaudeFailure.Unexpected,
+ ClaudeFailure.Unreachable,
+ ClaudeFailure.Stalled,
+ ClaudeFailure.Refused,
+ is ClaudeFailure.EmptyReply,
+ ClaudeFailure.ReasoningOnly,
+ ClaudeFailure.TruncatedBeforeReply,
+ is ClaudeFailure.Failed -> null
+ }
+
+ /** The failure [tag] names, or null for a tag this release no longer knows. */
+ fun ofTag(tag: String): CredentialFailure? = entries.firstOrNull { it.tag == tag }
+ }
+}
+
+/** Whether this failure is about the credential rather than the request, the model or the network. */
+internal val ClaudeFailure.isCredentialProblem: Boolean
+ get() = CredentialFailure.of(this) != null
+
+/**
+ * Classifies a Claude failure so it can be reported as one translated sentence.
+ *
+ * The log keeps the full body; **no [ClaudeFailure] ever carries a JSON payload** — putting the raw
+ * error body in the chat transcript is the bug this class exists to prevent.
+ */
+object ClaudeErrorFormatter {
+
+ /**
+ * Matches the status in a `Claude HTTP 404: {...}` message. A fallback: a status that arrived
+ * as a [ClaudeHttpException] field is read from the field, never from text.
+ */
+ private val HTTP_STATUS = Regex("""HTTP (\d{3})""")
+
+ /** Longest slice of the API's own wording carried onward; keeps a stray body out of the UI. */
+ private const val MAX_ECHOED_REASON = 160
+
+ /**
+ * Pull the status code and, when the message carries a JSON error body, the API's own
+ * `type`/`message` out of it. A non-JSON, truncated or absent body yields nulls rather than
+ * throwing, because this runs while already handling a failure.
+ *
+ * @param rawMessage the throwable message, typically `Claude HTTP : `
+ */
+ fun parse(rawMessage: String?): ClaudeApiError {
+ val raw = rawMessage.orEmpty()
+ val error = errorObjectIn(raw)
+
+ return ClaudeApiError(
+ httpStatus = HTTP_STATUS.find(raw)?.groupValues?.get(1)?.toIntOrNull(),
+ apiType = error?.optString("type")?.takeIf { it.isNotBlank() },
+ apiMessage = error?.optString("message")?.takeIf { it.isNotBlank() }?.toSingleLine(),
+ )
+ }
+
+ /**
+ * Decide what to tell the user about [error].
+ *
+ * @param error the failure as thrown; its message is parsed, and its type distinguishes a
+ * transport problem from an API refusal when there is no status to read
+ * @param modelName the model the request was for, so an unknown-model failure can name it
+ * @param hasApiKey whether a key was actually sent, to tell "wrong key" from "no key"
+ */
+ fun classify(
+ error: Throwable,
+ modelName: String,
+ hasApiKey: Boolean,
+ ): ClaudeFailure {
+ val parsed = parse(error.message)
+ // The transport reports its status as a field; the pattern below only has to cover a
+ // failure that reached here some other way.
+ val status = (error as? ClaudeHttpException)?.statusCode ?: parsed.httpStatus
+
+ return when {
+ status == 404 || parsed.apiType == "not_found_error" ->
+ ClaudeFailure.ModelUnavailable(modelName)
+
+ status == 402 || parsed.apiType == "billing_error" || parsed.mentionsCredit() ->
+ ClaudeFailure.BillingRequired
+
+ status == 429 || parsed.apiType == "rate_limit_error" -> ClaudeFailure.QuotaExceeded
+
+ status == 401 && !hasApiKey -> ClaudeFailure.KeyMissing
+
+ status == 401 || parsed.apiType == "authentication_error" -> ClaudeFailure.KeyRefused
+
+ status == 403 || parsed.apiType == "permission_error" -> ClaudeFailure.KeyForbidden
+
+ status == 413 || parsed.apiType == "request_too_large" -> ClaudeFailure.RequestTooLarge
+
+ status == 400 && parsed.needsWorkspace() -> ClaudeFailure.KeyNeedsWorkspace
+
+ status == 400 -> ClaudeFailure.RequestRejected(safeReason(parsed, error))
+
+ // 529 is the API's "overloaded", which is the common one.
+ status != null && status in 500..599 -> ClaudeFailure.ServiceUnavailable(status)
+
+ status != null -> ClaudeFailure.Unexpected(status, safeReason(parsed, error))
+
+ // Before the generic IOException: the request was accepted, so the network is fine.
+ error is ClaudeStreamStalledException -> ClaudeFailure.Stalled
+
+ // No status at all: the request never got an answer.
+ error is IOException -> ClaudeFailure.Unreachable
+
+ else -> ClaudeFailure.Failed(safeReason(parsed, error))
+ }
+ }
+
+ /**
+ * True when the API is saying the account is out of money. It reports that as a 400
+ * `invalid_request_error` whose message names the credit balance, not as a status of its own.
+ */
+ private fun ClaudeApiError.mentionsCredit(): Boolean =
+ apiMessage?.lowercase()?.contains("credit balance") == true
+
+ /**
+ * True when the API is refusing a key that belongs to no workspace. It names the header it
+ * wants, which is the most stable part of the wording to match on.
+ */
+ internal fun ClaudeApiError.needsWorkspace(): Boolean =
+ apiMessage?.contains("anthropic-workspace-id") == true
+
+ /**
+ * The API's own explanation, but only when it is short and safe to show.
+ *
+ * Falls back to the throwable's message when there was no JSON body, and never when that
+ * message contains one — carrying a `{` onward is the bug this class exists to prevent.
+ *
+ * @return the reason, or null when there is nothing showable
+ */
+ private fun safeReason(parsed: ClaudeApiError, error: Throwable): String? {
+ val reason = parsed.apiMessage
+ ?: error.message?.takeIf { !it.contains('{') }?.toSingleLine()
+ ?: return null
+ if (reason.isBlank() || reason.length > MAX_ECHOED_REASON) return null
+ return reason
+ }
+
+ /**
+ * The `error` object of an API error body, wherever it starts inside [raw]. Never throws: it
+ * runs while a failure is already being handled.
+ *
+ * @param raw a throwable message or a raw response body
+ */
+ internal fun errorObjectIn(raw: String?): JSONObject? {
+ val start = raw?.indexOf('{') ?: return null
+ if (start < 0) return null
+ return runCatching { JSONObject(raw.substring(start)).optJSONObject("error") }.getOrNull()
+ }
+
+ /** Collapse whitespace runs so a pretty-printed JSON string can't span lines in the UI. */
+ private fun String.toSingleLine(): String = trim().replace(Regex("""\s+"""), " ")
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeFailureMessages.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeFailureMessages.kt
new file mode 100644
index 00000000..d94858ad
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeFailureMessages.kt
@@ -0,0 +1,86 @@
+package com.itsaky.androidide.plugins.aiagentclaude.errors
+
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aiagentclaude.R
+
+/**
+ * The wording for a [ClaudeFailure].
+ *
+ * `context.androidContext` is plugin-scoped, so this plugin's string ids resolve here. Separate
+ * from the backend, which decides *what* failed and has no business also owning how it is phrased.
+ *
+ * @param context this plugin's context, whose resources carry the strings
+ */
+internal class ClaudeFailureMessages(private val context: PluginContext) {
+
+ /**
+ * One user-facing sentence for [failure]. A failed lookup degrades to the generic message
+ * rather than throwing out of an error handler.
+ */
+ fun of(failure: ClaudeFailure): String = try {
+ val resources = context.androidContext
+ when (failure) {
+ is ClaudeFailure.ModelUnavailable ->
+ resources.getString(R.string.claude_error_model_unavailable, failure.modelName)
+
+ ClaudeFailure.QuotaExceeded ->
+ resources.getString(R.string.claude_error_quota)
+
+ ClaudeFailure.BillingRequired ->
+ resources.getString(R.string.claude_error_billing)
+
+ // Through CredentialFailure, which is also what the settings pane resolves, so the
+ // transcript and the pane cannot describe the same refusal differently.
+ ClaudeFailure.KeyRefused ->
+ resources.getString(CredentialFailure.KeyRefused.messageRes)
+
+ ClaudeFailure.KeyMissing ->
+ resources.getString(CredentialFailure.KeyMissing.messageRes)
+
+ ClaudeFailure.KeyForbidden ->
+ resources.getString(CredentialFailure.KeyForbidden.messageRes)
+
+ ClaudeFailure.KeyNeedsWorkspace ->
+ resources.getString(CredentialFailure.KeyNeedsWorkspace.messageRes)
+
+ ClaudeFailure.RequestTooLarge ->
+ resources.getString(R.string.claude_error_too_large)
+
+ is ClaudeFailure.RequestRejected -> failure.reason?.let {
+ resources.getString(R.string.claude_error_request_rejected_reason, it)
+ } ?: resources.getString(R.string.claude_error_request_rejected)
+
+ is ClaudeFailure.ServiceUnavailable ->
+ resources.getString(R.string.claude_error_service_unavailable, failure.httpStatus)
+
+ is ClaudeFailure.Unexpected -> failure.reason?.let {
+ resources.getString(R.string.claude_error_unexpected_reason, failure.httpStatus, it)
+ } ?: resources.getString(R.string.claude_error_unexpected, failure.httpStatus)
+
+ ClaudeFailure.Refused ->
+ resources.getString(R.string.claude_error_refused)
+
+ is ClaudeFailure.EmptyReply ->
+ resources.getString(R.string.claude_error_empty_reply)
+
+ ClaudeFailure.ReasoningOnly ->
+ resources.getString(R.string.claude_error_reasoning_only)
+
+ ClaudeFailure.TruncatedBeforeReply ->
+ resources.getString(R.string.claude_error_truncated)
+
+ ClaudeFailure.Unreachable ->
+ resources.getString(R.string.claude_error_unreachable)
+
+ ClaudeFailure.Stalled ->
+ resources.getString(R.string.claude_error_stalled)
+
+ is ClaudeFailure.Failed -> failure.reason?.let {
+ resources.getString(R.string.claude_error_failed_reason, it)
+ } ?: resources.getString(R.string.claude_error_failed)
+ }
+ } catch (e: Exception) {
+ context.logger.error("ClaudeFailureMessages: could not resolve a string for $failure", e)
+ "The request to Claude failed."
+ }
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeHttpException.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeHttpException.kt
new file mode 100644
index 00000000..ec7d61d3
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/ClaudeHttpException.kt
@@ -0,0 +1,52 @@
+package com.itsaky.androidide.plugins.aiagentclaude.errors
+
+import java.io.IOException
+
+/**
+ * A failure the Claude API reported: a non-2xx answer, or an `error` event inside a stream that had
+ * already started.
+ *
+ * The status and the body are fields rather than something a reader digs back out of the message:
+ * the settings pane's verdict and the transient-failure retry both need the status, and reading it
+ * out of formatted text made the wording of a log line a contract that a reword would silently
+ * break.
+ *
+ * @param statusCode the HTTP status, or for a stream error the status its error type maps to
+ * @param body the API's error body; never shown to the user unfiltered
+ * @param retryAfterSeconds the `retry-after` header, when the API sent one
+ */
+class ClaudeHttpException(
+ val statusCode: Int,
+ val body: String,
+ val retryAfterSeconds: Long? = null,
+) : IOException("Claude HTTP $statusCode: $body") {
+
+ companion object {
+
+ /**
+ * The status an in-stream `error` event stands for, so it classifies — and retries — like
+ * the same failure arriving as an HTTP status.
+ *
+ * @param errorType the event's `error.type`, e.g. `overloaded_error`
+ */
+ fun statusForStreamError(errorType: String?): Int = when (errorType) {
+ "overloaded_error" -> 529
+ "rate_limit_error" -> 429
+ "api_error" -> 500
+ "authentication_error" -> 401
+ "permission_error" -> 403
+ "not_found_error" -> 404
+ "request_too_large" -> 413
+ "billing_error" -> 402
+ "invalid_request_error" -> 400
+ else -> 500
+ }
+ }
+}
+
+/**
+ * The API accepted the request, then went silent for longer than the read timeout. Its own type
+ * because the network was evidently up: reporting it as "check your internet connection" sends
+ * the user after the wrong problem.
+ */
+class ClaudeStreamStalledException(cause: Throwable) : IOException("Claude stopped responding", cause)
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/CredentialFailureLog.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/CredentialFailureLog.kt
new file mode 100644
index 00000000..251b7e12
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/errors/CredentialFailureLog.kt
@@ -0,0 +1,57 @@
+package com.itsaky.androidide.plugins.aiagentclaude.errors
+
+import android.content.SharedPreferences
+import com.itsaky.androidide.plugins.aiagentclaude.preferences.ClaudePreferences
+
+/**
+ * Why the last request was refused for credential reasons, kept for the settings pane to report.
+ *
+ * A refused request is otherwise only legible in the transcript, which the user has already left
+ * by the time they open the pane to fix the key — so the one screen that can fix it said nothing
+ * (ADFA-5491). Holds the classified failure, never the credential and never a rendered sentence.
+ *
+ * @param prefs this plugin's settings store; re-read on every call, and null before `initialize()`
+ */
+internal class CredentialFailureLog(private val prefs: () -> SharedPreferences?) {
+
+ /**
+ * Records the credential problem [failure] describes, replacing any earlier one. A failure
+ * about anything else is not recorded — [CredentialFailure.of] is the same test the caller's
+ * [isCredentialProblem] applies, so this drops nothing a caller meant to record.
+ *
+ * @param keyStamp when the key the refused request carried was saved, taken where the request
+ * read that key rather than here: a refusal can land after the user has already saved a
+ * replacement, and this stamp is what keeps it from being reported against the new key
+ */
+ fun record(failure: ClaudeFailure, keyStamp: Long) {
+ val credential = CredentialFailure.of(failure) ?: return
+ prefs()?.edit()
+ ?.putString(ClaudePreferences.KEY_CREDENTIAL_FAILURE, credential.tag)
+ ?.putLong(ClaudePreferences.KEY_CREDENTIAL_FAILURE_KEY_STAMP, keyStamp)
+ ?.apply()
+ }
+
+ /**
+ * The recorded failure, or null unless it describes the credential that is on disk now. Equal
+ * stamps mean nothing was saved or cleared between the refused request reading the key and
+ * now, so a refusal that landed after either is dropped.
+ */
+ fun read(): CredentialFailure? {
+ val prefs = prefs() ?: return null
+ val tag = prefs.getString(ClaudePreferences.KEY_CREDENTIAL_FAILURE, null)
+ ?.takeIf { it.isNotBlank() }
+ ?: return null
+ val keyStamp = prefs.getLong(ClaudePreferences.KEY_CREDENTIAL_FAILURE_KEY_STAMP, 0L)
+ // Clear removes the key's timestamp too, so "not older than" would keep a keyless refusal.
+ if (keyStamp != prefs.getLong(ClaudePreferences.KEY_API_KEY_TIMESTAMP, 0L)) return null
+ return CredentialFailure.ofTag(tag)
+ }
+
+ /** Forgets the reason: the credential changed, or a request has since gone through on it. */
+ fun clear() {
+ prefs()?.edit()
+ ?.remove(ClaudePreferences.KEY_CREDENTIAL_FAILURE)
+ ?.remove(ClaudePreferences.KEY_CREDENTIAL_FAILURE_KEY_STAMP)
+ ?.apply()
+ }
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/logging/LogTags.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/logging/LogTags.kt
new file mode 100644
index 00000000..cc951f82
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/logging/LogTags.kt
@@ -0,0 +1,8 @@
+package com.itsaky.androidide.plugins.aiagentclaude.logging
+
+/**
+ * Prefix on every logcat tag this plugin writes, so a line names the plugin that emitted it — every
+ * AI feature shares the host IDE's process, where a bare `SecureApiKeyStore` tag names no `.cgp`.
+ * Tags read `"$LOG_PREFIX.ClassName"`, so `adb logcat -s AiAgentClaude.*` is this plugin's log.
+ */
+internal const val LOG_PREFIX = "AiAgentClaude"
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/plugin/ClaudePlugin.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/plugin/ClaudePlugin.kt
new file mode 100644
index 00000000..1b0b6c08
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/plugin/ClaudePlugin.kt
@@ -0,0 +1,306 @@
+package com.itsaky.androidide.plugins.aiagentclaude.plugin
+
+import com.itsaky.androidide.plugins.IPlugin
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.PluginLifecycleListener
+import com.itsaky.androidide.plugins.aiagentclaude.backend.ClaudeBackend
+import com.itsaky.androidide.plugins.extensions.DocumentationExtension
+import com.itsaky.androidide.plugins.extensions.PluginTooltipButton
+import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry
+import com.itsaky.androidide.plugins.services.LlmInferenceService
+import com.itsaky.androidide.plugins.services.SharedServices
+
+/**
+ * Registers the Claude backend with AI Core's inference router.
+ *
+ * Owns the transport *and* the UI that configures it: the backend names a settings Fragment that
+ * ships in this plugin, which whichever screen offers a backend selector mounts under its own
+ * selector. AI Core owns routing; nothing outside this plugin handles the API key.
+ */
+class ClaudePlugin : IPlugin, DocumentationExtension {
+
+ private lateinit var context: PluginContext
+ private var backend: ClaudeBackend? = null
+
+ /** True once [backend] is registered with the router, so re-registration is idempotent. */
+ @Volatile private var registered = false
+
+ companion object {
+ const val PLUGIN_ID = "com.itsaky.androidide.plugins.aiagentclaude"
+
+ /** Provider of [LlmInferenceService]; this plugin is useless without it. */
+ private const val AI_CORE_PLUGIN_ID = "com.itsaky.androidide.plugins.aicore"
+
+ private const val TOOLTIP_TAG_PLUGIN = "plugin_ai_backend_claude"
+
+ /**
+ * Category the host registers this plugin's tooltips under. Must be `"plugin_"` + the full
+ * plugin id, or a long-press renders the literal string `n/a`.
+ */
+ const val TOOLTIP_CATEGORY = "plugin_$PLUGIN_ID"
+
+ // Tags for the controls on this backend's settings pane (see ClaudeSettingsFragment).
+ const val TOOLTIP_TAG_SETTINGS_KEY = "ai_claude_key"
+ const val TOOLTIP_TAG_SETTINGS_WORKSPACE = "ai_claude_workspace"
+ const val TOOLTIP_TAG_SETTINGS_MODEL = "ai_claude_model"
+ const val TOOLTIP_TAG_SETTINGS_TEST = "ai_claude_test_connection"
+ const val TOOLTIP_TAG_SETTINGS_GET_KEY = "ai_claude_get_key"
+
+ @Volatile
+ private var pluginContext: PluginContext? = null
+
+ @Volatile
+ private var activeBackend: ClaudeBackend? = null
+
+ /** This plugin's context, for the settings pane the backend contributes. */
+ fun getContext(): PluginContext? = pluginContext
+
+ /**
+ * The live backend, so the settings pane can test a connection and list models against the
+ * same transport that serves generation. Null before activation and after disposal.
+ */
+ fun getBackend(): ClaudeBackend? = activeBackend
+ }
+
+ /**
+ * Re-registers when AI Core activates. Plugins load in parallel with no ordering, so
+ * [activate] may run before AI Core has published its service; this closes that race instead
+ * of polling for it.
+ */
+ private val aiCoreLifecycle = object : PluginLifecycleListener {
+ override fun onPluginActivated(pluginId: String) {
+ if (pluginId == AI_CORE_PLUGIN_ID) registerBackend()
+ }
+
+ override fun onPluginDeactivated(pluginId: String) {
+ // The router went away and took the registration with it; allow a fresh one.
+ if (pluginId == AI_CORE_PLUGIN_ID) registered = false
+ }
+
+ override fun onPluginUninstalled(pluginId: String) {
+ if (pluginId == AI_CORE_PLUGIN_ID) registered = false
+ }
+ }
+
+ override fun initialize(context: PluginContext): Boolean {
+ return try {
+ this.context = context
+ // Published for the settings pane, which the hosting screen constructs directly.
+ pluginContext = context
+ context.logger.info("ClaudePlugin: Plugin initialized successfully")
+ true
+ } catch (e: Exception) {
+ context.logger.error("ClaudePlugin: Plugin initialization failed", e)
+ false
+ }
+ }
+
+ override fun activate(): Boolean {
+ context.logger.info("ClaudePlugin: Activating plugin")
+
+ return try {
+ // A half-failed activation can leave a backend behind; keep at most one live.
+ releaseBackend()
+
+ val claude = ClaudeBackend(context)
+ backend = claude
+ activeBackend = claude
+
+ // Decrypt the key off-thread now, so a main-thread isAvailable() can't say "no key".
+ claude.warmKeyCache()
+
+ // Listen first, then try: a listener added after a successful attempt would still be
+ // needed for a later AI Core restart, and one added before costs nothing.
+ context.addPluginLifecycleListener(aiCoreLifecycle)
+ if (!registerBackend()) {
+ context.logger.info(
+ "ClaudePlugin: AI Core is not active yet; will register when it activates"
+ )
+ }
+
+ true
+ } catch (e: Exception) {
+ context.logger.error("ClaudePlugin: Activation failed", e)
+ false
+ }
+ }
+
+ /**
+ * Registers the backend with AI Core's router, if the router is reachable.
+ *
+ * @return true when the backend is registered (now or already), false when AI Core is absent
+ */
+ private fun registerBackend(): Boolean {
+ if (registered) return true
+ val claude = backend ?: return false
+
+ val service = resolveInferenceService()
+ if (service == null) {
+ context.logger.debug("ClaudePlugin: LlmInferenceService not available yet")
+ return false
+ }
+
+ return try {
+ service.registerBackend(claude)
+ registered = true
+ context.logger.info("ClaudePlugin: Registered '${claude.getId()}' backend with AI Core")
+ true
+ } catch (e: Exception) {
+ context.logger.error("ClaudePlugin: Could not register the Claude backend", e)
+ false
+ }
+ }
+
+ /**
+ * Resolves AI Core's router, preferring the process-global registry and falling back to the
+ * provider-scoped lookup so a registry cleared by another plugin is not fatal.
+ */
+ private fun resolveInferenceService(): LlmInferenceService? = try {
+ SharedServices.get(LlmInferenceService::class.java)
+ ?: context.getPluginService(AI_CORE_PLUGIN_ID, LlmInferenceService::class.java)
+ } catch (e: Exception) {
+ context.logger.warn("ClaudePlugin: Could not resolve LlmInferenceService: ${e.message}")
+ null
+ }
+
+ override fun deactivate(): Boolean {
+ context.logger.info("ClaudePlugin: Deactivating plugin")
+
+ return try {
+ context.removePluginLifecycleListener(aiCoreLifecycle)
+
+ val claude = backend
+ if (claude != null && registered) {
+ resolveInferenceService()?.unregisterBackend(claude.getId())
+ registered = false
+ context.logger.info("ClaudePlugin: Unregistered '${claude.getId()}' backend")
+ }
+
+ // A disabled plugin must not keep the decrypted key on the host heap.
+ releaseBackend()
+
+ true
+ } catch (e: Exception) {
+ context.logger.error("ClaudePlugin: Deactivation failed", e)
+ false
+ }
+ }
+
+ /**
+ * Cancels in-flight requests, drops the decrypted key from the heap, and clears the published
+ * backend. Idempotent, so a [deactivate] followed by [dispose] closes nothing twice.
+ */
+ private fun releaseBackend() {
+ backend?.close()
+ backend = null
+ activeBackend = null
+ registered = false
+ }
+
+ override fun dispose() {
+ context.logger.info("ClaudePlugin: Disposing plugin")
+
+ // deactivate() removes this too; a dispose without one would leave the host holding this.
+ runCatching { context.removePluginLifecycleListener(aiCoreLifecycle) }
+
+ releaseBackend()
+ pluginContext = null
+ context.logger.info("ClaudePlugin: Released Claude backend")
+ }
+
+ override fun getTooltipCategory(): String = "plugin_$PLUGIN_ID"
+
+ override fun getTooltipEntries(): List = listOf(
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_PLUGIN,
+ summary = "Sends prompts to Anthropic's Claude. Needs an API key and a network connection.",
+ detail = """
+
AI Agent Claude is a headless plugin that adds the
+ claude backend to AI Core, calling Anthropic's
+ Messages API over HTTPS.
+
The agent's tools are declared to Claude directly, so it reads
+ and edits your project through structured calls rather than
+ text it has to get exactly right.
+
Install AI Core as well, then add your key in
+ AI Core → Agent settings. Prompts and any file
+ contents a plugin sends are transmitted to Anthropic.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(
+ description = "AI Agent Claude guide",
+ uri = "index.html",
+ order = 0
+ )
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_SETTINGS_KEY,
+ summary = "Your Claude API key. Required: Claude has no anonymous access.",
+ detail = """
+
Keys start with sk-ant- and come from the Claude
+ Console, not from a Claude.ai subscription.
+
The key is checked against Claude before being saved, then
+ encrypted with the Android Keystore. Only the ciphertext is
+ written to disk. A key that cannot be checked, because the device
+ is offline, can still be saved but is marked unverified rather
+ than claiming a check that never happened.
+ """.trimIndent(),
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_SETTINGS_WORKSPACE,
+ summary = "Only for a key that is not in a workspace: the workspace Claude should bill it to.",
+ detail = """
+
A key that is not in a workspace is refused on every request
+ until it names one, so this field appears only after Claude has
+ said so, and a key in a workspace never needs it.
+
Workspace IDs start with wrkspc_. Find yours in
+ the Claude Console under Workspaces, then copy it into this
+ field. It is saved with the key and removed when the key is
+ cleared.
+ """.trimIndent(),
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_SETTINGS_MODEL,
+ summary = "Which Claude model to use. Type any id, or tap to pick one your key can use.",
+ detail = """
+
One field, and it accepts both: type a model id, or tap it to
+ choose from the list Test Connection & List Models
+ fetched. Typing is saved as soon as you leave the field.
+
The default, claude-opus-5-5, suits building
+ apps. claude-fable-5-1 is more capable and costs
+ more; claude-sonnet-5-5 is faster and cheaper;
+ claude-haiku-4-5 is fastest and cheapest.
+ """.trimIndent(),
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_SETTINGS_TEST,
+ summary = "Checks your key and fills the model list. Both are the same request.",
+ detail = """
+
Tests the key without saving it, so a typo is caught here rather
+ than mid-chat. When Claude answers, that same answer fills the
+ Model list with the models your key can use.
+
Couldn't reach Claude means this device has no route to
+ the internet, or Claude is briefly overloaded. Try again in a
+ moment.
+ """.trimIndent(),
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_SETTINGS_GET_KEY,
+ summary = "Opens the Claude Console's API keys page in your browser. API use is not free.",
+ detail = """
+
Opens platform.claude.com in your own browser,
+ never an embedded WebView, so you can see the Console's URL bar
+ and sign-in works. Sign in, create a key, copy it, and paste it
+ into the field here.
+
The Claude API is billed to prepaid credit, separate from a
+ Claude.ai subscription. For a free option, use the
+ AI Agent Local or AI Agent Gemini plugin
+ instead.
+
This plugin never sees your password and never reads your
+ clipboard.
+ """.trimIndent(),
+ ),
+ )
+
+ override fun getTier3DocsAssetPath(): String = "docs"
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/preferences/ClaudePreferences.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/preferences/ClaudePreferences.kt
new file mode 100644
index 00000000..a8b36c7a
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/preferences/ClaudePreferences.kt
@@ -0,0 +1,69 @@
+package com.itsaky.androidide.plugins.aiagentclaude.preferences
+
+import android.content.SharedPreferences
+import com.itsaky.androidide.plugins.PluginContext
+
+/**
+ * This plugin's own settings store.
+ *
+ * The API key and model describe *this* backend, so they live in this plugin's storage rather
+ * than in AI Core's — a backend must be configurable whether or not any particular consumer
+ * plugin happens to be installed.
+ *
+ * There is no migration from an older file: this backend has never shipped before, so there is
+ * nothing on any device to adopt.
+ */
+internal object ClaudePreferences {
+
+ /** This plugin's preferences file. Namespaced to this plugin by the host. */
+ private const val FILE = "ClaudeSettings"
+
+ /** API key, stored as ciphertext only. */
+ const val KEY_API_KEY = "claude_api_key"
+
+ const val KEY_API_KEY_TIMESTAMP = "claude_api_key_timestamp"
+ const val KEY_API_KEY_VERIFIED = "claude_api_key_verified"
+
+ /**
+ * Workspace id sent with every request, for a key that belongs to no workspace. Absent for a
+ * key that does, which is the usual case. Belongs to the key, so it is cleared with it.
+ */
+ const val KEY_WORKSPACE_ID = "claude_workspace_id"
+
+ /** Model id to request, e.g. `claude-opus-5-5`. */
+ const val KEY_MODEL = "claude_model"
+
+ /**
+ * The last model list the API returned, so reopening the settings pane offers the dropdown
+ * without another request. Encoded by `RememberedModels`.
+ */
+ const val KEY_REMEMBERED_MODELS = "claude_remembered_models"
+
+ /**
+ * What the same listing said each model accepts — output cap, adaptive thinking, effort — so
+ * a request asks only for what its model takes. Encoded by `ClaudeModelCatalog`.
+ */
+ const val KEY_MODEL_CAPABILITIES = "claude_model_capabilities"
+
+ /**
+ * Why the last request was refused for credential reasons, or absent. Diagnostics rather than a
+ * setting: written by the backend, and cleared when a new credential is saved or a request goes
+ * through on the stored one — not when the settings pane reads it, which happens on every
+ * rotation.
+ */
+ const val KEY_CREDENTIAL_FAILURE = "claude_credential_failure"
+
+ /**
+ * When the key that [KEY_CREDENTIAL_FAILURE] describes was saved, so a refusal that lands after
+ * a replacement was saved can be told from one about the key in use.
+ */
+ const val KEY_CREDENTIAL_FAILURE_KEY_STAMP = "claude_credential_failure_key_stamp"
+
+ /**
+ * This plugin's preferences.
+ *
+ * @param context this plugin's own context — never another plugin's
+ */
+ fun of(context: PluginContext): SharedPreferences =
+ context.getPluginSharedPreferences(FILE)
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/ClaudeSystemPrompt.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/ClaudeSystemPrompt.kt
new file mode 100644
index 00000000..94b5d273
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/ClaudeSystemPrompt.kt
@@ -0,0 +1,110 @@
+package com.itsaky.androidide.plugins.aiagentclaude.prompt
+
+import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest
+
+/**
+ * The system prompt this backend asks for.
+ *
+ * Written for a large cloud model: it states a goal and a workflow and trusts the model to plan
+ * within them, where a small on-device model needs each step spelled out. That difference is a
+ * property of the model, so the prompt lives with the backend that talks to it.
+ *
+ * Model-facing text, so it stays in Kotlin rather than `strings.xml` — it is never shown to the
+ * user, must not be translated, and is asserted on in unit tests.
+ *
+ * Pure and free of Android types, so it is unit-testable without a device or a network.
+ */
+internal object ClaudeSystemPrompt {
+
+ /**
+ * Path used in the examples when the caller names none, so they still show a concrete shape.
+ */
+ private const val FALLBACK_EXAMPLE_PATH = "app/src/main/java/com/example/MainActivity.kt"
+
+ /** How to call a tool when the caller takes calls through the provider's own API. */
+ private val NATIVE_CALL_FORMAT = """
+ TOOL CALL FORMAT — the tools above are declared to you: call one through the function-calling
+ API. A call written into your reply text is NOT read by this system and will not run.
+ Do NOT describe the action in prose (e.g. "Okay, I'll open the file…") — narrating does nothing.
+ """.trimIndent()
+
+ /**
+ * Builds the prompt for [request].
+ *
+ * [SystemPromptRequest.toolCallSyntax] is reproduced verbatim — a paraphrase would produce
+ * replies nothing reads — and a null one means the caller reads calls off the provider's own
+ * function-calling API, so [NATIVE_CALL_FORMAT] replaces the envelope rather than joining it.
+ *
+ * @return the system prompt, without the caller's IDE-context block
+ */
+ fun build(request: SystemPromptRequest): String {
+ val toolDescriptions = request.tools.joinToString("\n") { "- ${it.name}: ${it.description}" }
+ val examplePath = request.exampleFilePath ?: FALLBACK_EXAMPLE_PATH
+ val exampleStem = examplePath.substringAfterLast('/').substringBeforeLast('.')
+
+ val head = """
+ You are a senior Android developer integrated into CodeOnTheGo. Your goal is to build complete, working Android apps from user descriptions.
+
+ AVAILABLE TOOLS:
+ $toolDescriptions
+
+ BEHAVIOR:
+ - Create complete, production-ready code
+ - Call tools proactively to build, test, and verify your work
+ - Read files to understand project structure before making changes
+ - After each file modification, verify the build compiles
+ - Generate apps that actually run and work as described
+
+ RULES:
+ - Emit ONE tool call per reply, then stop and wait. Do NOT plan a batch: a tool whose arguments depend on another tool's result (editing a file you just searched for) cannot use a result you have not received yet.
+ - To locate a file, call search_project ONCE with its name — it searches the whole project. Never walk the tree with repeated list_files calls; you have a limited number of turns and each level wastes one.
+ - Renaming a symbol everywhere in a file is ONE edit_file with replace_all set to true and old_string set to just the symbol — not one edit per line.
+ - To change an existing file, use edit_file (find/replace an exact snippet), not update_file — a whole-file rewrite gets truncated before it reaches disk.
+ - Before edit_file, read the exact file you are about to edit with read_file, and copy old_string byte-for-byte from that output, including indentation. Never edit a path you have not confirmed exists.
+ - old_string must be the text currently in the file and new_string what it should become. If they are identical the edit is rejected.
+ - Never fabricate tool output. Emit a tool call, then wait for the real result before continuing.
+ - Never write "User:", "Assistant:", a block, or a ```tool_response fence — the system supplies real results. Any tool output you write yourself is a hallucination and will be ignored.
+ - Paths are relative to the project root and must be complete. If you don't know a file's exact path, find it with search_project or list_files first, then act on the real path — don't guess.
+ - For plain chat (e.g. "Hi"), just reply briefly with no tool call. When the task is done, either give a short summary with no tool call, or end with a single respond call carrying that summary in its "message" — never an empty respond.
+ """.trimIndent()
+
+ val workflow = """
+ WORKFLOW:
+ 1. Understand the user's request
+ 2. Locate what you need with ONE search_project call — the IDE CONTEXT block above already names the source, layout and manifest paths
+ 3. Create/modify files with complete implementations
+ 4. Add dependencies if needed
+ 5. Sync gradle and verify compilation
+ 6. Run the app to confirm it works
+ 7. Report success and what was built
+ """.trimIndent()
+
+ // Null syntax means the caller reads calls off the function-calling API instead. Saying so
+ // is what stops the model writing one as text, where nothing would run it (ADFA-5410).
+ val syntax = request.toolCallSyntax ?: return listOf(head, NATIVE_CALL_FORMAT, workflow)
+ .joinToString("\n\n")
+
+ val callFormat = """
+ TOOL CALL FORMAT — to run a tool, emit a single line in EXACTLY this format and nothing after it:
+ $syntax
+ Do NOT describe the action in prose (e.g. "Okay, I'll open the file…") — narrating does nothing.
+ Do NOT use your provider's native function-calling channel either; a structured tool call is not read by this system.
+ The tool only runs when you emit the tool call line itself.
+
+ FORMAT EXAMPLES (the tool call is the entire reply; the paths are this project's — reuse a path
+ only when it is the file you actually mean):
+ Report the finished task (the summary goes in "message"):
+ {"tool":"respond","args":{"message":"Renamed count to itemCount."}}
+ Open a file once you know its path:
+ {"tool":"open_file","args":{"file_path":"$examplePath"}}
+ Find a file by name:
+ {"tool":"search_project","args":{"query":"$exampleStem"}}
+ List the project's top-level files (an empty directory means the project root):
+ {"tool":"list_files","args":{"directory":""}}
+ Change part of a file (line breaks inside a value MUST be written as \n):
+ {"tool":"edit_file","args":{"file_path":"$examplePath","old_string":"count = 0","new_string":"count = 1"}}
+ """.trimIndent()
+
+ return head + "\n\n" + callFormat + "\n\n" + workflow
+ }
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/security/ApiKeyCache.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/security/ApiKeyCache.kt
new file mode 100644
index 00000000..1a178f5b
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/security/ApiKeyCache.kt
@@ -0,0 +1,120 @@
+package com.itsaky.androidide.plugins.aiagentclaude.security
+
+import android.content.SharedPreferences
+import android.os.Looper
+import com.itsaky.androidide.plugins.PluginLogger
+import com.itsaky.androidide.plugins.security.KeystoreSecretStore
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+
+/**
+ * The decrypted API key, cached against the value on disk.
+ *
+ * Decrypting costs a Keystore IPC round trip and the backend's `isAvailable()` runs on every
+ * generate, so the cost is paid once and paid again only when the stored key actually changes.
+ * The main-thread rule lives here too, so the backend never has to know one exists.
+ *
+ * @param prefs the store holding the encrypted value; re-read on every call
+ * @param prefKey the preference name to read
+ * @param logger this plugin's IDE-surfaced log
+ * @param scope the owner's scope, used to refresh off a main-thread call
+ */
+internal class ApiKeyCache(
+ private val prefs: () -> SharedPreferences?,
+ private val prefKey: String,
+ private val logger: PluginLogger,
+ private val scope: CoroutineScope,
+) {
+
+ /** Last decryption, as (value on disk -> plaintext). */
+ @Volatile
+ private var cached: Pair? = null
+
+ /**
+ * The saved key, or null when none is stored.
+ *
+ * Decryption is Keystore IPC + AES/GCM and must not run on the main thread, so a main-thread
+ * call answers from the cache and kicks off a background refresh rather than blocking; [warm]
+ * fills the cache first so that never reports "no key".
+ */
+ fun read(): String? {
+ val stored = prefs()?.getString(prefKey, null)
+ if (stored.isNullOrBlank()) {
+ cached = null
+ return null
+ }
+ cached?.let { (raw, plain) -> if (raw == stored) return plain }
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ logger.warn("ApiKeyCache: API key read on the main thread; refreshing off-thread")
+ // The owner's close() cancels the scope, so without this guard launch is a no-op.
+ if (scope.isActive) {
+ scope.launch { refresh() }
+ } else {
+ logger.warn("ApiKeyCache: backend already closed; not refreshing the key cache")
+ }
+ return null
+ }
+ return refresh()
+ }
+
+ /**
+ * Fill the cache off-thread, so a synchronous main-thread [read] never reports "no key" for a
+ * stored, decryptable key just because it was first asked from the UI.
+ */
+ fun warm() {
+ if (!scope.isActive) return
+ scope.launch {
+ try {
+ val warmed = refresh() != null
+ logger.debug("ApiKeyCache: key cache warmed (key present: $warmed)")
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ logger.warn("ApiKeyCache: could not warm key cache: ${e.message}")
+ }
+ }
+ }
+
+ /**
+ * Drop the decrypted key. Otherwise the plaintext stays reachable on the host process heap for
+ * as long as the IDE runs, which is exactly what encrypting at rest is meant to prevent.
+ */
+ fun clear() {
+ cached = null
+ }
+
+ /**
+ * Decrypt the stored key — upgrading a legacy plaintext value in passing — and cache the
+ * result. Off-main-thread only; see [read].
+ */
+ private fun refresh(): String? {
+ val prefs = prefs()
+ val plain = when (val stored = secureApiKeyStore.readAndMigrate(prefs, prefKey)) {
+ is KeystoreSecretStore.Stored.Value -> stored.plain.trim().takeIf { it.isNotBlank() }
+ KeystoreSecretStore.Stored.Absent -> null
+ // Reported here rather than passed on as "no key": generation fails either way, but a
+ // lost Keystore entry needs the key entering again, and the log is all that says so.
+ KeystoreSecretStore.Stored.Unreadable -> {
+ logger.warn(
+ "ApiKeyCache: the saved API key cannot be decrypted on this device; " +
+ "it has to be entered again in settings"
+ )
+ null
+ }
+ // Transient, so it returns without caching: the key is very likely intact, and caching
+ // this answer would freeze "no key" until the stored value itself changed.
+ KeystoreSecretStore.Stored.Unavailable -> {
+ logger.warn(
+ "ApiKeyCache: the keystore could not be reached to read the saved API key; " +
+ "retrying on the next read"
+ )
+ return null
+ }
+ }
+ val raw = prefs?.getString(prefKey, null)
+ cached = raw?.let { it to plain }
+ return plain
+ }
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/security/SecureApiKeyStore.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/security/SecureApiKeyStore.kt
new file mode 100644
index 00000000..c095f528
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/security/SecureApiKeyStore.kt
@@ -0,0 +1,17 @@
+package com.itsaky.androidide.plugins.aiagentclaude.security
+
+import com.itsaky.androidide.plugins.security.KeystoreSecretStore
+
+/** Unique to this plugin and fixed across releases; see [KeystoreSecretStore] for why both matter. */
+private const val ALIAS = "cotg_ai_claude_key_v1"
+
+/**
+ * This plugin's binding of [KeystoreSecretStore]: its API key, encrypted under this plugin's own
+ * Keystore alias.
+ *
+ * The store is the IDE's, from plugin-api, and callers use it directly. A forwarding object per
+ * method would only be a second copy of its contract to keep in step — and one that had to pick a
+ * single answer for "absent" and "no longer decryptable", which callers here do not share. The
+ * thing this file owns is the alias.
+ */
+val secureApiKeyStore = KeystoreSecretStore(ALIAS)
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/CatalogResult.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/CatalogResult.kt
new file mode 100644
index 00000000..6e9b1ec7
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/CatalogResult.kt
@@ -0,0 +1,32 @@
+package com.itsaky.androidide.plugins.aiagentclaude.settings
+
+import com.itsaky.androidide.plugins.aiagentclaude.backend.ClaudeModelCatalog
+
+/**
+ * Outcome of one model-catalog lookup against the Claude backend.
+ *
+ * A closed hierarchy, so callers cannot treat "the backend isn't installed" and "the API refused
+ * the key" alike.
+ */
+sealed interface CatalogResult {
+
+ /**
+ * The API answered.
+ *
+ * @param models the models the key can use, possibly none
+ * @param entries the same models with what each accepts, for storing alongside the list
+ */
+ data class Success(
+ val models: List,
+ val entries: List = emptyList(),
+ ) : CatalogResult
+
+ /** No backend was resolvable — this plugin is not active, or was disposed. */
+ data object NoBackend : CatalogResult
+
+ /**
+ * The lookup failed. [cause] is the *unwrapped* failure — the backend's [java.io.IOException]
+ * for an HTTP error, or a [java.util.concurrent.TimeoutException].
+ */
+ data class Failed(val cause: Throwable) : CatalogResult
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeCatalogGateway.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeCatalogGateway.kt
new file mode 100644
index 00000000..80833c66
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeCatalogGateway.kt
@@ -0,0 +1,118 @@
+package com.itsaky.androidide.plugins.aiagentclaude.settings
+
+import com.itsaky.androidide.plugins.PluginLogger
+import com.itsaky.androidide.plugins.aiagentclaude.backend.ClaudeBackend
+import com.itsaky.androidide.plugins.aiagentclaude.backend.ClaudeModelCatalog
+import com.itsaky.androidide.plugins.aiagentclaude.logging.LOG_PREFIX
+import com.itsaky.androidide.plugins.aiagentclaude.plugin.ClaudePlugin
+import java.util.concurrent.CancellationException
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.ExecutionException
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+
+/**
+ * The one place this plugin's settings ask for a model catalog.
+ *
+ * An abstraction the ViewModel can fake in tests, so the blocking wait on the backend's future
+ * lives behind a single seam that fails in one recognisable way.
+ */
+interface ClaudeCatalogGateway {
+
+ /** Models available with the key saved on disk. Used to populate the model picker. */
+ fun listModelsForSavedSettings(): CatalogResult
+
+ /**
+ * Models available with [apiKey], which need not be — and during setup is not — the saved
+ * one. This is what makes testing a key before persisting it possible.
+ *
+ * @param apiKey the candidate key
+ * @param workspaceId the candidate workspace for a key that belongs to none, or null
+ */
+ fun listModels(apiKey: String, workspaceId: String?): CatalogResult
+}
+
+/**
+ * [ClaudeCatalogGateway] over this plugin's own [ClaudeBackend].
+ *
+ * A plain call: the backend and the settings that configure it ship in the same `.cgp`, so the
+ * types are the same types — no reflection across a classloader boundary.
+ *
+ * @param backendProvider resolves the backend; injectable so tests need no plugin lifecycle
+ */
+class BackendClaudeCatalogGateway(
+ private val backendProvider: () -> ClaudeBackend? = ClaudePlugin::getBackend
+) : ClaudeCatalogGateway {
+
+ companion object {
+ private const val TAG = "$LOG_PREFIX.ClaudeCatalogGateway"
+
+ /**
+ * Failsafe cap, well above the backend's own budget (15 s connect + 15 s read) so a
+ * slow-but-live fetch is never truncated. Bounds a future that may never complete, such as
+ * one from an already-cancelled backend scope; not the expected wait.
+ */
+ private const val LIST_MODELS_TIMEOUT_SECONDS = 60L
+ }
+
+ /**
+ * This plugin's IDE-surfaced log, so a failed catalog lookup shows up in the IDE's own log view
+ * rather than only in logcat. Null before `initialize()` and in JVM tests.
+ */
+ private val logger: PluginLogger?
+ get() = ClaudePlugin.getContext()?.logger
+
+ override fun listModelsForSavedSettings(): CatalogResult =
+ await { it.listModels() }
+
+ override fun listModels(apiKey: String, workspaceId: String?): CatalogResult =
+ await { it.listModels(apiKey, workspaceId) }
+
+ /**
+ * Runs [request] against the backend and awaits its future.
+ *
+ * Blocks on [CompletableFuture.get], so call it from an IO dispatcher — never the main thread.
+ *
+ * @param request the catalog call to make; picks which credential is used
+ */
+ private fun await(
+ request: (ClaudeBackend) -> CompletableFuture>
+ ): CatalogResult {
+ val backend = try {
+ backendProvider()
+ } catch (e: Exception) {
+ logger?.error("$TAG: could not resolve the Claude backend", e)
+ return CatalogResult.Failed(e)
+ } ?: return CatalogResult.NoBackend
+
+ val future = try {
+ request(backend)
+ } catch (e: Exception) {
+ logger?.error("$TAG: listModels threw", e)
+ return CatalogResult.Failed(e)
+ }
+
+ return try {
+ val entries = future.get(LIST_MODELS_TIMEOUT_SECONDS, TimeUnit.SECONDS).orEmpty()
+ CatalogResult.Success(entries.map { it.id }, entries)
+ } catch (e: ExecutionException) {
+ // The API failure the backend reported; it carries the HTTP status as a field.
+ CatalogResult.Failed(e.cause ?: e)
+ } catch (e: CancellationException) {
+ logger?.warn("$TAG: listModels was cancelled by the backend", e)
+ CatalogResult.Failed(e)
+ } catch (e: TimeoutException) {
+ future.cancel(true)
+ logger?.error(
+ "$TAG: listModels did not complete within ${LIST_MODELS_TIMEOUT_SECONDS}s",
+ e
+ )
+ CatalogResult.Failed(e)
+ } catch (e: InterruptedException) {
+ // Restore the flag so the cancelled coroutine's thread still sees it.
+ Thread.currentThread().interrupt()
+ future.cancel(true)
+ CatalogResult.Failed(e)
+ }
+ }
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeKeyOnboarding.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeKeyOnboarding.kt
new file mode 100644
index 00000000..e16c29c4
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeKeyOnboarding.kt
@@ -0,0 +1,19 @@
+package com.itsaky.androidide.plugins.aiagentclaude.settings
+
+/**
+ * Where a Claude API key comes from.
+ *
+ * Open [API_KEYS_URL] in a real browser, sign in there, copy the key, paste it into the key field.
+ * This plugin never sees a password, and never reads the clipboard.
+ */
+object ClaudeKeyOnboarding {
+
+ /**
+ * The Claude Console's API keys page.
+ *
+ * Note there is no free tier: API usage is billed to prepaid credit, separate from a Claude.ai
+ * subscription. The free paths are the other backend plugins; the settings pane and the guide
+ * both say so.
+ */
+ const val API_KEYS_URL = "https://platform.claude.com/settings/keys"
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeSettingsFragment.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeSettingsFragment.kt
new file mode 100644
index 00000000..3a0fe690
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeSettingsFragment.kt
@@ -0,0 +1,946 @@
+package com.itsaky.androidide.plugins.aiagentclaude.settings
+
+import android.annotation.SuppressLint
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowManager
+import android.widget.ArrayAdapter
+import android.widget.AutoCompleteTextView
+import android.widget.Button
+import android.widget.EditText
+import android.widget.Filter
+import android.widget.LinearLayout
+import android.widget.TextView
+import android.widget.Toast
+import androidx.annotation.DrawableRes
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.textfield.TextInputLayout
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aiagentclaude.R
+import com.itsaky.androidide.plugins.aiagentclaude.backend.WorkspaceIds
+import com.itsaky.androidide.plugins.aiagentclaude.plugin.ClaudePlugin
+import com.itsaky.androidide.plugins.aiagentclaude.ui.SecretRevealController
+import com.itsaky.androidide.plugins.aiagentclaude.ui.applyPaneStyling
+import com.itsaky.androidide.plugins.base.PluginFragmentHelper
+import com.itsaky.androidide.plugins.security.KeystoreSecretStore
+import com.itsaky.androidide.plugins.services.IdeTooltipService
+import kotlinx.coroutines.launch
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+/** The pane's secondary actions, drawn outlined; each section's Save stays filled. */
+private val OUTLINED_BUTTON_IDS = setOf(
+ R.id.btn_clear_api_key,
+ R.id.btn_edit_api_key,
+ R.id.btn_get_key,
+ R.id.btn_test_connection,
+)
+
+/**
+ * This backend's settings pane, mounted by whichever screen offers a backend selector.
+ *
+ * Named to the host through `ClaudeBackend.getSettingsFragmentClassName()`, loaded with this
+ * plugin's own classloader and inflated against this plugin's own resources — so the consumer needs
+ * to know nothing about API keys or model catalogs.
+ */
+class ClaudeSettingsFragment : Fragment() {
+
+ private lateinit var viewModel: ClaudeSettingsViewModel
+ private var tooltipService: IdeTooltipService? = null
+
+ /**
+ * Set while this pane is on screen, so [onResume] can nudge the user towards **Save** after
+ * they come back from the key page. Cleared when the view is destroyed — it captures views, so
+ * holding it any longer would leak them.
+ */
+ private var onPaneResume: (() -> Unit)? = null
+
+ /**
+ * The API key field's reveal control, held so the key can be re-masked when this pane leaves
+ * the foreground. Captures views, so it is dropped in [onDestroyView] like [onPaneResume].
+ */
+ private var apiKeyReveal: SecretRevealController? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ try {
+ tooltipService = PluginFragmentHelper.getServiceRegistry(ClaudePlugin.PLUGIN_ID)
+ ?.get(IdeTooltipService::class.java)
+ } catch (e: Exception) {
+ // Tooltip help is optional; long-press simply shows nothing when it's unavailable.
+ ClaudePlugin.getContext()?.logger
+ ?.warn("ClaudeSettingsFragment: tooltip service unavailable", e)
+ }
+ }
+
+ /**
+ * Route inflation through the host so this pane resolves against *this* plugin's resources and
+ * a Context whose Configuration tracks the IDE's day/night setting. The inflater inherited from
+ * the hosting screen belongs to that plugin and cannot see this one's layouts.
+ */
+ override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater {
+ val inflater = super.onGetLayoutInflater(savedInstanceState)
+ return PluginFragmentHelper.getPluginInflater(ClaudePlugin.PLUGIN_ID, inflater)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? = inflater.inflate(R.layout.fragment_claude_settings, container, false)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ viewModel = ViewModelProvider(
+ this,
+ ClaudeSettingsViewModelFactory { ClaudePlugin.getContext() }
+ )[ClaudeSettingsViewModel::class.java]
+
+ view.applyPaneStyling(OUTLINED_BUTTON_IDS)
+ setupApiKeyUi(view)
+ setupModelPicker(view)
+ setupConnectionTest(view)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ onPaneResume?.invoke()
+ }
+
+ /**
+ * Re-mask a revealed key on the way out of the foreground.
+ *
+ * Re-masking rather than only clearing [WindowManager.LayoutParams.FLAG_SECURE]: the flag has
+ * to go, since the window outlives this pane and nothing else would clear it, and dropping it
+ * over a legible key is what would let the recents thumbnail keep a copy of it.
+ */
+ override fun onPause() {
+ apiKeyReveal?.mask()
+ super.onPause()
+ }
+
+ override fun onDestroyView() {
+ // Drops the captured pane views along with the callbacks.
+ onPaneResume = null
+ apiKeyReveal = null
+ setSecureWindow(false)
+ super.onDestroyView()
+ }
+
+ /** Shows this plugin's tooltip for [tag] when [view] is long-pressed (Tier 1/2 + guide button). */
+ private fun wireTooltip(view: View, tag: String) {
+ view.setOnLongClickListener { anchor ->
+ val service = tooltipService ?: return@setOnLongClickListener false
+ service.showTooltip(anchor, ClaudePlugin.TOOLTIP_CATEGORY, tag)
+ true
+ }
+ }
+
+ /**
+ * Long-press on [box]'s end icon shows [tag]'s tooltip.
+ *
+ * Separate from [wireTooltip] because the end icon is a clickable child that consumes the
+ * long-press before the box sees it — without this every end icon on the pane, the reveal
+ * control and the dropdown chevron alike, would be a contributed element with no tooltip of
+ * its own.
+ */
+ private fun wireEndIconTooltip(box: TextInputLayout, tag: String) {
+ box.setEndIconOnLongClickListener { icon ->
+ val service = tooltipService ?: return@setEndIconOnLongClickListener false
+ service.showTooltip(icon, ClaudePlugin.TOOLTIP_CATEGORY, tag)
+ true
+ }
+ }
+
+ /**
+ * Show [message] on [target] with an optional leading status icon.
+ *
+ * @param icon leading status drawable, or 0 for the states that don't warrant one
+ */
+ private fun showStatus(target: TextView, message: String, @DrawableRes icon: Int = 0) {
+ target.text = message
+ // Relative (not left/right) so the icon follows the layout direction in RTL locales.
+ target.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, 0, 0, 0)
+ target.visibility = View.VISIBLE
+ }
+
+ /** Drop a status line that no longer describes what is on screen. */
+ private fun hideStatus(target: TextView) {
+ target.visibility = View.GONE
+ target.text = ""
+ target.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
+ }
+
+ /**
+ * Put the dropdown chevron on [box]'s end icon.
+ *
+ * The drawable is set here rather than in the layout because an end icon declared as
+ * `app:endIconDrawable` draws blank inside the host — the same reason [SecretRevealController]
+ * sets the reveal icon in code. Without a visible chevron the field
+ * reads as a plain, read-only text box rather than a list to open.
+ */
+ private fun setupDropdownEndIcon(box: TextInputLayout) {
+ // Already the mode declared in the layout; set again so the drawable below cannot be the
+ // one a later mode switch discards.
+ box.endIconMode = TextInputLayout.END_ICON_CUSTOM
+ box.setEndIconDrawable(R.drawable.ic_dropdown)
+ // Nothing ever moves the icon's checked state, so TalkBack would read "not checked" over
+ // a control that is not a toggle.
+ box.isEndIconCheckable = false
+ }
+
+ // --- API key ------------------------------------------------------------------------------
+
+ @SuppressLint("SetTextI18n")
+ private fun setupApiKeyUi(view: View) {
+ val apiKeyLayout = view.findViewById(R.id.claude_api_key_layout)
+ val apiKeyBox = view.findViewById(R.id.claude_api_key_box)
+ val apiKeyInput = view.findViewById(R.id.claude_api_key_input)
+ val saveButton = view.findViewById