From fe25ed9e16ff465c58f33f4aeadd950e8c47a838 Mon Sep 17 00:00:00 2001 From: Zero Liu Date: Sun, 26 Apr 2026 01:47:03 -0700 Subject: [PATCH] feat(agent-mode): add Claude Code backend and multi-backend session manager Introduces a second ACP backend (claude-agent-acp) alongside opencode. To support more than one backend, AgentSessionManager now resolves backends lazily per session via a registry, and a new AgentModelPreloader probes each ready backend at startup so the picker can show models for inactive backends. --- designdocs/AGENT_MODE_SYSTEM_PROMPT.md | 176 ++++++++++ .../{AGENT_MODE.md => AGENT_MODE_V0.md} | 0 designdocs/todo/AGENT_MODE_TODOS.md | 28 ++ src/agentMode/acp/AcpBackendProcess.ts | 74 +++- .../backends/claude-code/ClaudeCodeBackend.ts | 68 ++++ .../claude-code/ClaudeCodeInstallModal.tsx | 136 ++++++++ .../claude-code/ClaudeCodeSettingsPanel.tsx | 136 ++++++++ .../backends/claude-code/descriptor.ts | 89 +++++ src/agentMode/backends/claude-code/index.ts | 1 + .../opencode/OpencodeBinaryManager.test.ts | 10 +- .../opencode/OpencodeBinaryManager.ts | 20 +- .../opencode/OpencodeSettingsPanel.tsx | 45 ++- src/agentMode/backends/opencode/descriptor.ts | 31 +- src/agentMode/backends/registry.ts | 2 + src/agentMode/index.ts | 56 ++- src/agentMode/session/AgentModelPreloader.ts | 165 +++++++++ src/agentMode/session/AgentSession.test.ts | 96 ++++-- src/agentMode/session/AgentSession.ts | 23 +- .../session/AgentSessionManager.test.ts | 18 +- src/agentMode/session/AgentSessionManager.ts | 186 ++++++---- src/agentMode/session/types.ts | 15 + src/agentMode/ui/AgentChat.tsx | 4 +- src/agentMode/ui/AgentModeStatus.tsx | 8 +- src/agentMode/ui/index.ts | 6 +- src/agentMode/ui/useAgentModelPicker.ts | 318 ++++++++++++------ src/agentMode/ui/useBackendDescriptor.ts | 33 +- src/components/ui/ModelSelector.tsx | 57 +++- src/settings/model.ts | 83 ++++- .../v2/components/AgentModeSettings.tsx | 41 ++- src/utils/detectBinary.ts | 63 ++++ 30 files changed, 1704 insertions(+), 284 deletions(-) create mode 100644 designdocs/AGENT_MODE_SYSTEM_PROMPT.md rename designdocs/{AGENT_MODE.md => AGENT_MODE_V0.md} (100%) create mode 100644 designdocs/todo/AGENT_MODE_TODOS.md create mode 100644 src/agentMode/backends/claude-code/ClaudeCodeBackend.ts create mode 100644 src/agentMode/backends/claude-code/ClaudeCodeInstallModal.tsx create mode 100644 src/agentMode/backends/claude-code/ClaudeCodeSettingsPanel.tsx create mode 100644 src/agentMode/backends/claude-code/descriptor.ts create mode 100644 src/agentMode/backends/claude-code/index.ts create mode 100644 src/agentMode/session/AgentModelPreloader.ts create mode 100644 src/utils/detectBinary.ts diff --git a/designdocs/AGENT_MODE_SYSTEM_PROMPT.md b/designdocs/AGENT_MODE_SYSTEM_PROMPT.md new file mode 100644 index 00000000..0c2b02ed --- /dev/null +++ b/designdocs/AGENT_MODE_SYSTEM_PROMPT.md @@ -0,0 +1,176 @@ +# Agent Mode — system-prompt override + +> Design doc. Captures how Copilot's Agent Mode replaces opencode's default coding-agent system prompt with a Copilot-specific prompt for both `build` (default) and `plan` modes. Companion to [`AGENT_MODE.md`](./AGENT_MODE.md). + +## 1. Context + +opencode is the BYOK ACP backend behind Agent Mode (see [`AGENT_MODE.md`](./AGENT_MODE.md)). It ships opinionated **coding-agent** system prompts — `prompt/anthropic.txt`, `beast.txt`, `gpt.txt`, `default.txt`, etc. — selected per turn by substring-matching on the model name (`opencode/packages/opencode/src/session/system.ts:20-34`). Those prompts bake in software-engineering identity, repo workflows, and code-style conventions. + +For an Obsidian vault assistant, that identity is **wrong, not just missing**: the agent operates on markdown notes, not source files; the user's intent is knowledge management and writing assistance, not refactoring TypeScript. Appending a Copilot addendum on top of opencode's coding prompt produces an agent torn between two identities. We need Copilot's own prompt to drive both the default ("build") and plan modes. + +## 2. What ACP itself does and doesn't expose + +- ACP `NewSessionRequest`, `PromptRequest`, `LoadSessionRequest` have **no** `system` / `instructions` / `customPrompt` field. The wire protocol does not expose system-prompt override. +- opencode's internal `user.system` (per-message) is not surfaced through ACP either. +- Override must therefore go through opencode's **config layer**. Copilot already drives that via the `OPENCODE_CONFIG_CONTENT` env var on subprocess spawn (`src/agentMode/backends/opencode/OpencodeBackend.ts:53-71`). + +## 3. How opencode assembles the system prompt + +`opencode/packages/opencode/src/session/llm.ts:102-114`: + +```ts +const system: string[] = []; +system.push( + [ + ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)), + ...input.system, + ...(input.user.system ? [input.user.system] : []), + ] + .filter((x) => x) + .join("\n") +); +``` + +Three sources, in order: + +1. **Agent prompt or provider default.** If the active agent has a `prompt` set, it replaces the provider default entirely. Otherwise opencode picks a provider default by substring-matching the model name (`session/system.ts:20-34`): + + | Model name contains | Prompt file used | + | --------------------- | ---------------------- | + | `gpt-4` / `o1` / `o3` | `prompt/beast.txt` | + | `gpt` + `codex` | `prompt/codex.txt` | + | other `gpt` | `prompt/gpt.txt` | + | `gemini-` | `prompt/gemini.txt` | + | `claude` | `prompt/anthropic.txt` | + | `trinity` | `prompt/trinity.txt` | + | `kimi` | `prompt/kimi.txt` | + | else | `prompt/default.txt` | + + Note: Copilot Plus inherits whichever family-specific prompt the underlying model name matches — the `copilot-plus` provider id is irrelevant here; only the model name string is inspected. + +2. **`input.system`** — collected from `Instruction.system()`: AGENTS.md / CLAUDE.md / CONTEXT.md walked up from the cwd, plus anything listed in `config.instructions` (file paths, glob patterns, or `https?://` URLs). See `opencode/packages/opencode/src/session/instruction.ts:122-170`. + +3. **`input.user.system`** — per-message system text. Internal API only; not reachable via ACP. + +After assembly, opencode invokes a `experimental.chat.system.transform` plugin hook that lets a loaded plugin mutate the array in place. Heavier than what we need. + +## 4. Agents, a.k.a. session modes + +`opencode/packages/opencode/src/agent/agent.ts:107` defines native agents. The two primary, user-selectable ones are: + +- **`build`** — default. Permissive (allow edits, tools). No `prompt:` set → falls back to provider default. +- **`plan`** — `edit: deny` permission. No `prompt:` set → falls back to provider default. + +There are also hidden ones (`title`, `summary`, `compaction`) that already carry custom prompts, and subagents (`general`, `explore`) that aren't user-selectable as a session mode. + +`agent.ts:236-263` then merges anything under `cfg.agent.` into the matching native agent — including `prompt`, `permission`, `model`, `temperature`, etc. The override is field-wise: setting `prompt` does not disturb `permission`, `mode`, or `native: true`. **This is the lever.** Stamping `prompt:` onto `build` and `plan` overrides the provider default while preserving native permissions and visibility in `availableModes`. + +The wire-level toggle between modes is the ACP `setSessionMode({ sessionId, modeId })` method (`opencode/packages/opencode/src/acp/agent.ts:1313-1320`). It validates `modeId` against `loadAvailableModes(cwd)` and throws `Agent not found: ` otherwise. `availableModes` and `currentModeId` are returned in `NewSessionResponse.modes` (`agent.ts:1163-1176`). + +## 5. Decision: replace, not append + +Append vs. replace tradeoffs: + +| | Append (`config.instructions`) | Replace (`cfg.agent..prompt`) | +| ---------------------------- | ----------------------------------------------- | -------------------------------------------- | +| Identity collision | Coding-agent identity remains, fights addendum | Clean — only Copilot's identity is in scope | +| Token / cache stability | Larger header, varies by provider default | Stable, smaller, single source | +| Cross-model parity | Different base prompt per model name | One baseline everywhere | +| Future opencode improvements | Auto-pickup (sometimes desired) | Pinned (review on opencode upgrade) | +| Plan / build distinction | Free (provider default has plan-mode awareness) | Requires explicit plan-mode addendum | +| Implementation cost | One textarea + a file path | Maintain Copilot prompts; address each model | + +**Decision:** **replace**, applied to both `build` and `plan`. + +Use `config.instructions` (append) only for _additive_ per-vault context — e.g. an `AGENTS.md` the user maintains in their vault. The two compose: replacement handles identity; instructions handle per-vault context. + +## 6. Final design + +Inject prompts via `cfg.agent` in `OPENCODE_CONFIG_CONTENT`: + +```ts +config.agent = { + build: { prompt: COPILOT_PROMPT_BASE }, + plan: { prompt: COPILOT_PROMPT_BASE + "\n\n" + COPILOT_PLAN_ADDENDUM }, +}; +``` + +Why this works: + +- `build` and `plan` are merged from native agents (`agent.ts:236-263`), so their permissions (build = permissive, plan = edit-deny) and visibility in `availableModes` are preserved. +- Mode toggle via `setSessionMode("plan" | "build")` keeps working — same wire affordance opencode already exposes. +- One env var carries everything; no temp files, no `OPENCODE_CONFIG_DIR`, no agent markdown to manage on disk. + +Why plan mode needs its own addendum: opencode's `edit: deny` permission blocks the _tool call_, but the model still needs to _know_ it's in plan mode so it produces a written plan instead of attempting edits and getting rejected per turn. Opencode's provider defaults handle this implicitly (Anthropic's prompt has plan-mode awareness baked in). Our replacement must state it explicitly. Suggested wording for `COPILOT_PLAN_ADDENDUM`: + +> You are in plan mode. Do not modify files. Produce a written plan grounded in what's actually in the vault, then stop and wait for approval before executing. + +## 7. Implementation — minimum viable path + +1. **Author prompts.** New file `src/agentMode/backends/opencode/prompts.ts` exporting `COPILOT_PROMPT_BASE` and `COPILOT_PLAN_ADDENDUM` as TS string constants. Port from Copilot's existing chat system prompt, dropping renderer-specific bits that don't apply when an agent is driving tools (e.g. wikilink rendering instructions intended for the chat surface, not for tool calls). + +2. **(Optional) User override setting.** Add `agentMode.backends.opencode.systemPromptOverride?: string` to `CopilotSettings` in `src/settings/model.ts`. Skip for v1 if a fixed prompt is acceptable; can be added later without migration. + +3. **Inject in `buildOpencodeConfig()`** (`src/agentMode/backends/opencode/OpencodeBackend.ts:89-194`). Just before `return config`: + + ```ts + const base = + getSettings().agentMode?.backends?.opencode?.systemPromptOverride ?? COPILOT_PROMPT_BASE; + config.agent = { + build: { prompt: base }, + plan: { prompt: base + "\n\n" + COPILOT_PLAN_ADDENDUM }, + }; + ``` + +4. **Tests.** Extend `src/agentMode/backends/opencode/OpencodeBackend.test.ts` (already exercises `buildOpencodeConfig`) to assert: + - `config.agent.build.prompt === COPILOT_PROMPT_BASE` when no override is set. + - `config.agent.plan.prompt` ends with `COPILOT_PLAN_ADDENDUM`. + - The override setting, when populated, replaces `COPILOT_PROMPT_BASE` in both fields. + +After step 3 ships, the override is in effect for every new opencode session. + +## 8. Follow-up: surface plan-mode toggle in UI + +Today the Copilot UI has no way to call `setSessionMode`, so users always run in `build`. The prompt override is independent of this — it works without the toggle — but to expose plan mode end-to-end: + +- `src/agentMode/acp/AcpBackendProcess.ts` — add `setSessionMode({ sessionId, modeId })`, mirroring the `setSessionModel` pattern at lines 199-230 (try call, cache support flag, throw `MethodUnsupportedError` if absent). +- `src/agentMode/session/AgentSession.ts` — add `changeMode(modeId)`, mirroring `changeModel` at lines 122-141. +- `AgentSessionManager` and the chat UI — surface `availableModes` (already returned by `newSession`) and add a toggle next to the model picker. + +## 9. Verification + +1. `npm run lint && npm run test` — confirm new unit tests pass. +2. `npm run build` then `/Applications/Obsidian.app/Contents/MacOS/obsidian plugin:reload id=copilot`. +3. Start an Agent Mode session, send "what are you?" — answer should reflect Copilot identity, not opencode's "I'm a software engineer / defensive security tool" framing. +4. Confirm the prompt actually landed: tail the ACP frame log (commit `aa34c82` logs every JSON-RPC frame in debug mode) or run opencode with `OPENCODE_LOG_LEVEL=debug` on the spawned process and grep its log for the system prompt. +5. Once step 8 ships: switch to plan mode and confirm the model produces a written plan instead of attempting to edit. + +## 10. Critical files + +**Read-only (opencode internals, for reference):** + +- `opencode/packages/opencode/src/session/system.ts` — provider-default selection +- `opencode/packages/opencode/src/session/llm.ts:102-114` — system-array assembly +- `opencode/packages/opencode/src/session/instruction.ts:122-170` — `instructions` resolution +- `opencode/packages/opencode/src/agent/agent.ts:107-263` — native agents and `cfg.agent` merge +- `opencode/packages/opencode/src/acp/agent.ts:1313-1320` — `setSessionMode` handler + +**To edit (Copilot side):** + +- `src/agentMode/backends/opencode/prompts.ts` _(new)_ +- `src/agentMode/backends/opencode/OpencodeBackend.ts` +- `src/agentMode/backends/opencode/OpencodeBackend.test.ts` +- `src/settings/model.ts` _(optional, for override setting)_ + +**To edit for follow-up §8:** + +- `src/agentMode/acp/AcpBackendProcess.ts` +- `src/agentMode/session/AgentSession.ts` +- A UI component near the model picker + +## 11. Out of scope + +- Authoring the contents of `COPILOT_PROMPT_BASE` itself — separate writing task; should track Copilot's existing chat system prompt where applicable. +- The `setSessionMode` UI toggle — captured as §8 follow-up, not designed in detail here. +- Per-vault `AGENTS.md` plumbing — flagged as compositional with `config.instructions` but not designed here. +- `experimental.chat.system.transform` plugin hook — strictly more powerful than `cfg.agent..prompt` but requires shipping an opencode plugin file; rejected for complexity. diff --git a/designdocs/AGENT_MODE.md b/designdocs/AGENT_MODE_V0.md similarity index 100% rename from designdocs/AGENT_MODE.md rename to designdocs/AGENT_MODE_V0.md diff --git a/designdocs/todo/AGENT_MODE_TODOS.md b/designdocs/todo/AGENT_MODE_TODOS.md new file mode 100644 index 00000000..81d546ba --- /dev/null +++ b/designdocs/todo/AGENT_MODE_TODOS.md @@ -0,0 +1,28 @@ +# ACP Agent Mode TODOs + +- P0: Chat history + - [ ] Load chat history from agents + - [ ] Save chat history to notes +- [ ] P1: Provide copilot specific system prompt +- [ ] P2: Subagent nested tool calls +- [ ] P2: Better agent messages +- [ ] P1: MCP +- [ ] P1: Skills +- [ ] P3: Rerun agent response +- [ ] P2: Edit previous user message +- [ ] P1: Permission UI +- [ ] P1: Edit diff UI - https://agentclientprotocol.com/protocol/tool-calls#diffs +- [ ] P2: Agent path auto-detect doesn't work well +- [ ] P1: Agent mode (yolo, plan, safe) - https://agentclientprotocol.com/protocol/session-config-options +- [ ] P2: Integrate copilot plus tool calls +- [ ] P2: Keyboard shortcut +- [ ] P2: New agent command (/new, /usage) +- [ ] P1: How to support custom command and quick ask? +- [ ] P1: Content type support (image, audio) - https://agentclientprotocol.com/protocol/content +- [ ] P1: Better onboarding +- [ ] P2: Claude code authentication +- [ ] P1: Codex support +- [ ] P1: Cancel chat +- [ ] P1: Queue messages +- [ ] P1: Test bash tool call, shall they work? +- [ ] P3: Agent todo list diff --git a/src/agentMode/acp/AcpBackendProcess.ts b/src/agentMode/acp/AcpBackendProcess.ts index 772a631b..d59a9591 100644 --- a/src/agentMode/acp/AcpBackendProcess.ts +++ b/src/agentMode/acp/AcpBackendProcess.ts @@ -4,6 +4,8 @@ import { ClientSideConnection, ListSessionsRequest, ListSessionsResponse, + LoadSessionRequest, + LoadSessionResponse, NewSessionRequest, NewSessionResponse, PROTOCOL_VERSION, @@ -12,6 +14,8 @@ import { RequestError, RequestPermissionRequest, RequestPermissionResponse, + ResumeSessionRequest, + ResumeSessionResponse, SessionId, SessionNotification, SetSessionModelRequest, @@ -62,6 +66,10 @@ export class AcpBackendProcess { private setSessionModelSupported: boolean | null = null; /** Whether the agent advertised `sessionCapabilities.list` at initialize time. */ private listSessionsSupported = false; + /** Whether the agent advertised `sessionCapabilities.resume` at initialize time. */ + private resumeSessionSupported = false; + /** Whether the agent advertised top-level `loadSession` at initialize time. */ + private loadSessionSupported = false; constructor( private readonly app: App, @@ -105,6 +113,8 @@ export class AcpBackendProcess { this.permissionPrompter = null; this.setSessionModelSupported = null; this.listSessionsSupported = false; + this.resumeSessionSupported = false; + this.loadSessionSupported = false; for (const fn of this.exitListeners) { try { fn(); @@ -133,8 +143,10 @@ export class AcpBackendProcess { }, }); this.listSessionsSupported = init.agentCapabilities?.sessionCapabilities?.list != null; + this.resumeSessionSupported = init.agentCapabilities?.sessionCapabilities?.resume != null; + this.loadSessionSupported = init.agentCapabilities?.loadSession === true; logInfo( - `[AgentMode] initialized backend ${this.backend.id} (negotiated protocol v${init.protocolVersion}, listSessions=${this.listSessionsSupported})` + `[AgentMode] initialized backend ${this.backend.id} (negotiated protocol v${init.protocolVersion}, listSessions=${this.listSessionsSupported}, resumeSession=${this.resumeSessionSupported}, loadSession=${this.loadSessionSupported})` ); } catch (err) { // Initialize failed (bad binary, version mismatch, MCP boot error). The @@ -233,12 +245,7 @@ export class AcpBackendProcess { /** * List sessions tracked by the agent (subset matching `cwd` / * `additionalDirectories` filters). Throws `MethodUnsupportedError` if the - * agent did not advertise the `list` capability at initialize, or if the - * connection rejects with a JSON-RPC method-not-found. - * - * Used to pull agent-generated session titles for backends like opencode - * that persist titles internally but don't push `session_info_update` - * notifications. + * agent did not advertise the `list` capability. */ async listSessions(params: ListSessionsRequest): Promise { if (!this.listSessionsSupported) { @@ -260,6 +267,57 @@ export class AcpBackendProcess { return this.listSessionsSupported; } + /** + * Resume a previously-created session by id. Per ACP, resume restores the + * session context *without* replaying message history; the response carries + * the same `models` field as `session/new`. Throws `MethodUnsupportedError` + * when the agent did not advertise `sessionCapabilities.resume`. + */ + async resumeSession(params: ResumeSessionRequest): Promise { + if (!this.resumeSessionSupported) { + throw new MethodUnsupportedError("session/resume"); + } + try { + return await this.requireConnection().resumeSession(params); + } catch (err) { + if (isMethodNotFoundError(err)) { + this.resumeSessionSupported = false; + throw new MethodUnsupportedError("session/resume"); + } + throw err; + } + } + + /** Whether the agent advertised the `session/resume` capability. */ + isResumeSessionSupported(): boolean { + return this.resumeSessionSupported; + } + + /** + * Load a previously-created session by id. Per ACP, load restores context + * *and* streams the entire conversation history back as `session/update` + * notifications. Returns the same `models` field as `session/new`. + */ + async loadSession(params: LoadSessionRequest): Promise { + if (!this.loadSessionSupported) { + throw new MethodUnsupportedError("session/load"); + } + try { + return await this.requireConnection().loadSession(params); + } catch (err) { + if (isMethodNotFoundError(err)) { + this.loadSessionSupported = false; + throw new MethodUnsupportedError("session/load"); + } + throw err; + } + } + + /** Whether the agent advertised the `loadSession` capability. */ + isLoadSessionSupported(): boolean { + return this.loadSessionSupported; + } + /** * Tear down the subprocess. Cancels nothing on the agent side beyond * closing stdin (opencode self-exits when the parent goes away), so call @@ -271,6 +329,8 @@ export class AcpBackendProcess { this.permissionPrompter = null; this.setSessionModelSupported = null; this.listSessionsSupported = false; + this.resumeSessionSupported = false; + this.loadSessionSupported = false; if (this.process) { try { await this.process.shutdown(); diff --git a/src/agentMode/backends/claude-code/ClaudeCodeBackend.ts b/src/agentMode/backends/claude-code/ClaudeCodeBackend.ts new file mode 100644 index 00000000..b53bdec1 --- /dev/null +++ b/src/agentMode/backends/claude-code/ClaudeCodeBackend.ts @@ -0,0 +1,68 @@ +import { getSettings } from "@/settings/model"; +import { AcpBackend, AcpSpawnDescriptor } from "@/agentMode/acp/types"; +import * as path from "node:path"; + +/** + * Spawns the user-provided `claude-agent-acp` binary + * (`@agentclientprotocol/claude-agent-acp`). The package wraps the local + * `claude` CLI and exposes it as an ACP server over stdio. Authentication is + * inherited from `~/.claude/` — the user logs in with `claude auth login` + * outside the plugin and we just spawn the adapter; we deliberately do not + * pass `ANTHROPIC_API_KEY` so subscription accounts work transparently. + * + * Unlike OpenCode, Claude Code does not consume Copilot's `activeModels` or + * BYOK keys; its model list comes entirely from the agent's own + * `availableModels` stream (live or preloader-cached). + */ +export class ClaudeCodeBackend implements AcpBackend { + readonly id = "claude-code" as const; + readonly displayName = "Claude Code"; + + async buildSpawnDescriptor(_ctx: { vaultBasePath: string }): Promise { + const binaryPath = getSettings().agentMode?.backends?.["claude-code"]?.binaryPath; + if (!binaryPath) { + throw new Error( + "Claude Code binary path not configured. Open Agent Mode settings and set the path to claude-agent-acp." + ); + } + return { + command: binaryPath, + args: [], + env: { + ...process.env, + PATH: augmentPathForNodeShebang(binaryPath, process.env.PATH), + }, + }; + } +} + +/** + * macOS GUI apps (Obsidian) inherit a minimal PATH that omits Homebrew and + * common Node installer locations. `claude-agent-acp` is a `#!/usr/bin/env + * node` script, so the spawn fails with `env: node: No such file or + * directory` unless we put `node` on PATH ourselves. + * + * We prepend the directory containing the binary (npm globals install the + * launcher script next to `node`) plus the well-known Homebrew / system + * prefixes, then keep the inherited PATH for everything else. + */ +function augmentPathForNodeShebang(binaryPath: string, inherited: string | undefined): string { + const sep = process.platform === "win32" ? ";" : ":"; + const candidates = [ + path.dirname(binaryPath), + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + ]; + const inheritedParts = (inherited ?? "").split(sep).filter(Boolean); + const seen = new Set(); + const merged: string[] = []; + for (const p of [...candidates, ...inheritedParts]) { + if (!seen.has(p)) { + seen.add(p); + merged.push(p); + } + } + return merged.join(sep); +} diff --git a/src/agentMode/backends/claude-code/ClaudeCodeInstallModal.tsx b/src/agentMode/backends/claude-code/ClaudeCodeInstallModal.tsx new file mode 100644 index 00000000..3566fe6a --- /dev/null +++ b/src/agentMode/backends/claude-code/ClaudeCodeInstallModal.tsx @@ -0,0 +1,136 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { logError } from "@/logger"; +import { detectBinary, validateExecutableFile } from "@/utils/detectBinary"; +import { getSettings } from "@/settings/model"; +import { App, Modal, Notice } from "obsidian"; +import React from "react"; +import { createRoot, Root } from "react-dom/client"; +import { + CLAUDE_CODE_BINARY_NAME, + CLAUDE_CODE_INSTALL_COMMAND, + updateClaudeCodeFields, +} from "./descriptor"; + +interface ContentProps { + onClose: () => void; +} + +const ClaudeCodeInstallContent: React.FC = ({ onClose }) => { + const initial = getSettings().agentMode?.backends?.["claude-code"]?.binaryPath ?? ""; + const [path, setPath] = React.useState(initial); + const [error, setError] = React.useState(null); + const [busy, setBusy] = React.useState(false); + + const autoDetect = React.useCallback(async (): Promise => { + if (busy) return; + setBusy(true); + setError(null); + try { + const found = await detectBinary(CLAUDE_CODE_BINARY_NAME); + if (!found) { + setError( + `${CLAUDE_CODE_BINARY_NAME} not found on PATH. Run the install command above, then click Auto-detect again.` + ); + return; + } + setPath(found); + new Notice(`Found ${CLAUDE_CODE_BINARY_NAME} at ${found}`); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }, [busy]); + + const save = React.useCallback(async (): Promise => { + const trimmed = path.trim(); + if (!trimmed) { + setError("Path is required."); + return; + } + const validationError = await validateExecutableFile(trimmed); + if (validationError) { + setError(validationError); + return; + } + updateClaudeCodeFields({ binaryPath: trimmed }); + new Notice("Claude Code binary path saved."); + onClose(); + }, [path, onClose]); + + const copy = React.useCallback((): void => { + navigator.clipboard.writeText(CLAUDE_CODE_INSTALL_COMMAND).catch((e) => { + logError("[AgentMode] copy install command failed", e); + }); + new Notice("Copied to clipboard."); + }, []); + + return ( +
+

+ Claude Code uses the official @agentclientprotocol/claude-agent-acp adapter, + which wraps the local claude CLI. It inherits your existing{" "} + claude auth login credentials — no API key needed if you're already signed + in. +

+
+ Install command +
+ + {CLAUDE_CODE_INSTALL_COMMAND} + + +
+
+
+ Binary path +
+ setPath(e.target.value)} + /> + +
+ {error &&
{error}
} +
+
+ + +
+
+ ); +}; + +export class ClaudeCodeInstallModal extends Modal { + private root: Root | null = null; + + constructor(app: App) { + super(app); + // @ts-expect-error - setTitle is part of Obsidian's Modal but missing from older type defs + this.setTitle("Configure Claude Code (Agent backend)"); + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + this.root = createRoot(contentEl); + this.root.render( this.close()} />); + } + + onClose(): void { + this.root?.unmount(); + this.root = null; + this.contentEl.empty(); + } +} diff --git a/src/agentMode/backends/claude-code/ClaudeCodeSettingsPanel.tsx b/src/agentMode/backends/claude-code/ClaudeCodeSettingsPanel.tsx new file mode 100644 index 00000000..2ac8c980 --- /dev/null +++ b/src/agentMode/backends/claude-code/ClaudeCodeSettingsPanel.tsx @@ -0,0 +1,136 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { SettingItem } from "@/components/ui/setting-item"; +import { logError } from "@/logger"; +import type CopilotPlugin from "@/main"; +import { useSettingsValue } from "@/settings/model"; +import { detectBinary, validateExecutableFile } from "@/utils/detectBinary"; +import type { App } from "obsidian"; +import { Notice } from "obsidian"; +import React from "react"; +import { ClaudeCodeInstallModal } from "./ClaudeCodeInstallModal"; +import { CLAUDE_CODE_BINARY_NAME, updateClaudeCodeFields } from "./descriptor"; + +interface Props { + plugin: CopilotPlugin; + app: App; +} + +export const ClaudeCodeSettingsPanel: React.FC = ({ app }) => { + const settings = useSettingsValue(); + const stored = settings.agentMode?.backends?.["claude-code"]?.binaryPath ?? ""; + const [pathInput, setPathInput] = React.useState(stored); + const [error, setError] = React.useState(null); + const [busy, setBusy] = React.useState(false); + + React.useEffect(() => { + setPathInput(stored); + }, [stored]); + + const apply = React.useCallback(async (): Promise => { + const trimmed = pathInput.trim(); + if (!trimmed) { + setError("Path is required."); + return; + } + const validationError = await validateExecutableFile(trimmed); + if (validationError) { + setError(validationError); + return; + } + updateClaudeCodeFields({ binaryPath: trimmed }); + setError(null); + new Notice("Claude Code binary path saved."); + }, [pathInput]); + + const clear = React.useCallback((): void => { + updateClaudeCodeFields({ binaryPath: undefined }); + setPathInput(""); + setError(null); + }, []); + + const autoDetect = React.useCallback(async (): Promise => { + if (busy) return; + setBusy(true); + setError(null); + try { + const found = await detectBinary(CLAUDE_CODE_BINARY_NAME); + if (!found) { + setError( + `${CLAUDE_CODE_BINARY_NAME} not found on PATH. Install with \`npm i -g @agentclientprotocol/claude-agent-acp\` and try again.` + ); + return; + } + setPathInput(found); + updateClaudeCodeFields({ binaryPath: found }); + new Notice(`Found ${CLAUDE_CODE_BINARY_NAME} at ${found}`); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + logError("[AgentMode] claude-code auto-detect failed", e); + } finally { + setBusy(false); + } + }, [busy]); + + const openInstallModal = React.useCallback((): void => { + new ClaudeCodeInstallModal(app).open(); + }, [app]); + + const description = stored ? ( + <> +
+ Ready — {CLAUDE_CODE_BINARY_NAME} (custom path) +
+
{stored}
+ + ) : ( + + Setup required — Claude Code binary path not configured. + + ); + + return ( + <> + +
+ {!stored && ( + + )} + {stored && ( + + )} +
+
+ + +
+
+ setPathInput(e.target.value)} + /> + +
+
+ +
+ {error &&
{error}
} +
+
+ + ); +}; diff --git a/src/agentMode/backends/claude-code/descriptor.ts b/src/agentMode/backends/claude-code/descriptor.ts new file mode 100644 index 00000000..6065c977 --- /dev/null +++ b/src/agentMode/backends/claude-code/descriptor.ts @@ -0,0 +1,89 @@ +import type CopilotPlugin from "@/main"; +import { + setSettings, + subscribeToSettingsChange, + type ClaudeCodeBackendSettings, + type CopilotSettings, +} from "@/settings/model"; +import { ClaudeCodeBackend } from "./ClaudeCodeBackend"; +import { ClaudeCodeInstallModal } from "./ClaudeCodeInstallModal"; +import { ClaudeCodeSettingsPanel } from "./ClaudeCodeSettingsPanel"; +import type { BackendDescriptor, InstallState } from "@/agentMode/session/types"; + +export const CLAUDE_CODE_BINARY_NAME = "claude-agent-acp"; +export const CLAUDE_CODE_INSTALL_COMMAND = "npm install -g @agentclientprotocol/claude-agent-acp"; + +/** + * Read-modify-write helper for the `agentMode.backends["claude-code"]` slice. + * Centralizes the nested spread so callers don't repeat the four-level onion. + */ +export function updateClaudeCodeFields(partial: Partial): void { + setSettings((cur) => ({ + agentMode: { + ...cur.agentMode, + backends: { + ...cur.agentMode.backends, + "claude-code": { + ...(cur.agentMode.backends?.["claude-code"] ?? {}), + ...partial, + }, + }, + }, + })); +} + +/** + * Claude Code backend — wraps `@agentclientprotocol/claude-agent-acp`, which + * inherits auth from the local `claude` CLI login. Independent of Copilot's + * `activeModels` / BYOK keys, so the picker is fed entirely by live + * `availableModels` (active session or preloader cache). + */ +export const ClaudeCodeBackendDescriptor: BackendDescriptor = { + id: "claude-code", + displayName: "Claude Code", + + getInstallState(settings: CopilotSettings): InstallState { + const binaryPath = settings.agentMode?.backends?.["claude-code"]?.binaryPath; + if (!binaryPath) return { kind: "absent" }; + return { kind: "ready", version: "custom", source: "custom" }; + }, + + subscribeInstallState(_plugin: CopilotPlugin, cb: () => void): () => void { + return subscribeToSettingsChange((prev, next) => { + if ( + prev.agentMode?.backends?.["claude-code"]?.binaryPath !== + next.agentMode?.backends?.["claude-code"]?.binaryPath + ) { + cb(); + } + }); + }, + + openInstallUI(plugin: CopilotPlugin): void { + new ClaudeCodeInstallModal(plugin.app).open(); + }, + + createBackend(): ClaudeCodeBackend { + return new ClaudeCodeBackend(); + }, + + SettingsPanel: ClaudeCodeSettingsPanel, + + getPreferredModelId(settings: CopilotSettings): string | undefined { + const key = settings.agentMode?.backends?.["claude-code"]?.selectedModelKey; + return key && key.length > 0 ? key : undefined; + }, + + async persistModelSelection(modelId: string, _plugin: CopilotPlugin): Promise { + updateClaudeCodeFields({ selectedModelKey: modelId }); + }, + + getProbeSessionId(settings: CopilotSettings): string | undefined { + const id = settings.agentMode?.backends?.["claude-code"]?.probeSessionId; + return id && id.length > 0 ? id : undefined; + }, + + async persistProbeSessionId(sessionId: string, _plugin: CopilotPlugin): Promise { + updateClaudeCodeFields({ probeSessionId: sessionId }); + }, +}; diff --git a/src/agentMode/backends/claude-code/index.ts b/src/agentMode/backends/claude-code/index.ts new file mode 100644 index 00000000..83afba41 --- /dev/null +++ b/src/agentMode/backends/claude-code/index.ts @@ -0,0 +1 @@ +export { ClaudeCodeBackendDescriptor } from "./descriptor"; diff --git a/src/agentMode/backends/opencode/OpencodeBinaryManager.test.ts b/src/agentMode/backends/opencode/OpencodeBinaryManager.test.ts index bdf1a01c..a0fc2d27 100644 --- a/src/agentMode/backends/opencode/OpencodeBinaryManager.test.ts +++ b/src/agentMode/backends/opencode/OpencodeBinaryManager.test.ts @@ -11,7 +11,7 @@ jest.mock("@/logger", () => ({ logError: jest.fn(), })); -// In-memory settings store for the manager's getSettings/updateSetting calls. +// In-memory settings store for the manager's getSettings/setSettings calls. // Defined inside jest.mock so it's hoisted alongside the factory. jest.mock("@/settings/model", () => { type OpencodeSlice = { @@ -24,7 +24,8 @@ jest.mock("@/settings/model", () => { activeBackend?: string; backends?: { opencode?: OpencodeSlice }; }; - let store: { agentMode: AgentMode } = { + type Store = { agentMode: AgentMode }; + let store: Store = { agentMode: { backends: { opencode: {} } }, }; return { @@ -34,8 +35,9 @@ jest.mock("@/settings/model", () => { }, __get: () => store.agentMode.backends?.opencode ?? {}, getSettings: () => store, - updateSetting: (key: "agentMode", value: AgentMode) => { - store = { ...store, [key]: value }; + setSettings: (settings: Partial | ((current: Store) => Partial)) => { + const partial = typeof settings === "function" ? settings(store) : settings; + store = { ...store, ...partial }; }, }; }); diff --git a/src/agentMode/backends/opencode/OpencodeBinaryManager.ts b/src/agentMode/backends/opencode/OpencodeBinaryManager.ts index 113c9ff8..e71a359c 100644 --- a/src/agentMode/backends/opencode/OpencodeBinaryManager.ts +++ b/src/agentMode/backends/opencode/OpencodeBinaryManager.ts @@ -1,7 +1,7 @@ import { OPENCODE_PINNED_VERSION, OPENCODE_RELEASE_API_URL_TEMPLATE } from "@/constants"; import { logError, logInfo, logWarn } from "@/logger"; import type CopilotPlugin from "@/main"; -import { getSettings, updateSetting, type OpencodeBackendSettings } from "@/settings/model"; +import { getSettings, setSettings, type OpencodeBackendSettings } from "@/settings/model"; import { execFile, spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import * as fs from "node:fs"; @@ -103,18 +103,16 @@ export function readOpencodeSettings(): OpencodeBackendSettings { return getSettings().agentMode?.backends?.opencode ?? {}; } -// R-M-W on settings is racy with concurrent edits but tolerable: every caller -// is either a one-shot plugin-load reconcile or a user-initiated flow from -// the settings panel, neither of which races with itself. function updateOpencodeFields(partial: Partial): void { - const cur = getSettings().agentMode; - updateSetting("agentMode", { - ...cur, - backends: { - ...cur.backends, - opencode: { ...(cur.backends?.opencode ?? {}), ...partial }, + setSettings((cur) => ({ + agentMode: { + ...cur.agentMode, + backends: { + ...cur.agentMode.backends, + opencode: { ...(cur.agentMode.backends?.opencode ?? {}), ...partial }, + }, }, - }); + })); } function clearOpencodeBinary(): void { diff --git a/src/agentMode/backends/opencode/OpencodeSettingsPanel.tsx b/src/agentMode/backends/opencode/OpencodeSettingsPanel.tsx index 75e9ea75..dbb2f239 100644 --- a/src/agentMode/backends/opencode/OpencodeSettingsPanel.tsx +++ b/src/agentMode/backends/opencode/OpencodeSettingsPanel.tsx @@ -6,6 +6,7 @@ import { SettingItem } from "@/components/ui/setting-item"; import { logError } from "@/logger"; import type CopilotPlugin from "@/main"; import { useSettingsValue } from "@/settings/model"; +import { detectBinary } from "@/utils/detectBinary"; import type { App } from "obsidian"; import { Notice } from "obsidian"; import React from "react"; @@ -44,6 +45,7 @@ export const OpencodeSettingsPanel: React.FC = ({ plugin, app }) => { const [customPathInput, setCustomPathInput] = React.useState(""); const [customPathError, setCustomPathError] = React.useState(null); const [showAdvanced, setShowAdvanced] = React.useState(false); + const [detecting, setDetecting] = React.useState(false); const installState = computeInstallState(settings.agentMode?.backends?.opencode); @@ -93,6 +95,32 @@ export const OpencodeSettingsPanel: React.FC = ({ plugin, app }) => { setCustomPathInput(""); }; + const autoDetect = async (): Promise => { + setDetecting(true); + setCustomPathError(null); + try { + const found = await detectBinary("opencode"); + if (!found) { + setCustomPathError( + "opencode not found on PATH. Install it or paste a custom path manually." + ); + return; + } + setCustomPathInput(found); + try { + await manager.setCustomBinaryPath(found); + new Notice(`Found opencode at ${found}`); + } catch (e) { + setCustomPathError(e instanceof Error ? e.message : String(e)); + } + } catch (e) { + logError("[AgentMode] opencode auto-detect failed", e); + setCustomPathError(e instanceof Error ? e.message : String(e)); + } finally { + setDetecting(false); + } + }; + return ( <> = ({ plugin, app }) => { description="Skip the managed install and point Agent Mode at a binary you already have on disk. Useful for self-builders or air-gapped machines." >
- setCustomPathInput(e.target.value)} - /> +
+ setCustomPathInput(e.target.value)} + /> + +
); diff --git a/src/utils/detectBinary.ts b/src/utils/detectBinary.ts new file mode 100644 index 00000000..a8b66fc1 --- /dev/null +++ b/src/utils/detectBinary.ts @@ -0,0 +1,63 @@ +import { execFile } from "node:child_process"; +import * as fs from "node:fs"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +/** + * Allowed shape for binary names handed to `which`/`where`. Restricts to + * characters that real binary names use (alphanumerics, dot, dash, + * underscore, plus). `execFile` already skips the shell, but this is a + * defensive backstop so a future caller can't accidentally pipe a + * user-controlled string with spaces or path separators into the lookup. + */ +const BINARY_NAME_PATTERN = /^[A-Za-z0-9._+-]+$/; + +/** + * Resolve the absolute path of an executable on `PATH`. Returns the first + * match (Windows `where` may return many) or `null` when none exists or the + * lookup tool itself isn't available. + * + * `name` MUST be a trusted literal (matching {@link BINARY_NAME_PATTERN}) — + * never pass user input directly. Throws synchronously if the shape is + * wrong rather than silently doing the wrong thing. + * + * Implementation deliberately uses `which` (POSIX) / `where` (Windows) rather + * than parsing `PATH` ourselves so we honor the user's shell-equivalent + * resolution rules (PATHEXT on Windows, symlink chasing, etc.). + */ +export async function detectBinary(name: string): Promise { + if (!BINARY_NAME_PATTERN.test(name)) { + throw new Error(`Invalid binary name: ${JSON.stringify(name)}`); + } + const cmd = process.platform === "win32" ? "where" : "which"; + try { + const { stdout } = await execFileAsync(cmd, [name], { timeout: 5000 }); + const first = stdout + .split(/\r?\n/) + .map((s) => s.trim()) + .find(Boolean); + return first ?? null; + } catch { + return null; + } +} + +/** + * Validate that `p` is a real file and (on POSIX) marked executable. + * Returns an error message suitable for surfacing in UI, or `null` when ok. + * Centralizes the checks so backend-specific path inputs catch obvious + * misconfigurations at config time rather than at spawn time. + */ +export async function validateExecutableFile(p: string): Promise { + const stat = await fs.promises.stat(p).catch(() => null); + if (!stat || !stat.isFile()) return `No file at ${p}.`; + if (process.platform !== "win32") { + try { + await fs.promises.access(p, fs.constants.X_OK); + } catch { + return `${p} is not executable. chmod +x and try again.`; + } + } + return null; +}